From 78c49238cfcc3c889adf29ab0607b5a6c51c2fa7 Mon Sep 17 00:00:00 2001 From: Daichi Narushima <1938249+dceoy@users.noreply.github.com> Date: Sat, 13 Jun 2026 01:32:03 +0900 Subject: [PATCH] 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 * 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 --------- Co-authored-by: Cursor Agent Co-authored-by: Daichi Narushima --- README.md | 68 +++++- docs/api/client.md | 3 + docs/api/converters.md | 3 + docs/api/exceptions.md | 3 + docs/api/index.md | 150 +++--------- docs/api/schemas.md | 3 + docs/api/storage.md | 3 + docs/index.md | 66 +++--- mkdocs.yml | 7 +- mt5cli/__init__.py | 68 +++++- mt5cli/cli.py | 27 +-- mt5cli/client.py | 88 +++++++ mt5cli/converters.py | 162 +++++++++++++ mt5cli/exceptions.py | 90 +++++++ mt5cli/history.py | 9 +- mt5cli/retry.py | 64 +++++ mt5cli/schemas.py | 291 +++++++++++++++++++++++ mt5cli/sdk.py | 39 ++- mt5cli/storage.py | 49 ++++ pyproject.toml | 2 +- tests/test_contracts.py | 512 ++++++++++++++++++++++++++++++++++++++++ tests/test_sdk.py | 6 +- 22 files changed, 1506 insertions(+), 207 deletions(-) create mode 100644 docs/api/client.md create mode 100644 docs/api/converters.md create mode 100644 docs/api/exceptions.md create mode 100644 docs/api/schemas.md create mode 100644 docs/api/storage.md create mode 100644 mt5cli/client.py create mode 100644 mt5cli/converters.py create mode 100644 mt5cli/exceptions.py create mode 100644 mt5cli/retry.py create mode 100644 mt5cli/schemas.py create mode 100644 mt5cli/storage.py create mode 100644 tests/test_contracts.py diff --git a/README.md b/README.md index 8c6028b..0cf9f89 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,14 @@ [![CI/CD](https://github.com/dceoy/mt5cli/actions/workflows/ci.yml/badge.svg)](https://github.com/dceoy/mt5cli/actions/workflows/ci.yml) -Command-line tool for exporting MetaTrader 5 data to CSV, JSON, Parquet, and SQLite3. +Generic MT5 data and execution infrastructure for Python applications. Export from the CLI or import a small, stable Python API in downstream packages. Built on top of [pdmt5](https://github.com/dceoy/pdmt5), a pandas-based data handler for MetaTrader 5. ## Architecture - **pdmt5** — canonical MT5 client, DataFrame/trading primitives, and MT5 constant parsing (`TIMEFRAME_*`, `COPY_TICKS_*`, order types). -- **mt5cli** — CLI commands, CSV/JSON/Parquet/SQLite export, SQLite history collection, rate views, and local batch/automation SDK helpers built on pdmt5. +- **mt5cli** — public `MT5Client` API, standardized dataset schemas, storage helpers, CLI commands, and SQLite history collection built on pdmt5. - **mt5api** — sibling HTTP adapter for remote MT5 access; not a dependency of mt5cli. ## Features @@ -27,7 +27,65 @@ Built on top of [pdmt5](https://github.com/dceoy/pdmt5), a pandas-based data han pip install -U mt5cli MetaTrader5 ``` -## Usage +## Python API (downstream packages) + +Import `MT5Client` for generic MT5 data access, schema normalization, and optional order primitives. `Mt5CliClient` remains available as a backward-compatible alias. + +```python +from datetime import UTC, datetime +from pathlib import Path + +from mt5cli import ( + DataKind, + Dataset, + MT5Client, + build_config, + collect_history, + export_dataframe, + mt5_session, + normalize_dataframe, + update_history_with_config, +) + +# Persistent session for multiple calls +with mt5_session(build_config(login=12345, server="Broker-Demo")) as client: + rates = client.copy_rates_range( + "EURUSD", + timeframe="H1", + date_from="2024-01-01", + date_to="2024-02-01", + ) + positions = client.positions() + check = client.order_check({"action": 1, "symbol": "EURUSD", "volume": 0.1}) + +# Normalize MT5 frames to the public schema contract before storage +closed_rates = normalize_dataframe( + rates, DataKind.rates, symbol="EURUSD", timeframe="H1" +) +export_dataframe(closed_rates, Path("rates.csv"), "csv") + +# Bulk SQLite history (same behavior as collect-history CLI command) +collect_history( + Path("history.db"), + symbols=["EURUSD"], + date_from=datetime(2024, 1, 1, tzinfo=UTC), + date_to=datetime(2024, 2, 1, tzinfo=UTC), + datasets={Dataset.rates, Dataset.history_deals}, +) + +# Incremental append for automated pipelines +update_history_with_config( + output="history.db", + symbols=["EURUSD"], + config=build_config(login=12345), +) +``` + +Schema contracts live in `mt5cli.schemas` (`DataKind`, `validate_schema`, `normalize_dataframe`). Storage helpers are re-exported from `mt5cli.storage` and the package root. + +`MT5Client.order_send()` is a live execution primitive: it can place real trades on the connected account. mt5cli does not implement strategy logic, signal generation, backtesting, or optimization — downstream applications must gate live execution explicitly. + +## CLI usage ```bash # Export account information to CSV @@ -161,7 +219,7 @@ eurusd_m1 = rates["EURUSD", "M1"] # closed bars only - **Throttled history updates**: use `ThrottledHistoryUpdater` to wrap `update_history()` with a minimum `interval_seconds` between successful runs (monotonic clock). Call `should_update()` / `update(client, symbols)` from an application loop; errors propagate by default, or pass `suppress_errors=True` to swallow recoverable `Mt5*Error`, `sqlite3.Error`, `ValueError`, `OSError`, and MT5 client capability errors for history API methods without advancing the throttle (other `AttributeError` / `TypeError` values always propagate). - **Trading session helpers**: use `mt5_trading_session()` for a trading-capable `pdmt5.Mt5TradingClient` that initializes/logs in via `Mt5Config.path` and always shuts down safely. Pair with `detect_position_side()`, `calculate_margin_and_volume()`, and `determine_order_limits()` for generic position and sizing utilities. The read-only `mt5_session()` / `Mt5CliClient` SDK is unchanged. - **Granularity-keyed rate loading**: `load_rate_series_by_granularity()` builds targets with `build_rate_targets()`, loads them with `load_rate_series_from_sqlite()`, and returns a mapping keyed by `(symbol | None, granularity_name)` such as `("EURUSD", "M1")` to reduce downstream boilerplate. -- **MT5 session helper**: use the `mt5_session()` context manager to attach to (or, when `Mt5Config.path` is set, launch) an MT5 terminal, log in, and yield a connected `Mt5CliClient` that shuts down on exit. +- **MT5 session helper**: use the `mt5_session()` context manager to attach to (or, when `Mt5Config.path` is set, launch) an MT5 terminal, log in, and yield a connected `MT5Client` that shuts down on exit. - **SQLite export helpers**: use `export_dataframe_to_sqlite()` for append mode, optional index export, and post-write deduplication by key columns. - **Recent ticks and margins**: `recent_ticks()` and `minimum_margins()` SDK helpers (and matching CLI commands) cover common downstream read-only queries. @@ -226,7 +284,7 @@ finally: client.shutdown() ``` -Read-only collectors can keep using `mt5_session()` and `Mt5CliClient` without changes. +Read-only collectors can keep using `mt5_session()` and `MT5Client` (or the `Mt5CliClient` alias) without changes. ## Development diff --git a/docs/api/client.md b/docs/api/client.md new file mode 100644 index 0000000..f1b7bd4 --- /dev/null +++ b/docs/api/client.md @@ -0,0 +1,3 @@ +# Client + +::: mt5cli.client diff --git a/docs/api/converters.md b/docs/api/converters.md new file mode 100644 index 0000000..7660eee --- /dev/null +++ b/docs/api/converters.md @@ -0,0 +1,3 @@ +# Converters + +::: mt5cli.converters diff --git a/docs/api/exceptions.md b/docs/api/exceptions.md new file mode 100644 index 0000000..cca284c --- /dev/null +++ b/docs/api/exceptions.md @@ -0,0 +1,3 @@ +# Exceptions + +::: mt5cli.exceptions diff --git a/docs/api/index.md b/docs/api/index.md index 228a9d0..395ab20 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -1,125 +1,53 @@ # API Reference -This section contains the complete API documentation for mt5cli. +This section documents the mt5cli public Python API and CLI modules. -## Modules +## Public API layers -The mt5cli package consists of the following modules: +| Module | Purpose | +| ----------------------------------------- | ------------------------------------------------------------------------- | +| [Client](client.md) | `MT5Client` session abstraction for data access and order primitives | +| [Schemas](schemas.md) | Canonical DataFrame contracts and normalization helpers | +| [Storage](storage.md) | CSV/JSON/Parquet/SQLite export and history collection helpers | +| [Converters](converters.md) | Symbol, timeframe, timezone, and date-range utilities | +| [Exceptions](exceptions.md) | Stable mt5cli exception types and MT5 error normalization | +| [SDK](sdk.md) | Module-level fetch helpers, multi-account collectors, incremental history | +| [Trading](trading.md) | Trading-capable sessions and operational helpers | +| [History Collection (SQLite)](history.md) | SQLite schema, incremental writes, dedup, and rate views | +| [CLI](cli.md) | Typer commands that delegate to the Python API | +| [Utils](utils.md) | Parsing helpers and Click parameter types | -### [CLI](cli.md) +## Architecture overview -Command-line interface module providing typer-based commands for exporting MetaTrader 5 data to CSV, JSON, Parquet, and SQLite3 formats. - -### [Utils](utils.md) - -Utility module providing constants, enums, Click parameter types, and helper functions for parsing and exporting data. - -### [SDK](sdk.md) - -Programmatic SDK for read-only MetaTrader 5 data collection. Returns pandas DataFrames and provides `collect_history` for SQLite bulk collection. - -### [Trading](trading.md) - -Trading-capable session management and operational helpers built on `pdmt5.Mt5TradingClient`. Complements the read-only SDK without changing existing `Mt5CliClient` behavior. - -### [History Collection (SQLite)](history.md) - -SQLite storage helpers for the `collect-history` command schema, incremental updates, deduplication, indexes, and optional views. - -## Architecture Overview - -The package follows a simple architecture built on top of pdmt5: - -1. **CLI Layer** (`cli.py`): Typer application with subcommands that delegate to the SDK and export results. -2. **SDK Layer** (`sdk.py`): Read-only data access functions, `Mt5CliClient`, and `collect_history` orchestration. -3. **Trading Layer** (`trading.py`): Trading-capable sessions and operational helpers on `Mt5TradingClient`. -4. **Utils Layer** (`utils.py`): Constants, enums, custom Click parameter types, parsing helpers, and format detection/export utilities. -5. **Data Layer** (via `pdmt5`): Uses `Mt5DataClient`, `Mt5TradingClient`, and `Mt5Config` from the pdmt5 package for MetaTrader 5 access. - -## Usage Guidelines - -All modules follow these conventions: - -- **Type Safety**: All functions include comprehensive type hints -- **Error Handling**: User-friendly error messages via typer -- **Documentation**: Google-style docstrings with examples -- **Validation**: Custom Click parameter types for input validation - -## Quick Start - -```bash -# Export account information to CSV -mt5cli -o account.csv account-info - -# Export EURUSD H1 rates to Parquet -mt5cli -o rates.parquet rates-from --symbol EURUSD --timeframe H1 \ - --date-from 2024-01-01 --count 1000 - -# Export ticks to JSON -mt5cli -o ticks.json ticks-from --symbol EURUSD \ - --date-from 2024-01-01 --count 500 --flags ALL - -# Export to SQLite3 with custom table name -mt5cli -o data.db --table symbols symbols --group "*USD*" +```mermaid +flowchart TD + App["Downstream application"] --> Client["MT5Client"] + CLI["mt5cli CLI"] --> Client + Client --> SDK["sdk / pdmt5"] + Client --> Schemas["schemas"] + Storage["storage"] --> History["history SQLite"] + Storage --> Utils["utils export"] + SDK --> PDMT5["pdmt5.Mt5DataClient"] ``` -## Python API +Downstream packages should depend on the package root exports (`MT5Client`, `DataKind`, `normalize_dataframe`, `export_dataframe`, `collect_history`, etc.) rather than private modules. + +`MT5Client.order_send()` is a live execution primitive that can place real trades. mt5cli exposes minimal execution helpers only; strategy logic, signals, backtests, and optimization remain out of scope and must be implemented downstream with explicit execution gating. + +## Quick start ```python -from datetime import UTC, datetime -from pathlib import Path +from mt5cli import MT5Client, build_config, mt5_session -from mt5cli import ( - Dataset, - IfExists, - Mt5CliClient, - collect_history, - copy_rates_range, - detect_format, - export_dataframe, - export_dataframe_to_sqlite, - minimum_margins, - recent_ticks, -) -from mt5cli.history import resolve_rate_view_name - -# Fetch rates programmatically -rates = copy_rates_range( - "EURUSD", - timeframe="H1", - date_from="2024-01-01", - date_to="2024-02-01", -) - -# Detect output format from file extension -fmt = detect_format(Path("output.parquet")) # Returns "parquet" - -# Export a DataFrame -export_dataframe(rates, Path("output.csv"), "csv") - -# Append to SQLite with deduplication -export_dataframe_to_sqlite( - rates, - Path("history.db"), - "rates", - if_exists=IfExists.APPEND, - deduplicate_on=("symbol", "timeframe", "time"), -) - -# Resolve rate compatibility views and fetch recent ticks -view = resolve_rate_view_name(Path("history.db"), "EURUSD", "M1") -ticks = recent_ticks("EURUSD", seconds=300) -margins = minimum_margins("EURUSD") - -# Collect history into SQLite -collect_history( - Path("history.db"), - symbols=["EURUSD"], - date_from=datetime(2024, 1, 1, tzinfo=UTC), - date_to=datetime(2024, 2, 1, tzinfo=UTC), -) +with mt5_session(build_config(login=12345)) as client: + rates = client.copy_rates_range("EURUSD", "H1", "2024-01-01", "2024-02-01") + positions = client.positions() ``` -## Examples +```bash +mt5cli -o account.csv account-info +mt5cli -o rates.parquet rates-range --symbol EURUSD --timeframe H1 \ + --date-from 2024-01-01 --date-to 2024-02-01 +``` -See individual module pages for detailed usage examples and code samples. +See individual module pages for detailed usage examples. diff --git a/docs/api/schemas.md b/docs/api/schemas.md new file mode 100644 index 0000000..3f6f517 --- /dev/null +++ b/docs/api/schemas.md @@ -0,0 +1,3 @@ +# Schemas + +::: mt5cli.schemas diff --git a/docs/api/storage.md b/docs/api/storage.md new file mode 100644 index 0000000..dda244b --- /dev/null +++ b/docs/api/storage.md @@ -0,0 +1,3 @@ +# Storage + +::: mt5cli.storage diff --git a/docs/index.md b/docs/index.md index 5763494..9fb9bd4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,15 +1,15 @@ # mt5cli -Command-line tool for MetaTrader 5 data export. +Generic MT5 data and execution infrastructure for Python applications. ## Overview -mt5cli is a CLI application that exports MetaTrader 5 trading data to multiple file formats. It is built on top of [pdmt5](https://github.com/dceoy/pdmt5), a pandas-based data handler for MetaTrader 5. +mt5cli provides a stable `MT5Client` Python API, standardized dataset schemas, storage helpers, and a CLI for exporting MetaTrader 5 data. It is built on top of [pdmt5](https://github.com/dceoy/pdmt5), a pandas-based data handler for MetaTrader 5. ## Architecture - **pdmt5** — canonical MT5 client, DataFrame/trading primitives, and MT5 constant parsing (`TIMEFRAME_*`, `COPY_TICKS_*`, order types). -- **mt5cli** — CLI commands, CSV/JSON/Parquet/SQLite export, SQLite history collection, rate views, and local batch/automation SDK helpers built on pdmt5. +- **mt5cli** — public `MT5Client` API, schema contracts, storage helpers, CLI commands, and SQLite history collection built on pdmt5. - **mt5api** — sibling HTTP adapter for remote MT5 access; not a dependency of mt5cli. ## Features @@ -27,66 +27,68 @@ mt5cli is a CLI application that exports MetaTrader 5 trading data to multiple f pip install mt5cli ``` -## Programmatic usage / SDK usage +## Python API for downstream packages -mt5cli can be used as a small Python SDK for read-only MetaTrader 5 data collection. SDK functions return pandas DataFrames without writing files. Use `export_dataframe` or `export_dataframe_to_sqlite` when you need to persist results. +Import `MT5Client` for generic MT5 data access, schema normalization, and optional order primitives. `Mt5CliClient` remains available as a backward-compatible alias. ```python from datetime import UTC, datetime from pathlib import Path from mt5cli import ( - Mt5CliClient, + DataKind, + Dataset, + MT5Client, + build_config, collect_history, - copy_rates_range, export_dataframe, - export_dataframe_to_sqlite, load_rate_data, minimum_margins, + mt5_session, + normalize_dataframe, recent_ticks, + resolve_rate_view_name, ) -from mt5cli.history import resolve_rate_view_name -# One-off fetch with module-level helpers -rates = copy_rates_range( - "EURUSD", - timeframe="H1", - date_from="2024-01-01", - date_to="2024-02-01", +# Persistent session for multiple calls +with mt5_session(build_config(login=12345, server="Broker-Demo")) as client: + rates = client.copy_rates_range( + "EURUSD", + timeframe="H1", + date_from="2024-01-01", + date_to="2024-02-01", + ) + positions = client.positions() + check = client.order_check({"action": 1, "symbol": "EURUSD", "volume": 0.1}) + +# Normalize MT5 frames to the public schema contract before storage +closed_rates = normalize_dataframe( + rates, DataKind.rates, symbol="EURUSD", timeframe="H1" ) -export_dataframe(rates, Path("rates.csv"), "csv") +export_dataframe(closed_rates, Path("rates.csv"), "csv") -# Resolve SQLite rate compatibility views for downstream tools +# Offline rate loading from mt5cli-managed SQLite history view = resolve_rate_view_name(Path("history.db"), "EURUSD", "M1", require_existing=True) offline_rates = load_rate_data(Path("history.db"), view, count=1000) -# Recent tick window and minimum margin summary +# One-off helpers still work without instantiating a client ticks = recent_ticks("EURUSD", seconds=300) margins = minimum_margins("EURUSD") -# Reuse one MT5 connection for multiple calls -with Mt5CliClient(login=12345, password="secret", server="Broker-Demo") as client: - account = client.account_info() - positions = client.positions() - latest = client.latest_rates("EURUSD", "M1", count=100) - summary = client.mt5_summary() - summary_table = client.mt5_summary_as_df() - -# Bulk SQLite collection (same behavior as the collect-history CLI command) collect_history( Path("history.db"), symbols=["EURUSD", "GBPUSD"], date_from=datetime(2024, 1, 1, tzinfo=UTC), date_to=datetime(2024, 2, 1, tzinfo=UTC), - timeframe="M1", - flags="ALL", - with_views=True, + datasets={Dataset.rates, Dataset.history_deals}, ) ``` -Timeframes, tick flags, and ISO 8601 date strings are accepted wherever noted in the SDK API. +Schema contracts live in `mt5cli.schemas` (`DataKind`, `validate_schema`, `normalize_dataframe`). Storage helpers are re-exported from `mt5cli.storage` and the package root. -`Mt5CliClient.mt5_summary()` returns the SDK structured form as plain nested Python values. Use `Mt5CliClient.mt5_summary_as_df()` when you need a one-row DataFrame for export. The `mt5-summary` CLI command uses this tabular form, so nested terminal/account fields are JSON-encoded strings that are safe for CSV, JSON, Parquet, and SQLite output. +`MT5Client.order_send()` is a live execution primitive: it can place real trades on the connected account. mt5cli does not implement strategy logic, signal generation, backtesting, or optimization — downstream applications must gate live execution explicitly (the CLI requires `--yes` for `order-send`). + +`MT5Client.mt5_summary()` returns structured nested Python values. Use `MT5Client.mt5_summary_as_df()` when you need a one-row DataFrame for export. ## Quick Start diff --git a/mkdocs.yml b/mkdocs.yml index 09d141f..0e112ca 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,5 +1,5 @@ site_name: mt5cli API Documentation -site_description: Command-line tool for MetaTrader 5 +site_description: Generic MT5 data and execution infrastructure for Python site_author: dceoy site_url: https://github.com/dceoy/mt5cli @@ -56,6 +56,11 @@ nav: - Home: index.md - API Reference: - Overview: api/index.md + - Client: api/client.md + - Schemas: api/schemas.md + - Storage: api/storage.md + - Converters: api/converters.md + - Exceptions: api/exceptions.md - CLI: api/cli.md - SDK: api/sdk.md - Trading: api/trading.md diff --git a/mt5cli/__init__.py b/mt5cli/__init__.py index 3f1d3fc..5dcaa1e 100644 --- a/mt5cli/__init__.py +++ b/mt5cli/__init__.py @@ -1,7 +1,25 @@ -"""mt5cli: Command-line tool and SDK for MetaTrader 5.""" +"""mt5cli: Generic MT5 data and execution infrastructure for Python applications.""" from importlib.metadata import version +from .client import MT5Client, build_config, mt5_session +from .converters import ( + ensure_utc, + granularity_name, + normalize_symbol, + normalize_symbols, + parse_date_range, + recent_window, +) +from .exceptions import ( + Mt5CliError, + Mt5ConnectionError, + Mt5OperationError, + Mt5SchemaError, + call_with_normalized_errors, + is_recoverable_mt5_error, + normalize_mt5_exception, +) from .history import ( RateTarget, build_rate_targets, @@ -18,12 +36,22 @@ from .history import ( resolve_rate_view_name, resolve_rate_view_names, ) +from .schemas import ( + DEDUP_KEYS, + KNOWN_MT5_TIME_COLUMNS, + REQUIRED_COLUMNS, + TIME_COLUMNS, + DataKind, + normalize_dataframe, + normalize_time_columns, + schema_columns, + validate_schema, +) from .sdk import ( AccountSpec, Mt5CliClient, ThrottledHistoryUpdater, account_info, - build_config, collect_history, collect_latest_closed_rates_by_granularity, collect_latest_closed_rates_for_accounts, @@ -41,7 +69,6 @@ from .sdk import ( latest_rates, market_book, minimum_margins, - mt5_session, mt5_summary, mt5_summary_as_df, orders, @@ -61,6 +88,13 @@ from .sdk import ( from .sdk import ( version as mt5_version, ) +from .storage import ( + Dataset, + IfExists, + detect_format, + export_dataframe, + export_dataframe_to_sqlite, +) from .trading import ( calculate_margin_and_volume, detect_position_side, @@ -70,11 +104,6 @@ from .trading import ( from .utils import ( TICK_FLAG_MAP, TIMEFRAME_MAP, - Dataset, - IfExists, - detect_format, - export_dataframe, - export_dataframe_to_sqlite, parse_datetime, parse_tick_flags, parse_timeframe, @@ -83,12 +112,22 @@ from .utils import ( __version__ = version(__package__) if __package__ else None __all__ = [ + "DEDUP_KEYS", + "KNOWN_MT5_TIME_COLUMNS", + "REQUIRED_COLUMNS", "TICK_FLAG_MAP", "TIMEFRAME_MAP", + "TIME_COLUMNS", "AccountSpec", + "DataKind", "Dataset", "IfExists", + "MT5Client", "Mt5CliClient", + "Mt5CliError", + "Mt5ConnectionError", + "Mt5OperationError", + "Mt5SchemaError", "RateTarget", "ThrottledHistoryUpdater", "account_info", @@ -96,6 +135,7 @@ __all__ = [ "build_rate_targets", "build_rate_view_name", "calculate_margin_and_volume", + "call_with_normalized_errors", "collect_history", "collect_latest_closed_rates_by_granularity", "collect_latest_closed_rates_for_accounts", @@ -111,10 +151,13 @@ __all__ = [ "detect_position_side", "determine_order_limits", "drop_forming_rate_bar", + "ensure_utc", "export_dataframe", "export_dataframe_to_sqlite", + "granularity_name", "history_deals", "history_orders", + "is_recoverable_mt5_error", "last_error", "latest_rates", "load_rate_data", @@ -128,13 +171,20 @@ __all__ = [ "mt5_summary_as_df", "mt5_trading_session", "mt5_version", + "normalize_dataframe", + "normalize_mt5_exception", + "normalize_symbol", + "normalize_symbols", + "normalize_time_columns", "orders", + "parse_date_range", "parse_datetime", "parse_tick_flags", "parse_timeframe", "positions", "recent_history_deals", "recent_ticks", + "recent_window", "resolve_account_spec", "resolve_account_specs", "resolve_history_datasets", @@ -143,6 +193,7 @@ __all__ = [ "resolve_rate_tables", "resolve_rate_view_name", "resolve_rate_view_names", + "schema_columns", "substitute_env_placeholders", "symbol_info", "symbol_info_tick", @@ -150,4 +201,5 @@ __all__ = [ "terminal_info", "update_history", "update_history_with_config", + "validate_schema", ] diff --git a/mt5cli/cli.py b/mt5cli/cli.py index 6587f42..adf0db0 100644 --- a/mt5cli/cli.py +++ b/mt5cli/cli.py @@ -12,6 +12,7 @@ import typer from pdmt5 import Mt5Config from . import sdk +from .client import MT5Client from .utils import ( DATETIME_TYPE, REQUEST_TYPE, @@ -91,14 +92,14 @@ def _execute_export( ) -def _sdk_client(ctx: typer.Context) -> sdk.Mt5CliClient: +def _sdk_client(ctx: typer.Context) -> MT5Client: export_ctx = _get_export_context(ctx) - return sdk.Mt5CliClient(config=export_ctx.config) + return MT5Client(config=export_ctx.config) def _export_command( ctx: typer.Context, - fetch_fn: Callable[[sdk.Mt5CliClient], pd.DataFrame], + fetch_fn: Callable[[MT5Client], pd.DataFrame], ) -> None: """Create an SDK client, fetch a DataFrame, and export it.""" client = _sdk_client(ctx) @@ -573,15 +574,7 @@ def order_check( ], ) -> None: """Check funds sufficiency for a trading operation.""" - export_ctx = _get_export_context(ctx) - - def _fetch() -> pd.DataFrame: - return sdk._run_with_client( # noqa: SLF001 # pyright: ignore[reportPrivateUsage] - export_ctx.config, - lambda c: c.order_check_as_df(request=request), - ) - - _execute_export(ctx, _fetch) + _export_command(ctx, lambda client: client.order_check(request)) @app.command() @@ -604,15 +597,7 @@ def order_send( if not yes: msg = "Pass --yes to send a live trade request." raise typer.BadParameter(msg, param_hint="--yes") - export_ctx = _get_export_context(ctx) - - def _fetch() -> pd.DataFrame: - return sdk._run_with_client( # noqa: SLF001 # pyright: ignore[reportPrivateUsage] - export_ctx.config, - lambda c: c.order_send_as_df(request=request), - ) - - _execute_export(ctx, _fetch) + _export_command(ctx, lambda client: client.order_send(request)) @app.command() diff --git a/mt5cli/client.py b/mt5cli/client.py new file mode 100644 index 0000000..958c839 --- /dev/null +++ b/mt5cli/client.py @@ -0,0 +1,88 @@ +"""Stable public client abstraction for MT5 data and execution operations.""" + +from __future__ import annotations + +from contextlib import contextmanager +from typing import TYPE_CHECKING, Any, Self + +from .sdk import Mt5CliClient, build_config, connected_client + +if TYPE_CHECKING: + from collections.abc import Iterator + + import pandas as pd + from pdmt5 import Mt5Config, Mt5DataClient + +__all__ = [ + "MT5Client", + "build_config", + "mt5_session", +] + + +class MT5Client(Mt5CliClient): + """Public client for generic MT5 data access and order primitives. + + Extends the read-only SDK client with optional order check/send helpers and + exposes the same connection lifecycle as :class:`~mt5cli.sdk.Mt5CliClient`. + Downstream applications such as private trading packages should prefer this + type over the legacy ``Mt5CliClient`` name. + + mt5cli intentionally exposes minimal execution primitives only. Trading + decisions, signals, strategies, backtests, and optimization remain the + responsibility of downstream applications. + """ + + def order_check(self, request: dict[str, Any]) -> pd.DataFrame: + """Check funds sufficiency for a trade request. + + Args: + request: MT5 order request dictionary. + + Returns: + One-row DataFrame with the order-check result. + """ + return self._fetch(lambda client: client.order_check_as_df(request=request)) + + def order_send(self, request: dict[str, Any]) -> pd.DataFrame: + """Send a live trade request to the MT5 trade server. + + Warning: + This is a live execution primitive. A successful call can place, + modify, or close real trades on the connected account. Downstream + applications must gate usage explicitly (for example behind manual + confirmation or application-specific risk controls). mt5cli does + not implement strategy logic, signal generation, or trade sizing. + + Args: + request: MT5 order request dictionary. + + Returns: + One-row DataFrame with the order-send result. + """ + return self._fetch(lambda client: client.order_send_as_df(request=request)) + + @classmethod + def from_connected_client(cls, client: Mt5DataClient) -> Self: + """Bind to an already-connected ``Mt5DataClient`` without owning it. + + Returns: + Client wrapper bound to the injected connection. + """ + return cls(client=client) + + +@contextmanager +def mt5_session(config: Mt5Config | None = None) -> Iterator[MT5Client]: + """Open an MT5 terminal session and yield a connected :class:`MT5Client`. + + Args: + config: MT5 connection configuration. Defaults to an empty config that + attaches to a running terminal. + + Yields: + Connected :class:`MT5Client` bound to the session. + """ + mt5_config = config or build_config() + with connected_client(mt5_config) as client: + yield MT5Client.from_connected_client(client) diff --git a/mt5cli/converters.py b/mt5cli/converters.py new file mode 100644 index 0000000..e672116 --- /dev/null +++ b/mt5cli/converters.py @@ -0,0 +1,162 @@ +"""Shared conversion helpers for MT5 symbols, timeframes, and date ranges.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING + +from pdmt5 import get_timeframe_name as _get_timeframe_name + +from .utils import parse_datetime, parse_tick_flags, parse_timeframe + +if TYPE_CHECKING: + from collections.abc import Sequence + +__all__ = [ + "ensure_utc", + "granularity_name", + "normalize_symbol", + "normalize_symbols", + "parse_date_range", + "parse_datetime", + "parse_tick_flags", + "parse_timeframe", + "recent_window", +] + + +def normalize_symbol(symbol: str) -> str: + """Normalize a broker symbol name for MT5 API calls. + + Strips surrounding whitespace while preserving broker-specific casing and + suffixes (for example ``XAUUSDm``, ``US500.cash``, or ``EURUSD.r``). + + Args: + symbol: Raw symbol name. + + Returns: + Normalized symbol string. + + Raises: + ValueError: If the symbol is empty after normalization. + """ + normalized = symbol.strip() + if not normalized: + msg = "Symbol must not be empty." + raise ValueError(msg) + return normalized + + +def normalize_symbols(symbols: Sequence[str]) -> list[str]: + """Normalize a sequence of broker symbol names. + + Args: + symbols: Raw symbol names. + + Returns: + List of normalized, de-duplicated symbols preserving first-seen order. + """ + seen: set[str] = set() + resolved: list[str] = [] + for symbol in symbols: + normalized = normalize_symbol(symbol) + if normalized not in seen: + seen.add(normalized) + resolved.append(normalized) + return resolved + + +def ensure_utc(value: datetime | str) -> datetime: + """Return a timezone-aware UTC datetime. + + Args: + value: Datetime instance or ISO 8601 string. + + Returns: + UTC-aware datetime. + """ + if isinstance(value, str): + return parse_datetime(value) + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) + + +def parse_date_range( + date_from: datetime | str, + date_to: datetime | str, +) -> tuple[datetime, datetime]: + """Parse and validate an inclusive UTC date range. + + Args: + date_from: Range start as datetime or ISO 8601 string. + date_to: Range end as datetime or ISO 8601 string. + + Returns: + Tuple of UTC-aware ``(start, end)`` datetimes. + + Raises: + ValueError: If ``date_from`` is after ``date_to``. + """ + start = ensure_utc(date_from) + end = ensure_utc(date_to) + if start > end: + msg = ( + f"date_from ({start.isoformat()}) must not be after " + f"date_to ({end.isoformat()})." + ) + raise ValueError(msg) + return start, end + + +def recent_window( + *, + hours: float | None = None, + seconds: float | None = None, + date_to: datetime | str | None = None, +) -> tuple[datetime, datetime]: + """Build a trailing UTC window ending at ``date_to`` or now. + + Exactly one of ``hours`` or ``seconds`` must be provided. + + Args: + hours: Trailing window length in hours. + seconds: Trailing window length in seconds. + date_to: Window end. Defaults to current UTC time. + + Returns: + Tuple of UTC-aware ``(start, end)`` datetimes. + + Raises: + ValueError: If neither or both window lengths are provided, or if a + length is not positive. + """ + if (hours is None) == (seconds is None): + msg = "Provide exactly one of hours or seconds." + raise ValueError(msg) + if hours is not None: + length = timedelta(hours=hours) + else: + length = timedelta(seconds=seconds if seconds is not None else 0) + if length.total_seconds() <= 0: + msg = "Window length must be positive." + raise ValueError(msg) + end = ensure_utc(date_to) if date_to is not None else datetime.now(UTC) + return end - length, end + + +def granularity_name(timeframe: int | str) -> str: + """Return a short granularity label for a timeframe integer or name. + + Args: + timeframe: MT5 timeframe as integer or name (for example ``M1``). + + Returns: + Short name such as ``M1`` or the stringified integer when unknown. + """ + tf = parse_timeframe(timeframe) + try: + name = _get_timeframe_name(tf) + except ValueError: + return str(tf) + return name.removeprefix("TIMEFRAME_") diff --git a/mt5cli/exceptions.py b/mt5cli/exceptions.py new file mode 100644 index 0000000..5fcdc10 --- /dev/null +++ b/mt5cli/exceptions.py @@ -0,0 +1,90 @@ +"""Normalized exception types for MT5 and mt5cli operations.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, TypeVar + +from pdmt5 import Mt5RuntimeError, Mt5TradingError + +if TYPE_CHECKING: + from collections.abc import Callable + +T = TypeVar("T") + +__all__ = [ + "Mt5CliError", + "Mt5ConnectionError", + "Mt5OperationError", + "Mt5SchemaError", + "call_with_normalized_errors", + "is_recoverable_mt5_error", + "normalize_mt5_exception", +] + +_RECOVERABLE_MT5_ERRORS: tuple[type[BaseException], ...] = ( + Mt5TradingError, + Mt5RuntimeError, +) + + +class Mt5CliError(Exception): + """Base exception for mt5cli public API errors.""" + + +class Mt5ConnectionError(Mt5CliError): + """Raised when MT5 initialization, login, or shutdown fails.""" + + +class Mt5OperationError(Mt5CliError): + """Raised when an MT5 data or trading operation fails.""" + + +class Mt5SchemaError(Mt5CliError): + """Raised when a DataFrame does not match an expected dataset schema.""" + + +def is_recoverable_mt5_error(exc: BaseException) -> bool: + """Return whether an exception is a transient MT5 failure worth retrying. + + Args: + exc: Exception raised by MT5 or pdmt5. + + Returns: + True for ``Mt5RuntimeError`` and ``Mt5TradingError``. + """ + return isinstance(exc, _RECOVERABLE_MT5_ERRORS) + + +def normalize_mt5_exception(exc: BaseException) -> Mt5CliError: + """Map pdmt5/MT5 exceptions to stable mt5cli exception types. + + Args: + exc: Original exception from MT5 or pdmt5. + + Returns: + ``Mt5ConnectionError`` for runtime failures, ``Mt5OperationError`` for + trading failures, or the original exception when it is not recognized. + """ + if isinstance(exc, Mt5TradingError): + return Mt5OperationError(str(exc)) + if isinstance(exc, Mt5RuntimeError): + return Mt5ConnectionError(str(exc)) + if isinstance(exc, Mt5CliError): + return exc + return Mt5CliError(str(exc)) + + +def call_with_normalized_errors(fn: Callable[[], T]) -> T: + """Run ``fn`` and map recoverable MT5 errors to mt5cli types. + + Args: + fn: Callable performing MT5 work. + + Returns: + Value returned by ``fn``. + """ + try: + return fn() + except _RECOVERABLE_MT5_ERRORS as exc: + normalized = normalize_mt5_exception(exc) + raise normalized from exc diff --git a/mt5cli/history.py b/mt5cli/history.py index ca12070..4696fd2 100644 --- a/mt5cli/history.py +++ b/mt5cli/history.py @@ -12,6 +12,7 @@ from typing import TYPE_CHECKING, Literal, cast import pandas as pd from pdmt5 import get_timeframe_name as _get_timeframe_name +from .schemas import DEDUP_KEYS, DataKind from .utils import ( TIMEFRAME_NAMES, Dataset, @@ -31,10 +32,10 @@ logger = logging.getLogger(__name__) DEFAULT_HISTORY_TIMEFRAMES: tuple[str, ...] = TIMEFRAME_NAMES _HISTORY_DEDUP_KEYS: dict[Dataset, tuple[tuple[str, ...], ...]] = { - Dataset.rates: (("symbol", "timeframe", "time"), ("symbol", "time")), - Dataset.ticks: (("symbol", "time_msc"), ("symbol", "time")), - Dataset.history_orders: (("ticket",), ("symbol", "time", "type")), - Dataset.history_deals: (("ticket",), ("symbol", "time", "type", "entry")), + Dataset.rates: DEDUP_KEYS[DataKind.rates], + Dataset.ticks: DEDUP_KEYS[DataKind.ticks], + Dataset.history_orders: DEDUP_KEYS[DataKind.history_orders], + Dataset.history_deals: DEDUP_KEYS[DataKind.history_deals], } _TRADE_DEAL_TYPES: tuple[int, int] = (0, 1) diff --git a/mt5cli/retry.py b/mt5cli/retry.py new file mode 100644 index 0000000..e90615b --- /dev/null +++ b/mt5cli/retry.py @@ -0,0 +1,64 @@ +"""Retry and reconnect helpers for transient MT5 failures.""" + +from __future__ import annotations + +import logging +import time +from typing import TYPE_CHECKING, TypeVar + +from .exceptions import is_recoverable_mt5_error + +if TYPE_CHECKING: + from collections.abc import Callable + +T = TypeVar("T") + +logger = logging.getLogger(__name__) + +__all__ = [ + "retry_with_backoff", +] + + +def retry_with_backoff( + fn: Callable[[], T], + *, + retry_count: int = 0, + backoff_base: float = 2.0, + operation: str = "MT5 operation", +) -> T: + """Call ``fn`` with bounded exponential backoff on recoverable MT5 errors. + + Only ``pdmt5.Mt5RuntimeError`` and ``pdmt5.Mt5TradingError`` are retried. + Other exceptions propagate immediately. The final failure is re-raised once + retries are exhausted. + + Args: + fn: Callable performing MT5 work. + retry_count: Maximum number of retries after the first attempt. ``0`` + disables retries. + backoff_base: Base for exponential backoff. The delay before retry + attempt ``n`` (1-indexed) is ``backoff_base ** n`` seconds. + operation: Label used in warning logs. + + Returns: + Value returned by ``fn`` on success. + """ + attempts = max(retry_count, 0) + 1 + for attempt in range(attempts - 1): + try: + return fn() + except Exception as exc: + if not is_recoverable_mt5_error(exc): + raise + delay = backoff_base ** (attempt + 1) + logger.warning( + "%s failed (attempt %d/%d): %s; retrying in %.1fs", + operation, + attempt + 1, + attempts, + exc, + delay, + ) + time.sleep(delay) + return fn() diff --git a/mt5cli/schemas.py b/mt5cli/schemas.py new file mode 100644 index 0000000..3c16d7c --- /dev/null +++ b/mt5cli/schemas.py @@ -0,0 +1,291 @@ +"""Canonical DataFrame schemas for MT5 market and account datasets.""" + +from __future__ import annotations + +from enum import StrEnum +from typing import TYPE_CHECKING, Final + +import pandas as pd + +from .converters import normalize_symbol, parse_timeframe +from .exceptions import Mt5SchemaError + +if TYPE_CHECKING: + from collections.abc import Iterable + +__all__ = [ + "DEDUP_KEYS", + "KNOWN_MT5_TIME_COLUMNS", + "REQUIRED_COLUMNS", + "TIME_COLUMNS", + "DataKind", + "normalize_dataframe", + "normalize_time_columns", + "schema_columns", + "validate_schema", +] + +KNOWN_MT5_TIME_COLUMNS: Final[frozenset[str]] = frozenset({ + "time", + "time_setup", + "time_setup_msc", + "time_done", + "time_done_msc", + "time_msc", +}) + +_TIME_COLUMN_NAMES = KNOWN_MT5_TIME_COLUMNS + + +class DataKind(StrEnum): + """Supported MT5 dataset kinds with canonical column contracts.""" + + rates = "rates" + ticks = "ticks" + orders = "orders" + positions = "positions" + history_orders = "history_orders" + history_deals = "history_deals" + + +REQUIRED_COLUMNS: dict[DataKind, frozenset[str]] = { + DataKind.rates: frozenset({ + "time", + "open", + "high", + "low", + "close", + "tick_volume", + "spread", + "real_volume", + }), + DataKind.ticks: frozenset({ + "time", + "bid", + "ask", + "last", + "volume", + "time_msc", + "flags", + "volume_real", + }), + DataKind.orders: frozenset({ + "ticket", + "time_setup", + "type", + "state", + "symbol", + "volume_current", + "price_open", + }), + DataKind.positions: frozenset({ + "ticket", + "time", + "type", + "symbol", + "volume", + "price_open", + "price_current", + "profit", + }), + DataKind.history_orders: frozenset({ + "ticket", + "time_setup", + "type", + "state", + "symbol", + "volume_initial", + "price_open", + }), + DataKind.history_deals: frozenset({ + "ticket", + "order", + "time", + "type", + "entry", + "symbol", + "volume", + "price", + "profit", + }), +} + +_OPTIONAL_TIME_COLUMNS_BY_KIND: dict[DataKind, frozenset[str]] = { + DataKind.orders: frozenset({ + "time_setup_msc", + "time_done", + "time_done_msc", + }), + DataKind.history_orders: frozenset({ + "time_setup_msc", + "time_done", + "time_done_msc", + }), + DataKind.positions: frozenset({"time_msc"}), +} + +TIME_COLUMNS: dict[DataKind, frozenset[str]] = { + kind: (REQUIRED_COLUMNS[kind] & _TIME_COLUMN_NAMES) + | _OPTIONAL_TIME_COLUMNS_BY_KIND.get(kind, frozenset()) + for kind in DataKind +} + +DEDUP_KEYS: dict[DataKind, tuple[tuple[str, ...], ...]] = { + DataKind.rates: (("symbol", "timeframe", "time"), ("symbol", "time")), + DataKind.ticks: (("symbol", "time_msc"), ("symbol", "time")), + DataKind.history_orders: (("ticket",), ("symbol", "time", "type")), + DataKind.history_deals: (("ticket",), ("symbol", "time", "type", "entry")), +} + + +def schema_columns(kind: DataKind) -> frozenset[str]: + """Return required column names for a dataset kind. + + Args: + kind: Dataset kind. + + Returns: + Required column names for ``kind``. + """ + return REQUIRED_COLUMNS[kind] + + +def validate_schema( + frame: pd.DataFrame, + kind: DataKind, + *, + extra_required: Iterable[str] | None = None, +) -> None: + """Validate that a DataFrame includes required columns for a dataset kind. + + Args: + frame: DataFrame to validate. + kind: Expected dataset kind. + extra_required: Additional columns that must be present (for example + ``symbol`` and ``timeframe`` on stored rate history). + + Raises: + Mt5SchemaError: If required columns are missing. + """ + if frame.empty and len(frame.columns) == 0: + return + required = set(REQUIRED_COLUMNS[kind]) + if extra_required is not None: + required.update(extra_required) + missing = required - set(frame.columns) + if missing: + msg = ( + f"{kind.value} schema is missing required columns: " + f"{', '.join(sorted(missing))}." + ) + raise Mt5SchemaError(msg) + + +def _coerce_mt5_time_column(series: pd.Series, column: str) -> pd.Series: + """Coerce one MT5 time column to UTC-aware datetimes. + + Returns: + Series with UTC-aware datetime values. + """ + if pd.api.types.is_datetime64_any_dtype(series): + return pd.to_datetime(series, utc=True, errors="coerce") + if pd.api.types.is_numeric_dtype(series): + unit = "ms" if column.endswith("_msc") else "s" + return pd.to_datetime(series, unit=unit, utc=True, errors="coerce") + return pd.to_datetime(series, utc=True, errors="coerce") + + +def normalize_time_columns(frame: pd.DataFrame, kind: DataKind) -> pd.DataFrame: + """Coerce dataset time columns to UTC-aware datetimes when present. + + Any column in :data:`KNOWN_MT5_TIME_COLUMNS` that is present in ``frame`` + is normalized. Numeric MT5 epoch values use seconds for ``time``, + ``time_setup``, and ``time_done``, and milliseconds for ``*_msc`` columns. + + Args: + frame: Source DataFrame from MT5 or pdmt5. + kind: Dataset kind (retained for API compatibility). + + Returns: + DataFrame copy with normalized time columns. + """ + del kind + normalized = frame.copy() + for column in normalized.columns: + if column not in _TIME_COLUMN_NAMES: + continue + normalized[column] = _coerce_mt5_time_column(normalized[column], column) + return normalized + + +def normalize_dataframe( + frame: pd.DataFrame, + kind: DataKind, + *, + symbol: str | None = None, + timeframe: int | str | None = None, + sort: bool = True, +) -> pd.DataFrame: + """Normalize MT5 DataFrame columns, timestamps, and storage metadata. + + Ensures UTC timestamps, optionally injects ``symbol`` / ``timeframe`` for + storage-oriented datasets, and sorts chronologically when a ``time`` column + exists. + + Args: + frame: Source DataFrame from MT5 or pdmt5. + kind: Dataset kind guiding normalization rules. + symbol: Optional symbol to inject when missing. + timeframe: Optional timeframe integer or name to inject for rates. + sort: Whether to sort by ``time`` or ``time_msc`` when present. + + Returns: + Normalized DataFrame copy. + """ + if frame.empty and len(frame.columns) == 0: + return frame.copy() + + normalized = normalize_time_columns(frame, kind) + + if symbol is not None and "symbol" not in normalized.columns: + normalized.insert(0, "symbol", normalize_symbol(symbol)) + + if timeframe is not None and kind is DataKind.rates: + tf = parse_timeframe(timeframe) + if "timeframe" not in normalized.columns: + insert_at = 1 if "symbol" in normalized.columns else 0 + normalized.insert(insert_at, "timeframe", tf) + + validate_schema(normalized, kind) + + if sort: + if "time" in normalized.columns: + normalized = normalized.sort_values("time", kind="stable") + elif "time_msc" in normalized.columns: + normalized = normalized.sort_values("time_msc", kind="stable") + normalized = normalized.reset_index(drop=True) + + return normalized + + +def ensure_utc_columns(frame: pd.DataFrame, columns: Iterable[str]) -> pd.DataFrame: + """Return a copy with selected columns coerced to UTC datetimes. + + Args: + frame: Source DataFrame. + columns: Column names to coerce. + + Returns: + DataFrame copy with UTC-aware datetime columns. + """ + normalized = frame.copy() + for column in columns: + if column not in normalized.columns: + continue + if column in _TIME_COLUMN_NAMES: + normalized[column] = _coerce_mt5_time_column(normalized[column], column) + else: + normalized[column] = pd.to_datetime( + normalized[column], utc=True, errors="coerce" + ) + return normalized diff --git a/mt5cli/sdk.py b/mt5cli/sdk.py index 66b4eb3..8383b65 100644 --- a/mt5cli/sdk.py +++ b/mt5cli/sdk.py @@ -29,6 +29,7 @@ from .history import ( write_collected_datasets, write_incremental_datasets, ) +from .retry import retry_with_backoff from .utils import ( Dataset, IfExists, @@ -115,6 +116,7 @@ __all__ = [ "collect_latest_rates", "collect_latest_rates_for_accounts", "collect_latest_rates_for_accounts_with_retries", + "connected_client", "copy_rates_from", "copy_rates_from_pos", "copy_rates_range", @@ -319,7 +321,7 @@ def build_config( @contextmanager -def _connected_client(config: Mt5Config) -> Iterator[Mt5DataClient]: +def connected_client(config: Mt5Config) -> Iterator[Mt5DataClient]: """Initialize MT5, yield a connected client, and always shut down. Args: @@ -349,7 +351,7 @@ def _run_with_client( Returns: Value returned by ``fetch_fn``. """ - with _connected_client(config) as client: + with connected_client(config) as client: return fetch_fn(client) @@ -369,7 +371,7 @@ def mt5_session(config: Mt5Config | None = None) -> Iterator[Mt5CliClient]: Connected ``Mt5CliClient`` bound to the session. """ mt5_config = config or build_config() - with _connected_client(mt5_config) as client: + with connected_client(mt5_config) as client: yield Mt5CliClient.from_connected_client(client) @@ -384,6 +386,7 @@ class Mt5CliClient: password: str | None = None, server: str | None = None, timeout: int | None = None, + retry_count: int = 3, config: Mt5Config | None = None, client: Mt5DataClient | None = None, ) -> None: @@ -395,6 +398,8 @@ class Mt5CliClient: password: Trading account password. server: Trading server name. timeout: Connection timeout in milliseconds. + retry_count: Number of MT5 initialization retries for sessions + opened by this client. config: Optional pre-built ``Mt5Config`` (overrides other args). client: Optional already-connected ``Mt5DataClient``. Injected clients are reused as-is and are not initialized or shut down. @@ -406,6 +411,7 @@ class Mt5CliClient: server=server, timeout=timeout, ) + self._retry_count = retry_count self._client = client self._owns_client = client is None @@ -434,7 +440,7 @@ class Mt5CliClient: """ if self._client is not None: return self - client = Mt5DataClient(config=self._config) + client = Mt5DataClient(config=self._config, retry_count=self._retry_count) try: client.initialize_and_login_mt5() except Exception: @@ -1016,7 +1022,7 @@ def update_history_with_config( # noqa: PLR0913 if request is None: return mt5_config = config or build_config() - with _connected_client(mt5_config) as client: + with connected_client(mt5_config) as client: update_history( client=client, output=output, @@ -1195,7 +1201,7 @@ def collect_history( tf = _coerce_timeframe(timeframe) tick_flags = _coerce_tick_flags(flags) mt5_config = config or build_config() - with _connected_client(mt5_config) as client, sqlite3.connect(output) as conn: + with connected_client(mt5_config) as client, sqlite3.connect(output) as conn: conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA synchronous=NORMAL") written_tables, written_columns = write_collected_datasets( @@ -1591,7 +1597,6 @@ def collect_latest_rates_for_accounts_with_retries( re-raises the last ``pdmt5.Mt5TradingError`` or ``pdmt5.Mt5RuntimeError`` once retries are exhausted. """ - attempts = max(retry_count, 0) + 1 def _collect() -> dict[tuple[str, int], pd.DataFrame]: return collect_latest_rates_for_accounts( @@ -1602,20 +1607,12 @@ def collect_latest_rates_for_accounts_with_retries( base_config=base_config, ) - for attempt in range(attempts - 1): - try: - return _collect() - except (Mt5TradingError, Mt5RuntimeError) as exc: - delay = backoff_base ** (attempt + 1) - logger.warning( - "Rate collection failed (attempt %d/%d): %s; retrying in %.1fs", - attempt + 1, - attempts, - exc, - delay, - ) - time.sleep(delay) - return _collect() + return retry_with_backoff( + _collect, + retry_count=retry_count, + backoff_base=backoff_base, + operation="Rate collection", + ) def collect_latest_closed_rates_for_accounts( diff --git a/mt5cli/storage.py b/mt5cli/storage.py new file mode 100644 index 0000000..3cf3d70 --- /dev/null +++ b/mt5cli/storage.py @@ -0,0 +1,49 @@ +"""Generic storage helpers for MT5 market and account history.""" + +from __future__ import annotations + +from .history import ( + RateTarget, + build_rate_targets, + build_rate_view_name, + drop_forming_rate_bar, + load_rate_data, + load_rate_data_from_connection, + load_rate_series_by_granularity, + load_rate_series_from_sqlite, + resolve_rate_tables, + resolve_rate_view_name, + resolve_rate_view_names, +) +from .sdk import collect_history, update_history, update_history_with_config +from .utils import ( + Dataset, + IfExists, + OutputFormat, + detect_format, + export_dataframe, + export_dataframe_to_sqlite, +) + +__all__ = [ + "Dataset", + "IfExists", + "OutputFormat", + "RateTarget", + "build_rate_targets", + "build_rate_view_name", + "collect_history", + "detect_format", + "drop_forming_rate_bar", + "export_dataframe", + "export_dataframe_to_sqlite", + "load_rate_data", + "load_rate_data_from_connection", + "load_rate_series_by_granularity", + "load_rate_series_from_sqlite", + "resolve_rate_tables", + "resolve_rate_view_name", + "resolve_rate_view_names", + "update_history", + "update_history_with_config", +] diff --git a/pyproject.toml b/pyproject.toml index e9cd562..c705b8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "mt5cli" version = "0.7.1" -description = "Command-line tool for MetaTrader 5" +description = "Generic MT5 data and execution infrastructure for Python applications" authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}] maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}] license = "MIT" diff --git a/tests/test_contracts.py b/tests/test_contracts.py new file mode 100644 index 0000000..7313b49 --- /dev/null +++ b/tests/test_contracts.py @@ -0,0 +1,512 @@ +"""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 diff --git a/tests/test_sdk.py b/tests/test_sdk.py index e92e28a..996e599 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -175,7 +175,7 @@ class TestConnectionLifecycle: mock_client = MagicMock() mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=mock_client) config = MagicMock() - with sdk._connected_client(config): # type: ignore[reportPrivateUsage] + with sdk.connected_client(config): # type: ignore[reportPrivateUsage] mock_client.initialize_and_login_mt5.assert_called_once() mock_client.shutdown.assert_called_once() @@ -191,7 +191,7 @@ class TestConnectionLifecycle: mocker.patch("mt5cli.sdk.Mt5DataClient", return_value=mock_client) with ( pytest.raises(RuntimeError, match="login failed"), - sdk._connected_client(MagicMock()), # type: ignore[reportPrivateUsage] + sdk.connected_client(MagicMock()), # type: ignore[reportPrivateUsage] ): pass mock_client.shutdown.assert_called_once() @@ -1342,7 +1342,7 @@ class TestCollectLatestRatesForAccounts: """Test account fields override base_config, empty login falls back.""" configs: list[object] = [] - def _record_config(*, config: object) -> MagicMock: + def _record_config(*, config: object, **_: object) -> MagicMock: configs.append(config) return mock_client