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
I Luk Kim 5 months ago
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
}
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…
Cancel
Save