You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

482 lines
18 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""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 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: MonFri 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
_run_catchup(_now_et(), dry_run)
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()