"""Probabilistic Pipeline (Pipeline B) — Bayesian inference and verdict. Computes a posterior probability via regime-based priors, likelihood ratio accumulation with correlation penalty, entropy gating, and expected value calculation. Produces a BUY / WATCH / SKIP verdict. The pipeline reuses the existing ``classify_regime`` infrastructure from ``services.aggregation.regime`` for regime classification and wraps the Bayesian math with signal-cluster correlation penalties from ``services.signal_engine.correlation``. Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.7, 6.8, 6.9, 14.1, 14.2, 14.3, 14.4, 14.5 """ from __future__ import annotations import logging import math from services.aggregation.regime import MarketRegime, RegimeClassification from services.signal_engine.config import ProbabilisticConfig from services.signal_engine.correlation import ( apply_correlation_penalty, classify_signal, ) from services.signal_engine.models import ( ConfluenceSignal, LikelihoodRatio, NormalizedInput, ProbabilisticResult, SignalDirection, Verdict, ) logger = logging.getLogger(__name__) # Default hit rate used when no historical hit rate is available. _DEFAULT_HIT_RATE: float = 0.6 # --------------------------------------------------------------------------- # Regime → prior mapping # --------------------------------------------------------------------------- def _regime_to_prior( regime: RegimeClassification, config: ProbabilisticConfig, ) -> float: """Map a regime classification to a prior probability. Mapping (Req 14.2): - TREND_FOLLOWING with positive trend_indicator → bull prior (0.58) - TREND_FOLLOWING with negative trend_indicator → bear prior (0.42) - MEAN_REVERSION → range prior (0.50) - PANIC → bear prior (0.42) - UNCERTAINTY → range prior (0.50) """ if regime.regime == MarketRegime.TREND_FOLLOWING: if regime.trend_indicator > 0: return config.regime_prior_bull return config.regime_prior_bear if regime.regime == MarketRegime.MEAN_REVERSION: return config.regime_prior_range if regime.regime == MarketRegime.PANIC: return config.regime_prior_bear # UNCERTAINTY or any unknown → range prior return config.regime_prior_range # --------------------------------------------------------------------------- # Likelihood ratio computation # --------------------------------------------------------------------------- def _compute_likelihood_ratios( confluence_signals: list[ConfluenceSignal], ) -> list[LikelihoodRatio]: """Compute raw likelihood ratios for each confluence signal. For each signal: - h = hit rate (use confidence as proxy, default 0.6) - s = signal strength (confluence_score) - P(sig|up) = h * s + (1 - h) * (1 - s) * 0.5 - P(sig|down) = 1 - P(sig|up) - LR = P(sig|up) / P(sig|down) Direction-aware: bearish signals invert the LR (use 1/LR) so that bearish evidence reduces P_up. Requirements: 6.2 """ ratios: list[LikelihoodRatio] = [] for sig in confluence_signals: h = _DEFAULT_HIT_RATE s = sig.confluence_score # Clamp inputs to valid ranges to avoid numerical issues h = max(0.01, min(h, 0.99)) s = max(0.01, min(s, 0.99)) p_sig_up = h * s + (1.0 - h) * (1.0 - s) * 0.5 p_sig_down = 1.0 - p_sig_up # Guard against division by zero / near-zero if p_sig_down < 1e-10: p_sig_down = 1e-10 lr = p_sig_up / p_sig_down # Bearish signals: invert the LR so it reduces P_up if sig.direction == SignalDirection.BEARISH: lr = 1.0 / lr if lr > 1e-10 else 1e10 log_lr = math.log(lr) if lr > 0 else 0.0 cluster = classify_signal(sig.signal_type) ratios.append( LikelihoodRatio( signal_type=sig.signal_type, cluster=cluster.value, lr=lr, log_lr=log_lr, penalized_log_lr=log_lr, # will be updated by penalty hit_rate=h, strength=s, ) ) return ratios # --------------------------------------------------------------------------- # Log-odds / sigmoid helpers # --------------------------------------------------------------------------- def _logit(p: float) -> float: """Compute logit(p) = log(p / (1 - p)). Clamps p to (1e-10, 1 - 1e-10) to avoid infinities. """ p = max(1e-10, min(p, 1.0 - 1e-10)) return math.log(p / (1.0 - p)) def _sigmoid(x: float) -> float: """Compute sigmoid(x) = 1 / (1 + exp(-x)). Clamps the exponent to avoid overflow. """ if x > 500: return 1.0 if x < -500: return 0.0 return 1.0 / (1.0 + math.exp(-x)) # --------------------------------------------------------------------------- # Shannon entropy # --------------------------------------------------------------------------- def _shannon_entropy(p: float) -> float: """Compute Shannon entropy H = -p·log₂(p) - (1-p)·log₂(1-p). Returns 0.0 at the boundaries (p = 0 or p = 1). Result is in [0, 1] for binary entropy. """ if p <= 0.0 or p >= 1.0: return 0.0 return -(p * math.log2(p) + (1.0 - p) * math.log2(1.0 - p)) # --------------------------------------------------------------------------- # EV_R computation # --------------------------------------------------------------------------- def _compute_ev_r( p_up: float, confluence_signals: list[ConfluenceSignal], ) -> float: """Compute expected value per unit risk. EV_R = P_up · E[win_R] - (1 - P_up) · 1.0 E[win_R] is estimated as the average confluence_score × 2.0 (heuristic for expected win in R-units). Falls back to 1.0 if no signals are available. """ if confluence_signals: avg_score = sum(s.confluence_score for s in confluence_signals) / len( confluence_signals ) e_win_r = avg_score * 2.0 else: e_win_r = 1.0 return p_up * e_win_r - (1.0 - p_up) * 1.0 # --------------------------------------------------------------------------- # Verdict logic # --------------------------------------------------------------------------- def _determine_verdict( p_up: float, entropy: float, ev_r: float, normalized: NormalizedInput, config: ProbabilisticConfig, ) -> tuple[Verdict, list[str]]: """Apply threshold logic to determine BUY / WATCH / SKIP verdict. Returns the verdict and a list of reasoning strings. Requirements: 6.6, 6.7, 6.8 """ reasoning: list[str] = [] valuation_score = ( normalized.valuation_score if normalized.valuation_score is not None else 0.0 ) # --- Entropy gating (Req 6.4) --- if entropy > config.entropy_skip: reasoning.append( f"SKIP: entropy={entropy:.4f} > {config.entropy_skip} (high_entropy)" ) return Verdict.SKIP, reasoning # --- Check BUY conditions (Req 6.6) --- buy_conditions = { "p_up": p_up >= config.buy_p_up, "entropy": entropy <= config.buy_entropy_max, "ev_r": ev_r >= config.buy_ev_r_min, "macro_bias": normalized.macro_bias > config.macro_bias_threshold, "valuation": valuation_score >= config.buy_valuation_min, } all_buy_met = all(buy_conditions.values()) if all_buy_met: reasoning.append( f"BUY: all conditions met — P_up={p_up:.4f} " f"(>= {config.buy_p_up}), entropy={entropy:.4f} " f"(<= {config.buy_entropy_max}), EV_R={ev_r:.4f} " f"(>= {config.buy_ev_r_min}), macro_bias={normalized.macro_bias:.2f} " f"(> {config.macro_bias_threshold}), valuation={valuation_score:.2f} " f"(>= {config.buy_valuation_min})" ) return Verdict.BUY, reasoning # --- Check WATCH conditions (Req 6.7) --- watch_conditions = { "p_up": p_up >= config.watch_p_up, "entropy": entropy <= config.watch_entropy_max, } if all(watch_conditions.values()): failed_buy = [k for k, v in buy_conditions.items() if not v] reasoning.append( f"WATCH: P_up={p_up:.4f} (>= {config.watch_p_up}), " f"entropy={entropy:.4f} (<= {config.watch_entropy_max}) " f"but BUY conditions not fully met — failed: {', '.join(failed_buy)}" ) return Verdict.WATCH, reasoning # --- SKIP (Req 6.8) --- reasoning.append( f"SKIP: P_up={p_up:.4f}, entropy={entropy:.4f}, EV_R={ev_r:.4f} " f"— does not meet WATCH or BUY thresholds" ) return Verdict.SKIP, reasoning # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- def run_probabilistic_pipeline( normalized: NormalizedInput, confluence_signals: list[ConfluenceSignal], regime: RegimeClassification, config: ProbabilisticConfig, ) -> ProbabilisticResult: """Run the Bayesian probabilistic pipeline. Steps: 1. Initialize regime-based prior (bull=0.58, range=0.50, bear=0.42) 2. Compute likelihood ratios per signal 3. Apply correlation penalty via ``apply_correlation_penalty()`` 4. Accumulate via log-odds: logit(P_post) = logit(P_prior) + Σ log(LR_i) 5. Compute Shannon entropy and apply entropy gating 6. Compute EV_R = P_up · E[win_R] - (1 - P_up) · 1.0 7. Produce BUY / WATCH / SKIP verdict Args: normalized: The unified input structure for this evaluation tick. confluence_signals: Signals that passed multi-timeframe confluence filtering. regime: The current market regime classification. config: Probabilistic pipeline thresholds. Returns: A :class:`ProbabilisticResult` with verdict, posterior, entropy, EV_R, likelihood ratios, and reasoning. Requirements: 6.1–6.9, 14.1–14.5 """ reasoning: list[str] = [] # 1. Regime-based prior (Req 6.1, 14.2) prior = _regime_to_prior(regime, config) reasoning.append( f"Regime={regime.regime.value}, trend_indicator={regime.trend_indicator:.1f} " f"→ prior={prior:.2f}" ) # 2. Compute likelihood ratios (Req 6.2) raw_lrs = _compute_likelihood_ratios(confluence_signals) # 3. Apply correlation penalty (Req 7.1–7.4) penalized_lrs = apply_correlation_penalty(raw_lrs) # 4. Accumulate via log-odds (Req 6.3, 14.3) logit_prior = _logit(prior) sum_penalized_log_lr = sum(lr.penalized_log_lr for lr in penalized_lrs) logit_posterior = logit_prior + sum_penalized_log_lr p_up = _sigmoid(logit_posterior) reasoning.append( f"logit(prior)={logit_prior:.4f} + Σ penalized_log_lr={sum_penalized_log_lr:.4f} " f"= logit(posterior)={logit_posterior:.4f} → P_up={p_up:.4f}" ) # 5. Shannon entropy (Req 6.4) entropy = _shannon_entropy(p_up) reasoning.append(f"Shannon entropy H={entropy:.4f}") # 6. EV_R (Req 6.5) ev_r = _compute_ev_r(p_up, confluence_signals) reasoning.append(f"EV_R={ev_r:.4f}") # 7. Verdict (Req 6.6, 6.7, 6.8) verdict, verdict_reasoning = _determine_verdict( p_up, entropy, ev_r, normalized, config ) reasoning.extend(verdict_reasoning) logger.info( "Probabilistic pipeline [%s]: verdict=%s P_up=%.4f " "entropy=%.4f EV_R=%.4f prior=%.2f regime=%s signals=%d", normalized.ticker, verdict.value, p_up, entropy, ev_r, prior, regime.regime.value, len(confluence_signals), ) return ProbabilisticResult( verdict=verdict, p_up=p_up, entropy=entropy, ev_r=ev_r, prior=prior, posterior=p_up, likelihood_ratios=penalized_lrs, regime=regime.regime.value, reasoning=reasoning, )