Files
stonks-oracle/services/aggregation/bayesian.py
T

257 lines
7.5 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.
"""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,
)