feat(settings): add settings route and API updates\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

This commit is contained in:
2026-05-16 20:19:35 +02:00
parent 9b63d981b0
commit 0ee89cf052
38 changed files with 1426 additions and 562 deletions
+261 -23
View File
@@ -1,5 +1,7 @@
/* TRADINGGRAPH related file */
import { useState, useEffect } from "react";
import { useLoaderData, useNavigate, useLocation } from "react-router";
import { useLoaderData, useNavigate, useLocation, Link } from "react-router";
import TradingViewChart from "../components/TradingViewChart";
import Navbar from "../components/Navbar";
import JobHistory from "../components/JobHistory";
@@ -8,6 +10,26 @@ import type { TradingDecision, AnalystReport, DebateRound } from "../types/agent
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<string, { bars: any[]; timestamp: number }>();
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
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;
@@ -65,10 +87,19 @@ export async function loader({ params, request }: { params: { ticker: string };
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 bars for chart with timeframe and range (cached for 5 min)
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);
}
}
// Fetch stock record (to get lastJobId)
try {
@@ -100,6 +131,7 @@ export default function StockDetail() {
const [showDebate, setShowDebate] = useState(false);
const [jobStatus, setJobStatus] = useState<any>(null);
const [jobPolling, setJobPolling] = useState(false);
const [showTradingSummary, setShowTradingSummary] = useState(true);
// Cache key for this ticker
const cacheKey = `tradinggraph-${ticker}`;
@@ -131,10 +163,21 @@ export default function StockDetail() {
if (stockRecord?.lastJobId) {
setJobPolling(true);
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | 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}`);
if (!res.ok) return;
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);
@@ -143,16 +186,62 @@ export default function StockDetail() {
cancelled = true;
return;
}
} catch (e) {
console.warn("Failed to poll job status:", e);
} catch (e: any) {
if (e?.name !== 'AbortError') console.warn("Failed to poll job status:", e);
} finally {
if (!cancelled) timer = setTimeout(poll, 1000);
}
setTimeout(poll, 1000);
};
poll();
return () => { cancelled = true; };
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<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
}
};
es.onerror = (err) => {
console.warn("priceStream EventSource error", err);
};
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);
@@ -176,12 +265,63 @@ export default function StockDetail() {
console.warn("Failed to ensure ticker saved:", e);
}
// Enqueue background job for analysis so it runs reliably
const res = await fetch("/api/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ticker }),
body: JSON.stringify({ ticker, background: true }),
});
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);
}
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, 1000);
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
}
} catch (e: any) {
if (e?.name !== 'AbortError') console.warn("Failed to poll job status:", e);
} finally {
if (!cancelled) timer = setTimeout(poll, 1000);
}
};
poll();
return; // background job started
}
// Fallback: synchronous analysis response (older behavior)
if (!res.ok) throw new Error(data.error || "Analysis failed");
const reports = data.agentSignals.map((sig: any) => ({
@@ -197,12 +337,12 @@ export default function StockDetail() {
// 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 });
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);
}
@@ -287,7 +427,7 @@ export default function StockDetail() {
</select>
</div>
<TradingViewChart ticker={ticker} data={chartData} timeframe={timeframe} />
<TradingViewChart ticker={ticker} data={chartData} timeframe={timeframe} priceStream={priceStream} />
<button
onClick={runTradingGraph}
@@ -300,7 +440,7 @@ export default function StockDetail() {
{/* Job status link */}
{stockRecord?.lastJobId && (
<div className="mt-3 text-sm text-gray-600">
Background job: <a href={`/api/jobs/${stockRecord.lastJobId}`} className="text-blue-600 hover:underline">{stockRecord.lastJobId}</a>
Background job: <Link to={`/jobs/${stockRecord.lastJobId}`} className="text-blue-600 hover:underline">{stockRecord.lastJobId}</Link>
{jobStatus && (
<span className="ml-3">Status: <strong>{jobStatus.state}</strong></span>
)}
@@ -310,6 +450,74 @@ export default function StockDetail() {
{/* Job history */}
<JobHistory ticker={ticker} />
{/* Show TradingGraph summary when background job completes (collapsible) */}
{jobStatus?.state === 'completed' && jobStatus?.returnValue && (
<div className="mt-6 bg-white rounded-xl shadow-lg p-6 border border-gray-200">
<div className="flex items-center justify-between mb-3">
<h2 className="text-xl font-bold text-gray-900">TradingGraph Summary</h2>
<button
onClick={() => setShowTradingSummary((s) => !s)}
aria-expanded={showTradingSummary}
className="text-sm text-gray-500 hover:text-gray-700 focus:outline-none"
>
{showTradingSummary ? 'Hide' : 'Show'}
</button>
</div>
{showTradingSummary && (
<div>
{jobStatus.returnValue.action && (
<div className="mb-3">
<div className="text-sm text-gray-600">Decision</div>
<div className="text-base font-medium">
<span className={
jobStatus.returnValue.action === 'buy' ? 'text-green-600' : jobStatus.returnValue.action === 'sell' ? 'text-red-600' : 'text-gray-800'
}>{String(jobStatus.returnValue.action).toUpperCase()}</span>
<span className="text-sm text-gray-500 ml-2">(confidence: {Number(jobStatus.returnValue.confidence ?? 0).toFixed(2)})</span>
</div>
{jobStatus.returnValue.reasoning && <div className="mt-2 text-sm text-gray-700">{jobStatus.returnValue.reasoning}</div>}
</div>
)}
{Array.isArray(jobStatus.returnValue.agentSignals) && jobStatus.returnValue.agentSignals.length > 0 && (
<div className="mb-3">
<div className="text-sm text-gray-600">Analyst Signals</div>
<div className="mt-2 space-y-2">
{jobStatus.returnValue.agentSignals.map((s: any, i: number) => (
<div key={i} className="p-2 bg-gray-50 rounded border border-gray-100 text-sm">
<div className="flex items-center justify-between">
<div className="font-medium capitalize text-gray-900">{s.agent}</div>
<div className={
s.signal === 'bullish' ? 'text-green-600 text-sm' : s.signal === 'bearish' ? 'text-red-600 text-sm' : 'text-gray-600 text-sm'
}>{s.signal} ({Math.round((s.confidence ?? 0) * 100)}%)</div>
</div>
{s.reasoning && <div className="mt-1 text-sm text-gray-700">{s.reasoning}</div>}
</div>
))}
</div>
</div>
)}
{jobStatus.returnValue.executionPlan && (
<div className="mb-3">
<div className="text-sm text-gray-600">Execution Plan</div>
<div className="mt-2 text-sm text-gray-700">
{jobStatus.returnValue.executionPlan.amount != null && (<div>Amount: <strong>{jobStatus.returnValue.executionPlan.amount}</strong></div>)}
{jobStatus.returnValue.executionPlan.takeProfit != null && (<div>Take profit: <strong>${jobStatus.returnValue.executionPlan.takeProfit}</strong></div>)}
{jobStatus.returnValue.executionPlan.stopLoss != null && (<div>Stop loss: <strong>${jobStatus.returnValue.executionPlan.stopLoss}</strong></div>)}
{jobStatus.returnValue.executionPlan.riskManagement?.maxLossPercent != null && (<div>Risk: <strong>{jobStatus.returnValue.executionPlan.riskManagement.maxLossPercent}%</strong></div>)}
</div>
</div>
)}
<div className="mt-3">
<a href={`/jobs/${jobStatus.id}`} className="text-sm text-blue-600 hover:underline">View job details</a>
</div>
</div>
)}
</div>
)}
{/* Last persisted decision (if no live decision) */}
{!decision && stockRecord?.lastDecision && (
<div className="mt-3 bg-white rounded-lg p-3 border border-gray-200 text-gray-900">
@@ -320,7 +528,19 @@ export default function StockDetail() {
<div className="mt-2 text-sm text-gray-700">
{lastExecutionPlan.amount != null && (<div>Amount: <strong>{lastExecutionPlan.amount}</strong></div>)}
{lastExecutionPlan.takeProfit != null && (<div>Take profit: <strong>${lastExecutionPlan.takeProfit}</strong></div>)}
{lastExecutionPlan.stopLoss != null && (<div>Stop loss: <strong>${lastExecutionPlan.stopLoss}</strong></div>)}
{lastExecutionPlan.riskManagement?.maxLossPercent != null && (<div>Risk: <strong>{lastExecutionPlan.riskManagement.maxLossPercent}%</strong></div>)}
{/* LLM review metadata if present */}
{lastExecutionPlan._llmReview && (
<div className="mt-3 border-t pt-3 text-sm text-gray-700">
<h5 className="text-sm font-medium text-gray-800">LLM Review</h5>
<div className="mt-1">Approved: <strong className={lastExecutionPlan._llmReview.approved ? 'text-green-600' : 'text-red-600'}>{lastExecutionPlan._llmReview.approved ? 'Yes' : 'No'}</strong></div>
{lastExecutionPlan._llmReview.notes && (
<div className="mt-1">Notes: <span className="font-medium text-gray-800">{lastExecutionPlan._llmReview.notes}</span></div>
)}
</div>
)}
</div>
)}
</div>
@@ -467,6 +687,9 @@ export default function StockDetail() {
{decision.executionPlan.takeProfit != null && (
<div>Take profit: <span className="font-medium">${decision.executionPlan.takeProfit}</span></div>
)}
{decision.executionPlan.stopLoss != null && (
<div>Stop loss: <span className="font-medium">${decision.executionPlan.stopLoss}</span></div>
)}
{decision.executionPlan.riskManagement?.maxLossPercent != null && (
<div>Risk management: <span className="font-medium">{decision.executionPlan.riskManagement.maxLossPercent}% max loss</span></div>
)}
@@ -483,17 +706,32 @@ export default function StockDetail() {
<h5 className="text-sm font-medium text-gray-800">Order Suggestion</h5>
<div className="text-sm text-gray-700 mt-2">
<div>
<span className="font-medium">{decision.action.toUpperCase()}</span>
{decision.executionPlan.amount} shares
<span className="font-medium">{decision.action.toUpperCase()}</span>
<span className="ml-2">{decision.executionPlan.amount} (shares)</span>
{decision.executionPlan.takeProfit != null && (
<span> Take profit: ${decision.executionPlan.takeProfit}</span>
)}
{decision.executionPlan.stopLoss != null && (
<span> Stop loss: ${decision.executionPlan.stopLoss}</span>
)}
</div>
{decision.executionPlan.riskManagement?.maxLossPercent != null && (
<div className="text-xs text-gray-500">Risk: {decision.executionPlan.riskManagement.maxLossPercent}% max loss</div>
)}
</div>
</div>
{/* LLM Review (if provided) */}
{decision.executionPlan._llmReview && (
<div className="mt-3 border-t pt-3 text-sm text-gray-700">
<h5 className="text-sm font-medium text-gray-800">LLM Review</h5>
<div className="mt-1">Approved: <strong className={decision.executionPlan._llmReview.approved ? 'text-green-600' : 'text-red-600'}>{decision.executionPlan._llmReview.approved ? 'Yes' : 'No'}</strong></div>
{decision.executionPlan._llmReview.notes && (
<div className="mt-1">Notes: <span className="font-medium text-gray-800">{decision.executionPlan._llmReview.notes}</span></div>
)}
</div>
)}
</div>
)}
</div>
@@ -546,4 +784,4 @@ export default function StockDetail() {
</div>
</div>
);
}
}