Files
stonks-oracle/tests/test_signal_engine_formatter.py
T
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

575 lines
20 KiB
Python

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