diff --git a/strategy_runtime.py b/strategy_runtime.py index 01a97d7..d5252d0 100644 --- a/strategy_runtime.py +++ b/strategy_runtime.py @@ -80,6 +80,34 @@ } +def _get_direct_market_history_profiles() -> frozenset[str]: + try: + from hk_equity_strategies import get_direct_market_history_profiles + except (ImportError, AttributeError): # pragma: no cover - compatibility fallback + return frozenset() + return frozenset( + str(profile).strip().lower() + for profile in get_direct_market_history_profiles() + ) + + +def _requires_materialized_market_history(strategy_profile: str) -> bool: + return str(strategy_profile or "").strip().lower() in _get_direct_market_history_profiles() + + +def _loaded_history_to_rows(history): + if ( + hasattr(history, "items") + and not hasattr(history, "columns") + and not isinstance(history, Mapping) + ): + return [ + {"date": date_value, "close": close_value} + for date_value, close_value in history.items() + ] + return history + + @dataclass(frozen=True) class StrategyEvaluationResult: decision: StrategyDecision @@ -310,6 +338,37 @@ def _build_historical_close_map( close_map[symbol] = latest_close return close_map + def _market_history_symbols(self) -> tuple[str, ...]: + raw_symbols = ( + self.merged_runtime_config.get("universe_symbols") + or dict(getattr(self.entrypoint.manifest, "default_config", {}) or {}).get("universe_symbols") + or self.merged_runtime_config.get("managed_symbols") + or () + ) + if isinstance(raw_symbols, str): + raw_symbols = raw_symbols.replace(";", ",").split(",") + return tuple( + dict.fromkeys( + str(symbol).strip() + for symbol in raw_symbols + if str(symbol).strip() + ) + ) + + def _build_market_history_inputs( + self, + ib, + historical_close_loader: Callable[..., Any], + ) -> Mapping[str, Any]: + if not _requires_materialized_market_history(self.profile): + return build_market_history_inputs(historical_close_loader) + return { + _MARKET_HISTORY_INPUT: { + symbol: _loaded_history_to_rows(historical_close_loader(ib, symbol)) + for symbol in self._market_history_symbols() + } + } + def _build_strategy_context( self, *, @@ -429,7 +488,7 @@ def _evaluate_market_data_strategy( ctx = self._build_strategy_context( runtime_adapter=self.runtime_adapter, as_of=run_as_of, - market_inputs=build_market_history_inputs(historical_close_loader), + market_inputs=self._build_market_history_inputs(ib, historical_close_loader), portfolio_snapshot=portfolio_snapshot, runtime_config=runtime_config, current_holdings=current_holdings, @@ -619,7 +678,7 @@ def build_available_inputs(feature_snapshot) -> Mapping[str, Any]: runtime_config["option_chains"] = option_chains market_inputs: dict[str, Any] = {_FEATURE_SNAPSHOT_INPUT: feature_snapshot} if _MARKET_HISTORY_INPUT in self.required_inputs: - market_inputs.update(build_market_history_inputs(historical_close_loader)) + market_inputs.update(self._build_market_history_inputs(ib, historical_close_loader)) if _BENCHMARK_HISTORY_INPUT in self.required_inputs: if historical_candle_loader is None: raise ValueError( diff --git a/tests/test_strategy_runtime.py b/tests/test_strategy_runtime.py index b2c6187..3e1a1aa 100644 --- a/tests/test_strategy_runtime.py +++ b/tests/test_strategy_runtime.py @@ -543,6 +543,72 @@ def loader(*_args, **_kwargs): assert result.metadata["execution_timing_contract"] == "next_trading_day" +def test_hk_direct_market_history_runtime_materializes_loader(monkeypatch): + captured = {} + observed_calls = [] + + class FakeEntrypoint: + manifest = StrategyManifest( + profile="hk_listed_global_etf_rotation", + domain="hk_equity", + display_name="HK-listed Global ETF Rotation", + description="test", + required_inputs=frozenset({"market_history"}), + default_config={"universe_symbols": ("02800", "02834")}, + ) + + def evaluate(self, ctx): + captured["market_data"] = dict(ctx.market_data) + return StrategyDecision() + + def close_loader(ib, symbol, **_kwargs): + observed_calls.append((ib, symbol)) + return strategy_runtime_module.pd.Series( + [10.0, 11.0], + index=strategy_runtime_module.pd.to_datetime( + ["2026-05-29", "2026-06-01"], + utc=True, + ), + dtype=float, + ) + + runtime = strategy_runtime_module.LoadedStrategyRuntime( + entrypoint=FakeEntrypoint(), + runtime_adapter=StrategyRuntimeAdapter( + status_icon="🇭🇰", + runtime_policy=StrategyRuntimePolicy(signal_effective_after_trading_days=1), + ), + runtime_settings=_build_runtime_settings(profile="hk_listed_global_etf_rotation"), + runtime_config={}, + merged_runtime_config={"universe_symbols": ("02800", "02834")}, + status_icon="🇭🇰", + logger=lambda _message: None, + ) + monkeypatch.setattr( + strategy_runtime_module, + "_requires_materialized_market_history", + lambda profile: profile == "hk_listed_global_etf_rotation", + ) + + runtime.evaluate( + ib="fake-ib", + current_holdings=set(), + historical_close_loader=close_loader, + run_as_of=strategy_runtime_module.pd.Timestamp("2026-04-01"), + translator=lambda key, **_kwargs: key, + pacing_sec=0.5, + ) + + market_history = captured["market_data"]["market_history"] + assert observed_calls == [("fake-ib", "02800"), ("fake-ib", "02834")] + assert sorted(market_history) == ["02800", "02834"] + assert market_history["02800"][0]["date"] == strategy_runtime_module.pd.Timestamp( + "2026-05-29", + tz="UTC", + ) + assert market_history["02800"][0]["close"] == 10.0 + + def test_market_history_value_runtime_requires_portfolio_snapshot(monkeypatch): captured = {}