From c052cedb27316c139273092a9227e89cdc7deff2 Mon Sep 17 00:00:00 2001 From: marcvanduyn Date: Thu, 19 Mar 2026 14:59:57 +0100 Subject: [PATCH] feat: raise OperationalException when scheduling interval is faster than data timeframe (#396) - Add validation in TradingStrategy.__init__ that compares the strategy's scheduling interval (time_unit * interval) against the smallest OHLCV data source timeframe. Raises OperationalException with a descriptive message if the interval is too fast. - Add 7 tests in tests/app/test_strategy_interval_validation.py covering all edge cases (faster, equal, slower, multiple sources, no sources, non-OHLCV sources, error message content). - Fix test strategies in test_data_completeness.py that had invalid 1-minute intervals with 1-day OHLCV data. --- investing_algorithm_framework/app/strategy.py | 28 ++- tests/app/test_data_completeness.py | 4 +- .../app/test_strategy_interval_validation.py | 170 ++++++++++++++++++ 3 files changed, 199 insertions(+), 3 deletions(-) create mode 100644 tests/app/test_strategy_interval_validation.py diff --git a/investing_algorithm_framework/app/strategy.py b/investing_algorithm_framework/app/strategy.py index f6d5a018..f7d10c5c 100644 --- a/investing_algorithm_framework/app/strategy.py +++ b/investing_algorithm_framework/app/strategy.py @@ -6,7 +6,7 @@ from investing_algorithm_framework.domain import OperationalException, \ Position, PositionSize, TimeUnit, StrategyProfile, Trade, \ - DataSource, OrderSide, StopLossRule, TakeProfitRule, Order, \ + DataSource, DataType, OrderSide, StopLossRule, TakeProfitRule, Order, \ INDEX_DATETIME from .context import Context @@ -156,6 +156,32 @@ def __init__( f"Interval not set for strategy instance {self.strategy_id}" ) + # Check if scheduling interval is faster than the smallest + # OHLCV data source timeframe + ohlcv_timeframes = [ + ds.time_frame.amount_of_minutes + for ds in self.data_sources + if ds.time_frame is not None + and DataType.OHLCV.equals(ds.data_type) + ] + + if ohlcv_timeframes: + scheduling_interval = \ + self.time_unit.amount_of_minutes * self.interval + smallest_timeframe = min(ohlcv_timeframes) + + if scheduling_interval < smallest_timeframe: + raise OperationalException( + f"Strategy '{self.strategy_id}' scheduling interval " + f"({self.interval} {self.time_unit.value.lower()}" + f"{'s' if self.interval > 1 else ''}" + f" = {scheduling_interval} min) is faster than " + f"the smallest OHLCV data source timeframe " + f"({smallest_timeframe} min). The strategy would " + f"run without new data. Increase the scheduling " + f"interval or use a smaller data timeframe." + ) + # Initialize stop_losses as a new list per instance if stop_losses is not None: self.stop_losses = list(stop_losses) diff --git a/tests/app/test_data_completeness.py b/tests/app/test_data_completeness.py index 702fd4e6..ac9edefd 100644 --- a/tests/app/test_data_completeness.py +++ b/tests/app/test_data_completeness.py @@ -10,7 +10,7 @@ from tests.resources import TestBase class TestStrategy(TradingStrategy): - time_unit = "MINUTE" + time_unit = "DAY" interval = 1 data_sources = [ DataSource( @@ -35,7 +35,7 @@ def generate_buy_signals( class TestStrategyIncompleteData(TradingStrategy): - time_unit = "MINUTE" + time_unit = "DAY" interval = 1 data_sources = [ DataSource( diff --git a/tests/app/test_strategy_interval_validation.py b/tests/app/test_strategy_interval_validation.py new file mode 100644 index 00000000..5c655e1e --- /dev/null +++ b/tests/app/test_strategy_interval_validation.py @@ -0,0 +1,170 @@ +""" +Tests for the scheduling interval vs OHLCV data timeframe validation +added in TradingStrategy.__init__ (issue #396). + +The validation raises OperationalException when the strategy's scheduling +interval (time_unit.amount_of_minutes * interval) is strictly less than +the smallest OHLCV data source timeframe. +""" +from unittest import TestCase + +from investing_algorithm_framework.app.strategy import TradingStrategy +from investing_algorithm_framework.domain import ( + OperationalException, TimeUnit, DataType, TimeFrame, +) +from investing_algorithm_framework.domain.models.data.data_source import ( + DataSource, +) + + +class _ConcreteStrategy(TradingStrategy): + """Minimal concrete subclass for testing __init__ validation.""" + + def run_strategy(self, context, data): + pass + + +class TestStrategyIntervalValidation(TestCase): + + # ------------------------------------------------------------------ + # 1. Should raise when interval is faster than the OHLCV timeframe + # ------------------------------------------------------------------ + def test_raises_when_interval_faster_than_ohlcv_timeframe(self): + """1 minute interval < 60 minute (1h) OHLCV → must raise.""" + with self.assertRaises(OperationalException): + _ConcreteStrategy( + time_unit=TimeUnit.MINUTE, + interval=1, + data_sources=[ + DataSource( + symbol="BTC/EUR", + data_type=DataType.OHLCV, + time_frame=TimeFrame.ONE_HOUR, + market="BITVAVO", + warmup_window=100, + ) + ], + ) + + # ------------------------------------------------------------------ + # 2. No error when interval exactly matches the OHLCV timeframe + # ------------------------------------------------------------------ + def test_no_error_when_interval_matches_ohlcv_timeframe(self): + """1 hour interval == 60 min == 1h OHLCV → should NOT raise.""" + strategy = _ConcreteStrategy( + time_unit=TimeUnit.HOUR, + interval=1, + data_sources=[ + DataSource( + symbol="BTC/EUR", + data_type=DataType.OHLCV, + time_frame=TimeFrame.ONE_HOUR, + market="BITVAVO", + warmup_window=100, + ) + ], + ) + self.assertIsNotNone(strategy) + + # ------------------------------------------------------------------ + # 3. No error when interval is slower than the OHLCV timeframe + # ------------------------------------------------------------------ + def test_no_error_when_interval_slower_than_ohlcv_timeframe(self): + """1 day interval (1440 min) > 60 min (1h) OHLCV → should NOT raise.""" + strategy = _ConcreteStrategy( + time_unit=TimeUnit.DAY, + interval=1, + data_sources=[ + DataSource( + symbol="BTC/EUR", + data_type=DataType.OHLCV, + time_frame=TimeFrame.ONE_HOUR, + market="BITVAVO", + warmup_window=100, + ) + ], + ) + self.assertIsNotNone(strategy) + + # ------------------------------------------------------------------ + # 4. Validation uses the *smallest* OHLCV timeframe among sources + # ------------------------------------------------------------------ + def test_uses_smallest_ohlcv_timeframe_for_validation(self): + """5 min interval < 15 min (smallest OHLCV) → must raise.""" + with self.assertRaises(OperationalException): + _ConcreteStrategy( + time_unit=TimeUnit.MINUTE, + interval=5, + data_sources=[ + DataSource( + symbol="BTC/EUR", + data_type=DataType.OHLCV, + time_frame=TimeFrame.ONE_HOUR, + market="BITVAVO", + warmup_window=100, + ), + DataSource( + symbol="ETH/EUR", + data_type=DataType.OHLCV, + time_frame=TimeFrame.FIFTEEN_MINUTE, + market="BITVAVO", + warmup_window=100, + ), + ], + ) + + # ------------------------------------------------------------------ + # 5. No validation when there are no OHLCV data sources at all + # ------------------------------------------------------------------ + def test_no_validation_when_no_ohlcv_data_sources(self): + """No OHLCV sources → nothing to compare → should NOT raise.""" + strategy = _ConcreteStrategy( + time_unit=TimeUnit.MINUTE, + interval=1, + data_sources=[], + ) + self.assertIsNotNone(strategy) + + # ------------------------------------------------------------------ + # 6. Non-OHLCV sources (no time_frame) are skipped + # ------------------------------------------------------------------ + def test_skips_data_sources_without_timeframe(self): + """Ticker source has no time_frame → should NOT raise.""" + strategy = _ConcreteStrategy( + time_unit=TimeUnit.MINUTE, + interval=1, + data_sources=[ + DataSource( + symbol="BTC/EUR", + data_type=DataType.TICKER, + market="BITVAVO", + ) + ], + ) + self.assertIsNotNone(strategy) + + # ------------------------------------------------------------------ + # 7. Error message is descriptive + # ------------------------------------------------------------------ + def test_raises_with_descriptive_error_message(self): + """Exception message should contain interval info AND timeframe info.""" + with self.assertRaises(OperationalException) as cm: + _ConcreteStrategy( + time_unit=TimeUnit.MINUTE, + interval=1, + data_sources=[ + DataSource( + symbol="BTC/EUR", + data_type=DataType.OHLCV, + time_frame=TimeFrame.ONE_HOUR, + market="BITVAVO", + warmup_window=100, + ) + ], + ) + + msg = str(cm.exception) + # Verify scheduling interval info is present + self.assertIn("1 min", msg) + # Verify OHLCV timeframe info is present + self.assertIn("60 min", msg)