From 7b8bfac17b95ba96c41b7c8d2adec137d9e56e3d Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:13:11 +0800 Subject: [PATCH] Support leveraged combo regime market data Co-Authored-By: Codex --- pyproject.toml | 2 +- scripts/print_strategy_switch_env_plan.py | 4 +- strategy_runtime.py | 111 +++++++++++++++------- tests/test_runtime_config_support.py | 2 +- tests/test_strategy_runtime.py | 18 +++- uv.lock | 4 +- 6 files changed, 101 insertions(+), 40 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1ac2a8f..2819b36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ dependencies = [ "google-cloud-storage", "yfinance", "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@0063af3b4a974650ea58a7d3f26dd1b94f65d3e8", - "us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@46887bc3f5454d5b59623b1f5efb7c65912c6b8b", + "us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@cd959e30e91e57697ad878d98799c1afb43bf5fb", "hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@61993bf261aeccf64b5a75428b9405f4e1d1d682", ] diff --git a/scripts/print_strategy_switch_env_plan.py b/scripts/print_strategy_switch_env_plan.py index 6ea9d31..7896905 100644 --- a/scripts/print_strategy_switch_env_plan.py +++ b/scripts/print_strategy_switch_env_plan.py @@ -267,9 +267,9 @@ def build_switch_plan( if artifact_paths.bundled_config_path is not None: hints["bundled_strategy_config_path"] = str(artifact_paths.bundled_config_path) if definition.profile == "us_equity_combo_leveraged": - hints["shadow_352045_strategy_config_path"] = ( + hints["shadow_402040_strategy_config_path"] = ( "package://us_equity_strategies/configs/" - "us_equity_combo_leveraged_shadow_352045.json" + "us_equity_combo_leveraged_shadow_402040.json" ) return { diff --git a/strategy_runtime.py b/strategy_runtime.py index 8e41407..9d85ac2 100644 --- a/strategy_runtime.py +++ b/strategy_runtime.py @@ -469,50 +469,95 @@ def _build_direct_market_data_inputs( raise ValueError(f"Unsupported market_data strategy profile {self.profile!r}") trend_symbol = str(self.merged_runtime_config.get("market_data_trend_symbol") or "SPY").strip().upper() + raw_regime_symbols = self.merged_runtime_config.get("market_data_regime_symbols") or ("SPY", "QQQ", "SOXX") + if isinstance(raw_regime_symbols, str): + regime_symbols = tuple( + symbol.strip().upper() + for symbol in raw_regime_symbols.split(",") + if symbol.strip() + ) + else: + regime_symbols = tuple( + str(symbol).strip().upper() + for symbol in raw_regime_symbols + if str(symbol).strip() + ) + regime_symbols = tuple(dict.fromkeys((trend_symbol, *regime_symbols))) ma_window = int( self.merged_runtime_config.get( "market_data_ma_window", self.merged_runtime_config.get("sma_period", 200), ) ) + slope_window = int(self.merged_runtime_config.get("market_data_slope_window") or 20) if ma_window <= 0: raise ValueError("market_data_ma_window must be positive") - history = historical_close_loader( - ib, - trend_symbol, - duration=str(self.merged_runtime_config.get("market_data_history_duration") or "2 Y"), - bar_size=str(self.merged_runtime_config.get("market_data_history_bar_size") or "1 day"), - ) - if isinstance(history, pd.DataFrame): - if "close" in history.columns: - close_values = history["close"] - else: - close_values = history.iloc[:, 0] - elif isinstance(history, pd.Series): - close_values = history - else: - close_values = [ - item.get("close") if isinstance(item, Mapping) else getattr(item, "close", item) - for item in (history or ()) - ] - close_series = pd.to_numeric(pd.Series(close_values), errors="coerce").dropna() - close_series = close_series[close_series > 0] - if len(close_series) < ma_window: - raise ValueError( - f"{self.profile} requires at least {ma_window} positive {trend_symbol} closes, " - f"got {len(close_series)}" + if slope_window <= 1: + raise ValueError("market_data_slope_window must be greater than 1") + + def load_close_series(symbol: str) -> pd.Series: + history = historical_close_loader( + ib, + symbol, + duration=str(self.merged_runtime_config.get("market_data_history_duration") or "2 Y"), + bar_size=str(self.merged_runtime_config.get("market_data_history_bar_size") or "1 day"), ) - trend_price = float(close_series.iloc[-1]) - trend_ma = float(close_series.tail(ma_window).mean()) - return { - _MARKET_DATA_INPUT: { - "spy_above_ma200": trend_price > trend_ma, - "trend_symbol": trend_symbol, - "trend_price": trend_price, - "trend_ma": trend_ma, - "trend_ma_window": ma_window, + if isinstance(history, pd.DataFrame): + if "close" in history.columns: + close_values = history["close"] + else: + close_values = history.iloc[:, 0] + elif isinstance(history, pd.Series): + close_values = history + else: + close_values = [ + item.get("close") if isinstance(item, Mapping) else getattr(item, "close", item) + for item in (history or ()) + ] + close_series = pd.to_numeric(pd.Series(close_values), errors="coerce").dropna() + close_series = close_series[close_series > 0] + if len(close_series) < ma_window: + raise ValueError( + f"{self.profile} requires at least {ma_window} positive {symbol} closes, " + f"got {len(close_series)}" + ) + return close_series + + market_data: dict[str, Any] = { + "trend_symbol": trend_symbol, + "trend_symbols": regime_symbols, + "trend_ma_window": ma_window, + "trend_slope_window": slope_window, + "regime_indicators": {}, + } + for symbol in regime_symbols: + close_series = load_close_series(symbol) + price = float(close_series.iloc[-1]) + ma_value = float(close_series.tail(ma_window).mean()) + rolling_ma = close_series.rolling(slope_window).mean() + slope_positive = bool(len(rolling_ma.dropna()) >= 2 and rolling_ma.iloc[-1] > rolling_ma.iloc[-2]) + key = symbol.lower() + market_data[f"{key}_above_ma200"] = price > ma_value + market_data[f"{key}_price"] = price + market_data[f"{key}_ma200"] = ma_value + market_data[f"{key}_ma20_slope_positive"] = slope_positive + market_data["regime_indicators"][key] = { + "price": price, + "ma200": ma_value, + "above_ma200": price > ma_value, + "ma20_slope_positive": slope_positive, "history_observation_count": int(len(close_series)), } + if symbol == trend_symbol: + market_data.update( + { + "trend_price": price, + "trend_ma": ma_value, + "history_observation_count": int(len(close_series)), + } + ) + return { + _MARKET_DATA_INPUT: market_data, } def _build_strategy_context( diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index 15312d8..a0538af 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -948,7 +948,7 @@ def test_print_strategy_switch_env_plan_keeps_optional_config_for_leveraged_comb assert "IBKR_STRATEGY_CONFIG_PATH" in plan["optional_env"] assert "IBKR_STRATEGY_CONFIG_PATH" not in plan["remove_if_present"] assert "IBKR_FEATURE_SNAPSHOT_PATH" in plan["remove_if_present"] - assert plan["hints"]["shadow_352045_strategy_config_path"].startswith( + assert plan["hints"]["shadow_402040_strategy_config_path"].startswith( "package://us_equity_strategies/" ) diff --git a/tests/test_strategy_runtime.py b/tests/test_strategy_runtime.py index ee99013..8adc0cc 100644 --- a/tests/test_strategy_runtime.py +++ b/tests/test_strategy_runtime.py @@ -1,5 +1,15 @@ +import sys +from pathlib import Path from types import SimpleNamespace +ROOT = Path(__file__).resolve().parents[1] +QPK_SRC = ROOT.parent / "QuantPlatformKit" / "src" +UES_SRC = ROOT.parent / "UsEquityStrategies" / "src" +HES_SRC = ROOT.parent / "HkEquityStrategies" / "src" +for candidate in (ROOT, QPK_SRC, UES_SRC, HES_SRC): + if candidate.exists() and str(candidate) not in sys.path: + sys.path.insert(0, str(candidate)) + import strategy_runtime as strategy_runtime_module from quant_platform_kit.common.models import PortfolioSnapshot from quant_platform_kit.strategy_contracts import ( @@ -409,7 +419,7 @@ def evaluate(self, ctx): ) def fake_close_loader(_ib, symbol, **_kwargs): - if symbol == "SPY": + if symbol in {"SPY", "QQQ", "SOXX"}: return strategy_runtime_module.pd.Series([100.0] * 200 + [120.0]) return strategy_runtime_module.pd.Series([10.0] * 201) @@ -425,7 +435,13 @@ def fake_close_loader(_ib, symbol, **_kwargs): market_data = captured["market_data"]["market_data"] assert market_data["spy_above_ma200"] is True + assert market_data["qqq_above_ma200"] is True + assert market_data["soxx_above_ma200"] is True + assert market_data["spy_ma20_slope_positive"] is True + assert market_data["qqq_ma20_slope_positive"] is True + assert market_data["soxx_ma20_slope_positive"] is True assert market_data["trend_symbol"] == "SPY" + assert market_data["trend_symbols"] == ("SPY", "QQQ", "SOXX") assert market_data["trend_ma_window"] == 200 assert captured["runtime_config"]["market_data_ma_window"] == 200 assert result.metadata["managed_symbols"] == ("TQQQ", "SOXL", "BOXX") diff --git a/uv.lock b/uv.lock index cce0236..4a7ba91 100644 --- a/uv.lock +++ b/uv.lock @@ -791,7 +791,7 @@ requires-dist = [ { name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=0063af3b4a974650ea58a7d3f26dd1b94f65d3e8" }, { name = "requests" }, { name = "ruff", marker = "extra == 'test'" }, - { name = "us-equity-strategies", git = "https://github.com/QuantStrategyLab/UsEquityStrategies.git?rev=46887bc3f5454d5b59623b1f5efb7c65912c6b8b" }, + { name = "us-equity-strategies", git = "https://github.com/QuantStrategyLab/UsEquityStrategies.git?rev=cd959e30e91e57697ad878d98799c1afb43bf5fb" }, { name = "yfinance" }, ] provides-extras = ["test"] @@ -1490,7 +1490,7 @@ wheels = [ [[package]] name = "us-equity-strategies" version = "0.7.60" -source = { git = "https://github.com/QuantStrategyLab/UsEquityStrategies.git?rev=46887bc3f5454d5b59623b1f5efb7c65912c6b8b#46887bc3f5454d5b59623b1f5efb7c65912c6b8b" } +source = { git = "https://github.com/QuantStrategyLab/UsEquityStrategies.git?rev=cd959e30e91e57697ad878d98799c1afb43bf5fb#cd959e30e91e57697ad878d98799c1afb43bf5fb" } dependencies = [ { name = "pandas" }, { name = "pytz" },