Files
stonks-oracle/services/aggregation/projection.py
T

417 lines
14 KiB
Python

"""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)