Add generic MT5 rate-loading SDK APIs for downstream reuse (#20)

This commit is contained in:
Daichi Narushima
2026-06-09 15:37:24 +09:00
committed by GitHub
parent 9957b0a1de
commit 1b69e8f08e
7 changed files with 946 additions and 11 deletions
+323
View File
@@ -10,14 +10,18 @@ from unittest.mock import MagicMock
import pandas as pd
import pytest
from pytest_mock import MockerFixture # noqa: TC002
if TYPE_CHECKING:
from pathlib import Path
from mt5cli import history
from mt5cli.history import (
DEFAULT_HISTORY_TIMEFRAMES,
RateTarget,
append_dataframe,
augment_written_columns_from_sqlite,
build_rate_targets,
build_rate_view_name,
create_cash_events_view,
create_history_indexes,
@@ -33,6 +37,7 @@ from mt5cli.history import (
load_incremental_start_datetimes,
load_rate_data,
load_rate_data_from_connection,
load_rate_series_from_sqlite,
parse_sqlite_timestamp,
quote_sqlite_identifier,
record_written_columns,
@@ -40,6 +45,7 @@ from mt5cli.history import (
resolve_history_datasets,
resolve_history_tick_flags,
resolve_history_timeframes,
resolve_rate_tables,
resolve_rate_view_name,
resolve_rate_view_names,
write_collected_datasets,
@@ -60,6 +66,21 @@ class TestResolveRateViewName:
assert resolve_rate_view_name(db_path, "EURUSD", "M1") == "rate_EURUSD__1"
assert not db_path.exists()
def test_none_path_returns_default_name(self) -> None:
"""Test a None connection or path returns the deterministic default."""
assert resolve_rate_view_name(None, "EURUSD", "M1") == "rate_EURUSD__1"
assert resolve_rate_view_names(None, ["EURUSD"], ["M1", "H1"]) == [
"rate_EURUSD__1",
"rate_EURUSD__16385",
]
def test_none_path_with_require_existing_raises(self) -> None:
"""Test a None path under strict mode raises a clear error."""
with pytest.raises(ValueError, match="SQLite database not found"):
resolve_rate_view_name(None, "EURUSD", "M1", require_existing=True)
with pytest.raises(ValueError, match="SQLite database not found"):
resolve_rate_view_names(None, ["EURUSD"], ["M1"], require_existing=True)
def test_no_rates_table_falls_back_to_single_timeframe_name(
self,
tmp_path: Path,
@@ -1915,3 +1936,305 @@ class TestWriteHelpers:
)
assert get_table_columns(conn, "rates") == {"time", "open"}
create_history_indexes(conn, written_columns)
class TestRateSourceHelpers:
"""Tests for generic rate-source SDK helpers."""
def test_rate_target_timeframe_int(self) -> None:
"""Test RateTarget resolves named and integer timeframes."""
target = RateTarget(symbol="EURUSD", timeframe="M1")
assert target.timeframe == 1
assert target.timeframe_int == 1
assert RateTarget(symbol="EURUSD", timeframe=16385).timeframe_int == 16385
def test_build_rate_targets_row_major(self) -> None:
"""Test targets are built in row-major symbol/timeframe order."""
targets = build_rate_targets(["EURUSD", "GBPUSD"], ["M1", "H1"])
assert [(t.symbol, t.timeframe) for t in targets] == [
("EURUSD", 1),
("EURUSD", 16385),
("GBPUSD", 1),
("GBPUSD", 16385),
]
def test_build_rate_targets_allows_missing_symbol(self) -> None:
"""Test missing symbols produce None-symbol targets when allowed."""
targets = build_rate_targets([], ["M1", "H1"], allow_missing_symbol=True)
assert [(t.symbol, t.timeframe) for t in targets] == [
(None, 1),
(None, 16385),
]
@pytest.mark.parametrize(
("symbols", "timeframes", "match"),
[
(["EURUSD"], [], "At least one timeframe"),
([], ["M1"], "At least one symbol"),
],
)
def test_build_rate_targets_rejects_empty(
self,
symbols: list[str],
timeframes: list[str],
match: str,
) -> None:
"""Test target building input validation."""
with pytest.raises(ValueError, match=match):
build_rate_targets(symbols, timeframes)
def test_resolve_rate_tables_uses_explicit_tables(self) -> None:
"""Test explicit tables bypass view resolution when counts match."""
targets = build_rate_targets([], ["M1", "H1"], allow_missing_symbol=True)
assert resolve_rate_tables(None, targets, ["t1", "t2"]) == ["t1", "t2"]
def test_resolve_rate_tables_rejects_mismatched_explicit_count(self) -> None:
"""Test explicit table count must match the number of targets."""
targets = build_rate_targets(["EURUSD"], ["M1"])
with pytest.raises(ValueError, match="Expected 1 explicit table"):
resolve_rate_tables(None, targets, ["t1", "t2"])
def test_resolve_rate_tables_rejects_empty_targets(self) -> None:
"""Test resolving requires at least one target."""
with pytest.raises(ValueError, match="At least one rate target"):
resolve_rate_tables(None, [])
def test_resolve_rate_tables_requires_symbol_without_explicit(self) -> None:
"""Test None-symbol targets require explicit tables."""
targets = build_rate_targets([], ["M1"], allow_missing_symbol=True)
with pytest.raises(ValueError, match="without a symbol"):
resolve_rate_tables(None, targets)
def test_resolve_rate_tables_resolves_view_names(self) -> None:
"""Test symbol targets resolve to default view names without a database."""
targets = build_rate_targets(["EURUSD"], ["M1", "H1"])
assert resolve_rate_tables(None, targets) == [
"rate_EURUSD__1",
"rate_EURUSD__16385",
]
def test_resolve_rate_tables_none_path_with_require_existing_raises(self) -> None:
"""Test strict mode rejects a missing database path."""
targets = build_rate_targets(["EURUSD"], ["M1"])
with pytest.raises(ValueError, match="SQLite database not found"):
resolve_rate_tables(None, targets, require_existing=True)
def test_resolve_rate_tables_missing_db_with_require_existing_raises(
self,
tmp_path: Path,
) -> None:
"""Test strict mode rejects a non-existing database path."""
db_path = tmp_path / "missing.db"
targets = build_rate_targets(["EURUSD"], ["M1"])
with pytest.raises(ValueError, match="SQLite database not found"):
resolve_rate_tables(db_path, targets, require_existing=True)
def test_resolve_rate_tables_missing_view_with_require_existing_raises(
self,
tmp_path: Path,
) -> None:
"""Test strict mode rejects databases without managed rate views."""
db_path = tmp_path / "no-views.db"
with sqlite3.connect(db_path) as conn:
conn.execute(
"CREATE TABLE rates("
" symbol TEXT, timeframe INTEGER, time TEXT, close REAL)",
)
conn.execute(
"INSERT INTO rates(symbol, timeframe, time, close) VALUES (?, ?, ?, ?)",
("EURUSD", 1, "2024-01-01T00:00:00+00:00", 1.0),
)
targets = build_rate_targets(["EURUSD"], ["M1"])
with pytest.raises(ValueError, match="No rate compatibility view exists"):
resolve_rate_tables(db_path, targets, require_existing=True)
def test_resolve_rate_tables_with_require_existing_resolves_views(
self,
tmp_path: Path,
) -> None:
"""Test strict mode resolves existing managed rate views."""
db_path = tmp_path / "strict-views.db"
with sqlite3.connect(db_path) as conn:
conn.execute(
"CREATE TABLE rates("
" symbol TEXT, timeframe INTEGER, time TEXT, close REAL)",
)
conn.execute(
"INSERT INTO rates(symbol, timeframe, time, close) VALUES (?, ?, ?, ?)",
("EURUSD", 1, "2024-01-01T00:00:00+00:00", 1.0),
)
create_rate_compatibility_views(conn)
targets = build_rate_targets(["EURUSD"], ["M1"])
assert resolve_rate_tables(db_path, targets, require_existing=True) == [
"rate_EURUSD__1",
]
def test_resolve_rate_tables_batches_sqlite_metadata(
self,
tmp_path: Path,
mocker: MockerFixture,
) -> None:
"""Test resolving multiple targets loads SQLite metadata once."""
db_path = tmp_path / "batch-rate-tables.db"
with sqlite3.connect(db_path) as conn:
conn.execute(
"CREATE TABLE rates("
" symbol TEXT, timeframe INTEGER, time TEXT, close REAL)",
)
conn.executemany(
"INSERT INTO rates(symbol, timeframe, time, close) VALUES (?, ?, ?, ?)",
[
("EURUSD", 1, "2024-01-01T00:00:00+00:00", 1.0),
("EURUSD", 16385, "2024-01-01T01:00:00+00:00", 1.1),
("GBPUSD", 1, "2024-01-01T00:00:00+00:00", 1.2),
],
)
create_rate_compatibility_views(conn)
counts_spy = mocker.spy(history, "_load_rates_timeframe_counts")
views_spy = mocker.spy(history, "_load_existing_rate_views")
targets = build_rate_targets(["EURUSD", "GBPUSD"], ["M1", "H1"])
assert resolve_rate_tables(db_path, targets) == [
"rate_EURUSD__M1_1",
"rate_EURUSD__H1_16385",
"rate_GBPUSD__1",
"rate_GBPUSD__16385",
]
assert counts_spy.call_count == 1
assert views_spy.call_count == 1
def test_load_rate_series_from_sqlite(self, tmp_path: Path) -> None:
"""Test loading multiple rate series keyed by symbol and timeframe."""
db_path = tmp_path / "series.db"
with sqlite3.connect(db_path) as conn:
conn.execute(
"CREATE TABLE rates("
" symbol TEXT, timeframe INTEGER, time TEXT, close REAL)",
)
conn.executemany(
"INSERT INTO rates(symbol, timeframe, time, close) VALUES (?, ?, ?, ?)",
[
("EURUSD", 1, "2024-01-01T00:00:00+00:00", 1.0),
("EURUSD", 1, "2024-01-01T00:01:00+00:00", 1.1),
],
)
create_rate_compatibility_views(conn)
targets = build_rate_targets(["EURUSD"], ["M1"])
result = load_rate_series_from_sqlite(db_path, targets, count=2)
assert set(result) == {("EURUSD", 1)}
assert len(result["EURUSD", 1]) == 2
def test_load_rate_series_reuses_path_connection(
self,
tmp_path: Path,
mocker: MockerFixture,
) -> None:
"""Test loading from a path opens SQLite once for resolve and reads."""
db_path = tmp_path / "single-open-series.db"
with sqlite3.connect(db_path) as conn:
conn.execute(
"CREATE TABLE rates("
" symbol TEXT, timeframe INTEGER, time TEXT, close REAL)",
)
conn.execute(
"INSERT INTO rates(symbol, timeframe, time, close) VALUES (?, ?, ?, ?)",
("EURUSD", 1, "2024-01-01T00:00:00+00:00", 1.0),
)
create_rate_compatibility_views(conn)
connect_spy = mocker.spy(history.sqlite3, "connect")
result = load_rate_series_from_sqlite(
db_path,
build_rate_targets(["EURUSD"], ["M1"]),
count=1,
)
assert set(result) == {("EURUSD", 1)}
assert connect_spy.call_count == 1
def test_load_rate_series_with_explicit_tables(self, tmp_path: Path) -> None:
"""Test explicit tables and None-symbol targets load series."""
db_path = tmp_path / "explicit.db"
with sqlite3.connect(db_path) as conn:
conn.execute("CREATE TABLE custom_view(time TEXT, close REAL)")
conn.execute(
"INSERT INTO custom_view(time, close) VALUES (?, ?)",
("2024-01-01T00:00:00+00:00", 1.0),
)
targets = build_rate_targets([], ["M1"], allow_missing_symbol=True)
result = load_rate_series_from_sqlite(
db_path,
targets,
count=1,
explicit_tables=["custom_view"],
)
assert set(result) == {(None, 1)}
def test_load_rate_series_rejects_non_positive_count(self) -> None:
"""Test loading requires a positive count."""
targets = build_rate_targets(["EURUSD"], ["M1"])
with pytest.raises(ValueError, match="count must be positive"):
load_rate_series_from_sqlite("unused.db", targets, count=0)
def test_load_rate_series_rejects_empty_targets(self) -> None:
"""Test loading requires at least one target before opening SQLite."""
with pytest.raises(ValueError, match="At least one rate target"):
load_rate_series_from_sqlite("unused.db", [], count=1)
def test_load_rate_series_requires_symbol_without_explicit_tables(self) -> None:
"""Test None-symbol targets require explicit tables before opening SQLite."""
targets = build_rate_targets([], ["M1"], allow_missing_symbol=True)
with pytest.raises(ValueError, match="without a symbol"):
load_rate_series_from_sqlite("unused.db", targets, count=1)
def test_load_rate_series_requires_existing_managed_views(
self,
tmp_path: Path,
) -> None:
"""Test loading without explicit tables requires managed rate views."""
db_path = tmp_path / "no-managed-views.db"
with sqlite3.connect(db_path) as conn:
conn.execute(
"CREATE TABLE rates("
" symbol TEXT, timeframe INTEGER, time TEXT, close REAL)",
)
conn.execute(
"INSERT INTO rates(symbol, timeframe, time, close) VALUES (?, ?, ?, ?)",
("EURUSD", 1, "2024-01-01T00:00:00+00:00", 1.0),
)
targets = build_rate_targets(["EURUSD"], ["M1"])
with pytest.raises(ValueError, match="No rate compatibility view exists"):
load_rate_series_from_sqlite(db_path, targets, count=1)
def test_load_rate_series_rejects_duplicate_targets(self) -> None:
"""Test duplicate (symbol, timeframe) targets are rejected."""
targets = [
RateTarget("EURUSD", 1),
RateTarget("EURUSD", "M1"),
]
with pytest.raises(ValueError, match=r"Duplicate rate target: \('EURUSD', 1\)"):
load_rate_series_from_sqlite("unused.db", targets, count=1)
def test_load_rate_series_rejects_duplicate_targets_with_explicit_tables(
self,
tmp_path: Path,
) -> None:
"""Test duplicate targets are rejected even with explicit tables."""
db_path = tmp_path / "duplicate-explicit.db"
with sqlite3.connect(db_path) as conn:
conn.execute("CREATE TABLE custom_view(time TEXT, close REAL)")
conn.execute(
"INSERT INTO custom_view(time, close) VALUES (?, ?)",
("2024-01-01T00:00:00+00:00", 1.0),
)
targets = [
RateTarget("EURUSD", 1),
RateTarget("EURUSD", 1),
]
with pytest.raises(ValueError, match=r"Duplicate rate target: \('EURUSD', 1\)"):
load_rate_series_from_sqlite(
db_path,
targets,
count=1,
explicit_tables=["custom_view", "custom_view"],
)
+178 -1
View File
@@ -15,16 +15,18 @@ from pytest_mock import MockerFixture # noqa: TC002
if TYPE_CHECKING:
from pathlib import Path
from pdmt5 import Mt5DataClient
from pdmt5 import Mt5Config, Mt5DataClient
from mt5cli import sdk
from mt5cli.history import DEFAULT_HISTORY_TIMEFRAMES
from mt5cli.sdk import (
AccountSpec,
Mt5CliClient,
account_info,
build_config,
collect_history,
collect_latest_rates,
collect_latest_rates_for_accounts,
copy_rates_from,
copy_rates_from_pos,
copy_rates_range,
@@ -36,6 +38,7 @@ from mt5cli.sdk import (
latest_rates,
market_book,
minimum_margins,
mt5_session,
mt5_summary,
mt5_summary_as_df,
orders,
@@ -1248,3 +1251,177 @@ class TestMinimumMargins:
)
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) -> 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()