feat(settings): add settings route and API updates\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -56,8 +56,8 @@ describe("StockDetail UI - executionPlan", () => {
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/Execution Plan/i)).toBeInTheDocument());
|
||||
expect(screen.getByText(/Amount:/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/25 shares/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Take profit:/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/\$150/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/25 shares/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/Take profit:/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/\$150/).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
+261
-23
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { settingsService } from '../../../lib/settings.server';
|
||||
import { test, expect } from 'vitest';
|
||||
import { settingsService } from '../../../../lib/settings.server';
|
||||
|
||||
test('settings API helper behavior', async () => {
|
||||
const key = 'api_test_' + Date.now();
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { ActionFunction } from '@remix-run/node';
|
||||
import { settingsService } from '~/lib/settings.server';
|
||||
import { requireAdmin } from '~/lib/auth.server';
|
||||
import { settingsService } from '../../../../lib/settings.server';
|
||||
import { requireAdmin } from '../../../../lib/auth.server';
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
export async function action({ request, params }: { request: Request; params: any }) {
|
||||
await requireAdmin(request);
|
||||
const key = params.key as string;
|
||||
const body = await request.json();
|
||||
if (!key) return new Response('Missing key', { status: 400 });
|
||||
await settingsService.set(key, body.value, 'admin');
|
||||
return new Response(null, { status: 204 });
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import type { LoaderFunction, ActionFunction } from '@remix-run/node';
|
||||
import { json } from '@remix-run/node';
|
||||
import { settingsService } from '~/lib/settings.server';
|
||||
import { requireAdmin } from '~/lib/auth.server';
|
||||
import { settingsService } from '../../../../lib/settings.server';
|
||||
import { requireAdmin } from '../../../../lib/auth.server';
|
||||
|
||||
export const loader: LoaderFunction = async ({ request }) => {
|
||||
export async function loader({ request }: { request: Request }) {
|
||||
await requireAdmin(request);
|
||||
await settingsService.init?.();
|
||||
await (settingsService as any).init?.();
|
||||
const entries: any[] = [];
|
||||
// @ts-ignore access cache
|
||||
for (const key of (settingsService as any).cache.keys()) {
|
||||
entries.push({ key, value: await settingsService.get(key) });
|
||||
}
|
||||
return json(entries);
|
||||
};
|
||||
return new Response(JSON.stringify(entries), { headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
export async function action({ request }: { request: Request }) {
|
||||
await requireAdmin(request);
|
||||
const body = await request.json();
|
||||
if (!body || !body.key) return new Response('Missing key', { status: 400 });
|
||||
const created = await settingsService.set(body.key, body.value, 'admin');
|
||||
return new Response(JSON.stringify(created), { status: 201 });
|
||||
};
|
||||
return new Response(JSON.stringify(created), { status: 201, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
|
||||
@@ -1,37 +1,9 @@
|
||||
import type { AlpacaAccount } from "../../../types";
|
||||
import Alpaca from "@alpacahq/alpaca-trade-api";
|
||||
import alpacaService from "../../../lib/alpacaClient";
|
||||
|
||||
const alpaca = new Alpaca({
|
||||
keyId: process.env.ALPACA_API_KEY!,
|
||||
secretKey: process.env.ALPACA_SECRET_KEY!,
|
||||
baseUrl: process.env.ALPACA_BASE_URL || "https://paper-api.alpaca.markets",
|
||||
dataBaseUrl: process.env.ALPACA_DATA_URL || "https://data.alpaca.markets",
|
||||
retryOnError: false,
|
||||
});
|
||||
|
||||
async function fetchAlpacaAccount(): Promise<AlpacaAccount> {
|
||||
export async function loader({ request }: { request: Request }) {
|
||||
try {
|
||||
console.log("Fetching Alpaca account with key:", process.env.ALPACA_API_KEY?.substring(0, 8) + "...");
|
||||
const account = await alpaca.getAccount();
|
||||
console.log("Alpaca account fetched successfully");
|
||||
return {
|
||||
cash: parseFloat(account.cash),
|
||||
buying_power: parseFloat(account.buying_power),
|
||||
portfolio_value: parseFloat(account.portfolio_value),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Alpaca API fetch error:", error);
|
||||
return {
|
||||
cash: 0,
|
||||
buying_power: 0,
|
||||
portfolio_value: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function loader() {
|
||||
try {
|
||||
const account = await fetchAlpacaAccount();
|
||||
const account = await alpacaService.fetchAccount();
|
||||
return Response.json(account);
|
||||
} catch (error) {
|
||||
console.error("Alpaca API error:", error);
|
||||
|
||||
@@ -1,21 +1,36 @@
|
||||
import { fetchBars, fetchLatestBar } from "../../../lib/alpacaClient";
|
||||
import alpacaService from "../../../lib/alpacaClient";
|
||||
|
||||
export async function loader({ request, params }: { request: Request; params: { ticker: string } }) {
|
||||
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"; // 1D, 1W, 1M, 3M, 1Y, 3Y, ALL
|
||||
const mode = url.searchParams.get('mode') === 'live' ? 'live' : 'paper';
|
||||
|
||||
|
||||
if (!ticker) {
|
||||
return Response.json({ error: "Ticker is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Normalize timeframe to Alpaca API expected values
|
||||
function mapToAlpacaTimeframe(tf: string) {
|
||||
switch (tf) {
|
||||
case "1H":
|
||||
return "1Hour";
|
||||
case "1D":
|
||||
return "1Day";
|
||||
case "1W":
|
||||
case "1M":
|
||||
return "1Day"; // weekly/monthly UI ranges use daily bars
|
||||
default:
|
||||
return tf; // 1Min,5Min,15Min,30Min expected to be supported
|
||||
}
|
||||
}
|
||||
const alpacaTimeframe = mapToAlpacaTimeframe(timeframe);
|
||||
|
||||
// Get latest bar for current price (uses paper by default unless mode=live)
|
||||
let price = 0;
|
||||
try {
|
||||
const last = await fetchLatestBar(ticker, timeframe, mode as any);
|
||||
const last = await alpacaService.fetchLatestBar(ticker, alpacaTimeframe);
|
||||
price = last ? (last.ClosePrice ?? last.c ?? 0) : 0;
|
||||
} catch (tradeErr) {
|
||||
console.error(`API quote/${ticker}: fetchLatestBar failed`, tradeErr);
|
||||
@@ -44,13 +59,15 @@ export async function loader({ request, params }: { request: Request; params: {
|
||||
}
|
||||
|
||||
const barsOptions: any = { limit: 1000 }; // High limit for time range
|
||||
if (!isIntraday && range !== "ALL") {
|
||||
barsOptions.start = startDate.toISOString().split('T')[0];
|
||||
} else if (!isIntraday) {
|
||||
// For daily/non-intraday queries pass just the date part (YYYY-MM-DD)
|
||||
if (!isIntraday) {
|
||||
barsOptions.start = startDate.toISOString().split('T')[0];
|
||||
} else {
|
||||
// For intraday, pass full ISO start to be precise
|
||||
barsOptions.start = startDate.toISOString();
|
||||
}
|
||||
|
||||
const barsArray = await fetchBars(ticker, timeframe, barsOptions, mode as any);
|
||||
const barsArray = await alpacaService.fetchBars(ticker, alpacaTimeframe, barsOptions);
|
||||
|
||||
// Transform to chart format
|
||||
const transformedBars = barsArray.map((bar: any) => {
|
||||
@@ -59,18 +76,31 @@ export async function loader({ request, params }: { request: Request; params: {
|
||||
const high = typeof bar.HighPrice === 'number' ? bar.HighPrice : (typeof bar.h === 'number' ? bar.h : 0);
|
||||
const low = typeof bar.LowPrice === 'number' ? bar.LowPrice : (typeof bar.l === 'number' ? bar.l : 0);
|
||||
const close = typeof bar.ClosePrice === 'number' ? bar.ClosePrice : (typeof bar.c === 'number' ? bar.c : 0);
|
||||
const timestamp = bar.Timestamp ?? bar.t;
|
||||
const volume = typeof bar.Volume === 'number' ? bar.Volume : (typeof bar.v === 'number' ? bar.v : 0);
|
||||
|
||||
|
||||
// Normalize timestamp to ISO string so client can parse reliably
|
||||
const rawTs = bar.Timestamp ?? bar.t ?? bar.T ?? bar.timestamp;
|
||||
let dateObj: Date | null = null;
|
||||
if (rawTs != null) {
|
||||
if (typeof rawTs === 'number') {
|
||||
// If it's likely in seconds (< 1e12) convert to ms
|
||||
const asMs = rawTs > 1e12 ? rawTs : rawTs * 1000;
|
||||
dateObj = new Date(asMs);
|
||||
} else {
|
||||
dateObj = new Date(rawTs);
|
||||
}
|
||||
}
|
||||
const iso = dateObj && !isNaN(dateObj.getTime()) ? dateObj.toISOString() : null;
|
||||
|
||||
return {
|
||||
t: timestamp,
|
||||
t: iso,
|
||||
o: open,
|
||||
h: high,
|
||||
l: low,
|
||||
c: close,
|
||||
v: volume,
|
||||
};
|
||||
}).filter((bar: any) => bar.o > 0 && bar.h > 0 && bar.l > 0 && bar.c > 0);
|
||||
}).filter((bar: any) => bar.o > 0 && bar.h > 0 && bar.l > 0 && bar.c > 0 && bar.t);
|
||||
|
||||
return Response.json({
|
||||
ticker,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* TRADINGGRAPH related file */
|
||||
|
||||
// Server-only imports are loaded dynamically inside the action to avoid client bundling issues
|
||||
|
||||
export async function action({ request }: { request: Request }) {
|
||||
@@ -18,7 +20,7 @@ export async function action({ request }: { request: Request }) {
|
||||
const { OpenRouterClient } = await import("../../lib/openrouter");
|
||||
const { TradingGraph } = await import("../../agents/tradingGraph");
|
||||
const { db } = await import("../../lib/db.server");
|
||||
const { fetchAccount, fetchRecentCloses } = await import("../../lib/alpacaClient");
|
||||
const { fetchAccount, fetchRecentCloses, fetchBars } = await import("../../lib/alpacaClient");
|
||||
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
console.log("[analyze] API key configured:", !!apiKey, apiKey?.substring(0, 10) + "...");
|
||||
@@ -63,9 +65,20 @@ export async function action({ request }: { request: Request }) {
|
||||
// Fetch latest Alpaca account and recent prices; abort if unavailable
|
||||
let account: any = undefined;
|
||||
let prices: number[] = [];
|
||||
let recentBars: any[] = [];
|
||||
try {
|
||||
account = await fetchAccount();
|
||||
prices = await fetchRecentCloses(ticker);
|
||||
// Also fetch recent intraday bars to enable deterministic execution plan calculation
|
||||
try {
|
||||
recentBars = await fetchBars(ticker, '1Min', { limit: 200 });
|
||||
// derive prices from bars if available (prefer freshest closes)
|
||||
if (recentBars && recentBars.length) {
|
||||
prices = recentBars.map((b: any) => (typeof b.ClosePrice === 'number' ? b.ClosePrice : (typeof b.c === 'number' ? b.c : 0))).filter((p: number) => p > 0);
|
||||
}
|
||||
} catch (barErr) {
|
||||
console.warn('[analyze] Failed to fetch recent bars for deterministic execution plan:', barErr);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[analyze] Failed to fetch Alpaca data before analysis:", e);
|
||||
return Response.json({ error: "Failed to fetch Alpaca data: " + String(e) }, { status: 502 });
|
||||
@@ -75,6 +88,7 @@ export async function action({ request }: { request: Request }) {
|
||||
financialData: `Financial data for ${ticker} as of ${date}`,
|
||||
technicalData: {
|
||||
prices,
|
||||
bars: recentBars,
|
||||
sma: 0,
|
||||
ema: 0,
|
||||
rsi: 0,
|
||||
@@ -119,11 +133,17 @@ export async function action({ request }: { request: Request }) {
|
||||
console.warn("Failed to enrich execution plan:", e);
|
||||
}
|
||||
|
||||
console.log("[analyze] Decision received:", JSON.stringify(decision));
|
||||
// Avoid logging potentially verbose debate rounds to server CLI
|
||||
try {
|
||||
const { debateRounds, ...decisionSafe } = decision as any;
|
||||
console.log("[analyze] Decision received (debate redacted):", JSON.stringify(decisionSafe));
|
||||
} catch (e) {
|
||||
console.log("[analyze] Decision received");
|
||||
}
|
||||
return Response.json(decision);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
console.error("[analyze] Error:", error);
|
||||
return Response.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export async function loader(){
|
||||
return Response.json({ ok: true, msg: "price-stream-test" });
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
import { fetchLatestBar } from "../../lib/alpacaClient";
|
||||
import alpacaService from "../../lib/alpacaClient";
|
||||
|
||||
export async function loader({ request }: { request: Request }) {
|
||||
const url = new URL(request.url);
|
||||
const ticker = (url.searchParams.get("ticker") || "").toUpperCase();
|
||||
if (!ticker) return new Response("ticker required", { status: 400 });
|
||||
const timeframe = url.searchParams.get("timeframe") || "1Min"; // default to 1Min bars for live price
|
||||
const mode = url.searchParams.get("mode") === "live" ? 'live' : 'paper';
|
||||
function mapToAlpacaTimeframe(tf: string) {
|
||||
switch (tf) {
|
||||
case "1H": return "1Hour";
|
||||
case "1D": return "1Day";
|
||||
default: return tf;
|
||||
}
|
||||
}
|
||||
const alpacaTimeframe = mapToAlpacaTimeframe(timeframe);
|
||||
|
||||
const headers = new Headers({
|
||||
"Content-Type": "text/event-stream",
|
||||
@@ -13,9 +20,8 @@ export async function loader({ request }: { request: Request }) {
|
||||
Connection: "keep-alive",
|
||||
});
|
||||
|
||||
// Create a ReadableStream that polls latest bar every second and pushes SSE
|
||||
// Create a ReadableStream that polls latest bar with adaptive backoff and SSE
|
||||
let closed = false;
|
||||
let interval: any;
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
// helper to push SSE event
|
||||
@@ -31,28 +37,54 @@ export async function loader({ request }: { request: Request }) {
|
||||
// initial ping
|
||||
pushEvent({ event: "connected", ticker, timeframe });
|
||||
|
||||
interval = setInterval(async () => {
|
||||
const baseDelay = 5000; // start with 5s between Alpaca calls
|
||||
const maxDelay = 60000; // cap backoff at 60s
|
||||
let delay = baseDelay;
|
||||
let lastBarId: string | number | null = null;
|
||||
|
||||
async function poll() {
|
||||
if (closed) return;
|
||||
try {
|
||||
const last = await fetchLatestBar(ticker, timeframe, mode);
|
||||
const last = await alpacaService.fetchLatestBar(ticker, alpacaTimeframe);
|
||||
const price = last ? (last.ClosePrice ?? last.c ?? null) : null;
|
||||
|
||||
// create a dedupe id from available fields
|
||||
const barId = last ? (last.T ?? last.t ?? last.Timestamp ?? last.ClosePrice ?? last.c ?? null) : null;
|
||||
|
||||
if (price != null) {
|
||||
pushEvent({ price, ts: Date.now(), timeframe });
|
||||
if (barId == null || barId !== lastBarId) {
|
||||
lastBarId = barId;
|
||||
pushEvent({ price, ts: Date.now(), timeframe });
|
||||
}
|
||||
} else {
|
||||
pushEvent({ error: "no_bar", ts: Date.now() });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("price-stream: error fetching latest bar", err);
|
||||
pushEvent({ error: String(err), ts: Date.now() });
|
||||
// keep trying
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// no-op here; cleanup handled in cancel
|
||||
// on success, reset backoff
|
||||
delay = baseDelay;
|
||||
} catch (err: any) {
|
||||
const msg = String(err?.message ?? err ?? "error");
|
||||
console.error("price-stream: error fetching latest bar", msg);
|
||||
pushEvent({ error: msg, ts: Date.now() });
|
||||
|
||||
// apply exponential backoff on rate limit errors
|
||||
if (/429|too many requests/i.test(msg)) {
|
||||
delay = Math.min(delay * 2, maxDelay);
|
||||
console.warn(`price-stream: rate limited, backing off to ${delay}ms`);
|
||||
} else {
|
||||
// mild backoff for other errors
|
||||
delay = Math.min(Math.floor(delay * 1.5), maxDelay);
|
||||
}
|
||||
}
|
||||
|
||||
if (!closed) setTimeout(poll, delay);
|
||||
}
|
||||
|
||||
// start polling immediately
|
||||
setTimeout(poll, 0);
|
||||
},
|
||||
cancel() {
|
||||
closed = true;
|
||||
clearInterval(interval);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* TRADINGGRAPH related file */
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLoaderData } from "react-router";
|
||||
import Navbar from "../../components/Navbar";
|
||||
@@ -84,14 +86,88 @@ export default function JobDetail() {
|
||||
<a href={`/api/jobs/${job?.id}`} target="_blank" rel="noreferrer" className="text-sm text-blue-600 hover:underline">Open API</a>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-700 mb-3">Data:</div>
|
||||
<div className="text-sm text-gray-700 mb-3">Raw Data:</div>
|
||||
<pre className="bg-gray-50 p-3 rounded text-xs overflow-x-auto text-gray-800">{JSON.stringify(job?.data || job?.returnValue || job, null, 2)}</pre>
|
||||
|
||||
<div className="mt-4">
|
||||
|
||||
</div>
|
||||
{/* TradingGraph structured output */}
|
||||
{job?.returnValue && (
|
||||
<div className="mt-4 bg-white rounded-lg p-4 border border-gray-200 text-gray-900">
|
||||
<h4 className="text-lg font-semibold mb-3">TradingGraph Result</h4>
|
||||
|
||||
{/* Decision summary */}
|
||||
{job.returnValue.action && (
|
||||
<div className="mb-3">
|
||||
<div className="text-sm text-gray-600">Decision:</div>
|
||||
<div className="text-base font-medium">{String(job.returnValue.action).toUpperCase()} <span className="text-sm text-gray-500">(confidence: {Number(job.returnValue.confidence ?? 0).toFixed(2)})</span></div>
|
||||
{job.returnValue.reasoning && <div className="mt-2 text-sm text-gray-700">{job.returnValue.reasoning}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent signals / analyst reports */}
|
||||
{Array.isArray(job.returnValue.agentSignals) && job.returnValue.agentSignals.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="text-sm text-gray-600">Analyst Reports</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{job.returnValue.agentSignals.map((s: any, i: number) => (
|
||||
<div key={i} className="p-3 bg-gray-50 rounded border border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium capitalize">{s.agent}</div>
|
||||
<div className="text-sm text-gray-500">{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>
|
||||
)}
|
||||
|
||||
{/* Debate rounds (if present) */}
|
||||
{Array.isArray(job.returnValue.debateRounds) && job.returnValue.debateRounds.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="text-sm text-gray-600">Debate Rounds</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{job.returnValue.debateRounds.map((d: any, i: number) => (
|
||||
<div key={i} className="p-3 bg-gray-50 rounded border border-gray-100">
|
||||
<div className="text-sm font-medium">Researcher: {d.researcher ?? 'unknown'}</div>
|
||||
{d.bullishView && <div className="mt-1 text-sm text-green-600">Bullish: {d.bullishView}</div>}
|
||||
{d.bearishView && <div className="mt-1 text-sm text-red-600">Bearish: {d.bearishView}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Execution plan */}
|
||||
{job.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">
|
||||
{job.returnValue.executionPlan.amount != null && (<div>Amount: <strong>{job.returnValue.executionPlan.amount}</strong></div>)}
|
||||
{job.returnValue.executionPlan.takeProfit != null && (<div>Take profit: <strong>${job.returnValue.executionPlan.takeProfit}</strong></div>)}
|
||||
{job.returnValue.executionPlan.stopLoss != null && (<div>Stop loss: <strong>${job.returnValue.executionPlan.stopLoss}</strong></div>)}
|
||||
{job.returnValue.executionPlan.riskManagement?.maxLossPercent != null && (<div>Risk: <strong>{job.returnValue.executionPlan.riskManagement.maxLossPercent}%</strong></div>)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* LLM review if available */}
|
||||
{job.returnValue.executionPlan?._llmReview && (
|
||||
<div className="mt-4 bg-white rounded-lg p-3 border border-gray-200 text-gray-900">
|
||||
<h4 className="text-sm font-medium">LLM Review</h4>
|
||||
<div className="mt-2 text-sm text-gray-700">
|
||||
<div>Approved: <strong className={job.returnValue.executionPlan._llmReview.approved ? 'text-green-600' : 'text-red-600'}>{job.returnValue.executionPlan._llmReview.approved ? 'Yes' : 'No'}</strong></div>
|
||||
{job.returnValue.executionPlan._llmReview.notes && (
|
||||
<div className="mt-1">Notes: <span className="font-medium text-gray-800">{job.returnValue.executionPlan._llmReview.notes}</span></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,4 @@
|
||||
import type { LoaderFunction } from '@remix-run/node';
|
||||
import { json } from '@remix-run/node';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { requireAdmin } from '~/lib/auth.server';
|
||||
import { settingsService } from '~/lib/settings.server';
|
||||
|
||||
export const loader: LoaderFunction = async ({ request }) => {
|
||||
await requireAdmin(request);
|
||||
await settingsService.init?.();
|
||||
const entries: any[] = [];
|
||||
// @ts-ignore
|
||||
for (const key of (settingsService as any).cache.keys()) {
|
||||
entries.push({ key, value: await settingsService.get(key) });
|
||||
}
|
||||
return json({ entries });
|
||||
};
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [items, setItems] = useState<Array<{ key: string; value: any }>>([]);
|
||||
|
||||
Reference in New Issue
Block a user