From 701350fb65cf08a3188fdf8720f9199f2043165c Mon Sep 17 00:00:00 2001 From: agentson Date: Sun, 1 Mar 2026 09:44:24 +0900 Subject: [PATCH 1/2] feat: switch backtest triple barrier to calendar-minute horizon (#329) --- src/analysis/backtest_pipeline.py | 13 +++++++ tests/test_backtest_pipeline_integration.py | 38 +++++++++++++++++++-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/analysis/backtest_pipeline.py b/src/analysis/backtest_pipeline.py index 4b0701f..a41001c 100644 --- a/src/analysis/backtest_pipeline.py +++ b/src/analysis/backtest_pipeline.py @@ -8,6 +8,7 @@ from __future__ import annotations from collections.abc import Sequence from dataclasses import dataclass +from datetime import datetime from statistics import mean from typing import Literal @@ -22,6 +23,7 @@ class BacktestBar: low: float close: float session_id: str + timestamp: datetime | None = None @dataclass(frozen=True) @@ -86,16 +88,27 @@ def run_v2_backtest_pipeline( highs = [float(bar.high) for bar in bars] lows = [float(bar.low) for bar in bars] closes = [float(bar.close) for bar in bars] + timestamps = [bar.timestamp for bar in bars] normalized_entries = sorted(set(int(i) for i in entry_indices)) if normalized_entries[0] < 0 or normalized_entries[-1] >= len(bars): raise IndexError("entry index out of range") + resolved_timestamps: list[datetime] | None = None + if triple_barrier_spec.max_holding_minutes is not None: + if any(ts is None for ts in timestamps): + raise ValueError( + "BacktestBar.timestamp is required for all bars when " + "triple_barrier_spec.max_holding_minutes is set" + ) + resolved_timestamps = [ts for ts in timestamps if ts is not None] + labels_by_bar_index: dict[int, int] = {} for idx in normalized_entries: labels_by_bar_index[idx] = label_with_triple_barrier( highs=highs, lows=lows, closes=closes, + timestamps=resolved_timestamps, entry_index=idx, side=side, spec=triple_barrier_spec, diff --git a/tests/test_backtest_pipeline_integration.py b/tests/test_backtest_pipeline_integration.py index 60dca91..c0ad496 100644 --- a/tests/test_backtest_pipeline_integration.py +++ b/tests/test_backtest_pipeline_integration.py @@ -1,5 +1,7 @@ from __future__ import annotations +from datetime import UTC, datetime, timedelta + from src.analysis.backtest_cost_guard import BacktestCostModel from src.analysis.backtest_pipeline import ( BacktestBar, @@ -12,6 +14,7 @@ from src.analysis.walk_forward_split import generate_walk_forward_splits def _bars() -> list[BacktestBar]: + base_ts = datetime(2026, 2, 28, 0, 0, tzinfo=UTC) closes = [100.0, 101.0, 102.0, 101.5, 103.0, 102.5, 104.0, 103.5, 105.0, 104.5, 106.0, 105.5] bars: list[BacktestBar] = [] for i, close in enumerate(closes): @@ -21,6 +24,7 @@ def _bars() -> list[BacktestBar]: low=close - 1.0, close=close, session_id="KRX_REG" if i % 2 == 0 else "US_PRE", + timestamp=base_ts + timedelta(minutes=i), ) ) return bars @@ -43,7 +47,7 @@ def test_pipeline_happy_path_returns_fold_and_artifact_contract() -> None: triple_barrier_spec=TripleBarrierSpec( take_profit_pct=0.02, stop_loss_pct=0.01, - max_holding_bars=3, + max_holding_minutes=3, ), walk_forward=WalkForwardConfig( train_size=4, @@ -84,7 +88,7 @@ def test_pipeline_cost_guard_fail_fast() -> None: triple_barrier_spec=TripleBarrierSpec( take_profit_pct=0.02, stop_loss_pct=0.01, - max_holding_bars=3, + max_holding_minutes=3, ), walk_forward=WalkForwardConfig(train_size=2, test_size=1), cost_model=bad, @@ -119,7 +123,7 @@ def test_pipeline_deterministic_seed_free_deterministic_result() -> None: triple_barrier_spec=TripleBarrierSpec( take_profit_pct=0.02, stop_loss_pct=0.01, - max_holding_bars=3, + max_holding_minutes=3, ), walk_forward=WalkForwardConfig( train_size=4, @@ -134,3 +138,31 @@ def test_pipeline_deterministic_seed_free_deterministic_result() -> None: out1 = run_v2_backtest_pipeline(**cfg) out2 = run_v2_backtest_pipeline(**cfg) assert out1 == out2 + + +def test_pipeline_rejects_minutes_spec_when_timestamp_missing() -> None: + bars = _bars() + bars[2] = BacktestBar( + high=bars[2].high, + low=bars[2].low, + close=bars[2].close, + session_id=bars[2].session_id, + timestamp=None, + ) + try: + run_v2_backtest_pipeline( + bars=bars, + entry_indices=[0, 1, 2, 3], + side=1, + triple_barrier_spec=TripleBarrierSpec( + take_profit_pct=0.02, + stop_loss_pct=0.01, + max_holding_minutes=3, + ), + walk_forward=WalkForwardConfig(train_size=2, test_size=1), + cost_model=_cost_model(), + ) + except ValueError as exc: + assert "BacktestBar.timestamp is required" in str(exc) + else: + raise AssertionError("expected timestamp validation error") From 273a3c182a51b3ccc2fee0d2ac1c67a79c8c1ea6 Mon Sep 17 00:00:00 2001 From: agentson Date: Sun, 1 Mar 2026 09:50:45 +0900 Subject: [PATCH 2/2] refactor: simplify timestamp normalization after non-null validation (#329) --- src/analysis/backtest_pipeline.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/analysis/backtest_pipeline.py b/src/analysis/backtest_pipeline.py index a41001c..ba49289 100644 --- a/src/analysis/backtest_pipeline.py +++ b/src/analysis/backtest_pipeline.py @@ -11,6 +11,7 @@ from dataclasses import dataclass from datetime import datetime from statistics import mean from typing import Literal +from typing import cast from src.analysis.backtest_cost_guard import BacktestCostModel, validate_backtest_cost_model from src.analysis.triple_barrier import TripleBarrierSpec, label_with_triple_barrier @@ -100,7 +101,7 @@ def run_v2_backtest_pipeline( "BacktestBar.timestamp is required for all bars when " "triple_barrier_spec.max_holding_minutes is set" ) - resolved_timestamps = [ts for ts in timestamps if ts is not None] + resolved_timestamps = cast(list[datetime], timestamps) labels_by_bar_index: dict[int, int] = {} for idx in normalized_entries: