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>
This commit is contained in:
178
src/server/strategyService.ts
Normal file
178
src/server/strategyService.ts
Normal file
@@ -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<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 ?? []
|
||||
}
|
||||
};
|
||||
}
|
||||
161
src/shared/strategyTypes.ts
Normal file
161
src/shared/strategyTypes.ts
Normal file
@@ -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<string, StrategyParam>;
|
||||
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<string, number>;
|
||||
yearlyReturns: Record<string, number>;
|
||||
}
|
||||
|
||||
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<string, number>;
|
||||
holdingsDetail: HoldingDetail[];
|
||||
priceAsOf: string | null;
|
||||
equityCurve: EquityPoint[];
|
||||
metrics: StrategyMetrics | null;
|
||||
drawdowns: DrawdownPeriod[];
|
||||
trades: TradeEvent[];
|
||||
tradeLog: TradeLogEntry[];
|
||||
monthlyReturns: Record<string, number>;
|
||||
yearlyReturns: Record<string, number>;
|
||||
lastUpdated: string | null;
|
||||
}
|
||||
Reference in New Issue
Block a user