feat: implement dual-pipeline signal engine service
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

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)
This commit is contained in:
Celes Renata
2026-05-02 07:32:26 +00:00
parent 7e2343ec2c
commit f468e30af0
61 changed files with 14107 additions and 184 deletions
+358
View File
@@ -0,0 +1,358 @@
"""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