From ca36383bb363467a894698c4ce42a23e30a45654 Mon Sep 17 00:00:00 2001 From: dam Date: Sun, 8 Mar 2026 12:40:35 +0100 Subject: [PATCH] Strategy promotion added --- src/calibration/strategy_promotion.py | 68 +++++++ .../api/v2/routers/calibration_strategies.py | 13 ++ .../api/v2/schemas/calibration_strategies.py | 23 +++ .../static/js/pages/calibration_strategies.js | 176 +++++++++++++++--- .../calibration/calibration_strategies.html | 30 +++ 5 files changed, 286 insertions(+), 24 deletions(-) create mode 100644 src/calibration/strategy_promotion.py diff --git a/src/calibration/strategy_promotion.py b/src/calibration/strategy_promotion.py new file mode 100644 index 0000000..7d1acaf --- /dev/null +++ b/src/calibration/strategy_promotion.py @@ -0,0 +1,68 @@ +#src/calibration/strategy_promotion.py +from typing import List, Dict, Any + +def _clamp(v, lo, hi): + return max(lo, min(hi, v)) + +def _score_return(oos_return): + return _clamp((oos_return / 50.0) * 30.0, 0, 30) + +def _score_stability(positive_rate, std_return): + score = positive_rate * 15 + score += _clamp(10 - std_return, 0, 10) + return _clamp(score, 0, 25) + +def _score_risk(worst_dd): + return _clamp((1 + worst_dd / 20.0) * 20.0, 0, 20) + +def _score_trades(avg_trades): + return _clamp(avg_trades / 10.0 * 10.0, 0, 10) + +def _score_regime(regime_detail: Dict[str, Any]): + if not regime_detail: + return 0 + positives = sum(1 for r in regime_detail.values() if r.get("avg_return", 0) >= 0) + total = len(regime_detail) + return (positives / total) * 15 if total else 0 + +def evaluate_strategy(strategy: Dict[str, Any], config: Dict[str, Any]): + metrics = strategy.get("diagnostics", {}) + stability = metrics.get("stability", {}) + trades = metrics.get("trades", {}) + regimes = metrics.get("regimes", {}).get("performance", {}).get("detail", {}) + + oos_return = strategy.get("oos_total_return_pct", 0) + worst_dd = strategy.get("oos_max_dd_worst_pct", -100) + positive_rate = stability.get("positive_window_rate", 0) + std_return = stability.get("std_return_pct", 0) + avg_trades = trades.get("avg_trades_per_window", 0) + + score = 0 + score += _score_return(oos_return) + score += _score_stability(positive_rate, std_return) + score += _score_risk(worst_dd) + score += _score_trades(avg_trades) + score += _score_regime(regimes) + + promote_thr = config.get("promote_score_threshold", 70) + review_thr = config.get("review_score_threshold", 55) + + if score >= promote_thr: + status = "promote" + elif score >= review_thr: + status = "review" + else: + status = "reject" + + return { + "strategy_id": strategy.get("strategy_id"), + "score": round(score, 2), + "status": status, + } + +def rank_strategies(strategies: List[Dict[str, Any]], config: Dict[str, Any]): + evaluated = [evaluate_strategy(s, config) for s in strategies] + evaluated.sort(key=lambda x: x["score"], reverse=True) + for i, e in enumerate(evaluated): + e["rank"] = i + 1 + return evaluated \ No newline at end of file diff --git a/src/web/api/v2/routers/calibration_strategies.py b/src/web/api/v2/routers/calibration_strategies.py index 09e2c70..5a97c0f 100644 --- a/src/web/api/v2/routers/calibration_strategies.py +++ b/src/web/api/v2/routers/calibration_strategies.py @@ -16,11 +16,14 @@ from src.calibration.strategies_inspector import ( list_available_strategies, ) from src.calibration.reports.strategies_report import generate_strategies_report_pdf +from src.calibration.strategy_promotion import rank_strategies from ..schemas.calibration_strategies import ( CalibrationStrategiesInspectRequest, CalibrationStrategiesInspectResponse, CalibrationStrategiesValidateResponse, + CalibrationStrategiesPromoteRequest, + CalibrationStrategiesPromoteResponse, ) logger = logging.getLogger("tradingbot.api.v2") @@ -94,6 +97,7 @@ def inspect_strategies( ) return CalibrationStrategiesInspectResponse(**result) + @router.post("/validate", response_model=CalibrationStrategiesValidateResponse) def validate_strategies( payload: CalibrationStrategiesInspectRequest, @@ -115,6 +119,13 @@ def validate_strategies( ) return CalibrationStrategiesValidateResponse(**result) + +@router.post("/promote", response_model=CalibrationStrategiesPromoteResponse) +def promote_strategies(payload: CalibrationStrategiesPromoteRequest): + results = rank_strategies(payload.strategies, payload.promotion.dict()) + return {"results": results} + + @router.post("/report") def report_strategies( payload: CalibrationStrategiesInspectRequest, @@ -178,6 +189,7 @@ def report_strategies( public_url = f"/reports/strategies/{safe_symbol}/{filename}" return JSONResponse(content={"status": result.get("status", "ok"), "url": public_url}) + @router.post("/run") def run_strategies_async( payload: CalibrationStrategiesInspectRequest, @@ -247,6 +259,7 @@ def run_strategies_async( return {"job_id": job_id} + @router.get("/status/{job_id}") def get_status(job_id: str): return WF_JOBS.get(job_id, {"status": "unknown"}) diff --git a/src/web/api/v2/schemas/calibration_strategies.py b/src/web/api/v2/schemas/calibration_strategies.py index 805175b..f0a43fd 100644 --- a/src/web/api/v2/schemas/calibration_strategies.py +++ b/src/web/api/v2/schemas/calibration_strategies.py @@ -85,3 +85,26 @@ class CalibrationStrategiesInspectResponse(BaseModel): class CalibrationStrategiesValidateResponse(CalibrationStrategiesInspectResponse): series: Dict[str, Any] + + +class PromotionConfigSchema(BaseModel): + min_windows_required: int = 6 + min_positive_window_rate: float = 0.45 + max_worst_window_dd_pct: float = -15 + max_low_trade_window_ratio: float = 0.40 + min_avg_trades_per_window: float = 3 + promote_score_threshold: float = 70 + review_score_threshold: float = 55 + +class PromotionResultSchema(BaseModel): + strategy_id: str + score: float + status: Literal["promote", "review", "reject"] + rank: int + +class CalibrationStrategiesPromoteRequest(BaseModel): + strategies: List[Dict[str, Any]] + promotion: PromotionConfigSchema + +class CalibrationStrategiesPromoteResponse(BaseModel): + results: List[PromotionResultSchema] \ No newline at end of file diff --git a/src/web/ui/v2/static/js/pages/calibration_strategies.js b/src/web/ui/v2/static/js/pages/calibration_strategies.js index 6eeb5c3..3829153 100644 --- a/src/web/ui/v2/static/js/pages/calibration_strategies.js +++ b/src/web/ui/v2/static/js/pages/calibration_strategies.js @@ -12,6 +12,7 @@ const MAX_STRATEGIES = 10; // WIZARD NAVIGATION // ================================================= + function enableNextStep() { const btn = document.getElementById("next-step-btn"); if (!btn) return; @@ -20,6 +21,7 @@ function enableNextStep() { btn.setAttribute("aria-disabled", "false"); } + function disableNextStep() { const btn = document.getElementById("next-step-btn"); if (!btn) return; @@ -28,10 +30,12 @@ function disableNextStep() { btn.setAttribute("aria-disabled", "true"); } + // ================================================= // UTILS // ================================================= + function loadContextFromLocalStorage() { const symbol = localStorage.getItem("calibration.symbol"); const timeframe = localStorage.getItem("calibration.timeframe"); @@ -81,16 +85,19 @@ function loadContextFromLocalStorage() { if (slippage) setVal("slippage", slippage); } + function setVal(id, v) { const el = document.getElementById(id); if (el) el.value = v; } + function str(id) { const el = document.getElementById(id); return el ? String(el.value || "").trim() : ""; } + function num(id) { const el = document.getElementById(id); if (!el) return null; @@ -98,14 +105,17 @@ function num(id) { return Number.isFinite(v) ? v : null; } + function sleep(ms) { return new Promise(res => setTimeout(res, ms)); } + // ================================================= // FETCH CATALOG // ================================================= + async function fetchCatalog() { const symbol = str("symbol"); const timeframe = str("timeframe"); @@ -118,16 +128,19 @@ async function fetchCatalog() { console.log("[catalog] strategies:", STRATEGY_CATALOG.map(s => s.strategy_id)); } + // ================================================= // UI RENDERING // ================================================= + function initSlots() { strategySlots = [ { strategy_id: null, parameters: {} } ]; } + function rerenderStrategySlots() { const container = document.getElementById("strategies_container"); @@ -157,6 +170,7 @@ function rerenderStrategySlots() { updateCombinationCounter(); } + function renderStrategySlot(index) { const container = document.getElementById("strategies_container"); @@ -206,6 +220,7 @@ function renderStrategySlot(index) { } } + function onStrategySelected(index, strategyId) { if (!strategyId) { @@ -228,6 +243,7 @@ function onStrategySelected(index, strategyId) { updateCombinationCounter(); } + function renderParametersOnly(index, strategyId) { const paramsContainer = document.getElementById(`strategy_params_${index}`); @@ -304,6 +320,7 @@ function renderParametersOnly(index, strategyId) { validateParameterInputs(); } + function validateParameterInputs() { let valid = true; @@ -380,6 +397,7 @@ function validateParameterInputs() { return valid; } + function updateCombinationCounter() { let hasAnyStrategy = false; @@ -407,6 +425,7 @@ function updateCombinationCounter() { return globalTotal; } + function applyCombinationWarnings(total) { const maxComb = parseInt( @@ -425,6 +444,7 @@ function applyCombinationWarnings(total) { } } + function updateTimeEstimate(totalComb) { const trainDays = parseInt( @@ -459,6 +479,7 @@ function updateTimeEstimate(totalComb) { if (el) el.textContent = label; } + function removeStrategySlot(index) { strategySlots.splice(index, 1); @@ -466,10 +487,12 @@ function removeStrategySlot(index) { rerenderStrategySlots(); } + // ================================================= // PAYLOAD // ================================================= + function buildPayload() { const symbol = str("symbol"); const timeframe = str("timeframe"); @@ -530,6 +553,7 @@ function buildPayload() { }; } + function collectSelectedStrategies() { const strategies = []; @@ -581,33 +605,13 @@ function collectSelectedStrategies() { return strategies; } + async function fetchAvailableStrategies() { const res = await fetch("/api/v2/calibration/strategies/catalog"); const data = await res.json(); return data.strategies || []; } -function num(id) { - const el = document.getElementById(id); - if (!el) return null; - const val = el.value; - if (val === "" || val === null || val === undefined) return null; - const n = Number(val); - return Number.isFinite(n) ? n : null; -} - -function str(id) { - const el = document.getElementById(id); - if (!el) return null; - const v = el.value; - return v === null || v === undefined ? null : String(v); -} - -function setVal(id, value) { - const el = document.getElementById(id); - if (!el) return; - el.value = value ?? ""; -} function loadFromStep2() { @@ -649,6 +653,7 @@ function loadFromStep2() { console.log("[calibration_strategies] Parameters loaded from Step 2 ✅"); } + function updateStopUI() { const type = document.getElementById("stop_type").value; @@ -670,6 +675,7 @@ function updateStopUI() { } } + async function loadStrategyCatalog() { const res = await fetch("/api/v2/calibration/strategies/catalog"); @@ -678,6 +684,7 @@ async function loadStrategyCatalog() { STRATEGY_CATALOG = data.strategies; } + function addStrategySlot() { // Si ya hay un slot vacío al final, no crear otro @@ -698,10 +705,12 @@ function addStrategySlot() { renderStrategySlot(index); } + // ================================================= // PROGRESS BAR // ================================================= + function startWF() { document .getElementById("wf_progress_card") @@ -711,6 +720,7 @@ function startWF() { document.getElementById("wfProgressBar").innerText = "0%"; } + async function pollStatus(jobId) { const interval = setInterval(async () => { const res = await fetch(`/api/v2/calibration/strategies/status/${jobId}`); @@ -733,6 +743,7 @@ async function pollStatus(jobId) { // DIFFERENT RENDER PLOTS // ================================================= + function renderEquityAndReturns(strategyId, s, data) { const equity = s.window_equity || []; const ret = s.window_returns_pct || []; @@ -1090,6 +1101,7 @@ function renderEquityAndReturns(strategyId, s, data) { }); } + function renderRollingSharpe(strategyId, s, data) { const roll = s.diagnostics?.rolling?.rolling_sharpe_like || []; const x = roll.map((_, i) => i + 1); @@ -1118,6 +1130,7 @@ function renderRollingSharpe(strategyId, s, data) { ); } + function renderOOSReturnsDistribution(strategyId, s, data) { const edges = s.diagnostics?.distribution?.hist_bin_edges || []; const counts = s.diagnostics?.distribution?.hist_counts || []; @@ -1137,6 +1150,7 @@ function renderOOSReturnsDistribution(strategyId, s, data) { }); } + function renderDrawdownEvolution(strategyId, s, data) { const dd = s.diagnostics?.drawdown?.drawdown_pct || []; const x = dd.map((_, i) => i); @@ -1155,6 +1169,7 @@ function renderDrawdownEvolution(strategyId, s, data) { }); } + function renderTradeDensity(strategyId, s, data) { const tpw = s.diagnostics?.trades?.trades_per_window || []; const tpd = s.diagnostics?.trades?.trades_per_day || []; @@ -1203,10 +1218,12 @@ function renderTradeDensity(strategyId, s, data) { }); } + // ================================================= // RENDER RESULTS // ================================================= + function renderStrategiesList(strategies) { const list = document.getElementById("strategies_list"); if (!list) return; @@ -1245,6 +1262,7 @@ function renderStrategiesList(strategies) { }); } + function setBadge(status) { const badge = document.getElementById("strategies_status_badge"); if (!badge) return; @@ -1256,6 +1274,7 @@ function setBadge(status) { badge.textContent = status ? status.toUpperCase() : "—"; } + function renderResultsTable(data) { const wrap = document.getElementById("strategies_table_wrap"); if (!wrap) return; @@ -1300,6 +1319,7 @@ function renderResultsTable(data) { `; } + function populatePlotSelector(data) { const sel = document.getElementById("plot_strategy_select"); if (!sel) return; @@ -1326,6 +1346,7 @@ function populatePlotSelector(data) { } } + function selectStrategy(strategyId, data) { if (!strategyId || !data) return; @@ -1385,9 +1406,11 @@ function selectStrategy(strategyId, data) { }); } + function renderValidateResponse(data) { lastValidationResult = data; + window.lastStrategiesResult = Array.isArray(data.results) ? data.results : []; // ------------------------------- // 1️⃣ Badge + message @@ -1506,6 +1529,7 @@ function renderValidateResponse(data) { } } + function renderChart(chartType, strategyId, s, data) { switch (chartType) { case "equity": @@ -1529,6 +1553,7 @@ function renderChart(chartType, strategyId, s, data) { } } + function highlightSelectedRow(strategyId) { document.querySelectorAll(".strategy-row").forEach(el => { @@ -1544,6 +1569,7 @@ function highlightSelectedRow(strategyId) { } } + async function validateStrategies() { console.log("[calibration_strategies] validateStrategies() START"); @@ -1639,6 +1665,7 @@ async function validateStrategies() { } } + async function generateReport() { console.log("[calibration_strategies] generateReport() START"); @@ -1669,6 +1696,7 @@ async function generateReport() { } } + function wireButtons() { document.getElementById("validate_strategies_btn")?.addEventListener("click", validateStrategies); document.getElementById("report_strategies_btn")?.addEventListener("click", generateReport); @@ -1690,6 +1718,7 @@ function wireButtons() { }); } + function applyInheritedLock() { const locked = document.getElementById("lock_inherited").checked; const fields = document.querySelectorAll(".inherited-field"); @@ -1704,6 +1733,7 @@ function applyInheritedLock() { }); } + async function init() { await loadStrategyCatalog(); addStrategySlot(); @@ -1716,12 +1746,16 @@ async function init() { .addEventListener("change", updateStopUI); wireButtons(); + wirePromotionUI(); document.getElementById("plot_strategy_select").addEventListener("change", function() { - const chartType = this.value; - const strategyData = lastValidationResult.series.strategies[selectedStrategyId]; + if (!lastValidationResult || !selectedStrategyId) return; + + const strategyData = lastValidationResult?.series?.strategies?.[selectedStrategyId]; + if (!strategyData) return; + + const chartType = this.value; - // Verifica que selectedStrategyId tenga el valor correcto console.log("selectedStrategyId:", selectedStrategyId); console.log("Strategy Data:", strategyData); @@ -1738,20 +1772,24 @@ async function init() { }, 0); } + // ================================================= // MARKET REGIME // ================================================= + function fmtPct(v, digits = 2) { const n = Number(v ?? 0); return `${n.toFixed(digits)}%`; } + function fmtNum(v, digits = 2) { const n = Number(v ?? 0); return n.toFixed(digits); } + function regimeBadgeClass(regime) { switch (regime) { case "bull": @@ -1767,6 +1805,7 @@ function regimeBadgeClass(regime) { } } + function regimeLabel(regime) { switch (regime) { case "bull_strong": return "Bull strong"; @@ -1779,6 +1818,7 @@ function regimeLabel(regime) { } } + function ensureRegimeContainer() { let el = document.getElementById("regime_analysis_wrap"); if (el) return el; @@ -1794,11 +1834,13 @@ function ensureRegimeContainer() { return el; } + function clearRegimeSummary() { const el = document.getElementById("regime_analysis_wrap"); if (el) el.innerHTML = ""; } + function getBestRegime(perf) { const candidates = ["bull", "sideways", "bear"] .map((k) => ({ @@ -1814,6 +1856,7 @@ function getBestRegime(perf) { return candidates[0].regime; } + function renderRegimeSummary(strategyId, s, data) { const wrap = ensureRegimeContainer(); if (!wrap) return; @@ -1951,10 +1994,90 @@ function renderRegimeSummary(strategyId, s, data) { `; } + +// ================================================= +// STRATEGY PROMOTION +// ================================================= + + +async function runPromotion() { + + if (!Array.isArray(window.lastStrategiesResult) || window.lastStrategiesResult.length === 0) { + alert("Primero ejecuta Validate Strategies para generar resultados."); + return; + } + + try { + const payload = { + strategies: window.lastStrategiesResult, + promotion: { + promote_score_threshold: 70, + review_score_threshold: 55 + } + }; + + const res = await fetch('/api/v2/calibration/strategies/promote', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(payload) + }); + + if (!res.ok) { + const errText = await res.text(); + throw new Error(`Promotion failed: ${res.status} ${errText}`); + } + + const data = await res.json(); + renderPromotionResults(data.results || []); + + } catch (err) { + console.error(err); + alert(`Promotion error: ${err.message}`); + } +} + + +function renderPromotionResults(results) { + + const table = document.querySelector("#promotionTable tbody"); + table.innerHTML = ""; + + results.forEach(r => { + + const tr = document.createElement("tr"); + + tr.innerHTML = ` + ${r.rank} + ${r.strategy_id} + ${r.score} + ${r.status} + `; + + table.appendChild(tr); + }); + + const promoted = results.filter(r => r.status === "promote").length; + const review = results.filter(r => r.status === "review").length; + const reject = results.filter(r => r.status === "reject").length; + + document.getElementById("promotionSummary").innerHTML = + `Promoted: ${promoted} | Review: ${review} | Rejected: ${reject}`; +} + + +function wirePromotionUI() { + const btn = document.getElementById("btnRunPromotion"); + if (btn) { + btn.addEventListener("click", runPromotion); + } +} + + // ================================================= // PLOT ALERTS (Tabler) + SAFE PLOT CLEAR // ================================================= + function ensurePlotAlertContainer() { // Lo colocamos antes del primer plot si existe let el = document.getElementById("plot_alert"); @@ -1970,6 +2093,7 @@ function ensurePlotAlertContainer() { return el; } + function escapeHtml(str) { return String(str ?? "") .replaceAll("&", "&") @@ -1979,6 +2103,7 @@ function escapeHtml(str) { .replaceAll("'", "'"); } + function showPlotAlert(type, title, message, warnings) { const el = ensurePlotAlertContainer(); if (!el) return; @@ -1998,11 +2123,13 @@ function showPlotAlert(type, title, message, warnings) { `; } + function clearPlotAlert() { const el = document.getElementById("plot_alert"); if (el) el.innerHTML = ""; } + function clearPlots() { const eq = document.getElementById("plot_strategy"); if (eq) eq.innerHTML = ""; @@ -2010,6 +2137,7 @@ function clearPlots() { clearRegimeSummary(); } + document.getElementById("lock_inherited") .addEventListener("change", applyInheritedLock); diff --git a/src/web/ui/v2/templates/pages/calibration/calibration_strategies.html b/src/web/ui/v2/templates/pages/calibration/calibration_strategies.html index 6e6f14e..1d2fefb 100644 --- a/src/web/ui/v2/templates/pages/calibration/calibration_strategies.html +++ b/src/web/ui/v2/templates/pages/calibration/calibration_strategies.html @@ -290,6 +290,36 @@ + + + + +
+
+

Strategy Promotion / Selection

+
+ +
+ + +
+ + + + + + + + + + + +
RankStrategyScoreStatus
+
+
+