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
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.
1031 lines
41 KiB
Python
1031 lines
41 KiB
Python
"""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}"
|