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