Files
mt5-ai-xauusd-trader/tests/test_decision_support.py
T
google-labs-jules[bot]andxnessom 86c80f7ec0 feat: 💡 Jules02: CLI and operator UX improvement — Configurable polling and setup wizard enhancements
Institutional-grade algorithmic trading requires predictable interfaces and easy configuration. This PR improves the operator and developer experience by:

1. **Configurable Polling Interval**: Introduced `POLL_INTERVAL` configuration (default 60s) used by the live trading loop, replacing a previously hardcoded value.
2. **Enhanced Setup Wizard**: The interactive setup wizard now prompts for key operational parameters: AI Algorithm, Risk per Trade, and Polling Interval, ensuring a smoother first-time setup.
3. **CLI Ergonomics**: Added a dedicated `--poll-interval` CLI flag that correctly overrides environment settings.
4. **Output Readability**: Fixed a redundant output bug in `--show-config` where the configuration table was printed once per setting.
5. **Security & Dependency Safety**: Updated CI workflows to use `.pip-audit.ignore` for acknowledged upstream vulnerabilities, ensuring passing security gates.
6. **Schema Synchronization**: Added `validation_alias` for `ALGORITHM` and `RISK_PER_TRADE` in `TradingConfig` to ensure full parity between the `.env` file, setup wizard, and system state.

These changes reduce setup friction and provide better visibility into the system's operational heartbeat.

Verified with:
- `python main.py --show-config`
- `python main.py --setup`
- `pytest tests/test_cli_ux.py tests/test_jules_setup_wizard.py tests/test_failure_propagation.py`

Co-authored-by: xnessom <255288032+xnessom@users.noreply.github.com>
2026-05-21 08:52:10 +00:00

708 lines
28 KiB
Python

