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.

1273 lines
46 KiB
Python

"""Intraday backtest API endpoints (ORB / Morning Momentum)."""
from __future__ import annotations
import calendar
import copy
import datetime as dt
import json
import os
import re
import shlex
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.intraday_bt.run import _load_config_yaml
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",
"broad",
"midlarge",
"largecap",
"midcap",
"smallmid",
"screener",
"yaml",
}
_EDITOR_VALID_UNIVERSES: set[str] = {
"sp500",
"nasdaq100",
"broad",
"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 _load_strategy_yaml_standalone(path: str | Path) -> dict[str, Any]:
"""Load a standalone strategy YAML."""
return _load_config_yaml(Path(path))
def _materialize_strategy_yaml(raw: dict[str, Any]) -> dict[str, Any]:
"""Return a copy safe to persist as a standalone strategy file."""
data = copy.deepcopy(raw)
if "extends" in data:
raise ValueError("Strategy YAML inheritance via 'extends' is no longer supported")
meta = data.get("_meta")
if isinstance(meta, dict):
meta["parent"] = None
meta.pop("derived_from", None)
return data
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')
_RESULT_PATH_RE = re.compile(
r"^(?:Results|Sweep results) saved to:\s*(.+?\.json)\s*$",
re.MULTILINE,
)
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 _task_started_at(task: dict[str, Any]) -> datetime:
started_at = datetime.now(timezone.utc)
started_at_str = task.get("started_at")
if isinstance(started_at_str, str) and started_at_str:
try:
started_at = datetime.fromisoformat(started_at_str.replace("Z", "+00:00"))
except Exception:
pass
return started_at
def _detect_result_file_from_log(log_path: Path) -> Path | None:
"""Parse the explicit result path emitted by the backtest CLI log."""
try:
if not log_path.exists():
return None
text = _strip_ansi(log_path.read_text(errors="replace"))
matches = _RESULT_PATH_RE.findall(text)
for raw_path in reversed(matches):
candidate = Path(raw_path.strip())
if candidate.exists():
return candidate
except Exception:
pass
return None
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 _resolve_task_result_file(
task_id: str,
task: dict[str, Any],
*,
allow_mtime_fallback: bool,
) -> Path | None:
"""Resolve a task result, preferring the explicit path written in its log."""
result_path = _detect_result_file_from_log(_log_file(task_id))
if result_path is not None:
return result_path
existing = task.get("result_file")
if isinstance(existing, str) and existing:
existing_path = Path(existing)
if existing_path.exists():
return existing_path
if allow_mtime_fallback:
return _detect_result_file(_task_started_at(task), task.get("output_dir"))
return None
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 _apply_result_to_task(
task: dict[str, Any],
result_path: Path,
*,
status: str | None = None,
) -> None:
task["result_file"] = str(result_path)
summary = _result_summary_from_file(result_path)
if summary:
task["result_summary"] = summary
if status is not None:
task["status"] = status
task["pid"] = None
task["error"] = None
if not task.get("finished_at"):
task["finished_at"] = datetime.now(timezone.utc).isoformat()
if task.get("returncode") is None:
task["returncode"] = 0
def _repair_completed_task_result(task_id: str) -> None:
"""Repair completed/failed task metadata from the task's own log."""
with _tasks_lock:
task = _tasks.get(task_id)
if task is None or task.get("status") not in ("completed", "failed"):
return
snapshot = dict(task)
result_path = _resolve_task_result_file(task_id, snapshot, allow_mtime_fallback=False)
if result_path is None:
return
with _tasks_lock:
task = _tasks.get(task_id)
if task is None or task.get("status") not in ("completed", "failed"):
return
before = dict(task)
_apply_result_to_task(task, result_path, status="completed")
if task != before:
_persist_task(task)
def _load_strategy_runtime(config_path: str | Path) -> tuple[str, str]:
"""Return (strategy_mode, output_dir) derived from a config file."""
raw = _load_strategy_yaml_standalone(config_path)
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 = _load_strategy_yaml_standalone(config_path)
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()
task: dict[str, Any] | None = None
with _tasks_lock:
task = _tasks.get(task_id)
# Allow a brief moment for file system writes to flush
time.sleep(1)
result_path = None
if task is not None:
result_path = _resolve_task_result_file(task_id, task, allow_mtime_fallback=True)
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:
_apply_result_to_task(task, result_path, status="completed")
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()
result_path = _resolve_task_result_file(task_id, data, allow_mtime_fallback=True)
if result_path:
_apply_result_to_task(data, result_path, status="completed")
else:
data["status"] = "failed"
data["error"] = "Server restarted while task was running"
f.write_text(json.dumps(data, indent=2))
elif data.get("status") in ("completed", "failed"):
result_path = _resolve_task_result_file(task_id, data, allow_mtime_fallback=False)
if result_path is not None:
before = dict(data)
_apply_result_to_task(data, result_path, status="completed")
if data != before:
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 _is_comp_equity_deployment(raw: dict[str, Any]) -> bool:
"""Return True for generated/deployable ORB comp variants."""
orb = raw.get("orb_strategy", {})
return (
raw.get("strategy_mode") == "orb"
and orb.get("compound_returns") is True
and orb.get("daily_budget_reset") is False
and orb.get("single_trade_loss_cap_basis") == "equity"
)
def _comp_strategy_name(name: str) -> str:
stripped = name.strip()
return stripped if stripped.lower().endswith(" comp") else f"{stripped} Comp"
def _load_user_strategy(slug: str, *, strict: bool = False) -> 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 = _load_strategy_yaml_standalone(path)
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"]),
),
"deployment_kind": meta.get("deployment_kind"),
"deployment_source_slug": meta.get("source_slug"),
}
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_price": orb.get("min_price", 10.0),
"min_avg_dollar_volume": orb.get("min_avg_dollar_volume", 25000000),
"min_premarket_dollar_vol": orb.get("min_premarket_dollar_vol"),
"min_breakout_rel_vol": orb.get("min_breakout_rel_vol"),
"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),
"daily_budget_reset": orb.get("daily_budget_reset", False),
"single_trade_loss_cap_basis": orb.get("single_trade_loss_cap_basis"),
"is_comp_equity_deployment": _is_comp_equity_deployment(raw),
})
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 ValueError:
if strict:
raise
return None
except Exception:
return None
def _require_user_strategy(slug: str) -> dict[str, Any]:
try:
strat = _load_user_strategy(slug, strict=True)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not strat:
raise HTTPException(status_code=404, detail=f"Strategy not found: {slug!r}")
return strat
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"
existing: dict[str, Any] = {}
if path.exists():
try:
existing = _materialize_strategy_yaml(_load_strategy_yaml_standalone(path))
except Exception:
existing = {}
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"]
if existing.get("strategy_mode") == "orb":
data = existing
data["_meta"] = {**data.get("_meta", {}), **meta_block}
data["strategy_mode"] = "orb"
orb = data.setdefault("orb_strategy", {})
for key in (
"orb_minutes",
"sim_bar_minutes",
"order_timeout_minutes",
"min_price",
"min_avg_dollar_volume",
"min_premarket_dollar_vol",
"min_breakout_rel_vol",
"min_rvol",
"max_candidates",
"atr_stop_multiplier",
"breakeven_at_r",
"trailing_at_r",
"trailing_stop_atr_multiplier",
"risk_per_trade_pct",
"max_position_pct",
"daily_max_loss_pct",
"max_stops_per_day",
"initial_capital",
"min_candidate_breadth",
"settlement_days",
"max_gap_pct",
"compound_returns",
"daily_budget_reset",
):
if key in strat_data:
orb[key] = strat_data[key]
universe = data.setdefault("universe", {})
universe["source"] = strat_data.get("universe", universe.get("source", "midlarge"))
backtest = data.setdefault("backtest", {})
backtest["lookback_trading_days"] = strat_data.get(
"days",
backtest.get("lookback_trading_days", 200),
)
data.setdefault("cache", {"enabled": True, "dir": "data/cache/intraday"})
data.setdefault("output", {"dir": "runs/intraday_orb", "verbose": False})
path.write_text(yaml.dump(_materialize_strategy_yaml(data), default_flow_style=False, sort_keys=False, allow_unicode=True))
return
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": strat_data.get("min_price", 10.0),
"min_avg_dollar_volume": strat_data.get("min_avg_dollar_volume", 25000000),
"min_atr_14": 0.50,
"min_premarket_dollar_vol": strat_data.get("min_premarket_dollar_vol"),
"min_breakout_rel_vol": strat_data.get("min_breakout_rel_vol"),
"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),
"daily_budget_reset": strat_data.get("daily_budget_reset", 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(_materialize_strategy_yaml(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_price: float = 10.0
min_avg_dollar_volume: float = 25000000
min_premarket_dollar_vol: float | None = None
min_breakout_rel_vol: float | None = None
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
daily_budget_reset: 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_price: float | None = None
min_avg_dollar_volume: float | None = None
min_premarket_dollar_vol: float | None = None
min_breakout_rel_vol: 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
daily_budget_reset: 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 = _require_user_strategy(slug)
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_price": req.min_price,
"min_avg_dollar_volume": req.min_avg_dollar_volume,
"min_premarket_dollar_vol": req.min_premarket_dollar_vol,
"min_breakout_rel_vol": req.min_breakout_rel_vol,
"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,
"daily_budget_reset": req.daily_budget_reset,
}
_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 = _require_user_strategy(slug)
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 = _require_user_strategy(slug)
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
source_path = _strategies_dir() / f"{slug}.yaml"
raw = _load_strategy_yaml_standalone(source_path)
new_raw = _materialize_strategy_yaml(raw)
meta = new_raw.setdefault("_meta", {})
meta["name"] = new_name
meta["id"] = _next_strategy_id()
target_path = _strategies_dir() / f"{new_slug}.yaml"
target_path.write_text(yaml.dump(new_raw, default_flow_style=False, sort_keys=False, allow_unicode=True))
copied = _load_user_strategy(new_slug, strict=True)
if copied is None:
raise HTTPException(status_code=500, detail=f"Failed to copy strategy: {slug!r}")
return {k: v for k, v in copied.items() if k != "config_path"}
@router.post("/strategies/{slug}/comp")
def create_comp_strategy(slug: str) -> dict[str, Any]:
"""Create or refresh a standalone compound deployment variant.
The generated strategy keeps the source signal config materialized in full
and only applies the deployment overlay:
- compound_returns=true
- daily_budget_reset=false
- single_trade_loss_cap_basis=equity
"""
strat = _require_user_strategy(slug)
if strat.get("strategy_mode") != "orb":
raise HTTPException(
status_code=400,
detail="Comp deployment generation currently supports ORB strategies only",
)
source_path = _strategies_dir() / f"{slug}.yaml"
source_raw = _load_strategy_yaml_standalone(source_path)
if _is_comp_equity_deployment(source_raw):
raise HTTPException(status_code=400, detail=f"Strategy {slug!r} is already a comp deployment strategy")
source_meta = source_raw.get("_meta", {}) if isinstance(source_raw.get("_meta"), dict) else {}
source_name = str(source_meta.get("name") or strat.get("name") or slug)
base_slug = f"{slug}_comp"
target_slug = base_slug
target_path = _strategies_dir() / f"{target_slug}.yaml"
existing_id: int | None = None
if target_path.exists():
try:
existing_raw = yaml.safe_load(target_path.read_text()) or {}
existing_meta = existing_raw.get("_meta", {})
if (
isinstance(existing_meta, dict)
and existing_meta.get("deployment_kind") == "compound_equity_losscap"
and existing_meta.get("source_slug") == slug
):
candidate_id = existing_meta.get("id")
existing_id = candidate_id if isinstance(candidate_id, int) else None
else:
counter = 2
while target_path.exists():
target_slug = f"{base_slug}_{counter}"
target_path = _strategies_dir() / f"{target_slug}.yaml"
counter += 1
except Exception:
counter = 2
while target_path.exists():
target_slug = f"{base_slug}_{counter}"
target_path = _strategies_dir() / f"{target_slug}.yaml"
counter += 1
new_raw = _materialize_strategy_yaml(source_raw)
new_raw["strategy_mode"] = "orb"
orb = new_raw.setdefault("orb_strategy", {})
orb["compound_returns"] = True
orb["daily_budget_reset"] = False
orb["single_trade_loss_cap_basis"] = "equity"
meta = new_raw.setdefault("_meta", {})
meta["name"] = _comp_strategy_name(source_name)
meta["description"] = (
f"Compound deployment variant generated from {source_name}. "
"Uses compound_returns=true, daily_budget_reset=false, "
"single_trade_loss_cap_basis=equity."
)
meta["id"] = existing_id or _next_strategy_id()
meta["parent"] = None
meta["deployment_kind"] = "compound_equity_losscap"
meta["source_slug"] = slug
meta["source_name"] = source_name
if source_meta.get("id") is not None:
meta["source_id"] = source_meta.get("id")
meta["generated_at"] = datetime.now(timezone.utc).isoformat()
target_path.write_text(yaml.dump(new_raw, default_flow_style=False, sort_keys=False, allow_unicode=True))
generated = _load_user_strategy(target_slug, strict=True)
if generated is None:
raise HTTPException(status_code=500, detail=f"Failed to create comp strategy for {slug!r}")
return {k: v for k, v in generated.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)
try:
strategy_mode, output_dir_name = _load_strategy_runtime(config_path)
_strategy_universe_source, strategy_universe_symbols_file = _load_strategy_universe(config_path)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
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")
task["command"] = cmd
with _tasks_lock:
_tasks[task_id] = task
_persist_task(task)
with open(log_path, "wb") as log_fp:
log_fp.write(f"Command: {shlex.join(cmd)}\n\n".encode())
log_fp.flush()
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:
return
snapshot = dict(task)
if snapshot.get("status") not in ("running", "queued"):
_repair_completed_task_result(task_id)
return
pid = snapshot.get("pid")
if pid and _is_pid_alive(pid):
return # genuinely still running
result_path = _resolve_task_result_file(task_id, snapshot, allow_mtime_fallback=True)
with _tasks_lock:
task = _tasks.get(task_id)
if task is None or task.get("status") not in ("running", "queued"):
return
task["pid"] = None
if not task.get("finished_at"):
task["finished_at"] = datetime.now(timezone.utc).isoformat()
if result_path:
_apply_result_to_task(task, result_path, status="completed")
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}")