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