"""Trend projection module — forward-looking trend estimates. Computes TrendProjection objects by combining current trend momentum, macro signal decay trajectories, and upcoming catalyst outlook. Projections are persisted alongside trend_window records. Requirements: 12.1, 12.2, 12.3, 12.4, 12.5, 12.9 """ from __future__ import annotations import json import logging import math from dataclasses import dataclass, field from datetime import datetime, timezone import asyncpg from services.shared.schemas import TrendDirection, TrendSummary logger = logging.getLogger("projection") # --------------------------------------------------------------------------- # TrendProjection dataclass # --------------------------------------------------------------------------- VALID_DIRECTIONS = {"bullish", "bearish", "mixed", "neutral"} VALID_HORIZONS = {"1d", "7d", "30d"} # Default low-confidence threshold DEFAULT_CONFIDENCE_THRESHOLD = 0.3 # Macro signal decay half-lives (in days) by estimated_duration DECAY_HALF_LIFE_DAYS: dict[str, float] = { "short_term": 1.0, # halve impact per day "medium_term": 7.0, # halve impact per week "long_term": 30.0, # halve impact per month } @dataclass class TrendProjection: """Forward-looking trend projection for a company.""" projected_direction: str = "neutral" # bullish|bearish|mixed|neutral projected_strength: float = 0.5 # [0, 1] projected_confidence: float = 0.5 # [0, 1] projection_horizon: str = "7d" # 1d|7d|30d driving_factors: list[str] = field(default_factory=list) macro_contribution_pct: float = 0.0 # [0, 1] diverges_from_current: bool = False computed_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) low_confidence: bool = False # --------------------------------------------------------------------------- # Macro impact row type (lightweight, avoids circular import with worker) # --------------------------------------------------------------------------- @dataclass class MacroEventInfo: """Minimal macro event info needed for projection computation.""" event_id: str = "" macro_impact_score: float = 0.0 impact_direction: str = "neutral" confidence: float = 0.5 estimated_duration: str = "short_term" severity: str = "low" event_age_hours: float = 0.0 # hours since event publication # --------------------------------------------------------------------------- # Projection horizon mapping from trend window # --------------------------------------------------------------------------- _WINDOW_TO_HORIZON: dict[str, str] = { "intraday": "1d", "1d": "1d", "7d": "7d", "30d": "30d", "90d": "30d", } # --------------------------------------------------------------------------- # Momentum computation # --------------------------------------------------------------------------- def compute_trend_momentum( current_strength: float, current_direction: str, previous_strength: float | None = None, previous_direction: str | None = None, ) -> float: """Compute trend momentum as rate of change in signed strength. Returns a value in [-1, 1] representing the momentum: - Positive = strengthening bullish or weakening bearish - Negative = strengthening bearish or weakening bullish - Zero = no change or no previous data When no previous data is available, uses a simple heuristic based on current strength and direction. """ dir_sign = _direction_sign(current_direction) if previous_strength is None or previous_direction is None: # Heuristic: assume momentum proportional to current signed strength return round(dir_sign * current_strength * 0.5, 6) prev_sign = _direction_sign(previous_direction) current_signed = dir_sign * current_strength previous_signed = prev_sign * previous_strength momentum = current_signed - previous_signed return round(max(-1.0, min(1.0, momentum)), 6) def _direction_sign(direction: str) -> float: """Map direction to a sign multiplier.""" if direction == "bullish": return 1.0 elif direction == "bearish": return -1.0 return 0.0 # --------------------------------------------------------------------------- # Macro signal decay projection # --------------------------------------------------------------------------- _SEVERITY_WEIGHT: dict[str, float] = { "critical": 1.0, "high": 0.75, "moderate": 0.5, "low": 0.25, } def project_macro_decay( events: list[MacroEventInfo], horizon_days: float, ) -> tuple[float, str]: """Project the aggregate macro signal after decay over the horizon. For each active macro event, compute the projected remaining impact using exponential decay based on estimated_duration: - short_term: half-life = 1 day - medium_term: half-life = 7 days - long_term: half-life = 30 days Returns: (projected_macro_strength, projected_macro_direction) where strength is in [0, 1] and direction is bullish|bearish|mixed|neutral. """ if not events: return 0.0, "neutral" positive_weight = 0.0 negative_weight = 0.0 for ev in events: half_life = DECAY_HALF_LIFE_DAYS.get(ev.estimated_duration, 7.0) # Current age in days current_age_days = ev.event_age_hours / 24.0 # Projected age at end of horizon future_age_days = current_age_days + horizon_days # Decay factor: ratio of future impact to current impact if half_life > 0: current_factor = math.pow(2.0, -current_age_days / half_life) future_factor = math.pow(2.0, -future_age_days / half_life) else: current_factor = 0.0 future_factor = 0.0 severity_w = _SEVERITY_WEIGHT.get(ev.severity, 0.25) projected_impact = ev.macro_impact_score * future_factor * severity_w if ev.impact_direction == "positive": positive_weight += projected_impact elif ev.impact_direction == "negative": negative_weight += projected_impact else: # mixed/neutral: split evenly positive_weight += projected_impact * 0.5 negative_weight += projected_impact * 0.5 total = positive_weight + negative_weight if total == 0.0: return 0.0, "neutral" strength = min(total, 1.0) if positive_weight > negative_weight * 1.2: direction = "bullish" elif negative_weight > positive_weight * 1.2: direction = "bearish" elif positive_weight > 0 and negative_weight > 0: direction = "mixed" else: direction = "neutral" return round(strength, 6), direction # --------------------------------------------------------------------------- # Horizon days mapping # --------------------------------------------------------------------------- _HORIZON_DAYS: dict[str, float] = { "1d": 1.0, "7d": 7.0, "30d": 30.0, } # --------------------------------------------------------------------------- # Core projection computation # --------------------------------------------------------------------------- def compute_projection( summary: TrendSummary, macro_events: list[MacroEventInfo] | None = None, macro_enabled: bool = True, confidence_threshold: float = DEFAULT_CONFIDENCE_THRESHOLD, previous_strength: float | None = None, previous_direction: str | None = None, upcoming_catalysts: list[str] | None = None, ) -> TrendProjection: """Compute a forward-looking trend projection. Combines: 1. Trend momentum (rate of change in strength) 2. Macro signal decay projection 3. Upcoming catalyst outlook 4. Current trend baseline Args: summary: The current trend summary. macro_events: Active macro events with their info. macro_enabled: Whether the macro layer is enabled. confidence_threshold: Below this, mark as low_confidence. previous_strength: Previous window's trend strength (optional). previous_direction: Previous window's trend direction (optional). upcoming_catalysts: Known upcoming catalysts from doc intelligence. Returns: A TrendProjection with projected direction, strength, and confidence. """ now = datetime.now(timezone.utc) current_dir = summary.trend_direction.value current_strength = summary.trend_strength current_confidence = summary.confidence horizon = _WINDOW_TO_HORIZON.get(summary.window.value, "7d") horizon_days = _HORIZON_DAYS.get(horizon, 7.0) driving_factors: list[str] = [] # 1. Compute trend momentum momentum = compute_trend_momentum( current_strength, current_dir, previous_strength, previous_direction, ) if abs(momentum) > 0.05: if momentum > 0: driving_factors.append(f"Positive momentum ({momentum:+.3f}) in recent trend strength") else: driving_factors.append(f"Negative momentum ({momentum:+.3f}) in recent trend strength") # 2. Project macro signal decay macro_strength = 0.0 macro_direction = "neutral" macro_contribution = 0.0 if macro_enabled and macro_events: macro_strength, macro_direction = project_macro_decay(macro_events, horizon_days) if macro_strength > 0: driving_factors.append( f"Macro signals project {macro_direction} impact " f"(strength {macro_strength:.3f}) over {horizon}" ) # 3. Factor in upcoming catalysts catalysts = upcoming_catalysts or [] for catalyst in catalysts[:3]: # limit to top 3 driving_factors.append(f"Upcoming catalyst: {catalyst}") catalyst_boost = min(len(catalysts) * 0.02, 0.1) # small boost per catalyst # 4. Combine into projected direction/strength/confidence # Momentum-based projection of company-specific trend momentum_projected_signed = _direction_sign(current_dir) * current_strength + momentum * 0.5 momentum_projected_strength = min(abs(momentum_projected_signed), 1.0) if macro_enabled and macro_strength > 0: # Blend company momentum with macro trajectory macro_weight = min(macro_strength * 0.4, 0.4) company_weight = 1.0 - macro_weight macro_signed = _direction_sign(macro_direction) * macro_strength blended_signed = ( company_weight * momentum_projected_signed + macro_weight * macro_signed ) projected_strength = round(min(abs(blended_signed) + catalyst_boost, 1.0), 6) macro_contribution = round(macro_weight, 6) # Determine projected direction from blended signal projected_direction = _signed_to_direction(blended_signed) else: # Company-only projection projected_strength = round(min(momentum_projected_strength + catalyst_boost, 1.0), 6) projected_direction = _signed_to_direction(momentum_projected_signed) # Compute projected confidence base_confidence = current_confidence * 0.8 # projection inherently less certain if macro_enabled and macro_strength > 0: # Macro data adds information → slight confidence boost macro_conf_boost = min(macro_strength * 0.15, 0.1) projected_confidence = round(min(base_confidence + macro_conf_boost, 1.0), 6) else: # Without macro data, reduce confidence further if not macro_enabled: projected_confidence = round(base_confidence * 0.85, 6) else: projected_confidence = round(base_confidence, 6) # Ensure driving_factors is never empty if not driving_factors: driving_factors.append(f"Baseline trend continuation: {current_dir} at strength {current_strength:.3f}") # 5. Flag divergence diverges = projected_direction != current_dir if diverges: driving_factors.append( f"DIVERGENCE: Current trend is {current_dir}, " f"projection is {projected_direction}" ) # Mark low confidence is_low_confidence = projected_confidence < confidence_threshold return TrendProjection( projected_direction=projected_direction, projected_strength=projected_strength, projected_confidence=projected_confidence, projection_horizon=horizon, driving_factors=driving_factors, macro_contribution_pct=macro_contribution, diverges_from_current=diverges, computed_at=now, low_confidence=is_low_confidence, ) def _signed_to_direction(signed_value: float) -> str: """Convert a signed strength value to a direction string.""" if signed_value > 0.1: return "bullish" elif signed_value < -0.1: return "bearish" elif abs(signed_value) > 0.02: return "mixed" return "neutral" # --------------------------------------------------------------------------- # PostgreSQL persistence # --------------------------------------------------------------------------- _INSERT_PROJECTION = """ INSERT INTO trend_projections ( trend_window_id, projected_direction, projected_strength, projected_confidence, projection_horizon, driving_factors, macro_contribution_pct, diverges_from_current, computed_at ) VALUES ( $1::uuid, $2, $3, $4, $5, $6::jsonb, $7, $8, $9 ) RETURNING id """ async def persist_trend_projection( pool: asyncpg.Pool, trend_window_id: str, projection: TrendProjection, ) -> str: """Persist a TrendProjection to the trend_projections table. Returns the row UUID. """ row_id = await pool.fetchval( _INSERT_PROJECTION, trend_window_id, projection.projected_direction, projection.projected_strength, projection.projected_confidence, projection.projection_horizon, json.dumps(projection.driving_factors), projection.macro_contribution_pct, projection.diverges_from_current, projection.computed_at, ) logger.info( "Persisted trend projection for window=%s: direction=%s strength=%.3f confidence=%.3f diverges=%s", trend_window_id, projection.projected_direction, projection.projected_strength, projection.projected_confidence, projection.diverges_from_current, ) return str(row_id)