Files
QuantumTerminal/backend/account_routes.py
T

1186 lines
49 KiB
Python
Raw Normal View History

"""
================================================================================
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