From 5df6f160793b91da2ab529879a1fa3d900a53d97 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 20 Jun 2026 03:46:49 +0800 Subject: [PATCH] Consume unified technical DCA signals --- .../entrypoints/__init__.py | 19 ++- src/us_equity_strategies/signals/__init__.py | 2 + .../signals/runtime_market_signal_inputs.py | 1 + .../signals/signal_bundle_contract.py | 30 +++- .../strategies/nasdaq_sp500_smart_dca.py | 88 +++++++++- tests/test_nasdaq_sp500_smart_dca.py | 82 ++++++++- tests/test_runtime_market_signal_inputs.py | 18 +- tests/test_signal_bundle_cli.py | 155 +++++++++++++++--- tests/test_signal_bundle_contract.py | 111 ++++++++++--- tests/test_smart_dca_research_cli.py | 81 ++++++--- 10 files changed, 519 insertions(+), 68 deletions(-) diff --git a/src/us_equity_strategies/entrypoints/__init__.py b/src/us_equity_strategies/entrypoints/__init__.py index 90ed195..5498686 100644 --- a/src/us_equity_strategies/entrypoints/__init__.py +++ b/src/us_equity_strategies/entrypoints/__init__.py @@ -243,7 +243,12 @@ def _evaluate_global_etf_rotation_with_manifest(ctx: StrategyContext, *, manifes config["canary_assets"] = list(config.get("canary_assets", ())) config.pop("signal_effective_after_trading_days", None) pop_execution_only_config(config) - market_history = require_market_data(ctx, "market_history") + market_history = ctx.market_data.get("market_history") + if market_history is None: + def _empty_market_history(_client, _symbol): + return () + + market_history = _empty_market_history translator = config.pop("translator", default_translator) config.pop("signal_text_fn", None) weights, signal_desc, is_emergency, canary_str = legacy_global_etf_rotation.compute_signals( @@ -995,7 +1000,16 @@ def evaluate_nasdaq_sp500_smart_dca(ctx: StrategyContext) -> StrategyDecision: config.pop("pacing_sec", None) reserved_cash_policy = pop_reserved_cash_policy_config(config) pop_execution_only_config(config) - market_history = require_market_data(ctx, "market_history") + market_history = ctx.market_data.get("market_history") + if market_history is None: + def _empty_market_history(_client, _symbol): + return () + + market_history = _empty_market_history + technical_indicator_snapshot = ( + ctx.market_data.get("technical_indicator_snapshot") + or ctx.market_data.get("derived_indicators") + ) portfolio = require_portfolio(ctx) apply_reserved_cash_policy_to_usd_config( config, @@ -1006,6 +1020,7 @@ def evaluate_nasdaq_sp500_smart_dca(ctx: StrategyContext) -> StrategyDecision: market_history, portfolio, as_of=ctx.as_of, + technical_indicator_snapshot=technical_indicator_snapshot, broker_client=ctx.capabilities.get("broker_client"), translator=translator, **config, diff --git a/src/us_equity_strategies/signals/__init__.py b/src/us_equity_strategies/signals/__init__.py index 156b1d7..93a1495 100644 --- a/src/us_equity_strategies/signals/__init__.py +++ b/src/us_equity_strategies/signals/__init__.py @@ -70,6 +70,7 @@ IBIT_SMART_DCA_MARKET_SIGNAL_CONSUMER, MARKET_SIGNAL_FALLBACK_MODE_LAST_VALID, MARKET_SIGNAL_FALLBACK_MODE_NONE, + NASDAQ_SP500_SMART_DCA_MARKET_SIGNAL_CONSUMER, MARKET_SIGNAL_REFERENCE_CONSUMPTION_AUDIT, MARKET_SIGNAL_REFERENCE_PLATFORM_HANDOFF, MARKET_SIGNAL_REFERENCE_PLATFORM_HANDOFF_INDEX, @@ -147,6 +148,7 @@ "IBIT_SMART_DCA_MARKET_SIGNAL_CONSUMER", "MARKET_SIGNAL_FALLBACK_MODE_LAST_VALID", "MARKET_SIGNAL_FALLBACK_MODE_NONE", + "NASDAQ_SP500_SMART_DCA_MARKET_SIGNAL_CONSUMER", "MARKET_SIGNAL_REFERENCE_CONSUMPTION_AUDIT", "MARKET_SIGNAL_REFERENCE_PLATFORM_HANDOFF", "MARKET_SIGNAL_REFERENCE_PLATFORM_HANDOFF_INDEX", diff --git a/src/us_equity_strategies/signals/runtime_market_signal_inputs.py b/src/us_equity_strategies/signals/runtime_market_signal_inputs.py index 3b10958..c2661e8 100644 --- a/src/us_equity_strategies/signals/runtime_market_signal_inputs.py +++ b/src/us_equity_strategies/signals/runtime_market_signal_inputs.py @@ -22,6 +22,7 @@ IBIT_SMART_DCA_MARKET_SIGNAL_CONSUMER = "us_equity:ibit_smart_dca" +NASDAQ_SP500_SMART_DCA_MARKET_SIGNAL_CONSUMER = "us_equity:nasdaq_sp500_smart_dca" MARKET_SIGNAL_REFERENCE_CONSUMPTION_AUDIT = "consumption_audit" MARKET_SIGNAL_REFERENCE_PLATFORM_HANDOFF = "platform_handoff" MARKET_SIGNAL_REFERENCE_PLATFORM_HANDOFF_INDEX = "platform_handoff_index" diff --git a/src/us_equity_strategies/signals/signal_bundle_contract.py b/src/us_equity_strategies/signals/signal_bundle_contract.py index 1de83e4..7946ca9 100644 --- a/src/us_equity_strategies/signals/signal_bundle_contract.py +++ b/src/us_equity_strategies/signals/signal_bundle_contract.py @@ -34,7 +34,35 @@ REQUIRED_INDICATOR_FIELDS_BY_CONSUMER: dict[str, dict[str, tuple[str, ...]]] = { "us_equity:ibit_smart_dca": { - "BTC-USD": ("ahr999",), + "BTC-USD": ( + "close", + "sma200", + "sma200_gap", + "rsi14", + "ahr999", + "ahr999_sma", + "mayer_multiple", + ), + }, + "us_equity:nasdaq_sp500_smart_dca": { + "QQQ": ( + "close", + "sma50", + "sma200", + "high252", + "drawdown_252d", + "sma200_gap", + "rsi14", + ), + "SPY": ( + "close", + "sma50", + "sma200", + "high252", + "drawdown_252d", + "sma200_gap", + "rsi14", + ), }, "research:nasdaq_sp500_external_context_precomputed": { "US-EQUITY-CONTEXT": ( diff --git a/src/us_equity_strategies/strategies/nasdaq_sp500_smart_dca.py b/src/us_equity_strategies/strategies/nasdaq_sp500_smart_dca.py index 338bc87..854b8fc 100644 --- a/src/us_equity_strategies/strategies/nasdaq_sp500_smart_dca.py +++ b/src/us_equity_strategies/strategies/nasdaq_sp500_smart_dca.py @@ -8,7 +8,7 @@ STATUS_ICON = "🧺" -SIGNAL_SOURCE = "market_history+portfolio_snapshot" +SIGNAL_SOURCE = "derived_indicators/market_history+portfolio_snapshot" DEFAULT_SIGNAL_SYMBOLS = ("QQQ", "SPY") DEFAULT_TRADE_ALLOCATIONS = {"QQQM": 0.50, "SPLG": 0.50} DEFAULT_MANAGED_SYMBOLS = tuple(DEFAULT_TRADE_ALLOCATIONS) @@ -165,6 +165,86 @@ def _normalize_allocations(raw_allocations: Mapping[str, object] | None) -> dict return {symbol: weight / total for symbol, weight in allocations.items()} +def _payload_numeric(payload: Mapping[str, object], *keys: str) -> float: + lowered = {str(key).strip().lower(): value for key, value in payload.items()} + for key in keys: + value = lowered.get(key.lower()) + numeric = _coerce_float(value, default=float("nan")) + if not pd.isna(numeric): + return numeric + return float("nan") + + +def _resolve_indicator_payload( + indicator_snapshot: Mapping[str, object] | None, + symbol: str, +) -> Mapping[str, object] | None: + if not isinstance(indicator_snapshot, Mapping): + return None + if any( + str(key).lower() + in { + "close", + "price", + "sma200", + "sma_200", + "sma200_gap", + "rsi14", + "rsi_14", + } + for key in indicator_snapshot + ): + return indicator_snapshot + + candidates = { + symbol, + symbol.upper(), + symbol.removesuffix(".US"), + symbol.upper().removesuffix(".US"), + } + for key in candidates: + value = indicator_snapshot.get(key) + if isinstance(value, Mapping): + return value + normalized_snapshot = { + _normalize_symbol(key): value + for key, value in indicator_snapshot.items() + } + value = normalized_snapshot.get(_normalize_symbol(symbol)) + return value if isinstance(value, Mapping) else None + + +def _indicator_from_payload(symbol: str, payload: Mapping[str, object]) -> SymbolIndicator | None: + price = _payload_numeric(payload, "close", "price", "last", "last_price") + sma200 = _payload_numeric(payload, "sma200", "ma200", "sma_200") + high252 = _payload_numeric(payload, "high252", "high_252", "high252d", "high_252d") + if pd.isna(price) or pd.isna(sma200): + return None + sma50 = _payload_numeric(payload, "sma50", "ma50", "sma_50") + if pd.isna(sma50): + sma50 = sma200 + if pd.isna(high252): + high252 = max(price, sma200) + drawdown = _payload_numeric(payload, "drawdown_252d", "drawdown252", "drawdown") + if pd.isna(drawdown): + drawdown = 0.0 if high252 <= 0.0 else max(0.0, 1.0 - price / high252) + sma_gap = _payload_numeric(payload, "sma200_gap", "gap_vs_sma200", "price_vs_sma200") + if pd.isna(sma_gap): + sma_gap = 0.0 if sma200 <= 0.0 else price / sma200 - 1.0 + rsi14 = _payload_numeric(payload, "rsi14", "rsi_14", "rsi") + return SymbolIndicator( + symbol=symbol, + price=float(price), + sma50=float(sma50), + sma200=float(sma200), + high252=float(high252), + drawdown_252d=float(drawdown), + sma200_gap=float(sma_gap), + rsi14=None if pd.isna(rsi14) else float(rsi14), + trend_positive=bool(price >= sma200 and sma50 >= sma200), + ) + + def _extract_close_series(price_history: Any) -> pd.Series: if isinstance(price_history, pd.DataFrame): if price_history.empty: @@ -450,6 +530,7 @@ def build_rebalance_plan( severe_pullback_multiplier: float = 1.50, expensive_multiplier: float = 1.0, very_expensive_multiplier: float = 1.0, + technical_indicator_snapshot: Mapping[str, object] | None = None, broker_client=None, translator=None, ) -> dict[str, object]: @@ -481,6 +562,11 @@ def build_rebalance_plan( continue resolved_signal_symbols.append(symbol) if smart_enabled: + payload = _resolve_indicator_payload(technical_indicator_snapshot, symbol) + payload_indicator = _indicator_from_payload(symbol, payload) if payload else None + if payload_indicator is not None: + indicators.append(payload_indicator) + continue history = market_history(broker_client, symbol) indicators.append(_indicator_from_series(symbol, _extract_close_series(history))) diff --git a/tests/test_nasdaq_sp500_smart_dca.py b/tests/test_nasdaq_sp500_smart_dca.py index e61f154..f34f11d 100644 --- a/tests/test_nasdaq_sp500_smart_dca.py +++ b/tests/test_nasdaq_sp500_smart_dca.py @@ -110,6 +110,45 @@ def test_smart_dca_skips_when_too_expensive_and_overbought() -> None: assert plan["target_values"] == {} +def test_smart_dca_uses_external_technical_indicator_snapshot() -> None: + def unavailable_history(_client, _symbol): + raise AssertionError("external indicators should avoid market_history") + + plan = build_rebalance_plan( + unavailable_history, + _portfolio(), + as_of="2026-05-26", + smart_multiplier_enabled=True, + technical_indicator_snapshot={ + "QQQ": { + "close": 90.0, + "sma50": 95.0, + "sma200": 100.0, + "high252": 120.0, + "drawdown_252d": 0.20, + "sma200_gap": -0.10, + "rsi14": 42.0, + }, + "SPY": { + "close": 180.0, + "sma50": 190.0, + "sma200": 200.0, + "high252": 240.0, + "drawdown_252d": 0.20, + "sma200_gap": -0.10, + "rsi14": 44.0, + }, + }, + ) + + assert plan["actionable"] is True + assert plan["regime"] == "deep_pullback" + assert plan["multiplier"] == 1.25 + assert plan["avg_sma200_gap"] == pytest.approx(-0.10) + assert plan["avg_drawdown_252d"] == pytest.approx(0.20) + assert plan["signal_symbols"] == ("QQQ", "SPY") + + def test_smart_dca_waits_when_cash_is_below_minimum() -> None: history = {"QQQ": _normal_history(), "SPY": _normal_history()} @@ -215,7 +254,7 @@ def test_smart_dca_entrypoint_returns_value_targets_and_no_execute_flag() -> Non targets = {position.symbol: position.target_value for position in decision.positions} assert decision.risk_flags == () assert targets == {"QQQM": 1500.0, "SPLG": 1700.0} - assert decision.diagnostics["signal_source"] == "market_history+portfolio_snapshot" + assert decision.diagnostics["signal_source"] == "derived_indicators/market_history+portfolio_snapshot" assert decision.diagnostics["investment_amount_mode"] == "fixed" assert decision.diagnostics["smart_multiplier_enabled"] is False assert "普通定投" in decision.diagnostics["signal_description"] @@ -241,6 +280,47 @@ def test_smart_dca_entrypoint_returns_value_targets_and_no_execute_flag() -> Non assert expensive_decision.risk_flags == ("no_execute",) +def test_smart_dca_entrypoint_accepts_unified_derived_indicators() -> None: + entrypoint = get_strategy_entrypoint("nasdaq_sp500_smart_dca") + decision = entrypoint.evaluate( + StrategyContext( + as_of="2026-05-26", + market_data={ + "derived_indicators": { + "QQQ": { + "close": 90.0, + "sma50": 95.0, + "sma200": 100.0, + "high252": 120.0, + "drawdown_252d": 0.20, + "sma200_gap": -0.10, + "rsi14": 42.0, + }, + "SPY": { + "close": 180.0, + "sma50": 190.0, + "sma200": 200.0, + "high252": 240.0, + "drawdown_252d": 0.20, + "sma200_gap": -0.10, + "rsi14": 44.0, + }, + } + }, + portfolio=_portfolio(), + runtime_config={ + "investment_amount_mode": "fixed", + "smart_multiplier_enabled": True, + }, + ) + ) + + targets = {position.symbol: position.target_value for position in decision.positions} + assert targets == {"QQQM": 1625.0, "SPLG": 1825.0} + assert decision.diagnostics["regime"] == "deep_pullback" + assert decision.diagnostics["avg_sma200_gap"] == pytest.approx(-0.10) + + def test_smart_dca_entrypoint_applies_platform_reserved_cash_floor() -> None: entrypoint = get_strategy_entrypoint("nasdaq_sp500_smart_dca") decision = entrypoint.evaluate( diff --git a/tests/test_runtime_market_signal_inputs.py b/tests/test_runtime_market_signal_inputs.py index c66a8a0..2e4adab 100644 --- a/tests/test_runtime_market_signal_inputs.py +++ b/tests/test_runtime_market_signal_inputs.py @@ -1,7 +1,5 @@ from __future__ import annotations -from pathlib import Path - import pytest from us_equity_strategies.signals import runtime_market_signal_inputs as runtime_inputs @@ -64,7 +62,19 @@ def fake_materialize(reference, *, cache_dir, client_factory=None): raise RuntimeError("signal source unavailable") def fake_extract(path, *, consumer, as_of=None): - return {"derived_indicators": {"BTC-USD": {"ahr999": 0.8}}} + return { + "derived_indicators": { + "BTC-USD": { + "close": 64000.0, + "sma200": 59000.0, + "sma200_gap": 0.08, + "rsi14": 54.0, + "ahr999": 0.8, + "ahr999_sma": 0.82, + "mayer_multiple": 1.08, + } + } + } monkeypatch.setattr( runtime_inputs, @@ -94,7 +104,7 @@ def fake_extract(path, *, consumer, as_of=None): fallback_mode="last_valid", ) - assert first_inputs == {"derived_indicators": {"BTC-USD": {"ahr999": 0.8}}} + assert first_inputs["derived_indicators"]["BTC-USD"]["ahr999"] == 0.8 assert first_metadata["materialized_count"] == 3 assert fallback_inputs == first_inputs assert fallback_metadata["artifact_fallback_used"] is True diff --git a/tests/test_signal_bundle_cli.py b/tests/test_signal_bundle_cli.py index 15c3c8a..a8e41cd 100644 --- a/tests/test_signal_bundle_cli.py +++ b/tests/test_signal_bundle_cli.py @@ -61,6 +61,10 @@ def _write_platform_handoff_inputs(tmp_path: Path) -> Path: "symbols": ["BTC-USD"], "derived_indicator_fields": [ "ahr999", + "close", + "rsi14", + "sma200", + "sma200_gap", "ahr999_sma", "mayer_multiple", ], @@ -76,7 +80,29 @@ def _write_platform_handoff_inputs(tmp_path: Path) -> Path: "research:ibit_btc_ahr999_mayer_precomputed", "research:ibit_btc_ahr999_mayer_precomputed_variants", ], - } + }, + { + "family": "us_equity.technical_daily", + "canonical_input": "derived_indicators", + "transform": "technical.daily_ohlcv.v1", + "symbols": ["QQQ", "SPY"], + "derived_indicator_fields": [ + "close", + "sma50", + "sma200", + "high252", + "drawdown_252d", + "sma200_gap", + "rsi14", + ], + "compatible_profiles": [ + "us_equity:nasdaq_sp500_smart_dca", + ], + "runtime_consumers": [ + "us_equity:nasdaq_sp500_smart_dca", + ], + "research_consumers": [], + }, ], }, indent=2, @@ -97,8 +123,8 @@ def _write_platform_handoff_inputs(tmp_path: Path) -> Path: "catalog_sha256": _sha256_path(source_catalog_path), "catalog_size_bytes": source_catalog_path.stat().st_size, "catalog_schema_version": "market_signal_source_families.v1", - "family_count": 1, - "known_family_count": 1, + "family_count": 2, + "known_family_count": 2, "missing_known_families": [], "all_known_families_present": True, "all_consumer_contracts_satisfied": True, @@ -117,7 +143,41 @@ def _write_platform_handoff_inputs(tmp_path: Path) -> Path: { "consumer": "us_equity:ibit_smart_dca", "canonical_input": "derived_indicators", - "required_indicator_fields_by_symbol": {"BTC-USD": ["ahr999"]}, + "required_indicator_fields_by_symbol": { + "BTC-USD": [ + "close", + "sma200", + "sma200_gap", + "rsi14", + "ahr999", + "ahr999_sma", + "mayer_multiple", + ] + }, + }, + { + "consumer": "us_equity:nasdaq_sp500_smart_dca", + "canonical_input": "derived_indicators", + "required_indicator_fields_by_symbol": { + "QQQ": [ + "close", + "sma50", + "sma200", + "high252", + "drawdown_252d", + "sma200_gap", + "rsi14", + ], + "SPY": [ + "close", + "sma50", + "sma200", + "high252", + "drawdown_252d", + "sma200_gap", + "rsi14", + ], + }, }, { "consumer": "research:ibit_btc_ahr999_precomputed", @@ -205,8 +265,8 @@ def _write_platform_handoff_inputs(tmp_path: Path) -> Path: "registry_size_bytes": registry_path.stat().st_size, "registry_schema_version": "market_signal_consumer_contracts.v1", "canonical_input": "derived_indicators", - "consumer_count": 8, - "known_consumer_count": 8, + "consumer_count": 9, + "known_consumer_count": 9, "missing_known_consumers": [], "all_known_consumers_present": True, }, @@ -244,8 +304,11 @@ def _write_platform_handoff_inputs(tmp_path: Path) -> Path: "consumer_contract_registry_manifest_sha256": _sha256_path( registry_manifest_path ), - "source_family_count": 1, - "source_families": ["crypto.btc_cycle_daily"], + "source_family_count": 2, + "source_families": [ + "crypto.btc_cycle_daily", + "us_equity.technical_daily", + ], "all_known_source_families_present": True, "all_consumer_contracts_satisfied": True, "consumer_contract_count": len(contracts), @@ -588,9 +651,12 @@ def test_signal_bundle_cli_validates_platform_handoff_manifest( assert summary["schema_version"] == "market_signal_platform_handoff.v1" assert summary["consumer"] == "us_equity:ibit_smart_dca" assert summary["bundle_id"] == "crypto.btc.derived_indicators.2026-06-19" - assert summary["source_families"] == ["crypto.btc_cycle_daily"] + assert summary["source_families"] == [ + "crypto.btc_cycle_daily", + "us_equity.technical_daily", + ] assert summary["matched_source_families"] == ["crypto.btc_cycle_daily"] - assert summary["consumer_contract_count"] == 8 + assert summary["consumer_contract_count"] == 9 assert summary["all_known_consumers_present"] is True assert summary["all_runtime_consumers_covered"] is True assert summary["handoff_linked_manifest_sha256s_verified"] is True @@ -630,7 +696,10 @@ def test_signal_bundle_cli_validates_platform_handoff_index( assert summary["consumer"] == "us_equity:ibit_smart_dca" assert summary["bundle_id"] == "crypto.btc.derived_indicators.2026-06-19" assert summary["all_runtime_consumers_covered"] is True - assert summary["source_families"] == ["crypto.btc_cycle_daily"] + assert summary["source_families"] == [ + "crypto.btc_cycle_daily", + "us_equity.technical_daily", + ] assert summary["matched_source_families"] == ["crypto.btc_cycle_daily"] assert summary["handoff_linked_manifest_sha256s_verified"] is True @@ -733,7 +802,7 @@ def test_signal_bundle_cli_validates_research_handoff_manifest( assert summary["research_artifact_type"] == "btc_cycle_research_csv" assert summary["research_transform"] == "crypto.btc.ahr999.v1" assert summary["matched_source_families"] == ["crypto.btc_cycle_daily"] - assert summary["consumer_contract_count"] == 8 + assert summary["consumer_contract_count"] == 9 assert summary["research_export_output_csv_verified"] is True assert summary["handoff_linked_manifest_sha256s_verified"] is True @@ -745,7 +814,7 @@ def test_signal_bundle_cli_prints_local_consumer_contract_registry(capsys) -> No payload = json.loads(capsys.readouterr().out) assert payload["schema_version"] == "market_signal_consumer_contracts.v1" assert payload["canonical_input"] == "derived_indicators" - assert len(payload["contracts"]) == 8 + assert len(payload["contracts"]) == 9 consumers = [contract["consumer"] for contract in payload["contracts"]] assert "research:nasdaq_sp500_price_proxy" in consumers price_proxy_contract = next( @@ -784,7 +853,15 @@ def test_signal_bundle_cli_validates_consumer_contract_registry(tmp_path, capsys "consumer": "us_equity:ibit_smart_dca", "canonical_input": "derived_indicators", "required_indicator_fields_by_symbol": { - "BTC-USD": ["ahr999"], + "BTC-USD": [ + "close", + "sma200", + "sma200_gap", + "rsi14", + "ahr999", + "ahr999_sma", + "mayer_multiple", + ], }, } ], @@ -830,7 +907,39 @@ def test_signal_bundle_cli_validates_consumer_contract_registry_manifest( "consumer": "us_equity:ibit_smart_dca", "canonical_input": "derived_indicators", "required_indicator_fields_by_symbol": { - "BTC-USD": ["ahr999"], + "BTC-USD": [ + "close", + "sma200", + "sma200_gap", + "rsi14", + "ahr999", + "ahr999_sma", + "mayer_multiple", + ], + }, + }, + { + "consumer": "us_equity:nasdaq_sp500_smart_dca", + "canonical_input": "derived_indicators", + "required_indicator_fields_by_symbol": { + "QQQ": [ + "close", + "sma50", + "sma200", + "high252", + "drawdown_252d", + "sma200_gap", + "rsi14", + ], + "SPY": [ + "close", + "sma50", + "sma200", + "high252", + "drawdown_252d", + "sma200_gap", + "rsi14", + ], }, }, { @@ -929,8 +1038,8 @@ def test_signal_bundle_cli_validates_consumer_contract_registry_manifest( "registry_size_bytes": registry_path.stat().st_size, "registry_schema_version": "market_signal_consumer_contracts.v1", "canonical_input": "derived_indicators", - "consumer_count": 8, - "known_consumer_count": 8, + "consumer_count": 9, + "known_consumer_count": 9, "missing_known_consumers": [], "all_known_consumers_present": True, }, @@ -960,7 +1069,7 @@ def test_signal_bundle_cli_validates_consumer_contract_registry_manifest( assert summary["registry_sha256"] == hashlib.sha256( registry_path.read_bytes() ).hexdigest() - assert summary["consumer_count"] == 8 + assert summary["consumer_count"] == 9 assert summary["all_known_consumers_present"] is True assert summary["local_contract_registry_verified"] is True assert summary["canonical_registry_payload_sha256"] == summary[ @@ -980,7 +1089,15 @@ def test_signal_bundle_cli_can_require_complete_consumer_contract_registry(tmp_p "consumer": "us_equity:ibit_smart_dca", "canonical_input": "derived_indicators", "required_indicator_fields_by_symbol": { - "BTC-USD": ["ahr999"], + "BTC-USD": [ + "close", + "sma200", + "sma200_gap", + "rsi14", + "ahr999", + "ahr999_sma", + "mayer_multiple", + ], }, } ], diff --git a/tests/test_signal_bundle_contract.py b/tests/test_signal_bundle_contract.py index c7ef11c..7e93526 100644 --- a/tests/test_signal_bundle_contract.py +++ b/tests/test_signal_bundle_contract.py @@ -159,7 +159,15 @@ def _consumer_contract_registry() -> dict[str, object]: "consumer": "us_equity:ibit_smart_dca", "canonical_input": "derived_indicators", "required_indicator_fields_by_symbol": { - "BTC-USD": ["ahr999"], + "BTC-USD": [ + "close", + "sma200", + "sma200_gap", + "rsi14", + "ahr999", + "ahr999_sma", + "mayer_multiple", + ], }, }, { @@ -179,6 +187,33 @@ def _complete_consumer_contract_registry() -> dict[str, object]: assert isinstance(contracts, list) contracts.insert( 1, + { + "consumer": "us_equity:nasdaq_sp500_smart_dca", + "canonical_input": "derived_indicators", + "required_indicator_fields_by_symbol": { + "QQQ": [ + "close", + "sma50", + "sma200", + "high252", + "drawdown_252d", + "sma200_gap", + "rsi14", + ], + "SPY": [ + "close", + "sma50", + "sma200", + "high252", + "drawdown_252d", + "sma200_gap", + "rsi14", + ], + }, + }, + ) + contracts.insert( + 2, { "consumer": "research:nasdaq_sp500_external_context_precomputed", "canonical_input": "derived_indicators", @@ -192,7 +227,7 @@ def _complete_consumer_contract_registry() -> dict[str, object]: }, ) contracts.insert( - 2, + 3, { "consumer": "research:nasdaq_sp500_cape_vix_external_context_precomputed", "canonical_input": "derived_indicators", @@ -205,7 +240,7 @@ def _complete_consumer_contract_registry() -> dict[str, object]: }, ) contracts.insert( - 3, + 4, { "consumer": "research:nasdaq_sp500_price_proxy", "canonical_input": "derived_indicators", @@ -218,7 +253,7 @@ def _complete_consumer_contract_registry() -> dict[str, object]: }, ) contracts.insert( - 4, + 5, { "consumer": "research:ibit_btc_ahr999_precomputed", "canonical_input": "derived_indicators", @@ -228,7 +263,7 @@ def _complete_consumer_contract_registry() -> dict[str, object]: }, ) contracts.insert( - 5, + 6, { "consumer": "research:ibit_btc_ahr999_helper_precomputed_variants", "canonical_input": "derived_indicators", @@ -242,7 +277,7 @@ def _complete_consumer_contract_registry() -> dict[str, object]: }, ) contracts.insert( - 6, + 7, { "consumer": "research:ibit_btc_ahr999_mayer_precomputed", "canonical_input": "derived_indicators", @@ -269,6 +304,7 @@ def _write_consumer_contract_registry_manifest( missing_consumers = sorted( { "us_equity:ibit_smart_dca", + "us_equity:nasdaq_sp500_smart_dca", "research:ibit_btc_ahr999_precomputed", "research:ibit_btc_ahr999_helper_precomputed_variants", "research:ibit_btc_ahr999_mayer_precomputed", @@ -293,7 +329,7 @@ def _write_consumer_contract_registry_manifest( "registry_schema_version": registry["schema_version"], "canonical_input": registry["canonical_input"], "consumer_count": len(contracts), - "known_consumer_count": 8, + "known_consumer_count": 9, "missing_known_consumers": missing_consumers, "all_known_consumers_present": not missing_consumers, }, @@ -326,6 +362,10 @@ def _source_family_catalog() -> dict[str, object]: "ahr999_365d_percentile", "ahr999_30d_slope", "ahr999_sma", + "close", + "rsi14", + "sma200", + "sma200_gap", "mayer_multiple", ], "compatible_profiles": [ @@ -342,7 +382,31 @@ def _source_family_catalog() -> dict[str, object]: "research:ibit_btc_ahr999_mayer_precomputed", "research:ibit_btc_ahr999_mayer_precomputed_variants", ], - } + }, + { + "family": "us_equity.technical_daily", + "domain": "us_equity", + "bundle_type": "derived_indicators", + "bundle_id_prefix": "us_equity.technical.daily", + "canonical_input": "derived_indicators", + "transform": "technical.daily_ohlcv.v1", + "provider_dataset": "us_equity_daily_ohlcv", + "freshness_policy": "us_equity_daily_close_t_plus_1", + "minimum_history_rows": 252, + "symbols": ["QQQ", "SPY"], + "derived_indicator_fields": [ + "close", + "sma50", + "sma200", + "high252", + "drawdown_252d", + "sma200_gap", + "rsi14", + ], + "compatible_profiles": ["us_equity:nasdaq_sp500_smart_dca"], + "runtime_consumers": ["us_equity:nasdaq_sp500_smart_dca"], + "research_consumers": [], + }, ], } @@ -363,8 +427,8 @@ def _write_source_family_catalog_manifest(tmp_path: Path) -> tuple[Path, Path]: "catalog_sha256": _sha256_path(catalog_path), "catalog_size_bytes": catalog_path.stat().st_size, "catalog_schema_version": "market_signal_source_families.v1", - "family_count": 1, - "known_family_count": 1, + "family_count": 2, + "known_family_count": 2, "missing_known_families": [], "all_known_families_present": True, "all_consumer_contracts_satisfied": True, @@ -442,7 +506,7 @@ def _write_platform_handoff_manifest( "all_runtime_consumers_covered": True, "consumer_contract_count": len(consumers), "consumer_contracts": list(consumers), - "all_known_consumers_present": len(consumers) == 8, + "all_known_consumers_present": len(consumers) == 9, "canonical_registry_payload_sha256": registry_summary[ "canonical_registry_payload_sha256" ], @@ -745,7 +809,7 @@ def _write_research_handoff_manifest( ), "consumer_contract_count": len(consumers), "consumer_contracts": list(consumers), - "all_known_consumers_present": len(consumers) == 8, + "all_known_consumers_present": len(consumers) == 9, "canonical_registry_payload_sha256": registry_summary[ "canonical_registry_payload_sha256" ], @@ -1153,6 +1217,7 @@ def test_external_consumer_contract_registry_matches_local_contracts(tmp_path) - "research:nasdaq_sp500_cape_vix_external_context_precomputed", "research:nasdaq_sp500_external_context_precomputed", "research:nasdaq_sp500_price_proxy", + "us_equity:nasdaq_sp500_smart_dca", ) assert summary["local_contract_registry_verified"] is True assert summary["canonical_registry_payload_sha256"] == summary[ @@ -1190,7 +1255,7 @@ def test_external_consumer_contract_registry_manifest_matches_local_contracts( registry_path.read_bytes() ).hexdigest() assert summary["registry_schema_version"] == "market_signal_consumer_contracts.v1" - assert summary["consumer_count"] == 8 + assert summary["consumer_count"] == 9 assert summary["all_known_consumers_present"] is True assert summary["local_contract_registry_verified"] is True assert summary["canonical_registry_payload_sha256"] == summary[ @@ -1219,7 +1284,10 @@ def test_source_family_catalog_manifest_matches_required_consumers(tmp_path) -> assert summary["manifest_path"] == str(manifest_path.resolve()) assert summary["catalog_path"] == str(catalog_path.resolve()) assert summary["catalog_sha256"] == _sha256_path(catalog_path) - assert summary["families"] == ("crypto.btc_cycle_daily",) + assert summary["families"] == ( + "crypto.btc_cycle_daily", + "us_equity.technical_daily", + ) assert summary["matched_families"] == ("crypto.btc_cycle_daily",) assert summary["required_signal_consumers_present"] is True assert summary["runtime_consumer_coverage_present"] is True @@ -1269,7 +1337,7 @@ def test_platform_handoff_manifest_validates_linked_artifacts(tmp_path) -> None: ) source_manifest["catalog_sha256"] = _sha256_path(source_catalog_path) source_manifest["catalog_size_bytes"] = source_catalog_path.stat().st_size - source_manifest["family_count"] = 2 + source_manifest["family_count"] = 3 source_catalog_manifest_path.write_text( json.dumps(source_manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8", @@ -1317,11 +1385,12 @@ def test_platform_handoff_manifest_validates_linked_artifacts(tmp_path) -> None: ) assert summary["source_families"] == ( "crypto.btc_cycle_daily", + "us_equity.technical_daily", "us_equity.nasdaq_sp500_context_daily", ) assert summary["matched_source_family_count"] == 1 assert summary["matched_source_families"] == ("crypto.btc_cycle_daily",) - assert summary["consumer_contract_count"] == 8 + assert summary["consumer_contract_count"] == 9 assert summary["all_known_source_families_present"] is True assert summary["all_consumer_contracts_satisfied"] is True assert summary["all_known_consumers_present"] is True @@ -1396,9 +1465,9 @@ def test_runtime_consumption_audit_validates_linked_bundle_for_injection( assert summary["consumer"] == "us_equity:ibit_smart_dca" assert summary["lookup_as_of"] == "2026-06-20" assert summary["as_of"] == "2026-06-19" - assert summary["source_family_count"] == 1 + assert summary["source_family_count"] == 2 assert summary["matched_source_family_count"] == 1 - assert summary["consumer_contract_count"] == 8 + assert summary["consumer_contract_count"] == 9 assert summary["all_known_source_families_present"] is True assert summary["all_consumer_contracts_satisfied"] is True assert summary["all_known_consumers_present"] is True @@ -1542,7 +1611,7 @@ def test_research_handoff_manifest_validates_linked_research_export( assert handoff_summary["matched_source_families"] == ("crypto.btc_cycle_daily",) assert handoff_summary["source_family_count"] == 1 assert handoff_summary["source_families"] == ("crypto.btc_cycle_daily",) - assert handoff_summary["consumer_contract_count"] == 8 + assert handoff_summary["consumer_contract_count"] == 9 assert handoff_summary["all_runtime_consumers_covered"] is True assert handoff_summary["canonical_registry_payload_sha256"] == handoff_summary[ "local_registry_payload_sha256" @@ -1581,6 +1650,7 @@ def test_platform_handoff_index_resolves_matching_handoff_manifest(tmp_path) -> consumer_contract_registry_manifest_path=registry_manifest_path, consumers=( "us_equity:ibit_smart_dca", + "us_equity:nasdaq_sp500_smart_dca", "research:nasdaq_sp500_external_context_precomputed", "research:nasdaq_sp500_cape_vix_external_context_precomputed", "research:nasdaq_sp500_price_proxy", @@ -1656,6 +1726,7 @@ def test_external_consumer_contract_registry_can_require_all_known_consumers() - "research:nasdaq_sp500_external_context_precomputed", "research:nasdaq_sp500_price_proxy", "us_equity:ibit_smart_dca", + "us_equity:nasdaq_sp500_smart_dca", ) assert [ contract["consumer"] @@ -1680,7 +1751,7 @@ def test_external_consumer_contract_registry_can_require_all_known_consumers() - local_registry, require_all_known_consumers=True, ) - assert summary["consumer_count"] == 8 + assert summary["consumer_count"] == 9 assert summary["all_known_consumers_present"] is True assert summary["missing_known_consumers"] == () assert summary["local_contract_registry_verified"] is True diff --git a/tests/test_smart_dca_research_cli.py b/tests/test_smart_dca_research_cli.py index d69e183..a9bfe66 100644 --- a/tests/test_smart_dca_research_cli.py +++ b/tests/test_smart_dca_research_cli.py @@ -1657,8 +1657,8 @@ def test_smart_dca_research_cli_can_use_precomputed_ibit_cycle_columns( { "schema_version": "market_signal_source_families.v1", "families": [ - { - "family": "crypto.btc_cycle_daily", + { + "family": "crypto.btc_cycle_daily", "domain": "crypto", "bundle_type": "derived_indicators", "bundle_id_prefix": "crypto.btc.derived_indicators", @@ -1668,11 +1668,15 @@ def test_smart_dca_research_cli_can_use_precomputed_ibit_cycle_columns( "freshness_policy": "crypto_daily_close_t_plus_1", "minimum_history_rows": 200, "symbols": ["BTC-USD"], - "derived_indicator_fields": [ - "ahr999", - "ahr999_sma", - "mayer_multiple", - ], + "derived_indicator_fields": [ + "ahr999", + "ahr999_sma", + "close", + "mayer_multiple", + "rsi14", + "sma200", + "sma200_gap", + ], "compatible_profiles": [ "us_equity:ibit_smart_dca", "research:ibit_btc_ahr999_precomputed", @@ -1684,10 +1688,38 @@ def test_smart_dca_research_cli_can_use_precomputed_ibit_cycle_columns( "research:ibit_btc_ahr999_precomputed", "research:ibit_btc_ahr999_mayer_precomputed", "research:ibit_btc_ahr999_mayer_precomputed_variants", - ], - } - ], - }, + ], + }, + { + "family": "us_equity.technical_daily", + "domain": "us_equity", + "bundle_type": "derived_indicators", + "bundle_id_prefix": "us_equity.technical.daily", + "canonical_input": "derived_indicators", + "transform": "technical.daily_ohlcv.v1", + "provider_dataset": "us_equity_daily_ohlcv", + "freshness_policy": "us_equity_daily_close_t_plus_1", + "minimum_history_rows": 252, + "symbols": ["QQQ", "SPY"], + "derived_indicator_fields": [ + "close", + "sma50", + "sma200", + "high252", + "drawdown_252d", + "sma200_gap", + "rsi14", + ], + "compatible_profiles": [ + "us_equity:nasdaq_sp500_smart_dca", + ], + "runtime_consumers": [ + "us_equity:nasdaq_sp500_smart_dca", + ], + "research_consumers": [], + }, + ], + }, sort_keys=True, ), encoding="utf-8", @@ -1703,8 +1735,8 @@ def test_smart_dca_research_cli_can_use_precomputed_ibit_cycle_columns( "catalog_sha256": _sha256_file(source_catalog), "catalog_size_bytes": source_catalog.stat().st_size, "catalog_schema_version": "market_signal_source_families.v1", - "family_count": 1, - "known_family_count": 1, + "family_count": 2, + "known_family_count": 2, "missing_known_families": [], "all_known_families_present": True, "all_consumer_contracts_satisfied": True, @@ -1758,7 +1790,7 @@ def test_smart_dca_research_cli_can_use_precomputed_ibit_cycle_columns( "registry_schema_version": "market_signal_consumer_contracts.v1", "canonical_input": "derived_indicators", "consumer_count": 2, - "known_consumer_count": 8, + "known_consumer_count": 9, "missing_known_consumers": [ "research:ibit_btc_ahr999_helper_precomputed_variants", "research:ibit_btc_ahr999_precomputed", @@ -1766,6 +1798,7 @@ def test_smart_dca_research_cli_can_use_precomputed_ibit_cycle_columns( "research:nasdaq_sp500_external_context_precomputed", "research:nasdaq_sp500_price_proxy", "us_equity:ibit_smart_dca", + "us_equity:nasdaq_sp500_smart_dca", ], "all_known_consumers_present": False, } @@ -1855,8 +1888,11 @@ def test_smart_dca_research_cli_can_use_precomputed_ibit_cycle_columns( "consumer_contract_registry_manifest_sha256": _sha256_file( consumer_contract_registry_manifest ), - "source_family_count": 1, - "source_families": ["crypto.btc_cycle_daily"], + "source_family_count": 2, + "source_families": [ + "crypto.btc_cycle_daily", + "us_equity.technical_daily", + ], "all_known_source_families_present": True, "all_consumer_contracts_satisfied": True, "consumer_contract_count": len(contract_consumers), @@ -1950,8 +1986,11 @@ def test_smart_dca_research_cli_can_use_precomputed_ibit_cycle_columns( "consumer_contract_registry_manifest_sha256": _sha256_file( consumer_contract_registry_manifest ), - "source_family_count": 1, - "source_families": ["crypto.btc_cycle_daily"], + "source_family_count": 2, + "source_families": [ + "crypto.btc_cycle_daily", + "us_equity.technical_daily", + ], "matched_source_family_count": 1, "matched_source_families": ["crypto.btc_cycle_daily"], "all_known_source_families_present": True, @@ -2133,7 +2172,8 @@ def test_smart_dca_research_cli_can_use_precomputed_ibit_cycle_columns( "crypto.btc.derived_indicators.2026-06-19" ) assert platform_handoff_record["source_families"] == [ - "crypto.btc_cycle_daily" + "crypto.btc_cycle_daily", + "us_equity.technical_daily", ] assert platform_handoff_record["matched_source_families"] == [ "crypto.btc_cycle_daily" @@ -2439,7 +2479,7 @@ def test_smart_dca_research_cli_can_use_precomputed_ibit_cycle_columns( "registry_schema_version": "market_signal_consumer_contracts.v1", "canonical_input": "derived_indicators", "consumer_count": 1, - "known_consumer_count": 8, + "known_consumer_count": 9, "missing_known_consumers": [ "research:ibit_btc_ahr999_helper_precomputed_variants", "research:ibit_btc_ahr999_mayer_precomputed_variants", @@ -2448,6 +2488,7 @@ def test_smart_dca_research_cli_can_use_precomputed_ibit_cycle_columns( "research:nasdaq_sp500_external_context_precomputed", "research:nasdaq_sp500_price_proxy", "us_equity:ibit_smart_dca", + "us_equity:nasdaq_sp500_smart_dca", ], "all_known_consumers_present": False, }