Add Paper Trading web GUI with ANSI terminal log rendering
- Full paper trading page (sessions sidebar, 5-tab detail view) - Auto daemon panel: status, schedule, start/stop, live log - Auto daemon detection for terminal-started processes via psutil scan - Log source detection: process stdout file → web GUI log file → TTY hint - ANSI color rendering for paper task logs and auto daemon log - Dark terminal theme (matching backtest log style) with macOS traffic lights - Extracted ansiToHtml to shared lib/utils.ts (deduped from Backtest.tsx) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>main
parent
7df99447ee
commit
c5dea9a9a8
@ -0,0 +1,761 @@
|
||||
"""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))
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>web_frontend</title>
|
||||
<script type="module" crossorigin src="/assets/index-CFG6oWxa.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CrtbgS1f.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,432 @@
|
||||
const BASE = '/api';
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
...options,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// --- Experiments ---
|
||||
export interface ExperimentMeta {
|
||||
name: string;
|
||||
id: number | null;
|
||||
parent: string | null;
|
||||
version_family: string | null;
|
||||
generation: number | null;
|
||||
status: string;
|
||||
created_at: string | null;
|
||||
created_by: string | null;
|
||||
tags: string[];
|
||||
aliases: string[];
|
||||
description: string | null;
|
||||
changelog: string | null;
|
||||
has_journal_entry: boolean;
|
||||
sqs_score: number | null;
|
||||
}
|
||||
|
||||
export interface ExperimentConfig {
|
||||
experiment_name: string;
|
||||
dataset_snapshot_id: string;
|
||||
base_config: string;
|
||||
description: string | null;
|
||||
overrides: Record<string, unknown>;
|
||||
strategy_engines: Record<string, unknown>[];
|
||||
splits: unknown[];
|
||||
tags: string[];
|
||||
notes: string | null;
|
||||
id: number | null;
|
||||
parent: string | null;
|
||||
status: string;
|
||||
generation: number | null;
|
||||
version_family: string | null;
|
||||
created_at: string | null;
|
||||
aliases: string[];
|
||||
changelog: string | null;
|
||||
performance_summary: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface LineageNode {
|
||||
name: string;
|
||||
meta: Partial<ExperimentMeta>;
|
||||
depth: number;
|
||||
children: LineageNode[];
|
||||
}
|
||||
|
||||
export const experimentsApi = {
|
||||
list: (params?: {
|
||||
status?: string; family?: string; tag?: string; pattern?: string;
|
||||
sort_by?: string; limit?: number; offset?: number; has_journal?: boolean;
|
||||
}) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.status) q.set('status', params.status);
|
||||
if (params?.family) q.set('family', params.family);
|
||||
if (params?.tag) q.set('tag', params.tag);
|
||||
if (params?.pattern) q.set('pattern', params.pattern);
|
||||
if (params?.sort_by) q.set('sort_by', params.sort_by);
|
||||
if (params?.limit != null) q.set('limit', String(params.limit));
|
||||
if (params?.offset != null) q.set('offset', String(params.offset));
|
||||
if (params?.has_journal != null) q.set('has_journal', String(params.has_journal));
|
||||
return request<{ experiments: ExperimentMeta[]; total: number }>(`/experiments?${q}`);
|
||||
},
|
||||
|
||||
get: (name: string) => request<ExperimentConfig>(`/experiments/${encodeURIComponent(name)}`),
|
||||
|
||||
lineage: (name: string) =>
|
||||
request<{ tree: LineageNode; ancestor_chain: unknown[]; current: string }>(
|
||||
`/experiments/${encodeURIComponent(name)}/lineage`,
|
||||
),
|
||||
|
||||
diff: (a: string, b: string) =>
|
||||
request<Record<string, [unknown, unknown]>>(
|
||||
`/experiments/${encodeURIComponent(a)}/diff/${encodeURIComponent(b)}`,
|
||||
),
|
||||
|
||||
setStatus: (name: string, status: string, alias?: string) =>
|
||||
request<{ name: string; status: string }>(`/experiments/${encodeURIComponent(name)}/status`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status, alias }),
|
||||
}),
|
||||
|
||||
create: (parent: string, name: string, changelog?: string) =>
|
||||
request<{ name: string; parent: string }>('/experiments', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ parent, name, changelog }),
|
||||
}),
|
||||
|
||||
update: (name: string, config: ExperimentConfig) =>
|
||||
request<{ name: string; updated: boolean }>(`/experiments/${encodeURIComponent(name)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(config),
|
||||
}),
|
||||
|
||||
delete: (name: string) =>
|
||||
request<{ name: string; deleted: boolean }>(`/experiments/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
|
||||
families: () => request<{ families: string[] }>('/experiments/families'),
|
||||
tags: () => request<{ tags: string[] }>('/experiments/tags'),
|
||||
rebuildIndex: () => request<{ rebuilt: boolean }>('/experiments/rebuild-index', { method: 'POST' }),
|
||||
};
|
||||
|
||||
// --- Leaderboard ---
|
||||
export interface LeaderboardEntry {
|
||||
rank: number;
|
||||
entry_id: string;
|
||||
config_id: number | null;
|
||||
experiment_name: string;
|
||||
strategy_family: string;
|
||||
is_retired: boolean;
|
||||
sqs_score: number | null;
|
||||
rqs_score: number | null;
|
||||
wfqs_score: number | null;
|
||||
deployment_score: number | null;
|
||||
promotion_score: number | null;
|
||||
// test split
|
||||
total_return_pct: number | null;
|
||||
annualized_return_pct: number | null;
|
||||
win_rate: number | null;
|
||||
sharpe_ratio: number | null;
|
||||
max_drawdown_pct: number | null;
|
||||
trade_count: number;
|
||||
avg_gross_exposure_pct: number | null;
|
||||
days_in_market_pct: number | null;
|
||||
profit_factor: number | null;
|
||||
// train
|
||||
train_total_return_pct: number | null;
|
||||
// valid split
|
||||
valid_total_return_pct: number | null;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export const leaderboardApi = {
|
||||
list: (params?: { sort_by?: string; include_retired?: boolean; top?: number; offset?: number }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.sort_by) q.set('sort_by', params.sort_by);
|
||||
if (params?.include_retired) q.set('include_retired', String(params.include_retired));
|
||||
if (params?.top != null) q.set('top', String(params.top));
|
||||
if (params?.offset != null) q.set('offset', String(params.offset));
|
||||
return request<{ entries: LeaderboardEntry[]; total: number; sort_by: string }>(`/leaderboard?${q}`);
|
||||
},
|
||||
|
||||
getEntry: (entryId: string) =>
|
||||
request<Record<string, unknown>>(`/leaderboard/entry/${encodeURIComponent(entryId)}`),
|
||||
|
||||
getExperimentEntry: (name: string) =>
|
||||
request<Record<string, unknown>>(`/leaderboard/experiment/${encodeURIComponent(name)}`),
|
||||
|
||||
refresh: () => request<{ entries: number; rebuilt: boolean }>('/leaderboard/refresh', { method: 'POST' }),
|
||||
};
|
||||
|
||||
// --- Runs ---
|
||||
export const runsApi = {
|
||||
list: (params?: { experiment?: string; limit?: number }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.experiment) q.set('experiment', params.experiment);
|
||||
if (params?.limit != null) q.set('limit', String(params.limit));
|
||||
return request<{ runs: unknown[]; total: number }>(`/runs?${q}`);
|
||||
},
|
||||
metadata: (runId: string) => request<Record<string, unknown>>(`/runs/${runId}/metadata`),
|
||||
metrics: (runId: string) => request<Record<string, unknown>>(`/runs/${runId}/metrics`),
|
||||
equityCurve: (runId: string) => request<{ data: Record<string, unknown>[]; format: string }>(`/runs/${runId}/equity-curve`),
|
||||
tradeBlotter: (runId: string, params?: { limit?: number; offset?: number }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.limit != null) q.set('limit', String(params.limit));
|
||||
if (params?.offset != null) q.set('offset', String(params.offset));
|
||||
return request<{ trades: Record<string, unknown>[]; total: number }>(`/runs/${runId}/trade-blotter?${q}`);
|
||||
},
|
||||
perEngineMetrics: (runId: string) => request<Record<string, unknown>>(`/runs/${runId}/per-engine-metrics`),
|
||||
};
|
||||
|
||||
// --- Backtest ---
|
||||
export interface BacktestRequest {
|
||||
experiment_name: string;
|
||||
capital?: number;
|
||||
start?: string | null; // YYYY-MM-DD or YYYY
|
||||
end?: string | null; // YYYY-MM-DD
|
||||
year?: string | null; // YYYY shorthand
|
||||
no_trades?: boolean;
|
||||
}
|
||||
|
||||
export interface BacktestLastParams {
|
||||
capital?: number | null;
|
||||
start?: string | null;
|
||||
end?: string | null;
|
||||
year?: string | null;
|
||||
}
|
||||
|
||||
export interface BacktestTask {
|
||||
task_id: string;
|
||||
experiment_name: string;
|
||||
capital: number;
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
year: string | null;
|
||||
no_trades: boolean;
|
||||
status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
created_at: string;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
run_id: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const backtestApi = {
|
||||
submit: (req: BacktestRequest) =>
|
||||
request<BacktestTask>('/backtest/submit', { method: 'POST', body: JSON.stringify(req) }),
|
||||
|
||||
submitBatch: (names: string[], params: Omit<BacktestRequest, 'experiment_name'>) =>
|
||||
request<{ tasks: BacktestTask[] }>('/backtest/submit-batch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ experiment_names: names, ...params }),
|
||||
}),
|
||||
|
||||
tasks: () => request<{ tasks: BacktestTask[] }>('/backtest/tasks'),
|
||||
|
||||
task: (id: string) => request<BacktestTask>(`/backtest/tasks/${id}`),
|
||||
|
||||
cancel: (id: string) =>
|
||||
request<{ cancelled: boolean }>(`/backtest/tasks/${id}`, { method: 'DELETE' }),
|
||||
|
||||
taskLog: (id: string) =>
|
||||
request<{ log: string; total_lines?: number }>(`/backtest/tasks/${id}/log`),
|
||||
|
||||
lastParams: (name: string) =>
|
||||
request<BacktestLastParams>(`/backtest/last-params/${encodeURIComponent(name)}`),
|
||||
};
|
||||
|
||||
// --- Paper Trading ---
|
||||
|
||||
export interface PaperSession {
|
||||
session_id: string;
|
||||
session_name: string;
|
||||
config_path: string;
|
||||
initial_equity: number;
|
||||
created_at: string;
|
||||
status: 'active' | 'paused' | 'closed';
|
||||
current_equity: number;
|
||||
peak_equity: number;
|
||||
total_pnl: number;
|
||||
total_pnl_pct: number;
|
||||
drawdown_pct: number;
|
||||
trade_count: number;
|
||||
latest_date: string | null;
|
||||
kill_switch: boolean;
|
||||
cooldown_remaining: number;
|
||||
consecutive_losses: number;
|
||||
daily_new_risk_used: number;
|
||||
last_processed_date: string | null;
|
||||
}
|
||||
|
||||
export interface PaperPosition {
|
||||
symbol: string;
|
||||
qty: number | null;
|
||||
avg_entry_price: number | null;
|
||||
current_price: number | null;
|
||||
unrealized_pl: number | null;
|
||||
unrealized_pl_pct: number | null;
|
||||
days_held: number | null;
|
||||
stop_price: number | null;
|
||||
target_price: number | null;
|
||||
entry_date: string | null;
|
||||
engine_id: string | null;
|
||||
trade_direction: string | null;
|
||||
_ghost?: boolean;
|
||||
}
|
||||
|
||||
export interface PaperTrade {
|
||||
trade_id: string;
|
||||
session_id: string;
|
||||
symbol: string;
|
||||
entry_date: string | null;
|
||||
exit_date: string;
|
||||
entry_price: number | null;
|
||||
exit_price: number;
|
||||
exit_reason: string;
|
||||
shares: number;
|
||||
net_pnl: number;
|
||||
r_multiple: number;
|
||||
holding_days: number;
|
||||
}
|
||||
|
||||
export interface PaperSnapshot {
|
||||
session_id: string;
|
||||
date: string;
|
||||
equity: number;
|
||||
cash: number;
|
||||
market_value: number;
|
||||
daily_pnl: number | null;
|
||||
total_pnl: number | null;
|
||||
drawdown_pct: number | null;
|
||||
open_position_count: number | null;
|
||||
}
|
||||
|
||||
export interface PaperTask {
|
||||
task_id: string;
|
||||
session_name: string;
|
||||
operation: string;
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
created_at: string;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
log?: string;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface PaperConfig {
|
||||
name: string;
|
||||
path: string;
|
||||
id: number | null;
|
||||
status: string;
|
||||
version_family: string | null;
|
||||
generation: number | null;
|
||||
parent: string | null;
|
||||
}
|
||||
|
||||
export const paperApi = {
|
||||
// Sessions
|
||||
sessions: () => request<{ sessions: PaperSession[] }>('/paper/sessions'),
|
||||
|
||||
createSession: (name: string, config: string, capital: number) =>
|
||||
request<{ session_id: string; name: string; capital: number; config: string }>('/paper/sessions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, config, capital }),
|
||||
}),
|
||||
|
||||
getSession: (sessionId: string) =>
|
||||
request<PaperSession>(`/paper/sessions/${encodeURIComponent(sessionId)}`),
|
||||
|
||||
pauseSession: (sessionId: string) =>
|
||||
request<{ session_id: string; status: string }>(`/paper/sessions/${encodeURIComponent(sessionId)}/pause`, { method: 'POST' }),
|
||||
|
||||
resumeSession: (sessionId: string) =>
|
||||
request<{ session_id: string; status: string }>(`/paper/sessions/${encodeURIComponent(sessionId)}/resume`, { method: 'POST' }),
|
||||
|
||||
closeSession: (sessionId: string) =>
|
||||
request<{ deleted: boolean; positions_closed: number }>(`/paper/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' }),
|
||||
|
||||
// Data
|
||||
positions: (sessionId: string) =>
|
||||
request<{ positions: PaperPosition[]; broker_available: boolean; broker_error?: string }>(
|
||||
`/paper/sessions/${encodeURIComponent(sessionId)}/positions`,
|
||||
),
|
||||
|
||||
trades: (sessionId: string, last?: number) => {
|
||||
const q = last != null ? `?last=${last}` : '';
|
||||
return request<{ trades: PaperTrade[]; total: number }>(
|
||||
`/paper/sessions/${encodeURIComponent(sessionId)}/trades${q}`,
|
||||
);
|
||||
},
|
||||
|
||||
equity: (sessionId: string) =>
|
||||
request<{ snapshots: PaperSnapshot[]; initial_equity: number }>(
|
||||
`/paper/sessions/${encodeURIComponent(sessionId)}/equity`,
|
||||
),
|
||||
|
||||
// Run operations
|
||||
run: (sessionId: string, date?: string | null, force?: boolean) =>
|
||||
request<PaperTask>(`/paper/sessions/${encodeURIComponent(sessionId)}/run`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ date: date || null, force: force ?? false }),
|
||||
}),
|
||||
|
||||
runClose: (sessionId: string, date?: string | null, force?: boolean) =>
|
||||
request<PaperTask>(`/paper/sessions/${encodeURIComponent(sessionId)}/run-close`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ date: date || null, force: force ?? false }),
|
||||
}),
|
||||
|
||||
runOpen: (sessionId: string, date?: string | null, force?: boolean) =>
|
||||
request<PaperTask>(`/paper/sessions/${encodeURIComponent(sessionId)}/run-open`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ date: date || null, force: force ?? false }),
|
||||
}),
|
||||
|
||||
runAll: () => request<{ tasks: PaperTask[] }>('/paper/run-all', { method: 'POST' }),
|
||||
|
||||
// Tasks
|
||||
tasks: (sessionName?: string) => {
|
||||
const q = sessionName ? `?session_name=${encodeURIComponent(sessionName)}` : '';
|
||||
return request<{ tasks: PaperTask[] }>(`/paper/tasks${q}`);
|
||||
},
|
||||
|
||||
task: (taskId: string) => request<PaperTask>(`/paper/tasks/${taskId}`),
|
||||
|
||||
taskLog: (taskId: string) =>
|
||||
request<{ log: string; status: string }>(`/paper/tasks/${taskId}/log`),
|
||||
|
||||
// Auto daemon
|
||||
autoStatus: () =>
|
||||
request<{
|
||||
running: boolean; pid: number | null;
|
||||
schedule: { name: string; label: string; et_time: string; et_iso: string; past: boolean; wait_secs: number }[];
|
||||
log_tail: string[];
|
||||
}>('/paper/auto'),
|
||||
|
||||
autoStart: (sessions?: string[], dryRun?: boolean) =>
|
||||
request<{ started: boolean; pid: number; dry_run: boolean; sessions: string[] }>('/paper/auto/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sessions: sessions ?? [], dry_run: dryRun ?? false }),
|
||||
}),
|
||||
|
||||
autoStop: () =>
|
||||
request<{ stopped: boolean; pid: number }>('/paper/auto/stop', { method: 'POST' }),
|
||||
|
||||
autoLog: (lines?: number) =>
|
||||
request<{ log: string; lines: number; source?: string; tty?: boolean }>(`/paper/auto/log${lines ? `?lines=${lines}` : ''}`),
|
||||
|
||||
// Configs
|
||||
configs: (params?: { status?: string; pattern?: string; limit?: number }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.status) q.set('status', params.status);
|
||||
if (params?.pattern) q.set('pattern', params.pattern);
|
||||
if (params?.limit != null) q.set('limit', String(params.limit));
|
||||
return request<{ configs: PaperConfig[]; total: number }>(`/paper/configs?${q}`);
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,48 @@
|
||||
// ── ANSI terminal renderer ────────────────────────────────────────────────────
|
||||
|
||||
const ANSI_COLOR: Record<string, string> = {
|
||||
'30': '#4a4a4a', '31': 'var(--red)', '32': 'var(--green)',
|
||||
'33': 'var(--gold)', '34': '#60a5fa', '35': '#c084fc',
|
||||
'36': 'var(--cyan)', '37': '#e5e7eb',
|
||||
'90': '#6b7280', '91': '#f87171', '92': '#4ade80',
|
||||
'93': '#facc15', '94': '#818cf8', '95': '#e879f9',
|
||||
'96': '#22d3ee', '97': '#f9fafb',
|
||||
};
|
||||
|
||||
export function ansiToHtml(text: string): string {
|
||||
let html = '';
|
||||
let open = false;
|
||||
for (const chunk of text.split(/(\x1b\[[0-9;]*m)/)) {
|
||||
const m = chunk.match(/^\x1b\[([0-9;]*)m$/);
|
||||
if (m) {
|
||||
if (open) { html += '</span>'; open = false; }
|
||||
const codes = m[1].split(';');
|
||||
for (const c of codes) {
|
||||
if (c === '' || c === '0') break;
|
||||
if (c === '1') { html += '<span style="font-weight:700">'; open = true; break; }
|
||||
if (c === '2') { html += '<span style="opacity:0.55">'; open = true; break; }
|
||||
if (ANSI_COLOR[c]) { html += `<span style="color:${ANSI_COLOR[c]}"`; open = true; break; }
|
||||
}
|
||||
if (open && !html.endsWith('>')) html += '>';
|
||||
} else if (chunk) {
|
||||
html += chunk.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
}
|
||||
if (open) html += '</span>';
|
||||
return html;
|
||||
}
|
||||
|
||||
export function fmt(n: number | null | undefined, decimals = 1, suffix = ''): string {
|
||||
if (n == null) return '—';
|
||||
return n.toFixed(decimals) + suffix;
|
||||
}
|
||||
|
||||
export function fmtPct(n: number | null | undefined, decimals = 1): string {
|
||||
if (n == null) return '—';
|
||||
return `${n.toFixed(decimals)}%`;
|
||||
}
|
||||
|
||||
export function fmtDate(s: string | null | undefined): string {
|
||||
if (!s) return '—';
|
||||
return new Date(s).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: '2-digit' });
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue