Files
mt5cli/tests/test_contracts.py
T
Daichi Narushima 78c49238cf feat: stable MT5Client public API and infrastructure layer (#30)
* feat: add stable MT5Client public API and infrastructure layer

Introduce a reusable public API for downstream trading applications:

- MT5Client as the primary client abstraction with order_check/order_send
- schemas module with DataKind contracts, validation, and normalization
- converters, exceptions, retry, and storage facade modules
- CLI order commands now route through MT5Client
- connected_client made public; retry logic centralized
- Contract tests for API surface, schemas, and storage round-trips
- README and docs updated with Python API usage examples

Co-authored-by: Daichi Narushima <dceoy@users.noreply.github.com>

* fix: correct time coercion, broker-safe symbols, and execution docs

- Normalize MT5 time columns with correct second/millisecond units
- Coerce all present known MT5 time fields, including optional order times
- Preserve broker symbol casing in normalize_symbol()
- Document order_send() as a live execution primitive with clear scope boundaries
- Add contract tests for timestamp and symbol normalization behavior

Co-authored-by: Daichi Narushima <dceoy@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Daichi Narushima <dceoy@users.noreply.github.com>
2026-06-13 01:32:03 +09:00

513 lines
17 KiB
Python

"""Contract tests for the mt5cli public API and dataset schemas."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import TYPE_CHECKING
import pandas as pd
import pytest
from pdmt5 import Mt5RuntimeError, Mt5TradingError
from pytest_mock import MockerFixture # noqa: TC002
from mt5cli import (
DEDUP_KEYS,
REQUIRED_COLUMNS,
TIME_COLUMNS,
DataKind,
Dataset,
MT5Client,
Mt5CliError,
Mt5ConnectionError,
Mt5OperationError,
Mt5SchemaError,
build_config,
call_with_normalized_errors,
detect_format,
ensure_utc,
export_dataframe,
export_dataframe_to_sqlite,
granularity_name,
is_recoverable_mt5_error,
mt5_session,
normalize_dataframe,
normalize_mt5_exception,
normalize_symbol,
normalize_symbols,
parse_date_range,
recent_window,
schema_columns,
validate_schema,
)
from mt5cli.retry import retry_with_backoff
from mt5cli.schemas import ensure_utc_columns, normalize_time_columns
if TYPE_CHECKING:
from pathlib import Path
def _sample_frame(kind: DataKind) -> pd.DataFrame:
if kind is DataKind.rates:
return pd.DataFrame({
"time": [datetime(2024, 1, 1, tzinfo=UTC)],
"open": [1.1],
"high": [1.2],
"low": [1.0],
"close": [1.15],
"tick_volume": [10],
"spread": [1],
"real_volume": [0],
})
if kind is DataKind.ticks:
return pd.DataFrame({
"time": [datetime(2024, 1, 1, tzinfo=UTC)],
"bid": [1.1],
"ask": [1.11],
"last": [1.105],
"volume": [1],
"time_msc": [datetime(2024, 1, 1, tzinfo=UTC)],
"flags": [2],
"volume_real": [0.0],
})
if kind is DataKind.orders:
return pd.DataFrame({
"ticket": [1],
"time_setup": [datetime(2024, 1, 1, tzinfo=UTC)],
"type": [0],
"state": [1],
"symbol": ["EURUSD"],
"volume_current": [0.1],
"price_open": [1.1],
})
if kind is DataKind.positions:
return pd.DataFrame({
"ticket": [1],
"time": [datetime(2024, 1, 1, tzinfo=UTC)],
"type": [0],
"symbol": ["EURUSD"],
"volume": [0.1],
"price_open": [1.1],
"price_current": [1.11],
"profit": [1.0],
})
if kind is DataKind.history_orders:
return pd.DataFrame({
"ticket": [1],
"time_setup": [datetime(2024, 1, 1, tzinfo=UTC)],
"type": [0],
"state": [3],
"symbol": ["EURUSD"],
"volume_initial": [0.1],
"price_open": [1.1],
})
return pd.DataFrame({
"ticket": [1],
"order": [2],
"time": [datetime(2024, 1, 1, tzinfo=UTC)],
"type": [0],
"entry": [0],
"symbol": ["EURUSD"],
"volume": [0.1],
"price": [1.1],
"profit": [0.0],
})
@pytest.mark.parametrize("kind", list(DataKind))
def test_required_columns_contract(kind: DataKind) -> None:
"""Each dataset kind exposes a non-empty required column contract."""
assert REQUIRED_COLUMNS[kind]
validate_schema(_sample_frame(kind), kind)
@pytest.mark.parametrize("kind", list(DataKind))
def test_normalize_dataframe_injects_storage_metadata(kind: DataKind) -> None:
"""Normalization accepts MT5 frames and optional storage metadata."""
frame = _sample_frame(kind)
normalized = normalize_dataframe(
frame,
kind,
symbol="eurusd",
timeframe="M1" if kind is DataKind.rates else None,
)
if kind is DataKind.rates:
assert normalized.loc[0, "symbol"] == "eurusd"
assert normalized.loc[0, "timeframe"] == 1
validate_schema(normalized, kind)
def test_validate_schema_raises_for_missing_columns() -> None:
"""Schema validation fails fast on missing required columns."""
with pytest.raises(Mt5SchemaError, match="missing required columns"):
validate_schema(pd.DataFrame({"time": [1]}), DataKind.rates)
def test_history_dedup_keys_match_schema_contract() -> None:
"""SQLite history dedup keys stay aligned with schema contracts."""
assert DEDUP_KEYS[DataKind.rates][0] == ("symbol", "timeframe", "time")
assert DEDUP_KEYS[DataKind.ticks][0] == ("symbol", "time_msc")
assert Dataset.rates.table_name == "rates"
@pytest.mark.parametrize(
("raw", "expected"),
[
(" eurusd ", "eurusd"),
("GbpJpy", "GbpJpy"),
("XAUUSDm", "XAUUSDm"),
("US500.cash", "US500.cash"),
("EURUSD.r", "EURUSD.r"),
],
)
def test_normalize_symbol(raw: str, expected: str) -> None:
"""Symbol normalization trims whitespace and preserves broker casing."""
assert normalize_symbol(raw) == expected
def test_normalize_symbols_deduplicates() -> None:
"""Symbol lists are normalized and de-duplicated in order."""
assert normalize_symbols(["XAUUSDm", " XAUUSDm ", "EURUSD.r", "eurusd"]) == [
"XAUUSDm",
"EURUSD.r",
"eurusd",
]
def test_parse_date_range_rejects_inverted_bounds() -> None:
"""Date ranges must not be inverted."""
with pytest.raises(ValueError, match="must not be after"):
parse_date_range("2024-02-01", "2024-01-01")
def test_recent_window_builds_trailing_bounds() -> None:
"""Recent windows end at the provided timestamp."""
end = datetime(2024, 1, 2, tzinfo=UTC)
start, resolved_end = recent_window(hours=24, date_to=end)
assert resolved_end == end
assert start < end
def test_granularity_name_maps_timeframe_alias() -> None:
"""Granularity labels resolve MT5 timeframe aliases."""
assert granularity_name("M1") == "M1"
@pytest.mark.parametrize(
"exc",
[Mt5RuntimeError("init failed"), Mt5TradingError("trade failed")],
)
def test_is_recoverable_mt5_error(exc: Exception) -> None:
"""Recoverable MT5 errors are classified consistently."""
assert is_recoverable_mt5_error(exc)
def test_normalize_mt5_exception_maps_types() -> None:
"""MT5 exceptions map to stable mt5cli types."""
assert isinstance(
normalize_mt5_exception(Mt5RuntimeError("x")),
Mt5ConnectionError,
)
assert isinstance(
normalize_mt5_exception(Mt5TradingError("x")),
Mt5OperationError,
)
def test_call_with_normalized_errors_reraises_mapped_type() -> None:
"""Normalized error helper re-raises mapped mt5cli exceptions."""
def _raise() -> None:
message = "boom"
raise Mt5RuntimeError(message)
with pytest.raises(Mt5ConnectionError):
call_with_normalized_errors(_raise)
def test_retry_with_backoff_retries_recoverable_errors(
mocker: MockerFixture,
) -> None:
"""Retry helper retries recoverable MT5 failures."""
calls = {"count": 0}
def _flaky() -> str:
calls["count"] += 1
if calls["count"] == 1:
message = "transient"
raise Mt5RuntimeError(message)
return "ok"
mocker.patch("mt5cli.retry.time.sleep")
assert retry_with_backoff(_flaky, retry_count=1) == "ok"
assert calls["count"] == 2
def test_public_api_exports_mt5_client() -> None:
"""MT5Client is the primary importable client abstraction."""
client = MT5Client(config=build_config())
assert isinstance(client, MT5Client)
assert isinstance(client, MT5Client.__mro__[1])
def test_mt5_client_order_primitives_use_connected_client(
mock_client: object,
) -> None:
"""Order check/send route through the same client fetch path as exports."""
request = {"action": 1}
client = MT5Client()
client.order_check(request)
client.order_send(request)
assert mock_client.order_check_as_df.call_count == 1 # type: ignore[attr-defined]
assert mock_client.order_send_as_df.call_count == 1 # type: ignore[attr-defined]
def test_storage_export_round_trip_csv(tmp_path: Path) -> None:
"""Storage helpers export normalized rate frames to CSV."""
frame = normalize_dataframe(
_sample_frame(DataKind.rates),
DataKind.rates,
symbol="EURUSD",
timeframe="M1",
)
output = tmp_path / "rates.csv"
export_dataframe(frame, output, detect_format(output))
loaded = pd.read_csv(output)
assert len(loaded) == 1
assert "close" in loaded.columns
def test_normalize_symbol_rejects_empty_value() -> None:
"""Empty symbols are rejected after trimming."""
with pytest.raises(ValueError, match="must not be empty"):
normalize_symbol(" ")
def test_ensure_utc_handles_naive_and_aware_datetimes() -> None:
"""UTC coercion accepts naive and timezone-aware datetimes."""
naive = datetime(2024, 1, 1, tzinfo=UTC).replace(tzinfo=None)
aware = datetime(2024, 1, 1, tzinfo=UTC)
assert ensure_utc(naive).tzinfo == UTC
assert ensure_utc(aware).tzinfo == UTC
assert ensure_utc("2024-01-01T00:00:00+00:00").tzinfo == UTC
def test_recent_window_validation_errors() -> None:
"""Recent window helpers validate mutually exclusive length arguments."""
with pytest.raises(ValueError, match="exactly one"):
recent_window()
with pytest.raises(ValueError, match="exactly one"):
recent_window(hours=1, seconds=1)
with pytest.raises(ValueError, match="positive"):
recent_window(hours=0)
def test_recent_window_supports_seconds_argument() -> None:
"""Recent windows can be built from a seconds-based length."""
end = datetime(2024, 1, 2, tzinfo=UTC)
start, resolved_end = recent_window(seconds=3600, date_to=end)
assert resolved_end == end
assert start < end
def test_parse_date_range_returns_ordered_bounds() -> None:
"""Valid date ranges return UTC-aware bounds."""
start, end = parse_date_range("2024-01-01", "2024-02-01")
assert start < end
def test_granularity_name_falls_back_for_unknown_timeframe(
mocker: MockerFixture,
) -> None:
"""Unknown timeframe integers stringify as granularity labels."""
mocker.patch(
"mt5cli.converters._get_timeframe_name",
side_effect=ValueError("unknown"),
)
assert granularity_name(1) == "1"
def test_normalize_mt5_exception_passthrough_and_generic() -> None:
"""Normalization preserves mt5cli errors and wraps unknown exceptions."""
original = Mt5CliError("known")
assert normalize_mt5_exception(original) is original
assert isinstance(normalize_mt5_exception(ValueError("x")), Mt5CliError)
def test_schema_columns_and_extra_required_validation() -> None:
"""Schema helpers expose contracts and honor extra required columns."""
assert schema_columns(DataKind.rates) == REQUIRED_COLUMNS[DataKind.rates]
validate_schema(pd.DataFrame(), DataKind.rates)
frame = _sample_frame(DataKind.rates)
with pytest.raises(Mt5SchemaError, match="storage_symbol"):
validate_schema(frame, DataKind.rates, extra_required=["storage_symbol"])
def test_normalize_dataframe_empty_and_tick_sort_paths() -> None:
"""Normalization handles empty frames and tick time_msc sorting."""
empty = pd.DataFrame()
assert normalize_dataframe(empty, DataKind.rates).empty
ticks = _sample_frame(DataKind.ticks)
ticks = pd.concat([ticks, ticks], ignore_index=True)
sorted_ticks = normalize_dataframe(ticks, DataKind.ticks, sort=True)
assert len(sorted_ticks) == 2
unsorted_ticks = normalize_dataframe(ticks, DataKind.ticks, sort=False)
assert len(unsorted_ticks) == 2
def test_normalize_dataframe_rate_timeframe_without_symbol() -> None:
"""Rate normalization can inject timeframe without symbol metadata."""
frame = _sample_frame(DataKind.rates)
normalized = normalize_dataframe(frame, DataKind.rates, timeframe="M1")
assert "timeframe" in normalized.columns
def test_normalize_dataframe_keeps_existing_symbol_and_timeframe() -> None:
"""Normalization does not duplicate existing storage metadata columns."""
frame = normalize_dataframe(
_sample_frame(DataKind.rates),
DataKind.rates,
symbol="EURUSD",
timeframe="M1",
)
normalized = normalize_dataframe(
frame,
DataKind.rates,
symbol="GBPUSD",
timeframe="H1",
)
assert normalized.loc[0, "symbol"] == "EURUSD"
assert normalized.loc[0, "timeframe"] == 1
def test_normalize_time_columns_skips_absent_time_fields() -> None:
"""Time normalization ignores absent optional time columns."""
frame = pd.DataFrame({"open": [1.0]})
result = normalize_time_columns(frame, DataKind.rates)
assert list(result.columns) == ["open"]
def test_normalize_time_columns_converts_unix_seconds() -> None:
"""Numeric MT5 ``time`` values are interpreted as Unix seconds."""
frame = pd.DataFrame({"time": [1704067200]})
result = normalize_time_columns(frame, DataKind.rates)
assert result.loc[0, "time"] == pd.Timestamp("2024-01-01T00:00:00+00:00")
def test_normalize_time_columns_converts_unix_milliseconds() -> None:
"""Numeric MT5 ``time_msc`` values are interpreted as Unix milliseconds."""
frame = pd.DataFrame({"time_msc": [1704067200000]})
result = normalize_time_columns(frame, DataKind.ticks)
assert result.loc[0, "time_msc"] == pd.Timestamp("2024-01-01T00:00:00+00:00")
def test_normalize_time_columns_preserves_utc_datetimes() -> None:
"""Already-converted datetime values remain UTC-normalized."""
aware = datetime(2024, 1, 1, tzinfo=UTC)
frame = pd.DataFrame({"time": [aware]})
result = normalize_time_columns(frame, DataKind.rates)
assert result.loc[0, "time"] == pd.Timestamp("2024-01-01T00:00:00+00:00")
def test_normalize_time_columns_handles_optional_order_times() -> None:
"""Optional order/history time columns are normalized when present."""
frame = pd.DataFrame({
"time_setup": [1704067200],
"time_setup_msc": [1704067200000],
"time_done": [1704153600],
"time_done_msc": [1704153600000],
})
result = normalize_time_columns(frame, DataKind.orders)
assert result.loc[0, "time_setup"] == pd.Timestamp("2024-01-01T00:00:00+00:00")
assert result.loc[0, "time_setup_msc"] == pd.Timestamp(
"2024-01-01T00:00:00+00:00",
)
assert result.loc[0, "time_done"] == pd.Timestamp("2024-01-02T00:00:00+00:00")
assert result.loc[0, "time_done_msc"] == pd.Timestamp(
"2024-01-02T00:00:00+00:00",
)
def test_time_columns_include_optional_order_fields() -> None:
"""Schema contracts document optional MT5 time columns per dataset kind."""
assert "time_done" in TIME_COLUMNS[DataKind.orders]
assert "time_setup_msc" in TIME_COLUMNS[DataKind.history_orders]
def test_normalize_dataframe_sorts_ticks_by_time_msc(
mocker: MockerFixture,
) -> None:
"""Tick frames without ``time`` can still sort on ``time_msc``."""
mocker.patch("mt5cli.schemas.validate_schema")
ticks = pd.concat([_sample_frame(DataKind.ticks)] * 2, ignore_index=True).drop(
columns=["time"],
)
ticks.loc[0, "time_msc"] = datetime(2024, 1, 1, tzinfo=UTC)
ticks.loc[1, "time_msc"] = datetime(2024, 1, 2, tzinfo=UTC)
ticks = pd.concat([ticks.iloc[[1]], ticks.iloc[[0]]], ignore_index=True)
normalized = normalize_dataframe(ticks, DataKind.ticks, sort=True)
assert normalized.iloc[0]["time_msc"] <= normalized.iloc[1]["time_msc"]
def test_ensure_utc_columns_skips_missing_columns() -> None:
"""UTC column coercion ignores absent columns."""
frame = _sample_frame(DataKind.rates)
result = ensure_utc_columns(frame, ["time", "missing"])
assert "time" in result.columns
def test_normalize_time_columns_coerces_string_timestamps() -> None:
"""String timestamps are parsed with timezone-aware datetime coercion."""
frame = pd.DataFrame({"time": ["2024-01-01T00:00:00+00:00"]})
result = normalize_time_columns(frame, DataKind.rates)
assert result.loc[0, "time"] == pd.Timestamp("2024-01-01T00:00:00+00:00")
def test_ensure_utc_columns_coerces_non_mt5_columns() -> None:
"""Non-MT5 columns still coerce to UTC datetimes."""
frame = pd.DataFrame({"created_at": ["2024-01-01T00:00:00+00:00"]})
result = ensure_utc_columns(frame, ["created_at"])
assert result.loc[0, "created_at"] == pd.Timestamp("2024-01-01T00:00:00+00:00")
def test_mt5_session_yields_connected_client(mocker: MockerFixture) -> None:
"""Public mt5_session yields an MT5Client bound to a connected session."""
connected = mocker.MagicMock()
context = mocker.MagicMock()
context.__enter__.return_value = connected
context.__exit__.return_value = False
mocker.patch("mt5cli.client.connected_client", return_value=context)
with mt5_session(build_config()) as client:
assert isinstance(client, MT5Client)
def test_retry_with_backoff_reraises_non_recoverable_errors() -> None:
"""Non-MT5 errors are not retried."""
def _raise() -> None:
message = "fatal"
raise ValueError(message)
with pytest.raises(ValueError, match="fatal"):
retry_with_backoff(_raise, retry_count=2)
def test_storage_export_round_trip_sqlite(tmp_path: Path) -> None:
"""Storage helpers append deduplicated frames to SQLite."""
frame = normalize_dataframe(
_sample_frame(DataKind.rates),
DataKind.rates,
symbol="EURUSD",
timeframe="M1",
)
output = tmp_path / "rates.db"
export_dataframe_to_sqlite(
frame,
output,
"rates",
deduplicate_on=DEDUP_KEYS[DataKind.rates][0],
)
with __import__("sqlite3").connect(output) as conn:
count = conn.execute("SELECT COUNT(*) FROM rates").fetchone()[0]
assert count == 1