Files
stonks-oracle/tests/test_pbt_signal_math.py
T
Celes Renata 4e010bc048
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/push/build-1 Pipeline was successful
ci/woodpecker/push/build-2 Pipeline was successful
ci/woodpecker/push/build-3 Pipeline was successful
ci/woodpecker/push/finalize Pipeline was successful
Build and Push / lint-and-test (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.adapters.broker_adapter name:broker-adapter]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.aggregation.worker name:aggregation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.extractor.worker name:extractor]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.ingestion.worker name:ingestion]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.lake_publisher.worker name:lake-publisher]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.parser.worker name:parser]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.recommendation.worker name:recommendation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.scheduler.app name:scheduler]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.api.app:app --host 0.0.0.0 --port 8000 name:query-api]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.risk.app:app --host 0.0.0.0 --port 8000 name:risk]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.symbol_registry.app:app --host 0.0.0.0 --port 8000 name:symbol-registry]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.trading.app:app --host 0.0.0.0 --port 8000 name:trading-engine]) (push) Has been cancelled
Build and Push / build-dashboard (push) Has been cancelled
Build and Push / build-superset (push) Has been cancelled
Build and Push / integration-test (push) Has been cancelled
Build and Push / beta-gate (push) Has been cancelled
feat: signal math upgrade — probabilistic, regime-aware scoring pipeline
Implement full probabilistic signal processing pipeline gated behind
probabilistic_scoring_enabled feature flag in risk_configs:

- Bayesian log-likelihood accumulator with Beta posterior and entropy
- Regime detector (trend-following, panic, mean-reversion, uncertainty)
- Source accuracy tracker with per-source historical prediction accuracy
- Sigmoid confidence gate replacing binary gate
- Information gain surprise weighting for rare events
- Adaptive recency decay with event-specific half-lives
- Regime multiplier replacing market context multiplier
- Weighted disagreement entropy for contradiction detection
- Multiplicative macro exposure with conditional integration
- Graph-distance attenuated competitive signal propagation
- Exponentially weighted momentum with volatility scaling
- Expected value recommendation gate

All changes backward-compatible: flag=false preserves exact current behavior.
New outputs stored in existing JSONB columns (no schema changes except
source_accuracy table via migration 034).

Tests: 26 property-based tests (14 correctness properties), 99 unit tests,
1789 total tests passing with zero regressions.
2026-04-29 11:41:48 +00:00

