diff --git a/application/runtime_strategy_adapters.py b/application/runtime_strategy_adapters.py index 17534c9..3676c59 100644 --- a/application/runtime_strategy_adapters.py +++ b/application/runtime_strategy_adapters.py @@ -173,7 +173,27 @@ def build_strategy_plugin_alert_messages(self, signals): strategy_label=self.strategy_profile, ) + def _get_fallback_historical_candles(self, symbol, *, duration: str, bar_size: str) -> tuple[dict[str, Any], ...]: + if self.fallback_historical_candles_fn is None: + return () + try: + return tuple(self.fallback_historical_candles_fn(symbol, duration=duration, bar_size=bar_size) or ()) + except Exception: + return () + def get_historical_close(self, ib, symbol, duration="2 Y", bar_size="1 day"): + fallback_candles = self._get_fallback_historical_candles(symbol, duration=duration, bar_size=bar_size) + fallback_points = tuple( + (candle["as_of"], candle["close"]) + for candle in fallback_candles + if "close" in candle + ) + if fallback_points: + return pd.Series( + data=[close for _, close in fallback_points], + index=pd.to_datetime([as_of for as_of, _ in fallback_points]), + ) + series = self.fetch_historical_price_series_fn( ib, symbol, @@ -186,31 +206,19 @@ def get_historical_close(self, ib, symbol, duration="2 Y", bar_size="1 day"): data=[point.close for point in points], index=pd.to_datetime([point.as_of for point in points]), ) - if self.fallback_historical_candles_fn is not None: - candles = self.fallback_historical_candles_fn(symbol, duration=duration, bar_size=bar_size) - fallback_points = tuple( - (candle["as_of"], candle["close"]) - for candle in candles - if "close" in candle - ) - else: - fallback_points = () - if not fallback_points: - return pd.Series(dtype=float) - return pd.Series( - data=[close for _, close in fallback_points], - index=pd.to_datetime([as_of for as_of, _ in fallback_points]), - ) + return pd.Series(dtype=float) def get_historical_candles(self, ib, symbol, duration="2 Y", bar_size="1 day"): + fallback_candles = self._get_fallback_historical_candles(symbol, duration=duration, bar_size=bar_size) + if fallback_candles: + return list(fallback_candles) + candles = self.fetch_historical_price_candles_fn( ib, symbol, duration=duration, bar_size=bar_size, ) - if not candles and self.fallback_historical_candles_fn is not None: - return self.fallback_historical_candles_fn(symbol, duration=duration, bar_size=bar_size) return candles def compute_signals(self, ib, current_holdings): diff --git a/tests/test_runtime_strategy_adapters.py b/tests/test_runtime_strategy_adapters.py index c831d1b..c1cfed5 100644 --- a/tests/test_runtime_strategy_adapters.py +++ b/tests/test_runtime_strategy_adapters.py @@ -59,14 +59,17 @@ def fake_load(mounts, *, strategy_profile): assert adapters.build_strategy_plugin_alert_messages(signals) == () -def test_historical_close_falls_back_when_ibkr_history_is_empty(): +def test_historical_close_uses_fallback_before_ibkr_history(): + def fail_broker_history(*_args, **_kwargs): + raise AssertionError("broker historical close should not be called when fallback has data") + adapters = build_runtime_strategy_adapters( strategy_runtime=SimpleNamespace(), strategy_profile="tqqq_growth_income", translator=lambda key, **_kwargs: key, pacing_sec=0.0, resolve_run_as_of_date_fn=lambda: None, - fetch_historical_price_series_fn=lambda *_args, **_kwargs: SimpleNamespace(points=()), + fetch_historical_price_series_fn=fail_broker_history, fetch_historical_price_candles_fn=lambda *_args, **_kwargs: (), fallback_historical_candles_fn=lambda symbol, **_kwargs: [ {"as_of": pd.Timestamp("2026-05-21"), "close": 100.0}, @@ -81,7 +84,10 @@ def test_historical_close_falls_back_when_ibkr_history_is_empty(): assert [str(item.date()) for item in history.index] == ["2026-05-21", "2026-05-22"] -def test_historical_candles_fall_back_when_ibkr_history_is_empty(): +def test_historical_candles_use_fallback_before_ibkr_history(): + def fail_broker_history(*_args, **_kwargs): + raise AssertionError("broker historical candles should not be called when fallback has data") + fallback = [{"as_of": pd.Timestamp("2026-05-22"), "close": 101.0}] adapters = build_runtime_strategy_adapters( strategy_runtime=SimpleNamespace(), @@ -90,7 +96,7 @@ def test_historical_candles_fall_back_when_ibkr_history_is_empty(): pacing_sec=0.0, resolve_run_as_of_date_fn=lambda: None, fetch_historical_price_series_fn=lambda *_args, **_kwargs: SimpleNamespace(points=()), - fetch_historical_price_candles_fn=lambda *_args, **_kwargs: (), + fetch_historical_price_candles_fn=fail_broker_history, fallback_historical_candles_fn=lambda symbol, **_kwargs: fallback, map_strategy_decision_fn=lambda *_args, **_kwargs: (), ) @@ -98,6 +104,29 @@ def test_historical_candles_fall_back_when_ibkr_history_is_empty(): assert adapters.get_historical_candles("fake-ib", "QQQ") == fallback +def test_historical_close_uses_ibkr_history_when_fallback_is_empty(): + adapters = build_runtime_strategy_adapters( + strategy_runtime=SimpleNamespace(), + strategy_profile="tqqq_growth_income", + translator=lambda key, **_kwargs: key, + pacing_sec=0.0, + resolve_run_as_of_date_fn=lambda: None, + fetch_historical_price_series_fn=lambda *_args, **_kwargs: SimpleNamespace( + points=( + SimpleNamespace(as_of=pd.Timestamp("2026-05-21"), close=99.0), + SimpleNamespace(as_of=pd.Timestamp("2026-05-22"), close=100.0), + ) + ), + fetch_historical_price_candles_fn=lambda *_args, **_kwargs: (), + fallback_historical_candles_fn=lambda symbol, **_kwargs: (), + map_strategy_decision_fn=lambda *_args, **_kwargs: (), + ) + + history = adapters.get_historical_close("fake-ib", "QQQ") + + assert list(history) == [99.0, 100.0] + + def test_yfinance_candle_coercion_handles_single_symbol_multiindex_columns(): frame = pd.DataFrame( [[100.0, 101.0, 102.0, 99.0, 1_000_000.0]],