Files
mt5cli/tests/test_sdk.py
T
Daichi Narushima d156dd7176 [codex] fix mt5 adapter APIs (#36)
* fix mt5 adapter APIs

* address PR feedback

* fix zero ratio minimum volume sizing

* Bump version to v0.8.0
2026-06-15 02:47:05 +09:00

2103 lines
72 KiB
Python

"""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,
fetch_latest_closed_rates,
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, coerce_login
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 coerce_login(login) == expected
def test_coerce_login_rejects_non_numeric_string(self) -> None:
"""Test non-numeric login strings raise ValueError."""
with pytest.raises(ValueError, match="invalid literal"):
coerce_login("abc")
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 TestFetchLatestClosedRates:
"""Tests for fetch_latest_closed_rates."""
def test_fetches_extra_bar_and_drops_forming_row(self) -> None:
"""Test single-symbol closed-bar helper hides the forming bar."""
client = MagicMock()
client.latest_rates.return_value = pd.DataFrame(
{
"time": [1, 2, 3],
"close": [1.0, 1.1, 1.2],
},
)
result = fetch_latest_closed_rates(
client,
symbol="EURUSD",
granularity="M1",
count=2,
)
client.latest_rates.assert_called_once_with(
"EURUSD",
"M1",
3,
start_pos=0,
)
assert list(result["close"]) == [1.0, 1.1]
def test_raises_when_no_closed_bars_are_available(self) -> None:
"""Test empty closed-bar results raise an actionable ValueError."""
client = MagicMock()
client.latest_rates.return_value = pd.DataFrame({"close": [1.0]})
with pytest.raises(ValueError, match="Rate data is empty"):
fetch_latest_closed_rates(
client,
symbol="EURUSD",
granularity="M1",
count=1,
)
def test_rejects_non_positive_count_before_fetching(self) -> None:
"""Test invalid count values fail before calling MT5."""
client = MagicMock()
with pytest.raises(ValueError, match="count must be positive"):
fetch_latest_closed_rates(
client,
symbol="EURUSD",
granularity="M1",
count=0,
)
client.latest_rates.assert_not_called()
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