Fix direct backtest mode UX: live log, inline results, trade table sort/filter

- Fix log endpoint to serve .direct.log for direct mode tasks
- Fix _parse_dates: 4-digit start with no end now defaults to today
- Fix frontend year mode to send start=YYYY-01-01 instead of year param
- Replace DirectModePanel with DirectModeTaskView: live terminal log while
  running, inline results (metric cards + equity chart + trade blotter) on
  completion, collapsible log
- Add trade table sort/filter: symbol, engine, exit reason filters, Win/Loss
  toggle, sortable columns (No., PnL, entry/exit price), stats bar
- Add No. column showing original trade order for sort restoration
- Add BacktestDirectResultsPage at /backtest/direct-results/:taskId
- Add Results button in task list for has_direct_result tasks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
I Luk Kim 5 months ago
parent c5dea9a9a8
commit 0928eb2428

@ -0,0 +1,834 @@
"""Backtest execution API endpoints (paper backtest)."""
from __future__ import annotations
import datetime as dt
import json
import os
import re
import signal
import subprocess
import sys
import threading
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
_ANSI_RE = re.compile(r'\x1b\[[0-9;]*[mGKHABCDFrsu]|\x1b[()][AB012]')
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from apps.web.dependencies import get_configs_dir, get_project_root, get_runs_dir
router = APIRouter(prefix="/backtest", tags=["backtest"])
# ---------------------------------------------------------------------------
# Task registry — in-memory + disk persistence
# ---------------------------------------------------------------------------
_tasks: dict[str, dict[str, Any]] = {}
_tasks_lock = threading.Lock()
_tasks_initialized = False
# ---------------------------------------------------------------------------
# Direct result helpers (in-process backtest)
# ---------------------------------------------------------------------------
def _result_file(task_id: str) -> Path:
return _log_dir() / f"{task_id}.result.json"
def _json_default(obj: Any) -> Any:
"""JSON serializer for types not handled by default encoder."""
if isinstance(obj, (dt.date, dt.datetime)):
return obj.isoformat()
raise TypeError(f"Object of type {type(obj)} is not JSON serializable")
def _persist_direct_result(task_id: str, result: dict[str, Any]) -> None:
"""Write direct backtest result to disk."""
try:
_log_dir().mkdir(parents=True, exist_ok=True)
_result_file(task_id).write_text(
json.dumps(result, default=_json_default, indent=2)
)
except Exception:
pass
def _load_direct_result(task_id: str) -> dict[str, Any] | None:
"""Load direct backtest result from disk."""
try:
f = _result_file(task_id)
if f.exists():
return json.loads(f.read_text())
except Exception:
pass
return None
def _parse_dates(
start: str | None,
end: str | None,
year: str | None,
) -> tuple[dt.date, dt.date]:
"""Parse date parameters into (start_date, end_date)."""
if year:
y = int(year)
return dt.date(y, 1, 1), dt.date(y, 12, 31)
if not start:
raise ValueError("Either year or start date is required")
if len(start) == 4:
start_date = dt.date(int(start), 1, 1)
end_date = dt.date.fromisoformat(end) if end else dt.date.today()
else:
start_date = dt.date.fromisoformat(start)
end_date = dt.date.fromisoformat(end) if end else dt.date.today()
return start_date, end_date
def _log_dir() -> Path:
return get_runs_dir() / ".backtest_tasks"
def _task_file(task_id: str) -> Path:
return _log_dir() / f"{task_id}.task.json"
def _persist_task(task: dict[str, Any]) -> None:
"""Write task state to disk (includes pid so we can re-attach after restart)."""
try:
_log_dir().mkdir(parents=True, exist_ok=True)
_task_file(task["task_id"]).write_text(json.dumps(task, indent=2))
except Exception:
pass
def _delete_task_file(task_id: str) -> None:
try:
_task_file(task_id).unlink(missing_ok=True)
except Exception:
pass
def _is_pid_alive(pid: int) -> bool:
"""Return True if the process with the given PID is still running."""
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
except PermissionError:
return True # Exists but owned by a different user (unlikely but safe)
def _reattach_watcher(task_id: str, pid: int, output_root: Path) -> None:
"""Watch an orphaned subprocess by PID after server restart."""
import time
with _tasks_lock:
task = _tasks.get(task_id)
if task is None:
return
started_at_str = task.get("started_at") or datetime.now(timezone.utc).isoformat()
experiment_name = task["experiment_name"]
try:
started_at = datetime.fromisoformat(started_at_str.replace("Z", "+00:00"))
except Exception:
started_at = datetime.now(timezone.utc)
# Poll until the orphaned process exits
while _is_pid_alive(pid):
time.sleep(2)
# Process finished — check for results
with _tasks_lock:
task = _tasks.get(task_id)
if task is None:
return
mode = task.get("mode", "cli")
if mode == "direct":
# Direct mode: result is in a JSON file, not a run directory
direct_log = output_root / ".backtest_tasks" / f"{task_id}.direct.log"
with _tasks_lock:
task = _tasks.get(task_id)
if task is None:
return
if task["status"] == "cancelled":
_persist_task(task)
return
task["pid"] = None
if not task.get("finished_at"):
task["finished_at"] = datetime.now(timezone.utc).isoformat()
if _result_file(task_id).exists():
task["status"] = "completed"
task["has_direct_result"] = True
else:
task["status"] = "failed"
task["error"] = _log_tail_error(direct_log)
_persist_task(task)
return
run_id = _detect_any_run_id(output_root, experiment_name, started_at)
log_file = output_root / ".backtest_tasks" / f"{task_id}.log"
with _tasks_lock:
task = _tasks.get(task_id)
if task is None:
return
if task["status"] == "cancelled":
_persist_task(task)
return
task["pid"] = None
if not task.get("finished_at"):
task["finished_at"] = datetime.now(timezone.utc).isoformat()
if run_id:
task["status"] = "completed"
task["run_id"] = run_id
elif _log_success(log_file):
# Multi-config or run saved to unexpected path — log says success
task["status"] = "completed"
task["run_id"] = None
else:
task["status"] = "failed"
task["error"] = _log_tail_error(log_file)
_persist_task(task)
def _load_tasks_from_disk() -> list[tuple[str, int]]:
"""Load persisted task files. Returns list of (task_id, pid) needing re-attachment."""
ld = _log_dir()
reattach: list[tuple[str, int]] = []
if not ld.exists():
return reattach
for f in sorted(ld.glob("*.task.json")):
try:
data = json.loads(f.read_text())
task_id = data.get("task_id")
if not task_id or task_id in _tasks:
continue
if data.get("status") in ("running", "queued"):
pid = data.get("pid")
if pid and _is_pid_alive(pid):
# Process still running — schedule re-attachment
_tasks[task_id] = data
reattach.append((task_id, pid))
continue
# Process dead — check if it completed successfully
started_at_str = data.get("started_at")
run_id = None
log_file = _log_dir() / f"{data.get('task_id', '')}.log"
if started_at_str:
try:
started_at = datetime.fromisoformat(started_at_str.replace("Z", "+00:00"))
run_id = _detect_any_run_id(get_runs_dir(), data.get("experiment_name", ""), started_at)
except Exception:
pass
data["pid"] = None
if not data.get("finished_at"):
data["finished_at"] = datetime.now(timezone.utc).isoformat()
# Direct mode: check result file
if data.get("mode") == "direct":
if _result_file(task_id).exists():
data["status"] = "completed"
data["has_direct_result"] = True
else:
data["status"] = "failed"
log_file = _log_dir() / f"{task_id}.direct.log"
data["error"] = _log_tail_error(log_file)
elif run_id:
data["status"] = "completed"
data["run_id"] = run_id
elif _log_success(log_file):
data["status"] = "completed"
data["run_id"] = None
else:
data["status"] = "failed"
data["error"] = "Server restarted while task was running"
f.write_text(json.dumps(data, indent=2))
_tasks[task_id] = data
except Exception:
continue
return reattach
def _ensure_tasks_loaded() -> None:
global _tasks_initialized
if _tasks_initialized:
return
with _tasks_lock:
if _tasks_initialized:
return
_tasks_initialized = True
reattach = _load_tasks_from_disk()
# Start re-attachment threads outside the lock
for task_id, pid in reattach:
threading.Thread(
target=_reattach_watcher,
args=(task_id, pid, get_runs_dir()),
daemon=True,
).start()
# ---------------------------------------------------------------------------
# Request / Response schemas
# ---------------------------------------------------------------------------
class BacktestRequest(BaseModel):
experiment_name: str
capital: float = 10_000.0
start: str | None = None # YYYY-MM-DD or YYYY
end: str | None = None # YYYY-MM-DD
year: str | None = None # shorthand for full year (YYYY)
no_trades: bool = False
mode: str = "cli" # "cli" (subprocess) or "direct" (in-process)
class BatchBacktestRequest(BaseModel):
experiment_names: list[str]
capital: float = 10_000.0
start: str | None = None
end: str | None = None
year: str | None = None
no_trades: bool = False
# ---------------------------------------------------------------------------
# Task helpers
# ---------------------------------------------------------------------------
def _make_task(
experiment_name: str,
capital: float,
start: str | None,
end: str | None,
year: str | None,
no_trades: bool = False,
mode: str = "cli",
) -> dict[str, Any]:
return {
"task_id": str(uuid.uuid4())[:8],
"experiment_name": experiment_name,
"capital": capital,
"start_date": year if year else start,
"end_date": None if year else end,
"year": year,
"no_trades": no_trades,
"status": "queued",
"created_at": datetime.now(timezone.utc).isoformat(),
"started_at": None,
"finished_at": None,
"run_id": None,
"error": None,
"pid": None,
"mode": mode,
"has_direct_result": False,
}
def _detect_run_id(output_root: Path, experiment_name: str, started_before: datetime) -> str | None:
"""Scan runs dir for a new run directory created after started_before."""
if not output_root.exists():
return None
candidates = []
for d in output_root.iterdir():
if not d.is_dir():
continue
meta_file = d / "metadata.json"
if not meta_file.exists():
continue
try:
mtime = datetime.fromtimestamp(d.stat().st_mtime, tz=timezone.utc)
if mtime < started_before:
continue
meta = json.loads(meta_file.read_text())
if meta.get("experiment_name") == experiment_name:
candidates.append((mtime, d.name))
except Exception:
continue
if not candidates:
return None
candidates.sort(reverse=True)
return candidates[0][1]
def _detect_any_run_id(output_root: Path, experiment_name: str, started_before: datetime) -> str | None:
"""Detect run_id for single OR multi-config experiments (tries each name in joined list)."""
# First try the full name (handles single config)
run_id = _detect_run_id(output_root, experiment_name, started_before)
if run_id:
return run_id
# For multi-config joined names ("exp1, exp2, ..."), try each individual name
parts = [n.strip() for n in experiment_name.split(",") if n.strip()]
if len(parts) > 1:
for name in parts:
run_id = _detect_run_id(output_root, name, started_before)
if run_id:
return run_id
return None
def _log_success(log_file: Path) -> bool:
"""Return True if the log file ends with a success marker."""
if not log_file.exists():
return False
try:
tail = log_file.read_text(errors="replace")[-1500:]
return "results saved to" in tail.lower() or "results saved" in tail.lower()
except Exception:
return False
def _log_tail_error(log_file: Path, returncode: int | None = None) -> str:
"""Return last ~10 lines of log with ANSI stripped, for use as error message."""
if log_file.exists():
try:
lines = log_file.read_text(errors="replace").strip().splitlines()
# Strip ANSI codes from each line
clean = [_ANSI_RE.sub("", l) for l in lines[-10:]]
return "\n".join(clean) if clean else f"Process exited with code {returncode}"
except Exception:
pass
return f"Process exited with code {returncode}" if returncode is not None else "Process exited unexpectedly"
def _watch_process(task_id: str, proc: subprocess.Popen[bytes], output_root: Path) -> None:
"""Background thread: wait for process to finish and update task state."""
with _tasks_lock:
task = _tasks.get(task_id)
if task is None:
return
task["status"] = "running"
task["started_at"] = datetime.now(timezone.utc).isoformat()
task["pid"] = proc.pid
started_at = datetime.now(timezone.utc)
experiment_name = task["experiment_name"]
_persist_task(task)
returncode = proc.wait()
with _tasks_lock:
task = _tasks.get(task_id)
if task is None:
return
task["finished_at"] = datetime.now(timezone.utc).isoformat()
task["pid"] = None
if task["status"] == "cancelled":
_persist_task(task)
return
if returncode == 0:
task["status"] = "completed"
run_id = _detect_run_id(output_root, experiment_name, started_at)
task["run_id"] = run_id
else:
task["status"] = "failed"
log_file = output_root / ".backtest_tasks" / f"{task_id}.log"
task["error"] = _log_tail_error(log_file, returncode)
_persist_task(task)
def _build_cmd(config_paths: list[Path], capital: float, start: str | None, end: str | None, year: str | None, no_trades: bool, runs_dir: Path) -> list[str]:
"""Build the paper backtest CLI command."""
cmd = [
sys.executable, "-m", "apps.paper_trader.cli", "backtest",
"--capital", str(capital),
"--output", str(runs_dir),
]
for cp in config_paths:
cmd += ["--config", str(cp)]
if year:
cmd += ["--year", year]
elif start:
cmd += ["--start", start]
if end:
cmd += ["--end", end]
if no_trades:
cmd.append("--no-trades")
return cmd
def _launch_backtest(req: BacktestRequest) -> dict[str, Any]:
"""Build CLI args, launch subprocess, register task."""
project_root = get_project_root()
configs_dir = get_configs_dir()
runs_dir = get_runs_dir()
config_path = configs_dir / f"{req.experiment_name}.json"
if not config_path.exists():
raise HTTPException(status_code=404, detail=f"Experiment config not found: {req.experiment_name}")
task = _make_task(req.experiment_name, req.capital, req.start, req.end, req.year, req.no_trades)
task_id = task["task_id"]
# Log directory
log_dir = runs_dir / ".backtest_tasks"
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / f"{task_id}.log"
# Save params for last-params lookup
params_file = log_dir / f"{task_id}.params.json"
params_file.write_text(json.dumps({
"experiment_name": req.experiment_name,
"capital": req.capital,
"start": req.start,
"end": req.end,
"year": req.year,
"no_trades": req.no_trades,
"created_at": task["created_at"],
}, indent=2))
cmd = _build_cmd([config_path], req.capital, req.start, req.end, req.year, req.no_trades, runs_dir)
with open(log_file, "wb") as log_fp:
proc = subprocess.Popen(
cmd,
stdout=log_fp,
stderr=subprocess.STDOUT,
cwd=str(project_root),
)
with _tasks_lock:
_tasks[task_id] = task
_persist_task(task)
thread = threading.Thread(
target=_watch_process,
args=(task_id, proc, runs_dir),
daemon=True,
)
thread.start()
return task
def _launch_backtest_multi(names: list[str], capital: float, start: str | None, end: str | None, year: str | None, no_trades: bool) -> dict[str, Any]:
"""Launch ONE subprocess with multiple --config flags for batch experiments."""
project_root = get_project_root()
configs_dir = get_configs_dir()
runs_dir = get_runs_dir()
config_paths: list[Path] = []
for name in names:
cp = configs_dir / f"{name}.json"
if not cp.exists():
raise HTTPException(status_code=404, detail=f"Experiment config not found: {name}")
config_paths.append(cp)
display_name = ", ".join(names)
task = _make_task(display_name, capital, start, end, year, no_trades)
task_id = task["task_id"]
log_dir = runs_dir / ".backtest_tasks"
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / f"{task_id}.log"
# Save individual params files so last-params lookup works per experiment
created_at = task["created_at"]
for name in names:
pf = log_dir / f"{task_id}_{name[:30]}.params.json"
pf.write_text(json.dumps({
"experiment_name": name,
"capital": capital,
"start": start,
"end": end,
"year": year,
"no_trades": no_trades,
"created_at": created_at,
}, indent=2))
cmd = _build_cmd(config_paths, capital, start, end, year, no_trades, runs_dir)
with open(log_file, "wb") as log_fp:
proc = subprocess.Popen(
cmd,
stdout=log_fp,
stderr=subprocess.STDOUT,
cwd=str(project_root),
)
with _tasks_lock:
_tasks[task_id] = task
_persist_task(task)
thread = threading.Thread(
target=_watch_process,
args=(task_id, proc, runs_dir),
daemon=True,
)
thread.start()
return task
# ---------------------------------------------------------------------------
# Direct backtest — subprocess (survives server --reload restarts)
# ---------------------------------------------------------------------------
def _watch_direct_process(task_id: str, proc: subprocess.Popen[bytes]) -> None:
"""Background thread: wait for the direct-runner subprocess and update task state."""
with _tasks_lock:
task = _tasks.get(task_id)
if task is None:
return
task["status"] = "running"
task["started_at"] = datetime.now(timezone.utc).isoformat()
task["pid"] = proc.pid
_persist_task(task)
returncode = proc.wait()
with _tasks_lock:
task = _tasks.get(task_id)
if task is None:
return
if task["status"] == "cancelled":
_persist_task(task)
return
task["finished_at"] = datetime.now(timezone.utc).isoformat()
task["pid"] = None
if returncode == 0 and _result_file(task_id).exists():
task["status"] = "completed"
task["has_direct_result"] = True
else:
task["status"] = "failed"
log_file = _log_dir() / f"{task_id}.direct.log"
task["error"] = _log_tail_error(log_file, returncode)
_persist_task(task)
def _launch_direct_backtest(req: BacktestRequest) -> dict[str, Any]:
"""Launch the direct backtest as a subprocess (survives uvicorn --reload)."""
configs_dir = get_configs_dir()
project_root = get_project_root()
config_path = configs_dir / f"{req.experiment_name}.json"
if not config_path.exists():
raise HTTPException(status_code=404, detail=f"Experiment config not found: {req.experiment_name}")
try:
start_date, end_date = _parse_dates(req.start, req.end, req.year)
except (ValueError, TypeError) as exc:
raise HTTPException(status_code=400, detail=f"Invalid date params: {exc}")
task = _make_task(
req.experiment_name, req.capital, req.start, req.end, req.year, req.no_trades,
mode="direct",
)
task_id = task["task_id"]
log_dir = _log_dir()
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / f"{task_id}.direct.log"
cmd = [
sys.executable, "-m", "apps.web.direct_runner",
task_id,
str(config_path),
str(req.capital),
start_date.isoformat(),
end_date.isoformat(),
str(_result_file(task_id)),
]
with open(log_file, "wb") as log_fp:
proc = subprocess.Popen(
cmd,
stdout=log_fp,
stderr=subprocess.STDOUT,
cwd=str(project_root),
)
with _tasks_lock:
_tasks[task_id] = task
_persist_task(task)
thread = threading.Thread(
target=_watch_direct_process,
args=(task_id, proc),
daemon=True,
)
thread.start()
return task
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.post("/submit")
def submit_backtest(req: BacktestRequest) -> dict[str, Any]:
"""Submit a single paper backtest task (CLI subprocess or in-process direct)."""
_ensure_tasks_loaded()
if req.mode == "direct":
return _launch_direct_backtest(req)
return _launch_backtest(req)
@router.post("/submit-batch")
def submit_batch(req: BatchBacktestRequest) -> dict[str, Any]:
"""Submit a single backtest task running all experiments in one subprocess."""
_ensure_tasks_loaded()
if not req.experiment_names:
raise HTTPException(status_code=400, detail="experiment_names is empty")
if len(req.experiment_names) == 1:
single = BacktestRequest(
experiment_name=req.experiment_names[0],
capital=req.capital,
start=req.start,
end=req.end,
year=req.year,
no_trades=req.no_trades,
)
task = _launch_backtest(single)
return {"tasks": [task]}
task = _launch_backtest_multi(
names=req.experiment_names,
capital=req.capital,
start=req.start,
end=req.end,
year=req.year,
no_trades=req.no_trades,
)
return {"tasks": [task]}
@router.get("/tasks")
def list_tasks() -> dict[str, Any]:
"""List all backtest tasks (most recent first)."""
_ensure_tasks_loaded()
with _tasks_lock:
tasks = sorted(
list(_tasks.values()),
key=lambda t: t["created_at"],
reverse=True,
)
return {"tasks": tasks}
@router.get("/tasks/{task_id}")
def get_task(task_id: str) -> dict[str, Any]:
"""Get status of a specific task."""
_ensure_tasks_loaded()
with _tasks_lock:
task = _tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"Task not found: {task_id}")
return task
@router.delete("/tasks/{task_id}")
def cancel_task(task_id: str) -> dict[str, Any]:
"""Cancel a running task or delete a finished task from memory."""
_ensure_tasks_loaded()
with _tasks_lock:
task = _tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"Task not found: {task_id}")
status = task["status"]
pid = task.get("pid")
if status in ("queued", "running"):
task["status"] = "cancelled"
task["finished_at"] = datetime.now(timezone.utc).isoformat()
_persist_task(task)
else:
del _tasks[task_id]
_delete_task_file(task_id)
return {"cancelled": True, "deleted": True}
if pid:
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
pass
return {"cancelled": True} # direct-mode tasks have no PID; thread runs to completion but result is discarded
@router.get("/tasks/{task_id}/log")
def get_task_log(task_id: str) -> dict[str, Any]:
"""Get stdout/stderr log for a task."""
_ensure_tasks_loaded()
with _tasks_lock:
task = _tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"Task not found: {task_id}")
runs_dir = get_runs_dir()
mode = task.get("mode", "cli")
suffix = ".direct.log" if mode == "direct" else ".log"
log_file = runs_dir / ".backtest_tasks" / f"{task_id}{suffix}"
if not log_file.exists():
return {"log": ""}
try:
content = log_file.read_text(errors="replace")
lines = content.splitlines()
return {"log": "\n".join(lines[-200:]), "total_lines": len(lines)}
except Exception as e:
return {"log": f"Error reading log: {e}"}
@router.get("/tasks/{task_id}/direct-result")
def get_direct_result(task_id: str) -> dict[str, Any]:
"""Get the in-process backtest result for a direct-mode task."""
_ensure_tasks_loaded()
with _tasks_lock:
task = _tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"Task not found: {task_id}")
if task.get("mode") != "direct":
raise HTTPException(status_code=400, detail="Task is not a direct-mode task")
if task["status"] != "completed":
raise HTTPException(status_code=409, detail=f"Task not completed: {task['status']}")
result = _load_direct_result(task_id)
if result is None:
raise HTTPException(status_code=404, detail="Direct result not found on disk")
return result
@router.get("/last-params/{experiment_name}")
def get_last_params(experiment_name: str) -> dict[str, Any]:
"""Return parameters from the most recent run of this experiment."""
runs_dir = get_runs_dir()
log_dir = runs_dir / ".backtest_tasks"
if not log_dir.exists():
return {}
# Find most recent .params.json for this experiment
best: tuple[float, Path] | None = None
for f in log_dir.glob("*.params.json"):
try:
data = json.loads(f.read_text())
if data.get("experiment_name") != experiment_name:
continue
mtime = f.stat().st_mtime
if best is None or mtime > best[0]:
best = (mtime, f)
except Exception:
continue
if best is None:
return {}
try:
data = json.loads(best[1].read_text())
return {
"capital": data.get("capital"),
"start": data.get("start"),
"end": data.get("end"),
"year": data.get("year"),
}
except Exception:
return {}

