Skip to content

Commit c3c57cf

Browse files
authored
feat: gate optimization by dof multiplier (#116)
## Summary Add a data‑sufficiency gate for parameter optimization that scales with degrees of freedom, to avoid overfitting on small samples. ## Changes Added param_dof_multiplier config (default 100) and updated example config. Optimization is skipped when bars < max(2000, multiplier * n_params) with a log message. Breaking changes: None (defaults applied for older configs). ## How to Test `docker-compose run --rm app bash -lc "poetry run pytest -q tests/test_backtest_runner.py -k min_bars_and_dof_guard_behavior"` ### Optional Additional Tests (not implemented) - **DoF scaling with `n_params > 1`** Verify correct Degrees-of-Freedom scaling when optimizing across multiple grid dimensions. - **No search space (fixed parameters only)** Ensure the guard condition does **not** trigger when no parameter search space is defined. - **Boundary case: `len(df) == param_min_bars`** Confirm that no skip occurs when the dataset length is exactly equal to `param_min_bars`. - **`param_search="optuna"` behavior** Validate that skip / no-skip logic behaves identically to grid search. - **`stats["optimization"]` assignment** Ensure `stats["optimization"]` is set **only** in the skip case. ## Checklist (KISS) - [x] Pre-commit passes locally (`pre-commit run --all-files`) - [x] Tests added/updated where it makes sense (80% cov gate) - [x] Docs/README updated if needed - [x] No secrets committed; `.env` values are excluded - [x] Backward compatibility considered (configs, CLI flags) ## Related Issues/Links - Closes # - References # <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes the optimization execution path and result metadata, which can alter which parameter sets are evaluated and persisted; mitigated by defaults and targeted tests covering skip/boundary behavior. > > **Overview** > Adds a *data-sufficiency guardrail* that **skips parameter optimization** when there aren’t enough bars for the size of the search space. > > `BacktestRunner.run_all` now computes a minimum required history as `max(param_min_bars, param_dof_multiplier * n_params)` and, when unmet, logs a structured `optimization_skipped` event, runs only the fixed/default parameters (no grid/Optuna loop), and annotates result `stats` with an `optimization` block describing the skip. > > Configuration is extended with `param_dof_multiplier` and `param_min_bars` (defaults applied for older configs), README documents the new reliability behavior, and tests add coverage for skip/no-skip and boundary cases by asserting reduced eval counts and the presence/absence of `stats["optimization"]`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit aabcab7. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
2 parents c63d986 + aabcab7 commit c3c57cf

File tree

4 files changed

+135
-2
lines changed

4 files changed

+135
-2
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ This repository provides a Docker-based, cache-aware backtesting system to syste
1717
- Dockerized runtime for reproducibility
1818
- Results cache (SQLite) to resume and skip already-computed grids
1919
- Structured logging and timing metrics per data fetch and grid search
20+
- Reliability guardrails: optimization auto-skips when bar history is insufficient (min-bars/DoF thresholds)
2021

2122
## Requirements
2223

src/backtest/runner.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from ..data.yfinance_source import YFinanceSource
2626
from ..strategies.base import BaseStrategy
2727
from ..strategies.registry import discover_external_strategies
28-
from ..utils.telemetry import get_logger, time_block
28+
from ..utils.telemetry import get_logger, log_json, time_block
2929
from .metrics import (
3030
omega_ratio,
3131
pain_index,
@@ -514,6 +514,29 @@ def run_all(self, only_cached: bool = False) -> list[BestResult]:
514514
else:
515515
search_space[name] = options
516516

517+
n_params = len(search_space)
518+
dof_multiplier = self.cfg.param_dof_multiplier
519+
min_bars_floor = self.cfg.param_min_bars
520+
min_bars_for_optimization = max(min_bars_floor, dof_multiplier * n_params)
521+
optimization_skip_reason = None
522+
if search_space and len(df) < min_bars_for_optimization:
523+
optimization_skip_reason = "insufficient_bars_for_optimization"
524+
log_json(
525+
self.logger,
526+
"optimization_skipped",
527+
reason=optimization_skip_reason,
528+
collection=col.name,
529+
symbol=symbol,
530+
timeframe=timeframe,
531+
bars=len(df),
532+
min_bars=min_bars_for_optimization,
533+
n_params=n_params,
534+
dof_multiplier=dof_multiplier,
535+
min_bars_floor=min_bars_floor,
536+
strategy=strat.name,
537+
search_method=search_method,
538+
)
539+
517540
best_val = -np.inf
518541
best_params: dict[str, Any] | None = None
519542
best_stats: dict[str, Any] | None = None
@@ -623,6 +646,14 @@ def evaluate(
623646
if sim_result is None:
624647
return float("-inf")
625648
returns, equity_curve, stats = sim_result
649+
if optimization_skip_reason:
650+
stats = dict(stats)
651+
stats["optimization"] = {
652+
"skipped": True,
653+
"reason": optimization_skip_reason,
654+
"min_bars_required": min_bars_for_optimization,
655+
"bars_available": len(df_local),
656+
}
626657
self.metrics["param_evals"] += 1
627658
metric_val = self._evaluate_metric(
628659
self.cfg.metric, returns, equity_curve, bars_per_year_local
@@ -651,7 +682,7 @@ def evaluate(
651682

652683
space_items = list(search_space.items())
653684

654-
if search_space:
685+
if search_space and not optimization_skip_reason:
655686
if search_method == "optuna":
656687
try:
657688
import optuna

src/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ class Config:
5050
engine: str = "pybroker" # pybroker engine
5151
param_search: str = "grid" # grid | optuna
5252
param_trials: int = 25
53+
param_dof_multiplier: int = 100
54+
param_min_bars: int = 2000
5355
max_workers: int = 1
5456
asset_workers: int = 1
5557
param_workers: int = 1
@@ -127,6 +129,8 @@ def load_config(path: str | Path) -> Config:
127129
engine=str(raw.get("engine", "pybroker")).lower(),
128130
param_search=str(raw.get("param_search", raw.get("param_optimizer", "grid"))).lower(),
129131
param_trials=int(raw.get("param_trials", raw.get("opt_trials", 25))),
132+
param_dof_multiplier=int(raw.get("param_dof_multiplier", 100)),
133+
param_min_bars=int(raw.get("param_min_bars", 2000)),
130134
max_workers=int(raw.get("max_workers", raw.get("asset_workers", 1))),
131135
asset_workers=int(raw.get("asset_workers", raw.get("max_workers", 1))),
132136
param_workers=int(raw.get("param_workers", 1)),

tests/test_backtest_runner.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -597,3 +597,100 @@ def fetch(self, symbol, timeframe, only_cached=False):
597597
results = runner.run_all()
598598
assert results == []
599599
assert not runner.results_cache.saved
600+
601+
602+
def _make_ohlcv(periods: int) -> pd.DataFrame:
603+
dates = pd.date_range("2024-01-01", periods=periods, freq="D")
604+
return pd.DataFrame(
605+
{
606+
"Open": [10] * len(dates),
607+
"High": [11] * len(dates),
608+
"Low": [9] * len(dates),
609+
"Close": [10.5] * len(dates),
610+
"Volume": [100] * len(dates),
611+
},
612+
index=dates,
613+
)
614+
615+
616+
def _patch_source_with_bars(monkeypatch, bars: int) -> None:
617+
class _Source:
618+
def fetch(self, symbol, timeframe, only_cached=False):
619+
return _make_ohlcv(bars)
620+
621+
monkeypatch.setattr(BacktestRunner, "_make_source", lambda self, col: _Source())
622+
623+
624+
def _patch_pybroker_simulation(monkeypatch) -> dict[str, int]:
625+
eval_calls = {"count": 0}
626+
627+
def _fake_sim(self, *args, **kwargs):
628+
eval_calls["count"] += 1
629+
returns = pd.Series(
630+
[0.01, -0.005, 0.02],
631+
index=pd.date_range("2024-01-01", periods=3, freq="D"),
632+
)
633+
equity = (1 + returns.fillna(0.0)).cumprod()
634+
stats = {
635+
"sharpe": 1.0,
636+
"sortino": 0.8,
637+
"omega": 1.2,
638+
"tail_ratio": 1.1,
639+
"profit": 0.1,
640+
"pain_index": 0.02,
641+
"trades": 2,
642+
"max_drawdown": -0.05,
643+
"cagr": 0.12,
644+
"calmar": -2.4,
645+
"equity_curve": [],
646+
"drawdown_curve": [],
647+
"trades_log": [],
648+
}
649+
return returns, equity, stats
650+
651+
monkeypatch.setattr(BacktestRunner, "_run_pybroker_simulation", _fake_sim)
652+
return eval_calls
653+
654+
655+
@pytest.mark.parametrize(
656+
("param_dof_multiplier", "param_min_bars", "bars", "expected_eval_calls", "expect_skip"),
657+
[
658+
(100, 2000, 50, 1, True), # skip via min-bars floor
659+
(60, 1, 50, 1, True), # skip via DoF threshold
660+
(1, 1, 50, 2, False), # no guard, full grid evals
661+
(50, 1, 50, 2, False), # boundary: len(df) == required => no skip
662+
],
663+
ids=["min_bars_floor_skip", "dof_skip", "no_guard", "dof_boundary_no_skip"],
664+
)
665+
def test_run_all_min_bars_and_dof_guard_behavior(
666+
tmp_path,
667+
monkeypatch,
668+
param_dof_multiplier,
669+
param_min_bars,
670+
bars,
671+
expected_eval_calls,
672+
expect_skip,
673+
):
674+
runner = _make_runner(tmp_path, monkeypatch)
675+
runner.cfg.param_dof_multiplier = param_dof_multiplier
676+
runner.cfg.param_min_bars = param_min_bars
677+
runner.cfg.param_search = "grid"
678+
679+
_patch_source_with_bars(monkeypatch, bars)
680+
eval_calls = _patch_pybroker_simulation(monkeypatch)
681+
682+
results = runner.run_all()
683+
assert results
684+
assert eval_calls["count"] == expected_eval_calls
685+
686+
optimization = results[0].stats.get("optimization")
687+
if expect_skip:
688+
assert optimization is not None
689+
assert optimization["skipped"] is True
690+
assert optimization["reason"] == "insufficient_bars_for_optimization"
691+
# search_space has one dimension (`window`) in _make_runner, so n_params=1.
692+
expected_min_bars = max(runner.cfg.param_min_bars, runner.cfg.param_dof_multiplier * 1)
693+
assert optimization["min_bars_required"] == expected_min_bars
694+
assert optimization["bars_available"] == bars
695+
else:
696+
assert optimization is None

0 commit comments

Comments
 (0)