- Register trend_rider_v7_vt36 (target_vol=0.36, min_lev=0.75) in strategy registry, ETF universe map, and bridge metadata. 10y backtest: Ann 60.5%, Sharpe 1.87, MaxDD -29.2%. - Add hot-reload to monitor: each phase re-imports trader module to pick up newly registered strategies without restart. New strategies are logged on detection. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1193 lines
41 KiB
Python
1193 lines
41 KiB
Python
"""
|
|
Bridge script for stock-agent integration.
|
|
|
|
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
|
|
import universe_history as uh
|
|
from main import backtest
|
|
from research.permanent_yearly import equity_with_cashflows
|
|
from trader import (
|
|
ETF_STRATEGY_UNIVERSES,
|
|
STRATEGY_REGISTRY,
|
|
strategy_universe,
|
|
strategy_data_market,
|
|
load_state,
|
|
filter_tradable_tickers,
|
|
)
|
|
from universe import UNIVERSES
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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"],
|
|
},
|
|
}
|
|
|
|
|
|
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_trades(weights: pd.DataFrame, threshold: float = 0.01) -> list[dict]:
|
|
"""Extract significant trade events from weight changes."""
|
|
diff = weights.diff().fillna(0.0)
|
|
trades = []
|
|
for date in diff.index:
|
|
row = diff.loc[date]
|
|
significant = row[row.abs() > threshold]
|
|
if significant.empty:
|
|
continue
|
|
buys = significant[significant > 0]
|
|
sells = significant[significant < 0]
|
|
if len(buys) > 0 or len(sells) > 0:
|
|
trades.append({
|
|
"date": date.strftime("%Y-%m-%d"),
|
|
"buys": len(buys),
|
|
"sells": len(sells),
|
|
"buyTickers": list(buys.nlargest(3).index),
|
|
"sellTickers": list(sells.nsmallest(3).index),
|
|
})
|
|
return trades
|
|
|
|
|
|
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]:
|
|
"""Compute monthly and yearly returns from an equity curve."""
|
|
monthly = {}
|
|
yearly = {}
|
|
|
|
# Monthly returns
|
|
for (year, month), group in equity.groupby([equity.index.year, equity.index.month]):
|
|
if len(group) < 2:
|
|
continue
|
|
ret = group.iloc[-1] / group.iloc[0] - 1
|
|
monthly[f"{year}-{month:02d}"] = round(float(ret), 4)
|
|
|
|
# Yearly returns
|
|
for year, group in equity.groupby(equity.index.year):
|
|
if len(group) < 2:
|
|
continue
|
|
ret = group.iloc[-1] / group.iloc[0] - 1
|
|
yearly[str(year)] = round(float(ret), 4)
|
|
|
|
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": meta["markets"],
|
|
})
|
|
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}")
|
|
|
|
# Determine data needs
|
|
# Group strategies by their data source
|
|
stock_strategies = []
|
|
etf_strategies = []
|
|
for name in strategy_names:
|
|
if name not in STRATEGY_REGISTRY:
|
|
_log(f"WARNING: Unknown strategy '{name}', skipping")
|
|
continue
|
|
base_name = name.removeprefix("sim_")
|
|
if base_name in ETF_STRATEGY_UNIVERSES:
|
|
etf_strategies.append(name)
|
|
else:
|
|
stock_strategies.append(name)
|
|
|
|
results = []
|
|
|
|
# Process stock strategies
|
|
if stock_strategies:
|
|
universe = UNIVERSES[market]
|
|
with _redirect_stdout_to_stderr():
|
|
tickers = universe["fetch"]()
|
|
benchmark_ticker = universe["benchmark"]
|
|
benchmark_label = universe["benchmark_label"]
|
|
|
|
# PIT universe: for US market, include all tickers that were EVER
|
|
# S&P 500 members so that removed stocks are present in price data.
|
|
pit_intervals = None
|
|
if market == "us":
|
|
with _redirect_stdout_to_stderr():
|
|
pit_intervals = uh.load_sp500_history()
|
|
historical_tickers = uh.all_tickers_ever(pit_intervals)
|
|
all_tickers = sorted(set(tickers + historical_tickers + [benchmark_ticker]))
|
|
_log(f"PIT universe: {len(all_tickers)} tickers (current + historical members)")
|
|
else:
|
|
all_tickers = sorted(set(tickers + [benchmark_ticker]))
|
|
_log(f"WARNING: {market.upper()} market lacks PIT membership history; "
|
|
"backtest uses current index constituents (survivorship bias possible)")
|
|
|
|
_log(f"Fetching price data for {len(all_tickers)} tickers...")
|
|
with _redirect_stdout_to_stderr():
|
|
data = data_manager.update(market, all_tickers, with_open=False)
|
|
if isinstance(data, tuple):
|
|
data = data[0]
|
|
|
|
if years:
|
|
cutoff = data.index[-1] - pd.DateOffset(years=years)
|
|
data = data[data.index >= cutoff]
|
|
|
|
# Apply PIT mask: set prices to NaN for dates when a stock was NOT
|
|
# an index member. Strategies naturally skip NaN entries.
|
|
if pit_intervals is not None:
|
|
_log("Applying PIT membership mask (survivorship-bias fix)...")
|
|
data = uh.mask_prices(data, pit_intervals)
|
|
|
|
tickers = filter_tradable_tickers(data, tickers)
|
|
# For PIT mode, expand tickers to all columns with valid data
|
|
# (historical members that have been masked are NaN when not members,
|
|
# but have valid data during their membership period).
|
|
if pit_intervals is not None:
|
|
all_data_tickers = [
|
|
t for t in data.columns
|
|
if t != benchmark_ticker and data[t].notna().any()
|
|
]
|
|
tickers = all_data_tickers
|
|
_log(f"Universe: {len(tickers)} tradable stocks, period: {data.index[0].date()} to {data.index[-1].date()}")
|
|
|
|
for name in stock_strategies:
|
|
_log(f" Running: {name}")
|
|
strategy_fn = STRATEGY_REGISTRY[name]
|
|
# Derive top_n from strategy metadata (respects per-strategy defaults)
|
|
meta = STRATEGY_META.get(name, {})
|
|
top_n = int(meta.get("params", {}).get("top_n", {}).get("default", 10))
|
|
strategy = strategy_fn(top_n=top_n)
|
|
|
|
equity = backtest(
|
|
strategy, data[tickers],
|
|
initial_capital=capital,
|
|
transaction_cost=tx_cost,
|
|
fixed_fee=fixed_fee,
|
|
)
|
|
|
|
# Get weight signals for trade extraction
|
|
weights = strategy.generate_signals(data[tickers])
|
|
weights = weights.reindex(data[tickers].index).fillna(0.0)
|
|
trade_events = compute_rebalance_trades(weights)
|
|
|
|
result_entry = _build_strategy_result(
|
|
name, equity, trade_events, injection_schedule, capital
|
|
)
|
|
results.append(result_entry)
|
|
|
|
# Benchmark
|
|
bench = data[benchmark_ticker].dropna()
|
|
if years:
|
|
cutoff = bench.index[-1] - pd.DateOffset(years=years)
|
|
bench = bench[bench.index >= cutoff]
|
|
bench_equity = (bench / bench.iloc[0]) * capital
|
|
bench_curve = [{"date": d.strftime("%Y-%m-%d"), "value": round(float(v), 2)}
|
|
for d, v in bench_equity.items()]
|
|
else:
|
|
benchmark_label = "SPY"
|
|
bench_curve = []
|
|
|
|
# Process ETF strategies
|
|
if etf_strategies:
|
|
for name in etf_strategies:
|
|
_log(f" Running ETF strategy: {name}")
|
|
base_name = name.removeprefix("sim_")
|
|
etf_tickers = ETF_STRATEGY_UNIVERSES[base_name]
|
|
all_etf = sorted(set(etf_tickers + ["SPY"]))
|
|
|
|
with _redirect_stdout_to_stderr():
|
|
etf_data = data_manager.update("etfs", all_etf, with_open=False)
|
|
if isinstance(etf_data, tuple):
|
|
etf_data = etf_data[0]
|
|
|
|
if years:
|
|
cutoff = etf_data.index[-1] - pd.DateOffset(years=years)
|
|
etf_data = etf_data[etf_data.index >= cutoff]
|
|
|
|
strategy_fn = STRATEGY_REGISTRY[name]
|
|
strategy = strategy_fn()
|
|
|
|
tradable = filter_tradable_tickers(etf_data, etf_tickers)
|
|
equity = backtest(
|
|
strategy, etf_data[tradable],
|
|
initial_capital=capital,
|
|
transaction_cost=tx_cost,
|
|
fixed_fee=fixed_fee,
|
|
)
|
|
|
|
weights = strategy.generate_signals(etf_data[tradable])
|
|
weights = weights.reindex(etf_data[tradable].index).fillna(0.0)
|
|
trade_events = compute_rebalance_trades(weights)
|
|
|
|
result_entry = _build_strategy_result(
|
|
name, equity, trade_events, injection_schedule, capital
|
|
)
|
|
results.append(result_entry)
|
|
|
|
# If no stock strategies were run, use SPY as benchmark from ETF data
|
|
if not stock_strategies and len(etf_strategies) > 0:
|
|
benchmark_label = "SPY (Benchmark)"
|
|
with _redirect_stdout_to_stderr():
|
|
spy_data = data_manager.update("etfs", ["SPY"], with_open=False)
|
|
if isinstance(spy_data, tuple):
|
|
spy_data = spy_data[0]
|
|
if years:
|
|
cutoff = spy_data.index[-1] - pd.DateOffset(years=years)
|
|
spy_data = spy_data[spy_data.index >= cutoff]
|
|
if "SPY" in spy_data.columns:
|
|
bench = spy_data["SPY"].dropna()
|
|
bench_equity = (bench / bench.iloc[0]) * capital
|
|
bench_curve = [{"date": d.strftime("%Y-%m-%d"), "value": round(float(v), 2)}
|
|
for d, v in bench_equity.items()]
|
|
|
|
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."""
|
|
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)
|
|
|
|
# Use injected equity for metrics/curves if available
|
|
final_equity = equity_with_inj if equity_with_inj is not None else equity
|
|
|
|
# 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()]
|
|
|
|
# Metrics
|
|
strat_metrics = metrics.raw_summary(final_equity)
|
|
strat_metrics["rebalanceWinRate"] = compute_rebalance_win_rate(
|
|
final_equity, trade_events,
|
|
)
|
|
|
|
# Drawdown periods
|
|
drawdowns = compute_drawdown_periods(final_equity)
|
|
|
|
# Monthly/yearly returns
|
|
monthly_ret, yearly_ret = compute_periodic_returns(final_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,
|
|
"monthlyReturns": monthly_ret,
|
|
"yearlyReturns": yearly_ret,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Subcommand: live
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _load_latest_prices(markets_needed: set[str]) -> dict[str, dict]:
|
|
"""Pull the latest close + as-of date for each cached market panel.
|
|
|
|
Returns ``{market: {"asof": str|None, "prices": {ticker: float}}}``.
|
|
We read the on-disk CSVs directly so this stays fast even when called
|
|
against a freshly initialized live state with no network.
|
|
"""
|
|
import data_manager # local import to avoid heavyweight import at module load
|
|
out: dict[str, dict] = {}
|
|
for mk in markets_needed:
|
|
try:
|
|
df = data_manager.load(mk)
|
|
except Exception as e: # noqa: BLE001 — never fatal
|
|
_log(f" load_latest_prices: cannot load {mk}: {e}")
|
|
out[mk] = {"asof": None, "prices": {}}
|
|
continue
|
|
if df is None or df.empty:
|
|
out[mk] = {"asof": None, "prices": {}}
|
|
continue
|
|
last_row = df.iloc[-1]
|
|
out[mk] = {
|
|
"asof": df.index[-1].strftime("%Y-%m-%d"),
|
|
"prices": {col: float(v) for col, v in last_row.items()
|
|
if v is not None and not (isinstance(v, float) and np.isnan(v))},
|
|
}
|
|
return out
|
|
|
|
|
|
def _strategy_data_market_safe(strategy_name: str, default: str) -> str:
|
|
"""Return the price-data market a strategy reads from.
|
|
|
|
Mirrors trader.strategy_data_market without importing trader (to keep
|
|
bridge import-cheap when the CLI is hit). Tactical-allocation strategies
|
|
trade ETFs, so they pull from the 'etfs' panel; stock pickers stay on
|
|
their nominal market.
|
|
"""
|
|
try:
|
|
from trader import ETF_STRATEGY_UNIVERSES # noqa: WPS433
|
|
except Exception:
|
|
return default
|
|
base = strategy_name.removeprefix("sim_")
|
|
return "etfs" if base in ETF_STRATEGY_UNIVERSES else default
|
|
|
|
|
|
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 (using
|
|
the latest cached close price), and total assets.
|
|
- Daily equity curve from the trader's persisted state.
|
|
- Full trade log (one entry per ticker per fill) + day-aggregated view.
|
|
- Metrics, drawdowns, monthly/yearly returns.
|
|
"""
|
|
market = args.market
|
|
pattern = os.path.join("data", f"trader_{market}_*.json")
|
|
state_files = sorted(glob(pattern))
|
|
|
|
if args.strategy:
|
|
state_files = [f for f in state_files if args.strategy in f]
|
|
|
|
_log(f"Found {len(state_files)} state files for market={market}")
|
|
|
|
# Pre-load price panels once. Tactical/ETF strategies read from 'etfs',
|
|
# everything else from the nominal market.
|
|
markets_needed = {market, "etfs"}
|
|
latest_prices = _load_latest_prices(markets_needed)
|
|
|
|
results = []
|
|
for path in state_files:
|
|
basename = os.path.basename(path)
|
|
parts = basename.replace(".json", "").split("_", 2)
|
|
if len(parts) < 3:
|
|
continue
|
|
strategy_name = parts[2]
|
|
|
|
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_safe(strategy_name, market)
|
|
price_book = latest_prices.get(data_market, {"asof": None, "prices": {}})
|
|
|
|
holdings_raw = state.get("holdings", {}) or {}
|
|
cash = float(state.get("cash", 0) or 0)
|
|
|
|
# Per-holding marked-to-market table
|
|
holdings_detail = []
|
|
market_value = 0.0
|
|
for ticker, shares in holdings_raw.items():
|
|
shares_f = float(shares)
|
|
price = price_book["prices"].get(ticker)
|
|
value = shares_f * float(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,
|
|
})
|
|
holdings_detail.sort(
|
|
key=lambda h: (-(h["marketValue"] or 0.0), h["ticker"]),
|
|
)
|
|
|
|
# Equity curve from persisted state
|
|
daily_equity = state.get("daily_equity", {}) or {}
|
|
dates = sorted(daily_equity.keys())
|
|
equity_curve = [
|
|
{"date": d, "value": round(float(daily_equity[d]), 2)}
|
|
for d in dates
|
|
]
|
|
equity_series = pd.Series(
|
|
{pd.Timestamp(d): float(daily_equity[d]) for d in dates}
|
|
).sort_index() if dates else pd.Series(dtype=float)
|
|
|
|
strat_metrics = (
|
|
metrics.raw_summary(equity_series) if len(equity_series) >= 2 else None
|
|
)
|
|
|
|
# Trade-by-day aggregation (kept for backwards compatibility) plus
|
|
# full granular log.
|
|
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: prefer live MTM; fall back to last persisted equity.
|
|
live_value = cash + market_value if holdings_detail else cash
|
|
if not holdings_detail and equity_curve:
|
|
live_value = equity_curve[-1]["value"]
|
|
|
|
initial_capital = float(state.get("initial_capital", 0) or 0)
|
|
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
|
|
)
|
|
|
|
results.append({
|
|
"name": strategy_name,
|
|
"label": meta.get("label", strategy_name),
|
|
"category": meta.get("category", "other"),
|
|
"market": market,
|
|
"initialCapital": initial_capital,
|
|
"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,
|
|
"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)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Quant bridge for stock-agent")
|
|
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")
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.command == "list":
|
|
cmd_list()
|
|
elif args.command == "backtest":
|
|
cmd_backtest(args)
|
|
elif args.command == "live":
|
|
cmd_live(args)
|
|
else:
|
|
parser.print_help(sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|