diff --git a/app/routes/analyze.ticker.tsx b/app/routes/analyze.ticker.tsx index 477cb38..cf2a121 100644 --- a/app/routes/analyze.ticker.tsx +++ b/app/routes/analyze.ticker.tsx @@ -1,28 +1,19 @@ -/* TRADINGGRAPH related file */ - -import { useState, useEffect } from "react"; +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 JobHistory from "../components/JobHistory"; import { useMemo } from "react"; import type { TradingDecision, AnalystReport, DebateRound } from "../types/agents"; export const meta = () => [{ title: "Stock Detail - AITrader" }]; -// In-memory cache for Alpaca bars to avoid rate limiting -// Key: "ticker-timeframe-range", Value: { bars, timestamp } const barsCache = new Map(); -const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes +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); - } + if (entry && Date.now() - entry.timestamp < CACHE_TTL_MS) return entry.bars; + if (entry) barsCache.delete(key); return null; } @@ -38,6 +29,8 @@ interface LoaderData { timeframe: string; range: string; stockRecord?: any; + latestJob?: any; + runningJob?: any; } const TIMEFRAMES = [ @@ -64,8 +57,7 @@ export async function loader({ params, request }: { params: { ticker: string }; 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; @@ -75,19 +67,18 @@ export async function loader({ params, request }: { params: { ticker: string }; let orders = []; let bars = []; let stockRecord: any = null; - + let latestJob: any = null; + let runningJob: any = null; + 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 (cached for 5 min) const barsCacheKey = `${ticker}-${timeframe}-${range}`; const cachedBars = getCachedBars(barsCacheKey); if (cachedBars) { @@ -96,114 +87,339 @@ export async function loader({ params, request }: { params: { ticker: string }; 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); - } + if (bars.length > 0) setCachedBars(barsCacheKey, 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 (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 }); + 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) => ( + + + + + + + + ))} + +
SideQtyStatusPriceDate
+ {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 } = useLoaderData() as LoaderData; + 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 [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); - const [showTradingSummary, setShowTradingSummary] = useState(true); - // Cache key for this ticker + 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}`; - // Parsed last execution plan if present on stockRecord - const lastExecutionPlan = useMemo(() => { - try { - return stockRecord?.lastExecutionPlan ? JSON.parse(stockRecord.lastExecutionPlan) : null; - } catch (e) { - return null; - } - }, [stockRecord]); + // Poll running job if exists + useEffect(() => { + if (!runningJob) return; + let cancelled = false; + let timer: ReturnType | null = null; + let currentController: AbortController | null = null; - // Load cached results on mount + 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.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 (data.decision) setSelectedJob({ returnValue: data.decision, timestamp: data.timestamp }); + } catch (e) { /* ignore */ } } + }, [cacheKey]); - // If stock record contains a job id, start polling job status - if (stockRecord?.lastJobId) { - setJobPolling(true); - let cancelled = false; - let timer: ReturnType | null = null; - let currentController: AbortController | null = null; - - const poll = async () => { - // abort previous fetch if any - if (currentController) { - try { currentController.abort(); } catch (e) {} - } - currentController = new AbortController(); - try { - const res = await fetch(`/api/jobs/${stockRecord.lastJobId}`, { signal: currentController.signal }); - if (!res.ok) { - if (!cancelled) timer = setTimeout(poll, 1000); - 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: any) { - if (e?.name !== 'AbortError') console.warn("Failed to poll job status:", e); - } finally { - if (!cancelled) timer = setTimeout(poll, 1000); - } - }; - poll(); - return () => { - cancelled = true; - if (timer) clearTimeout(timer); - if (currentController) { - try { currentController.abort(); } catch (e) {} - } - }; - } - }, [cacheKey, stockRecord]); - - // Price stream broker: uses EventSource (SSE) to receive live prices and exposes subscribe(cb)->unsubscribe const [priceStream, setPriceStream] = useState(null); useEffect(() => { let es: EventSource | null = null; @@ -214,17 +430,9 @@ export default function StockDetail() { es.onmessage = (e) => { try { const d = JSON.parse(e.data); - if (d?.price != null) { - listeners.forEach((cb) => cb(d.price)); - } - } catch (err) { - // ignore - } + if (d?.price != null) listeners.forEach((cb) => cb(d.price)); + } catch (err) { /* ignore */ } }; - es.onerror = (err) => { - console.warn("priceStream EventSource error", err); - }; - const streamObj = { subscribe(cb: (p: number) => void) { listeners.push(cb); @@ -232,14 +440,9 @@ export default function StockDetail() { }, }; setPriceStream(streamObj); - } catch (e) { - console.warn("Failed to create price EventSource", e); - } + } catch (e) { console.warn("Failed to create price EventSource", e); } } - return () => { - try { if (es) es.close(); } catch (e) {} - setPriceStream(null); - }; + return () => { try { if (es) es.close(); } catch (e) {} setPriceStream(null); }; }, [ticker]); const updateParams = (newTimeframe: string, newRange: string) => { @@ -251,21 +454,13 @@ export default function StockDetail() { 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); - } + } catch (e) { console.warn("Failed to save ticker:", e); } - // Enqueue background job for analysis so it runs reliably const res = await fetch("/api/analyze", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -274,16 +469,13 @@ export default function StockDetail() { const data = await res.json(); if (res.status === 202 && data.jobId) { - // Job queued - persist lastJobId and start polling 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 to DB:", e); - } + } catch (e) { console.warn("Failed to save lastJobId:", e); } setJobPolling(true); setJobStatus({ id: jobId, state: "queued" }); @@ -292,68 +484,32 @@ export default function StockDetail() { let currentController: AbortController | null = null; const poll = async () => { - if (currentController) { - try { currentController.abort(); } catch (e) {} - } + 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, 1000); - return; - } + 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; - // Optionally refresh persisted stock record here + setSelectedJob(j); } } catch (e: any) { - if (e?.name !== 'AbortError') console.warn("Failed to poll job status:", e); + if (e?.name !== "AbortError") console.warn("Poll error:", e); } finally { - if (!cancelled) timer = setTimeout(poll, 1000); + if (!cancelled) timer = setTimeout(poll, 2000); } }; poll(); - - return; // background job started + return; } - // Fallback: synchronous analysis response (older behavior) 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 fd3 = new FormData(); - fd3.append("ticker", ticker); - fd3.append("lastDecision", data.action ?? ""); - fd3.append("lastExplanation", data.reasoning ?? ""); - if (data.executionPlan) fd3.append("lastExecutionPlan", JSON.stringify(data.executionPlan)); - await fetch("/api/stocks", { method: "POST", body: fd3 }); - } 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(), - })); + setSelectedJob({ returnValue: data, timestamp: Date.now() }); + sessionStorage.setItem(cacheKey, JSON.stringify({ decision: data, timestamp: Date.now() })); } catch (err) { console.error("Analysis error:", err); } finally { @@ -361,425 +517,86 @@ export default function StockDetail() { } }; - // 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]; + 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 { 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 (
-

