feat: math core v3 engine upgrade

This commit is contained in:
Celes Renata
2026-06-27 12:21:41 +00:00
parent 365bc5d4b7
commit b4bf0f2361
34 changed files with 11693 additions and 3 deletions
+625
View File
@@ -8,9 +8,12 @@ Requirements: 2.12.6, 3.13.5, 4.24.3, 5.15.7, 6.16.5, 16.416.5
"""
from __future__ import annotations
import hashlib
import logging
import math
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from services.shared.schemas import MarketContext
@@ -588,3 +591,625 @@ def weighted_sentiment_average(signals: list[WeightedSignal]) -> float:
if total_weight == 0.0:
return 0.0
return weighted_sum / total_weight
# ===========================================================================
# V3 Calibrated Evidence Engine — EvidenceUnit and Normalization
# ===========================================================================
# All code below this line implements the v3 pipeline. It is gated behind
# the `v3_engine_enabled` feature flag at the worker/orchestration layer.
# ===========================================================================
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# V3 Event type base rates (expanded for v3 pipeline)
# ---------------------------------------------------------------------------
V3_EVENT_TYPE_BASE_RATES: dict[str, float] = {
"earnings": 0.25,
"guidance": 0.20,
"merger_acquisition": 0.05,
"product_launch": 0.15,
"regulatory": 0.10,
"management_change": 0.08,
"partnership": 0.12,
"legal": 0.07,
"analyst_rating": 0.30,
"market_data": 0.40,
}
V3_DEFAULT_BASE_RATE: float = 0.10
# Direction mapping constants
_POSITIVE_DIRECTIONS: frozenset[str] = frozenset({"positive", "bullish"})
_NEGATIVE_DIRECTIONS: frozenset[str] = frozenset({"negative", "bearish"})
_NEUTRAL_DIRECTIONS: frozenset[str] = frozenset({"neutral", "mixed"})
# Macro horizon mapping
_MACRO_HORIZON_MAP: dict[str, str] = {
"short_term": "7d",
"medium_term": "30d",
"long_term": "90d",
}
# Valid horizons
_VALID_HORIZONS: frozenset[str] = frozenset({"intraday", "1d", "7d", "30d", "90d"})
# ---------------------------------------------------------------------------
# EvidenceUnit dataclass
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class EvidenceUnit:
"""Canonical normalized signal representation for the v3 pipeline.
Every signal — company, macro, or competitive — is normalized into this
shape before entering the calibrated reliability / LLR pipeline.
"""
symbol: str
layer: str # "company" | "macro" | "competitive"
event_type: str
source_id: str
source_group: str
timestamp: datetime
horizon: str # "intraday" | "1d" | "7d" | "30d" | "90d"
direction: int # -1, 0, +1
sentiment_strength: float # [0, 1]
impact: float # [0, 1]
extraction_conf: float # [0, 1]
source_cred: float # [0, 1]
novelty: float # [0, 1]
event_base_rate: float # (0, 1]
cluster_id: str
# ---------------------------------------------------------------------------
# Helper functions
# ---------------------------------------------------------------------------
def _map_direction(direction_str: str | None) -> int:
"""Map a sentiment/impact_direction string to a numeric direction.
Returns:
+1 for positive/bullish, -1 for negative/bearish, 0 for neutral/mixed/unknown.
"""
if direction_str is None:
return 0
lowered = direction_str.lower().strip()
if lowered in _POSITIVE_DIRECTIONS:
return 1
if lowered in _NEGATIVE_DIRECTIONS:
return -1
return 0
def _get_event_base_rate(event_type: str | None) -> float:
"""Look up base rate for an event type, defaulting to 0.10."""
if event_type is None:
return V3_DEFAULT_BASE_RATE
return V3_EVENT_TYPE_BASE_RATES.get(event_type, V3_DEFAULT_BASE_RATE)
def _compute_cluster_id(
symbol: str,
horizon: str,
event_type: str,
source_group: str,
time_bucket: str,
) -> str:
"""Compute a deterministic cluster_id from the grouping key."""
key = f"{symbol}|{horizon}|{event_type}|{source_group}|{time_bucket}"
return hashlib.sha256(key.encode()).hexdigest()[:16]
def _default_time_bucket(ts: datetime, horizon: str) -> str:
"""Compute a time bucket string for clustering based on horizon.
Bucket resolution per horizon:
intraday → 1h, 1d → 4h, 7d → 24h, 30d → 72h, 90d → 168h
"""
bucket_hours: dict[str, int] = {
"intraday": 1,
"1d": 4,
"7d": 24,
"30d": 72,
"90d": 168,
}
hours = bucket_hours.get(horizon, 24)
# Truncate timestamp to bucket boundary
epoch_hours = int(ts.timestamp() / 3600)
bucket_start = (epoch_hours // hours) * hours
return str(bucket_start)
def _safe_float(value: Any, default: float = 0.5) -> float:
"""Extract a float value, substituting default for missing/None."""
if value is None:
return default
try:
return float(value)
except (TypeError, ValueError):
return default
def _clamp(value: float, lo: float, hi: float) -> float:
"""Clamp a value to [lo, hi]."""
return max(lo, min(value, hi))
# ---------------------------------------------------------------------------
# Normalization functions
# ---------------------------------------------------------------------------
def normalize_company_signal(
signal: dict[str, Any],
*,
cluster_id: str | None = None,
) -> EvidenceUnit | None:
"""Normalize a company signal into an EvidenceUnit.
Args:
signal: Dict with keys from document_impact_records or similar.
Required: symbol, timestamp, source_id
Optional: event_type, source_group, horizon, sentiment,
sentiment_strength, impact, extraction_conf,
source_cred, novelty
cluster_id: If provided, use this cluster_id. Otherwise compute
from the signal's grouping key.
Returns:
EvidenceUnit or None if required fields are missing.
"""
# Validate required fields
symbol = signal.get("symbol")
timestamp = signal.get("timestamp")
source_id = signal.get("source_id")
if not symbol:
logger.warning("v3: Rejecting company signal — missing 'symbol'. source: %s", signal.get("source_id", "unknown"))
return None
if timestamp is None:
logger.warning("v3: Rejecting company signal — missing 'timestamp'. symbol=%s, source_id=%s", symbol, source_id)
return None
if not source_id:
logger.warning("v3: Rejecting company signal — missing 'source_id'. symbol=%s", symbol)
return None
# Ensure timestamp is datetime
if isinstance(timestamp, str):
timestamp = datetime.fromisoformat(timestamp)
if timestamp.tzinfo is None:
timestamp = timestamp.replace(tzinfo=timezone.utc)
# Extract and default fields
event_type = signal.get("event_type") or "unknown"
source_group = signal.get("source_group") or "company"
horizon = signal.get("horizon") or "7d"
if horizon not in _VALID_HORIZONS:
horizon = "7d"
# Direction mapping
direction = _map_direction(signal.get("sentiment") or signal.get("direction"))
# Optional numeric fields — default to 0.5 if missing
sentiment_strength = _clamp(_safe_float(signal.get("sentiment_strength")), 0.0, 1.0)
impact = _clamp(_safe_float(signal.get("impact")), 0.0, 1.0)
extraction_conf = _clamp(_safe_float(signal.get("extraction_conf") or signal.get("extraction_confidence")), 0.0, 1.0)
source_cred = _clamp(_safe_float(signal.get("source_cred") or signal.get("source_credibility")), 0.0, 1.0)
novelty = _clamp(_safe_float(signal.get("novelty") or signal.get("novelty_score")), 0.0, 1.0)
# Event base rate
event_base_rate = _get_event_base_rate(event_type)
# Cluster ID
if cluster_id is None:
time_bucket = _default_time_bucket(timestamp, horizon)
cluster_id = _compute_cluster_id(symbol, horizon, event_type, source_group, time_bucket)
return EvidenceUnit(
symbol=str(symbol),
layer="company",
event_type=event_type,
source_id=str(source_id),
source_group=source_group,
timestamp=timestamp,
horizon=horizon,
direction=direction,
sentiment_strength=sentiment_strength,
impact=impact,
extraction_conf=extraction_conf,
source_cred=source_cred,
novelty=novelty,
event_base_rate=event_base_rate,
cluster_id=cluster_id,
)
def normalize_macro_signal(
signal: dict[str, Any],
*,
cluster_id: str | None = None,
) -> EvidenceUnit | None:
"""Normalize a macro signal into an EvidenceUnit.
Macro signals come from macro_impact_records joined with global_events.
Args:
signal: Dict with keys from macro impact/global event records.
Required: symbol (or ticker), timestamp, source_id (or event_id)
Optional: event_type, impact_direction, macro_impact_score,
event_confidence, estimated_duration, novelty
cluster_id: If provided, use this cluster_id.
Returns:
EvidenceUnit or None if required fields are missing.
"""
# Validate required fields
symbol = signal.get("symbol") or signal.get("ticker")
timestamp = signal.get("timestamp")
source_id = signal.get("source_id") or signal.get("event_id")
if not symbol:
logger.warning("v3: Rejecting macro signal — missing 'symbol'/'ticker'. source: %s", signal.get("source_id", "unknown"))
return None
if timestamp is None:
logger.warning("v3: Rejecting macro signal — missing 'timestamp'. symbol=%s, source_id=%s", symbol, source_id)
return None
if not source_id:
logger.warning("v3: Rejecting macro signal — missing 'source_id'/'event_id'. symbol=%s", symbol)
return None
# Ensure timestamp is datetime
if isinstance(timestamp, str):
timestamp = datetime.fromisoformat(timestamp)
if timestamp.tzinfo is None:
timestamp = timestamp.replace(tzinfo=timezone.utc)
# Extract fields
event_type = signal.get("event_type") or "unknown"
source_group = "macro"
# Horizon from estimated_duration
estimated_duration = signal.get("estimated_duration") or "medium_term"
horizon = _MACRO_HORIZON_MAP.get(estimated_duration, "30d")
# Direction from impact_direction
direction = _map_direction(signal.get("impact_direction") or signal.get("direction"))
# Impact from macro_impact_score
impact = _clamp(_safe_float(signal.get("macro_impact_score") or signal.get("impact")), 0.0, 1.0)
# Source cred and extraction conf from event_confidence
event_confidence = _safe_float(signal.get("event_confidence") or signal.get("confidence"))
source_cred = _clamp(event_confidence, 0.0, 1.0)
extraction_conf = _clamp(event_confidence, 0.0, 1.0)
# Novelty: 1.0 for new events (as per requirement 1.2)
novelty = _clamp(_safe_float(signal.get("novelty"), default=1.0), 0.0, 1.0)
# Sentiment strength — default 0.5 for macro
sentiment_strength = _clamp(_safe_float(signal.get("sentiment_strength")), 0.0, 1.0)
# Event base rate
event_base_rate = _get_event_base_rate(event_type)
# Cluster ID
if cluster_id is None:
time_bucket = _default_time_bucket(timestamp, horizon)
cluster_id = _compute_cluster_id(str(symbol), horizon, event_type, source_group, time_bucket)
return EvidenceUnit(
symbol=str(symbol),
layer="macro",
event_type=event_type,
source_id=str(source_id),
source_group=source_group,
timestamp=timestamp,
horizon=horizon,
direction=direction,
sentiment_strength=sentiment_strength,
impact=impact,
extraction_conf=extraction_conf,
source_cred=source_cred,
novelty=novelty,
event_base_rate=event_base_rate,
cluster_id=cluster_id,
)
def normalize_competitive_signal(
signal: dict[str, Any],
*,
cluster_id: str | None = None,
) -> EvidenceUnit | None:
"""Normalize a competitive signal into an EvidenceUnit.
Competitive signals come from pattern mining and cross-company propagation.
Args:
signal: Dict with keys from competitive_signal_records.
Required: symbol (or target_ticker), timestamp, source_id (or source_document_id)
Optional: event_type, signal_direction, signal_strength,
relationship_strength, pattern_confidence, time_horizon
cluster_id: If provided, use this cluster_id.
Returns:
EvidenceUnit or None if required fields are missing.
"""
# Validate required fields
symbol = signal.get("symbol") or signal.get("target_ticker")
timestamp = signal.get("timestamp")
source_id = signal.get("source_id") or signal.get("source_document_id")
if not symbol:
logger.warning("v3: Rejecting competitive signal — missing 'symbol'/'target_ticker'. source: %s", signal.get("source_id", "unknown"))
return None
if timestamp is None:
logger.warning("v3: Rejecting competitive signal — missing 'timestamp'. symbol=%s, source_id=%s", symbol, source_id)
return None
if not source_id:
logger.warning("v3: Rejecting competitive signal — missing 'source_id'/'source_document_id'. symbol=%s", symbol)
return None
# Ensure timestamp is datetime
if isinstance(timestamp, str):
timestamp = datetime.fromisoformat(timestamp)
if timestamp.tzinfo is None:
timestamp = timestamp.replace(tzinfo=timezone.utc)
# Extract fields
event_type = signal.get("event_type") or "unknown"
source_group = "competitive"
# Horizon from time_horizon field
time_horizon = signal.get("time_horizon") or signal.get("horizon") or "7d"
if time_horizon in _MACRO_HORIZON_MAP:
horizon = _MACRO_HORIZON_MAP[time_horizon]
elif time_horizon in _VALID_HORIZONS:
horizon = time_horizon
else:
horizon = "7d"
# Direction from signal_direction (bullish/bearish/neutral)
direction = _map_direction(signal.get("signal_direction") or signal.get("direction"))
# Impact = signal_strength × relationship_strength (Req 1.3)
signal_strength = _safe_float(signal.get("signal_strength"))
relationship_strength = _safe_float(signal.get("relationship_strength"))
impact = _clamp(signal_strength * relationship_strength, 0.0, 1.0)
# Source cred from pattern_confidence (Req 1.3)
pattern_confidence = _safe_float(signal.get("pattern_confidence"))
source_cred = _clamp(pattern_confidence, 0.0, 1.0)
# Extraction conf = pattern_confidence (Req 1.3)
extraction_conf = _clamp(pattern_confidence, 0.0, 1.0)
# Novelty: 1.0 for competitive signals (Req 1.3)
novelty = _clamp(_safe_float(signal.get("novelty"), default=1.0), 0.0, 1.0)
# Sentiment strength — default 0.5 for competitive
sentiment_strength = _clamp(_safe_float(signal.get("sentiment_strength")), 0.0, 1.0)
# Event base rate
event_base_rate = _get_event_base_rate(event_type)
# Cluster ID
if cluster_id is None:
time_bucket = _default_time_bucket(timestamp, horizon)
cluster_id = _compute_cluster_id(str(symbol), horizon, event_type, source_group, time_bucket)
return EvidenceUnit(
symbol=str(symbol),
layer="competitive",
event_type=event_type,
source_id=str(source_id),
source_group=source_group,
timestamp=timestamp,
horizon=horizon,
direction=direction,
sentiment_strength=sentiment_strength,
impact=impact,
extraction_conf=extraction_conf,
source_cred=source_cred,
novelty=novelty,
event_base_rate=event_base_rate,
cluster_id=cluster_id,
)
# ===========================================================================
# V3 Calibrated Reliability Pipeline
# ===========================================================================
@dataclass(frozen=True)
class SourceStats:
"""Historical accuracy stats for a signal source (Bayesian prior).
Used to compute q_source via Beta-Binomial shrinkage.
"""
source_id: str
hits: int = 0 # correct directional predictions
misses: int = 0 # incorrect directional predictions
alpha_0: float = 3.0 # Beta prior alpha (pseudo-successes)
beta_0: float = 3.0 # Beta prior beta (pseudo-failures)
@dataclass(frozen=True)
class ReliabilityComponents:
"""Breakdown of calibrated reliability for a single signal.
Each q_* factor is in [0, 1] and represents one quality dimension.
q_i is the final combined reliability used downstream in LLR conversion.
"""
q_ext: float # extraction confidence reliability
q_source: float # source accuracy reliability (Bayesian shrinkage)
q_recency: float # temporal freshness reliability
q_uniqueness: float # novelty / de-duplication reliability
q_i: float # final combined: clamp(q_ext × q_source × source_cred × q_recency × q_uniqueness, 0, 1)
# Horizon-specific base half-lives for recency decay (hours)
_V3_TAU_BASE: dict[str, float] = {
"intraday": 2.0,
"1d": 12.0,
"7d": 72.0,
"30d": 240.0,
"90d": 720.0,
}
def _sigmoid(x: float) -> float:
"""Compute sigmoid(x) = 1 / (1 + exp(-x)) with overflow guard."""
if x < -500.0:
return 0.0
if x > 500.0:
return 1.0
return 1.0 / (1.0 + math.exp(-x))
def compute_v3_reliability(
unit: EvidenceUnit,
source_stats: SourceStats,
cluster_position: int, # duplicate_count_before
reference_time: datetime,
) -> ReliabilityComponents:
"""Compute calibrated reliability components for an EvidenceUnit.
Implements Requirements 2.12.9: extraction confidence gate, Bayesian
source accuracy, adaptive recency decay, and novelty/uniqueness penalty.
Args:
unit: The normalized evidence unit to score.
source_stats: Historical accuracy record for the signal's source.
cluster_position: Number of signals in the same cluster ingested
before this one (duplicate_count_before). 0 for first-in-cluster.
reference_time: The "now" anchor for computing age_hours.
Returns:
ReliabilityComponents with individual factors and combined q_i.
"""
# --- q_ext: extraction confidence reliability (Req 2.1) ---
# sigmoid(8.0 × (extraction_conf - 0.55))
q_ext = _sigmoid(8.0 * (unit.extraction_conf - 0.55))
# --- q_source: Bayesian shrinkage source reliability (Req 2.2, 2.3) ---
alpha = source_stats.alpha_0 + source_stats.hits
beta = source_stats.beta_0 + source_stats.misses
e_theta = alpha / (alpha + beta)
# clamp((E[theta] - 0.50) / 0.35, 0, 1)
q_source = _clamp((e_theta - 0.50) / 0.35, 0.0, 1.0)
# --- q_recency: adaptive exponential decay (Req 2.4, 2.5, 2.6) ---
# Ensure tz-aware timestamps
ts = unit.timestamp
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
ref = reference_time
if ref.tzinfo is None:
ref = ref.replace(tzinfo=timezone.utc)
age_hours = max((ref - ts).total_seconds() / 3600.0, 0.0)
# Adaptive half-life: tau_adaptive = tau_base × (1 + 0.75 × impact + 0.50 × surprise)
# surprise = clamp(-log2(event_base_rate) / 5, 0, 1)
event_base_rate = unit.event_base_rate
if event_base_rate <= 0.0:
event_base_rate = 0.10 # Req 2.5: default to 0.10 to prevent log(0)
surprise = _clamp(-math.log2(event_base_rate) / 5.0, 0.0, 1.0)
tau_base = _V3_TAU_BASE.get(unit.horizon, 72.0)
tau_adaptive = tau_base * (1.0 + 0.75 * unit.impact + 0.50 * surprise)
# q_recency = 2^(-age_hours / tau_adaptive)
# Guard against extreme exponents
if tau_adaptive <= 0.0:
tau_adaptive = tau_base # fallback
exponent = -age_hours / tau_adaptive
# For very large negative exponents, result is effectively 0
if exponent < -1000.0:
q_recency = 0.0
else:
q_recency = math.pow(2.0, exponent)
# --- q_uniqueness: novelty + de-duplication (Req 2.7) ---
# clamp(0.5 + 0.5 × novelty, 0.5, 1.0) × (1 / sqrt(1 + dup_count))
novelty_factor = _clamp(0.5 + 0.5 * unit.novelty, 0.5, 1.0)
dedup_factor = 1.0 / math.sqrt(1.0 + cluster_position)
q_uniqueness = novelty_factor * dedup_factor
# --- q_i: combined reliability (Req 2.8) ---
q_i = _clamp(
q_ext * q_source * unit.source_cred * q_recency * q_uniqueness,
0.0,
1.0,
)
# --- Explainability floor on q_recency (Req 2.9) ---
# Apply floor of 0.01 only for the display value; q_i uses raw q_recency
q_recency_display = max(q_recency, 0.01)
return ReliabilityComponents(
q_ext=q_ext,
q_source=q_source,
q_recency=q_recency_display,
q_uniqueness=q_uniqueness,
q_i=q_i,
)
# ---------------------------------------------------------------------------
# V3 LLR Conversion (Requirements 3.13.6)
# ---------------------------------------------------------------------------
def compute_llr(unit: EvidenceUnit, q_i: float) -> float:
"""Convert calibrated reliability to log-likelihood ratio.
Requirements: 3.13.6
Formula:
p_correct = clamp(0.50 + 0.35 × q_i × impact × sentiment_strength, 0.501, 0.85)
LLR_i = direction × ln(p_correct / (1 - p_correct))
For neutral signals (direction == 0), returns 0.0 immediately.
For directional signals, the LLR sign always matches direction.
Bounds:
- Minimum |LLR| ≈ ln(0.501/0.499) ≈ 0.004 for directional signals
- Maximum |LLR| ≈ ln(0.85/0.15) ≈ 1.735
Args:
unit: The normalized evidence unit containing direction, impact,
and sentiment_strength.
q_i: The combined calibrated reliability from compute_v3_reliability.
Returns:
Log-likelihood ratio. Positive for bullish, negative for bearish,
zero for neutral.
"""
# Req 3.5: Neutral signals produce zero LLR
if unit.direction == 0:
return 0.0
# Req 3.13.2: Compute p_correct with calibrated reliability
p_correct = _clamp(
0.50 + 0.35 * q_i * unit.impact * unit.sentiment_strength,
0.501,
0.85,
)
# Req 3.33.4: LLR_i = direction × ln(p_correct / (1 - p_correct))
llr = unit.direction * math.log(p_correct / (1.0 - p_correct))
return llr