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:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,12 +1435,32 @@ 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>
|
||||
<td class="strategy-row"
|
||||
@@ -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")
|
||||
|
||||
@@ -303,6 +303,10 @@
|
||||
<div class="card-body">
|
||||
<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="col-md-4">
|
||||
<label class="form-label">Strategy plot</label>
|
||||
@@ -320,9 +324,7 @@
|
||||
<div id="plot_strategy" style="height: 700px; width: 100%;"></div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<div id="strategies_table_wrap"></div>
|
||||
|
||||
<details class="mt-3">
|
||||
<summary class="text-secondary">Debug JSON</summary>
|
||||
|
||||
Reference in New Issue
Block a user