{ticker} Detail

- -
-
- Timeframe: - - - Range: - -
- - - + {/* Header */} +
+

{ticker}

- - {/* Job status link */} - {stockRecord?.lastJobId && ( -
- Background job: {stockRecord.lastJobId} - {jobStatus && ( - Status: {jobStatus.state} - )} -
- )} - - {/* Job history */} - - - {/* Show TradingGraph summary when background job completes (collapsible) */} - {jobStatus?.state === 'completed' && jobStatus?.returnValue && ( -
-
-

TradingGraph Summary

- -
- - {showTradingSummary && ( -
- {jobStatus.returnValue.action && ( -
-
Decision
-
- {String(jobStatus.returnValue.action).toUpperCase()} - (confidence: {Number(jobStatus.returnValue.confidence ?? 0).toFixed(2)}) -
- {jobStatus.returnValue.reasoning &&
{jobStatus.returnValue.reasoning}
} -
- )} - - {Array.isArray(jobStatus.returnValue.agentSignals) && jobStatus.returnValue.agentSignals.length > 0 && ( -
-
Analyst Signals
-
- {jobStatus.returnValue.agentSignals.map((s: any, i: number) => ( -
-
-
{s.agent}
-
{s.signal} ({Math.round((s.confidence ?? 0) * 100)}%)
-
- {s.reasoning &&
{s.reasoning}
} -
- ))} -
-
- )} - - {jobStatus.returnValue.executionPlan && ( -
-
Execution Plan
-
- {jobStatus.returnValue.executionPlan.amount != null && (
Amount: {jobStatus.returnValue.executionPlan.amount}
)} - {jobStatus.returnValue.executionPlan.takeProfit != null && (
Take profit: ${jobStatus.returnValue.executionPlan.takeProfit}
)} - {jobStatus.returnValue.executionPlan.stopLoss != null && (
Stop loss: ${jobStatus.returnValue.executionPlan.stopLoss}
)} - {jobStatus.returnValue.executionPlan.riskManagement?.maxLossPercent != null && (
Risk: {jobStatus.returnValue.executionPlan.riskManagement.maxLossPercent}%
)} -
-
- )} - - -
- )} -
- )} - - {/* Last persisted decision (if no live decision) */} - {!decision && stockRecord?.lastDecision && ( -
-

Last Saved Suggestion

-
-
Action: {stockRecord.lastDecision?.toUpperCase()}
- {lastExecutionPlan && ( -
- {lastExecutionPlan.amount != null && (
Amount: {lastExecutionPlan.amount}
)} - {lastExecutionPlan.takeProfit != null && (
Take profit: ${lastExecutionPlan.takeProfit}
)} - {lastExecutionPlan.stopLoss != null && (
Stop loss: ${lastExecutionPlan.stopLoss}
)} - {lastExecutionPlan.riskManagement?.maxLossPercent != null && (
Risk: {lastExecutionPlan.riskManagement.maxLossPercent}%
)} - - {/* LLM review metadata if present */} - {lastExecutionPlan._llmReview && ( -
-
LLM Review
-
Approved: {lastExecutionPlan._llmReview.approved ? 'Yes' : 'No'}
- {lastExecutionPlan._llmReview.notes && ( -
Notes: {lastExecutionPlan._llmReview.notes}
- )} -
- )} -
- )} -
-
- )}
- -
-

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

