Files
quant-trading/trader.py
Gahow Wang 465189ba9a feat(books): selected_2026_09 adds trend_rider, $2/¥10 per order, CN ¥100k
Adds five trend_rider ETF strategies (best 10-year Sharpe of each version,
plus v7_vt36): 31 strategies in all. Fees per order are $2 and ¥10, and CN
accounts start with ¥100,000. No trade has been executed yet (US accounts
hold only this morning's orders; CN starts 2026-09-28).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 22:13:49 +08:00

2175 lines
94 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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.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.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 0.8-1.4): the best
# 10-year Sharpe of each version, and v7_vt36 (the V7+VT36 champion).
"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",
"trend_rider_v7_vt24", "trend_rider_v7_vt36", "trend_rider_v3_vt24",
"trend_rider_v5_vt30", "trend_rider_v4",
],
"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,
),
# --- 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)),
}
# 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:
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."""
import importlib
import trader as _self_mod
importlib.reload(_self_mod)
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()