Single-process monitor for US + CN markets
- `trader.py monitor` now handles all markets in one process (US 9:45/16:35 ET, CN 9:45/15:35 CST) with unified UTC event loop - Events from different markets/timezones are merged; sleeps until the globally next event across all markets - Overlapping events (e.g. US evening + CN morning) fire together - `trader.py compare` defaults to US, use --market cn for A-shares - One tmux session handles everything: just `uv run python trader.py monitor` Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
255
trader.py
255
trader.py
@@ -5,10 +5,11 @@ 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 that auto-runs daily after market close (for tmux)
|
||||
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
|
||||
@@ -987,24 +988,20 @@ def cmd_monitor(args):
|
||||
"""
|
||||
Long-running monitor — runs in a tmux session, automatically executes daily.
|
||||
|
||||
Two-phase daily schedule:
|
||||
1. MORNING (~9:45 AM): Download open prices, run strategy, print orders
|
||||
2. EVENING (~4:35 PM): Download close prices, execute trades, record
|
||||
|
||||
Supports multiple strategies — each gets its own state file and runs
|
||||
independently at each phase.
|
||||
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 --market us \\
|
||||
--strategy recovery_mom_top10 momentum dual_momentum
|
||||
uv run python trader.py monitor
|
||||
# Ctrl-B D to detach
|
||||
"""
|
||||
import copy
|
||||
import time as _time
|
||||
import zoneinfo
|
||||
|
||||
market = args.market
|
||||
markets = args.market # list of markets
|
||||
strategies = args.strategy # list of strategy names
|
||||
|
||||
# Market schedule configuration — two phases per day
|
||||
@@ -1023,133 +1020,170 @@ def cmd_monitor(args):
|
||||
},
|
||||
}
|
||||
|
||||
config = MARKET_CONFIG.get(market)
|
||||
if not config:
|
||||
print(f"[monitor] Unknown market '{market}'. Supported: {list(MARKET_CONFIG.keys())}")
|
||||
return
|
||||
|
||||
tz = config["tz"]
|
||||
|
||||
# Compute actual run times (hour, minute)
|
||||
morn_h = config["open_hour"]
|
||||
morn_m = config["open_min"] + config["open_buffer"]
|
||||
if morn_m >= 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
|
||||
# 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 — {config['label']}")
|
||||
print(f" Market: {market.upper()} | Capital: ${args.capital:,.0f}")
|
||||
print(f" Strategies ({len(strategies)}):")
|
||||
for s in strategies:
|
||||
print(f" • {s}")
|
||||
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" 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}")
|
||||
print(f"[monitor] Started at {datetime.now(tz).strftime('%Y-%m-%d %H:%M:%S %Z')}")
|
||||
|
||||
# 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_event(now):
|
||||
"""Return (target_datetime, phase) for the next event to run.
|
||||
|
||||
phase is 'morning' or 'evening'.
|
||||
"""
|
||||
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)
|
||||
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 = []
|
||||
# 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"))
|
||||
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
|
||||
|
||||
# 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"))
|
||||
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])
|
||||
|
||||
# Pick the earliest
|
||||
target, phase = min(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)
|
||||
|
||||
# Skip weekends
|
||||
while target.weekday() >= 5:
|
||||
target += timedelta(days=1)
|
||||
|
||||
return target, phase
|
||||
|
||||
def _sleep_until(target):
|
||||
"""Sleep in chunks until target time. Print hourly heartbeats."""
|
||||
while True:
|
||||
now = datetime.now(tz)
|
||||
remaining = (target - now).total_seconds()
|
||||
if remaining <= 0:
|
||||
break
|
||||
chunk = min(remaining, 900) # 15-min chunks
|
||||
_time.sleep(chunk)
|
||||
|
||||
now = datetime.now(tz)
|
||||
remaining = (target - now).total_seconds()
|
||||
if remaining > 60:
|
||||
hours_left = remaining / 3600
|
||||
if int(remaining) % 3600 < 900:
|
||||
print(f"[monitor] {now.strftime('%H:%M:%S')} — "
|
||||
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!
|
||||
now = datetime.now(tz)
|
||||
print(f"\n[monitor] {'='*55}")
|
||||
print(f"[monitor] {phase.upper()} phase at "
|
||||
f"{now.strftime('%Y-%m-%d %H:%M:%S %Z')}")
|
||||
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:
|
||||
# Create a single-strategy args copy for each sub-command
|
||||
sub_args = copy.copy(args)
|
||||
sub_args.strategy = strat_name
|
||||
sub_args.market = market
|
||||
|
||||
print(f"\n[monitor] --- {strat_name} ---")
|
||||
print(f"\n[monitor] --- {market.upper()}:{strat_name} ---")
|
||||
try:
|
||||
if phase == "morning":
|
||||
cmd_morning(sub_args)
|
||||
else:
|
||||
# Check if morning was run today; if not, use auto (combined)
|
||||
state = load_state(market, strat_name)
|
||||
today_str = now.strftime("%Y-%m-%d")
|
||||
if state and state.get("last_morning") == today_str and state.get("pending_trades"):
|
||||
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 {strat_name} — using auto mode")
|
||||
print(f"[monitor] Morning not run for "
|
||||
f"{market.upper()}:{strat_name} — using auto")
|
||||
cmd_auto(sub_args)
|
||||
print(f"[monitor] {strat_name} {phase} OK")
|
||||
print(f"[monitor] {market.upper()}:{strat_name} {phase} OK")
|
||||
except KeyboardInterrupt:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"[monitor] ERROR in {strat_name} {phase}: {e}")
|
||||
print(f"[monitor] ERROR {market.upper()}:{strat_name} "
|
||||
f"{phase}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
print(f"[monitor] Continuing with next strategy...")
|
||||
|
||||
print(f"\n[monitor] All {len(strategies)} strategies processed.")
|
||||
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()
|
||||
|
||||
@@ -1298,10 +1332,13 @@ def main():
|
||||
help="Automated daily run: signal + execute in one step (for cron)")
|
||||
add_common(p_auto)
|
||||
|
||||
# Monitor (long-running daemon for tmux) — supports multiple strategies
|
||||
# Monitor (long-running daemon for tmux) — all markets + all strategies
|
||||
p_monitor = sub.add_parser("monitor",
|
||||
help="Long-running daemon: runs ALL strategies daily (for tmux)")
|
||||
p_monitor.add_argument("--market", choices=UNIVERSES.keys(), default="us")
|
||||
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()),
|
||||
|
||||
Reference in New Issue
Block a user