|
|
"""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 and refresh session list
|
|
|
if last_schedule_date != today:
|
|
|
completed.clear()
|
|
|
last_schedule_date = today
|
|
|
last_status_print = 0.0
|
|
|
|
|
|
# Re-read active sessions from DB so new/removed sessions are picked up
|
|
|
if not sessions: # only auto-refresh if not pinned via --session
|
|
|
fresh = _get_active_sessions(db)
|
|
|
if fresh != resolved:
|
|
|
added = set(fresh) - set(resolved)
|
|
|
removed = set(resolved) - set(fresh)
|
|
|
if added:
|
|
|
_log(f"Sessions added: [bold]{', '.join(sorted(added))}[/]")
|
|
|
if removed:
|
|
|
_log(f"Sessions removed: [bold]{', '.join(sorted(removed))}[/]")
|
|
|
resolved = fresh
|
|
|
|
|
|
_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()
|