import { useState, useEffect, useCallback } from "react"; import { useLoaderData, useNavigate, useLocation, Link } from "react-router"; import TradingViewChart from "../components/TradingViewChart"; import Navbar from "../components/Navbar"; import { useMemo } from "react"; import type { TradingDecision, AnalystReport, DebateRound } from "../types/agents"; export const meta = () => [{ title: "Stock Detail - AITrader" }]; const barsCache = new Map(); const CACHE_TTL_MS = 5 * 60 * 1000; function getCachedBars(key: string): any[] | null { const entry = barsCache.get(key); if (entry && Date.now() - entry.timestamp < CACHE_TTL_MS) return entry.bars; if (entry) barsCache.delete(key); return null; } function setCachedBars(key: string, bars: any[]) { barsCache.set(key, { bars, timestamp: Date.now() }); } 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; stockRecord?: any; latestJob?: any; runningJob?: any; } const TIMEFRAMES = [ { value: "1Min", label: "1 Minute" }, { 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"; 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 = []; let stockRecord: any = null; let latestJob: any = null; let runningJob: any = null; try { const posRes = await fetch(`${baseUrl}/api/alpaca/positions`); const positions = posRes.ok ? await posRes.json() : []; position = positions.find((p: any) => p.ticker === ticker) ?? null; 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) || []; const barsCacheKey = `${ticker}-${timeframe}-${range}`; const cachedBars = getCachedBars(barsCacheKey); if (cachedBars) { bars = cachedBars; } else { const barsRes = await fetch(`${baseUrl}/api/alpaca/quote/${ticker}?timeframe=${timeframe}&range=${range}`); const barsData = barsRes.ok ? await barsRes.json() : null; bars = barsData?.bars || []; if (bars.length > 0) setCachedBars(barsCacheKey, bars); } try { const stockRes = await fetch(`${baseUrl}/api/stocks`); if (stockRes.ok) { const list = await stockRes.json(); stockRecord = list.find((s: any) => s.ticker === ticker) || null; } } catch (e) { /* ignore */ } // Fetch latest completed job for this ticker if (stockRecord?.lastJobId) { try { const jobRes = await fetch(`${baseUrl}/api/jobs/${stockRecord.lastJobId}`); if (jobRes.ok) latestJob = await jobRes.json(); } catch (e) { /* ignore */ } } // Check for any currently running/active jobs for this ticker try { const jobsRes = await fetch(`${baseUrl}/api/jobs?ticker=${encodeURIComponent(ticker)}&limit=20`); if (jobsRes.ok) { const jobsData = await jobsRes.json(); runningJob = (jobsData.jobs || []).find((j: any) => j.state === "active" || j.state === "waiting") || null; } } catch (e) { /* ignore */ } } catch (err) { console.error(`analyze/${ticker}: loader error`, err); } return Response.json({ ticker, position, orders, bars, timeframe, range, stockRecord, latestJob, runningJob }); } function stateBadge(state: string) { const cls = state === "completed" ? "bg-green-100 text-green-700" : state === "failed" ? "bg-red-100 text-red-700" : state === "active" ? "bg-blue-100 text-blue-700" : "bg-yellow-100 text-yellow-700"; return {state}; } function DecisionBadge({ decision }: { decision: TradingDecision }) { const color = decision.action === "buy" ? "text-green-600" : decision.action === "sell" ? "text-red-600" : "text-gray-500"; return (
{decision.action.toUpperCase()} {(decision.confidence * 100).toFixed(0)}% confidence
); } function ExecutionPlanCompact({ plan }: { plan: TradingDecision["executionPlan"] }) { if (!plan) return null; return (
{plan.amount != null &&
Qty
{plan.amount} shares
} {plan.takeProfit != null &&
Take Profit
${plan.takeProfit}
} {plan.stopLoss != null &&
Stop Loss
${plan.stopLoss}
} {plan.riskManagement?.maxLossPercent != null &&
Risk
{plan.riskManagement.maxLossPercent}%
}
); } function AgentSignalRow({ signal }: { signal: any }) { const sigColor = signal.signal === "bullish" ? "text-green-600" : signal.signal === "bearish" ? "text-red-600" : "text-gray-500"; return (
{signal.agent} {signal.reasoning &&

{signal.reasoning}

}
{signal.signal} {(signal.confidence * 100).toFixed(0)}%
); } function DebateCompact({ rounds }: { rounds: DebateRound[] }) { if (!rounds?.length) return null; return (
{rounds.slice(0, 3).map((r, i) => (
📈 {r.bullishView}
📉 {r.bearishView}
))} {rounds.length > 3 &&
+{rounds.length - 3} more rounds
}
); } function PositionCard({ position, ticker }: { position: LoaderData["position"]; ticker: string }) { if (!position) return (

Position

No position held

); const pnlColor = position.unrealized_pl >= 0 ? "text-green-600" : "text-red-600"; const pnlSign = position.unrealized_pl >= 0 ? "+" : ""; return (

Position — {ticker}

Qty
{position.qty}
Avg Entry
${position.avg_entry_price?.toFixed(2)}
Current
${position.current_price?.toFixed(2)}
P&L
{pnlSign}${position.unrealized_pl?.toFixed(2)}
Market value: ${position.market_value?.toFixed(2)}
); } function OrdersCard({ orders, ticker }: { orders: any[]; ticker: string }) { if (!orders.length) return (

Orders

No orders for {ticker}

); return (

Orders — {ticker}

{orders.slice(0, 5).map((order: any, i: number) => ( ))}
Side Qty Status Price Date
{order.side?.toUpperCase()} {order.qty} {stateBadge(order.status)} {order.filled_avg_price ? `$${parseFloat(order.filled_avg_price).toFixed(2)}` : "-"} {order.filled_at ? new Date(order.filled_at).toLocaleDateString() : "-"}
); } function JobHistoryInline({ ticker, runningJob, latestJob, onJobSelect }: { ticker: string; runningJob: any; latestJob: any; onJobSelect: (job: any) => void }) { const [jobs, setJobs] = useState([]); const [loading, setLoading] = useState(false); const fetchJobs = useCallback(async () => { setLoading(true); try { const res = await fetch(`/api/jobs?ticker=${encodeURIComponent(ticker)}&limit=10`); if (res.ok) { const data = await res.json(); setJobs(data.jobs || []); } } catch (e) { console.warn("Failed to fetch jobs:", e); } finally { setLoading(false); } }, [ticker]); useEffect(() => { fetchJobs(); const id = setInterval(fetchJobs, 8000); return () => clearInterval(id); }, [fetchJobs]); const cancel = async (jobId: string) => { try { await fetch(`/api/jobs/${jobId}/cancel`, { method: "POST" }); fetchJobs(); } catch (e) { console.warn("Cancel failed:", e); } }; return (

Job History

{/* Running job banner */} {runningJob && (
Running: {runningJob.id}
{stateBadge(runningJob.state)}
)} {loading ? (
) : jobs.length === 0 ? (

No jobs for {ticker}

) : (
{jobs.map((j: any) => (
{stateBadge(j.state)} {j.id}
{(j.state === "waiting") && ( )}
))}
)}
); } function TradingResultCard({ job, expanded }: { job: any; expanded: boolean }) { const decision = job?.returnValue; if (!decision) return null; return (

Latest Analysis Result

{new Date(job.timestamp || Date.now()).toLocaleString()}
{decision.reasoning &&

{decision.reasoning}

} {expanded && decision.executionPlan && (

Execution Plan

)} {expanded && decision.agentSignals?.length > 0 && (

Agent Signals

{decision.agentSignals.map((s: any, i: number) => )}
)} {expanded && decision.debateRounds?.length > 0 && (

Debate Rounds

)}
); } export default function StockDetail() { const { ticker, position, orders, bars, timeframe, range, stockRecord, latestJob, runningJob } = useLoaderData() as LoaderData; const navigate = useNavigate(); const location = useLocation(); const [analysisLoading, setAnalysisLoading] = useState(false); const [jobStatus, setJobStatus] = useState(runningJob || null); const [jobPolling, setJobPolling] = useState(!!runningJob); const [showExpanded, setShowExpanded] = useState(false); const [selectedJob, setSelectedJob] = useState(latestJob || null); const cacheKey = `tradinggraph-${ticker}`; // Poll running job if exists useEffect(() => { if (!runningJob) return; let cancelled = false; let timer: ReturnType | null = null; let currentController: AbortController | null = null; const poll = async () => { if (currentController) { try { currentController.abort(); } catch (e) {} } currentController = new AbortController(); try { const res = await fetch(`/api/jobs/${runningJob.id}`, { signal: currentController.signal }); if (!res.ok) { if (!cancelled) timer = setTimeout(poll, 2000); return; } const j = await res.json(); if (cancelled) return; setJobStatus(j); if (j.state === "completed" || j.state === "failed") { setJobPolling(false); cancelled = true; setSelectedJob(j); } } catch (e: any) { if (e?.name !== "AbortError") console.warn("Poll error:", e); } finally { if (!cancelled) timer = setTimeout(poll, 2000); } }; poll(); return () => { cancelled = true; if (timer) clearTimeout(timer); if (currentController) { try { currentController.abort(); } catch (e) {} } }; }, [runningJob?.id]); // Load cached results useEffect(() => { const cached = sessionStorage.getItem(cacheKey); if (cached) { try { const data = JSON.parse(cached); if (data.decision) setSelectedJob({ returnValue: data.decision, timestamp: data.timestamp }); } catch (e) { /* ignore */ } } }, [cacheKey]); const [priceStream, setPriceStream] = useState(null); useEffect(() => { let es: EventSource | null = null; let listeners: ((p: number) => void)[] = []; if (typeof window !== "undefined" && typeof EventSource !== "undefined") { try { es = new EventSource(`/api/price-stream?ticker=${encodeURIComponent(ticker)}&timeframe=${encodeURIComponent(timeframe)}`); es.onmessage = (e) => { try { const d = JSON.parse(e.data); if (d?.price != null) listeners.forEach((cb) => cb(d.price)); } catch (err) { /* ignore */ } }; const streamObj = { subscribe(cb: (p: number) => void) { listeners.push(cb); return () => { listeners = listeners.filter((c) => c !== cb); }; }, }; setPriceStream(streamObj); } catch (e) { console.warn("Failed to create price EventSource", e); } } return () => { try { if (es) es.close(); } catch (e) {} setPriceStream(null); }; }, [ticker]); 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 }); }; const runTradingGraph = async () => { setAnalysisLoading(true); try { try { const fd = new FormData(); fd.append("ticker", ticker); await fetch("/api/stocks", { method: "POST", body: fd }); } catch (e) { console.warn("Failed to save ticker:", e); } const res = await fetch("/api/analyze", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ticker, background: true }), }); const data = await res.json(); if (res.status === 202 && data.jobId) { const jobId = data.jobId; try { const fd2 = new FormData(); fd2.append("ticker", ticker); fd2.append("lastJobId", jobId); await fetch("/api/stocks", { method: "POST", body: fd2 }); } catch (e) { console.warn("Failed to save lastJobId:", e); } setJobPolling(true); setJobStatus({ id: jobId, state: "queued" }); let cancelled = false; let timer: ReturnType | null = null; let currentController: AbortController | null = null; const poll = async () => { if (currentController) { try { currentController.abort(); } catch (e) {} } currentController = new AbortController(); try { const jr = await fetch(`/api/jobs/${jobId}`, { signal: currentController.signal }); if (!jr.ok) { if (!cancelled) timer = setTimeout(poll, 2000); return; } const j = await jr.json(); if (cancelled) return; setJobStatus(j); if (j.state === "completed" || j.state === "failed") { setJobPolling(false); cancelled = true; setSelectedJob(j); } } catch (e: any) { if (e?.name !== "AbortError") console.warn("Poll error:", e); } finally { if (!cancelled) timer = setTimeout(poll, 2000); } }; poll(); return; } if (!res.ok) throw new Error(data.error || "Analysis failed"); setSelectedJob({ returnValue: data, timestamp: Date.now() }); sessionStorage.setItem(cacheKey, JSON.stringify({ decision: data, timestamp: Date.now() })); } catch (err) { console.error("Analysis error:", err); } finally { setAnalysisLoading(false); } }; 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) => { let time: string | number = ""; if (bar.t) { const date = new Date(bar.t); if (!isNaN(date.getTime())) { 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 }; }).filter((bar: any, index: number, arr: any[]) => bar.time && bar.open != null && index === arr.findIndex((b: any) => b.time === bar.time) ) || []; const displayJob = selectedJob || (jobStatus?.state === "completed" ? jobStatus : null); return (
{/* Header */}

{ticker}

{/* Chart */}
Timeframe: Range:
{/* Position & Orders row */}
{/* Latest analysis result */} {displayJob && } {/* Expand toggle */} {displayJob && (
)} {/* Job history */}
{ setSelectedJob(j); setShowExpanded(false); }} />
); }