Разработка системы out-of-sample тестирования
Out-of-sample (OOS) тестирование — финальная проверка стратегии на данных, которые никогда не использовались ни для разработки, ни для оптимизации. Это самый близкий к реальному trading тест, показывающий насколько стратегия обобщается на неизвестных данных.
Принцип разделения данных
|-------- In-Sample (IS) --------|--- Out-of-Sample (OOS) ---|
IS используется для: OOS используется для:
- Разработки стратегии - Финальной оценки
- Отбора параметров - Проверки robustness
- Walk-forward оптимизации - Принятия решения о деплое
- Итеративных улучшений
Золотое правило: OOS данные нельзя смотреть до того, как стратегия окончательно зафиксирована. Как только вы посмотрели на OOS и что-то изменили — это данные больше не являются out-of-sample.
Типичное разбиение для крипто
import pandas as pd
def create_oos_split(data: pd.DataFrame, oos_pct: float = 0.20) -> tuple:
"""
Создаём строгое IS/OOS разбиение.
OOS — последние 20% данных, хронологически.
"""
split_idx = int(len(data) * (1 - oos_pct))
in_sample = data.iloc[:split_idx]
out_of_sample = data.iloc[split_idx:]
print(f"In-sample: {in_sample.index[0].date()} → {in_sample.index[-1].date()} ({len(in_sample)} bars)")
print(f"Out-of-sample: {out_of_sample.index[0].date()} → {out_of_sample.index[-1].date()} ({len(out_of_sample)} bars)")
return in_sample, out_of_sample
OOS Validation Framework
class OOSValidator:
def __init__(self, backtester, significance_threshold: float = 0.05):
self.backtester = backtester
self.alpha = significance_threshold
def validate(
self,
strategy_params: dict,
is_result: BacktestResult,
oos_data: pd.DataFrame,
) -> OOSValidationReport:
# Запускаем финальный тест на OOS данных
oos_result = self.backtester.run(strategy_params, oos_data)
# Статистическая значимость OOS результатов
oos_returns = oos_result.equity_curve.pct_change().dropna()
significance = self._test_significance(oos_returns)
# Сравнение IS vs OOS
is_vs_oos = self._compare_is_oos(is_result, oos_result)
# Вердикт
passed = self._evaluate_verdict(oos_result, significance, is_vs_oos)
return OOSValidationReport(
is_result=is_result,
oos_result=oos_result,
significance=significance,
is_vs_oos_comparison=is_vs_oos,
passed=passed,
recommendation=self._get_recommendation(passed, is_vs_oos),
)
def _test_significance(self, returns: pd.Series) -> dict:
"""Тест статистической значимости положительной доходности"""
from scipy import stats
# t-тест: H0: mean return == 0
t_stat, p_value = stats.ttest_1samp(returns, 0)
# Количество независимых наблюдений с учётом автокорреляции
n_effective = self._effective_sample_size(returns)
return {
't_statistic': t_stat,
'p_value': p_value,
'is_significant': p_value < self.alpha and t_stat > 0,
'n_effective': n_effective,
}
def _effective_sample_size(self, returns: pd.Series) -> float:
"""Корректируем выборку с учётом автокорреляции"""
n = len(returns)
autocorr = returns.autocorr(1)
if abs(autocorr) >= 1:
return n
return n * (1 - autocorr) / (1 + autocorr)
def _compare_is_oos(self, is_result: BacktestResult, oos_result: BacktestResult) -> dict:
is_m = is_result.metrics
oos_m = oos_result.metrics
return {
'sharpe_ratio_degradation': (is_m.sharpe_ratio - oos_m.sharpe_ratio) / max(abs(is_m.sharpe_ratio), 0.01),
'return_ratio': oos_m.annual_return_pct / max(is_m.annual_return_pct, 0.01),
'drawdown_ratio': oos_m.max_drawdown_pct / max(abs(is_m.max_drawdown_pct), 0.01),
'win_rate_change': oos_m.win_rate - is_m.win_rate,
}
def _evaluate_verdict(self, oos_result, significance, comparison) -> bool:
# Критерии прохождения OOS теста
checks = [
oos_result.metrics.sharpe_ratio > 0.5, # положительный Sharpe
significance['is_significant'], # статистически значимо
comparison['sharpe_ratio_degradation'] < 0.7, # деградация < 70%
oos_result.metrics.max_drawdown_pct > -40, # drawdown < 40%
oos_result.metrics.total_trades >= 15, # достаточно сделок
]
return all(checks)
Интерпретация результатов
def print_oos_report(report: OOSValidationReport):
print("=" * 60)
print("OOS VALIDATION REPORT")
print("=" * 60)
print(f"\n{'IS':25} {'OOS':>10}")
print("-" * 40)
print(f"{'Sharpe Ratio':25} {report.is_result.metrics.sharpe_ratio:>10.2f} {report.oos_result.metrics.sharpe_ratio:>10.2f}")
print(f"{'Annual Return %':25} {report.is_result.metrics.annual_return_pct:>10.1f} {report.oos_result.metrics.annual_return_pct:>10.1f}")
print(f"{'Max Drawdown %':25} {report.is_result.metrics.max_drawdown_pct:>10.1f} {report.oos_result.metrics.max_drawdown_pct:>10.1f}")
print(f"{'Win Rate %':25} {report.is_result.metrics.win_rate*100:>10.1f} {report.oos_result.metrics.win_rate*100:>10.1f}")
comp = report.is_vs_oos_comparison
print(f"\nSharpe degradation: {comp['sharpe_ratio_degradation']:.1%}")
print(f"OOS/IS return ratio: {comp['return_ratio']:.2f}x")
sig = report.significance
print(f"\nStatistical significance: p={sig['p_value']:.4f} (significant: {sig['is_significant']})")
print(f"Effective sample size: {sig['n_effective']:.0f}")
verdict = "PASSED" if report.passed else "FAILED"
print(f"\n{'='*20} {verdict} {'='*20}")
print(f"Recommendation: {report.recommendation}")
OOS результаты всегда хуже IS — это нормально. Важно что OOS положителен и деградация разумна (< 50–70%). Если OOS показывает значительно лучше IS — это тоже плохой знак: нужно проверить на look-ahead bias.







