Files
QuantumTerminal/backend/account_routes.py
T

1186 lines
49 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
================================================================================
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