gap-trading-system / analysis /bayesian_engine.py
l2345623's picture
Upload analysis/bayesian_engine.py with huggingface_hub
3e6b8c3 verified
Raw
History Blame Contribute Delete
17.9 kB
"""
Bayesian Gap Prediction Engine
═══════════════════════════════
Applies Bayes' theorem to iteratively update gap direction probabilities
as new evidence arrives throughout the week.
P(Gap_Up | Evidence) ∝ P(Evidence | Gap_Up) Γ— P(Gap_Up)
Evidence sources (each contributes a likelihood ratio):
1. Multi-timeframe momentum (40m, 1D, 3D)
2. RSI regime (overbought/oversold)
3. COT institutional positioning
4. News sentiment flow
5. Agent consensus (weighted by track record)
6. Historical gap seasonality (day-of-year patterns)
7. Volatility regime (ATR expansion/contraction)
8. Upcoming high-impact events (risk premium adjustment)
Priors are learned from historical trade outcomes stored in the database.
"""
from __future__ import annotations
import logging
import math
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
logger = logging.getLogger("gap_system.analysis.bayesian")
# ═══════════════════════════════════════════════════════════════════════════════
# BAYESIAN STATE
# ═══════════════════════════════════════════════════════════════════════════════
@dataclass
class BayesianState:
"""
Tracks the running posterior probability for a single asset.
Updated incrementally as new evidence arrives.
"""
asset: str
# Log-odds representation (numerically stable for repeated updates)
log_odds_bullish: float = 0.0 # 0.0 = 50/50 prior
# Track which evidence has been applied this cycle
evidence_applied: list[str] = field(default_factory=list)
# Historical prior calibration
historical_bull_rate: float = 0.50 # Updated from DB
total_historical_gaps: int = 0
# Cycle tracking
update_count: int = 0
last_updated: str = ""
@property
def probability_bullish(self) -> float:
"""Convert log-odds to probability."""
return 1.0 / (1.0 + math.exp(-self.log_odds_bullish))
@property
def probability_bearish(self) -> float:
return 1.0 - self.probability_bullish
@property
def direction(self) -> str:
p = self.probability_bullish
if p > 0.52:
return "BULLISH"
elif p < 0.48:
return "BEARISH"
return "NEUTRAL"
@property
def confidence(self) -> float:
"""Confidence = distance from 50%, scaled to [0, 1]."""
return abs(self.probability_bullish - 0.50) * 2.0
def apply_evidence(self, name: str, likelihood_ratio: float) -> None:
"""
Update posterior using a likelihood ratio (Bayes factor).
LR > 1.0 β†’ evidence supports BULLISH
LR < 1.0 β†’ evidence supports BEARISH
LR = 1.0 β†’ evidence is uninformative
log_odds += log(LR) is the Bayesian update rule.
"""
if likelihood_ratio <= 0:
logger.warning("Invalid likelihood ratio %.4f for %s β€” skipping", likelihood_ratio, name)
return
log_lr = math.log(likelihood_ratio)
# Damping: cap the maximum influence of any single evidence source
max_log_shift = 1.2 # ~3.3x odds shift max per evidence
log_lr = max(-max_log_shift, min(max_log_shift, log_lr))
old_p = self.probability_bullish
self.log_odds_bullish += log_lr
new_p = self.probability_bullish
self.evidence_applied.append(name)
self.update_count += 1
self.last_updated = datetime.now(timezone.utc).isoformat()
logger.info(
"Bayes [%s] %s: LR=%.3f β†’ P(Bull) %.1f%% β†’ %.1f%% (Ξ”%+.1f%%)",
self.asset, name, likelihood_ratio,
old_p * 100, new_p * 100, (new_p - old_p) * 100,
)
def reset_for_new_cycle(self) -> None:
"""Reset to prior for a new weekly cycle."""
# Use historical prior if available, else 50/50
if self.total_historical_gaps >= 5:
prior_p = max(0.20, min(0.80, self.historical_bull_rate))
else:
prior_p = 0.50
self.log_odds_bullish = math.log(prior_p / (1.0 - prior_p))
self.evidence_applied.clear()
self.update_count = 0
logger.info(
"Bayes [%s] reset. Prior: %.1f%% (from %d historical gaps)",
self.asset, prior_p * 100, self.total_historical_gaps,
)
def to_dict(self) -> dict:
return {
"asset": self.asset,
"probability_bullish": round(self.probability_bullish, 4),
"probability_bearish": round(self.probability_bearish, 4),
"direction": self.direction,
"confidence": round(self.confidence, 4),
"log_odds": round(self.log_odds_bullish, 4),
"evidence_applied": list(self.evidence_applied),
"update_count": self.update_count,
"historical_prior": round(self.historical_bull_rate, 4),
"historical_samples": self.total_historical_gaps,
"last_updated": self.last_updated,
}
# ═══════════════════════════════════════════════════════════════════════════════
# GLOBAL STATE REGISTRY
# ═══════════════════════════════════════════════════════════════════════════════
_states: dict[str, BayesianState] = {}
def get_state(asset: str) -> BayesianState:
if asset not in _states:
_states[asset] = BayesianState(asset=asset)
return _states[asset]
def get_all_states() -> dict[str, dict]:
return {asset: state.to_dict() for asset, state in _states.items()}
# ═══════════════════════════════════════════════════════════════════════════════
# EVIDENCE FUNCTIONS β€” Each returns a likelihood ratio
# ═══════════════════════════════════════════════════════════════════════════════
def _lr_momentum(fallback_metrics: dict) -> float:
"""
Multi-timeframe momentum.
Score ranges from -1 (strong bear) to +1 (strong bull).
Convert to likelihood ratio: LR = exp(score * strength).
"""
score = fallback_metrics.get("momentum_score", 0)
# Convert [-1, 1] score to LR
# score=+1.0 β†’ LRβ‰ˆ2.7 (strong bull evidence)
# score=-1.0 β†’ LRβ‰ˆ0.37 (strong bear evidence)
# score=0.0 β†’ LR=1.0 (uninformative)
return math.exp(score * 1.0)
def _lr_rsi(ta_4h: dict) -> float:
"""
RSI regime β€” mean-reverting signal.
Extreme RSI is contrarian evidence for gaps (weekend reversion).
"""
rsi = ta_4h.get("rsi", 50)
if rsi > 75:
return 0.65 # Overbought β†’ bear evidence for gap
elif rsi > 65:
return 0.85 # Mildly overbought
elif rsi < 25:
return 1.55 # Oversold β†’ bull evidence for gap
elif rsi < 35:
return 1.20 # Mildly oversold
return 1.0 # Neutral
def _lr_cot(cot_data: dict | None) -> float:
"""
COT institutional positioning β€” leading indicator.
Net long = institutions expect higher = bullish evidence.
Extreme positioning is CONTRARIAN (crowded trades reverse).
"""
if not cot_data:
return 1.0
bias = cot_data.get("bias", "NEUTRAL")
extreme = cot_data.get("extreme_flag", False)
if extreme:
# CONTRARIAN: extreme long = bearish signal
if bias == "BULLISH":
return 0.70 # Crowded long β†’ expect reversal down
elif bias == "BEARISH":
return 1.40 # Crowded short β†’ expect squeeze up
else:
# Non-extreme: follow the institutions
if bias == "BULLISH":
return 1.20
elif bias == "BEARISH":
return 0.85
return 1.0
def _lr_news_sentiment(news_data: list[dict]) -> float:
"""
News sentiment flow β€” aggregate positive/negative tilt.
Returns LR based on sentiment balance.
"""
if not news_data:
return 1.0
positive = sum(1 for n in news_data if str(n.get("sentiment", "")).startswith("positive"))
negative = sum(1 for n in news_data if str(n.get("sentiment", "")).startswith("negative"))
total = positive + negative
if total < 3:
return 1.0 # Not enough data
ratio = positive / total # 0 to 1
# ratio=0.8 β†’ LRβ‰ˆ1.5 (clear positive sentiment)
# ratio=0.2 β†’ LRβ‰ˆ0.67 (clear negative sentiment)
# ratio=0.5 β†’ LR=1.0 (balanced)
return math.exp((ratio - 0.5) * 1.5)
def _lr_agent_consensus(agents: list, agent_confidence: float, direction: str) -> float:
"""
Agent consensus β€” the multi-platform debate result.
Strong consensus is strong evidence. Split votes are weak.
"""
if not agents:
return 1.0
valid = [a for a in agents if getattr(a, 'last_cycle_success', True)]
if not valid:
return 1.0
bull = sum(1 for a in valid if a.current_stance == "BULLISH")
bear = sum(1 for a in valid if a.current_stance == "BEARISH")
total = bull + bear
if total == 0:
return 1.0
# Consensus strength: how lopsided is the vote?
consensus_ratio = max(bull, bear) / total
# Agent confidence modulates the strength
strength = (consensus_ratio - 0.5) * 2.0 * agent_confidence # 0 to 1
# Sign: positive if majority is BULLISH, negative if BEARISH
sign = 1.0 if bull > bear else -1.0
return math.exp(sign * strength * 0.8)
def _lr_technical_confluence(technical_summary: dict | None) -> float:
"""
Multi-timeframe technical confluence.
All timeframes aligned = strong directional evidence.
"""
if not technical_summary:
return 1.0
bias = technical_summary.get("weighted_bias", "NEUTRAL")
confluence = technical_summary.get("confluence_score", 0.5)
if bias == "NEUTRAL" or bias == "RANGING":
return 1.0
# Higher confluence = stronger evidence
strength = (confluence - 0.4) * 2.5 # 0 at 0.4, 1.5 at 1.0
strength = max(0, min(1.2, strength))
sign = 1.0 if bias == "BULLISH" else -1.0
return math.exp(sign * strength * 0.7)
def _lr_volatility_regime(fallback_metrics: dict) -> float:
"""
Volatility regime β€” modifies gap predictability.
High vol = larger gaps but less directional predictability.
Returns a dampening factor (closer to 1.0 in high vol).
"""
regime = fallback_metrics.get("volatility_regime", "NORMAL")
if regime == "HIGH":
# In high vol, all other evidence is less reliable
# We don't provide directional evidence, just reduce conviction
return 1.0 # Neutral β€” but other LRs are dampened by the engine
return 1.0 # Normal/Low vol doesn't modify
def _lr_structural_breakout(fallback_metrics: dict) -> float:
"""
Structural breakout/breakdown β€” strong directional evidence.
Price above prev week high or below prev week low.
"""
if fallback_metrics.get("above_prev_week_high", False):
return 1.6 # Strong bullish breakout
elif fallback_metrics.get("below_prev_week_low", False):
return 0.65 # Strong bearish breakdown
return 1.0
def _lr_upcoming_events(event_risk_score: float) -> float:
"""
Upcoming high-impact event risk β€” DAMPENER.
When major events are imminent, directional conviction should decrease.
This pulls all predictions toward 50/50 (LR β†’ 1.0).
event_risk_score: 0.0 (no events) to 1.0 (major event imminent)
"""
if event_risk_score <= 0.1:
return 1.0 # No dampening
# At risk=0.5 β†’ LRβ‰ˆ0.85 (mild dampening)
# At risk=1.0 β†’ LRβ‰ˆ0.6 (strong dampening toward uncertainty)
return 1.0 - (event_risk_score * 0.4)
# ═══════════════════════════════════════════════════════════════════════════════
# MAIN UPDATE FUNCTION β€” Called each debate cycle
# ═══════════════════════════════════════════════════════════════════════════════
def bayesian_update(
asset: str,
agents: list,
technical_summary: dict | None = None,
cot_data: dict | None = None,
news_data: list[dict] | None = None,
agent_confidence: float = 0.5,
agent_direction: str = "NEUTRAL",
event_risk_score: float = 0.0,
) -> dict:
"""
Run a full Bayesian update cycle for an asset.
Each evidence source contributes a likelihood ratio.
The posterior accumulates across cycles within the same week,
implementing true Bayesian learning.
Returns the current Bayesian state as a dict.
"""
state = get_state(asset)
# Extract sub-data
fallback = {}
ta_4h = {}
if technical_summary:
fallback = technical_summary.get("fallback_metrics", {})
ta_4h = technical_summary.get("4H", {})
# ── Apply each evidence source ──
# 1. Multi-timeframe momentum
if fallback:
lr = _lr_momentum(fallback)
state.apply_evidence("momentum", lr)
# 2. RSI regime
if ta_4h:
lr = _lr_rsi(ta_4h)
state.apply_evidence("rsi_regime", lr)
# 3. COT positioning
lr = _lr_cot(cot_data)
if lr != 1.0:
state.apply_evidence("cot_positioning", lr)
# 4. News sentiment
if news_data:
lr = _lr_news_sentiment(news_data)
if abs(lr - 1.0) > 0.05: # Only apply if meaningfully different
state.apply_evidence("news_sentiment", lr)
# 5. Agent consensus (the multi-platform AI debate)
if agents:
lr = _lr_agent_consensus(agents, agent_confidence, agent_direction)
state.apply_evidence("agent_consensus", lr)
# 6. Technical confluence
lr = _lr_technical_confluence(technical_summary)
if lr != 1.0:
state.apply_evidence("technical_confluence", lr)
# 7. Structural breakout
if fallback:
lr = _lr_structural_breakout(fallback)
if lr != 1.0:
state.apply_evidence("structural_breakout", lr)
# 8. Event risk dampening (reduces overall confidence)
if event_risk_score > 0.2:
# Dampen by pulling log-odds toward 0 (50/50)
dampen_factor = 1.0 - (event_risk_score * 0.3) # At max risk, retain 70%
state.log_odds_bullish *= dampen_factor
state.evidence_applied.append(f"event_risk_dampen({event_risk_score:.2f})")
logger.info(
"Bayes [%s] Event risk dampening: %.0f%% β†’ P(Bull) moved toward 50%%",
asset, event_risk_score * 100,
)
# 9. Volatility regime dampening
if fallback.get("volatility_regime") == "HIGH":
state.log_odds_bullish *= 0.85
state.evidence_applied.append("high_vol_dampen")
logger.info("Bayes [%s] High volatility dampening applied", asset)
logger.info(
"Bayes [%s] After update #%d: %s %.1f%% confidence (P_bull=%.1f%%, %d evidence sources)",
asset, state.update_count, state.direction,
state.confidence * 100, state.probability_bullish * 100,
len(state.evidence_applied),
)
return state.to_dict()
def load_historical_priors(trade_history: list[dict]) -> None:
"""
Load historical gap outcomes from the database to calibrate priors.
Called once at startup.
"""
# Group by asset
asset_outcomes: dict[str, list[bool]] = {}
for trade in trade_history:
asset = trade.get("asset", "")
direction = trade.get("direction", "")
profit = trade.get("profit_usd", 0) or 0
if not asset or not direction:
continue
# A "bullish gap" = gap opened higher than Friday close
# We track whether the BULLISH prediction was profitable
was_bullish = direction == "BULLISH"
was_correct = profit > 0
# If bullish and correct, gap went up. If bearish and correct, gap went down.
gap_went_up = (was_bullish and was_correct) or (not was_bullish and not was_correct)
asset_outcomes.setdefault(asset, []).append(gap_went_up)
for asset, outcomes in asset_outcomes.items():
state = get_state(asset)
state.total_historical_gaps = len(outcomes)
state.historical_bull_rate = sum(outcomes) / len(outcomes)
logger.info(
"Historical prior for %s: %.1f%% bullish (%d samples)",
asset, state.historical_bull_rate * 100, len(outcomes),
)
def reset_all_for_new_week() -> None:
"""Reset all Bayesian states for a new trading week."""
for state in _states.values():
state.reset_for_new_cycle()
logger.info("All Bayesian states reset for new week (%d assets)", len(_states))