feat: implement dual-pipeline signal engine service
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/push/build-2 Pipeline was successful
ci/woodpecker/push/build-1 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

New service at services/signal_engine/ implementing concurrent heuristic
(deterministic scoring) and probabilistic (Bayesian inference) pipelines
that evaluate technical signals across 6 timeframes (M30-M) and produce
independent BUY/WATCH/SKIP verdicts per ticker per evaluation tick.

Components:
- Input Normalizer: multi-source data assembly with sentinel fallbacks
- Signal Library: Fibonacci, MA Stack, RSI, Cup & Handle, Elliott Wave
- Multi-Timeframe Confluence Engine: weighted scoring with D/W/M anchors
- Hard Filter Engine: macro_bias, valuation, earnings proximity gating
- Heuristic Pipeline: S_total scoring with confidence-gated verdicts
- Probabilistic Pipeline: Bayesian log-odds with regime priors, entropy
  gating, EV_R calculation, and signal correlation penalty
- Exit Engine: stop-loss, targets, trailing ATR-based stops
- Delta Analyzer: pipeline agreement tracking with rolling Redis metrics
- Output Formatter: SignalOutput contract + Recommendation schema mapping
- Worker orchestrator: concurrent pipelines with failure isolation
- Main entry point: queue polling with fail-safe config loading

Infrastructure:
- Migration 039: signal_engine_outputs table with 3 indexes
- Helm chart: signalEngine service entry (processing tier)
- Redis key: QUEUE_SIGNAL_ENGINE constant

