Files
Trading-Bot/src/web/ui/v2/static/js/pages/calibration_strategies.js
dam a42255d58c Step 3 – Strategy validation, regime detection and UI improvements
Main changes:
- Implemented multi-horizon trend regime detection in src/core/market_regime.py
  - EMA(20, 50, 100, 200) trend score model
  - 5 regime states: bull_strong, bull_moderate, sideways, bear_moderate, bear_strong
  - asymmetric persistence filter (bull 5 bars, sideways 3 bars, bear 2 bars)
  - window regime classification using average trend score

- Extended walk-forward outputs
  - window_regimes now include:
    - regime_detail
    - bull_strong_pct
    - bull_moderate_pct
    - sideways_detail_pct
    - bear_moderate_pct
    - bear_strong_pct
    - avg_score

- UI improvements in Step 3
  - regime analysis cards redesigned to show 5 regimes
  - visual "M layout": bull regimes on top, sideways + bear regimes below
  - table updated to display detailed regime percentages
  - equity chart background colored by regime (colorblind-friendly palette)
  - trade density chart improved with aligned Y-axis zero levels

- UX improvements
  - automatic scroll to charts when selecting a strategy
  - better regime badges and labeling
  - colorblind-friendly visualization

Result:
Step 3 now provides full strategy inspection including:
- OOS performance
- regime behaviour
- regime distribution per WF window
- visual regime overlays

Next step:
Implement strategy promotion / selection layer before Step 4 (parameter refinement).
2026-03-06 20:39:37 +01:00

