Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions src/us_equity_strategies/entrypoints/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +246 to +251

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve missing-history failures for rotation

When global_etf_rotation is invoked without market_history, this fallback feeds an empty series for every ticker into compute_signals; with the default four canaries and canary_bad_threshold=4, that path returns a 100% safe-haven emergency allocation instead of surfacing the missing required input. Since the catalog/manifest still declare market_history as required for this strategy, a platform wiring issue would now silently produce an actionable defensive trade rather than fail fast.

Useful? React with 👍 / 👎.

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(
Expand Down Expand Up @@ -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")
)
Comment on lines +1009 to +1012

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Advertise the new derived-indicator input path

This new derived_indicators consumer path is not reflected in the runtime metadata: STRATEGY_REQUIRED_INPUTS/the manifest still list only market_history for nasdaq_sp500_smart_dca, and its base runtime adapter does not add derived_indicators to available_inputs (unlike IBIT). In catalog-driven/platform runs that build or validate inputs from those contracts, a unified signal bundle for this new consumer will not be requested or accepted, so the feature only works for callers that bypass the strategy contract and inject ctx.market_data manually.

Useful? React with 👍 / 👎.

portfolio = require_portfolio(ctx)
apply_reserved_cash_policy_to_usd_config(
config,
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/us_equity_strategies/signals/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
30 changes: 29 additions & 1 deletion src/us_equity_strategies/signals/signal_bundle_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": (
Expand Down
88 changes: 87 additions & 1 deletion src/us_equity_strategies/strategies/nasdaq_sp500_smart_dca.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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)))

Expand Down
82 changes: 81 additions & 1 deletion tests/test_nasdaq_sp500_smart_dca.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()}

Expand Down Expand Up @@ -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"]
Expand All @@ -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(
Expand Down
18 changes: 14 additions & 4 deletions tests/test_runtime_market_signal_inputs.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading