1186 lines
49 KiB
Python
1186 lines
49 KiB
Python
"""
|
||
================================================================================
|
||
Quantum Terminal — Account & Calibration API Routes
|
||
================================================================================
|
||
FastAPI router for Layer 3 (Account Management + Risk Governor) and
|
||
Layer 2 (Calibration Store) endpoints.
|
||
|
||
Wired into data_server.py via:
|
||
from account_routes import create_account_router
|
||
app.include_router(create_account_router(broadcast_event))
|
||
|
||
Endpoints:
|
||
# ── Account & Risk ──
|
||
GET /api/account/status — equity, P&L, circuit breakers, halt status
|
||
GET /api/account/settings — current trading settings (prop firm rules)
|
||
PATCH /api/account/settings — update trading settings
|
||
POST /api/account/sync — force MT5 equity sync
|
||
POST /api/account/reset-baseline — reset baseline (initial balance + clear breakers)
|
||
GET /api/account/trades — recent trade log (last 50)
|
||
|
||
# ── Calibration ──
|
||
GET /api/calibration/summary — universe calibration badge summary
|
||
GET /api/calibration/management — unified management dashboard (freshness + calibration + flow + anchor)
|
||
GET /api/calibration/{ticker} — full calibration report for one asset
|
||
GET /api/calibration/badges/{ticker} — badge lookup for all setups on one asset
|
||
GET /api/calibration/detail/{ticker}/{signal_type} — IS/OOS drill-down for one setup
|
||
GET /api/calibration/outcomes/{ticker}/{signal_type} — raw trade records for chart viz
|
||
POST /api/calibration/run/{ticker} — trigger backtest calibration for one asset
|
||
POST /api/calibration/run — trigger universe calibration (background)
|
||
GET /api/calibration/status — calibration job status
|
||
|
||
# ── Governor ──
|
||
GET /api/governor/status — risk governor status
|
||
POST /api/governor/check — pre-trade check (dry run)
|
||
|
||
# ── Walk-Forward (Phase 3I) ──
|
||
POST /api/wf/run/{ticker} — trigger WF engine for one asset
|
||
GET /api/wf/status — WF job status
|
||
|
||
# ── Flow Calibration (Phase 3I) ──
|
||
POST /api/flow/calibrate/{ticker} — trigger flow calibrator + validator
|
||
GET /api/flow/status — flow calibration job status
|
||
================================================================================
|
||
"""
|
||
|
||
import json
|
||
import asyncio
|
||
import logging
|
||
import threading
|
||
from debug_subprocess import debug_popen as _debug_popen
|
||
from typing import Optional, Callable, Awaitable
|
||
from pathlib import Path
|
||
from datetime import datetime, timezone, timedelta
|
||
from fastapi import APIRouter, HTTPException
|
||
from pydantic import BaseModel
|
||
|
||
log = logging.getLogger("account_routes")
|
||
|
||
PROJECT_ROOT = Path(__file__).resolve().parent
|
||
|
||
|
||
# ── Request models ──
|
||
|
||
class DebugConfigRequest(BaseModel):
|
||
"""Debug configuration update."""
|
||
subprocess_debug: Optional[bool] = None
|
||
|
||
|
||
class SettingsUpdateRequest(BaseModel):
|
||
"""Partial settings update — only include fields to change."""
|
||
account_size: Optional[float] = None
|
||
max_daily_loss_pct: Optional[float] = None
|
||
max_daily_profit_pct: Optional[float] = None
|
||
max_total_drawdown_pct: Optional[float] = None
|
||
max_trailing_drawdown_pct: Optional[float] = None
|
||
max_open_positions: Optional[int] = None
|
||
max_correlated_positions: Optional[int] = None
|
||
risk_per_trade_pct: Optional[float] = None
|
||
use_kelly: Optional[bool] = None
|
||
kelly_fraction: Optional[float] = None
|
||
min_calibration_grade: Optional[str] = None
|
||
require_calibration: Optional[bool] = None
|
||
trading_enabled: Optional[bool] = None
|
||
# Auto Trading (Phase 3G)
|
||
auto_trading_enabled: Optional[bool] = None
|
||
auto_min_confidence: Optional[float] = None
|
||
auto_min_grade: Optional[str] = None
|
||
auto_max_daily_trades: Optional[int] = None
|
||
auto_allowed_types: Optional[list] = None
|
||
auto_allowed_tickers: Optional[list] = None
|
||
auto_scan_interval: Optional[float] = None
|
||
auto_log_only: Optional[bool] = None
|
||
# Scheduler
|
||
scheduler_enabled: Optional[bool] = None
|
||
scheduler_weekly_enabled: Optional[bool] = None
|
||
scheduler_weekly_day: Optional[int] = None
|
||
scheduler_weekly_time: Optional[str] = None
|
||
scheduler_daily_enabled: Optional[bool] = None
|
||
scheduler_daily_time: Optional[str] = None
|
||
scheduler_daily_skip_weekends: Optional[bool] = None
|
||
scheduler_calc_before_weekly: Optional[bool] = None
|
||
# Calibration
|
||
calibration_workers: Optional[int] = None
|
||
|
||
|
||
class PreTradeCheckRequest(BaseModel):
|
||
"""Dry-run pre-trade check."""
|
||
ticker: str
|
||
direction: str # "long" or "short"
|
||
signal_type: str
|
||
entry_price: float
|
||
stop_loss: float
|
||
target_price: float
|
||
proposed_lots: float = 0.1
|
||
|
||
|
||
class CalibrationRunRequest(BaseModel):
|
||
"""Calibration run parameters."""
|
||
lookback_months: int = 6
|
||
mc_sims: int = 20000
|
||
parallel_workers: int = 1 # CPU cores for sweep (1 = sequential)
|
||
|
||
|
||
# ── Router factory ──
|
||
|
||
def create_account_router(
|
||
broadcast_event: Optional[Callable[[dict], Awaitable[None]]] = None,
|
||
) -> APIRouter:
|
||
"""
|
||
Create account/calibration API router.
|
||
|
||
Lazy-loads AccountManager, RiskGovernor, CalibrationStore on first call
|
||
to avoid import errors if modules aren't deployed yet.
|
||
"""
|
||
router = APIRouter(tags=["account"])
|
||
|
||
# ── Lazy singletons ──
|
||
_cache = {}
|
||
|
||
def _get_account_manager():
|
||
if 'acct' not in _cache:
|
||
try:
|
||
from account_manager import get_account_manager
|
||
_cache['acct'] = get_account_manager()
|
||
except ImportError:
|
||
raise HTTPException(503, "account_manager module not available")
|
||
return _cache['acct']
|
||
|
||
def _get_governor():
|
||
if 'gov' not in _cache:
|
||
try:
|
||
from risk_governor import get_governor
|
||
_cache['gov'] = get_governor()
|
||
except ImportError:
|
||
raise HTTPException(503, "risk_governor module not available")
|
||
return _cache['gov']
|
||
|
||
def _get_cal_store():
|
||
if 'cal' not in _cache:
|
||
try:
|
||
from calibration_store import get_store
|
||
_cache['cal'] = get_store()
|
||
except ImportError:
|
||
raise HTTPException(503, "calibration_store module not available")
|
||
return _cache['cal']
|
||
|
||
async def _notify(event_type: str, detail: str = ""):
|
||
if broadcast_event:
|
||
await broadcast_event({
|
||
"type": "account_update",
|
||
"detail": event_type,
|
||
"message": detail,
|
||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||
})
|
||
|
||
# ════════════════════════════════════════════════════════
|
||
# DEBUG CONFIG — subprocess_debug flag
|
||
# ════════════════════════════════════════════════════════
|
||
|
||
_DEBUG_CONFIG_PATH = PROJECT_ROOT / "debug_config.json"
|
||
|
||
def _load_debug_config() -> dict:
|
||
"""Load debug_config.json — returns defaults if missing."""
|
||
try:
|
||
if _DEBUG_CONFIG_PATH.exists():
|
||
return json.loads(_DEBUG_CONFIG_PATH.read_text())
|
||
except Exception:
|
||
pass
|
||
return {"subprocess_debug": False}
|
||
|
||
def _save_debug_config(cfg: dict):
|
||
try:
|
||
_DEBUG_CONFIG_PATH.write_text(json.dumps(cfg, indent=2))
|
||
except Exception as e:
|
||
log.warning(f"Failed to save debug_config.json: {e}")
|
||
|
||
@router.get("/api/dev/debug-config")
|
||
async def get_debug_config():
|
||
"""Get current debug configuration."""
|
||
return _load_debug_config()
|
||
|
||
@router.patch("/api/dev/debug-config")
|
||
async def update_debug_config(payload: DebugConfigRequest):
|
||
"""
|
||
Update debug configuration.
|
||
subprocess_debug (bool) — open visible console windows for subprocesses
|
||
"""
|
||
cfg = _load_debug_config()
|
||
updates = {k: v for k, v in payload.dict().items() if v is not None}
|
||
cfg.update(updates)
|
||
_save_debug_config(cfg)
|
||
mode = "ON" if cfg.get("subprocess_debug") else "OFF"
|
||
await _notify("debug_config_changed",
|
||
f"Subprocess debug mode: {mode} — takes effect on next calibration/WF run")
|
||
return cfg
|
||
|
||
# ════════════════════════════════════════════════════════
|
||
# ACCOUNT & RISK ENDPOINTS
|
||
# ════════════════════════════════════════════════════════
|
||
|
||
@router.get("/api/account/status")
|
||
async def get_account_status():
|
||
"""Full account status — equity, P&L, circuit breakers."""
|
||
mgr = _get_account_manager()
|
||
return mgr.get_status_dict()
|
||
|
||
@router.get("/api/account/settings")
|
||
async def get_account_settings():
|
||
"""Current trading settings."""
|
||
mgr = _get_account_manager()
|
||
return mgr.get_settings_dict()
|
||
|
||
@router.patch("/api/account/settings")
|
||
async def update_account_settings(req: SettingsUpdateRequest):
|
||
"""Update trading settings (partial — only include changed fields)."""
|
||
mgr = _get_account_manager()
|
||
# Build dict of non-None fields only
|
||
updates = {k: v for k, v in req.dict().items() if v is not None}
|
||
if not updates:
|
||
raise HTTPException(400, "No fields to update")
|
||
mgr.update_settings(updates)
|
||
await _notify("settings_changed", f"Updated: {', '.join(updates.keys())}")
|
||
return {"status": "ok", "updated": list(updates.keys()), "settings": mgr.get_settings_dict()}
|
||
|
||
@router.post("/api/account/sync")
|
||
async def force_mt5_sync():
|
||
"""Force MT5 equity sync."""
|
||
mgr = _get_account_manager()
|
||
success = await asyncio.to_thread(mgr.sync_from_mt5)
|
||
if success:
|
||
await _notify("mt5_sync", "Equity synced from MT5")
|
||
return {"status": "ok", "equity": mgr.state.current_equity}
|
||
else:
|
||
return {"status": "failed", "message": "MT5 sync failed — check connection"}
|
||
|
||
@router.post("/api/account/reset-baseline")
|
||
async def reset_baseline():
|
||
"""
|
||
Reset account baseline to current account_size.
|
||
Clears all circuit breaker halts, resets initial_balance,
|
||
peak_equity, and daily/weekly tracking.
|
||
|
||
Call this after changing account_size or starting fresh.
|
||
"""
|
||
mgr = _get_account_manager()
|
||
mgr.reset_baseline()
|
||
await _notify("baseline_reset", f"Baseline reset to ${mgr.state.initial_balance:,.2f}")
|
||
return {
|
||
"status": "ok",
|
||
"initial_balance": mgr.state.initial_balance,
|
||
"current_equity": mgr.state.current_equity,
|
||
"peak_equity": mgr.state.peak_equity,
|
||
"circuit_breakers_cleared": True,
|
||
}
|
||
|
||
@router.get("/api/account/trades")
|
||
async def get_recent_trades():
|
||
"""Recent trade log (last 50)."""
|
||
mgr = _get_account_manager()
|
||
return {"trades": mgr.state.recent_trades}
|
||
|
||
@router.get("/api/account/period-pnl")
|
||
async def get_period_pnl():
|
||
"""
|
||
Real closed-trade P&L from MT5 deal history.
|
||
Periods: today, yesterday, this_week, last_week, this_month, last_month.
|
||
Each period: {pnl, trades, pct}.
|
||
"""
|
||
mgr = _get_account_manager()
|
||
return await asyncio.to_thread(mgr.get_period_pnl)
|
||
|
||
# ════════════════════════════════════════════════════════
|
||
# GOVERNOR ENDPOINTS
|
||
# ════════════════════════════════════════════════════════
|
||
|
||
@router.get("/api/governor/status")
|
||
async def get_governor_status():
|
||
"""Full risk governor status."""
|
||
gov = _get_governor()
|
||
return gov.get_status()
|
||
|
||
@router.post("/api/governor/check")
|
||
async def pre_trade_check(req: PreTradeCheckRequest):
|
||
"""
|
||
Dry-run pre-trade check — tests if a trade would be allowed
|
||
without actually placing it. Useful for UI preview.
|
||
"""
|
||
gov = _get_governor()
|
||
|
||
# Build a mock signal object for the check
|
||
class _MockSignal:
|
||
pass
|
||
sig = _MockSignal()
|
||
sig.ticker = req.ticker.upper()
|
||
|
||
class _Dir:
|
||
value = req.direction.lower()
|
||
class _Type:
|
||
value = req.signal_type
|
||
sig.direction = _Dir()
|
||
sig.signal_type = _Type()
|
||
sig.entry_price = req.entry_price
|
||
sig.stop_loss = req.stop_loss
|
||
sig.target_price = req.target_price
|
||
|
||
allowed, reason = gov.pre_trade_check(sig, proposed_lots=req.proposed_lots)
|
||
adjusted_lots = req.proposed_lots
|
||
adjust_notes = ""
|
||
if allowed:
|
||
adjusted_lots, adjust_notes = gov.adjust_position_size(sig, req.proposed_lots)
|
||
|
||
return {
|
||
"allowed": allowed,
|
||
"reason": reason,
|
||
"adjusted_lots": round(adjusted_lots, 4),
|
||
"adjust_notes": adjust_notes,
|
||
}
|
||
|
||
# ════════════════════════════════════════════════════════
|
||
# CALIBRATION ENDPOINTS
|
||
# ════════════════════════════════════════════════════════
|
||
|
||
@router.get("/api/calibration/summary")
|
||
async def get_calibration_summary():
|
||
"""Universe calibration badge summary — for dashboard display."""
|
||
store = _get_cal_store()
|
||
return store.get_universe_summary()
|
||
|
||
@router.get("/api/calibration/management")
|
||
async def get_calibration_management():
|
||
"""
|
||
Unified management dashboard data — aggregates freshness, calibration,
|
||
flow params, and entry confirmation status across all assets.
|
||
|
||
Used by CalibrationDashboard.jsx (Phase 3I).
|
||
"""
|
||
import asyncio as _aio
|
||
|
||
# ── Universe ──
|
||
try:
|
||
from config_manager import ConfigManager
|
||
cm = ConfigManager()
|
||
universe = cm.get_active_universe()
|
||
except Exception:
|
||
try:
|
||
from server_config import ASSET_UNIVERSE
|
||
universe = list(ASSET_UNIVERSE)
|
||
except Exception:
|
||
universe = []
|
||
|
||
# ── Freshness (all 9 data types × universe) ──
|
||
freshness = {}
|
||
try:
|
||
from data_freshness import DataFreshnessMonitor
|
||
monitor = DataFreshnessMonitor()
|
||
for ticker in universe:
|
||
tf = monitor.get_ticker_freshness(ticker)
|
||
freshness[ticker] = tf.to_dict()
|
||
except ImportError:
|
||
log.warning("data_freshness not available for management endpoint")
|
||
except Exception as e:
|
||
log.warning(f"Freshness fetch failed: {e}")
|
||
|
||
# ── Calibration badges ──
|
||
calibration = {}
|
||
try:
|
||
store = _get_cal_store()
|
||
calibration = store.get_universe_summary()
|
||
except Exception as e:
|
||
log.warning(f"Calibration summary failed: {e}")
|
||
|
||
# ── Flow params status ──
|
||
flow = {"calibrated": [], "missing": [], "details": {}}
|
||
flow_path = PROJECT_ROOT / "flow_params.json"
|
||
if flow_path.exists():
|
||
try:
|
||
with open(flow_path, "r") as f:
|
||
flow_data = json.load(f)
|
||
for ticker in universe:
|
||
if ticker in flow_data:
|
||
flow["calibrated"].append(ticker)
|
||
fd = flow_data[ticker]
|
||
flow["details"][ticker] = {
|
||
"calibrated_at": fd.get("calibrated_at", ""),
|
||
"wf_score": fd.get("wf_score", 0),
|
||
"wf_variance": fd.get("wf_variance", 0),
|
||
"classify_method": fd.get("classify_method", ""),
|
||
}
|
||
else:
|
||
flow["missing"].append(ticker)
|
||
except Exception as e:
|
||
log.warning(f"Flow params read failed: {e}")
|
||
flow["missing"] = universe[:]
|
||
else:
|
||
flow["missing"] = universe[:]
|
||
|
||
# ── Anchor params status ──
|
||
anchor = {"calibrated": [], "missing": [], "details": {}}
|
||
anchor_path = PROJECT_ROOT / "anchor_params.json"
|
||
if anchor_path.exists():
|
||
try:
|
||
with open(anchor_path, "r") as f:
|
||
anchor_data = json.load(f)
|
||
for ticker in universe:
|
||
if ticker in anchor_data:
|
||
anchor["calibrated"].append(ticker)
|
||
ad = anchor_data[ticker]
|
||
anchor["details"][ticker] = {
|
||
"threshold": ad.get("anchor_distance_max_sd", ""),
|
||
"calibrated_at": ad.get("calibrated_at", ""),
|
||
}
|
||
else:
|
||
anchor["missing"].append(ticker)
|
||
except Exception as e:
|
||
log.warning(f"Anchor params read failed: {e}")
|
||
anchor["missing"] = universe[:]
|
||
else:
|
||
anchor["missing"] = universe[:]
|
||
|
||
# ── Entry confirmation edge (from calibration reports) ──
|
||
entry_conf = {}
|
||
for ticker in universe:
|
||
ticker_cal = calibration.get(ticker, {})
|
||
setups = ticker_cal.get("setups", {})
|
||
edges = {}
|
||
for sig_type, badge in setups.items():
|
||
edge = badge.get("confirmation_edge", "unknown")
|
||
edges[sig_type] = edge
|
||
has_any = any(e != "unknown" for e in edges.values())
|
||
entry_conf[ticker] = {
|
||
"calibrated": has_any,
|
||
"edges": edges,
|
||
}
|
||
|
||
# ── Source-of-truth reference ──
|
||
sources = {
|
||
"freshness": "data_freshness.py — 9 data types per asset",
|
||
"calibration": "calibration_reports/*.json — backtest_engine output",
|
||
"flow": "flow_params.json — flow_calibrator.py output",
|
||
"wf_settings": "best_wf_settings.json — walk_forward_engine output",
|
||
"anchor_params": "anchor_params.json — anchor_signal_backtester output",
|
||
}
|
||
|
||
return {
|
||
"universe": universe,
|
||
"freshness": freshness,
|
||
"calibration": calibration,
|
||
"flow": flow,
|
||
"anchor": anchor,
|
||
"entry_confirmation": entry_conf,
|
||
"sources": sources,
|
||
}
|
||
|
||
# ── Calibration job tracking (must be before {ticker} routes) ──
|
||
_running_jobs = {} # ticker → {"status": "running"/"done"/"error", ...}
|
||
_cancel_tokens = {} # ticker → threading.Event (set = cancel)
|
||
|
||
@router.get("/api/calibration/status")
|
||
async def get_calibration_status():
|
||
"""Get calibration job status for all tickers."""
|
||
return _running_jobs
|
||
|
||
@router.post("/api/calibration/cancel/{ticker}")
|
||
async def cancel_calibration_ticker(ticker: str):
|
||
"""Cancel a running calibration for one asset."""
|
||
canonical = ticker.upper()
|
||
token = _cancel_tokens.get(canonical)
|
||
if token:
|
||
token.set()
|
||
if canonical in _running_jobs:
|
||
_running_jobs[canonical]["status"] = "cancelled"
|
||
log.info(f"Calibration cancel requested for {canonical}")
|
||
return {"cancelled": True, "ticker": canonical}
|
||
return {"cancelled": False, "reason": f"No running job for {canonical}"}
|
||
|
||
@router.post("/api/calibration/cancel")
|
||
async def cancel_calibration_all():
|
||
"""Cancel all running calibrations."""
|
||
cancelled = []
|
||
for ticker, token in _cancel_tokens.items():
|
||
if not token.is_set():
|
||
token.set()
|
||
if ticker in _running_jobs:
|
||
_running_jobs[ticker]["status"] = "cancelled"
|
||
cancelled.append(ticker)
|
||
log.info(f"Calibration cancel all: {cancelled}")
|
||
return {"cancelled": cancelled}
|
||
|
||
@router.get("/api/calibration/badges/{ticker}")
|
||
async def get_calibration_badges(ticker: str):
|
||
"""Badge lookup for all setups on one asset."""
|
||
store = _get_cal_store()
|
||
badges = store.get_all_badges(ticker.upper())
|
||
if not badges:
|
||
return {"ticker": ticker.upper(), "calibrated": False, "badges": {}}
|
||
return {"ticker": ticker.upper(), "calibrated": True, "badges": badges}
|
||
|
||
@router.get("/api/calibration/detail/{ticker}/{signal_type}")
|
||
async def get_calibration_detail(ticker: str, signal_type: str):
|
||
"""
|
||
Detailed IS/OOS breakdown for one signal type on one asset.
|
||
Used by the calibration page drill-down.
|
||
|
||
Returns: {
|
||
ticker, signal_type,
|
||
overall: { grade, score, win_rate, expectancy, ... },
|
||
in_sample: { grade, score, win_rate, total, ... },
|
||
out_of_sample: { grade, score, win_rate, total, ... },
|
||
overfitting_risk, config, date_range, computed_at,
|
||
}
|
||
"""
|
||
store = _get_cal_store()
|
||
detail = store.get_detail(ticker.upper(), signal_type)
|
||
if detail is None:
|
||
raise HTTPException(
|
||
404,
|
||
f"No calibration detail for {ticker.upper()} / {signal_type}"
|
||
)
|
||
return detail
|
||
|
||
@router.get("/api/calibration/outcomes/{ticker}/{signal_type}")
|
||
async def get_calibration_outcomes(ticker: str, signal_type: str):
|
||
"""
|
||
Raw trade outcome records for one signal type on one asset.
|
||
Used by the trade chart visualization in the calibration page.
|
||
|
||
Returns: [
|
||
{ eval_date, direction, entry_price, stop_loss, target_price,
|
||
exit_price, exit_date, outcome, r_multiple, is_oos,
|
||
entry_confirmed, confirmation_pattern, ... },
|
||
...
|
||
]
|
||
"""
|
||
store = _get_cal_store()
|
||
outcomes = store.get_outcomes(ticker.upper(), signal_type)
|
||
if outcomes is None:
|
||
raise HTTPException(
|
||
404,
|
||
f"No outcome data for {ticker.upper()} / {signal_type}. "
|
||
f"Re-run calibration to generate outcomes."
|
||
)
|
||
return outcomes
|
||
|
||
@router.get("/api/calibration/{ticker}")
|
||
async def get_calibration_report(ticker: str):
|
||
"""Full calibration report for one asset."""
|
||
store = _get_cal_store()
|
||
report = store.load_report(ticker.upper())
|
||
if report is None:
|
||
raise HTTPException(404, f"No calibration report for {ticker.upper()}")
|
||
return report
|
||
|
||
@router.post("/api/calibration/run/{ticker}")
|
||
async def run_calibration_ticker(ticker: str, req: CalibrationRunRequest = None):
|
||
"""
|
||
Trigger backtest calibration for one asset (runs in background thread).
|
||
Returns immediately — poll /api/calibration/status for progress.
|
||
"""
|
||
canonical = ticker.upper()
|
||
|
||
if canonical in _running_jobs and _running_jobs[canonical].get("status") == "running":
|
||
raise HTTPException(409, f"Calibration already running for {canonical}")
|
||
|
||
if req is None:
|
||
req = CalibrationRunRequest()
|
||
|
||
_running_jobs[canonical] = {
|
||
"status": "running",
|
||
"ticker": canonical,
|
||
"started_at": datetime.now(timezone.utc).isoformat(),
|
||
"pct": 0,
|
||
"eval_date": "loading data...",
|
||
"eval_num": 0,
|
||
"eval_total": 0,
|
||
"signals": 0,
|
||
"wins": 0,
|
||
"losses": 0,
|
||
"elapsed_s": 0,
|
||
"eta_s": 0,
|
||
}
|
||
|
||
# Create cancel token for this job
|
||
cancel_token = threading.Event()
|
||
_cancel_tokens[canonical] = cancel_token
|
||
|
||
async def _run():
|
||
try:
|
||
from backtest_engine import BacktestEngine, BacktestConfig
|
||
|
||
# Resolve parallel workers: request → account settings → default 1
|
||
pw = req.parallel_workers
|
||
if pw <= 1:
|
||
try:
|
||
mgr = _get_account_manager()
|
||
pw = mgr.get_settings_dict().get("calibration_workers", 1) or 1
|
||
except Exception:
|
||
pw = 1
|
||
|
||
config = BacktestConfig(
|
||
mc_sims=req.mc_sims,
|
||
lookback_months=req.lookback_months,
|
||
parallel_workers=pw,
|
||
)
|
||
engine = BacktestEngine(config)
|
||
|
||
def _on_progress(info):
|
||
"""Update job status with live progress from engine."""
|
||
_running_jobs[canonical] = {
|
||
"status": "running",
|
||
"ticker": canonical,
|
||
"started_at": _running_jobs.get(canonical, {}).get("started_at", ""),
|
||
"pct": info.get("pct", 0),
|
||
"eval_date": info.get("eval_date", ""),
|
||
"eval_num": info.get("eval_num", 0),
|
||
"eval_total": info.get("eval_total", 0),
|
||
"signals": info.get("signals_so_far", 0),
|
||
"wins": info.get("wins_so_far", 0),
|
||
"losses": info.get("losses_so_far", 0),
|
||
"elapsed_s": info.get("elapsed_s", 0),
|
||
"eta_s": info.get("eta_s", 0),
|
||
}
|
||
|
||
report = await asyncio.to_thread(
|
||
engine.run_with_atr_sweep, canonical,
|
||
lookback_months=req.lookback_months,
|
||
progress_callback=_on_progress,
|
||
cancel_token=cancel_token,
|
||
)
|
||
|
||
# Check if cancelled
|
||
if cancel_token.is_set():
|
||
_running_jobs[canonical] = {
|
||
"status": "cancelled", "ticker": canonical,
|
||
}
|
||
log.info(f"Calibration cancelled for {canonical}")
|
||
else:
|
||
# Save to store
|
||
store = _get_cal_store()
|
||
store.save_report(report)
|
||
|
||
_running_jobs[canonical] = {
|
||
"status": "done",
|
||
"ticker": canonical,
|
||
"overall_score": report.overall_score,
|
||
"overall_grade": report.overall_grade,
|
||
"completed_at": datetime.now(timezone.utc).isoformat(),
|
||
}
|
||
|
||
await _notify("calibration_complete",
|
||
f"{canonical}: {report.overall_grade} ({report.overall_score:.2f})")
|
||
|
||
except Exception as e:
|
||
log.error(f"Calibration failed for {canonical}: {e}", exc_info=True)
|
||
_running_jobs[canonical] = {
|
||
"status": "error",
|
||
"ticker": canonical,
|
||
"error": str(e),
|
||
}
|
||
finally:
|
||
_cancel_tokens.pop(canonical, None)
|
||
|
||
asyncio.create_task(_run())
|
||
return {"status": "started", "ticker": canonical}
|
||
|
||
@router.post("/api/calibration/run")
|
||
async def run_calibration_universe(req: CalibrationRunRequest = None):
|
||
"""
|
||
Trigger calibration for the full universe (background).
|
||
Returns immediately — poll /api/calibration/status.
|
||
"""
|
||
if req is None:
|
||
req = CalibrationRunRequest()
|
||
|
||
# Get universe from config_manager
|
||
try:
|
||
from config_manager import ConfigManager
|
||
cm = ConfigManager()
|
||
tickers = cm.get_active_universe()
|
||
except Exception:
|
||
try:
|
||
from server_config import ASSET_UNIVERSE
|
||
tickers = list(ASSET_UNIVERSE)
|
||
except Exception:
|
||
raise HTTPException(500, "Cannot determine asset universe")
|
||
|
||
# Mark all as running
|
||
for t in tickers:
|
||
_running_jobs[t] = {
|
||
"status": "queued",
|
||
"ticker": t,
|
||
"started_at": datetime.now(timezone.utc).isoformat(),
|
||
"pct": 0,
|
||
"eval_date": "",
|
||
"eval_num": 0,
|
||
"eval_total": 0,
|
||
"signals": 0,
|
||
"wins": 0,
|
||
"losses": 0,
|
||
"elapsed_s": 0,
|
||
"eta_s": 0,
|
||
}
|
||
|
||
# Create a shared cancel token for the universe run
|
||
universe_cancel = threading.Event()
|
||
for t in tickers:
|
||
_cancel_tokens[t] = universe_cancel
|
||
|
||
async def _run_all():
|
||
try:
|
||
from backtest_engine import BacktestEngine, BacktestConfig
|
||
|
||
# Resolve parallel workers: request → account settings → default 1
|
||
pw = req.parallel_workers
|
||
if pw <= 1:
|
||
try:
|
||
mgr = _get_account_manager()
|
||
pw = mgr.get_settings_dict().get("calibration_workers", 1) or 1
|
||
except Exception:
|
||
pw = 1
|
||
|
||
config = BacktestConfig(
|
||
mc_sims=req.mc_sims,
|
||
lookback_months=req.lookback_months,
|
||
parallel_workers=pw,
|
||
)
|
||
engine = BacktestEngine(config)
|
||
store = _get_cal_store()
|
||
|
||
for i, ticker in enumerate(tickers):
|
||
# ── Cancel check between tickers ──
|
||
if universe_cancel.is_set():
|
||
for remaining in tickers[i:]:
|
||
_running_jobs[remaining] = {
|
||
"status": "cancelled", "ticker": remaining,
|
||
}
|
||
log.info(f"Universe calibration cancelled at ticker {i}/{len(tickers)}")
|
||
break
|
||
|
||
_running_jobs[ticker] = {
|
||
"status": "running",
|
||
"ticker": ticker,
|
||
"started_at": _running_jobs.get(ticker, {}).get("started_at", ""),
|
||
"pct": 0,
|
||
"eval_date": "loading data...",
|
||
"eval_num": 0,
|
||
"eval_total": 0,
|
||
"signals": 0,
|
||
"wins": 0,
|
||
"losses": 0,
|
||
"elapsed_s": 0,
|
||
"eta_s": 0,
|
||
}
|
||
|
||
def _make_cb(t):
|
||
"""Create a closure-safe callback for this ticker."""
|
||
def _on_progress(info):
|
||
_running_jobs[t] = {
|
||
"status": "running",
|
||
"ticker": t,
|
||
"started_at": _running_jobs.get(t, {}).get("started_at", ""),
|
||
"pct": info.get("pct", 0),
|
||
"eval_date": info.get("eval_date", ""),
|
||
"eval_num": info.get("eval_num", 0),
|
||
"eval_total": info.get("eval_total", 0),
|
||
"signals": info.get("signals_so_far", 0),
|
||
"wins": info.get("wins_so_far", 0),
|
||
"losses": info.get("losses_so_far", 0),
|
||
"elapsed_s": info.get("elapsed_s", 0),
|
||
"eta_s": info.get("eta_s", 0),
|
||
}
|
||
return _on_progress
|
||
|
||
try:
|
||
report = await asyncio.to_thread(
|
||
engine.run_with_atr_sweep, ticker,
|
||
lookback_months=req.lookback_months,
|
||
progress_callback=_make_cb(ticker),
|
||
cancel_token=universe_cancel,
|
||
)
|
||
|
||
if universe_cancel.is_set():
|
||
_running_jobs[ticker] = {
|
||
"status": "cancelled", "ticker": ticker,
|
||
}
|
||
else:
|
||
store.save_report(report)
|
||
_running_jobs[ticker] = {
|
||
"status": "done",
|
||
"ticker": ticker,
|
||
"overall_score": report.overall_score,
|
||
"overall_grade": report.overall_grade,
|
||
}
|
||
except Exception as e:
|
||
_running_jobs[ticker] = {
|
||
"status": "error",
|
||
"ticker": ticker,
|
||
"error": str(e),
|
||
}
|
||
|
||
if not universe_cancel.is_set():
|
||
await _notify("calibration_universe_complete",
|
||
f"{len(tickers)} assets calibrated")
|
||
|
||
except Exception as e:
|
||
log.error(f"Universe calibration failed: {e}", exc_info=True)
|
||
finally:
|
||
for t in tickers:
|
||
_cancel_tokens.pop(t, None)
|
||
|
||
asyncio.create_task(_run_all())
|
||
return {"status": "started", "tickers": tickers}
|
||
|
||
# ════════════════════════════════════════════════════════
|
||
# AUTO EXECUTOR ENDPOINTS (Phase 3G)
|
||
# ════════════════════════════════════════════════════════
|
||
|
||
@router.get("/api/auto/status")
|
||
async def get_auto_status():
|
||
"""Auto executor status — settings + today's activity."""
|
||
mgr = _get_account_manager()
|
||
settings = mgr.get_settings_dict()
|
||
|
||
# Read today's trade count from auto log
|
||
trades_today = 0
|
||
signals_processed = 0
|
||
auto_log_path = PROJECT_ROOT / "auto_executor_log.json"
|
||
if auto_log_path.exists():
|
||
try:
|
||
with open(auto_log_path, "r") as f:
|
||
log_data = json.load(f)
|
||
today_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||
for entry in reversed(log_data):
|
||
ts = entry.get("timestamp", "")
|
||
if not ts.startswith(today_str):
|
||
break
|
||
signals_processed += 1
|
||
if entry.get("action") in ("executed", "dry_run"):
|
||
trades_today += 1
|
||
except Exception:
|
||
pass
|
||
|
||
return {
|
||
"enabled": settings.get("auto_trading_enabled", False),
|
||
"log_only_mode": settings.get("auto_log_only", True),
|
||
"live_trading_on": mgr.settings.trading_enabled,
|
||
"trades_today": trades_today,
|
||
"signals_processed": signals_processed,
|
||
"max_daily_trades": settings.get("auto_max_daily_trades", 5),
|
||
"min_confidence": settings.get("auto_min_confidence", 0.60),
|
||
"min_grade": settings.get("auto_min_grade", "C"),
|
||
"allowed_types": settings.get("auto_allowed_types", []),
|
||
"allowed_tickers": settings.get("auto_allowed_tickers", []) or "all",
|
||
"scan_interval": settings.get("auto_scan_interval", 30.0),
|
||
}
|
||
|
||
@router.get("/api/auto/log")
|
||
async def get_auto_log(limit: int = 50):
|
||
"""Recent auto executor actions (audit trail)."""
|
||
auto_log_path = PROJECT_ROOT / "auto_executor_log.json"
|
||
if not auto_log_path.exists():
|
||
return {"actions": [], "count": 0}
|
||
try:
|
||
with open(auto_log_path, "r") as f:
|
||
data = json.load(f)
|
||
return {"actions": data[-limit:], "count": len(data)}
|
||
except Exception:
|
||
return {"actions": [], "count": 0}
|
||
|
||
@router.get("/api/auto/log/summary")
|
||
async def get_auto_log_summary(days: int = 30):
|
||
"""
|
||
Performance summary from auto executor log.
|
||
Aggregates by overall, per-ticker, per-signal-type, and per-ticker-type.
|
||
"""
|
||
auto_log_path = PROJECT_ROOT / "auto_executor_log.json"
|
||
if not auto_log_path.exists():
|
||
return {"trades": 0, "summary": {}}
|
||
try:
|
||
with open(auto_log_path, "r") as f:
|
||
all_data = json.load(f)
|
||
except Exception:
|
||
return {"trades": 0, "summary": {}}
|
||
|
||
# Filter to requested time window
|
||
cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
|
||
entries = [e for e in all_data
|
||
if e.get("action") in ("executed", "dry_run")
|
||
and e.get("timestamp", "") >= cutoff]
|
||
|
||
if not entries:
|
||
return {"trades": 0, "days": days, "summary": {}}
|
||
|
||
# Build aggregations
|
||
def _agg(subset):
|
||
count = len(subset)
|
||
tickers = list(set(e.get("ticker", "") for e in subset))
|
||
types = list(set(e.get("signal_type", "") for e in subset))
|
||
avg_conf = sum(e.get("confidence", 0) for e in subset) / count if count else 0
|
||
avg_rr = sum(e.get("risk_reward", 0) for e in subset) / count if count else 0
|
||
total_risk = sum(e.get("risk_usd", 0) for e in subset)
|
||
avg_risk_pct = sum(e.get("risk_pct", 0) for e in subset) / count if count else 0
|
||
grades = {}
|
||
for e in subset:
|
||
g = e.get("calibration_grade", "?")
|
||
grades[g] = grades.get(g, 0) + 1
|
||
regimes = {}
|
||
for e in subset:
|
||
r = e.get("regime", "?")
|
||
regimes[r] = regimes.get(r, 0) + 1
|
||
directions = {"long": 0, "short": 0}
|
||
for e in subset:
|
||
d = e.get("direction", "")
|
||
if d in directions:
|
||
directions[d] += 1
|
||
return {
|
||
"count": count,
|
||
"avg_confidence": round(avg_conf, 4),
|
||
"avg_risk_reward": round(avg_rr, 2),
|
||
"total_risk_usd": round(total_risk, 2),
|
||
"avg_risk_pct": round(avg_risk_pct, 4),
|
||
"grade_distribution": grades,
|
||
"regime_distribution": regimes,
|
||
"direction_split": directions,
|
||
"tickers": tickers,
|
||
"signal_types": types,
|
||
}
|
||
|
||
# Overall
|
||
summary = {"overall": _agg(entries)}
|
||
|
||
# Per ticker
|
||
by_ticker = {}
|
||
for e in entries:
|
||
t = e.get("ticker", "?")
|
||
by_ticker.setdefault(t, []).append(e)
|
||
summary["by_ticker"] = {t: _agg(v) for t, v in by_ticker.items()}
|
||
|
||
# Per signal type
|
||
by_type = {}
|
||
for e in entries:
|
||
st = e.get("signal_type", "?")
|
||
by_type.setdefault(st, []).append(e)
|
||
summary["by_signal_type"] = {st: _agg(v) for st, v in by_type.items()}
|
||
|
||
# Per ticker × signal type
|
||
by_ticker_type = {}
|
||
for e in entries:
|
||
key = f"{e.get('ticker', '?')}|{e.get('signal_type', '?')}"
|
||
by_ticker_type.setdefault(key, []).append(e)
|
||
summary["by_ticker_type"] = {k: _agg(v) for k, v in by_ticker_type.items()}
|
||
|
||
return {
|
||
"trades": len(entries),
|
||
"days": days,
|
||
"summary": summary,
|
||
}
|
||
|
||
# ════════════════════════════════════════════════════════
|
||
# TRADE MANAGER ENDPOINTS
|
||
# ════════════════════════════════════════════════════════
|
||
|
||
@router.get("/api/trade-manager/status")
|
||
async def get_trade_manager_status():
|
||
"""Trade manager status — managed positions and modes."""
|
||
try:
|
||
from trade_manager import get_trade_manager
|
||
tm = get_trade_manager()
|
||
return tm.get_status()
|
||
except ImportError:
|
||
raise HTTPException(503, "trade_manager module not available")
|
||
|
||
# ════════════════════════════════════════════════════════
|
||
# WALK-FORWARD CALIBRATION (Phase 3I)
|
||
# ════════════════════════════════════════════════════════
|
||
|
||
_wf_jobs = {} # ticker → {"status": "running"/"done"/"error", ...}
|
||
|
||
@router.post("/api/wf/run/{ticker}")
|
||
async def run_wf_ticker(ticker: str):
|
||
"""
|
||
Trigger walk-forward engine for one asset (background subprocess).
|
||
Calls: python walk_forward_engine.py --assets TICKER --sims 100000
|
||
Writes to backtest_results/best_wf_settings.json (merge-on-save).
|
||
Returns immediately — poll /api/wf/status for progress.
|
||
"""
|
||
import subprocess
|
||
canonical = ticker.upper()
|
||
|
||
if canonical in _wf_jobs and _wf_jobs[canonical].get("status") == "running":
|
||
raise HTTPException(409, f"WF already running for {canonical}")
|
||
|
||
_wf_jobs[canonical] = {
|
||
"status": "running",
|
||
"ticker": canonical,
|
||
"started_at": datetime.now(timezone.utc).isoformat(),
|
||
}
|
||
|
||
async def _run():
|
||
try:
|
||
# Walk-forward engine is verbose — redirect to log file, not PIPE.
|
||
cmd = [
|
||
"python", str(PROJECT_ROOT / "walk_forward_engine.py"),
|
||
"--assets", canonical,
|
||
"--sims", "100000",
|
||
"--step", "5",
|
||
]
|
||
wf_log_path = PROJECT_ROOT / "flow_output" / f"wf_{canonical}.log"
|
||
await _notify("wf_started",
|
||
f"Walk-forward engine started for {canonical}")
|
||
|
||
def _wf_runner():
|
||
p = _debug_popen(cmd, label=f"wf_{canonical}")
|
||
p.wait(timeout=900)
|
||
return p
|
||
proc = await asyncio.to_thread(_wf_runner)
|
||
|
||
if proc.returncode == 0:
|
||
_wf_jobs[canonical] = {
|
||
"status": "done",
|
||
"ticker": canonical,
|
||
"completed_at": datetime.now(timezone.utc).isoformat(),
|
||
"wf_log": str(wf_log_path),
|
||
}
|
||
await _notify("wf_done",
|
||
f"Walk-forward complete for {canonical} — best_wf_settings.json updated")
|
||
log.info(f"WF completed for {canonical}")
|
||
else:
|
||
tail = f"See subprocess_logs/wf_{canonical}.log"
|
||
_wf_jobs[canonical] = {
|
||
"status": "error",
|
||
"ticker": canonical,
|
||
"error": tail,
|
||
}
|
||
await _notify("wf_error",
|
||
f"Walk-forward FAILED for {canonical} — check flow_output/wf_{canonical}.log")
|
||
log.error(f"WF failed for {canonical} (log: {wf_log_path})")
|
||
except subprocess.TimeoutExpired:
|
||
_wf_jobs[canonical] = {
|
||
"status": "error", "ticker": canonical,
|
||
"error": "Timed out after 900s",
|
||
}
|
||
except Exception as e:
|
||
_wf_jobs[canonical] = {
|
||
"status": "error", "ticker": canonical,
|
||
"error": str(e),
|
||
}
|
||
|
||
asyncio.create_task(_run())
|
||
return {"status": "started", "ticker": canonical}
|
||
|
||
@router.get("/api/wf/status")
|
||
async def get_wf_status():
|
||
"""Walk-forward job status for all tickers."""
|
||
return _wf_jobs
|
||
|
||
# ════════════════════════════════════════════════════════
|
||
# FLOW CALIBRATION (Phase 3I)
|
||
# ════════════════════════════════════════════════════════
|
||
|
||
_flow_jobs = {} # ticker → {"status": "running"/"done"/"error", ...}
|
||
|
||
@router.post("/api/flow/calibrate/{ticker}")
|
||
async def run_flow_calibrate_ticker(ticker: str):
|
||
"""
|
||
Trigger flow_calibrator.py for one asset (background subprocess).
|
||
Updates flow_params.json for this ticker, then validates via
|
||
flow_confirmation.py CLI diagnostic.
|
||
Returns immediately — poll /api/flow/status for progress.
|
||
"""
|
||
import subprocess
|
||
canonical = ticker.upper()
|
||
|
||
if canonical in _flow_jobs and _flow_jobs[canonical].get("status") == "running":
|
||
raise HTTPException(409, f"Flow calibration already running for {canonical}")
|
||
|
||
_flow_jobs[canonical] = {
|
||
"status": "running",
|
||
"ticker": canonical,
|
||
"started_at": datetime.now(timezone.utc).isoformat(),
|
||
"phase": "calibrating",
|
||
}
|
||
|
||
async def _run():
|
||
try:
|
||
# Phase 1: Run flow_calibrator.py for this ticker
|
||
# IMPORTANT: flow_calibrator produces massive output (1944-combo grid search +
|
||
# GPU progress bars). capture_output=True / PIPE deadlocks when the pipe buffer
|
||
# fills (~64 KB on Windows). Redirect stdout+stderr to a log file instead.
|
||
# Rule: Use DEVNULL/file not PIPE for verbose subprocesses.
|
||
cal_cmd = [
|
||
"python", str(PROJECT_ROOT / "flow_calibrator.py"),
|
||
canonical,
|
||
]
|
||
_flow_jobs[canonical]["phase"] = "calibrating"
|
||
await _notify("flow_calibration_started",
|
||
f"Flow calibration started for {canonical} — GPU grid search running")
|
||
|
||
def _cal_runner():
|
||
p = _debug_popen(cal_cmd, label=f"flow_calibrator_{canonical}")
|
||
p.wait(timeout=900)
|
||
return p
|
||
proc = await asyncio.to_thread(_cal_runner)
|
||
|
||
if proc.returncode != 0:
|
||
# Read tail of log for error context
|
||
tail = f"See subprocess_logs/flow_calibrator_{canonical}.log"
|
||
_flow_jobs[canonical] = {
|
||
"status": "error", "ticker": canonical,
|
||
"phase": "calibration_failed",
|
||
"error": tail,
|
||
}
|
||
await _notify("flow_calibration_error",
|
||
f"Flow calibration FAILED for {canonical} — check flow_output/calibrate_{canonical}.log")
|
||
log.error(f"Flow calibration failed for {canonical} (log: {cal_log_path})")
|
||
return
|
||
|
||
# Phase 2: Run flow_confirmation.py CLI diagnostic to validate.
|
||
# Validator output is small (~50 lines) — PIPE is safe here.
|
||
_flow_jobs[canonical]["phase"] = "validating"
|
||
await _notify("flow_validation_started",
|
||
f"Flow calibration complete for {canonical} — running validator")
|
||
val_cmd = [
|
||
"python", str(PROJECT_ROOT / "flow_confirmation.py"),
|
||
canonical,
|
||
]
|
||
val_proc = await asyncio.to_thread(
|
||
subprocess.run, val_cmd,
|
||
cwd=str(PROJECT_ROOT),
|
||
capture_output=True, text=True, timeout=120,
|
||
env={**__import__("os").environ, "PYTHONUTF8": "1"},
|
||
)
|
||
|
||
_flow_jobs[canonical] = {
|
||
"status": "done",
|
||
"ticker": canonical,
|
||
"completed_at": datetime.now(timezone.utc).isoformat(),
|
||
"validation_output": val_proc.stdout[-1000:] if val_proc.stdout else "",
|
||
}
|
||
await _notify("flow_calibration_done",
|
||
f"Flow calibration + validation complete for {canonical} — flow_params.json updated")
|
||
log.info(f"Flow calibration + validation complete for {canonical}")
|
||
|
||
except subprocess.TimeoutExpired:
|
||
_flow_jobs[canonical] = {
|
||
"status": "error", "ticker": canonical,
|
||
"error": "Timed out after 900s",
|
||
}
|
||
await _notify("flow_calibration_error",
|
||
f"Flow calibration TIMED OUT for {canonical} (>900s)")
|
||
except Exception as e:
|
||
_flow_jobs[canonical] = {
|
||
"status": "error", "ticker": canonical,
|
||
"error": str(e),
|
||
}
|
||
await _notify("flow_calibration_error",
|
||
f"Flow calibration ERROR for {canonical}: {e}")
|
||
|
||
asyncio.create_task(_run())
|
||
return {"status": "started", "ticker": canonical}
|
||
|
||
@router.get("/api/flow/status")
|
||
async def get_flow_status():
|
||
"""Flow calibration job status for all tickers."""
|
||
return _flow_jobs
|
||
|
||
return router |