Files
stonks-oracle/services/aggregation/projection.py
T
Celes Renata 4e010bc048
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/push/build-1 Pipeline was successful
ci/woodpecker/push/build-2 Pipeline was successful
ci/woodpecker/push/build-3 Pipeline was successful
ci/woodpecker/push/finalize Pipeline was successful
Build and Push / lint-and-test (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.adapters.broker_adapter name:broker-adapter]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.aggregation.worker name:aggregation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.extractor.worker name:extractor]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.ingestion.worker name:ingestion]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.lake_publisher.worker name:lake-publisher]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.parser.worker name:parser]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.recommendation.worker name:recommendation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.scheduler.app name:scheduler]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.api.app:app --host 0.0.0.0 --port 8000 name:query-api]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.risk.app:app --host 0.0.0.0 --port 8000 name:risk]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.symbol_registry.app:app --host 0.0.0.0 --port 8000 name:symbol-registry]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.trading.app:app --host 0.0.0.0 --port 8000 name:trading-engine]) (push) Has been cancelled
Build and Push / build-dashboard (push) Has been cancelled
Build and Push / build-superset (push) Has been cancelled
Build and Push / integration-test (push) Has been cancelled
Build and Push / beta-gate (push) Has been cancelled
feat: signal math upgrade — probabilistic, regime-aware scoring pipeline
Implement full probabilistic signal processing pipeline gated behind
probabilistic_scoring_enabled feature flag in risk_configs:

- Bayesian log-likelihood accumulator with Beta posterior and entropy
- Regime detector (trend-following, panic, mean-reversion, uncertainty)
- Source accuracy tracker with per-source historical prediction accuracy
- Sigmoid confidence gate replacing binary gate
- Information gain surprise weighting for rare events
- Adaptive recency decay with event-specific half-lives
- Regime multiplier replacing market context multiplier
- Weighted disagreement entropy for contradiction detection
- Multiplicative macro exposure with conditional integration
- Graph-distance attenuated competitive signal propagation
- Exponentially weighted momentum with volatility scaling
- Expected value recommendation gate

All changes backward-compatible: flag=false preserves exact current behavior.
New outputs stored in existing JSONB columns (no schema changes except
source_accuracy table via migration 034).

Tests: 26 property-based tests (14 correctness properties), 99 unit tests,
1789 total tests passing with zero regressions.
2026-04-29 11:41:48 +00:00

496 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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, 13.1, 13.2, 13.3, 13.4, 13.5, 13.6
"""
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 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
# ---------------------------------------------------------------------------
# Exponentially weighted momentum (Requirements: 13.113.6)
# ---------------------------------------------------------------------------
def compute_ew_momentum(
strength_changes: list[float],
lambda_decay: float = 0.7,
) -> float:
"""Compute exponentially weighted momentum from historical strength changes.
Formula: M_t = Σ_{k=0}^{K-1} λ^k · ΔS_{t-k}
Normalized by geometric series sum Σ λ^k to produce value in [-1, 1].
When fewer than 2 historical cycles are available, returns 0.0
(caller should fall back to heuristic).
Args:
strength_changes: List of signed strength changes ΔS, most recent first.
Each value represents the change in signed trend strength from one
cycle to the next. Positive = strengthening bullish / weakening bearish.
lambda_decay: Decay factor λ (default 0.7). Must be in (0, 1).
Returns:
Normalized momentum in [-1, 1]. Returns 0.0 for empty or single-element lists.
Requirements: 13.1, 13.2, 13.3, 13.6
"""
if len(strength_changes) < 2:
return 0.0
# Use up to K=10 most recent changes, filtering out NaN values
k_max = min(len(strength_changes), 10)
changes = strength_changes[:k_max]
weighted_sum = 0.0
weight_sum = 0.0
for k, delta_s in enumerate(changes):
if math.isnan(delta_s):
continue
w = lambda_decay ** k
weighted_sum += w * delta_s
weight_sum += w
if weight_sum == 0.0:
return 0.0
normalized = weighted_sum / weight_sum
# Guard against NaN propagation
if math.isnan(normalized) or math.isinf(normalized):
return 0.0
return max(-1.0, min(1.0, normalized))
def compute_volatility_scaled_momentum(
momentum: float,
sigma_20: float,
) -> float:
"""Compute volatility-scaled momentum.
Formula: M_adj = M_t / max(σ_20, 0.01), clamped to [-2.0, 2.0].
Normalizes momentum relative to the ticker's typical price movement.
Args:
momentum: Raw or EW momentum value.
sigma_20: 20-day return standard deviation.
Returns:
Volatility-scaled momentum in [-2.0, 2.0].
Requirements: 13.4, 13.5
"""
denominator = max(sigma_20, 0.01)
scaled = momentum / denominator
# Guard against NaN propagation
if math.isnan(scaled) or math.isinf(scaled):
return 0.0
return max(-2.0, min(2.0, scaled))
# ---------------------------------------------------------------------------
# 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:
future_factor = math.pow(2.0, -future_age_days / half_life)
else:
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)