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
122 changes: 118 additions & 4 deletions application/runtime_strategy_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Map day durations to valid yfinance periods

The fallback currently converts IBKR day windows to "{N}d" (for example "10 D" becomes "10d"), but yfinance.download(period=...) only accepts a fixed set of period values and rejects arbitrary day counts. In this codebase, _build_historical_close_map calls the loader with duration="10 D", so when IBKR returns no data the yfinance fallback hits an invalid period, gets caught, and silently returns an empty series—defeating the new fallback path for close-history prechecks.

Useful? React with 👍 / 👎.

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
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
41 changes: 41 additions & 0 deletions tests/test_runtime_strategy_adapters.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from types import SimpleNamespace

import pandas as pd

from application.runtime_strategy_adapters import build_runtime_strategy_adapters


Expand Down Expand Up @@ -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}",
Expand Down