File diff suppressed because one or more lines are too long

@ -0,0 +1,45 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Sidebar } from './components/layout/Sidebar';
import { DashboardPage } from './pages/Dashboard';
import { LeaderboardPage } from './pages/Leaderboard';
import { ExperimentsPage } from './pages/Experiments';
import { ExperimentDetailPage } from './pages/ExperimentDetail';
import { ExperimentDiffPage } from './pages/ExperimentDiff';
import { LineagePage } from './pages/Lineage';
import { BacktestPage, BacktestTaskDetailPage, BacktestResultsPage, BacktestDirectResultsPage } from './pages/Backtest';
import { PaperTradingPage } from './pages/PaperTrading';
const qc = new QueryClient({
defaultOptions: {
queries: { staleTime: 30_000, retry: 1 },
},
});
export default function App() {
return (
<QueryClientProvider client={qc}>
<BrowserRouter>
<div style={{ display: 'flex', minHeight: '100vh', width: '100%' }}>
<Sidebar />
<main style={{ flex: 1, marginLeft: 'var(--sidebar-w, 230px)', minHeight: '100vh', overflowY: 'auto' }}>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/leaderboard" element={<LeaderboardPage />} />
<Route path="/experiments" element={<ExperimentsPage />} />
<Route path="/experiments/:name" element={<ExperimentDetailPage />} />
<Route path="/experiments/:name/diff" element={<ExperimentDiffPage />} />
<Route path="/experiments/:name/diff/:other" element={<ExperimentDiffPage />} />
<Route path="/lineage/:name" element={<LineagePage />} />
<Route path="/backtest" element={<BacktestPage />} />
<Route path="/backtest/tasks/:taskId" element={<BacktestTaskDetailPage />} />
<Route path="/backtest/results/:runId" element={<BacktestResultsPage />} />
<Route path="/backtest/direct-results/:taskId" element={<BacktestDirectResultsPage />} />
<Route path="/paper" element={<PaperTradingPage />} />
</Routes>
</main>
</div>
</BrowserRouter>
</QueryClientProvider>
);
}

@ -123,6 +123,7 @@ export function BacktestPage() {
const [endDate, setEndDate] = useState('');
const [capital, setCapital] = useState('10000');
const [noTrades, setNoTrades] = useState(false);
const [directMode, setDirectMode] = useState(false);
// Directly apply dup state to form — used both inline and on mount
const applyDup = (s: DupState) => {
@ -199,13 +200,15 @@ export function BacktestPage() {
mutationFn: async () => {
const params = {
capital: parseFloat(capital) || 10000,
year: dateMode === 'year' ? year || null : null,
start: dateMode === 'range' ? startDate || null : null,
year: null,
start: dateMode === 'year'
? (year ? `${year}-01-01` : null)
: (startDate || null),
end: dateMode === 'range' ? endDate || null : null,
no_trades: noTrades,
};
if (selectedExps.length === 1) {
await backtestApi.submit({ experiment_name: selectedExps[0], ...params });
await backtestApi.submit({ experiment_name: selectedExps[0], ...params, mode: directMode ? 'direct' : 'cli' });
} else {
await backtestApi.submitBatch(selectedExps, params);
}
@ -391,9 +394,9 @@ export function BacktestPage() {
{dateMode === 'year' ? (
<div style={{ gridColumn: 'span 2' }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, letterSpacing: '0.07em', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: 5 }}>
Year (YYYY)
From Year ( today)
</div>
<input type="text" placeholder="e.g. 2023" value={year} onChange={e => setYear(e.target.value)} style={inputStyle} />
<input type="text" placeholder="e.g. 2022" value={year} onChange={e => setYear(e.target.value)} style={inputStyle} />
</div>
) : (
<>
@ -439,6 +442,18 @@ export function BacktestPage() {
<input type="checkbox" checked={noTrades} onChange={e => setNoTrades(e.target.checked)} style={{ width: 13, height: 13 }} />
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text3)' }}>--no-trades</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: selectedExps.length === 1 ? 'pointer' : 'not-allowed', opacity: selectedExps.length === 1 ? 1 : 0.4 }}>
<input
type="checkbox"
checked={directMode}
onChange={e => setDirectMode(e.target.checked)}
disabled={selectedExps.length !== 1}
style={{ width: 13, height: 13 }}
/>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: directMode ? 'var(--orange)' : 'var(--text3)' }}>
Direct Mode (in-process)
</span>
</label>
</div>
{submitMutation.error && (
@ -539,6 +554,7 @@ export function BacktestPage() {
{filteredTasks.map((task, i) => {
const isActive = task.status === 'running' || task.status === 'queued';
const isDone = !isActive;
const modeColor = task.mode === 'direct' ? 'var(--orange)' : 'var(--cyan)';
return (
<tr
key={task.task_id}
@ -550,8 +566,21 @@ export function BacktestPage() {
onMouseEnter={e => (e.currentTarget.style.background = 'var(--cyan-dim)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
<td style={{ padding: '11px 14px', width: 100 }}>
<StatusDot status={task.status} />
<td style={{ padding: '11px 14px', width: 120 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<StatusDot status={task.status} />
{task.mode && (
<span style={{
fontFamily: 'var(--font-mono)', fontSize: 10,
color: modeColor,
background: `color-mix(in srgb, ${modeColor} 12%, transparent)`,
border: `1px solid color-mix(in srgb, ${modeColor} 25%, transparent)`,
borderRadius: 3, padding: '1px 5px', width: 'fit-content',
}}>
{task.mode === 'direct' ? 'DIRECT' : 'CLI'}
</span>
)}
</div>
</td>
<td style={{ padding: '11px 14px', maxWidth: 260 }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--text1)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
@ -602,6 +631,14 @@ export function BacktestPage() {
<ExternalLink size={11} /> Results
</button>
)}
{task.status === 'completed' && task.has_direct_result && (
<button
onClick={() => navigate(`/backtest/direct-results/${task.task_id}`)}
style={{ display: 'flex', alignItems: 'center', gap: 3, padding: '3px 8px', fontSize: 12, borderRadius: 5, cursor: 'pointer', background: 'var(--green-dim)', border: '1px solid rgba(5,150,105,0.22)', color: 'var(--green)', fontFamily: 'var(--font-mono)' }}
>
<ExternalLink size={11} /> Results
</button>
)}
{isDone && (
<button
onClick={() => deleteMutation.mutate(task.task_id)}
@ -741,43 +778,440 @@ export function BacktestTaskDetailPage() {
</div>
</div>
{/* Error banner */}
{task.status === 'failed' && task.error && (
{/* Error banner (CLI mode only — direct mode shows error inline) */}
{task.mode !== 'direct' && task.status === 'failed' && task.error && (
<div style={{ marginBottom: 20, padding: '12px 16px', background: 'var(--red-dim)', border: '1px solid rgba(220,38,38,0.2)', borderRadius: 9, fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--red)', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
{task.error}
</div>
)}
{/* Terminal log */}
<div style={{ background: '#0d1117', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 12, overflow: 'hidden', boxShadow: '0 4px 20px rgba(0,0,0,0.3)' }}>
{/* Terminal titlebar */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 16px', background: '#161b22', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<div style={{ display: 'flex', gap: 6 }}>
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#ff5f57' }} />
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#febc2e' }} />
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#28c840' }} />
{task.mode === 'direct' ? (
<DirectModeTaskView task={task} logData={logData} />
) : (
/* Terminal log (CLI mode) */
<div style={{ background: '#0d1117', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 12, overflow: 'hidden', boxShadow: '0 4px 20px rgba(0,0,0,0.3)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 16px', background: '#161b22', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<div style={{ display: 'flex', gap: 6 }}>
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#ff5f57' }} />
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#febc2e' }} />
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#28c840' }} />
</div>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'rgba(255,255,255,0.4)', marginLeft: 8 }}>
{task.task_id}.log
</span>
{isActive && (
<span style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--cyan)', animation: 'pulse 1.5s ease-in-out infinite' }}>
live
</span>
)}
{logData?.total_lines != null && (
<span style={{ marginLeft: isActive ? 8 : 'auto', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'rgba(255,255,255,0.3)' }}>
{logData.total_lines} lines
</span>
)}
</div>
<div className="terminal-scroll" style={{ padding: '16px 20px', minHeight: 320, maxHeight: 'calc(100vh - 320px)', overflowY: 'auto' }}>
{logData?.log
? <TerminalLog text={logData.log} large />
: <span style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: 'rgba(255,255,255,0.25)', fontStyle: 'italic' }}>(waiting for output...)</span>
}
</div>
</div>
)}
</div>
);
}
// ── Direct Mode Task View ────────────────────────────────────────────────────
function DirectModeTaskView({
task,
logData,
}: {
task: BacktestTask;
logData: { log: string; total_lines?: number } | undefined;
}) {
const [showLog, setShowLog] = useState(false);
const [tab, setTab] = useState<'summary' | 'equity' | 'trades'>('summary');
const [tradePage, setTradePage] = useState(0);
const TRADE_PAGE_SIZE = 50;
const [tradeSort, setTradeSort] = useState<{ col: string; dir: 'asc' | 'desc' }>({ col: '_no', dir: 'asc' });
const [tradeFilter, setTradeFilter] = useState({ symbol: '', engine: '', reason: '', outcome: 'all' as 'all' | 'win' | 'loss' });
const { data: result, isLoading } = useQuery({
queryKey: ['direct-result', task.task_id],
queryFn: () => backtestApi.directResult(task.task_id),
enabled: task.status === 'completed' && !!task.has_direct_result,
});
const logSection = (
<div style={{ marginTop: 16 }}>
<button
onClick={() => setShowLog(v => !v)}
style={{
display: 'flex', alignItems: 'center', gap: 6,
background: 'none', border: 'none', cursor: 'pointer',
fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text3)', padding: '4px 0',
}}
>
<ChevronRight size={12} style={{ transform: showLog ? 'rotate(90deg)' : 'none', transition: 'transform 0.15s' }} />
{showLog ? 'Hide log' : 'Show log'}
{logData?.total_lines != null && ` (${logData.total_lines} lines)`}
</button>
{showLog && (
<div style={{ marginTop: 8, background: '#0d1117', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 10, overflow: 'hidden' }}>
<div className="terminal-scroll" style={{ padding: '14px 18px', maxHeight: 320, overflowY: 'auto' }}>
{logData?.log
? <TerminalLog text={logData.log} large />
: <span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'rgba(255,255,255,0.25)', fontStyle: 'italic' }}>(no log)</span>
}
</div>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'rgba(255,255,255,0.4)', marginLeft: 8 }}>
{task.task_id}.log
</div>
)}
</div>
);
// Auto-scroll ref for live log
const liveLogRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if ((task.status === 'queued' || task.status === 'running') && liveLogRef.current) {
liveLogRef.current.scrollTop = liveLogRef.current.scrollHeight;
}
}, [logData?.log, task.status]);
if (task.status === 'queued' || task.status === 'running') {
return (
<div>
{/* Status bar */}
<div style={{
display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10,
padding: '10px 16px', background: 'var(--bg1)', border: '1px solid var(--border)',
borderRadius: 10,
}}>
<Loader size={14} style={{ color: 'var(--orange)', animation: 'spin 1s linear infinite', flexShrink: 0 }} />
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 13, fontWeight: 600, color: 'var(--orange)' }}>
{task.status === 'queued' ? 'Queued...' : 'Running backtest...'}
</span>
{isActive && (
<span style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--cyan)', animation: 'pulse 1.5s ease-in-out infinite' }}>
live
</span>
)}
{logData?.total_lines != null && (
<span style={{ marginLeft: isActive ? 8 : 'auto', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'rgba(255,255,255,0.3)' }}>
{logData.total_lines} lines
<span style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--orange)', animation: 'pulse 1.5s ease-in-out infinite' }}> live</span>
</div>
{/* Live terminal */}
<div style={{ background: '#0d1117', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 12, overflow: 'hidden', boxShadow: '0 4px 20px rgba(0,0,0,0.3)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 16px', background: '#161b22', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<div style={{ display: 'flex', gap: 6 }}>
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#ff5f57' }} />
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#febc2e' }} />
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#28c840' }} />
</div>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'rgba(255,255,255,0.4)', marginLeft: 8 }}>
{task.task_id}.direct.log
</span>
)}
{logData?.total_lines != null && (
<span style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'rgba(255,255,255,0.3)' }}>
{logData.total_lines} lines
</span>
)}
</div>
<div
ref={liveLogRef}
className="terminal-scroll"
style={{ padding: '16px 20px', minHeight: 200, maxHeight: 420, overflowY: 'auto' }}
>
{logData?.log ? (
<div>
<TerminalLog text={logData.log} large />
<span style={{
display: 'inline-block', width: 8, height: 15,
background: 'var(--orange)', opacity: 0.85,
animation: 'pulse 1s ease-in-out infinite',
marginLeft: 4, verticalAlign: 'text-bottom', borderRadius: 1,
}} />
</div>
) : (
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: 'rgba(255,255,255,0.3)', fontStyle: 'italic' }}>
Initializing...
</span>
)}
</div>
</div>
</div>
);
}
if (task.status === 'completed' && isLoading) {
return <div style={{ padding: 32, textAlign: 'center' }}><Loading /></div>;
}
if (task.status !== 'completed' || !result) {
// Failed/cancelled: show error + collapsible log
return (
<div>
{task.error && (
<div style={{ marginBottom: 12, padding: '12px 16px', background: 'var(--red-dim)', border: '1px solid rgba(220,38,38,0.2)', borderRadius: 9, fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--red)', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
{task.error}
</div>
)}
{logSection}
</div>
);
}
// Completed with result: full inline display
const s = result.summary;
const mb = result.metrics_bundle as Record<string, unknown>;
const returnPct = s.return_pct ?? 0;
const chartData = (result.equity_curve ?? []).map(row => ({ date: row.date, equity: row.equity }));
const allTrades = ((result.trades ?? []) as Record<string, unknown>[]).map((t, i) => ({ ...t, _no: i + 1 } as Record<string, unknown>));
const totalTrades = allTrades.length;
// unique filter options
const engineOptions = Array.from(new Set(allTrades.map(t => String(t.engine_id ?? '')))).filter(Boolean).sort();
const reasonOptions = Array.from(new Set(allTrades.map(t => String(t.reason ?? t.exit_reason ?? '')))).filter(v => v !== '—' && v !== '').sort();
// apply filters
const filteredTrades = allTrades.filter(t => {
if (tradeFilter.symbol && !String(t.symbol ?? '').toLowerCase().includes(tradeFilter.symbol.toLowerCase())) return false;
if (tradeFilter.engine && String(t.engine_id ?? '') !== tradeFilter.engine) return false;
if (tradeFilter.reason && String(t.reason ?? t.exit_reason ?? '') !== tradeFilter.reason) return false;
if (tradeFilter.outcome === 'win' && Number(t.pnl ?? 0) <= 0) return false;
if (tradeFilter.outcome === 'loss' && Number(t.pnl ?? 0) >= 0) return false;
return true;
});
// apply sort
const sortedTrades = [...filteredTrades].sort((a, b) => {
const dir = tradeSort.dir === 'asc' ? 1 : -1;
const col = tradeSort.col;
if (col === '_no' || col === 'pnl' || col === 'entry_price' || col === 'exit_price') {
return (Number(a[col] ?? 0) - Number(b[col] ?? 0)) * dir;
}
return String(a[col] ?? '').localeCompare(String(b[col] ?? '')) * dir;
});
const filteredTotal = sortedTrades.length;
const totalPages = Math.ceil(filteredTotal / TRADE_PAGE_SIZE);
const pageTrades = sortedTrades.slice(tradePage * TRADE_PAGE_SIZE, (tradePage + 1) * TRADE_PAGE_SIZE);
const filteredPnl = filteredTrades.reduce((sum, t) => sum + Number(t.pnl ?? 0), 0);
const toggleSort = (col: string) => {
setTradeSort(s => s.col === col ? { col, dir: s.dir === 'asc' ? 'desc' : 'asc' } : { col, dir: 'asc' });
setTradePage(0);
};
const sortIcon = (col: string) => {
if (tradeSort.col !== col) return <span style={{ opacity: 0.2, fontSize: 10 }}></span>;
return <span style={{ color: 'var(--orange)', fontSize: 10 }}>{tradeSort.dir === 'asc' ? '▲' : '▼'}</span>;
};
const TABS = [
{ id: 'summary' as const, label: 'Summary' },
{ id: 'equity' as const, label: 'Equity Curve' },
{ id: 'trades' as const, label: `Trades (${filteredTotal}/${totalTrades})` },
];
return (
<div>
{/* Metric cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: 10, marginBottom: 20 }}>
<MetricCard label="Total Return" value={fmtPct(returnPct)} color={returnPct >= 0 ? 'var(--green)' : 'var(--red)'} />
{mb?.annualized_return_pct != null && (
<MetricCard label="Ann. Return" value={fmtPct(mb.annualized_return_pct)} color={Number(mb.annualized_return_pct) >= 0 ? 'var(--green)' : 'var(--red)'} />
)}
<MetricCard label="Max Drawdown" value={`-${(s.max_dd_pct ?? 0).toFixed(2)}%`} color="var(--red)" />
<MetricCard label="Sharpe" value={(s.sharpe ?? 0).toFixed(2)} color="var(--cyan)" />
{mb?.profit_factor != null && <MetricCard label="Profit Factor" value={fmt(mb.profit_factor, 2)} color="var(--gold)" />}
<MetricCard label="Win Rate" value={`${(s.win_rate ?? 0).toFixed(1)}%`} />
<MetricCard label="Trades" value={String(s.trade_count ?? 0)} />
<MetricCard label="Final Equity" value={`$${(s.final_equity ?? 0).toLocaleString(undefined, { maximumFractionDigits: 0 })}`} />
</div>
{/* Tabs */}
<div style={{ display: 'flex', gap: 2, marginBottom: 20, borderBottom: '1px solid var(--border)' }}>
{TABS.map(t => (
<button key={t.id} onClick={() => { setTab(t.id); setTradePage(0); }} style={{
padding: '9px 18px', fontSize: 15, fontWeight: 500, border: 'none', background: 'none', cursor: 'pointer',
color: tab === t.id ? 'var(--text1)' : 'var(--text3)',
borderBottom: tab === t.id ? '2px solid var(--orange)' : '2px solid transparent',
}}>{t.label}</button>
))}
</div>
{/* Summary tab */}
{tab === 'summary' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
{[
{ title: 'Trade Stats', rows: [
['Total trades', s.trade_count ?? '—'],
['Win rate', fmtPct(s.win_rate)],
['Avg win', mb?.avg_win_pct != null ? fmtPct(mb.avg_win_pct) : '—'],
['Avg loss', mb?.avg_loss_pct != null ? fmtPct(mb.avg_loss_pct) : '—'],
['Profit factor', mb?.profit_factor != null ? fmt(mb.profit_factor, 2) : '—'],
['Expectancy R', mb?.expectancy_r != null ? fmt(mb.expectancy_r, 3) : '—'],
['Avg hold days', mb?.avg_holding_days != null ? fmt(mb.avg_holding_days) : '—'],
['Stop exit rate', mb?.stop_exit_rate != null ? fmtPct(mb.stop_exit_rate) : '—'],
]},
{ title: 'Portfolio Stats', rows: [
['Total return', fmtPct(returnPct)],
['Ann. return', mb?.annualized_return_pct != null ? fmtPct(mb.annualized_return_pct) : '—'],
['Max drawdown', `-${(s.max_dd_pct ?? 0).toFixed(2)}%`],
['Sharpe ratio', (s.sharpe ?? 0).toFixed(2)],
['Sortino ratio', mb?.sortino_ratio != null ? fmt(mb.sortino_ratio, 2) : '—'],
['Calmar ratio', mb?.calmar_ratio != null ? fmt(mb.calmar_ratio, 2) : '—'],
['Avg exposure', mb?.avg_gross_exposure_pct != null ? fmtPct(mb.avg_gross_exposure_pct) : '—'],
['Days in market', mb?.days_in_market_pct != null ? fmtPct(mb.days_in_market_pct) : '—'],
]},
].map(({ title, rows }) => (
<div key={title} style={{ background: 'var(--bg1)', border: '1px solid var(--border)', borderRadius: 12, padding: '16px 18px' }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.07em', marginBottom: 12 }}>{title}</div>
{rows.map(([k, v]) => (
<div key={String(k)} style={{ display: 'flex', justifyContent: 'space-between', padding: '5px 0', borderBottom: '1px solid var(--border)' }}>
<span style={{ color: 'var(--text3)', fontFamily: 'var(--font-mono)', fontSize: 13 }}>{String(k)}</span>
<span style={{ color: 'var(--text1)', fontFamily: 'var(--font-mono)', fontSize: 13 }}>{String(v)}</span>
</div>
))}
</div>
))}
</div>
<div className="terminal-scroll" style={{ padding: '16px 20px', minHeight: 320, maxHeight: 'calc(100vh - 320px)', overflowY: 'auto' }}>
{logData?.log
? <TerminalLog text={logData.log} large />
: <span style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: 'rgba(255,255,255,0.25)', fontStyle: 'italic' }}>(waiting for output...)</span>
)}
{/* Equity Curve tab */}
{tab === 'equity' && (
<div style={{ background: 'var(--bg1)', border: '1px solid var(--border)', borderRadius: 12, padding: '16px 18px' }}>
{chartData.length === 0
? <div style={{ textAlign: 'center', color: 'var(--text3)', fontFamily: 'var(--font-mono)', fontSize: 14, padding: '40px 0' }}>No equity curve data</div>
: <>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.07em', marginBottom: 16 }}>Equity Curve</div>
<ResponsiveContainer width="100%" height={280}>
<LineChart data={chartData} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="date" tick={{ fontFamily: 'var(--font-mono)', fontSize: 11, fill: 'var(--text3)' }} tickLine={false} minTickGap={40} />
<YAxis tick={{ fontFamily: 'var(--font-mono)', fontSize: 11, fill: 'var(--text3)' }} tickLine={false} axisLine={false} tickFormatter={v => `$${(v / 1000).toFixed(0)}k`} />
<Tooltip contentStyle={{ background: 'var(--bg1)', border: '1px solid var(--border)', borderRadius: 8, fontFamily: 'var(--font-mono)', fontSize: 12 }} formatter={(v) => [`$${Number(v).toLocaleString()}`, 'Equity']} />
<Line type="monotone" dataKey="equity" stroke="var(--orange)" strokeWidth={1.5} dot={false} />
</LineChart>
</ResponsiveContainer>
</>
}
</div>
</div>
)}
{/* Trades tab */}
{tab === 'trades' && (
<div>
{/* Filter bar */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 10, padding: '10px 14px', background: 'var(--bg1)', border: '1px solid var(--border)', borderRadius: 10 }}>
<input
type="text"
placeholder="Symbol..."
value={tradeFilter.symbol}
onChange={e => { setTradeFilter(f => ({ ...f, symbol: e.target.value })); setTradePage(0); }}
style={{ fontFamily: 'var(--font-mono)', fontSize: 12, background: 'var(--bg2)', border: '1px solid var(--border-md)', borderRadius: 6, padding: '5px 10px', color: 'var(--text1)', outline: 'none', width: 120 }}
/>
<select
value={tradeFilter.engine}
onChange={e => { setTradeFilter(f => ({ ...f, engine: e.target.value })); setTradePage(0); }}
style={{ fontFamily: 'var(--font-mono)', fontSize: 12, background: 'var(--bg2)', border: '1px solid var(--border-md)', borderRadius: 6, padding: '5px 10px', color: 'var(--text2)', outline: 'none', cursor: 'pointer' }}
>
<option value="">All Engines</option>
{engineOptions.map(e => <option key={e} value={e}>{e}</option>)}
</select>
<select
value={tradeFilter.reason}
onChange={e => { setTradeFilter(f => ({ ...f, reason: e.target.value })); setTradePage(0); }}
style={{ fontFamily: 'var(--font-mono)', fontSize: 12, background: 'var(--bg2)', border: '1px solid var(--border-md)', borderRadius: 6, padding: '5px 10px', color: 'var(--text2)', outline: 'none', cursor: 'pointer' }}
>
<option value="">All Exit Reasons</option>
{reasonOptions.map(r => <option key={r} value={r}>{r}</option>)}
</select>
<div style={{ display: 'flex', border: '1px solid var(--border-md)', borderRadius: 6, overflow: 'hidden' }}>
{(['all', 'win', 'loss'] as const).map(o => (
<button key={o} onClick={() => { setTradeFilter(f => ({ ...f, outcome: o })); setTradePage(0); }} style={{
padding: '5px 12px', fontFamily: 'var(--font-mono)', fontSize: 12, border: 'none', cursor: 'pointer',
background: tradeFilter.outcome === o ? (o === 'win' ? 'color-mix(in srgb, var(--green) 18%, transparent)' : o === 'loss' ? 'color-mix(in srgb, var(--red) 18%, transparent)' : 'var(--bg2)') : 'var(--bg1)',
color: tradeFilter.outcome === o ? (o === 'win' ? 'var(--green)' : o === 'loss' ? 'var(--red)' : 'var(--text1)') : 'var(--text3)',
fontWeight: tradeFilter.outcome === o ? 600 : 400,
}}>
{o === 'all' ? 'All' : o === 'win' ? 'Win' : 'Loss'}
</button>
))}
</div>
{(tradeFilter.symbol || tradeFilter.engine || tradeFilter.reason || tradeFilter.outcome !== 'all') && (
<button onClick={() => { setTradeFilter({ symbol: '', engine: '', reason: '', outcome: 'all' }); setTradePage(0); }} style={{ fontFamily: 'var(--font-mono)', fontSize: 12, background: 'none', border: '1px solid var(--border-md)', borderRadius: 6, padding: '5px 10px', color: 'var(--text3)', cursor: 'pointer' }}>
Clear
</button>
)}
<div style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text3)', display: 'flex', alignItems: 'center', gap: 12 }}>
<span>{filteredTotal} of {totalTrades} trades</span>
<span style={{ color: filteredPnl >= 0 ? 'var(--green)' : 'var(--red)', fontWeight: 600 }}>
PnL {filteredPnl >= 0 ? '+' : ''}${filteredPnl.toFixed(0)}
</span>
</div>
</div>
{sortedTrades.length === 0
? <div style={{ textAlign: 'center', color: 'var(--text3)', fontFamily: 'var(--font-mono)', fontSize: 14, padding: '40px 0' }}>
{totalTrades === 0 ? 'No trades' : 'No trades match filters'}
</div>
: <>
<div style={{ background: 'var(--bg1)', border: '1px solid var(--border)', borderRadius: 12, overflow: 'hidden' }}>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border-md)', background: 'var(--bg2)' }}>
{[
{ label: 'No.', col: '_no' },
{ label: 'Symbol', col: 'symbol' },
{ label: 'Engine', col: 'engine_id' },
{ label: 'Entry Date', col: 'entry_date' },
{ label: 'Exit Date', col: 'exit_date' },
{ label: 'Entry $', col: 'entry_price' },
{ label: 'Exit $', col: 'exit_price' },
{ label: 'PnL', col: 'pnl' },
{ label: 'Exit Reason', col: 'reason' },
].map(({ label, col }) => (
<th key={col} onClick={() => toggleSort(col)} style={{ padding: '11px 14px', fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 500, letterSpacing: '0.06em', textTransform: 'uppercase', color: tradeSort.col === col ? 'var(--orange)' : 'var(--text3)', textAlign: 'left', whiteSpace: 'nowrap', cursor: 'pointer', userSelect: 'none' }}>
{label} {sortIcon(col)}
</th>
))}
</tr>
</thead>
<tbody>
{pageTrades.map((t, i) => {
const pnl = Number(t.pnl ?? 0);
return (
<tr key={i} style={{ borderBottom: i < pageTrades.length - 1 ? '1px solid var(--border)' : 'none', background: i % 2 === 1 ? 'rgba(0,0,0,0.018)' : 'transparent' }}>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text3)', textAlign: 'right', width: 40 }}>{String(t._no ?? '')}</td>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--text1)', fontWeight: 500 }}>{String(t.symbol ?? '—')}</td>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text3)' }}>{String(t.engine_id ?? '—')}</td>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text3)' }}>{String(t.entry_date ?? '—')}</td>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text3)' }}>{String(t.exit_date ?? '—')}</td>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--text2)' }}>{t.entry_price != null ? `$${Number(t.entry_price).toFixed(2)}` : '—'}</td>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--text2)' }}>{t.exit_price != null ? `$${Number(t.exit_price).toFixed(2)}` : '—'}</td>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 13, fontWeight: 600, color: pnl >= 0 ? 'var(--green)' : 'var(--red)' }}>{pnl >= 0 ? '+' : ''}${pnl.toFixed(0)}</td>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text3)' }}>{String(t.reason ?? t.exit_reason ?? '—')}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{totalPages > 1 && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, marginTop: 14, fontFamily: 'var(--font-mono)', fontSize: 13 }}>
<button onClick={() => setTradePage(p => Math.max(0, p - 1))} disabled={tradePage === 0} style={{ padding: '5px 14px', borderRadius: 6, border: '1px solid var(--border-md)', background: 'none', cursor: tradePage === 0 ? 'default' : 'pointer', color: tradePage === 0 ? 'var(--text3)' : 'var(--text2)' }}> Prev</button>
<span style={{ color: 'var(--text3)' }}>{tradePage + 1} / {totalPages}</span>
<button onClick={() => setTradePage(p => Math.min(totalPages - 1, p + 1))} disabled={tradePage >= totalPages - 1} style={{ padding: '5px 14px', borderRadius: 6, border: '1px solid var(--border-md)', background: 'none', cursor: tradePage >= totalPages - 1 ? 'default' : 'pointer', color: tradePage >= totalPages - 1 ? 'var(--text3)' : 'var(--text2)' }}>Next </button>
</div>
)}
</>
}
</div>
)}
{/* Collapsible log */}
{logSection}
</div>
);
}
@ -1120,3 +1554,257 @@ export function BacktestResultsPage() {
</div>
);
}
// ── Direct Backtest Results Page ─────────────────────────────────────────────
type DirectResultTab = 'summary' | 'equity' | 'trades';
export function BacktestDirectResultsPage() {
const { taskId } = useParams<{ taskId: string }>();
const navigate = useNavigate();
const [tab, setTab] = useState<DirectResultTab>('summary');
const [tradePage, setTradePage] = useState(0);
const TRADE_PAGE_SIZE = 50;
const { data: task } = useQuery({
queryKey: ['backtest-task', taskId],
queryFn: () => backtestApi.task(taskId!),
enabled: !!taskId,
staleTime: 60_000,
});
const { data: result, isLoading } = useQuery({
queryKey: ['direct-result', taskId],
queryFn: () => backtestApi.directResult(taskId!),
enabled: !!taskId,
});
if (isLoading || !result) return <div style={{ padding: 32 }}><Loading /></div>;
const s = result.summary;
const mb = result.metrics_bundle as Record<string, unknown>;
const returnPct = s.return_pct ?? 0;
const chartData = (result.equity_curve ?? []).map(row => ({
date: row.date,
equity: row.equity,
}));
const allTrades = (result.trades ?? []) as Record<string, unknown>[];
const totalTrades = allTrades.length;
const totalPages = Math.ceil(totalTrades / TRADE_PAGE_SIZE);
const pageTrades = allTrades.slice(tradePage * TRADE_PAGE_SIZE, (tradePage + 1) * TRADE_PAGE_SIZE);
const TABS: { id: DirectResultTab; label: string }[] = [
{ id: 'summary', label: 'Summary' },
{ id: 'equity', label: 'Equity Curve' },
{ id: 'trades', label: `Trade Blotter (${totalTrades})` },
];
return (
<div style={{ padding: '36px 40px' }} className="fade-up">
{/* Breadcrumb */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 18, fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--text3)' }}>
<button
onClick={() => navigate('/backtest')}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text3)', fontFamily: 'var(--font-mono)', fontSize: 13, padding: 0, display: 'flex', alignItems: 'center', gap: 4 }}
>
<ArrowLeft size={12} /> backtest
</button>
<ChevronRight size={13} />
{task && (
<>
<button
onClick={() => navigate(`/backtest/tasks/${taskId}`)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text3)', fontFamily: 'var(--font-mono)', fontSize: 13, padding: 0 }}
>
{task.experiment_name}
</button>
<ChevronRight size={13} />
</>
)}
<span style={{ color: 'var(--text2)' }}>{taskId}</span>
</div>
{/* Header */}
<div style={{ marginBottom: 24 }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--orange)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 4 }}>
direct mode result
</div>
<h1 style={{ fontSize: 24, fontWeight: 700, color: 'var(--text1)', letterSpacing: '-0.02em' }}>
{result.session_name}
</h1>
</div>
{/* Metric cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: 10, marginBottom: 24 }}>
<MetricCard label="Total Return" value={fmtPct(returnPct)} color={returnPct >= 0 ? 'var(--green)' : 'var(--red)'} />
{mb?.annualized_return_pct != null && (
<MetricCard label="Ann. Return" value={fmtPct(mb.annualized_return_pct)} color={Number(mb.annualized_return_pct) >= 0 ? 'var(--green)' : 'var(--red)'} />
)}
<MetricCard label="Max Drawdown" value={`-${(s.max_dd_pct ?? 0).toFixed(2)}%`} color="var(--red)" />
<MetricCard label="Sharpe" value={(s.sharpe ?? 0).toFixed(2)} color="var(--cyan)" />
{mb?.profit_factor != null && (
<MetricCard label="Profit Factor" value={fmt(mb.profit_factor, 2)} color="var(--gold)" />
)}
<MetricCard label="Win Rate" value={`${(s.win_rate ?? 0).toFixed(1)}%`} />
<MetricCard label="Trades" value={String(s.trade_count ?? 0)} />
<MetricCard label="Final Equity" value={`$${(s.final_equity ?? 0).toLocaleString(undefined, { maximumFractionDigits: 0 })}`} />
</div>
{/* Tabs */}
<div style={{ display: 'flex', gap: 2, marginBottom: 20, borderBottom: '1px solid var(--border)' }}>
{TABS.map(t => (
<button
key={t.id}
onClick={() => setTab(t.id)}
style={{
padding: '9px 18px', fontSize: 15, fontWeight: 500, border: 'none',
background: 'none', cursor: 'pointer', transition: 'all 0.12s',
color: tab === t.id ? 'var(--text1)' : 'var(--text3)',
borderBottom: tab === t.id ? '2px solid var(--orange)' : '2px solid transparent',
}}
>
{t.label}
</button>
))}
</div>
{/* Summary tab */}
{tab === 'summary' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<div style={{ background: 'var(--bg1)', border: '1px solid var(--border)', borderRadius: 12, padding: '16px 18px' }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.07em', marginBottom: 12 }}>Trade Stats</div>
{[
['Total trades', s.trade_count ?? '—'],
['Win rate', fmtPct(s.win_rate)],
['Avg win', mb?.avg_win_pct != null ? fmtPct(mb.avg_win_pct) : '—'],
['Avg loss', mb?.avg_loss_pct != null ? fmtPct(mb.avg_loss_pct) : '—'],
['Profit factor', mb?.profit_factor != null ? fmt(mb.profit_factor, 2) : '—'],
['Expectancy R', mb?.expectancy_r != null ? fmt(mb.expectancy_r, 3) : '—'],
['Avg hold days', mb?.avg_holding_days != null ? fmt(mb.avg_holding_days) : '—'],
['Stop exit rate', mb?.stop_exit_rate != null ? fmtPct(mb.stop_exit_rate) : '—'],
['Target exit rate', mb?.target_exit_rate != null ? fmtPct(mb.target_exit_rate) : '—'],
].map(([k, v]) => (
<div key={String(k)} style={{ display: 'flex', justifyContent: 'space-between', padding: '5px 0', borderBottom: '1px solid var(--border)', fontSize: 14 }}>
<span style={{ color: 'var(--text3)', fontFamily: 'var(--font-mono)', fontSize: 13 }}>{String(k)}</span>
<span style={{ color: 'var(--text1)', fontFamily: 'var(--font-mono)', fontSize: 13 }}>{String(v)}</span>
</div>
))}
</div>
<div style={{ background: 'var(--bg1)', border: '1px solid var(--border)', borderRadius: 12, padding: '16px 18px' }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.07em', marginBottom: 12 }}>Portfolio Stats</div>
{[
['Total return', fmtPct(returnPct)],
['Ann. return', mb?.annualized_return_pct != null ? fmtPct(mb.annualized_return_pct) : '—'],
['Max drawdown', fmtPct(-(s.max_dd_pct ?? 0))],
['Sharpe ratio', (s.sharpe ?? 0).toFixed(2)],
['Sortino ratio', mb?.sortino_ratio != null ? fmt(mb.sortino_ratio, 2) : '—'],
['Calmar ratio', mb?.calmar_ratio != null ? fmt(mb.calmar_ratio, 2) : '—'],
['Avg exposure', mb?.avg_gross_exposure_pct != null ? fmtPct(mb.avg_gross_exposure_pct) : '—'],
['Days in market', mb?.days_in_market_pct != null ? fmtPct(mb.days_in_market_pct) : '—'],
['vs QQQ', mb?.excess_vs_qqq_pct != null ? fmtPct(mb.excess_vs_qqq_pct) : '—'],
].map(([k, v]) => (
<div key={String(k)} style={{ display: 'flex', justifyContent: 'space-between', padding: '5px 0', borderBottom: '1px solid var(--border)', fontSize: 14 }}>
<span style={{ color: 'var(--text3)', fontFamily: 'var(--font-mono)', fontSize: 13 }}>{String(k)}</span>
<span style={{ color: 'var(--text1)', fontFamily: 'var(--font-mono)', fontSize: 13 }}>{String(v)}</span>
</div>
))}
</div>
</div>
)}
{/* Equity Curve tab */}
{tab === 'equity' && (
<div style={{ background: 'var(--bg1)', border: '1px solid var(--border)', borderRadius: 12, padding: '16px 18px' }}>
{chartData.length === 0
? <div style={{ textAlign: 'center', color: 'var(--text3)', fontFamily: 'var(--font-mono)', fontSize: 14, padding: '40px 0' }}>No equity curve data</div>
: (
<>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.07em', marginBottom: 16 }}>Equity Curve</div>
<ResponsiveContainer width="100%" height={320}>
<LineChart data={chartData} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="date" tick={{ fontFamily: 'var(--font-mono)', fontSize: 11, fill: 'var(--text3)' }} tickLine={false} minTickGap={40} />
<YAxis tick={{ fontFamily: 'var(--font-mono)', fontSize: 11, fill: 'var(--text3)' }} tickLine={false} axisLine={false} tickFormatter={v => `$${(v / 1000).toFixed(0)}k`} />
<Tooltip
contentStyle={{ background: 'var(--bg1)', border: '1px solid var(--border)', borderRadius: 8, fontFamily: 'var(--font-mono)', fontSize: 12 }}
formatter={(v) => [`$${Number(v).toLocaleString()}`, 'Equity']}
/>
<Line type="monotone" dataKey="equity" stroke="var(--orange)" strokeWidth={1.5} dot={false} />
</LineChart>
</ResponsiveContainer>
</>
)
}
</div>
)}
{/* Trade Blotter tab */}
{tab === 'trades' && (
<div>
{pageTrades.length === 0
? <div style={{ textAlign: 'center', color: 'var(--text3)', fontFamily: 'var(--font-mono)', fontSize: 14, padding: '40px 0' }}>No trades</div>
: (
<>
<div style={{ background: 'var(--bg1)', border: '1px solid var(--border)', borderRadius: 12, overflow: 'hidden' }}>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border-md)', background: 'var(--bg2)' }}>
{['Symbol', 'Engine', 'Entry Date', 'Exit Date', 'Entry $', 'Exit $', 'PnL', 'Exit Reason'].map(h => (
<th key={h} style={{ padding: '11px 14px', fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 500, letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--text3)', textAlign: 'left', whiteSpace: 'nowrap' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{pageTrades.map((t, i) => {
const pnl = Number(t.pnl ?? 0);
return (
<tr key={i} style={{ borderBottom: i < pageTrades.length - 1 ? '1px solid var(--border)' : 'none', background: i % 2 === 1 ? 'rgba(0,0,0,0.018)' : 'transparent' }}>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--text1)' }}>{String(t.symbol ?? '—')}</td>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text3)' }}>{String(t.engine_id ?? '—')}</td>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text3)' }}>{String(t.entry_date ?? '—')}</td>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text3)' }}>{String(t.exit_date ?? '—')}</td>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--text2)' }}>{t.entry_price != null ? `$${Number(t.entry_price).toFixed(2)}` : '—'}</td>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--text2)' }}>{t.exit_price != null ? `$${Number(t.exit_price).toFixed(2)}` : '—'}</td>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 13, color: pnl >= 0 ? 'var(--green)' : 'var(--red)' }}>
{pnl >= 0 ? '+' : ''}${pnl.toFixed(0)}
</td>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text3)' }}>{String(t.reason ?? t.exit_reason ?? '—')}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{totalPages > 1 && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, marginTop: 14, fontFamily: 'var(--font-mono)', fontSize: 13 }}>
<button
onClick={() => setTradePage(p => Math.max(0, p - 1))}
disabled={tradePage === 0}
style={{ padding: '5px 14px', borderRadius: 6, border: '1px solid var(--border-md)', background: 'none', cursor: tradePage === 0 ? 'default' : 'pointer', color: tradePage === 0 ? 'var(--text3)' : 'var(--text2)' }}
>
Prev
</button>
<span style={{ color: 'var(--text3)' }}>{tradePage + 1} / {totalPages}</span>
<button
onClick={() => setTradePage(p => Math.min(totalPages - 1, p + 1))}
disabled={tradePage >= totalPages - 1}
style={{ padding: '5px 14px', borderRadius: 6, border: '1px solid var(--border-md)', background: 'none', cursor: tradePage >= totalPages - 1 ? 'default' : 'pointer', color: tradePage >= totalPages - 1 ? 'var(--text3)' : 'var(--text2)' }}
>
Next
</button>
</div>
)}
</>
)
}
</div>
)}
</div>
);
}

Loading…
Cancel
Save