417 lines
14 KiB
Python
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)
|