"""Paper trading API endpoints — full session management.""" from __future__ import annotations import datetime as dt import os from pathlib import Path from typing import Any from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel from apps.web.dependencies import get_project_root from apps.web import paper_trading_service as svc router = APIRouter(prefix="/paper", tags=["paper_trading"]) # ── Helpers ─────────────────────────────────────────────────────────────────── def _get_db_path() -> str: db = os.environ.get("PAPER_TRADER_DB", "paper_trading.db") p = Path(db) if not p.is_absolute(): p = get_project_root() / db return str(p) def _get_state_manager(): from apps.paper_trader.state import StateManager return StateManager(_get_db_path()) def _get_broker(): from apps.paper_trader.alpaca_broker import AlpacaBroker return AlpacaBroker.from_env() def _session_summary(session, state) -> dict[str, Any]: """Build a JSON-serializable summary of a session.""" snapshots = state.list_snapshots(session.session_id) session_st = state.get_session_state(session.session_id) latest = snapshots[-1] if snapshots else None initial = session.initial_equity current = float(latest["equity"]) if latest else initial peak = state.get_peak_equity(session.session_id, initial) drawdown_pct = max(0.0, (peak - current) / peak * 100) if peak > 0 else 0.0 total_pnl = current - initial total_pnl_pct = total_pnl / initial * 100 if initial else 0.0 trades = state.list_trades(session.session_id) return { "session_id": session.session_id, "session_name": session.session_name, "config_path": session.config_path, "initial_equity": initial, "created_at": session.created_at, "status": session.status, "parking_preset": session.parking_preset, "idle_alpha_preset": session.idle_alpha_preset, "form4_sleeve_preset": session.form4_sleeve_preset, "current_equity": current, "peak_equity": peak, "total_pnl": total_pnl, "total_pnl_pct": total_pnl_pct, "drawdown_pct": drawdown_pct, "trade_count": len(trades), "latest_date": latest["date"] if latest else None, "kill_switch": bool(session_st.kill_switch_triggered), "cooldown_remaining": session_st.cooldown_remaining, "consecutive_losses": session_st.consecutive_losses, "daily_new_risk_used": session_st.daily_new_risk_used, "last_processed_date": session_st.last_processed_date, } def _auto_schedule_info() -> list[dict[str, Any]]: """Return schedule with countdown info (server-side computation).""" from zoneinfo import ZoneInfo TZ_ET = ZoneInfo("America/New_York") SCHEDULE = [ {"name": "pipeline_pre", "et_hour": 7, "et_min": 0, "label": "Pre-market pipeline"}, {"name": "run_open", "et_hour": 9, "et_min": 35, "label": "run-open (장 시작 직후)"}, {"name": "run_close", "et_hour": 15, "et_min": 45, "label": "run-close (장 마감 직전)"}, {"name": "pipeline_post", "et_hour": 16, "et_min": 30, "label": "Post-close pipeline"}, ] now_et = dt.datetime.now(tz=TZ_ET) today = now_et.date() result = [] for ev in SCHEDULE: et_dt = dt.datetime(today.year, today.month, today.day, ev["et_hour"], ev["et_min"], tzinfo=TZ_ET) wait_secs = (et_dt - now_et).total_seconds() result.append({ "name": ev["name"], "label": ev["label"], "et_time": et_dt.strftime("%I:%M %p ET"), "wait_secs": max(0.0, wait_secs), }) return result # ── Sessions ────────────────────────────────────────────────────────────────── @router.get("/sessions") def list_sessions() -> dict[str, Any]: state = _get_state_manager() sessions = state.list_sessions() return {"sessions": [_session_summary(s, state) for s in sessions]} class CreateSessionRequest(BaseModel): name: str config: str # experiment name, numeric ID, or config path capital: float = 10000.0 parking: str | None = None # optional parking preset name idle_alpha: str | None = None # optional idle alpha sleeve preset name form4_sleeve: str | None = None # optional Form 4 sleeve preset name @router.post("/sessions") def create_session(req: CreateSessionRequest) -> dict[str, Any]: from apps.paper_trader.cli import _resolve_config_path config_path = _resolve_config_path(req.config) abs_path = Path(config_path) if not abs_path.is_absolute(): abs_path = get_project_root() / config_path if not abs_path.exists(): raise HTTPException(status_code=400, detail=f"Config not found: {config_path}") config_path = str(config_path) # keep relative for storage # Validate parking preset if provided if req.parking: try: from libs.backtest.domain import PARKING_PRESETS if req.parking not in PARKING_PRESETS: raise HTTPException(status_code=400, detail=f"Unknown parking preset: {req.parking}") except ImportError: pass if req.idle_alpha: try: from libs.backtest.domain import IDLE_ALPHA_SLEEVE_PRESETS if req.idle_alpha not in IDLE_ALPHA_SLEEVE_PRESETS: raise HTTPException(status_code=400, detail=f"Unknown idle alpha preset: {req.idle_alpha}") except ImportError: pass if req.form4_sleeve: try: from libs.backtest.domain import FORM4_CAPTURE_SLEEVE_PRESETS if req.form4_sleeve not in FORM4_CAPTURE_SLEEVE_PRESETS: raise HTTPException(status_code=400, detail=f"Unknown Form 4 sleeve preset: {req.form4_sleeve}") except ImportError: pass state = _get_state_manager() if state.get_session(req.name) is not None: raise HTTPException(status_code=409, detail=f"Session '{req.name}' already exists") session_id = state.create_session( session_name=req.name, config_path=config_path, initial_equity=req.capital, parking_preset=req.parking or None, idle_alpha_preset=req.idle_alpha or None, form4_sleeve_preset=req.form4_sleeve or None, ) return { "session_id": session_id, "name": req.name, "capital": req.capital, "config": config_path, "parking": req.parking, "idle_alpha": req.idle_alpha, "form4_sleeve": req.form4_sleeve, } @router.get("/sessions/{session_id}") def get_session(session_id: str) -> dict[str, Any]: state = _get_state_manager() session = state.get_session(session_id) if session is None: raise HTTPException(status_code=404, detail=f"Session not found: {session_id}") return _session_summary(session, state) @router.post("/sessions/{session_id}/pause") def pause_session(session_id: str) -> dict[str, Any]: state = _get_state_manager() session = state.get_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") if session.status != "active": raise HTTPException(status_code=400, detail=f"Session is already {session.status}") state.set_session_status(session.session_id, "paused") return {"session_id": session_id, "status": "paused"} @router.post("/sessions/{session_id}/resume") def resume_session(session_id: str) -> dict[str, Any]: state = _get_state_manager() session = state.get_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") if session.status != "paused": raise HTTPException(status_code=400, detail=f"Session is {session.status}, not paused") state.set_session_status(session.session_id, "active") return {"session_id": session_id, "status": "active"} @router.delete("/sessions/{session_id}") def close_session(session_id: str) -> dict[str, Any]: """Liquidate all Alpaca positions and delete all session data.""" state = _get_state_manager() session = state.get_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") orders_closed = 0 broker_error: str | None = None try: broker = _get_broker() orders = broker.close_all_positions() orders_closed = len(orders) except Exception as exc: broker_error = str(exc) for ss in state.get_open_strategy_states(session.session_id): state.close_strategy_state(session.session_id, ss.symbol) state.delete_session(session.session_id) return { "deleted": True, "session_id": session_id, "positions_closed": orders_closed, "broker_error": broker_error, } # ── Data endpoints ───────────────────────────────────────────────────────────── @router.get("/sessions/{session_id}/positions") def get_positions(session_id: str) -> dict[str, Any]: state = _get_state_manager() session = state.get_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") strategy_states = { ss.symbol: ss for ss in state.get_open_strategy_states(session.session_id) } try: broker = _get_broker() positions = broker.list_positions() result = [] for p in sorted(positions, key=lambda x: x.symbol): ss = strategy_states.get(p.symbol) qty = float(p.qty) entry = float(p.avg_entry_price) if p.avg_entry_price else 0.0 pnl = float(p.unrealized_pl) pnl_pct = pnl / (entry * qty) * 100 if entry and qty else 0.0 result.append({ "symbol": p.symbol, "qty": qty, "avg_entry_price": entry, "current_price": float(p.current_price) if p.current_price else None, "unrealized_pl": pnl, "unrealized_pl_pct": pnl_pct, "days_held": ss.days_held if ss else None, "stop_price": ss.current_stop if ss else None, "target_price": ss.target_price if ss else None, "entry_date": ss.entry_date if ss else None, "engine_id": ss.engine_id if ss else None, "trade_direction": ss.trade_direction if ss else None, }) # Include strategy states missing from broker (ghost positions) broker_symbols = {p.symbol for p in positions} for sym, ss in strategy_states.items(): if sym not in broker_symbols: result.append({ "symbol": sym, "qty": None, "avg_entry_price": None, "current_price": None, "unrealized_pl": None, "unrealized_pl_pct": None, "days_held": ss.days_held, "stop_price": ss.current_stop, "target_price": ss.target_price, "entry_date": ss.entry_date, "engine_id": ss.engine_id, "trade_direction": ss.trade_direction, "_ghost": True, }) return {"positions": result, "broker_available": True} except Exception as exc: result = [] for sym, ss in sorted(strategy_states.items()): result.append({ "symbol": sym, "qty": None, "avg_entry_price": None, "current_price": None, "unrealized_pl": None, "unrealized_pl_pct": None, "days_held": ss.days_held, "stop_price": ss.current_stop, "target_price": ss.target_price, "entry_date": ss.entry_date, "engine_id": ss.engine_id, "trade_direction": ss.trade_direction, }) return {"positions": result, "broker_available": False, "broker_error": str(exc)} @router.get("/sessions/{session_id}/trades") def get_trades( session_id: str, last: int | None = Query(None, description="Show last N trades"), ) -> dict[str, Any]: state = _get_state_manager() session = state.get_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") import datetime trades = state.list_trades(session.session_id, limit=last) # Include parking entries (active + closed) as parking-sleeve trades parking_entries = state.list_parking_entries(session.session_id) today = datetime.date.today() for p in parking_entries: entry_date = p.get("entry_date", "") try: days = (today - datetime.date.fromisoformat(entry_date)).days except Exception: days = 0 is_active = p.get("status") == "active" trades.append({ "trade_id": f"parking_{session_id}_{entry_date}_{p['symbol']}", "session_id": session_id, "symbol": p["symbol"], "entry_date": entry_date, "exit_date": None if is_active else entry_date, "entry_price": p.get("avg_price"), "exit_price": None, "exit_reason": "active" if is_active else "closed", "shares": p.get("qty"), "net_pnl": None, "r_multiple": None, "holding_days": days, "engine_id": "cash_parking", "capital_bucket_id": "parking", }) return {"trades": trades, "total": len(trades)} @router.get("/sessions/{session_id}/equity") def get_equity(session_id: str) -> dict[str, Any]: state = _get_state_manager() session = state.get_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") snapshots = state.list_snapshots(session.session_id) return {"snapshots": snapshots, "initial_equity": session.initial_equity} # ── Run tasks (in-process) ──────────────────────────────────────────────────── class RunRequest(BaseModel): date: str | None = None force: bool = False @router.post("/sessions/{session_id}/run") async def run_daily(session_id: str, req: RunRequest = RunRequest()) -> dict[str, Any]: state = _get_state_manager() session = state.get_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") return await svc.run_task(session, "run", _get_db_path(), date=req.date, force=req.force) @router.post("/sessions/{session_id}/run-close") async def run_close(session_id: str, req: RunRequest = RunRequest()) -> dict[str, Any]: """장 마감 직전: same-day 이벤트 → MOC 매수.""" state = _get_state_manager() session = state.get_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") return await svc.run_task(session, "run-close", _get_db_path(), date=req.date, force=req.force) @router.post("/sessions/{session_id}/run-open") async def run_open(session_id: str, req: RunRequest = RunRequest()) -> dict[str, Any]: """장 시작 직후: 전날 exit + after-close 이벤트 → 시장가 매수.""" state = _get_state_manager() session = state.get_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") return await svc.run_task(session, "run-open", _get_db_path(), date=req.date, force=req.force) @router.post("/run-all") async def run_all_sessions() -> dict[str, Any]: """Run daily processing for all active sessions.""" state = _get_state_manager() active = [s for s in state.list_sessions() if s.status == "active"] if not active: return {"tasks": [], "message": "No active sessions found"} task = await svc.run_all_task(active, _get_db_path()) return {"tasks": [task]} # ── Task management ──────────────────────────────────────────────────────────── @router.get("/tasks") def list_tasks(session_name: str | None = Query(None)) -> dict[str, Any]: tasks = svc.list_tasks(session_name=session_name) # Don't send full log in list view slim = [{k: v for k, v in t.items() if k != "log"} for t in tasks] return {"tasks": slim} @router.get("/tasks/{task_id}") def get_task(task_id: str) -> dict[str, Any]: task = svc.get_task(task_id) if task is None: raise HTTPException(status_code=404, detail="Task not found") return dict(task) @router.get("/tasks/{task_id}/log") def get_task_log(task_id: str) -> dict[str, Any]: result = svc.get_task_log(task_id) if result is None: raise HTTPException(status_code=404, detail="Task not found") return result # ── Utility ──────────────────────────────────────────────────────────────────── @router.get("/configs") def list_configs( status: str | None = Query(None, description="Filter by status (active/retired)"), pattern: str | None = Query(None, description="Filter by name pattern"), limit: int = Query(200, le=1000), ) -> dict[str, Any]: """List available experiment configs for session creation.""" configs_dir = get_project_root() / "configs" / "experiments" if not configs_dir.exists(): return {"configs": [], "total": 0} import json index_path = configs_dir / ".index.json" configs = [] if index_path.exists(): try: index = json.loads(index_path.read_text()) for name, meta in sorted(index.get("experiments", {}).items(), key=lambda x: x[0]): cfg_path = configs_dir / f"{name}.json" if not cfg_path.exists(): continue s = meta.get("status", "active") if status and s != status: continue if pattern and pattern.lower() not in name.lower(): continue configs.append({ "name": name, "path": f"configs/experiments/{name}.json", "id": meta.get("id"), "status": s, "version_family": meta.get("version_family"), "generation": meta.get("generation"), "parent": meta.get("parent"), }) except Exception: pass if not configs: for f in sorted(configs_dir.glob("*.json")): if f.name.startswith("."): continue if pattern and pattern.lower() not in f.stem.lower(): continue configs.append({ "name": f.stem, "path": f"configs/experiments/{f.name}", "id": None, "status": "active", "version_family": None, "generation": None, "parent": None, }) total = len(configs) return {"configs": configs[:limit], "total": total} # ── Auto scheduler endpoints ────────────────────────────────────────────────── class AutoStartRequest(BaseModel): sessions: list[str] = [] # empty = all active dry_run: bool = False @router.get("/auto") def get_auto_status() -> dict[str, Any]: """Get auto scheduler status + today's schedule.""" scheduler = svc.auto_scheduler return { "running": scheduler.running, "pid": None, # in-process: no separate PID "source": "in_process" if scheduler.running else None, "schedule": _auto_schedule_info(), "log_tail": scheduler.get_log_tail(80), "log_lines": scheduler.log_line_count, } @router.post("/auto/start") async def start_auto(req: AutoStartRequest) -> dict[str, Any]: """Start the in-process auto scheduler. Returns 409 if already running.""" scheduler = svc.auto_scheduler if scheduler.running: raise HTTPException( status_code=409, detail="Auto scheduler already running. Stop it first.", ) scheduler.start(sessions=req.sessions, dry_run=req.dry_run, db_path=_get_db_path()) return {"started": True, "pid": None, "dry_run": req.dry_run, "sessions": req.sessions} @router.post("/auto/stop") async def stop_auto() -> dict[str, Any]: """Stop the in-process auto scheduler.""" scheduler = svc.auto_scheduler if not scheduler.running: raise HTTPException(status_code=409, detail="Auto scheduler is not running.") scheduler.stop() return {"stopped": True, "pid": None} @router.get("/auto/log") def get_auto_log(lines: int = Query(200, le=2000)) -> dict[str, Any]: """Get the last N lines of the auto scheduler log.""" scheduler = svc.auto_scheduler return { "log": scheduler.get_log(lines), "lines": scheduler.log_line_count, "source": "in_process", }