You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

975 lines
34 KiB
Python

"""Intraday backtest API endpoints (ORB / Morning Momentum)."""
from __future__ import annotations
import calendar
import datetime as dt
import json
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
import yaml
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from apps.web.dependencies import get_project_root, get_runs_dir
router = APIRouter(prefix="/intraday", tags=["intraday"])
# ---------------------------------------------------------------------------
# Task registry — in-memory + disk persistence
# ---------------------------------------------------------------------------
_tasks: dict[str, dict[str, Any]] = {}
_tasks_lock = threading.Lock()
_tasks_initialized = False
_DEFAULT_OUTPUT_DIR_BY_MODE: dict[str, str] = {
"momentum": "runs/intraday",
"orb": "runs/intraday_orb",
}
_RUN_VALID_UNIVERSES: set[str] = {
"sp500",
"nasdaq100",
"midlarge",
"largecap",
"midcap",
"smallmid",
"screener",
"yaml",
}
_EDITOR_VALID_UNIVERSES: set[str] = {
"sp500",
"nasdaq100",
"midlarge",
"largecap",
"midcap",
"smallmid",
}
# Built-in strategies (read-only presets shipped with the system)
# All strategies are now directory-based (configs/intraday/strategies/).
_BUILTIN_STRATEGIES: dict[str, Any] = {}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _log_dir() -> Path:
return get_runs_dir() / ".intraday_tasks"
def _log_file(task_id: str) -> Path:
return _log_dir() / f"{task_id}.log"
def _task_file(task_id: str) -> Path:
return _log_dir() / f"{task_id}.task.json"
def _strategies_dir() -> Path:
return get_project_root() / "configs" / "intraday" / "strategies"
def _persist_task(task: dict[str, Any]) -> None:
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 _is_pid_alive(pid: int) -> bool:
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
except PermissionError:
return True
_ANSI_ESCAPE = re.compile(r'\x1b\[[0-9;]*[A-Za-z]|\x1b\[[0-9;]*m')
def _strip_ansi(text: str) -> str:
return _ANSI_ESCAPE.sub('', text)
def _log_tail_error(log_path: Path, lines: int = 50) -> str:
"""Return last N lines of a log file as an error message (ANSI stripped)."""
try:
if log_path.exists():
text = _strip_ansi(log_path.read_text(errors="replace"))
tail = "\n".join(text.splitlines()[-lines:])
return tail or "(empty log)"
except Exception:
pass
return "(log unavailable)"
def _detect_result_file(started_at: datetime, output_dir: str | None = None) -> Path | None:
"""Find the most recent intraday result JSON written after started_at."""
out_dir_name = output_dir or _DEFAULT_OUTPUT_DIR_BY_MODE["orb"]
out_dir = get_project_root() / out_dir_name
if not out_dir.exists():
return None
candidates = []
for f in out_dir.glob("intraday_*.json"):
try:
mtime = datetime.fromtimestamp(f.stat().st_mtime, tz=timezone.utc)
if mtime > started_at:
candidates.append((mtime, f))
except Exception:
pass
if not candidates:
return None
candidates.sort(key=lambda x: x[0], reverse=True)
return candidates[0][1]
def _result_summary_from_file(result_path: Path) -> dict[str, Any] | None:
"""Parse key metrics from an intraday result JSON file."""
try:
data = json.loads(result_path.read_text())
m = data.get("metrics", {})
return {
"return_pct": m.get("total_return_pct"),
"max_dd_pct": m.get("max_drawdown_pct"),
"sharpe": m.get("sharpe_ratio"),
"win_rate": (m.get("win_rate") or 0) * 100 if m.get("win_rate") is not None else None,
"trade_count": m.get("total_trades"),
"final_equity": m.get("final_equity"),
"calmar": m.get("calmar_ratio"),
"loss_containment_score": m.get("loss_containment_score"),
"avg_loss_day_pct": m.get("avg_loss_day_pct"),
"tail_loss_20_pct": m.get("tail_loss_20_pct"),
"worst_day_return_pct": m.get("worst_day_return_pct"),
}
except Exception:
return None
def _load_strategy_runtime(config_path: str | Path) -> tuple[str, str]:
"""Return (strategy_mode, output_dir) derived from a config file."""
raw = yaml.safe_load(Path(config_path).read_text()) or {}
strategy_mode = str(raw.get("strategy_mode") or "momentum").lower()
if strategy_mode not in _DEFAULT_OUTPUT_DIR_BY_MODE:
strategy_mode = "momentum"
output_dir = str(
raw.get("output", {}).get("dir")
or _DEFAULT_OUTPUT_DIR_BY_MODE[strategy_mode]
)
return strategy_mode, output_dir
def _load_strategy_universe(config_path: str | Path) -> tuple[str, str | None]:
"""Return (universe_source, symbols_file) from a strategy config."""
raw = yaml.safe_load(Path(config_path).read_text()) or {}
universe = raw.get("universe", {}) or {}
source = str(universe.get("source") or "midlarge").lower()
symbols_file = universe.get("symbols_file")
return source, str(symbols_file) if symbols_file else None
def _format_universe_label(source: str, symbols_file: str | None) -> str:
if source == "yaml" and symbols_file:
return f"yaml:{Path(symbols_file).name}"
return source
def _watch_process(task_id: str, proc: subprocess.Popen) -> None: # type: ignore[type-arg]
"""Background thread: wait for subprocess to finish, then update task state."""
import time
# Mark as running once PID is known
with _tasks_lock:
task = _tasks.get(task_id)
if task:
task["status"] = "running"
task["pid"] = proc.pid
task["started_at"] = datetime.now(timezone.utc).isoformat()
_persist_task(task)
# Wait for subprocess
proc.wait()
started_at_str = None
with _tasks_lock:
task = _tasks.get(task_id)
if task:
started_at_str = task.get("started_at")
started_at = datetime.now(timezone.utc)
if started_at_str:
try:
started_at = datetime.fromisoformat(started_at_str.replace("Z", "+00:00"))
except Exception:
pass
# Allow a brief moment for file system writes to flush
time.sleep(1)
result_path = _detect_result_file(started_at, task.get("output_dir") if task else None)
log_path = _log_file(task_id)
with _tasks_lock:
task = _tasks.get(task_id)
if task is None:
return
if task.get("status") == "cancelled":
_persist_task(task)
return
task["pid"] = None
task["finished_at"] = datetime.now(timezone.utc).isoformat()
task["returncode"] = proc.returncode
if proc.returncode == 0 and result_path:
task["status"] = "completed"
task["result_file"] = str(result_path)
summary = _result_summary_from_file(result_path)
if summary:
task["result_summary"] = summary
else:
task["status"] = "failed"
task["error"] = _log_tail_error(log_path)
_persist_task(task)
def _load_tasks_from_disk() -> None:
"""Restore persisted tasks on startup."""
ld = _log_dir()
if not ld.exists():
return
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
# Tasks stuck in running/queued get resolved
if data.get("status") in ("running", "queued"):
pid = data.get("pid")
if pid and _is_pid_alive(pid):
_tasks[task_id] = data
continue
# Process dead — check for result
data["pid"] = None
if not data.get("finished_at"):
data["finished_at"] = datetime.now(timezone.utc).isoformat()
started_at_str = data.get("started_at")
result_path = None
if started_at_str:
try:
started_at = datetime.fromisoformat(started_at_str.replace("Z", "+00:00"))
result_path = _detect_result_file(started_at, data.get("output_dir"))
except Exception:
pass
if result_path:
data["status"] = "completed"
data["result_file"] = str(result_path)
summary = _result_summary_from_file(result_path)
if summary:
data["result_summary"] = summary
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:
pass
def _ensure_initialized() -> None:
global _tasks_initialized
if not _tasks_initialized:
_tasks_initialized = True
_load_tasks_from_disk()
def _slugify(name: str) -> str:
"""Convert a name to a safe slug (lowercase, underscores, alphanumeric)."""
s = name.lower().strip()
s = re.sub(r"[^a-z0-9]+", "_", s)
s = s.strip("_")
return s or "strategy"
def _next_strategy_id() -> int:
"""Return the next available strategy ID (max existing + 1, starting at 1)."""
sdir = _strategies_dir()
used: set[int] = set()
if sdir.exists():
for yaml_file in sdir.glob("*.yaml"):
try:
raw = yaml.safe_load(yaml_file.read_text()) or {}
sid = raw.get("_meta", {}).get("id")
if isinstance(sid, int) and sid > 0:
used.add(sid)
except Exception:
pass
if not used:
return 1
return max(used) + 1
def _load_user_strategy(slug: str) -> dict[str, Any] | None:
"""Load a user strategy from its YAML file. Returns None if not found."""
path = _strategies_dir() / f"{slug}.yaml"
if not path.exists():
return None
try:
raw = yaml.safe_load(path.read_text()) or {}
meta = raw.get("_meta", {})
strategy_mode = str(raw.get("strategy_mode") or "momentum").lower()
orb = raw.get("orb_strategy", {})
momentum = raw.get("strategy", {})
backtest = raw.get("backtest", {})
universe = raw.get("universe", {})
base = {
"slug": slug,
"id": meta.get("id"),
"name": meta.get("name", slug),
"description": meta.get("description", ""),
"builtin": False,
"config_path": str(path.relative_to(get_project_root())),
"days": backtest.get("lookback_trading_days", 200),
"universe": universe.get("source", "midlarge"),
"universe_symbols_file": universe.get("symbols_file"),
"universe_label": _format_universe_label(
str(universe.get("source", "midlarge")),
str(universe.get("symbols_file")) if universe.get("symbols_file") else None,
),
"strategy_mode": strategy_mode,
"output_dir": raw.get("output", {}).get(
"dir",
_DEFAULT_OUTPUT_DIR_BY_MODE.get(strategy_mode, _DEFAULT_OUTPUT_DIR_BY_MODE["momentum"]),
),
}
if strategy_mode == "orb":
base.update({
"initial_capital": orb.get("initial_capital", 10000.0),
"risk_per_trade_pct": orb.get("risk_per_trade_pct", 0.0025),
"max_position_pct": orb.get("max_position_pct", 0.20),
"atr_stop_multiplier": orb.get("atr_stop_multiplier", 0.50),
"min_rvol": orb.get("min_rvol", 1.0),
"max_candidates": orb.get("max_candidates", 20),
"daily_max_loss_pct": orb.get("daily_max_loss_pct", 0.0125),
"max_stops_per_day": orb.get("max_stops_per_day", 3),
"breakeven_at_r": orb.get("breakeven_at_r", 1.0),
"trailing_at_r": orb.get("trailing_at_r", 2.0),
"trailing_stop_atr_multiplier": orb.get("trailing_stop_atr_multiplier", 0.0),
"order_timeout_minutes": orb.get("order_timeout_minutes", 45),
"settlement_days": orb.get("settlement_days", 1),
"min_candidate_breadth": orb.get("min_candidate_breadth"),
"max_gap_pct": orb.get("max_gap_pct", 0.10),
"sim_bar_minutes": orb.get("sim_bar_minutes", 5),
"orb_minutes": orb.get("orb_minutes", 5),
"compound_returns": orb.get("compound_returns", True),
})
else:
base.update({
"initial_capital": momentum.get("initial_capital", 10000.0),
"entry_minutes_after_open": momentum.get("entry_minutes_after_open", 30),
"exit_minutes_before_close": momentum.get("exit_minutes_before_close", 30),
"top_n": momentum.get("top_n", 3),
"min_morning_gain_pct": momentum.get("min_morning_gain_pct", 0.01),
"max_morning_gain_pct": momentum.get("max_morning_gain_pct"),
"min_entry_volume": momentum.get("min_entry_volume"),
"stop_loss_pct": momentum.get("stop_loss_pct"),
"trailing_stop_pct": momentum.get("trailing_stop_pct"),
"ticker_cooldown_days": momentum.get("ticker_cooldown_days", 0),
"market_regime_spy_threshold": momentum.get("market_regime_spy_threshold"),
"compound_returns": False,
})
return base
except Exception:
return None
def _save_user_strategy(strat_data: dict[str, Any]) -> None:
"""Write a user strategy dict to its YAML file."""
_strategies_dir().mkdir(parents=True, exist_ok=True)
slug = strat_data["slug"]
path = _strategies_dir() / f"{slug}.yaml"
meta_block: dict[str, Any] = {
"name": strat_data["name"],
"description": strat_data.get("description", ""),
}
if strat_data.get("id") is not None:
meta_block["id"] = strat_data["id"]
data: dict[str, Any] = {
"_meta": meta_block,
"strategy_mode": "orb",
"orb_strategy": {
"orb_minutes": strat_data.get("orb_minutes", 5),
"sim_bar_minutes": strat_data.get("sim_bar_minutes", 30),
"entry_direction": "long_only",
"order_timeout_minutes": strat_data.get("order_timeout_minutes", 45),
"min_price": 10.0,
"min_avg_dollar_volume": 25000000,
"min_atr_14": 0.50,
"min_rvol": strat_data.get("min_rvol", 1.0),
"max_candidates": strat_data.get("max_candidates", 20),
"min_candidates_to_trade": 3,
"weight_rvol": 0.60,
"weight_gap": 0.25,
"weight_dollar_vol": 0.15,
"atr_stop_multiplier": strat_data.get("atr_stop_multiplier", 0.50),
"breakeven_at_r": strat_data.get("breakeven_at_r", 1.0),
"trailing_at_r": strat_data.get("trailing_at_r", 2.0),
"trailing_stop_atr_multiplier": strat_data.get("trailing_stop_atr_multiplier", 0.0),
"risk_per_trade_pct": strat_data.get("risk_per_trade_pct", 0.0025),
"max_position_pct": strat_data.get("max_position_pct", 0.20),
"daily_max_loss_pct": strat_data.get("daily_max_loss_pct", 0.0125),
"max_stops_per_day": strat_data.get("max_stops_per_day", 3),
"exit_minutes_before_close": 5,
"slippage_bps": 5.0,
"initial_capital": strat_data.get("initial_capital", 10000.0),
"ticker_cooldown_days": 0,
"market_regime_spy_threshold": None,
"min_candidate_breadth": strat_data.get("min_candidate_breadth"),
"settlement_days": strat_data.get("settlement_days", 1),
"max_gap_pct": strat_data.get("max_gap_pct", 0.10),
"compound_returns": strat_data.get("compound_returns", False),
},
"universe": {
"source": strat_data.get("universe", "midlarge"),
"min_price": 10.0,
},
"backtest": {
"start_date": None,
"end_date": None,
"lookback_trading_days": strat_data.get("days", 200),
"pre_screen_threshold": 0.01,
},
"cache": {"enabled": True, "dir": "data/cache/intraday"},
"output": {"dir": "runs/intraday_orb", "verbose": False},
}
path.write_text(yaml.dump(data, default_flow_style=False, sort_keys=False, allow_unicode=True))
def _resolve_config_path(slug: str) -> str:
"""Resolve a strategy slug to an absolute config YAML path."""
user_path = _strategies_dir() / f"{slug}.yaml"
if user_path.exists():
return str(user_path)
raise HTTPException(status_code=404, detail=f"Strategy not found: {slug!r}")
def _parse_dates(start: str | None, end: str | None, year: str | None) -> tuple[str | None, str | None]:
"""Normalize date inputs to YYYY-MM-DD strings.
Accepts: YYYY, YYYY-MM, YYYY-MM-DD for start/end; YYYY for year.
Returns (start_date, end_date) as ISO strings or None.
"""
if year:
y = int(year)
return f"{y}-01-01", f"{y}-12-31"
def _expand(s: str, is_end: bool = False) -> str:
s = s.strip()
if len(s) == 4 and s.isdigit():
y = int(s)
return f"{y}-12-31" if is_end else f"{y}-01-01"
if len(s) == 7 and s[4] == "-":
y, m = int(s[:4]), int(s[5:])
if is_end:
last = calendar.monthrange(y, m)[1]
return f"{y}-{m:02d}-{last:02d}"
return f"{y}-{m:02d}-01"
# Assume YYYY-MM-DD
dt.date.fromisoformat(s) # validate; raises ValueError if bad
return s
start_out = _expand(start) if start else None
end_out = _expand(end, is_end=True) if end else None
return start_out, end_out
# ---------------------------------------------------------------------------
# Request / Response Models
# ---------------------------------------------------------------------------
class IntradayBacktestRequest(BaseModel):
config: str = "orb_aggressive" # strategy slug (built-in or user-defined)
days: int = 200
universe: str = "midlarge"
# Date range (optional — mutually exclusive with `days` when start is set)
year: str | None = None # YYYY shorthand (sets start=Jan 1, end=Dec 31)
start_date: str | None = None # YYYY, YYYY-MM, or YYYY-MM-DD
end_date: str | None = None # YYYY, YYYY-MM, or YYYY-MM-DD (default: today)
# Per-run overrides
compound_returns: bool | None = None # None = use strategy config default
initial_capital: float | None = None # None = use strategy config default
daily_budget_reset: bool | None = None # Research mode: reset budget daily
class CreateStrategyRequest(BaseModel):
name: str
description: str = ""
# Backtest params
days: int = 200
universe: str = "midlarge"
# ORB strategy params
initial_capital: float = 10000.0
risk_per_trade_pct: float = 0.0025
max_position_pct: float = 0.20
atr_stop_multiplier: float = 0.50
min_rvol: float = 1.0
max_candidates: int = 20
daily_max_loss_pct: float = 0.0125
max_stops_per_day: int = 3
breakeven_at_r: float = 1.0
trailing_at_r: float = 2.0
trailing_stop_atr_multiplier: float = 0.0
order_timeout_minutes: int = 45
settlement_days: int = 1
min_candidate_breadth: float | None = None
max_gap_pct: float | None = 0.10
sim_bar_minutes: int = 30
orb_minutes: int = 5
compound_returns: bool = False
class UpdateStrategyRequest(BaseModel):
name: str | None = None
description: str | None = None
days: int | None = None
universe: str | None = None
initial_capital: float | None = None
risk_per_trade_pct: float | None = None
max_position_pct: float | None = None
atr_stop_multiplier: float | None = None
min_rvol: float | None = None
max_candidates: int | None = None
daily_max_loss_pct: float | None = None
max_stops_per_day: int | None = None
breakeven_at_r: float | None = None
trailing_at_r: float | None = None
trailing_stop_atr_multiplier: float | None = None
order_timeout_minutes: int | None = None
settlement_days: int | None = None
min_candidate_breadth: float | None = None
max_gap_pct: float | None = None
sim_bar_minutes: int | None = None
orb_minutes: int | None = None
compound_returns: bool | None = None
# ---------------------------------------------------------------------------
# Strategy CRUD Endpoints
# ---------------------------------------------------------------------------
@router.get("/strategies")
def list_strategies() -> dict[str, Any]:
"""List all strategies from disk."""
strategies: list[dict[str, Any]] = []
sdir = _strategies_dir()
if sdir.exists():
for yaml_file in sorted(sdir.glob("*.yaml")):
strat = _load_user_strategy(yaml_file.stem)
if strat:
strategies.append({k: v for k, v in strat.items() if k != "config_path"})
return {"strategies": strategies}
@router.get("/strategies/{slug}")
def get_strategy(slug: str) -> dict[str, Any]:
"""Get a single strategy by slug."""
strat = _load_user_strategy(slug)
if not strat:
raise HTTPException(status_code=404, detail=f"Strategy not found: {slug!r}")
return {k: v for k, v in strat.items() if k != "config_path"}
@router.post("/strategies")
def create_strategy(req: CreateStrategyRequest) -> dict[str, Any]:
"""Create a new user-defined strategy."""
if req.universe not in _EDITOR_VALID_UNIVERSES:
raise HTTPException(status_code=400, detail=f"Unknown universe: {req.universe!r}")
slug = _slugify(req.name)
if not slug:
raise HTTPException(status_code=400, detail="Strategy name produces empty slug")
if (_strategies_dir() / f"{slug}.yaml").exists():
raise HTTPException(status_code=409, detail=f"Strategy {slug!r} already exists")
strat_data = {
"slug": slug,
"id": _next_strategy_id(),
"name": req.name,
"description": req.description,
"builtin": False,
"days": req.days,
"universe": req.universe,
"universe_label": req.universe,
"initial_capital": req.initial_capital,
"risk_per_trade_pct": req.risk_per_trade_pct,
"max_position_pct": req.max_position_pct,
"atr_stop_multiplier": req.atr_stop_multiplier,
"min_rvol": req.min_rvol,
"max_candidates": req.max_candidates,
"daily_max_loss_pct": req.daily_max_loss_pct,
"max_stops_per_day": req.max_stops_per_day,
"breakeven_at_r": req.breakeven_at_r,
"trailing_at_r": req.trailing_at_r,
"trailing_stop_atr_multiplier": req.trailing_stop_atr_multiplier,
"order_timeout_minutes": req.order_timeout_minutes,
"settlement_days": req.settlement_days,
"min_candidate_breadth": req.min_candidate_breadth,
"max_gap_pct": req.max_gap_pct,
"sim_bar_minutes": req.sim_bar_minutes,
"orb_minutes": req.orb_minutes,
"compound_returns": req.compound_returns,
}
_save_user_strategy(strat_data)
return strat_data
@router.put("/strategies/{slug}")
def update_strategy(slug: str, req: UpdateStrategyRequest) -> dict[str, Any]:
"""Update a strategy."""
strat = _load_user_strategy(slug)
if not strat:
raise HTTPException(status_code=404, detail=f"Strategy not found: {slug!r}")
if strat.get("strategy_mode") != "orb":
raise HTTPException(
status_code=400,
detail="Web strategy editor currently supports ORB strategies only",
)
# Apply non-None updates
updates = req.model_dump(exclude_none=True)
for k, v in updates.items():
strat[k] = v
if req.universe and req.universe not in _EDITOR_VALID_UNIVERSES:
raise HTTPException(status_code=400, detail=f"Unknown universe: {req.universe!r}")
_save_user_strategy(strat)
return {k: v for k, v in strat.items() if k != "config_path"}
@router.post("/strategies/{slug}/copy")
def copy_strategy(slug: str) -> dict[str, Any]:
"""Create a copy of an existing strategy with a new slug and ID."""
strat = _load_user_strategy(slug)
if not strat:
raise HTTPException(status_code=404, detail=f"Strategy not found: {slug!r}")
if strat.get("strategy_mode") != "orb":
raise HTTPException(
status_code=400,
detail="Web strategy copy currently supports ORB strategies only",
)
new_name = f"{strat['name']} (copy)"
base_slug = _slugify(new_name)
new_slug = base_slug
counter = 2
while (_strategies_dir() / f"{new_slug}.yaml").exists():
new_slug = f"{base_slug}_{counter}"
counter += 1
new_strat = {k: v for k, v in strat.items() if k not in ("config_path", "builtin")}
new_strat["slug"] = new_slug
new_strat["name"] = new_name
new_strat["id"] = _next_strategy_id()
new_strat["builtin"] = False
_save_user_strategy(new_strat)
return {k: v for k, v in new_strat.items() if k != "config_path"}
@router.delete("/strategies/{slug}")
def delete_strategy(slug: str) -> dict[str, Any]:
"""Delete a strategy."""
path = _strategies_dir() / f"{slug}.yaml"
if not path.exists():
raise HTTPException(status_code=404, detail=f"Strategy not found: {slug!r}")
path.unlink()
return {"slug": slug, "deleted": True}
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.post("/backtest/submit")
def submit_intraday_backtest(req: IntradayBacktestRequest) -> dict[str, Any]:
_ensure_initialized()
# Resolve config slug → YAML path
config_path = _resolve_config_path(req.config)
if req.universe not in _RUN_VALID_UNIVERSES:
raise HTTPException(status_code=400, detail=f"Unknown universe: {req.universe!r}")
# Normalize dates
try:
start_iso, end_iso = _parse_dates(req.start_date, req.end_date, req.year)
except (ValueError, TypeError) as exc:
raise HTTPException(status_code=400, detail=f"Invalid date: {exc}")
task_id = str(uuid.uuid4())
task: dict[str, Any] = {
"task_id": task_id,
"config": req.config,
"days": req.days,
"universe": req.universe,
"year": req.year,
"start_date": start_iso,
"end_date": end_iso,
"compound_returns": req.compound_returns,
"daily_budget_reset": req.daily_budget_reset,
"status": "queued",
"created_at": datetime.now(timezone.utc).isoformat(),
"started_at": None,
"finished_at": None,
"pid": None,
"returncode": None,
"error": None,
"result_file": None,
"result_summary": None,
}
project_root = get_project_root()
_log_dir().mkdir(parents=True, exist_ok=True)
log_path = _log_file(task_id)
strategy_mode, output_dir_name = _load_strategy_runtime(config_path)
_strategy_universe_source, strategy_universe_symbols_file = _load_strategy_universe(config_path)
task["strategy_mode"] = strategy_mode
task["output_dir"] = output_dir_name
task["universe_label"] = _format_universe_label(
req.universe,
strategy_universe_symbols_file if req.universe == "yaml" else None,
)
output_dir = str(project_root / output_dir_name)
cmd = [
sys.executable, "-u", "-m", "apps.intraday_bt.run",
"--strategy", strategy_mode,
"--config", config_path,
"--output-dir", output_dir,
]
if req.universe != "yaml":
cmd.extend(["--universe", req.universe])
# Date params: explicit range takes precedence over lookback days
if start_iso:
cmd.extend(["--start", start_iso])
if end_iso:
cmd.extend(["--end", end_iso])
else:
cmd.extend(["--days", str(req.days)])
# Per-run initial capital override
if req.initial_capital is not None:
cmd.extend(["--initial-capital", str(req.initial_capital)])
# Per-run compound/simple override
if req.compound_returns is True:
cmd.append("--compound-returns")
elif req.compound_returns is False:
cmd.append("--no-compound-returns")
# Per-run daily budget reset override (research mode)
if req.daily_budget_reset is True:
cmd.append("--daily-budget-reset")
elif req.daily_budget_reset is False:
cmd.append("--no-daily-budget-reset")
with _tasks_lock:
_tasks[task_id] = task
_persist_task(task)
with open(log_path, "wb") as log_fp:
proc = subprocess.Popen(
cmd,
stdout=log_fp,
stderr=subprocess.STDOUT,
cwd=str(project_root),
)
thread = threading.Thread(
target=_watch_process,
args=(task_id, proc),
daemon=True,
)
thread.start()
return {"task_id": task_id, "status": "queued"}
def _try_resolve_dead_task(task_id: str) -> None:
"""If a task is stuck 'running' with a dead PID, auto-resolve its status.
Called on task GET / list so the UI never shows stale 'running' after the
subprocess has already exited (handles daemon-thread death on server restart,
or rare cases where _watch_process didn't persist the final state).
"""
with _tasks_lock:
task = _tasks.get(task_id)
if task is None or task.get("status") != "running":
return
pid = task.get("pid")
if pid and _is_pid_alive(pid):
return # genuinely still running
# Process is gone — detect result file and resolve
started_at_str = task.get("started_at")
started_at = datetime.now(timezone.utc)
if started_at_str:
try:
started_at = datetime.fromisoformat(started_at_str.replace("Z", "+00:00"))
except Exception:
pass
result_path = _detect_result_file(started_at, task.get("output_dir"))
task["pid"] = None
if not task.get("finished_at"):
task["finished_at"] = datetime.now(timezone.utc).isoformat()
if result_path:
task["status"] = "completed"
task["result_file"] = str(result_path)
summary = _result_summary_from_file(result_path)
if summary:
task["result_summary"] = summary
else:
task["status"] = "failed"
log_path = _log_file(task_id)
task["error"] = _log_tail_error(log_path)
_persist_task(task)
@router.get("/backtest/tasks")
def list_intraday_tasks() -> dict[str, Any]:
_ensure_initialized()
with _tasks_lock:
task_ids = list(_tasks.keys())
for tid in task_ids:
_try_resolve_dead_task(tid)
with _tasks_lock:
tasks = list(_tasks.values())
tasks.sort(key=lambda t: t.get("created_at", ""), reverse=True)
return {"tasks": tasks}
@router.get("/backtest/tasks/{task_id}")
def get_intraday_task(task_id: str) -> dict[str, Any]:
_ensure_initialized()
_try_resolve_dead_task(task_id)
with _tasks_lock:
task = _tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
return task
@router.delete("/backtest/tasks/{task_id}")
def cancel_intraday_task(task_id: str) -> dict[str, Any]:
_ensure_initialized()
with _tasks_lock:
task = _tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
pid = task.get("pid")
if task["status"] in ("queued", "running"):
task["status"] = "cancelled"
task["finished_at"] = datetime.now(timezone.utc).isoformat()
_persist_task(task)
if pid:
try:
import signal as _sig
os.kill(pid, _sig.SIGTERM)
except Exception:
pass
return {"task_id": task_id, "status": "cancelled"}
@router.delete("/backtest/tasks/{task_id}/delete")
def delete_intraday_task(task_id: str) -> dict[str, Any]:
"""Permanently remove a task from history (cancel first if still running)."""
_ensure_initialized()
with _tasks_lock:
task = _tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
pid = task.get("pid")
if task["status"] in ("queued", "running"):
task["status"] = "cancelled"
task["finished_at"] = datetime.now(timezone.utc).isoformat()
_tasks.pop(task_id, None)
if pid:
try:
import signal as _sig
os.kill(pid, _sig.SIGTERM)
except Exception:
pass
# Remove persisted files
try:
_task_file(task_id).unlink(missing_ok=True)
_log_file(task_id).unlink(missing_ok=True)
except Exception:
pass
return {"task_id": task_id, "deleted": True}
@router.get("/backtest/tasks/{task_id}/log")
def get_intraday_task_log(task_id: str) -> dict[str, Any]:
_ensure_initialized()
with _tasks_lock:
task = _tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
log_path = _log_file(task_id)
try:
if log_path.exists():
_ANSI_RE = re.compile(r'\x1b\[[0-9;]*[mGKHABCDFrsu]|\x1b[()][AB012]')
text = _ANSI_RE.sub("", log_path.read_text(errors="replace"))
return {"log": text}
except Exception:
pass
return {"log": ""}
@router.get("/backtest/tasks/{task_id}/result")
def get_intraday_task_result(task_id: str) -> dict[str, Any]:
_ensure_initialized()
with _tasks_lock:
task = _tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
if task["status"] != "completed":
raise HTTPException(status_code=400, detail="Task not completed")
result_file = task.get("result_file")
if not result_file or not Path(result_file).exists():
raise HTTPException(status_code=404, detail="Result file not found")
try:
data = json.loads(Path(result_file).read_text())
return data
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to parse result: {e}")