From a42255d58caf5b2ce2dfeb4677e8facf68d97c83 Mon Sep 17 00:00:00 2001 From: dam Date: Fri, 6 Mar 2026 20:39:37 +0100 Subject: [PATCH] =?UTF-8?q?Step=203=20=E2=80=93=20Strategy=20validation,?= =?UTF-8?q?=20regime=20detection=20and=20UI=20improvements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/calibration/strategies_inspector.py | 79 +++++ src/core/market_regime.py | 312 ++++++++++++++++++ .../static/js/pages/calibration_strategies.js | 311 ++++++++++++++++- .../calibration/calibration_strategies.html | 8 +- 4 files changed, 702 insertions(+), 8 deletions(-) diff --git a/src/calibration/strategies_inspector.py b/src/calibration/strategies_inspector.py index ef57f0e..c795324 100644 --- a/src/calibration/strategies_inspector.py +++ b/src/calibration/strategies_inspector.py @@ -10,6 +10,7 @@ from src.data.storage import StorageManager from src.utils.logger import log from src.core.walk_forward import WalkForwardValidator +from src.core.market_regime import TrendScoreConfig, compute_regimes_for_windows from src.risk.stops.fixed_stop import FixedStop from src.risk.stops.trailing_stop import TrailingStop @@ -184,10 +185,43 @@ def _compute_wf_diagnostics( } +def _compute_regime_performance( + *, + window_returns_pct: List[float], + window_trades: List[int], + regime_windows: List[Dict[str, Any]], +) -> Dict[str, Any]: + def _stats(indices: List[int]) -> Dict[str, Any]: + rets = [float(window_returns_pct[i]) for i in indices if i < len(window_returns_pct)] + trds = [int(window_trades[i]) for i in indices if i < len(window_trades)] + return { + "n_windows": int(len(indices)), + "mean_return_pct": float(np.mean(rets)) if rets else 0.0, + "positive_window_rate": float(np.mean(np.asarray(rets) > 0.0)) if rets else 0.0, + "avg_trades": float(np.mean(trds)) if trds else 0.0, + } + + out_group: Dict[str, Any] = {} + for regime in ("bull", "sideways", "bear"): + idx = [i for i, rw in enumerate(regime_windows) if rw.get("regime") == regime] + out_group[regime] = _stats(idx) + + out_detail: Dict[str, Any] = {} + for regime in ("bull_strong", "bull_moderate", "sideways", "bear_moderate", "bear_strong"): + idx = [i for i, rw in enumerate(regime_windows) if rw.get("regime_detail") == regime] + out_detail[regime] = _stats(idx) + + return { + "group": out_group, + "detail": out_detail, + } + + # -------------------------------------------------- # Main # -------------------------------------------------- + def inspect_strategies_config( *, storage: StorageManager, @@ -242,6 +276,27 @@ def inspect_strategies_config( test_td = pd.Timedelta(days=int(payload.wf.test_days)) step_td = pd.Timedelta(days=int(payload.wf.step_days or payload.wf.test_days)) + # Regime analysis is market-level (shared by all strategies for the same WF config) + regime_cfg = TrendScoreConfig() + wf_probe = WalkForwardValidator( + strategy_class=BuyAndHold, + param_grid=None, + fixed_params={}, + data=df, + train_window=train_td, + test_window=test_td, + step_size=step_td, + initial_capital=float(payload.account_equity), + commission=float(payload.commission), + slippage=float(payload.slippage), + position_sizer=base_sizer, + stop_loss=stop_loss, + verbose=False, + ) + wf_windows = wf_probe._generate_windows() + regime_bundle = compute_regimes_for_windows(df, wf_windows, config=regime_cfg) + regime_by_window = {int(r["window"]): r for r in regime_bundle["by_window"]} + overall_status = "ok" results: List[Dict[str, Any]] = [] @@ -434,6 +489,7 @@ def inspect_strategies_config( msg = "WF OK" for _, r in win_df.iterrows(): + regime_meta = regime_by_window.get(int(r["window"]), {}) windows_out.append({ "window": int(r["window"]), "train_start": str(r["train_start"]), @@ -445,6 +501,13 @@ def inspect_strategies_config( "max_dd_pct": float(r["max_dd_pct"]), "trades": int(r["trades"]), "params": dict(r["params"]) if isinstance(r["params"], dict) else r["params"], + "regime": regime_meta.get("regime"), + "regime_detail": regime_meta.get("regime_detail"), + "bull_strong_pct": float(regime_meta.get("bull_strong_pct", 0.0)), + "bull_moderate_pct": float(regime_meta.get("bull_moderate_pct", 0.0)), + "sideways_detail_pct": float(regime_meta.get("sideways_detail_pct", 0.0)), + "bear_moderate_pct": float(regime_meta.get("bear_moderate_pct", 0.0)), + "bear_strong_pct": float(regime_meta.get("bear_strong_pct", 0.0)), }) oos_returns = win_df["return_pct"].astype(float).tolist() @@ -462,6 +525,17 @@ def inspect_strategies_config( hist_bins=10, ) + regime_windows = [regime_by_window.get(int(r["window"]), {"window": int(r["window"]), "regime": "sideways", "bull_pct": 0.0, "sideways_pct": 0.0, "bear_pct": 0.0}) for _, r in win_df.iterrows()] + diagnostics["regimes"] = { + "config": regime_bundle["config"], + "by_window": regime_windows, + "performance": _compute_regime_performance( + window_returns_pct=oos_returns, + window_trades=win_df["trades"].astype(int).tolist(), + regime_windows=regime_windows, + ), + } + # keep worst-window DD also at top-level for backwards compatibility diagnostics["drawdown"]["worst_window_dd_pct"] = float(oos_max_dd) @@ -486,6 +560,7 @@ def inspect_strategies_config( "window_returns_pct": oos_returns, "window_equity": eq_curve, "window_trades": win_df["trades"].tolist(), + "window_regimes": diagnostics.get("regimes", {}).get("by_window", []), "diagnostics": diagnostics, } @@ -529,6 +604,10 @@ def inspect_strategies_config( "commission": payload.commission, "slippage": payload.slippage, }, + "regimes": { + "config": regime_bundle["config"], + "by_window": regime_bundle["by_window"], + }, } if include_series: diff --git a/src/core/market_regime.py b/src/core/market_regime.py index e69de29..5edaec5 100644 --- a/src/core/market_regime.py +++ b/src/core/market_regime.py @@ -0,0 +1,312 @@ +# src/core/market_regime.py +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, Iterable, List, Sequence + +import numpy as np +import pandas as pd + + +REGIME_BULL_STRONG = "bull_strong" +REGIME_BULL_MODERATE = "bull_moderate" +REGIME_SIDEWAYS = "sideways" +REGIME_BEAR_MODERATE = "bear_moderate" +REGIME_BEAR_STRONG = "bear_strong" + +REGIME_DETAIL_LABELS = ( + REGIME_BULL_STRONG, + REGIME_BULL_MODERATE, + REGIME_SIDEWAYS, + REGIME_BEAR_MODERATE, + REGIME_BEAR_STRONG, +) + +REGIME_BULL = "bull" +REGIME_BEAR = "bear" +REGIME_GROUP_LABELS = (REGIME_BULL, REGIME_SIDEWAYS, REGIME_BEAR) + + +@dataclass(frozen=True) +class TrendScoreConfig: + price_col: str = "close" + ema_periods: Sequence[int] = (20, 50, 100, 200) + + bull_threshold: int = 2 + bull_strong_threshold: int = 4 + bear_threshold: int = -2 + bear_strong_threshold: int = -4 + + bull_persistence_bars: int = 5 + sideways_persistence_bars: int = 3 + bear_persistence_bars: int = 2 + + +def _classify_window_avg_score(avg_score: float) -> str: + if avg_score >= 3.0: + return REGIME_BULL_STRONG + if avg_score >= 1.0: + return REGIME_BULL_MODERATE + if avg_score > -1.0: + return REGIME_SIDEWAYS + if avg_score > -3.0: + return REGIME_BEAR_MODERATE + return REGIME_BEAR_STRONG + + +def _classify_score_detail( + score: float, + *, + bull_threshold: int, + bull_strong_threshold: int, + bear_threshold: int, + bear_strong_threshold: int, +) -> str: + if score >= bull_strong_threshold: + return REGIME_BULL_STRONG + if score >= bull_threshold: + return REGIME_BULL_MODERATE + if score <= bear_strong_threshold: + return REGIME_BEAR_STRONG + if score <= bear_threshold: + return REGIME_BEAR_MODERATE + return REGIME_SIDEWAYS + + +def _detail_to_group(label: str) -> str: + if label in (REGIME_BULL_STRONG, REGIME_BULL_MODERATE): + return REGIME_BULL + if label in (REGIME_BEAR_STRONG, REGIME_BEAR_MODERATE): + return REGIME_BEAR + return REGIME_SIDEWAYS + + +def _apply_asymmetric_persistence_filter( + labels: Sequence[str], + *, + bull_persistence_bars: int, + sideways_persistence_bars: int, + bear_persistence_bars: int, +) -> List[str]: + if not labels: + return [] + + def family(label: str) -> str: + if label in (REGIME_BULL_STRONG, REGIME_BULL_MODERATE): + return REGIME_BULL + if label in (REGIME_BEAR_STRONG, REGIME_BEAR_MODERATE): + return REGIME_BEAR + return REGIME_SIDEWAYS + + def required_bars(label: str) -> int: + fam = family(label) + if fam == REGIME_BULL: + return max(int(bull_persistence_bars), 1) + if fam == REGIME_BEAR: + return max(int(bear_persistence_bars), 1) + return max(int(sideways_persistence_bars), 1) + + confirmed = labels[0] + candidate = labels[0] + candidate_run = 0 + out: List[str] = [] + + for label in labels: + if family(label) == family(confirmed): + confirmed = label + candidate = label + candidate_run = 0 + out.append(confirmed) + continue + + if family(label) == family(candidate): + candidate_run += 1 + else: + candidate = label + candidate_run = 1 + + if candidate_run >= required_bars(candidate): + confirmed = candidate + candidate_run = 0 + + out.append(confirmed) + + return out + + +def compute_regime_series( + df: pd.DataFrame, + config: TrendScoreConfig | None = None, +) -> pd.DataFrame: + cfg = config or TrendScoreConfig() + + if df is None or df.empty: + return pd.DataFrame( + columns=[ + cfg.price_col, + "trend_score", + "raw_regime_detail", + "stable_regime_detail", + "stable_regime_group", + ] + ) + + if cfg.price_col not in df.columns: + raise ValueError(f"price_col '{cfg.price_col}' not found in DataFrame") + + out = pd.DataFrame(index=df.index.copy()) + out[cfg.price_col] = pd.to_numeric(df[cfg.price_col], errors="coerce").astype(float) + + score = pd.Series(0, index=out.index, dtype=int) + + for period in cfg.ema_periods: + ema_col = f"ema_{int(period)}" + out[ema_col] = out[cfg.price_col].ewm( + span=int(period), + adjust=False, + min_periods=1, + ).mean() + score += np.where(out[cfg.price_col] >= out[ema_col], 1, -1) + + out["trend_score"] = score.astype(int) + + raw_detail = [ + _classify_score_detail( + v, + bull_threshold=cfg.bull_threshold, + bull_strong_threshold=cfg.bull_strong_threshold, + bear_threshold=cfg.bear_threshold, + bear_strong_threshold=cfg.bear_strong_threshold, + ) + for v in out["trend_score"].tolist() + ] + + stable_detail = _apply_asymmetric_persistence_filter( + raw_detail, + bull_persistence_bars=cfg.bull_persistence_bars, + sideways_persistence_bars=cfg.sideways_persistence_bars, + bear_persistence_bars=cfg.bear_persistence_bars, + ) + + out["raw_regime_detail"] = raw_detail + out["stable_regime_detail"] = stable_detail + out["stable_regime_group"] = [_detail_to_group(x) for x in stable_detail] + + return out + + +def summarize_regimes_for_windows( + regime_df: pd.DataFrame, + windows: Iterable[Dict[str, Any]], + *, + detail_col: str = "stable_regime_detail", + group_col: str = "stable_regime_group", +) -> List[Dict[str, Any]]: + if regime_df is None or regime_df.empty: + return [] + + if detail_col not in regime_df.columns: + raise ValueError(f"detail_col '{detail_col}' not found in regime_df") + if group_col not in regime_df.columns: + raise ValueError(f"group_col '{group_col}' not found in regime_df") + + summaries: List[Dict[str, Any]] = [] + + for w in windows: + test_start = pd.Timestamp(w["test_start"]) + test_end = pd.Timestamp(w["test_end"]) + window_id = int(w["window_id"]) + + mask = (regime_df.index >= test_start) & (regime_df.index <= test_end) + + seg_detail = regime_df.loc[mask, detail_col] + seg_group = regime_df.loc[mask, group_col] + seg_score = regime_df.loc[mask, "trend_score"] + + total = int(len(seg_detail)) + + if total == 0: + summaries.append({ + "window": window_id, + "regime": REGIME_SIDEWAYS, + "regime_detail": REGIME_SIDEWAYS, + "bull_pct": 0.0, + "sideways_pct": 0.0, + "bear_pct": 0.0, + "bull_strong_pct": 0.0, + "bull_moderate_pct": 0.0, + "sideways_detail_pct": 0.0, + "bear_moderate_pct": 0.0, + "bear_strong_pct": 0.0, + "n_bars": 0, + }) + continue + + group_counts = seg_group.value_counts() + detail_counts = seg_detail.value_counts() + + bull_pct = float(group_counts.get(REGIME_BULL, 0) / total) + sideways_pct = float(group_counts.get(REGIME_SIDEWAYS, 0) / total) + bear_pct = float(group_counts.get(REGIME_BEAR, 0) / total) + + bull_strong_pct = float(detail_counts.get(REGIME_BULL_STRONG, 0) / total) + bull_moderate_pct = float(detail_counts.get(REGIME_BULL_MODERATE, 0) / total) + sideways_detail_pct = float(detail_counts.get(REGIME_SIDEWAYS, 0) / total) + bear_moderate_pct = float(detail_counts.get(REGIME_BEAR_MODERATE, 0) / total) + bear_strong_pct = float(detail_counts.get(REGIME_BEAR_STRONG, 0) / total) + + majority_group = max( + ( + (REGIME_BULL, bull_pct), + (REGIME_SIDEWAYS, sideways_pct), + (REGIME_BEAR, bear_pct), + ), + key=lambda x: x[1], + )[0] + + avg_score = float(seg_score.mean()) if len(seg_score) else 0.0 + majority_detail = _classify_window_avg_score(avg_score) + + summaries.append({ + "window": window_id, + "regime": majority_group, + "regime_detail": majority_detail, + "avg_score": avg_score, + "bull_pct": bull_pct, + "sideways_pct": sideways_pct, + "bear_pct": bear_pct, + "bull_strong_pct": bull_strong_pct, + "bull_moderate_pct": bull_moderate_pct, + "sideways_detail_pct": sideways_detail_pct, + "bear_moderate_pct": bear_moderate_pct, + "bear_strong_pct": bear_strong_pct, + "n_bars": total, + }) + + return summaries + + +def compute_regimes_for_windows( + benchmark_df: pd.DataFrame, + windows: Iterable[Dict[str, Any]], + config: TrendScoreConfig | None = None, +) -> Dict[str, Any]: + cfg = config or TrendScoreConfig() + regime_df = compute_regime_series(benchmark_df, config=cfg) + by_window = summarize_regimes_for_windows(regime_df, windows) + + return { + "config": { + "price_col": cfg.price_col, + "ema_periods": list(cfg.ema_periods), + "bull_threshold": int(cfg.bull_threshold), + "bull_strong_threshold": int(cfg.bull_strong_threshold), + "bear_threshold": int(cfg.bear_threshold), + "bear_strong_threshold": int(cfg.bear_strong_threshold), + "bull_persistence_bars": int(cfg.bull_persistence_bars), + "sideways_persistence_bars": int(cfg.sideways_persistence_bars), + "bear_persistence_bars": int(cfg.bear_persistence_bars), + }, + "by_bar": regime_df, + "by_window": by_window, + } \ No newline at end of file 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 d3c87cf..6eeb5c3 100644 --- a/src/web/ui/v2/static/js/pages/calibration_strategies.js +++ b/src/web/ui/v2/static/js/pages/calibration_strategies.js @@ -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) { OOS Return % OOS Max DD % Windows + Best Regime `; 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 += ` @@ -1387,6 +1472,7 @@ function renderValidateResponse(data) { ${r.oos_total_return_pct?.toFixed(2)} ${r.oos_max_dd_worst_pct?.toFixed(2)} ${r.n_windows} + ${bestRegime} `; @@ -1395,7 +1481,7 @@ function renderValidateResponse(data) { html += ` - + @@ -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 ` +
+
+
+
+

