From e147890066214e7b57460534b840d254437fbf1e Mon Sep 17 00:00:00 2001 From: Gahow Wang Date: Thu, 21 May 2026 00:46:48 +0800 Subject: [PATCH] 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) --- bridge.py | 1186 +++++++++++++++++++++++++++++++++++++++++++++++++++++ trader.py | 5 +- 2 files changed, 1187 insertions(+), 4 deletions(-) create mode 100644 bridge.py diff --git a/bridge.py b/bridge.py new file mode 100644 index 0000000..1b77058 --- /dev/null +++ b/bridge.py @@ -0,0 +1,1186 @@ +""" +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() diff --git a/trader.py b/trader.py index ee1cea5..e9d2eab 100644 --- a/trader.py +++ b/trader.py @@ -231,10 +231,7 @@ MIXED_STRATEGY_EXTRA_TICKERS = { "trend_rider_v6_top10": sorted(set(ETF_UNIVERSE)), } -DEFAULT_MONITOR_STRATEGIES = [ - name for name in STRATEGY_REGISTRY - if name not in ETF_STRATEGY_UNIVERSES -] +DEFAULT_MONITOR_STRATEGIES = list(STRATEGY_REGISTRY.keys()) def strategy_universe(market: str, strategy_name: str) -> tuple[list[str], str]: