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.
860 lines
30 KiB
Python
860 lines
30 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
|
|
|
|
INTRADAY_OUTPUT_DIR = "runs/intraday_orb"
|
|
|
|
# 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) -> Path | None:
|
|
"""Find the most recent intraday result JSON written after started_at."""
|
|
out_dir = get_project_root() / INTRADAY_OUTPUT_DIR
|
|
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"),
|
|
}
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
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)
|
|
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()
|
|
|
|
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)
|
|
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", {})
|
|
orb = raw.get("orb_strategy", {})
|
|
backtest = raw.get("backtest", {})
|
|
universe = raw.get("universe", {})
|
|
return {
|
|
"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())),
|
|
"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),
|
|
"days": backtest.get("lookback_trading_days", 200),
|
|
"universe": universe.get("source", "midlarge"),
|
|
}
|
|
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
|
|
|
|
|
|
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."""
|
|
valid_universes = {"sp500", "nasdaq100", "midlarge", "largecap", "midcap"}
|
|
if req.universe not in 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,
|
|
"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}")
|
|
|
|
# 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 {"sp500", "nasdaq100", "midlarge", "largecap", "midcap"}:
|
|
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}")
|
|
|
|
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)
|
|
|
|
valid_universes = {"sp500", "nasdaq100", "midlarge", "largecap", "midcap"}
|
|
if req.universe not in 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,
|
|
"status": "queued",
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
"started_at": None,
|
|
"finished_at": None,
|
|
"pid": 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)
|
|
|
|
output_dir = str(get_project_root() / INTRADAY_OUTPUT_DIR)
|
|
cmd = [
|
|
sys.executable, "-m", "apps.intraday_bt.run",
|
|
"--strategy", "orb",
|
|
"--config", config_path,
|
|
"--universe", req.universe,
|
|
"--output-dir", output_dir,
|
|
]
|
|
# 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")
|
|
|
|
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["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}")
|