- Monthly/yearly returns chain from the previous period's close (the first trading day of every period was dropped; up to ~9pp/yr error). - With cash injections, metrics/drawdowns/periodic returns are time-weighted on the no-injection curve; capitalFlows reports invested/profit separately. - Benchmarks cover exactly the strategies' dates. - CAGR uses calendar time, so gaps in live records do not inflate it. - Backtests skip the per-trade fee for rebalances below the live minimum order and keep a ruined portfolio at zero. - live: exact strategy match, ineligible strategies hidden, currency, startDate, same-window benchmark, stale-price fallback, equity anchored at the initial capital. - PIT membership falls back to the cached history when the Wikipedia change log cannot be parsed (the table is no longer on the page). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1218 lines
42 KiB
Python
1218 lines
42 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,
|
|
LIVE_EXCLUDED_STRATEGIES,
|
|
MARKET_FEE_TIERED,
|
|
STRATEGY_REGISTRY,
|
|
convert_quote_currency,
|
|
price_request,
|
|
strategy_supports_market,
|
|
strategy_data_market,
|
|
filter_tradable_tickers,
|
|
)
|
|
|
|
MARKET_CURRENCY = {"us": "USD", "cn": "CNY"}
|
|
|
|
# Rebalances below this size are not charged the per-trade fee in backtests,
|
|
# matching the live trader's minimum order size.
|
|
MIN_TRADE_VALUE = 50.0
|
|
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]:
|
|
"""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 benchmark_curve(prices: pd.Series, dates: pd.DatetimeIndex, capital: float) -> list[dict]:
|
|
"""Benchmark value on ``dates``, rebased to ``capital`` on the first date it has a price."""
|
|
series = prices.reindex(dates).ffill().dropna()
|
|
if series.empty:
|
|
return []
|
|
values = series / series.iloc[0] * capital
|
|
return [{"date": d.strftime("%Y-%m-%d"), "value": round(float(v), 2)} for d, v in values.items()]
|
|
|
|
|
|
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}")
|
|
# Registry defaults are the single source of strategy parameters,
|
|
# shared with the live trader.
|
|
strategy = STRATEGY_REGISTRY[name]()
|
|
|
|
equity = backtest(
|
|
strategy, data[tickers],
|
|
initial_capital=capital,
|
|
transaction_cost=tx_cost,
|
|
fixed_fee=fixed_fee,
|
|
min_trade_value=MIN_TRADE_VALUE,
|
|
**MARKET_FEE_TIERED.get(market, {}),
|
|
)
|
|
|
|
# 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 over exactly the strategies' dates
|
|
bench_curve = benchmark_curve(data[benchmark_ticker], data.index, capital)
|
|
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", price_request(market, all_etf), with_open=False)
|
|
if isinstance(etf_data, tuple):
|
|
etf_data = etf_data[0]
|
|
etf_data = convert_quote_currency(etf_data)
|
|
|
|
if years:
|
|
cutoff = etf_data.index[-1] - pd.DateOffset(years=years)
|
|
etf_data = etf_data[etf_data.index >= cutoff]
|
|
|
|
strategy = STRATEGY_REGISTRY[name]()
|
|
|
|
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,
|
|
min_trade_value=MIN_TRADE_VALUE,
|
|
**MARKET_FEE_TIERED.get(market, {}),
|
|
)
|
|
|
|
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_curve = benchmark_curve(spy_data["SPY"], spy_data.index, capital)
|
|
|
|
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}_"
|
|
state_files = sorted(glob(os.path.join("data", f"{prefix}*.json")))
|
|
wanted = args.strategy
|
|
|
|
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
|
|
|
|
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)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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()
|