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.

555 lines
21 KiB
Python

"""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",
}