diff --git a/web/src/server/strategyService.ts b/web/src/server/strategyService.ts new file mode 100644 index 0000000..bc206e3 --- /dev/null +++ b/web/src/server/strategyService.ts @@ -0,0 +1,178 @@ +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, unlinkSync } from "node:fs"; +import { join, resolve } from "node:path"; +import type { BacktestConfig, BacktestResult, LiveStrategyState, StrategyInfo } from "../shared/strategyTypes.js"; + +const QUANT_DIR = resolve(process.env.QUANT_PROJECT_DIR || join(process.cwd(), "../quant")); +const CACHE_DIR = join(process.env.STOCK_AGENT_DATA_DIR || join(process.cwd(), "data"), "strategy_cache"); +const TIMEOUT_MS = Number(process.env.QUANT_BACKTEST_TIMEOUT_MS) || 180_000; +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; +const MAX_CACHE_FILES = 50; + +function ensureCacheDir(): void { + if (!existsSync(CACHE_DIR)) { + mkdirSync(CACHE_DIR, { recursive: true }); + } +} + +function configHash(config: BacktestConfig): string { + const serialized = stableStringify(config); + return createHash("md5").update(serialized).digest("hex").slice(0, 12); +} + +function stableStringify(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item)).join(",")}]`; + } + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify((value as Record)[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function runBridge(args: string[]): Promise { + return new Promise((resolve, reject) => { + const proc = spawn("uv", ["run", "python", "bridge.py", ...args], { + cwd: QUANT_DIR, + env: { ...process.env, PYTHONUNBUFFERED: "1" }, + }); + + const chunks: Buffer[] = []; + let stderr = ""; + let exitCode: number | null = null; + let stdoutEnded = false; + let exited = false; + + function tryFinish() { + if (!stdoutEnded || !exited) return; + clearTimeout(timer); + const stdout = Buffer.concat(chunks).toString("utf-8"); + if (exitCode !== 0) { + reject(new Error(`bridge.py exited with code ${exitCode}: ${stderr.slice(-1000)}`)); + } else { + try { + resolve(JSON.parse(stdout)); + } catch { + reject(new Error(`Failed to parse bridge output (${stdout.length} bytes): ${stdout.slice(0, 200)}`)); + } + } + } + + proc.stdout.on("data", (chunk: Buffer) => { chunks.push(chunk); }); + proc.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString(); }); + proc.stdout.on("end", () => { stdoutEnded = true; tryFinish(); }); + proc.on("close", (code) => { exitCode = code; exited = true; tryFinish(); }); + + const timer = setTimeout(() => { + proc.kill("SIGTERM"); + reject(new Error(`Bridge timeout after ${TIMEOUT_MS}ms. stderr: ${stderr.slice(-500)}`)); + }, TIMEOUT_MS); + + proc.on("error", (err) => { + clearTimeout(timer); + reject(new Error(`Failed to spawn bridge.py: ${err.message}`)); + }); + }); +} + +function evictOldCache(): void { + ensureCacheDir(); + const files = readdirSync(CACHE_DIR) + .filter((f) => f.endsWith(".json")) + .map((f) => ({ name: f, mtime: statSync(join(CACHE_DIR, f)).mtimeMs })) + .sort((a, b) => b.mtime - a.mtime); + + while (files.length > MAX_CACHE_FILES) { + const old = files.pop()!; + unlinkSync(join(CACHE_DIR, old.name)); + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +let cachedStrategies: { data: StrategyInfo[]; fetchedAt: number } | null = null; + +export async function listStrategies(): Promise { + if (cachedStrategies && Date.now() - cachedStrategies.fetchedAt < CACHE_TTL_MS) { + return cachedStrategies.data; + } + const data = (await runBridge(["list"])) as StrategyInfo[]; + cachedStrategies = { data, fetchedAt: Date.now() }; + return data; +} + +export async function runBacktest(config: BacktestConfig): Promise { + const id = configHash(config); + ensureCacheDir(); + + // Check cache + const cachePath = join(CACHE_DIR, `${id}.json`); + if (existsSync(cachePath)) { + const stat = statSync(cachePath); + if (Date.now() - stat.mtimeMs < CACHE_TTL_MS) { + return normalizeBacktestResult(JSON.parse(readFileSync(cachePath, "utf-8")) as BacktestResult); + } + } + + // Run backtest + const args = [ + "backtest", + "--strategies", config.strategies.join(","), + "--market", config.market, + "--years", String(config.years), + "--capital", String(config.initialCapital), + "--fixed-fee", String(config.fixedFee), + "--tx-cost", String(config.txCost), + ]; + + if (config.injectionSchedule) { + args.push("--injection", JSON.stringify(config.injectionSchedule)); + } + + const raw = await runBridge(args); + + const result = normalizeBacktestResult({ + id, + ...(raw as Omit), + computedAt: new Date().toISOString(), + }); + + // Cache result + writeFileSync(cachePath, `${JSON.stringify(result)}\n`); + evictOldCache(); + + return result; +} + +export async function getCachedResult(id: string): Promise { + ensureCacheDir(); + const cachePath = join(CACHE_DIR, `${id}.json`); + if (!existsSync(cachePath)) return null; + return normalizeBacktestResult(JSON.parse(readFileSync(cachePath, "utf-8")) as BacktestResult); +} + +export async function getLiveStrategies(market: string, strategy?: string): Promise { + const args = ["live", "--market", market]; + if (strategy) args.push("--strategy", strategy); + return (await runBridge(args)) as LiveStrategyState[]; +} + +function normalizeBacktestResult(result: BacktestResult): BacktestResult { + return { + ...result, + strategies: result.strategies.map((strategy) => ({ + ...strategy, + label: typeof strategy.label === "string" && strategy.label.trim() ? strategy.label : strategy.name + })), + benchmark: { + name: result.benchmark?.name || "Benchmark", + equityCurve: result.benchmark?.equityCurve ?? [] + } + }; +} diff --git a/web/src/shared/strategyTypes.ts b/web/src/shared/strategyTypes.ts new file mode 100644 index 0000000..455625a --- /dev/null +++ b/web/src/shared/strategyTypes.ts @@ -0,0 +1,161 @@ +export type StrategyMarket = "us" | "cn"; +export type StrategyCategory = "stock_picker" | "tactical_allocation" | "factor_combo" | "other"; + +export interface StrategyParam { + type: "number" | "string" | "select"; + default: number | string; + options?: string[]; + min?: number; + max?: number; +} + +export interface StrategyInfo { + name: string; + label: string; + category: StrategyCategory; + params: Record; + markets: StrategyMarket[]; +} + +export interface InjectionSchedule { + amount: number; + frequency: "yearly" | "quarterly" | "monthly" | "yearly_months"; + startYear: number; + months?: number[]; +} + +export interface PrecomputedScenarioInfo { + id: string; + label: string; + description?: string; + capital: number; + injectionSchedule: InjectionSchedule | null; + availableMarkets: StrategyMarket[]; +} + +export interface PrecomputedIndex { + computedAt: string; + scenarios: PrecomputedScenarioInfo[]; +} + +export interface BacktestConfig { + strategies: string[]; + market: StrategyMarket; + years: number; + initialCapital: number; + fixedFee: number; + txCost: number; + injectionSchedule?: InjectionSchedule; +} + +export interface EquityPoint { + date: string; + value: number; +} + +export interface DrawdownPeriod { + start: string; + end: string; + depth: number; + recoveryDate: string | null; +} + +export interface TradeEvent { + date: string; + buys: number; + sells: number; + buyTickers: string[]; + sellTickers: string[]; +} + +export interface InjectionEvent { + date: string; + amount: number; +} + +export interface StrategyMetrics { + totalReturn: number; + annualizedReturn: number; + annualizedVolatility: number; + sharpeRatio: number; + sortinoRatio: number; + maxDrawdown: number; + calmarRatio: number; + winRate: number; + // Fraction of inter-rebalance periods with positive return. Null for + // strategies with fewer than two rebalances in the window (buy-and-hold). + rebalanceWinRate: number | null; +} + +export interface StrategyResult { + name: string; + label: string; + equityCurve: EquityPoint[]; + baseEquityCurve: EquityPoint[] | null; + metrics: StrategyMetrics; + drawdowns: DrawdownPeriod[]; + trades: TradeEvent[]; + injections: InjectionEvent[]; + monthlyReturns: Record; + yearlyReturns: Record; +} + +export interface BacktestMeta { + market: StrategyMarket; + years: number; + initialCapital: number; + startDate: string | null; + endDate: string | null; + txCost: number; + fixedFee: number; + injectionSchedule: InjectionSchedule | null; +} + +export interface BacktestResult { + id: string; + meta: BacktestMeta; + strategies: StrategyResult[]; + benchmark: { name: string; equityCurve: EquityPoint[] }; + computedAt: string; +} + +export interface HoldingDetail { + ticker: string; + shares: number; + lastPrice: number | null; + marketValue: number | null; +} + +export interface TradeLogEntry { + date: string; + action: "BUY" | "SELL"; + ticker: string; + shares: number; + price: number; + value: number; + commission: number; +} + +export interface LiveStrategyState { + name: string; + label: string; + category: StrategyCategory | string; + market: StrategyMarket; + initialCapital: number; + currentValue: number; + cash: number; + marketValue: number; + unrealizedPnl: number; + unrealizedPnlPct: number; + holdings: Record; + holdingsDetail: HoldingDetail[]; + priceAsOf: string | null; + equityCurve: EquityPoint[]; + metrics: StrategyMetrics | null; + drawdowns: DrawdownPeriod[]; + trades: TradeEvent[]; + tradeLog: TradeLogEntry[]; + monthlyReturns: Record; + yearlyReturns: Record; + lastUpdated: string | null; +}