Tests: 390 tests (unit + property-based) covering all components
Config: dual_pipeline_enabled=false by default (safe rollout)
This commit is contained in:
Celes Renata
2026-05-02 07:32:26 +00:00
parent 7e2343ec2c
commit f468e30af0
61 changed files with 14107 additions and 184 deletions
+178
View File
@@ -0,0 +1,178 @@
# Feature: dual-pipeline-signal-engine, Properties: Bayesian log-odds, entropy gate, EV_R
"""Property-based tests for the probabilistic pipeline math.
Feature: dual-pipeline-signal-engine
Tests three properties from the design specification:
1. Bayesian log-odds round-trip (Requirement 17.2)
2. Shannon entropy gate properties (Requirement 17.3)
3. EV_R monotonicity with P_up (Requirement 17.8)
Requirements: 6.3, 6.4, 6.5, 17.2, 17.3, 17.8
"""
from __future__ import annotations
import math
from hypothesis import given, settings
from hypothesis import strategies as st
from services.signal_engine.probabilistic import (
_logit,
_shannon_entropy,
_sigmoid,
)
# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------
# Prior probability in (0.01, 0.99) — avoids extreme clamping at boundaries
_prior_prob = st.floats(
min_value=0.01, max_value=0.99, allow_nan=False, allow_infinity=False,
)
# Log-likelihood ratio values — bounded to avoid overflow in sigmoid
_log_lr = st.floats(
min_value=-10.0, max_value=10.0, allow_nan=False, allow_infinity=False,
)
# List of log-LR values (1 to 10 signals)
_log_lr_list = st.lists(_log_lr, min_size=1, max_size=10)
# Probability in open interval (0, 1) for entropy tests
_open_prob = st.floats(
min_value=1e-6, max_value=1.0 - 1e-6, allow_nan=False, allow_infinity=False,
)
# P_up values for EV_R monotonicity — two ordered values
_p_up_pair = st.tuples(
st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
).filter(lambda pair: pair[0] < pair[1])
# Positive expected win in R-units
_e_win_r = st.floats(
min_value=0.01, max_value=100.0, allow_nan=False, allow_infinity=False,
)
# ---------------------------------------------------------------------------
# Property 1: Bayesian log-odds round-trip
# Validates: Requirements 6.3, 17.2
# ---------------------------------------------------------------------------
@given(p_prior=_prior_prob, log_lrs=_log_lr_list)
@settings(max_examples=100)
def test_bayesian_log_odds_round_trip(
p_prior: float, log_lrs: list[float],
) -> None:
"""**Validates: Requirements 6.3, 17.2**
Converting P_prior to logit, adding Σ log(LR_i), and converting back
via sigmoid SHALL produce a valid probability in [0, 1].
logit(P_post) = logit(P_prior) + Σ log(LR_i)
P_post = sigmoid(logit(P_post))
The sigmoid implementation clamps extreme values to 0.0 / 1.0, so the
result is in the closed interval [0, 1].
"""
logit_prior = _logit(p_prior)
sum_log_lr = sum(log_lrs)
logit_posterior = logit_prior + sum_log_lr
p_posterior = _sigmoid(logit_posterior)
# Posterior must be a valid probability in [0, 1]
assert 0.0 <= p_posterior <= 1.0, (
f"Posterior {p_posterior} not in [0, 1]. "
f"P_prior={p_prior}, logit_prior={logit_prior}, "
f"Σ log_lr={sum_log_lr}, logit_posterior={logit_posterior}"
)
# For non-saturated posteriors, round-trip should hold:
# sigmoid(logit(p)) ≈ p. At saturation (0.0 or 1.0) the logit
# clamps, so we only check the interior.
if 1e-9 < p_posterior < 1.0 - 1e-9:
round_trip = _sigmoid(_logit(p_posterior))
assert math.isclose(round_trip, p_posterior, rel_tol=1e-6), (
f"Round-trip failed: sigmoid(logit({p_posterior})) = {round_trip}"
)
# ---------------------------------------------------------------------------
# Property 2: Shannon entropy gate properties
# Validates: Requirements 6.4, 17.3
# ---------------------------------------------------------------------------
@given(p=_open_prob)
@settings(max_examples=100)
def test_entropy_maximized_at_half(p: float) -> None:
"""**Validates: Requirements 6.4, 17.3**
Shannon entropy H(p) SHALL be maximized at p = 0.5.
For all p in (0, 1): H(0.5) >= H(p).
"""
h_p = _shannon_entropy(p)
h_half = _shannon_entropy(0.5)
assert h_half >= h_p - 1e-12, (
f"Entropy at 0.5 ({h_half}) should be >= entropy at {p} ({h_p})"
)
def test_entropy_zero_at_boundaries() -> None:
"""**Validates: Requirements 6.4, 17.3**
Shannon entropy SHALL equal 0.0 at p = 0.0 and p = 1.0.
"""
assert _shannon_entropy(0.0) == 0.0, "H(0.0) should be 0.0"
assert _shannon_entropy(1.0) == 0.0, "H(1.0) should be 0.0"
@given(p=_open_prob)
@settings(max_examples=100)
def test_entropy_symmetric_around_half(p: float) -> None:
"""**Validates: Requirements 6.4, 17.3**
Shannon entropy SHALL be symmetric around 0.5: H(p) == H(1 - p).
"""
h_p = _shannon_entropy(p)
h_complement = _shannon_entropy(1.0 - p)
assert math.isclose(h_p, h_complement, rel_tol=1e-9), (
f"Entropy not symmetric: H({p}) = {h_p}, H({1.0 - p}) = {h_complement}"
)
# ---------------------------------------------------------------------------
# Property 3: EV_R monotonicity with P_up
# Validates: Requirements 6.5, 17.8
# ---------------------------------------------------------------------------
@given(p_pair=_p_up_pair, e_win=_e_win_r)
@settings(max_examples=100)
def test_ev_r_monotonically_increasing_with_p_up(
p_pair: tuple[float, float], e_win: float,
) -> None:
"""**Validates: Requirements 6.5, 17.8**
EV_R = P_up · E[win_R] - (1 - P_up) · 1.0 SHALL be monotonically
increasing with P_up for fixed E[win_R] > 0.
For p1 < p2 and fixed E[win_R] > 0: EV_R(p2) >= EV_R(p1).
"""
p1, p2 = p_pair
# Compute EV_R directly using the formula (not _compute_ev_r which
# derives E[win_R] from confluence signals)
ev_r_1 = p1 * e_win - (1.0 - p1) * 1.0
ev_r_2 = p2 * e_win - (1.0 - p2) * 1.0
assert ev_r_2 >= ev_r_1 - 1e-12, (
f"EV_R not monotonic: EV_R(p2={p2}) = {ev_r_2} < "
f"EV_R(p1={p1}) = {ev_r_1} with E[win_R]={e_win}"
)
+177
View File
@@ -0,0 +1,177 @@
# Feature: dual-pipeline-signal-engine, Property: Confluence score monotonicity
"""Property-based tests for the Multi-Timeframe Confluence Engine.
Feature: dual-pipeline-signal-engine
Tests the confluence score monotonicity property from the design specification:
activating a signal on an additional timeframe with non-zero weight always
increases or maintains the confluence score.
Requirements: 3.6, 17.5
"""
from __future__ import annotations
from hypothesis import given, settings
from hypothesis import strategies as st
from services.signal_engine.confluence import compute_confluence
from services.signal_engine.models import SignalDirection, SignalResult
# ---------------------------------------------------------------------------
# Property: Confluence score monotonicity
# Validates: Requirements 3.6, 17.5
# ---------------------------------------------------------------------------
# Default timeframe weights per the design specification
DEFAULT_WEIGHTS: dict[str, float] = {
"M30": 0.03,
"H1": 0.07,
"H4": 0.15,
"D": 0.30,
"W": 0.30,
"M": 0.15,
}
ALL_TIMEFRAMES = list(DEFAULT_WEIGHTS.keys())
ANCHOR_TIMEFRAMES = ["D", "W", "M"]
NON_ANCHOR_TIMEFRAMES = ["M30", "H1", "H4"]
# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------
_direction = st.sampled_from([SignalDirection.BULLISH, SignalDirection.BEARISH])
_nonzero_strength = st.floats(
min_value=0.01, max_value=1.0, allow_nan=False, allow_infinity=False,
)
_confidence = st.floats(
min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False,
)
_signal_type = st.just("test_signal")
def _signal_result(
timeframe: str,
strength: st.SearchStrategy[float] = _nonzero_strength,
) -> st.SearchStrategy[SignalResult]:
"""Build a SignalResult for a given timeframe with non-zero strength."""
return st.builds(
SignalResult,
signal_type=_signal_type,
timeframe=st.just(timeframe),
strength=strength,
direction=_direction,
confidence=_confidence,
)
@st.composite
def _base_and_extra_timeframe(draw: st.DrawFn) -> tuple[dict[str, SignalResult], str]:
"""Generate a base set of signal results that passes confluence, plus one extra timeframe.
The base set has at least 2 timeframes including at least one D/W/M anchor.
The extra timeframe is not in the base set and has a non-zero weight.
"""
# Pick 1 anchor timeframe (guaranteed)
anchor = draw(st.sampled_from(ANCHOR_TIMEFRAMES))
# Pick 1-4 additional timeframes from the remaining (to get at least 2 total)
remaining = [tf for tf in ALL_TIMEFRAMES if tf != anchor]
additional_count = draw(st.integers(min_value=1, max_value=min(4, len(remaining))))
additional = draw(
st.lists(
st.sampled_from(remaining),
min_size=additional_count,
max_size=additional_count,
unique=True,
)
)
base_tfs = [anchor] + additional
# Build signal results for the base set
base_results: dict[str, SignalResult] = {}
for tf in base_tfs:
base_results[tf] = draw(_signal_result(tf))
# Pick an extra timeframe NOT in the base set
unused = [tf for tf in ALL_TIMEFRAMES if tf not in base_tfs]
if not unused:
# All 6 timeframes used — remove one non-anchor from base to free it up
removable = [tf for tf in base_tfs if tf not in ANCHOR_TIMEFRAMES]
if not removable:
# All are anchors — remove one that isn't the primary anchor
removable = [tf for tf in base_tfs if tf != anchor]
to_remove = draw(st.sampled_from(removable))
del base_results[to_remove]
unused = [to_remove]
extra_tf = draw(st.sampled_from(unused))
return base_results, extra_tf
# ---------------------------------------------------------------------------
# Property test
# ---------------------------------------------------------------------------
@given(data=st.data(), base_and_extra=_base_and_extra_timeframe())
@settings(max_examples=100)
def test_confluence_score_monotonicity(
data: st.DataObject,
base_and_extra: tuple[dict[str, SignalResult], str],
) -> None:
"""**Validates: Requirements 3.6, 17.5**
Given a signal that already passes confluence (≥2 timeframes, ≥1 D/W/M
anchor), adding an additional timeframe with non-zero strength and
non-zero weight SHALL always increase or maintain the confluence score.
The weighted confluence score is C = Σ(w_tf · s_tf). Since both w_tf > 0
and s_tf > 0 for the added timeframe, the new term is strictly positive,
so the score must increase.
"""
base_results, extra_tf = base_and_extra
signal_type = "test_signal"
# Compute confluence for the base set
base_input = {signal_type: dict(base_results)}
base_confluence = compute_confluence(base_input, DEFAULT_WEIGHTS)
# The base set should pass confluence (≥2 TFs, ≥1 anchor)
assert len(base_confluence) == 1, (
f"Expected base set to pass confluence but got {len(base_confluence)} signals.\n"
f" Base timeframes: {list(base_results.keys())}"
)
base_score = base_confluence[0].confluence_score
# Add the extra timeframe with non-zero strength
extra_result = data.draw(_signal_result(extra_tf))
extended_results = dict(base_results)
extended_results[extra_tf] = extra_result
# Compute confluence for the extended set
extended_input = {signal_type: extended_results}
extended_confluence = compute_confluence(extended_input, DEFAULT_WEIGHTS)
# The extended set must also pass confluence (superset of a passing set)
assert len(extended_confluence) == 1, (
f"Expected extended set to pass confluence but got "
f"{len(extended_confluence)} signals.\n"
f" Extended timeframes: {list(extended_results.keys())}"
)
new_score = extended_confluence[0].confluence_score
# Monotonicity: new_score >= base_score
assert new_score >= base_score, (
f"Confluence score decreased when adding timeframe {extra_tf}!\n"
f" Base score: {base_score:.6f} (timeframes: {list(base_results.keys())})\n"
f" Extended score: {new_score:.6f} (timeframes: {list(extended_results.keys())})\n"
f" Added TF weight: {DEFAULT_WEIGHTS[extra_tf]}, "
f"strength: {extra_result.strength:.6f}"
)
+133
View File
@@ -0,0 +1,133 @@
# Feature: dual-pipeline-signal-engine, Property: Correlation penalty reduces confidence
"""Property-based tests for the signal correlation penalty.
Feature: dual-pipeline-signal-engine
Tests that the within-cluster correlation penalty always reduces (or maintains)
the posterior probability compared to the unpenalized posterior. Correlated
signals within the same cluster receive exponential decay (0.5^(n-1)), so the
penalized Σ log(LR_i) is always <= the unpenalized Σ log(LR_i) in absolute
magnitude, which means the posterior moves less from the prior.
Requirements: 7.5, 17.4
"""
from __future__ import annotations
import math
from hypothesis import given, settings
from hypothesis import strategies as st
from services.signal_engine.correlation import apply_correlation_penalty
from services.signal_engine.models import LikelihoodRatio
from services.signal_engine.probabilistic import _logit, _sigmoid
# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------
# Clusters that have multiple signal types mapped to them
_CLUSTER_SIGNAL_TYPES: dict[str, list[str]] = {
"momentum": ["ma_stack", "rsi"],
"structure": ["fibonacci", "elliott_wave", "cup_handle"],
"volatility": ["atr", "bollinger"],
"fundamentals": ["valuation", "earnings", "macro"],
}
# Pick a cluster that has at least 2 signal types
_cluster_with_types = st.sampled_from(
[(cluster, types) for cluster, types in _CLUSTER_SIGNAL_TYPES.items() if len(types) >= 2]
)
# Positive log-LR values (bullish signals) — ensures same direction within cluster
_positive_log_lr = st.floats(
min_value=0.01, max_value=5.0, allow_nan=False, allow_infinity=False,
)
# Prior probability
_prior_prob = st.floats(
min_value=0.01, max_value=0.99, allow_nan=False, allow_infinity=False,
)
@st.composite
def _correlated_lr_set(draw: st.DrawFn) -> list[LikelihoodRatio]:
"""Generate a list of LikelihoodRatio objects with at least 2 in the same cluster.
All signals within the chosen cluster have positive log_lr (bullish direction)
so the penalty effect is clearly measurable.
"""
cluster, signal_types = draw(_cluster_with_types)
# Draw at least 2 signals from the same cluster
n_correlated = draw(st.integers(min_value=2, max_value=len(signal_types)))
chosen_types = draw(
st.lists(
st.sampled_from(signal_types),
min_size=n_correlated,
max_size=n_correlated,
unique=True,
)
)
lrs: list[LikelihoodRatio] = []
for sig_type in chosen_types:
log_lr = draw(_positive_log_lr)
lr_val = math.exp(log_lr)
lrs.append(
LikelihoodRatio(
signal_type=sig_type,
cluster=cluster,
lr=lr_val,
log_lr=log_lr,
penalized_log_lr=log_lr, # unpenalized initially
hit_rate=0.6,
strength=0.5,
)
)
return lrs
# ---------------------------------------------------------------------------
# Property: Correlation penalty reduces confidence
# Validates: Requirements 7.5, 17.4
# ---------------------------------------------------------------------------
@given(lrs=_correlated_lr_set(), p_prior=_prior_prob)
@settings(max_examples=100)
def test_penalized_posterior_leq_unpenalized(
lrs: list[LikelihoodRatio], p_prior: float,
) -> None:
"""**Validates: Requirements 7.5, 17.4**
For any signal set with at least 2 correlated signals in the same
cluster, the penalized posterior SHALL be <= the unpenalized posterior.
The penalty reduces the magnitude of Σ penalized_log_lr relative to
Σ log_lr, so the posterior moves less from the prior.
"""
# Unpenalized posterior: use raw log_lr values
logit_prior = _logit(p_prior)
sum_unpenalized = sum(lr.log_lr for lr in lrs)
p_unpenalized = _sigmoid(logit_prior + sum_unpenalized)
# Apply correlation penalty
penalized_lrs = apply_correlation_penalty(lrs)
# Penalized posterior: use penalized_log_lr values
sum_penalized = sum(lr.penalized_log_lr for lr in penalized_lrs)
p_penalized = _sigmoid(logit_prior + sum_penalized)
# Since all log_lr values are positive (bullish), the penalty reduces
# the sum, which means the penalized posterior is <= unpenalized posterior
assert p_penalized <= p_unpenalized + 1e-12, (
f"Penalized posterior {p_penalized} > unpenalized {p_unpenalized}. "
f"Prior={p_prior}, Σ_raw={sum_unpenalized}, Σ_penalized={sum_penalized}"
)
# Also verify the penalized sum of log-LRs is <= the unpenalized sum
assert sum_penalized <= sum_unpenalized + 1e-12, (
f"Penalized Σ log_lr ({sum_penalized}) > unpenalized ({sum_unpenalized})"
)
+72
View File
@@ -0,0 +1,72 @@
# Feature: dual-pipeline-signal-engine, Property: Fibonacci retracement bounds
"""Property-based tests for the Fibonacci retracement formula.
Feature: dual-pipeline-signal-engine
Tests the Fibonacci retracement bounds property from the design specification:
for all retracement ratios r in [0, 1] and all swing high SH > swing low SL > 0,
the retracement level L(r) = SH - r * (SH - SL) must lie within [SL, SH].
Requirements: 2.1, 17.1
"""
from __future__ import annotations
from hypothesis import given, settings
from hypothesis import strategies as st
# ---------------------------------------------------------------------------
# Property: Fibonacci retracement bounds
# Validates: Requirements 2.1, 17.1
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------
# Retracement ratio in [0, 1]
_ratio = st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False)
# Positive floats for swing high / swing low
_positive_float = st.floats(
min_value=1e-8, max_value=1e8, allow_nan=False, allow_infinity=False,
)
@st.composite
def _swing_pair(draw: st.DrawFn) -> tuple[float, float]:
"""Generate (SH, SL) where SH > SL > 0."""
a = draw(_positive_float)
b = draw(_positive_float)
sh = max(a, b)
sl = min(a, b)
# Ensure strict inequality SH > SL
if sh == sl:
sh = sl + 1e-8
return sh, sl
# ---------------------------------------------------------------------------
# Property test
# ---------------------------------------------------------------------------
@given(r=_ratio, swing=_swing_pair())
@settings(max_examples=100)
def test_fibonacci_retracement_within_bounds(r: float, swing: tuple[float, float]) -> None:
"""**Validates: Requirements 2.1, 17.1**
For all r in [0, 1] and all SH > SL > 0, the Fibonacci retracement
level L(r) = SH - r * (SH - SL) SHALL be in [SL, SH].
This is a pure mathematical property — no evaluator class needed.
"""
sh, sl = swing
# Compute the retracement level
level = sh - r * (sh - sl)
assert sl <= level <= sh, (
f"Fibonacci level {level} out of bounds [SL={sl}, SH={sh}] "
f"for r={r}.\n"
f" L(r) = {sh} - {r} * ({sh} - {sl}) = {level}"
)
+222
View File
@@ -0,0 +1,222 @@
# Feature: dual-pipeline-signal-engine, Property: Hard filter determinism
"""Property-based tests for the Hard Filter Engine.
Feature: dual-pipeline-signal-engine
Tests the hard filter determinism property from the design specification:
certain input conditions SHALL always produce a filtered (SKIP) result
regardless of all other field values in the NormalizedInput.
Requirements: 4.1, 4.2, 4.3, 17.7
"""
from __future__ import annotations
from datetime import datetime, timezone
from hypothesis import given, settings
from hypothesis import strategies as st
from services.signal_engine.config import HardFilterConfig
from services.signal_engine.hard_filter import HardFilterResult, evaluate_hard_filters
from services.signal_engine.models import NormalizedInput, OHLCVBar, OpenPositionState
# ---------------------------------------------------------------------------
# Property: Hard Filter Determinism
# Validates: Requirements 4.1, 4.2, 4.3, 17.7
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Hypothesis strategies — building blocks
# ---------------------------------------------------------------------------
_finite_float = st.floats(allow_nan=False, allow_infinity=False)
_unit_float = st.floats(
min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False,
)
_positive_float = st.floats(
min_value=0.01, max_value=1e6, allow_nan=False, allow_infinity=False,
)
_aware_datetime = st.datetimes(
min_value=datetime(2020, 1, 1),
max_value=datetime(2030, 12, 31),
timezones=st.just(timezone.utc),
)
_ticker = st.text(
alphabet=st.characters(whitelist_categories=("Lu",)),
min_size=1,
max_size=5,
)
# --- OHLCVBar strategy ---
_ohlcv_bar = st.builds(
OHLCVBar,
timestamp=_aware_datetime,
open=_positive_float,
high=_positive_float,
low=_positive_float,
close=_positive_float,
volume=_positive_float,
)
# --- Bars dict strategy (0-3 bars per timeframe, 0-2 timeframes) ---
_bars_strategy = st.fixed_dictionaries(
{},
optional={
tf: st.lists(_ohlcv_bar, min_size=0, max_size=3)
for tf in ["M30", "H1", "H4", "D", "W", "M"]
},
)
# --- OpenPositionState strategy ---
_open_position = st.builds(
OpenPositionState,
position_id=st.uuids().map(str),
ticker=_ticker,
entry_price=_positive_float,
current_price=_positive_float,
stop_loss=_positive_float,
target_1=_positive_float,
target_2=_positive_float,
trailing_stop=st.one_of(st.none(), _positive_float),
partial_exit_done=st.booleans(),
atr=st.one_of(st.none(), _positive_float),
)
# --- Base NormalizedInput strategy (all fields arbitrary) ---
def _normalized_input_strategy(
*,
macro_bias: st.SearchStrategy[float] | None = None,
valuation_score: st.SearchStrategy[float | None] | None = None,
earnings_proximity_days: st.SearchStrategy[int | None] | None = None,
) -> st.SearchStrategy[NormalizedInput]:
"""Build a NormalizedInput strategy with optional field overrides.
Fields not overridden are generated with full arbitrary ranges so that
the property tests prove the filter holds *regardless* of other values.
"""
return st.builds(
NormalizedInput,
ticker=_ticker,
evaluated_at=_aware_datetime,
bars=_bars_strategy,
valuation_score=(
valuation_score
if valuation_score is not None
else st.one_of(st.none(), _unit_float)
),
earnings_proximity_days=(
earnings_proximity_days
if earnings_proximity_days is not None
else st.one_of(st.none(), st.integers(min_value=0, max_value=365))
),
macro_bias=(
macro_bias
if macro_bias is not None
else st.floats(min_value=-1.0, max_value=1.0, allow_nan=False)
),
open_positions=st.lists(_open_position, min_size=0, max_size=2),
closing_prices=st.lists(_positive_float, min_size=0, max_size=5),
returns=st.lists(_finite_float, min_size=0, max_size=5),
current_price=st.one_of(st.none(), _positive_float),
)
# Default config matches the production defaults
_default_config = HardFilterConfig()
# ---------------------------------------------------------------------------
# Property tests
# ---------------------------------------------------------------------------
@given(normalized=_normalized_input_strategy(macro_bias=st.just(-1.0)))
@settings(max_examples=100)
def test_macro_bias_negative_always_filters(normalized: NormalizedInput) -> None:
"""**Validates: Requirements 4.1, 17.7**
For any NormalizedInput where macro_bias == -1.0, the hard filter
SHALL always produce filtered=True with "macro_bias_negative" in
reasons, regardless of all other field values.
"""
result: HardFilterResult = evaluate_hard_filters(normalized, _default_config)
assert result.filtered is True, (
f"Expected filtered=True for macro_bias=-1.0 but got filtered=False.\n"
f" ticker={normalized.ticker}, valuation_score={normalized.valuation_score}, "
f"earnings_proximity_days={normalized.earnings_proximity_days}"
)
assert "macro_bias_negative" in result.reasons, (
f"Expected 'macro_bias_negative' in reasons but got {result.reasons}.\n"
f" ticker={normalized.ticker}, macro_bias={normalized.macro_bias}"
)
@given(
normalized=_normalized_input_strategy(
valuation_score=st.floats(
min_value=0.0,
max_value=0.3,
exclude_max=True,
allow_nan=False,
),
)
)
@settings(max_examples=100)
def test_valuation_below_threshold_always_filters(normalized: NormalizedInput) -> None:
"""**Validates: Requirements 4.2, 17.7**
For any NormalizedInput where valuation_score is not None and < 0.3,
the hard filter SHALL always produce filtered=True with
"valuation_below_threshold" in reasons, regardless of all other
field values.
"""
result: HardFilterResult = evaluate_hard_filters(normalized, _default_config)
assert result.filtered is True, (
f"Expected filtered=True for valuation_score={normalized.valuation_score} "
f"but got filtered=False.\n"
f" ticker={normalized.ticker}, macro_bias={normalized.macro_bias}, "
f"earnings_proximity_days={normalized.earnings_proximity_days}"
)
assert "valuation_below_threshold" in result.reasons, (
f"Expected 'valuation_below_threshold' in reasons but got {result.reasons}.\n"
f" ticker={normalized.ticker}, valuation_score={normalized.valuation_score}"
)
@given(
normalized=_normalized_input_strategy(
earnings_proximity_days=st.integers(min_value=0, max_value=5),
)
)
@settings(max_examples=100)
def test_earnings_proximity_always_filters(normalized: NormalizedInput) -> None:
"""**Validates: Requirements 4.3, 17.7**
For any NormalizedInput where earnings_proximity_days is not None
and <= 5, the hard filter SHALL always produce filtered=True with
"earnings_block" in reasons, regardless of all other field values.
"""
result: HardFilterResult = evaluate_hard_filters(normalized, _default_config)
assert result.filtered is True, (
f"Expected filtered=True for earnings_proximity_days="
f"{normalized.earnings_proximity_days} but got filtered=False.\n"
f" ticker={normalized.ticker}, macro_bias={normalized.macro_bias}, "
f"valuation_score={normalized.valuation_score}"
)
assert "earnings_block" in result.reasons, (
f"Expected 'earnings_block' in reasons but got {result.reasons}.\n"
f" ticker={normalized.ticker}, "
f"earnings_proximity_days={normalized.earnings_proximity_days}"
)
+144
View File
@@ -0,0 +1,144 @@
# Feature: dual-pipeline-signal-engine, Property: SignalOutput round-trip serialization
"""Property-based tests for SignalOutput round-trip serialization.
Feature: dual-pipeline-signal-engine
Tests the SignalOutput round-trip serialization property from the design
specification: for any valid SignalOutput instance, serializing to JSON via
model_dump_json() and deserializing back via model_validate_json() SHALL
produce a SignalOutput object equivalent to the original.
"""
from __future__ import annotations
from datetime import datetime, timezone
from hypothesis import given, settings
from hypothesis import strategies as st
from services.signal_engine.models import (
ExitSignal,
ExitType,
SignalOutput,
TradePlan,
)
# ---------------------------------------------------------------------------
# Property: SignalOutput Round-Trip Serialization
# Validates: Requirements 10.5, 17.6
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------
_finite_float = st.floats(allow_nan=False, allow_infinity=False)
_non_negative_finite_float = st.floats(
min_value=0.0, allow_nan=False, allow_infinity=False,
)
_unit_float = st.floats(
min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False,
)
_aware_datetime_strategy = st.datetimes(
min_value=datetime(2020, 1, 1),
max_value=datetime(2030, 12, 31),
timezones=st.just(timezone.utc),
)
_ticker_strategy = st.text(
alphabet=st.characters(whitelist_categories=("Lu",)),
min_size=1,
max_size=5,
)
_verdict_strategy = st.sampled_from(["BUY", "WATCH", "SKIP"])
_pipeline_mode_strategy = st.sampled_from(["dual_pipeline", "heuristic_only", "probabilistic_only"])
# --- TradePlan strategy ---
_trade_plan_strategy = st.builds(
TradePlan,
entry_price=_finite_float,
stop_loss=_finite_float,
target_1=_finite_float,
target_2=_finite_float,
position_size_pct=_unit_float,
max_loss_pct=_unit_float,
dual_confirmed=st.booleans(),
probabilistic_only=st.booleans(),
)
# --- ExitSignal strategy ---
_exit_signal_strategy = st.builds(
ExitSignal,
position_id=st.uuids().map(str),
ticker=_ticker_strategy,
exit_type=st.sampled_from(list(ExitType)),
reason=st.sampled_from(["stop_hit", "target_1_hit", "target_2_hit", "trailing_stop_hit"]),
price=_finite_float,
)
# --- Simple dict strategies for detail payloads ---
_simple_detail_strategy = st.fixed_dictionaries(
{},
optional={
"score": _finite_float,
"label": st.text(max_size=20),
"count": st.integers(min_value=0, max_value=1000),
},
)
# --- SignalOutput strategy ---
_signal_output_strategy = st.builds(
SignalOutput,
output_id=st.uuids().map(str),
ticker=_ticker_strategy,
timestamp=_aware_datetime_strategy,
price=_finite_float,
heuristic_verdict=_verdict_strategy,
heuristic_confidence=_unit_float,
heuristic_s_total=_finite_float,
probabilistic_verdict=_verdict_strategy,
probabilistic_p_up=_unit_float,
probabilistic_entropy=_unit_float,
probabilistic_ev_r=_finite_float,
delta_agreement=st.booleans(),
delta_confidence_delta=_non_negative_finite_float,
delta_reasons=st.lists(st.text(min_size=1, max_size=50), min_size=0, max_size=5),
trade_plan=st.one_of(st.none(), _trade_plan_strategy),
exit_signals=st.lists(_exit_signal_strategy, min_size=0, max_size=3),
heuristic_detail=_simple_detail_strategy,
probabilistic_detail=_simple_detail_strategy,
pipeline_mode=_pipeline_mode_strategy,
shadow_mode=st.booleans(),
)
# ---------------------------------------------------------------------------
# Property test
# ---------------------------------------------------------------------------
@given(output=_signal_output_strategy)
@settings(max_examples=100)
def test_signal_output_round_trip_serialization(output: SignalOutput) -> None:
"""**Validates: Requirements 10.5, 17.6**
For any valid SignalOutput instance, serializing to JSON and then
deserializing back SHALL produce a SignalOutput object equivalent
to the original.
"""
json_str = output.model_dump_json()
restored = SignalOutput.model_validate_json(json_str)
assert restored == output, (
f"Round-trip failed: deserialized SignalOutput differs from original.\n"
f" ticker: {output.ticker}\n"
f" heuristic_verdict: {output.heuristic_verdict}\n"
f" probabilistic_verdict: {output.probabilistic_verdict}\n"
f" trade_plan present: {output.trade_plan is not None}\n"
f" exit_signals count: {len(output.exit_signals)}"
)
+200
View File
@@ -0,0 +1,200 @@
"""Unit tests for services.signal_engine.signals.base helper functions.
Tests swing high/low detection, lookback validation, and SMA computation.
Requirements: 2.6, 2.7
"""
from __future__ import annotations
from datetime import datetime, timezone
from services.signal_engine.models import OHLCVBar
from services.signal_engine.signals.base import (
compute_sma,
find_swing_high,
find_swing_low,
validate_lookback,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _bar(
close: float,
high: float | None = None,
low: float | None = None,
ts_offset: int = 0,
) -> OHLCVBar:
"""Create a minimal OHLCVBar for testing."""
return OHLCVBar(
timestamp=datetime(2024, 1, 1, tzinfo=timezone.utc),
open=close,
high=high if high is not None else close,
low=low if low is not None else close,
close=close,
volume=1000.0,
)
# ---------------------------------------------------------------------------
# find_swing_high
# ---------------------------------------------------------------------------
def test_find_swing_high_basic() -> None:
bars = [_bar(10, high=12), _bar(10, high=15), _bar(10, high=11)]
result = find_swing_high(bars, lookback=3)
assert result is not None
idx, price = result
assert idx == 1
assert price == 15.0
def test_find_swing_high_lookback_subset() -> None:
bars = [_bar(10, high=20), _bar(10, high=12), _bar(10, high=15), _bar(10, high=11)]
# lookback=2 → only last 2 bars (index 2 and 3 in original)
result = find_swing_high(bars, lookback=2)
assert result is not None
idx, price = result
assert idx == 2 # bar at index 2 has high=15
assert price == 15.0
def test_find_swing_high_insufficient_data() -> None:
bars = [_bar(10, high=12)]
assert find_swing_high(bars, lookback=5) is None
def test_find_swing_high_zero_lookback() -> None:
bars = [_bar(10, high=12)]
assert find_swing_high(bars, lookback=0) is None
def test_find_swing_high_negative_lookback() -> None:
bars = [_bar(10, high=12)]
assert find_swing_high(bars, lookback=-1) is None
def test_find_swing_high_tie_takes_last() -> None:
"""When multiple bars share the same high, the last one wins (>=)."""
bars = [_bar(10, high=15), _bar(10, high=15), _bar(10, high=10)]
result = find_swing_high(bars, lookback=3)
assert result is not None
idx, price = result
assert idx == 1
assert price == 15.0
# ---------------------------------------------------------------------------
# find_swing_low
# ---------------------------------------------------------------------------
def test_find_swing_low_basic() -> None:
bars = [_bar(10, low=8), _bar(10, low=5), _bar(10, low=9)]
result = find_swing_low(bars, lookback=3)
assert result is not None
idx, price = result
assert idx == 1
assert price == 5.0
def test_find_swing_low_lookback_subset() -> None:
bars = [_bar(10, low=2), _bar(10, low=8), _bar(10, low=5), _bar(10, low=9)]
# lookback=2 → only last 2 bars (index 2 and 3)
result = find_swing_low(bars, lookback=2)
assert result is not None
idx, price = result
assert idx == 2 # bar at index 2 has low=5
assert price == 5.0
def test_find_swing_low_insufficient_data() -> None:
bars = [_bar(10, low=8)]
assert find_swing_low(bars, lookback=5) is None
def test_find_swing_low_zero_lookback() -> None:
bars = [_bar(10, low=8)]
assert find_swing_low(bars, lookback=0) is None
def test_find_swing_low_tie_takes_last() -> None:
"""When multiple bars share the same low, the last one wins (<=)."""
bars = [_bar(10, low=5), _bar(10, low=5), _bar(10, low=10)]
result = find_swing_low(bars, lookback=3)
assert result is not None
idx, price = result
assert idx == 1
assert price == 5.0
# ---------------------------------------------------------------------------
# validate_lookback
# ---------------------------------------------------------------------------
def test_validate_lookback_sufficient() -> None:
bars = [_bar(10)] * 20
assert validate_lookback(bars, min_bars=20) is True
def test_validate_lookback_more_than_enough() -> None:
bars = [_bar(10)] * 50
assert validate_lookback(bars, min_bars=20) is True
def test_validate_lookback_insufficient() -> None:
bars = [_bar(10)] * 5
assert validate_lookback(bars, min_bars=20) is False
def test_validate_lookback_empty() -> None:
assert validate_lookback([], min_bars=1) is False
def test_validate_lookback_zero_min() -> None:
assert validate_lookback([], min_bars=0) is True
# ---------------------------------------------------------------------------
# compute_sma
# ---------------------------------------------------------------------------
def test_compute_sma_basic() -> None:
bars = [_bar(10), _bar(20), _bar(30)]
result = compute_sma(bars, period=3)
assert result is not None
assert result == 20.0
def test_compute_sma_subset() -> None:
bars = [_bar(100), _bar(10), _bar(20), _bar(30)]
# period=3 → average of last 3 bars: (10+20+30)/3 = 20
result = compute_sma(bars, period=3)
assert result is not None
assert result == 20.0
def test_compute_sma_single_bar() -> None:
bars = [_bar(42)]
result = compute_sma(bars, period=1)
assert result is not None
assert result == 42.0
def test_compute_sma_insufficient_data() -> None:
bars = [_bar(10), _bar(20)]
assert compute_sma(bars, period=5) is None
def test_compute_sma_zero_period() -> None:
bars = [_bar(10)]
assert compute_sma(bars, period=0) is None
def test_compute_sma_negative_period() -> None:
bars = [_bar(10)]
assert compute_sma(bars, period=-1) is None
+326
View File
@@ -0,0 +1,326 @@
"""Unit tests for services.signal_engine.config.
Covers:
- Default values and fail-safe behaviour
- DB row parsing and application
- Environment variable overrides
- Sub-config derivation properties
- load_config() with mocked asyncpg pool
"""
from __future__ import annotations
import json
import os
from unittest.mock import AsyncMock, patch
import pytest
from services.signal_engine.config import (
ExitConfig,
HardFilterConfig,
HeuristicConfig,
ProbabilisticConfig,
SignalEngineConfig,
_apply_db_rows,
_apply_env_overrides,
_parse_value,
load_config,
)
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
class TestDefaults:
"""SignalEngineConfig defaults match the design spec."""
def test_dual_pipeline_disabled_by_default(self):
cfg = SignalEngineConfig()
assert cfg.dual_pipeline_enabled is False
def test_both_pipelines_enabled_by_default(self):
cfg = SignalEngineConfig()
assert cfg.heuristic_pipeline_enabled is True
assert cfg.probabilistic_pipeline_enabled is True
def test_shadow_mode_off_by_default(self):
cfg = SignalEngineConfig()
assert cfg.shadow_mode is False
def test_timeframe_weights_default(self):
cfg = SignalEngineConfig()
expected = {
"M30": 0.03,
"H1": 0.07,
"H4": 0.15,
"D": 0.30,
"W": 0.30,
"M": 0.15,
}
assert cfg.timeframe_weights == expected
def test_hard_filter_defaults(self):
cfg = SignalEngineConfig()
assert cfg.hard_filter_valuation_min == 0.3
assert cfg.hard_filter_earnings_days == 5
assert cfg.hard_filter_macro_bias_skip == -1.0
def test_heuristic_threshold_defaults(self):
cfg = SignalEngineConfig()
assert cfg.heuristic_buy_confidence == 0.70
assert cfg.heuristic_buy_s_total == 1.2
assert cfg.heuristic_buy_valuation_min == 0.5
assert cfg.heuristic_watch_confidence == 0.55
def test_probabilistic_threshold_defaults(self):
cfg = SignalEngineConfig()
assert cfg.prob_buy_p_up == 0.60
assert cfg.prob_buy_entropy_max == 0.90
assert cfg.prob_buy_ev_r_min == 1.5
assert cfg.prob_buy_valuation_min == 0.5
assert cfg.prob_watch_p_up == 0.55
assert cfg.prob_watch_entropy_max == 0.95
assert cfg.prob_entropy_skip == 0.95
def test_regime_prior_defaults(self):
cfg = SignalEngineConfig()
assert cfg.regime_prior_bull == 0.58
assert cfg.regime_prior_range == 0.50
assert cfg.regime_prior_bear == 0.42
def test_exit_and_polling_defaults(self):
cfg = SignalEngineConfig()
assert cfg.trailing_stop_atr_multiplier == 2.0
assert cfg.polling_interval_seconds == 30
# ---------------------------------------------------------------------------
# Sub-config derivation
# ---------------------------------------------------------------------------
class TestSubConfigs:
"""Properties derive correct sub-config instances."""
def test_hard_filter_config(self):
cfg = SignalEngineConfig(
hard_filter_valuation_min=0.4,
hard_filter_earnings_days=7,
hard_filter_macro_bias_skip=-0.5,
)
hf = cfg.hard_filter_config
assert isinstance(hf, HardFilterConfig)
assert hf.valuation_min == 0.4
assert hf.earnings_days == 7
assert hf.macro_bias_skip == -0.5
def test_heuristic_config(self):
cfg = SignalEngineConfig(
heuristic_buy_confidence=0.80,
heuristic_buy_s_total=1.5,
heuristic_buy_valuation_min=0.6,
heuristic_watch_confidence=0.60,
hard_filter_earnings_days=10,
)
hc = cfg.heuristic_config
assert isinstance(hc, HeuristicConfig)
assert hc.buy_confidence == 0.80
assert hc.buy_s_total == 1.5
assert hc.buy_valuation_min == 0.6
assert hc.watch_confidence == 0.60
assert hc.macro_bias_threshold == 0.0
assert hc.earnings_days_threshold == 10
def test_probabilistic_config(self):
cfg = SignalEngineConfig(
prob_buy_p_up=0.65,
regime_prior_bull=0.60,
)
pc = cfg.probabilistic_config
assert isinstance(pc, ProbabilisticConfig)
assert pc.buy_p_up == 0.65
assert pc.regime_prior_bull == 0.60
assert pc.macro_bias_threshold == 0.0
def test_exit_config(self):
cfg = SignalEngineConfig(trailing_stop_atr_multiplier=3.0)
ec = cfg.exit_config
assert isinstance(ec, ExitConfig)
assert ec.trailing_stop_atr_multiplier == 3.0
# ---------------------------------------------------------------------------
# _parse_value
# ---------------------------------------------------------------------------
class TestParseValue:
def test_bool_true_variants(self):
for v in ("true", "True", "TRUE", "1", "yes"):
assert _parse_value(v, bool) is True
def test_bool_false_variants(self):
for v in ("false", "False", "0", "no", "anything"):
assert _parse_value(v, bool) is False
def test_int(self):
assert _parse_value("42", int) == 42
def test_float(self):
assert _parse_value("0.75", float) == 0.75
def test_dict_json(self):
raw = json.dumps({"D": 0.30, "W": 0.30})
result = _parse_value(raw, dict)
assert result == {"D": 0.30, "W": 0.30}
def test_invalid_int_raises(self):
with pytest.raises(ValueError):
_parse_value("not_a_number", int)
def test_invalid_json_raises(self):
with pytest.raises(json.JSONDecodeError):
_parse_value("{bad json", dict)
# ---------------------------------------------------------------------------
# _apply_db_rows
# ---------------------------------------------------------------------------
class TestApplyDbRows:
def test_applies_known_keys(self):
cfg = SignalEngineConfig()
rows = [
("signal_engine_dual_pipeline_enabled", "true"),
("signal_engine_prob_buy_p_up", "0.65"),
("signal_engine_polling_interval_seconds", "60"),
]
_apply_db_rows(cfg, rows)
assert cfg.dual_pipeline_enabled is True
assert cfg.prob_buy_p_up == 0.65
assert cfg.polling_interval_seconds == 60
def test_ignores_unknown_keys(self):
cfg = SignalEngineConfig()
rows = [("signal_engine_unknown_field", "whatever")]
_apply_db_rows(cfg, rows) # should not raise
def test_invalid_value_keeps_default(self):
cfg = SignalEngineConfig()
rows = [("signal_engine_hard_filter_earnings_days", "not_a_number")]
_apply_db_rows(cfg, rows)
assert cfg.hard_filter_earnings_days == 5 # default preserved
def test_timeframe_weights_from_json(self):
cfg = SignalEngineConfig()
new_weights = {"D": 0.50, "W": 0.50}
rows = [
("signal_engine_timeframe_weights", json.dumps(new_weights)),
]
_apply_db_rows(cfg, rows)
assert cfg.timeframe_weights == new_weights
# ---------------------------------------------------------------------------
# _apply_env_overrides
# ---------------------------------------------------------------------------
class TestApplyEnvOverrides:
def test_env_override_bool(self):
cfg = SignalEngineConfig()
with patch.dict(os.environ, {"SIGNAL_ENGINE_DUAL_PIPELINE_ENABLED": "true"}):
_apply_env_overrides(cfg)
assert cfg.dual_pipeline_enabled is True
def test_env_override_float(self):
cfg = SignalEngineConfig()
with patch.dict(os.environ, {"SIGNAL_ENGINE_PROB_BUY_P_UP": "0.70"}):
_apply_env_overrides(cfg)
assert cfg.prob_buy_p_up == 0.70
def test_env_override_int(self):
cfg = SignalEngineConfig()
with patch.dict(os.environ, {"SIGNAL_ENGINE_POLLING_INTERVAL_SECONDS": "120"}):
_apply_env_overrides(cfg)
assert cfg.polling_interval_seconds == 120
def test_env_ignores_unrelated_vars(self):
cfg = SignalEngineConfig()
with patch.dict(os.environ, {"UNRELATED_VAR": "hello"}):
_apply_env_overrides(cfg)
# No change — just verifying no crash
assert cfg.dual_pipeline_enabled is False
def test_invalid_env_value_keeps_previous(self):
cfg = SignalEngineConfig()
cfg.hard_filter_earnings_days = 10
with patch.dict(os.environ, {"SIGNAL_ENGINE_HARD_FILTER_EARNINGS_DAYS": "bad"}):
_apply_env_overrides(cfg)
assert cfg.hard_filter_earnings_days == 10 # unchanged
# ---------------------------------------------------------------------------
# load_config (async)
# ---------------------------------------------------------------------------
class TestLoadConfig:
@pytest.mark.asyncio
async def test_load_with_db_rows(self):
"""DB rows are applied over defaults."""
pool = AsyncMock()
pool.fetch = AsyncMock(
return_value=[
{"key": "signal_engine_dual_pipeline_enabled", "value": "true"},
{"key": "signal_engine_shadow_mode", "value": "true"},
]
)
cfg = await load_config(pool)
assert cfg.dual_pipeline_enabled is True
assert cfg.shadow_mode is True
@pytest.mark.asyncio
async def test_load_with_empty_db(self):
"""Empty DB result returns safe defaults."""
pool = AsyncMock()
pool.fetch = AsyncMock(return_value=[])
cfg = await load_config(pool)
assert cfg.dual_pipeline_enabled is False
assert cfg.heuristic_pipeline_enabled is True
@pytest.mark.asyncio
async def test_load_db_failure_failsafe(self):
"""DB error falls back to disabled (fail-safe)."""
pool = AsyncMock()
pool.fetch = AsyncMock(side_effect=Exception("connection refused"))
cfg = await load_config(pool)
assert cfg.dual_pipeline_enabled is False
@pytest.mark.asyncio
async def test_env_overrides_db_values(self):
"""Environment variables take precedence over DB values."""
pool = AsyncMock()
pool.fetch = AsyncMock(
return_value=[
{"key": "signal_engine_prob_buy_p_up", "value": "0.55"},
]
)
with patch.dict(os.environ, {"SIGNAL_ENGINE_PROB_BUY_P_UP": "0.70"}):
cfg = await load_config(pool)
assert cfg.prob_buy_p_up == 0.70 # env wins
@pytest.mark.asyncio
async def test_env_overrides_applied_after_db_failure(self):
"""Env overrides still apply even when DB read fails."""
pool = AsyncMock()
pool.fetch = AsyncMock(side_effect=Exception("timeout"))
with patch.dict(
os.environ, {"SIGNAL_ENGINE_DUAL_PIPELINE_ENABLED": "true"}
):
cfg = await load_config(pool)
# Env override can re-enable even after DB failure
assert cfg.dual_pipeline_enabled is True
+388
View File
@@ -0,0 +1,388 @@
"""Unit tests for the multi-timeframe confluence engine.
Validates compute_confluence against requirements 3.13.6.
"""
from services.signal_engine.confluence import (
HIGHER_TIMEFRAME_ANCHORS,
MIN_TIMEFRAME_COUNT,
compute_confluence,
)
from services.signal_engine.models import (
SignalDirection,
SignalResult,
)
# Default timeframe weights from the design (Requirement 3.1)
DEFAULT_WEIGHTS: dict[str, float] = {
"M30": 0.03,
"H1": 0.07,
"H4": 0.15,
"D": 0.30,
"W": 0.30,
"M": 0.15,
}
def _make_signal(
signal_type: str = "fibonacci",
timeframe: str = "D",
strength: float = 0.8,
direction: SignalDirection = SignalDirection.BULLISH,
confidence: float = 0.9,
) -> SignalResult:
"""Build a minimal SignalResult with sensible defaults."""
return SignalResult(
signal_type=signal_type,
timeframe=timeframe,
strength=strength,
direction=direction,
confidence=confidence,
)
class TestMinimumConfluenceThreshold:
"""Requirement 3.3: signals triggering on < 2 timeframes are discarded."""
def test_single_timeframe_discarded(self):
signal_results = {
"fibonacci": {
"D": _make_signal(timeframe="D"),
}
}
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
assert result == []
def test_zero_timeframes_discarded(self):
signal_results = {"fibonacci": {}}
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
assert result == []
def test_two_timeframes_passes_minimum(self):
signal_results = {
"fibonacci": {
"D": _make_signal(timeframe="D"),
"W": _make_signal(timeframe="W"),
}
}
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
assert len(result) == 1
assert result[0].signal_type == "fibonacci"
class TestHigherTimeframeAnchor:
"""Requirement 3.4: signals without at least one of D, W, M are discarded."""
def test_only_intraday_timeframes_discarded(self):
"""M30 + H1 = 2 timeframes but no D/W/M anchor → discarded."""
signal_results = {
"rsi": {
"M30": _make_signal(timeframe="M30"),
"H1": _make_signal(timeframe="H1"),
}
}
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
assert result == []
def test_intraday_plus_h4_discarded(self):
"""M30 + H1 + H4 = 3 timeframes but no D/W/M → discarded."""
signal_results = {
"rsi": {
"M30": _make_signal(timeframe="M30"),
"H1": _make_signal(timeframe="H1"),
"H4": _make_signal(timeframe="H4"),
}
}
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
assert result == []
def test_with_daily_anchor_passes(self):
signal_results = {
"rsi": {
"H4": _make_signal(timeframe="H4"),
"D": _make_signal(timeframe="D"),
}
}
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
assert len(result) == 1
def test_with_weekly_anchor_passes(self):
signal_results = {
"rsi": {
"H1": _make_signal(timeframe="H1"),
"W": _make_signal(timeframe="W"),
}
}
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
assert len(result) == 1
def test_with_monthly_anchor_passes(self):
signal_results = {
"rsi": {
"H4": _make_signal(timeframe="H4"),
"M": _make_signal(timeframe="M"),
}
}
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
assert len(result) == 1
class TestConfluenceScoreComputation:
"""Requirement 3.2: C_confluence = Σ(w_tf · s_tf)."""
def test_two_timeframes_score(self):
"""D(0.30) * 0.8 + W(0.30) * 0.6 = 0.24 + 0.18 = 0.42."""
signal_results = {
"fibonacci": {
"D": _make_signal(timeframe="D", strength=0.8),
"W": _make_signal(timeframe="W", strength=0.6),
}
}
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
assert len(result) == 1
assert abs(result[0].confluence_score - 0.42) < 1e-9
def test_all_timeframes_score(self):
"""All six timeframes with strength 1.0 → sum of all weights."""
signal_results = {
"ma_stack": {
tf: _make_signal(timeframe=tf, strength=1.0)
for tf in DEFAULT_WEIGHTS
}
}
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
assert len(result) == 1
expected = sum(DEFAULT_WEIGHTS.values())
assert abs(result[0].confluence_score - expected) < 1e-9
def test_zero_strength_contributes_zero(self):
"""D(0.30) * 0.0 + W(0.30) * 1.0 = 0.0 + 0.30 = 0.30."""
signal_results = {
"rsi": {
"D": _make_signal(timeframe="D", strength=0.0),
"W": _make_signal(timeframe="W", strength=1.0),
}
}
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
assert len(result) == 1
assert abs(result[0].confluence_score - 0.30) < 1e-9
def test_unknown_timeframe_weight_defaults_to_zero(self):
"""A timeframe not in the weights dict contributes 0 to the score."""
signal_results = {
"fibonacci": {
"D": _make_signal(timeframe="D", strength=0.5),
"UNKNOWN": _make_signal(timeframe="UNKNOWN", strength=1.0),
}
}
# UNKNOWN is not in DEFAULT_WEIGHTS, so its weight is 0.0
# But we still need a D/W/M anchor and >= 2 timeframes
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
assert len(result) == 1
assert abs(result[0].confluence_score - 0.15) < 1e-9 # 0.30 * 0.5
class TestPerTimeframeStrengths:
"""Verify per_timeframe dict contains correct strength values."""
def test_per_timeframe_populated(self):
signal_results = {
"fibonacci": {
"D": _make_signal(timeframe="D", strength=0.7),
"W": _make_signal(timeframe="W", strength=0.9),
}
}
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
assert len(result) == 1
assert result[0].per_timeframe == {"D": 0.7, "W": 0.9}
def test_active_timeframes_match_per_timeframe_keys(self):
signal_results = {
"ma_stack": {
"H4": _make_signal(timeframe="H4", strength=0.5),
"D": _make_signal(timeframe="D", strength=0.6),
"W": _make_signal(timeframe="W", strength=0.8),
}
}
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
assert len(result) == 1
assert set(result[0].active_timeframes) == set(result[0].per_timeframe.keys())
class TestDominantDirection:
"""Verify direction is determined by majority vote across timeframes."""
def test_all_bullish(self):
signal_results = {
"fibonacci": {
"D": _make_signal(direction=SignalDirection.BULLISH),
"W": _make_signal(direction=SignalDirection.BULLISH),
}
}
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
assert result[0].direction == SignalDirection.BULLISH
def test_all_bearish(self):
signal_results = {
"fibonacci": {
"D": _make_signal(direction=SignalDirection.BEARISH),
"W": _make_signal(direction=SignalDirection.BEARISH),
}
}
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
assert result[0].direction == SignalDirection.BEARISH
def test_majority_bullish(self):
signal_results = {
"fibonacci": {
"D": _make_signal(direction=SignalDirection.BULLISH),
"W": _make_signal(direction=SignalDirection.BULLISH),
"M": _make_signal(direction=SignalDirection.BEARISH),
}
}
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
assert result[0].direction == SignalDirection.BULLISH
def test_tie_resolves_to_neutral(self):
signal_results = {
"fibonacci": {
"D": _make_signal(direction=SignalDirection.BULLISH),
"W": _make_signal(direction=SignalDirection.BEARISH),
}
}
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
assert result[0].direction == SignalDirection.NEUTRAL
def test_neutral_votes_do_not_count(self):
"""2 bullish + 1 neutral → bullish wins."""
signal_results = {
"fibonacci": {
"D": _make_signal(direction=SignalDirection.BULLISH),
"W": _make_signal(direction=SignalDirection.BULLISH),
"M": _make_signal(direction=SignalDirection.NEUTRAL),
}
}
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
assert result[0].direction == SignalDirection.BULLISH
class TestMultipleSignalTypes:
"""Verify that multiple signal types are processed independently."""
def test_two_signals_both_pass(self):
signal_results = {
"fibonacci": {
"D": _make_signal(signal_type="fibonacci", timeframe="D"),
"W": _make_signal(signal_type="fibonacci", timeframe="W"),
},
"rsi": {
"H4": _make_signal(signal_type="rsi", timeframe="H4"),
"D": _make_signal(signal_type="rsi", timeframe="D"),
},
}
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
assert len(result) == 2
types = {cs.signal_type for cs in result}
assert types == {"fibonacci", "rsi"}
def test_one_passes_one_discarded(self):
signal_results = {
"fibonacci": {
"D": _make_signal(signal_type="fibonacci", timeframe="D"),
"W": _make_signal(signal_type="fibonacci", timeframe="W"),
},
"rsi": {
# Only 1 timeframe → discarded
"D": _make_signal(signal_type="rsi", timeframe="D"),
},
}
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
assert len(result) == 1
assert result[0].signal_type == "fibonacci"
def test_one_passes_one_no_anchor(self):
signal_results = {
"fibonacci": {
"D": _make_signal(signal_type="fibonacci", timeframe="D"),
"W": _make_signal(signal_type="fibonacci", timeframe="W"),
},
"rsi": {
# 2 timeframes but no D/W/M → discarded
"M30": _make_signal(signal_type="rsi", timeframe="M30"),
"H1": _make_signal(signal_type="rsi", timeframe="H1"),
},
}
result = compute_confluence(signal_results, DEFAULT_WEIGHTS)
assert len(result) == 1
assert result[0].signal_type == "fibonacci"
class TestEmptyInputs:
"""Edge cases with empty inputs."""
def test_empty_signal_results(self):
result = compute_confluence({}, DEFAULT_WEIGHTS)
assert result == []
def test_empty_weights(self):
"""Signals pass filters but all weights are 0 → score is 0.0."""
signal_results = {
"fibonacci": {
"D": _make_signal(timeframe="D", strength=0.8),
"W": _make_signal(timeframe="W", strength=0.6),
}
}
result = compute_confluence(signal_results, {})
assert len(result) == 1
assert result[0].confluence_score == 0.0
class TestConfluenceScoreMonotonicity:
"""Requirement 3.6: more timeframes with higher weights → higher score."""
def test_adding_timeframe_increases_score(self):
"""Adding a third timeframe with non-zero strength increases the score."""
two_tf = {
"fibonacci": {
"D": _make_signal(timeframe="D", strength=0.8),
"W": _make_signal(timeframe="W", strength=0.6),
}
}
three_tf = {
"fibonacci": {
"D": _make_signal(timeframe="D", strength=0.8),
"W": _make_signal(timeframe="W", strength=0.6),
"H4": _make_signal(timeframe="H4", strength=0.5),
}
}
result_2 = compute_confluence(two_tf, DEFAULT_WEIGHTS)
result_3 = compute_confluence(three_tf, DEFAULT_WEIGHTS)
assert result_3[0].confluence_score > result_2[0].confluence_score
def test_higher_weight_timeframe_contributes_more(self):
"""D (weight 0.30) contributes more than M30 (weight 0.03) at same strength."""
with_d = {
"fibonacci": {
"D": _make_signal(timeframe="D", strength=0.5),
"W": _make_signal(timeframe="W", strength=0.5),
}
}
with_m30 = {
"fibonacci": {
"M30": _make_signal(timeframe="M30", strength=0.5),
"W": _make_signal(timeframe="W", strength=0.5),
}
}
result_d = compute_confluence(with_d, DEFAULT_WEIGHTS)
result_m30 = compute_confluence(with_m30, DEFAULT_WEIGHTS)
assert result_d[0].confluence_score > result_m30[0].confluence_score
class TestConstants:
"""Verify module-level constants match the design."""
def test_higher_timeframe_anchors(self):
assert HIGHER_TIMEFRAME_ANCHORS == frozenset({"D", "W", "M"})
def test_min_timeframe_count(self):
assert MIN_TIMEFRAME_COUNT == 2
+312
View File
@@ -0,0 +1,312 @@
"""Unit tests for services.signal_engine.correlation — Signal cluster classification and penalty.
Tests classify_signal mapping, apply_correlation_penalty decay logic,
cross-cluster independence, single-signal clusters, and edge cases.
Requirements: 7.1, 7.2, 7.3, 7.4
"""
from __future__ import annotations
import math
from services.signal_engine.correlation import (
SignalCluster,
apply_correlation_penalty,
classify_signal,
)
from services.signal_engine.models import LikelihoodRatio
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _lr(
signal_type: str,
cluster: str,
log_lr: float,
*,
hit_rate: float = 0.6,
strength: float = 0.7,
) -> LikelihoodRatio:
"""Create a LikelihoodRatio with sensible defaults."""
return LikelihoodRatio(
signal_type=signal_type,
cluster=cluster,
lr=math.exp(log_lr),
log_lr=log_lr,
penalized_log_lr=log_lr, # pre-penalty: same as log_lr
hit_rate=hit_rate,
strength=strength,
)
# ===========================================================================
# 1. classify_signal — known signal types (Requirement 7.1)
# ===========================================================================
class TestClassifySignal:
"""Verify signal type → cluster mapping."""
def test_ma_stack_is_momentum(self) -> None:
assert classify_signal("ma_stack") == SignalCluster.MOMENTUM
def test_rsi_is_momentum(self) -> None:
assert classify_signal("rsi") == SignalCluster.MOMENTUM
def test_fibonacci_is_structure(self) -> None:
assert classify_signal("fibonacci") == SignalCluster.STRUCTURE
def test_elliott_wave_is_structure(self) -> None:
assert classify_signal("elliott_wave") == SignalCluster.STRUCTURE
def test_cup_handle_is_structure(self) -> None:
assert classify_signal("cup_handle") == SignalCluster.STRUCTURE
def test_atr_is_volatility(self) -> None:
assert classify_signal("atr") == SignalCluster.VOLATILITY
def test_bollinger_is_volatility(self) -> None:
assert classify_signal("bollinger") == SignalCluster.VOLATILITY
def test_valuation_is_fundamentals(self) -> None:
assert classify_signal("valuation") == SignalCluster.FUNDAMENTALS
def test_earnings_is_fundamentals(self) -> None:
assert classify_signal("earnings") == SignalCluster.FUNDAMENTALS
def test_macro_is_fundamentals(self) -> None:
assert classify_signal("macro") == SignalCluster.FUNDAMENTALS
def test_unknown_signal_defaults_to_fundamentals(self) -> None:
"""Unknown signal types fall back to FUNDAMENTALS."""
assert classify_signal("unknown_xyz") == SignalCluster.FUNDAMENTALS
# ===========================================================================
# 2. apply_correlation_penalty — within-cluster decay (Requirement 7.2)
# ===========================================================================
class TestWithinClusterDecay:
"""Within a cluster, strongest LR at full weight, subsequent at 0.5^(n-1)."""
def test_two_momentum_signals_decay(self) -> None:
"""Second signal in same cluster gets 0.5 decay."""
lrs = [
_lr("ma_stack", "momentum", log_lr=0.8),
_lr("rsi", "momentum", log_lr=0.5),
]
result = apply_correlation_penalty(lrs)
# ma_stack is strongest (0.8 > 0.5) → full weight
ma = next(r for r in result if r.signal_type == "ma_stack")
rsi = next(r for r in result if r.signal_type == "rsi")
assert ma.penalized_log_lr == 0.8 # rank 0: 0.5^0 = 1.0
assert abs(rsi.penalized_log_lr - 0.5 * 0.5) < 1e-10 # rank 1: 0.5^1 = 0.5
def test_three_structure_signals_decay(self) -> None:
"""Three signals in same cluster: 1.0, 0.5, 0.25 decay."""
lrs = [
_lr("fibonacci", "structure", log_lr=1.0),
_lr("elliott_wave", "structure", log_lr=0.7),
_lr("cup_handle", "structure", log_lr=0.3),
]
result = apply_correlation_penalty(lrs)
fib = next(r for r in result if r.signal_type == "fibonacci")
ew = next(r for r in result if r.signal_type == "elliott_wave")
ch = next(r for r in result if r.signal_type == "cup_handle")
assert fib.penalized_log_lr == 1.0 # rank 0: 1.0
assert abs(ew.penalized_log_lr - 0.7 * 0.5) < 1e-10 # rank 1: 0.5
assert abs(ch.penalized_log_lr - 0.3 * 0.25) < 1e-10 # rank 2: 0.25
def test_ranking_by_absolute_log_lr(self) -> None:
"""Ranking uses abs(log_lr), so a negative LR with large magnitude ranks first."""
lrs = [
_lr("ma_stack", "momentum", log_lr=0.3),
_lr("rsi", "momentum", log_lr=-0.9), # abs = 0.9, strongest
]
result = apply_correlation_penalty(lrs)
rsi = next(r for r in result if r.signal_type == "rsi")
ma = next(r for r in result if r.signal_type == "ma_stack")
# RSI is strongest by abs → full weight
assert rsi.penalized_log_lr == -0.9
# MA is second → 0.5 decay
assert abs(ma.penalized_log_lr - 0.3 * 0.5) < 1e-10
def test_decay_reduces_penalized_log_lr_magnitude(self) -> None:
"""Penalized log_lr magnitude is always <= original for non-strongest."""
lrs = [
_lr("ma_stack", "momentum", log_lr=0.8),
_lr("rsi", "momentum", log_lr=0.6),
]
result = apply_correlation_penalty(lrs)
rsi = next(r for r in result if r.signal_type == "rsi")
assert abs(rsi.penalized_log_lr) < abs(rsi.log_lr)
# ===========================================================================
# 3. apply_correlation_penalty — cross-cluster independence (Requirement 7.3)
# ===========================================================================
class TestCrossClusterIndependence:
"""Signals from different clusters receive no penalty."""
def test_different_clusters_no_penalty(self) -> None:
"""Each signal in its own cluster → all at full weight."""
lrs = [
_lr("ma_stack", "momentum", log_lr=0.8),
_lr("fibonacci", "structure", log_lr=0.7),
_lr("atr", "volatility", log_lr=0.5),
_lr("valuation", "fundamentals", log_lr=0.3),
]
result = apply_correlation_penalty(lrs)
for r in result:
assert r.penalized_log_lr == r.log_lr, (
f"{r.signal_type}: penalized_log_lr should equal log_lr "
f"when alone in cluster"
)
def test_mixed_clusters_only_same_cluster_penalized(self) -> None:
"""Two momentum + one structure: only momentum signals get decay."""
lrs = [
_lr("ma_stack", "momentum", log_lr=0.8),
_lr("rsi", "momentum", log_lr=0.5),
_lr("fibonacci", "structure", log_lr=0.6),
]
result = apply_correlation_penalty(lrs)
ma = next(r for r in result if r.signal_type == "ma_stack")
rsi = next(r for r in result if r.signal_type == "rsi")
fib = next(r for r in result if r.signal_type == "fibonacci")
# Momentum cluster: ma_stack full, rsi decayed
assert ma.penalized_log_lr == 0.8
assert abs(rsi.penalized_log_lr - 0.5 * 0.5) < 1e-10
# Structure cluster: fibonacci alone → no penalty
assert fib.penalized_log_lr == 0.6
# ===========================================================================
# 4. apply_correlation_penalty — single-signal clusters (Requirement 7.4)
# ===========================================================================
class TestSingleSignalCluster:
"""Single-signal clusters receive no penalty."""
def test_single_signal_no_penalty(self) -> None:
"""One signal in a cluster → penalized_log_lr == log_lr."""
lrs = [_lr("fibonacci", "structure", log_lr=0.9)]
result = apply_correlation_penalty(lrs)
assert len(result) == 1
assert result[0].penalized_log_lr == 0.9
def test_multiple_single_signal_clusters(self) -> None:
"""Multiple clusters each with one signal → no penalties anywhere."""
lrs = [
_lr("rsi", "momentum", log_lr=0.4),
_lr("fibonacci", "structure", log_lr=0.6),
]
result = apply_correlation_penalty(lrs)
for r in result:
assert r.penalized_log_lr == r.log_lr
# ===========================================================================
# 5. Edge cases
# ===========================================================================
class TestEdgeCases:
"""Edge cases: empty input, zero log_lr, original order preserved."""
def test_empty_input_returns_empty(self) -> None:
"""Empty list → empty list."""
assert apply_correlation_penalty([]) == []
def test_zero_log_lr_no_effect(self) -> None:
"""log_lr = 0 → penalized_log_lr = 0 regardless of rank."""
lrs = [
_lr("ma_stack", "momentum", log_lr=0.5),
_lr("rsi", "momentum", log_lr=0.0),
]
result = apply_correlation_penalty(lrs)
rsi = next(r for r in result if r.signal_type == "rsi")
assert rsi.penalized_log_lr == 0.0
def test_original_order_preserved(self) -> None:
"""Output list preserves the original input order."""
lrs = [
_lr("rsi", "momentum", log_lr=0.3),
_lr("fibonacci", "structure", log_lr=0.9),
_lr("ma_stack", "momentum", log_lr=0.8),
]
result = apply_correlation_penalty(lrs)
assert result[0].signal_type == "rsi"
assert result[1].signal_type == "fibonacci"
assert result[2].signal_type == "ma_stack"
def test_original_objects_not_mutated(self) -> None:
"""Input LikelihoodRatio objects are not modified in place."""
original = _lr("ma_stack", "momentum", log_lr=0.8)
lrs = [
original,
_lr("rsi", "momentum", log_lr=0.5),
]
apply_correlation_penalty(lrs)
# Original object should still have its initial penalized_log_lr
assert original.penalized_log_lr == 0.8
def test_negative_log_lr_decay(self) -> None:
"""Negative log_lr values are decayed correctly (toward zero)."""
lrs = [
_lr("ma_stack", "momentum", log_lr=-0.8),
_lr("rsi", "momentum", log_lr=-0.4),
]
result = apply_correlation_penalty(lrs)
ma = next(r for r in result if r.signal_type == "ma_stack")
rsi = next(r for r in result if r.signal_type == "rsi")
# ma_stack strongest by abs → full weight
assert ma.penalized_log_lr == -0.8
# rsi second → 0.5 decay
assert abs(rsi.penalized_log_lr - (-0.4 * 0.5)) < 1e-10
def test_lr_field_unchanged_by_penalty(self) -> None:
"""The raw lr field is preserved unchanged through penalty."""
lr_val = math.exp(0.5)
lrs = [
_lr("ma_stack", "momentum", log_lr=0.8),
LikelihoodRatio(
signal_type="rsi",
cluster="momentum",
lr=lr_val,
log_lr=0.5,
penalized_log_lr=0.5,
hit_rate=0.6,
strength=0.7,
),
]
result = apply_correlation_penalty(lrs)
rsi = next(r for r in result if r.signal_type == "rsi")
assert rsi.lr == lr_val
assert rsi.hit_rate == 0.6
assert rsi.strength == 0.7
+425
View File
@@ -0,0 +1,425 @@
"""Unit tests for services.signal_engine.signals.cup_handle — Cup & Handle evaluator.
Requirements: 2.4, 2.6, 2.7
"""
from __future__ import annotations
from datetime import datetime, timezone
from services.signal_engine.models import OHLCVBar, SignalDirection
from services.signal_engine.signals.cup_handle import (
DEFAULT_MIN_BARS,
CupHandleEvaluator,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _bar(
close: float,
high: float | None = None,
low: float | None = None,
) -> OHLCVBar:
"""Create a minimal OHLCVBar for testing."""
h = high if high is not None else close
lo = low if low is not None else close
return OHLCVBar(
timestamp=datetime(2024, 1, 1, tzinfo=timezone.utc),
open=close,
high=h,
low=lo,
close=close,
volume=1000.0,
)
def _make_cup_handle_bars(
n: int = 40,
left_rim: float = 100.0,
bottom: float = 80.0,
right_rim: float = 99.0,
handle_low: float = 96.0,
) -> list[OHLCVBar]:
"""Create a synthetic cup & handle pattern.
Generates bars that form:
1. Rise to left_rim in the first third
2. Descent to bottom in the middle
3. Rise to right_rim in the last third
4. Small pullback to handle_low at the end
"""
bars: list[OHLCVBar] = []
first_third = n // 3
last_third_start = n - (n // 3)
handle_start = n - max(2, int(n * 0.15))
for i in range(n):
if i < first_third:
# Rise to left rim
frac = i / max(1, first_third - 1)
price = bottom + frac * (left_rim - bottom)
h = price + 1.0
lo = price - 1.0
elif i < last_third_start:
# Cup: descend to bottom then rise
mid = (first_third + last_third_start) / 2.0
if i <= mid:
frac = (i - first_third) / max(1, mid - first_third)
price = left_rim - frac * (left_rim - bottom)
else:
frac = (i - mid) / max(1, last_third_start - mid)
price = bottom + frac * (right_rim - bottom)
h = price + 1.0
lo = price - 1.0
elif i < handle_start:
# Rise to right rim
frac = (i - last_third_start) / max(1, handle_start - last_third_start - 1)
price = right_rim - 2.0 + frac * 2.0
h = price + 1.0
lo = price - 1.0
else:
# Handle: small pullback
handle_len = n - handle_start
frac = (i - handle_start) / max(1, handle_len - 1)
price = right_rim - frac * (right_rim - handle_low)
h = price + 0.5
lo = price - 0.5
bars.append(_bar(price, high=h, low=lo))
# Ensure the left rim bar has the correct high
bars[first_third - 1] = _bar(
left_rim - 1.0,
high=left_rim,
low=left_rim - 2.0,
)
# Ensure the right rim bar has the correct high
right_rim_idx = last_third_start + (handle_start - last_third_start) // 2
if right_rim_idx < n:
bars[right_rim_idx] = _bar(
right_rim - 1.0,
high=right_rim,
low=right_rim - 2.0,
)
return bars
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
def test_default_min_bars() -> None:
assert DEFAULT_MIN_BARS == 30
# ---------------------------------------------------------------------------
# Insufficient data → None (Requirement 2.6)
# ---------------------------------------------------------------------------
def test_returns_none_when_insufficient_bars() -> None:
"""Requirement 2.6: return None when fewer than min_bars."""
evaluator = CupHandleEvaluator()
bars = [_bar(100.0) for _ in range(29)]
assert evaluator.evaluate(bars, "D") is None
def test_returns_none_with_empty_bars() -> None:
evaluator = CupHandleEvaluator()
assert evaluator.evaluate([], "D") is None
def test_returns_none_with_one_bar() -> None:
evaluator = CupHandleEvaluator()
assert evaluator.evaluate([_bar(100.0)], "D") is None
# ---------------------------------------------------------------------------
# No pattern detected → None
# ---------------------------------------------------------------------------
def test_returns_none_for_flat_market() -> None:
"""Flat prices have no cup formation."""
evaluator = CupHandleEvaluator()
bars = [_bar(100.0, high=100.0, low=100.0) for _ in range(40)]
assert evaluator.evaluate(bars, "D") is None
def test_returns_none_for_monotonic_uptrend() -> None:
"""A steady uptrend has no cup shape."""
evaluator = CupHandleEvaluator()
bars = [_bar(50.0 + i * 1.0, high=51.0 + i * 1.0, low=49.0 + i * 1.0) for i in range(40)]
# Cup depth would be too shallow or non-existent
result = evaluator.evaluate(bars, "D")
# Either None or invalid pattern — the uptrend doesn't form a cup
assert result is None
def test_returns_none_when_cup_too_shallow() -> None:
"""Cup depth < 12% should be rejected."""
evaluator = CupHandleEvaluator()
# Left rim at 100, bottom at 92 → depth = 8% (too shallow)
bars = _make_cup_handle_bars(
n=40,
left_rim=100.0,
bottom=92.0,
right_rim=99.0,
handle_low=97.0,
)
result = evaluator.evaluate(bars, "D")
assert result is None
def test_returns_none_when_cup_too_deep() -> None:
"""Cup depth > 33% should be rejected."""
evaluator = CupHandleEvaluator()
# Left rim at 100, bottom at 60 → depth = 40% (too deep)
bars = _make_cup_handle_bars(
n=40,
left_rim=100.0,
bottom=60.0,
right_rim=99.0,
handle_low=95.0,
)
result = evaluator.evaluate(bars, "D")
assert result is None
def test_returns_none_when_handle_too_deep() -> None:
"""Handle retracement > 50% of cup depth should be rejected."""
evaluator = CupHandleEvaluator()
# Cup depth = 100 - 80 = 20. Handle depth > 10 (50% of 20) → rejected
bars = _make_cup_handle_bars(
n=40,
left_rim=100.0,
bottom=80.0,
right_rim=99.0,
handle_low=85.0, # handle depth = 99 - 85 = 14 > 10
)
result = evaluator.evaluate(bars, "D")
assert result is None
# ---------------------------------------------------------------------------
# Valid pattern detection
# ---------------------------------------------------------------------------
def test_detects_valid_cup_and_handle() -> None:
"""Requirement 2.4: detect cup formation and handle."""
evaluator = CupHandleEvaluator()
bars = _make_cup_handle_bars(
n=40,
left_rim=100.0,
bottom=80.0,
right_rim=99.0,
handle_low=95.0,
)
result = evaluator.evaluate(bars, "D")
assert result is not None
assert result.signal_type == "cup_handle"
assert result.direction == SignalDirection.BULLISH
def test_always_bullish_direction() -> None:
"""Cup & Handle is always a bullish continuation pattern."""
evaluator = CupHandleEvaluator()
bars = _make_cup_handle_bars(
n=40,
left_rim=100.0,
bottom=80.0,
right_rim=98.0,
handle_low=95.0,
)
result = evaluator.evaluate(bars, "D")
assert result is not None
assert result.direction == SignalDirection.BULLISH
# ---------------------------------------------------------------------------
# Completeness scoring
# ---------------------------------------------------------------------------
def test_strength_in_unit_interval() -> None:
"""Strength must be in [0, 1]."""
evaluator = CupHandleEvaluator()
bars = _make_cup_handle_bars(
n=40,
left_rim=100.0,
bottom=80.0,
right_rim=99.0,
handle_low=96.0,
)
result = evaluator.evaluate(bars, "D")
assert result is not None
assert 0.0 <= result.strength <= 1.0
def test_confidence_in_unit_interval() -> None:
"""Confidence must be in [0, 1]."""
evaluator = CupHandleEvaluator()
bars = _make_cup_handle_bars(
n=40,
left_rim=100.0,
bottom=80.0,
right_rim=99.0,
handle_low=96.0,
)
result = evaluator.evaluate(bars, "D")
assert result is not None
assert 0.0 <= result.confidence <= 1.0
def test_confidence_proportional_to_completeness() -> None:
"""Requirement 2.4: confidence proportional to pattern completeness."""
evaluator = CupHandleEvaluator()
bars = _make_cup_handle_bars(
n=40,
left_rim=100.0,
bottom=80.0,
right_rim=99.0,
handle_low=96.0,
)
result = evaluator.evaluate(bars, "D")
assert result is not None
# confidence = completeness * 0.90
expected_confidence = result.strength * 0.90
assert abs(result.confidence - expected_confidence) < 1e-9
def test_better_symmetry_yields_higher_completeness() -> None:
"""More symmetric rims should produce higher completeness."""
evaluator = CupHandleEvaluator()
# Good symmetry: right rim very close to left rim
bars_good = _make_cup_handle_bars(
n=40,
left_rim=100.0,
bottom=80.0,
right_rim=100.0,
handle_low=96.0,
)
result_good = evaluator.evaluate(bars_good, "D")
# Worse symmetry: right rim further from left rim
bars_worse = _make_cup_handle_bars(
n=40,
left_rim=100.0,
bottom=80.0,
right_rim=88.0,
handle_low=85.0,
)
result_worse = evaluator.evaluate(bars_worse, "D")
if result_good is not None and result_worse is not None:
assert result_good.metadata["symmetry_score"] >= result_worse.metadata["symmetry_score"]
# ---------------------------------------------------------------------------
# Metadata (Requirement 2.7)
# ---------------------------------------------------------------------------
def test_metadata_contains_required_fields() -> None:
"""Metadata should include left_rim, right_rim, bottom, handle_depth, completeness."""
evaluator = CupHandleEvaluator()
bars = _make_cup_handle_bars(
n=40,
left_rim=100.0,
bottom=80.0,
right_rim=99.0,
handle_low=96.0,
)
result = evaluator.evaluate(bars, "D")
assert result is not None
meta = result.metadata
assert "left_rim" in meta
assert "right_rim" in meta
assert "bottom" in meta
assert "handle_depth" in meta
assert "completeness" in meta
assert "cup_depth_pct" in meta
assert "symmetry_score" in meta
assert "handle_score" in meta
# ---------------------------------------------------------------------------
# Signal result structure (Requirement 2.7)
# ---------------------------------------------------------------------------
def test_signal_result_structure() -> None:
"""Requirement 2.7: SignalResult has all required fields."""
evaluator = CupHandleEvaluator()
bars = _make_cup_handle_bars(
n=40,
left_rim=100.0,
bottom=80.0,
right_rim=99.0,
handle_low=96.0,
)
result = evaluator.evaluate(bars, "D")
assert result is not None
assert result.signal_type == "cup_handle"
assert result.timeframe == "D"
assert 0.0 <= result.strength <= 1.0
assert 0.0 <= result.confidence <= 1.0
assert result.direction == SignalDirection.BULLISH
# ---------------------------------------------------------------------------
# Timeframe passthrough
# ---------------------------------------------------------------------------
def test_timeframe_passthrough() -> None:
"""The timeframe label is passed through to the result."""
evaluator = CupHandleEvaluator()
bars = _make_cup_handle_bars(
n=40,
left_rim=100.0,
bottom=80.0,
right_rim=99.0,
handle_low=96.0,
)
for tf in ("M30", "H1", "H4", "D", "W", "M"):
result = evaluator.evaluate(bars, tf)
assert result is not None
assert result.timeframe == tf
# ---------------------------------------------------------------------------
# Custom min_bars
# ---------------------------------------------------------------------------
def test_custom_min_bars() -> None:
"""CupHandleEvaluator with a custom min_bars should use that value."""
evaluator = CupHandleEvaluator(min_bars=50)
assert evaluator.min_bars == 50
# 40 bars should be insufficient
bars = _make_cup_handle_bars(n=40)
assert evaluator.evaluate(bars, "D") is None
def test_exactly_min_bars_works() -> None:
"""Exactly min_bars should be sufficient if pattern is present."""
evaluator = CupHandleEvaluator(min_bars=30)
bars = _make_cup_handle_bars(
n=30,
left_rim=100.0,
bottom=80.0,
right_rim=99.0,
handle_low=96.0,
)
result = evaluator.evaluate(bars, "D")
# Should produce a result if the pattern is valid
# (may be None if the synthetic data doesn't form a clean pattern at 30 bars)
# At minimum, it should not crash
assert result is None or result.signal_type == "cup_handle"
+304
View File
@@ -0,0 +1,304 @@
"""Unit tests for services.signal_engine.signals.elliott_wave — Elliott Wave evaluator.
Requirements: 2.5, 2.6, 2.7
"""
from __future__ import annotations
from datetime import datetime, timezone
from services.signal_engine.models import OHLCVBar, SignalDirection
from services.signal_engine.signals.elliott_wave import (
DEFAULT_MIN_BARS,
WAVE_TYPE_CORRECTIVE,
WAVE_TYPE_IMPULSE,
ElliottWaveEvaluator,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _bar(
close: float,
high: float | None = None,
low: float | None = None,
) -> OHLCVBar:
"""Create a minimal OHLCVBar for testing."""
h = high if high is not None else close
lo = low if low is not None else close
return OHLCVBar(
timestamp=datetime(2024, 1, 1, tzinfo=timezone.utc),
open=close,
high=h,
low=lo,
close=close,
volume=1000.0,
)
def _make_impulse_up_bars(n: int = 50) -> list[OHLCVBar]:
"""Create synthetic bars forming a bullish 5-wave impulse pattern.
Wave structure (bullish impulse):
Wave 1: 100 → 120 (up)
Wave 2: 120 → 108 (down, retracement)
Wave 3: 108 → 140 (up, largest wave)
Wave 4: 140 → 130 (down, retracement)
Wave 5: 130 → 150 (up, new high)
"""
# Define price waypoints for each wave
waypoints = [
(0.00, 100.0), # start
(0.20, 120.0), # wave 1 peak
(0.35, 108.0), # wave 2 trough
(0.60, 140.0), # wave 3 peak
(0.75, 130.0), # wave 4 trough
(1.00, 150.0), # wave 5 peak
]
return _interpolate_bars(waypoints, n)
def _make_impulse_down_bars(n: int = 50) -> list[OHLCVBar]:
"""Create synthetic bars forming a bearish 5-wave impulse pattern.
Wave structure (bearish impulse):
Wave 1: 150 → 130 (down)
Wave 2: 130 → 142 (up, retracement)
Wave 3: 142 → 110 (down, largest wave)
Wave 4: 110 → 120 (up, retracement)
Wave 5: 120 → 100 (down, new low)
"""
waypoints = [
(0.00, 150.0),
(0.20, 130.0),
(0.35, 142.0),
(0.60, 110.0),
(0.75, 120.0),
(1.00, 100.0),
]
return _interpolate_bars(waypoints, n)
def _make_corrective_bars(n: int = 50) -> list[OHLCVBar]:
"""Create synthetic bars forming a corrective A-B-C pattern after an uptrend.
First half: uptrend (impulse context)
Second half: A-B-C correction
Wave A: 150 → 130 (down)
Wave B: 130 → 140 (up, partial retracement)
Wave C: 140 → 120 (down, new low)
"""
waypoints = [
(0.00, 100.0), # start of uptrend
(0.40, 150.0), # end of uptrend / start of correction
(0.60, 130.0), # wave A trough
(0.75, 140.0), # wave B peak
(1.00, 120.0), # wave C trough
]
return _interpolate_bars(waypoints, n)
def _interpolate_bars(
waypoints: list[tuple[float, float]],
n: int,
) -> list[OHLCVBar]:
"""Interpolate price waypoints into n OHLCV bars with realistic high/low."""
bars: list[OHLCVBar] = []
for i in range(n):
frac = i / max(1, n - 1)
# Find the two surrounding waypoints
price = waypoints[-1][1] # default to last
for j in range(len(waypoints) - 1):
t0, p0 = waypoints[j]
t1, p1 = waypoints[j + 1]
if t0 <= frac <= t1:
seg_frac = (frac - t0) / (t1 - t0) if t1 > t0 else 0.0
price = p0 + seg_frac * (p1 - p0)
break
# Add some spread for high/low
spread = max(1.0, abs(price) * 0.01)
bars.append(_bar(price, high=price + spread, low=price - spread))
return bars
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
def test_default_min_bars() -> None:
assert DEFAULT_MIN_BARS == 30
# ---------------------------------------------------------------------------
# Insufficient data → None (Requirement 2.6)
# ---------------------------------------------------------------------------
def test_returns_none_when_insufficient_bars() -> None:
"""Requirement 2.6: return None when fewer than min_bars."""
evaluator = ElliottWaveEvaluator()
bars = [_bar(100.0) for _ in range(29)]
assert evaluator.evaluate(bars, "D") is None
def test_returns_none_with_empty_bars() -> None:
evaluator = ElliottWaveEvaluator()
assert evaluator.evaluate([], "D") is None
def test_returns_none_with_one_bar() -> None:
evaluator = ElliottWaveEvaluator()
assert evaluator.evaluate([_bar(100.0)], "D") is None
# ---------------------------------------------------------------------------
# Flat market → None
# ---------------------------------------------------------------------------
def test_returns_none_for_flat_market() -> None:
"""Flat prices have no wave structure."""
evaluator = ElliottWaveEvaluator()
bars = [_bar(100.0, high=100.0, low=100.0) for _ in range(40)]
assert evaluator.evaluate(bars, "D") is None
# ---------------------------------------------------------------------------
# Impulse wave detection (Requirement 2.5)
# ---------------------------------------------------------------------------
def test_detects_bullish_impulse_wave() -> None:
"""Requirement 2.5: detect impulse waves (5-wave structure)."""
evaluator = ElliottWaveEvaluator()
bars = _make_impulse_up_bars(n=50)
result = evaluator.evaluate(bars, "D")
assert result is not None
assert result.signal_type == "elliott_wave"
assert result.direction == SignalDirection.BULLISH
assert result.metadata["wave_type"] == WAVE_TYPE_IMPULSE
def test_detects_bearish_impulse_wave() -> None:
"""Requirement 2.5: detect bearish impulse waves."""
evaluator = ElliottWaveEvaluator()
bars = _make_impulse_down_bars(n=50)
result = evaluator.evaluate(bars, "D")
assert result is not None
assert result.signal_type == "elliott_wave"
assert result.direction == SignalDirection.BEARISH
assert result.metadata["wave_type"] == WAVE_TYPE_IMPULSE
# ---------------------------------------------------------------------------
# Corrective wave detection (Requirement 2.5)
# ---------------------------------------------------------------------------
def test_detects_corrective_wave() -> None:
"""Requirement 2.5: detect corrective waves (3-wave structure)."""
evaluator = ElliottWaveEvaluator()
bars = _make_corrective_bars(n=50)
result = evaluator.evaluate(bars, "D")
assert result is not None
assert result.signal_type == "elliott_wave"
assert result.metadata["wave_type"] in (WAVE_TYPE_CORRECTIVE, WAVE_TYPE_IMPULSE)
# ---------------------------------------------------------------------------
# Signal structure validation (Requirement 2.7)
# ---------------------------------------------------------------------------
def test_signal_result_structure() -> None:
"""Requirement 2.7: SignalResult has all required fields."""
evaluator = ElliottWaveEvaluator()
bars = _make_impulse_up_bars(n=50)
result = evaluator.evaluate(bars, "D")
assert result is not None
assert result.signal_type == "elliott_wave"
assert result.timeframe == "D"
assert 0.0 <= result.strength <= 1.0
assert 0.0 <= result.confidence <= 1.0
assert result.direction in (
SignalDirection.BULLISH,
SignalDirection.BEARISH,
SignalDirection.NEUTRAL,
)
def test_strength_in_unit_interval() -> None:
"""Strength must be in [0, 1]."""
evaluator = ElliottWaveEvaluator()
bars = _make_impulse_up_bars(n=50)
result = evaluator.evaluate(bars, "D")
assert result is not None
assert 0.0 <= result.strength <= 1.0
def test_confidence_in_unit_interval() -> None:
"""Confidence must be in [0, 1]."""
evaluator = ElliottWaveEvaluator()
bars = _make_impulse_up_bars(n=50)
result = evaluator.evaluate(bars, "D")
assert result is not None
assert 0.0 <= result.confidence <= 1.0
# ---------------------------------------------------------------------------
# Metadata (Requirement 2.7)
# ---------------------------------------------------------------------------
def test_metadata_contains_required_fields() -> None:
"""Metadata should include wave_count, wave_type, current_wave_position, pivots."""
evaluator = ElliottWaveEvaluator()
bars = _make_impulse_up_bars(n=50)
result = evaluator.evaluate(bars, "D")
assert result is not None
meta = result.metadata
assert "wave_count" in meta
assert "wave_type" in meta
assert "current_wave_position" in meta
assert "pivots" in meta
assert isinstance(meta["pivots"], list)
assert len(meta["pivots"]) > 0
# ---------------------------------------------------------------------------
# Timeframe passthrough
# ---------------------------------------------------------------------------
def test_timeframe_passthrough() -> None:
"""The timeframe label is passed through to the result."""
evaluator = ElliottWaveEvaluator()
bars = _make_impulse_up_bars(n=50)
for tf in ("M30", "H1", "H4", "D", "W", "M"):
result = evaluator.evaluate(bars, tf)
assert result is not None
assert result.timeframe == tf
# ---------------------------------------------------------------------------
# Custom min_bars
# ---------------------------------------------------------------------------
def test_custom_min_bars() -> None:
"""ElliottWaveEvaluator with a custom min_bars should use that value."""
evaluator = ElliottWaveEvaluator(min_bars=60)
assert evaluator.min_bars == 60
# 50 bars should be insufficient
bars = _make_impulse_up_bars(n=50)
assert evaluator.evaluate(bars, "D") is None
def test_custom_zigzag_pct() -> None:
"""Custom zigzag_pct should be stored and used."""
evaluator = ElliottWaveEvaluator(zigzag_pct=0.10)
assert evaluator.zigzag_pct == 0.10
+497
View File
@@ -0,0 +1,497 @@
"""Unit tests for services.signal_engine.exit_engine — Exit Engine.
Tests stop-loss triggers, target-1 partial exits, target-2 full exits,
trailing stop activation/ratchet behavior, priority ordering, empty
positions, and fallback to position.current_price when ticker is absent
from current_prices.
Requirements: 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7
"""
from __future__ import annotations
from services.signal_engine.config import ExitConfig
from services.signal_engine.exit_engine import evaluate_exits
from services.signal_engine.models import (
ExitType,
OpenPositionState,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _default_config() -> ExitConfig:
return ExitConfig(trailing_stop_atr_multiplier=2.0)
def _position(
*,
position_id: str = "pos-1",
ticker: str = "AAPL",
entry_price: float = 100.0,
current_price: float = 110.0,
stop_loss: float = 90.0,
target_1: float = 115.0,
target_2: float = 130.0,
trailing_stop: float | None = None,
partial_exit_done: bool = False,
atr: float | None = 5.0,
) -> OpenPositionState:
return OpenPositionState(
position_id=position_id,
ticker=ticker,
entry_price=entry_price,
current_price=current_price,
stop_loss=stop_loss,
target_1=target_1,
target_2=target_2,
trailing_stop=trailing_stop,
partial_exit_done=partial_exit_done,
atr=atr,
)
# ===========================================================================
# 1. Stop-loss trigger (Requirement 8.1)
# ===========================================================================
class TestStopLoss:
"""Stop-loss hit → EXIT_FULL with reason 'stop_hit'."""
def test_stop_loss_exact_hit(self) -> None:
"""Price exactly at stop_loss triggers exit."""
pos = _position(stop_loss=90.0)
signals = evaluate_exits([pos], {"AAPL": 90.0}, _default_config())
assert len(signals) == 1
assert signals[0].exit_type == ExitType.EXIT_FULL
assert signals[0].reason == "stop_hit"
assert signals[0].price == 90.0
assert signals[0].position_id == "pos-1"
assert signals[0].ticker == "AAPL"
def test_stop_loss_below(self) -> None:
"""Price below stop_loss triggers exit."""
pos = _position(stop_loss=90.0)
signals = evaluate_exits([pos], {"AAPL": 85.0}, _default_config())
assert len(signals) == 1
assert signals[0].exit_type == ExitType.EXIT_FULL
assert signals[0].reason == "stop_hit"
assert signals[0].price == 85.0
def test_stop_loss_has_highest_priority(self) -> None:
"""Stop-loss takes priority even when target_2 is also hit.
This can happen if stop_loss >= target_2 due to misconfiguration,
or if the price gaps through both levels.
"""
# Contrived: stop_loss at 130, target_2 at 120 (misconfigured)
pos = _position(stop_loss=130.0, target_2=120.0)
signals = evaluate_exits([pos], {"AAPL": 125.0}, _default_config())
assert len(signals) == 1
assert signals[0].reason == "stop_hit"
# ===========================================================================
# 2. Target-1 partial exit (Requirement 8.2)
# ===========================================================================
class TestTarget1:
"""Target-1 hit → EXIT_HALF with reason 'target_1_hit'."""
def test_target_1_exact_hit(self) -> None:
"""Price exactly at target_1 triggers partial exit."""
pos = _position(target_1=115.0)
signals = evaluate_exits([pos], {"AAPL": 115.0}, _default_config())
assert len(signals) == 1
assert signals[0].exit_type == ExitType.EXIT_HALF
assert signals[0].reason == "target_1_hit"
assert signals[0].price == 115.0
def test_target_1_above(self) -> None:
"""Price above target_1 (but below target_2) triggers partial exit."""
pos = _position(target_1=115.0, target_2=130.0)
signals = evaluate_exits([pos], {"AAPL": 120.0}, _default_config())
assert len(signals) == 1
assert signals[0].exit_type == ExitType.EXIT_HALF
assert signals[0].reason == "target_1_hit"
def test_target_1_not_triggered_when_partial_exit_done(self) -> None:
"""Target-1 is skipped when partial_exit_done is True."""
pos = _position(target_1=115.0, target_2=130.0, partial_exit_done=True, atr=5.0)
# Price above target_1 but below target_2, trailing stop not hit
signals = evaluate_exits([pos], {"AAPL": 120.0}, _default_config())
# No target_1_hit signal; trailing stop is 120 - 5*2 = 110, not hit
assert len(signals) == 0
# ===========================================================================
# 3. Target-2 full exit (Requirement 8.3)
# ===========================================================================
class TestTarget2:
"""Target-2 hit → EXIT_FULL with reason 'target_2_hit'."""
def test_target_2_exact_hit(self) -> None:
"""Price exactly at target_2 triggers full exit."""
pos = _position(target_2=130.0)
signals = evaluate_exits([pos], {"AAPL": 130.0}, _default_config())
assert len(signals) == 1
assert signals[0].exit_type == ExitType.EXIT_FULL
assert signals[0].reason == "target_2_hit"
assert signals[0].price == 130.0
def test_target_2_above(self) -> None:
"""Price above target_2 triggers full exit."""
pos = _position(target_2=130.0)
signals = evaluate_exits([pos], {"AAPL": 140.0}, _default_config())
assert len(signals) == 1
assert signals[0].exit_type == ExitType.EXIT_FULL
assert signals[0].reason == "target_2_hit"
def test_target_2_priority_over_target_1(self) -> None:
"""When price hits both target_1 and target_2, target_2 wins."""
pos = _position(target_1=115.0, target_2=130.0)
signals = evaluate_exits([pos], {"AAPL": 135.0}, _default_config())
assert len(signals) == 1
assert signals[0].reason == "target_2_hit"
assert signals[0].exit_type == ExitType.EXIT_FULL
# ===========================================================================
# 4. Trailing stop activation and ratchet (Requirement 8.4)
# ===========================================================================
class TestTrailingStop:
"""Trailing stop activates after partial exit and ratchets upward."""
def test_trailing_stop_not_active_before_partial_exit(self) -> None:
"""Trailing stop does not trigger when partial_exit_done is False."""
pos = _position(
partial_exit_done=False,
trailing_stop=108.0,
atr=5.0,
target_1=115.0,
target_2=130.0,
stop_loss=90.0,
)
# Price at 107 is below trailing_stop=108, but trailing is not active
signals = evaluate_exits([pos], {"AAPL": 107.0}, _default_config())
# No trailing stop signal; price is above stop_loss and below targets
assert len(signals) == 0
def test_trailing_stop_computed_from_atr(self) -> None:
"""Trailing stop = price - ATR * multiplier when no existing stop."""
pos = _position(
partial_exit_done=True,
trailing_stop=None,
atr=5.0,
target_2=150.0,
)
# Price = 120, trailing = 120 - 5*2 = 110, price > 110 → no exit
signals = evaluate_exits([pos], {"AAPL": 120.0}, _default_config())
assert len(signals) == 0
def test_trailing_stop_ratchets_upward(self) -> None:
"""New trailing stop level is used only if higher than existing."""
pos = _position(
partial_exit_done=True,
trailing_stop=112.0, # existing high trailing stop
atr=5.0,
target_2=150.0,
)
# Price = 120, new trailing = 120 - 10 = 110 < existing 112
# Effective trailing = 112 (ratchet keeps higher value)
# Price 120 > 112 → no exit
signals = evaluate_exits([pos], {"AAPL": 120.0}, _default_config())
assert len(signals) == 0
def test_trailing_stop_updates_when_price_advances(self) -> None:
"""Higher price produces higher trailing stop level."""
pos = _position(
partial_exit_done=True,
trailing_stop=105.0, # old trailing stop
atr=5.0,
target_2=200.0,
)
# Price = 130, new trailing = 130 - 10 = 120 > existing 105
# Effective trailing = 120, price 130 > 120 → no exit
signals = evaluate_exits([pos], {"AAPL": 130.0}, _default_config())
assert len(signals) == 0
def test_trailing_stop_no_atr_uses_existing(self) -> None:
"""When ATR is None, existing trailing_stop is used as-is."""
pos = _position(
partial_exit_done=True,
trailing_stop=115.0,
atr=None,
target_2=150.0,
)
# Price = 120 > trailing 115 → no exit
signals = evaluate_exits([pos], {"AAPL": 120.0}, _default_config())
assert len(signals) == 0
def test_trailing_stop_no_atr_no_existing_returns_zero(self) -> None:
"""When ATR is None and trailing_stop is None, effective stop is 0."""
pos = _position(
partial_exit_done=True,
trailing_stop=None,
atr=None,
target_2=150.0,
)
# Effective trailing = 0.0, price 120 > 0 → no exit
signals = evaluate_exits([pos], {"AAPL": 120.0}, _default_config())
assert len(signals) == 0
# ===========================================================================
# 5. Trailing stop hit (Requirement 8.5)
# ===========================================================================
class TestTrailingStopHit:
"""Trailing stop hit → EXIT_FULL with reason 'trailing_stop_hit'."""
def test_trailing_stop_hit_exact(self) -> None:
"""Price exactly at trailing stop triggers exit."""
pos = _position(
partial_exit_done=True,
trailing_stop=115.0,
atr=5.0,
target_2=150.0,
)
# Price = 115, new trailing = 115 - 10 = 105 < existing 115
# Effective trailing = 115, price 115 <= 115 → exit
signals = evaluate_exits([pos], {"AAPL": 115.0}, _default_config())
assert len(signals) == 1
assert signals[0].exit_type == ExitType.EXIT_FULL
assert signals[0].reason == "trailing_stop_hit"
assert signals[0].price == 115.0
def test_trailing_stop_hit_below(self) -> None:
"""Price below trailing stop triggers exit."""
pos = _position(
partial_exit_done=True,
trailing_stop=115.0,
atr=5.0,
target_2=150.0,
)
# Price = 110, new trailing = 110 - 10 = 100 < existing 115
# Effective trailing = 115, price 110 <= 115 → exit
signals = evaluate_exits([pos], {"AAPL": 110.0}, _default_config())
assert len(signals) == 1
assert signals[0].reason == "trailing_stop_hit"
def test_trailing_stop_hit_computed_from_atr(self) -> None:
"""Trailing stop computed from ATR triggers exit when price drops."""
pos = _position(
partial_exit_done=True,
trailing_stop=None, # no existing trailing stop
atr=3.0,
target_2=150.0,
)
# Price = 100, trailing = 100 - 3*2 = 94, max(0, 94) = 94
# Price 100 > 94 → no exit
signals = evaluate_exits([pos], {"AAPL": 100.0}, _default_config())
assert len(signals) == 0
# Now price drops to 93 → trailing = 93 - 6 = 87, max(0, 87) = 87
# Price 93 > 87 → still no exit (trailing recomputed each call)
signals2 = evaluate_exits([pos], {"AAPL": 93.0}, _default_config())
assert len(signals2) == 0
def test_trailing_stop_hit_with_high_existing_stop(self) -> None:
"""Existing high trailing stop triggers exit when price drops to it."""
pos = _position(
partial_exit_done=True,
trailing_stop=118.0, # previously ratcheted up
atr=5.0,
target_2=150.0,
)
# Price = 117, new trailing = 117 - 10 = 107 < existing 118
# Effective trailing = 118, price 117 <= 118 → exit
signals = evaluate_exits([pos], {"AAPL": 117.0}, _default_config())
assert len(signals) == 1
assert signals[0].reason == "trailing_stop_hit"
# ===========================================================================
# 6. No exit when price is between stop and targets
# ===========================================================================
class TestNoExit:
"""No exit signal when price is in the safe zone."""
def test_price_between_stop_and_target_1(self) -> None:
"""Price above stop_loss and below target_1 → no exit."""
pos = _position(stop_loss=90.0, target_1=115.0, target_2=130.0)
signals = evaluate_exits([pos], {"AAPL": 105.0}, _default_config())
assert len(signals) == 0
def test_price_just_above_stop_loss(self) -> None:
"""Price barely above stop_loss → no exit."""
pos = _position(stop_loss=90.0)
signals = evaluate_exits([pos], {"AAPL": 90.01}, _default_config())
assert len(signals) == 0
def test_price_just_below_target_1(self) -> None:
"""Price barely below target_1 → no exit."""
pos = _position(target_1=115.0)
signals = evaluate_exits([pos], {"AAPL": 114.99}, _default_config())
assert len(signals) == 0
# ===========================================================================
# 7. Empty positions list
# ===========================================================================
class TestEmptyPositions:
"""Empty positions list returns empty signals list."""
def test_empty_positions(self) -> None:
signals = evaluate_exits([], {"AAPL": 100.0}, _default_config())
assert signals == []
def test_empty_positions_empty_prices(self) -> None:
signals = evaluate_exits([], {}, _default_config())
assert signals == []
# ===========================================================================
# 8. Fallback to position.current_price when ticker not in current_prices
# ===========================================================================
class TestPriceFallback:
"""When ticker is absent from current_prices, use position.current_price."""
def test_uses_position_current_price_as_fallback(self) -> None:
"""Ticker not in current_prices → falls back to position.current_price."""
pos = _position(
ticker="MSFT",
current_price=85.0, # below stop_loss
stop_loss=90.0,
)
# "MSFT" not in current_prices → uses 85.0
signals = evaluate_exits([pos], {"AAPL": 200.0}, _default_config())
assert len(signals) == 1
assert signals[0].reason == "stop_hit"
assert signals[0].price == 85.0
def test_uses_current_prices_when_available(self) -> None:
"""Ticker in current_prices → uses that price, not position.current_price."""
pos = _position(
ticker="AAPL",
current_price=85.0, # would trigger stop
stop_loss=90.0,
)
# current_prices has AAPL at 105 → above stop, no exit
signals = evaluate_exits([pos], {"AAPL": 105.0}, _default_config())
assert len(signals) == 0
def test_fallback_triggers_target(self) -> None:
"""Fallback price can trigger target exits too."""
pos = _position(
ticker="TSLA",
current_price=135.0, # above target_2
target_2=130.0,
)
signals = evaluate_exits([pos], {}, _default_config())
assert len(signals) == 1
assert signals[0].reason == "target_2_hit"
assert signals[0].price == 135.0
# ===========================================================================
# 9. Multiple positions
# ===========================================================================
class TestMultiplePositions:
"""Multiple positions evaluated independently."""
def test_multiple_positions_different_exits(self) -> None:
"""Each position evaluated independently; different exit types."""
pos1 = _position(position_id="p1", ticker="AAPL", stop_loss=90.0)
pos2 = _position(position_id="p2", ticker="MSFT", stop_loss=40.0, target_1=50.0, target_2=130.0)
pos3 = _position(position_id="p3", ticker="GOOG")
prices = {"AAPL": 85.0, "MSFT": 55.0, "GOOG": 105.0}
signals = evaluate_exits([pos1, pos2, pos3], prices, _default_config())
assert len(signals) == 2 # AAPL stop hit, MSFT target_1 hit, GOOG no exit
by_id = {s.position_id: s for s in signals}
assert by_id["p1"].reason == "stop_hit"
assert by_id["p2"].reason == "target_1_hit"
assert "p3" not in by_id
def test_all_positions_no_exit(self) -> None:
"""All positions in safe zone → empty signals."""
pos1 = _position(position_id="p1", stop_loss=80.0, target_1=120.0)
pos2 = _position(position_id="p2", stop_loss=80.0, target_1=120.0)
signals = evaluate_exits(
[pos1, pos2],
{"AAPL": 100.0},
_default_config(),
)
assert len(signals) == 0
# ===========================================================================
# 10. Custom config — trailing_stop_atr_multiplier
# ===========================================================================
class TestCustomExitConfig:
"""Custom ATR multiplier affects trailing stop computation."""
def test_higher_multiplier_wider_trailing_stop(self) -> None:
"""Higher multiplier → wider trailing stop → less likely to trigger."""
# Use a pre-set trailing_stop that was ratcheted up previously.
# With tight config the existing trailing stop triggers; with wide
# config we use a lower existing stop that doesn't trigger.
pos_tight = _position(
partial_exit_done=True,
trailing_stop=110.0, # previously ratcheted high
atr=5.0,
target_2=200.0,
)
pos_wide = _position(
partial_exit_done=True,
trailing_stop=100.0, # lower trailing stop
atr=5.0,
target_2=200.0,
)
config = _default_config()
# Price at 108: tight trailing=max(110, 108-10)=110 → hit
signals_tight = evaluate_exits([pos_tight], {"AAPL": 108.0}, config)
# Price at 108: wide trailing=max(100, 108-10)=100 → not hit (108 > 100)
signals_wide = evaluate_exits([pos_wide], {"AAPL": 108.0}, config)
assert len(signals_tight) == 1
assert signals_tight[0].reason == "trailing_stop_hit"
assert len(signals_wide) == 0
+288
View File
@@ -0,0 +1,288 @@
"""Unit tests for services.signal_engine.signals.fibonacci — Fibonacci retracement evaluator.
Requirements: 2.1, 2.6, 2.7
"""
from __future__ import annotations
from datetime import datetime, timezone
from services.signal_engine.models import OHLCVBar, SignalDirection
from services.signal_engine.signals.fibonacci import (
DEFAULT_MIN_BARS,
RETRACEMENT_RATIOS,
FibonacciEvaluator,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _bar(
close: float,
high: float | None = None,
low: float | None = None,
) -> OHLCVBar:
"""Create a minimal OHLCVBar for testing."""
return OHLCVBar(
timestamp=datetime(2024, 1, 1, tzinfo=timezone.utc),
open=close,
high=high if high is not None else close,
low=low if low is not None else close,
close=close,
volume=1000.0,
)
def _make_bars(
n: int,
close: float = 100.0,
high: float | None = None,
low: float | None = None,
) -> list[OHLCVBar]:
"""Create *n* identical bars."""
return [_bar(close, high=high, low=low) for _ in range(n)]
# ---------------------------------------------------------------------------
# Insufficient data → None
# ---------------------------------------------------------------------------
def test_returns_none_when_insufficient_bars() -> None:
"""Requirement 2.6: return None when fewer bars than lookback."""
evaluator = FibonacciEvaluator(min_bars=20)
bars = _make_bars(10, close=100.0, high=110.0, low=90.0)
assert evaluator.evaluate(bars, "D") is None
def test_returns_none_with_empty_bars() -> None:
evaluator = FibonacciEvaluator()
assert evaluator.evaluate([], "D") is None
def test_returns_none_when_flat_market() -> None:
"""When SH == SL there is no valid retracement range."""
evaluator = FibonacciEvaluator(min_bars=5)
# All bars have the same high and low
bars = _make_bars(5, close=100.0, high=100.0, low=100.0)
assert evaluator.evaluate(bars, "D") is None
# ---------------------------------------------------------------------------
# Basic signal production
# ---------------------------------------------------------------------------
def test_produces_signal_with_sufficient_data() -> None:
"""Requirement 2.7: produces a valid SignalResult."""
evaluator = FibonacciEvaluator(min_bars=5)
# Create bars with a clear swing high and swing low
bars = [
_bar(100.0, high=100.0, low=95.0),
_bar(105.0, high=110.0, low=100.0), # swing high
_bar(95.0, high=100.0, low=90.0), # swing low
_bar(98.0, high=100.0, low=95.0),
_bar(100.0, high=102.0, low=98.0), # current price = 100
]
result = evaluator.evaluate(bars, "H4")
assert result is not None
assert result.signal_type == "fibonacci"
assert result.timeframe == "H4"
assert 0.0 <= result.strength <= 1.0
assert 0.0 <= result.confidence <= 1.0
assert result.direction in (SignalDirection.BULLISH, SignalDirection.BEARISH)
def test_signal_metadata_contains_expected_keys() -> None:
evaluator = FibonacciEvaluator(min_bars=5)
bars = [
_bar(100.0, high=100.0, low=95.0),
_bar(105.0, high=110.0, low=100.0),
_bar(95.0, high=100.0, low=90.0),
_bar(98.0, high=100.0, low=95.0),
_bar(100.0, high=102.0, low=98.0),
]
result = evaluator.evaluate(bars, "D")
assert result is not None
meta = result.metadata
assert "swing_high" in meta
assert "swing_low" in meta
assert "retracement_levels" in meta
assert "nearest_ratio" in meta
assert "nearest_level" in meta
assert "distance_to_nearest" in meta
assert "current_price" in meta
# ---------------------------------------------------------------------------
# Retracement level formula: L(r) = SH - r * (SH - SL)
# ---------------------------------------------------------------------------
def test_retracement_levels_formula() -> None:
"""Requirement 2.1: L(r) = SH - r * (SH - SL) for all standard ratios."""
evaluator = FibonacciEvaluator(min_bars=5)
# SH = 200, SL = 100 → range = 100
bars = [
_bar(150.0, high=200.0, low=100.0), # contains both SH and SL
_bar(150.0, high=180.0, low=120.0),
_bar(150.0, high=170.0, low=130.0),
_bar(150.0, high=160.0, low=140.0),
_bar(150.0, high=155.0, low=145.0), # current close = 150
]
result = evaluator.evaluate(bars, "D")
assert result is not None
levels = result.metadata["retracement_levels"]
sh = result.metadata["swing_high"]
sl = result.metadata["swing_low"]
assert sh == 200.0
assert sl == 100.0
for ratio in RETRACEMENT_RATIOS:
expected = sh - ratio * (sh - sl)
assert abs(levels[ratio] - expected) < 1e-10, (
f"Level for ratio {ratio}: expected {expected}, got {levels[ratio]}"
)
def test_retracement_ratios_constant() -> None:
"""Verify the RETRACEMENT_RATIOS constant matches the spec."""
assert RETRACEMENT_RATIOS == [0.236, 0.382, 0.5, 0.618, 0.786]
# ---------------------------------------------------------------------------
# Signal strength — proximity to nearest level
# ---------------------------------------------------------------------------
def test_strength_is_high_when_price_at_level() -> None:
"""When current price is exactly at a retracement level, strength ≈ 1.0."""
evaluator = FibonacciEvaluator(min_bars=5)
# SH = 200, SL = 100, range = 100
# 0.5 level = 200 - 0.5 * 100 = 150
# Set current close exactly at the 0.5 level
bars = [
_bar(150.0, high=200.0, low=100.0),
_bar(160.0, high=180.0, low=120.0),
_bar(140.0, high=170.0, low=110.0),
_bar(155.0, high=165.0, low=130.0),
_bar(150.0, high=155.0, low=145.0), # close = 150 = 0.5 level
]
result = evaluator.evaluate(bars, "D")
assert result is not None
assert result.strength == 1.0
def test_strength_decreases_with_distance() -> None:
"""Strength should be lower when price is far from any retracement level."""
evaluator = FibonacciEvaluator(min_bars=5)
# SH = 200, SL = 100
# Levels: 176.4, 161.8, 150.0, 138.2, 121.4
# Price at 110 → nearest is 121.4, distance = 11.4, strength = 1 - 11.4/100 = 0.886
bars = [
_bar(150.0, high=200.0, low=100.0),
_bar(160.0, high=180.0, low=120.0),
_bar(140.0, high=170.0, low=110.0),
_bar(130.0, high=165.0, low=105.0),
_bar(110.0, high=115.0, low=105.0), # close = 110
]
result = evaluator.evaluate(bars, "D")
assert result is not None
assert result.strength < 1.0
assert result.strength > 0.0
# ---------------------------------------------------------------------------
# Direction logic
# ---------------------------------------------------------------------------
def test_direction_bullish_when_above_swing_low() -> None:
"""Price above SL → BULLISH (potential bounce)."""
evaluator = FibonacciEvaluator(min_bars=5)
# SL = 100, current close = 150 (above SL)
bars = [
_bar(150.0, high=200.0, low=100.0),
_bar(160.0, high=180.0, low=120.0),
_bar(140.0, high=170.0, low=110.0),
_bar(155.0, high=165.0, low=130.0),
_bar(150.0, high=155.0, low=145.0),
]
result = evaluator.evaluate(bars, "D")
assert result is not None
assert result.direction == SignalDirection.BULLISH
def test_direction_bearish_when_below_swing_low() -> None:
"""Price below SL → BEARISH."""
evaluator = FibonacciEvaluator(min_bars=3)
# SH = 200 (bar 0 high), SL = 100 (bar 1 low)
# Current close = 95 (below SL of 100)
# The last bar's low must not be lower than SL, otherwise SL shifts down
bars = [
_bar(150.0, high=200.0, low=110.0),
_bar(120.0, high=150.0, low=100.0),
_bar(95.0, high=100.0, low=100.0), # close = 95 < SL=100, but low stays at 100
]
result = evaluator.evaluate(bars, "D")
assert result is not None
assert result.direction == SignalDirection.BEARISH
# ---------------------------------------------------------------------------
# Confidence — key ratios boost confidence
# ---------------------------------------------------------------------------
def test_confidence_higher_at_key_ratio() -> None:
"""Confidence should be boosted when nearest level is 0.5 or 0.618."""
evaluator = FibonacciEvaluator(min_bars=5)
# SH = 200, SL = 100
# 0.5 level = 150, 0.618 level = 138.2
# Price at 150 → nearest is 0.5 (key ratio)
bars_at_key = [
_bar(150.0, high=200.0, low=100.0),
_bar(160.0, high=180.0, low=120.0),
_bar(140.0, high=170.0, low=110.0),
_bar(155.0, high=165.0, low=130.0),
_bar(150.0, high=155.0, low=145.0), # at 0.5 level
]
result_key = evaluator.evaluate(bars_at_key, "D")
# Price at 176.4 → nearest is 0.236 (non-key ratio)
bars_at_nonkey = [
_bar(150.0, high=200.0, low=100.0),
_bar(160.0, high=180.0, low=120.0),
_bar(170.0, high=175.0, low=165.0),
_bar(175.0, high=178.0, low=172.0),
_bar(176.4, high=178.0, low=174.0), # at 0.236 level
]
result_nonkey = evaluator.evaluate(bars_at_nonkey, "D")
assert result_key is not None
assert result_nonkey is not None
# Both at their respective levels (distance ≈ 0), but key ratio gets higher confidence
assert result_key.confidence > result_nonkey.confidence
# ---------------------------------------------------------------------------
# Configurable min_bars
# ---------------------------------------------------------------------------
def test_custom_min_bars() -> None:
"""The lookback is configurable via constructor."""
evaluator = FibonacciEvaluator(min_bars=3)
bars = [
_bar(100.0, high=120.0, low=80.0),
_bar(110.0, high=115.0, low=90.0),
_bar(105.0, high=110.0, low=95.0),
]
result = evaluator.evaluate(bars, "M30")
assert result is not None
def test_default_min_bars_value() -> None:
assert DEFAULT_MIN_BARS == 20
+574
View File
@@ -0,0 +1,574 @@
"""Unit tests for services.signal_engine.formatter — Output Formatter.
Tests trade plan generation for dual_confirmed, probabilistic_only,
heuristic-only, and no-BUY cases. Also tests the
``signal_output_to_recommendation`` mapping to the existing
``Recommendation`` schema.
Requirements: 10.2, 10.3, 10.4, 12.3, 12.4
"""
from __future__ import annotations
from services.shared.schemas import ActionType, RecommendationMode
from services.signal_engine.config import SignalEngineConfig
from services.signal_engine.formatter import (
format_output,
signal_output_to_recommendation,
)
from services.signal_engine.models import (
DeltaResult,
ExitSignal,
ExitType,
HeuristicResult,
ProbabilisticResult,
Verdict,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _default_config() -> SignalEngineConfig:
return SignalEngineConfig()
def _heuristic(
verdict: Verdict = Verdict.BUY,
confidence: float = 0.85,
s_total: float = 1.5,
) -> HeuristicResult:
return HeuristicResult(
verdict=verdict,
confidence=confidence,
s_total=s_total,
s_company=1.0,
s_macro=0.3,
s_competitive=0.2,
signal_weights=[],
reasoning=[f"{verdict.value} verdict"],
)
def _probabilistic(
verdict: Verdict = Verdict.BUY,
p_up: float = 0.75,
entropy: float = 0.6,
ev_r: float = 2.0,
) -> ProbabilisticResult:
return ProbabilisticResult(
verdict=verdict,
p_up=p_up,
entropy=entropy,
ev_r=ev_r,
prior=0.58,
posterior=0.75,
likelihood_ratios=[],
regime="bull",
reasoning=[f"{verdict.value} verdict"],
)
def _delta(
agreement: bool = True,
confidence_delta: float = 0.1,
reasons: list[str] | None = None,
) -> DeltaResult:
return DeltaResult(
agreement=agreement,
confidence_delta=confidence_delta,
heuristic_verdict="BUY",
probabilistic_verdict="BUY",
disagreement_reasons=reasons or [],
rolling_agreement_rate=0.85,
)
# ===========================================================================
# 1. Dual confirmed trade plan (Requirement 10.4)
# ===========================================================================
class TestDualConfirmed:
"""Both pipelines BUY → dual_confirmed, full position sizing."""
def test_dual_confirmed_trade_plan(self) -> None:
"""Both BUY → trade_plan with dual_confirmed=True."""
output = format_output(
ticker="AAPL",
price=150.0,
heuristic=_heuristic(Verdict.BUY),
probabilistic=_probabilistic(Verdict.BUY),
delta=_delta(agreement=True),
exit_signals=[],
config=_default_config(),
)
assert output.trade_plan is not None
assert output.trade_plan.dual_confirmed is True
assert output.trade_plan.probabilistic_only is False
def test_dual_confirmed_full_position_sizing(self) -> None:
"""Dual confirmed → position_size_pct=0.02, max_loss_pct=0.005."""
output = format_output(
ticker="AAPL",
price=150.0,
heuristic=_heuristic(Verdict.BUY),
probabilistic=_probabilistic(Verdict.BUY),
delta=_delta(agreement=True),
exit_signals=[],
config=_default_config(),
)
assert output.trade_plan is not None
assert output.trade_plan.position_size_pct == 0.02
assert output.trade_plan.max_loss_pct == 0.005
def test_dual_confirmed_price_levels(self) -> None:
"""Trade plan price levels: stop=95%, target_1=105%, target_2=110%."""
price = 200.0
output = format_output(
ticker="AAPL",
price=price,
heuristic=_heuristic(Verdict.BUY),
probabilistic=_probabilistic(Verdict.BUY),
delta=_delta(agreement=True),
exit_signals=[],
config=_default_config(),
)
tp = output.trade_plan
assert tp is not None
assert tp.entry_price == price
assert abs(tp.stop_loss - price * 0.95) < 1e-6
assert abs(tp.target_1 - price * 1.05) < 1e-6
assert abs(tp.target_2 - price * 1.10) < 1e-6
# ===========================================================================
# 2. Probabilistic-only trade plan (Requirement 10.3)
# ===========================================================================
class TestProbabilisticOnly:
"""Probabilistic BUY, heuristic not BUY → probabilistic_only, 50% sizing."""
def test_probabilistic_only_trade_plan(self) -> None:
"""Probabilistic BUY + heuristic WATCH → probabilistic_only flag."""
output = format_output(
ticker="AAPL",
price=150.0,
heuristic=_heuristic(Verdict.WATCH, confidence=0.60),
probabilistic=_probabilistic(Verdict.BUY),
delta=_delta(agreement=False),
exit_signals=[],
config=_default_config(),
)
assert output.trade_plan is not None
assert output.trade_plan.probabilistic_only is True
assert output.trade_plan.dual_confirmed is False
def test_probabilistic_only_reduced_sizing(self) -> None:
"""Probabilistic-only → position_size_pct=0.01 (50% of standard)."""
output = format_output(
ticker="AAPL",
price=150.0,
heuristic=_heuristic(Verdict.SKIP, confidence=0.40),
probabilistic=_probabilistic(Verdict.BUY),
delta=_delta(agreement=False),
exit_signals=[],
config=_default_config(),
)
assert output.trade_plan is not None
assert output.trade_plan.position_size_pct == 0.01
assert output.trade_plan.max_loss_pct == 0.005
def test_probabilistic_only_price_levels(self) -> None:
"""Price levels are the same regardless of confirmation mode."""
price = 100.0
output = format_output(
ticker="MSFT",
price=price,
heuristic=_heuristic(Verdict.WATCH),
probabilistic=_probabilistic(Verdict.BUY),
delta=_delta(agreement=False),
exit_signals=[],
config=_default_config(),
)
tp = output.trade_plan
assert tp is not None
assert tp.entry_price == price
assert abs(tp.stop_loss - price * 0.95) < 1e-6
assert abs(tp.target_1 - price * 1.05) < 1e-6
assert abs(tp.target_2 - price * 1.10) < 1e-6
# ===========================================================================
# 3. Heuristic-only trade plan (Requirement 10.2)
# ===========================================================================
class TestHeuristicOnly:
"""Heuristic BUY, probabilistic not BUY → standard position sizing."""
def test_heuristic_only_trade_plan(self) -> None:
"""Heuristic BUY + probabilistic WATCH → standard trade plan."""
output = format_output(
ticker="AAPL",
price=150.0,
heuristic=_heuristic(Verdict.BUY),
probabilistic=_probabilistic(Verdict.WATCH, p_up=0.57),
delta=_delta(agreement=False),
exit_signals=[],
config=_default_config(),
)
assert output.trade_plan is not None
assert output.trade_plan.dual_confirmed is False
assert output.trade_plan.probabilistic_only is False
def test_heuristic_only_full_sizing(self) -> None:
"""Heuristic-only → position_size_pct=0.02 (full standard)."""
output = format_output(
ticker="AAPL",
price=150.0,
heuristic=_heuristic(Verdict.BUY),
probabilistic=_probabilistic(Verdict.SKIP, p_up=0.40, entropy=0.9, ev_r=0.5),
delta=_delta(agreement=False),
exit_signals=[],
config=_default_config(),
)
assert output.trade_plan is not None
assert output.trade_plan.position_size_pct == 0.02
assert output.trade_plan.max_loss_pct == 0.005
# ===========================================================================
# 4. No BUY — no trade plan (Requirement 10.4 inverse)
# ===========================================================================
class TestNoBuy:
"""Neither pipeline BUY → no trade_plan."""
def test_both_watch_no_trade_plan(self) -> None:
"""Both WATCH → no trade_plan."""
output = format_output(
ticker="AAPL",
price=150.0,
heuristic=_heuristic(Verdict.WATCH, confidence=0.60),
probabilistic=_probabilistic(Verdict.WATCH, p_up=0.57),
delta=_delta(agreement=True),
exit_signals=[],
config=_default_config(),
)
assert output.trade_plan is None
def test_both_skip_no_trade_plan(self) -> None:
"""Both SKIP → no trade_plan."""
output = format_output(
ticker="AAPL",
price=150.0,
heuristic=_heuristic(Verdict.SKIP, confidence=0.30),
probabilistic=_probabilistic(Verdict.SKIP, p_up=0.40, entropy=0.9, ev_r=0.5),
delta=_delta(agreement=True),
exit_signals=[],
config=_default_config(),
)
assert output.trade_plan is None
def test_watch_and_skip_no_trade_plan(self) -> None:
"""WATCH + SKIP → no trade_plan."""
output = format_output(
ticker="AAPL",
price=150.0,
heuristic=_heuristic(Verdict.WATCH, confidence=0.60),
probabilistic=_probabilistic(Verdict.SKIP, p_up=0.40, entropy=0.9, ev_r=0.5),
delta=_delta(agreement=False),
exit_signals=[],
config=_default_config(),
)
assert output.trade_plan is None
def test_no_buy_still_has_pipeline_data(self) -> None:
"""Even without trade_plan, pipeline data is populated for analysis."""
output = format_output(
ticker="AAPL",
price=150.0,
heuristic=_heuristic(Verdict.WATCH, confidence=0.60),
probabilistic=_probabilistic(Verdict.WATCH, p_up=0.57),
delta=_delta(agreement=True),
exit_signals=[],
config=_default_config(),
)
assert output.heuristic_verdict == "WATCH"
assert output.probabilistic_verdict == "WATCH"
assert output.heuristic_confidence == 0.60
assert output.probabilistic_p_up == 0.57
assert output.delta_agreement is True
# ===========================================================================
# 5. signal_output_to_recommendation mapping (Requirements 12.3, 12.4)
# ===========================================================================
class TestSignalOutputToRecommendation:
"""Map SignalOutput to existing Recommendation schema."""
def test_dual_confirmed_confidence(self) -> None:
"""Dual confirmed → confidence = max(heuristic, probabilistic)."""
output = format_output(
ticker="AAPL",
price=150.0,
heuristic=_heuristic(Verdict.BUY, confidence=0.85),
probabilistic=_probabilistic(Verdict.BUY, p_up=0.75),
delta=_delta(agreement=True),
exit_signals=[],
config=_default_config(),
)
rec = signal_output_to_recommendation(output)
assert rec.confidence == max(0.85, 0.75)
assert rec.confidence == 0.85
def test_probabilistic_only_confidence_haircut(self) -> None:
"""Probabilistic only → confidence = P_up * 0.8 (20% haircut)."""
output = format_output(
ticker="AAPL",
price=150.0,
heuristic=_heuristic(Verdict.WATCH, confidence=0.60),
probabilistic=_probabilistic(Verdict.BUY, p_up=0.75),
delta=_delta(agreement=False),
exit_signals=[],
config=_default_config(),
)
rec = signal_output_to_recommendation(output)
assert abs(rec.confidence - 0.75 * 0.8) < 1e-9
assert abs(rec.confidence - 0.6) < 1e-9
def test_heuristic_only_confidence(self) -> None:
"""Heuristic only → confidence = heuristic_confidence."""
output = format_output(
ticker="AAPL",
price=150.0,
heuristic=_heuristic(Verdict.BUY, confidence=0.80),
probabilistic=_probabilistic(Verdict.WATCH, p_up=0.57),
delta=_delta(agreement=False),
exit_signals=[],
config=_default_config(),
)
rec = signal_output_to_recommendation(output)
assert rec.confidence == 0.80
def test_buy_action_mapping(self) -> None:
"""Any BUY verdict → ActionType.BUY."""
output = format_output(
ticker="AAPL",
price=150.0,
heuristic=_heuristic(Verdict.BUY),
probabilistic=_probabilistic(Verdict.WATCH, p_up=0.57),
delta=_delta(agreement=False),
exit_signals=[],
config=_default_config(),
)
rec = signal_output_to_recommendation(output)
assert rec.action == ActionType.BUY
def test_watch_action_mapping(self) -> None:
"""Both WATCH → ActionType.WATCH."""
output = format_output(
ticker="AAPL",
price=150.0,
heuristic=_heuristic(Verdict.WATCH, confidence=0.60),
probabilistic=_probabilistic(Verdict.WATCH, p_up=0.57),
delta=_delta(agreement=True),
exit_signals=[],
config=_default_config(),
)
rec = signal_output_to_recommendation(output)
assert rec.action == ActionType.WATCH
def test_skip_action_mapping(self) -> None:
"""Both SKIP → ActionType.HOLD."""
output = format_output(
ticker="AAPL",
price=150.0,
heuristic=_heuristic(Verdict.SKIP, confidence=0.30),
probabilistic=_probabilistic(Verdict.SKIP, p_up=0.40, entropy=0.9, ev_r=0.5),
delta=_delta(agreement=True),
exit_signals=[],
config=_default_config(),
)
rec = signal_output_to_recommendation(output)
assert rec.action == ActionType.HOLD
def test_recommendation_mode_paper_eligible(self) -> None:
"""All recommendations use PAPER_ELIGIBLE mode."""
output = format_output(
ticker="AAPL",
price=150.0,
heuristic=_heuristic(Verdict.BUY),
probabilistic=_probabilistic(Verdict.BUY),
delta=_delta(agreement=True),
exit_signals=[],
config=_default_config(),
)
rec = signal_output_to_recommendation(output)
assert rec.mode == RecommendationMode.PAPER_ELIGIBLE
def test_recommendation_position_sizing_from_trade_plan(self) -> None:
"""Position sizing in recommendation matches trade plan."""
output = format_output(
ticker="AAPL",
price=150.0,
heuristic=_heuristic(Verdict.BUY),
probabilistic=_probabilistic(Verdict.BUY),
delta=_delta(agreement=True),
exit_signals=[],
config=_default_config(),
)
rec = signal_output_to_recommendation(output)
assert rec.position_sizing.portfolio_pct == 0.02
assert rec.position_sizing.max_loss_pct == 0.005
def test_recommendation_probabilistic_fields(self) -> None:
"""Recommendation includes probabilistic pipeline fields."""
output = format_output(
ticker="AAPL",
price=150.0,
heuristic=_heuristic(Verdict.BUY),
probabilistic=_probabilistic(Verdict.BUY, p_up=0.75, ev_r=2.0),
delta=_delta(agreement=True),
exit_signals=[],
config=_default_config(),
)
rec = signal_output_to_recommendation(output)
assert rec.p_bull == 0.75
assert rec.expected_value == 2.0
assert rec.pipeline_mode == "dual_pipeline"
def test_recommendation_ticker_and_id(self) -> None:
"""Recommendation inherits ticker and output_id."""
output = format_output(
ticker="MSFT",
price=300.0,
heuristic=_heuristic(Verdict.BUY),
probabilistic=_probabilistic(Verdict.BUY),
delta=_delta(agreement=True),
exit_signals=[],
config=_default_config(),
)
rec = signal_output_to_recommendation(output)
assert rec.ticker == "MSFT"
assert rec.recommendation_id == output.output_id
# ===========================================================================
# 6. SignalOutput structure and metadata
# ===========================================================================
class TestSignalOutputStructure:
"""Verify SignalOutput has all required fields populated."""
def test_output_has_all_pipeline_data(self) -> None:
"""SignalOutput contains heuristic, probabilistic, and delta sections."""
output = format_output(
ticker="AAPL",
price=150.0,
heuristic=_heuristic(Verdict.BUY, confidence=0.85, s_total=1.5),
probabilistic=_probabilistic(Verdict.BUY, p_up=0.75, entropy=0.6, ev_r=2.0),
delta=_delta(agreement=True, confidence_delta=0.1),
exit_signals=[],
config=_default_config(),
)
assert output.ticker == "AAPL"
assert output.price == 150.0
assert output.heuristic_verdict == "BUY"
assert output.heuristic_confidence == 0.85
assert output.heuristic_s_total == 1.5
assert output.probabilistic_verdict == "BUY"
assert output.probabilistic_p_up == 0.75
assert output.probabilistic_entropy == 0.6
assert output.probabilistic_ev_r == 2.0
assert output.delta_agreement is True
assert output.delta_confidence_delta == 0.1
assert output.pipeline_mode == "dual_pipeline"
def test_output_includes_exit_signals(self) -> None:
"""Exit signals are passed through to the output."""
exits = [
ExitSignal(
position_id="pos-1",
ticker="AAPL",
exit_type=ExitType.EXIT_HALF,
reason="target_1_hit",
price=157.5,
),
]
output = format_output(
ticker="AAPL",
price=150.0,
heuristic=_heuristic(Verdict.BUY),
probabilistic=_probabilistic(Verdict.BUY),
delta=_delta(agreement=True),
exit_signals=exits,
config=_default_config(),
)
assert len(output.exit_signals) == 1
assert output.exit_signals[0].position_id == "pos-1"
assert output.exit_signals[0].reason == "target_1_hit"
def test_output_shadow_mode(self) -> None:
"""Shadow mode flag is propagated from config."""
config = SignalEngineConfig(shadow_mode=True)
output = format_output(
ticker="AAPL",
price=150.0,
heuristic=_heuristic(Verdict.BUY),
probabilistic=_probabilistic(Verdict.BUY),
delta=_delta(agreement=True),
exit_signals=[],
config=config,
)
assert output.shadow_mode is True
def test_output_detail_payloads(self) -> None:
"""Heuristic and probabilistic detail payloads are populated for audit."""
h = _heuristic(Verdict.BUY, confidence=0.85)
p = _probabilistic(Verdict.BUY, p_up=0.75)
output = format_output(
ticker="AAPL",
price=150.0,
heuristic=h,
probabilistic=p,
delta=_delta(agreement=True),
exit_signals=[],
config=_default_config(),
)
assert output.heuristic_detail["verdict"] == "BUY"
assert output.heuristic_detail["confidence"] == 0.85
assert output.probabilistic_detail["verdict"] == "BUY"
assert output.probabilistic_detail["p_up"] == 0.75
+200
View File
@@ -0,0 +1,200 @@
"""Unit tests for the hard filter engine.
Validates evaluate_hard_filters against requirements 4.14.6.
"""
from datetime import datetime, timezone
from services.signal_engine.config import HardFilterConfig
from services.signal_engine.hard_filter import HardFilterResult, evaluate_hard_filters
from services.signal_engine.models import NormalizedInput
def _make_input(**overrides) -> NormalizedInput:
"""Build a minimal NormalizedInput with sensible defaults."""
defaults = {
"ticker": "AAPL",
"evaluated_at": datetime(2024, 1, 15, tzinfo=timezone.utc),
"bars": {},
"macro_bias": 0.5,
"valuation_score": 0.8,
"earnings_proximity_days": 30,
}
defaults.update(overrides)
return NormalizedInput(**defaults)
DEFAULT_CONFIG = HardFilterConfig()
class TestMacroBiasFilter:
"""Requirement 4.1: macro_bias == -1.0 → SKIP with reason 'macro_bias_negative'."""
def test_macro_bias_negative_triggers_filter(self):
inp = _make_input(macro_bias=-1.0)
result = evaluate_hard_filters(inp, DEFAULT_CONFIG)
assert result.filtered is True
assert "macro_bias_negative" in result.reasons
def test_macro_bias_zero_does_not_trigger(self):
inp = _make_input(macro_bias=0.0)
result = evaluate_hard_filters(inp, DEFAULT_CONFIG)
assert "macro_bias_negative" not in result.reasons
def test_macro_bias_positive_does_not_trigger(self):
inp = _make_input(macro_bias=0.5)
result = evaluate_hard_filters(inp, DEFAULT_CONFIG)
assert "macro_bias_negative" not in result.reasons
def test_macro_bias_slightly_above_negative_one(self):
inp = _make_input(macro_bias=-0.99)
result = evaluate_hard_filters(inp, DEFAULT_CONFIG)
assert "macro_bias_negative" not in result.reasons
class TestValuationFilter:
"""Requirement 4.2: valuation_score < 0.3 → SKIP with reason 'valuation_below_threshold'."""
def test_valuation_below_threshold_triggers(self):
inp = _make_input(valuation_score=0.1)
result = evaluate_hard_filters(inp, DEFAULT_CONFIG)
assert result.filtered is True
assert "valuation_below_threshold" in result.reasons
def test_valuation_at_threshold_does_not_trigger(self):
inp = _make_input(valuation_score=0.3)
result = evaluate_hard_filters(inp, DEFAULT_CONFIG)
assert "valuation_below_threshold" not in result.reasons
def test_valuation_above_threshold_does_not_trigger(self):
inp = _make_input(valuation_score=0.5)
result = evaluate_hard_filters(inp, DEFAULT_CONFIG)
assert "valuation_below_threshold" not in result.reasons
def test_valuation_none_does_not_trigger(self):
"""Missing valuation_score should NOT trigger the filter."""
inp = _make_input(valuation_score=None)
result = evaluate_hard_filters(inp, DEFAULT_CONFIG)
assert "valuation_below_threshold" not in result.reasons
class TestEarningsFilter:
"""Requirement 4.3: earnings_proximity_days <= 5 → SKIP with reason 'earnings_block'."""
def test_earnings_within_block_triggers(self):
inp = _make_input(earnings_proximity_days=3)
result = evaluate_hard_filters(inp, DEFAULT_CONFIG)
assert result.filtered is True
assert "earnings_block" in result.reasons
def test_earnings_at_boundary_triggers(self):
inp = _make_input(earnings_proximity_days=5)
result = evaluate_hard_filters(inp, DEFAULT_CONFIG)
assert result.filtered is True
assert "earnings_block" in result.reasons
def test_earnings_above_boundary_does_not_trigger(self):
inp = _make_input(earnings_proximity_days=6)
result = evaluate_hard_filters(inp, DEFAULT_CONFIG)
assert "earnings_block" not in result.reasons
def test_earnings_none_does_not_trigger(self):
"""Missing earnings_proximity_days should NOT trigger the filter."""
inp = _make_input(earnings_proximity_days=None)
result = evaluate_hard_filters(inp, DEFAULT_CONFIG)
assert "earnings_block" not in result.reasons
class TestMultipleFilters:
"""Requirement 4.4: all triggered filter reasons are recorded."""
def test_all_three_filters_trigger(self):
inp = _make_input(
macro_bias=-1.0,
valuation_score=0.1,
earnings_proximity_days=2,
)
result = evaluate_hard_filters(inp, DEFAULT_CONFIG)
assert result.filtered is True
assert len(result.reasons) == 3
assert "macro_bias_negative" in result.reasons
assert "valuation_below_threshold" in result.reasons
assert "earnings_block" in result.reasons
def test_two_filters_trigger(self):
inp = _make_input(
macro_bias=-1.0,
valuation_score=0.8,
earnings_proximity_days=2,
)
result = evaluate_hard_filters(inp, DEFAULT_CONFIG)
assert result.filtered is True
assert len(result.reasons) == 2
assert "macro_bias_negative" in result.reasons
assert "earnings_block" in result.reasons
class TestNoFilters:
"""Requirement 4.5: no hard filters trigger → pass through."""
def test_clean_input_passes(self):
inp = _make_input(
macro_bias=0.5,
valuation_score=0.8,
earnings_proximity_days=30,
)
result = evaluate_hard_filters(inp, DEFAULT_CONFIG)
assert result.filtered is False
assert result.reasons == []
def test_all_none_optional_fields_pass(self):
"""When optional fields are None, no filters trigger."""
inp = _make_input(
macro_bias=0.0,
valuation_score=None,
earnings_proximity_days=None,
)
result = evaluate_hard_filters(inp, DEFAULT_CONFIG)
assert result.filtered is False
assert result.reasons == []
class TestCustomConfig:
"""Verify that the filter respects custom config thresholds."""
def test_custom_valuation_min(self):
config = HardFilterConfig(valuation_min=0.5)
inp = _make_input(valuation_score=0.4)
result = evaluate_hard_filters(inp, config)
assert result.filtered is True
assert "valuation_below_threshold" in result.reasons
def test_custom_earnings_days(self):
config = HardFilterConfig(earnings_days=10)
inp = _make_input(earnings_proximity_days=8)
result = evaluate_hard_filters(inp, config)
assert result.filtered is True
assert "earnings_block" in result.reasons
def test_custom_macro_bias_skip(self):
config = HardFilterConfig(macro_bias_skip=-0.5)
inp = _make_input(macro_bias=-0.5)
result = evaluate_hard_filters(inp, config)
assert result.filtered is True
assert "macro_bias_negative" in result.reasons
class TestHardFilterResultDefaults:
"""Verify HardFilterResult dataclass defaults."""
def test_default_values(self):
result = HardFilterResult()
assert result.filtered is False
assert result.reasons == []
def test_mutable_default_isolation(self):
"""Each instance should have its own reasons list."""
r1 = HardFilterResult()
r2 = HardFilterResult()
r1.reasons.append("test")
assert r2.reasons == []
+814
View File
@@ -0,0 +1,814 @@
"""Unit tests for services.signal_engine.heuristic — Heuristic Pipeline verdict logic.
Tests BUY, WATCH, and SKIP verdict conditions, threshold edge cases,
confidence computation (agreement boosts, contradiction penalties),
S_total computation from multiple signals, and None-valued inputs.
Requirements: 5.4, 5.5, 5.6
"""
from __future__ import annotations
from datetime import datetime, timezone
from services.signal_engine.config import HeuristicConfig
from services.signal_engine.heuristic import (
_compute_confidence,
_compute_s_company,
_compute_s_competitive,
_compute_s_macro,
_determine_verdict,
run_heuristic_pipeline,
)
from services.signal_engine.models import (
ConfluenceSignal,
NormalizedInput,
SignalDirection,
Verdict,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_NOW = datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)
def _default_config() -> HeuristicConfig:
"""Return default heuristic config matching design thresholds."""
return HeuristicConfig()
def _normalized(
*,
valuation_score: float | None = 0.8,
earnings_proximity_days: int | None = 30,
macro_bias: float = 0.5,
) -> NormalizedInput:
"""Create a NormalizedInput with sensible defaults for testing."""
return NormalizedInput(
ticker="AAPL",
evaluated_at=_NOW,
bars={},
valuation_score=valuation_score,
earnings_proximity_days=earnings_proximity_days,
macro_bias=macro_bias,
)
def _bullish_signal(
signal_type: str = "fibonacci",
confluence_score: float = 0.8,
timeframes: list[str] | None = None,
) -> ConfluenceSignal:
"""Create a bullish confluence signal."""
tfs = timeframes or ["D", "W"]
return ConfluenceSignal(
signal_type=signal_type,
direction=SignalDirection.BULLISH,
confluence_score=confluence_score,
active_timeframes=tfs,
per_timeframe={tf: confluence_score for tf in tfs},
)
def _bearish_signal(
signal_type: str = "rsi",
confluence_score: float = 0.6,
timeframes: list[str] | None = None,
) -> ConfluenceSignal:
"""Create a bearish confluence signal."""
tfs = timeframes or ["D", "H4"]
return ConfluenceSignal(
signal_type=signal_type,
direction=SignalDirection.BEARISH,
confluence_score=confluence_score,
active_timeframes=tfs,
per_timeframe={tf: confluence_score for tf in tfs},
)
def _neutral_signal(
signal_type: str = "elliott_wave",
confluence_score: float = 0.5,
) -> ConfluenceSignal:
"""Create a neutral confluence signal."""
return ConfluenceSignal(
signal_type=signal_type,
direction=SignalDirection.NEUTRAL,
confluence_score=confluence_score,
active_timeframes=["D", "W"],
per_timeframe={"D": confluence_score, "W": confluence_score},
)
# ===========================================================================
# 1. BUY verdict — all conditions met (Requirement 5.4)
# ===========================================================================
class TestBuyVerdict:
"""BUY requires confidence >= 0.70, S_total >= 1.2, valuation >= 0.5,
macro_bias > 0, earnings_proximity_days > 5."""
def test_buy_all_conditions_met(self) -> None:
"""Strong bullish signals + favorable fundamentals → BUY."""
signals = [
_bullish_signal("fibonacci", 0.9),
_bullish_signal("ma_stack", 0.85),
_bullish_signal("rsi", 0.8),
]
normalized = _normalized(
valuation_score=0.7,
earnings_proximity_days=30,
macro_bias=0.5,
)
config = _default_config()
result = run_heuristic_pipeline(normalized, signals, config)
assert result.verdict == Verdict.BUY
assert result.confidence >= config.buy_confidence
assert result.s_total >= config.buy_s_total
assert len(result.reasoning) > 0
assert "BUY" in result.reasoning[0]
def test_buy_reasoning_includes_all_values(self) -> None:
"""BUY reasoning should mention confidence, S_total, valuation, macro, earnings."""
signals = [
_bullish_signal("fibonacci", 0.9),
_bullish_signal("ma_stack", 0.85),
_bullish_signal("rsi", 0.8),
]
normalized = _normalized(valuation_score=0.7, macro_bias=0.5, earnings_proximity_days=30)
result = run_heuristic_pipeline(normalized, signals, _default_config())
assert result.verdict == Verdict.BUY
reason = result.reasoning[0]
assert "confidence" in reason.lower()
assert "s_total" in reason.lower()
def test_buy_s_total_components_populated(self) -> None:
"""BUY result should have non-zero s_company and s_macro."""
signals = [
_bullish_signal("fibonacci", 0.9),
_bullish_signal("ma_stack", 0.85),
_bullish_signal("rsi", 0.8),
]
normalized = _normalized(macro_bias=0.5)
result = run_heuristic_pipeline(normalized, signals, _default_config())
assert result.s_company > 0
assert result.s_macro > 0 # macro_bias=0.5 * 0.5 weight = 0.25
assert result.s_total == result.s_company + result.s_macro + result.s_competitive
# ===========================================================================
# 2. WATCH verdict — confidence sufficient but BUY conditions not fully met
# (Requirement 5.5)
# ===========================================================================
class TestWatchVerdict:
"""WATCH: confidence >= 0.55 but at least one BUY condition fails."""
def test_watch_low_valuation(self) -> None:
"""Confidence OK but valuation below BUY threshold → WATCH."""
signals = [
_bullish_signal("fibonacci", 0.85),
_bullish_signal("ma_stack", 0.80),
]
normalized = _normalized(
valuation_score=0.3, # below 0.5 BUY threshold
macro_bias=0.5,
earnings_proximity_days=30,
)
result = run_heuristic_pipeline(normalized, signals, _default_config())
# Confidence should be >= watch threshold (0.55) with 2 strong bullish signals
if result.confidence >= 0.55:
assert result.verdict == Verdict.WATCH
assert any("WATCH" in r for r in result.reasoning)
def test_watch_negative_macro_bias(self) -> None:
"""Confidence OK but macro_bias <= 0 → WATCH (not BUY)."""
signals = [
_bullish_signal("fibonacci", 0.85),
_bullish_signal("ma_stack", 0.80),
]
normalized = _normalized(
valuation_score=0.8,
macro_bias=-0.1, # negative, fails macro_bias > 0
earnings_proximity_days=30,
)
result = run_heuristic_pipeline(normalized, signals, _default_config())
if result.confidence >= 0.55:
assert result.verdict == Verdict.WATCH
def test_watch_earnings_too_close(self) -> None:
"""Confidence OK but earnings within 5 days → WATCH."""
signals = [
_bullish_signal("fibonacci", 0.85),
_bullish_signal("ma_stack", 0.80),
]
normalized = _normalized(
valuation_score=0.8,
macro_bias=0.5,
earnings_proximity_days=3, # <= 5, fails earnings condition
)
result = run_heuristic_pipeline(normalized, signals, _default_config())
if result.confidence >= 0.55:
assert result.verdict == Verdict.WATCH
def test_watch_macro_bias_exactly_zero(self) -> None:
"""macro_bias == 0 fails the > 0 condition → WATCH if confidence OK."""
signals = [
_bullish_signal("fibonacci", 0.85),
_bullish_signal("ma_stack", 0.80),
]
normalized = _normalized(macro_bias=0.0)
result = run_heuristic_pipeline(normalized, signals, _default_config())
if result.confidence >= 0.55:
assert result.verdict == Verdict.WATCH
def test_watch_reasoning_lists_failed_conditions(self) -> None:
"""WATCH reasoning should identify which BUY conditions failed."""
signals = [
_bullish_signal("fibonacci", 0.85),
_bullish_signal("ma_stack", 0.80),
]
normalized = _normalized(valuation_score=0.3, macro_bias=0.0)
result = run_heuristic_pipeline(normalized, signals, _default_config())
if result.verdict == Verdict.WATCH:
full_reasoning = " ".join(result.reasoning)
assert "valuation" in full_reasoning.lower() or "macro" in full_reasoning.lower()
# ===========================================================================
# 3. SKIP verdict — confidence below watch threshold (Requirement 5.6)
# ===========================================================================
class TestSkipVerdict:
"""SKIP: confidence < 0.55 (watch threshold)."""
def test_skip_empty_signals(self) -> None:
"""No confluence signals → confidence = 0.0 → SKIP."""
normalized = _normalized()
result = run_heuristic_pipeline(normalized, [], _default_config())
assert result.verdict == Verdict.SKIP
assert result.confidence == 0.0
assert result.s_total == result.s_macro # only macro contributes
def test_skip_single_weak_signal(self) -> None:
"""Single weak signal → low confidence → SKIP."""
signals = [_bullish_signal("fibonacci", 0.3)]
normalized = _normalized()
result = run_heuristic_pipeline(normalized, signals, _default_config())
# Single signal with score 0.3 → base_confidence=0.3, source_factor=0.6
# confidence = 0.3 * 0.6 * 1.0 = 0.18 → well below 0.55
assert result.verdict == Verdict.SKIP
assert result.confidence < 0.55
def test_skip_reasoning_mentions_threshold(self) -> None:
"""SKIP reasoning should reference the watch threshold."""
result = run_heuristic_pipeline(_normalized(), [], _default_config())
assert result.verdict == Verdict.SKIP
assert any("SKIP" in r for r in result.reasoning)
# ===========================================================================
# 4. Edge cases at threshold boundaries
# ===========================================================================
class TestThresholdEdgeCases:
"""Test behavior at exact threshold values."""
def test_confidence_exactly_at_buy_threshold(self) -> None:
"""Verify _determine_verdict with confidence exactly at 0.70."""
config = _default_config()
normalized = _normalized(valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=30)
verdict, reasoning = _determine_verdict(
confidence=0.70,
s_total=1.5,
normalized=normalized,
config=config,
)
assert verdict == Verdict.BUY
def test_confidence_just_below_buy_threshold(self) -> None:
"""confidence = 0.699 → not BUY, should be WATCH."""
config = _default_config()
normalized = _normalized(valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=30)
verdict, _ = _determine_verdict(
confidence=0.699,
s_total=1.5,
normalized=normalized,
config=config,
)
assert verdict == Verdict.WATCH
def test_confidence_exactly_at_watch_threshold(self) -> None:
"""confidence = 0.55 → WATCH (not SKIP)."""
config = _default_config()
normalized = _normalized(valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=30)
verdict, _ = _determine_verdict(
confidence=0.55,
s_total=0.5, # below BUY s_total threshold
normalized=normalized,
config=config,
)
assert verdict == Verdict.WATCH
def test_confidence_just_below_watch_threshold(self) -> None:
"""confidence = 0.549 → SKIP."""
config = _default_config()
normalized = _normalized()
verdict, _ = _determine_verdict(
confidence=0.549,
s_total=2.0,
normalized=normalized,
config=config,
)
assert verdict == Verdict.SKIP
def test_s_total_exactly_at_buy_threshold(self) -> None:
"""S_total = 1.2 exactly → BUY if all other conditions met."""
config = _default_config()
normalized = _normalized(valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=30)
verdict, _ = _determine_verdict(
confidence=0.80,
s_total=1.2,
normalized=normalized,
config=config,
)
assert verdict == Verdict.BUY
def test_s_total_just_below_buy_threshold(self) -> None:
"""S_total = 1.199 → not BUY, should be WATCH."""
config = _default_config()
normalized = _normalized(valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=30)
verdict, _ = _determine_verdict(
confidence=0.80,
s_total=1.199,
normalized=normalized,
config=config,
)
assert verdict == Verdict.WATCH
def test_valuation_exactly_at_buy_threshold(self) -> None:
"""valuation_score = 0.5 exactly → BUY if all other conditions met."""
config = _default_config()
normalized = _normalized(valuation_score=0.5, macro_bias=0.5, earnings_proximity_days=30)
verdict, _ = _determine_verdict(
confidence=0.80,
s_total=1.5,
normalized=normalized,
config=config,
)
assert verdict == Verdict.BUY
def test_valuation_just_below_buy_threshold(self) -> None:
"""valuation_score = 0.499 → not BUY."""
config = _default_config()
normalized = _normalized(valuation_score=0.499, macro_bias=0.5, earnings_proximity_days=30)
verdict, _ = _determine_verdict(
confidence=0.80,
s_total=1.5,
normalized=normalized,
config=config,
)
assert verdict == Verdict.WATCH
def test_earnings_exactly_at_threshold(self) -> None:
"""earnings_proximity_days = 5 → fails > 5 condition → WATCH."""
config = _default_config()
normalized = _normalized(valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=5)
verdict, _ = _determine_verdict(
confidence=0.80,
s_total=1.5,
normalized=normalized,
config=config,
)
assert verdict == Verdict.WATCH
def test_earnings_just_above_threshold(self) -> None:
"""earnings_proximity_days = 6 → passes > 5 condition → BUY."""
config = _default_config()
normalized = _normalized(valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=6)
verdict, _ = _determine_verdict(
confidence=0.80,
s_total=1.5,
normalized=normalized,
config=config,
)
assert verdict == Verdict.BUY
def test_none_valuation_score_treated_as_zero(self) -> None:
"""None valuation_score defaults to 0.0 → fails BUY valuation check."""
config = _default_config()
normalized = _normalized(valuation_score=None, macro_bias=0.5, earnings_proximity_days=30)
verdict, _ = _determine_verdict(
confidence=0.80,
s_total=1.5,
normalized=normalized,
config=config,
)
assert verdict == Verdict.WATCH
def test_none_earnings_proximity_treated_as_zero(self) -> None:
"""None earnings_proximity_days defaults to 0 → fails BUY earnings check."""
config = _default_config()
normalized = _normalized(
valuation_score=0.8,
macro_bias=0.5,
earnings_proximity_days=None,
)
verdict, _ = _determine_verdict(
confidence=0.80,
s_total=1.5,
normalized=normalized,
config=config,
)
assert verdict == Verdict.WATCH
# ===========================================================================
# 5. Signal agreement boosts confidence
# ===========================================================================
class TestConfidenceAgreement:
"""All signals in the same direction → agreement boost (factor 1.15)."""
def test_all_bullish_signals_boost_confidence(self) -> None:
"""All bullish signals → agreement_factor = 1.15."""
signals = [
_bullish_signal("fibonacci", 0.7),
_bullish_signal("ma_stack", 0.7),
_bullish_signal("rsi", 0.7),
]
confidence = _compute_confidence(signals)
# base = 0.7, source_factor = 1 - 0.4/3 ≈ 0.867, agreement = 1.15
# confidence = 0.7 * 0.867 * 1.15 ≈ 0.698
assert confidence > 0.0
# Compare with a mixed-direction set to verify boost
mixed_signals = [
_bullish_signal("fibonacci", 0.7),
_bearish_signal("rsi", 0.7),
_bullish_signal("ma_stack", 0.7),
]
mixed_confidence = _compute_confidence(mixed_signals)
assert confidence > mixed_confidence
def test_all_bearish_signals_boost_confidence(self) -> None:
"""All bearish signals → agreement_factor = 1.15 (same boost)."""
signals = [
_bearish_signal("fibonacci", 0.7),
_bearish_signal("ma_stack", 0.7),
]
confidence = _compute_confidence(signals)
# base = 0.7, source_factor = 1 - 0.4/2 = 0.8, agreement = 1.15
# confidence = 0.7 * 0.8 * 1.15 = 0.644
assert confidence > 0.6
def test_single_signal_no_agreement_factor(self) -> None:
"""Single signal → agreement_factor = 1.0 (no boost or penalty)."""
signals = [_bullish_signal("fibonacci", 0.8)]
confidence = _compute_confidence(signals)
# base = 0.8, source_factor = 1 - 0.4/1 = 0.6, agreement = 1.0
# confidence = 0.8 * 0.6 * 1.0 = 0.48
assert abs(confidence - 0.48) < 0.001
def test_directional_plus_neutral_mild_boost(self) -> None:
"""Mix of directional and neutral signals → agreement_factor = 1.05."""
signals = [
_bullish_signal("fibonacci", 0.7),
_neutral_signal("elliott_wave", 0.7),
]
confidence = _compute_confidence(signals)
# base = 0.7, source_factor = 1 - 0.4/2 = 0.8, agreement = 1.05
# confidence = 0.7 * 0.8 * 1.05 = 0.588
assert abs(confidence - 0.588) < 0.001
# ===========================================================================
# 6. Contradicting signals reduce confidence
# ===========================================================================
class TestConfidenceContradiction:
"""Mixed bullish/bearish signals → contradiction penalty."""
def test_contradiction_reduces_confidence(self) -> None:
"""Bullish + bearish signals → penalty proportional to minority fraction."""
contradicting = [
_bullish_signal("fibonacci", 0.7),
_bearish_signal("rsi", 0.7),
]
agreeing = [
_bullish_signal("fibonacci", 0.7),
_bullish_signal("rsi", 0.7),
]
conf_contradicting = _compute_confidence(contradicting)
conf_agreeing = _compute_confidence(agreeing)
assert conf_contradicting < conf_agreeing
def test_more_contradiction_more_penalty(self) -> None:
"""Higher minority fraction → larger penalty."""
# 1 bearish out of 3 → minority = 1/3
mild_contradiction = [
_bullish_signal("fibonacci", 0.7),
_bullish_signal("ma_stack", 0.7),
_bearish_signal("rsi", 0.7),
]
# 2 bearish out of 4 → minority = 2/4 = 0.5
strong_contradiction = [
_bullish_signal("fibonacci", 0.7),
_bullish_signal("ma_stack", 0.7),
_bearish_signal("rsi", 0.7),
_bearish_signal("elliott_wave", 0.7),
]
conf_mild = _compute_confidence(mild_contradiction)
conf_strong = _compute_confidence(strong_contradiction)
# Strong contradiction should have lower confidence per-signal
# (accounting for source count factor difference)
# mild: agreement = 1 - 0.3*(1/3) = 0.9
# strong: agreement = 1 - 0.3*(2/4) = 0.85
# The agreement factor is lower for strong contradiction
mild_agreement = 1.0 - 0.3 * (1 / 3)
strong_agreement = 1.0 - 0.3 * (2 / 4)
assert strong_agreement < mild_agreement
def test_equal_split_maximum_penalty(self) -> None:
"""50/50 split → maximum contradiction penalty (0.3 * 0.5 = 0.15)."""
signals = [
_bullish_signal("fibonacci", 0.7),
_bearish_signal("rsi", 0.7),
]
confidence = _compute_confidence(signals)
# base = 0.7, source_factor = 0.8, agreement = 1 - 0.3*0.5 = 0.85
# confidence = 0.7 * 0.8 * 0.85 = 0.476
assert abs(confidence - 0.476) < 0.001
# ===========================================================================
# 7. Empty confluence signals → SKIP
# ===========================================================================
class TestEmptySignals:
"""Empty confluence signals produce confidence = 0 → SKIP."""
def test_empty_signals_confidence_zero(self) -> None:
"""No signals → confidence = 0.0."""
assert _compute_confidence([]) == 0.0
def test_empty_signals_skip_verdict(self) -> None:
"""No signals → SKIP regardless of fundamentals."""
normalized = _normalized(valuation_score=1.0, macro_bias=1.0, earnings_proximity_days=100)
result = run_heuristic_pipeline(normalized, [], _default_config())
assert result.verdict == Verdict.SKIP
assert result.confidence == 0.0
def test_empty_signals_s_total_only_macro(self) -> None:
"""No signals → S_company = 0, S_competitive = 0, S_total = S_macro only."""
normalized = _normalized(macro_bias=0.6)
result = run_heuristic_pipeline(normalized, [], _default_config())
assert result.s_company == 0.0
assert result.s_competitive == 0.0
assert result.s_macro == 0.6 * 0.5 # macro_bias * _MACRO_WEIGHT
assert result.s_total == result.s_macro
# ===========================================================================
# 8. S_total computation from multiple signals
# ===========================================================================
class TestSTotalComputation:
"""S_total = S_company + S_macro + S_competitive."""
def test_s_company_sums_company_signals(self) -> None:
"""Company-level signals (fibonacci, ma_stack, rsi) contribute to S_company."""
signals = [
_bullish_signal("fibonacci", 0.5),
_bullish_signal("ma_stack", 0.3),
]
s_company, weights = _compute_s_company(signals)
# Both bullish → positive contributions
assert s_company == 0.5 + 0.3
assert len(weights) == 2
assert all(w["layer"] == "company" for w in weights)
def test_s_company_bearish_signals_subtract(self) -> None:
"""Bearish company signals contribute negatively to S_company."""
signals = [
_bullish_signal("fibonacci", 0.5),
_bearish_signal("rsi", 0.3),
]
s_company, weights = _compute_s_company(signals)
# fibonacci: +0.5, rsi: -0.3
assert abs(s_company - 0.2) < 0.001
def test_s_company_neutral_signals_zero_contribution(self) -> None:
"""Neutral company signals contribute 0 to S_company."""
signals = [
ConfluenceSignal(
signal_type="fibonacci",
direction=SignalDirection.NEUTRAL,
confluence_score=0.8,
active_timeframes=["D", "W"],
per_timeframe={"D": 0.8, "W": 0.8},
),
]
s_company, _ = _compute_s_company(signals)
assert s_company == 0.0
def test_s_company_ignores_non_company_signals(self) -> None:
"""Signals not in COMPANY_SIGNAL_TYPES are ignored for S_company."""
signals = [
_bullish_signal("unknown_signal_type", 0.9),
]
s_company, weights = _compute_s_company(signals)
assert s_company == 0.0
assert len(weights) == 0
def test_s_macro_positive_bias(self) -> None:
"""Positive macro_bias → positive S_macro."""
normalized = _normalized(macro_bias=0.8)
s_macro = _compute_s_macro(normalized)
assert s_macro == 0.8 * 0.5 # macro_bias * _MACRO_WEIGHT
def test_s_macro_negative_bias(self) -> None:
"""Negative macro_bias → negative S_macro."""
normalized = _normalized(macro_bias=-0.6)
s_macro = _compute_s_macro(normalized)
assert s_macro == -0.6 * 0.5
def test_s_macro_zero_bias(self) -> None:
"""Zero macro_bias → zero S_macro."""
normalized = _normalized(macro_bias=0.0)
s_macro = _compute_s_macro(normalized)
assert s_macro == 0.0
def test_s_competitive_currently_zero(self) -> None:
"""No competitive signal types defined → S_competitive = 0."""
signals = [_bullish_signal("fibonacci", 0.9)]
s_competitive = _compute_s_competitive(signals)
assert s_competitive == 0.0
def test_s_total_is_sum_of_components(self) -> None:
"""S_total = S_company + S_macro + S_competitive."""
signals = [
_bullish_signal("fibonacci", 0.5),
_bullish_signal("ma_stack", 0.4),
]
normalized = _normalized(macro_bias=0.6)
result = run_heuristic_pipeline(normalized, signals, _default_config())
expected_s_company = 0.5 + 0.4
expected_s_macro = 0.6 * 0.5
expected_s_competitive = 0.0
expected_s_total = expected_s_company + expected_s_macro + expected_s_competitive
assert abs(result.s_company - expected_s_company) < 0.001
assert abs(result.s_macro - expected_s_macro) < 0.001
assert abs(result.s_competitive - expected_s_competitive) < 0.001
assert abs(result.s_total - expected_s_total) < 0.001
def test_signal_weights_audit_trail(self) -> None:
"""signal_weights list contains per-signal audit info."""
signals = [
_bullish_signal("fibonacci", 0.5),
_bullish_signal("rsi", 0.3),
]
result = run_heuristic_pipeline(_normalized(), signals, _default_config())
assert len(result.signal_weights) == 2
types = {w["signal_type"] for w in result.signal_weights}
assert "fibonacci" in types
assert "rsi" in types
for w in result.signal_weights:
assert "contribution" in w
assert "direction" in w
assert "active_timeframes" in w
# ===========================================================================
# 9. Full pipeline integration — HeuristicResult structure
# ===========================================================================
class TestHeuristicResultStructure:
"""Verify the HeuristicResult has all required fields."""
def test_result_has_all_fields(self) -> None:
"""HeuristicResult contains verdict, confidence, scores, weights, reasoning."""
signals = [_bullish_signal("fibonacci", 0.7)]
result = run_heuristic_pipeline(_normalized(), signals, _default_config())
assert result.verdict in (Verdict.BUY, Verdict.WATCH, Verdict.SKIP)
assert 0.0 <= result.confidence <= 1.0
assert isinstance(result.s_total, float)
assert isinstance(result.s_company, float)
assert isinstance(result.s_macro, float)
assert isinstance(result.s_competitive, float)
assert isinstance(result.signal_weights, list)
assert isinstance(result.reasoning, list)
assert len(result.reasoning) > 0
def test_confidence_clamped_to_unit_interval(self) -> None:
"""Confidence is always in [0.0, 1.0] even with strong agreement boost."""
# Very high confluence scores with perfect agreement
signals = [
_bullish_signal("fibonacci", 1.0),
_bullish_signal("ma_stack", 1.0),
_bullish_signal("rsi", 1.0),
_bullish_signal("cup_handle", 1.0),
_bullish_signal("elliott_wave", 1.0),
]
confidence = _compute_confidence(signals)
assert 0.0 <= confidence <= 1.0
# ===========================================================================
# 10. Custom config thresholds
# ===========================================================================
class TestCustomConfig:
"""Verify that custom config thresholds are respected."""
def test_custom_buy_confidence_threshold(self) -> None:
"""Lowering buy_confidence makes BUY easier to achieve."""
config = HeuristicConfig(buy_confidence=0.50, buy_s_total=0.5)
normalized = _normalized(valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=30)
verdict, _ = _determine_verdict(
confidence=0.55,
s_total=0.8,
normalized=normalized,
config=config,
)
assert verdict == Verdict.BUY
def test_custom_watch_confidence_threshold(self) -> None:
"""Raising watch_confidence makes WATCH harder to achieve."""
config = HeuristicConfig(watch_confidence=0.80)
normalized = _normalized()
verdict, _ = _determine_verdict(
confidence=0.75,
s_total=0.5,
normalized=normalized,
config=config,
)
assert verdict == Verdict.SKIP # 0.75 < 0.80 watch threshold
def test_custom_earnings_threshold(self) -> None:
"""Custom earnings_days_threshold changes BUY gating."""
config = HeuristicConfig(earnings_days_threshold=10)
normalized = _normalized(
valuation_score=0.8,
macro_bias=0.5,
earnings_proximity_days=8, # > 5 default but <= 10 custom
)
verdict, _ = _determine_verdict(
confidence=0.80,
s_total=1.5,
normalized=normalized,
config=config,
)
assert verdict == Verdict.WATCH # 8 is not > 10
+237
View File
@@ -0,0 +1,237 @@
"""Unit tests for services.signal_engine.signals.ma_stack — Moving average stack evaluator.
Requirements: 2.2, 2.6, 2.7
"""
from __future__ import annotations
from datetime import datetime, timezone
from services.signal_engine.models import OHLCVBar, SignalDirection
from services.signal_engine.signals.ma_stack import (
MA_PERIODS,
MIN_BARS,
MAStackEvaluator,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _bar(close: float) -> OHLCVBar:
"""Create a minimal OHLCVBar for testing."""
return OHLCVBar(
timestamp=datetime(2024, 1, 1, tzinfo=timezone.utc),
open=close,
high=close,
low=close,
close=close,
volume=1000.0,
)
def _make_bars(n: int, close: float = 100.0) -> list[OHLCVBar]:
"""Create *n* identical bars with the same close price."""
return [_bar(close) for _ in range(n)]
def _trending_bars(n: int, start: float, step: float) -> list[OHLCVBar]:
"""Create *n* bars with linearly increasing/decreasing close prices.
``start`` is the first bar's close; each subsequent bar adds ``step``.
"""
return [_bar(start + i * step) for i in range(n)]
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
def test_ma_periods_constant() -> None:
assert MA_PERIODS == [10, 20, 50, 200]
def test_min_bars_constant() -> None:
assert MIN_BARS == 200
# ---------------------------------------------------------------------------
# Insufficient data → None
# ---------------------------------------------------------------------------
def test_returns_none_when_insufficient_bars() -> None:
"""Requirement 2.6: return None when fewer bars than 200."""
evaluator = MAStackEvaluator()
bars = _make_bars(199)
assert evaluator.evaluate(bars, "D") is None
def test_returns_none_with_empty_bars() -> None:
evaluator = MAStackEvaluator()
assert evaluator.evaluate([], "D") is None
def test_returns_none_with_one_bar() -> None:
evaluator = MAStackEvaluator()
assert evaluator.evaluate([_bar(100.0)], "D") is None
# ---------------------------------------------------------------------------
# No alignment → None
# ---------------------------------------------------------------------------
def test_returns_none_when_all_mas_equal() -> None:
"""When all bars have the same close, all MAs are equal — no alignment."""
evaluator = MAStackEvaluator()
bars = _make_bars(200, close=100.0)
assert evaluator.evaluate(bars, "D") is None
# ---------------------------------------------------------------------------
# Full bullish alignment
# ---------------------------------------------------------------------------
def test_full_bullish_alignment() -> None:
"""Requirement 2.2: MA_10 > MA_20 > MA_50 > MA_200 → bullish, strength 1.0."""
evaluator = MAStackEvaluator()
# Strongly uptrending: recent prices much higher than old prices
# This ensures MA_10 > MA_20 > MA_50 > MA_200
bars = _trending_bars(200, start=50.0, step=1.0)
result = evaluator.evaluate(bars, "D")
assert result is not None
assert result.signal_type == "ma_stack"
assert result.direction == SignalDirection.BULLISH
assert result.strength == 1.0
assert result.confidence == 1.0 * 0.9
assert result.metadata["alignment"] == "full_bullish"
# ---------------------------------------------------------------------------
# Full bearish alignment
# ---------------------------------------------------------------------------
def test_full_bearish_alignment() -> None:
"""Requirement 2.2: MA_10 < MA_20 < MA_50 < MA_200 → bearish, strength 1.0."""
evaluator = MAStackEvaluator()
# Strongly downtrending: recent prices much lower than old prices
bars = _trending_bars(200, start=250.0, step=-1.0)
result = evaluator.evaluate(bars, "D")
assert result is not None
assert result.signal_type == "ma_stack"
assert result.direction == SignalDirection.BEARISH
assert result.strength == 1.0
assert result.confidence == 1.0 * 0.9
assert result.metadata["alignment"] == "full_bearish"
# ---------------------------------------------------------------------------
# Partial bullish alignment (3/4)
# ---------------------------------------------------------------------------
def test_partial_bullish_alignment() -> None:
"""3 out of 4 MAs in bullish order → strength 0.6."""
evaluator = MAStackEvaluator()
# Build bars where MA_10 > MA_20 > MA_50 but MA_50 < MA_200
# Use flat early prices (high MA_200) then a moderate uptrend at the end
bars = _make_bars(150, close=200.0) + _trending_bars(50, start=100.0, step=2.0)
result = evaluator.evaluate(bars, "D")
# The recent uptrend should give MA_10 > MA_20 > MA_50
# but MA_200 includes the high early prices, so MA_50 < MA_200
if result is not None:
assert result.direction == SignalDirection.BULLISH
assert result.strength == 0.6
assert result.confidence == 0.6 * 0.9
assert result.metadata["alignment"] == "partial_bullish"
# ---------------------------------------------------------------------------
# Partial bearish alignment (3/4)
# ---------------------------------------------------------------------------
def test_partial_bearish_alignment() -> None:
"""3 out of 4 MAs in bearish order → strength 0.6."""
evaluator = MAStackEvaluator()
# Build bars where MA_10 < MA_20 < MA_50 but MA_50 > MA_200
# Use flat low early prices (low MA_200) then a moderate downtrend at the end
bars = _make_bars(150, close=50.0) + _trending_bars(50, start=200.0, step=-2.0)
result = evaluator.evaluate(bars, "D")
if result is not None:
assert result.direction == SignalDirection.BEARISH
assert result.strength == 0.6
assert result.confidence == 0.6 * 0.9
assert result.metadata["alignment"] == "partial_bearish"
# ---------------------------------------------------------------------------
# Signal result structure (Requirement 2.7)
# ---------------------------------------------------------------------------
def test_signal_result_structure() -> None:
"""Requirement 2.7: SignalResult has all required fields."""
evaluator = MAStackEvaluator()
bars = _trending_bars(200, start=50.0, step=1.0)
result = evaluator.evaluate(bars, "H4")
assert result is not None
assert result.signal_type == "ma_stack"
assert result.timeframe == "H4"
assert 0.0 <= result.strength <= 1.0
assert 0.0 <= result.confidence <= 1.0
assert result.direction in (SignalDirection.BULLISH, SignalDirection.BEARISH)
def test_metadata_contains_all_ma_values() -> None:
"""Metadata should include all four MA values and alignment type."""
evaluator = MAStackEvaluator()
bars = _trending_bars(200, start=50.0, step=1.0)
result = evaluator.evaluate(bars, "D")
assert result is not None
meta = result.metadata
assert "ma_10" in meta
assert "ma_20" in meta
assert "ma_50" in meta
assert "ma_200" in meta
assert "alignment" in meta
# ---------------------------------------------------------------------------
# Timeframe passthrough
# ---------------------------------------------------------------------------
def test_timeframe_passthrough() -> None:
"""The timeframe label is passed through to the result."""
evaluator = MAStackEvaluator()
bars = _trending_bars(200, start=50.0, step=1.0)
for tf in ("M30", "H1", "H4", "D", "W", "M"):
result = evaluator.evaluate(bars, tf)
assert result is not None
assert result.timeframe == tf
# ---------------------------------------------------------------------------
# Exactly 200 bars (boundary)
# ---------------------------------------------------------------------------
def test_exactly_200_bars_works() -> None:
"""Exactly 200 bars should be sufficient (boundary condition)."""
evaluator = MAStackEvaluator()
bars = _trending_bars(200, start=50.0, step=1.0)
result = evaluator.evaluate(bars, "D")
assert result is not None
def test_199_bars_returns_none() -> None:
"""199 bars is insufficient."""
evaluator = MAStackEvaluator()
bars = _trending_bars(199, start=50.0, step=1.0)
result = evaluator.evaluate(bars, "D")
assert result is None
+419
View File
@@ -0,0 +1,419 @@
"""Unit tests for services.signal_engine.normalizer.
Tests the input normalizer's data assembly, sentinel handling, timestamp
validation, and derived field computation.
Requirements: 1.1, 1.2, 1.3, 1.4, 1.5
"""
from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock
import pytest
from services.signal_engine.config import SignalEngineConfig
from services.signal_engine.models import OHLCVBar
from services.signal_engine.normalizer import (
TIMEFRAMES,
_aggregate_bars_by_period,
_polygon_bar_to_ohlcv,
_validate_monotonic_timestamps,
normalize_input,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_bar_row(ts_ms: int, o: float, h: float, l: float, c: float, v: float) -> MagicMock:
"""Create a mock asyncpg.Record with Polygon bar data."""
row = MagicMock()
row.__getitem__ = lambda self, key: {
"data": {"t": ts_ms, "o": o, "h": h, "l": l, "c": c, "v": v},
}[key]
return row
def _make_bar(ts_ms: int, c: float = 100.0) -> OHLCVBar:
"""Create an OHLCVBar with a given timestamp and close price."""
return OHLCVBar(
timestamp=datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc),
open=c - 1,
high=c + 1,
low=c - 2,
close=c,
volume=1000.0,
)
# ---------------------------------------------------------------------------
# _polygon_bar_to_ohlcv
# ---------------------------------------------------------------------------
class TestPolygonBarToOhlcv:
def test_valid_bar(self):
row = _make_bar_row(1700000000000, 100.0, 105.0, 99.0, 103.0, 5000.0)
bar = _polygon_bar_to_ohlcv(row)
assert bar is not None
assert bar.open == 100.0
assert bar.high == 105.0
assert bar.low == 99.0
assert bar.close == 103.0
assert bar.volume == 5000.0
assert bar.timestamp.year == 2023
def test_missing_timestamp_returns_none(self):
row = MagicMock()
row.__getitem__ = lambda self, key: {"data": {"o": 1, "h": 2, "l": 0, "c": 1, "v": 10}}[key]
assert _polygon_bar_to_ohlcv(row) is None
def test_non_dict_data_returns_none(self):
row = MagicMock()
row.__getitem__ = lambda self, key: {"data": "not a dict"}[key]
assert _polygon_bar_to_ohlcv(row) is None
# ---------------------------------------------------------------------------
# _validate_monotonic_timestamps
# ---------------------------------------------------------------------------
class TestValidateMonotonicTimestamps:
def test_already_monotonic(self):
bars = [_make_bar(1000 * i) for i in [1000, 2000, 3000]]
result = _validate_monotonic_timestamps(bars, "D", "AAPL")
assert result is bars # same reference — no sorting needed
def test_non_monotonic_gets_sorted(self):
bars = [_make_bar(1000 * i) for i in [3000, 1000, 2000]]
result = _validate_monotonic_timestamps(bars, "D", "AAPL")
timestamps = [b.timestamp for b in result]
assert timestamps == sorted(timestamps)
def test_single_bar(self):
bars = [_make_bar(1000000)]
result = _validate_monotonic_timestamps(bars, "D", "AAPL")
assert len(result) == 1
def test_empty_list(self):
result = _validate_monotonic_timestamps([], "D", "AAPL")
assert result == []
# ---------------------------------------------------------------------------
# _aggregate_bars_by_period
# ---------------------------------------------------------------------------
class TestAggregate:
def test_weekly_aggregation(self):
# Create 10 daily bars spanning ~2 weeks
from datetime import timedelta
base = datetime(2024, 1, 1, tzinfo=timezone.utc)
daily = []
for i in range(10):
ts = base + timedelta(days=i)
daily.append(
OHLCVBar(
timestamp=ts,
open=100.0 + i,
high=110.0 + i,
low=90.0 + i,
close=105.0 + i,
volume=1000.0,
)
)
weekly = _aggregate_bars_by_period(daily, "week")
assert len(weekly) >= 2 # should span at least 2 ISO weeks
# Each weekly bar should have correct OHLCV aggregation
for w in weekly:
assert w.volume >= 1000.0 # at least one day's volume
def test_monthly_aggregation(self):
from datetime import timedelta
base = datetime(2024, 1, 15, tzinfo=timezone.utc)
daily = []
for i in range(45): # spans Jan and Feb
ts = base + timedelta(days=i)
daily.append(
OHLCVBar(
timestamp=ts,
open=100.0,
high=110.0,
low=90.0,
close=105.0,
volume=500.0,
)
)
monthly = _aggregate_bars_by_period(daily, "month")
assert len(monthly) >= 2 # should span at least 2 months
def test_empty_input(self):
assert _aggregate_bars_by_period([], "week") == []
# ---------------------------------------------------------------------------
# normalize_input — integration with mocked DB
# ---------------------------------------------------------------------------
class TestNormalizeInput:
"""Test the full normalize_input function with mocked asyncpg pool."""
@pytest.fixture
def config(self):
return SignalEngineConfig()
@pytest.fixture
def mock_pool(self):
pool = AsyncMock()
return pool
def _setup_pool_with_data(self, pool):
"""Configure the mock pool to return realistic data."""
ts_base = 1700000000000 # Nov 2023
# Daily bars
daily_rows = []
for i in range(5):
row = MagicMock()
data = {
"t": ts_base + i * 86400000,
"o": 100.0 + i,
"h": 105.0 + i,
"l": 98.0 + i,
"c": 103.0 + i,
"v": 10000.0,
}
row.__getitem__ = lambda self, key, d=data: {"data": d}[key]
daily_rows.append(row)
# Trend window row
trend_row = MagicMock()
trend_row.__getitem__ = lambda self, key: {"confidence": 0.75}[key]
# Earnings row
from datetime import date, timedelta
future_date = date.today() + timedelta(days=30)
earnings_row = MagicMock()
earnings_row.__getitem__ = lambda self, key: {"earnings_date": future_date}[key]
# Macro impact rows
macro_rows = []
for direction in ["positive", "positive", "negative"]:
row = MagicMock()
row.__getitem__ = lambda self, key, d=direction: {
"impact_direction": d,
"macro_impact_score": 0.5,
"confidence": 0.8,
}[key]
macro_rows.append(row)
# Position rows — empty
position_rows = []
# Configure pool.fetch / pool.fetchrow responses
call_count = {"fetch": 0, "fetchrow": 0}
async def mock_fetch(query, *args):
q = query.strip().lower()
if "market_snapshots" in q and "snapshot_type = 'bar'" in q:
return daily_rows
if "market_snapshots" in q and "intraday_bar" in q:
return []
if "macro_impact_records" in q:
return macro_rows
if "position_stop_levels" in q:
return position_rows
return []
async def mock_fetchrow(query, *args):
q = query.strip().lower()
if "trend_windows" in q:
return trend_row
if "earnings_calendar" in q:
return earnings_row
return None
pool.fetch = mock_fetch
pool.fetchrow = mock_fetchrow
@pytest.mark.asyncio
async def test_full_normalization(self, mock_pool, config):
self._setup_pool_with_data(mock_pool)
result = await normalize_input(mock_pool, "AAPL", config)
assert result.ticker == "AAPL"
assert result.evaluated_at is not None
assert len(result.bars["D"]) == 5
assert result.valuation_score == 0.75
assert result.earnings_proximity_days is not None
assert result.earnings_proximity_days > 0
assert result.macro_bias != 0.0 # should be positive-leaning
assert result.open_positions == []
assert len(result.closing_prices) == 5
assert len(result.returns) == 4 # n-1 returns
assert result.current_price is not None
@pytest.mark.asyncio
async def test_sentinel_values_on_empty_data(self, mock_pool, config):
"""When all data sources return empty, sentinels are used."""
async def empty_fetch(query, *args):
return []
async def empty_fetchrow(query, *args):
return None
mock_pool.fetch = empty_fetch
mock_pool.fetchrow = empty_fetchrow
result = await normalize_input(mock_pool, "UNKNOWN", config)
assert result.ticker == "UNKNOWN"
assert all(result.bars[tf] == [] for tf in TIMEFRAMES)
assert result.valuation_score is None
assert result.earnings_proximity_days is None
assert result.macro_bias == 0.0
assert result.open_positions == []
assert result.closing_prices == []
assert result.returns == []
assert result.current_price is None
@pytest.mark.asyncio
async def test_db_errors_produce_sentinels(self, mock_pool, config):
"""When DB queries raise exceptions, sentinels are used."""
async def failing_fetch(query, *args):
raise Exception("DB connection lost")
async def failing_fetchrow(query, *args):
raise Exception("DB connection lost")
mock_pool.fetch = failing_fetch
mock_pool.fetchrow = failing_fetchrow
result = await normalize_input(mock_pool, "FAIL", config)
assert result.ticker == "FAIL"
assert all(result.bars[tf] == [] for tf in TIMEFRAMES)
assert result.valuation_score is None
assert result.earnings_proximity_days is None
assert result.macro_bias == 0.0
assert result.open_positions == []
assert result.current_price is None
@pytest.mark.asyncio
async def test_weekly_monthly_derived_from_daily(self, mock_pool, config):
"""Weekly and monthly bars are derived from daily bars."""
ts_base = 1700000000000
daily_rows = []
for i in range(30): # 30 days of data
row = MagicMock()
data = {
"t": ts_base + i * 86400000,
"o": 100.0,
"h": 110.0,
"l": 90.0,
"c": 105.0,
"v": 1000.0,
}
row.__getitem__ = lambda self, key, d=data: {"data": d}[key]
daily_rows.append(row)
async def mock_fetch(query, *args):
q = query.strip().lower()
if "market_snapshots" in q and "snapshot_type = 'bar'" in q:
return daily_rows
return []
async def mock_fetchrow(query, *args):
return None
mock_pool.fetch = mock_fetch
mock_pool.fetchrow = mock_fetchrow
result = await normalize_input(mock_pool, "AAPL", config)
assert len(result.bars["D"]) == 30
assert len(result.bars["W"]) > 0 # weekly derived
assert len(result.bars["M"]) > 0 # monthly derived
@pytest.mark.asyncio
async def test_current_price_from_shortest_timeframe(self, mock_pool, config):
"""current_price comes from the shortest available timeframe."""
ts_base = 1700000000000
# Only provide intraday bars (M30), no daily
intraday_rows = []
for i in range(3):
row = MagicMock()
data = {
"t": ts_base + i * 1800000, # 30-min intervals
"o": 100.0,
"h": 110.0,
"l": 90.0,
"c": 150.0 + i, # last close = 152.0
"v": 500.0,
}
row.__getitem__ = lambda self, key, d=data: {"data": d}[key]
intraday_rows.append(row)
async def mock_fetch(query, *args):
q = query.strip().lower()
if "intraday_bar" in q:
return intraday_rows
return []
async def mock_fetchrow(query, *args):
return None
mock_pool.fetch = mock_fetch
mock_pool.fetchrow = mock_fetchrow
result = await normalize_input(mock_pool, "AAPL", config)
# M30 is the shortest timeframe and has data
assert result.current_price == 152.0
@pytest.mark.asyncio
async def test_macro_bias_computation(self, mock_pool, config):
"""macro_bias is a weighted average of direction scores."""
macro_rows = []
# 2 positive, 1 negative — should lean positive
for direction, score, conf in [
("positive", 0.8, 0.9),
("positive", 0.6, 0.7),
("negative", 0.3, 0.5),
]:
row = MagicMock()
row.__getitem__ = lambda self, key, d=direction, s=score, c=conf: {
"impact_direction": d,
"macro_impact_score": s,
"confidence": c,
}[key]
macro_rows.append(row)
async def mock_fetch(query, *args):
if "macro_impact_records" in query:
return macro_rows
return []
async def mock_fetchrow(query, *args):
return None
mock_pool.fetch = mock_fetch
mock_pool.fetchrow = mock_fetchrow
result = await normalize_input(mock_pool, "AAPL", config)
# Weighted: pos(0.8*0.9=0.72) + pos(0.6*0.7=0.42) + neg(0.3*0.5=0.15)
# = (1.0*0.72 + 1.0*0.42 + (-1.0)*0.15) / (0.72+0.42+0.15)
# = (0.72 + 0.42 - 0.15) / 1.29 ≈ 0.767
assert result.macro_bias > 0.0
assert result.macro_bias <= 1.0
+358
View File
@@ -0,0 +1,358 @@
"""Unit tests for the probabilistic (Bayesian) pipeline.
Tests cover:
- Regime-to-prior mapping
- Likelihood ratio computation
- Log-odds accumulation and sigmoid round-trip
- Shannon entropy computation
- Entropy gating (SKIP on high entropy)
- EV_R computation
- BUY / WATCH / SKIP verdict thresholds
- Edge cases (no signals, boundary values)
Requirements: 6.16.9, 14.114.5
"""
from __future__ import annotations
from datetime import datetime, timezone
from services.aggregation.regime import MarketRegime, RegimeClassification
from services.signal_engine.config import ProbabilisticConfig
from services.signal_engine.models import (
ConfluenceSignal,
NormalizedInput,
SignalDirection,
Verdict,
)
from services.signal_engine.probabilistic import (
_compute_ev_r,
_compute_likelihood_ratios,
_logit,
_regime_to_prior,
_shannon_entropy,
_sigmoid,
run_probabilistic_pipeline,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_normalized(
macro_bias: float = 0.5,
valuation_score: float | None = 0.7,
earnings_proximity_days: int | None = 30,
) -> NormalizedInput:
return NormalizedInput(
ticker="TEST",
evaluated_at=datetime.now(tz=timezone.utc),
bars={},
macro_bias=macro_bias,
valuation_score=valuation_score,
earnings_proximity_days=earnings_proximity_days,
)
def _make_regime(
regime: MarketRegime = MarketRegime.TREND_FOLLOWING,
trend_indicator: float = 1.0,
) -> RegimeClassification:
return RegimeClassification(
regime=regime,
trend_indicator=trend_indicator,
volatility_ratio=1.0,
bullish_threshold=0.15,
bearish_threshold=-0.15,
contradiction_penalty_multiplier=0.4,
)
def _make_confluence(
signal_type: str = "fibonacci",
direction: SignalDirection = SignalDirection.BULLISH,
confluence_score: float = 0.8,
active_timeframes: list[str] | None = None,
) -> ConfluenceSignal:
if active_timeframes is None:
active_timeframes = ["D", "W"]
return ConfluenceSignal(
signal_type=signal_type,
direction=direction,
confluence_score=confluence_score,
active_timeframes=active_timeframes,
per_timeframe={tf: confluence_score for tf in active_timeframes},
)
DEFAULT_CONFIG = ProbabilisticConfig()
# ---------------------------------------------------------------------------
# Regime → prior mapping
# ---------------------------------------------------------------------------
class TestRegimeToPrior:
def test_trend_following_bullish(self):
regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=1.0)
assert _regime_to_prior(regime, DEFAULT_CONFIG) == 0.58
def test_trend_following_bearish(self):
regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=-1.0)
assert _regime_to_prior(regime, DEFAULT_CONFIG) == 0.42
def test_trend_following_zero_indicator(self):
"""Zero trend_indicator is not positive → bear prior."""
regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=0.0)
assert _regime_to_prior(regime, DEFAULT_CONFIG) == 0.42
def test_mean_reversion(self):
regime = _make_regime(MarketRegime.MEAN_REVERSION)
assert _regime_to_prior(regime, DEFAULT_CONFIG) == 0.50
def test_panic(self):
regime = _make_regime(MarketRegime.PANIC)
assert _regime_to_prior(regime, DEFAULT_CONFIG) == 0.42
def test_uncertainty(self):
regime = _make_regime(MarketRegime.UNCERTAINTY)
assert _regime_to_prior(regime, DEFAULT_CONFIG) == 0.50
# ---------------------------------------------------------------------------
# Logit / sigmoid helpers
# ---------------------------------------------------------------------------
class TestLogitSigmoid:
def test_logit_sigmoid_round_trip(self):
for p in [0.1, 0.25, 0.5, 0.75, 0.9]:
assert abs(_sigmoid(_logit(p)) - p) < 1e-10
def test_logit_at_half(self):
assert abs(_logit(0.5)) < 1e-10
def test_sigmoid_at_zero(self):
assert abs(_sigmoid(0.0) - 0.5) < 1e-10
def test_sigmoid_large_positive(self):
assert _sigmoid(1000) == 1.0
def test_sigmoid_large_negative(self):
assert _sigmoid(-1000) == 0.0
# ---------------------------------------------------------------------------
# Shannon entropy
# ---------------------------------------------------------------------------
class TestShannonEntropy:
def test_max_at_half(self):
assert abs(_shannon_entropy(0.5) - 1.0) < 1e-10
def test_zero_at_boundaries(self):
assert _shannon_entropy(0.0) == 0.0
assert _shannon_entropy(1.0) == 0.0
def test_symmetric(self):
assert abs(_shannon_entropy(0.3) - _shannon_entropy(0.7)) < 1e-10
def test_in_range(self):
for p in [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]:
h = _shannon_entropy(p)
assert 0.0 <= h <= 1.0
# ---------------------------------------------------------------------------
# Likelihood ratio computation
# ---------------------------------------------------------------------------
class TestLikelihoodRatios:
def test_bullish_signal_produces_lr_gt_1(self):
sig = _make_confluence(direction=SignalDirection.BULLISH, confluence_score=0.8)
lrs = _compute_likelihood_ratios([sig])
assert len(lrs) == 1
assert lrs[0].lr > 1.0
assert lrs[0].log_lr > 0.0
def test_bearish_signal_produces_lr_lt_1(self):
sig = _make_confluence(direction=SignalDirection.BEARISH, confluence_score=0.8)
lrs = _compute_likelihood_ratios([sig])
assert len(lrs) == 1
assert lrs[0].lr < 1.0
assert lrs[0].log_lr < 0.0
def test_neutral_signal_produces_lr_gt_1(self):
"""Neutral signals still contribute based on strength."""
sig = _make_confluence(direction=SignalDirection.NEUTRAL, confluence_score=0.8)
lrs = _compute_likelihood_ratios([sig])
assert len(lrs) == 1
# Neutral is treated as bullish evidence (no inversion)
assert lrs[0].lr > 1.0
def test_empty_signals(self):
lrs = _compute_likelihood_ratios([])
assert lrs == []
def test_cluster_assignment(self):
sig = _make_confluence(signal_type="ma_stack")
lrs = _compute_likelihood_ratios([sig])
assert lrs[0].cluster == "momentum"
# ---------------------------------------------------------------------------
# EV_R computation
# ---------------------------------------------------------------------------
class TestEvR:
def test_ev_r_high_p_up(self):
signals = [_make_confluence(confluence_score=0.8)]
ev_r = _compute_ev_r(0.8, signals)
# E[win_R] = 0.8 * 2.0 = 1.6
# EV_R = 0.8 * 1.6 - 0.2 * 1.0 = 1.28 - 0.2 = 1.08
assert abs(ev_r - 1.08) < 1e-10
def test_ev_r_at_half(self):
signals = [_make_confluence(confluence_score=0.5)]
ev_r = _compute_ev_r(0.5, signals)
# E[win_R] = 0.5 * 2.0 = 1.0
# EV_R = 0.5 * 1.0 - 0.5 * 1.0 = 0.0
assert abs(ev_r) < 1e-10
def test_ev_r_no_signals(self):
ev_r = _compute_ev_r(0.7, [])
# E[win_R] = 1.0 (fallback)
# EV_R = 0.7 * 1.0 - 0.3 * 1.0 = 0.4
assert abs(ev_r - 0.4) < 1e-10
def test_ev_r_monotonic_with_p_up(self):
signals = [_make_confluence(confluence_score=0.8)]
ev_low = _compute_ev_r(0.5, signals)
ev_high = _compute_ev_r(0.8, signals)
assert ev_high > ev_low
# ---------------------------------------------------------------------------
# Full pipeline — verdict tests
# ---------------------------------------------------------------------------
class TestProbabilisticPipeline:
def test_no_signals_returns_prior_based_result(self):
"""With no signals, P_up equals the prior."""
normalized = _make_normalized()
regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=1.0)
result = run_probabilistic_pipeline(normalized, [], regime, DEFAULT_CONFIG)
assert abs(result.p_up - 0.58) < 1e-6
assert result.prior == 0.58
def test_buy_verdict_with_strong_signals(self):
"""Strong bullish signals + favorable conditions → BUY."""
normalized = _make_normalized(macro_bias=0.5, valuation_score=0.7)
regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=1.0)
# Multiple strong bullish signals from different clusters
signals = [
_make_confluence("fibonacci", SignalDirection.BULLISH, 0.9),
_make_confluence("ma_stack", SignalDirection.BULLISH, 0.85),
_make_confluence("rsi", SignalDirection.BULLISH, 0.8),
_make_confluence("valuation", SignalDirection.BULLISH, 0.75),
]
result = run_probabilistic_pipeline(normalized, signals, regime, DEFAULT_CONFIG)
# With strong signals and bull prior, P_up should be high
assert result.p_up >= 0.60
# Verdict depends on all conditions being met
assert result.verdict in (Verdict.BUY, Verdict.WATCH)
def test_skip_on_high_entropy(self):
"""P_up near 0.5 → high entropy → SKIP."""
normalized = _make_normalized()
regime = _make_regime(MarketRegime.MEAN_REVERSION) # prior = 0.50
# No signals → P_up stays at 0.50 → entropy = 1.0 > 0.95
result = run_probabilistic_pipeline(normalized, [], regime, DEFAULT_CONFIG)
assert result.verdict == Verdict.SKIP
assert result.entropy > 0.95
assert any("high_entropy" in r for r in result.reasoning)
def test_skip_on_low_p_up(self):
"""Bearish signals → low P_up → SKIP."""
normalized = _make_normalized()
regime = _make_regime(MarketRegime.PANIC) # prior = 0.42
signals = [
_make_confluence("fibonacci", SignalDirection.BEARISH, 0.9),
_make_confluence("ma_stack", SignalDirection.BEARISH, 0.85),
]
result = run_probabilistic_pipeline(normalized, signals, regime, DEFAULT_CONFIG)
assert result.p_up < 0.55
assert result.verdict == Verdict.SKIP
def test_watch_verdict(self):
"""Moderate signals → WATCH (P_up >= 0.55 but not all BUY conditions)."""
normalized = _make_normalized(macro_bias=-0.1) # macro_bias <= 0 blocks BUY
regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=1.0)
signals = [
_make_confluence("fibonacci", SignalDirection.BULLISH, 0.8),
_make_confluence("ma_stack", SignalDirection.BULLISH, 0.75),
]
result = run_probabilistic_pipeline(normalized, signals, regime, DEFAULT_CONFIG)
# P_up should be above 0.55 with bull prior + bullish signals
if result.p_up >= 0.55 and result.entropy <= 0.95:
assert result.verdict == Verdict.WATCH
def test_macro_bias_blocks_buy(self):
"""macro_bias <= 0 prevents BUY even with high P_up."""
normalized = _make_normalized(macro_bias=0.0, valuation_score=0.8)
regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=1.0)
signals = [
_make_confluence("fibonacci", SignalDirection.BULLISH, 0.9),
_make_confluence("ma_stack", SignalDirection.BULLISH, 0.9),
_make_confluence("rsi", SignalDirection.BULLISH, 0.85),
_make_confluence("valuation", SignalDirection.BULLISH, 0.8),
]
result = run_probabilistic_pipeline(normalized, signals, regime, DEFAULT_CONFIG)
assert result.verdict != Verdict.BUY
def test_valuation_blocks_buy(self):
"""valuation_score < 0.5 prevents BUY."""
normalized = _make_normalized(macro_bias=0.5, valuation_score=0.3)
regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=1.0)
signals = [
_make_confluence("fibonacci", SignalDirection.BULLISH, 0.9),
_make_confluence("ma_stack", SignalDirection.BULLISH, 0.9),
_make_confluence("rsi", SignalDirection.BULLISH, 0.85),
_make_confluence("valuation", SignalDirection.BULLISH, 0.8),
]
result = run_probabilistic_pipeline(normalized, signals, regime, DEFAULT_CONFIG)
assert result.verdict != Verdict.BUY
def test_result_fields_populated(self):
"""All ProbabilisticResult fields are populated correctly."""
normalized = _make_normalized()
regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=1.0)
signals = [_make_confluence("fibonacci", SignalDirection.BULLISH, 0.7)]
result = run_probabilistic_pipeline(normalized, signals, regime, DEFAULT_CONFIG)
assert 0.0 <= result.p_up <= 1.0
assert 0.0 <= result.entropy <= 1.0
assert isinstance(result.ev_r, float)
assert result.prior == 0.58
assert result.posterior == result.p_up
assert result.regime == "trend_following"
assert len(result.likelihood_ratios) == 1
assert len(result.reasoning) > 0
def test_none_valuation_treated_as_zero(self):
"""None valuation_score is treated as 0.0 for verdict logic."""
normalized = _make_normalized(valuation_score=None)
regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=1.0)
signals = [
_make_confluence("fibonacci", SignalDirection.BULLISH, 0.9),
_make_confluence("ma_stack", SignalDirection.BULLISH, 0.9),
_make_confluence("rsi", SignalDirection.BULLISH, 0.85),
]
result = run_probabilistic_pipeline(normalized, signals, regime, DEFAULT_CONFIG)
# valuation_score=None → 0.0 < 0.5 → BUY blocked
assert result.verdict != Verdict.BUY
+346
View File
@@ -0,0 +1,346 @@
"""Unit tests for services.signal_engine.signals.rsi — RSI evaluator.
Requirements: 2.3, 2.6, 2.7
"""
from __future__ import annotations
from datetime import datetime, timezone
from services.signal_engine.models import OHLCVBar, SignalDirection
from services.signal_engine.signals.rsi import (
DEFAULT_MIN_BARS,
DEFAULT_RSI_PERIOD,
OVERBOUGHT_THRESHOLD,
OVERSOLD_THRESHOLD,
RSIEvaluator,
compute_rsi,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _bar(close: float) -> OHLCVBar:
"""Create a minimal OHLCVBar for testing."""
return OHLCVBar(
timestamp=datetime(2024, 1, 1, tzinfo=timezone.utc),
open=close,
high=close,
low=close,
close=close,
volume=1000.0,
)
def _make_bars(n: int, close: float = 100.0) -> list[OHLCVBar]:
"""Create *n* identical bars with the same close price."""
return [_bar(close) for _ in range(n)]
def _trending_bars(n: int, start: float, step: float) -> list[OHLCVBar]:
"""Create *n* bars with linearly increasing/decreasing close prices."""
return [_bar(start + i * step) for i in range(n)]
def _alternating_bars(
n: int,
base: float,
gain: float,
loss: float,
) -> list[OHLCVBar]:
"""Create bars that alternate between gaining and losing.
Useful for producing RSI values in the neutral zone.
"""
bars: list[OHLCVBar] = [_bar(base)]
price = base
for i in range(1, n):
if i % 2 == 1:
price += gain
else:
price -= loss
bars.append(_bar(price))
return bars
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
def test_default_period() -> None:
assert DEFAULT_RSI_PERIOD == 14
def test_default_min_bars() -> None:
assert DEFAULT_MIN_BARS == 15
def test_thresholds() -> None:
assert OVERBOUGHT_THRESHOLD == 70.0
assert OVERSOLD_THRESHOLD == 30.0
# ---------------------------------------------------------------------------
# Insufficient data → None
# ---------------------------------------------------------------------------
def test_returns_none_when_insufficient_bars() -> None:
"""Requirement 2.6: return None when fewer than 15 bars."""
evaluator = RSIEvaluator()
bars = _make_bars(14)
assert evaluator.evaluate(bars, "D") is None
def test_returns_none_with_empty_bars() -> None:
evaluator = RSIEvaluator()
assert evaluator.evaluate([], "D") is None
def test_returns_none_with_one_bar() -> None:
evaluator = RSIEvaluator()
assert evaluator.evaluate([_bar(100.0)], "D") is None
# ---------------------------------------------------------------------------
# Neutral zone → None
# ---------------------------------------------------------------------------
def test_returns_none_in_neutral_zone() -> None:
"""RSI between 30 and 70 should return None (no signal)."""
evaluator = RSIEvaluator()
# Alternating gains and losses produce RSI near 50
bars = _alternating_bars(30, base=100.0, gain=1.0, loss=1.0)
result = evaluator.evaluate(bars, "D")
# RSI should be near 50 → neutral → None
assert result is None
def test_flat_market_returns_none() -> None:
"""All bars with the same close → no price changes → no signal.
When all changes are zero, avg_gain=0 and avg_loss=0.
avg_loss=0 means RSI=100, which is overbought. But with truly flat
prices (no gains, no losses), RSI is technically 100 (all gains are 0,
all losses are 0 → RS = 0/0 edge case handled as RSI=100).
"""
evaluator = RSIEvaluator()
bars = _make_bars(30, close=100.0)
rsi = compute_rsi(bars)
# With zero changes: avg_gain=0, avg_loss=0 → RSI=100 (per our implementation)
assert rsi == 100.0
# ---------------------------------------------------------------------------
# Overbought signal (RSI > 70) → BEARISH
# ---------------------------------------------------------------------------
def test_overbought_produces_bearish_signal() -> None:
"""Requirement 2.3: RSI > 70 → BEARISH signal (overbought → potential reversal down)."""
evaluator = RSIEvaluator()
# Strong uptrend: all gains, no losses → RSI approaches 100
bars = _trending_bars(30, start=50.0, step=2.0)
result = evaluator.evaluate(bars, "D")
assert result is not None
assert result.signal_type == "rsi"
assert result.direction == SignalDirection.BEARISH
assert result.metadata["zone"] == "overbought"
assert result.metadata["rsi"] > OVERBOUGHT_THRESHOLD
# ---------------------------------------------------------------------------
# Oversold signal (RSI < 30) → BULLISH
# ---------------------------------------------------------------------------
def test_oversold_produces_bullish_signal() -> None:
"""Requirement 2.3: RSI < 30 → BULLISH signal (oversold → potential reversal up)."""
evaluator = RSIEvaluator()
# Strong downtrend: all losses, no gains → RSI approaches 0
bars = _trending_bars(30, start=200.0, step=-2.0)
result = evaluator.evaluate(bars, "D")
assert result is not None
assert result.signal_type == "rsi"
assert result.direction == SignalDirection.BULLISH
assert result.metadata["zone"] == "oversold"
assert result.metadata["rsi"] < OVERSOLD_THRESHOLD
# ---------------------------------------------------------------------------
# Strength scaling
# ---------------------------------------------------------------------------
def test_overbought_strength_scales_with_distance() -> None:
"""Strength = (RSI - 70) / 30, clamped to [0, 1]."""
evaluator = RSIEvaluator()
# Strong uptrend → RSI near 100 → high strength
bars = _trending_bars(30, start=50.0, step=3.0)
result = evaluator.evaluate(bars, "D")
assert result is not None
rsi = result.metadata["rsi"]
expected_strength = min(1.0, max(0.0, (rsi - 70.0) / 30.0))
assert abs(result.strength - expected_strength) < 1e-9
def test_oversold_strength_scales_with_distance() -> None:
"""Strength = (30 - RSI) / 30, clamped to [0, 1]."""
evaluator = RSIEvaluator()
# Strong downtrend → RSI near 0 → high strength
bars = _trending_bars(30, start=200.0, step=-3.0)
result = evaluator.evaluate(bars, "D")
assert result is not None
rsi = result.metadata["rsi"]
expected_strength = min(1.0, max(0.0, (30.0 - rsi) / 30.0))
assert abs(result.strength - expected_strength) < 1e-9
def test_strength_clamped_to_unit_interval() -> None:
"""Strength must always be in [0, 1]."""
evaluator = RSIEvaluator()
# Extreme uptrend → RSI ≈ 100 → strength should be clamped to 1.0
bars = _trending_bars(30, start=10.0, step=5.0)
result = evaluator.evaluate(bars, "D")
assert result is not None
assert 0.0 <= result.strength <= 1.0
# ---------------------------------------------------------------------------
# Confidence = strength * 0.85
# ---------------------------------------------------------------------------
def test_confidence_equals_strength_times_085() -> None:
"""Confidence = strength * 0.85."""
evaluator = RSIEvaluator()
bars = _trending_bars(30, start=50.0, step=2.0)
result = evaluator.evaluate(bars, "D")
assert result is not None
expected_confidence = result.strength * 0.85
assert abs(result.confidence - expected_confidence) < 1e-9
# ---------------------------------------------------------------------------
# Signal result structure (Requirement 2.7)
# ---------------------------------------------------------------------------
def test_signal_result_structure() -> None:
"""Requirement 2.7: SignalResult has all required fields."""
evaluator = RSIEvaluator()
bars = _trending_bars(30, start=50.0, step=2.0)
result = evaluator.evaluate(bars, "H4")
assert result is not None
assert result.signal_type == "rsi"
assert result.timeframe == "H4"
assert 0.0 <= result.strength <= 1.0
assert 0.0 <= result.confidence <= 1.0
assert result.direction in (SignalDirection.BULLISH, SignalDirection.BEARISH)
def test_metadata_contains_rsi_and_period() -> None:
"""Metadata should include RSI value and period used."""
evaluator = RSIEvaluator()
bars = _trending_bars(30, start=50.0, step=2.0)
result = evaluator.evaluate(bars, "D")
assert result is not None
meta = result.metadata
assert "rsi" in meta
assert "period" in meta
assert meta["period"] == 14
assert isinstance(meta["rsi"], float)
# ---------------------------------------------------------------------------
# Timeframe passthrough
# ---------------------------------------------------------------------------
def test_timeframe_passthrough() -> None:
"""The timeframe label is passed through to the result."""
evaluator = RSIEvaluator()
bars = _trending_bars(30, start=50.0, step=2.0)
for tf in ("M30", "H1", "H4", "D", "W", "M"):
result = evaluator.evaluate(bars, tf)
assert result is not None
assert result.timeframe == tf
# ---------------------------------------------------------------------------
# Boundary: exactly 15 bars
# ---------------------------------------------------------------------------
def test_exactly_15_bars_works() -> None:
"""Exactly 15 bars (period + 1) should be sufficient."""
evaluator = RSIEvaluator()
bars = _trending_bars(15, start=50.0, step=2.0)
result = evaluator.evaluate(bars, "D")
# Should produce a result (strong uptrend → overbought)
assert result is not None
def test_14_bars_returns_none() -> None:
"""14 bars is insufficient for a 14-period RSI."""
evaluator = RSIEvaluator()
bars = _trending_bars(14, start=50.0, step=2.0)
result = evaluator.evaluate(bars, "D")
assert result is None
# ---------------------------------------------------------------------------
# compute_rsi standalone function
# ---------------------------------------------------------------------------
def test_compute_rsi_all_gains() -> None:
"""All gains, no losses → RSI approaches 100."""
bars = _trending_bars(30, start=50.0, step=1.0)
rsi = compute_rsi(bars)
assert rsi is not None
assert rsi > 95.0 # Should be very close to 100
def test_compute_rsi_all_losses() -> None:
"""All losses, no gains → RSI approaches 0."""
bars = _trending_bars(30, start=200.0, step=-1.0)
rsi = compute_rsi(bars)
assert rsi is not None
assert rsi < 5.0 # Should be very close to 0
def test_compute_rsi_insufficient_data() -> None:
"""Returns None when fewer than period + 1 bars."""
bars = _make_bars(10)
rsi = compute_rsi(bars)
assert rsi is None
def test_compute_rsi_range() -> None:
"""RSI should always be in [0, 100]."""
# Mixed trend
bars = _trending_bars(15, start=100.0, step=0.5) + _trending_bars(15, start=107.0, step=-0.3)
rsi = compute_rsi(bars)
assert rsi is not None
assert 0.0 <= rsi <= 100.0
# ---------------------------------------------------------------------------
# Custom period
# ---------------------------------------------------------------------------
def test_custom_period() -> None:
"""RSIEvaluator with a custom period should use that period."""
evaluator = RSIEvaluator(period=7)
assert evaluator.period == 7
assert evaluator.min_bars == 8
# 8 bars with uptrend should work
bars = _trending_bars(8, start=50.0, step=2.0)
result = evaluator.evaluate(bars, "D")
assert result is not None
assert result.metadata["period"] == 7
+592
View File
@@ -0,0 +1,592 @@
"""Integration tests for services.signal_engine.worker — Top-level orchestrator.
Tests the full evaluation tick flow with mocked DB/Redis, pipeline failure
isolation, hard filter short-circuit, and shadow mode behavior.
Requirements: 11.1, 11.2, 11.3, 11.6, 13.1, 13.6, 13.7, 15.1, 15.4, 16.1, 16.6
"""
from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from services.signal_engine.config import SignalEngineConfig
from services.signal_engine.models import (
DeltaResult,
HeuristicResult,
NormalizedInput,
ProbabilisticResult,
Verdict,
)
from services.signal_engine.worker import evaluate_tick
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _default_config(**overrides: object) -> SignalEngineConfig:
"""Build a SignalEngineConfig with sensible test defaults."""
defaults = {
"dual_pipeline_enabled": True,
"shadow_mode": False,
}
defaults.update(overrides)
return SignalEngineConfig(**defaults)
def _normalized_input(
*,
ticker: str = "AAPL",
macro_bias: float = 0.5,
valuation_score: float = 0.8,
earnings_proximity_days: int = 30,
current_price: float = 150.0,
) -> NormalizedInput:
"""Build a NormalizedInput with test defaults that pass hard filters."""
return NormalizedInput(
ticker=ticker,
evaluated_at=datetime.now(tz=timezone.utc),
bars={},
valuation_score=valuation_score,
earnings_proximity_days=earnings_proximity_days,
macro_bias=macro_bias,
open_positions=[],
closing_prices=[100.0 + i for i in range(120)],
returns=[0.01] * 119,
current_price=current_price,
)
def _heuristic_buy() -> HeuristicResult:
return HeuristicResult(
verdict=Verdict.BUY,
confidence=0.85,
s_total=1.5,
s_company=1.0,
s_macro=0.3,
s_competitive=0.2,
signal_weights=[],
reasoning=["BUY: all conditions met"],
)
def _heuristic_skip() -> HeuristicResult:
return HeuristicResult(
verdict=Verdict.SKIP,
confidence=0.3,
s_total=0.5,
s_company=0.3,
s_macro=0.1,
s_competitive=0.1,
signal_weights=[],
reasoning=["SKIP: low confidence"],
)
def _probabilistic_buy() -> ProbabilisticResult:
return ProbabilisticResult(
verdict=Verdict.BUY,
p_up=0.72,
entropy=0.6,
ev_r=2.0,
prior=0.58,
posterior=0.72,
likelihood_ratios=[],
regime="trend_following",
reasoning=["BUY: all conditions met"],
)
def _probabilistic_skip() -> ProbabilisticResult:
return ProbabilisticResult(
verdict=Verdict.SKIP,
p_up=0.4,
entropy=0.95,
ev_r=0.5,
prior=0.5,
posterior=0.4,
likelihood_ratios=[],
regime="uncertainty",
reasoning=["SKIP: low P_up"],
)
def _delta_result(agreement: bool = True) -> DeltaResult:
return DeltaResult(
agreement=agreement,
confidence_delta=0.1,
heuristic_verdict="BUY",
probabilistic_verdict="BUY",
disagreement_reasons=[],
rolling_agreement_rate=0.9,
)
# ===========================================================================
# 1. Full tick evaluation with mocked data (Req 11.1, 11.2, 11.5, 11.6)
# ===========================================================================
class TestFullTickEvaluation:
"""Test the full evaluation tick with both pipelines producing BUY."""
@pytest.mark.asyncio
async def test_full_tick_both_buy_publishes_to_queue(self) -> None:
"""Both pipelines BUY → output persisted and published to trading queue."""
pool = AsyncMock()
redis_client = AsyncMock()
config = _default_config()
normalized = _normalized_input()
heuristic = _heuristic_buy()
probabilistic = _probabilistic_buy()
delta = _delta_result(agreement=True)
with (
patch(
"services.signal_engine.worker.normalize_input",
new_callable=AsyncMock,
return_value=normalized,
),
patch(
"services.signal_engine.worker.evaluate_exits",
return_value=[],
),
patch(
"services.signal_engine.worker.evaluate_hard_filters",
) as mock_hf,
patch(
"services.signal_engine.worker._evaluate_signals",
return_value={},
),
patch(
"services.signal_engine.worker.compute_confluence",
return_value=[],
),
patch(
"services.signal_engine.worker.classify_regime",
),
patch(
"services.signal_engine.worker.run_heuristic_pipeline",
return_value=heuristic,
),
patch(
"services.signal_engine.worker.run_probabilistic_pipeline",
return_value=probabilistic,
),
patch(
"services.signal_engine.worker.analyze_delta",
new_callable=AsyncMock,
return_value=delta,
),
patch(
"services.signal_engine.worker.persist_signal_output",
new_callable=AsyncMock,
) as mock_persist,
):
mock_hf.return_value = MagicMock(filtered=False, reasons=[])
output = await evaluate_tick(pool, redis_client, "AAPL", config)
assert output is not None
assert output.ticker == "AAPL"
assert output.heuristic_verdict == "BUY"
assert output.probabilistic_verdict == "BUY"
# Persistence was called
mock_persist.assert_awaited_once()
# Trading queue was published to (at least one BUY, not shadow mode)
redis_client.rpush.assert_awaited_once()
call_args = redis_client.rpush.call_args
assert call_args[0][0] == "stonks:queue:trading_decisions"
@pytest.mark.asyncio
async def test_full_tick_no_buy_does_not_publish(self) -> None:
"""Both pipelines SKIP → output persisted but NOT published to queue."""
pool = AsyncMock()
redis_client = AsyncMock()
config = _default_config()
normalized = _normalized_input()
heuristic = _heuristic_skip()
probabilistic = _probabilistic_skip()
delta = _delta_result(agreement=True)
with (
patch(
"services.signal_engine.worker.normalize_input",
new_callable=AsyncMock,
return_value=normalized,
),
patch(
"services.signal_engine.worker.evaluate_exits",
return_value=[],
),
patch(
"services.signal_engine.worker.evaluate_hard_filters",
) as mock_hf,
patch(
"services.signal_engine.worker._evaluate_signals",
return_value={},
),
patch(
"services.signal_engine.worker.compute_confluence",
return_value=[],
),
patch(
"services.signal_engine.worker.classify_regime",
),
patch(
"services.signal_engine.worker.run_heuristic_pipeline",
return_value=heuristic,
),
patch(
"services.signal_engine.worker.run_probabilistic_pipeline",
return_value=probabilistic,
),
patch(
"services.signal_engine.worker.analyze_delta",
new_callable=AsyncMock,
return_value=delta,
),
patch(
"services.signal_engine.worker.persist_signal_output",
new_callable=AsyncMock,
) as mock_persist,
):
mock_hf.return_value = MagicMock(filtered=False, reasons=[])
output = await evaluate_tick(pool, redis_client, "AAPL", config)
assert output is not None
# Persisted
mock_persist.assert_awaited_once()
# NOT published to trading queue
redis_client.rpush.assert_not_awaited()
# ===========================================================================
# 2. Pipeline failure isolation (Req 11.3)
# ===========================================================================
class TestPipelineFailureIsolation:
"""One pipeline fails → SKIP verdict for that pipeline, other completes."""
@pytest.mark.asyncio
async def test_heuristic_fails_probabilistic_completes(self) -> None:
"""Heuristic raises exception → SKIP, probabilistic completes normally."""
pool = AsyncMock()
redis_client = AsyncMock()
config = _default_config()
normalized = _normalized_input()
probabilistic = _probabilistic_buy()
delta = _delta_result(agreement=False)
with (
patch(
"services.signal_engine.worker.normalize_input",
new_callable=AsyncMock,
return_value=normalized,
),
patch(
"services.signal_engine.worker.evaluate_exits",
return_value=[],
),
patch(
"services.signal_engine.worker.evaluate_hard_filters",
) as mock_hf,
patch(
"services.signal_engine.worker._evaluate_signals",
return_value={},
),
patch(
"services.signal_engine.worker.compute_confluence",
return_value=[],
),
patch(
"services.signal_engine.worker.classify_regime",
),
patch(
"services.signal_engine.worker.run_heuristic_pipeline",
side_effect=RuntimeError("heuristic boom"),
),
patch(
"services.signal_engine.worker.run_probabilistic_pipeline",
return_value=probabilistic,
),
patch(
"services.signal_engine.worker.analyze_delta",
new_callable=AsyncMock,
return_value=delta,
),
patch(
"services.signal_engine.worker.persist_signal_output",
new_callable=AsyncMock,
) as mock_persist,
):
mock_hf.return_value = MagicMock(filtered=False, reasons=[])
output = await evaluate_tick(pool, redis_client, "AAPL", config)
assert output is not None
# Heuristic fell back to SKIP
assert output.heuristic_verdict == "SKIP"
# Probabilistic completed normally
assert output.probabilistic_verdict == "BUY"
# Still persisted
mock_persist.assert_awaited_once()
@pytest.mark.asyncio
async def test_probabilistic_fails_heuristic_completes(self) -> None:
"""Probabilistic raises exception → SKIP, heuristic completes normally."""
pool = AsyncMock()
redis_client = AsyncMock()
config = _default_config()
normalized = _normalized_input()
heuristic = _heuristic_buy()
delta = _delta_result(agreement=False)
with (
patch(
"services.signal_engine.worker.normalize_input",
new_callable=AsyncMock,
return_value=normalized,
),
patch(
"services.signal_engine.worker.evaluate_exits",
return_value=[],
),
patch(
"services.signal_engine.worker.evaluate_hard_filters",
) as mock_hf,
patch(
"services.signal_engine.worker._evaluate_signals",
return_value={},
),
patch(
"services.signal_engine.worker.compute_confluence",
return_value=[],
),
patch(
"services.signal_engine.worker.classify_regime",
),
patch(
"services.signal_engine.worker.run_heuristic_pipeline",
return_value=heuristic,
),
patch(
"services.signal_engine.worker.run_probabilistic_pipeline",
side_effect=RuntimeError("probabilistic boom"),
),
patch(
"services.signal_engine.worker.analyze_delta",
new_callable=AsyncMock,
return_value=delta,
),
patch(
"services.signal_engine.worker.persist_signal_output",
new_callable=AsyncMock,
) as mock_persist,
):
mock_hf.return_value = MagicMock(filtered=False, reasons=[])
output = await evaluate_tick(pool, redis_client, "AAPL", config)
assert output is not None
assert output.heuristic_verdict == "BUY"
assert output.probabilistic_verdict == "SKIP"
mock_persist.assert_awaited_once()
@pytest.mark.asyncio
async def test_both_pipelines_fail_returns_none(self) -> None:
"""Both pipelines raise exceptions → returns None."""
pool = AsyncMock()
redis_client = AsyncMock()
config = _default_config()
normalized = _normalized_input()
with (
patch(
"services.signal_engine.worker.normalize_input",
new_callable=AsyncMock,
return_value=normalized,
),
patch(
"services.signal_engine.worker.evaluate_exits",
return_value=[],
),
patch(
"services.signal_engine.worker.evaluate_hard_filters",
) as mock_hf,
patch(
"services.signal_engine.worker._evaluate_signals",
return_value={},
),
patch(
"services.signal_engine.worker.compute_confluence",
return_value=[],
),
patch(
"services.signal_engine.worker.classify_regime",
),
patch(
"services.signal_engine.worker.run_heuristic_pipeline",
side_effect=RuntimeError("heuristic boom"),
),
patch(
"services.signal_engine.worker.run_probabilistic_pipeline",
side_effect=RuntimeError("probabilistic boom"),
),
patch(
"services.signal_engine.worker.persist_signal_output",
new_callable=AsyncMock,
) as mock_persist,
):
mock_hf.return_value = MagicMock(filtered=False, reasons=[])
output = await evaluate_tick(pool, redis_client, "AAPL", config)
assert output is None
# Nothing persisted when both fail
mock_persist.assert_not_awaited()
# ===========================================================================
# 3. Hard filter short-circuit (Req 4.1, 4.2, 4.3)
# ===========================================================================
class TestHardFilterShortCircuit:
"""Hard filter triggers → returns None without running pipelines."""
@pytest.mark.asyncio
async def test_hard_filter_returns_none(self) -> None:
"""When hard filter triggers, evaluate_tick returns None."""
pool = AsyncMock()
redis_client = AsyncMock()
config = _default_config()
normalized = _normalized_input(macro_bias=-1.0)
with (
patch(
"services.signal_engine.worker.normalize_input",
new_callable=AsyncMock,
return_value=normalized,
),
patch(
"services.signal_engine.worker.evaluate_exits",
return_value=[],
),
patch(
"services.signal_engine.worker.evaluate_hard_filters",
) as mock_hf,
patch(
"services.signal_engine.worker.run_heuristic_pipeline",
) as mock_heuristic,
patch(
"services.signal_engine.worker.run_probabilistic_pipeline",
) as mock_probabilistic,
patch(
"services.signal_engine.worker.persist_signal_output",
new_callable=AsyncMock,
) as mock_persist,
):
mock_hf.return_value = MagicMock(
filtered=True, reasons=["macro_bias_negative"]
)
output = await evaluate_tick(pool, redis_client, "AAPL", config)
assert output is None
# Pipelines were NOT called
mock_heuristic.assert_not_called()
mock_probabilistic.assert_not_called()
# Nothing persisted
mock_persist.assert_not_awaited()
# ===========================================================================
# 4. Shadow mode behavior (Req 16.6)
# ===========================================================================
class TestShadowMode:
"""Shadow mode: persists output but does NOT publish to trading queue."""
@pytest.mark.asyncio
async def test_shadow_mode_persists_but_does_not_publish(self) -> None:
"""In shadow mode, BUY signals are persisted but not forwarded."""
pool = AsyncMock()
redis_client = AsyncMock()
config = _default_config(shadow_mode=True)
normalized = _normalized_input()
heuristic = _heuristic_buy()
probabilistic = _probabilistic_buy()
delta = _delta_result(agreement=True)
with (
patch(
"services.signal_engine.worker.normalize_input",
new_callable=AsyncMock,
return_value=normalized,
),
patch(
"services.signal_engine.worker.evaluate_exits",
return_value=[],
),
patch(
"services.signal_engine.worker.evaluate_hard_filters",
) as mock_hf,
patch(
"services.signal_engine.worker._evaluate_signals",
return_value={},
),
patch(
"services.signal_engine.worker.compute_confluence",
return_value=[],
),
patch(
"services.signal_engine.worker.classify_regime",
),
patch(
"services.signal_engine.worker.run_heuristic_pipeline",
return_value=heuristic,
),
patch(
"services.signal_engine.worker.run_probabilistic_pipeline",
return_value=probabilistic,
),
patch(
"services.signal_engine.worker.analyze_delta",
new_callable=AsyncMock,
return_value=delta,
),
patch(
"services.signal_engine.worker.persist_signal_output",
new_callable=AsyncMock,
) as mock_persist,
):
mock_hf.return_value = MagicMock(filtered=False, reasons=[])
output = await evaluate_tick(pool, redis_client, "AAPL", config)
assert output is not None
assert output.heuristic_verdict == "BUY"
assert output.probabilistic_verdict == "BUY"
# Persisted (shadow mode still persists)
mock_persist.assert_awaited_once()
# NOT published to trading queue (shadow mode blocks publishing)
redis_client.rpush.assert_not_awaited()