|
|
"""Paper trading in-process service layer.
|
|
|
|
|
|
Replaces subprocess-based task execution (paper_trading.py router).
|
|
|
Provides:
|
|
|
- make_engine(): Engine factory (mirrors CLI _make_engine)
|
|
|
- run_task(): Async task runner for engine operations
|
|
|
- run_all_task(): Run daily for all active sessions
|
|
|
- task registry: dict backed by asyncio.Lock
|
|
|
- AutoScheduler: asyncio-based scheduler (mirrors auto.py)
|
|
|
"""
|
|
|
from __future__ import annotations
|
|
|
|
|
|
import asyncio
|
|
|
import datetime as dt
|
|
|
import json
|
|
|
import os
|
|
|
import sys
|
|
|
import traceback
|
|
|
import uuid
|
|
|
from datetime import datetime, timezone
|
|
|
from pathlib import Path
|
|
|
from typing import Any
|
|
|
from zoneinfo import ZoneInfo
|
|
|
|
|
|
# ── Shared task registry ──────────────────────────────────────────────────────
|
|
|
|
|
|
_tasks: dict[str, dict[str, Any]] = {}
|
|
|
_tasks_lock = asyncio.Lock()
|
|
|
|
|
|
# Per-session locks prevent concurrent runs on the same session
|
|
|
_session_locks: dict[str, asyncio.Lock] = {}
|
|
|
|
|
|
|
|
|
def _get_session_lock(session_name: str) -> asyncio.Lock:
|
|
|
if session_name not in _session_locks:
|
|
|
_session_locks[session_name] = asyncio.Lock()
|
|
|
return _session_locks[session_name]
|
|
|
|
|
|
|
|
|
# ── Engine factory ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def make_engine(session: Any, db_path: str) -> Any:
|
|
|
"""Create a PaperTradingEngine for the given session.
|
|
|
|
|
|
Mirrors CLI _make_engine(). Reads credentials from env vars.
|
|
|
"""
|
|
|
from apps.paper_trader.alpaca_broker import AlpacaBroker
|
|
|
from apps.paper_trader.engine import PaperTradingEngine
|
|
|
from apps.paper_trader.event_detector import EventDetector
|
|
|
from apps.paper_trader.state import StateManager
|
|
|
from apps.backtester.run import load_manifest, resolve_config
|
|
|
from apps.paper_trader.backtest_sim import load_snapshot_store_for_session
|
|
|
|
|
|
broker = AlpacaBroker.from_env()
|
|
|
oracle_url = (
|
|
|
os.environ.get("ORACLE_URL")
|
|
|
or os.environ.get("STOCK_ORACLE_URL", "http://localhost:8000")
|
|
|
)
|
|
|
db_dsn = os.environ.get("DB_DSN") or os.environ.get("POSTGRES_DSN", "")
|
|
|
state = StateManager(db_path)
|
|
|
detector = EventDetector(db_dsn=db_dsn, oracle_url=oracle_url)
|
|
|
manifest = load_manifest(session.config_path)
|
|
|
config = resolve_config(manifest)
|
|
|
snapshot_store = load_snapshot_store_for_session(config, oracle_url, db_dsn)
|
|
|
return PaperTradingEngine(
|
|
|
session=session, broker=broker, state=state, event_detector=detector,
|
|
|
snapshot_store=snapshot_store,
|
|
|
)
|
|
|
|
|
|
|
|
|
# ── Log helpers ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _fmt_header(operation: str, session_name: str) -> str:
|
|
|
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
|
return f"[{ts}] {operation} — session: {session_name}\n{'─' * 60}\n"
|
|
|
|
|
|
|
|
|
def _fmt_summary(summary: dict[str, Any]) -> str:
|
|
|
"""Format engine return dict into human-readable text."""
|
|
|
if not summary:
|
|
|
return "[No summary returned]\n"
|
|
|
lines: list[str] = []
|
|
|
for key, value in summary.items():
|
|
|
if value is None:
|
|
|
continue
|
|
|
if isinstance(value, (list, dict)) and not value:
|
|
|
continue
|
|
|
if key == "reconciliation":
|
|
|
# ReconciliationReport dataclass
|
|
|
for attr in ("orphaned_alpaca", "ghost_local", "reconciled_exits", "stale_orders_cancelled"):
|
|
|
v = getattr(value, attr, None)
|
|
|
if v:
|
|
|
lines.append(f" {attr}: {v}")
|
|
|
continue
|
|
|
if isinstance(value, dt.date):
|
|
|
lines.append(f" {key}: {value.isoformat()}")
|
|
|
else:
|
|
|
lines.append(f" {key}: {value}")
|
|
|
return "\n".join(lines) + "\n" if lines else "[done]\n"
|
|
|
|
|
|
|
|
|
# ── In-process task runner ────────────────────────────────────────────────────
|
|
|
|
|
|
async def run_task(
|
|
|
session: Any,
|
|
|
operation: str,
|
|
|
db_path: str,
|
|
|
date: str | None = None,
|
|
|
force: bool = False,
|
|
|
) -> dict[str, Any]:
|
|
|
"""Create a task and launch the engine operation in-process.
|
|
|
|
|
|
Returns the initial task dict immediately; the engine runs as an
|
|
|
asyncio background task.
|
|
|
"""
|
|
|
task_id = str(uuid.uuid4())[:8]
|
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
|
task: dict[str, Any] = {
|
|
|
"task_id": task_id,
|
|
|
"session_name": session.session_name,
|
|
|
"operation": operation,
|
|
|
"status": "running",
|
|
|
"created_at": now,
|
|
|
"started_at": now,
|
|
|
"finished_at": None,
|
|
|
"log": _fmt_header(operation, session.session_name),
|
|
|
"error": None,
|
|
|
}
|
|
|
async with _tasks_lock:
|
|
|
_tasks[task_id] = task
|
|
|
|
|
|
asyncio.create_task(
|
|
|
_execute_engine_task(task_id, session, operation, db_path, date, force)
|
|
|
)
|
|
|
return dict(task)
|
|
|
|
|
|
|
|
|
async def run_all_task(sessions: list[Any], db_path: str) -> dict[str, Any]:
|
|
|
"""Run run-daily for every given session as a single task."""
|
|
|
task_id = str(uuid.uuid4())[:8]
|
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
|
now_short = now[:19]
|
|
|
task: dict[str, Any] = {
|
|
|
"task_id": task_id,
|
|
|
"session_name": "all",
|
|
|
"operation": "run-all",
|
|
|
"status": "running",
|
|
|
"created_at": now,
|
|
|
"started_at": now,
|
|
|
"finished_at": None,
|
|
|
"log": f"[{now_short} UTC] run-all — {len(sessions)} session(s)\n{'─' * 60}\n",
|
|
|
"error": None,
|
|
|
}
|
|
|
async with _tasks_lock:
|
|
|
_tasks[task_id] = task
|
|
|
|
|
|
asyncio.create_task(_execute_run_all_task(task_id, sessions, db_path))
|
|
|
return dict(task)
|
|
|
|
|
|
|
|
|
async def _execute_engine_task(
|
|
|
task_id: str,
|
|
|
session: Any,
|
|
|
operation: str,
|
|
|
db_path: str,
|
|
|
date: str | None,
|
|
|
force: bool,
|
|
|
) -> None:
|
|
|
"""Background coroutine: acquire session lock, run engine, update task."""
|
|
|
target_date = dt.date.fromisoformat(date) if date else None
|
|
|
|
|
|
async with _get_session_lock(session.session_name):
|
|
|
extra_log, error = await asyncio.to_thread(
|
|
|
_run_engine_sync, session, operation, db_path, target_date, force
|
|
|
)
|
|
|
|
|
|
finished = datetime.now(timezone.utc).isoformat()
|
|
|
async with _tasks_lock:
|
|
|
t = _tasks.get(task_id)
|
|
|
if t:
|
|
|
t["log"] = t.get("log", "") + extra_log
|
|
|
t["finished_at"] = finished
|
|
|
t["status"] = "failed" if error else "completed"
|
|
|
t["error"] = error
|
|
|
|
|
|
|
|
|
def _run_engine_sync(
|
|
|
session: Any,
|
|
|
operation: str,
|
|
|
db_path: str,
|
|
|
target_date: dt.date | None,
|
|
|
force: bool,
|
|
|
) -> tuple[str, str | None]:
|
|
|
"""Run the engine in a thread with its own event loop.
|
|
|
|
|
|
Returns (log_text, error_message_or_None).
|
|
|
"""
|
|
|
loop = asyncio.new_event_loop()
|
|
|
asyncio.set_event_loop(loop)
|
|
|
try:
|
|
|
engine = make_engine(session, db_path)
|
|
|
if operation == "run":
|
|
|
coro = engine.run_daily(target_date=target_date, force=force)
|
|
|
elif operation == "run-close":
|
|
|
coro = engine.run_reaction_close(target_date=target_date, force=force)
|
|
|
elif operation == "run-open":
|
|
|
coro = engine.run_next_open(target_date=target_date, force=force)
|
|
|
else:
|
|
|
return f"Unknown operation: {operation}\n", f"Unknown operation: {operation}"
|
|
|
summary = loop.run_until_complete(coro)
|
|
|
return _fmt_summary(summary), None
|
|
|
except Exception as exc:
|
|
|
tb = traceback.format_exc()
|
|
|
return f"\nERROR: {type(exc).__name__}: {exc}\n{tb}\n", str(exc)
|
|
|
finally:
|
|
|
loop.close()
|
|
|
|
|
|
|
|
|
async def _execute_run_all_task(
|
|
|
task_id: str,
|
|
|
sessions: list[Any],
|
|
|
db_path: str,
|
|
|
) -> None:
|
|
|
"""Background coroutine: run daily for each session sequentially."""
|
|
|
any_error: str | None = None
|
|
|
|
|
|
for session in sessions:
|
|
|
header = f"\n▶ {session.session_name}\n"
|
|
|
|
|
|
async with _tasks_lock:
|
|
|
t = _tasks.get(task_id)
|
|
|
if t:
|
|
|
t["log"] = t.get("log", "") + header
|
|
|
|
|
|
extra_log, error = await asyncio.to_thread(
|
|
|
_run_engine_sync, session, "run", db_path, None, False
|
|
|
)
|
|
|
if error:
|
|
|
any_error = error
|
|
|
|
|
|
async with _tasks_lock:
|
|
|
t = _tasks.get(task_id)
|
|
|
if t:
|
|
|
t["log"] = t.get("log", "") + extra_log
|
|
|
|
|
|
finished = datetime.now(timezone.utc).isoformat()
|
|
|
async with _tasks_lock:
|
|
|
t = _tasks.get(task_id)
|
|
|
if t:
|
|
|
t["finished_at"] = finished
|
|
|
t["status"] = "failed" if any_error else "completed"
|
|
|
t["error"] = any_error
|
|
|
|
|
|
|
|
|
# ── Task registry accessors ───────────────────────────────────────────────────
|
|
|
|
|
|
def get_task(task_id: str) -> dict[str, Any] | None:
|
|
|
return _tasks.get(task_id)
|
|
|
|
|
|
|
|
|
def get_task_log(task_id: str) -> dict[str, Any] | None:
|
|
|
t = _tasks.get(task_id)
|
|
|
if t is None:
|
|
|
return None
|
|
|
return {"log": t.get("log", ""), "status": t["status"]}
|
|
|
|
|
|
|
|
|
def list_tasks(session_name: str | None = None) -> list[dict[str, Any]]:
|
|
|
tasks = list(_tasks.values())
|
|
|
if session_name:
|
|
|
tasks = [t for t in tasks if t.get("session_name") == session_name]
|
|
|
tasks.sort(key=lambda t: t.get("created_at", ""), reverse=True)
|
|
|
return tasks
|
|
|
|
|
|
|
|
|
# ── AutoScheduler ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
_TZ_ET = ZoneInfo("America/New_York")
|
|
|
|
|
|
_SCHEDULE: list[dict[str, Any]] = [
|
|
|
{"name": "pipeline_pre", "et_hour": 7, "et_min": 0, "kind": "pipeline_pre",
|
|
|
"label": "Pre-market pipeline"},
|
|
|
{"name": "run_open", "et_hour": 9, "et_min": 35, "kind": "run_open",
|
|
|
"label": "run-open (장 시작 직후)"},
|
|
|
{"name": "run_close", "et_hour": 15, "et_min": 45, "kind": "run_close",
|
|
|
"label": "run-close (장 마감 직전)"},
|
|
|
{"name": "pipeline_post", "et_hour": 16, "et_min": 30, "kind": "pipeline_post",
|
|
|
"label": "Post-close pipeline"},
|
|
|
]
|
|
|
|
|
|
_PIPELINE_CMDS: list[list[str]] = [
|
|
|
[sys.executable, "-m", "apps.pipeline.filing_poller.main"],
|
|
|
[sys.executable, "-m", "apps.pipeline.filing_fetcher.main"],
|
|
|
[sys.executable, "-m", "apps.pipeline.event_parser.main"],
|
|
|
[sys.executable, "-m", "apps.pipeline.feature_builder.main"],
|
|
|
[sys.executable, "-m", "apps.pipeline.label_generator.main"],
|
|
|
[sys.executable, "-m", "apps.pipeline.label_generator.main",
|
|
|
"--entry-convention", "reaction_close"],
|
|
|
]
|
|
|
|
|
|
_POST_PIPELINE_CMDS: list[list[str]] = [
|
|
|
[sys.executable, "-m", "apps.pipeline.filing_poller.main"],
|
|
|
[sys.executable, "-m", "apps.pipeline.filing_fetcher.main"],
|
|
|
[sys.executable, "-m", "apps.pipeline.event_parser.main"],
|
|
|
[sys.executable, "-m", "apps.pipeline.feature_builder.main"],
|
|
|
[sys.executable, "-m", "apps.pipeline.label_generator.main",
|
|
|
"--entry-convention", "reaction_close"],
|
|
|
[sys.executable, "-m", "apps.pipeline.label_generator.main"],
|
|
|
]
|
|
|
|
|
|
|
|
|
def _state_file_path(db_path: str) -> Path:
|
|
|
"""Return path to the auto scheduler persistence file."""
|
|
|
return Path(db_path).parent / ".paper_auto_state.json"
|
|
|
|
|
|
|
|
|
def load_saved_state(db_path: str) -> "dict[str, Any] | None":
|
|
|
"""Load previously persisted scheduler state. Returns None if absent/invalid."""
|
|
|
sf = _state_file_path(db_path)
|
|
|
if sf.exists():
|
|
|
try:
|
|
|
return json.loads(sf.read_text())
|
|
|
except Exception:
|
|
|
pass
|
|
|
return None
|
|
|
|
|
|
|
|
|
class AutoScheduler:
|
|
|
"""In-process auto scheduler. Runs inside FastAPI; no separate daemon process needed.
|
|
|
|
|
|
Pipeline steps remain subprocesses (via asyncio.create_subprocess_exec).
|
|
|
Trading operations use PaperTradingEngine in-process.
|
|
|
State is persisted to disk so FastAPI restart can auto-resume.
|
|
|
"""
|
|
|
|
|
|
def __init__(self) -> None:
|
|
|
self._task: asyncio.Task | None = None # type: ignore[type-arg]
|
|
|
self._sessions: list[str] = []
|
|
|
self._dry_run: bool = False
|
|
|
self._db_path: str = ""
|
|
|
self._log_lines: list[str] = []
|
|
|
self._completed: set[str] = set()
|
|
|
|
|
|
# ── Public interface ───────────────────────────────────────────────────────
|
|
|
|
|
|
@property
|
|
|
def running(self) -> bool:
|
|
|
return self._task is not None and not self._task.done()
|
|
|
|
|
|
def start(self, sessions: list[str], dry_run: bool, db_path: str) -> None:
|
|
|
if self.running:
|
|
|
raise RuntimeError("AutoScheduler already running")
|
|
|
self._sessions = sessions
|
|
|
self._dry_run = dry_run
|
|
|
self._db_path = db_path
|
|
|
self._log_lines = []
|
|
|
self._completed = set()
|
|
|
self._save_state()
|
|
|
self._task = asyncio.create_task(self._run_loop())
|
|
|
|
|
|
def stop(self) -> None:
|
|
|
"""User-initiated stop. Cancels task and clears persisted state."""
|
|
|
if self._task and not self._task.done():
|
|
|
self._task.cancel()
|
|
|
self._clear_state()
|
|
|
|
|
|
def shutdown(self) -> None:
|
|
|
"""Server shutdown stop. Cancels task but keeps persisted state for auto-restart."""
|
|
|
if self._task and not self._task.done():
|
|
|
self._task.cancel()
|
|
|
|
|
|
def _save_state(self) -> None:
|
|
|
if not self._db_path:
|
|
|
return
|
|
|
try:
|
|
|
_state_file_path(self._db_path).write_text(json.dumps({
|
|
|
"running": True,
|
|
|
"sessions": self._sessions,
|
|
|
"dry_run": self._dry_run,
|
|
|
"db_path": self._db_path,
|
|
|
}))
|
|
|
except Exception:
|
|
|
pass
|
|
|
|
|
|
def _clear_state(self) -> None:
|
|
|
if not self._db_path:
|
|
|
return
|
|
|
try:
|
|
|
sf = _state_file_path(self._db_path)
|
|
|
if sf.exists():
|
|
|
sf.unlink()
|
|
|
except Exception:
|
|
|
pass
|
|
|
|
|
|
def get_log(self, lines: int = 200) -> str:
|
|
|
return "\n".join(self._log_lines[-lines:])
|
|
|
|
|
|
def get_log_tail(self, lines: int = 80) -> list[str]:
|
|
|
return self._log_lines[-lines:]
|
|
|
|
|
|
@property
|
|
|
def log_line_count(self) -> int:
|
|
|
return len(self._log_lines)
|
|
|
|
|
|
# ── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _log(self, msg: str) -> None:
|
|
|
ts = datetime.now(timezone.utc).strftime("%H:%M UTC")
|
|
|
self._log_lines.append(f"{ts} {msg}")
|
|
|
|
|
|
def _now_et(self) -> dt.datetime:
|
|
|
return dt.datetime.now(tz=_TZ_ET)
|
|
|
|
|
|
def _et_dt_for(self, date: dt.date, ev: dict[str, Any]) -> dt.datetime:
|
|
|
return dt.datetime(
|
|
|
date.year, date.month, date.day,
|
|
|
ev["et_hour"], ev["et_min"], tzinfo=_TZ_ET,
|
|
|
)
|
|
|
|
|
|
def _is_trading_day(self, date: dt.date) -> bool:
|
|
|
try:
|
|
|
from libs.common.time_utils import is_trading_day
|
|
|
return is_trading_day(date)
|
|
|
except Exception:
|
|
|
return date.weekday() < 5 # Mon–Fri fallback
|
|
|
|
|
|
def _next_trading_day(self, from_date: dt.date) -> dt.date:
|
|
|
check = from_date + dt.timedelta(days=1)
|
|
|
for _ in range(14):
|
|
|
if self._is_trading_day(check):
|
|
|
return check
|
|
|
check += dt.timedelta(days=1)
|
|
|
raise RuntimeError("No trading day found within 14 days")
|
|
|
|
|
|
def _prev_trading_day(self, from_date: dt.date) -> dt.date:
|
|
|
check = from_date - dt.timedelta(days=1)
|
|
|
for _ in range(14):
|
|
|
if self._is_trading_day(check):
|
|
|
return check
|
|
|
check -= dt.timedelta(days=1)
|
|
|
raise RuntimeError("No previous trading day within 14 days")
|
|
|
|
|
|
def _get_active_sessions(self) -> list[str]:
|
|
|
try:
|
|
|
from apps.paper_trader.state import StateManager
|
|
|
return [
|
|
|
s.session_name
|
|
|
for s in StateManager(self._db_path).list_sessions()
|
|
|
if s.status == "active"
|
|
|
]
|
|
|
except Exception:
|
|
|
return []
|
|
|
|
|
|
@staticmethod
|
|
|
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"
|
|
|
|
|
|
# ── Pipeline execution (subprocess) ───────────────────────────────────────
|
|
|
|
|
|
async def _run_pipeline(self, cmds: list[list[str]]) -> None:
|
|
|
project_root = str(Path(__file__).parent.parent.parent)
|
|
|
for cmd in cmds:
|
|
|
label = " ".join(cmd[2:] if cmd[:2] == [sys.executable, "-m"] else cmd)
|
|
|
if self._dry_run:
|
|
|
self._log(f"[DRY] {label}")
|
|
|
continue
|
|
|
self._log(f" ▶ {label}")
|
|
|
try:
|
|
|
proc = await asyncio.create_subprocess_exec(
|
|
|
*cmd,
|
|
|
stdout=asyncio.subprocess.PIPE,
|
|
|
stderr=asyncio.subprocess.STDOUT,
|
|
|
cwd=project_root,
|
|
|
)
|
|
|
stdout, _ = await proc.communicate()
|
|
|
ok = proc.returncode == 0
|
|
|
self._log(f" {'OK' if ok else 'FAILED'} (exit {proc.returncode})")
|
|
|
# Surface error-level log entries from pipeline JSON output
|
|
|
for line in (stdout or b"").decode("utf-8", errors="replace").splitlines():
|
|
|
line = line.strip()
|
|
|
if not line:
|
|
|
continue
|
|
|
try:
|
|
|
rec = json.loads(line)
|
|
|
if rec.get("level") in ("error", "critical"):
|
|
|
self._log(f" ERROR: {rec.get('event', '')} {rec}")
|
|
|
except Exception:
|
|
|
pass
|
|
|
except Exception as exc:
|
|
|
self._log(f" ERROR running {label}: {exc}")
|
|
|
|
|
|
# ── Trading execution (in-process) ────────────────────────────────────────
|
|
|
|
|
|
async def _run_trading(self, operation: str, sessions: list[str]) -> None:
|
|
|
from apps.paper_trader.state import StateManager
|
|
|
|
|
|
state_mgr = StateManager(self._db_path)
|
|
|
for session_name in sessions:
|
|
|
session = state_mgr.get_session(session_name)
|
|
|
if session is None or session.status != "active":
|
|
|
self._log(f" {session_name}: skipped (not active)")
|
|
|
continue
|
|
|
if self._dry_run:
|
|
|
self._log(f" [DRY] {operation} — {session_name}")
|
|
|
continue
|
|
|
self._log(f" ▶ {operation} — {session_name}")
|
|
|
try:
|
|
|
def _run_sync(s: Any = session) -> dict[str, Any]:
|
|
|
loop = asyncio.new_event_loop()
|
|
|
asyncio.set_event_loop(loop)
|
|
|
try:
|
|
|
engine = make_engine(s, self._db_path)
|
|
|
if operation == "run-open":
|
|
|
coro = engine.run_next_open()
|
|
|
elif operation == "run-close":
|
|
|
coro = engine.run_reaction_close()
|
|
|
else:
|
|
|
raise ValueError(f"Unknown operation: {operation}")
|
|
|
return loop.run_until_complete(coro)
|
|
|
finally:
|
|
|
loop.close()
|
|
|
|
|
|
summary = await asyncio.to_thread(_run_sync)
|
|
|
status = summary.get("status", "?")
|
|
|
self._log(f" ✓ {session_name}: {status}")
|
|
|
except Exception as exc:
|
|
|
self._log(f" ERROR {session_name}: {type(exc).__name__}: {exc}")
|
|
|
|
|
|
# ── Catch-up on startup ───────────────────────────────────────────────────
|
|
|
|
|
|
async def _run_catchup(self) -> None:
|
|
|
"""Run missed pipeline steps and paper trading phases on startup."""
|
|
|
now_et = self._now_et()
|
|
|
today = now_et.date()
|
|
|
catchup_items: list[tuple[str, list[list[str]]]] = []
|
|
|
|
|
|
prev_td = self._prev_trading_day(today)
|
|
|
prev_post_close_et = self._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})", _POST_PIPELINE_CMDS))
|
|
|
|
|
|
if self._is_trading_day(today):
|
|
|
pre_market_et = self._et_dt_for(today, _SCHEDULE[0]) # pipeline_pre 07:00
|
|
|
if pre_market_et < now_et:
|
|
|
catchup_items.append((f"Pre-market pipeline ({today})", _PIPELINE_CMDS))
|
|
|
|
|
|
if catchup_items:
|
|
|
self._log("━━━ Catch-up: 놓친 파이프라인 실행 ━━━")
|
|
|
for label, cmds in catchup_items:
|
|
|
self._log(f"▶ {label}")
|
|
|
await self._run_pipeline(cmds)
|
|
|
self._log("━━━ Catch-up 완료 ━━━")
|
|
|
|
|
|
# Paper trading phase catchup: if run_open was missed but market is still open, run now.
|
|
|
# This fires when the server starts after 9:35 AM ET — the scheduler would otherwise
|
|
|
# silently skip run_open because its scheduled time has already passed.
|
|
|
if self._is_trading_day(today) and not self._dry_run:
|
|
|
run_open_et = self._et_dt_for(today, _SCHEDULE[1]) # run_open 9:35
|
|
|
market_close_et = now_et.replace(hour=16, minute=0, second=0, microsecond=0)
|
|
|
if run_open_et <= now_et < market_close_et:
|
|
|
sessions = self._sessions or self._get_active_sessions()
|
|
|
if sessions and self._db_path:
|
|
|
from apps.paper_trader.state import StateManager
|
|
|
state_mgr = StateManager(self._db_path)
|
|
|
already_ran = any(
|
|
|
(sess := state_mgr.get_session(s)) is not None
|
|
|
and state_mgr.is_phase_processed(sess.session_id, today, "next_open")
|
|
|
for s in sessions
|
|
|
)
|
|
|
if not already_ran:
|
|
|
self._log("━━━ Catch-up: run_open 누락 (9:35 ET 이후 시작) — 지금 실행 ━━━")
|
|
|
await self._run_trading("run-open", sessions)
|
|
|
self._completed.add("run_open")
|
|
|
self._log("━━━ Catch-up run_open 완료 ━━━")
|
|
|
|
|
|
# ── Main scheduler loop ───────────────────────────────────────────────────
|
|
|
|
|
|
async def _run_loop(self) -> None:
|
|
|
"""Main scheduler loop. Mirrors auto.py run_auto()."""
|
|
|
resolved = self._sessions or self._get_active_sessions()
|
|
|
if not resolved:
|
|
|
self._log("No active sessions found. Stopping.")
|
|
|
return
|
|
|
|
|
|
self._log(f"Auto scheduler started — sessions: {', '.join(resolved)}")
|
|
|
if self._dry_run:
|
|
|
self._log("DRY RUN — commands will not execute")
|
|
|
|
|
|
try:
|
|
|
await self._run_catchup()
|
|
|
except Exception as exc:
|
|
|
self._log(f"Catch-up error: {exc}")
|
|
|
|
|
|
last_schedule_date: dt.date | None = None
|
|
|
|
|
|
try:
|
|
|
while True:
|
|
|
now_et = self._now_et()
|
|
|
today = now_et.date()
|
|
|
|
|
|
# New day → reset completed set and refresh session list
|
|
|
if last_schedule_date != today:
|
|
|
self._completed.clear()
|
|
|
last_schedule_date = today
|
|
|
|
|
|
# Re-read active sessions from DB so new sessions are picked up
|
|
|
# and removed/closed sessions are dropped automatically.
|
|
|
if not self._sessions: # only auto-refresh if not pinned via --session
|
|
|
fresh = self._get_active_sessions()
|
|
|
if fresh != resolved:
|
|
|
added = set(fresh) - set(resolved)
|
|
|
removed = set(resolved) - set(fresh)
|
|
|
if added:
|
|
|
self._log(f"Sessions added: {', '.join(sorted(added))}")
|
|
|
if removed:
|
|
|
self._log(f"Sessions removed: {', '.join(sorted(removed))}")
|
|
|
resolved = fresh
|
|
|
if not resolved:
|
|
|
self._log("No active sessions. Waiting for next day.")
|
|
|
|
|
|
self._log(f"━━━ {today.strftime('%a %Y-%m-%d')} — sessions: {', '.join(resolved) or 'none'} ━━━")
|
|
|
|
|
|
if not self._is_trading_day(today):
|
|
|
next_td = self._next_trading_day(today)
|
|
|
self._log(f"Non-trading day. Next trading day: {next_td}")
|
|
|
else:
|
|
|
# Mark already-past events as skipped
|
|
|
for ev in _SCHEDULE:
|
|
|
if self._et_dt_for(today, ev) <= now_et:
|
|
|
self._completed.add(ev["name"])
|
|
|
self._log(f"Skipping past event: {ev['label']}")
|
|
|
|
|
|
if not self._is_trading_day(today):
|
|
|
await asyncio.sleep(1800)
|
|
|
continue
|
|
|
|
|
|
pending = [ev for ev in _SCHEDULE if ev["name"] not in self._completed]
|
|
|
if not pending:
|
|
|
next_td = self._next_trading_day(today)
|
|
|
first = _SCHEDULE[0]
|
|
|
wake_et = self._et_dt_for(next_td, first)
|
|
|
wait = (wake_et - now_et).total_seconds()
|
|
|
self._log(
|
|
|
f"All done today. Sleeping until "
|
|
|
f"{wake_et.strftime('%I:%M %p ET')} on {next_td} "
|
|
|
f"— {self._fmt_countdown(wait)}"
|
|
|
)
|
|
|
await asyncio.sleep(min(wait, 3600))
|
|
|
continue
|
|
|
|
|
|
next_ev = pending[0]
|
|
|
next_et = self._et_dt_for(today, next_ev)
|
|
|
wait = (next_et - now_et).total_seconds()
|
|
|
|
|
|
if wait > 90:
|
|
|
self._log(
|
|
|
f"Next: {next_ev['label']} at "
|
|
|
f"{next_et.strftime('%I:%M %p ET')} "
|
|
|
f"— {self._fmt_countdown(wait)}"
|
|
|
)
|
|
|
# Re-check every 10 min at most
|
|
|
await asyncio.sleep(min(wait - 60, 600))
|
|
|
continue
|
|
|
|
|
|
if wait > 0:
|
|
|
self._log(
|
|
|
f"Firing in {self._fmt_countdown(wait)}: {next_ev['label']}"
|
|
|
)
|
|
|
await asyncio.sleep(wait)
|
|
|
|
|
|
# ── Execute event ──────────────────────────────────────────
|
|
|
self._log(f"▶ {next_ev['label']}")
|
|
|
try:
|
|
|
kind = next_ev["kind"]
|
|
|
if kind == "pipeline_pre":
|
|
|
await self._run_pipeline(_PIPELINE_CMDS)
|
|
|
elif kind == "run_open":
|
|
|
await self._run_trading("run-open", resolved)
|
|
|
elif kind == "run_close":
|
|
|
await self._run_trading("run-close", resolved)
|
|
|
elif kind == "pipeline_post":
|
|
|
await self._run_pipeline(_POST_PIPELINE_CMDS)
|
|
|
except Exception as exc:
|
|
|
self._log(f"ERROR executing {next_ev['name']}: {exc}")
|
|
|
|
|
|
self._completed.add(next_ev["name"])
|
|
|
self._log(f"✓ Done: {next_ev['label']}")
|
|
|
|
|
|
except asyncio.CancelledError:
|
|
|
self._log("Auto scheduler stopped.")
|
|
|
raise
|
|
|
|
|
|
|
|
|
# ── Module-level singleton ────────────────────────────────────────────────────
|
|
|
|
|
|
auto_scheduler = AutoScheduler()
|