2017 lines
57 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// src/web/ui/v2/static/js/pages/calibration_strategies.js
console.log("[calibration_strategies] script loaded ✅", new Date().toISOString());
let STRATEGY_CATALOG = [];
let strategySlots = [];
let selectedStrategyId = null;
let lastValidationResult = null;
const MAX_STRATEGIES = 10;
// =================================================
// WIZARD NAVIGATION
// =================================================
function enableNextStep() {
const btn = document.getElementById("next-step-btn");
if (!btn) return;
btn.classList.remove("btn-outline-secondary");
btn.classList.add("btn-outline-primary");
btn.setAttribute("aria-disabled", "false");
}
function disableNextStep() {
const btn = document.getElementById("next-step-btn");
if (!btn) return;
btn.classList.remove("btn-outline-primary");
btn.classList.add("btn-outline-secondary");
btn.setAttribute("aria-disabled", "true");
}
// =================================================
// UTILS
// =================================================
function loadContextFromLocalStorage() {
const symbol = localStorage.getItem("calibration.symbol");
const timeframe = localStorage.getItem("calibration.timeframe");
if (symbol) setVal("symbol", symbol);
if (timeframe) setVal("timeframe", timeframe);
// Step 2 snapshot (if stored)
const stop_type = localStorage.getItem("calibration.stop.type");
const stop_fraction = localStorage.getItem("calibration.stop.stop_fraction");
const atr_period = localStorage.getItem("calibration.stop.atr_period");
const atr_multiplier = localStorage.getItem("calibration.stop.atr_multiplier");
const risk_fraction = localStorage.getItem("calibration.risk.risk_fraction");
const max_position_fraction = localStorage.getItem("calibration.risk.max_position_fraction");
const max_drawdown_pct = localStorage.getItem("calibration.rules.max_drawdown_pct");
const daily_loss_limit_pct = localStorage.getItem("calibration.rules.daily_loss_limit_pct");
const max_consecutive_losses = localStorage.getItem("calibration.rules.max_consecutive_losses");
const cooldown_bars = localStorage.getItem("calibration.rules.cooldown_bars");
const account_equity = localStorage.getItem("calibration.account_equity");
if (stop_type) setVal("stop_type", stop_type);
if (stop_fraction) setVal("stop_fraction", stop_fraction);
if (atr_period) setVal("atr_period", atr_period);
if (atr_multiplier) setVal("atr_multiplier", atr_multiplier);
if (risk_fraction) setVal("risk_fraction", risk_fraction);
if (max_position_fraction) setVal("max_position_fraction", max_position_fraction);
if (max_drawdown_pct) setVal("max_drawdown_pct", max_drawdown_pct);
if (daily_loss_limit_pct) setVal("daily_loss_limit_pct", daily_loss_limit_pct);
if (max_consecutive_losses) setVal("max_consecutive_losses", max_consecutive_losses);
if (cooldown_bars) setVal("cooldown_bars", cooldown_bars);
if (account_equity) setVal("account_equity", account_equity);
// WF defaults (if stored)
const wf_train_days = localStorage.getItem("calibration.wf.train_days");
const wf_test_days = localStorage.getItem("calibration.wf.test_days");
const wf_step_days = localStorage.getItem("calibration.wf.step_days");
const wf_min_trades_test = localStorage.getItem("calibration.wf.min_trades_test");
if (wf_train_days) setVal("wf_train_days", wf_train_days);
if (wf_test_days) setVal("wf_test_days", wf_test_days);
if (wf_step_days) setVal("wf_step_days", wf_step_days);
if (wf_min_trades_test) setVal("wf_min_trades_test", wf_min_trades_test);
// Optional fees
const commission = localStorage.getItem("calibration.commission");
const slippage = localStorage.getItem("calibration.slippage");
if (commission) setVal("commission", commission);
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;
const v = parseFloat(el.value);
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");
const res = await fetch(`/api/v2/calibration/strategies/catalog?symbol=${encodeURIComponent(symbol)}&timeframe=${encodeURIComponent(timeframe)}`);
if (!res.ok) throw new Error(`catalog failed: ${res.status}`);
const data = await res.json();
STRATEGY_CATALOG = data.strategies || [];
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");
container.innerHTML = "";
const currentStrategies = strategySlots
.filter(s => s.strategy_id !== null);
strategySlots = [];
currentStrategies.forEach((slotData, index) => {
strategySlots.push({
strategy_id: slotData.strategy_id,
parameters: slotData.parameters || {}
});
renderStrategySlot(index);
});
// always one empty slot at end (if room)
if (strategySlots.length < MAX_STRATEGIES) {
strategySlots.push({ strategy_id: null, parameters: {} });
renderStrategySlot(strategySlots.length - 1);
}
updateCombinationCounter();
}
function renderStrategySlot(index) {
const container = document.getElementById("strategies_container");
const slot = document.createElement("div");
slot.className = "card p-3";
slot.id = `strategy_slot_${index}`;
slot.innerHTML = `
<div class="mb-3">
<label class="form-label">Strategy ${index + 1}</label>
<select class="form-select" id="strategy_select_${index}">
<option value="">-- Select --</option>
${STRATEGY_CATALOG.map(s => `<option value="${s.strategy_id}">${s.name}</option>`).join("")}
</select>
</div>
<div class="row g-3" id="strategy_params_${index}"></div>
<div class="d-flex justify-content-between align-items-center mt-3">
<small class="text-muted">Combinations: <span id="strategy_combo_${index}">0</span></small>
<button class="btn btn-outline-danger btn-sm" id="remove_strategy_${index}" type="button">
Remove
</button>
</div>
`;
container.appendChild(slot);
const select = document.getElementById(`strategy_select_${index}`);
const removeBtn = document.getElementById(`remove_strategy_${index}`);
// set initial value
select.value = strategySlots[index].strategy_id || "";
select.addEventListener("change", (e) => {
onStrategySelected(index, e.target.value);
});
removeBtn.addEventListener("click", () => {
removeStrategySlot(index);
});
// if already selected, render params
if (strategySlots[index].strategy_id) {
renderParametersOnly(index, strategySlots[index].strategy_id);
}
}
function onStrategySelected(index, strategyId) {
if (!strategyId) {
removeStrategySlot(index);
return;
}
strategySlots[index].strategy_id = strategyId;
renderParametersOnly(index, strategyId);
// Si es el último slot activo, añadir nuevo vacío
if (index === strategySlots.length - 1 &&
strategySlots.length < MAX_STRATEGIES) {
strategySlots.push({ strategy_id: null, parameters: {} });
renderStrategySlot(strategySlots.length - 1);
}
updateCombinationCounter();
}
function renderParametersOnly(index, strategyId) {
const paramsContainer = document.getElementById(`strategy_params_${index}`);
paramsContainer.innerHTML = "";
if (!strategyId) return;
const strategyMeta = STRATEGY_CATALOG.find(
s => s.strategy_id === strategyId
);
if (!strategyMeta || !strategyMeta.parameters_meta) return;
strategyMeta.parameters_meta.forEach(meta => {
const col = document.createElement("div");
col.className = "col-md-4";
let inputHtml = "";
const paramName = meta.name;
// INT / FLOAT
if (meta.type === "int" || meta.type === "float") {
inputHtml = `
<input type="number"
class="form-control param-input"
id="${paramName}_value_${index}"
step="${meta.type === "int" ? "1" : "any"}"
${meta.min !== null ? `min="${meta.min}"` : ""}
${meta.max !== null ? `max="${meta.max}"` : ""}
value="${meta.default_value ?? ""}"
/>
`;
}
// ENUM
else if (meta.type === "enum") {
const options = (meta.choices || []).map(choice => `
<option value="${choice}" ${choice === meta.default_value ? "selected" : ""}>
${choice}
</option>
`).join("");
inputHtml = `
<select class="form-select param-input"
id="${paramName}_value_${index}">
${options}
</select>
`;
}
// BOOL
else if (meta.type === "bool") {
inputHtml = `
<select class="form-select param-input"
id="${paramName}_value_${index}">
<option value="true" ${meta.default_value === true ? "selected" : ""}>True</option>
<option value="false" ${meta.default_value === false ? "selected" : ""}>False</option>
</select>
`;
}
col.innerHTML = `
<label class="form-label fw-semibold">${paramName}</label>
${inputHtml}
`;
paramsContainer.appendChild(col);
});
validateParameterInputs();
}
function validateParameterInputs() {
let valid = true;
// Limpiar estados previos
document.querySelectorAll(".param-input").forEach(input => {
input.classList.remove("is-invalid");
});
strategySlots.forEach((slot, index) => {
if (!slot.strategy_id) return;
const strategyMeta = STRATEGY_CATALOG.find(
s => s.strategy_id === slot.strategy_id
);
if (!strategyMeta || !strategyMeta.parameters_meta) return;
strategyMeta.parameters_meta.forEach(meta => {
const el = document.getElementById(`${meta.name}_value_${index}`);
if (!el) return;
let raw = el.value;
let value = raw;
// 🔹 INT
if (meta.type === "int") {
value = parseInt(raw);
if (isNaN(value)) valid = false;
}
// 🔹 FLOAT
else if (meta.type === "float") {
value = parseFloat(raw);
if (isNaN(value)) valid = false;
}
// 🔹 BOOL
else if (meta.type === "bool") {
if (raw !== "true" && raw !== "false") valid = false;
}
// 🔹 ENUM
else if (meta.type === "enum") {
if (!meta.choices || !meta.choices.includes(raw)) {
valid = false;
}
}
// 🔹 Rango (solo números)
if ((meta.type === "int" || meta.type === "float") && !isNaN(value)) {
if (meta.min !== null && meta.min !== undefined && value < meta.min) {
valid = false;
}
if (meta.max !== null && meta.max !== undefined && value > meta.max) {
valid = false;
}
}
if (!valid) {
el.classList.add("is-invalid");
}
});
});
updateCombinationCounter();
return valid;
}
function updateCombinationCounter() {
let hasAnyStrategy = false;
strategySlots.forEach((slot, index) => {
if (!slot.strategy_id) return;
hasAnyStrategy = true;
const perStrategyEl = document.getElementById(`strategy_combo_${index}`);
if (perStrategyEl) {
perStrategyEl.textContent = "1";
}
});
const globalTotal = hasAnyStrategy ? 1 : 0;
const globalEl = document.getElementById("combination_counter");
if (globalEl) globalEl.textContent = globalTotal;
applyCombinationWarnings(globalTotal);
updateTimeEstimate(globalTotal);
return globalTotal;
}
function applyCombinationWarnings(total) {
const maxComb = parseInt(
document.getElementById("opt_max_combinations")?.value || 0
);
const counter = document.getElementById("combination_counter");
if (!counter) return;
counter.classList.remove("text-warning", "text-danger");
if (total > 10000) {
counter.classList.add("text-danger");
} else if (maxComb && total > maxComb) {
counter.classList.add("text-warning");
}
}
function updateTimeEstimate(totalComb) {
const trainDays = parseInt(
document.getElementById("wf_train_days")?.value || 0
);
const testDays = parseInt(
document.getElementById("wf_test_days")?.value || 0
);
const approxWindows = Math.max(
Math.floor(365 / testDays),
1
);
const operations = totalComb * approxWindows;
// 0.003s por combinación (estimación conservadora)
const seconds = operations * 0.003;
let label;
if (seconds < 60) {
label = `~ ${seconds.toFixed(1)} sec`;
} else if (seconds < 3600) {
label = `~ ${(seconds / 60).toFixed(1)} min`;
} else {
label = `~ ${(seconds / 3600).toFixed(1)} h`;
}
const el = document.getElementById("wf_time_estimate");
if (el) el.textContent = label;
}
function removeStrategySlot(index) {
strategySlots.splice(index, 1);
rerenderStrategySlots();
}
// =================================================
// PAYLOAD
// =================================================
function buildPayload() {
const symbol = str("symbol");
const timeframe = str("timeframe");
const stopType = str("stop_type");
if (!symbol || !timeframe) {
throw new Error("symbol/timeframe missing");
}
const stop = { type: stopType };
if (stopType === "fixed" || stopType === "trailing") {
stop.stop_fraction = (num("stop_fraction") ?? 1.0) / 100;
}
if (stopType === "atr") {
stop.atr_period = num("atr_period") ?? 14;
stop.atr_multiplier = num("atr_multiplier") ?? 3.0;
}
const risk_fraction = (num("risk_fraction") ?? 1.0) / 100;
const max_position_fraction = (num("max_position_fraction") ?? 95) / 100;
const global_rules = {
max_drawdown_pct: (num("max_drawdown_pct") ?? 20) / 100,
daily_loss_limit_pct: num("daily_loss_limit_pct") ? num("daily_loss_limit_pct") / 100 : null,
max_consecutive_losses: num("max_consecutive_losses"),
cooldown_bars: num("cooldown_bars"),
};
const wf_train_days = num("wf_train_days") ?? 120;
const wf_test_days = num("wf_test_days") ?? 30;
const wf_step_days = num("wf_step_days");
const wf_min_trades_test = num("wf_min_trades_test")
const strategies = collectSelectedStrategies();
return {
symbol,
timeframe,
account_equity: num("account_equity") ?? 10000,
stop,
risk: {
risk_fraction,
max_position_fraction,
},
global_rules,
strategies,
wf: {
train_days: wf_train_days,
test_days: wf_test_days,
step_days: wf_step_days,
min_trades_test: wf_min_trades_test,
},
commission: num("commission") ?? 0.001,
slippage: num("slippage") ?? 0.0005,
};
}
function collectSelectedStrategies() {
const strategies = [];
console.log("strategySlots:", strategySlots);
strategySlots.forEach((slot, index) => {
if (!slot.strategy_id) return;
const strategyMeta = STRATEGY_CATALOG.find(
s => s.strategy_id === slot.strategy_id
);
if (!strategyMeta) return;
const parameters = {};
strategyMeta.parameters_meta.forEach(meta => {
const el = document.getElementById(`${meta.name}_value_${index}`);
if (!el) return;
let value = el.value;
if (meta.type === "int") {
value = parseInt(value);
}
else if (meta.type === "float") {
value = parseFloat(value);
}
else if (meta.type === "bool") {
value = value === "true";
}
parameters[meta.name] = value;
});
strategies.push({
strategy_id: slot.strategy_id,
parameters: parameters
});
});
console.log("Collected strategies:", strategies);
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() {
document.getElementById("risk_fraction").value =
localStorage.getItem("calibration.risk_fraction") ?? "";
document.getElementById("max_position_fraction").value =
localStorage.getItem("calibration.max_position_fraction") ?? "";
document.getElementById("stop_type").value =
localStorage.getItem("calibration.stop_type") ?? "fixed";
document.getElementById("stop_fraction").value =
localStorage.getItem("calibration.stop_fraction") ?? "";
document.getElementById("atr_period").value =
localStorage.getItem("calibration.atr_period") ?? "";
document.getElementById("atr_multiplier").value =
localStorage.getItem("calibration.atr_multiplier") ?? "";
document.getElementById("max_drawdown_pct").value =
localStorage.getItem("calibration.max_drawdown_pct") ?? "";
document.getElementById("daily_loss_limit_pct").value =
localStorage.getItem("calibration.daily_loss_limit_pct") ?? "";
document.getElementById("max_consecutive_losses").value =
localStorage.getItem("calibration.max_consecutive_losses") ?? "";
document.getElementById("cooldown_bars").value =
localStorage.getItem("calibration.cooldown_bars") ?? "";
// Forzar actualización de UI según stop heredado
setTimeout(() => {
updateStopUI();
}, 0);
console.log("[calibration_strategies] Parameters loaded from Step 2 ✅");
}
function updateStopUI() {
const type = document.getElementById("stop_type").value;
const stopFraction = document.getElementById("stop_fraction_group");
const atrPeriod = document.getElementById("atr_group");
const atrMultiplier = document.getElementById("atr_multiplier_group");
if (type === "fixed" || type === "trailing") {
stopFraction.classList.remove("d-none");
atrPeriod.classList.add("d-none");
atrMultiplier.classList.add("d-none");
}
if (type === "atr") {
stopFraction.classList.add("d-none");
atrPeriod.classList.remove("d-none");
atrMultiplier.classList.remove("d-none");
}
}
async function loadStrategyCatalog() {
const res = await fetch("/api/v2/calibration/strategies/catalog");
const data = await res.json();
STRATEGY_CATALOG = data.strategies;
}
function addStrategySlot() {
// Si ya hay un slot vacío al final, no crear otro
if (strategySlots.length > 0 &&
strategySlots[strategySlots.length - 1].strategy_id === null) {
return;
}
if (strategySlots.length >= MAX_STRATEGIES) return;
const index = strategySlots.length;
strategySlots.push({
strategy_id: null,
parameters: {}
});
renderStrategySlot(index);
}
// =================================================
// PROGRESS BAR
// =================================================
function startWF() {
document
.getElementById("wf_progress_card")
.classList.remove("d-none");
document.getElementById("wfProgressBar").style.width = "0%";
document.getElementById("wfProgressBar").innerText = "0%";
}
async function pollStatus(jobId) {
const interval = setInterval(async () => {
const res = await fetch(`/api/v2/calibration/strategies/status/${jobId}`);
const data = await res.json();
const bar = document.getElementById("wfProgressBar");
bar.style.width = data.progress + "%";
bar.innerText = data.progress + "%";
if (data.status === "done") {
clearInterval(interval);
bar.classList.remove("progress-bar-animated");
console.log("WF finished");
}
}, 1000);
}
// =================================================
// DIFFERENT RENDER PLOTS
// =================================================
function renderEquityAndReturns(strategyId, s, data) {
const equity = s.window_equity || [];
const ret = s.window_returns_pct || [];
const trd = s.window_trades || [];
const regimeWindows = s.window_regimes || s.diagnostics?.regimes?.by_window || [];
function regimeBgColor(regime) {
switch (regime) {
case "bull_strong":
return "rgba(0,114,178,0.20)";
case "bull_moderate":
return "rgba(0,114,178,0.10)";
case "bear_strong":
return "rgba(230,159,0,0.20)";
case "bear_moderate":
return "rgba(230,159,0,0.10)";
default:
return "rgba(150,150,150,0.12)";
}
}
function buildRegimeBackgroundShapes(regimeWindows, topYRef = "paper") {
if (!Array.isArray(regimeWindows) || regimeWindows.length === 0) return [];
const shapes = [];
regimeWindows.forEach((w, idx) => {
const x0 = idx - 0.5;
const x1 = idx + 0.5;
shapes.push({
type: "rect",
xref: "x",
yref: topYRef,
x0,
x1,
y0: 0.60, // mismo inicio que domain del gráfico superior
y1: 1.00, // mismo final que domain del gráfico superior
fillcolor: regimeBgColor(w.regime_detail || w.regime),
line: { width: 0 },
layer: "below"
});
});
return shapes;
}
// X común (windows)
const n = Math.max(equity.length, ret.length, trd.length);
const x = Array.from({ length: n }, (_, i) => i);
// ---- Escalado para alinear visualmente el 0 (returns) con el 0 (trades) ----
const retMax = Math.max(0, ...ret);
const retMin = Math.min(0, ...ret);
const minTrades = data.config?.wf?.min_trades_test ?? 10;
const trdMaxRaw = Math.max(0, ...trd);
const trdMax = Math.max(trdMaxRaw, minTrades);
const retPosSpan = Math.max(1e-9, retMax);
const retNegSpan = Math.abs(retMin);
const trdNegSpan = (retNegSpan / retPosSpan) * trdMax;
const y1Range = [retMin, retMax];
const y2Range = [-trdNegSpan, trdMax];
// ---- Trazas ----
const equityTrace = {
x,
y: equity,
type: "scatter",
mode: "lines",
name: "Equity",
xaxis: "x",
yaxis: "y"
};
const returnsTrace = {
x,
y: ret,
type: "bar",
name: "Return %",
marker: { color: "#3b82f6" },
xaxis: "x2",
yaxis: "y2",
offsetgroup: "returns",
alignmentgroup: "bottom"
};
const tradesTrace = {
x,
y: trd,
type: "bar",
name: "Trades",
marker: { color: "#f59e0b" },
xaxis: "x2",
yaxis: "y3",
offsetgroup: "trades",
alignmentgroup: "bottom"
};
const regimeShapesTop = buildRegimeBackgroundShapes(regimeWindows);
// ---- Layout ----
const layout = {
grid: { rows: 2, columns: 1, pattern: "independent" },
// IMPORTANTE: share X de verdad (pan/zoom sincronizado)
xaxis: { matches: "x2", showgrid: true },
xaxis2: { matches: "x", showgrid: true },
// Dominios (más altura para ver mejor)
// Ajusta estos números a gusto:
yaxis: {
domain: [0.60, 1.00],
title: "Equity",
showgrid: true,
rangemode: "tozero"
},
xaxis: {
domain: [0.00, 1.00],
anchor: "y"
},
yaxis2: {
domain: [0.00, 0.25],
title: "Return %",
range: y1Range,
zeroline: true,
zerolinewidth: 2,
showgrid: true
},
xaxis2: {
domain: [0.00, 1.00],
anchor: "y2",
title: "Windows"
},
yaxis3: {
title: "Trades",
overlaying: "y2",
side: "right",
range: y2Range,
zeroline: true,
zerolinewidth: 2,
showgrid: false
},
// Barras lado a lado
barmode: "group",
bargap: 0.2,
shapes: [
...regimeShapesTop,
{
type: "line",
x0: -0.5,
x1: n - 0.5,
y0: minTrades,
y1: minTrades,
xref: "x2",
yref: "y3",
line: { color: "red", width: 2, dash: "dash" }
}
],
legend: { orientation: "h" },
margin: { t: 50, r: 70, l: 70, b: 50 }
};
const topDomain = layout.yaxis.domain; // [start,end]
const botDomain = layout.yaxis2.domain; // [start,end]
layout.annotations = [
{
text: `Equity — ${strategyId}`,
x: 0.5, xref: "paper",
y: topDomain[1] + 0.05, yref: "paper",
showarrow: false,
font: { size: 16 }
},
{
text: `Returns & Trades — ${strategyId}`,
x: 0.5, xref: "paper",
y: botDomain[1] + 0.05, yref: "paper",
showarrow: false,
font: { size: 16 }
}
];
Plotly.newPlot("plot_strategy", [equityTrace, returnsTrace, tradesTrace], layout, {
displayModeBar: true,
responsive: true
});
const gd = document.getElementById("plot_strategy");
// Limpia listeners anteriores (si re-renderizas)
gd.removeAllListeners?.("plotly_relayout");
// Flag para evitar bucles cuando hacemos relayout desde el listener
gd.__syncing = false;
function clamp01(v) {
return Math.max(0, Math.min(1, v));
}
function zeroFrac(min, max) {
const span = max - min;
if (!isFinite(span) || span <= 1e-12) return 0;
return clamp01((0 - min) / span);
}
// Dado un max fijo y una fracción f (posición del 0), calcula el min
function minFromMaxAndFrac(max, f) {
const denom = Math.max(1e-9, (1 - f));
return -(f / denom) * max;
}
gd.on("plotly_relayout", (ev) => {
if (gd.__syncing) return;
const update = {};
// ===== 0) DETECTAR AUTOSCALE / RESET (prioridad máxima) =====
// Plotly puede indicar autoscale de varias maneras.
const autoscaleTriggered =
ev["yaxis2.autorange"] === true ||
ev["yaxis3.autorange"] === true ||
ev["yaxis.autorange"] === true ||
ev["xaxis.autorange"] === true ||
ev["xaxis2.autorange"] === true ||
ev["autosize"] === true ||
ev["resetScale2d"] === true ||
ev["xaxis.range[0]"] === undefined && ev["xaxis.range[1]"] === undefined && ev["yaxis.range[0]"] === undefined && ev["yaxis.range[1]"] === undefined
? false
: false;
// Si el usuario pulsa Autoscale/Reset, queremos SIEMPRE:
// - Reforzar yaxis2/yaxis3 con tus rangos calculados (0 alineado)
// - Volver a dibujar la línea minTrades
// - (Opcional) dejar X en autorange en ambos
if (
ev["yaxis2.autorange"] === true ||
ev["yaxis3.autorange"] === true ||
ev["yaxis.autorange"] === true ||
ev["xaxis.autorange"] === true ||
ev["xaxis2.autorange"] === true ||
ev["resetScale2d"] === true
) {
// update["yaxis2.autorange"] = false;
// update["yaxis3.autorange"] = false;
update["yaxis2.range"] = y1Range;
update["yaxis3.range"] = y2Range;
// Asegura que la línea de Min Trades se vea SIEMPRE
update["shapes[0].type"] = "line";
update["shapes[0].xref"] = "x2";
update["shapes[0].yref"] = "y3";
update["shapes[0].x0"] = -0.5;
update["shapes[0].x1"] = n - 0.5;
update["shapes[0].y0"] = minTrades;
update["shapes[0].y1"] = minTrades;
update["shapes[0].line.color"] = "red";
update["shapes[0].line.width"] = 2;
update["shapes[0].line.dash"] = "dash";
// Mantén X sincronizado también tras autoscale
// if (ev["xaxis.autorange"] === true || ev["xaxis2.autorange"] === true || ev["resetScale2d"] === true) {
// update["xaxis.autorange"] = true;
// update["xaxis2.autorange"] = true;
// }
gd.__syncing = true;
Plotly.relayout(gd, update).finally(() => {
gd.__syncing = false;
});
return; // IMPORTANTÍSIMO: no seguimos con el resto de sincronizaciones
}
// ===== 1) Sync X arriba/abajo (pan/zoom normal) =====
if (ev["xaxis2.range[0]"] !== undefined && ev["xaxis2.range[1]"] !== undefined) {
update["xaxis.range"] = [ev["xaxis2.range[0]"], ev["xaxis2.range[1]"]];
update["xaxis.autorange"] = false;
}
if (ev["xaxis.range[0]"] !== undefined && ev["xaxis.range[1]"] !== undefined) {
update["xaxis2.range"] = [ev["xaxis.range[0]"], ev["xaxis.range[1]"]];
update["xaxis2.autorange"] = false;
}
// ===== 2) Mantener 0 alineado cuando pan/zoom en Y2 o Y3 =====
const y2Changed = ev["yaxis2.range[0]"] !== undefined && ev["yaxis2.range[1]"] !== undefined;
const y3Changed = ev["yaxis3.range[0]"] !== undefined && ev["yaxis3.range[1]"] !== undefined;
const full = gd._fullLayout || {};
const curY2 = full.yaxis2?.range || y1Range;
const curY3 = full.yaxis3?.range || y2Range;
if (y2Changed && !y3Changed) {
const newY2 = [ev["yaxis2.range[0]"], ev["yaxis2.range[1]"]];
const f = zeroFrac(newY2[0], newY2[1]);
const y3MaxKeep = (curY3 && curY3.length === 2) ? curY3[1] : y2Range[1];
const y3Min = minFromMaxAndFrac(y3MaxKeep, f);
update["yaxis2.autorange"] = false;
update["yaxis3.autorange"] = false;
update["yaxis2.range"] = newY2;
update["yaxis3.range"] = [y3Min, y3MaxKeep];
}
if (y3Changed && !y2Changed) {
const newY3 = [ev["yaxis3.range[0]"], ev["yaxis3.range[1]"]];
const f = zeroFrac(newY3[0], newY3[1]);
const y2MaxKeep = (curY2 && curY2.length === 2) ? curY2[1] : y1Range[1];
const y2Min = minFromMaxAndFrac(y2MaxKeep, f);
update["yaxis2.autorange"] = false;
update["yaxis3.autorange"] = false;
update["yaxis3.range"] = newY3;
update["yaxis2.range"] = [y2Min, y2MaxKeep];
}
// Si ambos cambian (zoom box), prioriza el zeroFrac de y2
if (y2Changed && y3Changed) {
const newY2 = [ev["yaxis2.range[0]"], ev["yaxis2.range[1]"]];
const f = zeroFrac(newY2[0], newY2[1]);
const newY3 = [ev["yaxis3.range[0]"], ev["yaxis3.range[1]"]];
const y3MaxKeep = newY3[1];
const y3Min = minFromMaxAndFrac(y3MaxKeep, f);
update["yaxis2.autorange"] = false;
update["yaxis3.autorange"] = false;
update["yaxis2.range"] = newY2;
update["yaxis3.range"] = [y3Min, y3MaxKeep];
}
// ===== 3) Re-asegurar la línea minTrades (por si Plotly la toca en relayouts) =====
// (No molesta y evita que desaparezca en algunos reset)
update["shapes[0].y0"] = minTrades;
update["shapes[0].y1"] = minTrades;
if (Object.keys(update).length === 0) return;
gd.__syncing = true;
Plotly.relayout(gd, update).finally(() => {
gd.__syncing = false;
});
});
}
function renderRollingSharpe(strategyId, s, data) {
const roll = s.diagnostics?.rolling?.rolling_sharpe_like || [];
const x = roll.map((_, i) => i + 1);
const k = s.diagnostics?.rolling?.rolling_window ?? "?";
Plotly.newPlot(
"plot_strategy",
[
{
x,
y: roll,
type: "scatter",
mode: "lines+markers",
name: `Rolling Sharpe-like (k=${k})`
}
],
{
margin: { t: 60, r: 40, l: 60, b: 50 },
title: { text: `Rolling Sharpe-like — ${strategyId}`, x: 0.5 }, // ✅ centrado + dinámico
xaxis: { title: "Window", showgrid: true, zeroline: false },
yaxis: { title: "Sharpe-like", showgrid: true, zeroline: true, zerolinewidth: 2 },
legend: { orientation: "h" }
},
{ responsive: true, displayModeBar: true }
);
}
function renderOOSReturnsDistribution(strategyId, s, data) {
const edges = s.diagnostics?.distribution?.hist_bin_edges || [];
const counts = s.diagnostics?.distribution?.hist_counts || [];
const centers = edges.map((_, i) => (edges[i] + edges[i + 1]) / 2);
Plotly.newPlot("plot_strategy", [{
x: centers,
y: counts,
type: "bar",
name: "OOS Return% (bins)"
}], {
margin: { t: 40 },
title: { text: `OOS Returns Distribution — ${strategyId}`, x: 0.5 }, // ✅ centrado + dinámico
xaxis: { title: "Return % (window)" },
yaxis: { title: "Count" }
});
}
function renderDrawdownEvolution(strategyId, s, data) {
const dd = s.diagnostics?.drawdown?.drawdown_pct || [];
const x = dd.map((_, i) => i);
Plotly.newPlot("plot_strategy", [{
x,
y: dd,
type: "scatter",
mode: "lines",
name: "Drawdown %"
}], {
margin: { t: 40 },
title: { text: `Drawdown Evolution — ${strategyId}`, x: 0.5 }, // ✅ centrado + dinámico
xaxis: { title: "Point (initial + windows)" },
yaxis: { title: "Drawdown %", zeroline: true }
});
}
function renderTradeDensity(strategyId, s, data) {
const tpw = s.diagnostics?.trades?.trades_per_window || [];
const tpd = s.diagnostics?.trades?.trades_per_day || [];
const x = tpw.map((_, i) => i + 1);
const tpwMax = Math.max(0, ...tpw);
const tpdMax = Math.max(0, ...tpd);
Plotly.newPlot("plot_strategy", [
{
x,
y: tpw,
type: "bar",
name: "Trades / window",
yaxis: "y"
},
{
x,
y: tpd,
type: "scatter",
mode: "lines+markers",
name: "Trades / day",
yaxis: "y2"
}
], {
margin: { t: 40 },
title: { text: `Trade Density — ${strategyId}`, x: 0.5 },
barmode: "group",
xaxis: { title: "Window" },
yaxis: {
title: "Trades / window",
range: [0, tpwMax],
zeroline: true,
zerolinewidth: 2
},
yaxis2: {
title: "Trades / day",
overlaying: "y",
side: "right",
range: [0, tpdMax],
zeroline: true,
zerolinewidth: 2
}
});
}
// =================================================
// RENDER RESULTS
// =================================================
function renderStrategiesList(strategies) {
const list = document.getElementById("strategies_list");
if (!list) return;
list.innerHTML = "";
strategies.forEach((s) => {
const col = document.createElement("div");
col.className = "col-12 col-lg-6";
col.setAttribute("data-strategy-item", "1");
const defaultGrid = s.default_grid || {};
const defaultGridText = JSON.stringify(defaultGrid, null, 2);
col.innerHTML = `
<div class="card">
<div class="card-body">
<label class="form-check form-switch">
<input class="form-check-input" type="checkbox" data-strategy-id="${s.strategy_id}">
<span class="form-check-label">
<b>${s.name}</b>
<span class="text-secondary ms-2">${(s.tags || []).join(" · ")}</span>
</span>
</label>
<div class="mt-3">
<label class="form-label">param_grid (JSON)</label>
<textarea class="form-control" rows="7" spellcheck="false">${defaultGridText}</textarea>
<div class="form-hint">Tip: usa listas. Ej: {"fast":[10,20],"slow":[50,100]}</div>
</div>
</div>
</div>
`;
list.appendChild(col);
});
}
function setBadge(status) {
const badge = document.getElementById("strategies_status_badge");
if (!badge) return;
badge.classList.remove("bg-secondary", "bg-success", "bg-warning", "bg-danger");
badge.classList.add(
status === "ok" ? "bg-success" : status === "warning" ? "bg-warning" : status === "fail" ? "bg-danger" : "bg-secondary"
);
badge.textContent = status ? status.toUpperCase() : "—";
}
function renderResultsTable(data) {
const wrap = document.getElementById("strategies_table_wrap");
if (!wrap) return;
const rows = [];
(data.results || []).forEach((r) => {
rows.push(`
<tr>
<td><b>${r.strategy_id}</b></td>
<td>${r.status}</td>
<td>${r.n_windows}</td>
<td>${Number(r.oos_total_return_pct).toFixed(2)}%</td>
<td>${Number(r.oos_max_dd_worst_pct).toFixed(2)}%</td>
<td>${Number(r.oos_final_equity).toFixed(2)}</td>
<td class="text-secondary">
${r.message || ""}
${Array.isArray(r.warnings) && r.warnings.length ? `<div class="mt-1"><small>${r.warnings.map(escapeHtml).join(" · ")}</small></div>` : ""}
</td>
</tr>
`);
});
wrap.innerHTML = `
<div class="table-responsive">
<table class="table table-vcenter">
<thead>
<tr>
<th>Strategy</th>
<th>Status</th>
<th>Windows</th>
<th>OOS return</th>
<th>Worst DD</th>
<th>Final equity</th>
<th>Message</th>
</tr>
</thead>
<tbody>
${rows.join("")}
</tbody>
</table>
</div>
`;
}
function populatePlotSelector(data) {
const sel = document.getElementById("plot_strategy_select");
if (!sel) return;
sel.innerHTML = "";
// ✅ usar results, no series (para que aparezcan también warning/fail)
const ids = (data.results || []).map(r => r.strategy_id);
ids.forEach((sid) => {
const opt = document.createElement("option");
opt.value = sid;
opt.textContent = sid;
sel.appendChild(opt);
});
sel.onchange = () => {
const sid = sel.value;
selectStrategy(sid, data);
};
if (ids.length > 0) {
sel.value = selectedStrategyId || ids[0];
}
}
function selectStrategy(strategyId, data) {
if (!strategyId || !data) return;
// Actualiza selectedStrategyId
selectedStrategyId = strategyId;
const row = (data.results || []).find(r => r.strategy_id === strategyId);
// 1) Alerts por status (siempre explícito)
if (row?.status === "warning") {
showPlotAlert("warning", `WARNING — ${strategyId}`, row.message || "Strategy warning.", row.warnings);
} else if (row?.status === "fail") {
showPlotAlert("danger", `FAIL — ${strategyId}`, row.message || "Strategy failed.", row.warnings);
} else {
clearPlotAlert();
}
// 2) Si el backend indica que NO hay serie, no intentamos renderizar
if (row && row.series_available === false) {
showPlotAlert(
row.status === "fail" ? "danger" : "warning",
`${(row.status || "warning").toUpperCase()}${strategyId}`,
row.series_error || row.message || "No chart series available for this strategy.",
row.warnings
);
clearPlots();
highlightSelectedRow(strategyId);
return;
}
// 3) Verificar si los datos de la estrategia están disponibles
const strategyData = data?.series?.strategies?.[selectedStrategyId];
if (!strategyData) {
showPlotAlert(
"warning",
`No data available — ${strategyId}`,
"Strategy data not available for rendering.",
[]
);
clearPlots();
highlightSelectedRow(strategyId);
return;
}
// 4) Mantén el gráfico previamente seleccionado en el dropdown
const chartType = document.getElementById("plot_strategy_select").value;
renderChart(chartType, selectedStrategyId, strategyData, data); // Renderiza el gráfico correctamente
renderRegimeSummary(selectedStrategyId, strategyData, data);
highlightSelectedRow(strategyId);
// scroll automático al gráfico
document.getElementById("plot_strategy")?.scrollIntoView({
behavior: "smooth",
block: "start"
});
}
function renderValidateResponse(data) {
lastValidationResult = data;
// -------------------------------
// 1⃣ Badge + message
// -------------------------------
const badge = document.getElementById("strategies_status_badge");
const msg = document.getElementById("strategies_message");
badge.textContent = data.status ?? "—";
badge.className = "badge";
if (data.status === "ok") badge.classList.add("bg-success");
else if (data.status === "warning") badge.classList.add("bg-warning");
else badge.classList.add("bg-danger");
msg.textContent = data.message ?? "";
// -------------------------------
// 2⃣ Debug JSON
// -------------------------------
document.getElementById("strategies_debug").textContent =
JSON.stringify(data, null, 2);
// -------------------------------
// 3⃣ Plots (primera estrategia por ahora)
// -------------------------------
if (data.results && data.results.length > 0) {
if (!selectedStrategyId) {
selectedStrategyId = data.results[0].strategy_id;
}
selectStrategy(selectedStrategyId, data);
}
// -------------------------------
// 4⃣ Table
// -------------------------------
const wrap = document.getElementById("strategies_table_wrap");
wrap.innerHTML = "";
if (data.results) {
let html = `<table class="table table-sm table-bordered">
<thead>
<tr>
<th>Strategy</th>
<th>Status</th>
<th>OOS Return %</th>
<th>OOS Max DD %</th>
<th>Windows</th>
<th>Best Regime</th>
</tr>
</thead>
<tbody>`;
for (const r of data.results) {
let bestRegime = "-";
const strat = data?.series?.strategies?.[r.strategy_id];
const perf = strat?.diagnostics?.regimes?.performance;
if (perf) {
const entries = ["bull", "sideways", "bear"]
.map(k => ({
regime: k,
val: perf?.[k]?.mean_return_pct ?? -Infinity
}));
entries.sort((a,b)=>b.val-a.val);
if (entries[0].val > -Infinity) {
bestRegime = entries[0].regime;
}
}
html += `
<tr>
<td class="strategy-row"
data-strategy="${r.strategy_id}"
style="cursor:pointer; font-weight:600;">
${r.strategy_id}
</td>
<td>${r.status}</td>
<td>${r.oos_total_return_pct?.toFixed(2)}</td>
<td>${r.oos_max_dd_worst_pct?.toFixed(2)}</td>
<td>${r.n_windows}</td>
<td>${bestRegime}</td>
</tr>
`;
// 🔸 Mostrar warnings debajo de la fila si existen
if (r.warnings && r.warnings.length > 0) {
html += `
<tr class="table-warning">
<td colspan="6">
<ul class="mb-0">
${r.warnings.map(w => `<li>${w}</li>`).join("")}
</ul>
</td>
</tr>
`;
}
}
html += "</tbody></table>";
wrap.innerHTML = html;
document.querySelectorAll(".strategy-row").forEach(el => {
el.addEventListener("click", function () {
console.log("Clicked:", selectedStrategyId);
selectedStrategyId = this.dataset.strategy;
selectStrategy(selectedStrategyId, lastValidationResult);
});
});
}
}
function renderChart(chartType, strategyId, s, data) {
switch (chartType) {
case "equity":
renderEquityAndReturns(strategyId, s, data);
break;
case "rolling_sharpe":
renderRollingSharpe(strategyId, s, data);
break;
case "hist_oos_returns":
renderOOSReturnsDistribution(strategyId, s, data);
break;
case "drawdown":
renderDrawdownEvolution(strategyId, s, data);
break;
case "trade_density":
renderTradeDensity(strategyId, s, data);
break;
default:
renderEquityAndReturns(strategyId, s, data);
break;
}
}
function highlightSelectedRow(strategyId) {
document.querySelectorAll(".strategy-row").forEach(el => {
el.style.backgroundColor = "";
});
const active = document.querySelector(
`.strategy-row[data-strategy="${strategyId}"]`
);
if (active) {
active.style.backgroundColor = "#e6f0ff";
}
}
async function validateStrategies() {
console.log("[calibration_strategies] validateStrategies() START");
const bar = document.getElementById("wfProgressBar");
const txt = document.getElementById("wf_progress_text");
const setProgress = (pct, text) => {
const p = Math.max(0, Math.min(100, Number(pct || 0)));
bar.style.width = `${p}%`;
bar.textContent = `${p}%`;
if (text) txt.textContent = text;
};
if (!validateParameterInputs()) {
alert("Please fix parameter errors before running WF.");
return;
}
try {
// 0) Reset UI
setProgress(0, "Starting...");
// 1) Construye payload igual que antes (usa tu función existente)
const payload = buildPayload(); // <-- NO CAMBIES tu builder, reutilízalo
// 2) Arranca job async
const runResp = await fetch("/api/v2/calibration/strategies/run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!runResp.ok) {
const errText = await runResp.text();
throw new Error(`Run failed: ${runResp.status} ${errText}`);
}
const { job_id } = await runResp.json();
if (!job_id) throw new Error("No job_id returned from /run");
// 3) Poll status
const pollEveryMs = 500;
const maxMinutes = 30;
const maxPolls = Math.ceil((maxMinutes * 60 * 1000) / pollEveryMs);
for (let i = 0; i < maxPolls; i++) {
await new Promise((r) => setTimeout(r, pollEveryMs));
const stResp = await fetch(`/api/v2/calibration/strategies/status/${job_id}`);
if (!stResp.ok) continue;
const st = await stResp.json();
const pct = st.progress ?? 0;
const cw = st.current_window ?? 0;
const tw = st.total_windows ?? 0;
const label =
tw > 0
? `WF running... window ${cw}/${tw}`
: "WF running...";
setProgress(pct, label);
if (st.status === "done") {
setProgress(100, "WF completed ✅");
// 4) Renderiza resultados usando el MISMO renderer que usabas con /validate
// (ojo: el resultado viene dentro de st.result)
if (!st.result) throw new Error("Job done but no result in status payload");
renderValidateResponse(st.result); // <-- usa tu función existente de render (plots, tablas, etc.)
console.log("[calibration_strategies] validateStrategies() DONE ok");
return;
}
if (st.status === "unknown") {
setProgress(0, "Unknown job (server lost state?)");
break;
}
}
throw new Error("Timeout waiting for WF job to finish");
} catch (err) {
console.error(err);
// deja un estado visible
const txt = document.getElementById("wf_progress_text");
if (txt) txt.textContent = `Error: ${err.message}`;
console.log("[calibration_strategies] validateStrategies() DONE fail");
}
}
async function generateReport() {
console.log("[calibration_strategies] generateReport() START");
let payload;
try {
payload = buildPayload();
} catch (e) {
alert(e.message);
return;
}
const res = await fetch("/api/v2/calibration/strategies/report", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json();
if (data.url) {
const viewer = document.getElementById("pdf_viewer_section");
const frame = document.getElementById("pdf_frame");
frame.src = data.url;
viewer.classList.remove("d-none");
viewer.scrollIntoView({ behavior: "smooth" });
} else {
alert("Failed to generate report");
}
}
function wireButtons() {
document.getElementById("validate_strategies_btn")?.addEventListener("click", validateStrategies);
document.getElementById("report_strategies_btn")?.addEventListener("click", generateReport);
document.getElementById("refresh_strategies_btn")?.addEventListener("click", async () => {
const strategies = await fetchAvailableStrategies();
renderStrategiesList(strategies);
});
document.getElementById("load_step2_btn")?.addEventListener("click", () => {
loadContextFromLocalStorage();
});
document.getElementById("close_pdf_btn")?.addEventListener("click", () => {
const viewer = document.getElementById("pdf_viewer_section");
const frame = document.getElementById("pdf_frame");
frame.src = "";
viewer.classList.add("d-none");
});
}
function applyInheritedLock() {
const locked = document.getElementById("lock_inherited").checked;
const fields = document.querySelectorAll(".inherited-field");
fields.forEach(f => {
f.disabled = locked;
if (locked) {
f.classList.add("bg-light");
} else {
f.classList.remove("bg-light");
}
});
}
async function init() {
await loadStrategyCatalog();
addStrategySlot();
loadContextFromLocalStorage();
loadFromStep2();
applyInheritedLock();
document.getElementById("stop_type")
.addEventListener("change", updateStopUI);
wireButtons();
document.getElementById("plot_strategy_select").addEventListener("change", function() {
const chartType = this.value;
const strategyData = lastValidationResult.series.strategies[selectedStrategyId];
// Verifica que selectedStrategyId tenga el valor correcto
console.log("selectedStrategyId:", selectedStrategyId);
console.log("Strategy Data:", strategyData);
renderChart(chartType, selectedStrategyId, strategyData, lastValidationResult);
});
const strategies = await fetchAvailableStrategies();
renderStrategiesList(strategies);
// Pre-select 1 strategy by default (moving_average) if exists
setTimeout(() => {
const first = document.querySelector('input[type=checkbox][data-strategy-id="moving_average"]');
if (first) first.checked = true;
}, 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":
case "bull_moderate":
case "bull_strong":
return "bg-blue-lt text-blue";
case "bear":
case "bear_moderate":
case "bear_strong":
return "bg-orange-lt text-orange";
default:
return "bg-secondary-lt text-secondary";
}
}
function regimeLabel(regime) {
switch (regime) {
case "bull_strong": return "Bull strong";
case "bull_moderate": return "Bull moderate";
case "bear_strong": return "Bear strong";
case "bear_moderate": return "Bear moderate";
case "bull": return "Bull";
case "bear": return "Bear";
default: return "Sideways";
}
}
function ensureRegimeContainer() {
let el = document.getElementById("regime_analysis_wrap");
if (el) return el;
const anchor = document.getElementById("plot_strategy");
if (!anchor || !anchor.parentElement) return null;
el = document.createElement("div");
el.id = "regime_analysis_wrap";
el.className = "mt-4";
anchor.parentElement.appendChild(el);
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) => ({
regime: k,
n_windows: Number(perf?.[k]?.n_windows ?? 0),
mean_return_pct: Number(perf?.[k]?.mean_return_pct ?? -Infinity),
}))
.filter((x) => x.n_windows > 0);
if (!candidates.length) return null;
candidates.sort((a, b) => b.mean_return_pct - a.mean_return_pct);
return candidates[0].regime;
}
function renderRegimeSummary(strategyId, s, data) {
const wrap = ensureRegimeContainer();
if (!wrap) return;
const regimesDiag = s?.diagnostics?.regimes || {};
const perf = regimesDiag.performance?.group || {};
const perfDetail = regimesDiag.performance?.detail || {};
const byWindow = s?.window_regimes || regimesDiag.by_window || [];
const cfg = regimesDiag.config || data?.regimes?.config || {};
const bestRegime = getBestRegime(perf);
const detailOrderTop = ["bull_moderate", "bear_moderate"];
const detailOrderBottom = ["bull_strong", "sideways", "bear_strong"];
function renderDetailCard(regime, extraClass = "") {
const p = perfDetail?.[regime] || {};
return `
<div class="${extraClass}">
<div class="card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2">
<h4 class="card-title mb-0">${regimeLabel(regime)}</h4>
<span class="badge ${regimeBadgeClass(regime)}">${regimeLabel(regime).toUpperCase()}</span>
</div>
<div class="row g-2 small">
<div class="col-6">
<div class="text-secondary">Windows</div>
<div class="fw-semibold">${Number(p.n_windows ?? 0)}</div>
</div>
<div class="col-6">
<div class="text-secondary">Mean return</div>
<div class="fw-semibold">${fmtPct(p.mean_return_pct)}</div>
</div>
<div class="col-6">
<div class="text-secondary">Positive rate</div>
<div class="fw-semibold">${fmtPct((Number(p.positive_window_rate ?? 0) * 100.0))}</div>
</div>
<div class="col-6">
<div class="text-secondary">Avg trades</div>
<div class="fw-semibold">${fmtNum(p.avg_trades)}</div>
</div>
</div>
</div>
</div>
</div>
`;
}
const topCards = detailOrderTop
.map((regime) => renderDetailCard(regime, "col-md-6"))
.join("");
const bottomCards = detailOrderBottom
.map((regime) => renderDetailCard(regime, "col-md-4"))
.join("");
const rows = byWindow.map((w) => `
<tr>
<td>${Number(w.window ?? 0)}</td>
<td>
<span class="badge ${regimeBadgeClass(w.regime_detail || w.regime)}">
${regimeLabel(w.regime_detail || w.regime)}
</span>
</td>
<td>${fmtPct(Number(w.bull_strong_pct ?? 0) * 100.0)}</td>
<td>${fmtPct(Number(w.bull_moderate_pct ?? 0) * 100.0)}</td>
<td>${fmtPct(Number(w.sideways_detail_pct ?? 0) * 100.0)}</td>
<td>${fmtPct(Number(w.bear_moderate_pct ?? 0) * 100.0)}</td>
<td>${fmtPct(Number(w.bear_strong_pct ?? 0) * 100.0)}</td>
</tr>
`).join("");
wrap.innerHTML = `
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<h3 class="card-title mb-0">Regime Analysis — ${strategyId}</h3>
<div class="text-secondary small mt-1">
EMA: ${(cfg.ema_periods || []).join(" / ")}
· Bull persistence: ${cfg.bull_persistence_bars ?? "-"}
· Sideways persistence: ${cfg.sideways_persistence_bars ?? "-"}
· Bear persistence: ${cfg.bear_persistence_bars ?? "-"}
· Bull ≥ ${cfg.bull_threshold ?? "-"}
· Bear ≤ ${cfg.bear_threshold ?? "-"}
</div>
</div>
${
bestRegime
? `<span class="badge ${regimeBadgeClass(bestRegime)}">
Best regime: ${regimeLabel(bestRegime)}
</span>`
: `<span class="badge bg-secondary-lt text-secondary">Best regime: —</span>`
}
</div>
<div class="card-body">
<div class="row justify-content-center g-3 mb-3">
<div class="col-12 col-xl-10">
<div class="row g-3">
${topCards}
</div>
</div>
</div>
<div class="row justify-content-center g-3 mb-4">
<div class="col-12">
<div class="row g-3">
${bottomCards}
</div>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm table-vcenter">
<thead>
<tr>
<th>Window</th>
<th>Majority regime</th>
<th>Bull strong %</th>
<th>Bull mod %</th>
<th>Sideways %</th>
<th>Bear mod %</th>
<th>Bear strong %</th>
</tr>
</thead>
<tbody>
${rows || `<tr><td colspan="7" class="text-secondary">No regime data available.</td></tr>`}
</tbody>
</table>
</div>
</div>
</div>
`;
}
// =================================================
// PLOT ALERTS (Tabler) + SAFE PLOT CLEAR
// =================================================
function ensurePlotAlertContainer() {
// Lo colocamos antes del primer plot si existe
let el = document.getElementById("plot_alert");
if (el) return el;
const anchor = document.getElementById("plot_strategy");
if (!anchor || !anchor.parentElement) return null;
el = document.createElement("div");
el.id = "plot_alert";
el.className = "mb-3";
anchor.parentElement.insertBefore(el, anchor);
return el;
}
function escapeHtml(str) {
return String(str ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function showPlotAlert(type, title, message, warnings) {
const el = ensurePlotAlertContainer();
if (!el) return;
const warnHtml = Array.isArray(warnings) && warnings.length
? `<ul class="mb-0">${warnings.map(w => `<li>${escapeHtml(w)}</li>`).join("")}</ul>`
: "";
el.innerHTML = `
<div class="alert alert-${type}" role="alert">
<div>
<h4 class="alert-title">${escapeHtml(title)}</h4>
<div class="text-secondary">${escapeHtml(message || "")}</div>
${warnHtml}
</div>
</div>
`;
}
function clearPlotAlert() {
const el = document.getElementById("plot_alert");
if (el) el.innerHTML = "";
}
function clearPlots() {
const eq = document.getElementById("plot_strategy");
if (eq) eq.innerHTML = "";
clearRegimeSummary();
}
document.getElementById("lock_inherited")
.addEventListener("change", applyInheritedLock);
init();