import { useLoaderData, useNavigate, useLocation } from "react-router"; import TradingViewChart from "../components/TradingViewChart"; import Navbar from "../components/Navbar"; export const meta = () => [{ title: "Stock Detail - AITrader" }]; interface LoaderData { ticker: string; position: { qty: number; avg_entry_price: number; current_price: number; market_value: number; unrealized_pl: number } | null; orders: any[]; bars: any[]; timeframe: string; range: string; } const TIMEFRAMES = [ { value: "1D", label: "1 Day" }, { value: "5Min", label: "5 Min" }, { value: "15Min", label: "15 Min" }, { value: "1H", label: "1 Hour" }, { value: "1W", label: "1 Week" }, ]; const RANGES = [ { value: "1D", label: "1 Day" }, { value: "1W", label: "1 Week" }, { value: "1M", label: "1 Month" }, { value: "3M", label: "3 Months" }, { value: "1Y", label: "1 Year" }, { value: "3Y", label: "3 Years" }, { value: "ALL", label: "All" }, ]; export async function loader({ params, request }: { params: { ticker: string }; request: Request }) { const ticker = params.ticker?.toUpperCase() || ""; const url = new URL(request.url); const timeframe = url.searchParams.get("timeframe") || "1D"; const range = url.searchParams.get("range") || "1M"; // Build base URL from request for server-side fetches const reqUrl = new URL(request.url); const host = request.headers.get("host") || reqUrl.host; const protocol = reqUrl.protocol; const baseUrl = `${protocol}//${host}`; let position = null; let orders = []; let bars = []; try { // Fetch positions const posRes = await fetch(`${baseUrl}/api/alpaca/positions`); const positions = posRes.ok ? await posRes.json() : []; position = positions.find((p: any) => p.ticker === ticker) ?? null; // Fetch orders const ordRes = await fetch(`${baseUrl}/api/alpaca/orders`); const ordersData = ordRes.ok ? await ordRes.json() : { orders: [] }; orders = ordersData.orders?.filter((o: any) => o.symbol === ticker) || []; // Fetch bars for chart with timeframe and range const barsRes = await fetch(`${baseUrl}/api/alpaca/quote/${ticker}?timeframe=${timeframe}&range=${range}`); const barsData = barsRes.ok ? await barsRes.json() : null; bars = barsData?.bars || []; } catch (err) { console.error(`analyze/${ticker}: loader error`, err); } return Response.json({ ticker, position, orders, bars, timeframe, range }); } export default function StockDetail() { const { ticker, position, orders, bars, timeframe, range } = useLoaderData() as LoaderData; const navigate = useNavigate(); const location = useLocation(); const updateParams = (newTimeframe: string, newRange: string) => { const searchParams = new URLSearchParams(location.search); searchParams.set("timeframe", newTimeframe); searchParams.set("range", newRange); navigate(`${location.pathname}?${searchParams.toString()}`, { replace: true }); }; // Convert Alpaca bars to TradingView format // Keep full timestamp for intraday, use date-only for daily // Sort bars by timestamp to ensure ascending order const sortedBars = [...(bars || [])].sort((a, b) => { const timeA = a.t ? new Date(a.t).getTime() : 0; const timeB = b.t ? new Date(b.t).getTime() : 0; return timeA - timeB; }); const chartData = sortedBars?.map((bar: any) => { // Handle timestamp - could be string, number, or Date let time: string | number = ""; if (bar.t) { const date = new Date(bar.t); if (!isNaN(date.getTime())) { // Use Unix timestamp for intraday, date string for daily time = ["1Min", "5Min", "15Min", "30Min", "1H"].includes(timeframe) ? Math.floor(date.getTime() / 1000) : date.toISOString().split('T')[0]; } } return { time, open: bar.o, high: bar.h, low: bar.l, close: bar.c, }; }) // Remove duplicates by time (keep first occurrence) and filter valid bars .filter((bar: any, index: number, arr: any[]) => bar.time && bar.open != null && bar.high != null && bar.low != null && bar.close != null && index === arr.findIndex((b: any) => b.time === bar.time) ) || []; return (

{ticker} Detail

Timeframe: Range:

Position

{position ? (
Quantity {position.qty} shares
Ticker {ticker}
Current Value ${position.market_value.toFixed(2)}
Earnings = 0 ? "text-green-600" : "text-red-600"}`}> ${position.unrealized_pl.toFixed(2)}
) : (

No position held

)}

Recent Orders

{orders.length === 0 ? (

No orders found for {ticker}

) : (
{orders.map((order: any, i: number) => ( ))}
Side Qty Status Filled Price Filled At
{order.side?.toUpperCase()} {order.qty} {order.status} {order.filled_avg_price ? `$${parseFloat(order.filled_avg_price).toFixed(2)}` : "-"} {order.filled_at ? new Date(order.filled_at).toLocaleDateString() : "-"}
)}
); }