feat: math core v3 engine upgrade

This commit is contained in:
Celes Renata
2026-06-27 12:21:41 +00:00
parent 365bc5d4b7
commit b4bf0f2361
34 changed files with 11693 additions and 3 deletions
@@ -0,0 +1 @@
{"specId": "ff0d03d7-3469-4551-bf05-15295b735c83", "workflowType": "requirements-first", "specType": "feature"}
+851
View File
@@ -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<j} rho_ij*w_i*w_j)"""
...
def compute_cluster_llr(llrs: list[float], n_eff: float) -> 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: <title>`
- 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
@@ -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)
+451
View File
@@ -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.11.8, 2.12.9, 3.13.6, 21.121.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.11.8, 2.12.9, 3.13.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.14.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.26.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.15.7, 6.16.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.17.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.18.5, 17.117.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.19.5, 10.110.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.111.7, 12.112.7, 13.113.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.114.7, 16.116.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.115.5, 18.118.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.119.6, 20.120.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"] }
]
}
```
File diff suppressed because it is too large Load Diff
+129
View File
@@ -5,14 +5,20 @@ log-likelihood accumulation, Beta distribution parameters, and
Shannon entropy for mixed-signal detection. Shannon entropy for mixed-signal detection.
Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 9.1, 9.7 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 from __future__ import annotations
import math import math
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING
from services.aggregation.scoring import WeightedSignal from services.aggregation.scoring import WeightedSignal
if TYPE_CHECKING:
from services.aggregation.regime import V3RegimeClassification
from services.aggregation.worker import EvidenceCluster
@dataclass(frozen=True) @dataclass(frozen=True)
class BayesianPosterior: class BayesianPosterior:
@@ -125,3 +131,126 @@ def compute_bayesian_posterior(
entropy=entropy, entropy=entropy,
signal_count=count, 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,
)
+68
View File
@@ -10,10 +10,14 @@ from __future__ import annotations
import math import math
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING
from services.aggregation.scoring import WeightedSignal from services.aggregation.scoring import WeightedSignal
from services.shared.schemas import DisagreementDetail from services.shared.schemas import DisagreementDetail
if TYPE_CHECKING:
from services.aggregation.worker import EvidenceCluster
@dataclass @dataclass
class CatalystEntry: class CatalystEntry:
@@ -236,3 +240,67 @@ def _detect_catalyst_disagreement(
)) ))
return details 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.17.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))
+105
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import json import json
import logging import logging
import math
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -955,3 +956,107 @@ def apply_accelerated_decay(
return accelerated return accelerated
return standard_decay return standard_decay
# ---------------------------------------------------------------------------
# V3 Macro Layer — Noisy-OR Exposure & LLR Emission (Requirements: 9.19.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
+102
View File
@@ -13,11 +13,15 @@ import logging
import math import math
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import TYPE_CHECKING
import asyncpg import asyncpg
from services.shared.schemas import TrendSummary from services.shared.schemas import TrendSummary
if TYPE_CHECKING:
from services.aggregation.regime import V3RegimeClassification
logger = logging.getLogger("projection") logger = logging.getLogger("projection")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -493,3 +497,101 @@ async def persist_trend_projection(
projection.diverges_from_current, projection.diverges_from_current,
) )
return str(row_id) return str(row_id)
# ---------------------------------------------------------------------------
# V3 Posterior State Projection (Requirements: 11.111.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.111.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,
)
+148
View File
@@ -168,3 +168,151 @@ def classify_regime(
bearish_threshold=-threshold, bearish_threshold=-threshold,
contradiction_penalty_multiplier=contradiction_mult, 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.26.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.26.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,
)
+625
View File
@@ -8,9 +8,12 @@ Requirements: 2.12.6, 3.13.5, 4.24.3, 5.15.7, 6.16.5, 16.416.5
""" """
from __future__ import annotations from __future__ import annotations
import hashlib
import logging
import math import math
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any
from services.shared.schemas import MarketContext from services.shared.schemas import MarketContext
@@ -588,3 +591,625 @@ def weighted_sentiment_average(signals: list[WeightedSignal]) -> float:
if total_weight == 0.0: if total_weight == 0.0:
return 0.0 return 0.0
return weighted_sum / total_weight 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.12.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.13.6)
# ---------------------------------------------------------------------------
def compute_llr(unit: EvidenceUnit, q_i: float) -> float:
"""Convert calibrated reliability to log-likelihood ratio.
Requirements: 3.13.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.13.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.33.4: LLR_i = direction × ln(p_correct / (1 - p_correct))
llr = unit.direction * math.log(p_correct / (1.0 - p_correct))
return llr
@@ -378,3 +378,74 @@ def build_pattern_weighted_signals(
)) ))
return signals return signals
# ---------------------------------------------------------------------------
# v3 — Correlation-shrunk competitive propagation (Requirements: 10.110.5)
# ---------------------------------------------------------------------------
_V3_MAX_NETWORK_DISTANCE = 3
def compute_shrunk_correlation(
rho_rolling: float,
n_observations: int,
same_sector: bool,
) -> float:
"""Compute shrinkage-adjusted correlation.
Shrinks the rolling correlation toward a sector-aware prior using a
Bayesian-style weight of n / (n + 30).
rho_prior = 0.30 if same_sector else 0.10
rho_shrunk = (n/(n+30)) × rho_rolling + (30/(n+30)) × rho_prior
rho_effective = max(rho_shrunk, 0)
Args:
rho_rolling: Rolling pairwise correlation estimate.
n_observations: Number of observations used to compute rho_rolling.
same_sector: Whether the two securities are in the same sector.
Returns:
Non-negative shrinkage-adjusted correlation (rho_effective).
Requirements: 10.1, 10.2
"""
rho_prior = 0.30 if same_sector else 0.10
n = n_observations
rho_shrunk = (n / (n + 30)) * rho_rolling + (30 / (n + 30)) * rho_prior
rho_effective = max(rho_shrunk, 0.0)
return rho_effective
def compute_competitive_llr(
llr_source: float,
rho_effective: float,
d_network: int,
pattern_confidence: float,
) -> float:
"""Compute competitive LLR with graph attenuation.
attenuation = rho_effective × exp(-0.85 × d_network)
LLR_competitive = clamp(llr_source × attenuation × pattern_confidence, -1.25, 1.25)
When d_network > 3 → attenuation = 0 → LLR_competitive = 0
Args:
llr_source: Source signal LLR value.
rho_effective: Shrinkage-adjusted correlation (non-negative).
d_network: Graph distance between source and target (integer >= 1).
pattern_confidence: Confidence of the historical pattern in [0, 1].
Returns:
Competitive LLR clamped to [-1.25, 1.25]. Returns 0.0 when
d_network exceeds max distance of 3.
Requirements: 10.3, 10.4, 10.5
"""
if d_network > _V3_MAX_NETWORK_DISTANCE:
return 0.0
attenuation = rho_effective * math.exp(-0.85 * d_network)
llr_competitive = llr_source * attenuation * pattern_confidence
return max(-1.25, min(1.25, llr_competitive))
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -456,7 +456,7 @@ async def list_trend_history(
dominant_catalysts, material_risks, generated_at dominant_catalysts, material_risks, generated_at
FROM trend_history FROM trend_history
{where} {where}
ORDER BY generated_at ASC ORDER BY generated_at DESC
LIMIT ${idx}""", LIMIT ${idx}""",
*params, limit, *params, limit,
) )
@@ -470,6 +470,9 @@ async def list_trend_history(
d["dominant_catalysts"] = _parse_jsonb(d.get("dominant_catalysts")) d["dominant_catalysts"] = _parse_jsonb(d.get("dominant_catalysts"))
d["material_risks"] = _parse_jsonb(d.get("material_risks")) d["material_risks"] = _parse_jsonb(d.get("material_risks"))
results.append(d) 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 return results
@@ -496,7 +499,7 @@ async def get_market_prices(
(data->>'t')::bigint AS bar_timestamp (data->>'t')::bigint AS bar_timestamp
FROM market_snapshots FROM market_snapshots
WHERE ticker = $1 AND snapshot_type = 'bar' WHERE ticker = $1 AND snapshot_type = 'bar'
ORDER BY captured_at ASC ORDER BY captured_at DESC
LIMIT $2""", LIMIT $2""",
ticker, limit, ticker, limit,
) )
@@ -521,6 +524,9 @@ async def get_market_prices(
"bar_timestamp": bar_ts, "bar_timestamp": bar_ts,
"captured_at": r["captured_at"].isoformat() if r["captured_at"] else None, "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 # Compute 90-day high/low from all bars in the window
cutoff_90d = datetime.now(timezone.utc) - timedelta(days=90) cutoff_90d = datetime.now(timezone.utc) - timedelta(days=90)
+291
View File
@@ -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). 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 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.113.5)
""" """
from __future__ import annotations from __future__ import annotations
@@ -464,3 +468,290 @@ def evaluate_eligibility(
p_bull=p_bull if probabilistic else None, p_bull=p_bull if probabilistic else None,
pipeline_mode="probabilistic" if probabilistic else "heuristic", pipeline_mode="probabilistic" if probabilistic else "heuristic",
) )
# ---------------------------------------------------------------------------
# V3 Return Distribution and EV Gate (Requirements 12.112.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.113.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.113.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.113.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,
)
+161
View File
@@ -14,6 +14,7 @@ from __future__ import annotations
import math import math
import uuid import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from enum import Enum from enum import Enum
from typing import Any from typing import Any
@@ -702,3 +703,163 @@ def evaluate_order(
state_snapshot=state, state_snapshot=state,
evaluated_at=now, evaluated_at=now,
) )
# ===========================================================================
# v3 Stop-Defined Portfolio Heat (Requirements 15.115.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.118.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"
+111
View File
@@ -4,11 +4,14 @@ Computes dollar allocation and share quantity for a trade by applying
a sequential adjustment pipeline: confidence gate, correlation reduction, a sequential adjustment pipeline: confidence gate, correlation reduction,
sector exposure, diversification bonus, earnings proximity, portfolio sector exposure, diversification bonus, earnings proximity, portfolio
heat check, active-pool minimum, absolute cap, and share rounding. heat check, active-pool minimum, absolute cap, and share rounding.
Also provides v3 fractional Kelly position sizing (Requirements 14.114.7).
""" """
from __future__ import annotations from __future__ import annotations
import math import math
from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from services.trading.models import ( from services.trading.models import (
@@ -345,3 +348,111 @@ class PositionSizer:
return new_dollar, new_pct return new_dollar, new_pct
return dollar_amount, allocation_pct return dollar_amount, allocation_pct
# ===========================================================================
# v3 Fractional Kelly Position Sizing (Requirements 14.114.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="",
)
+97
View File
@@ -5,12 +5,15 @@ re-evaluates levels when volatility or market conditions change, detects
price crossings that should trigger exits, and tightens stops under price crossings that should trigger exits, and tightens stops under
high-heat or high-severity-event conditions. high-heat or high-severity-event conditions.
Also provides v3 regime-aware stop loss and take profit (Requirements 16.116.5).
All public methods are synchronous (pure computation, no DB access). All public methods are synchronous (pure computation, no DB access).
Persistence is handled by the caller (engine.py). Persistence is handled by the caller (engine.py).
""" """
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from services.trading.models import ( from services.trading.models import (
@@ -20,6 +23,100 @@ from services.trading.models import (
StopTrigger, StopTrigger,
) )
# ---------------------------------------------------------------------------
# v3 Regime-Aware Stop Loss and Take Profit (Requirements 16.116.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: class StopLossManager:
"""Compute and maintain dynamic stop-loss / take-profit levels.""" """Compute and maintain dynamic stop-loss / take-profit levels."""
+168
View File
@@ -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]"
)
+454
View File
@@ -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}'"
)
+173
View File
@@ -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}"
)
+609
View File
@@ -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.26.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.26.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}"
)
+107
View File
@@ -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}"
)
+417
View File
@@ -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.11.8, 2.12.9, 3.13.6, 21.121.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.12.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.12.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.13.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.13.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.33.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.33.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.11.8, 21.121.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.11.8, 21.121.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.11.8, 21.121.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.11.8, 21.121.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"
+290
View File
@@ -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.14.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]
+349
View File
@@ -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.18.5, 17.117.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 == ""
+176
View File
@@ -0,0 +1,176 @@
"""Unit tests for v3 LLR entropy contradiction score.
Tests compute_v3_contradiction from services.aggregation.contradiction.
Requirements validated: 7.17.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)
+629
View File
@@ -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.111.7, 12.112.7, 13.113.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.112.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.312.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.113.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.111.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)
+569
View File
@@ -0,0 +1,569 @@
"""Unit tests for EvidenceUnit normalization and LLR conversion.
Validates: Requirements 1.11.8, 2.12.9, 3.13.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
+454
View File
@@ -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.119.6, 20.120.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"
+359
View File
@@ -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.19.5, 10.110.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
+471
View File
@@ -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.15.7, 6.16.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.16.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)
+314
View File
@@ -0,0 +1,314 @@
"""Unit tests for v3 stop-defined portfolio heat and risk tier auto-adjustment.
Validates: Requirements 15.115.5, 18.118.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"
+403
View File
@@ -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.114.7, 16.116.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.114.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.116.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.416.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