257 lines
7.5 KiB
Python
257 lines
7.5 KiB
Python
"""Bayesian accumulator for probabilistic sentiment aggregation.
|
||
|
||
Accumulates weighted signals into a Bayesian posterior using
|
||
log-likelihood accumulation, Beta distribution parameters, and
|
||
Shannon entropy for mixed-signal detection.
|
||
|
||
Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 9.1, 9.7
|
||
V3 posterior assembly: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from dataclasses import dataclass
|
||
from typing import TYPE_CHECKING
|
||
|
||
from services.aggregation.scoring import WeightedSignal
|
||
|
||
if TYPE_CHECKING:
|
||
from services.aggregation.regime import V3RegimeClassification
|
||
from services.aggregation.worker import EvidenceCluster
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class BayesianPosterior:
|
||
"""Bayesian posterior state from signal accumulation."""
|
||
|
||
p_bull: float # σ(L_t), bullish probability [0, 1]
|
||
alpha: float # Beta distribution α parameter (≥ 1.0)
|
||
beta: float # Beta distribution β parameter (≥ 1.0)
|
||
log_likelihood: float # Raw log-likelihood accumulation L_t
|
||
bayesian_confidence: float # 1 - 4αβ/(α+β)², [0, 1]
|
||
entropy: float # Shannon entropy H, [0, 1]
|
||
signal_count: int # Number of signals processed
|
||
|
||
|
||
# Uninformative prior (no evidence)
|
||
PRIOR = BayesianPosterior(
|
||
p_bull=0.5,
|
||
alpha=1.0,
|
||
beta=1.0,
|
||
log_likelihood=0.0,
|
||
bayesian_confidence=0.0,
|
||
entropy=1.0,
|
||
signal_count=0,
|
||
)
|
||
|
||
|
||
def compute_entropy(p_bull: float) -> float:
|
||
"""Shannon entropy H = -p·log₂(p) - (1-p)·log₂(1-p).
|
||
|
||
Returns value in [0, 1]. Maximum at p=0.5, zero at p=0 or p=1.
|
||
Handles edge cases p≤0 and p≥1 by returning 0.0.
|
||
"""
|
||
if p_bull <= 0.0 or p_bull >= 1.0:
|
||
return 0.0
|
||
q = 1.0 - p_bull
|
||
return -(p_bull * math.log2(p_bull) + q * math.log2(q))
|
||
|
||
|
||
def compute_bayesian_posterior(
|
||
signals: list[WeightedSignal],
|
||
) -> BayesianPosterior:
|
||
"""Accumulate weighted signals into a Bayesian posterior.
|
||
|
||
Computes:
|
||
- Log-likelihood: L_t = Σ(w_i · s_i)
|
||
- Bullish probability: P_bull = σ(L_t)
|
||
- Beta posterior: α = 1 + W_bull, β = 1 + W_bear
|
||
- Bayesian confidence: C = 1 - 4αβ/(α+β)²
|
||
- Shannon entropy: H = -p·log₂(p) - (1-p)·log₂(1-p)
|
||
|
||
Returns PRIOR for empty signal lists.
|
||
Skips signals with NaN weight or sentiment.
|
||
"""
|
||
if not signals:
|
||
return PRIOR
|
||
|
||
log_likelihood = 0.0
|
||
w_bull = 0.0
|
||
w_bear = 0.0
|
||
count = 0
|
||
|
||
for sig in signals:
|
||
combined = sig.weight.combined
|
||
sentiment = sig.sentiment_value
|
||
|
||
# Skip signals with NaN weight or sentiment
|
||
if math.isnan(combined) or math.isnan(sentiment):
|
||
continue
|
||
|
||
log_likelihood += combined * sentiment
|
||
|
||
if sentiment > 0.0:
|
||
w_bull += combined
|
||
elif sentiment < 0.0:
|
||
w_bear += combined
|
||
|
||
count += 1
|
||
|
||
if count == 0:
|
||
return PRIOR
|
||
|
||
# P_bull via sigmoid: σ(L_t) = 1 / (1 + exp(-L_t))
|
||
# Guard against overflow in exp for very large |L_t|
|
||
if log_likelihood > 500.0:
|
||
p_bull = 1.0
|
||
elif log_likelihood < -500.0:
|
||
p_bull = 0.0
|
||
else:
|
||
p_bull = 1.0 / (1.0 + math.exp(-log_likelihood))
|
||
|
||
# Beta posterior parameters
|
||
alpha = 1.0 + w_bull
|
||
beta_param = 1.0 + w_bear
|
||
|
||
# Bayesian confidence: C = 1 - 4αβ/(α+β)²
|
||
ab_sum = alpha + beta_param
|
||
bayesian_confidence = 1.0 - (4.0 * alpha * beta_param) / (ab_sum * ab_sum)
|
||
# Clamp to [0, 1] to guard against floating-point rounding
|
||
bayesian_confidence = max(0.0, min(1.0, bayesian_confidence))
|
||
|
||
# Shannon entropy
|
||
entropy = compute_entropy(p_bull)
|
||
|
||
return BayesianPosterior(
|
||
p_bull=p_bull,
|
||
alpha=alpha,
|
||
beta=beta_param,
|
||
log_likelihood=log_likelihood,
|
||
bayesian_confidence=bayesian_confidence,
|
||
entropy=entropy,
|
||
signal_count=count,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# V3 Posterior Assembly — Calibrated Evidence Engine
|
||
# Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class V3Posterior:
|
||
"""V3 posterior result from log-odds Bayesian assembly.
|
||
|
||
Attributes:
|
||
p_up: Posterior probability of upward move, (0, 1).
|
||
p_down: 1 - p_up.
|
||
log_odds: Raw log-odds (logit) of P_up.
|
||
strength: abs(2 × P_up - 1), signal conviction [0, 1].
|
||
direction: Classified direction string ('bullish', 'bearish', 'neutral').
|
||
n_eff_total: Total effective evidence count across all clusters.
|
||
regime: Market regime string used for this computation.
|
||
"""
|
||
|
||
p_up: float
|
||
p_down: float
|
||
log_odds: float
|
||
strength: float
|
||
direction: str
|
||
n_eff_total: float
|
||
regime: str
|
||
|
||
|
||
# Regime-specific direction thresholds: (bullish_threshold, bearish_threshold)
|
||
# P_up >= bullish → "bullish"; P_up <= bearish → "bearish"; else "neutral"
|
||
_V3_DIRECTION_THRESHOLDS: dict[str, tuple[float, float]] = {
|
||
"panic": (0.68, 0.32),
|
||
"trend_following": (0.60, 0.40),
|
||
"mean_reversion": (0.63, 0.37),
|
||
"uncertainty": (0.65, 0.35),
|
||
}
|
||
|
||
|
||
def _logit(p: float) -> float:
|
||
"""Compute logit = ln(p / (1-p)) with boundary guard."""
|
||
p = max(1e-10, min(1 - 1e-10, p))
|
||
return math.log(p / (1 - p))
|
||
|
||
|
||
def _sigmoid(x: float) -> float:
|
||
"""Compute sigmoid = 1 / (1 + exp(-x)) with overflow guard."""
|
||
if x > 500:
|
||
return 1.0
|
||
if x < -500:
|
||
return 0.0
|
||
return 1.0 / (1.0 + math.exp(-x))
|
||
|
||
|
||
def compute_v3_posterior(
|
||
clusters: list[EvidenceCluster],
|
||
regime: V3RegimeClassification,
|
||
p_prior: float = 0.50,
|
||
) -> V3Posterior:
|
||
"""Assemble v3 posterior via log-odds accumulation.
|
||
|
||
Computes:
|
||
logit(P_up) = logit(P_prior) + sum(gamma_regime × LLR_c)
|
||
P_up = sigmoid(log_odds), clamped to [1e-10, 1 - 1e-10]
|
||
strength = abs(2 × P_up - 1)
|
||
direction via regime-specific thresholds
|
||
|
||
Args:
|
||
clusters: List of EvidenceCluster objects with computed cluster_llr.
|
||
regime: V3RegimeClassification providing evidence_multiplier and regime.
|
||
p_prior: Calibrated prior probability, clamped to [0.40, 0.60].
|
||
|
||
Returns:
|
||
V3Posterior with computed posterior fields.
|
||
|
||
Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7
|
||
"""
|
||
# Clamp prior to [0.40, 0.60] (Req 5.6)
|
||
p_prior = max(0.40, min(0.60, p_prior))
|
||
|
||
# Gamma: regime-specific evidence multiplier (Req 5.2)
|
||
gamma = regime.evidence_multiplier
|
||
|
||
# Compute log-odds: logit(P_prior) + sum(gamma × LLR_c) (Req 5.1, 5.2)
|
||
log_odds = _logit(p_prior) + sum(gamma * c.cluster_llr for c in clusters)
|
||
|
||
# Compute P_up via sigmoid (Req 5.3)
|
||
p_up = _sigmoid(log_odds)
|
||
# Clamp to open interval (Req 5.3)
|
||
p_up = max(1e-10, min(1 - 1e-10, p_up))
|
||
|
||
p_down = 1.0 - p_up
|
||
|
||
# Strength = |2 × P_up - 1| (Req 5.4)
|
||
strength = abs(2.0 * p_up - 1.0)
|
||
|
||
# n_eff_total = sum of cluster n_eff (Req 5.5)
|
||
n_eff_total = sum(c.n_eff for c in clusters)
|
||
|
||
# Classify direction using regime-specific thresholds (Req 5.5)
|
||
regime_key = regime.regime.value # MarketRegime enum → string
|
||
bull_thresh, bear_thresh = _V3_DIRECTION_THRESHOLDS.get(
|
||
regime_key, (0.65, 0.35)
|
||
)
|
||
|
||
if p_up >= bull_thresh:
|
||
direction = "bullish"
|
||
elif p_up <= bear_thresh:
|
||
direction = "bearish"
|
||
else:
|
||
direction = "neutral"
|
||
|
||
return V3Posterior(
|
||
p_up=p_up,
|
||
p_down=p_down,
|
||
log_odds=log_odds,
|
||
strength=strength,
|
||
direction=direction,
|
||
n_eff_total=n_eff_total,
|
||
regime=regime_key,
|
||
)
|