Files
stonks-oracle/tests/test_signal_engine_heuristic.py
Celes Renata f468e30af0
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
feat: implement dual-pipeline signal engine service
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)
2026-05-02 07:32:26 +00:00

815 lines
30 KiB
Python

"""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