Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a126ced30 | |||
| 80c3f3f65e | |||
| 43f632bc40 | |||
| 8028263b24 | |||
| 63a8d67419 | |||
| 93565681e1 | |||
| f435544f07 | |||
| 668f38d8aa | |||
| 8da5ee9242 | |||
| 9dbb46fbb1 |
@@ -29,6 +29,12 @@ Built on top of [pdmt5](https://github.com/dceoy/pdmt5), a pandas-based data han
|
|||||||
pip install -U mt5cli MetaTrader5
|
pip install -U mt5cli MetaTrader5
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Parquet export is not included by default. To enable it, install the `parquet` extra:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -U "mt5cli[parquet]" MetaTrader5
|
||||||
|
```
|
||||||
|
|
||||||
## Python API (downstream packages)
|
## Python API (downstream packages)
|
||||||
|
|
||||||
Import `MT5Client` for generic MT5 data access, schema normalization, and optional order primitives.
|
Import `MT5Client` for generic MT5 data access, schema normalization, and optional order primitives.
|
||||||
@@ -38,16 +44,14 @@ from datetime import UTC, datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from mt5cli import (
|
from mt5cli import (
|
||||||
DataKind,
|
|
||||||
Dataset,
|
|
||||||
MT5Client,
|
MT5Client,
|
||||||
build_config,
|
build_config,
|
||||||
collect_history,
|
collect_history,
|
||||||
export_dataframe,
|
|
||||||
mt5_session,
|
mt5_session,
|
||||||
normalize_dataframe,
|
|
||||||
update_history_with_config,
|
update_history_with_config,
|
||||||
)
|
)
|
||||||
|
from mt5cli.schemas import DataKind, normalize_dataframe
|
||||||
|
from mt5cli.utils import Dataset, export_dataframe
|
||||||
|
|
||||||
# Persistent session for multiple calls
|
# Persistent session for multiple calls
|
||||||
with mt5_session(build_config(login=12345, server="Broker-Demo")) as client:
|
with mt5_session(build_config(login=12345, server="Broker-Demo")) as client:
|
||||||
@@ -83,7 +87,7 @@ update_history_with_config(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
Schema contracts live in `mt5cli.schemas` (`DataKind`, `validate_schema`, `normalize_dataframe`). Storage helpers are re-exported from `mt5cli.storage` and the package root.
|
Schema contracts live in `mt5cli.schemas` (`DataKind`, `validate_schema`, `normalize_dataframe`). Export and storage helpers are in `mt5cli.utils` (`Dataset`, `export_dataframe`) and `mt5cli.history`.
|
||||||
|
|
||||||
`MT5Client.order_send()` is a live execution primitive: it can place real trades on the connected account. mt5cli does not implement strategy logic, signal generation, backtesting, or optimization — downstream applications must gate live execution explicitly.
|
`MT5Client.order_send()` is a live execution primitive: it can place real trades on the connected account. mt5cli does not implement strategy logic, signal generation, backtesting, or optimization — downstream applications must gate live execution explicitly.
|
||||||
|
|
||||||
@@ -153,34 +157,34 @@ python -m mt5cli -o account.csv account-info
|
|||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| ---------------------- | ------------------------------------------------------------------------------------------------------------ |
|
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `rates-from` | Export rates from a start date |
|
| `rates-from` | Export rates from a start date |
|
||||||
| `rates-from-pos` | Export rates from a start position |
|
| `rates-from-pos` | Export rates from a start position |
|
||||||
| `latest-rates` | Export latest rates from a start position |
|
| `latest-rates` | Export latest rates from a start position |
|
||||||
| `rates-range` | Export rates for a date range |
|
| `rates-range` | Export rates for a date range |
|
||||||
| `ticks-from` | Export ticks from a start date |
|
| `ticks-from` | Export ticks from a start date |
|
||||||
| `ticks-range` | Export ticks for a date range |
|
| `ticks-range` | Export ticks for a date range |
|
||||||
| `ticks-recent` | Export ticks from a recent trailing window |
|
| `ticks-recent` | Export ticks from a recent trailing window |
|
||||||
| `account-info` | Export account information |
|
| `account-info` | Export account information |
|
||||||
| `terminal-info` | Export terminal information |
|
| `terminal-info` | Export terminal information |
|
||||||
| `version` | Export MetaTrader 5 version information |
|
| `version` | Export MetaTrader 5 version information |
|
||||||
| `last-error` | Export the last error information |
|
| `last-error` | Export the last error information |
|
||||||
| `symbols` | Export symbol list |
|
| `symbols` | Export symbol list |
|
||||||
| `symbol-info` | Export symbol details |
|
| `symbol-info` | Export symbol details |
|
||||||
| `symbol-info-tick` | Export the last tick for a symbol |
|
| `symbol-info-tick` | Export the last tick for a symbol |
|
||||||
| `minimum-margins` | Export minimum-volume buy and sell margin requirements |
|
| `minimum-margins` | Export minimum-volume buy and sell margin requirements |
|
||||||
| `market-book` | Export market depth (order book) |
|
| `market-book` | Export market depth (order book) |
|
||||||
| `orders` | Export active orders |
|
| `orders` | Export active orders |
|
||||||
| `positions` | Export open positions |
|
| `positions` | Export open positions |
|
||||||
| `history-orders` | Export historical orders |
|
| `history-orders` | Export historical orders |
|
||||||
| `history-deals` | Export historical deals |
|
| `history-deals` | Export historical deals |
|
||||||
| `recent-history-deals` | Export historical deals from a recent trailing window |
|
| `recent-history-deals` | Export historical deals from a recent trailing window |
|
||||||
| `mt5-summary` | Export terminal/account status summary |
|
| `mt5-summary` | Export terminal/account status summary |
|
||||||
| `order-check` | Check funds sufficiency for a trade request |
|
| `order-check` | Check funds sufficiency for a trade request |
|
||||||
| `order-send` | Send a raw trade request to the trade server (`--yes` required; expert path) |
|
| `order-send` | Send a raw trade request to the trade server (`--yes` required; expert path) |
|
||||||
| `close-positions` | Close open positions by `--symbol` or `--ticket` (`--yes` required for live; `--dry-run` available) |
|
| `close-positions` | Close open positions by `--symbol` or `--ticket` (`--yes` required for live; `--dry-run` available) |
|
||||||
| `collect-history` | Bundle rates, ticks, history-orders, and history-deals for one or more symbols into a single SQLite database |
|
| `collect-history` | Collect rates, history-orders, and history-deals for one or more symbols into a single SQLite database (ticks opt-in via `--dataset ticks`) |
|
||||||
|
|
||||||
Use `order-check` to validate a request payload before running `order-send --yes`.
|
Use `order-check` to validate a request payload before running `order-send --yes`.
|
||||||
`close-positions` is the safer high-level alternative that builds correct close
|
`close-positions` is the safer high-level alternative that builds correct close
|
||||||
@@ -188,7 +192,7 @@ requests automatically. At least one `--symbol` or `--ticket` must be provided.
|
|||||||
|
|
||||||
### `collect-history`
|
### `collect-history`
|
||||||
|
|
||||||
Collect several historical datasets per symbol into one SQLite database in a single MT5 session. Pick datasets with repeatable `--dataset` (default: all four), choose conflict behavior with `--if-exists append|replace|fail` (default: `fail`), and optionally derive `cash_events` / `positions_reconstructed` views from `history_deals` via `--with-views`.
|
Collect several historical datasets per symbol into one SQLite database in a single MT5 session. Pick datasets with repeatable `--dataset` (default: `rates`, `history-orders`, `history-deals`; add `--dataset ticks` when tick-level history is required — tick data can grow the SQLite database quickly), choose conflict behavior with `--if-exists append|replace|fail` (default: `fail`), and optionally derive `cash_events` / `positions_reconstructed` views from `history_deals` via `--with-views`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mt5cli -o history.db collect-history \
|
mt5cli -o history.db collect-history \
|
||||||
@@ -206,7 +210,8 @@ For automated pipelines, use the importable incremental API instead of re-fetchi
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
from pdmt5 import Mt5Config, Mt5DataClient
|
from pdmt5 import Mt5Config, Mt5DataClient
|
||||||
from mt5cli import Dataset, update_history, update_history_with_config
|
from mt5cli import update_history, update_history_with_config
|
||||||
|
from mt5cli.utils import Dataset
|
||||||
|
|
||||||
# Reuse an already-connected pdmt5 client (does not open/close MT5)
|
# Reuse an already-connected pdmt5 client (does not open/close MT5)
|
||||||
client = Mt5DataClient(config=Mt5Config(login=12345))
|
client = Mt5DataClient(config=Mt5Config(login=12345))
|
||||||
@@ -241,7 +246,7 @@ update_history_with_config(
|
|||||||
- **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.
|
||||||
- **Multi-account latest rates**: use `collect_latest_rates_for_accounts()` with `AccountSpec` to read the latest bars for several account groups, merged into a `(symbol, integer timeframe)` mapping. For long-running pollers, `collect_latest_rates_for_accounts_with_retries()` adds bounded exponential backoff that retries only `pdmt5.Mt5TradingError` / `pdmt5.Mt5RuntimeError` and re-raises once `retry_count` is exhausted.
|
- **Multi-account latest rates**: use `collect_latest_rates_for_accounts()` with `AccountSpec` to read the latest bars for several account groups, merged into a `(symbol, integer timeframe)` mapping. For long-running pollers, `collect_latest_rates_for_accounts_with_retries()` adds bounded exponential backoff that retries only recoverable MT5 errors and re-raises once `retry_count` is exhausted.
|
||||||
- **Latest closed bars**: use `collect_latest_closed_rates_for_accounts()` when downstream logic must exclude the still-forming current bar. It fetches `count + 1` bars at `start_pos=0`, drops the last row with `drop_forming_rate_bar()`, and validates each series is non-empty. `collect_latest_closed_rates_by_granularity()` returns the same data keyed by `(symbol, granularity_name)` such as `("EURUSD", "M1")`.
|
- **Latest closed bars**: use `collect_latest_closed_rates_for_accounts()` when downstream logic must exclude the still-forming current bar. It fetches `count + 1` bars at `start_pos=0`, drops the last row with `drop_forming_rate_bar()`, and validates each series is non-empty. `collect_latest_closed_rates_by_granularity()` returns the same data keyed by `(symbol, granularity_name)` such as `("EURUSD", "M1")`.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -258,7 +263,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. For config dicts or nested structures loaded from YAML/TOML, use `substitute_mapping_values(data, keys={"login", "password"})` to expand placeholders only for caller-specified keys — key names are never hard-coded in mt5cli.
|
- **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. For config dicts or nested structures loaded from YAML/TOML, use `substitute_mapping_values(data, keys={"login", "password"})` to expand placeholders only for caller-specified keys — key names are never hard-coded in mt5cli.
|
||||||
- **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`.
|
- **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. Keep read-only collection on `mt5_session()` / `MT5Client`.
|
- **Trading session helpers**: use `mt5_trading_session()` for a trading-capable client 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. Keep read-only collection on `mt5_session()` / `MT5Client`.
|
||||||
- **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.
|
||||||
- **SQLite export helpers**: use `export_dataframe_to_sqlite()` for append mode, optional index export, and post-write deduplication by key columns.
|
- **SQLite export helpers**: use `export_dataframe_to_sqlite()` for append mode, optional index export, and post-write deduplication by key columns.
|
||||||
|
|||||||
+5
-3
@@ -182,12 +182,14 @@ targets without hard-coding view names:
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from mt5cli import (
|
from mt5cli import (
|
||||||
load_rate_data,
|
|
||||||
load_rate_series_by_granularity,
|
load_rate_series_by_granularity,
|
||||||
load_rate_series_from_sqlite,
|
load_rate_series_from_sqlite,
|
||||||
resolve_rate_table_name,
|
|
||||||
)
|
)
|
||||||
from mt5cli.history import resolve_rate_view_name
|
from mt5cli.history import (
|
||||||
|
load_rate_data,
|
||||||
|
resolve_rate_table_name,
|
||||||
|
resolve_rate_view_name,
|
||||||
|
)
|
||||||
|
|
||||||
view = resolve_rate_view_name(Path("history.db"), "EURUSD", "M1", require_existing=True)
|
view = resolve_rate_view_name(Path("history.db"), "EURUSD", "M1", require_existing=True)
|
||||||
rates = load_rate_data(Path("history.db"), view, count=1000)
|
rates = load_rate_data(Path("history.db"), view, count=1000)
|
||||||
|
|||||||
+3
-5
@@ -13,7 +13,6 @@ responsibilities.
|
|||||||
| [Public API Contract](public-contract.md) | Stable downstream SDK exports, CLI boundary, and out-of-scope items |
|
| [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 |
|
|
||||||
| [Converters](converters.md) | Symbol, timeframe, timezone, and date-range utilities |
|
| [Converters](converters.md) | Symbol, timeframe, timezone, and date-range utilities |
|
||||||
| [Exceptions](exceptions.md) | Stable mt5cli exception types and MT5 error normalization |
|
| [Exceptions](exceptions.md) | Stable mt5cli exception types and MT5 error normalization |
|
||||||
| [SDK](sdk.md) | Module-level fetch helpers, multi-account collectors, incremental history |
|
| [SDK](sdk.md) | Module-level fetch helpers, multi-account collectors, incremental history |
|
||||||
@@ -30,15 +29,14 @@ flowchart TD
|
|||||||
CLI["mt5cli CLI"] --> Client
|
CLI["mt5cli CLI"] --> Client
|
||||||
Client --> SDK["sdk / pdmt5"]
|
Client --> SDK["sdk / pdmt5"]
|
||||||
Client --> Schemas["schemas"]
|
Client --> Schemas["schemas"]
|
||||||
Storage["storage"] --> History["history SQLite"]
|
History["history SQLite"] --> Utils["utils export"]
|
||||||
Storage --> Utils["utils export"]
|
|
||||||
SDK --> PDMT5["pdmt5.Mt5DataClient"]
|
SDK --> PDMT5["pdmt5.Mt5DataClient"]
|
||||||
```
|
```
|
||||||
|
|
||||||
Downstream packages should depend on the package root exports documented in the
|
Downstream packages should depend on the package root exports documented in the
|
||||||
[Public API Contract](public-contract.md) (`MT5Client`,
|
[Public API Contract](public-contract.md) (`MT5Client`,
|
||||||
`DataKind`, `normalize_dataframe`, `collect_history`, `load_rate_data`,
|
`collect_history`, `load_rate_series_from_sqlite`, etc.) rather than private
|
||||||
`resolve_rate_view_name`, etc.) rather than private modules.
|
modules. Lower-level helpers are accessible directly from their owning 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.
|
||||||
|
|
||||||
|
|||||||
+56
-91
@@ -1,29 +1,41 @@
|
|||||||
# Public API Contract
|
# Public API Contract
|
||||||
|
|
||||||
mt5cli is the generic MT5 data and execution infrastructure layer for downstream
|
mt5cli is the canonical operational trading SDK and CLI/batch layer over pdmt5.
|
||||||
Python applications. The intended dependency direction is:
|
The intended dependency direction is:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
downstream app -> mt5cli -> pdmt5 -> MetaTrader 5
|
downstream app -> mt5cli -> pdmt5 -> MetaTrader 5
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Responsibility boundary
|
||||||
|
|
||||||
|
| Layer | Owns |
|
||||||
|
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| **pdmt5** | MT5 core wrapper; DataFrame/dict conversion; canonical MT5 constants and parsers; direct low-level order primitives |
|
||||||
|
| **mt5cli** | CLI/batch workflows; SQLite history collection; normalized datasets; closed-bar helpers; small downstream operational SDK; generic broker-facing margin/volume/order orchestration |
|
||||||
|
| **downstream** | Strategy logic; signals; risk policy; backtesting; optimization; YAML/application semantics |
|
||||||
|
|
||||||
|
Downstream code should import raw pdmt5 types and constants (such as
|
||||||
|
`Mt5Config`, `Mt5RuntimeError`, `TIMEFRAME_MAP`, `COPY_TICKS_MAP`) directly
|
||||||
|
from `pdmt5` when needed. mt5cli does not serve as a pass-through compatibility
|
||||||
|
namespace for pdmt5. mt5cli's trading helpers type their client parameter against
|
||||||
|
an internal protocol backed by `pdmt5.Mt5DataClient`; `Mt5TradingClient` is no
|
||||||
|
longer required. `Mt5TradingError` is conditionally imported where still present
|
||||||
|
in pdmt5, but mt5cli raises `Mt5OperationError` for all trading-related failures.
|
||||||
|
|
||||||
|
Note: the former `mt5cli` re-export `TICK_FLAG_MAP` corresponds to `COPY_TICKS_MAP`
|
||||||
|
in pdmt5 — the name changed, it was not simply moved.
|
||||||
|
|
||||||
Downstream packages should import from the package root (`from mt5cli import
|
Downstream packages should import from the package root (`from mt5cli import
|
||||||
...`) and use the public tier sets in `mt5cli.contract` to distinguish API
|
...`). The contract set `STABLE_SDK_EXPORTS` in `mt5cli.contract` enumerates
|
||||||
stability. CLI commands mirror the same behavior but are not importable Python
|
every package-root symbol. Lower-level helpers (schema utilities, export
|
||||||
APIs.
|
functions, parser helpers, low-level MT5 wrappers) are available directly from
|
||||||
|
their owning modules (`mt5cli.schemas`, `mt5cli.utils`, `mt5cli.converters`,
|
||||||
## Public API tiers
|
`mt5cli.sdk`, etc.) and are not part of the root SDK surface.
|
||||||
|
|
||||||
mt5cli classifies package-root imports by intended downstream use:
|
|
||||||
|
|
||||||
| Tier | Contract set | Meaning |
|
|
||||||
| ---------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| Stable core | `STABLE_SDK_EXPORTS` | Preferred SDK surface for downstream MT5 infrastructure adapters. Changes require a deliberate compatibility path. |
|
|
||||||
| Secondary public | `SECONDARY_PUBLIC_EXPORTS` | Public helpers for CLI/export/schema integrations and lower-level MT5 wrappers. Importable, but less central to the downstream trading SDK. |
|
|
||||||
|
|
||||||
## Stable downstream SDK API
|
## Stable downstream SDK API
|
||||||
|
|
||||||
These names are exported from `mt5cli` and covered by the contract in
|
These names are exported from `mt5cli` and enumerated in
|
||||||
`mt5cli.STABLE_SDK_EXPORTS` (defined in `mt5cli.contract`).
|
`mt5cli.STABLE_SDK_EXPORTS` (defined in `mt5cli.contract`).
|
||||||
|
|
||||||
### Session lifecycle and configuration
|
### Session lifecycle and configuration
|
||||||
@@ -33,23 +45,9 @@ These names are exported from `mt5cli` and covered by the contract in
|
|||||||
| `MT5Client` | Read-only data client with optional `order_check` / `order_send` |
|
| `MT5Client` | Read-only data client with optional `order_check` / `order_send` |
|
||||||
| `build_config` | Build `pdmt5.Mt5Config` from connection fields; `login` accepts `int \| str \| None` — numeric strings are coerced to `int`, blank strings are treated as unset, and `${ENV_VAR}` / `$ENV_NAME` placeholders in string parameters are expanded when `allow_whole_dollar_env=True` |
|
| `build_config` | Build `pdmt5.Mt5Config` from connection fields; `login` accepts `int \| str \| None` — numeric strings are coerced to `int`, blank strings are treated as unset, and `${ENV_VAR}` / `$ENV_NAME` placeholders in string parameters are expanded when `allow_whole_dollar_env=True` |
|
||||||
| `mt5_session` | Context manager: initialize, login, yield client, shutdown |
|
| `mt5_session` | Context manager: initialize, login, yield client, shutdown |
|
||||||
| `create_trading_client`, `mt5_trading_session` | Trading-capable `pdmt5.Mt5TradingClient` lifecycle |
|
| `create_trading_client`, `mt5_trading_session` | Trading-capable MT5 client lifecycle; returns a client supporting order execution and account management |
|
||||||
| `AccountSpec` | Generic account group: symbols plus optional credentials |
|
| `AccountSpec` | Generic account group: symbols plus optional credentials |
|
||||||
| `resolve_account_spec`, `resolve_account_specs` | Merge overrides and expand `${ENV_VAR}` placeholders; opt-in `allow_whole_dollar_env` for bare `$NAME` |
|
| `resolve_account_spec`, `resolve_account_specs` | Merge overrides and expand `${ENV_VAR}` placeholders; opt-in `allow_whole_dollar_env` for bare `$NAME` |
|
||||||
| `substitute_env_placeholders` | Replace `${NAME}` substrings from the environment; opt-in `allow_whole_dollar_env` for whole-value `$NAME` |
|
|
||||||
| `substitute_mapping_values` | Recursively traverse a dict/list/scalar structure and substitute `${ENV_VAR}` placeholders for caller-selected mapping keys only; optionally normalise blank strings to `None` for a separate caller-selected key set; does not hard-code any application-specific key names |
|
|
||||||
|
|
||||||
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`.
|
|
||||||
|
|
||||||
Pass `allow_whole_dollar_env=True` to `substitute_env_placeholders()`,
|
|
||||||
`substitute_mapping_values()`, `resolve_account_spec()`, `resolve_account_specs()`,
|
|
||||||
and `build_config()` to additionally expand strings whose entire value is a bare
|
|
||||||
`$ENV_NAME` identifier.
|
|
||||||
Partial strings such as `"plan$pass"`, `"abc$ENV"`, or `"$ENV-suffix"` are
|
|
||||||
**never** expanded — only an exact `$IDENTIFIER` whole-string match qualifies.
|
|
||||||
Default is `False` to preserve backward compatibility.
|
|
||||||
|
|
||||||
### Closed-bar rate helpers
|
### Closed-bar rate helpers
|
||||||
|
|
||||||
@@ -61,7 +59,7 @@ timestamp normalization in downstream apps.
|
|||||||
| ------------------------------------------------ | ------------------------------------------------------------------------------- |
|
| ------------------------------------------------ | ------------------------------------------------------------------------------- |
|
||||||
| `drop_forming_rate_bar` | Remove the last row from chronologically ordered rate data |
|
| `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 |
|
| `fetch_latest_closed_rates` | Single connected client: fetch `count + 1`, drop forming bar |
|
||||||
| `fetch_latest_closed_rates_for_trading_client` | Closed bars from an active `Mt5TradingClient` session; returns RangeIndex |
|
| `fetch_latest_closed_rates_for_trading_client` | Closed bars from an active trading client session; returns RangeIndex |
|
||||||
| `fetch_latest_closed_rates_indexed` | Same as above but returns a UTC `DatetimeIndex` named `"time"` (no time column) |
|
| `fetch_latest_closed_rates_indexed` | Same as above but returns a UTC `DatetimeIndex` named `"time"` (no time column) |
|
||||||
| `collect_latest_closed_rates_for_accounts` | Multi-account closed bars with optional retry wrapper |
|
| `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_closed_rates_by_granularity` | Same data keyed by `(symbol, granularity_name)` |
|
||||||
@@ -69,20 +67,13 @@ timestamp normalization in downstream apps.
|
|||||||
|
|
||||||
### SQLite history collection and rate loading
|
### SQLite history collection and rate loading
|
||||||
|
|
||||||
| Symbol | Role |
|
| Symbol | Role |
|
||||||
| ----------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
||||||
| `collect_history` | One-shot date-range export into SQLite |
|
| `collect_history` | One-shot date-range export into SQLite |
|
||||||
| `update_history`, `update_history_with_config` | Incremental append from `MAX(time)` cursors |
|
| `update_history`, `update_history_with_config` | Incremental append from `MAX(time)` cursors |
|
||||||
| `ThrottledHistoryUpdater` | Minimum interval between successful incremental updates; optional `update_backend` injection |
|
| `ThrottledHistoryUpdater` | Minimum interval between successful incremental updates; optional `update_backend` injection |
|
||||||
| `resolve_history_datasets`, `resolve_history_timeframes`, `resolve_history_tick_flags` | History pipeline configuration |
|
| `RateTarget`, `build_rate_targets` | Neutral `(symbol, timeframe)` series descriptors |
|
||||||
| `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 |
|
| `load_rate_series_from_sqlite`, `load_rate_series_by_granularity` | Load one or many series; fail clearly when managed views are missing |
|
||||||
| `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
|
See [History Collection (SQLite)](history.md) for schema, view naming, and ER
|
||||||
diagrams.
|
diagrams.
|
||||||
@@ -123,7 +114,7 @@ strategy policy.
|
|||||||
`MT5Client.order_send()` and CLI `order-send --yes` are live execution paths.
|
`MT5Client.order_send()` and CLI `order-send --yes` are live execution paths.
|
||||||
|
|
||||||
Order helpers validate broker stop-level distance in `determine_order_limits()` and
|
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
|
raise `Mt5OperationError` when computed SL/TP prices are too close to the entry
|
||||||
quote. Validation uses `trade_stops_level * point` from the current quote and
|
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
|
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
|
after price movement and does not inspect `trade_freeze_level`. Live
|
||||||
@@ -133,51 +124,25 @@ sending requests. Failed, malformed, or unknown broker retcodes are fail-closed
|
|||||||
and returned as `status="failed"` with normalized `request` / `response` details;
|
and returned as `status="failed"` with normalized `request` / `response` details;
|
||||||
`dry_run=True` never calls `ensure_symbol_selected()` or `order_send()`.
|
`dry_run=True` never calls `ensure_symbol_selected()` or `order_send()`.
|
||||||
|
|
||||||
### Errors and MT5 type re-exports
|
### Errors
|
||||||
|
|
||||||
| Symbol | Role |
|
| Symbol | Role |
|
||||||
| ------------------------------------------------------------------------------------ | ----------------------------------------------- |
|
| -------------------------------------------------------------------------- | ----------------------------- |
|
||||||
| `Mt5CliError`, `Mt5ConnectionError`, `Mt5OperationError`, `Mt5SchemaError` | Stable mt5cli exception types |
|
| `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 |
|
|
||||||
|
|
||||||
## Secondary public exports
|
## Module-scoped helpers
|
||||||
|
|
||||||
These names remain importable from `mt5cli` and are covered by
|
Lower-level helpers are available from their owning modules and are not part
|
||||||
`SECONDARY_PUBLIC_EXPORTS`, but they are oriented toward CLI/export/schema
|
of the package-root stable surface. Import them directly when needed:
|
||||||
integrations, parsing, and lower-level MT5 access rather than the stable core
|
|
||||||
SDK surface. Prefer the stable symbols above for downstream infrastructure
|
|
||||||
adapters.
|
|
||||||
|
|
||||||
### Read-only MT5 data wrappers
|
| Module | Examples |
|
||||||
|
| ------------------- | ---------------------------------------------------------------------------------------------- |
|
||||||
Module-level helpers open a transient connection per call. Prefer `mt5_session`
|
| `mt5cli.history` | `resolve_rate_view_name`, `resolve_rate_tables`, `load_rate_data`, `build_rate_view_name` |
|
||||||
or `MT5Client` when making many requests in one process.
|
| `mt5cli.sdk` | `copy_rates_from`, `copy_ticks_from`, `account_info`, `symbols`, `mt5_summary`, `latest_rates` |
|
||||||
|
| `mt5cli.schemas` | `DataKind`, `normalize_dataframe`, `validate_schema`, `DEDUP_KEYS` |
|
||||||
| Area | Symbols |
|
| `mt5cli.utils` | `Dataset`, `IfExists`, `detect_format`, `export_dataframe`, `export_dataframe_to_sqlite` |
|
||||||
| -------------------- | ---------------------------------------------------------------------------------------------------- |
|
| `mt5cli.converters` | `normalize_symbol`, `ensure_utc`, `parse_date_range`, `granularity_name` |
|
||||||
| Rates | `copy_rates_from`, `copy_rates_from_pos`, `copy_rates_range`, `latest_rates`, `collect_latest_rates` |
|
| `mt5cli.exceptions` | `normalize_mt5_exception`, `call_with_normalized_errors`, `is_recoverable_mt5_error` |
|
||||||
| 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` |
|
|
||||||
| Multi-account rates | `collect_latest_rates_for_accounts` |
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
### Schema, export, and parser helpers
|
|
||||||
|
|
||||||
| Area | Symbols |
|
|
||||||
| -------------------- | ------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| Dataset contracts | `DataKind`, `Dataset`, `IfExists`, `DEDUP_KEYS`, `REQUIRED_COLUMNS`, `TIME_COLUMNS`, `KNOWN_MT5_TIME_COLUMNS` |
|
|
||||||
| Schema normalization | `normalize_dataframe`, `normalize_time_columns`, `schema_columns`, `validate_schema` |
|
|
||||||
| Export helpers | `detect_format`, `export_dataframe`, `export_dataframe_to_sqlite` |
|
|
||||||
| Symbol parsing | `normalize_symbol`, `normalize_symbols` |
|
|
||||||
| Time parsing | `ensure_utc`, `parse_date_range`, `parse_datetime`, `recent_window` |
|
|
||||||
| MT5 parsing maps | `granularity_name`, `parse_tick_flags`, `parse_timeframe`, `TICK_FLAG_MAP`, `TIMEFRAME_MAP` |
|
|
||||||
| Trading data shapes | `POSITION_COLUMNS` |
|
|
||||||
|
|
||||||
## CLI commands
|
## CLI commands
|
||||||
|
|
||||||
@@ -231,7 +196,7 @@ their own adapter layer.
|
|||||||
|
|
||||||
## Contract verification
|
## Contract verification
|
||||||
|
|
||||||
`tests/test_contracts.py` asserts that every name in the stable and secondary
|
`tests/test_contracts.py` asserts that every name in `STABLE_SDK_EXPORTS` is
|
||||||
tier sets is importable from `mt5cli`, documents key closed-bar, rate-view,
|
importable from `mt5cli`, that all package-root exports are covered by the
|
||||||
SQLite loading, account-resolution, and trading-session behaviors, and keeps the
|
stable set, and documents key closed-bar, SQLite loading, account-resolution,
|
||||||
tier sets aligned with `__all__`.
|
and trading-session behaviors.
|
||||||
|
|||||||
+2
-1
@@ -117,7 +117,8 @@ call it every iteration without over-fetching.
|
|||||||
```python
|
```python
|
||||||
from pdmt5 import Mt5Config, Mt5DataClient
|
from pdmt5 import Mt5Config, Mt5DataClient
|
||||||
|
|
||||||
from mt5cli import Dataset, ThrottledHistoryUpdater
|
from mt5cli import ThrottledHistoryUpdater
|
||||||
|
from mt5cli.utils import Dataset
|
||||||
|
|
||||||
updater = ThrottledHistoryUpdater(
|
updater = ThrottledHistoryUpdater(
|
||||||
output="history.db",
|
output="history.db",
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
# Storage
|
|
||||||
|
|
||||||
::: mt5cli.storage
|
|
||||||
+6
-5
@@ -6,8 +6,9 @@
|
|||||||
|
|
||||||
`create_trading_client()` and `mt5_trading_session()` complement the read-only
|
`create_trading_client()` and `mt5_trading_session()` complement the read-only
|
||||||
`mt5_session()` helper in `sdk.py`. They return or yield an initialized
|
`mt5_session()` helper in `sdk.py`. They return or yield an initialized
|
||||||
`pdmt5.Mt5TradingClient`, use `Mt5Config.path` to launch the terminal when
|
client supporting order execution and account management, use `Mt5Config.path`
|
||||||
configured, and `mt5_trading_session()` always calls `shutdown()` on exit.
|
to launch the terminal when configured, and `mt5_trading_session()` always
|
||||||
|
calls `shutdown()` on exit.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from mt5cli import create_trading_client, mt5_trading_session
|
from mt5cli import create_trading_client, mt5_trading_session
|
||||||
@@ -115,19 +116,19 @@ closed = close_open_positions(client, symbols="EURUSD", dry_run=True)
|
|||||||
`detect_position_side()` returns `long` for buy-only exposure, `short` for
|
`detect_position_side()` returns `long` for buy-only exposure, `short` for
|
||||||
sell-only exposure, and `None` for no positions or mixed long/short exposure.
|
sell-only exposure, and `None` for no positions or mixed long/short exposure.
|
||||||
`calculate_spread_ratio()` uses `(ask - bid) / ((ask + bid) / 2)` and raises
|
`calculate_spread_ratio()` uses `(ask - bid) / ((ask + bid) / 2)` and raises
|
||||||
`Mt5TradingError` when bid or ask is missing or non-positive.
|
`Mt5OperationError` when bid or ask is missing or non-positive.
|
||||||
`normalize_order_volume()` returns `0.0` for invalid constraints or
|
`normalize_order_volume()` returns `0.0` for invalid constraints or
|
||||||
sub-minimum requests; check the result before calling `estimate_order_margin()`,
|
sub-minimum requests; check the result before calling `estimate_order_margin()`,
|
||||||
which requires a positive finite volume. `calculate_positions_margin()` silently
|
which requires a positive finite volume. `calculate_positions_margin()` silently
|
||||||
skips rows with missing symbols, non-positive volumes, non-finite volumes, or
|
skips rows with missing symbols, non-positive volumes, non-finite volumes, or
|
||||||
unsupported position types, but propagates `Mt5TradingError` from `estimate_order_margin()` when a valid row
|
unsupported position types, but propagates `Mt5OperationError` from `estimate_order_margin()` when a valid row
|
||||||
encounters invalid tick data or margin results from the broker.
|
encounters invalid tick data or margin results from the broker.
|
||||||
|
|
||||||
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. `determine_order_limits()` pre-validates computed SL/TP prices against
|
available. `determine_order_limits()` pre-validates computed SL/TP prices against
|
||||||
available `trade_stops_level * point` metadata when present; violations raise
|
available `trade_stops_level * point` metadata when present; violations raise
|
||||||
`Mt5TradingError`. This is a planning helper only: it does not guarantee broker
|
`Mt5OperationError`. This is a planning helper only: it does not guarantee broker
|
||||||
acceptance because live validation can still depend on price movement, bid/ask
|
acceptance because live validation can still depend on price movement, bid/ask
|
||||||
side, freeze levels, and server-side rules, and it does not validate
|
side, freeze levels, and server-side rules, and it does not validate
|
||||||
`trade_freeze_level`. When symbol metadata cannot be loaded, protective prices
|
`trade_freeze_level`. When symbol metadata cannot be loaded, protective prices
|
||||||
|
|||||||
+49
-35
@@ -27,6 +27,12 @@ mt5cli provides a stable `MT5Client` Python API, standardized dataset schemas, s
|
|||||||
pip install mt5cli
|
pip install mt5cli
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Parquet export is not included by default. To enable it, install the `parquet` extra:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install "mt5cli[parquet]"
|
||||||
|
```
|
||||||
|
|
||||||
## Python API for downstream packages
|
## Python API for downstream packages
|
||||||
|
|
||||||
Import `MT5Client` for generic MT5 data access, schema normalization, and optional order primitives.
|
Import `MT5Client` for generic MT5 data access, schema normalization, and optional order primitives.
|
||||||
@@ -36,19 +42,15 @@ from datetime import UTC, datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from mt5cli import (
|
from mt5cli import (
|
||||||
DataKind,
|
|
||||||
Dataset,
|
|
||||||
MT5Client,
|
MT5Client,
|
||||||
build_config,
|
build_config,
|
||||||
collect_history,
|
collect_history,
|
||||||
export_dataframe,
|
|
||||||
load_rate_data,
|
|
||||||
minimum_margins,
|
|
||||||
mt5_session,
|
mt5_session,
|
||||||
normalize_dataframe,
|
|
||||||
recent_ticks,
|
|
||||||
resolve_rate_view_name,
|
|
||||||
)
|
)
|
||||||
|
from mt5cli.history import load_rate_data, resolve_rate_view_name
|
||||||
|
from mt5cli.schemas import DataKind, normalize_dataframe
|
||||||
|
from mt5cli.sdk import minimum_margins, recent_ticks
|
||||||
|
from mt5cli.utils import Dataset, export_dataframe
|
||||||
|
|
||||||
# Persistent session for multiple calls
|
# Persistent session for multiple calls
|
||||||
with mt5_session(build_config(login=12345, server="Broker-Demo")) as client:
|
with mt5_session(build_config(login=12345, server="Broker-Demo")) as client:
|
||||||
@@ -84,7 +86,7 @@ collect_history(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
Schema contracts live in `mt5cli.schemas` (`DataKind`, `validate_schema`, `normalize_dataframe`). Storage helpers are re-exported from `mt5cli.storage` and the package root.
|
Schema contracts live in `mt5cli.schemas` (`DataKind`, `validate_schema`, `normalize_dataframe`). Export and storage helpers are in `mt5cli.utils` (`Dataset`, `export_dataframe`) and `mt5cli.history`.
|
||||||
|
|
||||||
`MT5Client.order_send()` is a live execution primitive: it can place real trades on the connected account. mt5cli does not implement strategy logic, signal generation, backtesting, or optimization — downstream applications must gate live execution explicitly (the CLI requires `--yes` for `order-send`).
|
`MT5Client.order_send()` is a live execution primitive: it can place real trades on the connected account. mt5cli does not implement strategy logic, signal generation, backtesting, or optimization — downstream applications must gate live execution explicitly (the CLI requires `--yes` for `order-send`).
|
||||||
|
|
||||||
@@ -145,26 +147,38 @@ mt5cli --login 12345 --password mypass --server MyBroker-Demo \
|
|||||||
| `minimum-margins` | Export minimum-volume margin summary |
|
| `minimum-margins` | Export minimum-volume margin summary |
|
||||||
| `market-book` | Export market depth (order book) |
|
| `market-book` | Export market depth (order book) |
|
||||||
|
|
||||||
### Trading
|
### Trading State
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| ---------------------- | ----------------------------------------------------------- |
|
| ---------------------- | ------------------------------------------------------------------- |
|
||||||
| `orders` | Export active orders |
|
| `orders` | Export active orders |
|
||||||
| `positions` | Export open positions |
|
| `positions` | Export open positions |
|
||||||
| `history-orders` | Export historical orders |
|
| `history-orders` | Export historical orders |
|
||||||
| `history-deals` | Export historical deals |
|
| `history-deals` | Export historical deals |
|
||||||
| `recent-history-deals` | Export historical deals from a trailing window |
|
| `recent-history-deals` | Export historical deals from a trailing window |
|
||||||
| `mt5-summary` | Export terminal/account status summary |
|
| `mt5-summary` | Export terminal/account status summary |
|
||||||
| `order-check` | Check funds sufficiency for a trade request |
|
| `order-check` | Check funds sufficiency for a trade request (read-only, no `--yes`) |
|
||||||
| `order-send` | Send a trade request to the trade server (`--yes` required) |
|
|
||||||
|
|
||||||
Use `order-check` to validate a request payload before running `order-send --yes`.
|
### Execution (live / mutating)
|
||||||
|
|
||||||
|
These commands send requests to the live trade server and can place or close
|
||||||
|
real trades. Both require `--yes` for live execution.
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
| ----------------- | ---------------------------------------------------------------------------------------------------- |
|
||||||
|
| `order-send` | Send a **raw** trade request directly to MT5 (`--yes` required; expert path — no extra validation) |
|
||||||
|
| `close-positions` | Close open positions by `--symbol` or `--ticket` (`--yes` required for live; `--dry-run` to preview) |
|
||||||
|
|
||||||
|
Use `order-check` (Trading State) to validate funds before running `order-send --yes`.
|
||||||
|
`close-positions` is the safer high-level alternative that builds correct close
|
||||||
|
requests automatically. `order-send` is the expert raw path — downstream
|
||||||
|
applications should prefer dedicated closing helpers or their own risk controls.
|
||||||
|
|
||||||
### Bulk Collection
|
### Bulk Collection
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `collect-history` | Collect rates, ticks, history-orders, and history-deals for one or more symbols into a single SQLite database (optional cash-event/position views) |
|
| `collect-history` | Collect rates, history-orders, and history-deals (ticks opt-in via `--dataset ticks`) for one or more symbols into a single SQLite database (optional cash-event/position views) |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mt5cli -o history.db collect-history \
|
mt5cli -o history.db collect-history \
|
||||||
@@ -176,16 +190,16 @@ mt5cli -o history.db collect-history \
|
|||||||
|
|
||||||
`collect-history` options:
|
`collect-history` options:
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
| -------------- | ---------- | --------------------------------------------------------------------------------------------- |
|
| -------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `--symbol/-s` | _required_ | Symbol to collect (repeat for multiple). |
|
| `--symbol/-s` | _required_ | Symbol to collect (repeat for multiple). |
|
||||||
| `--date-from` | _required_ | Start date in ISO 8601. |
|
| `--date-from` | _required_ | Start date in ISO 8601. |
|
||||||
| `--date-to` | _required_ | End date in ISO 8601. |
|
| `--date-to` | _required_ | End date in ISO 8601. |
|
||||||
| `--dataset` | all four | Repeatable: `rates`, `ticks`, `history-orders`, `history-deals`. |
|
| `--dataset` | rates, history-orders, history-deals | Repeatable: `rates`, `ticks`, `history-orders`, `history-deals`. Ticks are opt-in: pass `--dataset ticks` to include them. |
|
||||||
| `--timeframe` | `M1` | Rates timeframe; recorded in a `timeframe` column on the `rates` table. |
|
| `--timeframe` | `M1` | Rates timeframe; recorded in a `timeframe` column on the `rates` table. |
|
||||||
| `--flags` | `ALL` | Tick copy flags forwarded to `copy_ticks_range`. |
|
| `--flags` | `ALL` | Tick copy flags forwarded to `copy_ticks_range`. |
|
||||||
| `--if-exists` | `fail` | `append`, `replace`, or `fail` when a target table already exists. |
|
| `--if-exists` | `fail` | `append`, `replace`, or `fail` when a target table already exists. |
|
||||||
| `--with-views` | off | Add `cash_events` and `positions_reconstructed` views (requires the `history-deals` dataset). |
|
| `--with-views` | off | Add `cash_events` and `positions_reconstructed` views (requires the `history-deals` dataset). |
|
||||||
|
|
||||||
History orders and deals are fetched per symbol and concatenated, so the symbol filter is applied consistently across all datasets. The `cash_events` view is derived from symbol-filtered `history_deals`, so account-level cash events with empty or non-matching symbols may be excluded. The `positions_reconstructed` view excludes positions with no closing deal, uses volume-weighted open/close prices, and reports reversal deals (`DEAL_ENTRY_INOUT`) via `volume_reversal` / `reversal_count`.
|
History orders and deals are fetched per symbol and concatenated, so the symbol filter is applied consistently across all datasets. The `cash_events` view is derived from symbol-filtered `history_deals`, so account-level cash events with empty or non-matching symbols may be excluded. The `positions_reconstructed` view excludes positions with no closing deal, uses volume-weighted open/close prices, and reports reversal deals (`DEAL_ENTRY_INOUT`) via `volume_reversal` / `reversal_count`.
|
||||||
|
|
||||||
@@ -215,7 +229,7 @@ See the [History schema diagram](api/history.md#entity-relationship-diagram) for
|
|||||||
|
|
||||||
Browse the API documentation for detailed module information:
|
Browse the API documentation for detailed module information:
|
||||||
|
|
||||||
- [CLI Module](api/cli.md) - CLI application with export commands
|
- [CLI Module](api/cli.md) - CLI application with data export and execution commands
|
||||||
- [SDK Module](api/sdk.md) - Programmatic read-only data collection API
|
- [SDK Module](api/sdk.md) - Programmatic read-only data collection API
|
||||||
- [Utils Module](api/utils.md) - Constants, parameter types, parsers, and export utilities
|
- [Utils Module](api/utils.md) - Constants, parameter types, parsers, and export utilities
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ nav:
|
|||||||
- Public API Contract: api/public-contract.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
|
|
||||||
- Converters: api/converters.md
|
- Converters: api/converters.md
|
||||||
- Exceptions: api/exceptions.md
|
- Exceptions: api/exceptions.md
|
||||||
- CLI: api/cli.md
|
- CLI: api/cli.md
|
||||||
|
|||||||
+1
-155
@@ -8,110 +8,35 @@ strategy responsibilities.
|
|||||||
|
|
||||||
from importlib.metadata import version
|
from importlib.metadata import version
|
||||||
|
|
||||||
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 (
|
from .contract import STABLE_SDK_EXPORTS
|
||||||
PUBLIC_EXPORT_TIERS,
|
|
||||||
SECONDARY_PUBLIC_EXPORTS,
|
|
||||||
STABLE_SDK_EXPORTS,
|
|
||||||
)
|
|
||||||
from .converters import (
|
|
||||||
ensure_utc,
|
|
||||||
granularity_name,
|
|
||||||
normalize_symbol,
|
|
||||||
normalize_symbols,
|
|
||||||
parse_date_range,
|
|
||||||
recent_window,
|
|
||||||
)
|
|
||||||
from .exceptions import (
|
from .exceptions import (
|
||||||
Mt5CliError,
|
Mt5CliError,
|
||||||
Mt5ConnectionError,
|
Mt5ConnectionError,
|
||||||
Mt5OperationError,
|
Mt5OperationError,
|
||||||
Mt5SchemaError,
|
Mt5SchemaError,
|
||||||
call_with_normalized_errors,
|
|
||||||
is_recoverable_mt5_error,
|
|
||||||
normalize_mt5_exception,
|
|
||||||
)
|
)
|
||||||
from .history import (
|
from .history import (
|
||||||
RateTarget,
|
RateTarget,
|
||||||
build_rate_targets,
|
build_rate_targets,
|
||||||
build_rate_view_name,
|
|
||||||
drop_forming_rate_bar,
|
drop_forming_rate_bar,
|
||||||
load_rate_data,
|
|
||||||
load_rate_data_from_connection,
|
|
||||||
load_rate_series_by_granularity,
|
load_rate_series_by_granularity,
|
||||||
load_rate_series_from_sqlite,
|
load_rate_series_from_sqlite,
|
||||||
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,
|
|
||||||
)
|
|
||||||
from .schemas import (
|
|
||||||
DEDUP_KEYS,
|
|
||||||
KNOWN_MT5_TIME_COLUMNS,
|
|
||||||
REQUIRED_COLUMNS,
|
|
||||||
TIME_COLUMNS,
|
|
||||||
DataKind,
|
|
||||||
normalize_dataframe,
|
|
||||||
normalize_time_columns,
|
|
||||||
schema_columns,
|
|
||||||
validate_schema,
|
|
||||||
)
|
)
|
||||||
from .sdk import (
|
from .sdk import (
|
||||||
AccountSpec,
|
AccountSpec,
|
||||||
ThrottledHistoryUpdater,
|
ThrottledHistoryUpdater,
|
||||||
account_info,
|
|
||||||
collect_history,
|
collect_history,
|
||||||
collect_latest_closed_rates_by_granularity,
|
collect_latest_closed_rates_by_granularity,
|
||||||
collect_latest_closed_rates_for_accounts,
|
collect_latest_closed_rates_for_accounts,
|
||||||
collect_latest_rates,
|
|
||||||
collect_latest_rates_for_accounts,
|
|
||||||
collect_latest_rates_for_accounts_with_retries,
|
collect_latest_rates_for_accounts_with_retries,
|
||||||
copy_rates_from,
|
|
||||||
copy_rates_from_pos,
|
|
||||||
copy_rates_range,
|
|
||||||
copy_ticks_from,
|
|
||||||
copy_ticks_range,
|
|
||||||
fetch_latest_closed_rates,
|
fetch_latest_closed_rates,
|
||||||
history_deals,
|
|
||||||
history_orders,
|
|
||||||
last_error,
|
|
||||||
latest_rates,
|
|
||||||
market_book,
|
|
||||||
minimum_margins,
|
|
||||||
mt5_summary,
|
|
||||||
mt5_summary_as_df,
|
|
||||||
orders,
|
|
||||||
positions,
|
|
||||||
recent_history_deals,
|
|
||||||
recent_ticks,
|
|
||||||
resolve_account_spec,
|
resolve_account_spec,
|
||||||
resolve_account_specs,
|
resolve_account_specs,
|
||||||
substitute_env_placeholders,
|
|
||||||
substitute_mapping_values,
|
|
||||||
symbol_info,
|
|
||||||
symbol_info_tick,
|
|
||||||
symbols,
|
|
||||||
terminal_info,
|
|
||||||
update_history,
|
update_history,
|
||||||
update_history_with_config,
|
update_history_with_config,
|
||||||
)
|
)
|
||||||
from .sdk import (
|
|
||||||
version as mt5_version,
|
|
||||||
)
|
|
||||||
from .storage import (
|
|
||||||
Dataset,
|
|
||||||
IfExists,
|
|
||||||
detect_format,
|
|
||||||
export_dataframe,
|
|
||||||
export_dataframe_to_sqlite,
|
|
||||||
)
|
|
||||||
from .trading import (
|
from .trading import (
|
||||||
POSITION_COLUMNS,
|
|
||||||
ExecutionStatus,
|
ExecutionStatus,
|
||||||
MarginVolume,
|
MarginVolume,
|
||||||
OrderExecutionResult,
|
OrderExecutionResult,
|
||||||
@@ -151,42 +76,19 @@ from .trading import (
|
|||||||
update_sltp_for_open_positions,
|
update_sltp_for_open_positions,
|
||||||
update_trailing_stop_loss_for_open_positions,
|
update_trailing_stop_loss_for_open_positions,
|
||||||
)
|
)
|
||||||
from .utils import (
|
|
||||||
TICK_FLAG_MAP,
|
|
||||||
TIMEFRAME_MAP,
|
|
||||||
parse_datetime,
|
|
||||||
parse_tick_flags,
|
|
||||||
parse_timeframe,
|
|
||||||
)
|
|
||||||
|
|
||||||
__version__ = version(__package__) if __package__ else None
|
__version__ = version(__package__) if __package__ else None
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DEDUP_KEYS",
|
|
||||||
"KNOWN_MT5_TIME_COLUMNS",
|
|
||||||
"POSITION_COLUMNS",
|
|
||||||
"PUBLIC_EXPORT_TIERS",
|
|
||||||
"REQUIRED_COLUMNS",
|
|
||||||
"SECONDARY_PUBLIC_EXPORTS",
|
|
||||||
"STABLE_SDK_EXPORTS",
|
"STABLE_SDK_EXPORTS",
|
||||||
"TICK_FLAG_MAP",
|
|
||||||
"TIMEFRAME_MAP",
|
|
||||||
"TIME_COLUMNS",
|
|
||||||
"AccountSpec",
|
"AccountSpec",
|
||||||
"DataKind",
|
|
||||||
"Dataset",
|
|
||||||
"ExecutionStatus",
|
"ExecutionStatus",
|
||||||
"IfExists",
|
|
||||||
"MT5Client",
|
"MT5Client",
|
||||||
"MarginVolume",
|
"MarginVolume",
|
||||||
"Mt5CliError",
|
"Mt5CliError",
|
||||||
"Mt5Config",
|
|
||||||
"Mt5ConnectionError",
|
"Mt5ConnectionError",
|
||||||
"Mt5OperationError",
|
"Mt5OperationError",
|
||||||
"Mt5RuntimeError",
|
|
||||||
"Mt5SchemaError",
|
"Mt5SchemaError",
|
||||||
"Mt5TradingClient",
|
|
||||||
"Mt5TradingError",
|
|
||||||
"OrderExecutionResult",
|
"OrderExecutionResult",
|
||||||
"OrderFillingMode",
|
"OrderFillingMode",
|
||||||
"OrderLimits",
|
"OrderLimits",
|
||||||
@@ -196,10 +98,8 @@ __all__ = [
|
|||||||
"ProjectionMode",
|
"ProjectionMode",
|
||||||
"RateTarget",
|
"RateTarget",
|
||||||
"ThrottledHistoryUpdater",
|
"ThrottledHistoryUpdater",
|
||||||
"account_info",
|
|
||||||
"build_config",
|
"build_config",
|
||||||
"build_rate_targets",
|
"build_rate_targets",
|
||||||
"build_rate_view_name",
|
|
||||||
"calculate_account_projected_margin_ratio",
|
"calculate_account_projected_margin_ratio",
|
||||||
"calculate_margin_and_volume",
|
"calculate_margin_and_volume",
|
||||||
"calculate_new_position_margin_ratio",
|
"calculate_new_position_margin_ratio",
|
||||||
@@ -211,29 +111,17 @@ __all__ = [
|
|||||||
"calculate_symbol_group_margin_ratio",
|
"calculate_symbol_group_margin_ratio",
|
||||||
"calculate_trailing_stop_updates",
|
"calculate_trailing_stop_updates",
|
||||||
"calculate_volume_by_margin",
|
"calculate_volume_by_margin",
|
||||||
"call_with_normalized_errors",
|
|
||||||
"close_open_positions",
|
"close_open_positions",
|
||||||
"collect_history",
|
"collect_history",
|
||||||
"collect_latest_closed_rates_by_granularity",
|
"collect_latest_closed_rates_by_granularity",
|
||||||
"collect_latest_closed_rates_for_accounts",
|
"collect_latest_closed_rates_for_accounts",
|
||||||
"collect_latest_rates",
|
|
||||||
"collect_latest_rates_for_accounts",
|
|
||||||
"collect_latest_rates_for_accounts_with_retries",
|
"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",
|
"create_trading_client",
|
||||||
"detect_format",
|
|
||||||
"detect_position_side",
|
"detect_position_side",
|
||||||
"determine_order_limits",
|
"determine_order_limits",
|
||||||
"drop_forming_rate_bar",
|
"drop_forming_rate_bar",
|
||||||
"ensure_symbol_selected",
|
"ensure_symbol_selected",
|
||||||
"ensure_utc",
|
|
||||||
"estimate_order_margin",
|
"estimate_order_margin",
|
||||||
"export_dataframe",
|
|
||||||
"export_dataframe_to_sqlite",
|
|
||||||
"extract_tick_price",
|
"extract_tick_price",
|
||||||
"fetch_latest_closed_rates",
|
"fetch_latest_closed_rates",
|
||||||
"fetch_latest_closed_rates_for_trading_client",
|
"fetch_latest_closed_rates_for_trading_client",
|
||||||
@@ -242,58 +130,16 @@ __all__ = [
|
|||||||
"get_positions_frame",
|
"get_positions_frame",
|
||||||
"get_symbol_snapshot",
|
"get_symbol_snapshot",
|
||||||
"get_tick_snapshot",
|
"get_tick_snapshot",
|
||||||
"granularity_name",
|
|
||||||
"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_by_granularity",
|
||||||
"load_rate_series_from_sqlite",
|
"load_rate_series_from_sqlite",
|
||||||
"market_book",
|
|
||||||
"minimum_margins",
|
|
||||||
"mt5_session",
|
"mt5_session",
|
||||||
"mt5_summary",
|
|
||||||
"mt5_summary_as_df",
|
|
||||||
"mt5_trading_session",
|
"mt5_trading_session",
|
||||||
"mt5_version",
|
|
||||||
"normalize_dataframe",
|
|
||||||
"normalize_mt5_exception",
|
|
||||||
"normalize_order_volume",
|
"normalize_order_volume",
|
||||||
"normalize_symbol",
|
|
||||||
"normalize_symbols",
|
|
||||||
"normalize_time_columns",
|
|
||||||
"orders",
|
|
||||||
"parse_date_range",
|
|
||||||
"parse_datetime",
|
|
||||||
"parse_tick_flags",
|
|
||||||
"parse_timeframe",
|
|
||||||
"place_market_order",
|
"place_market_order",
|
||||||
"positions",
|
|
||||||
"recent_history_deals",
|
|
||||||
"recent_ticks",
|
|
||||||
"recent_window",
|
|
||||||
"resolve_account_spec",
|
"resolve_account_spec",
|
||||||
"resolve_account_specs",
|
"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",
|
|
||||||
"schema_columns",
|
|
||||||
"substitute_env_placeholders",
|
|
||||||
"substitute_mapping_values",
|
|
||||||
"symbol_info",
|
|
||||||
"symbol_info_tick",
|
|
||||||
"symbols",
|
|
||||||
"terminal_info",
|
|
||||||
"update_history",
|
"update_history",
|
||||||
"update_history_with_config",
|
"update_history_with_config",
|
||||||
"update_sltp_for_open_positions",
|
"update_sltp_for_open_positions",
|
||||||
"update_trailing_stop_loss_for_open_positions",
|
"update_trailing_stop_loss_for_open_positions",
|
||||||
"validate_schema",
|
|
||||||
]
|
]
|
||||||
|
|||||||
+50
-36
@@ -1,4 +1,4 @@
|
|||||||
"""Command-line interface for MetaTrader 5 data export."""
|
"""Command-line interface for MetaTrader 5 data and execution utilities."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -55,7 +55,12 @@ class _ExportContext:
|
|||||||
|
|
||||||
app = typer.Typer(
|
app = typer.Typer(
|
||||||
name="mt5cli",
|
name="mt5cli",
|
||||||
help="Export MetaTrader5 data to CSV, JSON, Parquet, or SQLite3.",
|
help=(
|
||||||
|
"MT5 data and execution utilities — read market data, inspect account"
|
||||||
|
" state, and send trade requests. Data commands write to CSV, JSON,"
|
||||||
|
" Parquet, or SQLite3. Execution commands (order-send, close-positions)"
|
||||||
|
" require --yes for live mutations."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
_REQUEST_OPTION_HELP = (
|
_REQUEST_OPTION_HELP = (
|
||||||
@@ -151,7 +156,7 @@ def _callback( # pyright: ignore[reportUnusedFunction]
|
|||||||
typer.Option("--log-level", help="Logging level."),
|
typer.Option("--log-level", help="Logging level."),
|
||||||
] = LogLevel.WARNING,
|
] = LogLevel.WARNING,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Configure shared options for all export commands.
|
"""Configure shared connection and output options.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
typer.BadParameter: If the output format cannot be determined.
|
typer.BadParameter: If the output format cannot be determined.
|
||||||
@@ -183,7 +188,7 @@ def _callback( # pyright: ignore[reportUnusedFunction]
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def rates_from(
|
def rates_from(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
||||||
@@ -210,7 +215,7 @@ def rates_from(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def rates_from_pos(
|
def rates_from_pos(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
||||||
@@ -236,7 +241,7 @@ def rates_from_pos(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def latest_rates(
|
def latest_rates(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
||||||
@@ -265,7 +270,7 @@ def latest_rates(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def rates_range(
|
def rates_range(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
||||||
@@ -292,7 +297,7 @@ def rates_range(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def ticks_from(
|
def ticks_from(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
||||||
@@ -316,7 +321,7 @@ def ticks_from(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def ticks_range(
|
def ticks_range(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
||||||
@@ -340,7 +345,7 @@ def ticks_range(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def ticks_recent(
|
def ticks_recent(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
||||||
@@ -377,19 +382,19 @@ def ticks_recent(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def account_info(ctx: typer.Context) -> None:
|
def account_info(ctx: typer.Context) -> None:
|
||||||
"""Export account information."""
|
"""Export account information."""
|
||||||
_export_command(ctx, lambda client: client.account_info())
|
_export_command(ctx, lambda client: client.account_info())
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def terminal_info(ctx: typer.Context) -> None:
|
def terminal_info(ctx: typer.Context) -> None:
|
||||||
"""Export terminal information."""
|
"""Export terminal information."""
|
||||||
_export_command(ctx, lambda client: client.terminal_info())
|
_export_command(ctx, lambda client: client.terminal_info())
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def symbols(
|
def symbols(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
group: Annotated[
|
group: Annotated[
|
||||||
@@ -401,7 +406,7 @@ def symbols(
|
|||||||
_export_command(ctx, lambda client: client.symbols(group=group))
|
_export_command(ctx, lambda client: client.symbols(group=group))
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def symbol_info(
|
def symbol_info(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
||||||
@@ -410,7 +415,7 @@ def symbol_info(
|
|||||||
_export_command(ctx, lambda client: client.symbol_info(symbol))
|
_export_command(ctx, lambda client: client.symbol_info(symbol))
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def minimum_margins(
|
def minimum_margins(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
||||||
@@ -419,7 +424,7 @@ def minimum_margins(
|
|||||||
_export_command(ctx, lambda client: client.minimum_margins(symbol))
|
_export_command(ctx, lambda client: client.minimum_margins(symbol))
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def orders(
|
def orders(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
symbol: Annotated[str | None, typer.Option(help="Symbol filter.")] = None,
|
symbol: Annotated[str | None, typer.Option(help="Symbol filter.")] = None,
|
||||||
@@ -433,7 +438,7 @@ def orders(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def positions(
|
def positions(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
symbol: Annotated[str | None, typer.Option(help="Symbol filter.")] = None,
|
symbol: Annotated[str | None, typer.Option(help="Symbol filter.")] = None,
|
||||||
@@ -447,7 +452,7 @@ def positions(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def history_orders(
|
def history_orders(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
date_from: Annotated[
|
date_from: Annotated[
|
||||||
@@ -477,7 +482,7 @@ def history_orders(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def history_deals(
|
def history_deals(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
date_from: Annotated[
|
date_from: Annotated[
|
||||||
@@ -507,7 +512,7 @@ def history_deals(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def recent_history_deals(
|
def recent_history_deals(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
hours: Annotated[float, typer.Option(help="Lookback window in hours.")],
|
hours: Annotated[float, typer.Option(help="Lookback window in hours.")],
|
||||||
@@ -530,25 +535,25 @@ def recent_history_deals(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def mt5_summary(ctx: typer.Context) -> None:
|
def mt5_summary(ctx: typer.Context) -> None:
|
||||||
"""Export a compact terminal/account status summary."""
|
"""Export a compact terminal/account status summary."""
|
||||||
_export_command(ctx, lambda client: client.mt5_summary_as_df())
|
_export_command(ctx, lambda client: client.mt5_summary_as_df())
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def version(ctx: typer.Context) -> None:
|
def version(ctx: typer.Context) -> None:
|
||||||
"""Export MetaTrader5 version information."""
|
"""Export MetaTrader5 version information."""
|
||||||
_export_command(ctx, lambda client: client.version())
|
_export_command(ctx, lambda client: client.version())
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def last_error(ctx: typer.Context) -> None:
|
def last_error(ctx: typer.Context) -> None:
|
||||||
"""Export the last error information."""
|
"""Export the last error information."""
|
||||||
_export_command(ctx, lambda client: client.last_error())
|
_export_command(ctx, lambda client: client.last_error())
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def symbol_info_tick(
|
def symbol_info_tick(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
||||||
@@ -557,7 +562,7 @@ def symbol_info_tick(
|
|||||||
_export_command(ctx, lambda client: client.symbol_info_tick(symbol))
|
_export_command(ctx, lambda client: client.symbol_info_tick(symbol))
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def market_book(
|
def market_book(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
symbol: Annotated[str, typer.Option(help="Symbol name.")],
|
||||||
@@ -566,7 +571,7 @@ def market_book(
|
|||||||
_export_command(ctx, lambda client: client.market_book(symbol))
|
_export_command(ctx, lambda client: client.market_book(symbol))
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Data / Export")
|
||||||
def order_check(
|
def order_check(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
request: Annotated[
|
request: Annotated[
|
||||||
@@ -578,7 +583,7 @@ def order_check(
|
|||||||
_export_command(ctx, lambda client: client.order_check(request))
|
_export_command(ctx, lambda client: client.order_check(request))
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Execution")
|
||||||
def order_send(
|
def order_send(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
request: Annotated[
|
request: Annotated[
|
||||||
@@ -590,7 +595,13 @@ def order_send(
|
|||||||
typer.Option("--yes", help="Confirm the live trade request."),
|
typer.Option("--yes", help="Confirm the live trade request."),
|
||||||
] = False,
|
] = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Send a trading operation request to the trade server.
|
"""Send a raw trade request to the trade server (expert path, live execution).
|
||||||
|
|
||||||
|
Passes the request JSON directly to MT5 ``order_send``. This is the
|
||||||
|
low-level expert path — it places real trades on the connected account
|
||||||
|
with no additional validation beyond what MT5 itself performs. Use
|
||||||
|
``order-check`` first to validate funds sufficiency. Prefer
|
||||||
|
``close-positions`` for closing open positions. ``--yes`` is required.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
typer.BadParameter: If --yes is not provided.
|
typer.BadParameter: If --yes is not provided.
|
||||||
@@ -628,7 +639,7 @@ def _execution_results_to_df(results: list[OrderExecutionResult]) -> pd.DataFram
|
|||||||
return pd.DataFrame(rows)
|
return pd.DataFrame(rows)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Execution")
|
||||||
def close_positions(
|
def close_positions(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
symbol: Annotated[
|
symbol: Annotated[
|
||||||
@@ -691,7 +702,7 @@ def close_positions(
|
|||||||
_execute_export(ctx, lambda: df)
|
_execute_export(ctx, lambda: df)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command(rich_help_panel="Collection")
|
||||||
def collect_history(
|
def collect_history(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
symbol: Annotated[
|
symbol: Annotated[
|
||||||
@@ -716,7 +727,8 @@ def collect_history(
|
|||||||
"--dataset",
|
"--dataset",
|
||||||
help=(
|
help=(
|
||||||
"Dataset to include (repeat for multiple)."
|
"Dataset to include (repeat for multiple)."
|
||||||
" Defaults to all: rates, ticks, history-orders, history-deals."
|
" Defaults to rates, history-orders, history-deals."
|
||||||
|
" Ticks are opt-in: pass --dataset ticks to include them."
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
] = None,
|
] = None,
|
||||||
@@ -754,10 +766,12 @@ def collect_history(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Collect historical datasets into a single SQLite database.
|
"""Collect historical datasets into a single SQLite database.
|
||||||
|
|
||||||
Tables written depend on ``--dataset``: ``rates``, ``ticks``,
|
Tables written depend on ``--dataset``: ``rates``, ``history_orders``,
|
||||||
``history_orders``, ``history_deals``. History datasets are fetched per
|
``history_deals`` by default. ``ticks`` are opt-in: pass
|
||||||
symbol and concatenated. Rates rows carry the requested ``timeframe`` so
|
``--dataset ticks`` to include them (tick data grows the database quickly).
|
||||||
appended runs at different timeframes remain distinguishable.
|
History datasets are fetched per symbol and concatenated. Rates rows carry
|
||||||
|
the requested ``timeframe`` so appended runs at different timeframes remain
|
||||||
|
distinguishable.
|
||||||
|
|
||||||
With ``--with-views`` (requires the ``history-deals`` dataset), optional
|
With ``--with-views`` (requires the ``history-deals`` dataset), optional
|
||||||
views ``cash_events`` and ``positions_reconstructed`` are derived from
|
views ``cash_events`` and ``positions_reconstructed`` are derived from
|
||||||
@@ -773,7 +787,7 @@ def collect_history(
|
|||||||
" Use a .db/.sqlite/.sqlite3 extension or --format sqlite3."
|
" Use a .db/.sqlite/.sqlite3 extension or --format sqlite3."
|
||||||
)
|
)
|
||||||
raise typer.BadParameter(msg)
|
raise typer.BadParameter(msg)
|
||||||
datasets = set(dataset) if dataset else set(Dataset)
|
datasets = set(dataset) if dataset is not None else None
|
||||||
sdk.collect_history(
|
sdk.collect_history(
|
||||||
output=export_ctx.output,
|
output=export_ctx.output,
|
||||||
symbols=symbol,
|
symbols=symbol,
|
||||||
|
|||||||
+2
-84
@@ -1,4 +1,4 @@
|
|||||||
"""Downstream SDK export tiers for mt5cli."""
|
"""Downstream SDK export tier for mt5cli."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -6,13 +6,9 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
|||||||
"AccountSpec",
|
"AccountSpec",
|
||||||
"MT5Client",
|
"MT5Client",
|
||||||
"Mt5CliError",
|
"Mt5CliError",
|
||||||
"Mt5Config",
|
|
||||||
"Mt5ConnectionError",
|
"Mt5ConnectionError",
|
||||||
"Mt5OperationError",
|
"Mt5OperationError",
|
||||||
"Mt5RuntimeError",
|
|
||||||
"Mt5SchemaError",
|
"Mt5SchemaError",
|
||||||
"Mt5TradingClient",
|
|
||||||
"Mt5TradingError",
|
|
||||||
"OrderFillingMode",
|
"OrderFillingMode",
|
||||||
"OrderSide",
|
"OrderSide",
|
||||||
"OrderTimeMode",
|
"OrderTimeMode",
|
||||||
@@ -26,7 +22,6 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
|||||||
"ThrottledHistoryUpdater",
|
"ThrottledHistoryUpdater",
|
||||||
"build_config",
|
"build_config",
|
||||||
"build_rate_targets",
|
"build_rate_targets",
|
||||||
"build_rate_view_name",
|
|
||||||
"calculate_account_projected_margin_ratio",
|
"calculate_account_projected_margin_ratio",
|
||||||
"calculate_margin_and_volume",
|
"calculate_margin_and_volume",
|
||||||
"calculate_new_position_margin_ratio",
|
"calculate_new_position_margin_ratio",
|
||||||
@@ -38,7 +33,6 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
|||||||
"calculate_symbol_group_margin_ratio",
|
"calculate_symbol_group_margin_ratio",
|
||||||
"calculate_trailing_stop_updates",
|
"calculate_trailing_stop_updates",
|
||||||
"calculate_volume_by_margin",
|
"calculate_volume_by_margin",
|
||||||
"call_with_normalized_errors",
|
|
||||||
"close_open_positions",
|
"close_open_positions",
|
||||||
"collect_history",
|
"collect_history",
|
||||||
"collect_latest_closed_rates_by_granularity",
|
"collect_latest_closed_rates_by_granularity",
|
||||||
@@ -58,94 +52,18 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
|||||||
"get_positions_frame",
|
"get_positions_frame",
|
||||||
"get_symbol_snapshot",
|
"get_symbol_snapshot",
|
||||||
"get_tick_snapshot",
|
"get_tick_snapshot",
|
||||||
"is_recoverable_mt5_error",
|
|
||||||
"load_rate_data",
|
|
||||||
"load_rate_data_from_connection",
|
|
||||||
"load_rate_series_by_granularity",
|
"load_rate_series_by_granularity",
|
||||||
"load_rate_series_from_sqlite",
|
"load_rate_series_from_sqlite",
|
||||||
"mt5_session",
|
"mt5_session",
|
||||||
"mt5_trading_session",
|
"mt5_trading_session",
|
||||||
"normalize_mt5_exception",
|
|
||||||
"normalize_order_volume",
|
"normalize_order_volume",
|
||||||
"place_market_order",
|
"place_market_order",
|
||||||
"resolve_account_spec",
|
"resolve_account_spec",
|
||||||
"resolve_account_specs",
|
"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",
|
|
||||||
"substitute_mapping_values",
|
|
||||||
"update_history",
|
"update_history",
|
||||||
"update_history_with_config",
|
"update_history_with_config",
|
||||||
"update_sltp_for_open_positions",
|
"update_sltp_for_open_positions",
|
||||||
"update_trailing_stop_loss_for_open_positions",
|
"update_trailing_stop_loss_for_open_positions",
|
||||||
})
|
})
|
||||||
|
|
||||||
SECONDARY_PUBLIC_EXPORTS: frozenset[str] = frozenset({
|
__all__ = ["STABLE_SDK_EXPORTS"]
|
||||||
"DEDUP_KEYS",
|
|
||||||
"DataKind",
|
|
||||||
"Dataset",
|
|
||||||
"IfExists",
|
|
||||||
"KNOWN_MT5_TIME_COLUMNS",
|
|
||||||
"POSITION_COLUMNS",
|
|
||||||
"REQUIRED_COLUMNS",
|
|
||||||
"TICK_FLAG_MAP",
|
|
||||||
"TIMEFRAME_MAP",
|
|
||||||
"TIME_COLUMNS",
|
|
||||||
"account_info",
|
|
||||||
"collect_latest_rates",
|
|
||||||
"collect_latest_rates_for_accounts",
|
|
||||||
"copy_rates_from",
|
|
||||||
"copy_rates_from_pos",
|
|
||||||
"copy_rates_range",
|
|
||||||
"copy_ticks_from",
|
|
||||||
"copy_ticks_range",
|
|
||||||
"detect_format",
|
|
||||||
"ensure_utc",
|
|
||||||
"export_dataframe",
|
|
||||||
"export_dataframe_to_sqlite",
|
|
||||||
"granularity_name",
|
|
||||||
"history_deals",
|
|
||||||
"history_orders",
|
|
||||||
"last_error",
|
|
||||||
"latest_rates",
|
|
||||||
"market_book",
|
|
||||||
"minimum_margins",
|
|
||||||
"mt5_summary",
|
|
||||||
"mt5_summary_as_df",
|
|
||||||
"mt5_version",
|
|
||||||
"normalize_dataframe",
|
|
||||||
"normalize_symbol",
|
|
||||||
"normalize_symbols",
|
|
||||||
"normalize_time_columns",
|
|
||||||
"orders",
|
|
||||||
"parse_date_range",
|
|
||||||
"parse_datetime",
|
|
||||||
"parse_tick_flags",
|
|
||||||
"parse_timeframe",
|
|
||||||
"positions",
|
|
||||||
"recent_history_deals",
|
|
||||||
"recent_ticks",
|
|
||||||
"recent_window",
|
|
||||||
"schema_columns",
|
|
||||||
"symbol_info",
|
|
||||||
"symbol_info_tick",
|
|
||||||
"symbols",
|
|
||||||
"terminal_info",
|
|
||||||
"validate_schema",
|
|
||||||
})
|
|
||||||
|
|
||||||
PUBLIC_EXPORT_TIERS: dict[str, frozenset[str]] = {
|
|
||||||
"stable": STABLE_SDK_EXPORTS,
|
|
||||||
"secondary": SECONDARY_PUBLIC_EXPORTS,
|
|
||||||
}
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"PUBLIC_EXPORT_TIERS",
|
|
||||||
"SECONDARY_PUBLIC_EXPORTS",
|
|
||||||
"STABLE_SDK_EXPORTS",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -4,11 +4,16 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING, TypeVar
|
from typing import TYPE_CHECKING, TypeVar
|
||||||
|
|
||||||
from pdmt5 import Mt5RuntimeError, Mt5TradingError
|
from pdmt5 import Mt5RuntimeError
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pdmt5 import Mt5TradingError
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
Mt5TradingError = None # type: ignore[assignment]
|
||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -22,7 +27,7 @@ __all__ = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
_RECOVERABLE_MT5_ERRORS: tuple[type[BaseException], ...] = (
|
_RECOVERABLE_MT5_ERRORS: tuple[type[BaseException], ...] = (
|
||||||
Mt5TradingError,
|
*([Mt5TradingError] if Mt5TradingError is not None else []), # type: ignore[misc]
|
||||||
Mt5RuntimeError,
|
Mt5RuntimeError,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,7 +55,7 @@ def is_recoverable_mt5_error(exc: BaseException) -> bool:
|
|||||||
exc: Exception raised by MT5 or pdmt5.
|
exc: Exception raised by MT5 or pdmt5.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True for ``Mt5RuntimeError`` and ``Mt5TradingError``.
|
True for ``Mt5RuntimeError`` and ``Mt5TradingError`` (if available).
|
||||||
"""
|
"""
|
||||||
return isinstance(exc, _RECOVERABLE_MT5_ERRORS)
|
return isinstance(exc, _RECOVERABLE_MT5_ERRORS)
|
||||||
|
|
||||||
@@ -65,7 +70,7 @@ def normalize_mt5_exception(exc: BaseException) -> Mt5CliError:
|
|||||||
``Mt5ConnectionError`` for runtime failures, ``Mt5OperationError`` for
|
``Mt5ConnectionError`` for runtime failures, ``Mt5OperationError`` for
|
||||||
trading failures, or the original exception when it is not recognized.
|
trading failures, or the original exception when it is not recognized.
|
||||||
"""
|
"""
|
||||||
if isinstance(exc, Mt5TradingError):
|
if Mt5TradingError is not None and isinstance(exc, Mt5TradingError):
|
||||||
return Mt5OperationError(str(exc))
|
return Mt5OperationError(str(exc))
|
||||||
if isinstance(exc, Mt5RuntimeError):
|
if isinstance(exc, Mt5RuntimeError):
|
||||||
return Mt5ConnectionError(str(exc))
|
return Mt5ConnectionError(str(exc))
|
||||||
|
|||||||
+9
-3
@@ -30,6 +30,11 @@ if TYPE_CHECKING:
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_HISTORY_TIMEFRAMES: tuple[str, ...] = TIMEFRAME_NAMES
|
DEFAULT_HISTORY_TIMEFRAMES: tuple[str, ...] = TIMEFRAME_NAMES
|
||||||
|
DEFAULT_HISTORY_DATASETS: frozenset[Dataset] = frozenset({
|
||||||
|
Dataset.rates,
|
||||||
|
Dataset.history_orders,
|
||||||
|
Dataset.history_deals,
|
||||||
|
})
|
||||||
|
|
||||||
_HISTORY_DEDUP_KEYS: dict[Dataset, tuple[tuple[str, ...], ...]] = {
|
_HISTORY_DEDUP_KEYS: dict[Dataset, tuple[tuple[str, ...], ...]] = {
|
||||||
Dataset.rates: DEDUP_KEYS[DataKind.rates],
|
Dataset.rates: DEDUP_KEYS[DataKind.rates],
|
||||||
@@ -62,11 +67,12 @@ def resolve_history_datasets(datasets: set[Dataset] | None) -> set[Dataset]:
|
|||||||
"""Resolve configured history datasets.
|
"""Resolve configured history datasets.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
All supported datasets when ``datasets`` is None, otherwise the
|
``DEFAULT_HISTORY_DATASETS`` (rates, history-orders, history-deals)
|
||||||
configured selection (which may be empty).
|
when ``datasets`` is None, otherwise the configured selection (which
|
||||||
|
may be empty or explicitly include ``Dataset.ticks``).
|
||||||
"""
|
"""
|
||||||
if datasets is None:
|
if datasets is None:
|
||||||
return set(Dataset)
|
return set(DEFAULT_HISTORY_DATASETS)
|
||||||
return set(datasets)
|
return set(datasets)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+14
-6
@@ -15,7 +15,12 @@ from pathlib import Path
|
|||||||
from typing import TYPE_CHECKING, Self, TypeVar, cast
|
from typing import TYPE_CHECKING, Self, TypeVar, cast
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from pdmt5 import Mt5Config, Mt5DataClient, Mt5RuntimeError, Mt5TradingError
|
from pdmt5 import Mt5Config, Mt5DataClient, Mt5RuntimeError
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pdmt5 import Mt5TradingError
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
Mt5TradingError = None # type: ignore[assignment]
|
||||||
|
|
||||||
from .history import (
|
from .history import (
|
||||||
create_cash_events_view,
|
create_cash_events_view,
|
||||||
@@ -49,7 +54,7 @@ T = TypeVar("T")
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_RECOVERABLE_HISTORY_UPDATE_ERRORS: tuple[type[BaseException], ...] = (
|
_RECOVERABLE_HISTORY_UPDATE_ERRORS: tuple[type[BaseException], ...] = (
|
||||||
Mt5TradingError,
|
*([Mt5TradingError] if Mt5TradingError is not None else []), # type: ignore[assignment]
|
||||||
Mt5RuntimeError,
|
Mt5RuntimeError,
|
||||||
sqlite3.Error,
|
sqlite3.Error,
|
||||||
ValueError,
|
ValueError,
|
||||||
@@ -976,7 +981,8 @@ def update_history( # noqa: PLR0913
|
|||||||
client: Connected MT5 data client.
|
client: Connected MT5 data client.
|
||||||
output: SQLite database path.
|
output: SQLite database path.
|
||||||
symbols: Symbols to update.
|
symbols: Symbols to update.
|
||||||
datasets: Datasets to include (defaults to all).
|
datasets: Datasets to include (defaults to rates, history-orders,
|
||||||
|
history-deals; pass ``{Dataset.ticks}`` to opt in to ticks).
|
||||||
timeframes: Rate timeframes to update (defaults to all fixed MT5
|
timeframes: Rate timeframes to update (defaults to all fixed MT5
|
||||||
timeframes when None).
|
timeframes when None).
|
||||||
flags: Tick copy flags as integer or name (e.g. ``ALL``).
|
flags: Tick copy flags as integer or name (e.g. ``ALL``).
|
||||||
@@ -1105,7 +1111,8 @@ class ThrottledHistoryUpdater:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
output: SQLite database path.
|
output: SQLite database path.
|
||||||
datasets: Datasets to include (defaults to all).
|
datasets: Datasets to include (defaults to rates, history-orders,
|
||||||
|
history-deals; pass ``{Dataset.ticks}`` to opt in to ticks).
|
||||||
timeframes: Rate timeframes to update (defaults to all fixed MT5
|
timeframes: Rate timeframes to update (defaults to all fixed MT5
|
||||||
timeframes).
|
timeframes).
|
||||||
flags: Tick copy flags as integer or name (e.g. ``ALL``).
|
flags: Tick copy flags as integer or name (e.g. ``ALL``).
|
||||||
@@ -1237,7 +1244,8 @@ def collect_history(
|
|||||||
symbols: Symbols to collect.
|
symbols: Symbols to collect.
|
||||||
date_from: Start date.
|
date_from: Start date.
|
||||||
date_to: End date.
|
date_to: End date.
|
||||||
datasets: Datasets to include (defaults to all).
|
datasets: Datasets to include (defaults to rates, history-orders,
|
||||||
|
history-deals; pass ``{Dataset.ticks}`` to opt in to ticks).
|
||||||
timeframe: Rates timeframe as integer or name (e.g. ``M1``).
|
timeframe: Rates timeframe as integer or name (e.g. ``M1``).
|
||||||
flags: Tick copy flags as integer or name (e.g. ``ALL``).
|
flags: Tick copy flags as integer or name (e.g. ``ALL``).
|
||||||
if_exists: Behavior when a target table already exists.
|
if_exists: Behavior when a target table already exists.
|
||||||
@@ -1246,7 +1254,7 @@ def collect_history(
|
|||||||
"""
|
"""
|
||||||
start = _require_datetime(date_from)
|
start = _require_datetime(date_from)
|
||||||
end = _require_datetime(date_to)
|
end = _require_datetime(date_to)
|
||||||
selected = datasets if datasets is not None else set(Dataset)
|
selected = resolve_history_datasets(datasets)
|
||||||
tf = _coerce_timeframe(timeframe)
|
tf = _coerce_timeframe(timeframe)
|
||||||
tick_flags = _coerce_tick_flags(flags)
|
tick_flags = _coerce_tick_flags(flags)
|
||||||
mt5_config = config or build_config()
|
mt5_config = config or build_config()
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
"""Generic storage helpers for MT5 market and account history."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .history import (
|
|
||||||
RateTarget,
|
|
||||||
build_rate_targets,
|
|
||||||
build_rate_view_name,
|
|
||||||
drop_forming_rate_bar,
|
|
||||||
load_rate_data,
|
|
||||||
load_rate_data_from_connection,
|
|
||||||
load_rate_series_by_granularity,
|
|
||||||
load_rate_series_from_sqlite,
|
|
||||||
resolve_rate_tables,
|
|
||||||
resolve_rate_view_name,
|
|
||||||
resolve_rate_view_names,
|
|
||||||
)
|
|
||||||
from .sdk import collect_history, update_history, update_history_with_config
|
|
||||||
from .utils import (
|
|
||||||
Dataset,
|
|
||||||
IfExists,
|
|
||||||
OutputFormat,
|
|
||||||
detect_format,
|
|
||||||
export_dataframe,
|
|
||||||
export_dataframe_to_sqlite,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"Dataset",
|
|
||||||
"IfExists",
|
|
||||||
"OutputFormat",
|
|
||||||
"RateTarget",
|
|
||||||
"build_rate_targets",
|
|
||||||
"build_rate_view_name",
|
|
||||||
"collect_history",
|
|
||||||
"detect_format",
|
|
||||||
"drop_forming_rate_bar",
|
|
||||||
"export_dataframe",
|
|
||||||
"export_dataframe_to_sqlite",
|
|
||||||
"load_rate_data",
|
|
||||||
"load_rate_data_from_connection",
|
|
||||||
"load_rate_series_by_granularity",
|
|
||||||
"load_rate_series_from_sqlite",
|
|
||||||
"resolve_rate_tables",
|
|
||||||
"resolve_rate_view_name",
|
|
||||||
"resolve_rate_view_names",
|
|
||||||
"update_history",
|
|
||||||
"update_history_with_config",
|
|
||||||
]
|
|
||||||
+153
-91
@@ -6,20 +6,79 @@ import logging
|
|||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from math import floor, isfinite
|
from math import floor, isfinite
|
||||||
from numbers import Integral, Real
|
from numbers import Integral, Real
|
||||||
from typing import TYPE_CHECKING, Literal, TypedDict, cast
|
from typing import TYPE_CHECKING, Literal, Protocol, TypedDict, cast
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from pdmt5 import Mt5Config, Mt5RuntimeError, Mt5TradingClient, Mt5TradingError
|
from pdmt5 import Mt5Config, Mt5DataClient, Mt5RuntimeError
|
||||||
|
|
||||||
|
from .exceptions import Mt5OperationError
|
||||||
from .history import drop_forming_rate_bar
|
from .history import drop_forming_rate_bar
|
||||||
from .sdk import build_config
|
from .sdk import build_config
|
||||||
from .utils import coerce_login as _coerce_login
|
from .utils import coerce_login as _coerce_login
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Iterator, Mapping, Sequence
|
from collections.abc import Iterator, Mapping, Sequence
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class _Mt5ClientProtocol(Protocol):
|
||||||
|
"""Minimal protocol for MT5 clients with methods required by mt5cli.
|
||||||
|
|
||||||
|
This protocol describes the interface required by mt5cli trading helpers.
|
||||||
|
It uses positional-only parameters to avoid structural subtyping issues with
|
||||||
|
different client implementations that may use different parameter names.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mt5(self) -> Any: # noqa: ANN401
|
||||||
|
"""MT5 module with trading constants (POSITION_TYPE_*, ORDER_TYPE_*, etc.)."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def account_info_as_dict(self) -> dict[str, Any]:
|
||||||
|
"""Return account information as a dictionary."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def symbol_info(self, symbol: str, /) -> object:
|
||||||
|
"""Return symbol information."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def symbol_info_tick(self, symbol: str, /) -> object:
|
||||||
|
"""Return latest symbol tick information."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def positions_get_as_df(self, symbol: str | None = None) -> pd.DataFrame:
|
||||||
|
"""Return open positions as a DataFrame."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def order_calc_margin(
|
||||||
|
self, /, action: int, symbol: str, volume: float, price: float
|
||||||
|
) -> Any: # noqa: ANN401
|
||||||
|
"""Calculate required margin for an order."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def order_send(self, request: dict[str, Any], /) -> Any: # noqa: ANN401
|
||||||
|
"""Send an order request and return the response."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def symbol_select(self, symbol: str, enable: bool = True) -> bool:
|
||||||
|
"""Select/deselect a symbol in Market Watch."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def last_error(self) -> object:
|
||||||
|
"""Return the last error message or info."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
"""Shut down the MT5 client."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def initialize_and_login_mt5(self) -> None:
|
||||||
|
"""Initialize and login to MT5."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
PositionSide = Literal["long", "short"]
|
PositionSide = Literal["long", "short"]
|
||||||
OrderSide = Literal["BUY", "SELL"]
|
OrderSide = Literal["BUY", "SELL"]
|
||||||
OrderFillingMode = Literal["IOC", "FOK", "RETURN"]
|
OrderFillingMode = Literal["IOC", "FOK", "RETURN"]
|
||||||
@@ -188,7 +247,7 @@ def _validate_protective_prices(
|
|||||||
"""Validate SL/TP distances against broker stop-level constraints.
|
"""Validate SL/TP distances against broker stop-level constraints.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
Mt5TradingError: When a protective price is closer than ``min_distance``.
|
Mt5OperationError: When a protective price is closer than ``min_distance``.
|
||||||
"""
|
"""
|
||||||
if min_distance <= 0:
|
if min_distance <= 0:
|
||||||
return
|
return
|
||||||
@@ -198,37 +257,37 @@ def _validate_protective_prices(
|
|||||||
f"Stop loss for {symbol!r} violates broker stop level "
|
f"Stop loss for {symbol!r} violates broker stop level "
|
||||||
f"(minimum distance {min_distance})."
|
f"(minimum distance {min_distance})."
|
||||||
)
|
)
|
||||||
raise Mt5TradingError(msg)
|
raise Mt5OperationError(msg)
|
||||||
if take_profit is not None and (take_profit - entry) < min_distance:
|
if take_profit is not None and (take_profit - entry) < min_distance:
|
||||||
msg = (
|
msg = (
|
||||||
f"Take profit for {symbol!r} violates broker stop level "
|
f"Take profit for {symbol!r} violates broker stop level "
|
||||||
f"(minimum distance {min_distance})."
|
f"(minimum distance {min_distance})."
|
||||||
)
|
)
|
||||||
raise Mt5TradingError(msg)
|
raise Mt5OperationError(msg)
|
||||||
return
|
return
|
||||||
if stop_loss is not None and (stop_loss - entry) < min_distance:
|
if stop_loss is not None and (stop_loss - entry) < min_distance:
|
||||||
msg = (
|
msg = (
|
||||||
f"Stop loss for {symbol!r} violates broker stop level "
|
f"Stop loss for {symbol!r} violates broker stop level "
|
||||||
f"(minimum distance {min_distance})."
|
f"(minimum distance {min_distance})."
|
||||||
)
|
)
|
||||||
raise Mt5TradingError(msg)
|
raise Mt5OperationError(msg)
|
||||||
if take_profit is not None and (entry - take_profit) < min_distance:
|
if take_profit is not None and (entry - take_profit) < min_distance:
|
||||||
msg = (
|
msg = (
|
||||||
f"Take profit for {symbol!r} violates broker stop level "
|
f"Take profit for {symbol!r} violates broker stop level "
|
||||||
f"(minimum distance {min_distance})."
|
f"(minimum distance {min_distance})."
|
||||||
)
|
)
|
||||||
raise Mt5TradingError(msg)
|
raise Mt5OperationError(msg)
|
||||||
|
|
||||||
|
|
||||||
def ensure_symbol_selected(client: Mt5TradingClient, symbol: str) -> None:
|
def ensure_symbol_selected(client: _Mt5ClientProtocol, symbol: str) -> None:
|
||||||
"""Ensure a symbol is visible in Market Watch before sending orders.
|
"""Ensure a symbol is visible in Market Watch before sending orders.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
client: Connected ``Mt5TradingClient`` instance.
|
client: Connected MT5 client instance.
|
||||||
symbol: Symbol to select.
|
symbol: Symbol to select.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
Mt5TradingError: If the symbol cannot be selected in Market Watch or
|
Mt5OperationError: If the symbol cannot be selected in Market Watch or
|
||||||
``symbol_select`` is unavailable on the client.
|
``symbol_select`` is unavailable on the client.
|
||||||
"""
|
"""
|
||||||
snapshot = get_symbol_snapshot(client, symbol)
|
snapshot = get_symbol_snapshot(client, symbol)
|
||||||
@@ -237,13 +296,13 @@ def ensure_symbol_selected(client: Mt5TradingClient, symbol: str) -> None:
|
|||||||
select = getattr(client, "symbol_select", None)
|
select = getattr(client, "symbol_select", None)
|
||||||
if not callable(select):
|
if not callable(select):
|
||||||
msg = "MT5 client is missing required method: symbol_select"
|
msg = "MT5 client is missing required method: symbol_select"
|
||||||
raise Mt5TradingError(msg)
|
raise Mt5OperationError(msg)
|
||||||
if select(symbol, enable=True):
|
if select(symbol, enable=True):
|
||||||
return
|
return
|
||||||
last_error = getattr(client, "last_error", None)
|
last_error = getattr(client, "last_error", None)
|
||||||
detail = f" ({last_error()})" if callable(last_error) else ""
|
detail = f" ({last_error()})" if callable(last_error) else ""
|
||||||
msg = f"Failed to select symbol {symbol!r} in Market Watch{detail}."
|
msg = f"Failed to select symbol {symbol!r} in Market Watch{detail}."
|
||||||
raise Mt5TradingError(msg)
|
raise Mt5OperationError(msg)
|
||||||
|
|
||||||
|
|
||||||
def _require_unit_ratio(value: float, name: str) -> None:
|
def _require_unit_ratio(value: float, name: str) -> None:
|
||||||
@@ -370,7 +429,7 @@ def _snapshot_from_value(value: object, fields: tuple[str, ...]) -> dict[str, ob
|
|||||||
return {field: row.get(field) for field in fields}
|
return {field: row.get(field) for field in fields}
|
||||||
|
|
||||||
|
|
||||||
def _call_snapshot_method(client: Mt5TradingClient, *names: str) -> object:
|
def _call_snapshot_method(client: _Mt5ClientProtocol, *names: str) -> object:
|
||||||
for name in names:
|
for name in names:
|
||||||
method = getattr(client, name, None)
|
method = getattr(client, name, None)
|
||||||
if callable(method):
|
if callable(method):
|
||||||
@@ -394,7 +453,7 @@ def _resolve_mt5_constant(
|
|||||||
return cast("int", getattr(mt5, name))
|
return cast("int", getattr(mt5, name))
|
||||||
except AttributeError as exc:
|
except AttributeError as exc:
|
||||||
msg = f"MT5 module is missing required constant: {name}"
|
msg = f"MT5 module is missing required constant: {name}"
|
||||||
raise Mt5TradingError(msg) from exc
|
raise Mt5OperationError(msg) from exc
|
||||||
|
|
||||||
|
|
||||||
def _parse_digit_string(value: str) -> int | None:
|
def _parse_digit_string(value: str) -> int | None:
|
||||||
@@ -478,7 +537,7 @@ def _order_status_from_retcode(mt5: object, retcode: object) -> ExecutionStatus:
|
|||||||
|
|
||||||
|
|
||||||
def _calculate_min_volume_if_affordable(
|
def _calculate_min_volume_if_affordable(
|
||||||
client: Mt5TradingClient,
|
client: _Mt5ClientProtocol,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
available_margin: float,
|
available_margin: float,
|
||||||
order_side: OrderSide,
|
order_side: OrderSide,
|
||||||
@@ -495,14 +554,14 @@ def _calculate_min_volume_if_affordable(
|
|||||||
or (volume_max > 0 and volume_min > volume_max)
|
or (volume_max > 0 and volume_min > volume_max)
|
||||||
):
|
):
|
||||||
msg = f"Invalid volume constraints for {symbol!r}."
|
msg = f"Invalid volume constraints for {symbol!r}."
|
||||||
raise Mt5TradingError(msg)
|
raise Mt5OperationError(msg)
|
||||||
side = _normalize_order_side(order_side)
|
side = _normalize_order_side(order_side)
|
||||||
price = extract_tick_price(
|
price = extract_tick_price(
|
||||||
get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid"
|
get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid"
|
||||||
)
|
)
|
||||||
if price is None:
|
if price is None:
|
||||||
msg = f"Tick price is unavailable for {symbol!r}."
|
msg = f"Tick price is unavailable for {symbol!r}."
|
||||||
raise Mt5TradingError(msg)
|
raise Mt5OperationError(msg)
|
||||||
order_type = (
|
order_type = (
|
||||||
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
|
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
|
||||||
)
|
)
|
||||||
@@ -519,8 +578,12 @@ def create_trading_client(
|
|||||||
path: str | None = None,
|
path: str | None = None,
|
||||||
timeout: int | None = None,
|
timeout: int | None = None,
|
||||||
retry_count: int = 0,
|
retry_count: int = 0,
|
||||||
) -> Mt5TradingClient:
|
) -> _Mt5ClientProtocol:
|
||||||
"""Return an initialized and logged-in trading client."""
|
"""Return an initialized and logged-in trading client.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A client instance supporting the required MT5 trading methods.
|
||||||
|
"""
|
||||||
mt5_config = _resolve_config(
|
mt5_config = _resolve_config(
|
||||||
config=config,
|
config=config,
|
||||||
login=login,
|
login=login,
|
||||||
@@ -529,7 +592,7 @@ def create_trading_client(
|
|||||||
path=path,
|
path=path,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
client = Mt5TradingClient(config=mt5_config, retry_count=retry_count)
|
client = Mt5DataClient(config=mt5_config, retry_count=retry_count)
|
||||||
try:
|
try:
|
||||||
client.initialize_and_login_mt5()
|
client.initialize_and_login_mt5()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -539,13 +602,13 @@ def create_trading_client(
|
|||||||
|
|
||||||
|
|
||||||
def detect_position_side(
|
def detect_position_side(
|
||||||
client: Mt5TradingClient,
|
client: _Mt5ClientProtocol,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
) -> PositionSide | None:
|
) -> PositionSide | None:
|
||||||
"""Detect the net open position side for a symbol.
|
"""Detect the net open position side for a symbol.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
client: Connected ``Mt5TradingClient`` instance.
|
client: Connected MT5 client instance.
|
||||||
symbol: Symbol to inspect.
|
symbol: Symbol to inspect.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -569,7 +632,7 @@ def detect_position_side(
|
|||||||
|
|
||||||
|
|
||||||
def get_account_snapshot(
|
def get_account_snapshot(
|
||||||
client: Mt5TradingClient,
|
client: _Mt5ClientProtocol,
|
||||||
) -> dict[str, float | int | str | None]:
|
) -> dict[str, float | int | str | None]:
|
||||||
"""Return normalized account state with stable keys."""
|
"""Return normalized account state with stable keys."""
|
||||||
value = _call_snapshot_method(client, "account_info_as_dict", "account_info")
|
value = _call_snapshot_method(client, "account_info_as_dict", "account_info")
|
||||||
@@ -580,7 +643,7 @@ def get_account_snapshot(
|
|||||||
|
|
||||||
|
|
||||||
def get_symbol_snapshot(
|
def get_symbol_snapshot(
|
||||||
client: Mt5TradingClient,
|
client: _Mt5ClientProtocol,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
) -> dict[str, float | int | str | bool | None]:
|
) -> dict[str, float | int | str | bool | None]:
|
||||||
"""Return normalized symbol metadata required for trading decisions."""
|
"""Return normalized symbol metadata required for trading decisions."""
|
||||||
@@ -592,7 +655,7 @@ def get_symbol_snapshot(
|
|||||||
|
|
||||||
|
|
||||||
def get_tick_snapshot(
|
def get_tick_snapshot(
|
||||||
client: Mt5TradingClient,
|
client: _Mt5ClientProtocol,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
) -> dict[str, float | int | None]:
|
) -> dict[str, float | int | None]:
|
||||||
"""Return normalized latest tick data, including bid, ask, and timestamp."""
|
"""Return normalized latest tick data, including bid, ask, and timestamp."""
|
||||||
@@ -606,7 +669,7 @@ def get_tick_snapshot(
|
|||||||
|
|
||||||
|
|
||||||
def get_positions_frame(
|
def get_positions_frame(
|
||||||
client: Mt5TradingClient,
|
client: _Mt5ClientProtocol,
|
||||||
symbol: str | None = None,
|
symbol: str | None = None,
|
||||||
) -> pd.DataFrame:
|
) -> pd.DataFrame:
|
||||||
"""Return open positions as a DataFrame with stable baseline columns."""
|
"""Return open positions as a DataFrame with stable baseline columns."""
|
||||||
@@ -618,7 +681,7 @@ def get_positions_frame(
|
|||||||
|
|
||||||
|
|
||||||
def _order_side_from_position_type(
|
def _order_side_from_position_type(
|
||||||
client: Mt5TradingClient,
|
client: _Mt5ClientProtocol,
|
||||||
position_type: object,
|
position_type: object,
|
||||||
) -> OrderSide | None:
|
) -> OrderSide | None:
|
||||||
if position_type == client.mt5.POSITION_TYPE_BUY:
|
if position_type == client.mt5.POSITION_TYPE_BUY:
|
||||||
@@ -640,7 +703,7 @@ def _ensure_rate_time_column(frame: pd.DataFrame) -> pd.DataFrame:
|
|||||||
|
|
||||||
|
|
||||||
def estimate_order_margin(
|
def estimate_order_margin(
|
||||||
client: Mt5TradingClient,
|
client: _Mt5ClientProtocol,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
order_side: OrderSide | str,
|
order_side: OrderSide | str,
|
||||||
volume: float,
|
volume: float,
|
||||||
@@ -651,17 +714,17 @@ def estimate_order_margin(
|
|||||||
Positive finite margin required for the order at the current quote.
|
Positive finite margin required for the order at the current quote.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
Mt5TradingError: If volume, tick data, or margin estimation is invalid.
|
Mt5OperationError: If volume, tick data, or margin estimation is invalid.
|
||||||
"""
|
"""
|
||||||
if not _is_positive_finite_number(volume):
|
if not _is_positive_finite_number(volume):
|
||||||
msg = "Volume must be a positive finite number to estimate order margin."
|
msg = "Volume must be a positive finite number to estimate order margin."
|
||||||
raise Mt5TradingError(msg)
|
raise Mt5OperationError(msg)
|
||||||
side = _normalize_order_side(order_side)
|
side = _normalize_order_side(order_side)
|
||||||
tick = get_tick_snapshot(client, symbol)
|
tick = get_tick_snapshot(client, symbol)
|
||||||
price = extract_tick_price(tick, "ask" if side == "BUY" else "bid")
|
price = extract_tick_price(tick, "ask" if side == "BUY" else "bid")
|
||||||
if price is None:
|
if price is None:
|
||||||
msg = f"Tick price is unavailable for {symbol!r}."
|
msg = f"Tick price is unavailable for {symbol!r}."
|
||||||
raise Mt5TradingError(msg)
|
raise Mt5OperationError(msg)
|
||||||
order_type = (
|
order_type = (
|
||||||
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
|
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
|
||||||
)
|
)
|
||||||
@@ -670,22 +733,22 @@ def estimate_order_margin(
|
|||||||
margin = float(raw_margin)
|
margin = float(raw_margin)
|
||||||
except (TypeError, ValueError) as exc:
|
except (TypeError, ValueError) as exc:
|
||||||
msg = f"Margin estimate is invalid for {symbol!r}."
|
msg = f"Margin estimate is invalid for {symbol!r}."
|
||||||
raise Mt5TradingError(msg) from exc
|
raise Mt5OperationError(msg) from exc
|
||||||
if margin <= 0 or not isfinite(margin):
|
if margin <= 0 or not isfinite(margin):
|
||||||
msg = f"Margin estimate is invalid for {symbol!r}."
|
msg = f"Margin estimate is invalid for {symbol!r}."
|
||||||
raise Mt5TradingError(msg)
|
raise Mt5OperationError(msg)
|
||||||
return margin
|
return margin
|
||||||
|
|
||||||
|
|
||||||
def calculate_positions_margin(
|
def calculate_positions_margin(
|
||||||
client: Mt5TradingClient,
|
client: _Mt5ClientProtocol,
|
||||||
*,
|
*,
|
||||||
symbols: Sequence[str] | None = None,
|
symbols: Sequence[str] | None = None,
|
||||||
) -> float:
|
) -> float:
|
||||||
"""Return the sum of estimated current margin for open positions.
|
"""Return the sum of estimated current margin for open positions.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
client: Connected ``Mt5TradingClient`` instance.
|
client: Connected MT5 client instance.
|
||||||
symbols: Optional symbol filter. When omitted, all open positions are
|
symbols: Optional symbol filter. When omitted, all open positions are
|
||||||
included.
|
included.
|
||||||
|
|
||||||
@@ -720,7 +783,7 @@ def calculate_positions_margin(
|
|||||||
|
|
||||||
|
|
||||||
def calculate_positions_margin_by_symbol(
|
def calculate_positions_margin_by_symbol(
|
||||||
client: Mt5TradingClient,
|
client: _Mt5ClientProtocol,
|
||||||
*,
|
*,
|
||||||
symbols: Sequence[str],
|
symbols: Sequence[str],
|
||||||
suppress_errors: bool = True,
|
suppress_errors: bool = True,
|
||||||
@@ -732,10 +795,10 @@ def calculate_positions_margin_by_symbol(
|
|||||||
first-seen order.
|
first-seen order.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
client: Connected ``Mt5TradingClient`` instance.
|
client: Connected MT5 client instance.
|
||||||
symbols: Symbols to compute margin for.
|
symbols: Symbols to compute margin for.
|
||||||
suppress_errors: When ``True``, log and skip symbols that raise
|
suppress_errors: When ``True``, log and skip symbols that raise
|
||||||
``Mt5TradingError``, ``Mt5RuntimeError``, or ``AttributeError``.
|
``Mt5OperationError``, ``Mt5RuntimeError``, or ``AttributeError``.
|
||||||
When ``False``, re-raise the first failure.
|
When ``False``, re-raise the first failure.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -744,7 +807,7 @@ def calculate_positions_margin_by_symbol(
|
|||||||
with ``suppress_errors=True``.
|
with ``suppress_errors=True``.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
Mt5TradingError: When a symbol raises ``Mt5TradingError`` and
|
Mt5OperationError: When a symbol raises ``Mt5OperationError`` and
|
||||||
``suppress_errors=False``.
|
``suppress_errors=False``.
|
||||||
Mt5RuntimeError: When a symbol raises ``Mt5RuntimeError`` and
|
Mt5RuntimeError: When a symbol raises ``Mt5RuntimeError`` and
|
||||||
``suppress_errors=False``.
|
``suppress_errors=False``.
|
||||||
@@ -755,7 +818,7 @@ def calculate_positions_margin_by_symbol(
|
|||||||
for symbol in dict.fromkeys(symbols):
|
for symbol in dict.fromkeys(symbols):
|
||||||
try:
|
try:
|
||||||
result[symbol] = calculate_positions_margin(client, symbols=[symbol])
|
result[symbol] = calculate_positions_margin(client, symbols=[symbol])
|
||||||
except (Mt5TradingError, Mt5RuntimeError, AttributeError) as exc:
|
except (Mt5OperationError, Mt5RuntimeError, AttributeError) as exc:
|
||||||
if not suppress_errors:
|
if not suppress_errors:
|
||||||
raise
|
raise
|
||||||
_logger.warning("Skipping margin for %r: %s", symbol, exc)
|
_logger.warning("Skipping margin for %r: %s", symbol, exc)
|
||||||
@@ -763,7 +826,7 @@ def calculate_positions_margin_by_symbol(
|
|||||||
|
|
||||||
|
|
||||||
def calculate_positions_margin_safe(
|
def calculate_positions_margin_safe(
|
||||||
client: Mt5TradingClient,
|
client: _Mt5ClientProtocol,
|
||||||
*,
|
*,
|
||||||
symbols: Sequence[str],
|
symbols: Sequence[str],
|
||||||
) -> float:
|
) -> float:
|
||||||
@@ -773,7 +836,7 @@ def calculate_positions_margin_safe(
|
|||||||
``suppress_errors=True``. Failed symbols are silently skipped.
|
``suppress_errors=True``. Failed symbols are silently skipped.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
client: Connected ``Mt5TradingClient`` instance.
|
client: Connected MT5 client instance.
|
||||||
symbols: Symbols to include.
|
symbols: Symbols to include.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -785,23 +848,23 @@ def calculate_positions_margin_safe(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def calculate_spread_ratio(client: Mt5TradingClient, symbol: str) -> float:
|
def calculate_spread_ratio(client: _Mt5ClientProtocol, symbol: str) -> float:
|
||||||
"""Return ``(ask - bid) / ((ask + bid) / 2)`` for the latest tick.
|
"""Return ``(ask - bid) / ((ask + bid) / 2)`` for the latest tick.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
Mt5TradingError: If bid or ask is unavailable.
|
Mt5OperationError: If bid or ask is unavailable.
|
||||||
"""
|
"""
|
||||||
tick = get_tick_snapshot(client, symbol)
|
tick = get_tick_snapshot(client, symbol)
|
||||||
bid = extract_tick_price(tick, "bid")
|
bid = extract_tick_price(tick, "bid")
|
||||||
ask = extract_tick_price(tick, "ask")
|
ask = extract_tick_price(tick, "ask")
|
||||||
if bid is None or ask is None:
|
if bid is None or ask is None:
|
||||||
msg = f"Tick bid/ask is unavailable for {symbol!r}."
|
msg = f"Tick bid/ask is unavailable for {symbol!r}."
|
||||||
raise Mt5TradingError(msg)
|
raise Mt5OperationError(msg)
|
||||||
return (ask - bid) / ((ask + bid) / 2.0)
|
return (ask - bid) / ((ask + bid) / 2.0)
|
||||||
|
|
||||||
|
|
||||||
def calculate_new_position_margin_ratio(
|
def calculate_new_position_margin_ratio(
|
||||||
client: Mt5TradingClient,
|
client: _Mt5ClientProtocol,
|
||||||
*,
|
*,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
new_position_side: OrderSide | None = None,
|
new_position_side: OrderSide | None = None,
|
||||||
@@ -810,13 +873,13 @@ def calculate_new_position_margin_ratio(
|
|||||||
"""Return total margin/equity ratio after an optional hypothetical position.
|
"""Return total margin/equity ratio after an optional hypothetical position.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
Mt5TradingError: If equity or required tick data is invalid.
|
Mt5OperationError: If equity or required tick data is invalid.
|
||||||
"""
|
"""
|
||||||
account = get_account_snapshot(client)
|
account = get_account_snapshot(client)
|
||||||
equity = float(account.get("equity") or 0.0)
|
equity = float(account.get("equity") or 0.0)
|
||||||
if equity <= 0:
|
if equity <= 0:
|
||||||
msg = "Account equity must be positive to calculate margin ratio."
|
msg = "Account equity must be positive to calculate margin ratio."
|
||||||
raise Mt5TradingError(msg)
|
raise Mt5OperationError(msg)
|
||||||
margin = float(account.get("margin") or 0.0)
|
margin = float(account.get("margin") or 0.0)
|
||||||
if new_position_side is not None and new_position_volume > 0:
|
if new_position_side is not None and new_position_volume > 0:
|
||||||
side = _normalize_order_side(new_position_side)
|
side = _normalize_order_side(new_position_side)
|
||||||
@@ -825,7 +888,7 @@ def calculate_new_position_margin_ratio(
|
|||||||
)
|
)
|
||||||
if price is None:
|
if price is None:
|
||||||
msg = f"Tick price is unavailable for {symbol!r}."
|
msg = f"Tick price is unavailable for {symbol!r}."
|
||||||
raise Mt5TradingError(msg)
|
raise Mt5OperationError(msg)
|
||||||
order_type = (
|
order_type = (
|
||||||
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
|
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
|
||||||
)
|
)
|
||||||
@@ -835,7 +898,7 @@ def calculate_new_position_margin_ratio(
|
|||||||
return margin / equity
|
return margin / equity
|
||||||
|
|
||||||
|
|
||||||
def _account_equity(client: Mt5TradingClient) -> float:
|
def _account_equity(client: _Mt5ClientProtocol) -> float:
|
||||||
account = get_account_snapshot(client)
|
account = get_account_snapshot(client)
|
||||||
return _required_account_number(account, "equity", allow_zero=False)
|
return _required_account_number(account, "equity", allow_zero=False)
|
||||||
|
|
||||||
@@ -849,7 +912,7 @@ def _required_account_number(
|
|||||||
raw_value = account.get(field)
|
raw_value = account.get(field)
|
||||||
if isinstance(raw_value, bool) or not isinstance(raw_value, Real):
|
if isinstance(raw_value, bool) or not isinstance(raw_value, Real):
|
||||||
msg = f"Account {field} must be a finite number to calculate margin ratio."
|
msg = f"Account {field} must be a finite number to calculate margin ratio."
|
||||||
raise Mt5TradingError(msg)
|
raise Mt5OperationError(msg)
|
||||||
value = float(raw_value)
|
value = float(raw_value)
|
||||||
if (
|
if (
|
||||||
not isfinite(value)
|
not isfinite(value)
|
||||||
@@ -861,12 +924,12 @@ def _required_account_number(
|
|||||||
if allow_zero
|
if allow_zero
|
||||||
else f"Account {field} must be a positive finite number."
|
else f"Account {field} must be a positive finite number."
|
||||||
)
|
)
|
||||||
raise Mt5TradingError(msg)
|
raise Mt5OperationError(msg)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def calculate_account_projected_margin_ratio(
|
def calculate_account_projected_margin_ratio(
|
||||||
client: Mt5TradingClient,
|
client: _Mt5ClientProtocol,
|
||||||
*,
|
*,
|
||||||
symbol: str | None = None,
|
symbol: str | None = None,
|
||||||
new_position_side: OrderSide | None = None,
|
new_position_side: OrderSide | None = None,
|
||||||
@@ -894,7 +957,7 @@ def calculate_account_projected_margin_ratio(
|
|||||||
|
|
||||||
|
|
||||||
def calculate_projected_margin_ratio(
|
def calculate_projected_margin_ratio(
|
||||||
client: Mt5TradingClient,
|
client: _Mt5ClientProtocol,
|
||||||
*,
|
*,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
new_position_side: OrderSide | None = None,
|
new_position_side: OrderSide | None = None,
|
||||||
@@ -933,7 +996,7 @@ def _validate_projection_mode(projection_mode: str) -> ProjectionMode:
|
|||||||
|
|
||||||
|
|
||||||
def calculate_symbol_group_margin_ratio(
|
def calculate_symbol_group_margin_ratio(
|
||||||
client: Mt5TradingClient,
|
client: _Mt5ClientProtocol,
|
||||||
*,
|
*,
|
||||||
symbols: Sequence[str],
|
symbols: Sequence[str],
|
||||||
new_symbol: str | None = None,
|
new_symbol: str | None = None,
|
||||||
@@ -962,7 +1025,7 @@ def calculate_symbol_group_margin_ratio(
|
|||||||
fails and ``suppress_errors`` is ``False``.
|
fails and ``suppress_errors`` is ``False``.
|
||||||
Mt5RuntimeError: When symbol margin lookup or projected margin lookup
|
Mt5RuntimeError: When symbol margin lookup or projected margin lookup
|
||||||
fails and ``suppress_errors`` is ``False``.
|
fails and ``suppress_errors`` is ``False``.
|
||||||
Mt5TradingError: When account equity is invalid, or when symbol margin
|
Mt5OperationError: When account equity is invalid, or when symbol margin
|
||||||
lookup or projected margin lookup fails and ``suppress_errors`` is
|
lookup or projected margin lookup fails and ``suppress_errors`` is
|
||||||
``False``.
|
``False``.
|
||||||
"""
|
"""
|
||||||
@@ -987,7 +1050,7 @@ def calculate_symbol_group_margin_ratio(
|
|||||||
new_position_side,
|
new_position_side,
|
||||||
new_position_volume,
|
new_position_volume,
|
||||||
)
|
)
|
||||||
except (Mt5TradingError, Mt5RuntimeError, AttributeError):
|
except (Mt5OperationError, Mt5RuntimeError, AttributeError):
|
||||||
if not suppress_errors:
|
if not suppress_errors:
|
||||||
raise
|
raise
|
||||||
_logger.warning("Skipping projected margin for %r.", new_symbol)
|
_logger.warning("Skipping projected margin for %r.", new_symbol)
|
||||||
@@ -999,7 +1062,7 @@ def calculate_symbol_group_margin_ratio(
|
|||||||
|
|
||||||
|
|
||||||
def calculate_margin_and_volume(
|
def calculate_margin_and_volume(
|
||||||
client: Mt5TradingClient,
|
client: _Mt5ClientProtocol,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
unit_margin_ratio: float,
|
unit_margin_ratio: float,
|
||||||
preserved_margin_ratio: float,
|
preserved_margin_ratio: float,
|
||||||
@@ -1013,7 +1076,7 @@ def calculate_margin_and_volume(
|
|||||||
side when the post-reserve margin can afford it.
|
side when the post-reserve margin can afford it.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
client: Connected ``Mt5TradingClient`` instance.
|
client: Connected MT5 client instance.
|
||||||
symbol: Symbol used for minimum-lot margin and volume calculations.
|
symbol: Symbol used for minimum-lot margin and volume calculations.
|
||||||
unit_margin_ratio: Fraction of post-reserve margin to allocate per unit.
|
unit_margin_ratio: Fraction of post-reserve margin to allocate per unit.
|
||||||
preserved_margin_ratio: Fraction of ``margin_free`` to preserve.
|
preserved_margin_ratio: Fraction of ``margin_free`` to preserve.
|
||||||
@@ -1066,7 +1129,7 @@ def calculate_margin_and_volume(
|
|||||||
|
|
||||||
|
|
||||||
def calculate_volume_by_margin(
|
def calculate_volume_by_margin(
|
||||||
client: Mt5TradingClient,
|
client: _Mt5ClientProtocol,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
available_margin: float,
|
available_margin: float,
|
||||||
order_side: OrderSide,
|
order_side: OrderSide,
|
||||||
@@ -1079,7 +1142,7 @@ def calculate_volume_by_margin(
|
|||||||
constraints; ``0.0`` when no affordable step exists.
|
constraints; ``0.0`` when no affordable step exists.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
Mt5TradingError: If symbol volume constraints or tick data are invalid.
|
Mt5OperationError: If symbol volume constraints or tick data are invalid.
|
||||||
"""
|
"""
|
||||||
if available_margin <= 0:
|
if available_margin <= 0:
|
||||||
return 0.0
|
return 0.0
|
||||||
@@ -1089,14 +1152,14 @@ def calculate_volume_by_margin(
|
|||||||
volume_step = float(symbol_info.get("volume_step") or volume_min or 0.0)
|
volume_step = float(symbol_info.get("volume_step") or volume_min or 0.0)
|
||||||
if volume_min <= 0 or volume_step <= 0:
|
if volume_min <= 0 or volume_step <= 0:
|
||||||
msg = f"Invalid volume constraints for {symbol!r}."
|
msg = f"Invalid volume constraints for {symbol!r}."
|
||||||
raise Mt5TradingError(msg)
|
raise Mt5OperationError(msg)
|
||||||
side = _normalize_order_side(order_side)
|
side = _normalize_order_side(order_side)
|
||||||
price = extract_tick_price(
|
price = extract_tick_price(
|
||||||
get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid"
|
get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid"
|
||||||
)
|
)
|
||||||
if price is None:
|
if price is None:
|
||||||
msg = f"Tick price is unavailable for {symbol!r}."
|
msg = f"Tick price is unavailable for {symbol!r}."
|
||||||
raise Mt5TradingError(msg)
|
raise Mt5OperationError(msg)
|
||||||
order_type = (
|
order_type = (
|
||||||
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
|
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
|
||||||
)
|
)
|
||||||
@@ -1138,7 +1201,7 @@ def calculate_volume_by_margin(
|
|||||||
|
|
||||||
|
|
||||||
def determine_order_limits(
|
def determine_order_limits(
|
||||||
client: Mt5TradingClient,
|
client: _Mt5ClientProtocol,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
side: PositionSide | str,
|
side: PositionSide | str,
|
||||||
stop_loss_limit_ratio: float | None = None,
|
stop_loss_limit_ratio: float | None = None,
|
||||||
@@ -1147,7 +1210,7 @@ def determine_order_limits(
|
|||||||
"""Derive entry and protective order prices from current market quotes.
|
"""Derive entry and protective order prices from current market quotes.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
client: Connected ``Mt5TradingClient`` instance.
|
client: Connected MT5 client instance.
|
||||||
symbol: Symbol used for the quote lookup.
|
symbol: Symbol used for the quote lookup.
|
||||||
side: Position side as ``"long"``/``"short"`` (``"buy"``/``"sell"``
|
side: Position side as ``"long"``/``"short"`` (``"buy"``/``"sell"``
|
||||||
aliases are accepted).
|
aliases are accepted).
|
||||||
@@ -1161,7 +1224,7 @@ 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 or computed SL/TP
|
Mt5OperationError: If required tick data is invalid or computed SL/TP
|
||||||
prices violate available ``trade_stops_level`` pre-validation.
|
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
|
||||||
@@ -1174,7 +1237,7 @@ def determine_order_limits(
|
|||||||
entry = extract_tick_price(tick, entry_key)
|
entry = extract_tick_price(tick, entry_key)
|
||||||
if entry is None:
|
if entry is None:
|
||||||
msg = f"Tick price is unavailable for {symbol!r}."
|
msg = f"Tick price is unavailable for {symbol!r}."
|
||||||
raise Mt5TradingError(msg)
|
raise Mt5OperationError(msg)
|
||||||
try:
|
try:
|
||||||
symbol_info = get_symbol_snapshot(client, symbol)
|
symbol_info = get_symbol_snapshot(client, symbol)
|
||||||
except (AttributeError, KeyError, TypeError, ValueError):
|
except (AttributeError, KeyError, TypeError, ValueError):
|
||||||
@@ -1218,7 +1281,7 @@ def determine_order_limits(
|
|||||||
|
|
||||||
|
|
||||||
def place_market_order(
|
def place_market_order(
|
||||||
client: Mt5TradingClient,
|
client: _Mt5ClientProtocol,
|
||||||
*,
|
*,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
volume: float,
|
volume: float,
|
||||||
@@ -1232,20 +1295,20 @@ def place_market_order(
|
|||||||
) -> OrderExecutionResult:
|
) -> 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
|
``order_send()`` raises only when MT5 returns no response. When MT5 returns
|
||||||
response. When MT5 returns a response with a known non-success retcode, this
|
a response with a known non-success retcode, this helper returns
|
||||||
helper returns ``status="failed"`` and keeps the normalized response
|
``status="failed"`` and keeps the normalized response details for callers
|
||||||
details for callers to inspect.
|
to inspect.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Normalized execution result containing request and response details.
|
Normalized execution result containing request and response details.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
Mt5TradingError: If volume or required tick data is invalid.
|
Mt5OperationError: If volume or required tick data is invalid.
|
||||||
"""
|
"""
|
||||||
if volume <= 0:
|
if volume <= 0:
|
||||||
msg = "volume must be positive."
|
msg = "volume must be positive."
|
||||||
raise Mt5TradingError(msg)
|
raise Mt5OperationError(msg)
|
||||||
side = _normalize_order_side(order_side)
|
side = _normalize_order_side(order_side)
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
ensure_symbol_selected(client, symbol)
|
ensure_symbol_selected(client, symbol)
|
||||||
@@ -1253,7 +1316,7 @@ def place_market_order(
|
|||||||
price = extract_tick_price(tick, "ask" if side == "BUY" else "bid")
|
price = extract_tick_price(tick, "ask" if side == "BUY" else "bid")
|
||||||
if price is None:
|
if price is None:
|
||||||
msg = f"Tick price is unavailable for {symbol!r}."
|
msg = f"Tick price is unavailable for {symbol!r}."
|
||||||
raise Mt5TradingError(msg)
|
raise Mt5OperationError(msg)
|
||||||
request = {
|
request = {
|
||||||
"action": client.mt5.TRADE_ACTION_DEAL,
|
"action": client.mt5.TRADE_ACTION_DEAL,
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
@@ -1326,7 +1389,7 @@ def _filter_positions(
|
|||||||
|
|
||||||
|
|
||||||
def close_open_positions(
|
def close_open_positions(
|
||||||
client: Mt5TradingClient,
|
client: _Mt5ClientProtocol,
|
||||||
*,
|
*,
|
||||||
symbols: str | list[str] | None = None,
|
symbols: str | list[str] | None = None,
|
||||||
tickets: list[int] | None = None,
|
tickets: list[int] | None = None,
|
||||||
@@ -1358,7 +1421,7 @@ def close_open_positions(
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def _symbol_digits(client: Mt5TradingClient, symbol: str) -> int | None:
|
def _symbol_digits(client: _Mt5ClientProtocol, symbol: str) -> int | None:
|
||||||
try:
|
try:
|
||||||
raw_digits = get_symbol_snapshot(client, symbol).get("digits")
|
raw_digits = get_symbol_snapshot(client, symbol).get("digits")
|
||||||
if raw_digits is None:
|
if raw_digits is None:
|
||||||
@@ -1379,7 +1442,7 @@ def _current_stop_loss(value: object) -> float | None:
|
|||||||
|
|
||||||
|
|
||||||
def _trailing_stop_loss(
|
def _trailing_stop_loss(
|
||||||
client: Mt5TradingClient,
|
client: _Mt5ClientProtocol,
|
||||||
*,
|
*,
|
||||||
position_type: object,
|
position_type: object,
|
||||||
current_sl: float | None,
|
current_sl: float | None,
|
||||||
@@ -1402,7 +1465,7 @@ def _trailing_stop_loss(
|
|||||||
|
|
||||||
|
|
||||||
def calculate_trailing_stop_updates(
|
def calculate_trailing_stop_updates(
|
||||||
client: Mt5TradingClient,
|
client: _Mt5ClientProtocol,
|
||||||
*,
|
*,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
trailing_stop_ratio: float,
|
trailing_stop_ratio: float,
|
||||||
@@ -1447,7 +1510,7 @@ def calculate_trailing_stop_updates(
|
|||||||
|
|
||||||
|
|
||||||
def update_trailing_stop_loss_for_open_positions(
|
def update_trailing_stop_loss_for_open_positions(
|
||||||
client: Mt5TradingClient,
|
client: _Mt5ClientProtocol,
|
||||||
*,
|
*,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
trailing_stop_ratio: float,
|
trailing_stop_ratio: float,
|
||||||
@@ -1478,7 +1541,7 @@ def update_trailing_stop_loss_for_open_positions(
|
|||||||
|
|
||||||
|
|
||||||
def update_sltp_for_open_positions(
|
def update_sltp_for_open_positions(
|
||||||
client: Mt5TradingClient,
|
client: _Mt5ClientProtocol,
|
||||||
*,
|
*,
|
||||||
symbol: str | None = None,
|
symbol: str | None = None,
|
||||||
tickets: list[int] | None = None,
|
tickets: list[int] | None = None,
|
||||||
@@ -1542,7 +1605,7 @@ def update_sltp_for_open_positions(
|
|||||||
|
|
||||||
|
|
||||||
def fetch_latest_closed_rates_for_trading_client(
|
def fetch_latest_closed_rates_for_trading_client(
|
||||||
client: Mt5TradingClient,
|
client: _Mt5ClientProtocol,
|
||||||
*,
|
*,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
granularity: str,
|
granularity: str,
|
||||||
@@ -1556,7 +1619,7 @@ def fetch_latest_closed_rates_for_trading_client(
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: If ``count`` is not positive, rate data is empty or
|
ValueError: If ``count`` is not positive, rate data is empty or
|
||||||
malformed, or the ``time`` column is missing.
|
malformed, or the ``time`` column is missing.
|
||||||
Mt5TradingError: If the trading client cannot fetch rate data.
|
Mt5OperationError: If the trading client cannot fetch rate data.
|
||||||
"""
|
"""
|
||||||
if count <= 0:
|
if count <= 0:
|
||||||
msg = "count must be positive."
|
msg = "count must be positive."
|
||||||
@@ -1564,7 +1627,7 @@ def fetch_latest_closed_rates_for_trading_client(
|
|||||||
fetch_method = getattr(client, "fetch_latest_rates_as_df", None)
|
fetch_method = getattr(client, "fetch_latest_rates_as_df", None)
|
||||||
if not callable(fetch_method):
|
if not callable(fetch_method):
|
||||||
msg = "MT5 trading client cannot fetch rate data."
|
msg = "MT5 trading client cannot fetch rate data."
|
||||||
raise Mt5TradingError(msg)
|
raise Mt5OperationError(msg)
|
||||||
fetched = fetch_method(symbol, granularity, count + 1)
|
fetched = fetch_method(symbol, granularity, count + 1)
|
||||||
if not isinstance(fetched, pd.DataFrame):
|
if not isinstance(fetched, pd.DataFrame):
|
||||||
msg = (
|
msg = (
|
||||||
@@ -1627,7 +1690,7 @@ def _rate_time_to_utc(series: pd.Series, symbol: str) -> pd.DatetimeIndex:
|
|||||||
|
|
||||||
|
|
||||||
def fetch_latest_closed_rates_indexed(
|
def fetch_latest_closed_rates_indexed(
|
||||||
client: Mt5TradingClient,
|
client: _Mt5ClientProtocol,
|
||||||
*,
|
*,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
granularity: str,
|
granularity: str,
|
||||||
@@ -1683,13 +1746,13 @@ def mt5_trading_session(
|
|||||||
path: str | None = None,
|
path: str | None = None,
|
||||||
timeout: int | None = None,
|
timeout: int | None = None,
|
||||||
retry_count: int = 0,
|
retry_count: int = 0,
|
||||||
) -> Iterator[Mt5TradingClient]:
|
) -> Iterator[_Mt5ClientProtocol]:
|
||||||
"""Open a trading-capable MT5 session and always shut down safely.
|
"""Open a trading-capable MT5 session and always shut down safely.
|
||||||
|
|
||||||
Launches the MetaTrader 5 terminal using ``Mt5Config.path`` when set,
|
Launches the MetaTrader 5 terminal using ``Mt5Config.path`` when set,
|
||||||
initializes and logs in via ``initialize_and_login_mt5()``, yields a
|
initializes and logs in via ``initialize_and_login_mt5()``, yields a
|
||||||
connected :class:`~pdmt5.Mt5TradingClient`, and calls ``shutdown()`` on
|
connected client supporting required MT5 methods, and calls ``shutdown()``
|
||||||
exit even when an error is raised inside the context.
|
on exit even when an error is raised inside the context.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config: MT5 connection configuration. Defaults to an empty config that
|
config: MT5 connection configuration. Defaults to an empty config that
|
||||||
@@ -1699,11 +1762,10 @@ def mt5_trading_session(
|
|||||||
server: Optional trading server name.
|
server: Optional trading server name.
|
||||||
path: Optional terminal executable path.
|
path: Optional terminal executable path.
|
||||||
timeout: Optional connection timeout in milliseconds.
|
timeout: Optional connection timeout in milliseconds.
|
||||||
retry_count: Number of initialization retries passed to
|
retry_count: Number of initialization retries.
|
||||||
``Mt5TradingClient``.
|
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Connected ``Mt5TradingClient`` bound to the session.
|
Connected client supporting required MT5 trading methods.
|
||||||
"""
|
"""
|
||||||
client = create_trading_client(
|
client = create_trading_client(
|
||||||
config=config,
|
config=config,
|
||||||
|
|||||||
+13
-6
@@ -10,7 +10,8 @@ from pathlib import Path
|
|||||||
from typing import TYPE_CHECKING, Any, TypeGuard
|
from typing import TYPE_CHECKING, Any, TypeGuard
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from pdmt5 import COPY_TICKS_MAP, TIMEFRAME_MAP
|
from pdmt5 import COPY_TICKS_MAP as _COPY_TICKS_MAP
|
||||||
|
from pdmt5 import TIMEFRAME_MAP as _TIMEFRAME_MAP
|
||||||
from pdmt5 import parse_copy_ticks as _parse_copy_ticks
|
from pdmt5 import parse_copy_ticks as _parse_copy_ticks
|
||||||
from pdmt5 import parse_timeframe as _parse_timeframe
|
from pdmt5 import parse_timeframe as _parse_timeframe
|
||||||
|
|
||||||
@@ -23,14 +24,11 @@ if TYPE_CHECKING:
|
|||||||
# Constants
|
# Constants
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# Backward-compatible snapshot; prefer ``COPY_TICKS_MAP`` from pdmt5 directly.
|
|
||||||
TICK_FLAG_MAP: dict[str, int] = dict(COPY_TICKS_MAP)
|
|
||||||
|
|
||||||
TIMEFRAME_NAMES: tuple[str, ...] = tuple(
|
TIMEFRAME_NAMES: tuple[str, ...] = tuple(
|
||||||
name for name in TIMEFRAME_MAP if not name.startswith("TIMEFRAME_")
|
name for name in _TIMEFRAME_MAP if not name.startswith("TIMEFRAME_")
|
||||||
)
|
)
|
||||||
_TICK_FLAG_NAMES: tuple[str, ...] = tuple(
|
_TICK_FLAG_NAMES: tuple[str, ...] = tuple(
|
||||||
name for name in COPY_TICKS_MAP if not name.startswith("COPY_TICKS_")
|
name for name in _COPY_TICKS_MAP if not name.startswith("COPY_TICKS_")
|
||||||
)
|
)
|
||||||
|
|
||||||
_FORMAT_EXTENSIONS: dict[str, str] = {
|
_FORMAT_EXTENSIONS: dict[str, str] = {
|
||||||
@@ -314,6 +312,7 @@ def export_dataframe(
|
|||||||
table_name: Table name for SQLite3 output.
|
table_name: Table name for SQLite3 output.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
|
ImportError: If the parquet format is requested but pyarrow is not installed.
|
||||||
ValueError: If the output format is not supported.
|
ValueError: If the output format is not supported.
|
||||||
"""
|
"""
|
||||||
if output_format == "csv":
|
if output_format == "csv":
|
||||||
@@ -326,6 +325,14 @@ def export_dataframe(
|
|||||||
indent=2,
|
indent=2,
|
||||||
)
|
)
|
||||||
elif output_format == "parquet":
|
elif output_format == "parquet":
|
||||||
|
try:
|
||||||
|
__import__("pyarrow")
|
||||||
|
except ImportError as exc:
|
||||||
|
msg = (
|
||||||
|
"Parquet export requires the optional dependency pyarrow. "
|
||||||
|
'Install it with: pip install "mt5cli[parquet]"'
|
||||||
|
)
|
||||||
|
raise ImportError(msg) from exc
|
||||||
df.to_parquet(output_path, index=False)
|
df.to_parquet(output_path, index=False)
|
||||||
elif output_format == "sqlite3":
|
elif output_format == "sqlite3":
|
||||||
export_dataframe_to_sqlite(
|
export_dataframe_to_sqlite(
|
||||||
|
|||||||
+10
-4
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mt5cli"
|
name = "mt5cli"
|
||||||
version = "0.9.6"
|
version = "1.0.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"}]
|
||||||
@@ -9,9 +9,8 @@ license-files = ["LICENSE"]
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">= 3.11, < 3.14"
|
requires-python = ">= 3.11, < 3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pdmt5>=0.3.0",
|
"pdmt5>=1.0.0",
|
||||||
"click >= 8.1.0",
|
"click >= 8.1.0",
|
||||||
"pyarrow >= 19.0.0",
|
|
||||||
"typer >= 0.15.0",
|
"typer >= 0.15.0",
|
||||||
]
|
]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
@@ -25,6 +24,9 @@ classifiers = [
|
|||||||
"Topic :: Office/Business :: Financial :: Investment",
|
"Topic :: Office/Business :: Financial :: Investment",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
parquet = ["pyarrow >= 19.0.0"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
mt5cli = "mt5cli.cli:main"
|
mt5cli = "mt5cli.cli:main"
|
||||||
|
|
||||||
@@ -42,6 +44,7 @@ dev = [
|
|||||||
"pytest-mock >= 3.12.0",
|
"pytest-mock >= 3.12.0",
|
||||||
"pytest-cov >= 5.0.0",
|
"pytest-cov >= 5.0.0",
|
||||||
"pandas-stubs >= 2.2.3.250527",
|
"pandas-stubs >= 2.2.3.250527",
|
||||||
|
"pyarrow >= 19.0.0",
|
||||||
"mkdocs >= 1.6.1",
|
"mkdocs >= 1.6.1",
|
||||||
"mkdocs-material >= 9.7.6",
|
"mkdocs-material >= 9.7.6",
|
||||||
"mkdocstrings[python] >= 1.0.4",
|
"mkdocstrings[python] >= 1.0.4",
|
||||||
@@ -175,7 +178,10 @@ omit = [
|
|||||||
[tool.coverage.report]
|
[tool.coverage.report]
|
||||||
show_missing = true
|
show_missing = true
|
||||||
fail_under = 100
|
fail_under = 100
|
||||||
exclude_lines = ["if TYPE_CHECKING:"]
|
exclude_also = [
|
||||||
|
"if TYPE_CHECKING:",
|
||||||
|
"^\\s+\\.\\.\\.$",
|
||||||
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
|
|||||||
+103
-6
@@ -740,6 +740,66 @@ class TestCommands:
|
|||||||
assert "must be a JSON object" in normalize_cli_output(result.output)
|
assert "must be a JSON object" in normalize_cli_output(result.output)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Help text / scope tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestHelpText:
|
||||||
|
"""Tests verifying CLI help text matches the documented scope."""
|
||||||
|
|
||||||
|
def test_top_level_help_mentions_execution(self) -> None:
|
||||||
|
"""Top-level help must describe execution utilities, not export only."""
|
||||||
|
result = runner.invoke(app, ["--help"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
output = normalize_cli_output(result.output)
|
||||||
|
assert "execution" in output.lower()
|
||||||
|
|
||||||
|
def test_top_level_help_has_execution_panel(self) -> None:
|
||||||
|
"""Top-level help must show an Execution command group."""
|
||||||
|
result = runner.invoke(app, ["--help"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Execution" in result.output
|
||||||
|
|
||||||
|
def test_top_level_help_has_data_export_panel(self) -> None:
|
||||||
|
"""Top-level help must show a Data / Export command group."""
|
||||||
|
result = runner.invoke(app, ["--help"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Data / Export" in result.output
|
||||||
|
|
||||||
|
def test_order_send_help_mentions_expert_and_raw(self) -> None:
|
||||||
|
"""order-send help must communicate it is the expert raw-request path."""
|
||||||
|
result2 = runner.invoke(
|
||||||
|
app,
|
||||||
|
["-o", "out.csv", "order-send", "--help"],
|
||||||
|
)
|
||||||
|
assert result2.exit_code == 0
|
||||||
|
output = normalize_cli_output(result2.output)
|
||||||
|
assert "raw" in output.lower()
|
||||||
|
assert "expert" in output.lower()
|
||||||
|
|
||||||
|
def test_order_send_help_mentions_live_execution(self) -> None:
|
||||||
|
"""order-send help must warn about live execution."""
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["-o", "out.csv", "order-send", "--help"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
output = normalize_cli_output(result.output)
|
||||||
|
assert "live" in output.lower()
|
||||||
|
|
||||||
|
def test_close_positions_help_mentions_dry_run_and_yes(self) -> None:
|
||||||
|
"""close-positions help must document both safety gates."""
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["-o", "out.csv", "close-positions", "--help"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
output = normalize_cli_output(result.output)
|
||||||
|
assert "--dry-run" in output
|
||||||
|
assert "--yes" in output
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# close-positions command
|
# close-positions command
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1238,12 +1298,12 @@ class TestCollectHistory:
|
|||||||
"""Create a mocked Mt5DataClient with history-style DataFrames."""
|
"""Create a mocked Mt5DataClient with history-style DataFrames."""
|
||||||
return _build_history_client(mocker)
|
return _build_history_client(mocker)
|
||||||
|
|
||||||
def test_collect_history_writes_all_tables(
|
def test_collect_history_writes_default_tables(
|
||||||
self,
|
self,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
history_client: MagicMock,
|
history_client: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test that collect-history writes rates, ticks, and history tables."""
|
"""Test that collect-history default excludes ticks."""
|
||||||
output = tmp_path / "history.db"
|
output = tmp_path / "history.db"
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
app,
|
app,
|
||||||
@@ -1263,8 +1323,42 @@ class TestCollectHistory:
|
|||||||
)
|
)
|
||||||
assert result.exit_code == 0, result.output
|
assert result.exit_code == 0, result.output
|
||||||
assert history_client.copy_rates_range_as_df.call_count == 2
|
assert history_client.copy_rates_range_as_df.call_count == 2
|
||||||
assert history_client.copy_ticks_range_as_df.call_count == 2
|
assert history_client.copy_ticks_range_as_df.call_count == 0
|
||||||
history_client.copy_ticks_range_as_df.assert_any_call(
|
with sqlite3.connect(output) as conn:
|
||||||
|
tables = {
|
||||||
|
row[0]
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table'",
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
|
assert {"rates", "history_orders", "history_deals"} <= tables
|
||||||
|
assert "ticks" not in tables
|
||||||
|
|
||||||
|
def test_collect_history_explicit_ticks_dataset(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
history_client: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test that --dataset ticks writes the ticks table with the correct flags."""
|
||||||
|
output = tmp_path / "history.db"
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"-o",
|
||||||
|
str(output),
|
||||||
|
"collect-history",
|
||||||
|
"--symbol",
|
||||||
|
"EURUSD",
|
||||||
|
"--date-from",
|
||||||
|
"2024-01-01",
|
||||||
|
"--date-to",
|
||||||
|
"2024-02-01",
|
||||||
|
"--dataset",
|
||||||
|
"ticks",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
history_client.copy_ticks_range_as_df.assert_called_once_with(
|
||||||
symbol="EURUSD",
|
symbol="EURUSD",
|
||||||
date_from=datetime(2024, 1, 1, tzinfo=UTC),
|
date_from=datetime(2024, 1, 1, tzinfo=UTC),
|
||||||
date_to=datetime(2024, 2, 1, tzinfo=UTC),
|
date_to=datetime(2024, 2, 1, tzinfo=UTC),
|
||||||
@@ -1277,7 +1371,8 @@ class TestCollectHistory:
|
|||||||
"SELECT name FROM sqlite_master WHERE type='table'",
|
"SELECT name FROM sqlite_master WHERE type='table'",
|
||||||
).fetchall()
|
).fetchall()
|
||||||
}
|
}
|
||||||
assert {"rates", "ticks", "history_orders", "history_deals"} <= tables
|
assert "ticks" in tables
|
||||||
|
assert "rates" not in tables
|
||||||
|
|
||||||
def test_collect_history_history_fetched_per_symbol(
|
def test_collect_history_history_fetched_per_symbol(
|
||||||
self,
|
self,
|
||||||
@@ -1460,7 +1555,7 @@ class TestCollectHistory:
|
|||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
history_client: MagicMock,
|
history_client: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test that --flags defaults to ALL for ticks."""
|
"""Test that --flags defaults to ALL when --dataset ticks is explicit."""
|
||||||
output = tmp_path / "history.db"
|
output = tmp_path / "history.db"
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
app,
|
app,
|
||||||
@@ -1474,6 +1569,8 @@ class TestCollectHistory:
|
|||||||
"2024-01-01",
|
"2024-01-01",
|
||||||
"--date-to",
|
"--date-to",
|
||||||
"2024-02-01",
|
"2024-02-01",
|
||||||
|
"--dataset",
|
||||||
|
"ticks",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
assert result.exit_code == 0, result.output
|
assert result.exit_code == 0, result.output
|
||||||
|
|||||||
+138
-111
@@ -2,13 +2,16 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import importlib
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from importlib.metadata import requires
|
||||||
from typing import get_type_hints
|
from typing import TYPE_CHECKING, get_type_hints
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import pytest
|
import pytest
|
||||||
from pdmt5 import Mt5RuntimeError, Mt5TradingError
|
from pdmt5 import Mt5RuntimeError, Mt5TradingError
|
||||||
@@ -16,15 +19,8 @@ from pytest_mock import MockerFixture # noqa: TC002
|
|||||||
|
|
||||||
import mt5cli
|
import mt5cli
|
||||||
from mt5cli import (
|
from mt5cli import (
|
||||||
DEDUP_KEYS,
|
|
||||||
PUBLIC_EXPORT_TIERS,
|
|
||||||
REQUIRED_COLUMNS,
|
|
||||||
SECONDARY_PUBLIC_EXPORTS,
|
|
||||||
STABLE_SDK_EXPORTS,
|
STABLE_SDK_EXPORTS,
|
||||||
TIME_COLUMNS,
|
|
||||||
AccountSpec,
|
AccountSpec,
|
||||||
DataKind,
|
|
||||||
Dataset,
|
|
||||||
ExecutionStatus,
|
ExecutionStatus,
|
||||||
MarginVolume,
|
MarginVolume,
|
||||||
MT5Client,
|
MT5Client,
|
||||||
@@ -43,40 +39,56 @@ from mt5cli import (
|
|||||||
calculate_projected_margin_ratio,
|
calculate_projected_margin_ratio,
|
||||||
calculate_symbol_group_margin_ratio,
|
calculate_symbol_group_margin_ratio,
|
||||||
calculate_trailing_stop_updates,
|
calculate_trailing_stop_updates,
|
||||||
call_with_normalized_errors,
|
|
||||||
detect_format,
|
|
||||||
drop_forming_rate_bar,
|
drop_forming_rate_bar,
|
||||||
ensure_symbol_selected,
|
ensure_symbol_selected,
|
||||||
ensure_utc,
|
|
||||||
export_dataframe,
|
|
||||||
export_dataframe_to_sqlite,
|
|
||||||
extract_tick_price,
|
extract_tick_price,
|
||||||
fetch_latest_closed_rates,
|
fetch_latest_closed_rates,
|
||||||
fetch_latest_closed_rates_for_trading_client,
|
fetch_latest_closed_rates_for_trading_client,
|
||||||
fetch_latest_closed_rates_indexed,
|
fetch_latest_closed_rates_indexed,
|
||||||
granularity_name,
|
|
||||||
is_recoverable_mt5_error,
|
|
||||||
load_rate_data,
|
|
||||||
load_rate_series_from_sqlite,
|
load_rate_series_from_sqlite,
|
||||||
mt5_session,
|
mt5_session,
|
||||||
mt5_trading_session,
|
mt5_trading_session,
|
||||||
normalize_dataframe,
|
|
||||||
normalize_mt5_exception,
|
|
||||||
normalize_order_volume,
|
normalize_order_volume,
|
||||||
|
place_market_order,
|
||||||
|
resolve_account_spec,
|
||||||
|
resolve_account_specs,
|
||||||
|
)
|
||||||
|
from mt5cli.converters import (
|
||||||
|
ensure_utc,
|
||||||
|
granularity_name,
|
||||||
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,
|
from mt5cli.exceptions import (
|
||||||
|
call_with_normalized_errors,
|
||||||
|
is_recoverable_mt5_error,
|
||||||
|
normalize_mt5_exception,
|
||||||
|
)
|
||||||
|
from mt5cli.history import (
|
||||||
|
create_rate_compatibility_views,
|
||||||
|
load_rate_data,
|
||||||
resolve_rate_view_name,
|
resolve_rate_view_name,
|
||||||
|
)
|
||||||
|
from mt5cli.retry import retry_with_backoff
|
||||||
|
from mt5cli.schemas import (
|
||||||
|
DEDUP_KEYS,
|
||||||
|
REQUIRED_COLUMNS,
|
||||||
|
TIME_COLUMNS,
|
||||||
|
DataKind,
|
||||||
|
ensure_utc_columns,
|
||||||
|
normalize_dataframe,
|
||||||
|
normalize_time_columns,
|
||||||
schema_columns,
|
schema_columns,
|
||||||
validate_schema,
|
validate_schema,
|
||||||
)
|
)
|
||||||
from mt5cli.history import create_rate_compatibility_views
|
from mt5cli.utils import (
|
||||||
from mt5cli.retry import retry_with_backoff
|
Dataset,
|
||||||
from mt5cli.schemas import ensure_utc_columns, normalize_time_columns
|
detect_format,
|
||||||
|
export_dataframe,
|
||||||
|
export_dataframe_to_sqlite,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _sample_frame(kind: DataKind) -> pd.DataFrame:
|
def _sample_frame(kind: DataKind) -> pd.DataFrame:
|
||||||
@@ -539,6 +551,12 @@ def test_storage_export_round_trip_sqlite(tmp_path: Path) -> None:
|
|||||||
assert count == 1
|
assert count == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_storage_module_does_not_exist() -> None:
|
||||||
|
"""mt5cli.storage re-export module has been removed."""
|
||||||
|
with pytest.raises(ModuleNotFoundError):
|
||||||
|
importlib.import_module("mt5cli.storage")
|
||||||
|
|
||||||
|
|
||||||
class TestStableSdkContract:
|
class TestStableSdkContract:
|
||||||
"""Tests for the documented stable downstream SDK contract."""
|
"""Tests for the documented stable downstream SDK contract."""
|
||||||
|
|
||||||
@@ -547,51 +565,19 @@ class TestStableSdkContract:
|
|||||||
missing = sorted(STABLE_SDK_EXPORTS - set(mt5cli.__all__))
|
missing = sorted(STABLE_SDK_EXPORTS - set(mt5cli.__all__))
|
||||||
assert not missing, f"STABLE_SDK_EXPORTS missing from __all__: {missing}"
|
assert not missing, f"STABLE_SDK_EXPORTS missing from __all__: {missing}"
|
||||||
|
|
||||||
def test_public_export_tiers_are_disjoint_and_complete(self) -> None:
|
def test_stable_exports_cover_root_api(self) -> None:
|
||||||
"""Documented public tiers do not overlap and classify root exports."""
|
"""STABLE_SDK_EXPORTS classifies every package-root symbol."""
|
||||||
assert PUBLIC_EXPORT_TIERS == {
|
tier_metadata = {"STABLE_SDK_EXPORTS"}
|
||||||
"stable": STABLE_SDK_EXPORTS,
|
|
||||||
"secondary": SECONDARY_PUBLIC_EXPORTS,
|
|
||||||
}
|
|
||||||
assert not (STABLE_SDK_EXPORTS & SECONDARY_PUBLIC_EXPORTS)
|
|
||||||
tiered_exports = STABLE_SDK_EXPORTS | SECONDARY_PUBLIC_EXPORTS
|
|
||||||
root_exports = set(mt5cli.__all__)
|
root_exports = set(mt5cli.__all__)
|
||||||
|
|
||||||
missing_from_root = sorted(tiered_exports - root_exports)
|
missing_from_root = sorted(STABLE_SDK_EXPORTS - root_exports)
|
||||||
assert not missing_from_root, (
|
assert not missing_from_root, (
|
||||||
f"Tiered exports missing from __all__: {missing_from_root}"
|
f"STABLE_SDK_EXPORTS missing from __all__: {missing_from_root}"
|
||||||
)
|
)
|
||||||
|
|
||||||
tier_metadata_exports = {
|
unclassified = sorted(root_exports - STABLE_SDK_EXPORTS - tier_metadata)
|
||||||
"PUBLIC_EXPORT_TIERS",
|
assert not unclassified, (
|
||||||
"SECONDARY_PUBLIC_EXPORTS",
|
f"Root exports not in STABLE_SDK_EXPORTS: {unclassified}"
|
||||||
"STABLE_SDK_EXPORTS",
|
|
||||||
}
|
|
||||||
unclassified_root_exports = sorted(
|
|
||||||
root_exports - tiered_exports - tier_metadata_exports,
|
|
||||||
)
|
|
||||||
assert not unclassified_root_exports, (
|
|
||||||
f"Root exports missing from public API tiers: {unclassified_root_exports}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_stable_docs_do_not_document_nonstable_exports(self) -> None:
|
|
||||||
"""Stable docs do not promote secondary root exports."""
|
|
||||||
docs_path = Path("docs/api/public-contract.md")
|
|
||||||
docs = docs_path.read_text(encoding="utf-8")
|
|
||||||
stable_section = docs.split("## Stable downstream SDK API", maxsplit=1)[
|
|
||||||
1
|
|
||||||
].split(
|
|
||||||
"## Secondary public exports",
|
|
||||||
maxsplit=1,
|
|
||||||
)[0]
|
|
||||||
documented_symbols = set(
|
|
||||||
re.findall(r"`([A-Za-z_][A-Za-z0-9_]*)`", stable_section)
|
|
||||||
)
|
|
||||||
nonstable_exports = SECONDARY_PUBLIC_EXPORTS
|
|
||||||
|
|
||||||
wrongly_stable = sorted(documented_symbols & nonstable_exports)
|
|
||||||
assert not wrongly_stable, (
|
|
||||||
f"Non-stable exports documented in stable section: {wrongly_stable}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@pytest.mark.parametrize("name", sorted(STABLE_SDK_EXPORTS))
|
@pytest.mark.parametrize("name", sorted(STABLE_SDK_EXPORTS))
|
||||||
@@ -599,17 +585,6 @@ class TestStableSdkContract:
|
|||||||
"""Stable SDK names resolve through ``from mt5cli import ...``."""
|
"""Stable SDK names resolve through ``from mt5cli import ...``."""
|
||||||
assert hasattr(mt5cli, name), f"{name!r} missing from mt5cli package root"
|
assert hasattr(mt5cli, name), f"{name!r} missing from mt5cli package root"
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"name",
|
|
||||||
sorted(SECONDARY_PUBLIC_EXPORTS),
|
|
||||||
)
|
|
||||||
def test_secondary_exports_are_importable(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
) -> None:
|
|
||||||
"""Non-stable public names remain available from the package root."""
|
|
||||||
assert hasattr(mt5cli, name), f"{name!r} missing from mt5cli package root"
|
|
||||||
|
|
||||||
def test_drop_forming_rate_bar_from_package_root(self) -> None:
|
def test_drop_forming_rate_bar_from_package_root(self) -> None:
|
||||||
"""Closed-bar trimming is available from the stable package surface."""
|
"""Closed-bar trimming is available from the stable package surface."""
|
||||||
frame = pd.DataFrame({"time": [1, 2, 3], "close": [1.0, 1.1, 1.2]})
|
frame = pd.DataFrame({"time": [1, 2, 3], "close": [1.0, 1.1, 1.2]})
|
||||||
@@ -683,38 +658,6 @@ class TestStableSdkContract:
|
|||||||
assert callable(calculate_projected_margin_ratio)
|
assert callable(calculate_projected_margin_ratio)
|
||||||
assert callable(calculate_symbol_group_margin_ratio)
|
assert callable(calculate_symbol_group_margin_ratio)
|
||||||
|
|
||||||
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(
|
def test_load_rate_series_from_sqlite_requires_managed_views(
|
||||||
self,
|
self,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
@@ -762,7 +705,7 @@ class TestStableSdkContract:
|
|||||||
"""Trading session helper initializes and always shuts down."""
|
"""Trading session helper initializes and always shuts down."""
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"mt5cli.trading.Mt5TradingClient",
|
"mt5cli.trading.Mt5DataClient",
|
||||||
return_value=mock_client,
|
return_value=mock_client,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -791,7 +734,7 @@ class TestStableSdkContract:
|
|||||||
"""Trading session helper shuts down even when the body raises."""
|
"""Trading session helper shuts down even when the body raises."""
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"mt5cli.trading.Mt5TradingClient",
|
"mt5cli.trading.Mt5DataClient",
|
||||||
return_value=mock_client,
|
return_value=mock_client,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -832,3 +775,87 @@ class TestStableSdkContract:
|
|||||||
assert result.index.tz is not None
|
assert result.index.tz is not None
|
||||||
assert "time" not in result.columns
|
assert "time" not in result.columns
|
||||||
assert "close" in result.columns
|
assert "close" in result.columns
|
||||||
|
|
||||||
|
def test_rate_view_helpers_in_history_module(self, tmp_path: Path) -> None:
|
||||||
|
"""Rate view helpers are available from mt5cli.history."""
|
||||||
|
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_in_history_module(self, tmp_path: Path) -> None:
|
||||||
|
"""SQLite rate loading normalizes timestamps through mt5cli.history."""
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"name",
|
||||||
|
[
|
||||||
|
"Mt5Config",
|
||||||
|
"Mt5RuntimeError",
|
||||||
|
"Mt5TradingClient",
|
||||||
|
"Mt5TradingError",
|
||||||
|
"TICK_FLAG_MAP",
|
||||||
|
"TIMEFRAME_MAP",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_pdmt5_pass_through_names_removed_from_public_contract(name: str) -> None:
|
||||||
|
"""Removed pdmt5 pass-through names are not part of the public contract."""
|
||||||
|
assert name not in STABLE_SDK_EXPORTS, (
|
||||||
|
f"{name!r} should not be in STABLE_SDK_EXPORTS"
|
||||||
|
)
|
||||||
|
assert name not in mt5cli.__all__, f"{name!r} should not be in mt5cli.__all__"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mt5cli_does_not_import_high_level_trading_symbols() -> None:
|
||||||
|
"""mt5cli doesn't import Mt5TradingClient or Mt5TradingError at module level."""
|
||||||
|
trading_module = importlib.import_module("mt5cli.trading")
|
||||||
|
module_dict = vars(trading_module)
|
||||||
|
assert "Mt5TradingClient" not in module_dict, (
|
||||||
|
"mt5cli.trading should not import Mt5TradingClient at module level"
|
||||||
|
)
|
||||||
|
assert "Mt5TradingError" not in module_dict, (
|
||||||
|
"mt5cli.trading should not import Mt5TradingError at module level"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Packaging metadata
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_parquet_extra_declares_pyarrow() -> None:
|
||||||
|
"""Package metadata lists pyarrow under the parquet optional extra."""
|
||||||
|
reqs = requires("mt5cli") or []
|
||||||
|
parquet_reqs = [r for r in reqs if "pyarrow" in r and "parquet" in r]
|
||||||
|
assert parquet_reqs, "pyarrow not found in parquet optional extra"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pyarrow_not_in_core_dependencies() -> None:
|
||||||
|
"""Pyarrow is not a core dependency; it belongs only in the parquet extra."""
|
||||||
|
reqs = requires("mt5cli") or []
|
||||||
|
core_reqs = [r for r in reqs if "extra ==" not in r]
|
||||||
|
assert not any("pyarrow" in r for r in core_reqs), (
|
||||||
|
"pyarrow should not appear in core dependencies"
|
||||||
|
)
|
||||||
|
|||||||
+19
-3
@@ -15,8 +15,11 @@ from pytest_mock import MockerFixture # noqa: TC002
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pdmt5 import TIMEFRAME_MAP
|
||||||
|
|
||||||
from mt5cli import history
|
from mt5cli import history
|
||||||
from mt5cli.history import (
|
from mt5cli.history import (
|
||||||
|
DEFAULT_HISTORY_DATASETS,
|
||||||
DEFAULT_HISTORY_TIMEFRAMES,
|
DEFAULT_HISTORY_TIMEFRAMES,
|
||||||
DedupScope,
|
DedupScope,
|
||||||
RateTarget,
|
RateTarget,
|
||||||
@@ -58,7 +61,7 @@ from mt5cli.history import (
|
|||||||
write_rates_dataset,
|
write_rates_dataset,
|
||||||
write_streamed_frame,
|
write_streamed_frame,
|
||||||
)
|
)
|
||||||
from mt5cli.utils import TIMEFRAME_MAP, Dataset, IfExists
|
from mt5cli.utils import Dataset, IfExists
|
||||||
|
|
||||||
|
|
||||||
class TestResolveRateViewName:
|
class TestResolveRateViewName:
|
||||||
@@ -545,10 +548,23 @@ class TestResolveHistorySettings:
|
|||||||
"""Tests for history dataset and timeframe resolution."""
|
"""Tests for history dataset and timeframe resolution."""
|
||||||
|
|
||||||
def test_resolve_history_datasets_defaults_and_empty(self) -> None:
|
def test_resolve_history_datasets_defaults_and_empty(self) -> None:
|
||||||
"""Test dataset resolution distinguishes None from empty selection."""
|
"""Test dataset resolution excludes ticks by default."""
|
||||||
assert resolve_history_datasets(None) == set(Dataset)
|
resolved = resolve_history_datasets(None)
|
||||||
|
assert resolved == set(DEFAULT_HISTORY_DATASETS)
|
||||||
|
assert Dataset.ticks not in resolved
|
||||||
|
assert {
|
||||||
|
Dataset.rates,
|
||||||
|
Dataset.history_orders,
|
||||||
|
Dataset.history_deals,
|
||||||
|
} == resolved
|
||||||
assert resolve_history_datasets(set()) == set()
|
assert resolve_history_datasets(set()) == set()
|
||||||
|
|
||||||
|
def test_resolve_history_datasets_explicit_ticks(self) -> None:
|
||||||
|
"""Test that explicit ticks selection is honored."""
|
||||||
|
assert resolve_history_datasets({Dataset.ticks}) == {Dataset.ticks}
|
||||||
|
all_ds = resolve_history_datasets(set(Dataset))
|
||||||
|
assert Dataset.ticks in all_ds
|
||||||
|
|
||||||
def test_resolve_history_timeframes_defaults(self) -> None:
|
def test_resolve_history_timeframes_defaults(self) -> None:
|
||||||
"""Test default timeframes include all fixed MT5 values."""
|
"""Test default timeframes include all fixed MT5 values."""
|
||||||
resolved = resolve_history_timeframes(None)
|
resolved = resolve_history_timeframes(None)
|
||||||
|
|||||||
+65
-4
@@ -618,12 +618,12 @@ class TestCollectHistory:
|
|||||||
"""Create a mocked Mt5DataClient with history-style DataFrames."""
|
"""Create a mocked Mt5DataClient with history-style DataFrames."""
|
||||||
return _build_history_client(mocker)
|
return _build_history_client(mocker)
|
||||||
|
|
||||||
def test_collect_history_writes_all_tables(
|
def test_collect_history_writes_default_tables(
|
||||||
self,
|
self,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
history_client: MagicMock,
|
history_client: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test that collect_history writes rates, ticks, and history tables."""
|
"""Test that collect_history default excludes ticks."""
|
||||||
output = tmp_path / "history.db"
|
output = tmp_path / "history.db"
|
||||||
collect_history(
|
collect_history(
|
||||||
output,
|
output,
|
||||||
@@ -632,7 +632,7 @@ class TestCollectHistory:
|
|||||||
"2024-02-01",
|
"2024-02-01",
|
||||||
)
|
)
|
||||||
assert history_client.copy_rates_range_as_df.call_count == 2
|
assert history_client.copy_rates_range_as_df.call_count == 2
|
||||||
assert history_client.copy_ticks_range_as_df.call_count == 2
|
assert history_client.copy_ticks_range_as_df.call_count == 0
|
||||||
with sqlite3.connect(output) as conn:
|
with sqlite3.connect(output) as conn:
|
||||||
tables = {
|
tables = {
|
||||||
row[0]
|
row[0]
|
||||||
@@ -640,7 +640,34 @@ class TestCollectHistory:
|
|||||||
"SELECT name FROM sqlite_master WHERE type='table'",
|
"SELECT name FROM sqlite_master WHERE type='table'",
|
||||||
).fetchall()
|
).fetchall()
|
||||||
}
|
}
|
||||||
assert {"rates", "ticks", "history_orders", "history_deals"} <= tables
|
assert {"rates", "history_orders", "history_deals"} <= tables
|
||||||
|
assert "ticks" not in tables
|
||||||
|
|
||||||
|
def test_collect_history_explicit_ticks_dataset(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
history_client: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test that explicit datasets={Dataset.ticks} writes the ticks table."""
|
||||||
|
output = tmp_path / "history.db"
|
||||||
|
collect_history(
|
||||||
|
output,
|
||||||
|
["EURUSD", "GBPUSD"],
|
||||||
|
"2024-01-01",
|
||||||
|
"2024-02-01",
|
||||||
|
datasets={Dataset.ticks},
|
||||||
|
)
|
||||||
|
assert history_client.copy_ticks_range_as_df.call_count == 2
|
||||||
|
assert history_client.copy_rates_range_as_df.call_count == 0
|
||||||
|
with sqlite3.connect(output) as conn:
|
||||||
|
tables = {
|
||||||
|
row[0]
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table'",
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
|
assert "ticks" in tables
|
||||||
|
assert "rates" not in tables
|
||||||
|
|
||||||
def test_collect_history_with_views(
|
def test_collect_history_with_views(
|
||||||
self,
|
self,
|
||||||
@@ -1066,6 +1093,40 @@ class TestUpdateHistory:
|
|||||||
after = datetime.now(UTC)
|
after = datetime.now(UTC)
|
||||||
assert before <= captured["end"] <= after
|
assert before <= captured["end"] <= after
|
||||||
|
|
||||||
|
def test_update_history_default_datasets_exclude_ticks(
|
||||||
|
self,
|
||||||
|
connected_client: MagicMock,
|
||||||
|
mocker: MockerFixture,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test update_history with datasets=None does not collect ticks."""
|
||||||
|
datasets_written: list[set[Dataset]] = []
|
||||||
|
|
||||||
|
def capture(
|
||||||
|
*args: object,
|
||||||
|
**_kwargs: object,
|
||||||
|
) -> tuple[set[Dataset], dict[Dataset, set[str]]]:
|
||||||
|
datasets_written.append(args[3]) # type: ignore[arg-type]
|
||||||
|
return set(), {}
|
||||||
|
|
||||||
|
mocker.patch("mt5cli.sdk.write_incremental_datasets", side_effect=capture)
|
||||||
|
update_history(
|
||||||
|
client=connected_client,
|
||||||
|
output=tmp_path / "default-datasets.db",
|
||||||
|
symbols=["EURUSD"],
|
||||||
|
datasets=None,
|
||||||
|
timeframes=["M1"],
|
||||||
|
lookback_hours=1,
|
||||||
|
date_to=datetime(2024, 1, 1, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
assert len(datasets_written) == 1
|
||||||
|
assert Dataset.ticks not in datasets_written[0]
|
||||||
|
assert {
|
||||||
|
Dataset.rates,
|
||||||
|
Dataset.history_orders,
|
||||||
|
Dataset.history_deals,
|
||||||
|
} == datasets_written[0]
|
||||||
|
|
||||||
|
|
||||||
class TestRecentTicks:
|
class TestRecentTicks:
|
||||||
"""Tests for recent_ticks helper."""
|
"""Tests for recent_ticks helper."""
|
||||||
|
|||||||
+44
-43
@@ -14,6 +14,7 @@ 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.exceptions import Mt5OperationError
|
||||||
from mt5cli.sdk import build_config
|
from mt5cli.sdk import build_config
|
||||||
from mt5cli.trading import (
|
from mt5cli.trading import (
|
||||||
MarginVolume,
|
MarginVolume,
|
||||||
@@ -434,7 +435,7 @@ class TestDetermineOrderLimits:
|
|||||||
client = MagicMock()
|
client = MagicMock()
|
||||||
client.symbol_info_tick_as_dict.return_value = {"ask": None, "bid": 1.1}
|
client.symbol_info_tick_as_dict.return_value = {"ask": None, "bid": 1.1}
|
||||||
|
|
||||||
with pytest.raises(Mt5TradingError, match="Tick price is unavailable"):
|
with pytest.raises(Mt5OperationError, match="Tick price is unavailable"):
|
||||||
determine_order_limits(client, "EURUSD", "long")
|
determine_order_limits(client, "EURUSD", "long")
|
||||||
|
|
||||||
def test_accepts_numeric_string_entry(self) -> None:
|
def test_accepts_numeric_string_entry(self) -> None:
|
||||||
@@ -483,7 +484,7 @@ class TestDetermineOrderLimits:
|
|||||||
client = MagicMock()
|
client = MagicMock()
|
||||||
client.symbol_info_tick_as_dict.return_value = tick
|
client.symbol_info_tick_as_dict.return_value = tick
|
||||||
|
|
||||||
with pytest.raises(Mt5TradingError, match="Tick price is unavailable"):
|
with pytest.raises(Mt5OperationError, match="Tick price is unavailable"):
|
||||||
determine_order_limits(client, "EURUSD", side)
|
determine_order_limits(client, "EURUSD", side)
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -512,7 +513,7 @@ class TestDetermineOrderLimits:
|
|||||||
"point": 0.0001,
|
"point": 0.0001,
|
||||||
}
|
}
|
||||||
|
|
||||||
with pytest.raises(Mt5TradingError, match=match):
|
with pytest.raises(Mt5OperationError, match=match):
|
||||||
determine_order_limits(client, "EURUSD", side, **{kwarg: 0.0001})
|
determine_order_limits(client, "EURUSD", side, **{kwarg: 0.0001})
|
||||||
|
|
||||||
def test_accepts_stop_loss_exactly_at_minimum_stop_distance(self) -> None:
|
def test_accepts_stop_loss_exactly_at_minimum_stop_distance(self) -> None:
|
||||||
@@ -619,7 +620,7 @@ class TestDetermineOrderLimits:
|
|||||||
client.symbol_select.return_value = False
|
client.symbol_select.return_value = False
|
||||||
client.last_error.return_value = (1, "not found")
|
client.last_error.return_value = (1, "not found")
|
||||||
|
|
||||||
with pytest.raises(Mt5TradingError, match="Failed to select symbol 'EURUSD'"):
|
with pytest.raises(Mt5OperationError, match="Failed to select symbol 'EURUSD'"):
|
||||||
ensure_symbol_selected(client, "EURUSD")
|
ensure_symbol_selected(client, "EURUSD")
|
||||||
|
|
||||||
def test_raises_when_symbol_select_is_unavailable(self) -> None:
|
def test_raises_when_symbol_select_is_unavailable(self) -> None:
|
||||||
@@ -629,7 +630,7 @@ class TestDetermineOrderLimits:
|
|||||||
del client.symbol_select
|
del client.symbol_select
|
||||||
|
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
Mt5TradingError,
|
Mt5OperationError,
|
||||||
match="missing required method: symbol_select",
|
match="missing required method: symbol_select",
|
||||||
):
|
):
|
||||||
ensure_symbol_selected(client, "EURUSD")
|
ensure_symbol_selected(client, "EURUSD")
|
||||||
@@ -645,7 +646,7 @@ class TestMt5TradingSession:
|
|||||||
"""Test mt5_trading_session connects, yields a client, and shuts down."""
|
"""Test mt5_trading_session connects, yields a client, and shuts down."""
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
trading_client = mocker.patch(
|
trading_client = mocker.patch(
|
||||||
"mt5cli.trading.Mt5TradingClient",
|
"mt5cli.trading.Mt5DataClient",
|
||||||
return_value=mock_client,
|
return_value=mock_client,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -668,10 +669,10 @@ class TestCreateTradingClient:
|
|||||||
"""Tests for create_trading_client."""
|
"""Tests for create_trading_client."""
|
||||||
|
|
||||||
def test_initializes_with_keyword_config(self, mocker: MockerFixture) -> None:
|
def test_initializes_with_keyword_config(self, mocker: MockerFixture) -> None:
|
||||||
"""Test keyword configuration is forwarded to Mt5TradingClient."""
|
"""Test keyword configuration is forwarded to Mt5DataClient."""
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
trading_client = mocker.patch(
|
trading_client = mocker.patch(
|
||||||
"mt5cli.trading.Mt5TradingClient",
|
"mt5cli.trading.Mt5DataClient",
|
||||||
return_value=mock_client,
|
return_value=mock_client,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -695,7 +696,7 @@ class TestCreateTradingClient:
|
|||||||
def test_empty_login_string_is_unset(self, mocker: MockerFixture) -> None:
|
def test_empty_login_string_is_unset(self, mocker: MockerFixture) -> None:
|
||||||
"""Test empty login strings are treated as None."""
|
"""Test empty login strings are treated as None."""
|
||||||
trading_client = mocker.patch(
|
trading_client = mocker.patch(
|
||||||
"mt5cli.trading.Mt5TradingClient",
|
"mt5cli.trading.Mt5DataClient",
|
||||||
return_value=MagicMock(),
|
return_value=MagicMock(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -708,7 +709,7 @@ class TestCreateTradingClient:
|
|||||||
"""Test failed initialization shuts the client down."""
|
"""Test failed initialization shuts the client down."""
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_client.initialize_and_login_mt5.side_effect = Mt5RuntimeError("boom")
|
mock_client.initialize_and_login_mt5.side_effect = Mt5RuntimeError("boom")
|
||||||
mocker.patch("mt5cli.trading.Mt5TradingClient", return_value=mock_client)
|
mocker.patch("mt5cli.trading.Mt5DataClient", return_value=mock_client)
|
||||||
|
|
||||||
with pytest.raises(Mt5RuntimeError, match="boom"):
|
with pytest.raises(Mt5RuntimeError, match="boom"):
|
||||||
create_trading_client()
|
create_trading_client()
|
||||||
@@ -791,7 +792,7 @@ class TestSnapshotsAndState:
|
|||||||
client = MagicMock()
|
client = MagicMock()
|
||||||
client.symbol_info_tick_as_dict.return_value = {"bid": None, "ask": 1.0}
|
client.symbol_info_tick_as_dict.return_value = {"bid": None, "ask": 1.0}
|
||||||
|
|
||||||
with pytest.raises(Mt5TradingError):
|
with pytest.raises(Mt5OperationError):
|
||||||
calculate_spread_ratio(client, "EURUSD")
|
calculate_spread_ratio(client, "EURUSD")
|
||||||
|
|
||||||
def test_calculate_spread_ratio_rejects_non_positive_tick(self) -> None:
|
def test_calculate_spread_ratio_rejects_non_positive_tick(self) -> None:
|
||||||
@@ -799,7 +800,7 @@ class TestSnapshotsAndState:
|
|||||||
client = MagicMock()
|
client = MagicMock()
|
||||||
client.symbol_info_tick_as_dict.return_value = {"bid": 0.0, "ask": 1.0}
|
client.symbol_info_tick_as_dict.return_value = {"bid": 0.0, "ask": 1.0}
|
||||||
|
|
||||||
with pytest.raises(Mt5TradingError):
|
with pytest.raises(Mt5OperationError):
|
||||||
calculate_spread_ratio(client, "EURUSD")
|
calculate_spread_ratio(client, "EURUSD")
|
||||||
|
|
||||||
def test_calculate_spread_ratio_accepts_numeric_string_tick(self) -> None:
|
def test_calculate_spread_ratio_accepts_numeric_string_tick(self) -> None:
|
||||||
@@ -843,7 +844,7 @@ class TestSnapshotsAndState:
|
|||||||
client = MagicMock()
|
client = MagicMock()
|
||||||
client.symbol_info_tick_as_dict.return_value = tick
|
client.symbol_info_tick_as_dict.return_value = tick
|
||||||
|
|
||||||
with pytest.raises(Mt5TradingError, match="Tick bid/ask is unavailable"):
|
with pytest.raises(Mt5OperationError, match="Tick bid/ask is unavailable"):
|
||||||
calculate_spread_ratio(client, "EURUSD")
|
calculate_spread_ratio(client, "EURUSD")
|
||||||
|
|
||||||
|
|
||||||
@@ -1033,7 +1034,7 @@ class TestEstimateOrderMargin:
|
|||||||
"""Test non-positive volume raises Mt5TradingError."""
|
"""Test non-positive volume raises Mt5TradingError."""
|
||||||
client = _mock_trade_client()
|
client = _mock_trade_client()
|
||||||
|
|
||||||
with pytest.raises(Mt5TradingError, match="positive finite number"):
|
with pytest.raises(Mt5OperationError, match="positive finite number"):
|
||||||
estimate_order_margin(client, "EURUSD", "BUY", 0.0)
|
estimate_order_margin(client, "EURUSD", "BUY", 0.0)
|
||||||
|
|
||||||
@pytest.mark.parametrize("volume", [float("nan"), float("inf")], ids=["nan", "inf"])
|
@pytest.mark.parametrize("volume", [float("nan"), float("inf")], ids=["nan", "inf"])
|
||||||
@@ -1041,7 +1042,7 @@ class TestEstimateOrderMargin:
|
|||||||
"""Test NaN or infinite volume raises Mt5TradingError without broker calls."""
|
"""Test NaN or infinite volume raises Mt5TradingError without broker calls."""
|
||||||
client = _mock_trade_client()
|
client = _mock_trade_client()
|
||||||
|
|
||||||
with pytest.raises(Mt5TradingError, match="positive finite number"):
|
with pytest.raises(Mt5OperationError, match="positive finite number"):
|
||||||
estimate_order_margin(client, "EURUSD", "BUY", volume)
|
estimate_order_margin(client, "EURUSD", "BUY", volume)
|
||||||
|
|
||||||
client.symbol_info_tick_as_dict.assert_not_called()
|
client.symbol_info_tick_as_dict.assert_not_called()
|
||||||
@@ -1052,7 +1053,7 @@ class TestEstimateOrderMargin:
|
|||||||
client = _mock_trade_client()
|
client = _mock_trade_client()
|
||||||
client.symbol_info_tick_as_dict.return_value = {"ask": None, "bid": 1.1000}
|
client.symbol_info_tick_as_dict.return_value = {"ask": None, "bid": 1.1000}
|
||||||
|
|
||||||
with pytest.raises(Mt5TradingError, match="Tick price is unavailable"):
|
with pytest.raises(Mt5OperationError, match="Tick price is unavailable"):
|
||||||
estimate_order_margin(client, "EURUSD", "BUY", 0.1)
|
estimate_order_margin(client, "EURUSD", "BUY", 0.1)
|
||||||
|
|
||||||
def test_rejects_non_positive_tick_price(self) -> None:
|
def test_rejects_non_positive_tick_price(self) -> None:
|
||||||
@@ -1060,7 +1061,7 @@ class TestEstimateOrderMargin:
|
|||||||
client = _mock_trade_client()
|
client = _mock_trade_client()
|
||||||
client.symbol_info_tick_as_dict.return_value = {"ask": 0.0, "bid": 1.1000}
|
client.symbol_info_tick_as_dict.return_value = {"ask": 0.0, "bid": 1.1000}
|
||||||
|
|
||||||
with pytest.raises(Mt5TradingError, match="Tick price is unavailable"):
|
with pytest.raises(Mt5OperationError, match="Tick price is unavailable"):
|
||||||
estimate_order_margin(client, "EURUSD", "BUY", 0.1)
|
estimate_order_margin(client, "EURUSD", "BUY", 0.1)
|
||||||
|
|
||||||
def test_rejects_non_finite_tick_price(self) -> None:
|
def test_rejects_non_finite_tick_price(self) -> None:
|
||||||
@@ -1071,7 +1072,7 @@ class TestEstimateOrderMargin:
|
|||||||
"bid": 1.1000,
|
"bid": 1.1000,
|
||||||
}
|
}
|
||||||
|
|
||||||
with pytest.raises(Mt5TradingError, match="Tick price is unavailable"):
|
with pytest.raises(Mt5OperationError, match="Tick price is unavailable"):
|
||||||
estimate_order_margin(client, "EURUSD", "BUY", 0.1)
|
estimate_order_margin(client, "EURUSD", "BUY", 0.1)
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -1085,7 +1086,7 @@ class TestEstimateOrderMargin:
|
|||||||
client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000}
|
client.symbol_info_tick_as_dict.return_value = {"ask": 1.1010, "bid": 1.1000}
|
||||||
client.order_calc_margin.return_value = margin_value
|
client.order_calc_margin.return_value = margin_value
|
||||||
|
|
||||||
with pytest.raises(Mt5TradingError, match="Margin estimate is invalid"):
|
with pytest.raises(Mt5OperationError, match="Margin estimate is invalid"):
|
||||||
estimate_order_margin(client, "EURUSD", "BUY", 0.1)
|
estimate_order_margin(client, "EURUSD", "BUY", 0.1)
|
||||||
|
|
||||||
|
|
||||||
@@ -1184,7 +1185,7 @@ class TestCalculatePositionsMargin:
|
|||||||
)
|
)
|
||||||
client.symbol_info_tick_as_dict.return_value = {"ask": None, "bid": 1.1000}
|
client.symbol_info_tick_as_dict.return_value = {"ask": None, "bid": 1.1000}
|
||||||
|
|
||||||
with pytest.raises(Mt5TradingError, match="Tick price is unavailable"):
|
with pytest.raises(Mt5OperationError, match="Tick price is unavailable"):
|
||||||
calculate_positions_margin(client)
|
calculate_positions_margin(client)
|
||||||
|
|
||||||
def test_skips_rows_with_invalid_symbol_volume_or_type(self) -> None:
|
def test_skips_rows_with_invalid_symbol_volume_or_type(self) -> None:
|
||||||
@@ -1376,7 +1377,7 @@ class TestVolumeAndExecution:
|
|||||||
"volume_step": 0.1,
|
"volume_step": 0.1,
|
||||||
}
|
}
|
||||||
|
|
||||||
with pytest.raises(Mt5TradingError):
|
with pytest.raises(Mt5OperationError):
|
||||||
calculate_volume_by_margin(client, "EURUSD", 100.0, "BUY")
|
calculate_volume_by_margin(client, "EURUSD", 100.0, "BUY")
|
||||||
|
|
||||||
def test_calculate_volume_by_margin_rejects_bad_tick(self) -> None:
|
def test_calculate_volume_by_margin_rejects_bad_tick(self) -> None:
|
||||||
@@ -1389,7 +1390,7 @@ class TestVolumeAndExecution:
|
|||||||
}
|
}
|
||||||
client.symbol_info_tick_as_dict.return_value = {"ask": 1.0, "bid": None}
|
client.symbol_info_tick_as_dict.return_value = {"ask": 1.0, "bid": None}
|
||||||
|
|
||||||
with pytest.raises(Mt5TradingError):
|
with pytest.raises(Mt5OperationError):
|
||||||
calculate_volume_by_margin(client, "EURUSD", 100.0, "SELL")
|
calculate_volume_by_margin(client, "EURUSD", 100.0, "SELL")
|
||||||
|
|
||||||
def test_calculate_volume_by_margin_steps_down_when_margin_exceeds_budget(
|
def test_calculate_volume_by_margin_steps_down_when_margin_exceeds_budget(
|
||||||
@@ -1604,7 +1605,7 @@ class TestVolumeAndExecution:
|
|||||||
"volume_step": 0.1,
|
"volume_step": 0.1,
|
||||||
}
|
}
|
||||||
|
|
||||||
with pytest.raises(Mt5TradingError, match="Invalid volume constraints"):
|
with pytest.raises(Mt5OperationError, match="Invalid volume constraints"):
|
||||||
calculate_margin_and_volume(
|
calculate_margin_and_volume(
|
||||||
client,
|
client,
|
||||||
"EURUSD",
|
"EURUSD",
|
||||||
@@ -1623,7 +1624,7 @@ class TestVolumeAndExecution:
|
|||||||
}
|
}
|
||||||
client.symbol_info_tick_as_dict.return_value = {"ask": None, "bid": 99.0}
|
client.symbol_info_tick_as_dict.return_value = {"ask": None, "bid": 99.0}
|
||||||
|
|
||||||
with pytest.raises(Mt5TradingError, match="Tick price is unavailable"):
|
with pytest.raises(Mt5OperationError, match="Tick price is unavailable"):
|
||||||
calculate_margin_and_volume(
|
calculate_margin_and_volume(
|
||||||
client,
|
client,
|
||||||
"EURUSD",
|
"EURUSD",
|
||||||
@@ -1722,7 +1723,7 @@ class TestVolumeAndExecution:
|
|||||||
client = _mock_trade_client()
|
client = _mock_trade_client()
|
||||||
client.account_info_as_dict.return_value = {"equity": 0.0, "margin": 50.0}
|
client.account_info_as_dict.return_value = {"equity": 0.0, "margin": 50.0}
|
||||||
|
|
||||||
with pytest.raises(Mt5TradingError):
|
with pytest.raises(Mt5OperationError):
|
||||||
calculate_new_position_margin_ratio(client, symbol="EURUSD")
|
calculate_new_position_margin_ratio(client, symbol="EURUSD")
|
||||||
|
|
||||||
def test_new_position_margin_ratio_rejects_bad_tick(self) -> None:
|
def test_new_position_margin_ratio_rejects_bad_tick(self) -> None:
|
||||||
@@ -1731,7 +1732,7 @@ class TestVolumeAndExecution:
|
|||||||
client.account_info_as_dict.return_value = {"equity": 1000.0, "margin": 50.0}
|
client.account_info_as_dict.return_value = {"equity": 1000.0, "margin": 50.0}
|
||||||
client.symbol_info_tick_as_dict.return_value = {"ask": None, "bid": 1.0}
|
client.symbol_info_tick_as_dict.return_value = {"ask": None, "bid": 1.0}
|
||||||
|
|
||||||
with pytest.raises(Mt5TradingError):
|
with pytest.raises(Mt5OperationError):
|
||||||
calculate_new_position_margin_ratio(
|
calculate_new_position_margin_ratio(
|
||||||
client,
|
client,
|
||||||
symbol="EURUSD",
|
symbol="EURUSD",
|
||||||
@@ -1905,7 +1906,7 @@ class TestVolumeAndExecution:
|
|||||||
client = _mock_trade_client()
|
client = _mock_trade_client()
|
||||||
client.account_info_as_dict.return_value = account
|
client.account_info_as_dict.return_value = account
|
||||||
|
|
||||||
with pytest.raises(Mt5TradingError, match=match):
|
with pytest.raises(Mt5OperationError, match=match):
|
||||||
calculate_account_projected_margin_ratio(client)
|
calculate_account_projected_margin_ratio(client)
|
||||||
|
|
||||||
def test_account_projected_margin_ratio_propagates_candidate_margin_error(
|
def test_account_projected_margin_ratio_propagates_candidate_margin_error(
|
||||||
@@ -1920,10 +1921,10 @@ class TestVolumeAndExecution:
|
|||||||
}
|
}
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"mt5cli.trading.estimate_order_margin",
|
"mt5cli.trading.estimate_order_margin",
|
||||||
side_effect=Mt5TradingError("bad tick"),
|
side_effect=Mt5OperationError("bad tick"),
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(Mt5TradingError, match="bad tick"):
|
with pytest.raises(Mt5OperationError, match="bad tick"):
|
||||||
calculate_account_projected_margin_ratio(
|
calculate_account_projected_margin_ratio(
|
||||||
client,
|
client,
|
||||||
symbol="EURUSD",
|
symbol="EURUSD",
|
||||||
@@ -1999,7 +2000,7 @@ class TestVolumeAndExecution:
|
|||||||
client = _mock_trade_client()
|
client = _mock_trade_client()
|
||||||
client.account_info_as_dict.return_value = {"equity": 0.0}
|
client.account_info_as_dict.return_value = {"equity": 0.0}
|
||||||
|
|
||||||
with pytest.raises(Mt5TradingError, match="Account equity"):
|
with pytest.raises(Mt5OperationError, match="Account equity"):
|
||||||
calculate_symbol_group_margin_ratio(client, symbols=["EURUSD"])
|
calculate_symbol_group_margin_ratio(client, symbols=["EURUSD"])
|
||||||
|
|
||||||
def test_projected_margin_ratio_rejects_nonnumeric_equity(self) -> None:
|
def test_projected_margin_ratio_rejects_nonnumeric_equity(self) -> None:
|
||||||
@@ -2007,7 +2008,7 @@ class TestVolumeAndExecution:
|
|||||||
client = _mock_trade_client()
|
client = _mock_trade_client()
|
||||||
client.account_info_as_dict.return_value = {"equity": "invalid"}
|
client.account_info_as_dict.return_value = {"equity": "invalid"}
|
||||||
|
|
||||||
with pytest.raises(Mt5TradingError, match="Account equity"):
|
with pytest.raises(Mt5OperationError, match="Account equity"):
|
||||||
calculate_projected_margin_ratio(client, symbol="EURUSD")
|
calculate_projected_margin_ratio(client, symbol="EURUSD")
|
||||||
|
|
||||||
def test_symbol_group_margin_ratio_suppresses_projected_failure(
|
def test_symbol_group_margin_ratio_suppresses_projected_failure(
|
||||||
@@ -2024,7 +2025,7 @@ class TestVolumeAndExecution:
|
|||||||
)
|
)
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"mt5cli.trading.estimate_order_margin",
|
"mt5cli.trading.estimate_order_margin",
|
||||||
side_effect=Mt5TradingError("bad tick"),
|
side_effect=Mt5OperationError("bad tick"),
|
||||||
)
|
)
|
||||||
|
|
||||||
with caplog.at_level(logging.WARNING, logger="mt5cli.trading"):
|
with caplog.at_level(logging.WARNING, logger="mt5cli.trading"):
|
||||||
@@ -2053,10 +2054,10 @@ class TestVolumeAndExecution:
|
|||||||
)
|
)
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"mt5cli.trading.estimate_order_margin",
|
"mt5cli.trading.estimate_order_margin",
|
||||||
side_effect=Mt5TradingError("bad tick"),
|
side_effect=Mt5OperationError("bad tick"),
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(Mt5TradingError, match="bad tick"):
|
with pytest.raises(Mt5OperationError, match="bad tick"):
|
||||||
calculate_symbol_group_margin_ratio(
|
calculate_symbol_group_margin_ratio(
|
||||||
client,
|
client,
|
||||||
symbols=["EURUSD"],
|
symbols=["EURUSD"],
|
||||||
@@ -2177,7 +2178,7 @@ class TestVolumeAndExecution:
|
|||||||
)
|
)
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"mt5cli.trading.estimate_order_margin",
|
"mt5cli.trading.estimate_order_margin",
|
||||||
side_effect=Mt5TradingError("bad tick"),
|
side_effect=Mt5OperationError("bad tick"),
|
||||||
)
|
)
|
||||||
|
|
||||||
with caplog.at_level(logging.WARNING, logger="mt5cli.trading"):
|
with caplog.at_level(logging.WARNING, logger="mt5cli.trading"):
|
||||||
@@ -2208,10 +2209,10 @@ class TestVolumeAndExecution:
|
|||||||
)
|
)
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"mt5cli.trading.estimate_order_margin",
|
"mt5cli.trading.estimate_order_margin",
|
||||||
side_effect=Mt5TradingError("bad tick"),
|
side_effect=Mt5OperationError("bad tick"),
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(Mt5TradingError, match="bad tick"):
|
with pytest.raises(Mt5OperationError, match="bad tick"):
|
||||||
calculate_symbol_group_margin_ratio(
|
calculate_symbol_group_margin_ratio(
|
||||||
client,
|
client,
|
||||||
symbols=["EURUSD"],
|
symbols=["EURUSD"],
|
||||||
@@ -2335,7 +2336,7 @@ class TestVolumeAndExecution:
|
|||||||
del client.mt5.ORDER_FILLING_IOC
|
del client.mt5.ORDER_FILLING_IOC
|
||||||
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}
|
||||||
|
|
||||||
with pytest.raises(Mt5TradingError, match="ORDER_FILLING_IOC"):
|
with pytest.raises(Mt5OperationError, match="ORDER_FILLING_IOC"):
|
||||||
place_market_order(
|
place_market_order(
|
||||||
client,
|
client,
|
||||||
symbol="EURUSD",
|
symbol="EURUSD",
|
||||||
@@ -2346,7 +2347,7 @@ class TestVolumeAndExecution:
|
|||||||
|
|
||||||
def test_place_market_order_rejects_invalid_volume(self) -> None:
|
def test_place_market_order_rejects_invalid_volume(self) -> None:
|
||||||
"""Test non-positive volume raises a trading error."""
|
"""Test non-positive volume raises a trading error."""
|
||||||
with pytest.raises(Mt5TradingError):
|
with pytest.raises(Mt5OperationError):
|
||||||
place_market_order(
|
place_market_order(
|
||||||
_mock_trade_client(),
|
_mock_trade_client(),
|
||||||
symbol="EURUSD",
|
symbol="EURUSD",
|
||||||
@@ -2359,7 +2360,7 @@ class TestVolumeAndExecution:
|
|||||||
client = _mock_trade_client()
|
client = _mock_trade_client()
|
||||||
client.symbol_info_tick_as_dict.return_value = {"ask": None, "bid": 1.1}
|
client.symbol_info_tick_as_dict.return_value = {"ask": None, "bid": 1.1}
|
||||||
|
|
||||||
with pytest.raises(Mt5TradingError):
|
with pytest.raises(Mt5OperationError):
|
||||||
place_market_order(
|
place_market_order(
|
||||||
client,
|
client,
|
||||||
symbol="EURUSD",
|
symbol="EURUSD",
|
||||||
@@ -2913,7 +2914,7 @@ class TestVolumeAndExecution:
|
|||||||
"""Test shutdown is called when initialization fails."""
|
"""Test shutdown is called when initialization fails."""
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_client.initialize_and_login_mt5.side_effect = Mt5RuntimeError("boom")
|
mock_client.initialize_and_login_mt5.side_effect = Mt5RuntimeError("boom")
|
||||||
mocker.patch("mt5cli.trading.Mt5TradingClient", return_value=mock_client)
|
mocker.patch("mt5cli.trading.Mt5DataClient", return_value=mock_client)
|
||||||
|
|
||||||
with pytest.raises(Mt5RuntimeError, match="boom"), mt5_trading_session():
|
with pytest.raises(Mt5RuntimeError, match="boom"), mt5_trading_session():
|
||||||
pass
|
pass
|
||||||
@@ -3070,7 +3071,7 @@ class TestVolumeAndExecution:
|
|||||||
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()
|
||||||
mocker.patch("mt5cli.trading.Mt5TradingClient", return_value=mock_client)
|
mocker.patch("mt5cli.trading.Mt5DataClient", return_value=mock_client)
|
||||||
|
|
||||||
body_error = "body error"
|
body_error = "body error"
|
||||||
with pytest.raises(RuntimeError, match=body_error), mt5_trading_session():
|
with pytest.raises(RuntimeError, match=body_error), mt5_trading_session():
|
||||||
@@ -3201,7 +3202,7 @@ class TestFetchLatestClosedRatesForTradingClient:
|
|||||||
"""Test missing rate-fetch methods raise Mt5TradingError."""
|
"""Test missing rate-fetch methods raise Mt5TradingError."""
|
||||||
client = MagicMock(spec=[])
|
client = MagicMock(spec=[])
|
||||||
|
|
||||||
with pytest.raises(Mt5TradingError, match="cannot fetch rate data"):
|
with pytest.raises(Mt5OperationError, match="cannot fetch rate data"):
|
||||||
fetch_latest_closed_rates_for_trading_client(
|
fetch_latest_closed_rates_for_trading_client(
|
||||||
client,
|
client,
|
||||||
symbol="EURUSD",
|
symbol="EURUSD",
|
||||||
|
|||||||
+20
-12
@@ -4,21 +4,22 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import sys
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
import mt5cli.utils
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from mt5cli.utils import (
|
from mt5cli.utils import (
|
||||||
DATETIME_TYPE,
|
DATETIME_TYPE,
|
||||||
REQUEST_TYPE,
|
REQUEST_TYPE,
|
||||||
TICK_FLAG_MAP,
|
|
||||||
TICK_FLAGS_TYPE,
|
TICK_FLAGS_TYPE,
|
||||||
TIMEFRAME_MAP,
|
|
||||||
TIMEFRAME_TYPE,
|
TIMEFRAME_TYPE,
|
||||||
Dataset,
|
Dataset,
|
||||||
IfExists,
|
IfExists,
|
||||||
@@ -111,6 +112,17 @@ class TestExportDataframe:
|
|||||||
result = pd.read_parquet(output)
|
result = pd.read_parquet(output)
|
||||||
pd.testing.assert_frame_equal(result, sample_df)
|
pd.testing.assert_frame_equal(result, sample_df)
|
||||||
|
|
||||||
|
def test_export_parquet_without_pyarrow(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
sample_df: pd.DataFrame,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test that a clear error is raised when pyarrow is not installed."""
|
||||||
|
monkeypatch.setitem(sys.modules, "pyarrow", None)
|
||||||
|
with pytest.raises(ImportError, match="mt5cli\\[parquet\\]"):
|
||||||
|
export_dataframe(sample_df, tmp_path / "out.parquet", "parquet")
|
||||||
|
|
||||||
def test_export_sqlite3(self, tmp_path: Path, sample_df: pd.DataFrame) -> None:
|
def test_export_sqlite3(self, tmp_path: Path, sample_df: pd.DataFrame) -> None:
|
||||||
"""Test SQLite3 export."""
|
"""Test SQLite3 export."""
|
||||||
output = tmp_path / "out.db"
|
output = tmp_path / "out.db"
|
||||||
@@ -361,17 +373,13 @@ class TestParseRequest:
|
|||||||
class TestConstants:
|
class TestConstants:
|
||||||
"""Tests for module constants."""
|
"""Tests for module constants."""
|
||||||
|
|
||||||
def test_timeframe_map_has_expected_keys(self) -> None:
|
def test_timeframe_map_is_private_in_utils(self) -> None:
|
||||||
"""Test that TIMEFRAME_MAP contains standard timeframes."""
|
"""TIMEFRAME_MAP is a private implementation detail; not a public attribute."""
|
||||||
for key in ("M1", "M5", "M15", "M30", "H1", "H4", "D1", "W1", "MN1"):
|
assert not hasattr(mt5cli.utils, "TIMEFRAME_MAP")
|
||||||
assert key in TIMEFRAME_MAP
|
|
||||||
|
|
||||||
def test_tick_flag_map_has_expected_keys(self) -> None:
|
def test_tick_flag_map_absent_from_utils(self) -> None:
|
||||||
"""Test that TICK_FLAG_MAP contains standard flags with MT5 values."""
|
"""TICK_FLAG_MAP is not exposed by mt5cli.utils."""
|
||||||
assert {"ALL", "INFO", "TRADE"} <= set(TICK_FLAG_MAP)
|
assert not hasattr(mt5cli.utils, "TICK_FLAG_MAP")
|
||||||
assert TICK_FLAG_MAP["ALL"] == -1
|
|
||||||
assert TICK_FLAG_MAP["INFO"] == 1
|
|
||||||
assert TICK_FLAG_MAP["TRADE"] == 2
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("dataset", "expected"),
|
("dataset", "expected"),
|
||||||
|
|||||||
@@ -358,7 +358,7 @@ name = "metatrader5"
|
|||||||
version = "5.0.5640"
|
version = "5.0.5640"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "numpy", marker = "sys_platform == 'win32'" },
|
{ name = "numpy" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ef/a0/3b764c6743ef601ff12f7d8d62ca5768eb25e90d3758ac7e1d08e667af85/metatrader5-5.0.5640-cp311-cp311-win_amd64.whl", hash = "sha256:4057255f2d63138a3ea1a5d492715038a71d3177cad793fca97a5c58771b4eb1", size = 48091, upload-time = "2026-02-20T23:31:12.289Z" },
|
{ url = "https://files.pythonhosted.org/packages/ef/a0/3b764c6743ef601ff12f7d8d62ca5768eb25e90d3758ac7e1d08e667af85/metatrader5-5.0.5640-cp311-cp311-win_amd64.whl", hash = "sha256:4057255f2d63138a3ea1a5d492715038a71d3177cad793fca97a5c58771b4eb1", size = 48091, upload-time = "2026-02-20T23:31:12.289Z" },
|
||||||
@@ -487,21 +487,26 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mt5cli"
|
name = "mt5cli"
|
||||||
version = "0.9.6"
|
version = "1.0.2"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
{ name = "pdmt5" },
|
{ name = "pdmt5" },
|
||||||
{ name = "pyarrow" },
|
|
||||||
{ name = "typer" },
|
{ name = "typer" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
parquet = [
|
||||||
|
{ name = "pyarrow" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "mkdocs" },
|
{ name = "mkdocs" },
|
||||||
{ name = "mkdocs-material" },
|
{ name = "mkdocs-material" },
|
||||||
{ name = "mkdocstrings", extra = ["python"] },
|
{ name = "mkdocstrings", extra = ["python"] },
|
||||||
{ name = "pandas-stubs" },
|
{ name = "pandas-stubs" },
|
||||||
|
{ name = "pyarrow" },
|
||||||
{ name = "pymdown-extensions" },
|
{ name = "pymdown-extensions" },
|
||||||
{ name = "pyright" },
|
{ name = "pyright" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
@@ -513,10 +518,11 @@ dev = [
|
|||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "click", specifier = ">=8.1.0" },
|
{ name = "click", specifier = ">=8.1.0" },
|
||||||
{ name = "pdmt5", specifier = ">=0.3.0" },
|
{ name = "pdmt5", specifier = ">=1.0.0" },
|
||||||
{ name = "pyarrow", specifier = ">=19.0.0" },
|
{ name = "pyarrow", marker = "extra == 'parquet'", specifier = ">=19.0.0" },
|
||||||
{ name = "typer", specifier = ">=0.15.0" },
|
{ name = "typer", specifier = ">=0.15.0" },
|
||||||
]
|
]
|
||||||
|
provides-extras = ["parquet"]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
@@ -524,6 +530,7 @@ dev = [
|
|||||||
{ name = "mkdocs-material", specifier = ">=9.7.6" },
|
{ name = "mkdocs-material", specifier = ">=9.7.6" },
|
||||||
{ name = "mkdocstrings", extras = ["python"], specifier = ">=1.0.4" },
|
{ name = "mkdocstrings", extras = ["python"], specifier = ">=1.0.4" },
|
||||||
{ name = "pandas-stubs", specifier = ">=2.2.3.250527" },
|
{ name = "pandas-stubs", specifier = ">=2.2.3.250527" },
|
||||||
|
{ name = "pyarrow", specifier = ">=19.0.0" },
|
||||||
{ name = "pymdown-extensions", specifier = ">=10.21.2" },
|
{ name = "pymdown-extensions", specifier = ">=10.21.2" },
|
||||||
{ name = "pyright", specifier = ">=1.1.407" },
|
{ name = "pyright", specifier = ">=1.1.407" },
|
||||||
{ name = "pytest", specifier = ">=9.0.3" },
|
{ name = "pytest", specifier = ">=9.0.3" },
|
||||||
@@ -684,16 +691,16 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdmt5"
|
name = "pdmt5"
|
||||||
version = "0.3.0"
|
version = "1.0.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "metatrader5", marker = "sys_platform == 'win32'" },
|
{ name = "metatrader5", marker = "sys_platform == 'win32'" },
|
||||||
{ name = "pandas" },
|
{ name = "pandas" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/bf/cc/c8fa3a01e0e34178fec8527992f7bb8eda5881477ce23aaacaa9b2ef7bec/pdmt5-0.3.0.tar.gz", hash = "sha256:bb612d5c2695eafac9b2a7b74756e13bd383d7e5517bd90c9a2efa92492c484c", size = 215100, upload-time = "2026-06-11T13:26:46.976Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/21/6d/b51d2d0ec4636e914210be03a7da20e5078bc8cd7a351edd13bc30b7d2b1/pdmt5-1.0.0.tar.gz", hash = "sha256:ba53a1a5db41fdf4c022ef55353f5a4f6884f625c9310de2b0ddb20457956716", size = 123155, upload-time = "2026-06-25T23:41:50.503Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/f2/03/b12cc4c9db983d971c9172b3765161b6d91136d0624e6718a04dd815e7a1/pdmt5-0.3.0-py3-none-any.whl", hash = "sha256:5388b406cc583202600cfe22c9d781679b1d931b1ed5a2b5dcf37c566149b49f", size = 26250, upload-time = "2026-06-11T13:26:45.689Z" },
|
{ url = "https://files.pythonhosted.org/packages/5e/38/27b712c572d8146efddf571a0e4e7b6d2314a335eb0f47dec1f499ab2189/pdmt5-1.0.0-py3-none-any.whl", hash = "sha256:f969c17902f9ffcbf56d7ccf9285aab39ececd676fcca50c094abcf9d728d517", size = 23992, upload-time = "2026-06-25T23:41:49.067Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Reference in New Issue
Block a user