feat: math core v3 engine upgrade
This commit is contained in:
@@ -0,0 +1 @@
|
||||
{"specId": "ff0d03d7-3469-4551-bf05-15295b735c83", "workflowType": "requirements-first", "specType": "feature"}
|
||||
@@ -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)
|
||||
@@ -0,0 +1,451 @@
|
||||
# Implementation Plan: Math Core v3 Engine
|
||||
|
||||
## Overview
|
||||
|
||||
Incremental upgrade of the Stonks Oracle signal processing engine from dual-mode heuristic/probabilistic to the v3 Calibrated Evidence Engine. Implementation follows the v3 math doc section 20 order: EvidenceUnit → reliability → LLR → clustering → posterior → contradiction → confidence → macro/competitive layers → EV gate → Kelly sizing → stop-defined heat → retire heuristic scoring. All v3 code operates behind the `v3_engine_enabled` feature flag with heuristic fallback.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. EvidenceUnit and LLR conversion behind feature flag
|
||||
- [x] 1.1 Implement EvidenceUnit dataclass and normalization functions in `services/aggregation/scoring.py`
|
||||
- Add frozen dataclass `EvidenceUnit` with all 16 fields (symbol, layer, event_type, source_id, source_group, timestamp, horizon, direction, sentiment_strength, impact, extraction_conf, source_cred, novelty, event_base_rate, cluster_id)
|
||||
- Implement `normalize_company_signal()`, `normalize_macro_signal()`, `normalize_competitive_signal()`
|
||||
- Validate required fields (symbol, timestamp, source_id) — reject with warning on missing
|
||||
- Substitute 0.5 for missing optional numeric fields
|
||||
- Map direction from sentiment/impact_direction strings (+1/-1/0)
|
||||
- Assign event_base_rate from EVENT_TYPE_BASE_RATES lookup (default 0.10)
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8_
|
||||
|
||||
- [x] 1.2 Implement calibrated reliability pipeline (`compute_v3_reliability`) in `services/aggregation/scoring.py`
|
||||
- Add `SourceStats` dataclass (source_id, hits, misses, alpha_0=3, beta_0=3)
|
||||
- Add `ReliabilityComponents` dataclass (q_ext, q_source, q_recency, q_uniqueness, q_i)
|
||||
- Implement q_ext = sigmoid(8.0 × (extraction_conf - 0.55))
|
||||
- Implement q_source via Bayesian shrinkage: E[theta_s] = (alpha_0 + hits) / (alpha_0 + beta_0 + hits + misses), then clamp((E - 0.50) / 0.35, 0, 1)
|
||||
- Implement q_recency = 2^(-age_hours / tau_adaptive) with adaptive half-life formula
|
||||
- Implement q_uniqueness = clamp(0.5 + 0.5 × novelty, 0.5, 1.0) × (1 / sqrt(1 + dup_count))
|
||||
- Implement q_i = clamp(q_ext × q_source × source_cred × q_recency × q_uniqueness, 0, 1)
|
||||
- Apply floor of 0.01 on q_recency only for explainability display
|
||||
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 2.8, 2.9_
|
||||
|
||||
- [x] 1.3 Implement LLR conversion (`compute_llr`) in `services/aggregation/scoring.py`
|
||||
- Compute p_correct = clamp(0.50 + 0.35 × q_i × impact × sentiment_strength, 0.501, 0.85)
|
||||
- Compute LLR_i = direction × ln(p_correct / (1 - p_correct))
|
||||
- Return 0.0 for neutral signals (direction == 0)
|
||||
- Ensure LLR sign always matches direction for directional signals
|
||||
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6_
|
||||
|
||||
- [x] 1.4 Add feature flag routing in `services/aggregation/worker.py`
|
||||
- Read `v3_engine_enabled` from risk_configs table at start of each aggregation cycle
|
||||
- Route to v3 pipeline when True, heuristic when False
|
||||
- Default to heuristic mode if DB read fails (log warning)
|
||||
- Wrap v3 pipeline in try/except — fall back to heuristic on unhandled error (log ERROR with traceback)
|
||||
- Store `pipeline_mode` field ("v3" or "heuristic") in output metadata
|
||||
- _Requirements: 19.1, 19.2, 19.3, 19.4, 19.5, 19.6_
|
||||
|
||||
- [x] 1.5 Write property tests for EvidenceUnit, reliability, and LLR (`tests/test_pbt_v3_reliability.py`)
|
||||
- **Property 1: Reliability q_i is bounded in [0, 1]**
|
||||
- **Property 2: p_correct is bounded in [0.501, 0.85]**
|
||||
- **Property 3: LLR sign matches direction and magnitude is bounded**
|
||||
- **Property 4: Neutral signals produce zero LLR**
|
||||
- **Property 21: EvidenceUnit normalization preserves field ranges**
|
||||
- **Validates: Requirements 1.1–1.8, 2.1–2.9, 3.1–3.6, 21.1–21.3**
|
||||
- Use Hypothesis with `@settings(max_examples=100)`
|
||||
|
||||
- [x] 1.6 Write unit tests for EvidenceUnit normalization and LLR conversion (`tests/test_v3_evidence_unit.py`)
|
||||
- Test company signal → EvidenceUnit with correct field mapping
|
||||
- Test macro signal → EvidenceUnit with correct horizon mapping (short_term→7d, medium_term→30d, long_term→90d)
|
||||
- Test competitive signal → EvidenceUnit with correct direction mapping
|
||||
- Test missing required fields → rejection with warning
|
||||
- Test missing optional fields → default 0.5 substitution
|
||||
- Test known inputs through full q_i pipeline → expected outputs
|
||||
- Test LLR boundary cases: p_correct at clamp boundaries
|
||||
- _Requirements: 1.1–1.8, 2.1–2.9, 3.1–3.6_
|
||||
|
||||
- [x] 2. Evidence clustering and n_eff
|
||||
|
||||
- [x] 2.1 Implement correlation-aware clustering in `services/aggregation/worker.py`
|
||||
- Add `EvidenceCluster` dataclass (cluster_id, units, llrs, n_eff, cluster_llr)
|
||||
- Implement `cluster_evidence()` — group EvidenceUnits by (symbol, horizon, event_type, source_group, time_bucket)
|
||||
- Compute cluster_id as hash of grouping key
|
||||
- Define time_bucket resolution per horizon (intraday=1h, 1d=4h, 7d=24h, 30d=72h, 90d=168h)
|
||||
- _Requirements: 4.1_
|
||||
|
||||
- [x] 2.2 Implement n_eff computation in `services/aggregation/worker.py`
|
||||
- Implement `compute_n_eff(llrs, correlations)` using formula: (sum w_i)² / (sum w_i² + 2 × sum_{i<j} rho_ij × w_i × w_j)
|
||||
- Use default pairwise correlations: same wire=0.80, same event diff publisher=0.50, same theme diff event=0.25, independent=0.00
|
||||
- Guard against division by zero (denominator → return n_eff=1.0)
|
||||
- _Requirements: 4.2, 4.3_
|
||||
|
||||
- [x] 2.3 Implement cluster LLR computation in `services/aggregation/worker.py`
|
||||
- Implement `compute_cluster_llr(llrs, n_eff)` = clamp(weighted_mean(LLR_i, |LLR_i|) × sqrt(n_eff), -2.5, 2.5)
|
||||
- Handle single-signal clusters (LLR_c = LLR_i clamped)
|
||||
- Handle all-zero LLR clusters (cluster_llr = 0.0)
|
||||
- _Requirements: 4.4, 4.5_
|
||||
|
||||
- [x] 2.4 Write property tests for clustering (`tests/test_pbt_v3_clustering.py`)
|
||||
- **Property 5: Effective evidence count n_eff is bounded by cluster size**
|
||||
- **Property 6: Cluster LLR is clamped to [-2.5, 2.5]**
|
||||
- **Validates: Requirements 4.2, 4.3, 4.4, 4.5, 21.4**
|
||||
- Use Hypothesis with `@settings(max_examples=100)`
|
||||
|
||||
- [x] 2.5 Write unit tests for clustering (`tests/test_v3_clustering.py`)
|
||||
- Test 3 identical articles from same source → n_eff < 3
|
||||
- Test 3 independent articles → n_eff ≈ 3
|
||||
- Test single signal cluster → n_eff = 1.0
|
||||
- Test cluster LLR clamp at ±2.5
|
||||
- Test grouping by correct key dimensions
|
||||
- _Requirements: 4.1–4.5_
|
||||
|
||||
- [x] 3. Checkpoint - Verify foundation layer
|
||||
- Ensure all tests pass for EvidenceUnit, reliability, LLR, and clustering.
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 4. Replace trend assembly with posterior P_up
|
||||
|
||||
- [x] 4.1 Implement regime detection v3 in `services/aggregation/regime.py`
|
||||
- Add `V3RegimeClassification` dataclass (regime, trend_z, vol_ratio, evidence_multiplier, confidence_multiplier, phi_decay, atr_multiplier)
|
||||
- Implement `classify_regime_v3(closing_prices, daily_returns, atr_20)` using ATR-normalized trend_z = (EMA_20 - EMA_100) / ATR_20
|
||||
- Compute vol_ratio = sigma_20 / sigma_100
|
||||
- Classification rules in priority: panic (vol_ratio > 1.5 OR |trend_z| > 2.5), trend_following (|trend_z| >= 0.75 AND vol_ratio < 1.3), mean_reversion (|trend_z| < 0.50 AND vol_ratio < 1.0), uncertainty (default)
|
||||
- Assign regime parameters: gamma, confidence_mult, phi, ATR_mult, min_edge
|
||||
- Default to uncertainty when data insufficient
|
||||
- _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.7, 6.8_
|
||||
|
||||
- [x] 4.2 Implement posterior assembly via log-odds in `services/aggregation/bayesian.py`
|
||||
- Add `V3Posterior` dataclass (p_up, p_down, log_odds, strength, direction, n_eff_total, regime)
|
||||
- Implement `compute_v3_posterior(clusters, regime, p_prior=0.50)`
|
||||
- Compute logit(P_up) = logit(P_prior) + sum(gamma_regime × LLR_c)
|
||||
- Compute P_up = sigmoid(log_odds), clamp to [1e-10, 1 - 1e-10]
|
||||
- Compute strength = abs(2 × P_up - 1)
|
||||
- Classify direction using regime-specific thresholds (panic: 0.68/0.32, trend_following: 0.60/0.40, mean_reversion: 0.63/0.37, uncertainty: 0.65/0.35)
|
||||
- Load calibrated prior from risk_configs if available (clamp to [0.40, 0.60]), fall back to 0.50 on error
|
||||
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7_
|
||||
|
||||
- [x] 4.3 Write property tests for posterior and regime (`tests/test_pbt_v3_posterior.py`)
|
||||
- **Property 7: Posterior P_up is in open interval (0, 1)**
|
||||
- **Property 20: Regime classification is exhaustive and deterministic**
|
||||
- **Validates: Requirements 5.3, 6.2–6.5, 21.5**
|
||||
- Use Hypothesis with `@settings(max_examples=100)`
|
||||
|
||||
- [x] 4.4 Write unit tests for posterior assembly (`tests/test_v3_posterior.py`)
|
||||
- Test empty clusters → P_up = 0.50 (neutral prior)
|
||||
- Test all bullish clusters → P_up > 0.50
|
||||
- Test regime direction thresholds at boundary values
|
||||
- Test prior clamp [0.40, 0.60]
|
||||
- Test regime classification with known inputs
|
||||
- _Requirements: 5.1–5.7, 6.1–6.8_
|
||||
|
||||
- [x] 5. Replace contradiction with LLR entropy
|
||||
|
||||
- [x] 5.1 Implement LLR entropy contradiction in `services/aggregation/contradiction.py`
|
||||
- Add `compute_v3_contradiction(clusters: list[EvidenceCluster]) -> float`
|
||||
- Compute E_pos = sum(max(LLR_c, 0)), E_neg = sum(max(-LLR_c, 0)), E_total = E_pos + E_neg
|
||||
- When E_total == 0 → return 0.0
|
||||
- When only one direction exists (E_pos == 0 or E_neg == 0) → return 0.0
|
||||
- Compute f_pos = E_pos / E_total, f_neg = E_neg / E_total
|
||||
- Compute H_conflict = -f_pos × log2(f_pos) - f_neg × log2(f_neg), treating 0×log2(0) = 0
|
||||
- Compute volume_factor = 1 - exp(-E_total / 3.0)
|
||||
- Return H_conflict × volume_factor, bounded in [0.0, 1.0]
|
||||
- _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 7.7_
|
||||
|
||||
- [x] 5.2 Write property tests for contradiction (`tests/test_pbt_v3_posterior.py`)
|
||||
- **Property 8: Contradiction is zero when evidence is unidirectional**
|
||||
- **Property 9: Contradiction score is bounded in [0, 1]**
|
||||
- **Validates: Requirements 7.6, 7.7, 21.7**
|
||||
- Use Hypothesis with `@settings(max_examples=100)`
|
||||
|
||||
- [x] 5.3 Write unit tests for LLR entropy contradiction (`tests/test_v3_contradiction.py`)
|
||||
- Test all bullish clusters → contradiction = 0.0
|
||||
- Test equal split of evidence → high contradiction near 1.0
|
||||
- Test E_total = 0 → contradiction = 0.0
|
||||
- Test volume_factor growth: small evidence mass → suppressed score
|
||||
- _Requirements: 7.1–7.7_
|
||||
|
||||
- [x] 6. Replace confidence formula
|
||||
|
||||
- [x] 6.1 Implement multiplicative confidence v3 in `services/aggregation/worker.py`
|
||||
- Add `compute_v3_confidence(n_eff_total, q_values, llrs, strength, regime_confidence_mult, contradiction, data_quality) -> float`
|
||||
- Compute C_evidence = 1 - exp(-n_eff_total / 5.0)
|
||||
- Compute C_quality = weighted_mean(q_i, weight=|LLR_i|)
|
||||
- Compute confidence = clamp(C_evidence × sqrt(C_quality) × sqrt(max(strength, 0.05)) × regime_confidence_mult × (1 - contradiction) × data_quality, 0, 1)
|
||||
- _Requirements: 8.1, 8.2, 8.3, 8.4, 8.5_
|
||||
|
||||
- [x] 6.2 Implement data quality v3 in `services/aggregation/worker.py`
|
||||
- Add `compute_v3_data_quality(units, extraction_failure_rate, age_newest_hours, n_source_types) -> float`
|
||||
- Q_parse = 1 - extraction_failure_rate
|
||||
- Q_conf = weighted_mean(extraction_conf_i, weight=impact_i)
|
||||
- Q_fresh = exp(-age_newest_hours / 168)
|
||||
- Q_coverage = 1 - exp(-N_valid / 5)
|
||||
- Q_diversity = min(1, log2(1 + N_source_types) / log2(4))
|
||||
- data_quality_score = clamp(Q_parse × sqrt(Q_conf) × Q_fresh × Q_coverage × Q_diversity, 0, 1)
|
||||
- Force informational mode when data_quality < 0.50 OR N_valid < 2 OR Q_parse < 0.50
|
||||
- Force informational when only macro/competitive evidence unless macro_only_enabled
|
||||
- _Requirements: 17.1, 17.2, 17.3, 17.4, 17.5, 17.6, 17.7, 17.8_
|
||||
|
||||
- [x] 6.3 Write property tests for confidence and data quality (`tests/test_pbt_v3_posterior.py`)
|
||||
- **Property 10: Multiplicative confidence is bounded in [0, 1] and suppressed by weak dimensions**
|
||||
- **Property 17: Data quality score is bounded in [0, 1]**
|
||||
- **Validates: Requirements 8.3, 8.5, 17.6, 21.6**
|
||||
- Use Hypothesis with `@settings(max_examples=100)`
|
||||
|
||||
- [x] 6.4 Write unit tests for confidence and data quality (`tests/test_v3_confidence.py`)
|
||||
- Test zero data quality → near-zero confidence
|
||||
- Test full contradiction (1.0) → zero confidence
|
||||
- Test low n_eff → suppressed C_evidence
|
||||
- Test data quality boundary cases (Q_parse < 0.50 → informational)
|
||||
- _Requirements: 8.1–8.5, 17.1–17.8_
|
||||
|
||||
- [x] 7. Checkpoint - Verify core pipeline
|
||||
- Ensure all tests pass for posterior, contradiction, confidence, and data quality.
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 8. Convert macro and competitive layers to emit LLR
|
||||
|
||||
- [x] 8.1 Implement noisy-OR macro exposure and LLR emission in `services/aggregation/interpolation.py`
|
||||
- Add `compute_normalized_macro_exposure(overlaps: dict[str, float]) -> float`
|
||||
- E_raw = 1 - product(1 - w_k × O_k) with weights: w_geo=0.35, w_supply=0.25, w_commodity=0.25, w_sector=0.15
|
||||
- E_max = 1 - product(1 - w_k)
|
||||
- E_macro = E_raw / E_max (normalized to [0, 1])
|
||||
- Apply resilience dampener per tier: global_leader=0.70, multinational=0.85, regional=1.00, domestic=1.20
|
||||
- Add `compute_macro_llr(macro_impact, event_confidence, q_recency, macro_direction) -> float`
|
||||
- p_macro = clamp(0.50 + 0.30 × macro_impact × event_confidence × q_recency, 0.501, 0.80)
|
||||
- LLR_macro = macro_direction × ln(p_macro / (1 - p_macro))
|
||||
- Feed macro LLR into shared posterior without separate post-hoc modifier
|
||||
- _Requirements: 9.1, 9.2, 9.3, 9.4, 9.5_
|
||||
|
||||
- [x] 8.2 Implement correlation-shrunk competitive propagation in `services/aggregation/signal_propagation.py`
|
||||
- Add `compute_shrunk_correlation(rho_rolling, n_observations, same_sector) -> float`
|
||||
- rho_prior = 0.30 (same_sector) or 0.10 (cross_sector)
|
||||
- rho_shrunk = (n/(n+30)) × rho_rolling + (30/(n+30)) × rho_prior
|
||||
- rho_effective = max(rho_shrunk, 0)
|
||||
- Add `compute_competitive_llr(llr_source, rho_effective, d_network, pattern_confidence) -> float`
|
||||
- attenuation = rho_effective × exp(-0.85 × d_network), max_distance = 3
|
||||
- LLR_competitive = clamp(llr_source × attenuation × pattern_confidence, -1.25, 1.25)
|
||||
- _Requirements: 10.1, 10.2, 10.3, 10.4, 10.5_
|
||||
|
||||
- [x] 8.3 Write property tests for macro and competitive layers (`tests/test_pbt_v3_layers.py`)
|
||||
- **Property 13: Noisy-OR normalized exposure is bounded in [0, 1]**
|
||||
- **Property 14: Competitive LLR is clamped to [-1.25, 1.25]**
|
||||
- **Property 15: Graph attenuation is zero beyond max distance**
|
||||
- **Validates: Requirements 9.1, 9.2, 10.3, 10.4, 10.5**
|
||||
- Use Hypothesis with `@settings(max_examples=100)`
|
||||
|
||||
- [x] 8.4 Write unit tests for macro and competitive layers (`tests/test_v3_layers.py`)
|
||||
- Test noisy-OR: all O_k = 1.0 → E_macro = 1.0; all O_k = 0 → E_macro = 0
|
||||
- Test resilience dampener per tier
|
||||
- Test macro LLR at boundary values
|
||||
- Test shrunk correlation convergence (n → ∞ approaches rho_rolling)
|
||||
- Test competitive LLR clamp at ±1.25
|
||||
- Test distance > 3 → zero attenuation
|
||||
- _Requirements: 9.1–9.5, 10.1–10.5_
|
||||
|
||||
- [x] 9. Replace EV gate with expected-return distribution
|
||||
|
||||
- [x] 9.1 Implement posterior state projection in `services/aggregation/projection.py`
|
||||
- Add `V3ProjectionState` dataclass (a_t, p_up_projected, projected_strength, diverges, phi_regime)
|
||||
- Implement `compute_v3_projection(a_prev, cluster_llrs, regime, p_prior, projection_horizon, known_catalyst_llr=0.0)`
|
||||
- Evidence state: A_t = phi_regime × A_{t-1} + sum(LLR_c), init A_0 = 0.0
|
||||
- Regime decay: panic=0.35, trend_following=0.80, mean_reversion=0.55, uncertainty=0.50
|
||||
- Projected alpha: A_projected = phi^h × A_t + known_catalyst_LLR
|
||||
- P_up_projected = sigmoid(logit(P_prior) + A_projected)
|
||||
- Projected strength = abs(2 × P_up_projected - 1)
|
||||
- Flag divergence when sign(P_up_projected - 0.5) ≠ sign(P_up_t - 0.5)
|
||||
- _Requirements: 11.1, 11.2, 11.3, 11.4, 11.5, 11.6, 11.7_
|
||||
|
||||
- [x] 9.2 Implement return distribution and EV gate in `services/recommendation/eligibility.py`
|
||||
- Add `ReturnDistribution` dataclass (sigma_h, mu_h, ev_long, min_edge, eligible)
|
||||
- Add `compute_return_distribution(a_projected, confidence, realized_vol_20d, horizon_days, costs, regime)`
|
||||
- sigma_h = realized_vol_20d × sqrt(horizon_days / 252); default vol = 0.25 if unavailable
|
||||
- mu_h = tanh(A_projected / 3.0) × confidence × sigma_h
|
||||
- CVaR_5 = sigma_h × 1.645 × 1.4
|
||||
- EV_long = mu_h - costs - 0.10 × CVaR_5
|
||||
- Regime min_edge: panic=0.0100, trend_following=0.0035, mean_reversion=0.0050, uncertainty=0.0075
|
||||
- Eligibility: EV_long > min_edge AND EV_long > max(0.0025, 0.25 × costs)
|
||||
- Also require: confidence >= regime_confidence_min, contradiction <= regime_contradiction_max, n_eff_total >= 2.0, data_quality >= 0.50
|
||||
- _Requirements: 12.1, 12.2, 12.3, 12.4, 12.5, 12.6, 12.7_
|
||||
|
||||
- [x] 9.3 Implement regime-aware eligibility and mode escalation in `services/recommendation/eligibility.py`
|
||||
- Add regime-specific eligibility thresholds: panic (conf≥0.70, contra≤0.25, str≥0.36), trend_following (conf≥0.55, contra≤0.40, str≥0.20), mean_reversion (conf≥0.60, contra≤0.35, str≥0.26), uncertainty (conf≥0.65, contra≤0.30, str≥0.30)
|
||||
- Action mapping: BUY when P_up >= bullish_threshold and EV > min_edge; SELL when existing position and EV_exit > EV_hold; HOLD when existing; WATCH otherwise
|
||||
- live_eligible: BUY/SELL + conf >= 0.75 + contra <= 0.20 + n_eff >= 5 + EV > 2×min_edge + risk_passed
|
||||
- paper_eligible: BUY/SELL + conf >= 0.60 + EV > min_edge + risk_passed
|
||||
- Otherwise: informational
|
||||
- _Requirements: 13.1, 13.2, 13.3, 13.4, 13.5_
|
||||
|
||||
- [x] 9.4 Write property tests for projection (`tests/test_pbt_v3_projection.py`)
|
||||
- **Property 16: Projection evidence state decays toward zero**
|
||||
- **Validates: Requirements 11.1, 11.3**
|
||||
- Use Hypothesis with `@settings(max_examples=100)`
|
||||
|
||||
- [x] 9.5 Write unit tests for EV gate and eligibility (`tests/test_v3_eligibility.py`)
|
||||
- Test EV_long positive → eligible
|
||||
- Test EV_long negative → ineligible
|
||||
- Test regime-specific min_edge thresholds
|
||||
- Test mode escalation: live vs paper vs informational
|
||||
- Test projection decay convergence
|
||||
- Test divergence flag behavior
|
||||
- _Requirements: 11.1–11.7, 12.1–12.7, 13.1–13.5_
|
||||
|
||||
- [x] 10. Checkpoint - Verify decision layer
|
||||
- Ensure all tests pass for projection, EV gate, eligibility, and layer integrations.
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 11. Replace sizing with fractional Kelly under existing risk caps
|
||||
|
||||
- [x] 11.1 Implement fractional Kelly position sizing in `services/trading/position_sizer.py`
|
||||
- Add `compute_kelly_sizing(p_win, b, confidence, data_quality, contradiction, max_position_pct, available_caps) -> float`
|
||||
- Compute reward ratio b = clamp(1.2 + 2.0 × confidence + 1.0 × strength - contradiction, 1.2, 3.0)
|
||||
- Compute f_kelly = (p_win × b - (1 - p_win)) / b
|
||||
- portfolio_pct = clamp(max(0, f_kelly) × 0.25 × confidence × data_quality × (1 - contradiction), 0, max_position_pct)
|
||||
- Apply min of: max_position_pct, sector_capacity, correlation_capacity (0 if avg corr > 0.80), heat_capacity
|
||||
- If portfolio_pct < 0.005 → downgrade to WATCH (reason: position_below_minimum)
|
||||
- If f_kelly <= 0 → portfolio_pct = 0, downgrade to WATCH (reason: negative_edge)
|
||||
- _Requirements: 14.1, 14.2, 14.3, 14.4, 14.5, 14.6, 14.7_
|
||||
|
||||
- [x] 11.2 Implement regime-aware stop loss and take profit in `services/trading/stop_loss_manager.py`
|
||||
- Add v3 stop computation: stop_distance_pct = max(ATR_pct × regime_ATR_mult, sigma_h × 1.25, 0.005)
|
||||
- stop_loss = entry_price × (1 - stop_distance_pct)
|
||||
- take_profit = entry_price × (1 + b × stop_distance_pct) where b = reward ratio
|
||||
- Activate trailing stop when unrealized_gain >= 0.50 × TP distance
|
||||
- trailing_stop = max(existing_stop, current_price × (1 - trailing_distance_pct))
|
||||
- trailing_distance_pct = max(ATR_pct × trailing_ATR_mult, sigma_h × 0.75)
|
||||
- Trailing stop must be monotonically non-decreasing
|
||||
- _Requirements: 16.1, 16.2, 16.3, 16.4, 16.5_
|
||||
|
||||
- [x] 11.3 Write property tests for Kelly sizing and stops (`tests/test_pbt_v3_decision.py`)
|
||||
- **Property 11: Fractional Kelly sizing is bounded and respects negative edge**
|
||||
- **Property 18: Stop loss is below entry price and take profit is above**
|
||||
- **Property 19: Trailing stop never decreases**
|
||||
- **Validates: Requirements 14.4, 14.7, 16.2, 16.3, 16.5, 21.8, 21.9**
|
||||
- Use Hypothesis with `@settings(max_examples=100)`
|
||||
|
||||
- [x] 11.4 Write unit tests for Kelly sizing and stops (`tests/test_v3_sizing.py`)
|
||||
- Test p_win=0.3, b=2 → f_kelly < 0 → size = 0
|
||||
- Test p_win=0.7, b=2 → positive size within caps
|
||||
- Test cap enforcement (sector, correlation, heat)
|
||||
- Test position_below_minimum downgrade
|
||||
- Test stop/TP computation with known inputs
|
||||
- Test trailing stop monotonicity over a price sequence
|
||||
- _Requirements: 14.1–14.7, 16.1–16.5_
|
||||
|
||||
- [x] 12. Replace portfolio heat with stop-defined risk dollars
|
||||
|
||||
- [x] 12.1 Implement stop-defined portfolio heat in `services/risk/engine.py`
|
||||
- Add `compute_portfolio_heat(positions, stop_distances) -> float`
|
||||
- risk_dollars = position_value × stop_distance_pct for each position
|
||||
- portfolio_heat = sum(risk_dollars)
|
||||
- Add `check_heat_capacity(current_heat, new_risk_dollars, max_heat_pct, portfolio_value) -> bool`
|
||||
- Reject new entry when current_heat + new_risk_dollars > max_heat_pct × portfolio_value
|
||||
- Integrate available_heat_capacity into Kelly sizing pipeline
|
||||
- _Requirements: 15.1, 15.2, 15.3, 15.4, 15.5_
|
||||
|
||||
- [x] 12.2 Implement risk tier auto-adjustment v3 in `services/risk/engine.py`
|
||||
- Add `TierMetrics` dataclass (profit_factor_30d, max_drawdown_30d, calibration_error, realized_sharpe_30d, n_trades_30d, reserve_pool_pct)
|
||||
- Add `evaluate_tier_adjustment(metrics) -> str` returning 'upgrade'|'downgrade'|'hold'
|
||||
- Downgrade if ANY: profit_factor < 1.0 OR drawdown > 0.12 OR calibration_error > 0.20 OR sharpe < 0
|
||||
- Upgrade only if ALL: profit_factor > 1.35 AND drawdown < 0.05 AND calibration_error < 0.12 AND reserve > 0.20 AND N_trades >= 20
|
||||
- Apply downgrade immediately; enforce 7-day upgrade cooldown
|
||||
- Evaluate once per calendar day after session close
|
||||
- _Requirements: 18.1, 18.2, 18.3, 18.4, 18.5, 18.6_
|
||||
|
||||
- [x] 12.3 Write property tests for heat and tier adjustment (`tests/test_pbt_v3_decision.py`)
|
||||
- **Property 22: Portfolio heat rejection is correct**
|
||||
- **Property 23: Tier auto-adjustment obeys downgrade-any, upgrade-all logic**
|
||||
- **Validates: Requirements 15.3, 15.5, 18.3, 18.4**
|
||||
- Use Hypothesis with `@settings(max_examples=100)`
|
||||
|
||||
- [x] 12.4 Write unit tests for heat and tier (`tests/test_v3_risk.py`)
|
||||
- Test heat computation: 3 positions with known stops → expected heat
|
||||
- Test heat rejection: heat at limit → new entry blocked
|
||||
- Test tier downgrade: single bad metric triggers downgrade
|
||||
- Test tier upgrade: all metrics good → upgrade
|
||||
- Test tier upgrade: one metric bad → hold (not upgrade)
|
||||
- Test 7-day cooldown enforcement
|
||||
- _Requirements: 15.1–15.5, 18.1–18.6_
|
||||
|
||||
- [x] 13. Checkpoint - Verify sizing and risk layer
|
||||
- Ensure all tests pass for Kelly sizing, stops, heat, and tier adjustment.
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 14. Retire heuristic scoring to explainability-only mode
|
||||
|
||||
- [x] 14.1 Wire v3 pipeline end-to-end in `services/aggregation/worker.py`
|
||||
- Orchestrate full pipeline: EvidenceUnit → q_i → LLR → cluster → posterior → contradiction → confidence → data_quality
|
||||
- Store v3 posterior in TrendSummary JSONB metadata (p_up, log_odds, strength, confidence, contradiction, n_eff, data_quality, regime)
|
||||
- Store return model in Recommendation JSONB metadata (mu_h, sigma_h, ev_long, min_edge)
|
||||
- Store explainability payload (top_positive_clusters, top_negative_clusters, suppression_reasons, risk_adjustments)
|
||||
- Preserve WeightedSignal as intermediate representation before LLR conversion
|
||||
- _Requirements: 20.1, 20.2, 20.3, 20.4, 20.5_
|
||||
|
||||
- [x] 14.2 Retain heuristic pipeline as fallback with explainability overlay
|
||||
- Keep existing heuristic scoring path fully functional (no removal)
|
||||
- Mark heuristic outputs with `pipeline_mode: "heuristic"` in metadata
|
||||
- Ensure heuristic mode still produces valid TrendSummary and Recommendation objects
|
||||
- Test feature flag toggle: v3 → heuristic → v3 round-trip
|
||||
- _Requirements: 19.1, 19.2, 19.5, 20.4_
|
||||
|
||||
- [x] 14.3 Write property test for JSON round-trip (`tests/test_pbt_v3_posterior.py`)
|
||||
- **Property 12: Posterior state JSON round-trip**
|
||||
- **Validates: Requirements 20.1, 21.10**
|
||||
- Serialize V3Posterior to JSON and deserialize, verify equivalence within 1e-10
|
||||
- Use Hypothesis with `@settings(max_examples=100)`
|
||||
|
||||
- [x] 14.4 Write integration tests for full pipeline (`tests/test_v3_integration.py`)
|
||||
- Test full path: raw signals → EvidenceUnit → q_i → LLR → cluster → posterior → recommendation
|
||||
- Test feature flag false → heuristic path, flag true → v3 path
|
||||
- Test v3 exception → heuristic fallback + error logged
|
||||
- Test output JSONB contains expected v3 fields
|
||||
- _Requirements: 19.1–19.6, 20.1–20.5_
|
||||
|
||||
- [x] 15. Final checkpoint - All tests green
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
- Run full test suite: `.venv/bin/python -m pytest tests/test_pbt_v3_*.py tests/test_v3_*.py -x --tb=short -q`
|
||||
- Verify no regressions in existing heuristic pipeline tests
|
||||
|
||||
## Notes
|
||||
|
||||
- Tasks marked with `*` are optional and can be skipped for faster MVP
|
||||
- Each task references specific requirements for traceability
|
||||
- Checkpoints ensure incremental validation after each logical phase
|
||||
- Property tests validate the 23 correctness properties defined in the design
|
||||
- Unit tests validate specific examples, edge cases, and error handling
|
||||
- The implementation preserves the existing heuristic pipeline as a fully functional fallback
|
||||
- All v3 code is gated behind `v3_engine_enabled` — no changes to production behavior until flag is flipped
|
||||
- Run tests with: `.venv/bin/python -m pytest tests/ -x --tb=short -q`
|
||||
- Property tests use Hypothesis: `@settings(max_examples=100)`
|
||||
|
||||
## Task Dependency Graph
|
||||
|
||||
```json
|
||||
{
|
||||
"waves": [
|
||||
{ "id": 0, "tasks": ["1.1"] },
|
||||
{ "id": 1, "tasks": ["1.2", "1.4"] },
|
||||
{ "id": 2, "tasks": ["1.3"] },
|
||||
{ "id": 3, "tasks": ["1.5", "1.6"] },
|
||||
{ "id": 4, "tasks": ["2.1"] },
|
||||
{ "id": 5, "tasks": ["2.2"] },
|
||||
{ "id": 6, "tasks": ["2.3"] },
|
||||
{ "id": 7, "tasks": ["2.4", "2.5"] },
|
||||
{ "id": 8, "tasks": ["4.1"] },
|
||||
{ "id": 9, "tasks": ["4.2"] },
|
||||
{ "id": 10, "tasks": ["4.3", "4.4"] },
|
||||
{ "id": 11, "tasks": ["5.1"] },
|
||||
{ "id": 12, "tasks": ["5.2", "5.3"] },
|
||||
{ "id": 13, "tasks": ["6.1", "6.2"] },
|
||||
{ "id": 14, "tasks": ["6.3", "6.4"] },
|
||||
{ "id": 15, "tasks": ["8.1", "8.2"] },
|
||||
{ "id": 16, "tasks": ["8.3", "8.4"] },
|
||||
{ "id": 17, "tasks": ["9.1"] },
|
||||
{ "id": 18, "tasks": ["9.2", "9.3"] },
|
||||
{ "id": 19, "tasks": ["9.4", "9.5"] },
|
||||
{ "id": 20, "tasks": ["11.1", "11.2"] },
|
||||
{ "id": 21, "tasks": ["11.3", "11.4"] },
|
||||
{ "id": 22, "tasks": ["12.1", "12.2"] },
|
||||
{ "id": 23, "tasks": ["12.3", "12.4"] },
|
||||
{ "id": 24, "tasks": ["14.1"] },
|
||||
{ "id": 25, "tasks": ["14.2"] },
|
||||
{ "id": 26, "tasks": ["14.3", "14.4"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,14 +5,20 @@ log-likelihood accumulation, Beta distribution parameters, and
|
||||
Shannon entropy for mixed-signal detection.
|
||||
|
||||
Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 9.1, 9.7
|
||||
V3 posterior assembly: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from services.aggregation.scoring import WeightedSignal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from services.aggregation.regime import V3RegimeClassification
|
||||
from services.aggregation.worker import EvidenceCluster
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BayesianPosterior:
|
||||
@@ -125,3 +131,126 @@ def compute_bayesian_posterior(
|
||||
entropy=entropy,
|
||||
signal_count=count,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# V3 Posterior Assembly — Calibrated Evidence Engine
|
||||
# Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class V3Posterior:
|
||||
"""V3 posterior result from log-odds Bayesian assembly.
|
||||
|
||||
Attributes:
|
||||
p_up: Posterior probability of upward move, (0, 1).
|
||||
p_down: 1 - p_up.
|
||||
log_odds: Raw log-odds (logit) of P_up.
|
||||
strength: abs(2 × P_up - 1), signal conviction [0, 1].
|
||||
direction: Classified direction string ('bullish', 'bearish', 'neutral').
|
||||
n_eff_total: Total effective evidence count across all clusters.
|
||||
regime: Market regime string used for this computation.
|
||||
"""
|
||||
|
||||
p_up: float
|
||||
p_down: float
|
||||
log_odds: float
|
||||
strength: float
|
||||
direction: str
|
||||
n_eff_total: float
|
||||
regime: str
|
||||
|
||||
|
||||
# Regime-specific direction thresholds: (bullish_threshold, bearish_threshold)
|
||||
# P_up >= bullish → "bullish"; P_up <= bearish → "bearish"; else "neutral"
|
||||
_V3_DIRECTION_THRESHOLDS: dict[str, tuple[float, float]] = {
|
||||
"panic": (0.68, 0.32),
|
||||
"trend_following": (0.60, 0.40),
|
||||
"mean_reversion": (0.63, 0.37),
|
||||
"uncertainty": (0.65, 0.35),
|
||||
}
|
||||
|
||||
|
||||
def _logit(p: float) -> float:
|
||||
"""Compute logit = ln(p / (1-p)) with boundary guard."""
|
||||
p = max(1e-10, min(1 - 1e-10, p))
|
||||
return math.log(p / (1 - p))
|
||||
|
||||
|
||||
def _sigmoid(x: float) -> float:
|
||||
"""Compute sigmoid = 1 / (1 + exp(-x)) with overflow guard."""
|
||||
if x > 500:
|
||||
return 1.0
|
||||
if x < -500:
|
||||
return 0.0
|
||||
return 1.0 / (1.0 + math.exp(-x))
|
||||
|
||||
|
||||
def compute_v3_posterior(
|
||||
clusters: list[EvidenceCluster],
|
||||
regime: V3RegimeClassification,
|
||||
p_prior: float = 0.50,
|
||||
) -> V3Posterior:
|
||||
"""Assemble v3 posterior via log-odds accumulation.
|
||||
|
||||
Computes:
|
||||
logit(P_up) = logit(P_prior) + sum(gamma_regime × LLR_c)
|
||||
P_up = sigmoid(log_odds), clamped to [1e-10, 1 - 1e-10]
|
||||
strength = abs(2 × P_up - 1)
|
||||
direction via regime-specific thresholds
|
||||
|
||||
Args:
|
||||
clusters: List of EvidenceCluster objects with computed cluster_llr.
|
||||
regime: V3RegimeClassification providing evidence_multiplier and regime.
|
||||
p_prior: Calibrated prior probability, clamped to [0.40, 0.60].
|
||||
|
||||
Returns:
|
||||
V3Posterior with computed posterior fields.
|
||||
|
||||
Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7
|
||||
"""
|
||||
# Clamp prior to [0.40, 0.60] (Req 5.6)
|
||||
p_prior = max(0.40, min(0.60, p_prior))
|
||||
|
||||
# Gamma: regime-specific evidence multiplier (Req 5.2)
|
||||
gamma = regime.evidence_multiplier
|
||||
|
||||
# Compute log-odds: logit(P_prior) + sum(gamma × LLR_c) (Req 5.1, 5.2)
|
||||
log_odds = _logit(p_prior) + sum(gamma * c.cluster_llr for c in clusters)
|
||||
|
||||
# Compute P_up via sigmoid (Req 5.3)
|
||||
p_up = _sigmoid(log_odds)
|
||||
# Clamp to open interval (Req 5.3)
|
||||
p_up = max(1e-10, min(1 - 1e-10, p_up))
|
||||
|
||||
p_down = 1.0 - p_up
|
||||
|
||||
# Strength = |2 × P_up - 1| (Req 5.4)
|
||||
strength = abs(2.0 * p_up - 1.0)
|
||||
|
||||
# n_eff_total = sum of cluster n_eff (Req 5.5)
|
||||
n_eff_total = sum(c.n_eff for c in clusters)
|
||||
|
||||
# Classify direction using regime-specific thresholds (Req 5.5)
|
||||
regime_key = regime.regime.value # MarketRegime enum → string
|
||||
bull_thresh, bear_thresh = _V3_DIRECTION_THRESHOLDS.get(
|
||||
regime_key, (0.65, 0.35)
|
||||
)
|
||||
|
||||
if p_up >= bull_thresh:
|
||||
direction = "bullish"
|
||||
elif p_up <= bear_thresh:
|
||||
direction = "bearish"
|
||||
else:
|
||||
direction = "neutral"
|
||||
|
||||
return V3Posterior(
|
||||
p_up=p_up,
|
||||
p_down=p_down,
|
||||
log_odds=log_odds,
|
||||
strength=strength,
|
||||
direction=direction,
|
||||
n_eff_total=n_eff_total,
|
||||
regime=regime_key,
|
||||
)
|
||||
|
||||
@@ -10,10 +10,14 @@ from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from services.aggregation.scoring import WeightedSignal
|
||||
from services.shared.schemas import DisagreementDetail
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from services.aggregation.worker import EvidenceCluster
|
||||
|
||||
|
||||
@dataclass
|
||||
class CatalystEntry:
|
||||
@@ -236,3 +240,67 @@ def _detect_catalyst_disagreement(
|
||||
))
|
||||
|
||||
return details
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# V3 LLR Entropy Contradiction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def compute_v3_contradiction(clusters: list[EvidenceCluster]) -> float:
|
||||
"""Compute LLR entropy contradiction score.
|
||||
|
||||
Uses Shannon entropy over positive/negative cluster LLR magnitudes,
|
||||
weighted by a volume factor that grows with total evidence mass.
|
||||
|
||||
Formula:
|
||||
E_pos = sum(max(LLR_c, 0))
|
||||
E_neg = sum(max(-LLR_c, 0))
|
||||
E_total = E_pos + E_neg
|
||||
f_pos = E_pos / E_total, f_neg = E_neg / E_total
|
||||
H_conflict = -f_pos × log2(f_pos) - f_neg × log2(f_neg)
|
||||
volume_factor = 1 - exp(-E_total / 3.0)
|
||||
result = H_conflict × volume_factor, bounded in [0.0, 1.0]
|
||||
|
||||
Returns 0.0 when:
|
||||
- clusters is empty
|
||||
- E_total == 0 (all cluster LLRs are zero)
|
||||
- Only one direction exists (E_pos == 0 or E_neg == 0)
|
||||
|
||||
Requirements: 7.1–7.7
|
||||
"""
|
||||
if not clusters:
|
||||
return 0.0
|
||||
|
||||
e_pos = 0.0
|
||||
e_neg = 0.0
|
||||
for cluster in clusters:
|
||||
llr_c = cluster.cluster_llr
|
||||
if llr_c > 0.0:
|
||||
e_pos += llr_c
|
||||
elif llr_c < 0.0:
|
||||
e_neg += -llr_c # max(-LLR_c, 0) when LLR_c < 0
|
||||
|
||||
e_total = e_pos + e_neg
|
||||
|
||||
# No evidence or unidirectional → no contradiction
|
||||
if e_total == 0.0:
|
||||
return 0.0
|
||||
if e_pos == 0.0 or e_neg == 0.0:
|
||||
return 0.0
|
||||
|
||||
# Compute fractions
|
||||
f_pos = e_pos / e_total
|
||||
f_neg = e_neg / e_total
|
||||
|
||||
# Shannon entropy H_conflict = -f_pos × log2(f_pos) - f_neg × log2(f_neg)
|
||||
# 0 × log2(0) is treated as 0, but the early returns above guarantee
|
||||
# both f_pos and f_neg are positive here.
|
||||
h_conflict = -f_pos * math.log2(f_pos) - f_neg * math.log2(f_neg)
|
||||
|
||||
# Volume factor: suppresses score when total evidence mass is small
|
||||
volume_factor = 1.0 - math.exp(-e_total / 3.0)
|
||||
|
||||
# Final score bounded to [0.0, 1.0]
|
||||
result = h_conflict * volume_factor
|
||||
return max(0.0, min(1.0, result))
|
||||
|
||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -955,3 +956,107 @@ def apply_accelerated_decay(
|
||||
return accelerated
|
||||
|
||||
return standard_decay
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# V3 Macro Layer — Noisy-OR Exposure & LLR Emission (Requirements: 9.1–9.5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Noisy-OR weights per dimension
|
||||
_V3_MACRO_WEIGHTS: dict[str, float] = {
|
||||
"geo": 0.35,
|
||||
"supply": 0.25,
|
||||
"commodity": 0.25,
|
||||
"sector": 0.15,
|
||||
}
|
||||
|
||||
# Resilience dampener per tier
|
||||
_V3_RESILIENCE_DAMPENER: dict[str, float] = {
|
||||
"global_leader": 0.70,
|
||||
"multinational": 0.85,
|
||||
"regional": 1.00,
|
||||
"domestic": 1.20,
|
||||
}
|
||||
|
||||
|
||||
def compute_normalized_macro_exposure(
|
||||
overlaps: dict[str, float],
|
||||
tier: str = "regional",
|
||||
) -> float:
|
||||
"""Compute normalized macro exposure via noisy-OR.
|
||||
|
||||
E_raw = 1 - product(1 - w_k × O_k)
|
||||
E_max = 1 - product(1 - w_k)
|
||||
E_macro = E_raw / E_max × resilience_dampener
|
||||
|
||||
Args:
|
||||
overlaps: Dimension overlap values keyed by 'geo', 'supply',
|
||||
'commodity', 'sector'. Missing keys treated as 0.0.
|
||||
tier: Market position tier for resilience dampening.
|
||||
|
||||
Returns:
|
||||
Normalized macro exposure in [0, ∞) (can exceed 1.0 for domestic
|
||||
tier due to 1.20 dampener, but typically in [0, ~1.2]).
|
||||
|
||||
Requirements: 9.1, 9.2, 9.3
|
||||
"""
|
||||
# E_raw = 1 - product(1 - w_k × O_k)
|
||||
product_raw = 1.0
|
||||
for dim, weight in _V3_MACRO_WEIGHTS.items():
|
||||
o_k = max(0.0, min(1.0, overlaps.get(dim, 0.0)))
|
||||
product_raw *= (1.0 - weight * o_k)
|
||||
e_raw = 1.0 - product_raw
|
||||
|
||||
# E_max = 1 - product(1 - w_k) — theoretical max when all overlaps = 1.0
|
||||
product_max = 1.0
|
||||
for weight in _V3_MACRO_WEIGHTS.values():
|
||||
product_max *= (1.0 - weight)
|
||||
e_max = 1.0 - product_max
|
||||
|
||||
# Guard against zero (should never happen with default weights)
|
||||
if e_max <= 0.0:
|
||||
return 0.0
|
||||
|
||||
# Normalize to [0, 1]
|
||||
e_macro = e_raw / e_max
|
||||
|
||||
# Apply resilience dampener per tier
|
||||
dampener = _V3_RESILIENCE_DAMPENER.get(tier, 1.0)
|
||||
return e_macro * dampener
|
||||
|
||||
|
||||
def compute_macro_llr(
|
||||
macro_impact: float,
|
||||
event_confidence: float,
|
||||
q_recency: float,
|
||||
macro_direction: int,
|
||||
) -> float:
|
||||
"""Compute macro LLR for shared posterior.
|
||||
|
||||
p_macro = clamp(0.50 + 0.30 × macro_impact × event_confidence × q_recency, 0.501, 0.80)
|
||||
LLR_macro = macro_direction × ln(p_macro / (1 - p_macro))
|
||||
|
||||
When macro_direction == 0, returns 0.0 (neutral — no directional signal).
|
||||
|
||||
Args:
|
||||
macro_impact: Normalized macro impact score (typically [0, 1]).
|
||||
event_confidence: Event classification confidence [0, 1].
|
||||
q_recency: Recency quality factor [0, 1].
|
||||
macro_direction: +1 for positive, -1 for negative, 0 for neutral.
|
||||
|
||||
Returns:
|
||||
Log-likelihood ratio for the macro signal. Feeds directly into the
|
||||
shared posterior without separate post-hoc modifier.
|
||||
|
||||
Requirements: 9.4, 9.5
|
||||
"""
|
||||
if macro_direction == 0:
|
||||
return 0.0
|
||||
|
||||
# p_macro = clamp(0.50 + 0.30 × macro_impact × event_confidence × q_recency, 0.501, 0.80)
|
||||
p_macro = 0.50 + 0.30 * macro_impact * event_confidence * q_recency
|
||||
p_macro = max(0.501, min(0.80, p_macro))
|
||||
|
||||
# LLR_macro = macro_direction × ln(p_macro / (1 - p_macro))
|
||||
llr = macro_direction * math.log(p_macro / (1.0 - p_macro))
|
||||
return llr
|
||||
|
||||
@@ -13,11 +13,15 @@ import logging
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import asyncpg
|
||||
|
||||
from services.shared.schemas import TrendSummary
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from services.aggregation.regime import V3RegimeClassification
|
||||
|
||||
logger = logging.getLogger("projection")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -493,3 +497,101 @@ async def persist_trend_projection(
|
||||
projection.diverges_from_current,
|
||||
)
|
||||
return str(row_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# V3 Posterior State Projection (Requirements: 11.1–11.7)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Regime decay factors (phi) — Req 11.3
|
||||
_V3_PHI_DECAY: dict[str, float] = {
|
||||
"panic": 0.35,
|
||||
"trend_following": 0.80,
|
||||
"mean_reversion": 0.55,
|
||||
"uncertainty": 0.50,
|
||||
}
|
||||
|
||||
|
||||
def _logit(p: float) -> float:
|
||||
"""Compute logit = ln(p / (1-p)) with boundary guard."""
|
||||
p = max(1e-10, min(1 - 1e-10, p))
|
||||
return math.log(p / (1 - p))
|
||||
|
||||
|
||||
def _sigmoid(x: float) -> float:
|
||||
"""Compute sigmoid = 1 / (1 + exp(-x)) with overflow guard."""
|
||||
if x > 500:
|
||||
return 1.0
|
||||
if x < -500:
|
||||
return 0.0
|
||||
return 1.0 / (1.0 + math.exp(-x))
|
||||
|
||||
|
||||
@dataclass
|
||||
class V3ProjectionState:
|
||||
"""V3 posterior state projection result.
|
||||
|
||||
Attributes:
|
||||
a_t: Accumulated evidence state A_t.
|
||||
p_up_projected: Projected probability sigmoid(logit(P_prior) + phi^h * A_t).
|
||||
projected_strength: abs(2 * P_up_projected - 1).
|
||||
diverges: True when sign(P_up_projected - 0.5) != sign(P_up_t - 0.5).
|
||||
phi_regime: Regime-specific decay factor used.
|
||||
"""
|
||||
|
||||
a_t: float
|
||||
p_up_projected: float
|
||||
projected_strength: float
|
||||
diverges: bool
|
||||
phi_regime: float
|
||||
|
||||
|
||||
def compute_v3_projection(
|
||||
a_prev: float,
|
||||
cluster_llrs: list[float],
|
||||
regime: V3RegimeClassification,
|
||||
p_prior: float,
|
||||
projection_horizon: int,
|
||||
known_catalyst_llr: float = 0.0,
|
||||
) -> V3ProjectionState:
|
||||
"""Compute posterior state projection with regime-aware decay.
|
||||
|
||||
Evidence state: A_t = phi_regime * A_{t-1} + sum(LLR_c), init A_0 = 0.0
|
||||
Projected alpha: A_projected = phi^h * A_t + known_catalyst_LLR
|
||||
P_up_projected = sigmoid(logit(P_prior) + A_projected)
|
||||
Projected strength = abs(2 * P_up_projected - 1)
|
||||
Divergence flagged when sign(P_up_projected - 0.5) != sign(P_up_t - 0.5)
|
||||
|
||||
Requirements: 11.1–11.7
|
||||
"""
|
||||
# Resolve phi from regime; default to uncertainty (0.50) if unavailable (Req 11.7)
|
||||
phi = _V3_PHI_DECAY.get(regime.regime.value, 0.50) if regime else 0.50
|
||||
|
||||
# Evidence state update: A_t = phi * A_{t-1} + sum(LLR_c) — Req 11.1, 11.2
|
||||
a_t = phi * a_prev + sum(cluster_llrs)
|
||||
|
||||
# Projected alpha: A_projected = phi^h * A_t + known_catalyst_LLR — Req 11.4
|
||||
a_projected = (phi ** projection_horizon) * a_t + known_catalyst_llr
|
||||
|
||||
# P_up_projected = sigmoid(logit(P_prior) + A_projected) — Req 11.5
|
||||
p_up_projected = _sigmoid(_logit(p_prior) + a_projected)
|
||||
|
||||
# Projected strength = abs(2 * P_up_projected - 1) — Req 11.6
|
||||
projected_strength = abs(2.0 * p_up_projected - 1.0)
|
||||
|
||||
# Compute current P_up_t for divergence check (not projected)
|
||||
p_up_t = _sigmoid(_logit(p_prior) + a_t)
|
||||
|
||||
# Flag divergence when projected direction differs from current — Req 11.6
|
||||
sign_projected = (p_up_projected - 0.5) >= 0
|
||||
sign_current = (p_up_t - 0.5) >= 0
|
||||
diverges = sign_projected != sign_current
|
||||
|
||||
return V3ProjectionState(
|
||||
a_t=a_t,
|
||||
p_up_projected=p_up_projected,
|
||||
projected_strength=projected_strength,
|
||||
diverges=diverges,
|
||||
phi_regime=phi,
|
||||
)
|
||||
|
||||
@@ -168,3 +168,151 @@ def classify_regime(
|
||||
bearish_threshold=-threshold,
|
||||
contradiction_penalty_multiplier=contradiction_mult,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# V3 Regime Detection — Calibrated Evidence Engine
|
||||
# Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.7, 6.8
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class V3RegimeClassification:
|
||||
"""V3 regime classification result with calibrated parameters.
|
||||
|
||||
Attributes:
|
||||
regime: Market regime category.
|
||||
trend_z: ATR-normalized trend indicator (EMA_20 - EMA_100) / ATR_20.
|
||||
vol_ratio: Volatility ratio sigma_20 / sigma_100.
|
||||
evidence_multiplier: Regime-specific gamma for posterior LLR scaling.
|
||||
confidence_multiplier: Regime-specific confidence scaling factor.
|
||||
phi_decay: Evidence state decay factor for projection.
|
||||
atr_multiplier: Regime-specific ATR multiplier for stop computation.
|
||||
"""
|
||||
|
||||
regime: MarketRegime
|
||||
trend_z: float
|
||||
vol_ratio: float
|
||||
evidence_multiplier: float
|
||||
confidence_multiplier: float
|
||||
phi_decay: float
|
||||
atr_multiplier: float
|
||||
|
||||
|
||||
# Regime parameter lookup: (gamma, confidence_mult, phi, ATR_mult, min_edge)
|
||||
_V3_REGIME_PARAMS: dict[MarketRegime, tuple[float, float, float, float, float]] = {
|
||||
MarketRegime.PANIC: (0.70, 0.70, 0.35, 2.5, 0.0100),
|
||||
MarketRegime.TREND_FOLLOWING: (1.10, 1.00, 0.80, 1.8, 0.0035),
|
||||
MarketRegime.MEAN_REVERSION: (0.90, 0.95, 0.55, 1.4, 0.0050),
|
||||
MarketRegime.UNCERTAINTY: (0.80, 0.85, 0.50, 2.0, 0.0075),
|
||||
}
|
||||
|
||||
# Default uncertainty classification for v3 when data is insufficient (Req 6.8)
|
||||
_DEFAULT_V3_UNCERTAINTY = V3RegimeClassification(
|
||||
regime=MarketRegime.UNCERTAINTY,
|
||||
trend_z=0.0,
|
||||
vol_ratio=1.0,
|
||||
evidence_multiplier=0.80,
|
||||
confidence_multiplier=0.85,
|
||||
phi_decay=0.50,
|
||||
atr_multiplier=2.0,
|
||||
)
|
||||
|
||||
|
||||
def _compute_ema_full(values: list[float], span: int) -> float:
|
||||
"""Compute EMA over the full values list with given span.
|
||||
|
||||
Uses standard EMA formula: alpha = 2 / (span + 1), iterating from the
|
||||
beginning of the list. Seeds EMA with the first value.
|
||||
|
||||
This differs from ``compute_ema`` which only uses the last ``period``
|
||||
values. V3 requires iterating over the full history to produce a stable
|
||||
EMA_100.
|
||||
"""
|
||||
if not values or span < 1:
|
||||
raise ValueError("values must be non-empty and span must be >= 1")
|
||||
|
||||
alpha = 2.0 / (span + 1)
|
||||
ema = values[0]
|
||||
for value in values[1:]:
|
||||
ema = alpha * value + (1.0 - alpha) * ema
|
||||
return ema
|
||||
|
||||
|
||||
def classify_regime_v3(
|
||||
closing_prices: list[float],
|
||||
daily_returns: list[float],
|
||||
atr_20: float,
|
||||
) -> V3RegimeClassification:
|
||||
"""Classify market regime using v3 ATR-normalized indicators.
|
||||
|
||||
Computes trend_z = (EMA_20 - EMA_100) / ATR_20 and
|
||||
vol_ratio = sigma_20 / sigma_100 to determine the market regime.
|
||||
|
||||
Classification priority (Req 6.2–6.5):
|
||||
1. Panic: vol_ratio > 1.5 OR |trend_z| > 2.5
|
||||
2. Trend following: |trend_z| >= 0.75 AND vol_ratio < 1.3
|
||||
3. Mean reversion: |trend_z| < 0.50 AND vol_ratio < 1.0
|
||||
4. Uncertainty: all other cases
|
||||
|
||||
Falls back to uncertainty when data is insufficient (Req 6.8):
|
||||
- Fewer than 100 closing prices for EMA_100
|
||||
- ATR_20 <= 0 (insufficient bars for ATR)
|
||||
- Fewer than 100 daily returns for sigma_100
|
||||
|
||||
Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.7, 6.8
|
||||
"""
|
||||
# --- Data sufficiency check (Req 6.8) ---
|
||||
if len(closing_prices) < 100:
|
||||
return _DEFAULT_V3_UNCERTAINTY
|
||||
|
||||
if atr_20 <= 0.0:
|
||||
return _DEFAULT_V3_UNCERTAINTY
|
||||
|
||||
if len(daily_returns) < 100:
|
||||
return _DEFAULT_V3_UNCERTAINTY
|
||||
|
||||
# --- Compute trend_z (Req 6.1) ---
|
||||
ema_20 = _compute_ema_full(closing_prices, span=20)
|
||||
ema_100 = _compute_ema_full(closing_prices, span=100)
|
||||
trend_z = (ema_20 - ema_100) / atr_20
|
||||
|
||||
# --- Compute vol_ratio (Req 6.1) ---
|
||||
sigma_20 = statistics.stdev(daily_returns[-20:]) if len(daily_returns) >= 20 else 0.0
|
||||
sigma_100 = statistics.stdev(daily_returns[-100:])
|
||||
|
||||
# Guard against zero sigma_100
|
||||
if sigma_100 <= 0.0 or math.isnan(sigma_100):
|
||||
return _DEFAULT_V3_UNCERTAINTY
|
||||
|
||||
if math.isnan(sigma_20):
|
||||
return _DEFAULT_V3_UNCERTAINTY
|
||||
|
||||
vol_ratio = sigma_20 / sigma_100
|
||||
|
||||
# --- Classification rules (Req 6.2–6.5) ---
|
||||
# Priority 1: Panic (Req 6.2)
|
||||
if vol_ratio > 1.5 or abs(trend_z) > 2.5:
|
||||
regime = MarketRegime.PANIC
|
||||
# Priority 2: Trend following (Req 6.3)
|
||||
elif abs(trend_z) >= 0.75 and vol_ratio < 1.3:
|
||||
regime = MarketRegime.TREND_FOLLOWING
|
||||
# Priority 3: Mean reversion (Req 6.4)
|
||||
elif abs(trend_z) < 0.50 and vol_ratio < 1.0:
|
||||
regime = MarketRegime.MEAN_REVERSION
|
||||
# Priority 4: Uncertainty (Req 6.5)
|
||||
else:
|
||||
regime = MarketRegime.UNCERTAINTY
|
||||
|
||||
# --- Assign regime parameters (Req 6.6, 6.7) ---
|
||||
gamma, conf_mult, phi, atr_mult, _min_edge = _V3_REGIME_PARAMS[regime]
|
||||
|
||||
return V3RegimeClassification(
|
||||
regime=regime,
|
||||
trend_z=trend_z,
|
||||
vol_ratio=vol_ratio,
|
||||
evidence_multiplier=gamma,
|
||||
confidence_multiplier=conf_mult,
|
||||
phi_decay=phi,
|
||||
atr_multiplier=atr_mult,
|
||||
)
|
||||
|
||||
@@ -8,9 +8,12 @@ Requirements: 2.1–2.6, 3.1–3.5, 4.2–4.3, 5.1–5.7, 6.1–6.5, 16.4–16.5
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from services.shared.schemas import MarketContext
|
||||
|
||||
@@ -588,3 +591,625 @@ def weighted_sentiment_average(signals: list[WeightedSignal]) -> float:
|
||||
if total_weight == 0.0:
|
||||
return 0.0
|
||||
return weighted_sum / total_weight
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# V3 Calibrated Evidence Engine — EvidenceUnit and Normalization
|
||||
# ===========================================================================
|
||||
# All code below this line implements the v3 pipeline. It is gated behind
|
||||
# the `v3_engine_enabled` feature flag at the worker/orchestration layer.
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# V3 Event type base rates (expanded for v3 pipeline)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
V3_EVENT_TYPE_BASE_RATES: dict[str, float] = {
|
||||
"earnings": 0.25,
|
||||
"guidance": 0.20,
|
||||
"merger_acquisition": 0.05,
|
||||
"product_launch": 0.15,
|
||||
"regulatory": 0.10,
|
||||
"management_change": 0.08,
|
||||
"partnership": 0.12,
|
||||
"legal": 0.07,
|
||||
"analyst_rating": 0.30,
|
||||
"market_data": 0.40,
|
||||
}
|
||||
V3_DEFAULT_BASE_RATE: float = 0.10
|
||||
|
||||
# Direction mapping constants
|
||||
_POSITIVE_DIRECTIONS: frozenset[str] = frozenset({"positive", "bullish"})
|
||||
_NEGATIVE_DIRECTIONS: frozenset[str] = frozenset({"negative", "bearish"})
|
||||
_NEUTRAL_DIRECTIONS: frozenset[str] = frozenset({"neutral", "mixed"})
|
||||
|
||||
# Macro horizon mapping
|
||||
_MACRO_HORIZON_MAP: dict[str, str] = {
|
||||
"short_term": "7d",
|
||||
"medium_term": "30d",
|
||||
"long_term": "90d",
|
||||
}
|
||||
|
||||
# Valid horizons
|
||||
_VALID_HORIZONS: frozenset[str] = frozenset({"intraday", "1d", "7d", "30d", "90d"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EvidenceUnit dataclass
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EvidenceUnit:
|
||||
"""Canonical normalized signal representation for the v3 pipeline.
|
||||
|
||||
Every signal — company, macro, or competitive — is normalized into this
|
||||
shape before entering the calibrated reliability / LLR pipeline.
|
||||
"""
|
||||
|
||||
symbol: str
|
||||
layer: str # "company" | "macro" | "competitive"
|
||||
event_type: str
|
||||
source_id: str
|
||||
source_group: str
|
||||
timestamp: datetime
|
||||
horizon: str # "intraday" | "1d" | "7d" | "30d" | "90d"
|
||||
direction: int # -1, 0, +1
|
||||
sentiment_strength: float # [0, 1]
|
||||
impact: float # [0, 1]
|
||||
extraction_conf: float # [0, 1]
|
||||
source_cred: float # [0, 1]
|
||||
novelty: float # [0, 1]
|
||||
event_base_rate: float # (0, 1]
|
||||
cluster_id: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _map_direction(direction_str: str | None) -> int:
|
||||
"""Map a sentiment/impact_direction string to a numeric direction.
|
||||
|
||||
Returns:
|
||||
+1 for positive/bullish, -1 for negative/bearish, 0 for neutral/mixed/unknown.
|
||||
"""
|
||||
if direction_str is None:
|
||||
return 0
|
||||
lowered = direction_str.lower().strip()
|
||||
if lowered in _POSITIVE_DIRECTIONS:
|
||||
return 1
|
||||
if lowered in _NEGATIVE_DIRECTIONS:
|
||||
return -1
|
||||
return 0
|
||||
|
||||
|
||||
def _get_event_base_rate(event_type: str | None) -> float:
|
||||
"""Look up base rate for an event type, defaulting to 0.10."""
|
||||
if event_type is None:
|
||||
return V3_DEFAULT_BASE_RATE
|
||||
return V3_EVENT_TYPE_BASE_RATES.get(event_type, V3_DEFAULT_BASE_RATE)
|
||||
|
||||
|
||||
def _compute_cluster_id(
|
||||
symbol: str,
|
||||
horizon: str,
|
||||
event_type: str,
|
||||
source_group: str,
|
||||
time_bucket: str,
|
||||
) -> str:
|
||||
"""Compute a deterministic cluster_id from the grouping key."""
|
||||
key = f"{symbol}|{horizon}|{event_type}|{source_group}|{time_bucket}"
|
||||
return hashlib.sha256(key.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def _default_time_bucket(ts: datetime, horizon: str) -> str:
|
||||
"""Compute a time bucket string for clustering based on horizon.
|
||||
|
||||
Bucket resolution per horizon:
|
||||
intraday → 1h, 1d → 4h, 7d → 24h, 30d → 72h, 90d → 168h
|
||||
"""
|
||||
bucket_hours: dict[str, int] = {
|
||||
"intraday": 1,
|
||||
"1d": 4,
|
||||
"7d": 24,
|
||||
"30d": 72,
|
||||
"90d": 168,
|
||||
}
|
||||
hours = bucket_hours.get(horizon, 24)
|
||||
# Truncate timestamp to bucket boundary
|
||||
epoch_hours = int(ts.timestamp() / 3600)
|
||||
bucket_start = (epoch_hours // hours) * hours
|
||||
return str(bucket_start)
|
||||
|
||||
|
||||
def _safe_float(value: Any, default: float = 0.5) -> float:
|
||||
"""Extract a float value, substituting default for missing/None."""
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _clamp(value: float, lo: float, hi: float) -> float:
|
||||
"""Clamp a value to [lo, hi]."""
|
||||
return max(lo, min(value, hi))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Normalization functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def normalize_company_signal(
|
||||
signal: dict[str, Any],
|
||||
*,
|
||||
cluster_id: str | None = None,
|
||||
) -> EvidenceUnit | None:
|
||||
"""Normalize a company signal into an EvidenceUnit.
|
||||
|
||||
Args:
|
||||
signal: Dict with keys from document_impact_records or similar.
|
||||
Required: symbol, timestamp, source_id
|
||||
Optional: event_type, source_group, horizon, sentiment,
|
||||
sentiment_strength, impact, extraction_conf,
|
||||
source_cred, novelty
|
||||
cluster_id: If provided, use this cluster_id. Otherwise compute
|
||||
from the signal's grouping key.
|
||||
|
||||
Returns:
|
||||
EvidenceUnit or None if required fields are missing.
|
||||
"""
|
||||
# Validate required fields
|
||||
symbol = signal.get("symbol")
|
||||
timestamp = signal.get("timestamp")
|
||||
source_id = signal.get("source_id")
|
||||
|
||||
if not symbol:
|
||||
logger.warning("v3: Rejecting company signal — missing 'symbol'. source: %s", signal.get("source_id", "unknown"))
|
||||
return None
|
||||
if timestamp is None:
|
||||
logger.warning("v3: Rejecting company signal — missing 'timestamp'. symbol=%s, source_id=%s", symbol, source_id)
|
||||
return None
|
||||
if not source_id:
|
||||
logger.warning("v3: Rejecting company signal — missing 'source_id'. symbol=%s", symbol)
|
||||
return None
|
||||
|
||||
# Ensure timestamp is datetime
|
||||
if isinstance(timestamp, str):
|
||||
timestamp = datetime.fromisoformat(timestamp)
|
||||
if timestamp.tzinfo is None:
|
||||
timestamp = timestamp.replace(tzinfo=timezone.utc)
|
||||
|
||||
# Extract and default fields
|
||||
event_type = signal.get("event_type") or "unknown"
|
||||
source_group = signal.get("source_group") or "company"
|
||||
horizon = signal.get("horizon") or "7d"
|
||||
if horizon not in _VALID_HORIZONS:
|
||||
horizon = "7d"
|
||||
|
||||
# Direction mapping
|
||||
direction = _map_direction(signal.get("sentiment") or signal.get("direction"))
|
||||
|
||||
# Optional numeric fields — default to 0.5 if missing
|
||||
sentiment_strength = _clamp(_safe_float(signal.get("sentiment_strength")), 0.0, 1.0)
|
||||
impact = _clamp(_safe_float(signal.get("impact")), 0.0, 1.0)
|
||||
extraction_conf = _clamp(_safe_float(signal.get("extraction_conf") or signal.get("extraction_confidence")), 0.0, 1.0)
|
||||
source_cred = _clamp(_safe_float(signal.get("source_cred") or signal.get("source_credibility")), 0.0, 1.0)
|
||||
novelty = _clamp(_safe_float(signal.get("novelty") or signal.get("novelty_score")), 0.0, 1.0)
|
||||
|
||||
# Event base rate
|
||||
event_base_rate = _get_event_base_rate(event_type)
|
||||
|
||||
# Cluster ID
|
||||
if cluster_id is None:
|
||||
time_bucket = _default_time_bucket(timestamp, horizon)
|
||||
cluster_id = _compute_cluster_id(symbol, horizon, event_type, source_group, time_bucket)
|
||||
|
||||
return EvidenceUnit(
|
||||
symbol=str(symbol),
|
||||
layer="company",
|
||||
event_type=event_type,
|
||||
source_id=str(source_id),
|
||||
source_group=source_group,
|
||||
timestamp=timestamp,
|
||||
horizon=horizon,
|
||||
direction=direction,
|
||||
sentiment_strength=sentiment_strength,
|
||||
impact=impact,
|
||||
extraction_conf=extraction_conf,
|
||||
source_cred=source_cred,
|
||||
novelty=novelty,
|
||||
event_base_rate=event_base_rate,
|
||||
cluster_id=cluster_id,
|
||||
)
|
||||
|
||||
|
||||
def normalize_macro_signal(
|
||||
signal: dict[str, Any],
|
||||
*,
|
||||
cluster_id: str | None = None,
|
||||
) -> EvidenceUnit | None:
|
||||
"""Normalize a macro signal into an EvidenceUnit.
|
||||
|
||||
Macro signals come from macro_impact_records joined with global_events.
|
||||
|
||||
Args:
|
||||
signal: Dict with keys from macro impact/global event records.
|
||||
Required: symbol (or ticker), timestamp, source_id (or event_id)
|
||||
Optional: event_type, impact_direction, macro_impact_score,
|
||||
event_confidence, estimated_duration, novelty
|
||||
cluster_id: If provided, use this cluster_id.
|
||||
|
||||
Returns:
|
||||
EvidenceUnit or None if required fields are missing.
|
||||
"""
|
||||
# Validate required fields
|
||||
symbol = signal.get("symbol") or signal.get("ticker")
|
||||
timestamp = signal.get("timestamp")
|
||||
source_id = signal.get("source_id") or signal.get("event_id")
|
||||
|
||||
if not symbol:
|
||||
logger.warning("v3: Rejecting macro signal — missing 'symbol'/'ticker'. source: %s", signal.get("source_id", "unknown"))
|
||||
return None
|
||||
if timestamp is None:
|
||||
logger.warning("v3: Rejecting macro signal — missing 'timestamp'. symbol=%s, source_id=%s", symbol, source_id)
|
||||
return None
|
||||
if not source_id:
|
||||
logger.warning("v3: Rejecting macro signal — missing 'source_id'/'event_id'. symbol=%s", symbol)
|
||||
return None
|
||||
|
||||
# Ensure timestamp is datetime
|
||||
if isinstance(timestamp, str):
|
||||
timestamp = datetime.fromisoformat(timestamp)
|
||||
if timestamp.tzinfo is None:
|
||||
timestamp = timestamp.replace(tzinfo=timezone.utc)
|
||||
|
||||
# Extract fields
|
||||
event_type = signal.get("event_type") or "unknown"
|
||||
source_group = "macro"
|
||||
|
||||
# Horizon from estimated_duration
|
||||
estimated_duration = signal.get("estimated_duration") or "medium_term"
|
||||
horizon = _MACRO_HORIZON_MAP.get(estimated_duration, "30d")
|
||||
|
||||
# Direction from impact_direction
|
||||
direction = _map_direction(signal.get("impact_direction") or signal.get("direction"))
|
||||
|
||||
# Impact from macro_impact_score
|
||||
impact = _clamp(_safe_float(signal.get("macro_impact_score") or signal.get("impact")), 0.0, 1.0)
|
||||
|
||||
# Source cred and extraction conf from event_confidence
|
||||
event_confidence = _safe_float(signal.get("event_confidence") or signal.get("confidence"))
|
||||
source_cred = _clamp(event_confidence, 0.0, 1.0)
|
||||
extraction_conf = _clamp(event_confidence, 0.0, 1.0)
|
||||
|
||||
# Novelty: 1.0 for new events (as per requirement 1.2)
|
||||
novelty = _clamp(_safe_float(signal.get("novelty"), default=1.0), 0.0, 1.0)
|
||||
|
||||
# Sentiment strength — default 0.5 for macro
|
||||
sentiment_strength = _clamp(_safe_float(signal.get("sentiment_strength")), 0.0, 1.0)
|
||||
|
||||
# Event base rate
|
||||
event_base_rate = _get_event_base_rate(event_type)
|
||||
|
||||
# Cluster ID
|
||||
if cluster_id is None:
|
||||
time_bucket = _default_time_bucket(timestamp, horizon)
|
||||
cluster_id = _compute_cluster_id(str(symbol), horizon, event_type, source_group, time_bucket)
|
||||
|
||||
return EvidenceUnit(
|
||||
symbol=str(symbol),
|
||||
layer="macro",
|
||||
event_type=event_type,
|
||||
source_id=str(source_id),
|
||||
source_group=source_group,
|
||||
timestamp=timestamp,
|
||||
horizon=horizon,
|
||||
direction=direction,
|
||||
sentiment_strength=sentiment_strength,
|
||||
impact=impact,
|
||||
extraction_conf=extraction_conf,
|
||||
source_cred=source_cred,
|
||||
novelty=novelty,
|
||||
event_base_rate=event_base_rate,
|
||||
cluster_id=cluster_id,
|
||||
)
|
||||
|
||||
|
||||
def normalize_competitive_signal(
|
||||
signal: dict[str, Any],
|
||||
*,
|
||||
cluster_id: str | None = None,
|
||||
) -> EvidenceUnit | None:
|
||||
"""Normalize a competitive signal into an EvidenceUnit.
|
||||
|
||||
Competitive signals come from pattern mining and cross-company propagation.
|
||||
|
||||
Args:
|
||||
signal: Dict with keys from competitive_signal_records.
|
||||
Required: symbol (or target_ticker), timestamp, source_id (or source_document_id)
|
||||
Optional: event_type, signal_direction, signal_strength,
|
||||
relationship_strength, pattern_confidence, time_horizon
|
||||
cluster_id: If provided, use this cluster_id.
|
||||
|
||||
Returns:
|
||||
EvidenceUnit or None if required fields are missing.
|
||||
"""
|
||||
# Validate required fields
|
||||
symbol = signal.get("symbol") or signal.get("target_ticker")
|
||||
timestamp = signal.get("timestamp")
|
||||
source_id = signal.get("source_id") or signal.get("source_document_id")
|
||||
|
||||
if not symbol:
|
||||
logger.warning("v3: Rejecting competitive signal — missing 'symbol'/'target_ticker'. source: %s", signal.get("source_id", "unknown"))
|
||||
return None
|
||||
if timestamp is None:
|
||||
logger.warning("v3: Rejecting competitive signal — missing 'timestamp'. symbol=%s, source_id=%s", symbol, source_id)
|
||||
return None
|
||||
if not source_id:
|
||||
logger.warning("v3: Rejecting competitive signal — missing 'source_id'/'source_document_id'. symbol=%s", symbol)
|
||||
return None
|
||||
|
||||
# Ensure timestamp is datetime
|
||||
if isinstance(timestamp, str):
|
||||
timestamp = datetime.fromisoformat(timestamp)
|
||||
if timestamp.tzinfo is None:
|
||||
timestamp = timestamp.replace(tzinfo=timezone.utc)
|
||||
|
||||
# Extract fields
|
||||
event_type = signal.get("event_type") or "unknown"
|
||||
source_group = "competitive"
|
||||
|
||||
# Horizon from time_horizon field
|
||||
time_horizon = signal.get("time_horizon") or signal.get("horizon") or "7d"
|
||||
if time_horizon in _MACRO_HORIZON_MAP:
|
||||
horizon = _MACRO_HORIZON_MAP[time_horizon]
|
||||
elif time_horizon in _VALID_HORIZONS:
|
||||
horizon = time_horizon
|
||||
else:
|
||||
horizon = "7d"
|
||||
|
||||
# Direction from signal_direction (bullish/bearish/neutral)
|
||||
direction = _map_direction(signal.get("signal_direction") or signal.get("direction"))
|
||||
|
||||
# Impact = signal_strength × relationship_strength (Req 1.3)
|
||||
signal_strength = _safe_float(signal.get("signal_strength"))
|
||||
relationship_strength = _safe_float(signal.get("relationship_strength"))
|
||||
impact = _clamp(signal_strength * relationship_strength, 0.0, 1.0)
|
||||
|
||||
# Source cred from pattern_confidence (Req 1.3)
|
||||
pattern_confidence = _safe_float(signal.get("pattern_confidence"))
|
||||
source_cred = _clamp(pattern_confidence, 0.0, 1.0)
|
||||
|
||||
# Extraction conf = pattern_confidence (Req 1.3)
|
||||
extraction_conf = _clamp(pattern_confidence, 0.0, 1.0)
|
||||
|
||||
# Novelty: 1.0 for competitive signals (Req 1.3)
|
||||
novelty = _clamp(_safe_float(signal.get("novelty"), default=1.0), 0.0, 1.0)
|
||||
|
||||
# Sentiment strength — default 0.5 for competitive
|
||||
sentiment_strength = _clamp(_safe_float(signal.get("sentiment_strength")), 0.0, 1.0)
|
||||
|
||||
# Event base rate
|
||||
event_base_rate = _get_event_base_rate(event_type)
|
||||
|
||||
# Cluster ID
|
||||
if cluster_id is None:
|
||||
time_bucket = _default_time_bucket(timestamp, horizon)
|
||||
cluster_id = _compute_cluster_id(str(symbol), horizon, event_type, source_group, time_bucket)
|
||||
|
||||
return EvidenceUnit(
|
||||
symbol=str(symbol),
|
||||
layer="competitive",
|
||||
event_type=event_type,
|
||||
source_id=str(source_id),
|
||||
source_group=source_group,
|
||||
timestamp=timestamp,
|
||||
horizon=horizon,
|
||||
direction=direction,
|
||||
sentiment_strength=sentiment_strength,
|
||||
impact=impact,
|
||||
extraction_conf=extraction_conf,
|
||||
source_cred=source_cred,
|
||||
novelty=novelty,
|
||||
event_base_rate=event_base_rate,
|
||||
cluster_id=cluster_id,
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# V3 Calibrated Reliability Pipeline
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceStats:
|
||||
"""Historical accuracy stats for a signal source (Bayesian prior).
|
||||
|
||||
Used to compute q_source via Beta-Binomial shrinkage.
|
||||
"""
|
||||
|
||||
source_id: str
|
||||
hits: int = 0 # correct directional predictions
|
||||
misses: int = 0 # incorrect directional predictions
|
||||
alpha_0: float = 3.0 # Beta prior alpha (pseudo-successes)
|
||||
beta_0: float = 3.0 # Beta prior beta (pseudo-failures)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReliabilityComponents:
|
||||
"""Breakdown of calibrated reliability for a single signal.
|
||||
|
||||
Each q_* factor is in [0, 1] and represents one quality dimension.
|
||||
q_i is the final combined reliability used downstream in LLR conversion.
|
||||
"""
|
||||
|
||||
q_ext: float # extraction confidence reliability
|
||||
q_source: float # source accuracy reliability (Bayesian shrinkage)
|
||||
q_recency: float # temporal freshness reliability
|
||||
q_uniqueness: float # novelty / de-duplication reliability
|
||||
q_i: float # final combined: clamp(q_ext × q_source × source_cred × q_recency × q_uniqueness, 0, 1)
|
||||
|
||||
|
||||
# Horizon-specific base half-lives for recency decay (hours)
|
||||
_V3_TAU_BASE: dict[str, float] = {
|
||||
"intraday": 2.0,
|
||||
"1d": 12.0,
|
||||
"7d": 72.0,
|
||||
"30d": 240.0,
|
||||
"90d": 720.0,
|
||||
}
|
||||
|
||||
|
||||
def _sigmoid(x: float) -> float:
|
||||
"""Compute sigmoid(x) = 1 / (1 + exp(-x)) with overflow guard."""
|
||||
if x < -500.0:
|
||||
return 0.0
|
||||
if x > 500.0:
|
||||
return 1.0
|
||||
return 1.0 / (1.0 + math.exp(-x))
|
||||
|
||||
|
||||
def compute_v3_reliability(
|
||||
unit: EvidenceUnit,
|
||||
source_stats: SourceStats,
|
||||
cluster_position: int, # duplicate_count_before
|
||||
reference_time: datetime,
|
||||
) -> ReliabilityComponents:
|
||||
"""Compute calibrated reliability components for an EvidenceUnit.
|
||||
|
||||
Implements Requirements 2.1–2.9: extraction confidence gate, Bayesian
|
||||
source accuracy, adaptive recency decay, and novelty/uniqueness penalty.
|
||||
|
||||
Args:
|
||||
unit: The normalized evidence unit to score.
|
||||
source_stats: Historical accuracy record for the signal's source.
|
||||
cluster_position: Number of signals in the same cluster ingested
|
||||
before this one (duplicate_count_before). 0 for first-in-cluster.
|
||||
reference_time: The "now" anchor for computing age_hours.
|
||||
|
||||
Returns:
|
||||
ReliabilityComponents with individual factors and combined q_i.
|
||||
"""
|
||||
# --- q_ext: extraction confidence reliability (Req 2.1) ---
|
||||
# sigmoid(8.0 × (extraction_conf - 0.55))
|
||||
q_ext = _sigmoid(8.0 * (unit.extraction_conf - 0.55))
|
||||
|
||||
# --- q_source: Bayesian shrinkage source reliability (Req 2.2, 2.3) ---
|
||||
alpha = source_stats.alpha_0 + source_stats.hits
|
||||
beta = source_stats.beta_0 + source_stats.misses
|
||||
e_theta = alpha / (alpha + beta)
|
||||
# clamp((E[theta] - 0.50) / 0.35, 0, 1)
|
||||
q_source = _clamp((e_theta - 0.50) / 0.35, 0.0, 1.0)
|
||||
|
||||
# --- q_recency: adaptive exponential decay (Req 2.4, 2.5, 2.6) ---
|
||||
# Ensure tz-aware timestamps
|
||||
ts = unit.timestamp
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
ref = reference_time
|
||||
if ref.tzinfo is None:
|
||||
ref = ref.replace(tzinfo=timezone.utc)
|
||||
|
||||
age_hours = max((ref - ts).total_seconds() / 3600.0, 0.0)
|
||||
|
||||
# Adaptive half-life: tau_adaptive = tau_base × (1 + 0.75 × impact + 0.50 × surprise)
|
||||
# surprise = clamp(-log2(event_base_rate) / 5, 0, 1)
|
||||
event_base_rate = unit.event_base_rate
|
||||
if event_base_rate <= 0.0:
|
||||
event_base_rate = 0.10 # Req 2.5: default to 0.10 to prevent log(0)
|
||||
|
||||
surprise = _clamp(-math.log2(event_base_rate) / 5.0, 0.0, 1.0)
|
||||
|
||||
tau_base = _V3_TAU_BASE.get(unit.horizon, 72.0)
|
||||
tau_adaptive = tau_base * (1.0 + 0.75 * unit.impact + 0.50 * surprise)
|
||||
|
||||
# q_recency = 2^(-age_hours / tau_adaptive)
|
||||
# Guard against extreme exponents
|
||||
if tau_adaptive <= 0.0:
|
||||
tau_adaptive = tau_base # fallback
|
||||
exponent = -age_hours / tau_adaptive
|
||||
# For very large negative exponents, result is effectively 0
|
||||
if exponent < -1000.0:
|
||||
q_recency = 0.0
|
||||
else:
|
||||
q_recency = math.pow(2.0, exponent)
|
||||
|
||||
# --- q_uniqueness: novelty + de-duplication (Req 2.7) ---
|
||||
# clamp(0.5 + 0.5 × novelty, 0.5, 1.0) × (1 / sqrt(1 + dup_count))
|
||||
novelty_factor = _clamp(0.5 + 0.5 * unit.novelty, 0.5, 1.0)
|
||||
dedup_factor = 1.0 / math.sqrt(1.0 + cluster_position)
|
||||
q_uniqueness = novelty_factor * dedup_factor
|
||||
|
||||
# --- q_i: combined reliability (Req 2.8) ---
|
||||
q_i = _clamp(
|
||||
q_ext * q_source * unit.source_cred * q_recency * q_uniqueness,
|
||||
0.0,
|
||||
1.0,
|
||||
)
|
||||
|
||||
# --- Explainability floor on q_recency (Req 2.9) ---
|
||||
# Apply floor of 0.01 only for the display value; q_i uses raw q_recency
|
||||
q_recency_display = max(q_recency, 0.01)
|
||||
|
||||
return ReliabilityComponents(
|
||||
q_ext=q_ext,
|
||||
q_source=q_source,
|
||||
q_recency=q_recency_display,
|
||||
q_uniqueness=q_uniqueness,
|
||||
q_i=q_i,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# V3 LLR Conversion (Requirements 3.1–3.6)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def compute_llr(unit: EvidenceUnit, q_i: float) -> float:
|
||||
"""Convert calibrated reliability to log-likelihood ratio.
|
||||
|
||||
Requirements: 3.1–3.6
|
||||
|
||||
Formula:
|
||||
p_correct = clamp(0.50 + 0.35 × q_i × impact × sentiment_strength, 0.501, 0.85)
|
||||
LLR_i = direction × ln(p_correct / (1 - p_correct))
|
||||
|
||||
For neutral signals (direction == 0), returns 0.0 immediately.
|
||||
For directional signals, the LLR sign always matches direction.
|
||||
|
||||
Bounds:
|
||||
- Minimum |LLR| ≈ ln(0.501/0.499) ≈ 0.004 for directional signals
|
||||
- Maximum |LLR| ≈ ln(0.85/0.15) ≈ 1.735
|
||||
|
||||
Args:
|
||||
unit: The normalized evidence unit containing direction, impact,
|
||||
and sentiment_strength.
|
||||
q_i: The combined calibrated reliability from compute_v3_reliability.
|
||||
|
||||
Returns:
|
||||
Log-likelihood ratio. Positive for bullish, negative for bearish,
|
||||
zero for neutral.
|
||||
"""
|
||||
# Req 3.5: Neutral signals produce zero LLR
|
||||
if unit.direction == 0:
|
||||
return 0.0
|
||||
|
||||
# Req 3.1–3.2: Compute p_correct with calibrated reliability
|
||||
p_correct = _clamp(
|
||||
0.50 + 0.35 * q_i * unit.impact * unit.sentiment_strength,
|
||||
0.501,
|
||||
0.85,
|
||||
)
|
||||
|
||||
# Req 3.3–3.4: LLR_i = direction × ln(p_correct / (1 - p_correct))
|
||||
llr = unit.direction * math.log(p_correct / (1.0 - p_correct))
|
||||
|
||||
return llr
|
||||
|
||||
@@ -378,3 +378,74 @@ def build_pattern_weighted_signals(
|
||||
))
|
||||
|
||||
return signals
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# v3 — Correlation-shrunk competitive propagation (Requirements: 10.1–10.5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_V3_MAX_NETWORK_DISTANCE = 3
|
||||
|
||||
|
||||
def compute_shrunk_correlation(
|
||||
rho_rolling: float,
|
||||
n_observations: int,
|
||||
same_sector: bool,
|
||||
) -> float:
|
||||
"""Compute shrinkage-adjusted correlation.
|
||||
|
||||
Shrinks the rolling correlation toward a sector-aware prior using a
|
||||
Bayesian-style weight of n / (n + 30).
|
||||
|
||||
rho_prior = 0.30 if same_sector else 0.10
|
||||
rho_shrunk = (n/(n+30)) × rho_rolling + (30/(n+30)) × rho_prior
|
||||
rho_effective = max(rho_shrunk, 0)
|
||||
|
||||
Args:
|
||||
rho_rolling: Rolling pairwise correlation estimate.
|
||||
n_observations: Number of observations used to compute rho_rolling.
|
||||
same_sector: Whether the two securities are in the same sector.
|
||||
|
||||
Returns:
|
||||
Non-negative shrinkage-adjusted correlation (rho_effective).
|
||||
|
||||
Requirements: 10.1, 10.2
|
||||
"""
|
||||
rho_prior = 0.30 if same_sector else 0.10
|
||||
n = n_observations
|
||||
rho_shrunk = (n / (n + 30)) * rho_rolling + (30 / (n + 30)) * rho_prior
|
||||
rho_effective = max(rho_shrunk, 0.0)
|
||||
return rho_effective
|
||||
|
||||
|
||||
def compute_competitive_llr(
|
||||
llr_source: float,
|
||||
rho_effective: float,
|
||||
d_network: int,
|
||||
pattern_confidence: float,
|
||||
) -> float:
|
||||
"""Compute competitive LLR with graph attenuation.
|
||||
|
||||
attenuation = rho_effective × exp(-0.85 × d_network)
|
||||
LLR_competitive = clamp(llr_source × attenuation × pattern_confidence, -1.25, 1.25)
|
||||
|
||||
When d_network > 3 → attenuation = 0 → LLR_competitive = 0
|
||||
|
||||
Args:
|
||||
llr_source: Source signal LLR value.
|
||||
rho_effective: Shrinkage-adjusted correlation (non-negative).
|
||||
d_network: Graph distance between source and target (integer >= 1).
|
||||
pattern_confidence: Confidence of the historical pattern in [0, 1].
|
||||
|
||||
Returns:
|
||||
Competitive LLR clamped to [-1.25, 1.25]. Returns 0.0 when
|
||||
d_network exceeds max distance of 3.
|
||||
|
||||
Requirements: 10.3, 10.4, 10.5
|
||||
"""
|
||||
if d_network > _V3_MAX_NETWORK_DISTANCE:
|
||||
return 0.0
|
||||
|
||||
attenuation = rho_effective * math.exp(-0.85 * d_network)
|
||||
llr_competitive = llr_source * attenuation * pattern_confidence
|
||||
return max(-1.25, min(1.25, llr_competitive))
|
||||
|
||||
+1017
-1
File diff suppressed because it is too large
Load Diff
+8
-2
@@ -456,7 +456,7 @@ async def list_trend_history(
|
||||
dominant_catalysts, material_risks, generated_at
|
||||
FROM trend_history
|
||||
{where}
|
||||
ORDER BY generated_at ASC
|
||||
ORDER BY generated_at DESC
|
||||
LIMIT ${idx}""",
|
||||
*params, limit,
|
||||
)
|
||||
@@ -470,6 +470,9 @@ async def list_trend_history(
|
||||
d["dominant_catalysts"] = _parse_jsonb(d.get("dominant_catalysts"))
|
||||
d["material_risks"] = _parse_jsonb(d.get("material_risks"))
|
||||
results.append(d)
|
||||
# Return in ascending order for chart rendering (query fetches newest first
|
||||
# so the LIMIT captures recent data relevant to short time windows).
|
||||
results.reverse()
|
||||
return results
|
||||
|
||||
|
||||
@@ -496,7 +499,7 @@ async def get_market_prices(
|
||||
(data->>'t')::bigint AS bar_timestamp
|
||||
FROM market_snapshots
|
||||
WHERE ticker = $1 AND snapshot_type = 'bar'
|
||||
ORDER BY captured_at ASC
|
||||
ORDER BY captured_at DESC
|
||||
LIMIT $2""",
|
||||
ticker, limit,
|
||||
)
|
||||
@@ -521,6 +524,9 @@ async def get_market_prices(
|
||||
"bar_timestamp": bar_ts,
|
||||
"captured_at": r["captured_at"].isoformat() if r["captured_at"] else None,
|
||||
})
|
||||
# Reverse to ascending order for chart rendering (query fetches newest first
|
||||
# so the LIMIT captures recent data relevant to short time windows).
|
||||
results.reverse()
|
||||
|
||||
# Compute 90-day high/low from all bars in the window
|
||||
cutoff_90d = datetime.now(timezone.utc) - timedelta(days=90)
|
||||
|
||||
@@ -10,6 +10,10 @@ All decisions are rule-based with no model involvement. The LLM is only
|
||||
used downstream for optional thesis wording (a separate task).
|
||||
|
||||
Requirements: 7.1, 7.2, 7.3, 7.4, 14.1, 14.2, 14.3, 14.4, 14.5, 14.6
|
||||
|
||||
v3 additions:
|
||||
- ReturnDistribution and EV gate (Requirement 12)
|
||||
- Regime-aware eligibility and mode escalation (Requirements 13.1–13.5)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -464,3 +468,290 @@ def evaluate_eligibility(
|
||||
p_bull=p_bull if probabilistic else None,
|
||||
pipeline_mode="probabilistic" if probabilistic else "heuristic",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# V3 Return Distribution and EV Gate (Requirements 12.1–12.7)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_V3_MIN_EDGE: dict[str, float] = {
|
||||
"panic": 0.0100,
|
||||
"trend_following": 0.0035,
|
||||
"mean_reversion": 0.0050,
|
||||
"uncertainty": 0.0075,
|
||||
}
|
||||
|
||||
_V3_ELIGIBILITY_THRESHOLDS: dict[str, tuple[float, float, float]] = {
|
||||
# (confidence_min, contradiction_max, strength_min)
|
||||
"panic": (0.70, 0.25, 0.36),
|
||||
"trend_following": (0.55, 0.40, 0.20),
|
||||
"mean_reversion": (0.60, 0.35, 0.26),
|
||||
"uncertainty": (0.65, 0.30, 0.30),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReturnDistribution:
|
||||
"""V3 return distribution model output.
|
||||
|
||||
Encapsulates the horizon-scaled volatility, expected return, risk-adjusted
|
||||
expected value, regime minimum edge, and final eligibility decision.
|
||||
|
||||
Requirements: 12.1, 12.2, 12.3, 12.4, 12.5, 12.6, 12.7
|
||||
"""
|
||||
|
||||
sigma_h: float # realized_vol_20d * sqrt(horizon_days / 252)
|
||||
mu_h: float # tanh(A_projected / 3.0) * confidence * sigma_h
|
||||
ev_long: float # mu_h - costs - 0.10 * CVaR_5
|
||||
min_edge: float # regime-specific minimum edge
|
||||
eligible: bool
|
||||
|
||||
|
||||
def compute_return_distribution(
|
||||
a_projected: float,
|
||||
confidence: float,
|
||||
realized_vol_20d: float,
|
||||
horizon_days: int,
|
||||
costs: float,
|
||||
regime: str,
|
||||
*,
|
||||
confidence_actual: float = 1.0,
|
||||
contradiction: float = 0.0,
|
||||
n_eff_total: float = 0.0,
|
||||
data_quality: float = 1.0,
|
||||
) -> ReturnDistribution:
|
||||
"""Compute the v3 return distribution and EV gate eligibility.
|
||||
|
||||
Implements the return distribution model from the v3 math spec:
|
||||
- sigma_h: horizon-scaled volatility
|
||||
- mu_h: expected return using tanh-compressed projected alpha
|
||||
- CVaR_5: Gaussian approximation of 5th percentile tail loss
|
||||
- EV_long: risk-adjusted expected value after costs and tail risk
|
||||
|
||||
Eligibility requires:
|
||||
- EV_long > regime min_edge
|
||||
- EV_long > max(0.0025, 0.25 * costs)
|
||||
- confidence_actual >= regime confidence_min
|
||||
- contradiction <= regime contradiction_max
|
||||
- n_eff_total >= 2.0
|
||||
- data_quality >= 0.50
|
||||
|
||||
Args:
|
||||
a_projected: Projected evidence state A_projected_h from projection.
|
||||
confidence: Multiplicative confidence from the v3 pipeline.
|
||||
realized_vol_20d: 20-day realized annualized volatility.
|
||||
If <= 0 or unavailable, defaults to 0.25.
|
||||
horizon_days: Trading days for the horizon (1, 7, 30, or 90).
|
||||
costs: Total costs (spread + slippage + commission).
|
||||
regime: Market regime string (panic, trend_following, mean_reversion, uncertainty).
|
||||
confidence_actual: The raw confidence value for threshold checks (defaults to
|
||||
same as confidence if not separately provided).
|
||||
contradiction: Contradiction score in [0, 1].
|
||||
n_eff_total: Total effective evidence count across clusters.
|
||||
data_quality: Data quality score in [0, 1].
|
||||
|
||||
Returns:
|
||||
ReturnDistribution with computed fields and eligibility decision.
|
||||
|
||||
Requirements: 12.1, 12.2, 12.3, 12.4, 12.5, 12.6, 12.7
|
||||
"""
|
||||
# Default volatility when unavailable (Req 12.7)
|
||||
if realized_vol_20d is None or realized_vol_20d <= 0: # type: ignore[redundant-expr]
|
||||
realized_vol_20d = 0.25
|
||||
|
||||
# Req 12.1: sigma_h = realized_vol_20d * sqrt(horizon_days / 252)
|
||||
sigma_h = realized_vol_20d * math.sqrt(horizon_days / 252.0)
|
||||
|
||||
# Req 12.2: mu_h = tanh(A_projected / 3.0) * confidence * sigma_h
|
||||
mu_h = math.tanh(a_projected / 3.0) * confidence * sigma_h
|
||||
|
||||
# Req 12.3: CVaR_5 = sigma_h * 1.645 * 1.4
|
||||
cvar_5 = sigma_h * 1.645 * 1.4
|
||||
|
||||
# Req 12.3: EV_long = mu_h - costs - 0.10 * CVaR_5
|
||||
ev_long = mu_h - costs - 0.10 * cvar_5
|
||||
|
||||
# Req 12.4: regime-specific min_edge
|
||||
min_edge = _V3_MIN_EDGE.get(regime, _V3_MIN_EDGE["uncertainty"])
|
||||
|
||||
# Req 12.5: EV_long > min_edge AND EV_long > max(0.0025, 0.25 * costs)
|
||||
ev_gate_passed = ev_long > min_edge and ev_long > max(0.0025, 0.25 * costs)
|
||||
|
||||
# Req 12.6: Additional eligibility checks
|
||||
thresholds = _V3_ELIGIBILITY_THRESHOLDS.get(
|
||||
regime, _V3_ELIGIBILITY_THRESHOLDS["uncertainty"]
|
||||
)
|
||||
confidence_min, contradiction_max, _strength_min = thresholds
|
||||
|
||||
quality_gate_passed = (
|
||||
confidence_actual >= confidence_min
|
||||
and contradiction <= contradiction_max
|
||||
and n_eff_total >= 2.0
|
||||
and data_quality >= 0.50
|
||||
)
|
||||
|
||||
eligible = ev_gate_passed and quality_gate_passed
|
||||
|
||||
return ReturnDistribution(
|
||||
sigma_h=sigma_h,
|
||||
mu_h=mu_h,
|
||||
ev_long=ev_long,
|
||||
min_edge=min_edge,
|
||||
eligible=eligible,
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# v3 Regime-Aware Eligibility and Mode Escalation (Requirements 13.1–13.5)
|
||||
# ===========================================================================
|
||||
|
||||
# Direction thresholds for action mapping (from bayesian.py)
|
||||
_V3_DIRECTION_THRESHOLDS: dict[str, tuple[float, float]] = {
|
||||
"panic": (0.68, 0.32),
|
||||
"trend_following": (0.60, 0.40),
|
||||
"mean_reversion": (0.63, 0.37),
|
||||
"uncertainty": (0.65, 0.35),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class V3Eligibility:
|
||||
"""Result of v3 regime-aware eligibility evaluation.
|
||||
|
||||
Requirements: 13.1–13.5
|
||||
"""
|
||||
|
||||
action: str # "BUY", "SELL", "HOLD", "WATCH"
|
||||
mode: str # "live", "paper", "informational"
|
||||
eligible: bool # meets regime thresholds
|
||||
reasons: list[str] # reasons for non-eligibility or downgrade
|
||||
|
||||
|
||||
def compute_v3_eligibility(
|
||||
p_up: float,
|
||||
ev_long: float,
|
||||
min_edge: float,
|
||||
confidence: float,
|
||||
contradiction: float,
|
||||
strength: float,
|
||||
n_eff_total: float,
|
||||
data_quality: float,
|
||||
regime: str,
|
||||
has_existing_position: bool = False,
|
||||
risk_engine_passed: bool = True,
|
||||
) -> V3Eligibility:
|
||||
"""Compute regime-aware eligibility and mode escalation.
|
||||
|
||||
Requirements: 13.1–13.5
|
||||
|
||||
Steps:
|
||||
1. Check regime-specific eligibility gates (confidence, contradiction, strength)
|
||||
2. Determine action (BUY/SELL/HOLD/WATCH) based on P_up and EV
|
||||
3. Escalate mode (live/paper/informational) based on signal quality
|
||||
|
||||
Args:
|
||||
p_up: Posterior probability of upward move from Bayesian posterior.
|
||||
ev_long: Expected value from return distribution.
|
||||
min_edge: Regime-specific minimum edge from EV gate.
|
||||
confidence: Multiplicative confidence score in [0, 1].
|
||||
contradiction: LLR entropy contradiction in [0, 1].
|
||||
strength: Signal strength = abs(2 * P_up - 1).
|
||||
n_eff_total: Effective evidence count across all clusters.
|
||||
data_quality: Data quality score in [0, 1].
|
||||
regime: Current market regime string.
|
||||
has_existing_position: Whether the entity already has an open position.
|
||||
risk_engine_passed: Whether the risk engine approved the trade.
|
||||
|
||||
Returns:
|
||||
V3Eligibility with action, mode, eligible flag, and reasons.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
|
||||
# --- 1. Regime-specific eligibility gates (Requirement 13.1) ---
|
||||
conf_min, contra_max, str_min = _V3_ELIGIBILITY_THRESHOLDS.get(
|
||||
regime, (0.65, 0.30, 0.30) # default to uncertainty thresholds
|
||||
)
|
||||
|
||||
eligible = True
|
||||
|
||||
if confidence < conf_min:
|
||||
eligible = False
|
||||
reasons.append(f"confidence {confidence:.3f} < regime min {conf_min:.2f}")
|
||||
|
||||
if contradiction > contra_max:
|
||||
eligible = False
|
||||
reasons.append(
|
||||
f"contradiction {contradiction:.3f} > regime max {contra_max:.2f}"
|
||||
)
|
||||
|
||||
if strength < str_min:
|
||||
eligible = False
|
||||
reasons.append(f"strength {strength:.3f} < regime min {str_min:.2f}")
|
||||
|
||||
if data_quality < 0.50:
|
||||
eligible = False
|
||||
reasons.append(f"data_quality {data_quality:.3f} < 0.50")
|
||||
|
||||
if n_eff_total < 2.0:
|
||||
eligible = False
|
||||
reasons.append(f"n_eff_total {n_eff_total:.2f} < 2.0")
|
||||
|
||||
# --- 2. Action mapping (Requirement 13.2) ---
|
||||
bull_thresh, _bear_thresh = _V3_DIRECTION_THRESHOLDS.get(
|
||||
regime, (0.65, 0.35)
|
||||
)
|
||||
|
||||
if not eligible:
|
||||
action = "WATCH"
|
||||
elif p_up >= bull_thresh and ev_long > min_edge:
|
||||
action = "BUY"
|
||||
elif has_existing_position and ev_long <= 0:
|
||||
# SELL when existing position and exit EV > hold EV
|
||||
# Simplified: EV_exit > EV_hold approximated as ev_long <= 0
|
||||
# (holding has negative expected value → better to exit)
|
||||
action = "SELL"
|
||||
elif has_existing_position:
|
||||
action = "HOLD"
|
||||
else:
|
||||
action = "WATCH"
|
||||
|
||||
# --- 3. Mode escalation (Requirements 13.3, 13.4, 13.5) ---
|
||||
if action in ("BUY", "SELL"):
|
||||
# Check live eligibility (Requirement 13.3)
|
||||
if (
|
||||
confidence >= 0.75
|
||||
and contradiction <= 0.20
|
||||
and n_eff_total >= 5
|
||||
and ev_long > 2 * min_edge
|
||||
and risk_engine_passed
|
||||
):
|
||||
mode = "live"
|
||||
# Check paper eligibility (Requirement 13.4)
|
||||
elif (
|
||||
confidence >= 0.60
|
||||
and ev_long > min_edge
|
||||
and risk_engine_passed
|
||||
):
|
||||
mode = "paper"
|
||||
else:
|
||||
mode = "informational"
|
||||
if confidence < 0.60:
|
||||
reasons.append(
|
||||
f"paper requires confidence >= 0.60, got {confidence:.3f}"
|
||||
)
|
||||
if ev_long <= min_edge:
|
||||
reasons.append(
|
||||
f"paper requires EV > min_edge ({min_edge:.4f}), got {ev_long:.4f}"
|
||||
)
|
||||
if not risk_engine_passed:
|
||||
reasons.append("risk engine did not pass")
|
||||
else:
|
||||
# HOLD and WATCH are always informational (Requirement 13.5)
|
||||
mode = "informational"
|
||||
|
||||
return V3Eligibility(
|
||||
action=action,
|
||||
mode=mode,
|
||||
eligible=eligible,
|
||||
reasons=reasons,
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ from __future__ import annotations
|
||||
|
||||
import math
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
@@ -702,3 +703,163 @@ def evaluate_order(
|
||||
state_snapshot=state,
|
||||
evaluated_at=now,
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# v3 Stop-Defined Portfolio Heat (Requirements 15.1–15.5)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def compute_portfolio_heat(
|
||||
positions: list[dict[str, float]],
|
||||
stop_distances: dict[str, float],
|
||||
) -> float:
|
||||
"""Compute total portfolio heat from stop-defined risk dollars.
|
||||
|
||||
risk_dollars = position_value × stop_distance_pct for each position.
|
||||
portfolio_heat = sum of all risk_dollars.
|
||||
|
||||
positions: list of dicts with keys "ticker" and "position_value"
|
||||
stop_distances: dict mapping ticker to stop_distance_pct
|
||||
|
||||
Requirements: 15.1, 15.2
|
||||
"""
|
||||
total_heat = 0.0
|
||||
for pos in positions:
|
||||
ticker = pos.get("ticker", "")
|
||||
position_value = pos.get("position_value", 0.0)
|
||||
stop_distance_pct = stop_distances.get(ticker, 0.0)
|
||||
risk_dollars = position_value * stop_distance_pct
|
||||
total_heat += risk_dollars
|
||||
return total_heat
|
||||
|
||||
|
||||
def check_heat_capacity(
|
||||
current_heat: float,
|
||||
new_risk_dollars: float,
|
||||
max_heat_pct: float,
|
||||
portfolio_value: float,
|
||||
) -> bool:
|
||||
"""Check if a new position would exceed heat capacity.
|
||||
|
||||
Returns True if the new entry is allowed (capacity exists).
|
||||
Returns False if it would exceed max_heat_pct × portfolio_value.
|
||||
|
||||
Requirements: 15.3, 15.4, 15.5
|
||||
"""
|
||||
max_heat_dollars = max_heat_pct * portfolio_value
|
||||
return (current_heat + new_risk_dollars) <= max_heat_dollars
|
||||
|
||||
|
||||
def compute_available_heat_capacity(
|
||||
current_heat: float,
|
||||
max_heat_pct: float,
|
||||
portfolio_value: float,
|
||||
) -> float:
|
||||
"""Compute available heat capacity for new positions.
|
||||
|
||||
available = max_heat_pct × portfolio_value - current_heat
|
||||
Returns max(0, available).
|
||||
|
||||
Requirement: 15.4
|
||||
"""
|
||||
max_heat_dollars = max_heat_pct * portfolio_value
|
||||
available = max_heat_dollars - current_heat
|
||||
return max(0.0, available)
|
||||
|
||||
|
||||
def compute_heat_capacity_pct(
|
||||
current_heat: float,
|
||||
max_heat_pct: float,
|
||||
portfolio_value: float,
|
||||
) -> float:
|
||||
"""Compute available heat capacity as a portfolio percentage for Kelly sizing.
|
||||
|
||||
This converts absolute available heat dollars into a fraction of portfolio
|
||||
value, suitable for use as `heat_capacity` in the Kelly sizing pipeline's
|
||||
`available_caps` dict.
|
||||
|
||||
Requirements: 15.4, 15.5
|
||||
"""
|
||||
if portfolio_value <= 0.0:
|
||||
return 0.0
|
||||
available_dollars = compute_available_heat_capacity(
|
||||
current_heat, max_heat_pct, portfolio_value
|
||||
)
|
||||
return available_dollars / portfolio_value
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# v3 Risk Tier Auto-Adjustment (Requirements 18.1–18.6)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TierMetrics:
|
||||
"""30-day rolling performance metrics for tier adjustment.
|
||||
|
||||
Collected once per calendar day after session close.
|
||||
|
||||
Requirements: 18.1
|
||||
"""
|
||||
|
||||
profit_factor_30d: float
|
||||
"""Gross profit / gross loss over last 30 days."""
|
||||
|
||||
max_drawdown_30d: float
|
||||
"""Largest peak-to-trough as fraction over last 30 days."""
|
||||
|
||||
calibration_error: float
|
||||
"""Mean |predicted P_up - realized outcome| over last 30 days."""
|
||||
|
||||
realized_sharpe_30d: float
|
||||
"""Annualized Sharpe ratio of daily returns over last 30 days."""
|
||||
|
||||
n_trades_30d: int
|
||||
"""Number of trades executed in last 30 days."""
|
||||
|
||||
reserve_pool_pct: float
|
||||
"""Reserve pool as fraction of total portfolio value."""
|
||||
|
||||
|
||||
def evaluate_tier_adjustment(metrics: TierMetrics) -> str:
|
||||
"""Evaluate whether to upgrade, downgrade, or hold current risk tier.
|
||||
|
||||
Decision logic:
|
||||
- Downgrade if ANY of: profit_factor < 1.0 OR max_drawdown > 0.12 OR
|
||||
calibration_error > 0.20 OR realized_sharpe < 0
|
||||
- Upgrade only if ALL of: profit_factor > 1.35 AND max_drawdown < 0.05 AND
|
||||
calibration_error < 0.12 AND reserve_pool_pct > 0.20 AND n_trades >= 20
|
||||
- Otherwise: hold
|
||||
|
||||
Downgrade is applied immediately; 7-day upgrade cooldown is enforced
|
||||
at the caller level (not in this function). Evaluation runs once per
|
||||
calendar day after session close.
|
||||
|
||||
Returns:
|
||||
'upgrade' | 'downgrade' | 'hold'
|
||||
|
||||
Requirements: 18.2, 18.3, 18.4, 18.5, 18.6
|
||||
"""
|
||||
# --- Downgrade: any single condition triggers ---
|
||||
if (
|
||||
metrics.profit_factor_30d < 1.0
|
||||
or metrics.max_drawdown_30d > 0.12
|
||||
or metrics.calibration_error > 0.20
|
||||
or metrics.realized_sharpe_30d < 0
|
||||
):
|
||||
return "downgrade"
|
||||
|
||||
# --- Upgrade: all conditions must be satisfied ---
|
||||
if (
|
||||
metrics.profit_factor_30d > 1.35
|
||||
and metrics.max_drawdown_30d < 0.05
|
||||
and metrics.calibration_error < 0.12
|
||||
and metrics.reserve_pool_pct > 0.20
|
||||
and metrics.n_trades_30d >= 20
|
||||
):
|
||||
return "upgrade"
|
||||
|
||||
# --- Hold: neither downgrade nor upgrade criteria met ---
|
||||
return "hold"
|
||||
|
||||
@@ -4,11 +4,14 @@ Computes dollar allocation and share quantity for a trade by applying
|
||||
a sequential adjustment pipeline: confidence gate, correlation reduction,
|
||||
sector exposure, diversification bonus, earnings proximity, portfolio
|
||||
heat check, active-pool minimum, absolute cap, and share rounding.
|
||||
|
||||
Also provides v3 fractional Kelly position sizing (Requirements 14.1–14.7).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from services.trading.models import (
|
||||
@@ -345,3 +348,111 @@ class PositionSizer:
|
||||
return new_dollar, new_pct
|
||||
|
||||
return dollar_amount, allocation_pct
|
||||
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# v3 Fractional Kelly Position Sizing (Requirements 14.1–14.7)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KellySizingResult:
|
||||
"""Result of v3 fractional Kelly position sizing computation."""
|
||||
|
||||
portfolio_pct: float
|
||||
f_kelly: float
|
||||
reward_ratio: float
|
||||
downgrade: bool
|
||||
downgrade_reason: str
|
||||
|
||||
|
||||
def _clamp(value: float, lo: float, hi: float) -> float:
|
||||
"""Clamp value to [lo, hi]."""
|
||||
return max(lo, min(hi, value))
|
||||
|
||||
|
||||
def compute_reward_ratio(
|
||||
confidence: float, strength: float, contradiction: float
|
||||
) -> float:
|
||||
"""Compute reward ratio b = clamp(1.2 + 2.0*confidence + 1.0*strength - contradiction, 1.2, 3.0).
|
||||
|
||||
Requirements: 14.2
|
||||
"""
|
||||
raw = 1.2 + 2.0 * confidence + 1.0 * strength - contradiction
|
||||
return _clamp(raw, 1.2, 3.0)
|
||||
|
||||
|
||||
def compute_kelly_sizing(
|
||||
p_win: float,
|
||||
b: float,
|
||||
confidence: float,
|
||||
data_quality: float,
|
||||
contradiction: float,
|
||||
max_position_pct: float,
|
||||
available_caps: dict[str, float],
|
||||
) -> KellySizingResult:
|
||||
"""Compute fractional Kelly position sizing.
|
||||
|
||||
f_kelly = (p_win * b - (1 - p_win)) / b
|
||||
portfolio_pct = clamp(max(0, f_kelly) * 0.25 * confidence * data_quality * (1 - contradiction), 0, max_position_pct)
|
||||
|
||||
Apply min of all capacity constraints from available_caps:
|
||||
- sector_capacity
|
||||
- correlation_capacity (0 if avg corr > 0.80)
|
||||
- heat_capacity
|
||||
|
||||
Downgrade rules:
|
||||
- If f_kelly <= 0 → portfolio_pct = 0, downgrade with reason "negative_edge"
|
||||
- If portfolio_pct < 0.005 → downgrade with reason "position_below_minimum"
|
||||
|
||||
Requirements: 14.1, 14.2, 14.3, 14.4, 14.5, 14.6, 14.7
|
||||
"""
|
||||
# Compute Kelly fraction (Req 14.3)
|
||||
f_kelly = (p_win * b - (1.0 - p_win)) / b
|
||||
|
||||
# Negative edge → immediate downgrade (Req 14.7)
|
||||
if f_kelly <= 0.0:
|
||||
return KellySizingResult(
|
||||
portfolio_pct=0.0,
|
||||
f_kelly=f_kelly,
|
||||
reward_ratio=b,
|
||||
downgrade=True,
|
||||
downgrade_reason="negative_edge",
|
||||
)
|
||||
|
||||
# Apply fractional Kelly with dampening factors (Req 14.4)
|
||||
raw_pct = f_kelly * 0.25 * confidence * data_quality * (1.0 - contradiction)
|
||||
portfolio_pct = _clamp(max(0.0, raw_pct), 0.0, max_position_pct)
|
||||
|
||||
# Apply capacity constraints (Req 14.5)
|
||||
sector_capacity = available_caps.get("sector_capacity", max_position_pct)
|
||||
correlation_capacity = available_caps.get("correlation_capacity", max_position_pct)
|
||||
heat_capacity = available_caps.get("heat_capacity", max_position_pct)
|
||||
|
||||
# Correlation capacity of 0 means avg corr > 0.80 → force zero
|
||||
portfolio_pct = min(
|
||||
portfolio_pct,
|
||||
max_position_pct,
|
||||
sector_capacity,
|
||||
correlation_capacity,
|
||||
heat_capacity,
|
||||
)
|
||||
|
||||
# Position below minimum threshold → downgrade (Req 14.6)
|
||||
if portfolio_pct < 0.005:
|
||||
return KellySizingResult(
|
||||
portfolio_pct=0.0,
|
||||
f_kelly=f_kelly,
|
||||
reward_ratio=b,
|
||||
downgrade=True,
|
||||
downgrade_reason="position_below_minimum",
|
||||
)
|
||||
|
||||
return KellySizingResult(
|
||||
portfolio_pct=portfolio_pct,
|
||||
f_kelly=f_kelly,
|
||||
reward_ratio=b,
|
||||
downgrade=False,
|
||||
downgrade_reason="",
|
||||
)
|
||||
|
||||
@@ -5,12 +5,15 @@ re-evaluates levels when volatility or market conditions change, detects
|
||||
price crossings that should trigger exits, and tightens stops under
|
||||
high-heat or high-severity-event conditions.
|
||||
|
||||
Also provides v3 regime-aware stop loss and take profit (Requirements 16.1–16.5).
|
||||
|
||||
All public methods are synchronous (pure computation, no DB access).
|
||||
Persistence is handled by the caller (engine.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from services.trading.models import (
|
||||
@@ -20,6 +23,100 @@ from services.trading.models import (
|
||||
StopTrigger,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# v3 Regime-Aware Stop Loss and Take Profit (Requirements 16.1–16.5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class V3StopLevels:
|
||||
"""v3 stop-loss and take-profit levels computed from regime-aware volatility."""
|
||||
|
||||
stop_loss: float
|
||||
take_profit: float
|
||||
stop_distance_pct: float
|
||||
reward_ratio: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrailingStopResult:
|
||||
"""Result of trailing stop computation."""
|
||||
|
||||
trailing_stop: float
|
||||
activated: bool
|
||||
|
||||
|
||||
def compute_v3_stops(
|
||||
entry_price: float,
|
||||
atr_pct: float,
|
||||
regime_atr_mult: float,
|
||||
sigma_h: float,
|
||||
reward_ratio: float,
|
||||
) -> V3StopLevels:
|
||||
"""Compute regime-aware stop loss and take profit.
|
||||
|
||||
stop_distance_pct = max(ATR_pct × regime_ATR_mult, sigma_h × 1.25, 0.005)
|
||||
stop_loss = entry_price × (1 - stop_distance_pct)
|
||||
take_profit = entry_price × (1 + b × stop_distance_pct)
|
||||
|
||||
Requirements: 16.1, 16.2, 16.3
|
||||
"""
|
||||
# Requirement 16.1: stop distance from regime-aware volatility
|
||||
z_stop = 1.25
|
||||
min_stop_pct = 0.005
|
||||
stop_distance_pct = max(atr_pct * regime_atr_mult, sigma_h * z_stop, min_stop_pct)
|
||||
|
||||
# Requirement 16.2: stop loss for long position
|
||||
stop_loss = entry_price * (1.0 - stop_distance_pct)
|
||||
|
||||
# Requirement 16.3: take profit using dynamic reward ratio
|
||||
take_profit = entry_price * (1.0 + reward_ratio * stop_distance_pct)
|
||||
|
||||
return V3StopLevels(
|
||||
stop_loss=stop_loss,
|
||||
take_profit=take_profit,
|
||||
stop_distance_pct=stop_distance_pct,
|
||||
reward_ratio=reward_ratio,
|
||||
)
|
||||
|
||||
|
||||
def compute_trailing_stop(
|
||||
existing_stop: float,
|
||||
current_price: float,
|
||||
entry_price: float,
|
||||
take_profit: float,
|
||||
atr_pct: float,
|
||||
trailing_atr_mult: float,
|
||||
sigma_h: float,
|
||||
) -> TrailingStopResult:
|
||||
"""Compute trailing stop level.
|
||||
|
||||
Activated when unrealized_gain >= 0.50 × TP distance.
|
||||
trailing_stop = max(existing_stop, current_price × (1 - trailing_distance_pct))
|
||||
trailing_distance_pct = max(ATR_pct × trailing_ATR_mult, sigma_h × 0.75)
|
||||
|
||||
Trailing stop is monotonically non-decreasing.
|
||||
|
||||
Requirements: 16.4, 16.5
|
||||
"""
|
||||
# Requirement 16.4: activation check
|
||||
take_profit_distance = take_profit - entry_price
|
||||
unrealized_gain = current_price - entry_price
|
||||
|
||||
# Activation threshold: gain >= 50% of TP distance
|
||||
if take_profit_distance <= 0 or unrealized_gain < 0.50 * take_profit_distance:
|
||||
# Not activated — return existing stop unchanged
|
||||
return TrailingStopResult(trailing_stop=existing_stop, activated=False)
|
||||
|
||||
# Requirement 16.5: compute trailing stop
|
||||
trailing_distance_pct = max(atr_pct * trailing_atr_mult, sigma_h * 0.75)
|
||||
candidate_stop = current_price * (1.0 - trailing_distance_pct)
|
||||
|
||||
# Monotonically non-decreasing: never lower than existing stop
|
||||
trailing_stop = max(existing_stop, candidate_stop)
|
||||
|
||||
return TrailingStopResult(trailing_stop=trailing_stop, activated=True)
|
||||
|
||||
|
||||
class StopLossManager:
|
||||
"""Compute and maintain dynamic stop-loss / take-profit levels."""
|
||||
|
||||
@@ -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]"
|
||||
)
|
||||
@@ -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}'"
|
||||
)
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -0,0 +1,609 @@
|
||||
"""Property-based tests for v3 posterior assembly and regime classification.
|
||||
|
||||
Feature: math-core-v3-engine
|
||||
|
||||
Uses Hypothesis to validate correctness properties of the v3 posterior
|
||||
and regime detection layer.
|
||||
|
||||
Validates: Requirements 5.3, 6.2–6.5, 21.5
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from services.aggregation.bayesian import V3Posterior, compute_v3_posterior
|
||||
from services.aggregation.regime import (
|
||||
V3RegimeClassification,
|
||||
MarketRegime,
|
||||
classify_regime_v3,
|
||||
)
|
||||
from services.aggregation.worker import EvidenceCluster
|
||||
from services.aggregation.scoring import EvidenceUnit
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hypothesis strategies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Cluster LLR values in [-2.5, 2.5] as per spec
|
||||
cluster_llrs = st.floats(
|
||||
min_value=-2.5, max_value=2.5, allow_nan=False, allow_infinity=False
|
||||
)
|
||||
|
||||
# Lists of cluster LLR values (0 to 10 clusters)
|
||||
cluster_llr_lists = st.lists(cluster_llrs, min_size=0, max_size=10)
|
||||
|
||||
# Regime evidence multiplier gamma from the defined set
|
||||
gammas = st.sampled_from([0.70, 0.80, 0.90, 1.10])
|
||||
|
||||
# Prior probability in [0.40, 0.60]
|
||||
priors = st.floats(
|
||||
min_value=0.40, max_value=0.60, allow_nan=False, allow_infinity=False
|
||||
)
|
||||
|
||||
# Random walk closing prices for regime classification
|
||||
# Generate 120+ prices as a random walk starting from a base price
|
||||
_base_price = st.floats(min_value=10.0, max_value=500.0, allow_nan=False, allow_infinity=False)
|
||||
_price_step = st.floats(min_value=-2.0, max_value=2.0, allow_nan=False, allow_infinity=False)
|
||||
|
||||
|
||||
@st.composite
|
||||
def closing_prices_strategy(draw: st.DrawFn) -> list[float]:
|
||||
"""Generate 120+ closing prices as a random walk."""
|
||||
base = draw(_base_price)
|
||||
n = draw(st.integers(min_value=120, max_value=200))
|
||||
steps = draw(st.lists(_price_step, min_size=n, max_size=n))
|
||||
prices = [base]
|
||||
for step in steps:
|
||||
prices.append(max(0.01, prices[-1] + step)) # keep prices positive
|
||||
return prices
|
||||
|
||||
|
||||
@st.composite
|
||||
def daily_returns_strategy(draw: st.DrawFn) -> list[float]:
|
||||
"""Generate 120+ daily returns (small floats)."""
|
||||
n = draw(st.integers(min_value=120, max_value=200))
|
||||
returns = draw(
|
||||
st.lists(
|
||||
st.floats(min_value=-0.10, max_value=0.10, allow_nan=False, allow_infinity=False),
|
||||
min_size=n,
|
||||
max_size=n,
|
||||
)
|
||||
)
|
||||
return returns
|
||||
|
||||
|
||||
# ATR_20 > 0
|
||||
atr_20_strategy = st.floats(
|
||||
min_value=0.01, max_value=50.0, allow_nan=False, allow_infinity=False
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: build mock EvidenceCluster from a cluster_llr value
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_cluster(cluster_llr: float) -> EvidenceCluster:
|
||||
"""Create a minimal EvidenceCluster with the given cluster_llr."""
|
||||
return EvidenceCluster(
|
||||
cluster_id=f"test_cluster_{id(cluster_llr)}",
|
||||
units=[],
|
||||
llrs=[cluster_llr],
|
||||
n_eff=1.0,
|
||||
cluster_llr=cluster_llr,
|
||||
)
|
||||
|
||||
|
||||
def _make_regime(gamma: float) -> V3RegimeClassification:
|
||||
"""Create a minimal V3RegimeClassification with the given gamma."""
|
||||
return V3RegimeClassification(
|
||||
regime=MarketRegime.UNCERTAINTY,
|
||||
trend_z=0.0,
|
||||
vol_ratio=1.0,
|
||||
evidence_multiplier=gamma,
|
||||
confidence_multiplier=0.85,
|
||||
phi_decay=0.50,
|
||||
atr_multiplier=2.0,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 7: Posterior P_up is in open interval (0, 1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given(
|
||||
llr_values=cluster_llr_lists,
|
||||
gamma=gammas,
|
||||
p_prior=priors,
|
||||
)
|
||||
@settings(max_examples=100)
|
||||
def test_property_7_posterior_p_up_in_open_interval(
|
||||
llr_values: list[float],
|
||||
gamma: float,
|
||||
p_prior: float,
|
||||
) -> None:
|
||||
"""**Validates: Requirements 5.3**
|
||||
|
||||
Property 7: For any set of cluster LLRs (each in [-2.5, 2.5]), any regime
|
||||
evidence multiplier gamma in {0.70, 0.80, 0.90, 1.10}, and any prior
|
||||
P_prior in [0.40, 0.60], the computed P_up SHALL be in (1e-10, 1 - 1e-10).
|
||||
"""
|
||||
# Build mock clusters from the LLR values
|
||||
clusters = [_make_cluster(llr) for llr in llr_values]
|
||||
|
||||
# Build mock regime with the given gamma
|
||||
regime = _make_regime(gamma)
|
||||
|
||||
# Compute posterior
|
||||
result = compute_v3_posterior(clusters, regime, p_prior=p_prior)
|
||||
|
||||
# P_up must be in the open interval (1e-10, 1 - 1e-10)
|
||||
assert result.p_up >= 1e-10, (
|
||||
f"P_up {result.p_up} is below lower bound 1e-10"
|
||||
)
|
||||
assert result.p_up <= 1 - 1e-10, (
|
||||
f"P_up {result.p_up} is above upper bound 1 - 1e-10"
|
||||
)
|
||||
|
||||
# p_down should be 1 - p_up
|
||||
assert math.isclose(result.p_up + result.p_down, 1.0, abs_tol=1e-12), (
|
||||
f"p_up + p_down = {result.p_up + result.p_down}, expected 1.0"
|
||||
)
|
||||
|
||||
# Strength should be abs(2 * P_up - 1)
|
||||
expected_strength = abs(2.0 * result.p_up - 1.0)
|
||||
assert math.isclose(result.strength, expected_strength, abs_tol=1e-12), (
|
||||
f"strength {result.strength} != expected {expected_strength}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 20: Regime classification is exhaustive and deterministic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_VALID_REGIMES = {"panic", "trend_following", "mean_reversion", "uncertainty"}
|
||||
|
||||
|
||||
@given(
|
||||
closing_prices=closing_prices_strategy(),
|
||||
daily_returns=daily_returns_strategy(),
|
||||
atr_20=atr_20_strategy,
|
||||
)
|
||||
@settings(max_examples=100)
|
||||
def test_property_20_regime_classification_exhaustive_and_deterministic(
|
||||
closing_prices: list[float],
|
||||
daily_returns: list[float],
|
||||
atr_20: float,
|
||||
) -> None:
|
||||
"""**Validates: Requirements 6.2–6.5, 21.5**
|
||||
|
||||
Property 20: For any valid market data inputs (closing_prices of sufficient
|
||||
length, daily_returns, ATR_20 > 0), the regime classification SHALL produce
|
||||
exactly one of {panic, trend_following, mean_reversion, uncertainty} and the
|
||||
same inputs SHALL always produce the same classification.
|
||||
"""
|
||||
# First classification
|
||||
result_1 = classify_regime_v3(closing_prices, daily_returns, atr_20)
|
||||
|
||||
# Result must be exactly one of the four valid regimes
|
||||
assert result_1.regime.value in _VALID_REGIMES, (
|
||||
f"Regime '{result_1.regime.value}' not in valid set {_VALID_REGIMES}"
|
||||
)
|
||||
|
||||
# Determinism: second call with same inputs must produce same result
|
||||
result_2 = classify_regime_v3(closing_prices, daily_returns, atr_20)
|
||||
|
||||
assert result_1.regime == result_2.regime, (
|
||||
f"Non-deterministic: first call gave {result_1.regime.value}, "
|
||||
f"second gave {result_2.regime.value}"
|
||||
)
|
||||
assert result_1.trend_z == result_2.trend_z, (
|
||||
f"Non-deterministic trend_z: {result_1.trend_z} vs {result_2.trend_z}"
|
||||
)
|
||||
assert result_1.vol_ratio == result_2.vol_ratio, (
|
||||
f"Non-deterministic vol_ratio: {result_1.vol_ratio} vs {result_2.vol_ratio}"
|
||||
)
|
||||
assert result_1.evidence_multiplier == result_2.evidence_multiplier, (
|
||||
f"Non-deterministic evidence_multiplier: "
|
||||
f"{result_1.evidence_multiplier} vs {result_2.evidence_multiplier}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 8: Contradiction is zero when evidence is unidirectional
|
||||
# Feature: math-core-v3-engine, Property 8: Contradiction is zero when evidence is unidirectional
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from services.aggregation.contradiction import compute_v3_contradiction
|
||||
|
||||
# Strategies for unidirectional cluster LLRs
|
||||
all_positive_llrs = st.lists(
|
||||
st.floats(min_value=0.001, max_value=2.5, allow_nan=False, allow_infinity=False),
|
||||
min_size=1, max_size=10,
|
||||
)
|
||||
all_negative_llrs = st.lists(
|
||||
st.floats(min_value=-2.5, max_value=-0.001, allow_nan=False, allow_infinity=False),
|
||||
min_size=1, max_size=10,
|
||||
)
|
||||
|
||||
|
||||
@given(llr_values=all_positive_llrs)
|
||||
@settings(max_examples=100)
|
||||
def test_property_8_contradiction_zero_all_positive(
|
||||
llr_values: list[float],
|
||||
) -> None:
|
||||
"""**Validates: Requirements 7.6, 7.7**
|
||||
|
||||
Property 8: For any set of cluster LLRs where all clusters have the same
|
||||
sign (all positive), the computed contradiction score SHALL be 0.0.
|
||||
"""
|
||||
clusters = [_make_cluster(llr) for llr in llr_values]
|
||||
score = compute_v3_contradiction(clusters)
|
||||
assert score == 0.0, (
|
||||
f"Expected contradiction 0.0 for all-positive LLRs {llr_values}, got {score}"
|
||||
)
|
||||
|
||||
|
||||
@given(llr_values=all_negative_llrs)
|
||||
@settings(max_examples=100)
|
||||
def test_property_8_contradiction_zero_all_negative(
|
||||
llr_values: list[float],
|
||||
) -> None:
|
||||
"""**Validates: Requirements 7.6, 7.7**
|
||||
|
||||
Property 8: For any set of cluster LLRs where all clusters have the same
|
||||
sign (all negative), the computed contradiction score SHALL be 0.0.
|
||||
"""
|
||||
clusters = [_make_cluster(llr) for llr in llr_values]
|
||||
score = compute_v3_contradiction(clusters)
|
||||
assert score == 0.0, (
|
||||
f"Expected contradiction 0.0 for all-negative LLRs {llr_values}, got {score}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 9: Contradiction score is bounded in [0, 1]
|
||||
# Feature: math-core-v3-engine, Property 9: Contradiction score is bounded in [0, 1]
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Mix of positive and negative cluster LLRs
|
||||
mixed_llrs = st.lists(
|
||||
st.floats(min_value=-2.5, max_value=2.5, allow_nan=False, allow_infinity=False),
|
||||
min_size=0, max_size=15,
|
||||
)
|
||||
|
||||
|
||||
@given(llr_values=mixed_llrs)
|
||||
@settings(max_examples=100)
|
||||
def test_property_9_contradiction_bounded_zero_one(
|
||||
llr_values: list[float],
|
||||
) -> None:
|
||||
"""**Validates: Requirements 7.6, 7.7, 21.7**
|
||||
|
||||
Property 9: For any set of cluster LLRs (including mixed positive and
|
||||
negative), the computed contradiction score SHALL be in [0.0, 1.0].
|
||||
"""
|
||||
clusters = [_make_cluster(llr) for llr in llr_values]
|
||||
score = compute_v3_contradiction(clusters)
|
||||
assert 0.0 <= score <= 1.0, (
|
||||
f"Contradiction score {score} out of bounds [0, 1] for LLRs {llr_values}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 10: Multiplicative confidence is bounded in [0, 1] and suppressed
|
||||
# by weak dimensions
|
||||
# Feature: math-core-v3-engine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from services.aggregation.worker import compute_v3_confidence, compute_v3_data_quality
|
||||
|
||||
# Strategies for Property 10
|
||||
n_eff_totals = st.floats(min_value=0.0, max_value=20.0, allow_nan=False, allow_infinity=False)
|
||||
unit_floats = st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False)
|
||||
q_value_lists = st.lists(unit_floats, min_size=1, max_size=15)
|
||||
llr_lists_conf = st.lists(
|
||||
st.floats(min_value=-2.5, max_value=2.5, allow_nan=False, allow_infinity=False),
|
||||
min_size=1, max_size=15,
|
||||
)
|
||||
regime_conf_mults = st.floats(min_value=0.01, max_value=1.0, allow_nan=False, allow_infinity=False)
|
||||
|
||||
|
||||
@given(
|
||||
n_eff_total=n_eff_totals,
|
||||
q_values=q_value_lists,
|
||||
llrs=llr_lists_conf,
|
||||
strength=unit_floats,
|
||||
regime_confidence_mult=regime_conf_mults,
|
||||
contradiction=unit_floats,
|
||||
data_quality=unit_floats,
|
||||
)
|
||||
@settings(max_examples=100)
|
||||
def test_property_10_confidence_bounded_and_suppressed(
|
||||
n_eff_total: float,
|
||||
q_values: list[float],
|
||||
llrs: list[float],
|
||||
strength: float,
|
||||
regime_confidence_mult: float,
|
||||
contradiction: float,
|
||||
data_quality: float,
|
||||
) -> None:
|
||||
"""**Validates: Requirements 8.3, 8.5**
|
||||
|
||||
Property 10: For any valid inputs (n_eff_total >= 0, q_values in [0,1],
|
||||
strength in [0,1], regime_confidence_mult in (0,1], contradiction in [0,1],
|
||||
data_quality in [0,1]), the computed confidence SHALL be in [0, 1].
|
||||
Furthermore, if any single dimension (data_quality, 1-contradiction, or
|
||||
C_quality) is below 0.01, the resulting confidence SHALL be below 0.10.
|
||||
"""
|
||||
# Align list lengths: q_values and llrs must be parallel
|
||||
min_len = min(len(q_values), len(llrs))
|
||||
q_values = q_values[:min_len]
|
||||
llrs = llrs[:min_len]
|
||||
|
||||
confidence = compute_v3_confidence(
|
||||
n_eff_total=n_eff_total,
|
||||
q_values=q_values,
|
||||
llrs=llrs,
|
||||
strength=strength,
|
||||
regime_confidence_mult=regime_confidence_mult,
|
||||
contradiction=contradiction,
|
||||
data_quality=data_quality,
|
||||
)
|
||||
|
||||
# Confidence must be in [0, 1]
|
||||
assert 0.0 <= confidence <= 1.0, (
|
||||
f"Confidence {confidence} out of bounds [0, 1]"
|
||||
)
|
||||
|
||||
# Suppression check: if data_quality < 0.01, confidence < 0.10
|
||||
if data_quality < 0.01:
|
||||
assert confidence < 0.10, (
|
||||
f"Confidence {confidence} not suppressed by data_quality={data_quality}"
|
||||
)
|
||||
|
||||
# Suppression check: if (1 - contradiction) < 0.01, confidence < 0.10
|
||||
if (1.0 - contradiction) < 0.01:
|
||||
assert confidence < 0.10, (
|
||||
f"Confidence {confidence} not suppressed by contradiction={contradiction}"
|
||||
)
|
||||
|
||||
# Suppression check: if C_quality < 0.01, confidence < 0.10
|
||||
# C_quality = weighted_mean(q_i, |LLR_i|) = sum(q_i * |LLR_i|) / sum(|LLR_i|)
|
||||
abs_llrs = [abs(llr) for llr in llrs]
|
||||
sum_abs_llrs = sum(abs_llrs)
|
||||
if sum_abs_llrs == 0.0:
|
||||
c_quality = 0.0
|
||||
else:
|
||||
c_quality = sum(q * w for q, w in zip(q_values, abs_llrs)) / sum_abs_llrs
|
||||
if c_quality < 0.01:
|
||||
assert confidence < 0.10, (
|
||||
f"Confidence {confidence} not suppressed by C_quality={c_quality}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 10 suppression sub-test: force one dimension below 0.01
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@given(
|
||||
n_eff_total=st.floats(min_value=1.0, max_value=20.0, allow_nan=False, allow_infinity=False),
|
||||
q_values=st.lists(
|
||||
st.floats(min_value=0.5, max_value=1.0, allow_nan=False, allow_infinity=False),
|
||||
min_size=1, max_size=10,
|
||||
),
|
||||
llrs=st.lists(
|
||||
st.floats(min_value=0.5, max_value=2.5, allow_nan=False, allow_infinity=False),
|
||||
min_size=1, max_size=10,
|
||||
),
|
||||
strength=st.floats(min_value=0.3, max_value=1.0, allow_nan=False, allow_infinity=False),
|
||||
regime_confidence_mult=regime_conf_mults,
|
||||
dimension=st.sampled_from(["data_quality", "contradiction", "c_quality"]),
|
||||
)
|
||||
@settings(max_examples=100)
|
||||
def test_property_10_suppression_by_weak_dimension(
|
||||
n_eff_total: float,
|
||||
q_values: list[float],
|
||||
llrs: list[float],
|
||||
strength: float,
|
||||
regime_confidence_mult: float,
|
||||
dimension: str,
|
||||
) -> None:
|
||||
"""**Validates: Requirements 8.3, 8.5**
|
||||
|
||||
Property 10 (suppression): If any single dimension (data_quality,
|
||||
1-contradiction, or C_quality) is below 0.01, the resulting confidence
|
||||
SHALL be below 0.10.
|
||||
"""
|
||||
# Align list lengths
|
||||
min_len = min(len(q_values), len(llrs))
|
||||
q_values = q_values[:min_len]
|
||||
llrs = llrs[:min_len]
|
||||
|
||||
# Force one dimension to be weak
|
||||
if dimension == "data_quality":
|
||||
data_quality = 0.005 # below 0.01
|
||||
contradiction = 0.0
|
||||
elif dimension == "contradiction":
|
||||
data_quality = 0.9
|
||||
contradiction = 0.995 # (1 - contradiction) = 0.005 < 0.01
|
||||
else: # c_quality
|
||||
# Force all q_values near zero so C_quality < 0.01
|
||||
q_values = [0.001] * min_len
|
||||
data_quality = 0.9
|
||||
contradiction = 0.0
|
||||
|
||||
confidence = compute_v3_confidence(
|
||||
n_eff_total=n_eff_total,
|
||||
q_values=q_values,
|
||||
llrs=llrs,
|
||||
strength=strength,
|
||||
regime_confidence_mult=regime_confidence_mult,
|
||||
contradiction=contradiction,
|
||||
data_quality=data_quality,
|
||||
)
|
||||
|
||||
assert 0.0 <= confidence <= 1.0, (
|
||||
f"Confidence {confidence} out of bounds [0, 1]"
|
||||
)
|
||||
assert confidence < 0.10, (
|
||||
f"Confidence {confidence} not suppressed when {dimension} is weak"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 17: Data quality score is bounded in [0, 1]
|
||||
# Feature: math-core-v3-engine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from datetime import datetime, timezone as _tz
|
||||
|
||||
# Strategies for Property 17
|
||||
extraction_failure_rates = st.floats(
|
||||
min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False
|
||||
)
|
||||
extraction_confs = st.floats(
|
||||
min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False
|
||||
)
|
||||
impacts = st.floats(
|
||||
min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False
|
||||
)
|
||||
age_newest_hours_strat = st.floats(
|
||||
min_value=0.0, max_value=10000.0, allow_nan=False, allow_infinity=False
|
||||
)
|
||||
n_source_types_strat = st.integers(min_value=0, max_value=20)
|
||||
|
||||
|
||||
@st.composite
|
||||
def evidence_unit_list_strategy(draw: st.DrawFn) -> list[EvidenceUnit]:
|
||||
"""Generate a list of EvidenceUnit-like objects for data quality testing."""
|
||||
n = draw(st.integers(min_value=0, max_value=15))
|
||||
units = []
|
||||
_ts = datetime(2024, 1, 1, tzinfo=_tz.utc)
|
||||
for i in range(n):
|
||||
ext_conf = draw(extraction_confs)
|
||||
impact = draw(impacts)
|
||||
unit = EvidenceUnit(
|
||||
symbol="TEST",
|
||||
layer="company",
|
||||
event_type="earnings",
|
||||
source_id=f"src_{i}",
|
||||
source_group="news",
|
||||
timestamp=_ts,
|
||||
horizon="7d",
|
||||
direction=1,
|
||||
sentiment_strength=0.5,
|
||||
impact=impact,
|
||||
extraction_conf=ext_conf,
|
||||
source_cred=0.7,
|
||||
novelty=0.5,
|
||||
event_base_rate=0.25,
|
||||
cluster_id=f"cluster_{i}",
|
||||
)
|
||||
units.append(unit)
|
||||
return units
|
||||
|
||||
|
||||
@given(
|
||||
units=evidence_unit_list_strategy(),
|
||||
extraction_failure_rate=extraction_failure_rates,
|
||||
age_newest_hours=age_newest_hours_strat,
|
||||
n_source_types=n_source_types_strat,
|
||||
)
|
||||
@settings(max_examples=100)
|
||||
def test_property_17_data_quality_bounded_zero_one(
|
||||
units: list[EvidenceUnit],
|
||||
extraction_failure_rate: float,
|
||||
age_newest_hours: float,
|
||||
n_source_types: int,
|
||||
) -> None:
|
||||
"""**Validates: Requirements 17.6, 21.6**
|
||||
|
||||
Property 17: For any valid inputs (extraction_failure_rate in [0,1],
|
||||
extraction_conf_i in [0,1], impact_i in [0,1], age_newest_hours >= 0,
|
||||
N_valid >= 0, N_source_types >= 0), the computed data_quality_score
|
||||
SHALL be in [0, 1].
|
||||
"""
|
||||
data_quality = compute_v3_data_quality(
|
||||
units=units,
|
||||
extraction_failure_rate=extraction_failure_rate,
|
||||
age_newest_hours=age_newest_hours,
|
||||
n_source_types=n_source_types,
|
||||
)
|
||||
|
||||
assert 0.0 <= data_quality <= 1.0, (
|
||||
f"Data quality score {data_quality} out of bounds [0, 1] "
|
||||
f"for extraction_failure_rate={extraction_failure_rate}, "
|
||||
f"age_newest_hours={age_newest_hours}, n_source_types={n_source_types}, "
|
||||
f"n_units={len(units)}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 12: Posterior state JSON round-trip
|
||||
# Feature: math-core-v3-engine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
import json
|
||||
from dataclasses import asdict
|
||||
|
||||
|
||||
@given(
|
||||
llr_values=st.lists(
|
||||
st.floats(min_value=-2.5, max_value=2.5, allow_nan=False, allow_infinity=False),
|
||||
min_size=1, max_size=10,
|
||||
),
|
||||
gamma=gammas,
|
||||
p_prior=priors,
|
||||
)
|
||||
@settings(max_examples=100)
|
||||
def test_property_12_posterior_json_round_trip(
|
||||
llr_values: list[float],
|
||||
gamma: float,
|
||||
p_prior: float,
|
||||
) -> None:
|
||||
"""**Validates: Requirements 20.1, 21.10**
|
||||
|
||||
Property 12: Serialize V3Posterior to JSON via dataclasses.asdict() and
|
||||
json.dumps(), then deserialize via json.loads() and V3Posterior(**data).
|
||||
All numeric fields SHALL be equivalent within 1e-10, and string fields
|
||||
SHALL be exactly equal.
|
||||
"""
|
||||
# Build clusters and regime, compute posterior
|
||||
clusters = [_make_cluster(llr) for llr in llr_values]
|
||||
regime = _make_regime(gamma)
|
||||
posterior = compute_v3_posterior(clusters, regime, p_prior=p_prior)
|
||||
|
||||
# Serialize to JSON
|
||||
serialized = json.dumps(asdict(posterior))
|
||||
|
||||
# Deserialize back to V3Posterior
|
||||
restored = V3Posterior(**json.loads(serialized))
|
||||
|
||||
# Verify numeric fields within 1e-10
|
||||
assert math.isclose(posterior.p_up, restored.p_up, abs_tol=1e-10), (
|
||||
f"p_up mismatch: {posterior.p_up} vs {restored.p_up}"
|
||||
)
|
||||
assert math.isclose(posterior.p_down, restored.p_down, abs_tol=1e-10), (
|
||||
f"p_down mismatch: {posterior.p_down} vs {restored.p_down}"
|
||||
)
|
||||
assert math.isclose(posterior.log_odds, restored.log_odds, abs_tol=1e-10), (
|
||||
f"log_odds mismatch: {posterior.log_odds} vs {restored.log_odds}"
|
||||
)
|
||||
assert math.isclose(posterior.strength, restored.strength, abs_tol=1e-10), (
|
||||
f"strength mismatch: {posterior.strength} vs {restored.strength}"
|
||||
)
|
||||
assert math.isclose(posterior.n_eff_total, restored.n_eff_total, abs_tol=1e-10), (
|
||||
f"n_eff_total mismatch: {posterior.n_eff_total} vs {restored.n_eff_total}"
|
||||
)
|
||||
|
||||
# Verify string fields are exactly equal
|
||||
assert posterior.direction == restored.direction, (
|
||||
f"direction mismatch: {posterior.direction!r} vs {restored.direction!r}"
|
||||
)
|
||||
assert posterior.regime == restored.regime, (
|
||||
f"regime mismatch: {posterior.regime!r} vs {restored.regime!r}"
|
||||
)
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -0,0 +1,417 @@
|
||||
"""Property-based tests for EvidenceUnit normalization, calibrated reliability, and LLR.
|
||||
|
||||
Feature: math-core-v3-engine
|
||||
|
||||
Uses Hypothesis to validate correctness properties of the v3 calibrated
|
||||
evidence engine foundation: EvidenceUnit normalization preserves field ranges,
|
||||
reliability q_i is bounded, p_correct is bounded, LLR sign matches direction,
|
||||
and neutral signals produce zero LLR.
|
||||
|
||||
Validates: Requirements 1.1–1.8, 2.1–2.9, 3.1–3.6, 21.1–21.3
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from services.aggregation.scoring import (
|
||||
EvidenceUnit,
|
||||
ReliabilityComponents,
|
||||
SourceStats,
|
||||
_clamp,
|
||||
compute_llr,
|
||||
compute_v3_reliability,
|
||||
normalize_company_signal,
|
||||
normalize_competitive_signal,
|
||||
normalize_macro_signal,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hypothesis strategies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
unit_floats = st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False)
|
||||
directions = st.sampled_from([-1, 0, 1])
|
||||
horizons = st.sampled_from(["intraday", "1d", "7d", "30d", "90d"])
|
||||
positive_floats = st.floats(min_value=0.0, max_value=10000.0, allow_nan=False, allow_infinity=False)
|
||||
non_negative_ints = st.integers(min_value=0, max_value=100)
|
||||
|
||||
|
||||
def _evidence_unit_strategy() -> st.SearchStrategy[EvidenceUnit]:
|
||||
"""Generate valid EvidenceUnit instances with fields in valid ranges."""
|
||||
return st.builds(
|
||||
EvidenceUnit,
|
||||
symbol=st.just("AAPL"),
|
||||
layer=st.sampled_from(["company", "macro", "competitive"]),
|
||||
event_type=st.sampled_from(["earnings", "product_launch", "regulatory", "unknown"]),
|
||||
source_id=st.just("src-001"),
|
||||
source_group=st.sampled_from(["company", "macro", "competitive"]),
|
||||
timestamp=st.just(datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)),
|
||||
horizon=horizons,
|
||||
direction=directions,
|
||||
sentiment_strength=unit_floats,
|
||||
impact=unit_floats,
|
||||
extraction_conf=unit_floats,
|
||||
source_cred=unit_floats,
|
||||
novelty=unit_floats,
|
||||
event_base_rate=st.floats(min_value=0.01, max_value=1.0, allow_nan=False, allow_infinity=False),
|
||||
cluster_id=st.just("cluster-001"),
|
||||
)
|
||||
|
||||
|
||||
def _source_stats_strategy() -> st.SearchStrategy[SourceStats]:
|
||||
"""Generate SourceStats with valid counts."""
|
||||
return st.builds(
|
||||
SourceStats,
|
||||
source_id=st.just("src-001"),
|
||||
hits=non_negative_ints,
|
||||
misses=non_negative_ints,
|
||||
alpha_0=st.floats(min_value=1.0, max_value=10.0, allow_nan=False, allow_infinity=False),
|
||||
beta_0=st.floats(min_value=1.0, max_value=10.0, allow_nan=False, allow_infinity=False),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 1: Reliability q_i is bounded in [0, 1]
|
||||
# Feature: math-core-v3-engine, Property 1: Reliability q_i is bounded in [0, 1]
|
||||
# Validates: Requirements 2.1–2.9
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@settings(max_examples=100)
|
||||
@given(
|
||||
extraction_conf=unit_floats,
|
||||
source_cred=unit_floats,
|
||||
novelty=unit_floats,
|
||||
impact=unit_floats,
|
||||
sentiment_strength=unit_floats,
|
||||
event_base_rate=st.floats(min_value=0.01, max_value=1.0, allow_nan=False, allow_infinity=False),
|
||||
age_hours=st.floats(min_value=0.0, max_value=5000.0, allow_nan=False, allow_infinity=False),
|
||||
duplicate_count_before=non_negative_ints,
|
||||
hits=non_negative_ints,
|
||||
misses=non_negative_ints,
|
||||
)
|
||||
def test_property_1_reliability_qi_bounded(
|
||||
extraction_conf: float,
|
||||
source_cred: float,
|
||||
novelty: float,
|
||||
impact: float,
|
||||
sentiment_strength: float,
|
||||
event_base_rate: float,
|
||||
age_hours: float,
|
||||
duplicate_count_before: int,
|
||||
hits: int,
|
||||
misses: int,
|
||||
) -> None:
|
||||
"""Property 1: Reliability q_i is bounded in [0, 1].
|
||||
|
||||
For any valid EvidenceUnit with extraction_conf in [0,1], source_cred in [0,1],
|
||||
novelty in [0,1], any non-negative age_hours, and any non-negative
|
||||
duplicate_count_before, the computed q_i SHALL be in [0.0, 1.0].
|
||||
|
||||
**Validates: Requirements 2.1–2.9**
|
||||
"""
|
||||
reference_time = datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||
unit_time = reference_time - timedelta(hours=age_hours)
|
||||
|
||||
unit = EvidenceUnit(
|
||||
symbol="AAPL",
|
||||
layer="company",
|
||||
event_type="earnings",
|
||||
source_id="src-001",
|
||||
source_group="company",
|
||||
timestamp=unit_time,
|
||||
horizon="7d",
|
||||
direction=1,
|
||||
sentiment_strength=sentiment_strength,
|
||||
impact=impact,
|
||||
extraction_conf=extraction_conf,
|
||||
source_cred=source_cred,
|
||||
novelty=novelty,
|
||||
event_base_rate=event_base_rate,
|
||||
cluster_id="cluster-001",
|
||||
)
|
||||
|
||||
stats = SourceStats(source_id="src-001", hits=hits, misses=misses)
|
||||
result = compute_v3_reliability(unit, stats, duplicate_count_before, reference_time)
|
||||
|
||||
assert 0.0 <= result.q_i <= 1.0, f"q_i={result.q_i} out of [0, 1]"
|
||||
assert 0.0 <= result.q_ext <= 1.0, f"q_ext={result.q_ext} out of [0, 1]"
|
||||
assert 0.0 <= result.q_source <= 1.0, f"q_source={result.q_source} out of [0, 1]"
|
||||
assert 0.0 <= result.q_recency <= 1.0, f"q_recency={result.q_recency} out of [0, 1]"
|
||||
assert 0.0 <= result.q_uniqueness <= 1.0, f"q_uniqueness={result.q_uniqueness} out of [0, 1]"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 2: p_correct is bounded in [0.501, 0.85]
|
||||
# Feature: math-core-v3-engine, Property 2: p_correct is bounded in [0.501, 0.85]
|
||||
# Validates: Requirements 3.1–3.2
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@settings(max_examples=100)
|
||||
@given(
|
||||
q_i=unit_floats,
|
||||
impact=unit_floats,
|
||||
sentiment_strength=unit_floats,
|
||||
)
|
||||
def test_property_2_p_correct_bounded(
|
||||
q_i: float,
|
||||
impact: float,
|
||||
sentiment_strength: float,
|
||||
) -> None:
|
||||
"""Property 2: p_correct is bounded in [0.501, 0.85].
|
||||
|
||||
For any valid q_i in [0,1], impact in [0,1], and sentiment_strength in [0,1],
|
||||
the computed p_correct SHALL be in [0.501, 0.85].
|
||||
|
||||
**Validates: Requirements 3.1–3.2**
|
||||
"""
|
||||
# Replicate the p_correct computation from compute_llr
|
||||
p_correct = _clamp(
|
||||
0.50 + 0.35 * q_i * impact * sentiment_strength,
|
||||
0.501,
|
||||
0.85,
|
||||
)
|
||||
|
||||
assert 0.501 <= p_correct <= 0.85, f"p_correct={p_correct} out of [0.501, 0.85]"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 3: LLR sign matches direction and magnitude is bounded
|
||||
# Feature: math-core-v3-engine, Property 3: LLR sign matches direction and magnitude is bounded
|
||||
# Validates: Requirements 3.3–3.4, 3.6
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@settings(max_examples=100)
|
||||
@given(
|
||||
direction=st.sampled_from([-1, 1]),
|
||||
q_i=unit_floats,
|
||||
impact=unit_floats,
|
||||
sentiment_strength=unit_floats,
|
||||
)
|
||||
def test_property_3_llr_sign_matches_direction(
|
||||
direction: int,
|
||||
q_i: float,
|
||||
impact: float,
|
||||
sentiment_strength: float,
|
||||
) -> None:
|
||||
"""Property 3: LLR sign matches direction and magnitude is bounded.
|
||||
|
||||
For any valid signal with direction in {-1, +1}, the LLR SHALL have the same
|
||||
sign as direction, with abs magnitude in [~0.004, ~1.735].
|
||||
|
||||
**Validates: Requirements 3.3–3.4, 3.6**
|
||||
"""
|
||||
unit = EvidenceUnit(
|
||||
symbol="AAPL",
|
||||
layer="company",
|
||||
event_type="earnings",
|
||||
source_id="src-001",
|
||||
source_group="company",
|
||||
timestamp=datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc),
|
||||
horizon="7d",
|
||||
direction=direction,
|
||||
sentiment_strength=sentiment_strength,
|
||||
impact=impact,
|
||||
extraction_conf=0.8,
|
||||
source_cred=0.8,
|
||||
novelty=0.8,
|
||||
event_base_rate=0.25,
|
||||
cluster_id="cluster-001",
|
||||
)
|
||||
|
||||
llr = compute_llr(unit, q_i)
|
||||
|
||||
# LLR sign must match direction
|
||||
if direction == 1:
|
||||
assert llr > 0.0, f"LLR={llr} should be positive for direction=+1"
|
||||
else:
|
||||
assert llr < 0.0, f"LLR={llr} should be negative for direction=-1"
|
||||
|
||||
# Magnitude bounds: ln(0.501/0.499) ≈ 0.004, ln(0.85/0.15) ≈ 1.735
|
||||
min_magnitude = math.log(0.501 / 0.499) # ~0.004
|
||||
max_magnitude = math.log(0.85 / 0.15) # ~1.735
|
||||
|
||||
assert abs(llr) >= min_magnitude - 1e-9, f"|LLR|={abs(llr)} below min ~0.004"
|
||||
assert abs(llr) <= max_magnitude + 1e-9, f"|LLR|={abs(llr)} above max ~1.735"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 4: Neutral signals produce zero LLR
|
||||
# Feature: math-core-v3-engine, Property 4: Neutral signals produce zero LLR
|
||||
# Validates: Requirements 3.5
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@settings(max_examples=100)
|
||||
@given(
|
||||
q_i=unit_floats,
|
||||
impact=unit_floats,
|
||||
sentiment_strength=unit_floats,
|
||||
extraction_conf=unit_floats,
|
||||
source_cred=unit_floats,
|
||||
novelty=unit_floats,
|
||||
)
|
||||
def test_property_4_neutral_produces_zero_llr(
|
||||
q_i: float,
|
||||
impact: float,
|
||||
sentiment_strength: float,
|
||||
extraction_conf: float,
|
||||
source_cred: float,
|
||||
novelty: float,
|
||||
) -> None:
|
||||
"""Property 4: Neutral signals produce zero LLR.
|
||||
|
||||
For any EvidenceUnit with direction=0, LLR SHALL be exactly 0.0.
|
||||
|
||||
**Validates: Requirements 3.5**
|
||||
"""
|
||||
unit = EvidenceUnit(
|
||||
symbol="AAPL",
|
||||
layer="company",
|
||||
event_type="earnings",
|
||||
source_id="src-001",
|
||||
source_group="company",
|
||||
timestamp=datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc),
|
||||
horizon="7d",
|
||||
direction=0,
|
||||
sentiment_strength=sentiment_strength,
|
||||
impact=impact,
|
||||
extraction_conf=extraction_conf,
|
||||
source_cred=source_cred,
|
||||
novelty=novelty,
|
||||
event_base_rate=0.25,
|
||||
cluster_id="cluster-001",
|
||||
)
|
||||
|
||||
llr = compute_llr(unit, q_i)
|
||||
assert llr == 0.0, f"LLR={llr} should be exactly 0.0 for neutral direction"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 21: EvidenceUnit normalization preserves field ranges
|
||||
# Feature: math-core-v3-engine, Property 21: EvidenceUnit normalization preserves field ranges
|
||||
# Validates: Requirements 1.1–1.8, 21.1–21.3
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _company_signal_strategy() -> st.SearchStrategy[dict]:
|
||||
"""Generate raw company signal dicts with arbitrary values."""
|
||||
return st.fixed_dictionaries({
|
||||
"symbol": st.just("TSLA"),
|
||||
"timestamp": st.just(datetime(2024, 6, 15, 10, 0, 0, tzinfo=timezone.utc)),
|
||||
"source_id": st.just("doc-123"),
|
||||
"event_type": st.sampled_from(["earnings", "product_launch", "regulatory", "unknown", None]),
|
||||
"source_group": st.sampled_from(["company", "reuters", None]),
|
||||
"horizon": st.sampled_from(["intraday", "1d", "7d", "30d", "90d", "invalid", None]),
|
||||
"sentiment": st.sampled_from(["positive", "negative", "neutral", "mixed", "bullish", "bearish", None]),
|
||||
"sentiment_strength": st.one_of(unit_floats, st.just(None)),
|
||||
"impact": st.one_of(unit_floats, st.just(None)),
|
||||
"extraction_conf": st.one_of(unit_floats, st.just(None)),
|
||||
"source_cred": st.one_of(unit_floats, st.just(None)),
|
||||
"novelty": st.one_of(unit_floats, st.just(None)),
|
||||
})
|
||||
|
||||
|
||||
def _macro_signal_strategy() -> st.SearchStrategy[dict]:
|
||||
"""Generate raw macro signal dicts with arbitrary values."""
|
||||
return st.fixed_dictionaries({
|
||||
"symbol": st.just("AAPL"),
|
||||
"timestamp": st.just(datetime(2024, 6, 15, 10, 0, 0, tzinfo=timezone.utc)),
|
||||
"source_id": st.just("event-456"),
|
||||
"event_type": st.sampled_from(["earnings", "regulatory", "market_data", None]),
|
||||
"estimated_duration": st.sampled_from(["short_term", "medium_term", "long_term", "unknown", None]),
|
||||
"impact_direction": st.sampled_from(["positive", "negative", "neutral", "bullish", "bearish", None]),
|
||||
"macro_impact_score": st.one_of(unit_floats, st.just(None)),
|
||||
"event_confidence": st.one_of(unit_floats, st.just(None)),
|
||||
"novelty": st.one_of(unit_floats, st.just(None)),
|
||||
"sentiment_strength": st.one_of(unit_floats, st.just(None)),
|
||||
})
|
||||
|
||||
|
||||
def _competitive_signal_strategy() -> st.SearchStrategy[dict]:
|
||||
"""Generate raw competitive signal dicts with arbitrary values."""
|
||||
return st.fixed_dictionaries({
|
||||
"symbol": st.just("MSFT"),
|
||||
"timestamp": st.just(datetime(2024, 6, 15, 10, 0, 0, tzinfo=timezone.utc)),
|
||||
"source_id": st.just("comp-789"),
|
||||
"event_type": st.sampled_from(["earnings", "product_launch", None]),
|
||||
"signal_direction": st.sampled_from(["bullish", "bearish", "neutral", None]),
|
||||
"signal_strength": st.one_of(unit_floats, st.just(None)),
|
||||
"relationship_strength": st.one_of(unit_floats, st.just(None)),
|
||||
"pattern_confidence": st.one_of(unit_floats, st.just(None)),
|
||||
"time_horizon": st.sampled_from(["intraday", "1d", "7d", "30d", "90d", "short_term", None]),
|
||||
"novelty": st.one_of(unit_floats, st.just(None)),
|
||||
"sentiment_strength": st.one_of(unit_floats, st.just(None)),
|
||||
})
|
||||
|
||||
|
||||
@settings(max_examples=100)
|
||||
@given(signal=_company_signal_strategy())
|
||||
def test_property_21_company_normalization_preserves_ranges(signal: dict) -> None:
|
||||
"""Property 21: EvidenceUnit normalization preserves field ranges (company).
|
||||
|
||||
For any valid company signal input, normalized EvidenceUnit SHALL have
|
||||
direction in {-1,0,+1}, all float fields in [0,1], event_base_rate in (0,1].
|
||||
|
||||
**Validates: Requirements 1.1–1.8, 21.1–21.3**
|
||||
"""
|
||||
unit = normalize_company_signal(signal)
|
||||
assert unit is not None, "Expected valid EvidenceUnit from company signal"
|
||||
|
||||
_assert_evidence_unit_ranges(unit)
|
||||
|
||||
|
||||
@settings(max_examples=100)
|
||||
@given(signal=_macro_signal_strategy())
|
||||
def test_property_21_macro_normalization_preserves_ranges(signal: dict) -> None:
|
||||
"""Property 21: EvidenceUnit normalization preserves field ranges (macro).
|
||||
|
||||
For any valid macro signal input, normalized EvidenceUnit SHALL have
|
||||
direction in {-1,0,+1}, all float fields in [0,1], event_base_rate in (0,1].
|
||||
|
||||
**Validates: Requirements 1.1–1.8, 21.1–21.3**
|
||||
"""
|
||||
unit = normalize_macro_signal(signal)
|
||||
assert unit is not None, "Expected valid EvidenceUnit from macro signal"
|
||||
|
||||
_assert_evidence_unit_ranges(unit)
|
||||
|
||||
|
||||
@settings(max_examples=100)
|
||||
@given(signal=_competitive_signal_strategy())
|
||||
def test_property_21_competitive_normalization_preserves_ranges(signal: dict) -> None:
|
||||
"""Property 21: EvidenceUnit normalization preserves field ranges (competitive).
|
||||
|
||||
For any valid competitive signal input, normalized EvidenceUnit SHALL have
|
||||
direction in {-1,0,+1}, all float fields in [0,1], event_base_rate in (0,1].
|
||||
|
||||
**Validates: Requirements 1.1–1.8, 21.1–21.3**
|
||||
"""
|
||||
unit = normalize_competitive_signal(signal)
|
||||
assert unit is not None, "Expected valid EvidenceUnit from competitive signal"
|
||||
|
||||
_assert_evidence_unit_ranges(unit)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared assertion helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _assert_evidence_unit_ranges(unit: EvidenceUnit) -> None:
|
||||
"""Assert all EvidenceUnit fields are within their valid ranges."""
|
||||
assert unit.direction in (-1, 0, 1), f"direction={unit.direction} not in {{-1, 0, +1}}"
|
||||
assert 0.0 <= unit.sentiment_strength <= 1.0, f"sentiment_strength={unit.sentiment_strength} out of [0, 1]"
|
||||
assert 0.0 <= unit.impact <= 1.0, f"impact={unit.impact} out of [0, 1]"
|
||||
assert 0.0 <= unit.extraction_conf <= 1.0, f"extraction_conf={unit.extraction_conf} out of [0, 1]"
|
||||
assert 0.0 <= unit.source_cred <= 1.0, f"source_cred={unit.source_cred} out of [0, 1]"
|
||||
assert 0.0 <= unit.novelty <= 1.0, f"novelty={unit.novelty} out of [0, 1]"
|
||||
assert 0.0 < unit.event_base_rate <= 1.0, f"event_base_rate={unit.event_base_rate} out of (0, 1]"
|
||||
assert unit.horizon in ("intraday", "1d", "7d", "30d", "90d"), f"horizon={unit.horizon} invalid"
|
||||
assert unit.layer in ("company", "macro", "competitive"), f"layer={unit.layer} invalid"
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Unit tests for v3 correlation-aware clustering.
|
||||
|
||||
Tests for compute_n_eff, compute_cluster_llr, and cluster_evidence functions.
|
||||
|
||||
Requirements validated: 4.1–4.5
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from services.aggregation.scoring import EvidenceUnit
|
||||
from services.aggregation.worker import (
|
||||
cluster_evidence,
|
||||
compute_cluster_llr,
|
||||
compute_n_eff,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_unit(cluster_id: str = "cluster_a", symbol: str = "AAPL") -> EvidenceUnit:
|
||||
"""Create a minimal EvidenceUnit for testing."""
|
||||
return EvidenceUnit(
|
||||
symbol=symbol,
|
||||
layer="company",
|
||||
event_type="earnings",
|
||||
source_id="doc_1",
|
||||
source_group="reuters",
|
||||
timestamp=datetime(2024, 1, 15, 12, 0, tzinfo=timezone.utc),
|
||||
horizon="7d",
|
||||
direction=1,
|
||||
sentiment_strength=0.8,
|
||||
impact=0.7,
|
||||
extraction_conf=0.9,
|
||||
source_cred=0.85,
|
||||
novelty=0.6,
|
||||
event_base_rate=0.25,
|
||||
cluster_id=cluster_id,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: 3 identical articles from same source → n_eff < 3
|
||||
# Requirement: 4.2, 4.3
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNEffIdenticalArticles:
|
||||
"""3 identical articles from same wire (default rho=0.80) → n_eff < 3."""
|
||||
|
||||
def test_n_eff_less_than_cluster_size(self):
|
||||
llrs = [1.0, 1.0, 1.0]
|
||||
# Default correlations: rho=0.80 for all pairs (same wire/source)
|
||||
n_eff = compute_n_eff(llrs)
|
||||
|
||||
# Formula: (3)² / (3 + 2×3×0.80×1×1) = 9 / (3 + 4.8) = 9/7.8 ≈ 1.154
|
||||
expected = 9.0 / 7.8
|
||||
assert n_eff < 3.0
|
||||
assert n_eff == pytest.approx(expected, rel=1e-6)
|
||||
|
||||
def test_n_eff_discounts_correlated_signals(self):
|
||||
"""Higher correlation → lower n_eff."""
|
||||
llrs = [1.0, 1.0, 1.0]
|
||||
n_eff_correlated = compute_n_eff(llrs) # default rho=0.80
|
||||
|
||||
# Independent: rho=0.0
|
||||
identity = [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
|
||||
n_eff_independent = compute_n_eff(llrs, correlations=identity)
|
||||
|
||||
assert n_eff_correlated < n_eff_independent
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: 3 independent articles → n_eff ≈ 3
|
||||
# Requirement: 4.2, 4.3
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNEffIndependentArticles:
|
||||
"""3 independent articles (rho=0.0) → n_eff = 3.0."""
|
||||
|
||||
def test_n_eff_equals_cluster_size(self):
|
||||
llrs = [1.0, 1.0, 1.0]
|
||||
# Zero off-diagonal correlations
|
||||
correlations = [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
|
||||
n_eff = compute_n_eff(llrs, correlations=correlations)
|
||||
|
||||
# n_eff = (3)² / (3 + 0) = 3.0
|
||||
assert n_eff == pytest.approx(3.0, rel=1e-6)
|
||||
|
||||
def test_n_eff_with_varying_magnitudes(self):
|
||||
"""Independent signals with different magnitudes still give n <= cluster size."""
|
||||
llrs = [0.5, 1.0, 2.0]
|
||||
correlations = [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
|
||||
n_eff = compute_n_eff(llrs, correlations=correlations)
|
||||
|
||||
# With zero correlations, n_eff = (sum |w|)^2 / sum(w^2)
|
||||
# = (0.5+1.0+2.0)^2 / (0.25+1.0+4.0) = 12.25 / 5.25 ≈ 2.333
|
||||
expected = (3.5**2) / (0.25 + 1.0 + 4.0)
|
||||
assert n_eff == pytest.approx(expected, rel=1e-6)
|
||||
assert n_eff <= 3.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: Single signal cluster → n_eff = 1.0
|
||||
# Requirement: 4.2
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNEffSingleSignal:
|
||||
"""Single signal in a cluster → n_eff = 1.0."""
|
||||
|
||||
def test_single_signal(self):
|
||||
llrs = [0.5]
|
||||
n_eff = compute_n_eff(llrs)
|
||||
assert n_eff == 1.0
|
||||
|
||||
def test_empty_cluster(self):
|
||||
llrs: list[float] = []
|
||||
n_eff = compute_n_eff(llrs)
|
||||
assert n_eff == 1.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: Cluster LLR clamp at ±2.5
|
||||
# Requirement: 4.4, 4.5
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestClusterLLRClamp:
|
||||
"""Cluster LLR is clamped to [-2.5, 2.5]."""
|
||||
|
||||
def test_positive_clamp(self):
|
||||
"""Very large positive LLRs with high n_eff → clamped to 2.5."""
|
||||
llrs = [2.0, 2.0, 2.0, 2.0, 2.0]
|
||||
# Use independent correlations for max n_eff
|
||||
correlations = [
|
||||
[1, 0, 0, 0, 0],
|
||||
[0, 1, 0, 0, 0],
|
||||
[0, 0, 1, 0, 0],
|
||||
[0, 0, 0, 1, 0],
|
||||
[0, 0, 0, 0, 1],
|
||||
]
|
||||
n_eff = compute_n_eff(llrs, correlations=correlations)
|
||||
cluster_llr = compute_cluster_llr(llrs, n_eff)
|
||||
|
||||
# weighted_mean = 2.0, sqrt(5) ≈ 2.236, raw = 4.47 → clamp to 2.5
|
||||
assert cluster_llr == pytest.approx(2.5, rel=1e-6)
|
||||
|
||||
def test_negative_clamp(self):
|
||||
"""Very negative LLRs with high n_eff → clamped to -2.5."""
|
||||
llrs = [-2.0, -2.0, -2.0, -2.0, -2.0]
|
||||
correlations = [
|
||||
[1, 0, 0, 0, 0],
|
||||
[0, 1, 0, 0, 0],
|
||||
[0, 0, 1, 0, 0],
|
||||
[0, 0, 0, 1, 0],
|
||||
[0, 0, 0, 0, 1],
|
||||
]
|
||||
n_eff = compute_n_eff(llrs, correlations=correlations)
|
||||
cluster_llr = compute_cluster_llr(llrs, n_eff)
|
||||
|
||||
assert cluster_llr == pytest.approx(-2.5, rel=1e-6)
|
||||
|
||||
def test_within_bounds_no_clamp(self):
|
||||
"""Small LLRs with low n_eff → no clamping needed."""
|
||||
llrs = [0.3, 0.4]
|
||||
n_eff = compute_n_eff(llrs)
|
||||
cluster_llr = compute_cluster_llr(llrs, n_eff)
|
||||
|
||||
assert -2.5 <= cluster_llr <= 2.5
|
||||
# Should NOT be at the clamp boundary
|
||||
assert abs(cluster_llr) < 2.5
|
||||
|
||||
def test_single_signal_clamp(self):
|
||||
"""Single signal beyond clamp → clamped."""
|
||||
llrs = [3.0]
|
||||
cluster_llr = compute_cluster_llr(llrs, n_eff=1.0)
|
||||
assert cluster_llr == pytest.approx(2.5, rel=1e-6)
|
||||
|
||||
def test_single_signal_negative_clamp(self):
|
||||
"""Single negative signal beyond clamp → clamped to -2.5."""
|
||||
llrs = [-3.0]
|
||||
cluster_llr = compute_cluster_llr(llrs, n_eff=1.0)
|
||||
assert cluster_llr == pytest.approx(-2.5, rel=1e-6)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: All-zero LLRs → cluster_llr = 0.0
|
||||
# Requirement: 4.4
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestClusterLLRZero:
|
||||
"""All-zero LLRs produce zero cluster LLR."""
|
||||
|
||||
def test_all_zeros(self):
|
||||
llrs = [0.0, 0.0, 0.0]
|
||||
n_eff = compute_n_eff(llrs)
|
||||
cluster_llr = compute_cluster_llr(llrs, n_eff)
|
||||
assert cluster_llr == 0.0
|
||||
|
||||
def test_empty_llrs(self):
|
||||
"""Empty LLR list → 0.0."""
|
||||
cluster_llr = compute_cluster_llr([], n_eff=1.0)
|
||||
assert cluster_llr == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: Grouping by correct key dimensions (cluster_id)
|
||||
# Requirement: 4.1
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestClusterEvidence:
|
||||
"""cluster_evidence groups EvidenceUnits by cluster_id."""
|
||||
|
||||
def test_grouping_by_cluster_id(self):
|
||||
"""Units with same cluster_id are grouped together."""
|
||||
unit_a1 = _make_unit(cluster_id="cluster_a")
|
||||
unit_a2 = _make_unit(cluster_id="cluster_a")
|
||||
unit_b1 = _make_unit(cluster_id="cluster_b")
|
||||
|
||||
units = [unit_a1, unit_a2, unit_b1]
|
||||
llrs = [1.0, 0.5, -0.3]
|
||||
|
||||
clusters = cluster_evidence(units, llrs)
|
||||
|
||||
assert len(clusters) == 2
|
||||
|
||||
# Find clusters by id
|
||||
cluster_map = {c.cluster_id: c for c in clusters}
|
||||
|
||||
assert "cluster_a" in cluster_map
|
||||
assert "cluster_b" in cluster_map
|
||||
|
||||
# Cluster A has 2 units
|
||||
assert len(cluster_map["cluster_a"].units) == 2
|
||||
assert cluster_map["cluster_a"].llrs == [1.0, 0.5]
|
||||
|
||||
# Cluster B has 1 unit
|
||||
assert len(cluster_map["cluster_b"].units) == 1
|
||||
assert cluster_map["cluster_b"].llrs == [-0.3]
|
||||
|
||||
def test_single_cluster(self):
|
||||
"""All units with same cluster_id → one cluster."""
|
||||
units = [_make_unit(cluster_id="only") for _ in range(4)]
|
||||
llrs = [0.1, 0.2, 0.3, 0.4]
|
||||
|
||||
clusters = cluster_evidence(units, llrs)
|
||||
|
||||
assert len(clusters) == 1
|
||||
assert clusters[0].cluster_id == "only"
|
||||
assert len(clusters[0].units) == 4
|
||||
assert clusters[0].llrs == [0.1, 0.2, 0.3, 0.4]
|
||||
|
||||
def test_each_unit_different_cluster(self):
|
||||
"""Each unit in its own cluster → N clusters."""
|
||||
units = [_make_unit(cluster_id=f"c_{i}") for i in range(5)]
|
||||
llrs = [0.1 * i for i in range(5)]
|
||||
|
||||
clusters = cluster_evidence(units, llrs)
|
||||
|
||||
assert len(clusters) == 5
|
||||
for c in clusters:
|
||||
assert len(c.units) == 1
|
||||
|
||||
def test_empty_input(self):
|
||||
"""No units → no clusters."""
|
||||
clusters = cluster_evidence([], [])
|
||||
assert clusters == []
|
||||
|
||||
def test_llrs_parallel_to_units(self):
|
||||
"""LLRs are correctly associated with their units."""
|
||||
unit_x = _make_unit(cluster_id="x")
|
||||
unit_y = _make_unit(cluster_id="y")
|
||||
unit_x2 = _make_unit(cluster_id="x")
|
||||
|
||||
units = [unit_x, unit_y, unit_x2]
|
||||
llrs = [1.5, -0.7, 2.3]
|
||||
|
||||
clusters = cluster_evidence(units, llrs)
|
||||
cluster_map = {c.cluster_id: c for c in clusters}
|
||||
|
||||
assert cluster_map["x"].llrs == [1.5, 2.3]
|
||||
assert cluster_map["y"].llrs == [-0.7]
|
||||
@@ -0,0 +1,349 @@
|
||||
"""Unit tests for v3 multiplicative confidence and data quality.
|
||||
|
||||
Tests for compute_v3_confidence, compute_v3_data_quality, and
|
||||
should_force_informational_v3 functions.
|
||||
|
||||
Requirements validated: 8.1–8.5, 17.1–17.8
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from services.aggregation.scoring import EvidenceUnit
|
||||
from services.aggregation.worker import (
|
||||
compute_v3_confidence,
|
||||
compute_v3_data_quality,
|
||||
should_force_informational_v3,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_NOW = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _make_unit(
|
||||
layer: str = "company",
|
||||
extraction_conf: float = 0.8,
|
||||
impact: float = 0.7,
|
||||
) -> EvidenceUnit:
|
||||
"""Create a minimal EvidenceUnit for testing."""
|
||||
return EvidenceUnit(
|
||||
symbol="AAPL",
|
||||
layer=layer,
|
||||
event_type="earnings",
|
||||
source_id="doc-1",
|
||||
source_group="company",
|
||||
timestamp=_NOW,
|
||||
horizon="7d",
|
||||
direction=1,
|
||||
sentiment_strength=0.8,
|
||||
impact=impact,
|
||||
extraction_conf=extraction_conf,
|
||||
source_cred=0.85,
|
||||
novelty=0.9,
|
||||
event_base_rate=0.25,
|
||||
cluster_id="test-cluster",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: Zero data quality → zero confidence
|
||||
# Requirement: 8.1, 8.5
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestZeroDataQualityConfidence:
|
||||
"""When data_quality = 0.0, confidence must be 0.0."""
|
||||
|
||||
def test_zero_data_quality_produces_zero_confidence(self):
|
||||
confidence = compute_v3_confidence(
|
||||
n_eff_total=5.0,
|
||||
q_values=[0.8, 0.7],
|
||||
llrs=[1.0, 0.5],
|
||||
strength=0.6,
|
||||
regime_confidence_mult=1.0,
|
||||
contradiction=0.0,
|
||||
data_quality=0.0,
|
||||
)
|
||||
assert confidence == 0.0
|
||||
|
||||
def test_near_zero_data_quality_suppresses_confidence(self):
|
||||
"""Very low data_quality → near-zero confidence."""
|
||||
confidence = compute_v3_confidence(
|
||||
n_eff_total=10.0,
|
||||
q_values=[0.9, 0.9],
|
||||
llrs=[1.5, 1.5],
|
||||
strength=0.8,
|
||||
regime_confidence_mult=1.0,
|
||||
contradiction=0.0,
|
||||
data_quality=0.01,
|
||||
)
|
||||
assert confidence < 0.05
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: Full contradiction (1.0) → zero confidence
|
||||
# Requirement: 8.4
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFullContradictionConfidence:
|
||||
"""When contradiction = 1.0, confidence must be 0.0."""
|
||||
|
||||
def test_full_contradiction_produces_zero_confidence(self):
|
||||
confidence = compute_v3_confidence(
|
||||
n_eff_total=10.0,
|
||||
q_values=[0.9, 0.8],
|
||||
llrs=[1.0, 1.2],
|
||||
strength=0.7,
|
||||
regime_confidence_mult=1.0,
|
||||
contradiction=1.0,
|
||||
data_quality=0.9,
|
||||
)
|
||||
assert confidence == 0.0
|
||||
|
||||
def test_high_contradiction_suppresses_confidence(self):
|
||||
"""Contradiction = 0.9 → confidence heavily suppressed."""
|
||||
conf_no_contra = compute_v3_confidence(
|
||||
n_eff_total=5.0,
|
||||
q_values=[0.8],
|
||||
llrs=[1.0],
|
||||
strength=0.6,
|
||||
regime_confidence_mult=1.0,
|
||||
contradiction=0.0,
|
||||
data_quality=0.8,
|
||||
)
|
||||
conf_high_contra = compute_v3_confidence(
|
||||
n_eff_total=5.0,
|
||||
q_values=[0.8],
|
||||
llrs=[1.0],
|
||||
strength=0.6,
|
||||
regime_confidence_mult=1.0,
|
||||
contradiction=0.9,
|
||||
data_quality=0.8,
|
||||
)
|
||||
assert conf_high_contra < conf_no_contra * 0.15
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: Low n_eff → suppressed C_evidence
|
||||
# Requirement: 8.2
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLowNEffConfidence:
|
||||
"""Low n_eff_total → C_evidence is suppressed."""
|
||||
|
||||
def test_very_low_n_eff_suppresses_c_evidence(self):
|
||||
"""n_eff_total=0.5 → C_evidence = 1 - exp(-0.1) ≈ 0.095."""
|
||||
# C_evidence = 1 - exp(-0.5 / 5.0) = 1 - exp(-0.1) ≈ 0.0952
|
||||
expected_c_evidence = 1.0 - math.exp(-0.1)
|
||||
assert expected_c_evidence == pytest.approx(0.0952, rel=1e-2)
|
||||
|
||||
confidence = compute_v3_confidence(
|
||||
n_eff_total=0.5,
|
||||
q_values=[0.8],
|
||||
llrs=[1.0],
|
||||
strength=0.8,
|
||||
regime_confidence_mult=1.0,
|
||||
contradiction=0.0,
|
||||
data_quality=0.9,
|
||||
)
|
||||
# Confidence is bounded by C_evidence ≈ 0.095
|
||||
assert confidence < 0.15
|
||||
|
||||
def test_high_n_eff_yields_higher_confidence(self):
|
||||
"""Higher n_eff → higher C_evidence → higher overall confidence."""
|
||||
conf_low = compute_v3_confidence(
|
||||
n_eff_total=1.0,
|
||||
q_values=[0.8],
|
||||
llrs=[1.0],
|
||||
strength=0.6,
|
||||
regime_confidence_mult=1.0,
|
||||
contradiction=0.0,
|
||||
data_quality=0.8,
|
||||
)
|
||||
conf_high = compute_v3_confidence(
|
||||
n_eff_total=10.0,
|
||||
q_values=[0.8],
|
||||
llrs=[1.0],
|
||||
strength=0.6,
|
||||
regime_confidence_mult=1.0,
|
||||
contradiction=0.0,
|
||||
data_quality=0.8,
|
||||
)
|
||||
assert conf_high > conf_low
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: Data quality computed from known inputs
|
||||
# Requirement: 17.1, 17.2, 17.3, 17.4, 17.5, 17.6
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDataQualityComputation:
|
||||
"""Verify data quality formula with known inputs."""
|
||||
|
||||
def test_high_quality_inputs(self):
|
||||
"""Zero failure, fresh signal, many sources → high quality."""
|
||||
units = [_make_unit() for _ in range(10)]
|
||||
dq = compute_v3_data_quality(
|
||||
units=units,
|
||||
extraction_failure_rate=0.0,
|
||||
age_newest_hours=1.0,
|
||||
n_source_types=4,
|
||||
)
|
||||
# Q_parse=1.0, Q_fresh=exp(-1/168)≈0.994, Q_coverage=1-exp(-2)≈0.865,
|
||||
# Q_diversity=min(1, log2(5)/log2(4))=1.0
|
||||
assert dq > 0.60
|
||||
|
||||
def test_high_extraction_failure_rate(self):
|
||||
"""extraction_failure_rate=0.8 → Q_parse=0.2 → low quality."""
|
||||
units = [_make_unit() for _ in range(5)]
|
||||
dq = compute_v3_data_quality(
|
||||
units=units,
|
||||
extraction_failure_rate=0.8,
|
||||
age_newest_hours=1.0,
|
||||
n_source_types=3,
|
||||
)
|
||||
# Q_parse = 0.2 → heavily suppresses data_quality
|
||||
assert dq < 0.30
|
||||
|
||||
def test_zero_sources_zero_diversity(self):
|
||||
"""No source types → Q_diversity = 0 → data_quality = 0."""
|
||||
units = [_make_unit() for _ in range(5)]
|
||||
dq = compute_v3_data_quality(
|
||||
units=units,
|
||||
extraction_failure_rate=0.0,
|
||||
age_newest_hours=1.0,
|
||||
n_source_types=0,
|
||||
)
|
||||
assert dq == 0.0
|
||||
|
||||
def test_empty_units_low_coverage(self):
|
||||
"""No valid units → Q_coverage = 1 - exp(0) = 0 → data_quality = 0."""
|
||||
dq = compute_v3_data_quality(
|
||||
units=[],
|
||||
extraction_failure_rate=0.0,
|
||||
age_newest_hours=1.0,
|
||||
n_source_types=3,
|
||||
)
|
||||
assert dq == 0.0
|
||||
|
||||
def test_stale_signal_decays_quality(self):
|
||||
"""Very old signal → Q_fresh low → suppresses data_quality."""
|
||||
units = [_make_unit() for _ in range(5)]
|
||||
dq_fresh = compute_v3_data_quality(
|
||||
units=units,
|
||||
extraction_failure_rate=0.0,
|
||||
age_newest_hours=1.0,
|
||||
n_source_types=3,
|
||||
)
|
||||
dq_stale = compute_v3_data_quality(
|
||||
units=units,
|
||||
extraction_failure_rate=0.0,
|
||||
age_newest_hours=500.0,
|
||||
n_source_types=3,
|
||||
)
|
||||
assert dq_stale < dq_fresh
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: Force informational mode
|
||||
# Requirement: 17.7, 17.8
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestForceInformational:
|
||||
"""Test should_force_informational_v3 forcing conditions."""
|
||||
|
||||
def test_data_quality_below_threshold_forces(self):
|
||||
"""data_quality < 0.50 → forces informational."""
|
||||
units = [_make_unit() for _ in range(5)]
|
||||
forced, reason = should_force_informational_v3(
|
||||
data_quality=0.49,
|
||||
units=units,
|
||||
extraction_failure_rate=0.0,
|
||||
)
|
||||
assert forced is True
|
||||
assert reason == "data_quality_below_threshold"
|
||||
|
||||
def test_data_quality_at_threshold_does_not_force(self):
|
||||
"""data_quality = 0.50 → does NOT force informational."""
|
||||
units = [_make_unit() for _ in range(5)]
|
||||
forced, reason = should_force_informational_v3(
|
||||
data_quality=0.50,
|
||||
units=units,
|
||||
extraction_failure_rate=0.0,
|
||||
)
|
||||
assert forced is False
|
||||
|
||||
def test_insufficient_evidence_forces(self):
|
||||
"""N_valid < 2 → forces informational."""
|
||||
units = [_make_unit()]
|
||||
forced, reason = should_force_informational_v3(
|
||||
data_quality=0.80,
|
||||
units=units,
|
||||
extraction_failure_rate=0.0,
|
||||
)
|
||||
assert forced is True
|
||||
assert reason == "insufficient_evidence_count"
|
||||
|
||||
def test_high_extraction_failure_forces(self):
|
||||
"""extraction_failure_rate > 0.50 → Q_parse < 0.50 → forces."""
|
||||
units = [_make_unit() for _ in range(5)]
|
||||
forced, reason = should_force_informational_v3(
|
||||
data_quality=0.80,
|
||||
units=units,
|
||||
extraction_failure_rate=0.51,
|
||||
)
|
||||
assert forced is True
|
||||
assert reason == "extraction_parse_rate_below_threshold"
|
||||
|
||||
def test_only_macro_signals_forces(self):
|
||||
"""Only macro/competitive evidence (no company) → forces."""
|
||||
units = [_make_unit(layer="macro"), _make_unit(layer="competitive")]
|
||||
forced, reason = should_force_informational_v3(
|
||||
data_quality=0.80,
|
||||
units=units,
|
||||
extraction_failure_rate=0.0,
|
||||
)
|
||||
assert forced is True
|
||||
assert reason == "macro_competitive_only_evidence"
|
||||
|
||||
def test_only_macro_with_macro_only_enabled_does_not_force(self):
|
||||
"""Only macro evidence WITH macro_only_enabled → does NOT force."""
|
||||
units = [_make_unit(layer="macro"), _make_unit(layer="macro")]
|
||||
forced, reason = should_force_informational_v3(
|
||||
data_quality=0.80,
|
||||
units=units,
|
||||
extraction_failure_rate=0.0,
|
||||
macro_only_enabled=True,
|
||||
)
|
||||
assert forced is False
|
||||
|
||||
def test_mixed_signals_does_not_force(self):
|
||||
"""Company + macro evidence → does NOT force."""
|
||||
units = [_make_unit(layer="company"), _make_unit(layer="macro")]
|
||||
forced, reason = should_force_informational_v3(
|
||||
data_quality=0.80,
|
||||
units=units,
|
||||
extraction_failure_rate=0.0,
|
||||
)
|
||||
assert forced is False
|
||||
|
||||
def test_good_inputs_do_not_force(self):
|
||||
"""All good → no forcing."""
|
||||
units = [_make_unit() for _ in range(5)]
|
||||
forced, reason = should_force_informational_v3(
|
||||
data_quality=0.80,
|
||||
units=units,
|
||||
extraction_failure_rate=0.1,
|
||||
)
|
||||
assert forced is False
|
||||
assert reason == ""
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Unit tests for v3 LLR entropy contradiction score.
|
||||
|
||||
Tests compute_v3_contradiction from services.aggregation.contradiction.
|
||||
|
||||
Requirements validated: 7.1–7.7
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from services.aggregation.contradiction import compute_v3_contradiction
|
||||
from services.aggregation.worker import EvidenceCluster
|
||||
|
||||
|
||||
def _make_cluster(cluster_llr: float) -> EvidenceCluster:
|
||||
"""Helper to create a minimal EvidenceCluster with a given cluster_llr."""
|
||||
return EvidenceCluster(
|
||||
cluster_id="test", units=[], llrs=[], n_eff=1.0, cluster_llr=cluster_llr
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. All bullish (unidirectional positive) → contradiction = 0.0
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUnidirectionalPositive:
|
||||
"""When all cluster LLRs are positive, there is no contradiction."""
|
||||
|
||||
def test_all_bullish(self):
|
||||
clusters = [_make_cluster(1.0), _make_cluster(0.5), _make_cluster(0.8)]
|
||||
assert compute_v3_contradiction(clusters) == 0.0
|
||||
|
||||
def test_single_bullish(self):
|
||||
clusters = [_make_cluster(2.0)]
|
||||
assert compute_v3_contradiction(clusters) == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. All bearish (unidirectional negative) → contradiction = 0.0
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUnidirectionalNegative:
|
||||
"""When all cluster LLRs are negative, there is no contradiction."""
|
||||
|
||||
def test_all_bearish(self):
|
||||
clusters = [_make_cluster(-1.0), _make_cluster(-0.5)]
|
||||
assert compute_v3_contradiction(clusters) == 0.0
|
||||
|
||||
def test_single_bearish(self):
|
||||
clusters = [_make_cluster(-2.5)]
|
||||
assert compute_v3_contradiction(clusters) == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Equal split → high contradiction near 1.0
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEqualSplit:
|
||||
"""Equal positive and negative evidence produces maximum entropy (H=1.0),
|
||||
modulated by the volume factor."""
|
||||
|
||||
def test_equal_split_moderate_mass(self):
|
||||
"""LLR +2.0 and -2.0: E_pos=2, E_neg=2, E_total=4.
|
||||
H_conflict = 1.0 (50/50 split).
|
||||
volume_factor = 1 - exp(-4/3) ≈ 0.7364.
|
||||
contradiction ≈ 0.7364.
|
||||
"""
|
||||
clusters = [_make_cluster(2.0), _make_cluster(-2.0)]
|
||||
result = compute_v3_contradiction(clusters)
|
||||
expected_volume = 1.0 - math.exp(-4.0 / 3.0)
|
||||
expected = 1.0 * expected_volume # H_conflict = 1.0 for equal split
|
||||
assert result == pytest.approx(expected, abs=1e-4)
|
||||
|
||||
def test_equal_split_large_mass(self):
|
||||
"""LLR +5.0 and -5.0: E_total=10, volume_factor → ~0.964.
|
||||
contradiction near 1.0.
|
||||
"""
|
||||
clusters = [_make_cluster(5.0), _make_cluster(-5.0)]
|
||||
result = compute_v3_contradiction(clusters)
|
||||
expected_volume = 1.0 - math.exp(-10.0 / 3.0)
|
||||
expected = 1.0 * expected_volume
|
||||
assert result == pytest.approx(expected, abs=1e-4)
|
||||
assert result > 0.9 # Near 1.0 due to large evidence mass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. E_total = 0 → contradiction = 0.0
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestZeroEvidence:
|
||||
"""When all cluster LLRs are zero, E_total=0, contradiction is 0."""
|
||||
|
||||
def test_all_zero_llrs(self):
|
||||
clusters = [_make_cluster(0.0), _make_cluster(0.0), _make_cluster(0.0)]
|
||||
assert compute_v3_contradiction(clusters) == 0.0
|
||||
|
||||
def test_single_zero_llr(self):
|
||||
clusters = [_make_cluster(0.0)]
|
||||
assert compute_v3_contradiction(clusters) == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Empty clusters → contradiction = 0.0
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEmptyClusters:
|
||||
"""Empty cluster list returns 0.0."""
|
||||
|
||||
def test_empty_list(self):
|
||||
assert compute_v3_contradiction([]) == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Small evidence mass → suppressed score (volume_factor effect)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSmallEvidenceMass:
|
||||
"""Small E_total leads to a small volume_factor that suppresses the score."""
|
||||
|
||||
def test_tiny_equal_split(self):
|
||||
"""LLR +0.1 and -0.1: E_total=0.2.
|
||||
H_conflict = 1.0 (equal split).
|
||||
volume_factor = 1 - exp(-0.2/3) ≈ 0.0645.
|
||||
contradiction ≈ 0.0645 (suppressed).
|
||||
"""
|
||||
clusters = [_make_cluster(0.1), _make_cluster(-0.1)]
|
||||
result = compute_v3_contradiction(clusters)
|
||||
expected_volume = 1.0 - math.exp(-0.2 / 3.0)
|
||||
expected = 1.0 * expected_volume
|
||||
assert result == pytest.approx(expected, abs=1e-4)
|
||||
# Confirm suppression: score well below 0.1
|
||||
assert result < 0.1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Large evidence mass → volume_factor approaches 1.0
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLargeEvidenceMass:
|
||||
"""Large E_total pushes volume_factor near 1.0, so score ≈ H_conflict."""
|
||||
|
||||
def test_large_equal_split(self):
|
||||
"""LLR +5.0 and -5.0: E_total=10, volume_factor ≈ 0.964.
|
||||
contradiction ≈ 0.964 (near maximum).
|
||||
"""
|
||||
clusters = [_make_cluster(5.0), _make_cluster(-5.0)]
|
||||
result = compute_v3_contradiction(clusters)
|
||||
# Volume factor should be very close to 1.0
|
||||
volume_factor = 1.0 - math.exp(-10.0 / 3.0)
|
||||
assert volume_factor > 0.95
|
||||
assert result > 0.95
|
||||
|
||||
def test_asymmetric_large_mass(self):
|
||||
"""LLR +4.0 and -1.0: E_pos=4, E_neg=1, E_total=5.
|
||||
f_pos=0.8, f_neg=0.2.
|
||||
H_conflict = -0.8×log2(0.8) - 0.2×log2(0.2) ≈ 0.7219.
|
||||
volume_factor = 1 - exp(-5/3) ≈ 0.8111.
|
||||
contradiction ≈ 0.586.
|
||||
"""
|
||||
clusters = [_make_cluster(4.0), _make_cluster(-1.0)]
|
||||
result = compute_v3_contradiction(clusters)
|
||||
f_pos = 4.0 / 5.0
|
||||
f_neg = 1.0 / 5.0
|
||||
h_conflict = -f_pos * math.log2(f_pos) - f_neg * math.log2(f_neg)
|
||||
volume_factor = 1.0 - math.exp(-5.0 / 3.0)
|
||||
expected = h_conflict * volume_factor
|
||||
assert result == pytest.approx(expected, abs=1e-4)
|
||||
@@ -0,0 +1,629 @@
|
||||
"""Unit tests for v3 EV gate, return distribution, and eligibility.
|
||||
|
||||
Tests the return distribution computation, regime-specific min_edge thresholds,
|
||||
mode escalation logic, posterior state projection, and divergence detection.
|
||||
|
||||
Requirements validated: 11.1–11.7, 12.1–12.7, 13.1–13.5
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from services.aggregation.projection import V3ProjectionState, compute_v3_projection
|
||||
from services.aggregation.regime import MarketRegime, V3RegimeClassification
|
||||
from services.recommendation.eligibility import (
|
||||
ReturnDistribution,
|
||||
V3Eligibility,
|
||||
compute_return_distribution,
|
||||
compute_v3_eligibility,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: construct a V3RegimeClassification for tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_regime(regime: MarketRegime, phi: float = 0.50) -> V3RegimeClassification:
|
||||
"""Create a minimal V3RegimeClassification for testing."""
|
||||
params = {
|
||||
MarketRegime.PANIC: (0.70, 0.70, 0.35, 2.5),
|
||||
MarketRegime.TREND_FOLLOWING: (1.10, 1.00, 0.80, 1.8),
|
||||
MarketRegime.MEAN_REVERSION: (0.90, 0.95, 0.55, 1.4),
|
||||
MarketRegime.UNCERTAINTY: (0.80, 0.85, 0.50, 2.0),
|
||||
}
|
||||
gamma, conf_mult, phi_val, atr_mult = params[regime]
|
||||
return V3RegimeClassification(
|
||||
regime=regime,
|
||||
trend_z=0.0,
|
||||
vol_ratio=1.0,
|
||||
evidence_multiplier=gamma,
|
||||
confidence_multiplier=conf_mult,
|
||||
phi_decay=phi_val,
|
||||
atr_multiplier=atr_mult,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EV positive → eligible (Req 12.1–12.7)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEVPositiveEligible:
|
||||
"""Tests that positive EV with passing quality gates → eligible=True."""
|
||||
|
||||
def test_strong_signal_trend_following(self):
|
||||
"""a_projected=2.0, conf=0.8, vol=0.25, h=7, costs=0.001, trend_following → eligible."""
|
||||
result = compute_return_distribution(
|
||||
a_projected=2.0,
|
||||
confidence=0.8,
|
||||
realized_vol_20d=0.25,
|
||||
horizon_days=7,
|
||||
costs=0.001,
|
||||
regime="trend_following",
|
||||
confidence_actual=0.8,
|
||||
contradiction=0.1,
|
||||
n_eff_total=5.0,
|
||||
data_quality=0.8,
|
||||
)
|
||||
assert isinstance(result, ReturnDistribution)
|
||||
assert result.ev_long > 0.0
|
||||
assert result.ev_long > result.min_edge
|
||||
assert result.eligible is True
|
||||
# Verify min_edge for trend_following
|
||||
assert result.min_edge == pytest.approx(0.0035, abs=1e-9)
|
||||
|
||||
def test_sigma_h_formula(self):
|
||||
"""Verify sigma_h = realized_vol * sqrt(horizon / 252)."""
|
||||
result = compute_return_distribution(
|
||||
a_projected=2.0,
|
||||
confidence=0.8,
|
||||
realized_vol_20d=0.25,
|
||||
horizon_days=7,
|
||||
costs=0.001,
|
||||
regime="trend_following",
|
||||
confidence_actual=0.8,
|
||||
contradiction=0.1,
|
||||
n_eff_total=5.0,
|
||||
data_quality=0.8,
|
||||
)
|
||||
expected_sigma_h = 0.25 * math.sqrt(7 / 252.0)
|
||||
assert result.sigma_h == pytest.approx(expected_sigma_h, rel=1e-9)
|
||||
|
||||
def test_mu_h_formula(self):
|
||||
"""Verify mu_h = tanh(A_projected / 3.0) * confidence * sigma_h."""
|
||||
result = compute_return_distribution(
|
||||
a_projected=2.0,
|
||||
confidence=0.8,
|
||||
realized_vol_20d=0.25,
|
||||
horizon_days=7,
|
||||
costs=0.001,
|
||||
regime="trend_following",
|
||||
confidence_actual=0.8,
|
||||
contradiction=0.1,
|
||||
n_eff_total=5.0,
|
||||
data_quality=0.8,
|
||||
)
|
||||
sigma_h = 0.25 * math.sqrt(7 / 252.0)
|
||||
expected_mu_h = math.tanh(2.0 / 3.0) * 0.8 * sigma_h
|
||||
assert result.mu_h == pytest.approx(expected_mu_h, rel=1e-9)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EV negative → ineligible (Req 12.3–12.5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEVNegativeIneligible:
|
||||
"""Tests that weak signals with negative or sub-threshold EV → ineligible."""
|
||||
|
||||
def test_weak_signal_high_costs(self):
|
||||
"""a_projected=0.01, conf=0.3, costs=0.01 → EV < min_edge → ineligible."""
|
||||
result = compute_return_distribution(
|
||||
a_projected=0.01,
|
||||
confidence=0.3,
|
||||
realized_vol_20d=0.25,
|
||||
horizon_days=7,
|
||||
costs=0.01,
|
||||
regime="uncertainty",
|
||||
confidence_actual=0.3,
|
||||
contradiction=0.5,
|
||||
n_eff_total=1.0,
|
||||
data_quality=0.4,
|
||||
)
|
||||
# Weak signal: tanh(0.01/3) ≈ 0.0033, * 0.3 * sigma_h is tiny
|
||||
# Costs + CVaR should dominate → EV negative
|
||||
assert result.ev_long < result.min_edge
|
||||
assert result.eligible is False
|
||||
|
||||
def test_zero_alpha_negative_ev(self):
|
||||
"""a_projected=0 → mu_h=0, then costs + CVaR push EV negative."""
|
||||
result = compute_return_distribution(
|
||||
a_projected=0.0,
|
||||
confidence=0.5,
|
||||
realized_vol_20d=0.25,
|
||||
horizon_days=7,
|
||||
costs=0.001,
|
||||
regime="trend_following",
|
||||
confidence_actual=0.7,
|
||||
contradiction=0.1,
|
||||
n_eff_total=5.0,
|
||||
data_quality=0.8,
|
||||
)
|
||||
# mu_h = tanh(0) * ... = 0. EV = 0 - costs - 0.10*CVaR < 0
|
||||
assert result.mu_h == pytest.approx(0.0, abs=1e-12)
|
||||
assert result.ev_long < 0.0
|
||||
assert result.eligible is False
|
||||
|
||||
def test_quality_gate_fails_despite_positive_ev(self):
|
||||
"""Strong EV but low n_eff → ineligible (quality gate blocks)."""
|
||||
result = compute_return_distribution(
|
||||
a_projected=3.0,
|
||||
confidence=0.9,
|
||||
realized_vol_20d=0.25,
|
||||
horizon_days=7,
|
||||
costs=0.001,
|
||||
regime="trend_following",
|
||||
confidence_actual=0.9,
|
||||
contradiction=0.1,
|
||||
n_eff_total=1.0, # Below 2.0 threshold
|
||||
data_quality=0.8,
|
||||
)
|
||||
# EV should be positive, but n_eff < 2.0 fails quality gate
|
||||
assert result.ev_long > 0.0
|
||||
assert result.eligible is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regime-specific min_edge thresholds (Req 12.4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegimeMinEdge:
|
||||
"""Tests that regime-specific min_edge values are correct."""
|
||||
|
||||
def test_panic_min_edge_strictest(self):
|
||||
"""Panic regime has min_edge = 0.0100 (strictest)."""
|
||||
result = compute_return_distribution(
|
||||
a_projected=1.0,
|
||||
confidence=0.5,
|
||||
realized_vol_20d=0.25,
|
||||
horizon_days=7,
|
||||
costs=0.001,
|
||||
regime="panic",
|
||||
confidence_actual=0.7,
|
||||
contradiction=0.2,
|
||||
n_eff_total=3.0,
|
||||
data_quality=0.8,
|
||||
)
|
||||
assert result.min_edge == pytest.approx(0.0100, abs=1e-9)
|
||||
|
||||
def test_trend_following_min_edge_most_lenient(self):
|
||||
"""Trend following has min_edge = 0.0035 (most lenient)."""
|
||||
result = compute_return_distribution(
|
||||
a_projected=1.0,
|
||||
confidence=0.5,
|
||||
realized_vol_20d=0.25,
|
||||
horizon_days=7,
|
||||
costs=0.001,
|
||||
regime="trend_following",
|
||||
confidence_actual=0.7,
|
||||
contradiction=0.2,
|
||||
n_eff_total=3.0,
|
||||
data_quality=0.8,
|
||||
)
|
||||
assert result.min_edge == pytest.approx(0.0035, abs=1e-9)
|
||||
|
||||
def test_mean_reversion_min_edge(self):
|
||||
"""Mean reversion has min_edge = 0.0050."""
|
||||
result = compute_return_distribution(
|
||||
a_projected=1.0,
|
||||
confidence=0.5,
|
||||
realized_vol_20d=0.25,
|
||||
horizon_days=7,
|
||||
costs=0.001,
|
||||
regime="mean_reversion",
|
||||
confidence_actual=0.7,
|
||||
contradiction=0.2,
|
||||
n_eff_total=3.0,
|
||||
data_quality=0.8,
|
||||
)
|
||||
assert result.min_edge == pytest.approx(0.0050, abs=1e-9)
|
||||
|
||||
def test_uncertainty_min_edge(self):
|
||||
"""Uncertainty has min_edge = 0.0075."""
|
||||
result = compute_return_distribution(
|
||||
a_projected=1.0,
|
||||
confidence=0.5,
|
||||
realized_vol_20d=0.25,
|
||||
horizon_days=7,
|
||||
costs=0.001,
|
||||
regime="uncertainty",
|
||||
confidence_actual=0.7,
|
||||
contradiction=0.2,
|
||||
n_eff_total=3.0,
|
||||
data_quality=0.8,
|
||||
)
|
||||
assert result.min_edge == pytest.approx(0.0075, abs=1e-9)
|
||||
|
||||
def test_unknown_regime_defaults_to_uncertainty(self):
|
||||
"""Unknown regime string → falls back to uncertainty min_edge."""
|
||||
result = compute_return_distribution(
|
||||
a_projected=1.0,
|
||||
confidence=0.5,
|
||||
realized_vol_20d=0.25,
|
||||
horizon_days=7,
|
||||
costs=0.001,
|
||||
regime="nonexistent_regime",
|
||||
confidence_actual=0.7,
|
||||
contradiction=0.2,
|
||||
n_eff_total=3.0,
|
||||
data_quality=0.8,
|
||||
)
|
||||
assert result.min_edge == pytest.approx(0.0075, abs=1e-9)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mode escalation: live vs paper vs informational (Req 13.1–13.5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestModeEscalation:
|
||||
"""Tests for v3 mode escalation logic."""
|
||||
|
||||
def test_live_eligible(self):
|
||||
"""BUY with high conf/low contra/high n_eff/EV >> min_edge → live."""
|
||||
result = compute_v3_eligibility(
|
||||
p_up=0.75,
|
||||
ev_long=0.020, # >> 2 * 0.0035 = 0.0070
|
||||
min_edge=0.0035,
|
||||
confidence=0.80,
|
||||
contradiction=0.10,
|
||||
strength=0.50,
|
||||
n_eff_total=6.0,
|
||||
data_quality=0.85,
|
||||
regime="trend_following",
|
||||
has_existing_position=False,
|
||||
risk_engine_passed=True,
|
||||
)
|
||||
assert isinstance(result, V3Eligibility)
|
||||
assert result.action == "BUY"
|
||||
assert result.mode == "live"
|
||||
assert result.eligible is True
|
||||
|
||||
def test_paper_eligible(self):
|
||||
"""BUY with moderate confidence → paper."""
|
||||
result = compute_v3_eligibility(
|
||||
p_up=0.70,
|
||||
ev_long=0.010, # > min_edge but < 2 * min_edge for live
|
||||
min_edge=0.0035,
|
||||
confidence=0.65, # >= 0.60 for paper but < 0.75 for live
|
||||
contradiction=0.15,
|
||||
strength=0.40,
|
||||
n_eff_total=4.0,
|
||||
data_quality=0.80,
|
||||
regime="trend_following",
|
||||
has_existing_position=False,
|
||||
risk_engine_passed=True,
|
||||
)
|
||||
assert result.action == "BUY"
|
||||
assert result.mode == "paper"
|
||||
assert result.eligible is True
|
||||
|
||||
def test_informational_low_confidence(self):
|
||||
"""BUY with low confidence → informational."""
|
||||
result = compute_v3_eligibility(
|
||||
p_up=0.70,
|
||||
ev_long=0.010,
|
||||
min_edge=0.0035,
|
||||
confidence=0.55, # >= regime min but < 0.60 for paper
|
||||
contradiction=0.15,
|
||||
strength=0.40,
|
||||
n_eff_total=4.0,
|
||||
data_quality=0.80,
|
||||
regime="trend_following",
|
||||
has_existing_position=False,
|
||||
risk_engine_passed=True,
|
||||
)
|
||||
assert result.action == "BUY"
|
||||
assert result.mode == "informational"
|
||||
|
||||
def test_hold_always_informational(self):
|
||||
"""HOLD action is always informational regardless of quality."""
|
||||
result = compute_v3_eligibility(
|
||||
p_up=0.55, # Below bullish threshold for trend_following (0.60)
|
||||
ev_long=0.020,
|
||||
min_edge=0.0035,
|
||||
confidence=0.90,
|
||||
contradiction=0.05,
|
||||
strength=0.50,
|
||||
n_eff_total=10.0,
|
||||
data_quality=0.95,
|
||||
regime="trend_following",
|
||||
has_existing_position=True,
|
||||
risk_engine_passed=True,
|
||||
)
|
||||
assert result.action == "HOLD"
|
||||
assert result.mode == "informational"
|
||||
|
||||
def test_watch_when_ineligible(self):
|
||||
"""Low confidence below regime min → WATCH."""
|
||||
result = compute_v3_eligibility(
|
||||
p_up=0.80,
|
||||
ev_long=0.020,
|
||||
min_edge=0.0035,
|
||||
confidence=0.40, # Below trend_following min of 0.55
|
||||
contradiction=0.10,
|
||||
strength=0.60,
|
||||
n_eff_total=5.0,
|
||||
data_quality=0.80,
|
||||
regime="trend_following",
|
||||
has_existing_position=False,
|
||||
risk_engine_passed=True,
|
||||
)
|
||||
assert result.action == "WATCH"
|
||||
assert result.eligible is False
|
||||
|
||||
def test_risk_engine_blocks_live(self):
|
||||
"""Risk engine failure blocks live but allows paper."""
|
||||
result = compute_v3_eligibility(
|
||||
p_up=0.75,
|
||||
ev_long=0.020,
|
||||
min_edge=0.0035,
|
||||
confidence=0.80,
|
||||
contradiction=0.10,
|
||||
strength=0.50,
|
||||
n_eff_total=6.0,
|
||||
data_quality=0.85,
|
||||
regime="trend_following",
|
||||
has_existing_position=False,
|
||||
risk_engine_passed=False,
|
||||
)
|
||||
# Both live and paper require risk_engine_passed
|
||||
assert result.action == "BUY"
|
||||
assert result.mode == "informational"
|
||||
|
||||
def test_sell_on_negative_ev_with_position(self):
|
||||
"""Existing position with negative EV → SELL."""
|
||||
result = compute_v3_eligibility(
|
||||
p_up=0.35, # bearish
|
||||
ev_long=-0.005,
|
||||
min_edge=0.0035,
|
||||
confidence=0.70,
|
||||
contradiction=0.15,
|
||||
strength=0.30,
|
||||
n_eff_total=5.0,
|
||||
data_quality=0.80,
|
||||
regime="trend_following",
|
||||
has_existing_position=True,
|
||||
risk_engine_passed=True,
|
||||
)
|
||||
assert result.action == "SELL"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Projection decay convergence (Req 11.1–11.4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProjectionDecay:
|
||||
"""Tests for compute_v3_projection decay behavior."""
|
||||
|
||||
def test_evidence_accumulates(self):
|
||||
"""cluster_llrs=[1.0, 0.5] → A_t = phi*A_prev + 1.5."""
|
||||
regime = _make_regime(MarketRegime.TREND_FOLLOWING)
|
||||
result = compute_v3_projection(
|
||||
a_prev=0.0,
|
||||
cluster_llrs=[1.0, 0.5],
|
||||
regime=regime,
|
||||
p_prior=0.50,
|
||||
projection_horizon=1,
|
||||
)
|
||||
# A_t = 0.80 * 0.0 + 1.5 = 1.5
|
||||
assert result.a_t == pytest.approx(1.5, abs=1e-9)
|
||||
# P_up_projected should be > 0.5 (bullish evidence)
|
||||
assert result.p_up_projected > 0.5
|
||||
|
||||
def test_projection_horizon_decays(self):
|
||||
"""Higher projection_horizon → stronger decay → closer to prior."""
|
||||
regime = _make_regime(MarketRegime.TREND_FOLLOWING)
|
||||
# phi=0.80, horizon=5 → phi^5 = 0.32768
|
||||
result_h1 = compute_v3_projection(
|
||||
a_prev=0.0,
|
||||
cluster_llrs=[2.0],
|
||||
regime=regime,
|
||||
p_prior=0.50,
|
||||
projection_horizon=1,
|
||||
)
|
||||
result_h5 = compute_v3_projection(
|
||||
a_prev=0.0,
|
||||
cluster_llrs=[2.0],
|
||||
regime=regime,
|
||||
p_prior=0.50,
|
||||
projection_horizon=5,
|
||||
)
|
||||
# Longer horizon → more decay → projected_strength should be lower
|
||||
assert result_h5.projected_strength < result_h1.projected_strength
|
||||
# Both still bullish
|
||||
assert result_h1.p_up_projected > 0.5
|
||||
assert result_h5.p_up_projected > 0.5
|
||||
|
||||
def test_panic_decays_faster_than_trend(self):
|
||||
"""Panic (phi=0.35) decays much faster than trend_following (phi=0.80)."""
|
||||
regime_panic = _make_regime(MarketRegime.PANIC)
|
||||
regime_trend = _make_regime(MarketRegime.TREND_FOLLOWING)
|
||||
|
||||
result_panic = compute_v3_projection(
|
||||
a_prev=0.0,
|
||||
cluster_llrs=[2.0],
|
||||
regime=regime_panic,
|
||||
p_prior=0.50,
|
||||
projection_horizon=3,
|
||||
)
|
||||
result_trend = compute_v3_projection(
|
||||
a_prev=0.0,
|
||||
cluster_llrs=[2.0],
|
||||
regime=regime_trend,
|
||||
p_prior=0.50,
|
||||
projection_horizon=3,
|
||||
)
|
||||
# Trend should retain more signal after projection
|
||||
assert result_trend.projected_strength > result_panic.projected_strength
|
||||
|
||||
def test_phi_regime_stored(self):
|
||||
"""Result stores the correct phi_regime value."""
|
||||
regime = _make_regime(MarketRegime.MEAN_REVERSION)
|
||||
result = compute_v3_projection(
|
||||
a_prev=0.0,
|
||||
cluster_llrs=[1.0],
|
||||
regime=regime,
|
||||
p_prior=0.50,
|
||||
projection_horizon=1,
|
||||
)
|
||||
assert result.phi_regime == pytest.approx(0.55, abs=1e-9)
|
||||
|
||||
def test_a_prev_contributes(self):
|
||||
"""Non-zero a_prev gets decayed and added to new evidence."""
|
||||
regime = _make_regime(MarketRegime.UNCERTAINTY) # phi=0.50
|
||||
result = compute_v3_projection(
|
||||
a_prev=2.0,
|
||||
cluster_llrs=[1.0],
|
||||
regime=regime,
|
||||
p_prior=0.50,
|
||||
projection_horizon=1,
|
||||
)
|
||||
# A_t = 0.50 * 2.0 + 1.0 = 2.0
|
||||
assert result.a_t == pytest.approx(2.0, abs=1e-9)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Divergence flag behavior (Req 11.6)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDivergenceFlag:
|
||||
"""Tests for divergence detection between current and projected P_up."""
|
||||
|
||||
def test_no_divergence_same_direction(self):
|
||||
"""Bullish current and projected → diverges=False."""
|
||||
regime = _make_regime(MarketRegime.TREND_FOLLOWING)
|
||||
result = compute_v3_projection(
|
||||
a_prev=0.0,
|
||||
cluster_llrs=[2.0],
|
||||
regime=regime,
|
||||
p_prior=0.50,
|
||||
projection_horizon=1,
|
||||
)
|
||||
# Both current P_up_t and projected should be > 0.5 (bullish)
|
||||
assert result.diverges is False
|
||||
|
||||
def test_divergence_strong_decay(self):
|
||||
"""Bullish current but projected decay crosses 0.5 boundary → diverges=True."""
|
||||
# Use panic regime (phi=0.35) with small evidence and large projection horizon
|
||||
regime = _make_regime(MarketRegime.PANIC)
|
||||
# A_t = 0.35 * 0 + 0.1 = 0.1 → P_up_t > 0.5 (bullish)
|
||||
# A_projected = 0.35^20 * 0.1 ≈ 0 → P_up_projected ≈ 0.5
|
||||
# Need negative catalyst to push projected below 0.5
|
||||
result = compute_v3_projection(
|
||||
a_prev=0.0,
|
||||
cluster_llrs=[0.5], # Mild bullish evidence
|
||||
regime=regime,
|
||||
p_prior=0.50,
|
||||
projection_horizon=20,
|
||||
known_catalyst_llr=-1.0, # Bearish catalyst flips projected direction
|
||||
)
|
||||
# Current: A_t = 0.5, P_up_t = sigmoid(0.5) > 0.5 (bullish)
|
||||
# Projected: phi^20 * 0.5 - 1.0 = practically -1.0 → P_up_projected < 0.5
|
||||
assert result.diverges is True
|
||||
|
||||
def test_no_divergence_both_bearish(self):
|
||||
"""Bearish current and projected → diverges=False."""
|
||||
regime = _make_regime(MarketRegime.TREND_FOLLOWING)
|
||||
result = compute_v3_projection(
|
||||
a_prev=0.0,
|
||||
cluster_llrs=[-2.0],
|
||||
regime=regime,
|
||||
p_prior=0.50,
|
||||
projection_horizon=1,
|
||||
)
|
||||
# Both should be < 0.5 (bearish)
|
||||
assert result.p_up_projected < 0.5
|
||||
assert result.diverges is False
|
||||
|
||||
def test_known_catalyst_shifts_projection(self):
|
||||
"""known_catalyst_llr adds to projected alpha."""
|
||||
regime = _make_regime(MarketRegime.TREND_FOLLOWING)
|
||||
result_no_catalyst = compute_v3_projection(
|
||||
a_prev=0.0,
|
||||
cluster_llrs=[1.0],
|
||||
regime=regime,
|
||||
p_prior=0.50,
|
||||
projection_horizon=1,
|
||||
known_catalyst_llr=0.0,
|
||||
)
|
||||
result_with_catalyst = compute_v3_projection(
|
||||
a_prev=0.0,
|
||||
cluster_llrs=[1.0],
|
||||
regime=regime,
|
||||
p_prior=0.50,
|
||||
projection_horizon=1,
|
||||
known_catalyst_llr=1.0,
|
||||
)
|
||||
# Catalyst boosts projected P_up
|
||||
assert result_with_catalyst.p_up_projected > result_no_catalyst.p_up_projected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default vol (Req 12.7)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDefaultVolatility:
|
||||
"""Tests that realized_vol_20d=0 defaults to 0.25."""
|
||||
|
||||
def test_zero_vol_uses_default(self):
|
||||
"""realized_vol_20d=0 → uses 0.25 default."""
|
||||
result_zero = compute_return_distribution(
|
||||
a_projected=2.0,
|
||||
confidence=0.8,
|
||||
realized_vol_20d=0,
|
||||
horizon_days=7,
|
||||
costs=0.001,
|
||||
regime="trend_following",
|
||||
confidence_actual=0.8,
|
||||
contradiction=0.1,
|
||||
n_eff_total=5.0,
|
||||
data_quality=0.8,
|
||||
)
|
||||
result_default = compute_return_distribution(
|
||||
a_projected=2.0,
|
||||
confidence=0.8,
|
||||
realized_vol_20d=0.25,
|
||||
horizon_days=7,
|
||||
costs=0.001,
|
||||
regime="trend_following",
|
||||
confidence_actual=0.8,
|
||||
contradiction=0.1,
|
||||
n_eff_total=5.0,
|
||||
data_quality=0.8,
|
||||
)
|
||||
assert result_zero.sigma_h == pytest.approx(result_default.sigma_h, abs=1e-12)
|
||||
assert result_zero.ev_long == pytest.approx(result_default.ev_long, abs=1e-12)
|
||||
|
||||
def test_negative_vol_uses_default(self):
|
||||
"""realized_vol_20d=-0.1 → uses 0.25 default."""
|
||||
result = compute_return_distribution(
|
||||
a_projected=2.0,
|
||||
confidence=0.8,
|
||||
realized_vol_20d=-0.1,
|
||||
horizon_days=7,
|
||||
costs=0.001,
|
||||
regime="trend_following",
|
||||
confidence_actual=0.8,
|
||||
contradiction=0.1,
|
||||
n_eff_total=5.0,
|
||||
data_quality=0.8,
|
||||
)
|
||||
expected_sigma_h = 0.25 * math.sqrt(7 / 252.0)
|
||||
assert result.sigma_h == pytest.approx(expected_sigma_h, rel=1e-9)
|
||||
@@ -0,0 +1,569 @@
|
||||
"""Unit tests for EvidenceUnit normalization and LLR conversion.
|
||||
|
||||
Validates: Requirements 1.1–1.8, 2.1–2.9, 3.1–3.6
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from services.aggregation.scoring import (
|
||||
EvidenceUnit,
|
||||
ReliabilityComponents,
|
||||
SourceStats,
|
||||
compute_llr,
|
||||
compute_v3_reliability,
|
||||
normalize_company_signal,
|
||||
normalize_competitive_signal,
|
||||
normalize_macro_signal,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_NOW = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _make_company_signal(**overrides) -> dict:
|
||||
"""Create a minimal valid company signal dict."""
|
||||
base = {
|
||||
"symbol": "AAPL",
|
||||
"timestamp": _NOW,
|
||||
"source_id": "doc-001",
|
||||
"event_type": "earnings",
|
||||
"source_group": "company",
|
||||
"horizon": "7d",
|
||||
"sentiment": "positive",
|
||||
"sentiment_strength": 0.8,
|
||||
"impact": 0.7,
|
||||
"extraction_conf": 0.9,
|
||||
"source_cred": 0.85,
|
||||
"novelty": 0.9,
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def _make_macro_signal(**overrides) -> dict:
|
||||
"""Create a minimal valid macro signal dict."""
|
||||
base = {
|
||||
"symbol": "MSFT",
|
||||
"timestamp": _NOW,
|
||||
"source_id": "event-100",
|
||||
"event_type": "regulatory",
|
||||
"impact_direction": "positive",
|
||||
"macro_impact_score": 0.6,
|
||||
"event_confidence": 0.75,
|
||||
"estimated_duration": "medium_term",
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def _make_competitive_signal(**overrides) -> dict:
|
||||
"""Create a minimal valid competitive signal dict."""
|
||||
base = {
|
||||
"symbol": "GOOG",
|
||||
"timestamp": _NOW,
|
||||
"source_id": "comp-doc-55",
|
||||
"event_type": "product_launch",
|
||||
"signal_direction": "bearish",
|
||||
"signal_strength": 0.7,
|
||||
"relationship_strength": 0.8,
|
||||
"pattern_confidence": 0.65,
|
||||
"time_horizon": "30d",
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestNormalizeCompanySignal
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestNormalizeCompanySignal:
|
||||
"""Test normalize_company_signal mapping and validation."""
|
||||
|
||||
def test_full_company_signal(self):
|
||||
"""A complete company signal maps all fields correctly."""
|
||||
sig = _make_company_signal()
|
||||
eu = normalize_company_signal(sig)
|
||||
|
||||
assert eu is not None
|
||||
assert eu.symbol == "AAPL"
|
||||
assert eu.layer == "company"
|
||||
assert eu.event_type == "earnings"
|
||||
assert eu.source_id == "doc-001"
|
||||
assert eu.source_group == "company"
|
||||
assert eu.timestamp == _NOW
|
||||
assert eu.horizon == "7d"
|
||||
assert eu.direction == 1 # "positive" → +1
|
||||
assert eu.sentiment_strength == 0.8
|
||||
assert eu.impact == 0.7
|
||||
assert eu.extraction_conf == 0.9
|
||||
assert eu.source_cred == 0.85
|
||||
assert eu.novelty == 0.9
|
||||
assert eu.event_base_rate == 0.25 # earnings base rate
|
||||
assert len(eu.cluster_id) == 16 # sha256 hex prefix
|
||||
|
||||
def test_missing_symbol_rejected(self):
|
||||
"""Missing symbol → returns None with warning."""
|
||||
sig = _make_company_signal(symbol=None)
|
||||
assert normalize_company_signal(sig) is None
|
||||
|
||||
def test_missing_timestamp_rejected(self):
|
||||
"""Missing timestamp → returns None with warning."""
|
||||
sig = _make_company_signal(timestamp=None)
|
||||
assert normalize_company_signal(sig) is None
|
||||
|
||||
def test_missing_source_id_rejected(self):
|
||||
"""Missing source_id → returns None with warning."""
|
||||
sig = _make_company_signal(source_id=None)
|
||||
assert normalize_company_signal(sig) is None
|
||||
|
||||
def test_empty_string_symbol_rejected(self):
|
||||
"""Empty string symbol → returns None (falsy check)."""
|
||||
sig = _make_company_signal(symbol="")
|
||||
assert normalize_company_signal(sig) is None
|
||||
|
||||
def test_direction_mappings(self):
|
||||
"""Direction string mappings: positive→+1, negative→-1, neutral→0."""
|
||||
for sentiment, expected in [
|
||||
("positive", 1),
|
||||
("negative", -1),
|
||||
("neutral", 0),
|
||||
("bullish", 1),
|
||||
("bearish", -1),
|
||||
("mixed", 0),
|
||||
]:
|
||||
eu = normalize_company_signal(_make_company_signal(sentiment=sentiment))
|
||||
assert eu is not None
|
||||
assert eu.direction == expected, f"'{sentiment}' should map to {expected}"
|
||||
|
||||
def test_missing_optional_fields_default_0_5(self):
|
||||
"""Missing optional numeric fields substitute 0.5."""
|
||||
sig = {
|
||||
"symbol": "TSLA",
|
||||
"timestamp": _NOW,
|
||||
"source_id": "doc-xyz",
|
||||
}
|
||||
eu = normalize_company_signal(sig)
|
||||
|
||||
assert eu is not None
|
||||
assert eu.sentiment_strength == 0.5
|
||||
assert eu.impact == 0.5
|
||||
assert eu.extraction_conf == 0.5
|
||||
assert eu.source_cred == 0.5
|
||||
assert eu.novelty == 0.5
|
||||
|
||||
def test_invalid_horizon_defaults_to_7d(self):
|
||||
"""Invalid horizon string falls back to '7d'."""
|
||||
sig = _make_company_signal(horizon="invalid_horizon")
|
||||
eu = normalize_company_signal(sig)
|
||||
assert eu is not None
|
||||
assert eu.horizon == "7d"
|
||||
|
||||
def test_timestamp_string_parsed(self):
|
||||
"""ISO timestamp string is parsed to datetime."""
|
||||
sig = _make_company_signal(timestamp="2025-01-10T08:00:00+00:00")
|
||||
eu = normalize_company_signal(sig)
|
||||
assert eu is not None
|
||||
assert eu.timestamp == datetime(2025, 1, 10, 8, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
def test_unknown_event_type_uses_default_base_rate(self):
|
||||
"""Unknown event_type uses default base rate of 0.10."""
|
||||
sig = _make_company_signal(event_type="mysterious_event")
|
||||
eu = normalize_company_signal(sig)
|
||||
assert eu is not None
|
||||
assert eu.event_base_rate == 0.10
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestNormalizeMacroSignal
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestNormalizeMacroSignal:
|
||||
"""Test normalize_macro_signal mapping and validation."""
|
||||
|
||||
def test_full_macro_signal(self):
|
||||
"""A complete macro signal maps all fields correctly."""
|
||||
sig = _make_macro_signal()
|
||||
eu = normalize_macro_signal(sig)
|
||||
|
||||
assert eu is not None
|
||||
assert eu.symbol == "MSFT"
|
||||
assert eu.layer == "macro"
|
||||
assert eu.source_group == "macro"
|
||||
assert eu.direction == 1 # "positive" → +1
|
||||
assert eu.impact == 0.6 # macro_impact_score
|
||||
assert eu.source_cred == 0.75 # event_confidence
|
||||
assert eu.extraction_conf == 0.75 # event_confidence
|
||||
assert eu.novelty == 1.0 # default for new events
|
||||
|
||||
def test_horizon_short_term(self):
|
||||
"""short_term → 7d."""
|
||||
sig = _make_macro_signal(estimated_duration="short_term")
|
||||
eu = normalize_macro_signal(sig)
|
||||
assert eu is not None
|
||||
assert eu.horizon == "7d"
|
||||
|
||||
def test_horizon_medium_term(self):
|
||||
"""medium_term → 30d."""
|
||||
sig = _make_macro_signal(estimated_duration="medium_term")
|
||||
eu = normalize_macro_signal(sig)
|
||||
assert eu is not None
|
||||
assert eu.horizon == "30d"
|
||||
|
||||
def test_horizon_long_term(self):
|
||||
"""long_term → 90d."""
|
||||
sig = _make_macro_signal(estimated_duration="long_term")
|
||||
eu = normalize_macro_signal(sig)
|
||||
assert eu is not None
|
||||
assert eu.horizon == "90d"
|
||||
|
||||
def test_missing_symbol_rejected(self):
|
||||
"""Missing symbol in macro signal → None."""
|
||||
sig = _make_macro_signal()
|
||||
del sig["symbol"]
|
||||
assert normalize_macro_signal(sig) is None
|
||||
|
||||
def test_ticker_alias_accepted(self):
|
||||
"""'ticker' key is accepted as alias for 'symbol'."""
|
||||
sig = _make_macro_signal()
|
||||
del sig["symbol"]
|
||||
sig["ticker"] = "AMZN"
|
||||
eu = normalize_macro_signal(sig)
|
||||
assert eu is not None
|
||||
assert eu.symbol == "AMZN"
|
||||
|
||||
def test_event_id_alias_accepted(self):
|
||||
"""'event_id' key is accepted as alias for 'source_id'."""
|
||||
sig = _make_macro_signal()
|
||||
del sig["source_id"]
|
||||
sig["event_id"] = "global-evt-42"
|
||||
eu = normalize_macro_signal(sig)
|
||||
assert eu is not None
|
||||
assert eu.source_id == "global-evt-42"
|
||||
|
||||
def test_direction_mapping_negative(self):
|
||||
"""Negative impact_direction → direction = -1."""
|
||||
sig = _make_macro_signal(impact_direction="negative")
|
||||
eu = normalize_macro_signal(sig)
|
||||
assert eu is not None
|
||||
assert eu.direction == -1
|
||||
|
||||
def test_direction_mapping_neutral(self):
|
||||
"""Neutral impact_direction → direction = 0."""
|
||||
sig = _make_macro_signal(impact_direction="neutral")
|
||||
eu = normalize_macro_signal(sig)
|
||||
assert eu is not None
|
||||
assert eu.direction == 0
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestNormalizeCompetitiveSignal
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestNormalizeCompetitiveSignal:
|
||||
"""Test normalize_competitive_signal mapping and validation."""
|
||||
|
||||
def test_full_competitive_signal(self):
|
||||
"""A complete competitive signal maps all fields correctly."""
|
||||
sig = _make_competitive_signal()
|
||||
eu = normalize_competitive_signal(sig)
|
||||
|
||||
assert eu is not None
|
||||
assert eu.symbol == "GOOG"
|
||||
assert eu.layer == "competitive"
|
||||
assert eu.source_group == "competitive"
|
||||
assert eu.horizon == "30d"
|
||||
assert eu.direction == -1 # "bearish" → -1
|
||||
|
||||
def test_impact_is_product_of_strengths(self):
|
||||
"""Impact = signal_strength × relationship_strength."""
|
||||
sig = _make_competitive_signal(signal_strength=0.7, relationship_strength=0.8)
|
||||
eu = normalize_competitive_signal(sig)
|
||||
assert eu is not None
|
||||
assert abs(eu.impact - 0.56) < 1e-9 # 0.7 × 0.8
|
||||
|
||||
def test_source_cred_from_pattern_confidence(self):
|
||||
"""source_cred mapped from pattern_confidence."""
|
||||
sig = _make_competitive_signal(pattern_confidence=0.65)
|
||||
eu = normalize_competitive_signal(sig)
|
||||
assert eu is not None
|
||||
assert eu.source_cred == 0.65
|
||||
assert eu.extraction_conf == 0.65
|
||||
|
||||
def test_direction_bullish(self):
|
||||
"""signal_direction='bullish' → direction = +1."""
|
||||
sig = _make_competitive_signal(signal_direction="bullish")
|
||||
eu = normalize_competitive_signal(sig)
|
||||
assert eu is not None
|
||||
assert eu.direction == 1
|
||||
|
||||
def test_direction_neutral(self):
|
||||
"""signal_direction='neutral' → direction = 0."""
|
||||
sig = _make_competitive_signal(signal_direction="neutral")
|
||||
eu = normalize_competitive_signal(sig)
|
||||
assert eu is not None
|
||||
assert eu.direction == 0
|
||||
|
||||
def test_novelty_defaults_to_1(self):
|
||||
"""Novelty defaults to 1.0 for competitive signals."""
|
||||
sig = _make_competitive_signal()
|
||||
# Ensure no explicit novelty key
|
||||
sig.pop("novelty", None)
|
||||
eu = normalize_competitive_signal(sig)
|
||||
assert eu is not None
|
||||
assert eu.novelty == 1.0
|
||||
|
||||
def test_missing_required_source_id_rejected(self):
|
||||
"""Missing source_id in competitive signal → None."""
|
||||
sig = _make_competitive_signal(source_id=None)
|
||||
# Also ensure alias key is absent
|
||||
sig.pop("source_document_id", None)
|
||||
assert normalize_competitive_signal(sig) is None
|
||||
|
||||
def test_target_ticker_alias(self):
|
||||
"""'target_ticker' key accepted as alias for 'symbol'."""
|
||||
sig = _make_competitive_signal()
|
||||
del sig["symbol"]
|
||||
sig["target_ticker"] = "META"
|
||||
eu = normalize_competitive_signal(sig)
|
||||
assert eu is not None
|
||||
assert eu.symbol == "META"
|
||||
|
||||
def test_time_horizon_short_term_maps_to_7d(self):
|
||||
"""Competitive time_horizon='short_term' maps to '7d' via macro map."""
|
||||
sig = _make_competitive_signal(time_horizon="short_term")
|
||||
eu = normalize_competitive_signal(sig)
|
||||
assert eu is not None
|
||||
assert eu.horizon == "7d"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestReliabilityPipeline
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestReliabilityPipeline:
|
||||
"""Test compute_v3_reliability with known inputs."""
|
||||
|
||||
def test_known_inputs_perfect_signal(self):
|
||||
"""Perfect inputs (source_cred=1, extraction_conf=1, novelty=1, fresh, no duplicates) → q_i close to 1."""
|
||||
unit = EvidenceUnit(
|
||||
symbol="AAPL",
|
||||
layer="company",
|
||||
event_type="earnings",
|
||||
source_id="doc-perfect",
|
||||
source_group="company",
|
||||
timestamp=_NOW,
|
||||
horizon="7d",
|
||||
direction=1,
|
||||
sentiment_strength=1.0,
|
||||
impact=1.0,
|
||||
extraction_conf=1.0,
|
||||
source_cred=1.0,
|
||||
novelty=1.0,
|
||||
event_base_rate=0.25,
|
||||
cluster_id="test-cluster",
|
||||
)
|
||||
# Source with strong track record
|
||||
stats = SourceStats(source_id="doc-perfect", hits=50, misses=0)
|
||||
# Fresh signal (0 age)
|
||||
rel = compute_v3_reliability(unit, stats, cluster_position=0, reference_time=_NOW)
|
||||
|
||||
# q_ext: sigmoid(8.0 * (1.0 - 0.55)) = sigmoid(3.6) ≈ 0.9734
|
||||
assert rel.q_ext > 0.95
|
||||
|
||||
# q_source: E[theta] = (3+50)/(3+3+50+0) = 53/56 ≈ 0.946
|
||||
# clamp((0.946 - 0.50) / 0.35, 0, 1) = clamp(1.274, 0, 1) = 1.0
|
||||
assert rel.q_source == 1.0
|
||||
|
||||
# q_recency: fresh signal → 2^0 = 1.0
|
||||
assert rel.q_recency == 1.0
|
||||
|
||||
# q_uniqueness: clamp(0.5 + 0.5*1.0, 0.5, 1.0) * 1/sqrt(1) = 1.0
|
||||
assert rel.q_uniqueness == 1.0
|
||||
|
||||
# q_i should be close to 1 (bounded by q_ext ≈ 0.97)
|
||||
assert rel.q_i > 0.90
|
||||
|
||||
def test_zero_history_source_yields_zero_q_source(self):
|
||||
"""A source with zero history (hits=0, misses=0) → q_source = 0.0 (Req 2.3)."""
|
||||
unit = EvidenceUnit(
|
||||
symbol="AAPL",
|
||||
layer="company",
|
||||
event_type="earnings",
|
||||
source_id="new-source",
|
||||
source_group="company",
|
||||
timestamp=_NOW,
|
||||
horizon="7d",
|
||||
direction=1,
|
||||
sentiment_strength=0.8,
|
||||
impact=0.7,
|
||||
extraction_conf=0.9,
|
||||
source_cred=0.85,
|
||||
novelty=0.9,
|
||||
event_base_rate=0.25,
|
||||
cluster_id="test-cluster",
|
||||
)
|
||||
stats = SourceStats(source_id="new-source", hits=0, misses=0)
|
||||
rel = compute_v3_reliability(unit, stats, cluster_position=0, reference_time=_NOW)
|
||||
|
||||
# E[theta] = 3/(3+3) = 0.5; clamp((0.5-0.5)/0.35, 0, 1) = 0.0
|
||||
assert rel.q_source == 0.0
|
||||
# Therefore q_i = 0.0 (multiplied by zero)
|
||||
assert rel.q_i == 0.0
|
||||
|
||||
def test_duplicate_signal_penalized(self):
|
||||
"""Signals later in a cluster (high cluster_position) get lower q_uniqueness."""
|
||||
unit = EvidenceUnit(
|
||||
symbol="AAPL",
|
||||
layer="company",
|
||||
event_type="earnings",
|
||||
source_id="doc-dup",
|
||||
source_group="company",
|
||||
timestamp=_NOW,
|
||||
horizon="7d",
|
||||
direction=1,
|
||||
sentiment_strength=0.8,
|
||||
impact=0.7,
|
||||
extraction_conf=0.9,
|
||||
source_cred=0.85,
|
||||
novelty=0.9,
|
||||
event_base_rate=0.25,
|
||||
cluster_id="test-cluster",
|
||||
)
|
||||
stats = SourceStats(source_id="doc-dup", hits=20, misses=5)
|
||||
|
||||
rel_first = compute_v3_reliability(unit, stats, cluster_position=0, reference_time=_NOW)
|
||||
rel_third = compute_v3_reliability(unit, stats, cluster_position=3, reference_time=_NOW)
|
||||
|
||||
# Third signal has lower q_uniqueness due to 1/sqrt(1+3) = 0.5
|
||||
assert rel_third.q_uniqueness < rel_first.q_uniqueness
|
||||
assert rel_third.q_i < rel_first.q_i
|
||||
|
||||
def test_stale_signal_low_recency(self):
|
||||
"""A signal that is very old gets low q_recency."""
|
||||
old_timestamp = datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
|
||||
unit = EvidenceUnit(
|
||||
symbol="AAPL",
|
||||
layer="company",
|
||||
event_type="earnings",
|
||||
source_id="doc-old",
|
||||
source_group="company",
|
||||
timestamp=old_timestamp,
|
||||
horizon="7d",
|
||||
direction=1,
|
||||
sentiment_strength=0.8,
|
||||
impact=0.7,
|
||||
extraction_conf=0.9,
|
||||
source_cred=0.85,
|
||||
novelty=0.9,
|
||||
event_base_rate=0.25,
|
||||
cluster_id="test-cluster",
|
||||
)
|
||||
stats = SourceStats(source_id="doc-old", hits=20, misses=5)
|
||||
rel = compute_v3_reliability(unit, stats, cluster_position=0, reference_time=_NOW)
|
||||
|
||||
# Over a year old with 7d horizon (tau_base=72h) → q_recency very low
|
||||
# Display floor is 0.01
|
||||
assert rel.q_recency == 0.01
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestLLRConversion
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestLLRConversion:
|
||||
"""Test compute_llr boundary cases and sign behavior."""
|
||||
|
||||
def _make_unit(self, direction: int, impact: float = 0.7, sentiment_strength: float = 0.8) -> EvidenceUnit:
|
||||
"""Helper to create an EvidenceUnit with specified direction."""
|
||||
return EvidenceUnit(
|
||||
symbol="TEST",
|
||||
layer="company",
|
||||
event_type="earnings",
|
||||
source_id="llr-test",
|
||||
source_group="company",
|
||||
timestamp=_NOW,
|
||||
horizon="7d",
|
||||
direction=direction,
|
||||
sentiment_strength=sentiment_strength,
|
||||
impact=impact,
|
||||
extraction_conf=0.9,
|
||||
source_cred=0.85,
|
||||
novelty=0.9,
|
||||
event_base_rate=0.25,
|
||||
cluster_id="test-cluster",
|
||||
)
|
||||
|
||||
def test_neutral_signal_zero_llr(self):
|
||||
"""Neutral signal (direction=0) → LLR = 0.0 exactly (Req 3.3)."""
|
||||
unit = self._make_unit(direction=0)
|
||||
llr = compute_llr(unit, q_i=0.9)
|
||||
assert llr == 0.0
|
||||
|
||||
def test_bullish_positive_llr(self):
|
||||
"""Bullish signal (direction=+1) → positive LLR (Req 3.6)."""
|
||||
unit = self._make_unit(direction=1)
|
||||
llr = compute_llr(unit, q_i=0.9)
|
||||
assert llr > 0.0
|
||||
|
||||
def test_bearish_negative_llr(self):
|
||||
"""Bearish signal (direction=-1) → negative LLR (Req 3.6)."""
|
||||
unit = self._make_unit(direction=-1)
|
||||
llr = compute_llr(unit, q_i=0.9)
|
||||
assert llr < 0.0
|
||||
|
||||
def test_p_correct_max_clamp(self):
|
||||
"""Maximum p_correct = 0.85 → LLR ≈ ln(0.85/0.15) ≈ 1.735 (Req 3.5)."""
|
||||
# With direction=+1, q_i=1.0, impact=1.0, sentiment_strength=1.0:
|
||||
# p_correct = clamp(0.50 + 0.35*1*1*1, 0.501, 0.85) = 0.85
|
||||
unit = self._make_unit(direction=1, impact=1.0, sentiment_strength=1.0)
|
||||
llr = compute_llr(unit, q_i=1.0)
|
||||
expected = math.log(0.85 / 0.15) # ≈ 1.7346
|
||||
assert abs(llr - expected) < 0.001
|
||||
|
||||
def test_p_correct_min_clamp(self):
|
||||
"""Minimum p_correct = 0.501 → |LLR| ≈ ln(0.501/0.499) ≈ 0.004 (Req 3.4)."""
|
||||
# With direction=-1, q_i very small → p_correct clamps to 0.501
|
||||
# q_i=0 → 0.50 + 0.35*0*anything = 0.50 → clamped to 0.501
|
||||
unit = self._make_unit(direction=-1, impact=0.0, sentiment_strength=0.0)
|
||||
llr = compute_llr(unit, q_i=0.0)
|
||||
expected = -math.log(0.501 / 0.499) # ≈ -0.004
|
||||
assert abs(llr - expected) < 0.001
|
||||
|
||||
def test_llr_sign_always_matches_direction(self):
|
||||
"""For directional signals, LLR sign must match direction (Req 3.6)."""
|
||||
for direction in [1, -1]:
|
||||
for q_i in [0.0, 0.1, 0.5, 0.9, 1.0]:
|
||||
unit = self._make_unit(direction=direction)
|
||||
llr = compute_llr(unit, q_i=q_i)
|
||||
if direction == 1:
|
||||
assert llr > 0.0, f"direction=+1, q_i={q_i} should give positive LLR"
|
||||
else:
|
||||
assert llr < 0.0, f"direction=-1, q_i={q_i} should give negative LLR"
|
||||
|
||||
def test_llr_magnitude_bounded(self):
|
||||
"""LLR magnitude is bounded by [≈0.004, ≈1.735] for directional signals."""
|
||||
min_mag = math.log(0.501 / 0.499) # ≈ 0.004
|
||||
max_mag = math.log(0.85 / 0.15) # ≈ 1.735
|
||||
|
||||
# Test at both extremes
|
||||
unit_max = self._make_unit(direction=1, impact=1.0, sentiment_strength=1.0)
|
||||
llr_max = compute_llr(unit_max, q_i=1.0)
|
||||
assert abs(llr_max) <= max_mag + 0.001
|
||||
|
||||
unit_min = self._make_unit(direction=1, impact=0.0, sentiment_strength=0.0)
|
||||
llr_min = compute_llr(unit_min, q_i=0.0)
|
||||
assert abs(llr_min) >= min_mag - 0.001
|
||||
@@ -0,0 +1,454 @@
|
||||
"""Integration tests for the v3 calibrated evidence pipeline.
|
||||
|
||||
Tests the full pipeline path through pure functions end-to-end:
|
||||
raw signals → EvidenceUnit → q_i → LLR → cluster → posterior → recommendation
|
||||
|
||||
Also validates feature flag routing and v3 metadata fields.
|
||||
|
||||
Requirements validated: 19.1–19.6, 20.1–20.5
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from services.aggregation.bayesian import V3Posterior, compute_v3_posterior
|
||||
from services.aggregation.contradiction import compute_v3_contradiction
|
||||
from services.aggregation.regime import (
|
||||
_DEFAULT_V3_UNCERTAINTY,
|
||||
)
|
||||
from services.aggregation.scoring import (
|
||||
SourceStats,
|
||||
compute_llr,
|
||||
compute_v3_reliability,
|
||||
normalize_company_signal,
|
||||
)
|
||||
from services.aggregation.worker import (
|
||||
_annotate_pipeline_mode,
|
||||
cluster_evidence,
|
||||
compute_cluster_llr,
|
||||
compute_n_eff,
|
||||
compute_v3_confidence,
|
||||
compute_v3_data_quality,
|
||||
should_force_informational_v3,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures / helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_NOW = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
_DEFAULT_REGIME = _DEFAULT_V3_UNCERTAINTY
|
||||
|
||||
|
||||
def _make_signal(
|
||||
sentiment: str = "positive",
|
||||
impact: float = 0.7,
|
||||
source_id: str = "doc1",
|
||||
event_type: str = "earnings",
|
||||
source_group: str = "company",
|
||||
extraction_conf: float = 0.85,
|
||||
source_cred: float = 0.80,
|
||||
novelty: float = 0.7,
|
||||
) -> dict:
|
||||
"""Build a raw company signal dict for normalization."""
|
||||
return {
|
||||
"symbol": "AAPL",
|
||||
"timestamp": _NOW,
|
||||
"source_id": source_id,
|
||||
"event_type": event_type,
|
||||
"source_group": source_group,
|
||||
"horizon": "7d",
|
||||
"sentiment": sentiment,
|
||||
"sentiment_strength": impact,
|
||||
"impact": impact,
|
||||
"extraction_conf": extraction_conf,
|
||||
"source_cred": source_cred,
|
||||
"novelty": novelty,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 1: Full pipeline path through pure functions
|
||||
# Requirements: 20.1, 20.2, 20.3, 20.4, 20.5
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_full_pipeline_path():
|
||||
"""End-to-end test: raw signals → EvidenceUnit → q_i → LLR → cluster → posterior."""
|
||||
# 1. Create raw signal dicts with opposing sentiments
|
||||
signals = [
|
||||
_make_signal(
|
||||
sentiment="positive",
|
||||
impact=0.8,
|
||||
source_id="doc1",
|
||||
event_type="earnings",
|
||||
extraction_conf=0.9,
|
||||
source_cred=0.85,
|
||||
novelty=0.8,
|
||||
),
|
||||
_make_signal(
|
||||
sentiment="positive",
|
||||
impact=0.6,
|
||||
source_id="doc2",
|
||||
event_type="earnings",
|
||||
extraction_conf=0.75,
|
||||
source_cred=0.7,
|
||||
novelty=0.6,
|
||||
),
|
||||
_make_signal(
|
||||
sentiment="negative",
|
||||
impact=0.5,
|
||||
source_id="doc3",
|
||||
event_type="regulatory",
|
||||
extraction_conf=0.7,
|
||||
source_cred=0.6,
|
||||
novelty=0.9,
|
||||
),
|
||||
]
|
||||
|
||||
# 2. Normalize to EvidenceUnit
|
||||
units = [normalize_company_signal(s) for s in signals]
|
||||
assert all(u is not None for u in units), "All signals should normalize successfully"
|
||||
units = [u for u in units if u is not None] # type narrowing
|
||||
assert len(units) == 3
|
||||
|
||||
# 3. Compute q_i (reliability) for each unit
|
||||
neutral_stats = SourceStats(source_id="default", hits=0, misses=0)
|
||||
q_values = []
|
||||
for unit in units:
|
||||
reliability = compute_v3_reliability(
|
||||
unit=unit,
|
||||
source_stats=neutral_stats,
|
||||
cluster_position=0,
|
||||
reference_time=_NOW,
|
||||
)
|
||||
q_values.append(reliability.q_i)
|
||||
|
||||
# All q_i should be in [0, 1]
|
||||
for q in q_values:
|
||||
assert 0.0 <= q <= 1.0, f"q_i out of bounds: {q}"
|
||||
|
||||
# 4. Compute LLR for each unit
|
||||
llrs = [compute_llr(unit, q) for unit, q in zip(units, q_values)]
|
||||
# Positive sentiment → positive LLR, negative → negative LLR
|
||||
assert llrs[0] > 0.0, "Positive signal should produce positive LLR"
|
||||
assert llrs[1] > 0.0, "Positive signal should produce positive LLR"
|
||||
assert llrs[2] < 0.0, "Negative signal should produce negative LLR"
|
||||
|
||||
# 5. Cluster evidence
|
||||
clusters = cluster_evidence(units, llrs)
|
||||
assert len(clusters) >= 1, "Should produce at least one cluster"
|
||||
|
||||
# 6. Compute n_eff and cluster_llr for each cluster
|
||||
for cluster in clusters:
|
||||
cluster.n_eff = compute_n_eff(cluster.llrs)
|
||||
cluster.cluster_llr = compute_cluster_llr(cluster.llrs, cluster.n_eff)
|
||||
assert cluster.n_eff >= 1.0, "n_eff should be >= 1.0"
|
||||
assert -2.5 <= cluster.cluster_llr <= 2.5, "cluster_llr should be clamped"
|
||||
|
||||
# 7. Compute posterior (using default uncertainty regime)
|
||||
posterior = compute_v3_posterior(clusters, _DEFAULT_REGIME, p_prior=0.50)
|
||||
assert isinstance(posterior, V3Posterior)
|
||||
|
||||
# 8. Assert posterior is valid
|
||||
assert 0.0 < posterior.p_up < 1.0, f"P_up should be in (0, 1), got {posterior.p_up}"
|
||||
assert 0.0 < posterior.p_down < 1.0, "P_down should be in (0, 1)"
|
||||
assert abs(posterior.p_up + posterior.p_down - 1.0) < 1e-9
|
||||
assert 0.0 <= posterior.strength <= 1.0
|
||||
assert posterior.direction in ("bullish", "bearish", "neutral")
|
||||
assert posterior.n_eff_total > 0
|
||||
|
||||
# 9. Compute contradiction
|
||||
contradiction = compute_v3_contradiction(clusters)
|
||||
# 10. Assert contradiction is bounded
|
||||
assert 0.0 <= contradiction <= 1.0
|
||||
|
||||
# 11. Compute data quality
|
||||
data_quality = compute_v3_data_quality(
|
||||
units=units,
|
||||
extraction_failure_rate=0.0,
|
||||
age_newest_hours=0.0,
|
||||
n_source_types=1,
|
||||
)
|
||||
assert 0.0 <= data_quality <= 1.0
|
||||
|
||||
# 12. Compute confidence
|
||||
confidence = compute_v3_confidence(
|
||||
n_eff_total=posterior.n_eff_total,
|
||||
q_values=q_values,
|
||||
llrs=llrs,
|
||||
strength=posterior.strength,
|
||||
regime_confidence_mult=_DEFAULT_REGIME.confidence_multiplier,
|
||||
contradiction=contradiction,
|
||||
data_quality=data_quality,
|
||||
)
|
||||
assert 0.0 <= confidence <= 1.0
|
||||
|
||||
# With real positive signals we should get a non-trivial posterior
|
||||
# (not exactly 0.5 since we have net-positive evidence)
|
||||
assert posterior.p_up > 0.50, (
|
||||
"Net-positive evidence should push P_up above 0.50"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 2: Feature flag routing — _annotate_pipeline_mode
|
||||
# Requirements: 19.1, 19.5
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_annotate_pipeline_mode_v3():
|
||||
"""_annotate_pipeline_mode sets pipeline_mode correctly for v3."""
|
||||
from services.shared.schemas import TrendDirection, TrendSummary, TrendWindow
|
||||
|
||||
summary = TrendSummary(
|
||||
entity_type="company",
|
||||
entity_id="AAPL",
|
||||
window=TrendWindow.SEVEN_DAY,
|
||||
trend_direction=TrendDirection.BULLISH,
|
||||
trend_strength=0.6,
|
||||
confidence=0.7,
|
||||
top_supporting_evidence=["doc1"],
|
||||
top_opposing_evidence=[],
|
||||
dominant_catalysts=["earnings"],
|
||||
material_risks=[],
|
||||
contradiction_score=0.1,
|
||||
disagreement_details=[],
|
||||
generated_at=_NOW,
|
||||
)
|
||||
# Initially no market_context
|
||||
summary.market_context = {}
|
||||
|
||||
_annotate_pipeline_mode(summary, "v3")
|
||||
assert summary.market_context["pipeline_mode"] == "v3"
|
||||
|
||||
|
||||
def test_annotate_pipeline_mode_heuristic():
|
||||
"""_annotate_pipeline_mode sets pipeline_mode correctly for heuristic."""
|
||||
from services.shared.schemas import TrendDirection, TrendSummary, TrendWindow
|
||||
|
||||
summary = TrendSummary(
|
||||
entity_type="company",
|
||||
entity_id="AAPL",
|
||||
window=TrendWindow.SEVEN_DAY,
|
||||
trend_direction=TrendDirection.NEUTRAL,
|
||||
trend_strength=0.0,
|
||||
confidence=0.0,
|
||||
top_supporting_evidence=[],
|
||||
top_opposing_evidence=[],
|
||||
dominant_catalysts=[],
|
||||
material_risks=[],
|
||||
contradiction_score=0.0,
|
||||
disagreement_details=[],
|
||||
generated_at=_NOW,
|
||||
)
|
||||
summary.market_context = {}
|
||||
|
||||
_annotate_pipeline_mode(summary, "heuristic")
|
||||
assert summary.market_context["pipeline_mode"] == "heuristic"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 3: v3 metadata contains expected fields
|
||||
# Requirements: 20.1, 20.2, 20.3
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_v3_metadata_contains_expected_fields():
|
||||
"""Run through pipeline and verify output metadata dict contains v3 fields."""
|
||||
# Build a simple pipeline run
|
||||
signals = [
|
||||
_make_signal(sentiment="positive", impact=0.7, source_id="a1"),
|
||||
_make_signal(sentiment="negative", impact=0.4, source_id="a2", event_type="regulatory"),
|
||||
]
|
||||
units = [normalize_company_signal(s) for s in signals]
|
||||
units = [u for u in units if u is not None]
|
||||
|
||||
neutral_stats = SourceStats(source_id="default", hits=0, misses=0)
|
||||
q_values = []
|
||||
for unit in units:
|
||||
rel = compute_v3_reliability(
|
||||
unit=unit,
|
||||
source_stats=neutral_stats,
|
||||
cluster_position=0,
|
||||
reference_time=_NOW,
|
||||
)
|
||||
q_values.append(rel.q_i)
|
||||
|
||||
llrs = [compute_llr(u, q) for u, q in zip(units, q_values)]
|
||||
clusters = cluster_evidence(units, llrs)
|
||||
for c in clusters:
|
||||
c.n_eff = compute_n_eff(c.llrs)
|
||||
c.cluster_llr = compute_cluster_llr(c.llrs, c.n_eff)
|
||||
|
||||
posterior = compute_v3_posterior(clusters, _DEFAULT_REGIME, p_prior=0.50)
|
||||
contradiction = compute_v3_contradiction(clusters)
|
||||
data_quality = compute_v3_data_quality(
|
||||
units=units,
|
||||
extraction_failure_rate=0.0,
|
||||
age_newest_hours=0.5,
|
||||
n_source_types=1,
|
||||
)
|
||||
confidence = compute_v3_confidence(
|
||||
n_eff_total=posterior.n_eff_total,
|
||||
q_values=q_values,
|
||||
llrs=llrs,
|
||||
strength=posterior.strength,
|
||||
regime_confidence_mult=_DEFAULT_REGIME.confidence_multiplier,
|
||||
contradiction=contradiction,
|
||||
data_quality=data_quality,
|
||||
)
|
||||
|
||||
# Build the v3 metadata dict (mirrors _run_v3_pipeline logic)
|
||||
v3_metadata = {
|
||||
"v3_posterior": {
|
||||
"p_up": round(posterior.p_up, 6),
|
||||
"p_down": round(posterior.p_down, 6),
|
||||
"log_odds": round(posterior.log_odds, 6),
|
||||
"strength": round(posterior.strength, 6),
|
||||
"confidence": round(confidence, 6),
|
||||
"contradiction": round(contradiction, 6),
|
||||
"n_eff": round(posterior.n_eff_total, 4),
|
||||
"data_quality": round(data_quality, 6),
|
||||
"regime": posterior.regime,
|
||||
},
|
||||
"pipeline_mode": "v3",
|
||||
"explainability": {
|
||||
"top_positive_clusters": [],
|
||||
"top_negative_clusters": [],
|
||||
"suppression_reasons": [],
|
||||
"risk_adjustments": [],
|
||||
},
|
||||
}
|
||||
|
||||
# Verify all required v3_posterior keys exist
|
||||
required_posterior_keys = {
|
||||
"p_up", "p_down", "log_odds", "strength",
|
||||
"confidence", "contradiction", "n_eff", "data_quality", "regime",
|
||||
}
|
||||
assert set(v3_metadata["v3_posterior"].keys()) == required_posterior_keys
|
||||
|
||||
# Verify top-level metadata keys
|
||||
assert "pipeline_mode" in v3_metadata
|
||||
assert v3_metadata["pipeline_mode"] == "v3"
|
||||
assert "explainability" in v3_metadata
|
||||
|
||||
# Verify explainability structure
|
||||
explainability = v3_metadata["explainability"]
|
||||
assert "top_positive_clusters" in explainability
|
||||
assert "top_negative_clusters" in explainability
|
||||
assert "suppression_reasons" in explainability
|
||||
assert "risk_adjustments" in explainability
|
||||
|
||||
# Verify numeric ranges
|
||||
p = v3_metadata["v3_posterior"]
|
||||
assert 0.0 < p["p_up"] < 1.0
|
||||
assert 0.0 < p["p_down"] < 1.0
|
||||
assert abs(p["p_up"] + p["p_down"] - 1.0) < 1e-5
|
||||
assert 0.0 <= p["strength"] <= 1.0
|
||||
assert 0.0 <= p["confidence"] <= 1.0
|
||||
assert 0.0 <= p["contradiction"] <= 1.0
|
||||
assert 0.0 <= p["data_quality"] <= 1.0
|
||||
assert p["regime"] in ("panic", "trend_following", "mean_reversion", "uncertainty")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 4: Heuristic fallback function exists and is callable
|
||||
# Requirements: 19.2, 19.4
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_heuristic_fallback_function_exists():
|
||||
"""Verify the heuristic fallback function exists and is callable."""
|
||||
from services.aggregation.worker import _aggregate_company_heuristic
|
||||
|
||||
assert callable(_aggregate_company_heuristic)
|
||||
|
||||
|
||||
def test_v3_read_flag_function_exists():
|
||||
"""Verify the _read_v3_flag async function exists and is callable."""
|
||||
from services.aggregation.worker import _read_v3_flag
|
||||
|
||||
assert callable(_read_v3_flag)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 5: should_force_informational_v3 routing
|
||||
# Requirements: 19.3, 19.4
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_force_informational_low_data_quality():
|
||||
"""Low data quality should force informational mode."""
|
||||
units = [
|
||||
normalize_company_signal(_make_signal(source_id="x1")),
|
||||
normalize_company_signal(_make_signal(source_id="x2")),
|
||||
]
|
||||
units = [u for u in units if u is not None]
|
||||
|
||||
should_force, reason = should_force_informational_v3(
|
||||
data_quality=0.3,
|
||||
units=units,
|
||||
extraction_failure_rate=0.0,
|
||||
)
|
||||
assert should_force is True
|
||||
assert reason == "data_quality_below_threshold"
|
||||
|
||||
|
||||
def test_no_force_informational_good_quality():
|
||||
"""Good data quality with sufficient evidence should NOT force informational."""
|
||||
units = [
|
||||
normalize_company_signal(_make_signal(source_id="x1")),
|
||||
normalize_company_signal(_make_signal(source_id="x2")),
|
||||
normalize_company_signal(_make_signal(source_id="x3")),
|
||||
]
|
||||
units = [u for u in units if u is not None]
|
||||
|
||||
should_force, reason = should_force_informational_v3(
|
||||
data_quality=0.75,
|
||||
units=units,
|
||||
extraction_failure_rate=0.1,
|
||||
)
|
||||
assert should_force is False
|
||||
assert reason == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 6: Pipeline with all-neutral signals produces neutral posterior
|
||||
# Requirements: 20.1, 20.4
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_neutral_signals_produce_neutral_posterior():
|
||||
"""All neutral signals should produce posterior at P_up ≈ 0.50."""
|
||||
signals = [
|
||||
_make_signal(sentiment="neutral", impact=0.5, source_id="n1"),
|
||||
_make_signal(sentiment="neutral", impact=0.3, source_id="n2"),
|
||||
]
|
||||
units = [normalize_company_signal(s) for s in signals]
|
||||
units = [u for u in units if u is not None]
|
||||
|
||||
neutral_stats = SourceStats(source_id="default", hits=0, misses=0)
|
||||
q_values = []
|
||||
for unit in units:
|
||||
rel = compute_v3_reliability(
|
||||
unit=unit, source_stats=neutral_stats,
|
||||
cluster_position=0, reference_time=_NOW,
|
||||
)
|
||||
q_values.append(rel.q_i)
|
||||
|
||||
llrs = [compute_llr(u, q) for u, q in zip(units, q_values)]
|
||||
# All neutral → all LLRs should be 0
|
||||
assert all(llr == 0.0 for llr in llrs), "Neutral signals should produce zero LLR"
|
||||
|
||||
clusters = cluster_evidence(units, llrs)
|
||||
for c in clusters:
|
||||
c.n_eff = compute_n_eff(c.llrs)
|
||||
c.cluster_llr = compute_cluster_llr(c.llrs, c.n_eff)
|
||||
|
||||
posterior = compute_v3_posterior(clusters, _DEFAULT_REGIME, p_prior=0.50)
|
||||
assert abs(posterior.p_up - 0.50) < 1e-6, (
|
||||
f"Neutral evidence should maintain prior, got P_up={posterior.p_up}"
|
||||
)
|
||||
assert posterior.direction == "neutral"
|
||||
@@ -0,0 +1,359 @@
|
||||
"""Unit tests for v3 macro and competitive layers.
|
||||
|
||||
Tests the noisy-OR normalized macro exposure, resilience dampener,
|
||||
macro LLR computation, shrunk correlation convergence, competitive LLR
|
||||
clamping, and graph-distance attenuation.
|
||||
|
||||
Requirements validated: 9.1–9.5, 10.1–10.5
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from services.aggregation.interpolation import (
|
||||
compute_macro_llr,
|
||||
compute_normalized_macro_exposure,
|
||||
)
|
||||
from services.aggregation.signal_propagation import (
|
||||
compute_competitive_llr,
|
||||
compute_shrunk_correlation,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Noisy-OR: compute_normalized_macro_exposure
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNoisyORExposure:
|
||||
"""Tests for noisy-OR normalized macro exposure (Req 9.1, 9.2, 9.3)."""
|
||||
|
||||
def test_all_overlaps_max_regional(self):
|
||||
"""All O_k = 1.0 with regional tier → E_macro = 1.0."""
|
||||
overlaps = {"geo": 1.0, "supply": 1.0, "commodity": 1.0, "sector": 1.0}
|
||||
result = compute_normalized_macro_exposure(overlaps, tier="regional")
|
||||
assert result == pytest.approx(1.0, abs=1e-9)
|
||||
|
||||
def test_all_overlaps_zero(self):
|
||||
"""All O_k = 0 → E_macro = 0.0."""
|
||||
overlaps = {"geo": 0.0, "supply": 0.0, "commodity": 0.0, "sector": 0.0}
|
||||
result = compute_normalized_macro_exposure(overlaps, tier="regional")
|
||||
assert result == pytest.approx(0.0, abs=1e-9)
|
||||
|
||||
def test_empty_overlaps(self):
|
||||
"""Empty overlaps dict → E_macro = 0.0."""
|
||||
result = compute_normalized_macro_exposure({}, tier="regional")
|
||||
assert result == pytest.approx(0.0, abs=1e-9)
|
||||
|
||||
def test_single_dimension_geo(self):
|
||||
"""Only geo overlap → partial exposure."""
|
||||
overlaps = {"geo": 1.0}
|
||||
result = compute_normalized_macro_exposure(overlaps, tier="regional")
|
||||
# E_raw = 1 - (1-0.35*1)(1-0.25*0)(1-0.25*0)(1-0.15*0) = 1 - 0.65 = 0.35
|
||||
# E_max = 1 - (0.65)(0.75)(0.75)(0.85) = 1 - 0.311484375 ≈ 0.688515625
|
||||
# E_macro = 0.35 / 0.688515625 ≈ 0.508
|
||||
expected_e_raw = 0.35
|
||||
e_max = 1.0 - (0.65 * 0.75 * 0.75 * 0.85)
|
||||
expected = expected_e_raw / e_max
|
||||
assert result == pytest.approx(expected, rel=1e-6)
|
||||
|
||||
def test_partial_overlaps(self):
|
||||
"""Partial overlaps produce intermediate exposure."""
|
||||
overlaps = {"geo": 0.5, "supply": 0.3, "commodity": 0.0, "sector": 0.8}
|
||||
result = compute_normalized_macro_exposure(overlaps, tier="regional")
|
||||
# Should be between 0 and 1
|
||||
assert 0.0 < result < 1.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resilience dampener per tier
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResilienceDampener:
|
||||
"""Tests for resilience dampener application (Req 9.3)."""
|
||||
|
||||
def test_global_leader_dampener(self):
|
||||
"""Global leader tier dampens exposure by 0.70."""
|
||||
overlaps = {"geo": 1.0, "supply": 1.0, "commodity": 1.0, "sector": 1.0}
|
||||
result = compute_normalized_macro_exposure(overlaps, tier="global_leader")
|
||||
# E_macro = 1.0 * 0.70 = 0.70
|
||||
assert result == pytest.approx(0.70, abs=1e-9)
|
||||
|
||||
def test_multinational_dampener(self):
|
||||
"""Multinational tier dampens exposure by 0.85."""
|
||||
overlaps = {"geo": 1.0, "supply": 1.0, "commodity": 1.0, "sector": 1.0}
|
||||
result = compute_normalized_macro_exposure(overlaps, tier="multinational")
|
||||
assert result == pytest.approx(0.85, abs=1e-9)
|
||||
|
||||
def test_regional_dampener(self):
|
||||
"""Regional tier has no dampening (1.00)."""
|
||||
overlaps = {"geo": 1.0, "supply": 1.0, "commodity": 1.0, "sector": 1.0}
|
||||
result = compute_normalized_macro_exposure(overlaps, tier="regional")
|
||||
assert result == pytest.approx(1.00, abs=1e-9)
|
||||
|
||||
def test_domestic_amplifier(self):
|
||||
"""Domestic tier amplifies exposure by 1.20."""
|
||||
overlaps = {"geo": 1.0, "supply": 1.0, "commodity": 1.0, "sector": 1.0}
|
||||
result = compute_normalized_macro_exposure(overlaps, tier="domestic")
|
||||
assert result == pytest.approx(1.20, abs=1e-9)
|
||||
|
||||
def test_unknown_tier_no_dampening(self):
|
||||
"""Unknown tier defaults to 1.0 dampener."""
|
||||
overlaps = {"geo": 1.0, "supply": 1.0, "commodity": 1.0, "sector": 1.0}
|
||||
result = compute_normalized_macro_exposure(overlaps, tier="unknown_tier")
|
||||
assert result == pytest.approx(1.00, abs=1e-9)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Macro LLR at boundary values
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMacroLLR:
|
||||
"""Tests for macro LLR computation (Req 9.4, 9.5)."""
|
||||
|
||||
def test_max_positive_inputs(self):
|
||||
"""macro_impact=1, event_conf=1, q_recency=1, direction=+1 → p_macro=0.80."""
|
||||
llr = compute_macro_llr(
|
||||
macro_impact=1.0,
|
||||
event_confidence=1.0,
|
||||
q_recency=1.0,
|
||||
macro_direction=1,
|
||||
)
|
||||
# p_macro = 0.50 + 0.30*1*1*1 = 0.80
|
||||
expected = math.log(0.80 / 0.20) # ≈ 1.386
|
||||
assert llr == pytest.approx(expected, rel=1e-6)
|
||||
|
||||
def test_max_negative_inputs(self):
|
||||
"""All max with direction=-1 → negative LLR."""
|
||||
llr = compute_macro_llr(
|
||||
macro_impact=1.0,
|
||||
event_confidence=1.0,
|
||||
q_recency=1.0,
|
||||
macro_direction=-1,
|
||||
)
|
||||
expected = -math.log(0.80 / 0.20)
|
||||
assert llr == pytest.approx(expected, rel=1e-6)
|
||||
|
||||
def test_neutral_direction_zero_llr(self):
|
||||
"""direction=0 → LLR=0.0 regardless of other inputs."""
|
||||
llr = compute_macro_llr(
|
||||
macro_impact=1.0,
|
||||
event_confidence=1.0,
|
||||
q_recency=1.0,
|
||||
macro_direction=0,
|
||||
)
|
||||
assert llr == 0.0
|
||||
|
||||
def test_minimum_p_macro_clamp(self):
|
||||
"""All impact factors zero → p_macro clamped to 0.501."""
|
||||
llr = compute_macro_llr(
|
||||
macro_impact=0.0,
|
||||
event_confidence=0.0,
|
||||
q_recency=0.0,
|
||||
macro_direction=1,
|
||||
)
|
||||
# p_macro = 0.50 + 0 = 0.50, clamped up to 0.501
|
||||
expected = math.log(0.501 / (1.0 - 0.501))
|
||||
assert llr == pytest.approx(expected, rel=1e-6)
|
||||
|
||||
def test_mid_range_inputs(self):
|
||||
"""Intermediate inputs produce reasonable LLR."""
|
||||
llr = compute_macro_llr(
|
||||
macro_impact=0.5,
|
||||
event_confidence=0.7,
|
||||
q_recency=0.8,
|
||||
macro_direction=1,
|
||||
)
|
||||
# p_macro = 0.50 + 0.30 * 0.5 * 0.7 * 0.8 = 0.50 + 0.084 = 0.584
|
||||
p_macro = 0.584
|
||||
expected = math.log(p_macro / (1.0 - p_macro))
|
||||
assert llr == pytest.approx(expected, rel=1e-6)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shrunk correlation convergence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestShrunkCorrelation:
|
||||
"""Tests for shrinkage-adjusted correlation (Req 10.1, 10.2)."""
|
||||
|
||||
def test_large_n_approaches_rho_rolling(self):
|
||||
"""n=1000 with same_sector → result ≈ rho_rolling."""
|
||||
rho_rolling = 0.65
|
||||
result = compute_shrunk_correlation(
|
||||
rho_rolling=rho_rolling,
|
||||
n_observations=1000,
|
||||
same_sector=True,
|
||||
)
|
||||
# (1000/1030) × 0.65 + (30/1030) × 0.30 ≈ 0.6311 + 0.00874 ≈ 0.6398
|
||||
weight_data = 1000 / 1030
|
||||
weight_prior = 30 / 1030
|
||||
expected = weight_data * rho_rolling + weight_prior * 0.30
|
||||
assert result == pytest.approx(expected, rel=1e-6)
|
||||
# Should be close to rho_rolling
|
||||
assert abs(result - rho_rolling) < 0.02
|
||||
|
||||
def test_zero_observations_returns_prior(self):
|
||||
"""n=0 → result = prior (same_sector: 0.30, cross_sector: 0.10)."""
|
||||
# same sector
|
||||
result_same = compute_shrunk_correlation(
|
||||
rho_rolling=0.9,
|
||||
n_observations=0,
|
||||
same_sector=True,
|
||||
)
|
||||
# (0/30) × 0.9 + (30/30) × 0.30 = 0.30
|
||||
assert result_same == pytest.approx(0.30, abs=1e-9)
|
||||
|
||||
# cross sector
|
||||
result_cross = compute_shrunk_correlation(
|
||||
rho_rolling=0.9,
|
||||
n_observations=0,
|
||||
same_sector=False,
|
||||
)
|
||||
# (0/30) × 0.9 + (30/30) × 0.10 = 0.10
|
||||
assert result_cross == pytest.approx(0.10, abs=1e-9)
|
||||
|
||||
def test_cross_sector_prior(self):
|
||||
"""Cross-sector uses prior = 0.10."""
|
||||
result = compute_shrunk_correlation(
|
||||
rho_rolling=0.50,
|
||||
n_observations=30,
|
||||
same_sector=False,
|
||||
)
|
||||
# (30/60) × 0.50 + (30/60) × 0.10 = 0.25 + 0.05 = 0.30
|
||||
assert result == pytest.approx(0.30, abs=1e-9)
|
||||
|
||||
def test_negative_rolling_floored_at_zero(self):
|
||||
"""Negative rolling correlation → rho_effective floored at 0."""
|
||||
result = compute_shrunk_correlation(
|
||||
rho_rolling=-0.50,
|
||||
n_observations=100,
|
||||
same_sector=False,
|
||||
)
|
||||
# (100/130)×(-0.50) + (30/130)×0.10 = -0.3846 + 0.0231 ≈ -0.3615
|
||||
# Floored at 0
|
||||
assert result == 0.0
|
||||
|
||||
def test_n_30_equal_weight(self):
|
||||
"""n=30 → data and prior have equal weight."""
|
||||
rho_rolling = 0.80
|
||||
result = compute_shrunk_correlation(
|
||||
rho_rolling=rho_rolling,
|
||||
n_observations=30,
|
||||
same_sector=True,
|
||||
)
|
||||
# (30/60)×0.80 + (30/60)×0.30 = 0.40 + 0.15 = 0.55
|
||||
assert result == pytest.approx(0.55, abs=1e-9)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Competitive LLR clamp at ±1.25
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCompetitiveLLRClamp:
|
||||
"""Tests for competitive LLR clamping (Req 10.3, 10.4, 10.5)."""
|
||||
|
||||
def test_large_positive_clamped(self):
|
||||
"""Large positive inputs → clamped to +1.25."""
|
||||
result = compute_competitive_llr(
|
||||
llr_source=10.0,
|
||||
rho_effective=0.9,
|
||||
d_network=1,
|
||||
pattern_confidence=1.0,
|
||||
)
|
||||
assert result == pytest.approx(1.25, abs=1e-9)
|
||||
|
||||
def test_large_negative_clamped(self):
|
||||
"""Large negative inputs → clamped to -1.25."""
|
||||
result = compute_competitive_llr(
|
||||
llr_source=-10.0,
|
||||
rho_effective=0.9,
|
||||
d_network=1,
|
||||
pattern_confidence=1.0,
|
||||
)
|
||||
assert result == pytest.approx(-1.25, abs=1e-9)
|
||||
|
||||
def test_within_bounds_not_clamped(self):
|
||||
"""Small inputs produce unclamped result."""
|
||||
# attenuation = 0.5 × exp(-0.85 × 1) ≈ 0.5 × 0.4274 ≈ 0.2137
|
||||
# LLR_competitive = 1.0 × 0.2137 × 0.8 ≈ 0.1710
|
||||
result = compute_competitive_llr(
|
||||
llr_source=1.0,
|
||||
rho_effective=0.5,
|
||||
d_network=1,
|
||||
pattern_confidence=0.8,
|
||||
)
|
||||
expected = 1.0 * 0.5 * math.exp(-0.85 * 1) * 0.8
|
||||
assert result == pytest.approx(expected, rel=1e-6)
|
||||
assert abs(result) < 1.25
|
||||
|
||||
def test_zero_rho_gives_zero(self):
|
||||
"""Zero correlation → zero competitive LLR."""
|
||||
result = compute_competitive_llr(
|
||||
llr_source=5.0,
|
||||
rho_effective=0.0,
|
||||
d_network=1,
|
||||
pattern_confidence=1.0,
|
||||
)
|
||||
assert result == pytest.approx(0.0, abs=1e-9)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Distance > 3 → zero attenuation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDistanceAttenuation:
|
||||
"""Tests for graph distance cutoff (Req 10.5)."""
|
||||
|
||||
def test_distance_4_returns_zero(self):
|
||||
"""d_network=4 → LLR_competitive = 0.0."""
|
||||
result = compute_competitive_llr(
|
||||
llr_source=5.0,
|
||||
rho_effective=0.9,
|
||||
d_network=4,
|
||||
pattern_confidence=1.0,
|
||||
)
|
||||
assert result == 0.0
|
||||
|
||||
def test_distance_10_returns_zero(self):
|
||||
"""Very large distance → LLR_competitive = 0.0."""
|
||||
result = compute_competitive_llr(
|
||||
llr_source=5.0,
|
||||
rho_effective=0.9,
|
||||
d_network=10,
|
||||
pattern_confidence=1.0,
|
||||
)
|
||||
assert result == 0.0
|
||||
|
||||
def test_distance_3_still_active(self):
|
||||
"""d_network=3 (max allowed) → non-zero result."""
|
||||
result = compute_competitive_llr(
|
||||
llr_source=2.0,
|
||||
rho_effective=0.8,
|
||||
d_network=3,
|
||||
pattern_confidence=0.9,
|
||||
)
|
||||
# attenuation = 0.8 × exp(-0.85 × 3) ≈ 0.8 × 0.0776 ≈ 0.0621
|
||||
# LLR_competitive = 2.0 × 0.0621 × 0.9 ≈ 0.1118
|
||||
expected = 2.0 * 0.8 * math.exp(-0.85 * 3) * 0.9
|
||||
assert result == pytest.approx(expected, rel=1e-6)
|
||||
assert result != 0.0
|
||||
|
||||
def test_distance_1_strongest(self):
|
||||
"""d_network=1 gives strongest attenuation (least decay)."""
|
||||
result_d1 = compute_competitive_llr(
|
||||
llr_source=2.0, rho_effective=0.8, d_network=1, pattern_confidence=0.9,
|
||||
)
|
||||
result_d2 = compute_competitive_llr(
|
||||
llr_source=2.0, rho_effective=0.8, d_network=2, pattern_confidence=0.9,
|
||||
)
|
||||
result_d3 = compute_competitive_llr(
|
||||
llr_source=2.0, rho_effective=0.8, d_network=3, pattern_confidence=0.9,
|
||||
)
|
||||
assert result_d1 > result_d2 > result_d3 > 0.0
|
||||
@@ -0,0 +1,471 @@
|
||||
"""Unit tests for v3 posterior assembly and regime classification.
|
||||
|
||||
Tests for compute_v3_posterior and classify_regime_v3 functions.
|
||||
|
||||
Requirements validated: 5.1–5.7, 6.1–6.8
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from services.aggregation.bayesian import V3Posterior, compute_v3_posterior
|
||||
from services.aggregation.regime import (
|
||||
_V3_REGIME_PARAMS,
|
||||
MarketRegime,
|
||||
V3RegimeClassification,
|
||||
classify_regime_v3,
|
||||
)
|
||||
from services.aggregation.worker import EvidenceCluster
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_cluster(cluster_llr: float, n_eff: float = 1.0) -> EvidenceCluster:
|
||||
"""Create a minimal EvidenceCluster with given LLR and n_eff."""
|
||||
return EvidenceCluster(
|
||||
cluster_id="test", units=[], llrs=[], n_eff=n_eff, cluster_llr=cluster_llr
|
||||
)
|
||||
|
||||
|
||||
def _make_regime(
|
||||
regime: MarketRegime = MarketRegime.UNCERTAINTY, gamma: float = 0.80
|
||||
) -> V3RegimeClassification:
|
||||
"""Create a V3RegimeClassification with specified regime and gamma."""
|
||||
return V3RegimeClassification(
|
||||
regime=regime,
|
||||
trend_z=0.0,
|
||||
vol_ratio=1.0,
|
||||
evidence_multiplier=gamma,
|
||||
confidence_multiplier=0.85,
|
||||
phi_decay=0.50,
|
||||
atr_multiplier=2.0,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: Empty clusters → P_up = 0.50 (neutral prior)
|
||||
# Requirements: 5.1, 5.3
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEmptyClusters:
|
||||
"""No evidence → log_odds = logit(0.50) = 0 → P_up = 0.50."""
|
||||
|
||||
def test_empty_clusters_gives_neutral_posterior(self):
|
||||
regime = _make_regime()
|
||||
result = compute_v3_posterior(clusters=[], regime=regime, p_prior=0.50)
|
||||
|
||||
assert isinstance(result, V3Posterior)
|
||||
assert result.p_up == pytest.approx(0.50, abs=1e-9)
|
||||
assert result.p_down == pytest.approx(0.50, abs=1e-9)
|
||||
assert result.log_odds == pytest.approx(0.0, abs=1e-9)
|
||||
assert result.strength == pytest.approx(0.0, abs=1e-9)
|
||||
assert result.direction == "neutral"
|
||||
assert result.n_eff_total == 0.0
|
||||
|
||||
def test_empty_clusters_with_default_prior(self):
|
||||
regime = _make_regime()
|
||||
result = compute_v3_posterior(clusters=[], regime=regime)
|
||||
|
||||
assert result.p_up == pytest.approx(0.50, abs=1e-9)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: All bullish clusters → P_up > 0.50
|
||||
# Requirements: 5.1, 5.2
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAllBullishClusters:
|
||||
"""Positive cluster LLRs with uncertainty regime (gamma=0.80) → P_up > 0.50."""
|
||||
|
||||
def test_bullish_clusters_give_p_up_above_half(self):
|
||||
clusters = [_make_cluster(1.0), _make_cluster(0.5)]
|
||||
regime = _make_regime(MarketRegime.UNCERTAINTY, gamma=0.80)
|
||||
|
||||
result = compute_v3_posterior(clusters=clusters, regime=regime, p_prior=0.50)
|
||||
|
||||
assert result.p_up > 0.50
|
||||
assert result.direction in ("bullish", "neutral") # depends on threshold
|
||||
assert result.log_odds > 0.0
|
||||
|
||||
def test_bullish_computes_correct_log_odds(self):
|
||||
"""Verify log_odds = logit(0.50) + gamma * sum(LLR_c)."""
|
||||
clusters = [_make_cluster(1.0), _make_cluster(0.5)]
|
||||
regime = _make_regime(MarketRegime.UNCERTAINTY, gamma=0.80)
|
||||
|
||||
result = compute_v3_posterior(clusters=clusters, regime=regime, p_prior=0.50)
|
||||
|
||||
expected_log_odds = 0.0 + 0.80 * (1.0 + 0.5) # = 1.2
|
||||
assert result.log_odds == pytest.approx(expected_log_odds, abs=1e-9)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: All bearish clusters → P_up < 0.50
|
||||
# Requirements: 5.1, 5.2
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAllBearishClusters:
|
||||
"""Negative cluster LLRs → P_up < 0.50."""
|
||||
|
||||
def test_bearish_clusters_give_p_up_below_half(self):
|
||||
clusters = [_make_cluster(-1.0), _make_cluster(-0.5)]
|
||||
regime = _make_regime(MarketRegime.UNCERTAINTY, gamma=0.80)
|
||||
|
||||
result = compute_v3_posterior(clusters=clusters, regime=regime, p_prior=0.50)
|
||||
|
||||
assert result.p_up < 0.50
|
||||
assert result.log_odds < 0.0
|
||||
|
||||
def test_bearish_computes_correct_log_odds(self):
|
||||
clusters = [_make_cluster(-1.0), _make_cluster(-0.5)]
|
||||
regime = _make_regime(MarketRegime.UNCERTAINTY, gamma=0.80)
|
||||
|
||||
result = compute_v3_posterior(clusters=clusters, regime=regime, p_prior=0.50)
|
||||
|
||||
expected_log_odds = 0.0 + 0.80 * (-1.0 + -0.5) # = -1.2
|
||||
assert result.log_odds == pytest.approx(expected_log_odds, abs=1e-9)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: Regime direction thresholds at boundary values
|
||||
# Requirements: 5.5
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDirectionThresholds:
|
||||
"""Test direction classification at panic regime boundaries (0.68/0.32)."""
|
||||
|
||||
def _compute_with_target_p_up(self, target_p_up: float) -> V3Posterior:
|
||||
"""Compute posterior that results in a specific P_up value.
|
||||
|
||||
We reverse-engineer the cluster LLR needed to produce the target P_up
|
||||
under panic regime with gamma=0.70 and p_prior=0.50.
|
||||
"""
|
||||
# logit(target) = logit(0.50) + gamma * cluster_llr
|
||||
# logit(target) = 0.0 + 0.70 * cluster_llr
|
||||
# cluster_llr = logit(target) / 0.70
|
||||
logit_target = math.log(target_p_up / (1.0 - target_p_up))
|
||||
cluster_llr = logit_target / 0.70
|
||||
|
||||
clusters = [_make_cluster(cluster_llr)]
|
||||
regime = _make_regime(MarketRegime.PANIC, gamma=0.70)
|
||||
return compute_v3_posterior(clusters=clusters, regime=regime, p_prior=0.50)
|
||||
|
||||
def test_panic_bullish_at_068(self):
|
||||
"""P_up = 0.68 → bullish in panic regime (threshold is 0.68)."""
|
||||
result = self._compute_with_target_p_up(0.68)
|
||||
assert result.p_up == pytest.approx(0.68, abs=1e-6)
|
||||
assert result.direction == "bullish"
|
||||
|
||||
def test_panic_neutral_at_067(self):
|
||||
"""P_up = 0.67 → neutral in panic regime (below 0.68 threshold)."""
|
||||
result = self._compute_with_target_p_up(0.67)
|
||||
assert result.p_up == pytest.approx(0.67, abs=1e-6)
|
||||
assert result.direction == "neutral"
|
||||
|
||||
def test_panic_bearish_at_032(self):
|
||||
"""P_up = 0.32 → bearish in panic regime (threshold is 0.32)."""
|
||||
result = self._compute_with_target_p_up(0.32)
|
||||
assert result.p_up == pytest.approx(0.32, abs=1e-6)
|
||||
assert result.direction == "bearish"
|
||||
|
||||
def test_panic_neutral_at_033(self):
|
||||
"""P_up = 0.33 → neutral in panic regime (above 0.32 threshold)."""
|
||||
result = self._compute_with_target_p_up(0.33)
|
||||
assert result.p_up == pytest.approx(0.33, abs=1e-6)
|
||||
assert result.direction == "neutral"
|
||||
|
||||
def test_trend_following_thresholds(self):
|
||||
"""Trend following thresholds: bullish >= 0.60, bearish <= 0.40."""
|
||||
# Bullish at 0.60
|
||||
logit_target = math.log(0.60 / 0.40)
|
||||
cluster_llr = logit_target / 1.10 # gamma for trend_following
|
||||
clusters = [_make_cluster(cluster_llr)]
|
||||
regime = _make_regime(MarketRegime.TREND_FOLLOWING, gamma=1.10)
|
||||
result = compute_v3_posterior(clusters=clusters, regime=regime, p_prior=0.50)
|
||||
assert result.p_up == pytest.approx(0.60, abs=1e-6)
|
||||
assert result.direction == "bullish"
|
||||
|
||||
def test_mean_reversion_thresholds(self):
|
||||
"""Mean reversion thresholds: bullish >= 0.63, bearish <= 0.37."""
|
||||
# Use slightly above 0.63 to avoid floating-point boundary issues
|
||||
target = 0.631
|
||||
logit_target = math.log(target / (1.0 - target))
|
||||
cluster_llr = logit_target / 0.90 # gamma for mean_reversion
|
||||
clusters = [_make_cluster(cluster_llr)]
|
||||
regime = _make_regime(MarketRegime.MEAN_REVERSION, gamma=0.90)
|
||||
result = compute_v3_posterior(clusters=clusters, regime=regime, p_prior=0.50)
|
||||
assert result.p_up >= 0.63
|
||||
assert result.direction == "bullish"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: Prior clamp [0.40, 0.60]
|
||||
# Requirements: 5.6
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPriorClamp:
|
||||
"""Prior values outside [0.40, 0.60] are clamped."""
|
||||
|
||||
def test_prior_below_040_clamped(self):
|
||||
"""p_prior=0.30 → clamped to 0.40."""
|
||||
regime = _make_regime()
|
||||
result = compute_v3_posterior(clusters=[], regime=regime, p_prior=0.30)
|
||||
|
||||
# logit(0.40) ≈ -0.4055
|
||||
expected_log_odds = math.log(0.40 / 0.60)
|
||||
assert result.log_odds == pytest.approx(expected_log_odds, abs=1e-9)
|
||||
# P_up should be 0.40 with no evidence
|
||||
assert result.p_up == pytest.approx(0.40, abs=1e-6)
|
||||
|
||||
def test_prior_above_060_clamped(self):
|
||||
"""p_prior=0.80 → clamped to 0.60."""
|
||||
regime = _make_regime()
|
||||
result = compute_v3_posterior(clusters=[], regime=regime, p_prior=0.80)
|
||||
|
||||
expected_log_odds = math.log(0.60 / 0.40)
|
||||
assert result.log_odds == pytest.approx(expected_log_odds, abs=1e-9)
|
||||
assert result.p_up == pytest.approx(0.60, abs=1e-6)
|
||||
|
||||
def test_prior_at_040_not_clamped(self):
|
||||
"""p_prior=0.40 is within bounds, no clamping."""
|
||||
regime = _make_regime()
|
||||
result = compute_v3_posterior(clusters=[], regime=regime, p_prior=0.40)
|
||||
assert result.p_up == pytest.approx(0.40, abs=1e-6)
|
||||
|
||||
def test_prior_at_060_not_clamped(self):
|
||||
"""p_prior=0.60 is within bounds, no clamping."""
|
||||
regime = _make_regime()
|
||||
result = compute_v3_posterior(clusters=[], regime=regime, p_prior=0.60)
|
||||
assert result.p_up == pytest.approx(0.60, abs=1e-6)
|
||||
|
||||
def test_prior_at_050_standard(self):
|
||||
"""p_prior=0.50 is standard neutral prior."""
|
||||
regime = _make_regime()
|
||||
result = compute_v3_posterior(clusters=[], regime=regime, p_prior=0.50)
|
||||
assert result.p_up == pytest.approx(0.50, abs=1e-9)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: Regime classification with known inputs
|
||||
# Requirements: 6.1–6.8
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegimeClassificationKnownInputs:
|
||||
"""Test classify_regime_v3 with known inputs mapping to specific regimes."""
|
||||
|
||||
def _make_prices_and_returns(
|
||||
self, n: int = 120
|
||||
) -> tuple[list[float], list[float]]:
|
||||
"""Generate flat price series and near-zero returns."""
|
||||
prices = [100.0] * n
|
||||
returns = [0.001] * n
|
||||
return prices, returns
|
||||
|
||||
def test_high_vol_ratio_triggers_panic(self):
|
||||
"""vol_ratio > 1.5 → panic."""
|
||||
# Generate returns where sigma_20 >> sigma_100
|
||||
# sigma_20 high, sigma_100 low → vol_ratio > 1.5
|
||||
prices = [100.0] * 120
|
||||
# Low-vol returns for sigma_100 context
|
||||
returns_low = [0.001] * 80
|
||||
# High-vol returns for sigma_20
|
||||
returns_high = [0.05, -0.05] * 10 # stdev ≈ 0.0526
|
||||
returns = returns_low + returns_high
|
||||
|
||||
# With flat prices, trend_z ≈ 0, but vol_ratio > 1.5 → panic
|
||||
result = classify_regime_v3(prices, returns, atr_20=1.0)
|
||||
assert result.regime == MarketRegime.PANIC
|
||||
|
||||
def test_trend_following_regime(self):
|
||||
"""trend_z >= 0.75, vol_ratio < 1.3 → trend_following.
|
||||
|
||||
|trend_z| >= 0.75 AND vol_ratio < 1.3 → trend_following.
|
||||
"""
|
||||
# Trending price series: EMA_20 significantly above EMA_100
|
||||
prices = [100.0 + i * 0.5 for i in range(120)]
|
||||
|
||||
# Varied returns with stable vol (sigma_20 ≈ sigma_100 → vol_ratio near 1.0)
|
||||
returns = [0.005 + (i % 5) * 0.001 for i in range(120)]
|
||||
|
||||
# ATR chosen so trend_z ≈ 1.77 (well above 0.75 threshold)
|
||||
result = classify_regime_v3(prices, returns, atr_20=10.0)
|
||||
|
||||
assert result.regime == MarketRegime.TREND_FOLLOWING
|
||||
assert abs(result.trend_z) >= 0.75
|
||||
assert result.vol_ratio < 1.3
|
||||
|
||||
def test_mean_reversion_regime(self):
|
||||
"""|trend_z| < 0.50 AND vol_ratio < 1.0 → mean_reversion."""
|
||||
# Flat prices → trend_z ≈ 0
|
||||
prices = [100.0] * 120
|
||||
|
||||
# Returns with decreasing volatility (sigma_20 < sigma_100)
|
||||
# High vol early, low vol recently
|
||||
returns_early = [0.03, -0.03] * 40 # high vol for sigma_100
|
||||
returns_recent = [0.001] * 40 # low vol for sigma_20
|
||||
returns = returns_early + returns_recent
|
||||
|
||||
# Large ATR so trend_z stays small
|
||||
result = classify_regime_v3(prices, returns, atr_20=50.0)
|
||||
|
||||
assert result.regime == MarketRegime.MEAN_REVERSION
|
||||
assert abs(result.trend_z) < 0.50
|
||||
assert result.vol_ratio < 1.0
|
||||
|
||||
def test_uncertainty_regime_default(self):
|
||||
"""When conditions don't match any specific regime → uncertainty.
|
||||
|
||||
|trend_z| between 0.50 and 0.75 OR vol_ratio between 1.0 and 1.3.
|
||||
"""
|
||||
# Mild trend + moderate vol → uncertainty
|
||||
# Slightly trending prices but not enough for trend_following
|
||||
prices = [100.0 + i * 0.1 for i in range(120)]
|
||||
|
||||
# Uniform returns → vol_ratio ≈ 1.0
|
||||
returns = [0.01] * 120
|
||||
|
||||
# ATR chosen so |trend_z| is between 0.50 and 0.75
|
||||
# We need to find ATR such that it lands in uncertainty
|
||||
# With mild trend, vol_ratio ≈ 1.0 (not < 1.0), so mean_reversion won't fire
|
||||
# And trend_z might be < 0.75, so trend_following won't fire
|
||||
result = classify_regime_v3(prices, returns, atr_20=5.0)
|
||||
|
||||
# With uniform returns, stdev is 0 → sigma_100 = 0
|
||||
# We need non-trivial returns. Let's use a better approach.
|
||||
# Use returns that give vol_ratio between 1.0 and 1.3
|
||||
returns_varied = [0.01 + (i % 3) * 0.002 for i in range(120)]
|
||||
result = classify_regime_v3(prices, returns_varied, atr_20=5.0)
|
||||
|
||||
# This should fall through to uncertainty since conditions are moderate
|
||||
assert result.regime == MarketRegime.UNCERTAINTY
|
||||
|
||||
def test_data_insufficient_returns_uncertainty(self):
|
||||
"""Fewer than 100 closing prices → default uncertainty."""
|
||||
prices = [100.0] * 50 # < 100
|
||||
returns = [0.01] * 50
|
||||
result = classify_regime_v3(prices, returns, atr_20=1.0)
|
||||
|
||||
assert result.regime == MarketRegime.UNCERTAINTY
|
||||
assert result.trend_z == 0.0
|
||||
assert result.vol_ratio == 1.0
|
||||
assert result.evidence_multiplier == 0.80
|
||||
|
||||
def test_atr_zero_returns_uncertainty(self):
|
||||
"""ATR_20 <= 0 → default uncertainty (Req 6.8)."""
|
||||
prices = [100.0] * 120
|
||||
returns = [0.01] * 120
|
||||
result = classify_regime_v3(prices, returns, atr_20=0.0)
|
||||
|
||||
assert result.regime == MarketRegime.UNCERTAINTY
|
||||
|
||||
def test_insufficient_returns_data(self):
|
||||
"""Fewer than 100 daily returns → default uncertainty."""
|
||||
prices = [100.0] * 120
|
||||
returns = [0.01] * 50 # < 100
|
||||
result = classify_regime_v3(prices, returns, atr_20=1.0)
|
||||
|
||||
assert result.regime == MarketRegime.UNCERTAINTY
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: Regime parameters are correctly assigned
|
||||
# Requirements: 6.6, 6.7
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegimeParameters:
|
||||
"""Verify regime parameters (gamma, conf_mult, phi, atr_mult) are assigned."""
|
||||
|
||||
def test_panic_parameters(self):
|
||||
gamma, conf_mult, phi, atr_mult, min_edge = _V3_REGIME_PARAMS[
|
||||
MarketRegime.PANIC
|
||||
]
|
||||
assert gamma == 0.70
|
||||
assert conf_mult == 0.70
|
||||
assert phi == 0.35
|
||||
assert atr_mult == 2.5
|
||||
assert min_edge == 0.0100
|
||||
|
||||
def test_trend_following_parameters(self):
|
||||
gamma, conf_mult, phi, atr_mult, min_edge = _V3_REGIME_PARAMS[
|
||||
MarketRegime.TREND_FOLLOWING
|
||||
]
|
||||
assert gamma == 1.10
|
||||
assert conf_mult == 1.00
|
||||
assert phi == 0.80
|
||||
assert atr_mult == 1.8
|
||||
assert min_edge == 0.0035
|
||||
|
||||
def test_mean_reversion_parameters(self):
|
||||
gamma, conf_mult, phi, atr_mult, min_edge = _V3_REGIME_PARAMS[
|
||||
MarketRegime.MEAN_REVERSION
|
||||
]
|
||||
assert gamma == 0.90
|
||||
assert conf_mult == 0.95
|
||||
assert phi == 0.55
|
||||
assert atr_mult == 1.4
|
||||
assert min_edge == 0.0050
|
||||
|
||||
def test_uncertainty_parameters(self):
|
||||
gamma, conf_mult, phi, atr_mult, min_edge = _V3_REGIME_PARAMS[
|
||||
MarketRegime.UNCERTAINTY
|
||||
]
|
||||
assert gamma == 0.80
|
||||
assert conf_mult == 0.85
|
||||
assert phi == 0.50
|
||||
assert atr_mult == 2.0
|
||||
assert min_edge == 0.0075
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: Posterior output fields
|
||||
# Requirements: 5.4, 5.5
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPosteriorOutputFields:
|
||||
"""Verify derived fields (strength, n_eff_total, regime) are correct."""
|
||||
|
||||
def test_strength_computed_correctly(self):
|
||||
"""strength = abs(2 × P_up - 1)."""
|
||||
clusters = [_make_cluster(1.5)]
|
||||
regime = _make_regime(MarketRegime.UNCERTAINTY, gamma=0.80)
|
||||
result = compute_v3_posterior(clusters=clusters, regime=regime, p_prior=0.50)
|
||||
|
||||
expected_strength = abs(2.0 * result.p_up - 1.0)
|
||||
assert result.strength == pytest.approx(expected_strength, abs=1e-9)
|
||||
|
||||
def test_n_eff_total_sums_clusters(self):
|
||||
"""n_eff_total = sum of cluster n_eff values."""
|
||||
clusters = [
|
||||
_make_cluster(0.5, n_eff=2.0),
|
||||
_make_cluster(0.3, n_eff=1.5),
|
||||
_make_cluster(-0.2, n_eff=3.0),
|
||||
]
|
||||
regime = _make_regime()
|
||||
result = compute_v3_posterior(clusters=clusters, regime=regime)
|
||||
|
||||
assert result.n_eff_total == pytest.approx(6.5, abs=1e-9)
|
||||
|
||||
def test_regime_string_matches_input(self):
|
||||
"""regime field should match the input regime's value."""
|
||||
regime = _make_regime(MarketRegime.PANIC, gamma=0.70)
|
||||
result = compute_v3_posterior(clusters=[], regime=regime)
|
||||
assert result.regime == "panic"
|
||||
|
||||
def test_p_down_is_complement(self):
|
||||
"""p_down = 1 - p_up."""
|
||||
clusters = [_make_cluster(0.8)]
|
||||
regime = _make_regime()
|
||||
result = compute_v3_posterior(clusters=clusters, regime=regime)
|
||||
|
||||
assert result.p_down == pytest.approx(1.0 - result.p_up, abs=1e-10)
|
||||
@@ -0,0 +1,314 @@
|
||||
"""Unit tests for v3 stop-defined portfolio heat and risk tier auto-adjustment.
|
||||
|
||||
Validates: Requirements 15.1–15.5, 18.1–18.6
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from services.risk.engine import (
|
||||
TierMetrics,
|
||||
check_heat_capacity,
|
||||
compute_available_heat_capacity,
|
||||
compute_portfolio_heat,
|
||||
evaluate_tier_adjustment,
|
||||
)
|
||||
|
||||
# ===========================================================================
|
||||
# Heat computation tests (Requirements 15.1, 15.2)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestComputePortfolioHeat:
|
||||
"""Test stop-defined portfolio heat calculation."""
|
||||
|
||||
def test_heat_three_positions_known_stops(self):
|
||||
"""3 positions with known stops → expected heat = 710.
|
||||
|
||||
risk_dollars = position_value × stop_distance_pct
|
||||
AAPL: 10000 × 0.03 = 300
|
||||
MSFT: 5000 × 0.05 = 250
|
||||
GOOG: 8000 × 0.02 = 160
|
||||
Total heat = 710
|
||||
"""
|
||||
positions = [
|
||||
{"ticker": "AAPL", "position_value": 10000},
|
||||
{"ticker": "MSFT", "position_value": 5000},
|
||||
{"ticker": "GOOG", "position_value": 8000},
|
||||
]
|
||||
stop_distances = {"AAPL": 0.03, "MSFT": 0.05, "GOOG": 0.02}
|
||||
|
||||
heat = compute_portfolio_heat(positions, stop_distances)
|
||||
assert heat == pytest.approx(710.0)
|
||||
|
||||
def test_heat_empty_positions(self):
|
||||
"""No positions → zero heat."""
|
||||
heat = compute_portfolio_heat([], {})
|
||||
assert heat == 0.0
|
||||
|
||||
def test_heat_missing_stop_distance_defaults_to_zero(self):
|
||||
"""Position with no stop distance entry contributes zero risk."""
|
||||
positions = [{"ticker": "AAPL", "position_value": 10000}]
|
||||
stop_distances = {} # no entry for AAPL
|
||||
|
||||
heat = compute_portfolio_heat(positions, stop_distances)
|
||||
assert heat == 0.0
|
||||
|
||||
def test_heat_single_position(self):
|
||||
"""Single position → risk = value × stop."""
|
||||
positions = [{"ticker": "TSLA", "position_value": 20000}]
|
||||
stop_distances = {"TSLA": 0.04}
|
||||
|
||||
heat = compute_portfolio_heat(positions, stop_distances)
|
||||
assert heat == pytest.approx(800.0)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Heat capacity / rejection tests (Requirements 15.3, 15.4, 15.5)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestCheckHeatCapacity:
|
||||
"""Test heat capacity check — rejection when exceeding limit."""
|
||||
|
||||
def test_heat_at_limit_rejects_new_entry(self):
|
||||
"""current_heat=4500, new_risk=600, max_heat=5000 → rejected (False).
|
||||
|
||||
4500 + 600 = 5100 > 5000 → False
|
||||
"""
|
||||
result = check_heat_capacity(
|
||||
current_heat=4500,
|
||||
new_risk_dollars=600,
|
||||
max_heat_pct=0.05,
|
||||
portfolio_value=100000,
|
||||
)
|
||||
assert result is False
|
||||
|
||||
def test_heat_within_limit_allows_entry(self):
|
||||
"""current_heat=4000, new_risk=500, max_heat=5000 → allowed (True).
|
||||
|
||||
4000 + 500 = 4500 <= 5000 → True
|
||||
"""
|
||||
result = check_heat_capacity(
|
||||
current_heat=4000,
|
||||
new_risk_dollars=500,
|
||||
max_heat_pct=0.05,
|
||||
portfolio_value=100000,
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_heat_exactly_at_limit_allows_entry(self):
|
||||
"""current_heat=4500, new_risk=500, max_heat=5000 → allowed (True).
|
||||
|
||||
4500 + 500 = 5000 <= 5000 → True (at boundary)
|
||||
"""
|
||||
result = check_heat_capacity(
|
||||
current_heat=4500,
|
||||
new_risk_dollars=500,
|
||||
max_heat_pct=0.05,
|
||||
portfolio_value=100000,
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_heat_zero_portfolio_rejects(self):
|
||||
"""Zero portfolio value → max_heat = 0 → any new risk rejected."""
|
||||
result = check_heat_capacity(
|
||||
current_heat=0,
|
||||
new_risk_dollars=100,
|
||||
max_heat_pct=0.05,
|
||||
portfolio_value=0,
|
||||
)
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestComputeAvailableHeatCapacity:
|
||||
"""Test available heat capacity computation."""
|
||||
|
||||
def test_available_capacity_normal(self):
|
||||
"""max_heat=5000, current=3000 → available=2000."""
|
||||
available = compute_available_heat_capacity(
|
||||
current_heat=3000,
|
||||
max_heat_pct=0.05,
|
||||
portfolio_value=100000,
|
||||
)
|
||||
assert available == pytest.approx(2000.0)
|
||||
|
||||
def test_available_capacity_fully_used(self):
|
||||
"""current >= max → available = 0."""
|
||||
available = compute_available_heat_capacity(
|
||||
current_heat=5500,
|
||||
max_heat_pct=0.05,
|
||||
portfolio_value=100000,
|
||||
)
|
||||
assert available == 0.0
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Tier downgrade tests (Requirements 18.2, 18.3, 18.5)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestTierDowngrade:
|
||||
"""Test that a single bad metric triggers downgrade."""
|
||||
|
||||
def _good_metrics(self, **overrides) -> TierMetrics:
|
||||
"""Create metrics that pass all upgrade conditions, then override."""
|
||||
defaults = {
|
||||
"profit_factor_30d": 1.5,
|
||||
"max_drawdown_30d": 0.03,
|
||||
"calibration_error": 0.08,
|
||||
"realized_sharpe_30d": 1.5,
|
||||
"n_trades_30d": 25,
|
||||
"reserve_pool_pct": 0.25,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return TierMetrics(**defaults)
|
||||
|
||||
def test_downgrade_low_profit_factor(self):
|
||||
"""profit_factor=0.9 (< 1.0) → downgrade."""
|
||||
metrics = self._good_metrics(profit_factor_30d=0.9)
|
||||
assert evaluate_tier_adjustment(metrics) == "downgrade"
|
||||
|
||||
def test_downgrade_high_drawdown(self):
|
||||
"""max_drawdown=0.15 (> 0.12) → downgrade."""
|
||||
metrics = self._good_metrics(max_drawdown_30d=0.15)
|
||||
assert evaluate_tier_adjustment(metrics) == "downgrade"
|
||||
|
||||
def test_downgrade_high_calibration_error(self):
|
||||
"""calibration_error=0.25 (> 0.20) → downgrade."""
|
||||
metrics = self._good_metrics(calibration_error=0.25)
|
||||
assert evaluate_tier_adjustment(metrics) == "downgrade"
|
||||
|
||||
def test_downgrade_negative_sharpe(self):
|
||||
"""sharpe=-0.5 (< 0) → downgrade."""
|
||||
metrics = self._good_metrics(realized_sharpe_30d=-0.5)
|
||||
assert evaluate_tier_adjustment(metrics) == "downgrade"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Tier upgrade tests (Requirements 18.4)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestTierUpgrade:
|
||||
"""Test that all metrics must be good for upgrade."""
|
||||
|
||||
def test_upgrade_all_good(self):
|
||||
"""All upgrade conditions met → upgrade.
|
||||
|
||||
profit_factor=1.5, drawdown=0.03, cal_error=0.08,
|
||||
sharpe=1.5, n_trades=25, reserve=0.25
|
||||
"""
|
||||
metrics = TierMetrics(
|
||||
profit_factor_30d=1.5,
|
||||
max_drawdown_30d=0.03,
|
||||
calibration_error=0.08,
|
||||
realized_sharpe_30d=1.5,
|
||||
n_trades_30d=25,
|
||||
reserve_pool_pct=0.25,
|
||||
)
|
||||
assert evaluate_tier_adjustment(metrics) == "upgrade"
|
||||
|
||||
def test_hold_insufficient_trades(self):
|
||||
"""All upgrade conditions met EXCEPT n_trades=15 (< 20) → hold."""
|
||||
metrics = TierMetrics(
|
||||
profit_factor_30d=1.5,
|
||||
max_drawdown_30d=0.03,
|
||||
calibration_error=0.08,
|
||||
realized_sharpe_30d=1.5,
|
||||
n_trades_30d=15,
|
||||
reserve_pool_pct=0.25,
|
||||
)
|
||||
assert evaluate_tier_adjustment(metrics) == "hold"
|
||||
|
||||
def test_hold_insufficient_reserve(self):
|
||||
"""All good except reserve=0.15 (< 0.20) → hold."""
|
||||
metrics = TierMetrics(
|
||||
profit_factor_30d=1.5,
|
||||
max_drawdown_30d=0.03,
|
||||
calibration_error=0.08,
|
||||
realized_sharpe_30d=1.5,
|
||||
n_trades_30d=25,
|
||||
reserve_pool_pct=0.15,
|
||||
)
|
||||
assert evaluate_tier_adjustment(metrics) == "hold"
|
||||
|
||||
def test_hold_drawdown_too_high_for_upgrade(self):
|
||||
"""drawdown=0.06 (> 0.05 for upgrade) but < 0.12 (no downgrade) → hold."""
|
||||
metrics = TierMetrics(
|
||||
profit_factor_30d=1.5,
|
||||
max_drawdown_30d=0.06,
|
||||
calibration_error=0.08,
|
||||
realized_sharpe_30d=1.5,
|
||||
n_trades_30d=25,
|
||||
reserve_pool_pct=0.25,
|
||||
)
|
||||
assert evaluate_tier_adjustment(metrics) == "hold"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 7-day cooldown enforcement (Requirements 18.5, 18.6)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestTierCooldown:
|
||||
"""Test 7-day cooldown enforcement logic at the caller level.
|
||||
|
||||
The evaluate_tier_adjustment function is pure — it doesn't track state.
|
||||
The 7-day cooldown is enforced by the caller. Here we verify the pure
|
||||
logic that a caller would use: compare last_upgrade_time to now and
|
||||
only allow upgrade if >= 7 days have passed.
|
||||
"""
|
||||
|
||||
def test_cooldown_blocks_upgrade_within_7_days(self):
|
||||
"""Upgrade blocked when last upgrade was < 7 days ago."""
|
||||
last_upgrade = datetime(2024, 1, 10, tzinfo=timezone.utc)
|
||||
now = datetime(2024, 1, 15, tzinfo=timezone.utc) # 5 days later
|
||||
cooldown_days = 7
|
||||
|
||||
days_since_last = (now - last_upgrade).days
|
||||
can_upgrade = days_since_last >= cooldown_days
|
||||
|
||||
assert can_upgrade is False
|
||||
|
||||
def test_cooldown_allows_upgrade_after_7_days(self):
|
||||
"""Upgrade allowed when last upgrade was >= 7 days ago."""
|
||||
last_upgrade = datetime(2024, 1, 10, tzinfo=timezone.utc)
|
||||
now = datetime(2024, 1, 17, tzinfo=timezone.utc) # 7 days later
|
||||
cooldown_days = 7
|
||||
|
||||
days_since_last = (now - last_upgrade).days
|
||||
can_upgrade = days_since_last >= cooldown_days
|
||||
|
||||
assert can_upgrade is True
|
||||
|
||||
def test_cooldown_allows_upgrade_well_past_7_days(self):
|
||||
"""Upgrade allowed when last upgrade was well past cooldown."""
|
||||
last_upgrade = datetime(2024, 1, 1, tzinfo=timezone.utc)
|
||||
now = datetime(2024, 2, 1, tzinfo=timezone.utc) # 31 days later
|
||||
cooldown_days = 7
|
||||
|
||||
days_since_last = (now - last_upgrade).days
|
||||
can_upgrade = days_since_last >= cooldown_days
|
||||
|
||||
assert can_upgrade is True
|
||||
|
||||
def test_downgrade_ignores_cooldown(self):
|
||||
"""Downgrade is applied immediately regardless of cooldown.
|
||||
|
||||
Even if an upgrade happened yesterday, downgrade still fires.
|
||||
"""
|
||||
# The evaluate function doesn't have cooldown logic — it always
|
||||
# returns "downgrade" when conditions are met, regardless of timing.
|
||||
metrics = TierMetrics(
|
||||
profit_factor_30d=0.8, # triggers downgrade
|
||||
max_drawdown_30d=0.03,
|
||||
calibration_error=0.08,
|
||||
realized_sharpe_30d=1.5,
|
||||
n_trades_30d=25,
|
||||
reserve_pool_pct=0.25,
|
||||
)
|
||||
# Downgrade fires regardless of when last upgrade occurred
|
||||
assert evaluate_tier_adjustment(metrics) == "downgrade"
|
||||
@@ -0,0 +1,403 @@
|
||||
"""Unit tests for v3 fractional Kelly sizing and regime-aware stops.
|
||||
|
||||
Tests Kelly fraction computation, capacity cap enforcement, position minimum
|
||||
downgrade, stop/take-profit levels, and trailing stop monotonicity.
|
||||
|
||||
Requirements validated: 14.1–14.7, 16.1–16.5
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from services.trading.position_sizer import (
|
||||
KellySizingResult,
|
||||
compute_kelly_sizing,
|
||||
compute_reward_ratio,
|
||||
)
|
||||
from services.trading.stop_loss_manager import (
|
||||
TrailingStopResult,
|
||||
V3StopLevels,
|
||||
compute_trailing_stop,
|
||||
compute_v3_stops,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kelly sizing: negative edge → size = 0 (Req 14.7)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestKellyNegativeEdge:
|
||||
"""Negative Kelly fraction forces zero sizing and downgrade."""
|
||||
|
||||
def test_p_win_03_b_2_negative_edge(self):
|
||||
"""p_win=0.3, b=2.0 → f_kelly = (0.3*2 - 0.7)/2 = -0.05 → downgrade."""
|
||||
result = compute_kelly_sizing(
|
||||
p_win=0.3,
|
||||
b=2.0,
|
||||
confidence=0.8,
|
||||
data_quality=0.9,
|
||||
contradiction=0.1,
|
||||
max_position_pct=0.10,
|
||||
available_caps={"sector_capacity": 0.10, "correlation_capacity": 0.10, "heat_capacity": 0.10},
|
||||
)
|
||||
assert isinstance(result, KellySizingResult)
|
||||
assert result.f_kelly == pytest.approx(-0.05, abs=1e-9)
|
||||
assert result.portfolio_pct == 0.0
|
||||
assert result.downgrade is True
|
||||
assert result.downgrade_reason == "negative_edge"
|
||||
|
||||
def test_p_win_05_b_1_zero_edge(self):
|
||||
"""p_win=0.5, b=1.0 → f_kelly = (0.5*1 - 0.5)/1 = 0.0 → downgrade."""
|
||||
result = compute_kelly_sizing(
|
||||
p_win=0.5,
|
||||
b=1.0,
|
||||
confidence=0.8,
|
||||
data_quality=0.9,
|
||||
contradiction=0.1,
|
||||
max_position_pct=0.10,
|
||||
available_caps={},
|
||||
)
|
||||
assert result.f_kelly == pytest.approx(0.0, abs=1e-9)
|
||||
assert result.portfolio_pct == 0.0
|
||||
assert result.downgrade is True
|
||||
assert result.downgrade_reason == "negative_edge"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kelly sizing: positive edge → bounded size (Req 14.1–14.4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestKellyPositiveEdge:
|
||||
"""Positive Kelly fraction produces a sized position within caps."""
|
||||
|
||||
def test_p_win_07_b_2_positive_size(self):
|
||||
"""p_win=0.7, b=2.0 → f_kelly = (1.4-0.3)/2 = 0.55 → positive size."""
|
||||
confidence = 0.8
|
||||
data_quality = 0.9
|
||||
contradiction = 0.1
|
||||
result = compute_kelly_sizing(
|
||||
p_win=0.7,
|
||||
b=2.0,
|
||||
confidence=confidence,
|
||||
data_quality=data_quality,
|
||||
contradiction=contradiction,
|
||||
max_position_pct=0.10,
|
||||
available_caps={"sector_capacity": 0.10, "correlation_capacity": 0.10, "heat_capacity": 0.10},
|
||||
)
|
||||
assert result.f_kelly == pytest.approx(0.55, abs=1e-9)
|
||||
# portfolio_pct = 0.55 * 0.25 * 0.8 * 0.9 * (1 - 0.1) = 0.55 * 0.25 * 0.8 * 0.9 * 0.9
|
||||
expected_raw = 0.55 * 0.25 * confidence * data_quality * (1.0 - contradiction)
|
||||
# Clamped to max_position_pct = 0.10
|
||||
expected_pct = min(expected_raw, 0.10)
|
||||
assert result.portfolio_pct == pytest.approx(expected_pct, abs=1e-9)
|
||||
assert result.portfolio_pct > 0.0
|
||||
assert result.portfolio_pct <= 0.10
|
||||
assert result.downgrade is False
|
||||
assert result.downgrade_reason == ""
|
||||
|
||||
def test_high_confidence_respects_max_cap(self):
|
||||
"""Even with strong edge, portfolio_pct cannot exceed max_position_pct."""
|
||||
result = compute_kelly_sizing(
|
||||
p_win=0.9,
|
||||
b=3.0,
|
||||
confidence=1.0,
|
||||
data_quality=1.0,
|
||||
contradiction=0.0,
|
||||
max_position_pct=0.05,
|
||||
available_caps={},
|
||||
)
|
||||
assert result.portfolio_pct <= 0.05
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cap enforcement: sector, correlation, heat (Req 14.5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCapEnforcement:
|
||||
"""Capacity constraints cap portfolio_pct."""
|
||||
|
||||
def test_sector_capacity_caps(self):
|
||||
"""sector_capacity=0.02 caps portfolio_pct at 0.02."""
|
||||
result = compute_kelly_sizing(
|
||||
p_win=0.7,
|
||||
b=2.0,
|
||||
confidence=0.8,
|
||||
data_quality=0.9,
|
||||
contradiction=0.1,
|
||||
max_position_pct=0.10,
|
||||
available_caps={"sector_capacity": 0.02, "correlation_capacity": 0.10, "heat_capacity": 0.10},
|
||||
)
|
||||
assert result.portfolio_pct <= 0.02
|
||||
|
||||
def test_correlation_capacity_zero_forces_zero(self):
|
||||
"""correlation_capacity=0.0 (avg corr > 0.80) → portfolio_pct = 0."""
|
||||
result = compute_kelly_sizing(
|
||||
p_win=0.7,
|
||||
b=2.0,
|
||||
confidence=0.8,
|
||||
data_quality=0.9,
|
||||
contradiction=0.1,
|
||||
max_position_pct=0.10,
|
||||
available_caps={"sector_capacity": 0.10, "correlation_capacity": 0.0, "heat_capacity": 0.10},
|
||||
)
|
||||
# correlation_capacity=0 → min(pct, 0) = 0 → below minimum → downgrade
|
||||
assert result.portfolio_pct == 0.0
|
||||
assert result.downgrade is True
|
||||
assert result.downgrade_reason == "position_below_minimum"
|
||||
|
||||
def test_heat_capacity_caps(self):
|
||||
"""heat_capacity=0.01 caps portfolio_pct at 0.01."""
|
||||
result = compute_kelly_sizing(
|
||||
p_win=0.7,
|
||||
b=2.0,
|
||||
confidence=0.8,
|
||||
data_quality=0.9,
|
||||
contradiction=0.1,
|
||||
max_position_pct=0.10,
|
||||
available_caps={"sector_capacity": 0.10, "correlation_capacity": 0.10, "heat_capacity": 0.01},
|
||||
)
|
||||
assert result.portfolio_pct <= 0.01
|
||||
|
||||
def test_minimum_of_all_caps(self):
|
||||
"""portfolio_pct is min of all capacity constraints."""
|
||||
result = compute_kelly_sizing(
|
||||
p_win=0.7,
|
||||
b=2.0,
|
||||
confidence=0.8,
|
||||
data_quality=0.9,
|
||||
contradiction=0.1,
|
||||
max_position_pct=0.10,
|
||||
available_caps={"sector_capacity": 0.03, "correlation_capacity": 0.05, "heat_capacity": 0.04},
|
||||
)
|
||||
# Minimum cap is sector_capacity=0.03
|
||||
assert result.portfolio_pct <= 0.03
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Position below minimum → downgrade (Req 14.6)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPositionBelowMinimum:
|
||||
"""Tiny positions below 0.005 trigger a downgrade."""
|
||||
|
||||
def test_tiny_position_downgrade(self):
|
||||
"""Low data_quality and high contradiction → pct < 0.005 → downgrade."""
|
||||
result = compute_kelly_sizing(
|
||||
p_win=0.55,
|
||||
b=1.5,
|
||||
confidence=0.3,
|
||||
data_quality=0.3,
|
||||
contradiction=0.7,
|
||||
max_position_pct=0.10,
|
||||
available_caps={},
|
||||
)
|
||||
# f_kelly = (0.55*1.5 - 0.45)/1.5 = (0.825-0.45)/1.5 = 0.25
|
||||
# raw = 0.25 * 0.25 * 0.3 * 0.3 * 0.3 = 0.0016875 < 0.005
|
||||
assert result.portfolio_pct == 0.0
|
||||
assert result.downgrade is True
|
||||
assert result.downgrade_reason == "position_below_minimum"
|
||||
|
||||
def test_just_above_minimum_no_downgrade(self):
|
||||
"""Position at or above 0.005 is not downgraded."""
|
||||
result = compute_kelly_sizing(
|
||||
p_win=0.7,
|
||||
b=2.0,
|
||||
confidence=0.8,
|
||||
data_quality=0.9,
|
||||
contradiction=0.1,
|
||||
max_position_pct=0.10,
|
||||
available_caps={},
|
||||
)
|
||||
# f_kelly=0.55, raw=0.55*0.25*0.8*0.9*0.9 = 0.0891 >> 0.005
|
||||
assert result.portfolio_pct >= 0.005
|
||||
assert result.downgrade is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reward ratio computation (Req 14.2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRewardRatio:
|
||||
"""Tests compute_reward_ratio clamping and formula."""
|
||||
|
||||
def test_reward_ratio_typical(self):
|
||||
"""Typical values produce a ratio between 1.2 and 3.0."""
|
||||
b = compute_reward_ratio(confidence=0.7, strength=0.5, contradiction=0.2)
|
||||
# raw = 1.2 + 2.0*0.7 + 1.0*0.5 - 0.2 = 1.2 + 1.4 + 0.5 - 0.2 = 2.9
|
||||
assert b == pytest.approx(2.9, abs=1e-9)
|
||||
|
||||
def test_reward_ratio_clamp_low(self):
|
||||
"""Low confidence/strength and high contradiction → clamped to 1.2."""
|
||||
b = compute_reward_ratio(confidence=0.0, strength=0.0, contradiction=1.0)
|
||||
# raw = 1.2 + 0 + 0 - 1.0 = 0.2 → clamped to 1.2
|
||||
assert b == pytest.approx(1.2, abs=1e-9)
|
||||
|
||||
def test_reward_ratio_clamp_high(self):
|
||||
"""High confidence/strength → clamped to 3.0."""
|
||||
b = compute_reward_ratio(confidence=1.0, strength=1.0, contradiction=0.0)
|
||||
# raw = 1.2 + 2.0 + 1.0 - 0 = 4.2 → clamped to 3.0
|
||||
assert b == pytest.approx(3.0, abs=1e-9)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stop/TP computation with known inputs (Req 16.1–16.3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestV3Stops:
|
||||
"""Tests for regime-aware stop loss and take profit computation."""
|
||||
|
||||
def test_known_inputs(self):
|
||||
"""entry=100, ATR_pct=0.02, regime_mult=2.0, sigma_h=0.04, b=2.0."""
|
||||
result = compute_v3_stops(
|
||||
entry_price=100.0,
|
||||
atr_pct=0.02,
|
||||
regime_atr_mult=2.0,
|
||||
sigma_h=0.04,
|
||||
reward_ratio=2.0,
|
||||
)
|
||||
assert isinstance(result, V3StopLevels)
|
||||
# stop_distance = max(0.02*2.0, 0.04*1.25, 0.005) = max(0.04, 0.05, 0.005) = 0.05
|
||||
assert result.stop_distance_pct == pytest.approx(0.05, abs=1e-9)
|
||||
# stop = 100 * (1 - 0.05) = 95
|
||||
assert result.stop_loss == pytest.approx(95.0, abs=1e-9)
|
||||
# TP = 100 * (1 + 2.0 * 0.05) = 110
|
||||
assert result.take_profit == pytest.approx(110.0, abs=1e-9)
|
||||
assert result.reward_ratio == pytest.approx(2.0, abs=1e-9)
|
||||
|
||||
def test_min_stop_distance_enforced(self):
|
||||
"""Very low ATR and sigma → min stop distance of 0.005 is enforced."""
|
||||
result = compute_v3_stops(
|
||||
entry_price=50.0,
|
||||
atr_pct=0.001,
|
||||
regime_atr_mult=1.0,
|
||||
sigma_h=0.001,
|
||||
reward_ratio=2.0,
|
||||
)
|
||||
# max(0.001*1.0, 0.001*1.25, 0.005) = 0.005
|
||||
assert result.stop_distance_pct == pytest.approx(0.005, abs=1e-9)
|
||||
assert result.stop_loss == pytest.approx(50.0 * (1 - 0.005), abs=1e-9)
|
||||
|
||||
def test_atr_dominates(self):
|
||||
"""High ATR*mult dominates stop distance."""
|
||||
result = compute_v3_stops(
|
||||
entry_price=200.0,
|
||||
atr_pct=0.05,
|
||||
regime_atr_mult=2.5,
|
||||
sigma_h=0.03,
|
||||
reward_ratio=1.5,
|
||||
)
|
||||
# max(0.05*2.5, 0.03*1.25, 0.005) = max(0.125, 0.0375, 0.005) = 0.125
|
||||
assert result.stop_distance_pct == pytest.approx(0.125, abs=1e-9)
|
||||
assert result.stop_loss == pytest.approx(200.0 * (1 - 0.125), abs=1e-9)
|
||||
assert result.take_profit == pytest.approx(200.0 * (1 + 1.5 * 0.125), abs=1e-9)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trailing stop monotonicity (Req 16.4–16.5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTrailingStopMonotonicity:
|
||||
"""Tests trailing stop activation and non-decreasing behavior."""
|
||||
|
||||
def test_not_activated_below_threshold(self):
|
||||
"""Gain < 50% of TP distance → trailing not activated."""
|
||||
result = compute_trailing_stop(
|
||||
existing_stop=95.0,
|
||||
current_price=101.0, # Gain = 1.0, TP distance = 10, 1.0 < 0.5*10
|
||||
entry_price=100.0,
|
||||
take_profit=110.0,
|
||||
atr_pct=0.02,
|
||||
trailing_atr_mult=1.5,
|
||||
sigma_h=0.04,
|
||||
)
|
||||
assert isinstance(result, TrailingStopResult)
|
||||
assert result.activated is False
|
||||
assert result.trailing_stop == 95.0 # Unchanged
|
||||
|
||||
def test_activated_above_threshold(self):
|
||||
"""Gain >= 50% of TP distance → trailing activated."""
|
||||
result = compute_trailing_stop(
|
||||
existing_stop=95.0,
|
||||
current_price=105.0, # Gain = 5.0, TP distance = 10, 5.0 >= 0.5*10
|
||||
entry_price=100.0,
|
||||
take_profit=110.0,
|
||||
atr_pct=0.02,
|
||||
trailing_atr_mult=1.5,
|
||||
sigma_h=0.04,
|
||||
)
|
||||
assert result.activated is True
|
||||
# trailing_distance = max(0.02*1.5, 0.04*0.75) = max(0.03, 0.03) = 0.03
|
||||
# candidate = 105 * (1 - 0.03) = 101.85
|
||||
# max(95.0, 101.85) = 101.85
|
||||
assert result.trailing_stop == pytest.approx(105.0 * (1 - 0.03), abs=1e-9)
|
||||
assert result.trailing_stop > 95.0
|
||||
|
||||
def test_monotonicity_over_price_sequence(self):
|
||||
"""Trailing stop never decreases over a price sequence."""
|
||||
# Setup: entry=100, TP=110, existing_stop=95
|
||||
entry = 100.0
|
||||
take_profit = 110.0
|
||||
atr_pct = 0.02
|
||||
trailing_atr_mult = 1.5
|
||||
sigma_h = 0.04
|
||||
prices = [102.0, 105.0, 103.0, 108.0]
|
||||
|
||||
current_stop = 95.0
|
||||
stops_recorded = [current_stop]
|
||||
|
||||
for price in prices:
|
||||
result = compute_trailing_stop(
|
||||
existing_stop=current_stop,
|
||||
current_price=price,
|
||||
entry_price=entry,
|
||||
take_profit=take_profit,
|
||||
atr_pct=atr_pct,
|
||||
trailing_atr_mult=trailing_atr_mult,
|
||||
sigma_h=sigma_h,
|
||||
)
|
||||
current_stop = result.trailing_stop
|
||||
stops_recorded.append(current_stop)
|
||||
|
||||
# Verify monotonically non-decreasing
|
||||
for i in range(1, len(stops_recorded)):
|
||||
assert stops_recorded[i] >= stops_recorded[i - 1], (
|
||||
f"Stop decreased at step {i}: {stops_recorded[i]} < {stops_recorded[i-1]}"
|
||||
)
|
||||
|
||||
def test_trailing_stop_never_below_existing(self):
|
||||
"""Even with price drop, trailing stop stays at existing_stop."""
|
||||
result = compute_trailing_stop(
|
||||
existing_stop=102.0,
|
||||
current_price=105.0,
|
||||
entry_price=100.0,
|
||||
take_profit=110.0,
|
||||
atr_pct=0.05, # Large ATR → candidate might be below existing
|
||||
trailing_atr_mult=2.0,
|
||||
sigma_h=0.08,
|
||||
)
|
||||
# trailing_distance = max(0.05*2.0, 0.08*0.75) = max(0.10, 0.06) = 0.10
|
||||
# candidate = 105 * (1 - 0.10) = 94.5
|
||||
# max(102.0, 94.5) = 102.0
|
||||
assert result.trailing_stop == pytest.approx(102.0, abs=1e-9)
|
||||
assert result.activated is True
|
||||
|
||||
def test_exact_50pct_threshold_activates(self):
|
||||
"""Gain exactly at 50% of TP distance activates trailing."""
|
||||
# TP distance = 10, so gain must be >= 5.0
|
||||
result = compute_trailing_stop(
|
||||
existing_stop=95.0,
|
||||
current_price=105.0, # Gain = 5.0 = 0.50 * 10
|
||||
entry_price=100.0,
|
||||
take_profit=110.0,
|
||||
atr_pct=0.02,
|
||||
trailing_atr_mult=1.5,
|
||||
sigma_h=0.04,
|
||||
)
|
||||
assert result.activated is True
|
||||
Reference in New Issue
Block a user