From b5510c3d381f15d3d27e631668c47d0a5ac70540 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 27 May 2026 03:15:19 +0800 Subject: [PATCH] Fall back when IBKR historical data is empty --- application/runtime_strategy_adapters.py | 122 ++++++++++++++++++++++- tests/test_runtime_strategy_adapters.py | 41 ++++++++ 2 files changed, 159 insertions(+), 4 deletions(-) diff --git a/application/runtime_strategy_adapters.py b/application/runtime_strategy_adapters.py index 52b32fc..277cf6b 100644 --- a/application/runtime_strategy_adapters.py +++ b/application/runtime_strategy_adapters.py @@ -3,6 +3,8 @@ from __future__ import annotations from dataclasses import dataclass +from math import ceil +import re from typing import Any import pandas as pd @@ -14,6 +16,97 @@ ) +def _duration_to_yfinance_period(duration: str) -> str: + text = str(duration or "").strip() + match = re.fullmatch(r"(\d+)\s*([A-Za-z]+)", text) + if not match: + return "2y" + + quantity = int(match.group(1)) + unit = match.group(2).upper() + if unit == "Y": + return f"{max(quantity, 1)}y" + if unit == "M": + return f"{max(quantity, 1)}mo" + if unit == "W": + return f"{max(quantity, 1)}wk" + if unit == "D": + if quantity > 365: + return f"{ceil(quantity / 365)}y" + return f"{max(quantity, 1)}d" + return "2y" + + +def _bar_size_to_yfinance_interval(bar_size: str) -> str: + text = str(bar_size or "").strip().lower() + if text in {"1 day", "1d", "day"}: + return "1d" + if text in {"1 week", "1wk", "week"}: + return "1wk" + if text in {"1 month", "1mo", "month"}: + return "1mo" + return "1d" + + +def _frame_column(frame: pd.DataFrame, name: str): + if name in frame.columns: + return frame[name] + if isinstance(frame.columns, pd.MultiIndex): + expected = name.lower() + for column in frame.columns: + if any(str(part).lower() == expected for part in column): + return frame[column] + return None + + +def _coerce_yfinance_candles(frame: pd.DataFrame) -> list[dict[str, Any]]: + if frame is None or frame.empty: + return [] + close_series = _frame_column(frame, "Adj Close") + if close_series is None: + close_series = _frame_column(frame, "Close") + if close_series is None: + return [] + open_series = _frame_column(frame, "Open") + high_series = _frame_column(frame, "High") + low_series = _frame_column(frame, "Low") + volume_series = _frame_column(frame, "Volume") + candles: list[dict[str, Any]] = [] + for as_of, close in close_series.dropna().items(): + close_value = float(close) + candles.append( + { + "as_of": pd.Timestamp(as_of).to_pydatetime(), + "open": float(open_series.get(as_of, close_value)) if open_series is not None else close_value, + "high": float(high_series.get(as_of, close_value)) if high_series is not None else close_value, + "low": float(low_series.get(as_of, close_value)) if low_series is not None else close_value, + "close": close_value, + "volume": float(volume_series.get(as_of, 0.0) or 0.0) if volume_series is not None else 0.0, + } + ) + return candles + + +def fetch_yfinance_historical_candles(symbol: str, *, duration: str = "2 Y", bar_size: str = "1 day") -> list[dict[str, Any]]: + try: + import yfinance as yf + except Exception: + return [] + + try: + frame = yf.download( + str(symbol).strip().upper(), + period=_duration_to_yfinance_period(duration), + interval=_bar_size_to_yfinance_interval(bar_size), + auto_adjust=False, + progress=False, + threads=False, + ) + except Exception: + return [] + return _coerce_yfinance_candles(frame) + + @dataclass(frozen=True) class IBKRRuntimeStrategyAdapters: strategy_runtime: Any @@ -24,6 +117,7 @@ class IBKRRuntimeStrategyAdapters: fetch_historical_price_series_fn: Any fetch_historical_price_candles_fn: Any map_strategy_decision_fn: Any + fallback_historical_candles_fn: Any = fetch_yfinance_historical_candles build_strategy_plugin_report_payload_fn: Any = None load_configured_strategy_plugin_signals_fn: Any = None parse_strategy_plugin_mounts_fn: Any = None @@ -74,20 +168,38 @@ def get_historical_close(self, ib, symbol, duration="2 Y", bar_size="1 day"): duration=duration, bar_size=bar_size, ) - if not series.points: + points = tuple(series.points or ()) + if points: + return pd.Series( + 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=[point.close for point in series.points], - index=pd.to_datetime([point.as_of for point in series.points]), + data=[close for _, close in fallback_points], + index=pd.to_datetime([as_of for as_of, _ in fallback_points]), ) def get_historical_candles(self, ib, symbol, duration="2 Y", bar_size="1 day"): - return self.fetch_historical_price_candles_fn( + 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): evaluation = self.strategy_runtime.evaluate( @@ -116,6 +228,7 @@ def build_runtime_strategy_adapters( fetch_historical_price_series_fn, fetch_historical_price_candles_fn, map_strategy_decision_fn, + fallback_historical_candles_fn=fetch_yfinance_historical_candles, build_strategy_plugin_report_payload_fn=None, load_configured_strategy_plugin_signals_fn=None, parse_strategy_plugin_mounts_fn=None, @@ -129,6 +242,7 @@ def build_runtime_strategy_adapters( fetch_historical_price_series_fn=fetch_historical_price_series_fn, fetch_historical_price_candles_fn=fetch_historical_price_candles_fn, map_strategy_decision_fn=map_strategy_decision_fn, + fallback_historical_candles_fn=fallback_historical_candles_fn, build_strategy_plugin_report_payload_fn=build_strategy_plugin_report_payload_fn, load_configured_strategy_plugin_signals_fn=load_configured_strategy_plugin_signals_fn, parse_strategy_plugin_mounts_fn=parse_strategy_plugin_mounts_fn, diff --git a/tests/test_runtime_strategy_adapters.py b/tests/test_runtime_strategy_adapters.py index 25871b8..cfe1190 100644 --- a/tests/test_runtime_strategy_adapters.py +++ b/tests/test_runtime_strategy_adapters.py @@ -1,5 +1,7 @@ from types import SimpleNamespace +import pandas as pd + from application.runtime_strategy_adapters import build_runtime_strategy_adapters @@ -57,6 +59,45 @@ 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(): + 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_candles_fn=lambda *_args, **_kwargs: (), + fallback_historical_candles_fn=lambda symbol, **_kwargs: [ + {"as_of": pd.Timestamp("2026-05-21"), "close": 100.0}, + {"as_of": pd.Timestamp("2026-05-22"), "close": 101.0}, + ], + map_strategy_decision_fn=lambda *_args, **_kwargs: (), + ) + + history = adapters.get_historical_close("fake-ib", "QQQ") + + assert list(history) == [100.0, 101.0] + 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(): + fallback = [{"as_of": pd.Timestamp("2026-05-22"), "close": 101.0}] + 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_candles_fn=lambda *_args, **_kwargs: (), + fallback_historical_candles_fn=lambda symbol, **_kwargs: fallback, + map_strategy_decision_fn=lambda *_args, **_kwargs: (), + ) + + assert adapters.get_historical_candles("fake-ib", "QQQ") == fallback + + def test_strategy_plugin_true_crisis_builds_escalated_alert_message(): translations = { "strategy_plugin_line": "plugin={plugin}|mode={mode}|route={route}|action={action}",