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.

762 lines
27 KiB
Python

"""Paper trading API endpoints — full session management."""
from __future__ import annotations
import os
import re
import subprocess
import sys
import threading
import uuid
from datetime import datetime, timezone
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
router = APIRouter(prefix="/paper", tags=["paper_trading"])
_ANSI_RE = re.compile(r'\x1b\[[0-9;]*[mGKHABCDFrsu]|\x1b[()][AB012]')
# In-memory task registry
_tasks: dict[str, dict[str, Any]] = {}
_tasks_lock = threading.Lock()
# ── Auto daemon state ─────────────────────────────────────────────────────────
def _auto_pid_file() -> Path:
return get_project_root() / ".paper_auto.pid"
def _auto_log_file() -> Path:
return get_project_root() / ".paper_auto.log"
def _find_auto_process() -> int | None:
"""Scan process list for a running auto daemon (regardless of how it was started).
Matches any of:
fithia2 paper auto (installed CLI entrypoint)
python -m apps.paper_trader.cli auto
python -m apps.paper_trader.auto
"""
try:
import psutil
for proc in psutil.process_iter(["pid", "cmdline"]):
try:
cmdline: list[str] = proc.info.get("cmdline") or []
if not cmdline:
continue
# Must have "auto" as one of the arguments
if "auto" not in cmdline:
continue
cmdline_str = " ".join(cmdline)
# fithia2 paper auto
if "fithia2" in cmdline_str and "paper" in cmdline:
return proc.info["pid"]
# python -m apps.paper_trader.cli auto
if "paper_trader" in cmdline_str:
return proc.info["pid"]
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
except ImportError:
pass
return None
def _get_process_stdout_path(pid: int) -> str | None:
"""Return the file path that the process's stdout (fd=1) is writing to.
Returns None if stdout is a TTY, socket, or unreadable.
"""
try:
import psutil
p = psutil.Process(pid)
for f in p.open_files():
if f.fd == 1:
return f.path
except Exception:
pass
return None
def _get_process_tty(pid: int) -> str | None:
"""Return the controlling TTY device path of the process, if any."""
try:
import psutil
return psutil.Process(pid).terminal()
except Exception:
return None
def _auto_status() -> dict[str, Any]:
"""Return current auto daemon status.
Checks PID file first; if not found (e.g. process started from terminal),
falls back to psutil process scan. Keeps PID file in sync.
Also reports where the log output is going.
"""
pid_file = _auto_pid_file()
# 1. Check PID file
if pid_file.exists():
try:
pid = int(pid_file.read_text().strip())
os.kill(pid, 0)
log_path = _get_process_stdout_path(pid)
return {
"running": True, "pid": pid, "source": "pid_file",
"log_path": log_path,
"log_tty": None if log_path else _get_process_tty(pid),
}
except (ProcessLookupError, ValueError, OSError):
pid_file.unlink(missing_ok=True)
# 2. Fallback: scan process list
pid = _find_auto_process()
if pid is not None:
try:
pid_file.write_text(str(pid))
except Exception:
pass
log_path = _get_process_stdout_path(pid)
return {
"running": True, "pid": pid, "source": "process_scan",
"log_path": log_path,
"log_tty": None if log_path else _get_process_tty(pid),
}
return {"running": False, "pid": None, "source": None, "log_path": None, "log_tty": None}
def _auto_schedule_info() -> list[dict[str, Any]]:
"""Return schedule with countdown info (server-side computation)."""
import datetime as dt
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": f"{ev['et_hour']:02d}:{ev['et_min']:02d} ET",
"et_iso": et_dt.isoformat(),
"past": wait_secs <= 0,
"wait_secs": max(0.0, wait_secs),
})
return result
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,
"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,
}
# ── 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
@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
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,
)
return {"session_id": session_id, "name": req.name, "capital": req.capital, "config": config_path}
@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,
})
# Also include strategy states not in broker positions (ghost)
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:
# Return local strategy states only
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")
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 = _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 (subprocess-based) ──────────────────────────────────────────────
class RunRequest(BaseModel):
date: str | None = None
force: bool = False
def _launch_task(
cmd: list[str],
session_name: str,
operation: str,
) -> dict[str, Any]:
task_id = str(uuid.uuid4())[:8]
now = datetime.now(timezone.utc).isoformat()
task: dict[str, Any] = {
"task_id": task_id,
"session_name": session_name,
"operation": operation,
"status": "running",
"created_at": now,
"started_at": now,
"finished_at": None,
"log": "",
"error": None,
}
with _tasks_lock:
_tasks[task_id] = task
def _run() -> None:
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
cwd=str(get_project_root()),
)
raw, _ = proc.communicate()
log_text = _ANSI_RE.sub("", raw.decode("utf-8", errors="replace"))
with _tasks_lock:
t = _tasks.get(task_id)
if t:
t["log"] = log_text
t["finished_at"] = datetime.now(timezone.utc).isoformat()
t["status"] = "completed" if proc.returncode == 0 else "failed"
if proc.returncode != 0:
t["error"] = f"Exit code {proc.returncode}"
except Exception as exc:
with _tasks_lock:
t = _tasks.get(task_id)
if t:
t["status"] = "failed"
t["error"] = str(exc)
t["finished_at"] = datetime.now(timezone.utc).isoformat()
threading.Thread(target=_run, daemon=True).start()
return dict(task)
def _paper_cmd(subcommand: str, session_name: str, date: str | None, force: bool) -> list[str]:
cmd = [
sys.executable, "-m", "apps.paper_trader.cli",
subcommand, "--session", session_name,
"--db", _get_db_path(),
]
if date:
cmd += ["--date", date]
if force:
cmd.append("--force")
return cmd
@router.post("/sessions/{session_id}/run")
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")
cmd = _paper_cmd("run", session.session_name, req.date, req.force)
return _launch_task(cmd, session.session_name, "run")
@router.post("/sessions/{session_id}/run-close")
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")
cmd = _paper_cmd("run-close", session.session_name, req.date, req.force)
return _launch_task(cmd, session.session_name, "run-close")
@router.post("/sessions/{session_id}/run-open")
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")
cmd = _paper_cmd("run-open", session.session_name, req.date, req.force)
return _launch_task(cmd, session.session_name, "run-open")
@router.post("/run-all")
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"}
cmd = [
sys.executable, "-m", "apps.paper_trader.cli",
"run-all", "--db", _get_db_path(),
]
task = _launch_task(cmd, "all", "run-all")
return {"tasks": [task]}
# ── Task management ────────────────────────────────────────────────────────────
@router.get("/tasks")
def list_tasks(session_name: str | None = Query(None)) -> dict[str, Any]:
with _tasks_lock:
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)
# 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]:
with _tasks_lock:
task = _tasks.get(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]:
with _tasks_lock:
task = _tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
return {"log": task.get("log", ""), "status": task["status"]}
# ── 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}
index_path = configs_dir / ".index.json"
configs = []
if index_path.exists():
import json
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 daemon 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 daemon status + today's schedule."""
status = _auto_status()
log_tail: list[str] = []
log_file = _auto_log_file()
if log_file.exists():
try:
lines = log_file.read_text(errors="replace").splitlines()
log_tail = lines[-80:] # last 80 lines for status panel
except Exception:
pass
return {
**status,
"schedule": _auto_schedule_info(),
"log_tail": log_tail,
"log_lines": len(log_tail),
}
@router.post("/auto/start")
def start_auto(req: AutoStartRequest) -> dict[str, Any]:
"""Start the auto daemon. Returns 409 if already running."""
status = _auto_status()
if status["running"]:
raise HTTPException(
status_code=409,
detail=f"Auto daemon already running (PID {status['pid']}). Stop it first.",
)
project_root = get_project_root()
log_file = _auto_log_file()
pid_file = _auto_pid_file()
cmd = [sys.executable, "-m", "apps.paper_trader.cli", "auto",
"--db", _get_db_path()]
for s in req.sessions:
cmd += ["--session", s]
if req.dry_run:
cmd.append("--dry-run")
# Open log file for subprocess output
log_fp = open(log_file, "w", buffering=1)
proc = subprocess.Popen(
cmd,
stdout=log_fp,
stderr=subprocess.STDOUT,
cwd=str(project_root),
start_new_session=True, # detach from web server process group
)
pid_file.write_text(str(proc.pid))
# Monitor in background: clean up PID file when process exits
def _watch():
proc.wait()
log_fp.close()
pid_file.unlink(missing_ok=True)
threading.Thread(target=_watch, daemon=True).start()
return {"started": True, "pid": proc.pid, "dry_run": req.dry_run, "sessions": req.sessions}
@router.post("/auto/stop")
def stop_auto() -> dict[str, Any]:
"""Send SIGTERM to the auto daemon."""
import signal as _signal
status = _auto_status()
if not status["running"]:
raise HTTPException(status_code=409, detail="Auto daemon is not running.")
pid = status["pid"]
try:
os.kill(pid, _signal.SIGTERM)
except ProcessLookupError:
_auto_pid_file().unlink(missing_ok=True)
raise HTTPException(status_code=404, detail="Process not found (already exited?)")
return {"stopped": True, "pid": pid}
@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 daemon log.
Priority:
1. Process stdout file (wherever the running process is writing)
2. Fallback: known web-GUI log file
"""
# Find where the running process is logging
status = _auto_status()
log_source: Path | None = None
log_source_label = "none"
if status["running"] and status.get("log_path"):
candidate = Path(status["log_path"])
if candidate.exists():
log_source = candidate
log_source_label = str(candidate)
# Fallback: web-GUI log file
if log_source is None:
fallback = _auto_log_file()
if fallback.exists():
log_source = fallback
log_source_label = str(fallback)
if log_source is None:
tty = status.get("log_tty") if status["running"] else None
if tty:
return {
"log": f"[Auto daemon is running but outputting to terminal: {tty}]\n"
f"Log cannot be captured from an active TTY.\n\n"
f"To view logs in the web GUI, stop the daemon and restart it here,\n"
f"or run with output redirected:\n\n"
f" fithia2 paper auto 2>&1 | tee ~/.paper_auto.log",
"lines": 0,
"source": f"tty:{tty}",
"tty": True,
}
return {"log": "", "lines": 0, "source": "none"}
try:
all_lines = log_source.read_text(errors="replace").splitlines()
tail = all_lines[-lines:]
return {
"log": "\n".join(tail),
"lines": len(all_lines),
"source": log_source_label,
}
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc))