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).
This commit is contained in:
dam
2026-03-06 20:39:37 +01:00
parent 365304f396
commit a42255d58c
4 changed files with 702 additions and 8 deletions

View File

@@ -738,6 +738,49 @@ function renderEquityAndReturns(strategyId, s, data) {
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);
@@ -794,6 +837,8 @@ function renderEquityAndReturns(strategyId, s, data) {
alignmentgroup: "bottom"
};
const regimeShapesTop = buildRegimeBackgroundShapes(regimeWindows);
// ---- Layout ----
const layout = {
grid: { rows: 2, columns: 1, pattern: "independent" },
@@ -844,6 +889,7 @@ function renderEquityAndReturns(strategyId, s, data) {
bargap: 0.2,
shapes: [
...regimeShapesTop,
{
type: "line",
x0: -0.5,
@@ -1114,13 +1160,16 @@ function renderTradeDensity(strategyId, s, data) {
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: "y1"
yaxis: "y"
},
{
x,
@@ -1132,15 +1181,24 @@ function renderTradeDensity(strategyId, s, data) {
}
], {
margin: { t: 40 },
title: { text: `Trade Density — ${strategyId}`, x: 0.5 }, // ✅ centrado + dinámico,
title: { text: `Trade Density — ${strategyId}`, x: 0.5 },
barmode: "group",
xaxis: { title: "Window" },
yaxis: { title: "Trades / window" },
yaxis: {
title: "Trades / window",
range: [0, tpwMax],
zeroline: true,
zerolinewidth: 2
},
yaxis2: {
title: "Trades / day",
overlaying: "y",
side: "right",
zeroline: false
range: [0, tpdMax],
zeroline: true,
zerolinewidth: 2
}
});
}
@@ -1316,8 +1374,15 @@ function selectStrategy(strategyId, data) {
// 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) {
@@ -1370,11 +1435,31 @@ function renderValidateResponse(data) {
<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>
@@ -1387,6 +1472,7 @@ function renderValidateResponse(data) {
<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>
`;
@@ -1395,7 +1481,7 @@ function renderValidateResponse(data) {
html += `
<tr class="table-warning">
<td colspan="5">
<td colspan="6">
<ul class="mb-0">
${r.warnings.map(w => `<li>${w}</li>`).join("")}
</ul>
@@ -1652,6 +1738,219 @@ 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":
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
// =================================================
@@ -1707,6 +2006,8 @@ function clearPlotAlert() {
function clearPlots() {
const eq = document.getElementById("plot_strategy");
if (eq) eq.innerHTML = "";
clearRegimeSummary();
}
document.getElementById("lock_inherited")