Files
quant/bridge.py
Gahow Wang e147890066 feat: include ETF strategies in monitor and register V7 in bridge
- DEFAULT_MONITOR_STRATEGIES now includes ALL strategies (stock + ETF)
  instead of excluding ETF strategies. The cmd_morning/evening/auto
  already route ETF strategies to the correct data pipeline via
  strategy_universe() and strategy_data_market().

- Register trend_rider_v7, v7_vt24, v7_vt32 in bridge.py STRATEGY_META
  so they appear in the stock-agent frontend via /api/strategies.

- Monitor now runs as a background daemon with logs written to
  logs/monitor.log (PYTHONUNBUFFERED=1, no tmux dependency).
  PID saved to logs/monitor.pid.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 00:46:48 +08:00

1187 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"],
},
# --- 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()