You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

105 lines
3.7 KiB
Python

"""
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,
}