""" Overlay scorer - compute final overlay_score, band, confidence, and decision hints from a feature dict produced by FeatureBuilder. """ import math import logging from typing import Dict, Optional from app.core.overlay_config import ( SOURCE_WEIGHTS, BAND_THRESHOLDS, CONFIDENCE_PER_SOURCE, HOLD_EXTENSION_EXTEND_THRESHOLD, HOLD_EXTENSION_TRIM_THRESHOLD, ADD_ON_ELIGIBILITY_THRESHOLD, ) logger = logging.getLogger(__name__) def _sigmoid(x: float) -> float: """Sigmoid function mapping any real to (0, 1).""" return 1.0 / (1.0 + math.exp(-x)) def _zscore_to_01(z: Optional[float]) -> Optional[float]: """Convert z-score to 0~1 via sigmoid (z=0 → 0.5).""" if z is None: return None return _sigmoid(z) class OverlayScorer: """Compute final overlay score and derived metrics from a feature dict.""" def score(self, features: Dict) -> Dict: """ Given a features dict (output of FeatureBuilder.build_all_features), compute overlay_score, overlay_confidence, overlay_band, and decision hints. Returns a dict suitable for storing in OverlayFeatureRecord. """ # Map each z-score to 0~1 normalized = { "yahoo": _zscore_to_01(features.get("headline_burst_z")), "youtube": _zscore_to_01(features.get("youtube_influence_z")), "wikimedia": _zscore_to_01(features.get("wiki_attention_z")), "google_trends": _zscore_to_01(features.get("theme_heat_z")), "finra": _zscore_to_01(features.get("crowding_stress_z")), } # Source presence: based on actual raw data, not z-score availability. # Z-scores require 2+ days of history; a source is "present" if it has any data at all. source_presence_mask = { "yahoo": (features.get("headline_count_24h") or 0) > 0, "youtube": (features.get("youtube_mentions_24h") or 0) > 0, "wikimedia": features.get("wiki_views_1d") is not None, "google_trends": normalized.get("google_trends") is not None, "finra": features.get("short_volume_ratio") is not None, } # Weighted average across present sources total_weight = 0.0 weighted_sum = 0.0 present_count = 0 for src, val in normalized.items(): if val is not None: w = SOURCE_WEIGHTS.get(src, 0.0) weighted_sum += val * w total_weight += w present_count += 1 overlay_score = weighted_sum / total_weight if total_weight > 0 else 0.0 # Confidence based on number of active sources overlay_confidence = min(1.0, present_count * CONFIDENCE_PER_SOURCE) # Band assignment (evaluate thresholds from high to low) overlay_band = "silent" for band_name, threshold in sorted(BAND_THRESHOLDS.items(), key=lambda kv: -kv[1]): if overlay_score >= threshold: overlay_band = band_name break # Hold-extension hint if overlay_score >= HOLD_EXTENSION_EXTEND_THRESHOLD: hold_extension_hint = "extend" elif overlay_score <= HOLD_EXTENSION_TRIM_THRESHOLD: hold_extension_hint = "trim" else: hold_extension_hint = "neutral" # Add-on eligibility add_on_eligibility = overlay_score >= ADD_ON_ELIGIBILITY_THRESHOLD return { "overlay_score": round(overlay_score, 4), "overlay_confidence": round(overlay_confidence, 4), "overlay_band": overlay_band, "source_presence_mask": source_presence_mask, "hold_extension_hint": hold_extension_hint, "add_on_eligibility": add_on_eligibility, }