Compare commits

...

2 Commits

Author SHA1 Message Date
Daichi Narushima 7de3ce0b7a feat: add injectable update_backend to ThrottledHistoryUpdater (#38)
* feat: add injectable update_backend to ThrottledHistoryUpdater

Allow downstream applications to substitute the history update backend via
the ThrottledHistoryUpdater constructor without monkey-patching
mt5cli.sdk.update_history. Defaults to update_history for backward
compatibility.

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

* chore: fix lint and format after QA

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

* chore: bump version to 0.8.2

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

* fix: use explicit None check for ThrottledHistoryUpdater backend

Only None selects the default update_history backend so falsy callable
objects with __bool__ returning False are preserved as custom backends.

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-18 22:58:09 +09:00
Daichi Narushima 897f7f0a0d docs: stable SDK contract and strategy-neutral order helpers (#37) 2026-06-18 19:12:11 +09:00
15 changed files with 1632 additions and 55 deletions
+6 -4
View File
@@ -4,6 +4,8 @@
Generic MT5 data and execution infrastructure for Python applications. Export from the CLI or import a small, stable Python API in downstream packages. Generic MT5 data and execution infrastructure for Python applications. Export from the CLI or import a small, stable Python API in downstream packages.
The [Public API Contract](docs/api/public-contract.md) lists stable SDK exports (`mt5cli.STABLE_SDK_EXPORTS`), CLI commands, internal helpers, and responsibilities that remain out of scope (strategy logic, backtests, optimization).
Built on top of [pdmt5](https://github.com/dceoy/pdmt5), a pandas-based data handler for MetaTrader 5. Built on top of [pdmt5](https://github.com/dceoy/pdmt5), a pandas-based data handler for MetaTrader 5.
## Architecture ## Architecture
@@ -227,7 +229,7 @@ update_history_with_config(
- **`collect-history`**: explicit date-range export into SQLite. - **`collect-history`**: explicit date-range export into SQLite.
- **`update_history`**: incremental append based on existing SQLite `MAX(time)` per symbol (and timeframe for rates); account-level deals use a separate cursor when `include_account_events=True`. - **`update_history`**: incremental append based on existing SQLite `MAX(time)` per symbol (and timeframe for rates); account-level deals use a separate cursor when `include_account_events=True`.
- **`rates` table**: normalized storage with `symbol` and `timeframe` columns. - **`rates` table**: normalized storage with `symbol` and `timeframe` columns.
- **Rate compatibility views**: mt5cli manages all `rate_*` views. Naming is `rate_<symbol>__<timeframe>` when a symbol has one timeframe, otherwise `rate_<symbol>__<granularity>_<timeframe>` (for example `rate_EURUSD__M1_1`). Stale `rate_*` views are dropped and recreated when rates change for offline tools such as mteor optimize. - **Rate compatibility views**: mt5cli manages all `rate_*` views. Naming is `rate_<symbol>__<timeframe>` when a symbol has one timeframe, otherwise `rate_<symbol>__<granularity>_<timeframe>` (for example `rate_EURUSD__M1_1`). Stale `rate_*` views are dropped and recreated when rates change for offline downstream tools.
- **Rate view resolution**: use `resolve_rate_view_name()` / `resolve_rate_view_names()` to map symbols and granularities to existing SQLite compatibility views without creating databases. Both accept `None` (or a missing path) and return deterministic default names unless `require_existing=True`. - **Rate view resolution**: use `resolve_rate_view_name()` / `resolve_rate_view_names()` to map symbols and granularities to existing SQLite compatibility views without creating databases. Both accept `None` (or a missing path) and return deterministic default names unless `require_existing=True`.
- **Rate view loading**: use `load_rate_data()` / `load_rate_data_from_connection()` to load a SQLite rate table or view into a `DatetimeIndex` DataFrame. - **Rate view loading**: use `load_rate_data()` / `load_rate_data_from_connection()` to load a SQLite rate table or view into a `DatetimeIndex` DataFrame.
- **Multi-series rate loading**: use `build_rate_targets()` to build neutral `RateTarget(symbol, timeframe)` pairs, `resolve_rate_tables()` to map them to table/view names (pass `require_existing=True` for strict resolution), and `load_rate_series_from_sqlite()` to load them into a mapping keyed by `(symbol, integer timeframe)`. The loader requires existing managed views unless `explicit_tables` is supplied, and rejects duplicate `(symbol, timeframe)` targets. - **Multi-series rate loading**: use `build_rate_targets()` to build neutral `RateTarget(symbol, timeframe)` pairs, `resolve_rate_tables()` to map them to table/view names (pass `require_existing=True` for strict resolution), and `load_rate_series_from_sqlite()` to load them into a mapping keyed by `(symbol, integer timeframe)`. The loader requires existing managed views unless `explicit_tables` is supplied, and rejects duplicate `(symbol, timeframe)` targets.
@@ -247,7 +249,7 @@ eurusd_m1 = rates["EURUSD", "M1"] # closed bars only
``` ```
- **Credential resolution**: use `resolve_account_spec()` / `resolve_account_specs()` to merge explicit override values over `AccountSpec` fields and expand `${ENV_VAR}` placeholders (via `substitute_env_placeholders()`), raising `ValueError` for missing variables. This keeps secrets out of plan/config files without coupling to any strategy code. - **Credential resolution**: use `resolve_account_spec()` / `resolve_account_specs()` to merge explicit override values over `AccountSpec` fields and expand `${ENV_VAR}` placeholders (via `substitute_env_placeholders()`), raising `ValueError` for missing variables. This keeps secrets out of plan/config files without coupling to any strategy code.
- **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). - **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). Pass `update_backend` to inject a custom history update callable (same keyword arguments as `update_history`) instead of monkey-patching `mt5cli.sdk.update_history`.
- **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. - **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. - **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 `MT5Client` 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.
@@ -260,12 +262,12 @@ eurusd_m1 = rates["EURUSD", "M1"] # closed bars only
- Windows OS (MetaTrader 5 requirement) - Windows OS (MetaTrader 5 requirement)
- MetaTrader 5 platform installed - MetaTrader 5 platform installed
### Migration note for mteor ### Migration note for downstream trading apps
Replace local MT5 lifecycle and trading helper code with mt5cli imports: Replace local MT5 lifecycle and trading helper code with mt5cli imports:
```python ```python
# Before (local mteor helpers) # Before (local application helpers)
# with local_mt5_trading_session(config) as client: # with local_mt5_trading_session(config) as client:
# side = local_detect_position_side(client, symbol) # side = local_detect_position_side(client, symbol)
# sizing = local_calculate_margin_and_volume(client, symbol, unit_ratio, preserved_ratio) # sizing = local_calculate_margin_and_volume(client, symbol, unit_ratio, preserved_ratio)
+9 -1
View File
@@ -2,10 +2,15 @@
This section documents the mt5cli public Python API and CLI modules. This section documents the mt5cli public Python API and CLI modules.
Start with the [Public API Contract](public-contract.md) for the stable
downstream SDK surface, CLI boundary, internal modules, and out-of-scope strategy
responsibilities.
## Public API layers ## Public API layers
| Module | Purpose | | Module | Purpose |
| ----------------------------------------- | ------------------------------------------------------------------------- | | ----------------------------------------- | ------------------------------------------------------------------------- |
| [Public API Contract](public-contract.md) | Stable downstream SDK exports, CLI boundary, and out-of-scope items |
| [Client](client.md) | `MT5Client` session abstraction for data access and order primitives | | [Client](client.md) | `MT5Client` session abstraction for data access and order primitives |
| [Schemas](schemas.md) | Canonical DataFrame contracts and normalization helpers | | [Schemas](schemas.md) | Canonical DataFrame contracts and normalization helpers |
| [Storage](storage.md) | CSV/JSON/Parquet/SQLite export and history collection helpers | | [Storage](storage.md) | CSV/JSON/Parquet/SQLite export and history collection helpers |
@@ -30,7 +35,10 @@ flowchart TD
SDK --> PDMT5["pdmt5.Mt5DataClient"] SDK --> PDMT5["pdmt5.Mt5DataClient"]
``` ```
Downstream packages should depend on the package root exports (`MT5Client`, `DataKind`, `normalize_dataframe`, `export_dataframe`, `collect_history`, etc.) rather than private modules. Downstream packages should depend on the package root exports documented in the
[Public API Contract](public-contract.md) (`MT5Client`,
`DataKind`, `normalize_dataframe`, `collect_history`, `load_rate_data`,
`resolve_rate_view_name`, 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. `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.
+184
View File
@@ -0,0 +1,184 @@
# Public API Contract
mt5cli is the generic MT5 data and execution infrastructure layer for downstream
Python applications. The intended dependency direction is:
```text
downstream app -> mt5cli -> pdmt5 -> MetaTrader 5
```
Downstream packages should import from the package root (`from mt5cli import
...`) and treat the symbols listed below as the stable SDK contract. CLI
commands mirror the same behavior but are not importable Python APIs.
## Stable downstream SDK API
These names are exported from `mt5cli` and covered by the contract in
`mt5cli.STABLE_SDK_EXPORTS` (defined in `mt5cli.contract`). Prefer `MT5Client` over the legacy `Mt5CliClient`
alias for new code.
### Session lifecycle and configuration
| Symbol | Role |
| ----------------------------------------------- | ---------------------------------------------------------------- |
| `MT5Client`, `Mt5CliClient` | Read-only data client with optional `order_check` / `order_send` |
| `build_config` | Build `pdmt5.Mt5Config` from connection fields |
| `mt5_session` | Context manager: initialize, login, yield client, shutdown |
| `create_trading_client`, `mt5_trading_session` | Trading-capable `pdmt5.Mt5TradingClient` lifecycle |
| `AccountSpec` | Generic account group: symbols plus optional credentials |
| `resolve_account_spec`, `resolve_account_specs` | Merge overrides and expand `${ENV_VAR}` placeholders |
| `substitute_env_placeholders` | Replace `${NAME}` substrings from the environment |
Credential resolution is generic: any environment variable name may appear inside
`${...}`. mt5cli does not hard-code application-specific keys such as
`mt5_login` or `mt5_exe`.
### Read-only MT5 data access
Module-level helpers open a transient connection per call. Prefer `mt5_session`
or `MT5Client` when making many requests in one process.
| Area | Symbols |
| -------------------- | ---------------------------------------------------------------------------------------------------- |
| Rates | `copy_rates_from`, `copy_rates_from_pos`, `copy_rates_range`, `latest_rates`, `collect_latest_rates` |
| Ticks | `copy_ticks_from`, `copy_ticks_range`, `recent_ticks` |
| Account / terminal | `account_info`, `terminal_info`, `mt5_version`, `last_error`, `mt5_summary`, `mt5_summary_as_df` |
| Symbols / market | `symbols`, `symbol_info`, `symbol_info_tick`, `market_book`, `minimum_margins` |
| Trading state (read) | `orders`, `positions`, `history_orders`, `history_deals`, `recent_history_deals` |
Use `mt5_version` for MetaTrader 5 terminal version data. The name `version` at
the package root refers to `importlib.metadata.version` (package metadata), not
the MT5 SDK helper.
### Closed-bar rate helpers
MetaTrader 5 returns the still-forming bar as the last row when
`start_pos=0`. Use these helpers instead of reimplementing bar trimming or
timestamp normalization in downstream apps.
| Symbol | Role |
| ------------------------------------------------ | ------------------------------------------------------------ |
| `drop_forming_rate_bar` | Remove the last row from chronologically ordered rate data |
| `fetch_latest_closed_rates` | Single connected client: fetch `count + 1`, drop forming bar |
| `collect_latest_closed_rates_for_accounts` | Multi-account closed bars with optional retry wrapper |
| `collect_latest_closed_rates_by_granularity` | Same data keyed by `(symbol, granularity_name)` |
| `collect_latest_rates_for_accounts` | Latest bars including the forming bar when `start_pos=0` |
| `collect_latest_rates_for_accounts_with_retries` | Bounded exponential backoff for transient MT5 errors |
### SQLite history collection and rate loading
| Symbol | Role |
| ----------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `collect_history` | One-shot date-range export into SQLite |
| `update_history`, `update_history_with_config` | Incremental append from `MAX(time)` cursors |
| `ThrottledHistoryUpdater` | Minimum interval between successful incremental updates; optional `update_backend` injection |
| `resolve_history_datasets`, `resolve_history_timeframes`, `resolve_history_tick_flags` | History pipeline configuration |
| `build_rate_view_name`, `resolve_rate_table_name`, `resolve_rate_view_name`, `resolve_rate_view_names`, `resolve_rate_tables` | Map symbols/timeframes to mt5cli-managed table or view names |
| `RateTarget`, `build_rate_targets` | Neutral `(symbol, timeframe)` series descriptors |
| `load_rate_data`, `load_rate_data_from_connection` | Load one table/view into a time-indexed DataFrame |
| `load_rate_series_from_sqlite`, `load_rate_series_by_granularity` | Load one or many series; fail clearly when managed views are missing |
Pass `require_existing=True` to rate view resolution helpers when downstream
code must fail instead of receiving a best-guess view name. Multi-series loaders
require existing managed `rate_*__*` views unless `explicit_tables` is supplied.
See [History Collection (SQLite)](history.md) for schema, view naming, and ER
diagrams.
### Trading and sizing primitives (generic)
These helpers implement broker-facing calculations only. They do not encode
strategy entries, exits, Kelly sizing, or signal logic.
| Symbol | Role |
| -------------------------------------------------------------------------------------------------- | --------------------------------------------- |
| `get_account_snapshot`, `get_symbol_snapshot`, `get_tick_snapshot`, `get_positions_frame` | Normalized account/symbol/tick/position views |
| `detect_position_side` | Net long / short / flat from open positions |
| `calculate_spread_ratio` | Relative bid-ask spread |
| `calculate_margin_and_volume`, `calculate_volume_by_margin`, `calculate_new_position_margin_ratio` | Margin budget and volume sizing |
| `determine_order_limits` | SL/TP price levels from ratios |
| `ensure_symbol_selected` | Select/verify Market Watch visibility |
| `place_market_order`, `close_open_positions`, `update_sltp_for_open_positions` | Order execution helpers (`dry_run` supported) |
| `MarginVolume`, `OrderLimits`, `OrderExecutionResult` | Typed return contracts for order helpers |
| `OrderSide`, `OrderFillingMode`, `OrderTimeMode`, `PositionSide`, `ExecutionStatus` | Typed enums for order helpers |
`MT5Client.order_send()` and CLI `order-send --yes` are live execution paths.
Order helpers validate broker stop-level distance in `determine_order_limits()` and
raise `Mt5TradingError` when computed SL/TP prices are too close to the entry
quote. Validation uses `trade_stops_level * point` from the current quote and
symbol metadata as a pre-check only; it does not guarantee live order acceptance
after price movement and does not inspect `trade_freeze_level`. Live
`place_market_order()` and SL/TP updates call
`ensure_symbol_selected()` so hidden symbols are added to Market Watch before
sending requests. Failed, malformed, or unknown broker retcodes are fail-closed
and returned as `status="failed"` with normalized `request` / `response` details;
`dry_run=True` never calls `ensure_symbol_selected()` or `order_send()`.
### Errors and MT5 type re-exports
| Symbol | Role |
| ------------------------------------------------------------------------------------ | ----------------------------------------------- |
| `Mt5CliError`, `Mt5ConnectionError`, `Mt5OperationError`, `Mt5SchemaError` | Stable mt5cli exception types |
| `normalize_mt5_exception`, `call_with_normalized_errors`, `is_recoverable_mt5_error` | Error normalization and retry classification |
| `Mt5Config`, `Mt5RuntimeError`, `Mt5TradingClient`, `Mt5TradingError` | Re-exported pdmt5 types for adapter convenience |
### Additional public exports (secondary)
The package root also exports schema, storage, and parsing helpers (for example
`DataKind`, `Dataset`, `normalize_dataframe`, `export_dataframe`,
`parse_timeframe`, `TIMEFRAME_MAP`). These are public but oriented toward export
pipelines and advanced integration. Prefer the stable symbols above for core
infrastructure.
## CLI commands
The Typer application in `mt5cli.cli` exposes file-export commands documented in
[CLI Module](cli.md) and the project README. CLI commands:
- Require `-o/--output` and write CSV, JSON, Parquet, or SQLite.
- Accept global MT5 connection options (`--login`, `--password`, `--server`,
`--path`, `--timeout`).
- Delegate to the same Python APIs described here; they are not duplicated
business logic.
`order-send` requires `--yes` before placing live trades.
## Internal helpers (not stable)
Do not import these for downstream contracts; they may change without a semver
notice:
| Module | Examples |
| ------------------------ | ------------------------------------------------------------------------- |
| `mt5cli.sdk` | `connected_client`, `_run_with_client`, private coercion helpers |
| `mt5cli.history` | `write_*_dataset`, `deduplicate_history_tables`, `parse_sqlite_timestamp` |
| `mt5cli.retry` | `retry_with_backoff` |
| `mt5cli.cli` | Typer command handlers and Click parameter types |
| Leading-underscore names | Any `_`-prefixed function or method |
Use the package-root stable exports instead of reaching into submodule
internals.
## Explicitly out of scope
mt5cli must **not** implement downstream strategy or research responsibilities.
The following belong in consuming applications, not in mt5cli:
- Signal detection (for example AR-GARCH or other model-specific triggers)
- Backtesting, walk-forward analysis, or parameter optimization
- Strategy-specific risk policy, position sizing systems, or Kelly fractions
- Entry/exit decision logic or YAML strategy semantics
- Application-specific credential schema keys wired into mt5cli internals
mt5cli provides connection lifecycle, normalized data access, SQLite history
machinery, closed-bar helpers, generic margin/volume/spread/SL/TP utilities, and
optional order primitives so downstream apps can focus on strategy code behind
their own adapter layer.
## Contract verification
`tests/test_contracts.py` asserts that every name in `STABLE_SDK_EXPORTS` is
importable from `mt5cli`, documents key closed-bar, rate-view, SQLite loading,
account-resolution, and trading-session behaviors, and keeps the contract set
aligned with `__all__`.
+22
View File
@@ -113,6 +113,28 @@ finally:
client.shutdown() client.shutdown()
``` ```
Pass `update_backend` to substitute the default `update_history` implementation
without monkey-patching `mt5cli.sdk.update_history`. The callable receives the
same keyword arguments as `update_history` (`client`, `output`, `symbols`,
`datasets`, `timeframes`, `flags`, `lookback_hours`, `with_views`,
`include_account_events`). The resolved backend is stored on
`updater.update_backend` for inspection or subclassing.
```python
from mt5cli import ThrottledHistoryUpdater, update_history
def app_update_history(**kwargs) -> None:
update_history(**kwargs) # or delegate to application-specific logic
updater = ThrottledHistoryUpdater(
output="history.db",
interval_seconds=60,
update_backend=app_update_history,
)
```
By default recoverable errors (`Mt5TradingError`, `Mt5RuntimeError`, By default recoverable errors (`Mt5TradingError`, `Mt5RuntimeError`,
`sqlite3.Error`, `ValueError`, `OSError`, and MT5 client capability `sqlite3.Error`, `ValueError`, `OSError`, and MT5 client capability
`AttributeError` / `TypeError` for history API methods) propagate so the caller `AttributeError` / `TypeError` for history API methods) propagate so the caller
+57 -10
View File
@@ -90,18 +90,65 @@ sell-only exposure, and `None` for no positions or mixed long/short exposure.
SL/TP ratios for `determine_order_limits()` must satisfy `0 <= ratio < 1`; `0` SL/TP ratios for `determine_order_limits()` must satisfy `0 <= ratio < 1`; `0`
omits that level. SL/TP prices are rounded with symbol `digits` metadata when omits that level. SL/TP prices are rounded with symbol `digits` metadata when
available. `unit_margin_ratio` and `preserved_margin_ratio` for available. `determine_order_limits()` pre-validates computed SL/TP prices against
`calculate_margin_and_volume()` accept `0 <= ratio <= 1`; `unit_margin_ratio=0` available `trade_stops_level * point` metadata when present; violations raise
requests one minimum valid unit when the post-reserve margin can afford it. `Mt5TradingError`. This is a planning helper only: it does not guarantee broker
Negative `margin_free` is clamped to `0.0` before sizing. Execution helpers acceptance because live validation can still depend on price movement, bid/ask
return normalized dictionaries containing the request, response, status, side, freeze levels, and server-side rules, and it does not validate
retcode, and `dry_run` flag; `dry_run=True` never sends an order. Market order `trade_freeze_level`. When symbol metadata cannot be loaded, protective prices
helpers mark known non-success MT5 retcodes as `status="failed"` while keeping still round with `digits=8` and stop-level validation is skipped.
the normalized response for inspection. `unit_margin_ratio` and `preserved_margin_ratio` for `calculate_margin_and_volume()`
accept `0 <= ratio <= 1`; `unit_margin_ratio=0` requests one minimum valid unit
when the post-reserve margin can afford it. Negative `margin_free` is clamped to
`0.0` before sizing. Execution helpers return normalized `OrderExecutionResult`
dictionaries containing the request, response, status, retcode, and `dry_run`
flag; `dry_run=True` never sends an order or mutates Market Watch visibility.
`ensure_symbol_selected()` adds hidden symbols to Market Watch before live order
placement and SL/TP updates. Failed, malformed, or unknown broker retcodes are
fail-closed and returned as `status="failed"` while keeping the normalized
response for inspection.
## Migration from mteor-local helpers ## Order planning return contracts
| mteor-local concern | mt5cli replacement | ```python
from mt5cli import MarginVolume, OrderLimits, OrderExecutionResult
sizing: MarginVolume = calculate_margin_and_volume(
client,
"EURUSD",
unit_margin_ratio=0.5,
preserved_margin_ratio=0.2,
)
limits: OrderLimits = determine_order_limits(
client,
"EURUSD",
side="long",
stop_loss_limit_ratio=0.01,
take_profit_limit_ratio=0.02,
)
preview: OrderExecutionResult = place_market_order(
client,
symbol="EURUSD",
volume=sizing["buy_volume"],
order_side="BUY",
sl=limits["stop_loss"],
tp=limits["take_profit"],
dry_run=True,
)
updates: list[OrderExecutionResult] = update_sltp_for_open_positions(
client,
symbol="EURUSD",
stop_loss=limits["stop_loss"],
dry_run=True,
)
```
Closes issue #33: strategy-neutral order planning and execution helpers exposed
through the stable package root without embedding entry/exit policy.
## Migration from application-local helpers
| Application-local concern | mt5cli replacement |
| -------------------------------------------------------- | ----------------------------------------------- | | -------------------------------------------------------- | ----------------------------------------------- |
| Manual terminal spawn/kill around trading code | `mt5_trading_session()` | | Manual terminal spawn/kill around trading code | `mt5_trading_session()` |
| Local position-side detection | `detect_position_side()` | | Local position-side detection | `detect_position_side()` |
+1
View File
@@ -56,6 +56,7 @@ nav:
- Home: index.md - Home: index.md
- API Reference: - API Reference:
- Overview: api/index.md - Overview: api/index.md
- Public API Contract: api/public-contract.md
- Client: api/client.md - Client: api/client.md
- Schemas: api/schemas.md - Schemas: api/schemas.md
- Storage: api/storage.md - Storage: api/storage.md
+19 -1
View File
@@ -1,10 +1,17 @@
"""mt5cli: Generic MT5 data and execution infrastructure for Python applications.""" """mt5cli: Generic MT5 data and execution infrastructure for Python applications.
Downstream packages should import from this module (``from mt5cli import ...``)
rather than private submodule helpers. See ``docs/api/public-contract.md`` for
the stable SDK contract, CLI surface, internal modules, and out-of-scope
strategy responsibilities.
"""
from importlib.metadata import version from importlib.metadata import version
from pdmt5 import Mt5Config, Mt5RuntimeError, Mt5TradingClient, Mt5TradingError from pdmt5 import Mt5Config, Mt5RuntimeError, Mt5TradingClient, Mt5TradingError
from .client import MT5Client, build_config, mt5_session from .client import MT5Client, build_config, mt5_session
from .contract import STABLE_SDK_EXPORTS
from .converters import ( from .converters import (
ensure_utc, ensure_utc,
granularity_name, granularity_name,
@@ -101,7 +108,11 @@ from .storage import (
) )
from .trading import ( from .trading import (
POSITION_COLUMNS, POSITION_COLUMNS,
ExecutionStatus,
MarginVolume,
OrderExecutionResult,
OrderFillingMode, OrderFillingMode,
OrderLimits,
OrderSide, OrderSide,
OrderTimeMode, OrderTimeMode,
PositionSide, PositionSide,
@@ -113,6 +124,7 @@ from .trading import (
create_trading_client, create_trading_client,
detect_position_side, detect_position_side,
determine_order_limits, determine_order_limits,
ensure_symbol_selected,
get_account_snapshot, get_account_snapshot,
get_positions_frame, get_positions_frame,
get_symbol_snapshot, get_symbol_snapshot,
@@ -136,14 +148,17 @@ __all__ = [
"KNOWN_MT5_TIME_COLUMNS", "KNOWN_MT5_TIME_COLUMNS",
"POSITION_COLUMNS", "POSITION_COLUMNS",
"REQUIRED_COLUMNS", "REQUIRED_COLUMNS",
"STABLE_SDK_EXPORTS",
"TICK_FLAG_MAP", "TICK_FLAG_MAP",
"TIMEFRAME_MAP", "TIMEFRAME_MAP",
"TIME_COLUMNS", "TIME_COLUMNS",
"AccountSpec", "AccountSpec",
"DataKind", "DataKind",
"Dataset", "Dataset",
"ExecutionStatus",
"IfExists", "IfExists",
"MT5Client", "MT5Client",
"MarginVolume",
"Mt5CliClient", "Mt5CliClient",
"Mt5CliError", "Mt5CliError",
"Mt5Config", "Mt5Config",
@@ -153,7 +168,9 @@ __all__ = [
"Mt5SchemaError", "Mt5SchemaError",
"Mt5TradingClient", "Mt5TradingClient",
"Mt5TradingError", "Mt5TradingError",
"OrderExecutionResult",
"OrderFillingMode", "OrderFillingMode",
"OrderLimits",
"OrderSide", "OrderSide",
"OrderTimeMode", "OrderTimeMode",
"PositionSide", "PositionSide",
@@ -185,6 +202,7 @@ __all__ = [
"detect_position_side", "detect_position_side",
"determine_order_limits", "determine_order_limits",
"drop_forming_rate_bar", "drop_forming_rate_bar",
"ensure_symbol_selected",
"ensure_utc", "ensure_utc",
"export_dataframe", "export_dataframe",
"export_dataframe_to_sqlite", "export_dataframe_to_sqlite",
+101
View File
@@ -0,0 +1,101 @@
"""Stable downstream SDK export names for mt5cli."""
from __future__ import annotations
STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
"AccountSpec",
"MT5Client",
"Mt5CliClient",
"Mt5CliError",
"Mt5Config",
"Mt5ConnectionError",
"Mt5OperationError",
"Mt5RuntimeError",
"Mt5SchemaError",
"Mt5TradingClient",
"Mt5TradingError",
"OrderFillingMode",
"OrderSide",
"OrderTimeMode",
"PositionSide",
"ExecutionStatus",
"MarginVolume",
"OrderExecutionResult",
"OrderLimits",
"RateTarget",
"ThrottledHistoryUpdater",
"account_info",
"build_config",
"build_rate_targets",
"build_rate_view_name",
"calculate_margin_and_volume",
"calculate_new_position_margin_ratio",
"calculate_spread_ratio",
"calculate_volume_by_margin",
"call_with_normalized_errors",
"close_open_positions",
"collect_history",
"collect_latest_closed_rates_by_granularity",
"collect_latest_closed_rates_for_accounts",
"collect_latest_rates",
"collect_latest_rates_for_accounts",
"collect_latest_rates_for_accounts_with_retries",
"copy_rates_from",
"copy_rates_from_pos",
"copy_rates_range",
"copy_ticks_from",
"copy_ticks_range",
"create_trading_client",
"detect_position_side",
"determine_order_limits",
"drop_forming_rate_bar",
"ensure_symbol_selected",
"export_dataframe",
"export_dataframe_to_sqlite",
"fetch_latest_closed_rates",
"get_account_snapshot",
"get_positions_frame",
"get_symbol_snapshot",
"get_tick_snapshot",
"history_deals",
"history_orders",
"is_recoverable_mt5_error",
"last_error",
"latest_rates",
"load_rate_data",
"load_rate_data_from_connection",
"load_rate_series_by_granularity",
"load_rate_series_from_sqlite",
"market_book",
"minimum_margins",
"mt5_session",
"mt5_summary",
"mt5_summary_as_df",
"mt5_trading_session",
"mt5_version",
"normalize_mt5_exception",
"orders",
"place_market_order",
"positions",
"recent_history_deals",
"recent_ticks",
"resolve_account_spec",
"resolve_account_specs",
"resolve_history_datasets",
"resolve_history_tick_flags",
"resolve_history_timeframes",
"resolve_rate_table_name",
"resolve_rate_tables",
"resolve_rate_view_name",
"resolve_rate_view_names",
"substitute_env_placeholders",
"symbol_info",
"symbol_info_tick",
"symbols",
"terminal_info",
"update_history",
"update_history_with_config",
"update_sltp_for_open_positions",
})
__all__ = ["STABLE_SDK_EXPORTS"]
+23 -5
View File
@@ -42,6 +42,8 @@ from .utils import coerce_login as _coerce_login
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable, Iterator, Sequence from collections.abc import Callable, Iterator, Sequence
UpdateHistoryBackend = Callable[..., None]
T = TypeVar("T") T = TypeVar("T")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -1044,10 +1046,16 @@ def update_history_with_config( # noqa: PLR0913
class ThrottledHistoryUpdater: class ThrottledHistoryUpdater:
"""Throttled incremental SQLite history updater for long-running apps. """Throttled incremental SQLite history updater for long-running apps.
Wraps :func:`update_history` with a minimum interval between successful Wraps :func:`update_history` (or a custom ``update_backend``) with a minimum
updates, so a tight application loop can call :meth:`update` every interval between successful updates, so a tight application loop can call
iteration without re-fetching MT5 history more often than desired. Timing :meth:`update` every iteration without re-fetching MT5 history more often
uses a monotonic clock, so it is unaffected by wall-clock changes. than desired. Timing uses a monotonic clock, so it is unaffected by
wall-clock changes.
Downstream applications may pass ``update_backend`` to substitute the
default :func:`update_history` implementation—for example to add
application-specific logging, metrics, or test doubles—without monkey-
patching ``mt5cli.sdk.update_history``.
""" """
def __init__( def __init__(
@@ -1062,6 +1070,7 @@ class ThrottledHistoryUpdater:
include_account_events: bool = True, include_account_events: bool = True,
interval_seconds: float = 0.0, interval_seconds: float = 0.0,
suppress_errors: bool = False, suppress_errors: bool = False,
update_backend: UpdateHistoryBackend | None = None,
) -> None: ) -> None:
"""Initialize the throttled updater. """Initialize the throttled updater.
@@ -1085,6 +1094,12 @@ class ThrottledHistoryUpdater:
the throttle. Other ``AttributeError`` / ``TypeError`` values the throttle. Other ``AttributeError`` / ``TypeError`` values
always propagate. When False (default), recoverable errors always propagate. When False (default), recoverable errors
propagate so callers control logging. propagate so callers control logging.
update_backend: Callable invoked instead of :func:`update_history`
when :meth:`update` runs. Receives the same keyword arguments as
:func:`update_history` (``client``, ``output``, ``symbols``,
``datasets``, ``timeframes``, ``flags``, ``lookback_hours``,
``with_views``, ``include_account_events``). Defaults to
:func:`update_history`.
""" """
self.output = output self.output = output
self.datasets = datasets self.datasets = datasets
@@ -1095,6 +1110,9 @@ class ThrottledHistoryUpdater:
self.include_account_events = include_account_events self.include_account_events = include_account_events
self.interval_seconds = interval_seconds self.interval_seconds = interval_seconds
self.suppress_errors = suppress_errors self.suppress_errors = suppress_errors
self.update_backend = (
update_history if update_backend is None else update_backend
)
self._last_update_monotonic: float | None = None self._last_update_monotonic: float | None = None
@property @property
@@ -1145,7 +1163,7 @@ class ThrottledHistoryUpdater:
lookback_hours=self.lookback_hours, lookback_hours=self.lookback_hours,
date_to=None, date_to=None,
) )
update_history( self.update_backend(
client=client, client=client,
output=self.output, output=self.output,
symbols=symbols, symbols=symbols,
+210 -27
View File
@@ -4,7 +4,8 @@ from __future__ import annotations
from contextlib import contextmanager from contextlib import contextmanager
from math import floor, isfinite from math import floor, isfinite
from typing import TYPE_CHECKING, Literal, cast from numbers import Integral
from typing import TYPE_CHECKING, Literal, TypedDict, cast
import pandas as pd import pandas as pd
from pdmt5 import Mt5Config, Mt5TradingClient, Mt5TradingError from pdmt5 import Mt5Config, Mt5TradingClient, Mt5TradingError
@@ -19,6 +20,44 @@ PositionSide = Literal["long", "short"]
OrderSide = Literal["BUY", "SELL"] OrderSide = Literal["BUY", "SELL"]
OrderFillingMode = Literal["IOC", "FOK", "RETURN"] OrderFillingMode = Literal["IOC", "FOK", "RETURN"]
OrderTimeMode = Literal["GTC", "DAY", "SPECIFIED", "SPECIFIED_DAY"] OrderTimeMode = Literal["GTC", "DAY", "SPECIFIED", "SPECIFIED_DAY"]
ExecutionStatus = Literal["executed", "dry_run", "skipped", "failed"]
class MarginVolume(TypedDict):
"""Affordable volume bounds derived from account margin and symbol constraints."""
margin_free: float
available_margin: float
trade_margin: float
buy_volume: float
sell_volume: float
volume_min: float
volume_max: float
volume_step: float
class OrderLimits(TypedDict):
"""Protective order prices derived from current quotes and ratio parameters."""
entry: float
stop_loss: float | None
take_profit: float | None
class OrderExecutionResult(TypedDict):
"""Normalized result from market-order and position-management helpers."""
status: ExecutionStatus
symbol: str
order_side: OrderSide
volume: float
retcode: int | None
comment: str | None
request: dict[str, object]
response: dict[str, object] | None
dry_run: bool
_ORDER_FILLING_MODES: frozenset[str] = frozenset({"IOC", "FOK", "RETURN"}) _ORDER_FILLING_MODES: frozenset[str] = frozenset({"IOC", "FOK", "RETURN"})
_ORDER_TIME_MODES: frozenset[str] = frozenset({ _ORDER_TIME_MODES: frozenset[str] = frozenset({
"GTC", "GTC",
@@ -76,7 +115,11 @@ POSITION_COLUMNS = (
__all__ = [ __all__ = [
"POSITION_COLUMNS", "POSITION_COLUMNS",
"ExecutionStatus",
"MarginVolume",
"OrderExecutionResult",
"OrderFillingMode", "OrderFillingMode",
"OrderLimits",
"OrderSide", "OrderSide",
"OrderTimeMode", "OrderTimeMode",
"PositionSide", "PositionSide",
@@ -88,6 +131,7 @@ __all__ = [
"create_trading_client", "create_trading_client",
"detect_position_side", "detect_position_side",
"determine_order_limits", "determine_order_limits",
"ensure_symbol_selected",
"get_account_snapshot", "get_account_snapshot",
"get_positions_frame", "get_positions_frame",
"get_symbol_snapshot", "get_symbol_snapshot",
@@ -98,6 +142,91 @@ __all__ = [
] ]
def _minimum_stop_distance(
symbol_info: dict[str, float | int | str | bool | None],
) -> float:
"""Return the minimum SL/TP distance in price units from broker stop level."""
stops_level = symbol_info.get("trade_stops_level")
point = symbol_info.get("point")
if not isinstance(stops_level, int | float) or not isinstance(point, int | float):
return 0.0
level = float(stops_level)
pt = float(point)
if level <= 0 or pt <= 0:
return 0.0
return level * pt
def _validate_protective_prices(
*,
symbol: str,
side: PositionSide,
entry: float,
stop_loss: float | None,
take_profit: float | None,
min_distance: float,
) -> None:
"""Validate SL/TP distances against broker stop-level constraints.
Raises:
Mt5TradingError: When a protective price is closer than ``min_distance``.
"""
if min_distance <= 0:
return
if side == "long":
if stop_loss is not None and (entry - stop_loss) < min_distance:
msg = (
f"Stop loss for {symbol!r} violates broker stop level "
f"(minimum distance {min_distance})."
)
raise Mt5TradingError(msg)
if take_profit is not None and (take_profit - entry) < min_distance:
msg = (
f"Take profit for {symbol!r} violates broker stop level "
f"(minimum distance {min_distance})."
)
raise Mt5TradingError(msg)
return
if stop_loss is not None and (stop_loss - entry) < min_distance:
msg = (
f"Stop loss for {symbol!r} violates broker stop level "
f"(minimum distance {min_distance})."
)
raise Mt5TradingError(msg)
if take_profit is not None and (entry - take_profit) < min_distance:
msg = (
f"Take profit for {symbol!r} violates broker stop level "
f"(minimum distance {min_distance})."
)
raise Mt5TradingError(msg)
def ensure_symbol_selected(client: Mt5TradingClient, symbol: str) -> None:
"""Ensure a symbol is visible in Market Watch before sending orders.
Args:
client: Connected ``Mt5TradingClient`` instance.
symbol: Symbol to select.
Raises:
Mt5TradingError: If the symbol cannot be selected in Market Watch or
``symbol_select`` is unavailable on the client.
"""
snapshot = get_symbol_snapshot(client, symbol)
if snapshot.get("visible"):
return
select = getattr(client, "symbol_select", None)
if not callable(select):
msg = "MT5 client is missing required method: symbol_select"
raise Mt5TradingError(msg)
if select(symbol, enable=True):
return
last_error = getattr(client, "last_error", None)
detail = f" ({last_error()})" if callable(last_error) else ""
msg = f"Failed to select symbol {symbol!r} in Market Watch{detail}."
raise Mt5TradingError(msg)
def _require_unit_ratio(value: float, name: str) -> None: def _require_unit_ratio(value: float, name: str) -> None:
if not 0.0 <= value <= 1.0: if not 0.0 <= value <= 1.0:
msg = f"{name} must be between 0 and 1 inclusive." msg = f"{name} must be between 0 and 1 inclusive."
@@ -207,6 +336,33 @@ def _resolve_mt5_constant(
raise Mt5TradingError(msg) from exc raise Mt5TradingError(msg) from exc
def _parse_digit_string(value: str) -> int | None:
text = value.strip()
if not text:
return None
sign = 1
if text[0] == "+":
text = text[1:].strip()
elif text[0] == "-":
sign = -1
text = text[1:].strip()
return sign * int(text) if text.isdigit() else None
def _optional_int(value: object) -> int | None:
if value is None or isinstance(value, bool):
return None
if isinstance(value, Integral):
return int(value)
if isinstance(value, str):
return _parse_digit_string(value)
return None
def _optional_str(value: object) -> str | None:
return value if isinstance(value, str) else None
def _optional_price(value: object) -> float | None: def _optional_price(value: object) -> float | None:
if value is None: if value is None:
return None return None
@@ -227,10 +383,11 @@ def _success_retcodes(mt5: object) -> frozenset[int]:
return frozenset(values) or _SUCCESS_RETCODE_FALLBACKS return frozenset(values) or _SUCCESS_RETCODE_FALLBACKS
def _order_status_from_retcode(mt5: object, retcode: object) -> str: def _order_status_from_retcode(mt5: object, retcode: object) -> ExecutionStatus:
if retcode is None: normalized = _optional_int(retcode)
return "executed" if normalized is None:
if isinstance(retcode, int) and retcode not in _success_retcodes(mt5): return "failed"
if normalized not in _success_retcodes(mt5):
return "failed" return "failed"
return "executed" return "executed"
@@ -431,7 +588,7 @@ def calculate_margin_and_volume(
symbol: str, symbol: str,
unit_margin_ratio: float, unit_margin_ratio: float,
preserved_margin_ratio: float, preserved_margin_ratio: float,
) -> dict[str, float]: ) -> MarginVolume:
"""Calculate tradable margin and volumes from account free margin. """Calculate tradable margin and volumes from account free margin.
Applies ``preserved_margin_ratio`` to keep a reserve off ``margin_free``, Applies ``preserved_margin_ratio`` to keep a reserve off ``margin_free``,
@@ -561,7 +718,7 @@ def determine_order_limits(
side: PositionSide | str, side: PositionSide | str,
stop_loss_limit_ratio: float | None = None, stop_loss_limit_ratio: float | None = None,
take_profit_limit_ratio: float | None = None, take_profit_limit_ratio: float | None = None,
) -> dict[str, float | None]: ) -> OrderLimits:
"""Derive entry and protective order prices from current market quotes. """Derive entry and protective order prices from current market quotes.
Args: Args:
@@ -579,7 +736,8 @@ def determine_order_limits(
Omitted protective levels are returned as ``None``. Omitted protective levels are returned as ``None``.
Raises: Raises:
Mt5TradingError: If required tick data is invalid. Mt5TradingError: If required tick data is invalid or computed SL/TP
prices violate available ``trade_stops_level`` pre-validation.
""" """
stop_loss_ratio = stop_loss_limit_ratio or 0.0 stop_loss_ratio = stop_loss_limit_ratio or 0.0
take_profit_ratio = take_profit_limit_ratio or 0.0 take_profit_ratio = take_profit_limit_ratio or 0.0
@@ -593,9 +751,14 @@ def determine_order_limits(
raise Mt5TradingError(msg) raise Mt5TradingError(msg)
entry = float(entry_value) entry = float(entry_value)
try: try:
digits = int(get_symbol_snapshot(client, symbol).get("digits") or 8) symbol_info = get_symbol_snapshot(client, symbol)
except AttributeError: except (AttributeError, KeyError, TypeError, ValueError):
symbol_info = {}
try:
digits = int(symbol_info.get("digits") or 8)
except (TypeError, ValueError):
digits = 8 digits = 8
min_distance = _minimum_stop_distance(symbol_info)
stop_loss: float | None = None stop_loss: float | None = None
if stop_loss_ratio > 0: if stop_loss_ratio > 0:
@@ -613,6 +776,15 @@ def determine_order_limits(
take_profit = entry * (1.0 - take_profit_ratio) take_profit = entry * (1.0 - take_profit_ratio)
take_profit = round(take_profit, digits) take_profit = round(take_profit, digits)
_validate_protective_prices(
symbol=symbol,
side=normalized_side,
entry=entry,
stop_loss=stop_loss,
take_profit=take_profit,
min_distance=min_distance,
)
return { return {
"entry": entry, "entry": entry,
"stop_loss": stop_loss, "stop_loss": stop_loss,
@@ -632,7 +804,7 @@ def place_market_order(
tp: float | None = None, tp: float | None = None,
position: int | None = None, position: int | None = None,
dry_run: bool = False, dry_run: bool = False,
) -> dict[str, object]: ) -> OrderExecutionResult:
"""Place one normalized market order or return a dry-run result. """Place one normalized market order or return a dry-run result.
``pdmt5.Mt5TradingClient.order_send()`` raises only when MT5 returns no ``pdmt5.Mt5TradingClient.order_send()`` raises only when MT5 returns no
@@ -650,6 +822,8 @@ def place_market_order(
msg = "volume must be positive." msg = "volume must be positive."
raise Mt5TradingError(msg) raise Mt5TradingError(msg)
side = _normalize_order_side(order_side) side = _normalize_order_side(order_side)
if not dry_run:
ensure_symbol_selected(client, symbol)
tick = get_tick_snapshot(client, symbol) tick = get_tick_snapshot(client, symbol)
price = tick["ask"] if side == "BUY" else tick["bid"] price = tick["ask"] if side == "BUY" else tick["bid"]
if not isinstance(price, int | float) or price <= 0: if not isinstance(price, int | float) or price <= 0:
@@ -690,21 +864,22 @@ def place_market_order(
"volume": volume, "volume": volume,
"retcode": None, "retcode": None,
"comment": None, "comment": None,
"request": request, "request": cast("dict[str, object]", request),
"response": None, "response": None,
"dry_run": True, "dry_run": True,
} }
response = client.order_send(request) response = client.order_send(request)
response_dict = _snapshot_from_value(response, ()) response_dict = _snapshot_from_value(response, ())
retcode = response_dict.get("retcode") raw_retcode = response_dict.get("retcode")
retcode = _optional_int(raw_retcode)
return { return {
"status": _order_status_from_retcode(client.mt5, retcode), "status": _order_status_from_retcode(client.mt5, raw_retcode),
"symbol": symbol, "symbol": symbol,
"order_side": side, "order_side": side,
"volume": volume, "volume": volume,
"retcode": retcode, "retcode": retcode,
"comment": response_dict.get("comment"), "comment": _optional_str(response_dict.get("comment")),
"request": request, "request": cast("dict[str, object]", request),
"response": response_dict, "response": response_dict,
"dry_run": False, "dry_run": False,
} }
@@ -731,7 +906,7 @@ def close_open_positions(
symbols: str | list[str] | None = None, symbols: str | list[str] | None = None,
tickets: list[int] | None = None, tickets: list[int] | None = None,
dry_run: bool = False, dry_run: bool = False,
) -> list[dict[str, object]]: ) -> list[OrderExecutionResult]:
"""Close matching open positions. """Close matching open positions.
Returns: Returns:
@@ -742,7 +917,7 @@ def close_open_positions(
symbols=symbols, symbols=symbols,
tickets=tickets, tickets=tickets,
) )
results: list[dict[str, object]] = [] results: list[OrderExecutionResult] = []
for row in positions.to_dict("records"): for row in positions.to_dict("records"):
pos_type = row["type"] pos_type = row["type"]
side: OrderSide = "SELL" if pos_type == client.mt5.POSITION_TYPE_BUY else "BUY" side: OrderSide = "SELL" if pos_type == client.mt5.POSITION_TYPE_BUY else "BUY"
@@ -766,7 +941,7 @@ def update_sltp_for_open_positions(
stop_loss: float | None = None, stop_loss: float | None = None,
take_profit: float | None = None, take_profit: float | None = None,
dry_run: bool = False, dry_run: bool = False,
) -> list[dict[str, object]]: ) -> list[OrderExecutionResult]:
"""Update SL/TP for matching open positions. """Update SL/TP for matching open positions.
Returns: Returns:
@@ -777,7 +952,7 @@ def update_sltp_for_open_positions(
symbols=symbol, symbols=symbol,
tickets=tickets, tickets=tickets,
) )
results: list[dict[str, object]] = [] results: list[OrderExecutionResult] = []
for row in positions.to_dict("records"): for row in positions.to_dict("records"):
request = { request = {
"action": client.mt5.TRADE_ACTION_SLTP, "action": client.mt5.TRADE_ACTION_SLTP,
@@ -792,21 +967,29 @@ def update_sltp_for_open_positions(
request["tp"] = tp request["tp"] = tp
if dry_run: if dry_run:
response = None response = None
status = "dry_run" status: ExecutionStatus = "dry_run"
else: else:
ensure_symbol_selected(client, str(row["symbol"]))
response = _snapshot_from_value(client.order_send(request), ()) response = _snapshot_from_value(client.order_send(request), ())
status = "executed" status = _order_status_from_retcode(
client.mt5,
response.get("retcode"),
)
results.append( results.append(
{ {
"status": status, "status": status,
"symbol": row["symbol"], "symbol": str(row["symbol"]),
"order_side": "BUY" "order_side": "BUY"
if row["type"] == client.mt5.POSITION_TYPE_BUY if row["type"] == client.mt5.POSITION_TYPE_BUY
else "SELL", else "SELL",
"volume": row["volume"], "volume": float(row["volume"]),
"retcode": None if response is None else response.get("retcode"), "retcode": None
"comment": None if response is None else response.get("comment"), if response is None
"request": request, else _optional_int(response.get("retcode")),
"comment": None
if response is None
else _optional_str(response.get("comment")),
"request": cast("dict[str, object]", request),
"response": response, "response": response,
"dry_run": dry_run, "dry_run": dry_run,
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "mt5cli" name = "mt5cli"
version = "0.8.0" version = "0.8.2"
description = "Generic MT5 data and execution infrastructure for Python applications" description = "Generic MT5 data and execution infrastructure for Python applications"
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}] authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}] maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
+183 -1
View File
@@ -2,43 +2,66 @@
from __future__ import annotations from __future__ import annotations
import sqlite3
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, get_type_hints
from unittest.mock import MagicMock
import pandas as pd import pandas as pd
import pytest import pytest
from pdmt5 import Mt5RuntimeError, Mt5TradingError from pdmt5 import Mt5RuntimeError, Mt5TradingError
from pytest_mock import MockerFixture # noqa: TC002 from pytest_mock import MockerFixture # noqa: TC002
import mt5cli
from mt5cli import ( from mt5cli import (
DEDUP_KEYS, DEDUP_KEYS,
REQUIRED_COLUMNS, REQUIRED_COLUMNS,
STABLE_SDK_EXPORTS,
TIME_COLUMNS, TIME_COLUMNS,
AccountSpec,
DataKind, DataKind,
Dataset, Dataset,
ExecutionStatus,
MarginVolume,
MT5Client, MT5Client,
Mt5CliError, Mt5CliError,
Mt5ConnectionError, Mt5ConnectionError,
Mt5OperationError, Mt5OperationError,
Mt5SchemaError, Mt5SchemaError,
OrderExecutionResult,
OrderLimits,
RateTarget,
build_config, build_config,
build_rate_targets,
calculate_margin_and_volume,
call_with_normalized_errors, call_with_normalized_errors,
detect_format, detect_format,
drop_forming_rate_bar,
ensure_symbol_selected,
ensure_utc, ensure_utc,
export_dataframe, export_dataframe,
export_dataframe_to_sqlite, export_dataframe_to_sqlite,
fetch_latest_closed_rates,
granularity_name, granularity_name,
is_recoverable_mt5_error, is_recoverable_mt5_error,
load_rate_data,
load_rate_series_from_sqlite,
mt5_session, mt5_session,
mt5_trading_session,
normalize_dataframe, normalize_dataframe,
normalize_mt5_exception, normalize_mt5_exception,
normalize_symbol, normalize_symbol,
normalize_symbols, normalize_symbols,
parse_date_range, parse_date_range,
place_market_order,
recent_window, recent_window,
resolve_account_spec,
resolve_account_specs,
resolve_rate_view_name,
schema_columns, schema_columns,
validate_schema, validate_schema,
) )
from mt5cli.history import create_rate_compatibility_views
from mt5cli.retry import retry_with_backoff from mt5cli.retry import retry_with_backoff
from mt5cli.schemas import ensure_utc_columns, normalize_time_columns from mt5cli.schemas import ensure_utc_columns, normalize_time_columns
@@ -510,3 +533,162 @@ def test_storage_export_round_trip_sqlite(tmp_path: Path) -> None:
with __import__("sqlite3").connect(output) as conn: with __import__("sqlite3").connect(output) as conn:
count = conn.execute("SELECT COUNT(*) FROM rates").fetchone()[0] count = conn.execute("SELECT COUNT(*) FROM rates").fetchone()[0]
assert count == 1 assert count == 1
class TestStableSdkContract:
"""Tests for the documented stable downstream SDK contract."""
def test_stable_exports_are_subset_of_all(self) -> None:
"""Every stable export is also listed in the package __all__."""
missing = sorted(STABLE_SDK_EXPORTS - set(mt5cli.__all__))
assert not missing, f"STABLE_SDK_EXPORTS missing from __all__: {missing}"
@pytest.mark.parametrize("name", sorted(STABLE_SDK_EXPORTS))
def test_stable_exports_are_importable_from_package_root(self, name: str) -> None:
"""Stable SDK names resolve through ``from mt5cli import ...``."""
assert hasattr(mt5cli, name), f"{name!r} missing from mt5cli package root"
def test_drop_forming_rate_bar_from_package_root(self) -> None:
"""Closed-bar trimming is available from the stable package surface."""
frame = pd.DataFrame({"time": [1, 2, 3], "close": [1.0, 1.1, 1.2]})
closed = drop_forming_rate_bar(frame)
assert list(closed["close"]) == [1.0, 1.1]
assert len(closed) == 2
def test_fetch_latest_closed_rates_from_package_root(self) -> None:
"""Single-client closed-bar helper drops the forming row."""
client = MagicMock()
client.latest_rates.return_value = pd.DataFrame(
{"time": [1, 2, 3], "close": [1.0, 1.1, 1.2]},
)
result = fetch_latest_closed_rates(
client,
symbol="EURUSD",
granularity="M1",
count=2,
)
client.latest_rates.assert_called_once_with("EURUSD", "M1", 3, start_pos=0)
assert list(result["close"]) == [1.0, 1.1]
def test_resolve_rate_view_name_from_package_root(self, tmp_path: Path) -> None:
"""Rate view resolution is importable and honors require_existing."""
db_path = tmp_path / "rates.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)
assert resolve_rate_view_name(db_path, "EURUSD", "M1") == "rate_EURUSD__1"
missing = tmp_path / "missing.db"
with pytest.raises(ValueError, match="SQLite database not found"):
resolve_rate_view_name(missing, "EURUSD", "M1", require_existing=True)
def test_load_rate_data_from_package_root(self, tmp_path: Path) -> None:
"""SQLite rate loading normalizes timestamps through the stable API."""
db_path = tmp_path / "view.db"
with sqlite3.connect(db_path) as conn:
conn.execute(
'CREATE VIEW "rate_EURUSD__1" AS'
" SELECT '2024-01-01T00:00:00+00:00' AS time, 1.1 AS close",
)
frame = load_rate_data(db_path, "rate_EURUSD__1")
assert frame.index.name == "time"
assert abs(float(frame.iloc[0]["close"]) - 1.1) < 1e-9
def test_load_rate_series_from_sqlite_requires_managed_views(
self,
tmp_path: Path,
) -> None:
"""Multi-series loading fails clearly when managed views are absent."""
db_path = tmp_path / "empty-views.db"
with sqlite3.connect(db_path) as conn:
conn.execute(
"CREATE TABLE rates("
" symbol TEXT, timeframe INTEGER, time TEXT, close REAL)",
)
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=10)
assert targets == [RateTarget(symbol="EURUSD", timeframe=1)]
def test_resolve_account_spec_from_package_root(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Account credential resolution uses generic ${ENV_VAR} placeholders."""
monkeypatch.setenv("APP_MT5_LOGIN", "555")
monkeypatch.setenv("APP_MT5_PASSWORD", "secret")
account = AccountSpec(
symbols=["EURUSD"],
login="${APP_MT5_LOGIN}",
password="${APP_MT5_PASSWORD}",
server="Broker-Demo",
)
resolved = resolve_account_spec(account, timeout=3000)
assert resolved.login == "555"
assert resolved.password == "secret" # noqa: S105
assert resolved.timeout == 3000
batch = resolve_account_specs([account], server="Override")
assert batch[0].server == "Override"
def test_mt5_trading_session_lifecycle_from_package_root(
self,
mocker: MockerFixture,
) -> None:
"""Trading session helper initializes and always shuts down."""
mock_client = MagicMock()
mocker.patch(
"mt5cli.trading.Mt5TradingClient",
return_value=mock_client,
)
with mt5_trading_session(login=12345, server="Broker-Demo") as client:
assert client is mock_client
mock_client.initialize_and_login_mt5.assert_called_once()
mock_client.shutdown.assert_called_once()
def test_trading_order_helpers_importable_from_package_root(self) -> None:
"""Order planning helpers resolve through the stable package surface."""
assert callable(calculate_margin_and_volume)
assert callable(ensure_symbol_selected)
assert callable(place_market_order)
margin_hints = get_type_hints(MarginVolume)
limits_hints = get_type_hints(OrderLimits)
execution_hints = get_type_hints(OrderExecutionResult)
assert margin_hints["buy_volume"] is float
assert limits_hints["stop_loss"] == float | None
assert execution_hints["status"] == ExecutionStatus
def test_mt5_trading_session_shuts_down_on_exception(
self,
mocker: MockerFixture,
) -> None:
"""Trading session helper shuts down even when the body raises."""
mock_client = MagicMock()
mocker.patch(
"mt5cli.trading.Mt5TradingClient",
return_value=mock_client,
)
message = "strategy error"
with (
pytest.raises(RuntimeError, match=message),
mt5_trading_session(login=12345, server="Broker-Demo"),
):
raise RuntimeError(message)
mock_client.shutdown.assert_called_once()
+151
View File
@@ -2100,3 +2100,154 @@ class TestThrottledHistoryUpdater:
assert updater.update(MagicMock(), []) is False assert updater.update(MagicMock(), []) is False
update.assert_not_called() update.assert_not_called()
assert updater.last_update_monotonic is None assert updater.last_update_monotonic is None
def test_default_update_backend_is_update_history(self) -> None:
"""Test the default backend resolves to update_history."""
updater = ThrottledHistoryUpdater(output="history.db")
assert updater.update_backend is update_history
def test_falsy_callable_update_backend_is_preserved(
self,
mocker: MockerFixture,
) -> None:
"""Test only None selects the default backend, not falsy callables."""
class FalsyCallable:
def __init__(self) -> None:
self.calls: list[dict[str, object]] = []
def __bool__(self) -> bool:
return False
def __call__(self, **kwargs: object) -> None:
self.calls.append(kwargs)
falsy_backend = FalsyCallable()
default_backend = mocker.patch("mt5cli.sdk.update_history")
updater = ThrottledHistoryUpdater(
output="history.db",
update_backend=falsy_backend,
)
assert updater.update_backend is falsy_backend
client = MagicMock()
assert updater.update(client, ["EURUSD"]) is True
assert len(falsy_backend.calls) == 1
assert falsy_backend.calls[0]["client"] is client
assert falsy_backend.calls[0]["symbols"] == ["EURUSD"]
default_backend.assert_not_called()
def test_custom_update_backend_receives_expected_kwargs(
self,
mocker: MockerFixture,
) -> None:
"""Test a custom backend receives update_history keyword arguments."""
backend = mocker.Mock()
client = MagicMock()
updater = ThrottledHistoryUpdater(
output="history.db",
datasets={Dataset.rates},
timeframes=["M1", "H1"],
flags="INFO",
lookback_hours=12.0,
with_views=True,
include_account_events=False,
update_backend=backend,
)
updater.update(client, ["EURUSD", "GBPUSD"])
backend.assert_called_once_with(
client=client,
output="history.db",
symbols=["EURUSD", "GBPUSD"],
datasets={Dataset.rates},
timeframes=["M1", "H1"],
flags="INFO",
lookback_hours=12.0,
with_views=True,
include_account_events=False,
)
def test_throttled_calls_do_not_invoke_custom_backend(
self,
mocker: MockerFixture,
) -> None:
"""Test throttled update cycles skip the injected backend."""
backend = mocker.Mock()
monotonic = mocker.patch("mt5cli.sdk.time.monotonic")
monotonic.side_effect = [100.0, 105.0, 200.0, 200.0]
client = MagicMock()
updater = ThrottledHistoryUpdater(
output="history.db",
interval_seconds=60,
update_backend=backend,
)
assert updater.update(client, ["EURUSD"]) is True
assert updater.update(client, ["EURUSD"]) is False
assert updater.update(client, ["EURUSD"]) is True
assert backend.call_count == 2
def test_successful_custom_backend_advances_throttle(
self,
mocker: MockerFixture,
) -> None:
"""Test a successful custom backend updates _last_update_monotonic."""
backend = mocker.Mock()
monotonic = mocker.patch("mt5cli.sdk.time.monotonic", return_value=42.0)
updater = ThrottledHistoryUpdater(
output="history.db",
update_backend=backend,
)
assert updater.update(MagicMock(), ["EURUSD"]) is True
assert updater.last_update_monotonic is monotonic.return_value
monotonic.assert_called_once()
def test_failed_custom_backend_does_not_advance_throttle(
self,
mocker: MockerFixture,
) -> None:
"""Test a failing custom backend leaves _last_update_monotonic unchanged."""
backend = mocker.Mock(side_effect=Mt5RuntimeError("boom"))
updater = ThrottledHistoryUpdater(
output="history.db",
update_backend=backend,
)
with pytest.raises(Mt5RuntimeError, match="boom"):
updater.update(MagicMock(), ["EURUSD"])
assert updater.last_update_monotonic is None
def test_custom_backend_suppresses_recoverable_errors_when_requested(
self,
mocker: MockerFixture,
) -> None:
"""Test suppress_errors swallows recoverable custom backend errors."""
backend = mocker.Mock(side_effect=Mt5RuntimeError("boom"))
updater = ThrottledHistoryUpdater(
output="history.db",
suppress_errors=True,
update_backend=backend,
)
assert updater.update(MagicMock(), ["EURUSD"]) is False
assert updater.last_update_monotonic is None
def test_custom_backend_propagates_errors_when_not_suppressed(
self,
mocker: MockerFixture,
) -> None:
"""Test recoverable custom backend errors propagate by default."""
backend = mocker.Mock(side_effect=Mt5RuntimeError("boom"))
updater = ThrottledHistoryUpdater(
output="history.db",
update_backend=backend,
)
with pytest.raises(Mt5RuntimeError, match="boom"):
updater.update(MagicMock(), ["EURUSD"])
assert updater.last_update_monotonic is None
+664 -4
View File
@@ -8,11 +8,15 @@ from unittest.mock import MagicMock
import pandas as pd import pandas as pd
import pytest import pytest
from numpy import int64 as np_int64
from pdmt5 import Mt5RuntimeError, Mt5TradingClient, Mt5TradingError from pdmt5 import Mt5RuntimeError, Mt5TradingClient, Mt5TradingError
from pytest_mock import MockerFixture # noqa: TC002 from pytest_mock import MockerFixture # noqa: TC002
from mt5cli.sdk import build_config from mt5cli.sdk import build_config
from mt5cli.trading import ( from mt5cli.trading import (
MarginVolume,
OrderExecutionResult,
OrderLimits,
calculate_margin_and_volume, calculate_margin_and_volume,
calculate_new_position_margin_ratio, calculate_new_position_margin_ratio,
calculate_spread_ratio, calculate_spread_ratio,
@@ -21,6 +25,7 @@ from mt5cli.trading import (
create_trading_client, create_trading_client,
detect_position_side, detect_position_side,
determine_order_limits, determine_order_limits,
ensure_symbol_selected,
get_account_snapshot, get_account_snapshot,
get_positions_frame, get_positions_frame,
get_symbol_snapshot, get_symbol_snapshot,
@@ -51,8 +56,8 @@ def _assert_close(actual: object, expected: float) -> None:
assert abs(float(cast("float", actual)) - expected) < 1e-9 assert abs(float(cast("float", actual)) - expected) < 1e-9
def _request_from_result(result: dict[str, object]) -> dict[str, object]: def _request_from_result(result: OrderExecutionResult) -> dict[str, object]: # noqa: FURB118
return cast("dict[str, object]", result["request"]) return result["request"]
class TestDetectPositionSide: class TestDetectPositionSide:
@@ -249,7 +254,7 @@ class TestDetermineOrderLimits:
"""Test long stop loss and take profit are placed below/above entry.""" """Test long stop loss and take profit are placed below/above entry."""
client = MagicMock() client = MagicMock()
client.symbol_info_tick_as_dict.return_value = {"ask": 100.0, "bid": 99.0} client.symbol_info_tick_as_dict.return_value = {"ask": 100.0, "bid": 99.0}
client.symbol_info_as_dict.side_effect = AttributeError("missing") client.symbol_info_as_dict.return_value = {}
result = determine_order_limits( result = determine_order_limits(
client, client,
@@ -269,7 +274,7 @@ class TestDetermineOrderLimits:
"""Test short stop loss and take profit are placed above/below entry.""" """Test short stop loss and take profit are placed above/below entry."""
client = MagicMock() client = MagicMock()
client.symbol_info_tick_as_dict.return_value = {"ask": 100.0, "bid": 99.0} client.symbol_info_tick_as_dict.return_value = {"ask": 100.0, "bid": 99.0}
client.symbol_info_as_dict.side_effect = AttributeError("missing") client.symbol_info_as_dict.return_value = {}
result = determine_order_limits( result = determine_order_limits(
client, client,
@@ -348,6 +353,22 @@ class TestDetermineOrderLimits:
"""Test order limit rounding falls back when symbol metadata is missing.""" """Test order limit rounding falls back when symbol metadata is missing."""
client = MagicMock() client = MagicMock()
client.symbol_info_tick_as_dict.return_value = {"ask": 1.234567891, "bid": 1.0} client.symbol_info_tick_as_dict.return_value = {"ask": 1.234567891, "bid": 1.0}
client.symbol_info_as_dict.return_value = {"digits": "invalid"}
result = determine_order_limits(
client,
"EURUSD",
"long",
stop_loss_limit_ratio=0.01,
take_profit_limit_ratio=0.01,
)
_assert_close(result["stop_loss"], 1.22222221)
def test_uses_default_digits_when_symbol_lookup_raises(self) -> None:
"""Test order limits fall back when symbol metadata lookup fails."""
client = MagicMock()
client.symbol_info_tick_as_dict.return_value = {"ask": 1.234567891, "bid": 1.0}
client.symbol_info_as_dict.side_effect = AttributeError("missing") client.symbol_info_as_dict.side_effect = AttributeError("missing")
result = determine_order_limits( result = determine_order_limits(
@@ -385,6 +406,204 @@ class TestDetermineOrderLimits:
with pytest.raises(Mt5TradingError, match="Tick price is unavailable"): with pytest.raises(Mt5TradingError, match="Tick price is unavailable"):
determine_order_limits(client, "EURUSD", "long") determine_order_limits(client, "EURUSD", "long")
def test_rejects_stop_loss_inside_broker_stop_level(self) -> None:
"""Test stop-loss prices closer than trade_stops_level raise Mt5TradingError."""
client = MagicMock()
client.symbol_info_tick_as_dict.return_value = {"ask": 1.0, "bid": 0.99}
client.symbol_info_as_dict.return_value = {
"digits": 2,
"trade_stops_level": 100,
"point": 0.0001,
}
with pytest.raises(Mt5TradingError, match="Stop loss for 'EURUSD'"):
determine_order_limits(
client,
"EURUSD",
"long",
stop_loss_limit_ratio=0.0001,
)
def test_accepts_stop_loss_exactly_at_minimum_stop_distance(self) -> None:
"""Test protective levels exactly at trade_stops_level distance pass."""
client = MagicMock()
client.symbol_info_tick_as_dict.return_value = {"ask": 1.0, "bid": 0.99}
client.symbol_info_as_dict.return_value = {
"digits": 2,
"trade_stops_level": 100,
"point": 0.0001,
}
result = determine_order_limits(
client,
"EURUSD",
"long",
stop_loss_limit_ratio=0.01,
take_profit_limit_ratio=0.0,
)
_assert_close(result["stop_loss"], 0.99)
def test_allows_protective_levels_beyond_broker_stop_level(self) -> None:
"""Test SL/TP beyond trade_stops_level pass validation."""
client = MagicMock()
client.symbol_info_tick_as_dict.return_value = {"ask": 1.0, "bid": 0.99}
client.symbol_info_as_dict.return_value = {
"digits": 2,
"trade_stops_level": 10,
"point": 0.0001,
}
result = determine_order_limits(
client,
"EURUSD",
"long",
stop_loss_limit_ratio=0.05,
take_profit_limit_ratio=0.05,
)
_assert_close(result["stop_loss"], 0.95)
_assert_close(result["take_profit"], 1.05)
def test_rejects_take_profit_inside_broker_stop_level(self) -> None:
"""Test long take-profit inside trade_stops_level raises Mt5TradingError."""
client = MagicMock()
client.symbol_info_tick_as_dict.return_value = {"ask": 1.0, "bid": 0.99}
client.symbol_info_as_dict.return_value = {
"digits": 2,
"trade_stops_level": 100,
"point": 0.0001,
}
with pytest.raises(Mt5TradingError, match="Take profit for 'EURUSD'"):
determine_order_limits(
client,
"EURUSD",
"long",
take_profit_limit_ratio=0.0001,
)
def test_rejects_short_stop_loss_inside_broker_stop_level(self) -> None:
"""Test short stop-loss inside trade_stops_level raises Mt5TradingError."""
client = MagicMock()
client.symbol_info_tick_as_dict.return_value = {"ask": 1.01, "bid": 1.0}
client.symbol_info_as_dict.return_value = {
"digits": 2,
"trade_stops_level": 100,
"point": 0.0001,
}
with pytest.raises(Mt5TradingError, match="Stop loss for 'EURUSD'"):
determine_order_limits(
client,
"EURUSD",
"short",
stop_loss_limit_ratio=0.0001,
)
def test_rejects_short_take_profit_inside_broker_stop_level(self) -> None:
"""Test short take-profit inside trade_stops_level raises Mt5TradingError."""
client = MagicMock()
client.symbol_info_tick_as_dict.return_value = {"ask": 1.01, "bid": 1.0}
client.symbol_info_as_dict.return_value = {
"digits": 2,
"trade_stops_level": 100,
"point": 0.0001,
}
with pytest.raises(Mt5TradingError, match="Take profit for 'EURUSD'"):
determine_order_limits(
client,
"EURUSD",
"short",
take_profit_limit_ratio=0.0001,
)
def test_allows_short_protective_levels_beyond_broker_stop_level(self) -> None:
"""Test short SL/TP beyond trade_stops_level pass validation."""
client = MagicMock()
client.symbol_info_tick_as_dict.return_value = {"ask": 1.01, "bid": 1.0}
client.symbol_info_as_dict.return_value = {
"digits": 2,
"trade_stops_level": 10,
"point": 0.0001,
}
result = determine_order_limits(
client,
"EURUSD",
"short",
stop_loss_limit_ratio=0.05,
take_profit_limit_ratio=0.05,
)
_assert_close(result["stop_loss"], 1.05)
_assert_close(result["take_profit"], 0.95)
def test_ignores_non_positive_broker_stop_level(self) -> None:
"""Test zero trade_stops_level skips stop-distance validation."""
client = MagicMock()
client.symbol_info_tick_as_dict.return_value = {"ask": 1.0, "bid": 0.99}
client.symbol_info_as_dict.return_value = {
"digits": 2,
"trade_stops_level": 0,
"point": 0.0001,
}
result = determine_order_limits(
client,
"EURUSD",
"long",
stop_loss_limit_ratio=0.0001,
take_profit_limit_ratio=0.0001,
)
assert result["stop_loss"] is not None
assert result["take_profit"] is not None
"""Tests for ensure_symbol_selected."""
def test_skips_selection_when_symbol_is_visible(self) -> None:
"""Test visible symbols do not call symbol_select."""
client = MagicMock()
client.symbol_info_as_dict.return_value = {"visible": True}
ensure_symbol_selected(client, "EURUSD")
client.symbol_select.assert_not_called()
def test_selects_hidden_symbol_before_trading(self) -> None:
"""Test hidden symbols are selected in Market Watch."""
client = MagicMock()
client.symbol_info_as_dict.return_value = {"visible": False}
client.symbol_select.return_value = True
ensure_symbol_selected(client, "EURUSD")
client.symbol_select.assert_called_once_with("EURUSD", enable=True)
def test_raises_when_symbol_selection_fails(self) -> None:
"""Test failed symbol selection raises Mt5TradingError."""
client = MagicMock()
client.symbol_info_as_dict.return_value = {"visible": False}
client.symbol_select.return_value = False
client.last_error.return_value = (1, "not found")
with pytest.raises(Mt5TradingError, match="Failed to select symbol 'EURUSD'"):
ensure_symbol_selected(client, "EURUSD")
def test_raises_when_symbol_select_is_unavailable(self) -> None:
"""Test missing symbol_select raises Mt5TradingError."""
client = MagicMock()
client.symbol_info_as_dict.return_value = {"visible": False}
del client.symbol_select
with pytest.raises(
Mt5TradingError,
match="missing required method: symbol_select",
):
ensure_symbol_selected(client, "EURUSD")
class TestMt5TradingSession: class TestMt5TradingSession:
"""Tests for the mt5_trading_session context manager.""" """Tests for the mt5_trading_session context manager."""
@@ -625,6 +844,23 @@ class TestVolumeAndExecution:
0.0, 0.0,
) )
def test_calculate_volume_by_margin_never_returns_nonzero_below_volume_min(
self,
) -> None:
"""Test non-zero affordable volume is never below volume_min."""
client = _mock_trade_client()
client.symbol_info_as_dict.return_value = {
"volume_min": 0.1,
"volume_max": 1.0,
"volume_step": 0.1,
}
client.symbol_info_tick_as_dict.return_value = {"ask": 100.0, "bid": 99.0}
client.order_calc_margin.return_value = 10.0
volume = calculate_volume_by_margin(client, "EURUSD", 35.0, "BUY")
assert abs(volume) < 1e-9 or volume >= 0.1
def test_calculate_volume_by_margin_returns_zero_without_margin(self) -> None: def test_calculate_volume_by_margin_returns_zero_without_margin(self) -> None:
"""Test non-positive available margin returns zero before MT5 calls.""" """Test non-positive available margin returns zero before MT5 calls."""
client = _mock_trade_client() client = _mock_trade_client()
@@ -943,6 +1179,7 @@ class TestVolumeAndExecution:
def test_place_market_order_dry_run_does_not_send(self) -> None: def test_place_market_order_dry_run_does_not_send(self) -> None:
"""Test dry-run market orders return a request without sending.""" """Test dry-run market orders return a request without sending."""
client = _mock_trade_client() client = _mock_trade_client()
client.symbol_info_as_dict.return_value = {"visible": False}
client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1} client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1}
result = place_market_order( result = place_market_order(
@@ -956,6 +1193,7 @@ class TestVolumeAndExecution:
assert result["status"] == "dry_run" assert result["status"] == "dry_run"
assert _request_from_result(result)["type"] == client.mt5.ORDER_TYPE_BUY assert _request_from_result(result)["type"] == client.mt5.ORDER_TYPE_BUY
client.order_send.assert_not_called() client.order_send.assert_not_called()
client.symbol_select.assert_not_called()
def test_place_market_order_supports_limits(self) -> None: def test_place_market_order_supports_limits(self) -> None:
"""Test optional SL/TP values are included in the request.""" """Test optional SL/TP values are included in the request."""
@@ -1093,6 +1331,173 @@ class TestVolumeAndExecution:
assert result["status"] == "failed" assert result["status"] == "failed"
assert result["retcode"] == 10013 assert result["retcode"] == 10013
def test_place_market_order_marks_failed_numpy_retcode(self) -> None:
"""Test numpy integer retcodes normalize to failed status."""
client = _mock_trade_client()
client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1}
client.order_send.return_value = pd.DataFrame(
[{"retcode": np_int64(10013), "comment": "invalid request"}],
)
result = place_market_order(
client,
symbol="EURUSD",
volume=0.1,
order_side="BUY",
)
assert result["status"] == "failed"
assert result["retcode"] == 10013
def test_place_market_order_rejects_bool_retcode(self) -> None:
"""Test bool retcodes are not treated as integer broker codes."""
client = _mock_trade_client()
client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1}
client.order_send.return_value = pd.DataFrame(
[{"retcode": True, "comment": "weird"}],
)
result = place_market_order(
client,
symbol="EURUSD",
volume=0.1,
order_side="BUY",
)
assert result["retcode"] is None
assert result["status"] == "failed"
def test_place_market_order_marks_failed_string_retcode(self) -> None:
"""Test digit-string failure retcodes normalize to failed status."""
client = _mock_trade_client()
client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1}
client.order_send.return_value = pd.DataFrame(
[{"retcode": "10013", "comment": "invalid request"}],
)
result = place_market_order(
client,
symbol="EURUSD",
volume=0.1,
order_side="BUY",
)
assert result["retcode"] == 10013
assert result["status"] == "failed"
def test_place_market_order_marks_failed_whitespace_string_retcode(self) -> None:
"""Test whitespace-padded digit-string retcodes normalize to failed status."""
client = _mock_trade_client()
client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1}
client.order_send.return_value = pd.DataFrame(
[{"retcode": " 10013 ", "comment": "invalid request"}],
)
result = place_market_order(
client,
symbol="EURUSD",
volume=0.1,
order_side="BUY",
)
assert result["retcode"] == 10013
assert result["status"] == "failed"
@pytest.mark.parametrize("retcode", ["+10013", "-10013"])
def test_place_market_order_marks_signed_string_retcode_as_failed(
self,
retcode: str,
) -> None:
"""Test signed digit-string failure retcodes normalize to failed status."""
client = _mock_trade_client()
client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1}
client.order_send.return_value = pd.DataFrame(
[{"retcode": retcode, "comment": "invalid request"}],
)
result = place_market_order(
client,
symbol="EURUSD",
volume=0.1,
order_side="BUY",
)
expected = 10013 if retcode.startswith("+") else -10013
assert result["retcode"] == expected
assert result["status"] == "failed"
def test_place_market_order_marks_missing_retcode_as_failed(self) -> None:
"""Test live responses without retcode are fail-closed."""
client = _mock_trade_client()
client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1}
client.order_send.return_value = pd.DataFrame(
[{"comment": "missing retcode"}],
)
result = place_market_order(
client,
symbol="EURUSD",
volume=0.1,
order_side="BUY",
)
assert result["retcode"] is None
assert result["status"] == "failed"
def test_place_market_order_marks_malformed_retcode_as_failed(self) -> None:
"""Test malformed non-None retcodes are fail-closed."""
client = _mock_trade_client()
client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1}
client.order_send.return_value = pd.DataFrame(
[{"retcode": "invalid", "comment": "invalid request"}],
)
result = place_market_order(
client,
symbol="EURUSD",
volume=0.1,
order_side="BUY",
)
assert result["retcode"] is None
assert result["status"] == "failed"
def test_place_market_order_marks_empty_string_retcode_as_failed(self) -> None:
"""Test empty string retcodes are fail-closed."""
client = _mock_trade_client()
client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1}
client.order_send.return_value = pd.DataFrame(
[{"retcode": " ", "comment": "invalid request"}],
)
result = place_market_order(
client,
symbol="EURUSD",
volume=0.1,
order_side="BUY",
)
assert result["retcode"] is None
assert result["status"] == "failed"
def test_place_market_order_marks_object_retcode_as_failed(self) -> None:
"""Test unsupported retcode object types are fail-closed."""
client = _mock_trade_client()
client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1}
client.order_send.return_value = pd.DataFrame(
[{"retcode": object(), "comment": "invalid request"}],
)
result = place_market_order(
client,
symbol="EURUSD",
volume=0.1,
order_side="BUY",
)
assert result["retcode"] is None
assert result["status"] == "failed"
def test_close_open_positions_filters_and_dry_runs(self) -> None: def test_close_open_positions_filters_and_dry_runs(self) -> None:
"""Test close helper filters positions and builds opposite orders.""" """Test close helper filters positions and builds opposite orders."""
client = _mock_trade_client() client = _mock_trade_client()
@@ -1142,6 +1547,7 @@ class TestVolumeAndExecution:
def test_update_sltp_filters_and_dry_runs(self) -> None: def test_update_sltp_filters_and_dry_runs(self) -> None:
"""Test SL/TP updates filter positions and do not send in dry-run mode.""" """Test SL/TP updates filter positions and do not send in dry-run mode."""
client = _mock_trade_client() client = _mock_trade_client()
client.symbol_info_as_dict.return_value = {"visible": False}
client.positions_get_as_df.return_value = pd.DataFrame( client.positions_get_as_df.return_value = pd.DataFrame(
[ [
{ {
@@ -1174,6 +1580,34 @@ class TestVolumeAndExecution:
assert len(result) == 1 assert len(result) == 1
_assert_close(_request_from_result(result[0])["sl"], 1.1) _assert_close(_request_from_result(result[0])["sl"], 1.1)
_assert_close(_request_from_result(result[0])["tp"], 1.3) _assert_close(_request_from_result(result[0])["tp"], 1.3)
client.order_send.assert_not_called()
client.symbol_select.assert_not_called()
def test_update_sltp_selects_hidden_symbol_for_live_send(self) -> None:
"""Test live SL/TP updates ensure hidden symbols are selected first."""
client = _mock_trade_client()
client.symbol_info_as_dict.return_value = {"visible": False}
client.symbol_select.return_value = True
client.positions_get_as_df.return_value = pd.DataFrame(
[
{
"ticket": 1,
"symbol": "EURUSD",
"type": 0,
"volume": 0.1,
"sl": 1.0,
"tp": 1.4,
},
],
)
client.order_send.return_value = pd.DataFrame(
[{"retcode": 10009, "comment": "updated"}],
)
update_sltp_for_open_positions(client, tickets=[1], stop_loss=1.1)
client.symbol_select.assert_called_once_with("EURUSD", enable=True)
client.order_send.assert_called_once()
def test_update_sltp_sends_and_normalizes_response(self) -> None: def test_update_sltp_sends_and_normalizes_response(self) -> None:
"""Test live SL/TP updates send requests and normalize responses.""" """Test live SL/TP updates send requests and normalize responses."""
@@ -1258,6 +1692,232 @@ class TestVolumeAndExecution:
mock_client.shutdown.assert_called_once() mock_client.shutdown.assert_called_once()
def test_place_market_order_selects_hidden_symbol_for_live_send(self) -> None:
"""Test live market orders select hidden symbols before reading ticks."""
client = _mock_trade_client()
client.symbol_info_as_dict.return_value = {"visible": False}
client.symbol_select.return_value = True
client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1}
client.order_send.return_value = pd.DataFrame(
[{"retcode": 10009, "comment": "done"}],
)
call_order: list[str] = []
def _record_select(*_args: object, **_kwargs: object) -> bool:
call_order.append("symbol_select")
return True
def _record_tick(*_args: object, **_kwargs: object) -> dict[str, float]:
call_order.append("tick")
return {"ask": 1.2, "bid": 1.1}
client.symbol_select.side_effect = _record_select
client.symbol_info_tick_as_dict.side_effect = _record_tick
place_market_order(
client,
symbol="EURUSD",
volume=0.1,
order_side="BUY",
)
client.symbol_select.assert_called_once_with("EURUSD", enable=True)
client.symbol_info_tick_as_dict.assert_called_once()
client.order_send.assert_called_once()
assert call_order == ["symbol_select", "tick"]
def test_place_market_order_reads_ticks_after_hidden_symbol_selection(self) -> None:
"""Test live orders can read ticks only after hidden symbols are selected."""
client = _mock_trade_client()
selected = {"value": False}
def _symbol_info_side_effect(**_kwargs: object) -> dict[str, bool]:
return {"visible": selected["value"]}
def _select_symbol(*_args: object, **_kwargs: object) -> bool:
selected["value"] = True
return True
def _tick_side_effect(**_kwargs: object) -> dict[str, float | None]:
if not selected["value"]:
return {"ask": None, "bid": None}
return {"ask": 1.2, "bid": 1.1}
client.symbol_info_as_dict.side_effect = _symbol_info_side_effect
client.symbol_select.side_effect = _select_symbol
client.symbol_info_tick_as_dict.side_effect = _tick_side_effect
client.order_send.return_value = pd.DataFrame(
[{"retcode": 10009, "comment": "done"}],
)
result = place_market_order(
client,
symbol="EURUSD",
volume=0.1,
order_side="BUY",
)
assert result["status"] == "executed"
client.symbol_select.assert_called_once_with("EURUSD", enable=True)
client.order_send.assert_called_once()
def test_update_sltp_marks_failed_retcode(self) -> None:
"""Test SL/TP updates normalize failed broker retcodes."""
client = _mock_trade_client()
client.symbol_info_as_dict.return_value = {"visible": True}
client.positions_get_as_df.return_value = pd.DataFrame(
[
{
"ticket": 1,
"symbol": "EURUSD",
"type": 0,
"volume": 0.1,
"sl": 1.0,
"tp": 1.4,
},
],
)
client.order_send.return_value = pd.DataFrame(
[{"retcode": 10013, "comment": "invalid stops"}],
)
result = update_sltp_for_open_positions(client, tickets=[1], stop_loss=1.1)
assert result[0]["status"] == "failed"
assert result[0]["retcode"] == 10013
def test_update_sltp_marks_failed_numpy_retcode(self) -> None:
"""Test numpy integer retcodes normalize to failed SL/TP status."""
client = _mock_trade_client()
client.symbol_info_as_dict.return_value = {"visible": True}
client.positions_get_as_df.return_value = pd.DataFrame(
[
{
"ticket": 1,
"symbol": "EURUSD",
"type": 0,
"volume": 0.1,
"sl": 1.0,
"tp": 1.4,
},
],
)
client.order_send.return_value = pd.DataFrame(
[{"retcode": np_int64(10013), "comment": "invalid stops"}],
)
result = update_sltp_for_open_positions(client, tickets=[1], stop_loss=1.1)
assert result[0]["status"] == "failed"
assert result[0]["retcode"] == 10013
def test_update_sltp_marks_failed_string_retcode(self) -> None:
"""Test digit-string failure retcodes normalize to failed SL/TP status."""
client = _mock_trade_client()
client.symbol_info_as_dict.return_value = {"visible": True}
client.positions_get_as_df.return_value = pd.DataFrame(
[
{
"ticket": 1,
"symbol": "EURUSD",
"type": 0,
"volume": 0.1,
"sl": 1.0,
"tp": 1.4,
},
],
)
client.order_send.return_value = pd.DataFrame(
[{"retcode": "10013", "comment": "invalid stops"}],
)
result = update_sltp_for_open_positions(client, tickets=[1], stop_loss=1.1)
assert result[0]["retcode"] == 10013
assert result[0]["status"] == "failed"
def test_update_sltp_marks_malformed_retcode_as_failed(self) -> None:
"""Test malformed non-None SL/TP retcodes are fail-closed."""
client = _mock_trade_client()
client.symbol_info_as_dict.return_value = {"visible": True}
client.positions_get_as_df.return_value = pd.DataFrame(
[
{
"ticket": 1,
"symbol": "EURUSD",
"type": 0,
"volume": 0.1,
"sl": 1.0,
"tp": 1.4,
},
],
)
client.order_send.return_value = pd.DataFrame(
[{"retcode": "invalid", "comment": "invalid stops"}],
)
result = update_sltp_for_open_positions(client, tickets=[1], stop_loss=1.1)
assert result[0]["retcode"] is None
assert result[0]["status"] == "failed"
def test_update_sltp_marks_missing_retcode_as_failed(self) -> None:
"""Test live SL/TP responses without retcode are fail-closed."""
client = _mock_trade_client()
client.symbol_info_as_dict.return_value = {"visible": True}
client.positions_get_as_df.return_value = pd.DataFrame(
[
{
"ticket": 1,
"symbol": "EURUSD",
"type": 0,
"volume": 0.1,
"sl": 1.0,
"tp": 1.4,
},
],
)
client.order_send.return_value = pd.DataFrame(
[{"comment": "missing retcode"}],
)
result = update_sltp_for_open_positions(client, tickets=[1], stop_loss=1.1)
assert result[0]["retcode"] is None
assert result[0]["status"] == "failed"
def test_trading_typed_dict_exports(self) -> None:
"""Test order-planning TypedDict contracts are importable."""
margin: MarginVolume = {
"margin_free": 1.0,
"available_margin": 1.0,
"trade_margin": 0.5,
"buy_volume": 0.1,
"sell_volume": 0.1,
"volume_min": 0.1,
"volume_max": 1.0,
"volume_step": 0.1,
}
limits: OrderLimits = {
"entry": 1.0,
"stop_loss": 0.9,
"take_profit": 1.1,
}
execution: OrderExecutionResult = {
"status": "dry_run",
"symbol": "EURUSD",
"order_side": "BUY",
"volume": 0.1,
"retcode": None,
"comment": None,
"request": {"action": 20},
"response": None,
"dry_run": True,
}
_assert_close(margin["buy_volume"], 0.1)
_assert_close(limits["entry"], 1.0)
assert execution["status"] == "dry_run"
def test_shuts_down_when_body_raises(self, mocker: MockerFixture) -> None: def test_shuts_down_when_body_raises(self, mocker: MockerFixture) -> None:
"""Test shutdown is called when the context body raises.""" """Test shutdown is called when the context body raises."""
mock_client = MagicMock() mock_client = MagicMock()
Generated
+1 -1
View File
@@ -487,7 +487,7 @@ wheels = [
[[package]] [[package]]
name = "mt5cli" name = "mt5cli"
version = "0.8.0" version = "0.8.2"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },