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
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))
|