Two-phase monitor, integer shares, and daily log

- Monitor now runs morning (9:45 → signals) and evening (4:35 → execute)
  instead of single daily run; falls back to auto if morning missed
- Add --integer-shares flag for whole-share-only trading (no fractional)
- Add daily_log to state: each day records holdings, cash, and operations
- Add 'log' subcommand to view daily snapshots with date range filter
- record_daily_snapshot() called from both simulate and auto commands

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 23:57:03 +08:00
parent 4709271504
commit 942164a38f
2 changed files with 323 additions and 69 deletions

View File

@@ -23,8 +23,11 @@ uv run python main.py --execution open-close # Signal on open, execute at close
uv run python trader.py auto --market us --strategy recovery_mom_top10 # Single daily run (for cron) uv run python trader.py auto --market us --strategy recovery_mom_top10 # Single daily run (for cron)
uv run python trader.py morning --market us --strategy recovery_mom_top10 # Morning: generate orders uv run python trader.py morning --market us --strategy recovery_mom_top10 # Morning: generate orders
uv run python trader.py evening --market us --strategy recovery_mom_top10 # Evening: record execution uv run python trader.py evening --market us --strategy recovery_mom_top10 # Evening: record execution
uv run python trader.py monitor --market us --strategy recovery_mom_top10 # Long-running daemon (for tmux)
uv run python trader.py status --market us --strategy recovery_mom_top10 # Portfolio status uv run python trader.py status --market us --strategy recovery_mom_top10 # Portfolio status
uv run python trader.py simulate --market us --strategy recovery_mom_top10 --start 2026-01-01 --end 2026-04-01 # Historical replay uv run python trader.py simulate --market us --strategy recovery_mom_top10 --start 2026-01-01 --end 2026-04-01 # Historical replay
uv run python trader.py log --market us --strategy sim_recovery_mom_top10 --start 2026-03-01 # View daily log
uv run python trader.py simulate --integer-shares --fixed-fee 2.0 ... # Integer shares mode
# Setup # Setup
uv sync # Install/sync dependencies uv sync # Install/sync dependencies
@@ -66,7 +69,21 @@ No test suite or linter is configured.
- Python 3.12+, managed with `uv`. - Python 3.12+, managed with `uv`.
- Do not show matplotlib figures when running backtests; use `--no-plot`. - Do not show matplotlib figures when running backtests; use `--no-plot`.
## Server Deployment (Cron) ## Server Deployment
### Option 1: tmux monitor (recommended)
Two-phase daily schedule — morning (open prices → generate signals) and evening (close prices → execute trades):
```bash
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
```
If monitor starts mid-day (after open, before close), it automatically falls back to `auto` mode for the evening phase.
### Option 2: Cron
Run daily after market close. The `auto` command is idempotent — safe to re-run: Run daily after market close. The `auto` command is idempotent — safe to re-run:

373
trader.py
View File

@@ -8,6 +8,7 @@ Subcommands:
monitor — Long-running daemon that auto-runs daily after market close (for tmux) monitor — Long-running daemon that auto-runs daily after market close (for tmux)
status — Print current portfolio, P&L, and recent trades status — Print current portfolio, P&L, and recent trades
simulate — Replay a date range day-by-day (for forward testing) simulate — Replay a date range day-by-day (for forward testing)
log — View daily log: each day's holdings, cash, and operations
Usage: Usage:
uv run python trader.py morning --market us --strategy recovery_mom_top10 uv run python trader.py morning --market us --strategy recovery_mom_top10
@@ -95,6 +96,7 @@ def init_state(market: str, strategy_name: str, capital: float) -> dict:
"pending_trades": None, # set by morning, consumed by evening "pending_trades": None, # set by morning, consumed by evening
"trade_log": [], # list of trade dicts "trade_log": [], # list of trade dicts
"daily_equity": {}, # date_str -> portfolio value "daily_equity": {}, # date_str -> portfolio value
"daily_log": [], # list of per-day snapshots (holdings, cash, trades)
"last_morning": None, "last_morning": None,
"last_evening": None, "last_evening": None,
} }
@@ -150,14 +152,19 @@ def generate_target_weights(strategy, open_data: pd.DataFrame, target_date) -> d
def compute_trades(holdings: dict, cash: float, target_weights: dict, def compute_trades(holdings: dict, cash: float, target_weights: dict,
prices: dict, min_trade_value: float = 50.0) -> list[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. 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}. Returns list of {ticker, shares_delta, direction, est_value}.
""" """
total = portfolio_value(holdings, prices, cash) total = portfolio_value(holdings, prices, cash)
trades = [] raw = []
all_tickers = set(list(target_weights.keys()) + list(holdings.keys())) all_tickers = set(list(target_weights.keys()) + list(holdings.keys()))
for ticker in sorted(all_tickers): for ticker in sorted(all_tickers):
@@ -171,31 +178,49 @@ def compute_trades(holdings: dict, cash: float, target_weights: dict,
current_shares = holdings.get(ticker, 0.0) current_shares = holdings.get(ticker, 0.0)
delta = target_shares - current_shares 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) trade_value = abs(delta * price)
if trade_value < min_trade_value: if trade_value < min_trade_value:
continue continue
if integer_shares and abs(delta) < 1:
continue
trades.append({ raw.append({
"ticker": ticker, "ticker": ticker,
"shares_delta": round(delta, 4), "shares_delta": int(delta) if integer_shares else round(delta, 4),
"direction": "BUY" if delta > 0 else "SELL", "direction": "BUY" if delta > 0 else "SELL",
"est_value": round(trade_value, 2), "est_value": round(trade_value, 2),
"price": round(price, 2), "price": round(price, 2),
"target_shares": round(target_shares, 4), "target_shares": int(round(target_shares)) if integer_shares else round(target_shares, 4),
"current_shares": round(current_shares, 4), "current_shares": int(current_shares) if integer_shares else round(current_shares, 4),
}) })
return trades return raw
def execute_trades(state: dict, trades: list[dict], prices: dict, def execute_trades(state: dict, trades: list[dict], prices: dict,
tx_cost: float = 0.001, fixed_fee: float = 0.0, tx_cost: float = 0.001, fixed_fee: float = 0.0,
trade_date: str = "") -> None: trade_date: str = "", integer_shares: bool = False) -> None:
"""Execute trades: update holdings and cash in state, append to trade_log.""" """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"] holdings = state["holdings"]
cash = state["cash"] cash = state["cash"]
for trade in trades: # 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"] ticker = trade["ticker"]
delta = trade["shares_delta"] delta = trade["shares_delta"]
price = prices.get(ticker, trade["price"]) price = prices.get(ticker, trade["price"])
@@ -203,7 +228,15 @@ def execute_trades(state: dict, trades: list[dict], prices: dict,
commission = cost * tx_cost + fixed_fee commission = cost * tx_cost + fixed_fee
if delta > 0: if delta > 0:
# BUY # 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) cash -= (cost + commission)
holdings[ticker] = holdings.get(ticker, 0.0) + delta holdings[ticker] = holdings.get(ticker, 0.0) + delta
else: else:
@@ -212,21 +245,74 @@ def execute_trades(state: dict, trades: list[dict], prices: dict,
holdings[ticker] = holdings.get(ticker, 0.0) + delta # delta is negative holdings[ticker] = holdings.get(ticker, 0.0) + delta # delta is negative
# Remove zero holdings # Remove zero holdings
if ticker in holdings and abs(holdings[ticker]) < 0.001: if ticker in holdings and abs(holdings[ticker]) < (0.5 if integer_shares else 0.001):
del holdings[ticker] del holdings[ticker]
state["trade_log"].append({ state["trade_log"].append({
"date": trade_date, "date": trade_date,
"action": trade["direction"], "action": "BUY" if delta > 0 else "SELL",
"ticker": ticker, "ticker": ticker,
"shares": round(abs(delta), 4), "shares": abs(delta) if integer_shares else round(abs(delta), 4),
"price": round(price, 2), "price": round(price, 2),
"value": round(cost, 2), "value": round(cost, 2),
"commission": round(commission, 2), "commission": round(commission, 2),
}) })
state["cash"] = round(cash, 2) state["cash"] = round(cash, 2)
state["holdings"] = {k: round(v, 4) for k, v in holdings.items() if abs(v) >= 0.001} 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: def get_prices_for_date(tickers: list[str], date_idx, price_df: pd.DataFrame) -> dict:
@@ -288,7 +374,8 @@ def cmd_morning(args):
total = portfolio_value(state["holdings"], open_prices, state["cash"]) total = portfolio_value(state["holdings"], open_prices, state["cash"])
trades = compute_trades(state["holdings"], state["cash"], target_weights, trades = compute_trades(state["holdings"], state["cash"], target_weights,
open_prices, min_trade_value=max(50, total * 0.001)) open_prices, min_trade_value=max(50, total * 0.001),
integer_shares=args.integer_shares)
# Store pending # Store pending
state["pending_trades"] = { state["pending_trades"] = {
@@ -386,12 +473,13 @@ def cmd_evening(args):
target_weights = pending.get("target_weights", {}) target_weights = pending.get("target_weights", {})
exec_trades = compute_trades( exec_trades = compute_trades(
state["holdings"], state["cash"], target_weights, state["holdings"], state["cash"], target_weights,
close_prices, min_trade_value=max(50, pre_value * 0.001) close_prices, min_trade_value=max(50, pre_value * 0.001),
integer_shares=args.integer_shares
) )
execute_trades(state, exec_trades, close_prices, execute_trades(state, exec_trades, close_prices,
tx_cost=args.tx_cost, fixed_fee=args.fixed_fee, tx_cost=args.tx_cost, fixed_fee=args.fixed_fee,
trade_date=trade_date) trade_date=trade_date, integer_shares=args.integer_shares)
post_value = portfolio_value(state["holdings"], close_prices, state["cash"]) post_value = portfolio_value(state["holdings"], close_prices, state["cash"])
state["daily_equity"][trade_date] = round(post_value, 2) state["daily_equity"][trade_date] = round(post_value, 2)
@@ -505,6 +593,92 @@ def cmd_status(args):
print() 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): def cmd_simulate(args):
"""Simulate day-by-day over a date range.""" """Simulate day-by-day over a date range."""
market = args.market market = args.market
@@ -579,16 +753,21 @@ def cmd_simulate(args):
# Compute and execute trades at close prices # Compute and execute trades at close prices
trades = compute_trades( trades = compute_trades(
state["holdings"], state["cash"], target_weights, state["holdings"], state["cash"], target_weights,
close_prices, min_trade_value=max(50, pre_value * 0.001) close_prices, min_trade_value=max(50, pre_value * 0.001),
integer_shares=args.integer_shares
) )
execute_trades(state, trades, close_prices, execute_trades(state, trades, close_prices,
tx_cost=args.tx_cost, fixed_fee=args.fixed_fee, tx_cost=args.tx_cost, fixed_fee=args.fixed_fee,
trade_date=day_str) trade_date=day_str, integer_shares=args.integer_shares)
post_value = portfolio_value(state["holdings"], close_prices, state["cash"]) post_value = portfolio_value(state["holdings"], close_prices, state["cash"])
state["daily_equity"][day_str] = round(post_value, 2) 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_trades = len(trades)
day_commission = sum( day_commission = sum(
t.get("commission", 0) for t in state["trade_log"][-day_trades:] t.get("commission", 0) for t in state["trade_log"][-day_trades:]
@@ -661,8 +840,11 @@ def cmd_monitor(args):
""" """
Long-running monitor — runs in a tmux session, automatically executes daily. Long-running monitor — runs in a tmux session, automatically executes daily.
Sleeps until market close + buffer, runs auto logic, then sleeps until Two-phase daily schedule:
the next trading day. Handles weekends and holidays gracefully. 1. MORNING (~9:45 AM): Download open prices, run strategy, print orders
2. EVENING (~4:35 PM): Download close prices, execute trades, record
Sleeps between phases and between trading days. Skips weekends.
Usage: Usage:
tmux new -s quant tmux new -s quant
@@ -674,20 +856,18 @@ def cmd_monitor(args):
market = args.market market = args.market
# Market schedule configuration # Market schedule configuration — two phases per day
MARKET_CONFIG = { MARKET_CONFIG = {
"us": { "us": {
"tz": zoneinfo.ZoneInfo("America/New_York"), "tz": zoneinfo.ZoneInfo("America/New_York"),
"close_hour": 16, # 4:00 PM ET "open_hour": 9, "open_min": 30, "open_buffer": 15, # run at 9:45 AM ET
"close_min": 0, "close_hour": 16, "close_min": 0, "close_buffer": 35, # run at 4:35 PM ET
"buffer_min": 35, # Wait 35 min after close for data settlement
"label": "US (NYSE/NASDAQ)", "label": "US (NYSE/NASDAQ)",
}, },
"cn": { "cn": {
"tz": zoneinfo.ZoneInfo("Asia/Shanghai"), "tz": zoneinfo.ZoneInfo("Asia/Shanghai"),
"close_hour": 15, # 3:00 PM CST "open_hour": 9, "open_min": 30, "open_buffer": 15, # run at 9:45 AM CST
"close_min": 0, "close_hour": 15, "close_min": 0, "close_buffer": 35, # run at 3:35 PM CST
"buffer_min": 35,
"label": "CN (SSE/SZSE)", "label": "CN (SSE/SZSE)",
}, },
} }
@@ -698,80 +878,117 @@ def cmd_monitor(args):
return return
tz = config["tz"] tz = config["tz"]
run_hour = config["close_hour"]
run_min = config["close_min"] + config["buffer_min"] # Compute actual run times (hour, minute)
# Handle minute overflow morn_h = config["open_hour"]
if run_min >= 60: morn_m = config["open_min"] + config["open_buffer"]
run_hour += run_min // 60 if morn_m >= 60:
run_min = run_min % 60 morn_h += morn_m // 60
morn_m = morn_m % 60
eve_h = config["close_hour"]
eve_m = config["close_min"] + config["close_buffer"]
if eve_m >= 60:
eve_h += eve_m // 60
eve_m = eve_m % 60
print(f"\n{'='*60}") print(f"\n{'='*60}")
print(f" MONITOR MODE — {config['label']}") print(f" MONITOR MODE — {config['label']}")
print(f" Strategy: {args.strategy} | Market: {market.upper()}") print(f" Strategy: {args.strategy} | Market: {market.upper()}")
print(f" Daily run at: {run_hour:02d}:{run_min:02d} {tz}") print(f" Morning (open → signals): {morn_h:02d}:{morn_m:02d} {tz}")
print(f" Evening (close → execute): {eve_h:02d}:{eve_m:02d} {tz}")
print(f" Capital: ${args.capital:,.0f}") print(f" Capital: ${args.capital:,.0f}")
print(f"{'='*60}") print(f"{'='*60}")
print(f"[monitor] Started at {datetime.now(tz).strftime('%Y-%m-%d %H:%M:%S %Z')}") print(f"[monitor] Started at {datetime.now(tz).strftime('%Y-%m-%d %H:%M:%S %Z')}")
print(f"[monitor] Press Ctrl+C to stop\n") print(f"[monitor] Press Ctrl+C to stop\n")
def _next_run_time(now): def _next_event(now):
"""Compute the next execution datetime (skip weekends).""" """Return (target_datetime, phase) for the next event to run.
# Today's run time
target = now.replace(hour=run_hour, minute=run_min, second=0, microsecond=0)
if now >= target: phase is 'morning' or 'evening'.
# Already past today's run time, schedule for tomorrow """
target += timedelta(days=1) today_morning = now.replace(hour=morn_h, minute=morn_m, second=0, microsecond=0)
today_evening = now.replace(hour=eve_h, minute=eve_m, second=0, microsecond=0)
# Skip weekends: Saturday=5, Sunday=6 candidates = []
# Today's morning (if not past)
if now < today_morning:
candidates.append((today_morning, "morning"))
# Today's evening (if not past)
if now < today_evening:
candidates.append((today_evening, "evening"))
# If nothing left today, schedule tomorrow's morning
if not candidates:
tomorrow = now + timedelta(days=1)
tom_morning = tomorrow.replace(hour=morn_h, minute=morn_m, second=0, microsecond=0)
candidates.append((tom_morning, "morning"))
# Pick the earliest
target, phase = min(candidates, key=lambda x: x[0])
# Skip weekends
while target.weekday() >= 5: while target.weekday() >= 5:
target += timedelta(days=1) target += timedelta(days=1)
return target return target, phase
while True: def _sleep_until(target):
now = datetime.now(tz) """Sleep in chunks until target time. Print hourly heartbeats."""
next_run = _next_run_time(now)
wait_seconds = (next_run - now).total_seconds()
print(f"[monitor] {now.strftime('%Y-%m-%d %H:%M:%S')}"
f"Next run: {next_run.strftime('%Y-%m-%d %H:%M:%S %Z')} "
f"(in {wait_seconds/3600:.1f}h)")
# Sleep in chunks so Ctrl+C is responsive and we print heartbeats
while True: while True:
now = datetime.now(tz) now = datetime.now(tz)
remaining = (next_run - now).total_seconds() remaining = (target - now).total_seconds()
if remaining <= 0: if remaining <= 0:
break break
# Sleep in 15-minute chunks, print heartbeat every hour chunk = min(remaining, 900) # 15-min chunks
chunk = min(remaining, 900) # 15 min
_time.sleep(chunk) _time.sleep(chunk)
now = datetime.now(tz) now = datetime.now(tz)
remaining = (next_run - now).total_seconds() remaining = (target - now).total_seconds()
if remaining > 60: if remaining > 60:
hours_left = remaining / 3600 hours_left = remaining / 3600
if int(remaining) % 3600 < 900: # ~on the hour if int(remaining) % 3600 < 900:
print(f"[monitor] {now.strftime('%H:%M:%S')}" print(f"[monitor] {now.strftime('%H:%M:%S')}"
f"waiting... ({hours_left:.1f}h until run)") f"waiting... ({hours_left:.1f}h until next)")
while True:
now = datetime.now(tz)
target, phase = _next_event(now)
wait_seconds = (target - now).total_seconds()
print(f"[monitor] {now.strftime('%Y-%m-%d %H:%M:%S')}"
f"Next: {phase.upper()} at {target.strftime('%Y-%m-%d %H:%M:%S %Z')} "
f"(in {wait_seconds/3600:.1f}h)")
_sleep_until(target)
# Time to run! # Time to run!
now = datetime.now(tz) now = datetime.now(tz)
print(f"\n[monitor] {'='*55}") print(f"\n[monitor] {'='*55}")
print(f"[monitor] Executing daily run at {now.strftime('%Y-%m-%d %H:%M:%S %Z')}") print(f"[monitor] {phase.upper()} phase at "
f"{now.strftime('%Y-%m-%d %H:%M:%S %Z')}")
print(f"[monitor] {'='*55}") print(f"[monitor] {'='*55}")
try: try:
cmd_auto(args) if phase == "morning":
print(f"[monitor] Daily run completed successfully.") cmd_morning(args)
else:
# Check if morning was run today; if not, use auto (combined)
state = load_state(market, args.strategy)
today_str = now.strftime("%Y-%m-%d")
if state and state.get("last_morning") == today_str and state.get("pending_trades"):
cmd_evening(args)
else:
print(f"[monitor] Morning was not run today — using auto (combined) mode")
cmd_auto(args)
print(f"[monitor] {phase.upper()} completed successfully.")
except KeyboardInterrupt: except KeyboardInterrupt:
raise raise
except Exception as e: except Exception as e:
print(f"[monitor] ERROR during daily run: {e}") print(f"[monitor] ERROR during {phase}: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
print(f"[monitor] Will retry on next scheduled run.") print(f"[monitor] Will continue to next scheduled event.")
print() print()
@@ -837,15 +1054,22 @@ def cmd_auto(args):
# --- EVENING PHASE: Execute trades at close prices --- # --- EVENING PHASE: Execute trades at close prices ---
trades = compute_trades( trades = compute_trades(
state["holdings"], state["cash"], target_weights, state["holdings"], state["cash"], target_weights,
close_prices, min_trade_value=max(50, pre_value * 0.001) close_prices, min_trade_value=max(50, pre_value * 0.001),
integer_shares=args.integer_shares
) )
execute_trades(state, trades, close_prices, execute_trades(state, trades, close_prices,
tx_cost=args.tx_cost, fixed_fee=args.fixed_fee, tx_cost=args.tx_cost, fixed_fee=args.fixed_fee,
trade_date=today_str) trade_date=today_str, integer_shares=args.integer_shares)
post_value = portfolio_value(state["holdings"], close_prices, state["cash"]) post_value = portfolio_value(state["holdings"], close_prices, state["cash"])
state["daily_equity"][today_str] = round(post_value, 2) 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_morning"] = today_str
state["last_evening"] = today_str state["last_evening"] = today_str
state["pending_trades"] = None state["pending_trades"] = None
@@ -897,6 +1121,8 @@ def main():
help="Proportional transaction cost (default: 0.001 = 10bps)") help="Proportional transaction cost (default: 0.001 = 10bps)")
p.add_argument("--fixed-fee", type=float, default=0.0, p.add_argument("--fixed-fee", type=float, default=0.0,
help="Fixed dollar fee per trade") help="Fixed dollar fee per trade")
p.add_argument("--integer-shares", action="store_true", default=False,
help="Only trade whole shares (no fractional)")
# Morning # Morning
p_morning = sub.add_parser("morning", help="Generate trade orders from open prices") p_morning = sub.add_parser("morning", help="Generate trade orders from open prices")
@@ -924,6 +1150,7 @@ def main():
p_status.add_argument("--capital", type=float, default=100_000) 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("--tx-cost", type=float, default=0.001)
p_status.add_argument("--fixed-fee", type=float, default=0.0) p_status.add_argument("--fixed-fee", type=float, default=0.0)
p_status.add_argument("--integer-shares", action="store_true", default=False)
# Simulate # Simulate
p_sim = sub.add_parser("simulate", help="Simulate over a date range") p_sim = sub.add_parser("simulate", help="Simulate over a date range")
@@ -931,6 +1158,14 @@ def main():
p_sim.add_argument("--start", required=True, help="Start date (YYYY-MM-DD)") 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)") 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)")
args = parser.parse_args() args = parser.parse_args()
if args.command == "morning": if args.command == "morning":
@@ -945,6 +1180,8 @@ def main():
cmd_status(args) cmd_status(args)
elif args.command == "simulate": elif args.command == "simulate":
cmd_simulate(args) cmd_simulate(args)
elif args.command == "log":
cmd_log(args)
if __name__ == "__main__": if __name__ == "__main__":