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 ?? [] } }; }