feat(settings): add settings route and API updates\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -8,3 +8,4 @@
|
|||||||
|
|
||||||
/generated/prisma
|
/generated/prisma
|
||||||
/prisma/dev.db
|
/prisma/dev.db
|
||||||
|
/graphify-out
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
/* TRADINGGRAPH related file */
|
||||||
|
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { TradingGraph } from "../tradingGraph";
|
import { TradingGraph } from "../tradingGraph";
|
||||||
|
|
||||||
@@ -28,3 +30,4 @@ describe("TradingGraph execution step", () => {
|
|||||||
expect(decision.executionPlan?.amount).toBe(100);
|
expect(decision.executionPlan?.amount).toBe(100);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
/* TRADINGGRAPH related file */
|
||||||
|
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { TradingGraph } from "../tradingGraph";
|
import { TradingGraph } from "../tradingGraph";
|
||||||
|
|
||||||
|
|||||||
+13
-10
@@ -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
|
- confidence: a number between 0 and 1
|
||||||
- reasoning: brief explanation
|
- reasoning: brief explanation
|
||||||
|
|
||||||
If the action is "sell", also include an "executionPlan" object with:
|
If the action is "buy" or "sell", also include an "executionPlan" object with:
|
||||||
- amount: number (shares to sell)
|
- amount: number (shares to trade)
|
||||||
- riskManagement: object (e.g., { maxLossPercent: 2 })
|
- riskManagement: object (e.g., { maxLossPercent: 2 })
|
||||||
- takeProfit: number (target take-profit price)
|
- takeProfit: number (target take-profit price)
|
||||||
|
- stopLoss: number (stop-loss price or absolute value)
|
||||||
|
|
||||||
Format your response as JSON with these fields.`;
|
Format your response as JSON with these fields.`;
|
||||||
|
|
||||||
const response = await this.client.createChatCompletion(
|
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 },
|
{ role: "user", content: prompt },
|
||||||
],
|
],
|
||||||
this.model
|
this.model
|
||||||
@@ -86,13 +87,15 @@ Format your response as JSON with these fields.`;
|
|||||||
executionPlan = JSON.parse(execMatch[1]);
|
executionPlan = JSON.parse(execMatch[1]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// fallback: try to extract primitive fields
|
// fallback: try to extract primitive fields
|
||||||
const amountMatch = content.match(/"amount"\s*:\s*([0-9.]+)/);
|
const amountMatch = content.match(/(?:"amount"|\bamount\b)\s*:\s*([0-9.]+)/);
|
||||||
const takeProfitMatch = content.match(/"takeProfit"\s*:\s*([0-9.]+)/);
|
const takeProfitMatch = content.match(/(?:"takeProfit"|\btakeProfit\b)\s*:\s*([0-9.]+)/);
|
||||||
const maxLossMatch = content.match(/"maxLossPercent"\s*:\s*([0-9.]+)/);
|
const stopLossMatch = content.match(/(?:"stopLoss"|\bstopLoss\b)\s*:\s*([0-9.]+)/);
|
||||||
const methodMatch = content.match(/"method"\s*:\s*"([^"]+)"/);
|
const maxLossMatch = content.match(/(?:"maxLossPercent"|\bmaxLossPercent\b)\s*:\s*([0-9.]+)/);
|
||||||
|
const methodMatch = content.match(/(?:"method"|\bmethod\b)\s*:\s*"([^"]+)"/);
|
||||||
executionPlan = {} as any;
|
executionPlan = {} as any;
|
||||||
if (amountMatch) executionPlan.amount = parseFloat(amountMatch[1]);
|
if (amountMatch) executionPlan.amount = parseFloat(amountMatch[1]);
|
||||||
if (takeProfitMatch) executionPlan.takeProfit = parseFloat(takeProfitMatch[1]);
|
if (takeProfitMatch) executionPlan.takeProfit = parseFloat(takeProfitMatch[1]);
|
||||||
|
if (stopLossMatch) executionPlan.stopLoss = parseFloat(stopLossMatch[1]);
|
||||||
executionPlan.riskManagement = {};
|
executionPlan.riskManagement = {};
|
||||||
if (maxLossMatch) executionPlan.riskManagement.maxLossPercent = parseFloat(maxLossMatch[1]);
|
if (maxLossMatch) executionPlan.riskManagement.maxLossPercent = parseFloat(maxLossMatch[1]);
|
||||||
if (methodMatch) executionPlan.riskManagement.method = methodMatch[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
|
// Additional fallback: if executionPlan parsed but missing nested riskManagement fields, try to extract them
|
||||||
if (executionPlan && executionPlan.riskManagement == null) {
|
if (executionPlan && executionPlan.riskManagement == null) {
|
||||||
const maxLossMatch2 = content.match(/"maxLossPercent"\s*:\s*([0-9.]+)/);
|
const maxLossMatch2 = content.match(/(?:"maxLossPercent"|\bmaxLossPercent\b)\s*:\s*([0-9.]+)/);
|
||||||
const methodMatch2 = content.match(/"method"\s*:\s*"([^"]+)"/);
|
const methodMatch2 = content.match(/(?:"method"|\bmethod\b)\s*:\s*"([^"]+)"/);
|
||||||
if (maxLossMatch2 || methodMatch2) {
|
if (maxLossMatch2 || methodMatch2) {
|
||||||
executionPlan.riskManagement = executionPlan.riskManagement || {};
|
executionPlan.riskManagement = executionPlan.riskManagement || {};
|
||||||
if (maxLossMatch2) executionPlan.riskManagement.maxLossPercent = parseFloat(maxLossMatch2[1]);
|
if (maxLossMatch2) executionPlan.riskManagement.maxLossPercent = parseFloat(maxLossMatch2[1]);
|
||||||
@@ -118,7 +121,7 @@ Format your response as JSON with these fields.`;
|
|||||||
debateRounds: debates,
|
debateRounds: debates,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (action === 'sell' && executionPlan) {
|
if ((action === 'sell' || action === 'buy') && executionPlan) {
|
||||||
decision.executionPlan = executionPlan;
|
decision.executionPlan = executionPlan;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-14
@@ -1,3 +1,5 @@
|
|||||||
|
/* TRADINGGRAPH related file */
|
||||||
|
|
||||||
import { OpenRouterClient } from "../lib/openrouter";
|
import { OpenRouterClient } from "../lib/openrouter";
|
||||||
import { FundamentalsAnalyst } from "./fundamentals";
|
import { FundamentalsAnalyst } from "./fundamentals";
|
||||||
import { TechnicalAnalyst } from "./technical";
|
import { TechnicalAnalyst } from "./technical";
|
||||||
@@ -41,14 +43,14 @@ export class TradingGraph {
|
|||||||
sentimentData: { headlines: string[]; source?: "news" | "social" | "stocktwits" };
|
sentimentData: { headlines: string[]; source?: "news" | "social" | "stocktwits" };
|
||||||
}
|
}
|
||||||
): Promise<TradingDecision> {
|
): Promise<TradingDecision> {
|
||||||
console.log(`[TradingGraph] Starting analysis for ${ticker} with model ${this.model}`);
|
|
||||||
|
|
||||||
const reports = await this.runAnalysts(ticker, input);
|
const reports = await this.runAnalysts(ticker, input);
|
||||||
const debates = await this.runDebate(ticker, reports);
|
const debates = await this.runDebate(ticker, reports);
|
||||||
const decision = await this.trader.decide(ticker, reports, debates);
|
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.
|
// Build workflow steps for observability. Include an execution step when selling.
|
||||||
const steps: GraphStep[] = [
|
const steps: GraphStep[] = [
|
||||||
@@ -57,13 +59,13 @@ export class TradingGraph {
|
|||||||
{ step: "trader", data: decision },
|
{ step: "trader", data: decision },
|
||||||
];
|
];
|
||||||
|
|
||||||
if (decision.action === 'sell' && decision.executionPlan) {
|
if (decision.executionPlan) {
|
||||||
steps.push({ step: "execution", data: 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.
|
// Log steps for debugging; external systems can be extended to consume GraphStep sequence.
|
||||||
console.log(`[TradingGraph] Workflow steps: ${JSON.stringify(steps)}`);
|
|
||||||
|
|
||||||
return decision;
|
return decision;
|
||||||
}
|
}
|
||||||
@@ -76,7 +78,7 @@ export class TradingGraph {
|
|||||||
sentimentData: { headlines: string[]; source?: "news" | "social" | "stocktwits" };
|
sentimentData: { headlines: string[]; source?: "news" | "social" | "stocktwits" };
|
||||||
}
|
}
|
||||||
): Promise<AnalystReport[]> {
|
): Promise<AnalystReport[]> {
|
||||||
console.log(`[TradingGraph] Running analysts for ${ticker}...`);
|
|
||||||
|
|
||||||
const [fundamentals, technical, sentiment] = await Promise.all([
|
const [fundamentals, technical, sentiment] = await Promise.all([
|
||||||
this.fundamentalsAnalyst.analyze(ticker, input.financialData),
|
this.fundamentalsAnalyst.analyze(ticker, input.financialData),
|
||||||
@@ -84,24 +86,20 @@ export class TradingGraph {
|
|||||||
this.sentimentAnalyst.analyze(ticker, input.sentimentData),
|
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];
|
return [fundamentals, technical, sentiment];
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runDebate(ticker: string, reports: AnalystReport[]): Promise<DebateRound[]> {
|
private async runDebate(ticker: string, reports: AnalystReport[]): Promise<DebateRound[]> {
|
||||||
console.log(`[TradingGraph] Running debate for ${ticker}...`);
|
|
||||||
|
|
||||||
const [bullish, bearish] = await Promise.all([
|
const [bullish, bearish] = await Promise.all([
|
||||||
this.bullishResearcher.research(ticker, reports),
|
this.bullishResearcher.research(ticker, reports),
|
||||||
this.bearishResearcher.research(ticker, reports),
|
this.bearishResearcher.research(ticker, reports),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
console.log(`[TradingGraph] Debate complete`);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import * as LightweightCharts from "lightweight-charts";
|
import * as LightweightCharts from "lightweight-charts";
|
||||||
|
|
||||||
type ChartTime = string | number;
|
type ChartTime = string | number;
|
||||||
@@ -15,6 +15,9 @@ interface TradingViewChartProps {
|
|||||||
ticker: string;
|
ticker: string;
|
||||||
data?: ChartDataPoint[];
|
data?: ChartDataPoint[];
|
||||||
timeframe?: string;
|
timeframe?: string;
|
||||||
|
currentPrice?: number;
|
||||||
|
// priceStream.subscribe(cb) should return an unsubscribe function
|
||||||
|
priceStream?: { subscribe: (cb: (price: number) => void) => () => void };
|
||||||
}
|
}
|
||||||
|
|
||||||
const TIMEFRAME_HEIGHTS: Record<string, number> = {
|
const TIMEFRAME_HEIGHTS: Record<string, number> = {
|
||||||
@@ -25,8 +28,9 @@ const TIMEFRAME_HEIGHTS: Record<string, number> = {
|
|||||||
"1W": 400,
|
"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<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [livePrice, setLivePrice] = useState<number | undefined>(undefined);
|
||||||
const height = TIMEFRAME_HEIGHTS[timeframe] ?? 400;
|
const height = TIMEFRAME_HEIGHTS[timeframe] ?? 400;
|
||||||
|
|
||||||
const isIntraday = ["1Min", "5Min", "15Min", "30Min", "1H"].includes(timeframe);
|
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();
|
return () => chart.remove();
|
||||||
}, [data, ticker, isIntraday, timeframe]);
|
}, [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 (
|
return (
|
||||||
<div className="bg-white rounded-xl shadow-lg p-4">
|
<div className="bg-white rounded-xl shadow-lg p-4">
|
||||||
<h3 className="text-lg font-bold mb-3">{ticker} Price Chart</h3>
|
<div className="flex items-baseline justify-between mb-3">
|
||||||
|
<h3 className="text-lg font-bold">{ticker} Price Chart</h3>
|
||||||
|
{typeof derivedPrice === "number" ? (
|
||||||
|
<div data-testid="current-price" className="text-xl font-semibold text-gray-900">
|
||||||
|
${derivedPrice.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<div ref={containerRef} className="w-full" />
|
<div ref={containerRef} className="w-full" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -71,6 +71,43 @@ describe("TradingViewChart", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows current price when provided via prop", () => {
|
||||||
|
render(<TradingViewChart ticker="PRC" currentPrice={123.456} />);
|
||||||
|
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(<TradingViewChart ticker="DER" data={data} />);
|
||||||
|
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(<TradingViewChart ticker="STR" priceStream={priceStream} />);
|
||||||
|
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", () => {
|
it("creates candlestick series with explicit colors", () => {
|
||||||
const mockAddSeries = vi.fn();
|
const mockAddSeries = vi.fn();
|
||||||
mockCreateChart.mockReturnValue({
|
mockCreateChart.mockReturnValue({
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
+113
-21
@@ -1,6 +1,8 @@
|
|||||||
import Alpaca from "@alpacahq/alpaca-trade-api";
|
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 isLive = mode === 'live';
|
||||||
const keyId = isLive ? (process.env.ALPACA_API_KEY_LIVE || process.env.ALPACA_API_KEY) : process.env.ALPACA_API_KEY;
|
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;
|
const secretKey = isLive ? (process.env.ALPACA_SECRET_KEY_LIVE || process.env.ALPACA_SECRET_KEY) : process.env.ALPACA_SECRET_KEY;
|
||||||
@@ -15,27 +17,46 @@ function makeAlpaca(mode: 'paper' | 'live' = 'paper') {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAccount(mode: 'paper' | 'live' = 'paper') {
|
class AlpacaService {
|
||||||
|
private mode: Mode;
|
||||||
|
private client: any;
|
||||||
|
private lastBarCache = new Map<string, { bar: any; ts: number }>();
|
||||||
|
|
||||||
|
constructor(mode: Mode = 'paper') {
|
||||||
|
this.mode = mode;
|
||||||
|
this.client = makeAlpaca(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
setMode(mode: Mode) {
|
||||||
|
if (this.mode !== mode) {
|
||||||
|
this.mode = mode;
|
||||||
|
this.client = makeAlpaca(mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getMode() {
|
||||||
|
return this.mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchAccount() {
|
||||||
try {
|
try {
|
||||||
const client = makeAlpaca(mode);
|
const account = await this.client.getAccount();
|
||||||
const account = await client.getAccount();
|
|
||||||
return {
|
return {
|
||||||
cash: parseFloat(account.cash),
|
cash: parseFloat(account.cash),
|
||||||
buying_power: parseFloat(account.buying_power),
|
buying_power: parseFloat(account.buying_power),
|
||||||
portfolio_value: parseFloat(account.portfolio_value),
|
portfolio_value: parseFloat(account.portfolio_value),
|
||||||
};
|
};
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("alpacaClient: fetchAccount failed:", err);
|
console.error("AlpacaService: fetchAccount failed:", err);
|
||||||
throw new Error(err?.message || String(err));
|
throw new Error(err?.message || String(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchRecentCloses(ticker: string, days = 30, mode: 'paper' | 'live' = 'paper') {
|
async fetchRecentCloses(ticker: string, days = 30) {
|
||||||
try {
|
try {
|
||||||
const client = makeAlpaca(mode);
|
|
||||||
const startDate = new Date();
|
const startDate = new Date();
|
||||||
startDate.setDate(startDate.getDate() - days);
|
startDate.setDate(startDate.getDate() - days);
|
||||||
const barsIter = await client.getBarsV2(ticker, { timeframe: "1D", start: startDate.toISOString().split("T")[0], limit: 1000 });
|
const barsIter = await this.client.getBarsV2(ticker, { timeframe: "1D", start: startDate.toISOString().split("T")[0], limit: 1000 });
|
||||||
const barsArray: any[] = [];
|
const barsArray: any[] = [];
|
||||||
for await (const b of barsIter) barsArray.push(b);
|
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);
|
const closes = barsArray.map((bar: any) => (typeof bar.ClosePrice === 'number' ? bar.ClosePrice : (typeof bar.c === 'number' ? bar.c : 0))).filter((c: number) => c > 0);
|
||||||
@@ -43,43 +64,114 @@ export async function fetchRecentCloses(ticker: string, days = 30, mode: 'paper'
|
|||||||
|
|
||||||
// fallback to latest trade
|
// fallback to latest trade
|
||||||
try {
|
try {
|
||||||
const trade: any = await client.getLatestTrade(ticker);
|
const trade: any = await this.client.getLatestTrade(ticker);
|
||||||
const price = trade?.Price || trade?.price || 0;
|
const price = trade?.Price || trade?.price || 0;
|
||||||
if (price) return [price];
|
if (price) return [price];
|
||||||
} catch (tErr) {
|
} catch (tErr) {
|
||||||
console.warn("alpacaClient: getLatestTrade fallback failed:", tErr);
|
console.warn("AlpacaService: getLatestTrade fallback failed:", tErr);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("No recent price data available from Alpaca");
|
throw new Error("No recent price data available from Alpaca");
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("alpacaClient: fetchRecentCloses failed:", err);
|
console.error("AlpacaService: fetchRecentCloses failed:", err);
|
||||||
throw new Error(err?.message || String(err));
|
throw new Error(err?.message || String(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchLatestBar(ticker: string, timeframe = '1Min', mode: 'paper' | 'live' = 'paper') {
|
async fetchLatestBar(ticker: string, timeframe = '1Min') {
|
||||||
|
const cacheKey = `${ticker}:${timeframe}`;
|
||||||
|
const maxRetries = 3;
|
||||||
|
let attempt = 0;
|
||||||
|
let baseDelay = 500; // ms
|
||||||
try {
|
try {
|
||||||
const client = makeAlpaca(mode);
|
while (attempt < maxRetries) {
|
||||||
const barsIter = await client.getBarsV2(ticker, { timeframe, limit: 1 });
|
try {
|
||||||
|
const barsIter = await this.client.getBarsV2(ticker, { timeframe, limit: 1 });
|
||||||
const barsArr: any[] = [];
|
const barsArr: any[] = [];
|
||||||
for await (const b of barsIter) barsArr.push(b);
|
for await (const b of barsIter) barsArr.push(b);
|
||||||
const last = barsArr[barsArr.length - 1];
|
const last = barsArr[barsArr.length - 1] || null;
|
||||||
return last || null;
|
if (last) {
|
||||||
|
this.lastBarCache.set(cacheKey, { bar: last, ts: Date.now() });
|
||||||
|
}
|
||||||
|
return last || (this.lastBarCache.get(cacheKey)?.bar ?? null);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('alpacaClient: fetchLatestBar failed:', err);
|
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));
|
throw new Error(err?.message || String(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchBars(ticker: string, timeframe = '1D', options: any = {}, mode: 'paper' | 'live' = 'paper') {
|
// 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 {
|
try {
|
||||||
const client = makeAlpaca(mode);
|
while (attempt < maxRetries) {
|
||||||
const barsIter = await client.getBarsV2(ticker, { timeframe, ...options });
|
try {
|
||||||
|
const barsIter = await this.client.getBarsV2(ticker, { timeframe, ...options });
|
||||||
const barsArr: any[] = [];
|
const barsArr: any[] = [];
|
||||||
for await (const b of barsIter) barsArr.push(b);
|
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;
|
return barsArr;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('alpacaClient: fetchBars failed:', err);
|
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));
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton configured to use paper trading API by default
|
||||||
|
export const alpacaService = new AlpacaService('paper');
|
||||||
|
|
||||||
|
// 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;
|
||||||
+11
-2
@@ -5,9 +5,18 @@ export function enrichExecutionPlan(decision: TradingDecision, input: any): Trad
|
|||||||
const prices: number[] = input?.technicalData?.prices || [];
|
const prices: number[] = input?.technicalData?.prices || [];
|
||||||
const entryPrice = prices.length ? prices[prices.length - 1] : undefined;
|
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;
|
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;
|
let sum = 0;
|
||||||
for (let i = 1; i < prices.length; i++) sum += Math.abs(prices[i] - prices[i - 1]);
|
for (let i = 1; i < prices.length; i++) sum += Math.abs(prices[i] - prices[i - 1]);
|
||||||
atr = sum / (prices.length - 1);
|
atr = sum / (prices.length - 1);
|
||||||
|
|||||||
+61
-2
@@ -1,6 +1,7 @@
|
|||||||
import pkg from "bullmq";
|
import pkg from "bullmq";
|
||||||
const { Queue, Worker } = pkg as any;
|
const { Queue, Worker } = pkg as any;
|
||||||
import IORedis from "ioredis";
|
import IORedis from "ioredis";
|
||||||
|
import { fetchAccount, fetchRecentCloses } from "./alpacaClient";
|
||||||
import { OpenRouterClient } from "./openrouter";
|
import { OpenRouterClient } from "./openrouter";
|
||||||
import { TradingGraph } from "../agents/tradingGraph";
|
import { TradingGraph } from "../agents/tradingGraph";
|
||||||
import { db } from "./db.server";
|
import { db } from "./db.server";
|
||||||
@@ -42,7 +43,35 @@ if (REDIS_URL) {
|
|||||||
|
|
||||||
const client = new OpenRouterClient(apiKey);
|
const client = new OpenRouterClient(apiKey);
|
||||||
const graph = new TradingGraph(client);
|
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({
|
await db.stock.upsert({
|
||||||
where: { ticker },
|
where: { ticker },
|
||||||
@@ -164,7 +193,37 @@ if (REDIS_URL) {
|
|||||||
}
|
}
|
||||||
const client = new OpenRouterClient(process.env.OPENROUTER_API_KEY as string);
|
const client = new OpenRouterClient(process.env.OPENROUTER_API_KEY as string);
|
||||||
const graph = new TradingGraph(client);
|
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.result = decision;
|
||||||
job.state = "completed";
|
job.state = "completed";
|
||||||
await db.stock.upsert({
|
await db.stock.upsert({
|
||||||
|
|||||||
@@ -19,4 +19,5 @@ export default [
|
|||||||
route("jobs/:jobId", "routes/jobs/$jobId.tsx"),
|
route("jobs/:jobId", "routes/jobs/$jobId.tsx"),
|
||||||
route("analyze", "routes/analyze.tsx"),
|
route("analyze", "routes/analyze.tsx"),
|
||||||
route("analyze/:ticker", "routes/analyze.ticker.tsx"),
|
route("analyze/:ticker", "routes/analyze.ticker.tsx"),
|
||||||
|
route("settings", "routes/settings.tsx"),
|
||||||
] satisfies RouteConfig;
|
] satisfies RouteConfig;
|
||||||
@@ -56,8 +56,8 @@ describe("StockDetail UI - executionPlan", () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(screen.getByText(/Execution Plan/i)).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText(/Execution Plan/i)).toBeInTheDocument());
|
||||||
expect(screen.getByText(/Amount:/i)).toBeInTheDocument();
|
expect(screen.getByText(/Amount:/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/25 shares/i)).toBeInTheDocument();
|
expect(screen.getAllByText(/25 shares/i).length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText(/Take profit:/i)).toBeInTheDocument();
|
expect(screen.getAllByText(/Take profit:/i).length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText(/\$150/)).toBeInTheDocument();
|
expect(screen.getAllByText(/\$150/).length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+256
-18
@@ -1,5 +1,7 @@
|
|||||||
|
/* TRADINGGRAPH related file */
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
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 TradingViewChart from "../components/TradingViewChart";
|
||||||
import Navbar from "../components/Navbar";
|
import Navbar from "../components/Navbar";
|
||||||
import JobHistory from "../components/JobHistory";
|
import JobHistory from "../components/JobHistory";
|
||||||
@@ -8,6 +10,26 @@ import type { TradingDecision, AnalystReport, DebateRound } from "../types/agent
|
|||||||
|
|
||||||
export const meta = () => [{ title: "Stock Detail - AITrader" }];
|
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 {
|
interface LoaderData {
|
||||||
ticker: string;
|
ticker: string;
|
||||||
position: { qty: number; avg_entry_price: number; current_price: number; market_value: number; unrealized_pl: number } | null;
|
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: [] };
|
const ordersData = ordRes.ok ? await ordRes.json() : { orders: [] };
|
||||||
orders = ordersData.orders?.filter((o: any) => o.symbol === ticker) || [];
|
orders = ordersData.orders?.filter((o: any) => o.symbol === ticker) || [];
|
||||||
|
|
||||||
// Fetch bars for chart with timeframe and range
|
// 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 barsRes = await fetch(`${baseUrl}/api/alpaca/quote/${ticker}?timeframe=${timeframe}&range=${range}`);
|
||||||
const barsData = barsRes.ok ? await barsRes.json() : null;
|
const barsData = barsRes.ok ? await barsRes.json() : null;
|
||||||
bars = barsData?.bars || [];
|
bars = barsData?.bars || [];
|
||||||
|
if (bars.length > 0) {
|
||||||
|
setCachedBars(barsCacheKey, bars);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch stock record (to get lastJobId)
|
// Fetch stock record (to get lastJobId)
|
||||||
try {
|
try {
|
||||||
@@ -100,6 +131,7 @@ export default function StockDetail() {
|
|||||||
const [showDebate, setShowDebate] = useState(false);
|
const [showDebate, setShowDebate] = useState(false);
|
||||||
const [jobStatus, setJobStatus] = useState<any>(null);
|
const [jobStatus, setJobStatus] = useState<any>(null);
|
||||||
const [jobPolling, setJobPolling] = useState(false);
|
const [jobPolling, setJobPolling] = useState(false);
|
||||||
|
const [showTradingSummary, setShowTradingSummary] = useState(true);
|
||||||
|
|
||||||
// Cache key for this ticker
|
// Cache key for this ticker
|
||||||
const cacheKey = `tradinggraph-${ticker}`;
|
const cacheKey = `tradinggraph-${ticker}`;
|
||||||
@@ -131,10 +163,21 @@ export default function StockDetail() {
|
|||||||
if (stockRecord?.lastJobId) {
|
if (stockRecord?.lastJobId) {
|
||||||
setJobPolling(true);
|
setJobPolling(true);
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let currentController: AbortController | null = null;
|
||||||
|
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
|
// abort previous fetch if any
|
||||||
|
if (currentController) {
|
||||||
|
try { currentController.abort(); } catch (e) {}
|
||||||
|
}
|
||||||
|
currentController = new AbortController();
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/jobs/${stockRecord.lastJobId}`);
|
const res = await fetch(`/api/jobs/${stockRecord.lastJobId}`, { signal: currentController.signal });
|
||||||
if (!res.ok) return;
|
if (!res.ok) {
|
||||||
|
if (!cancelled) timer = setTimeout(poll, 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const j = await res.json();
|
const j = await res.json();
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setJobStatus(j);
|
setJobStatus(j);
|
||||||
@@ -143,16 +186,62 @@ export default function StockDetail() {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
console.warn("Failed to poll job status:", e);
|
if (e?.name !== 'AbortError') console.warn("Failed to poll job status:", e);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) timer = setTimeout(poll, 1000);
|
||||||
}
|
}
|
||||||
setTimeout(poll, 1000);
|
|
||||||
};
|
};
|
||||||
poll();
|
poll();
|
||||||
return () => { cancelled = true; };
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
if (currentController) {
|
||||||
|
try { currentController.abort(); } catch (e) {}
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}, [cacheKey, stockRecord]);
|
}, [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 updateParams = (newTimeframe: string, newRange: string) => {
|
||||||
const searchParams = new URLSearchParams(location.search);
|
const searchParams = new URLSearchParams(location.search);
|
||||||
searchParams.set("timeframe", newTimeframe);
|
searchParams.set("timeframe", newTimeframe);
|
||||||
@@ -176,12 +265,63 @@ export default function StockDetail() {
|
|||||||
console.warn("Failed to ensure ticker saved:", e);
|
console.warn("Failed to ensure ticker saved:", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enqueue background job for analysis so it runs reliably
|
||||||
const res = await fetch("/api/analyze", {
|
const res = await fetch("/api/analyze", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ ticker }),
|
body: JSON.stringify({ ticker, background: true }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
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");
|
if (!res.ok) throw new Error(data.error || "Analysis failed");
|
||||||
|
|
||||||
const reports = data.agentSignals.map((sig: any) => ({
|
const reports = data.agentSignals.map((sig: any) => ({
|
||||||
@@ -197,12 +337,12 @@ export default function StockDetail() {
|
|||||||
|
|
||||||
// Save last decision/explanation to DB
|
// Save last decision/explanation to DB
|
||||||
try {
|
try {
|
||||||
const fd2 = new FormData();
|
const fd3 = new FormData();
|
||||||
fd2.append("ticker", ticker);
|
fd3.append("ticker", ticker);
|
||||||
fd2.append("lastDecision", data.action ?? "");
|
fd3.append("lastDecision", data.action ?? "");
|
||||||
fd2.append("lastExplanation", data.reasoning ?? "");
|
fd3.append("lastExplanation", data.reasoning ?? "");
|
||||||
if (data.executionPlan) fd2.append("lastExecutionPlan", JSON.stringify(data.executionPlan));
|
if (data.executionPlan) fd3.append("lastExecutionPlan", JSON.stringify(data.executionPlan));
|
||||||
await fetch("/api/stocks", { method: "POST", body: fd2 });
|
await fetch("/api/stocks", { method: "POST", body: fd3 });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Failed to save decision to DB:", e);
|
console.warn("Failed to save decision to DB:", e);
|
||||||
}
|
}
|
||||||
@@ -287,7 +427,7 @@ export default function StockDetail() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TradingViewChart ticker={ticker} data={chartData} timeframe={timeframe} />
|
<TradingViewChart ticker={ticker} data={chartData} timeframe={timeframe} priceStream={priceStream} />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={runTradingGraph}
|
onClick={runTradingGraph}
|
||||||
@@ -300,7 +440,7 @@ export default function StockDetail() {
|
|||||||
{/* Job status link */}
|
{/* Job status link */}
|
||||||
{stockRecord?.lastJobId && (
|
{stockRecord?.lastJobId && (
|
||||||
<div className="mt-3 text-sm text-gray-600">
|
<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 && (
|
{jobStatus && (
|
||||||
<span className="ml-3">Status: <strong>{jobStatus.state}</strong></span>
|
<span className="ml-3">Status: <strong>{jobStatus.state}</strong></span>
|
||||||
)}
|
)}
|
||||||
@@ -310,6 +450,74 @@ export default function StockDetail() {
|
|||||||
{/* Job history */}
|
{/* Job history */}
|
||||||
<JobHistory ticker={ticker} />
|
<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) */}
|
{/* Last persisted decision (if no live decision) */}
|
||||||
{!decision && stockRecord?.lastDecision && (
|
{!decision && stockRecord?.lastDecision && (
|
||||||
<div className="mt-3 bg-white rounded-lg p-3 border border-gray-200 text-gray-900">
|
<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">
|
<div className="mt-2 text-sm text-gray-700">
|
||||||
{lastExecutionPlan.amount != null && (<div>Amount: <strong>{lastExecutionPlan.amount}</strong></div>)}
|
{lastExecutionPlan.amount != null && (<div>Amount: <strong>{lastExecutionPlan.amount}</strong></div>)}
|
||||||
{lastExecutionPlan.takeProfit != null && (<div>Take profit: <strong>${lastExecutionPlan.takeProfit}</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>)}
|
{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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -467,6 +687,9 @@ export default function StockDetail() {
|
|||||||
{decision.executionPlan.takeProfit != null && (
|
{decision.executionPlan.takeProfit != null && (
|
||||||
<div>Take profit: <span className="font-medium">${decision.executionPlan.takeProfit}</span></div>
|
<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 && (
|
{decision.executionPlan.riskManagement?.maxLossPercent != null && (
|
||||||
<div>Risk management: <span className="font-medium">{decision.executionPlan.riskManagement.maxLossPercent}% max loss</span></div>
|
<div>Risk management: <span className="font-medium">{decision.executionPlan.riskManagement.maxLossPercent}% max loss</span></div>
|
||||||
)}
|
)}
|
||||||
@@ -484,16 +707,31 @@ export default function StockDetail() {
|
|||||||
<div className="text-sm text-gray-700 mt-2">
|
<div className="text-sm text-gray-700 mt-2">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">{decision.action.toUpperCase()}</span>
|
<span className="font-medium">{decision.action.toUpperCase()}</span>
|
||||||
{decision.executionPlan.amount} shares
|
<span className="ml-2">{decision.executionPlan.amount} (shares)</span>
|
||||||
{decision.executionPlan.takeProfit != null && (
|
{decision.executionPlan.takeProfit != null && (
|
||||||
<span> — Take profit: ${decision.executionPlan.takeProfit}</span>
|
<span> — Take profit: ${decision.executionPlan.takeProfit}</span>
|
||||||
)}
|
)}
|
||||||
|
{decision.executionPlan.stopLoss != null && (
|
||||||
|
<span> — Stop loss: ${decision.executionPlan.stopLoss}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{decision.executionPlan.riskManagement?.maxLossPercent != null && (
|
{decision.executionPlan.riskManagement?.maxLossPercent != null && (
|
||||||
<div className="text-xs text-gray-500">Risk: {decision.executionPlan.riskManagement.maxLossPercent}% max loss</div>
|
<div className="text-xs text-gray-500">Risk: {decision.executionPlan.riskManagement.maxLossPercent}% max loss</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</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 () => {
|
test('settings API helper behavior', async () => {
|
||||||
const key = 'api_test_' + Date.now();
|
const key = 'api_test_' + Date.now();
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import type { ActionFunction } from '@remix-run/node';
|
import { settingsService } from '../../../../lib/settings.server';
|
||||||
import { settingsService } from '~/lib/settings.server';
|
import { requireAdmin } from '../../../../lib/auth.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);
|
await requireAdmin(request);
|
||||||
const key = params.key as string;
|
const key = params.key as string;
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
if (!key) return new Response('Missing key', { status: 400 });
|
if (!key) return new Response('Missing key', { status: 400 });
|
||||||
await settingsService.set(key, body.value, 'admin');
|
await settingsService.set(key, body.value, 'admin');
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,23 +1,20 @@
|
|||||||
import type { LoaderFunction, ActionFunction } from '@remix-run/node';
|
import { settingsService } from '../../../../lib/settings.server';
|
||||||
import { json } from '@remix-run/node';
|
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 requireAdmin(request);
|
||||||
await settingsService.init?.();
|
await (settingsService as any).init?.();
|
||||||
const entries: any[] = [];
|
const entries: any[] = [];
|
||||||
// @ts-ignore access cache
|
|
||||||
for (const key of (settingsService as any).cache.keys()) {
|
for (const key of (settingsService as any).cache.keys()) {
|
||||||
entries.push({ key, value: await settingsService.get(key) });
|
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);
|
await requireAdmin(request);
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
if (!body || !body.key) return new Response('Missing key', { status: 400 });
|
if (!body || !body.key) return new Response('Missing key', { status: 400 });
|
||||||
const created = await settingsService.set(body.key, body.value, 'admin');
|
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 type { AlpacaAccount } from "../../../types";
|
||||||
import Alpaca from "@alpacahq/alpaca-trade-api";
|
import alpacaService from "../../../lib/alpacaClient";
|
||||||
|
|
||||||
const alpaca = new Alpaca({
|
export async function loader({ request }: { request: Request }) {
|
||||||
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> {
|
|
||||||
try {
|
try {
|
||||||
console.log("Fetching Alpaca account with key:", process.env.ALPACA_API_KEY?.substring(0, 8) + "...");
|
const account = await alpacaService.fetchAccount();
|
||||||
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();
|
|
||||||
return Response.json(account);
|
return Response.json(account);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Alpaca API error:", 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 } }) {
|
export async function loader({ request, params }: { request: Request; params: { ticker: string } }) {
|
||||||
const ticker = params.ticker?.toUpperCase();
|
const ticker = params.ticker?.toUpperCase();
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const timeframe = url.searchParams.get("timeframe") || "1D";
|
const timeframe = url.searchParams.get("timeframe") || "1D";
|
||||||
const range = url.searchParams.get("range") || "1M"; // 1D, 1W, 1M, 3M, 1Y, 3Y, ALL
|
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) {
|
if (!ticker) {
|
||||||
return Response.json({ error: "Ticker is required" }, { status: 400 });
|
return Response.json({ error: "Ticker is required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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)
|
// Get latest bar for current price (uses paper by default unless mode=live)
|
||||||
let price = 0;
|
let price = 0;
|
||||||
try {
|
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;
|
price = last ? (last.ClosePrice ?? last.c ?? 0) : 0;
|
||||||
} catch (tradeErr) {
|
} catch (tradeErr) {
|
||||||
console.error(`API quote/${ticker}: fetchLatestBar failed`, 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
|
const barsOptions: any = { limit: 1000 }; // High limit for time range
|
||||||
if (!isIntraday && range !== "ALL") {
|
// For daily/non-intraday queries pass just the date part (YYYY-MM-DD)
|
||||||
barsOptions.start = startDate.toISOString().split('T')[0];
|
if (!isIntraday) {
|
||||||
} else if (!isIntraday) {
|
|
||||||
barsOptions.start = startDate.toISOString().split('T')[0];
|
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
|
// Transform to chart format
|
||||||
const transformedBars = barsArray.map((bar: any) => {
|
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 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 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 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);
|
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 {
|
return {
|
||||||
t: timestamp,
|
t: iso,
|
||||||
o: open,
|
o: open,
|
||||||
h: high,
|
h: high,
|
||||||
l: low,
|
l: low,
|
||||||
c: close,
|
c: close,
|
||||||
v: volume,
|
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({
|
return Response.json({
|
||||||
ticker,
|
ticker,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
/* TRADINGGRAPH related file */
|
||||||
|
|
||||||
// Server-only imports are loaded dynamically inside the action to avoid client bundling issues
|
// Server-only imports are loaded dynamically inside the action to avoid client bundling issues
|
||||||
|
|
||||||
export async function action({ request }: { request: Request }) {
|
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 { OpenRouterClient } = await import("../../lib/openrouter");
|
||||||
const { TradingGraph } = await import("../../agents/tradingGraph");
|
const { TradingGraph } = await import("../../agents/tradingGraph");
|
||||||
const { db } = await import("../../lib/db.server");
|
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;
|
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||||
console.log("[analyze] API key configured:", !!apiKey, apiKey?.substring(0, 10) + "...");
|
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
|
// Fetch latest Alpaca account and recent prices; abort if unavailable
|
||||||
let account: any = undefined;
|
let account: any = undefined;
|
||||||
let prices: number[] = [];
|
let prices: number[] = [];
|
||||||
|
let recentBars: any[] = [];
|
||||||
try {
|
try {
|
||||||
account = await fetchAccount();
|
account = await fetchAccount();
|
||||||
prices = await fetchRecentCloses(ticker);
|
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) {
|
} catch (e) {
|
||||||
console.error("[analyze] Failed to fetch Alpaca data before analysis:", 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 });
|
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}`,
|
financialData: `Financial data for ${ticker} as of ${date}`,
|
||||||
technicalData: {
|
technicalData: {
|
||||||
prices,
|
prices,
|
||||||
|
bars: recentBars,
|
||||||
sma: 0,
|
sma: 0,
|
||||||
ema: 0,
|
ema: 0,
|
||||||
rsi: 0,
|
rsi: 0,
|
||||||
@@ -119,7 +133,13 @@ export async function action({ request }: { request: Request }) {
|
|||||||
console.warn("Failed to enrich execution plan:", e);
|
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);
|
return Response.json(decision);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Unknown error";
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
|||||||
@@ -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 }) {
|
export async function loader({ request }: { request: Request }) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const ticker = (url.searchParams.get("ticker") || "").toUpperCase();
|
const ticker = (url.searchParams.get("ticker") || "").toUpperCase();
|
||||||
if (!ticker) return new Response("ticker required", { status: 400 });
|
if (!ticker) return new Response("ticker required", { status: 400 });
|
||||||
const timeframe = url.searchParams.get("timeframe") || "1Min"; // default to 1Min bars for live price
|
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({
|
const headers = new Headers({
|
||||||
"Content-Type": "text/event-stream",
|
"Content-Type": "text/event-stream",
|
||||||
@@ -13,9 +20,8 @@ export async function loader({ request }: { request: Request }) {
|
|||||||
Connection: "keep-alive",
|
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 closed = false;
|
||||||
let interval: any;
|
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
// helper to push SSE event
|
// helper to push SSE event
|
||||||
@@ -31,28 +37,54 @@ export async function loader({ request }: { request: Request }) {
|
|||||||
// initial ping
|
// initial ping
|
||||||
pushEvent({ event: "connected", ticker, timeframe });
|
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;
|
if (closed) return;
|
||||||
try {
|
try {
|
||||||
const last = await fetchLatestBar(ticker, timeframe, mode);
|
const last = await alpacaService.fetchLatestBar(ticker, alpacaTimeframe);
|
||||||
const price = last ? (last.ClosePrice ?? last.c ?? null) : null;
|
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) {
|
if (price != null) {
|
||||||
|
if (barId == null || barId !== lastBarId) {
|
||||||
|
lastBarId = barId;
|
||||||
pushEvent({ price, ts: Date.now(), timeframe });
|
pushEvent({ price, ts: Date.now(), timeframe });
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
pushEvent({ error: "no_bar", ts: Date.now() });
|
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() {
|
cancel() {
|
||||||
closed = true;
|
closed = true;
|
||||||
clearInterval(interval);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
/* TRADINGGRAPH related file */
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useLoaderData } from "react-router";
|
import { useLoaderData } from "react-router";
|
||||||
import Navbar from "../../components/Navbar";
|
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>
|
<a href={`/api/jobs/${job?.id}`} target="_blank" rel="noreferrer" className="text-sm text-blue-600 hover:underline">Open API</a>
|
||||||
</div>
|
</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>
|
<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">
|
{/* 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>
|
||||||
</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 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() {
|
export default function SettingsPage() {
|
||||||
const [items, setItems] = useState<Array<{ key: string; value: any }>>([]);
|
const [items, setItems] = useState<Array<{ key: string; value: any }>>([]);
|
||||||
|
|||||||
@@ -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<string, JSONValue> = 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<Array<{ key: string; value: any }>>([]);
|
||||||
|
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 <div>Loading...</div>;
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Settings</h1>
|
||||||
|
<ul>
|
||||||
|
{items.map(it => (
|
||||||
|
<li key={it.key} className="mb-3">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="font-medium">{it.key}</div>
|
||||||
|
<textarea
|
||||||
|
className="border p-2 w-2/3"
|
||||||
|
defaultValue={JSON.stringify(it.value, null, 2)}
|
||||||
|
onBlur={e => {
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(e.currentTarget.value);
|
||||||
|
save(it.key, v);
|
||||||
|
} catch (err) {
|
||||||
|
alert('Invalid JSON');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add Navbar link**
|
||||||
|
|
||||||
|
Modify `app\components\Navbar.tsx` to conditionally render a link to `/settings` for admins. Use existing session/isAdmin check.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Playwright E2E test**
|
||||||
|
|
||||||
|
Create `tests\e2e\settings.spec.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test('admin can view and edit settings', async ({ page }) => {
|
||||||
|
await page.goto('http://localhost:5173/settings');
|
||||||
|
await expect(page.locator('text=Settings')).toBeVisible();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `npm run test:e2e -- --project=chromium tests/e2e/settings.spec.ts`
|
||||||
|
|
||||||
|
Commit after tests pass.
|
||||||
|
|
||||||
|
|
||||||
|
### Task 5: Wiring settings into app behavior (example flag)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: A feature-flagged consumer (example: `app\routes\landing.tsx` or the component that uses ANALYSIS_BACKGROUND)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read setting where used**
|
||||||
|
|
||||||
|
Example change in a consumer component:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { settingsService } from '~/lib/settings.server';
|
||||||
|
|
||||||
|
export async function loader() {
|
||||||
|
const analysisBackground = (await settingsService.get('ANALYSIS_BACKGROUND')) ?? { enabled: false };
|
||||||
|
return json({ analysisBackground });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write test demonstrating behavior switch**
|
||||||
|
|
||||||
|
Add a unit test that sets the setting then validates the consumer behavior.
|
||||||
|
|
||||||
|
Commit changes.
|
||||||
|
|
||||||
|
|
||||||
|
### Task 6: Tests, CI, and rollout notes
|
||||||
|
|
||||||
|
- Unit tests: SettingsService, API tests, consumer behavior tests.
|
||||||
|
- E2E: settings.spec.ts to validate page loads and editing works.
|
||||||
|
- CI: ensure `npx prisma migrate deploy` runs in deployment and that environment variables for DB are configured.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review checklist
|
||||||
|
|
||||||
|
1. Spec coverage: Tasks implement Prisma model, SettingsService, API, UI, tests, and migration. Multi-instance propagation is out-of-scope for v1, noted in spec.
|
||||||
|
2. Placeholders: No TODOs remain in the plan. All code blocks are concrete starting points.
|
||||||
|
3. Type consistency: Method names `settingsService.get` and `.set` used consistently.
|
||||||
|
|
||||||
|
Plan saved to `docs/superpowers/plans/2026-05-16-settings-page-plan.md`.
|
||||||
|
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
Plan author: Copilot
|
||||||
@@ -1,328 +0,0 @@
|
|||||||
{
|
|
||||||
"nodes": [
|
|
||||||
{
|
|
||||||
"id": "react_router_config_ts",
|
|
||||||
"label": "react-router.config.ts",
|
|
||||||
"file_type": "code",
|
|
||||||
"source_file": "react-router.config.ts",
|
|
||||||
"source_location": "L1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "vite_config_ts",
|
|
||||||
"label": "vite.config.ts",
|
|
||||||
"file_type": "code",
|
|
||||||
"source_file": "vite.config.ts",
|
|
||||||
"source_location": "L1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "app_root_tsx",
|
|
||||||
"label": "root.tsx",
|
|
||||||
"file_type": "code",
|
|
||||||
"source_file": "app\\root.tsx",
|
|
||||||
"source_location": "L1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "app_root_links",
|
|
||||||
"label": "links()",
|
|
||||||
"file_type": "code",
|
|
||||||
"source_file": "app\\root.tsx",
|
|
||||||
"source_location": "L13"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "app_root_layout",
|
|
||||||
"label": "Layout()",
|
|
||||||
"file_type": "code",
|
|
||||||
"source_file": "app\\root.tsx",
|
|
||||||
"source_location": "L26"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "app_root_app",
|
|
||||||
"label": "App()",
|
|
||||||
"file_type": "code",
|
|
||||||
"source_file": "app\\root.tsx",
|
|
||||||
"source_location": "L44"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "app_root_errorboundary",
|
|
||||||
"label": "ErrorBoundary()",
|
|
||||||
"file_type": "code",
|
|
||||||
"source_file": "app\\root.tsx",
|
|
||||||
"source_location": "L48"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "app_routes_ts",
|
|
||||||
"label": "routes.ts",
|
|
||||||
"file_type": "code",
|
|
||||||
"source_file": "app\\routes.ts",
|
|
||||||
"source_location": "L1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "app_routes_home_tsx",
|
|
||||||
"label": "home.tsx",
|
|
||||||
"file_type": "code",
|
|
||||||
"source_file": "app\\routes\\home.tsx",
|
|
||||||
"source_location": "L1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "routes_home_meta",
|
|
||||||
"label": "meta()",
|
|
||||||
"file_type": "code",
|
|
||||||
"source_file": "app\\routes\\home.tsx",
|
|
||||||
"source_location": "L4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "routes_home_home",
|
|
||||||
"label": "Home()",
|
|
||||||
"file_type": "code",
|
|
||||||
"source_file": "app\\routes\\home.tsx",
|
|
||||||
"source_location": "L11"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "app_welcome_welcome_tsx",
|
|
||||||
"label": "welcome.tsx",
|
|
||||||
"file_type": "code",
|
|
||||||
"source_file": "app\\welcome\\welcome.tsx",
|
|
||||||
"source_location": "L1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "welcome_welcome_welcome",
|
|
||||||
"label": "Welcome()",
|
|
||||||
"file_type": "code",
|
|
||||||
"source_file": "app\\welcome\\welcome.tsx",
|
|
||||||
"source_location": "L4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "welcome_welcome_resources",
|
|
||||||
"label": "resources",
|
|
||||||
"file_type": "code",
|
|
||||||
"source_file": "app\\welcome\\welcome.tsx",
|
|
||||||
"source_location": "L49"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"edges": [
|
|
||||||
{
|
|
||||||
"source": "react_router_config_ts",
|
|
||||||
"target": "config",
|
|
||||||
"relation": "imports_from",
|
|
||||||
"context": "import",
|
|
||||||
"confidence": "EXTRACTED",
|
|
||||||
"source_file": "react-router.config.ts",
|
|
||||||
"source_location": "L1",
|
|
||||||
"weight": 1.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "vite_config_ts",
|
|
||||||
"target": "vite",
|
|
||||||
"relation": "imports_from",
|
|
||||||
"context": "import",
|
|
||||||
"confidence": "EXTRACTED",
|
|
||||||
"source_file": "vite.config.ts",
|
|
||||||
"source_location": "L1",
|
|
||||||
"weight": 1.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "vite_config_ts",
|
|
||||||
"target": "vite",
|
|
||||||
"relation": "imports_from",
|
|
||||||
"context": "import",
|
|
||||||
"confidence": "EXTRACTED",
|
|
||||||
"source_file": "vite.config.ts",
|
|
||||||
"source_location": "L2",
|
|
||||||
"weight": 1.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "vite_config_ts",
|
|
||||||
"target": "vite",
|
|
||||||
"relation": "imports_from",
|
|
||||||
"context": "import",
|
|
||||||
"confidence": "EXTRACTED",
|
|
||||||
"source_file": "vite.config.ts",
|
|
||||||
"source_location": "L3",
|
|
||||||
"weight": 1.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "app_root_tsx",
|
|
||||||
"target": "react_router",
|
|
||||||
"relation": "imports_from",
|
|
||||||
"context": "import",
|
|
||||||
"confidence": "EXTRACTED",
|
|
||||||
"source_file": "app\\root.tsx",
|
|
||||||
"source_location": "L1",
|
|
||||||
"weight": 1.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "app_root_tsx",
|
|
||||||
"target": "c_users_henry_programming_aitrader_app_types_root",
|
|
||||||
"relation": "imports_from",
|
|
||||||
"context": "import",
|
|
||||||
"confidence": "EXTRACTED",
|
|
||||||
"source_file": "app\\root.tsx",
|
|
||||||
"source_location": "L10",
|
|
||||||
"weight": 1.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "app_root_tsx",
|
|
||||||
"target": "types_root_route",
|
|
||||||
"relation": "imports",
|
|
||||||
"context": "import",
|
|
||||||
"confidence": "EXTRACTED",
|
|
||||||
"source_file": "app\\root.tsx",
|
|
||||||
"source_location": "L10",
|
|
||||||
"weight": 1.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "app_root_tsx",
|
|
||||||
"target": "c_users_henry_programming_aitrader_app_app_css",
|
|
||||||
"relation": "imports_from",
|
|
||||||
"context": "import",
|
|
||||||
"confidence": "EXTRACTED",
|
|
||||||
"source_file": "app\\root.tsx",
|
|
||||||
"source_location": "L11",
|
|
||||||
"weight": 1.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "app_root_tsx",
|
|
||||||
"target": "app_root_links",
|
|
||||||
"relation": "contains",
|
|
||||||
"confidence": "EXTRACTED",
|
|
||||||
"source_file": "app\\root.tsx",
|
|
||||||
"source_location": "L13",
|
|
||||||
"weight": 1.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "app_root_tsx",
|
|
||||||
"target": "app_root_layout",
|
|
||||||
"relation": "contains",
|
|
||||||
"confidence": "EXTRACTED",
|
|
||||||
"source_file": "app\\root.tsx",
|
|
||||||
"source_location": "L26",
|
|
||||||
"weight": 1.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "app_root_tsx",
|
|
||||||
"target": "app_root_app",
|
|
||||||
"relation": "contains",
|
|
||||||
"confidence": "EXTRACTED",
|
|
||||||
"source_file": "app\\root.tsx",
|
|
||||||
"source_location": "L44",
|
|
||||||
"weight": 1.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "app_root_tsx",
|
|
||||||
"target": "app_root_errorboundary",
|
|
||||||
"relation": "contains",
|
|
||||||
"confidence": "EXTRACTED",
|
|
||||||
"source_file": "app\\root.tsx",
|
|
||||||
"source_location": "L48",
|
|
||||||
"weight": 1.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "app_routes_ts",
|
|
||||||
"target": "routes",
|
|
||||||
"relation": "imports_from",
|
|
||||||
"context": "import",
|
|
||||||
"confidence": "EXTRACTED",
|
|
||||||
"source_file": "app\\routes.ts",
|
|
||||||
"source_location": "L1",
|
|
||||||
"weight": 1.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "app_routes_home_tsx",
|
|
||||||
"target": "c_users_henry_programming_aitrader_app_routes_types_home",
|
|
||||||
"relation": "imports_from",
|
|
||||||
"context": "import",
|
|
||||||
"confidence": "EXTRACTED",
|
|
||||||
"source_file": "app\\routes\\home.tsx",
|
|
||||||
"source_location": "L1",
|
|
||||||
"weight": 1.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "app_routes_home_tsx",
|
|
||||||
"target": "types_home_route",
|
|
||||||
"relation": "imports",
|
|
||||||
"context": "import",
|
|
||||||
"confidence": "EXTRACTED",
|
|
||||||
"source_file": "app\\routes\\home.tsx",
|
|
||||||
"source_location": "L1",
|
|
||||||
"weight": 1.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "app_routes_home_tsx",
|
|
||||||
"target": "app_welcome_welcome_tsx",
|
|
||||||
"relation": "imports_from",
|
|
||||||
"context": "import",
|
|
||||||
"confidence": "EXTRACTED",
|
|
||||||
"source_file": "app\\routes\\home.tsx",
|
|
||||||
"source_location": "L2",
|
|
||||||
"weight": 1.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "app_routes_home_tsx",
|
|
||||||
"target": "welcome_welcome_welcome",
|
|
||||||
"relation": "imports",
|
|
||||||
"context": "import",
|
|
||||||
"confidence": "EXTRACTED",
|
|
||||||
"source_file": "app\\routes\\home.tsx",
|
|
||||||
"source_location": "L2",
|
|
||||||
"weight": 1.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "app_routes_home_tsx",
|
|
||||||
"target": "routes_home_meta",
|
|
||||||
"relation": "contains",
|
|
||||||
"confidence": "EXTRACTED",
|
|
||||||
"source_file": "app\\routes\\home.tsx",
|
|
||||||
"source_location": "L4",
|
|
||||||
"weight": 1.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "app_routes_home_tsx",
|
|
||||||
"target": "routes_home_home",
|
|
||||||
"relation": "contains",
|
|
||||||
"confidence": "EXTRACTED",
|
|
||||||
"source_file": "app\\routes\\home.tsx",
|
|
||||||
"source_location": "L11",
|
|
||||||
"weight": 1.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "app_welcome_welcome_tsx",
|
|
||||||
"target": "c_users_henry_programming_aitrader_app_welcome_logo_dark_svg",
|
|
||||||
"relation": "imports_from",
|
|
||||||
"context": "import",
|
|
||||||
"confidence": "EXTRACTED",
|
|
||||||
"source_file": "app\\welcome\\welcome.tsx",
|
|
||||||
"source_location": "L1",
|
|
||||||
"weight": 1.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "app_welcome_welcome_tsx",
|
|
||||||
"target": "c_users_henry_programming_aitrader_app_welcome_logo_light_svg",
|
|
||||||
"relation": "imports_from",
|
|
||||||
"context": "import",
|
|
||||||
"confidence": "EXTRACTED",
|
|
||||||
"source_file": "app\\welcome\\welcome.tsx",
|
|
||||||
"source_location": "L2",
|
|
||||||
"weight": 1.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "app_welcome_welcome_tsx",
|
|
||||||
"target": "welcome_welcome_welcome",
|
|
||||||
"relation": "contains",
|
|
||||||
"confidence": "EXTRACTED",
|
|
||||||
"source_file": "app\\welcome\\welcome.tsx",
|
|
||||||
"source_location": "L4",
|
|
||||||
"weight": 1.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "app_welcome_welcome_tsx",
|
|
||||||
"target": "welcome_welcome_resources",
|
|
||||||
"relation": "contains",
|
|
||||||
"confidence": "EXTRACTED",
|
|
||||||
"source_file": "app\\welcome\\welcome.tsx",
|
|
||||||
"source_location": "L49",
|
|
||||||
"weight": 1.0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"input_tokens": 0,
|
|
||||||
"output_tokens": 0
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"nodes": [], "edges": [], "hyperedges": []}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"files": {"code": ["C:\\Users\\Henry\\programming\\AITrader\\react-router.config.ts", "C:\\Users\\Henry\\programming\\AITrader\\vite.config.ts", "C:\\Users\\Henry\\programming\\AITrader\\app\\root.tsx", "C:\\Users\\Henry\\programming\\AITrader\\app\\routes.ts", "C:\\Users\\Henry\\programming\\AITrader\\app\\routes\\home.tsx", "C:\\Users\\Henry\\programming\\AITrader\\app\\welcome\\welcome.tsx"], "document": ["C:\\Users\\Henry\\programming\\AITrader\\AGENTS.md", "C:\\Users\\Henry\\programming\\AITrader\\README.md"], "paper": [], "image": ["C:\\Users\\Henry\\programming\\AITrader\\app\\welcome\\logo-dark.svg", "C:\\Users\\Henry\\programming\\AITrader\\app\\welcome\\logo-light.svg"], "video": []}, "total_files": 10, "total_words": 2379, "needs_graph": false, "warning": "Corpus is ~2,379 words - fits in a single context window. You may not need a graph.", "skipped_sensitive": [], "graphifyignore_patterns": 0}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
C:\Users\Henry\programming\AITrader\react-router.config.ts
|
|
||||||
C:\Users\Henry\programming\AITrader\vite.config.ts
|
|
||||||
C:\Users\Henry\programming\AITrader\app\root.tsx
|
|
||||||
C:\Users\Henry\programming\AITrader\app\routes.ts
|
|
||||||
C:\Users\Henry\programming\AITrader\app\routes\home.tsx
|
|
||||||
C:\Users\Henry\programming\AITrader\app\welcome\welcome.tsx
|
|
||||||
C:\Users\Henry\programming\AITrader\AGENTS.md
|
|
||||||
C:\Users\Henry\programming\AITrader\README.md
|
|
||||||
C:\Users\Henry\programming\AITrader\app\welcome\logo-dark.svg
|
|
||||||
C:\Users\Henry\programming\AITrader\app\welcome\logo-light.svg
|
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -0,0 +1,17 @@
|
|||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_AppSetting" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
"updatedBy" TEXT
|
||||||
|
);
|
||||||
|
INSERT INTO "new_AppSetting" ("description", "id", "key", "updatedAt", "updatedBy", "value") SELECT "description", "id", "key", "updatedAt", "updatedBy", "value" FROM "AppSetting";
|
||||||
|
DROP TABLE "AppSetting";
|
||||||
|
ALTER TABLE "new_AppSetting" RENAME TO "AppSetting";
|
||||||
|
CREATE UNIQUE INDEX "AppSetting_key_key" ON "AppSetting"("key");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
@@ -23,7 +23,7 @@ model Stock {
|
|||||||
model AppSetting {
|
model AppSetting {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
key String @unique
|
key String @unique
|
||||||
value Json
|
value String
|
||||||
description String?
|
description String?
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
updatedBy String?
|
updatedBy String?
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
{
|
{
|
||||||
"status": "passed",
|
"status": "failed",
|
||||||
"failedTests": []
|
"failedTests": [
|
||||||
|
"87418b536bb3b16b9965-5b389d46641fb5894dfa"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Instructions
|
||||||
|
|
||||||
|
- Following Playwright test failed.
|
||||||
|
- Explain why, be concise, respect Playwright best practices.
|
||||||
|
- Provide a snippet of code with the fix, if possible.
|
||||||
|
|
||||||
|
# Test info
|
||||||
|
|
||||||
|
- Name: e2e\settings.spec.ts >> admin can view settings page
|
||||||
|
- Location: tests\e2e\settings.spec.ts:3:1
|
||||||
|
|
||||||
|
# Error details
|
||||||
|
|
||||||
|
```
|
||||||
|
Error: expect(locator).toBeVisible() failed
|
||||||
|
|
||||||
|
Locator: locator('text=Settings')
|
||||||
|
Expected: visible
|
||||||
|
Timeout: 5000ms
|
||||||
|
Error: element(s) not found
|
||||||
|
|
||||||
|
Call log:
|
||||||
|
- Expect "toBeVisible" with timeout 5000ms
|
||||||
|
- waiting for locator('text=Settings')
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- main:
|
||||||
|
- heading "404" [level=1]
|
||||||
|
- paragraph: The requested page could not be found.
|
||||||
|
```
|
||||||
|
|
||||||
|
# Test source
|
||||||
|
|
||||||
|
```ts
|
||||||
|
1 | import { test, expect } from '@playwright/test';
|
||||||
|
2 |
|
||||||
|
3 | test('admin can view settings page', async ({ page }) => {
|
||||||
|
4 | await page.goto('http://localhost:5173/settings');
|
||||||
|
> 5 | await expect(page.locator('text=Settings')).toBeVisible();
|
||||||
|
| ^ Error: expect(locator).toBeVisible() failed
|
||||||
|
6 | });
|
||||||
|
7 |
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user