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
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)
359 lines
14 KiB
Python
359 lines
14 KiB
Python
"""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.1–6.9, 14.1–14.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
|