"""Paper trading auto daemon — runs on Phoenix (MST) timezone. Watches the ET market schedule and automatically executes: 07:00 ET Pre-market pipeline (filing_poller → label_generator, both conventions) 09:35 ET run-open (exits + after-close entries at market open) 15:45 ET run-close (same-day MOC entries before market close) 16:30 ET Post-close pipeline (pending label regen + after-close labels) Usage: python -m apps.paper_trader.auto --session v504_live python -m apps.paper_trader.auto # all active sessions python -m apps.paper_trader.auto --dry-run # print without executing """ from __future__ import annotations import argparse import datetime as dt import json import os import subprocess import sys import threading import time as _time from dataclasses import dataclass, field from zoneinfo import ZoneInfo from rich import box as rbox from rich.console import Console from rich.panel import Panel from rich.table import Table _TZ_ET = ZoneInfo("America/New_York") _TZ_PHX = ZoneInfo("America/Phoenix") # MST, UTC-7, no DST _console = Console(width=120) _DEFAULT_DB = os.environ.get("PAPER_TRADER_DB", "paper_trading.db") # How often (seconds) to reprint the countdown while waiting _STATUS_INTERVAL = 600 # 10 min # ------------------------------------------------------------------ # # Schedule definition # ------------------------------------------------------------------ # @dataclass class ScheduledEvent: name: str et_hour: int et_min: int description: str kind: str # pipeline_pre | run_open | run_close | pipeline_post SCHEDULE: list[ScheduledEvent] = [ ScheduledEvent("pipeline_pre", 7, 0, "Pre-market pipeline (poller → parser → label_gen both conventions)", "pipeline_pre"), ScheduledEvent("run_open", 9, 35, "run-open — exits + after-close entries at market open", "run_open"), ScheduledEvent("run_close", 15, 45, "run-close — same-day MOC entries (Alpaca cutoff 3:45 PM ET)", "run_close"), ScheduledEvent("pipeline_post", 16, 30, "Post-close pipeline — pending label regen + after-close labels", "pipeline_post"), ] # Pipeline commands (in execution order) _PIPELINE_CMDS = [ ["python", "-m", "apps.pipeline.filing_poller.main"], ["python", "-m", "apps.pipeline.filing_fetcher.main"], ["python", "-m", "apps.pipeline.event_parser.main"], ["python", "-m", "apps.pipeline.feature_builder.main"], ["python", "-m", "apps.pipeline.label_generator.main"], ["python", "-m", "apps.pipeline.label_generator.main", "--entry-convention", "reaction_close"], ] _POST_PIPELINE_CMDS = [ ["python", "-m", "apps.pipeline.filing_poller.main"], ["python", "-m", "apps.pipeline.filing_fetcher.main"], ["python", "-m", "apps.pipeline.event_parser.main"], ["python", "-m", "apps.pipeline.feature_builder.main"], # reaction_close first (regenerates pending labels with final close price) ["python", "-m", "apps.pipeline.label_generator.main", "--entry-convention", "reaction_close"], # then next_open labels for today's after-close events ["python", "-m", "apps.pipeline.label_generator.main"], ] # ------------------------------------------------------------------ # # Time helpers # ------------------------------------------------------------------ # def _now_et() -> dt.datetime: return dt.datetime.now(tz=_TZ_ET) def _now_phx() -> dt.datetime: return dt.datetime.now(tz=_TZ_PHX) def _to_phx_str(et_dt: dt.datetime) -> str: return et_dt.astimezone(_TZ_PHX).strftime("%I:%M %p") def _fmt_countdown(seconds: float) -> str: if seconds <= 0: return "now" h = int(seconds // 3600) m = int((seconds % 3600) // 60) s = int(seconds % 60) if h > 0: return f"{h}h {m:02d}m" if m > 0: return f"{m}m {s:02d}s" return f"{s}s" def _et_dt_for(date: dt.date, event: ScheduledEvent) -> dt.datetime: return dt.datetime(date.year, date.month, date.day, event.et_hour, event.et_min, tzinfo=_TZ_ET) def _is_trading_day(date: dt.date) -> bool: try: from libs.common.time_utils import is_trading_day return is_trading_day(date) except Exception: # Fallback: Mon–Fri excluding obvious US holidays return date.weekday() < 5 def _next_trading_day(from_date: dt.date) -> dt.date: check = from_date + dt.timedelta(days=1) for _ in range(14): if _is_trading_day(check): return check check += dt.timedelta(days=1) raise RuntimeError("No trading day found within 14 days") def _prev_trading_day(from_date: dt.date) -> dt.date: check = from_date - dt.timedelta(days=1) for _ in range(14): if _is_trading_day(check): return check check -= dt.timedelta(days=1) raise RuntimeError("No previous trading day found within 14 days") # ------------------------------------------------------------------ # # Catch-up: run missed pipeline steps on startup # ------------------------------------------------------------------ # def _run_catchup(now_et: dt.datetime, dry_run: bool) -> None: """자동으로 놓친 파이프라인 단계를 시작 시 실행. 파이프라인은 멱등성이 있어 재실행 안전 (기존 레코드는 skip). 트레이딩 커맨드(run-open/run-close)는 타이밍이 중요해 catch-up 제외. """ today = now_et.date() catchup_items: list[tuple[str, str, list[list[str]]]] = [] # (label, reason, cmds) # 1. 직전 거래일의 post-close pipeline (16:30 ET)이 안 돌았을 가능성 # → 오늘 장마감 후 이벤트 라벨링이 안 됨 → 다음 run-open 준비 안 됨 prev_td = _prev_trading_day(today) prev_post_close_et = _et_dt_for(prev_td, SCHEDULE[3]) # pipeline_post = 16:30 if prev_post_close_et < now_et: catchup_items.append(( f"Post-close pipeline ({prev_td})", f"직전 거래일 16:30 ET 파이프라인 — 오늘 run-open 후보 준비", _POST_PIPELINE_CMDS, )) # 2. 오늘이 거래일이고 pre-market pipeline (07:00 ET) 시간이 지났으면 if _is_trading_day(today): pre_market_et = _et_dt_for(today, SCHEDULE[0]) # pipeline_pre = 07:00 if pre_market_et < now_et: catchup_items.append(( f"Pre-market pipeline ({today})", f"오늘 07:00 ET 파이프라인 — 새벽 공시 라벨링", _PIPELINE_CMDS, )) if not catchup_items: return _console.print("\n[bold yellow]━━━ Catch-up: 놓친 파이프라인 실행 ━━━[/]") for label, reason, cmds in catchup_items: _console.print(f"\n[yellow]▶ {label}[/] [dim]{reason}[/]") _run_pipeline(cmds, dry_run) _console.print("[bold yellow]━━━ Catch-up 완료 ━━━[/]\n") # ------------------------------------------------------------------ # # Subprocess helpers # ------------------------------------------------------------------ # def _run_cmd(cmd: list[str], dry_run: bool, pipeline: bool = False) -> bool: label = " ".join(cmd[2:] if cmd[:2] == ["python", "-m"] else cmd) if dry_run: _console.print(f" [dim][DRY] {label}[/]") return True _console.print(f" [dim]▶ {label}[/]", end="") try: result = subprocess.run( cmd, check=False, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, ) ok = result.returncode == 0 if pipeline: # Pipeline commands: parse JSON logs, show only summary + errors summary_parts: list[str] = [] error_lines: list[str] = [] for line in (result.stdout or "").splitlines(): line = line.strip() if not line: continue try: rec = json.loads(line) level = rec.get("level", "") event = rec.get("event", "") if level in ("error", "critical"): error_lines.append(f"[red] {event}: {rec}[/]") elif event.endswith("_done"): # Extract key counts from the summary event parts = [f"{k}={v}" for k, v in rec.items() if k not in ("event", "level", "timestamp", "job_run_id")] summary_parts.append(f"{event}({', '.join(parts)})") except (json.JSONDecodeError, ValueError): pass # httpx HTTP Request lines and other non-JSON: silently skip _console.print(f" {'[green]OK[/]' if ok else '[red]FAILED[/]'}") if summary_parts: _console.print(f" [dim]{' | '.join(summary_parts)}[/]") for err in error_lines: _console.print(err) else: # Non-pipeline commands (paper run-open, run-close): pass through output _console.print(f" {'[green]OK[/]' if ok else '[red]FAILED[/]'}") if result.stdout: _console.print(result.stdout.rstrip()) if not ok: _console.print(f" [red] ↳ FAILED (exit {result.returncode})[/]") return ok except Exception as exc: _console.print(f"\n [red] ↳ ERROR: {exc}[/]") return False def _run_pipeline(cmds: list[list[str]], dry_run: bool) -> None: for cmd in cmds: _run_cmd(cmd, dry_run, pipeline=True) def _run_paper(command: str, sessions: list[str], db: str, dry_run: bool) -> None: for session in sessions: cmd = ["python", "-m", "apps.paper_trader.cli", command, "--session", session, "--db", db] _run_cmd(cmd, dry_run) # ------------------------------------------------------------------ # # Display # ------------------------------------------------------------------ # def _print_header(sessions: list[str]) -> None: now_phx = _now_phx() now_et = _now_et() _console.print() _console.print(Panel( f"[bold cyan]fithia2 paper auto[/] | " f"PHX [bold]{now_phx.strftime('%I:%M %p MST')}[/] " f"ET [bold]{now_et.strftime('%I:%M %p %Z')}[/]\n" f"[dim]Sessions: {', '.join(sessions)}[/]", border_style="cyan", padding=(0, 2), )) def _print_schedule(now_et: dt.datetime, completed: set[str]) -> None: today = now_et.date() is_td = _is_trading_day(today) tbl = Table(box=rbox.SIMPLE, show_header=True, header_style="bold yellow", padding=(0, 2), expand=False) tbl.add_column("ET", style="bold", no_wrap=True) tbl.add_column("PHX", style="dim", no_wrap=True) tbl.add_column("Action") tbl.add_column("", no_wrap=True) for ev in SCHEDULE: et_dt = _et_dt_for(today, ev) phx_str = _to_phx_str(et_dt) et_str = et_dt.strftime("%I:%M %p") if ev.name in completed: marker = "[green]✓ done[/]" elif et_dt <= now_et: marker = "[dim]skipped[/]" else: secs = (et_dt - now_et).total_seconds() marker = f"[dim]in {_fmt_countdown(secs)}[/]" tbl.add_row(et_str, phx_str, ev.description, marker) day_str = f"[bold]{today.strftime('%a %Y-%m-%d')}[/]" td_str = "[green]Trading Day[/]" if is_td else "[red]Non-Trading Day[/]" _console.print(f"\nSchedule {day_str} {td_str}") _console.print(tbl) def _log(msg: str) -> None: ts = _now_phx().strftime("%H:%M PHX") _console.print(f"[dim]{ts}[/] {msg}") # ------------------------------------------------------------------ # # Main daemon loop # ------------------------------------------------------------------ # def _get_active_sessions(db: str) -> list[str]: try: from apps.paper_trader.state import StateManager return [s.session_name for s in StateManager(db).list_sessions() if s.status == "active"] except Exception: return [] def run_auto(sessions: list[str], db: str, dry_run: bool) -> None: resolved = sessions or _get_active_sessions(db) if not resolved: _console.print(f"[red]No active sessions in '{db}'. Use --session NAME or create a session first.[/]") sys.exit(1) _console.print(f"\n[bold cyan]fithia2 paper auto[/] sessions: [bold]{', '.join(resolved)}[/]") if dry_run: _console.print("[yellow]DRY RUN — commands will not execute[/]") _console.print("Ctrl+C to stop.\n") # 시작 시 놓친 파이프라인 자동 catch-up (background — 메인 스케줄 루프 블로킹 방지) _catchup_thread = threading.Thread( target=_run_catchup, args=(_now_et(), dry_run), daemon=True, name="catchup" ) _catchup_thread.start() completed: set[str] = set() last_schedule_date: dt.date | None = None last_status_print: float = 0.0 try: while True: now_et = _now_et() today = now_et.date() # New day → reset if last_schedule_date != today: completed.clear() last_schedule_date = today last_status_print = 0.0 _print_header(resolved) if not _is_trading_day(today): next_td = _next_trading_day(today) _log(f"Non-trading day. Next trading day: [bold]{next_td}[/]") else: # Mark events that already passed when starting mid-day as skipped for ev in SCHEDULE: if _et_dt_for(today, ev) <= now_et: completed.add(ev.name) _log(f"[dim]Skipping past event: {ev.description}[/]") _print_schedule(now_et, completed) if not _is_trading_day(today): _time.sleep(1800) # 30 min; loop will recheck continue # Find next pending event pending = [ev for ev in SCHEDULE if ev.name not in completed] if not pending: # All done today → sleep until tomorrow's first event next_td = _next_trading_day(today) first = SCHEDULE[0] wake_et = _et_dt_for(next_td, first) wait = (wake_et - now_et).total_seconds() _log(f"[green]All done for today.[/] Sleeping until [bold]{wake_et.strftime('%I:%M %p ET')} " f"({_to_phx_str(wake_et)} PHX)[/] on {next_td} " f"— {_fmt_countdown(wait)}") _time.sleep(min(wait, 3600)) continue next_ev = pending[0] next_et = _et_dt_for(today, next_ev) wait = (next_et - now_et).total_seconds() if wait > 90: # Periodic status line every _STATUS_INTERVAL seconds now_mono = _time.monotonic() if now_mono - last_status_print >= _STATUS_INTERVAL: _log(f"Next: [bold]{next_ev.description}[/] " f"at {next_et.strftime('%I:%M %p ET')} ({_to_phx_str(next_et)} PHX) " f"— {_fmt_countdown(wait)}") last_status_print = now_mono _time.sleep(min(wait - 60, _STATUS_INTERVAL)) continue # ≤ 90s away — wait out remainder if wait > 0: _log(f"[yellow]Firing in {_fmt_countdown(wait)}: {next_ev.description}[/]") _time.sleep(wait) # ── Execute ────────────────────────────────────────────── now_phx_str = _now_phx().strftime("%H:%M PHX") _console.print(f"\n{'─'*60}") _console.print(f"[bold green]{now_phx_str} ▶ {next_ev.description}[/]") _console.print(f"{'─'*60}") if next_ev.kind == "pipeline_pre": _run_pipeline(_PIPELINE_CMDS, dry_run) elif next_ev.kind == "run_open": _run_paper("run-open", resolved, db, dry_run) elif next_ev.kind == "run_close": _run_paper("run-close", resolved, db, dry_run) elif next_ev.kind == "pipeline_post": _run_pipeline(_POST_PIPELINE_CMDS, dry_run) completed.add(next_ev.name) last_status_print = 0.0 # reprint schedule next status line done_str = _now_phx().strftime("%H:%M PHX") _console.print(f"[green]✓ Done ({done_str})[/]") _print_schedule(_now_et(), completed) except KeyboardInterrupt: _console.print("\n[yellow]Auto daemon stopped.[/]") # ------------------------------------------------------------------ # # Entry point # ------------------------------------------------------------------ # def main() -> None: parser = argparse.ArgumentParser( prog="fithia2-paper-auto", description="Paper trading auto daemon (Phoenix/MST timezone)", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" ET schedule (runs every trading day): 07:00 Pre-market pipeline (filing_poller → label_generator, both conventions) 09:35 run-open (exits + after-close entries at market open) 15:45 run-close (same-day MOC entries, Alpaca cutoff 3:45 PM ET) 16:30 Post-close pipeline (pending label regen + after-close labels) Examples: python -m apps.paper_trader.auto --session v504_live python -m apps.paper_trader.auto # all active sessions python -m apps.paper_trader.auto --dry-run --session v504_live """, ) parser.add_argument( "--session", "-s", nargs="*", dest="session", default=[], metavar="NAME", help="Session name(s). Default: all active sessions.", ) parser.add_argument( "--db", default=_DEFAULT_DB, metavar="PATH", help=f"SQLite DB path (default: {_DEFAULT_DB} or $PAPER_TRADER_DB)", ) parser.add_argument( "--dry-run", action="store_true", help="Show what would run without executing.", ) args = parser.parse_args() run_auto(sessions=args.session, db=args.db, dry_run=args.dry_run) if __name__ == "__main__": main()