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