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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user