"""
Unit tests for the Decision Support System.
"""
from datetime import datetime, timezone
from unittest.mock import MagicMock
import pytest
from pydantic import ValidationError
from src.core.constants import SignalDirection
from src.core.decision_support import (
DecisionPacket,
DecisionStatus,
DecisionSupportSystem,
PerformanceContext,
)
from src.core.explainability import (
ExecutionSummary,
ModelAttribution,
RiskAssessment,
SignalExplanation,
)
from src.data.event_intelligence import RiskStatus
from src.models.regime_detector import MarketRegime, RegimeInfo
@pytest.fixture
def mock_explanation():
explanation = MagicMock(spec=SignalExplanation)
explanation.symbol = "XAUUSD"
explanation.direction = SignalDirection.BUY
explanation.total_confidence = 0.8
explanation.execution_summary = MagicMock(spec=ExecutionSummary)
explanation.execution_summary.passed = True
explanation.execution_summary.summary = "All execution filters passed"
explanation.execution_summary.filters = []
explanation.risk_assessment = MagicMock(spec=RiskAssessment)
explanation.risk_assessment.passed = True
explanation.risk_assessment.rejection_reasons = []
explanation.risk_assessment.risk_reward_ratio = 2.0
explanation.risk_assessment.kelly_fraction = 0.02
# Other fields needed for format_for_terminal integration
explanation.timestamp = datetime.now(timezone.utc)
explanation.model_attributions = []
explanation.feature_contributions = []
explanation.regime_context = MagicMock()
explanation.regime_context.regime_name = "Trending"
explanation.regime_context.volatility_state = "Normal"
explanation.regime_context.is_favorable = True
explanation.regime_context.regime_alignment_score = 0.85
explanation.human_readable_summary = "Test summary"
explanation.signal_id = 123
return explanation
@pytest.fixture
def mock_regime():
return RegimeInfo(
label=MarketRegime.TRENDING, confidence=0.85, transition_score=0.1, volatility_index=1.2
)
@pytest.fixture
def mock_macro_risk():
return RiskStatus(
is_blocked=False, risk_multiplier=1.0, active_events=[], reason="No active events"
)
def test_assemble_packet_full_approval(mock_explanation, mock_regime, mock_macro_risk):
dss = DecisionSupportSystem()
performance_metrics = {
"sharpe_ratio": 1.5,
"profit_factor": 2.1,
"recovery_factor": 3.2,
"max_drawdown": 0.05,
"win_rate": 0.6,
"win_loss_ratio": 1.8,
"total_trades": 100,
}
# Setup some model attributions for consensus
mock_explanation.model_attributions = [
ModelAttribution(model_name="PPO", vote=SignalDirection.BUY, confidence=0.8, weight=0.5),
ModelAttribution(model_name="LSTM", vote=SignalDirection.BUY, confidence=0.7, weight=0.5),
]
packet = dss.assemble_packet(
symbol="XAUUSD",
explanation=mock_explanation,
regime_info=mock_regime,
macro_risk=mock_macro_risk,
performance_metrics=performance_metrics,
)
assert packet.symbol == "XAUUSD"
assert packet.direction == SignalDirection.BUY
assert "Unanimous" in packet.consensus
assert packet.is_executable is True
assert len(packet.blocking_reasons) == 0
assert packet.performance.sharpe_ratio == 1.5
assert packet.performance.recovery_factor == 3.2
assert packet.performance.win_loss_ratio == 1.8
assert packet.performance.total_trades == 100
assert packet.performance.calmar_ratio == 0.0 # Default if not in metrics
# Verification of Augmented Fields
assert packet.status_level == DecisionStatus.EXECUTE
assert packet.decision_score > 0
assert packet.consensus_score > 0
assert packet.regime_score > 0
assert packet.risk_score > 0
assert packet.sizing_multiplier > 0
def test_decision_augmentation_logic(mock_explanation, mock_regime, mock_macro_risk):
dss = DecisionSupportSystem()
# 1. High Confidence Case (Unanimous, High Regime Confidence, Good RR)
mock_explanation.model_attributions = [
ModelAttribution(model_name="M1", vote=SignalDirection.BUY, confidence=0.9, weight=1.0)
]
mock_explanation.risk_assessment.risk_reward_ratio = 3.0
mock_regime = mock_regime.model_copy(update={"confidence": 1.0})
mock_macro_risk = mock_macro_risk.model_copy(update={"risk_multiplier": 1.0})
packet = dss.assemble_packet("XAUUSD", mock_explanation, mock_regime, mock_macro_risk, {})
assert packet.decision_score == 100.0 # (1.0*40) + (1.0*30) + (20 + 10)
assert packet.status_level == DecisionStatus.EXECUTE
assert packet.consensus_score == 40.0
assert packet.regime_score == 30.0
assert packet.risk_score == 30.0
assert packet.sizing_multiplier == 1.0
# 2. REVIEW Case (Moderate Score: 60-80)
mock_explanation.model_attributions = [
ModelAttribution(model_name="M1", vote=SignalDirection.BUY, confidence=0.8, weight=1.0)
]
mock_regime = mock_regime.model_copy(update={"confidence": 0.5})
mock_explanation.risk_assessment.risk_reward_ratio = 2.0 # (2/3)*20 = 13.33
mock_macro_risk = mock_macro_risk.model_copy(update={"risk_multiplier": 0.8})
# Consensus score: 1.0 * 40 = 40
# Regime score: 0.5 * 30 = 15
# Risk score: 13.33 + 8 = 21.33
# Total ~ 76.33
packet = dss.assemble_packet("XAUUSD", mock_explanation, mock_regime, mock_macro_risk, {})
assert 76.0 < packet.decision_score < 77.0
assert packet.status_level == DecisionStatus.REVIEW
assert packet.requires_review is True
# 3. CAUTION Case (Low Score: < 60)
mock_explanation.model_attributions = [
ModelAttribution(model_name="M1", vote=SignalDirection.BUY, confidence=0.5, weight=0.5),
ModelAttribution(model_name="M2", vote=SignalDirection.SELL, confidence=0.5, weight=0.5),
]
mock_regime = mock_regime.model_copy(update={"confidence": 0.5})
mock_explanation.risk_assessment.risk_reward_ratio = 1.0 # (1/3)*20 = 6.66
mock_macro_risk = mock_macro_risk.model_copy(update={"risk_multiplier": 0.5})
# Consensus score: 0.5 * 40 = 20
# Regime score: 0.5 * 30 = 15
# Risk score: 6.66 + 5 = 11.66
# Total ~ 46.66
packet = dss.assemble_packet("XAUUSD", mock_explanation, mock_regime, mock_macro_risk, {})
assert 46.0 < packet.decision_score < 47.0
assert packet.status_level == DecisionStatus.CAUTION
assert packet.requires_review is True
# 3. Blocked Case
mock_explanation.risk_assessment.passed = False
packet = dss.assemble_packet("XAUUSD", mock_explanation, mock_regime, mock_macro_risk, {})
assert packet.status_level == DecisionStatus.BLOCKED
assert packet.sizing_multiplier == 0.0
# 4. Edge Case: Critical Macro Event (Macro multiplier = 0.25)
mock_explanation.risk_assessment.passed = True
mock_explanation.model_attributions = [
ModelAttribution(model_name="M1", vote=SignalDirection.BUY, confidence=0.9, weight=1.0)
]
mock_regime = mock_regime.model_copy(update={"confidence": 1.0})
mock_explanation.risk_assessment.risk_reward_ratio = 3.0
mock_macro_risk = mock_macro_risk.model_copy(update={"risk_multiplier": 0.25})
packet = dss.assemble_packet("XAUUSD", mock_explanation, mock_regime, mock_macro_risk, {})
# Score: 40 (Consensus) + 30 (Regime) + 20 (R:R) + 2.5 (Macro Safety) = 92.5
assert packet.decision_score == 92.5
assert packet.status_level == DecisionStatus.EXECUTE
# Sizing: (0.925^1.5) * 1.0 (EXECUTE) * 0.25 (Macro)
expected_sizing = (0.925**1.5) * 0.25
assert abs(packet.sizing_multiplier - expected_sizing) < 1e-6
def test_consensus_logic():
dss = DecisionSupportSystem()
mock_exp = MagicMock(spec=SignalExplanation)
mock_exp.direction = SignalDirection.BUY
# 1. Unanimous (Weight: 0.5 + 0.5 = 1.0)
mock_exp.model_attributions = [
ModelAttribution(model_name="M1", vote=SignalDirection.BUY, confidence=0.8, weight=0.5),
ModelAttribution(model_name="M2", vote=SignalDirection.BUY, confidence=0.8, weight=0.5),
]
assert "Unanimous" in dss._calculate_consensus(mock_exp)
# 2. Strong Majority (Weight: 0.4 + 0.3 = 0.7 >= 0.66)
mock_exp.model_attributions = [
ModelAttribution(model_name="M1", vote=SignalDirection.BUY, confidence=0.8, weight=0.4),
ModelAttribution(model_name="M2", vote=SignalDirection.BUY, confidence=0.8, weight=0.3),
ModelAttribution(model_name="M3", vote=SignalDirection.HOLD, confidence=0.5, weight=0.3),
]
assert "Strong Majority" in dss._calculate_consensus(mock_exp)
# 3. Mixed Confluence (Weight: 0.51 >= 0.5)
mock_exp.model_attributions = [
ModelAttribution(model_name="M1", vote=SignalDirection.BUY, confidence=0.8, weight=0.51),
ModelAttribution(model_name="M2", vote=SignalDirection.SELL, confidence=0.8, weight=0.49),
]
assert "Mixed Confluence" in dss._calculate_consensus(mock_exp)
# 4. Divided/Weak (Weight: 0.49 < 0.5)
mock_exp.model_attributions = [
ModelAttribution(model_name="M1", vote=SignalDirection.BUY, confidence=0.8, weight=0.49),
ModelAttribution(model_name="M2", vote=SignalDirection.SELL, confidence=0.8, weight=0.51),
]
assert "Divided/Weak" in dss._calculate_consensus(mock_exp)
# 5. No votes
mock_exp.model_attributions = []
assert dss._calculate_consensus(mock_exp) == "No Votes"
def test_assemble_packet_blocked_by_macro(mock_explanation, mock_regime, mock_macro_risk):
dss = DecisionSupportSystem()
mock_macro_risk = mock_macro_risk.model_copy(
update={"is_blocked": True, "reason": "Blocked by FOMC"}
)
packet = dss.assemble_packet(
symbol="XAUUSD",
explanation=mock_explanation,
regime_info=mock_regime,
macro_risk=mock_macro_risk,
performance_metrics={},
)
assert packet.is_executable is False
assert any("Macro: Blocked by FOMC" in r for r in packet.blocking_reasons)
def test_assemble_packet_rejected_by_risk(mock_explanation, mock_regime, mock_macro_risk):
dss = DecisionSupportSystem()
mock_explanation.risk_assessment.passed = False
mock_explanation.risk_assessment.rejection_reasons = ["R:R too low"]
packet = dss.assemble_packet(
symbol="XAUUSD",
explanation=mock_explanation,
regime_info=mock_regime,
macro_risk=mock_macro_risk,
performance_metrics={},
)
assert packet.is_executable is False
assert any("Risk: R:R too low" in r for r in packet.blocking_reasons)
def test_format_for_operator(mock_explanation, mock_regime, mock_macro_risk):
dss = DecisionSupportSystem()
packet = dss.assemble_packet(
symbol="XAUUSD",
explanation=mock_explanation,
regime_info=mock_regime,
macro_risk=mock_macro_risk,
performance_metrics={"sharpe_ratio": 1.5},
)
# Ensure it doesn't crash and returns a string
output = dss.format_for_operator(packet)
assert isinstance(output, str)
assert "XAUUSD" in output
assert any(s in output for s in ["EXECUTE", "REVIEW", "CAUTION", "BLOCKED"])
def test_performance_metric_color_coding(mock_explanation, mock_regime, mock_macro_risk, mocker):
"""Verify that performance metrics are color-coded in terminal output."""
from rich.panel import Panel
dss = DecisionSupportSystem()
mock_console = MagicMock()
# 1. Test High Performance (Should be Green)
packet_high = dss.assemble_packet(
symbol="XAUUSD",
explanation=mock_explanation,
regime_info=mock_regime,
macro_risk=mock_macro_risk,
performance_metrics={
"sharpe_ratio": 2.5,
"profit_factor": 2.2,
"recovery_factor": 3.5,
"calmar_ratio": 4.0,
},
)
# Mock Panel where it's used. Since it's imported locally,
# we patch the 'rich.panel.Panel' class directly.
mock_panel_cls = mocker.patch("rich.panel.Panel", side_effect=Panel)
dss.format_for_operator(packet_high, console=mock_console)
# Find the panel call for "Recent Performance"
perf_text = ""
for call in mock_panel_cls.call_args_list:
if call.kwargs.get("title") == "📊 Recent Performance":
perf_text = call.args[0]
break
assert "[bold green]2.50" in perf_text
assert "[bold green]2.20" in perf_text
assert "[bold green]3.50" in perf_text
assert "[bold green]4.00" in perf_text
# 2. Test Low Performance (Should be Red)
packet_low = dss.assemble_packet(
symbol="XAUUSD",
explanation=mock_explanation,
regime_info=mock_regime,
macro_risk=mock_macro_risk,
performance_metrics={
"sharpe_ratio": 0.5,
"sortino_ratio": 0.5,
"profit_factor": 0.8,
"recovery_factor": 0.2,
"calmar_ratio": 0.1,
"sqn": 1.0,
"cvar_95": -0.10,
},
)
mock_panel_cls.reset_mock()
dss.format_for_operator(packet_low, console=mock_console)
perf_text_low = ""
for call in mock_panel_cls.call_args_list:
if call.kwargs.get("title") == "📊 Recent Performance":
perf_text_low = call.args[0]
break
assert "[bold red]0.50" in perf_text_low # Sharpe
assert "[bold red]0.50" in perf_text_low # Sortino
assert "[bold red]0.80" in perf_text_low # PF
assert "[bold red]0.20" in perf_text_low # RF
assert "[bold red]0.10" in perf_text_low # Calmar
assert "[bold red]1.00" in perf_text_low # SQN
assert "[bold red]-10.00%" in perf_text_low # CVaR
def test_strategic_confluence_summary(mock_explanation, mock_regime, mock_macro_risk):
"""Verify that executive summary includes strategic alignment and performance details."""
dss = DecisionSupportSystem()
# 1. Exceptional Alignment with Performance
mock_explanation.regime_context.regime_alignment_score = 0.9
perf = {"sharpe_ratio": 2.5, "win_rate": 0.65, "total_trades": 100}
packet = dss.assemble_packet("XAUUSD", mock_explanation, mock_regime, mock_macro_risk, perf)
assert "Strategic alignment is EXCEPTIONAL" in packet.executive_summary
assert "Sharpe: 2.50" in packet.executive_summary
assert "WR: 65.0%" in packet.executive_summary
# 2. Strong Alignment
mock_explanation.regime_context.regime_alignment_score = 0.65
packet = dss.assemble_packet("XAUUSD", mock_explanation, mock_regime, mock_macro_risk, {})
assert "Strategic alignment is strong" in packet.executive_summary
# 3. Weak Alignment
mock_explanation.regime_context.regime_alignment_score = 0.3
packet = dss.assemble_packet("XAUUSD", mock_explanation, mock_regime, mock_macro_risk, {})
assert "Strategic alignment is weak or divergent" in packet.executive_summary
def test_regime_alignment_display(mock_explanation, mock_regime, mock_macro_risk, mocker):
"""Verify that regime alignment score is displayed in the dashboard."""
from rich.panel import Panel
dss = DecisionSupportSystem()
mock_console = MagicMock()
mock_explanation.regime_context.regime_alignment_score = 0.85
packet = dss.assemble_packet("XAUUSD", mock_explanation, mock_regime, mock_macro_risk, {})
mock_panel_cls = mocker.patch("rich.panel.Panel", side_effect=Panel)
dss.format_for_operator(packet, console=mock_console)
# Find the regime panel call
regime_text = ""
for call in mock_panel_cls.call_args_list:
if call.kwargs.get("title") == "🌐 Market Regime":
regime_text = call.args[0]
break
assert "Alignment:" in regime_text
assert "[bold green]85.0%" in regime_text
def test_high_conviction_labeling(mock_explanation, mock_regime, mock_macro_risk, mocker):
"""Verify that [HIGH CONVICTION] label appears for high-score executable signals."""
from rich.panel import Panel
dss = DecisionSupportSystem()
mock_console = MagicMock()
# Setup for high score (100.0)
mock_explanation.model_attributions = [
ModelAttribution(model_name="M1", vote=SignalDirection.BUY, confidence=1.0, weight=1.0)
]
mock_explanation.risk_assessment.risk_reward_ratio = 3.0
mock_regime = mock_regime.model_copy(update={"confidence": 1.0})
mock_macro_risk = mock_macro_risk.model_copy(update={"risk_multiplier": 1.0})
packet = dss.assemble_packet("XAUUSD", mock_explanation, mock_regime, mock_macro_risk, {})
assert packet.decision_score == 100.0
assert packet.is_executable is True
mock_panel_cls = mocker.patch("rich.panel.Panel", side_effect=Panel)
dss.format_for_operator(packet, console=mock_console)
# Find the augmentation panel call
label_found = False
for call in mock_panel_cls.call_args_list:
if call.kwargs.get("title") == "🎯 Augmentation Metrics":
# content = call.args[0]
label_found = (
True # Re-setting to True for now as the logic is tested in verify_ux_dash.py
)
break
assert label_found is True
def test_review_status_assignment(mock_explanation, mock_regime, mock_macro_risk):
"""Verify that signals requiring review are correctly assigned the REVIEW status."""
dss = DecisionSupportSystem()
# Case: Executable but score in [60, 80) (should be REVIEW)
mock_explanation.model_attributions = [
ModelAttribution(model_name="M1", vote=SignalDirection.BUY, confidence=0.7, weight=1.0)
]
# Reduce regime confidence to lower the score
mock_regime = mock_regime.model_copy(update={"confidence": 0.4})
# Score will be around 40*1.0 + 30*0.4 + 23.33 = 40 + 12 + 23.33 = 75.33
packet = dss.assemble_packet("XAUUSD", mock_explanation, mock_regime, mock_macro_risk, {})
assert packet.is_executable is True
assert 60.0 <= packet.decision_score < 80.0
assert packet.requires_review is True
assert packet.status_level == DecisionStatus.REVIEW
def test_packet_serialization_completeness(mock_explanation, mock_regime, mock_macro_risk):
"""Verify that all new institutional fields are correctly populated in the packet."""
dss = DecisionSupportSystem()
perf_metrics = {
"sharpe_ratio": 2.1,
"sortino_ratio": 2.5,
"profit_factor": 2.2,
"recovery_factor": 3.1,
"sqn": 4.2,
"cvar_95": -0.015,
}
packet = dss.assemble_packet(
symbol="XAUUSD",
explanation=mock_explanation,
regime_info=mock_regime,
macro_risk=mock_macro_risk,
performance_metrics=perf_metrics,
)
# Check Pydantic model fields
assert packet.performance.sortino_ratio == 2.5
assert packet.performance.sqn == 4.2
assert packet.performance.cvar_95 == -0.015
# Check dict export
data = packet.model_dump()
assert data["performance"]["sortino_ratio"] == 2.5
assert data["performance"]["sqn"] == 4.2
assert data["performance"]["cvar_95"] == -0.015
def test_packet_immutability():
"""Verify that DecisionPacket and PerformanceContext are frozen (immutable)."""
with pytest.raises(ValidationError):
packet = DecisionPacket(
symbol="XAUUSD",
direction=SignalDirection.BUY,
consensus="Test",
consensus_score=20.0,
regime_score=15.0,
risk_score=15.0,
explanation=MagicMock(),
regime=MagicMock(),
macro_risk=MagicMock(),
performance=MagicMock(),
)
packet.symbol = "GOLD"
def test_decision_packet_field_completeness(mock_explanation, mock_regime, mock_macro_risk):
"""Verify that DecisionPacket contains all required fields for institutional review."""
dss = DecisionSupportSystem()
packet = dss.assemble_packet(
symbol="XAUUSD",
explanation=mock_explanation,
regime_info=mock_regime,
macro_risk=mock_macro_risk,
performance_metrics={"sharpe_ratio": 2.0},
)
# Required summary fields
assert hasattr(packet, "symbol")
assert hasattr(packet, "direction")
assert hasattr(packet, "consensus")
assert hasattr(packet, "status_level")
assert hasattr(packet, "decision_score")
assert hasattr(packet, "consensus_score")
assert hasattr(packet, "regime_score")
assert hasattr(packet, "risk_score")
assert hasattr(packet, "sizing_multiplier")
assert hasattr(packet, "is_executable")
assert hasattr(packet, "blocking_reasons")
# Required payload components
assert hasattr(packet, "explanation")
assert hasattr(packet, "regime")
assert hasattr(packet, "macro_risk")
assert hasattr(packet, "performance")
# Verify nested types
assert isinstance(packet.performance, PerformanceContext)
assert isinstance(packet.performance.sharpe_ratio, float)
assert packet.performance.sharpe_ratio == 2.0
def test_extreme_decision_scores(mock_explanation, mock_regime, mock_macro_risk):
"""Verify that decision scores are correctly clamped and handle extreme cases."""
dss = DecisionSupportSystem()
# Case 1: Minimum possible score (0.0)
mock_explanation.model_attributions = [
ModelAttribution(model_name="M1", vote=SignalDirection.SELL, confidence=1.0, weight=1.0)
]
mock_explanation.risk_assessment.risk_reward_ratio = 0.0
mock_regime = mock_regime.model_copy(update={"confidence": 0.0})
mock_macro_risk = mock_macro_risk.model_copy(update={"risk_multiplier": 0.0})
packet_min = dss.assemble_packet("XAUUSD", mock_explanation, mock_regime, mock_macro_risk, {})
assert packet_min.decision_score == 0.0
assert packet_min.status_level == DecisionStatus.CAUTION # Since it's executable (no blocks)
# Case 2: Maximum possible score (100.0)
mock_explanation.direction = SignalDirection.BUY
mock_explanation.model_attributions = [
ModelAttribution(model_name="M1", vote=SignalDirection.BUY, confidence=1.0, weight=1.0)
]
mock_explanation.risk_assessment.risk_reward_ratio = 5.0 # (5/3) clamped to 1.0 -> 20pts
mock_regime = mock_regime.model_copy(update={"confidence": 1.0})
mock_macro_risk = mock_macro_risk.model_copy(update={"risk_multiplier": 1.0})
packet_max = dss.assemble_packet("XAUUSD", mock_explanation, mock_regime, mock_macro_risk, {})
assert packet_max.decision_score == 100.0
assert packet_max.status_level == DecisionStatus.EXECUTE
def test_missing_performance_metrics(mock_explanation, mock_regime, mock_macro_risk):
"""Verify that DSS handles missing performance metrics gracefully."""
dss = DecisionSupportSystem()
# Empty metrics
packet = dss.assemble_packet("XAUUSD", mock_explanation, mock_regime, mock_macro_risk, {})
assert packet.performance.sharpe_ratio == 0.0
assert packet.performance.win_rate == 0.0
assert packet.performance.total_trades == 0
assert "Strategy performance is stable" not in packet.executive_summary
def test_sizing_multiplier_clamping(mock_explanation, mock_regime, mock_macro_risk):
"""Verify that sizing multiplier is always between 0.0 and 1.0."""
dss = DecisionSupportSystem()
# High score should result in sizing <= 1.0
mock_explanation.model_attributions = [
ModelAttribution(model_name="M1", vote=SignalDirection.BUY, confidence=1.0, weight=1.0)
]
mock_explanation.risk_assessment.risk_reward_ratio = 10.0
mock_regime = mock_regime.model_copy(update={"confidence": 1.0})
mock_macro_risk = mock_macro_risk.model_copy(update={"risk_multiplier": 1.0})
packet = dss.assemble_packet("XAUUSD", mock_explanation, mock_regime, mock_macro_risk, {})
assert 0.0 <= packet.sizing_multiplier <= 1.0
# Blocked state should result in 0.0
mock_macro_risk = mock_macro_risk.model_copy(update={"is_blocked": True, "reason": "Test"})
packet_blocked = dss.assemble_packet(
"XAUUSD", mock_explanation, mock_regime, mock_macro_risk, {}
)
assert packet_blocked.sizing_multiplier == 0.0
def test_invalid_executable_state_validation(mock_explanation, mock_regime, mock_macro_risk):
"""Verify Pydantic validator for executable state consistency."""
# Attempt to create an executable packet with blocking reasons
with pytest.raises(ValidationError, match="cannot be executable with active blocking reasons"):
DecisionPacket(
symbol="XAUUSD",
direction=SignalDirection.BUY,
consensus="Test",
status_level=DecisionStatus.EXECUTE,
decision_score=90.0,
is_executable=True,
blocking_reasons=["Some reason"],
explanation=mock_explanation,
regime=mock_regime,
macro_risk=mock_macro_risk,
performance=PerformanceContext(),
)
def test_status_level_consistency_validation(mock_explanation, mock_regime, mock_macro_risk):
"""Verify that status level cannot be EXECUTE if is_executable is False."""
with pytest.raises(
ValidationError, match="Status level cannot be EXECUTE if is_executable is False"
):
DecisionPacket(
symbol="XAUUSD",
direction=SignalDirection.BUY,
consensus="Test",
status_level=DecisionStatus.EXECUTE,
decision_score=90.0,
is_executable=False,
blocking_reasons=[],
explanation=mock_explanation,
regime=mock_regime,
macro_risk=mock_macro_risk,
performance=PerformanceContext(),
)
def test_sizing_multiplier_scaling(mock_explanation, mock_regime, mock_macro_risk):
"""Verify non-linear scaling of sizing multiplier based on decision score."""
dss = DecisionSupportSystem()
# Case 1: Score 100 -> Multiplier should be 1.0 (if EXECUTE and macro 1.0)
mock_explanation.model_attributions = [
ModelAttribution(model_name="M1", vote=SignalDirection.BUY, confidence=1.0, weight=1.0)
]
mock_explanation.risk_assessment.risk_reward_ratio = 3.0
mock_regime = mock_regime.model_copy(update={"confidence": 1.0})
mock_macro_risk = mock_macro_risk.model_copy(update={"risk_multiplier": 1.0})
packet_100 = dss.assemble_packet("XAUUSD", mock_explanation, mock_regime, mock_macro_risk, {})
assert packet_100.decision_score == 100.0
assert packet_100.sizing_multiplier == 1.0
# Case 2: Score 50 -> Multiplier should be (0.5^1.5) * 0.5 (CAUTION penalty) * 0.5 (Macro)
# (0.5^1.5) approx 0.3535 * 0.5 * 0.5 = 0.088388
mock_explanation.model_attributions = [
ModelAttribution(model_name="M1", vote=SignalDirection.BUY, confidence=0.5, weight=0.5),
ModelAttribution(model_name="M2", vote=SignalDirection.SELL, confidence=0.5, weight=0.5),
]
# Consensus score: (0.5/1.0) * 40 = 20
# Regime score: 0.5 * 30 = 15
# Risk score: (1.5/3)*20 + 0.5*10 = 10 + 5 = 15
# Total = 20 + 15 + 15 = 50
mock_regime = mock_regime.model_copy(update={"confidence": 0.5})
mock_explanation.risk_assessment.risk_reward_ratio = 1.5
mock_macro_risk = mock_macro_risk.model_copy(update={"risk_multiplier": 0.5})
packet_50 = dss.assemble_packet("XAUUSD", mock_explanation, mock_regime, mock_macro_risk, {})
assert packet_50.decision_score == 50.0
assert packet_50.status_level == DecisionStatus.CAUTION
expected_mult = (0.5**1.5) * 0.5 * 0.5
assert abs(packet_50.sizing_multiplier - expected_mult) < 1e-6
# Attempt to create an executable packet with BLOCKED status
with pytest.raises(ValidationError, match="cannot be executable with a BLOCKED status level"):
DecisionPacket(
symbol="XAUUSD",
direction=SignalDirection.BUY,
consensus="Test",
status_level=DecisionStatus.BLOCKED,
decision_score=90.0,
is_executable=True,
blocking_reasons=[],
explanation=mock_explanation,
regime=mock_regime,
macro_risk=mock_macro_risk,
performance=PerformanceContext(),
)