overnight_recovery_agent_top10 (strategies/agent_review.py) re-ranks the
overnight_recovery top 50 with an LLM agent's daily scores:
final = quant percentile + 0.25 x score (-2..+2), vetoes for five event types,
top 10 on the base strategy's rebalance rows and hysteresis. Score files are
stamped by `agent_review.py submit` and steer a session only if stamped before
its 16:00 New York close; without one the strategy holds exactly what
overnight_recovery_top10 holds (checked on the live panel 2016-2026).
- agent_review.py: candidates / submit / evaluate; skill
.claude/skills/agent-stock-review drives the daily research.
- US only, skipped by precompute (no history before its first score file);
runs in the original book and in selected_2026_09 (from the book's start).
- .gitignore tracks .claude/skills/ and ignores data/agent/.
Monitor fix: hot-reload re-imports trader.py but not strategies/*. After the
2026-09-29 strategy commits the stale modules made every phase's panel refresh
fail ("Unknown signal 'low_risk'") and US books skipped 09-29 until a restart.
_reload_strategies now builds every factory and keeps the old registry when
any fails; CLAUDE.md says to restart the monitor after changing strategy code.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2213 lines
96 KiB
Python
2213 lines
96 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
|
||
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.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),
|
||
# --- 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),
|
||
}
|
||
|
||
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)),
|
||
}
|
||
|
||
# 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": "equal weight across ~500 names is not investable with whole "
|
||
"shares at live capital (every slice is below the minimum trade)",
|
||
}
|
||
|
||
DEFAULT_MONITOR_STRATEGIES = [
|
||
name for name in STRATEGY_REGISTRY if name not in LIVE_EXCLUDED_STRATEGIES
|
||
]
|
||
|
||
|
||
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(strategy_name.removeprefix("sim_"), []))
|
||
|
||
|
||
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 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 = strategy_name.removeprefix("sim_")
|
||
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 == "us":
|
||
mask = universe_history.membership_mask(days, tickers=universe_history.all_tickers_ever())
|
||
elif market == "cn":
|
||
snaps = universe_history.load_csi300_history()
|
||
mask = universe_history.csi300_membership_mask(
|
||
days, universe_history.all_csi300_tickers_ever(snaps), snaps)
|
||
else:
|
||
return None
|
||
return mask.loc[:, mask.any()]
|
||
|
||
|
||
def session_universes(market: str, strategy_name: str, tickers: list[str],
|
||
days: pd.DatetimeIndex) -> list[tuple[list[str], pd.DatetimeIndex]]:
|
||
"""Split ``days`` into runs of sessions that share one stock universe.
|
||
|
||
The latest session trades ``tickers`` (today's list). Earlier sessions
|
||
trade the index members of their own day: a replay or catch-up over past
|
||
sessions with today's list would buy names before they joined the index,
|
||
i.e. names that were added because they had risen. ETF strategies keep
|
||
their fixed basket.
|
||
"""
|
||
if strategy_name.removeprefix("sim_") in ETF_STRATEGY_UNIVERSES or len(days) < 2:
|
||
return [(list(tickers), days)]
|
||
members = index_members(market, days[:-1])
|
||
if members is None:
|
||
return [(list(tickers), days)]
|
||
extras = set(strategy_extra_tickers(strategy_name))
|
||
runs: list[tuple[list[str], list]] = []
|
||
for day in days:
|
||
if day == days[-1]:
|
||
universe = sorted(tickers)
|
||
else:
|
||
universe = sorted(set(members.columns[members.loc[day].to_numpy()]) | extras)
|
||
if runs and runs[-1][0] == universe:
|
||
runs[-1][1].append(day)
|
||
else:
|
||
runs.append((universe, [day]))
|
||
return [(u, pd.DatetimeIndex(d)) for u, d in runs]
|
||
|
||
|
||
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, pd.Timestamp.today().normalize()) if since is not None else []
|
||
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 session_signals(market: str, strategy_name: str, tickers: list[str], days: pd.DatetimeIndex,
|
||
close: pd.DataFrame, open_data: pd.DataFrame) -> pd.DataFrame:
|
||
"""Target weights for each day in ``days``, each computed on that day's
|
||
point-in-time universe (see ``session_universes``); columns are the union."""
|
||
parts = []
|
||
for universe, run in session_universes(market, strategy_name, tickers, days):
|
||
universe = filter_tradable_tickers(close, universe)
|
||
bars = strategy_bars(market, strategy_name, universe, close, open_data)
|
||
parts.append(_signals_for_sessions(strategy_name, open_data[universe], run, bars))
|
||
return pd.concat(parts).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().bfill().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 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"] if with_open else ["close"]
|
||
fields += [f for f in strategy_fields(strategy_name) if f not in fields]
|
||
if refresh:
|
||
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)
|
||
with open(path, "w") as f:
|
||
json.dump(state, f, indent=2, default=str)
|
||
|
||
|
||
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 portfolio_value(holdings: dict, prices: dict, cash: float) -> float:
|
||
stock_value = sum(holdings.get(t, 0) * prices.get(t, 0) for t in holdings)
|
||
return cash + stock_value
|
||
|
||
|
||
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 _allocate_lots(desired: dict, holdings: dict, prices: dict, cash: float,
|
||
lot_size: int, reserve: float) -> dict:
|
||
"""Whole-lot share targets: round each move toward zero, then spend the
|
||
remaining cash one lot at a time on the names furthest below target,
|
||
as long as the lot brings them nearer (nearest-lot rounding)."""
|
||
final, spend = {}, 0.0
|
||
for t, want in desired.items():
|
||
cur = holdings.get(t, 0.0)
|
||
if want <= 0:
|
||
final[t] = 0.0 # full exit, odd lot included
|
||
else:
|
||
final[t] = cur + int((want - cur) / lot_size) * lot_size
|
||
spend += (final[t] - cur) * prices[t]
|
||
budget = cash - spend - reserve
|
||
short = sorted(((desired[t] - final[t]) * prices[t], t) for t in desired if desired[t] > 0)
|
||
for gap, t in reversed(short):
|
||
lot_value = lot_size * prices[t]
|
||
if gap > 0.5 * lot_value and lot_value <= budget:
|
||
final[t] += lot_size
|
||
budget -= lot_value
|
||
return final
|
||
|
||
|
||
def compute_trades(holdings: dict, cash: float, target_weights: dict,
|
||
prices: dict, min_trade_value: float = 50.0,
|
||
integer_shares: bool = False, lot_size: int = 1,
|
||
frozen: set | None = None, resize_band: float = 0.0) -> list[dict]:
|
||
"""
|
||
Compute trades needed to move from current holdings to target weights.
|
||
|
||
If integer_shares=True, positions move in whole multiples of ``lot_size``
|
||
(e.g. 100-share A-share board lots): each move is rounded toward zero and
|
||
leftover cash then buys one more lot for the names furthest below target
|
||
when that lot brings them nearer; a position whose target is zero is
|
||
always sold in full, odd lot included.
|
||
A held position with a nonzero target is not resized while it is within
|
||
``resize_band`` (fraction of its target value) of the target.
|
||
Tickers in ``frozen`` (no fresh price today) are neither bought nor sold.
|
||
|
||
Returns list of {ticker, shares_delta, direction, est_value}.
|
||
"""
|
||
total = portfolio_value(holdings, prices, cash)
|
||
frozen = frozen or set()
|
||
desired = {}
|
||
for ticker in sorted(set(target_weights) | set(holdings)):
|
||
if ticker in frozen:
|
||
continue
|
||
price = prices.get(ticker, 0.0)
|
||
if price <= 0:
|
||
continue
|
||
target_weight = target_weights.get(ticker, 0.0)
|
||
target_value = total * target_weight
|
||
current_shares = holdings.get(ticker, 0.0)
|
||
if (current_shares > 0 and target_weight > 0
|
||
and abs(target_value - current_shares * price) < resize_band * target_value):
|
||
continue
|
||
desired[ticker] = target_value / price
|
||
|
||
targets = (_allocate_lots(desired, holdings, prices, cash, lot_size,
|
||
reserve=0.002 * total + 10.0 * len(desired))
|
||
if integer_shares else desired)
|
||
|
||
raw = []
|
||
for ticker, target_shares in targets.items():
|
||
price = prices[ticker]
|
||
current_shares = holdings.get(ticker, 0.0)
|
||
delta = target_shares - current_shares
|
||
trade_value = abs(delta * price)
|
||
if trade_value < min_trade_value:
|
||
continue
|
||
if integer_shares and abs(delta) < 1:
|
||
continue
|
||
|
||
raw.append({
|
||
"ticker": ticker,
|
||
"shares_delta": int(round(delta)) if integer_shares else round(delta, 4),
|
||
"direction": "BUY" if delta > 0 else "SELL",
|
||
"est_value": round(trade_value, 2),
|
||
"price": round(price, 6),
|
||
"target_shares": int(round(target_shares)) if integer_shares else round(target_shares, 4),
|
||
"current_shares": int(current_shares) if integer_shares else round(current_shares, 4),
|
||
})
|
||
|
||
return raw
|
||
|
||
|
||
def _per_trade_commission(
|
||
shares: float,
|
||
price: float,
|
||
tx_cost: float,
|
||
fixed_fee: float,
|
||
fee_base: float = 0.0,
|
||
fee_per_share: float = 0.0,
|
||
) -> float:
|
||
"""Commission for one trade.
|
||
|
||
Matches the IBKR-style tiered formula used by the backtest engine:
|
||
commission = bps_cost + max(fixed_fee, fee_base + fee_per_share * shares)
|
||
With fee_base=0 and fee_per_share=0 this degenerates to the flat
|
||
fixed-fee model (legacy behavior).
|
||
"""
|
||
bps_cost = abs(shares) * price * tx_cost
|
||
per_trade = fee_base + fee_per_share * abs(shares)
|
||
floor = max(fixed_fee, per_trade)
|
||
return bps_cost + floor
|
||
|
||
|
||
def execute_trades(state: dict, trades: list[dict], prices: dict,
|
||
tx_cost: float = 0.001, fixed_fee: float = 0.0,
|
||
fee_base: float = 0.0, fee_per_share: float = 0.0,
|
||
trade_date: str = "", integer_shares: bool = False,
|
||
lot_size: int = 1) -> None:
|
||
"""Execute trades: update holdings and cash in state, append to trade_log.
|
||
|
||
When integer_shares=True, sells are executed first to free up cash,
|
||
then buys are executed only if sufficient cash is available.
|
||
|
||
Per-trade commission supports both the legacy flat ``fixed_fee`` and
|
||
the IBKR-style tiered ``max(fixed_fee, fee_base + fee_per_share*shares)``
|
||
schedule used by the backtest engine.
|
||
"""
|
||
holdings = state["holdings"]
|
||
cash = state["cash"]
|
||
|
||
# When using integer shares, execute sells first to free cash for buys
|
||
sells = [t for t in trades if t["shares_delta"] < 0]
|
||
buys = [t for t in trades if t["shares_delta"] > 0]
|
||
ordered = sells + buys if integer_shares else trades
|
||
|
||
us_fee_per_share = fee_per_share
|
||
for trade in ordered:
|
||
ticker = trade["ticker"]
|
||
delta = trade["shares_delta"]
|
||
price = prices.get(ticker, trade["price"])
|
||
cost = abs(delta * price)
|
||
# The per-share schedule applies to US-listed shares only; HK lines
|
||
# (often < $1/share) pay the proportional cost and the ticket floor.
|
||
fee_per_share = 0.0 if ticker.endswith(".HK") else us_fee_per_share
|
||
commission = _per_trade_commission(
|
||
abs(delta), price, tx_cost, fixed_fee, fee_base, fee_per_share,
|
||
)
|
||
|
||
if delta > 0:
|
||
# BUY — skip if insufficient cash in integer mode
|
||
if integer_shares and (cost + commission) > cash:
|
||
# Try buying fewer shares that we can afford, accounting for
|
||
# the per-share variable component of the commission.
|
||
affordable_price_unit = price * (1 + tx_cost) + fee_per_share
|
||
if affordable_price_unit <= 0:
|
||
continue
|
||
affordable = int((cash - max(fixed_fee, fee_base)) / affordable_price_unit)
|
||
affordable = affordable // lot_size * lot_size
|
||
if affordable < 1:
|
||
continue
|
||
delta = affordable
|
||
cost = abs(delta * price)
|
||
commission = _per_trade_commission(
|
||
delta, price, tx_cost, fixed_fee, fee_base, fee_per_share,
|
||
)
|
||
cash -= (cost + commission)
|
||
holdings[ticker] = holdings.get(ticker, 0.0) + delta
|
||
else:
|
||
# SELL
|
||
cash += (cost - commission)
|
||
holdings[ticker] = holdings.get(ticker, 0.0) + delta # delta is negative
|
||
|
||
# Remove zero holdings
|
||
if ticker in holdings and abs(holdings[ticker]) < (0.5 if integer_shares else 0.001):
|
||
del holdings[ticker]
|
||
|
||
state["trade_log"].append({
|
||
"date": trade_date,
|
||
"action": "BUY" if delta > 0 else "SELL",
|
||
"ticker": ticker,
|
||
"shares": abs(delta) if integer_shares else round(abs(delta), 4),
|
||
"price": round(price, 6),
|
||
"value": round(cost, 2),
|
||
"commission": round(commission, 2),
|
||
})
|
||
|
||
state["cash"] = round(cash, 2)
|
||
if integer_shares:
|
||
state["holdings"] = {k: int(round(v)) for k, v in holdings.items() if abs(v) >= 0.5}
|
||
else:
|
||
state["holdings"] = {k: round(v, 4) for k, v in holdings.items() if abs(v) >= 0.001}
|
||
|
||
|
||
def record_daily_snapshot(state: dict, date_str: str, prices: dict,
|
||
trades: list[dict], prev_equity: float) -> 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.
|
||
"""
|
||
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 = {
|
||
"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 "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 _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:
|
||
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]()
|
||
if isinstance(strategy, BarStrategy):
|
||
return bar_session_weights(strategy, bars, days)
|
||
hist = open_data.loc[:days[-1]]
|
||
dummy = pd.DataFrame([hist.iloc[-1].values], index=[hist.index[-1] + pd.Timedelta(days=1)],
|
||
columns=hist.columns)
|
||
weights = strategy.generate_signals(pd.concat([hist, dummy]))
|
||
return weights.shift(-1).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) -> 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.
|
||
"""
|
||
day_str = str(day.date())
|
||
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])}
|
||
|
||
pre_value = portfolio_value(state["holdings"], close_prices, state["cash"])
|
||
trades = compute_trades(
|
||
state["holdings"], state["cash"], target_weights, close_prices,
|
||
min_trade_value=max(50, pre_value * 0.001),
|
||
integer_shares=integer_shares, lot_size=lot_size, frozen=frozen,
|
||
resize_band=RESIZE_BAND,
|
||
)
|
||
n_log = len(state["trade_log"])
|
||
execute_trades(state, trades, close_prices, trade_date=day_str,
|
||
integer_shares=integer_shares, lot_size=lot_size, **fees)
|
||
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)
|
||
record_daily_snapshot(state, day_str, close_prices, trades, prev_eq)
|
||
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, _ = 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)
|
||
bars = strategy_bars(market, strategy_name, tickers, close_data, open_data)
|
||
row = _signals_for_sessions(strategy_name, open_data[tickers],
|
||
pd.DatetimeIndex([today]), bars).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=RESIZE_BAND)
|
||
|
||
# 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, _, _ = 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)
|
||
exec_trades = compute_trades(
|
||
state["holdings"], state["cash"], target_weights,
|
||
close_prices, min_trade_value=max(50, pre_value * 0.001),
|
||
integer_shares=args.integer_shares, lot_size=lot_size,
|
||
)
|
||
execute_trades(state, exec_trades, close_prices, trade_date=trade_date,
|
||
integer_shares=args.integer_shares, lot_size=lot_size,
|
||
**_strategy_fees(args, market))
|
||
|
||
post_value = portfolio_value(state["holdings"], close_prices, state["cash"])
|
||
state["daily_equity"][trade_date] = round(post_value, 2)
|
||
|
||
# Record daily snapshot so daily_log stays complete even on no-trade days
|
||
eq_vals = list(state["daily_equity"].values())
|
||
prev_eq = eq_vals[-2] if len(eq_vals) >= 2 else state["initial_capital"]
|
||
record_daily_snapshot(state, trade_date, close_prices, exec_trades, prev_eq)
|
||
|
||
state["pending_trades"] = None
|
||
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, _, _ = 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),
|
||
})
|
||
|
||
# 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}")
|
||
print(f" {'─'*87}")
|
||
|
||
for i, r in enumerate(results, 1):
|
||
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}")
|
||
|
||
# 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, _ = load_strategy_prices(
|
||
market, strategy_name, request, benchmark, with_open=True,
|
||
)
|
||
tickers = filter_tradable_tickers(close_data, tickers)
|
||
|
||
# Date range
|
||
trading_days = close_data.index[(close_data.index >= start) & (close_data.index <= 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_data, 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
|
||
|
||
for i, day in enumerate(trading_days):
|
||
day_str = str(day.date())
|
||
|
||
# Morning: get target weights from pre-computed matrix
|
||
if day in full_weights.index:
|
||
row = full_weights.loc[day]
|
||
target_weights = {t: float(w) for t, w in row.items() if w > 1e-6}
|
||
else:
|
||
target_weights = {}
|
||
|
||
# Get open and close prices
|
||
all_held = list(set(list(target_weights.keys()) + list(state["holdings"].keys())))
|
||
open_prices = get_prices_for_date(all_held, day, open_data)
|
||
close_prices = get_prices_for_date(all_held + [benchmark], day, close_data)
|
||
|
||
if not close_prices:
|
||
continue
|
||
|
||
# Compute portfolio value before trades (at close, reflecting overnight changes)
|
||
pre_value = portfolio_value(state["holdings"], close_prices, state["cash"])
|
||
|
||
# Compute and execute trades at close prices
|
||
lot_size = MARKET_LOT_SIZE.get(market, 1)
|
||
trades = compute_trades(
|
||
state["holdings"], state["cash"], target_weights,
|
||
close_prices, min_trade_value=max(50, pre_value * 0.001),
|
||
integer_shares=args.integer_shares, lot_size=lot_size,
|
||
resize_band=RESIZE_BAND,
|
||
)
|
||
|
||
execute_trades(state, trades, close_prices,
|
||
tx_cost=args.tx_cost, fixed_fee=args.fixed_fee,
|
||
trade_date=day_str, integer_shares=args.integer_shares,
|
||
lot_size=lot_size)
|
||
|
||
post_value = portfolio_value(state["holdings"], close_prices, state["cash"])
|
||
state["daily_equity"][day_str] = round(post_value, 2)
|
||
|
||
# Record daily snapshot (holdings + operations)
|
||
prev_eq_val = args.capital if i == 0 else list(state["daily_equity"].values())[-2] if len(state["daily_equity"]) >= 2 else args.capital
|
||
record_daily_snapshot(state, day_str, close_prices, trades, prev_eq_val)
|
||
|
||
day_trades = len(trades)
|
||
day_commission = sum(
|
||
t.get("commission", 0) for t in state["trade_log"][-day_trades:]
|
||
) if day_trades else 0
|
||
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."""
|
||
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"}).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 _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:
|
||
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)
|
||
signals = session_signals(market, strategy_name, tickers, todo, close_data, open_data)
|
||
for day in todo:
|
||
r = _run_session(state, market, day, benchmark, close_data, close_raw, fees,
|
||
args.integer_shares, signal_row=signals.loc[day])
|
||
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.
|
||
|
||
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()
|