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

This commit is contained in:
2026-05-16 20:19:35 +02:00
parent 9b63d981b0
commit 0ee89cf052
38 changed files with 1426 additions and 562 deletions
@@ -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);
});
});
+55
View File
@@ -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");
});
});
+153 -61
View File
@@ -1,6 +1,8 @@
import Alpaca from "@alpacahq/alpaca-trade-api";
function makeAlpaca(mode: 'paper' | 'live' = 'paper') {
type Mode = 'paper' | 'live';
function makeAlpaca(mode: Mode = 'paper') {
const isLive = mode === 'live';
const keyId = isLive ? (process.env.ALPACA_API_KEY_LIVE || process.env.ALPACA_API_KEY) : process.env.ALPACA_API_KEY;
const secretKey = isLive ? (process.env.ALPACA_SECRET_KEY_LIVE || process.env.ALPACA_SECRET_KEY) : process.env.ALPACA_SECRET_KEY;
@@ -15,71 +17,161 @@ function makeAlpaca(mode: 'paper' | 'live' = 'paper') {
});
}
export async function fetchAccount(mode: 'paper' | 'live' = 'paper') {
try {
const client = makeAlpaca(mode);
const account = await client.getAccount();
return {
cash: parseFloat(account.cash),
buying_power: parseFloat(account.buying_power),
portfolio_value: parseFloat(account.portfolio_value),
};
} catch (err: any) {
console.error("alpacaClient: fetchAccount failed:", err);
throw new Error(err?.message || String(err));
class AlpacaService {
private mode: Mode;
private client: any;
private lastBarCache = new Map<string, { bar: any; ts: number }>();
constructor(mode: Mode = 'paper') {
this.mode = mode;
this.client = makeAlpaca(mode);
}
}
export async function fetchRecentCloses(ticker: string, days = 30, mode: 'paper' | 'live' = 'paper') {
try {
const client = makeAlpaca(mode);
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
const barsIter = await client.getBarsV2(ticker, { timeframe: "1D", start: startDate.toISOString().split("T")[0], limit: 1000 });
const barsArray: any[] = [];
for await (const b of barsIter) barsArray.push(b);
const closes = barsArray.map((bar: any) => (typeof bar.ClosePrice === 'number' ? bar.ClosePrice : (typeof bar.c === 'number' ? bar.c : 0))).filter((c: number) => c > 0);
if (closes.length) return closes;
// fallback to latest trade
try {
const trade: any = await client.getLatestTrade(ticker);
const price = trade?.Price || trade?.price || 0;
if (price) return [price];
} catch (tErr) {
console.warn("alpacaClient: getLatestTrade fallback failed:", tErr);
setMode(mode: Mode) {
if (this.mode !== mode) {
this.mode = mode;
this.client = makeAlpaca(mode);
}
}
throw new Error("No recent price data available from Alpaca");
} catch (err: any) {
console.error("alpacaClient: fetchRecentCloses failed:", err);
throw new Error(err?.message || String(err));
getMode() {
return this.mode;
}
async fetchAccount() {
try {
const account = await this.client.getAccount();
return {
cash: parseFloat(account.cash),
buying_power: parseFloat(account.buying_power),
portfolio_value: parseFloat(account.portfolio_value),
};
} catch (err: any) {
console.error("AlpacaService: fetchAccount failed:", err);
throw new Error(err?.message || String(err));
}
}
async fetchRecentCloses(ticker: string, days = 30) {
try {
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
const barsIter = await this.client.getBarsV2(ticker, { timeframe: "1D", start: startDate.toISOString().split("T")[0], limit: 1000 });
const barsArray: any[] = [];
for await (const b of barsIter) barsArray.push(b);
const closes = barsArray.map((bar: any) => (typeof bar.ClosePrice === 'number' ? bar.ClosePrice : (typeof bar.c === 'number' ? bar.c : 0))).filter((c: number) => c > 0);
if (closes.length) return closes;
// fallback to latest trade
try {
const trade: any = await this.client.getLatestTrade(ticker);
const price = trade?.Price || trade?.price || 0;
if (price) return [price];
} catch (tErr) {
console.warn("AlpacaService: getLatestTrade fallback failed:", tErr);
}
throw new Error("No recent price data available from Alpaca");
} catch (err: any) {
console.error("AlpacaService: fetchRecentCloses failed:", err);
throw new Error(err?.message || String(err));
}
}
async fetchLatestBar(ticker: string, timeframe = '1Min') {
const cacheKey = `${ticker}:${timeframe}`;
const maxRetries = 3;
let attempt = 0;
let baseDelay = 500; // ms
try {
while (attempt < maxRetries) {
try {
const barsIter = await this.client.getBarsV2(ticker, { timeframe, limit: 1 });
const barsArr: any[] = [];
for await (const b of barsIter) barsArr.push(b);
const last = barsArr[barsArr.length - 1] || null;
if (last) {
this.lastBarCache.set(cacheKey, { bar: last, ts: Date.now() });
}
return last || (this.lastBarCache.get(cacheKey)?.bar ?? null);
} catch (err: any) {
const msg = err?.message ?? String(err);
// Rate limit -> retry with exponential backoff
if (/429|too many requests/i.test(msg)) {
attempt++;
const backoff = Math.min(baseDelay * Math.pow(2, attempt - 1), 10000);
console.warn(`AlpacaService.fetchLatestBar rate limited, retry ${attempt}/${maxRetries} after ${backoff}ms`);
await new Promise((r) => setTimeout(r, backoff));
continue;
}
// non-rate-limit error -> rethrow
console.error('AlpacaService: fetchLatestBar failed:', err);
throw new Error(err?.message || String(err));
}
}
// exhausted retries, fall back to cache if available
const cached = this.lastBarCache.get(cacheKey);
if (cached) {
console.warn('AlpacaService.fetchLatestBar: returning cached bar after retries');
return cached.bar;
}
return null;
} catch (err: any) {
console.error('AlpacaService: fetchLatestBar final error:', err);
throw new Error(err?.message || String(err));
}
}
async fetchBars(ticker: string, timeframe = '1D', options: any = {}) {
const maxRetries = 3;
let attempt = 0;
let baseDelay = 500;
try {
while (attempt < maxRetries) {
try {
const barsIter = await this.client.getBarsV2(ticker, { timeframe, ...options });
const barsArr: any[] = [];
for await (const b of barsIter) barsArr.push(b);
// update last-bar cache for this ticker/timeframe
if (barsArr.length) {
const cacheKey = `${ticker}:${timeframe}`;
this.lastBarCache.set(cacheKey, { bar: barsArr[barsArr.length - 1], ts: Date.now() });
}
return barsArr;
} catch (err: any) {
const msg = err?.message ?? String(err);
if (/429|too many requests/i.test(msg)) {
attempt++;
const backoff = Math.min(baseDelay * Math.pow(2, attempt - 1), 10000);
console.warn(`AlpacaService.fetchBars rate limited, retry ${attempt}/${maxRetries} after ${backoff}ms`);
await new Promise((r) => setTimeout(r, backoff));
continue;
}
console.error('AlpacaService: fetchBars failed:', err);
throw new Error(err?.message || String(err));
}
}
console.warn('AlpacaService.fetchBars: exhausted retries, returning empty array');
return [];
} catch (err: any) {
console.error('AlpacaService: fetchBars final error:', err);
throw new Error(err?.message || String(err));
}
}
}
export async function fetchLatestBar(ticker: string, timeframe = '1Min', mode: 'paper' | 'live' = 'paper') {
try {
const client = makeAlpaca(mode);
const barsIter = await client.getBarsV2(ticker, { timeframe, limit: 1 });
const barsArr: any[] = [];
for await (const b of barsIter) barsArr.push(b);
const last = barsArr[barsArr.length - 1];
return last || null;
} catch (err: any) {
console.error('alpacaClient: fetchLatestBar failed:', err);
throw new Error(err?.message || String(err));
}
}
// Singleton configured to use paper trading API by default
export const alpacaService = new AlpacaService('paper');
export async function fetchBars(ticker: string, timeframe = '1D', options: any = {}, mode: 'paper' | 'live' = 'paper') {
try {
const client = makeAlpaca(mode);
const barsIter = await client.getBarsV2(ticker, { timeframe, ...options });
const barsArr: any[] = [];
for await (const b of barsIter) barsArr.push(b);
return barsArr;
} catch (err: any) {
console.error('alpacaClient: fetchBars failed:', err);
throw new Error(err?.message || String(err));
}
}
// 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
View File
@@ -5,9 +5,18 @@ export function enrichExecutionPlan(decision: TradingDecision, input: any): Trad
const prices: number[] = input?.technicalData?.prices || [];
const entryPrice = prices.length ? prices[prices.length - 1] : undefined;
// simple ATR approximation: average absolute diff
// ATR approximation: prefer bar-based ATR (high-low average), fall back to price diffs
let atr = 0;
if (prices && prices.length >= 2) {
const bars: any[] = input?.technicalData?.bars || [];
if (bars && bars.length >= 2) {
let sum = 0;
for (const b of bars) {
const high = typeof b.HighPrice === 'number' ? b.HighPrice : (typeof b.h === 'number' ? b.h : 0);
const low = typeof b.LowPrice === 'number' ? b.LowPrice : (typeof b.l === 'number' ? b.l : 0);
sum += Math.max(0, high - low);
}
atr = sum / bars.length;
} else if (prices && prices.length >= 2) {
let sum = 0;
for (let i = 1; i < prices.length; i++) sum += Math.abs(prices[i] - prices[i - 1]);
atr = sum / (prices.length - 1);
+61 -2
View File
@@ -1,6 +1,7 @@
import pkg from "bullmq";
const { Queue, Worker } = pkg as any;
import IORedis from "ioredis";
import { fetchAccount, fetchRecentCloses } from "./alpacaClient";
import { OpenRouterClient } from "./openrouter";
import { TradingGraph } from "../agents/tradingGraph";
import { db } from "./db.server";
@@ -42,7 +43,35 @@ if (REDIS_URL) {
const client = new OpenRouterClient(apiKey);
const graph = new TradingGraph(client);
const decision = await graph.propagate(ticker, input);
// Fetch latest Alpaca account and prices; abort job if unavailable so work runs on fresh data
try {
const account = await fetchAccount();
const prices = await fetchRecentCloses(ticker);
input.account = input.account || account;
input.technicalData = input.technicalData || {};
input.technicalData.prices = input.technicalData.prices && input.technicalData.prices.length ? input.technicalData.prices : prices;
} catch (e) {
console.error("[queue] Failed to fetch Alpaca data, aborting job:", e);
// Throw to mark the job as failed early
throw new Error("Failed to fetch Alpaca data: " + String(e));
}
let decision = await graph.propagate(ticker, input);
// Enrich executionPlan deterministically server-side before persisting
try {
const { enrichExecutionPlan, verifyExecutionPlanWithLLM } = await import("./execution");
decision = enrichExecutionPlan(decision, input);
if (process.env.OPENROUTER_API_KEY) {
try {
decision = await verifyExecutionPlanWithLLM(decision, input);
} catch (e) {
console.warn("[queue] LLM verification failed:", e);
}
}
} catch (e) {
console.warn("[queue] Failed to enrich execution plan:", e);
}
await db.stock.upsert({
where: { ticker },
@@ -164,7 +193,37 @@ if (REDIS_URL) {
}
const client = new OpenRouterClient(process.env.OPENROUTER_API_KEY as string);
const graph = new TradingGraph(client);
const decision = await graph.propagate(job.ticker, job.input);
// Fetch latest Alpaca account and prices; abort job if unavailable so work runs on fresh data
try {
const account = await fetchAccount();
const prices = await fetchRecentCloses(job.ticker);
job.input = job.input || {};
job.input.account = job.input.account || account;
job.input.technicalData = job.input.technicalData || {};
job.input.technicalData.prices = job.input.technicalData.prices && job.input.technicalData.prices.length ? job.input.technicalData.prices : prices;
} catch (e) {
console.error("[inproc queue] Failed to fetch Alpaca data, aborting job:", e);
// throw so the outer catch marks job as failed
throw new Error("Failed to fetch Alpaca data: " + String(e));
}
let decision = await graph.propagate(job.ticker, job.input);
// Enrich executionPlan deterministically server-side before persisting
try {
const { enrichExecutionPlan, verifyExecutionPlanWithLLM } = await import("./execution");
decision = enrichExecutionPlan(decision, job.input);
if (process.env.OPENROUTER_API_KEY) {
try {
decision = await verifyExecutionPlanWithLLM(decision, job.input);
} catch (e) {
console.warn("[inproc queue] LLM verification failed:", e);
}
}
} catch (e) {
console.warn("[inproc queue] Failed to enrich execution plan:", e);
}
job.result = decision;
job.state = "completed";
await db.stock.upsert({