Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 25 additions & 17 deletions application/runtime_strategy_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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):
Expand Down
37 changes: 33 additions & 4 deletions tests/test_runtime_strategy_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand All @@ -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(),
Expand All @@ -90,14 +96,37 @@ 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: (),
)

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]],
Expand Down