666 lines
27 KiB
TypeScript
666 lines
27 KiB
TypeScript
import { useState, useEffect, useCallback } from "react";
|
|
import { useLoaderData, Link, useSearchParams } 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<string, { bars: any[]; timestamp: number }>();
|
|
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 <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${cls}`}>{state}</span>;
|
|
}
|
|
|
|
function DecisionBadge({ decision }: { decision: TradingDecision }) {
|
|
const color = decision.action === "buy" ? "text-green-600" : decision.action === "sell" ? "text-red-600" : "text-gray-500";
|
|
return (
|
|
<div className="flex items-center gap-3">
|
|
<span className={`text-2xl font-bold ${color}`}>{decision.action.toUpperCase()}</span>
|
|
<span className="text-sm text-gray-500">{(decision.confidence * 100).toFixed(0)}% confidence</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ExecutionPlanCompact({ plan }: { plan: TradingDecision["executionPlan"] }) {
|
|
if (!plan) return null;
|
|
return (
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
|
|
{plan.amount != null && <div><span className="text-gray-500">Qty</span><div className="font-medium">{plan.amount} shares</div></div>}
|
|
{plan.takeProfit != null && <div><span className="text-gray-500">Take Profit</span><div className="font-medium text-green-600">${plan.takeProfit}</div></div>}
|
|
{plan.stopLoss != null && <div><span className="text-gray-500">Stop Loss</span><div className="font-medium text-red-600">${plan.stopLoss}</div></div>}
|
|
{plan.riskManagement?.maxLossPercent != null && <div><span className="text-gray-500">Risk</span><div className="font-medium">{plan.riskManagement.maxLossPercent}%</div></div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AgentSignalRow({ signal }: { signal: any }) {
|
|
const sigColor = signal.signal === "bullish" ? "text-green-600" : signal.signal === "bearish" ? "text-red-600" : "text-gray-500";
|
|
return (
|
|
<div className="flex items-start justify-between gap-2 py-1.5 border-b border-gray-100 last:border-0">
|
|
<div className="flex-1 min-w-0">
|
|
<span className="text-xs font-medium capitalize text-gray-700">{signal.agent}</span>
|
|
{signal.reasoning && <p className="text-xs text-gray-500 truncate">{signal.reasoning}</p>}
|
|
</div>
|
|
<span className={`text-xs font-medium whitespace-nowrap ${sigColor}`}>{signal.signal} {(signal.confidence * 100).toFixed(0)}%</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DebateCompact({ rounds }: { rounds: DebateRound[] }) {
|
|
if (!rounds?.length) return null;
|
|
return (
|
|
<div className="space-y-1.5">
|
|
{rounds.slice(0, 3).map((r, i) => (
|
|
<div key={i} className="text-xs grid grid-cols-2 gap-2">
|
|
<div className="text-green-700 bg-green-50 rounded px-2 py-1 truncate" title={r.bullishView}>📈 {r.bullishView}</div>
|
|
<div className="text-red-700 bg-red-50 rounded px-2 py-1 truncate" title={r.bearishView}>📉 {r.bearishView}</div>
|
|
</div>
|
|
))}
|
|
{rounds.length > 3 && <div className="text-xs text-gray-400">+{rounds.length - 3} more rounds</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PositionCard({ position, ticker }: { position: LoaderData["position"]; ticker: string }) {
|
|
if (!position) return (
|
|
<div className="bg-white rounded-xl shadow-lg p-4 border border-gray-200">
|
|
<h3 className="text-sm font-semibold text-gray-800 mb-2">Position</h3>
|
|
<p className="text-sm text-gray-500">No position held</p>
|
|
</div>
|
|
);
|
|
|
|
const pnlColor = position.unrealized_pl >= 0 ? "text-green-600" : "text-red-600";
|
|
const pnlSign = position.unrealized_pl >= 0 ? "+" : "";
|
|
|
|
return (
|
|
<div className="bg-white rounded-xl shadow-lg p-4 border border-gray-200">
|
|
<h3 className="text-sm font-semibold text-gray-800 mb-3">Position — {ticker}</h3>
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm">
|
|
<div><span className="text-gray-500 text-xs">Qty</span><div className="font-medium">{position.qty}</div></div>
|
|
<div><span className="text-gray-500 text-xs">Avg Entry</span><div className="font-medium">${position.avg_entry_price?.toFixed(2)}</div></div>
|
|
<div><span className="text-gray-500 text-xs">Current</span><div className="font-medium">${position.current_price?.toFixed(2)}</div></div>
|
|
<div><span className="text-gray-500 text-xs">P&L</span><div className={`font-bold ${pnlColor}`}>{pnlSign}${position.unrealized_pl?.toFixed(2)}</div></div>
|
|
</div>
|
|
<div className="mt-2 text-xs text-gray-500">Market value: ${position.market_value?.toFixed(2)}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function OrdersCard({ orders, ticker }: { orders: any[]; ticker: string }) {
|
|
if (!orders.length) return (
|
|
<div className="bg-white rounded-xl shadow-lg p-4 border border-gray-200">
|
|
<h3 className="text-sm font-semibold text-gray-800 mb-2">Orders</h3>
|
|
<p className="text-sm text-gray-500">No orders for {ticker}</p>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="bg-white rounded-xl shadow-lg p-4 border border-gray-200">
|
|
<h3 className="text-sm font-semibold text-gray-800 mb-3">Orders — {ticker}</h3>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-xs">
|
|
<thead>
|
|
<tr className="border-b border-gray-200">
|
|
<th className="text-left py-1.5 px-2 font-medium text-gray-500">Side</th>
|
|
<th className="text-left py-1.5 px-2 font-medium text-gray-500">Qty</th>
|
|
<th className="text-left py-1.5 px-2 font-medium text-gray-500">Status</th>
|
|
<th className="text-left py-1.5 px-2 font-medium text-gray-500">Price</th>
|
|
<th className="text-left py-1.5 px-2 font-medium text-gray-500">Date</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{orders.slice(0, 5).map((order: any, i: number) => (
|
|
<tr key={order.id || i} className="border-b border-gray-100">
|
|
<td className="py-1.5 px-2">
|
|
<span className={order.side === "buy" ? "text-green-600 font-medium" : "text-red-600 font-medium"}>{order.side?.toUpperCase()}</span>
|
|
</td>
|
|
<td className="py-1.5 px-2">{order.qty}</td>
|
|
<td className="py-1.5 px-2">{stateBadge(order.status)}</td>
|
|
<td className="py-1.5 px-2">{order.filled_avg_price ? `$${parseFloat(order.filled_avg_price).toFixed(2)}` : "-"}</td>
|
|
<td className="py-1.5 px-2 text-gray-500">{order.filled_at ? new Date(order.filled_at).toLocaleDateString() : "-"}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function JobHistoryInline({ ticker, runningJob, latestJob, onJobSelect }: { ticker: string; runningJob: any; latestJob: any; onJobSelect: (job: any) => void }) {
|
|
const [jobs, setJobs] = useState<any[]>([]);
|
|
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 (
|
|
<div className="bg-white rounded-xl shadow-lg p-4 border border-gray-200">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-sm font-semibold text-gray-800">Job History</h3>
|
|
<button onClick={fetchJobs} className="text-xs text-blue-600 hover:underline">Refresh</button>
|
|
</div>
|
|
|
|
{/* Running job banner */}
|
|
{runningJob && (
|
|
<div className="mb-3 p-2 bg-blue-50 border border-blue-200 rounded-lg flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className="animate-pulse w-2 h-2 bg-blue-500 rounded-full" />
|
|
<span className="text-xs font-medium text-blue-700">Running: {runningJob.id}</span>
|
|
</div>
|
|
{stateBadge(runningJob.state)}
|
|
</div>
|
|
)}
|
|
|
|
{loading ? (
|
|
<div className="space-y-2">
|
|
<div className="h-8 bg-gray-100 rounded animate-pulse" />
|
|
<div className="h-8 bg-gray-100 rounded animate-pulse" />
|
|
</div>
|
|
) : jobs.length === 0 ? (
|
|
<p className="text-xs text-gray-500">No jobs for {ticker}</p>
|
|
) : (
|
|
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
|
{jobs.map((j: any) => (
|
|
<div key={j.id} className="flex items-center justify-between p-2 bg-gray-50 rounded text-xs">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
{stateBadge(j.state)}
|
|
<span className="font-mono truncate text-gray-700">{j.id}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
{(j.state === "waiting") && (
|
|
<button onClick={() => cancel(j.id)} className="text-red-600 hover:underline">Cancel</button>
|
|
)}
|
|
<button onClick={() => onJobSelect(j)} className="text-blue-600 hover:underline">View</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TradingResultCard({ job, expanded, onRefresh }: { job: any; expanded: boolean; onRefresh?: () => void }) {
|
|
const decision = job?.returnValue;
|
|
const isRunning = job && !decision && (job.state === "active" || job.state === "waiting" || job.state === "queued" || job.state === "processing");
|
|
|
|
if (isRunning) {
|
|
return (
|
|
<div className="bg-white rounded-xl shadow-lg p-4 border border-gray-200">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-sm font-semibold text-gray-800">Job in Progress</h3>
|
|
{onRefresh && <button onClick={onRefresh} className="text-xs text-blue-600 hover:underline">Refresh</button>}
|
|
</div>
|
|
<div className="flex items-center gap-3 mb-3">
|
|
<span className="animate-pulse w-2.5 h-2.5 bg-blue-500 rounded-full" />
|
|
<span className="font-mono text-xs text-gray-600 truncate">{job.id}</span>
|
|
{stateBadge(job.state)}
|
|
</div>
|
|
<p className="text-xs text-gray-500">The TradingGraph analysis is running. Results will appear here when complete.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!decision) return null;
|
|
|
|
return (
|
|
<div className="bg-white rounded-xl shadow-lg p-4 border border-gray-200">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-sm font-semibold text-gray-800">Latest Analysis Result</h3>
|
|
<span className="text-xs text-gray-400">{new Date(job.timestamp || Date.now()).toLocaleString()}</span>
|
|
</div>
|
|
|
|
<DecisionBadge decision={decision} />
|
|
|
|
{decision.reasoning && <p className="text-sm text-gray-600 mt-2">{decision.reasoning}</p>}
|
|
|
|
{expanded && decision.executionPlan && (
|
|
<div className="mt-3 pt-3 border-t border-gray-100">
|
|
<h4 className="text-xs font-medium text-gray-700 mb-2">Execution Plan</h4>
|
|
<ExecutionPlanCompact plan={decision.executionPlan} />
|
|
</div>
|
|
)}
|
|
|
|
{expanded && decision.agentSignals?.length > 0 && (
|
|
<div className="mt-3 pt-3 border-t border-gray-100">
|
|
<h4 className="text-xs font-medium text-gray-700 mb-2">Agent Signals</h4>
|
|
<div className="space-y-0">
|
|
{decision.agentSignals.map((s: any, i: number) => <AgentSignalRow key={i} signal={s} />)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{expanded && decision.debateRounds?.length > 0 && (
|
|
<div className="mt-3 pt-3 border-t border-gray-100">
|
|
<h4 className="text-xs font-medium text-gray-700 mb-2">Debate Rounds</h4>
|
|
<DebateCompact rounds={decision.debateRounds} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function StockDetail() {
|
|
const { ticker, position, orders, bars, timeframe, range, stockRecord, latestJob, runningJob } = useLoaderData() as LoaderData;
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
|
|
const [analysisLoading, setAnalysisLoading] = useState(false);
|
|
const [jobStatus, setJobStatus] = useState<any>(runningJob || null);
|
|
const [jobPolling, setJobPolling] = useState(!!runningJob);
|
|
const [showExpanded, setShowExpanded] = useState(false);
|
|
const [selectedJob, setSelectedJob] = useState<any>(latestJob || null);
|
|
|
|
// Poll selected job if it's in progress
|
|
useEffect(() => {
|
|
if (!selectedJob?.id) return;
|
|
const state = selectedJob.state;
|
|
if (state !== "active" && state !== "waiting" && state !== "queued" && state !== "processing") return;
|
|
|
|
let cancelled = false;
|
|
let timer: ReturnType<typeof setTimeout> | 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/${selectedJob.id}`, { signal: currentController.signal });
|
|
if (!res.ok) { if (!cancelled) timer = setTimeout(poll, 2000); return; }
|
|
const j = await res.json();
|
|
if (cancelled) return;
|
|
setSelectedJob(j);
|
|
if (j.state === "completed" || j.state === "failed") {
|
|
cancelled = true;
|
|
// Also update the main job status if this is the same job
|
|
if (jobStatus?.id === j.id) setJobStatus(j);
|
|
}
|
|
} catch (e: any) {
|
|
if (e?.name !== "AbortError") console.warn("Selected job 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) {} } };
|
|
}, [selectedJob?.id, selectedJob?.state, jobStatus?.id]);
|
|
|
|
const cacheKey = `tradinggraph-${ticker}`;
|
|
|
|
// Poll running job if exists
|
|
useEffect(() => {
|
|
if (!runningJob) return;
|
|
let cancelled = false;
|
|
let timer: ReturnType<typeof setTimeout> | 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<any>(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) => {
|
|
setSearchParams({ timeframe: newTimeframe, range: newRange }, { replace: true, preventScrollReset: 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<typeof setTimeout> | 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 refreshSelectedJob = useCallback(async () => {
|
|
if (!selectedJob?.id) return;
|
|
try {
|
|
const res = await fetch(`/api/jobs/${selectedJob.id}`);
|
|
if (res.ok) {
|
|
const j = await res.json();
|
|
setSelectedJob(j);
|
|
if (jobStatus?.id === j.id) setJobStatus(j);
|
|
}
|
|
} catch (e) { console.warn("Refresh job error:", e); }
|
|
}, [selectedJob?.id, jobStatus?.id]);
|
|
|
|
const displayJob = selectedJob || (jobStatus?.state === "completed" ? jobStatus : null);
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-blue-50">
|
|
<Navbar />
|
|
<div className="mx-auto max-w-7xl px-6 py-8">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h1 className="text-3xl font-bold text-gray-900">{ticker}</h1>
|
|
<button
|
|
onClick={runTradingGraph}
|
|
disabled={analysisLoading || jobPolling}
|
|
className="bg-purple-600 text-white px-5 py-2 rounded-lg font-medium hover:bg-purple-700 disabled:opacity-50 transition-colors text-sm"
|
|
>
|
|
{analysisLoading ? "Starting..." : jobPolling ? `Analyzing... (${jobStatus?.state})` : "Run Analysis"}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Chart */}
|
|
<div className="bg-white rounded-xl shadow-lg p-4 mb-4 border border-gray-200">
|
|
<div className="flex items-center gap-3 mb-3">
|
|
<span className="text-xs text-gray-500">Timeframe:</span>
|
|
<select value={timeframe} onChange={(e) => updateParams(e.target.value, range)} className="border border-gray-300 rounded px-2 py-1 text-sm text-gray-900 bg-white focus:ring-2 focus:ring-blue-500">
|
|
{TIMEFRAMES.map((tf) => <option key={tf.value} value={tf.value}>{tf.label}</option>)}
|
|
</select>
|
|
<span className="text-xs text-gray-500">Range:</span>
|
|
<select value={range} onChange={(e) => updateParams(timeframe, e.target.value)} className="border border-gray-300 rounded px-2 py-1 text-sm text-gray-900 bg-white focus:ring-2 focus:ring-blue-500">
|
|
{RANGES.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
|
|
</select>
|
|
</div>
|
|
<TradingViewChart ticker={ticker} data={chartData} timeframe={timeframe} priceStream={priceStream} />
|
|
</div>
|
|
|
|
{/* Position & Orders row */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
|
|
<PositionCard position={position} ticker={ticker} />
|
|
<OrdersCard orders={orders} ticker={ticker} />
|
|
</div>
|
|
|
|
{/* Latest analysis result */}
|
|
{displayJob && <TradingResultCard job={displayJob} expanded={showExpanded} onRefresh={refreshSelectedJob} />}
|
|
|
|
{/* Expand toggle */}
|
|
{displayJob && (
|
|
<div className="flex justify-center mt-2">
|
|
<button onClick={() => setShowExpanded((s) => !s)} className="text-xs text-blue-600 hover:underline">
|
|
{showExpanded ? "Show less" : "Show full analysis"}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Job history */}
|
|
<div className="mt-4">
|
|
<JobHistoryInline
|
|
ticker={ticker}
|
|
runningJob={jobPolling ? jobStatus : null}
|
|
latestJob={latestJob}
|
|
onJobSelect={(j) => { setSelectedJob(j); setShowExpanded(false); }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|