From b4bf0f236111ee4a17aae171da0f888d60439b78 Mon Sep 17 00:00:00 2001 From: Celes Renata Date: Sat, 27 Jun 2026 12:21:31 +0000 Subject: [PATCH] feat: math core v3 engine upgrade --- .kiro/specs/math-core-v3-engine/.config.kiro | 1 + .kiro/specs/math-core-v3-engine/design.md | 851 ++++++++++++ .../specs/math-core-v3-engine/requirements.md | 314 +++++ .kiro/specs/math-core-v3-engine/tasks.md | 451 +++++++ docs/stonks_oracle_math_v3_upgrade.md | 1201 +++++++++++++++++ services/aggregation/bayesian.py | 129 ++ services/aggregation/contradiction.py | 68 + services/aggregation/interpolation.py | 105 ++ services/aggregation/projection.py | 102 ++ services/aggregation/regime.py | 148 ++ services/aggregation/scoring.py | 625 +++++++++ services/aggregation/signal_propagation.py | 71 + services/aggregation/worker.py | 1018 +++++++++++++- services/api/app.py | 10 +- services/recommendation/eligibility.py | 291 ++++ services/risk/engine.py | 161 +++ services/trading/position_sizer.py | 111 ++ services/trading/stop_loss_manager.py | 97 ++ tests/test_pbt_v3_clustering.py | 168 +++ tests/test_pbt_v3_decision.py | 454 +++++++ tests/test_pbt_v3_layers.py | 173 +++ tests/test_pbt_v3_posterior.py | 609 +++++++++ tests/test_pbt_v3_projection.py | 107 ++ tests/test_pbt_v3_reliability.py | 417 ++++++ tests/test_v3_clustering.py | 290 ++++ tests/test_v3_confidence.py | 349 +++++ tests/test_v3_contradiction.py | 176 +++ tests/test_v3_eligibility.py | 629 +++++++++ tests/test_v3_evidence_unit.py | 569 ++++++++ tests/test_v3_integration.py | 454 +++++++ tests/test_v3_layers.py | 359 +++++ tests/test_v3_posterior.py | 471 +++++++ tests/test_v3_risk.py | 314 +++++ tests/test_v3_sizing.py | 403 ++++++ 34 files changed, 11693 insertions(+), 3 deletions(-) create mode 100644 .kiro/specs/math-core-v3-engine/.config.kiro create mode 100644 .kiro/specs/math-core-v3-engine/design.md create mode 100644 .kiro/specs/math-core-v3-engine/requirements.md create mode 100644 .kiro/specs/math-core-v3-engine/tasks.md create mode 100644 docs/stonks_oracle_math_v3_upgrade.md create mode 100644 tests/test_pbt_v3_clustering.py create mode 100644 tests/test_pbt_v3_decision.py create mode 100644 tests/test_pbt_v3_layers.py create mode 100644 tests/test_pbt_v3_posterior.py create mode 100644 tests/test_pbt_v3_projection.py create mode 100644 tests/test_pbt_v3_reliability.py create mode 100644 tests/test_v3_clustering.py create mode 100644 tests/test_v3_confidence.py create mode 100644 tests/test_v3_contradiction.py create mode 100644 tests/test_v3_eligibility.py create mode 100644 tests/test_v3_evidence_unit.py create mode 100644 tests/test_v3_integration.py create mode 100644 tests/test_v3_layers.py create mode 100644 tests/test_v3_posterior.py create mode 100644 tests/test_v3_risk.py create mode 100644 tests/test_v3_sizing.py diff --git a/.kiro/specs/math-core-v3-engine/.config.kiro b/.kiro/specs/math-core-v3-engine/.config.kiro new file mode 100644 index 0000000..292a8ee --- /dev/null +++ b/.kiro/specs/math-core-v3-engine/.config.kiro @@ -0,0 +1 @@ +{"specId": "ff0d03d7-3469-4551-bf05-15295b735c83", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/math-core-v3-engine/design.md b/.kiro/specs/math-core-v3-engine/design.md new file mode 100644 index 0000000..39d440e --- /dev/null +++ b/.kiro/specs/math-core-v3-engine/design.md @@ -0,0 +1,851 @@ +# Design Document: Math Core v3 Engine + +## Overview + +The v3 Calibrated Evidence Engine replaces the current dual-mode pipeline (heuristic + probabilistic) with a principled Bayesian evidence accumulation system. The upgrade transforms the signal processing core from weighted-sentiment averaging to: + +``` +EvidenceUnit → calibrated reliability (q_i) → log-likelihood ratio (LLR_i) +→ correlation-adjusted cluster LLR → posterior P_up → return distribution → EV/risk decision +``` + +**Key design goals:** +- Replace arbitrary weight products with calibrated probabilistic evidence +- Prevent correlated articles from inflating evidence counts +- Make confidence multiplicative so one bad dimension suppresses the trade +- Size positions using fractional Kelly criterion under hard risk caps +- Preserve the existing three-layer architecture and service boundaries +- Gate behind `v3_engine_enabled` feature flag with heuristic fallback + +**What changes vs. what stays:** +- The `WeightedSignal` abstraction remains as an intermediate before LLR conversion +- All output goes into existing JSONB metadata columns (no new migrations) +- Service file boundaries are preserved; internals are upgraded +- The heuristic pipeline stays as fallback, controlled by a single flag read per aggregation cycle + +## Architecture + +```mermaid +flowchart TD + subgraph Input Layer + CS[Company Signals] + MS[Macro Signals] + XS[Competitive Signals] + end + + subgraph Normalization + EU[EvidenceUnit Normalization] + end + + subgraph Scoring Pipeline + QI[Calibrated Reliability q_i] + LLR[LLR Conversion] + end + + subgraph Clustering + CL[Correlation-Aware Clustering] + NEFF[n_eff Computation] + CLLR[Cluster LLR] + end + + subgraph Posterior Assembly + REG[Regime Detection v3] + POST[Log-Odds Posterior P_up] + CON[LLR Entropy Contradiction] + CONF[Multiplicative Confidence] + end + + subgraph Decision Layer + PROJ[Posterior State Projection] + RET[Return Distribution / EV Gate] + ELIG[Regime-Aware Eligibility] + KELLY[Fractional Kelly Sizing] + STOP[Regime-Aware Stops] + HEAT[Stop-Defined Portfolio Heat] + end + + subgraph Quality & Control + DQ[Data Quality v3] + FF[Feature Flag Router] + TIER[Risk Tier Auto-Adjustment] + end + + CS --> EU + MS --> EU + XS --> EU + EU --> QI + QI --> LLR + LLR --> CL + CL --> NEFF + NEFF --> CLLR + REG --> POST + CLLR --> POST + POST --> CON + POST --> CONF + CONF --> PROJ + PROJ --> RET + RET --> ELIG + ELIG --> KELLY + KELLY --> STOP + KELLY --> HEAT + DQ --> CONF + FF --> EU + TIER --> KELLY +``` + +### Service File Mapping + +| Service File | v3 Responsibility | +|---|---| +| `services/aggregation/scoring.py` | EvidenceUnit normalization, q_i pipeline, LLR conversion | +| `services/aggregation/bayesian.py` | Posterior assembly via log-odds, P_up, strength | +| `services/aggregation/contradiction.py` | LLR entropy contradiction score | +| `services/aggregation/regime.py` | Regime detection v3 (ATR-normalized trend_z) | +| `services/aggregation/interpolation.py` | Noisy-OR macro exposure, LLR emission | +| `services/aggregation/signal_propagation.py` | Correlation-shrunk competitive propagation | +| `services/aggregation/projection.py` | Posterior state A_t, regime-aware decay | +| `services/aggregation/worker.py` | Orchestration, clustering, n_eff, confidence assembly | +| `services/recommendation/eligibility.py` | EV gate, regime-aware eligibility, mode escalation | +| `services/trading/position_sizer.py` | Fractional Kelly sizing under caps | +| `services/trading/stop_loss_manager.py` | Regime-aware stop/TP, trailing activation | +| `services/risk/engine.py` | Stop-defined heat, tier auto-adjustment | + +### Feature Flag Flow + +```mermaid +sequenceDiagram + participant Worker as aggregation/worker.py + participant DB as risk_configs table + participant V3 as v3 Pipeline + participant Heuristic as Heuristic Pipeline + + Worker->>DB: SELECT v3_engine_enabled + alt v3_engine_enabled = True + Worker->>V3: Run v3 pipeline + V3-->>Worker: Posterior + confidence + EV + alt Unhandled error + V3-->>Worker: Exception + Worker->>Heuristic: Fallback to heuristic + Worker->>Worker: Log error, record fallback in metadata + end + else v3_engine_enabled = False or DB error + Worker->>Heuristic: Run heuristic pipeline + end +``` + +## Components and Interfaces + +### 1. EvidenceUnit Dataclass (`scoring.py`) + +```python +@dataclass(frozen=True) +class EvidenceUnit: + """Canonical normalized signal representation for v3 pipeline.""" + symbol: str + layer: str # "company" | "macro" | "competitive" + event_type: str + source_id: str + source_group: str + timestamp: datetime + horizon: str # "intraday" | "1d" | "7d" | "30d" | "90d" + direction: int # -1, 0, +1 + sentiment_strength: float # [0, 1] + impact: float # [0, 1] + extraction_conf: float # [0, 1] + source_cred: float # [0, 1] + novelty: float # [0, 1] + event_base_rate: float # (0, 1] + cluster_id: str +``` + +**Normalization functions** — one per layer: +- `normalize_company_signal(impact_row, ...) -> EvidenceUnit` +- `normalize_macro_signal(macro_impact_record, global_event, ...) -> EvidenceUnit` +- `normalize_competitive_signal(competitive_signal_record, ...) -> EvidenceUnit` + +Each validates required fields (symbol, timestamp, source_id), substitutes 0.5 for missing optional numeric fields, and assigns direction from sentiment/impact_direction strings. + +### 2. Calibrated Reliability Pipeline (`scoring.py`) + +```python +@dataclass(frozen=True) +class ReliabilityComponents: + q_ext: float + q_source: float + q_recency: float + q_uniqueness: float + q_i: float # final combined reliability + +def compute_v3_reliability( + unit: EvidenceUnit, + source_stats: SourceStats, + cluster_position: int, # duplicate_count_before + reference_time: datetime, +) -> ReliabilityComponents: ... +``` + +Sub-computations: +- `q_ext = sigmoid(8.0 * (extraction_conf - 0.55))` +- `q_source = clamp((E[theta_s] - 0.50) / 0.35, 0, 1)` with Beta(alpha_0+hits, beta_0+misses) +- `q_recency = 2^(-age_hours / tau_adaptive)` with adaptive half-life +- `q_uniqueness = clamp(0.5 + 0.5 * novelty, 0.5, 1.0) * (1 / sqrt(1 + dup_count))` +- `q_i = clamp(q_ext * q_source * source_cred * q_recency * q_uniqueness, 0, 1)` + +### 3. LLR Conversion (`scoring.py`) + +```python +def compute_llr(unit: EvidenceUnit, q_i: float) -> float: + """Convert calibrated reliability to log-likelihood ratio.""" + p_correct = clamp(0.50 + 0.35 * q_i * unit.impact * unit.sentiment_strength, 0.501, 0.85) + if unit.direction == 0: + return 0.0 + return unit.direction * math.log(p_correct / (1 - p_correct)) +``` + +### 4. Correlation-Aware Clustering (`worker.py`) + +```python +@dataclass +class EvidenceCluster: + cluster_id: str + units: list[EvidenceUnit] + llrs: list[float] + n_eff: float + cluster_llr: float + +def cluster_evidence(units: list[EvidenceUnit], llrs: list[float]) -> list[EvidenceCluster]: + """Group by (symbol, horizon, event_type, source_group, time_bucket).""" + ... + +def compute_n_eff(llrs: list[float], correlations: list[list[float]]) -> float: + """n_eff = (sum w_i)^2 / (sum w_i^2 + 2*sum_{i float: + """LLR_c = clamp(weighted_mean(LLR_i, |LLR_i|) * sqrt(n_eff), -2.5, 2.5)""" + ... +``` + +Default pairwise correlations: +| Relationship | rho | +|---|---:| +| Same wire/story/source group | 0.80 | +| Same event, different publisher | 0.50 | +| Same theme, different event | 0.25 | +| Independent events | 0.00 | + +### 5. Posterior Assembly (`bayesian.py`) + +```python +@dataclass(frozen=True) +class V3Posterior: + p_up: float # sigmoid(log_odds) + p_down: float # 1 - p_up + log_odds: float # logit(P_prior) + sum(gamma * LLR_c) + strength: float # abs(2 * p_up - 1) + direction: str # bullish | bearish | neutral + n_eff_total: float + regime: str + +def compute_v3_posterior( + clusters: list[EvidenceCluster], + regime: RegimeClassification, + p_prior: float = 0.50, +) -> V3Posterior: ... +``` + +Direction thresholds by regime: +| Regime | Bullish if P_up >= | Bearish if P_up <= | +|---|---:|---:| +| panic | 0.68 | 0.32 | +| trend_following | 0.60 | 0.40 | +| mean_reversion | 0.63 | 0.37 | +| uncertainty | 0.65 | 0.35 | + +### 6. Regime Detection v3 (`regime.py`) + +```python +@dataclass(frozen=True) +class V3RegimeClassification: + regime: MarketRegime + trend_z: float # (EMA_20 - EMA_100) / ATR_20 + vol_ratio: float # sigma_20 / sigma_100 + evidence_multiplier: float # gamma_regime + confidence_multiplier: float + phi_decay: float # for projection + atr_multiplier: float # for stops + +def classify_regime_v3( + closing_prices: list[float], + daily_returns: list[float], + atr_20: float, +) -> V3RegimeClassification: ... +``` + +Classification rules (in priority order): +1. **Panic**: vol_ratio > 1.5 OR abs(trend_z) > 2.5 +2. **Trend following**: abs(trend_z) >= 0.75 AND vol_ratio < 1.3 +3. **Mean reversion**: abs(trend_z) < 0.50 AND vol_ratio < 1.0 +4. **Uncertainty**: all other cases + +### 7. LLR Entropy Contradiction (`contradiction.py`) + +```python +def compute_v3_contradiction(clusters: list[EvidenceCluster]) -> float: + """ + E_pos = sum(max(LLR_c, 0)) + E_neg = sum(max(-LLR_c, 0)) + H = -f_pos*log2(f_pos) - f_neg*log2(f_neg) + volume_factor = 1 - exp(-E_total / 3.0) + return H * volume_factor + """ + ... +``` + +### 8. Multiplicative Confidence (`worker.py`) + +```python +def compute_v3_confidence( + n_eff_total: float, + q_values: list[float], + llrs: list[float], + strength: float, + regime_confidence_mult: float, + contradiction: float, + data_quality: float, +) -> float: + """ + C_evidence = 1 - exp(-n_eff_total / 5.0) + C_quality = weighted_mean(q_i, |LLR_i|) + confidence = clamp( + C_evidence * sqrt(C_quality) * sqrt(max(strength, 0.05)) + * regime_confidence_mult * (1 - contradiction) * data_quality, + 0, 1 + ) + """ + ... +``` + +### 9. Noisy-OR Macro Exposure (`interpolation.py`) + +```python +def compute_normalized_macro_exposure(overlaps: dict[str, float]) -> float: + """ + E_raw = 1 - prod(1 - w_k * O_k) + E_max = 1 - prod(1 - w_k) + return E_raw / E_max + """ + ... + +def compute_macro_llr( + macro_impact: float, + event_confidence: float, + q_recency: float, + macro_direction: int, +) -> float: + """ + p_macro = clamp(0.50 + 0.30 * macro_impact * event_confidence * q_recency, 0.501, 0.80) + return macro_direction * log(p_macro / (1 - p_macro)) + """ + ... +``` + +### 10. Correlation-Shrunk Competitive Propagation (`signal_propagation.py`) + +```python +def compute_shrunk_correlation( + rho_rolling: float, + n_observations: int, + same_sector: bool, +) -> float: + """ + rho_prior = 0.30 if same_sector else 0.10 + rho_shrunk = (n/(n+30)) * rho_rolling + (30/(n+30)) * rho_prior + return max(rho_shrunk, 0) + """ + ... + +def compute_competitive_llr( + llr_source: float, + rho_effective: float, + d_network: int, + pattern_confidence: float, +) -> float: + """ + attenuation = rho_effective * exp(-0.85 * d_network) + return clamp(llr_source * attenuation * pattern_confidence, -1.25, 1.25) + """ + ... +``` + +### 11. Posterior State Projection (`projection.py`) + +```python +@dataclass +class V3ProjectionState: + a_t: float # accumulated evidence state + p_up_projected: float # sigmoid(logit(P_prior) + phi^h * A_t) + projected_strength: float + diverges: bool + phi_regime: float + +def compute_v3_projection( + a_prev: float, + cluster_llrs: list[float], + regime: V3RegimeClassification, + p_prior: float, + projection_horizon: int, + known_catalyst_llr: float = 0.0, +) -> V3ProjectionState: ... +``` + +Regime decay factors (phi): +| Regime | phi | +|---|---:| +| panic | 0.35 | +| trend_following | 0.80 | +| mean_reversion | 0.55 | +| uncertainty | 0.50 | + +### 12. Return Distribution and EV Gate (`eligibility.py`) + +```python +@dataclass(frozen=True) +class ReturnDistribution: + sigma_h: float # realized_vol_20d * sqrt(horizon_days / 252) + mu_h: float # tanh(A_projected / 3.0) * confidence * sigma_h + ev_long: float # mu_h - costs - 0.10 * CVaR_5 + min_edge: float # regime-specific minimum edge + eligible: bool + +def compute_return_distribution( + a_projected: float, + confidence: float, + realized_vol_20d: float, + horizon_days: int, + costs: float, + regime: str, +) -> ReturnDistribution: ... +``` + +### 13. Fractional Kelly Position Sizing (`position_sizer.py`) + +```python +def compute_kelly_sizing( + p_win: float, # P_up from posterior + b: float, # reward ratio: clamp(1.2 + 2*conf + str - contra, 1.2, 3.0) + confidence: float, + data_quality: float, + contradiction: float, + max_position_pct: float, + available_caps: dict[str, float], # sector, correlation, heat capacities +) -> float: + """ + f_kelly = (p_win * b - (1 - p_win)) / b + portfolio_pct = clamp(max(0, f_kelly) * 0.25 * confidence * data_quality * (1 - contradiction), 0, max_position_pct) + Apply min of all capacity constraints. + """ + ... +``` + +### 14. Stop-Defined Portfolio Heat (`risk/engine.py`) + +```python +def compute_portfolio_heat( + positions: list[OpenPosition], + stop_distances: dict[str, float], +) -> float: + """risk_dollars = position_value * stop_distance_pct; heat = sum(risk_dollars)""" + ... + +def check_heat_capacity( + current_heat: float, + new_risk_dollars: float, + max_heat_pct: float, + portfolio_value: float, +) -> bool: ... +``` + +### 15. Data Quality v3 (`worker.py`) + +```python +def compute_v3_data_quality( + units: list[EvidenceUnit], + extraction_failure_rate: float, + age_newest_hours: float, + n_source_types: int, +) -> float: + """ + Q_parse = 1 - extraction_failure_rate + Q_conf = weighted_mean(extraction_conf, impact) + Q_fresh = exp(-age_newest_hours / 168) + Q_coverage = 1 - exp(-N_valid / 5) + Q_diversity = min(1, log2(1 + N_source_types) / log2(4)) + return clamp(Q_parse * sqrt(Q_conf) * Q_fresh * Q_coverage * Q_diversity, 0, 1) + """ + ... +``` + +### 16. Risk Tier Auto-Adjustment (`risk/engine.py`) + +```python +@dataclass +class TierMetrics: + profit_factor_30d: float + max_drawdown_30d: float + calibration_error: float + realized_sharpe_30d: float + n_trades_30d: int + reserve_pool_pct: float + +def evaluate_tier_adjustment(metrics: TierMetrics) -> str: + """Returns 'upgrade' | 'downgrade' | 'hold'""" + ... +``` + +## Data Models + +### EvidenceUnit (frozen dataclass) + +| Field | Type | Range | Source | +|---|---|---|---| +| symbol | str | — | Required from signal | +| layer | str | company/macro/competitive | Set during normalization | +| event_type | str | — | catalyst_type or impact_type | +| source_id | str | — | document_id or event_id | +| source_group | str | — | publisher / "macro" / "competitive" | +| timestamp | datetime | — | published_at | +| horizon | str | intraday/1d/7d/30d/90d | window or estimated_duration mapping | +| direction | int | -1, 0, +1 | sentiment/direction mapping | +| sentiment_strength | float | [0, 1] | impact_score or sentiment confidence | +| impact | float | [0, 1] | impact_score | +| extraction_conf | float | [0, 1] | confidence field | +| source_cred | float | [0, 1] | source_credibility | +| novelty | float | [0, 1] | novelty_score | +| event_base_rate | float | (0, 1] | lookup by event_type | +| cluster_id | str | — | computed hash of clustering key | + +### V3 Posterior Output (stored in JSONB metadata) + +```json +{ + "v3_posterior": { + "p_up": 0.64, + "p_down": 0.36, + "log_odds": 0.58, + "strength": 0.28, + "confidence": 0.61, + "contradiction": 0.18, + "n_eff": 5.7, + "data_quality": 0.82, + "regime": "trend_following" + }, + "v3_return_model": { + "mu_h": 0.012, + "sigma_h": 0.041, + "ev_long": 0.007, + "min_edge": 0.0035 + }, + "v3_explainability": { + "top_positive_clusters": [...], + "top_negative_clusters": [...], + "suppression_reasons": [], + "risk_adjustments": [] + }, + "pipeline_mode": "v3" +} +``` + +### Source Statistics (for q_source computation) + +```python +@dataclass +class SourceStats: + source_id: str + hits: int = 0 # correct directional predictions + misses: int = 0 # incorrect directional predictions + alpha_0: int = 3 # prior + beta_0: int = 3 # prior +``` + +### Regime Parameters Table + +| Regime | gamma (evidence) | confidence_mult | phi (decay) | ATR_mult (stops) | min_edge | +|---|---:|---:|---:|---:|---:| +| panic | 0.70 | 0.70 | 0.35 | 2.5 | 0.0100 | +| trend_following | 1.10 | 1.00 | 0.80 | 1.8 | 0.0035 | +| mean_reversion | 0.90 | 0.95 | 0.55 | 1.4 | 0.0050 | +| uncertainty | 0.80 | 0.85 | 0.50 | 2.0 | 0.0075 | + +### Eligibility Thresholds (Regime-Specific) + +| Regime | confidence_min | contradiction_max | strength_min | +|---|---:|---:|---:| +| panic | 0.70 | 0.25 | 0.36 | +| trend_following | 0.55 | 0.40 | 0.20 | +| mean_reversion | 0.60 | 0.35 | 0.26 | +| uncertainty | 0.65 | 0.30 | 0.30 | + + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: Reliability q_i is bounded in [0, 1] + +*For any* valid EvidenceUnit with extraction_conf in [0,1], source_cred in [0,1], novelty in [0,1], any non-negative age_hours, and any non-negative duplicate_count_before, the computed q_i SHALL be in the range [0.0, 1.0]. + +**Validates: Requirements 2.8, 21.1** + +### Property 2: p_correct is bounded in [0.501, 0.85] + +*For any* valid q_i in [0, 1], impact in [0, 1], and sentiment_strength in [0, 1], the computed p_correct SHALL be in the range [0.501, 0.85]. + +**Validates: Requirements 3.1, 21.2** + +### Property 3: LLR sign matches direction and magnitude is bounded + +*For any* valid signal with direction in {-1, +1}, the computed LLR SHALL have the same sign as direction, with absolute magnitude in [ln(0.501/0.499), ln(0.85/0.15)] ≈ [0.004, 1.735]. + +**Validates: Requirements 3.2, 3.4, 3.5, 3.6, 21.3** + +### Property 4: Neutral signals produce zero LLR + +*For any* valid EvidenceUnit with direction = 0, regardless of all other field values, the computed LLR SHALL be exactly 0.0. + +**Validates: Requirements 1.5, 3.3** + +### Property 5: Effective evidence count n_eff is bounded by cluster size + +*For any* cluster of N signals with non-negative pairwise correlations rho_ij in [0, 1], the computed n_eff SHALL satisfy 0 < n_eff <= N. + +**Validates: Requirements 4.2, 21.4** + +### Property 6: Cluster LLR is clamped to [-2.5, 2.5] + +*For any* cluster configuration with any number of signals and any LLR values, the computed cluster LLR_c SHALL be in the range [-2.5, 2.5]. + +**Validates: Requirements 4.4, 4.5** + +### Property 7: Posterior P_up is in open interval (0, 1) + +*For any* set of cluster LLRs (each in [-2.5, 2.5]), any regime evidence multiplier gamma in {0.70, 0.80, 0.90, 1.10}, and any prior P_prior in [0.40, 0.60], the computed P_up SHALL be in (1e-10, 1 - 1e-10). + +**Validates: Requirements 5.3, 21.5** + +### Property 8: Contradiction is zero when evidence is unidirectional + +*For any* set of cluster LLRs where all clusters have the same sign (all positive or all negative), the computed contradiction score SHALL be 0.0. + +**Validates: Requirements 7.7, 21.7** + +### Property 9: Contradiction score is bounded in [0, 1] + +*For any* set of cluster LLRs (including mixed positive and negative), the computed contradiction score SHALL be in the range [0.0, 1.0]. + +**Validates: Requirements 7.6** + +### Property 10: Multiplicative confidence is bounded in [0, 1] and suppressed by weak dimensions + +*For any* valid inputs (n_eff_total >= 0, q_values in [0,1], strength in [0,1], regime_confidence_mult in (0,1], contradiction in [0,1], data_quality in [0,1]), the computed confidence SHALL be in [0, 1]. Furthermore, if any single dimension (data_quality, 1-contradiction, or C_quality) is below 0.01, the resulting confidence SHALL be below 0.10. + +**Validates: Requirements 8.3, 8.5, 21.6** + +### Property 11: Fractional Kelly sizing is bounded and respects negative edge + +*For any* valid inputs (P_up in (0,1), b in [1.2, 3.0], confidence in [0,1], data_quality in [0,1], contradiction in [0,1], max_position_pct > 0), the computed portfolio_pct SHALL be in [0, max_position_pct]. When f_kelly = (P_up * b - (1 - P_up)) / b <= 0, portfolio_pct SHALL be exactly 0. + +**Validates: Requirements 14.4, 14.7, 21.8, 21.9** + +### Property 12: Posterior state JSON round-trip + +*For any* valid V3Posterior state (p_up, log_odds, strength, confidence, contradiction, n_eff, data_quality, regime), serializing to JSON and deserializing SHALL produce an equivalent state within floating-point tolerance (1e-10). + +**Validates: Requirements 20.1, 21.10** + +### Property 13: Noisy-OR normalized exposure is bounded in [0, 1] + +*For any* overlap values O_k in [0, 1] for each dimension (geo, supply, commodity, sector) with fixed positive weights, the normalized macro exposure E_macro SHALL be in [0.0, 1.0], reaching exactly 1.0 when all O_k = 1.0. + +**Validates: Requirements 9.1, 9.2** + +### Property 14: Competitive LLR is clamped to [-1.25, 1.25] + +*For any* source LLR, shrunk correlation (non-negative), graph distance (1-3), and pattern confidence in [0,1], the computed competitive LLR SHALL be in [-1.25, 1.25]. + +**Validates: Requirements 10.4, 10.5** + +### Property 15: Graph attenuation is zero beyond max distance + +*For any* inputs where graph distance > 3, the computed attenuation SHALL be 0.0, producing zero competitive LLR regardless of other parameters. + +**Validates: Requirements 10.3** + +### Property 16: Projection evidence state decays toward zero + +*For any* initial evidence state A_t and regime decay phi in (0, 1), the projected state A_projected_h = phi^h * A_t SHALL have |A_projected_h| < |A_t| for all h >= 1, converging toward 0 as h increases. + +**Validates: Requirements 11.1, 11.3** + +### Property 17: Data quality score is bounded in [0, 1] + +*For any* valid inputs (extraction_failure_rate in [0,1], extraction_conf_i in [0,1], impact_i in [0,1], age_newest_hours >= 0, N_valid >= 0, N_source_types >= 0), the computed data_quality_score SHALL be in [0, 1]. + +**Validates: Requirements 17.6** + +### Property 18: Stop loss is below entry price and take profit is above + +*For any* entry_price > 0, stop_distance_pct in [0.005, 1.0), and reward ratio b >= 1.2, the computed stop_loss SHALL be less than entry_price and take_profit SHALL be greater than entry_price. + +**Validates: Requirements 16.2, 16.3** + +### Property 19: Trailing stop never decreases + +*For any* sequence of current prices and trailing stop computations, each new trailing_stop value SHALL be >= the previous trailing_stop value (monotonically non-decreasing). + +**Validates: Requirements 16.5** + +### Property 20: Regime classification is exhaustive and deterministic + +*For any* valid market data inputs (closing_prices of sufficient length, daily_returns, ATR_20 > 0), the regime classification SHALL produce exactly one of {panic, trend_following, mean_reversion, uncertainty} and the same inputs SHALL always produce the same classification. + +**Validates: Requirements 6.2, 6.3, 6.4, 6.5** + +### Property 21: EvidenceUnit normalization preserves field ranges + +*For any* valid company, macro, or competitive signal input, the normalized EvidenceUnit SHALL have: direction in {-1, 0, +1}, sentiment_strength in [0, 1], impact in [0, 1], extraction_conf in [0, 1], source_cred in [0, 1], novelty in [0, 1], and event_base_rate in (0, 1]. + +**Validates: Requirements 1.1, 1.2, 1.3, 1.4, 1.7** + +### Property 22: Portfolio heat rejection is correct + +*For any* set of open positions with stop distances, if the sum of (position_value × stop_distance_pct) exceeds max_portfolio_heat × portfolio_value, then new position entry SHALL be rejected. + +**Validates: Requirements 15.3, 15.5** + +### Property 23: Tier auto-adjustment obeys downgrade-any, upgrade-all logic + +*For any* TierMetrics, if ANY single downgrade condition is met (profit_factor < 1.0 OR drawdown > 0.12 OR calibration_error > 0.20 OR sharpe < 0), the result SHALL be "downgrade". An "upgrade" SHALL only occur when ALL upgrade conditions are simultaneously met. + +**Validates: Requirements 18.3, 18.4** + +## Error Handling + +### Fail-Closed Philosophy + +The v3 engine follows a fail-closed design: when in doubt, suppress the trade rather than emit a false signal. + +| Error Scenario | Response | Fallback | +|---|---|---| +| `v3_engine_enabled` flag unreadable | Default to heuristic mode | Log warning | +| Unhandled exception in v3 pipeline | Fall back to heuristic for that cycle | Log ERROR with traceback, record in metadata | +| Missing market data for regime | Default to "uncertainty" regime | Most conservative multipliers | +| Missing source statistics | q_source = 0.0 (neutral prior) | Source treated as untrusted | +| Missing realized_vol_20d | Use default 0.25 annualized | Conservative volatility estimate | +| Division by zero in n_eff | Return n_eff = 1.0 (single signal) | Denominator guard | +| NaN/Inf in any computation | Clamp to boundary, log warning | Never propagate NaN to output | +| data_quality < 0.50 | Force informational mode | Suppress trade recommendation | +| No company evidence (only macro/competitive) | Force informational | Unless macro_only_enabled | + +### Numerical Guards + +All mathematical functions include: +- **Sigmoid overflow**: Guard `exp(-x)` for x > 500 or x < -500 +- **Log domain**: Guard `log(x)` with x > 0 check; `log2(0)` treated as 0 in entropy +- **Division by zero**: All denominators checked > 0 before division +- **NaN propagation**: All outputs validated with `math.isnan()` check before storage +- **Clamp boundaries**: Final values clamped to documented ranges + +### Graceful Degradation Chain + +``` +v3 pipeline error → heuristic fallback → informational mode → no recommendation +``` + +Each level preserves audit trail via output metadata. + +## Testing Strategy + +### Dual Testing Approach + +**Property-Based Tests (Hypothesis):** +- Library: `hypothesis` (already in use in this project) +- Configuration: `@settings(max_examples=100)` minimum per property +- File naming: `tests/test_pbt_v3_*.py` +- Each property test tagged with: `# Feature: math-core-v3-engine, Property N: ` +- One property-based test per correctness property (23 properties → 23 PBT tests) + +**Unit Tests (pytest):** +- Specific examples with known inputs/outputs for each formula +- Edge cases: zero inputs, boundary values, NaN handling +- Integration between components (e.g., full pipeline from EvidenceUnit to recommendation) +- Error handling paths (DB errors, missing data, feature flag states) + +### Property Test Organization + +| Test File | Properties Covered | Module Under Test | +|---|---|---| +| `tests/test_pbt_v3_reliability.py` | 1, 2, 3, 4, 21 | scoring.py (q_i, p_correct, LLR) | +| `tests/test_pbt_v3_clustering.py` | 5, 6 | worker.py (n_eff, cluster LLR) | +| `tests/test_pbt_v3_posterior.py` | 7, 8, 9, 10, 12, 20 | bayesian.py, contradiction.py, worker.py | +| `tests/test_pbt_v3_layers.py` | 13, 14, 15 | interpolation.py, signal_propagation.py | +| `tests/test_pbt_v3_projection.py` | 16 | projection.py | +| `tests/test_pbt_v3_decision.py` | 11, 17, 18, 19, 22 | position_sizer.py, stop_loss_manager.py, eligibility.py | +| `tests/test_pbt_v3_tier.py` | 23 | risk/engine.py | + +### Hypothesis Strategy Design + +Key custom strategies for generating valid inputs: + +```python +from hypothesis import strategies as st + +# EvidenceUnit generator +evidence_units = st.builds( + EvidenceUnit, + symbol=st.text(min_size=1, max_size=5), + layer=st.sampled_from(["company", "macro", "competitive"]), + direction=st.sampled_from([-1, 0, 1]), + sentiment_strength=st.floats(min_value=0.0, max_value=1.0), + impact=st.floats(min_value=0.0, max_value=1.0), + extraction_conf=st.floats(min_value=0.0, max_value=1.0), + source_cred=st.floats(min_value=0.0, max_value=1.0), + novelty=st.floats(min_value=0.0, max_value=1.0), + event_base_rate=st.floats(min_value=0.01, max_value=1.0), + ... +) + +# Cluster LLR list generator +cluster_llrs = st.lists( + st.floats(min_value=-2.5, max_value=2.5), + min_size=1, max_size=20, +) + +# Regime generator +regimes = st.sampled_from(["panic", "trend_following", "mean_reversion", "uncertainty"]) +``` + +### Unit Test Coverage + +| Area | Key Example Tests | +|---|---| +| EvidenceUnit normalization | Company signal → correct fields; macro → correct horizon mapping | +| q_i pipeline | Known inputs → known outputs for each sub-formula | +| LLR conversion | p_correct=0.60 → LLR≈0.405; direction=-1 → negative LLR | +| Clustering | 3 identical articles → n_eff < 3; independent → n_eff = N | +| Posterior | Empty evidence → P_up=0.50; strong bullish → P_up > 0.60 | +| Contradiction | All bullish → 0; equal split → high score | +| Confidence | Zero data quality → near-zero confidence | +| Macro LLR | Full exposure → max LLR ≈ 1.10; zero overlap → LLR ≈ 0 | +| Competitive | Distance 4 → zero propagation; direct rival → attenuated signal | +| Kelly sizing | p_win=0.3, b=2 → f_kelly < 0 → size = 0 | +| Stops | Entry=100, stop_dist=0.02 → stop=98, TP > 100 | +| Feature flag | Flag false → heuristic path; flag true → v3 path | +| Error fallback | v3 raises → heuristic runs, error logged | + +### Integration Tests + +- Full pipeline: raw signals → EvidenceUnit → q_i → LLR → cluster → posterior → recommendation +- Feature flag toggle: verify clean switch between pipelines mid-run +- JSONB round-trip: store v3 output in PostgreSQL JSONB, retrieve and verify +- Regime transitions: price series that crosses regime boundaries diff --git a/.kiro/specs/math-core-v3-engine/requirements.md b/.kiro/specs/math-core-v3-engine/requirements.md new file mode 100644 index 0000000..5f6ce81 --- /dev/null +++ b/.kiro/specs/math-core-v3-engine/requirements.md @@ -0,0 +1,314 @@ +# Requirements Document + +## Introduction + +This specification defines the requirements for upgrading the Stonks Oracle signal processing engine from the current dual-mode pipeline (heuristic + probabilistic) to the v3 Calibrated Evidence Engine. The v3 engine replaces arbitrary weighted-sentiment scoring with a principled probabilistic pipeline: calibrated reliability estimation, log-likelihood ratio (LLR) evidence accumulation, correlation-aware clustering, Bayesian posterior assembly, return distribution modeling, and Kelly-criterion position sizing. The upgrade preserves the existing three-layer architecture (company, macro, competitive), the WeightedSignal abstraction, database schema compatibility, and the service boundary structure. The entire v3 engine operates behind a `v3_engine_enabled` feature flag with the heuristic mode retained as a fallback. + +## Glossary + +- **V3_Engine**: The new calibrated evidence engine that replaces the current heuristic and probabilistic scoring modes +- **EvidenceUnit**: The canonical normalized shape for all signals (company, macro, competitive) before aggregation +- **LLR**: Log-Likelihood Ratio — the calibrated evidence contribution of a single signal or cluster, measured in log-odds units +- **Cluster**: A group of correlated signals sharing (symbol, horizon, event_type, source_group, time_bucket) +- **n_eff**: Effective evidence count within a cluster after de-correlation adjustment +- **Posterior**: The Bayesian posterior probability P_up computed via log-odds accumulation +- **Regime**: Market regime classification (panic, trend_following, mean_reversion, uncertainty) derived from EMA trend and volatility indicators +- **Contradiction_Score**: A measure of opposing evidence based on LLR entropy and evidence volume +- **Data_Quality_Score**: Multiplicative fail-closed quality metric combining parse rate, confidence, freshness, coverage, and diversity +- **Fractional_Kelly**: Position sizing method using Kelly criterion scaled by a conservative fraction (0.25) and further modulated by confidence, data quality, and contradiction +- **Portfolio_Heat**: Total stop-defined risk dollars across all open positions as a fraction of portfolio value +- **Feature_Flag**: The `v3_engine_enabled` runtime toggle that activates the v3 pipeline without code deployment +- **Scoring_Service**: The `services/aggregation/scoring.py` module responsible for signal weight computation +- **Bayesian_Service**: The `services/aggregation/bayesian.py` module responsible for posterior computation +- **Contradiction_Service**: The `services/aggregation/contradiction.py` module responsible for conflict detection +- **Regime_Service**: The `services/aggregation/regime.py` module responsible for market regime classification +- **Projection_Service**: The `services/aggregation/projection.py` module responsible for trend projection +- **Eligibility_Service**: The `services/recommendation/eligibility.py` module responsible for recommendation gating +- **Position_Sizer**: The `services/trading/position_sizer.py` module responsible for trade sizing +- **Stop_Loss_Manager**: The `services/trading/stop_loss_manager.py` module responsible for stop/TP computation +- **Risk_Engine**: The `services/risk/engine.py` module responsible for portfolio risk enforcement + +## Requirements + +### Requirement 1: Canonical Evidence Unit Normalization + +**User Story:** As the aggregation engine, I want all signals normalized into a canonical EvidenceUnit shape, so that company, macro, and competitive signals flow through a single unified pipeline. + +#### Acceptance Criteria + +1. WHEN a company signal is received, THE V3_Engine SHALL normalize the signal into an EvidenceUnit containing symbol, layer (set to "company"), event_type, source_id, source_group, timestamp, horizon (one of: intraday, 1d, 7d, 30d, 90d), direction (-1/0/+1), sentiment_strength [0,1], impact [0,1], extraction_conf [0,1], source_cred [0,1], novelty [0,1], event_base_rate (float in (0.0, 1.0]), and cluster_id +2. WHEN a macro signal is received, THE V3_Engine SHALL normalize the signal into an EvidenceUnit with layer set to "macro", symbol mapped from the macro impact record's ticker, direction mapped from impact_direction (positive→+1, negative→-1, neutral→0), impact mapped from macro_impact_score, source_cred mapped from event confidence, extraction_conf mapped from event confidence, novelty set to 1.0 for new events, source_id mapped from the global event id, source_group set to "macro", and horizon derived from the event's estimated_duration (short_term→7d, medium_term→30d, long_term→90d) +3. WHEN a competitive signal is received, THE V3_Engine SHALL normalize the signal into an EvidenceUnit with layer set to "competitive", symbol set to the target ticker, direction mapped from signal_direction (bullish→+1, bearish→-1, neutral→0), impact mapped from signal_strength × relationship_strength, source_cred mapped from pattern_confidence, extraction_conf set to pattern_confidence, novelty set to 1.0, source_id mapped from source_document_id, source_group set to "competitive", and horizon derived from the pattern's time_horizon field +4. THE V3_Engine SHALL assign direction value of +1 for signals with sentiment or impact_direction equal to "positive" or "bullish", -1 for "negative" or "bearish", and 0 for "neutral" or "mixed" +5. WHEN a signal has direction equal to 0 (neutral), THE V3_Engine SHALL include the signal in quality, coverage, and contradiction context computations but SHALL exclude the signal from directional posterior voting +6. IF a required source field (symbol, timestamp, or source_id) is missing or null in the incoming signal, THEN THE V3_Engine SHALL reject the signal, log a warning identifying the signal source and missing field, and not produce an EvidenceUnit for that signal +7. IF an optional numeric field (sentiment_strength, impact, extraction_conf, source_cred, novelty) is missing or null, THEN THE V3_Engine SHALL substitute a default value of 0.5 for the missing field +8. THE V3_Engine SHALL assign event_base_rate from a configured lookup by event_type, defaulting to 0.10 when no event_type-specific base rate is configured + +### Requirement 2: Calibrated Reliability Computation + +**User Story:** As the scoring engine, I want to compute a calibrated reliability q_i for each signal, so that evidence quality is measured probabilistically instead of via arbitrary weight products. + +#### Acceptance Criteria + +1. THE Scoring_Service SHALL compute extraction reliability as q_ext = sigmoid(k_ext × (extraction_conf - m_ext)) with defaults k_ext = 8.0 and m_ext = 0.55, where extraction_conf is in [0.0, 1.0] and q_ext output is in (0.0, 1.0) +2. THE Scoring_Service SHALL compute source reliability using Bayesian shrinkage: E[theta_s] = (alpha_0 + hits_s) / (alpha_0 + beta_0 + hits_s + misses_s) with defaults alpha_0 = 3, beta_0 = 3, and q_source = clamp((E[theta_s] - 0.50) / 0.35, 0.0, 1.0), where hits_s and misses_s are non-negative integers representing the source's historical correct and incorrect directional predictions +3. IF a source has zero historical outcomes (hits_s = 0 AND misses_s = 0), THEN THE Scoring_Service SHALL compute q_source = 0.0 from the prior (E[theta_s] = 0.5) +4. THE Scoring_Service SHALL compute recency reliability as q_recency = 2^(-age_hours / tau_adaptive) where tau_adaptive = tau_base × (1 + 0.75 × impact + 0.50 × surprise) and surprise = clamp(-log2(event_base_rate) / 5, 0, 1), with age_hours = max((reference_time - signal_timestamp).total_seconds() / 3600, 0.0) +5. IF event_base_rate is unavailable or equal to zero, THEN THE Scoring_Service SHALL use a default event_base_rate of 0.10 to prevent undefined logarithm computation +6. THE Scoring_Service SHALL use horizon-specific half-life defaults: intraday=2h, 1d=12h, 7d=72h, 30d=240h, 90d=720h +7. THE Scoring_Service SHALL compute uniqueness as q_uniqueness = clamp(0.50 + 0.50 × novelty, 0.50, 1.00) × (1 / sqrt(1 + duplicate_count_before)), where duplicate_count_before is the number of other signals in the same cluster that were ingested before this signal +8. THE Scoring_Service SHALL compute final signal reliability as q_i = clamp(q_ext × q_source × source_cred × q_recency × q_uniqueness, 0.0, 1.0) +9. WHEN q_recency falls below 0.01, THE Scoring_Service SHALL apply a floor of 0.01 only for explainability display output and SHALL use the unmodified q_recency value (including zero) for posterior voting computation + +### Requirement 3: Log-Likelihood Ratio Conversion + +**User Story:** As the posterior engine, I want signals converted to calibrated log-likelihood ratios, so that evidence accumulation follows proper Bayesian updating rules. + +#### Acceptance Criteria + +1. THE Scoring_Service SHALL compute directional correctness probability as p_correct = clamp(0.50 + 0.35 × q_i × impact × sentiment_strength, 0.501, 0.85) +2. THE Scoring_Service SHALL compute the signal log-likelihood ratio as LLR_i = direction × ln(p_correct / (1 - p_correct)), where ln denotes the natural logarithm (base e), consistent with the logit function used in posterior assembly +3. WHEN direction equals 0 (neutral signal), THE Scoring_Service SHALL produce LLR_i = 0.0, excluding the signal from directional posterior voting while retaining it for quality and contradiction context +4. THE Scoring_Service SHALL clamp p_correct to a minimum of 0.501 to ensure LLR_i is always nonzero for directional signals (direction ≠ 0), producing a minimum absolute LLR magnitude of approximately 0.004 +5. THE Scoring_Service SHALL clamp p_correct to a maximum of 0.85 to prevent any single signal from dominating the posterior, producing a maximum absolute LLR magnitude of approximately 1.735 +6. FOR ALL valid directional signals (direction ∈ {-1, +1}), THE Scoring_Service SHALL produce LLR_i values with the same sign as direction + +### Requirement 4: Correlation-Aware Evidence Clustering + +**User Story:** As the aggregation engine, I want correlated signals grouped and de-duplicated before posterior assembly, so that near-identical articles cannot inflate evidence counts. + +#### Acceptance Criteria + +1. THE V3_Engine SHALL cluster signals by (symbol, horizon, event_type, source_group, time_bucket) +2. THE V3_Engine SHALL compute effective evidence count as n_eff_c = (sum_i w_i)^2 / (sum_i w_i^2 + 2 × sum_{i<j}(rho_ij × w_i × w_j)) where w_i = abs(LLR_i) +3. THE V3_Engine SHALL use default pairwise correlations: rho=0.80 for same wire/story/source group, rho=0.50 for same event different publisher, rho=0.25 for same theme different event, rho=0.00 for independent events +4. THE V3_Engine SHALL compute cluster LLR as LLR_c = weighted_mean(LLR_i, abs(LLR_i)) × sqrt(n_eff_c) +5. THE V3_Engine SHALL clamp each cluster LLR to the range [-2.5, 2.5] to prevent any single cluster from dominating the posterior + +### Requirement 5: Posterior Assembly via Log-Odds + +**User Story:** As the Bayesian engine, I want to assemble a posterior probability from cluster LLRs and a regime-aware prior, so that the trading decision is based on calibrated belief. + +#### Acceptance Criteria + +1. THE Bayesian_Service SHALL use a neutral base prior of P_prior = 0.50 unless a calibrated symbol/sector prior is stored in the risk_configs table for the given ticker or its sector +2. THE Bayesian_Service SHALL compute posterior log-odds as logit(P_up) = logit(P_prior) + sum_c(gamma_regime × LLR_c) where gamma_regime is the regime evidence multiplier from Requirement 6 criterion 6 +3. THE Bayesian_Service SHALL compute P_up = sigmoid(logit(P_up)) = 1/(1+exp(-logit(P_up))) and P_down = 1 - P_up, clamping P_up to the range [1e-10, 1 - 1e-10] to avoid numerical boundary issues +4. THE Bayesian_Service SHALL compute trend strength as strength = abs(2 × P_up - 1), producing a value in [0.0, 1.0] where 0.0 indicates maximum uncertainty and 1.0 indicates maximum directional conviction +5. THE Bayesian_Service SHALL apply regime-specific direction thresholds to classify direction from P_up: panic (bullish when P_up >= 0.68, bearish when P_up <= 0.32), trend_following (bullish when P_up >= 0.60, bearish when P_up <= 0.40), mean_reversion (bullish when P_up >= 0.63, bearish when P_up <= 0.37), uncertainty (bullish when P_up >= 0.65, bearish when P_up <= 0.35). WHEN P_up falls between the bullish and bearish thresholds, THE Bayesian_Service SHALL classify direction as neutral +6. WHEN P_prior is calibrated from the risk_configs table, THE Bayesian_Service SHALL clamp P_prior to the range [0.40, 0.60] before computing logit(P_prior) +7. IF the risk_configs lookup for a calibrated prior fails due to a database error, THEN THE Bayesian_Service SHALL fall back to the neutral base prior of 0.50 and log a warning + +### Requirement 6: Regime Detection v3 + +**User Story:** As the regime service, I want to classify market regimes using z-scored indicators and apply regime-appropriate evidence multipliers, so that the engine adapts its sensitivity to market conditions. + +#### Acceptance Criteria + +1. THE Regime_Service SHALL compute trend_z = (EMA_20 - EMA_100) / ATR_20 where EMA_20 and EMA_100 are exponential moving averages of closing prices, and ATR_20 is the 20-day Average True Range. THE Regime_Service SHALL compute vol_ratio = sigma_20 / sigma_100 where sigma_20 and sigma_100 are standard deviations of daily returns +2. THE Regime_Service SHALL classify panic when vol_ratio > 1.5 OR abs(trend_z) > 2.5. Panic classification SHALL take priority over all other regimes +3. IF the regime is not panic, THEN THE Regime_Service SHALL classify trend_following when abs(trend_z) >= 0.75 AND vol_ratio < 1.3 +4. IF the regime is neither panic nor trend_following, THEN THE Regime_Service SHALL classify mean_reversion when abs(trend_z) < 0.50 AND vol_ratio < 1.0 +5. THE Regime_Service SHALL classify uncertainty for all conditions not matching panic, trend_following, or mean_reversion +6. THE Regime_Service SHALL apply regime evidence multipliers (gamma_regime): panic=0.70, trend_following=1.10, mean_reversion=0.90, uncertainty=0.80 +7. THE Regime_Service SHALL apply regime confidence multipliers: panic=0.70, trend_following=1.00, mean_reversion=0.95, uncertainty=0.85 +8. IF market data is insufficient to compute EMA_100 (fewer than 100 closing prices) or ATR_20 (fewer than 20 bars) or sigma_100 (fewer than 100 daily returns), THEN THE Regime_Service SHALL default to the uncertainty regime + +### Requirement 7: LLR Entropy Contradiction + +**User Story:** As the contradiction service, I want to measure meaningful opposing evidence using LLR entropy, so that contradiction reflects genuine disagreement scaled by evidence volume. + +#### Acceptance Criteria + +1. THE Contradiction_Service SHALL compute E_pos = sum_c(max(LLR_c, 0)) and E_neg = sum_c(max(-LLR_c, 0)) and E_total = E_pos + E_neg +2. WHEN E_total equals zero (no directional evidence from any cluster), THE Contradiction_Service SHALL return a contradiction score of 0.0 +3. WHEN E_total is greater than zero, THE Contradiction_Service SHALL compute f_pos = E_pos / E_total and f_neg = E_neg / E_total where f_pos + f_neg = 1.0 +4. THE Contradiction_Service SHALL compute H_conflict = -f_pos × log2(f_pos) - f_neg × log2(f_neg), treating 0 × log2(0) as 0 for the boundary case. H_conflict ranges from 0.0 (all one direction) to 1.0 (equal split) +5. THE Contradiction_Service SHALL compute volume_factor = 1 - exp(-E_total / 3.0), where 3.0 represents the evidence mass at which contradiction becomes 95% significant +6. THE Contradiction_Service SHALL compute the final contradiction score as H_conflict × volume_factor, producing a value in [0.0, 1.0] +7. WHEN only one direction of evidence exists (E_pos = 0 or E_neg = 0 but E_total > 0), THE Contradiction_Service SHALL return a contradiction score of 0.0 + +### Requirement 8: Multiplicative Confidence v3 + +**User Story:** As the aggregation engine, I want confidence computed multiplicatively from independent quality dimensions, so that one weak dimension suppresses the trade rather than being averaged away. + +#### Acceptance Criteria + +1. THE V3_Engine SHALL compute C_evidence = 1 - exp(-n_eff_total / 5.0) where n_eff_total = sum_c(n_eff_c) +2. THE V3_Engine SHALL compute C_quality = weighted_mean(q_i, weight=abs(LLR_i)) +3. THE V3_Engine SHALL compute confidence = clamp(C_evidence × sqrt(C_quality) × sqrt(max(strength, 0.05)) × regime_confidence_multiplier × (1 - contradiction) × data_quality_score, 0, 1) +4. THE V3_Engine SHALL use strength = abs(2 × P_up - 1) as the directional separation term +5. WHEN any single confidence dimension is near zero, THE V3_Engine SHALL produce a near-zero final confidence due to the multiplicative formula + +### Requirement 9: Macro Layer v3 (Noisy-OR Exposure) + +**User Story:** As the macro interpolation service, I want to compute normalized exposure via noisy-OR and emit macro evidence as LLR into the shared posterior, so that macro signals integrate with company evidence without special post-hoc modifiers. + +#### Acceptance Criteria + +1. THE V3_Engine SHALL compute macro exposure as E_raw = 1 - product_k(1 - w_k × O_k) with default weights w_geo=0.35, w_supply=0.25, w_commodity=0.25, w_sector=0.15 +2. THE V3_Engine SHALL normalize macro exposure as E_macro = E_raw / E_max where E_max = 1 - product_k(1 - w_k) +3. THE V3_Engine SHALL apply resilience dampener per market position tier: global_leader=0.70, multinational=0.85, regional=1.00, domestic=1.20 +4. THE V3_Engine SHALL compute macro likelihood ratio as LLR_macro = macro_direction × log(p_macro / (1 - p_macro)) where p_macro = clamp(0.50 + 0.30 × macro_impact × event_confidence × q_recency, 0.501, 0.80) +5. THE V3_Engine SHALL feed macro LLR into the same posterior engine as company evidence without requiring a separate post-hoc modifier + +### Requirement 10: Competitive Layer v3 (Correlation-Shrunk Propagation) + +**User Story:** As the signal propagation service, I want to use correlation-shrunk attenuation for competitive signals, so that propagated evidence is properly discounted by distance and relationship strength. + +#### Acceptance Criteria + +1. THE V3_Engine SHALL compute shrunk correlation as rho_shrunk = (n / (n + 30)) × rho_rolling + (30 / (n + 30)) × rho_prior with rho_prior_same_sector = 0.30 and rho_prior_cross_sector = 0.10 +2. THE V3_Engine SHALL compute rho_effective = max(rho_shrunk, 0) to use only positive propagation unless the relationship is explicitly inverse +3. THE V3_Engine SHALL compute graph attenuation as attenuation = rho_effective × exp(-0.85 × d_network) with max_distance = 3 +4. THE V3_Engine SHALL compute competitive LLR as LLR_competitive = LLR_source × attenuation × pattern_confidence +5. THE V3_Engine SHALL clamp competitive LLR to the range [-1.25, 1.25] to prevent competitive signals from dominating the posterior + +### Requirement 11: Trend Projection v3 (Posterior State) + +**User Story:** As the projection service, I want to project trends using a posterior state with regime-aware decay, so that projections are grounded in the same Bayesian framework as current estimates. + +#### Acceptance Criteria + +1. THE Projection_Service SHALL maintain an evidence state A_t = phi_regime × A_{t-1} + sum_c(LLR_c), initialized to A_0 = 0.0 when no prior state exists for a ticker-horizon pair +2. THE Projection_Service SHALL use regime-specific decay factors: panic phi=0.35, trend_following phi=0.80, mean_reversion phi=0.55, uncertainty phi=0.50 +3. THE Projection_Service SHALL compute projected alpha as A_projected_h = phi_regime^h × A_t + expected_known_catalyst_LLR_h, where h is the projection horizon in aggregation cycles and expected_known_catalyst_LLR_h defaults to 0.0 when no known catalysts exist +4. THE Projection_Service SHALL compute projected probability as P_up_projected_h = sigmoid(logit(P_prior_h) + A_projected_h) +5. THE Projection_Service SHALL compute projected strength as abs(2 × P_up_projected_h - 1) +6. THE Projection_Service SHALL flag divergence when sign(P_up_projected_h - 0.5) differs from sign(P_up_t - 0.5) +7. WHEN market data is insufficient for regime classification, THE Projection_Service SHALL use the uncertainty decay factor (phi=0.50) as the default + +### Requirement 12: Return Distribution and Expected Value Gate + +**User Story:** As the eligibility service, I want to gate recommendations using a return distribution model, so that only trades with positive risk-adjusted expected value pass through. + +#### Acceptance Criteria + +1. THE Eligibility_Service SHALL compute horizon volatility as sigma_h = realized_vol_20d × sqrt(horizon_days / 252), where horizon_days maps to 1 (intraday/1d), 7 (7d), 30 (30d), or 90 (90d) +2. THE Eligibility_Service SHALL compute expected return as mu_h = tanh(A_projected_h / 3.0) × confidence × sigma_h +3. THE Eligibility_Service SHALL compute EV_long = mu_h - costs - 0.10 × CVaR_5_loss, where costs = spread_cost + slippage_estimate + commission_estimate, and CVaR_5_loss = sigma_h × 1.645 × 1.4 (Gaussian approximation of expected loss beyond the 5th percentile) +4. THE Eligibility_Service SHALL compute regime-specific minimum edge: panic=0.0100, trend_following=0.0035, mean_reversion=0.0050, uncertainty=0.0075 +5. THE Eligibility_Service SHALL require EV_long > min_edge AND EV_long > max(0.0025, 0.25 × costs) for trade eligibility +6. THE Eligibility_Service SHALL require confidence >= regime_confidence_min AND contradiction <= regime_contradiction_max AND n_eff_total >= 2.0 AND data_quality_score >= 0.50 for eligibility +7. IF realized_vol_20d is unavailable (fewer than 20 trading days of price data), THEN THE Eligibility_Service SHALL use a default volatility of 0.25 (annualized) for sigma_h computation + +### Requirement 13: Recommendation Eligibility and Mode Escalation v3 + +**User Story:** As the recommendation service, I want regime-aware eligibility gates and mode escalation, so that recommendation quality matches the rigor of the v3 posterior. + +#### Acceptance Criteria + +1. THE Eligibility_Service SHALL apply regime-specific eligibility thresholds: panic (confidence_min=0.70, contradiction_max=0.25, strength_min=0.36), trend_following (confidence_min=0.55, contradiction_max=0.40, strength_min=0.20), mean_reversion (confidence_min=0.60, contradiction_max=0.35, strength_min=0.26), uncertainty (confidence_min=0.65, contradiction_max=0.30, strength_min=0.30) +2. THE Eligibility_Service SHALL map action as BUY when P_up >= bullish_threshold and EV_long > min_edge, SELL when existing position and EV_exit > EV_hold, HOLD when existing position, and WATCH otherwise +3. THE Eligibility_Service SHALL escalate to live_eligible when action is BUY or SELL and confidence >= 0.75 and contradiction <= 0.20 and n_eff_total >= 5 and EV_long > 2 × min_edge and risk_engine_passed +4. THE Eligibility_Service SHALL escalate to paper_eligible when action is BUY or SELL and confidence >= 0.60 and EV_long > min_edge and risk_engine_passed +5. IF eligibility gates are not met, THEN THE Eligibility_Service SHALL assign mode as informational + +### Requirement 14: Fractional Kelly Position Sizing + +**User Story:** As the position sizer, I want to size positions using fractional Kelly criterion, so that position sizes are proportional to edge and constrained by risk. + +#### Acceptance Criteria + +1. THE Position_Sizer SHALL compute stop_distance_pct = max(ATR_pct × ATR_multiplier_regime, sigma_h × 1.25, 0.005) where ATR_pct = ATR_14 / current_price, with regime ATR multipliers: panic=2.5, trend_following=1.8, mean_reversion=1.4, uncertainty=2.0 +2. THE Position_Sizer SHALL compute reward ratio b = clamp(1.2 + 2.0 × confidence + 1.0 × strength - contradiction, 1.2, 3.0) +3. THE Position_Sizer SHALL compute f_kelly = (p_win × b - (1 - p_win)) / b where p_win = P_up from the Bayesian posterior +4. THE Position_Sizer SHALL compute final sizing as portfolio_pct = clamp(max(0, f_kelly) × 0.25 × confidence × data_quality_score × (1 - contradiction), 0, max_position_pct) +5. THE Position_Sizer SHALL enforce hard caps by reducing portfolio_pct to the minimum of: max_position_pct from the active risk tier, available_sector_capacity_pct, available_correlation_capacity_pct (0 if weighted average absolute correlation with existing positions exceeds 0.80), and available_heat_capacity_pct +6. IF portfolio_pct after all caps is less than 0.005 (0.5% of portfolio), THEN THE Position_Sizer SHALL downgrade the recommendation to WATCH with reason "position_below_minimum" +7. IF f_kelly is less than or equal to zero, THEN THE Position_Sizer SHALL produce portfolio_pct = 0 and downgrade the recommendation to WATCH with reason "negative_edge" + +### Requirement 15: Stop-Defined Portfolio Heat + +**User Story:** As the risk engine, I want portfolio heat calculated from stop-defined risk dollars, so that risk measurement reflects actual loss exposure rather than position notional. + +#### Acceptance Criteria + +1. THE Risk_Engine SHALL compute risk_dollars = position_value × stop_distance_pct for each open position +2. THE Risk_Engine SHALL compute portfolio_heat = sum of risk_dollars across all open positions +3. IF portfolio_heat exceeds max_portfolio_heat × portfolio_value, THEN THE Risk_Engine SHALL reject new position entries +4. THE Position_Sizer SHALL compute available_heat_capacity = max_portfolio_heat × portfolio_value - current_portfolio_heat +5. THE Position_Sizer SHALL reject a position when the new risk_dollars would exceed available_heat_capacity + +### Requirement 16: Regime-Aware Stop Loss and Take Profit + +**User Story:** As the stop loss manager, I want stops and targets computed from regime-aware volatility and dynamic reward ratios, so that exit levels adapt to current market conditions. + +#### Acceptance Criteria + +1. THE Stop_Loss_Manager SHALL compute stop_distance_pct = max(ATR_pct × regime_ATR_multiplier, sigma_h × z_stop, min_stop_pct) with z_stop = 1.25 and min_stop_pct = 0.005 +2. THE Stop_Loss_Manager SHALL compute stop_loss = entry_price × (1 - stop_distance_pct) for long positions +3. THE Stop_Loss_Manager SHALL compute take_profit = entry_price × (1 + b × stop_distance_pct) where b = clamp(1.2 + 2.0 × confidence + 1.0 × strength - contradiction, 1.2, 3.0) +4. THE Stop_Loss_Manager SHALL activate trailing stop when unrealized_gain_pct >= 0.50 × take_profit_distance_pct +5. THE Stop_Loss_Manager SHALL compute trailing_stop = max(existing_stop, current_price × (1 - trailing_distance_pct)) where trailing_distance_pct = max(ATR_pct × trailing_ATR_mult, sigma_h × 0.75) + +### Requirement 17: Data Quality v3 (Multiplicative Fail-Closed) + +**User Story:** As the suppression layer, I want data quality computed as a multiplicative fail-closed metric, so that a single catastrophic quality failure suppresses the entire recommendation. + +#### Acceptance Criteria + +1. THE V3_Engine SHALL compute Q_parse = 1 - extraction_failure_rate +2. THE V3_Engine SHALL compute Q_conf = weighted_mean(extraction_conf_i, weight=impact_i) +3. THE V3_Engine SHALL compute Q_fresh = exp(-age_newest_hours / 168) +4. THE V3_Engine SHALL compute Q_coverage = 1 - exp(-N_valid / 5) +5. THE V3_Engine SHALL compute Q_diversity = min(1, log2(1 + N_source_types) / log2(4)) +6. THE V3_Engine SHALL compute data_quality_score = clamp(Q_parse × sqrt(Q_conf) × Q_fresh × Q_coverage × Q_diversity, 0, 1) +7. IF data_quality_score < 0.50 OR N_valid < 2 OR Q_parse < 0.50, THEN THE V3_Engine SHALL force the recommendation to informational mode +8. IF company evidence is zero and only macro or competitive evidence exists, THEN THE V3_Engine SHALL force the recommendation to informational mode unless macro_only_enabled is configured + +### Requirement 18: Risk Tier Auto-Adjustment v3 + +**User Story:** As the risk tier controller, I want tier adjustments based on risk-adjusted performance metrics, so that the engine self-corrects when performance degrades. + +#### Acceptance Criteria + +1. THE Risk_Engine SHALL track profit_factor_30d (gross_profit / gross_loss over 30 days), max_drawdown_30d (largest peak-to-trough portfolio decline over 30 days as a fraction), calibration_error (mean absolute difference between predicted P_up and realized binary outcome over 30 days), and realized_sharpe_30d (annualized Sharpe ratio of daily returns over 30 days) +2. THE Risk_Engine SHALL evaluate tier adjustment conditions once per calendar day after the trading session closes +3. THE Risk_Engine SHALL downgrade one tier if any condition is met: profit_factor_30d < 1.0 OR max_drawdown_30d > 0.12 OR calibration_error > 0.20 OR realized_sharpe_30d < 0 +4. THE Risk_Engine SHALL upgrade one tier only if all conditions are met: profit_factor_30d > 1.35 AND max_drawdown_30d < 0.05 AND calibration_error < 0.12 AND reserve_pool > 0.20 AND N_trades_30d >= 20 +5. IF a downgrade condition is triggered, THEN THE Risk_Engine SHALL apply the downgrade immediately without waiting for an upgrade evaluation +6. THE Risk_Engine SHALL enforce a minimum cooldown of 7 calendar days between consecutive upgrade evaluations to prevent tier oscillation + +### Requirement 19: Feature Flag and Fallback + +**User Story:** As an operator, I want the v3 engine gated behind a runtime feature flag with the heuristic mode as fallback, so that the upgrade can be rolled out safely without downtime. + +#### Acceptance Criteria + +1. WHILE `v3_engine_enabled` is False, THE V3_Engine SHALL route all aggregation through the existing heuristic pipeline without any v3 computation +2. WHILE `v3_engine_enabled` is True, THE V3_Engine SHALL route all aggregation through the v3 calibrated evidence pipeline +3. THE V3_Engine SHALL read the `v3_engine_enabled` flag from the risk_configs table at the start of each aggregation cycle without requiring a service restart +4. IF the v3 pipeline encounters an unhandled error during aggregation, THEN THE V3_Engine SHALL log the error at ERROR level with full traceback and fall back to heuristic mode for that aggregation cycle, recording the fallback event in output metadata +5. THE V3_Engine SHALL store a `pipeline_mode` field value of "v3" or "heuristic" in all output records (TrendSummary, Recommendation) JSONB metadata indicating which pipeline produced the result +6. IF the `v3_engine_enabled` flag cannot be read from the database (connection error or missing row), THEN THE V3_Engine SHALL default to heuristic mode and log a warning + +### Requirement 20: Database Compatibility and Output Contract + +**User Story:** As the system architect, I want v3 output stored in existing tables using JSONB metadata, so that no schema migration or downtime is required. + +#### Acceptance Criteria + +1. THE V3_Engine SHALL store posterior fields (p_up, log_odds, strength, confidence, contradiction, n_eff, data_quality) in the existing TrendSummary JSONB metadata column +2. THE V3_Engine SHALL store return model fields (mu_h, sigma_h, ev_long, min_edge) in the Recommendation JSONB metadata column +3. THE V3_Engine SHALL expose an explainability payload containing top_positive_clusters, top_negative_clusters, suppression_reasons, and risk_adjustments +4. THE V3_Engine SHALL preserve the existing WeightedSignal abstraction as an intermediate representation before LLR conversion +5. THE V3_Engine SHALL preserve all existing database table schemas without requiring new migrations for core functionality + +### Requirement 21: Mathematical Correctness Properties + +**User Story:** As a developer, I want property-based tests validating all v3 mathematical invariants, so that correctness is verified across the input space. + +#### Acceptance Criteria + +1. FOR ALL valid EvidenceUnits, THE V3_Engine SHALL produce q_i values in the range [0, 1] +2. FOR ALL valid q_i values, THE V3_Engine SHALL produce p_correct values in the range [0.501, 0.85] +3. FOR ALL valid signals with direction != 0, THE V3_Engine SHALL produce LLR values with the same sign as direction +4. FOR ALL valid cluster configurations, THE V3_Engine SHALL produce n_eff_c values satisfying 0 < n_eff_c <= N (where N is the cluster size) +5. FOR ALL valid cluster LLRs, THE Bayesian_Service SHALL produce P_up values in the range (0, 1) exclusive +6. FOR ALL valid inputs, THE V3_Engine SHALL produce confidence values in the range [0, 1] +7. FOR ALL valid inputs with no opposing evidence, THE Contradiction_Service SHALL produce a contradiction score of 0 +8. FOR ALL valid inputs, THE Position_Sizer SHALL produce portfolio_pct values in the range [0, max_position_pct] +9. FOR ALL valid inputs where f_kelly <= 0, THE Position_Sizer SHALL produce a portfolio_pct of 0 +10. FOR ALL valid EvidenceUnit sequences, serializing the posterior state to JSON and deserializing SHALL produce an equivalent posterior state (round-trip property) diff --git a/.kiro/specs/math-core-v3-engine/tasks.md b/.kiro/specs/math-core-v3-engine/tasks.md new file mode 100644 index 0000000..2b0063e --- /dev/null +++ b/.kiro/specs/math-core-v3-engine/tasks.md @@ -0,0 +1,451 @@ +# Implementation Plan: Math Core v3 Engine + +## Overview + +Incremental upgrade of the Stonks Oracle signal processing engine from dual-mode heuristic/probabilistic to the v3 Calibrated Evidence Engine. Implementation follows the v3 math doc section 20 order: EvidenceUnit → reliability → LLR → clustering → posterior → contradiction → confidence → macro/competitive layers → EV gate → Kelly sizing → stop-defined heat → retire heuristic scoring. All v3 code operates behind the `v3_engine_enabled` feature flag with heuristic fallback. + +## Tasks + +- [x] 1. EvidenceUnit and LLR conversion behind feature flag + - [x] 1.1 Implement EvidenceUnit dataclass and normalization functions in `services/aggregation/scoring.py` + - Add frozen dataclass `EvidenceUnit` with all 16 fields (symbol, layer, event_type, source_id, source_group, timestamp, horizon, direction, sentiment_strength, impact, extraction_conf, source_cred, novelty, event_base_rate, cluster_id) + - Implement `normalize_company_signal()`, `normalize_macro_signal()`, `normalize_competitive_signal()` + - Validate required fields (symbol, timestamp, source_id) — reject with warning on missing + - Substitute 0.5 for missing optional numeric fields + - Map direction from sentiment/impact_direction strings (+1/-1/0) + - Assign event_base_rate from EVENT_TYPE_BASE_RATES lookup (default 0.10) + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8_ + + - [x] 1.2 Implement calibrated reliability pipeline (`compute_v3_reliability`) in `services/aggregation/scoring.py` + - Add `SourceStats` dataclass (source_id, hits, misses, alpha_0=3, beta_0=3) + - Add `ReliabilityComponents` dataclass (q_ext, q_source, q_recency, q_uniqueness, q_i) + - Implement q_ext = sigmoid(8.0 × (extraction_conf - 0.55)) + - Implement q_source via Bayesian shrinkage: E[theta_s] = (alpha_0 + hits) / (alpha_0 + beta_0 + hits + misses), then clamp((E - 0.50) / 0.35, 0, 1) + - Implement q_recency = 2^(-age_hours / tau_adaptive) with adaptive half-life formula + - Implement q_uniqueness = clamp(0.5 + 0.5 × novelty, 0.5, 1.0) × (1 / sqrt(1 + dup_count)) + - Implement q_i = clamp(q_ext × q_source × source_cred × q_recency × q_uniqueness, 0, 1) + - Apply floor of 0.01 on q_recency only for explainability display + - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 2.8, 2.9_ + + - [x] 1.3 Implement LLR conversion (`compute_llr`) in `services/aggregation/scoring.py` + - Compute p_correct = clamp(0.50 + 0.35 × q_i × impact × sentiment_strength, 0.501, 0.85) + - Compute LLR_i = direction × ln(p_correct / (1 - p_correct)) + - Return 0.0 for neutral signals (direction == 0) + - Ensure LLR sign always matches direction for directional signals + - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6_ + + - [x] 1.4 Add feature flag routing in `services/aggregation/worker.py` + - Read `v3_engine_enabled` from risk_configs table at start of each aggregation cycle + - Route to v3 pipeline when True, heuristic when False + - Default to heuristic mode if DB read fails (log warning) + - Wrap v3 pipeline in try/except — fall back to heuristic on unhandled error (log ERROR with traceback) + - Store `pipeline_mode` field ("v3" or "heuristic") in output metadata + - _Requirements: 19.1, 19.2, 19.3, 19.4, 19.5, 19.6_ + + - [x] 1.5 Write property tests for EvidenceUnit, reliability, and LLR (`tests/test_pbt_v3_reliability.py`) + - **Property 1: Reliability q_i is bounded in [0, 1]** + - **Property 2: p_correct is bounded in [0.501, 0.85]** + - **Property 3: LLR sign matches direction and magnitude is bounded** + - **Property 4: Neutral signals produce zero LLR** + - **Property 21: EvidenceUnit normalization preserves field ranges** + - **Validates: Requirements 1.1–1.8, 2.1–2.9, 3.1–3.6, 21.1–21.3** + - Use Hypothesis with `@settings(max_examples=100)` + + - [x] 1.6 Write unit tests for EvidenceUnit normalization and LLR conversion (`tests/test_v3_evidence_unit.py`) + - Test company signal → EvidenceUnit with correct field mapping + - Test macro signal → EvidenceUnit with correct horizon mapping (short_term→7d, medium_term→30d, long_term→90d) + - Test competitive signal → EvidenceUnit with correct direction mapping + - Test missing required fields → rejection with warning + - Test missing optional fields → default 0.5 substitution + - Test known inputs through full q_i pipeline → expected outputs + - Test LLR boundary cases: p_correct at clamp boundaries + - _Requirements: 1.1–1.8, 2.1–2.9, 3.1–3.6_ + +- [x] 2. Evidence clustering and n_eff + + - [x] 2.1 Implement correlation-aware clustering in `services/aggregation/worker.py` + - Add `EvidenceCluster` dataclass (cluster_id, units, llrs, n_eff, cluster_llr) + - Implement `cluster_evidence()` — group EvidenceUnits by (symbol, horizon, event_type, source_group, time_bucket) + - Compute cluster_id as hash of grouping key + - Define time_bucket resolution per horizon (intraday=1h, 1d=4h, 7d=24h, 30d=72h, 90d=168h) + - _Requirements: 4.1_ + + - [x] 2.2 Implement n_eff computation in `services/aggregation/worker.py` + - Implement `compute_n_eff(llrs, correlations)` using formula: (sum w_i)² / (sum w_i² + 2 × sum_{i<j} rho_ij × w_i × w_j) + - Use default pairwise correlations: same wire=0.80, same event diff publisher=0.50, same theme diff event=0.25, independent=0.00 + - Guard against division by zero (denominator → return n_eff=1.0) + - _Requirements: 4.2, 4.3_ + + - [x] 2.3 Implement cluster LLR computation in `services/aggregation/worker.py` + - Implement `compute_cluster_llr(llrs, n_eff)` = clamp(weighted_mean(LLR_i, |LLR_i|) × sqrt(n_eff), -2.5, 2.5) + - Handle single-signal clusters (LLR_c = LLR_i clamped) + - Handle all-zero LLR clusters (cluster_llr = 0.0) + - _Requirements: 4.4, 4.5_ + + - [x] 2.4 Write property tests for clustering (`tests/test_pbt_v3_clustering.py`) + - **Property 5: Effective evidence count n_eff is bounded by cluster size** + - **Property 6: Cluster LLR is clamped to [-2.5, 2.5]** + - **Validates: Requirements 4.2, 4.3, 4.4, 4.5, 21.4** + - Use Hypothesis with `@settings(max_examples=100)` + + - [x] 2.5 Write unit tests for clustering (`tests/test_v3_clustering.py`) + - Test 3 identical articles from same source → n_eff < 3 + - Test 3 independent articles → n_eff ≈ 3 + - Test single signal cluster → n_eff = 1.0 + - Test cluster LLR clamp at ±2.5 + - Test grouping by correct key dimensions + - _Requirements: 4.1–4.5_ + +- [x] 3. Checkpoint - Verify foundation layer + - Ensure all tests pass for EvidenceUnit, reliability, LLR, and clustering. + - Ensure all tests pass, ask the user if questions arise. + +- [x] 4. Replace trend assembly with posterior P_up + + - [x] 4.1 Implement regime detection v3 in `services/aggregation/regime.py` + - Add `V3RegimeClassification` dataclass (regime, trend_z, vol_ratio, evidence_multiplier, confidence_multiplier, phi_decay, atr_multiplier) + - Implement `classify_regime_v3(closing_prices, daily_returns, atr_20)` using ATR-normalized trend_z = (EMA_20 - EMA_100) / ATR_20 + - Compute vol_ratio = sigma_20 / sigma_100 + - Classification rules in priority: panic (vol_ratio > 1.5 OR |trend_z| > 2.5), trend_following (|trend_z| >= 0.75 AND vol_ratio < 1.3), mean_reversion (|trend_z| < 0.50 AND vol_ratio < 1.0), uncertainty (default) + - Assign regime parameters: gamma, confidence_mult, phi, ATR_mult, min_edge + - Default to uncertainty when data insufficient + - _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.7, 6.8_ + + - [x] 4.2 Implement posterior assembly via log-odds in `services/aggregation/bayesian.py` + - Add `V3Posterior` dataclass (p_up, p_down, log_odds, strength, direction, n_eff_total, regime) + - Implement `compute_v3_posterior(clusters, regime, p_prior=0.50)` + - Compute logit(P_up) = logit(P_prior) + sum(gamma_regime × LLR_c) + - Compute P_up = sigmoid(log_odds), clamp to [1e-10, 1 - 1e-10] + - Compute strength = abs(2 × P_up - 1) + - Classify direction using regime-specific thresholds (panic: 0.68/0.32, trend_following: 0.60/0.40, mean_reversion: 0.63/0.37, uncertainty: 0.65/0.35) + - Load calibrated prior from risk_configs if available (clamp to [0.40, 0.60]), fall back to 0.50 on error + - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7_ + + - [x] 4.3 Write property tests for posterior and regime (`tests/test_pbt_v3_posterior.py`) + - **Property 7: Posterior P_up is in open interval (0, 1)** + - **Property 20: Regime classification is exhaustive and deterministic** + - **Validates: Requirements 5.3, 6.2–6.5, 21.5** + - Use Hypothesis with `@settings(max_examples=100)` + + - [x] 4.4 Write unit tests for posterior assembly (`tests/test_v3_posterior.py`) + - Test empty clusters → P_up = 0.50 (neutral prior) + - Test all bullish clusters → P_up > 0.50 + - Test regime direction thresholds at boundary values + - Test prior clamp [0.40, 0.60] + - Test regime classification with known inputs + - _Requirements: 5.1–5.7, 6.1–6.8_ + +- [x] 5. Replace contradiction with LLR entropy + + - [x] 5.1 Implement LLR entropy contradiction in `services/aggregation/contradiction.py` + - Add `compute_v3_contradiction(clusters: list[EvidenceCluster]) -> float` + - Compute E_pos = sum(max(LLR_c, 0)), E_neg = sum(max(-LLR_c, 0)), E_total = E_pos + E_neg + - When E_total == 0 → return 0.0 + - When only one direction exists (E_pos == 0 or E_neg == 0) → return 0.0 + - Compute f_pos = E_pos / E_total, f_neg = E_neg / E_total + - Compute H_conflict = -f_pos × log2(f_pos) - f_neg × log2(f_neg), treating 0×log2(0) = 0 + - Compute volume_factor = 1 - exp(-E_total / 3.0) + - Return H_conflict × volume_factor, bounded in [0.0, 1.0] + - _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 7.7_ + + - [x] 5.2 Write property tests for contradiction (`tests/test_pbt_v3_posterior.py`) + - **Property 8: Contradiction is zero when evidence is unidirectional** + - **Property 9: Contradiction score is bounded in [0, 1]** + - **Validates: Requirements 7.6, 7.7, 21.7** + - Use Hypothesis with `@settings(max_examples=100)` + + - [x] 5.3 Write unit tests for LLR entropy contradiction (`tests/test_v3_contradiction.py`) + - Test all bullish clusters → contradiction = 0.0 + - Test equal split of evidence → high contradiction near 1.0 + - Test E_total = 0 → contradiction = 0.0 + - Test volume_factor growth: small evidence mass → suppressed score + - _Requirements: 7.1–7.7_ + +- [x] 6. Replace confidence formula + + - [x] 6.1 Implement multiplicative confidence v3 in `services/aggregation/worker.py` + - Add `compute_v3_confidence(n_eff_total, q_values, llrs, strength, regime_confidence_mult, contradiction, data_quality) -> float` + - Compute C_evidence = 1 - exp(-n_eff_total / 5.0) + - Compute C_quality = weighted_mean(q_i, weight=|LLR_i|) + - Compute confidence = clamp(C_evidence × sqrt(C_quality) × sqrt(max(strength, 0.05)) × regime_confidence_mult × (1 - contradiction) × data_quality, 0, 1) + - _Requirements: 8.1, 8.2, 8.3, 8.4, 8.5_ + + - [x] 6.2 Implement data quality v3 in `services/aggregation/worker.py` + - Add `compute_v3_data_quality(units, extraction_failure_rate, age_newest_hours, n_source_types) -> float` + - Q_parse = 1 - extraction_failure_rate + - Q_conf = weighted_mean(extraction_conf_i, weight=impact_i) + - Q_fresh = exp(-age_newest_hours / 168) + - Q_coverage = 1 - exp(-N_valid / 5) + - Q_diversity = min(1, log2(1 + N_source_types) / log2(4)) + - data_quality_score = clamp(Q_parse × sqrt(Q_conf) × Q_fresh × Q_coverage × Q_diversity, 0, 1) + - Force informational mode when data_quality < 0.50 OR N_valid < 2 OR Q_parse < 0.50 + - Force informational when only macro/competitive evidence unless macro_only_enabled + - _Requirements: 17.1, 17.2, 17.3, 17.4, 17.5, 17.6, 17.7, 17.8_ + + - [x] 6.3 Write property tests for confidence and data quality (`tests/test_pbt_v3_posterior.py`) + - **Property 10: Multiplicative confidence is bounded in [0, 1] and suppressed by weak dimensions** + - **Property 17: Data quality score is bounded in [0, 1]** + - **Validates: Requirements 8.3, 8.5, 17.6, 21.6** + - Use Hypothesis with `@settings(max_examples=100)` + + - [x] 6.4 Write unit tests for confidence and data quality (`tests/test_v3_confidence.py`) + - Test zero data quality → near-zero confidence + - Test full contradiction (1.0) → zero confidence + - Test low n_eff → suppressed C_evidence + - Test data quality boundary cases (Q_parse < 0.50 → informational) + - _Requirements: 8.1–8.5, 17.1–17.8_ + +- [x] 7. Checkpoint - Verify core pipeline + - Ensure all tests pass for posterior, contradiction, confidence, and data quality. + - Ensure all tests pass, ask the user if questions arise. + +- [x] 8. Convert macro and competitive layers to emit LLR + + - [x] 8.1 Implement noisy-OR macro exposure and LLR emission in `services/aggregation/interpolation.py` + - Add `compute_normalized_macro_exposure(overlaps: dict[str, float]) -> float` + - E_raw = 1 - product(1 - w_k × O_k) with weights: w_geo=0.35, w_supply=0.25, w_commodity=0.25, w_sector=0.15 + - E_max = 1 - product(1 - w_k) + - E_macro = E_raw / E_max (normalized to [0, 1]) + - Apply resilience dampener per tier: global_leader=0.70, multinational=0.85, regional=1.00, domestic=1.20 + - Add `compute_macro_llr(macro_impact, event_confidence, q_recency, macro_direction) -> float` + - p_macro = clamp(0.50 + 0.30 × macro_impact × event_confidence × q_recency, 0.501, 0.80) + - LLR_macro = macro_direction × ln(p_macro / (1 - p_macro)) + - Feed macro LLR into shared posterior without separate post-hoc modifier + - _Requirements: 9.1, 9.2, 9.3, 9.4, 9.5_ + + - [x] 8.2 Implement correlation-shrunk competitive propagation in `services/aggregation/signal_propagation.py` + - Add `compute_shrunk_correlation(rho_rolling, n_observations, same_sector) -> float` + - rho_prior = 0.30 (same_sector) or 0.10 (cross_sector) + - rho_shrunk = (n/(n+30)) × rho_rolling + (30/(n+30)) × rho_prior + - rho_effective = max(rho_shrunk, 0) + - Add `compute_competitive_llr(llr_source, rho_effective, d_network, pattern_confidence) -> float` + - attenuation = rho_effective × exp(-0.85 × d_network), max_distance = 3 + - LLR_competitive = clamp(llr_source × attenuation × pattern_confidence, -1.25, 1.25) + - _Requirements: 10.1, 10.2, 10.3, 10.4, 10.5_ + + - [x] 8.3 Write property tests for macro and competitive layers (`tests/test_pbt_v3_layers.py`) + - **Property 13: Noisy-OR normalized exposure is bounded in [0, 1]** + - **Property 14: Competitive LLR is clamped to [-1.25, 1.25]** + - **Property 15: Graph attenuation is zero beyond max distance** + - **Validates: Requirements 9.1, 9.2, 10.3, 10.4, 10.5** + - Use Hypothesis with `@settings(max_examples=100)` + + - [x] 8.4 Write unit tests for macro and competitive layers (`tests/test_v3_layers.py`) + - Test noisy-OR: all O_k = 1.0 → E_macro = 1.0; all O_k = 0 → E_macro = 0 + - Test resilience dampener per tier + - Test macro LLR at boundary values + - Test shrunk correlation convergence (n → ∞ approaches rho_rolling) + - Test competitive LLR clamp at ±1.25 + - Test distance > 3 → zero attenuation + - _Requirements: 9.1–9.5, 10.1–10.5_ + +- [x] 9. Replace EV gate with expected-return distribution + + - [x] 9.1 Implement posterior state projection in `services/aggregation/projection.py` + - Add `V3ProjectionState` dataclass (a_t, p_up_projected, projected_strength, diverges, phi_regime) + - Implement `compute_v3_projection(a_prev, cluster_llrs, regime, p_prior, projection_horizon, known_catalyst_llr=0.0)` + - Evidence state: A_t = phi_regime × A_{t-1} + sum(LLR_c), init A_0 = 0.0 + - Regime decay: panic=0.35, trend_following=0.80, mean_reversion=0.55, uncertainty=0.50 + - Projected alpha: A_projected = phi^h × A_t + known_catalyst_LLR + - P_up_projected = sigmoid(logit(P_prior) + A_projected) + - Projected strength = abs(2 × P_up_projected - 1) + - Flag divergence when sign(P_up_projected - 0.5) ≠ sign(P_up_t - 0.5) + - _Requirements: 11.1, 11.2, 11.3, 11.4, 11.5, 11.6, 11.7_ + + - [x] 9.2 Implement return distribution and EV gate in `services/recommendation/eligibility.py` + - Add `ReturnDistribution` dataclass (sigma_h, mu_h, ev_long, min_edge, eligible) + - Add `compute_return_distribution(a_projected, confidence, realized_vol_20d, horizon_days, costs, regime)` + - sigma_h = realized_vol_20d × sqrt(horizon_days / 252); default vol = 0.25 if unavailable + - mu_h = tanh(A_projected / 3.0) × confidence × sigma_h + - CVaR_5 = sigma_h × 1.645 × 1.4 + - EV_long = mu_h - costs - 0.10 × CVaR_5 + - Regime min_edge: panic=0.0100, trend_following=0.0035, mean_reversion=0.0050, uncertainty=0.0075 + - Eligibility: EV_long > min_edge AND EV_long > max(0.0025, 0.25 × costs) + - Also require: confidence >= regime_confidence_min, contradiction <= regime_contradiction_max, n_eff_total >= 2.0, data_quality >= 0.50 + - _Requirements: 12.1, 12.2, 12.3, 12.4, 12.5, 12.6, 12.7_ + + - [x] 9.3 Implement regime-aware eligibility and mode escalation in `services/recommendation/eligibility.py` + - Add regime-specific eligibility thresholds: panic (conf≥0.70, contra≤0.25, str≥0.36), trend_following (conf≥0.55, contra≤0.40, str≥0.20), mean_reversion (conf≥0.60, contra≤0.35, str≥0.26), uncertainty (conf≥0.65, contra≤0.30, str≥0.30) + - Action mapping: BUY when P_up >= bullish_threshold and EV > min_edge; SELL when existing position and EV_exit > EV_hold; HOLD when existing; WATCH otherwise + - live_eligible: BUY/SELL + conf >= 0.75 + contra <= 0.20 + n_eff >= 5 + EV > 2×min_edge + risk_passed + - paper_eligible: BUY/SELL + conf >= 0.60 + EV > min_edge + risk_passed + - Otherwise: informational + - _Requirements: 13.1, 13.2, 13.3, 13.4, 13.5_ + + - [x] 9.4 Write property tests for projection (`tests/test_pbt_v3_projection.py`) + - **Property 16: Projection evidence state decays toward zero** + - **Validates: Requirements 11.1, 11.3** + - Use Hypothesis with `@settings(max_examples=100)` + + - [x] 9.5 Write unit tests for EV gate and eligibility (`tests/test_v3_eligibility.py`) + - Test EV_long positive → eligible + - Test EV_long negative → ineligible + - Test regime-specific min_edge thresholds + - Test mode escalation: live vs paper vs informational + - Test projection decay convergence + - Test divergence flag behavior + - _Requirements: 11.1–11.7, 12.1–12.7, 13.1–13.5_ + +- [x] 10. Checkpoint - Verify decision layer + - Ensure all tests pass for projection, EV gate, eligibility, and layer integrations. + - Ensure all tests pass, ask the user if questions arise. + +- [x] 11. Replace sizing with fractional Kelly under existing risk caps + + - [x] 11.1 Implement fractional Kelly position sizing in `services/trading/position_sizer.py` + - Add `compute_kelly_sizing(p_win, b, confidence, data_quality, contradiction, max_position_pct, available_caps) -> float` + - Compute reward ratio b = clamp(1.2 + 2.0 × confidence + 1.0 × strength - contradiction, 1.2, 3.0) + - Compute f_kelly = (p_win × b - (1 - p_win)) / b + - portfolio_pct = clamp(max(0, f_kelly) × 0.25 × confidence × data_quality × (1 - contradiction), 0, max_position_pct) + - Apply min of: max_position_pct, sector_capacity, correlation_capacity (0 if avg corr > 0.80), heat_capacity + - If portfolio_pct < 0.005 → downgrade to WATCH (reason: position_below_minimum) + - If f_kelly <= 0 → portfolio_pct = 0, downgrade to WATCH (reason: negative_edge) + - _Requirements: 14.1, 14.2, 14.3, 14.4, 14.5, 14.6, 14.7_ + + - [x] 11.2 Implement regime-aware stop loss and take profit in `services/trading/stop_loss_manager.py` + - Add v3 stop computation: stop_distance_pct = max(ATR_pct × regime_ATR_mult, sigma_h × 1.25, 0.005) + - stop_loss = entry_price × (1 - stop_distance_pct) + - take_profit = entry_price × (1 + b × stop_distance_pct) where b = reward ratio + - Activate trailing stop when unrealized_gain >= 0.50 × TP distance + - trailing_stop = max(existing_stop, current_price × (1 - trailing_distance_pct)) + - trailing_distance_pct = max(ATR_pct × trailing_ATR_mult, sigma_h × 0.75) + - Trailing stop must be monotonically non-decreasing + - _Requirements: 16.1, 16.2, 16.3, 16.4, 16.5_ + + - [x] 11.3 Write property tests for Kelly sizing and stops (`tests/test_pbt_v3_decision.py`) + - **Property 11: Fractional Kelly sizing is bounded and respects negative edge** + - **Property 18: Stop loss is below entry price and take profit is above** + - **Property 19: Trailing stop never decreases** + - **Validates: Requirements 14.4, 14.7, 16.2, 16.3, 16.5, 21.8, 21.9** + - Use Hypothesis with `@settings(max_examples=100)` + + - [x] 11.4 Write unit tests for Kelly sizing and stops (`tests/test_v3_sizing.py`) + - Test p_win=0.3, b=2 → f_kelly < 0 → size = 0 + - Test p_win=0.7, b=2 → positive size within caps + - Test cap enforcement (sector, correlation, heat) + - Test position_below_minimum downgrade + - Test stop/TP computation with known inputs + - Test trailing stop monotonicity over a price sequence + - _Requirements: 14.1–14.7, 16.1–16.5_ + +- [x] 12. Replace portfolio heat with stop-defined risk dollars + + - [x] 12.1 Implement stop-defined portfolio heat in `services/risk/engine.py` + - Add `compute_portfolio_heat(positions, stop_distances) -> float` + - risk_dollars = position_value × stop_distance_pct for each position + - portfolio_heat = sum(risk_dollars) + - Add `check_heat_capacity(current_heat, new_risk_dollars, max_heat_pct, portfolio_value) -> bool` + - Reject new entry when current_heat + new_risk_dollars > max_heat_pct × portfolio_value + - Integrate available_heat_capacity into Kelly sizing pipeline + - _Requirements: 15.1, 15.2, 15.3, 15.4, 15.5_ + + - [x] 12.2 Implement risk tier auto-adjustment v3 in `services/risk/engine.py` + - Add `TierMetrics` dataclass (profit_factor_30d, max_drawdown_30d, calibration_error, realized_sharpe_30d, n_trades_30d, reserve_pool_pct) + - Add `evaluate_tier_adjustment(metrics) -> str` returning 'upgrade'|'downgrade'|'hold' + - Downgrade if ANY: profit_factor < 1.0 OR drawdown > 0.12 OR calibration_error > 0.20 OR sharpe < 0 + - Upgrade only if ALL: profit_factor > 1.35 AND drawdown < 0.05 AND calibration_error < 0.12 AND reserve > 0.20 AND N_trades >= 20 + - Apply downgrade immediately; enforce 7-day upgrade cooldown + - Evaluate once per calendar day after session close + - _Requirements: 18.1, 18.2, 18.3, 18.4, 18.5, 18.6_ + + - [x] 12.3 Write property tests for heat and tier adjustment (`tests/test_pbt_v3_decision.py`) + - **Property 22: Portfolio heat rejection is correct** + - **Property 23: Tier auto-adjustment obeys downgrade-any, upgrade-all logic** + - **Validates: Requirements 15.3, 15.5, 18.3, 18.4** + - Use Hypothesis with `@settings(max_examples=100)` + + - [x] 12.4 Write unit tests for heat and tier (`tests/test_v3_risk.py`) + - Test heat computation: 3 positions with known stops → expected heat + - Test heat rejection: heat at limit → new entry blocked + - Test tier downgrade: single bad metric triggers downgrade + - Test tier upgrade: all metrics good → upgrade + - Test tier upgrade: one metric bad → hold (not upgrade) + - Test 7-day cooldown enforcement + - _Requirements: 15.1–15.5, 18.1–18.6_ + +- [x] 13. Checkpoint - Verify sizing and risk layer + - Ensure all tests pass for Kelly sizing, stops, heat, and tier adjustment. + - Ensure all tests pass, ask the user if questions arise. + +- [x] 14. Retire heuristic scoring to explainability-only mode + + - [x] 14.1 Wire v3 pipeline end-to-end in `services/aggregation/worker.py` + - Orchestrate full pipeline: EvidenceUnit → q_i → LLR → cluster → posterior → contradiction → confidence → data_quality + - Store v3 posterior in TrendSummary JSONB metadata (p_up, log_odds, strength, confidence, contradiction, n_eff, data_quality, regime) + - Store return model in Recommendation JSONB metadata (mu_h, sigma_h, ev_long, min_edge) + - Store explainability payload (top_positive_clusters, top_negative_clusters, suppression_reasons, risk_adjustments) + - Preserve WeightedSignal as intermediate representation before LLR conversion + - _Requirements: 20.1, 20.2, 20.3, 20.4, 20.5_ + + - [x] 14.2 Retain heuristic pipeline as fallback with explainability overlay + - Keep existing heuristic scoring path fully functional (no removal) + - Mark heuristic outputs with `pipeline_mode: "heuristic"` in metadata + - Ensure heuristic mode still produces valid TrendSummary and Recommendation objects + - Test feature flag toggle: v3 → heuristic → v3 round-trip + - _Requirements: 19.1, 19.2, 19.5, 20.4_ + + - [x] 14.3 Write property test for JSON round-trip (`tests/test_pbt_v3_posterior.py`) + - **Property 12: Posterior state JSON round-trip** + - **Validates: Requirements 20.1, 21.10** + - Serialize V3Posterior to JSON and deserialize, verify equivalence within 1e-10 + - Use Hypothesis with `@settings(max_examples=100)` + + - [x] 14.4 Write integration tests for full pipeline (`tests/test_v3_integration.py`) + - Test full path: raw signals → EvidenceUnit → q_i → LLR → cluster → posterior → recommendation + - Test feature flag false → heuristic path, flag true → v3 path + - Test v3 exception → heuristic fallback + error logged + - Test output JSONB contains expected v3 fields + - _Requirements: 19.1–19.6, 20.1–20.5_ + +- [x] 15. Final checkpoint - All tests green + - Ensure all tests pass, ask the user if questions arise. + - Run full test suite: `.venv/bin/python -m pytest tests/test_pbt_v3_*.py tests/test_v3_*.py -x --tb=short -q` + - Verify no regressions in existing heuristic pipeline tests + +## Notes + +- Tasks marked with `*` are optional and can be skipped for faster MVP +- Each task references specific requirements for traceability +- Checkpoints ensure incremental validation after each logical phase +- Property tests validate the 23 correctness properties defined in the design +- Unit tests validate specific examples, edge cases, and error handling +- The implementation preserves the existing heuristic pipeline as a fully functional fallback +- All v3 code is gated behind `v3_engine_enabled` — no changes to production behavior until flag is flipped +- Run tests with: `.venv/bin/python -m pytest tests/ -x --tb=short -q` +- Property tests use Hypothesis: `@settings(max_examples=100)` + +## Task Dependency Graph + +```json +{ + "waves": [ + { "id": 0, "tasks": ["1.1"] }, + { "id": 1, "tasks": ["1.2", "1.4"] }, + { "id": 2, "tasks": ["1.3"] }, + { "id": 3, "tasks": ["1.5", "1.6"] }, + { "id": 4, "tasks": ["2.1"] }, + { "id": 5, "tasks": ["2.2"] }, + { "id": 6, "tasks": ["2.3"] }, + { "id": 7, "tasks": ["2.4", "2.5"] }, + { "id": 8, "tasks": ["4.1"] }, + { "id": 9, "tasks": ["4.2"] }, + { "id": 10, "tasks": ["4.3", "4.4"] }, + { "id": 11, "tasks": ["5.1"] }, + { "id": 12, "tasks": ["5.2", "5.3"] }, + { "id": 13, "tasks": ["6.1", "6.2"] }, + { "id": 14, "tasks": ["6.3", "6.4"] }, + { "id": 15, "tasks": ["8.1", "8.2"] }, + { "id": 16, "tasks": ["8.3", "8.4"] }, + { "id": 17, "tasks": ["9.1"] }, + { "id": 18, "tasks": ["9.2", "9.3"] }, + { "id": 19, "tasks": ["9.4", "9.5"] }, + { "id": 20, "tasks": ["11.1", "11.2"] }, + { "id": 21, "tasks": ["11.3", "11.4"] }, + { "id": 22, "tasks": ["12.1", "12.2"] }, + { "id": 23, "tasks": ["12.3", "12.4"] }, + { "id": 24, "tasks": ["14.1"] }, + { "id": 25, "tasks": ["14.2"] }, + { "id": 26, "tasks": ["14.3", "14.4"] } + ] +} +``` diff --git a/docs/stonks_oracle_math_v3_upgrade.md b/docs/stonks_oracle_math_v3_upgrade.md new file mode 100644 index 0000000..ce5ee98 --- /dev/null +++ b/docs/stonks_oracle_math_v3_upgrade.md @@ -0,0 +1,1201 @@ +# Stonks Oracle — Math Core v3 Upgrade + +Purpose: replace the linear weighted-sentiment core with a calibrated evidence engine while preserving the existing service boundaries: + +`ingestion -> parser -> extractor -> aggregation -> recommendation -> risk -> trading` + +This version keeps the public concepts already used by Stonks Oracle: + +- `WeightedSignal` +- company / macro / competitive layers +- intraday / 1d / 7d / 30d / 90d windows +- contradiction detection +- trend projection +- recommendation eligibility +- risk-tiered position sizing +- circuit breakers + +The internal math changes from “weighted sum of vibes” to: + +`EvidenceUnit -> calibrated reliability -> likelihood ratio -> de-correlated posterior -> return distribution -> EV/risk decision` + +--- + +## 0. Core Design Change + +### Old core + +```text +W_combined = confidence * recency * credibility * novelty * context +S_avg = sum(W_combined * impact * sentiment) / sum(W_combined * impact) +``` + +This is explainable, but it is not probabilistic. It double-counts correlated articles, treats arbitrary weights as likelihood, and turns noisy agreement into false certainty. + +### New core + +```text +raw signal + -> EvidenceUnit + -> calibrated reliability p_correct + -> signal log-likelihood ratio LLR_i + -> correlation-adjusted cluster evidence LLR_c + -> posterior probability P_up + -> expected return distribution + -> EV-gated recommendation + -> Kelly/risk-capped position size +``` + +The deterministic score remains as an explainability layer only. The trading/recommendation decision should use the posterior and EV layers. + +--- + +## 1. Canonical Evidence Unit + +Every company, macro, or competitive signal is normalized into the same internal shape before aggregation. + +```text +EvidenceUnit_i = { + symbol, + layer_i, # company | macro | competitive + event_type_i, + source_id_i, + source_group_i, + timestamp_i, + horizon_i, # intraday | 1d | 7d | 30d | 90d + direction_i, # -1 bearish, 0 neutral, +1 bullish + sentiment_strength_i, # [0, 1] + impact_i, # [0, 1] + extraction_conf_i, # [0, 1] + source_cred_i, # [0, 1] + novelty_i, # [0, 1] + event_base_rate_i, # P(event_type) + cluster_id_i +} +``` + +Neutral signals do not vote directionally, but they still count toward quality, coverage, and contradiction context. + +--- + +## 2. Replace Combined Weight with Calibrated Reliability + +### 2.1 Extraction reliability + +Replace the hard confidence gate and raw sigmoid multiplier with a calibrated reliability term. + +```text +q_ext = sigmoid(k_ext * (extraction_conf_i - m_ext)) +``` + +Defaults: + +```text +k_ext = 8.0 +m_ext = 0.55 +``` + +This makes low-confidence extraction fade instead of abruptly disappearing, but still penalizes weak extraction aggressively. + +--- + +### 2.2 Source reliability with Bayesian shrinkage + +Replace: + +```text +F_accuracy = 0.5 + accuracy_ratio +``` + +with a posterior source skill estimate. + +For each source or source group: + +```text +theta_s ~ Beta(alpha_0 + hits_s, beta_0 + misses_s) +E[theta_s] = (alpha_0 + hits_s) / (alpha_0 + beta_0 + hits_s + misses_s) +``` + +Defaults: + +```text +alpha_0 = 3 +beta_0 = 3 +``` + +This starts every source near 50% until it earns trust. + +Convert to usable reliability: + +```text +q_source = clamp((E[theta_s] - 0.50) / 0.35, 0.0, 1.0) +``` + +A source with 50% realized usefulness contributes little extra skill. A source near 85% realized usefulness approaches full source reliability. + +--- + +### 2.3 Recency reliability + +Keep exponential half-life decay, but do not clamp stale evidence to a nonzero floor for directional voting. + +```text +q_recency = 2^(-age_hours / half_life_hours) +``` + +Use a floor only for explainability display, not for posterior voting. + +Recommended half-lives: + +| Horizon | Half-life | +|---|---:| +| intraday | 2h | +| 1d | 12h | +| 7d | 72h | +| 30d | 240h | +| 90d | 720h | + +Adaptive extension: + +```text +tau_adaptive = tau_base * (1 + 0.75 * impact_i + 0.50 * surprise_i) +q_recency = 2^(-age_hours / tau_adaptive) +``` + +where: + +```text +surprise_i = clamp(-log2(event_base_rate_i) / 5, 0, 1) +``` + +This keeps rare/major events alive longer without letting them live forever. + +--- + +### 2.4 Novelty as de-duplication, not hype boost + +Replace direct novelty multiplication: + +```text +1 + 0.25 * novelty +``` + +with a saturation term inside each event cluster. + +For signal `i` inside cluster `c`: + +```text +q_novelty_i = 1 / sqrt(1 + duplicate_count_before_i) +``` + +Then: + +```text +q_uniqueness_i = clamp(0.50 + 0.50 * novelty_i, 0.50, 1.00) * q_novelty_i +``` + +This prevents 12 near-identical articles from becoming 12 units of evidence. + +--- + +### 2.5 Final signal reliability + +```text +q_i = q_ext * q_source * source_cred_i * q_recency * q_uniqueness_i +``` + +Clamp only at the very end: + +```text +q_i = clamp(q_i, 0, 1) +``` + +--- + +## 3. Convert Signals to Log-Likelihood Ratios + +The posterior engine should not sum arbitrary sentiment weights. It should sum calibrated evidence. + +### 3.1 Directional correctness probability + +```text +p_correct_i = 0.50 + p_edge_max * q_i * impact_i * sentiment_strength_i +``` + +Defaults: + +```text +p_edge_max = 0.35 +p_correct_i = clamp(p_correct_i, 0.501, 0.85) +``` + +Why 0.85 max? Because even very good text signals should not become near-certain by themselves. + +--- + +### 3.2 Signal likelihood ratio + +```text +LLR_i = direction_i * log(p_correct_i / (1 - p_correct_i)) +``` + +Examples: + +| p_correct | abs(LLR) | +|---:|---:| +| 0.55 | 0.20 | +| 0.60 | 0.41 | +| 0.70 | 0.85 | +| 0.80 | 1.39 | +| 0.85 | 1.73 | + +This makes one strong signal meaningful but not magical. + +--- + +## 4. Correlation-Aware Evidence Clustering + +### 4.1 Cluster signals + +Cluster by: + +```text +(symbol, horizon, event_type, normalized_event_key, source_group, time_bucket) +``` + +Also include embedding/content similarity when available. + +Near-duplicates go into the same cluster even if they come from different URLs. + +--- + +### 4.2 Effective evidence count + +For a cluster `c` with signal weights `w_i = abs(LLR_i)`: + +```text +n_eff_c = (sum_i w_i)^2 / (sum_i w_i^2 + 2 * sum_{i<j}(rho_ij * w_i * w_j)) +``` + +Defaults: + +```text +rho_ij = 0.80 same wire/story/source group +rho_ij = 0.50 same event, different publisher +rho_ij = 0.25 same theme, different event +rho_ij = 0.00 independent event +``` + +This is the anti-bullshit lever. Correlated evidence gets discounted before it reaches the posterior. + +--- + +### 4.3 Cluster likelihood ratio + +```text +LLR_mean_c = sum_i(LLR_i * abs(LLR_i)) / sum_i(abs(LLR_i)) +LLR_c = LLR_mean_c * sqrt(n_eff_c) +``` + +Clamp cluster evidence: + +```text +LLR_c = clamp(LLR_c, -2.5, 2.5) +``` + +One event cluster cannot dominate the whole decision by itself. + +--- + +## 5. Market and Regime Prior + +### 5.1 Base prior + +Use a neutral prior unless there is validated symbol/sector history. + +```text +P_prior = 0.50 +``` + +Optional calibrated prior: + +```text +logit(P_prior) = beta_0 + + beta_symbol * symbol_drift_z + + beta_sector * sector_relative_strength_z + + beta_market * index_trend_z +``` + +Clamp: + +```text +P_prior = clamp(P_prior, 0.40, 0.60) +``` + +The prior should nudge, not decide. + +--- + +### 5.2 Regime detection + +Keep the current regimes, but change how they are used. Do not boost raw evidence during panic. Panic should usually reduce confidence, not make the model louder. + +Inputs: + +```text +trend_z = (EMA_20 - EMA_100) / ATR_20 +vol_ratio = sigma_20 / sigma_100 +volume_z = (log(volume_t) - mean_log_volume_60) / std_log_volume_60 +``` + +Regime mapping: + +| Regime | Condition | Evidence multiplier | Confidence multiplier | Min edge | +|---|---|---:|---:|---:| +| panic | vol_ratio > 1.5 or abs(trend_z) > 2.5 | 0.70 | 0.70 | high | +| trend_following | abs(trend_z) >= 0.75 and vol_ratio < 1.3 | 1.10 | 1.00 | normal | +| mean_reversion | abs(trend_z) < 0.50 and vol_ratio < 1.0 | 0.90 | 0.95 | normal | +| uncertainty | otherwise | 0.80 | 0.85 | high | + +--- + +## 6. Posterior Trend Assembly + +### 6.1 Posterior log-odds + +```text +logit(P_up) = logit(P_prior) + sum_c(gamma_regime * LLR_c) +``` + +where: + +```text +gamma_regime = evidence multiplier from regime table +``` + +Then: + +```text +P_up = sigmoid(logit(P_up)) +P_down = 1 - P_up +``` + +--- + +### 6.2 Direction + +Use dynamic thresholds by regime. + +| Regime | Bullish if | Bearish if | +|---|---:|---:| +| panic | P_up >= 0.68 | P_up <= 0.32 | +| trend_following | P_up >= 0.60 | P_up <= 0.40 | +| mean_reversion | P_up >= 0.63 | P_up <= 0.37 | +| uncertainty | P_up >= 0.65 | P_up <= 0.35 | + +Otherwise: neutral or mixed. + +--- + +### 6.3 Strength + +Replace: + +```text +strength = abs(S_avg) +``` + +with: + +```text +strength = abs(2 * P_up - 1) +``` + +This keeps strength in `[0, 1]`, but now it actually means posterior directional separation. + +--- + +## 7. Contradiction Score v3 + +Contradiction should measure meaningful opposing evidence, not just minority weight. + +```text +E_pos = sum_c(max(LLR_c, 0)) +E_neg = sum_c(max(-LLR_c, 0)) +E_total = E_pos + E_neg +``` + +If `E_total = 0`: + +```text +contradiction = 0 +``` + +Otherwise: + +```text +f_pos = E_pos / E_total +f_neg = E_neg / E_total +H_conflict = -f_pos * log2(f_pos) - f_neg * log2(f_neg) +volume_factor = 1 - exp(-E_total / E_conflict_scale) +contradiction = H_conflict * volume_factor +``` + +Default: + +```text +E_conflict_scale = 3.0 +``` + +This avoids screaming “contradiction” when there are only two tiny weak signals. + +--- + +## 8. Confidence v3 + +Confidence must not be the same thing as bullishness. A model can be confidently mixed, weakly bullish, or confidently bearish. + +```text +n_eff_total = sum_c(n_eff_c) +C_evidence = 1 - exp(-n_eff_total / k_evidence) +C_direction = abs(2 * P_up - 1) +C_quality = weighted_mean(q_i, weight=abs(LLR_i)) +C_regime = regime confidence multiplier +C_contradiction = 1 - contradiction +C_data = data_quality_score +``` + +Default: + +```text +k_evidence = 5.0 +``` + +Final confidence: + +```text +confidence = clamp( + C_evidence + * sqrt(C_quality) + * sqrt(max(C_direction, 0.05)) + * C_regime + * C_contradiction + * C_data, + 0, + 1 +) +``` + +Why multiplicative? Because one bad dimension should actually suppress the trade instead of being averaged away. + +--- + +## 9. Macro Layer v3 + +### 9.1 Exposure as noisy-OR + +Keep the multiplicative macro exposure idea, but normalize it so full exposure can actually reach 1. + +```text +E_macro_raw = 1 - product_k(1 - w_k * O_k) +E_macro_max = 1 - product_k(1 - w_k) +E_macro = E_macro_raw / E_macro_max +``` + +Default weights: + +```text +w_geo = 0.35 +w_supply = 0.25 +w_commodity = 0.25 +w_sector = 0.15 +``` + +Now full overlap maps to 1.0, not 0.689. + +--- + +### 9.2 Resilience as effect dampener + +Do not multiply the whole score blindly. Apply resilience to impact. + +```text +resilience_dampener = { + global_leader: 0.70, + multinational: 0.85, + regional: 1.00, + domestic: 1.20 +} +``` + +For international events: + +```text +macro_impact = clamp(severity_weight * E_macro * resilience_dampener, 0, 1) +``` + +For domestic events: + +```text +macro_impact = clamp(severity_weight * E_macro, 0, 1) +``` + +--- + +### 9.3 Macro likelihood ratio + +```text +p_macro = 0.50 + 0.30 * macro_impact * event_confidence * q_recency +p_macro = clamp(p_macro, 0.501, 0.80) +LLR_macro = macro_direction * log(p_macro / (1 - p_macro)) +``` + +Macro enters the same posterior engine as company evidence. No special post-hoc modifier is needed. + +--- + +## 10. Competitive Layer v3 + +### 10.1 Correlation shrinkage + +Replace raw rolling correlation with shrunk correlation. + +```text +rho_shrunk = (n / (n + k_rho)) * rho_rolling + (k_rho / (n + k_rho)) * rho_prior +``` + +Defaults: + +```text +k_rho = 30 +rho_prior_same_sector = 0.30 +rho_prior_cross_sector = 0.10 +``` + +Use only positive propagation unless the relationship is explicitly inverse. + +```text +rho_effective = max(rho_shrunk, 0) +``` + +--- + +### 10.2 Graph attenuation + +```text +attenuation = rho_effective * exp(-lambda_graph * d_network) +``` + +Default: + +```text +lambda_graph = 0.85 +max_distance = 3 +``` + +No propagation when `d_network > 3`. + +--- + +### 10.3 Competitive likelihood ratio + +```text +LLR_competitive = LLR_source * attenuation * pattern_confidence +``` + +Clamp: + +```text +LLR_competitive = clamp(LLR_competitive, -1.25, 1.25) +``` + +Competitive signals can support a case. They should not dominate a case. + +--- + +## 11. Trend Projection v3 + +Replace simple momentum difference with a posterior state. + +### 11.1 Evidence state + +```text +A_t = phi_regime * A_{t-1} + sum_c(LLR_c) +P_up_t = sigmoid(logit(P_prior_t) + A_t) +``` + +Regime decay: + +| Regime | phi_regime | +|---|---:| +| panic | 0.35 | +| trend_following | 0.80 | +| mean_reversion | 0.55 | +| uncertainty | 0.50 | + +This makes trend-following evidence persist and panic evidence decay quickly. + +--- + +### 11.2 Projected alpha + +For horizon `h`: + +```text +A_projected_h = phi_regime^h * A_t + expected_known_catalyst_LLR_h +P_up_projected_h = sigmoid(logit(P_prior_h) + A_projected_h) +``` + +Projected strength: + +```text +strength_projected_h = abs(2 * P_up_projected_h - 1) +``` + +Divergence flag: + +```text +divergence = sign(P_up_projected_h - 0.5) != sign(P_up_t - 0.5) +``` + +--- + +## 12. Return Distribution and Expected Value Gate + +The existing EV formula uses strength as if it were payoff size. Replace it with a return distribution. + +### 12.1 Horizon volatility + +```text +sigma_h = realized_vol_20d * sqrt(horizon_days / 252) +``` + +For intraday, use intraday realized volatility when available. + +--- + +### 12.2 Expected return + +```text +edge_z = tanh(A_projected_h / z_scale) +mu_h = edge_z * confidence * sigma_h +``` + +Default: + +```text +z_scale = 3.0 +``` + +--- + +### 12.3 Trading costs + +```text +cost = spread_cost + slippage_estimate + commission_estimate +``` + +--- + +### 12.4 Long EV + +Simple approximation: + +```text +EV_long = mu_h - cost +``` + +Risk-adjusted approximation: + +```text +EV_long_risk_adjusted = mu_h - cost - lambda_tail * CVaR_5_loss +``` + +Default: + +```text +lambda_tail = 0.10 +``` + +For a sell/exit decision on an existing long: + +```text +EV_hold = mu_h - expected_drawdown_penalty - cost_to_exit_later +EV_exit = -exit_cost +sell_if EV_exit > EV_hold +``` + +--- + +### 12.5 EV gate + +```text +min_edge = max(0.0025, 0.25 * cost, regime_min_edge) +``` + +Suggested regime minimum edge: + +| Regime | min_edge | +|---|---:| +| panic | 0.0100 | +| trend_following | 0.0035 | +| mean_reversion | 0.0050 | +| uncertainty | 0.0075 | + +Trade-eligible only when: + +```text +EV_long > min_edge +confidence >= confidence_min +contradiction <= contradiction_max +n_eff_total >= evidence_min +``` + +--- + +## 13. Recommendation Mapping v3 + +### 13.1 Eligibility gates + +```text +eligible = ( + data_quality_score >= 0.50 + and confidence >= confidence_min_regime + and n_eff_total >= 2.0 + and contradiction <= contradiction_max_regime + and abs(2 * P_up - 1) >= strength_min_regime +) +``` + +Defaults: + +| Regime | confidence_min | contradiction_max | strength_min | +|---|---:|---:|---:| +| panic | 0.70 | 0.25 | 0.36 | +| trend_following | 0.55 | 0.40 | 0.20 | +| mean_reversion | 0.60 | 0.35 | 0.26 | +| uncertainty | 0.65 | 0.30 | 0.30 | + +--- + +### 13.2 Action mapping + +```text +if not eligible: + action = WATCH or INFORMATIONAL +elif P_up >= bullish_threshold and EV_long > min_edge: + action = BUY +elif existing_position and EV_exit > EV_hold: + action = SELL +elif existing_position: + action = HOLD +else: + action = WATCH +``` + +Do not emit SELL as a short recommendation unless shorting is explicitly enabled in config. + +--- + +### 13.3 Mode escalation + +```text +live_eligible = ( + action in {BUY, SELL} + and confidence >= 0.75 + and contradiction <= 0.20 + and n_eff_total >= 5 + and EV_long > 2 * min_edge + and risk_engine_passed +) + +paper_eligible = ( + action in {BUY, SELL} + and confidence >= 0.60 + and EV_long > min_edge + and risk_engine_passed +) + +informational = otherwise +``` + +--- + +## 14. Position Sizing v3 + +Replace the two separate sizing formulas with one core sizing equation plus hard risk caps. + +### 14.1 Stop distance + +```text +stop_distance_pct = max( + ATR_pct * ATR_multiplier_regime, + sigma_h * z_stop, + min_stop_pct +) +``` + +Defaults: + +```text +z_stop = 1.25 +min_stop_pct = 0.005 +``` + +Regime ATR multiplier: + +| Regime | ATR multiplier | +|---|---:| +| panic | 2.5 | +| trend_following | 1.8 | +| mean_reversion | 1.4 | +| uncertainty | 2.0 | + +--- + +### 14.2 Win probability + +```text +p_win = P(R_h > 0) +``` + +Approximation: + +```text +p_win = P_up +``` + +Better version when distribution is available: + +```text +p_win = 1 - CDF_return_distribution(0) +``` + +--- + +### 14.3 Fractional Kelly + +```text +b = take_profit_distance_pct / stop_distance_pct +f_kelly = (p_win * b - (1 - p_win)) / b +``` + +Conservative final sizing: + +```text +f_raw = max(0, f_kelly) + * kelly_fraction + * confidence + * data_quality_score + * (1 - contradiction) +``` + +Defaults: + +```text +kelly_fraction = 0.25 +``` + +--- + +### 14.4 Hard caps + +```text +portfolio_pct = clamp(f_raw, 0, max_position_pct) +portfolio_pct = min(portfolio_pct, available_sector_capacity_pct) +portfolio_pct = min(portfolio_pct, available_correlation_capacity_pct) +portfolio_pct = min(portfolio_pct, available_heat_capacity_pct) +``` + +If `portfolio_pct < min_trade_pct`, downgrade to WATCH. + +--- + +## 15. Correlation and Portfolio Heat v3 + +### 15.1 Position correlation penalty + +```text +rho_portfolio = weighted_mean(abs(rho(symbol, held_symbol)), weight=position_weight) +``` + +```text +correlation_capacity = clamp(1 - ((rho_portfolio - 0.40) / 0.40), 0, 1) +``` + +Reject if: + +```text +rho_portfolio > 0.80 +``` + +--- + +### 15.2 Heat + +Risk dollars, not position dollars: + +```text +risk_dollars_new = position_value * stop_distance_pct +portfolio_heat_new = current_open_risk + risk_dollars_new +``` + +Reject if: + +```text +portfolio_heat_new > max_portfolio_heat * portfolio_value +``` + +This is better than `dollar_amount * atr_multiplier * 0.02`, because it measures actual stop-defined loss exposure. + +--- + +## 16. Stop Loss and Take Profit v3 + +For a long position: + +```text +stop_loss = entry_price * (1 - stop_distance_pct) +take_profit = entry_price * (1 + b * stop_distance_pct) +``` + +Dynamic reward ratio: + +```text +b = clamp(1.2 + 2.0 * confidence + 1.0 * strength - contradiction, 1.2, 3.0) +``` + +Trailing activation: + +```text +activate_trailing = unrealized_gain_pct >= 0.50 * take_profit_distance_pct +``` + +Trailing stop: + +```text +trailing_stop = max(existing_stop, current_price * (1 - trailing_distance_pct)) +``` + +```text +trailing_distance_pct = max(ATR_pct * trailing_ATR_mult, sigma_h * 0.75) +``` + +--- + +## 17. Data Quality v3 + +Replace additive quality with fail-closed multiplicative quality. + +```text +Q_parse = 1 - extraction_failure_rate +Q_conf = weighted_mean(extraction_conf_i, weight=impact_i) +Q_fresh = exp(-age_newest_hours / freshness_tau) +Q_coverage = 1 - exp(-N_valid / coverage_scale) +Q_diversity = min(1, log2(1 + N_source_types) / log2(4)) +``` + +Defaults: + +```text +freshness_tau = 168h +coverage_scale = 5 +``` + +Final: + +```text +data_quality_score = clamp( + Q_parse + * sqrt(Q_conf) + * Q_fresh + * Q_coverage + * Q_diversity, + 0, + 1 +) +``` + +Suppression: + +```text +if data_quality_score < 0.50: informational +if N_valid < 2: informational +if Q_parse < 0.50: informational +if company_evidence_abs == 0 and macro_evidence_abs > 0: informational unless macro_only_enabled +if company_evidence_abs == 0 and competitive_evidence_abs > 0: informational +``` + +--- + +## 18. Risk Tier Auto-Adjustment v3 + +Replace raw win rate with risk-adjusted performance. + +Track: + +```text +win_rate_30d +profit_factor_30d = gross_profit / abs(gross_loss) +max_drawdown_30d +calibration_error = mean(abs(predicted_probability - realized_outcome)) +realized_sharpe_30d +``` + +Downgrade one tier if any: + +```text +profit_factor_30d < 1.0 +max_drawdown_30d > 0.12 +calibration_error > 0.20 +realized_sharpe_30d < 0 +``` + +Upgrade one tier only if all: + +```text +profit_factor_30d > 1.35 +max_drawdown_30d < 0.05 +calibration_error < 0.12 +reserve_pool > 0.20 +N_trades_30d >= 20 +``` + +--- + +## 19. Mapping to Existing Services + +| Existing service | Keep | Replace / add | +|---|---|---| +| `services/aggregation/scoring.py` | input normalization, recency windows | replace `W_combined` with `q_i`, `p_correct_i`, `LLR_i` | +| `services/aggregation/contradiction.py` | contradiction output field | replace minority-weight formula with LLR entropy contradiction | +| `services/aggregation/bayesian.py` | feature-flagged probabilistic path | make this the primary posterior engine | +| `services/aggregation/regime.py` | EMA/volatility regime concept | stop boosting evidence in panic; use regime multipliers and thresholds | +| `services/aggregation/interpolation.py` | macro/company overlap model | normalize noisy-OR exposure; emit macro LLR as normal evidence | +| `services/aggregation/signal_propagation.py` | competitor graph | add correlation shrinkage and cap propagated LLR | +| `services/aggregation/projection.py` | projected trend API | replace momentum delta with posterior state `A_t` | +| `services/recommendation/suppression.py` | suppression layer | replace additive quality with multiplicative fail-closed quality | +| `services/recommendation/eligibility.py` | mode/action output | use posterior, confidence, contradiction, and EV gates | +| `services/trading/position_sizer.py` | risk caps, sector/correlation checks | replace allocation formula with fractional Kelly under caps | +| `services/trading/stop_loss_manager.py` | ATR stops/trailing stops | make stops volatility/regime/distribution aware | +| `services/risk/engine.py` | hard limits | calculate heat from stop-defined risk dollars | +| `services/trading/risk_tier_controller.py` | tier state machine | use profit factor, drawdown, calibration error, Sharpe | + +--- + +## 20. Implementation Order + +Recommended order so the system stays testable: + +1. Add `EvidenceUnit` and LLR conversion behind a feature flag. +2. Add evidence clustering and `n_eff`. +3. Replace `S_avg` trend assembly with posterior `P_up`. +4. Replace contradiction with LLR entropy contradiction. +5. Replace confidence formula. +6. Convert macro and competitive layers to emit LLR-compatible evidence. +7. Replace EV gate with expected-return distribution. +8. Replace sizing with fractional Kelly under existing risk caps. +9. Replace portfolio heat with stop-defined risk dollars. +10. Retire heuristic scoring to explainability-only mode. + +--- + +## 21. Output Contract + +Aggregation output should expose both machine decision fields and explainability fields. + +```json +{ + "symbol": "string", + "horizon": "7d", + "regime": "trend_following", + "posterior": { + "p_up": 0.64, + "p_down": 0.36, + "log_odds": 0.58, + "strength": 0.28, + "confidence": 0.61, + "contradiction": 0.18, + "n_eff": 5.7, + "data_quality": 0.82 + }, + "return_model": { + "mu_h": 0.012, + "sigma_h": 0.041, + "ev_long": 0.007, + "min_edge": 0.0035 + }, + "recommendation": { + "action": "BUY", + "mode": "paper_eligible", + "reason": "Positive posterior, sufficient effective evidence, EV clears threshold, risk engine passed." + }, + "explainability": { + "top_positive_clusters": [], + "top_negative_clusters": [], + "suppression_reasons": [], + "risk_adjustments": [] + } +} +``` + +--- + +## 22. The New Core in One Block + +```text +q_i = q_ext * q_source * source_cred_i * q_recency * q_uniqueness_i + +p_correct_i = clamp( + 0.50 + 0.35 * q_i * impact_i * sentiment_strength_i, + 0.501, + 0.85 +) + +LLR_i = direction_i * log(p_correct_i / (1 - p_correct_i)) + +n_eff_c = (sum_i w_i)^2 / (sum_i w_i^2 + 2 * sum_{i<j}(rho_ij * w_i * w_j)) + +LLR_c = clamp(weighted_mean(LLR_i, abs(LLR_i)) * sqrt(n_eff_c), -2.5, 2.5) + +logit(P_up) = logit(P_prior) + sum_c(gamma_regime * LLR_c) +P_up = sigmoid(logit(P_up)) +strength = abs(2 * P_up - 1) + +contradiction = entropy(pos_LLR_share, neg_LLR_share) * (1 - exp(-E_total / 3.0)) + +confidence = clamp( + (1 - exp(-n_eff_total / 5.0)) + * sqrt(weighted_mean(q_i)) + * sqrt(max(strength, 0.05)) + * regime_confidence_multiplier + * (1 - contradiction) + * data_quality_score, + 0, + 1 +) + +A_t = phi_regime * A_{t-1} + sum_c(LLR_c) +P_up_projected_h = sigmoid(logit(P_prior_h) + phi_regime^h * A_t + known_catalyst_LLR_h) + +sigma_h = realized_vol_20d * sqrt(horizon_days / 252) +mu_h = tanh(A_projected_h / 3.0) * confidence * sigma_h +EV_long = mu_h - costs - 0.10 * CVaR_5_loss + +eligible = data_quality >= 0.50 + and confidence >= regime_confidence_min + and contradiction <= regime_contradiction_max + and n_eff_total >= 2.0 + and EV_long > min_edge + +f_kelly = (p_win * b - (1 - p_win)) / b +portfolio_pct = clamp( + max(0, f_kelly) + * 0.25 + * confidence + * data_quality + * (1 - contradiction), + 0, + max_position_pct +) +``` diff --git a/services/aggregation/bayesian.py b/services/aggregation/bayesian.py index e0f361e..3495049 100644 --- a/services/aggregation/bayesian.py +++ b/services/aggregation/bayesian.py @@ -5,14 +5,20 @@ log-likelihood accumulation, Beta distribution parameters, and Shannon entropy for mixed-signal detection. Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 9.1, 9.7 +V3 posterior assembly: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7 """ from __future__ import annotations import math from dataclasses import dataclass +from typing import TYPE_CHECKING from services.aggregation.scoring import WeightedSignal +if TYPE_CHECKING: + from services.aggregation.regime import V3RegimeClassification + from services.aggregation.worker import EvidenceCluster + @dataclass(frozen=True) class BayesianPosterior: @@ -125,3 +131,126 @@ def compute_bayesian_posterior( entropy=entropy, signal_count=count, ) + + +# --------------------------------------------------------------------------- +# V3 Posterior Assembly — Calibrated Evidence Engine +# Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7 +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class V3Posterior: + """V3 posterior result from log-odds Bayesian assembly. + + Attributes: + p_up: Posterior probability of upward move, (0, 1). + p_down: 1 - p_up. + log_odds: Raw log-odds (logit) of P_up. + strength: abs(2 × P_up - 1), signal conviction [0, 1]. + direction: Classified direction string ('bullish', 'bearish', 'neutral'). + n_eff_total: Total effective evidence count across all clusters. + regime: Market regime string used for this computation. + """ + + p_up: float + p_down: float + log_odds: float + strength: float + direction: str + n_eff_total: float + regime: str + + +# Regime-specific direction thresholds: (bullish_threshold, bearish_threshold) +# P_up >= bullish → "bullish"; P_up <= bearish → "bearish"; else "neutral" +_V3_DIRECTION_THRESHOLDS: dict[str, tuple[float, float]] = { + "panic": (0.68, 0.32), + "trend_following": (0.60, 0.40), + "mean_reversion": (0.63, 0.37), + "uncertainty": (0.65, 0.35), +} + + +def _logit(p: float) -> float: + """Compute logit = ln(p / (1-p)) with boundary guard.""" + p = max(1e-10, min(1 - 1e-10, p)) + return math.log(p / (1 - p)) + + +def _sigmoid(x: float) -> float: + """Compute sigmoid = 1 / (1 + exp(-x)) with overflow guard.""" + if x > 500: + return 1.0 + if x < -500: + return 0.0 + return 1.0 / (1.0 + math.exp(-x)) + + +def compute_v3_posterior( + clusters: list[EvidenceCluster], + regime: V3RegimeClassification, + p_prior: float = 0.50, +) -> V3Posterior: + """Assemble v3 posterior via log-odds accumulation. + + Computes: + logit(P_up) = logit(P_prior) + sum(gamma_regime × LLR_c) + P_up = sigmoid(log_odds), clamped to [1e-10, 1 - 1e-10] + strength = abs(2 × P_up - 1) + direction via regime-specific thresholds + + Args: + clusters: List of EvidenceCluster objects with computed cluster_llr. + regime: V3RegimeClassification providing evidence_multiplier and regime. + p_prior: Calibrated prior probability, clamped to [0.40, 0.60]. + + Returns: + V3Posterior with computed posterior fields. + + Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7 + """ + # Clamp prior to [0.40, 0.60] (Req 5.6) + p_prior = max(0.40, min(0.60, p_prior)) + + # Gamma: regime-specific evidence multiplier (Req 5.2) + gamma = regime.evidence_multiplier + + # Compute log-odds: logit(P_prior) + sum(gamma × LLR_c) (Req 5.1, 5.2) + log_odds = _logit(p_prior) + sum(gamma * c.cluster_llr for c in clusters) + + # Compute P_up via sigmoid (Req 5.3) + p_up = _sigmoid(log_odds) + # Clamp to open interval (Req 5.3) + p_up = max(1e-10, min(1 - 1e-10, p_up)) + + p_down = 1.0 - p_up + + # Strength = |2 × P_up - 1| (Req 5.4) + strength = abs(2.0 * p_up - 1.0) + + # n_eff_total = sum of cluster n_eff (Req 5.5) + n_eff_total = sum(c.n_eff for c in clusters) + + # Classify direction using regime-specific thresholds (Req 5.5) + regime_key = regime.regime.value # MarketRegime enum → string + bull_thresh, bear_thresh = _V3_DIRECTION_THRESHOLDS.get( + regime_key, (0.65, 0.35) + ) + + if p_up >= bull_thresh: + direction = "bullish" + elif p_up <= bear_thresh: + direction = "bearish" + else: + direction = "neutral" + + return V3Posterior( + p_up=p_up, + p_down=p_down, + log_odds=log_odds, + strength=strength, + direction=direction, + n_eff_total=n_eff_total, + regime=regime_key, + ) diff --git a/services/aggregation/contradiction.py b/services/aggregation/contradiction.py index de3a0b0..584dba7 100644 --- a/services/aggregation/contradiction.py +++ b/services/aggregation/contradiction.py @@ -10,10 +10,14 @@ from __future__ import annotations import math from dataclasses import dataclass +from typing import TYPE_CHECKING from services.aggregation.scoring import WeightedSignal from services.shared.schemas import DisagreementDetail +if TYPE_CHECKING: + from services.aggregation.worker import EvidenceCluster + @dataclass class CatalystEntry: @@ -236,3 +240,67 @@ def _detect_catalyst_disagreement( )) return details + + +# --------------------------------------------------------------------------- +# V3 LLR Entropy Contradiction +# --------------------------------------------------------------------------- + + +def compute_v3_contradiction(clusters: list[EvidenceCluster]) -> float: + """Compute LLR entropy contradiction score. + + Uses Shannon entropy over positive/negative cluster LLR magnitudes, + weighted by a volume factor that grows with total evidence mass. + + Formula: + E_pos = sum(max(LLR_c, 0)) + E_neg = sum(max(-LLR_c, 0)) + E_total = E_pos + E_neg + f_pos = E_pos / E_total, f_neg = E_neg / E_total + H_conflict = -f_pos × log2(f_pos) - f_neg × log2(f_neg) + volume_factor = 1 - exp(-E_total / 3.0) + result = H_conflict × volume_factor, bounded in [0.0, 1.0] + + Returns 0.0 when: + - clusters is empty + - E_total == 0 (all cluster LLRs are zero) + - Only one direction exists (E_pos == 0 or E_neg == 0) + + Requirements: 7.1–7.7 + """ + if not clusters: + return 0.0 + + e_pos = 0.0 + e_neg = 0.0 + for cluster in clusters: + llr_c = cluster.cluster_llr + if llr_c > 0.0: + e_pos += llr_c + elif llr_c < 0.0: + e_neg += -llr_c # max(-LLR_c, 0) when LLR_c < 0 + + e_total = e_pos + e_neg + + # No evidence or unidirectional → no contradiction + if e_total == 0.0: + return 0.0 + if e_pos == 0.0 or e_neg == 0.0: + return 0.0 + + # Compute fractions + f_pos = e_pos / e_total + f_neg = e_neg / e_total + + # Shannon entropy H_conflict = -f_pos × log2(f_pos) - f_neg × log2(f_neg) + # 0 × log2(0) is treated as 0, but the early returns above guarantee + # both f_pos and f_neg are positive here. + h_conflict = -f_pos * math.log2(f_pos) - f_neg * math.log2(f_neg) + + # Volume factor: suppresses score when total evidence mass is small + volume_factor = 1.0 - math.exp(-e_total / 3.0) + + # Final score bounded to [0.0, 1.0] + result = h_conflict * volume_factor + return max(0.0, min(1.0, result)) diff --git a/services/aggregation/interpolation.py b/services/aggregation/interpolation.py index 2434765..f173ab2 100644 --- a/services/aggregation/interpolation.py +++ b/services/aggregation/interpolation.py @@ -11,6 +11,7 @@ from __future__ import annotations import json import logging +import math from dataclasses import dataclass, field from datetime import datetime, timezone @@ -955,3 +956,107 @@ def apply_accelerated_decay( return accelerated return standard_decay + +# --------------------------------------------------------------------------- +# V3 Macro Layer — Noisy-OR Exposure & LLR Emission (Requirements: 9.1–9.5) +# --------------------------------------------------------------------------- + + +# Noisy-OR weights per dimension +_V3_MACRO_WEIGHTS: dict[str, float] = { + "geo": 0.35, + "supply": 0.25, + "commodity": 0.25, + "sector": 0.15, +} + +# Resilience dampener per tier +_V3_RESILIENCE_DAMPENER: dict[str, float] = { + "global_leader": 0.70, + "multinational": 0.85, + "regional": 1.00, + "domestic": 1.20, +} + + +def compute_normalized_macro_exposure( + overlaps: dict[str, float], + tier: str = "regional", +) -> float: + """Compute normalized macro exposure via noisy-OR. + + E_raw = 1 - product(1 - w_k × O_k) + E_max = 1 - product(1 - w_k) + E_macro = E_raw / E_max × resilience_dampener + + Args: + overlaps: Dimension overlap values keyed by 'geo', 'supply', + 'commodity', 'sector'. Missing keys treated as 0.0. + tier: Market position tier for resilience dampening. + + Returns: + Normalized macro exposure in [0, ∞) (can exceed 1.0 for domestic + tier due to 1.20 dampener, but typically in [0, ~1.2]). + + Requirements: 9.1, 9.2, 9.3 + """ + # E_raw = 1 - product(1 - w_k × O_k) + product_raw = 1.0 + for dim, weight in _V3_MACRO_WEIGHTS.items(): + o_k = max(0.0, min(1.0, overlaps.get(dim, 0.0))) + product_raw *= (1.0 - weight * o_k) + e_raw = 1.0 - product_raw + + # E_max = 1 - product(1 - w_k) — theoretical max when all overlaps = 1.0 + product_max = 1.0 + for weight in _V3_MACRO_WEIGHTS.values(): + product_max *= (1.0 - weight) + e_max = 1.0 - product_max + + # Guard against zero (should never happen with default weights) + if e_max <= 0.0: + return 0.0 + + # Normalize to [0, 1] + e_macro = e_raw / e_max + + # Apply resilience dampener per tier + dampener = _V3_RESILIENCE_DAMPENER.get(tier, 1.0) + return e_macro * dampener + + +def compute_macro_llr( + macro_impact: float, + event_confidence: float, + q_recency: float, + macro_direction: int, +) -> float: + """Compute macro LLR for shared posterior. + + p_macro = clamp(0.50 + 0.30 × macro_impact × event_confidence × q_recency, 0.501, 0.80) + LLR_macro = macro_direction × ln(p_macro / (1 - p_macro)) + + When macro_direction == 0, returns 0.0 (neutral — no directional signal). + + Args: + macro_impact: Normalized macro impact score (typically [0, 1]). + event_confidence: Event classification confidence [0, 1]. + q_recency: Recency quality factor [0, 1]. + macro_direction: +1 for positive, -1 for negative, 0 for neutral. + + Returns: + Log-likelihood ratio for the macro signal. Feeds directly into the + shared posterior without separate post-hoc modifier. + + Requirements: 9.4, 9.5 + """ + if macro_direction == 0: + return 0.0 + + # p_macro = clamp(0.50 + 0.30 × macro_impact × event_confidence × q_recency, 0.501, 0.80) + p_macro = 0.50 + 0.30 * macro_impact * event_confidence * q_recency + p_macro = max(0.501, min(0.80, p_macro)) + + # LLR_macro = macro_direction × ln(p_macro / (1 - p_macro)) + llr = macro_direction * math.log(p_macro / (1.0 - p_macro)) + return llr diff --git a/services/aggregation/projection.py b/services/aggregation/projection.py index 4755ca9..a51d53b 100644 --- a/services/aggregation/projection.py +++ b/services/aggregation/projection.py @@ -13,11 +13,15 @@ import logging import math from dataclasses import dataclass, field from datetime import datetime, timezone +from typing import TYPE_CHECKING import asyncpg from services.shared.schemas import TrendSummary +if TYPE_CHECKING: + from services.aggregation.regime import V3RegimeClassification + logger = logging.getLogger("projection") # --------------------------------------------------------------------------- @@ -493,3 +497,101 @@ async def persist_trend_projection( projection.diverges_from_current, ) return str(row_id) + + +# --------------------------------------------------------------------------- +# V3 Posterior State Projection (Requirements: 11.1–11.7) +# --------------------------------------------------------------------------- + + +# Regime decay factors (phi) — Req 11.3 +_V3_PHI_DECAY: dict[str, float] = { + "panic": 0.35, + "trend_following": 0.80, + "mean_reversion": 0.55, + "uncertainty": 0.50, +} + + +def _logit(p: float) -> float: + """Compute logit = ln(p / (1-p)) with boundary guard.""" + p = max(1e-10, min(1 - 1e-10, p)) + return math.log(p / (1 - p)) + + +def _sigmoid(x: float) -> float: + """Compute sigmoid = 1 / (1 + exp(-x)) with overflow guard.""" + if x > 500: + return 1.0 + if x < -500: + return 0.0 + return 1.0 / (1.0 + math.exp(-x)) + + +@dataclass +class V3ProjectionState: + """V3 posterior state projection result. + + Attributes: + a_t: Accumulated evidence state A_t. + p_up_projected: Projected probability sigmoid(logit(P_prior) + phi^h * A_t). + projected_strength: abs(2 * P_up_projected - 1). + diverges: True when sign(P_up_projected - 0.5) != sign(P_up_t - 0.5). + phi_regime: Regime-specific decay factor used. + """ + + a_t: float + p_up_projected: float + projected_strength: float + diverges: bool + phi_regime: float + + +def compute_v3_projection( + a_prev: float, + cluster_llrs: list[float], + regime: V3RegimeClassification, + p_prior: float, + projection_horizon: int, + known_catalyst_llr: float = 0.0, +) -> V3ProjectionState: + """Compute posterior state projection with regime-aware decay. + + Evidence state: A_t = phi_regime * A_{t-1} + sum(LLR_c), init A_0 = 0.0 + Projected alpha: A_projected = phi^h * A_t + known_catalyst_LLR + P_up_projected = sigmoid(logit(P_prior) + A_projected) + Projected strength = abs(2 * P_up_projected - 1) + Divergence flagged when sign(P_up_projected - 0.5) != sign(P_up_t - 0.5) + + Requirements: 11.1–11.7 + """ + # Resolve phi from regime; default to uncertainty (0.50) if unavailable (Req 11.7) + phi = _V3_PHI_DECAY.get(regime.regime.value, 0.50) if regime else 0.50 + + # Evidence state update: A_t = phi * A_{t-1} + sum(LLR_c) — Req 11.1, 11.2 + a_t = phi * a_prev + sum(cluster_llrs) + + # Projected alpha: A_projected = phi^h * A_t + known_catalyst_LLR — Req 11.4 + a_projected = (phi ** projection_horizon) * a_t + known_catalyst_llr + + # P_up_projected = sigmoid(logit(P_prior) + A_projected) — Req 11.5 + p_up_projected = _sigmoid(_logit(p_prior) + a_projected) + + # Projected strength = abs(2 * P_up_projected - 1) — Req 11.6 + projected_strength = abs(2.0 * p_up_projected - 1.0) + + # Compute current P_up_t for divergence check (not projected) + p_up_t = _sigmoid(_logit(p_prior) + a_t) + + # Flag divergence when projected direction differs from current — Req 11.6 + sign_projected = (p_up_projected - 0.5) >= 0 + sign_current = (p_up_t - 0.5) >= 0 + diverges = sign_projected != sign_current + + return V3ProjectionState( + a_t=a_t, + p_up_projected=p_up_projected, + projected_strength=projected_strength, + diverges=diverges, + phi_regime=phi, + ) diff --git a/services/aggregation/regime.py b/services/aggregation/regime.py index 77f3a62..d81a8a2 100644 --- a/services/aggregation/regime.py +++ b/services/aggregation/regime.py @@ -168,3 +168,151 @@ def classify_regime( bearish_threshold=-threshold, contradiction_penalty_multiplier=contradiction_mult, ) + + +# --------------------------------------------------------------------------- +# V3 Regime Detection — Calibrated Evidence Engine +# Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.7, 6.8 +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class V3RegimeClassification: + """V3 regime classification result with calibrated parameters. + + Attributes: + regime: Market regime category. + trend_z: ATR-normalized trend indicator (EMA_20 - EMA_100) / ATR_20. + vol_ratio: Volatility ratio sigma_20 / sigma_100. + evidence_multiplier: Regime-specific gamma for posterior LLR scaling. + confidence_multiplier: Regime-specific confidence scaling factor. + phi_decay: Evidence state decay factor for projection. + atr_multiplier: Regime-specific ATR multiplier for stop computation. + """ + + regime: MarketRegime + trend_z: float + vol_ratio: float + evidence_multiplier: float + confidence_multiplier: float + phi_decay: float + atr_multiplier: float + + +# Regime parameter lookup: (gamma, confidence_mult, phi, ATR_mult, min_edge) +_V3_REGIME_PARAMS: dict[MarketRegime, tuple[float, float, float, float, float]] = { + MarketRegime.PANIC: (0.70, 0.70, 0.35, 2.5, 0.0100), + MarketRegime.TREND_FOLLOWING: (1.10, 1.00, 0.80, 1.8, 0.0035), + MarketRegime.MEAN_REVERSION: (0.90, 0.95, 0.55, 1.4, 0.0050), + MarketRegime.UNCERTAINTY: (0.80, 0.85, 0.50, 2.0, 0.0075), +} + +# Default uncertainty classification for v3 when data is insufficient (Req 6.8) +_DEFAULT_V3_UNCERTAINTY = V3RegimeClassification( + regime=MarketRegime.UNCERTAINTY, + trend_z=0.0, + vol_ratio=1.0, + evidence_multiplier=0.80, + confidence_multiplier=0.85, + phi_decay=0.50, + atr_multiplier=2.0, +) + + +def _compute_ema_full(values: list[float], span: int) -> float: + """Compute EMA over the full values list with given span. + + Uses standard EMA formula: alpha = 2 / (span + 1), iterating from the + beginning of the list. Seeds EMA with the first value. + + This differs from ``compute_ema`` which only uses the last ``period`` + values. V3 requires iterating over the full history to produce a stable + EMA_100. + """ + if not values or span < 1: + raise ValueError("values must be non-empty and span must be >= 1") + + alpha = 2.0 / (span + 1) + ema = values[0] + for value in values[1:]: + ema = alpha * value + (1.0 - alpha) * ema + return ema + + +def classify_regime_v3( + closing_prices: list[float], + daily_returns: list[float], + atr_20: float, +) -> V3RegimeClassification: + """Classify market regime using v3 ATR-normalized indicators. + + Computes trend_z = (EMA_20 - EMA_100) / ATR_20 and + vol_ratio = sigma_20 / sigma_100 to determine the market regime. + + Classification priority (Req 6.2–6.5): + 1. Panic: vol_ratio > 1.5 OR |trend_z| > 2.5 + 2. Trend following: |trend_z| >= 0.75 AND vol_ratio < 1.3 + 3. Mean reversion: |trend_z| < 0.50 AND vol_ratio < 1.0 + 4. Uncertainty: all other cases + + Falls back to uncertainty when data is insufficient (Req 6.8): + - Fewer than 100 closing prices for EMA_100 + - ATR_20 <= 0 (insufficient bars for ATR) + - Fewer than 100 daily returns for sigma_100 + + Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.7, 6.8 + """ + # --- Data sufficiency check (Req 6.8) --- + if len(closing_prices) < 100: + return _DEFAULT_V3_UNCERTAINTY + + if atr_20 <= 0.0: + return _DEFAULT_V3_UNCERTAINTY + + if len(daily_returns) < 100: + return _DEFAULT_V3_UNCERTAINTY + + # --- Compute trend_z (Req 6.1) --- + ema_20 = _compute_ema_full(closing_prices, span=20) + ema_100 = _compute_ema_full(closing_prices, span=100) + trend_z = (ema_20 - ema_100) / atr_20 + + # --- Compute vol_ratio (Req 6.1) --- + sigma_20 = statistics.stdev(daily_returns[-20:]) if len(daily_returns) >= 20 else 0.0 + sigma_100 = statistics.stdev(daily_returns[-100:]) + + # Guard against zero sigma_100 + if sigma_100 <= 0.0 or math.isnan(sigma_100): + return _DEFAULT_V3_UNCERTAINTY + + if math.isnan(sigma_20): + return _DEFAULT_V3_UNCERTAINTY + + vol_ratio = sigma_20 / sigma_100 + + # --- Classification rules (Req 6.2–6.5) --- + # Priority 1: Panic (Req 6.2) + if vol_ratio > 1.5 or abs(trend_z) > 2.5: + regime = MarketRegime.PANIC + # Priority 2: Trend following (Req 6.3) + elif abs(trend_z) >= 0.75 and vol_ratio < 1.3: + regime = MarketRegime.TREND_FOLLOWING + # Priority 3: Mean reversion (Req 6.4) + elif abs(trend_z) < 0.50 and vol_ratio < 1.0: + regime = MarketRegime.MEAN_REVERSION + # Priority 4: Uncertainty (Req 6.5) + else: + regime = MarketRegime.UNCERTAINTY + + # --- Assign regime parameters (Req 6.6, 6.7) --- + gamma, conf_mult, phi, atr_mult, _min_edge = _V3_REGIME_PARAMS[regime] + + return V3RegimeClassification( + regime=regime, + trend_z=trend_z, + vol_ratio=vol_ratio, + evidence_multiplier=gamma, + confidence_multiplier=conf_mult, + phi_decay=phi, + atr_multiplier=atr_mult, + ) diff --git a/services/aggregation/scoring.py b/services/aggregation/scoring.py index c785aac..685e066 100644 --- a/services/aggregation/scoring.py +++ b/services/aggregation/scoring.py @@ -8,9 +8,12 @@ Requirements: 2.1–2.6, 3.1–3.5, 4.2–4.3, 5.1–5.7, 6.1–6.5, 16.4–16.5 """ from __future__ import annotations +import hashlib +import logging import math from dataclasses import dataclass, field from datetime import datetime, timezone +from typing import Any from services.shared.schemas import MarketContext @@ -588,3 +591,625 @@ def weighted_sentiment_average(signals: list[WeightedSignal]) -> float: if total_weight == 0.0: return 0.0 return weighted_sum / total_weight + + +# =========================================================================== +# V3 Calibrated Evidence Engine — EvidenceUnit and Normalization +# =========================================================================== +# All code below this line implements the v3 pipeline. It is gated behind +# the `v3_engine_enabled` feature flag at the worker/orchestration layer. +# =========================================================================== + + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# V3 Event type base rates (expanded for v3 pipeline) +# --------------------------------------------------------------------------- + +V3_EVENT_TYPE_BASE_RATES: dict[str, float] = { + "earnings": 0.25, + "guidance": 0.20, + "merger_acquisition": 0.05, + "product_launch": 0.15, + "regulatory": 0.10, + "management_change": 0.08, + "partnership": 0.12, + "legal": 0.07, + "analyst_rating": 0.30, + "market_data": 0.40, +} +V3_DEFAULT_BASE_RATE: float = 0.10 + +# Direction mapping constants +_POSITIVE_DIRECTIONS: frozenset[str] = frozenset({"positive", "bullish"}) +_NEGATIVE_DIRECTIONS: frozenset[str] = frozenset({"negative", "bearish"}) +_NEUTRAL_DIRECTIONS: frozenset[str] = frozenset({"neutral", "mixed"}) + +# Macro horizon mapping +_MACRO_HORIZON_MAP: dict[str, str] = { + "short_term": "7d", + "medium_term": "30d", + "long_term": "90d", +} + +# Valid horizons +_VALID_HORIZONS: frozenset[str] = frozenset({"intraday", "1d", "7d", "30d", "90d"}) + + +# --------------------------------------------------------------------------- +# EvidenceUnit dataclass +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class EvidenceUnit: + """Canonical normalized signal representation for the v3 pipeline. + + Every signal — company, macro, or competitive — is normalized into this + shape before entering the calibrated reliability / LLR pipeline. + """ + + symbol: str + layer: str # "company" | "macro" | "competitive" + event_type: str + source_id: str + source_group: str + timestamp: datetime + horizon: str # "intraday" | "1d" | "7d" | "30d" | "90d" + direction: int # -1, 0, +1 + sentiment_strength: float # [0, 1] + impact: float # [0, 1] + extraction_conf: float # [0, 1] + source_cred: float # [0, 1] + novelty: float # [0, 1] + event_base_rate: float # (0, 1] + cluster_id: str + + +# --------------------------------------------------------------------------- +# Helper functions +# --------------------------------------------------------------------------- + + +def _map_direction(direction_str: str | None) -> int: + """Map a sentiment/impact_direction string to a numeric direction. + + Returns: + +1 for positive/bullish, -1 for negative/bearish, 0 for neutral/mixed/unknown. + """ + if direction_str is None: + return 0 + lowered = direction_str.lower().strip() + if lowered in _POSITIVE_DIRECTIONS: + return 1 + if lowered in _NEGATIVE_DIRECTIONS: + return -1 + return 0 + + +def _get_event_base_rate(event_type: str | None) -> float: + """Look up base rate for an event type, defaulting to 0.10.""" + if event_type is None: + return V3_DEFAULT_BASE_RATE + return V3_EVENT_TYPE_BASE_RATES.get(event_type, V3_DEFAULT_BASE_RATE) + + +def _compute_cluster_id( + symbol: str, + horizon: str, + event_type: str, + source_group: str, + time_bucket: str, +) -> str: + """Compute a deterministic cluster_id from the grouping key.""" + key = f"{symbol}|{horizon}|{event_type}|{source_group}|{time_bucket}" + return hashlib.sha256(key.encode()).hexdigest()[:16] + + +def _default_time_bucket(ts: datetime, horizon: str) -> str: + """Compute a time bucket string for clustering based on horizon. + + Bucket resolution per horizon: + intraday → 1h, 1d → 4h, 7d → 24h, 30d → 72h, 90d → 168h + """ + bucket_hours: dict[str, int] = { + "intraday": 1, + "1d": 4, + "7d": 24, + "30d": 72, + "90d": 168, + } + hours = bucket_hours.get(horizon, 24) + # Truncate timestamp to bucket boundary + epoch_hours = int(ts.timestamp() / 3600) + bucket_start = (epoch_hours // hours) * hours + return str(bucket_start) + + +def _safe_float(value: Any, default: float = 0.5) -> float: + """Extract a float value, substituting default for missing/None.""" + if value is None: + return default + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _clamp(value: float, lo: float, hi: float) -> float: + """Clamp a value to [lo, hi].""" + return max(lo, min(value, hi)) + + +# --------------------------------------------------------------------------- +# Normalization functions +# --------------------------------------------------------------------------- + + +def normalize_company_signal( + signal: dict[str, Any], + *, + cluster_id: str | None = None, +) -> EvidenceUnit | None: + """Normalize a company signal into an EvidenceUnit. + + Args: + signal: Dict with keys from document_impact_records or similar. + Required: symbol, timestamp, source_id + Optional: event_type, source_group, horizon, sentiment, + sentiment_strength, impact, extraction_conf, + source_cred, novelty + cluster_id: If provided, use this cluster_id. Otherwise compute + from the signal's grouping key. + + Returns: + EvidenceUnit or None if required fields are missing. + """ + # Validate required fields + symbol = signal.get("symbol") + timestamp = signal.get("timestamp") + source_id = signal.get("source_id") + + if not symbol: + logger.warning("v3: Rejecting company signal — missing 'symbol'. source: %s", signal.get("source_id", "unknown")) + return None + if timestamp is None: + logger.warning("v3: Rejecting company signal — missing 'timestamp'. symbol=%s, source_id=%s", symbol, source_id) + return None + if not source_id: + logger.warning("v3: Rejecting company signal — missing 'source_id'. symbol=%s", symbol) + return None + + # Ensure timestamp is datetime + if isinstance(timestamp, str): + timestamp = datetime.fromisoformat(timestamp) + if timestamp.tzinfo is None: + timestamp = timestamp.replace(tzinfo=timezone.utc) + + # Extract and default fields + event_type = signal.get("event_type") or "unknown" + source_group = signal.get("source_group") or "company" + horizon = signal.get("horizon") or "7d" + if horizon not in _VALID_HORIZONS: + horizon = "7d" + + # Direction mapping + direction = _map_direction(signal.get("sentiment") or signal.get("direction")) + + # Optional numeric fields — default to 0.5 if missing + sentiment_strength = _clamp(_safe_float(signal.get("sentiment_strength")), 0.0, 1.0) + impact = _clamp(_safe_float(signal.get("impact")), 0.0, 1.0) + extraction_conf = _clamp(_safe_float(signal.get("extraction_conf") or signal.get("extraction_confidence")), 0.0, 1.0) + source_cred = _clamp(_safe_float(signal.get("source_cred") or signal.get("source_credibility")), 0.0, 1.0) + novelty = _clamp(_safe_float(signal.get("novelty") or signal.get("novelty_score")), 0.0, 1.0) + + # Event base rate + event_base_rate = _get_event_base_rate(event_type) + + # Cluster ID + if cluster_id is None: + time_bucket = _default_time_bucket(timestamp, horizon) + cluster_id = _compute_cluster_id(symbol, horizon, event_type, source_group, time_bucket) + + return EvidenceUnit( + symbol=str(symbol), + layer="company", + event_type=event_type, + source_id=str(source_id), + source_group=source_group, + timestamp=timestamp, + horizon=horizon, + direction=direction, + sentiment_strength=sentiment_strength, + impact=impact, + extraction_conf=extraction_conf, + source_cred=source_cred, + novelty=novelty, + event_base_rate=event_base_rate, + cluster_id=cluster_id, + ) + + +def normalize_macro_signal( + signal: dict[str, Any], + *, + cluster_id: str | None = None, +) -> EvidenceUnit | None: + """Normalize a macro signal into an EvidenceUnit. + + Macro signals come from macro_impact_records joined with global_events. + + Args: + signal: Dict with keys from macro impact/global event records. + Required: symbol (or ticker), timestamp, source_id (or event_id) + Optional: event_type, impact_direction, macro_impact_score, + event_confidence, estimated_duration, novelty + cluster_id: If provided, use this cluster_id. + + Returns: + EvidenceUnit or None if required fields are missing. + """ + # Validate required fields + symbol = signal.get("symbol") or signal.get("ticker") + timestamp = signal.get("timestamp") + source_id = signal.get("source_id") or signal.get("event_id") + + if not symbol: + logger.warning("v3: Rejecting macro signal — missing 'symbol'/'ticker'. source: %s", signal.get("source_id", "unknown")) + return None + if timestamp is None: + logger.warning("v3: Rejecting macro signal — missing 'timestamp'. symbol=%s, source_id=%s", symbol, source_id) + return None + if not source_id: + logger.warning("v3: Rejecting macro signal — missing 'source_id'/'event_id'. symbol=%s", symbol) + return None + + # Ensure timestamp is datetime + if isinstance(timestamp, str): + timestamp = datetime.fromisoformat(timestamp) + if timestamp.tzinfo is None: + timestamp = timestamp.replace(tzinfo=timezone.utc) + + # Extract fields + event_type = signal.get("event_type") or "unknown" + source_group = "macro" + + # Horizon from estimated_duration + estimated_duration = signal.get("estimated_duration") or "medium_term" + horizon = _MACRO_HORIZON_MAP.get(estimated_duration, "30d") + + # Direction from impact_direction + direction = _map_direction(signal.get("impact_direction") or signal.get("direction")) + + # Impact from macro_impact_score + impact = _clamp(_safe_float(signal.get("macro_impact_score") or signal.get("impact")), 0.0, 1.0) + + # Source cred and extraction conf from event_confidence + event_confidence = _safe_float(signal.get("event_confidence") or signal.get("confidence")) + source_cred = _clamp(event_confidence, 0.0, 1.0) + extraction_conf = _clamp(event_confidence, 0.0, 1.0) + + # Novelty: 1.0 for new events (as per requirement 1.2) + novelty = _clamp(_safe_float(signal.get("novelty"), default=1.0), 0.0, 1.0) + + # Sentiment strength — default 0.5 for macro + sentiment_strength = _clamp(_safe_float(signal.get("sentiment_strength")), 0.0, 1.0) + + # Event base rate + event_base_rate = _get_event_base_rate(event_type) + + # Cluster ID + if cluster_id is None: + time_bucket = _default_time_bucket(timestamp, horizon) + cluster_id = _compute_cluster_id(str(symbol), horizon, event_type, source_group, time_bucket) + + return EvidenceUnit( + symbol=str(symbol), + layer="macro", + event_type=event_type, + source_id=str(source_id), + source_group=source_group, + timestamp=timestamp, + horizon=horizon, + direction=direction, + sentiment_strength=sentiment_strength, + impact=impact, + extraction_conf=extraction_conf, + source_cred=source_cred, + novelty=novelty, + event_base_rate=event_base_rate, + cluster_id=cluster_id, + ) + + +def normalize_competitive_signal( + signal: dict[str, Any], + *, + cluster_id: str | None = None, +) -> EvidenceUnit | None: + """Normalize a competitive signal into an EvidenceUnit. + + Competitive signals come from pattern mining and cross-company propagation. + + Args: + signal: Dict with keys from competitive_signal_records. + Required: symbol (or target_ticker), timestamp, source_id (or source_document_id) + Optional: event_type, signal_direction, signal_strength, + relationship_strength, pattern_confidence, time_horizon + cluster_id: If provided, use this cluster_id. + + Returns: + EvidenceUnit or None if required fields are missing. + """ + # Validate required fields + symbol = signal.get("symbol") or signal.get("target_ticker") + timestamp = signal.get("timestamp") + source_id = signal.get("source_id") or signal.get("source_document_id") + + if not symbol: + logger.warning("v3: Rejecting competitive signal — missing 'symbol'/'target_ticker'. source: %s", signal.get("source_id", "unknown")) + return None + if timestamp is None: + logger.warning("v3: Rejecting competitive signal — missing 'timestamp'. symbol=%s, source_id=%s", symbol, source_id) + return None + if not source_id: + logger.warning("v3: Rejecting competitive signal — missing 'source_id'/'source_document_id'. symbol=%s", symbol) + return None + + # Ensure timestamp is datetime + if isinstance(timestamp, str): + timestamp = datetime.fromisoformat(timestamp) + if timestamp.tzinfo is None: + timestamp = timestamp.replace(tzinfo=timezone.utc) + + # Extract fields + event_type = signal.get("event_type") or "unknown" + source_group = "competitive" + + # Horizon from time_horizon field + time_horizon = signal.get("time_horizon") or signal.get("horizon") or "7d" + if time_horizon in _MACRO_HORIZON_MAP: + horizon = _MACRO_HORIZON_MAP[time_horizon] + elif time_horizon in _VALID_HORIZONS: + horizon = time_horizon + else: + horizon = "7d" + + # Direction from signal_direction (bullish/bearish/neutral) + direction = _map_direction(signal.get("signal_direction") or signal.get("direction")) + + # Impact = signal_strength × relationship_strength (Req 1.3) + signal_strength = _safe_float(signal.get("signal_strength")) + relationship_strength = _safe_float(signal.get("relationship_strength")) + impact = _clamp(signal_strength * relationship_strength, 0.0, 1.0) + + # Source cred from pattern_confidence (Req 1.3) + pattern_confidence = _safe_float(signal.get("pattern_confidence")) + source_cred = _clamp(pattern_confidence, 0.0, 1.0) + + # Extraction conf = pattern_confidence (Req 1.3) + extraction_conf = _clamp(pattern_confidence, 0.0, 1.0) + + # Novelty: 1.0 for competitive signals (Req 1.3) + novelty = _clamp(_safe_float(signal.get("novelty"), default=1.0), 0.0, 1.0) + + # Sentiment strength — default 0.5 for competitive + sentiment_strength = _clamp(_safe_float(signal.get("sentiment_strength")), 0.0, 1.0) + + # Event base rate + event_base_rate = _get_event_base_rate(event_type) + + # Cluster ID + if cluster_id is None: + time_bucket = _default_time_bucket(timestamp, horizon) + cluster_id = _compute_cluster_id(str(symbol), horizon, event_type, source_group, time_bucket) + + return EvidenceUnit( + symbol=str(symbol), + layer="competitive", + event_type=event_type, + source_id=str(source_id), + source_group=source_group, + timestamp=timestamp, + horizon=horizon, + direction=direction, + sentiment_strength=sentiment_strength, + impact=impact, + extraction_conf=extraction_conf, + source_cred=source_cred, + novelty=novelty, + event_base_rate=event_base_rate, + cluster_id=cluster_id, + ) + + +# =========================================================================== +# V3 Calibrated Reliability Pipeline +# =========================================================================== + + +@dataclass(frozen=True) +class SourceStats: + """Historical accuracy stats for a signal source (Bayesian prior). + + Used to compute q_source via Beta-Binomial shrinkage. + """ + + source_id: str + hits: int = 0 # correct directional predictions + misses: int = 0 # incorrect directional predictions + alpha_0: float = 3.0 # Beta prior alpha (pseudo-successes) + beta_0: float = 3.0 # Beta prior beta (pseudo-failures) + + +@dataclass(frozen=True) +class ReliabilityComponents: + """Breakdown of calibrated reliability for a single signal. + + Each q_* factor is in [0, 1] and represents one quality dimension. + q_i is the final combined reliability used downstream in LLR conversion. + """ + + q_ext: float # extraction confidence reliability + q_source: float # source accuracy reliability (Bayesian shrinkage) + q_recency: float # temporal freshness reliability + q_uniqueness: float # novelty / de-duplication reliability + q_i: float # final combined: clamp(q_ext × q_source × source_cred × q_recency × q_uniqueness, 0, 1) + + +# Horizon-specific base half-lives for recency decay (hours) +_V3_TAU_BASE: dict[str, float] = { + "intraday": 2.0, + "1d": 12.0, + "7d": 72.0, + "30d": 240.0, + "90d": 720.0, +} + + +def _sigmoid(x: float) -> float: + """Compute sigmoid(x) = 1 / (1 + exp(-x)) with overflow guard.""" + if x < -500.0: + return 0.0 + if x > 500.0: + return 1.0 + return 1.0 / (1.0 + math.exp(-x)) + + +def compute_v3_reliability( + unit: EvidenceUnit, + source_stats: SourceStats, + cluster_position: int, # duplicate_count_before + reference_time: datetime, +) -> ReliabilityComponents: + """Compute calibrated reliability components for an EvidenceUnit. + + Implements Requirements 2.1–2.9: extraction confidence gate, Bayesian + source accuracy, adaptive recency decay, and novelty/uniqueness penalty. + + Args: + unit: The normalized evidence unit to score. + source_stats: Historical accuracy record for the signal's source. + cluster_position: Number of signals in the same cluster ingested + before this one (duplicate_count_before). 0 for first-in-cluster. + reference_time: The "now" anchor for computing age_hours. + + Returns: + ReliabilityComponents with individual factors and combined q_i. + """ + # --- q_ext: extraction confidence reliability (Req 2.1) --- + # sigmoid(8.0 × (extraction_conf - 0.55)) + q_ext = _sigmoid(8.0 * (unit.extraction_conf - 0.55)) + + # --- q_source: Bayesian shrinkage source reliability (Req 2.2, 2.3) --- + alpha = source_stats.alpha_0 + source_stats.hits + beta = source_stats.beta_0 + source_stats.misses + e_theta = alpha / (alpha + beta) + # clamp((E[theta] - 0.50) / 0.35, 0, 1) + q_source = _clamp((e_theta - 0.50) / 0.35, 0.0, 1.0) + + # --- q_recency: adaptive exponential decay (Req 2.4, 2.5, 2.6) --- + # Ensure tz-aware timestamps + ts = unit.timestamp + if ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + ref = reference_time + if ref.tzinfo is None: + ref = ref.replace(tzinfo=timezone.utc) + + age_hours = max((ref - ts).total_seconds() / 3600.0, 0.0) + + # Adaptive half-life: tau_adaptive = tau_base × (1 + 0.75 × impact + 0.50 × surprise) + # surprise = clamp(-log2(event_base_rate) / 5, 0, 1) + event_base_rate = unit.event_base_rate + if event_base_rate <= 0.0: + event_base_rate = 0.10 # Req 2.5: default to 0.10 to prevent log(0) + + surprise = _clamp(-math.log2(event_base_rate) / 5.0, 0.0, 1.0) + + tau_base = _V3_TAU_BASE.get(unit.horizon, 72.0) + tau_adaptive = tau_base * (1.0 + 0.75 * unit.impact + 0.50 * surprise) + + # q_recency = 2^(-age_hours / tau_adaptive) + # Guard against extreme exponents + if tau_adaptive <= 0.0: + tau_adaptive = tau_base # fallback + exponent = -age_hours / tau_adaptive + # For very large negative exponents, result is effectively 0 + if exponent < -1000.0: + q_recency = 0.0 + else: + q_recency = math.pow(2.0, exponent) + + # --- q_uniqueness: novelty + de-duplication (Req 2.7) --- + # clamp(0.5 + 0.5 × novelty, 0.5, 1.0) × (1 / sqrt(1 + dup_count)) + novelty_factor = _clamp(0.5 + 0.5 * unit.novelty, 0.5, 1.0) + dedup_factor = 1.0 / math.sqrt(1.0 + cluster_position) + q_uniqueness = novelty_factor * dedup_factor + + # --- q_i: combined reliability (Req 2.8) --- + q_i = _clamp( + q_ext * q_source * unit.source_cred * q_recency * q_uniqueness, + 0.0, + 1.0, + ) + + # --- Explainability floor on q_recency (Req 2.9) --- + # Apply floor of 0.01 only for the display value; q_i uses raw q_recency + q_recency_display = max(q_recency, 0.01) + + return ReliabilityComponents( + q_ext=q_ext, + q_source=q_source, + q_recency=q_recency_display, + q_uniqueness=q_uniqueness, + q_i=q_i, + ) + + +# --------------------------------------------------------------------------- +# V3 LLR Conversion (Requirements 3.1–3.6) +# --------------------------------------------------------------------------- + + +def compute_llr(unit: EvidenceUnit, q_i: float) -> float: + """Convert calibrated reliability to log-likelihood ratio. + + Requirements: 3.1–3.6 + + Formula: + p_correct = clamp(0.50 + 0.35 × q_i × impact × sentiment_strength, 0.501, 0.85) + LLR_i = direction × ln(p_correct / (1 - p_correct)) + + For neutral signals (direction == 0), returns 0.0 immediately. + For directional signals, the LLR sign always matches direction. + + Bounds: + - Minimum |LLR| ≈ ln(0.501/0.499) ≈ 0.004 for directional signals + - Maximum |LLR| ≈ ln(0.85/0.15) ≈ 1.735 + + Args: + unit: The normalized evidence unit containing direction, impact, + and sentiment_strength. + q_i: The combined calibrated reliability from compute_v3_reliability. + + Returns: + Log-likelihood ratio. Positive for bullish, negative for bearish, + zero for neutral. + """ + # Req 3.5: Neutral signals produce zero LLR + if unit.direction == 0: + return 0.0 + + # Req 3.1–3.2: Compute p_correct with calibrated reliability + p_correct = _clamp( + 0.50 + 0.35 * q_i * unit.impact * unit.sentiment_strength, + 0.501, + 0.85, + ) + + # Req 3.3–3.4: LLR_i = direction × ln(p_correct / (1 - p_correct)) + llr = unit.direction * math.log(p_correct / (1.0 - p_correct)) + + return llr diff --git a/services/aggregation/signal_propagation.py b/services/aggregation/signal_propagation.py index b6f4979..5883260 100644 --- a/services/aggregation/signal_propagation.py +++ b/services/aggregation/signal_propagation.py @@ -378,3 +378,74 @@ def build_pattern_weighted_signals( )) return signals + + +# --------------------------------------------------------------------------- +# v3 — Correlation-shrunk competitive propagation (Requirements: 10.1–10.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)) diff --git a/services/aggregation/worker.py b/services/aggregation/worker.py index b23e0ec..64baeb1 100644 --- a/services/aggregation/worker.py +++ b/services/aggregation/worker.py @@ -13,7 +13,8 @@ import logging import math import time import uuid as _uuid -from dataclasses import dataclass +from collections import defaultdict +from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone from typing import Any @@ -46,6 +47,7 @@ from services.aggregation.regime import ( classify_regime, ) from services.aggregation.scoring import ( + EvidenceUnit, ScoringConfig, WeightedSignal, compute_signal_weight, @@ -298,6 +300,934 @@ async def fetch_probabilistic_scoring_enabled(pool: asyncpg.Pool) -> bool: return False +# --------------------------------------------------------------------------- +# v3 Calibrated Evidence Engine feature flag +# +# V3 ENGINE TOGGLE (Requirements 19.1–19.6): +# - Read fresh from the risk_configs table at the start of each aggregation +# cycle. When True, the v3 calibrated evidence pipeline is used. When +# False (or on any read failure), the heuristic pipeline is used. +# - Defaults to False (heuristic) when: +# * The key is missing from risk_configs +# * The value is not a recognized truthy string +# * The database is unreachable +# - This ensures fail-safe behavior: any failure defaults to the stable +# heuristic pipeline. +# --------------------------------------------------------------------------- + +_V3_ENGINE_FLAG_QUERY = """ +SELECT value FROM risk_configs WHERE key = 'v3_engine_enabled' +""" + + +async def _read_v3_flag(pool: asyncpg.Pool) -> bool: + """Read v3_engine_enabled from risk_configs. Default False on error. + + Requirements: 19.3, 19.6 + """ + try: + row = await pool.fetchrow(_V3_ENGINE_FLAG_QUERY) + if row: + return str(row["value"]).lower() in ("true", "1", "yes") + return False + except Exception as e: + logger.warning("Failed to read v3_engine_enabled flag: %s", e) + return False + + +# --------------------------------------------------------------------------- +# v3 Calibrated Evidence Pipeline — Full End-to-End Orchestration +# +# Pipeline stages: +# EvidenceUnit → q_i (reliability) → LLR → cluster → posterior → +# contradiction → confidence → data_quality → TrendSummary +# +# Requirements: 20.1, 20.2, 20.3, 20.4, 20.5 +# --------------------------------------------------------------------------- + + +async def _run_v3_pipeline( + pool: asyncpg.Pool, + ticker: str, + window: str, + reference_time: datetime, + config: AggregationConfig, +) -> TrendSummary: + """Run the v3 calibrated evidence pipeline for a single ticker/window. + + Orchestrates the full pipeline: + 1. Fetch signals from DB (company, macro, competitive) + 2. Normalize each into EvidenceUnit + 3. Compute calibrated reliability (q_i) for each unit + 4. Convert to LLR + 5. Cluster evidence by grouping key + 6. Compute n_eff and cluster_llr per cluster + 7. Classify market regime + 8. Compute posterior (p_up, direction, strength) + 9. Compute contradiction + 10. Compute data quality + 11. Compute multiplicative confidence + 12. Assemble TrendSummary with v3 metadata in market_context JSONB + + Requirements: 20.1, 20.2, 20.3, 20.4, 20.5 + """ + from services.aggregation.bayesian import compute_v3_posterior + from services.aggregation.contradiction import compute_v3_contradiction + from services.aggregation.scoring import ( + SourceStats, + compute_llr, + compute_v3_reliability, + normalize_company_signal, + normalize_competitive_signal, + normalize_macro_signal, + ) + + duration = WINDOW_DURATIONS.get(window, timedelta(days=7)) + window_start = reference_time - duration + + # ----------------------------------------------------------------------- + # Stage 1: Fetch signals from DB + # ----------------------------------------------------------------------- + impacts = await fetch_impact_records(pool, ticker, window_start, reference_time) + + macro_impacts: list[MacroImpactRow] = [] + macro_enabled = config.macro_enabled + db_macro_toggle = await fetch_macro_enabled(pool) + if db_macro_toggle is not None: + macro_enabled = db_macro_toggle + if macro_enabled: + macro_impacts = await fetch_macro_impact_records( + pool, ticker, window_start, reference_time, + ) + + competitive_signals: list[CompetitiveSignalRecord] = [] + competitive_enabled = config.competitive_enabled + db_comp_toggle = await fetch_competitive_enabled(pool) + if db_comp_toggle is not None: + competitive_enabled = db_comp_toggle + if competitive_enabled: + try: + competitive_signals = await fetch_competitive_signals( + pool, ticker, window_start, reference_time, + ) + except Exception: + logger.warning( + "v3: Failed to fetch competitive signals for %s/%s", + ticker, window, exc_info=True, + ) + + # ----------------------------------------------------------------------- + # Stage 2: Normalize into EvidenceUnits + # ----------------------------------------------------------------------- + units: list[EvidenceUnit] = [] + # Track WeightedSignal intermediates for explainability (Req 20.5) + weighted_signal_intermediates: list[WeightedSignal] = [] + + # Company signals + for imp in impacts: + signal_dict: dict[str, Any] = { + "symbol": ticker, + "timestamp": imp.published_at, + "source_id": imp.document_id, + "event_type": imp.catalyst_type, + "source_group": "company", + "horizon": window if window in ("intraday", "1d", "7d", "30d", "90d") else "7d", + "sentiment": imp.sentiment, + "sentiment_strength": imp.impact_score, + "impact": imp.impact_score, + "extraction_conf": imp.confidence, + "source_cred": imp.source_credibility, + "novelty": imp.novelty_score, + } + unit = normalize_company_signal(signal_dict) + if unit is not None: + units.append(unit) + # Build WeightedSignal intermediate (Req 20.5) + weighted_signal_intermediates.append( + WeightedSignal( + document_id=imp.document_id, + weight=compute_signal_weight( + published_at=imp.published_at, + reference_time=reference_time, + window=window, + source_credibility=imp.source_credibility, + novelty_score=imp.novelty_score, + extraction_confidence=imp.confidence, + ), + sentiment_value=sentiment_to_numeric(imp.sentiment), + impact_score=imp.impact_score, + ) + ) + + # Macro signals + for mir in macro_impacts: + signal_dict = { + "symbol": ticker, + "timestamp": mir.event_published_at, + "source_id": mir.event_id, + "event_type": "macro", + "impact_direction": mir.impact_direction, + "macro_impact_score": mir.macro_impact_score, + "confidence": mir.confidence, + "estimated_duration": "medium_term", + "novelty": 1.0, + "sentiment_strength": mir.macro_impact_score, + } + unit = normalize_macro_signal(signal_dict) + if unit is not None: + units.append(unit) + + # Competitive signals + for cs in competitive_signals: + signal_dict = { + "symbol": ticker, + "target_ticker": cs.target_ticker, + "timestamp": cs.computed_at, + "source_id": cs.source_document_id, + "source_document_id": cs.source_document_id, + "event_type": cs.catalyst_type, + "signal_direction": cs.signal_direction, + "signal_strength": cs.signal_strength, + "relationship_strength": cs.relationship_strength, + "pattern_confidence": cs.pattern_confidence, + "novelty": 1.0, + "sentiment_strength": cs.signal_strength, + } + unit = normalize_competitive_signal(signal_dict) + if unit is not None: + units.append(unit) + + # If no valid evidence units, return a neutral TrendSummary + if not units: + return _build_empty_v3_summary(ticker, window, reference_time) + + # ----------------------------------------------------------------------- + # Stage 3: Compute calibrated reliability (q_i) for each unit + # ----------------------------------------------------------------------- + # Use neutral SourceStats (hits=0, misses=0) for initial wiring + # Historical source tracking will be added in a future task. + neutral_stats = SourceStats(source_id="default", hits=0, misses=0) + + # Count duplicates per cluster for q_uniqueness + cluster_seen: dict[str, int] = defaultdict(int) + q_values: list[float] = [] + reliability_results = [] + + for unit in units: + cluster_pos = cluster_seen[unit.cluster_id] + cluster_seen[unit.cluster_id] += 1 + + reliability = compute_v3_reliability( + unit=unit, + source_stats=neutral_stats, + cluster_position=cluster_pos, + reference_time=reference_time, + ) + q_values.append(reliability.q_i) + reliability_results.append(reliability) + + # ----------------------------------------------------------------------- + # Stage 4: Convert to LLR + # ----------------------------------------------------------------------- + llrs: list[float] = [] + for unit, q_i in zip(units, q_values): + llr = compute_llr(unit, q_i) + llrs.append(llr) + + # ----------------------------------------------------------------------- + # Stage 5: Cluster evidence + # ----------------------------------------------------------------------- + clusters = cluster_evidence(units, llrs) + + # ----------------------------------------------------------------------- + # Stage 6: Compute n_eff and cluster_llr per cluster + # ----------------------------------------------------------------------- + for cluster in clusters: + cluster.n_eff = compute_n_eff(cluster.llrs) + cluster.cluster_llr = compute_cluster_llr(cluster.llrs, cluster.n_eff) + + # ----------------------------------------------------------------------- + # Stage 7: Classify market regime + # ----------------------------------------------------------------------- + regime = await _classify_regime_v3(pool, ticker) + + # ----------------------------------------------------------------------- + # Stage 8: Compute posterior + # ----------------------------------------------------------------------- + posterior = compute_v3_posterior(clusters, regime, p_prior=0.50) + + # ----------------------------------------------------------------------- + # Stage 9: Compute contradiction + # ----------------------------------------------------------------------- + contradiction = compute_v3_contradiction(clusters) + + # ----------------------------------------------------------------------- + # Stage 10: Compute data quality + # ----------------------------------------------------------------------- + # Compute extraction_failure_rate: fraction of signals that failed normalization + total_attempted = len(impacts) + len(macro_impacts) + len(competitive_signals) + n_valid = len(units) + extraction_failure_rate = 1.0 - (n_valid / max(total_attempted, 1)) + + # Compute age of newest unit + if units: + newest_ts = max(u.timestamp for u in units) + if newest_ts.tzinfo is None: + newest_ts = newest_ts.replace(tzinfo=timezone.utc) + age_newest_hours = max( + (reference_time - newest_ts).total_seconds() / 3600.0, 0.0 + ) + else: + age_newest_hours = 168.0 # max staleness + + # Count source types (company, macro, competitive) + source_types = {u.layer for u in units} + n_source_types = len(source_types) + + data_quality = compute_v3_data_quality( + units=units, + extraction_failure_rate=extraction_failure_rate, + age_newest_hours=age_newest_hours, + n_source_types=n_source_types, + ) + + # ----------------------------------------------------------------------- + # Stage 11: Compute multiplicative confidence + # ----------------------------------------------------------------------- + confidence = compute_v3_confidence( + n_eff_total=posterior.n_eff_total, + q_values=q_values, + llrs=llrs, + strength=posterior.strength, + regime_confidence_mult=regime.confidence_multiplier, + contradiction=contradiction, + data_quality=data_quality, + ) + + # ----------------------------------------------------------------------- + # Stage 12: Assemble TrendSummary with v3 metadata + # ----------------------------------------------------------------------- + + # Map posterior direction to TrendDirection + if posterior.direction == "bullish": + trend_direction = TrendDirection.BULLISH + elif posterior.direction == "bearish": + trend_direction = TrendDirection.BEARISH + else: + trend_direction = TrendDirection.NEUTRAL + + # Check if informational mode should be forced + force_info, force_reason = should_force_informational_v3( + data_quality=data_quality, + units=units, + extraction_failure_rate=extraction_failure_rate, + ) + + # Build explainability payload: top positive/negative clusters (Req 20.3) + sorted_pos_clusters = sorted( + [c for c in clusters if c.cluster_llr > 0], + key=lambda c: c.cluster_llr, + reverse=True, + )[:5] + sorted_neg_clusters = sorted( + [c for c in clusters if c.cluster_llr < 0], + key=lambda c: c.cluster_llr, + )[:5] + + top_positive_clusters = [ + { + "cluster_id": c.cluster_id, + "llr": round(c.cluster_llr, 4), + "n_eff": round(c.n_eff, 2), + "n_units": len(c.units), + } + for c in sorted_pos_clusters + ] + top_negative_clusters = [ + { + "cluster_id": c.cluster_id, + "llr": round(c.cluster_llr, 4), + "n_eff": round(c.n_eff, 2), + "n_units": len(c.units), + } + for c in sorted_neg_clusters + ] + + # Suppression reasons + suppression_reasons: list[str] = [] + if force_info: + suppression_reasons.append(force_reason) + if contradiction > 0.5: + suppression_reasons.append("high_contradiction") + if data_quality < 0.5: + suppression_reasons.append("low_data_quality") + + # Build v3 metadata for market_context JSONB (Req 20.1, 20.2) + v3_metadata: dict[str, Any] = { + "v3_posterior": { + "p_up": round(posterior.p_up, 6), + "p_down": round(posterior.p_down, 6), + "log_odds": round(posterior.log_odds, 6), + "strength": round(posterior.strength, 6), + "confidence": round(confidence, 6), + "contradiction": round(contradiction, 6), + "n_eff": round(posterior.n_eff_total, 4), + "data_quality": round(data_quality, 6), + "regime": posterior.regime, + }, + "pipeline_mode": "v3", + "explainability": { + "top_positive_clusters": top_positive_clusters, + "top_negative_clusters": top_negative_clusters, + "suppression_reasons": suppression_reasons, + "risk_adjustments": [], + }, + } + + # Derive evidence rankings from weighted signals + supporting_ids: list[str] = [] + opposing_ids: list[str] = [] + for ws in weighted_signal_intermediates: + if ws.sentiment_value > 0: + supporting_ids.append(ws.document_id) + elif ws.sentiment_value < 0: + opposing_ids.append(ws.document_id) + supporting_ids = supporting_ids[:MAX_EVIDENCE_REFS] + opposing_ids = opposing_ids[:MAX_EVIDENCE_REFS] + + # Extract catalysts and risks from impacts + catalysts, risks = extract_catalysts_and_risks( + impacts, weighted_signal_intermediates, + ) + + summary = TrendSummary( + entity_type="company", + entity_id=ticker, + window=TrendWindow(window), + trend_direction=trend_direction, + trend_strength=round(posterior.strength, 4), + confidence=round(confidence, 4), + top_supporting_evidence=supporting_ids, + top_opposing_evidence=opposing_ids, + dominant_catalysts=catalysts, + material_risks=risks, + contradiction_score=round(contradiction, 4), + disagreement_details=[], + generated_at=reference_time, + p_bull=round(posterior.p_up, 6), + regime=posterior.regime, + pipeline_mode="v3", + ) + # Assign market_context as dict after creation to avoid MarketContext coercion + summary.market_context = v3_metadata # type: ignore[assignment] + + # Persist trend summary and get trend_id + await persist_trend_summary(pool, summary) + + logger.info( + "v3 pipeline: %s/%s → p_up=%.4f direction=%s strength=%.3f " + "confidence=%.3f contradiction=%.3f data_quality=%.3f " + "n_eff=%.2f regime=%s clusters=%d units=%d", + ticker, window, posterior.p_up, posterior.direction, + posterior.strength, confidence, contradiction, data_quality, + posterior.n_eff_total, posterior.regime, len(clusters), len(units), + ) + + return summary + + +def _build_empty_v3_summary( + ticker: str, window: str, reference_time: datetime +) -> TrendSummary: + """Build a neutral TrendSummary when no evidence is available for v3 pipeline.""" + v3_metadata: dict[str, Any] = { + "v3_posterior": { + "p_up": 0.5, + "p_down": 0.5, + "log_odds": 0.0, + "strength": 0.0, + "confidence": 0.0, + "contradiction": 0.0, + "n_eff": 0.0, + "data_quality": 0.0, + "regime": "uncertainty", + }, + "pipeline_mode": "v3", + "explainability": { + "top_positive_clusters": [], + "top_negative_clusters": [], + "suppression_reasons": ["no_evidence"], + "risk_adjustments": [], + }, + } + summary = TrendSummary( + entity_type="company", + entity_id=ticker, + window=TrendWindow(window), + trend_direction=TrendDirection.NEUTRAL, + trend_strength=0.0, + confidence=0.0, + top_supporting_evidence=[], + top_opposing_evidence=[], + dominant_catalysts=[], + material_risks=[], + contradiction_score=0.0, + disagreement_details=[], + generated_at=reference_time, + p_bull=0.5, + regime="uncertainty", + pipeline_mode="v3", + ) + # Assign dict directly to avoid MarketContext coercion + summary.market_context = v3_metadata # type: ignore[assignment] + return summary + + +# --------------------------------------------------------------------------- +# v3 Regime Classification Helper +# --------------------------------------------------------------------------- + + +async def _classify_regime_v3( + pool: asyncpg.Pool, + ticker: str, +) -> Any: + """Classify market regime for v3 pipeline using ATR-normalized indicators. + + Fetches closing prices and daily returns from market_data_daily. Computes + ATR_20 approximation from absolute daily returns (true range proxy when + high/low data is unavailable). + + Falls back to default uncertainty regime on any failure. + """ + from services.aggregation.regime import ( + _DEFAULT_V3_UNCERTAINTY, + classify_regime_v3, + ) + + try: + price_rows = await pool.fetch(_CLOSING_PRICES_QUERY, ticker) + if not price_rows or len(price_rows) < 100: + return _DEFAULT_V3_UNCERTAINTY + + # Prices come in DESC order; reverse to chronological + closing_prices = [ + float(r["close"]) for r in reversed(price_rows) + if r["close"] is not None + ] + + if len(closing_prices) < 100: + return _DEFAULT_V3_UNCERTAINTY + + return_rows = await pool.fetch(_DAILY_RETURNS_QUERY, ticker) + daily_returns = [ + float(r["daily_return"]) + for r in reversed(return_rows) + if r["daily_return"] is not None + ] + + if len(daily_returns) < 100: + return _DEFAULT_V3_UNCERTAINTY + + # Approximate ATR_20 from absolute daily returns × latest close + # (True Range ≈ |daily_return| × close when high/low unavailable) + recent_abs_returns = [abs(r) for r in daily_returns[-20:]] + avg_abs_return = sum(recent_abs_returns) / len(recent_abs_returns) if recent_abs_returns else 0.0 + latest_close = closing_prices[-1] + atr_20 = avg_abs_return * latest_close + + if atr_20 <= 0.0: + return _DEFAULT_V3_UNCERTAINTY + + return classify_regime_v3(closing_prices, daily_returns, atr_20) + + except Exception: + logger.warning( + "v3: Failed to classify regime for %s — defaulting to uncertainty", + ticker, + exc_info=True, + ) + return _DEFAULT_V3_UNCERTAINTY + + +# --------------------------------------------------------------------------- +# v3 Correlation-Aware Clustering +# +# Groups EvidenceUnits into clusters by their pre-computed cluster_id. +# The cluster_id encodes (symbol, horizon, event_type, source_group, +# time_bucket) — computed during normalization (task 1.1). +# +# Time-bucket resolution per horizon (already baked into cluster_id): +# intraday → 1h, 1d → 4h, 7d → 24h, 30d → 72h, 90d → 168h +# +# Requirements: 4.1 +# --------------------------------------------------------------------------- + + +@dataclass +class EvidenceCluster: + """A group of correlated signals sharing the same cluster key.""" + + cluster_id: str + units: list[EvidenceUnit] = field(default_factory=list) + llrs: list[float] = field(default_factory=list) + n_eff: float = 0.0 # computed later by compute_n_eff + cluster_llr: float = 0.0 # computed later by compute_cluster_llr + + +def cluster_evidence( + units: list[EvidenceUnit], llrs: list[float] +) -> list[EvidenceCluster]: + """Group EvidenceUnits into clusters by their pre-computed cluster_id. + + Each EvidenceUnit carries a cluster_id computed during normalization from + the grouping key (symbol, horizon, event_type, source_group, time_bucket). + Units with the same cluster_id are grouped into one EvidenceCluster. + + The llrs list must be parallel to units (one LLR per unit, same order). + + Requirements: 4.1 + """ + groups: dict[str, tuple[list[EvidenceUnit], list[float]]] = defaultdict( + lambda: ([], []) + ) + + for unit, llr in zip(units, llrs): + group_units, group_llrs = groups[unit.cluster_id] + group_units.append(unit) + group_llrs.append(llr) + + return [ + EvidenceCluster( + cluster_id=cid, + units=group_units, + llrs=group_llrs, + ) + for cid, (group_units, group_llrs) in groups.items() + ] + + +# --------------------------------------------------------------------------- +# v3 Effective Evidence Count (n_eff) +# +# Computes the effective number of independent observations in a cluster +# using pairwise correlations to discount redundant signals. +# +# Default pairwise correlations for signals within a cluster: +# same_wire (same source_group & event) → 0.80 +# same_event_diff_publisher → 0.50 +# same_theme_diff_event → 0.25 +# independent → 0.00 +# +# Since signals in the same cluster share (symbol, horizon, event_type, +# source_group, time_bucket), the default intra-cluster rho is 0.80. +# +# Requirements: 4.2, 4.3 +# --------------------------------------------------------------------------- + +# Default pairwise correlations for signals within a cluster +_V3_DEFAULT_CLUSTER_CORRELATIONS: dict[str, float] = { + "same_wire": 0.80, + "same_event_diff_publisher": 0.50, + "same_theme_diff_event": 0.25, + "independent": 0.00, +} +_V3_DEFAULT_INTRA_CLUSTER_RHO: float = 0.80 # same source_group & event + + +def compute_n_eff( + llrs: list[float], correlations: list[list[float]] | None = None +) -> float: + """Compute effective evidence count for a cluster. + + n_eff = (sum w_i)² / (sum w_i² + 2 × sum_{i<j} rho_ij × w_i × w_j) + + Where w_i = |LLR_i| and rho_ij are pairwise correlations. + + If no correlation matrix provided, uses default rho=0.80 for all pairs + (same wire/source group within a cluster). + + Guards: Returns 1.0 on division by zero. + + Requirements: 4.2, 4.3 + """ + n = len(llrs) + if n == 0: + return 1.0 + if n == 1: + return 1.0 + + # Weights are absolute values of LLRs + weights = [abs(llr) for llr in llrs] + + # Numerator: (sum w_i)² + sum_w = sum(weights) + numerator = sum_w * sum_w + + # Denominator: sum w_i² + 2 × sum_{i<j} rho_ij × w_i × w_j + sum_w_sq = sum(w * w for w in weights) + + cross_term = 0.0 + for i in range(n): + for j in range(i + 1, n): + if correlations is not None: + rho = correlations[i][j] + else: + rho = _V3_DEFAULT_INTRA_CLUSTER_RHO + cross_term += rho * weights[i] * weights[j] + + denominator = sum_w_sq + 2.0 * cross_term + + # Guard against division by zero + if denominator <= 0.0: + return 1.0 + + n_eff = numerator / denominator + return n_eff + + +# --------------------------------------------------------------------------- +# v3 Cluster LLR Computation +# +# Combines individual LLRs within a cluster into a single cluster-level +# log-likelihood ratio using a magnitude-weighted mean scaled by sqrt(n_eff). +# +# weighted_mean(LLR_i, |LLR_i|) = sum(LLR_i × |LLR_i|) / sum(|LLR_i|) +# +# This weights toward higher-magnitude signals so that strong evidence +# dominates over noise within the cluster. +# +# Requirements: 4.4, 4.5 +# --------------------------------------------------------------------------- + +_V3_CLUSTER_LLR_CLAMP = 2.5 + + +def compute_cluster_llr(llrs: list[float], n_eff: float) -> float: + """Compute the cluster-level log-likelihood ratio. + + LLR_c = clamp(weighted_mean(LLR_i, |LLR_i|) × sqrt(n_eff), -2.5, 2.5) + + weighted_mean uses |LLR_i| as weights to weight toward stronger signals. + + Special cases: + - Single signal: LLR_c = clamp(LLR_i, -2.5, 2.5) + - All-zero LLRs: cluster_llr = 0.0 + + Requirements: 4.4, 4.5 + """ + if not llrs: + return 0.0 + + # Single-signal cluster: just clamp it + if len(llrs) == 1: + return max(-_V3_CLUSTER_LLR_CLAMP, min(_V3_CLUSTER_LLR_CLAMP, llrs[0])) + + # Check for all-zero LLRs + abs_sum = sum(abs(llr) for llr in llrs) + if abs_sum == 0.0: + return 0.0 + + # weighted_mean(LLR_i, |LLR_i|) = sum(LLR_i × |LLR_i|) / sum(|LLR_i|) + weighted_sum = sum(llr * abs(llr) for llr in llrs) + weighted_mean = weighted_sum / abs_sum + + # Scale by sqrt(n_eff) and clamp + raw = weighted_mean * math.sqrt(n_eff) + return max(-_V3_CLUSTER_LLR_CLAMP, min(_V3_CLUSTER_LLR_CLAMP, raw)) + + +# --------------------------------------------------------------------------- +# v3 Multiplicative Confidence +# +# Computes a single confidence score by multiplying independent quality +# dimensions together. Each dimension can suppress the final score if it +# is weak — the multiplicative structure ensures that *any* weak link +# pulls confidence down. +# +# confidence = clamp( +# C_evidence × sqrt(C_quality) × sqrt(max(strength, 0.05)) +# × regime_confidence_mult × (1 - contradiction) × data_quality, +# 0, 1 +# ) +# +# Requirements: 8.1, 8.2, 8.3, 8.4, 8.5 +# --------------------------------------------------------------------------- + + +def compute_v3_confidence( + n_eff_total: float, + q_values: list[float], + llrs: list[float], + strength: float, + regime_confidence_mult: float, + contradiction: float, + data_quality: float, +) -> float: + """Compute multiplicative confidence for v3 pipeline. + + confidence = clamp( + C_evidence × sqrt(C_quality) × sqrt(max(strength, 0.05)) + × regime_confidence_mult × (1 - contradiction) × data_quality, + 0, 1 + ) + + C_evidence = 1 - exp(-n_eff_total / 5.0) + C_quality = weighted_mean(q_i, weight=|LLR_i|) + = sum(q_i × |LLR_i|) / sum(|LLR_i|) + → 0.0 if sum(|LLR_i|) == 0 + + Requirements: 8.1, 8.2, 8.3, 8.4, 8.5 + """ + # C_evidence: saturating evidence mass factor + c_evidence = 1.0 - math.exp(-n_eff_total / 5.0) + + # C_quality: LLR-magnitude-weighted mean of quality scores + abs_llrs = [abs(llr) for llr in llrs] + sum_abs_llrs = sum(abs_llrs) + if sum_abs_llrs == 0.0: + c_quality = 0.0 + else: + c_quality = sum(q * w for q, w in zip(q_values, abs_llrs)) / sum_abs_llrs + + # Multiplicative confidence formula + confidence = ( + c_evidence + * math.sqrt(c_quality) + * math.sqrt(max(strength, 0.05)) + * regime_confidence_mult + * (1.0 - contradiction) + * data_quality + ) + + # Clamp to [0, 1] + return max(0.0, min(1.0, confidence)) + + +# --------------------------------------------------------------------------- +# v3 Data Quality Score +# +# Multiplicative fail-closed quality score measuring the overall quality +# of the evidence base. Low data quality suppresses confidence and can +# force informational mode. +# +# data_quality = clamp( +# Q_parse × sqrt(Q_conf) × Q_fresh × Q_coverage × Q_diversity, +# 0, 1 +# ) +# +# Requirements: 17.1, 17.2, 17.3, 17.4, 17.5, 17.6 +# --------------------------------------------------------------------------- + + +def compute_v3_data_quality( + units: list[EvidenceUnit], + extraction_failure_rate: float, + age_newest_hours: float, + n_source_types: int, +) -> float: + """Compute multiplicative fail-closed data quality score. + + data_quality = clamp(Q_parse × sqrt(Q_conf) × Q_fresh × Q_coverage × Q_diversity, 0, 1) + + Components: + Q_parse = 1 - extraction_failure_rate + Q_conf = weighted_mean(extraction_conf_i, weight=impact_i) + → 0.5 if sum(impact_i) == 0 (neutral default) + Q_fresh = exp(-age_newest_hours / 168) + Q_coverage = 1 - exp(-N_valid / 5) + Q_diversity = min(1, log2(1 + N_source_types) / log2(4)) + + Requirements: 17.1, 17.2, 17.3, 17.4, 17.5, 17.6 + """ + # Q_parse: parse success rate + q_parse = 1.0 - extraction_failure_rate + + # Q_conf: impact-weighted mean of extraction confidence + sum_impact = sum(u.impact for u in units) + if sum_impact == 0.0: + q_conf = 0.5 # neutral default + else: + q_conf = sum(u.extraction_conf * u.impact for u in units) / sum_impact + + # Q_fresh: exponential freshness decay (half-life ~116h at 168 denominator) + q_fresh = math.exp(-age_newest_hours / 168.0) + + # Q_coverage: saturating coverage factor based on valid evidence count + n_valid = len(units) + q_coverage = 1.0 - math.exp(-n_valid / 5.0) + + # Q_diversity: source type diversity (saturates at 4 source types) + if n_source_types <= 0: + q_diversity = 0.0 + else: + q_diversity = min(1.0, math.log2(1.0 + n_source_types) / math.log2(4.0)) + + # Multiplicative combination + data_quality = q_parse * math.sqrt(q_conf) * q_fresh * q_coverage * q_diversity + + # Clamp to [0, 1] + return max(0.0, min(1.0, data_quality)) + + +# --------------------------------------------------------------------------- +# v3 Informational Mode Forcing +# +# Determines whether a recommendation should be forced to informational +# mode based on data quality thresholds and evidence composition. +# +# Requirements: 17.7, 17.8 +# --------------------------------------------------------------------------- + +# Layer classification constants for macro/competitive-only detection +_COMPANY_LAYER = "company" +_MACRO_LAYER = "macro" +_COMPETITIVE_LAYER = "competitive" + + +def should_force_informational_v3( + data_quality: float, + units: list[EvidenceUnit], + extraction_failure_rate: float, + macro_only_enabled: bool = False, +) -> tuple[bool, str]: + """Check if recommendation should be forced to informational mode. + + Returns (should_force, reason) tuple. + + Forcing conditions: + 1. data_quality < 0.50 + 2. N_valid < 2 (fewer than 2 valid evidence units) + 3. Q_parse < 0.50 (extraction_failure_rate > 0.50) + 4. Only macro/competitive evidence present (no company signals) + unless macro_only_enabled is True + + Requirements: 17.7, 17.8 + """ + n_valid = len(units) + + # Condition 1: Low overall data quality + if data_quality < 0.50: + return (True, "data_quality_below_threshold") + + # Condition 2: Insufficient evidence volume + if n_valid < 2: + return (True, "insufficient_evidence_count") + + # Condition 3: High extraction failure rate (Q_parse < 0.50) + q_parse = 1.0 - extraction_failure_rate + if q_parse < 0.50: + return (True, "extraction_parse_rate_below_threshold") + + # Condition 4: Only macro/competitive evidence (no company layer) + if not macro_only_enabled: + has_company = any(u.layer == _COMPANY_LAYER for u in units) + if not has_company and n_valid > 0: + return (True, "macro_competitive_only_evidence") + + return (False, "") + + # --------------------------------------------------------------------------- # Fetch competitive signals targeting a ticker within a time window # --------------------------------------------------------------------------- @@ -1573,6 +2503,89 @@ async def aggregate_company( if reference_time is None: reference_time = datetime.now(timezone.utc) + # --- v3 engine feature flag (Requirements 19.1–19.6) --- + # Read v3_engine_enabled once per aggregation cycle. When True, route + # to the v3 calibrated evidence pipeline. Default to heuristic on any + # read failure (fail-safe). + v3_enabled = await _read_v3_flag(pool) + + if v3_enabled: + # Attempt the v3 pipeline with try/except fallback (Req 19.4) + try: + summaries = await _aggregate_company_v3( + pool, ticker, reference_time, cfg, + ) + logger.info( + "Aggregation cycle for %s completed via v3 pipeline", + ticker, + ) + return summaries + except Exception: + logger.error( + "v3 pipeline failed for %s, falling back to heuristic", + ticker, + exc_info=True, + ) + # Fall through to heuristic pipeline below + + # --- Heuristic pipeline (existing behavior) --- + return await _aggregate_company_heuristic(pool, ticker, reference_time, cfg) + + +async def _aggregate_company_v3( + pool: asyncpg.Pool, + ticker: str, + reference_time: datetime, + config: AggregationConfig, +) -> list[TrendSummary]: + """Run the v3 calibrated evidence pipeline for all windows. + + Routes each window through _run_v3_pipeline and annotates output + metadata with pipeline_mode="v3". + + Requirements: 19.2, 19.5 + """ + cfg = config + summaries: list[TrendSummary] = [] + for window in cfg.effective_windows(): + summary = await _run_v3_pipeline( + pool, ticker, window, reference_time, cfg, + ) + # Annotate output with pipeline_mode (Req 19.5) + _annotate_pipeline_mode(summary, "v3") + summaries.append(summary) + return summaries + + +def _annotate_pipeline_mode(summary: TrendSummary, mode: str) -> None: + """Store pipeline_mode in the TrendSummary market_context JSONB. + + Requirements: 19.5 + """ + ctx = summary.market_context + if isinstance(ctx, dict): + ctx["pipeline_mode"] = mode + elif ctx is not None and hasattr(ctx, "model_dump"): + ctx_dict = ctx.model_dump() + ctx_dict["pipeline_mode"] = mode + summary.market_context = ctx_dict + else: + summary.market_context = {"pipeline_mode": mode} + + +async def _aggregate_company_heuristic( + pool: asyncpg.Pool, + ticker: str, + reference_time: datetime, + config: AggregationConfig, +) -> list[TrendSummary]: + """Run the existing heuristic/probabilistic pipeline for all windows. + + This preserves the exact existing behavior. The pipeline_mode field + is set to "heuristic" in output metadata (Req 19.5). + """ + cfg = config + # Read probabilistic scoring flag once per cycle (Requirement 16.7). # Mid-cycle changes take effect on the next cycle. probabilistic = await fetch_probabilistic_scoring_enabled(pool) @@ -1686,6 +2699,9 @@ async def aggregate_company( else: summary.market_context = {"quality_gate_passed": False} + # Annotate output with pipeline_mode (Req 19.5) + _annotate_pipeline_mode(summary, "heuristic") + summaries.append(summary) return summaries diff --git a/services/api/app.py b/services/api/app.py index 8f7c757..7da4343 100644 --- a/services/api/app.py +++ b/services/api/app.py @@ -456,7 +456,7 @@ async def list_trend_history( dominant_catalysts, material_risks, generated_at FROM trend_history {where} - ORDER BY generated_at ASC + ORDER BY generated_at DESC LIMIT ${idx}""", *params, limit, ) @@ -470,6 +470,9 @@ async def list_trend_history( d["dominant_catalysts"] = _parse_jsonb(d.get("dominant_catalysts")) d["material_risks"] = _parse_jsonb(d.get("material_risks")) results.append(d) + # Return in ascending order for chart rendering (query fetches newest first + # so the LIMIT captures recent data relevant to short time windows). + results.reverse() return results @@ -496,7 +499,7 @@ async def get_market_prices( (data->>'t')::bigint AS bar_timestamp FROM market_snapshots WHERE ticker = $1 AND snapshot_type = 'bar' - ORDER BY captured_at ASC + ORDER BY captured_at DESC LIMIT $2""", ticker, limit, ) @@ -521,6 +524,9 @@ async def get_market_prices( "bar_timestamp": bar_ts, "captured_at": r["captured_at"].isoformat() if r["captured_at"] else None, }) + # Reverse to ascending order for chart rendering (query fetches newest first + # so the LIMIT captures recent data relevant to short time windows). + results.reverse() # Compute 90-day high/low from all bars in the window cutoff_90d = datetime.now(timezone.utc) - timedelta(days=90) diff --git a/services/recommendation/eligibility.py b/services/recommendation/eligibility.py index 72fccb8..766e660 100644 --- a/services/recommendation/eligibility.py +++ b/services/recommendation/eligibility.py @@ -10,6 +10,10 @@ All decisions are rule-based with no model involvement. The LLM is only used downstream for optional thesis wording (a separate task). Requirements: 7.1, 7.2, 7.3, 7.4, 14.1, 14.2, 14.3, 14.4, 14.5, 14.6 + +v3 additions: +- ReturnDistribution and EV gate (Requirement 12) +- Regime-aware eligibility and mode escalation (Requirements 13.1–13.5) """ from __future__ import annotations @@ -464,3 +468,290 @@ def evaluate_eligibility( p_bull=p_bull if probabilistic else None, pipeline_mode="probabilistic" if probabilistic else "heuristic", ) + + +# --------------------------------------------------------------------------- +# V3 Return Distribution and EV Gate (Requirements 12.1–12.7) +# --------------------------------------------------------------------------- + +_V3_MIN_EDGE: dict[str, float] = { + "panic": 0.0100, + "trend_following": 0.0035, + "mean_reversion": 0.0050, + "uncertainty": 0.0075, +} + +_V3_ELIGIBILITY_THRESHOLDS: dict[str, tuple[float, float, float]] = { + # (confidence_min, contradiction_max, strength_min) + "panic": (0.70, 0.25, 0.36), + "trend_following": (0.55, 0.40, 0.20), + "mean_reversion": (0.60, 0.35, 0.26), + "uncertainty": (0.65, 0.30, 0.30), +} + + +@dataclass(frozen=True) +class ReturnDistribution: + """V3 return distribution model output. + + Encapsulates the horizon-scaled volatility, expected return, risk-adjusted + expected value, regime minimum edge, and final eligibility decision. + + Requirements: 12.1, 12.2, 12.3, 12.4, 12.5, 12.6, 12.7 + """ + + sigma_h: float # realized_vol_20d * sqrt(horizon_days / 252) + mu_h: float # tanh(A_projected / 3.0) * confidence * sigma_h + ev_long: float # mu_h - costs - 0.10 * CVaR_5 + min_edge: float # regime-specific minimum edge + eligible: bool + + +def compute_return_distribution( + a_projected: float, + confidence: float, + realized_vol_20d: float, + horizon_days: int, + costs: float, + regime: str, + *, + confidence_actual: float = 1.0, + contradiction: float = 0.0, + n_eff_total: float = 0.0, + data_quality: float = 1.0, +) -> ReturnDistribution: + """Compute the v3 return distribution and EV gate eligibility. + + Implements the return distribution model from the v3 math spec: + - sigma_h: horizon-scaled volatility + - mu_h: expected return using tanh-compressed projected alpha + - CVaR_5: Gaussian approximation of 5th percentile tail loss + - EV_long: risk-adjusted expected value after costs and tail risk + + Eligibility requires: + - EV_long > regime min_edge + - EV_long > max(0.0025, 0.25 * costs) + - confidence_actual >= regime confidence_min + - contradiction <= regime contradiction_max + - n_eff_total >= 2.0 + - data_quality >= 0.50 + + Args: + a_projected: Projected evidence state A_projected_h from projection. + confidence: Multiplicative confidence from the v3 pipeline. + realized_vol_20d: 20-day realized annualized volatility. + If <= 0 or unavailable, defaults to 0.25. + horizon_days: Trading days for the horizon (1, 7, 30, or 90). + costs: Total costs (spread + slippage + commission). + regime: Market regime string (panic, trend_following, mean_reversion, uncertainty). + confidence_actual: The raw confidence value for threshold checks (defaults to + same as confidence if not separately provided). + contradiction: Contradiction score in [0, 1]. + n_eff_total: Total effective evidence count across clusters. + data_quality: Data quality score in [0, 1]. + + Returns: + ReturnDistribution with computed fields and eligibility decision. + + Requirements: 12.1, 12.2, 12.3, 12.4, 12.5, 12.6, 12.7 + """ + # Default volatility when unavailable (Req 12.7) + if realized_vol_20d is None or realized_vol_20d <= 0: # type: ignore[redundant-expr] + realized_vol_20d = 0.25 + + # Req 12.1: sigma_h = realized_vol_20d * sqrt(horizon_days / 252) + sigma_h = realized_vol_20d * math.sqrt(horizon_days / 252.0) + + # Req 12.2: mu_h = tanh(A_projected / 3.0) * confidence * sigma_h + mu_h = math.tanh(a_projected / 3.0) * confidence * sigma_h + + # Req 12.3: CVaR_5 = sigma_h * 1.645 * 1.4 + cvar_5 = sigma_h * 1.645 * 1.4 + + # Req 12.3: EV_long = mu_h - costs - 0.10 * CVaR_5 + ev_long = mu_h - costs - 0.10 * cvar_5 + + # Req 12.4: regime-specific min_edge + min_edge = _V3_MIN_EDGE.get(regime, _V3_MIN_EDGE["uncertainty"]) + + # Req 12.5: EV_long > min_edge AND EV_long > max(0.0025, 0.25 * costs) + ev_gate_passed = ev_long > min_edge and ev_long > max(0.0025, 0.25 * costs) + + # Req 12.6: Additional eligibility checks + thresholds = _V3_ELIGIBILITY_THRESHOLDS.get( + regime, _V3_ELIGIBILITY_THRESHOLDS["uncertainty"] + ) + confidence_min, contradiction_max, _strength_min = thresholds + + quality_gate_passed = ( + confidence_actual >= confidence_min + and contradiction <= contradiction_max + and n_eff_total >= 2.0 + and data_quality >= 0.50 + ) + + eligible = ev_gate_passed and quality_gate_passed + + return ReturnDistribution( + sigma_h=sigma_h, + mu_h=mu_h, + ev_long=ev_long, + min_edge=min_edge, + eligible=eligible, + ) + + +# =========================================================================== +# v3 Regime-Aware Eligibility and Mode Escalation (Requirements 13.1–13.5) +# =========================================================================== + +# Direction thresholds for action mapping (from bayesian.py) +_V3_DIRECTION_THRESHOLDS: dict[str, tuple[float, float]] = { + "panic": (0.68, 0.32), + "trend_following": (0.60, 0.40), + "mean_reversion": (0.63, 0.37), + "uncertainty": (0.65, 0.35), +} + + +@dataclass(frozen=True) +class V3Eligibility: + """Result of v3 regime-aware eligibility evaluation. + + Requirements: 13.1–13.5 + """ + + action: str # "BUY", "SELL", "HOLD", "WATCH" + mode: str # "live", "paper", "informational" + eligible: bool # meets regime thresholds + reasons: list[str] # reasons for non-eligibility or downgrade + + +def compute_v3_eligibility( + p_up: float, + ev_long: float, + min_edge: float, + confidence: float, + contradiction: float, + strength: float, + n_eff_total: float, + data_quality: float, + regime: str, + has_existing_position: bool = False, + risk_engine_passed: bool = True, +) -> V3Eligibility: + """Compute regime-aware eligibility and mode escalation. + + Requirements: 13.1–13.5 + + Steps: + 1. Check regime-specific eligibility gates (confidence, contradiction, strength) + 2. Determine action (BUY/SELL/HOLD/WATCH) based on P_up and EV + 3. Escalate mode (live/paper/informational) based on signal quality + + Args: + p_up: Posterior probability of upward move from Bayesian posterior. + ev_long: Expected value from return distribution. + min_edge: Regime-specific minimum edge from EV gate. + confidence: Multiplicative confidence score in [0, 1]. + contradiction: LLR entropy contradiction in [0, 1]. + strength: Signal strength = abs(2 * P_up - 1). + n_eff_total: Effective evidence count across all clusters. + data_quality: Data quality score in [0, 1]. + regime: Current market regime string. + has_existing_position: Whether the entity already has an open position. + risk_engine_passed: Whether the risk engine approved the trade. + + Returns: + V3Eligibility with action, mode, eligible flag, and reasons. + """ + reasons: list[str] = [] + + # --- 1. Regime-specific eligibility gates (Requirement 13.1) --- + conf_min, contra_max, str_min = _V3_ELIGIBILITY_THRESHOLDS.get( + regime, (0.65, 0.30, 0.30) # default to uncertainty thresholds + ) + + eligible = True + + if confidence < conf_min: + eligible = False + reasons.append(f"confidence {confidence:.3f} < regime min {conf_min:.2f}") + + if contradiction > contra_max: + eligible = False + reasons.append( + f"contradiction {contradiction:.3f} > regime max {contra_max:.2f}" + ) + + if strength < str_min: + eligible = False + reasons.append(f"strength {strength:.3f} < regime min {str_min:.2f}") + + if data_quality < 0.50: + eligible = False + reasons.append(f"data_quality {data_quality:.3f} < 0.50") + + if n_eff_total < 2.0: + eligible = False + reasons.append(f"n_eff_total {n_eff_total:.2f} < 2.0") + + # --- 2. Action mapping (Requirement 13.2) --- + bull_thresh, _bear_thresh = _V3_DIRECTION_THRESHOLDS.get( + regime, (0.65, 0.35) + ) + + if not eligible: + action = "WATCH" + elif p_up >= bull_thresh and ev_long > min_edge: + action = "BUY" + elif has_existing_position and ev_long <= 0: + # SELL when existing position and exit EV > hold EV + # Simplified: EV_exit > EV_hold approximated as ev_long <= 0 + # (holding has negative expected value → better to exit) + action = "SELL" + elif has_existing_position: + action = "HOLD" + else: + action = "WATCH" + + # --- 3. Mode escalation (Requirements 13.3, 13.4, 13.5) --- + if action in ("BUY", "SELL"): + # Check live eligibility (Requirement 13.3) + if ( + confidence >= 0.75 + and contradiction <= 0.20 + and n_eff_total >= 5 + and ev_long > 2 * min_edge + and risk_engine_passed + ): + mode = "live" + # Check paper eligibility (Requirement 13.4) + elif ( + confidence >= 0.60 + and ev_long > min_edge + and risk_engine_passed + ): + mode = "paper" + else: + mode = "informational" + if confidence < 0.60: + reasons.append( + f"paper requires confidence >= 0.60, got {confidence:.3f}" + ) + if ev_long <= min_edge: + reasons.append( + f"paper requires EV > min_edge ({min_edge:.4f}), got {ev_long:.4f}" + ) + if not risk_engine_passed: + reasons.append("risk engine did not pass") + else: + # HOLD and WATCH are always informational (Requirement 13.5) + mode = "informational" + + return V3Eligibility( + action=action, + mode=mode, + eligible=eligible, + reasons=reasons, + ) diff --git a/services/risk/engine.py b/services/risk/engine.py index a602d30..afcec2a 100644 --- a/services/risk/engine.py +++ b/services/risk/engine.py @@ -14,6 +14,7 @@ from __future__ import annotations import math import uuid +from dataclasses import dataclass from datetime import datetime, timedelta, timezone from enum import Enum from typing import Any @@ -702,3 +703,163 @@ def evaluate_order( state_snapshot=state, evaluated_at=now, ) + + +# =========================================================================== +# v3 Stop-Defined Portfolio Heat (Requirements 15.1–15.5) +# =========================================================================== + + +def compute_portfolio_heat( + positions: list[dict[str, float]], + stop_distances: dict[str, float], +) -> float: + """Compute total portfolio heat from stop-defined risk dollars. + + risk_dollars = position_value × stop_distance_pct for each position. + portfolio_heat = sum of all risk_dollars. + + positions: list of dicts with keys "ticker" and "position_value" + stop_distances: dict mapping ticker to stop_distance_pct + + Requirements: 15.1, 15.2 + """ + total_heat = 0.0 + for pos in positions: + ticker = pos.get("ticker", "") + position_value = pos.get("position_value", 0.0) + stop_distance_pct = stop_distances.get(ticker, 0.0) + risk_dollars = position_value * stop_distance_pct + total_heat += risk_dollars + return total_heat + + +def check_heat_capacity( + current_heat: float, + new_risk_dollars: float, + max_heat_pct: float, + portfolio_value: float, +) -> bool: + """Check if a new position would exceed heat capacity. + + Returns True if the new entry is allowed (capacity exists). + Returns False if it would exceed max_heat_pct × portfolio_value. + + Requirements: 15.3, 15.4, 15.5 + """ + max_heat_dollars = max_heat_pct * portfolio_value + return (current_heat + new_risk_dollars) <= max_heat_dollars + + +def compute_available_heat_capacity( + current_heat: float, + max_heat_pct: float, + portfolio_value: float, +) -> float: + """Compute available heat capacity for new positions. + + available = max_heat_pct × portfolio_value - current_heat + Returns max(0, available). + + Requirement: 15.4 + """ + max_heat_dollars = max_heat_pct * portfolio_value + available = max_heat_dollars - current_heat + return max(0.0, available) + + +def compute_heat_capacity_pct( + current_heat: float, + max_heat_pct: float, + portfolio_value: float, +) -> float: + """Compute available heat capacity as a portfolio percentage for Kelly sizing. + + This converts absolute available heat dollars into a fraction of portfolio + value, suitable for use as `heat_capacity` in the Kelly sizing pipeline's + `available_caps` dict. + + Requirements: 15.4, 15.5 + """ + if portfolio_value <= 0.0: + return 0.0 + available_dollars = compute_available_heat_capacity( + current_heat, max_heat_pct, portfolio_value + ) + return available_dollars / portfolio_value + + +# --------------------------------------------------------------------------- +# v3 Risk Tier Auto-Adjustment (Requirements 18.1–18.6) +# --------------------------------------------------------------------------- + + + +@dataclass(frozen=True) +class TierMetrics: + """30-day rolling performance metrics for tier adjustment. + + Collected once per calendar day after session close. + + Requirements: 18.1 + """ + + profit_factor_30d: float + """Gross profit / gross loss over last 30 days.""" + + max_drawdown_30d: float + """Largest peak-to-trough as fraction over last 30 days.""" + + calibration_error: float + """Mean |predicted P_up - realized outcome| over last 30 days.""" + + realized_sharpe_30d: float + """Annualized Sharpe ratio of daily returns over last 30 days.""" + + n_trades_30d: int + """Number of trades executed in last 30 days.""" + + reserve_pool_pct: float + """Reserve pool as fraction of total portfolio value.""" + + +def evaluate_tier_adjustment(metrics: TierMetrics) -> str: + """Evaluate whether to upgrade, downgrade, or hold current risk tier. + + Decision logic: + - Downgrade if ANY of: profit_factor < 1.0 OR max_drawdown > 0.12 OR + calibration_error > 0.20 OR realized_sharpe < 0 + - Upgrade only if ALL of: profit_factor > 1.35 AND max_drawdown < 0.05 AND + calibration_error < 0.12 AND reserve_pool_pct > 0.20 AND n_trades >= 20 + - Otherwise: hold + + Downgrade is applied immediately; 7-day upgrade cooldown is enforced + at the caller level (not in this function). Evaluation runs once per + calendar day after session close. + + Returns: + 'upgrade' | 'downgrade' | 'hold' + + Requirements: 18.2, 18.3, 18.4, 18.5, 18.6 + """ + # --- Downgrade: any single condition triggers --- + if ( + metrics.profit_factor_30d < 1.0 + or metrics.max_drawdown_30d > 0.12 + or metrics.calibration_error > 0.20 + or metrics.realized_sharpe_30d < 0 + ): + return "downgrade" + + # --- Upgrade: all conditions must be satisfied --- + if ( + metrics.profit_factor_30d > 1.35 + and metrics.max_drawdown_30d < 0.05 + and metrics.calibration_error < 0.12 + and metrics.reserve_pool_pct > 0.20 + and metrics.n_trades_30d >= 20 + ): + return "upgrade" + + # --- Hold: neither downgrade nor upgrade criteria met --- + return "hold" diff --git a/services/trading/position_sizer.py b/services/trading/position_sizer.py index 9381b10..c132b14 100644 --- a/services/trading/position_sizer.py +++ b/services/trading/position_sizer.py @@ -4,11 +4,14 @@ Computes dollar allocation and share quantity for a trade by applying a sequential adjustment pipeline: confidence gate, correlation reduction, sector exposure, diversification bonus, earnings proximity, portfolio heat check, active-pool minimum, absolute cap, and share rounding. + +Also provides v3 fractional Kelly position sizing (Requirements 14.1–14.7). """ from __future__ import annotations import math +from dataclasses import dataclass from datetime import datetime, timezone from services.trading.models import ( @@ -345,3 +348,111 @@ class PositionSizer: return new_dollar, new_pct return dollar_amount, allocation_pct + + + +# =========================================================================== +# v3 Fractional Kelly Position Sizing (Requirements 14.1–14.7) +# =========================================================================== + + +@dataclass(frozen=True) +class KellySizingResult: + """Result of v3 fractional Kelly position sizing computation.""" + + portfolio_pct: float + f_kelly: float + reward_ratio: float + downgrade: bool + downgrade_reason: str + + +def _clamp(value: float, lo: float, hi: float) -> float: + """Clamp value to [lo, hi].""" + return max(lo, min(hi, value)) + + +def compute_reward_ratio( + confidence: float, strength: float, contradiction: float +) -> float: + """Compute reward ratio b = clamp(1.2 + 2.0*confidence + 1.0*strength - contradiction, 1.2, 3.0). + + Requirements: 14.2 + """ + raw = 1.2 + 2.0 * confidence + 1.0 * strength - contradiction + return _clamp(raw, 1.2, 3.0) + + +def compute_kelly_sizing( + p_win: float, + b: float, + confidence: float, + data_quality: float, + contradiction: float, + max_position_pct: float, + available_caps: dict[str, float], +) -> KellySizingResult: + """Compute fractional Kelly position sizing. + + f_kelly = (p_win * b - (1 - p_win)) / b + portfolio_pct = clamp(max(0, f_kelly) * 0.25 * confidence * data_quality * (1 - contradiction), 0, max_position_pct) + + Apply min of all capacity constraints from available_caps: + - sector_capacity + - correlation_capacity (0 if avg corr > 0.80) + - heat_capacity + + Downgrade rules: + - If f_kelly <= 0 → portfolio_pct = 0, downgrade with reason "negative_edge" + - If portfolio_pct < 0.005 → downgrade with reason "position_below_minimum" + + Requirements: 14.1, 14.2, 14.3, 14.4, 14.5, 14.6, 14.7 + """ + # Compute Kelly fraction (Req 14.3) + f_kelly = (p_win * b - (1.0 - p_win)) / b + + # Negative edge → immediate downgrade (Req 14.7) + if f_kelly <= 0.0: + return KellySizingResult( + portfolio_pct=0.0, + f_kelly=f_kelly, + reward_ratio=b, + downgrade=True, + downgrade_reason="negative_edge", + ) + + # Apply fractional Kelly with dampening factors (Req 14.4) + raw_pct = f_kelly * 0.25 * confidence * data_quality * (1.0 - contradiction) + portfolio_pct = _clamp(max(0.0, raw_pct), 0.0, max_position_pct) + + # Apply capacity constraints (Req 14.5) + sector_capacity = available_caps.get("sector_capacity", max_position_pct) + correlation_capacity = available_caps.get("correlation_capacity", max_position_pct) + heat_capacity = available_caps.get("heat_capacity", max_position_pct) + + # Correlation capacity of 0 means avg corr > 0.80 → force zero + portfolio_pct = min( + portfolio_pct, + max_position_pct, + sector_capacity, + correlation_capacity, + heat_capacity, + ) + + # Position below minimum threshold → downgrade (Req 14.6) + if portfolio_pct < 0.005: + return KellySizingResult( + portfolio_pct=0.0, + f_kelly=f_kelly, + reward_ratio=b, + downgrade=True, + downgrade_reason="position_below_minimum", + ) + + return KellySizingResult( + portfolio_pct=portfolio_pct, + f_kelly=f_kelly, + reward_ratio=b, + downgrade=False, + downgrade_reason="", + ) diff --git a/services/trading/stop_loss_manager.py b/services/trading/stop_loss_manager.py index c9af933..108a6b8 100644 --- a/services/trading/stop_loss_manager.py +++ b/services/trading/stop_loss_manager.py @@ -5,12 +5,15 @@ re-evaluates levels when volatility or market conditions change, detects price crossings that should trigger exits, and tightens stops under high-heat or high-severity-event conditions. +Also provides v3 regime-aware stop loss and take profit (Requirements 16.1–16.5). + All public methods are synchronous (pure computation, no DB access). Persistence is handled by the caller (engine.py). """ from __future__ import annotations +from dataclasses import dataclass from datetime import datetime, timezone from services.trading.models import ( @@ -20,6 +23,100 @@ from services.trading.models import ( StopTrigger, ) +# --------------------------------------------------------------------------- +# v3 Regime-Aware Stop Loss and Take Profit (Requirements 16.1–16.5) +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class V3StopLevels: + """v3 stop-loss and take-profit levels computed from regime-aware volatility.""" + + stop_loss: float + take_profit: float + stop_distance_pct: float + reward_ratio: float + + +@dataclass(frozen=True) +class TrailingStopResult: + """Result of trailing stop computation.""" + + trailing_stop: float + activated: bool + + +def compute_v3_stops( + entry_price: float, + atr_pct: float, + regime_atr_mult: float, + sigma_h: float, + reward_ratio: float, +) -> V3StopLevels: + """Compute regime-aware stop loss and take profit. + + stop_distance_pct = max(ATR_pct × regime_ATR_mult, sigma_h × 1.25, 0.005) + stop_loss = entry_price × (1 - stop_distance_pct) + take_profit = entry_price × (1 + b × stop_distance_pct) + + Requirements: 16.1, 16.2, 16.3 + """ + # Requirement 16.1: stop distance from regime-aware volatility + z_stop = 1.25 + min_stop_pct = 0.005 + stop_distance_pct = max(atr_pct * regime_atr_mult, sigma_h * z_stop, min_stop_pct) + + # Requirement 16.2: stop loss for long position + stop_loss = entry_price * (1.0 - stop_distance_pct) + + # Requirement 16.3: take profit using dynamic reward ratio + take_profit = entry_price * (1.0 + reward_ratio * stop_distance_pct) + + return V3StopLevels( + stop_loss=stop_loss, + take_profit=take_profit, + stop_distance_pct=stop_distance_pct, + reward_ratio=reward_ratio, + ) + + +def compute_trailing_stop( + existing_stop: float, + current_price: float, + entry_price: float, + take_profit: float, + atr_pct: float, + trailing_atr_mult: float, + sigma_h: float, +) -> TrailingStopResult: + """Compute trailing stop level. + + Activated when unrealized_gain >= 0.50 × TP distance. + trailing_stop = max(existing_stop, current_price × (1 - trailing_distance_pct)) + trailing_distance_pct = max(ATR_pct × trailing_ATR_mult, sigma_h × 0.75) + + Trailing stop is monotonically non-decreasing. + + Requirements: 16.4, 16.5 + """ + # Requirement 16.4: activation check + take_profit_distance = take_profit - entry_price + unrealized_gain = current_price - entry_price + + # Activation threshold: gain >= 50% of TP distance + if take_profit_distance <= 0 or unrealized_gain < 0.50 * take_profit_distance: + # Not activated — return existing stop unchanged + return TrailingStopResult(trailing_stop=existing_stop, activated=False) + + # Requirement 16.5: compute trailing stop + trailing_distance_pct = max(atr_pct * trailing_atr_mult, sigma_h * 0.75) + candidate_stop = current_price * (1.0 - trailing_distance_pct) + + # Monotonically non-decreasing: never lower than existing stop + trailing_stop = max(existing_stop, candidate_stop) + + return TrailingStopResult(trailing_stop=trailing_stop, activated=True) + class StopLossManager: """Compute and maintain dynamic stop-loss / take-profit levels.""" diff --git a/tests/test_pbt_v3_clustering.py b/tests/test_pbt_v3_clustering.py new file mode 100644 index 0000000..83692f3 --- /dev/null +++ b/tests/test_pbt_v3_clustering.py @@ -0,0 +1,168 @@ +"""Property-based tests for v3 correlation-aware clustering. + +Feature: math-core-v3-engine + +Uses Hypothesis to validate correctness properties of the v3 clustering +layer: effective evidence count n_eff is bounded by cluster size, and +cluster LLR is clamped to [-2.5, 2.5]. + +Validates: Requirements 4.2, 4.3, 4.4, 4.5, 21.4 +""" +from __future__ import annotations + +import math + +from hypothesis import given, settings +from hypothesis import strategies as st + +from services.aggregation.worker import ( + EvidenceCluster, + cluster_evidence, + compute_cluster_llr, + compute_n_eff, +) +from services.aggregation.scoring import EvidenceUnit + +# --------------------------------------------------------------------------- +# Hypothesis strategies +# --------------------------------------------------------------------------- + +# LLR values for testing +llr_values = st.floats(min_value=-5.0, max_value=5.0, allow_nan=False, allow_infinity=False) +llr_lists = st.lists(llr_values, min_size=1, max_size=20) + +# Correlation values +rho_values = st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False) + + +def _symmetric_correlation_matrix(n: int) -> st.SearchStrategy[list[list[float]]]: + """Generate an NxN symmetric correlation matrix with values in [0, 1]. + + Diagonal is 1.0, off-diagonal entries are symmetric rho in [0, 1]. + """ + if n <= 1: + return st.just([[1.0]]) + + # Generate upper-triangle entries (n*(n-1)/2 values) + n_pairs = n * (n - 1) // 2 + upper_triangle = st.lists(rho_values, min_size=n_pairs, max_size=n_pairs) + + @st.composite + def build_matrix(draw: st.DrawFn) -> list[list[float]]: + entries = draw(upper_triangle) + matrix = [[0.0] * n for _ in range(n)] + idx = 0 + for i in range(n): + matrix[i][i] = 1.0 + for j in range(i + 1, n): + matrix[i][j] = entries[idx] + matrix[j][i] = entries[idx] + idx += 1 + return matrix + + return build_matrix() + + +# n_eff positive floats for property 6 +n_eff_values = st.floats(min_value=0.1, max_value=20.0, allow_nan=False, allow_infinity=False) + + +# --------------------------------------------------------------------------- +# Property 5: Effective evidence count n_eff is bounded by cluster size +# Feature: math-core-v3-engine, Property 5: Effective evidence count n_eff is bounded by cluster size +# Validates: Requirements 4.2, 4.3, 21.4 +# --------------------------------------------------------------------------- + + +@settings(max_examples=100) +@given(llrs=llr_lists) +def test_property_5_n_eff_bounded_by_cluster_size_default_correlations( + llrs: list[float], +) -> None: + """Property 5: n_eff is bounded by cluster size (default correlations). + + For any cluster of N signals using default pairwise correlations (rho=0.80), + the computed n_eff SHALL satisfy 0 < n_eff <= N. + + **Validates: Requirements 4.2, 4.3** + """ + n = len(llrs) + n_eff = compute_n_eff(llrs) + + assert n_eff > 0.0, f"n_eff={n_eff} must be positive" + assert n_eff <= n + 1e-9, f"n_eff={n_eff} exceeds cluster size N={n}" + + +@settings(max_examples=100) +@given( + data=st.data(), + llrs=st.lists(llr_values, min_size=2, max_size=10), +) +def test_property_5_n_eff_bounded_with_explicit_correlations( + data: st.DataObject, + llrs: list[float], +) -> None: + """Property 5: n_eff is bounded by cluster size (explicit correlation matrix). + + For any cluster of N signals with a symmetric NxN correlation matrix + with values in [0, 1], the computed n_eff SHALL satisfy 0 < n_eff <= N. + + **Validates: Requirements 4.2, 4.3, 21.4** + """ + n = len(llrs) + corr_matrix = data.draw(_symmetric_correlation_matrix(n)) + + n_eff = compute_n_eff(llrs, correlations=corr_matrix) + + assert n_eff > 0.0, f"n_eff={n_eff} must be positive" + assert n_eff <= n + 1e-9, f"n_eff={n_eff} exceeds cluster size N={n}" + + +# --------------------------------------------------------------------------- +# Property 6: Cluster LLR is clamped to [-2.5, 2.5] +# Feature: math-core-v3-engine, Property 6: Cluster LLR is clamped to [-2.5, 2.5] +# Validates: Requirements 4.4, 4.5 +# --------------------------------------------------------------------------- + + +@settings(max_examples=100) +@given( + llrs=llr_lists, + n_eff=n_eff_values, +) +def test_property_6_cluster_llr_clamped( + llrs: list[float], + n_eff: float, +) -> None: + """Property 6: Cluster LLR is clamped to [-2.5, 2.5]. + + For any cluster configuration with any number of signals and any LLR values, + the computed cluster LLR_c SHALL be in [-2.5, 2.5]. + + **Validates: Requirements 4.4, 4.5** + """ + cluster_llr = compute_cluster_llr(llrs, n_eff) + + assert -2.5 <= cluster_llr <= 2.5, ( + f"cluster_llr={cluster_llr} out of [-2.5, 2.5]" + ) + + +@settings(max_examples=100) +@given(llrs=llr_lists) +def test_property_6_cluster_llr_clamped_with_computed_n_eff( + llrs: list[float], +) -> None: + """Property 6: Cluster LLR is clamped when using computed n_eff. + + End-to-end: compute n_eff from the LLRs, then compute cluster LLR. + The result SHALL still be in [-2.5, 2.5]. + + **Validates: Requirements 4.4, 4.5** + """ + n_eff = compute_n_eff(llrs) + cluster_llr = compute_cluster_llr(llrs, n_eff) + + assert -2.5 <= cluster_llr <= 2.5, ( + f"cluster_llr={cluster_llr} out of [-2.5, 2.5]" + ) diff --git a/tests/test_pbt_v3_decision.py b/tests/test_pbt_v3_decision.py new file mode 100644 index 0000000..a82d59b --- /dev/null +++ b/tests/test_pbt_v3_decision.py @@ -0,0 +1,454 @@ +"""Property-based tests for v3 Kelly sizing and regime-aware stops. + +Validates: +- Property 11: Fractional Kelly sizing is bounded and respects negative edge +- Property 18: Stop loss is below entry price and take profit is above +- Property 19: Trailing stop never decreases + +Requirements: 14.4, 14.7, 16.2, 16.3, 16.5, 21.8, 21.9 +""" +from __future__ import annotations + +from hypothesis import given, settings +from hypothesis import strategies as st + +from services.trading.position_sizer import ( + KellySizingResult, + compute_kelly_sizing, + compute_reward_ratio, +) +from services.trading.stop_loss_manager import ( + TrailingStopResult, + V3StopLevels, + compute_trailing_stop, + compute_v3_stops, +) + +# --------------------------------------------------------------------------- +# Strategies +# --------------------------------------------------------------------------- + +# Kelly sizing inputs +p_up_values = st.floats(min_value=0.01, max_value=0.99, allow_nan=False, allow_infinity=False) +b_values = st.floats(min_value=1.2, max_value=3.0, allow_nan=False, allow_infinity=False) +confidence_values = st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False) +data_quality_values = st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False) +contradiction_values = st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False) +max_position_pct_values = st.floats(min_value=0.001, max_value=0.50, allow_nan=False, allow_infinity=False) + +# Stop loss inputs +entry_price_values = st.floats(min_value=0.01, max_value=100000.0, allow_nan=False, allow_infinity=False) +stop_distance_pct_values = st.floats(min_value=0.005, max_value=0.999, allow_nan=False, allow_infinity=False) +reward_ratio_values = st.floats(min_value=1.2, max_value=5.0, allow_nan=False, allow_infinity=False) + +# Trailing stop price sequences +price_values = st.floats(min_value=1.0, max_value=10000.0, allow_nan=False, allow_infinity=False) +atr_pct_values = st.floats(min_value=0.005, max_value=0.20, allow_nan=False, allow_infinity=False) +trailing_atr_mult_values = st.floats(min_value=0.5, max_value=3.0, allow_nan=False, allow_infinity=False) +sigma_h_values = st.floats(min_value=0.005, max_value=0.50, allow_nan=False, allow_infinity=False) + + +# --------------------------------------------------------------------------- +# Feature: math-core-v3-engine, Property 11: Fractional Kelly sizing is +# bounded and respects negative edge +# --------------------------------------------------------------------------- +# **Validates: Requirements 14.4, 14.7, 21.8, 21.9** + + +@settings(max_examples=100) +@given( + p_up=p_up_values, + b=b_values, + confidence=confidence_values, + data_quality=data_quality_values, + contradiction=contradiction_values, + max_position_pct=max_position_pct_values, +) +def test_property_11_kelly_sizing_bounded_and_respects_negative_edge( + p_up: float, + b: float, + confidence: float, + data_quality: float, + contradiction: float, + max_position_pct: float, +) -> None: + """Property 11: Fractional Kelly sizing is bounded and respects negative edge. + + For any valid inputs (P_up in (0,1), b in [1.2, 3.0], confidence in [0,1], + data_quality in [0,1], contradiction in [0,1], max_position_pct > 0), + the computed portfolio_pct SHALL be in [0, max_position_pct]. + When f_kelly = (P_up * b - (1 - P_up)) / b <= 0, portfolio_pct SHALL be exactly 0. + """ + # Use generous capacity caps that don't constrain + available_caps = { + "sector_capacity": max_position_pct, + "correlation_capacity": max_position_pct, + "heat_capacity": max_position_pct, + } + + result = compute_kelly_sizing( + p_win=p_up, + b=b, + confidence=confidence, + data_quality=data_quality, + contradiction=contradiction, + max_position_pct=max_position_pct, + available_caps=available_caps, + ) + + # portfolio_pct must be in [0, max_position_pct] + assert 0.0 <= result.portfolio_pct <= max_position_pct, ( + f"portfolio_pct={result.portfolio_pct} not in [0, {max_position_pct}] " + f"for p_up={p_up}, b={b}, conf={confidence}, dq={data_quality}, " + f"contra={contradiction}" + ) + + # When f_kelly <= 0, portfolio_pct must be exactly 0 + f_kelly = (p_up * b - (1.0 - p_up)) / b + if f_kelly <= 0: + assert result.portfolio_pct == 0.0, ( + f"portfolio_pct={result.portfolio_pct} should be 0.0 when " + f"f_kelly={f_kelly} <= 0 (p_up={p_up}, b={b})" + ) + assert result.downgrade is True, ( + f"downgrade should be True when f_kelly={f_kelly} <= 0" + ) + assert result.downgrade_reason == "negative_edge", ( + f"downgrade_reason should be 'negative_edge' when f_kelly={f_kelly} <= 0, " + f"got '{result.downgrade_reason}'" + ) + + +# --------------------------------------------------------------------------- +# Feature: math-core-v3-engine, Property 18: Stop loss is below entry price +# and take profit is above +# --------------------------------------------------------------------------- +# **Validates: Requirements 16.2, 16.3** + + +@settings(max_examples=100) +@given( + entry_price=entry_price_values, + atr_pct=atr_pct_values, + regime_atr_mult=st.floats(min_value=0.5, max_value=3.0, allow_nan=False, allow_infinity=False), + sigma_h=sigma_h_values, + reward_ratio=reward_ratio_values, +) +def test_property_18_stop_loss_below_entry_take_profit_above( + entry_price: float, + atr_pct: float, + regime_atr_mult: float, + sigma_h: float, + reward_ratio: float, +) -> None: + """Property 18: Stop loss is below entry price and take profit is above. + + For any entry_price > 0, stop_distance_pct in [0.005, 1.0), and reward + ratio b >= 1.2, the computed stop_loss SHALL be less than entry_price + and take_profit SHALL be greater than entry_price. + """ + result = compute_v3_stops( + entry_price=entry_price, + atr_pct=atr_pct, + regime_atr_mult=regime_atr_mult, + sigma_h=sigma_h, + reward_ratio=reward_ratio, + ) + + # stop_distance_pct should be at least 0.005 (the min floor) + assert result.stop_distance_pct >= 0.005, ( + f"stop_distance_pct={result.stop_distance_pct} should be >= 0.005" + ) + + # Stop loss must be strictly below entry price + assert result.stop_loss < entry_price, ( + f"stop_loss={result.stop_loss} should be < entry_price={entry_price} " + f"(stop_distance_pct={result.stop_distance_pct})" + ) + + # Take profit must be strictly above entry price + assert result.take_profit > entry_price, ( + f"take_profit={result.take_profit} should be > entry_price={entry_price} " + f"(reward_ratio={reward_ratio}, stop_distance_pct={result.stop_distance_pct})" + ) + + +# --------------------------------------------------------------------------- +# Feature: math-core-v3-engine, Property 19: Trailing stop never decreases +# --------------------------------------------------------------------------- +# **Validates: Requirements 16.5** + + +@settings(max_examples=100) +@given( + entry_price=st.floats(min_value=10.0, max_value=1000.0, allow_nan=False, allow_infinity=False), + atr_pct=atr_pct_values, + trailing_atr_mult=trailing_atr_mult_values, + sigma_h=sigma_h_values, + price_moves=st.lists( + st.floats(min_value=0.0, max_value=2.0, allow_nan=False, allow_infinity=False), + min_size=3, + max_size=20, + ), +) +def test_property_19_trailing_stop_never_decreases( + entry_price: float, + atr_pct: float, + trailing_atr_mult: float, + sigma_h: float, + price_moves: list[float], +) -> None: + """Property 19: Trailing stop never decreases. + + For any sequence of current prices and trailing stop computations, + each new trailing_stop value SHALL be >= the previous trailing_stop + value (monotonically non-decreasing). + """ + # Set up a take profit that's achievable + reward_ratio = 2.0 + stop_distance_pct = max(atr_pct * 1.5, sigma_h * 1.25, 0.005) + take_profit = entry_price * (1.0 + reward_ratio * stop_distance_pct) + + # Start with an initial stop below entry + existing_stop = entry_price * (1.0 - stop_distance_pct) + + # Generate a sequence of prices that move upward from entry + # (scaled by price_moves multiplied by stop distance to be meaningful) + previous_trailing_stop = existing_stop + + for move_factor in price_moves: + # Price moves upward from entry by some fraction of TP distance + tp_distance = take_profit - entry_price + current_price = entry_price + move_factor * tp_distance + + result = compute_trailing_stop( + existing_stop=previous_trailing_stop, + current_price=current_price, + entry_price=entry_price, + take_profit=take_profit, + atr_pct=atr_pct, + trailing_atr_mult=trailing_atr_mult, + sigma_h=sigma_h, + ) + + # The trailing stop must never decrease (monotonically non-decreasing) + assert result.trailing_stop >= previous_trailing_stop, ( + f"trailing_stop={result.trailing_stop} decreased from " + f"previous={previous_trailing_stop} at current_price={current_price}, " + f"entry={entry_price}, tp={take_profit}" + ) + + # Update for next iteration + previous_trailing_stop = result.trailing_stop + + +# --------------------------------------------------------------------------- +# Additional imports for heat and tier adjustment tests +# --------------------------------------------------------------------------- + +from services.risk.engine import ( + TierMetrics, + check_heat_capacity, + compute_portfolio_heat, + evaluate_tier_adjustment, +) + +# --------------------------------------------------------------------------- +# Strategies for heat and tier tests +# --------------------------------------------------------------------------- + +# Portfolio heat inputs +position_value_strategy = st.floats( + min_value=100.0, max_value=1_000_000.0, allow_nan=False, allow_infinity=False +) +stop_distance_strategy = st.floats( + min_value=0.005, max_value=0.50, allow_nan=False, allow_infinity=False +) +max_heat_pct_strategy = st.floats( + min_value=0.01, max_value=0.50, allow_nan=False, allow_infinity=False +) +portfolio_value_strategy = st.floats( + min_value=10_000.0, max_value=10_000_000.0, allow_nan=False, allow_infinity=False +) + +# Tier metrics inputs +profit_factor_strategy = st.floats( + min_value=0.0, max_value=5.0, allow_nan=False, allow_infinity=False +) +drawdown_strategy = st.floats( + min_value=0.0, max_value=0.50, allow_nan=False, allow_infinity=False +) +calibration_error_strategy = st.floats( + min_value=0.0, max_value=0.50, allow_nan=False, allow_infinity=False +) +sharpe_strategy = st.floats( + min_value=-3.0, max_value=5.0, allow_nan=False, allow_infinity=False +) +n_trades_strategy = st.integers(min_value=0, max_value=200) +reserve_pool_strategy = st.floats( + min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False +) + + +# --------------------------------------------------------------------------- +# Feature: math-core-v3-engine, Property 22: Portfolio heat rejection is +# correct +# --------------------------------------------------------------------------- +# **Validates: Requirements 15.3, 15.5** + + +@settings(max_examples=100) +@given( + position_values=st.lists( + position_value_strategy, min_size=1, max_size=10 + ), + stop_distances_list=st.lists( + stop_distance_strategy, min_size=1, max_size=10 + ), + new_position_value=position_value_strategy, + new_stop_distance=stop_distance_strategy, + max_heat_pct=max_heat_pct_strategy, + portfolio_value=portfolio_value_strategy, +) +def test_property_22_portfolio_heat_rejection_is_correct( + position_values: list[float], + stop_distances_list: list[float], + new_position_value: float, + new_stop_distance: float, + max_heat_pct: float, + portfolio_value: float, +) -> None: + """Property 22: Portfolio heat rejection is correct. + + For any set of open positions with stop distances, if the sum of + (position_value × stop_distance_pct) exceeds max_portfolio_heat × + portfolio_value, then new position entry SHALL be rejected. + """ + # Align list lengths (use shorter of the two) + n = min(len(position_values), len(stop_distances_list)) + position_values = position_values[:n] + stop_distances_list = stop_distances_list[:n] + + # Build positions and stop_distances dicts + positions = [] + stop_distances_dict: dict[str, float] = {} + for i in range(n): + ticker = f"TICK{i}" + positions.append({"ticker": ticker, "position_value": position_values[i]}) + stop_distances_dict[ticker] = stop_distances_list[i] + + # Compute current portfolio heat + current_heat = compute_portfolio_heat(positions, stop_distances_dict) + + # Compute new position risk dollars + new_risk_dollars = new_position_value * new_stop_distance + + # Check heat capacity + allowed = check_heat_capacity( + current_heat=current_heat, + new_risk_dollars=new_risk_dollars, + max_heat_pct=max_heat_pct, + portfolio_value=portfolio_value, + ) + + # The max allowed heat in dollars + max_heat_dollars = max_heat_pct * portfolio_value + + # Verify: if adding new position exceeds limit, must be rejected (False) + if (current_heat + new_risk_dollars) > max_heat_dollars: + assert allowed is False, ( + f"Expected rejection: current_heat={current_heat:.2f} + " + f"new_risk={new_risk_dollars:.2f} = {current_heat + new_risk_dollars:.2f} " + f"> max_heat={max_heat_dollars:.2f}, but got allowed=True" + ) + else: + # If within limit, must be allowed (True) + assert allowed is True, ( + f"Expected allowance: current_heat={current_heat:.2f} + " + f"new_risk={new_risk_dollars:.2f} = {current_heat + new_risk_dollars:.2f} " + f"<= max_heat={max_heat_dollars:.2f}, but got allowed=False" + ) + + +# --------------------------------------------------------------------------- +# Feature: math-core-v3-engine, Property 23: Tier auto-adjustment obeys +# downgrade-any, upgrade-all logic +# --------------------------------------------------------------------------- +# **Validates: Requirements 18.3, 18.4** + + +@settings(max_examples=100) +@given( + profit_factor=profit_factor_strategy, + max_drawdown=drawdown_strategy, + calibration_error=calibration_error_strategy, + sharpe=sharpe_strategy, + n_trades=n_trades_strategy, + reserve_pool=reserve_pool_strategy, +) +def test_property_23_tier_adjustment_downgrade_any_upgrade_all( + profit_factor: float, + max_drawdown: float, + calibration_error: float, + sharpe: float, + n_trades: int, + reserve_pool: float, +) -> None: + """Property 23: Tier auto-adjustment obeys downgrade-any, upgrade-all logic. + + For any TierMetrics, if ANY single downgrade condition is met + (profit_factor < 1.0 OR drawdown > 0.12 OR calibration_error > 0.20 OR + sharpe < 0), the result SHALL be "downgrade". An "upgrade" SHALL only + occur when ALL upgrade conditions are simultaneously met. + """ + metrics = TierMetrics( + profit_factor_30d=profit_factor, + max_drawdown_30d=max_drawdown, + calibration_error=calibration_error, + realized_sharpe_30d=sharpe, + n_trades_30d=n_trades, + reserve_pool_pct=reserve_pool, + ) + + result = evaluate_tier_adjustment(metrics) + + # Check downgrade conditions (any single one triggers downgrade) + downgrade_triggered = ( + profit_factor < 1.0 + or max_drawdown > 0.12 + or calibration_error > 0.20 + or sharpe < 0 + ) + + # Check upgrade conditions (all must be met simultaneously) + upgrade_triggered = ( + profit_factor > 1.35 + and max_drawdown < 0.05 + and calibration_error < 0.12 + and reserve_pool > 0.20 + and n_trades >= 20 + ) + + if downgrade_triggered: + assert result == "downgrade", ( + f"Expected 'downgrade' when downgrade condition met: " + f"pf={profit_factor}, dd={max_drawdown}, " + f"cal_err={calibration_error}, sharpe={sharpe}, " + f"but got '{result}'" + ) + elif upgrade_triggered: + assert result == "upgrade", ( + f"Expected 'upgrade' when all upgrade conditions met: " + f"pf={profit_factor}, dd={max_drawdown}, " + f"cal_err={calibration_error}, sharpe={sharpe}, " + f"n_trades={n_trades}, reserve={reserve_pool}, " + f"but got '{result}'" + ) + else: + assert result == "hold", ( + f"Expected 'hold' when neither downgrade nor upgrade: " + f"pf={profit_factor}, dd={max_drawdown}, " + f"cal_err={calibration_error}, sharpe={sharpe}, " + f"n_trades={n_trades}, reserve={reserve_pool}, " + f"but got '{result}'" + ) diff --git a/tests/test_pbt_v3_layers.py b/tests/test_pbt_v3_layers.py new file mode 100644 index 0000000..975d589 --- /dev/null +++ b/tests/test_pbt_v3_layers.py @@ -0,0 +1,173 @@ +"""Property-based tests for v3 macro and competitive layers. + +Validates: +- Property 13: Noisy-OR normalized exposure is bounded in [0, 1] +- Property 14: Competitive LLR is clamped to [-1.25, 1.25] +- Property 15: Graph attenuation is zero beyond max distance + +Requirements: 9.1, 9.2, 10.3, 10.4, 10.5 +""" +from __future__ import annotations + +from hypothesis import given, settings +from hypothesis import strategies as st + +from services.aggregation.interpolation import ( + compute_macro_llr, + compute_normalized_macro_exposure, +) +from services.aggregation.signal_propagation import ( + compute_competitive_llr, + compute_shrunk_correlation, +) + +# --------------------------------------------------------------------------- +# Strategies +# --------------------------------------------------------------------------- + +unit_floats = st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False) +llr_source_values = st.floats(min_value=-5.0, max_value=5.0, allow_nan=False, allow_infinity=False) +distances_valid = st.integers(min_value=1, max_value=3) +distances_beyond = st.integers(min_value=4, max_value=10) + + +# --------------------------------------------------------------------------- +# Feature: math-core-v3-engine, Property 13: Noisy-OR normalized exposure +# is bounded in [0, 1] +# --------------------------------------------------------------------------- +# **Validates: Requirements 9.1, 9.2** + + +@settings(max_examples=100) +@given( + geo=unit_floats, + supply=unit_floats, + commodity=unit_floats, + sector=unit_floats, +) +def test_property_13_noisy_or_exposure_bounded( + geo: float, + supply: float, + commodity: float, + sector: float, +) -> None: + """Property 13: Noisy-OR normalized exposure is bounded in [0, 1]. + + For any overlap values O_k in [0, 1] for each dimension (geo, supply, + commodity, sector) with fixed positive weights, the normalized macro + exposure E_macro SHALL be in [0.0, 1.0], reaching exactly 1.0 when + all O_k = 1.0. (Use tier="regional" with dampener=1.0 for this + property test) + """ + overlaps = { + "geo": geo, + "supply": supply, + "commodity": commodity, + "sector": sector, + } + e_macro = compute_normalized_macro_exposure(overlaps, tier="regional") + + # Must be bounded in [0, 1] with regional dampener = 1.0 + assert 0.0 <= e_macro <= 1.0, ( + f"E_macro={e_macro} out of bounds [0, 1] for overlaps={overlaps}" + ) + + +@settings(max_examples=1) +@given(st.just(None)) +def test_property_13_max_exposure_is_one(_: None) -> None: + """Property 13 (edge): E_macro reaches exactly 1.0 when all O_k = 1.0.""" + overlaps = {"geo": 1.0, "supply": 1.0, "commodity": 1.0, "sector": 1.0} + e_macro = compute_normalized_macro_exposure(overlaps, tier="regional") + assert e_macro == 1.0, f"Expected 1.0 when all overlaps=1.0, got {e_macro}" + + +# --------------------------------------------------------------------------- +# Feature: math-core-v3-engine, Property 14: Competitive LLR is clamped +# to [-1.25, 1.25] +# --------------------------------------------------------------------------- +# **Validates: Requirements 10.3, 10.4, 10.5** + + +@settings(max_examples=100) +@given( + llr_source=llr_source_values, + rho_rolling=unit_floats, + n_observations=st.integers(min_value=1, max_value=500), + same_sector=st.booleans(), + d_network=distances_valid, + pattern_confidence=unit_floats, +) +def test_property_14_competitive_llr_clamped( + llr_source: float, + rho_rolling: float, + n_observations: int, + same_sector: bool, + d_network: int, + pattern_confidence: float, +) -> None: + """Property 14: Competitive LLR is clamped to [-1.25, 1.25]. + + For any source LLR, shrunk correlation (non-negative), graph distance + (1-3), and pattern confidence in [0,1], the computed competitive LLR + SHALL be in [-1.25, 1.25]. + """ + rho_effective = compute_shrunk_correlation( + rho_rolling=rho_rolling, + n_observations=n_observations, + same_sector=same_sector, + ) + + # rho_effective should be non-negative per definition + assert rho_effective >= 0.0, f"rho_effective={rho_effective} is negative" + + llr_competitive = compute_competitive_llr( + llr_source=llr_source, + rho_effective=rho_effective, + d_network=d_network, + pattern_confidence=pattern_confidence, + ) + + assert -1.25 <= llr_competitive <= 1.25, ( + f"Competitive LLR={llr_competitive} out of bounds [-1.25, 1.25] " + f"for llr_source={llr_source}, rho_effective={rho_effective}, " + f"d_network={d_network}, pattern_confidence={pattern_confidence}" + ) + + +# --------------------------------------------------------------------------- +# Feature: math-core-v3-engine, Property 15: Graph attenuation is zero +# beyond max distance +# --------------------------------------------------------------------------- +# **Validates: Requirements 10.4, 10.5** + + +@settings(max_examples=100) +@given( + llr_source=llr_source_values, + rho_effective=unit_floats, + d_network=distances_beyond, + pattern_confidence=unit_floats, +) +def test_property_15_zero_attenuation_beyond_max_distance( + llr_source: float, + rho_effective: float, + d_network: int, + pattern_confidence: float, +) -> None: + """Property 15: Graph attenuation is zero beyond max distance. + + For any inputs where graph distance > 3, the computed attenuation + SHALL be 0.0, producing zero competitive LLR regardless of other + parameters. + """ + llr_competitive = compute_competitive_llr( + llr_source=llr_source, + rho_effective=rho_effective, + d_network=d_network, + pattern_confidence=pattern_confidence, + ) + + assert llr_competitive == 0.0, ( + f"Expected 0.0 for d_network={d_network} > 3, got {llr_competitive}" + ) diff --git a/tests/test_pbt_v3_posterior.py b/tests/test_pbt_v3_posterior.py new file mode 100644 index 0000000..bf67f52 --- /dev/null +++ b/tests/test_pbt_v3_posterior.py @@ -0,0 +1,609 @@ +"""Property-based tests for v3 posterior assembly and regime classification. + +Feature: math-core-v3-engine + +Uses Hypothesis to validate correctness properties of the v3 posterior +and regime detection layer. + +Validates: Requirements 5.3, 6.2–6.5, 21.5 +""" +from __future__ import annotations + +import math + +from hypothesis import given, settings +from hypothesis import strategies as st + +from services.aggregation.bayesian import V3Posterior, compute_v3_posterior +from services.aggregation.regime import ( + V3RegimeClassification, + MarketRegime, + classify_regime_v3, +) +from services.aggregation.worker import EvidenceCluster +from services.aggregation.scoring import EvidenceUnit + +# --------------------------------------------------------------------------- +# Hypothesis strategies +# --------------------------------------------------------------------------- + +# Cluster LLR values in [-2.5, 2.5] as per spec +cluster_llrs = st.floats( + min_value=-2.5, max_value=2.5, allow_nan=False, allow_infinity=False +) + +# Lists of cluster LLR values (0 to 10 clusters) +cluster_llr_lists = st.lists(cluster_llrs, min_size=0, max_size=10) + +# Regime evidence multiplier gamma from the defined set +gammas = st.sampled_from([0.70, 0.80, 0.90, 1.10]) + +# Prior probability in [0.40, 0.60] +priors = st.floats( + min_value=0.40, max_value=0.60, allow_nan=False, allow_infinity=False +) + +# Random walk closing prices for regime classification +# Generate 120+ prices as a random walk starting from a base price +_base_price = st.floats(min_value=10.0, max_value=500.0, allow_nan=False, allow_infinity=False) +_price_step = st.floats(min_value=-2.0, max_value=2.0, allow_nan=False, allow_infinity=False) + + +@st.composite +def closing_prices_strategy(draw: st.DrawFn) -> list[float]: + """Generate 120+ closing prices as a random walk.""" + base = draw(_base_price) + n = draw(st.integers(min_value=120, max_value=200)) + steps = draw(st.lists(_price_step, min_size=n, max_size=n)) + prices = [base] + for step in steps: + prices.append(max(0.01, prices[-1] + step)) # keep prices positive + return prices + + +@st.composite +def daily_returns_strategy(draw: st.DrawFn) -> list[float]: + """Generate 120+ daily returns (small floats).""" + n = draw(st.integers(min_value=120, max_value=200)) + returns = draw( + st.lists( + st.floats(min_value=-0.10, max_value=0.10, allow_nan=False, allow_infinity=False), + min_size=n, + max_size=n, + ) + ) + return returns + + +# ATR_20 > 0 +atr_20_strategy = st.floats( + min_value=0.01, max_value=50.0, allow_nan=False, allow_infinity=False +) + + +# --------------------------------------------------------------------------- +# Helper: build mock EvidenceCluster from a cluster_llr value +# --------------------------------------------------------------------------- + +def _make_cluster(cluster_llr: float) -> EvidenceCluster: + """Create a minimal EvidenceCluster with the given cluster_llr.""" + return EvidenceCluster( + cluster_id=f"test_cluster_{id(cluster_llr)}", + units=[], + llrs=[cluster_llr], + n_eff=1.0, + cluster_llr=cluster_llr, + ) + + +def _make_regime(gamma: float) -> V3RegimeClassification: + """Create a minimal V3RegimeClassification with the given gamma.""" + return V3RegimeClassification( + regime=MarketRegime.UNCERTAINTY, + trend_z=0.0, + vol_ratio=1.0, + evidence_multiplier=gamma, + confidence_multiplier=0.85, + phi_decay=0.50, + atr_multiplier=2.0, + ) + + +# --------------------------------------------------------------------------- +# Property 7: Posterior P_up is in open interval (0, 1) +# --------------------------------------------------------------------------- + + +@given( + llr_values=cluster_llr_lists, + gamma=gammas, + p_prior=priors, +) +@settings(max_examples=100) +def test_property_7_posterior_p_up_in_open_interval( + llr_values: list[float], + gamma: float, + p_prior: float, +) -> None: + """**Validates: Requirements 5.3** + + Property 7: For any set of cluster LLRs (each in [-2.5, 2.5]), any regime + evidence multiplier gamma in {0.70, 0.80, 0.90, 1.10}, and any prior + P_prior in [0.40, 0.60], the computed P_up SHALL be in (1e-10, 1 - 1e-10). + """ + # Build mock clusters from the LLR values + clusters = [_make_cluster(llr) for llr in llr_values] + + # Build mock regime with the given gamma + regime = _make_regime(gamma) + + # Compute posterior + result = compute_v3_posterior(clusters, regime, p_prior=p_prior) + + # P_up must be in the open interval (1e-10, 1 - 1e-10) + assert result.p_up >= 1e-10, ( + f"P_up {result.p_up} is below lower bound 1e-10" + ) + assert result.p_up <= 1 - 1e-10, ( + f"P_up {result.p_up} is above upper bound 1 - 1e-10" + ) + + # p_down should be 1 - p_up + assert math.isclose(result.p_up + result.p_down, 1.0, abs_tol=1e-12), ( + f"p_up + p_down = {result.p_up + result.p_down}, expected 1.0" + ) + + # Strength should be abs(2 * P_up - 1) + expected_strength = abs(2.0 * result.p_up - 1.0) + assert math.isclose(result.strength, expected_strength, abs_tol=1e-12), ( + f"strength {result.strength} != expected {expected_strength}" + ) + + +# --------------------------------------------------------------------------- +# Property 20: Regime classification is exhaustive and deterministic +# --------------------------------------------------------------------------- + +_VALID_REGIMES = {"panic", "trend_following", "mean_reversion", "uncertainty"} + + +@given( + closing_prices=closing_prices_strategy(), + daily_returns=daily_returns_strategy(), + atr_20=atr_20_strategy, +) +@settings(max_examples=100) +def test_property_20_regime_classification_exhaustive_and_deterministic( + closing_prices: list[float], + daily_returns: list[float], + atr_20: float, +) -> None: + """**Validates: Requirements 6.2–6.5, 21.5** + + Property 20: For any valid market data inputs (closing_prices of sufficient + length, daily_returns, ATR_20 > 0), the regime classification SHALL produce + exactly one of {panic, trend_following, mean_reversion, uncertainty} and the + same inputs SHALL always produce the same classification. + """ + # First classification + result_1 = classify_regime_v3(closing_prices, daily_returns, atr_20) + + # Result must be exactly one of the four valid regimes + assert result_1.regime.value in _VALID_REGIMES, ( + f"Regime '{result_1.regime.value}' not in valid set {_VALID_REGIMES}" + ) + + # Determinism: second call with same inputs must produce same result + result_2 = classify_regime_v3(closing_prices, daily_returns, atr_20) + + assert result_1.regime == result_2.regime, ( + f"Non-deterministic: first call gave {result_1.regime.value}, " + f"second gave {result_2.regime.value}" + ) + assert result_1.trend_z == result_2.trend_z, ( + f"Non-deterministic trend_z: {result_1.trend_z} vs {result_2.trend_z}" + ) + assert result_1.vol_ratio == result_2.vol_ratio, ( + f"Non-deterministic vol_ratio: {result_1.vol_ratio} vs {result_2.vol_ratio}" + ) + assert result_1.evidence_multiplier == result_2.evidence_multiplier, ( + f"Non-deterministic evidence_multiplier: " + f"{result_1.evidence_multiplier} vs {result_2.evidence_multiplier}" + ) + + +# --------------------------------------------------------------------------- +# Property 8: Contradiction is zero when evidence is unidirectional +# Feature: math-core-v3-engine, Property 8: Contradiction is zero when evidence is unidirectional +# --------------------------------------------------------------------------- + +from services.aggregation.contradiction import compute_v3_contradiction + +# Strategies for unidirectional cluster LLRs +all_positive_llrs = st.lists( + st.floats(min_value=0.001, max_value=2.5, allow_nan=False, allow_infinity=False), + min_size=1, max_size=10, +) +all_negative_llrs = st.lists( + st.floats(min_value=-2.5, max_value=-0.001, allow_nan=False, allow_infinity=False), + min_size=1, max_size=10, +) + + +@given(llr_values=all_positive_llrs) +@settings(max_examples=100) +def test_property_8_contradiction_zero_all_positive( + llr_values: list[float], +) -> None: + """**Validates: Requirements 7.6, 7.7** + + Property 8: For any set of cluster LLRs where all clusters have the same + sign (all positive), the computed contradiction score SHALL be 0.0. + """ + clusters = [_make_cluster(llr) for llr in llr_values] + score = compute_v3_contradiction(clusters) + assert score == 0.0, ( + f"Expected contradiction 0.0 for all-positive LLRs {llr_values}, got {score}" + ) + + +@given(llr_values=all_negative_llrs) +@settings(max_examples=100) +def test_property_8_contradiction_zero_all_negative( + llr_values: list[float], +) -> None: + """**Validates: Requirements 7.6, 7.7** + + Property 8: For any set of cluster LLRs where all clusters have the same + sign (all negative), the computed contradiction score SHALL be 0.0. + """ + clusters = [_make_cluster(llr) for llr in llr_values] + score = compute_v3_contradiction(clusters) + assert score == 0.0, ( + f"Expected contradiction 0.0 for all-negative LLRs {llr_values}, got {score}" + ) + + +# --------------------------------------------------------------------------- +# Property 9: Contradiction score is bounded in [0, 1] +# Feature: math-core-v3-engine, Property 9: Contradiction score is bounded in [0, 1] +# --------------------------------------------------------------------------- + +# Mix of positive and negative cluster LLRs +mixed_llrs = st.lists( + st.floats(min_value=-2.5, max_value=2.5, allow_nan=False, allow_infinity=False), + min_size=0, max_size=15, +) + + +@given(llr_values=mixed_llrs) +@settings(max_examples=100) +def test_property_9_contradiction_bounded_zero_one( + llr_values: list[float], +) -> None: + """**Validates: Requirements 7.6, 7.7, 21.7** + + Property 9: For any set of cluster LLRs (including mixed positive and + negative), the computed contradiction score SHALL be in [0.0, 1.0]. + """ + clusters = [_make_cluster(llr) for llr in llr_values] + score = compute_v3_contradiction(clusters) + assert 0.0 <= score <= 1.0, ( + f"Contradiction score {score} out of bounds [0, 1] for LLRs {llr_values}" + ) + + +# --------------------------------------------------------------------------- +# Property 10: Multiplicative confidence is bounded in [0, 1] and suppressed +# by weak dimensions +# Feature: math-core-v3-engine +# --------------------------------------------------------------------------- + +from services.aggregation.worker import compute_v3_confidence, compute_v3_data_quality + +# Strategies for Property 10 +n_eff_totals = st.floats(min_value=0.0, max_value=20.0, allow_nan=False, allow_infinity=False) +unit_floats = st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False) +q_value_lists = st.lists(unit_floats, min_size=1, max_size=15) +llr_lists_conf = st.lists( + st.floats(min_value=-2.5, max_value=2.5, allow_nan=False, allow_infinity=False), + min_size=1, max_size=15, +) +regime_conf_mults = st.floats(min_value=0.01, max_value=1.0, allow_nan=False, allow_infinity=False) + + +@given( + n_eff_total=n_eff_totals, + q_values=q_value_lists, + llrs=llr_lists_conf, + strength=unit_floats, + regime_confidence_mult=regime_conf_mults, + contradiction=unit_floats, + data_quality=unit_floats, +) +@settings(max_examples=100) +def test_property_10_confidence_bounded_and_suppressed( + n_eff_total: float, + q_values: list[float], + llrs: list[float], + strength: float, + regime_confidence_mult: float, + contradiction: float, + data_quality: float, +) -> None: + """**Validates: Requirements 8.3, 8.5** + + Property 10: For any valid inputs (n_eff_total >= 0, q_values in [0,1], + strength in [0,1], regime_confidence_mult in (0,1], contradiction in [0,1], + data_quality in [0,1]), the computed confidence SHALL be in [0, 1]. + Furthermore, if any single dimension (data_quality, 1-contradiction, or + C_quality) is below 0.01, the resulting confidence SHALL be below 0.10. + """ + # Align list lengths: q_values and llrs must be parallel + min_len = min(len(q_values), len(llrs)) + q_values = q_values[:min_len] + llrs = llrs[:min_len] + + confidence = compute_v3_confidence( + n_eff_total=n_eff_total, + q_values=q_values, + llrs=llrs, + strength=strength, + regime_confidence_mult=regime_confidence_mult, + contradiction=contradiction, + data_quality=data_quality, + ) + + # Confidence must be in [0, 1] + assert 0.0 <= confidence <= 1.0, ( + f"Confidence {confidence} out of bounds [0, 1]" + ) + + # Suppression check: if data_quality < 0.01, confidence < 0.10 + if data_quality < 0.01: + assert confidence < 0.10, ( + f"Confidence {confidence} not suppressed by data_quality={data_quality}" + ) + + # Suppression check: if (1 - contradiction) < 0.01, confidence < 0.10 + if (1.0 - contradiction) < 0.01: + assert confidence < 0.10, ( + f"Confidence {confidence} not suppressed by contradiction={contradiction}" + ) + + # Suppression check: if C_quality < 0.01, confidence < 0.10 + # C_quality = weighted_mean(q_i, |LLR_i|) = sum(q_i * |LLR_i|) / sum(|LLR_i|) + abs_llrs = [abs(llr) for llr in llrs] + sum_abs_llrs = sum(abs_llrs) + if sum_abs_llrs == 0.0: + c_quality = 0.0 + else: + c_quality = sum(q * w for q, w in zip(q_values, abs_llrs)) / sum_abs_llrs + if c_quality < 0.01: + assert confidence < 0.10, ( + f"Confidence {confidence} not suppressed by C_quality={c_quality}" + ) + + +# --------------------------------------------------------------------------- +# Property 10 suppression sub-test: force one dimension below 0.01 +# --------------------------------------------------------------------------- + +@given( + n_eff_total=st.floats(min_value=1.0, max_value=20.0, allow_nan=False, allow_infinity=False), + q_values=st.lists( + st.floats(min_value=0.5, max_value=1.0, allow_nan=False, allow_infinity=False), + min_size=1, max_size=10, + ), + llrs=st.lists( + st.floats(min_value=0.5, max_value=2.5, allow_nan=False, allow_infinity=False), + min_size=1, max_size=10, + ), + strength=st.floats(min_value=0.3, max_value=1.0, allow_nan=False, allow_infinity=False), + regime_confidence_mult=regime_conf_mults, + dimension=st.sampled_from(["data_quality", "contradiction", "c_quality"]), +) +@settings(max_examples=100) +def test_property_10_suppression_by_weak_dimension( + n_eff_total: float, + q_values: list[float], + llrs: list[float], + strength: float, + regime_confidence_mult: float, + dimension: str, +) -> None: + """**Validates: Requirements 8.3, 8.5** + + Property 10 (suppression): If any single dimension (data_quality, + 1-contradiction, or C_quality) is below 0.01, the resulting confidence + SHALL be below 0.10. + """ + # Align list lengths + min_len = min(len(q_values), len(llrs)) + q_values = q_values[:min_len] + llrs = llrs[:min_len] + + # Force one dimension to be weak + if dimension == "data_quality": + data_quality = 0.005 # below 0.01 + contradiction = 0.0 + elif dimension == "contradiction": + data_quality = 0.9 + contradiction = 0.995 # (1 - contradiction) = 0.005 < 0.01 + else: # c_quality + # Force all q_values near zero so C_quality < 0.01 + q_values = [0.001] * min_len + data_quality = 0.9 + contradiction = 0.0 + + confidence = compute_v3_confidence( + n_eff_total=n_eff_total, + q_values=q_values, + llrs=llrs, + strength=strength, + regime_confidence_mult=regime_confidence_mult, + contradiction=contradiction, + data_quality=data_quality, + ) + + assert 0.0 <= confidence <= 1.0, ( + f"Confidence {confidence} out of bounds [0, 1]" + ) + assert confidence < 0.10, ( + f"Confidence {confidence} not suppressed when {dimension} is weak" + ) + + +# --------------------------------------------------------------------------- +# Property 17: Data quality score is bounded in [0, 1] +# Feature: math-core-v3-engine +# --------------------------------------------------------------------------- + +from datetime import datetime, timezone as _tz + +# Strategies for Property 17 +extraction_failure_rates = st.floats( + min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False +) +extraction_confs = st.floats( + min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False +) +impacts = st.floats( + min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False +) +age_newest_hours_strat = st.floats( + min_value=0.0, max_value=10000.0, allow_nan=False, allow_infinity=False +) +n_source_types_strat = st.integers(min_value=0, max_value=20) + + +@st.composite +def evidence_unit_list_strategy(draw: st.DrawFn) -> list[EvidenceUnit]: + """Generate a list of EvidenceUnit-like objects for data quality testing.""" + n = draw(st.integers(min_value=0, max_value=15)) + units = [] + _ts = datetime(2024, 1, 1, tzinfo=_tz.utc) + for i in range(n): + ext_conf = draw(extraction_confs) + impact = draw(impacts) + unit = EvidenceUnit( + symbol="TEST", + layer="company", + event_type="earnings", + source_id=f"src_{i}", + source_group="news", + timestamp=_ts, + horizon="7d", + direction=1, + sentiment_strength=0.5, + impact=impact, + extraction_conf=ext_conf, + source_cred=0.7, + novelty=0.5, + event_base_rate=0.25, + cluster_id=f"cluster_{i}", + ) + units.append(unit) + return units + + +@given( + units=evidence_unit_list_strategy(), + extraction_failure_rate=extraction_failure_rates, + age_newest_hours=age_newest_hours_strat, + n_source_types=n_source_types_strat, +) +@settings(max_examples=100) +def test_property_17_data_quality_bounded_zero_one( + units: list[EvidenceUnit], + extraction_failure_rate: float, + age_newest_hours: float, + n_source_types: int, +) -> None: + """**Validates: Requirements 17.6, 21.6** + + Property 17: For any valid inputs (extraction_failure_rate in [0,1], + extraction_conf_i in [0,1], impact_i in [0,1], age_newest_hours >= 0, + N_valid >= 0, N_source_types >= 0), the computed data_quality_score + SHALL be in [0, 1]. + """ + data_quality = compute_v3_data_quality( + units=units, + extraction_failure_rate=extraction_failure_rate, + age_newest_hours=age_newest_hours, + n_source_types=n_source_types, + ) + + assert 0.0 <= data_quality <= 1.0, ( + f"Data quality score {data_quality} out of bounds [0, 1] " + f"for extraction_failure_rate={extraction_failure_rate}, " + f"age_newest_hours={age_newest_hours}, n_source_types={n_source_types}, " + f"n_units={len(units)}" + ) + + +# --------------------------------------------------------------------------- +# Property 12: Posterior state JSON round-trip +# Feature: math-core-v3-engine +# --------------------------------------------------------------------------- + +import json +from dataclasses import asdict + + +@given( + llr_values=st.lists( + st.floats(min_value=-2.5, max_value=2.5, allow_nan=False, allow_infinity=False), + min_size=1, max_size=10, + ), + gamma=gammas, + p_prior=priors, +) +@settings(max_examples=100) +def test_property_12_posterior_json_round_trip( + llr_values: list[float], + gamma: float, + p_prior: float, +) -> None: + """**Validates: Requirements 20.1, 21.10** + + Property 12: Serialize V3Posterior to JSON via dataclasses.asdict() and + json.dumps(), then deserialize via json.loads() and V3Posterior(**data). + All numeric fields SHALL be equivalent within 1e-10, and string fields + SHALL be exactly equal. + """ + # Build clusters and regime, compute posterior + clusters = [_make_cluster(llr) for llr in llr_values] + regime = _make_regime(gamma) + posterior = compute_v3_posterior(clusters, regime, p_prior=p_prior) + + # Serialize to JSON + serialized = json.dumps(asdict(posterior)) + + # Deserialize back to V3Posterior + restored = V3Posterior(**json.loads(serialized)) + + # Verify numeric fields within 1e-10 + assert math.isclose(posterior.p_up, restored.p_up, abs_tol=1e-10), ( + f"p_up mismatch: {posterior.p_up} vs {restored.p_up}" + ) + assert math.isclose(posterior.p_down, restored.p_down, abs_tol=1e-10), ( + f"p_down mismatch: {posterior.p_down} vs {restored.p_down}" + ) + assert math.isclose(posterior.log_odds, restored.log_odds, abs_tol=1e-10), ( + f"log_odds mismatch: {posterior.log_odds} vs {restored.log_odds}" + ) + assert math.isclose(posterior.strength, restored.strength, abs_tol=1e-10), ( + f"strength mismatch: {posterior.strength} vs {restored.strength}" + ) + assert math.isclose(posterior.n_eff_total, restored.n_eff_total, abs_tol=1e-10), ( + f"n_eff_total mismatch: {posterior.n_eff_total} vs {restored.n_eff_total}" + ) + + # Verify string fields are exactly equal + assert posterior.direction == restored.direction, ( + f"direction mismatch: {posterior.direction!r} vs {restored.direction!r}" + ) + assert posterior.regime == restored.regime, ( + f"regime mismatch: {posterior.regime!r} vs {restored.regime!r}" + ) diff --git a/tests/test_pbt_v3_projection.py b/tests/test_pbt_v3_projection.py new file mode 100644 index 0000000..d4a77fa --- /dev/null +++ b/tests/test_pbt_v3_projection.py @@ -0,0 +1,107 @@ +"""Property-based tests for v3 posterior state projection. + +Validates: +- Property 16: Projection evidence state decays toward zero + +Requirements: 11.1, 11.3 +""" +from __future__ import annotations + +from hypothesis import given, settings +from hypothesis import strategies as st + +from services.aggregation.projection import V3ProjectionState, compute_v3_projection +from services.aggregation.regime import MarketRegime, V3RegimeClassification + +# --------------------------------------------------------------------------- +# Strategies +# --------------------------------------------------------------------------- + +# Initial evidence states (non-zero) +a_t_values = st.floats( + min_value=-10.0, max_value=10.0, allow_nan=False, allow_infinity=False +).filter(lambda x: abs(x) > 0.01) + +# Horizons >= 1 +horizons = st.integers(min_value=1, max_value=50) + +# Regimes +regimes = st.sampled_from(["panic", "trend_following", "mean_reversion", "uncertainty"]) + + +def _make_regime(regime_name: str) -> V3RegimeClassification: + """Create a V3RegimeClassification with the given regime name.""" + return V3RegimeClassification( + regime=MarketRegime(regime_name), + trend_z=0.0, + vol_ratio=1.0, + evidence_multiplier=1.0, + confidence_multiplier=1.0, + phi_decay={"panic": 0.35, "trend_following": 0.80, "mean_reversion": 0.55, "uncertainty": 0.50}[regime_name], + atr_multiplier=2.0, + ) + + +# --------------------------------------------------------------------------- +# Feature: math-core-v3-engine, Property 16: Projection evidence state +# decays toward zero +# --------------------------------------------------------------------------- +# **Validates: Requirements 11.1, 11.3** + + +@settings(max_examples=100) +@given( + a_t=a_t_values, + regime_name=regimes, + h=horizons, +) +def test_property_16_projection_evidence_state_decays_toward_zero( + a_t: float, + regime_name: str, + h: int, +) -> None: + """Property 16: Projection evidence state decays toward zero. + + For any initial evidence state A_t and regime decay phi in (0, 1), + the projected state A_projected_h = phi^h * A_t SHALL have + |A_projected_h| < |A_t| for all h >= 1, converging toward 0 as h + increases. + """ + regime = _make_regime(regime_name) + + # Call compute_v3_projection with a_prev=a_t, cluster_llrs=[] (no new evidence), + # known_catalyst_llr=0.0. This gives A_t = phi * a_prev (since no new LLRs). + result = compute_v3_projection( + a_prev=a_t, + cluster_llrs=[], + regime=regime, + p_prior=0.50, + projection_horizon=h, + known_catalyst_llr=0.0, + ) + + # After update: A_t_new = phi * a_prev + 0 = phi * a_prev + # After projection: A_projected = phi^h * A_t_new = phi^h * (phi * a_prev) = phi^(h+1) * a_prev + # The phi values are all in (0, 1), so phi^(h+1) < 1 for h >= 1 + # Therefore |A_projected| < |a_prev| + + phi = regime.phi_decay + # The evidence state after update (no new LLRs): A_t_new = phi * a_prev + a_t_new = result.a_t + # The projected alpha: A_projected = phi^h * A_t_new + a_projected = (phi ** h) * a_t_new + + # |A_projected| must be less than |a_prev| because phi is in (0, 1) + # and A_projected = phi^(h+1) * a_prev + assert abs(a_projected) < abs(a_t), ( + f"|A_projected|={abs(a_projected)} should be < |a_prev|={abs(a_t)} " + f"for phi={phi}, h={h}, regime={regime_name}" + ) + + # Verify convergence: larger h → smaller magnitude + # Compute projected at h+10 and verify it's smaller than at h + a_projected_larger_h = (phi ** (h + 10)) * a_t_new + assert abs(a_projected_larger_h) <= abs(a_projected), ( + f"|A_projected(h+10)|={abs(a_projected_larger_h)} should be <= " + f"|A_projected(h)|={abs(a_projected)} for phi={phi}, regime={regime_name}" + ) diff --git a/tests/test_pbt_v3_reliability.py b/tests/test_pbt_v3_reliability.py new file mode 100644 index 0000000..99d2cdd --- /dev/null +++ b/tests/test_pbt_v3_reliability.py @@ -0,0 +1,417 @@ +"""Property-based tests for EvidenceUnit normalization, calibrated reliability, and LLR. + +Feature: math-core-v3-engine + +Uses Hypothesis to validate correctness properties of the v3 calibrated +evidence engine foundation: EvidenceUnit normalization preserves field ranges, +reliability q_i is bounded, p_correct is bounded, LLR sign matches direction, +and neutral signals produce zero LLR. + +Validates: Requirements 1.1–1.8, 2.1–2.9, 3.1–3.6, 21.1–21.3 +""" +from __future__ import annotations + +import math +from datetime import datetime, timedelta, timezone + +from hypothesis import given, settings +from hypothesis import strategies as st + +from services.aggregation.scoring import ( + EvidenceUnit, + ReliabilityComponents, + SourceStats, + _clamp, + compute_llr, + compute_v3_reliability, + normalize_company_signal, + normalize_competitive_signal, + normalize_macro_signal, +) + +# --------------------------------------------------------------------------- +# Hypothesis strategies +# --------------------------------------------------------------------------- + +unit_floats = st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False) +directions = st.sampled_from([-1, 0, 1]) +horizons = st.sampled_from(["intraday", "1d", "7d", "30d", "90d"]) +positive_floats = st.floats(min_value=0.0, max_value=10000.0, allow_nan=False, allow_infinity=False) +non_negative_ints = st.integers(min_value=0, max_value=100) + + +def _evidence_unit_strategy() -> st.SearchStrategy[EvidenceUnit]: + """Generate valid EvidenceUnit instances with fields in valid ranges.""" + return st.builds( + EvidenceUnit, + symbol=st.just("AAPL"), + layer=st.sampled_from(["company", "macro", "competitive"]), + event_type=st.sampled_from(["earnings", "product_launch", "regulatory", "unknown"]), + source_id=st.just("src-001"), + source_group=st.sampled_from(["company", "macro", "competitive"]), + timestamp=st.just(datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)), + horizon=horizons, + direction=directions, + sentiment_strength=unit_floats, + impact=unit_floats, + extraction_conf=unit_floats, + source_cred=unit_floats, + novelty=unit_floats, + event_base_rate=st.floats(min_value=0.01, max_value=1.0, allow_nan=False, allow_infinity=False), + cluster_id=st.just("cluster-001"), + ) + + +def _source_stats_strategy() -> st.SearchStrategy[SourceStats]: + """Generate SourceStats with valid counts.""" + return st.builds( + SourceStats, + source_id=st.just("src-001"), + hits=non_negative_ints, + misses=non_negative_ints, + alpha_0=st.floats(min_value=1.0, max_value=10.0, allow_nan=False, allow_infinity=False), + beta_0=st.floats(min_value=1.0, max_value=10.0, allow_nan=False, allow_infinity=False), + ) + + +# --------------------------------------------------------------------------- +# Property 1: Reliability q_i is bounded in [0, 1] +# Feature: math-core-v3-engine, Property 1: Reliability q_i is bounded in [0, 1] +# Validates: Requirements 2.1–2.9 +# --------------------------------------------------------------------------- + + +@settings(max_examples=100) +@given( + extraction_conf=unit_floats, + source_cred=unit_floats, + novelty=unit_floats, + impact=unit_floats, + sentiment_strength=unit_floats, + event_base_rate=st.floats(min_value=0.01, max_value=1.0, allow_nan=False, allow_infinity=False), + age_hours=st.floats(min_value=0.0, max_value=5000.0, allow_nan=False, allow_infinity=False), + duplicate_count_before=non_negative_ints, + hits=non_negative_ints, + misses=non_negative_ints, +) +def test_property_1_reliability_qi_bounded( + extraction_conf: float, + source_cred: float, + novelty: float, + impact: float, + sentiment_strength: float, + event_base_rate: float, + age_hours: float, + duplicate_count_before: int, + hits: int, + misses: int, +) -> None: + """Property 1: Reliability q_i is bounded in [0, 1]. + + For any valid EvidenceUnit with extraction_conf in [0,1], source_cred in [0,1], + novelty in [0,1], any non-negative age_hours, and any non-negative + duplicate_count_before, the computed q_i SHALL be in [0.0, 1.0]. + + **Validates: Requirements 2.1–2.9** + """ + reference_time = datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc) + unit_time = reference_time - timedelta(hours=age_hours) + + unit = EvidenceUnit( + symbol="AAPL", + layer="company", + event_type="earnings", + source_id="src-001", + source_group="company", + timestamp=unit_time, + horizon="7d", + direction=1, + sentiment_strength=sentiment_strength, + impact=impact, + extraction_conf=extraction_conf, + source_cred=source_cred, + novelty=novelty, + event_base_rate=event_base_rate, + cluster_id="cluster-001", + ) + + stats = SourceStats(source_id="src-001", hits=hits, misses=misses) + result = compute_v3_reliability(unit, stats, duplicate_count_before, reference_time) + + assert 0.0 <= result.q_i <= 1.0, f"q_i={result.q_i} out of [0, 1]" + assert 0.0 <= result.q_ext <= 1.0, f"q_ext={result.q_ext} out of [0, 1]" + assert 0.0 <= result.q_source <= 1.0, f"q_source={result.q_source} out of [0, 1]" + assert 0.0 <= result.q_recency <= 1.0, f"q_recency={result.q_recency} out of [0, 1]" + assert 0.0 <= result.q_uniqueness <= 1.0, f"q_uniqueness={result.q_uniqueness} out of [0, 1]" + + +# --------------------------------------------------------------------------- +# Property 2: p_correct is bounded in [0.501, 0.85] +# Feature: math-core-v3-engine, Property 2: p_correct is bounded in [0.501, 0.85] +# Validates: Requirements 3.1–3.2 +# --------------------------------------------------------------------------- + + +@settings(max_examples=100) +@given( + q_i=unit_floats, + impact=unit_floats, + sentiment_strength=unit_floats, +) +def test_property_2_p_correct_bounded( + q_i: float, + impact: float, + sentiment_strength: float, +) -> None: + """Property 2: p_correct is bounded in [0.501, 0.85]. + + For any valid q_i in [0,1], impact in [0,1], and sentiment_strength in [0,1], + the computed p_correct SHALL be in [0.501, 0.85]. + + **Validates: Requirements 3.1–3.2** + """ + # Replicate the p_correct computation from compute_llr + p_correct = _clamp( + 0.50 + 0.35 * q_i * impact * sentiment_strength, + 0.501, + 0.85, + ) + + assert 0.501 <= p_correct <= 0.85, f"p_correct={p_correct} out of [0.501, 0.85]" + + +# --------------------------------------------------------------------------- +# Property 3: LLR sign matches direction and magnitude is bounded +# Feature: math-core-v3-engine, Property 3: LLR sign matches direction and magnitude is bounded +# Validates: Requirements 3.3–3.4, 3.6 +# --------------------------------------------------------------------------- + + +@settings(max_examples=100) +@given( + direction=st.sampled_from([-1, 1]), + q_i=unit_floats, + impact=unit_floats, + sentiment_strength=unit_floats, +) +def test_property_3_llr_sign_matches_direction( + direction: int, + q_i: float, + impact: float, + sentiment_strength: float, +) -> None: + """Property 3: LLR sign matches direction and magnitude is bounded. + + For any valid signal with direction in {-1, +1}, the LLR SHALL have the same + sign as direction, with abs magnitude in [~0.004, ~1.735]. + + **Validates: Requirements 3.3–3.4, 3.6** + """ + unit = EvidenceUnit( + symbol="AAPL", + layer="company", + event_type="earnings", + source_id="src-001", + source_group="company", + timestamp=datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + horizon="7d", + direction=direction, + sentiment_strength=sentiment_strength, + impact=impact, + extraction_conf=0.8, + source_cred=0.8, + novelty=0.8, + event_base_rate=0.25, + cluster_id="cluster-001", + ) + + llr = compute_llr(unit, q_i) + + # LLR sign must match direction + if direction == 1: + assert llr > 0.0, f"LLR={llr} should be positive for direction=+1" + else: + assert llr < 0.0, f"LLR={llr} should be negative for direction=-1" + + # Magnitude bounds: ln(0.501/0.499) ≈ 0.004, ln(0.85/0.15) ≈ 1.735 + min_magnitude = math.log(0.501 / 0.499) # ~0.004 + max_magnitude = math.log(0.85 / 0.15) # ~1.735 + + assert abs(llr) >= min_magnitude - 1e-9, f"|LLR|={abs(llr)} below min ~0.004" + assert abs(llr) <= max_magnitude + 1e-9, f"|LLR|={abs(llr)} above max ~1.735" + + +# --------------------------------------------------------------------------- +# Property 4: Neutral signals produce zero LLR +# Feature: math-core-v3-engine, Property 4: Neutral signals produce zero LLR +# Validates: Requirements 3.5 +# --------------------------------------------------------------------------- + + +@settings(max_examples=100) +@given( + q_i=unit_floats, + impact=unit_floats, + sentiment_strength=unit_floats, + extraction_conf=unit_floats, + source_cred=unit_floats, + novelty=unit_floats, +) +def test_property_4_neutral_produces_zero_llr( + q_i: float, + impact: float, + sentiment_strength: float, + extraction_conf: float, + source_cred: float, + novelty: float, +) -> None: + """Property 4: Neutral signals produce zero LLR. + + For any EvidenceUnit with direction=0, LLR SHALL be exactly 0.0. + + **Validates: Requirements 3.5** + """ + unit = EvidenceUnit( + symbol="AAPL", + layer="company", + event_type="earnings", + source_id="src-001", + source_group="company", + timestamp=datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + horizon="7d", + direction=0, + sentiment_strength=sentiment_strength, + impact=impact, + extraction_conf=extraction_conf, + source_cred=source_cred, + novelty=novelty, + event_base_rate=0.25, + cluster_id="cluster-001", + ) + + llr = compute_llr(unit, q_i) + assert llr == 0.0, f"LLR={llr} should be exactly 0.0 for neutral direction" + + +# --------------------------------------------------------------------------- +# Property 21: EvidenceUnit normalization preserves field ranges +# Feature: math-core-v3-engine, Property 21: EvidenceUnit normalization preserves field ranges +# Validates: Requirements 1.1–1.8, 21.1–21.3 +# --------------------------------------------------------------------------- + + +def _company_signal_strategy() -> st.SearchStrategy[dict]: + """Generate raw company signal dicts with arbitrary values.""" + return st.fixed_dictionaries({ + "symbol": st.just("TSLA"), + "timestamp": st.just(datetime(2024, 6, 15, 10, 0, 0, tzinfo=timezone.utc)), + "source_id": st.just("doc-123"), + "event_type": st.sampled_from(["earnings", "product_launch", "regulatory", "unknown", None]), + "source_group": st.sampled_from(["company", "reuters", None]), + "horizon": st.sampled_from(["intraday", "1d", "7d", "30d", "90d", "invalid", None]), + "sentiment": st.sampled_from(["positive", "negative", "neutral", "mixed", "bullish", "bearish", None]), + "sentiment_strength": st.one_of(unit_floats, st.just(None)), + "impact": st.one_of(unit_floats, st.just(None)), + "extraction_conf": st.one_of(unit_floats, st.just(None)), + "source_cred": st.one_of(unit_floats, st.just(None)), + "novelty": st.one_of(unit_floats, st.just(None)), + }) + + +def _macro_signal_strategy() -> st.SearchStrategy[dict]: + """Generate raw macro signal dicts with arbitrary values.""" + return st.fixed_dictionaries({ + "symbol": st.just("AAPL"), + "timestamp": st.just(datetime(2024, 6, 15, 10, 0, 0, tzinfo=timezone.utc)), + "source_id": st.just("event-456"), + "event_type": st.sampled_from(["earnings", "regulatory", "market_data", None]), + "estimated_duration": st.sampled_from(["short_term", "medium_term", "long_term", "unknown", None]), + "impact_direction": st.sampled_from(["positive", "negative", "neutral", "bullish", "bearish", None]), + "macro_impact_score": st.one_of(unit_floats, st.just(None)), + "event_confidence": st.one_of(unit_floats, st.just(None)), + "novelty": st.one_of(unit_floats, st.just(None)), + "sentiment_strength": st.one_of(unit_floats, st.just(None)), + }) + + +def _competitive_signal_strategy() -> st.SearchStrategy[dict]: + """Generate raw competitive signal dicts with arbitrary values.""" + return st.fixed_dictionaries({ + "symbol": st.just("MSFT"), + "timestamp": st.just(datetime(2024, 6, 15, 10, 0, 0, tzinfo=timezone.utc)), + "source_id": st.just("comp-789"), + "event_type": st.sampled_from(["earnings", "product_launch", None]), + "signal_direction": st.sampled_from(["bullish", "bearish", "neutral", None]), + "signal_strength": st.one_of(unit_floats, st.just(None)), + "relationship_strength": st.one_of(unit_floats, st.just(None)), + "pattern_confidence": st.one_of(unit_floats, st.just(None)), + "time_horizon": st.sampled_from(["intraday", "1d", "7d", "30d", "90d", "short_term", None]), + "novelty": st.one_of(unit_floats, st.just(None)), + "sentiment_strength": st.one_of(unit_floats, st.just(None)), + }) + + +@settings(max_examples=100) +@given(signal=_company_signal_strategy()) +def test_property_21_company_normalization_preserves_ranges(signal: dict) -> None: + """Property 21: EvidenceUnit normalization preserves field ranges (company). + + For any valid company signal input, normalized EvidenceUnit SHALL have + direction in {-1,0,+1}, all float fields in [0,1], event_base_rate in (0,1]. + + **Validates: Requirements 1.1–1.8, 21.1–21.3** + """ + unit = normalize_company_signal(signal) + assert unit is not None, "Expected valid EvidenceUnit from company signal" + + _assert_evidence_unit_ranges(unit) + + +@settings(max_examples=100) +@given(signal=_macro_signal_strategy()) +def test_property_21_macro_normalization_preserves_ranges(signal: dict) -> None: + """Property 21: EvidenceUnit normalization preserves field ranges (macro). + + For any valid macro signal input, normalized EvidenceUnit SHALL have + direction in {-1,0,+1}, all float fields in [0,1], event_base_rate in (0,1]. + + **Validates: Requirements 1.1–1.8, 21.1–21.3** + """ + unit = normalize_macro_signal(signal) + assert unit is not None, "Expected valid EvidenceUnit from macro signal" + + _assert_evidence_unit_ranges(unit) + + +@settings(max_examples=100) +@given(signal=_competitive_signal_strategy()) +def test_property_21_competitive_normalization_preserves_ranges(signal: dict) -> None: + """Property 21: EvidenceUnit normalization preserves field ranges (competitive). + + For any valid competitive signal input, normalized EvidenceUnit SHALL have + direction in {-1,0,+1}, all float fields in [0,1], event_base_rate in (0,1]. + + **Validates: Requirements 1.1–1.8, 21.1–21.3** + """ + unit = normalize_competitive_signal(signal) + assert unit is not None, "Expected valid EvidenceUnit from competitive signal" + + _assert_evidence_unit_ranges(unit) + + +# --------------------------------------------------------------------------- +# Shared assertion helper +# --------------------------------------------------------------------------- + + +def _assert_evidence_unit_ranges(unit: EvidenceUnit) -> None: + """Assert all EvidenceUnit fields are within their valid ranges.""" + assert unit.direction in (-1, 0, 1), f"direction={unit.direction} not in {{-1, 0, +1}}" + assert 0.0 <= unit.sentiment_strength <= 1.0, f"sentiment_strength={unit.sentiment_strength} out of [0, 1]" + assert 0.0 <= unit.impact <= 1.0, f"impact={unit.impact} out of [0, 1]" + assert 0.0 <= unit.extraction_conf <= 1.0, f"extraction_conf={unit.extraction_conf} out of [0, 1]" + assert 0.0 <= unit.source_cred <= 1.0, f"source_cred={unit.source_cred} out of [0, 1]" + assert 0.0 <= unit.novelty <= 1.0, f"novelty={unit.novelty} out of [0, 1]" + assert 0.0 < unit.event_base_rate <= 1.0, f"event_base_rate={unit.event_base_rate} out of (0, 1]" + assert unit.horizon in ("intraday", "1d", "7d", "30d", "90d"), f"horizon={unit.horizon} invalid" + assert unit.layer in ("company", "macro", "competitive"), f"layer={unit.layer} invalid" diff --git a/tests/test_v3_clustering.py b/tests/test_v3_clustering.py new file mode 100644 index 0000000..73aa9c6 --- /dev/null +++ b/tests/test_v3_clustering.py @@ -0,0 +1,290 @@ +"""Unit tests for v3 correlation-aware clustering. + +Tests for compute_n_eff, compute_cluster_llr, and cluster_evidence functions. + +Requirements validated: 4.1–4.5 +""" +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from services.aggregation.scoring import EvidenceUnit +from services.aggregation.worker import ( + cluster_evidence, + compute_cluster_llr, + compute_n_eff, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_unit(cluster_id: str = "cluster_a", symbol: str = "AAPL") -> EvidenceUnit: + """Create a minimal EvidenceUnit for testing.""" + return EvidenceUnit( + symbol=symbol, + layer="company", + event_type="earnings", + source_id="doc_1", + source_group="reuters", + timestamp=datetime(2024, 1, 15, 12, 0, tzinfo=timezone.utc), + horizon="7d", + direction=1, + sentiment_strength=0.8, + impact=0.7, + extraction_conf=0.9, + source_cred=0.85, + novelty=0.6, + event_base_rate=0.25, + cluster_id=cluster_id, + ) + + +# --------------------------------------------------------------------------- +# Test: 3 identical articles from same source → n_eff < 3 +# Requirement: 4.2, 4.3 +# --------------------------------------------------------------------------- + + +class TestNEffIdenticalArticles: + """3 identical articles from same wire (default rho=0.80) → n_eff < 3.""" + + def test_n_eff_less_than_cluster_size(self): + llrs = [1.0, 1.0, 1.0] + # Default correlations: rho=0.80 for all pairs (same wire/source) + n_eff = compute_n_eff(llrs) + + # Formula: (3)² / (3 + 2×3×0.80×1×1) = 9 / (3 + 4.8) = 9/7.8 ≈ 1.154 + expected = 9.0 / 7.8 + assert n_eff < 3.0 + assert n_eff == pytest.approx(expected, rel=1e-6) + + def test_n_eff_discounts_correlated_signals(self): + """Higher correlation → lower n_eff.""" + llrs = [1.0, 1.0, 1.0] + n_eff_correlated = compute_n_eff(llrs) # default rho=0.80 + + # Independent: rho=0.0 + identity = [[1, 0, 0], [0, 1, 0], [0, 0, 1]] + n_eff_independent = compute_n_eff(llrs, correlations=identity) + + assert n_eff_correlated < n_eff_independent + + +# --------------------------------------------------------------------------- +# Test: 3 independent articles → n_eff ≈ 3 +# Requirement: 4.2, 4.3 +# --------------------------------------------------------------------------- + + +class TestNEffIndependentArticles: + """3 independent articles (rho=0.0) → n_eff = 3.0.""" + + def test_n_eff_equals_cluster_size(self): + llrs = [1.0, 1.0, 1.0] + # Zero off-diagonal correlations + correlations = [[1, 0, 0], [0, 1, 0], [0, 0, 1]] + n_eff = compute_n_eff(llrs, correlations=correlations) + + # n_eff = (3)² / (3 + 0) = 3.0 + assert n_eff == pytest.approx(3.0, rel=1e-6) + + def test_n_eff_with_varying_magnitudes(self): + """Independent signals with different magnitudes still give n <= cluster size.""" + llrs = [0.5, 1.0, 2.0] + correlations = [[1, 0, 0], [0, 1, 0], [0, 0, 1]] + n_eff = compute_n_eff(llrs, correlations=correlations) + + # With zero correlations, n_eff = (sum |w|)^2 / sum(w^2) + # = (0.5+1.0+2.0)^2 / (0.25+1.0+4.0) = 12.25 / 5.25 ≈ 2.333 + expected = (3.5**2) / (0.25 + 1.0 + 4.0) + assert n_eff == pytest.approx(expected, rel=1e-6) + assert n_eff <= 3.0 + + +# --------------------------------------------------------------------------- +# Test: Single signal cluster → n_eff = 1.0 +# Requirement: 4.2 +# --------------------------------------------------------------------------- + + +class TestNEffSingleSignal: + """Single signal in a cluster → n_eff = 1.0.""" + + def test_single_signal(self): + llrs = [0.5] + n_eff = compute_n_eff(llrs) + assert n_eff == 1.0 + + def test_empty_cluster(self): + llrs: list[float] = [] + n_eff = compute_n_eff(llrs) + assert n_eff == 1.0 + + +# --------------------------------------------------------------------------- +# Test: Cluster LLR clamp at ±2.5 +# Requirement: 4.4, 4.5 +# --------------------------------------------------------------------------- + + +class TestClusterLLRClamp: + """Cluster LLR is clamped to [-2.5, 2.5].""" + + def test_positive_clamp(self): + """Very large positive LLRs with high n_eff → clamped to 2.5.""" + llrs = [2.0, 2.0, 2.0, 2.0, 2.0] + # Use independent correlations for max n_eff + correlations = [ + [1, 0, 0, 0, 0], + [0, 1, 0, 0, 0], + [0, 0, 1, 0, 0], + [0, 0, 0, 1, 0], + [0, 0, 0, 0, 1], + ] + n_eff = compute_n_eff(llrs, correlations=correlations) + cluster_llr = compute_cluster_llr(llrs, n_eff) + + # weighted_mean = 2.0, sqrt(5) ≈ 2.236, raw = 4.47 → clamp to 2.5 + assert cluster_llr == pytest.approx(2.5, rel=1e-6) + + def test_negative_clamp(self): + """Very negative LLRs with high n_eff → clamped to -2.5.""" + llrs = [-2.0, -2.0, -2.0, -2.0, -2.0] + correlations = [ + [1, 0, 0, 0, 0], + [0, 1, 0, 0, 0], + [0, 0, 1, 0, 0], + [0, 0, 0, 1, 0], + [0, 0, 0, 0, 1], + ] + n_eff = compute_n_eff(llrs, correlations=correlations) + cluster_llr = compute_cluster_llr(llrs, n_eff) + + assert cluster_llr == pytest.approx(-2.5, rel=1e-6) + + def test_within_bounds_no_clamp(self): + """Small LLRs with low n_eff → no clamping needed.""" + llrs = [0.3, 0.4] + n_eff = compute_n_eff(llrs) + cluster_llr = compute_cluster_llr(llrs, n_eff) + + assert -2.5 <= cluster_llr <= 2.5 + # Should NOT be at the clamp boundary + assert abs(cluster_llr) < 2.5 + + def test_single_signal_clamp(self): + """Single signal beyond clamp → clamped.""" + llrs = [3.0] + cluster_llr = compute_cluster_llr(llrs, n_eff=1.0) + assert cluster_llr == pytest.approx(2.5, rel=1e-6) + + def test_single_signal_negative_clamp(self): + """Single negative signal beyond clamp → clamped to -2.5.""" + llrs = [-3.0] + cluster_llr = compute_cluster_llr(llrs, n_eff=1.0) + assert cluster_llr == pytest.approx(-2.5, rel=1e-6) + + +# --------------------------------------------------------------------------- +# Test: All-zero LLRs → cluster_llr = 0.0 +# Requirement: 4.4 +# --------------------------------------------------------------------------- + + +class TestClusterLLRZero: + """All-zero LLRs produce zero cluster LLR.""" + + def test_all_zeros(self): + llrs = [0.0, 0.0, 0.0] + n_eff = compute_n_eff(llrs) + cluster_llr = compute_cluster_llr(llrs, n_eff) + assert cluster_llr == 0.0 + + def test_empty_llrs(self): + """Empty LLR list → 0.0.""" + cluster_llr = compute_cluster_llr([], n_eff=1.0) + assert cluster_llr == 0.0 + + +# --------------------------------------------------------------------------- +# Test: Grouping by correct key dimensions (cluster_id) +# Requirement: 4.1 +# --------------------------------------------------------------------------- + + +class TestClusterEvidence: + """cluster_evidence groups EvidenceUnits by cluster_id.""" + + def test_grouping_by_cluster_id(self): + """Units with same cluster_id are grouped together.""" + unit_a1 = _make_unit(cluster_id="cluster_a") + unit_a2 = _make_unit(cluster_id="cluster_a") + unit_b1 = _make_unit(cluster_id="cluster_b") + + units = [unit_a1, unit_a2, unit_b1] + llrs = [1.0, 0.5, -0.3] + + clusters = cluster_evidence(units, llrs) + + assert len(clusters) == 2 + + # Find clusters by id + cluster_map = {c.cluster_id: c for c in clusters} + + assert "cluster_a" in cluster_map + assert "cluster_b" in cluster_map + + # Cluster A has 2 units + assert len(cluster_map["cluster_a"].units) == 2 + assert cluster_map["cluster_a"].llrs == [1.0, 0.5] + + # Cluster B has 1 unit + assert len(cluster_map["cluster_b"].units) == 1 + assert cluster_map["cluster_b"].llrs == [-0.3] + + def test_single_cluster(self): + """All units with same cluster_id → one cluster.""" + units = [_make_unit(cluster_id="only") for _ in range(4)] + llrs = [0.1, 0.2, 0.3, 0.4] + + clusters = cluster_evidence(units, llrs) + + assert len(clusters) == 1 + assert clusters[0].cluster_id == "only" + assert len(clusters[0].units) == 4 + assert clusters[0].llrs == [0.1, 0.2, 0.3, 0.4] + + def test_each_unit_different_cluster(self): + """Each unit in its own cluster → N clusters.""" + units = [_make_unit(cluster_id=f"c_{i}") for i in range(5)] + llrs = [0.1 * i for i in range(5)] + + clusters = cluster_evidence(units, llrs) + + assert len(clusters) == 5 + for c in clusters: + assert len(c.units) == 1 + + def test_empty_input(self): + """No units → no clusters.""" + clusters = cluster_evidence([], []) + assert clusters == [] + + def test_llrs_parallel_to_units(self): + """LLRs are correctly associated with their units.""" + unit_x = _make_unit(cluster_id="x") + unit_y = _make_unit(cluster_id="y") + unit_x2 = _make_unit(cluster_id="x") + + units = [unit_x, unit_y, unit_x2] + llrs = [1.5, -0.7, 2.3] + + clusters = cluster_evidence(units, llrs) + cluster_map = {c.cluster_id: c for c in clusters} + + assert cluster_map["x"].llrs == [1.5, 2.3] + assert cluster_map["y"].llrs == [-0.7] diff --git a/tests/test_v3_confidence.py b/tests/test_v3_confidence.py new file mode 100644 index 0000000..36b63b4 --- /dev/null +++ b/tests/test_v3_confidence.py @@ -0,0 +1,349 @@ +"""Unit tests for v3 multiplicative confidence and data quality. + +Tests for compute_v3_confidence, compute_v3_data_quality, and +should_force_informational_v3 functions. + +Requirements validated: 8.1–8.5, 17.1–17.8 +""" +from __future__ import annotations + +import math +from datetime import datetime, timezone + +import pytest + +from services.aggregation.scoring import EvidenceUnit +from services.aggregation.worker import ( + compute_v3_confidence, + compute_v3_data_quality, + should_force_informational_v3, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_NOW = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc) + + +def _make_unit( + layer: str = "company", + extraction_conf: float = 0.8, + impact: float = 0.7, +) -> EvidenceUnit: + """Create a minimal EvidenceUnit for testing.""" + return EvidenceUnit( + symbol="AAPL", + layer=layer, + event_type="earnings", + source_id="doc-1", + source_group="company", + timestamp=_NOW, + horizon="7d", + direction=1, + sentiment_strength=0.8, + impact=impact, + extraction_conf=extraction_conf, + source_cred=0.85, + novelty=0.9, + event_base_rate=0.25, + cluster_id="test-cluster", + ) + + +# --------------------------------------------------------------------------- +# Test: Zero data quality → zero confidence +# Requirement: 8.1, 8.5 +# --------------------------------------------------------------------------- + + +class TestZeroDataQualityConfidence: + """When data_quality = 0.0, confidence must be 0.0.""" + + def test_zero_data_quality_produces_zero_confidence(self): + confidence = compute_v3_confidence( + n_eff_total=5.0, + q_values=[0.8, 0.7], + llrs=[1.0, 0.5], + strength=0.6, + regime_confidence_mult=1.0, + contradiction=0.0, + data_quality=0.0, + ) + assert confidence == 0.0 + + def test_near_zero_data_quality_suppresses_confidence(self): + """Very low data_quality → near-zero confidence.""" + confidence = compute_v3_confidence( + n_eff_total=10.0, + q_values=[0.9, 0.9], + llrs=[1.5, 1.5], + strength=0.8, + regime_confidence_mult=1.0, + contradiction=0.0, + data_quality=0.01, + ) + assert confidence < 0.05 + + +# --------------------------------------------------------------------------- +# Test: Full contradiction (1.0) → zero confidence +# Requirement: 8.4 +# --------------------------------------------------------------------------- + + +class TestFullContradictionConfidence: + """When contradiction = 1.0, confidence must be 0.0.""" + + def test_full_contradiction_produces_zero_confidence(self): + confidence = compute_v3_confidence( + n_eff_total=10.0, + q_values=[0.9, 0.8], + llrs=[1.0, 1.2], + strength=0.7, + regime_confidence_mult=1.0, + contradiction=1.0, + data_quality=0.9, + ) + assert confidence == 0.0 + + def test_high_contradiction_suppresses_confidence(self): + """Contradiction = 0.9 → confidence heavily suppressed.""" + conf_no_contra = compute_v3_confidence( + n_eff_total=5.0, + q_values=[0.8], + llrs=[1.0], + strength=0.6, + regime_confidence_mult=1.0, + contradiction=0.0, + data_quality=0.8, + ) + conf_high_contra = compute_v3_confidence( + n_eff_total=5.0, + q_values=[0.8], + llrs=[1.0], + strength=0.6, + regime_confidence_mult=1.0, + contradiction=0.9, + data_quality=0.8, + ) + assert conf_high_contra < conf_no_contra * 0.15 + + +# --------------------------------------------------------------------------- +# Test: Low n_eff → suppressed C_evidence +# Requirement: 8.2 +# --------------------------------------------------------------------------- + + +class TestLowNEffConfidence: + """Low n_eff_total → C_evidence is suppressed.""" + + def test_very_low_n_eff_suppresses_c_evidence(self): + """n_eff_total=0.5 → C_evidence = 1 - exp(-0.1) ≈ 0.095.""" + # C_evidence = 1 - exp(-0.5 / 5.0) = 1 - exp(-0.1) ≈ 0.0952 + expected_c_evidence = 1.0 - math.exp(-0.1) + assert expected_c_evidence == pytest.approx(0.0952, rel=1e-2) + + confidence = compute_v3_confidence( + n_eff_total=0.5, + q_values=[0.8], + llrs=[1.0], + strength=0.8, + regime_confidence_mult=1.0, + contradiction=0.0, + data_quality=0.9, + ) + # Confidence is bounded by C_evidence ≈ 0.095 + assert confidence < 0.15 + + def test_high_n_eff_yields_higher_confidence(self): + """Higher n_eff → higher C_evidence → higher overall confidence.""" + conf_low = compute_v3_confidence( + n_eff_total=1.0, + q_values=[0.8], + llrs=[1.0], + strength=0.6, + regime_confidence_mult=1.0, + contradiction=0.0, + data_quality=0.8, + ) + conf_high = compute_v3_confidence( + n_eff_total=10.0, + q_values=[0.8], + llrs=[1.0], + strength=0.6, + regime_confidence_mult=1.0, + contradiction=0.0, + data_quality=0.8, + ) + assert conf_high > conf_low + + +# --------------------------------------------------------------------------- +# Test: Data quality computed from known inputs +# Requirement: 17.1, 17.2, 17.3, 17.4, 17.5, 17.6 +# --------------------------------------------------------------------------- + + +class TestDataQualityComputation: + """Verify data quality formula with known inputs.""" + + def test_high_quality_inputs(self): + """Zero failure, fresh signal, many sources → high quality.""" + units = [_make_unit() for _ in range(10)] + dq = compute_v3_data_quality( + units=units, + extraction_failure_rate=0.0, + age_newest_hours=1.0, + n_source_types=4, + ) + # Q_parse=1.0, Q_fresh=exp(-1/168)≈0.994, Q_coverage=1-exp(-2)≈0.865, + # Q_diversity=min(1, log2(5)/log2(4))=1.0 + assert dq > 0.60 + + def test_high_extraction_failure_rate(self): + """extraction_failure_rate=0.8 → Q_parse=0.2 → low quality.""" + units = [_make_unit() for _ in range(5)] + dq = compute_v3_data_quality( + units=units, + extraction_failure_rate=0.8, + age_newest_hours=1.0, + n_source_types=3, + ) + # Q_parse = 0.2 → heavily suppresses data_quality + assert dq < 0.30 + + def test_zero_sources_zero_diversity(self): + """No source types → Q_diversity = 0 → data_quality = 0.""" + units = [_make_unit() for _ in range(5)] + dq = compute_v3_data_quality( + units=units, + extraction_failure_rate=0.0, + age_newest_hours=1.0, + n_source_types=0, + ) + assert dq == 0.0 + + def test_empty_units_low_coverage(self): + """No valid units → Q_coverage = 1 - exp(0) = 0 → data_quality = 0.""" + dq = compute_v3_data_quality( + units=[], + extraction_failure_rate=0.0, + age_newest_hours=1.0, + n_source_types=3, + ) + assert dq == 0.0 + + def test_stale_signal_decays_quality(self): + """Very old signal → Q_fresh low → suppresses data_quality.""" + units = [_make_unit() for _ in range(5)] + dq_fresh = compute_v3_data_quality( + units=units, + extraction_failure_rate=0.0, + age_newest_hours=1.0, + n_source_types=3, + ) + dq_stale = compute_v3_data_quality( + units=units, + extraction_failure_rate=0.0, + age_newest_hours=500.0, + n_source_types=3, + ) + assert dq_stale < dq_fresh + + +# --------------------------------------------------------------------------- +# Test: Force informational mode +# Requirement: 17.7, 17.8 +# --------------------------------------------------------------------------- + + +class TestForceInformational: + """Test should_force_informational_v3 forcing conditions.""" + + def test_data_quality_below_threshold_forces(self): + """data_quality < 0.50 → forces informational.""" + units = [_make_unit() for _ in range(5)] + forced, reason = should_force_informational_v3( + data_quality=0.49, + units=units, + extraction_failure_rate=0.0, + ) + assert forced is True + assert reason == "data_quality_below_threshold" + + def test_data_quality_at_threshold_does_not_force(self): + """data_quality = 0.50 → does NOT force informational.""" + units = [_make_unit() for _ in range(5)] + forced, reason = should_force_informational_v3( + data_quality=0.50, + units=units, + extraction_failure_rate=0.0, + ) + assert forced is False + + def test_insufficient_evidence_forces(self): + """N_valid < 2 → forces informational.""" + units = [_make_unit()] + forced, reason = should_force_informational_v3( + data_quality=0.80, + units=units, + extraction_failure_rate=0.0, + ) + assert forced is True + assert reason == "insufficient_evidence_count" + + def test_high_extraction_failure_forces(self): + """extraction_failure_rate > 0.50 → Q_parse < 0.50 → forces.""" + units = [_make_unit() for _ in range(5)] + forced, reason = should_force_informational_v3( + data_quality=0.80, + units=units, + extraction_failure_rate=0.51, + ) + assert forced is True + assert reason == "extraction_parse_rate_below_threshold" + + def test_only_macro_signals_forces(self): + """Only macro/competitive evidence (no company) → forces.""" + units = [_make_unit(layer="macro"), _make_unit(layer="competitive")] + forced, reason = should_force_informational_v3( + data_quality=0.80, + units=units, + extraction_failure_rate=0.0, + ) + assert forced is True + assert reason == "macro_competitive_only_evidence" + + def test_only_macro_with_macro_only_enabled_does_not_force(self): + """Only macro evidence WITH macro_only_enabled → does NOT force.""" + units = [_make_unit(layer="macro"), _make_unit(layer="macro")] + forced, reason = should_force_informational_v3( + data_quality=0.80, + units=units, + extraction_failure_rate=0.0, + macro_only_enabled=True, + ) + assert forced is False + + def test_mixed_signals_does_not_force(self): + """Company + macro evidence → does NOT force.""" + units = [_make_unit(layer="company"), _make_unit(layer="macro")] + forced, reason = should_force_informational_v3( + data_quality=0.80, + units=units, + extraction_failure_rate=0.0, + ) + assert forced is False + + def test_good_inputs_do_not_force(self): + """All good → no forcing.""" + units = [_make_unit() for _ in range(5)] + forced, reason = should_force_informational_v3( + data_quality=0.80, + units=units, + extraction_failure_rate=0.1, + ) + assert forced is False + assert reason == "" diff --git a/tests/test_v3_contradiction.py b/tests/test_v3_contradiction.py new file mode 100644 index 0000000..1523672 --- /dev/null +++ b/tests/test_v3_contradiction.py @@ -0,0 +1,176 @@ +"""Unit tests for v3 LLR entropy contradiction score. + +Tests compute_v3_contradiction from services.aggregation.contradiction. + +Requirements validated: 7.1–7.7 +""" +from __future__ import annotations + +import math + +import pytest + +from services.aggregation.contradiction import compute_v3_contradiction +from services.aggregation.worker import EvidenceCluster + + +def _make_cluster(cluster_llr: float) -> EvidenceCluster: + """Helper to create a minimal EvidenceCluster with a given cluster_llr.""" + return EvidenceCluster( + cluster_id="test", units=[], llrs=[], n_eff=1.0, cluster_llr=cluster_llr + ) + + +# --------------------------------------------------------------------------- +# 1. All bullish (unidirectional positive) → contradiction = 0.0 +# --------------------------------------------------------------------------- + + +class TestUnidirectionalPositive: + """When all cluster LLRs are positive, there is no contradiction.""" + + def test_all_bullish(self): + clusters = [_make_cluster(1.0), _make_cluster(0.5), _make_cluster(0.8)] + assert compute_v3_contradiction(clusters) == 0.0 + + def test_single_bullish(self): + clusters = [_make_cluster(2.0)] + assert compute_v3_contradiction(clusters) == 0.0 + + +# --------------------------------------------------------------------------- +# 2. All bearish (unidirectional negative) → contradiction = 0.0 +# --------------------------------------------------------------------------- + + +class TestUnidirectionalNegative: + """When all cluster LLRs are negative, there is no contradiction.""" + + def test_all_bearish(self): + clusters = [_make_cluster(-1.0), _make_cluster(-0.5)] + assert compute_v3_contradiction(clusters) == 0.0 + + def test_single_bearish(self): + clusters = [_make_cluster(-2.5)] + assert compute_v3_contradiction(clusters) == 0.0 + + +# --------------------------------------------------------------------------- +# 3. Equal split → high contradiction near 1.0 +# --------------------------------------------------------------------------- + + +class TestEqualSplit: + """Equal positive and negative evidence produces maximum entropy (H=1.0), + modulated by the volume factor.""" + + def test_equal_split_moderate_mass(self): + """LLR +2.0 and -2.0: E_pos=2, E_neg=2, E_total=4. + H_conflict = 1.0 (50/50 split). + volume_factor = 1 - exp(-4/3) ≈ 0.7364. + contradiction ≈ 0.7364. + """ + clusters = [_make_cluster(2.0), _make_cluster(-2.0)] + result = compute_v3_contradiction(clusters) + expected_volume = 1.0 - math.exp(-4.0 / 3.0) + expected = 1.0 * expected_volume # H_conflict = 1.0 for equal split + assert result == pytest.approx(expected, abs=1e-4) + + def test_equal_split_large_mass(self): + """LLR +5.0 and -5.0: E_total=10, volume_factor → ~0.964. + contradiction near 1.0. + """ + clusters = [_make_cluster(5.0), _make_cluster(-5.0)] + result = compute_v3_contradiction(clusters) + expected_volume = 1.0 - math.exp(-10.0 / 3.0) + expected = 1.0 * expected_volume + assert result == pytest.approx(expected, abs=1e-4) + assert result > 0.9 # Near 1.0 due to large evidence mass + + +# --------------------------------------------------------------------------- +# 4. E_total = 0 → contradiction = 0.0 +# --------------------------------------------------------------------------- + + +class TestZeroEvidence: + """When all cluster LLRs are zero, E_total=0, contradiction is 0.""" + + def test_all_zero_llrs(self): + clusters = [_make_cluster(0.0), _make_cluster(0.0), _make_cluster(0.0)] + assert compute_v3_contradiction(clusters) == 0.0 + + def test_single_zero_llr(self): + clusters = [_make_cluster(0.0)] + assert compute_v3_contradiction(clusters) == 0.0 + + +# --------------------------------------------------------------------------- +# 5. Empty clusters → contradiction = 0.0 +# --------------------------------------------------------------------------- + + +class TestEmptyClusters: + """Empty cluster list returns 0.0.""" + + def test_empty_list(self): + assert compute_v3_contradiction([]) == 0.0 + + +# --------------------------------------------------------------------------- +# 6. Small evidence mass → suppressed score (volume_factor effect) +# --------------------------------------------------------------------------- + + +class TestSmallEvidenceMass: + """Small E_total leads to a small volume_factor that suppresses the score.""" + + def test_tiny_equal_split(self): + """LLR +0.1 and -0.1: E_total=0.2. + H_conflict = 1.0 (equal split). + volume_factor = 1 - exp(-0.2/3) ≈ 0.0645. + contradiction ≈ 0.0645 (suppressed). + """ + clusters = [_make_cluster(0.1), _make_cluster(-0.1)] + result = compute_v3_contradiction(clusters) + expected_volume = 1.0 - math.exp(-0.2 / 3.0) + expected = 1.0 * expected_volume + assert result == pytest.approx(expected, abs=1e-4) + # Confirm suppression: score well below 0.1 + assert result < 0.1 + + +# --------------------------------------------------------------------------- +# 7. Large evidence mass → volume_factor approaches 1.0 +# --------------------------------------------------------------------------- + + +class TestLargeEvidenceMass: + """Large E_total pushes volume_factor near 1.0, so score ≈ H_conflict.""" + + def test_large_equal_split(self): + """LLR +5.0 and -5.0: E_total=10, volume_factor ≈ 0.964. + contradiction ≈ 0.964 (near maximum). + """ + clusters = [_make_cluster(5.0), _make_cluster(-5.0)] + result = compute_v3_contradiction(clusters) + # Volume factor should be very close to 1.0 + volume_factor = 1.0 - math.exp(-10.0 / 3.0) + assert volume_factor > 0.95 + assert result > 0.95 + + def test_asymmetric_large_mass(self): + """LLR +4.0 and -1.0: E_pos=4, E_neg=1, E_total=5. + f_pos=0.8, f_neg=0.2. + H_conflict = -0.8×log2(0.8) - 0.2×log2(0.2) ≈ 0.7219. + volume_factor = 1 - exp(-5/3) ≈ 0.8111. + contradiction ≈ 0.586. + """ + clusters = [_make_cluster(4.0), _make_cluster(-1.0)] + result = compute_v3_contradiction(clusters) + f_pos = 4.0 / 5.0 + f_neg = 1.0 / 5.0 + h_conflict = -f_pos * math.log2(f_pos) - f_neg * math.log2(f_neg) + volume_factor = 1.0 - math.exp(-5.0 / 3.0) + expected = h_conflict * volume_factor + assert result == pytest.approx(expected, abs=1e-4) diff --git a/tests/test_v3_eligibility.py b/tests/test_v3_eligibility.py new file mode 100644 index 0000000..1f76140 --- /dev/null +++ b/tests/test_v3_eligibility.py @@ -0,0 +1,629 @@ +"""Unit tests for v3 EV gate, return distribution, and eligibility. + +Tests the return distribution computation, regime-specific min_edge thresholds, +mode escalation logic, posterior state projection, and divergence detection. + +Requirements validated: 11.1–11.7, 12.1–12.7, 13.1–13.5 +""" +from __future__ import annotations + +import math + +import pytest + +from services.aggregation.projection import V3ProjectionState, compute_v3_projection +from services.aggregation.regime import MarketRegime, V3RegimeClassification +from services.recommendation.eligibility import ( + ReturnDistribution, + V3Eligibility, + compute_return_distribution, + compute_v3_eligibility, +) + + +# --------------------------------------------------------------------------- +# Helper: construct a V3RegimeClassification for tests +# --------------------------------------------------------------------------- + +def _make_regime(regime: MarketRegime, phi: float = 0.50) -> V3RegimeClassification: + """Create a minimal V3RegimeClassification for testing.""" + params = { + MarketRegime.PANIC: (0.70, 0.70, 0.35, 2.5), + MarketRegime.TREND_FOLLOWING: (1.10, 1.00, 0.80, 1.8), + MarketRegime.MEAN_REVERSION: (0.90, 0.95, 0.55, 1.4), + MarketRegime.UNCERTAINTY: (0.80, 0.85, 0.50, 2.0), + } + gamma, conf_mult, phi_val, atr_mult = params[regime] + return V3RegimeClassification( + regime=regime, + trend_z=0.0, + vol_ratio=1.0, + evidence_multiplier=gamma, + confidence_multiplier=conf_mult, + phi_decay=phi_val, + atr_multiplier=atr_mult, + ) + + +# --------------------------------------------------------------------------- +# EV positive → eligible (Req 12.1–12.7) +# --------------------------------------------------------------------------- + + +class TestEVPositiveEligible: + """Tests that positive EV with passing quality gates → eligible=True.""" + + def test_strong_signal_trend_following(self): + """a_projected=2.0, conf=0.8, vol=0.25, h=7, costs=0.001, trend_following → eligible.""" + result = compute_return_distribution( + a_projected=2.0, + confidence=0.8, + realized_vol_20d=0.25, + horizon_days=7, + costs=0.001, + regime="trend_following", + confidence_actual=0.8, + contradiction=0.1, + n_eff_total=5.0, + data_quality=0.8, + ) + assert isinstance(result, ReturnDistribution) + assert result.ev_long > 0.0 + assert result.ev_long > result.min_edge + assert result.eligible is True + # Verify min_edge for trend_following + assert result.min_edge == pytest.approx(0.0035, abs=1e-9) + + def test_sigma_h_formula(self): + """Verify sigma_h = realized_vol * sqrt(horizon / 252).""" + result = compute_return_distribution( + a_projected=2.0, + confidence=0.8, + realized_vol_20d=0.25, + horizon_days=7, + costs=0.001, + regime="trend_following", + confidence_actual=0.8, + contradiction=0.1, + n_eff_total=5.0, + data_quality=0.8, + ) + expected_sigma_h = 0.25 * math.sqrt(7 / 252.0) + assert result.sigma_h == pytest.approx(expected_sigma_h, rel=1e-9) + + def test_mu_h_formula(self): + """Verify mu_h = tanh(A_projected / 3.0) * confidence * sigma_h.""" + result = compute_return_distribution( + a_projected=2.0, + confidence=0.8, + realized_vol_20d=0.25, + horizon_days=7, + costs=0.001, + regime="trend_following", + confidence_actual=0.8, + contradiction=0.1, + n_eff_total=5.0, + data_quality=0.8, + ) + sigma_h = 0.25 * math.sqrt(7 / 252.0) + expected_mu_h = math.tanh(2.0 / 3.0) * 0.8 * sigma_h + assert result.mu_h == pytest.approx(expected_mu_h, rel=1e-9) + + +# --------------------------------------------------------------------------- +# EV negative → ineligible (Req 12.3–12.5) +# --------------------------------------------------------------------------- + + +class TestEVNegativeIneligible: + """Tests that weak signals with negative or sub-threshold EV → ineligible.""" + + def test_weak_signal_high_costs(self): + """a_projected=0.01, conf=0.3, costs=0.01 → EV < min_edge → ineligible.""" + result = compute_return_distribution( + a_projected=0.01, + confidence=0.3, + realized_vol_20d=0.25, + horizon_days=7, + costs=0.01, + regime="uncertainty", + confidence_actual=0.3, + contradiction=0.5, + n_eff_total=1.0, + data_quality=0.4, + ) + # Weak signal: tanh(0.01/3) ≈ 0.0033, * 0.3 * sigma_h is tiny + # Costs + CVaR should dominate → EV negative + assert result.ev_long < result.min_edge + assert result.eligible is False + + def test_zero_alpha_negative_ev(self): + """a_projected=0 → mu_h=0, then costs + CVaR push EV negative.""" + result = compute_return_distribution( + a_projected=0.0, + confidence=0.5, + realized_vol_20d=0.25, + horizon_days=7, + costs=0.001, + regime="trend_following", + confidence_actual=0.7, + contradiction=0.1, + n_eff_total=5.0, + data_quality=0.8, + ) + # mu_h = tanh(0) * ... = 0. EV = 0 - costs - 0.10*CVaR < 0 + assert result.mu_h == pytest.approx(0.0, abs=1e-12) + assert result.ev_long < 0.0 + assert result.eligible is False + + def test_quality_gate_fails_despite_positive_ev(self): + """Strong EV but low n_eff → ineligible (quality gate blocks).""" + result = compute_return_distribution( + a_projected=3.0, + confidence=0.9, + realized_vol_20d=0.25, + horizon_days=7, + costs=0.001, + regime="trend_following", + confidence_actual=0.9, + contradiction=0.1, + n_eff_total=1.0, # Below 2.0 threshold + data_quality=0.8, + ) + # EV should be positive, but n_eff < 2.0 fails quality gate + assert result.ev_long > 0.0 + assert result.eligible is False + + +# --------------------------------------------------------------------------- +# Regime-specific min_edge thresholds (Req 12.4) +# --------------------------------------------------------------------------- + + +class TestRegimeMinEdge: + """Tests that regime-specific min_edge values are correct.""" + + def test_panic_min_edge_strictest(self): + """Panic regime has min_edge = 0.0100 (strictest).""" + result = compute_return_distribution( + a_projected=1.0, + confidence=0.5, + realized_vol_20d=0.25, + horizon_days=7, + costs=0.001, + regime="panic", + confidence_actual=0.7, + contradiction=0.2, + n_eff_total=3.0, + data_quality=0.8, + ) + assert result.min_edge == pytest.approx(0.0100, abs=1e-9) + + def test_trend_following_min_edge_most_lenient(self): + """Trend following has min_edge = 0.0035 (most lenient).""" + result = compute_return_distribution( + a_projected=1.0, + confidence=0.5, + realized_vol_20d=0.25, + horizon_days=7, + costs=0.001, + regime="trend_following", + confidence_actual=0.7, + contradiction=0.2, + n_eff_total=3.0, + data_quality=0.8, + ) + assert result.min_edge == pytest.approx(0.0035, abs=1e-9) + + def test_mean_reversion_min_edge(self): + """Mean reversion has min_edge = 0.0050.""" + result = compute_return_distribution( + a_projected=1.0, + confidence=0.5, + realized_vol_20d=0.25, + horizon_days=7, + costs=0.001, + regime="mean_reversion", + confidence_actual=0.7, + contradiction=0.2, + n_eff_total=3.0, + data_quality=0.8, + ) + assert result.min_edge == pytest.approx(0.0050, abs=1e-9) + + def test_uncertainty_min_edge(self): + """Uncertainty has min_edge = 0.0075.""" + result = compute_return_distribution( + a_projected=1.0, + confidence=0.5, + realized_vol_20d=0.25, + horizon_days=7, + costs=0.001, + regime="uncertainty", + confidence_actual=0.7, + contradiction=0.2, + n_eff_total=3.0, + data_quality=0.8, + ) + assert result.min_edge == pytest.approx(0.0075, abs=1e-9) + + def test_unknown_regime_defaults_to_uncertainty(self): + """Unknown regime string → falls back to uncertainty min_edge.""" + result = compute_return_distribution( + a_projected=1.0, + confidence=0.5, + realized_vol_20d=0.25, + horizon_days=7, + costs=0.001, + regime="nonexistent_regime", + confidence_actual=0.7, + contradiction=0.2, + n_eff_total=3.0, + data_quality=0.8, + ) + assert result.min_edge == pytest.approx(0.0075, abs=1e-9) + + +# --------------------------------------------------------------------------- +# Mode escalation: live vs paper vs informational (Req 13.1–13.5) +# --------------------------------------------------------------------------- + + +class TestModeEscalation: + """Tests for v3 mode escalation logic.""" + + def test_live_eligible(self): + """BUY with high conf/low contra/high n_eff/EV >> min_edge → live.""" + result = compute_v3_eligibility( + p_up=0.75, + ev_long=0.020, # >> 2 * 0.0035 = 0.0070 + min_edge=0.0035, + confidence=0.80, + contradiction=0.10, + strength=0.50, + n_eff_total=6.0, + data_quality=0.85, + regime="trend_following", + has_existing_position=False, + risk_engine_passed=True, + ) + assert isinstance(result, V3Eligibility) + assert result.action == "BUY" + assert result.mode == "live" + assert result.eligible is True + + def test_paper_eligible(self): + """BUY with moderate confidence → paper.""" + result = compute_v3_eligibility( + p_up=0.70, + ev_long=0.010, # > min_edge but < 2 * min_edge for live + min_edge=0.0035, + confidence=0.65, # >= 0.60 for paper but < 0.75 for live + contradiction=0.15, + strength=0.40, + n_eff_total=4.0, + data_quality=0.80, + regime="trend_following", + has_existing_position=False, + risk_engine_passed=True, + ) + assert result.action == "BUY" + assert result.mode == "paper" + assert result.eligible is True + + def test_informational_low_confidence(self): + """BUY with low confidence → informational.""" + result = compute_v3_eligibility( + p_up=0.70, + ev_long=0.010, + min_edge=0.0035, + confidence=0.55, # >= regime min but < 0.60 for paper + contradiction=0.15, + strength=0.40, + n_eff_total=4.0, + data_quality=0.80, + regime="trend_following", + has_existing_position=False, + risk_engine_passed=True, + ) + assert result.action == "BUY" + assert result.mode == "informational" + + def test_hold_always_informational(self): + """HOLD action is always informational regardless of quality.""" + result = compute_v3_eligibility( + p_up=0.55, # Below bullish threshold for trend_following (0.60) + ev_long=0.020, + min_edge=0.0035, + confidence=0.90, + contradiction=0.05, + strength=0.50, + n_eff_total=10.0, + data_quality=0.95, + regime="trend_following", + has_existing_position=True, + risk_engine_passed=True, + ) + assert result.action == "HOLD" + assert result.mode == "informational" + + def test_watch_when_ineligible(self): + """Low confidence below regime min → WATCH.""" + result = compute_v3_eligibility( + p_up=0.80, + ev_long=0.020, + min_edge=0.0035, + confidence=0.40, # Below trend_following min of 0.55 + contradiction=0.10, + strength=0.60, + n_eff_total=5.0, + data_quality=0.80, + regime="trend_following", + has_existing_position=False, + risk_engine_passed=True, + ) + assert result.action == "WATCH" + assert result.eligible is False + + def test_risk_engine_blocks_live(self): + """Risk engine failure blocks live but allows paper.""" + result = compute_v3_eligibility( + p_up=0.75, + ev_long=0.020, + min_edge=0.0035, + confidence=0.80, + contradiction=0.10, + strength=0.50, + n_eff_total=6.0, + data_quality=0.85, + regime="trend_following", + has_existing_position=False, + risk_engine_passed=False, + ) + # Both live and paper require risk_engine_passed + assert result.action == "BUY" + assert result.mode == "informational" + + def test_sell_on_negative_ev_with_position(self): + """Existing position with negative EV → SELL.""" + result = compute_v3_eligibility( + p_up=0.35, # bearish + ev_long=-0.005, + min_edge=0.0035, + confidence=0.70, + contradiction=0.15, + strength=0.30, + n_eff_total=5.0, + data_quality=0.80, + regime="trend_following", + has_existing_position=True, + risk_engine_passed=True, + ) + assert result.action == "SELL" + + +# --------------------------------------------------------------------------- +# Projection decay convergence (Req 11.1–11.4) +# --------------------------------------------------------------------------- + + +class TestProjectionDecay: + """Tests for compute_v3_projection decay behavior.""" + + def test_evidence_accumulates(self): + """cluster_llrs=[1.0, 0.5] → A_t = phi*A_prev + 1.5.""" + regime = _make_regime(MarketRegime.TREND_FOLLOWING) + result = compute_v3_projection( + a_prev=0.0, + cluster_llrs=[1.0, 0.5], + regime=regime, + p_prior=0.50, + projection_horizon=1, + ) + # A_t = 0.80 * 0.0 + 1.5 = 1.5 + assert result.a_t == pytest.approx(1.5, abs=1e-9) + # P_up_projected should be > 0.5 (bullish evidence) + assert result.p_up_projected > 0.5 + + def test_projection_horizon_decays(self): + """Higher projection_horizon → stronger decay → closer to prior.""" + regime = _make_regime(MarketRegime.TREND_FOLLOWING) + # phi=0.80, horizon=5 → phi^5 = 0.32768 + result_h1 = compute_v3_projection( + a_prev=0.0, + cluster_llrs=[2.0], + regime=regime, + p_prior=0.50, + projection_horizon=1, + ) + result_h5 = compute_v3_projection( + a_prev=0.0, + cluster_llrs=[2.0], + regime=regime, + p_prior=0.50, + projection_horizon=5, + ) + # Longer horizon → more decay → projected_strength should be lower + assert result_h5.projected_strength < result_h1.projected_strength + # Both still bullish + assert result_h1.p_up_projected > 0.5 + assert result_h5.p_up_projected > 0.5 + + def test_panic_decays_faster_than_trend(self): + """Panic (phi=0.35) decays much faster than trend_following (phi=0.80).""" + regime_panic = _make_regime(MarketRegime.PANIC) + regime_trend = _make_regime(MarketRegime.TREND_FOLLOWING) + + result_panic = compute_v3_projection( + a_prev=0.0, + cluster_llrs=[2.0], + regime=regime_panic, + p_prior=0.50, + projection_horizon=3, + ) + result_trend = compute_v3_projection( + a_prev=0.0, + cluster_llrs=[2.0], + regime=regime_trend, + p_prior=0.50, + projection_horizon=3, + ) + # Trend should retain more signal after projection + assert result_trend.projected_strength > result_panic.projected_strength + + def test_phi_regime_stored(self): + """Result stores the correct phi_regime value.""" + regime = _make_regime(MarketRegime.MEAN_REVERSION) + result = compute_v3_projection( + a_prev=0.0, + cluster_llrs=[1.0], + regime=regime, + p_prior=0.50, + projection_horizon=1, + ) + assert result.phi_regime == pytest.approx(0.55, abs=1e-9) + + def test_a_prev_contributes(self): + """Non-zero a_prev gets decayed and added to new evidence.""" + regime = _make_regime(MarketRegime.UNCERTAINTY) # phi=0.50 + result = compute_v3_projection( + a_prev=2.0, + cluster_llrs=[1.0], + regime=regime, + p_prior=0.50, + projection_horizon=1, + ) + # A_t = 0.50 * 2.0 + 1.0 = 2.0 + assert result.a_t == pytest.approx(2.0, abs=1e-9) + + +# --------------------------------------------------------------------------- +# Divergence flag behavior (Req 11.6) +# --------------------------------------------------------------------------- + + +class TestDivergenceFlag: + """Tests for divergence detection between current and projected P_up.""" + + def test_no_divergence_same_direction(self): + """Bullish current and projected → diverges=False.""" + regime = _make_regime(MarketRegime.TREND_FOLLOWING) + result = compute_v3_projection( + a_prev=0.0, + cluster_llrs=[2.0], + regime=regime, + p_prior=0.50, + projection_horizon=1, + ) + # Both current P_up_t and projected should be > 0.5 (bullish) + assert result.diverges is False + + def test_divergence_strong_decay(self): + """Bullish current but projected decay crosses 0.5 boundary → diverges=True.""" + # Use panic regime (phi=0.35) with small evidence and large projection horizon + regime = _make_regime(MarketRegime.PANIC) + # A_t = 0.35 * 0 + 0.1 = 0.1 → P_up_t > 0.5 (bullish) + # A_projected = 0.35^20 * 0.1 ≈ 0 → P_up_projected ≈ 0.5 + # Need negative catalyst to push projected below 0.5 + result = compute_v3_projection( + a_prev=0.0, + cluster_llrs=[0.5], # Mild bullish evidence + regime=regime, + p_prior=0.50, + projection_horizon=20, + known_catalyst_llr=-1.0, # Bearish catalyst flips projected direction + ) + # Current: A_t = 0.5, P_up_t = sigmoid(0.5) > 0.5 (bullish) + # Projected: phi^20 * 0.5 - 1.0 = practically -1.0 → P_up_projected < 0.5 + assert result.diverges is True + + def test_no_divergence_both_bearish(self): + """Bearish current and projected → diverges=False.""" + regime = _make_regime(MarketRegime.TREND_FOLLOWING) + result = compute_v3_projection( + a_prev=0.0, + cluster_llrs=[-2.0], + regime=regime, + p_prior=0.50, + projection_horizon=1, + ) + # Both should be < 0.5 (bearish) + assert result.p_up_projected < 0.5 + assert result.diverges is False + + def test_known_catalyst_shifts_projection(self): + """known_catalyst_llr adds to projected alpha.""" + regime = _make_regime(MarketRegime.TREND_FOLLOWING) + result_no_catalyst = compute_v3_projection( + a_prev=0.0, + cluster_llrs=[1.0], + regime=regime, + p_prior=0.50, + projection_horizon=1, + known_catalyst_llr=0.0, + ) + result_with_catalyst = compute_v3_projection( + a_prev=0.0, + cluster_llrs=[1.0], + regime=regime, + p_prior=0.50, + projection_horizon=1, + known_catalyst_llr=1.0, + ) + # Catalyst boosts projected P_up + assert result_with_catalyst.p_up_projected > result_no_catalyst.p_up_projected + + +# --------------------------------------------------------------------------- +# Default vol (Req 12.7) +# --------------------------------------------------------------------------- + + +class TestDefaultVolatility: + """Tests that realized_vol_20d=0 defaults to 0.25.""" + + def test_zero_vol_uses_default(self): + """realized_vol_20d=0 → uses 0.25 default.""" + result_zero = compute_return_distribution( + a_projected=2.0, + confidence=0.8, + realized_vol_20d=0, + horizon_days=7, + costs=0.001, + regime="trend_following", + confidence_actual=0.8, + contradiction=0.1, + n_eff_total=5.0, + data_quality=0.8, + ) + result_default = compute_return_distribution( + a_projected=2.0, + confidence=0.8, + realized_vol_20d=0.25, + horizon_days=7, + costs=0.001, + regime="trend_following", + confidence_actual=0.8, + contradiction=0.1, + n_eff_total=5.0, + data_quality=0.8, + ) + assert result_zero.sigma_h == pytest.approx(result_default.sigma_h, abs=1e-12) + assert result_zero.ev_long == pytest.approx(result_default.ev_long, abs=1e-12) + + def test_negative_vol_uses_default(self): + """realized_vol_20d=-0.1 → uses 0.25 default.""" + result = compute_return_distribution( + a_projected=2.0, + confidence=0.8, + realized_vol_20d=-0.1, + horizon_days=7, + costs=0.001, + regime="trend_following", + confidence_actual=0.8, + contradiction=0.1, + n_eff_total=5.0, + data_quality=0.8, + ) + expected_sigma_h = 0.25 * math.sqrt(7 / 252.0) + assert result.sigma_h == pytest.approx(expected_sigma_h, rel=1e-9) diff --git a/tests/test_v3_evidence_unit.py b/tests/test_v3_evidence_unit.py new file mode 100644 index 0000000..ad52a6c --- /dev/null +++ b/tests/test_v3_evidence_unit.py @@ -0,0 +1,569 @@ +"""Unit tests for EvidenceUnit normalization and LLR conversion. + +Validates: Requirements 1.1–1.8, 2.1–2.9, 3.1–3.6 +""" +from __future__ import annotations + +import math +from datetime import datetime, timezone + +import pytest + +from services.aggregation.scoring import ( + EvidenceUnit, + ReliabilityComponents, + SourceStats, + compute_llr, + compute_v3_reliability, + normalize_company_signal, + normalize_competitive_signal, + normalize_macro_signal, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +_NOW = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc) + + +def _make_company_signal(**overrides) -> dict: + """Create a minimal valid company signal dict.""" + base = { + "symbol": "AAPL", + "timestamp": _NOW, + "source_id": "doc-001", + "event_type": "earnings", + "source_group": "company", + "horizon": "7d", + "sentiment": "positive", + "sentiment_strength": 0.8, + "impact": 0.7, + "extraction_conf": 0.9, + "source_cred": 0.85, + "novelty": 0.9, + } + base.update(overrides) + return base + + +def _make_macro_signal(**overrides) -> dict: + """Create a minimal valid macro signal dict.""" + base = { + "symbol": "MSFT", + "timestamp": _NOW, + "source_id": "event-100", + "event_type": "regulatory", + "impact_direction": "positive", + "macro_impact_score": 0.6, + "event_confidence": 0.75, + "estimated_duration": "medium_term", + } + base.update(overrides) + return base + + +def _make_competitive_signal(**overrides) -> dict: + """Create a minimal valid competitive signal dict.""" + base = { + "symbol": "GOOG", + "timestamp": _NOW, + "source_id": "comp-doc-55", + "event_type": "product_launch", + "signal_direction": "bearish", + "signal_strength": 0.7, + "relationship_strength": 0.8, + "pattern_confidence": 0.65, + "time_horizon": "30d", + } + base.update(overrides) + return base + + +# =========================================================================== +# TestNormalizeCompanySignal +# =========================================================================== + + +class TestNormalizeCompanySignal: + """Test normalize_company_signal mapping and validation.""" + + def test_full_company_signal(self): + """A complete company signal maps all fields correctly.""" + sig = _make_company_signal() + eu = normalize_company_signal(sig) + + assert eu is not None + assert eu.symbol == "AAPL" + assert eu.layer == "company" + assert eu.event_type == "earnings" + assert eu.source_id == "doc-001" + assert eu.source_group == "company" + assert eu.timestamp == _NOW + assert eu.horizon == "7d" + assert eu.direction == 1 # "positive" → +1 + assert eu.sentiment_strength == 0.8 + assert eu.impact == 0.7 + assert eu.extraction_conf == 0.9 + assert eu.source_cred == 0.85 + assert eu.novelty == 0.9 + assert eu.event_base_rate == 0.25 # earnings base rate + assert len(eu.cluster_id) == 16 # sha256 hex prefix + + def test_missing_symbol_rejected(self): + """Missing symbol → returns None with warning.""" + sig = _make_company_signal(symbol=None) + assert normalize_company_signal(sig) is None + + def test_missing_timestamp_rejected(self): + """Missing timestamp → returns None with warning.""" + sig = _make_company_signal(timestamp=None) + assert normalize_company_signal(sig) is None + + def test_missing_source_id_rejected(self): + """Missing source_id → returns None with warning.""" + sig = _make_company_signal(source_id=None) + assert normalize_company_signal(sig) is None + + def test_empty_string_symbol_rejected(self): + """Empty string symbol → returns None (falsy check).""" + sig = _make_company_signal(symbol="") + assert normalize_company_signal(sig) is None + + def test_direction_mappings(self): + """Direction string mappings: positive→+1, negative→-1, neutral→0.""" + for sentiment, expected in [ + ("positive", 1), + ("negative", -1), + ("neutral", 0), + ("bullish", 1), + ("bearish", -1), + ("mixed", 0), + ]: + eu = normalize_company_signal(_make_company_signal(sentiment=sentiment)) + assert eu is not None + assert eu.direction == expected, f"'{sentiment}' should map to {expected}" + + def test_missing_optional_fields_default_0_5(self): + """Missing optional numeric fields substitute 0.5.""" + sig = { + "symbol": "TSLA", + "timestamp": _NOW, + "source_id": "doc-xyz", + } + eu = normalize_company_signal(sig) + + assert eu is not None + assert eu.sentiment_strength == 0.5 + assert eu.impact == 0.5 + assert eu.extraction_conf == 0.5 + assert eu.source_cred == 0.5 + assert eu.novelty == 0.5 + + def test_invalid_horizon_defaults_to_7d(self): + """Invalid horizon string falls back to '7d'.""" + sig = _make_company_signal(horizon="invalid_horizon") + eu = normalize_company_signal(sig) + assert eu is not None + assert eu.horizon == "7d" + + def test_timestamp_string_parsed(self): + """ISO timestamp string is parsed to datetime.""" + sig = _make_company_signal(timestamp="2025-01-10T08:00:00+00:00") + eu = normalize_company_signal(sig) + assert eu is not None + assert eu.timestamp == datetime(2025, 1, 10, 8, 0, 0, tzinfo=timezone.utc) + + def test_unknown_event_type_uses_default_base_rate(self): + """Unknown event_type uses default base rate of 0.10.""" + sig = _make_company_signal(event_type="mysterious_event") + eu = normalize_company_signal(sig) + assert eu is not None + assert eu.event_base_rate == 0.10 + + +# =========================================================================== +# TestNormalizeMacroSignal +# =========================================================================== + + +class TestNormalizeMacroSignal: + """Test normalize_macro_signal mapping and validation.""" + + def test_full_macro_signal(self): + """A complete macro signal maps all fields correctly.""" + sig = _make_macro_signal() + eu = normalize_macro_signal(sig) + + assert eu is not None + assert eu.symbol == "MSFT" + assert eu.layer == "macro" + assert eu.source_group == "macro" + assert eu.direction == 1 # "positive" → +1 + assert eu.impact == 0.6 # macro_impact_score + assert eu.source_cred == 0.75 # event_confidence + assert eu.extraction_conf == 0.75 # event_confidence + assert eu.novelty == 1.0 # default for new events + + def test_horizon_short_term(self): + """short_term → 7d.""" + sig = _make_macro_signal(estimated_duration="short_term") + eu = normalize_macro_signal(sig) + assert eu is not None + assert eu.horizon == "7d" + + def test_horizon_medium_term(self): + """medium_term → 30d.""" + sig = _make_macro_signal(estimated_duration="medium_term") + eu = normalize_macro_signal(sig) + assert eu is not None + assert eu.horizon == "30d" + + def test_horizon_long_term(self): + """long_term → 90d.""" + sig = _make_macro_signal(estimated_duration="long_term") + eu = normalize_macro_signal(sig) + assert eu is not None + assert eu.horizon == "90d" + + def test_missing_symbol_rejected(self): + """Missing symbol in macro signal → None.""" + sig = _make_macro_signal() + del sig["symbol"] + assert normalize_macro_signal(sig) is None + + def test_ticker_alias_accepted(self): + """'ticker' key is accepted as alias for 'symbol'.""" + sig = _make_macro_signal() + del sig["symbol"] + sig["ticker"] = "AMZN" + eu = normalize_macro_signal(sig) + assert eu is not None + assert eu.symbol == "AMZN" + + def test_event_id_alias_accepted(self): + """'event_id' key is accepted as alias for 'source_id'.""" + sig = _make_macro_signal() + del sig["source_id"] + sig["event_id"] = "global-evt-42" + eu = normalize_macro_signal(sig) + assert eu is not None + assert eu.source_id == "global-evt-42" + + def test_direction_mapping_negative(self): + """Negative impact_direction → direction = -1.""" + sig = _make_macro_signal(impact_direction="negative") + eu = normalize_macro_signal(sig) + assert eu is not None + assert eu.direction == -1 + + def test_direction_mapping_neutral(self): + """Neutral impact_direction → direction = 0.""" + sig = _make_macro_signal(impact_direction="neutral") + eu = normalize_macro_signal(sig) + assert eu is not None + assert eu.direction == 0 + + +# =========================================================================== +# TestNormalizeCompetitiveSignal +# =========================================================================== + + +class TestNormalizeCompetitiveSignal: + """Test normalize_competitive_signal mapping and validation.""" + + def test_full_competitive_signal(self): + """A complete competitive signal maps all fields correctly.""" + sig = _make_competitive_signal() + eu = normalize_competitive_signal(sig) + + assert eu is not None + assert eu.symbol == "GOOG" + assert eu.layer == "competitive" + assert eu.source_group == "competitive" + assert eu.horizon == "30d" + assert eu.direction == -1 # "bearish" → -1 + + def test_impact_is_product_of_strengths(self): + """Impact = signal_strength × relationship_strength.""" + sig = _make_competitive_signal(signal_strength=0.7, relationship_strength=0.8) + eu = normalize_competitive_signal(sig) + assert eu is not None + assert abs(eu.impact - 0.56) < 1e-9 # 0.7 × 0.8 + + def test_source_cred_from_pattern_confidence(self): + """source_cred mapped from pattern_confidence.""" + sig = _make_competitive_signal(pattern_confidence=0.65) + eu = normalize_competitive_signal(sig) + assert eu is not None + assert eu.source_cred == 0.65 + assert eu.extraction_conf == 0.65 + + def test_direction_bullish(self): + """signal_direction='bullish' → direction = +1.""" + sig = _make_competitive_signal(signal_direction="bullish") + eu = normalize_competitive_signal(sig) + assert eu is not None + assert eu.direction == 1 + + def test_direction_neutral(self): + """signal_direction='neutral' → direction = 0.""" + sig = _make_competitive_signal(signal_direction="neutral") + eu = normalize_competitive_signal(sig) + assert eu is not None + assert eu.direction == 0 + + def test_novelty_defaults_to_1(self): + """Novelty defaults to 1.0 for competitive signals.""" + sig = _make_competitive_signal() + # Ensure no explicit novelty key + sig.pop("novelty", None) + eu = normalize_competitive_signal(sig) + assert eu is not None + assert eu.novelty == 1.0 + + def test_missing_required_source_id_rejected(self): + """Missing source_id in competitive signal → None.""" + sig = _make_competitive_signal(source_id=None) + # Also ensure alias key is absent + sig.pop("source_document_id", None) + assert normalize_competitive_signal(sig) is None + + def test_target_ticker_alias(self): + """'target_ticker' key accepted as alias for 'symbol'.""" + sig = _make_competitive_signal() + del sig["symbol"] + sig["target_ticker"] = "META" + eu = normalize_competitive_signal(sig) + assert eu is not None + assert eu.symbol == "META" + + def test_time_horizon_short_term_maps_to_7d(self): + """Competitive time_horizon='short_term' maps to '7d' via macro map.""" + sig = _make_competitive_signal(time_horizon="short_term") + eu = normalize_competitive_signal(sig) + assert eu is not None + assert eu.horizon == "7d" + + +# =========================================================================== +# TestReliabilityPipeline +# =========================================================================== + + +class TestReliabilityPipeline: + """Test compute_v3_reliability with known inputs.""" + + def test_known_inputs_perfect_signal(self): + """Perfect inputs (source_cred=1, extraction_conf=1, novelty=1, fresh, no duplicates) → q_i close to 1.""" + unit = EvidenceUnit( + symbol="AAPL", + layer="company", + event_type="earnings", + source_id="doc-perfect", + source_group="company", + timestamp=_NOW, + horizon="7d", + direction=1, + sentiment_strength=1.0, + impact=1.0, + extraction_conf=1.0, + source_cred=1.0, + novelty=1.0, + event_base_rate=0.25, + cluster_id="test-cluster", + ) + # Source with strong track record + stats = SourceStats(source_id="doc-perfect", hits=50, misses=0) + # Fresh signal (0 age) + rel = compute_v3_reliability(unit, stats, cluster_position=0, reference_time=_NOW) + + # q_ext: sigmoid(8.0 * (1.0 - 0.55)) = sigmoid(3.6) ≈ 0.9734 + assert rel.q_ext > 0.95 + + # q_source: E[theta] = (3+50)/(3+3+50+0) = 53/56 ≈ 0.946 + # clamp((0.946 - 0.50) / 0.35, 0, 1) = clamp(1.274, 0, 1) = 1.0 + assert rel.q_source == 1.0 + + # q_recency: fresh signal → 2^0 = 1.0 + assert rel.q_recency == 1.0 + + # q_uniqueness: clamp(0.5 + 0.5*1.0, 0.5, 1.0) * 1/sqrt(1) = 1.0 + assert rel.q_uniqueness == 1.0 + + # q_i should be close to 1 (bounded by q_ext ≈ 0.97) + assert rel.q_i > 0.90 + + def test_zero_history_source_yields_zero_q_source(self): + """A source with zero history (hits=0, misses=0) → q_source = 0.0 (Req 2.3).""" + unit = EvidenceUnit( + symbol="AAPL", + layer="company", + event_type="earnings", + source_id="new-source", + source_group="company", + timestamp=_NOW, + horizon="7d", + direction=1, + sentiment_strength=0.8, + impact=0.7, + extraction_conf=0.9, + source_cred=0.85, + novelty=0.9, + event_base_rate=0.25, + cluster_id="test-cluster", + ) + stats = SourceStats(source_id="new-source", hits=0, misses=0) + rel = compute_v3_reliability(unit, stats, cluster_position=0, reference_time=_NOW) + + # E[theta] = 3/(3+3) = 0.5; clamp((0.5-0.5)/0.35, 0, 1) = 0.0 + assert rel.q_source == 0.0 + # Therefore q_i = 0.0 (multiplied by zero) + assert rel.q_i == 0.0 + + def test_duplicate_signal_penalized(self): + """Signals later in a cluster (high cluster_position) get lower q_uniqueness.""" + unit = EvidenceUnit( + symbol="AAPL", + layer="company", + event_type="earnings", + source_id="doc-dup", + source_group="company", + timestamp=_NOW, + horizon="7d", + direction=1, + sentiment_strength=0.8, + impact=0.7, + extraction_conf=0.9, + source_cred=0.85, + novelty=0.9, + event_base_rate=0.25, + cluster_id="test-cluster", + ) + stats = SourceStats(source_id="doc-dup", hits=20, misses=5) + + rel_first = compute_v3_reliability(unit, stats, cluster_position=0, reference_time=_NOW) + rel_third = compute_v3_reliability(unit, stats, cluster_position=3, reference_time=_NOW) + + # Third signal has lower q_uniqueness due to 1/sqrt(1+3) = 0.5 + assert rel_third.q_uniqueness < rel_first.q_uniqueness + assert rel_third.q_i < rel_first.q_i + + def test_stale_signal_low_recency(self): + """A signal that is very old gets low q_recency.""" + old_timestamp = datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc) + unit = EvidenceUnit( + symbol="AAPL", + layer="company", + event_type="earnings", + source_id="doc-old", + source_group="company", + timestamp=old_timestamp, + horizon="7d", + direction=1, + sentiment_strength=0.8, + impact=0.7, + extraction_conf=0.9, + source_cred=0.85, + novelty=0.9, + event_base_rate=0.25, + cluster_id="test-cluster", + ) + stats = SourceStats(source_id="doc-old", hits=20, misses=5) + rel = compute_v3_reliability(unit, stats, cluster_position=0, reference_time=_NOW) + + # Over a year old with 7d horizon (tau_base=72h) → q_recency very low + # Display floor is 0.01 + assert rel.q_recency == 0.01 + + +# =========================================================================== +# TestLLRConversion +# =========================================================================== + + +class TestLLRConversion: + """Test compute_llr boundary cases and sign behavior.""" + + def _make_unit(self, direction: int, impact: float = 0.7, sentiment_strength: float = 0.8) -> EvidenceUnit: + """Helper to create an EvidenceUnit with specified direction.""" + return EvidenceUnit( + symbol="TEST", + layer="company", + event_type="earnings", + source_id="llr-test", + source_group="company", + timestamp=_NOW, + horizon="7d", + direction=direction, + sentiment_strength=sentiment_strength, + impact=impact, + extraction_conf=0.9, + source_cred=0.85, + novelty=0.9, + event_base_rate=0.25, + cluster_id="test-cluster", + ) + + def test_neutral_signal_zero_llr(self): + """Neutral signal (direction=0) → LLR = 0.0 exactly (Req 3.3).""" + unit = self._make_unit(direction=0) + llr = compute_llr(unit, q_i=0.9) + assert llr == 0.0 + + def test_bullish_positive_llr(self): + """Bullish signal (direction=+1) → positive LLR (Req 3.6).""" + unit = self._make_unit(direction=1) + llr = compute_llr(unit, q_i=0.9) + assert llr > 0.0 + + def test_bearish_negative_llr(self): + """Bearish signal (direction=-1) → negative LLR (Req 3.6).""" + unit = self._make_unit(direction=-1) + llr = compute_llr(unit, q_i=0.9) + assert llr < 0.0 + + def test_p_correct_max_clamp(self): + """Maximum p_correct = 0.85 → LLR ≈ ln(0.85/0.15) ≈ 1.735 (Req 3.5).""" + # With direction=+1, q_i=1.0, impact=1.0, sentiment_strength=1.0: + # p_correct = clamp(0.50 + 0.35*1*1*1, 0.501, 0.85) = 0.85 + unit = self._make_unit(direction=1, impact=1.0, sentiment_strength=1.0) + llr = compute_llr(unit, q_i=1.0) + expected = math.log(0.85 / 0.15) # ≈ 1.7346 + assert abs(llr - expected) < 0.001 + + def test_p_correct_min_clamp(self): + """Minimum p_correct = 0.501 → |LLR| ≈ ln(0.501/0.499) ≈ 0.004 (Req 3.4).""" + # With direction=-1, q_i very small → p_correct clamps to 0.501 + # q_i=0 → 0.50 + 0.35*0*anything = 0.50 → clamped to 0.501 + unit = self._make_unit(direction=-1, impact=0.0, sentiment_strength=0.0) + llr = compute_llr(unit, q_i=0.0) + expected = -math.log(0.501 / 0.499) # ≈ -0.004 + assert abs(llr - expected) < 0.001 + + def test_llr_sign_always_matches_direction(self): + """For directional signals, LLR sign must match direction (Req 3.6).""" + for direction in [1, -1]: + for q_i in [0.0, 0.1, 0.5, 0.9, 1.0]: + unit = self._make_unit(direction=direction) + llr = compute_llr(unit, q_i=q_i) + if direction == 1: + assert llr > 0.0, f"direction=+1, q_i={q_i} should give positive LLR" + else: + assert llr < 0.0, f"direction=-1, q_i={q_i} should give negative LLR" + + def test_llr_magnitude_bounded(self): + """LLR magnitude is bounded by [≈0.004, ≈1.735] for directional signals.""" + min_mag = math.log(0.501 / 0.499) # ≈ 0.004 + max_mag = math.log(0.85 / 0.15) # ≈ 1.735 + + # Test at both extremes + unit_max = self._make_unit(direction=1, impact=1.0, sentiment_strength=1.0) + llr_max = compute_llr(unit_max, q_i=1.0) + assert abs(llr_max) <= max_mag + 0.001 + + unit_min = self._make_unit(direction=1, impact=0.0, sentiment_strength=0.0) + llr_min = compute_llr(unit_min, q_i=0.0) + assert abs(llr_min) >= min_mag - 0.001 diff --git a/tests/test_v3_integration.py b/tests/test_v3_integration.py new file mode 100644 index 0000000..2c78862 --- /dev/null +++ b/tests/test_v3_integration.py @@ -0,0 +1,454 @@ +"""Integration tests for the v3 calibrated evidence pipeline. + +Tests the full pipeline path through pure functions end-to-end: + raw signals → EvidenceUnit → q_i → LLR → cluster → posterior → recommendation + +Also validates feature flag routing and v3 metadata fields. + +Requirements validated: 19.1–19.6, 20.1–20.5 +""" +from __future__ import annotations + +from datetime import datetime, timezone + +from services.aggregation.bayesian import V3Posterior, compute_v3_posterior +from services.aggregation.contradiction import compute_v3_contradiction +from services.aggregation.regime import ( + _DEFAULT_V3_UNCERTAINTY, +) +from services.aggregation.scoring import ( + SourceStats, + compute_llr, + compute_v3_reliability, + normalize_company_signal, +) +from services.aggregation.worker import ( + _annotate_pipeline_mode, + cluster_evidence, + compute_cluster_llr, + compute_n_eff, + compute_v3_confidence, + compute_v3_data_quality, + should_force_informational_v3, +) + +# --------------------------------------------------------------------------- +# Fixtures / helpers +# --------------------------------------------------------------------------- + +_NOW = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc) + +_DEFAULT_REGIME = _DEFAULT_V3_UNCERTAINTY + + +def _make_signal( + sentiment: str = "positive", + impact: float = 0.7, + source_id: str = "doc1", + event_type: str = "earnings", + source_group: str = "company", + extraction_conf: float = 0.85, + source_cred: float = 0.80, + novelty: float = 0.7, +) -> dict: + """Build a raw company signal dict for normalization.""" + return { + "symbol": "AAPL", + "timestamp": _NOW, + "source_id": source_id, + "event_type": event_type, + "source_group": source_group, + "horizon": "7d", + "sentiment": sentiment, + "sentiment_strength": impact, + "impact": impact, + "extraction_conf": extraction_conf, + "source_cred": source_cred, + "novelty": novelty, + } + + +# --------------------------------------------------------------------------- +# Test 1: Full pipeline path through pure functions +# Requirements: 20.1, 20.2, 20.3, 20.4, 20.5 +# --------------------------------------------------------------------------- + + +def test_full_pipeline_path(): + """End-to-end test: raw signals → EvidenceUnit → q_i → LLR → cluster → posterior.""" + # 1. Create raw signal dicts with opposing sentiments + signals = [ + _make_signal( + sentiment="positive", + impact=0.8, + source_id="doc1", + event_type="earnings", + extraction_conf=0.9, + source_cred=0.85, + novelty=0.8, + ), + _make_signal( + sentiment="positive", + impact=0.6, + source_id="doc2", + event_type="earnings", + extraction_conf=0.75, + source_cred=0.7, + novelty=0.6, + ), + _make_signal( + sentiment="negative", + impact=0.5, + source_id="doc3", + event_type="regulatory", + extraction_conf=0.7, + source_cred=0.6, + novelty=0.9, + ), + ] + + # 2. Normalize to EvidenceUnit + units = [normalize_company_signal(s) for s in signals] + assert all(u is not None for u in units), "All signals should normalize successfully" + units = [u for u in units if u is not None] # type narrowing + assert len(units) == 3 + + # 3. Compute q_i (reliability) for each unit + neutral_stats = SourceStats(source_id="default", hits=0, misses=0) + q_values = [] + for unit in units: + reliability = compute_v3_reliability( + unit=unit, + source_stats=neutral_stats, + cluster_position=0, + reference_time=_NOW, + ) + q_values.append(reliability.q_i) + + # All q_i should be in [0, 1] + for q in q_values: + assert 0.0 <= q <= 1.0, f"q_i out of bounds: {q}" + + # 4. Compute LLR for each unit + llrs = [compute_llr(unit, q) for unit, q in zip(units, q_values)] + # Positive sentiment → positive LLR, negative → negative LLR + assert llrs[0] > 0.0, "Positive signal should produce positive LLR" + assert llrs[1] > 0.0, "Positive signal should produce positive LLR" + assert llrs[2] < 0.0, "Negative signal should produce negative LLR" + + # 5. Cluster evidence + clusters = cluster_evidence(units, llrs) + assert len(clusters) >= 1, "Should produce at least one cluster" + + # 6. Compute n_eff and cluster_llr for each cluster + for cluster in clusters: + cluster.n_eff = compute_n_eff(cluster.llrs) + cluster.cluster_llr = compute_cluster_llr(cluster.llrs, cluster.n_eff) + assert cluster.n_eff >= 1.0, "n_eff should be >= 1.0" + assert -2.5 <= cluster.cluster_llr <= 2.5, "cluster_llr should be clamped" + + # 7. Compute posterior (using default uncertainty regime) + posterior = compute_v3_posterior(clusters, _DEFAULT_REGIME, p_prior=0.50) + assert isinstance(posterior, V3Posterior) + + # 8. Assert posterior is valid + assert 0.0 < posterior.p_up < 1.0, f"P_up should be in (0, 1), got {posterior.p_up}" + assert 0.0 < posterior.p_down < 1.0, "P_down should be in (0, 1)" + assert abs(posterior.p_up + posterior.p_down - 1.0) < 1e-9 + assert 0.0 <= posterior.strength <= 1.0 + assert posterior.direction in ("bullish", "bearish", "neutral") + assert posterior.n_eff_total > 0 + + # 9. Compute contradiction + contradiction = compute_v3_contradiction(clusters) + # 10. Assert contradiction is bounded + assert 0.0 <= contradiction <= 1.0 + + # 11. Compute data quality + data_quality = compute_v3_data_quality( + units=units, + extraction_failure_rate=0.0, + age_newest_hours=0.0, + n_source_types=1, + ) + assert 0.0 <= data_quality <= 1.0 + + # 12. Compute confidence + confidence = compute_v3_confidence( + n_eff_total=posterior.n_eff_total, + q_values=q_values, + llrs=llrs, + strength=posterior.strength, + regime_confidence_mult=_DEFAULT_REGIME.confidence_multiplier, + contradiction=contradiction, + data_quality=data_quality, + ) + assert 0.0 <= confidence <= 1.0 + + # With real positive signals we should get a non-trivial posterior + # (not exactly 0.5 since we have net-positive evidence) + assert posterior.p_up > 0.50, ( + "Net-positive evidence should push P_up above 0.50" + ) + + +# --------------------------------------------------------------------------- +# Test 2: Feature flag routing — _annotate_pipeline_mode +# Requirements: 19.1, 19.5 +# --------------------------------------------------------------------------- + + +def test_annotate_pipeline_mode_v3(): + """_annotate_pipeline_mode sets pipeline_mode correctly for v3.""" + from services.shared.schemas import TrendDirection, TrendSummary, TrendWindow + + summary = TrendSummary( + entity_type="company", + entity_id="AAPL", + window=TrendWindow.SEVEN_DAY, + trend_direction=TrendDirection.BULLISH, + trend_strength=0.6, + confidence=0.7, + top_supporting_evidence=["doc1"], + top_opposing_evidence=[], + dominant_catalysts=["earnings"], + material_risks=[], + contradiction_score=0.1, + disagreement_details=[], + generated_at=_NOW, + ) + # Initially no market_context + summary.market_context = {} + + _annotate_pipeline_mode(summary, "v3") + assert summary.market_context["pipeline_mode"] == "v3" + + +def test_annotate_pipeline_mode_heuristic(): + """_annotate_pipeline_mode sets pipeline_mode correctly for heuristic.""" + from services.shared.schemas import TrendDirection, TrendSummary, TrendWindow + + summary = TrendSummary( + entity_type="company", + entity_id="AAPL", + window=TrendWindow.SEVEN_DAY, + trend_direction=TrendDirection.NEUTRAL, + trend_strength=0.0, + confidence=0.0, + top_supporting_evidence=[], + top_opposing_evidence=[], + dominant_catalysts=[], + material_risks=[], + contradiction_score=0.0, + disagreement_details=[], + generated_at=_NOW, + ) + summary.market_context = {} + + _annotate_pipeline_mode(summary, "heuristic") + assert summary.market_context["pipeline_mode"] == "heuristic" + + +# --------------------------------------------------------------------------- +# Test 3: v3 metadata contains expected fields +# Requirements: 20.1, 20.2, 20.3 +# --------------------------------------------------------------------------- + + +def test_v3_metadata_contains_expected_fields(): + """Run through pipeline and verify output metadata dict contains v3 fields.""" + # Build a simple pipeline run + signals = [ + _make_signal(sentiment="positive", impact=0.7, source_id="a1"), + _make_signal(sentiment="negative", impact=0.4, source_id="a2", event_type="regulatory"), + ] + units = [normalize_company_signal(s) for s in signals] + units = [u for u in units if u is not None] + + neutral_stats = SourceStats(source_id="default", hits=0, misses=0) + q_values = [] + for unit in units: + rel = compute_v3_reliability( + unit=unit, + source_stats=neutral_stats, + cluster_position=0, + reference_time=_NOW, + ) + q_values.append(rel.q_i) + + llrs = [compute_llr(u, q) for u, q in zip(units, q_values)] + clusters = cluster_evidence(units, llrs) + for c in clusters: + c.n_eff = compute_n_eff(c.llrs) + c.cluster_llr = compute_cluster_llr(c.llrs, c.n_eff) + + posterior = compute_v3_posterior(clusters, _DEFAULT_REGIME, p_prior=0.50) + contradiction = compute_v3_contradiction(clusters) + data_quality = compute_v3_data_quality( + units=units, + extraction_failure_rate=0.0, + age_newest_hours=0.5, + n_source_types=1, + ) + confidence = compute_v3_confidence( + n_eff_total=posterior.n_eff_total, + q_values=q_values, + llrs=llrs, + strength=posterior.strength, + regime_confidence_mult=_DEFAULT_REGIME.confidence_multiplier, + contradiction=contradiction, + data_quality=data_quality, + ) + + # Build the v3 metadata dict (mirrors _run_v3_pipeline logic) + v3_metadata = { + "v3_posterior": { + "p_up": round(posterior.p_up, 6), + "p_down": round(posterior.p_down, 6), + "log_odds": round(posterior.log_odds, 6), + "strength": round(posterior.strength, 6), + "confidence": round(confidence, 6), + "contradiction": round(contradiction, 6), + "n_eff": round(posterior.n_eff_total, 4), + "data_quality": round(data_quality, 6), + "regime": posterior.regime, + }, + "pipeline_mode": "v3", + "explainability": { + "top_positive_clusters": [], + "top_negative_clusters": [], + "suppression_reasons": [], + "risk_adjustments": [], + }, + } + + # Verify all required v3_posterior keys exist + required_posterior_keys = { + "p_up", "p_down", "log_odds", "strength", + "confidence", "contradiction", "n_eff", "data_quality", "regime", + } + assert set(v3_metadata["v3_posterior"].keys()) == required_posterior_keys + + # Verify top-level metadata keys + assert "pipeline_mode" in v3_metadata + assert v3_metadata["pipeline_mode"] == "v3" + assert "explainability" in v3_metadata + + # Verify explainability structure + explainability = v3_metadata["explainability"] + assert "top_positive_clusters" in explainability + assert "top_negative_clusters" in explainability + assert "suppression_reasons" in explainability + assert "risk_adjustments" in explainability + + # Verify numeric ranges + p = v3_metadata["v3_posterior"] + assert 0.0 < p["p_up"] < 1.0 + assert 0.0 < p["p_down"] < 1.0 + assert abs(p["p_up"] + p["p_down"] - 1.0) < 1e-5 + assert 0.0 <= p["strength"] <= 1.0 + assert 0.0 <= p["confidence"] <= 1.0 + assert 0.0 <= p["contradiction"] <= 1.0 + assert 0.0 <= p["data_quality"] <= 1.0 + assert p["regime"] in ("panic", "trend_following", "mean_reversion", "uncertainty") + + +# --------------------------------------------------------------------------- +# Test 4: Heuristic fallback function exists and is callable +# Requirements: 19.2, 19.4 +# --------------------------------------------------------------------------- + + +def test_heuristic_fallback_function_exists(): + """Verify the heuristic fallback function exists and is callable.""" + from services.aggregation.worker import _aggregate_company_heuristic + + assert callable(_aggregate_company_heuristic) + + +def test_v3_read_flag_function_exists(): + """Verify the _read_v3_flag async function exists and is callable.""" + from services.aggregation.worker import _read_v3_flag + + assert callable(_read_v3_flag) + + +# --------------------------------------------------------------------------- +# Test 5: should_force_informational_v3 routing +# Requirements: 19.3, 19.4 +# --------------------------------------------------------------------------- + + +def test_force_informational_low_data_quality(): + """Low data quality should force informational mode.""" + units = [ + normalize_company_signal(_make_signal(source_id="x1")), + normalize_company_signal(_make_signal(source_id="x2")), + ] + units = [u for u in units if u is not None] + + should_force, reason = should_force_informational_v3( + data_quality=0.3, + units=units, + extraction_failure_rate=0.0, + ) + assert should_force is True + assert reason == "data_quality_below_threshold" + + +def test_no_force_informational_good_quality(): + """Good data quality with sufficient evidence should NOT force informational.""" + units = [ + normalize_company_signal(_make_signal(source_id="x1")), + normalize_company_signal(_make_signal(source_id="x2")), + normalize_company_signal(_make_signal(source_id="x3")), + ] + units = [u for u in units if u is not None] + + should_force, reason = should_force_informational_v3( + data_quality=0.75, + units=units, + extraction_failure_rate=0.1, + ) + assert should_force is False + assert reason == "" + + +# --------------------------------------------------------------------------- +# Test 6: Pipeline with all-neutral signals produces neutral posterior +# Requirements: 20.1, 20.4 +# --------------------------------------------------------------------------- + + +def test_neutral_signals_produce_neutral_posterior(): + """All neutral signals should produce posterior at P_up ≈ 0.50.""" + signals = [ + _make_signal(sentiment="neutral", impact=0.5, source_id="n1"), + _make_signal(sentiment="neutral", impact=0.3, source_id="n2"), + ] + units = [normalize_company_signal(s) for s in signals] + units = [u for u in units if u is not None] + + neutral_stats = SourceStats(source_id="default", hits=0, misses=0) + q_values = [] + for unit in units: + rel = compute_v3_reliability( + unit=unit, source_stats=neutral_stats, + cluster_position=0, reference_time=_NOW, + ) + q_values.append(rel.q_i) + + llrs = [compute_llr(u, q) for u, q in zip(units, q_values)] + # All neutral → all LLRs should be 0 + assert all(llr == 0.0 for llr in llrs), "Neutral signals should produce zero LLR" + + clusters = cluster_evidence(units, llrs) + for c in clusters: + c.n_eff = compute_n_eff(c.llrs) + c.cluster_llr = compute_cluster_llr(c.llrs, c.n_eff) + + posterior = compute_v3_posterior(clusters, _DEFAULT_REGIME, p_prior=0.50) + assert abs(posterior.p_up - 0.50) < 1e-6, ( + f"Neutral evidence should maintain prior, got P_up={posterior.p_up}" + ) + assert posterior.direction == "neutral" diff --git a/tests/test_v3_layers.py b/tests/test_v3_layers.py new file mode 100644 index 0000000..82ac6f0 --- /dev/null +++ b/tests/test_v3_layers.py @@ -0,0 +1,359 @@ +"""Unit tests for v3 macro and competitive layers. + +Tests the noisy-OR normalized macro exposure, resilience dampener, +macro LLR computation, shrunk correlation convergence, competitive LLR +clamping, and graph-distance attenuation. + +Requirements validated: 9.1–9.5, 10.1–10.5 +""" +from __future__ import annotations + +import math + +import pytest + +from services.aggregation.interpolation import ( + compute_macro_llr, + compute_normalized_macro_exposure, +) +from services.aggregation.signal_propagation import ( + compute_competitive_llr, + compute_shrunk_correlation, +) + +# --------------------------------------------------------------------------- +# Noisy-OR: compute_normalized_macro_exposure +# --------------------------------------------------------------------------- + + +class TestNoisyORExposure: + """Tests for noisy-OR normalized macro exposure (Req 9.1, 9.2, 9.3).""" + + def test_all_overlaps_max_regional(self): + """All O_k = 1.0 with regional tier → E_macro = 1.0.""" + overlaps = {"geo": 1.0, "supply": 1.0, "commodity": 1.0, "sector": 1.0} + result = compute_normalized_macro_exposure(overlaps, tier="regional") + assert result == pytest.approx(1.0, abs=1e-9) + + def test_all_overlaps_zero(self): + """All O_k = 0 → E_macro = 0.0.""" + overlaps = {"geo": 0.0, "supply": 0.0, "commodity": 0.0, "sector": 0.0} + result = compute_normalized_macro_exposure(overlaps, tier="regional") + assert result == pytest.approx(0.0, abs=1e-9) + + def test_empty_overlaps(self): + """Empty overlaps dict → E_macro = 0.0.""" + result = compute_normalized_macro_exposure({}, tier="regional") + assert result == pytest.approx(0.0, abs=1e-9) + + def test_single_dimension_geo(self): + """Only geo overlap → partial exposure.""" + overlaps = {"geo": 1.0} + result = compute_normalized_macro_exposure(overlaps, tier="regional") + # E_raw = 1 - (1-0.35*1)(1-0.25*0)(1-0.25*0)(1-0.15*0) = 1 - 0.65 = 0.35 + # E_max = 1 - (0.65)(0.75)(0.75)(0.85) = 1 - 0.311484375 ≈ 0.688515625 + # E_macro = 0.35 / 0.688515625 ≈ 0.508 + expected_e_raw = 0.35 + e_max = 1.0 - (0.65 * 0.75 * 0.75 * 0.85) + expected = expected_e_raw / e_max + assert result == pytest.approx(expected, rel=1e-6) + + def test_partial_overlaps(self): + """Partial overlaps produce intermediate exposure.""" + overlaps = {"geo": 0.5, "supply": 0.3, "commodity": 0.0, "sector": 0.8} + result = compute_normalized_macro_exposure(overlaps, tier="regional") + # Should be between 0 and 1 + assert 0.0 < result < 1.0 + + +# --------------------------------------------------------------------------- +# Resilience dampener per tier +# --------------------------------------------------------------------------- + + +class TestResilienceDampener: + """Tests for resilience dampener application (Req 9.3).""" + + def test_global_leader_dampener(self): + """Global leader tier dampens exposure by 0.70.""" + overlaps = {"geo": 1.0, "supply": 1.0, "commodity": 1.0, "sector": 1.0} + result = compute_normalized_macro_exposure(overlaps, tier="global_leader") + # E_macro = 1.0 * 0.70 = 0.70 + assert result == pytest.approx(0.70, abs=1e-9) + + def test_multinational_dampener(self): + """Multinational tier dampens exposure by 0.85.""" + overlaps = {"geo": 1.0, "supply": 1.0, "commodity": 1.0, "sector": 1.0} + result = compute_normalized_macro_exposure(overlaps, tier="multinational") + assert result == pytest.approx(0.85, abs=1e-9) + + def test_regional_dampener(self): + """Regional tier has no dampening (1.00).""" + overlaps = {"geo": 1.0, "supply": 1.0, "commodity": 1.0, "sector": 1.0} + result = compute_normalized_macro_exposure(overlaps, tier="regional") + assert result == pytest.approx(1.00, abs=1e-9) + + def test_domestic_amplifier(self): + """Domestic tier amplifies exposure by 1.20.""" + overlaps = {"geo": 1.0, "supply": 1.0, "commodity": 1.0, "sector": 1.0} + result = compute_normalized_macro_exposure(overlaps, tier="domestic") + assert result == pytest.approx(1.20, abs=1e-9) + + def test_unknown_tier_no_dampening(self): + """Unknown tier defaults to 1.0 dampener.""" + overlaps = {"geo": 1.0, "supply": 1.0, "commodity": 1.0, "sector": 1.0} + result = compute_normalized_macro_exposure(overlaps, tier="unknown_tier") + assert result == pytest.approx(1.00, abs=1e-9) + + +# --------------------------------------------------------------------------- +# Macro LLR at boundary values +# --------------------------------------------------------------------------- + + +class TestMacroLLR: + """Tests for macro LLR computation (Req 9.4, 9.5).""" + + def test_max_positive_inputs(self): + """macro_impact=1, event_conf=1, q_recency=1, direction=+1 → p_macro=0.80.""" + llr = compute_macro_llr( + macro_impact=1.0, + event_confidence=1.0, + q_recency=1.0, + macro_direction=1, + ) + # p_macro = 0.50 + 0.30*1*1*1 = 0.80 + expected = math.log(0.80 / 0.20) # ≈ 1.386 + assert llr == pytest.approx(expected, rel=1e-6) + + def test_max_negative_inputs(self): + """All max with direction=-1 → negative LLR.""" + llr = compute_macro_llr( + macro_impact=1.0, + event_confidence=1.0, + q_recency=1.0, + macro_direction=-1, + ) + expected = -math.log(0.80 / 0.20) + assert llr == pytest.approx(expected, rel=1e-6) + + def test_neutral_direction_zero_llr(self): + """direction=0 → LLR=0.0 regardless of other inputs.""" + llr = compute_macro_llr( + macro_impact=1.0, + event_confidence=1.0, + q_recency=1.0, + macro_direction=0, + ) + assert llr == 0.0 + + def test_minimum_p_macro_clamp(self): + """All impact factors zero → p_macro clamped to 0.501.""" + llr = compute_macro_llr( + macro_impact=0.0, + event_confidence=0.0, + q_recency=0.0, + macro_direction=1, + ) + # p_macro = 0.50 + 0 = 0.50, clamped up to 0.501 + expected = math.log(0.501 / (1.0 - 0.501)) + assert llr == pytest.approx(expected, rel=1e-6) + + def test_mid_range_inputs(self): + """Intermediate inputs produce reasonable LLR.""" + llr = compute_macro_llr( + macro_impact=0.5, + event_confidence=0.7, + q_recency=0.8, + macro_direction=1, + ) + # p_macro = 0.50 + 0.30 * 0.5 * 0.7 * 0.8 = 0.50 + 0.084 = 0.584 + p_macro = 0.584 + expected = math.log(p_macro / (1.0 - p_macro)) + assert llr == pytest.approx(expected, rel=1e-6) + + +# --------------------------------------------------------------------------- +# Shrunk correlation convergence +# --------------------------------------------------------------------------- + + +class TestShrunkCorrelation: + """Tests for shrinkage-adjusted correlation (Req 10.1, 10.2).""" + + def test_large_n_approaches_rho_rolling(self): + """n=1000 with same_sector → result ≈ rho_rolling.""" + rho_rolling = 0.65 + result = compute_shrunk_correlation( + rho_rolling=rho_rolling, + n_observations=1000, + same_sector=True, + ) + # (1000/1030) × 0.65 + (30/1030) × 0.30 ≈ 0.6311 + 0.00874 ≈ 0.6398 + weight_data = 1000 / 1030 + weight_prior = 30 / 1030 + expected = weight_data * rho_rolling + weight_prior * 0.30 + assert result == pytest.approx(expected, rel=1e-6) + # Should be close to rho_rolling + assert abs(result - rho_rolling) < 0.02 + + def test_zero_observations_returns_prior(self): + """n=0 → result = prior (same_sector: 0.30, cross_sector: 0.10).""" + # same sector + result_same = compute_shrunk_correlation( + rho_rolling=0.9, + n_observations=0, + same_sector=True, + ) + # (0/30) × 0.9 + (30/30) × 0.30 = 0.30 + assert result_same == pytest.approx(0.30, abs=1e-9) + + # cross sector + result_cross = compute_shrunk_correlation( + rho_rolling=0.9, + n_observations=0, + same_sector=False, + ) + # (0/30) × 0.9 + (30/30) × 0.10 = 0.10 + assert result_cross == pytest.approx(0.10, abs=1e-9) + + def test_cross_sector_prior(self): + """Cross-sector uses prior = 0.10.""" + result = compute_shrunk_correlation( + rho_rolling=0.50, + n_observations=30, + same_sector=False, + ) + # (30/60) × 0.50 + (30/60) × 0.10 = 0.25 + 0.05 = 0.30 + assert result == pytest.approx(0.30, abs=1e-9) + + def test_negative_rolling_floored_at_zero(self): + """Negative rolling correlation → rho_effective floored at 0.""" + result = compute_shrunk_correlation( + rho_rolling=-0.50, + n_observations=100, + same_sector=False, + ) + # (100/130)×(-0.50) + (30/130)×0.10 = -0.3846 + 0.0231 ≈ -0.3615 + # Floored at 0 + assert result == 0.0 + + def test_n_30_equal_weight(self): + """n=30 → data and prior have equal weight.""" + rho_rolling = 0.80 + result = compute_shrunk_correlation( + rho_rolling=rho_rolling, + n_observations=30, + same_sector=True, + ) + # (30/60)×0.80 + (30/60)×0.30 = 0.40 + 0.15 = 0.55 + assert result == pytest.approx(0.55, abs=1e-9) + + +# --------------------------------------------------------------------------- +# Competitive LLR clamp at ±1.25 +# --------------------------------------------------------------------------- + + +class TestCompetitiveLLRClamp: + """Tests for competitive LLR clamping (Req 10.3, 10.4, 10.5).""" + + def test_large_positive_clamped(self): + """Large positive inputs → clamped to +1.25.""" + result = compute_competitive_llr( + llr_source=10.0, + rho_effective=0.9, + d_network=1, + pattern_confidence=1.0, + ) + assert result == pytest.approx(1.25, abs=1e-9) + + def test_large_negative_clamped(self): + """Large negative inputs → clamped to -1.25.""" + result = compute_competitive_llr( + llr_source=-10.0, + rho_effective=0.9, + d_network=1, + pattern_confidence=1.0, + ) + assert result == pytest.approx(-1.25, abs=1e-9) + + def test_within_bounds_not_clamped(self): + """Small inputs produce unclamped result.""" + # attenuation = 0.5 × exp(-0.85 × 1) ≈ 0.5 × 0.4274 ≈ 0.2137 + # LLR_competitive = 1.0 × 0.2137 × 0.8 ≈ 0.1710 + result = compute_competitive_llr( + llr_source=1.0, + rho_effective=0.5, + d_network=1, + pattern_confidence=0.8, + ) + expected = 1.0 * 0.5 * math.exp(-0.85 * 1) * 0.8 + assert result == pytest.approx(expected, rel=1e-6) + assert abs(result) < 1.25 + + def test_zero_rho_gives_zero(self): + """Zero correlation → zero competitive LLR.""" + result = compute_competitive_llr( + llr_source=5.0, + rho_effective=0.0, + d_network=1, + pattern_confidence=1.0, + ) + assert result == pytest.approx(0.0, abs=1e-9) + + +# --------------------------------------------------------------------------- +# Distance > 3 → zero attenuation +# --------------------------------------------------------------------------- + + +class TestDistanceAttenuation: + """Tests for graph distance cutoff (Req 10.5).""" + + def test_distance_4_returns_zero(self): + """d_network=4 → LLR_competitive = 0.0.""" + result = compute_competitive_llr( + llr_source=5.0, + rho_effective=0.9, + d_network=4, + pattern_confidence=1.0, + ) + assert result == 0.0 + + def test_distance_10_returns_zero(self): + """Very large distance → LLR_competitive = 0.0.""" + result = compute_competitive_llr( + llr_source=5.0, + rho_effective=0.9, + d_network=10, + pattern_confidence=1.0, + ) + assert result == 0.0 + + def test_distance_3_still_active(self): + """d_network=3 (max allowed) → non-zero result.""" + result = compute_competitive_llr( + llr_source=2.0, + rho_effective=0.8, + d_network=3, + pattern_confidence=0.9, + ) + # attenuation = 0.8 × exp(-0.85 × 3) ≈ 0.8 × 0.0776 ≈ 0.0621 + # LLR_competitive = 2.0 × 0.0621 × 0.9 ≈ 0.1118 + expected = 2.0 * 0.8 * math.exp(-0.85 * 3) * 0.9 + assert result == pytest.approx(expected, rel=1e-6) + assert result != 0.0 + + def test_distance_1_strongest(self): + """d_network=1 gives strongest attenuation (least decay).""" + result_d1 = compute_competitive_llr( + llr_source=2.0, rho_effective=0.8, d_network=1, pattern_confidence=0.9, + ) + result_d2 = compute_competitive_llr( + llr_source=2.0, rho_effective=0.8, d_network=2, pattern_confidence=0.9, + ) + result_d3 = compute_competitive_llr( + llr_source=2.0, rho_effective=0.8, d_network=3, pattern_confidence=0.9, + ) + assert result_d1 > result_d2 > result_d3 > 0.0 diff --git a/tests/test_v3_posterior.py b/tests/test_v3_posterior.py new file mode 100644 index 0000000..3f52ad8 --- /dev/null +++ b/tests/test_v3_posterior.py @@ -0,0 +1,471 @@ +"""Unit tests for v3 posterior assembly and regime classification. + +Tests for compute_v3_posterior and classify_regime_v3 functions. + +Requirements validated: 5.1–5.7, 6.1–6.8 +""" +from __future__ import annotations + +import math + +import pytest + +from services.aggregation.bayesian import V3Posterior, compute_v3_posterior +from services.aggregation.regime import ( + _V3_REGIME_PARAMS, + MarketRegime, + V3RegimeClassification, + classify_regime_v3, +) +from services.aggregation.worker import EvidenceCluster + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_cluster(cluster_llr: float, n_eff: float = 1.0) -> EvidenceCluster: + """Create a minimal EvidenceCluster with given LLR and n_eff.""" + return EvidenceCluster( + cluster_id="test", units=[], llrs=[], n_eff=n_eff, cluster_llr=cluster_llr + ) + + +def _make_regime( + regime: MarketRegime = MarketRegime.UNCERTAINTY, gamma: float = 0.80 +) -> V3RegimeClassification: + """Create a V3RegimeClassification with specified regime and gamma.""" + return V3RegimeClassification( + regime=regime, + trend_z=0.0, + vol_ratio=1.0, + evidence_multiplier=gamma, + confidence_multiplier=0.85, + phi_decay=0.50, + atr_multiplier=2.0, + ) + + +# --------------------------------------------------------------------------- +# Test: Empty clusters → P_up = 0.50 (neutral prior) +# Requirements: 5.1, 5.3 +# --------------------------------------------------------------------------- + + +class TestEmptyClusters: + """No evidence → log_odds = logit(0.50) = 0 → P_up = 0.50.""" + + def test_empty_clusters_gives_neutral_posterior(self): + regime = _make_regime() + result = compute_v3_posterior(clusters=[], regime=regime, p_prior=0.50) + + assert isinstance(result, V3Posterior) + assert result.p_up == pytest.approx(0.50, abs=1e-9) + assert result.p_down == pytest.approx(0.50, abs=1e-9) + assert result.log_odds == pytest.approx(0.0, abs=1e-9) + assert result.strength == pytest.approx(0.0, abs=1e-9) + assert result.direction == "neutral" + assert result.n_eff_total == 0.0 + + def test_empty_clusters_with_default_prior(self): + regime = _make_regime() + result = compute_v3_posterior(clusters=[], regime=regime) + + assert result.p_up == pytest.approx(0.50, abs=1e-9) + + +# --------------------------------------------------------------------------- +# Test: All bullish clusters → P_up > 0.50 +# Requirements: 5.1, 5.2 +# --------------------------------------------------------------------------- + + +class TestAllBullishClusters: + """Positive cluster LLRs with uncertainty regime (gamma=0.80) → P_up > 0.50.""" + + def test_bullish_clusters_give_p_up_above_half(self): + clusters = [_make_cluster(1.0), _make_cluster(0.5)] + regime = _make_regime(MarketRegime.UNCERTAINTY, gamma=0.80) + + result = compute_v3_posterior(clusters=clusters, regime=regime, p_prior=0.50) + + assert result.p_up > 0.50 + assert result.direction in ("bullish", "neutral") # depends on threshold + assert result.log_odds > 0.0 + + def test_bullish_computes_correct_log_odds(self): + """Verify log_odds = logit(0.50) + gamma * sum(LLR_c).""" + clusters = [_make_cluster(1.0), _make_cluster(0.5)] + regime = _make_regime(MarketRegime.UNCERTAINTY, gamma=0.80) + + result = compute_v3_posterior(clusters=clusters, regime=regime, p_prior=0.50) + + expected_log_odds = 0.0 + 0.80 * (1.0 + 0.5) # = 1.2 + assert result.log_odds == pytest.approx(expected_log_odds, abs=1e-9) + + +# --------------------------------------------------------------------------- +# Test: All bearish clusters → P_up < 0.50 +# Requirements: 5.1, 5.2 +# --------------------------------------------------------------------------- + + +class TestAllBearishClusters: + """Negative cluster LLRs → P_up < 0.50.""" + + def test_bearish_clusters_give_p_up_below_half(self): + clusters = [_make_cluster(-1.0), _make_cluster(-0.5)] + regime = _make_regime(MarketRegime.UNCERTAINTY, gamma=0.80) + + result = compute_v3_posterior(clusters=clusters, regime=regime, p_prior=0.50) + + assert result.p_up < 0.50 + assert result.log_odds < 0.0 + + def test_bearish_computes_correct_log_odds(self): + clusters = [_make_cluster(-1.0), _make_cluster(-0.5)] + regime = _make_regime(MarketRegime.UNCERTAINTY, gamma=0.80) + + result = compute_v3_posterior(clusters=clusters, regime=regime, p_prior=0.50) + + expected_log_odds = 0.0 + 0.80 * (-1.0 + -0.5) # = -1.2 + assert result.log_odds == pytest.approx(expected_log_odds, abs=1e-9) + + +# --------------------------------------------------------------------------- +# Test: Regime direction thresholds at boundary values +# Requirements: 5.5 +# --------------------------------------------------------------------------- + + +class TestDirectionThresholds: + """Test direction classification at panic regime boundaries (0.68/0.32).""" + + def _compute_with_target_p_up(self, target_p_up: float) -> V3Posterior: + """Compute posterior that results in a specific P_up value. + + We reverse-engineer the cluster LLR needed to produce the target P_up + under panic regime with gamma=0.70 and p_prior=0.50. + """ + # logit(target) = logit(0.50) + gamma * cluster_llr + # logit(target) = 0.0 + 0.70 * cluster_llr + # cluster_llr = logit(target) / 0.70 + logit_target = math.log(target_p_up / (1.0 - target_p_up)) + cluster_llr = logit_target / 0.70 + + clusters = [_make_cluster(cluster_llr)] + regime = _make_regime(MarketRegime.PANIC, gamma=0.70) + return compute_v3_posterior(clusters=clusters, regime=regime, p_prior=0.50) + + def test_panic_bullish_at_068(self): + """P_up = 0.68 → bullish in panic regime (threshold is 0.68).""" + result = self._compute_with_target_p_up(0.68) + assert result.p_up == pytest.approx(0.68, abs=1e-6) + assert result.direction == "bullish" + + def test_panic_neutral_at_067(self): + """P_up = 0.67 → neutral in panic regime (below 0.68 threshold).""" + result = self._compute_with_target_p_up(0.67) + assert result.p_up == pytest.approx(0.67, abs=1e-6) + assert result.direction == "neutral" + + def test_panic_bearish_at_032(self): + """P_up = 0.32 → bearish in panic regime (threshold is 0.32).""" + result = self._compute_with_target_p_up(0.32) + assert result.p_up == pytest.approx(0.32, abs=1e-6) + assert result.direction == "bearish" + + def test_panic_neutral_at_033(self): + """P_up = 0.33 → neutral in panic regime (above 0.32 threshold).""" + result = self._compute_with_target_p_up(0.33) + assert result.p_up == pytest.approx(0.33, abs=1e-6) + assert result.direction == "neutral" + + def test_trend_following_thresholds(self): + """Trend following thresholds: bullish >= 0.60, bearish <= 0.40.""" + # Bullish at 0.60 + logit_target = math.log(0.60 / 0.40) + cluster_llr = logit_target / 1.10 # gamma for trend_following + clusters = [_make_cluster(cluster_llr)] + regime = _make_regime(MarketRegime.TREND_FOLLOWING, gamma=1.10) + result = compute_v3_posterior(clusters=clusters, regime=regime, p_prior=0.50) + assert result.p_up == pytest.approx(0.60, abs=1e-6) + assert result.direction == "bullish" + + def test_mean_reversion_thresholds(self): + """Mean reversion thresholds: bullish >= 0.63, bearish <= 0.37.""" + # Use slightly above 0.63 to avoid floating-point boundary issues + target = 0.631 + logit_target = math.log(target / (1.0 - target)) + cluster_llr = logit_target / 0.90 # gamma for mean_reversion + clusters = [_make_cluster(cluster_llr)] + regime = _make_regime(MarketRegime.MEAN_REVERSION, gamma=0.90) + result = compute_v3_posterior(clusters=clusters, regime=regime, p_prior=0.50) + assert result.p_up >= 0.63 + assert result.direction == "bullish" + + +# --------------------------------------------------------------------------- +# Test: Prior clamp [0.40, 0.60] +# Requirements: 5.6 +# --------------------------------------------------------------------------- + + +class TestPriorClamp: + """Prior values outside [0.40, 0.60] are clamped.""" + + def test_prior_below_040_clamped(self): + """p_prior=0.30 → clamped to 0.40.""" + regime = _make_regime() + result = compute_v3_posterior(clusters=[], regime=regime, p_prior=0.30) + + # logit(0.40) ≈ -0.4055 + expected_log_odds = math.log(0.40 / 0.60) + assert result.log_odds == pytest.approx(expected_log_odds, abs=1e-9) + # P_up should be 0.40 with no evidence + assert result.p_up == pytest.approx(0.40, abs=1e-6) + + def test_prior_above_060_clamped(self): + """p_prior=0.80 → clamped to 0.60.""" + regime = _make_regime() + result = compute_v3_posterior(clusters=[], regime=regime, p_prior=0.80) + + expected_log_odds = math.log(0.60 / 0.40) + assert result.log_odds == pytest.approx(expected_log_odds, abs=1e-9) + assert result.p_up == pytest.approx(0.60, abs=1e-6) + + def test_prior_at_040_not_clamped(self): + """p_prior=0.40 is within bounds, no clamping.""" + regime = _make_regime() + result = compute_v3_posterior(clusters=[], regime=regime, p_prior=0.40) + assert result.p_up == pytest.approx(0.40, abs=1e-6) + + def test_prior_at_060_not_clamped(self): + """p_prior=0.60 is within bounds, no clamping.""" + regime = _make_regime() + result = compute_v3_posterior(clusters=[], regime=regime, p_prior=0.60) + assert result.p_up == pytest.approx(0.60, abs=1e-6) + + def test_prior_at_050_standard(self): + """p_prior=0.50 is standard neutral prior.""" + regime = _make_regime() + result = compute_v3_posterior(clusters=[], regime=regime, p_prior=0.50) + assert result.p_up == pytest.approx(0.50, abs=1e-9) + + +# --------------------------------------------------------------------------- +# Test: Regime classification with known inputs +# Requirements: 6.1–6.8 +# --------------------------------------------------------------------------- + + +class TestRegimeClassificationKnownInputs: + """Test classify_regime_v3 with known inputs mapping to specific regimes.""" + + def _make_prices_and_returns( + self, n: int = 120 + ) -> tuple[list[float], list[float]]: + """Generate flat price series and near-zero returns.""" + prices = [100.0] * n + returns = [0.001] * n + return prices, returns + + def test_high_vol_ratio_triggers_panic(self): + """vol_ratio > 1.5 → panic.""" + # Generate returns where sigma_20 >> sigma_100 + # sigma_20 high, sigma_100 low → vol_ratio > 1.5 + prices = [100.0] * 120 + # Low-vol returns for sigma_100 context + returns_low = [0.001] * 80 + # High-vol returns for sigma_20 + returns_high = [0.05, -0.05] * 10 # stdev ≈ 0.0526 + returns = returns_low + returns_high + + # With flat prices, trend_z ≈ 0, but vol_ratio > 1.5 → panic + result = classify_regime_v3(prices, returns, atr_20=1.0) + assert result.regime == MarketRegime.PANIC + + def test_trend_following_regime(self): + """trend_z >= 0.75, vol_ratio < 1.3 → trend_following. + + |trend_z| >= 0.75 AND vol_ratio < 1.3 → trend_following. + """ + # Trending price series: EMA_20 significantly above EMA_100 + prices = [100.0 + i * 0.5 for i in range(120)] + + # Varied returns with stable vol (sigma_20 ≈ sigma_100 → vol_ratio near 1.0) + returns = [0.005 + (i % 5) * 0.001 for i in range(120)] + + # ATR chosen so trend_z ≈ 1.77 (well above 0.75 threshold) + result = classify_regime_v3(prices, returns, atr_20=10.0) + + assert result.regime == MarketRegime.TREND_FOLLOWING + assert abs(result.trend_z) >= 0.75 + assert result.vol_ratio < 1.3 + + def test_mean_reversion_regime(self): + """|trend_z| < 0.50 AND vol_ratio < 1.0 → mean_reversion.""" + # Flat prices → trend_z ≈ 0 + prices = [100.0] * 120 + + # Returns with decreasing volatility (sigma_20 < sigma_100) + # High vol early, low vol recently + returns_early = [0.03, -0.03] * 40 # high vol for sigma_100 + returns_recent = [0.001] * 40 # low vol for sigma_20 + returns = returns_early + returns_recent + + # Large ATR so trend_z stays small + result = classify_regime_v3(prices, returns, atr_20=50.0) + + assert result.regime == MarketRegime.MEAN_REVERSION + assert abs(result.trend_z) < 0.50 + assert result.vol_ratio < 1.0 + + def test_uncertainty_regime_default(self): + """When conditions don't match any specific regime → uncertainty. + + |trend_z| between 0.50 and 0.75 OR vol_ratio between 1.0 and 1.3. + """ + # Mild trend + moderate vol → uncertainty + # Slightly trending prices but not enough for trend_following + prices = [100.0 + i * 0.1 for i in range(120)] + + # Uniform returns → vol_ratio ≈ 1.0 + returns = [0.01] * 120 + + # ATR chosen so |trend_z| is between 0.50 and 0.75 + # We need to find ATR such that it lands in uncertainty + # With mild trend, vol_ratio ≈ 1.0 (not < 1.0), so mean_reversion won't fire + # And trend_z might be < 0.75, so trend_following won't fire + result = classify_regime_v3(prices, returns, atr_20=5.0) + + # With uniform returns, stdev is 0 → sigma_100 = 0 + # We need non-trivial returns. Let's use a better approach. + # Use returns that give vol_ratio between 1.0 and 1.3 + returns_varied = [0.01 + (i % 3) * 0.002 for i in range(120)] + result = classify_regime_v3(prices, returns_varied, atr_20=5.0) + + # This should fall through to uncertainty since conditions are moderate + assert result.regime == MarketRegime.UNCERTAINTY + + def test_data_insufficient_returns_uncertainty(self): + """Fewer than 100 closing prices → default uncertainty.""" + prices = [100.0] * 50 # < 100 + returns = [0.01] * 50 + result = classify_regime_v3(prices, returns, atr_20=1.0) + + assert result.regime == MarketRegime.UNCERTAINTY + assert result.trend_z == 0.0 + assert result.vol_ratio == 1.0 + assert result.evidence_multiplier == 0.80 + + def test_atr_zero_returns_uncertainty(self): + """ATR_20 <= 0 → default uncertainty (Req 6.8).""" + prices = [100.0] * 120 + returns = [0.01] * 120 + result = classify_regime_v3(prices, returns, atr_20=0.0) + + assert result.regime == MarketRegime.UNCERTAINTY + + def test_insufficient_returns_data(self): + """Fewer than 100 daily returns → default uncertainty.""" + prices = [100.0] * 120 + returns = [0.01] * 50 # < 100 + result = classify_regime_v3(prices, returns, atr_20=1.0) + + assert result.regime == MarketRegime.UNCERTAINTY + + +# --------------------------------------------------------------------------- +# Test: Regime parameters are correctly assigned +# Requirements: 6.6, 6.7 +# --------------------------------------------------------------------------- + + +class TestRegimeParameters: + """Verify regime parameters (gamma, conf_mult, phi, atr_mult) are assigned.""" + + def test_panic_parameters(self): + gamma, conf_mult, phi, atr_mult, min_edge = _V3_REGIME_PARAMS[ + MarketRegime.PANIC + ] + assert gamma == 0.70 + assert conf_mult == 0.70 + assert phi == 0.35 + assert atr_mult == 2.5 + assert min_edge == 0.0100 + + def test_trend_following_parameters(self): + gamma, conf_mult, phi, atr_mult, min_edge = _V3_REGIME_PARAMS[ + MarketRegime.TREND_FOLLOWING + ] + assert gamma == 1.10 + assert conf_mult == 1.00 + assert phi == 0.80 + assert atr_mult == 1.8 + assert min_edge == 0.0035 + + def test_mean_reversion_parameters(self): + gamma, conf_mult, phi, atr_mult, min_edge = _V3_REGIME_PARAMS[ + MarketRegime.MEAN_REVERSION + ] + assert gamma == 0.90 + assert conf_mult == 0.95 + assert phi == 0.55 + assert atr_mult == 1.4 + assert min_edge == 0.0050 + + def test_uncertainty_parameters(self): + gamma, conf_mult, phi, atr_mult, min_edge = _V3_REGIME_PARAMS[ + MarketRegime.UNCERTAINTY + ] + assert gamma == 0.80 + assert conf_mult == 0.85 + assert phi == 0.50 + assert atr_mult == 2.0 + assert min_edge == 0.0075 + + +# --------------------------------------------------------------------------- +# Test: Posterior output fields +# Requirements: 5.4, 5.5 +# --------------------------------------------------------------------------- + + +class TestPosteriorOutputFields: + """Verify derived fields (strength, n_eff_total, regime) are correct.""" + + def test_strength_computed_correctly(self): + """strength = abs(2 × P_up - 1).""" + clusters = [_make_cluster(1.5)] + regime = _make_regime(MarketRegime.UNCERTAINTY, gamma=0.80) + result = compute_v3_posterior(clusters=clusters, regime=regime, p_prior=0.50) + + expected_strength = abs(2.0 * result.p_up - 1.0) + assert result.strength == pytest.approx(expected_strength, abs=1e-9) + + def test_n_eff_total_sums_clusters(self): + """n_eff_total = sum of cluster n_eff values.""" + clusters = [ + _make_cluster(0.5, n_eff=2.0), + _make_cluster(0.3, n_eff=1.5), + _make_cluster(-0.2, n_eff=3.0), + ] + regime = _make_regime() + result = compute_v3_posterior(clusters=clusters, regime=regime) + + assert result.n_eff_total == pytest.approx(6.5, abs=1e-9) + + def test_regime_string_matches_input(self): + """regime field should match the input regime's value.""" + regime = _make_regime(MarketRegime.PANIC, gamma=0.70) + result = compute_v3_posterior(clusters=[], regime=regime) + assert result.regime == "panic" + + def test_p_down_is_complement(self): + """p_down = 1 - p_up.""" + clusters = [_make_cluster(0.8)] + regime = _make_regime() + result = compute_v3_posterior(clusters=clusters, regime=regime) + + assert result.p_down == pytest.approx(1.0 - result.p_up, abs=1e-10) diff --git a/tests/test_v3_risk.py b/tests/test_v3_risk.py new file mode 100644 index 0000000..2f2a593 --- /dev/null +++ b/tests/test_v3_risk.py @@ -0,0 +1,314 @@ +"""Unit tests for v3 stop-defined portfolio heat and risk tier auto-adjustment. + +Validates: Requirements 15.1–15.5, 18.1–18.6 +""" +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from services.risk.engine import ( + TierMetrics, + check_heat_capacity, + compute_available_heat_capacity, + compute_portfolio_heat, + evaluate_tier_adjustment, +) + +# =========================================================================== +# Heat computation tests (Requirements 15.1, 15.2) +# =========================================================================== + + +class TestComputePortfolioHeat: + """Test stop-defined portfolio heat calculation.""" + + def test_heat_three_positions_known_stops(self): + """3 positions with known stops → expected heat = 710. + + risk_dollars = position_value × stop_distance_pct + AAPL: 10000 × 0.03 = 300 + MSFT: 5000 × 0.05 = 250 + GOOG: 8000 × 0.02 = 160 + Total heat = 710 + """ + positions = [ + {"ticker": "AAPL", "position_value": 10000}, + {"ticker": "MSFT", "position_value": 5000}, + {"ticker": "GOOG", "position_value": 8000}, + ] + stop_distances = {"AAPL": 0.03, "MSFT": 0.05, "GOOG": 0.02} + + heat = compute_portfolio_heat(positions, stop_distances) + assert heat == pytest.approx(710.0) + + def test_heat_empty_positions(self): + """No positions → zero heat.""" + heat = compute_portfolio_heat([], {}) + assert heat == 0.0 + + def test_heat_missing_stop_distance_defaults_to_zero(self): + """Position with no stop distance entry contributes zero risk.""" + positions = [{"ticker": "AAPL", "position_value": 10000}] + stop_distances = {} # no entry for AAPL + + heat = compute_portfolio_heat(positions, stop_distances) + assert heat == 0.0 + + def test_heat_single_position(self): + """Single position → risk = value × stop.""" + positions = [{"ticker": "TSLA", "position_value": 20000}] + stop_distances = {"TSLA": 0.04} + + heat = compute_portfolio_heat(positions, stop_distances) + assert heat == pytest.approx(800.0) + + +# =========================================================================== +# Heat capacity / rejection tests (Requirements 15.3, 15.4, 15.5) +# =========================================================================== + + +class TestCheckHeatCapacity: + """Test heat capacity check — rejection when exceeding limit.""" + + def test_heat_at_limit_rejects_new_entry(self): + """current_heat=4500, new_risk=600, max_heat=5000 → rejected (False). + + 4500 + 600 = 5100 > 5000 → False + """ + result = check_heat_capacity( + current_heat=4500, + new_risk_dollars=600, + max_heat_pct=0.05, + portfolio_value=100000, + ) + assert result is False + + def test_heat_within_limit_allows_entry(self): + """current_heat=4000, new_risk=500, max_heat=5000 → allowed (True). + + 4000 + 500 = 4500 <= 5000 → True + """ + result = check_heat_capacity( + current_heat=4000, + new_risk_dollars=500, + max_heat_pct=0.05, + portfolio_value=100000, + ) + assert result is True + + def test_heat_exactly_at_limit_allows_entry(self): + """current_heat=4500, new_risk=500, max_heat=5000 → allowed (True). + + 4500 + 500 = 5000 <= 5000 → True (at boundary) + """ + result = check_heat_capacity( + current_heat=4500, + new_risk_dollars=500, + max_heat_pct=0.05, + portfolio_value=100000, + ) + assert result is True + + def test_heat_zero_portfolio_rejects(self): + """Zero portfolio value → max_heat = 0 → any new risk rejected.""" + result = check_heat_capacity( + current_heat=0, + new_risk_dollars=100, + max_heat_pct=0.05, + portfolio_value=0, + ) + assert result is False + + +class TestComputeAvailableHeatCapacity: + """Test available heat capacity computation.""" + + def test_available_capacity_normal(self): + """max_heat=5000, current=3000 → available=2000.""" + available = compute_available_heat_capacity( + current_heat=3000, + max_heat_pct=0.05, + portfolio_value=100000, + ) + assert available == pytest.approx(2000.0) + + def test_available_capacity_fully_used(self): + """current >= max → available = 0.""" + available = compute_available_heat_capacity( + current_heat=5500, + max_heat_pct=0.05, + portfolio_value=100000, + ) + assert available == 0.0 + + +# =========================================================================== +# Tier downgrade tests (Requirements 18.2, 18.3, 18.5) +# =========================================================================== + + +class TestTierDowngrade: + """Test that a single bad metric triggers downgrade.""" + + def _good_metrics(self, **overrides) -> TierMetrics: + """Create metrics that pass all upgrade conditions, then override.""" + defaults = { + "profit_factor_30d": 1.5, + "max_drawdown_30d": 0.03, + "calibration_error": 0.08, + "realized_sharpe_30d": 1.5, + "n_trades_30d": 25, + "reserve_pool_pct": 0.25, + } + defaults.update(overrides) + return TierMetrics(**defaults) + + def test_downgrade_low_profit_factor(self): + """profit_factor=0.9 (< 1.0) → downgrade.""" + metrics = self._good_metrics(profit_factor_30d=0.9) + assert evaluate_tier_adjustment(metrics) == "downgrade" + + def test_downgrade_high_drawdown(self): + """max_drawdown=0.15 (> 0.12) → downgrade.""" + metrics = self._good_metrics(max_drawdown_30d=0.15) + assert evaluate_tier_adjustment(metrics) == "downgrade" + + def test_downgrade_high_calibration_error(self): + """calibration_error=0.25 (> 0.20) → downgrade.""" + metrics = self._good_metrics(calibration_error=0.25) + assert evaluate_tier_adjustment(metrics) == "downgrade" + + def test_downgrade_negative_sharpe(self): + """sharpe=-0.5 (< 0) → downgrade.""" + metrics = self._good_metrics(realized_sharpe_30d=-0.5) + assert evaluate_tier_adjustment(metrics) == "downgrade" + + +# =========================================================================== +# Tier upgrade tests (Requirements 18.4) +# =========================================================================== + + +class TestTierUpgrade: + """Test that all metrics must be good for upgrade.""" + + def test_upgrade_all_good(self): + """All upgrade conditions met → upgrade. + + profit_factor=1.5, drawdown=0.03, cal_error=0.08, + sharpe=1.5, n_trades=25, reserve=0.25 + """ + metrics = TierMetrics( + profit_factor_30d=1.5, + max_drawdown_30d=0.03, + calibration_error=0.08, + realized_sharpe_30d=1.5, + n_trades_30d=25, + reserve_pool_pct=0.25, + ) + assert evaluate_tier_adjustment(metrics) == "upgrade" + + def test_hold_insufficient_trades(self): + """All upgrade conditions met EXCEPT n_trades=15 (< 20) → hold.""" + metrics = TierMetrics( + profit_factor_30d=1.5, + max_drawdown_30d=0.03, + calibration_error=0.08, + realized_sharpe_30d=1.5, + n_trades_30d=15, + reserve_pool_pct=0.25, + ) + assert evaluate_tier_adjustment(metrics) == "hold" + + def test_hold_insufficient_reserve(self): + """All good except reserve=0.15 (< 0.20) → hold.""" + metrics = TierMetrics( + profit_factor_30d=1.5, + max_drawdown_30d=0.03, + calibration_error=0.08, + realized_sharpe_30d=1.5, + n_trades_30d=25, + reserve_pool_pct=0.15, + ) + assert evaluate_tier_adjustment(metrics) == "hold" + + def test_hold_drawdown_too_high_for_upgrade(self): + """drawdown=0.06 (> 0.05 for upgrade) but < 0.12 (no downgrade) → hold.""" + metrics = TierMetrics( + profit_factor_30d=1.5, + max_drawdown_30d=0.06, + calibration_error=0.08, + realized_sharpe_30d=1.5, + n_trades_30d=25, + reserve_pool_pct=0.25, + ) + assert evaluate_tier_adjustment(metrics) == "hold" + + +# =========================================================================== +# 7-day cooldown enforcement (Requirements 18.5, 18.6) +# =========================================================================== + + +class TestTierCooldown: + """Test 7-day cooldown enforcement logic at the caller level. + + The evaluate_tier_adjustment function is pure — it doesn't track state. + The 7-day cooldown is enforced by the caller. Here we verify the pure + logic that a caller would use: compare last_upgrade_time to now and + only allow upgrade if >= 7 days have passed. + """ + + def test_cooldown_blocks_upgrade_within_7_days(self): + """Upgrade blocked when last upgrade was < 7 days ago.""" + last_upgrade = datetime(2024, 1, 10, tzinfo=timezone.utc) + now = datetime(2024, 1, 15, tzinfo=timezone.utc) # 5 days later + cooldown_days = 7 + + days_since_last = (now - last_upgrade).days + can_upgrade = days_since_last >= cooldown_days + + assert can_upgrade is False + + def test_cooldown_allows_upgrade_after_7_days(self): + """Upgrade allowed when last upgrade was >= 7 days ago.""" + last_upgrade = datetime(2024, 1, 10, tzinfo=timezone.utc) + now = datetime(2024, 1, 17, tzinfo=timezone.utc) # 7 days later + cooldown_days = 7 + + days_since_last = (now - last_upgrade).days + can_upgrade = days_since_last >= cooldown_days + + assert can_upgrade is True + + def test_cooldown_allows_upgrade_well_past_7_days(self): + """Upgrade allowed when last upgrade was well past cooldown.""" + last_upgrade = datetime(2024, 1, 1, tzinfo=timezone.utc) + now = datetime(2024, 2, 1, tzinfo=timezone.utc) # 31 days later + cooldown_days = 7 + + days_since_last = (now - last_upgrade).days + can_upgrade = days_since_last >= cooldown_days + + assert can_upgrade is True + + def test_downgrade_ignores_cooldown(self): + """Downgrade is applied immediately regardless of cooldown. + + Even if an upgrade happened yesterday, downgrade still fires. + """ + # The evaluate function doesn't have cooldown logic — it always + # returns "downgrade" when conditions are met, regardless of timing. + metrics = TierMetrics( + profit_factor_30d=0.8, # triggers downgrade + max_drawdown_30d=0.03, + calibration_error=0.08, + realized_sharpe_30d=1.5, + n_trades_30d=25, + reserve_pool_pct=0.25, + ) + # Downgrade fires regardless of when last upgrade occurred + assert evaluate_tier_adjustment(metrics) == "downgrade" diff --git a/tests/test_v3_sizing.py b/tests/test_v3_sizing.py new file mode 100644 index 0000000..fd228e1 --- /dev/null +++ b/tests/test_v3_sizing.py @@ -0,0 +1,403 @@ +"""Unit tests for v3 fractional Kelly sizing and regime-aware stops. + +Tests Kelly fraction computation, capacity cap enforcement, position minimum +downgrade, stop/take-profit levels, and trailing stop monotonicity. + +Requirements validated: 14.1–14.7, 16.1–16.5 +""" +from __future__ import annotations + +import pytest + +from services.trading.position_sizer import ( + KellySizingResult, + compute_kelly_sizing, + compute_reward_ratio, +) +from services.trading.stop_loss_manager import ( + TrailingStopResult, + V3StopLevels, + compute_trailing_stop, + compute_v3_stops, +) + + +# --------------------------------------------------------------------------- +# Kelly sizing: negative edge → size = 0 (Req 14.7) +# --------------------------------------------------------------------------- + + +class TestKellyNegativeEdge: + """Negative Kelly fraction forces zero sizing and downgrade.""" + + def test_p_win_03_b_2_negative_edge(self): + """p_win=0.3, b=2.0 → f_kelly = (0.3*2 - 0.7)/2 = -0.05 → downgrade.""" + result = compute_kelly_sizing( + p_win=0.3, + b=2.0, + confidence=0.8, + data_quality=0.9, + contradiction=0.1, + max_position_pct=0.10, + available_caps={"sector_capacity": 0.10, "correlation_capacity": 0.10, "heat_capacity": 0.10}, + ) + assert isinstance(result, KellySizingResult) + assert result.f_kelly == pytest.approx(-0.05, abs=1e-9) + assert result.portfolio_pct == 0.0 + assert result.downgrade is True + assert result.downgrade_reason == "negative_edge" + + def test_p_win_05_b_1_zero_edge(self): + """p_win=0.5, b=1.0 → f_kelly = (0.5*1 - 0.5)/1 = 0.0 → downgrade.""" + result = compute_kelly_sizing( + p_win=0.5, + b=1.0, + confidence=0.8, + data_quality=0.9, + contradiction=0.1, + max_position_pct=0.10, + available_caps={}, + ) + assert result.f_kelly == pytest.approx(0.0, abs=1e-9) + assert result.portfolio_pct == 0.0 + assert result.downgrade is True + assert result.downgrade_reason == "negative_edge" + + +# --------------------------------------------------------------------------- +# Kelly sizing: positive edge → bounded size (Req 14.1–14.4) +# --------------------------------------------------------------------------- + + +class TestKellyPositiveEdge: + """Positive Kelly fraction produces a sized position within caps.""" + + def test_p_win_07_b_2_positive_size(self): + """p_win=0.7, b=2.0 → f_kelly = (1.4-0.3)/2 = 0.55 → positive size.""" + confidence = 0.8 + data_quality = 0.9 + contradiction = 0.1 + result = compute_kelly_sizing( + p_win=0.7, + b=2.0, + confidence=confidence, + data_quality=data_quality, + contradiction=contradiction, + max_position_pct=0.10, + available_caps={"sector_capacity": 0.10, "correlation_capacity": 0.10, "heat_capacity": 0.10}, + ) + assert result.f_kelly == pytest.approx(0.55, abs=1e-9) + # portfolio_pct = 0.55 * 0.25 * 0.8 * 0.9 * (1 - 0.1) = 0.55 * 0.25 * 0.8 * 0.9 * 0.9 + expected_raw = 0.55 * 0.25 * confidence * data_quality * (1.0 - contradiction) + # Clamped to max_position_pct = 0.10 + expected_pct = min(expected_raw, 0.10) + assert result.portfolio_pct == pytest.approx(expected_pct, abs=1e-9) + assert result.portfolio_pct > 0.0 + assert result.portfolio_pct <= 0.10 + assert result.downgrade is False + assert result.downgrade_reason == "" + + def test_high_confidence_respects_max_cap(self): + """Even with strong edge, portfolio_pct cannot exceed max_position_pct.""" + result = compute_kelly_sizing( + p_win=0.9, + b=3.0, + confidence=1.0, + data_quality=1.0, + contradiction=0.0, + max_position_pct=0.05, + available_caps={}, + ) + assert result.portfolio_pct <= 0.05 + + +# --------------------------------------------------------------------------- +# Cap enforcement: sector, correlation, heat (Req 14.5) +# --------------------------------------------------------------------------- + + +class TestCapEnforcement: + """Capacity constraints cap portfolio_pct.""" + + def test_sector_capacity_caps(self): + """sector_capacity=0.02 caps portfolio_pct at 0.02.""" + result = compute_kelly_sizing( + p_win=0.7, + b=2.0, + confidence=0.8, + data_quality=0.9, + contradiction=0.1, + max_position_pct=0.10, + available_caps={"sector_capacity": 0.02, "correlation_capacity": 0.10, "heat_capacity": 0.10}, + ) + assert result.portfolio_pct <= 0.02 + + def test_correlation_capacity_zero_forces_zero(self): + """correlation_capacity=0.0 (avg corr > 0.80) → portfolio_pct = 0.""" + result = compute_kelly_sizing( + p_win=0.7, + b=2.0, + confidence=0.8, + data_quality=0.9, + contradiction=0.1, + max_position_pct=0.10, + available_caps={"sector_capacity": 0.10, "correlation_capacity": 0.0, "heat_capacity": 0.10}, + ) + # correlation_capacity=0 → min(pct, 0) = 0 → below minimum → downgrade + assert result.portfolio_pct == 0.0 + assert result.downgrade is True + assert result.downgrade_reason == "position_below_minimum" + + def test_heat_capacity_caps(self): + """heat_capacity=0.01 caps portfolio_pct at 0.01.""" + result = compute_kelly_sizing( + p_win=0.7, + b=2.0, + confidence=0.8, + data_quality=0.9, + contradiction=0.1, + max_position_pct=0.10, + available_caps={"sector_capacity": 0.10, "correlation_capacity": 0.10, "heat_capacity": 0.01}, + ) + assert result.portfolio_pct <= 0.01 + + def test_minimum_of_all_caps(self): + """portfolio_pct is min of all capacity constraints.""" + result = compute_kelly_sizing( + p_win=0.7, + b=2.0, + confidence=0.8, + data_quality=0.9, + contradiction=0.1, + max_position_pct=0.10, + available_caps={"sector_capacity": 0.03, "correlation_capacity": 0.05, "heat_capacity": 0.04}, + ) + # Minimum cap is sector_capacity=0.03 + assert result.portfolio_pct <= 0.03 + + +# --------------------------------------------------------------------------- +# Position below minimum → downgrade (Req 14.6) +# --------------------------------------------------------------------------- + + +class TestPositionBelowMinimum: + """Tiny positions below 0.005 trigger a downgrade.""" + + def test_tiny_position_downgrade(self): + """Low data_quality and high contradiction → pct < 0.005 → downgrade.""" + result = compute_kelly_sizing( + p_win=0.55, + b=1.5, + confidence=0.3, + data_quality=0.3, + contradiction=0.7, + max_position_pct=0.10, + available_caps={}, + ) + # f_kelly = (0.55*1.5 - 0.45)/1.5 = (0.825-0.45)/1.5 = 0.25 + # raw = 0.25 * 0.25 * 0.3 * 0.3 * 0.3 = 0.0016875 < 0.005 + assert result.portfolio_pct == 0.0 + assert result.downgrade is True + assert result.downgrade_reason == "position_below_minimum" + + def test_just_above_minimum_no_downgrade(self): + """Position at or above 0.005 is not downgraded.""" + result = compute_kelly_sizing( + p_win=0.7, + b=2.0, + confidence=0.8, + data_quality=0.9, + contradiction=0.1, + max_position_pct=0.10, + available_caps={}, + ) + # f_kelly=0.55, raw=0.55*0.25*0.8*0.9*0.9 = 0.0891 >> 0.005 + assert result.portfolio_pct >= 0.005 + assert result.downgrade is False + + +# --------------------------------------------------------------------------- +# Reward ratio computation (Req 14.2) +# --------------------------------------------------------------------------- + + +class TestRewardRatio: + """Tests compute_reward_ratio clamping and formula.""" + + def test_reward_ratio_typical(self): + """Typical values produce a ratio between 1.2 and 3.0.""" + b = compute_reward_ratio(confidence=0.7, strength=0.5, contradiction=0.2) + # raw = 1.2 + 2.0*0.7 + 1.0*0.5 - 0.2 = 1.2 + 1.4 + 0.5 - 0.2 = 2.9 + assert b == pytest.approx(2.9, abs=1e-9) + + def test_reward_ratio_clamp_low(self): + """Low confidence/strength and high contradiction → clamped to 1.2.""" + b = compute_reward_ratio(confidence=0.0, strength=0.0, contradiction=1.0) + # raw = 1.2 + 0 + 0 - 1.0 = 0.2 → clamped to 1.2 + assert b == pytest.approx(1.2, abs=1e-9) + + def test_reward_ratio_clamp_high(self): + """High confidence/strength → clamped to 3.0.""" + b = compute_reward_ratio(confidence=1.0, strength=1.0, contradiction=0.0) + # raw = 1.2 + 2.0 + 1.0 - 0 = 4.2 → clamped to 3.0 + assert b == pytest.approx(3.0, abs=1e-9) + + +# --------------------------------------------------------------------------- +# Stop/TP computation with known inputs (Req 16.1–16.3) +# --------------------------------------------------------------------------- + + +class TestV3Stops: + """Tests for regime-aware stop loss and take profit computation.""" + + def test_known_inputs(self): + """entry=100, ATR_pct=0.02, regime_mult=2.0, sigma_h=0.04, b=2.0.""" + result = compute_v3_stops( + entry_price=100.0, + atr_pct=0.02, + regime_atr_mult=2.0, + sigma_h=0.04, + reward_ratio=2.0, + ) + assert isinstance(result, V3StopLevels) + # stop_distance = max(0.02*2.0, 0.04*1.25, 0.005) = max(0.04, 0.05, 0.005) = 0.05 + assert result.stop_distance_pct == pytest.approx(0.05, abs=1e-9) + # stop = 100 * (1 - 0.05) = 95 + assert result.stop_loss == pytest.approx(95.0, abs=1e-9) + # TP = 100 * (1 + 2.0 * 0.05) = 110 + assert result.take_profit == pytest.approx(110.0, abs=1e-9) + assert result.reward_ratio == pytest.approx(2.0, abs=1e-9) + + def test_min_stop_distance_enforced(self): + """Very low ATR and sigma → min stop distance of 0.005 is enforced.""" + result = compute_v3_stops( + entry_price=50.0, + atr_pct=0.001, + regime_atr_mult=1.0, + sigma_h=0.001, + reward_ratio=2.0, + ) + # max(0.001*1.0, 0.001*1.25, 0.005) = 0.005 + assert result.stop_distance_pct == pytest.approx(0.005, abs=1e-9) + assert result.stop_loss == pytest.approx(50.0 * (1 - 0.005), abs=1e-9) + + def test_atr_dominates(self): + """High ATR*mult dominates stop distance.""" + result = compute_v3_stops( + entry_price=200.0, + atr_pct=0.05, + regime_atr_mult=2.5, + sigma_h=0.03, + reward_ratio=1.5, + ) + # max(0.05*2.5, 0.03*1.25, 0.005) = max(0.125, 0.0375, 0.005) = 0.125 + assert result.stop_distance_pct == pytest.approx(0.125, abs=1e-9) + assert result.stop_loss == pytest.approx(200.0 * (1 - 0.125), abs=1e-9) + assert result.take_profit == pytest.approx(200.0 * (1 + 1.5 * 0.125), abs=1e-9) + + +# --------------------------------------------------------------------------- +# Trailing stop monotonicity (Req 16.4–16.5) +# --------------------------------------------------------------------------- + + +class TestTrailingStopMonotonicity: + """Tests trailing stop activation and non-decreasing behavior.""" + + def test_not_activated_below_threshold(self): + """Gain < 50% of TP distance → trailing not activated.""" + result = compute_trailing_stop( + existing_stop=95.0, + current_price=101.0, # Gain = 1.0, TP distance = 10, 1.0 < 0.5*10 + entry_price=100.0, + take_profit=110.0, + atr_pct=0.02, + trailing_atr_mult=1.5, + sigma_h=0.04, + ) + assert isinstance(result, TrailingStopResult) + assert result.activated is False + assert result.trailing_stop == 95.0 # Unchanged + + def test_activated_above_threshold(self): + """Gain >= 50% of TP distance → trailing activated.""" + result = compute_trailing_stop( + existing_stop=95.0, + current_price=105.0, # Gain = 5.0, TP distance = 10, 5.0 >= 0.5*10 + entry_price=100.0, + take_profit=110.0, + atr_pct=0.02, + trailing_atr_mult=1.5, + sigma_h=0.04, + ) + assert result.activated is True + # trailing_distance = max(0.02*1.5, 0.04*0.75) = max(0.03, 0.03) = 0.03 + # candidate = 105 * (1 - 0.03) = 101.85 + # max(95.0, 101.85) = 101.85 + assert result.trailing_stop == pytest.approx(105.0 * (1 - 0.03), abs=1e-9) + assert result.trailing_stop > 95.0 + + def test_monotonicity_over_price_sequence(self): + """Trailing stop never decreases over a price sequence.""" + # Setup: entry=100, TP=110, existing_stop=95 + entry = 100.0 + take_profit = 110.0 + atr_pct = 0.02 + trailing_atr_mult = 1.5 + sigma_h = 0.04 + prices = [102.0, 105.0, 103.0, 108.0] + + current_stop = 95.0 + stops_recorded = [current_stop] + + for price in prices: + result = compute_trailing_stop( + existing_stop=current_stop, + current_price=price, + entry_price=entry, + take_profit=take_profit, + atr_pct=atr_pct, + trailing_atr_mult=trailing_atr_mult, + sigma_h=sigma_h, + ) + current_stop = result.trailing_stop + stops_recorded.append(current_stop) + + # Verify monotonically non-decreasing + for i in range(1, len(stops_recorded)): + assert stops_recorded[i] >= stops_recorded[i - 1], ( + f"Stop decreased at step {i}: {stops_recorded[i]} < {stops_recorded[i-1]}" + ) + + def test_trailing_stop_never_below_existing(self): + """Even with price drop, trailing stop stays at existing_stop.""" + result = compute_trailing_stop( + existing_stop=102.0, + current_price=105.0, + entry_price=100.0, + take_profit=110.0, + atr_pct=0.05, # Large ATR → candidate might be below existing + trailing_atr_mult=2.0, + sigma_h=0.08, + ) + # trailing_distance = max(0.05*2.0, 0.08*0.75) = max(0.10, 0.06) = 0.10 + # candidate = 105 * (1 - 0.10) = 94.5 + # max(102.0, 94.5) = 102.0 + assert result.trailing_stop == pytest.approx(102.0, abs=1e-9) + assert result.activated is True + + def test_exact_50pct_threshold_activates(self): + """Gain exactly at 50% of TP distance activates trailing.""" + # TP distance = 10, so gain must be >= 5.0 + result = compute_trailing_stop( + existing_stop=95.0, + current_price=105.0, # Gain = 5.0 = 0.50 * 10 + entry_price=100.0, + take_profit=110.0, + atr_pct=0.02, + trailing_atr_mult=1.5, + sigma_h=0.04, + ) + assert result.activated is True