diff --git a/src/core/market_regime.py b/src/core/market_regime.py
new file mode 100644
index 0000000..e69de29
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 0dddb27..d3c87cf 100644
--- a/src/web/ui/v2/static/js/pages/calibration_strategies.js
+++ b/src/web/ui/v2/static/js/pages/calibration_strategies.js
@@ -734,98 +734,342 @@ async function pollStatus(jobId) {
// =================================================
function renderEquityAndReturns(strategyId, s, data) {
+ const equity = s.window_equity || [];
+ const ret = s.window_returns_pct || [];
+ const trd = s.window_trades || [];
- console.log("Plotly object:", Plotly); // Esto debería mostrarte el objeto Plotly completo
+ // X común (windows)
+ const n = Math.max(equity.length, ret.length, trd.length);
+ const x = Array.from({ length: n }, (_, i) => i);
- if (Plotly && Plotly.subplots) {
- console.log("make_subplots is available");
- } else {
- console.error("make_subplots is NOT available");
+ // ---- 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"
+ };
+
+ // ---- 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: [
+ {
+ 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));
}
- // Crea el subplot con dos filas y una columna (1x2)
- var fig = Plotly.subplots.make_subplots({
- rows: 2, // 2 filas
- cols: 1, // 1 columna
- shared_xaxes: true, // Compartir el eje X entre ambos gráficos
- vertical_spacing: 0.1, // Espacio entre los gráficos
- subplot_titles: [`Equity — ${strategyId}`, `Returns & Trades — ${strategyId}`], // Títulos para cada subgráfico
- column_widths: [0.7] // Ajustar el ancho de las columnas si es necesario
+ 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;
+ });
});
-
- // Datos de Equity
- const equityTrace = {
- y: s.window_equity, // Datos de la equity
- type: "scatter", // Tipo de gráfico: línea
- mode: "lines", // Modo: línea
- name: "Equity"
- };
-
- // Datos de Return %
- const returnsTrace = {
- y: s.window_returns_pct || [], // Datos de returns
- type: "bar", // Tipo de gráfico: barra
- name: "Return %",
- marker: { color: "#3b82f6" }, // Color de la barra
- yaxis: "y1", // Asociar al primer eje Y
- };
-
- // Datos de Trades
- const tradesTrace = {
- y: s.window_trades || [], // Datos de trades
- type: "bar", // Tipo de gráfico: barra
- name: "Trades",
- marker: { color: "#f59e0b" }, // Color de la barra
- yaxis: "y2", // Asociar al segundo eje Y
- };
-
- // Agregar la traza de "Equity" al subplot (fila 1, columna 1)
- fig.addTrace(equityTrace, 1, 1);
-
- // Agregar las trazas de "Returns" y "Trades" al subplot (fila 2, columna 1)
- fig.addTrace(returnsTrace, 2, 1);
- fig.addTrace(tradesTrace, 2, 1);
-
- // Configurar los ejes y los márgenes
- fig.update_layout({
- title: `Strategy Overview — ${strategyId}`,
- yaxis: {
- title: "Equity",
- showgrid: true
- },
- yaxis2: {
- title: "Return % / Trades",
- overlaying: "y",
- side: "right",
- showgrid: true
- },
- xaxis: {
- title: "Windows",
- showgrid: true
- },
- showlegend: true
- });
-
- // Renderizar en el contenedor del gráfico
- Plotly.newPlot("plot_strategy", fig);
}
function renderRollingSharpe(strategyId, s, data) {
const roll = s.diagnostics?.rolling?.rolling_sharpe_like || [];
const x = roll.map((_, i) => i + 1);
- Plotly.newPlot("plot_strategy", [{
- x,
- y: roll,
- type: "scatter",
- mode: "lines+markers",
- name: `Rolling Sharpe-like (k=${s.diagnostics?.rolling?.rolling_window ?? "?"})`
- }], {
- margin: { t: 40 },
- title: `Rolling Sharpe-like — ${strategyId}`,
- xaxis: { title: "Window" },
- yaxis: { title: "Sharpe-like" }
- });
+ 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) {
@@ -841,7 +1085,7 @@ function renderOOSReturnsDistribution(strategyId, s, data) {
name: "OOS Return% (bins)"
}], {
margin: { t: 40 },
- title: `OOS Returns Distribution — ${strategyId}`,
+ title: { text: `OOS Returns Distribution — ${strategyId}`, x: 0.5 }, // ✅ centrado + dinámico
xaxis: { title: "Return % (window)" },
yaxis: { title: "Count" }
});
@@ -859,7 +1103,7 @@ function renderDrawdownEvolution(strategyId, s, data) {
name: "Drawdown %"
}], {
margin: { t: 40 },
- title: `Drawdown Evolution — ${strategyId}`,
+ title: { text: `Drawdown Evolution — ${strategyId}`, x: 0.5 }, // ✅ centrado + dinámico
xaxis: { title: "Point (initial + windows)" },
yaxis: { title: "Drawdown %", zeroline: true }
});
@@ -888,7 +1132,7 @@ function renderTradeDensity(strategyId, s, data) {
}
], {
margin: { t: 40 },
- title: `Trade Density — ${strategyId}`,
+ title: { text: `Trade Density — ${strategyId}`, x: 0.5 }, // ✅ centrado + dinámico,
barmode: "group",
xaxis: { title: "Window" },
yaxis: { title: "Trades / window" },
diff --git a/src/web/ui/v2/templates/base.html b/src/web/ui/v2/templates/base.html
index 48ebccb..3d0997a 100644
--- a/src/web/ui/v2/templates/base.html
+++ b/src/web/ui/v2/templates/base.html
@@ -27,9 +27,9 @@
-
+
{% block extra_js %}{% endblock %}