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.
fithia2/apps/web/paper_trading_service.py

664 lines
25 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 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
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)
return PaperTradingEngine(
session=session, broker=broker, state=state, event_detector=detector
)
# ── 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 # MonFri 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 on startup. Mirrors auto.py _run_catchup()."""
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 not catchup_items:
return
self._log("━━━ Catch-up: 놓친 파이프라인 실행 ━━━")
for label, cmds in catchup_items:
self._log(f"{label}")
await self._run_pipeline(cmds)
self._log("━━━ Catch-up 완료 ━━━")
# ── 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
if last_schedule_date != today:
self._completed.clear()
last_schedule_date = today
self._log(f"━━━ {today.strftime('%a %Y-%m-%d')} ━━━")
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()