diff --git a/app/lib/execution.ts b/app/lib/execution.ts new file mode 100644 index 0000000..911e095 --- /dev/null +++ b/app/lib/execution.ts @@ -0,0 +1,135 @@ +import type { TradingDecision, ExecutionPlan } from "../types/agents"; + +export function enrichExecutionPlan(decision: TradingDecision, input: any): TradingDecision { + try { + const prices: number[] = input?.technicalData?.prices || []; + const entryPrice = prices.length ? prices[prices.length - 1] : undefined; + + // simple ATR approximation: average absolute diff + let atr = 0; + if (prices && prices.length >= 2) { + let sum = 0; + for (let i = 1; i < prices.length; i++) sum += Math.abs(prices[i] - prices[i - 1]); + atr = sum / (prices.length - 1); + } else if (entryPrice) { + atr = entryPrice * 0.01; // fallback 1% + } + + const rr = 2; // default risk:reward + const equity = Number(input?.account?.cash ?? input?.account?.buying_power ?? process.env.DEFAULT_ACCOUNT_EQUITY ?? 10000); + + if (!decision.executionPlan) decision.executionPlan = {} as ExecutionPlan; + const plan = decision.executionPlan as any; + + const maxLossPercent = plan.riskManagement?.maxLossPercent ?? plan.maxLossPercent ?? 1; // default 1% + const riskPercent = Number(maxLossPercent) / 100; + + // compute stop distance (price units) + let stopDistanceByPercent = entryPrice ? Math.abs(entryPrice * riskPercent) : 0; + const stopDistanceByAtr = atr ? atr * 1.5 : 0; // multiplier + let stopDistance = Math.max(stopDistanceByAtr, stopDistanceByPercent, 0.0001); + + // compute stopLoss absolute price if missing + if (plan.stopLoss == null && entryPrice != null) { + if (decision.action === 'buy') { + plan.stopLoss = Number((entryPrice - stopDistance).toFixed(2)); + } else if (decision.action === 'sell') { + // for sell (exit/short) place stop above entry + plan.stopLoss = Number((entryPrice + stopDistance).toFixed(2)); + } else { + // default: buy-style stop + plan.stopLoss = Number((entryPrice - stopDistance).toFixed(2)); + } + } + + // compute takeProfit if missing + if (plan.takeProfit == null && entryPrice != null) { + if (decision.action === 'buy') { + plan.takeProfit = Number((entryPrice + stopDistance * rr).toFixed(2)); + } else if (decision.action === 'sell') { + plan.takeProfit = Number((entryPrice - stopDistance * rr).toFixed(2)); + } else { + plan.takeProfit = Number((entryPrice + stopDistance * rr).toFixed(2)); + } + } + + // compute shares if missing using risk-based sizing + if (plan.amount == null) { + const riskAmount = equity * riskPercent; + // Protect against extremely small stopDistance (which can occur with missing/flat prices) + if (!stopDistance || stopDistance < 0.01) { + stopDistance = entryPrice ? Math.max(entryPrice * 0.01, 0.01) : 1; // default 1 unit when no price + } + const rawShares = Math.max(1, Math.floor(riskAmount / stopDistance)); + // Cap shares to what is affordable with full equity and a reasonable absolute cap + const affordableMax = Math.max(1, Math.floor(equity / Math.max(entryPrice || 1, 1))); + const absoluteMax = 100000; // safety cap + const shares = Math.min(rawShares, affordableMax, absoluteMax); + plan.amount = shares; + } + + // normalize nested riskManagement + plan.riskManagement = plan.riskManagement || {}; + if (plan.riskManagement.maxLossPercent == null) plan.riskManagement.maxLossPercent = maxLossPercent; + + decision.executionPlan = plan as ExecutionPlan; + } catch (err) { + console.warn("enrichExecutionPlan error:", err); + } + return decision; +} + +// Optional LLM verification step: review computed executionPlan and suggest adjustments +export async function verifyExecutionPlanWithLLM(decision: TradingDecision, input: any): Promise { + try { + const apiKey = process.env.OPENROUTER_API_KEY; + if (!apiKey) return decision; + + const { OpenRouterClient } = await import("./openrouter"); + const client = new OpenRouterClient(apiKey); + + const plan = decision.executionPlan || {}; + const prices: number[] = input?.technicalData?.prices || []; + const entryPrice = prices.length ? prices[prices.length - 1] : null; + + const userPrompt = `Review the following deterministic execution plan and either approve or provide corrected values. Respond with a JSON object only with the shape: { "approved": boolean, "executionPlan": { /* fields to use */ }, "notes": "..." }\n\nContext:\nentryPrice: ${entryPrice}\nrecentPrices: ${JSON.stringify(prices.slice(-10))}\nComputedPlan: ${JSON.stringify(plan)}\n\nGuidelines:\n- If values look reasonable, return {"approved": true, "executionPlan": {}} (empty executionPlan means keep computed values).\n- If any value should be adjusted, return an executionPlan object with corrected fields (amount, stopLoss, takeProfit, riskManagement).\n- Do not include any extra text outside the JSON object.`; + + const messages = [ + { role: "system", content: "You are a conservative trading assistant. Validate risk sizing and stop levels. If you suggest changes, prefer conservative (smaller) position sizes and wider stops only if volatility justifies it." }, + { role: "user", content: userPrompt }, + ]; + + const res: any = await client.createChatCompletion(messages as any); + const content = res?.choices?.[0]?.message?.content ?? ""; + + // try to parse JSON out of the content + let parsed: any = null; + try { + parsed = JSON.parse(content); + } catch (e) { + // fallback: extract first JSON object + const m = content.match(/(\{[\s\S]*\})/); + if (m) { + try { + parsed = JSON.parse(m[1]); + } catch (e2) { + console.warn("verifyExecutionPlanWithLLM: failed to parse LLM response JSON"); + } + } + } + + if (parsed && typeof parsed === "object") { + const approved = parsed.approved !== false; // default true if missing + const suggested = parsed.executionPlan || {}; + // attach llmReview metadata + const newPlan = { ...plan, ...suggested }; + newPlan._llmReview = { approved: !!approved, notes: parsed.notes || null }; + decision.executionPlan = newPlan as any; + } + + return decision; + } catch (err) { + console.warn("verifyExecutionPlanWithLLM error:", err); + return decision; + } +} diff --git a/app/routes/api/analyze.ts b/app/routes/api/analyze.ts index 5abfd77..0822a57 100644 --- a/app/routes/api/analyze.ts +++ b/app/routes/api/analyze.ts @@ -1,6 +1,7 @@ import { OpenRouterClient } from "../../lib/openrouter"; import { TradingGraph } from "../../agents/tradingGraph"; import { db } from "../../lib/db.server"; +import { fetchAccount, fetchRecentCloses } from "../../lib/alpacaClient"; export async function action({ request }: { request: Request }) { console.log("[analyze] Request received:", request.method, request.url); @@ -56,19 +57,31 @@ export async function action({ request }: { request: Request }) { const client = new OpenRouterClient(apiKey); const graph = new TradingGraph(client); + // Fetch latest Alpaca account and recent prices; abort if unavailable + let account: any = undefined; + let prices: number[] = []; + try { + account = await fetchAccount(); + prices = await fetchRecentCloses(ticker); + } catch (e) { + console.error("[analyze] Failed to fetch Alpaca data before analysis:", e); + return Response.json({ error: "Failed to fetch Alpaca data: " + String(e) }, { status: 502 }); + } + const input = { financialData: `Financial data for ${ticker} as of ${date}`, technicalData: { - prices: [100, 102, 101, 103, 105], - sma: 102, - ema: 103, - rsi: 55, - macd: 0.5, + prices, + sma: 0, + ema: 0, + rsi: 0, + macd: 0, }, sentimentData: { headlines: [`${ticker} showing positive momentum`], source: "news" as const, }, + account, }; try { @@ -86,7 +99,23 @@ export async function action({ request }: { request: Request }) { } } - const decision = await graph.propagate(ticker, input); + let decision = await graph.propagate(ticker, input); + // Enrich executionPlan deterministically on server-side + try { + const { enrichExecutionPlan, verifyExecutionPlanWithLLM } = await import("../../lib/execution"); + decision = enrichExecutionPlan(decision, input); + // Optionally ask LLM to verify/adjust the computed plan if API key is present + if (process.env.OPENROUTER_API_KEY) { + try { + decision = await verifyExecutionPlanWithLLM(decision, input); + } catch (e) { + console.warn("LLM verification failed:", e); + } + } + } catch (e) { + console.warn("Failed to enrich execution plan:", e); + } + console.log("[analyze] Decision received:", JSON.stringify(decision)); return Response.json(decision); } catch (error) { diff --git a/app/types/agents.ts b/app/types/agents.ts index 1fb0893..02ef296 100644 --- a/app/types/agents.ts +++ b/app/types/agents.ts @@ -27,7 +27,9 @@ export interface ExecutionPlan { method?: string } takeProfit?: number // target price for take-profit + stopLoss?: number // stop-loss price or absolute value note?: string + _llmReview?: { approved: boolean; notes?: string | null } } export interface TradingDecision { diff --git a/react-router.config.ts b/react-router.config.ts index 5fea3e7..6d7ba1f 100644 --- a/react-router.config.ts +++ b/react-router.config.ts @@ -3,6 +3,6 @@ import type { Config } from "@react-router/dev/config"; export default { // Config options... // Server-side render by default, to enable SPA mode set this to `false` - // Disabled SSR in dev to avoid server-side JSX runtime mismatch causing crashes - ssr: false, + // Server-side render by default, to enable SPA mode set this to `false` + ssr: true, } satisfies Config; diff --git a/tsconfig.json b/tsconfig.json index cbe49c7..39d76ac 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "**/.client/**/*", ".react-router/types/**/*" ], + "exclude": ["node_modules", "tests", "app/lib/__tests__"], "compilerOptions": { "lib": ["DOM", "DOM.Iterable", "ES2022"], "types": ["node", "vite/client"],