${regimeLabel(regime)}

+ ${regimeLabel(regime).toUpperCase()} +
+ +
+
+
Windows
+
${Number(p.n_windows ?? 0)}
+
+
+
Mean return
+
${fmtPct(p.mean_return_pct)}
+
+
+
Positive rate
+
${fmtPct((Number(p.positive_window_rate ?? 0) * 100.0))}
+
+
+
Avg trades
+
${fmtNum(p.avg_trades)}
+
+
+
+
+
+ `; + } + + 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) => ` + + ${Number(w.window ?? 0)} + + + ${regimeLabel(w.regime_detail || w.regime)} + + + ${fmtPct(Number(w.bull_strong_pct ?? 0) * 100.0)} + ${fmtPct(Number(w.bull_moderate_pct ?? 0) * 100.0)} + ${fmtPct(Number(w.sideways_detail_pct ?? 0) * 100.0)} + ${fmtPct(Number(w.bear_moderate_pct ?? 0) * 100.0)} + ${fmtPct(Number(w.bear_strong_pct ?? 0) * 100.0)} + + `).join(""); + + wrap.innerHTML = ` +
+
+
+

Regime Analysis — ${strategyId}

+
+ 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 ?? "-"} +
+
+ ${ + bestRegime + ? ` + Best regime: ${regimeLabel(bestRegime)} + ` + : `Best regime: —` + } +
+ +
+
+
+
+ ${topCards} +
+
+
+ +
+
+
+ ${bottomCards} +
+
+
+ +
+ + + + + + + + + + + + + + ${rows || ``} + +
WindowMajority regimeBull strong %Bull mod %Sideways %Bear mod %Bear strong %
No regime data available.
+
+
+
+ `; +} + // ================================================= // 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") diff --git a/src/web/ui/v2/templates/pages/calibration/calibration_strategies.html b/src/web/ui/v2/templates/pages/calibration/calibration_strategies.html index a6bff9c..6e6f14e 100644 --- a/src/web/ui/v2/templates/pages/calibration/calibration_strategies.html +++ b/src/web/ui/v2/templates/pages/calibration/calibration_strategies.html @@ -303,6 +303,10 @@
Run validation to see results.
+
+ +
+
@@ -320,9 +324,7 @@
-
- -
+
Debug JSON