feat: competitive intelligence & historical pattern matching layer
This commit is contained in:
@@ -0,0 +1,416 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user