Files
mt5-ai-xauusd-trader/tests/test_monitor.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

394 lines
16 KiB
Python

"""
Tests for Monitor class.
Ensures real-time tracking, metrics updates, and Telegram alerting work as expected.
"""
import asyncio
import contextlib
import time
import unittest
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
from src.core.monitor import (
AVG_TRADE_DURATION_GAUGE,
CONFIDENCE_GAUGE,
CPU_USAGE_GAUGE,
DAILY_PNL_GAUGE,
DATA_FRESHNESS_GAUGE,
DISK_USAGE_GAUGE,
DRAWDOWN_GAUGE,
EQUITY_GAUGE,
EXECUTION_LATENCY_HISTOGRAM,
FILL_RATE_GAUGE,
MEMORY_USAGE_GAUGE,
MODEL_ACCURACY_GAUGE,
MODEL_DRIFT_GAUGE,
MONTHLY_RETURN_GAUGE,
PARTIAL_FILL_COUNTER,
REJECTED_ORDER_COUNTER,
SHARPE_RATIO_GAUGE,
SLIPPAGE_HISTOGRAM,
SYSTEM_ERROR_COUNTER,
TRADE_COUNTER,
TRADING_BLOCK_DURATION,
WIN_RATE_GAUGE,
Monitor,
)
class TestMonitor(unittest.TestCase):
def setUp(self):
self.config = MagicMock()
self.config.telegram_token.get_secret_value.return_value = "fake_token"
self.config.telegram_chat_id = "fake_chat_id"
self.config.confidence_threshold = 0.6
self.config.prometheus_port = 8000
self.config.model_accuracy_floor = 0.5
self.config.model_drift_threshold = 0.3
self.config.model_calibration_threshold = 0.25
self.config.data_freshness_threshold = 300
self.config.execution_latency_threshold = 1.0
with patch("telegram.Bot"):
self.monitor = Monitor(self.config)
def test_log_equity(self):
with patch.object(EQUITY_GAUGE, "set") as mock_set:
self.monitor.log_equity(10500.0)
self.assertEqual(len(self.monitor.equity_history), 1)
self.assertEqual(self.monitor.equity_history[0]["equity"], 10500.0)
mock_set.assert_called_once_with(10500.0)
def test_get_equity_curve(self):
self.monitor.log_equity(10000.0)
self.monitor.log_equity(10100.0)
curve = self.monitor.get_equity_curve()
self.assertEqual(len(curve), 2)
self.assertEqual(curve[0]["equity"], 10000.0)
self.assertEqual(curve[1]["equity"], 10100.0)
def test_log_pnl(self):
with patch.object(DAILY_PNL_GAUGE, "set") as mock_set:
self.monitor.log_pnl(250.0)
mock_set.assert_called_once_with(250.0)
def test_log_monthly_return(self):
with patch.object(MONTHLY_RETURN_GAUGE, "set") as mock_set:
self.monitor.log_monthly_return(1500.0)
mock_set.assert_called_once_with(1500.0)
def test_log_trade_duration(self):
with patch.object(AVG_TRADE_DURATION_GAUGE, "set") as mock_set:
self.monitor.log_trade_duration(3600.0)
mock_set.assert_called_once_with(3600.0)
@patch("asyncio.run")
@patch("asyncio.get_running_loop")
def test_send_message_sync(self, mock_get_running_loop, mock_asyncio_run):
self.monitor.bot = MagicMock()
self.monitor.bot.send_message = AsyncMock()
mock_get_running_loop.side_effect = RuntimeError("No loop")
self.monitor.send_message("test message")
mock_asyncio_run.assert_called_once()
@patch("asyncio.get_running_loop")
def test_send_message_async(self, mock_get_running_loop):
self.monitor.bot = MagicMock()
self.monitor.bot.send_message = AsyncMock()
mock_loop = MagicMock()
mock_get_running_loop.return_value = mock_loop
self.monitor.send_message("test message")
mock_loop.create_task.assert_called_once()
def test_alert_circuit_breaker(self):
self.monitor.bot = MagicMock()
self.monitor.bot.send_message = AsyncMock()
with patch.object(DRAWDOWN_GAUGE, "set") as mock_set:
self.monitor.alert_circuit_breaker(0.15)
self.assertTrue(self.monitor.bot.send_message.called)
msg = self.monitor.bot.send_message.call_args[1]["text"]
self.assertIn("Circuit Breaker", msg)
self.assertIn("15.00%", msg)
mock_set.assert_called_once_with(15.0)
def test_send_daily_summary(self):
self.monitor.bot = MagicMock()
self.monitor.bot.send_message = AsyncMock()
with patch.object(DAILY_PNL_GAUGE, "set") as mock_set:
self.monitor.send_daily_summary(500.0, 10)
self.assertTrue(self.monitor.bot.send_message.called)
msg = self.monitor.bot.send_message.call_args[1]["text"]
self.assertIn("Daily Summary", msg)
self.assertIn("500.00", msg)
self.assertIn("10", msg)
mock_set.assert_called_once_with(500.0)
def test_check_confidence_degradation(self):
self.monitor.bot = MagicMock()
self.monitor.bot.send_message = AsyncMock()
with patch.object(CONFIDENCE_GAUGE, "set") as mock_set:
# Case 1: Below threshold
self.monitor.check_confidence_degradation(0.5)
self.assertTrue(self.monitor.bot.send_message.called)
msg = self.monitor.bot.send_message.call_args[1]["text"]
self.assertIn("Confidence Degradation", msg)
mock_set.assert_called_with(0.5)
self.monitor.bot.send_message.reset_mock()
# Case 2: Above threshold
self.monitor.check_confidence_degradation(0.7)
self.assertFalse(self.monitor.bot.send_message.called)
mock_set.assert_called_with(0.7)
def test_record_trade(self):
with patch.object(TRADE_COUNTER, "inc") as mock_inc:
self.monitor.record_trade()
mock_inc.assert_called_once()
def test_log_system_error(self):
self.monitor.bot = MagicMock()
self.monitor.bot.send_message = AsyncMock()
with patch.object(SYSTEM_ERROR_COUNTER, "labels") as mock_labels:
mock_counter = MagicMock()
mock_labels.return_value = mock_counter
self.monitor.log_system_error("MT5", "Connection failed")
mock_labels.assert_called_once_with(component="MT5")
mock_counter.inc.assert_called_once()
self.assertTrue(self.monitor.bot.send_message.called)
msg = self.monitor.bot.send_message.call_args[1]["text"]
self.assertIn("SYSTEM ERROR", msg)
self.assertIn("MT5", msg)
self.assertIn("Connection failed", msg)
def test_update_performance_metrics(self):
with (
patch.object(WIN_RATE_GAUGE, "set") as mock_win_set,
patch.object(SHARPE_RATIO_GAUGE, "set") as mock_sharpe_set,
):
self.monitor.update_performance_metrics(0.65, 2.1)
mock_win_set.assert_called_once_with(65.0)
mock_sharpe_set.assert_called_once_with(2.1)
@patch("src.core.monitor.start_http_server")
@patch("asyncio.get_running_loop")
def test_start_metrics_server(self, mock_get_loop, mock_start_server):
mock_loop = MagicMock()
mock_get_loop.return_value = mock_loop
self.monitor.start_metrics_server()
mock_start_server.assert_called_once_with(8000)
self.assertTrue(self.monitor._server_started)
mock_loop.create_task.assert_called_once()
@patch("psutil.cpu_percent")
@patch("psutil.virtual_memory")
@patch("psutil.disk_usage")
@patch("asyncio.sleep", side_effect=asyncio.CancelledError)
def test_collect_system_metrics(self, mock_sleep, mock_disk, mock_mem, mock_cpu):
self.monitor.bot = MagicMock()
mock_cpu.return_value = 10.0
mock_mem.return_value.percent = 50.0
mock_disk.return_value.percent = 30.0
with (
patch.object(CPU_USAGE_GAUGE, "set") as mock_cpu_set,
patch.object(MEMORY_USAGE_GAUGE, "set") as mock_mem_set,
patch.object(DISK_USAGE_GAUGE, "set") as mock_disk_set,
):
with contextlib.suppress(asyncio.CancelledError):
asyncio.run(self.monitor._collect_system_metrics(interval=1))
mock_cpu_set.assert_called_with(10.0)
mock_mem_set.assert_called_with(50.0)
mock_disk_set.assert_called_with(30.0)
def test_alert_balance_mismatch(self):
self.monitor.bot = MagicMock()
self.monitor.bot.send_message = AsyncMock()
self.monitor.alert_balance_mismatch(10000.0, 9500.0)
self.assertTrue(self.monitor.bot.send_message.called)
msg = self.monitor.bot.send_message.call_args[1]["text"]
self.assertIn("Balance Mismatch", msg)
self.assertIn("5.00%", msg)
def test_alert_margin_call(self):
self.monitor.bot = MagicMock()
self.monitor.bot.send_message = AsyncMock()
self.monitor.alert_margin_call(50.0)
self.assertTrue(self.monitor.bot.send_message.called)
msg = self.monitor.bot.send_message.call_args[1]["text"]
self.assertIn("Margin Call", msg)
self.assertIn("50.00%", msg)
def test_log_execution_quality(self):
self.monitor.bot = MagicMock()
self.monitor.bot.send_message = AsyncMock()
with (
patch.object(EXECUTION_LATENCY_HISTOGRAM, "observe") as mock_latency,
patch.object(SLIPPAGE_HISTOGRAM, "observe") as mock_slippage,
patch.object(FILL_RATE_GAUGE, "set") as mock_fill,
):
# Case 1: Normal latency
self.monitor.log_execution_quality(150.0, 0.5, 0.95)
mock_latency.assert_called_with(0.15)
mock_slippage.assert_called_with(0.5)
mock_fill.assert_called_with(95.0)
self.assertFalse(self.monitor.bot.send_message.called)
# Case 2: High latency alert
self.monitor.log_execution_quality(1500.0, 0.5, 0.95)
mock_latency.assert_called_with(1.5)
self.assertTrue(self.monitor.bot.send_message.called)
msg = self.monitor.bot.send_message.call_args[1]["text"]
self.assertIn("High Execution Latency", msg)
self.assertIn("1500.00ms", msg)
def test_record_rejection(self):
with patch.object(REJECTED_ORDER_COUNTER, "labels") as mock_labels:
mock_counter = MagicMock()
mock_labels.return_value = mock_counter
self.monitor.record_rejection("insufficient_margin")
mock_labels.assert_called_once_with(reason="insufficient_margin")
mock_counter.inc.assert_called_once()
def test_record_partial_fill(self):
with patch.object(PARTIAL_FILL_COUNTER, "inc") as mock_inc:
self.monitor.record_partial_fill()
mock_inc.assert_called_once()
def test_log_model_performance(self):
self.monitor.bot = MagicMock()
self.monitor.bot.send_message = AsyncMock()
with (
patch.object(MODEL_ACCURACY_GAUGE, "set") as mock_acc,
patch.object(MODEL_DRIFT_GAUGE, "set") as mock_drift,
):
# Case 1: Healthy performance
self.monitor.log_model_performance(0.85, 0.05)
mock_acc.assert_called_with(85.0)
mock_drift.assert_called_with(0.05)
self.assertFalse(self.monitor.bot.send_message.called)
# Case 2: Accuracy breach
self.monitor.log_model_performance(0.45, 0.05)
self.assertTrue(self.monitor.bot.send_message.called)
msg = self.monitor.bot.send_message.call_args[1]["text"]
self.assertIn("Accuracy Below Floor", msg)
self.monitor.bot.send_message.reset_mock()
# Case 3: Drift breach
self.monitor.log_model_performance(0.85, 0.4)
self.assertTrue(self.monitor.bot.send_message.called)
msg = self.monitor.bot.send_message.call_args[1]["text"]
self.assertIn("Model Drift Detected", msg)
def test_log_data_freshness(self):
self.monitor.bot = MagicMock()
self.monitor.bot.send_message = AsyncMock()
with patch.object(DATA_FRESHNESS_GAUGE, "set") as mock_set:
# Case 1: Fresh data
now = datetime.now(timezone.utc)
self.monitor.log_data_freshness(now)
mock_set.assert_called_with(mock_set.call_args[0][0])
self.assertLess(mock_set.call_args[0][0], 1.0)
self.assertFalse(self.monitor.bot.send_message.called)
# Case 2: Stale data
stale_time = datetime.now(timezone.utc).timestamp() - 600
stale_dt = datetime.fromtimestamp(stale_time, tz=timezone.utc)
self.monitor.log_data_freshness(stale_dt)
self.assertTrue(self.monitor.bot.send_message.called)
msg = self.monitor.bot.send_message.call_args[1]["text"]
self.assertIn("Data Stale", msg)
def test_track_block_duration(self):
with patch.object(TRADING_BLOCK_DURATION, "labels") as mock_labels:
mock_hist = MagicMock()
mock_labels.return_value = mock_hist
with self.monitor.track_block_duration("test_block"):
time.sleep(0.1)
mock_labels.assert_called_once_with(block_label="test_block")
mock_hist.observe.assert_called_once()
duration = mock_hist.observe.call_args[0][0]
self.assertGreaterEqual(duration, 0.1)
def test_alert_liquidity_crisis(self):
self.monitor.bot = MagicMock()
self.monitor.bot.send_message = AsyncMock()
self.monitor.alert_liquidity_crisis("XAUUSD", 2.5)
self.assertTrue(self.monitor.bot.send_message.called)
msg = self.monitor.bot.send_message.call_args[1]["text"]
self.assertIn("Liquidity Crisis", msg)
self.assertIn("2.50 pips", msg)
def test_alert_broker_connection_lost(self):
self.monitor.bot = MagicMock()
self.monitor.bot.send_message = AsyncMock()
self.monitor.alert_broker_connection_lost()
self.assertTrue(self.monitor.bot.send_message.called)
msg = self.monitor.bot.send_message.call_args[1]["text"]
self.assertIn("Broker Connection Lost", msg)
def test_alert_broker_connection_restored(self):
self.monitor.bot = MagicMock()
self.monitor.bot.send_message = AsyncMock()
self.monitor.alert_broker_connection_restored()
self.assertTrue(self.monitor.bot.send_message.called)
msg = self.monitor.bot.send_message.call_args[1]["text"]
self.assertIn("Broker Connection Restored", msg)
def test_alert_inference_timeout(self):
self.monitor.bot = MagicMock()
self.monitor.bot.send_message = AsyncMock()
self.monitor.alert_inference_timeout(150.0, 100.0)
self.assertTrue(self.monitor.bot.send_message.called)
msg = self.monitor.bot.send_message.call_args[1]["text"]
self.assertIn("Inference Timeout", msg)
def test_alert_feature_missing(self):
self.monitor.bot = MagicMock()
self.monitor.bot.send_message = AsyncMock()
self.monitor.alert_feature_missing("RSI")
self.assertTrue(self.monitor.bot.send_message.called)
msg = self.monitor.bot.send_message.call_args[1]["text"]
self.assertIn("Missing Model Feature", msg)
self.assertIn("RSI", msg)
def test_alert_stale_model(self):
self.monitor.bot = MagicMock()
self.monitor.bot.send_message = AsyncMock()
self.monitor.alert_stale_model(8.5)
self.assertTrue(self.monitor.bot.send_message.called)
msg = self.monitor.bot.send_message.call_args[1]["text"]
self.assertIn("Stale Model Detected", msg)
self.assertIn("8.5 days", msg)
def test_alert_training_failed(self):
self.monitor.bot = MagicMock()
self.monitor.bot.send_message = AsyncMock()
self.monitor.alert_training_failed("Disk full")
self.assertTrue(self.monitor.bot.send_message.called)
msg = self.monitor.bot.send_message.call_args[1]["text"]
self.assertIn("Model Retraining Failed", msg)
self.assertIn("Disk full", msg)
if __name__ == "__main__":
unittest.main()