feat: quant validation framework — DSR, PSR, Haircut, regimes, walk-forward
Three-module quant framework replacing 'sort by Sharpe' with proper statistical validation: quant/significance.py (15 tests): - deflated_sharpe_ratio(): adjusts for N trials (Harvey & Liu 2015) - probabilistic_sharpe_ratio(): P(True SR > benchmark) given T, skew, kurt - sharpe_haircut(): expected OOS Sharpe after selection bias deflation - QuantVerdict: DEPLOY / SIMULATE / DISCARD with 5-point scoring - validate_strategy(): one-shot validation function quant/regimes.py (8 tests): - classify_regime(): trending_up/down, ranging, volatile - RegimeClassifier: stateful rolling-window classifier - conditional_performance(): per-regime trade statistics quant/walkforward.py (5 tests): - WalkForwardRunner: sequential IS/OOS window optimization - WFWindow/WFReport: structured walk-forward results - consistency score, performance decay, concatenated OOS equity - significance_report() integration Walk-forward results (real HL data with date-sliced windows): grid_mm 1h: 2/4 pos, OOS S=-0.45, 74t, haircut=-22.66 → DISCARD momentum 4h: 2/4 pos, OOS S=-1.47, 116t, haircut=-45.35 → DISCARD composite_mm 1h: 2/4 pos, OOS S=+2.97, 6t, haircut=+43.25 → SIMULATE 28 tests total
This commit is contained in:
@@ -312,6 +312,8 @@ class VBTBacktestRunner:
|
|||||||
interval: str = "1h",
|
interval: str = "1h",
|
||||||
testnet: bool = False,
|
testnet: bool = False,
|
||||||
limit: int = 5000,
|
limit: int = 5000,
|
||||||
|
start_ms: int | None = None,
|
||||||
|
end_ms: int | None = None,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Fetch candles, generate signals, run VBT backtest, return metrics."""
|
"""Fetch candles, generate signals, run VBT backtest, return metrics."""
|
||||||
coins = self._get_coins(strategy)
|
coins = self._get_coins(strategy)
|
||||||
@@ -320,7 +322,7 @@ class VBTBacktestRunner:
|
|||||||
data = {}
|
data = {}
|
||||||
for coin in coins:
|
for coin in coins:
|
||||||
try:
|
try:
|
||||||
df = provider.fetch_candles(coin, interval=interval, limit=limit)
|
df = provider.fetch_candles(coin, interval=interval, limit=limit, start_ms=start_ms, end_ms=end_ms)
|
||||||
if not df.empty:
|
if not df.empty:
|
||||||
data[coin] = df
|
data[coin] = df
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""
|
||||||
|
Market regime classification for strategy gating.
|
||||||
|
|
||||||
|
Classifies each bar into one of four regimes based on rolling returns,
|
||||||
|
volatility, and volume profile. Used to compute conditional strategy
|
||||||
|
performance — a strategy that only works in trending regimes must
|
||||||
|
KNOW when it's in a trending regime.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import deque
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
def classify_regime(
|
||||||
|
returns_20: float,
|
||||||
|
vol_20: float,
|
||||||
|
vol_ratio: float = 1.0,
|
||||||
|
trend_threshold: float = 0.05,
|
||||||
|
vol_threshold: float = 0.04,
|
||||||
|
) -> str:
|
||||||
|
"""Classify a single bar into a market regime.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
returns_20: rolling 20-bar return (fraction, e.g. 0.15 = +15%)
|
||||||
|
vol_20: rolling 20-bar realized volatility (annualized or period)
|
||||||
|
vol_ratio: current volume / rolling average volume
|
||||||
|
trend_threshold: minimum abs return to classify as trending
|
||||||
|
vol_threshold: volatility above which market is "volatile"
|
||||||
|
|
||||||
|
Returns one of: trending_up, trending_down, ranging, volatile
|
||||||
|
"""
|
||||||
|
if vol_20 > vol_threshold or vol_ratio > 2.0:
|
||||||
|
return "volatile"
|
||||||
|
|
||||||
|
if returns_20 > trend_threshold:
|
||||||
|
return "trending_up"
|
||||||
|
elif returns_20 < -trend_threshold:
|
||||||
|
return "trending_down"
|
||||||
|
else:
|
||||||
|
return "ranging"
|
||||||
|
|
||||||
|
|
||||||
|
class RegimeClassifier:
|
||||||
|
"""Stateful regime classifier using rolling windows.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
rc = RegimeClassifier(window=20)
|
||||||
|
for bar in bars:
|
||||||
|
rc.feed(mid_px=bar.close, close=bar.close,
|
||||||
|
open_px=bar.open, vol=bar.volume)
|
||||||
|
regime = rc.current_regime
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
window: int = 20,
|
||||||
|
trend_threshold: float = 0.05,
|
||||||
|
vol_threshold: float = 0.04,
|
||||||
|
):
|
||||||
|
self._window = window
|
||||||
|
self._trend_threshold = trend_threshold
|
||||||
|
self._vol_threshold = vol_threshold
|
||||||
|
|
||||||
|
self._prices: deque[float] = deque(maxlen=window + 1)
|
||||||
|
self._volumes: deque[float] = deque(maxlen=window)
|
||||||
|
self._current_regime: str = "unknown"
|
||||||
|
self._regime_counts: dict[str, int] = {"trending_up": 0, "trending_down": 0,
|
||||||
|
"ranging": 0, "volatile": 0, "unknown": 0}
|
||||||
|
|
||||||
|
def feed(self, mid_px: float, close: float, open_px: float, vol: float):
|
||||||
|
"""Feed a new bar observation."""
|
||||||
|
self._prices.append(mid_px)
|
||||||
|
self._volumes.append(vol)
|
||||||
|
|
||||||
|
if len(self._prices) < self._window + 1:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Rolling return
|
||||||
|
returns_20 = (self._prices[-1] - self._prices[0]) / self._prices[0] if self._prices[0] > 0 else 0
|
||||||
|
|
||||||
|
# Rolling volatility
|
||||||
|
price_list = list(self._prices)
|
||||||
|
rets = [(price_list[i] - price_list[i - 1]) / price_list[i - 1]
|
||||||
|
for i in range(1, len(price_list)) if price_list[i - 1] > 0]
|
||||||
|
vol_20 = np.std(rets) if rets else 0.0
|
||||||
|
|
||||||
|
# Volume ratio
|
||||||
|
avg_vol = sum(self._volumes) / max(len(self._volumes), 1)
|
||||||
|
vol_ratio = vol / avg_vol if avg_vol > 0 else 1.0
|
||||||
|
|
||||||
|
self._current_regime = classify_regime(
|
||||||
|
returns_20, vol_20, vol_ratio,
|
||||||
|
self._trend_threshold, self._vol_threshold,
|
||||||
|
)
|
||||||
|
self._regime_counts[self._current_regime] += 1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_regime(self) -> str:
|
||||||
|
return self._current_regime
|
||||||
|
|
||||||
|
def regime_counts(self) -> dict[str, int]:
|
||||||
|
return dict(self._regime_counts)
|
||||||
|
|
||||||
|
def dominant_regime(self) -> str:
|
||||||
|
"""Most frequent regime observed so far."""
|
||||||
|
return max(self._regime_counts, key=self._regime_counts.get)
|
||||||
|
|
||||||
|
|
||||||
|
def conditional_performance(
|
||||||
|
trades: list[dict],
|
||||||
|
regime_trade_counts: dict[str, int],
|
||||||
|
) -> dict:
|
||||||
|
"""Compute per-regime strategy performance from trade records.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
trades: list of trade dicts with pnl_net, pnl_gross, time
|
||||||
|
regime_trade_counts: {regime_name: count_of_trades}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with per-regime stats (avg_pnl, win_rate, total_pnl, count)
|
||||||
|
and best_regime / worst_regime classification.
|
||||||
|
"""
|
||||||
|
# For simplicity, assume all trades belong to the regime
|
||||||
|
# In production, each trade would be timestamp-matched to regime at entry
|
||||||
|
all_pnls = [float(t.get("pnl_net", t.get("pnl", 0))) for t in trades]
|
||||||
|
|
||||||
|
result = {"regimes": {}, "overall": {"count": len(trades), "total_pnl": round(sum(all_pnls), 2)}}
|
||||||
|
|
||||||
|
for regime, count in regime_trade_counts.items():
|
||||||
|
if count == 0:
|
||||||
|
result["regimes"][regime] = {"count": 0, "total_pnl": 0, "avg_pnl": 0, "win_rate": 0}
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Take the next batch of trades for this regime
|
||||||
|
# (simplified — real impl would match by timestamp)
|
||||||
|
regime_pnls = all_pnls[:count]
|
||||||
|
wins = sum(1 for p in regime_pnls if p > 0)
|
||||||
|
|
||||||
|
result["regimes"][regime] = {
|
||||||
|
"count": count,
|
||||||
|
"total_pnl": round(sum(regime_pnls), 2),
|
||||||
|
"avg_pnl": round(sum(regime_pnls) / count, 2) if count else 0,
|
||||||
|
"win_rate": round(wins / count, 3) if count else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Best and worst regime
|
||||||
|
scored = [(r, info["avg_pnl"]) for r, info in result["regimes"].items() if info["count"] > 0]
|
||||||
|
if scored:
|
||||||
|
result["best_regime"] = max(scored, key=lambda x: x[1])[0]
|
||||||
|
result["worst_regime"] = min(scored, key=lambda x: x[1])[0]
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
"""
|
||||||
|
Statistical significance framework for backtest validation.
|
||||||
|
|
||||||
|
Deflated Sharpe Ratio (Harvey & Liu 2015): adjusts for multiple testing.
|
||||||
|
Probabilistic Sharpe Ratio (Bailey & López de Prado 2012): probability
|
||||||
|
that true Sharpe exceeds a benchmark, given sample size and skew/kurtosis.
|
||||||
|
Haircut: expected OOS Sharpe given IS Sharpe and number of trials.
|
||||||
|
|
||||||
|
QuantVerdict: combines all three into a deploy/simulate/discard decision.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# ── Deflated Sharpe Ratio ───────────────────────────────────
|
||||||
|
|
||||||
|
def deflated_sharpe_ratio(
|
||||||
|
sharpe: float,
|
||||||
|
n_trials: int = 1,
|
||||||
|
n_periods: int = 100,
|
||||||
|
) -> float:
|
||||||
|
"""Probability that the true Sharpe ratio exceeds the observed value,
|
||||||
|
adjusted for multiple testing (data snooping).
|
||||||
|
|
||||||
|
DSR = Φ⁻¹[(1 - p)^(1/N)] formulated as a probability.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sharpe: observed annualized Sharpe ratio
|
||||||
|
n_trials: number of independent strategy variations tested
|
||||||
|
n_periods: number of return observations
|
||||||
|
|
||||||
|
Returns: probability (0-1) that true Sharpe > 0 after deflation.
|
||||||
|
"""
|
||||||
|
if n_trials <= 0 or n_periods <= 0 or math.isnan(sharpe):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# Convert Sharpe to standard normal probability
|
||||||
|
from scipy.stats import norm
|
||||||
|
p_value = 1.0 - norm.cdf(sharpe)
|
||||||
|
|
||||||
|
# Bonferroni-type adjustment for multiple testing
|
||||||
|
adjusted_p = min(1.0, p_value * n_trials)
|
||||||
|
|
||||||
|
# DSR = 1 - adjusted_p (probability true Sharpe > 0)
|
||||||
|
dsr = max(0.0, 1.0 - adjusted_p)
|
||||||
|
return round(dsr, 6)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Probabilistic Sharpe Ratio ──────────────────────────────
|
||||||
|
|
||||||
|
def probabilistic_sharpe_ratio(
|
||||||
|
sharpe: float,
|
||||||
|
n_periods: int,
|
||||||
|
benchmark: float = 0.0,
|
||||||
|
skewness: float = 0.0,
|
||||||
|
kurtosis: float = 3.0,
|
||||||
|
) -> float:
|
||||||
|
"""Probability that the true Sharpe ratio exceeds the benchmark.
|
||||||
|
|
||||||
|
PSR = Φ((ŜR - SR*) * √T / √(1 - γ₃ * ŜR + (γ₄ - 1)/4 * ŜR²))
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sharpe: observed (annualized) Sharpe ratio
|
||||||
|
n_periods: number of return observations
|
||||||
|
benchmark: target Sharpe ratio (default 0)
|
||||||
|
skewness: sample skewness of returns
|
||||||
|
kurtosis: sample kurtosis of returns (normal = 3)
|
||||||
|
|
||||||
|
Returns: probability (0-1) that true SR > benchmark
|
||||||
|
"""
|
||||||
|
if n_periods <= 0 or math.isnan(sharpe):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
from scipy.stats import norm
|
||||||
|
|
||||||
|
numerator = (sharpe - benchmark) * math.sqrt(n_periods)
|
||||||
|
denominator = math.sqrt(
|
||||||
|
1.0 - skewness * sharpe + (kurtosis - 1.0) / 4.0 * sharpe * sharpe
|
||||||
|
)
|
||||||
|
|
||||||
|
if denominator <= 0:
|
||||||
|
return 0.5
|
||||||
|
|
||||||
|
z_score = numerator / denominator
|
||||||
|
psr = float(norm.cdf(z_score))
|
||||||
|
return round(psr, 6)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Sharpe Haircut ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def sharpe_haircut(
|
||||||
|
sharpe: float,
|
||||||
|
n_trials: int = 1,
|
||||||
|
n_periods: int = 100,
|
||||||
|
) -> float:
|
||||||
|
"""Expected out-of-sample Sharpe after adjusting for data snooping.
|
||||||
|
|
||||||
|
E[OOS Sharpe] ≈ IS Sharpe - E[max_i Z_i] / √T
|
||||||
|
|
||||||
|
Where E[max_i Z_i] is the expected maximum of N independent
|
||||||
|
standard normals (the selection bias term).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sharpe: observed in-sample Sharpe ratio
|
||||||
|
n_trials: number of independent trials
|
||||||
|
n_periods: number of observations
|
||||||
|
|
||||||
|
Returns: expected OOS Sharpe ratio (the "haircut" value)
|
||||||
|
"""
|
||||||
|
if n_periods <= 1:
|
||||||
|
n_periods = 1
|
||||||
|
|
||||||
|
n_trials = max(1, n_trials)
|
||||||
|
n_periods = max(2, n_periods)
|
||||||
|
|
||||||
|
if n_trials <= 1:
|
||||||
|
return round(sharpe, 6)
|
||||||
|
|
||||||
|
euler_gamma = 0.5772156649
|
||||||
|
|
||||||
|
# Expected maximum of N independent standard normals
|
||||||
|
log_n = math.log(n_trials)
|
||||||
|
if log_n <= 0:
|
||||||
|
expected_max = 0.0
|
||||||
|
else:
|
||||||
|
expected_max = math.sqrt(2.0 * log_n)
|
||||||
|
log_log_n = math.log(log_n)
|
||||||
|
expected_max -= (log_log_n + math.log(4 * math.pi)) / (2 * math.sqrt(2 * log_n))
|
||||||
|
expected_max += euler_gamma / math.sqrt(2 * log_n)
|
||||||
|
|
||||||
|
expected_max = max(expected_max, 0.0)
|
||||||
|
|
||||||
|
# Deflation: observed - selection bias
|
||||||
|
haircut = sharpe - expected_max / math.sqrt(n_periods)
|
||||||
|
|
||||||
|
return round(haircut, 6)
|
||||||
|
|
||||||
|
|
||||||
|
# ── QuantVerdict ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
class QuantVerdict:
|
||||||
|
"""Combined validation report: DSR + PSR + Haircut + walk-forward.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
verdict = QuantVerdict(observed_sharpe=2.0, wf_consistency=0.7, ...)
|
||||||
|
report = verdict.evaluate()
|
||||||
|
print(report['verdict'], report['recommendation'])
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
observed_sharpe: float,
|
||||||
|
wf_consistency: float, # fraction of walk-forward windows with positive OOS Sharpe
|
||||||
|
n_trials: int = 1, # total strategies/intervals tested
|
||||||
|
n_periods: int = 100, # return observations
|
||||||
|
positive_regimes: int = 0, # number of regimes with positive Sharpe
|
||||||
|
benchmark_sharpe: float = 0.0,
|
||||||
|
skewness: float = 0.0,
|
||||||
|
kurtosis: float = 3.0,
|
||||||
|
):
|
||||||
|
self.observed_sharpe = observed_sharpe
|
||||||
|
self.wf_consistency = wf_consistency
|
||||||
|
self.n_trials = n_trials
|
||||||
|
self.n_periods = n_periods
|
||||||
|
self.positive_regimes = positive_regimes
|
||||||
|
self.benchmark_sharpe = benchmark_sharpe
|
||||||
|
self.skewness = skewness
|
||||||
|
self.kurtosis = kurtosis
|
||||||
|
|
||||||
|
def evaluate(self) -> dict:
|
||||||
|
dsr = deflated_sharpe_ratio(self.observed_sharpe, self.n_trials, self.n_periods)
|
||||||
|
psr = probabilistic_sharpe_ratio(
|
||||||
|
self.observed_sharpe, self.n_periods,
|
||||||
|
self.benchmark_sharpe, self.skewness, self.kurtosis,
|
||||||
|
)
|
||||||
|
hc = sharpe_haircut(self.observed_sharpe, self.n_trials, self.n_periods)
|
||||||
|
|
||||||
|
# Decision logic
|
||||||
|
score = 0
|
||||||
|
if dsr > 0.80: score += 1
|
||||||
|
if psr > 0.70: score += 1
|
||||||
|
if self.wf_consistency > 0.50: score += 1
|
||||||
|
if self.positive_regimes >= 2: score += 1
|
||||||
|
if hc > 0.5: score += 1
|
||||||
|
|
||||||
|
if score >= 4:
|
||||||
|
verdict = "DEPLOY"
|
||||||
|
rec = "Strategy passes all significance tests. Deploy with 1/10th size + daily PnL stop."
|
||||||
|
elif score >= 2:
|
||||||
|
verdict = "SIMULATE"
|
||||||
|
rec = "Marginal significance. Run through Phase 3 queue simulator before live."
|
||||||
|
else:
|
||||||
|
verdict = "DISCARD"
|
||||||
|
rec = "Fails significance tests. Strategy is indistinguishable from noise."
|
||||||
|
|
||||||
|
return {
|
||||||
|
"verdict": verdict,
|
||||||
|
"deflated_sharpe": dsr,
|
||||||
|
"psr": psr,
|
||||||
|
"haircut_sharpe": hc,
|
||||||
|
"wf_consistency": round(self.wf_consistency, 3),
|
||||||
|
"observed_sharpe": round(self.observed_sharpe, 3),
|
||||||
|
"n_trials": self.n_trials,
|
||||||
|
"n_periods": self.n_periods,
|
||||||
|
"positive_regimes": self.positive_regimes,
|
||||||
|
"skewness": round(self.skewness, 4),
|
||||||
|
"kurtosis": round(self.kurtosis, 4),
|
||||||
|
"score": f"{score}/5",
|
||||||
|
"recommendation": rec,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_strategy(
|
||||||
|
sharpe: float,
|
||||||
|
n_trades: int,
|
||||||
|
n_trials: int = 1,
|
||||||
|
wf_consistency: float = 0.0,
|
||||||
|
positive_regimes: int = 0,
|
||||||
|
skewness: float = 0.0,
|
||||||
|
kurtosis: float = 3.0,
|
||||||
|
) -> dict:
|
||||||
|
"""Quick one-shot validation of a strategy."""
|
||||||
|
v = QuantVerdict(
|
||||||
|
observed_sharpe=sharpe,
|
||||||
|
n_periods=n_trades,
|
||||||
|
n_trials=n_trials,
|
||||||
|
wf_consistency=wf_consistency,
|
||||||
|
positive_regimes=positive_regimes,
|
||||||
|
skewness=skewness,
|
||||||
|
kurtosis=kurtosis,
|
||||||
|
)
|
||||||
|
return v.evaluate()
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
"""
|
||||||
|
Walk-forward validation framework.
|
||||||
|
|
||||||
|
Splits market data into sequential IS/OOS windows, optimizes strategy
|
||||||
|
parameters on in-sample data, and tests on out-of-sample data. This is
|
||||||
|
the minimum bar for any strategy before live deployment.
|
||||||
|
|
||||||
|
Computes:
|
||||||
|
- OOS Sharpe per window
|
||||||
|
- Walk-forward consistency (% positive OOS windows)
|
||||||
|
- Performance decay (IS → OOS degradation)
|
||||||
|
- Concatenated OOS equity curve
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from quant.significance import QuantVerdict
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WFWindow:
|
||||||
|
"""Single walk-forward window result."""
|
||||||
|
window_idx: int
|
||||||
|
is_start: str
|
||||||
|
is_end: str
|
||||||
|
oos_start: str
|
||||||
|
oos_end: str
|
||||||
|
is_sharpe: float
|
||||||
|
oos_sharpe: float
|
||||||
|
is_return_pct: float
|
||||||
|
oos_return_pct: float
|
||||||
|
oos_trades: int
|
||||||
|
best_params: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WFReport:
|
||||||
|
"""Complete walk-forward analysis report."""
|
||||||
|
strategy: str
|
||||||
|
interval: str
|
||||||
|
n_windows: int
|
||||||
|
windows: list[WFWindow] = field(default_factory=list)
|
||||||
|
oos_equity_curve: list[dict] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def oos_sharpe(self) -> float:
|
||||||
|
if not self.oos_equity_curve:
|
||||||
|
return 0.0
|
||||||
|
vals = [p["v"] for p in self.oos_equity_curve if p.get("v")]
|
||||||
|
if len(vals) < 2:
|
||||||
|
return 0.0
|
||||||
|
rets = [(vals[i] - vals[i - 1]) / vals[i - 1] for i in range(1, len(vals)) if vals[i - 1] > 0]
|
||||||
|
if not rets:
|
||||||
|
return 0.0
|
||||||
|
mean = sum(rets) / len(rets)
|
||||||
|
std = (sum((r - mean) ** 2 for r in rets) / max(len(rets) - 1, 1)) ** 0.5
|
||||||
|
return mean / std * np.sqrt(365 * 24) if std > 0 else 0.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def consistency(self) -> float:
|
||||||
|
"""Fraction of windows with positive OOS Sharpe."""
|
||||||
|
if not self.windows:
|
||||||
|
return 0.0
|
||||||
|
positive = sum(1 for w in self.windows if w.oos_sharpe > 0)
|
||||||
|
return positive / len(self.windows)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def avg_oos_sharpe(self) -> float:
|
||||||
|
if not self.windows:
|
||||||
|
return 0.0
|
||||||
|
return sum(w.oos_sharpe for w in self.windows) / len(self.windows)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def performance_decay(self) -> float:
|
||||||
|
"""IS Sharpe → OOS decay ratio. <1 = decay, >1 = improvement (rare)."""
|
||||||
|
avg_is = sum(w.is_sharpe for w in self.windows) / max(len(self.windows), 1)
|
||||||
|
avg_oos = self.avg_oos_sharpe
|
||||||
|
return avg_oos / avg_is if avg_is != 0 else 0.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_oos_trades(self) -> int:
|
||||||
|
return sum(w.oos_trades for w in self.windows)
|
||||||
|
|
||||||
|
def significance_report(self, n_trials: int = 639) -> dict:
|
||||||
|
return QuantVerdict(
|
||||||
|
observed_sharpe=self.oos_sharpe,
|
||||||
|
wf_consistency=self.consistency,
|
||||||
|
n_trials=n_trials,
|
||||||
|
n_periods=max(self.total_oos_trades, 1),
|
||||||
|
positive_regimes=0,
|
||||||
|
).evaluate()
|
||||||
|
|
||||||
|
def summary(self) -> dict:
|
||||||
|
return {
|
||||||
|
"strategy": self.strategy,
|
||||||
|
"interval": self.interval,
|
||||||
|
"n_windows": self.n_windows,
|
||||||
|
"consistency": round(self.consistency, 3),
|
||||||
|
"oos_sharpe": round(self.oos_sharpe, 3),
|
||||||
|
"avg_oos_sharpe": round(self.avg_oos_sharpe, 3),
|
||||||
|
"performance_decay": round(self.performance_decay, 3),
|
||||||
|
"total_oos_trades": self.total_oos_trades,
|
||||||
|
"verdict": self.significance_report()["verdict"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class WalkForwardRunner:
|
||||||
|
"""Run walk-forward validation using VBT runner on historical data."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
n_windows: int = 5,
|
||||||
|
bar_limits: list[int] | None = None,
|
||||||
|
fee_tier: int = 0,
|
||||||
|
staking_tier: str = "none",
|
||||||
|
):
|
||||||
|
self._n_windows = n_windows
|
||||||
|
self._bar_limits = bar_limits or [100, 200, 500, 1000, 2000]
|
||||||
|
self._fee_tier = fee_tier
|
||||||
|
self._staking_tier = staking_tier
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
strategy: str,
|
||||||
|
interval: str = "1h",
|
||||||
|
coin: str = "BTC",
|
||||||
|
testnet: bool = False,
|
||||||
|
) -> WFReport:
|
||||||
|
"""Execute walk-forward validation on real Hyperliquid data.
|
||||||
|
|
||||||
|
Uses HyperliquidDataProvider to fetch candle data, then splits
|
||||||
|
into sequential IS/OOS windows. For each window:
|
||||||
|
1. Optimize bar limit on IS data (pick best by Sharpe)
|
||||||
|
2. Test the optimal bar limit on OOS data
|
||||||
|
3. Record IS/OOS Sharpe, returns, trades
|
||||||
|
"""
|
||||||
|
from framework.data import HyperliquidDataProvider
|
||||||
|
from backtests.vbt_runner import VBTBacktestRunner
|
||||||
|
|
||||||
|
provider = HyperliquidDataProvider(testnet=testnet)
|
||||||
|
|
||||||
|
# Fetch maximum data needed
|
||||||
|
max_bars = max(self._bar_limits) * (self._n_windows + 1)
|
||||||
|
df = provider.fetch_candles(coin, interval=interval, limit=max_bars)
|
||||||
|
|
||||||
|
if df.empty or len(df) < 100:
|
||||||
|
return WFReport(strategy=strategy, interval=interval, n_windows=self._n_windows)
|
||||||
|
|
||||||
|
total_bars = len(df)
|
||||||
|
window_size = total_bars // (self._n_windows + 1)
|
||||||
|
if window_size < 50:
|
||||||
|
return WFReport(strategy=strategy, interval=interval, n_windows=self._n_windows)
|
||||||
|
|
||||||
|
report = WFReport(strategy=strategy, interval=interval, n_windows=self._n_windows)
|
||||||
|
cumulative_oos_equity = 10000.0
|
||||||
|
report.oos_equity_curve.append({"t": 0, "v": cumulative_oos_equity})
|
||||||
|
|
||||||
|
for w in range(self._n_windows):
|
||||||
|
is_start_idx = w * window_size
|
||||||
|
is_end_idx = is_start_idx + window_size
|
||||||
|
oos_start_idx = is_end_idx
|
||||||
|
oos_end_idx = min(oos_start_idx + window_size, total_bars)
|
||||||
|
|
||||||
|
is_start_ts = str(df.index[is_start_idx])[:10]
|
||||||
|
is_end_ts = str(df.index[min(is_end_idx - 1, total_bars - 1)])[:10]
|
||||||
|
oos_start_ts = str(df.index[min(oos_start_idx, total_bars - 1)])[:10]
|
||||||
|
oos_end_ts = str(df.index[min(oos_end_idx - 1, total_bars - 1)])[:10]
|
||||||
|
|
||||||
|
# Convert dates to ms for HL API
|
||||||
|
is_start_ms = int(df.index[is_start_idx].timestamp() * 1000)
|
||||||
|
is_end_ms = int(df.index[min(is_end_idx - 1, total_bars - 1)].timestamp() * 1000)
|
||||||
|
oos_start_ms = int(df.index[min(oos_start_idx, total_bars - 1)].timestamp() * 1000)
|
||||||
|
oos_end_ms = int(df.index[min(oos_end_idx - 1, total_bars - 1)].timestamp() * 1000)
|
||||||
|
|
||||||
|
# IN-SAMPLE: optimize bar limit
|
||||||
|
best_limit = self._bar_limits[0]
|
||||||
|
best_is_sharpe = -999.0
|
||||||
|
best_is_return = 0.0
|
||||||
|
|
||||||
|
for limit in self._bar_limits:
|
||||||
|
is_bars = min(limit, window_size)
|
||||||
|
try:
|
||||||
|
runner = VBTBacktestRunner(
|
||||||
|
vip_tier=self._fee_tier, staking_tier=self._staking_tier
|
||||||
|
)
|
||||||
|
result = runner.run_strategy(
|
||||||
|
strategy=strategy, interval=interval, testnet=testnet,
|
||||||
|
limit=is_bars, start_ms=is_start_ms, end_ms=is_end_ms,
|
||||||
|
)
|
||||||
|
if result and result.get("sharpe", -999) > best_is_sharpe:
|
||||||
|
best_is_sharpe = result.get("sharpe", -999)
|
||||||
|
best_is_return = result.get("total_return_pct", 0)
|
||||||
|
best_limit = limit
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# OUT-OF-SAMPLE: test the best bar limit
|
||||||
|
if best_is_sharpe <= -998:
|
||||||
|
continue
|
||||||
|
|
||||||
|
oos_bars = min(best_limit, oos_end_idx - oos_start_idx)
|
||||||
|
try:
|
||||||
|
runner = VBTBacktestRunner(
|
||||||
|
vip_tier=self._fee_tier, staking_tier=self._staking_tier
|
||||||
|
)
|
||||||
|
oos_result = runner.run_strategy(
|
||||||
|
strategy=strategy, interval=interval, testnet=testnet,
|
||||||
|
limit=oos_bars, start_ms=oos_start_ms, end_ms=oos_end_ms,
|
||||||
|
)
|
||||||
|
if oos_result:
|
||||||
|
oos_sharpe = oos_result.get("sharpe", 0)
|
||||||
|
oos_return = oos_result.get("total_return_pct", 0)
|
||||||
|
oos_trades = len(oos_result.get("trades", []))
|
||||||
|
|
||||||
|
cumulative_oos_equity += cumulative_oos_equity * oos_return / 100.0
|
||||||
|
report.oos_equity_curve.append({
|
||||||
|
"t": w + 1,
|
||||||
|
"v": round(cumulative_oos_equity, 2),
|
||||||
|
})
|
||||||
|
|
||||||
|
report.windows.append(WFWindow(
|
||||||
|
window_idx=w,
|
||||||
|
is_start=is_start_ts, is_end=is_end_ts,
|
||||||
|
oos_start=oos_start_ts, oos_end=oos_end_ts,
|
||||||
|
is_sharpe=round(best_is_sharpe, 3),
|
||||||
|
oos_sharpe=round(oos_sharpe, 3),
|
||||||
|
is_return_pct=round(best_is_return, 2),
|
||||||
|
oos_return_pct=round(oos_return, 2),
|
||||||
|
oos_trades=oos_trades,
|
||||||
|
best_params={"limit": best_limit},
|
||||||
|
))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
# ── Quick validation ────────────────────────────────────────
|
||||||
|
|
||||||
|
def quick_validate(strategy: str, interval: str = "1h", **kwargs) -> dict:
|
||||||
|
"""Run walk-forward and return significance report in one call."""
|
||||||
|
wfr = WalkForwardRunner(**kwargs)
|
||||||
|
report = wfr.run(strategy=strategy, interval=interval)
|
||||||
|
return report.summary()
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""
|
||||||
|
Tests for quant/regimes.py and quant/walkforward.py.
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
from quant.regimes import RegimeClassifier, classify_regime, conditional_performance
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegimeClassifier:
|
||||||
|
def test_initial_state(self):
|
||||||
|
rc = RegimeClassifier()
|
||||||
|
assert rc.current_regime == "unknown"
|
||||||
|
|
||||||
|
def test_classify_trending(self):
|
||||||
|
"""Rising prices with low volatility → trending_up."""
|
||||||
|
regime = classify_regime(
|
||||||
|
returns_20=0.15, # +15% over 20 bars
|
||||||
|
vol_20=0.02, # 2% vol
|
||||||
|
vol_ratio=1.0, # normal volume
|
||||||
|
)
|
||||||
|
assert regime == "trending_up"
|
||||||
|
|
||||||
|
def test_classify_ranging(self):
|
||||||
|
"""Flat prices with low volatility → ranging."""
|
||||||
|
regime = classify_regime(
|
||||||
|
returns_20=0.005, # near flat
|
||||||
|
vol_20=0.01,
|
||||||
|
vol_ratio=1.0,
|
||||||
|
)
|
||||||
|
assert regime == "ranging"
|
||||||
|
|
||||||
|
def test_classify_volatile(self):
|
||||||
|
"""High volatility regardless of direction → volatile."""
|
||||||
|
regime = classify_regime(
|
||||||
|
returns_20=0.02,
|
||||||
|
vol_20=0.08, # high vol
|
||||||
|
vol_ratio=2.5, # volume spike
|
||||||
|
)
|
||||||
|
assert regime == "volatile"
|
||||||
|
|
||||||
|
def test_classify_trending_down(self):
|
||||||
|
"""Falling prices → trending_down."""
|
||||||
|
regime = classify_regime(
|
||||||
|
returns_20=-0.12, # -12%
|
||||||
|
vol_20=0.03,
|
||||||
|
vol_ratio=1.0,
|
||||||
|
)
|
||||||
|
assert regime == "trending_down"
|
||||||
|
|
||||||
|
def test_feed_updates_regime(self):
|
||||||
|
rc = RegimeClassifier(window=20)
|
||||||
|
# Feed 21 downtrend bars (need window+1 for first classification)
|
||||||
|
for i in range(21):
|
||||||
|
px = 100 - i * 2
|
||||||
|
rc.feed(px, close=px, open_px=px - 0.5, vol=10)
|
||||||
|
assert rc.current_regime == "trending_down"
|
||||||
|
|
||||||
|
# Feed 21 uptrend bars
|
||||||
|
for i in range(21):
|
||||||
|
px = 58 + i * 3
|
||||||
|
rc.feed(px, close=px, open_px=px + 0.5, vol=10)
|
||||||
|
assert rc.current_regime == "trending_up"
|
||||||
|
|
||||||
|
def test_regime_counts(self):
|
||||||
|
rc = RegimeClassifier(window=20)
|
||||||
|
for i in range(100):
|
||||||
|
if i < 30:
|
||||||
|
px = 100 + i
|
||||||
|
elif i < 60:
|
||||||
|
px = 130 - (i - 30) * 0.5
|
||||||
|
else:
|
||||||
|
px = 115 + (i - 60) * 2
|
||||||
|
rc.feed(px, close=px, open_px=px, vol=10)
|
||||||
|
counts = rc.regime_counts()
|
||||||
|
assert sum(counts.values()) > 0
|
||||||
|
|
||||||
|
def test_conditional_performance(self):
|
||||||
|
"""Verify per-regime statistics computation."""
|
||||||
|
trades = [
|
||||||
|
{"time": "2026-01-01T00:00:00", "pnl_net": 100, "pnl_gross": 120},
|
||||||
|
{"time": "2026-01-02T00:00:00", "pnl_net": -50, "pnl_gross": -40},
|
||||||
|
{"time": "2026-01-03T00:00:00", "pnl_net": 200, "pnl_gross": 220},
|
||||||
|
]
|
||||||
|
# 3 trades all in trending_up regime
|
||||||
|
result = conditional_performance(trades, {"trending_up": 3, "volatile": 0})
|
||||||
|
assert result["regimes"]["trending_up"]["count"] == 3
|
||||||
|
assert result["regimes"]["trending_up"]["avg_pnl"] > 0
|
||||||
|
assert result["best_regime"] == "trending_up"
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""
|
||||||
|
Tests for quant/significance.py — deflated Sharpe, probabilistic Sharpe, haircut.
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
from quant.significance import (
|
||||||
|
deflated_sharpe_ratio,
|
||||||
|
probabilistic_sharpe_ratio,
|
||||||
|
sharpe_haircut,
|
||||||
|
QuantVerdict,
|
||||||
|
validate_strategy,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeflatedSharpeRatio:
|
||||||
|
def test_no_trials_same_as_observed(self):
|
||||||
|
"""With 1 trial, DSR should equal the standard N(0,1) probability."""
|
||||||
|
dsr = deflated_sharpe_ratio(sharpe=2.0, n_trials=1, n_periods=100)
|
||||||
|
assert 0.95 < dsr < 1.0
|
||||||
|
|
||||||
|
def test_many_trials_deflates(self):
|
||||||
|
"""With 1000 trials, a Sharpe of 2.0 should be heavily deflated."""
|
||||||
|
dsr = deflated_sharpe_ratio(sharpe=2.0, n_trials=1000, n_periods=100)
|
||||||
|
assert dsr < 0.5
|
||||||
|
|
||||||
|
def test_negative_sharpe_zero_dsr(self):
|
||||||
|
"""Negative Sharpe should produce near-zero DSR."""
|
||||||
|
dsr = deflated_sharpe_ratio(sharpe=-1.0, n_trials=100, n_periods=100)
|
||||||
|
assert dsr < 0.05
|
||||||
|
|
||||||
|
def test_extreme_sharpe_approaches_one(self):
|
||||||
|
"""Extremely high Sharpe should survive deflation."""
|
||||||
|
dsr = deflated_sharpe_ratio(sharpe=10.0, n_trials=100, n_periods=100)
|
||||||
|
assert dsr > 0.9
|
||||||
|
|
||||||
|
|
||||||
|
class TestProbabilisticSharpeRatio:
|
||||||
|
def test_large_sample_high_sharpe(self):
|
||||||
|
"""With many periods, high Sharpe has high PSR."""
|
||||||
|
psr = probabilistic_sharpe_ratio(sharpe=1.5, n_periods=252, benchmark=0)
|
||||||
|
assert psr > 0.90
|
||||||
|
|
||||||
|
def test_few_periods_uncertain(self):
|
||||||
|
"""With few periods, Sharpe must be modest to show uncertainty."""
|
||||||
|
psr = probabilistic_sharpe_ratio(sharpe=0.3, n_periods=5, benchmark=0)
|
||||||
|
assert psr < 0.80 # Low Sharpe with few periods = uncertain
|
||||||
|
|
||||||
|
def test_zero_sharpe_fifty_percent(self):
|
||||||
|
"""Zero Sharpe should give ~50% PSR (even odds)."""
|
||||||
|
psr = probabilistic_sharpe_ratio(sharpe=0.0, n_periods=100, benchmark=0)
|
||||||
|
assert 0.40 < psr < 0.60
|
||||||
|
|
||||||
|
def test_zero_sharpe_fifty_percent(self):
|
||||||
|
"""Zero Sharpe should give ~50% PSR (even odds)."""
|
||||||
|
psr = probabilistic_sharpe_ratio(sharpe=0.0, n_periods=100, benchmark=0)
|
||||||
|
assert 0.40 < psr < 0.60
|
||||||
|
|
||||||
|
def test_benchmark_above_observed_low_psr(self):
|
||||||
|
"""If benchmark > Sharpe, PSR should be low."""
|
||||||
|
psr = probabilistic_sharpe_ratio(sharpe=0.5, n_periods=100, benchmark=1.0)
|
||||||
|
assert psr < 0.30
|
||||||
|
|
||||||
|
|
||||||
|
class TestSharpeHaircut:
|
||||||
|
def test_single_trial_no_haircut(self):
|
||||||
|
"""With 1 trial, haircut is minimal."""
|
||||||
|
hc = sharpe_haircut(sharpe=1.0, n_trials=1, n_periods=100)
|
||||||
|
assert hc > 0.5
|
||||||
|
|
||||||
|
def test_many_trials_heavy_haircut(self):
|
||||||
|
"""With many trials, haircut should be significant below observed."""
|
||||||
|
hc = sharpe_haircut(sharpe=0.5, n_trials=639, n_periods=100)
|
||||||
|
assert hc < 0.5 # Deflated below starting value
|
||||||
|
|
||||||
|
def test_haircut_range(self):
|
||||||
|
"""Haircut should be between -1 and 6 typically."""
|
||||||
|
for s in [0.5, 1.0, 2.0, 5.0]:
|
||||||
|
hc = sharpe_haircut(sharpe=s, n_trials=100, n_periods=100)
|
||||||
|
assert -1.0 < hc < 6.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestQuantVerdict:
|
||||||
|
def test_strong_strategy_deploy(self):
|
||||||
|
verdict = QuantVerdict(
|
||||||
|
observed_sharpe=2.0,
|
||||||
|
wf_consistency=0.80,
|
||||||
|
n_trials=10,
|
||||||
|
n_periods=252,
|
||||||
|
positive_regimes=3,
|
||||||
|
)
|
||||||
|
result = verdict.evaluate()
|
||||||
|
assert result["verdict"] in ("DEPLOY", "SIMULATE")
|
||||||
|
|
||||||
|
def test_weak_strategy_discard(self):
|
||||||
|
verdict = QuantVerdict(
|
||||||
|
observed_sharpe=0.3,
|
||||||
|
wf_consistency=0.20,
|
||||||
|
n_trials=639,
|
||||||
|
n_periods=10,
|
||||||
|
positive_regimes=0,
|
||||||
|
)
|
||||||
|
result = verdict.evaluate()
|
||||||
|
assert result["verdict"] == "DISCARD"
|
||||||
|
|
||||||
|
def test_grid_mm_simulated(self):
|
||||||
|
"""Simulate grid_mm 1h: S=8.56, 4 trades, 639 trials — should SIMULATE or DISCARD."""
|
||||||
|
verdict = QuantVerdict(
|
||||||
|
observed_sharpe=8.56,
|
||||||
|
wf_consistency=0.25,
|
||||||
|
n_trials=639,
|
||||||
|
n_periods=4,
|
||||||
|
positive_regimes=1,
|
||||||
|
)
|
||||||
|
result = verdict.evaluate()
|
||||||
|
assert result["verdict"] in ("DISCARD", "SIMULATE")
|
||||||
|
assert result["psr"] > 0 # PSR should be computed
|
||||||
|
assert result["haircut_sharpe"] < result["observed_sharpe"] # Haircut reduces Sharpe
|
||||||
|
|
||||||
|
def test_summary_includes_all_fields(self):
|
||||||
|
v = QuantVerdict(
|
||||||
|
observed_sharpe=1.5,
|
||||||
|
wf_consistency=0.70,
|
||||||
|
n_trials=50,
|
||||||
|
n_periods=100,
|
||||||
|
positive_regimes=2,
|
||||||
|
)
|
||||||
|
r = v.evaluate()
|
||||||
|
for key in ("verdict", "deflated_sharpe", "psr", "haircut_sharpe",
|
||||||
|
"wf_consistency", "skewness", "recommendation"):
|
||||||
|
assert key in r
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""
|
||||||
|
Tests for quant/walkforward.py.
|
||||||
|
"""
|
||||||
|
from quant.walkforward import WalkForwardRunner, WFReport, WFWindow
|
||||||
|
|
||||||
|
|
||||||
|
class TestWFReport:
|
||||||
|
def test_empty_report(self):
|
||||||
|
report = WFReport(strategy="test", interval="1h", n_windows=5)
|
||||||
|
assert report.consistency == 0.0
|
||||||
|
assert report.oos_sharpe == 0.0
|
||||||
|
assert report.performance_decay == 0.0
|
||||||
|
assert report.total_oos_trades == 0
|
||||||
|
|
||||||
|
def test_single_window_positive(self):
|
||||||
|
report = WFReport(strategy="test", interval="1h", n_windows=5)
|
||||||
|
report.windows.append(WFWindow(
|
||||||
|
window_idx=0, is_start="2026-01-01", is_end="2026-02-01",
|
||||||
|
oos_start="2026-02-01", oos_end="2026-03-01",
|
||||||
|
is_sharpe=2.0, oos_sharpe=1.5, is_return_pct=5.0,
|
||||||
|
oos_return_pct=3.0, oos_trades=10,
|
||||||
|
))
|
||||||
|
assert report.consistency == 1.0
|
||||||
|
assert report.avg_oos_sharpe == 1.5
|
||||||
|
assert report.performance_decay == 1.5 / 2.0
|
||||||
|
assert report.total_oos_trades == 10
|
||||||
|
|
||||||
|
def test_mixed_windows(self):
|
||||||
|
report = WFReport(strategy="test", interval="1h", n_windows=3)
|
||||||
|
report.windows = [
|
||||||
|
WFWindow(0, "A", "B", "B", "C", 2.0, 1.0, 5.0, 2.0, 5),
|
||||||
|
WFWindow(1, "B", "C", "C", "D", 1.0, -0.5, 2.0, -1.0, 8),
|
||||||
|
WFWindow(2, "C", "D", "D", "E", 1.5, 0.3, 3.0, 0.5, 6),
|
||||||
|
]
|
||||||
|
assert report.consistency == 2 / 3 # 2 of 3 windows positive OOS
|
||||||
|
assert report.avg_oos_sharpe == (1.0 - 0.5 + 0.3) / 3
|
||||||
|
|
||||||
|
def test_significance_discard_weak(self):
|
||||||
|
report = WFReport(strategy="test", interval="1h", n_windows=5)
|
||||||
|
report.windows.append(WFWindow(
|
||||||
|
0, "A", "B", "B", "C", 8.0, -2.0, 2.5, -5.0, 4,
|
||||||
|
))
|
||||||
|
report.oos_equity_curve = [{"t": 0, "v": 10000}, {"t": 1, "v": 9500}]
|
||||||
|
sig = report.significance_report(n_trials=639)
|
||||||
|
assert sig["verdict"] == "DISCARD"
|
||||||
|
|
||||||
|
def test_summary(self):
|
||||||
|
report = WFReport(strategy="momentum", interval="4h", n_windows=3)
|
||||||
|
s = report.summary()
|
||||||
|
assert s["strategy"] == "momentum"
|
||||||
|
assert s["interval"] == "4h"
|
||||||
Reference in New Issue
Block a user