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

@@ -10,6 +10,7 @@ from src.data.storage import StorageManager
from src.utils.logger import log from src.utils.logger import log
from src.core.walk_forward import WalkForwardValidator 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.fixed_stop import FixedStop
from src.risk.stops.trailing_stop import TrailingStop 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 # Main
# -------------------------------------------------- # --------------------------------------------------
def inspect_strategies_config( def inspect_strategies_config(
*, *,
storage: StorageManager, storage: StorageManager,
@@ -242,6 +276,27 @@ def inspect_strategies_config(
test_td = pd.Timedelta(days=int(payload.wf.test_days)) 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)) 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" overall_status = "ok"
results: List[Dict[str, Any]] = [] results: List[Dict[str, Any]] = []
@@ -434,6 +489,7 @@ def inspect_strategies_config(
msg = "WF OK" msg = "WF OK"
for _, r in win_df.iterrows(): for _, r in win_df.iterrows():
regime_meta = regime_by_window.get(int(r["window"]), {})
windows_out.append({ windows_out.append({
"window": int(r["window"]), "window": int(r["window"]),
"train_start": str(r["train_start"]), "train_start": str(r["train_start"]),
@@ -445,6 +501,13 @@ def inspect_strategies_config(
"max_dd_pct": float(r["max_dd_pct"]), "max_dd_pct": float(r["max_dd_pct"]),
"trades": int(r["trades"]), "trades": int(r["trades"]),
"params": dict(r["params"]) if isinstance(r["params"], dict) else r["params"], "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() oos_returns = win_df["return_pct"].astype(float).tolist()
@@ -462,6 +525,17 @@ def inspect_strategies_config(
hist_bins=10, 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 # keep worst-window DD also at top-level for backwards compatibility
diagnostics["drawdown"]["worst_window_dd_pct"] = float(oos_max_dd) diagnostics["drawdown"]["worst_window_dd_pct"] = float(oos_max_dd)
@@ -486,6 +560,7 @@ def inspect_strategies_config(
"window_returns_pct": oos_returns, "window_returns_pct": oos_returns,
"window_equity": eq_curve, "window_equity": eq_curve,
"window_trades": win_df["trades"].tolist(), "window_trades": win_df["trades"].tolist(),
"window_regimes": diagnostics.get("regimes", {}).get("by_window", []),
"diagnostics": diagnostics, "diagnostics": diagnostics,
} }
@@ -529,6 +604,10 @@ def inspect_strategies_config(
"commission": payload.commission, "commission": payload.commission,
"slippage": payload.slippage, "slippage": payload.slippage,
}, },
"regimes": {
"config": regime_bundle["config"],
"by_window": regime_bundle["by_window"],
},
} }
if include_series: if include_series:

View File

@@ -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,
}

View File

@@ -738,6 +738,49 @@ function renderEquityAndReturns(strategyId, s, data) {
const ret = s.window_returns_pct || []; const ret = s.window_returns_pct || [];
const trd = s.window_trades || []; 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) // X común (windows)
const n = Math.max(equity.length, ret.length, trd.length); const n = Math.max(equity.length, ret.length, trd.length);
const x = Array.from({ length: n }, (_, i) => i); const x = Array.from({ length: n }, (_, i) => i);
@@ -794,6 +837,8 @@ function renderEquityAndReturns(strategyId, s, data) {
alignmentgroup: "bottom" alignmentgroup: "bottom"
}; };
const regimeShapesTop = buildRegimeBackgroundShapes(regimeWindows);
// ---- Layout ---- // ---- Layout ----
const layout = { const layout = {
grid: { rows: 2, columns: 1, pattern: "independent" }, grid: { rows: 2, columns: 1, pattern: "independent" },
@@ -844,6 +889,7 @@ function renderEquityAndReturns(strategyId, s, data) {
bargap: 0.2, bargap: 0.2,
shapes: [ shapes: [
...regimeShapesTop,
{ {
type: "line", type: "line",
x0: -0.5, x0: -0.5,
@@ -1114,13 +1160,16 @@ function renderTradeDensity(strategyId, s, data) {
const tpd = s.diagnostics?.trades?.trades_per_day || []; const tpd = s.diagnostics?.trades?.trades_per_day || [];
const x = tpw.map((_, i) => i + 1); const x = tpw.map((_, i) => i + 1);
const tpwMax = Math.max(0, ...tpw);
const tpdMax = Math.max(0, ...tpd);
Plotly.newPlot("plot_strategy", [ Plotly.newPlot("plot_strategy", [
{ {
x, x,
y: tpw, y: tpw,
type: "bar", type: "bar",
name: "Trades / window", name: "Trades / window",
yaxis: "y1" yaxis: "y"
}, },
{ {
x, x,
@@ -1132,15 +1181,24 @@ function renderTradeDensity(strategyId, s, data) {
} }
], { ], {
margin: { t: 40 }, margin: { t: 40 },
title: { text: `Trade Density — ${strategyId}`, x: 0.5 }, // ✅ centrado + dinámico, title: { text: `Trade Density — ${strategyId}`, x: 0.5 },
barmode: "group", barmode: "group",
xaxis: { title: "Window" }, xaxis: { title: "Window" },
yaxis: { title: "Trades / window" },
yaxis: {
title: "Trades / window",
range: [0, tpwMax],
zeroline: true,
zerolinewidth: 2
},
yaxis2: { yaxis2: {
title: "Trades / day", title: "Trades / day",
overlaying: "y", overlaying: "y",
side: "right", 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 // 4) Mantén el gráfico previamente seleccionado en el dropdown
const chartType = document.getElementById("plot_strategy_select").value; const chartType = document.getElementById("plot_strategy_select").value;
renderChart(chartType, selectedStrategyId, strategyData, data); // Renderiza el gráfico correctamente renderChart(chartType, selectedStrategyId, strategyData, data); // Renderiza el gráfico correctamente
renderRegimeSummary(selectedStrategyId, strategyData, data);
highlightSelectedRow(strategyId); highlightSelectedRow(strategyId);
// scroll automático al gráfico
document.getElementById("plot_strategy")?.scrollIntoView({
behavior: "smooth",
block: "start"
});
} }
function renderValidateResponse(data) { function renderValidateResponse(data) {
@@ -1370,11 +1435,31 @@ function renderValidateResponse(data) {
<th>OOS Return %</th> <th>OOS Return %</th>
<th>OOS Max DD %</th> <th>OOS Max DD %</th>
<th>Windows</th> <th>Windows</th>
<th>Best Regime</th>
</tr> </tr>
</thead> </thead>
<tbody>`; <tbody>`;
for (const r of data.results) { 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 += ` html += `
<tr> <tr>
@@ -1387,6 +1472,7 @@ function renderValidateResponse(data) {
<td>${r.oos_total_return_pct?.toFixed(2)}</td> <td>${r.oos_total_return_pct?.toFixed(2)}</td>
<td>${r.oos_max_dd_worst_pct?.toFixed(2)}</td> <td>${r.oos_max_dd_worst_pct?.toFixed(2)}</td>
<td>${r.n_windows}</td> <td>${r.n_windows}</td>
<td>${bestRegime}</td>
</tr> </tr>
`; `;
@@ -1395,7 +1481,7 @@ function renderValidateResponse(data) {
html += ` html += `
<tr class="table-warning"> <tr class="table-warning">
<td colspan="5"> <td colspan="6">
<ul class="mb-0"> <ul class="mb-0">
${r.warnings.map(w => `<li>${w}</li>`).join("")} ${r.warnings.map(w => `<li>${w}</li>`).join("")}
</ul> </ul>
@@ -1652,6 +1738,219 @@ async function init() {
}, 0); }, 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 // PLOT ALERTS (Tabler) + SAFE PLOT CLEAR
// ================================================= // =================================================
@@ -1707,6 +2006,8 @@ function clearPlotAlert() {
function clearPlots() { function clearPlots() {
const eq = document.getElementById("plot_strategy"); const eq = document.getElementById("plot_strategy");
if (eq) eq.innerHTML = ""; if (eq) eq.innerHTML = "";
clearRegimeSummary();
} }
document.getElementById("lock_inherited") document.getElementById("lock_inherited")

View File

@@ -303,6 +303,10 @@
<div class="card-body"> <div class="card-body">
<div id="strategies_message" class="mb-3 text-secondary">Run validation to see results.</div> <div id="strategies_message" class="mb-3 text-secondary">Run validation to see results.</div>
<div id="strategies_table_wrap"></div>
<hr class="my-4">
<div class="row g-3"> <div class="row g-3">
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">Strategy plot</label> <label class="form-label">Strategy plot</label>
@@ -320,9 +324,7 @@
<div id="plot_strategy" style="height: 700px; width: 100%;"></div> <div id="plot_strategy" style="height: 700px; width: 100%;"></div>
</div> </div>
<hr class="my-4">
<div id="strategies_table_wrap"></div>
<details class="mt-3"> <details class="mt-3">
<summary class="text-secondary">Debug JSON</summary> <summary class="text-secondary">Debug JSON</summary>