diff --git a/README.md b/README.md index 0d696bda..9dd068ad 100644 --- a/README.md +++ b/README.md @@ -13,14 +13,15 @@ This repository is the strategy layer: it owns pure signal, allocation, and targ ### Strategy index -| Profile | Downstream runtime today | Core idea | -| --- | --- | --- | -| `global_etf_rotation` | `InteractiveBrokersPlatform` | Quarterly top-2 global ETF rotation with a daily canary defense | -| `russell_1000_multi_factor_defensive` | `InteractiveBrokersPlatform` | Russell 1000 price-only monthly stock selection with SPY + breadth defense and BOXX parking | -| `hybrid_growth_income` | `CharlesSchwabPlatform` | QQQ-driven TQQQ attack layer plus SPYI / QQQI income layer and BOXX defense | -| `semiconductor_rotation_income` | `LongBridgePlatform` | SOXL / SOXX trend switch with BOXX parking and an additive income sleeve | +| Canonical profile | Display name | Alias | Compatible platforms | Cadence | Benchmark | Role | Status | +| --- | --- | --- | --- | --- | --- | --- | --- | +| `global_etf_rotation` | Global ETF Rotation Defense | `global_macro_etf_rotation` | `InteractiveBrokersPlatform` | `quarterly + daily canary` | `VOO` | `defensive_rotation` | `runtime_enabled` | +| `russell_1000_multi_factor_defensive` | Russell 1000 Multi-Factor Defensive | `r1000_multifactor_defensive` | `InteractiveBrokersPlatform` | `monthly` | `SPY` | `defensive_stock_baseline` | `runtime_enabled` | +| `cash_buffer_branch_default` | Tech Pullback Cash Buffer | `tech_pullback_cash_buffer` | `InteractiveBrokersPlatform` | `monthly` | `QQQ` | `parallel_cash_buffer_branch` | `paper_dry_run` | +| `hybrid_growth_income` | QQQ/TQQQ Growth Income | `qqq_tqqq_growth_income` | `CharlesSchwabPlatform` | `daily` | `QQQ` | `offensive_income` | `runtime_enabled` | +| `semiconductor_rotation_income` | Semiconductor Trend Income | `semiconductor_trend_income` | `LongBridgePlatform` | `daily` | `SOXX` | `sector_offensive_income` | `runtime_enabled` | -These strategies are consumed by platform repositories through `QuantPlatformKit` strategy contracts and component loaders. +These strategies are consumed by platform repositories through `QuantPlatformKit` strategy contracts and component loaders. Canonical profile keys stay stable for runtime compatibility; display names and aliases are the human-facing layer. Compatibility here means the strategy is structurally usable on that broker stack. Whether a profile is actually enabled, default, or rollback is now owned by each platform repository. ### global_etf_rotation @@ -274,14 +275,15 @@ PYTHONPATH=src:. python3 scripts/backtest_russell_1000_multi_factor_defensive.py ### 策略索引 -| 策略档位 | 当前下游运行仓库 | 核心思路 | -| --- | --- | --- | -| `global_etf_rotation` | `InteractiveBrokersPlatform` | 22 只全球 ETF 的季度 Top 2 轮动,带每日 canary 防守 | -| `russell_1000_multi_factor_defensive` | `InteractiveBrokersPlatform` | Russell 1000 个股月频 price-only 选股,带 SPY + breadth 防守和 BOXX 停泊 | -| `hybrid_growth_income` | `CharlesSchwabPlatform` | 由 QQQ 驱动的 TQQQ 攻击层,加上 SPYI / QQQI 收入层和 BOXX 防守层 | -| `semiconductor_rotation_income` | `LongBridgePlatform` | SOXL / SOXX 趋势切换,剩余资金停在 BOXX,并叠加收入层 | +| Canonical profile | 显示名 | Alias | 当前下游运行仓库 | 核心思路 | +| --- | --- | --- | --- | --- | +| `global_etf_rotation` | 全球 ETF 轮动防守 | `global_macro_etf_rotation` | `InteractiveBrokersPlatform` | 22 只全球 ETF 的季度 Top 2 轮动,带每日 canary 防守 | +| `russell_1000_multi_factor_defensive` | Russell 1000 多因子防守 | `r1000_multifactor_defensive` | `InteractiveBrokersPlatform` | Russell 1000 个股月频 price-only 选股,带 SPY + breadth 防守和 BOXX 停泊 | +| `cash_buffer_branch_default` | 科技回调现金缓冲分支 | `tech_pullback_cash_buffer` | `InteractiveBrokersPlatform` | tech-heavy 月频个股选择,做受控回调,并显式保留 BOXX 缓冲 | +| `hybrid_growth_income` | QQQ/TQQQ 增长收入混合 | `qqq_tqqq_growth_income` | `CharlesSchwabPlatform` | 由 QQQ 驱动的 TQQQ 攻击层,加上 SPYI / QQQI 收入层和 BOXX 防守层 | +| `semiconductor_rotation_income` | 半导体趋势收入增强 | `semiconductor_trend_income` | `LongBridgePlatform` | SOXL / SOXX 趋势切换,剩余资金停在 BOXX,并叠加收入层 | -这些策略通过 `QuantPlatformKit` 提供的策略契约和组件加载接口,被各个平台仓库引用。 +这些策略通过 `QuantPlatformKit` 提供的策略契约和组件加载接口,被各个平台仓库引用。运行时继续使用稳定的 canonical profile key;显示名和 alias 只负责让人更容易看懂。 ### global_etf_rotation diff --git a/src/us_equity_strategies/__init__.py b/src/us_equity_strategies/__init__.py index 51f3077b..bf7244e1 100644 --- a/src/us_equity_strategies/__init__.py +++ b/src/us_equity_strategies/__init__.py @@ -1,7 +1,31 @@ -from .catalog import STRATEGY_DEFINITIONS, get_strategy_definition, get_strategy_definitions - __all__ = [ "STRATEGY_DEFINITIONS", + "get_compatible_platforms", + "get_profile_aliases", + "get_strategy_index_rows", "get_strategy_definition", "get_strategy_definitions", + "get_strategy_metadata", + "get_strategy_metadata_map", + "get_strategy_platform_compatibility_map", + "resolve_canonical_profile", ] + + +def __getattr__(name: str): + if name in { + "STRATEGY_DEFINITIONS", + "get_profile_aliases", + "get_compatible_platforms", + "get_strategy_index_rows", + "get_strategy_definition", + "get_strategy_definitions", + "get_strategy_metadata", + "get_strategy_metadata_map", + "get_strategy_platform_compatibility_map", + "resolve_canonical_profile", + }: + from . import catalog as _catalog + + return getattr(_catalog, name) + 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 ca6740ef..27c36157 100644 --- a/src/us_equity_strategies/catalog.py +++ b/src/us_equity_strategies/catalog.py @@ -1,5 +1,7 @@ from __future__ import annotations +from dataclasses import dataclass + from quant_platform_kit.common.strategies import ( StrategyComponentDefinition, StrategyDefinition, @@ -12,74 +14,222 @@ RUSSELL_1000_MULTI_FACTOR_DEFENSIVE_PROFILE = "russell_1000_multi_factor_defensive" CASH_BUFFER_BRANCH_DEFAULT_PROFILE = "cash_buffer_branch_default" -STRATEGY_DEFINITIONS: dict[str, StrategyDefinition] = { - GLOBAL_ETF_ROTATION_PROFILE: StrategyDefinition( - profile=GLOBAL_ETF_ROTATION_PROFILE, + +STRATEGY_PLATFORM_COMPATIBILITY: dict[str, frozenset[str]] = { + GLOBAL_ETF_ROTATION_PROFILE: frozenset({"ibkr"}), + HYBRID_GROWTH_INCOME_PROFILE: frozenset({"schwab"}), + SEMICONDUCTOR_ROTATION_INCOME_PROFILE: frozenset({"longbridge"}), + RUSSELL_1000_MULTI_FACTOR_DEFENSIVE_PROFILE: frozenset({"ibkr"}), + CASH_BUFFER_BRANCH_DEFAULT_PROFILE: frozenset({"ibkr"}), +} + + +@dataclass(frozen=True) +class StrategyMetadata: + canonical_profile: str + display_name: str + description: str + aliases: tuple[str, ...] = () + cadence: str | None = None + asset_scope: str | None = None + benchmark: str | None = None + role: str | None = None + status: str | None = None + + +# `supported_platforms` 仍保留为兼容镜像,避免一次性改动所有平台 runtime。 +# 平台真正的启用状态由各自 runtime 仓库维护;UES 这里只表达策略层兼容性。 +def _build_strategy_definition( + profile: str, + *, + component_name: str, + module_path: str, +) -> StrategyDefinition: + return StrategyDefinition( + profile=profile, domain=US_EQUITY_DOMAIN, - supported_platforms=frozenset({"ibkr"}), + supported_platforms=STRATEGY_PLATFORM_COMPATIBILITY[profile], components=( StrategyComponentDefinition( - name="signal_logic", - module_path="us_equity_strategies.strategies.global_etf_rotation", + name=component_name, + module_path=module_path, ), ), + ) + + +STRATEGY_DEFINITIONS: dict[str, StrategyDefinition] = { + GLOBAL_ETF_ROTATION_PROFILE: _build_strategy_definition( + GLOBAL_ETF_ROTATION_PROFILE, + component_name="signal_logic", + module_path="us_equity_strategies.strategies.global_etf_rotation", ), - HYBRID_GROWTH_INCOME_PROFILE: StrategyDefinition( - profile=HYBRID_GROWTH_INCOME_PROFILE, - domain=US_EQUITY_DOMAIN, - supported_platforms=frozenset({"schwab"}), - components=( - StrategyComponentDefinition( - name="allocation", - module_path="us_equity_strategies.strategies.hybrid_growth_income", - ), - ), + HYBRID_GROWTH_INCOME_PROFILE: _build_strategy_definition( + HYBRID_GROWTH_INCOME_PROFILE, + component_name="allocation", + module_path="us_equity_strategies.strategies.hybrid_growth_income", ), - SEMICONDUCTOR_ROTATION_INCOME_PROFILE: StrategyDefinition( - profile=SEMICONDUCTOR_ROTATION_INCOME_PROFILE, - domain=US_EQUITY_DOMAIN, - supported_platforms=frozenset({"longbridge"}), - components=( - StrategyComponentDefinition( - name="allocation", - module_path="us_equity_strategies.strategies.semiconductor_rotation_income", - ), - ), + SEMICONDUCTOR_ROTATION_INCOME_PROFILE: _build_strategy_definition( + SEMICONDUCTOR_ROTATION_INCOME_PROFILE, + component_name="allocation", + module_path="us_equity_strategies.strategies.semiconductor_rotation_income", ), - RUSSELL_1000_MULTI_FACTOR_DEFENSIVE_PROFILE: StrategyDefinition( - profile=RUSSELL_1000_MULTI_FACTOR_DEFENSIVE_PROFILE, - domain=US_EQUITY_DOMAIN, - supported_platforms=frozenset({"ibkr"}), - components=( - StrategyComponentDefinition( - name="signal_logic", - module_path="us_equity_strategies.strategies.russell_1000_multi_factor_defensive", - ), - ), + RUSSELL_1000_MULTI_FACTOR_DEFENSIVE_PROFILE: _build_strategy_definition( + RUSSELL_1000_MULTI_FACTOR_DEFENSIVE_PROFILE, + component_name="signal_logic", + module_path="us_equity_strategies.strategies.russell_1000_multi_factor_defensive", ), - CASH_BUFFER_BRANCH_DEFAULT_PROFILE: StrategyDefinition( - profile=CASH_BUFFER_BRANCH_DEFAULT_PROFILE, - domain=US_EQUITY_DOMAIN, - supported_platforms=frozenset({"ibkr"}), - components=( - StrategyComponentDefinition( - name="signal_logic", - module_path="us_equity_strategies.strategies.cash_buffer_branch_default", - ), - ), + CASH_BUFFER_BRANCH_DEFAULT_PROFILE: _build_strategy_definition( + CASH_BUFFER_BRANCH_DEFAULT_PROFILE, + component_name="signal_logic", + module_path="us_equity_strategies.strategies.cash_buffer_branch_default", + ), +} + + +STRATEGY_METADATA: dict[str, StrategyMetadata] = { + GLOBAL_ETF_ROTATION_PROFILE: StrategyMetadata( + canonical_profile=GLOBAL_ETF_ROTATION_PROFILE, + 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",), + cadence="quarterly + daily canary", + asset_scope="global_etf_rotation", + benchmark="VOO", + role="defensive_rotation", + status="runtime_enabled", + ), + HYBRID_GROWTH_INCOME_PROFILE: StrategyMetadata( + canonical_profile=HYBRID_GROWTH_INCOME_PROFILE, + display_name="QQQ/TQQQ Growth Income", + description="QQQ-led TQQQ attack sleeve with SPYI / QQQI income and BOXX defense.", + aliases=("qqq_tqqq_growth_income",), + cadence="daily", + asset_scope="us_equity_etf_plus_income", + benchmark="QQQ", + role="offensive_income", + status="runtime_enabled", ), + SEMICONDUCTOR_ROTATION_INCOME_PROFILE: StrategyMetadata( + canonical_profile=SEMICONDUCTOR_ROTATION_INCOME_PROFILE, + display_name="Semiconductor Trend Income", + description="SOXL / SOXX semiconductor trend switch with BOXX parking and additive income sleeve.", + aliases=("semiconductor_trend_income",), + cadence="daily", + asset_scope="semiconductor_etf_plus_income", + benchmark="SOXX", + role="sector_offensive_income", + status="runtime_enabled", + ), + RUSSELL_1000_MULTI_FACTOR_DEFENSIVE_PROFILE: StrategyMetadata( + canonical_profile=RUSSELL_1000_MULTI_FACTOR_DEFENSIVE_PROFILE, + 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",), + cadence="monthly", + asset_scope="us_large_cap_stocks", + benchmark="SPY", + role="defensive_stock_baseline", + status="runtime_enabled", + ), + CASH_BUFFER_BRANCH_DEFAULT_PROFILE: StrategyMetadata( + canonical_profile=CASH_BUFFER_BRANCH_DEFAULT_PROFILE, + display_name="Tech Pullback Cash Buffer", + description="Tech-heavy monthly stock selection with controlled pullback entry and explicit BOXX cash buffer.", + aliases=("tech_pullback_cash_buffer",), + cadence="monthly", + asset_scope="us_tech_communication_stocks", + benchmark="QQQ", + role="parallel_cash_buffer_branch", + status="paper_dry_run", + ), +} + +PROFILE_ALIASES: dict[str, str] = { + alias: metadata.canonical_profile + for metadata in STRATEGY_METADATA.values() + for alias in metadata.aliases } +def normalize_profile_name(profile: str | None) -> str: + return str(profile or "").strip().lower() + + +def resolve_canonical_profile(profile: str | None) -> str: + normalized = normalize_profile_name(profile) + return PROFILE_ALIASES.get(normalized, normalized) + + def get_strategy_definitions() -> dict[str, StrategyDefinition]: return dict(STRATEGY_DEFINITIONS) +def get_strategy_platform_compatibility_map() -> dict[str, frozenset[str]]: + return dict(STRATEGY_PLATFORM_COMPATIBILITY) + + +def get_compatible_platforms(profile: str) -> frozenset[str]: + canonical = resolve_canonical_profile(profile) + if canonical not in STRATEGY_PLATFORM_COMPATIBILITY: + supported = ", ".join(sorted(STRATEGY_PLATFORM_COMPATIBILITY)) or "" + aliases = ", ".join(sorted(PROFILE_ALIASES)) or "" + raise ValueError( + f"Unknown us_equity strategy profile={profile!r}; supported canonical values: {supported}; aliases: {aliases}" + ) + return STRATEGY_PLATFORM_COMPATIBILITY[canonical] + + def get_strategy_definition(profile: str) -> StrategyDefinition: - normalized = str(profile or "").strip().lower() - if normalized not in STRATEGY_DEFINITIONS: + canonical = resolve_canonical_profile(profile) + if canonical not in STRATEGY_DEFINITIONS: supported = ", ".join(sorted(STRATEGY_DEFINITIONS)) or "" + aliases = ", ".join(sorted(PROFILE_ALIASES)) or "" + raise ValueError( + f"Unknown us_equity strategy profile={profile!r}; supported canonical values: {supported}; aliases: {aliases}" + ) + return STRATEGY_DEFINITIONS[canonical] + + + +def get_strategy_index_rows() -> list[dict[str, object]]: + rows: list[dict[str, object]] = [] + for canonical_profile in sorted(STRATEGY_METADATA): + metadata = STRATEGY_METADATA[canonical_profile] + definition = STRATEGY_DEFINITIONS[canonical_profile] + rows.append( + { + "canonical_profile": metadata.canonical_profile, + "display_name": metadata.display_name, + "aliases": metadata.aliases, + "description": metadata.description, + "cadence": metadata.cadence, + "asset_scope": metadata.asset_scope, + "benchmark": metadata.benchmark, + "role": metadata.role, + "status": metadata.status, + "component_names": tuple(component.name for component in definition.components), + "compatible_platforms": STRATEGY_PLATFORM_COMPATIBILITY[canonical_profile], + } + ) + return rows + + + +def get_strategy_metadata_map() -> dict[str, StrategyMetadata]: + return dict(STRATEGY_METADATA) + + +def get_strategy_metadata(profile: str) -> StrategyMetadata: + canonical = resolve_canonical_profile(profile) + if canonical not in STRATEGY_METADATA: + supported = ", ".join(sorted(STRATEGY_METADATA)) or "" + aliases = ", ".join(sorted(PROFILE_ALIASES)) or "" raise ValueError( - f"Unknown us_equity strategy profile={profile!r}; supported values: {supported}" + f"Unknown us_equity strategy profile={profile!r}; supported canonical values: {supported}; aliases: {aliases}" ) - return STRATEGY_DEFINITIONS[normalized] + return STRATEGY_METADATA[canonical] + + +def get_profile_aliases() -> dict[str, str]: + return dict(PROFILE_ALIASES) diff --git a/src/us_equity_strategies/platform_registry_support.py b/src/us_equity_strategies/platform_registry_support.py new file mode 100644 index 00000000..1425c7fa --- /dev/null +++ b/src/us_equity_strategies/platform_registry_support.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from quant_platform_kit.common.strategies import StrategyDefinition + +from .catalog import ( + get_strategy_definition, + get_strategy_metadata, + resolve_canonical_profile, +) + + +def get_enabled_profiles_for_platform( + platform_id: str, + *, + expected_platform_id: str, + enabled_profiles: frozenset[str], +) -> frozenset[str]: + if platform_id != expected_platform_id: + return frozenset() + return enabled_profiles + + +def build_platform_profile_matrix( + *, + platform_id: str, + enabled_profiles: frozenset[str], + default_profile: str, + rollback_profile: str, +) -> list[dict[str, object]]: + rows: list[dict[str, object]] = [] + for profile in sorted(enabled_profiles): + definition = get_strategy_definition(profile) + metadata = get_strategy_metadata(profile) + rows.append( + { + "platform": platform_id, + "canonical_profile": definition.profile, + "display_name": metadata.display_name, + "aliases": metadata.aliases, + "enabled": True, + "is_default": definition.profile == default_profile, + "is_rollback": definition.profile == rollback_profile, + "domain": definition.domain, + } + ) + return rows + + +def resolve_platform_strategy_definition( + raw_value: str | None, + *, + platform_id: str, + expected_platform_id: str, + enabled_profiles: frozenset[str], + platform_supported_domains: dict[str, frozenset[str]], + default_profile: str | None = None, + require_explicit: bool = False, +) -> StrategyDefinition: + if platform_id != expected_platform_id: + raise ValueError(f"Unsupported platform_id={platform_id!r}") + + normalized = str(raw_value or "").strip() + if require_explicit and not normalized: + raise EnvironmentError("STRATEGY_PROFILE is required") + + candidate = normalized or str(default_profile or "").strip() + if not candidate: + raise EnvironmentError("STRATEGY_PROFILE is required") + + canonical_profile = resolve_canonical_profile(candidate) + supported = ", ".join(sorted(enabled_profiles)) + + if canonical_profile not in enabled_profiles: + raise ValueError( + f"Unsupported STRATEGY_PROFILE={raw_value!r}; supported values: {supported}" + ) + + definition = get_strategy_definition(canonical_profile) + if definition.domain not in platform_supported_domains.get(platform_id, frozenset()): + raise ValueError( + f"Unsupported strategy domain {definition.domain!r} for platform {platform_id!r}" + ) + + return definition diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 561d33e2..056f0d35 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -6,8 +6,16 @@ CASH_BUFFER_BRANCH_DEFAULT_PROFILE, GLOBAL_ETF_ROTATION_PROFILE, HYBRID_GROWTH_INCOME_PROFILE, + RUSSELL_1000_MULTI_FACTOR_DEFENSIVE_PROFILE, SEMICONDUCTOR_ROTATION_INCOME_PROFILE, + get_compatible_platforms, + get_profile_aliases, + get_strategy_index_rows, get_strategy_definition, + get_strategy_metadata, + get_strategy_metadata_map, + get_strategy_platform_compatibility_map, + resolve_canonical_profile, ) @@ -16,19 +24,29 @@ def test_catalog_contains_supported_profiles(self): catalog = get_strategy_definitions() self.assertIn(GLOBAL_ETF_ROTATION_PROFILE, catalog) self.assertEqual(catalog[GLOBAL_ETF_ROTATION_PROFILE].domain, "us_equity") - self.assertEqual(catalog[GLOBAL_ETF_ROTATION_PROFILE].supported_platforms, frozenset({"ibkr"})) + self.assertEqual(get_compatible_platforms(GLOBAL_ETF_ROTATION_PROFILE), frozenset({"ibkr"})) self.assertIn(HYBRID_GROWTH_INCOME_PROFILE, catalog) self.assertEqual(catalog[HYBRID_GROWTH_INCOME_PROFILE].domain, "us_equity") - self.assertEqual(catalog[HYBRID_GROWTH_INCOME_PROFILE].supported_platforms, frozenset({"schwab"})) + self.assertEqual(get_compatible_platforms(HYBRID_GROWTH_INCOME_PROFILE), frozenset({"schwab"})) self.assertIn(SEMICONDUCTOR_ROTATION_INCOME_PROFILE, catalog) self.assertEqual(catalog[SEMICONDUCTOR_ROTATION_INCOME_PROFILE].domain, "us_equity") - self.assertEqual(catalog[SEMICONDUCTOR_ROTATION_INCOME_PROFILE].supported_platforms, frozenset({"longbridge"})) + self.assertEqual(get_compatible_platforms(SEMICONDUCTOR_ROTATION_INCOME_PROFILE), frozenset({"longbridge"})) + + self.assertIn(RUSSELL_1000_MULTI_FACTOR_DEFENSIVE_PROFILE, catalog) + self.assertEqual(catalog[RUSSELL_1000_MULTI_FACTOR_DEFENSIVE_PROFILE].domain, "us_equity") + self.assertEqual(get_compatible_platforms(RUSSELL_1000_MULTI_FACTOR_DEFENSIVE_PROFILE), frozenset({"ibkr"})) self.assertIn(CASH_BUFFER_BRANCH_DEFAULT_PROFILE, catalog) self.assertEqual(catalog[CASH_BUFFER_BRANCH_DEFAULT_PROFILE].domain, "us_equity") - self.assertEqual(catalog[CASH_BUFFER_BRANCH_DEFAULT_PROFILE].supported_platforms, frozenset({"ibkr"})) + self.assertEqual(get_compatible_platforms(CASH_BUFFER_BRANCH_DEFAULT_PROFILE), frozenset({"ibkr"})) + + def test_supported_platforms_remains_only_a_compatibility_mirror(self): + catalog = get_strategy_definitions() + compatibility = get_strategy_platform_compatibility_map() + for profile, definition in catalog.items(): + self.assertEqual(definition.supported_platforms, compatibility[profile]) def test_known_profile_resolves(self): definition = get_strategy_definition("global_etf_rotation") @@ -55,6 +73,14 @@ def test_known_profile_resolves(self): "us_equity_strategies.strategies.semiconductor_rotation_income", ) + ibkr_definition = get_strategy_definition("russell_1000_multi_factor_defensive") + self.assertEqual(ibkr_definition.profile, RUSSELL_1000_MULTI_FACTOR_DEFENSIVE_PROFILE) + ibkr_module = get_strategy_component_map(ibkr_definition)["signal_logic"] + self.assertEqual( + ibkr_module.module_path, + "us_equity_strategies.strategies.russell_1000_multi_factor_defensive", + ) + cash_buffer_definition = get_strategy_definition("cash_buffer_branch_default") self.assertEqual(cash_buffer_definition.profile, CASH_BUFFER_BRANCH_DEFAULT_PROFILE) cash_buffer_module = get_strategy_component_map(cash_buffer_definition)["signal_logic"] @@ -63,6 +89,33 @@ def test_known_profile_resolves(self): "us_equity_strategies.strategies.cash_buffer_branch_default", ) + def test_aliases_resolve_to_canonical_profiles(self): + self.assertEqual(resolve_canonical_profile("tech_pullback_cash_buffer"), CASH_BUFFER_BRANCH_DEFAULT_PROFILE) + self.assertEqual(resolve_canonical_profile("r1000_multifactor_defensive"), RUSSELL_1000_MULTI_FACTOR_DEFENSIVE_PROFILE) + self.assertEqual(resolve_canonical_profile("qqq_tqqq_growth_income"), HYBRID_GROWTH_INCOME_PROFILE) + self.assertEqual(resolve_canonical_profile("semiconductor_trend_income"), SEMICONDUCTOR_ROTATION_INCOME_PROFILE) + self.assertEqual(get_strategy_definition("tech_pullback_cash_buffer").profile, CASH_BUFFER_BRANCH_DEFAULT_PROFILE) + + def test_metadata_map_exposes_display_names_and_roles(self): + metadata_map = get_strategy_metadata_map() + self.assertEqual(metadata_map[CASH_BUFFER_BRANCH_DEFAULT_PROFILE].display_name, "Tech Pullback Cash Buffer") + self.assertEqual(metadata_map[CASH_BUFFER_BRANCH_DEFAULT_PROFILE].role, "parallel_cash_buffer_branch") + self.assertEqual(metadata_map[GLOBAL_ETF_ROTATION_PROFILE].benchmark, "VOO") + self.assertEqual(get_strategy_metadata("tech_pullback_cash_buffer").canonical_profile, CASH_BUFFER_BRANCH_DEFAULT_PROFILE) + aliases = get_profile_aliases() + self.assertEqual(aliases["tech_pullback_cash_buffer"], CASH_BUFFER_BRANCH_DEFAULT_PROFILE) + compatibility = get_strategy_platform_compatibility_map() + self.assertEqual(compatibility[CASH_BUFFER_BRANCH_DEFAULT_PROFILE], frozenset({"ibkr"})) + + def test_strategy_index_rows_are_human_readable(self): + rows = get_strategy_index_rows() + by_profile = {row["canonical_profile"]: row for row in rows} + self.assertEqual(by_profile[CASH_BUFFER_BRANCH_DEFAULT_PROFILE]["display_name"], "Tech Pullback Cash Buffer") + self.assertEqual(by_profile[HYBRID_GROWTH_INCOME_PROFILE]["aliases"], ("qqq_tqqq_growth_income",)) + self.assertIn("signal_logic", by_profile[GLOBAL_ETF_ROTATION_PROFILE]["component_names"]) + self.assertEqual(by_profile[CASH_BUFFER_BRANCH_DEFAULT_PROFILE]["compatible_platforms"], frozenset({"ibkr"})) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_platform_registry_support.py b/tests/test_platform_registry_support.py new file mode 100644 index 00000000..b24bf0ed --- /dev/null +++ b/tests/test_platform_registry_support.py @@ -0,0 +1,57 @@ +import unittest + +from us_equity_strategies.platform_registry_support import ( + build_platform_profile_matrix, + get_enabled_profiles_for_platform, + resolve_platform_strategy_definition, +) + + +class PlatformRegistrySupportTest(unittest.TestCase): + def test_get_enabled_profiles_for_platform_filters_by_platform(self): + enabled = frozenset({"cash_buffer_branch_default"}) + self.assertEqual( + get_enabled_profiles_for_platform( + "ibkr", + expected_platform_id="ibkr", + enabled_profiles=enabled, + ), + enabled, + ) + self.assertEqual( + get_enabled_profiles_for_platform( + "schwab", + expected_platform_id="ibkr", + enabled_profiles=enabled, + ), + frozenset(), + ) + + def test_build_platform_profile_matrix_uses_metadata(self): + rows = build_platform_profile_matrix( + platform_id="ibkr", + enabled_profiles=frozenset({"cash_buffer_branch_default"}), + default_profile="global_etf_rotation", + rollback_profile="global_etf_rotation", + ) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["canonical_profile"], "cash_buffer_branch_default") + self.assertEqual(rows[0]["display_name"], "Tech Pullback Cash Buffer") + self.assertEqual(rows[0]["aliases"], ("tech_pullback_cash_buffer",)) + self.assertFalse(rows[0]["is_default"]) + self.assertFalse(rows[0]["is_rollback"]) + + def test_resolve_platform_strategy_definition_supports_alias(self): + definition = resolve_platform_strategy_definition( + "tech_pullback_cash_buffer", + platform_id="ibkr", + expected_platform_id="ibkr", + enabled_profiles=frozenset({"cash_buffer_branch_default"}), + platform_supported_domains={"ibkr": frozenset({"us_equity"})}, + require_explicit=True, + ) + self.assertEqual(definition.profile, "cash_buffer_branch_default") + + +if __name__ == "__main__": + unittest.main()