import { useState, useEffect } from "react"; import { useLoaderData, useNavigate, useLocation } from "react-router"; import TradingViewChart from "../components/TradingViewChart"; import Navbar from "../components/Navbar"; import type { TradingDecision, AnalystReport, DebateRound } from "../types/agents"; 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; stockRecord?: 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"; // 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 = []; let stockRecord: any = null; let stockRecord: any = null; 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 } 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 || []; // Fetch stock record (to get lastJobId) 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 } } 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, stockRecord } = useLoaderData() as LoaderData; const navigate = useNavigate(); const location = useLocation(); const [analysisLoading, setAnalysisLoading] = useState(false); const [analystReports, setAnalystReports] = useState([]); const [debateRounds, setDebateRounds] = useState([]); const [decision, setDecision] = useState(null); const [showAnalysts, setShowAnalysts] = useState(false); const [showDebate, setShowDebate] = useState(false); const [jobStatus, setJobStatus] = useState(null); const [jobPolling, setJobPolling] = useState(false); // Cache key for this ticker const cacheKey = `tradinggraph-${ticker}`; // Load cached results on mount useEffect(() => { const cached = sessionStorage.getItem(cacheKey); if (cached) { try { const data = JSON.parse(cached); if (data.analystReports) setAnalystReports(data.analystReports); if (data.debateRounds) setDebateRounds(data.debateRounds); if (data.decision) setDecision(data.decision); } catch (e) { console.error("Failed to parse cached trading graph data:", e); } } // If stock record contains a job id, start polling job status if (stockRecord?.lastJobId) { setJobPolling(true); let cancelled = false; const poll = async () => { try { const res = await fetch(`/api/jobs/${stockRecord.lastJobId}`); if (!res.ok) return; const j = await res.json(); if (cancelled) return; setJobStatus(j); if (j.state === "completed" || j.state === "failed") { setJobPolling(false); cancelled = true; return; } } catch (e) { console.warn("Failed to poll job status:", e); } setTimeout(poll, 1000); }; poll(); return () => { cancelled = true; }; } }, [cacheKey, stockRecord]); 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); setAnalystReports([]); setDebateRounds([]); setDecision(null); try { // Ensure ticker is saved in DB before analysis try { const fd = new FormData(); fd.append("ticker", ticker); await fetch("/api/stocks", { method: "POST", body: fd }); } catch (e) { console.warn("Failed to ensure ticker saved:", e); } const res = await fetch("/api/analyze", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ticker }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error || "Analysis failed"); const reports = data.agentSignals.map((sig: any) => ({ analyst: sig.agent, signal: sig, report: sig.reasoning, })); const debates = data.debateRounds || []; setAnalystReports(reports); setDebateRounds(debates); setDecision(data); // Save last decision/explanation to DB try { const fd2 = new FormData(); fd2.append("ticker", ticker); fd2.append("lastDecision", data.action ?? ""); fd2.append("lastExplanation", data.reasoning ?? ""); if (data.executionPlan) fd2.append("lastExecutionPlan", JSON.stringify(data.executionPlan)); await fetch("/api/stocks", { method: "POST", body: fd2 }); } catch (e) { console.warn("Failed to save decision to DB:", e); } // Cache the results sessionStorage.setItem(cacheKey, JSON.stringify({ analystReports: reports, debateRounds: debates, decision: data, timestamp: Date.now(), })); } catch (err) { console.error("Analysis error:", err); } finally { setAnalysisLoading(false); } }; // 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:
{/* Job status link */} {stockRecord?.lastJobId && (
Background job: {stockRecord.lastJobId} {jobStatus && ( Status: {jobStatus.state} )}
)}

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

)}
{(analystReports.length > 0 || debateRounds.length > 0 || decision) && (

Trading Graph Workflow

{sessionStorage.getItem(cacheKey) && ( Cached results )}
{/* Analysts Step - Collapsible */} {analystReports.length > 0 && (
{showAnalysts && (
{analystReports.map((report, i) => (
{report.analyst} {report.signal?.signal} ({(report.signal?.confidence * 100).toFixed(0)}%)

{report.signal?.reasoning}

))}
)}
)} {/* Debate Step - Collapsible */} {debateRounds.length > 0 && (
{showDebate && (
{debateRounds.map((round, i) => (
Bullish View:

{round.bullishView}

Bearish View:

{round.bearishView}

))}
)}
)} {/* Decision Step - Always Visible */} {decision && (

3 Final Decision

Action: {decision.action.toUpperCase()} Confidence: {(decision.confidence * 100).toFixed(0)}%
{decision.reasoning && (

{decision.reasoning}

{decision.executionPlan && (

Execution Plan

Amount: {decision.executionPlan.amount} shares
{decision.executionPlan.takeProfit != null && (
Take profit: ${decision.executionPlan.takeProfit}
)} {decision.executionPlan.riskManagement?.maxLossPercent != null && (
Risk management: {decision.executionPlan.riskManagement.maxLossPercent}% max loss
)} {decision.executionPlan.riskManagement?.method && (
Method: {decision.executionPlan.riskManagement.method}
)} {decision.executionPlan.note && (
Note: {decision.executionPlan.note}
)}
{/* Order suggestion summary */}
Order Suggestion
{decision.action.toUpperCase()} {decision.executionPlan.amount} shares {decision.executionPlan.takeProfit != null && ( — Take profit: ${decision.executionPlan.takeProfit} )}
{decision.executionPlan.riskManagement?.maxLossPercent != null && (
Risk: {decision.executionPlan.riskManagement.maxLossPercent}% max loss
)}
)}
)}
)}
)}

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() : "-"}
)}
); }