A live book is a cohort of strategies, each with its own account, started on one date with one capital and a flat fee per order (trader.LIVE_BOOKS). The original book keeps its state in data/; a named book in data/books/<book>/. The monitor runs every book; auto/status/log/compare/ replay take --book. A fresh account opens on the book's first session. selected_2026_09: US $10,000 and $1 per order from 2026-09-25, CN ¥50,000 and ¥5 per order from 2026-09-28 (the first CN session after the choice). Strategies beat their benchmark in the Apr-Sep 2026 point-in-time replay and passed a 10-year PIT backtest (US Sharpe >= 0.6, MaxDD > -50%; CN Sharpe >= 0.2, CAGR > 0), no daily variants, at most two frequencies per factor signal, plus the three 2026-09 research strategies. bridge.py live --book and bridge.py books feed a book selector on the live page (/api/strategies/live?book=, /api/strategies/live/books). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1090 lines
37 KiB
Python
1090 lines
37 KiB
Python
"""
|
||
Bridge between the Python engine and the web API (web/src/server/strategyService.ts).
|
||
|
||
Outputs JSON to stdout; all logs go to stderr.
|
||
|
||
Subcommands:
|
||
list — List available strategies with metadata
|
||
backtest — Run backtest(s) and output results as JSON
|
||
live — Read live trader state files and output normalized JSON
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import sys
|
||
from glob import glob
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
|
||
import data_manager
|
||
import metrics
|
||
from backtest_runner import benchmark_curve, run_market
|
||
from research.permanent_yearly import equity_with_cashflows
|
||
from trader import (
|
||
LIVE_BOOKS,
|
||
LIVE_EXCLUDED_STRATEGIES,
|
||
MARKET_FEE_TIERED,
|
||
MONITOR_CAPITAL,
|
||
STRATEGY_REGISTRY,
|
||
book_dir,
|
||
book_strategies,
|
||
convert_quote_currency,
|
||
strategy_data_market,
|
||
strategy_supports_market,
|
||
)
|
||
|
||
MARKET_CURRENCY = {"us": "USD", "cn": "CNY"}
|
||
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Strategy metadata
|
||
# ---------------------------------------------------------------------------
|
||
|
||
STRATEGY_META = {
|
||
# --- Classic stock-picker strategies ---
|
||
"recovery_mom_top10": {
|
||
"label": "Recovery + Momentum Top 10",
|
||
"category": "stock_picker",
|
||
"params": {"top_n": {"type": "number", "default": 10, "min": 5, "max": 50}},
|
||
"markets": ["us", "cn"],
|
||
},
|
||
"recovery_mom_top20": {
|
||
"label": "Recovery + Momentum Top 20",
|
||
"category": "stock_picker",
|
||
"params": {"top_n": {"type": "number", "default": 20, "min": 5, "max": 50}},
|
||
"markets": ["us", "cn"],
|
||
},
|
||
"recovery_mom_top50": {
|
||
"label": "Recovery + Momentum Top 50",
|
||
"category": "stock_picker",
|
||
"params": {"top_n": {"type": "number", "default": 50, "min": 5, "max": 100}},
|
||
"markets": ["us", "cn"],
|
||
},
|
||
"momentum": {
|
||
"label": "Cross-Sectional Momentum (12-1)",
|
||
"category": "stock_picker",
|
||
"params": {"top_n": {"type": "number", "default": 20, "min": 5, "max": 50}},
|
||
"markets": ["us", "cn"],
|
||
},
|
||
"momentum_quality": {
|
||
"label": "Momentum + Quality",
|
||
"category": "stock_picker",
|
||
"params": {"top_n": {"type": "number", "default": 20, "min": 5, "max": 50}},
|
||
"markets": ["us", "cn"],
|
||
},
|
||
"dual_momentum": {
|
||
"label": "Dual Momentum (3-timeframe)",
|
||
"category": "stock_picker",
|
||
"params": {"top_n": {"type": "number", "default": 20, "min": 5, "max": 50}},
|
||
"markets": ["us", "cn"],
|
||
},
|
||
"inverse_vol": {
|
||
"label": "Inverse Volatility (Risk Parity)",
|
||
"category": "stock_picker",
|
||
"params": {},
|
||
"markets": ["us", "cn"],
|
||
},
|
||
"trend_following": {
|
||
"label": "Trend Following (MA150 + Momentum)",
|
||
"category": "stock_picker",
|
||
"params": {"top_n": {"type": "number", "default": 20, "min": 5, "max": 50}},
|
||
"markets": ["us", "cn"],
|
||
},
|
||
"buy_and_hold": {
|
||
"label": "Buy & Hold (Equal Weight)",
|
||
"category": "stock_picker",
|
||
"params": {},
|
||
"markets": ["us", "cn"],
|
||
},
|
||
# --- Factor combo: US ---
|
||
"fc_rec_mfilt_deep_upvol_daily": {
|
||
"label": "Factor: Recovery+UpVol (Daily)",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"fc_rec_mfilt_deep_upvol_weekly": {
|
||
"label": "Factor: Recovery+UpVol (Weekly)",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"fc_rec_mfilt_deep_upvol_biweekly": {
|
||
"label": "Factor: Recovery+UpVol (Biweekly)",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"fc_rec_mfilt_deep_upvol_monthly": {
|
||
"label": "Factor: Recovery+UpVol (Monthly)",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"fc_ma200_mom7m_rec126_daily": {
|
||
"label": "Factor: MA200+Mom7m+Rec126 (Daily)",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"fc_ma200_mom7m_rec126_weekly": {
|
||
"label": "Factor: MA200+Mom7m+Rec126 (Weekly)",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"fc_ma200_mom7m_rec126_biweekly": {
|
||
"label": "Factor: MA200+Mom7m+Rec126 (Biweekly)",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"fc_ma200_mom7m_rec126_monthly": {
|
||
"label": "Factor: MA200+Mom7m+Rec126 (Monthly)",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"fc_rec_mfilt_ma200_daily": {
|
||
"label": "Factor: Recovery+MA200 (Daily)",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"fc_rec_mfilt_ma200_weekly": {
|
||
"label": "Factor: Recovery+MA200 (Weekly)",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"fc_rec_mfilt_ma200_biweekly": {
|
||
"label": "Factor: Recovery+MA200 (Biweekly)",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"fc_rec_mfilt_ma200_monthly": {
|
||
"label": "Factor: Recovery+MA200 (Monthly)",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"fc_mom7m_rec126_daily": {
|
||
"label": "Factor: Mom7m+Rec126 (Daily)",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"fc_mom7m_rec126_weekly": {
|
||
"label": "Factor: Mom7m+Rec126 (Weekly)",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"fc_mom7m_rec126_biweekly": {
|
||
"label": "Factor: Mom7m+Rec126 (Biweekly)",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"fc_mom7m_rec126_monthly": {
|
||
"label": "Factor: Mom7m+Rec126 (Monthly)",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
# --- Factor combo: CN ---
|
||
"fc_up_cap_quality_mom_daily": {
|
||
"label": "Factor: UpCap+QualityMom (Daily)",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["cn"],
|
||
},
|
||
"fc_up_cap_quality_mom_weekly": {
|
||
"label": "Factor: UpCap+QualityMom (Weekly)",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["cn"],
|
||
},
|
||
"fc_up_cap_quality_mom_biweekly": {
|
||
"label": "Factor: UpCap+QualityMom (Biweekly)",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["cn"],
|
||
},
|
||
"fc_up_cap_quality_mom_monthly": {
|
||
"label": "Factor: UpCap+QualityMom (Monthly)",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["cn"],
|
||
},
|
||
"fc_down_resil_qual_mom_daily": {
|
||
"label": "Factor: DownResil+QualMom (Daily)",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["cn"],
|
||
},
|
||
"fc_down_resil_qual_mom_weekly": {
|
||
"label": "Factor: DownResil+QualMom (Weekly)",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["cn"],
|
||
},
|
||
"fc_down_resil_qual_mom_biweekly": {
|
||
"label": "Factor: DownResil+QualMom (Biweekly)",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["cn"],
|
||
},
|
||
"fc_down_resil_qual_mom_monthly": {
|
||
"label": "Factor: DownResil+QualMom (Monthly)",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["cn"],
|
||
},
|
||
"fc_rec63_mom_gap_daily": {
|
||
"label": "Factor: Rec63+MomGap (Daily)",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["cn"],
|
||
},
|
||
"fc_rec63_mom_gap_weekly": {
|
||
"label": "Factor: Rec63+MomGap (Weekly)",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["cn"],
|
||
},
|
||
"fc_rec63_mom_gap_biweekly": {
|
||
"label": "Factor: Rec63+MomGap (Biweekly)",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["cn"],
|
||
},
|
||
"fc_rec63_mom_gap_monthly": {
|
||
"label": "Factor: Rec63+MomGap (Monthly)",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["cn"],
|
||
},
|
||
"fc_up_cap_mom_gap_daily": {
|
||
"label": "Factor: UpCap+MomGap (Daily)",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["cn"],
|
||
},
|
||
"fc_up_cap_mom_gap_weekly": {
|
||
"label": "Factor: UpCap+MomGap (Weekly)",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["cn"],
|
||
},
|
||
"fc_up_cap_mom_gap_biweekly": {
|
||
"label": "Factor: UpCap+MomGap (Biweekly)",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["cn"],
|
||
},
|
||
"fc_up_cap_mom_gap_monthly": {
|
||
"label": "Factor: UpCap+MomGap (Monthly)",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["cn"],
|
||
},
|
||
# --- ETF tactical allocation ---
|
||
"trend_rider_v3_us": {
|
||
"label": "Trend Rider V3 (US ETFs)",
|
||
"category": "tactical_allocation",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"trend_rider_v3_global": {
|
||
"label": "Trend Rider V3 (Global ETFs)",
|
||
"category": "tactical_allocation",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"trend_rider_v3_hk": {
|
||
"label": "Trend Rider V3 (HK ETFs)",
|
||
"category": "tactical_allocation",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"trend_rider_v4": {
|
||
"label": "Trend Rider V4 (Diversified Sleeves)",
|
||
"category": "tactical_allocation",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"trend_rider_v5_us": {
|
||
"label": "Trend Rider V5 (US ETFs, conviction tier)",
|
||
"category": "tactical_allocation",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"trend_rider_v5_panic": {
|
||
"label": "Trend Rider V5 (panic-tuned 1.4/3%)",
|
||
"category": "tactical_allocation",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"trend_rider_v5_global": {
|
||
"label": "Trend Rider V5 (Global ETFs)",
|
||
"category": "tactical_allocation",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"trend_rider_v3_vt28": {
|
||
"label": "Trend Rider V3 + 28% Vol Target",
|
||
"category": "tactical_allocation",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"trend_rider_v3_vt28_ief": {
|
||
"label": "Trend Rider V3 + 28% Vol Target + IEF",
|
||
"category": "tactical_allocation",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"trend_rider_v3_vt32": {
|
||
"label": "Trend Rider V3 + 32% Vol Target",
|
||
"category": "tactical_allocation",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"trend_rider_v3_vt24": {
|
||
"label": "Trend Rider V3 + 24% Vol Target",
|
||
"category": "tactical_allocation",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"trend_rider_v5_vt30": {
|
||
"label": "Trend Rider V5 + 30% Vol Target",
|
||
"category": "tactical_allocation",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
# --- V7: V3 + vol-target + profit-take ---
|
||
"trend_rider_v7": {
|
||
"label": "Trend Rider V7 (VT28% + PT30)",
|
||
"category": "tactical_allocation",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"trend_rider_v7_vt24": {
|
||
"label": "Trend Rider V7 (VT24% + PT30)",
|
||
"category": "tactical_allocation",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"trend_rider_v7_vt32": {
|
||
"label": "Trend Rider V7 (VT32% + PT30)",
|
||
"category": "tactical_allocation",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"trend_rider_v7_vt36": {
|
||
"label": "Trend Rider V7 (VT36% + PT30) ★ SOTA",
|
||
"category": "tactical_allocation",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
# --- Stock-picker ensembles (US S&P 500 universe) ---
|
||
"ensemble_alpha_top10": {
|
||
"label": "Ensemble Alpha Top 10",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"ensemble_alpha_top12": {
|
||
"label": "Ensemble Alpha Top 12",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"ensemble_alpha_top15_tail": {
|
||
"label": "Ensemble Alpha Top 15 + Tail Protection",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"enhanced_factor_combo_top10": {
|
||
"label": "Enhanced Factor Combo Top 10",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"risk_managed_ensemble_top10": {
|
||
"label": "Risk-Managed Ensemble Top 10",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"sharpe_boosted_ensemble_top8": {
|
||
"label": "Sharpe-Boosted Ensemble Top 8",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
"sharpe_boosted_ensemble_top12_rebal63": {
|
||
"label": "Sharpe-Boosted Ensemble Top 12 (Rebal 63d)",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["us"],
|
||
},
|
||
# --- Robust momentum (2026-09 PIT research): US pick, CN picks ---
|
||
"overnight_recovery_top10": {
|
||
"label": "Robust: Overnight Mom + Recovery Top 10",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["us", "cn"],
|
||
},
|
||
"lowrisk_momentum_top15": {
|
||
"label": "Robust: Low-Risk Momentum Top 15",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["us", "cn"],
|
||
},
|
||
"lowbeta_sharpe_mom_top20": {
|
||
"label": "Robust: Low-Beta + Sharpe Mom Top 20",
|
||
"category": "factor_combo",
|
||
"params": {},
|
||
"markets": ["us", "cn"],
|
||
},
|
||
}
|
||
|
||
|
||
def _log(msg: str) -> None:
|
||
print(msg, file=sys.stderr)
|
||
|
||
|
||
def _sanitize_for_json(obj):
|
||
"""Recursively replace NaN/Infinity with None for JSON compatibility."""
|
||
if isinstance(obj, float):
|
||
if np.isnan(obj) or np.isinf(obj):
|
||
return None
|
||
return obj
|
||
if isinstance(obj, dict):
|
||
return {k: _sanitize_for_json(v) for k, v in obj.items()}
|
||
if isinstance(obj, list):
|
||
return [_sanitize_for_json(v) for v in obj]
|
||
return obj
|
||
|
||
|
||
from contextlib import contextmanager
|
||
|
||
@contextmanager
|
||
def _redirect_stdout_to_stderr():
|
||
"""Redirect stdout to stderr so noisy library prints don't pollute JSON."""
|
||
old_stdout = sys.stdout
|
||
sys.stdout = sys.stderr
|
||
try:
|
||
yield
|
||
finally:
|
||
sys.stdout = old_stdout
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def compute_drawdown_periods(equity: pd.Series, threshold: float = -0.05) -> list[dict]:
|
||
"""Find drawdown periods deeper than threshold (e.g. -0.05 = -5%)."""
|
||
peak = equity.cummax()
|
||
dd = (equity - peak) / peak
|
||
periods = []
|
||
in_dd = False
|
||
start = None
|
||
trough_val = 0.0
|
||
|
||
for i, (date, val) in enumerate(dd.items()):
|
||
if not in_dd and val < threshold:
|
||
in_dd = True
|
||
start = date
|
||
trough_val = val
|
||
elif in_dd:
|
||
if val < trough_val:
|
||
trough_val = val
|
||
if val >= 0:
|
||
periods.append({
|
||
"start": start.strftime("%Y-%m-%d"),
|
||
"end": dd.index[i - 1].strftime("%Y-%m-%d") if i > 0 else start.strftime("%Y-%m-%d"),
|
||
"depth": round(float(trough_val), 4),
|
||
"recoveryDate": date.strftime("%Y-%m-%d"),
|
||
})
|
||
in_dd = False
|
||
|
||
# If still in drawdown at end
|
||
if in_dd:
|
||
periods.append({
|
||
"start": start.strftime("%Y-%m-%d"),
|
||
"end": dd.index[-1].strftime("%Y-%m-%d"),
|
||
"depth": round(float(trough_val), 4),
|
||
"recoveryDate": None,
|
||
})
|
||
|
||
return periods
|
||
|
||
|
||
def compute_rebalance_win_rate(equity: pd.Series, trade_events: list[dict]) -> float | None:
|
||
"""Fraction of inter-rebalance periods with positive cumulative return.
|
||
|
||
Each pair of consecutive rebalance dates defines a period; the return
|
||
is equity[next] / equity[this] - 1. Periods with magnitude under 1e-8
|
||
are treated as "no movement" and excluded (consistent with how
|
||
metrics.win_rate treats day-level returns). Returns None when there
|
||
are fewer than two qualifying rebalance dates (e.g. buy-and-hold).
|
||
"""
|
||
if not trade_events or len(equity) < 2:
|
||
return None
|
||
eq = equity.dropna()
|
||
if eq.empty:
|
||
return None
|
||
dates = []
|
||
for ev in trade_events:
|
||
try:
|
||
ts = pd.Timestamp(ev["date"])
|
||
except (KeyError, ValueError, TypeError):
|
||
continue
|
||
if ts in eq.index:
|
||
dates.append(ts)
|
||
dates = sorted(set(dates))
|
||
if len(dates) < 2:
|
||
return None
|
||
periods = []
|
||
for start, end in zip(dates[:-1], dates[1:]):
|
||
try:
|
||
ret = float(eq.at[end] / eq.at[start] - 1.0)
|
||
except (KeyError, ZeroDivisionError):
|
||
continue
|
||
if not np.isfinite(ret):
|
||
continue
|
||
periods.append(ret)
|
||
active = [p for p in periods if abs(p) > 1e-8]
|
||
if not active:
|
||
return None
|
||
return sum(1 for p in active if p > 0) / len(active)
|
||
|
||
|
||
def compute_periodic_returns(equity: pd.Series) -> tuple[dict, dict]:
|
||
"""Monthly and yearly returns from an equity curve.
|
||
|
||
Each period is measured from the previous period's last value (the first
|
||
period from the curve's first value), so the first trading day's move of
|
||
every period is included.
|
||
"""
|
||
def by_period(keys) -> dict:
|
||
ends = equity.groupby(keys).last()
|
||
out = {}
|
||
prev = float(equity.iloc[0])
|
||
for key, end in ends.items():
|
||
if prev > 0:
|
||
out[key] = round(float(end) / prev - 1, 4)
|
||
prev = float(end)
|
||
return out
|
||
|
||
idx = equity.index
|
||
monthly = by_period([f"{d.year}-{d.month:02d}" for d in idx])
|
||
yearly = by_period([str(d.year) for d in idx])
|
||
return monthly, yearly
|
||
|
||
|
||
def build_injection_schedule(equity: pd.Series, schedule: dict) -> tuple[pd.Series, list[dict]]:
|
||
"""Build a contributions Series from injection schedule config.
|
||
|
||
schedule: {"amount": 10000, "frequency": "yearly", "startYear": 2}
|
||
Returns: (contributions Series, list of injection events)
|
||
"""
|
||
amount = schedule.get("amount", 10000)
|
||
frequency = schedule.get("frequency", "yearly")
|
||
start_year = schedule.get("startYear", 2) # year offset
|
||
|
||
first_year = equity.index[0].year
|
||
contributions = pd.Series(0.0, index=equity.index)
|
||
injection_events = []
|
||
|
||
if frequency == "yearly":
|
||
for year_offset in range(start_year - 1, 50):
|
||
target_year = first_year + year_offset
|
||
year_mask = equity.index.year == target_year
|
||
if not year_mask.any():
|
||
continue
|
||
first_day = equity.index[year_mask][0]
|
||
contributions.at[first_day] = amount
|
||
injection_events.append({
|
||
"date": first_day.strftime("%Y-%m-%d"),
|
||
"amount": amount,
|
||
})
|
||
elif frequency == "quarterly":
|
||
quarter_starts = equity.resample("QS").first().index
|
||
for i, date in enumerate(quarter_starts):
|
||
year_offset = (date.year - first_year) + 1
|
||
if year_offset < start_year:
|
||
continue
|
||
if date in equity.index:
|
||
contributions.at[date] = amount
|
||
injection_events.append({
|
||
"date": date.strftime("%Y-%m-%d"),
|
||
"amount": amount,
|
||
})
|
||
elif frequency == "monthly":
|
||
month_starts = equity.resample("MS").first().index
|
||
for date in month_starts:
|
||
year_offset = (date.year - first_year) + 1
|
||
if year_offset < start_year:
|
||
continue
|
||
if date in equity.index:
|
||
contributions.at[date] = amount
|
||
injection_events.append({
|
||
"date": date.strftime("%Y-%m-%d"),
|
||
"amount": amount,
|
||
})
|
||
elif frequency == "yearly_months":
|
||
# Inject on the first trading day of each specified month, every year.
|
||
months = schedule.get("months") or [1]
|
||
last_year = equity.index[-1].year
|
||
for target_year in range(first_year, last_year + 1):
|
||
year_offset = (target_year - first_year) + 1
|
||
if year_offset < start_year:
|
||
continue
|
||
for month in months:
|
||
mask = (equity.index.year == target_year) & (equity.index.month == month)
|
||
if not mask.any():
|
||
continue
|
||
first_day = equity.index[mask][0]
|
||
contributions.at[first_day] = amount
|
||
injection_events.append({
|
||
"date": first_day.strftime("%Y-%m-%d"),
|
||
"amount": amount,
|
||
})
|
||
|
||
return contributions, injection_events
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Subcommand: list
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def cmd_list() -> None:
|
||
"""Output available strategies as JSON."""
|
||
strategies = []
|
||
for name in STRATEGY_REGISTRY:
|
||
meta = STRATEGY_META.get(name, {
|
||
"label": name,
|
||
"category": "other",
|
||
"params": {},
|
||
"markets": ["us", "cn"],
|
||
})
|
||
strategies.append({
|
||
"name": name,
|
||
"label": meta["label"],
|
||
"category": meta["category"],
|
||
"params": meta["params"],
|
||
"markets": [m for m in meta["markets"] if strategy_supports_market(name, m)],
|
||
})
|
||
json.dump(strategies, sys.stdout, indent=2)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Subcommand: backtest
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def cmd_backtest(args: argparse.Namespace) -> None:
|
||
"""Run backtests and output results as JSON."""
|
||
market = args.market
|
||
years = args.years
|
||
capital = args.capital
|
||
fixed_fee = args.fixed_fee
|
||
tx_cost = args.tx_cost
|
||
strategy_names = [s.strip() for s in args.strategies.split(",")]
|
||
|
||
injection_schedule = None
|
||
if args.injection:
|
||
injection_schedule = json.loads(args.injection)
|
||
|
||
_log(f"Running backtest: market={market}, years={years}, capital={capital}")
|
||
_log(f"Strategies: {strategy_names}")
|
||
|
||
known = [n for n in strategy_names if n in STRATEGY_REGISTRY]
|
||
for name in set(strategy_names) - set(known):
|
||
_log(f"WARNING: Unknown strategy '{name}', skipping")
|
||
with _redirect_stdout_to_stderr():
|
||
raw = run_market(market, known, years, capital, tx_cost, fixed_fee,
|
||
**MARKET_FEE_TIERED.get(market, {}))
|
||
results = [
|
||
_build_strategy_result(run["name"], run["equity"], run["trades"], injection_schedule, capital)
|
||
for run in (raw["strategy_runs"] if raw else [])
|
||
]
|
||
benchmark_label = raw["benchmark_label"] if raw else "SPY"
|
||
bench_curve = raw["benchmark_curve"] if raw else []
|
||
|
||
output = {
|
||
"meta": {
|
||
"market": market,
|
||
"years": years,
|
||
"initialCapital": capital,
|
||
"startDate": results[0]["equityCurve"][0]["date"] if results else None,
|
||
"endDate": results[0]["equityCurve"][-1]["date"] if results else None,
|
||
"txCost": tx_cost,
|
||
"fixedFee": fixed_fee,
|
||
"injectionSchedule": injection_schedule,
|
||
},
|
||
"strategies": results,
|
||
"benchmark": {
|
||
"name": benchmark_label,
|
||
"equityCurve": bench_curve,
|
||
},
|
||
}
|
||
|
||
json.dump(_sanitize_for_json(output), sys.stdout, default=str)
|
||
|
||
|
||
def _build_strategy_result(
|
||
name: str,
|
||
equity: pd.Series,
|
||
trade_events: list[dict],
|
||
injection_schedule: dict | None,
|
||
initial_capital: float,
|
||
) -> dict:
|
||
"""Build a strategy result dict with optional injection applied.
|
||
|
||
Return and risk metrics, drawdowns and periodic returns are time-weighted
|
||
(computed on the curve without injections), so injected cash never counts
|
||
as performance. ``capitalFlows`` reports the money view: total invested,
|
||
final value and profit of the injected curve.
|
||
"""
|
||
injection_events = []
|
||
equity_with_inj = None
|
||
|
||
if injection_schedule:
|
||
returns = equity.pct_change().fillna(0.0)
|
||
contributions, injection_events = build_injection_schedule(equity, injection_schedule)
|
||
equity_with_inj = equity_with_cashflows(returns, contributions, initial_capital)
|
||
|
||
# Curve shown to the user: the money curve when cash is injected.
|
||
final_equity = equity_with_inj if equity_with_inj is not None else equity
|
||
invested = initial_capital + sum(ev["amount"] for ev in injection_events)
|
||
final_value = float(final_equity.iloc[-1])
|
||
|
||
# Equity curve
|
||
curve = [{"date": d.strftime("%Y-%m-%d"), "value": round(float(v), 2)}
|
||
for d, v in final_equity.items()]
|
||
|
||
# Base equity curve (without injections) — for comparison
|
||
base_curve = None
|
||
if equity_with_inj is not None:
|
||
base_curve = [{"date": d.strftime("%Y-%m-%d"), "value": round(float(v), 2)}
|
||
for d, v in equity.items()]
|
||
|
||
# Time-weighted metrics on the curve without injections
|
||
strat_metrics = metrics.raw_summary(equity)
|
||
strat_metrics["rebalanceWinRate"] = compute_rebalance_win_rate(
|
||
equity, trade_events,
|
||
)
|
||
|
||
drawdowns = compute_drawdown_periods(equity)
|
||
monthly_ret, yearly_ret = compute_periodic_returns(equity)
|
||
|
||
meta = STRATEGY_META.get(name, {"label": name, "category": "other"})
|
||
|
||
return {
|
||
"name": name,
|
||
"label": meta["label"],
|
||
"equityCurve": curve,
|
||
"baseEquityCurve": base_curve,
|
||
"metrics": strat_metrics,
|
||
"drawdowns": drawdowns,
|
||
"trades": trade_events,
|
||
"injections": injection_events,
|
||
"capitalFlows": {
|
||
"invested": round(invested, 2),
|
||
"finalValue": round(final_value, 2),
|
||
"profit": round(final_value - invested, 2),
|
||
"returnOnInvested": final_value / invested - 1 if invested else None,
|
||
},
|
||
"ruined": bool((equity <= 0).any()),
|
||
"monthlyReturns": monthly_ret,
|
||
"yearlyReturns": yearly_ret,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Subcommand: live
|
||
# ---------------------------------------------------------------------------
|
||
|
||
LIVE_BENCHMARK = {"us": ("SPY", "SPY"), "cn": ("510300.SS", "CSI 300 ETF 510300")}
|
||
|
||
|
||
def _load_panels(markets_needed: set[str]) -> dict[str, pd.DataFrame | None]:
|
||
"""Load each cached close panel once (forward-filled, HK tickers in USD).
|
||
|
||
Reads the on-disk CSVs directly so the live view stays fast and works
|
||
without network access.
|
||
"""
|
||
panels: dict[str, pd.DataFrame | None] = {}
|
||
for mk in markets_needed:
|
||
try:
|
||
df = data_manager.load(mk)
|
||
except Exception as e: # noqa: BLE001 — never fatal
|
||
_log(f" load panel {mk}: {e}")
|
||
df = None
|
||
panels[mk] = convert_quote_currency(df) if df is not None and not df.empty else None
|
||
return panels
|
||
|
||
|
||
def _latest_prices(panel: pd.DataFrame | None) -> dict:
|
||
if panel is None:
|
||
return {"asof": None, "prices": {}}
|
||
last_row = panel.iloc[-1]
|
||
return {
|
||
"asof": panel.index[-1].strftime("%Y-%m-%d"),
|
||
"prices": {col: float(v) for col, v in last_row.items() if pd.notna(v)},
|
||
}
|
||
|
||
|
||
def _last_recorded_prices(state: dict) -> dict[str, float]:
|
||
"""Most recent price recorded in the daily log for each ticker."""
|
||
out: dict[str, float] = {}
|
||
for entry in reversed(state.get("daily_log", []) or []):
|
||
for ticker, pos in (entry.get("holdings") or {}).items():
|
||
if ticker not in out and pos.get("price"):
|
||
out[ticker] = float(pos["price"])
|
||
return out
|
||
|
||
|
||
def cmd_live(args: argparse.Namespace) -> None:
|
||
"""Read live trader state files and output normalized JSON.
|
||
|
||
Each strategy's record contains:
|
||
- Current cash, holdings, marked-to-market value of each holding (latest
|
||
cached price, falling back to the last recorded price), and total assets.
|
||
- Daily equity curve starting from the initial capital, plus the market
|
||
benchmark rebased to the same capital over the same dates.
|
||
- Full trade log (one entry per ticker per fill) + day-aggregated view.
|
||
- Metrics, drawdowns, monthly/yearly returns.
|
||
"""
|
||
market = args.market
|
||
prefix = f"trader_{market}_"
|
||
book = args.book or None
|
||
state_files = sorted(glob(os.path.join(book_dir(book), f"{prefix}*.json")))
|
||
wanted = args.strategy
|
||
in_book = set(book_strategies(book, market)) if book else None
|
||
|
||
panels = _load_panels({market, "etfs"})
|
||
bench_ticker, bench_label = LIVE_BENCHMARK[market]
|
||
bench_panel = panels.get(market)
|
||
bench_prices = (
|
||
bench_panel[bench_ticker]
|
||
if bench_panel is not None and bench_ticker in bench_panel.columns
|
||
else pd.Series(dtype=float)
|
||
)
|
||
|
||
results = []
|
||
for path in state_files:
|
||
strategy_name = os.path.basename(path)[len(prefix):-len(".json")]
|
||
if strategy_name.startswith("sim_"):
|
||
continue
|
||
if wanted and strategy_name != wanted:
|
||
continue
|
||
if strategy_name in LIVE_EXCLUDED_STRATEGIES or not strategy_supports_market(strategy_name, market):
|
||
_log(f" Skipping {strategy_name}: not a live strategy in the {market.upper()} book")
|
||
continue
|
||
if in_book is not None and strategy_name not in in_book:
|
||
_log(f" Skipping {strategy_name}: no longer in book {book}")
|
||
continue
|
||
|
||
try:
|
||
with open(path) as f:
|
||
state = json.load(f)
|
||
except (json.JSONDecodeError, OSError) as e:
|
||
_log(f" Skipping {path}: {e}")
|
||
continue
|
||
|
||
data_market = strategy_data_market(market, strategy_name)
|
||
price_book = _latest_prices(panels.get(data_market))
|
||
recorded = _last_recorded_prices(state)
|
||
|
||
holdings_raw = state.get("holdings", {}) or {}
|
||
cash = float(state.get("cash", 0) or 0)
|
||
|
||
holdings_detail = []
|
||
market_value = 0.0
|
||
for ticker, shares in holdings_raw.items():
|
||
shares_f = float(shares)
|
||
price = price_book["prices"].get(ticker)
|
||
stale = price is None
|
||
if stale:
|
||
price = recorded.get(ticker)
|
||
value = shares_f * price if price is not None else None
|
||
if value is not None:
|
||
market_value += value
|
||
holdings_detail.append({
|
||
"ticker": ticker,
|
||
"shares": shares_f,
|
||
"lastPrice": float(price) if price is not None else None,
|
||
"marketValue": round(value, 2) if value is not None else None,
|
||
"priceStale": stale,
|
||
})
|
||
holdings_detail.sort(key=lambda h: (-(h["marketValue"] or 0.0), h["ticker"]))
|
||
|
||
initial_capital = float(state.get("initial_capital", 0) or 0)
|
||
daily_equity = state.get("daily_equity", {}) or {}
|
||
dates = sorted(daily_equity.keys())
|
||
equity_series = pd.Series(
|
||
{pd.Timestamp(d): float(daily_equity[d]) for d in dates}, dtype=float,
|
||
).sort_index()
|
||
if len(equity_series) and initial_capital:
|
||
# Anchor at the initial capital one day before the first session so
|
||
# the first day's P&L is part of every metric.
|
||
anchor = equity_series.index[0] - pd.Timedelta(days=1)
|
||
equity_series = pd.concat([pd.Series({anchor: initial_capital}), equity_series])
|
||
equity_curve = [
|
||
{"date": d.strftime("%Y-%m-%d"), "value": round(float(v), 2)}
|
||
for d, v in equity_series.items()
|
||
]
|
||
|
||
strat_metrics = metrics.raw_summary(equity_series) if len(equity_series) >= 2 else None
|
||
|
||
trade_log_raw = state.get("trade_log", []) or []
|
||
trade_by_date: dict[str, dict] = {}
|
||
trade_log = []
|
||
for t in trade_log_raw:
|
||
d = t.get("date")
|
||
if not d:
|
||
continue
|
||
entry = {
|
||
"date": d,
|
||
"action": t.get("action"),
|
||
"ticker": t.get("ticker"),
|
||
"shares": float(t.get("shares", 0) or 0),
|
||
"price": float(t.get("price", 0) or 0),
|
||
"value": float(t.get("value", 0) or 0),
|
||
"commission": float(t.get("commission", 0) or 0),
|
||
}
|
||
trade_log.append(entry)
|
||
agg = trade_by_date.setdefault(
|
||
d, {"buys": 0, "sells": 0, "buyTickers": [], "sellTickers": []},
|
||
)
|
||
if entry["action"] == "BUY":
|
||
agg["buys"] += 1
|
||
if len(agg["buyTickers"]) < 3:
|
||
agg["buyTickers"].append(entry["ticker"])
|
||
else:
|
||
agg["sells"] += 1
|
||
if len(agg["sellTickers"]) < 3:
|
||
agg["sellTickers"].append(entry["ticker"])
|
||
trades = [{"date": d, **v} for d, v in sorted(trade_by_date.items())]
|
||
|
||
drawdowns = compute_drawdown_periods(equity_series) if len(equity_series) >= 2 else []
|
||
monthly_ret, yearly_ret = (
|
||
compute_periodic_returns(equity_series) if len(equity_series) >= 2 else ({}, {})
|
||
)
|
||
|
||
meta = STRATEGY_META.get(strategy_name, {"label": strategy_name, "category": "other"})
|
||
|
||
# Current value: live mark-to-market of holdings plus cash.
|
||
live_value = cash + market_value
|
||
if not holdings_detail and dates:
|
||
live_value = float(daily_equity[dates[-1]])
|
||
|
||
unrealized_pnl = live_value - initial_capital if initial_capital else 0.0
|
||
unrealized_pnl_pct = unrealized_pnl / initial_capital if initial_capital else 0.0
|
||
|
||
bench_curve = (
|
||
benchmark_curve(bench_prices, pd.DatetimeIndex(equity_series.index[1:]), initial_capital)
|
||
if len(equity_series) >= 2 and len(bench_prices) else []
|
||
)
|
||
|
||
results.append({
|
||
"name": strategy_name,
|
||
"label": meta.get("label", strategy_name),
|
||
"category": meta.get("category", "other"),
|
||
"market": market,
|
||
"currency": MARKET_CURRENCY.get(market, "USD"),
|
||
"initialCapital": initial_capital,
|
||
"startDate": dates[0] if dates else None,
|
||
"currentValue": round(live_value, 2),
|
||
"cash": round(cash, 2),
|
||
"marketValue": round(market_value, 2),
|
||
"unrealizedPnl": round(unrealized_pnl, 2),
|
||
"unrealizedPnlPct": unrealized_pnl_pct,
|
||
"holdings": {h["ticker"]: h["shares"] for h in holdings_detail},
|
||
"holdingsDetail": holdings_detail,
|
||
"priceAsOf": price_book["asof"],
|
||
"equityCurve": equity_curve,
|
||
"benchmark": {"name": bench_label, "equityCurve": bench_curve},
|
||
"metrics": strat_metrics,
|
||
"drawdowns": drawdowns,
|
||
"trades": trades,
|
||
"tradeLog": trade_log,
|
||
"monthlyReturns": monthly_ret,
|
||
"yearlyReturns": yearly_ret,
|
||
"lastUpdated": dates[-1] if dates else None,
|
||
})
|
||
|
||
json.dump(_sanitize_for_json(results), sys.stdout, default=str)
|
||
|
||
|
||
def cmd_books() -> None:
|
||
"""Live books: the original book first, then the named ones (newest last)."""
|
||
books = [{"id": "", "label": "全部策略(2026-04 起回放)", "startDate": None,
|
||
"capital": MONITOR_CAPITAL, "feePerOrder": None,
|
||
"strategies": {m: book_strategies(None, m) for m in ("us", "cn")}}]
|
||
for book_id, cfg in LIVE_BOOKS.items():
|
||
books.append({"id": book_id, "label": cfg["label"], "startDate": cfg["start"],
|
||
"capital": cfg["capital"], "feePerOrder": cfg["fee_per_order"],
|
||
"strategies": {m: book_strategies(book_id, m) for m in ("us", "cn")}})
|
||
json.dump(books, sys.stdout)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# CLI
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def main() -> None:
|
||
parser = argparse.ArgumentParser(description="Quant bridge for the web API")
|
||
subparsers = parser.add_subparsers(dest="command")
|
||
|
||
# list
|
||
subparsers.add_parser("list", help="List available strategies")
|
||
|
||
# backtest
|
||
bt_parser = subparsers.add_parser("backtest", help="Run backtest(s)")
|
||
bt_parser.add_argument("--strategies", required=True, help="Comma-separated strategy names")
|
||
bt_parser.add_argument("--market", default="us", choices=["us", "cn"])
|
||
bt_parser.add_argument("--years", type=int, default=10)
|
||
bt_parser.add_argument("--capital", type=float, default=100000)
|
||
bt_parser.add_argument("--fixed-fee", type=float, default=2.0)
|
||
bt_parser.add_argument("--tx-cost", type=float, default=0.001)
|
||
bt_parser.add_argument("--injection", default=None, help="JSON injection schedule")
|
||
|
||
# live
|
||
live_parser = subparsers.add_parser("live", help="Read live trader states")
|
||
live_parser.add_argument("--market", default="us", choices=["us", "cn"])
|
||
live_parser.add_argument("--strategy", default=None, help="Filter to specific strategy")
|
||
live_parser.add_argument("--book", default="", choices=["", *LIVE_BOOKS],
|
||
help="Named live book (default: the original book)")
|
||
|
||
# books
|
||
subparsers.add_parser("books", help="List live books")
|
||
|
||
args = parser.parse_args()
|
||
|
||
if args.command == "list":
|
||
cmd_list()
|
||
elif args.command == "backtest":
|
||
cmd_backtest(args)
|
||
elif args.command == "live":
|
||
cmd_live(args)
|
||
elif args.command == "books":
|
||
cmd_books()
|
||
else:
|
||
parser.print_help(sys.stderr)
|
||
sys.exit(1)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|