Files
stonks-oracle/tests/test_signal_engine_probabilistic.py
T
Celes Renata f468e30af0
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/push/build-2 Pipeline was successful
ci/woodpecker/push/build-1 Pipeline was successful
ci/woodpecker/push/build-3 Pipeline was successful
ci/woodpecker/push/finalize Pipeline was successful
Build and Push / lint-and-test (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.adapters.broker_adapter name:broker-adapter]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.aggregation.worker name:aggregation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.extractor.worker name:extractor]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.ingestion.worker name:ingestion]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.lake_publisher.worker name:lake-publisher]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.parser.worker name:parser]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.recommendation.worker name:recommendation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.scheduler.app name:scheduler]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.api.app:app --host 0.0.0.0 --port 8000 name:query-api]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.risk.app:app --host 0.0.0.0 --port 8000 name:risk]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.symbol_registry.app:app --host 0.0.0.0 --port 8000 name:symbol-registry]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.trading.app:app --host 0.0.0.0 --port 8000 name:trading-engine]) (push) Has been cancelled
Build and Push / build-dashboard (push) Has been cancelled
Build and Push / build-superset (push) Has been cancelled
Build and Push / integration-test (push) Has been cancelled
Build and Push / beta-gate (push) Has been cancelled
feat: implement dual-pipeline signal engine service
New service at services/signal_engine/ implementing concurrent heuristic
(deterministic scoring) and probabilistic (Bayesian inference) pipelines
that evaluate technical signals across 6 timeframes (M30-M) and produce
independent BUY/WATCH/SKIP verdicts per ticker per evaluation tick.

Components:
- Input Normalizer: multi-source data assembly with sentinel fallbacks
- Signal Library: Fibonacci, MA Stack, RSI, Cup & Handle, Elliott Wave
- Multi-Timeframe Confluence Engine: weighted scoring with D/W/M anchors
- Hard Filter Engine: macro_bias, valuation, earnings proximity gating
- Heuristic Pipeline: S_total scoring with confidence-gated verdicts
- Probabilistic Pipeline: Bayesian log-odds with regime priors, entropy
  gating, EV_R calculation, and signal correlation penalty
- Exit Engine: stop-loss, targets, trailing ATR-based stops
- Delta Analyzer: pipeline agreement tracking with rolling Redis metrics
- Output Formatter: SignalOutput contract + Recommendation schema mapping
- Worker orchestrator: concurrent pipelines with failure isolation
- Main entry point: queue polling with fail-safe config loading

Infrastructure:
- Migration 039: signal_engine_outputs table with 3 indexes
- Helm chart: signalEngine service entry (processing tier)
- Redis key: QUEUE_SIGNAL_ENGINE constant

Tests: 390 tests (unit + property-based) covering all components
Config: dual_pipeline_enabled=false by default (safe rollout)
2026-05-02 07:32:26 +00:00