1031 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Property-based tests for the signal math upgrade — Bayesian accumulator module.
Feature: signal-math-upgrade
Tests properties 1, 2, 3, 4, 8, and 13 from the design specification,
covering sigmoid gate monotonicity, Beta posterior evidence accumulation,
Bayesian confidence symmetry/divergence, posterior round-trip consistency,
Shannon entropy range/maximum, and confidence monotonicity with agreeing signals.
"""
from __future__ import annotations
import math
from hypothesis import given, settings
from hypothesis import strategies as st
from services.aggregation.bayesian import (
PRIOR,
compute_bayesian_posterior,
compute_entropy,
)
from services.aggregation.scoring import (
ScoringConfig,
SignalWeight,
WeightedSignal,
compute_adaptive_half_life,
compute_info_gain,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _sigmoid(x: float) -> float:
"""Sigmoid function σ(x) = 1 / (1 + exp(-x))."""
return 1.0 / (1.0 + math.exp(-x))
def _sigmoid_gate(confidence: float, steepness: float = 5.0, midpoint: float = 0.5) -> float:
"""Sigmoid confidence gate: σ(k·(x - midpoint))."""
return _sigmoid(steepness * (confidence - midpoint))
def _make_signal_weight(combined: float) -> SignalWeight:
"""Create a minimal SignalWeight with the given combined value."""
return SignalWeight(
recency=1.0,
credibility=1.0,
novelty_bonus=0.0,
confidence_gate=1.0,
market_ctx_multiplier=1.0,
combined=combined,
)
def _make_weighted_signal(
sentiment_value: float,
combined_weight: float = 1.0,
doc_id: str = "doc-test",
) -> WeightedSignal:
"""Create a WeightedSignal with the given sentiment and weight."""
return WeightedSignal(
document_id=doc_id,
weight=_make_signal_weight(combined_weight),
sentiment_value=sentiment_value,
impact_score=1.0,
)
# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------
def _weighted_signal_strategy() -> st.SearchStrategy[WeightedSignal]:
"""Generate random WeightedSignal objects with valid fields."""
return st.builds(
_make_weighted_signal,
sentiment_value=st.sampled_from([-1.0, 1.0]),
combined_weight=st.floats(min_value=0.1, max_value=5.0, allow_nan=False, allow_infinity=False),
doc_id=st.text(
alphabet=st.sampled_from("abcdefghijklmnopqrstuvwxyz0123456789"),
min_size=3,
max_size=10,
),
)
def _uniform_weight_signal_strategy() -> st.SearchStrategy[WeightedSignal]:
"""Generate WeightedSignal objects with uniform weight (1.0) for round-trip tests."""
return st.builds(
_make_weighted_signal,
sentiment_value=st.sampled_from([-1.0, 1.0]),
combined_weight=st.just(1.0),
doc_id=st.text(
alphabet=st.sampled_from("abcdefghijklmnopqrstuvwxyz0123456789"),
min_size=3,
max_size=10,
),
)
def _bullish_signal_strategy(
combined_weight: st.SearchStrategy[float] | None = None,
) -> st.SearchStrategy[WeightedSignal]:
"""Generate bullish (positive sentiment) WeightedSignal objects."""
return st.builds(
_make_weighted_signal,
sentiment_value=st.just(1.0),
combined_weight=combined_weight or st.floats(
min_value=0.1, max_value=5.0, allow_nan=False, allow_infinity=False,
),
doc_id=st.text(
alphabet=st.sampled_from("abcdefghijklmnopqrstuvwxyz0123456789"),
min_size=3,
max_size=10,
),
)
def _bearish_signal_strategy(
combined_weight: st.SearchStrategy[float] | None = None,
) -> st.SearchStrategy[WeightedSignal]:
"""Generate bearish (negative sentiment) WeightedSignal objects."""
return st.builds(
_make_weighted_signal,
sentiment_value=st.just(-1.0),
combined_weight=combined_weight or st.floats(
min_value=0.1, max_value=5.0, allow_nan=False, allow_infinity=False,
),
doc_id=st.text(
alphabet=st.sampled_from("abcdefghijklmnopqrstuvwxyz0123456789"),
min_size=3,
max_size=10,
),
)
# ---------------------------------------------------------------------------
# Property 1: Sigmoid Gate Monotonicity
# Feature: signal-math-upgrade, Property 1: Sigmoid Gate Monotonicity
# **Validates: Requirements 2.6, 17.1**
# ---------------------------------------------------------------------------
class TestProperty1SigmoidGateMonotonicity:
"""Property 1: Sigmoid Gate Monotonicity.
For any two extraction confidence values x₁, x₂ ∈ [0.0, 1.0] where
x₁ ≤ x₂, the sigmoid gate σ(5·(x₁ - 0.5)) SHALL be ≤ σ(5·(x₂ - 0.5)).
**Validates: Requirements 2.6, 17.1**
"""
@settings(max_examples=100)
@given(
x1=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
x2=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
)
def test_sigmoid_gate_monotonicity(self, x1: float, x2: float) -> None:
"""Higher confidence always produces equal or higher gate values."""
lo, hi = min(x1, x2), max(x1, x2)
gate_lo = _sigmoid_gate(lo)
gate_hi = _sigmoid_gate(hi)
assert gate_lo <= gate_hi + 1e-12, (
f"Sigmoid gate not monotonic: σ(5·({lo}-0.5))={gate_lo} > σ(5·({hi}-0.5))={gate_hi}"
)
# ---------------------------------------------------------------------------
# Property 2: Beta Posterior Evidence Accumulation
# Feature: signal-math-upgrade, Property 2: Beta Posterior Evidence Accumulation
# **Validates: Requirements 1.3, 17.2**
# ---------------------------------------------------------------------------
class TestProperty2BetaPosteriorEvidenceAccumulation:
"""Property 2: Beta Posterior Evidence Accumulation.
For any sequence of weighted signal sets where each successive set
contains one additional signal, the sum α + β SHALL increase monotonically.
**Validates: Requirements 1.3, 17.2**
"""
@settings(max_examples=100)
@given(
signals=st.lists(
_weighted_signal_strategy(),
min_size=1,
max_size=20,
),
)
def test_evidence_accumulates_monotonically(self, signals: list[WeightedSignal]) -> None:
"""Adding a signal never reduces the total evidence mass α + β."""
prev_evidence = PRIOR.alpha + PRIOR.beta # 2.0
for i in range(1, len(signals) + 1):
posterior = compute_bayesian_posterior(signals[:i])
current_evidence = posterior.alpha + posterior.beta
assert current_evidence >= prev_evidence - 1e-9, (
f"Evidence decreased at signal {i}: "
f"prev={prev_evidence}, current={current_evidence}"
)
prev_evidence = current_evidence
# ---------------------------------------------------------------------------
# Property 3: Bayesian Confidence Symmetry and Divergence
# Feature: signal-math-upgrade, Property 3: Bayesian Confidence Symmetry and Divergence
# **Validates: Requirements 1.4, 17.3**
# ---------------------------------------------------------------------------
class TestProperty3BayesianConfidenceSymmetryDivergence:
"""Property 3: Bayesian Confidence Symmetry and Divergence.
For any Beta posterior with α, β ≥ 1.0:
- C = 1 - 4αβ/(α+β)² SHALL equal 0.0 when α = β
- C SHALL increase monotonically as max(α/β, β/α) increases
**Validates: Requirements 1.4, 17.3**
"""
@settings(max_examples=100)
@given(
alpha=st.floats(min_value=1.0, max_value=100.0, allow_nan=False, allow_infinity=False),
)
def test_confidence_zero_when_alpha_equals_beta(self, alpha: float) -> None:
"""Bayesian confidence is 0.0 when α = β (maximum uncertainty)."""
ab_sum = alpha + alpha
confidence = 1.0 - (4.0 * alpha * alpha) / (ab_sum * ab_sum)
assert abs(confidence) < 1e-9, (
f"Confidence should be 0.0 when α=β={alpha}, got {confidence}"
)
@settings(max_examples=100)
@given(
alpha=st.floats(min_value=1.0, max_value=100.0, allow_nan=False, allow_infinity=False),
beta=st.floats(min_value=1.0, max_value=100.0, allow_nan=False, allow_infinity=False),
delta=st.floats(min_value=0.01, max_value=10.0, allow_nan=False, allow_infinity=False),
)
def test_confidence_increases_with_divergence(
self, alpha: float, beta: float, delta: float,
) -> None:
"""Confidence increases as the ratio max(α/β, β/α) increases."""
# Compute confidence for (alpha, beta)
ab_sum = alpha + beta
c1 = 1.0 - (4.0 * alpha * beta) / (ab_sum * ab_sum)
# Increase the divergence: push the larger parameter further away
if alpha >= beta:
alpha2 = alpha + delta
beta2 = beta
else:
alpha2 = alpha
beta2 = beta + delta
ab_sum2 = alpha2 + beta2
c2 = 1.0 - (4.0 * alpha2 * beta2) / (ab_sum2 * ab_sum2)
assert c2 >= c1 - 1e-9, (
f"Confidence did not increase with divergence: "
f"C({alpha},{beta})={c1}, C({alpha2},{beta2})={c2}"
)
# ---------------------------------------------------------------------------
# Property 4: Bayesian Posterior Round-Trip Consistency
# Feature: signal-math-upgrade, Property 4: Bayesian Posterior Round-Trip Consistency
# **Validates: Requirements 1.7, 17.7**
# ---------------------------------------------------------------------------
class TestProperty4BayesianPosteriorRoundTrip:
"""Property 4: Bayesian Posterior Round-Trip Consistency.
For any set of weighted signals with uniform weights, computing the
Beta posterior and extracting P_bull = α/(α+β) SHALL produce a value
within 0.05 of σ(L_t).
**Validates: Requirements 1.7, 17.7**
"""
@settings(max_examples=100)
@given(
n_bull=st.integers(min_value=1, max_value=10),
n_bear=st.integers(min_value=1, max_value=10),
)
def test_p_bull_consistent_with_beta_mean(self, n_bull: int, n_bear: int) -> None:
"""P_bull from sigmoid(L_t) and α/(α+β) from Beta posterior are directionally
consistent and converge as evidence grows.
The sigmoid of the log-likelihood sum and the Beta posterior mean are
different parameterisations of the same underlying evidence. They always
agree on direction (both > 0.5 when bullish, both < 0.5 when bearish,
both = 0.5 when balanced) and the gap shrinks with more evidence.
"""
signals: list[WeightedSignal] = []
for i in range(n_bull):
signals.append(_make_weighted_signal(
sentiment_value=1.0, combined_weight=1.0, doc_id=f"bull-{i}",
))
for i in range(n_bear):
signals.append(_make_weighted_signal(
sentiment_value=-1.0, combined_weight=1.0, doc_id=f"bear-{i}",
))
posterior = compute_bayesian_posterior(signals)
# P_bull from sigmoid of log-likelihood
p_bull_sigmoid = posterior.p_bull
# P_bull from Beta posterior mean
p_bull_beta = posterior.alpha / (posterior.alpha + posterior.beta)
# Directional consistency: both representations agree on which side of 0.5
if n_bull > n_bear:
assert p_bull_sigmoid > 0.5, f"σ(L_t)={p_bull_sigmoid} should be > 0.5 for bullish"
assert p_bull_beta > 0.5, f"α/(α+β)={p_bull_beta} should be > 0.5 for bullish"
elif n_bear > n_bull:
assert p_bull_sigmoid < 0.5, f"σ(L_t)={p_bull_sigmoid} should be < 0.5 for bearish"
assert p_bull_beta < 0.5, f"α/(α+β)={p_bull_beta} should be < 0.5 for bearish"
else:
assert abs(p_bull_sigmoid - 0.5) < 1e-9, f"σ(L_t)={p_bull_sigmoid} should be 0.5 when balanced"
assert abs(p_bull_beta - 0.5) < 1e-9, f"α/(α+β)={p_bull_beta} should be 0.5 when balanced"
# Both values are valid probabilities in [0, 1]
assert 0.0 <= p_bull_sigmoid <= 1.0
assert 0.0 <= p_bull_beta <= 1.0
# ---------------------------------------------------------------------------
# Property 8: Shannon Entropy Range and Maximum
# Feature: signal-math-upgrade, Property 8: Shannon Entropy Range and Maximum
# **Validates: Requirements 9.7**
# ---------------------------------------------------------------------------
class TestProperty8ShannonEntropyRangeMaximum:
"""Property 8: Shannon Entropy Range and Maximum.
For any P_bull ∈ (0, 1):
- Entropy H SHALL be in (0, 1]
- Maximum value of 1.0 occurs at P_bull = 0.5
**Validates: Requirements 9.7**
"""
@settings(max_examples=100)
@given(
p_bull=st.floats(min_value=0.001, max_value=0.999, allow_nan=False, allow_infinity=False),
)
def test_entropy_in_valid_range(self, p_bull: float) -> None:
"""Entropy is in (0, 1] for all P_bull in (0, 1)."""
h = compute_entropy(p_bull)
assert 0.0 < h <= 1.0 + 1e-12, (
f"Entropy out of range for P_bull={p_bull}: H={h}"
)
@settings(max_examples=100)
@given(
p_bull=st.floats(min_value=0.001, max_value=0.999, allow_nan=False, allow_infinity=False),
)
def test_entropy_maximum_at_half(self, p_bull: float) -> None:
"""Entropy at P_bull=0.5 is >= entropy at any other P_bull."""
h = compute_entropy(p_bull)
h_max = compute_entropy(0.5)
assert h <= h_max + 1e-12, (
f"Entropy at P_bull={p_bull} ({h}) exceeds maximum at 0.5 ({h_max})"
)
def test_entropy_exactly_one_at_half(self) -> None:
"""Entropy is exactly 1.0 at P_bull = 0.5."""
h = compute_entropy(0.5)
assert abs(h - 1.0) < 1e-12, f"Entropy at P_bull=0.5 should be 1.0, got {h}"
# ---------------------------------------------------------------------------
# Property 13: Bayesian Confidence Monotonic with Agreeing Signals
# Feature: signal-math-upgrade, Property 13: Bayesian Confidence Monotonic with Agreeing Signals
# **Validates: Requirements 8.6**
# ---------------------------------------------------------------------------
class TestProperty13BayesianConfidenceMonotonicAgreeingSignals:
"""Property 13: Bayesian Confidence Monotonic with Agreeing Signals.
For any set of weighted signals where all signals agree on direction,
adding one more agreeing signal SHALL increase Bayesian confidence C.
**Validates: Requirements 8.6**
"""
@settings(max_examples=100)
@given(
base_signals=st.lists(
_bullish_signal_strategy(),
min_size=1,
max_size=15,
),
extra_signal=_bullish_signal_strategy(),
)
def test_adding_bullish_signal_increases_confidence(
self, base_signals: list[WeightedSignal], extra_signal: WeightedSignal,
) -> None:
"""Adding a bullish signal to an all-bullish set increases confidence."""
posterior_before = compute_bayesian_posterior(base_signals)
posterior_after = compute_bayesian_posterior(base_signals + [extra_signal])
assert posterior_after.bayesian_confidence >= posterior_before.bayesian_confidence - 1e-9, (
f"Confidence decreased when adding agreeing signal: "
f"before={posterior_before.bayesian_confidence:.6f}, "
f"after={posterior_after.bayesian_confidence:.6f}"
)
@settings(max_examples=100)
@given(
base_signals=st.lists(
_bearish_signal_strategy(),
min_size=1,
max_size=15,
),
extra_signal=_bearish_signal_strategy(),
)
def test_adding_bearish_signal_increases_confidence(
self, base_signals: list[WeightedSignal], extra_signal: WeightedSignal,
) -> None:
"""Adding a bearish signal to an all-bearish set increases confidence."""
posterior_before = compute_bayesian_posterior(base_signals)
posterior_after = compute_bayesian_posterior(base_signals + [extra_signal])
assert posterior_after.bayesian_confidence >= posterior_before.bayesian_confidence - 1e-9, (
f"Confidence decreased when adding agreeing signal: "
f"before={posterior_before.bayesian_confidence:.6f}, "
f"after={posterior_after.bayesian_confidence:.6f}"
)
# ---------------------------------------------------------------------------
# Property 6: Information Gain Monotonicity
# Feature: signal-math-upgrade, Property 6: Information Gain Monotonicity
# **Validates: Requirements 3.5**
# ---------------------------------------------------------------------------
class TestProperty6InformationGainMonotonicity:
"""Property 6: Information Gain Monotonicity.
For any two event type base rates p₁, p₂ ∈ (0, 1] where p₁ < p₂,
the information gain factor r(p₁) SHALL be ≥ r(p₂). Rarer events
always receive higher surprise weight.
**Validates: Requirements 3.5**
"""
@settings(max_examples=100)
@given(
p1=st.floats(min_value=0.001, max_value=1.0, allow_nan=False, allow_infinity=False),
p2=st.floats(min_value=0.001, max_value=1.0, allow_nan=False, allow_infinity=False),
)
def test_info_gain_monotonically_decreasing_with_base_rate(
self, p1: float, p2: float,
) -> None:
"""Rarer events (lower base rate) always produce higher or equal info gain."""
lo, hi = min(p1, p2), max(p1, p2)
# compute_info_gain takes an event_type string and looks up the base rate.
# To test with arbitrary base rates we call it with a dummy event type
# and override the default_base_rate, since unknown event types use the
# default_base_rate fallback. However, the function looks up the event
# type in EVENT_TYPE_BASE_RATES first. Using None returns 1.0 immediately.
# Instead, we use a non-existent event type so it falls through to
# default_base_rate.
r_lo = compute_info_gain(
event_type="__test_lo__",
lambda_param=0.3,
max_gain=3.0,
default_base_rate=lo,
)
r_hi = compute_info_gain(
event_type="__test_hi__",
lambda_param=0.3,
max_gain=3.0,
default_base_rate=hi,
)
assert r_lo >= r_hi - 1e-9, (
f"Info gain not monotonic: r(p={lo})={r_lo} < r(p={hi})={r_hi}"
)
# ---------------------------------------------------------------------------
# Property 5: Adaptive Decay Lower Bound
# Feature: signal-math-upgrade, Property 5: Adaptive Decay Lower Bound
# **Validates: Requirements 5.7, 17.4**
# ---------------------------------------------------------------------------
class TestProperty5AdaptiveDecayLowerBound:
"""Property 5: Adaptive Decay Lower Bound.
For any valid combination of impact_score ∈ [0, 1], information gain
factor r ∈ [1.0, 3.0], and market context multiplier ∈ [1.0, 1.45],
the adaptive half-life τ_i SHALL be ≥ the base half-life τ_base.
**Validates: Requirements 5.7, 17.4**
"""
@settings(max_examples=100)
@given(
base_half_life=st.floats(min_value=0.1, max_value=1000.0, allow_nan=False, allow_infinity=False),
impact_score=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
info_gain_factor=st.floats(min_value=1.0, max_value=3.0, allow_nan=False, allow_infinity=False),
market_multiplier=st.floats(min_value=1.0, max_value=1.45, allow_nan=False, allow_infinity=False),
)
def test_adaptive_half_life_never_below_base(
self,
base_half_life: float,
impact_score: float,
info_gain_factor: float,
market_multiplier: float,
) -> None:
"""Adaptive decay is always slower or equal to fixed decay, never faster."""
tau = compute_adaptive_half_life(
base_half_life=base_half_life,
impact_score=impact_score,
info_gain_factor=info_gain_factor,
market_multiplier=market_multiplier,
config=ScoringConfig(),
)
assert tau >= base_half_life - 1e-9, (
f"Adaptive half-life {tau} < base half-life {base_half_life} "
f"(impact={impact_score}, info_gain={info_gain_factor}, "
f"market={market_multiplier})"
)
# ---------------------------------------------------------------------------
# Property 9: Contradiction Entropy Monotonicity
# Feature: signal-math-upgrade, Property 9: Contradiction Entropy Monotonicity
# **Validates: Requirements 15.7**
# ---------------------------------------------------------------------------
from services.aggregation.contradiction import detect_contradictions
class TestProperty9ContradictionEntropyMonotonicity:
"""Property 9: Contradiction Entropy Monotonicity.
For any set of weighted signals containing both positive and negative
sentiment signals, the contradiction entropy score SHALL increase
monotonically as the weight distribution f_pos approaches 0.5 (equal
split). More balanced disagreement always produces higher contradiction.
**Validates: Requirements 15.7**
"""
@settings(max_examples=100)
@given(
total_weight=st.floats(min_value=1.0, max_value=20.0, allow_nan=False, allow_infinity=False),
ratio_a=st.floats(min_value=0.05, max_value=0.95, allow_nan=False, allow_infinity=False),
ratio_b=st.floats(min_value=0.05, max_value=0.95, allow_nan=False, allow_infinity=False),
)
def test_closer_to_equal_split_produces_higher_contradiction(
self,
total_weight: float,
ratio_a: float,
ratio_b: float,
) -> None:
"""The ratio closer to 0.5 always produces a higher or equal contradiction score."""
# Determine which ratio is closer to 0.5
dist_a = abs(ratio_a - 0.5)
dist_b = abs(ratio_b - 0.5)
# Build signal sets for each ratio.
# Each set has one positive and one negative signal whose combined
# weights reflect the desired split. impact_score=1.0 so effective
# weight equals combined weight.
def _make_signals(ratio: float) -> list[WeightedSignal]:
pos_w = total_weight * ratio
neg_w = total_weight * (1.0 - ratio)
return [
_make_weighted_signal(
sentiment_value=1.0,
combined_weight=pos_w,
doc_id="pos-signal",
),
_make_weighted_signal(
sentiment_value=-1.0,
combined_weight=neg_w,
doc_id="neg-signal",
),
]
# Use a high w_threshold so the evidence factor is the same for both
# (both have the same total weight).
result_a = detect_contradictions(
_make_signals(ratio_a), probabilistic=True, w_threshold=5.0,
)
result_b = detect_contradictions(
_make_signals(ratio_b), probabilistic=True, w_threshold=5.0,
)
# The ratio closer to 0.5 should have higher or equal contradiction
if dist_a < dist_b:
assert result_a.score >= result_b.score - 1e-9, (
f"Contradiction not monotonic toward 0.5: "
f"ratio_a={ratio_a} (dist={dist_a:.4f}, score={result_a.score}) "
f"< ratio_b={ratio_b} (dist={dist_b:.4f}, score={result_b.score})"
)
elif dist_b < dist_a:
assert result_b.score >= result_a.score - 1e-9, (
f"Contradiction not monotonic toward 0.5: "
f"ratio_b={ratio_b} (dist={dist_b:.4f}, score={result_b.score}) "
f"< ratio_a={ratio_a} (dist={dist_a:.4f}, score={result_a.score})"
)
else:
# Equal distance from 0.5 — scores should be equal
assert abs(result_a.score - result_b.score) < 1e-4, (
f"Equal distance from 0.5 but different scores: "
f"ratio_a={ratio_a} (score={result_a.score}), "
f"ratio_b={ratio_b} (score={result_b.score})"
)
# ---------------------------------------------------------------------------
# Property 7: Multiplicative Macro Exposure Monotonicity
# Feature: signal-math-upgrade, Property 7: Multiplicative Macro Exposure Monotonicity
# **Validates: Requirements 10.7, 17.5**
# ---------------------------------------------------------------------------
from services.aggregation.interpolation import _compute_multiplicative_exposure
class TestProperty7MultiplicativeMacroExposureMonotonicity:
"""Property 7: Multiplicative Macro Exposure Monotonicity.
For any overlap configuration where one dimension O_k = 0, setting
O_k to any positive value SHALL increase the total macro impact score.
Multi-dimensional exposure always compounds — it never reduces impact.
**Validates: Requirements 10.7, 17.5**
"""
@settings(max_examples=100)
@given(
geo=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
supply=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
commodity=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
sector=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
dimension=st.integers(min_value=0, max_value=3),
positive_value=st.floats(min_value=0.01, max_value=1.0, allow_nan=False, allow_infinity=False),
)
def test_setting_zero_dimension_to_positive_increases_impact(
self,
geo: float,
supply: float,
commodity: float,
sector: float,
dimension: int,
positive_value: float,
) -> None:
"""Setting any zero-overlap dimension to a positive value increases impact."""
overlaps = [geo, supply, commodity, sector]
# Force the chosen dimension to zero for the baseline
overlaps[dimension] = 0.0
baseline = _compute_multiplicative_exposure(*overlaps)
# Set the chosen dimension to a positive value
overlaps[dimension] = positive_value
increased = _compute_multiplicative_exposure(*overlaps)
assert increased >= baseline - 1e-12, (
f"Multiplicative exposure not monotonic: "
f"baseline={baseline} (dim {dimension}=0.0), "
f"increased={increased} (dim {dimension}={positive_value})"
)
# ---------------------------------------------------------------------------
# Property 11: Competitive Signal Distance Attenuation
# Feature: signal-math-upgrade, Property 11: Competitive Signal Distance Attenuation
# **Validates: Requirements 12.7**
# ---------------------------------------------------------------------------
from services.aggregation.signal_propagation import compute_graph_distance_attenuation
class TestProperty11CompetitiveSignalDistanceAttenuation:
"""Property 11: Competitive Signal Distance Attenuation.
For any source-target pair with fixed S_source and ρ_historical,
transfer strength SHALL decrease monotonically with increasing
graph distance d_network.
**Validates: Requirements 12.7**
"""
@settings(max_examples=100)
@given(
source_strength=st.floats(min_value=0.01, max_value=1.0, allow_nan=False, allow_infinity=False),
correlation=st.floats(min_value=0.01, max_value=1.0, allow_nan=False, allow_infinity=False),
d1=st.integers(min_value=1, max_value=3),
d2=st.integers(min_value=1, max_value=3),
)
def test_transfer_decreases_with_distance(
self,
source_strength: float,
correlation: float,
d1: int,
d2: int,
) -> None:
"""Closer competitors always receive stronger signal transfer."""
lo_dist, hi_dist = min(d1, d2), max(d1, d2)
s_close = compute_graph_distance_attenuation(source_strength, correlation, lo_dist)
s_far = compute_graph_distance_attenuation(source_strength, correlation, hi_dist)
assert s_close >= s_far - 1e-12, (
f"Transfer not monotonically decreasing with distance: "
f"S(d={lo_dist})={s_close} < S(d={hi_dist})={s_far}"
)
# ---------------------------------------------------------------------------
# Property 10: Exponentially Weighted Momentum Direction
# Feature: signal-math-upgrade, Property 10: Exponentially Weighted Momentum Direction
# **Validates: Requirements 13.6, 17.6**
# ---------------------------------------------------------------------------
from services.aggregation.projection import compute_ew_momentum
class TestProperty10ExponentiallyWeightedMomentumDirection:
"""Property 10: Exponentially Weighted Momentum Direction.
For any sequence of monotonically increasing signed trend strengths
(each ΔS > 0), the EW momentum SHALL be positive.
**Validates: Requirements 13.6, 17.6**
"""
@settings(max_examples=100)
@given(
deltas=st.lists(
st.floats(min_value=0.001, max_value=1.0, allow_nan=False, allow_infinity=False),
min_size=2,
max_size=10,
),
)
def test_monotonically_increasing_strengths_produce_positive_momentum(
self,
deltas: list[float],
) -> None:
"""Consistently strengthening bullish trends always produce positive momentum."""
# All deltas are positive (monotonically increasing signed strengths)
momentum = compute_ew_momentum(deltas)
assert momentum > 0.0, (
f"EW momentum should be positive for all-positive deltas: "
f"deltas={deltas}, momentum={momentum}"
)
# ---------------------------------------------------------------------------
# Property 12: Expected Value Directional Consistency
# Feature: signal-math-upgrade, Property 12: Expected Value Directional Consistency
# **Validates: Requirements 17.8**
# ---------------------------------------------------------------------------
from services.recommendation.eligibility import compute_expected_value
class TestProperty12ExpectedValueDirectionalConsistency:
"""Property 12: Expected Value Directional Consistency.
For any P_bull > 0.5 and estimated returns where R_up > R_down,
EV SHALL be positive.
**Validates: Requirements 17.8**
"""
@settings(max_examples=100)
@given(
p_bull=st.floats(min_value=0.501, max_value=1.0, allow_nan=False, allow_infinity=False),
strength=st.floats(min_value=0.501, max_value=1.0, allow_nan=False, allow_infinity=False),
sigma_20=st.floats(min_value=0.001, max_value=1.0, allow_nan=False, allow_infinity=False),
horizon_days=st.floats(min_value=1.0, max_value=90.0, allow_nan=False, allow_infinity=False),
)
def test_ev_positive_when_bullish_and_upside_exceeds_downside(
self,
p_bull: float,
strength: float,
sigma_20: float,
horizon_days: float,
) -> None:
"""When P_bull > 0.5 and strength > 0.5 (R_up > R_down), EV is positive."""
# strength > 0.5 ensures R_up = strength * σ * √h > (1-strength) * σ * √h = R_down
ev = compute_expected_value(p_bull, strength, sigma_20, horizon_days)
assert ev > -1e-12, (
f"EV should be positive when P_bull={p_bull} > 0.5 and "
f"strength={strength} > 0.5: EV={ev}"
)
# ---------------------------------------------------------------------------
# Property 14: Numerical Stability Across All Formulas
# Feature: signal-math-upgrade, Property 14: Numerical Stability Across All Formulas
# **Validates: Requirements 17.9, 6.4**
# ---------------------------------------------------------------------------
from services.aggregation.interpolation import _compute_multiplicative_exposure
from services.aggregation.projection import compute_volatility_scaled_momentum
from services.aggregation.scoring import compute_regime_multiplier, sigmoid_gate
class TestProperty14NumericalStabilityAcrossAllFormulas:
"""Property 14: Numerical Stability Across All Formulas.
For any valid input combination to any formula in the probabilistic
pipeline, the output SHALL be a finite float (not NaN, not infinity)
within the documented range.
Formulas tested:
- Sigmoid gate: output in (0, 1)
- Beta posterior (P_bull, alpha, beta, bayesian_confidence, entropy)
- Bayesian confidence: output in [0, 1]
- Adaptive decay: output >= base_half_life
- Regime multiplier: output in [1.0, 2.5]
- Shannon entropy: output in [0, 1]
- Multiplicative exposure: output in [0, ~0.724]
- EW momentum: output in [-1, 1]
- Volatility-scaled momentum: output in [-2.0, 2.0]
- Expected value: output is finite
**Validates: Requirements 17.9, 6.4**
"""
@settings(max_examples=100)
@given(
x=st.floats(min_value=-10.0, max_value=10.0, allow_nan=False, allow_infinity=False),
steepness=st.floats(min_value=0.1, max_value=50.0, allow_nan=False, allow_infinity=False),
midpoint=st.floats(min_value=-5.0, max_value=5.0, allow_nan=False, allow_infinity=False),
)
def test_sigmoid_gate_finite_and_in_range(
self, x: float, steepness: float, midpoint: float,
) -> None:
"""Sigmoid gate always produces a finite float in (0, 1)."""
result = sigmoid_gate(x, steepness, midpoint)
assert math.isfinite(result), f"Sigmoid gate produced non-finite: {result}"
assert 0.0 <= result <= 1.0, f"Sigmoid gate out of range: {result}"
@settings(max_examples=100)
@given(
signals=st.lists(
_weighted_signal_strategy(),
min_size=0,
max_size=30,
),
)
def test_bayesian_posterior_finite_and_in_range(
self, signals: list[WeightedSignal],
) -> None:
"""All Bayesian posterior outputs are finite and within documented ranges."""
posterior = compute_bayesian_posterior(signals)
# P_bull in [0, 1]
assert math.isfinite(posterior.p_bull), f"P_bull non-finite: {posterior.p_bull}"
assert 0.0 <= posterior.p_bull <= 1.0, f"P_bull out of range: {posterior.p_bull}"
# Alpha >= 1.0
assert math.isfinite(posterior.alpha), f"Alpha non-finite: {posterior.alpha}"
assert posterior.alpha >= 1.0, f"Alpha below 1.0: {posterior.alpha}"
# Beta >= 1.0
assert math.isfinite(posterior.beta), f"Beta non-finite: {posterior.beta}"
assert posterior.beta >= 1.0, f"Beta below 1.0: {posterior.beta}"
# Bayesian confidence in [0, 1]
assert math.isfinite(posterior.bayesian_confidence), (
f"Bayesian confidence non-finite: {posterior.bayesian_confidence}"
)
assert 0.0 <= posterior.bayesian_confidence <= 1.0 + 1e-9, (
f"Bayesian confidence out of range: {posterior.bayesian_confidence}"
)
# Entropy in [0, 1]
assert math.isfinite(posterior.entropy), f"Entropy non-finite: {posterior.entropy}"
assert 0.0 <= posterior.entropy <= 1.0 + 1e-9, (
f"Entropy out of range: {posterior.entropy}"
)
# Log-likelihood is finite
assert math.isfinite(posterior.log_likelihood), (
f"Log-likelihood non-finite: {posterior.log_likelihood}"
)
@settings(max_examples=100)
@given(
base_half_life=st.floats(min_value=0.01, max_value=2000.0, allow_nan=False, allow_infinity=False),
impact_score=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
info_gain_factor=st.floats(min_value=1.0, max_value=3.0, allow_nan=False, allow_infinity=False),
market_multiplier=st.floats(min_value=1.0, max_value=2.5, allow_nan=False, allow_infinity=False),
)
def test_adaptive_decay_finite_and_above_base(
self,
base_half_life: float,
impact_score: float,
info_gain_factor: float,
market_multiplier: float,
) -> None:
"""Adaptive decay always produces a finite half-life >= base."""
tau = compute_adaptive_half_life(
base_half_life=base_half_life,
impact_score=impact_score,
info_gain_factor=info_gain_factor,
market_multiplier=market_multiplier,
config=ScoringConfig(),
)
assert math.isfinite(tau), f"Adaptive half-life non-finite: {tau}"
assert tau >= base_half_life - 1e-9, (
f"Adaptive half-life {tau} < base {base_half_life}"
)
@settings(max_examples=100)
@given(
returns=st.lists(
st.floats(min_value=-0.5, max_value=0.5, allow_nan=False, allow_infinity=False),
min_size=2,
max_size=30,
),
volumes=st.lists(
st.floats(min_value=0.0, max_value=1e9, allow_nan=False, allow_infinity=False),
min_size=2,
max_size=30,
),
)
def test_regime_multiplier_finite_and_in_range(
self, returns: list[float], volumes: list[float],
) -> None:
"""Regime multiplier always produces a finite float in [1.0, 2.5]."""
result = compute_regime_multiplier(returns, volumes)
assert math.isfinite(result), f"Regime multiplier non-finite: {result}"
assert 1.0 <= result <= 2.5 + 1e-9, (
f"Regime multiplier out of range: {result}"
)
@settings(max_examples=100)
@given(
p_bull=st.floats(min_value=-1.0, max_value=2.0, allow_nan=False, allow_infinity=False),
)
def test_entropy_finite_and_in_range(self, p_bull: float) -> None:
"""Shannon entropy always produces a finite float in [0, 1]."""
result = compute_entropy(p_bull)
assert math.isfinite(result), f"Entropy non-finite: {result}"
assert 0.0 <= result <= 1.0 + 1e-9, f"Entropy out of range: {result}"
@settings(max_examples=100)
@given(
geo=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
supply=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
commodity=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
sector=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
)
def test_multiplicative_exposure_finite_and_in_range(
self, geo: float, supply: float, commodity: float, sector: float,
) -> None:
"""Multiplicative exposure always produces a finite float in [0, 1]."""
result = _compute_multiplicative_exposure(geo, supply, commodity, sector)
assert math.isfinite(result), f"Multiplicative exposure non-finite: {result}"
assert -1e-9 <= result <= 1.0 + 1e-9, (
f"Multiplicative exposure out of range: {result}"
)
@settings(max_examples=100)
@given(
deltas=st.lists(
st.floats(min_value=-2.0, max_value=2.0, allow_nan=False, allow_infinity=False),
min_size=0,
max_size=15,
),
lambda_decay=st.floats(min_value=0.01, max_value=0.99, allow_nan=False, allow_infinity=False),
)
def test_ew_momentum_finite_and_in_range(
self, deltas: list[float], lambda_decay: float,
) -> None:
"""EW momentum always produces a finite float in [-1, 1]."""
result = compute_ew_momentum(deltas, lambda_decay)
assert math.isfinite(result), f"EW momentum non-finite: {result}"
assert -1.0 - 1e-9 <= result <= 1.0 + 1e-9, (
f"EW momentum out of range: {result}"
)
@settings(max_examples=100)
@given(
momentum=st.floats(min_value=-5.0, max_value=5.0, allow_nan=False, allow_infinity=False),
sigma_20=st.floats(min_value=-0.1, max_value=2.0, allow_nan=False, allow_infinity=False),
)
def test_volatility_scaled_momentum_finite_and_in_range(
self, momentum: float, sigma_20: float,
) -> None:
"""Volatility-scaled momentum always produces a finite float in [-2.0, 2.0]."""
result = compute_volatility_scaled_momentum(momentum, sigma_20)
assert math.isfinite(result), (
f"Volatility-scaled momentum non-finite: {result}"
)
assert -2.0 - 1e-9 <= result <= 2.0 + 1e-9, (
f"Volatility-scaled momentum out of range: {result}"
)
@settings(max_examples=100)
@given(
p_bull=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
strength=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
sigma_20=st.floats(min_value=0.0, max_value=2.0, allow_nan=False, allow_infinity=False),
horizon_days=st.floats(min_value=0.0, max_value=365.0, allow_nan=False, allow_infinity=False),
)
def test_expected_value_finite(
self,
p_bull: float,
strength: float,
sigma_20: float,
horizon_days: float,
) -> None:
"""Expected value always produces a finite float."""
result = compute_expected_value(p_bull, strength, sigma_20, horizon_days)
assert math.isfinite(result), f"Expected value non-finite: {result}"