Files
quant-trading/bridge.py
Gahow Wang 36cb125719 live: commit the TrendRider V9/V10 registrations the monitor already runs
trader.py registers trend_rider_v9 (+ vt24/vt32/vt36) and trend_rider_v10
(+ vt32/vt36) from strategies/trend_rider_v9.py, and bridge.py labels them.
These were left uncommitted by the 2026-10-07 research session while the
monitor ran them from the working tree (state files data/trader_us_trend_rider_v9*/v10*).
research/tr9_*.py is the research trail CLAUDE.md cites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:20:28 -07:00

1175 lines
41 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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, EXECUTION_VARIANTS,
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)",
"category": "tactical_allocation",
"params": {},
"markets": ["us"],
},
"trend_rider_v9": {
"label": "Trend Rider V9 (VT34% + PT30 + trail20)",
"category": "tactical_allocation",
"params": {},
"markets": ["us"],
},
"trend_rider_v9_vt24": {
"label": "Trend Rider V9 (VT24% + PT30 + trail20)",
"category": "tactical_allocation",
"params": {},
"markets": ["us"],
},
"trend_rider_v9_vt32": {
"label": "Trend Rider V9 (VT32% + PT30 + trail20)",
"category": "tactical_allocation",
"params": {},
"markets": ["us"],
},
"trend_rider_v9_vt36": {
"label": "Trend Rider V9 (VT36% + PT30 + trail20)",
"category": "tactical_allocation",
"params": {},
"markets": ["us"],
},
"trend_rider_v10": {
"label": "Trend Rider V10 (VT34% + PT28/8pp/lock10 + trail20) ★ SOTA",
"category": "tactical_allocation",
"params": {},
"markets": ["us"],
},
"trend_rider_v10_vt32": {
"label": "Trend Rider V10 (VT32% + PT28/8pp/lock10 + trail20)",
"category": "tactical_allocation",
"params": {},
"markets": ["us"],
},
"trend_rider_v10_vt36": {
"label": "Trend Rider V10 (VT36% + PT28/8pp/lock10 + trail20)",
"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"],
},
# --- US portfolio research (2026-09-29) ---
"lowrisk_top20": {
"label": "Robust: Low Risk (IVol + Beta) Top 20",
"category": "factor_combo",
"params": {},
"markets": ["us", "cn"],
},
"us_three_sleeve": {
"label": "US 3-Sleeve: Momentum + Low Risk + Trend Rider (1/3 each)",
"category": "tactical_allocation",
"params": {},
"markets": ["us"],
},
"us_momentum_qqq_trend": {
"label": "US Momentum + QQQ Trend (1/2 each)",
"category": "tactical_allocation",
"params": {},
"markets": ["us"],
},
# --- Agent-reviewed (live from 2026-09-29; no backtest of its own) ---
"overnight_recovery_agent_top10": {
"label": "Agent Review: Overnight Mom + Recovery Top 50 -> 10",
"category": "stock_picker",
"params": {},
"markets": ["us"],
},
}
for _name, (_base, _market) in EXECUTION_VARIANTS.items():
STRATEGY_META[_name] = {**STRATEGY_META[_base], "markets": [_market],
"label": STRATEGY_META[_base]["label"] + " · 20%再平衡带(前瞻候选)"}
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,
execution_plan=run.get("_plan"), fee_params=run.get("_fees"))
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": raw.get("actual_years", years) if raw else years,
"requestedYears": 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,
"engineVersion": raw.get("engine_version") if raw else None,
"warnings": raw.get("warnings", []) if raw else [],
"coverage": raw.get("coverage") if raw else None,
"errors": raw.get("errors", []) if raw else [],
},
"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,
*, execution_plan: dict | None = None, fee_params: dict | None = None,
) -> dict:
"""Build a strategy result dict with optional injection applied.
Return and risk metrics, drawdowns and periodic returns are time-weighted
(computed from cash-flow-adjusted NAV), so injected cash never counts
as performance. The orders and fees are replayed at the actual capital. ``capitalFlows`` reports the money view: total invested,
final value and profit of the injected curve.
"""
injection_events = []
equity_with_inj = None
metric_equity = equity
if injection_schedule:
if execution_plan is None or fee_params is None:
raise ValueError("Cash injections require an order replay; scaling a return curve is not supported")
from backtest_runner import replay_prepared
days = execution_plan["prices"].index
contributions, injection_events = build_injection_schedule(equity.reindex(days), injection_schedule)
replay = replay_prepared(execution_plan, initial_capital, contributions=contributions, **fee_params)
equity_with_inj = replay["equity"]
metric_equity = replay["unit_equity"]
trade_events = replay["trades"]
# 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()]
strat_metrics = metrics.raw_summary(metric_equity)
strat_metrics["rebalanceWinRate"] = compute_rebalance_win_rate(metric_equity, trade_events)
drawdowns = compute_drawdown_periods(metric_equity)
monthly_ret, yearly_ret = compute_periodic_returns(metric_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": None if "fee_schedule" in cfg else cfg["fee_per_order"],
"feeSchedule": cfg.get("fee_schedule"),
"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()