359 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Unit tests for the probabilistic (Bayesian) pipeline.
Tests cover:
- Regime-to-prior mapping
- Likelihood ratio computation
- Log-odds accumulation and sigmoid round-trip
- Shannon entropy computation
- Entropy gating (SKIP on high entropy)
- EV_R computation
- BUY / WATCH / SKIP verdict thresholds
- Edge cases (no signals, boundary values)
Requirements: 6.16.9, 14.114.5
"""
from __future__ import annotations
from datetime import datetime, timezone
from services.aggregation.regime import MarketRegime, RegimeClassification
from services.signal_engine.config import ProbabilisticConfig
from services.signal_engine.models import (
ConfluenceSignal,
NormalizedInput,
SignalDirection,
Verdict,
)
from services.signal_engine.probabilistic import (
_compute_ev_r,
_compute_likelihood_ratios,
_logit,
_regime_to_prior,
_shannon_entropy,
_sigmoid,
run_probabilistic_pipeline,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_normalized(
macro_bias: float = 0.5,
valuation_score: float | None = 0.7,
earnings_proximity_days: int | None = 30,
) -> NormalizedInput:
return NormalizedInput(
ticker="TEST",
evaluated_at=datetime.now(tz=timezone.utc),
bars={},
macro_bias=macro_bias,
valuation_score=valuation_score,
earnings_proximity_days=earnings_proximity_days,
)
def _make_regime(
regime: MarketRegime = MarketRegime.TREND_FOLLOWING,
trend_indicator: float = 1.0,
) -> RegimeClassification:
return RegimeClassification(
regime=regime,
trend_indicator=trend_indicator,
volatility_ratio=1.0,
bullish_threshold=0.15,
bearish_threshold=-0.15,
contradiction_penalty_multiplier=0.4,
)
def _make_confluence(
signal_type: str = "fibonacci",
direction: SignalDirection = SignalDirection.BULLISH,
confluence_score: float = 0.8,
active_timeframes: list[str] | None = None,
) -> ConfluenceSignal:
if active_timeframes is None:
active_timeframes = ["D", "W"]
return ConfluenceSignal(
signal_type=signal_type,
direction=direction,
confluence_score=confluence_score,
active_timeframes=active_timeframes,
per_timeframe={tf: confluence_score for tf in active_timeframes},
)
DEFAULT_CONFIG = ProbabilisticConfig()
# ---------------------------------------------------------------------------
# Regime → prior mapping
# ---------------------------------------------------------------------------
class TestRegimeToPrior:
def test_trend_following_bullish(self):
regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=1.0)
assert _regime_to_prior(regime, DEFAULT_CONFIG) == 0.58
def test_trend_following_bearish(self):
regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=-1.0)
assert _regime_to_prior(regime, DEFAULT_CONFIG) == 0.42
def test_trend_following_zero_indicator(self):
"""Zero trend_indicator is not positive → bear prior."""
regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=0.0)
assert _regime_to_prior(regime, DEFAULT_CONFIG) == 0.42
def test_mean_reversion(self):
regime = _make_regime(MarketRegime.MEAN_REVERSION)
assert _regime_to_prior(regime, DEFAULT_CONFIG) == 0.50
def test_panic(self):
regime = _make_regime(MarketRegime.PANIC)
assert _regime_to_prior(regime, DEFAULT_CONFIG) == 0.42
def test_uncertainty(self):
regime = _make_regime(MarketRegime.UNCERTAINTY)
assert _regime_to_prior(regime, DEFAULT_CONFIG) == 0.50
# ---------------------------------------------------------------------------
# Logit / sigmoid helpers
# ---------------------------------------------------------------------------
class TestLogitSigmoid:
def test_logit_sigmoid_round_trip(self):
for p in [0.1, 0.25, 0.5, 0.75, 0.9]:
assert abs(_sigmoid(_logit(p)) - p) < 1e-10
def test_logit_at_half(self):
assert abs(_logit(0.5)) < 1e-10
def test_sigmoid_at_zero(self):
assert abs(_sigmoid(0.0) - 0.5) < 1e-10
def test_sigmoid_large_positive(self):
assert _sigmoid(1000) == 1.0
def test_sigmoid_large_negative(self):
assert _sigmoid(-1000) == 0.0
# ---------------------------------------------------------------------------
# Shannon entropy
# ---------------------------------------------------------------------------
class TestShannonEntropy:
def test_max_at_half(self):
assert abs(_shannon_entropy(0.5) - 1.0) < 1e-10
def test_zero_at_boundaries(self):
assert _shannon_entropy(0.0) == 0.0
assert _shannon_entropy(1.0) == 0.0
def test_symmetric(self):
assert abs(_shannon_entropy(0.3) - _shannon_entropy(0.7)) < 1e-10
def test_in_range(self):
for p in [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]:
h = _shannon_entropy(p)
assert 0.0 <= h <= 1.0
# ---------------------------------------------------------------------------
# Likelihood ratio computation
# ---------------------------------------------------------------------------
class TestLikelihoodRatios:
def test_bullish_signal_produces_lr_gt_1(self):
sig = _make_confluence(direction=SignalDirection.BULLISH, confluence_score=0.8)
lrs = _compute_likelihood_ratios([sig])
assert len(lrs) == 1
assert lrs[0].lr > 1.0
assert lrs[0].log_lr > 0.0
def test_bearish_signal_produces_lr_lt_1(self):
sig = _make_confluence(direction=SignalDirection.BEARISH, confluence_score=0.8)
lrs = _compute_likelihood_ratios([sig])
assert len(lrs) == 1
assert lrs[0].lr < 1.0
assert lrs[0].log_lr < 0.0
def test_neutral_signal_produces_lr_gt_1(self):
"""Neutral signals still contribute based on strength."""
sig = _make_confluence(direction=SignalDirection.NEUTRAL, confluence_score=0.8)
lrs = _compute_likelihood_ratios([sig])
assert len(lrs) == 1
# Neutral is treated as bullish evidence (no inversion)
assert lrs[0].lr > 1.0
def test_empty_signals(self):
lrs = _compute_likelihood_ratios([])
assert lrs == []
def test_cluster_assignment(self):
sig = _make_confluence(signal_type="ma_stack")
lrs = _compute_likelihood_ratios([sig])
assert lrs[0].cluster == "momentum"
# ---------------------------------------------------------------------------
# EV_R computation
# ---------------------------------------------------------------------------
class TestEvR:
def test_ev_r_high_p_up(self):
signals = [_make_confluence(confluence_score=0.8)]
ev_r = _compute_ev_r(0.8, signals)
# E[win_R] = 0.8 * 2.0 = 1.6
# EV_R = 0.8 * 1.6 - 0.2 * 1.0 = 1.28 - 0.2 = 1.08
assert abs(ev_r - 1.08) < 1e-10
def test_ev_r_at_half(self):
signals = [_make_confluence(confluence_score=0.5)]
ev_r = _compute_ev_r(0.5, signals)
# E[win_R] = 0.5 * 2.0 = 1.0
# EV_R = 0.5 * 1.0 - 0.5 * 1.0 = 0.0
assert abs(ev_r) < 1e-10
def test_ev_r_no_signals(self):
ev_r = _compute_ev_r(0.7, [])
# E[win_R] = 1.0 (fallback)
# EV_R = 0.7 * 1.0 - 0.3 * 1.0 = 0.4
assert abs(ev_r - 0.4) < 1e-10
def test_ev_r_monotonic_with_p_up(self):
signals = [_make_confluence(confluence_score=0.8)]
ev_low = _compute_ev_r(0.5, signals)
ev_high = _compute_ev_r(0.8, signals)
assert ev_high > ev_low
# ---------------------------------------------------------------------------
# Full pipeline — verdict tests
# ---------------------------------------------------------------------------
class TestProbabilisticPipeline:
def test_no_signals_returns_prior_based_result(self):
"""With no signals, P_up equals the prior."""
normalized = _make_normalized()
regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=1.0)
result = run_probabilistic_pipeline(normalized, [], regime, DEFAULT_CONFIG)
assert abs(result.p_up - 0.58) < 1e-6
assert result.prior == 0.58
def test_buy_verdict_with_strong_signals(self):
"""Strong bullish signals + favorable conditions → BUY."""
normalized = _make_normalized(macro_bias=0.5, valuation_score=0.7)
regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=1.0)
# Multiple strong bullish signals from different clusters
signals = [
_make_confluence("fibonacci", SignalDirection.BULLISH, 0.9),
_make_confluence("ma_stack", SignalDirection.BULLISH, 0.85),
_make_confluence("rsi", SignalDirection.BULLISH, 0.8),
_make_confluence("valuation", SignalDirection.BULLISH, 0.75),
]
result = run_probabilistic_pipeline(normalized, signals, regime, DEFAULT_CONFIG)
# With strong signals and bull prior, P_up should be high
assert result.p_up >= 0.60
# Verdict depends on all conditions being met
assert result.verdict in (Verdict.BUY, Verdict.WATCH)
def test_skip_on_high_entropy(self):
"""P_up near 0.5 → high entropy → SKIP."""
normalized = _make_normalized()
regime = _make_regime(MarketRegime.MEAN_REVERSION) # prior = 0.50
# No signals → P_up stays at 0.50 → entropy = 1.0 > 0.95
result = run_probabilistic_pipeline(normalized, [], regime, DEFAULT_CONFIG)
assert result.verdict == Verdict.SKIP
assert result.entropy > 0.95
assert any("high_entropy" in r for r in result.reasoning)
def test_skip_on_low_p_up(self):
"""Bearish signals → low P_up → SKIP."""
normalized = _make_normalized()
regime = _make_regime(MarketRegime.PANIC) # prior = 0.42
signals = [
_make_confluence("fibonacci", SignalDirection.BEARISH, 0.9),
_make_confluence("ma_stack", SignalDirection.BEARISH, 0.85),
]
result = run_probabilistic_pipeline(normalized, signals, regime, DEFAULT_CONFIG)
assert result.p_up < 0.55
assert result.verdict == Verdict.SKIP
def test_watch_verdict(self):
"""Moderate signals → WATCH (P_up >= 0.55 but not all BUY conditions)."""
normalized = _make_normalized(macro_bias=-0.1) # macro_bias <= 0 blocks BUY
regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=1.0)
signals = [
_make_confluence("fibonacci", SignalDirection.BULLISH, 0.8),
_make_confluence("ma_stack", SignalDirection.BULLISH, 0.75),
]
result = run_probabilistic_pipeline(normalized, signals, regime, DEFAULT_CONFIG)
# P_up should be above 0.55 with bull prior + bullish signals
if result.p_up >= 0.55 and result.entropy <= 0.95:
assert result.verdict == Verdict.WATCH
def test_macro_bias_blocks_buy(self):
"""macro_bias <= 0 prevents BUY even with high P_up."""
normalized = _make_normalized(macro_bias=0.0, valuation_score=0.8)
regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=1.0)
signals = [
_make_confluence("fibonacci", SignalDirection.BULLISH, 0.9),
_make_confluence("ma_stack", SignalDirection.BULLISH, 0.9),
_make_confluence("rsi", SignalDirection.BULLISH, 0.85),
_make_confluence("valuation", SignalDirection.BULLISH, 0.8),
]
result = run_probabilistic_pipeline(normalized, signals, regime, DEFAULT_CONFIG)
assert result.verdict != Verdict.BUY
def test_valuation_blocks_buy(self):
"""valuation_score < 0.5 prevents BUY."""
normalized = _make_normalized(macro_bias=0.5, valuation_score=0.3)
regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=1.0)
signals = [
_make_confluence("fibonacci", SignalDirection.BULLISH, 0.9),
_make_confluence("ma_stack", SignalDirection.BULLISH, 0.9),
_make_confluence("rsi", SignalDirection.BULLISH, 0.85),
_make_confluence("valuation", SignalDirection.BULLISH, 0.8),
]
result = run_probabilistic_pipeline(normalized, signals, regime, DEFAULT_CONFIG)
assert result.verdict != Verdict.BUY
def test_result_fields_populated(self):
"""All ProbabilisticResult fields are populated correctly."""
normalized = _make_normalized()
regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=1.0)
signals = [_make_confluence("fibonacci", SignalDirection.BULLISH, 0.7)]
result = run_probabilistic_pipeline(normalized, signals, regime, DEFAULT_CONFIG)
assert 0.0 <= result.p_up <= 1.0
assert 0.0 <= result.entropy <= 1.0
assert isinstance(result.ev_r, float)
assert result.prior == 0.58
assert result.posterior == result.p_up
assert result.regime == "trend_following"
assert len(result.likelihood_ratios) == 1
assert len(result.reasoning) > 0
def test_none_valuation_treated_as_zero(self):
"""None valuation_score is treated as 0.0 for verdict logic."""
normalized = _make_normalized(valuation_score=None)
regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=1.0)
signals = [
_make_confluence("fibonacci", SignalDirection.BULLISH, 0.9),
_make_confluence("ma_stack", SignalDirection.BULLISH, 0.9),
_make_confluence("rsi", SignalDirection.BULLISH, 0.85),
]
result = run_probabilistic_pipeline(normalized, signals, regime, DEFAULT_CONFIG)
# valuation_score=None → 0.0 < 0.5 → BUY blocked
assert result.verdict != Verdict.BUY