diff --git a/.gitignore b/.gitignore index 3f42693..83650a1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +.claude + # Python __pycache__/ *.py[cod] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/trader.py b/trader.py index df71ee0..c503a8f 100644 --- a/trader.py +++ b/trader.py @@ -4,16 +4,25 @@ 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 that auto-runs daily after market close (for tmux) status — Print current portfolio, P&L, and recent trades simulate — Replay a date range day-by-day (for forward testing) 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. """ @@ -648,6 +657,125 @@ def cmd_simulate(args): print() +def cmd_monitor(args): + """ + Long-running monitor — runs in a tmux session, automatically executes daily. + + Sleeps until market close + buffer, runs auto logic, then sleeps until + the next trading day. Handles weekends and holidays gracefully. + + Usage: + tmux new -s quant + uv run python trader.py monitor --market us --strategy recovery_mom_top10 + # Ctrl-B D to detach + """ + import time as _time + import zoneinfo + + market = args.market + + # Market schedule configuration + MARKET_CONFIG = { + "us": { + "tz": zoneinfo.ZoneInfo("America/New_York"), + "close_hour": 16, # 4:00 PM ET + "close_min": 0, + "buffer_min": 35, # Wait 35 min after close for data settlement + "label": "US (NYSE/NASDAQ)", + }, + "cn": { + "tz": zoneinfo.ZoneInfo("Asia/Shanghai"), + "close_hour": 15, # 3:00 PM CST + "close_min": 0, + "buffer_min": 35, + "label": "CN (SSE/SZSE)", + }, + } + + config = MARKET_CONFIG.get(market) + if not config: + print(f"[monitor] Unknown market '{market}'. Supported: {list(MARKET_CONFIG.keys())}") + return + + tz = config["tz"] + run_hour = config["close_hour"] + run_min = config["close_min"] + config["buffer_min"] + # Handle minute overflow + if run_min >= 60: + run_hour += run_min // 60 + run_min = run_min % 60 + + print(f"\n{'='*60}") + print(f" MONITOR MODE — {config['label']}") + print(f" Strategy: {args.strategy} | Market: {market.upper()}") + print(f" Daily run at: {run_hour:02d}:{run_min:02d} {tz}") + print(f" Capital: ${args.capital:,.0f}") + print(f"{'='*60}") + 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") + + def _next_run_time(now): + """Compute the next execution datetime (skip weekends).""" + # Today's run time + target = now.replace(hour=run_hour, minute=run_min, second=0, microsecond=0) + + if now >= target: + # Already past today's run time, schedule for tomorrow + target += timedelta(days=1) + + # Skip weekends: Saturday=5, Sunday=6 + while target.weekday() >= 5: + target += timedelta(days=1) + + return target + + while True: + now = datetime.now(tz) + 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: + now = datetime.now(tz) + remaining = (next_run - now).total_seconds() + if remaining <= 0: + break + # Sleep in 15-minute chunks, print heartbeat every hour + chunk = min(remaining, 900) # 15 min + _time.sleep(chunk) + + now = datetime.now(tz) + remaining = (next_run - now).total_seconds() + if remaining > 60: + hours_left = remaining / 3600 + if int(remaining) % 3600 < 900: # ~on the hour + print(f"[monitor] {now.strftime('%H:%M:%S')} — " + f"waiting... ({hours_left:.1f}h until run)") + + # Time to run! + now = datetime.now(tz) + print(f"\n[monitor] {'='*55}") + print(f"[monitor] Executing daily run at {now.strftime('%Y-%m-%d %H:%M:%S %Z')}") + print(f"[monitor] {'='*55}") + + try: + cmd_auto(args) + print(f"[monitor] Daily run completed successfully.") + except KeyboardInterrupt: + raise + except Exception as e: + print(f"[monitor] ERROR during daily run: {e}") + import traceback + traceback.print_exc() + print(f"[monitor] Will retry on next scheduled run.") + + print() + + def cmd_auto(args): """ Automated daily run — single invocation handles both morning + evening. @@ -783,6 +911,11 @@ def main(): help="Automated daily run: signal + execute in one step (for cron)") add_common(p_auto) + # Monitor (long-running daemon for tmux) + p_monitor = sub.add_parser("monitor", + help="Long-running daemon: auto-runs daily after market close (for tmux)") + add_common(p_monitor) + # 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") @@ -806,6 +939,8 @@ def main(): 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":