diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 936943f0..bf7a5e37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,23 @@ jobs: - name: Checkout uses: actions/checkout@v5 + - name: Resolve QuantPlatformKit ref + id: quant-platform-kit-ref + run: | + set -euo pipefail + ref="main" + if [ -n "${GITHUB_HEAD_REF:-}" ] && git ls-remote --exit-code --heads https://github.com/QuantStrategyLab/QuantPlatformKit.git "${GITHUB_HEAD_REF}" >/dev/null 2>&1; then + ref="${GITHUB_HEAD_REF}" + fi + echo "ref=${ref}" >> "$GITHUB_OUTPUT" + + - name: Checkout QuantPlatformKit + uses: actions/checkout@v5 + with: + repository: QuantStrategyLab/QuantPlatformKit + ref: ${{ steps.quant-platform-kit-ref.outputs.ref }} + path: external/QuantPlatformKit + - name: Setup Python uses: actions/setup-python@v6 with: @@ -21,7 +38,8 @@ jobs: run: | set -euo pipefail python -m pip install --upgrade pip - python -m pip install -e . numpy pandas ruff + python -m pip install -e . numpy pandas pandas_market_calendars pytz ruff + python -m pip install --no-deps -e external/QuantPlatformKit - name: Run Ruff run: | diff --git a/pyproject.toml b/pyproject.toml index 05df1e5c..7b627269 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ description = "Shared US equity strategy catalog and implementations" readme = "README.md" requires-python = ">=3.11" dependencies = [ - "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@6e8cc058b821aea8a54015d4b39e02fbdd3dc198", + "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@5174d9e40f79fffae47450a42e26434145d28b31", ] [tool.setuptools] diff --git a/src/us_equity_strategies/__init__.py b/src/us_equity_strategies/__init__.py index 61ccd330..d50eb463 100644 --- a/src/us_equity_strategies/__init__.py +++ b/src/us_equity_strategies/__init__.py @@ -4,11 +4,13 @@ "get_compatible_platforms", "get_profile_aliases", "get_strategy_catalog", + "get_strategy_entrypoint", "get_strategy_index_rows", "get_strategy_definition", "get_strategy_definitions", "get_strategy_metadata", "get_strategy_metadata_map", + "get_platform_runtime_adapter", "get_strategy_platform_compatibility_map", "resolve_canonical_profile", ] @@ -21,6 +23,7 @@ def __getattr__(name: str): "get_profile_aliases", "get_compatible_platforms", "get_strategy_catalog", + "get_strategy_entrypoint", "get_strategy_index_rows", "get_strategy_definition", "get_strategy_definitions", @@ -32,4 +35,8 @@ def __getattr__(name: str): from . import catalog as _catalog return getattr(_catalog, name) + if name == "get_platform_runtime_adapter": + from .runtime_adapters import get_platform_runtime_adapter as _get_platform_runtime_adapter + + return _get_platform_runtime_adapter raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/us_equity_strategies/catalog.py b/src/us_equity_strategies/catalog.py index 0d35c6cc..061d09b4 100644 --- a/src/us_equity_strategies/catalog.py +++ b/src/us_equity_strategies/catalog.py @@ -4,6 +4,7 @@ StrategyCatalog, StrategyComponentDefinition, StrategyDefinition, + StrategyEntrypointDefinition, StrategyMetadata, US_EQUITY_DOMAIN, build_strategy_catalog, @@ -11,6 +12,7 @@ get_catalog_compatible_platforms, get_catalog_strategy_definition, get_catalog_strategy_metadata, + load_strategy_entrypoint, normalize_profile_name as qpk_normalize_profile_name, resolve_catalog_profile, ) @@ -30,6 +32,108 @@ TECH_PULLBACK_CASH_BUFFER_PROFILE: frozenset({"ibkr"}), } +STRATEGY_REQUIRED_INPUTS: dict[str, frozenset[str]] = { + GLOBAL_ETF_ROTATION_PROFILE: frozenset({"historical_close_loader"}), + HYBRID_GROWTH_INCOME_PROFILE: frozenset({"qqq_history"}), + SEMICONDUCTOR_ROTATION_INCOME_PROFILE: frozenset({"indicators", "account_state"}), + RUSSELL_1000_MULTI_FACTOR_DEFENSIVE_PROFILE: frozenset({"feature_snapshot"}), + TECH_PULLBACK_CASH_BUFFER_PROFILE: frozenset({"feature_snapshot"}), +} + +STRATEGY_DEFAULT_CONFIG: dict[str, dict[str, object]] = { + GLOBAL_ETF_ROTATION_PROFILE: { + "ranking_pool": ( + "EWY", "EWT", "INDA", "FXI", "EWJ", "VGK", "VOO", "XLK", "SMH", "GLD", + "SLV", "USO", "DBA", "XLE", "XLF", "ITA", "XLP", "XLU", "XLV", "IHI", "VNQ", "KRE", + ), + "canary_assets": ("SPY", "EFA", "EEM", "AGG"), + "safe_haven": "BIL", + "top_n": 2, + "hold_bonus": 0.02, + "canary_bad_threshold": 4, + "rebalance_months": (3, 6, 9, 12), + "sma_period": 200, + }, + HYBRID_GROWTH_INCOME_PROFILE: { + "benchmark_symbol": "QQQ", + "managed_symbols": ("TQQQ", "BOXX", "SPYI", "QQQI"), + "income_threshold_usd": 100000.0, + "qqqi_income_ratio": 0.5, + "cash_reserve_ratio": 0.05, + "rebalance_threshold_ratio": 0.01, + "alloc_tier1_breakpoints": (0, 15000, 30000, 70000), + "alloc_tier1_values": (1.0, 0.95, 0.85, 0.70), + "alloc_tier2_breakpoints": (70000, 140000), + "alloc_tier2_values": (0.70, 0.50), + "risk_leverage_factor": 3.0, + "risk_agg_cap": 0.50, + "risk_numerator": 0.30, + "atr_exit_scale": 2.0, + "atr_entry_scale": 2.5, + "exit_line_floor": 0.92, + "exit_line_cap": 0.98, + "entry_line_floor": 1.02, + "entry_line_cap": 1.08, + }, + SEMICONDUCTOR_ROTATION_INCOME_PROFILE: { + "managed_symbols": ("SOXL", "SOXX", "BOXX", "QQQI", "SPYI"), + "trend_ma_window": 150, + "cash_reserve_ratio": 0.03, + "min_trade_ratio": 0.01, + "min_trade_floor": 100.0, + "rebalance_threshold_ratio": 0.01, + "small_account_deploy_ratio": 0.60, + "mid_account_deploy_ratio": 0.57, + "large_account_deploy_ratio": 0.50, + "trade_layer_decay_coeff": 0.04, + "income_layer_start_usd": 150000.0, + "income_layer_max_ratio": 0.15, + "income_layer_qqqi_weight": 0.70, + "income_layer_spyi_weight": 0.30, + }, + RUSSELL_1000_MULTI_FACTOR_DEFENSIVE_PROFILE: { + "benchmark_symbol": "SPY", + "safe_haven": "BOXX", + "holdings_count": 24, + "single_name_cap": 0.06, + "sector_cap": 0.20, + "hold_bonus": 0.15, + "soft_defense_exposure": 0.50, + "hard_defense_exposure": 0.10, + "soft_breadth_threshold": 0.55, + "hard_breadth_threshold": 0.35, + }, + TECH_PULLBACK_CASH_BUFFER_PROFILE: { + "benchmark_symbol": "QQQ", + "safe_haven": "BOXX", + "holdings_count": 8, + "single_name_cap": 0.10, + "sector_cap": 0.40, + "hold_bonus": 0.10, + "risk_on_exposure": 0.80, + "soft_defense_exposure": 0.60, + "hard_defense_exposure": 0.00, + "soft_breadth_threshold": 0.55, + "hard_breadth_threshold": 0.35, + "min_adv20_usd": 50000000.0, + "sector_whitelist": ("Information Technology", "Communication"), + "normalization": "universe_cross_sectional", + "score_template": "balanced_pullback", + "runtime_execution_window_trading_days": 3, + "execution_cash_reserve_ratio": 0.0, + "residual_proxy": "simple_excess_return_vs_QQQ", + }, +} + +STRATEGY_ENTRYPOINT_ATTRIBUTES: dict[str, str] = { + GLOBAL_ETF_ROTATION_PROFILE: "global_etf_rotation_entrypoint", + HYBRID_GROWTH_INCOME_PROFILE: "hybrid_growth_income_entrypoint", + SEMICONDUCTOR_ROTATION_INCOME_PROFILE: "semiconductor_rotation_income_entrypoint", + RUSSELL_1000_MULTI_FACTOR_DEFENSIVE_PROFILE: "russell_1000_multi_factor_defensive_entrypoint", + TECH_PULLBACK_CASH_BUFFER_PROFILE: "tech_pullback_cash_buffer_entrypoint", +} + + # `supported_platforms` 仍保留为兼容镜像,避免一次性改动所有平台 runtime。 # 平台真正的启用状态由各自 runtime 仓库维护;UES 这里只表达策略层兼容性。 def _build_strategy_definition( @@ -48,6 +152,12 @@ def _build_strategy_definition( module_path=module_path, ), ), + entrypoint=StrategyEntrypointDefinition( + module_path="us_equity_strategies.entrypoints", + attribute_name=STRATEGY_ENTRYPOINT_ATTRIBUTES[profile], + ), + required_inputs=STRATEGY_REQUIRED_INPUTS[profile], + default_config=STRATEGY_DEFAULT_CONFIG[profile], ) @@ -180,6 +290,12 @@ def get_strategy_definition(profile: str) -> StrategyDefinition: return get_catalog_strategy_definition(STRATEGY_CATALOG, profile) +def get_strategy_entrypoint(profile: str): + definition = get_strategy_definition(profile) + metadata = get_strategy_metadata(profile) + return load_strategy_entrypoint(definition, metadata=metadata) + + def get_strategy_index_rows() -> list[dict[str, object]]: return build_strategy_index_rows(STRATEGY_CATALOG) diff --git a/src/us_equity_strategies/entrypoints/__init__.py b/src/us_equity_strategies/entrypoints/__init__.py new file mode 100644 index 00000000..6ba0877c --- /dev/null +++ b/src/us_equity_strategies/entrypoints/__init__.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +from quant_platform_kit.strategy_contracts import CallableStrategyEntrypoint, StrategyDecision, StrategyContext + +from us_equity_strategies.manifests import ( + global_etf_rotation_manifest, + hybrid_growth_income_manifest, + russell_1000_multi_factor_defensive_manifest, + semiconductor_rotation_income_manifest, + tech_pullback_cash_buffer_manifest, +) +from us_equity_strategies.strategies import ( + global_etf_rotation as legacy_global_etf_rotation, + hybrid_growth_income as legacy_hybrid_growth_income, + russell_1000_multi_factor_defensive as legacy_russell, + semiconductor_rotation_income as legacy_semiconductor, + tech_pullback_cash_buffer as legacy_tech_pullback, +) + +from ._common import ( + default_signal_text_fn, + default_translator, + get_current_holdings, + merge_runtime_config, + require_market_data, + require_portfolio, + target_values_to_positions, + weights_to_positions, +) + + +"""Unified strategy entrypoints built as adapters over legacy implementations.""" + + +def evaluate_global_etf_rotation(ctx: StrategyContext) -> StrategyDecision: + config = merge_runtime_config(global_etf_rotation_manifest.default_config, ctx) + config["ranking_pool"] = list(config.get("ranking_pool", ())) + config["canary_assets"] = list(config.get("canary_assets", ())) + weights, signal_desc, is_emergency, canary_str = legacy_global_etf_rotation.compute_signals( + ctx.capabilities.get("broker_client"), + get_current_holdings(ctx), + get_historical_close=require_market_data(ctx, "historical_close_loader"), + translator=config.pop("translator", default_translator), + pacing_sec=float(config.pop("pacing_sec", 0.0)), + **config, + ) + diagnostics = { + "signal_description": signal_desc, + "canary_status": canary_str, + "actionable": weights is not None, + } + risk_flags = ("emergency",) if is_emergency else () + return StrategyDecision( + positions=weights_to_positions(weights, safe_haven=str(config.get("safe_haven", "BIL"))), + risk_flags=risk_flags, + diagnostics=diagnostics, + ) + + +GLOBAL_ETF_ROTATION_LEGACY_DOC = "Legacy compute_signals adapter retained for platform compatibility." +legacy_global_etf_rotation.compute_signals.__doc__ = ( + (legacy_global_etf_rotation.compute_signals.__doc__ or "").strip() + "\n\nLegacy adapter: prefer us_equity_strategies entrypoints for new integrations." +).strip() + + +def evaluate_hybrid_growth_income(ctx: StrategyContext) -> StrategyDecision: + config = merge_runtime_config(hybrid_growth_income_manifest.default_config, ctx) + config.pop("managed_symbols", None) + config.pop("benchmark_symbol", None) + plan = legacy_hybrid_growth_income.build_rebalance_plan( + require_market_data(ctx, "qqq_history"), + require_portfolio(ctx), + signal_text_fn=config.pop("signal_text_fn", default_signal_text_fn), + translator=config.pop("translator", default_translator), + **config, + ) + diagnostics = { + "signal_display": plan["sig_display"], + "dashboard": plan["dashboard"], + "threshold": plan["threshold"], + "reserved": plan["reserved"], + "qqq_price": plan["qqq_p"], + "ma200": plan["ma200"], + "exit_line": plan["exit_line"], + "real_buying_power": plan["real_buying_power"], + "total_equity": plan["total_equity"], + } + return StrategyDecision( + positions=target_values_to_positions(plan["target_values"]), + diagnostics=diagnostics, + ) + + +legacy_hybrid_growth_income.build_rebalance_plan.__doc__ = ( + ((legacy_hybrid_growth_income.build_rebalance_plan.__doc__ or "").strip() + + "\n\nLegacy adapter: prefer us_equity_strategies entrypoints for new integrations.") + .strip() +) + + +def evaluate_semiconductor_rotation_income(ctx: StrategyContext) -> StrategyDecision: + config = merge_runtime_config(semiconductor_rotation_income_manifest.default_config, ctx) + config.pop("managed_symbols", None) + plan = legacy_semiconductor.build_rebalance_plan( + require_market_data(ctx, "indicators"), + require_market_data(ctx, "account_state"), + translator=config.pop("translator", default_translator), + **config, + ) + diagnostics = { + "market_status": plan["market_status"], + "signal_message": plan["signal_message"], + "deploy_ratio_text": plan["deploy_ratio_text"], + "income_ratio_text": plan["income_ratio_text"], + "income_locked_ratio_text": plan["income_locked_ratio_text"], + "active_risk_asset": plan["active_risk_asset"], + "investable_cash": plan["investable_cash"], + "threshold_value": plan["threshold_value"], + "current_min_trade": plan["current_min_trade"], + "total_strategy_equity": plan["total_strategy_equity"], + } + return StrategyDecision( + positions=target_values_to_positions(plan["targets"]), + diagnostics=diagnostics, + ) + + +legacy_semiconductor.build_rebalance_plan.__doc__ = ( + ((legacy_semiconductor.build_rebalance_plan.__doc__ or "").strip() + + "\n\nLegacy adapter: prefer us_equity_strategies entrypoints for new integrations.") + .strip() +) + + +def evaluate_russell_1000_multi_factor_defensive(ctx: StrategyContext) -> StrategyDecision: + config = merge_runtime_config(russell_1000_multi_factor_defensive_manifest.default_config, ctx) + weights, signal_desc, is_emergency, status_desc, metadata = legacy_russell.compute_signals( + require_market_data(ctx, "feature_snapshot"), + get_current_holdings(ctx), + **config, + ) + diagnostics = { + **metadata, + "signal_description": signal_desc, + "status_description": status_desc, + "signal_source": legacy_russell.SIGNAL_SOURCE, + } + risk_flags = ("hard_defense",) if is_emergency else () + return StrategyDecision( + positions=weights_to_positions(weights, safe_haven=str(config.get("safe_haven", "BOXX"))), + risk_flags=risk_flags, + diagnostics=diagnostics, + ) + + +legacy_russell.compute_signals.__doc__ = ( + ((legacy_russell.compute_signals.__doc__ or "").strip() + + "\n\nLegacy adapter: prefer us_equity_strategies entrypoints for new integrations.") + .strip() +) + + +def evaluate_tech_pullback_cash_buffer(ctx: StrategyContext) -> StrategyDecision: + config = merge_runtime_config(tech_pullback_cash_buffer_manifest.default_config, ctx) + config.pop("execution_cash_reserve_ratio", None) + weights, signal_desc, is_emergency, status_desc, metadata = legacy_tech_pullback.compute_signals( + require_market_data(ctx, "feature_snapshot"), + get_current_holdings(ctx), + **config, + ) + diagnostics = { + **metadata, + "signal_description": signal_desc, + "status_description": status_desc, + "signal_source": legacy_tech_pullback.SIGNAL_SOURCE, + "actionable": weights is not None, + } + risk_flags: tuple[str, ...] = () + if is_emergency: + risk_flags += ("hard_defense",) + if weights is None: + risk_flags += ("no_execute",) + return StrategyDecision( + positions=weights_to_positions(weights, safe_haven=str(config.get("safe_haven", "BOXX"))), + risk_flags=risk_flags, + diagnostics=diagnostics, + ) + + +legacy_tech_pullback.compute_signals.__doc__ = ( + ((legacy_tech_pullback.compute_signals.__doc__ or "").strip() + + "\n\nLegacy adapter: prefer us_equity_strategies entrypoints for new integrations.") + .strip() +) + + +global_etf_rotation_entrypoint = CallableStrategyEntrypoint( + manifest=global_etf_rotation_manifest, + _evaluate=evaluate_global_etf_rotation, +) +hybrid_growth_income_entrypoint = CallableStrategyEntrypoint( + manifest=hybrid_growth_income_manifest, + _evaluate=evaluate_hybrid_growth_income, +) +semiconductor_rotation_income_entrypoint = CallableStrategyEntrypoint( + manifest=semiconductor_rotation_income_manifest, + _evaluate=evaluate_semiconductor_rotation_income, +) +russell_1000_multi_factor_defensive_entrypoint = CallableStrategyEntrypoint( + manifest=russell_1000_multi_factor_defensive_manifest, + _evaluate=evaluate_russell_1000_multi_factor_defensive, +) +tech_pullback_cash_buffer_entrypoint = CallableStrategyEntrypoint( + manifest=tech_pullback_cash_buffer_manifest, + _evaluate=evaluate_tech_pullback_cash_buffer, +) + + +__all__ = [ + "global_etf_rotation_entrypoint", + "hybrid_growth_income_entrypoint", + "semiconductor_rotation_income_entrypoint", + "russell_1000_multi_factor_defensive_entrypoint", + "tech_pullback_cash_buffer_entrypoint", + "evaluate_global_etf_rotation", + "evaluate_hybrid_growth_income", + "evaluate_semiconductor_rotation_income", + "evaluate_russell_1000_multi_factor_defensive", + "evaluate_tech_pullback_cash_buffer", +] diff --git a/src/us_equity_strategies/entrypoints/_common.py b/src/us_equity_strategies/entrypoints/_common.py new file mode 100644 index 00000000..842b261a --- /dev/null +++ b/src/us_equity_strategies/entrypoints/_common.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from collections.abc import Mapping + +from quant_platform_kit.strategy_contracts import PositionTarget, StrategyContext + + +SAFE_HAVENS = {"BIL", "BOXX"} +INCOME_SYMBOLS = {"SPYI", "QQQI"} + + +def merge_runtime_config(manifest_default_config: Mapping[str, object], ctx: StrategyContext) -> dict[str, object]: + config = dict(manifest_default_config) + config.update(dict(ctx.runtime_config)) + return config + + +def get_current_holdings(ctx: StrategyContext): + if "current_holdings" in ctx.state: + return ctx.state["current_holdings"] + if ctx.portfolio is not None and hasattr(ctx.portfolio, "positions"): + return tuple(getattr(position, "symbol", position) for position in ctx.portfolio.positions) + return () + + +def require_market_data(ctx: StrategyContext, key: str): + if key not in ctx.market_data: + raise ValueError(f"StrategyContext.market_data missing required key: {key}") + return ctx.market_data[key] + + +def require_portfolio(ctx: StrategyContext): + if ctx.portfolio is None: + raise ValueError("StrategyContext.portfolio is required for this entrypoint") + return ctx.portfolio + + +def default_translator(key: str, **kwargs) -> str: + if not kwargs: + return key + pairs = ", ".join(f"{name}={value}" for name, value in sorted(kwargs.items())) + return f"{key}({pairs})" + + +def default_signal_text_fn(icon: str) -> str: + return str(icon) + + +def weights_to_positions(weights, *, safe_haven: str | None = None) -> tuple[PositionTarget, ...]: + if not weights: + return () + positions: list[PositionTarget] = [] + for symbol, weight in sorted(weights.items()): + role = "safe_haven" if safe_haven and symbol == safe_haven else None + positions.append(PositionTarget(symbol=symbol, target_weight=float(weight), role=role)) + return tuple(positions) + + +def target_values_to_positions(target_values: Mapping[str, float]) -> tuple[PositionTarget, ...]: + positions: list[PositionTarget] = [] + for symbol, value in sorted(target_values.items()): + role = None + if symbol in SAFE_HAVENS: + role = "safe_haven" + elif symbol in INCOME_SYMBOLS: + role = "income" + positions.append(PositionTarget(symbol=symbol, target_value=float(value), role=role)) + return tuple(positions) diff --git a/src/us_equity_strategies/manifests/__init__.py b/src/us_equity_strategies/manifests/__init__.py new file mode 100644 index 00000000..20ddf899 --- /dev/null +++ b/src/us_equity_strategies/manifests/__init__.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +from quant_platform_kit.strategy_contracts import StrategyManifest + + +def _manifest( + *, + profile: str, + display_name: str, + description: str, + aliases: tuple[str, ...] = (), + required_inputs: frozenset[str] = frozenset(), + default_config: dict[str, object] | None = None, +) -> StrategyManifest: + return StrategyManifest( + profile=profile, + domain="us_equity", + display_name=display_name, + description=description, + aliases=aliases, + required_inputs=required_inputs, + default_config=default_config or {}, + ) + + +global_etf_rotation_manifest = _manifest( + profile="global_etf_rotation", + display_name="Global ETF Rotation Defense", + description="Quarterly top-2 global ETF rotation with daily canary defense and BIL safe haven.", + aliases=("global_macro_etf_rotation",), + required_inputs=frozenset({"historical_close_loader"}), + default_config={ + "ranking_pool": ( + "EWY", + "EWT", + "INDA", + "FXI", + "EWJ", + "VGK", + "VOO", + "XLK", + "SMH", + "GLD", + "SLV", + "USO", + "DBA", + "XLE", + "XLF", + "ITA", + "XLP", + "XLU", + "XLV", + "IHI", + "VNQ", + "KRE", + ), + "canary_assets": ("SPY", "EFA", "EEM", "AGG"), + "safe_haven": "BIL", + "top_n": 2, + "hold_bonus": 0.02, + "canary_bad_threshold": 4, + "rebalance_months": (3, 6, 9, 12), + "sma_period": 200, + }, +) + +hybrid_growth_income_manifest = _manifest( + profile="hybrid_growth_income", + display_name="QQQ/TQQQ Growth Income", + description="QQQ-led TQQQ attack sleeve with SPYI / QQQI income and BOXX defense.", + aliases=("qqq_tqqq_growth_income",), + required_inputs=frozenset({"qqq_history"}), + default_config={ + "benchmark_symbol": "QQQ", + "managed_symbols": ("TQQQ", "BOXX", "SPYI", "QQQI"), + "income_threshold_usd": 100000.0, + "qqqi_income_ratio": 0.5, + "cash_reserve_ratio": 0.05, + "rebalance_threshold_ratio": 0.01, + "alloc_tier1_breakpoints": (0, 15000, 30000, 70000), + "alloc_tier1_values": (1.0, 0.95, 0.85, 0.70), + "alloc_tier2_breakpoints": (70000, 140000), + "alloc_tier2_values": (0.70, 0.50), + "risk_leverage_factor": 3.0, + "risk_agg_cap": 0.50, + "risk_numerator": 0.30, + "atr_exit_scale": 2.0, + "atr_entry_scale": 2.5, + "exit_line_floor": 0.92, + "exit_line_cap": 0.98, + "entry_line_floor": 1.02, + "entry_line_cap": 1.08, + }, +) + +semiconductor_rotation_income_manifest = _manifest( + profile="semiconductor_rotation_income", + display_name="Semiconductor Trend Income", + description="SOXL / SOXX semiconductor trend switch with BOXX parking and additive income sleeve.", + aliases=("semiconductor_trend_income",), + required_inputs=frozenset({"indicators", "account_state"}), + default_config={ + "managed_symbols": ("SOXL", "SOXX", "BOXX", "QQQI", "SPYI"), + "trend_ma_window": 150, + "cash_reserve_ratio": 0.03, + "min_trade_ratio": 0.01, + "min_trade_floor": 100.0, + "rebalance_threshold_ratio": 0.01, + "small_account_deploy_ratio": 0.60, + "mid_account_deploy_ratio": 0.57, + "large_account_deploy_ratio": 0.50, + "trade_layer_decay_coeff": 0.04, + "income_layer_start_usd": 150000.0, + "income_layer_max_ratio": 0.15, + "income_layer_qqqi_weight": 0.70, + "income_layer_spyi_weight": 0.30, + }, +) + +russell_1000_multi_factor_defensive_manifest = _manifest( + profile="russell_1000_multi_factor_defensive", + display_name="Russell 1000 Multi-Factor Defensive", + description="Monthly price-only Russell 1000 stock selection with SPY+breadth defense and BOXX parking.", + aliases=("r1000_multifactor_defensive",), + required_inputs=frozenset({"feature_snapshot"}), + default_config={ + "benchmark_symbol": "SPY", + "safe_haven": "BOXX", + "holdings_count": 24, + "single_name_cap": 0.06, + "sector_cap": 0.20, + "hold_bonus": 0.15, + "soft_defense_exposure": 0.50, + "hard_defense_exposure": 0.10, + "soft_breadth_threshold": 0.55, + "hard_breadth_threshold": 0.35, + }, +) + +tech_pullback_cash_buffer_manifest = _manifest( + profile="tech_pullback_cash_buffer", + display_name="Tech Pullback Cash Buffer", + description="Tech-heavy monthly stock selection with controlled pullback entry and explicit BOXX cash buffer.", + required_inputs=frozenset({"feature_snapshot"}), + default_config={ + "benchmark_symbol": "QQQ", + "safe_haven": "BOXX", + "holdings_count": 8, + "single_name_cap": 0.10, + "sector_cap": 0.40, + "hold_bonus": 0.10, + "risk_on_exposure": 0.80, + "soft_defense_exposure": 0.60, + "hard_defense_exposure": 0.00, + "soft_breadth_threshold": 0.55, + "hard_breadth_threshold": 0.35, + "min_adv20_usd": 50000000.0, + "sector_whitelist": ("Information Technology", "Communication"), + "normalization": "universe_cross_sectional", + "score_template": "balanced_pullback", + "runtime_execution_window_trading_days": 3, + "execution_cash_reserve_ratio": 0.0, + "residual_proxy": "simple_excess_return_vs_QQQ", + }, +) + +MANIFESTS = { + global_etf_rotation_manifest.profile: global_etf_rotation_manifest, + hybrid_growth_income_manifest.profile: hybrid_growth_income_manifest, + semiconductor_rotation_income_manifest.profile: semiconductor_rotation_income_manifest, + russell_1000_multi_factor_defensive_manifest.profile: russell_1000_multi_factor_defensive_manifest, + tech_pullback_cash_buffer_manifest.profile: tech_pullback_cash_buffer_manifest, +} + + +def get_strategy_manifest(profile: str) -> StrategyManifest: + return MANIFESTS[profile] + + +__all__ = [ + "MANIFESTS", + "get_strategy_manifest", + "global_etf_rotation_manifest", + "hybrid_growth_income_manifest", + "semiconductor_rotation_income_manifest", + "russell_1000_multi_factor_defensive_manifest", + "tech_pullback_cash_buffer_manifest", +] diff --git a/src/us_equity_strategies/runtime_adapters.py b/src/us_equity_strategies/runtime_adapters.py new file mode 100644 index 00000000..6ff587d2 --- /dev/null +++ b/src/us_equity_strategies/runtime_adapters.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from quant_platform_kit.strategy_contracts import ( + StrategyRuntimeAdapter, + validate_strategy_runtime_adapter, +) + +from us_equity_strategies.catalog import resolve_canonical_profile +from us_equity_strategies.strategies import ( + russell_1000_multi_factor_defensive as legacy_russell, + tech_pullback_cash_buffer as legacy_tech_pullback, +) + + +IBKR_PLATFORM = "ibkr" + + +IBKR_RUNTIME_ADAPTERS: dict[str, StrategyRuntimeAdapter] = { + "global_etf_rotation": StrategyRuntimeAdapter( + status_icon="🐤", + ), + "russell_1000_multi_factor_defensive": StrategyRuntimeAdapter( + status_icon=legacy_russell.STATUS_ICON, + required_feature_columns=legacy_russell.REQUIRED_FEATURE_COLUMNS, + managed_symbols_extractor=legacy_russell.extract_managed_symbols, + ), + "tech_pullback_cash_buffer": StrategyRuntimeAdapter( + status_icon=legacy_tech_pullback.STATUS_ICON, + required_feature_columns=legacy_tech_pullback.REQUIRED_FEATURE_COLUMNS, + snapshot_date_columns=legacy_tech_pullback.SNAPSHOT_DATE_COLUMNS, + max_snapshot_month_lag=legacy_tech_pullback.MAX_SNAPSHOT_MONTH_LAG, + require_snapshot_manifest=legacy_tech_pullback.REQUIRE_SNAPSHOT_MANIFEST, + snapshot_contract_version=legacy_tech_pullback.SNAPSHOT_CONTRACT_VERSION, + runtime_parameter_loader=legacy_tech_pullback.load_runtime_parameters, + managed_symbols_extractor=legacy_tech_pullback.extract_managed_symbols, + ), +} + +PLATFORM_RUNTIME_ADAPTERS: dict[str, dict[str, StrategyRuntimeAdapter]] = { + IBKR_PLATFORM: IBKR_RUNTIME_ADAPTERS, +} + + +def get_platform_runtime_adapter(profile: str | None, *, platform_id: str) -> StrategyRuntimeAdapter: + canonical_profile = resolve_canonical_profile(profile) + adapters = PLATFORM_RUNTIME_ADAPTERS.get(str(platform_id).strip().lower()) + if adapters is None: + raise ValueError(f"Unsupported platform runtime adapter lookup for {platform_id!r}") + try: + adapter = adapters[canonical_profile] + except KeyError as exc: + raise ValueError( + f"Strategy profile {canonical_profile!r} has no runtime adapter for platform {platform_id!r}" + ) from exc + return validate_strategy_runtime_adapter(adapter) + + +__all__ = [ + "IBKR_PLATFORM", + "IBKR_RUNTIME_ADAPTERS", + "PLATFORM_RUNTIME_ADAPTERS", + "get_platform_runtime_adapter", +] diff --git a/tests/test_entrypoints.py b/tests/test_entrypoints.py new file mode 100644 index 00000000..17677878 --- /dev/null +++ b/tests/test_entrypoints.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +import unittest + +import pandas as pd + +from quant_platform_kit.common.models import PortfolioSnapshot, Position +from quant_platform_kit.strategy_contracts import StrategyContext +from us_equity_strategies import get_platform_runtime_adapter, get_strategy_entrypoint +from us_equity_strategies.strategies.global_etf_rotation import compute_signals as legacy_global_compute_signals +from us_equity_strategies.strategies.hybrid_growth_income import build_rebalance_plan as legacy_hybrid_build_rebalance_plan +from us_equity_strategies.strategies.semiconductor_rotation_income import build_rebalance_plan as legacy_semiconductor_build_rebalance_plan +from us_equity_strategies.strategies.russell_1000_multi_factor_defensive import extract_managed_symbols as legacy_russell_managed_symbols +from us_equity_strategies.strategies.tech_pullback_cash_buffer import extract_managed_symbols as legacy_tech_managed_symbols + +from tests.test_russell_1000_multi_factor_defensive import _normal_snapshot +from tests.test_tech_pullback_cash_buffer import _feature_snapshot + + +class StrategyEntrypointTests(unittest.TestCase): + def test_all_live_profiles_expose_unified_entrypoints(self) -> None: + for profile in ( + "global_etf_rotation", + "hybrid_growth_income", + "semiconductor_rotation_income", + "russell_1000_multi_factor_defensive", + "tech_pullback_cash_buffer", + ): + entrypoint = get_strategy_entrypoint(profile) + self.assertEqual(entrypoint.manifest.profile, profile) + + def test_global_etf_rotation_entrypoint_matches_legacy_emergency_weights(self) -> None: + entrypoint = get_strategy_entrypoint("global_etf_rotation") + index = pd.date_range("2024-01-01", periods=320, freq="B") + price_series = pd.Series([100.0 + (i * 0.1) for i in range(len(index))], index=index) + + def get_historical_close(_ib, _ticker): + return price_series + + legacy_weights, legacy_signal, legacy_is_emergency, legacy_canary = legacy_global_compute_signals( + None, + current_holdings={"VOO"}, + get_historical_close=get_historical_close, + translator=lambda key, **kwargs: f"{key}:{kwargs}", + pacing_sec=0.0, + canary_bad_threshold=0, + ) + + decision = entrypoint.evaluate( + StrategyContext( + as_of="2026-04-06", + market_data={"historical_close_loader": get_historical_close}, + state={"current_holdings": {"VOO"}}, + runtime_config={ + "translator": lambda key, **kwargs: f"{key}:{kwargs}", + "canary_bad_threshold": 0, + }, + ) + ) + + self.assertTrue(legacy_is_emergency) + self.assertEqual(decision.risk_flags, ("emergency",)) + self.assertEqual({p.symbol: p.target_weight for p in decision.positions}, legacy_weights) + self.assertEqual(decision.diagnostics["signal_description"], legacy_signal) + self.assertEqual(decision.diagnostics["canary_status"], legacy_canary) + + def test_hybrid_growth_income_entrypoint_maps_target_values_without_platform_layout(self) -> None: + entrypoint = get_strategy_entrypoint("hybrid_growth_income") + qqq_history = [ + { + "close": 300.0 + day * 0.4, + "high": 301.0 + day * 0.4, + "low": 299.0 + day * 0.4, + } + for day in range(260) + ] + snapshot = PortfolioSnapshot( + as_of=pd.Timestamp("2026-04-06").to_pydatetime(), + total_equity=120000.0, + buying_power=20000.0, + positions=( + Position(symbol="TQQQ", quantity=10, market_value=8000.0), + Position(symbol="BOXX", quantity=20, market_value=4000.0), + Position(symbol="SPYI", quantity=30, market_value=1500.0), + Position(symbol="QQQI", quantity=30, market_value=1700.0), + ), + metadata={"account_hash": "demo"}, + ) + legacy_plan = legacy_hybrid_build_rebalance_plan( + qqq_history, + snapshot, + signal_text_fn=str, + translator=lambda key, **kwargs: key, + **{ + key: value + for key, value in entrypoint.manifest.default_config.items() + if key not in {"benchmark_symbol", "managed_symbols"} + }, + ) + + decision = entrypoint.evaluate( + StrategyContext( + as_of="2026-04-06", + market_data={"qqq_history": qqq_history}, + portfolio=snapshot, + runtime_config={"signal_text_fn": str, "translator": lambda key, **kwargs: key}, + ) + ) + + self.assertEqual( + {position.symbol: position.target_value for position in decision.positions}, + legacy_plan["target_values"], + ) + self.assertNotIn("sell_order_symbols", decision.diagnostics) + self.assertNotIn("portfolio_rows", decision.diagnostics) + self.assertEqual(decision.diagnostics["threshold"], legacy_plan["threshold"]) + self.assertEqual( + entrypoint.manifest.default_config["managed_symbols"], + ("TQQQ", "BOXX", "SPYI", "QQQI"), + ) + + def test_semiconductor_rotation_income_entrypoint_maps_target_values_without_execution_fields(self) -> None: + entrypoint = get_strategy_entrypoint("semiconductor_rotation_income") + indicators = {"soxl": {"price": 80.0, "ma_trend": 75.0}} + account_state = { + "available_cash": 10000.0, + "market_values": {"SOXL": 0.0, "SOXX": 0.0, "BOXX": 5000.0, "QQQI": 1000.0, "SPYI": 1000.0}, + "quantities": {"SOXL": 0, "SOXX": 0, "BOXX": 50, "QQQI": 10, "SPYI": 10}, + "sellable_quantities": {"SOXL": 0, "SOXX": 0, "BOXX": 50, "QQQI": 10, "SPYI": 10}, + "total_strategy_equity": 50000.0, + } + legacy_plan = legacy_semiconductor_build_rebalance_plan( + indicators, + account_state, + translator=lambda key, **kwargs: key, + **{ + key: value + for key, value in entrypoint.manifest.default_config.items() + if key != "managed_symbols" + }, + ) + + decision = entrypoint.evaluate( + StrategyContext( + as_of="2026-04-06", + market_data={"indicators": indicators, "account_state": account_state}, + runtime_config={"translator": lambda key, **kwargs: key}, + ) + ) + + self.assertEqual( + {position.symbol: position.target_value for position in decision.positions}, + legacy_plan["targets"], + ) + self.assertNotIn("limit_order_symbols", decision.diagnostics) + self.assertNotIn("portfolio_rows", decision.diagnostics) + self.assertEqual(decision.diagnostics["active_risk_asset"], legacy_plan["active_risk_asset"]) + self.assertEqual( + entrypoint.manifest.default_config["managed_symbols"], + ("SOXL", "SOXX", "BOXX", "QQQI", "SPYI"), + ) + + def test_russell_and_tech_entrypoints_match_legacy_weight_outputs(self) -> None: + russell = get_strategy_entrypoint("russell_1000_multi_factor_defensive") + russell_decision = russell.evaluate( + StrategyContext( + as_of="2026-04-06", + market_data={"feature_snapshot": _normal_snapshot()}, + state={"current_holdings": {"BBB"}}, + runtime_config={ + "holdings_count": 3, + "single_name_cap": 0.40, + "sector_cap": 0.50, + "hold_bonus": 5.0, + }, + ) + ) + self.assertIn("BBB", {position.symbol for position in russell_decision.positions}) + self.assertEqual(russell_decision.diagnostics["signal_source"], "feature_snapshot") + + tech = get_strategy_entrypoint("tech_pullback_cash_buffer") + tech_decision = tech.evaluate( + StrategyContext( + as_of="2026-04-01", + market_data={"feature_snapshot": _feature_snapshot()}, + state={"current_holdings": {"AAPL"}}, + ) + ) + self.assertIn("BOXX", {position.symbol for position in tech_decision.positions}) + self.assertNotIn("portfolio_rows", tech_decision.diagnostics) + self.assertEqual(tech_decision.diagnostics["signal_source"], "feature_snapshot") + + def test_ibkr_runtime_adapters_expose_unified_snapshot_runtime_metadata(self) -> None: + global_adapter = get_platform_runtime_adapter("global_macro_etf_rotation", platform_id="ibkr") + self.assertEqual(global_adapter.status_icon, "🐤") + + russell_adapter = get_platform_runtime_adapter( + "r1000_multifactor_defensive", + platform_id="ibkr", + ) + self.assertEqual(russell_adapter.status_icon, "📏") + self.assertEqual( + set(russell_adapter.required_feature_columns), + {"symbol", "sector", "mom_6_1", "mom_12_1", "sma200_gap", "vol_63", "maxdd_126"}, + ) + self.assertEqual( + russell_adapter.managed_symbols_extractor( + _normal_snapshot(), + benchmark_symbol="SPY", + safe_haven="BOXX", + ), + legacy_russell_managed_symbols( + _normal_snapshot(), + benchmark_symbol="SPY", + safe_haven="BOXX", + ), + ) + + tech_adapter = get_platform_runtime_adapter("tech_pullback_cash_buffer", platform_id="ibkr") + self.assertEqual(tech_adapter.status_icon, "🧲") + self.assertEqual(tech_adapter.snapshot_date_columns, ("as_of", "snapshot_date")) + self.assertTrue(tech_adapter.require_snapshot_manifest) + self.assertEqual( + tech_adapter.managed_symbols_extractor( + _feature_snapshot(), + benchmark_symbol="QQQ", + safe_haven="BOXX", + ), + legacy_tech_managed_symbols( + _feature_snapshot(), + benchmark_symbol="QQQ", + safe_haven="BOXX", + ), + ) + self.assertEqual( + tech_adapter.runtime_parameter_loader( + config_path=None, + logger=lambda _message: None, + )["runtime_config_name"], + "tech_pullback_cash_buffer", + )