mirror of
https://github.com/triqbit/mt5-ai-xauusd-trader.git
synced 2026-11-02 06:56:15 +00:00
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>
708 lines
28 KiB
Python
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(),
|
|
)
|