""" 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()