"""ORB Paper Trading API endpoints.""" from __future__ import annotations import datetime as dt import os from typing import Any from fastapi import APIRouter, HTTPException from pydantic import BaseModel router = APIRouter(prefix="/orb", tags=["orb-trading"]) # --------------------------------------------------------------------------- # DB path resolution # --------------------------------------------------------------------------- def _db_path() -> str: from pathlib import Path project_root = Path(__file__).parent.parent.parent.parent env_path = os.environ.get("ORB_TRADER_DB", "data/paper/orb.db") path = Path(env_path) if Path(env_path).is_absolute() else project_root / env_path path.parent.mkdir(parents=True, exist_ok=True) return str(path) def _state() -> Any: from apps.orb_trader.state import ORBStateManager return ORBStateManager(_db_path()) # --------------------------------------------------------------------------- # Request/Response models # --------------------------------------------------------------------------- class CreateSessionRequest(BaseModel): name: str config: str # config path (relative to project root) capital: float = 10000.0 class AutoStartRequest(BaseModel): sessions: list[str] = [] dry_run: bool = False # --------------------------------------------------------------------------- # Sessions # --------------------------------------------------------------------------- @router.get("/sessions") def list_sessions() -> dict[str, Any]: state = _state() sessions = state.list_sessions() today = dt.date.today().isoformat() results = [] for s in sessions: daily = state.get_daily_state(s.session_id, today) # phase starts as "idle" (default); engine sets it to "orb_detection" # or beyond when it actually runs today ran_today = daily.phase not in ("idle", "", None) results.append({ "session_id": s.session_id, "session_name": s.session_name, "config_path": s.config_path, "initial_equity": s.initial_equity, "created_at": s.created_at, "status": s.status, "ran_today": ran_today, }) return {"sessions": results} @router.post("/sessions") def create_session(req: CreateSessionRequest) -> dict[str, Any]: from pathlib import Path import yaml project_root = Path(__file__).parent.parent.parent.parent config_path = req.config if Path(req.config).is_absolute() else str(project_root / req.config) if not Path(config_path).exists(): raise HTTPException(status_code=404, detail=f"Config not found: {config_path}") # Validate it's an ORB strategy config try: with open(config_path) as f: raw = yaml.safe_load(f) if raw.get("strategy_mode") != "orb": raise HTTPException( status_code=400, detail=f"Config strategy_mode must be 'orb', got '{raw.get('strategy_mode')}'" ) except HTTPException: raise except Exception as e: raise HTTPException(status_code=400, detail=f"Invalid config: {e}") state = _state() # Check duplicate name 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(req.name, config_path, req.capital) return { "session_id": session_id, "session_name": req.name, "config_path": config_path, "initial_equity": req.capital, } @router.get("/sessions/{session_id}") def get_session(session_id: str) -> dict[str, Any]: session = _state().get_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") equity = _state().get_equity(session.session_id) or session.initial_equity return { "session_id": session.session_id, "session_name": session.session_name, "config_path": session.config_path, "initial_equity": session.initial_equity, "current_equity": equity, "total_return_pct": (equity - session.initial_equity) / session.initial_equity * 100, "created_at": session.created_at, "status": session.status, } @router.post("/sessions/{session_id}/pause") def pause_session(session_id: str) -> dict[str, Any]: state = _state() session = state.get_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") state.set_session_status(session.session_id, "paused") return {"session_id": session.session_id, "status": "paused"} @router.post("/sessions/{session_id}/resume") def resume_session(session_id: str) -> dict[str, Any]: state = _state() session = state.get_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") state.set_session_status(session.session_id, "active") return {"session_id": session.session_id, "status": "active"} @router.post("/sessions/{session_id}/run_today") async def run_session_today(session_id: str) -> dict[str, Any]: """Immediately run ORB detection for a session that missed the morning window. Requires the auto-scheduler to be running. After detection, the session's remaining today-events (breakout checks, stop checks, EOD exit) are injected into the live schedule automatically. """ import asyncio as _asyncio from apps.web.orb_trading_service import orb_auto_scheduler state = _state() session = state.get_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") if not orb_auto_scheduler.running: raise HTTPException( status_code=400, detail="스케줄러가 실행 중이 아닙니다. 먼저 자동 스케줄러를 시작하세요.", ) # Fire-and-forget — detection takes ~1–2 min; we return immediately _asyncio.create_task(orb_auto_scheduler.run_session_now(session.session_name)) return { "session_id": session.session_id, "session_name": session.session_name, "status": "started", "note": "ORB 감지가 백그라운드에서 시작되었습니다. 스케줄러 로그를 확인하세요.", } @router.delete("/sessions/{session_id}") def close_session(session_id: str) -> dict[str, Any]: state = _state() session = state.get_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") # Close all open Alpaca positions across all dates positions_closed = 0 close_errors: list[str] = [] try: from apps.paper_trader.alpaca_broker import AlpacaBroker broker = AlpacaBroker.from_env() open_pos = state.get_all_open_positions(session.session_id) for pos in open_pos: try: # Always close only this session's shares (partial close if other # sessions also hold the same ticker in the same Alpaca account) broker.close_position(pos.ticker, qty=int(pos.shares)) positions_closed += 1 except Exception as e: close_errors.append(f"{pos.ticker}: {e}") except Exception as e: # Broker init failed — refuse to delete so user can investigate raise HTTPException(status_code=500, detail=f"Alpaca broker error: {e}") # Always delete the session; report any per-ticker failures in response state.delete_session(session.session_id) return { "deleted": True, "positions_closed": positions_closed, "close_errors": close_errors, # empty list if all succeeded } # --------------------------------------------------------------------------- # Data endpoints # --------------------------------------------------------------------------- @router.get("/sessions/{session_id}/positions") def get_positions(session_id: str) -> dict[str, Any]: state = _state() session = state.get_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") today = dt.date.today().isoformat() positions = state.get_open_positions(session.session_id, today) # Enrich with real-time prices from Oracle/Alpaca snapshot API from libs.oracle_client.alpaca import get_snapshots, AlpacaSnapshot snapshots: dict[str, AlpacaSnapshot] = {} if positions: tickers = [p.ticker for p in positions] try: snapshots = get_snapshots(tickers) except Exception: pass def _pos_to_dict(p: Any) -> dict[str, Any]: snap = snapshots.get(p.ticker) current_price = (snap.price if snap and snap.price else None) or p.entry_price bid = snap.bid if snap else None ask = snap.ask if snap else None change_pct = snap.change_pct if snap else None if p.direction == "long": unrealized_pnl = (current_price - p.entry_price) * p.shares r_multiple = (current_price - p.entry_price) / p.stop_distance if p.stop_distance > 0 else 0 else: unrealized_pnl = (p.entry_price - current_price) * p.shares r_multiple = (p.entry_price - current_price) / p.stop_distance if p.stop_distance > 0 else 0 return { "ticker": p.ticker, "direction": p.direction, "entry_price": p.entry_price, "entry_time": p.entry_time, "shares": p.shares, "current_stop": p.current_stop, "peak_price": p.peak_price, "trailing_active": p.trailing_active, "atr_at_entry": p.atr_at_entry, "stop_distance": p.stop_distance, "rvol": p.rvol, "composite_score": p.composite_score, "current_price": current_price, "bid": bid, "ask": ask, "change_pct": change_pct, "unrealized_pnl": round(unrealized_pnl, 2), "r_multiple": round(r_multiple, 3), } return {"positions": [_pos_to_dict(p) for p in positions], "date": today} @router.get("/sessions/{session_id}/trades") def get_trades(session_id: str, last: int | None = None) -> dict[str, Any]: state = _state() session = state.get_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") trades = state.list_trades(session.session_id, limit=last) return {"trades": trades, "total": len(trades)} @router.get("/sessions/{session_id}/equity") def get_equity(session_id: str) -> dict[str, Any]: state = _state() 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} @router.get("/sessions/{session_id}/candidates") def get_candidates(session_id: str) -> dict[str, Any]: state = _state() session = state.get_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") today = dt.date.today().isoformat() candidates = state.list_candidates(session.session_id, today) return {"candidates": candidates, "date": today} # --------------------------------------------------------------------------- # Auto scheduler # --------------------------------------------------------------------------- @router.get("/auto") def get_auto_status() -> dict[str, Any]: from apps.web.orb_trading_service import orb_auto_scheduler return orb_auto_scheduler.get_status() @router.post("/auto/start") async def start_auto(req: AutoStartRequest) -> dict[str, Any]: from apps.web.orb_trading_service import orb_auto_scheduler if orb_auto_scheduler.running: raise HTTPException(status_code=409, detail="Scheduler already running") try: orb_auto_scheduler.start( sessions=req.sessions, db_path=_db_path(), dry_run=req.dry_run, ) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) return {"started": True, "dry_run": req.dry_run, "sessions": req.sessions} @router.post("/auto/stop") async def stop_auto() -> dict[str, Any]: from apps.web.orb_trading_service import orb_auto_scheduler orb_auto_scheduler.stop() return {"stopped": True} # --------------------------------------------------------------------------- # Strategies — list ORB configs with config_path for session creation # --------------------------------------------------------------------------- @router.get("/strategies") def list_orb_strategies() -> dict[str, Any]: """List all ORB strategy configs available for paper trading sessions. Returns both built-in presets and every *.yaml under configs/intraday/strategies/ that has strategy_mode=orb. Always includes config_path so the frontend can pass it to POST /sessions. """ from pathlib import Path import yaml as _yaml from apps.web.routers.intraday import _BUILTIN_STRATEGIES, get_project_root strategies: list[dict[str, Any]] = [] # 1. Built-in presets (always include, they are all ORB) for strat in _BUILTIN_STRATEGIES.values(): strategies.append({ "slug": strat["slug"], "name": strat["name"], "description": strat.get("description", ""), "builtin": True, "config_path": strat["config_path"], "orb_minutes": strat.get("orb_minutes"), "sim_bar_minutes": strat.get("sim_bar_minutes"), "entry_direction": strat.get("entry_direction", "long_only"), "risk_per_trade_pct": strat.get("risk_per_trade_pct"), "atr_stop_multiplier": strat.get("atr_stop_multiplier"), }) # 2. YAMLs in configs/intraday/strategies/ not already listed as builtins builtin_paths = {s["config_path"] for s in _BUILTIN_STRATEGIES.values()} strategies_dir = Path(get_project_root()) / "configs" / "intraday" / "strategies" if strategies_dir.exists(): for yaml_file in sorted(strategies_dir.glob("*.yaml")): rel_path = str(yaml_file.relative_to(get_project_root())) if rel_path in builtin_paths: continue try: raw = _yaml.safe_load(yaml_file.read_text()) or {} if raw.get("strategy_mode") != "orb": continue meta = raw.get("_meta", {}) orb = raw.get("orb_strategy", {}) slug = yaml_file.stem strategies.append({ "slug": slug, "name": meta.get("name", slug), "description": meta.get("description", ""), "builtin": False, "config_path": rel_path, "orb_minutes": orb.get("orb_minutes"), "sim_bar_minutes": orb.get("sim_bar_minutes"), "entry_direction": orb.get("entry_direction", "long_only"), "risk_per_trade_pct": orb.get("risk_per_trade_pct"), "atr_stop_multiplier": orb.get("atr_stop_multiplier"), }) except Exception: continue return {"strategies": strategies}