Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7de3ce0b7a | |||
| 897f7f0a0d |
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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__`.
|
||||||
@@ -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
@@ -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()` |
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user