Strategy promotion added

This commit is contained in:
dam
2026-03-08 12:40:35 +01:00
parent a42255d58c
commit ca36383bb3
5 changed files with 286 additions and 24 deletions

View File

@@ -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

View File

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

View File

@@ -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]

View File

@@ -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 = `
<td>${r.rank}</td>
<td>${r.strategy_id}</td>
<td>${r.score}</td>
<td>${r.status}</td>
`;
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("&", "&amp;")
@@ -1979,6 +2103,7 @@ function escapeHtml(str) {
.replaceAll("'", "&#039;");
}
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);

View File

@@ -290,6 +290,36 @@
</div>
</div>
<!-- ========================= -->
<!-- Strategy Promotion / Selection -->
<!-- ========================= -->
<div class="card mb-4">
<div class="card-header">
<h3 class="card-title">Strategy Promotion / Selection</h3>
</div>
<div class="card-body">
<button id="btnRunPromotion" class="btn btn-primary">
Run Promotion
</button>
<div id="promotionSummary" class="mt-3"></div>
<table class="table mt-3" id="promotionTable">
<thead>
<tr>
<th>Rank</th>
<th>Strategy</th>
<th>Score</th>
<th>Status</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<!-- ========================= -->
<!-- Results -->
<!-- ========================= -->