A $10k book in whole shares holds SNDK at 17.8% for a 10% target, so the live record mixes the strategy with rounding and fee noise (2026-09-25..10-12: overnight_recovery_top10 -9.0% live vs -7.65% at its own weights). Each daily_log entry now stores the target held from that close and ideal_return, the previous target's return to this close with fractional shares, no fees, no resize band and no idle cash. compare prints Ideal and Gap (live minus ideal) over the recorded days. The recorded returns compound to the frictionless ledger (new contract test); an in-memory rerun of 10-09/10-12 on live states reproduces the recorded equity to the cent. systemd: SuccessExitStatus=143 on the monitor and overnight-paper units, so a stop or restart no longer logs 'Failed' and the journal shows only crashes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2197 lines
99 KiB
Python
2197 lines
99 KiB
Python
"""
|
||
Daily trading simulation system.
|
||
|
||
Subcommands:
|
||
morning — Download today's open prices, run strategy, output trade orders
|
||
evening — Download close prices, record execution, update portfolio
|
||
auto — Single daily run (morning + evening in one step, for cron)
|
||
monitor — Long-running daemon for US+CN, all strategies (one tmux)
|
||
status — Print current portfolio, P&L, and recent trades
|
||
simulate — Replay a date range day-by-day (for forward testing)
|
||
log — View daily log: each day's holdings, cash, and operations
|
||
compare — Compare all strategies side-by-side (auto-discovers state files)
|
||
|
||
Usage:
|
||
uv run python trader.py morning --market us --strategy recovery_mom_top10
|
||
uv run python trader.py evening --market us --strategy recovery_mom_top10
|
||
uv run python trader.py auto --market us --strategy recovery_mom_top10
|
||
uv run python trader.py monitor --market us --strategy recovery_mom_top10
|
||
uv run python trader.py status --market us --strategy recovery_mom_top10
|
||
uv run python trader.py simulate --market us --strategy recovery_mom_top10 \\
|
||
--start 2026-01-01 --end 2026-04-04
|
||
|
||
Monitor mode (recommended for tmux):
|
||
tmux new -s quant
|
||
uv run python trader.py monitor --market us --strategy recovery_mom_top10
|
||
# Ctrl-B D to detach; tmux attach -t quant to reconnect
|
||
|
||
State is persisted in data/trader_{market}_{strategy}.json between runs.
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import sys
|
||
import zoneinfo
|
||
from datetime import datetime, timedelta, timezone
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
import yfinance as yf
|
||
|
||
import data_manager
|
||
from signals import session_targets
|
||
from execution import (ENGINE_VERSION, compute_trades, execute_trades, portfolio_value, rebalance, market_permissions,
|
||
_allocate_lots, _per_trade_commission)
|
||
import universe_history
|
||
from strategies.base import BarStrategy
|
||
from strategies.agent_review import AgentReviewStrategy
|
||
from strategies.buy_and_hold import BuyAndHoldStrategy
|
||
from strategies.dual_momentum import DualMomentumStrategy
|
||
from strategies.factor_combo import FactorComboStrategy
|
||
from strategies.inverse_vol import InverseVolatilityStrategy
|
||
from strategies.momentum import MomentumStrategy
|
||
from strategies.momentum_quality import MomentumQualityStrategy
|
||
from strategies.permanent import (
|
||
ETF_UNIVERSE,
|
||
GLOBAL_ETF_UNIVERSE,
|
||
HK_ETF_UNIVERSE,
|
||
TREND_RIDER_V4_UNIVERSE,
|
||
TrendRiderV3,
|
||
TrendRiderV4,
|
||
)
|
||
from strategies.recovery_momentum import RecoveryMomentumStrategy
|
||
from strategies.robust_momentum import RobustMomentumStrategy
|
||
from strategies.sleeve_portfolio import (MOMENTUM_QQQ_TREND_ETFS, us_momentum_qqq_trend,
|
||
us_three_sleeve)
|
||
from strategies.trend_following import TrendFollowingStrategy
|
||
from strategies.trend_rider_v5 import TrendRiderV5
|
||
from strategies.trend_rider_voltgt import (
|
||
TrendRiderV3VolTarget,
|
||
TrendRiderV5VolTarget,
|
||
)
|
||
from strategies.trend_rider_v7 import TrendRiderV7
|
||
from strategies.trend_rider_v9 import TrendRiderV9
|
||
from strategies.ensemble_alpha import (
|
||
EnsembleAlphaStrategy,
|
||
EnhancedFactorComboStrategy,
|
||
RiskManagedEnsembleStrategy,
|
||
SharpeBoostedEnsembleStrategy,
|
||
)
|
||
from strategies.composite_alpha import CompositeAlphaStrategy
|
||
from strategies.enhanced_recovery_momentum import EnhancedRecoveryMomentumStrategy
|
||
from strategies.hybrid_alpha import HybridAlphaStrategy, RecoveryQualityBlendStrategy
|
||
from strategies.improved_momentum_quality import ImprovedMomentumQualityStrategy
|
||
from strategies.trend_rider_v6 import TrendRiderV6
|
||
from universe import UNIVERSES
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Per-market fixed trading fees (per trade, in the market's local currency)
|
||
# ---------------------------------------------------------------------------
|
||
# These are applied automatically by cmd_monitor and cmd_auto; they can still
|
||
# be overridden by explicitly passing --fixed-fee on the CLI.
|
||
MARKET_FEES = {
|
||
"us": 2.0, # USD per trade (floor)
|
||
"cn": 5.0, # CNY per trade (A-share minimum commission)
|
||
}
|
||
|
||
# IBKR-style tiered schedule on top of the floor. `commission = max(fixed_fee,
|
||
# fee_base + fee_per_share * shares)`. CN defaults stay at flat 5 CNY.
|
||
MARKET_FEE_TIERED = {
|
||
"us": {"fee_base": 1.88, "fee_per_share": 0.009},
|
||
}
|
||
|
||
# Minimum tradable lot per market when trading whole shares. A-shares trade in
|
||
# board lots of 100.
|
||
MARKET_LOT_SIZE = {"us": 1, "cn": 100}
|
||
|
||
# A position already held is resized only once it is this far from its target
|
||
# (relative to the target value); entries and exits always trade. Without the
|
||
# band the books paid a ticket fee almost daily to trade weight drift that
|
||
# the backtests never trade.
|
||
RESIZE_BAND = 0.10
|
||
|
||
# Default live capital per market, in the market's own currency.
|
||
MONITOR_CAPITAL = {"us": 10_000.0, "cn": 70_000.0}
|
||
|
||
# Named live books. A book is a cohort of strategies, each trading its own
|
||
# account from the book's start date with the book's capital and a flat fee per
|
||
# order (no proportional cost). The original book (every registered strategy,
|
||
# MONITOR_CAPITAL, MARKET_FEES) keeps its state files in data/; a named book
|
||
# keeps them in data/books/<name>/. The monitor runs every book.
|
||
LIVE_BOOKS: dict[str, dict] = {
|
||
"selected_2026_09": {
|
||
"label": "精选组合(2026-09-25 起)",
|
||
# CN starts with the first session after the strategies were chosen
|
||
# (the choice was made after the CN close of 2026-09-25).
|
||
"start": {"us": "2026-09-25", "cn": "2026-09-28"},
|
||
"capital": {"us": 10_000.0, "cn": 100_000.0},
|
||
"fee_per_order": {"us": 2.0, "cn": 10.0},
|
||
# Chosen on 2026-09-25: beat the benchmark in the Apr-Sep 2026 replay
|
||
# (point-in-time members) AND passed a 10-year PIT backtest (US:
|
||
# Sharpe >= 0.6, MaxDD > -50%; CN: Sharpe >= 0.2, CAGR > 0); no daily
|
||
# variants; at most two frequencies per factor signal. Plus the three
|
||
# strategies from the 2026-09 PIT research, and the trend_rider ETF
|
||
# strategies (lost 7-12% in Apr-Sep, 10-year Sharpe 1.0-1.4): v7_vt24
|
||
# and v5_vt30 (best 10-year Sharpe of their version) and v7_vt36 (the
|
||
# V7+VT36 champion). overnight_recovery_agent_top10 was added on
|
||
# 2026-09-30 at the user's request; it starts with the book, and until
|
||
# its first agent score file it holds what overnight_recovery_top10 holds.
|
||
"strategies": {
|
||
"us": [
|
||
"fc_up_cap_quality_mom_biweekly", "fc_up_cap_quality_mom_weekly",
|
||
"fc_up_cap_mom_gap_biweekly", "fc_up_cap_mom_gap_weekly",
|
||
"fc_rec_mfilt_deep_upvol_monthly", "fc_rec_mfilt_deep_upvol_weekly",
|
||
"fc_down_resil_qual_mom_monthly", "fc_down_resil_qual_mom_biweekly",
|
||
"fc_mom7m_rec126_monthly", "fc_mom7m_rec126_biweekly",
|
||
"fc_rec63_mom_gap_biweekly", "fc_rec63_mom_gap_monthly",
|
||
"fc_ma200_mom7m_rec126_monthly", "fc_ma200_mom7m_rec126_biweekly",
|
||
"fc_rec_mfilt_ma200_monthly", "fc_rec_mfilt_ma200_biweekly",
|
||
"hybrid_alpha_top10", "recovery_mom_top10",
|
||
"overnight_recovery_top10", "overnight_recovery_agent_top10",
|
||
"trend_rider_v7_vt24", "trend_rider_v7_vt36", "trend_rider_v5_vt30",
|
||
],
|
||
"cn": [
|
||
"fc_up_cap_mom_gap_monthly", "fc_up_cap_mom_gap_biweekly",
|
||
"fc_up_cap_quality_mom_biweekly", "fc_up_cap_quality_mom_monthly",
|
||
"fc_mom7m_rec126_monthly",
|
||
"lowrisk_momentum_top15", "lowbeta_sharpe_mom_top20",
|
||
],
|
||
},
|
||
},
|
||
}
|
||
|
||
# Exchange close per price panel. A bar dated today is treated as a closing
|
||
# price only once the session has closed and settled for a few minutes.
|
||
MARKET_SESSION = {
|
||
"us": ("America/New_York", 16, 0),
|
||
"etfs": ("America/New_York", 16, 0),
|
||
"cn": ("Asia/Shanghai", 15, 0),
|
||
}
|
||
SESSION_SETTLE_MINUTES = 20
|
||
|
||
# HK-listed tickers held in a USD book are converted with this FX series
|
||
# (HKD per 1 USD). HKD is pegged within 7.75-7.85, so the band midpoint is
|
||
# used on dates the series has no quote (Yahoo's FX feed is intermittent).
|
||
HKD_FX_TICKER = "HKD=X"
|
||
HKD_PEG_MIDPOINT = 7.80
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Strategy registry
|
||
# ---------------------------------------------------------------------------
|
||
|
||
STRATEGY_REGISTRY = {
|
||
# --- Original strategies ---
|
||
"recovery_mom_top10": lambda **kw: RecoveryMomentumStrategy(top_n=10),
|
||
"recovery_mom_top20": lambda **kw: RecoveryMomentumStrategy(top_n=20),
|
||
"recovery_mom_top50": lambda **kw: RecoveryMomentumStrategy(top_n=50),
|
||
"momentum": lambda **kw: MomentumStrategy(lookback=252, skip=21, top_n=kw.get("top_n", 20)),
|
||
"momentum_quality": lambda **kw: MomentumQualityStrategy(top_n=kw.get("top_n", 20)),
|
||
"dual_momentum": lambda **kw: DualMomentumStrategy(top_n=kw.get("top_n", 20)),
|
||
"inverse_vol": lambda **kw: InverseVolatilityStrategy(vol_window=20),
|
||
"trend_following": lambda **kw: TrendFollowingStrategy(top_n=kw.get("top_n", 20)),
|
||
"buy_and_hold": lambda **kw: BuyAndHoldStrategy(),
|
||
# --- Factor combo: US champions ---
|
||
"fc_rec_mfilt_deep_upvol_daily": lambda **kw: FactorComboStrategy("rec_mfilt+deep_upvol", rebal_freq=1),
|
||
"fc_rec_mfilt_deep_upvol_weekly": lambda **kw: FactorComboStrategy("rec_mfilt+deep_upvol", rebal_freq=5),
|
||
"fc_rec_mfilt_deep_upvol_biweekly": lambda **kw: FactorComboStrategy("rec_mfilt+deep_upvol", rebal_freq=10),
|
||
"fc_rec_mfilt_deep_upvol_monthly": lambda **kw: FactorComboStrategy("rec_mfilt+deep_upvol", rebal_freq=21),
|
||
"fc_ma200_mom7m_rec126_daily": lambda **kw: FactorComboStrategy("ma200+mom7m+rec126", rebal_freq=1),
|
||
"fc_ma200_mom7m_rec126_weekly": lambda **kw: FactorComboStrategy("ma200+mom7m+rec126", rebal_freq=5),
|
||
"fc_ma200_mom7m_rec126_biweekly": lambda **kw: FactorComboStrategy("ma200+mom7m+rec126", rebal_freq=10),
|
||
"fc_ma200_mom7m_rec126_monthly": lambda **kw: FactorComboStrategy("ma200+mom7m+rec126", rebal_freq=21),
|
||
"fc_rec_mfilt_ma200_daily": lambda **kw: FactorComboStrategy("rec_mfilt+ma200", rebal_freq=1),
|
||
"fc_rec_mfilt_ma200_weekly": lambda **kw: FactorComboStrategy("rec_mfilt+ma200", rebal_freq=5),
|
||
"fc_rec_mfilt_ma200_biweekly": lambda **kw: FactorComboStrategy("rec_mfilt+ma200", rebal_freq=10),
|
||
"fc_rec_mfilt_ma200_monthly": lambda **kw: FactorComboStrategy("rec_mfilt+ma200", rebal_freq=21),
|
||
"fc_mom7m_rec126_daily": lambda **kw: FactorComboStrategy("mom7m+rec126", rebal_freq=1),
|
||
"fc_mom7m_rec126_weekly": lambda **kw: FactorComboStrategy("mom7m+rec126", rebal_freq=5),
|
||
"fc_mom7m_rec126_biweekly": lambda **kw: FactorComboStrategy("mom7m+rec126", rebal_freq=10),
|
||
"fc_mom7m_rec126_monthly": lambda **kw: FactorComboStrategy("mom7m+rec126", rebal_freq=21),
|
||
# --- Factor combo: CN champions ---
|
||
"fc_up_cap_quality_mom_daily": lambda **kw: FactorComboStrategy("up_cap+quality_mom", rebal_freq=1),
|
||
"fc_up_cap_quality_mom_weekly": lambda **kw: FactorComboStrategy("up_cap+quality_mom", rebal_freq=5),
|
||
"fc_up_cap_quality_mom_biweekly": lambda **kw: FactorComboStrategy("up_cap+quality_mom", rebal_freq=10),
|
||
"fc_up_cap_quality_mom_monthly": lambda **kw: FactorComboStrategy("up_cap+quality_mom", rebal_freq=21),
|
||
"fc_down_resil_qual_mom_daily": lambda **kw: FactorComboStrategy("down_resil+qual_mom", rebal_freq=1),
|
||
"fc_down_resil_qual_mom_weekly": lambda **kw: FactorComboStrategy("down_resil+qual_mom", rebal_freq=5),
|
||
"fc_down_resil_qual_mom_biweekly": lambda **kw: FactorComboStrategy("down_resil+qual_mom", rebal_freq=10),
|
||
"fc_down_resil_qual_mom_monthly": lambda **kw: FactorComboStrategy("down_resil+qual_mom", rebal_freq=21),
|
||
"fc_rec63_mom_gap_daily": lambda **kw: FactorComboStrategy("rec63+mom_gap", rebal_freq=1),
|
||
"fc_rec63_mom_gap_weekly": lambda **kw: FactorComboStrategy("rec63+mom_gap", rebal_freq=5),
|
||
"fc_rec63_mom_gap_biweekly": lambda **kw: FactorComboStrategy("rec63+mom_gap", rebal_freq=10),
|
||
"fc_rec63_mom_gap_monthly": lambda **kw: FactorComboStrategy("rec63+mom_gap", rebal_freq=21),
|
||
"fc_up_cap_mom_gap_daily": lambda **kw: FactorComboStrategy("up_cap+mom_gap", rebal_freq=1),
|
||
"fc_up_cap_mom_gap_weekly": lambda **kw: FactorComboStrategy("up_cap+mom_gap", rebal_freq=5),
|
||
"fc_up_cap_mom_gap_biweekly": lambda **kw: FactorComboStrategy("up_cap+mom_gap", rebal_freq=10),
|
||
"fc_up_cap_mom_gap_monthly": lambda **kw: FactorComboStrategy("up_cap+mom_gap", rebal_freq=21),
|
||
# --- ETF tactical allocation strategies ---
|
||
"trend_rider_v3_us": lambda **kw: TrendRiderV3(),
|
||
"trend_rider_v3_global": lambda **kw: TrendRiderV3(
|
||
risk_on=("TQQQ", "UPRO", "YINN", "CHAU"),
|
||
risk_off=("GLD", "DBC"),
|
||
),
|
||
"trend_rider_v3_hk": lambda **kw: TrendRiderV3(
|
||
risk_on=("7200.HK", "7500.HK"),
|
||
risk_off=("GLD", "DBC"),
|
||
),
|
||
"trend_rider_v4": lambda **kw: TrendRiderV4(),
|
||
# --- V5: V3 + conviction-gated leverage tier modulator ---
|
||
"trend_rider_v5_us": lambda **kw: TrendRiderV5(),
|
||
"trend_rider_v5_panic": lambda **kw: TrendRiderV5(
|
||
panic_vol_ratio=1.4, panic_peak_drop_pct=0.03,
|
||
),
|
||
"trend_rider_v5_global": lambda **kw: TrendRiderV5(
|
||
risk_on=("TQQQ", "UPRO", "YINN", "CHAU"),
|
||
risk_off=("GLD", "DBC"),
|
||
),
|
||
# --- Vol-targeted variants (smoother equity, tighter drawdowns) ---
|
||
"trend_rider_v3_vt28": lambda **kw: TrendRiderV3VolTarget(
|
||
target_vol=0.28, min_lev=0.6,
|
||
),
|
||
"trend_rider_v3_vt28_ief": lambda **kw: TrendRiderV3VolTarget(
|
||
target_vol=0.28, min_lev=0.6, risk_off=("GLD", "DBC", "IEF"),
|
||
),
|
||
"trend_rider_v3_vt32": lambda **kw: TrendRiderV3VolTarget(
|
||
target_vol=0.32, min_lev=0.7,
|
||
),
|
||
"trend_rider_v3_vt24": lambda **kw: TrendRiderV3VolTarget(
|
||
target_vol=0.24, min_lev=0.5,
|
||
),
|
||
"trend_rider_v5_vt30": lambda **kw: TrendRiderV5VolTarget(
|
||
target_vol=0.30, min_lev=0.6,
|
||
),
|
||
# --- V7: V3 + vol-target + profit-take for leveraged ETFs ---
|
||
"trend_rider_v7": lambda **kw: TrendRiderV7(),
|
||
"trend_rider_v7_vt24": lambda **kw: TrendRiderV7(target_vol=0.24, min_lev=0.5),
|
||
"trend_rider_v7_vt32": lambda **kw: TrendRiderV7(target_vol=0.32, min_lev=0.7),
|
||
"trend_rider_v7_vt36": lambda **kw: TrendRiderV7(target_vol=0.36, min_lev=0.75),
|
||
# --- V9: V7 + trailing stop on the leveraged risk-on leg ---
|
||
"trend_rider_v9": lambda **kw: TrendRiderV9(),
|
||
"trend_rider_v9_vt24": lambda **kw: TrendRiderV9(target_vol=0.24, min_lev=0.5),
|
||
"trend_rider_v9_vt32": lambda **kw: TrendRiderV9(target_vol=0.32, min_lev=0.7),
|
||
"trend_rider_v9_vt36": lambda **kw: TrendRiderV9(target_vol=0.36, min_lev=0.75),
|
||
# --- V10: V9 + PT refinement (clear 28% / restore band 8pp / 10-day re-clear lockout) ---
|
||
"trend_rider_v10": lambda **kw: TrendRiderV9(
|
||
pt_threshold=0.28, pt_band=0.08, pt_min_hold=10),
|
||
"trend_rider_v10_vt32": lambda **kw: TrendRiderV9(
|
||
target_vol=0.32, min_lev=0.70, pt_threshold=0.28, pt_band=0.08, pt_min_hold=10),
|
||
"trend_rider_v10_vt36": lambda **kw: TrendRiderV9(
|
||
target_vol=0.36, min_lev=0.75, pt_threshold=0.28, pt_band=0.08, pt_min_hold=10),
|
||
# --- Stock-picker ensemble strategies (S&P 500 universe) ---
|
||
"ensemble_alpha_top10": lambda **kw: EnsembleAlphaStrategy(top_n=10),
|
||
"ensemble_alpha_top12": lambda **kw: EnsembleAlphaStrategy(top_n=12),
|
||
"ensemble_alpha_top15_tail": lambda **kw: EnsembleAlphaStrategy(
|
||
top_n=15, tail_protection=True, tail_threshold=-0.12, tail_scale=0.4,
|
||
),
|
||
"enhanced_factor_combo_top10": lambda **kw: EnhancedFactorComboStrategy(top_n=10),
|
||
"risk_managed_ensemble_top10": lambda **kw: RiskManagedEnsembleStrategy(top_n=10),
|
||
"sharpe_boosted_ensemble_top8": lambda **kw: SharpeBoostedEnsembleStrategy(top_n=8),
|
||
"sharpe_boosted_ensemble_top12_rebal63": lambda **kw: SharpeBoostedEnsembleStrategy(
|
||
top_n=12, rebal_freq=63,
|
||
),
|
||
# --- Research-round stock strategies ---
|
||
"composite_alpha_top20": lambda **kw: CompositeAlphaStrategy(top_n=20),
|
||
"composite_alpha_top10": lambda **kw: CompositeAlphaStrategy(top_n=10),
|
||
"enhanced_recovery_top20": lambda **kw: EnhancedRecoveryMomentumStrategy(top_n=20),
|
||
"enhanced_recovery_top10": lambda **kw: EnhancedRecoveryMomentumStrategy(top_n=10),
|
||
"hybrid_alpha_top20": lambda **kw: HybridAlphaStrategy(top_n=20),
|
||
"hybrid_alpha_top10": lambda **kw: HybridAlphaStrategy(top_n=10),
|
||
"recovery_quality_blend_top20": lambda **kw: RecoveryQualityBlendStrategy(top_n=20),
|
||
"recovery_quality_blend_top10": lambda **kw: RecoveryQualityBlendStrategy(top_n=10),
|
||
"improved_mom_quality_top20": lambda **kw: ImprovedMomentumQualityStrategy(top_n=20),
|
||
"improved_mom_quality_top10": lambda **kw: ImprovedMomentumQualityStrategy(top_n=10),
|
||
# --- Robust momentum (2026-09 PIT research; bar strategies) ---
|
||
"overnight_recovery_top10": lambda **kw: RobustMomentumStrategy(
|
||
"overnight_recovery", top_n=10, rebal_freq=10, buffer=2.0,
|
||
),
|
||
"lowrisk_momentum_top15": lambda **kw: RobustMomentumStrategy(
|
||
"lowrisk_momentum", top_n=15, rebal_freq=10, buffer=2.0,
|
||
),
|
||
"lowbeta_sharpe_mom_top20": lambda **kw: RobustMomentumStrategy(
|
||
"lowbeta_sharpe_mom", top_n=20, rebal_freq=10, buffer=1.0,
|
||
),
|
||
# --- US portfolio research (2026-09-29): defensive sleeve and the 3-sleeve book ---
|
||
"lowrisk_top20": lambda **kw: RobustMomentumStrategy(
|
||
"low_risk", top_n=20, rebal_freq=21, buffer=2.0,
|
||
),
|
||
# 1/3 each of overnight_recovery_top10, lowrisk_top20 and trend_rider_v7_vt24
|
||
# in one account (strategies/sleeve_portfolio.py)
|
||
"us_three_sleeve": lambda **kw: us_three_sleeve(),
|
||
# half overnight_recovery_top10, half QQQ 200-day trend with a 30% vol target (TQQQ + SHY)
|
||
"us_momentum_qqq_trend": lambda **kw: us_momentum_qqq_trend(),
|
||
# overnight_recovery's top 50, re-ranked with an LLM agent's daily scores
|
||
# (strategies/agent_review.py, agent_review.py); live from 2026-09-29, no backtest of its own
|
||
"overnight_recovery_agent_top10": lambda **kw: AgentReviewStrategy(),
|
||
# --- TrendRiderV6: stock-picking + V5 regime engine ---
|
||
"trend_rider_v6": lambda **kw: TrendRiderV6(),
|
||
"trend_rider_v6_top10": lambda **kw: TrendRiderV6(top_n=10),
|
||
}
|
||
|
||
# Execution-only candidates retain their baseline signals and a separate record.
|
||
EXECUTION_VARIANTS = {
|
||
"overnight_recovery_top10_rb20": ("overnight_recovery_top10", "us"),
|
||
"us_three_sleeve_rb20": ("us_three_sleeve", "us"),
|
||
"us_momentum_qqq_trend_rb20": ("us_momentum_qqq_trend", "us"),
|
||
"lowbeta_sharpe_mom_top20_rb20": ("lowbeta_sharpe_mom_top20", "cn"),
|
||
}
|
||
|
||
|
||
def _resize_variant(factory):
|
||
def build(**kwargs):
|
||
strategy = factory(**kwargs)
|
||
strategy.execution_resize_band = .20
|
||
return strategy
|
||
return build
|
||
|
||
|
||
for _variant, (_base, _market) in EXECUTION_VARIANTS.items():
|
||
STRATEGY_REGISTRY[_variant] = _resize_variant(STRATEGY_REGISTRY[_base])
|
||
|
||
|
||
def canonical_strategy_name(name):
|
||
name = name.removeprefix("sim_")
|
||
return EXECUTION_VARIANTS.get(name, (name, None))[0]
|
||
|
||
|
||
def strategy_resize_band(name):
|
||
return .20 if name.removeprefix("sim_") in EXECUTION_VARIANTS else RESIZE_BAND
|
||
|
||
|
||
LIVE_BOOKS["validation_2026_10"] = {
|
||
"label": "修复后前瞻对照(2026-10-02 起)",
|
||
"start": {"us": "2026-10-02", "cn": "2026-10-02"},
|
||
"capital": {"us": 10_000.0, "cn": 70_000.0},
|
||
"fee_per_order": {"us": 2.0, "cn": 5.0},
|
||
"fee_schedule": {
|
||
"us": {"tx_cost": .001, "fixed_fee": 2., "fee_base": 1.88, "fee_per_share": .009},
|
||
"cn": {"tx_cost": .001, "fixed_fee": 5., "fee_base": 0., "fee_per_share": 0.},
|
||
},
|
||
"strategies": {
|
||
"us": ["overnight_recovery_top10", "overnight_recovery_top10_rb20",
|
||
"us_three_sleeve", "us_three_sleeve_rb20",
|
||
"us_momentum_qqq_trend", "us_momentum_qqq_trend_rb20"],
|
||
"cn": ["lowbeta_sharpe_mom_top20", "lowbeta_sharpe_mom_top20_rb20"],
|
||
},
|
||
}
|
||
|
||
|
||
ETF_STRATEGY_UNIVERSES = {
|
||
"trend_rider_v3_us": sorted(set(ETF_UNIVERSE)),
|
||
"trend_rider_v3_global": sorted(set(GLOBAL_ETF_UNIVERSE)),
|
||
"trend_rider_v3_hk": sorted(set(HK_ETF_UNIVERSE)),
|
||
"trend_rider_v4": sorted(set(TREND_RIDER_V4_UNIVERSE)),
|
||
"trend_rider_v5_us": sorted(set(ETF_UNIVERSE)),
|
||
"trend_rider_v5_panic": sorted(set(ETF_UNIVERSE)),
|
||
"trend_rider_v5_global": sorted(set(GLOBAL_ETF_UNIVERSE)),
|
||
"trend_rider_v3_vt28": sorted(set(ETF_UNIVERSE)),
|
||
"trend_rider_v3_vt28_ief": sorted(set(ETF_UNIVERSE + ["IEF"])),
|
||
"trend_rider_v3_vt32": sorted(set(ETF_UNIVERSE)),
|
||
"trend_rider_v3_vt24": sorted(set(ETF_UNIVERSE)),
|
||
"trend_rider_v5_vt30": sorted(set(ETF_UNIVERSE)),
|
||
"trend_rider_v7": sorted(set(ETF_UNIVERSE)),
|
||
"trend_rider_v7_vt24": sorted(set(ETF_UNIVERSE)),
|
||
"trend_rider_v7_vt32": sorted(set(ETF_UNIVERSE)),
|
||
"trend_rider_v7_vt36": sorted(set(ETF_UNIVERSE)),
|
||
"trend_rider_v9": sorted(set(ETF_UNIVERSE)),
|
||
"trend_rider_v9_vt24": sorted(set(ETF_UNIVERSE)),
|
||
"trend_rider_v9_vt32": sorted(set(ETF_UNIVERSE)),
|
||
"trend_rider_v9_vt36": sorted(set(ETF_UNIVERSE)),
|
||
"trend_rider_v10": sorted(set(ETF_UNIVERSE)),
|
||
"trend_rider_v10_vt32": sorted(set(ETF_UNIVERSE)),
|
||
"trend_rider_v10_vt36": sorted(set(ETF_UNIVERSE)),
|
||
}
|
||
|
||
# Strategies that use the market's stock universe PLUS fixed extra ETF tickers.
|
||
# These are NOT pure-ETF strategies — they need both stocks and ETFs in the panel.
|
||
MIXED_STRATEGY_EXTRA_TICKERS = {
|
||
"trend_rider_v6": sorted(set(ETF_UNIVERSE)),
|
||
"trend_rider_v6_top10": sorted(set(ETF_UNIVERSE)),
|
||
"us_three_sleeve": sorted(set(ETF_UNIVERSE)),
|
||
"us_momentum_qqq_trend": MOMENTUM_QQQ_TREND_ETFS,
|
||
}
|
||
|
||
# Strategies steered by agent score files (data/agent/us/). US only; before
|
||
# their first score file they hold what their base strategy holds, so they
|
||
# have no backtest of their own and precompute_backtest.py skips them.
|
||
AGENT_REVIEW_STRATEGIES = {"overnight_recovery_agent_top10"}
|
||
|
||
# Registered strategies the live monitor does not run, with the reason.
|
||
LIVE_EXCLUDED_STRATEGIES = {
|
||
"buy_and_hold": "benchmark is reported separately from strategy forward accounts",
|
||
}
|
||
|
||
DEFAULT_MONITOR_STRATEGIES = [
|
||
name for name in STRATEGY_REGISTRY if name not in LIVE_EXCLUDED_STRATEGIES and name not in EXECUTION_VARIANTS
|
||
]
|
||
|
||
|
||
def strategy_extra_tickers(strategy_name: str) -> list[str]:
|
||
"""Fixed ETF tickers a mixed stock+ETF strategy needs beside its stock universe."""
|
||
return list(MIXED_STRATEGY_EXTRA_TICKERS.get(canonical_strategy_name(strategy_name), []))
|
||
|
||
|
||
def strategy_fields(strategy_name: str) -> tuple[str, ...]:
|
||
"""Bar fields a strategy reads; close-price strategies read ("close",)."""
|
||
strategy = STRATEGY_REGISTRY[strategy_name.removeprefix("sim_")]()
|
||
return tuple(strategy.fields) if isinstance(strategy, BarStrategy) else ("close",)
|
||
|
||
|
||
def strategy_supports_market(strategy_name: str, market: str) -> bool:
|
||
"""ETF-based strategies trade USD-listed ETFs, so they only run in the US book."""
|
||
base_name = strategy_name.removeprefix("sim_")
|
||
if base_name in EXECUTION_VARIANTS:
|
||
return market == EXECUTION_VARIANTS[base_name][1]
|
||
if base_name in ETF_STRATEGY_UNIVERSES or base_name in MIXED_STRATEGY_EXTRA_TICKERS \
|
||
or base_name in AGENT_REVIEW_STRATEGIES:
|
||
return market == "us"
|
||
return True
|
||
|
||
|
||
def strategy_universe(market: str, strategy_name: str) -> tuple[list[str], str]:
|
||
"""Return tradable tickers and benchmark for a strategy.
|
||
|
||
Stock strategies use the market's dynamic universe. TrendRider variants
|
||
trade fixed USD/HK ETF baskets and use SPY as the regime benchmark.
|
||
Mixed strategies (e.g. V6) get the stock universe + extra ETF tickers.
|
||
"""
|
||
base_name = canonical_strategy_name(strategy_name)
|
||
if base_name in ETF_STRATEGY_UNIVERSES:
|
||
return ETF_STRATEGY_UNIVERSES[base_name], "SPY"
|
||
|
||
universe = UNIVERSES[market]
|
||
tickers = universe["fetch"]()
|
||
|
||
if base_name in MIXED_STRATEGY_EXTRA_TICKERS:
|
||
extras = MIXED_STRATEGY_EXTRA_TICKERS[base_name]
|
||
tickers = sorted(set(tickers + extras))
|
||
|
||
return tickers, universe["benchmark"]
|
||
|
||
|
||
def index_members(market: str, days: pd.DatetimeIndex) -> pd.DataFrame | None:
|
||
"""Point-in-time index membership (``days`` x every ticker that was a member
|
||
on one of them), or None for a market without membership history."""
|
||
if market not in ("us", "cn"):
|
||
return None
|
||
return universe_history.pit_membership(market, days)
|
||
|
||
|
||
def settlement_members(market: str, strategy_name: str, days: pd.DatetimeIndex) -> pd.DataFrame | None:
|
||
"""Index membership on ``days`` for delisting settlement; None where it does
|
||
not apply (A-shares, pure-ETF strategies)."""
|
||
if market != "us" or strategy_data_market(market, strategy_name) == "etfs":
|
||
return None
|
||
return index_members(market, days)
|
||
|
||
|
||
def pit_candidates(market: str, strategy_name: str, since: pd.Timestamp | None) -> list[str]:
|
||
"""Tickers that were index members on any day from ``since`` to today, so a
|
||
catch-up or replay has prices for names that have left the index since."""
|
||
days = pd.bdate_range(since or "2000-01-01", pd.Timestamp.today().normalize())
|
||
if len(days) == 0 or strategy_name.removeprefix("sim_") in ETF_STRATEGY_UNIVERSES:
|
||
return []
|
||
members = index_members(market, days)
|
||
return [] if members is None else list(members.columns)
|
||
|
||
|
||
def make_strategy(name: str, market: str):
|
||
strategy = STRATEGY_REGISTRY[name.removeprefix("sim_")]()
|
||
if isinstance(strategy, CompositeAlphaStrategy):
|
||
strategy.benchmark = UNIVERSES[market]["benchmark"]
|
||
if isinstance(strategy, BuyAndHoldStrategy):
|
||
strategy.asset = UNIVERSES[market]["benchmark"]
|
||
return strategy
|
||
|
||
|
||
def strategy_signal_tickers(name: str, market: str) -> list[str]:
|
||
strategy = make_strategy(name, market)
|
||
if isinstance(strategy, CompositeAlphaStrategy):
|
||
return [strategy.benchmark]
|
||
return [strategy.asset] if isinstance(strategy, BuyAndHoldStrategy) else []
|
||
|
||
|
||
def session_signals(market: str, strategy_name: str, tickers: list[str], days: pd.DatetimeIndex,
|
||
close: pd.DataFrame, open_data: pd.DataFrame,
|
||
*, bars: dict | None = None, member: pd.DataFrame | None = None,
|
||
strategy=None) -> pd.DataFrame:
|
||
if len(days) == 0:
|
||
return pd.DataFrame(index=days, columns=tickers, dtype=float)
|
||
close = close.loc[:days[-1]]
|
||
benchmark = "SPY" if strategy_name.removeprefix("sim_") in ETF_STRATEGY_UNIVERSES else UNIVERSES[market]["benchmark"]
|
||
if benchmark in close and benchmark in open_data:
|
||
valid_calendar = close[benchmark].notna() | open_data[benchmark].reindex(close.index).notna()
|
||
close = close.loc[valid_calendar]
|
||
if days[-1] not in close.index:
|
||
close = close.reindex(close.index.union(days))
|
||
pure_etf = strategy_name.removeprefix("sim_") in ETF_STRATEGY_UNIVERSES
|
||
if member is None and not pure_etf:
|
||
member = index_members(market, close.index)
|
||
extras = strategy_extra_tickers(strategy_name) + strategy_signal_tickers(strategy_name, market)
|
||
cols = (list(member.columns) + extras if member is not None else list(tickers) + extras)
|
||
cols = sorted(set(filter_tradable_tickers(close, cols)))
|
||
eligible = (member.reindex(index=close.index, columns=cols).fillna(False)
|
||
if member is not None else pd.DataFrame(True, index=close.index, columns=cols))
|
||
for t in extras:
|
||
if t in eligible:
|
||
eligible[t] = True
|
||
strategy = strategy if strategy is not None else make_strategy(strategy_name, market)
|
||
if bars is None:
|
||
bars = strategy_bars(market, strategy_name, cols, close, open_data)
|
||
if bars is not None:
|
||
bars = {f: b.reindex(index=close.index, columns=cols) for f, b in bars.items()}
|
||
targets = session_targets(strategy, close[cols], open_data.reindex(index=close.index, columns=cols),
|
||
bars, eligible)
|
||
return targets.reindex(days).fillna(0.0)
|
||
|
||
|
||
def strategy_data_market(market: str, strategy_name: str) -> str:
|
||
"""Return the cache namespace used for a strategy's price data."""
|
||
base_name = strategy_name.removeprefix("sim_")
|
||
return "etfs" if base_name in ETF_STRATEGY_UNIVERSES else market
|
||
|
||
|
||
def filter_tradable_tickers(price_data: pd.DataFrame, tickers: list[str]) -> list[str]:
|
||
"""Keep requested tickers that are present in a downloaded price panel."""
|
||
return [t for t in tickers if t in price_data.columns]
|
||
|
||
|
||
def _hk_columns(columns) -> list[str]:
|
||
return [c for c in columns if str(c).endswith(".HK")]
|
||
|
||
|
||
def convert_quote_currency(panel: pd.DataFrame) -> pd.DataFrame:
|
||
"""Express HK-listed tickers of a USD panel in USD using the HKD=X series."""
|
||
hk = _hk_columns(panel.columns)
|
||
if not hk:
|
||
return panel
|
||
if HKD_FX_TICKER in panel.columns:
|
||
fx = panel[HKD_FX_TICKER].ffill().fillna(HKD_PEG_MIDPOINT)
|
||
else:
|
||
print(f" WARNING: {HKD_FX_TICKER} not in panel; converting HK prices at "
|
||
f"the peg midpoint {HKD_PEG_MIDPOINT}", file=sys.stderr)
|
||
fx = pd.Series(HKD_PEG_MIDPOINT, index=panel.index)
|
||
out = panel.copy()
|
||
out[hk] = out[hk].div(fx, axis=0)
|
||
return out
|
||
|
||
|
||
def price_request(market: str, tickers: list[str]) -> list[str]:
|
||
"""Tickers to download for a USD panel, including FX needed for conversion."""
|
||
request = sorted(set(tickers))
|
||
if market != "cn" and _hk_columns(request):
|
||
request.append(HKD_FX_TICKER)
|
||
return request
|
||
|
||
|
||
def refresh_membership(market: str) -> None:
|
||
if market == "us":
|
||
universe_history.load_sp500_history()
|
||
elif market == "cn":
|
||
universe_history.load_csi300_history()
|
||
|
||
|
||
def load_strategy_prices(market: str, strategy_name: str, tickers: list[str],
|
||
benchmark: str, with_open: bool = False, refresh: bool = True):
|
||
"""Load a strategy's prices in its portfolio currency, refreshing them first
|
||
unless ``refresh`` is False (the panel is then read as stored).
|
||
|
||
Returns ``(close, open_or_None, close_raw)``. ``close_raw`` keeps NaN
|
||
where the exchange printed no bar that day, so callers can distinguish a
|
||
stale forward-filled price from a real one.
|
||
"""
|
||
data_market = strategy_data_market(market, strategy_name)
|
||
fields = ["close", "open", "volume"] if with_open else ["close", "volume"]
|
||
fields += [f for f in strategy_fields(strategy_name) if f not in fields]
|
||
if refresh:
|
||
refresh_membership(market)
|
||
request = price_request(market, list(tickers) + [benchmark])
|
||
panels = data_manager.update(data_market, request, fields=fields)
|
||
else:
|
||
panels = data_manager.load_fields(data_market, fields)
|
||
close = panels["close"]
|
||
open_data = panels["open"] if with_open else None
|
||
close_raw = data_manager.load_raw(data_market, "close").reindex(
|
||
index=close.index, columns=close.columns,
|
||
)
|
||
close = convert_quote_currency(close)
|
||
close_raw = convert_quote_currency(close_raw)
|
||
if open_data is not None:
|
||
open_data = convert_quote_currency(open_data)
|
||
return close, open_data, close_raw
|
||
|
||
|
||
def completed_sessions(close_raw: pd.DataFrame, data_market: str, benchmark: str,
|
||
now: datetime | None = None) -> pd.DatetimeIndex:
|
||
"""Trading days whose closing prices are final.
|
||
|
||
Stored panel rows are trading days (sparse rows are dropped on update).
|
||
The ETF panel mixes US and HK listings, so there a day also needs a
|
||
benchmark (SPY) print. Today's bar is excluded until the exchange has
|
||
closed and settled, because before that it is an intraday snapshot.
|
||
"""
|
||
index = close_raw.index
|
||
if data_market == "etfs" and benchmark in close_raw.columns:
|
||
index = index[close_raw[benchmark].notna().to_numpy()]
|
||
tz_name, hour, minute = MARKET_SESSION.get(data_market, MARKET_SESSION["us"])
|
||
now_local = (now or datetime.now(timezone.utc)).astimezone(zoneinfo.ZoneInfo(tz_name))
|
||
today = pd.Timestamp(now_local.date())
|
||
settled = now_local.replace(hour=hour, minute=minute, second=0, microsecond=0) + timedelta(
|
||
minutes=SESSION_SETTLE_MINUTES,
|
||
)
|
||
return index[index < today] if now_local < settled else index[index <= today]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Persistent state
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def book_dir(book: str | None) -> str:
|
||
"""Directory holding a book's state files (the original book: data/)."""
|
||
return os.path.join("data", "books", book) if book else "data"
|
||
|
||
|
||
def book_strategies(book: str | None, market: str) -> list[str]:
|
||
"""Strategies a book runs in ``market`` (the original book: all live ones)."""
|
||
names = LIVE_BOOKS[book]["strategies"][market] if book else DEFAULT_MONITOR_STRATEGIES
|
||
return [n for n in names if strategy_supports_market(n, market)]
|
||
|
||
|
||
def book_start(book: str | None, market: str) -> pd.Timestamp | None:
|
||
return pd.Timestamp(LIVE_BOOKS[book]["start"][market]) if book else None
|
||
|
||
|
||
def _state_path(market: str, strategy_name: str, book: str | None = None) -> str:
|
||
return os.path.join(book_dir(book), f"trader_{market}_{strategy_name}.json")
|
||
|
||
|
||
def load_state(market: str, strategy_name: str, book: str | None = None) -> dict:
|
||
path = _state_path(market, strategy_name, book)
|
||
if os.path.exists(path):
|
||
with open(path) as f:
|
||
return json.load(f)
|
||
return {}
|
||
|
||
|
||
def save_state(state: dict, market: str, strategy_name: str, book: str | None = None) -> None:
|
||
path = _state_path(market, strategy_name, book)
|
||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||
tmp = f"{path}.tmp"
|
||
with open(tmp, "w") as f:
|
||
json.dump(state, f, indent=2, default=str)
|
||
os.replace(tmp, path)
|
||
|
||
|
||
def init_state(market: str, strategy_name: str, capital: float) -> dict:
|
||
return {
|
||
"strategy": strategy_name,
|
||
"market": market,
|
||
"initial_capital": capital,
|
||
"cash": capital,
|
||
"holdings": {}, # ticker -> shares (float)
|
||
"pending_trades": None, # set by morning, consumed by evening
|
||
"trade_log": [], # list of trade dicts
|
||
"daily_equity": {}, # date_str -> portfolio value
|
||
"daily_log": [], # list of per-day snapshots (holdings, cash, trades)
|
||
"last_morning": None,
|
||
"last_evening": None,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Core logic
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def strategy_bars(market: str, strategy_name: str, tickers: list[str],
|
||
close: pd.DataFrame, open_data: pd.DataFrame | None) -> dict | None:
|
||
"""Stored bar panels a BarStrategy reads (refreshed by load_strategy_prices),
|
||
aligned to ``close``; None for close-price strategies."""
|
||
fields = strategy_fields(strategy_name)
|
||
if not isinstance(STRATEGY_REGISTRY[strategy_name.removeprefix("sim_")](), BarStrategy):
|
||
return None
|
||
stored = data_manager.load_fields(strategy_data_market(market, strategy_name),
|
||
[f for f in fields if f not in ("close", "open")])
|
||
bars = {"close": close[tickers]}
|
||
if "open" in fields:
|
||
bars["open"] = open_data[tickers]
|
||
for f in fields:
|
||
if f not in bars:
|
||
bars[f] = stored[f].reindex(index=close.index, columns=tickers)
|
||
return bars
|
||
|
||
|
||
def bar_session_weights(strategy: BarStrategy, bars: dict, days: pd.DatetimeIndex) -> pd.DataFrame:
|
||
"""Targets traded at the close of each day in ``days``: row d uses bars through d-1."""
|
||
hist = {f: b.loc[:days[-1]] for f, b in bars.items()}
|
||
close = hist["close"]
|
||
if days[-1] not in close.index:
|
||
# morning run before today's bar exists: add an empty row for today
|
||
hist = {f: pd.concat([b, pd.DataFrame(np.nan, index=[days[-1]], columns=b.columns)])
|
||
for f, b in hist.items()}
|
||
return strategy.generate_signals(hist).reindex(days).fillna(0.0)
|
||
|
||
|
||
|
||
def record_daily_snapshot(state: dict, date_str: str, prices: dict,
|
||
trades: list[dict], prev_equity: float,
|
||
target: dict | None = None, ideal_return: float | None = None) -> None:
|
||
"""Append a daily snapshot to state['daily_log'].
|
||
|
||
Each entry captures the full picture: date, holdings with prices/values,
|
||
cash, total equity, daily return, and today's operations. ``target`` is the
|
||
weight the strategy asked to hold from this close, and ``ideal_return`` what
|
||
the previous entry's target earned to this close without whole shares, fees,
|
||
the resize band or idle cash (see ``_ideal_return``).
|
||
"""
|
||
holdings = state["holdings"]
|
||
cash = state["cash"]
|
||
total = portfolio_value(holdings, prices, cash)
|
||
daily_ret = (total / prev_equity - 1) * 100 if prev_equity > 0 else 0.0
|
||
|
||
# Holdings detail: ticker -> {shares, price, value}
|
||
positions = {}
|
||
for ticker, shares in sorted(holdings.items()):
|
||
p = prices.get(ticker, 0.0)
|
||
positions[ticker] = {
|
||
"shares": shares,
|
||
"price": round(p, 6),
|
||
"value": round(shares * p, 2),
|
||
}
|
||
|
||
# Operations: list of {action, ticker, shares, price, value, commission}
|
||
operations = []
|
||
for t in trades:
|
||
p = prices.get(t["ticker"], t["price"])
|
||
operations.append({
|
||
"action": t["direction"],
|
||
"ticker": t["ticker"],
|
||
"shares": abs(t["shares_delta"]),
|
||
"price": round(p, 6),
|
||
"value": round(abs(t["shares_delta"]) * p, 2),
|
||
})
|
||
|
||
entry = {
|
||
"engine_version": state.get("engine_version"),
|
||
"date": date_str,
|
||
"equity": round(total, 2),
|
||
"cash": round(cash, 2),
|
||
"daily_return_pct": round(daily_ret, 2),
|
||
"n_positions": len(holdings),
|
||
"n_trades": len(trades),
|
||
"holdings": positions,
|
||
"operations": operations,
|
||
}
|
||
if target is not None:
|
||
entry["target"] = {t: round(float(w), 6) for t, w in sorted(target.items())}
|
||
if ideal_return is not None:
|
||
entry["ideal_return"] = round(float(ideal_return), 8)
|
||
|
||
if "daily_log" not in state:
|
||
state["daily_log"] = []
|
||
state["daily_log"].append(entry)
|
||
|
||
|
||
def get_prices_for_date(tickers: list[str], date_idx, price_df: pd.DataFrame) -> dict:
|
||
"""Extract prices for specific tickers on a given date from a DataFrame."""
|
||
if date_idx in price_df.index:
|
||
row = price_df.loc[date_idx]
|
||
else:
|
||
idx = price_df.index.get_indexer([date_idx], method="ffill")
|
||
if idx[0] >= 0:
|
||
row = price_df.iloc[idx[0]]
|
||
else:
|
||
return {}
|
||
return {t: float(row[t]) for t in tickers if t in row.index and pd.notna(row[t])}
|
||
|
||
|
||
def _ideal_return(prev_entry: dict | None, day: pd.Timestamp, close_data: pd.DataFrame) -> float | None:
|
||
"""Return of ``prev_entry``'s target weights from its close to ``day``'s
|
||
close: the strategy's own weights held frictionlessly (fractional shares,
|
||
no fees, no resize band; the unallocated part earns nothing). A name
|
||
without a price on either date contributes 0. None when the previous entry
|
||
recorded no target (state written before targets were recorded)."""
|
||
if not prev_entry or "target" not in prev_entry or prev_entry["date"] >= str(day.date()):
|
||
return None
|
||
w = prev_entry["target"]
|
||
if not w:
|
||
return 0.0
|
||
p0 = get_prices_for_date(list(w), pd.Timestamp(prev_entry["date"]), close_data)
|
||
p1 = get_prices_for_date(list(w), day, close_data)
|
||
return float(sum(x * (p1[t] / p0[t] - 1) for t, x in w.items() if t in p0 and t in p1 and p0[t] > 0))
|
||
|
||
|
||
def _ideal_window(state: dict) -> dict | None:
|
||
"""Live vs ideal over the days whose ``ideal_return`` is recorded."""
|
||
log = state.get("daily_log") or []
|
||
k = next((i for i, e in enumerate(log) if e.get("ideal_return") is not None), None)
|
||
if k is None or k == 0:
|
||
return None
|
||
ideal = float(np.prod([1 + e.get("ideal_return", 0.0) for e in log[k:]]) - 1)
|
||
live = log[-1]["equity"] / log[k - 1]["equity"] - 1
|
||
return {"start": log[k - 1]["date"], "live": live, "ideal": ideal, "gap": live - ideal}
|
||
|
||
|
||
def _with_last_known_prices(state: dict, prices: dict) -> dict:
|
||
"""Fill prices of held tickers missing from the panel with their last recorded price.
|
||
|
||
Without this a holding that drops out of the price panel would be valued
|
||
at zero and could never be sold.
|
||
"""
|
||
missing = [t for t in state["holdings"] if t not in prices]
|
||
if not missing:
|
||
return prices
|
||
out = dict(prices)
|
||
for entry in reversed(state.get("daily_log", [])):
|
||
for ticker in missing:
|
||
if ticker not in out:
|
||
price = entry.get("holdings", {}).get(ticker, {}).get("price")
|
||
if price:
|
||
out[ticker] = float(price)
|
||
if all(t in out for t in missing):
|
||
break
|
||
still = [t for t in missing if t not in out]
|
||
print(f" WARNING: no panel price for held {missing}; "
|
||
f"{'unvalued: ' + str(still) if still else 'using last recorded prices'}")
|
||
return out
|
||
|
||
|
||
def _book_capital(args, market: str) -> float:
|
||
book = getattr(args, "book", None)
|
||
if book:
|
||
return LIVE_BOOKS[book]["capital"][market]
|
||
return args.capital or MONITOR_CAPITAL.get(market, 10_000.0)
|
||
|
||
|
||
def _strategy_fees(args, market: str) -> dict:
|
||
book = getattr(args, "book", None)
|
||
if book:
|
||
if "fee_schedule" in LIVE_BOOKS[book]:
|
||
return dict(LIVE_BOOKS[book]["fee_schedule"][market])
|
||
return {"tx_cost": 0.0, "fixed_fee": LIVE_BOOKS[book]["fee_per_order"][market],
|
||
"fee_base": 0.0, "fee_per_share": 0.0}
|
||
tier = MARKET_FEE_TIERED.get(market, {})
|
||
return {
|
||
"tx_cost": args.tx_cost,
|
||
"fixed_fee": args.fixed_fee if args.fixed_fee > 0 else MARKET_FEES.get(market, 0.0),
|
||
"fee_base": tier.get("fee_base", 0.0),
|
||
"fee_per_share": tier.get("fee_per_share", 0.0),
|
||
}
|
||
|
||
|
||
def _signals_for_sessions(strategy_name: str, open_data: pd.DataFrame,
|
||
days: pd.DatetimeIndex, bars: dict | None = None) -> pd.DataFrame:
|
||
"""Target weights traded at the close of each day in ``days``, in one pass.
|
||
|
||
A close-price strategy reads opens as its prices: the row for ``d`` uses
|
||
opens up to ``d`` (a dummy row after the last open makes the strategy's
|
||
internal shift(1) yield that row). A BarStrategy uses completed bars
|
||
through d-1. Every registered strategy must be causal (weights at t never
|
||
depend on data after t), so one pass equals running each day separately.
|
||
"""
|
||
strategy = STRATEGY_REGISTRY[strategy_name]()
|
||
hist = open_data.loc[:days[-1]]
|
||
close = bars["close"].loc[:days[-1]] if bars is not None else hist
|
||
if days[-1] not in close.index:
|
||
close = close.reindex(close.index.union(days))
|
||
if bars is not None:
|
||
bars = {f: b.reindex(close.index) for f, b in bars.items()}
|
||
return session_targets(strategy, close, hist.reindex(close.index), bars).reindex(days).fillna(0.0)
|
||
|
||
|
||
def _run_session(state: dict, market: str, day: pd.Timestamp, benchmark: str,
|
||
close_data: pd.DataFrame, close_raw: pd.DataFrame, fees: dict,
|
||
integer_shares: bool, signal_row: pd.Series,
|
||
volume_row: pd.Series | None = None,
|
||
member_row: pd.Series | None = None) -> dict:
|
||
"""Execute ``signal_row`` (targets from ``day``'s open; a BarStrategy: bars
|
||
through the previous session, see ``session_signals``) at ``day``'s close
|
||
and record the day.
|
||
|
||
Tickers without a real closing print on ``day`` (forward-filled price) are
|
||
valued but not traded, except a held US stock that stopped printing and
|
||
left the index (``member_row``), which is settled at its last print as in
|
||
``execution.simulate_book``.
|
||
"""
|
||
day_str = str(day.date())
|
||
if state.get("engine_version") != ENGINE_VERSION:
|
||
state.setdefault("engine_changes", []).append({"first_session": day_str, "version": ENGINE_VERSION,
|
||
"recorded_at": datetime.now(timezone.utc).isoformat()})
|
||
state["engine_version"] = ENGINE_VERSION
|
||
lot_size = MARKET_LOT_SIZE.get(market, 1) if integer_shares else 1
|
||
target_weights = {t: float(w) for t, w in signal_row.items() if w > 1e-6}
|
||
|
||
all_held = sorted(set(target_weights) | set(state["holdings"]))
|
||
close_prices = _with_last_known_prices(
|
||
state, get_prices_for_date(all_held + [benchmark], day, close_data),
|
||
)
|
||
raw_row = close_raw.loc[day]
|
||
frozen = {t for t in all_held if t not in raw_row.index or pd.isna(raw_row[t])}
|
||
# Imported here so a running monitor's hot reload of this module does not
|
||
# need an execution module newer than the one it started with.
|
||
from execution import delisting_settlements
|
||
upto = close_raw.loc[:day]
|
||
without_print = {t: int((upto.index > upto[t].last_valid_index()).sum())
|
||
if t in upto.columns and upto[t].notna().any() else len(upto)
|
||
for t in state["holdings"]}
|
||
settle = delisting_settlements(market, state["holdings"], without_print, member_row)
|
||
target_weights = {t: w for t, w in target_weights.items() if t not in settle}
|
||
|
||
pre_value = portfolio_value(state["holdings"], close_prices, state["cash"])
|
||
previous_rows = close_data.loc[close_data.index < day]
|
||
previous = previous_rows.iloc[-1] if len(previous_rows) else pd.Series(dtype=float)
|
||
allowed_buy, allowed_sell, sell_tax = market_permissions(market, day, raw_row, previous, volume_row)
|
||
n_log = len(state["trade_log"])
|
||
actual = rebalance(state, target_weights, close_prices, set(raw_row.dropna().index) | settle,
|
||
transaction_cost=fees["tx_cost"], fixed_fee=fees["fixed_fee"],
|
||
fee_base=fees.get("fee_base", 0), fee_per_share=fees.get("fee_per_share", 0),
|
||
integer_shares=integer_shares, lot_size=lot_size,
|
||
trade_date=day_str, resize_band=strategy_resize_band(state["strategy"]),
|
||
can_buy=allowed_buy, can_sell=allowed_sell | settle, sell_tax=sell_tax)
|
||
for trade in actual:
|
||
if trade["ticker"] in settle:
|
||
trade["settled"] = True
|
||
trades = [{**t, "direction": t["action"], "est_value": t["value"],
|
||
"shares_delta": t["shares"] * (1 if t["action"] == "BUY" else -1)} for t in actual]
|
||
post_value = portfolio_value(state["holdings"], close_prices, state["cash"])
|
||
|
||
previous = [d for d in sorted(state["daily_equity"]) if d < day_str]
|
||
prev_eq = state["daily_equity"][previous[-1]] if previous else state["initial_capital"]
|
||
state["daily_equity"][day_str] = round(post_value, 2)
|
||
prev_entry = state["daily_log"][-1] if state.get("daily_log") else None
|
||
record_daily_snapshot(state, day_str, close_prices, trades, prev_eq, target=target_weights,
|
||
ideal_return=_ideal_return(prev_entry, day, close_data))
|
||
state["last_morning"] = day_str
|
||
state["last_evening"] = day_str
|
||
state["pending_trades"] = None
|
||
return {
|
||
"date": day_str,
|
||
"value": post_value,
|
||
"daily_return": post_value / prev_eq - 1 if prev_eq else 0.0,
|
||
"trades": len(state["trade_log"]) - n_log,
|
||
"commission": sum(t["commission"] for t in state["trade_log"][n_log:]),
|
||
"frozen": sorted(t for t in frozen if t in state["holdings"] or t in target_weights),
|
||
"positions": len(state["holdings"]),
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Commands
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def cmd_morning(args):
|
||
"""Morning: download open prices, generate today's trade orders."""
|
||
market = args.market
|
||
strategy_name = args.strategy
|
||
if not strategy_supports_market(strategy_name, market):
|
||
print(f"{strategy_name} does not run in the {market.upper()} book; skipping.")
|
||
return
|
||
book = getattr(args, "book", None)
|
||
tickers, benchmark = strategy_universe(market, strategy_name)
|
||
|
||
# Load or init state
|
||
state = load_state(market, strategy_name, book)
|
||
if not state:
|
||
capital = _book_capital(args, market)
|
||
state = init_state(market, strategy_name, capital)
|
||
print(f"--- Initialized new portfolio: {capital:,.0f} cash ---")
|
||
|
||
# Download data (close + open)
|
||
close_data, open_data, close_raw = load_strategy_prices(
|
||
market, strategy_name, tickers, benchmark, with_open=True,
|
||
refresh=getattr(args, "refresh", True),
|
||
)
|
||
tickers = filter_tradable_tickers(close_data, tickers)
|
||
|
||
today = open_data.index[-1]
|
||
today_str = str(today.date())
|
||
start = book_start(book, market)
|
||
if start is not None and today < start:
|
||
print(f"{strategy_name}: book {book} starts on {start.date()}; no orders on {today_str}.")
|
||
return
|
||
|
||
if state["last_morning"] == today_str:
|
||
print(f"Morning already run for {today_str}. Showing pending trades.\n")
|
||
|
||
print(f"\n{'='*60}")
|
||
print(f" MORNING SIGNAL — {today_str}")
|
||
print(f" Strategy: {strategy_name} | Market: {market.upper()}")
|
||
print(f"{'='*60}")
|
||
|
||
# Run strategy on open prices (a BarStrategy: on completed bars)
|
||
row = session_signals(market, strategy_name, tickers, pd.DatetimeIndex([today]),
|
||
close_raw, open_data).loc[today]
|
||
target_weights = {t: float(w) for t, w in row.items() if w > 1e-6}
|
||
|
||
# Get today's open prices for trade computation
|
||
open_prices = get_prices_for_date(
|
||
list(set(list(target_weights.keys()) + list(state["holdings"].keys()))),
|
||
today, open_data
|
||
)
|
||
|
||
total = portfolio_value(state["holdings"], open_prices, state["cash"])
|
||
trades = compute_trades(state["holdings"], state["cash"], target_weights,
|
||
open_prices, min_trade_value=max(50, total * 0.001),
|
||
integer_shares=args.integer_shares,
|
||
lot_size=MARKET_LOT_SIZE.get(market, 1),
|
||
resize_band=strategy_resize_band(strategy_name))
|
||
|
||
# Store pending
|
||
state["pending_trades"] = {
|
||
"date": today_str,
|
||
"target_weights": target_weights,
|
||
"trades": trades,
|
||
}
|
||
state["last_morning"] = today_str
|
||
save_state(state, market, strategy_name, book)
|
||
|
||
# Print summary
|
||
print(f"\n Portfolio value (at open): ${total:,.2f}")
|
||
print(f" Cash: ${state['cash']:,.2f}")
|
||
print(f" Target positions: {len(target_weights)}")
|
||
print(f" Trades needed: {len(trades)}")
|
||
|
||
if target_weights:
|
||
print(f"\n {'Target Weights':}")
|
||
print(f" {'Ticker':<8} {'Weight':>8} {'Open Price':>12}")
|
||
print(f" {'-'*30}")
|
||
for t, w in sorted(target_weights.items(), key=lambda x: -x[1]):
|
||
p = open_prices.get(t, 0)
|
||
print(f" {t:<8} {w:>7.1%} {p:>11.2f}")
|
||
|
||
if trades:
|
||
print(f"\n {'Trade Orders (execute at close)':}")
|
||
print(f" {'Action':<6} {'Ticker':<8} {'Shares':>10} {'~Value':>12} {'Open':>10}")
|
||
print(f" {'-'*48}")
|
||
total_buy = 0
|
||
total_sell = 0
|
||
for t in trades:
|
||
print(f" {t['direction']:<6} {t['ticker']:<8} {t['shares_delta']:>+10.2f} "
|
||
f"${t['est_value']:>10,.2f} {t['price']:>10.2f}")
|
||
if t["direction"] == "BUY":
|
||
total_buy += t["est_value"]
|
||
else:
|
||
total_sell += t["est_value"]
|
||
print(f"\n Total buys: ${total_buy:>12,.2f}")
|
||
print(f" Total sells: ${total_sell:>12,.2f}")
|
||
print(f" Net flow: ${total_sell - total_buy:>+12,.2f}")
|
||
else:
|
||
print("\n No trades needed today.")
|
||
|
||
print()
|
||
|
||
|
||
def cmd_evening(args):
|
||
"""Evening: record execution at close prices, update portfolio."""
|
||
market = args.market
|
||
strategy_name = args.strategy
|
||
book = getattr(args, "book", None)
|
||
tickers, benchmark = strategy_universe(market, strategy_name)
|
||
|
||
state = load_state(market, strategy_name, book)
|
||
if not state:
|
||
print("No state found. Run 'morning' first.")
|
||
return
|
||
|
||
if not state.get("pending_trades"):
|
||
print("No pending trades. Run 'morning' first.")
|
||
return
|
||
|
||
pending = state["pending_trades"]
|
||
trade_date = pending["date"]
|
||
|
||
if state["last_evening"] == trade_date:
|
||
print(f"Evening already recorded for {trade_date}.")
|
||
return
|
||
|
||
# Get close prices
|
||
close_data, _, close_raw = load_strategy_prices(market, strategy_name, tickers, benchmark)
|
||
tickers = filter_tradable_tickers(close_data, tickers)
|
||
|
||
target_date = pd.Timestamp(trade_date)
|
||
all_held = list(set(
|
||
list(pending.get("target_weights", {}).keys()) +
|
||
list(state["holdings"].keys())
|
||
))
|
||
close_prices = _with_last_known_prices(
|
||
state, get_prices_for_date(all_held, target_date, close_data),
|
||
)
|
||
|
||
# Recompute trades at close prices for accurate execution
|
||
trades = pending.get("trades", [])
|
||
|
||
print(f"\n{'='*60}")
|
||
print(f" EVENING EXECUTION — {trade_date}")
|
||
print(f" Strategy: {strategy_name} | Market: {market.upper()}")
|
||
print(f"{'='*60}")
|
||
|
||
pre_value = portfolio_value(state["holdings"], close_prices, state["cash"])
|
||
print(f"\n Pre-trade value: ${pre_value:,.2f}")
|
||
|
||
# Recompute trades with close prices for execution
|
||
target_weights = pending.get("target_weights", {})
|
||
lot_size = MARKET_LOT_SIZE.get(market, 1)
|
||
first_log = len(state["trade_log"])
|
||
volume = data_manager.load_raw(strategy_data_market(market, strategy_name), "volume")
|
||
members = settlement_members(market, strategy_name, pd.DatetimeIndex([target_date]))
|
||
_run_session(state, market, target_date, benchmark, close_data, close_raw,
|
||
_strategy_fees(args, market), args.integer_shares,
|
||
pd.Series(target_weights, dtype=float),
|
||
volume_row=volume.reindex(close_data.index).loc[target_date] if volume is not None else None,
|
||
member_row=members.loc[target_date] if members is not None else None)
|
||
exec_trades = [{**t, "direction": t["action"], "est_value": t["value"]}
|
||
for t in state["trade_log"][first_log:]]
|
||
|
||
post_value = portfolio_value(state["holdings"], close_prices, state["cash"])
|
||
state["last_evening"] = trade_date
|
||
save_state(state, market, strategy_name, book)
|
||
|
||
n_executed = len(exec_trades)
|
||
total_commission = sum(t.get("commission", 0)
|
||
for t in state["trade_log"][-n_executed:]) if n_executed else 0
|
||
|
||
print(f" Trades executed: {n_executed}")
|
||
print(f" Total commission: ${total_commission:,.2f}")
|
||
print(f" Post-trade value: ${post_value:,.2f}")
|
||
print(f" Cash remaining: ${state['cash']:,.2f}")
|
||
print(f" Holdings: {len(state['holdings'])} positions")
|
||
|
||
if exec_trades:
|
||
print(f"\n {'Executed':}")
|
||
print(f" {'Action':<6} {'Ticker':<8} {'Shares':>10} {'Close':>10} {'Value':>12}")
|
||
print(f" {'-'*48}")
|
||
for t in exec_trades:
|
||
p = close_prices.get(t["ticker"], t["price"])
|
||
v = abs(t["shares_delta"]) * p
|
||
print(f" {t['direction']:<6} {t['ticker']:<8} {t['shares_delta']:>+10.2f} "
|
||
f"{p:>10.2f} ${v:>10,.2f}")
|
||
|
||
pnl = post_value - state["initial_capital"]
|
||
pnl_pct = pnl / state["initial_capital"] * 100
|
||
print(f"\n P&L since inception: ${pnl:>+,.2f} ({pnl_pct:>+.2f}%)")
|
||
print()
|
||
|
||
|
||
def cmd_status(args):
|
||
"""Print current portfolio status."""
|
||
market = args.market
|
||
strategy_name = args.strategy
|
||
|
||
state = load_state(market, strategy_name, getattr(args, "book", None))
|
||
if not state:
|
||
print("No state found. Run 'morning' to initialize.")
|
||
return
|
||
|
||
# Get latest prices
|
||
tickers, benchmark = strategy_universe(market, strategy_name)
|
||
close_data, _, close_raw = load_strategy_prices(market, strategy_name, tickers, benchmark)
|
||
|
||
last_date = close_data.index[-1]
|
||
all_held = list(state["holdings"].keys())
|
||
prices = _with_last_known_prices(
|
||
state, get_prices_for_date(all_held + [benchmark], last_date, close_data),
|
||
)
|
||
|
||
total = portfolio_value(state["holdings"], prices, state["cash"])
|
||
pnl = total - state["initial_capital"]
|
||
|
||
print(f"\n{'='*60}")
|
||
print(f" PORTFOLIO STATUS")
|
||
print(f" Strategy: {strategy_name} | Market: {market.upper()}")
|
||
print(f" Last data: {last_date.date()}")
|
||
print(f"{'='*60}")
|
||
|
||
print(f"\n Initial capital: ${state['initial_capital']:>12,.2f}")
|
||
print(f" Current value: ${total:>12,.2f}")
|
||
print(f" Cash: ${state['cash']:>12,.2f}")
|
||
print(f" P&L: ${pnl:>+12,.2f} ({pnl/state['initial_capital']*100:>+.2f}%)")
|
||
print(f" Last morning: {state.get('last_morning', 'N/A')}")
|
||
print(f" Last evening: {state.get('last_evening', 'N/A')}")
|
||
|
||
if state["holdings"]:
|
||
print(f"\n {'Holdings':}")
|
||
print(f" {'Ticker':<8} {'Shares':>10} {'Price':>10} {'Value':>12} {'Weight':>8}")
|
||
print(f" {'-'*52}")
|
||
stock_value = total - state["cash"]
|
||
for ticker, shares in sorted(state["holdings"].items(),
|
||
key=lambda x: -x[1] * prices.get(x[0], 0)):
|
||
p = prices.get(ticker, 0)
|
||
v = shares * p
|
||
w = v / total if total > 0 else 0
|
||
print(f" {ticker:<8} {shares:>10.2f} {p:>10.2f} ${v:>10,.2f} {w:>7.1%}")
|
||
print(f" {'Cash':<8} {'':>10} {'':>10} ${state['cash']:>10,.2f} "
|
||
f"{state['cash']/total*100 if total > 0 else 0:>6.1f}%")
|
||
else:
|
||
print("\n No holdings (100% cash)")
|
||
|
||
# Equity curve
|
||
if state.get("daily_equity"):
|
||
eq = state["daily_equity"]
|
||
dates = sorted(eq.keys())
|
||
print(f"\n {'Equity History (last 10 days)':}")
|
||
print(f" {'Date':<12} {'Value':>12} {'Daily':>8}")
|
||
print(f" {'-'*34}")
|
||
for i, d in enumerate(dates[-10:]):
|
||
v = eq[d]
|
||
prev = eq[dates[dates.index(d) - 1]] if dates.index(d) > 0 else state["initial_capital"]
|
||
daily_ret = (v / prev - 1) * 100
|
||
print(f" {d:<12} ${v:>10,.2f} {daily_ret:>+7.2f}%")
|
||
|
||
# Recent trades
|
||
log = state.get("trade_log", [])
|
||
if log:
|
||
recent = log[-15:]
|
||
print(f"\n {'Recent Trades (last 15)':}")
|
||
print(f" {'Date':<12} {'Action':<6} {'Ticker':<8} {'Shares':>8} {'Price':>10} {'Value':>10}")
|
||
print(f" {'-'*58}")
|
||
for t in recent:
|
||
print(f" {t['date']:<12} {t['action']:<6} {t['ticker']:<8} "
|
||
f"{t['shares']:>8.2f} {t['price']:>10.2f} ${t['value']:>8,.2f}")
|
||
|
||
print()
|
||
|
||
|
||
def cmd_compare(args):
|
||
"""Compare multiple strategies side-by-side."""
|
||
import glob as _glob
|
||
|
||
market = args.market
|
||
strategy_names = getattr(args, "strategy", None)
|
||
book = getattr(args, "book", None)
|
||
|
||
# Auto-discover: find all trader_{market}_*.json state files
|
||
if not strategy_names:
|
||
pattern = os.path.join(book_dir(book), f"trader_{market}_*.json")
|
||
files = sorted(_glob.glob(pattern))
|
||
strategy_names = []
|
||
for f in files:
|
||
# Extract strategy name: trader_{market}_{name}.json
|
||
base = os.path.basename(f) # trader_us_foo.json
|
||
prefix = f"trader_{market}_"
|
||
name = base[len(prefix):-len(".json")]
|
||
# Skip sim_ prefixed (historical simulations) unless no live ones
|
||
strategy_names.append(name)
|
||
|
||
if not strategy_names:
|
||
print(f"No state files found for market '{market}' in data/.")
|
||
print(f"Run 'monitor' or 'auto' first to generate state.")
|
||
return
|
||
|
||
print(f" Auto-discovered {len(strategy_names)} strategies: "
|
||
f"{', '.join(strategy_names)}")
|
||
|
||
# Load all states
|
||
states = {}
|
||
for name in strategy_names:
|
||
state = load_state(market, name, book)
|
||
if state and state.get("daily_equity"):
|
||
states[name] = state
|
||
else:
|
||
print(f" Warning: no data for '{name}' — skipping")
|
||
|
||
if not states:
|
||
print("No strategies with data found.")
|
||
return
|
||
|
||
# Find common date range
|
||
all_dates = set()
|
||
for state in states.values():
|
||
all_dates.update(state["daily_equity"].keys())
|
||
all_dates = sorted(all_dates)
|
||
|
||
if not all_dates:
|
||
print("No equity data found.")
|
||
return
|
||
|
||
print(f"\n{'='*80}")
|
||
print(f" STRATEGY COMPARISON — {market.upper()}")
|
||
print(f" Period: {all_dates[0]} to {all_dates[-1]} ({len(all_dates)} days)")
|
||
print(f"{'='*80}")
|
||
|
||
# Summary table
|
||
results = []
|
||
for name, state in states.items():
|
||
eq = state["daily_equity"]
|
||
dates = sorted(eq.keys())
|
||
if len(dates) < 2:
|
||
continue
|
||
|
||
initial = state["initial_capital"]
|
||
final = eq[dates[-1]]
|
||
n_days = len(dates)
|
||
total_ret = (final / initial - 1) * 100
|
||
ann_ret = ((final / initial) ** (252 / n_days) - 1) * 100 if n_days > 0 else 0
|
||
|
||
# Max drawdown
|
||
values = [eq[d] for d in dates]
|
||
peak = values[0]
|
||
max_dd = 0
|
||
for v in values:
|
||
peak = max(peak, v)
|
||
dd = (v - peak) / peak
|
||
max_dd = min(max_dd, dd)
|
||
|
||
# Sharpe
|
||
daily_rets = []
|
||
for i in range(1, len(values)):
|
||
daily_rets.append(values[i] / values[i - 1] - 1)
|
||
daily_rets = np.array(daily_rets) if daily_rets else np.array([0])
|
||
sharpe = (daily_rets.mean() / daily_rets.std() * np.sqrt(252)) if daily_rets.std() > 0 else 0
|
||
|
||
n_trades = len(state.get("trade_log", []))
|
||
total_comm = sum(t.get("commission", 0) for t in state.get("trade_log", []))
|
||
|
||
results.append({
|
||
"name": name,
|
||
"initial": initial,
|
||
"final": final,
|
||
"total_ret": total_ret,
|
||
"ann_ret": ann_ret,
|
||
"sharpe": sharpe,
|
||
"max_dd": max_dd * 100,
|
||
"n_trades": n_trades,
|
||
"commission": total_comm,
|
||
"n_days": n_days,
|
||
"n_positions": len(state.get("holdings", {})),
|
||
"cash": state.get("cash", 0),
|
||
"ideal": _ideal_window(state),
|
||
})
|
||
|
||
# Sort by total return descending
|
||
results.sort(key=lambda r: -r["total_ret"])
|
||
|
||
# Print ranking
|
||
print(f"\n {'#':<3} {'Strategy':<25} {'Return':>9} {'Ann.Ret':>9} "
|
||
f"{'Sharpe':>8} {'MaxDD':>8} {'Trades':>7} {'Final':>14} {'Ideal':>8} {'Gap':>7}")
|
||
print(f" {'─'*104}")
|
||
|
||
for i, r in enumerate(results, 1):
|
||
iw = r["ideal"]
|
||
ideal = f"{iw['ideal'] * 100:>+7.1f}% {iw['gap'] * 100:>+6.1f}pp" if iw else f"{'—':>8} {'—':>7}"
|
||
print(f" {i:<3} {r['name']:<25} {r['total_ret']:>+8.1f}% {r['ann_ret']:>+8.1f}% "
|
||
f"{r['sharpe']:>8.2f} {r['max_dd']:>7.1f}% {r['n_trades']:>7} "
|
||
f"${r['final']:>12,.2f} {ideal}")
|
||
starts = sorted({r["ideal"]["start"] for r in results if r["ideal"]})
|
||
if starts:
|
||
print(f" Ideal: the strategy's own target weights held without whole shares, fees, the resize band "
|
||
f"or idle cash, from the close of {starts[0]}" + (f" (latest start {starts[-1]})" if len(starts) > 1 else "")
|
||
+ ". Gap: live return minus ideal over the same days.")
|
||
else:
|
||
print(" Ideal: not recorded yet (state files record session targets from the next session on).")
|
||
|
||
# Equity curve comparison (last 20 dates)
|
||
recent_dates = all_dates[-20:]
|
||
print(f"\n Equity Curve (last {len(recent_dates)} days):")
|
||
header = f" {'Date':<12}"
|
||
for r in results:
|
||
header += f" {r['name'][:14]:>14}"
|
||
print(header)
|
||
print(f" {'─' * (12 + 15 * len(results))}")
|
||
|
||
for d in recent_dates:
|
||
row = f" {d:<12}"
|
||
for r in results:
|
||
eq = states[r["name"]]["daily_equity"]
|
||
if d in eq:
|
||
row += f" ${eq[d]:>12,.2f}"
|
||
else:
|
||
row += f" {'—':>14}"
|
||
print(row)
|
||
|
||
# Winner summary
|
||
if results:
|
||
best = results[0]
|
||
worst = results[-1]
|
||
print(f"\n Best: {best['name']} ({best['total_ret']:>+.1f}%)")
|
||
if len(results) > 1:
|
||
print(f" Worst: {worst['name']} ({worst['total_ret']:>+.1f}%)")
|
||
print(f" Spread: {best['total_ret'] - worst['total_ret']:.1f}pp")
|
||
print()
|
||
|
||
|
||
def cmd_log(args):
|
||
"""Print the daily log: each day's holdings, cash, operations."""
|
||
market = args.market
|
||
strategy_name = args.strategy
|
||
|
||
state = load_state(market, strategy_name, getattr(args, "book", None))
|
||
if not state:
|
||
print("No state found. Run 'simulate' or 'auto' first.")
|
||
return
|
||
|
||
daily_log = state.get("daily_log", [])
|
||
if not daily_log:
|
||
print("No daily log entries. Re-run simulate with the latest code to generate logs.")
|
||
return
|
||
|
||
# Optional date filter
|
||
start = args.start if hasattr(args, "start") and args.start else None
|
||
end = args.end if hasattr(args, "end") and args.end else None
|
||
|
||
filtered = daily_log
|
||
if start:
|
||
filtered = [d for d in filtered if d["date"] >= start]
|
||
if end:
|
||
filtered = [d for d in filtered if d["date"] <= end]
|
||
|
||
if not filtered:
|
||
print(f"No log entries in range {start or '...'} to {end or '...'}.")
|
||
return
|
||
|
||
print(f"\n{'='*80}")
|
||
print(f" DAILY LOG — {strategy_name} | {market.upper()}")
|
||
print(f" {filtered[0]['date']} to {filtered[-1]['date']} ({len(filtered)} days)")
|
||
print(f"{'='*80}")
|
||
|
||
for entry in filtered:
|
||
d = entry["date"]
|
||
eq = entry["equity"]
|
||
cash = entry["cash"]
|
||
ret = entry["daily_return_pct"]
|
||
ops = entry.get("operations", [])
|
||
holds = entry.get("holdings", {})
|
||
|
||
print(f"\n┌─ {d} equity: ${eq:>10,.2f} daily: {ret:>+.2f}% "
|
||
f"cash: ${cash:>10,.2f} positions: {entry['n_positions']}")
|
||
|
||
# Holdings
|
||
if holds:
|
||
print(f"│ {'Ticker':<8} {'Shares':>8} {'Price':>10} {'Value':>12} {'Weight':>8}")
|
||
print(f"│ {'─'*50}")
|
||
for ticker in sorted(holds, key=lambda t: -holds[t]["value"]):
|
||
h = holds[ticker]
|
||
w = h["value"] / eq * 100 if eq > 0 else 0
|
||
print(f"│ {ticker:<8} {h['shares']:>8} {h['price']:>10.2f} "
|
||
f"${h['value']:>10,.2f} {w:>7.1f}%")
|
||
cash_w = cash / eq * 100 if eq > 0 else 0
|
||
print(f"│ {'Cash':<8} {'':>8} {'':>10} ${cash:>10,.2f} {cash_w:>7.1f}%")
|
||
|
||
# Operations
|
||
if ops:
|
||
print(f"│")
|
||
print(f"│ Operations:")
|
||
print(f"│ {'Action':<6} {'Ticker':<8} {'Shares':>8} {'Price':>10} {'Value':>12}")
|
||
print(f"│ {'─'*46}")
|
||
for op in ops:
|
||
sign = "+" if op["action"] == "BUY" else "-"
|
||
print(f"│ {op['action']:<6} {op['ticker']:<8} {sign}{op['shares']:>7} "
|
||
f"{op['price']:>10.2f} ${op['value']:>10,.2f}")
|
||
else:
|
||
print(f"│ (no trades)")
|
||
|
||
print(f"└{'─'*79}")
|
||
|
||
# Summary
|
||
first = filtered[0]
|
||
last = filtered[-1]
|
||
total_ops = sum(e["n_trades"] for e in filtered)
|
||
trade_days = sum(1 for e in filtered if e["n_trades"] > 0)
|
||
print(f"\n Period: {first['date']} → {last['date']}")
|
||
print(f" Start equity: ${first['equity']:>12,.2f}")
|
||
print(f" End equity: ${last['equity']:>12,.2f}")
|
||
print(f" Return: {(last['equity']/first['equity']-1)*100:>+11.2f}%")
|
||
print(f" Total trades: {total_ops:>11}")
|
||
print(f" Trade days: {trade_days:>11} / {len(filtered)}")
|
||
print()
|
||
|
||
|
||
def cmd_simulate(args):
|
||
"""Simulate day-by-day over a date range."""
|
||
market = args.market
|
||
strategy_name = args.strategy
|
||
tickers, benchmark = strategy_universe(market, strategy_name)
|
||
start = pd.Timestamp(args.start)
|
||
end = pd.Timestamp(args.end)
|
||
|
||
# Load both open and close data
|
||
request = sorted(set(tickers) | set(pit_candidates(market, strategy_name, start)))
|
||
close_data, open_data, close_raw = load_strategy_prices(
|
||
market, strategy_name, request, benchmark, with_open=True,
|
||
)
|
||
tickers = filter_tradable_tickers(close_data, tickers)
|
||
|
||
# Date range
|
||
sessions = completed_sessions(close_raw, strategy_data_market(market, strategy_name), benchmark)
|
||
trading_days = sessions[(sessions >= start) & (sessions <= end)]
|
||
|
||
if len(trading_days) == 0:
|
||
print(f"No trading days found between {args.start} and {args.end}")
|
||
return
|
||
|
||
# Fresh state for simulation
|
||
state = init_state(market, f"sim_{strategy_name}", args.capital)
|
||
|
||
# Pre-compute strategy weights for the full period (much faster than per-day),
|
||
# each day on its point-in-time index members
|
||
full_weights = session_signals(market, strategy_name, tickers, trading_days,
|
||
close_raw, open_data)
|
||
|
||
print(f"\n{'='*70}")
|
||
print(f" SIMULATION: {strategy_name} | {market.upper()}")
|
||
print(f" Period: {trading_days[0].date()} to {trading_days[-1].date()} "
|
||
f"({len(trading_days)} trading days)")
|
||
print(f" Capital: ${args.capital:,.0f}")
|
||
print(f"{'='*70}\n")
|
||
|
||
total_trades = 0
|
||
total_commission = 0.0
|
||
|
||
volume = data_manager.load_raw(strategy_data_market(market, strategy_name), "volume")
|
||
members = settlement_members(market, strategy_name, trading_days)
|
||
for i, day in enumerate(trading_days):
|
||
day_str = str(day.date())
|
||
|
||
stats = _run_session(state, market, day, benchmark, close_data, close_raw,
|
||
_strategy_fees(args, market), args.integer_shares,
|
||
full_weights.loc[day],
|
||
volume_row=(volume.loc[day] if day in volume.index else pd.Series(dtype=float)) if volume is not None else None,
|
||
member_row=members.loc[day] if members is not None else None)
|
||
post_value = stats["value"]
|
||
day_trades, day_commission = stats["trades"], stats["commission"]
|
||
total_trades += day_trades
|
||
total_commission += day_commission
|
||
|
||
# Progress: print weekly or on trade days
|
||
if day_trades > 0 or i == 0 or i == len(trading_days) - 1:
|
||
prev_date = dates[-1] if (dates := sorted(state["daily_equity"].keys())[:-1]) else None
|
||
prev_val = state["daily_equity"].get(prev_date, args.capital) if prev_date else args.capital
|
||
daily_ret = (post_value / prev_val - 1) * 100 if prev_val > 0 else 0
|
||
print(f" {day_str} ${post_value:>12,.2f} {daily_ret:>+7.2f}% "
|
||
f"trades: {day_trades:>2} positions: {len(state['holdings']):>2}")
|
||
|
||
# Final summary
|
||
equity = state["daily_equity"]
|
||
dates = sorted(equity.keys())
|
||
final = equity[dates[-1]]
|
||
initial = args.capital
|
||
total_ret = (final / initial - 1) * 100
|
||
n_days = len(dates)
|
||
ann_ret = ((final / initial) ** (252 / n_days) - 1) * 100 if n_days > 0 else 0
|
||
|
||
# Max drawdown
|
||
values = [equity[d] for d in dates]
|
||
peak = values[0]
|
||
max_dd = 0
|
||
for v in values:
|
||
peak = max(peak, v)
|
||
dd = (v - peak) / peak
|
||
max_dd = min(max_dd, dd)
|
||
|
||
# Daily returns for Sharpe
|
||
daily_rets = []
|
||
for i in range(1, len(values)):
|
||
daily_rets.append(values[i] / values[i - 1] - 1)
|
||
daily_rets = np.array(daily_rets) if daily_rets else np.array([0])
|
||
sharpe = (daily_rets.mean() / daily_rets.std() * np.sqrt(252)) if daily_rets.std() > 0 else 0
|
||
|
||
print(f"\n{'='*70}")
|
||
print(f" SIMULATION RESULTS")
|
||
print(f"{'='*70}")
|
||
print(f" Initial capital: ${initial:>12,.2f}")
|
||
print(f" Final value: ${final:>12,.2f}")
|
||
print(f" Total return: {total_ret:>+11.2f}%")
|
||
print(f" Annualized return: {ann_ret:>+11.2f}%")
|
||
print(f" Sharpe ratio: {sharpe:>11.2f}")
|
||
print(f" Max drawdown: {max_dd * 100:>11.2f}%")
|
||
print(f" Total trades: {total_trades:>11}")
|
||
print(f" Total commission: ${total_commission:>11,.2f}")
|
||
print(f" Trading days: {n_days:>11}")
|
||
|
||
# Save state for review
|
||
state["strategy"] = strategy_name
|
||
state["market"] = market
|
||
save_state(state, market, f"sim_{strategy_name}")
|
||
print(f"\n State saved to: {_state_path(market, f'sim_{strategy_name}')}")
|
||
|
||
# Benchmark comparison
|
||
bench_start = close_data[benchmark].loc[trading_days[0]]
|
||
bench_end = close_data[benchmark].loc[trading_days[-1]]
|
||
bench_ret = (bench_end / bench_start - 1) * 100
|
||
print(f"\n Benchmark ({benchmark}): {bench_ret:>+.2f}%")
|
||
print(f" Alpha: {total_ret - bench_ret:>+.2f}%")
|
||
print()
|
||
|
||
|
||
def cmd_monitor(args):
|
||
"""
|
||
Long-running monitor — runs in a tmux session, automatically executes daily.
|
||
|
||
Manages ALL markets (US + CN) in a single process. Each market has its
|
||
own timezone and two daily phases (morning + evening). All strategies
|
||
run for every market.
|
||
|
||
Usage:
|
||
tmux new -s quant
|
||
uv run python trader.py monitor
|
||
# Ctrl-B D to detach
|
||
"""
|
||
import copy
|
||
import time as _time
|
||
import zoneinfo
|
||
|
||
markets = args.market # list of markets
|
||
strategies = args.strategy # list of strategy names
|
||
|
||
# Market schedule configuration — two phases per day
|
||
MARKET_CONFIG = {
|
||
"us": {
|
||
"tz": zoneinfo.ZoneInfo("America/New_York"),
|
||
"open_hour": 9, "open_min": 30, "open_buffer": 15, # run at 9:45 AM ET
|
||
"close_hour": 16, "close_min": 0, "close_buffer": 35, # run at 4:35 PM ET
|
||
"label": "US (NYSE/NASDAQ)",
|
||
},
|
||
"cn": {
|
||
"tz": zoneinfo.ZoneInfo("Asia/Shanghai"),
|
||
"open_hour": 9, "open_min": 30, "open_buffer": 15, # run at 9:45 AM CST
|
||
"close_hour": 15, "close_min": 0, "close_buffer": 35, # run at 3:35 PM CST
|
||
"label": "CN (SSE/SZSE)",
|
||
},
|
||
}
|
||
|
||
# Compute run times for each market
|
||
market_schedules = {}
|
||
for mkt in markets:
|
||
cfg = MARKET_CONFIG[mkt]
|
||
tz = cfg["tz"]
|
||
morn_h = cfg["open_hour"]
|
||
morn_m = cfg["open_min"] + cfg["open_buffer"]
|
||
if morn_m >= 60:
|
||
morn_h += morn_m // 60
|
||
morn_m = morn_m % 60
|
||
eve_h = cfg["close_hour"]
|
||
eve_m = cfg["close_min"] + cfg["close_buffer"]
|
||
if eve_m >= 60:
|
||
eve_h += eve_m // 60
|
||
eve_m = eve_m % 60
|
||
market_schedules[mkt] = {
|
||
"tz": tz, "label": cfg["label"],
|
||
"morn_h": morn_h, "morn_m": morn_m,
|
||
"eve_h": eve_h, "eve_m": eve_m,
|
||
}
|
||
|
||
# Banner
|
||
print(f"\n{'='*60}")
|
||
print(f" MONITOR MODE — {len(markets)} market(s), "
|
||
f"{len(strategies)} strategies")
|
||
capital_note = (f"{args.capital:,.0f}" if args.capital else
|
||
", ".join(f"{m.upper()} {MONITOR_CAPITAL[m]:,.0f}" for m in markets))
|
||
print(f" Capital (new portfolios): {capital_note} | "
|
||
f"Integer shares: {args.integer_shares}")
|
||
for mkt, sched in market_schedules.items():
|
||
fee = MARKET_FEES.get(mkt, args.fixed_fee)
|
||
print(f" {sched['label']}:")
|
||
print(f" Morning: {sched['morn_h']:02d}:{sched['morn_m']:02d} {sched['tz']}")
|
||
print(f" Evening: {sched['eve_h']:02d}:{sched['eve_m']:02d} {sched['tz']}")
|
||
print(f" Fixed fee: {fee:.2f}/trade")
|
||
print(f" Strategies: {', '.join(strategies)}")
|
||
print(f" Auto-reload: ON (new strategies picked up each phase)")
|
||
print(f"{'='*60}")
|
||
|
||
# Use UTC as common reference for sleeping
|
||
utc = zoneinfo.ZoneInfo("UTC")
|
||
print(f"[monitor] Started at {datetime.now(utc).strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
||
print(f"[monitor] Press Ctrl+C to stop\n")
|
||
|
||
def _next_events_for_market(mkt, now_utc):
|
||
"""Return list of (utc_datetime, market, phase) for next events."""
|
||
sched = market_schedules[mkt]
|
||
tz = sched["tz"]
|
||
now_local = now_utc.astimezone(tz)
|
||
|
||
candidates = []
|
||
for phase, h, m in [("morning", sched["morn_h"], sched["morn_m"]),
|
||
("evening", sched["eve_h"], sched["eve_m"])]:
|
||
target = now_local.replace(hour=h, minute=m, second=0, microsecond=0)
|
||
if now_local >= target:
|
||
target += timedelta(days=1)
|
||
# Skip weekends
|
||
while target.weekday() >= 5:
|
||
target += timedelta(days=1)
|
||
candidates.append((target.astimezone(utc), mkt, phase))
|
||
return candidates
|
||
|
||
def _next_event(now_utc):
|
||
"""Find the globally next event across all markets."""
|
||
all_candidates = []
|
||
for mkt in markets:
|
||
all_candidates.extend(_next_events_for_market(mkt, now_utc))
|
||
return min(all_candidates, key=lambda x: x[0])
|
||
|
||
def _reload_strategies():
|
||
"""Re-read STRATEGY_REGISTRY to pick up new strategies without restart.
|
||
|
||
Only trader.py is reloaded; strategies/* modules stay as first
|
||
imported. A registry whose entries need newer strategy code would
|
||
fail at every phase (2026-09-29: one stale signal name broke the panel
|
||
refresh for every book), so every factory is built first and the old
|
||
registry is kept if any fails; changed strategy code needs a restart.
|
||
"""
|
||
import importlib
|
||
import trader as _self_mod
|
||
importlib.reload(_self_mod)
|
||
for name, factory in _self_mod.STRATEGY_REGISTRY.items():
|
||
try:
|
||
factory()
|
||
except Exception as exc:
|
||
raise RuntimeError(f"{name} needs strategy code this process has not loaded "
|
||
f"({exc}); restart the monitor") from exc
|
||
current = list(_self_mod.DEFAULT_MONITOR_STRATEGIES)
|
||
return current, _self_mod.STRATEGY_REGISTRY
|
||
|
||
def _run_phase(market, phase, now_utc):
|
||
"""Run all strategies for a market/phase."""
|
||
nonlocal strategies
|
||
sched = market_schedules[market]
|
||
tz = sched["tz"]
|
||
now_local = now_utc.astimezone(tz)
|
||
|
||
# Hot-reload strategy list from registry
|
||
try:
|
||
reloaded_names, reloaded_reg = _reload_strategies()
|
||
new_strats = set(reloaded_names) - set(strategies)
|
||
if new_strats:
|
||
print(f"[monitor] Hot-reload: +{len(new_strats)} new strategies: "
|
||
f"{', '.join(sorted(new_strats))}")
|
||
strategies = reloaded_names
|
||
# Update the global registry so cmd_morning/cmd_auto use new strategies
|
||
globals().update({"STRATEGY_REGISTRY": reloaded_reg})
|
||
except Exception as e:
|
||
print(f"[monitor] Hot-reload failed (using cached list): {e}")
|
||
|
||
print(f"\n[monitor] {'='*55}")
|
||
print(f"[monitor] {market.upper()} {phase.upper()} at "
|
||
f"{now_local.strftime('%Y-%m-%d %H:%M:%S %Z')}")
|
||
print(f"[monitor] {'='*55}")
|
||
|
||
market_fee = MARKET_FEES.get(market, args.fixed_fee)
|
||
eligible = [s for s in strategies if strategy_supports_market(s, market)]
|
||
runs = [(None, name) for name in eligible] + [
|
||
(book, name) for book in LIVE_BOOKS for name in book_strategies(book, market)]
|
||
# One download per price panel per phase; strategies then read offline.
|
||
try:
|
||
refresh_panels(market, sorted({name for _, name in runs}), label="monitor")
|
||
except Exception as e:
|
||
print(f"[monitor] Panel refresh failed ({e}); using stored prices")
|
||
for book, strat_name in runs:
|
||
sub_args = copy.copy(args)
|
||
sub_args.refresh = False
|
||
sub_args.strategy = strat_name
|
||
sub_args.market = market
|
||
sub_args.fixed_fee = market_fee
|
||
sub_args.book = book
|
||
if book:
|
||
strat_name = f"{book}/{strat_name}"
|
||
|
||
print(f"\n[monitor] --- {market.upper()}:{strat_name} ---")
|
||
try:
|
||
if phase == "morning":
|
||
cmd_morning(sub_args)
|
||
else:
|
||
# Evening always goes through auto: it re-derives the open
|
||
# signal, executes at the final close and catches up any
|
||
# sessions missed while the monitor was down.
|
||
cmd_auto(sub_args)
|
||
print(f"[monitor] {market.upper()}:{strat_name} {phase} OK")
|
||
except KeyboardInterrupt:
|
||
raise
|
||
except Exception as e:
|
||
print(f"[monitor] ERROR {market.upper()}:{strat_name} "
|
||
f"{phase}: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
print(f"[monitor] {market.upper()} {phase} done — {len(runs)} books x strategies\n")
|
||
|
||
# Catch up sessions missed while the monitor was down. cmd_auto only
|
||
# processes sessions whose close is final, so this is safe at any time.
|
||
for mkt in markets:
|
||
print(f"[monitor] Startup catch-up for {mkt.upper()}")
|
||
_run_phase(mkt, "evening", datetime.now(utc))
|
||
|
||
while True:
|
||
now_utc = datetime.now(utc)
|
||
target_utc, next_mkt, next_phase = _next_event(now_utc)
|
||
wait_seconds = (target_utc - now_utc).total_seconds()
|
||
next_tz = market_schedules[next_mkt]["tz"]
|
||
target_local = target_utc.astimezone(next_tz)
|
||
|
||
print(f"[monitor] {now_utc.strftime('%Y-%m-%d %H:%M UTC')} — "
|
||
f"Next: {next_mkt.upper()} {next_phase.upper()} at "
|
||
f"{target_local.strftime('%H:%M %Z %m/%d')} "
|
||
f"(in {wait_seconds/3600:.1f}h)")
|
||
|
||
# Sleep in chunks
|
||
while True:
|
||
now_utc = datetime.now(utc)
|
||
remaining = (target_utc - now_utc).total_seconds()
|
||
if remaining <= 0:
|
||
break
|
||
chunk = min(remaining, 900)
|
||
_time.sleep(chunk)
|
||
|
||
now_utc = datetime.now(utc)
|
||
remaining = (target_utc - now_utc).total_seconds()
|
||
if remaining > 60:
|
||
hours_left = remaining / 3600
|
||
if int(remaining) % 3600 < 900:
|
||
print(f"[monitor] {now_utc.strftime('%H:%M UTC')} — "
|
||
f"waiting... ({hours_left:.1f}h until "
|
||
f"{next_mkt.upper()} {next_phase})")
|
||
|
||
# Execute
|
||
now_utc = datetime.now(utc)
|
||
|
||
# Run all events that are due NOW (within 2 min window) — handles
|
||
# case where US evening and CN morning overlap
|
||
for mkt in markets:
|
||
for phase_check in ("morning", "evening"):
|
||
sched = market_schedules[mkt]
|
||
tz = sched["tz"]
|
||
now_local = now_utc.astimezone(tz)
|
||
if phase_check == "morning":
|
||
h, m = sched["morn_h"], sched["morn_m"]
|
||
else:
|
||
h, m = sched["eve_h"], sched["eve_m"]
|
||
target_local = now_local.replace(
|
||
hour=h, minute=m, second=0, microsecond=0)
|
||
diff = abs((now_local - target_local).total_seconds())
|
||
# Fire if within 2-minute window and it's a weekday
|
||
if diff < 120 and now_local.weekday() < 5:
|
||
try:
|
||
_run_phase(mkt, phase_check, now_utc)
|
||
except KeyboardInterrupt:
|
||
raise
|
||
except Exception as e:
|
||
print(f"[monitor] ERROR in {mkt} {phase_check}: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
print()
|
||
|
||
|
||
def refresh_panels(market: str, strategy_names: list[str], label: str) -> None:
|
||
"""Refresh every price panel the strategies read, once, for the union of their tickers."""
|
||
refresh_membership(market)
|
||
requests: dict[str, set[str]] = {}
|
||
fields: dict[str, set[str]] = {}
|
||
for name in strategy_names:
|
||
tickers, benchmark = strategy_universe(market, name)
|
||
data_market = strategy_data_market(market, name)
|
||
requests.setdefault(data_market, set()).update(
|
||
price_request(market, list(tickers) + [benchmark]))
|
||
fields.setdefault(data_market, {"close", "open", "volume"}).update(strategy_fields(name))
|
||
for data_market, request in requests.items():
|
||
print(f"[{label}] Refreshing {data_market} panel for {len(request)} tickers")
|
||
data_manager.update(data_market, sorted(request), fields=sorted(fields[data_market]))
|
||
|
||
|
||
def _rewind_state(state: dict, before: pd.Timestamp) -> None:
|
||
cutoff = str(before.date())
|
||
holdings = dict(state["holdings"])
|
||
cash = state["cash"]
|
||
# Trade values and commissions retain full precision; daily snapshots round cash.
|
||
for trade in reversed(state["trade_log"]):
|
||
if trade["date"] < cutoff:
|
||
continue
|
||
ticker, shares = trade["ticker"], trade["shares"]
|
||
if trade["action"] == "BUY":
|
||
holdings[ticker] = holdings.get(ticker, 0) - shares
|
||
cash += trade["value"] + trade["commission"]
|
||
else:
|
||
holdings[ticker] = holdings.get(ticker, 0) + shares
|
||
cash -= trade["value"] - trade["commission"]
|
||
state["holdings"] = {t: q for t, q in holdings.items() if abs(q) > 1e-8}
|
||
state["cash"] = cash
|
||
state["trade_log"] = [t for t in state["trade_log"] if t["date"] < cutoff]
|
||
state["daily_log"] = [d for d in state.get("daily_log", []) if d["date"] < cutoff]
|
||
state["daily_equity"] = {d: v for d, v in state["daily_equity"].items() if d < cutoff}
|
||
state["engine_changes"] = [e for e in state.get("engine_changes", []) if e["first_session"] < cutoff]
|
||
last = max(state["daily_equity"], default=None)
|
||
state["last_morning"] = state["last_evening"] = last
|
||
state["pending_trades"] = None
|
||
state["engine_version"] = state["daily_log"][-1].get("engine_version") if state["daily_log"] else None
|
||
if state["daily_log"] and not state["engine_version"]:
|
||
# The legacy ledger rounded trades, so its recorded snapshot is authoritative.
|
||
snapshot = state["daily_log"][-1]
|
||
state["cash"] = snapshot["cash"]
|
||
state["holdings"] = {t: h["shares"] for t, h in snapshot["holdings"].items()}
|
||
|
||
|
||
def _process_sessions(args, state: dict | None, start: pd.Timestamp | None = None,
|
||
label: str = "auto", refresh: bool = True) -> dict | None:
|
||
"""Run every completed session not yet recorded in ``state`` (catch-up safe).
|
||
|
||
A fresh portfolio starts at ``start`` when given, otherwise at the most
|
||
recent completed session. Returns the updated state, or None if skipped.
|
||
"""
|
||
market = args.market
|
||
strategy_name = args.strategy
|
||
if not strategy_supports_market(strategy_name, market):
|
||
print(f"[{label}] {strategy_name} does not run in the {market.upper()} book; skipping.")
|
||
return None
|
||
fresh = not state
|
||
if fresh and start is None:
|
||
start = book_start(getattr(args, "book", None), market)
|
||
tickers, benchmark = strategy_universe(market, strategy_name)
|
||
since = start if not state else (
|
||
pd.Timestamp(state["last_evening"]) if state.get("last_evening") else None)
|
||
request = sorted(set(tickers) | set(pit_candidates(market, strategy_name, since)))
|
||
close_data, open_data, close_raw = load_strategy_prices(
|
||
market, strategy_name, request, benchmark, with_open=True, refresh=refresh,
|
||
)
|
||
tickers = filter_tradable_tickers(close_data, tickers)
|
||
sessions = completed_sessions(close_raw, strategy_data_market(market, strategy_name), benchmark)
|
||
if len(sessions) == 0:
|
||
print(f"[{label}] No completed session in the price panel.")
|
||
return None
|
||
|
||
if fresh:
|
||
first = start if start is not None else sessions[-1]
|
||
todo = sessions[sessions >= first]
|
||
if len(todo) == 0:
|
||
print(f"[{label}] {strategy_name}: starts on {first.date()}; no session yet.")
|
||
return None
|
||
capital = _book_capital(args, market)
|
||
state = init_state(market, strategy_name, capital)
|
||
print(f"[{label}] Initialized {strategy_name} ({market.upper()}) with {capital:,.0f} cash")
|
||
else:
|
||
last = state.get("last_evening")
|
||
first = book_start(getattr(args, "book", None), market)
|
||
if last and state.get("daily_equity"):
|
||
beginning = first if first is not None else pd.Timestamp(min(state["daily_equity"]))
|
||
recorded = pd.to_datetime(list(state["daily_equity"]))
|
||
gaps = sessions[(sessions >= beginning) & (sessions <= pd.Timestamp(last))
|
||
& ~sessions.isin(recorded)]
|
||
if len(gaps):
|
||
print(f"[{label}] {strategy_name}: backfilled {gaps[0].date()}; replaying from that session")
|
||
_rewind_state(state, gaps[0])
|
||
last = state.get("last_evening")
|
||
first = beginning
|
||
if last:
|
||
todo = sessions[sessions > pd.Timestamp(last)]
|
||
else: # created by a morning run: start with the book's first session
|
||
todo = sessions[sessions >= first] if first is not None else sessions[-1:]
|
||
|
||
if len(todo) == 0:
|
||
print(f"[{label}] {strategy_name}: {state.get('last_evening')} already processed.")
|
||
return state
|
||
if len(todo) > 1:
|
||
print(f"[{label}] {strategy_name}: processing {len(todo)} sessions "
|
||
f"{todo[0].date()} .. {todo[-1].date()}")
|
||
|
||
fees = _strategy_fees(args, market)
|
||
volume = data_manager.load_raw(strategy_data_market(market, strategy_name), "volume")
|
||
signals = session_signals(market, strategy_name, tickers, todo, close_raw, open_data)
|
||
members = settlement_members(market, strategy_name, todo)
|
||
for day in todo:
|
||
r = _run_session(state, market, day, benchmark, close_data, close_raw, fees,
|
||
args.integer_shares, signal_row=signals.loc[day],
|
||
volume_row=(volume.loc[day] if day in volume.index else pd.Series(dtype=float)) if volume is not None else None,
|
||
member_row=members.loc[day] if members is not None else None)
|
||
frozen = f" | no fresh price: {', '.join(r['frozen'])}" if r["frozen"] else ""
|
||
print(f"[{label}] {r['date']} {strategy_name:<34} value {r['value']:>12,.2f} "
|
||
f"({r['daily_return']:+.2%}) trades {r['trades']:>2} "
|
||
f"fee {r['commission']:>6.2f} positions {r['positions']:>2}{frozen}")
|
||
return state
|
||
|
||
|
||
def cmd_auto(args):
|
||
"""
|
||
Automated daily run — signal on each session's open, execute at its close.
|
||
|
||
Processes every completed session since the last recorded one, so missed
|
||
days (monitor downtime, late data) are caught up in order. A session is
|
||
only processed after the exchange has closed. Backfilled historical days
|
||
rewind the ledger and replay the affected sessions in order.
|
||
|
||
Usage with cron (US market, run at 5pm ET weekdays):
|
||
0 17 * * 1-5 cd /path/to/quant && uv run python trader.py auto --market us
|
||
For CN market (run at 4pm CST):
|
||
0 16 * * 1-5 cd /path/to/quant && uv run python trader.py auto --market cn
|
||
"""
|
||
book = getattr(args, "book", None)
|
||
state = load_state(args.market, args.strategy, book)
|
||
state = _process_sessions(args, state, refresh=getattr(args, "refresh", True))
|
||
if state is not None and state.get("last_evening"):
|
||
save_state(state, args.market, args.strategy, book)
|
||
pnl = state["daily_equity"][state["last_evening"]] - state["initial_capital"]
|
||
print(f"[auto] P&L since inception: {pnl:>+,.2f} "
|
||
f"({pnl / state['initial_capital']:+.2%}) | state: "
|
||
f"{_state_path(args.market, args.strategy, book)}")
|
||
|
||
|
||
def cmd_replay(args):
|
||
"""Rebuild live state files by replaying every session from each strategy's start.
|
||
|
||
Uses exactly the per-session logic of ``auto``. Existing state files are
|
||
replaced; their original start date is kept unless ``--start`` is given
|
||
(a named book replays from its own start date).
|
||
"""
|
||
import copy
|
||
book = getattr(args, "book", None)
|
||
names = args.strategy or sorted(
|
||
os.path.basename(p)[len(f"trader_{args.market}_"):-len(".json")]
|
||
for p in __import__("glob").glob(os.path.join(book_dir(book), f"trader_{args.market}_*.json"))
|
||
)
|
||
live_names = [n for n in names if not n.startswith("sim_") and n in STRATEGY_REGISTRY
|
||
and n not in LIVE_EXCLUDED_STRATEGIES and strategy_supports_market(n, args.market)]
|
||
|
||
# Refresh each price panel once for the union of tickers, then replay offline.
|
||
refresh_panels(args.market, live_names, label="replay")
|
||
|
||
for name in names:
|
||
if name.startswith("sim_"):
|
||
continue
|
||
sub = copy.copy(args)
|
||
sub.strategy = name
|
||
if name not in STRATEGY_REGISTRY or name in LIVE_EXCLUDED_STRATEGIES \
|
||
or not strategy_supports_market(name, args.market):
|
||
print(f"[replay] {args.market.upper()}:{name} is not a live strategy here; "
|
||
f"removing its state file")
|
||
if os.path.exists(_state_path(args.market, name, book)):
|
||
os.remove(_state_path(args.market, name, book))
|
||
continue
|
||
old = load_state(args.market, name, book)
|
||
start = pd.Timestamp(args.start) if args.start else book_start(book, args.market) or (
|
||
pd.Timestamp(min(old["daily_equity"])) if old.get("daily_equity") else None
|
||
)
|
||
if start is None:
|
||
print(f"[replay] {name}: no start date; skipping")
|
||
continue
|
||
state = _process_sessions(sub, None, start=start, label="replay", refresh=False)
|
||
if state is not None:
|
||
save_state(state, args.market, name, book)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# CLI
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="Daily trading simulation")
|
||
sub = parser.add_subparsers(dest="command", required=True)
|
||
|
||
book_help = "Named live book (default: the original book in data/)"
|
||
|
||
# Common args
|
||
def add_common(p):
|
||
p.add_argument("--market", choices=UNIVERSES.keys(), default="us")
|
||
p.add_argument("--strategy", choices=STRATEGY_REGISTRY.keys(),
|
||
default="recovery_mom_top10")
|
||
p.add_argument("--capital", type=float, default=100_000)
|
||
p.add_argument("--tx-cost", type=float, default=0.001,
|
||
help="Proportional transaction cost (default: 0.001 = 10bps)")
|
||
p.add_argument("--fixed-fee", type=float, default=0.0,
|
||
help="Fixed dollar fee per trade")
|
||
p.add_argument("--integer-shares", action="store_true", default=False,
|
||
help="Only trade whole shares (no fractional)")
|
||
p.add_argument("--book", choices=list(LIVE_BOOKS), default=None, help=book_help)
|
||
|
||
# Morning
|
||
p_morning = sub.add_parser("morning", help="Generate trade orders from open prices")
|
||
add_common(p_morning)
|
||
|
||
# Evening
|
||
p_evening = sub.add_parser("evening", help="Record execution at close prices")
|
||
add_common(p_evening)
|
||
|
||
# Auto (single daily run — for cron/systemd)
|
||
p_auto = sub.add_parser("auto",
|
||
help="Automated daily run: signal + execute in one step (for cron)")
|
||
add_common(p_auto)
|
||
|
||
# Monitor (long-running daemon for tmux) — all markets + all strategies
|
||
p_monitor = sub.add_parser("monitor",
|
||
help="Long-running daemon: runs ALL markets & strategies (for tmux)")
|
||
p_monitor.add_argument("--market", nargs="+",
|
||
choices=list(UNIVERSES.keys()),
|
||
default=list(UNIVERSES.keys()),
|
||
help="Markets to monitor (default: ALL)")
|
||
p_monitor.add_argument("--strategy", nargs="+",
|
||
choices=list(STRATEGY_REGISTRY.keys()),
|
||
default=DEFAULT_MONITOR_STRATEGIES,
|
||
help="Strategies to run (default: ALL)")
|
||
p_monitor.add_argument("--capital", type=float, default=None,
|
||
help="Capital for new portfolios (default: per-market MONITOR_CAPITAL)")
|
||
p_monitor.add_argument("--tx-cost", type=float, default=0.001,
|
||
help="Proportional transaction cost (default: 0.001 = 10bps)")
|
||
p_monitor.add_argument("--fixed-fee", type=float, default=2.0,
|
||
help="Fixed dollar fee per trade (default: $2)")
|
||
p_monitor.add_argument("--integer-shares", action="store_true", default=True,
|
||
help="Only trade whole shares (default: True)")
|
||
|
||
# Replay: rebuild live state files from each strategy's start date
|
||
p_replay = sub.add_parser("replay",
|
||
help="Rebuild live state files by replaying all sessions (replaces state)")
|
||
p_replay.add_argument("--market", choices=list(UNIVERSES.keys()), default="us")
|
||
p_replay.add_argument("--strategy", nargs="+", default=None,
|
||
help="Strategies to replay (default: every existing state file)")
|
||
p_replay.add_argument("--start", default=None,
|
||
help="Start date (default: each state file's first recorded day)")
|
||
p_replay.add_argument("--capital", type=float, default=None,
|
||
help="Initial capital (default: per-market MONITOR_CAPITAL)")
|
||
p_replay.add_argument("--tx-cost", type=float, default=0.001)
|
||
p_replay.add_argument("--fixed-fee", type=float, default=0.0,
|
||
help="Per-trade fee floor (default: per-market MARKET_FEES)")
|
||
p_replay.add_argument("--integer-shares", action=argparse.BooleanOptionalAction, default=True)
|
||
p_replay.add_argument("--book", choices=list(LIVE_BOOKS), default=None, help=book_help)
|
||
|
||
# Status (strategy is a free-form string to allow sim_ prefixed names)
|
||
p_status = sub.add_parser("status", help="Show current portfolio")
|
||
p_status.add_argument("--market", choices=UNIVERSES.keys(), default="us")
|
||
p_status.add_argument("--strategy", default="recovery_mom_top10",
|
||
help="Strategy name (or sim_<name> for simulation state)")
|
||
p_status.add_argument("--capital", type=float, default=100_000)
|
||
p_status.add_argument("--tx-cost", type=float, default=0.001)
|
||
p_status.add_argument("--fixed-fee", type=float, default=0.0)
|
||
p_status.add_argument("--integer-shares", action="store_true", default=False)
|
||
p_status.add_argument("--book", choices=list(LIVE_BOOKS), default=None, help=book_help)
|
||
|
||
# Simulate
|
||
p_sim = sub.add_parser("simulate", help="Simulate over a date range")
|
||
add_common(p_sim)
|
||
p_sim.add_argument("--start", required=True, help="Start date (YYYY-MM-DD)")
|
||
p_sim.add_argument("--end", required=True, help="End date (YYYY-MM-DD)")
|
||
|
||
# Log viewer
|
||
p_log = sub.add_parser("log", help="View daily log (holdings + operations per day)")
|
||
p_log.add_argument("--market", choices=UNIVERSES.keys(), default="us")
|
||
p_log.add_argument("--strategy", default="sim_recovery_mom_top10",
|
||
help="Strategy name (e.g. sim_recovery_mom_top10)")
|
||
p_log.add_argument("--start", default=None, help="Start date filter (YYYY-MM-DD)")
|
||
p_log.add_argument("--end", default=None, help="End date filter (YYYY-MM-DD)")
|
||
p_log.add_argument("--book", choices=list(LIVE_BOOKS), default=None, help=book_help)
|
||
|
||
# Compare strategies
|
||
p_cmp = sub.add_parser("compare",
|
||
help="Compare strategies side-by-side (auto-discovers all if no --strategy)")
|
||
p_cmp.add_argument("--market", choices=UNIVERSES.keys(), default="us")
|
||
p_cmp.add_argument("--strategy", nargs="+", default=None,
|
||
help="Strategy names to compare (default: auto-discover all)")
|
||
p_cmp.add_argument("--book", choices=list(LIVE_BOOKS), default=None, help=book_help)
|
||
|
||
args = parser.parse_args()
|
||
|
||
if args.command == "morning":
|
||
cmd_morning(args)
|
||
elif args.command == "evening":
|
||
cmd_evening(args)
|
||
elif args.command == "auto":
|
||
cmd_auto(args)
|
||
elif args.command == "monitor":
|
||
cmd_monitor(args)
|
||
elif args.command == "replay":
|
||
cmd_replay(args)
|
||
elif args.command == "status":
|
||
cmd_status(args)
|
||
elif args.command == "simulate":
|
||
cmd_simulate(args)
|
||
elif args.command == "log":
|
||
cmd_log(args)
|
||
elif args.command == "compare":
|
||
cmd_compare(args)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|