Files
quant/trader.py
Gahow Wang ae25f2f6b5 Add 32 factor-combo strategies with configurable rebalancing frequency
New FactorComboStrategy class (strategies/factor_combo.py) implements
8 champion factor signals (4 US, 4 CN) discovered through iterative
factor research, each at 4 rebalancing frequencies (daily/weekly/
biweekly/monthly). Registered in trader.py as fc_{signal}_{freq}.

Existing strategies and state files are untouched — safe to git pull
and restart monitor on server.

Also includes factor research scripts (factor_loop.py, factor_research.py,
etc.) used to discover and validate these factors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 10:41:34 +08:00

1443 lines
58 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
from datetime import datetime, timedelta
import numpy as np
import pandas as pd
import yfinance as yf
import data_manager
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.recovery_momentum import RecoveryMomentumStrategy
from strategies.trend_following import TrendFollowingStrategy
from universe import UNIVERSES
# ---------------------------------------------------------------------------
# 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),
}
# ---------------------------------------------------------------------------
# Persistent state
# ---------------------------------------------------------------------------
def _state_path(market: str, strategy_name: str) -> str:
return os.path.join("data", f"trader_{market}_{strategy_name}.json")
def load_state(market: str, strategy_name: str) -> dict:
path = _state_path(market, strategy_name)
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) -> None:
os.makedirs("data", exist_ok=True)
path = _state_path(market, strategy_name)
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 generate_target_weights(strategy, open_data: pd.DataFrame, target_date) -> dict:
"""
Run strategy on full open price history, extract target_date's weights.
Strategies have an internal shift(1) designed for close prices: weights[t]
uses data up to t-1. With open prices (observable same-day), we want the
signal that uses data up to today. To get this, we append a dummy future
row so the strategy computes one extra day of signals, then we read that
last row — which incorporates today's open prices.
"""
# Append a dummy row one day after the last date so the strategy's
# shift(1) produces a valid signal row that uses target_date's open
dummy_date = open_data.index[-1] + pd.Timedelta(days=1)
dummy_row = pd.DataFrame(
[open_data.iloc[-1].values],
index=[dummy_date],
columns=open_data.columns,
)
extended = pd.concat([open_data, dummy_row])
weights = strategy.generate_signals(extended)
# The dummy_date row in weights now uses data up to target_date (the real last day)
# Read the dummy row — that's today's actual signal
if dummy_date in weights.index:
row = weights.loc[dummy_date]
elif target_date in weights.index:
# Fallback: use target_date row (which uses data up to target_date - 1)
row = weights.loc[target_date]
else:
idx = weights.index.get_indexer([target_date], method="ffill")
if idx[0] >= 0:
row = weights.iloc[idx[0]]
else:
return {}
# Return only non-zero weights
return {t: float(w) for t, w in row.items() if w > 1e-6}
def compute_trades(holdings: dict, cash: float, target_weights: dict,
prices: dict, min_trade_value: float = 50.0,
integer_shares: bool = False) -> list[dict]:
"""
Compute trades needed to move from current holdings to target weights.
If integer_shares=True, share deltas are rounded to whole numbers and
buys are capped so they don't exceed available cash (accounting for
commissions). Sells are processed first to free up cash.
Returns list of {ticker, shares_delta, direction, est_value}.
"""
total = portfolio_value(holdings, prices, cash)
raw = []
all_tickers = set(list(target_weights.keys()) + list(holdings.keys()))
for ticker in sorted(all_tickers):
target_weight = target_weights.get(ticker, 0.0)
price = prices.get(ticker, 0.0)
if price <= 0:
continue
target_value = total * target_weight
target_shares = target_value / price
current_shares = holdings.get(ticker, 0.0)
delta = target_shares - current_shares
if integer_shares:
# Round toward zero for buys (floor), away from zero for sells (floor of abs)
if delta > 0:
delta = int(delta) # floor: don't overshoot cash
else:
delta = -int(abs(delta)) # floor of magnitude: don't over-sell
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(delta) if integer_shares else round(delta, 4),
"direction": "BUY" if delta > 0 else "SELL",
"est_value": round(trade_value, 2),
"price": round(price, 2),
"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 execute_trades(state: dict, trades: list[dict], prices: dict,
tx_cost: float = 0.001, fixed_fee: float = 0.0,
trade_date: str = "", integer_shares: bool = False) -> 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.
"""
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
for trade in ordered:
ticker = trade["ticker"]
delta = trade["shares_delta"]
price = prices.get(ticker, trade["price"])
cost = abs(delta * price)
commission = cost * tx_cost + fixed_fee
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
affordable = int((cash - fixed_fee) / (price * (1 + tx_cost)))
if affordable < 1:
continue
delta = affordable
cost = abs(delta * price)
commission = cost * tx_cost + fixed_fee
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, 2),
"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, 2),
"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, 2),
"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])}
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
def cmd_morning(args):
"""Morning: download open prices, generate today's trade orders."""
market = args.market
strategy_name = args.strategy
universe = UNIVERSES[market]
tickers = universe["fetch"]()
benchmark = universe["benchmark"]
all_tickers = sorted(set(tickers + [benchmark]))
# Load or init state
state = load_state(market, strategy_name)
if not state:
state = init_state(market, strategy_name, args.capital)
print(f"--- Initialized new portfolio: ${args.capital:,.0f} cash ---")
# Download data (close + open)
close_data, open_data = data_manager.update(market, all_tickers, with_open=True)
tickers = [t for t in tickers if t in close_data.columns]
today = open_data.index[-1]
today_str = str(today.date())
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
strategy = STRATEGY_REGISTRY[strategy_name](top_n=max(5, len(tickers) // 10))
target_weights = generate_target_weights(strategy, open_data[tickers], today)
# 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)
# 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)
# 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
universe = UNIVERSES[market]
tickers = universe["fetch"]()
benchmark = universe["benchmark"]
all_tickers = sorted(set(tickers + [benchmark]))
state = load_state(market, strategy_name)
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 = data_manager.update(market, all_tickers)
tickers = [t for t in tickers if t in close_data.columns]
target_date = pd.Timestamp(trade_date)
all_held = list(set(
list(pending.get("target_weights", {}).keys()) +
list(state["holdings"].keys())
))
close_prices = 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", {})
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
)
execute_trades(state, exec_trades, close_prices,
tx_cost=args.tx_cost, fixed_fee=args.fixed_fee,
trade_date=trade_date, integer_shares=args.integer_shares)
post_value = portfolio_value(state["holdings"], close_prices, state["cash"])
state["daily_equity"][trade_date] = round(post_value, 2)
state["pending_trades"] = None
state["last_evening"] = trade_date
save_state(state, market, strategy_name)
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)
if not state:
print("No state found. Run 'morning' to initialize.")
return
# Get latest prices
universe = UNIVERSES[market]
tickers = universe["fetch"]()
benchmark = universe["benchmark"]
all_tickers = sorted(set(tickers + [benchmark]))
close_data = data_manager.update(market, all_tickers)
last_date = close_data.index[-1]
all_held = list(state["holdings"].keys())
prices = 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)
# Auto-discover: find all trader_{market}_*.json state files
if not strategy_names:
pattern = os.path.join("data", 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)
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)
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
universe = UNIVERSES[market]
tickers = universe["fetch"]()
benchmark = universe["benchmark"]
all_tickers = sorted(set(tickers + [benchmark]))
# Load both open and close data
close_data, open_data = data_manager.update(market, all_tickers, with_open=True)
tickers = [t for t in tickers if t in close_data.columns]
# Date range
start = pd.Timestamp(args.start)
end = pd.Timestamp(args.end)
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)
# Append dummy row so shift(1) inside strategy produces valid last-day signal
strategy = STRATEGY_REGISTRY[strategy_name](top_n=max(5, len(tickers) // 10))
open_tickers = open_data[tickers]
dummy_date = open_tickers.index[-1] + pd.Timedelta(days=1)
dummy_row = pd.DataFrame(
[open_tickers.iloc[-1].values], index=[dummy_date], columns=open_tickers.columns
)
extended = pd.concat([open_tickers, dummy_row])
raw_weights = strategy.generate_signals(extended)
# Strategy's shift(1): raw_weights[t] uses data up to t-1
# For date t, the signal using open[t] is in raw_weights[t+1]
# So shift(-1) to align: full_weights[t] = signal using open[t]
full_weights = raw_weights.shift(-1).fillna(0.0)
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
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
)
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)
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 each")
print(f" Capital: ${args.capital:,.0f} | "
f"Fee: ${args.fixed_fee:.2f}/trade | "
f"Integer shares: {args.integer_shares}")
for mkt, sched in market_schedules.items():
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" Strategies: {', '.join(strategies)}")
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 _run_phase(market, phase, now_utc):
"""Run all strategies for a market/phase."""
sched = market_schedules[market]
tz = sched["tz"]
now_local = now_utc.astimezone(tz)
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}")
for strat_name in strategies:
sub_args = copy.copy(args)
sub_args.strategy = strat_name
sub_args.market = market
print(f"\n[monitor] --- {market.upper()}:{strat_name} ---")
try:
if phase == "morning":
cmd_morning(sub_args)
else:
state = load_state(market, strat_name)
today_str = now_local.strftime("%Y-%m-%d")
if (state and state.get("last_morning") == today_str
and state.get("pending_trades")):
cmd_evening(sub_args)
else:
print(f"[monitor] Morning not run for "
f"{market.upper()}:{strat_name} — using auto")
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 — "
f"{len(strategies)} strategies")
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 cmd_auto(args):
"""
Automated daily run — single invocation handles both morning + evening.
Designed for cron/systemd: run once after market close each trading day.
Downloads open + close prices for today, generates signals, executes, records.
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
"""
import time
market = args.market
strategy_name = args.strategy
universe = UNIVERSES[market]
tickers = universe["fetch"]()
benchmark = universe["benchmark"]
all_tickers = sorted(set(tickers + [benchmark]))
# Load or init state
state = load_state(market, strategy_name)
if not state:
state = init_state(market, strategy_name, args.capital)
print(f"[auto] Initialized new portfolio: ${args.capital:,.0f} cash")
# Download data (close + open)
close_data, open_data = data_manager.update(market, all_tickers, with_open=True)
tickers = [t for t in tickers if t in close_data.columns]
today = close_data.index[-1]
today_str = str(today.date())
# Skip if already processed today
if state.get("last_evening") == today_str:
print(f"[auto] {today_str} already processed. Nothing to do.")
return
print(f"\n[auto] {'='*55}")
print(f"[auto] {today_str} | {strategy_name} | {market.upper()}")
print(f"[auto] {'='*55}")
# --- MORNING PHASE: Generate target weights from open prices ---
strategy = STRATEGY_REGISTRY[strategy_name](top_n=max(5, len(tickers) // 10))
target_weights = generate_target_weights(strategy, open_data[tickers], today)
all_held = list(set(list(target_weights.keys()) + list(state["holdings"].keys())))
close_prices = get_prices_for_date(all_held + [benchmark], today, close_data)
if not close_prices:
print(f"[auto] No close prices for {today_str}. Market likely closed.")
return
pre_value = portfolio_value(state["holdings"], close_prices, state["cash"])
print(f"[auto] Pre-trade value: ${pre_value:,.2f}")
print(f"[auto] Target positions: {len(target_weights)}")
# --- EVENING PHASE: Execute trades at close prices ---
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
)
execute_trades(state, trades, close_prices,
tx_cost=args.tx_cost, fixed_fee=args.fixed_fee,
trade_date=today_str, integer_shares=args.integer_shares)
post_value = portfolio_value(state["holdings"], close_prices, state["cash"])
state["daily_equity"][today_str] = round(post_value, 2)
# Record daily snapshot
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, today_str, close_prices, trades, prev_eq)
state["last_morning"] = today_str
state["last_evening"] = today_str
state["pending_trades"] = None
save_state(state, market, strategy_name)
# Print summary
pnl = post_value - state["initial_capital"]
pnl_pct = pnl / state["initial_capital"] * 100
n_trades = len(trades)
commission = sum(t.get("commission", 0) for t in state["trade_log"][-n_trades:]) if n_trades else 0
print(f"[auto] Trades: {n_trades} | Commission: ${commission:,.2f}")
print(f"[auto] Post-trade value: ${post_value:,.2f} | Cash: ${state['cash']:,.2f}")
print(f"[auto] P&L: ${pnl:>+,.2f} ({pnl_pct:>+.2f}%)")
print(f"[auto] Holdings: {len(state['holdings'])} positions")
if trades:
for t in trades:
p = close_prices.get(t["ticker"], t["price"])
print(f"[auto] {t['direction']:<4} {t['ticker']:<8} {t['shares_delta']:>+10.2f} @ {p:.2f}")
# Benchmark
bench_eq = state["daily_equity"]
dates = sorted(bench_eq.keys())
if len(dates) >= 2:
prev_val = bench_eq[dates[-2]]
daily_ret = (post_value / prev_val - 1) * 100
print(f"[auto] Daily return: {daily_ret:>+.2f}%")
print(f"[auto] State saved: {_state_path(market, strategy_name)}")
print()
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description="Daily trading simulation")
sub = parser.add_subparsers(dest="command", required=True)
# 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)")
# 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=list(STRATEGY_REGISTRY.keys()),
help="Strategies to run (default: ALL)")
p_monitor.add_argument("--capital", type=float, default=10_000)
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)")
# 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)
# 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)")
# 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)")
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 == "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()