- )} + + {/* Chart */} +
+
+ Timeframe: + + Range: + +
+
- - {(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.stopLoss != null && ( -
Stop loss: ${decision.executionPlan.stopLoss}
- )} - {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.stopLoss != null && ( - — Stop loss: ${decision.executionPlan.stopLoss} - )} -
- {decision.executionPlan.riskManagement?.maxLossPercent != null && ( -
Risk: {decision.executionPlan.riskManagement.maxLossPercent}% max loss
- )} -
-
+ {/* Position & Orders row */} +
+ + +
- {/* LLM Review (if provided) */} - {decision.executionPlan._llmReview && ( -
-
LLM Review
-
Approved: {decision.executionPlan._llmReview.approved ? 'Yes' : 'No'}
- {decision.executionPlan._llmReview.notes && ( -
Notes: {decision.executionPlan._llmReview.notes}
- )} -
- )} + {/* Latest analysis result */} + {displayJob && } -
- )} -
- )} -
-
- )} + {/* Expand toggle */} + {displayJob && ( +
+
)} - -
-

Recent Orders

- {orders.length === 0 ? ( -

No orders found for {ticker}

- ) : ( -
- - - - - - - - - - - - {orders.map((order: any, i: number) => ( - - - - - - - - ))} - -
SideQtyStatusFilled PriceFilled 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() : "-"} -
-
- )} + + {/* Job history */} +
+ { setSelectedJob(j); setShowExpanded(false); }} + />