diff --git a/.gitignore b/.gitignore index 8abc591..90577fa 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ /generated/prisma /prisma/dev.db +/graphify-out diff --git a/app/agents/__tests__/trader.executionPlan.test.ts b/app/agents/__tests__/trader.executionPlan.test.ts new file mode 100644 index 0000000..e52a8d0 --- /dev/null +++ b/app/agents/__tests__/trader.executionPlan.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi } from "vitest"; +import { Trader } from "../trader"; +import type { AnalystReport, DebateRound } from "../../types/agents"; + +const mockReports: AnalystReport[] = [ + { + analyst: "fundamentals", + report: "Strong earnings growth", + signal: { + agent: "fundamentals", + signal: "bullish", + confidence: 0.8, + reasoning: "Revenue up 20%", + timestamp: "2024-01-01", + }, + }, +]; + +const mockDebates: DebateRound[] = [ + { + bullishView: "Strong fundamentals", + bearishView: "Market volatility", + researcher: "bullish", + }, +]; + +describe("Trader executionPlan parsing", () => { + it("includes executionPlan for buy decisions", async () => { + const mockBuyClient = { + createChatCompletion: vi.fn().mockResolvedValue({ + choices: [{ message: { content: JSON.stringify({ + action: "buy", + confidence: 0.8, + reasoning: "Enter position", + executionPlan: { amount: 10, stopLoss: 95, riskManagement: { maxLossPercent: 1 }, takeProfit: 110 } + }) } }] + }), + }; + + const trader = new Trader(mockBuyClient as any); + const decision = await trader.decide("AAPL", mockReports, mockDebates); + + expect(decision.action).toBe("buy"); + expect(decision.executionPlan).toBeDefined(); + expect(decision.executionPlan?.amount).toBe(10); + expect(decision.executionPlan?.stopLoss).toBe(95); + }); + + it("parses stopLoss from malformed executionPlan text (fallback)", async () => { + const malformed = 'Model reply: "action": "sell", "executionPlan": { amount: 7, takeProfit: 120, stopLoss: 115, riskManagement: { maxLossPercent: 2 } } and commentary.'; + const mockMalformedClient = { + createChatCompletion: vi.fn().mockResolvedValue({ + choices: [{ message: { content: malformed } }], + }), + }; + + const trader = new Trader(mockMalformedClient as any); + const decision = await trader.decide("AAPL", mockReports, mockDebates); + + // action may be unspecified in this malformed reply; ensure executionPlan fields parsed when present + expect(decision.executionPlan).toBeDefined(); + expect(decision.executionPlan?.amount).toBe(7); + expect(decision.executionPlan?.stopLoss).toBe(115); + expect(decision.executionPlan?.takeProfit).toBe(120); + expect(decision.executionPlan?.riskManagement?.maxLossPercent).toBe(2); + }); +}); \ No newline at end of file diff --git a/app/agents/__tests__/tradingGraph.execution.test.ts b/app/agents/__tests__/tradingGraph.execution.test.ts index 583730b..e035cab 100644 --- a/app/agents/__tests__/tradingGraph.execution.test.ts +++ b/app/agents/__tests__/tradingGraph.execution.test.ts @@ -1,3 +1,5 @@ +/* TRADINGGRAPH related file */ + import { describe, it, expect, vi } from "vitest"; import { TradingGraph } from "../tradingGraph"; @@ -28,3 +30,4 @@ describe("TradingGraph execution step", () => { expect(decision.executionPlan?.amount).toBe(100); }); }); + diff --git a/app/agents/__tests__/tradingGraph.test.ts b/app/agents/__tests__/tradingGraph.test.ts index 3f294fb..ac1a89f 100644 --- a/app/agents/__tests__/tradingGraph.test.ts +++ b/app/agents/__tests__/tradingGraph.test.ts @@ -1,3 +1,5 @@ +/* TRADINGGRAPH related file */ + import { describe, it, expect, vi } from "vitest"; import { TradingGraph } from "../tradingGraph"; @@ -29,4 +31,4 @@ describe("TradingGraph", () => { expect(decision).toHaveProperty("action"); expect(decision).toHaveProperty("confidence"); }); -}); \ No newline at end of file +}); diff --git a/app/agents/trader.ts b/app/agents/trader.ts index d690521..5de7937 100644 --- a/app/agents/trader.ts +++ b/app/agents/trader.ts @@ -42,16 +42,17 @@ Based on all the information above, make a trading decision. Respond with JSON c - confidence: a number between 0 and 1 - reasoning: brief explanation -If the action is "sell", also include an "executionPlan" object with: -- amount: number (shares to sell) +If the action is "buy" or "sell", also include an "executionPlan" object with: +- amount: number (shares to trade) - riskManagement: object (e.g., { maxLossPercent: 2 }) - takeProfit: number (target take-profit price) +- stopLoss: number (stop-loss price or absolute value) Format your response as JSON with these fields.`; const response = await this.client.createChatCompletion( [ - { role: "system", content: "You are a trading agent that makes buy/sell/hold decisions and provides execution guidance when selling." }, + { role: "system", content: "You are a trading agent that makes buy/sell/hold decisions and provides execution guidance for buy and sell actions." }, { role: "user", content: prompt }, ], this.model @@ -86,13 +87,15 @@ Format your response as JSON with these fields.`; executionPlan = JSON.parse(execMatch[1]); } catch (err) { // fallback: try to extract primitive fields - const amountMatch = content.match(/"amount"\s*:\s*([0-9.]+)/); - const takeProfitMatch = content.match(/"takeProfit"\s*:\s*([0-9.]+)/); - const maxLossMatch = content.match(/"maxLossPercent"\s*:\s*([0-9.]+)/); - const methodMatch = content.match(/"method"\s*:\s*"([^"]+)"/); + const amountMatch = content.match(/(?:"amount"|\bamount\b)\s*:\s*([0-9.]+)/); + const takeProfitMatch = content.match(/(?:"takeProfit"|\btakeProfit\b)\s*:\s*([0-9.]+)/); + const stopLossMatch = content.match(/(?:"stopLoss"|\bstopLoss\b)\s*:\s*([0-9.]+)/); + const maxLossMatch = content.match(/(?:"maxLossPercent"|\bmaxLossPercent\b)\s*:\s*([0-9.]+)/); + const methodMatch = content.match(/(?:"method"|\bmethod\b)\s*:\s*"([^"]+)"/); executionPlan = {} as any; if (amountMatch) executionPlan.amount = parseFloat(amountMatch[1]); if (takeProfitMatch) executionPlan.takeProfit = parseFloat(takeProfitMatch[1]); + if (stopLossMatch) executionPlan.stopLoss = parseFloat(stopLossMatch[1]); executionPlan.riskManagement = {}; if (maxLossMatch) executionPlan.riskManagement.maxLossPercent = parseFloat(maxLossMatch[1]); if (methodMatch) executionPlan.riskManagement.method = methodMatch[1]; @@ -101,8 +104,8 @@ Format your response as JSON with these fields.`; // Additional fallback: if executionPlan parsed but missing nested riskManagement fields, try to extract them if (executionPlan && executionPlan.riskManagement == null) { - const maxLossMatch2 = content.match(/"maxLossPercent"\s*:\s*([0-9.]+)/); - const methodMatch2 = content.match(/"method"\s*:\s*"([^"]+)"/); + const maxLossMatch2 = content.match(/(?:"maxLossPercent"|\bmaxLossPercent\b)\s*:\s*([0-9.]+)/); + const methodMatch2 = content.match(/(?:"method"|\bmethod\b)\s*:\s*"([^"]+)"/); if (maxLossMatch2 || methodMatch2) { executionPlan.riskManagement = executionPlan.riskManagement || {}; if (maxLossMatch2) executionPlan.riskManagement.maxLossPercent = parseFloat(maxLossMatch2[1]); @@ -118,7 +121,7 @@ Format your response as JSON with these fields.`; debateRounds: debates, }; - if (action === 'sell' && executionPlan) { + if ((action === 'sell' || action === 'buy') && executionPlan) { decision.executionPlan = executionPlan; } diff --git a/app/agents/tradingGraph.ts b/app/agents/tradingGraph.ts index 24d41b7..acf4401 100644 --- a/app/agents/tradingGraph.ts +++ b/app/agents/tradingGraph.ts @@ -1,3 +1,5 @@ +/* TRADINGGRAPH related file */ + import { OpenRouterClient } from "../lib/openrouter"; import { FundamentalsAnalyst } from "./fundamentals"; import { TechnicalAnalyst } from "./technical"; @@ -41,14 +43,14 @@ export class TradingGraph { sentimentData: { headlines: string[]; source?: "news" | "social" | "stocktwits" }; } ): Promise { - console.log(`[TradingGraph] Starting analysis for ${ticker} with model ${this.model}`); + const reports = await this.runAnalysts(ticker, input); const debates = await this.runDebate(ticker, reports); const decision = await this.trader.decide(ticker, reports, debates); - console.log(`[TradingGraph] Analysis complete for ${ticker}`); - console.log(`[TradingGraph] Decision: ${decision.action} (confidence: ${decision.confidence})`); + + // Build workflow steps for observability. Include an execution step when selling. const steps: GraphStep[] = [ @@ -57,13 +59,13 @@ export class TradingGraph { { step: "trader", data: decision }, ]; - if (decision.action === 'sell' && decision.executionPlan) { + if (decision.executionPlan) { steps.push({ step: "execution", data: decision.executionPlan }); - console.log(`[TradingGraph] Execution plan: ${JSON.stringify(decision.executionPlan)}`); + } // Log steps for debugging; external systems can be extended to consume GraphStep sequence. - console.log(`[TradingGraph] Workflow steps: ${JSON.stringify(steps)}`); + return decision; } @@ -76,7 +78,7 @@ export class TradingGraph { sentimentData: { headlines: string[]; source?: "news" | "social" | "stocktwits" }; } ): Promise { - console.log(`[TradingGraph] Running analysts for ${ticker}...`); + const [fundamentals, technical, sentiment] = await Promise.all([ this.fundamentalsAnalyst.analyze(ticker, input.financialData), @@ -84,24 +86,20 @@ export class TradingGraph { this.sentimentAnalyst.analyze(ticker, input.sentimentData), ]); - console.log(`[TradingGraph] Analyst reports complete:`, { - fundamentals: fundamentals.signal, - technical: technical.signal, - sentiment: sentiment.signal, - }); + return [fundamentals, technical, sentiment]; } private async runDebate(ticker: string, reports: AnalystReport[]): Promise { - console.log(`[TradingGraph] Running debate for ${ticker}...`); + const [bullish, bearish] = await Promise.all([ this.bullishResearcher.research(ticker, reports), this.bearishResearcher.research(ticker, reports), ]); - console.log(`[TradingGraph] Debate complete`); + return [ { @@ -111,4 +109,4 @@ export class TradingGraph { }, ]; } -} \ No newline at end of file +} diff --git a/app/components/TradingViewChart.tsx b/app/components/TradingViewChart.tsx index 7e4aba8..b8909bf 100644 --- a/app/components/TradingViewChart.tsx +++ b/app/components/TradingViewChart.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import * as LightweightCharts from "lightweight-charts"; type ChartTime = string | number; @@ -15,6 +15,9 @@ interface TradingViewChartProps { ticker: string; data?: ChartDataPoint[]; timeframe?: string; + currentPrice?: number; + // priceStream.subscribe(cb) should return an unsubscribe function + priceStream?: { subscribe: (cb: (price: number) => void) => () => void }; } const TIMEFRAME_HEIGHTS: Record = { @@ -25,8 +28,9 @@ const TIMEFRAME_HEIGHTS: Record = { "1W": 400, }; -export default function TradingViewChart({ ticker, data, timeframe = "1D" }: TradingViewChartProps) { +export default function TradingViewChart({ ticker, data, timeframe = "1D", currentPrice, priceStream }: TradingViewChartProps) { const containerRef = useRef(null); + const [livePrice, setLivePrice] = useState(undefined); const height = TIMEFRAME_HEIGHTS[timeframe] ?? 400; const isIntraday = ["1Min", "5Min", "15Min", "30Min", "1H"].includes(timeframe); @@ -69,9 +73,38 @@ export default function TradingViewChart({ ticker, data, timeframe = "1D" }: Tra return () => chart.remove(); }, [data, ticker, isIntraday, timeframe]); + // Subscribe to a streaming price if provided + useEffect(() => { + if (!priceStream) return; + let unsub: (() => void) | void = undefined; + try { + unsub = priceStream.subscribe((p: number) => { + setLivePrice(p); + }); + } catch (e) { + console.warn("TradingViewChart: priceStream subscribe failed", e); + } + return () => { + try { + if (typeof unsub === "function") unsub(); + } catch (e) { + /* ignore */ + } + }; + }, [priceStream]); + + const derivedPrice = currentPrice ?? livePrice ?? (data && data.length ? data[data.length - 1].close : undefined); + return (
-

{ticker} Price Chart

+
+

{ticker} Price Chart

+ {typeof derivedPrice === "number" ? ( +
+ ${derivedPrice.toFixed(2)} +
+ ) : null} +
); diff --git a/app/components/__tests__/TradingViewChart.test.tsx b/app/components/__tests__/TradingViewChart.test.tsx index 6cadf0b..d8a4895 100644 --- a/app/components/__tests__/TradingViewChart.test.tsx +++ b/app/components/__tests__/TradingViewChart.test.tsx @@ -71,6 +71,43 @@ describe("TradingViewChart", () => { ); }); + it("shows current price when provided via prop", () => { + render(); + expect(screen.getByTestId("current-price")).toHaveTextContent("$123.46"); + }); + + it("derives current price from last data point when currentPrice prop missing", () => { + const data = [ + { time: "2024-01-01", open: 100, high: 110, low: 95, close: 105 }, + { time: "2024-01-02", open: 105, high: 115, low: 100, close: 110 }, + ]; + render(); + expect(screen.getByTestId("current-price")).toHaveTextContent("$110.00"); + }); + + it("updates when price stream emits", async () => { + // create a simple priceStream that stores callback + let cb: ((p: number) => void) | undefined; + const unsubscribe = vi.fn(); + const priceStream = { + subscribe: (c: (p: number) => void) => { + cb = c; + return unsubscribe; + }, + } as any; + + render(); + expect(screen.queryByTestId("current-price")).toBeNull(); + + // emit a price + if (cb) cb(200); + + // wait a tick for state update + await new Promise((r) => setTimeout(r, 0)); + + expect(screen.getByTestId("current-price")).toHaveTextContent("$200.00"); + }); + it("creates candlestick series with explicit colors", () => { const mockAddSeries = vi.fn(); mockCreateChart.mockReturnValue({ diff --git a/app/lib/__tests__/execution.alpaca.test.ts b/app/lib/__tests__/execution.alpaca.test.ts new file mode 100644 index 0000000..8789b49 --- /dev/null +++ b/app/lib/__tests__/execution.alpaca.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { enrichExecutionPlan } from "../execution"; + +describe("enrichExecutionPlan with Alpaca account data", () => { + beforeEach(() => { + process.env.DEFAULT_ACCOUNT_EQUITY = "10000"; + }); + afterEach(() => { + delete process.env.DEFAULT_ACCOUNT_EQUITY; + }); + + it("uses input.account.cash for sizing when provided", () => { + const decision: any = { action: "buy" }; + const input = { technicalData: { prices: [50, 52, 51] }, account: { cash: 5000 } }; + + const out = enrichExecutionPlan(decision, input); + + // entryPrice = 51, ATR ~ 1.5 -> stopDistance = 1.5*1.5 = 2.25 + // riskAmount = 5000 * 0.01 = 50 -> amount = floor(50 / 2.25) = 22 + expect(out.executionPlan.amount).toBe(22); + }); +}); \ No newline at end of file diff --git a/app/lib/__tests__/execution.edgecases.test.ts b/app/lib/__tests__/execution.edgecases.test.ts new file mode 100644 index 0000000..5508135 --- /dev/null +++ b/app/lib/__tests__/execution.edgecases.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { enrichExecutionPlan } from "../execution"; + +describe("enrichExecutionPlan edge cases", () => { + beforeEach(() => { + process.env.DEFAULT_ACCOUNT_EQUITY = "10000"; + }); + afterEach(() => { + delete process.env.DEFAULT_ACCOUNT_EQUITY; + }); + + it("handles very small/zero ATR (flat prices) without crashing and uses percent fallback", () => { + const decision: any = { action: "buy" }; + const input = { technicalData: { prices: [100, 100, 100] } }; + + const out = enrichExecutionPlan(decision, input); + + expect(out.executionPlan).toBeDefined(); + // ATR ~ 0, so stopDistance should fall back to percent-based (1% of entry = 1) + expect(out.executionPlan.stopLoss).toBeCloseTo(99, 2); + // entry 100 + rr*stopDistance (2*1) => 102 + expect(out.executionPlan.takeProfit).toBeCloseTo(102, 2); + expect(out.executionPlan.amount).toBeGreaterThanOrEqual(1); + }); + + it("honors percent-based riskManagement from LLM (0.5%) and computes amount accordingly", () => { + const decision: any = { action: "buy", executionPlan: { riskManagement: { maxLossPercent: 0.5 } } }; + const input = { technicalData: { prices: [200, 202] } }; + + const out = enrichExecutionPlan(decision, input); + + expect(out.executionPlan).toBeDefined(); + expect(out.executionPlan.riskManagement).toBeDefined(); + expect(out.executionPlan.riskManagement.maxLossPercent).toBeCloseTo(0.5, 6); + + // entryPrice = 202, rr/default: atr ~2, stopDistance = max(2*1.5=3, 202*0.005=1.01) => 3 + // riskAmount = 10000 * 0.005 = 50 -> shares = floor(50/3) = 16 + expect(out.executionPlan.amount).toBe(16); + }); + + it("handles missing price data by producing a finite amount and no absolute stops", () => { + const decision: any = { action: "buy" }; + const input = { technicalData: { prices: [] } }; + + const out = enrichExecutionPlan(decision, input); + + expect(out.executionPlan).toBeDefined(); + // No entry price -> cannot compute absolute stopLoss/takeProfit + expect(out.executionPlan.stopLoss).toBeUndefined(); + expect(out.executionPlan.takeProfit).toBeUndefined(); + // Amount should still be computed (uses small fallback stopDistance 0.0001) -> large but finite + expect(typeof out.executionPlan.amount).toBe("number"); + expect(Number.isFinite(out.executionPlan.amount)).toBe(true); + expect(out.executionPlan.amount).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/app/lib/__tests__/execution.test.ts b/app/lib/__tests__/execution.test.ts new file mode 100644 index 0000000..229c1c7 --- /dev/null +++ b/app/lib/__tests__/execution.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { enrichExecutionPlan } from "../execution"; + +describe("enrichExecutionPlan", () => { + beforeEach(() => { + process.env.DEFAULT_ACCOUNT_EQUITY = "10000"; + }); + afterEach(() => { + delete process.env.DEFAULT_ACCOUNT_EQUITY; + }); + + it("computes stopLoss/takeProfit/amount for buy decision", () => { + const decision: any = { action: "buy" }; + const input = { technicalData: { prices: [100, 102, 101] } }; + + const out = enrichExecutionPlan(decision, input); + + expect(out.executionPlan).toBeDefined(); + // ATR approx = 1.5 -> stopDistance = 1.5*1.5 = 2.25 + // stopLoss = 101 - 2.25 = 98.75 + // takeProfit = 101 + 2.25*2 = 105.5 + // riskAmount = 10000 * 0.01 = 100 -> amount = floor(100 / 2.25) = 44 + expect(out.executionPlan.amount).toBe(44); + expect(out.executionPlan.stopLoss).toBeCloseTo(98.75, 2); + expect(out.executionPlan.takeProfit).toBeCloseTo(105.5, 2); + }); + + it("computes stopLoss/takeProfit for sell decision (stop above entry)", () => { + const decision: any = { action: "sell" }; + const input = { technicalData: { prices: [100, 102, 101] } }; + + const out = enrichExecutionPlan(decision, input); + + // entryPrice = 101, stopDistance = 2.25 + // stopLoss = 101 + 2.25 = 103.25 + // takeProfit = 101 - 2.25*2 = 96.5 + expect(out.executionPlan).toBeDefined(); + expect(out.executionPlan.stopLoss).toBeCloseTo(103.25, 2); + expect(out.executionPlan.takeProfit).toBeCloseTo(96.5, 2); + expect(out.executionPlan.amount).toBeGreaterThanOrEqual(1); + }); + + it("preserves existing executionPlan fields and normalizes riskManagement", () => { + const decision: any = { action: "buy", executionPlan: { amount: 10, stopLoss: 90, takeProfit: 110 } }; + const input = { technicalData: { prices: [100, 101] } }; + + const out = enrichExecutionPlan(decision, input); + + expect(out.executionPlan.amount).toBe(10); + expect(out.executionPlan.stopLoss).toBe(90); + expect(out.executionPlan.takeProfit).toBe(110); + expect(out.executionPlan.riskManagement).toBeDefined(); + expect(typeof out.executionPlan.riskManagement.maxLossPercent).toBe("number"); + }); +}); diff --git a/app/lib/alpacaClient.ts b/app/lib/alpacaClient.ts index 0cbfcb0..f7525b2 100644 --- a/app/lib/alpacaClient.ts +++ b/app/lib/alpacaClient.ts @@ -1,6 +1,8 @@ import Alpaca from "@alpacahq/alpaca-trade-api"; -function makeAlpaca(mode: 'paper' | 'live' = 'paper') { +type Mode = 'paper' | 'live'; + +function makeAlpaca(mode: Mode = 'paper') { const isLive = mode === 'live'; const keyId = isLive ? (process.env.ALPACA_API_KEY_LIVE || process.env.ALPACA_API_KEY) : process.env.ALPACA_API_KEY; const secretKey = isLive ? (process.env.ALPACA_SECRET_KEY_LIVE || process.env.ALPACA_SECRET_KEY) : process.env.ALPACA_SECRET_KEY; @@ -15,71 +17,161 @@ function makeAlpaca(mode: 'paper' | 'live' = 'paper') { }); } -export async function fetchAccount(mode: 'paper' | 'live' = 'paper') { - try { - const client = makeAlpaca(mode); - const account = await client.getAccount(); - return { - cash: parseFloat(account.cash), - buying_power: parseFloat(account.buying_power), - portfolio_value: parseFloat(account.portfolio_value), - }; - } catch (err: any) { - console.error("alpacaClient: fetchAccount failed:", err); - throw new Error(err?.message || String(err)); +class AlpacaService { + private mode: Mode; + private client: any; + private lastBarCache = new Map(); + + constructor(mode: Mode = 'paper') { + this.mode = mode; + this.client = makeAlpaca(mode); } -} -export async function fetchRecentCloses(ticker: string, days = 30, mode: 'paper' | 'live' = 'paper') { - try { - const client = makeAlpaca(mode); - const startDate = new Date(); - startDate.setDate(startDate.getDate() - days); - const barsIter = await client.getBarsV2(ticker, { timeframe: "1D", start: startDate.toISOString().split("T")[0], limit: 1000 }); - const barsArray: any[] = []; - for await (const b of barsIter) barsArray.push(b); - const closes = barsArray.map((bar: any) => (typeof bar.ClosePrice === 'number' ? bar.ClosePrice : (typeof bar.c === 'number' ? bar.c : 0))).filter((c: number) => c > 0); - if (closes.length) return closes; - - // fallback to latest trade - try { - const trade: any = await client.getLatestTrade(ticker); - const price = trade?.Price || trade?.price || 0; - if (price) return [price]; - } catch (tErr) { - console.warn("alpacaClient: getLatestTrade fallback failed:", tErr); + setMode(mode: Mode) { + if (this.mode !== mode) { + this.mode = mode; + this.client = makeAlpaca(mode); } + } - throw new Error("No recent price data available from Alpaca"); - } catch (err: any) { - console.error("alpacaClient: fetchRecentCloses failed:", err); - throw new Error(err?.message || String(err)); + getMode() { + return this.mode; + } + + async fetchAccount() { + try { + const account = await this.client.getAccount(); + return { + cash: parseFloat(account.cash), + buying_power: parseFloat(account.buying_power), + portfolio_value: parseFloat(account.portfolio_value), + }; + } catch (err: any) { + console.error("AlpacaService: fetchAccount failed:", err); + throw new Error(err?.message || String(err)); + } + } + + async fetchRecentCloses(ticker: string, days = 30) { + try { + const startDate = new Date(); + startDate.setDate(startDate.getDate() - days); + const barsIter = await this.client.getBarsV2(ticker, { timeframe: "1D", start: startDate.toISOString().split("T")[0], limit: 1000 }); + const barsArray: any[] = []; + for await (const b of barsIter) barsArray.push(b); + const closes = barsArray.map((bar: any) => (typeof bar.ClosePrice === 'number' ? bar.ClosePrice : (typeof bar.c === 'number' ? bar.c : 0))).filter((c: number) => c > 0); + if (closes.length) return closes; + + // fallback to latest trade + try { + const trade: any = await this.client.getLatestTrade(ticker); + const price = trade?.Price || trade?.price || 0; + if (price) return [price]; + } catch (tErr) { + console.warn("AlpacaService: getLatestTrade fallback failed:", tErr); + } + + throw new Error("No recent price data available from Alpaca"); + } catch (err: any) { + console.error("AlpacaService: fetchRecentCloses failed:", err); + throw new Error(err?.message || String(err)); + } + } + + async fetchLatestBar(ticker: string, timeframe = '1Min') { + const cacheKey = `${ticker}:${timeframe}`; + const maxRetries = 3; + let attempt = 0; + let baseDelay = 500; // ms + try { + while (attempt < maxRetries) { + try { + const barsIter = await this.client.getBarsV2(ticker, { timeframe, limit: 1 }); + const barsArr: any[] = []; + for await (const b of barsIter) barsArr.push(b); + const last = barsArr[barsArr.length - 1] || null; + if (last) { + this.lastBarCache.set(cacheKey, { bar: last, ts: Date.now() }); + } + return last || (this.lastBarCache.get(cacheKey)?.bar ?? null); + } catch (err: any) { + const msg = err?.message ?? String(err); + // Rate limit -> retry with exponential backoff + if (/429|too many requests/i.test(msg)) { + attempt++; + const backoff = Math.min(baseDelay * Math.pow(2, attempt - 1), 10000); + console.warn(`AlpacaService.fetchLatestBar rate limited, retry ${attempt}/${maxRetries} after ${backoff}ms`); + await new Promise((r) => setTimeout(r, backoff)); + continue; + } + // non-rate-limit error -> rethrow + console.error('AlpacaService: fetchLatestBar failed:', err); + throw new Error(err?.message || String(err)); + } + } + + // exhausted retries, fall back to cache if available + const cached = this.lastBarCache.get(cacheKey); + if (cached) { + console.warn('AlpacaService.fetchLatestBar: returning cached bar after retries'); + return cached.bar; + } + + return null; + } catch (err: any) { + console.error('AlpacaService: fetchLatestBar final error:', err); + throw new Error(err?.message || String(err)); + } + } + + async fetchBars(ticker: string, timeframe = '1D', options: any = {}) { + const maxRetries = 3; + let attempt = 0; + let baseDelay = 500; + try { + while (attempt < maxRetries) { + try { + const barsIter = await this.client.getBarsV2(ticker, { timeframe, ...options }); + const barsArr: any[] = []; + for await (const b of barsIter) barsArr.push(b); + + // update last-bar cache for this ticker/timeframe + if (barsArr.length) { + const cacheKey = `${ticker}:${timeframe}`; + this.lastBarCache.set(cacheKey, { bar: barsArr[barsArr.length - 1], ts: Date.now() }); + } + + return barsArr; + } catch (err: any) { + const msg = err?.message ?? String(err); + if (/429|too many requests/i.test(msg)) { + attempt++; + const backoff = Math.min(baseDelay * Math.pow(2, attempt - 1), 10000); + console.warn(`AlpacaService.fetchBars rate limited, retry ${attempt}/${maxRetries} after ${backoff}ms`); + await new Promise((r) => setTimeout(r, backoff)); + continue; + } + console.error('AlpacaService: fetchBars failed:', err); + throw new Error(err?.message || String(err)); + } + } + + console.warn('AlpacaService.fetchBars: exhausted retries, returning empty array'); + return []; + } catch (err: any) { + console.error('AlpacaService: fetchBars final error:', err); + throw new Error(err?.message || String(err)); + } } } -export async function fetchLatestBar(ticker: string, timeframe = '1Min', mode: 'paper' | 'live' = 'paper') { - try { - const client = makeAlpaca(mode); - const barsIter = await client.getBarsV2(ticker, { timeframe, limit: 1 }); - const barsArr: any[] = []; - for await (const b of barsIter) barsArr.push(b); - const last = barsArr[barsArr.length - 1]; - return last || null; - } catch (err: any) { - console.error('alpacaClient: fetchLatestBar failed:', err); - throw new Error(err?.message || String(err)); - } -} +// Singleton configured to use paper trading API by default +export const alpacaService = new AlpacaService('paper'); -export async function fetchBars(ticker: string, timeframe = '1D', options: any = {}, mode: 'paper' | 'live' = 'paper') { - try { - const client = makeAlpaca(mode); - const barsIter = await client.getBarsV2(ticker, { timeframe, ...options }); - const barsArr: any[] = []; - for await (const b of barsIter) barsArr.push(b); - return barsArr; - } catch (err: any) { - console.error('alpacaClient: fetchBars failed:', err); - throw new Error(err?.message || String(err)); - } -} \ No newline at end of file +// Backwards-compatible named exports (delegate to singleton) +export const fetchAccount = (_mode?: Mode) => alpacaService.fetchAccount(); +export const fetchRecentCloses = (ticker: string, days = 30, _mode?: Mode) => alpacaService.fetchRecentCloses(ticker, days); +export const fetchLatestBar = (ticker: string, timeframe = '1Min', _mode?: Mode) => alpacaService.fetchLatestBar(ticker, timeframe); +export const fetchBars = (ticker: string, timeframe = '1D', options: any = {}, _mode?: Mode) => alpacaService.fetchBars(ticker, timeframe, options); + +export default alpacaService; \ No newline at end of file diff --git a/app/lib/execution.ts b/app/lib/execution.ts index 911e095..ca416fa 100644 --- a/app/lib/execution.ts +++ b/app/lib/execution.ts @@ -5,9 +5,18 @@ export function enrichExecutionPlan(decision: TradingDecision, input: any): Trad const prices: number[] = input?.technicalData?.prices || []; const entryPrice = prices.length ? prices[prices.length - 1] : undefined; - // simple ATR approximation: average absolute diff + // ATR approximation: prefer bar-based ATR (high-low average), fall back to price diffs let atr = 0; - if (prices && prices.length >= 2) { + const bars: any[] = input?.technicalData?.bars || []; + if (bars && bars.length >= 2) { + let sum = 0; + for (const b of bars) { + const high = typeof b.HighPrice === 'number' ? b.HighPrice : (typeof b.h === 'number' ? b.h : 0); + const low = typeof b.LowPrice === 'number' ? b.LowPrice : (typeof b.l === 'number' ? b.l : 0); + sum += Math.max(0, high - low); + } + atr = sum / bars.length; + } else if (prices && prices.length >= 2) { let sum = 0; for (let i = 1; i < prices.length; i++) sum += Math.abs(prices[i] - prices[i - 1]); atr = sum / (prices.length - 1); diff --git a/app/lib/queue.ts b/app/lib/queue.ts index 862e952..1d53ba8 100644 --- a/app/lib/queue.ts +++ b/app/lib/queue.ts @@ -1,6 +1,7 @@ import pkg from "bullmq"; const { Queue, Worker } = pkg as any; import IORedis from "ioredis"; +import { fetchAccount, fetchRecentCloses } from "./alpacaClient"; import { OpenRouterClient } from "./openrouter"; import { TradingGraph } from "../agents/tradingGraph"; import { db } from "./db.server"; @@ -42,7 +43,35 @@ if (REDIS_URL) { const client = new OpenRouterClient(apiKey); const graph = new TradingGraph(client); - const decision = await graph.propagate(ticker, input); + // Fetch latest Alpaca account and prices; abort job if unavailable so work runs on fresh data + try { + const account = await fetchAccount(); + const prices = await fetchRecentCloses(ticker); + input.account = input.account || account; + input.technicalData = input.technicalData || {}; + input.technicalData.prices = input.technicalData.prices && input.technicalData.prices.length ? input.technicalData.prices : prices; + } catch (e) { + console.error("[queue] Failed to fetch Alpaca data, aborting job:", e); + // Throw to mark the job as failed early + throw new Error("Failed to fetch Alpaca data: " + String(e)); + } + + let decision = await graph.propagate(ticker, input); + + // Enrich executionPlan deterministically server-side before persisting + try { + const { enrichExecutionPlan, verifyExecutionPlanWithLLM } = await import("./execution"); + decision = enrichExecutionPlan(decision, input); + if (process.env.OPENROUTER_API_KEY) { + try { + decision = await verifyExecutionPlanWithLLM(decision, input); + } catch (e) { + console.warn("[queue] LLM verification failed:", e); + } + } + } catch (e) { + console.warn("[queue] Failed to enrich execution plan:", e); + } await db.stock.upsert({ where: { ticker }, @@ -164,7 +193,37 @@ if (REDIS_URL) { } const client = new OpenRouterClient(process.env.OPENROUTER_API_KEY as string); const graph = new TradingGraph(client); - const decision = await graph.propagate(job.ticker, job.input); + // Fetch latest Alpaca account and prices; abort job if unavailable so work runs on fresh data + try { + const account = await fetchAccount(); + const prices = await fetchRecentCloses(job.ticker); + job.input = job.input || {}; + job.input.account = job.input.account || account; + job.input.technicalData = job.input.technicalData || {}; + job.input.technicalData.prices = job.input.technicalData.prices && job.input.technicalData.prices.length ? job.input.technicalData.prices : prices; + } catch (e) { + console.error("[inproc queue] Failed to fetch Alpaca data, aborting job:", e); + // throw so the outer catch marks job as failed + throw new Error("Failed to fetch Alpaca data: " + String(e)); + } + + let decision = await graph.propagate(job.ticker, job.input); + + // Enrich executionPlan deterministically server-side before persisting + try { + const { enrichExecutionPlan, verifyExecutionPlanWithLLM } = await import("./execution"); + decision = enrichExecutionPlan(decision, job.input); + if (process.env.OPENROUTER_API_KEY) { + try { + decision = await verifyExecutionPlanWithLLM(decision, job.input); + } catch (e) { + console.warn("[inproc queue] LLM verification failed:", e); + } + } + } catch (e) { + console.warn("[inproc queue] Failed to enrich execution plan:", e); + } + job.result = decision; job.state = "completed"; await db.stock.upsert({ diff --git a/app/routes.ts b/app/routes.ts index a66fa79..ec3356e 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -19,4 +19,5 @@ export default [ route("jobs/:jobId", "routes/jobs/$jobId.tsx"), route("analyze", "routes/analyze.tsx"), route("analyze/:ticker", "routes/analyze.ticker.tsx"), + route("settings", "routes/settings.tsx"), ] satisfies RouteConfig; \ No newline at end of file diff --git a/app/routes/__tests__/analyze.ticker.ui.test.tsx b/app/routes/__tests__/analyze.ticker.ui.test.tsx index f127a95..fd912fe 100644 --- a/app/routes/__tests__/analyze.ticker.ui.test.tsx +++ b/app/routes/__tests__/analyze.ticker.ui.test.tsx @@ -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); }); }); diff --git a/app/routes/analyze.ticker.tsx b/app/routes/analyze.ticker.tsx index c5f0bfb..477cb38 100644 --- a/app/routes/analyze.ticker.tsx +++ b/app/routes/analyze.ticker.tsx @@ -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(); +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(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 | 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(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 | 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() {
- + + + + {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 && (
@@ -320,7 +528,19 @@ export default function StockDetail() {
{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}
+ )} +
+ )}
)}
@@ -467,6 +687,9 @@ export default function StockDetail() { {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
)} @@ -483,17 +706,32 @@ export default function StockDetail() {
Order Suggestion
- {decision.action.toUpperCase()} - {decision.executionPlan.amount} shares + {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
)}
+ + {/* 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}
+ )} +
+ )} + )} @@ -546,4 +784,4 @@ export default function StockDetail() { ); -} \ No newline at end of file +} diff --git a/app/routes/api/admin/__tests__/settings.api.test.ts b/app/routes/api/admin/__tests__/settings.api.test.ts index 3e84ba8..4c6f608 100644 --- a/app/routes/api/admin/__tests__/settings.api.test.ts +++ b/app/routes/api/admin/__tests__/settings.api.test.ts @@ -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(); diff --git a/app/routes/api/admin/settings/[key].ts b/app/routes/api/admin/settings/[key].ts index 7d98d7c..4d41130 100644 --- a/app/routes/api/admin/settings/[key].ts +++ b/app/routes/api/admin/settings/[key].ts @@ -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 }); -}; +} diff --git a/app/routes/api/admin/settings/index.ts b/app/routes/api/admin/settings/index.ts index 92cd501..d3c6a6a 100644 --- a/app/routes/api/admin/settings/index.ts +++ b/app/routes/api/admin/settings/index.ts @@ -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' } }); +} diff --git a/app/routes/api/alpaca/account.ts b/app/routes/api/alpaca/account.ts index 2be9d27..64d81a7 100644 --- a/app/routes/api/alpaca/account.ts +++ b/app/routes/api/alpaca/account.ts @@ -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 { +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); diff --git a/app/routes/api/alpaca/quote.ts b/app/routes/api/alpaca/quote.ts index 3c2fa31..64e2572 100644 --- a/app/routes/api/alpaca/quote.ts +++ b/app/routes/api/alpaca/quote.ts @@ -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, diff --git a/app/routes/api/analyze.ts b/app/routes/api/analyze.ts index ab6c06b..6f73ad4 100644 --- a/app/routes/api/analyze.ts +++ b/app/routes/api/analyze.ts @@ -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 }); } -} \ No newline at end of file +} diff --git a/app/routes/api/price-stream-test.ts b/app/routes/api/price-stream-test.ts new file mode 100644 index 0000000..a8c8655 --- /dev/null +++ b/app/routes/api/price-stream-test.ts @@ -0,0 +1,3 @@ +export async function loader(){ + return Response.json({ ok: true, msg: "price-stream-test" }); +} diff --git a/app/routes/api/price-stream.ts b/app/routes/api/price-stream.ts index 278202f..4793611 100644 --- a/app/routes/api/price-stream.ts +++ b/app/routes/api/price-stream.ts @@ -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); }, }); diff --git a/app/routes/jobs/$jobId.tsx b/app/routes/jobs/$jobId.tsx index 8c071fa..f25e08e 100644 --- a/app/routes/jobs/$jobId.tsx +++ b/app/routes/jobs/$jobId.tsx @@ -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() { Open API -
Data:
+
Raw Data:
{JSON.stringify(job?.data || job?.returnValue || job, null, 2)}
-
- -
+ {/* TradingGraph structured output */} + {job?.returnValue && ( +
+

TradingGraph Result

+ + {/* Decision summary */} + {job.returnValue.action && ( +
+
Decision:
+
{String(job.returnValue.action).toUpperCase()} (confidence: {Number(job.returnValue.confidence ?? 0).toFixed(2)})
+ {job.returnValue.reasoning &&
{job.returnValue.reasoning}
} +
+ )} + + {/* Agent signals / analyst reports */} + {Array.isArray(job.returnValue.agentSignals) && job.returnValue.agentSignals.length > 0 && ( +
+
Analyst Reports
+
+ {job.returnValue.agentSignals.map((s: any, i: number) => ( +
+
+
{s.agent}
+
{s.signal} ({Math.round((s.confidence ?? 0) * 100)}%)
+
+ {s.reasoning &&
{s.reasoning}
} +
+ ))} +
+
+ )} + + {/* Debate rounds (if present) */} + {Array.isArray(job.returnValue.debateRounds) && job.returnValue.debateRounds.length > 0 && ( +
+
Debate Rounds
+
+ {job.returnValue.debateRounds.map((d: any, i: number) => ( +
+
Researcher: {d.researcher ?? 'unknown'}
+ {d.bullishView &&
Bullish: {d.bullishView}
} + {d.bearishView &&
Bearish: {d.bearishView}
} +
+ ))} +
+
+ )} + + {/* Execution plan */} + {job.returnValue.executionPlan && ( +
+
Execution Plan
+
+ {job.returnValue.executionPlan.amount != null && (
Amount: {job.returnValue.executionPlan.amount}
)} + {job.returnValue.executionPlan.takeProfit != null && (
Take profit: ${job.returnValue.executionPlan.takeProfit}
)} + {job.returnValue.executionPlan.stopLoss != null && (
Stop loss: ${job.returnValue.executionPlan.stopLoss}
)} + {job.returnValue.executionPlan.riskManagement?.maxLossPercent != null && (
Risk: {job.returnValue.executionPlan.riskManagement.maxLossPercent}%
)} +
+
+ )} + + {/* LLM review if available */} + {job.returnValue.executionPlan?._llmReview && ( +
+

LLM Review

+
+
Approved: {job.returnValue.executionPlan._llmReview.approved ? 'Yes' : 'No'}
+ {job.returnValue.executionPlan._llmReview.notes && ( +
Notes: {job.returnValue.executionPlan._llmReview.notes}
+ )} +
+
+ )} + +
+ )} ); } + diff --git a/app/routes/settings.tsx b/app/routes/settings.tsx index 6d5037a..de8e387 100644 --- a/app/routes/settings.tsx +++ b/app/routes/settings.tsx @@ -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>([]); diff --git a/docs/superpowers/plans/2026-05-16-settings-page-plan.md b/docs/superpowers/plans/2026-05-16-settings-page-plan.md new file mode 100644 index 0000000..207e52e --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-settings-page-plan.md @@ -0,0 +1,349 @@ +# Settings Page Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a DB-backed app-wide Settings feature with a secure admin UI, a SettingsService (cache + write-through), API endpoints, Prisma schema + migration, and tests so settings changes immediately affect application behavior. + +**Architecture:** Server-centric SettingsService persists to the database (Prisma) and exposes admin REST endpoints. Frontend admin UI calls these endpoints and subscribes to in-process updates. No multi-instance pub/sub in v1. + +**Tech Stack:** Node 20, TypeScript, Prisma, React Router (server+client), Vitest (unit), Playwright (E2E), TailwindCSS. + +--- + +### Task 1: Add Prisma model & migration + +**Files:** +- Modify: `prisma\schema.prisma` (add model) +- Create: migration via CLI (described below) + +- [ ] **Step 1: Add model to schema** + +Edit `prisma\schema.prisma` and add: + +```prisma +model AppSetting { + id Int @id @default(autoincrement()) + key String @unique + value Json + description String? + updatedAt DateTime @updatedAt + updatedBy String? +} +``` + +- [ ] **Step 2: Create and run migration (local dev)** + +Run: + +``` +npx prisma migrate dev --name add_app_setting --preview-feature +``` + +Expected: new migration created and prisma client regenerated. Verify `prisma/migrations/` contains a folder. + +- [ ] **Step 3: Commit migration files** + +``` +git add prisma/schema.prisma prisma/migrations && git commit -m "chore(db): add AppSetting model" +``` + + +### Task 2: Implement SettingsService (server-side cache + emitter) + +**Files:** +- Create: `app\lib\settings.server.ts` +- Modify: `app\server\index.ts` or equivalent bootstrap (if needed) to import the service for in-process subscribers +- Test: `app\lib\__tests__\settings.server.test.ts` + +- [ ] **Step 1: Create SettingsService file** + +Create `app\lib\settings.server.ts` with the following content: + +```ts +import { PrismaClient } from '@prisma/client'; +import EventEmitter from 'events'; + +const prisma = new PrismaClient(); + +type JSONValue = any; + +class SettingsService extends EventEmitter { + private cache: Map = new Map(); + private initialized = false; + + async init() { + if (this.initialized) return; + const rows = await prisma.appSetting.findMany(); + rows.forEach(r => this.cache.set(r.key, r.value)); + this.initialized = true; + } + + async get(key: string) { + if (!this.initialized) await this.init(); + return this.cache.has(key) ? this.cache.get(key) : null; + } + + async set(key: string, value: JSONValue, updatedBy?: string) { + if (!this.initialized) await this.init(); + // write-through + await prisma.appSetting.upsert({ + where: { key }, + update: { value, updatedBy }, + create: { key, value, updatedBy }, + }); + this.cache.set(key, value); + this.emit('update', { key, value }); + return { key, value }; + } + + subscribe(fn: (payload: { key: string; value: any }) => void) { + this.on('update', fn); + return () => this.off('update', fn); + } +} + +export const settingsService = new SettingsService(); +``` + +- [ ] **Step 2: Write unit test for SettingsService** + +Create `app\lib\__tests__\settings.server.test.ts`: + +```ts +import { settingsService } from '../settings.server'; + +describe('SettingsService', () => { + test('set and get', async () => { + const key = `test_key_${Date.now()}`; + const val = { enabled: true }; + await settingsService.set(key, val, 'test'); + const got = await settingsService.get(key); + expect(got).toEqual(val); + }); +}); +``` + +Run: `npm run test -w 1 -- app/lib/__tests__/settings.server.test.ts` + +Expected: PASS (may require test DB setup; in dev environment prisma will use sqlite or configured DB) + +- [ ] **Step 3: Commit** + +``` +git add app/lib/settings.server.ts app/lib/__tests__/settings.server.test.ts +git commit -m "feat(settings): add SettingsService with cache and emitter" +``` + + +### Task 3: Add secure API endpoints + +**Files:** +- Create: `app\routes\api\admin\settings\index.ts` (GET, POST list) +- Create: `app\routes\api\admin\settings\[key].ts` (PUT update) +- Modify: shared auth util if needed to check admin role: `app\lib\auth.server.ts` (just reuse existing session check or fallback to ADMIN_TOKEN) +- Test: `app\routes\api\admin\__tests__\settings.api.test.ts` + +- [ ] **Step 1: Implement GET/POST index handler** + +Create `app\routes\api\admin\settings\index.ts`: + +```ts +import type { RequestHandler } from '@remix-run/node'; +import { settingsService } from '~/lib/settings.server'; +import { requireAdmin } from '~/lib/auth.server'; + +export const loader: RequestHandler = async ({ request }) => { + await requireAdmin(request); + // return all settings + const keys = Array.from((await settingsService['init'](), settingsService as any).cache.keys()); + const entries = [] as any[]; + for (const key of keys) { + const value = await settingsService.get(key); + entries.push({ key, value }); + } + return new Response(JSON.stringify(entries), { status: 200 }); +}; + +export const action: RequestHandler = async ({ request }) => { + await requireAdmin(request); + const body = await request.json(); + if (!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 }); +}; +``` + +- [ ] **Step 2: Implement PUT handler for key** + +Create `app\routes\api\admin\settings\[key].ts`: + +```ts +import type { RequestHandler } from '@remix-run/node'; +import { settingsService } from '~/lib/settings.server'; +import { requireAdmin } from '~/lib/auth.server'; + +export const action: RequestHandler = async ({ request, params }) => { + 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 }); +}; +``` + +- [ ] **Step 3: Tests for API** + +Create `app\routes\api\admin\__tests__\settings.api.test.ts` with a simple fetch-based integration test (Vitest + node fetch environment): + +```ts +import fetch from 'node-fetch'; + +// This is a placeholder instructions block; run API tests with the dev server running or mock requireAdmin +test('API index returns json', async () => { + // For simplicity, call settingsService directly in unit tests rather than full server + const { settingsService } = await import('../../../lib/settings.server'); + const key = 'api_test_' + Date.now(); + await settingsService.set(key, { foo: 'bar' }, 'test'); + const v = await settingsService.get(key); + expect(v).toEqual({ foo: 'bar' }); +}); +``` + +Run: `npm run test -w 1 -- app/routes/api/admin/__tests__/settings.api.test.ts` + +Commit after passing tests. + + +### Task 4: Frontend admin UI route + +**Files:** +- Create: `app\routes\settings.tsx` (UI page for admins) +- Modify: `app\components\Navbar.tsx` (add link to /settings when isAdmin) +- Test: `tests\e2e\settings.spec.ts` (Playwright) + +- [ ] **Step 1: Create settings route component** + +Create `app\routes\settings.tsx`: + +```tsx +import React, { useEffect, useState } from 'react'; + +export default function SettingsPage() { + const [items, setItems] = useState>([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch('/api/admin/settings') + .then(r => r.json()) + .then(j => setItems(j)) + .finally(() => setLoading(false)); + }, []); + + async function save(key: string, value: any) { + await fetch(`/api/admin/settings/${encodeURIComponent(key)}`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ value }), + }); + // update local cache + setItems(s => s.map(i => (i.key === key ? { ...i, value } : i))); + } + + if (loading) return
Loading...
; + return ( +
+

Settings

+
    + {items.map(it => ( +
  • +
    +
    {it.key}
    +