"""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}")