"""Tests for mt5cli.sdk module.""" from __future__ import annotations import logging import sqlite3 from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING, NamedTuple, cast from unittest.mock import MagicMock, call import pandas as pd import pytest from pdmt5 import Mt5RuntimeError, Mt5TradingError from pytest_mock import MockerFixture # noqa: TC002 if TYPE_CHECKING: from pathlib import Path from pdmt5 import Mt5Config, Mt5DataClient from mt5cli import sdk from mt5cli.history import DEFAULT_HISTORY_TIMEFRAMES, write_rates_dataset from mt5cli.sdk import ( AccountSpec, Mt5CliClient, ThrottledHistoryUpdater, account_info, build_config, collect_history, collect_latest_closed_rates_by_granularity, collect_latest_closed_rates_for_accounts, collect_latest_rates, collect_latest_rates_for_accounts, collect_latest_rates_for_accounts_with_retries, copy_rates_from, copy_rates_from_pos, copy_rates_range, copy_ticks_from, copy_ticks_range, history_deals, history_orders, last_error, latest_rates, market_book, minimum_margins, mt5_session, mt5_summary, mt5_summary_as_df, orders, positions, recent_history_deals, recent_ticks, resolve_account_spec, resolve_account_specs, substitute_env_placeholders, symbol_info, symbol_info_tick, symbols, terminal_info, update_history, update_history_with_config, version, ) from mt5cli.utils import Dataset, IfExists class _TerminalInfo(NamedTuple): connected: bool path: str class _AccountInfo(NamedTuple): login: int limits: dict[str, object] class _MissingSummaryMethodClient: def version(self) -> tuple[int, int, int]: return (5, 0, 1) def terminal_info(self) -> dict[str, bool]: return {"connected": True} def symbols_total(self) -> int: return 42 class _NonCallableSummaryMethodClient: version = (5, 0, 1) _DEALS_FIXTURE: dict[str, list[object]] = { "ticket": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], "position_id": [100, 100, 100, 0, 200, 200, 300, 400, 400, 500, 500, 600, 600, 600], "symbol": [ "EURUSD", "EURUSD", "EURUSD", "", "EURUSD", "EURUSD", "GBPUSD", "GBPUSD", "GBPUSD", "EURUSD", "EURUSD", "GBPUSD", "GBPUSD", "GBPUSD", ], "time": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], "type": [0, 0, 1, 2, 0, 1, 0, 0, 2, 0, 1, 0, 1, 1], "entry": [0, 0, 1, 0, 0, 1, 0, 0, 2, 0, 3, 0, 2, 1], "volume": [1.0, 3.0, 4.0, 0.0, 2.0, 2.0, 5.0, 1.0, 1.0, 2.0, 2.0, 3.0, 1.0, 3.0], "price": [ 1.10, 1.20, 1.50, 0.0, 2.00, 2.20, 1.30, 1.30, 1.40, 1.00, 1.05, 1.10, 9.99, 1.40, ], "profit": [0.0, 0.0, 10.0, 5.0, 0.0, 8.0, 0.0, 0.0, -1.0, 0.0, 3.0, 0.0, -2.0, 7.0], } def _build_history_client(mocker: MockerFixture) -> MagicMock: """Build a mocked Mt5DataClient with per-symbol history results.""" client = MagicMock() def _rates(**kwargs: object) -> pd.DataFrame: return pd.DataFrame({ "time": [1], "open": [1.0], "symbol_arg": [kwargs.get("symbol")], }) def _ticks(**kwargs: object) -> pd.DataFrame: return pd.DataFrame({ "time": [1], "bid": [1.0], "symbol_arg": [kwargs.get("symbol")], }) client.copy_rates_range_as_df.side_effect = _rates client.copy_ticks_range_as_df.side_effect = _ticks def _orders(**kwargs: object) -> pd.DataFrame: return pd.DataFrame({"ticket": [10], "symbol": [kwargs.get("symbol")]}) def _deals(**kwargs: object) -> pd.DataFrame: sym = kwargs.get("symbol") df = pd.DataFrame(_DEALS_FIXTURE) return df[df["symbol"] == sym].reset_index(drop=True) client.history_orders_get_as_df.side_effect = _orders client.history_deals_get_as_df.side_effect = _deals mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=client) return client class TestConnectionLifecycle: """Tests for MT5 connection lifecycle helpers.""" def test_connected_client_shuts_down(self, mocker: MockerFixture) -> None: """Test that _connected_client always shuts down.""" mock_client = MagicMock() mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=mock_client) config = MagicMock() with sdk.connected_client(config): # type: ignore[reportPrivateUsage] mock_client.initialize_and_login_mt5.assert_called_once() mock_client.shutdown.assert_called_once() def test_connected_client_shutdown_on_init_failure( self, mocker: MockerFixture, ) -> None: """Test that shutdown is called when initialize/login fails.""" mock_client = MagicMock() mock_client.initialize_and_login_mt5.side_effect = RuntimeError( "login failed", ) mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=mock_client) with ( pytest.raises(RuntimeError, match="login failed"), sdk.connected_client(MagicMock()), # type: ignore[reportPrivateUsage] ): pass mock_client.shutdown.assert_called_once() def test_run_with_client_shutdown_on_error( self, mocker: MockerFixture, ) -> None: """Test that shutdown is called even when fetch raises.""" mock_client = MagicMock() mock_client.account_info_as_df.side_effect = RuntimeError("boom") mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=mock_client) with pytest.raises(RuntimeError, match="boom"): sdk._run_with_client( # type: ignore[reportPrivateUsage] MagicMock(), lambda c: c.account_info_as_df(), ) mock_client.shutdown.assert_called_once() def test_client_context_manager_reuses_connection( self, mocker: MockerFixture, ) -> None: """Test that context-managed client reuses one connection.""" mock_client = MagicMock() mock_client.account_info_as_df.return_value = pd.DataFrame({"a": [1]}) mock_client.terminal_info_as_df.return_value = pd.DataFrame({"b": [2]}) mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=mock_client) with Mt5CliClient() as client: client.account_info() client.terminal_info() assert client.config is not None mock_client.initialize_and_login_mt5.assert_called_once() mock_client.shutdown.assert_called_once() assert mock_client.account_info_as_df.call_count == 1 assert mock_client.terminal_info_as_df.call_count == 1 def test_client_context_manager_shutdown_on_init_failure( self, mocker: MockerFixture, ) -> None: """Test that shutdown is called when context manager login fails.""" mock_client = MagicMock() mock_client.initialize_and_login_mt5.side_effect = RuntimeError( "login failed", ) mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=mock_client) client = Mt5CliClient() with pytest.raises(RuntimeError, match="login failed"), client: pass mock_client.shutdown.assert_called_once() assert client._client is None # type: ignore[reportPrivateUsage] def test_exit_without_enter_is_noop(self) -> None: """Test that __exit__ without __enter__ does not fail.""" client = Mt5CliClient() client.__exit__(None, None, None) def test_injected_client_is_reused_and_not_shutdown(self) -> None: """Test injected connected clients are not initialized or shut down.""" connected = MagicMock() connected.account_info_as_df.return_value = pd.DataFrame({"a": [1]}) connected.terminal_info_as_df.return_value = pd.DataFrame({"b": [2]}) with Mt5CliClient.from_connected_client(connected) as client: result = client.account_info() assert result.to_dict("list") == {"a": [1]} connected.initialize_and_login_mt5.assert_not_called() connected.shutdown.assert_not_called() connected.account_info_as_df.assert_called_once() after_exit = client.terminal_info() assert after_exit.to_dict("list") == {"b": [2]} connected.terminal_info_as_df.assert_called_once() def test_constructor_injected_client_is_reused_and_not_shutdown(self) -> None: """Test constructor injection has the same non-owning lifecycle.""" connected = MagicMock() connected.terminal_info_as_df.return_value = pd.DataFrame({"b": [2]}) client = Mt5CliClient(client=connected) with client: result = client.terminal_info() assert result.to_dict("list") == {"b": [2]} connected.initialize_and_login_mt5.assert_not_called() connected.shutdown.assert_not_called() class TestModuleFunctions: """Tests for module-level SDK wrappers.""" @pytest.mark.parametrize( ("fn", "args", "method"), [ ( copy_rates_from, ("EURUSD", "M1", "2024-01-01", 10), "copy_rates_from_as_df", ), ( copy_rates_from_pos, ("EURUSD", "M1", 0, 10), "copy_rates_from_pos_as_df", ), ( copy_ticks_from, ("EURUSD", "2024-01-01", 10, "ALL"), "copy_ticks_from_as_df", ), ( copy_ticks_range, ("EURUSD", "2024-01-01", "2024-02-01", "ALL"), "copy_ticks_range_as_df", ), (account_info, (), "account_info_as_df"), (terminal_info, (), "terminal_info_as_df"), (symbols, ("*USD*",), "symbols_get_as_df"), (symbol_info, ("EURUSD",), "symbol_info_as_df"), (orders, (), "orders_get_as_df"), (positions, (), "positions_get_as_df"), (history_orders, (), "history_orders_get_as_df"), (history_deals, (), "history_deals_get_as_df"), (version, (), "version_as_df"), (last_error, (), "last_error_as_df"), (symbol_info_tick, ("EURUSD",), "symbol_info_tick_as_df"), (market_book, ("EURUSD",), "market_book_get_as_df"), (latest_rates, ("EURUSD", "M1", 10), "copy_rates_from_pos_as_df"), ], ) def test_module_functions_delegate( self, mock_client: MagicMock, fn: object, args: tuple[object, ...], method: str, ) -> None: """Test module-level functions call the expected client methods.""" config = build_config(login=123) result = fn(*args, config=config) # type: ignore[operator] assert isinstance(result, pd.DataFrame) getattr(mock_client, method).assert_called_once() class TestMt5CliClient: """Tests for Mt5CliClient SDK methods.""" def test_copy_rates_range_returns_dataframe( self, mock_client: MagicMock, ) -> None: """Test that copy_rates_range returns a DataFrame.""" df = Mt5CliClient().copy_rates_range( "EURUSD", "D1", "2024-01-01", "2024-02-01", ) assert isinstance(df, pd.DataFrame) mock_client.copy_rates_range_as_df.assert_called_once_with( symbol="EURUSD", timeframe=16408, date_from=datetime(2024, 1, 1, tzinfo=UTC), date_to=datetime(2024, 2, 1, tzinfo=UTC), ) def test_copy_ticks_from_parses_flags( self, mock_client: MagicMock, ) -> None: """Test that string tick flags are parsed.""" Mt5CliClient().copy_ticks_from("EURUSD", "2024-01-01", 100, "INFO") mock_client.copy_ticks_from_as_df.assert_called_once_with( symbol="EURUSD", date_from=datetime(2024, 1, 1, tzinfo=UTC), count=100, flags=1, ) def test_history_orders_accepts_string_dates( self, mock_client: MagicMock, ) -> None: """Test that string datetime inputs are parsed.""" Mt5CliClient().history_orders( date_from="2024-01-01", date_to="2024-02-01", ) mock_client.history_orders_get_as_df.assert_called_once_with( date_from=datetime(2024, 1, 1, tzinfo=UTC), date_to=datetime(2024, 2, 1, tzinfo=UTC), group=None, symbol=None, ticket=None, position=None, ) def test_module_function_delegates_to_client( self, mock_client: MagicMock, ) -> None: """Test module-level copy_rates_range delegates to the client.""" df = copy_rates_range( "USDJPY", "M1", "2024-01-01", "2024-02-01", ) assert isinstance(df, pd.DataFrame) mock_client.copy_rates_range_as_df.assert_called_once() def test_latest_rates_delegates_to_copy_rates_from_pos( self, mock_client: MagicMock, ) -> None: """Test latest_rates is a convenience wrapper for positional rates.""" Mt5CliClient().latest_rates("EURUSD", "M1", 5, start_pos=2) mock_client.copy_rates_from_pos_as_df.assert_called_once_with( symbol="EURUSD", timeframe=1, start_pos=2, count=5, ) def test_latest_rates_rejects_non_positive_count(self) -> None: """Test latest_rates validates count.""" with pytest.raises(ValueError, match="count must be positive"): Mt5CliClient().latest_rates("EURUSD", "M1", 0) def test_collect_latest_rates_returns_mapping( self, mock_client: MagicMock, ) -> None: """Test multi-target latest rate collection.""" result = collect_latest_rates(["EURUSD", "GBPUSD"], ["M1", "H1"], count=3) assert set(result) == { ("EURUSD", 1), ("EURUSD", 16385), ("GBPUSD", 1), ("GBPUSD", 16385), } assert mock_client.copy_rates_from_pos_as_df.call_count == 4 def test_collect_latest_rates_uses_single_transient_connection( self, mock_client: MagicMock, mocker: MockerFixture, ) -> None: """Test module helper opens one connection for all target pairs.""" mt5_data_client = mocker.patch( "mt5cli.sdk.Mt5DataClient", return_value=mock_client, ) collect_latest_rates(["EURUSD", "GBPUSD"], ["M1", "H1"], count=3) mt5_data_client.assert_called_once() mock_client.initialize_and_login_mt5.assert_called_once() mock_client.shutdown.assert_called_once() assert mock_client.copy_rates_from_pos_as_df.call_count == 4 mock_client.copy_rates_from_pos_as_df.assert_has_calls( [ call(symbol="EURUSD", timeframe=1, start_pos=0, count=3), call(symbol="EURUSD", timeframe=16385, start_pos=0, count=3), call(symbol="GBPUSD", timeframe=1, start_pos=0, count=3), call(symbol="GBPUSD", timeframe=16385, start_pos=0, count=3), ], ) @pytest.mark.parametrize( ("symbols", "timeframes", "match"), [ ([], ["M1"], "At least one symbol"), (["EURUSD"], [], "At least one timeframe"), ], ) def test_collect_latest_rates_rejects_empty_inputs( self, symbols: list[str], timeframes: list[str], match: str, ) -> None: """Test multi-target latest rate input validation.""" with pytest.raises(ValueError, match=match): Mt5CliClient().collect_latest_rates(symbols, timeframes, count=1) def test_recent_history_deals_uses_trailing_window( self, mock_client: MagicMock, ) -> None: """Test recent_history_deals calculates date_from from hours.""" result = recent_history_deals( 6, date_to="2024-01-02T00:00:00+00:00", group="*", symbol="EURUSD", ) assert isinstance(result, pd.DataFrame) mock_client.history_deals_get_as_df.assert_called_once_with( date_from=datetime(2024, 1, 1, 18, tzinfo=UTC), date_to=datetime(2024, 1, 2, tzinfo=UTC), group="*", symbol="EURUSD", ticket=None, position=None, ) def test_recent_history_deals_defaults_date_to_now( self, mock_client: MagicMock, ) -> None: """Test recent_history_deals uses current UTC time when date_to is omitted.""" before = datetime.now(UTC) recent_history_deals(1.0) after = datetime.now(UTC) call_kwargs = mock_client.history_deals_get_as_df.call_args.kwargs assert before <= call_kwargs["date_to"] <= after assert call_kwargs["date_from"] == call_kwargs["date_to"] - timedelta(hours=1) def test_recent_history_deals_rejects_non_positive_hours(self) -> None: """Test recent_history_deals validates hours.""" with pytest.raises(ValueError, match="hours must be positive"): Mt5CliClient().recent_history_deals(0) def test_mt5_summary_returns_status_mapping( self, mock_client: MagicMock, ) -> None: """Test mt5_summary calls raw terminal/account status methods.""" mock_client.version.return_value = (5, 0, 1) mock_client.terminal_info.return_value = {"connected": True} mock_client.account_info.return_value = {"login": 123} mock_client.symbols_total.return_value = 42 assert mt5_summary() == { "version": [5, 0, 1], "terminal_info": {"connected": True}, "account_info": {"login": 123}, "symbols_total": 42, } def test_mt5_summary_normalizes_namedtuple_values( self, mock_client: MagicMock, ) -> None: """Test mt5_summary returns structured plain Python values.""" mock_client.version.return_value = (5, 0, 1) mock_client.terminal_info.return_value = _TerminalInfo( connected=True, path="terminal.exe", ) mock_client.account_info.return_value = _AccountInfo( login=123, limits={"modes": ("netting", "hedging"), "servers": ["demo"]}, ) mock_client.symbols_total.return_value = 42 assert mt5_summary() == { "version": [5, 0, 1], "terminal_info": {"connected": True, "path": "terminal.exe"}, "account_info": { "login": 123, "limits": {"modes": ["netting", "hedging"], "servers": ["demo"]}, }, "symbols_total": 42, } def test_mt5_summary_as_df_stringifies_nested_values( self, mock_client: MagicMock, ) -> None: """Test mt5_summary_as_df returns export-safe tabular values.""" mock_client.version.return_value = (5, 0, 1) mock_client.terminal_info.return_value = _TerminalInfo( connected=True, path="terminal.exe", ) mock_client.account_info.return_value = _AccountInfo( login=123, limits={"modes": ("netting", "hedging"), "servers": ["demo"]}, ) mock_client.symbols_total.return_value = 42 result = mt5_summary_as_df() assert len(result) == 1 assert result.iloc[0].to_dict() == { "version": "[5,0,1]", "terminal_info": '{"connected":true,"path":"terminal.exe"}', "account_info": ( '{"limits":{"modes":["netting","hedging"],' '"servers":["demo"]},"login":123}' ), "symbols_total": 42, } def test_mt5_summary_missing_method_raises_clear_error(self) -> None: """Test mt5_summary fails clearly when a required method is missing.""" client = Mt5CliClient( client=cast("Mt5DataClient", _MissingSummaryMethodClient()), ) with pytest.raises( AttributeError, match="MT5 client is missing required method: account_info", ): client.mt5_summary() def test_mt5_summary_non_callable_method_raises_clear_error(self) -> None: """Test mt5_summary fails clearly when a required method is not callable.""" client = Mt5CliClient( client=cast("Mt5DataClient", _NonCallableSummaryMethodClient()), ) with pytest.raises( TypeError, match="MT5 client attribute is not callable: version", ): client.mt5_summary() class TestCollectHistory: """Tests for collect_history SDK function.""" @pytest.fixture def history_client(self, mocker: MockerFixture) -> MagicMock: """Create a mocked Mt5DataClient with history-style DataFrames.""" return _build_history_client(mocker) def test_collect_history_writes_all_tables( self, tmp_path: Path, history_client: MagicMock, ) -> None: """Test that collect_history writes rates, ticks, and history tables.""" output = tmp_path / "history.db" collect_history( output, ["EURUSD", "GBPUSD"], "2024-01-01", "2024-02-01", ) assert history_client.copy_rates_range_as_df.call_count == 2 assert history_client.copy_ticks_range_as_df.call_count == 2 with sqlite3.connect(output) as conn: tables = { row[0] for row in conn.execute( "SELECT name FROM sqlite_master WHERE type='table'", ).fetchall() } assert {"rates", "ticks", "history_orders", "history_deals"} <= tables def test_collect_history_with_views( self, tmp_path: Path, history_client: MagicMock, # noqa: ARG002 ) -> None: """Test that with_views creates cash_events and positions views.""" output = tmp_path / "history.db" collect_history( output, ["EURUSD", "GBPUSD"], "2024-01-01", "2024-02-01", with_views=True, ) with sqlite3.connect(output) as conn: views = { row[0] for row in conn.execute( "SELECT name FROM sqlite_master WHERE type='view'", ).fetchall() } positions = { row[0] for row in conn.execute( "SELECT position_id FROM positions_reconstructed", ).fetchall() } assert {"cash_events", "positions_reconstructed"} <= views assert set(positions) == {100, 200, 500, 600} def test_collect_history_rates_table_has_timeframe( self, tmp_path: Path, history_client: MagicMock, # noqa: ARG002 ) -> None: """Test that the rates table carries the requested timeframe value.""" output = tmp_path / "history.db" collect_history( output, ["EURUSD"], "2024-01-01", "2024-02-01", datasets={Dataset.rates}, timeframe="H1", ) with sqlite3.connect(output) as conn: rows = conn.execute( "SELECT DISTINCT timeframe FROM rates", ).fetchall() assert rows == [(16385,)] def test_collect_history_views_skipped_when_columns_missing( self, tmp_path: Path, mocker: MockerFixture, caplog: pytest.LogCaptureFixture, ) -> None: """Test that views are not created when required columns are missing.""" client = MagicMock() client.copy_rates_range_as_df.return_value = pd.DataFrame({"x": [1]}) client.copy_ticks_range_as_df.return_value = pd.DataFrame({"x": [1]}) client.history_orders_get_as_df.return_value = pd.DataFrame({"x": [1]}) client.history_deals_get_as_df.return_value = pd.DataFrame({"x": [1]}) mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=client) output = tmp_path / "history.db" with caplog.at_level(logging.WARNING, logger="mt5cli.sdk"): collect_history( output, ["EURUSD"], "2024-01-01", "2024-02-01", with_views=True, ) with sqlite3.connect(output) as conn: views = { row[0] for row in conn.execute( "SELECT name FROM sqlite_master WHERE type='view'", ).fetchall() } assert "cash_events" not in views assert "positions_reconstructed" not in views class TestUpdateHistory: """Tests for update_history SDK functions.""" @pytest.fixture def connected_client(self) -> MagicMock: """Create a connected mock client without MT5 lifecycle patching.""" return MagicMock() def test_update_history_appends_incrementally( self, connected_client: MagicMock, mocker: MockerFixture, tmp_path: Path, ) -> None: """Test sequential SQLite history updates use existing max timestamps.""" date_to = datetime(2024, 1, 2, tzinfo=UTC) first_expected_start = datetime(2024, 1, 1, tzinfo=UTC) second_expected_start = datetime(2024, 1, 1, 12, tzinfo=UTC) rate_starts: list[datetime] = [] deal_starts: list[datetime] = [] def make_rates(**kwargs: object) -> pd.DataFrame: assert kwargs["symbol"] == "EURUSD" assert kwargs["timeframe"] == 1 assert kwargs["date_to"] == date_to rate_starts.append(kwargs["date_from"]) # type: ignore[arg-type] return pd.DataFrame({ "time": ["2024-01-01T12:00:00+00:00"], "open": [1.0 + len(rate_starts) / 10], }) def make_deals(**kwargs: object) -> pd.DataFrame: assert kwargs["date_to"] == date_to deal_starts.append(kwargs["date_from"]) # type: ignore[arg-type] return pd.DataFrame({ "ticket": [10], "position_id": [100], "symbol": ["EURUSD"], "time": ["2024-01-01T12:00:00+00:00"], "type": [0], "entry": [0], "volume": [1.0], "price": [1.1], "profit": [0.0], }) connected_client.copy_rates_range_as_df.side_effect = make_rates connected_client.history_deals_get_as_df.side_effect = make_deals mocker.patch("mt5cli.sdk.Mt5DataClient") output = tmp_path / "incremental-history.db" for _ in range(2): update_history( client=connected_client, output=output, symbols=["EURUSD"], datasets={Dataset.rates, Dataset.history_deals}, timeframes=["M1"], lookback_hours=24, date_to=date_to, with_views=True, ) assert rate_starts == [first_expected_start, second_expected_start] assert deal_starts == [first_expected_start, first_expected_start] connected_client.initialize_and_login_mt5.assert_not_called() connected_client.shutdown.assert_not_called() with sqlite3.connect(output) as conn: assert conn.execute("SELECT COUNT(*) FROM rates").fetchone() == (1,) assert conn.execute("SELECT open FROM rates").fetchone() == (1.2,) assert conn.execute( "SELECT COUNT(*) FROM history_deals", ).fetchone() == (1,) assert conn.execute( "SELECT name FROM sqlite_master WHERE name = 'cash_events'", ).fetchone() == ("cash_events",) def test_update_history_rejects_invalid_inputs( self, connected_client: MagicMock, tmp_path: Path, ) -> None: """Test validation errors for incremental history updates.""" output = tmp_path / "invalid-update.db" with pytest.raises(ValueError, match="At least one symbol"): update_history( client=connected_client, output=output, symbols=[], ) with pytest.raises(ValueError, match="lookback_hours must be positive"): update_history( client=connected_client, output=output, symbols=["EURUSD"], lookback_hours=0, ) with pytest.raises(ValueError, match="Invalid timeframe"): update_history( client=connected_client, output=output, symbols=["EURUSD"], datasets={Dataset.rates}, timeframes=["BAD"], ) with pytest.raises(ValueError, match="Invalid tick flags"): update_history( client=connected_client, output=output, symbols=["EURUSD"], datasets={Dataset.ticks}, flags="BAD", ) def test_update_history_noops_for_empty_datasets( self, connected_client: MagicMock, mocker: MockerFixture, tmp_path: Path, ) -> None: """Test empty dataset selection skips MT5 and SQLite writes.""" writer = mocker.patch("mt5cli.sdk.write_incremental_datasets") connect = mocker.patch("mt5cli.sdk.sqlite3.connect") update_history( client=connected_client, output=tmp_path / "empty-datasets.db", symbols=["EURUSD"], datasets=set(), ) writer.assert_not_called() connect.assert_not_called() def test_update_history_uses_all_default_timeframes( self, connected_client: MagicMock, mocker: MockerFixture, tmp_path: Path, ) -> None: """Test that timeframes=None writes rates for all default MT5 timeframes.""" timeframes_written: list[int] = [] def capture( *args: object, **_kwargs: object, ) -> tuple[set[Dataset], dict[Dataset, set[str]]]: timeframes_written.extend(args[4]) # type: ignore[arg-type] return set(), {} mocker.patch("mt5cli.sdk.write_incremental_datasets", side_effect=capture) update_history( client=connected_client, output=tmp_path / "default-timeframes.db", symbols=["EURUSD"], datasets={Dataset.rates}, timeframes=None, lookback_hours=1, date_to=datetime(2024, 1, 1, tzinfo=UTC), ) assert len(timeframes_written) == len(DEFAULT_HISTORY_TIMEFRAMES) def test_update_history_uses_specified_timeframes( self, connected_client: MagicMock, mocker: MockerFixture, tmp_path: Path, ) -> None: """Test explicit timeframes limit rate updates.""" timeframes_written: list[int] = [] def capture( *args: object, **_kwargs: object, ) -> tuple[set[Dataset], dict[Dataset, set[str]]]: timeframes_written.extend(args[4]) # type: ignore[arg-type] return set(), {} mocker.patch("mt5cli.sdk.write_incremental_datasets", side_effect=capture) update_history( client=connected_client, output=tmp_path / "specific-timeframes.db", symbols=["EURUSD"], datasets={Dataset.rates}, timeframes=["M1", "H1"], lookback_hours=1, date_to=datetime(2024, 1, 1, tzinfo=UTC), ) assert timeframes_written == [1, 16385] def test_update_history_updates_ticks_and_orders( self, connected_client: MagicMock, tmp_path: Path, ) -> None: """Test incremental update writes selected ticks and orders datasets.""" date_to = datetime(2024, 1, 2, tzinfo=UTC) expected_start = datetime(2024, 1, 1, tzinfo=UTC) def make_ticks(**kwargs: object) -> pd.DataFrame: assert kwargs["symbol"] == "EURUSD" assert kwargs["date_from"] == expected_start assert kwargs["date_to"] == date_to assert kwargs["flags"] == -1 return pd.DataFrame({ "time": ["2024-01-01T12:00:00+00:00"], "time_msc": [1_704_110_400_000], "bid": [1.1], }) def make_orders(**kwargs: object) -> pd.DataFrame: assert kwargs["symbol"] == "EURUSD" assert kwargs["date_from"] == expected_start assert kwargs["date_to"] == date_to return pd.DataFrame({ "ticket": [1], "symbol": ["EURUSD"], "time": ["2024-01-01T12:00:00+00:00"], "type": [0], }) connected_client.copy_ticks_range_as_df.side_effect = make_ticks connected_client.history_orders_get_as_df.side_effect = make_orders output = tmp_path / "ticks-orders.db" update_history( client=connected_client, output=output, symbols=["EURUSD"], datasets={Dataset.ticks, Dataset.history_orders}, lookback_hours=24, date_to=date_to, ) with sqlite3.connect(output) as conn: assert conn.execute("SELECT COUNT(*) FROM ticks").fetchone() == (1,) assert conn.execute( "SELECT COUNT(*) FROM history_orders", ).fetchone() == (1,) def test_update_history_with_config_opens_and_closes_connection( self, mocker: MockerFixture, tmp_path: Path, ) -> None: """Test update_history_with_config manages MT5 connection lifecycle.""" mock_client = MagicMock() mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=mock_client) updater = mocker.patch("mt5cli.sdk.update_history") update_history_with_config( output=tmp_path / "config-wrapper.db", symbols=["EURUSD"], datasets={Dataset.history_deals}, timeframes=["M1"], flags="ALL", lookback_hours=1, date_to=datetime(2024, 1, 1, tzinfo=UTC), deduplicate=False, create_rate_views=False, with_views=True, include_account_events=False, ) mock_client.initialize_and_login_mt5.assert_called_once() mock_client.shutdown.assert_called_once() updater.assert_called_once() assert updater.call_args.kwargs == { "client": mock_client, "output": tmp_path / "config-wrapper.db", "symbols": ["EURUSD"], "datasets": {Dataset.history_deals}, "timeframes": ["M1"], "flags": "ALL", "lookback_hours": 1, "date_to": datetime(2024, 1, 1, tzinfo=UTC), "deduplicate": False, "create_rate_views": False, "with_views": True, "include_account_events": False, } def test_update_history_with_config_validates_before_connecting( self, mocker: MockerFixture, tmp_path: Path, ) -> None: """Test invalid inputs fail before MT5 is initialized.""" mock_client = MagicMock() mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=mock_client) with pytest.raises(ValueError, match="lookback_hours must be positive"): update_history_with_config( output=tmp_path / "invalid-config.db", symbols=["EURUSD"], lookback_hours=0, ) mock_client.initialize_and_login_mt5.assert_not_called() mock_client.shutdown.assert_not_called() def test_update_history_with_config_noops_for_empty_datasets( self, mocker: MockerFixture, tmp_path: Path, ) -> None: """Test empty dataset selection skips MT5 initialization.""" mock_client = MagicMock() mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=mock_client) updater = mocker.patch("mt5cli.sdk.update_history") update_history_with_config( output=tmp_path / "empty-config.db", symbols=["EURUSD"], datasets=set(), ) mock_client.initialize_and_login_mt5.assert_not_called() mock_client.shutdown.assert_not_called() updater.assert_not_called() def test_update_history_defaults_date_to_now( self, connected_client: MagicMock, mocker: MockerFixture, tmp_path: Path, ) -> None: """Test update_history uses current UTC time when date_to is omitted.""" captured: dict[str, datetime] = {} def capture( *args: object, **_kwargs: object, ) -> tuple[set[Dataset], dict[Dataset, set[str]]]: captured["end"] = args[7] # type: ignore[assignment] return set(), {} mocker.patch("mt5cli.sdk.write_incremental_datasets", side_effect=capture) before = datetime.now(UTC) update_history( client=connected_client, output=tmp_path / "now-default.db", symbols=["EURUSD"], datasets={Dataset.rates}, timeframes=["M1"], lookback_hours=12, ) after = datetime.now(UTC) assert before <= captured["end"] <= after class TestRecentTicks: """Tests for recent_ticks helper.""" def test_recent_ticks_uses_explicit_date_to_window( self, mocker: MockerFixture, ) -> None: """Test recent_ticks fetches the requested trailing window.""" client = MagicMock() end = datetime(2024, 1, 2, 12, 0, 0, tzinfo=UTC) client.copy_ticks_from_as_df.return_value = pd.DataFrame({ "time": [end], "bid": [1.0], }) mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=client) result = recent_ticks( "EURUSD", 60, date_to=end, count=100, flags="INFO", config=build_config(login=123), ) assert isinstance(result, pd.DataFrame) client.copy_ticks_from_as_df.assert_called_once_with( symbol="EURUSD", date_from=end - timedelta(seconds=60), count=100, flags=1, ) client.copy_ticks_range_as_df.assert_not_called() def test_recent_ticks_uses_latest_tick_when_date_to_omitted( self, mocker: MockerFixture, ) -> None: """Test recent_ticks anchors the window on the latest tick time.""" client = MagicMock() tick = MagicMock() tick.time = datetime(2024, 1, 2, 12, 0, 0, tzinfo=UTC) client.symbol_info_tick.return_value = tick client.copy_ticks_from_as_df.return_value = pd.DataFrame({ "time": [1, 2], "bid": [1.0, 1.1], }) client.copy_ticks_range_as_df.return_value = pd.DataFrame({ "time": [1, 2, 3], "bid": [1.0, 1.1, 1.2], }) mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=client) result = Mt5CliClient().recent_ticks("EURUSD", 30, count=2, flags="ALL") assert len(result) == 2 client.symbol_info_tick.assert_called_once_with("EURUSD") client.copy_ticks_from_as_df.assert_called_once() _, kwargs = client.copy_ticks_range_as_df.call_args assert kwargs["symbol"] == "EURUSD" assert kwargs["date_to"] == tick.time assert kwargs["date_from"] == tick.time - timedelta(seconds=30) assert kwargs["flags"] == -1 def test_recent_ticks_rejects_unsupported_tick_time( self, mocker: MockerFixture, ) -> None: """Test recent_ticks raises when the latest tick time is unsupported.""" client = MagicMock() tick = MagicMock() tick.time = object() client.symbol_info_tick.return_value = tick mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=client) with pytest.raises(TypeError, match="Unsupported tick time value"): Mt5CliClient().recent_ticks("EURUSD", 30) @pytest.mark.parametrize( "tick_time", [ "2024-01-02T12:00:00+00:00", 1704196800, ], ) def test_recent_ticks_coerces_string_and_unix_tick_times( self, mocker: MockerFixture, tick_time: str | int, ) -> None: """Test recent_ticks accepts string and unix tick timestamps.""" client = MagicMock() tick = MagicMock() tick.time = tick_time client.symbol_info_tick.return_value = tick expected_end = ( datetime(2024, 1, 2, 12, 0, 0, tzinfo=UTC) if isinstance(tick_time, str) else datetime.fromtimestamp(tick_time, tz=UTC) ) client.copy_ticks_from_as_df.return_value = pd.DataFrame({ "time": [expected_end], }) mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=client) Mt5CliClient().recent_ticks("EURUSD", 30) _, kwargs = client.copy_ticks_from_as_df.call_args assert kwargs["date_from"] == expected_end - timedelta(seconds=30) def test_recent_ticks_returns_full_frame_when_count_not_positive( self, mocker: MockerFixture, ) -> None: """Test non-positive count returns the full range without trimming.""" client = MagicMock() end = datetime(2024, 1, 2, 12, 0, 0, tzinfo=UTC) client.copy_ticks_range_as_df.return_value = pd.DataFrame({ "time": [1, 2, 3], "bid": [1.0, 1.1, 1.2], }) mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=client) result = recent_ticks( "EURUSD", 60, date_to=end, count=0, config=build_config(login=123), ) assert len(result) == 3 client.copy_ticks_from_as_df.assert_not_called() client.copy_ticks_range_as_df.assert_called_once_with( symbol="EURUSD", date_from=end - timedelta(seconds=60), date_to=end, flags=-1, ) class TestMinimumMargins: """Tests for minimum_margins helper.""" def test_minimum_margins_shape( self, mocker: MockerFixture, ) -> None: """Test minimum_margins returns the expected summary columns.""" client = MagicMock() sym = MagicMock(volume_min=0.01) account = MagicMock(currency="USD") tick = MagicMock(ask=1.1010, bid=1.1000) client.symbol_info.return_value = sym client.account_info.return_value = account client.symbol_info_tick.return_value = tick client.order_calc_margin.side_effect = [12.5, 12.4] client.mt5.ORDER_TYPE_BUY = 0 client.mt5.ORDER_TYPE_SELL = 1 mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=client) result = minimum_margins("EURUSD", config=build_config(login=123)) pd.testing.assert_frame_equal( result, pd.DataFrame([ { "symbol": "EURUSD", "account_currency": "USD", "volume_min": 0.01, "buy_margin": 12.5, "sell_margin": 12.4, } ]), ) client.order_calc_margin.assert_any_call(0, "EURUSD", 0.01, 1.1010) client.order_calc_margin.assert_any_call(1, "EURUSD", 0.01, 1.1000) class TestMt5Session: """Tests for the mt5_session context manager.""" def test_yields_connected_client_and_shuts_down( self, mocker: MockerFixture, ) -> None: """Test mt5_session connects, yields a client wrapper, and shuts down.""" mock_client = MagicMock() mt5_data_client = mocker.patch( "mt5cli.sdk.Mt5DataClient", return_value=mock_client, ) with mt5_session(build_config(path="/opt/mt5/terminal64.exe")) as client: mock_client.initialize_and_login_mt5.assert_called_once() assert isinstance(client, Mt5CliClient) config = mt5_data_client.call_args.kwargs["config"] assert config.path == "/opt/mt5/terminal64.exe" mock_client.shutdown.assert_called_once() def test_default_config_attaches_to_running_terminal( self, mocker: MockerFixture, ) -> None: """Test mt5_session builds a default config when none is supplied.""" mock_client = MagicMock() mt5_data_client = mocker.patch( "mt5cli.sdk.Mt5DataClient", return_value=mock_client, ) with mt5_session(): pass mt5_data_client.assert_called_once() mock_client.shutdown.assert_called_once() class TestAccountSpec: """Tests for account configuration helpers.""" def test_repr_omits_password(self) -> None: """Test AccountSpec repr does not expose plaintext passwords.""" spec = AccountSpec(symbols=["EURUSD"], login=123, password="secret") assert "secret" not in repr(spec) assert "password" not in repr(spec) @pytest.mark.parametrize( ("login", "expected"), [ (None, None), (123, 123), ("", None), (" ", None), ("456", 456), ], ) def test_coerce_login( self, login: int | str | None, expected: int | None, ) -> None: """Test login values are normalized for account configs.""" assert sdk._coerce_login(login) == expected # type: ignore[reportPrivateUsage] def test_coerce_login_rejects_non_numeric_string(self) -> None: """Test non-numeric login strings raise ValueError.""" with pytest.raises(ValueError, match="invalid literal"): sdk._coerce_login("abc") # type: ignore[reportPrivateUsage] class TestCollectLatestRatesForAccounts: """Tests for collect_latest_rates_for_accounts.""" def test_merges_results_across_accounts( self, mock_client: MagicMock, mocker: MockerFixture, ) -> None: """Test rates are collected and merged for each account group.""" mt5_data_client = mocker.patch( "mt5cli.sdk.Mt5DataClient", return_value=mock_client, ) accounts = [ AccountSpec(symbols=["EURUSD"], login="123"), AccountSpec(symbols=["GBPUSD"], login=456), ] result = collect_latest_rates_for_accounts(accounts, ["M1"], count=2) assert set(result) == {("EURUSD", 1), ("GBPUSD", 1)} assert mt5_data_client.call_count == 2 assert mock_client.initialize_and_login_mt5.call_count == 2 assert mock_client.shutdown.call_count == 2 def test_builds_config_from_account_and_base( self, mock_client: MagicMock, mocker: MockerFixture, ) -> None: """Test account fields override base_config, empty login falls back.""" configs: list[object] = [] def _record_config(*, config: object, **_: object) -> MagicMock: configs.append(config) return mock_client mocker.patch("mt5cli.sdk.Mt5DataClient", side_effect=_record_config) base = build_config(login=999, server="Base-Server", timeout=5000) accounts = [ AccountSpec(symbols=["EURUSD"], login="", server="Acct-Server"), ] collect_latest_rates_for_accounts(accounts, ["M1"], count=1, base_config=base) assert len(configs) == 1 config = cast("Mt5Config", configs[0]) assert config.login == 999 assert config.server == "Acct-Server" assert config.timeout == 5000 @pytest.mark.parametrize( ("accounts", "timeframes", "count", "match"), [ ([], ["M1"], 1, "At least one account"), ([AccountSpec(symbols=["EURUSD"])], [], 1, "At least one timeframe"), ( [AccountSpec(symbols=[])], ["M1"], 1, "Each account requires at least one symbol", ), ( [AccountSpec(symbols=["EURUSD"])], ["M1"], 0, "count must be positive", ), ], ) def test_rejects_invalid_inputs( self, accounts: list[AccountSpec], timeframes: list[str], count: int, match: str, ) -> None: """Test input validation for account-level rate collection.""" with pytest.raises(ValueError, match=match): collect_latest_rates_for_accounts(accounts, timeframes, count) def test_rejects_empty_symbols_before_connecting( self, mocker: MockerFixture, ) -> None: """Test all account symbols are validated before any MT5 connection.""" mt5_data_client = mocker.patch("mt5cli.sdk.Mt5DataClient") accounts = [ AccountSpec(symbols=["EURUSD"], login=123), AccountSpec(symbols=[], login=456), ] with pytest.raises( ValueError, match="Each account requires at least one symbol" ): collect_latest_rates_for_accounts(accounts, ["M1"], count=1) mt5_data_client.assert_not_called() class TestCollectLatestRatesForAccountsWithRetries: """Tests for collect_latest_rates_for_accounts_with_retries.""" def test_returns_result_on_first_success(self, mocker: MockerFixture) -> None: """Test no retry happens when the first attempt succeeds.""" expected = {("EURUSD", 1): pd.DataFrame()} wrapped = mocker.patch( "mt5cli.sdk.collect_latest_rates_for_accounts", return_value=expected, ) sleep = mocker.patch("mt5cli.sdk.time.sleep") accounts = [AccountSpec(symbols=["EURUSD"])] result = collect_latest_rates_for_accounts_with_retries( accounts, ["M1"], count=1, retry_count=3, ) assert result is expected assert wrapped.call_count == 1 sleep.assert_not_called() def test_retries_then_succeeds(self, mocker: MockerFixture) -> None: """Test transient MT5 errors are retried with exponential backoff.""" expected = {("EURUSD", 1): pd.DataFrame()} wrapped = mocker.patch( "mt5cli.sdk.collect_latest_rates_for_accounts", side_effect=[ Mt5TradingError("boom"), Mt5RuntimeError("boom"), expected, ], ) sleep = mocker.patch("mt5cli.sdk.time.sleep") accounts = [AccountSpec(symbols=["EURUSD"])] result = collect_latest_rates_for_accounts_with_retries( accounts, ["M1"], count=1, retry_count=2, backoff_base=2, ) assert result is expected assert wrapped.call_count == 3 assert sleep.call_args_list == [call(2), call(4)] def test_reraises_after_exhausting_retries(self, mocker: MockerFixture) -> None: """Test the final error is re-raised once retries are exhausted.""" wrapped = mocker.patch( "mt5cli.sdk.collect_latest_rates_for_accounts", side_effect=Mt5RuntimeError("boom"), ) sleep = mocker.patch("mt5cli.sdk.time.sleep") accounts = [AccountSpec(symbols=["EURUSD"])] with pytest.raises(Mt5RuntimeError, match="boom"): collect_latest_rates_for_accounts_with_retries( accounts, ["M1"], count=1, retry_count=2, ) assert wrapped.call_count == 3 assert sleep.call_count == 2 def test_does_not_retry_unrelated_errors(self, mocker: MockerFixture) -> None: """Test non-MT5 errors propagate without retrying.""" wrapped = mocker.patch( "mt5cli.sdk.collect_latest_rates_for_accounts", side_effect=ValueError("bad input"), ) sleep = mocker.patch("mt5cli.sdk.time.sleep") with pytest.raises(ValueError, match="bad input"): collect_latest_rates_for_accounts_with_retries( [AccountSpec(symbols=["EURUSD"])], ["M1"], count=1, retry_count=3, ) assert wrapped.call_count == 1 sleep.assert_not_called() class TestCollectLatestClosedRatesForAccounts: """Tests for collect_latest_closed_rates_for_accounts.""" def test_fetches_count_plus_one_and_drops_forming_bar( self, mocker: MockerFixture, ) -> None: """Test closed-bar collection requests one extra bar at start_pos=0.""" df_rate = pd.DataFrame({"time": [1, 2, 3], "close": [1.1, 1.2, 1.3]}) wrapped = mocker.patch( "mt5cli.sdk.collect_latest_rates_for_accounts_with_retries", return_value={("EURUSD", 1): df_rate}, ) accounts = [AccountSpec(symbols=["EURUSD"])] result = collect_latest_closed_rates_for_accounts( accounts, ["M1"], count=2, retry_count=1, backoff_base=3, ) wrapped.assert_called_once_with( accounts, ["M1"], 3, start_pos=0, base_config=None, retry_count=1, backoff_base=3, ) pd.testing.assert_frame_equal( result["EURUSD", 1], pd.DataFrame({"time": [1, 2], "close": [1.1, 1.2]}), ) def test_rejects_forming_bar_only_frames(self, mocker: MockerFixture) -> None: """Test empty results after dropping the forming bar raise ValueError.""" mocker.patch( "mt5cli.sdk.collect_latest_rates_for_accounts_with_retries", return_value={("EURUSD", 1): pd.DataFrame({"time": [1], "close": [1.1]})}, ) with pytest.raises(ValueError, match="Rate data is empty"): collect_latest_closed_rates_for_accounts( [AccountSpec(symbols=["EURUSD"])], ["M1"], count=1, ) def test_skips_extra_fetch_when_start_pos_nonzero( self, mocker: MockerFixture, ) -> None: """Test start_pos > 0 fetches count bars without dropping the last row.""" df_rate = pd.DataFrame({"time": [1, 2], "close": [1.1, 1.2]}) wrapped = mocker.patch( "mt5cli.sdk.collect_latest_rates_for_accounts_with_retries", return_value={("EURUSD", 1): df_rate}, ) result = collect_latest_closed_rates_for_accounts( [AccountSpec(symbols=["EURUSD"])], ["M1"], count=2, start_pos=1, ) wrapped.assert_called_once_with( [AccountSpec(symbols=["EURUSD"])], ["M1"], 2, start_pos=1, base_config=None, retry_count=0, backoff_base=2.0, ) pd.testing.assert_frame_equal(result["EURUSD", 1], df_rate) def test_rejects_zero_count_before_fetching(self, mocker: MockerFixture) -> None: """Test count=0 is rejected before any MT5 collection attempt.""" wrapped = mocker.patch( "mt5cli.sdk.collect_latest_rates_for_accounts_with_retries", ) with pytest.raises(ValueError, match="count must be positive"): collect_latest_closed_rates_for_accounts( [AccountSpec(symbols=["EURUSD"])], ["M1"], count=0, ) wrapped.assert_not_called() def test_rejects_negative_start_pos(self, mocker: MockerFixture) -> None: """Test negative start_pos is rejected before any MT5 collection attempt.""" wrapped = mocker.patch( "mt5cli.sdk.collect_latest_rates_for_accounts_with_retries", ) with pytest.raises(ValueError, match="start_pos must be non-negative"): collect_latest_closed_rates_for_accounts( [AccountSpec(symbols=["EURUSD"])], ["M1"], count=1, start_pos=-1, ) wrapped.assert_not_called() def test_rejects_empty_frames_with_start_pos_nonzero( self, mocker: MockerFixture, ) -> None: """Test empty upstream frames raise ValueError when start_pos > 0.""" mocker.patch( "mt5cli.sdk.collect_latest_rates_for_accounts_with_retries", return_value={("EURUSD", 1): pd.DataFrame(columns=["time", "close"])}, ) with pytest.raises(ValueError, match="Rate data is empty"): collect_latest_closed_rates_for_accounts( [AccountSpec(symbols=["EURUSD"])], ["M1"], count=1, start_pos=1, ) def test_processes_multiple_symbol_timeframe_pairs( self, mocker: MockerFixture, ) -> None: """Test each returned series is trimmed and validated independently.""" mocker.patch( "mt5cli.sdk.collect_latest_rates_for_accounts_with_retries", return_value={ ("EURUSD", 1): pd.DataFrame( {"time": [1, 2, 3], "close": [1.1, 1.2, 1.3]}, ), ("GBPUSD", 16385): pd.DataFrame( {"time": [4, 5, 6], "close": [2.1, 2.2, 2.3]}, ), }, ) result = collect_latest_closed_rates_for_accounts( [AccountSpec(symbols=["EURUSD", "GBPUSD"])], ["M1", "H1"], count=2, ) assert set(result) == {("EURUSD", 1), ("GBPUSD", 16385)} pd.testing.assert_frame_equal( result["EURUSD", 1], pd.DataFrame({"time": [1, 2], "close": [1.1, 1.2]}), ) pd.testing.assert_frame_equal( result["GBPUSD", 16385], pd.DataFrame({"time": [4, 5], "close": [2.1, 2.2]}), ) class TestCollectLatestClosedRatesByGranularity: """Tests for collect_latest_closed_rates_by_granularity.""" def test_rekeys_by_granularity_name(self, mocker: MockerFixture) -> None: """Test closed rates are keyed by symbol and granularity name.""" df_rate = pd.DataFrame({"time": [1, 2], "close": [1.1, 1.2]}) wrapped = mocker.patch( "mt5cli.sdk.collect_latest_closed_rates_for_accounts", return_value={("EURUSD", 1): df_rate}, ) result = collect_latest_closed_rates_by_granularity( [AccountSpec(symbols=["EURUSD"])], ["M1"], count=2, ) wrapped.assert_called_once_with( [AccountSpec(symbols=["EURUSD"])], ["M1"], 2, start_pos=0, base_config=None, retry_count=0, backoff_base=2.0, ) assert ("EURUSD", "M1") in result pd.testing.assert_frame_equal(result["EURUSD", "M1"], df_rate) class TestSubstituteEnvPlaceholders: """Tests for ${ENV_VAR} substitution.""" def test_substitutes_known_variables( self, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test placeholders are replaced with environment values.""" monkeypatch.setenv("MT5_LOGIN", "12345") monkeypatch.setenv("MT5_SERVER", "Broker-Demo") assert substitute_env_placeholders("${MT5_LOGIN}") == "12345" assert substitute_env_placeholders("srv=${MT5_SERVER}!") == "srv=Broker-Demo!" def test_returns_plain_strings_unchanged(self) -> None: """Test strings without placeholders are returned as-is.""" assert substitute_env_placeholders("plain") == "plain" def test_raises_on_missing_variable( self, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test a missing environment variable raises a clear error.""" monkeypatch.delenv("MT5_MISSING", raising=False) with pytest.raises(ValueError, match="'MT5_MISSING' is not set"): substitute_env_placeholders("${MT5_MISSING}") class TestResolveAccountSpec: """Tests for resolve_account_spec and resolve_account_specs.""" def test_substitutes_env_placeholders_in_account( self, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test account string fields resolve ${ENV_VAR} placeholders.""" monkeypatch.setenv("MT5_PASSWORD", "secret") account = AccountSpec( symbols=["EURUSD"], login="${MT5_LOGIN}", password="${MT5_PASSWORD}", ) monkeypatch.setenv("MT5_LOGIN", "999") resolved = resolve_account_spec(account) assert resolved.login == "999" assert resolved.password == "secret" # noqa: S105 assert resolved.symbols == ["EURUSD"] def test_explicit_overrides_take_precedence(self) -> None: """Test explicit override values win over account fields.""" account = AccountSpec(symbols=["EURUSD"], login=111, server="Acct") resolved = resolve_account_spec( account, login=222, server="Override", timeout=5000, ) assert resolved.login == 222 assert resolved.server == "Override" assert resolved.timeout == 5000 def test_resolves_string_login_override( self, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test string login overrides expand ${ENV_VAR} placeholders.""" monkeypatch.setenv("MT5_LOGIN", "777") account = AccountSpec(symbols=["EURUSD"], login=111) resolved = resolve_account_spec(account, login="${MT5_LOGIN}") assert resolved.login == "777" def test_preserves_integer_login_without_coercion(self) -> None: """Test integer logins remain integers after resolution.""" account = AccountSpec(symbols=["EURUSD"], login=111) resolved = resolve_account_spec(account) assert resolved.login == 111 assert isinstance(resolved.login, int) def test_raises_on_missing_env_variable( self, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test missing environment variables raise ValueError.""" monkeypatch.delenv("MT5_NOPE", raising=False) account = AccountSpec(symbols=["EURUSD"], server="${MT5_NOPE}") with pytest.raises(ValueError, match="'MT5_NOPE' is not set"): resolve_account_spec(account) def test_resolve_account_specs_applies_to_all( self, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test resolve_account_specs resolves every account in order.""" monkeypatch.setenv("MT5_SERVER", "Shared") accounts = [ AccountSpec(symbols=["EURUSD"], server="${MT5_SERVER}"), AccountSpec(symbols=["GBPUSD"], server="Fixed"), ] resolved = resolve_account_specs(accounts, timeout=1000) assert [a.server for a in resolved] == ["Shared", "Fixed"] assert all(a.timeout == 1000 for a in resolved) class TestThrottledHistoryUpdater: """Tests for the throttled incremental history updater.""" def test_updates_every_call_when_interval_non_positive( self, mocker: MockerFixture, ) -> None: """Test interval_seconds <= 0 updates on every call.""" update = mocker.patch("mt5cli.sdk.update_history") client = MagicMock() updater = ThrottledHistoryUpdater(output="history.db", interval_seconds=0) assert updater.update(client, ["EURUSD"]) is True assert updater.update(client, ["EURUSD"]) is True assert update.call_count == 2 def test_throttles_within_interval(self, mocker: MockerFixture) -> None: """Test updates are skipped until the interval elapses.""" update = mocker.patch("mt5cli.sdk.update_history") monotonic = mocker.patch("mt5cli.sdk.time.monotonic") # Calls: set(t=100), check(t=105), check(t=200), set(t=200). monotonic.side_effect = [100.0, 105.0, 200.0, 200.0] client = MagicMock() updater = ThrottledHistoryUpdater(output="history.db", interval_seconds=60) assert updater.update(client, ["EURUSD"]) is True # first update at t=100 assert updater.update(client, ["EURUSD"]) is False # t=105, throttled assert updater.update(client, ["EURUSD"]) is True # t=200, elapsed assert update.call_count == 2 def test_update_passes_expected_arguments( self, mocker: MockerFixture, ) -> None: """Test update_history is called with the configured arguments.""" update = mocker.patch("mt5cli.sdk.update_history") client = MagicMock() updater = ThrottledHistoryUpdater( output="history.db", datasets={Dataset.rates}, timeframes=["M1", "H1"], flags="INFO", lookback_hours=12.0, with_views=True, include_account_events=False, ) updater.update(client, ["EURUSD", "GBPUSD"]) update.assert_called_once_with( client=client, output="history.db", symbols=["EURUSD", "GBPUSD"], datasets={Dataset.rates}, timeframes=["M1", "H1"], flags="INFO", lookback_hours=12.0, with_views=True, include_account_events=False, ) def test_propagates_errors_by_default(self, mocker: MockerFixture) -> None: """Test MT5/SQLite errors propagate and do not advance the throttle.""" mocker.patch( "mt5cli.sdk.update_history", side_effect=Mt5RuntimeError("boom"), ) updater = ThrottledHistoryUpdater(output="history.db") with pytest.raises(Mt5RuntimeError, match="boom"): updater.update(MagicMock(), ["EURUSD"]) assert updater.last_update_monotonic is None @pytest.mark.parametrize( "error", [ Mt5RuntimeError("boom"), Mt5TradingError("trade failed"), sqlite3.OperationalError("locked"), ValueError("invalid symbols"), OSError("disk full"), AttributeError( "'StubClient' object has no attribute 'copy_rates_range_as_df'", name="copy_rates_range_as_df", ), AttributeError( "MT5 client is missing required method: copy_ticks_range_as_df" ), TypeError("MT5 client attribute is not callable: history_orders_get_as_df"), ], ) def test_suppresses_errors_when_requested( self, mocker: MockerFixture, error: Exception, ) -> None: """Test suppress_errors swallows recoverable errors and returns False.""" mocker.patch( "mt5cli.sdk.update_history", side_effect=error, ) updater = ThrottledHistoryUpdater( output="history.db", suppress_errors=True, ) assert updater.update(MagicMock(), ["EURUSD"]) is False assert updater.last_update_monotonic is None @pytest.mark.parametrize( "error", [ AttributeError("'dict' object has no attribute 'typo'"), TypeError("unsupported operand types"), ], ) def test_suppress_errors_does_not_hide_programming_errors( self, mocker: MockerFixture, error: Exception, ) -> None: """Test generic AttributeError/TypeError still propagate when suppressed.""" mocker.patch( "mt5cli.sdk.update_history", side_effect=error, ) updater = ThrottledHistoryUpdater( output="history.db", suppress_errors=True, ) with pytest.raises(type(error)): updater.update(MagicMock(), ["EURUSD"]) assert updater.last_update_monotonic is None @pytest.mark.parametrize( ("error", "expected"), [ (AttributeError("MT5 client is missing required method: version"), True), ( AttributeError( "'Stub' object has no attribute 'copy_rates_range_as_df'", name="copy_rates_range_as_df", ), True, ), (AttributeError("'dict' object has no attribute 'typo'"), False), (TypeError("MT5 client attribute is not callable: version"), True), (TypeError("unsupported operand types"), False), (TypeError("'NoneType' object is not callable"), False), (ValueError("invalid"), False), ], ) def test_is_mt5_client_capability_error( self, error: BaseException, expected: bool, ) -> None: """Test MT5 client capability error detection.""" assert sdk._is_mt5_client_capability_error(error) is expected # type: ignore[reportPrivateUsage] def test_is_mt5_client_capability_error_for_non_callable_history_client( self, ) -> None: """Test non-callable history client attributes are capability errors.""" client = MagicMock() client.copy_rates_range_as_df = None with ( sqlite3.connect(":memory:") as conn, pytest.raises(TypeError, match="not callable") as exc_info, ): write_rates_dataset( conn, client, ["EURUSD"], 1, datetime.now(UTC), datetime.now(UTC), IfExists.APPEND, {}, ) assert sdk._is_mt5_client_capability_error(exc_info.value) is True # type: ignore[reportPrivateUsage] def test_suppresses_non_callable_history_client_method( self, tmp_path: Path, ) -> None: """Test suppress_errors swallows non-callable history client API attributes.""" client = MagicMock() client.copy_rates_range_as_df = None updater = ThrottledHistoryUpdater( output=tmp_path / "history.db", datasets={Dataset.rates}, timeframes=["M1"], suppress_errors=True, ) assert updater.update(client, ["EURUSD"]) is False assert updater.last_update_monotonic is None def test_suppress_errors_does_not_hide_internal_client_type_error( self, mocker: MockerFixture, ) -> None: """Test TypeError raised inside a callable client method still propagates.""" mocker.patch( "mt5cli.sdk.update_history", side_effect=TypeError("'int' object is not callable"), ) updater = ThrottledHistoryUpdater( output="history.db", suppress_errors=True, ) with pytest.raises(TypeError, match="not callable"): updater.update(MagicMock(), ["EURUSD"]) assert updater.last_update_monotonic is None def test_suppresses_validation_errors_before_update( self, mocker: MockerFixture, ) -> None: """Test validation failures are suppressed without calling update_history.""" update = mocker.patch("mt5cli.sdk.update_history") updater = ThrottledHistoryUpdater( output="history.db", suppress_errors=True, ) assert updater.update(MagicMock(), []) is False update.assert_not_called() assert updater.last_update_monotonic is None