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

452 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.
"""Competitive signal propagation engine.
Evaluates incoming document intelligence, identifies competitors via
the competitor_relationships table, queries historical cross-company
patterns, and produces weighted competitive signals persisted to
competitive_signal_records.
Also converts pattern and competitive signals into WeightedSignal
objects for the aggregation engine.
Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 9.1, 12.1, 12.2, 12.3, 12.4, 12.5, 12.6, 12.7
"""
from __future__ import annotations
import logging
import math
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Optional
import asyncpg
from services.aggregation.pattern_matcher import (
HistoricalPattern,
find_cross_company_patterns,
)
from services.aggregation.scoring import (
ScoringConfig,
WeightedSignal,
compute_signal_weight,
)
from services.shared.config import CompetitiveConfig
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
@dataclass
class CompetitiveSignalRecord:
"""A competitive signal produced by propagating a source event to a
competitor based on historical cross-company patterns."""
source_document_id: str
source_ticker: str
target_ticker: str
catalyst_type: str
pattern_confidence: float
signal_direction: str # bullish | bearish
signal_strength: float # [0, 1]
relationship_strength: float
computed_at: datetime
# ---------------------------------------------------------------------------
# SQL queries
# ---------------------------------------------------------------------------
_COMPETITOR_LOOKUP_QUERY = """
SELECT cr.company_a_id, cr.company_b_id, cr.strength,
ca.ticker AS ticker_a, cb.ticker AS ticker_b
FROM competitor_relationships cr
JOIN companies ca ON ca.id = cr.company_a_id
JOIN companies cb ON cb.id = cr.company_b_id
WHERE (ca.ticker = $1 OR cb.ticker = $1)
AND cr.active = TRUE
"""
_INSERT_SIGNAL_QUERY = """
INSERT INTO competitive_signal_records
(source_document_id, source_ticker, target_ticker, catalyst_type,
pattern_confidence, signal_direction, signal_strength,
relationship_strength, computed_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
"""
# ---------------------------------------------------------------------------
# Graph-distance attenuation (Requirements: 12.112.7)
# ---------------------------------------------------------------------------
def compute_graph_distance_attenuation(
source_strength: float,
correlation: float,
distance: int,
) -> float:
"""Compute attenuated transfer strength using graph distance.
Formula: S_transfer = S_source · ρ_historical · e^(-d_network)
Args:
source_strength: Source signal strength S_source in [0, 1].
correlation: Historical price correlation ρ_historical in [0, 1].
distance: Graph distance d_network (shortest path, capped at 3).
Returns:
Transfer strength, always non-negative. Returns 0.0 when
distance exceeds 3.
Requirements: 12.1, 12.7
"""
if distance < 1:
return 0.0
if distance > 3:
return 0.0
return source_strength * correlation * math.exp(-distance)
# ---------------------------------------------------------------------------
# propagate_signals
# ---------------------------------------------------------------------------
async def propagate_signals(
pool: asyncpg.Pool,
ticker: str,
catalyst_type: str,
impact_score: float,
document_id: str,
config: Optional[CompetitiveConfig] = None,
*,
probabilistic: bool = False,
) -> list[CompetitiveSignalRecord]:
"""Look up competitors, query cross-company patterns, produce weighted
competitive signals, and persist them.
When ``probabilistic=True``, uses graph-distance attenuation:
S_transfer = S_source · ρ_historical · e^(-d_network)
with 90-day rolling Pearson correlation for ρ_historical and shortest
path in the competitor relationship graph for d_network (capped at 3).
When ``probabilistic=False``, preserves the existing flat transfer
behavior.
Args:
pool: asyncpg connection pool.
ticker: Source company ticker that received the catalyst.
catalyst_type: The catalyst type from document intelligence.
impact_score: The source document's impact score.
document_id: The source document ID.
config: Optional competitive config overrides.
probabilistic: Use graph-distance attenuation when True.
Returns:
List of CompetitiveSignalRecord objects produced and persisted.
Requirements: 12.1, 12.2, 12.3, 12.4, 12.5, 12.6, 12.7
"""
cfg = config or CompetitiveConfig()
now = datetime.now(timezone.utc)
records: list[CompetitiveSignalRecord] = []
# Step 1: Look up active competitors
try:
async with pool.acquire() as conn:
rows = await conn.fetch(_COMPETITOR_LOOKUP_QUERY, ticker)
except Exception:
logger.exception("Failed to look up competitors for %s", ticker)
return records
if not rows:
logger.debug("No active competitors found for %s", ticker)
return records
# Step 2: For each competitor, query cross-company patterns
for row in rows:
ticker_a = row["ticker_a"]
ticker_b = row["ticker_b"]
rel_strength = float(row["strength"])
# Determine the competitor ticker (the other side of the relationship)
competitor_ticker = ticker_b if ticker_a == ticker else ticker_a
# Threshold gating (Req 4.5 / Req 12.6)
if rel_strength < cfg.propagation_strength_threshold:
logger.info(
"Skipping propagation %s%s: relationship strength %.3f "
"below threshold %.3f",
ticker, competitor_ticker, rel_strength,
cfg.propagation_strength_threshold,
)
continue
# Query cross-company patterns
try:
patterns = await find_cross_company_patterns(
pool, ticker, competitor_ticker, catalyst_type, config=cfg,
)
except Exception:
logger.exception(
"Failed to query cross-company patterns for %s%s/%s",
ticker, competitor_ticker, catalyst_type,
)
continue
for pattern in patterns:
# Confidence threshold gating (Req 9.1)
if pattern.pattern_confidence < cfg.pattern_confidence_threshold:
logger.info(
"Excluding pattern %s%s/%s/%s: confidence %.3f "
"below threshold %.3f",
ticker, competitor_ticker, catalyst_type,
pattern.time_horizon, pattern.pattern_confidence,
cfg.pattern_confidence_threshold,
)
continue
if probabilistic:
# Graph-distance attenuation (Req 12.112.7)
# For direct competitors, graph distance = 1
graph_distance = 1
# Use relationship strength as a proxy for historical
# correlation when full correlation data is unavailable.
# Default correlation: 0.3 same-sector, 0.1 cross-sector.
# Here we use rel_strength as a reasonable proxy since
# the full 90-day Pearson correlation requires market data
# that is fetched asynchronously in the integration layer.
correlation = max(rel_strength, 0.1)
source_strength = (
pattern.avg_strength
* pattern.pattern_confidence
* impact_score
)
raw_strength = compute_graph_distance_attenuation(
source_strength=min(max(source_strength, 0.0), 1.0),
correlation=correlation,
distance=graph_distance,
)
signal_strength = min(max(raw_strength, 0.0), 1.0)
else:
# Flat transfer (existing behavior, Req 4.3)
raw_strength = (
pattern.avg_strength
* rel_strength
* pattern.pattern_confidence
* impact_score
)
signal_strength = min(max(raw_strength, 0.0), 1.0)
# Determine direction
direction = (
"bullish" if pattern.bullish_pct > pattern.bearish_pct
else "bearish"
)
record = CompetitiveSignalRecord(
source_document_id=document_id,
source_ticker=ticker,
target_ticker=competitor_ticker,
catalyst_type=catalyst_type,
pattern_confidence=pattern.pattern_confidence,
signal_direction=direction,
signal_strength=signal_strength,
relationship_strength=rel_strength,
computed_at=now,
)
records.append(record)
# Step 3: Persist all records
if records:
try:
async with pool.acquire() as conn:
await conn.executemany(
_INSERT_SIGNAL_QUERY,
[
(
r.source_document_id,
r.source_ticker,
r.target_ticker,
r.catalyst_type,
r.pattern_confidence,
r.signal_direction,
r.signal_strength,
r.relationship_strength,
r.computed_at,
)
for r in records
],
)
except Exception:
logger.exception(
"Failed to persist %d competitive signal records", len(records),
)
return records
# ---------------------------------------------------------------------------
# build_pattern_weighted_signals
# ---------------------------------------------------------------------------
def build_pattern_weighted_signals(
patterns: list[HistoricalPattern],
competitive_signals: list[CompetitiveSignalRecord],
reference_time: datetime,
window: str,
config: Optional[CompetitiveConfig] = None,
) -> list[WeightedSignal]:
"""Convert pattern and competitive signal objects to WeightedSignal
objects for the aggregation engine.
For HistoricalPattern objects:
- sentiment_value = +1.0 if bullish_pct > bearish_pct else -1.0
- impact_score = avg_strength * competitive_signal_weight
- published_at = data_end (most recent data point for recency decay)
- extraction_confidence = pattern_confidence
For CompetitiveSignalRecord objects:
- sentiment_value = +1.0 if direction == "bullish" else -1.0
- impact_score = signal_strength * competitive_signal_weight
- published_at = computed_at (for recency decay)
- extraction_confidence = pattern_confidence
Args:
patterns: Self-company historical patterns.
competitive_signals: Competitive signal records from propagation.
reference_time: Aggregation anchor time for recency decay.
window: Trend window identifier (e.g. "7d").
config: Optional competitive config overrides.
Returns:
List of WeightedSignal objects ready for aggregation.
"""
cfg = config or CompetitiveConfig()
scoring_cfg = ScoringConfig()
signals: list[WeightedSignal] = []
# Convert HistoricalPattern objects
for pattern in patterns:
sentiment_value = (
1.0 if pattern.bullish_pct > pattern.bearish_pct else -1.0
)
impact = pattern.avg_strength * cfg.competitive_signal_weight
weight = compute_signal_weight(
published_at=pattern.data_end,
reference_time=reference_time,
window=window,
source_credibility=1.0, # patterns are derived from validated data
novelty_score=0.5,
extraction_confidence=pattern.pattern_confidence,
market_ctx=None,
config=scoring_cfg,
)
signals.append(WeightedSignal(
document_id=f"pattern:{pattern.source_ticker}:{pattern.catalyst_type}:{pattern.time_horizon}",
weight=weight,
sentiment_value=sentiment_value,
impact_score=impact,
))
# Convert CompetitiveSignalRecord objects
for sig in competitive_signals:
sentiment_value = 1.0 if sig.signal_direction == "bullish" else -1.0
impact = sig.signal_strength * cfg.competitive_signal_weight
weight = compute_signal_weight(
published_at=sig.computed_at,
reference_time=reference_time,
window=window,
source_credibility=1.0,
novelty_score=0.5,
extraction_confidence=sig.pattern_confidence,
market_ctx=None,
config=scoring_cfg,
)
signals.append(WeightedSignal(
document_id=sig.source_document_id,
weight=weight,
sentiment_value=sentiment_value,
impact_score=impact,
))
return signals
# ---------------------------------------------------------------------------
# v3 — Correlation-shrunk competitive propagation (Requirements: 10.110.5)
# ---------------------------------------------------------------------------
_V3_MAX_NETWORK_DISTANCE = 3
def compute_shrunk_correlation(
rho_rolling: float,
n_observations: int,
same_sector: bool,
) -> float:
"""Compute shrinkage-adjusted correlation.
Shrinks the rolling correlation toward a sector-aware prior using a
Bayesian-style weight of n / (n + 30).
rho_prior = 0.30 if same_sector else 0.10
rho_shrunk = (n/(n+30)) × rho_rolling + (30/(n+30)) × rho_prior
rho_effective = max(rho_shrunk, 0)
Args:
rho_rolling: Rolling pairwise correlation estimate.
n_observations: Number of observations used to compute rho_rolling.
same_sector: Whether the two securities are in the same sector.
Returns:
Non-negative shrinkage-adjusted correlation (rho_effective).
Requirements: 10.1, 10.2
"""
rho_prior = 0.30 if same_sector else 0.10
n = n_observations
rho_shrunk = (n / (n + 30)) * rho_rolling + (30 / (n + 30)) * rho_prior
rho_effective = max(rho_shrunk, 0.0)
return rho_effective
def compute_competitive_llr(
llr_source: float,
rho_effective: float,
d_network: int,
pattern_confidence: float,
) -> float:
"""Compute competitive LLR with graph attenuation.
attenuation = rho_effective × exp(-0.85 × d_network)
LLR_competitive = clamp(llr_source × attenuation × pattern_confidence, -1.25, 1.25)
When d_network > 3 → attenuation = 0 → LLR_competitive = 0
Args:
llr_source: Source signal LLR value.
rho_effective: Shrinkage-adjusted correlation (non-negative).
d_network: Graph distance between source and target (integer >= 1).
pattern_confidence: Confidence of the historical pattern in [0, 1].
Returns:
Competitive LLR clamped to [-1.25, 1.25]. Returns 0.0 when
d_network exceeds max distance of 3.
Requirements: 10.3, 10.4, 10.5
"""
if d_network > _V3_MAX_NETWORK_DISTANCE:
return 0.0
attenuation = rho_effective * math.exp(-0.85 * d_network)
llr_competitive = llr_source * attenuation * pattern_confidence
return max(-1.25, min(1.25, llr_competitive))