Files
quant-trading/web/src/server/strategyService.ts
Gahow Wang f54041646f feat(strategy): add backtest strategy module
Introduces strategyTypes shared schema and a server-side strategyService
that lists strategies, runs backtests, and caches results.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:20:50 +08:00

179 lines
5.9 KiB
TypeScript

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<string, unknown>)[key])}`)
.join(",")}}`;
}
return JSON.stringify(value);
}
function runBridge(args: string[]): Promise<unknown> {
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<StrategyInfo[]> {
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<BacktestResult> {
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<BacktestResult, "id" | "computedAt">),
computedAt: new Date().toISOString(),
});
// Cache result
writeFileSync(cachePath, `${JSON.stringify(result)}\n`);
evictOldCache();
return result;
}
export async function getCachedResult(id: string): Promise<BacktestResult | null> {
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<LiveStrategyState[]> {
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 ?? []
}
};
}