feat: math core v3 engine upgrade

This commit is contained in:
Celes Renata
2026-06-27 12:21:41 +00:00
parent 365bc5d4b7
commit b4bf0f2361
34 changed files with 11693 additions and 3 deletions
+168
View File
@@ -0,0 +1,168 @@
"""Property-based tests for v3 correlation-aware clustering.
Feature: math-core-v3-engine
Uses Hypothesis to validate correctness properties of the v3 clustering
layer: effective evidence count n_eff is bounded by cluster size, and
cluster LLR is clamped to [-2.5, 2.5].
Validates: Requirements 4.2, 4.3, 4.4, 4.5, 21.4
"""
from __future__ import annotations
import math
from hypothesis import given, settings
from hypothesis import strategies as st
from services.aggregation.worker import (
EvidenceCluster,
cluster_evidence,
compute_cluster_llr,
compute_n_eff,
)
from services.aggregation.scoring import EvidenceUnit
# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------
# LLR values for testing
llr_values = st.floats(min_value=-5.0, max_value=5.0, allow_nan=False, allow_infinity=False)
llr_lists = st.lists(llr_values, min_size=1, max_size=20)
# Correlation values
rho_values = st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False)
def _symmetric_correlation_matrix(n: int) -> st.SearchStrategy[list[list[float]]]:
"""Generate an NxN symmetric correlation matrix with values in [0, 1].
Diagonal is 1.0, off-diagonal entries are symmetric rho in [0, 1].
"""
if n <= 1:
return st.just([[1.0]])
# Generate upper-triangle entries (n*(n-1)/2 values)
n_pairs = n * (n - 1) // 2
upper_triangle = st.lists(rho_values, min_size=n_pairs, max_size=n_pairs)
@st.composite
def build_matrix(draw: st.DrawFn) -> list[list[float]]:
entries = draw(upper_triangle)
matrix = [[0.0] * n for _ in range(n)]
idx = 0
for i in range(n):
matrix[i][i] = 1.0
for j in range(i + 1, n):
matrix[i][j] = entries[idx]
matrix[j][i] = entries[idx]
idx += 1
return matrix
return build_matrix()
# n_eff positive floats for property 6
n_eff_values = st.floats(min_value=0.1, max_value=20.0, allow_nan=False, allow_infinity=False)
# ---------------------------------------------------------------------------
# Property 5: Effective evidence count n_eff is bounded by cluster size
# Feature: math-core-v3-engine, Property 5: Effective evidence count n_eff is bounded by cluster size
# Validates: Requirements 4.2, 4.3, 21.4
# ---------------------------------------------------------------------------
@settings(max_examples=100)
@given(llrs=llr_lists)
def test_property_5_n_eff_bounded_by_cluster_size_default_correlations(
llrs: list[float],
) -> None:
"""Property 5: n_eff is bounded by cluster size (default correlations).
For any cluster of N signals using default pairwise correlations (rho=0.80),
the computed n_eff SHALL satisfy 0 < n_eff <= N.
**Validates: Requirements 4.2, 4.3**
"""
n = len(llrs)
n_eff = compute_n_eff(llrs)
assert n_eff > 0.0, f"n_eff={n_eff} must be positive"
assert n_eff <= n + 1e-9, f"n_eff={n_eff} exceeds cluster size N={n}"
@settings(max_examples=100)
@given(
data=st.data(),
llrs=st.lists(llr_values, min_size=2, max_size=10),
)
def test_property_5_n_eff_bounded_with_explicit_correlations(
data: st.DataObject,
llrs: list[float],
) -> None:
"""Property 5: n_eff is bounded by cluster size (explicit correlation matrix).
For any cluster of N signals with a symmetric NxN correlation matrix
with values in [0, 1], the computed n_eff SHALL satisfy 0 < n_eff <= N.
**Validates: Requirements 4.2, 4.3, 21.4**
"""
n = len(llrs)
corr_matrix = data.draw(_symmetric_correlation_matrix(n))
n_eff = compute_n_eff(llrs, correlations=corr_matrix)
assert n_eff > 0.0, f"n_eff={n_eff} must be positive"
assert n_eff <= n + 1e-9, f"n_eff={n_eff} exceeds cluster size N={n}"
# ---------------------------------------------------------------------------
# Property 6: Cluster LLR is clamped to [-2.5, 2.5]
# Feature: math-core-v3-engine, Property 6: Cluster LLR is clamped to [-2.5, 2.5]
# Validates: Requirements 4.4, 4.5
# ---------------------------------------------------------------------------
@settings(max_examples=100)
@given(
llrs=llr_lists,
n_eff=n_eff_values,
)
def test_property_6_cluster_llr_clamped(
llrs: list[float],
n_eff: float,
) -> None:
"""Property 6: Cluster LLR is clamped to [-2.5, 2.5].
For any cluster configuration with any number of signals and any LLR values,
the computed cluster LLR_c SHALL be in [-2.5, 2.5].
**Validates: Requirements 4.4, 4.5**
"""
cluster_llr = compute_cluster_llr(llrs, n_eff)
assert -2.5 <= cluster_llr <= 2.5, (
f"cluster_llr={cluster_llr} out of [-2.5, 2.5]"
)
@settings(max_examples=100)
@given(llrs=llr_lists)
def test_property_6_cluster_llr_clamped_with_computed_n_eff(
llrs: list[float],
) -> None:
"""Property 6: Cluster LLR is clamped when using computed n_eff.
End-to-end: compute n_eff from the LLRs, then compute cluster LLR.
The result SHALL still be in [-2.5, 2.5].
**Validates: Requirements 4.4, 4.5**
"""
n_eff = compute_n_eff(llrs)
cluster_llr = compute_cluster_llr(llrs, n_eff)
assert -2.5 <= cluster_llr <= 2.5, (
f"cluster_llr={cluster_llr} out of [-2.5, 2.5]"
)
+454
View File
@@ -0,0 +1,454 @@
"""Property-based tests for v3 Kelly sizing and regime-aware stops.
Validates:
- Property 11: Fractional Kelly sizing is bounded and respects negative edge
- Property 18: Stop loss is below entry price and take profit is above
- Property 19: Trailing stop never decreases
Requirements: 14.4, 14.7, 16.2, 16.3, 16.5, 21.8, 21.9
"""
from __future__ import annotations
from hypothesis import given, settings
from hypothesis import strategies as st
from services.trading.position_sizer import (
KellySizingResult,
compute_kelly_sizing,
compute_reward_ratio,
)
from services.trading.stop_loss_manager import (
TrailingStopResult,
V3StopLevels,
compute_trailing_stop,
compute_v3_stops,
)
# ---------------------------------------------------------------------------
# Strategies
# ---------------------------------------------------------------------------
# Kelly sizing inputs
p_up_values = st.floats(min_value=0.01, max_value=0.99, allow_nan=False, allow_infinity=False)
b_values = st.floats(min_value=1.2, max_value=3.0, allow_nan=False, allow_infinity=False)
confidence_values = st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False)
data_quality_values = st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False)
contradiction_values = st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False)
max_position_pct_values = st.floats(min_value=0.001, max_value=0.50, allow_nan=False, allow_infinity=False)
# Stop loss inputs
entry_price_values = st.floats(min_value=0.01, max_value=100000.0, allow_nan=False, allow_infinity=False)
stop_distance_pct_values = st.floats(min_value=0.005, max_value=0.999, allow_nan=False, allow_infinity=False)
reward_ratio_values = st.floats(min_value=1.2, max_value=5.0, allow_nan=False, allow_infinity=False)
# Trailing stop price sequences
price_values = st.floats(min_value=1.0, max_value=10000.0, allow_nan=False, allow_infinity=False)
atr_pct_values = st.floats(min_value=0.005, max_value=0.20, allow_nan=False, allow_infinity=False)
trailing_atr_mult_values = st.floats(min_value=0.5, max_value=3.0, allow_nan=False, allow_infinity=False)
sigma_h_values = st.floats(min_value=0.005, max_value=0.50, allow_nan=False, allow_infinity=False)
# ---------------------------------------------------------------------------
# Feature: math-core-v3-engine, Property 11: Fractional Kelly sizing is
# bounded and respects negative edge
# ---------------------------------------------------------------------------
# **Validates: Requirements 14.4, 14.7, 21.8, 21.9**
@settings(max_examples=100)
@given(
p_up=p_up_values,
b=b_values,
confidence=confidence_values,
data_quality=data_quality_values,
contradiction=contradiction_values,
max_position_pct=max_position_pct_values,
)
def test_property_11_kelly_sizing_bounded_and_respects_negative_edge(
p_up: float,
b: float,
confidence: float,
data_quality: float,
contradiction: float,
max_position_pct: float,
) -> None:
"""Property 11: Fractional Kelly sizing is bounded and respects negative edge.
For any valid inputs (P_up in (0,1), b in [1.2, 3.0], confidence in [0,1],
data_quality in [0,1], contradiction in [0,1], max_position_pct > 0),
the computed portfolio_pct SHALL be in [0, max_position_pct].
When f_kelly = (P_up * b - (1 - P_up)) / b <= 0, portfolio_pct SHALL be exactly 0.
"""
# Use generous capacity caps that don't constrain
available_caps = {
"sector_capacity": max_position_pct,
"correlation_capacity": max_position_pct,
"heat_capacity": max_position_pct,
}
result = compute_kelly_sizing(
p_win=p_up,
b=b,
confidence=confidence,
data_quality=data_quality,
contradiction=contradiction,
max_position_pct=max_position_pct,
available_caps=available_caps,
)
# portfolio_pct must be in [0, max_position_pct]
assert 0.0 <= result.portfolio_pct <= max_position_pct, (
f"portfolio_pct={result.portfolio_pct} not in [0, {max_position_pct}] "
f"for p_up={p_up}, b={b}, conf={confidence}, dq={data_quality}, "
f"contra={contradiction}"
)
# When f_kelly <= 0, portfolio_pct must be exactly 0
f_kelly = (p_up * b - (1.0 - p_up)) / b
if f_kelly <= 0:
assert result.portfolio_pct == 0.0, (
f"portfolio_pct={result.portfolio_pct} should be 0.0 when "
f"f_kelly={f_kelly} <= 0 (p_up={p_up}, b={b})"
)
assert result.downgrade is True, (
f"downgrade should be True when f_kelly={f_kelly} <= 0"
)
assert result.downgrade_reason == "negative_edge", (
f"downgrade_reason should be 'negative_edge' when f_kelly={f_kelly} <= 0, "
f"got '{result.downgrade_reason}'"
)
# ---------------------------------------------------------------------------
# Feature: math-core-v3-engine, Property 18: Stop loss is below entry price
# and take profit is above
# ---------------------------------------------------------------------------
# **Validates: Requirements 16.2, 16.3**
@settings(max_examples=100)
@given(
entry_price=entry_price_values,
atr_pct=atr_pct_values,
regime_atr_mult=st.floats(min_value=0.5, max_value=3.0, allow_nan=False, allow_infinity=False),
sigma_h=sigma_h_values,
reward_ratio=reward_ratio_values,
)
def test_property_18_stop_loss_below_entry_take_profit_above(
entry_price: float,
atr_pct: float,
regime_atr_mult: float,
sigma_h: float,
reward_ratio: float,
) -> None:
"""Property 18: Stop loss is below entry price and take profit is above.
For any entry_price > 0, stop_distance_pct in [0.005, 1.0), and reward
ratio b >= 1.2, the computed stop_loss SHALL be less than entry_price
and take_profit SHALL be greater than entry_price.
"""
result = compute_v3_stops(
entry_price=entry_price,
atr_pct=atr_pct,
regime_atr_mult=regime_atr_mult,
sigma_h=sigma_h,
reward_ratio=reward_ratio,
)
# stop_distance_pct should be at least 0.005 (the min floor)
assert result.stop_distance_pct >= 0.005, (
f"stop_distance_pct={result.stop_distance_pct} should be >= 0.005"
)
# Stop loss must be strictly below entry price
assert result.stop_loss < entry_price, (
f"stop_loss={result.stop_loss} should be < entry_price={entry_price} "
f"(stop_distance_pct={result.stop_distance_pct})"
)
# Take profit must be strictly above entry price
assert result.take_profit > entry_price, (
f"take_profit={result.take_profit} should be > entry_price={entry_price} "
f"(reward_ratio={reward_ratio}, stop_distance_pct={result.stop_distance_pct})"
)
# ---------------------------------------------------------------------------
# Feature: math-core-v3-engine, Property 19: Trailing stop never decreases
# ---------------------------------------------------------------------------
# **Validates: Requirements 16.5**
@settings(max_examples=100)
@given(
entry_price=st.floats(min_value=10.0, max_value=1000.0, allow_nan=False, allow_infinity=False),
atr_pct=atr_pct_values,
trailing_atr_mult=trailing_atr_mult_values,
sigma_h=sigma_h_values,
price_moves=st.lists(
st.floats(min_value=0.0, max_value=2.0, allow_nan=False, allow_infinity=False),
min_size=3,
max_size=20,
),
)
def test_property_19_trailing_stop_never_decreases(
entry_price: float,
atr_pct: float,
trailing_atr_mult: float,
sigma_h: float,
price_moves: list[float],
) -> None:
"""Property 19: Trailing stop never decreases.
For any sequence of current prices and trailing stop computations,
each new trailing_stop value SHALL be >= the previous trailing_stop
value (monotonically non-decreasing).
"""
# Set up a take profit that's achievable
reward_ratio = 2.0
stop_distance_pct = max(atr_pct * 1.5, sigma_h * 1.25, 0.005)
take_profit = entry_price * (1.0 + reward_ratio * stop_distance_pct)
# Start with an initial stop below entry
existing_stop = entry_price * (1.0 - stop_distance_pct)
# Generate a sequence of prices that move upward from entry
# (scaled by price_moves multiplied by stop distance to be meaningful)
previous_trailing_stop = existing_stop
for move_factor in price_moves:
# Price moves upward from entry by some fraction of TP distance
tp_distance = take_profit - entry_price
current_price = entry_price + move_factor * tp_distance
result = compute_trailing_stop(
existing_stop=previous_trailing_stop,
current_price=current_price,
entry_price=entry_price,
take_profit=take_profit,
atr_pct=atr_pct,
trailing_atr_mult=trailing_atr_mult,
sigma_h=sigma_h,
)
# The trailing stop must never decrease (monotonically non-decreasing)
assert result.trailing_stop >= previous_trailing_stop, (
f"trailing_stop={result.trailing_stop} decreased from "
f"previous={previous_trailing_stop} at current_price={current_price}, "
f"entry={entry_price}, tp={take_profit}"
)
# Update for next iteration
previous_trailing_stop = result.trailing_stop
# ---------------------------------------------------------------------------
# Additional imports for heat and tier adjustment tests
# ---------------------------------------------------------------------------
from services.risk.engine import (
TierMetrics,
check_heat_capacity,
compute_portfolio_heat,
evaluate_tier_adjustment,
)
# ---------------------------------------------------------------------------
# Strategies for heat and tier tests
# ---------------------------------------------------------------------------
# Portfolio heat inputs
position_value_strategy = st.floats(
min_value=100.0, max_value=1_000_000.0, allow_nan=False, allow_infinity=False
)
stop_distance_strategy = st.floats(
min_value=0.005, max_value=0.50, allow_nan=False, allow_infinity=False
)
max_heat_pct_strategy = st.floats(
min_value=0.01, max_value=0.50, allow_nan=False, allow_infinity=False
)
portfolio_value_strategy = st.floats(
min_value=10_000.0, max_value=10_000_000.0, allow_nan=False, allow_infinity=False
)
# Tier metrics inputs
profit_factor_strategy = st.floats(
min_value=0.0, max_value=5.0, allow_nan=False, allow_infinity=False
)
drawdown_strategy = st.floats(
min_value=0.0, max_value=0.50, allow_nan=False, allow_infinity=False
)
calibration_error_strategy = st.floats(
min_value=0.0, max_value=0.50, allow_nan=False, allow_infinity=False
)
sharpe_strategy = st.floats(
min_value=-3.0, max_value=5.0, allow_nan=False, allow_infinity=False
)
n_trades_strategy = st.integers(min_value=0, max_value=200)
reserve_pool_strategy = st.floats(
min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False
)
# ---------------------------------------------------------------------------
# Feature: math-core-v3-engine, Property 22: Portfolio heat rejection is
# correct
# ---------------------------------------------------------------------------
# **Validates: Requirements 15.3, 15.5**
@settings(max_examples=100)
@given(
position_values=st.lists(
position_value_strategy, min_size=1, max_size=10
),
stop_distances_list=st.lists(
stop_distance_strategy, min_size=1, max_size=10
),
new_position_value=position_value_strategy,
new_stop_distance=stop_distance_strategy,
max_heat_pct=max_heat_pct_strategy,
portfolio_value=portfolio_value_strategy,
)
def test_property_22_portfolio_heat_rejection_is_correct(
position_values: list[float],
stop_distances_list: list[float],
new_position_value: float,
new_stop_distance: float,
max_heat_pct: float,
portfolio_value: float,
) -> None:
"""Property 22: Portfolio heat rejection is correct.
For any set of open positions with stop distances, if the sum of
(position_value × stop_distance_pct) exceeds max_portfolio_heat ×
portfolio_value, then new position entry SHALL be rejected.
"""
# Align list lengths (use shorter of the two)
n = min(len(position_values), len(stop_distances_list))
position_values = position_values[:n]
stop_distances_list = stop_distances_list[:n]
# Build positions and stop_distances dicts
positions = []
stop_distances_dict: dict[str, float] = {}
for i in range(n):
ticker = f"TICK{i}"
positions.append({"ticker": ticker, "position_value": position_values[i]})
stop_distances_dict[ticker] = stop_distances_list[i]
# Compute current portfolio heat
current_heat = compute_portfolio_heat(positions, stop_distances_dict)
# Compute new position risk dollars
new_risk_dollars = new_position_value * new_stop_distance
# Check heat capacity
allowed = check_heat_capacity(
current_heat=current_heat,
new_risk_dollars=new_risk_dollars,
max_heat_pct=max_heat_pct,
portfolio_value=portfolio_value,
)
# The max allowed heat in dollars
max_heat_dollars = max_heat_pct * portfolio_value
# Verify: if adding new position exceeds limit, must be rejected (False)
if (current_heat + new_risk_dollars) > max_heat_dollars:
assert allowed is False, (
f"Expected rejection: current_heat={current_heat:.2f} + "
f"new_risk={new_risk_dollars:.2f} = {current_heat + new_risk_dollars:.2f} "
f"> max_heat={max_heat_dollars:.2f}, but got allowed=True"
)
else:
# If within limit, must be allowed (True)
assert allowed is True, (
f"Expected allowance: current_heat={current_heat:.2f} + "
f"new_risk={new_risk_dollars:.2f} = {current_heat + new_risk_dollars:.2f} "
f"<= max_heat={max_heat_dollars:.2f}, but got allowed=False"
)
# ---------------------------------------------------------------------------
# Feature: math-core-v3-engine, Property 23: Tier auto-adjustment obeys
# downgrade-any, upgrade-all logic
# ---------------------------------------------------------------------------
# **Validates: Requirements 18.3, 18.4**
@settings(max_examples=100)
@given(
profit_factor=profit_factor_strategy,
max_drawdown=drawdown_strategy,
calibration_error=calibration_error_strategy,
sharpe=sharpe_strategy,
n_trades=n_trades_strategy,
reserve_pool=reserve_pool_strategy,
)
def test_property_23_tier_adjustment_downgrade_any_upgrade_all(
profit_factor: float,
max_drawdown: float,
calibration_error: float,
sharpe: float,
n_trades: int,
reserve_pool: float,
) -> None:
"""Property 23: Tier auto-adjustment obeys downgrade-any, upgrade-all logic.
For any TierMetrics, if ANY single downgrade condition is met
(profit_factor < 1.0 OR drawdown > 0.12 OR calibration_error > 0.20 OR
sharpe < 0), the result SHALL be "downgrade". An "upgrade" SHALL only
occur when ALL upgrade conditions are simultaneously met.
"""
metrics = TierMetrics(
profit_factor_30d=profit_factor,
max_drawdown_30d=max_drawdown,
calibration_error=calibration_error,
realized_sharpe_30d=sharpe,
n_trades_30d=n_trades,
reserve_pool_pct=reserve_pool,
)
result = evaluate_tier_adjustment(metrics)
# Check downgrade conditions (any single one triggers downgrade)
downgrade_triggered = (
profit_factor < 1.0
or max_drawdown > 0.12
or calibration_error > 0.20
or sharpe < 0
)
# Check upgrade conditions (all must be met simultaneously)
upgrade_triggered = (
profit_factor > 1.35
and max_drawdown < 0.05
and calibration_error < 0.12
and reserve_pool > 0.20
and n_trades >= 20
)
if downgrade_triggered:
assert result == "downgrade", (
f"Expected 'downgrade' when downgrade condition met: "
f"pf={profit_factor}, dd={max_drawdown}, "
f"cal_err={calibration_error}, sharpe={sharpe}, "
f"but got '{result}'"
)
elif upgrade_triggered:
assert result == "upgrade", (
f"Expected 'upgrade' when all upgrade conditions met: "
f"pf={profit_factor}, dd={max_drawdown}, "
f"cal_err={calibration_error}, sharpe={sharpe}, "
f"n_trades={n_trades}, reserve={reserve_pool}, "
f"but got '{result}'"
)
else:
assert result == "hold", (
f"Expected 'hold' when neither downgrade nor upgrade: "
f"pf={profit_factor}, dd={max_drawdown}, "
f"cal_err={calibration_error}, sharpe={sharpe}, "
f"n_trades={n_trades}, reserve={reserve_pool}, "
f"but got '{result}'"
)
+173
View File
@@ -0,0 +1,173 @@
"""Property-based tests for v3 macro and competitive layers.
Validates:
- Property 13: Noisy-OR normalized exposure is bounded in [0, 1]
- Property 14: Competitive LLR is clamped to [-1.25, 1.25]
- Property 15: Graph attenuation is zero beyond max distance
Requirements: 9.1, 9.2, 10.3, 10.4, 10.5
"""
from __future__ import annotations
from hypothesis import given, settings
from hypothesis import strategies as st
from services.aggregation.interpolation import (
compute_macro_llr,
compute_normalized_macro_exposure,
)
from services.aggregation.signal_propagation import (
compute_competitive_llr,
compute_shrunk_correlation,
)
# ---------------------------------------------------------------------------
# Strategies
# ---------------------------------------------------------------------------
unit_floats = st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False)
llr_source_values = st.floats(min_value=-5.0, max_value=5.0, allow_nan=False, allow_infinity=False)
distances_valid = st.integers(min_value=1, max_value=3)
distances_beyond = st.integers(min_value=4, max_value=10)
# ---------------------------------------------------------------------------
# Feature: math-core-v3-engine, Property 13: Noisy-OR normalized exposure
# is bounded in [0, 1]
# ---------------------------------------------------------------------------
# **Validates: Requirements 9.1, 9.2**
@settings(max_examples=100)
@given(
geo=unit_floats,
supply=unit_floats,
commodity=unit_floats,
sector=unit_floats,
)
def test_property_13_noisy_or_exposure_bounded(
geo: float,
supply: float,
commodity: float,
sector: float,
) -> None:
"""Property 13: Noisy-OR normalized exposure is bounded in [0, 1].
For any overlap values O_k in [0, 1] for each dimension (geo, supply,
commodity, sector) with fixed positive weights, the normalized macro
exposure E_macro SHALL be in [0.0, 1.0], reaching exactly 1.0 when
all O_k = 1.0. (Use tier="regional" with dampener=1.0 for this
property test)
"""
overlaps = {
"geo": geo,
"supply": supply,
"commodity": commodity,
"sector": sector,
}
e_macro = compute_normalized_macro_exposure(overlaps, tier="regional")
# Must be bounded in [0, 1] with regional dampener = 1.0
assert 0.0 <= e_macro <= 1.0, (
f"E_macro={e_macro} out of bounds [0, 1] for overlaps={overlaps}"
)
@settings(max_examples=1)
@given(st.just(None))
def test_property_13_max_exposure_is_one(_: None) -> None:
"""Property 13 (edge): E_macro reaches exactly 1.0 when all O_k = 1.0."""
overlaps = {"geo": 1.0, "supply": 1.0, "commodity": 1.0, "sector": 1.0}
e_macro = compute_normalized_macro_exposure(overlaps, tier="regional")
assert e_macro == 1.0, f"Expected 1.0 when all overlaps=1.0, got {e_macro}"
# ---------------------------------------------------------------------------
# Feature: math-core-v3-engine, Property 14: Competitive LLR is clamped
# to [-1.25, 1.25]
# ---------------------------------------------------------------------------
# **Validates: Requirements 10.3, 10.4, 10.5**
@settings(max_examples=100)
@given(
llr_source=llr_source_values,
rho_rolling=unit_floats,
n_observations=st.integers(min_value=1, max_value=500),
same_sector=st.booleans(),
d_network=distances_valid,
pattern_confidence=unit_floats,
)
def test_property_14_competitive_llr_clamped(
llr_source: float,
rho_rolling: float,
n_observations: int,
same_sector: bool,
d_network: int,
pattern_confidence: float,
) -> None:
"""Property 14: Competitive LLR is clamped to [-1.25, 1.25].
For any source LLR, shrunk correlation (non-negative), graph distance
(1-3), and pattern confidence in [0,1], the computed competitive LLR
SHALL be in [-1.25, 1.25].
"""
rho_effective = compute_shrunk_correlation(
rho_rolling=rho_rolling,
n_observations=n_observations,
same_sector=same_sector,
)
# rho_effective should be non-negative per definition
assert rho_effective >= 0.0, f"rho_effective={rho_effective} is negative"
llr_competitive = compute_competitive_llr(
llr_source=llr_source,
rho_effective=rho_effective,
d_network=d_network,
pattern_confidence=pattern_confidence,
)
assert -1.25 <= llr_competitive <= 1.25, (
f"Competitive LLR={llr_competitive} out of bounds [-1.25, 1.25] "
f"for llr_source={llr_source}, rho_effective={rho_effective}, "
f"d_network={d_network}, pattern_confidence={pattern_confidence}"
)
# ---------------------------------------------------------------------------
# Feature: math-core-v3-engine, Property 15: Graph attenuation is zero
# beyond max distance
# ---------------------------------------------------------------------------
# **Validates: Requirements 10.4, 10.5**
@settings(max_examples=100)
@given(
llr_source=llr_source_values,
rho_effective=unit_floats,
d_network=distances_beyond,
pattern_confidence=unit_floats,
)
def test_property_15_zero_attenuation_beyond_max_distance(
llr_source: float,
rho_effective: float,
d_network: int,
pattern_confidence: float,
) -> None:
"""Property 15: Graph attenuation is zero beyond max distance.
For any inputs where graph distance > 3, the computed attenuation
SHALL be 0.0, producing zero competitive LLR regardless of other
parameters.
"""
llr_competitive = compute_competitive_llr(
llr_source=llr_source,
rho_effective=rho_effective,
d_network=d_network,
pattern_confidence=pattern_confidence,
)
assert llr_competitive == 0.0, (
f"Expected 0.0 for d_network={d_network} > 3, got {llr_competitive}"
)
+609
View File
@@ -0,0 +1,609 @@
"""Property-based tests for v3 posterior assembly and regime classification.
Feature: math-core-v3-engine
Uses Hypothesis to validate correctness properties of the v3 posterior
and regime detection layer.
Validates: Requirements 5.3, 6.26.5, 21.5
"""
from __future__ import annotations
import math
from hypothesis import given, settings
from hypothesis import strategies as st
from services.aggregation.bayesian import V3Posterior, compute_v3_posterior
from services.aggregation.regime import (
V3RegimeClassification,
MarketRegime,
classify_regime_v3,
)
from services.aggregation.worker import EvidenceCluster
from services.aggregation.scoring import EvidenceUnit
# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------
# Cluster LLR values in [-2.5, 2.5] as per spec
cluster_llrs = st.floats(
min_value=-2.5, max_value=2.5, allow_nan=False, allow_infinity=False
)
# Lists of cluster LLR values (0 to 10 clusters)
cluster_llr_lists = st.lists(cluster_llrs, min_size=0, max_size=10)
# Regime evidence multiplier gamma from the defined set
gammas = st.sampled_from([0.70, 0.80, 0.90, 1.10])
# Prior probability in [0.40, 0.60]
priors = st.floats(
min_value=0.40, max_value=0.60, allow_nan=False, allow_infinity=False
)
# Random walk closing prices for regime classification
# Generate 120+ prices as a random walk starting from a base price
_base_price = st.floats(min_value=10.0, max_value=500.0, allow_nan=False, allow_infinity=False)
_price_step = st.floats(min_value=-2.0, max_value=2.0, allow_nan=False, allow_infinity=False)
@st.composite
def closing_prices_strategy(draw: st.DrawFn) -> list[float]:
"""Generate 120+ closing prices as a random walk."""
base = draw(_base_price)
n = draw(st.integers(min_value=120, max_value=200))
steps = draw(st.lists(_price_step, min_size=n, max_size=n))
prices = [base]
for step in steps:
prices.append(max(0.01, prices[-1] + step)) # keep prices positive
return prices
@st.composite
def daily_returns_strategy(draw: st.DrawFn) -> list[float]:
"""Generate 120+ daily returns (small floats)."""
n = draw(st.integers(min_value=120, max_value=200))
returns = draw(
st.lists(
st.floats(min_value=-0.10, max_value=0.10, allow_nan=False, allow_infinity=False),
min_size=n,
max_size=n,
)
)
return returns
# ATR_20 > 0
atr_20_strategy = st.floats(
min_value=0.01, max_value=50.0, allow_nan=False, allow_infinity=False
)
# ---------------------------------------------------------------------------
# Helper: build mock EvidenceCluster from a cluster_llr value
# ---------------------------------------------------------------------------
def _make_cluster(cluster_llr: float) -> EvidenceCluster:
"""Create a minimal EvidenceCluster with the given cluster_llr."""
return EvidenceCluster(
cluster_id=f"test_cluster_{id(cluster_llr)}",
units=[],
llrs=[cluster_llr],
n_eff=1.0,
cluster_llr=cluster_llr,
)
def _make_regime(gamma: float) -> V3RegimeClassification:
"""Create a minimal V3RegimeClassification with the given gamma."""
return V3RegimeClassification(
regime=MarketRegime.UNCERTAINTY,
trend_z=0.0,
vol_ratio=1.0,
evidence_multiplier=gamma,
confidence_multiplier=0.85,
phi_decay=0.50,
atr_multiplier=2.0,
)
# ---------------------------------------------------------------------------
# Property 7: Posterior P_up is in open interval (0, 1)
# ---------------------------------------------------------------------------
@given(
llr_values=cluster_llr_lists,
gamma=gammas,
p_prior=priors,
)
@settings(max_examples=100)
def test_property_7_posterior_p_up_in_open_interval(
llr_values: list[float],
gamma: float,
p_prior: float,
) -> None:
"""**Validates: Requirements 5.3**
Property 7: For any set of cluster LLRs (each in [-2.5, 2.5]), any regime
evidence multiplier gamma in {0.70, 0.80, 0.90, 1.10}, and any prior
P_prior in [0.40, 0.60], the computed P_up SHALL be in (1e-10, 1 - 1e-10).
"""
# Build mock clusters from the LLR values
clusters = [_make_cluster(llr) for llr in llr_values]
# Build mock regime with the given gamma
regime = _make_regime(gamma)
# Compute posterior
result = compute_v3_posterior(clusters, regime, p_prior=p_prior)
# P_up must be in the open interval (1e-10, 1 - 1e-10)
assert result.p_up >= 1e-10, (
f"P_up {result.p_up} is below lower bound 1e-10"
)
assert result.p_up <= 1 - 1e-10, (
f"P_up {result.p_up} is above upper bound 1 - 1e-10"
)
# p_down should be 1 - p_up
assert math.isclose(result.p_up + result.p_down, 1.0, abs_tol=1e-12), (
f"p_up + p_down = {result.p_up + result.p_down}, expected 1.0"
)
# Strength should be abs(2 * P_up - 1)
expected_strength = abs(2.0 * result.p_up - 1.0)
assert math.isclose(result.strength, expected_strength, abs_tol=1e-12), (
f"strength {result.strength} != expected {expected_strength}"
)
# ---------------------------------------------------------------------------
# Property 20: Regime classification is exhaustive and deterministic
# ---------------------------------------------------------------------------
_VALID_REGIMES = {"panic", "trend_following", "mean_reversion", "uncertainty"}
@given(
closing_prices=closing_prices_strategy(),
daily_returns=daily_returns_strategy(),
atr_20=atr_20_strategy,
)
@settings(max_examples=100)
def test_property_20_regime_classification_exhaustive_and_deterministic(
closing_prices: list[float],
daily_returns: list[float],
atr_20: float,
) -> None:
"""**Validates: Requirements 6.26.5, 21.5**
Property 20: For any valid market data inputs (closing_prices of sufficient
length, daily_returns, ATR_20 > 0), the regime classification SHALL produce
exactly one of {panic, trend_following, mean_reversion, uncertainty} and the
same inputs SHALL always produce the same classification.
"""
# First classification
result_1 = classify_regime_v3(closing_prices, daily_returns, atr_20)
# Result must be exactly one of the four valid regimes
assert result_1.regime.value in _VALID_REGIMES, (
f"Regime '{result_1.regime.value}' not in valid set {_VALID_REGIMES}"
)
# Determinism: second call with same inputs must produce same result
result_2 = classify_regime_v3(closing_prices, daily_returns, atr_20)
assert result_1.regime == result_2.regime, (
f"Non-deterministic: first call gave {result_1.regime.value}, "
f"second gave {result_2.regime.value}"
)
assert result_1.trend_z == result_2.trend_z, (
f"Non-deterministic trend_z: {result_1.trend_z} vs {result_2.trend_z}"
)
assert result_1.vol_ratio == result_2.vol_ratio, (
f"Non-deterministic vol_ratio: {result_1.vol_ratio} vs {result_2.vol_ratio}"
)
assert result_1.evidence_multiplier == result_2.evidence_multiplier, (
f"Non-deterministic evidence_multiplier: "
f"{result_1.evidence_multiplier} vs {result_2.evidence_multiplier}"
)
# ---------------------------------------------------------------------------
# Property 8: Contradiction is zero when evidence is unidirectional
# Feature: math-core-v3-engine, Property 8: Contradiction is zero when evidence is unidirectional
# ---------------------------------------------------------------------------
from services.aggregation.contradiction import compute_v3_contradiction
# Strategies for unidirectional cluster LLRs
all_positive_llrs = st.lists(
st.floats(min_value=0.001, max_value=2.5, allow_nan=False, allow_infinity=False),
min_size=1, max_size=10,
)
all_negative_llrs = st.lists(
st.floats(min_value=-2.5, max_value=-0.001, allow_nan=False, allow_infinity=False),
min_size=1, max_size=10,
)
@given(llr_values=all_positive_llrs)
@settings(max_examples=100)
def test_property_8_contradiction_zero_all_positive(
llr_values: list[float],
) -> None:
"""**Validates: Requirements 7.6, 7.7**
Property 8: For any set of cluster LLRs where all clusters have the same
sign (all positive), the computed contradiction score SHALL be 0.0.
"""
clusters = [_make_cluster(llr) for llr in llr_values]
score = compute_v3_contradiction(clusters)
assert score == 0.0, (
f"Expected contradiction 0.0 for all-positive LLRs {llr_values}, got {score}"
)
@given(llr_values=all_negative_llrs)
@settings(max_examples=100)
def test_property_8_contradiction_zero_all_negative(
llr_values: list[float],
) -> None:
"""**Validates: Requirements 7.6, 7.7**
Property 8: For any set of cluster LLRs where all clusters have the same
sign (all negative), the computed contradiction score SHALL be 0.0.
"""
clusters = [_make_cluster(llr) for llr in llr_values]
score = compute_v3_contradiction(clusters)
assert score == 0.0, (
f"Expected contradiction 0.0 for all-negative LLRs {llr_values}, got {score}"
)
# ---------------------------------------------------------------------------
# Property 9: Contradiction score is bounded in [0, 1]
# Feature: math-core-v3-engine, Property 9: Contradiction score is bounded in [0, 1]
# ---------------------------------------------------------------------------
# Mix of positive and negative cluster LLRs
mixed_llrs = st.lists(
st.floats(min_value=-2.5, max_value=2.5, allow_nan=False, allow_infinity=False),
min_size=0, max_size=15,
)
@given(llr_values=mixed_llrs)
@settings(max_examples=100)
def test_property_9_contradiction_bounded_zero_one(
llr_values: list[float],
) -> None:
"""**Validates: Requirements 7.6, 7.7, 21.7**
Property 9: For any set of cluster LLRs (including mixed positive and
negative), the computed contradiction score SHALL be in [0.0, 1.0].
"""
clusters = [_make_cluster(llr) for llr in llr_values]
score = compute_v3_contradiction(clusters)
assert 0.0 <= score <= 1.0, (
f"Contradiction score {score} out of bounds [0, 1] for LLRs {llr_values}"
)
# ---------------------------------------------------------------------------
# Property 10: Multiplicative confidence is bounded in [0, 1] and suppressed
# by weak dimensions
# Feature: math-core-v3-engine
# ---------------------------------------------------------------------------
from services.aggregation.worker import compute_v3_confidence, compute_v3_data_quality
# Strategies for Property 10
n_eff_totals = st.floats(min_value=0.0, max_value=20.0, allow_nan=False, allow_infinity=False)
unit_floats = st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False)
q_value_lists = st.lists(unit_floats, min_size=1, max_size=15)
llr_lists_conf = st.lists(
st.floats(min_value=-2.5, max_value=2.5, allow_nan=False, allow_infinity=False),
min_size=1, max_size=15,
)
regime_conf_mults = st.floats(min_value=0.01, max_value=1.0, allow_nan=False, allow_infinity=False)
@given(
n_eff_total=n_eff_totals,
q_values=q_value_lists,
llrs=llr_lists_conf,
strength=unit_floats,
regime_confidence_mult=regime_conf_mults,
contradiction=unit_floats,
data_quality=unit_floats,
)
@settings(max_examples=100)
def test_property_10_confidence_bounded_and_suppressed(
n_eff_total: float,
q_values: list[float],
llrs: list[float],
strength: float,
regime_confidence_mult: float,
contradiction: float,
data_quality: float,
) -> None:
"""**Validates: Requirements 8.3, 8.5**
Property 10: For any valid inputs (n_eff_total >= 0, q_values in [0,1],
strength in [0,1], regime_confidence_mult in (0,1], contradiction in [0,1],
data_quality in [0,1]), the computed confidence SHALL be in [0, 1].
Furthermore, if any single dimension (data_quality, 1-contradiction, or
C_quality) is below 0.01, the resulting confidence SHALL be below 0.10.
"""
# Align list lengths: q_values and llrs must be parallel
min_len = min(len(q_values), len(llrs))
q_values = q_values[:min_len]
llrs = llrs[:min_len]
confidence = compute_v3_confidence(
n_eff_total=n_eff_total,
q_values=q_values,
llrs=llrs,
strength=strength,
regime_confidence_mult=regime_confidence_mult,
contradiction=contradiction,
data_quality=data_quality,
)
# Confidence must be in [0, 1]
assert 0.0 <= confidence <= 1.0, (
f"Confidence {confidence} out of bounds [0, 1]"
)
# Suppression check: if data_quality < 0.01, confidence < 0.10
if data_quality < 0.01:
assert confidence < 0.10, (
f"Confidence {confidence} not suppressed by data_quality={data_quality}"
)
# Suppression check: if (1 - contradiction) < 0.01, confidence < 0.10
if (1.0 - contradiction) < 0.01:
assert confidence < 0.10, (
f"Confidence {confidence} not suppressed by contradiction={contradiction}"
)
# Suppression check: if C_quality < 0.01, confidence < 0.10
# C_quality = weighted_mean(q_i, |LLR_i|) = sum(q_i * |LLR_i|) / sum(|LLR_i|)
abs_llrs = [abs(llr) for llr in llrs]
sum_abs_llrs = sum(abs_llrs)
if sum_abs_llrs == 0.0:
c_quality = 0.0
else:
c_quality = sum(q * w for q, w in zip(q_values, abs_llrs)) / sum_abs_llrs
if c_quality < 0.01:
assert confidence < 0.10, (
f"Confidence {confidence} not suppressed by C_quality={c_quality}"
)
# ---------------------------------------------------------------------------
# Property 10 suppression sub-test: force one dimension below 0.01
# ---------------------------------------------------------------------------
@given(
n_eff_total=st.floats(min_value=1.0, max_value=20.0, allow_nan=False, allow_infinity=False),
q_values=st.lists(
st.floats(min_value=0.5, max_value=1.0, allow_nan=False, allow_infinity=False),
min_size=1, max_size=10,
),
llrs=st.lists(
st.floats(min_value=0.5, max_value=2.5, allow_nan=False, allow_infinity=False),
min_size=1, max_size=10,
),
strength=st.floats(min_value=0.3, max_value=1.0, allow_nan=False, allow_infinity=False),
regime_confidence_mult=regime_conf_mults,
dimension=st.sampled_from(["data_quality", "contradiction", "c_quality"]),
)
@settings(max_examples=100)
def test_property_10_suppression_by_weak_dimension(
n_eff_total: float,
q_values: list[float],
llrs: list[float],
strength: float,
regime_confidence_mult: float,
dimension: str,
) -> None:
"""**Validates: Requirements 8.3, 8.5**
Property 10 (suppression): If any single dimension (data_quality,
1-contradiction, or C_quality) is below 0.01, the resulting confidence
SHALL be below 0.10.
"""
# Align list lengths
min_len = min(len(q_values), len(llrs))
q_values = q_values[:min_len]
llrs = llrs[:min_len]
# Force one dimension to be weak
if dimension == "data_quality":
data_quality = 0.005 # below 0.01
contradiction = 0.0
elif dimension == "contradiction":
data_quality = 0.9
contradiction = 0.995 # (1 - contradiction) = 0.005 < 0.01
else: # c_quality
# Force all q_values near zero so C_quality < 0.01
q_values = [0.001] * min_len
data_quality = 0.9
contradiction = 0.0
confidence = compute_v3_confidence(
n_eff_total=n_eff_total,
q_values=q_values,
llrs=llrs,
strength=strength,
regime_confidence_mult=regime_confidence_mult,
contradiction=contradiction,
data_quality=data_quality,
)
assert 0.0 <= confidence <= 1.0, (
f"Confidence {confidence} out of bounds [0, 1]"
)
assert confidence < 0.10, (
f"Confidence {confidence} not suppressed when {dimension} is weak"
)
# ---------------------------------------------------------------------------
# Property 17: Data quality score is bounded in [0, 1]
# Feature: math-core-v3-engine
# ---------------------------------------------------------------------------
from datetime import datetime, timezone as _tz
# Strategies for Property 17
extraction_failure_rates = st.floats(
min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False
)
extraction_confs = st.floats(
min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False
)
impacts = st.floats(
min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False
)
age_newest_hours_strat = st.floats(
min_value=0.0, max_value=10000.0, allow_nan=False, allow_infinity=False
)
n_source_types_strat = st.integers(min_value=0, max_value=20)
@st.composite
def evidence_unit_list_strategy(draw: st.DrawFn) -> list[EvidenceUnit]:
"""Generate a list of EvidenceUnit-like objects for data quality testing."""
n = draw(st.integers(min_value=0, max_value=15))
units = []
_ts = datetime(2024, 1, 1, tzinfo=_tz.utc)
for i in range(n):
ext_conf = draw(extraction_confs)
impact = draw(impacts)
unit = EvidenceUnit(
symbol="TEST",
layer="company",
event_type="earnings",
source_id=f"src_{i}",
source_group="news",
timestamp=_ts,
horizon="7d",
direction=1,
sentiment_strength=0.5,
impact=impact,
extraction_conf=ext_conf,
source_cred=0.7,
novelty=0.5,
event_base_rate=0.25,
cluster_id=f"cluster_{i}",
)
units.append(unit)
return units
@given(
units=evidence_unit_list_strategy(),
extraction_failure_rate=extraction_failure_rates,
age_newest_hours=age_newest_hours_strat,
n_source_types=n_source_types_strat,
)
@settings(max_examples=100)
def test_property_17_data_quality_bounded_zero_one(
units: list[EvidenceUnit],
extraction_failure_rate: float,
age_newest_hours: float,
n_source_types: int,
) -> None:
"""**Validates: Requirements 17.6, 21.6**
Property 17: For any valid inputs (extraction_failure_rate in [0,1],
extraction_conf_i in [0,1], impact_i in [0,1], age_newest_hours >= 0,
N_valid >= 0, N_source_types >= 0), the computed data_quality_score
SHALL be in [0, 1].
"""
data_quality = compute_v3_data_quality(
units=units,
extraction_failure_rate=extraction_failure_rate,
age_newest_hours=age_newest_hours,
n_source_types=n_source_types,
)
assert 0.0 <= data_quality <= 1.0, (
f"Data quality score {data_quality} out of bounds [0, 1] "
f"for extraction_failure_rate={extraction_failure_rate}, "
f"age_newest_hours={age_newest_hours}, n_source_types={n_source_types}, "
f"n_units={len(units)}"
)
# ---------------------------------------------------------------------------
# Property 12: Posterior state JSON round-trip
# Feature: math-core-v3-engine
# ---------------------------------------------------------------------------
import json
from dataclasses import asdict
@given(
llr_values=st.lists(
st.floats(min_value=-2.5, max_value=2.5, allow_nan=False, allow_infinity=False),
min_size=1, max_size=10,
),
gamma=gammas,
p_prior=priors,
)
@settings(max_examples=100)
def test_property_12_posterior_json_round_trip(
llr_values: list[float],
gamma: float,
p_prior: float,
) -> None:
"""**Validates: Requirements 20.1, 21.10**
Property 12: Serialize V3Posterior to JSON via dataclasses.asdict() and
json.dumps(), then deserialize via json.loads() and V3Posterior(**data).
All numeric fields SHALL be equivalent within 1e-10, and string fields
SHALL be exactly equal.
"""
# Build clusters and regime, compute posterior
clusters = [_make_cluster(llr) for llr in llr_values]
regime = _make_regime(gamma)
posterior = compute_v3_posterior(clusters, regime, p_prior=p_prior)
# Serialize to JSON
serialized = json.dumps(asdict(posterior))
# Deserialize back to V3Posterior
restored = V3Posterior(**json.loads(serialized))
# Verify numeric fields within 1e-10
assert math.isclose(posterior.p_up, restored.p_up, abs_tol=1e-10), (
f"p_up mismatch: {posterior.p_up} vs {restored.p_up}"
)
assert math.isclose(posterior.p_down, restored.p_down, abs_tol=1e-10), (
f"p_down mismatch: {posterior.p_down} vs {restored.p_down}"
)
assert math.isclose(posterior.log_odds, restored.log_odds, abs_tol=1e-10), (
f"log_odds mismatch: {posterior.log_odds} vs {restored.log_odds}"
)
assert math.isclose(posterior.strength, restored.strength, abs_tol=1e-10), (
f"strength mismatch: {posterior.strength} vs {restored.strength}"
)
assert math.isclose(posterior.n_eff_total, restored.n_eff_total, abs_tol=1e-10), (
f"n_eff_total mismatch: {posterior.n_eff_total} vs {restored.n_eff_total}"
)
# Verify string fields are exactly equal
assert posterior.direction == restored.direction, (
f"direction mismatch: {posterior.direction!r} vs {restored.direction!r}"
)
assert posterior.regime == restored.regime, (
f"regime mismatch: {posterior.regime!r} vs {restored.regime!r}"
)
+107
View File
@@ -0,0 +1,107 @@
"""Property-based tests for v3 posterior state projection.
Validates:
- Property 16: Projection evidence state decays toward zero
Requirements: 11.1, 11.3
"""
from __future__ import annotations
from hypothesis import given, settings
from hypothesis import strategies as st
from services.aggregation.projection import V3ProjectionState, compute_v3_projection
from services.aggregation.regime import MarketRegime, V3RegimeClassification
# ---------------------------------------------------------------------------
# Strategies
# ---------------------------------------------------------------------------
# Initial evidence states (non-zero)
a_t_values = st.floats(
min_value=-10.0, max_value=10.0, allow_nan=False, allow_infinity=False
).filter(lambda x: abs(x) > 0.01)
# Horizons >= 1
horizons = st.integers(min_value=1, max_value=50)
# Regimes
regimes = st.sampled_from(["panic", "trend_following", "mean_reversion", "uncertainty"])
def _make_regime(regime_name: str) -> V3RegimeClassification:
"""Create a V3RegimeClassification with the given regime name."""
return V3RegimeClassification(
regime=MarketRegime(regime_name),
trend_z=0.0,
vol_ratio=1.0,
evidence_multiplier=1.0,
confidence_multiplier=1.0,
phi_decay={"panic": 0.35, "trend_following": 0.80, "mean_reversion": 0.55, "uncertainty": 0.50}[regime_name],
atr_multiplier=2.0,
)
# ---------------------------------------------------------------------------
# Feature: math-core-v3-engine, Property 16: Projection evidence state
# decays toward zero
# ---------------------------------------------------------------------------
# **Validates: Requirements 11.1, 11.3**
@settings(max_examples=100)
@given(
a_t=a_t_values,
regime_name=regimes,
h=horizons,
)
def test_property_16_projection_evidence_state_decays_toward_zero(
a_t: float,
regime_name: str,
h: int,
) -> None:
"""Property 16: Projection evidence state decays toward zero.
For any initial evidence state A_t and regime decay phi in (0, 1),
the projected state A_projected_h = phi^h * A_t SHALL have
|A_projected_h| < |A_t| for all h >= 1, converging toward 0 as h
increases.
"""
regime = _make_regime(regime_name)
# Call compute_v3_projection with a_prev=a_t, cluster_llrs=[] (no new evidence),
# known_catalyst_llr=0.0. This gives A_t = phi * a_prev (since no new LLRs).
result = compute_v3_projection(
a_prev=a_t,
cluster_llrs=[],
regime=regime,
p_prior=0.50,
projection_horizon=h,
known_catalyst_llr=0.0,
)
# After update: A_t_new = phi * a_prev + 0 = phi * a_prev
# After projection: A_projected = phi^h * A_t_new = phi^h * (phi * a_prev) = phi^(h+1) * a_prev
# The phi values are all in (0, 1), so phi^(h+1) < 1 for h >= 1
# Therefore |A_projected| < |a_prev|
phi = regime.phi_decay
# The evidence state after update (no new LLRs): A_t_new = phi * a_prev
a_t_new = result.a_t
# The projected alpha: A_projected = phi^h * A_t_new
a_projected = (phi ** h) * a_t_new
# |A_projected| must be less than |a_prev| because phi is in (0, 1)
# and A_projected = phi^(h+1) * a_prev
assert abs(a_projected) < abs(a_t), (
f"|A_projected|={abs(a_projected)} should be < |a_prev|={abs(a_t)} "
f"for phi={phi}, h={h}, regime={regime_name}"
)
# Verify convergence: larger h → smaller magnitude
# Compute projected at h+10 and verify it's smaller than at h
a_projected_larger_h = (phi ** (h + 10)) * a_t_new
assert abs(a_projected_larger_h) <= abs(a_projected), (
f"|A_projected(h+10)|={abs(a_projected_larger_h)} should be <= "
f"|A_projected(h)|={abs(a_projected)} for phi={phi}, regime={regime_name}"
)
+417
View File
@@ -0,0 +1,417 @@
"""Property-based tests for EvidenceUnit normalization, calibrated reliability, and LLR.
Feature: math-core-v3-engine
Uses Hypothesis to validate correctness properties of the v3 calibrated
evidence engine foundation: EvidenceUnit normalization preserves field ranges,
reliability q_i is bounded, p_correct is bounded, LLR sign matches direction,
and neutral signals produce zero LLR.
Validates: Requirements 1.11.8, 2.12.9, 3.13.6, 21.121.3
"""
from __future__ import annotations
import math
from datetime import datetime, timedelta, timezone
from hypothesis import given, settings
from hypothesis import strategies as st
from services.aggregation.scoring import (
EvidenceUnit,
ReliabilityComponents,
SourceStats,
_clamp,
compute_llr,
compute_v3_reliability,
normalize_company_signal,
normalize_competitive_signal,
normalize_macro_signal,
)
# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------
unit_floats = st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False)
directions = st.sampled_from([-1, 0, 1])
horizons = st.sampled_from(["intraday", "1d", "7d", "30d", "90d"])
positive_floats = st.floats(min_value=0.0, max_value=10000.0, allow_nan=False, allow_infinity=False)
non_negative_ints = st.integers(min_value=0, max_value=100)
def _evidence_unit_strategy() -> st.SearchStrategy[EvidenceUnit]:
"""Generate valid EvidenceUnit instances with fields in valid ranges."""
return st.builds(
EvidenceUnit,
symbol=st.just("AAPL"),
layer=st.sampled_from(["company", "macro", "competitive"]),
event_type=st.sampled_from(["earnings", "product_launch", "regulatory", "unknown"]),
source_id=st.just("src-001"),
source_group=st.sampled_from(["company", "macro", "competitive"]),
timestamp=st.just(datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)),
horizon=horizons,
direction=directions,
sentiment_strength=unit_floats,
impact=unit_floats,
extraction_conf=unit_floats,
source_cred=unit_floats,
novelty=unit_floats,
event_base_rate=st.floats(min_value=0.01, max_value=1.0, allow_nan=False, allow_infinity=False),
cluster_id=st.just("cluster-001"),
)
def _source_stats_strategy() -> st.SearchStrategy[SourceStats]:
"""Generate SourceStats with valid counts."""
return st.builds(
SourceStats,
source_id=st.just("src-001"),
hits=non_negative_ints,
misses=non_negative_ints,
alpha_0=st.floats(min_value=1.0, max_value=10.0, allow_nan=False, allow_infinity=False),
beta_0=st.floats(min_value=1.0, max_value=10.0, allow_nan=False, allow_infinity=False),
)
# ---------------------------------------------------------------------------
# Property 1: Reliability q_i is bounded in [0, 1]
# Feature: math-core-v3-engine, Property 1: Reliability q_i is bounded in [0, 1]
# Validates: Requirements 2.12.9
# ---------------------------------------------------------------------------
@settings(max_examples=100)
@given(
extraction_conf=unit_floats,
source_cred=unit_floats,
novelty=unit_floats,
impact=unit_floats,
sentiment_strength=unit_floats,
event_base_rate=st.floats(min_value=0.01, max_value=1.0, allow_nan=False, allow_infinity=False),
age_hours=st.floats(min_value=0.0, max_value=5000.0, allow_nan=False, allow_infinity=False),
duplicate_count_before=non_negative_ints,
hits=non_negative_ints,
misses=non_negative_ints,
)
def test_property_1_reliability_qi_bounded(
extraction_conf: float,
source_cred: float,
novelty: float,
impact: float,
sentiment_strength: float,
event_base_rate: float,
age_hours: float,
duplicate_count_before: int,
hits: int,
misses: int,
) -> None:
"""Property 1: Reliability q_i is bounded in [0, 1].
For any valid EvidenceUnit with extraction_conf in [0,1], source_cred in [0,1],
novelty in [0,1], any non-negative age_hours, and any non-negative
duplicate_count_before, the computed q_i SHALL be in [0.0, 1.0].
**Validates: Requirements 2.12.9**
"""
reference_time = datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)
unit_time = reference_time - timedelta(hours=age_hours)
unit = EvidenceUnit(
symbol="AAPL",
layer="company",
event_type="earnings",
source_id="src-001",
source_group="company",
timestamp=unit_time,
horizon="7d",
direction=1,
sentiment_strength=sentiment_strength,
impact=impact,
extraction_conf=extraction_conf,
source_cred=source_cred,
novelty=novelty,
event_base_rate=event_base_rate,
cluster_id="cluster-001",
)
stats = SourceStats(source_id="src-001", hits=hits, misses=misses)
result = compute_v3_reliability(unit, stats, duplicate_count_before, reference_time)
assert 0.0 <= result.q_i <= 1.0, f"q_i={result.q_i} out of [0, 1]"
assert 0.0 <= result.q_ext <= 1.0, f"q_ext={result.q_ext} out of [0, 1]"
assert 0.0 <= result.q_source <= 1.0, f"q_source={result.q_source} out of [0, 1]"
assert 0.0 <= result.q_recency <= 1.0, f"q_recency={result.q_recency} out of [0, 1]"
assert 0.0 <= result.q_uniqueness <= 1.0, f"q_uniqueness={result.q_uniqueness} out of [0, 1]"
# ---------------------------------------------------------------------------
# Property 2: p_correct is bounded in [0.501, 0.85]
# Feature: math-core-v3-engine, Property 2: p_correct is bounded in [0.501, 0.85]
# Validates: Requirements 3.13.2
# ---------------------------------------------------------------------------
@settings(max_examples=100)
@given(
q_i=unit_floats,
impact=unit_floats,
sentiment_strength=unit_floats,
)
def test_property_2_p_correct_bounded(
q_i: float,
impact: float,
sentiment_strength: float,
) -> None:
"""Property 2: p_correct is bounded in [0.501, 0.85].
For any valid q_i in [0,1], impact in [0,1], and sentiment_strength in [0,1],
the computed p_correct SHALL be in [0.501, 0.85].
**Validates: Requirements 3.13.2**
"""
# Replicate the p_correct computation from compute_llr
p_correct = _clamp(
0.50 + 0.35 * q_i * impact * sentiment_strength,
0.501,
0.85,
)
assert 0.501 <= p_correct <= 0.85, f"p_correct={p_correct} out of [0.501, 0.85]"
# ---------------------------------------------------------------------------
# Property 3: LLR sign matches direction and magnitude is bounded
# Feature: math-core-v3-engine, Property 3: LLR sign matches direction and magnitude is bounded
# Validates: Requirements 3.33.4, 3.6
# ---------------------------------------------------------------------------
@settings(max_examples=100)
@given(
direction=st.sampled_from([-1, 1]),
q_i=unit_floats,
impact=unit_floats,
sentiment_strength=unit_floats,
)
def test_property_3_llr_sign_matches_direction(
direction: int,
q_i: float,
impact: float,
sentiment_strength: float,
) -> None:
"""Property 3: LLR sign matches direction and magnitude is bounded.
For any valid signal with direction in {-1, +1}, the LLR SHALL have the same
sign as direction, with abs magnitude in [~0.004, ~1.735].
**Validates: Requirements 3.33.4, 3.6**
"""
unit = EvidenceUnit(
symbol="AAPL",
layer="company",
event_type="earnings",
source_id="src-001",
source_group="company",
timestamp=datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc),
horizon="7d",
direction=direction,
sentiment_strength=sentiment_strength,
impact=impact,
extraction_conf=0.8,
source_cred=0.8,
novelty=0.8,
event_base_rate=0.25,
cluster_id="cluster-001",
)
llr = compute_llr(unit, q_i)
# LLR sign must match direction
if direction == 1:
assert llr > 0.0, f"LLR={llr} should be positive for direction=+1"
else:
assert llr < 0.0, f"LLR={llr} should be negative for direction=-1"
# Magnitude bounds: ln(0.501/0.499) ≈ 0.004, ln(0.85/0.15) ≈ 1.735
min_magnitude = math.log(0.501 / 0.499) # ~0.004
max_magnitude = math.log(0.85 / 0.15) # ~1.735
assert abs(llr) >= min_magnitude - 1e-9, f"|LLR|={abs(llr)} below min ~0.004"
assert abs(llr) <= max_magnitude + 1e-9, f"|LLR|={abs(llr)} above max ~1.735"
# ---------------------------------------------------------------------------
# Property 4: Neutral signals produce zero LLR
# Feature: math-core-v3-engine, Property 4: Neutral signals produce zero LLR
# Validates: Requirements 3.5
# ---------------------------------------------------------------------------
@settings(max_examples=100)
@given(
q_i=unit_floats,
impact=unit_floats,
sentiment_strength=unit_floats,
extraction_conf=unit_floats,
source_cred=unit_floats,
novelty=unit_floats,
)
def test_property_4_neutral_produces_zero_llr(
q_i: float,
impact: float,
sentiment_strength: float,
extraction_conf: float,
source_cred: float,
novelty: float,
) -> None:
"""Property 4: Neutral signals produce zero LLR.
For any EvidenceUnit with direction=0, LLR SHALL be exactly 0.0.
**Validates: Requirements 3.5**
"""
unit = EvidenceUnit(
symbol="AAPL",
layer="company",
event_type="earnings",
source_id="src-001",
source_group="company",
timestamp=datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc),
horizon="7d",
direction=0,
sentiment_strength=sentiment_strength,
impact=impact,
extraction_conf=extraction_conf,
source_cred=source_cred,
novelty=novelty,
event_base_rate=0.25,
cluster_id="cluster-001",
)
llr = compute_llr(unit, q_i)
assert llr == 0.0, f"LLR={llr} should be exactly 0.0 for neutral direction"
# ---------------------------------------------------------------------------
# Property 21: EvidenceUnit normalization preserves field ranges
# Feature: math-core-v3-engine, Property 21: EvidenceUnit normalization preserves field ranges
# Validates: Requirements 1.11.8, 21.121.3
# ---------------------------------------------------------------------------
def _company_signal_strategy() -> st.SearchStrategy[dict]:
"""Generate raw company signal dicts with arbitrary values."""
return st.fixed_dictionaries({
"symbol": st.just("TSLA"),
"timestamp": st.just(datetime(2024, 6, 15, 10, 0, 0, tzinfo=timezone.utc)),
"source_id": st.just("doc-123"),
"event_type": st.sampled_from(["earnings", "product_launch", "regulatory", "unknown", None]),
"source_group": st.sampled_from(["company", "reuters", None]),
"horizon": st.sampled_from(["intraday", "1d", "7d", "30d", "90d", "invalid", None]),
"sentiment": st.sampled_from(["positive", "negative", "neutral", "mixed", "bullish", "bearish", None]),
"sentiment_strength": st.one_of(unit_floats, st.just(None)),
"impact": st.one_of(unit_floats, st.just(None)),
"extraction_conf": st.one_of(unit_floats, st.just(None)),
"source_cred": st.one_of(unit_floats, st.just(None)),
"novelty": st.one_of(unit_floats, st.just(None)),
})
def _macro_signal_strategy() -> st.SearchStrategy[dict]:
"""Generate raw macro signal dicts with arbitrary values."""
return st.fixed_dictionaries({
"symbol": st.just("AAPL"),
"timestamp": st.just(datetime(2024, 6, 15, 10, 0, 0, tzinfo=timezone.utc)),
"source_id": st.just("event-456"),
"event_type": st.sampled_from(["earnings", "regulatory", "market_data", None]),
"estimated_duration": st.sampled_from(["short_term", "medium_term", "long_term", "unknown", None]),
"impact_direction": st.sampled_from(["positive", "negative", "neutral", "bullish", "bearish", None]),
"macro_impact_score": st.one_of(unit_floats, st.just(None)),
"event_confidence": st.one_of(unit_floats, st.just(None)),
"novelty": st.one_of(unit_floats, st.just(None)),
"sentiment_strength": st.one_of(unit_floats, st.just(None)),
})
def _competitive_signal_strategy() -> st.SearchStrategy[dict]:
"""Generate raw competitive signal dicts with arbitrary values."""
return st.fixed_dictionaries({
"symbol": st.just("MSFT"),
"timestamp": st.just(datetime(2024, 6, 15, 10, 0, 0, tzinfo=timezone.utc)),
"source_id": st.just("comp-789"),
"event_type": st.sampled_from(["earnings", "product_launch", None]),
"signal_direction": st.sampled_from(["bullish", "bearish", "neutral", None]),
"signal_strength": st.one_of(unit_floats, st.just(None)),
"relationship_strength": st.one_of(unit_floats, st.just(None)),
"pattern_confidence": st.one_of(unit_floats, st.just(None)),
"time_horizon": st.sampled_from(["intraday", "1d", "7d", "30d", "90d", "short_term", None]),
"novelty": st.one_of(unit_floats, st.just(None)),
"sentiment_strength": st.one_of(unit_floats, st.just(None)),
})
@settings(max_examples=100)
@given(signal=_company_signal_strategy())
def test_property_21_company_normalization_preserves_ranges(signal: dict) -> None:
"""Property 21: EvidenceUnit normalization preserves field ranges (company).
For any valid company signal input, normalized EvidenceUnit SHALL have
direction in {-1,0,+1}, all float fields in [0,1], event_base_rate in (0,1].
**Validates: Requirements 1.11.8, 21.121.3**
"""
unit = normalize_company_signal(signal)
assert unit is not None, "Expected valid EvidenceUnit from company signal"
_assert_evidence_unit_ranges(unit)
@settings(max_examples=100)
@given(signal=_macro_signal_strategy())
def test_property_21_macro_normalization_preserves_ranges(signal: dict) -> None:
"""Property 21: EvidenceUnit normalization preserves field ranges (macro).
For any valid macro signal input, normalized EvidenceUnit SHALL have
direction in {-1,0,+1}, all float fields in [0,1], event_base_rate in (0,1].
**Validates: Requirements 1.11.8, 21.121.3**
"""
unit = normalize_macro_signal(signal)
assert unit is not None, "Expected valid EvidenceUnit from macro signal"
_assert_evidence_unit_ranges(unit)
@settings(max_examples=100)
@given(signal=_competitive_signal_strategy())
def test_property_21_competitive_normalization_preserves_ranges(signal: dict) -> None:
"""Property 21: EvidenceUnit normalization preserves field ranges (competitive).
For any valid competitive signal input, normalized EvidenceUnit SHALL have
direction in {-1,0,+1}, all float fields in [0,1], event_base_rate in (0,1].
**Validates: Requirements 1.11.8, 21.121.3**
"""
unit = normalize_competitive_signal(signal)
assert unit is not None, "Expected valid EvidenceUnit from competitive signal"
_assert_evidence_unit_ranges(unit)
# ---------------------------------------------------------------------------
# Shared assertion helper
# ---------------------------------------------------------------------------
def _assert_evidence_unit_ranges(unit: EvidenceUnit) -> None:
"""Assert all EvidenceUnit fields are within their valid ranges."""
assert unit.direction in (-1, 0, 1), f"direction={unit.direction} not in {{-1, 0, +1}}"
assert 0.0 <= unit.sentiment_strength <= 1.0, f"sentiment_strength={unit.sentiment_strength} out of [0, 1]"
assert 0.0 <= unit.impact <= 1.0, f"impact={unit.impact} out of [0, 1]"
assert 0.0 <= unit.extraction_conf <= 1.0, f"extraction_conf={unit.extraction_conf} out of [0, 1]"
assert 0.0 <= unit.source_cred <= 1.0, f"source_cred={unit.source_cred} out of [0, 1]"
assert 0.0 <= unit.novelty <= 1.0, f"novelty={unit.novelty} out of [0, 1]"
assert 0.0 < unit.event_base_rate <= 1.0, f"event_base_rate={unit.event_base_rate} out of (0, 1]"
assert unit.horizon in ("intraday", "1d", "7d", "30d", "90d"), f"horizon={unit.horizon} invalid"
assert unit.layer in ("company", "macro", "competitive"), f"layer={unit.layer} invalid"
+290
View File
@@ -0,0 +1,290 @@
"""Unit tests for v3 correlation-aware clustering.
Tests for compute_n_eff, compute_cluster_llr, and cluster_evidence functions.
Requirements validated: 4.14.5
"""
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from services.aggregation.scoring import EvidenceUnit
from services.aggregation.worker import (
cluster_evidence,
compute_cluster_llr,
compute_n_eff,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_unit(cluster_id: str = "cluster_a", symbol: str = "AAPL") -> EvidenceUnit:
"""Create a minimal EvidenceUnit for testing."""
return EvidenceUnit(
symbol=symbol,
layer="company",
event_type="earnings",
source_id="doc_1",
source_group="reuters",
timestamp=datetime(2024, 1, 15, 12, 0, tzinfo=timezone.utc),
horizon="7d",
direction=1,
sentiment_strength=0.8,
impact=0.7,
extraction_conf=0.9,
source_cred=0.85,
novelty=0.6,
event_base_rate=0.25,
cluster_id=cluster_id,
)
# ---------------------------------------------------------------------------
# Test: 3 identical articles from same source → n_eff < 3
# Requirement: 4.2, 4.3
# ---------------------------------------------------------------------------
class TestNEffIdenticalArticles:
"""3 identical articles from same wire (default rho=0.80) → n_eff < 3."""
def test_n_eff_less_than_cluster_size(self):
llrs = [1.0, 1.0, 1.0]
# Default correlations: rho=0.80 for all pairs (same wire/source)
n_eff = compute_n_eff(llrs)
# Formula: (3)² / (3 + 2×3×0.80×1×1) = 9 / (3 + 4.8) = 9/7.8 ≈ 1.154
expected = 9.0 / 7.8
assert n_eff < 3.0
assert n_eff == pytest.approx(expected, rel=1e-6)
def test_n_eff_discounts_correlated_signals(self):
"""Higher correlation → lower n_eff."""
llrs = [1.0, 1.0, 1.0]
n_eff_correlated = compute_n_eff(llrs) # default rho=0.80
# Independent: rho=0.0
identity = [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
n_eff_independent = compute_n_eff(llrs, correlations=identity)
assert n_eff_correlated < n_eff_independent
# ---------------------------------------------------------------------------
# Test: 3 independent articles → n_eff ≈ 3
# Requirement: 4.2, 4.3
# ---------------------------------------------------------------------------
class TestNEffIndependentArticles:
"""3 independent articles (rho=0.0) → n_eff = 3.0."""
def test_n_eff_equals_cluster_size(self):
llrs = [1.0, 1.0, 1.0]
# Zero off-diagonal correlations
correlations = [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
n_eff = compute_n_eff(llrs, correlations=correlations)
# n_eff = (3)² / (3 + 0) = 3.0
assert n_eff == pytest.approx(3.0, rel=1e-6)
def test_n_eff_with_varying_magnitudes(self):
"""Independent signals with different magnitudes still give n <= cluster size."""
llrs = [0.5, 1.0, 2.0]
correlations = [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
n_eff = compute_n_eff(llrs, correlations=correlations)
# With zero correlations, n_eff = (sum |w|)^2 / sum(w^2)
# = (0.5+1.0+2.0)^2 / (0.25+1.0+4.0) = 12.25 / 5.25 ≈ 2.333
expected = (3.5**2) / (0.25 + 1.0 + 4.0)
assert n_eff == pytest.approx(expected, rel=1e-6)
assert n_eff <= 3.0
# ---------------------------------------------------------------------------
# Test: Single signal cluster → n_eff = 1.0
# Requirement: 4.2
# ---------------------------------------------------------------------------
class TestNEffSingleSignal:
"""Single signal in a cluster → n_eff = 1.0."""
def test_single_signal(self):
llrs = [0.5]
n_eff = compute_n_eff(llrs)
assert n_eff == 1.0
def test_empty_cluster(self):
llrs: list[float] = []
n_eff = compute_n_eff(llrs)
assert n_eff == 1.0
# ---------------------------------------------------------------------------
# Test: Cluster LLR clamp at ±2.5
# Requirement: 4.4, 4.5
# ---------------------------------------------------------------------------
class TestClusterLLRClamp:
"""Cluster LLR is clamped to [-2.5, 2.5]."""
def test_positive_clamp(self):
"""Very large positive LLRs with high n_eff → clamped to 2.5."""
llrs = [2.0, 2.0, 2.0, 2.0, 2.0]
# Use independent correlations for max n_eff
correlations = [
[1, 0, 0, 0, 0],
[0, 1, 0, 0, 0],
[0, 0, 1, 0, 0],
[0, 0, 0, 1, 0],
[0, 0, 0, 0, 1],
]
n_eff = compute_n_eff(llrs, correlations=correlations)
cluster_llr = compute_cluster_llr(llrs, n_eff)
# weighted_mean = 2.0, sqrt(5) ≈ 2.236, raw = 4.47 → clamp to 2.5
assert cluster_llr == pytest.approx(2.5, rel=1e-6)
def test_negative_clamp(self):
"""Very negative LLRs with high n_eff → clamped to -2.5."""
llrs = [-2.0, -2.0, -2.0, -2.0, -2.0]
correlations = [
[1, 0, 0, 0, 0],
[0, 1, 0, 0, 0],
[0, 0, 1, 0, 0],
[0, 0, 0, 1, 0],
[0, 0, 0, 0, 1],
]
n_eff = compute_n_eff(llrs, correlations=correlations)
cluster_llr = compute_cluster_llr(llrs, n_eff)
assert cluster_llr == pytest.approx(-2.5, rel=1e-6)
def test_within_bounds_no_clamp(self):
"""Small LLRs with low n_eff → no clamping needed."""
llrs = [0.3, 0.4]
n_eff = compute_n_eff(llrs)
cluster_llr = compute_cluster_llr(llrs, n_eff)
assert -2.5 <= cluster_llr <= 2.5
# Should NOT be at the clamp boundary
assert abs(cluster_llr) < 2.5
def test_single_signal_clamp(self):
"""Single signal beyond clamp → clamped."""
llrs = [3.0]
cluster_llr = compute_cluster_llr(llrs, n_eff=1.0)
assert cluster_llr == pytest.approx(2.5, rel=1e-6)
def test_single_signal_negative_clamp(self):
"""Single negative signal beyond clamp → clamped to -2.5."""
llrs = [-3.0]
cluster_llr = compute_cluster_llr(llrs, n_eff=1.0)
assert cluster_llr == pytest.approx(-2.5, rel=1e-6)
# ---------------------------------------------------------------------------
# Test: All-zero LLRs → cluster_llr = 0.0
# Requirement: 4.4
# ---------------------------------------------------------------------------
class TestClusterLLRZero:
"""All-zero LLRs produce zero cluster LLR."""
def test_all_zeros(self):
llrs = [0.0, 0.0, 0.0]
n_eff = compute_n_eff(llrs)
cluster_llr = compute_cluster_llr(llrs, n_eff)
assert cluster_llr == 0.0
def test_empty_llrs(self):
"""Empty LLR list → 0.0."""
cluster_llr = compute_cluster_llr([], n_eff=1.0)
assert cluster_llr == 0.0
# ---------------------------------------------------------------------------
# Test: Grouping by correct key dimensions (cluster_id)
# Requirement: 4.1
# ---------------------------------------------------------------------------
class TestClusterEvidence:
"""cluster_evidence groups EvidenceUnits by cluster_id."""
def test_grouping_by_cluster_id(self):
"""Units with same cluster_id are grouped together."""
unit_a1 = _make_unit(cluster_id="cluster_a")
unit_a2 = _make_unit(cluster_id="cluster_a")
unit_b1 = _make_unit(cluster_id="cluster_b")
units = [unit_a1, unit_a2, unit_b1]
llrs = [1.0, 0.5, -0.3]
clusters = cluster_evidence(units, llrs)
assert len(clusters) == 2
# Find clusters by id
cluster_map = {c.cluster_id: c for c in clusters}
assert "cluster_a" in cluster_map
assert "cluster_b" in cluster_map
# Cluster A has 2 units
assert len(cluster_map["cluster_a"].units) == 2
assert cluster_map["cluster_a"].llrs == [1.0, 0.5]
# Cluster B has 1 unit
assert len(cluster_map["cluster_b"].units) == 1
assert cluster_map["cluster_b"].llrs == [-0.3]
def test_single_cluster(self):
"""All units with same cluster_id → one cluster."""
units = [_make_unit(cluster_id="only") for _ in range(4)]
llrs = [0.1, 0.2, 0.3, 0.4]
clusters = cluster_evidence(units, llrs)
assert len(clusters) == 1
assert clusters[0].cluster_id == "only"
assert len(clusters[0].units) == 4
assert clusters[0].llrs == [0.1, 0.2, 0.3, 0.4]
def test_each_unit_different_cluster(self):
"""Each unit in its own cluster → N clusters."""
units = [_make_unit(cluster_id=f"c_{i}") for i in range(5)]
llrs = [0.1 * i for i in range(5)]
clusters = cluster_evidence(units, llrs)
assert len(clusters) == 5
for c in clusters:
assert len(c.units) == 1
def test_empty_input(self):
"""No units → no clusters."""
clusters = cluster_evidence([], [])
assert clusters == []
def test_llrs_parallel_to_units(self):
"""LLRs are correctly associated with their units."""
unit_x = _make_unit(cluster_id="x")
unit_y = _make_unit(cluster_id="y")
unit_x2 = _make_unit(cluster_id="x")
units = [unit_x, unit_y, unit_x2]
llrs = [1.5, -0.7, 2.3]
clusters = cluster_evidence(units, llrs)
cluster_map = {c.cluster_id: c for c in clusters}
assert cluster_map["x"].llrs == [1.5, 2.3]
assert cluster_map["y"].llrs == [-0.7]
+349
View File
@@ -0,0 +1,349 @@
"""Unit tests for v3 multiplicative confidence and data quality.
Tests for compute_v3_confidence, compute_v3_data_quality, and
should_force_informational_v3 functions.
Requirements validated: 8.18.5, 17.117.8
"""
from __future__ import annotations
import math
from datetime import datetime, timezone
import pytest
from services.aggregation.scoring import EvidenceUnit
from services.aggregation.worker import (
compute_v3_confidence,
compute_v3_data_quality,
should_force_informational_v3,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_NOW = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
def _make_unit(
layer: str = "company",
extraction_conf: float = 0.8,
impact: float = 0.7,
) -> EvidenceUnit:
"""Create a minimal EvidenceUnit for testing."""
return EvidenceUnit(
symbol="AAPL",
layer=layer,
event_type="earnings",
source_id="doc-1",
source_group="company",
timestamp=_NOW,
horizon="7d",
direction=1,
sentiment_strength=0.8,
impact=impact,
extraction_conf=extraction_conf,
source_cred=0.85,
novelty=0.9,
event_base_rate=0.25,
cluster_id="test-cluster",
)
# ---------------------------------------------------------------------------
# Test: Zero data quality → zero confidence
# Requirement: 8.1, 8.5
# ---------------------------------------------------------------------------
class TestZeroDataQualityConfidence:
"""When data_quality = 0.0, confidence must be 0.0."""
def test_zero_data_quality_produces_zero_confidence(self):
confidence = compute_v3_confidence(
n_eff_total=5.0,
q_values=[0.8, 0.7],
llrs=[1.0, 0.5],
strength=0.6,
regime_confidence_mult=1.0,
contradiction=0.0,
data_quality=0.0,
)
assert confidence == 0.0
def test_near_zero_data_quality_suppresses_confidence(self):
"""Very low data_quality → near-zero confidence."""
confidence = compute_v3_confidence(
n_eff_total=10.0,
q_values=[0.9, 0.9],
llrs=[1.5, 1.5],
strength=0.8,
regime_confidence_mult=1.0,
contradiction=0.0,
data_quality=0.01,
)
assert confidence < 0.05
# ---------------------------------------------------------------------------
# Test: Full contradiction (1.0) → zero confidence
# Requirement: 8.4
# ---------------------------------------------------------------------------
class TestFullContradictionConfidence:
"""When contradiction = 1.0, confidence must be 0.0."""
def test_full_contradiction_produces_zero_confidence(self):
confidence = compute_v3_confidence(
n_eff_total=10.0,
q_values=[0.9, 0.8],
llrs=[1.0, 1.2],
strength=0.7,
regime_confidence_mult=1.0,
contradiction=1.0,
data_quality=0.9,
)
assert confidence == 0.0
def test_high_contradiction_suppresses_confidence(self):
"""Contradiction = 0.9 → confidence heavily suppressed."""
conf_no_contra = compute_v3_confidence(
n_eff_total=5.0,
q_values=[0.8],
llrs=[1.0],
strength=0.6,
regime_confidence_mult=1.0,
contradiction=0.0,
data_quality=0.8,
)
conf_high_contra = compute_v3_confidence(
n_eff_total=5.0,
q_values=[0.8],
llrs=[1.0],
strength=0.6,
regime_confidence_mult=1.0,
contradiction=0.9,
data_quality=0.8,
)
assert conf_high_contra < conf_no_contra * 0.15
# ---------------------------------------------------------------------------
# Test: Low n_eff → suppressed C_evidence
# Requirement: 8.2
# ---------------------------------------------------------------------------
class TestLowNEffConfidence:
"""Low n_eff_total → C_evidence is suppressed."""
def test_very_low_n_eff_suppresses_c_evidence(self):
"""n_eff_total=0.5 → C_evidence = 1 - exp(-0.1) ≈ 0.095."""
# C_evidence = 1 - exp(-0.5 / 5.0) = 1 - exp(-0.1) ≈ 0.0952
expected_c_evidence = 1.0 - math.exp(-0.1)
assert expected_c_evidence == pytest.approx(0.0952, rel=1e-2)
confidence = compute_v3_confidence(
n_eff_total=0.5,
q_values=[0.8],
llrs=[1.0],
strength=0.8,
regime_confidence_mult=1.0,
contradiction=0.0,
data_quality=0.9,
)
# Confidence is bounded by C_evidence ≈ 0.095
assert confidence < 0.15
def test_high_n_eff_yields_higher_confidence(self):
"""Higher n_eff → higher C_evidence → higher overall confidence."""
conf_low = compute_v3_confidence(
n_eff_total=1.0,
q_values=[0.8],
llrs=[1.0],
strength=0.6,
regime_confidence_mult=1.0,
contradiction=0.0,
data_quality=0.8,
)
conf_high = compute_v3_confidence(
n_eff_total=10.0,
q_values=[0.8],
llrs=[1.0],
strength=0.6,
regime_confidence_mult=1.0,
contradiction=0.0,
data_quality=0.8,
)
assert conf_high > conf_low
# ---------------------------------------------------------------------------
# Test: Data quality computed from known inputs
# Requirement: 17.1, 17.2, 17.3, 17.4, 17.5, 17.6
# ---------------------------------------------------------------------------
class TestDataQualityComputation:
"""Verify data quality formula with known inputs."""
def test_high_quality_inputs(self):
"""Zero failure, fresh signal, many sources → high quality."""
units = [_make_unit() for _ in range(10)]
dq = compute_v3_data_quality(
units=units,
extraction_failure_rate=0.0,
age_newest_hours=1.0,
n_source_types=4,
)
# Q_parse=1.0, Q_fresh=exp(-1/168)≈0.994, Q_coverage=1-exp(-2)≈0.865,
# Q_diversity=min(1, log2(5)/log2(4))=1.0
assert dq > 0.60
def test_high_extraction_failure_rate(self):
"""extraction_failure_rate=0.8 → Q_parse=0.2 → low quality."""
units = [_make_unit() for _ in range(5)]
dq = compute_v3_data_quality(
units=units,
extraction_failure_rate=0.8,
age_newest_hours=1.0,
n_source_types=3,
)
# Q_parse = 0.2 → heavily suppresses data_quality
assert dq < 0.30
def test_zero_sources_zero_diversity(self):
"""No source types → Q_diversity = 0 → data_quality = 0."""
units = [_make_unit() for _ in range(5)]
dq = compute_v3_data_quality(
units=units,
extraction_failure_rate=0.0,
age_newest_hours=1.0,
n_source_types=0,
)
assert dq == 0.0
def test_empty_units_low_coverage(self):
"""No valid units → Q_coverage = 1 - exp(0) = 0 → data_quality = 0."""
dq = compute_v3_data_quality(
units=[],
extraction_failure_rate=0.0,
age_newest_hours=1.0,
n_source_types=3,
)
assert dq == 0.0
def test_stale_signal_decays_quality(self):
"""Very old signal → Q_fresh low → suppresses data_quality."""
units = [_make_unit() for _ in range(5)]
dq_fresh = compute_v3_data_quality(
units=units,
extraction_failure_rate=0.0,
age_newest_hours=1.0,
n_source_types=3,
)
dq_stale = compute_v3_data_quality(
units=units,
extraction_failure_rate=0.0,
age_newest_hours=500.0,
n_source_types=3,
)
assert dq_stale < dq_fresh
# ---------------------------------------------------------------------------
# Test: Force informational mode
# Requirement: 17.7, 17.8
# ---------------------------------------------------------------------------
class TestForceInformational:
"""Test should_force_informational_v3 forcing conditions."""
def test_data_quality_below_threshold_forces(self):
"""data_quality < 0.50 → forces informational."""
units = [_make_unit() for _ in range(5)]
forced, reason = should_force_informational_v3(
data_quality=0.49,
units=units,
extraction_failure_rate=0.0,
)
assert forced is True
assert reason == "data_quality_below_threshold"
def test_data_quality_at_threshold_does_not_force(self):
"""data_quality = 0.50 → does NOT force informational."""
units = [_make_unit() for _ in range(5)]
forced, reason = should_force_informational_v3(
data_quality=0.50,
units=units,
extraction_failure_rate=0.0,
)
assert forced is False
def test_insufficient_evidence_forces(self):
"""N_valid < 2 → forces informational."""
units = [_make_unit()]
forced, reason = should_force_informational_v3(
data_quality=0.80,
units=units,
extraction_failure_rate=0.0,
)
assert forced is True
assert reason == "insufficient_evidence_count"
def test_high_extraction_failure_forces(self):
"""extraction_failure_rate > 0.50 → Q_parse < 0.50 → forces."""
units = [_make_unit() for _ in range(5)]
forced, reason = should_force_informational_v3(
data_quality=0.80,
units=units,
extraction_failure_rate=0.51,
)
assert forced is True
assert reason == "extraction_parse_rate_below_threshold"
def test_only_macro_signals_forces(self):
"""Only macro/competitive evidence (no company) → forces."""
units = [_make_unit(layer="macro"), _make_unit(layer="competitive")]
forced, reason = should_force_informational_v3(
data_quality=0.80,
units=units,
extraction_failure_rate=0.0,
)
assert forced is True
assert reason == "macro_competitive_only_evidence"
def test_only_macro_with_macro_only_enabled_does_not_force(self):
"""Only macro evidence WITH macro_only_enabled → does NOT force."""
units = [_make_unit(layer="macro"), _make_unit(layer="macro")]
forced, reason = should_force_informational_v3(
data_quality=0.80,
units=units,
extraction_failure_rate=0.0,
macro_only_enabled=True,
)
assert forced is False
def test_mixed_signals_does_not_force(self):
"""Company + macro evidence → does NOT force."""
units = [_make_unit(layer="company"), _make_unit(layer="macro")]
forced, reason = should_force_informational_v3(
data_quality=0.80,
units=units,
extraction_failure_rate=0.0,
)
assert forced is False
def test_good_inputs_do_not_force(self):
"""All good → no forcing."""
units = [_make_unit() for _ in range(5)]
forced, reason = should_force_informational_v3(
data_quality=0.80,
units=units,
extraction_failure_rate=0.1,
)
assert forced is False
assert reason == ""
+176
View File
@@ -0,0 +1,176 @@
"""Unit tests for v3 LLR entropy contradiction score.
Tests compute_v3_contradiction from services.aggregation.contradiction.
Requirements validated: 7.17.7
"""
from __future__ import annotations
import math
import pytest
from services.aggregation.contradiction import compute_v3_contradiction
from services.aggregation.worker import EvidenceCluster
def _make_cluster(cluster_llr: float) -> EvidenceCluster:
"""Helper to create a minimal EvidenceCluster with a given cluster_llr."""
return EvidenceCluster(
cluster_id="test", units=[], llrs=[], n_eff=1.0, cluster_llr=cluster_llr
)
# ---------------------------------------------------------------------------
# 1. All bullish (unidirectional positive) → contradiction = 0.0
# ---------------------------------------------------------------------------
class TestUnidirectionalPositive:
"""When all cluster LLRs are positive, there is no contradiction."""
def test_all_bullish(self):
clusters = [_make_cluster(1.0), _make_cluster(0.5), _make_cluster(0.8)]
assert compute_v3_contradiction(clusters) == 0.0
def test_single_bullish(self):
clusters = [_make_cluster(2.0)]
assert compute_v3_contradiction(clusters) == 0.0
# ---------------------------------------------------------------------------
# 2. All bearish (unidirectional negative) → contradiction = 0.0
# ---------------------------------------------------------------------------
class TestUnidirectionalNegative:
"""When all cluster LLRs are negative, there is no contradiction."""
def test_all_bearish(self):
clusters = [_make_cluster(-1.0), _make_cluster(-0.5)]
assert compute_v3_contradiction(clusters) == 0.0
def test_single_bearish(self):
clusters = [_make_cluster(-2.5)]
assert compute_v3_contradiction(clusters) == 0.0
# ---------------------------------------------------------------------------
# 3. Equal split → high contradiction near 1.0
# ---------------------------------------------------------------------------
class TestEqualSplit:
"""Equal positive and negative evidence produces maximum entropy (H=1.0),
modulated by the volume factor."""
def test_equal_split_moderate_mass(self):
"""LLR +2.0 and -2.0: E_pos=2, E_neg=2, E_total=4.
H_conflict = 1.0 (50/50 split).
volume_factor = 1 - exp(-4/3) ≈ 0.7364.
contradiction ≈ 0.7364.
"""
clusters = [_make_cluster(2.0), _make_cluster(-2.0)]
result = compute_v3_contradiction(clusters)
expected_volume = 1.0 - math.exp(-4.0 / 3.0)
expected = 1.0 * expected_volume # H_conflict = 1.0 for equal split
assert result == pytest.approx(expected, abs=1e-4)
def test_equal_split_large_mass(self):
"""LLR +5.0 and -5.0: E_total=10, volume_factor → ~0.964.
contradiction near 1.0.
"""
clusters = [_make_cluster(5.0), _make_cluster(-5.0)]
result = compute_v3_contradiction(clusters)
expected_volume = 1.0 - math.exp(-10.0 / 3.0)
expected = 1.0 * expected_volume
assert result == pytest.approx(expected, abs=1e-4)
assert result > 0.9 # Near 1.0 due to large evidence mass
# ---------------------------------------------------------------------------
# 4. E_total = 0 → contradiction = 0.0
# ---------------------------------------------------------------------------
class TestZeroEvidence:
"""When all cluster LLRs are zero, E_total=0, contradiction is 0."""
def test_all_zero_llrs(self):
clusters = [_make_cluster(0.0), _make_cluster(0.0), _make_cluster(0.0)]
assert compute_v3_contradiction(clusters) == 0.0
def test_single_zero_llr(self):
clusters = [_make_cluster(0.0)]
assert compute_v3_contradiction(clusters) == 0.0
# ---------------------------------------------------------------------------
# 5. Empty clusters → contradiction = 0.0
# ---------------------------------------------------------------------------
class TestEmptyClusters:
"""Empty cluster list returns 0.0."""
def test_empty_list(self):
assert compute_v3_contradiction([]) == 0.0
# ---------------------------------------------------------------------------
# 6. Small evidence mass → suppressed score (volume_factor effect)
# ---------------------------------------------------------------------------
class TestSmallEvidenceMass:
"""Small E_total leads to a small volume_factor that suppresses the score."""
def test_tiny_equal_split(self):
"""LLR +0.1 and -0.1: E_total=0.2.
H_conflict = 1.0 (equal split).
volume_factor = 1 - exp(-0.2/3) ≈ 0.0645.
contradiction ≈ 0.0645 (suppressed).
"""
clusters = [_make_cluster(0.1), _make_cluster(-0.1)]
result = compute_v3_contradiction(clusters)
expected_volume = 1.0 - math.exp(-0.2 / 3.0)
expected = 1.0 * expected_volume
assert result == pytest.approx(expected, abs=1e-4)
# Confirm suppression: score well below 0.1
assert result < 0.1
# ---------------------------------------------------------------------------
# 7. Large evidence mass → volume_factor approaches 1.0
# ---------------------------------------------------------------------------
class TestLargeEvidenceMass:
"""Large E_total pushes volume_factor near 1.0, so score ≈ H_conflict."""
def test_large_equal_split(self):
"""LLR +5.0 and -5.0: E_total=10, volume_factor ≈ 0.964.
contradiction ≈ 0.964 (near maximum).
"""
clusters = [_make_cluster(5.0), _make_cluster(-5.0)]
result = compute_v3_contradiction(clusters)
# Volume factor should be very close to 1.0
volume_factor = 1.0 - math.exp(-10.0 / 3.0)
assert volume_factor > 0.95
assert result > 0.95
def test_asymmetric_large_mass(self):
"""LLR +4.0 and -1.0: E_pos=4, E_neg=1, E_total=5.
f_pos=0.8, f_neg=0.2.
H_conflict = -0.8×log2(0.8) - 0.2×log2(0.2) ≈ 0.7219.
volume_factor = 1 - exp(-5/3) ≈ 0.8111.
contradiction ≈ 0.586.
"""
clusters = [_make_cluster(4.0), _make_cluster(-1.0)]
result = compute_v3_contradiction(clusters)
f_pos = 4.0 / 5.0
f_neg = 1.0 / 5.0
h_conflict = -f_pos * math.log2(f_pos) - f_neg * math.log2(f_neg)
volume_factor = 1.0 - math.exp(-5.0 / 3.0)
expected = h_conflict * volume_factor
assert result == pytest.approx(expected, abs=1e-4)
+629
View File
@@ -0,0 +1,629 @@
"""Unit tests for v3 EV gate, return distribution, and eligibility.
Tests the return distribution computation, regime-specific min_edge thresholds,
mode escalation logic, posterior state projection, and divergence detection.
Requirements validated: 11.111.7, 12.112.7, 13.113.5
"""
from __future__ import annotations
import math
import pytest
from services.aggregation.projection import V3ProjectionState, compute_v3_projection
from services.aggregation.regime import MarketRegime, V3RegimeClassification
from services.recommendation.eligibility import (
ReturnDistribution,
V3Eligibility,
compute_return_distribution,
compute_v3_eligibility,
)
# ---------------------------------------------------------------------------
# Helper: construct a V3RegimeClassification for tests
# ---------------------------------------------------------------------------
def _make_regime(regime: MarketRegime, phi: float = 0.50) -> V3RegimeClassification:
"""Create a minimal V3RegimeClassification for testing."""
params = {
MarketRegime.PANIC: (0.70, 0.70, 0.35, 2.5),
MarketRegime.TREND_FOLLOWING: (1.10, 1.00, 0.80, 1.8),
MarketRegime.MEAN_REVERSION: (0.90, 0.95, 0.55, 1.4),
MarketRegime.UNCERTAINTY: (0.80, 0.85, 0.50, 2.0),
}
gamma, conf_mult, phi_val, atr_mult = params[regime]
return V3RegimeClassification(
regime=regime,
trend_z=0.0,
vol_ratio=1.0,
evidence_multiplier=gamma,
confidence_multiplier=conf_mult,
phi_decay=phi_val,
atr_multiplier=atr_mult,
)
# ---------------------------------------------------------------------------
# EV positive → eligible (Req 12.112.7)
# ---------------------------------------------------------------------------
class TestEVPositiveEligible:
"""Tests that positive EV with passing quality gates → eligible=True."""
def test_strong_signal_trend_following(self):
"""a_projected=2.0, conf=0.8, vol=0.25, h=7, costs=0.001, trend_following → eligible."""
result = compute_return_distribution(
a_projected=2.0,
confidence=0.8,
realized_vol_20d=0.25,
horizon_days=7,
costs=0.001,
regime="trend_following",
confidence_actual=0.8,
contradiction=0.1,
n_eff_total=5.0,
data_quality=0.8,
)
assert isinstance(result, ReturnDistribution)
assert result.ev_long > 0.0
assert result.ev_long > result.min_edge
assert result.eligible is True
# Verify min_edge for trend_following
assert result.min_edge == pytest.approx(0.0035, abs=1e-9)
def test_sigma_h_formula(self):
"""Verify sigma_h = realized_vol * sqrt(horizon / 252)."""
result = compute_return_distribution(
a_projected=2.0,
confidence=0.8,
realized_vol_20d=0.25,
horizon_days=7,
costs=0.001,
regime="trend_following",
confidence_actual=0.8,
contradiction=0.1,
n_eff_total=5.0,
data_quality=0.8,
)
expected_sigma_h = 0.25 * math.sqrt(7 / 252.0)
assert result.sigma_h == pytest.approx(expected_sigma_h, rel=1e-9)
def test_mu_h_formula(self):
"""Verify mu_h = tanh(A_projected / 3.0) * confidence * sigma_h."""
result = compute_return_distribution(
a_projected=2.0,
confidence=0.8,
realized_vol_20d=0.25,
horizon_days=7,
costs=0.001,
regime="trend_following",
confidence_actual=0.8,
contradiction=0.1,
n_eff_total=5.0,
data_quality=0.8,
)
sigma_h = 0.25 * math.sqrt(7 / 252.0)
expected_mu_h = math.tanh(2.0 / 3.0) * 0.8 * sigma_h
assert result.mu_h == pytest.approx(expected_mu_h, rel=1e-9)
# ---------------------------------------------------------------------------
# EV negative → ineligible (Req 12.312.5)
# ---------------------------------------------------------------------------
class TestEVNegativeIneligible:
"""Tests that weak signals with negative or sub-threshold EV → ineligible."""
def test_weak_signal_high_costs(self):
"""a_projected=0.01, conf=0.3, costs=0.01 → EV < min_edge → ineligible."""
result = compute_return_distribution(
a_projected=0.01,
confidence=0.3,
realized_vol_20d=0.25,
horizon_days=7,
costs=0.01,
regime="uncertainty",
confidence_actual=0.3,
contradiction=0.5,
n_eff_total=1.0,
data_quality=0.4,
)
# Weak signal: tanh(0.01/3) ≈ 0.0033, * 0.3 * sigma_h is tiny
# Costs + CVaR should dominate → EV negative
assert result.ev_long < result.min_edge
assert result.eligible is False
def test_zero_alpha_negative_ev(self):
"""a_projected=0 → mu_h=0, then costs + CVaR push EV negative."""
result = compute_return_distribution(
a_projected=0.0,
confidence=0.5,
realized_vol_20d=0.25,
horizon_days=7,
costs=0.001,
regime="trend_following",
confidence_actual=0.7,
contradiction=0.1,
n_eff_total=5.0,
data_quality=0.8,
)
# mu_h = tanh(0) * ... = 0. EV = 0 - costs - 0.10*CVaR < 0
assert result.mu_h == pytest.approx(0.0, abs=1e-12)
assert result.ev_long < 0.0
assert result.eligible is False
def test_quality_gate_fails_despite_positive_ev(self):
"""Strong EV but low n_eff → ineligible (quality gate blocks)."""
result = compute_return_distribution(
a_projected=3.0,
confidence=0.9,
realized_vol_20d=0.25,
horizon_days=7,
costs=0.001,
regime="trend_following",
confidence_actual=0.9,
contradiction=0.1,
n_eff_total=1.0, # Below 2.0 threshold
data_quality=0.8,
)
# EV should be positive, but n_eff < 2.0 fails quality gate
assert result.ev_long > 0.0
assert result.eligible is False
# ---------------------------------------------------------------------------
# Regime-specific min_edge thresholds (Req 12.4)
# ---------------------------------------------------------------------------
class TestRegimeMinEdge:
"""Tests that regime-specific min_edge values are correct."""
def test_panic_min_edge_strictest(self):
"""Panic regime has min_edge = 0.0100 (strictest)."""
result = compute_return_distribution(
a_projected=1.0,
confidence=0.5,
realized_vol_20d=0.25,
horizon_days=7,
costs=0.001,
regime="panic",
confidence_actual=0.7,
contradiction=0.2,
n_eff_total=3.0,
data_quality=0.8,
)
assert result.min_edge == pytest.approx(0.0100, abs=1e-9)
def test_trend_following_min_edge_most_lenient(self):
"""Trend following has min_edge = 0.0035 (most lenient)."""
result = compute_return_distribution(
a_projected=1.0,
confidence=0.5,
realized_vol_20d=0.25,
horizon_days=7,
costs=0.001,
regime="trend_following",
confidence_actual=0.7,
contradiction=0.2,
n_eff_total=3.0,
data_quality=0.8,
)
assert result.min_edge == pytest.approx(0.0035, abs=1e-9)
def test_mean_reversion_min_edge(self):
"""Mean reversion has min_edge = 0.0050."""
result = compute_return_distribution(
a_projected=1.0,
confidence=0.5,
realized_vol_20d=0.25,
horizon_days=7,
costs=0.001,
regime="mean_reversion",
confidence_actual=0.7,
contradiction=0.2,
n_eff_total=3.0,
data_quality=0.8,
)
assert result.min_edge == pytest.approx(0.0050, abs=1e-9)
def test_uncertainty_min_edge(self):
"""Uncertainty has min_edge = 0.0075."""
result = compute_return_distribution(
a_projected=1.0,
confidence=0.5,
realized_vol_20d=0.25,
horizon_days=7,
costs=0.001,
regime="uncertainty",
confidence_actual=0.7,
contradiction=0.2,
n_eff_total=3.0,
data_quality=0.8,
)
assert result.min_edge == pytest.approx(0.0075, abs=1e-9)
def test_unknown_regime_defaults_to_uncertainty(self):
"""Unknown regime string → falls back to uncertainty min_edge."""
result = compute_return_distribution(
a_projected=1.0,
confidence=0.5,
realized_vol_20d=0.25,
horizon_days=7,
costs=0.001,
regime="nonexistent_regime",
confidence_actual=0.7,
contradiction=0.2,
n_eff_total=3.0,
data_quality=0.8,
)
assert result.min_edge == pytest.approx(0.0075, abs=1e-9)
# ---------------------------------------------------------------------------
# Mode escalation: live vs paper vs informational (Req 13.113.5)
# ---------------------------------------------------------------------------
class TestModeEscalation:
"""Tests for v3 mode escalation logic."""
def test_live_eligible(self):
"""BUY with high conf/low contra/high n_eff/EV >> min_edge → live."""
result = compute_v3_eligibility(
p_up=0.75,
ev_long=0.020, # >> 2 * 0.0035 = 0.0070
min_edge=0.0035,
confidence=0.80,
contradiction=0.10,
strength=0.50,
n_eff_total=6.0,
data_quality=0.85,
regime="trend_following",
has_existing_position=False,
risk_engine_passed=True,
)
assert isinstance(result, V3Eligibility)
assert result.action == "BUY"
assert result.mode == "live"
assert result.eligible is True
def test_paper_eligible(self):
"""BUY with moderate confidence → paper."""
result = compute_v3_eligibility(
p_up=0.70,
ev_long=0.010, # > min_edge but < 2 * min_edge for live
min_edge=0.0035,
confidence=0.65, # >= 0.60 for paper but < 0.75 for live
contradiction=0.15,
strength=0.40,
n_eff_total=4.0,
data_quality=0.80,
regime="trend_following",
has_existing_position=False,
risk_engine_passed=True,
)
assert result.action == "BUY"
assert result.mode == "paper"
assert result.eligible is True
def test_informational_low_confidence(self):
"""BUY with low confidence → informational."""
result = compute_v3_eligibility(
p_up=0.70,
ev_long=0.010,
min_edge=0.0035,
confidence=0.55, # >= regime min but < 0.60 for paper
contradiction=0.15,
strength=0.40,
n_eff_total=4.0,
data_quality=0.80,
regime="trend_following",
has_existing_position=False,
risk_engine_passed=True,
)
assert result.action == "BUY"
assert result.mode == "informational"
def test_hold_always_informational(self):
"""HOLD action is always informational regardless of quality."""
result = compute_v3_eligibility(
p_up=0.55, # Below bullish threshold for trend_following (0.60)
ev_long=0.020,
min_edge=0.0035,
confidence=0.90,
contradiction=0.05,
strength=0.50,
n_eff_total=10.0,
data_quality=0.95,
regime="trend_following",
has_existing_position=True,
risk_engine_passed=True,
)
assert result.action == "HOLD"
assert result.mode == "informational"
def test_watch_when_ineligible(self):
"""Low confidence below regime min → WATCH."""
result = compute_v3_eligibility(
p_up=0.80,
ev_long=0.020,
min_edge=0.0035,
confidence=0.40, # Below trend_following min of 0.55
contradiction=0.10,
strength=0.60,
n_eff_total=5.0,
data_quality=0.80,
regime="trend_following",
has_existing_position=False,
risk_engine_passed=True,
)
assert result.action == "WATCH"
assert result.eligible is False
def test_risk_engine_blocks_live(self):
"""Risk engine failure blocks live but allows paper."""
result = compute_v3_eligibility(
p_up=0.75,
ev_long=0.020,
min_edge=0.0035,
confidence=0.80,
contradiction=0.10,
strength=0.50,
n_eff_total=6.0,
data_quality=0.85,
regime="trend_following",
has_existing_position=False,
risk_engine_passed=False,
)
# Both live and paper require risk_engine_passed
assert result.action == "BUY"
assert result.mode == "informational"
def test_sell_on_negative_ev_with_position(self):
"""Existing position with negative EV → SELL."""
result = compute_v3_eligibility(
p_up=0.35, # bearish
ev_long=-0.005,
min_edge=0.0035,
confidence=0.70,
contradiction=0.15,
strength=0.30,
n_eff_total=5.0,
data_quality=0.80,
regime="trend_following",
has_existing_position=True,
risk_engine_passed=True,
)
assert result.action == "SELL"
# ---------------------------------------------------------------------------
# Projection decay convergence (Req 11.111.4)
# ---------------------------------------------------------------------------
class TestProjectionDecay:
"""Tests for compute_v3_projection decay behavior."""
def test_evidence_accumulates(self):
"""cluster_llrs=[1.0, 0.5] → A_t = phi*A_prev + 1.5."""
regime = _make_regime(MarketRegime.TREND_FOLLOWING)
result = compute_v3_projection(
a_prev=0.0,
cluster_llrs=[1.0, 0.5],
regime=regime,
p_prior=0.50,
projection_horizon=1,
)
# A_t = 0.80 * 0.0 + 1.5 = 1.5
assert result.a_t == pytest.approx(1.5, abs=1e-9)
# P_up_projected should be > 0.5 (bullish evidence)
assert result.p_up_projected > 0.5
def test_projection_horizon_decays(self):
"""Higher projection_horizon → stronger decay → closer to prior."""
regime = _make_regime(MarketRegime.TREND_FOLLOWING)
# phi=0.80, horizon=5 → phi^5 = 0.32768
result_h1 = compute_v3_projection(
a_prev=0.0,
cluster_llrs=[2.0],
regime=regime,
p_prior=0.50,
projection_horizon=1,
)
result_h5 = compute_v3_projection(
a_prev=0.0,
cluster_llrs=[2.0],
regime=regime,
p_prior=0.50,
projection_horizon=5,
)
# Longer horizon → more decay → projected_strength should be lower
assert result_h5.projected_strength < result_h1.projected_strength
# Both still bullish
assert result_h1.p_up_projected > 0.5
assert result_h5.p_up_projected > 0.5
def test_panic_decays_faster_than_trend(self):
"""Panic (phi=0.35) decays much faster than trend_following (phi=0.80)."""
regime_panic = _make_regime(MarketRegime.PANIC)
regime_trend = _make_regime(MarketRegime.TREND_FOLLOWING)
result_panic = compute_v3_projection(
a_prev=0.0,
cluster_llrs=[2.0],
regime=regime_panic,
p_prior=0.50,
projection_horizon=3,
)
result_trend = compute_v3_projection(
a_prev=0.0,
cluster_llrs=[2.0],
regime=regime_trend,
p_prior=0.50,
projection_horizon=3,
)
# Trend should retain more signal after projection
assert result_trend.projected_strength > result_panic.projected_strength
def test_phi_regime_stored(self):
"""Result stores the correct phi_regime value."""
regime = _make_regime(MarketRegime.MEAN_REVERSION)
result = compute_v3_projection(
a_prev=0.0,
cluster_llrs=[1.0],
regime=regime,
p_prior=0.50,
projection_horizon=1,
)
assert result.phi_regime == pytest.approx(0.55, abs=1e-9)
def test_a_prev_contributes(self):
"""Non-zero a_prev gets decayed and added to new evidence."""
regime = _make_regime(MarketRegime.UNCERTAINTY) # phi=0.50
result = compute_v3_projection(
a_prev=2.0,
cluster_llrs=[1.0],
regime=regime,
p_prior=0.50,
projection_horizon=1,
)
# A_t = 0.50 * 2.0 + 1.0 = 2.0
assert result.a_t == pytest.approx(2.0, abs=1e-9)
# ---------------------------------------------------------------------------
# Divergence flag behavior (Req 11.6)
# ---------------------------------------------------------------------------
class TestDivergenceFlag:
"""Tests for divergence detection between current and projected P_up."""
def test_no_divergence_same_direction(self):
"""Bullish current and projected → diverges=False."""
regime = _make_regime(MarketRegime.TREND_FOLLOWING)
result = compute_v3_projection(
a_prev=0.0,
cluster_llrs=[2.0],
regime=regime,
p_prior=0.50,
projection_horizon=1,
)
# Both current P_up_t and projected should be > 0.5 (bullish)
assert result.diverges is False
def test_divergence_strong_decay(self):
"""Bullish current but projected decay crosses 0.5 boundary → diverges=True."""
# Use panic regime (phi=0.35) with small evidence and large projection horizon
regime = _make_regime(MarketRegime.PANIC)
# A_t = 0.35 * 0 + 0.1 = 0.1 → P_up_t > 0.5 (bullish)
# A_projected = 0.35^20 * 0.1 ≈ 0 → P_up_projected ≈ 0.5
# Need negative catalyst to push projected below 0.5
result = compute_v3_projection(
a_prev=0.0,
cluster_llrs=[0.5], # Mild bullish evidence
regime=regime,
p_prior=0.50,
projection_horizon=20,
known_catalyst_llr=-1.0, # Bearish catalyst flips projected direction
)
# Current: A_t = 0.5, P_up_t = sigmoid(0.5) > 0.5 (bullish)
# Projected: phi^20 * 0.5 - 1.0 = practically -1.0 → P_up_projected < 0.5
assert result.diverges is True
def test_no_divergence_both_bearish(self):
"""Bearish current and projected → diverges=False."""
regime = _make_regime(MarketRegime.TREND_FOLLOWING)
result = compute_v3_projection(
a_prev=0.0,
cluster_llrs=[-2.0],
regime=regime,
p_prior=0.50,
projection_horizon=1,
)
# Both should be < 0.5 (bearish)
assert result.p_up_projected < 0.5
assert result.diverges is False
def test_known_catalyst_shifts_projection(self):
"""known_catalyst_llr adds to projected alpha."""
regime = _make_regime(MarketRegime.TREND_FOLLOWING)
result_no_catalyst = compute_v3_projection(
a_prev=0.0,
cluster_llrs=[1.0],
regime=regime,
p_prior=0.50,
projection_horizon=1,
known_catalyst_llr=0.0,
)
result_with_catalyst = compute_v3_projection(
a_prev=0.0,
cluster_llrs=[1.0],
regime=regime,
p_prior=0.50,
projection_horizon=1,
known_catalyst_llr=1.0,
)
# Catalyst boosts projected P_up
assert result_with_catalyst.p_up_projected > result_no_catalyst.p_up_projected
# ---------------------------------------------------------------------------
# Default vol (Req 12.7)
# ---------------------------------------------------------------------------
class TestDefaultVolatility:
"""Tests that realized_vol_20d=0 defaults to 0.25."""
def test_zero_vol_uses_default(self):
"""realized_vol_20d=0 → uses 0.25 default."""
result_zero = compute_return_distribution(
a_projected=2.0,
confidence=0.8,
realized_vol_20d=0,
horizon_days=7,
costs=0.001,
regime="trend_following",
confidence_actual=0.8,
contradiction=0.1,
n_eff_total=5.0,
data_quality=0.8,
)
result_default = compute_return_distribution(
a_projected=2.0,
confidence=0.8,
realized_vol_20d=0.25,
horizon_days=7,
costs=0.001,
regime="trend_following",
confidence_actual=0.8,
contradiction=0.1,
n_eff_total=5.0,
data_quality=0.8,
)
assert result_zero.sigma_h == pytest.approx(result_default.sigma_h, abs=1e-12)
assert result_zero.ev_long == pytest.approx(result_default.ev_long, abs=1e-12)
def test_negative_vol_uses_default(self):
"""realized_vol_20d=-0.1 → uses 0.25 default."""
result = compute_return_distribution(
a_projected=2.0,
confidence=0.8,
realized_vol_20d=-0.1,
horizon_days=7,
costs=0.001,
regime="trend_following",
confidence_actual=0.8,
contradiction=0.1,
n_eff_total=5.0,
data_quality=0.8,
)
expected_sigma_h = 0.25 * math.sqrt(7 / 252.0)
assert result.sigma_h == pytest.approx(expected_sigma_h, rel=1e-9)
+569
View File
@@ -0,0 +1,569 @@
"""Unit tests for EvidenceUnit normalization and LLR conversion.
Validates: Requirements 1.11.8, 2.12.9, 3.13.6
"""
from __future__ import annotations
import math
from datetime import datetime, timezone
import pytest
from services.aggregation.scoring import (
EvidenceUnit,
ReliabilityComponents,
SourceStats,
compute_llr,
compute_v3_reliability,
normalize_company_signal,
normalize_competitive_signal,
normalize_macro_signal,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
_NOW = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
def _make_company_signal(**overrides) -> dict:
"""Create a minimal valid company signal dict."""
base = {
"symbol": "AAPL",
"timestamp": _NOW,
"source_id": "doc-001",
"event_type": "earnings",
"source_group": "company",
"horizon": "7d",
"sentiment": "positive",
"sentiment_strength": 0.8,
"impact": 0.7,
"extraction_conf": 0.9,
"source_cred": 0.85,
"novelty": 0.9,
}
base.update(overrides)
return base
def _make_macro_signal(**overrides) -> dict:
"""Create a minimal valid macro signal dict."""
base = {
"symbol": "MSFT",
"timestamp": _NOW,
"source_id": "event-100",
"event_type": "regulatory",
"impact_direction": "positive",
"macro_impact_score": 0.6,
"event_confidence": 0.75,
"estimated_duration": "medium_term",
}
base.update(overrides)
return base
def _make_competitive_signal(**overrides) -> dict:
"""Create a minimal valid competitive signal dict."""
base = {
"symbol": "GOOG",
"timestamp": _NOW,
"source_id": "comp-doc-55",
"event_type": "product_launch",
"signal_direction": "bearish",
"signal_strength": 0.7,
"relationship_strength": 0.8,
"pattern_confidence": 0.65,
"time_horizon": "30d",
}
base.update(overrides)
return base
# ===========================================================================
# TestNormalizeCompanySignal
# ===========================================================================
class TestNormalizeCompanySignal:
"""Test normalize_company_signal mapping and validation."""
def test_full_company_signal(self):
"""A complete company signal maps all fields correctly."""
sig = _make_company_signal()
eu = normalize_company_signal(sig)
assert eu is not None
assert eu.symbol == "AAPL"
assert eu.layer == "company"
assert eu.event_type == "earnings"
assert eu.source_id == "doc-001"
assert eu.source_group == "company"
assert eu.timestamp == _NOW
assert eu.horizon == "7d"
assert eu.direction == 1 # "positive" → +1
assert eu.sentiment_strength == 0.8
assert eu.impact == 0.7
assert eu.extraction_conf == 0.9
assert eu.source_cred == 0.85
assert eu.novelty == 0.9
assert eu.event_base_rate == 0.25 # earnings base rate
assert len(eu.cluster_id) == 16 # sha256 hex prefix
def test_missing_symbol_rejected(self):
"""Missing symbol → returns None with warning."""
sig = _make_company_signal(symbol=None)
assert normalize_company_signal(sig) is None
def test_missing_timestamp_rejected(self):
"""Missing timestamp → returns None with warning."""
sig = _make_company_signal(timestamp=None)
assert normalize_company_signal(sig) is None
def test_missing_source_id_rejected(self):
"""Missing source_id → returns None with warning."""
sig = _make_company_signal(source_id=None)
assert normalize_company_signal(sig) is None
def test_empty_string_symbol_rejected(self):
"""Empty string symbol → returns None (falsy check)."""
sig = _make_company_signal(symbol="")
assert normalize_company_signal(sig) is None
def test_direction_mappings(self):
"""Direction string mappings: positive→+1, negative→-1, neutral→0."""
for sentiment, expected in [
("positive", 1),
("negative", -1),
("neutral", 0),
("bullish", 1),
("bearish", -1),
("mixed", 0),
]:
eu = normalize_company_signal(_make_company_signal(sentiment=sentiment))
assert eu is not None
assert eu.direction == expected, f"'{sentiment}' should map to {expected}"
def test_missing_optional_fields_default_0_5(self):
"""Missing optional numeric fields substitute 0.5."""
sig = {
"symbol": "TSLA",
"timestamp": _NOW,
"source_id": "doc-xyz",
}
eu = normalize_company_signal(sig)
assert eu is not None
assert eu.sentiment_strength == 0.5
assert eu.impact == 0.5
assert eu.extraction_conf == 0.5
assert eu.source_cred == 0.5
assert eu.novelty == 0.5
def test_invalid_horizon_defaults_to_7d(self):
"""Invalid horizon string falls back to '7d'."""
sig = _make_company_signal(horizon="invalid_horizon")
eu = normalize_company_signal(sig)
assert eu is not None
assert eu.horizon == "7d"
def test_timestamp_string_parsed(self):
"""ISO timestamp string is parsed to datetime."""
sig = _make_company_signal(timestamp="2025-01-10T08:00:00+00:00")
eu = normalize_company_signal(sig)
assert eu is not None
assert eu.timestamp == datetime(2025, 1, 10, 8, 0, 0, tzinfo=timezone.utc)
def test_unknown_event_type_uses_default_base_rate(self):
"""Unknown event_type uses default base rate of 0.10."""
sig = _make_company_signal(event_type="mysterious_event")
eu = normalize_company_signal(sig)
assert eu is not None
assert eu.event_base_rate == 0.10
# ===========================================================================
# TestNormalizeMacroSignal
# ===========================================================================
class TestNormalizeMacroSignal:
"""Test normalize_macro_signal mapping and validation."""
def test_full_macro_signal(self):
"""A complete macro signal maps all fields correctly."""
sig = _make_macro_signal()
eu = normalize_macro_signal(sig)
assert eu is not None
assert eu.symbol == "MSFT"
assert eu.layer == "macro"
assert eu.source_group == "macro"
assert eu.direction == 1 # "positive" → +1
assert eu.impact == 0.6 # macro_impact_score
assert eu.source_cred == 0.75 # event_confidence
assert eu.extraction_conf == 0.75 # event_confidence
assert eu.novelty == 1.0 # default for new events
def test_horizon_short_term(self):
"""short_term → 7d."""
sig = _make_macro_signal(estimated_duration="short_term")
eu = normalize_macro_signal(sig)
assert eu is not None
assert eu.horizon == "7d"
def test_horizon_medium_term(self):
"""medium_term → 30d."""
sig = _make_macro_signal(estimated_duration="medium_term")
eu = normalize_macro_signal(sig)
assert eu is not None
assert eu.horizon == "30d"
def test_horizon_long_term(self):
"""long_term → 90d."""
sig = _make_macro_signal(estimated_duration="long_term")
eu = normalize_macro_signal(sig)
assert eu is not None
assert eu.horizon == "90d"
def test_missing_symbol_rejected(self):
"""Missing symbol in macro signal → None."""
sig = _make_macro_signal()
del sig["symbol"]
assert normalize_macro_signal(sig) is None
def test_ticker_alias_accepted(self):
"""'ticker' key is accepted as alias for 'symbol'."""
sig = _make_macro_signal()
del sig["symbol"]
sig["ticker"] = "AMZN"
eu = normalize_macro_signal(sig)
assert eu is not None
assert eu.symbol == "AMZN"
def test_event_id_alias_accepted(self):
"""'event_id' key is accepted as alias for 'source_id'."""
sig = _make_macro_signal()
del sig["source_id"]
sig["event_id"] = "global-evt-42"
eu = normalize_macro_signal(sig)
assert eu is not None
assert eu.source_id == "global-evt-42"
def test_direction_mapping_negative(self):
"""Negative impact_direction → direction = -1."""
sig = _make_macro_signal(impact_direction="negative")
eu = normalize_macro_signal(sig)
assert eu is not None
assert eu.direction == -1
def test_direction_mapping_neutral(self):
"""Neutral impact_direction → direction = 0."""
sig = _make_macro_signal(impact_direction="neutral")
eu = normalize_macro_signal(sig)
assert eu is not None
assert eu.direction == 0
# ===========================================================================
# TestNormalizeCompetitiveSignal
# ===========================================================================
class TestNormalizeCompetitiveSignal:
"""Test normalize_competitive_signal mapping and validation."""
def test_full_competitive_signal(self):
"""A complete competitive signal maps all fields correctly."""
sig = _make_competitive_signal()
eu = normalize_competitive_signal(sig)
assert eu is not None
assert eu.symbol == "GOOG"
assert eu.layer == "competitive"
assert eu.source_group == "competitive"
assert eu.horizon == "30d"
assert eu.direction == -1 # "bearish" → -1
def test_impact_is_product_of_strengths(self):
"""Impact = signal_strength × relationship_strength."""
sig = _make_competitive_signal(signal_strength=0.7, relationship_strength=0.8)
eu = normalize_competitive_signal(sig)
assert eu is not None
assert abs(eu.impact - 0.56) < 1e-9 # 0.7 × 0.8
def test_source_cred_from_pattern_confidence(self):
"""source_cred mapped from pattern_confidence."""
sig = _make_competitive_signal(pattern_confidence=0.65)
eu = normalize_competitive_signal(sig)
assert eu is not None
assert eu.source_cred == 0.65
assert eu.extraction_conf == 0.65
def test_direction_bullish(self):
"""signal_direction='bullish' → direction = +1."""
sig = _make_competitive_signal(signal_direction="bullish")
eu = normalize_competitive_signal(sig)
assert eu is not None
assert eu.direction == 1
def test_direction_neutral(self):
"""signal_direction='neutral' → direction = 0."""
sig = _make_competitive_signal(signal_direction="neutral")
eu = normalize_competitive_signal(sig)
assert eu is not None
assert eu.direction == 0
def test_novelty_defaults_to_1(self):
"""Novelty defaults to 1.0 for competitive signals."""
sig = _make_competitive_signal()
# Ensure no explicit novelty key
sig.pop("novelty", None)
eu = normalize_competitive_signal(sig)
assert eu is not None
assert eu.novelty == 1.0
def test_missing_required_source_id_rejected(self):
"""Missing source_id in competitive signal → None."""
sig = _make_competitive_signal(source_id=None)
# Also ensure alias key is absent
sig.pop("source_document_id", None)
assert normalize_competitive_signal(sig) is None
def test_target_ticker_alias(self):
"""'target_ticker' key accepted as alias for 'symbol'."""
sig = _make_competitive_signal()
del sig["symbol"]
sig["target_ticker"] = "META"
eu = normalize_competitive_signal(sig)
assert eu is not None
assert eu.symbol == "META"
def test_time_horizon_short_term_maps_to_7d(self):
"""Competitive time_horizon='short_term' maps to '7d' via macro map."""
sig = _make_competitive_signal(time_horizon="short_term")
eu = normalize_competitive_signal(sig)
assert eu is not None
assert eu.horizon == "7d"
# ===========================================================================
# TestReliabilityPipeline
# ===========================================================================
class TestReliabilityPipeline:
"""Test compute_v3_reliability with known inputs."""
def test_known_inputs_perfect_signal(self):
"""Perfect inputs (source_cred=1, extraction_conf=1, novelty=1, fresh, no duplicates) → q_i close to 1."""
unit = EvidenceUnit(
symbol="AAPL",
layer="company",
event_type="earnings",
source_id="doc-perfect",
source_group="company",
timestamp=_NOW,
horizon="7d",
direction=1,
sentiment_strength=1.0,
impact=1.0,
extraction_conf=1.0,
source_cred=1.0,
novelty=1.0,
event_base_rate=0.25,
cluster_id="test-cluster",
)
# Source with strong track record
stats = SourceStats(source_id="doc-perfect", hits=50, misses=0)
# Fresh signal (0 age)
rel = compute_v3_reliability(unit, stats, cluster_position=0, reference_time=_NOW)
# q_ext: sigmoid(8.0 * (1.0 - 0.55)) = sigmoid(3.6) ≈ 0.9734
assert rel.q_ext > 0.95
# q_source: E[theta] = (3+50)/(3+3+50+0) = 53/56 ≈ 0.946
# clamp((0.946 - 0.50) / 0.35, 0, 1) = clamp(1.274, 0, 1) = 1.0
assert rel.q_source == 1.0
# q_recency: fresh signal → 2^0 = 1.0
assert rel.q_recency == 1.0
# q_uniqueness: clamp(0.5 + 0.5*1.0, 0.5, 1.0) * 1/sqrt(1) = 1.0
assert rel.q_uniqueness == 1.0
# q_i should be close to 1 (bounded by q_ext ≈ 0.97)
assert rel.q_i > 0.90
def test_zero_history_source_yields_zero_q_source(self):
"""A source with zero history (hits=0, misses=0) → q_source = 0.0 (Req 2.3)."""
unit = EvidenceUnit(
symbol="AAPL",
layer="company",
event_type="earnings",
source_id="new-source",
source_group="company",
timestamp=_NOW,
horizon="7d",
direction=1,
sentiment_strength=0.8,
impact=0.7,
extraction_conf=0.9,
source_cred=0.85,
novelty=0.9,
event_base_rate=0.25,
cluster_id="test-cluster",
)
stats = SourceStats(source_id="new-source", hits=0, misses=0)
rel = compute_v3_reliability(unit, stats, cluster_position=0, reference_time=_NOW)
# E[theta] = 3/(3+3) = 0.5; clamp((0.5-0.5)/0.35, 0, 1) = 0.0
assert rel.q_source == 0.0
# Therefore q_i = 0.0 (multiplied by zero)
assert rel.q_i == 0.0
def test_duplicate_signal_penalized(self):
"""Signals later in a cluster (high cluster_position) get lower q_uniqueness."""
unit = EvidenceUnit(
symbol="AAPL",
layer="company",
event_type="earnings",
source_id="doc-dup",
source_group="company",
timestamp=_NOW,
horizon="7d",
direction=1,
sentiment_strength=0.8,
impact=0.7,
extraction_conf=0.9,
source_cred=0.85,
novelty=0.9,
event_base_rate=0.25,
cluster_id="test-cluster",
)
stats = SourceStats(source_id="doc-dup", hits=20, misses=5)
rel_first = compute_v3_reliability(unit, stats, cluster_position=0, reference_time=_NOW)
rel_third = compute_v3_reliability(unit, stats, cluster_position=3, reference_time=_NOW)
# Third signal has lower q_uniqueness due to 1/sqrt(1+3) = 0.5
assert rel_third.q_uniqueness < rel_first.q_uniqueness
assert rel_third.q_i < rel_first.q_i
def test_stale_signal_low_recency(self):
"""A signal that is very old gets low q_recency."""
old_timestamp = datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
unit = EvidenceUnit(
symbol="AAPL",
layer="company",
event_type="earnings",
source_id="doc-old",
source_group="company",
timestamp=old_timestamp,
horizon="7d",
direction=1,
sentiment_strength=0.8,
impact=0.7,
extraction_conf=0.9,
source_cred=0.85,
novelty=0.9,
event_base_rate=0.25,
cluster_id="test-cluster",
)
stats = SourceStats(source_id="doc-old", hits=20, misses=5)
rel = compute_v3_reliability(unit, stats, cluster_position=0, reference_time=_NOW)
# Over a year old with 7d horizon (tau_base=72h) → q_recency very low
# Display floor is 0.01
assert rel.q_recency == 0.01
# ===========================================================================
# TestLLRConversion
# ===========================================================================
class TestLLRConversion:
"""Test compute_llr boundary cases and sign behavior."""
def _make_unit(self, direction: int, impact: float = 0.7, sentiment_strength: float = 0.8) -> EvidenceUnit:
"""Helper to create an EvidenceUnit with specified direction."""
return EvidenceUnit(
symbol="TEST",
layer="company",
event_type="earnings",
source_id="llr-test",
source_group="company",
timestamp=_NOW,
horizon="7d",
direction=direction,
sentiment_strength=sentiment_strength,
impact=impact,
extraction_conf=0.9,
source_cred=0.85,
novelty=0.9,
event_base_rate=0.25,
cluster_id="test-cluster",
)
def test_neutral_signal_zero_llr(self):
"""Neutral signal (direction=0) → LLR = 0.0 exactly (Req 3.3)."""
unit = self._make_unit(direction=0)
llr = compute_llr(unit, q_i=0.9)
assert llr == 0.0
def test_bullish_positive_llr(self):
"""Bullish signal (direction=+1) → positive LLR (Req 3.6)."""
unit = self._make_unit(direction=1)
llr = compute_llr(unit, q_i=0.9)
assert llr > 0.0
def test_bearish_negative_llr(self):
"""Bearish signal (direction=-1) → negative LLR (Req 3.6)."""
unit = self._make_unit(direction=-1)
llr = compute_llr(unit, q_i=0.9)
assert llr < 0.0
def test_p_correct_max_clamp(self):
"""Maximum p_correct = 0.85 → LLR ≈ ln(0.85/0.15) ≈ 1.735 (Req 3.5)."""
# With direction=+1, q_i=1.0, impact=1.0, sentiment_strength=1.0:
# p_correct = clamp(0.50 + 0.35*1*1*1, 0.501, 0.85) = 0.85
unit = self._make_unit(direction=1, impact=1.0, sentiment_strength=1.0)
llr = compute_llr(unit, q_i=1.0)
expected = math.log(0.85 / 0.15) # ≈ 1.7346
assert abs(llr - expected) < 0.001
def test_p_correct_min_clamp(self):
"""Minimum p_correct = 0.501 → |LLR| ≈ ln(0.501/0.499) ≈ 0.004 (Req 3.4)."""
# With direction=-1, q_i very small → p_correct clamps to 0.501
# q_i=0 → 0.50 + 0.35*0*anything = 0.50 → clamped to 0.501
unit = self._make_unit(direction=-1, impact=0.0, sentiment_strength=0.0)
llr = compute_llr(unit, q_i=0.0)
expected = -math.log(0.501 / 0.499) # ≈ -0.004
assert abs(llr - expected) < 0.001
def test_llr_sign_always_matches_direction(self):
"""For directional signals, LLR sign must match direction (Req 3.6)."""
for direction in [1, -1]:
for q_i in [0.0, 0.1, 0.5, 0.9, 1.0]:
unit = self._make_unit(direction=direction)
llr = compute_llr(unit, q_i=q_i)
if direction == 1:
assert llr > 0.0, f"direction=+1, q_i={q_i} should give positive LLR"
else:
assert llr < 0.0, f"direction=-1, q_i={q_i} should give negative LLR"
def test_llr_magnitude_bounded(self):
"""LLR magnitude is bounded by [≈0.004, ≈1.735] for directional signals."""
min_mag = math.log(0.501 / 0.499) # ≈ 0.004
max_mag = math.log(0.85 / 0.15) # ≈ 1.735
# Test at both extremes
unit_max = self._make_unit(direction=1, impact=1.0, sentiment_strength=1.0)
llr_max = compute_llr(unit_max, q_i=1.0)
assert abs(llr_max) <= max_mag + 0.001
unit_min = self._make_unit(direction=1, impact=0.0, sentiment_strength=0.0)
llr_min = compute_llr(unit_min, q_i=0.0)
assert abs(llr_min) >= min_mag - 0.001
+454
View File
@@ -0,0 +1,454 @@
"""Integration tests for the v3 calibrated evidence pipeline.
Tests the full pipeline path through pure functions end-to-end:
raw signals → EvidenceUnit → q_i → LLR → cluster → posterior → recommendation
Also validates feature flag routing and v3 metadata fields.
Requirements validated: 19.119.6, 20.120.5
"""
from __future__ import annotations
from datetime import datetime, timezone
from services.aggregation.bayesian import V3Posterior, compute_v3_posterior
from services.aggregation.contradiction import compute_v3_contradiction
from services.aggregation.regime import (
_DEFAULT_V3_UNCERTAINTY,
)
from services.aggregation.scoring import (
SourceStats,
compute_llr,
compute_v3_reliability,
normalize_company_signal,
)
from services.aggregation.worker import (
_annotate_pipeline_mode,
cluster_evidence,
compute_cluster_llr,
compute_n_eff,
compute_v3_confidence,
compute_v3_data_quality,
should_force_informational_v3,
)
# ---------------------------------------------------------------------------
# Fixtures / helpers
# ---------------------------------------------------------------------------
_NOW = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
_DEFAULT_REGIME = _DEFAULT_V3_UNCERTAINTY
def _make_signal(
sentiment: str = "positive",
impact: float = 0.7,
source_id: str = "doc1",
event_type: str = "earnings",
source_group: str = "company",
extraction_conf: float = 0.85,
source_cred: float = 0.80,
novelty: float = 0.7,
) -> dict:
"""Build a raw company signal dict for normalization."""
return {
"symbol": "AAPL",
"timestamp": _NOW,
"source_id": source_id,
"event_type": event_type,
"source_group": source_group,
"horizon": "7d",
"sentiment": sentiment,
"sentiment_strength": impact,
"impact": impact,
"extraction_conf": extraction_conf,
"source_cred": source_cred,
"novelty": novelty,
}
# ---------------------------------------------------------------------------
# Test 1: Full pipeline path through pure functions
# Requirements: 20.1, 20.2, 20.3, 20.4, 20.5
# ---------------------------------------------------------------------------
def test_full_pipeline_path():
"""End-to-end test: raw signals → EvidenceUnit → q_i → LLR → cluster → posterior."""
# 1. Create raw signal dicts with opposing sentiments
signals = [
_make_signal(
sentiment="positive",
impact=0.8,
source_id="doc1",
event_type="earnings",
extraction_conf=0.9,
source_cred=0.85,
novelty=0.8,
),
_make_signal(
sentiment="positive",
impact=0.6,
source_id="doc2",
event_type="earnings",
extraction_conf=0.75,
source_cred=0.7,
novelty=0.6,
),
_make_signal(
sentiment="negative",
impact=0.5,
source_id="doc3",
event_type="regulatory",
extraction_conf=0.7,
source_cred=0.6,
novelty=0.9,
),
]
# 2. Normalize to EvidenceUnit
units = [normalize_company_signal(s) for s in signals]
assert all(u is not None for u in units), "All signals should normalize successfully"
units = [u for u in units if u is not None] # type narrowing
assert len(units) == 3
# 3. Compute q_i (reliability) for each unit
neutral_stats = SourceStats(source_id="default", hits=0, misses=0)
q_values = []
for unit in units:
reliability = compute_v3_reliability(
unit=unit,
source_stats=neutral_stats,
cluster_position=0,
reference_time=_NOW,
)
q_values.append(reliability.q_i)
# All q_i should be in [0, 1]
for q in q_values:
assert 0.0 <= q <= 1.0, f"q_i out of bounds: {q}"
# 4. Compute LLR for each unit
llrs = [compute_llr(unit, q) for unit, q in zip(units, q_values)]
# Positive sentiment → positive LLR, negative → negative LLR
assert llrs[0] > 0.0, "Positive signal should produce positive LLR"
assert llrs[1] > 0.0, "Positive signal should produce positive LLR"
assert llrs[2] < 0.0, "Negative signal should produce negative LLR"
# 5. Cluster evidence
clusters = cluster_evidence(units, llrs)
assert len(clusters) >= 1, "Should produce at least one cluster"
# 6. Compute n_eff and cluster_llr for each cluster
for cluster in clusters:
cluster.n_eff = compute_n_eff(cluster.llrs)
cluster.cluster_llr = compute_cluster_llr(cluster.llrs, cluster.n_eff)
assert cluster.n_eff >= 1.0, "n_eff should be >= 1.0"
assert -2.5 <= cluster.cluster_llr <= 2.5, "cluster_llr should be clamped"
# 7. Compute posterior (using default uncertainty regime)
posterior = compute_v3_posterior(clusters, _DEFAULT_REGIME, p_prior=0.50)
assert isinstance(posterior, V3Posterior)
# 8. Assert posterior is valid
assert 0.0 < posterior.p_up < 1.0, f"P_up should be in (0, 1), got {posterior.p_up}"
assert 0.0 < posterior.p_down < 1.0, "P_down should be in (0, 1)"
assert abs(posterior.p_up + posterior.p_down - 1.0) < 1e-9
assert 0.0 <= posterior.strength <= 1.0
assert posterior.direction in ("bullish", "bearish", "neutral")
assert posterior.n_eff_total > 0
# 9. Compute contradiction
contradiction = compute_v3_contradiction(clusters)
# 10. Assert contradiction is bounded
assert 0.0 <= contradiction <= 1.0
# 11. Compute data quality
data_quality = compute_v3_data_quality(
units=units,
extraction_failure_rate=0.0,
age_newest_hours=0.0,
n_source_types=1,
)
assert 0.0 <= data_quality <= 1.0
# 12. Compute confidence
confidence = compute_v3_confidence(
n_eff_total=posterior.n_eff_total,
q_values=q_values,
llrs=llrs,
strength=posterior.strength,
regime_confidence_mult=_DEFAULT_REGIME.confidence_multiplier,
contradiction=contradiction,
data_quality=data_quality,
)
assert 0.0 <= confidence <= 1.0
# With real positive signals we should get a non-trivial posterior
# (not exactly 0.5 since we have net-positive evidence)
assert posterior.p_up > 0.50, (
"Net-positive evidence should push P_up above 0.50"
)
# ---------------------------------------------------------------------------
# Test 2: Feature flag routing — _annotate_pipeline_mode
# Requirements: 19.1, 19.5
# ---------------------------------------------------------------------------
def test_annotate_pipeline_mode_v3():
"""_annotate_pipeline_mode sets pipeline_mode correctly for v3."""
from services.shared.schemas import TrendDirection, TrendSummary, TrendWindow
summary = TrendSummary(
entity_type="company",
entity_id="AAPL",
window=TrendWindow.SEVEN_DAY,
trend_direction=TrendDirection.BULLISH,
trend_strength=0.6,
confidence=0.7,
top_supporting_evidence=["doc1"],
top_opposing_evidence=[],
dominant_catalysts=["earnings"],
material_risks=[],
contradiction_score=0.1,
disagreement_details=[],
generated_at=_NOW,
)
# Initially no market_context
summary.market_context = {}
_annotate_pipeline_mode(summary, "v3")
assert summary.market_context["pipeline_mode"] == "v3"
def test_annotate_pipeline_mode_heuristic():
"""_annotate_pipeline_mode sets pipeline_mode correctly for heuristic."""
from services.shared.schemas import TrendDirection, TrendSummary, TrendWindow
summary = TrendSummary(
entity_type="company",
entity_id="AAPL",
window=TrendWindow.SEVEN_DAY,
trend_direction=TrendDirection.NEUTRAL,
trend_strength=0.0,
confidence=0.0,
top_supporting_evidence=[],
top_opposing_evidence=[],
dominant_catalysts=[],
material_risks=[],
contradiction_score=0.0,
disagreement_details=[],
generated_at=_NOW,
)
summary.market_context = {}
_annotate_pipeline_mode(summary, "heuristic")
assert summary.market_context["pipeline_mode"] == "heuristic"
# ---------------------------------------------------------------------------
# Test 3: v3 metadata contains expected fields
# Requirements: 20.1, 20.2, 20.3
# ---------------------------------------------------------------------------
def test_v3_metadata_contains_expected_fields():
"""Run through pipeline and verify output metadata dict contains v3 fields."""
# Build a simple pipeline run
signals = [
_make_signal(sentiment="positive", impact=0.7, source_id="a1"),
_make_signal(sentiment="negative", impact=0.4, source_id="a2", event_type="regulatory"),
]
units = [normalize_company_signal(s) for s in signals]
units = [u for u in units if u is not None]
neutral_stats = SourceStats(source_id="default", hits=0, misses=0)
q_values = []
for unit in units:
rel = compute_v3_reliability(
unit=unit,
source_stats=neutral_stats,
cluster_position=0,
reference_time=_NOW,
)
q_values.append(rel.q_i)
llrs = [compute_llr(u, q) for u, q in zip(units, q_values)]
clusters = cluster_evidence(units, llrs)
for c in clusters:
c.n_eff = compute_n_eff(c.llrs)
c.cluster_llr = compute_cluster_llr(c.llrs, c.n_eff)
posterior = compute_v3_posterior(clusters, _DEFAULT_REGIME, p_prior=0.50)
contradiction = compute_v3_contradiction(clusters)
data_quality = compute_v3_data_quality(
units=units,
extraction_failure_rate=0.0,
age_newest_hours=0.5,
n_source_types=1,
)
confidence = compute_v3_confidence(
n_eff_total=posterior.n_eff_total,
q_values=q_values,
llrs=llrs,
strength=posterior.strength,
regime_confidence_mult=_DEFAULT_REGIME.confidence_multiplier,
contradiction=contradiction,
data_quality=data_quality,
)
# Build the v3 metadata dict (mirrors _run_v3_pipeline logic)
v3_metadata = {
"v3_posterior": {
"p_up": round(posterior.p_up, 6),
"p_down": round(posterior.p_down, 6),
"log_odds": round(posterior.log_odds, 6),
"strength": round(posterior.strength, 6),
"confidence": round(confidence, 6),
"contradiction": round(contradiction, 6),
"n_eff": round(posterior.n_eff_total, 4),
"data_quality": round(data_quality, 6),
"regime": posterior.regime,
},
"pipeline_mode": "v3",
"explainability": {
"top_positive_clusters": [],
"top_negative_clusters": [],
"suppression_reasons": [],
"risk_adjustments": [],
},
}
# Verify all required v3_posterior keys exist
required_posterior_keys = {
"p_up", "p_down", "log_odds", "strength",
"confidence", "contradiction", "n_eff", "data_quality", "regime",
}
assert set(v3_metadata["v3_posterior"].keys()) == required_posterior_keys
# Verify top-level metadata keys
assert "pipeline_mode" in v3_metadata
assert v3_metadata["pipeline_mode"] == "v3"
assert "explainability" in v3_metadata
# Verify explainability structure
explainability = v3_metadata["explainability"]
assert "top_positive_clusters" in explainability
assert "top_negative_clusters" in explainability
assert "suppression_reasons" in explainability
assert "risk_adjustments" in explainability
# Verify numeric ranges
p = v3_metadata["v3_posterior"]
assert 0.0 < p["p_up"] < 1.0
assert 0.0 < p["p_down"] < 1.0
assert abs(p["p_up"] + p["p_down"] - 1.0) < 1e-5
assert 0.0 <= p["strength"] <= 1.0
assert 0.0 <= p["confidence"] <= 1.0
assert 0.0 <= p["contradiction"] <= 1.0
assert 0.0 <= p["data_quality"] <= 1.0
assert p["regime"] in ("panic", "trend_following", "mean_reversion", "uncertainty")
# ---------------------------------------------------------------------------
# Test 4: Heuristic fallback function exists and is callable
# Requirements: 19.2, 19.4
# ---------------------------------------------------------------------------
def test_heuristic_fallback_function_exists():
"""Verify the heuristic fallback function exists and is callable."""
from services.aggregation.worker import _aggregate_company_heuristic
assert callable(_aggregate_company_heuristic)
def test_v3_read_flag_function_exists():
"""Verify the _read_v3_flag async function exists and is callable."""
from services.aggregation.worker import _read_v3_flag
assert callable(_read_v3_flag)
# ---------------------------------------------------------------------------
# Test 5: should_force_informational_v3 routing
# Requirements: 19.3, 19.4
# ---------------------------------------------------------------------------
def test_force_informational_low_data_quality():
"""Low data quality should force informational mode."""
units = [
normalize_company_signal(_make_signal(source_id="x1")),
normalize_company_signal(_make_signal(source_id="x2")),
]
units = [u for u in units if u is not None]
should_force, reason = should_force_informational_v3(
data_quality=0.3,
units=units,
extraction_failure_rate=0.0,
)
assert should_force is True
assert reason == "data_quality_below_threshold"
def test_no_force_informational_good_quality():
"""Good data quality with sufficient evidence should NOT force informational."""
units = [
normalize_company_signal(_make_signal(source_id="x1")),
normalize_company_signal(_make_signal(source_id="x2")),
normalize_company_signal(_make_signal(source_id="x3")),
]
units = [u for u in units if u is not None]
should_force, reason = should_force_informational_v3(
data_quality=0.75,
units=units,
extraction_failure_rate=0.1,
)
assert should_force is False
assert reason == ""
# ---------------------------------------------------------------------------
# Test 6: Pipeline with all-neutral signals produces neutral posterior
# Requirements: 20.1, 20.4
# ---------------------------------------------------------------------------
def test_neutral_signals_produce_neutral_posterior():
"""All neutral signals should produce posterior at P_up ≈ 0.50."""
signals = [
_make_signal(sentiment="neutral", impact=0.5, source_id="n1"),
_make_signal(sentiment="neutral", impact=0.3, source_id="n2"),
]
units = [normalize_company_signal(s) for s in signals]
units = [u for u in units if u is not None]
neutral_stats = SourceStats(source_id="default", hits=0, misses=0)
q_values = []
for unit in units:
rel = compute_v3_reliability(
unit=unit, source_stats=neutral_stats,
cluster_position=0, reference_time=_NOW,
)
q_values.append(rel.q_i)
llrs = [compute_llr(u, q) for u, q in zip(units, q_values)]
# All neutral → all LLRs should be 0
assert all(llr == 0.0 for llr in llrs), "Neutral signals should produce zero LLR"
clusters = cluster_evidence(units, llrs)
for c in clusters:
c.n_eff = compute_n_eff(c.llrs)
c.cluster_llr = compute_cluster_llr(c.llrs, c.n_eff)
posterior = compute_v3_posterior(clusters, _DEFAULT_REGIME, p_prior=0.50)
assert abs(posterior.p_up - 0.50) < 1e-6, (
f"Neutral evidence should maintain prior, got P_up={posterior.p_up}"
)
assert posterior.direction == "neutral"
+359
View File
@@ -0,0 +1,359 @@
"""Unit tests for v3 macro and competitive layers.
Tests the noisy-OR normalized macro exposure, resilience dampener,
macro LLR computation, shrunk correlation convergence, competitive LLR
clamping, and graph-distance attenuation.
Requirements validated: 9.19.5, 10.110.5
"""
from __future__ import annotations
import math
import pytest
from services.aggregation.interpolation import (
compute_macro_llr,
compute_normalized_macro_exposure,
)
from services.aggregation.signal_propagation import (
compute_competitive_llr,
compute_shrunk_correlation,
)
# ---------------------------------------------------------------------------
# Noisy-OR: compute_normalized_macro_exposure
# ---------------------------------------------------------------------------
class TestNoisyORExposure:
"""Tests for noisy-OR normalized macro exposure (Req 9.1, 9.2, 9.3)."""
def test_all_overlaps_max_regional(self):
"""All O_k = 1.0 with regional tier → E_macro = 1.0."""
overlaps = {"geo": 1.0, "supply": 1.0, "commodity": 1.0, "sector": 1.0}
result = compute_normalized_macro_exposure(overlaps, tier="regional")
assert result == pytest.approx(1.0, abs=1e-9)
def test_all_overlaps_zero(self):
"""All O_k = 0 → E_macro = 0.0."""
overlaps = {"geo": 0.0, "supply": 0.0, "commodity": 0.0, "sector": 0.0}
result = compute_normalized_macro_exposure(overlaps, tier="regional")
assert result == pytest.approx(0.0, abs=1e-9)
def test_empty_overlaps(self):
"""Empty overlaps dict → E_macro = 0.0."""
result = compute_normalized_macro_exposure({}, tier="regional")
assert result == pytest.approx(0.0, abs=1e-9)
def test_single_dimension_geo(self):
"""Only geo overlap → partial exposure."""
overlaps = {"geo": 1.0}
result = compute_normalized_macro_exposure(overlaps, tier="regional")
# E_raw = 1 - (1-0.35*1)(1-0.25*0)(1-0.25*0)(1-0.15*0) = 1 - 0.65 = 0.35
# E_max = 1 - (0.65)(0.75)(0.75)(0.85) = 1 - 0.311484375 ≈ 0.688515625
# E_macro = 0.35 / 0.688515625 ≈ 0.508
expected_e_raw = 0.35
e_max = 1.0 - (0.65 * 0.75 * 0.75 * 0.85)
expected = expected_e_raw / e_max
assert result == pytest.approx(expected, rel=1e-6)
def test_partial_overlaps(self):
"""Partial overlaps produce intermediate exposure."""
overlaps = {"geo": 0.5, "supply": 0.3, "commodity": 0.0, "sector": 0.8}
result = compute_normalized_macro_exposure(overlaps, tier="regional")
# Should be between 0 and 1
assert 0.0 < result < 1.0
# ---------------------------------------------------------------------------
# Resilience dampener per tier
# ---------------------------------------------------------------------------
class TestResilienceDampener:
"""Tests for resilience dampener application (Req 9.3)."""
def test_global_leader_dampener(self):
"""Global leader tier dampens exposure by 0.70."""
overlaps = {"geo": 1.0, "supply": 1.0, "commodity": 1.0, "sector": 1.0}
result = compute_normalized_macro_exposure(overlaps, tier="global_leader")
# E_macro = 1.0 * 0.70 = 0.70
assert result == pytest.approx(0.70, abs=1e-9)
def test_multinational_dampener(self):
"""Multinational tier dampens exposure by 0.85."""
overlaps = {"geo": 1.0, "supply": 1.0, "commodity": 1.0, "sector": 1.0}
result = compute_normalized_macro_exposure(overlaps, tier="multinational")
assert result == pytest.approx(0.85, abs=1e-9)
def test_regional_dampener(self):
"""Regional tier has no dampening (1.00)."""
overlaps = {"geo": 1.0, "supply": 1.0, "commodity": 1.0, "sector": 1.0}
result = compute_normalized_macro_exposure(overlaps, tier="regional")
assert result == pytest.approx(1.00, abs=1e-9)
def test_domestic_amplifier(self):
"""Domestic tier amplifies exposure by 1.20."""
overlaps = {"geo": 1.0, "supply": 1.0, "commodity": 1.0, "sector": 1.0}
result = compute_normalized_macro_exposure(overlaps, tier="domestic")
assert result == pytest.approx(1.20, abs=1e-9)
def test_unknown_tier_no_dampening(self):
"""Unknown tier defaults to 1.0 dampener."""
overlaps = {"geo": 1.0, "supply": 1.0, "commodity": 1.0, "sector": 1.0}
result = compute_normalized_macro_exposure(overlaps, tier="unknown_tier")
assert result == pytest.approx(1.00, abs=1e-9)
# ---------------------------------------------------------------------------
# Macro LLR at boundary values
# ---------------------------------------------------------------------------
class TestMacroLLR:
"""Tests for macro LLR computation (Req 9.4, 9.5)."""
def test_max_positive_inputs(self):
"""macro_impact=1, event_conf=1, q_recency=1, direction=+1 → p_macro=0.80."""
llr = compute_macro_llr(
macro_impact=1.0,
event_confidence=1.0,
q_recency=1.0,
macro_direction=1,
)
# p_macro = 0.50 + 0.30*1*1*1 = 0.80
expected = math.log(0.80 / 0.20) # ≈ 1.386
assert llr == pytest.approx(expected, rel=1e-6)
def test_max_negative_inputs(self):
"""All max with direction=-1 → negative LLR."""
llr = compute_macro_llr(
macro_impact=1.0,
event_confidence=1.0,
q_recency=1.0,
macro_direction=-1,
)
expected = -math.log(0.80 / 0.20)
assert llr == pytest.approx(expected, rel=1e-6)
def test_neutral_direction_zero_llr(self):
"""direction=0 → LLR=0.0 regardless of other inputs."""
llr = compute_macro_llr(
macro_impact=1.0,
event_confidence=1.0,
q_recency=1.0,
macro_direction=0,
)
assert llr == 0.0
def test_minimum_p_macro_clamp(self):
"""All impact factors zero → p_macro clamped to 0.501."""
llr = compute_macro_llr(
macro_impact=0.0,
event_confidence=0.0,
q_recency=0.0,
macro_direction=1,
)
# p_macro = 0.50 + 0 = 0.50, clamped up to 0.501
expected = math.log(0.501 / (1.0 - 0.501))
assert llr == pytest.approx(expected, rel=1e-6)
def test_mid_range_inputs(self):
"""Intermediate inputs produce reasonable LLR."""
llr = compute_macro_llr(
macro_impact=0.5,
event_confidence=0.7,
q_recency=0.8,
macro_direction=1,
)
# p_macro = 0.50 + 0.30 * 0.5 * 0.7 * 0.8 = 0.50 + 0.084 = 0.584
p_macro = 0.584
expected = math.log(p_macro / (1.0 - p_macro))
assert llr == pytest.approx(expected, rel=1e-6)
# ---------------------------------------------------------------------------
# Shrunk correlation convergence
# ---------------------------------------------------------------------------
class TestShrunkCorrelation:
"""Tests for shrinkage-adjusted correlation (Req 10.1, 10.2)."""
def test_large_n_approaches_rho_rolling(self):
"""n=1000 with same_sector → result ≈ rho_rolling."""
rho_rolling = 0.65
result = compute_shrunk_correlation(
rho_rolling=rho_rolling,
n_observations=1000,
same_sector=True,
)
# (1000/1030) × 0.65 + (30/1030) × 0.30 ≈ 0.6311 + 0.00874 ≈ 0.6398
weight_data = 1000 / 1030
weight_prior = 30 / 1030
expected = weight_data * rho_rolling + weight_prior * 0.30
assert result == pytest.approx(expected, rel=1e-6)
# Should be close to rho_rolling
assert abs(result - rho_rolling) < 0.02
def test_zero_observations_returns_prior(self):
"""n=0 → result = prior (same_sector: 0.30, cross_sector: 0.10)."""
# same sector
result_same = compute_shrunk_correlation(
rho_rolling=0.9,
n_observations=0,
same_sector=True,
)
# (0/30) × 0.9 + (30/30) × 0.30 = 0.30
assert result_same == pytest.approx(0.30, abs=1e-9)
# cross sector
result_cross = compute_shrunk_correlation(
rho_rolling=0.9,
n_observations=0,
same_sector=False,
)
# (0/30) × 0.9 + (30/30) × 0.10 = 0.10
assert result_cross == pytest.approx(0.10, abs=1e-9)
def test_cross_sector_prior(self):
"""Cross-sector uses prior = 0.10."""
result = compute_shrunk_correlation(
rho_rolling=0.50,
n_observations=30,
same_sector=False,
)
# (30/60) × 0.50 + (30/60) × 0.10 = 0.25 + 0.05 = 0.30
assert result == pytest.approx(0.30, abs=1e-9)
def test_negative_rolling_floored_at_zero(self):
"""Negative rolling correlation → rho_effective floored at 0."""
result = compute_shrunk_correlation(
rho_rolling=-0.50,
n_observations=100,
same_sector=False,
)
# (100/130)×(-0.50) + (30/130)×0.10 = -0.3846 + 0.0231 ≈ -0.3615
# Floored at 0
assert result == 0.0
def test_n_30_equal_weight(self):
"""n=30 → data and prior have equal weight."""
rho_rolling = 0.80
result = compute_shrunk_correlation(
rho_rolling=rho_rolling,
n_observations=30,
same_sector=True,
)
# (30/60)×0.80 + (30/60)×0.30 = 0.40 + 0.15 = 0.55
assert result == pytest.approx(0.55, abs=1e-9)
# ---------------------------------------------------------------------------
# Competitive LLR clamp at ±1.25
# ---------------------------------------------------------------------------
class TestCompetitiveLLRClamp:
"""Tests for competitive LLR clamping (Req 10.3, 10.4, 10.5)."""
def test_large_positive_clamped(self):
"""Large positive inputs → clamped to +1.25."""
result = compute_competitive_llr(
llr_source=10.0,
rho_effective=0.9,
d_network=1,
pattern_confidence=1.0,
)
assert result == pytest.approx(1.25, abs=1e-9)
def test_large_negative_clamped(self):
"""Large negative inputs → clamped to -1.25."""
result = compute_competitive_llr(
llr_source=-10.0,
rho_effective=0.9,
d_network=1,
pattern_confidence=1.0,
)
assert result == pytest.approx(-1.25, abs=1e-9)
def test_within_bounds_not_clamped(self):
"""Small inputs produce unclamped result."""
# attenuation = 0.5 × exp(-0.85 × 1) ≈ 0.5 × 0.4274 ≈ 0.2137
# LLR_competitive = 1.0 × 0.2137 × 0.8 ≈ 0.1710
result = compute_competitive_llr(
llr_source=1.0,
rho_effective=0.5,
d_network=1,
pattern_confidence=0.8,
)
expected = 1.0 * 0.5 * math.exp(-0.85 * 1) * 0.8
assert result == pytest.approx(expected, rel=1e-6)
assert abs(result) < 1.25
def test_zero_rho_gives_zero(self):
"""Zero correlation → zero competitive LLR."""
result = compute_competitive_llr(
llr_source=5.0,
rho_effective=0.0,
d_network=1,
pattern_confidence=1.0,
)
assert result == pytest.approx(0.0, abs=1e-9)
# ---------------------------------------------------------------------------
# Distance > 3 → zero attenuation
# ---------------------------------------------------------------------------
class TestDistanceAttenuation:
"""Tests for graph distance cutoff (Req 10.5)."""
def test_distance_4_returns_zero(self):
"""d_network=4 → LLR_competitive = 0.0."""
result = compute_competitive_llr(
llr_source=5.0,
rho_effective=0.9,
d_network=4,
pattern_confidence=1.0,
)
assert result == 0.0
def test_distance_10_returns_zero(self):
"""Very large distance → LLR_competitive = 0.0."""
result = compute_competitive_llr(
llr_source=5.0,
rho_effective=0.9,
d_network=10,
pattern_confidence=1.0,
)
assert result == 0.0
def test_distance_3_still_active(self):
"""d_network=3 (max allowed) → non-zero result."""
result = compute_competitive_llr(
llr_source=2.0,
rho_effective=0.8,
d_network=3,
pattern_confidence=0.9,
)
# attenuation = 0.8 × exp(-0.85 × 3) ≈ 0.8 × 0.0776 ≈ 0.0621
# LLR_competitive = 2.0 × 0.0621 × 0.9 ≈ 0.1118
expected = 2.0 * 0.8 * math.exp(-0.85 * 3) * 0.9
assert result == pytest.approx(expected, rel=1e-6)
assert result != 0.0
def test_distance_1_strongest(self):
"""d_network=1 gives strongest attenuation (least decay)."""
result_d1 = compute_competitive_llr(
llr_source=2.0, rho_effective=0.8, d_network=1, pattern_confidence=0.9,
)
result_d2 = compute_competitive_llr(
llr_source=2.0, rho_effective=0.8, d_network=2, pattern_confidence=0.9,
)
result_d3 = compute_competitive_llr(
llr_source=2.0, rho_effective=0.8, d_network=3, pattern_confidence=0.9,
)
assert result_d1 > result_d2 > result_d3 > 0.0
+471
View File
@@ -0,0 +1,471 @@
"""Unit tests for v3 posterior assembly and regime classification.
Tests for compute_v3_posterior and classify_regime_v3 functions.
Requirements validated: 5.15.7, 6.16.8
"""
from __future__ import annotations
import math
import pytest
from services.aggregation.bayesian import V3Posterior, compute_v3_posterior
from services.aggregation.regime import (
_V3_REGIME_PARAMS,
MarketRegime,
V3RegimeClassification,
classify_regime_v3,
)
from services.aggregation.worker import EvidenceCluster
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_cluster(cluster_llr: float, n_eff: float = 1.0) -> EvidenceCluster:
"""Create a minimal EvidenceCluster with given LLR and n_eff."""
return EvidenceCluster(
cluster_id="test", units=[], llrs=[], n_eff=n_eff, cluster_llr=cluster_llr
)
def _make_regime(
regime: MarketRegime = MarketRegime.UNCERTAINTY, gamma: float = 0.80
) -> V3RegimeClassification:
"""Create a V3RegimeClassification with specified regime and gamma."""
return V3RegimeClassification(
regime=regime,
trend_z=0.0,
vol_ratio=1.0,
evidence_multiplier=gamma,
confidence_multiplier=0.85,
phi_decay=0.50,
atr_multiplier=2.0,
)
# ---------------------------------------------------------------------------
# Test: Empty clusters → P_up = 0.50 (neutral prior)
# Requirements: 5.1, 5.3
# ---------------------------------------------------------------------------
class TestEmptyClusters:
"""No evidence → log_odds = logit(0.50) = 0 → P_up = 0.50."""
def test_empty_clusters_gives_neutral_posterior(self):
regime = _make_regime()
result = compute_v3_posterior(clusters=[], regime=regime, p_prior=0.50)
assert isinstance(result, V3Posterior)
assert result.p_up == pytest.approx(0.50, abs=1e-9)
assert result.p_down == pytest.approx(0.50, abs=1e-9)
assert result.log_odds == pytest.approx(0.0, abs=1e-9)
assert result.strength == pytest.approx(0.0, abs=1e-9)
assert result.direction == "neutral"
assert result.n_eff_total == 0.0
def test_empty_clusters_with_default_prior(self):
regime = _make_regime()
result = compute_v3_posterior(clusters=[], regime=regime)
assert result.p_up == pytest.approx(0.50, abs=1e-9)
# ---------------------------------------------------------------------------
# Test: All bullish clusters → P_up > 0.50
# Requirements: 5.1, 5.2
# ---------------------------------------------------------------------------
class TestAllBullishClusters:
"""Positive cluster LLRs with uncertainty regime (gamma=0.80) → P_up > 0.50."""
def test_bullish_clusters_give_p_up_above_half(self):
clusters = [_make_cluster(1.0), _make_cluster(0.5)]
regime = _make_regime(MarketRegime.UNCERTAINTY, gamma=0.80)
result = compute_v3_posterior(clusters=clusters, regime=regime, p_prior=0.50)
assert result.p_up > 0.50
assert result.direction in ("bullish", "neutral") # depends on threshold
assert result.log_odds > 0.0
def test_bullish_computes_correct_log_odds(self):
"""Verify log_odds = logit(0.50) + gamma * sum(LLR_c)."""
clusters = [_make_cluster(1.0), _make_cluster(0.5)]
regime = _make_regime(MarketRegime.UNCERTAINTY, gamma=0.80)
result = compute_v3_posterior(clusters=clusters, regime=regime, p_prior=0.50)
expected_log_odds = 0.0 + 0.80 * (1.0 + 0.5) # = 1.2
assert result.log_odds == pytest.approx(expected_log_odds, abs=1e-9)
# ---------------------------------------------------------------------------
# Test: All bearish clusters → P_up < 0.50
# Requirements: 5.1, 5.2
# ---------------------------------------------------------------------------
class TestAllBearishClusters:
"""Negative cluster LLRs → P_up < 0.50."""
def test_bearish_clusters_give_p_up_below_half(self):
clusters = [_make_cluster(-1.0), _make_cluster(-0.5)]
regime = _make_regime(MarketRegime.UNCERTAINTY, gamma=0.80)
result = compute_v3_posterior(clusters=clusters, regime=regime, p_prior=0.50)
assert result.p_up < 0.50
assert result.log_odds < 0.0
def test_bearish_computes_correct_log_odds(self):
clusters = [_make_cluster(-1.0), _make_cluster(-0.5)]
regime = _make_regime(MarketRegime.UNCERTAINTY, gamma=0.80)
result = compute_v3_posterior(clusters=clusters, regime=regime, p_prior=0.50)
expected_log_odds = 0.0 + 0.80 * (-1.0 + -0.5) # = -1.2
assert result.log_odds == pytest.approx(expected_log_odds, abs=1e-9)
# ---------------------------------------------------------------------------
# Test: Regime direction thresholds at boundary values
# Requirements: 5.5
# ---------------------------------------------------------------------------
class TestDirectionThresholds:
"""Test direction classification at panic regime boundaries (0.68/0.32)."""
def _compute_with_target_p_up(self, target_p_up: float) -> V3Posterior:
"""Compute posterior that results in a specific P_up value.
We reverse-engineer the cluster LLR needed to produce the target P_up
under panic regime with gamma=0.70 and p_prior=0.50.
"""
# logit(target) = logit(0.50) + gamma * cluster_llr
# logit(target) = 0.0 + 0.70 * cluster_llr
# cluster_llr = logit(target) / 0.70
logit_target = math.log(target_p_up / (1.0 - target_p_up))
cluster_llr = logit_target / 0.70
clusters = [_make_cluster(cluster_llr)]
regime = _make_regime(MarketRegime.PANIC, gamma=0.70)
return compute_v3_posterior(clusters=clusters, regime=regime, p_prior=0.50)
def test_panic_bullish_at_068(self):
"""P_up = 0.68 → bullish in panic regime (threshold is 0.68)."""
result = self._compute_with_target_p_up(0.68)
assert result.p_up == pytest.approx(0.68, abs=1e-6)
assert result.direction == "bullish"
def test_panic_neutral_at_067(self):
"""P_up = 0.67 → neutral in panic regime (below 0.68 threshold)."""
result = self._compute_with_target_p_up(0.67)
assert result.p_up == pytest.approx(0.67, abs=1e-6)
assert result.direction == "neutral"
def test_panic_bearish_at_032(self):
"""P_up = 0.32 → bearish in panic regime (threshold is 0.32)."""
result = self._compute_with_target_p_up(0.32)
assert result.p_up == pytest.approx(0.32, abs=1e-6)
assert result.direction == "bearish"
def test_panic_neutral_at_033(self):
"""P_up = 0.33 → neutral in panic regime (above 0.32 threshold)."""
result = self._compute_with_target_p_up(0.33)
assert result.p_up == pytest.approx(0.33, abs=1e-6)
assert result.direction == "neutral"
def test_trend_following_thresholds(self):
"""Trend following thresholds: bullish >= 0.60, bearish <= 0.40."""
# Bullish at 0.60
logit_target = math.log(0.60 / 0.40)
cluster_llr = logit_target / 1.10 # gamma for trend_following
clusters = [_make_cluster(cluster_llr)]
regime = _make_regime(MarketRegime.TREND_FOLLOWING, gamma=1.10)
result = compute_v3_posterior(clusters=clusters, regime=regime, p_prior=0.50)
assert result.p_up == pytest.approx(0.60, abs=1e-6)
assert result.direction == "bullish"
def test_mean_reversion_thresholds(self):
"""Mean reversion thresholds: bullish >= 0.63, bearish <= 0.37."""
# Use slightly above 0.63 to avoid floating-point boundary issues
target = 0.631
logit_target = math.log(target / (1.0 - target))
cluster_llr = logit_target / 0.90 # gamma for mean_reversion
clusters = [_make_cluster(cluster_llr)]
regime = _make_regime(MarketRegime.MEAN_REVERSION, gamma=0.90)
result = compute_v3_posterior(clusters=clusters, regime=regime, p_prior=0.50)
assert result.p_up >= 0.63
assert result.direction == "bullish"
# ---------------------------------------------------------------------------
# Test: Prior clamp [0.40, 0.60]
# Requirements: 5.6
# ---------------------------------------------------------------------------
class TestPriorClamp:
"""Prior values outside [0.40, 0.60] are clamped."""
def test_prior_below_040_clamped(self):
"""p_prior=0.30 → clamped to 0.40."""
regime = _make_regime()
result = compute_v3_posterior(clusters=[], regime=regime, p_prior=0.30)
# logit(0.40) ≈ -0.4055
expected_log_odds = math.log(0.40 / 0.60)
assert result.log_odds == pytest.approx(expected_log_odds, abs=1e-9)
# P_up should be 0.40 with no evidence
assert result.p_up == pytest.approx(0.40, abs=1e-6)
def test_prior_above_060_clamped(self):
"""p_prior=0.80 → clamped to 0.60."""
regime = _make_regime()
result = compute_v3_posterior(clusters=[], regime=regime, p_prior=0.80)
expected_log_odds = math.log(0.60 / 0.40)
assert result.log_odds == pytest.approx(expected_log_odds, abs=1e-9)
assert result.p_up == pytest.approx(0.60, abs=1e-6)
def test_prior_at_040_not_clamped(self):
"""p_prior=0.40 is within bounds, no clamping."""
regime = _make_regime()
result = compute_v3_posterior(clusters=[], regime=regime, p_prior=0.40)
assert result.p_up == pytest.approx(0.40, abs=1e-6)
def test_prior_at_060_not_clamped(self):
"""p_prior=0.60 is within bounds, no clamping."""
regime = _make_regime()
result = compute_v3_posterior(clusters=[], regime=regime, p_prior=0.60)
assert result.p_up == pytest.approx(0.60, abs=1e-6)
def test_prior_at_050_standard(self):
"""p_prior=0.50 is standard neutral prior."""
regime = _make_regime()
result = compute_v3_posterior(clusters=[], regime=regime, p_prior=0.50)
assert result.p_up == pytest.approx(0.50, abs=1e-9)
# ---------------------------------------------------------------------------
# Test: Regime classification with known inputs
# Requirements: 6.16.8
# ---------------------------------------------------------------------------
class TestRegimeClassificationKnownInputs:
"""Test classify_regime_v3 with known inputs mapping to specific regimes."""
def _make_prices_and_returns(
self, n: int = 120
) -> tuple[list[float], list[float]]:
"""Generate flat price series and near-zero returns."""
prices = [100.0] * n
returns = [0.001] * n
return prices, returns
def test_high_vol_ratio_triggers_panic(self):
"""vol_ratio > 1.5 → panic."""
# Generate returns where sigma_20 >> sigma_100
# sigma_20 high, sigma_100 low → vol_ratio > 1.5
prices = [100.0] * 120
# Low-vol returns for sigma_100 context
returns_low = [0.001] * 80
# High-vol returns for sigma_20
returns_high = [0.05, -0.05] * 10 # stdev ≈ 0.0526
returns = returns_low + returns_high
# With flat prices, trend_z ≈ 0, but vol_ratio > 1.5 → panic
result = classify_regime_v3(prices, returns, atr_20=1.0)
assert result.regime == MarketRegime.PANIC
def test_trend_following_regime(self):
"""trend_z >= 0.75, vol_ratio < 1.3 → trend_following.
|trend_z| >= 0.75 AND vol_ratio < 1.3 → trend_following.
"""
# Trending price series: EMA_20 significantly above EMA_100
prices = [100.0 + i * 0.5 for i in range(120)]
# Varied returns with stable vol (sigma_20 ≈ sigma_100 → vol_ratio near 1.0)
returns = [0.005 + (i % 5) * 0.001 for i in range(120)]
# ATR chosen so trend_z ≈ 1.77 (well above 0.75 threshold)
result = classify_regime_v3(prices, returns, atr_20=10.0)
assert result.regime == MarketRegime.TREND_FOLLOWING
assert abs(result.trend_z) >= 0.75
assert result.vol_ratio < 1.3
def test_mean_reversion_regime(self):
"""|trend_z| < 0.50 AND vol_ratio < 1.0 → mean_reversion."""
# Flat prices → trend_z ≈ 0
prices = [100.0] * 120
# Returns with decreasing volatility (sigma_20 < sigma_100)
# High vol early, low vol recently
returns_early = [0.03, -0.03] * 40 # high vol for sigma_100
returns_recent = [0.001] * 40 # low vol for sigma_20
returns = returns_early + returns_recent
# Large ATR so trend_z stays small
result = classify_regime_v3(prices, returns, atr_20=50.0)
assert result.regime == MarketRegime.MEAN_REVERSION
assert abs(result.trend_z) < 0.50
assert result.vol_ratio < 1.0
def test_uncertainty_regime_default(self):
"""When conditions don't match any specific regime → uncertainty.
|trend_z| between 0.50 and 0.75 OR vol_ratio between 1.0 and 1.3.
"""
# Mild trend + moderate vol → uncertainty
# Slightly trending prices but not enough for trend_following
prices = [100.0 + i * 0.1 for i in range(120)]
# Uniform returns → vol_ratio ≈ 1.0
returns = [0.01] * 120
# ATR chosen so |trend_z| is between 0.50 and 0.75
# We need to find ATR such that it lands in uncertainty
# With mild trend, vol_ratio ≈ 1.0 (not < 1.0), so mean_reversion won't fire
# And trend_z might be < 0.75, so trend_following won't fire
result = classify_regime_v3(prices, returns, atr_20=5.0)
# With uniform returns, stdev is 0 → sigma_100 = 0
# We need non-trivial returns. Let's use a better approach.
# Use returns that give vol_ratio between 1.0 and 1.3
returns_varied = [0.01 + (i % 3) * 0.002 for i in range(120)]
result = classify_regime_v3(prices, returns_varied, atr_20=5.0)
# This should fall through to uncertainty since conditions are moderate
assert result.regime == MarketRegime.UNCERTAINTY
def test_data_insufficient_returns_uncertainty(self):
"""Fewer than 100 closing prices → default uncertainty."""
prices = [100.0] * 50 # < 100
returns = [0.01] * 50
result = classify_regime_v3(prices, returns, atr_20=1.0)
assert result.regime == MarketRegime.UNCERTAINTY
assert result.trend_z == 0.0
assert result.vol_ratio == 1.0
assert result.evidence_multiplier == 0.80
def test_atr_zero_returns_uncertainty(self):
"""ATR_20 <= 0 → default uncertainty (Req 6.8)."""
prices = [100.0] * 120
returns = [0.01] * 120
result = classify_regime_v3(prices, returns, atr_20=0.0)
assert result.regime == MarketRegime.UNCERTAINTY
def test_insufficient_returns_data(self):
"""Fewer than 100 daily returns → default uncertainty."""
prices = [100.0] * 120
returns = [0.01] * 50 # < 100
result = classify_regime_v3(prices, returns, atr_20=1.0)
assert result.regime == MarketRegime.UNCERTAINTY
# ---------------------------------------------------------------------------
# Test: Regime parameters are correctly assigned
# Requirements: 6.6, 6.7
# ---------------------------------------------------------------------------
class TestRegimeParameters:
"""Verify regime parameters (gamma, conf_mult, phi, atr_mult) are assigned."""
def test_panic_parameters(self):
gamma, conf_mult, phi, atr_mult, min_edge = _V3_REGIME_PARAMS[
MarketRegime.PANIC
]
assert gamma == 0.70
assert conf_mult == 0.70
assert phi == 0.35
assert atr_mult == 2.5
assert min_edge == 0.0100
def test_trend_following_parameters(self):
gamma, conf_mult, phi, atr_mult, min_edge = _V3_REGIME_PARAMS[
MarketRegime.TREND_FOLLOWING
]
assert gamma == 1.10
assert conf_mult == 1.00
assert phi == 0.80
assert atr_mult == 1.8
assert min_edge == 0.0035
def test_mean_reversion_parameters(self):
gamma, conf_mult, phi, atr_mult, min_edge = _V3_REGIME_PARAMS[
MarketRegime.MEAN_REVERSION
]
assert gamma == 0.90
assert conf_mult == 0.95
assert phi == 0.55
assert atr_mult == 1.4
assert min_edge == 0.0050
def test_uncertainty_parameters(self):
gamma, conf_mult, phi, atr_mult, min_edge = _V3_REGIME_PARAMS[
MarketRegime.UNCERTAINTY
]
assert gamma == 0.80
assert conf_mult == 0.85
assert phi == 0.50
assert atr_mult == 2.0
assert min_edge == 0.0075
# ---------------------------------------------------------------------------
# Test: Posterior output fields
# Requirements: 5.4, 5.5
# ---------------------------------------------------------------------------
class TestPosteriorOutputFields:
"""Verify derived fields (strength, n_eff_total, regime) are correct."""
def test_strength_computed_correctly(self):
"""strength = abs(2 × P_up - 1)."""
clusters = [_make_cluster(1.5)]
regime = _make_regime(MarketRegime.UNCERTAINTY, gamma=0.80)
result = compute_v3_posterior(clusters=clusters, regime=regime, p_prior=0.50)
expected_strength = abs(2.0 * result.p_up - 1.0)
assert result.strength == pytest.approx(expected_strength, abs=1e-9)
def test_n_eff_total_sums_clusters(self):
"""n_eff_total = sum of cluster n_eff values."""
clusters = [
_make_cluster(0.5, n_eff=2.0),
_make_cluster(0.3, n_eff=1.5),
_make_cluster(-0.2, n_eff=3.0),
]
regime = _make_regime()
result = compute_v3_posterior(clusters=clusters, regime=regime)
assert result.n_eff_total == pytest.approx(6.5, abs=1e-9)
def test_regime_string_matches_input(self):
"""regime field should match the input regime's value."""
regime = _make_regime(MarketRegime.PANIC, gamma=0.70)
result = compute_v3_posterior(clusters=[], regime=regime)
assert result.regime == "panic"
def test_p_down_is_complement(self):
"""p_down = 1 - p_up."""
clusters = [_make_cluster(0.8)]
regime = _make_regime()
result = compute_v3_posterior(clusters=clusters, regime=regime)
assert result.p_down == pytest.approx(1.0 - result.p_up, abs=1e-10)
+314
View File
@@ -0,0 +1,314 @@
"""Unit tests for v3 stop-defined portfolio heat and risk tier auto-adjustment.
Validates: Requirements 15.115.5, 18.118.6
"""
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from services.risk.engine import (
TierMetrics,
check_heat_capacity,
compute_available_heat_capacity,
compute_portfolio_heat,
evaluate_tier_adjustment,
)
# ===========================================================================
# Heat computation tests (Requirements 15.1, 15.2)
# ===========================================================================
class TestComputePortfolioHeat:
"""Test stop-defined portfolio heat calculation."""
def test_heat_three_positions_known_stops(self):
"""3 positions with known stops → expected heat = 710.
risk_dollars = position_value × stop_distance_pct
AAPL: 10000 × 0.03 = 300
MSFT: 5000 × 0.05 = 250
GOOG: 8000 × 0.02 = 160
Total heat = 710
"""
positions = [
{"ticker": "AAPL", "position_value": 10000},
{"ticker": "MSFT", "position_value": 5000},
{"ticker": "GOOG", "position_value": 8000},
]
stop_distances = {"AAPL": 0.03, "MSFT": 0.05, "GOOG": 0.02}
heat = compute_portfolio_heat(positions, stop_distances)
assert heat == pytest.approx(710.0)
def test_heat_empty_positions(self):
"""No positions → zero heat."""
heat = compute_portfolio_heat([], {})
assert heat == 0.0
def test_heat_missing_stop_distance_defaults_to_zero(self):
"""Position with no stop distance entry contributes zero risk."""
positions = [{"ticker": "AAPL", "position_value": 10000}]
stop_distances = {} # no entry for AAPL
heat = compute_portfolio_heat(positions, stop_distances)
assert heat == 0.0
def test_heat_single_position(self):
"""Single position → risk = value × stop."""
positions = [{"ticker": "TSLA", "position_value": 20000}]
stop_distances = {"TSLA": 0.04}
heat = compute_portfolio_heat(positions, stop_distances)
assert heat == pytest.approx(800.0)
# ===========================================================================
# Heat capacity / rejection tests (Requirements 15.3, 15.4, 15.5)
# ===========================================================================
class TestCheckHeatCapacity:
"""Test heat capacity check — rejection when exceeding limit."""
def test_heat_at_limit_rejects_new_entry(self):
"""current_heat=4500, new_risk=600, max_heat=5000 → rejected (False).
4500 + 600 = 5100 > 5000 → False
"""
result = check_heat_capacity(
current_heat=4500,
new_risk_dollars=600,
max_heat_pct=0.05,
portfolio_value=100000,
)
assert result is False
def test_heat_within_limit_allows_entry(self):
"""current_heat=4000, new_risk=500, max_heat=5000 → allowed (True).
4000 + 500 = 4500 <= 5000 → True
"""
result = check_heat_capacity(
current_heat=4000,
new_risk_dollars=500,
max_heat_pct=0.05,
portfolio_value=100000,
)
assert result is True
def test_heat_exactly_at_limit_allows_entry(self):
"""current_heat=4500, new_risk=500, max_heat=5000 → allowed (True).
4500 + 500 = 5000 <= 5000 → True (at boundary)
"""
result = check_heat_capacity(
current_heat=4500,
new_risk_dollars=500,
max_heat_pct=0.05,
portfolio_value=100000,
)
assert result is True
def test_heat_zero_portfolio_rejects(self):
"""Zero portfolio value → max_heat = 0 → any new risk rejected."""
result = check_heat_capacity(
current_heat=0,
new_risk_dollars=100,
max_heat_pct=0.05,
portfolio_value=0,
)
assert result is False
class TestComputeAvailableHeatCapacity:
"""Test available heat capacity computation."""
def test_available_capacity_normal(self):
"""max_heat=5000, current=3000 → available=2000."""
available = compute_available_heat_capacity(
current_heat=3000,
max_heat_pct=0.05,
portfolio_value=100000,
)
assert available == pytest.approx(2000.0)
def test_available_capacity_fully_used(self):
"""current >= max → available = 0."""
available = compute_available_heat_capacity(
current_heat=5500,
max_heat_pct=0.05,
portfolio_value=100000,
)
assert available == 0.0
# ===========================================================================
# Tier downgrade tests (Requirements 18.2, 18.3, 18.5)
# ===========================================================================
class TestTierDowngrade:
"""Test that a single bad metric triggers downgrade."""
def _good_metrics(self, **overrides) -> TierMetrics:
"""Create metrics that pass all upgrade conditions, then override."""
defaults = {
"profit_factor_30d": 1.5,
"max_drawdown_30d": 0.03,
"calibration_error": 0.08,
"realized_sharpe_30d": 1.5,
"n_trades_30d": 25,
"reserve_pool_pct": 0.25,
}
defaults.update(overrides)
return TierMetrics(**defaults)
def test_downgrade_low_profit_factor(self):
"""profit_factor=0.9 (< 1.0) → downgrade."""
metrics = self._good_metrics(profit_factor_30d=0.9)
assert evaluate_tier_adjustment(metrics) == "downgrade"
def test_downgrade_high_drawdown(self):
"""max_drawdown=0.15 (> 0.12) → downgrade."""
metrics = self._good_metrics(max_drawdown_30d=0.15)
assert evaluate_tier_adjustment(metrics) == "downgrade"
def test_downgrade_high_calibration_error(self):
"""calibration_error=0.25 (> 0.20) → downgrade."""
metrics = self._good_metrics(calibration_error=0.25)
assert evaluate_tier_adjustment(metrics) == "downgrade"
def test_downgrade_negative_sharpe(self):
"""sharpe=-0.5 (< 0) → downgrade."""
metrics = self._good_metrics(realized_sharpe_30d=-0.5)
assert evaluate_tier_adjustment(metrics) == "downgrade"
# ===========================================================================
# Tier upgrade tests (Requirements 18.4)
# ===========================================================================
class TestTierUpgrade:
"""Test that all metrics must be good for upgrade."""
def test_upgrade_all_good(self):
"""All upgrade conditions met → upgrade.
profit_factor=1.5, drawdown=0.03, cal_error=0.08,
sharpe=1.5, n_trades=25, reserve=0.25
"""
metrics = TierMetrics(
profit_factor_30d=1.5,
max_drawdown_30d=0.03,
calibration_error=0.08,
realized_sharpe_30d=1.5,
n_trades_30d=25,
reserve_pool_pct=0.25,
)
assert evaluate_tier_adjustment(metrics) == "upgrade"
def test_hold_insufficient_trades(self):
"""All upgrade conditions met EXCEPT n_trades=15 (< 20) → hold."""
metrics = TierMetrics(
profit_factor_30d=1.5,
max_drawdown_30d=0.03,
calibration_error=0.08,
realized_sharpe_30d=1.5,
n_trades_30d=15,
reserve_pool_pct=0.25,
)
assert evaluate_tier_adjustment(metrics) == "hold"
def test_hold_insufficient_reserve(self):
"""All good except reserve=0.15 (< 0.20) → hold."""
metrics = TierMetrics(
profit_factor_30d=1.5,
max_drawdown_30d=0.03,
calibration_error=0.08,
realized_sharpe_30d=1.5,
n_trades_30d=25,
reserve_pool_pct=0.15,
)
assert evaluate_tier_adjustment(metrics) == "hold"
def test_hold_drawdown_too_high_for_upgrade(self):
"""drawdown=0.06 (> 0.05 for upgrade) but < 0.12 (no downgrade) → hold."""
metrics = TierMetrics(
profit_factor_30d=1.5,
max_drawdown_30d=0.06,
calibration_error=0.08,
realized_sharpe_30d=1.5,
n_trades_30d=25,
reserve_pool_pct=0.25,
)
assert evaluate_tier_adjustment(metrics) == "hold"
# ===========================================================================
# 7-day cooldown enforcement (Requirements 18.5, 18.6)
# ===========================================================================
class TestTierCooldown:
"""Test 7-day cooldown enforcement logic at the caller level.
The evaluate_tier_adjustment function is pure — it doesn't track state.
The 7-day cooldown is enforced by the caller. Here we verify the pure
logic that a caller would use: compare last_upgrade_time to now and
only allow upgrade if >= 7 days have passed.
"""
def test_cooldown_blocks_upgrade_within_7_days(self):
"""Upgrade blocked when last upgrade was < 7 days ago."""
last_upgrade = datetime(2024, 1, 10, tzinfo=timezone.utc)
now = datetime(2024, 1, 15, tzinfo=timezone.utc) # 5 days later
cooldown_days = 7
days_since_last = (now - last_upgrade).days
can_upgrade = days_since_last >= cooldown_days
assert can_upgrade is False
def test_cooldown_allows_upgrade_after_7_days(self):
"""Upgrade allowed when last upgrade was >= 7 days ago."""
last_upgrade = datetime(2024, 1, 10, tzinfo=timezone.utc)
now = datetime(2024, 1, 17, tzinfo=timezone.utc) # 7 days later
cooldown_days = 7
days_since_last = (now - last_upgrade).days
can_upgrade = days_since_last >= cooldown_days
assert can_upgrade is True
def test_cooldown_allows_upgrade_well_past_7_days(self):
"""Upgrade allowed when last upgrade was well past cooldown."""
last_upgrade = datetime(2024, 1, 1, tzinfo=timezone.utc)
now = datetime(2024, 2, 1, tzinfo=timezone.utc) # 31 days later
cooldown_days = 7
days_since_last = (now - last_upgrade).days
can_upgrade = days_since_last >= cooldown_days
assert can_upgrade is True
def test_downgrade_ignores_cooldown(self):
"""Downgrade is applied immediately regardless of cooldown.
Even if an upgrade happened yesterday, downgrade still fires.
"""
# The evaluate function doesn't have cooldown logic — it always
# returns "downgrade" when conditions are met, regardless of timing.
metrics = TierMetrics(
profit_factor_30d=0.8, # triggers downgrade
max_drawdown_30d=0.03,
calibration_error=0.08,
realized_sharpe_30d=1.5,
n_trades_30d=25,
reserve_pool_pct=0.25,
)
# Downgrade fires regardless of when last upgrade occurred
assert evaluate_tier_adjustment(metrics) == "downgrade"
+403
View File
@@ -0,0 +1,403 @@
"""Unit tests for v3 fractional Kelly sizing and regime-aware stops.
Tests Kelly fraction computation, capacity cap enforcement, position minimum
downgrade, stop/take-profit levels, and trailing stop monotonicity.
Requirements validated: 14.114.7, 16.116.5
"""
from __future__ import annotations
import pytest
from services.trading.position_sizer import (
KellySizingResult,
compute_kelly_sizing,
compute_reward_ratio,
)
from services.trading.stop_loss_manager import (
TrailingStopResult,
V3StopLevels,
compute_trailing_stop,
compute_v3_stops,
)
# ---------------------------------------------------------------------------
# Kelly sizing: negative edge → size = 0 (Req 14.7)
# ---------------------------------------------------------------------------
class TestKellyNegativeEdge:
"""Negative Kelly fraction forces zero sizing and downgrade."""
def test_p_win_03_b_2_negative_edge(self):
"""p_win=0.3, b=2.0 → f_kelly = (0.3*2 - 0.7)/2 = -0.05 → downgrade."""
result = compute_kelly_sizing(
p_win=0.3,
b=2.0,
confidence=0.8,
data_quality=0.9,
contradiction=0.1,
max_position_pct=0.10,
available_caps={"sector_capacity": 0.10, "correlation_capacity": 0.10, "heat_capacity": 0.10},
)
assert isinstance(result, KellySizingResult)
assert result.f_kelly == pytest.approx(-0.05, abs=1e-9)
assert result.portfolio_pct == 0.0
assert result.downgrade is True
assert result.downgrade_reason == "negative_edge"
def test_p_win_05_b_1_zero_edge(self):
"""p_win=0.5, b=1.0 → f_kelly = (0.5*1 - 0.5)/1 = 0.0 → downgrade."""
result = compute_kelly_sizing(
p_win=0.5,
b=1.0,
confidence=0.8,
data_quality=0.9,
contradiction=0.1,
max_position_pct=0.10,
available_caps={},
)
assert result.f_kelly == pytest.approx(0.0, abs=1e-9)
assert result.portfolio_pct == 0.0
assert result.downgrade is True
assert result.downgrade_reason == "negative_edge"
# ---------------------------------------------------------------------------
# Kelly sizing: positive edge → bounded size (Req 14.114.4)
# ---------------------------------------------------------------------------
class TestKellyPositiveEdge:
"""Positive Kelly fraction produces a sized position within caps."""
def test_p_win_07_b_2_positive_size(self):
"""p_win=0.7, b=2.0 → f_kelly = (1.4-0.3)/2 = 0.55 → positive size."""
confidence = 0.8
data_quality = 0.9
contradiction = 0.1
result = compute_kelly_sizing(
p_win=0.7,
b=2.0,
confidence=confidence,
data_quality=data_quality,
contradiction=contradiction,
max_position_pct=0.10,
available_caps={"sector_capacity": 0.10, "correlation_capacity": 0.10, "heat_capacity": 0.10},
)
assert result.f_kelly == pytest.approx(0.55, abs=1e-9)
# portfolio_pct = 0.55 * 0.25 * 0.8 * 0.9 * (1 - 0.1) = 0.55 * 0.25 * 0.8 * 0.9 * 0.9
expected_raw = 0.55 * 0.25 * confidence * data_quality * (1.0 - contradiction)
# Clamped to max_position_pct = 0.10
expected_pct = min(expected_raw, 0.10)
assert result.portfolio_pct == pytest.approx(expected_pct, abs=1e-9)
assert result.portfolio_pct > 0.0
assert result.portfolio_pct <= 0.10
assert result.downgrade is False
assert result.downgrade_reason == ""
def test_high_confidence_respects_max_cap(self):
"""Even with strong edge, portfolio_pct cannot exceed max_position_pct."""
result = compute_kelly_sizing(
p_win=0.9,
b=3.0,
confidence=1.0,
data_quality=1.0,
contradiction=0.0,
max_position_pct=0.05,
available_caps={},
)
assert result.portfolio_pct <= 0.05
# ---------------------------------------------------------------------------
# Cap enforcement: sector, correlation, heat (Req 14.5)
# ---------------------------------------------------------------------------
class TestCapEnforcement:
"""Capacity constraints cap portfolio_pct."""
def test_sector_capacity_caps(self):
"""sector_capacity=0.02 caps portfolio_pct at 0.02."""
result = compute_kelly_sizing(
p_win=0.7,
b=2.0,
confidence=0.8,
data_quality=0.9,
contradiction=0.1,
max_position_pct=0.10,
available_caps={"sector_capacity": 0.02, "correlation_capacity": 0.10, "heat_capacity": 0.10},
)
assert result.portfolio_pct <= 0.02
def test_correlation_capacity_zero_forces_zero(self):
"""correlation_capacity=0.0 (avg corr > 0.80) → portfolio_pct = 0."""
result = compute_kelly_sizing(
p_win=0.7,
b=2.0,
confidence=0.8,
data_quality=0.9,
contradiction=0.1,
max_position_pct=0.10,
available_caps={"sector_capacity": 0.10, "correlation_capacity": 0.0, "heat_capacity": 0.10},
)
# correlation_capacity=0 → min(pct, 0) = 0 → below minimum → downgrade
assert result.portfolio_pct == 0.0
assert result.downgrade is True
assert result.downgrade_reason == "position_below_minimum"
def test_heat_capacity_caps(self):
"""heat_capacity=0.01 caps portfolio_pct at 0.01."""
result = compute_kelly_sizing(
p_win=0.7,
b=2.0,
confidence=0.8,
data_quality=0.9,
contradiction=0.1,
max_position_pct=0.10,
available_caps={"sector_capacity": 0.10, "correlation_capacity": 0.10, "heat_capacity": 0.01},
)
assert result.portfolio_pct <= 0.01
def test_minimum_of_all_caps(self):
"""portfolio_pct is min of all capacity constraints."""
result = compute_kelly_sizing(
p_win=0.7,
b=2.0,
confidence=0.8,
data_quality=0.9,
contradiction=0.1,
max_position_pct=0.10,
available_caps={"sector_capacity": 0.03, "correlation_capacity": 0.05, "heat_capacity": 0.04},
)
# Minimum cap is sector_capacity=0.03
assert result.portfolio_pct <= 0.03
# ---------------------------------------------------------------------------
# Position below minimum → downgrade (Req 14.6)
# ---------------------------------------------------------------------------
class TestPositionBelowMinimum:
"""Tiny positions below 0.005 trigger a downgrade."""
def test_tiny_position_downgrade(self):
"""Low data_quality and high contradiction → pct < 0.005 → downgrade."""
result = compute_kelly_sizing(
p_win=0.55,
b=1.5,
confidence=0.3,
data_quality=0.3,
contradiction=0.7,
max_position_pct=0.10,
available_caps={},
)
# f_kelly = (0.55*1.5 - 0.45)/1.5 = (0.825-0.45)/1.5 = 0.25
# raw = 0.25 * 0.25 * 0.3 * 0.3 * 0.3 = 0.0016875 < 0.005
assert result.portfolio_pct == 0.0
assert result.downgrade is True
assert result.downgrade_reason == "position_below_minimum"
def test_just_above_minimum_no_downgrade(self):
"""Position at or above 0.005 is not downgraded."""
result = compute_kelly_sizing(
p_win=0.7,
b=2.0,
confidence=0.8,
data_quality=0.9,
contradiction=0.1,
max_position_pct=0.10,
available_caps={},
)
# f_kelly=0.55, raw=0.55*0.25*0.8*0.9*0.9 = 0.0891 >> 0.005
assert result.portfolio_pct >= 0.005
assert result.downgrade is False
# ---------------------------------------------------------------------------
# Reward ratio computation (Req 14.2)
# ---------------------------------------------------------------------------
class TestRewardRatio:
"""Tests compute_reward_ratio clamping and formula."""
def test_reward_ratio_typical(self):
"""Typical values produce a ratio between 1.2 and 3.0."""
b = compute_reward_ratio(confidence=0.7, strength=0.5, contradiction=0.2)
# raw = 1.2 + 2.0*0.7 + 1.0*0.5 - 0.2 = 1.2 + 1.4 + 0.5 - 0.2 = 2.9
assert b == pytest.approx(2.9, abs=1e-9)
def test_reward_ratio_clamp_low(self):
"""Low confidence/strength and high contradiction → clamped to 1.2."""
b = compute_reward_ratio(confidence=0.0, strength=0.0, contradiction=1.0)
# raw = 1.2 + 0 + 0 - 1.0 = 0.2 → clamped to 1.2
assert b == pytest.approx(1.2, abs=1e-9)
def test_reward_ratio_clamp_high(self):
"""High confidence/strength → clamped to 3.0."""
b = compute_reward_ratio(confidence=1.0, strength=1.0, contradiction=0.0)
# raw = 1.2 + 2.0 + 1.0 - 0 = 4.2 → clamped to 3.0
assert b == pytest.approx(3.0, abs=1e-9)
# ---------------------------------------------------------------------------
# Stop/TP computation with known inputs (Req 16.116.3)
# ---------------------------------------------------------------------------
class TestV3Stops:
"""Tests for regime-aware stop loss and take profit computation."""
def test_known_inputs(self):
"""entry=100, ATR_pct=0.02, regime_mult=2.0, sigma_h=0.04, b=2.0."""
result = compute_v3_stops(
entry_price=100.0,
atr_pct=0.02,
regime_atr_mult=2.0,
sigma_h=0.04,
reward_ratio=2.0,
)
assert isinstance(result, V3StopLevels)
# stop_distance = max(0.02*2.0, 0.04*1.25, 0.005) = max(0.04, 0.05, 0.005) = 0.05
assert result.stop_distance_pct == pytest.approx(0.05, abs=1e-9)
# stop = 100 * (1 - 0.05) = 95
assert result.stop_loss == pytest.approx(95.0, abs=1e-9)
# TP = 100 * (1 + 2.0 * 0.05) = 110
assert result.take_profit == pytest.approx(110.0, abs=1e-9)
assert result.reward_ratio == pytest.approx(2.0, abs=1e-9)
def test_min_stop_distance_enforced(self):
"""Very low ATR and sigma → min stop distance of 0.005 is enforced."""
result = compute_v3_stops(
entry_price=50.0,
atr_pct=0.001,
regime_atr_mult=1.0,
sigma_h=0.001,
reward_ratio=2.0,
)
# max(0.001*1.0, 0.001*1.25, 0.005) = 0.005
assert result.stop_distance_pct == pytest.approx(0.005, abs=1e-9)
assert result.stop_loss == pytest.approx(50.0 * (1 - 0.005), abs=1e-9)
def test_atr_dominates(self):
"""High ATR*mult dominates stop distance."""
result = compute_v3_stops(
entry_price=200.0,
atr_pct=0.05,
regime_atr_mult=2.5,
sigma_h=0.03,
reward_ratio=1.5,
)
# max(0.05*2.5, 0.03*1.25, 0.005) = max(0.125, 0.0375, 0.005) = 0.125
assert result.stop_distance_pct == pytest.approx(0.125, abs=1e-9)
assert result.stop_loss == pytest.approx(200.0 * (1 - 0.125), abs=1e-9)
assert result.take_profit == pytest.approx(200.0 * (1 + 1.5 * 0.125), abs=1e-9)
# ---------------------------------------------------------------------------
# Trailing stop monotonicity (Req 16.416.5)
# ---------------------------------------------------------------------------
class TestTrailingStopMonotonicity:
"""Tests trailing stop activation and non-decreasing behavior."""
def test_not_activated_below_threshold(self):
"""Gain < 50% of TP distance → trailing not activated."""
result = compute_trailing_stop(
existing_stop=95.0,
current_price=101.0, # Gain = 1.0, TP distance = 10, 1.0 < 0.5*10
entry_price=100.0,
take_profit=110.0,
atr_pct=0.02,
trailing_atr_mult=1.5,
sigma_h=0.04,
)
assert isinstance(result, TrailingStopResult)
assert result.activated is False
assert result.trailing_stop == 95.0 # Unchanged
def test_activated_above_threshold(self):
"""Gain >= 50% of TP distance → trailing activated."""
result = compute_trailing_stop(
existing_stop=95.0,
current_price=105.0, # Gain = 5.0, TP distance = 10, 5.0 >= 0.5*10
entry_price=100.0,
take_profit=110.0,
atr_pct=0.02,
trailing_atr_mult=1.5,
sigma_h=0.04,
)
assert result.activated is True
# trailing_distance = max(0.02*1.5, 0.04*0.75) = max(0.03, 0.03) = 0.03
# candidate = 105 * (1 - 0.03) = 101.85
# max(95.0, 101.85) = 101.85
assert result.trailing_stop == pytest.approx(105.0 * (1 - 0.03), abs=1e-9)
assert result.trailing_stop > 95.0
def test_monotonicity_over_price_sequence(self):
"""Trailing stop never decreases over a price sequence."""
# Setup: entry=100, TP=110, existing_stop=95
entry = 100.0
take_profit = 110.0
atr_pct = 0.02
trailing_atr_mult = 1.5
sigma_h = 0.04
prices = [102.0, 105.0, 103.0, 108.0]
current_stop = 95.0
stops_recorded = [current_stop]
for price in prices:
result = compute_trailing_stop(
existing_stop=current_stop,
current_price=price,
entry_price=entry,
take_profit=take_profit,
atr_pct=atr_pct,
trailing_atr_mult=trailing_atr_mult,
sigma_h=sigma_h,
)
current_stop = result.trailing_stop
stops_recorded.append(current_stop)
# Verify monotonically non-decreasing
for i in range(1, len(stops_recorded)):
assert stops_recorded[i] >= stops_recorded[i - 1], (
f"Stop decreased at step {i}: {stops_recorded[i]} < {stops_recorded[i-1]}"
)
def test_trailing_stop_never_below_existing(self):
"""Even with price drop, trailing stop stays at existing_stop."""
result = compute_trailing_stop(
existing_stop=102.0,
current_price=105.0,
entry_price=100.0,
take_profit=110.0,
atr_pct=0.05, # Large ATR → candidate might be below existing
trailing_atr_mult=2.0,
sigma_h=0.08,
)
# trailing_distance = max(0.05*2.0, 0.08*0.75) = max(0.10, 0.06) = 0.10
# candidate = 105 * (1 - 0.10) = 94.5
# max(102.0, 94.5) = 102.0
assert result.trailing_stop == pytest.approx(102.0, abs=1e-9)
assert result.activated is True
def test_exact_50pct_threshold_activates(self):
"""Gain exactly at 50% of TP distance activates trailing."""
# TP distance = 10, so gain must be >= 5.0
result = compute_trailing_stop(
existing_stop=95.0,
current_price=105.0, # Gain = 5.0 = 0.50 * 10
entry_price=100.0,
take_profit=110.0,
atr_pct=0.02,
trailing_atr_mult=1.5,
sigma_h=0.04,
)
assert result.activated is True