Compare commits

...

12 Commits

Author SHA1 Message Date
dceoy 8028263b24 docs: format public contract table
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 02:04:29 +09:00
dceoy 63a8d67419 chore: bump version to 1.0.0
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 02:03:46 +09:00
Daichi Narushima 93565681e1 fix: decouple mt5cli from pdmt5 high-level trading helpers (#76)
* fix: decouple mt5cli from pdmt5 high-level trading helpers

- Replace Mt5TradingClient type annotations with internal _Mt5ClientProtocol
- Lazy-import Mt5TradingClient in create_trading_client to avoid hard dependency
- Replace Mt5TradingError with Mt5OperationError in mt5cli validation paths
- Update exception handling to support future pdmt5 versions without Mt5TradingError
- Add test to enforce that mt5cli doesn't import high-level symbols at module level
- Update documentation to clarify dependency boundaries

mt5cli now relies only on low-level MT5 primitives:
- Mt5Config for configuration
- Mt5RuntimeError for runtime errors
- Raw MT5 methods (order_send, order_check, account_info, etc.)

This aligns with pdmt5's direction to remove high-level trading helpers and focus
on low-level MT5 access plus DataFrame/dict conversion.

Fixes #75 (dceoy/mt5cli#75)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcGVFTVgyqzse3LLw38ber

* fix: address PR #76 review feedback on pdmt5 decoupling

- Replace Mt5TradingClient with Mt5DataClient in create_trading_client()
  so the function no longer depends on the high-level trading client
- Fix _RECOVERABLE_MT5_ERRORS in exceptions.py to use tuple unpacking
  form, removing the incorrect ternary assignment
- Add pragma: no cover to except ImportError branches in exceptions.py
  and sdk.py (dead code when pdmt5 is installed)
- Switch coverage exclude_lines to exclude_also so the default
  pragma: no cover pattern is preserved; also exclude bare ... stubs
  (Protocol method bodies) from coverage
- Correct inaccurate note in docs/api/public-contract.md: Mt5TradingClient
  is no longer required internally; Mt5TradingError is conditionally
  available but mt5cli raises Mt5OperationError for trading failures
- Update all mock patches from pdmt5.Mt5TradingClient to
  mt5cli.trading.Mt5DataClient to match the new module-level import

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-26 22:26:54 +09:00
Daichi Narushima f435544f07 Shrink public API surface and remove storage re-export module (#74) 2026-06-26 18:23:30 +09:00
Daichi Narushima 668f38d8aa feat: reduce package-root API surface and require pdmt5>=1.0.0 (closes #70) (#73) 2026-06-26 12:08:00 +09:00
dceoy 8da5ee9242 Bump version to v0.9.7
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 14:10:23 +09:00
Daichi Narushima 9dbb46fbb1 feat: make pyarrow optional via mt5cli[parquet] extra (#69) 2026-06-25 14:03:24 +09:00
Daichi Narushima dfe80ce500 feat: add close-positions CLI and replace_symbol projection mode (#65 #66) (#67)
* feat: add close-positions CLI command and replace_symbol projection mode (#65 #66)

Part 1 — close-positions CLI (#65):
- Add `close-positions` subcommand delegating to `close_open_positions()`.
- Accepts repeated `--symbol` and `--ticket` filters (AND semantics).
- Supports `--dry-run` (no `--yes` required); live execution requires `--yes`.
- Fails closed with `BadParameter` when neither `--symbol` nor `--ticket` is given.
- Exports normalized `OrderExecutionResult` list as a DataFrame (request/response
  serialized as JSON strings for clean CSV/JSON/Parquet/SQLite output).
- `order-send` remains the raw expert path; `close-positions` is the safer
  high-level helper that builds correct close requests automatically.

Part 2 — ProjectionMode and replace_symbol (#66):
- Add `ProjectionMode = Literal["add", "replace_symbol"]` type alias.
- Add optional `projection_mode` parameter to `calculate_symbol_group_margin_ratio`.
  Default `"add"` preserves existing additive behavior.
  `"replace_symbol"` subtracts current margin for `new_symbol`, then adds
  candidate margin — the subtraction and addition are atomic (suppressed together).
- Export `ProjectionMode` from `mt5cli` and add to `STABLE_SDK_EXPORTS`.
- No mteor-specific strategy, risk-threshold, or policy logic added.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: remove unused ProjectionMode import in test_contracts.py

The parametrized test_stable_exports_are_importable_from_package_root
already covers ProjectionMode via hasattr(mt5cli, name). Ruff correctly
flagged the explicit top-level import as unused (F401).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Bump version to v0.9.6

* fix: address PR #67 review feedback

- Floor replace_symbol margin subtraction at zero to prevent negative ratio
- Serialize response unconditionally via json.dumps (null for dry-run rows)
- Return a schema-preserving empty DataFrame when results list is empty
- Add test: --dry-run --yes precedence (dry-run wins, no order_send)
- Add test: zero-match filter produces empty JSON array with exit 0
- Move projection_mode prose to stable trading section in docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add runtime validation for projection_mode in calculate_symbol_group_margin_ratio

Unsupported values previously silently fell through as "add". The new
_validate_projection_mode helper raises ValueError with a message that
names the bad value and the two accepted modes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: agent <agent@localhost>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 10:39:49 +09:00
Daichi Narushima 15bfd17db3 test: reduce test_trading.py duplication with parametrize (#64)
* test: reduce test_trading.py duplication with parametrize

Collapse repetitive individual tests in test_trading.py into
parametrized equivalents, cutting 267 lines without losing any cases.

- TestExtractTickPrice: 13 tests → 2 parametrized (×3 valid, ×10 None)
- TestEstimateOrderMargin: 4 invalid-margin tests → 1 parametrized ×4;
  nan/inf volume tests → 1 parametrized ×2
- TestNormalizeOrderVolume: multi-assert bodies split into parametrized
  cases for non-finite volume and constraints
- TestVolumeAndExecution: 9 place_market_order retcode tests → 1 ×11;
  5 update_sltp retcode tests → 1 ×5
- test_calculate_trailing_stop_updates_missing_symbol_digits:
  inline double-assert body → 1 parametrized ×2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: further reduce test_trading.py duplication with parametrize

Merge six broker stop-level tests into two parametrized tests, collapse
two default-digits fallback tests and three symbol-filter zero-margin
tests into one each.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: address claude[bot] review on PR #64

- Consolidate _MISSING_RETCODE sentinel to one line with corrected comment
- Add comment explaining ids list is required for deterministic node IDs
- Document intentional narrower retcode coverage in update_sltp test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: reduce duplication in test_sdk, test_history, test_contracts

- TestBuildConfigWholeDollarEnv: 3 field tests (server/password/path) → 1
  parametrized ×3
- TestResolveAccountSpec: whole-dollar expand/no-expand pair → 1 parametrized ×2
- test_normalize_mt5_exception_maps_types: 2 isinstance asserts → parametrized ×2
- test_resolve_history_tick_flags_invalid: 2 pytest.raises blocks → parametrized ×2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: agent <agent@localhost>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 01:55:04 +09:00
Daichi Narushima 37eef16e99 feat: support string login in build_config and add substitute_mapping_values (#63)
* feat: support string login in build_config and add substitute_mapping_values (#61, #62)

Extend build_config() to accept login: int | str | None. String logins
are coerced via the existing coerce_login() helper (empty/whitespace →
None, numeric strings → int, non-numeric → ValueError). When
allow_whole_dollar_env=True, ${ENV} and $ENV placeholders are expanded
before coercion, consistent with path/password/server behavior.

Add substitute_mapping_values(), a generic recursive helper that
substitutes environment placeholders in nested dicts/lists only for
caller-selected mapping keys. Non-selected fields (including literal
dollar signs) are preserved exactly. Supports blank_string_keys_as_none
to normalise empty strings to None after substitution. No application-
specific key names (e.g. mt5_login) are hard-coded in mt5cli.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Bump version to v0.9.5

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs+test: clarify substitute_mapping_values docstring and pin tuple behaviour

- Adds sentence noting list-element strings are never substituted (only
  immediate dict values are), addressing reviewer finding #1.
- Rewrites Returns section to accurately describe scalar pass-through
  behaviour, addressing reviewer finding #2.
- Adds recursion-depth caveat to the generic-utility docstring,
  addressing reviewer finding #4.
- Adds test_tuple_container_not_traversed to pin the existing silent
  tuple-exclusion contract, addressing reviewer finding #3.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: update public contract and README for build_config login coercion and substitute_mapping_values

- Expands build_config row to document login: int | str | None,
  numeric-string coercion, blank-string handling, and env placeholder
  expansion when allow_whole_dollar_env=True.
- Adds substitute_mapping_values to the stable SDK table with a note
  that key names are never hard-coded in mt5cli.
- Extends allow_whole_dollar_env paragraph to list substitute_mapping_values.
- README: adds build_config env-placeholder example and imports to the
  trading lifecycle snippet.
- README: extends credential-resolution bullet with a substitute_mapping_values
  usage example using generic key names.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: agent <agent@localhost>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 01:04:32 +09:00
Daichi Narushima 96c75f7852 Add account-wide projected margin ratio helper (#60)
* feat: add account projected margin ratio helper

* Bump version to v0.9.4

* fix: address account margin ratio review feedback

* fix: simplify account margin ratio errors
2026-06-24 03:43:52 +09:00
Daichi Narushima 292fac899a Add generic trading helpers and reduce public API tiers (#58)
* feat: add generic trading helpers and API tiers

* Bump version to v0.9.3

* fix: require symbol digits for trailing stops

* fix: allow side-specific trailing stop ticks

* test: enforce complete public export tiers

* docs: align public contract tiers

* refactor: remove legacy public supports
2026-06-24 01:58:32 +09:00
26 changed files with 2728 additions and 1324 deletions
+26 -13
View File
@@ -29,25 +29,29 @@ Built on top of [pdmt5](https://github.com/dceoy/pdmt5), a pandas-based data han
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)
Import `MT5Client` for generic MT5 data access, schema normalization, and optional order primitives. `Mt5CliClient` remains available as a backward-compatible alias.
Import `MT5Client` for generic MT5 data access, schema normalization, and optional order primitives.
```python
from datetime import UTC, datetime
from pathlib import Path
from mt5cli import (
DataKind,
Dataset,
MT5Client,
build_config,
collect_history,
export_dataframe,
mt5_session,
normalize_dataframe,
update_history_with_config,
)
from mt5cli.schemas import DataKind, normalize_dataframe
from mt5cli.utils import Dataset, export_dataframe
# Persistent session for multiple calls
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.
@@ -92,16 +96,21 @@ Schema contracts live in `mt5cli.schemas` (`DataKind`, `validate_schema`, `norma
Trading applications can depend on `mt5cli` imports only; terminal path,
credentials, server, and timeout are forwarded to `pdmt5.Mt5Config`, numeric
login strings are coerced to integers, and empty login strings are treated as
unset.
unset. Pass `allow_whole_dollar_env=True` to expand `${ENV_VAR}` and bare
`$ENV_NAME` placeholders in connection string parameters before coercion.
```python
from mt5cli import (
build_config,
calculate_spread_ratio,
create_trading_client,
get_account_snapshot,
mt5_trading_session,
)
# Login from environment — numeric string is coerced to int automatically
config = build_config(login="$MT5_LOGIN", allow_whole_dollar_env=True)
with mt5_trading_session(
path=r"C:\Program Files\MetaTrader 5\terminal64.exe",
login="12345",
@@ -173,10 +182,13 @@ python -m mt5cli -o account.csv account-info
| `recent-history-deals` | Export historical deals from a recent trailing window |
| `mt5-summary` | Export terminal/account status summary |
| `order-check` | Check funds sufficiency for a trade request |
| `order-send` | Send a trade request to the trade server (`--yes` required) |
| `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) |
| `collect-history` | Bundle rates, ticks, history-orders, and history-deals for one or more symbols into a single SQLite database |
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
requests automatically. At least one `--symbol` or `--ticket` must be provided.
### `collect-history`
@@ -198,7 +210,8 @@ For automated pipelines, use the importable incremental API instead of re-fetchi
```python
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)
client = Mt5DataClient(config=Mt5Config(login=12345))
@@ -233,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 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-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")`.
```python
@@ -248,9 +261,9 @@ rates = collect_latest_closed_rates_by_granularity(
eurusd_m1 = rates["EURUSD", "M1"] # closed bars only
```
- **Credential resolution**: use `resolve_account_spec()` / `resolve_account_specs()` to merge explicit override values over `AccountSpec` fields and expand `${ENV_VAR}` placeholders (via `substitute_env_placeholders()`), raising `ValueError` for missing variables. This keeps secrets out of plan/config files without coupling to any strategy code.
- **Credential resolution**: use `resolve_account_spec()` / `resolve_account_specs()` to merge explicit override values over `AccountSpec` fields and expand `${ENV_VAR}` placeholders (via `substitute_env_placeholders()`), raising `ValueError` for missing variables. This keeps secrets out of plan/config files without coupling to any strategy code. 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`.
- **Trading session helpers**: use `mt5_trading_session()` for a trading-capable `pdmt5.Mt5TradingClient` that initializes/logs in via `Mt5Config.path` and always shuts down safely. Pair with `detect_position_side()`, `calculate_margin_and_volume()`, and `determine_order_limits()` for generic position and sizing utilities. The read-only `mt5_session()` / `Mt5CliClient` SDK is unchanged.
- **Trading session helpers**: use `mt5_trading_session()` for a trading-capable 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.
- **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.
@@ -317,7 +330,7 @@ finally:
client.shutdown()
```
Read-only collectors can keep using `mt5_session()` and `MT5Client` (or the `Mt5CliClient` alias) without changes.
Read-only collectors can keep using `mt5_session()` and `MT5Client`.
## Development
+5 -3
View File
@@ -182,12 +182,14 @@ targets without hard-coding view names:
from pathlib import Path
from mt5cli import (
load_rate_data,
load_rate_series_by_granularity,
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)
rates = load_rate_data(Path("history.db"), view, count=1000)
+3 -5
View File
@@ -13,7 +13,6 @@ responsibilities.
| [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 |
| [Schemas](schemas.md) | Canonical DataFrame contracts and normalization helpers |
| [Storage](storage.md) | CSV/JSON/Parquet/SQLite export and history collection helpers |
| [Converters](converters.md) | Symbol, timeframe, timezone, and date-range utilities |
| [Exceptions](exceptions.md) | Stable mt5cli exception types and MT5 error normalization |
| [SDK](sdk.md) | Module-level fetch helpers, multi-account collectors, incremental history |
@@ -30,15 +29,14 @@ flowchart TD
CLI["mt5cli CLI"] --> Client
Client --> SDK["sdk / pdmt5"]
Client --> Schemas["schemas"]
Storage["storage"] --> History["history SQLite"]
Storage --> Utils["utils export"]
History["history SQLite"] --> Utils["utils export"]
SDK --> PDMT5["pdmt5.Mt5DataClient"]
```
Downstream packages should depend on the package root exports documented in the
[Public API Contract](public-contract.md) (`MT5Client`,
`DataKind`, `normalize_dataframe`, `collect_history`, `load_rate_data`,
`resolve_rate_view_name`, etc.) rather than private modules.
`collect_history`, `load_rate_series_from_sqlite`, etc.) rather than private
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.
+97 -91
View File
@@ -1,61 +1,53 @@
# Public API Contract
mt5cli is the generic MT5 data and execution infrastructure layer for downstream
Python applications. The intended dependency direction is:
mt5cli is the canonical operational trading SDK and CLI/batch layer over pdmt5.
The intended dependency direction is:
```text
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
...`) and treat the symbols listed below as the stable SDK contract. CLI
commands mirror the same behavior but are not importable Python APIs.
...`). The contract set `STABLE_SDK_EXPORTS` in `mt5cli.contract` enumerates
every package-root symbol. Lower-level helpers (schema utilities, export
functions, parser helpers, low-level MT5 wrappers) are available directly from
their owning modules (`mt5cli.schemas`, `mt5cli.utils`, `mt5cli.converters`,
`mt5cli.sdk`, etc.) and are not part of the root SDK surface.
## Stable downstream SDK API
These names are exported from `mt5cli` and covered by the contract in
`mt5cli.STABLE_SDK_EXPORTS` (defined in `mt5cli.contract`). Prefer `MT5Client` over the legacy `Mt5CliClient`
alias for new code.
These names are exported from `mt5cli` and enumerated in
`mt5cli.STABLE_SDK_EXPORTS` (defined in `mt5cli.contract`).
### Session lifecycle and configuration
| Symbol | Role |
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `MT5Client`, `Mt5CliClient` | Read-only data client with optional `order_check` / `order_send` |
| `build_config` | Build `pdmt5.Mt5Config` from connection fields |
| `mt5_session` | Context manager: initialize, login, yield client, shutdown |
| `create_trading_client`, `mt5_trading_session` | Trading-capable `pdmt5.Mt5TradingClient` lifecycle |
| `AccountSpec` | Generic account group: symbols plus optional credentials |
| `resolve_account_spec`, `resolve_account_specs` | Merge overrides and expand `${ENV_VAR}` placeholders; 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` |
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()`,
`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.
### Read-only MT5 data access
Module-level helpers open a transient connection per call. Prefer `mt5_session`
or `MT5Client` when making many requests in one process.
| Area | Symbols |
| -------------------- | ---------------------------------------------------------------------------------------------------- |
| Rates | `copy_rates_from`, `copy_rates_from_pos`, `copy_rates_range`, `latest_rates`, `collect_latest_rates` |
| Ticks | `copy_ticks_from`, `copy_ticks_range`, `recent_ticks` |
| Account / terminal | `account_info`, `terminal_info`, `mt5_version`, `last_error`, `mt5_summary`, `mt5_summary_as_df` |
| Symbols / market | `symbols`, `symbol_info`, `symbol_info_tick`, `market_book`, `minimum_margins` |
| Trading state (read) | `orders`, `positions`, `history_orders`, `history_deals`, `recent_history_deals` |
Use `mt5_version` for MetaTrader 5 terminal version data. The name `version` at
the package root refers to `importlib.metadata.version` (package metadata), not
the MT5 SDK helper.
| Symbol | Role |
| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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` |
| `mt5_session` | Context manager: initialize, login, yield client, shutdown |
| `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 |
| `resolve_account_spec`, `resolve_account_specs` | Merge overrides and expand `${ENV_VAR}` placeholders; opt-in `allow_whole_dollar_env` for bare `$NAME` |
### Closed-bar rate helpers
@@ -67,29 +59,21 @@ timestamp normalization in downstream apps.
| ------------------------------------------------ | ------------------------------------------------------------------------------- |
| `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_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) |
| `collect_latest_closed_rates_for_accounts` | Multi-account closed bars with optional retry wrapper |
| `collect_latest_closed_rates_by_granularity` | Same data keyed by `(symbol, granularity_name)` |
| `collect_latest_rates_for_accounts` | Latest bars including the forming bar when `start_pos=0` |
| `collect_latest_rates_for_accounts_with_retries` | Bounded exponential backoff for transient MT5 errors |
### SQLite history collection and rate loading
| Symbol | Role |
| ----------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `collect_history` | One-shot date-range export into SQLite |
| `update_history`, `update_history_with_config` | Incremental append from `MAX(time)` cursors |
| `ThrottledHistoryUpdater` | Minimum interval between successful incremental updates; optional `update_backend` injection |
| `resolve_history_datasets`, `resolve_history_timeframes`, `resolve_history_tick_flags` | History pipeline configuration |
| `build_rate_view_name`, `resolve_rate_table_name`, `resolve_rate_view_name`, `resolve_rate_view_names`, `resolve_rate_tables` | Map symbols/timeframes to mt5cli-managed table or view names |
| `RateTarget`, `build_rate_targets` | Neutral `(symbol, timeframe)` series descriptors |
| `load_rate_data`, `load_rate_data_from_connection` | Load one table/view into a time-indexed DataFrame |
| `load_rate_series_from_sqlite`, `load_rate_series_by_granularity` | Load one or many series; fail clearly when managed views are missing |
Pass `require_existing=True` to rate view resolution helpers when downstream
code must fail instead of receiving a best-guess view name. Multi-series loaders
require existing managed `rate_*__*` views unless `explicit_tables` is supplied.
| Symbol | Role |
| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `collect_history` | One-shot date-range export into SQLite |
| `update_history`, `update_history_with_config` | Incremental append from `MAX(time)` cursors |
| `ThrottledHistoryUpdater` | Minimum interval between successful incremental updates; optional `update_backend` injection |
| `RateTarget`, `build_rate_targets` | Neutral `(symbol, timeframe)` series descriptors |
| `load_rate_series_from_sqlite`, `load_rate_series_by_granularity` | Load one or many series; fail clearly when managed views are missing |
See [History Collection (SQLite)](history.md) for schema, view naming, and ER
diagrams.
@@ -99,25 +83,38 @@ diagrams.
These helpers implement broker-facing calculations only. They do not encode
strategy entries, exits, Kelly sizing, or signal logic.
| Symbol | Role |
| -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
| `get_account_snapshot`, `get_symbol_snapshot`, `get_tick_snapshot`, `get_positions_frame` | Normalized account/symbol/tick/position views |
| `detect_position_side` | Net long / short / flat from open positions |
| `calculate_spread_ratio` | Relative bid-ask spread |
| `calculate_margin_and_volume`, `calculate_volume_by_margin`, `calculate_new_position_margin_ratio` | Margin budget and volume sizing |
| `normalize_order_volume`, `estimate_order_margin`, `calculate_positions_margin` | Broker volume normalization and margin totals |
| `calculate_positions_margin_by_symbol` | Per-symbol margin map (resilient, first-seen order) |
| `calculate_positions_margin_safe` | Summed total margin across symbols (failed symbols skipped) |
| `determine_order_limits` | SL/TP price levels from ratios |
| `ensure_symbol_selected` | Select/verify Market Watch visibility |
| `place_market_order`, `close_open_positions`, `update_sltp_for_open_positions` | Order execution helpers (`dry_run` supported) |
| `MarginVolume`, `OrderLimits`, `OrderExecutionResult` | Typed return contracts for order helpers |
| `OrderSide`, `OrderFillingMode`, `OrderTimeMode`, `PositionSide`, `ExecutionStatus` | Typed enums for order helpers |
| Symbol | Role |
| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- |
| `get_account_snapshot`, `get_symbol_snapshot`, `get_tick_snapshot`, `get_positions_frame` | Normalized account/symbol/tick/position views |
| `extract_tick_price` | Positive finite bid/ask extraction from tick mappings |
| `detect_position_side` | Net long / short / flat from open positions |
| `calculate_spread_ratio` | Relative bid-ask spread |
| `calculate_margin_and_volume`, `calculate_volume_by_margin`, `calculate_new_position_margin_ratio` | Margin budget and volume sizing |
| `normalize_order_volume`, `estimate_order_margin`, `calculate_positions_margin` | Broker volume normalization and margin totals |
| `calculate_positions_margin_by_symbol` | Per-symbol margin map (resilient, first-seen order) |
| `calculate_positions_margin_safe` | Summed total margin across symbols (failed symbols skipped) |
| `calculate_projected_margin_ratio` | Estimated symbol-scoped margin/equity after optional new exposure |
| `calculate_account_projected_margin_ratio` | Account snapshot margin/equity after optional new exposure |
| `calculate_symbol_group_margin_ratio` | Estimated symbol-group margin/equity with optional exposure |
| `determine_order_limits` | SL/TP price levels from ratios |
| `calculate_trailing_stop_updates` | Per-ticket generic trailing stop-loss update plan |
| `ensure_symbol_selected` | Select/verify Market Watch visibility |
| `place_market_order`, `close_open_positions`, `update_sltp_for_open_positions`, `update_trailing_stop_loss_for_open_positions` | Order execution helpers (`dry_run` supported) |
| `MarginVolume`, `OrderLimits`, `OrderExecutionResult` | Typed return contracts for order helpers |
| `OrderSide`, `OrderFillingMode`, `OrderTimeMode`, `PositionSide`, `ExecutionStatus` | Typed enums for order helpers |
| `ProjectionMode` | Literal type for `calculate_symbol_group_margin_ratio` projection |
`calculate_symbol_group_margin_ratio` accepts an optional `projection_mode`
parameter (`"add"` by default). Pass `projection_mode="replace_symbol"` to
subtract current exposure for `new_symbol` before adding the candidate margin —
useful for reversal-style projections. mt5cli only calculates broker-facing
exposure; downstream applications own thresholds, risk guard actions, and
strategy policy.
`MT5Client.order_send()` and CLI `order-send --yes` are live execution paths.
Order helpers validate broker stop-level distance in `determine_order_limits()` and
raise `Mt5TradingError` when computed SL/TP prices are too close to the entry
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
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
@@ -127,21 +124,25 @@ sending requests. Failed, malformed, or unknown broker retcodes are fail-closed
and returned as `status="failed"` with normalized `request` / `response` details;
`dry_run=True` never calls `ensure_symbol_selected()` or `order_send()`.
### Errors and MT5 type re-exports
### Errors
| Symbol | Role |
| ------------------------------------------------------------------------------------ | ----------------------------------------------- |
| `Mt5CliError`, `Mt5ConnectionError`, `Mt5OperationError`, `Mt5SchemaError` | Stable mt5cli exception types |
| `normalize_mt5_exception`, `call_with_normalized_errors`, `is_recoverable_mt5_error` | Error normalization and retry classification |
| `Mt5Config`, `Mt5RuntimeError`, `Mt5TradingClient`, `Mt5TradingError` | Re-exported pdmt5 types for adapter convenience |
| Symbol | Role |
| -------------------------------------------------------------------------- | ----------------------------- |
| `Mt5CliError`, `Mt5ConnectionError`, `Mt5OperationError`, `Mt5SchemaError` | Stable mt5cli exception types |
### Additional public exports (secondary)
## Module-scoped helpers
The package root also exports schema, storage, and parsing helpers (for example
`DataKind`, `Dataset`, `normalize_dataframe`, `export_dataframe`,
`parse_timeframe`, `TIMEFRAME_MAP`). These are public but oriented toward export
pipelines and advanced integration. Prefer the stable symbols above for core
infrastructure.
Lower-level helpers are available from their owning modules and are not part
of the package-root stable surface. Import them directly when needed:
| Module | Examples |
| ------------------- | ---------------------------------------------------------------------------------------------- |
| `mt5cli.history` | `resolve_rate_view_name`, `resolve_rate_tables`, `load_rate_data`, `build_rate_view_name` |
| `mt5cli.sdk` | `copy_rates_from`, `copy_ticks_from`, `account_info`, `symbols`, `mt5_summary`, `latest_rates` |
| `mt5cli.schemas` | `DataKind`, `normalize_dataframe`, `validate_schema`, `DEDUP_KEYS` |
| `mt5cli.utils` | `Dataset`, `IfExists`, `detect_format`, `export_dataframe`, `export_dataframe_to_sqlite` |
| `mt5cli.converters` | `normalize_symbol`, `ensure_utc`, `parse_date_range`, `granularity_name` |
| `mt5cli.exceptions` | `normalize_mt5_exception`, `call_with_normalized_errors`, `is_recoverable_mt5_error` |
## CLI commands
@@ -154,7 +155,12 @@ The Typer application in `mt5cli.cli` exposes file-export commands documented in
- Delegate to the same Python APIs described here; they are not duplicated
business logic.
`order-send` requires `--yes` before placing live trades.
`order-send` is the expert raw-request path; it requires `--yes` and a fully
constructed request payload. `close-positions` is the safer high-level helper
that closes open positions by `--symbol` or `--ticket` using
`close_open_positions()`. Both `order-send --yes` and `close-positions --yes`
are live execution paths. `close-positions --dry-run` previews close orders
without placing them and does not require `--yes`.
## Internal helpers (not stable)
@@ -191,6 +197,6 @@ their own adapter layer.
## Contract verification
`tests/test_contracts.py` asserts that every name in `STABLE_SDK_EXPORTS` is
importable from `mt5cli`, documents key closed-bar, rate-view, SQLite loading,
account-resolution, and trading-session behaviors, and keeps the contract set
aligned with `__all__`.
importable from `mt5cli`, that all package-root exports are covered by the
stable set, and documents key closed-bar, SQLite loading, account-resolution,
and trading-session behaviors.
+5 -4
View File
@@ -31,7 +31,7 @@ rates = collect_latest_rates_for_accounts_with_retries(
### Latest closed rate bars
MetaTrader 5 `start_pos=0` includes the still-forming current bar as the last
row. `fetch_latest_closed_rates()` handles one connected `Mt5CliClient`; use
row. `fetch_latest_closed_rates()` handles one connected `MT5Client`; use
`fetch_latest_closed_rates_for_trading_client()` from an active
`Mt5TradingClient` session. Multi-account helpers fetch `count + 1` bars, drop
that row with `drop_forming_rate_bar()`, and validate each series is non-empty. Returned frames are ordered
@@ -117,7 +117,8 @@ call it every iteration without over-fetching.
```python
from pdmt5 import Mt5Config, Mt5DataClient
from mt5cli import Dataset, ThrottledHistoryUpdater
from mt5cli import ThrottledHistoryUpdater
from mt5cli.utils import Dataset
updater = ThrottledHistoryUpdater(
output="history.db",
@@ -170,5 +171,5 @@ resulting `ValueError` is suppressed along with other recoverable errors.
## Trading-capable sessions
For order placement and trading calculations, use the dedicated
[Trading module](trading.md). The read-only `Mt5CliClient` and `mt5_session()`
helpers in this module are unchanged.
[Trading module](trading.md). Use `mt5_session()` / `MT5Client` for read-only
collection.
-3
View File
@@ -1,3 +0,0 @@
# Storage
::: mt5cli.storage
+8 -7
View File
@@ -6,8 +6,9 @@
`create_trading_client()` and `mt5_trading_session()` complement the read-only
`mt5_session()` helper in `sdk.py`. They return or yield an initialized
`pdmt5.Mt5TradingClient`, use `Mt5Config.path` to launch the terminal when
configured, and `mt5_trading_session()` always calls `shutdown()` on exit.
client supporting order execution and account management, use `Mt5Config.path`
to launch the terminal when configured, and `mt5_trading_session()` always
calls `shutdown()` on exit.
```python
from mt5cli import create_trading_client, mt5_trading_session
@@ -31,7 +32,7 @@ finally:
`login` accepts `int`, numeric `str`, or an empty string; empty strings are
treated as unset. `path`, `password`, `server`, and `timeout` are forwarded to
`pdmt5.Mt5Config`, and omitted `timeout` values keep the lower-level default.
The read-only `Mt5CliClient` / `mt5_session()` API is unchanged.
Use `mt5_session()` / `MT5Client` for read-only data collection.
## State and order helpers
@@ -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
sell-only exposure, and `None` for no positions or mixed long/short exposure.
`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
sub-minimum requests; check the result before calling `estimate_order_margin()`,
which requires a positive finite volume. `calculate_positions_margin()` silently
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.
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
available. `determine_order_limits()` pre-validates computed SL/TP prices against
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
side, freeze levels, and server-side rules, and it does not validate
`trade_freeze_level`. When symbol metadata cannot be loaded, protective prices
@@ -194,6 +195,6 @@ through the stable package root without embedding entry/exit policy.
| Local SL/TP price derivation | `determine_order_limits()` |
| Throttled SQLite history loop with ad-hoc error handling | `ThrottledHistoryUpdater(suppress_errors=True)` |
Keep read-only data collection on `mt5_session()` / `Mt5CliClient`; use
Keep read-only data collection on `mt5_session()` / `MT5Client`; use
`mt5_trading_session()` only where order placement or trading calculations are
required.
+12 -10
View File
@@ -27,28 +27,30 @@ mt5cli provides a stable `MT5Client` Python API, standardized dataset schemas, s
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
Import `MT5Client` for generic MT5 data access, schema normalization, and optional order primitives. `Mt5CliClient` remains available as a backward-compatible alias.
Import `MT5Client` for generic MT5 data access, schema normalization, and optional order primitives.
```python
from datetime import UTC, datetime
from pathlib import Path
from mt5cli import (
DataKind,
Dataset,
MT5Client,
build_config,
collect_history,
export_dataframe,
load_rate_data,
minimum_margins,
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
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`).
-1
View File
@@ -59,7 +59,6 @@ nav:
- Public API Contract: api/public-contract.md
- Client: api/client.md
- Schemas: api/schemas.md
- Storage: api/storage.md
- Converters: api/converters.md
- Exceptions: api/exceptions.md
- CLI: api/cli.md
+14 -148
View File
@@ -8,106 +8,35 @@ strategy responsibilities.
from importlib.metadata import version
from pdmt5 import Mt5Config, Mt5RuntimeError, Mt5TradingClient, Mt5TradingError
from .client import MT5Client, build_config, mt5_session
from .contract import STABLE_SDK_EXPORTS
from .converters import (
ensure_utc,
granularity_name,
normalize_symbol,
normalize_symbols,
parse_date_range,
recent_window,
)
from .exceptions import (
Mt5CliError,
Mt5ConnectionError,
Mt5OperationError,
Mt5SchemaError,
call_with_normalized_errors,
is_recoverable_mt5_error,
normalize_mt5_exception,
)
from .history import (
RateTarget,
build_rate_targets,
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_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 (
AccountSpec,
Mt5CliClient,
ThrottledHistoryUpdater,
account_info,
collect_history,
collect_latest_closed_rates_by_granularity,
collect_latest_closed_rates_for_accounts,
collect_latest_rates,
collect_latest_rates_for_accounts,
collect_latest_rates_for_accounts_with_retries,
copy_rates_from,
copy_rates_from_pos,
copy_rates_range,
copy_ticks_from,
copy_ticks_range,
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_specs,
substitute_env_placeholders,
symbol_info,
symbol_info_tick,
symbols,
terminal_info,
update_history,
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 (
POSITION_COLUMNS,
ExecutionStatus,
MarginVolume,
OrderExecutionResult,
@@ -116,12 +45,17 @@ from .trading import (
OrderSide,
OrderTimeMode,
PositionSide,
ProjectionMode,
calculate_account_projected_margin_ratio,
calculate_margin_and_volume,
calculate_new_position_margin_ratio,
calculate_positions_margin,
calculate_positions_margin_by_symbol,
calculate_positions_margin_safe,
calculate_projected_margin_ratio,
calculate_spread_ratio,
calculate_symbol_group_margin_ratio,
calculate_trailing_stop_updates,
calculate_volume_by_margin,
close_open_positions,
create_trading_client,
@@ -129,6 +63,7 @@ from .trading import (
determine_order_limits,
ensure_symbol_selected,
estimate_order_margin,
extract_tick_price,
fetch_latest_closed_rates_for_trading_client,
fetch_latest_closed_rates_indexed,
get_account_snapshot,
@@ -139,84 +74,55 @@ from .trading import (
normalize_order_volume,
place_market_order,
update_sltp_for_open_positions,
)
from .utils import (
TICK_FLAG_MAP,
TIMEFRAME_MAP,
parse_datetime,
parse_tick_flags,
parse_timeframe,
update_trailing_stop_loss_for_open_positions,
)
__version__ = version(__package__) if __package__ else None
__all__ = [
"DEDUP_KEYS",
"KNOWN_MT5_TIME_COLUMNS",
"POSITION_COLUMNS",
"REQUIRED_COLUMNS",
"STABLE_SDK_EXPORTS",
"TICK_FLAG_MAP",
"TIMEFRAME_MAP",
"TIME_COLUMNS",
"AccountSpec",
"DataKind",
"Dataset",
"ExecutionStatus",
"IfExists",
"MT5Client",
"MarginVolume",
"Mt5CliClient",
"Mt5CliError",
"Mt5Config",
"Mt5ConnectionError",
"Mt5OperationError",
"Mt5RuntimeError",
"Mt5SchemaError",
"Mt5TradingClient",
"Mt5TradingError",
"OrderExecutionResult",
"OrderFillingMode",
"OrderLimits",
"OrderSide",
"OrderTimeMode",
"PositionSide",
"ProjectionMode",
"RateTarget",
"ThrottledHistoryUpdater",
"account_info",
"build_config",
"build_rate_targets",
"build_rate_view_name",
"calculate_account_projected_margin_ratio",
"calculate_margin_and_volume",
"calculate_new_position_margin_ratio",
"calculate_positions_margin",
"calculate_positions_margin_by_symbol",
"calculate_positions_margin_safe",
"calculate_projected_margin_ratio",
"calculate_spread_ratio",
"calculate_symbol_group_margin_ratio",
"calculate_trailing_stop_updates",
"calculate_volume_by_margin",
"call_with_normalized_errors",
"close_open_positions",
"collect_history",
"collect_latest_closed_rates_by_granularity",
"collect_latest_closed_rates_for_accounts",
"collect_latest_rates",
"collect_latest_rates_for_accounts",
"collect_latest_rates_for_accounts_with_retries",
"copy_rates_from",
"copy_rates_from_pos",
"copy_rates_range",
"copy_ticks_from",
"copy_ticks_range",
"create_trading_client",
"detect_format",
"detect_position_side",
"determine_order_limits",
"drop_forming_rate_bar",
"ensure_symbol_selected",
"ensure_utc",
"estimate_order_margin",
"export_dataframe",
"export_dataframe_to_sqlite",
"extract_tick_price",
"fetch_latest_closed_rates",
"fetch_latest_closed_rates_for_trading_client",
"fetch_latest_closed_rates_indexed",
@@ -224,56 +130,16 @@ __all__ = [
"get_positions_frame",
"get_symbol_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_from_sqlite",
"market_book",
"minimum_margins",
"mt5_session",
"mt5_summary",
"mt5_summary_as_df",
"mt5_trading_session",
"mt5_version",
"normalize_dataframe",
"normalize_mt5_exception",
"normalize_order_volume",
"normalize_symbol",
"normalize_symbols",
"normalize_time_columns",
"orders",
"parse_date_range",
"parse_datetime",
"parse_tick_flags",
"parse_timeframe",
"place_market_order",
"positions",
"recent_history_deals",
"recent_ticks",
"recent_window",
"resolve_account_spec",
"resolve_account_specs",
"resolve_history_datasets",
"resolve_history_tick_flags",
"resolve_history_timeframes",
"resolve_rate_table_name",
"resolve_rate_tables",
"resolve_rate_view_name",
"resolve_rate_view_names",
"schema_columns",
"substitute_env_placeholders",
"symbol_info",
"symbol_info_tick",
"symbols",
"terminal_info",
"update_history",
"update_history_with_config",
"update_sltp_for_open_positions",
"validate_schema",
"update_trailing_stop_loss_for_open_positions",
]
+93 -2
View File
@@ -2,17 +2,20 @@
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from datetime import datetime # noqa: TC003
from pathlib import Path # noqa: TC003
from typing import TYPE_CHECKING, Annotated, Any, cast
import pandas as pd
import typer
from pdmt5 import Mt5Config
from . import sdk
from .client import MT5Client
from .trading import OrderExecutionResult, close_open_positions, create_trading_client
from .utils import (
DATETIME_TYPE,
REQUEST_TYPE,
@@ -29,8 +32,6 @@ from .utils import (
if TYPE_CHECKING:
from collections.abc import Callable
import pandas as pd
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
@@ -600,6 +601,96 @@ def order_send(
_export_command(ctx, lambda client: client.order_send(request))
_EXECUTION_RESULT_COLUMNS: list[str] = [
"status",
"symbol",
"order_side",
"volume",
"retcode",
"comment",
"request",
"response",
"dry_run",
]
def _execution_results_to_df(results: list[OrderExecutionResult]) -> pd.DataFrame:
if not results:
return pd.DataFrame(columns=_EXECUTION_RESULT_COLUMNS)
rows = [
{
**r,
"request": json.dumps(r["request"]),
"response": json.dumps(r["response"]),
}
for r in results
]
return pd.DataFrame(rows)
@app.command()
def close_positions(
ctx: typer.Context,
symbol: Annotated[
list[str] | None,
typer.Option(
"--symbol",
"-s",
help="Symbol to close (repeat for multiple symbols).",
),
] = None,
ticket: Annotated[
list[int] | None,
typer.Option(
"--ticket",
"-t",
help="Position ticket to close (repeat for multiple tickets).",
),
] = None,
dry_run: Annotated[
bool,
typer.Option("--dry-run", help="Preview close orders without executing them."),
] = False,
yes: Annotated[
bool,
typer.Option("--yes", help="Confirm live position closing."),
] = False,
) -> None:
"""Close open positions by symbol or ticket.
Delegates to :func:`mt5cli.trading.close_open_positions`. At least one
``--symbol`` or ``--ticket`` must be provided to avoid accidentally closing
all positions. Use ``--dry-run`` to preview without executing; ``--yes`` is
required for live execution.
``order-send`` is the expert raw-request path. ``close-positions`` is the
safer high-level helper that builds correct close requests automatically.
Raises:
typer.BadParameter: If neither ``--symbol`` nor ``--ticket`` is given,
or if ``--yes`` is missing for a live (non-dry-run) run.
"""
if not symbol and not ticket:
msg = "Provide at least one --symbol or --ticket to close positions."
raise typer.BadParameter(msg)
if not dry_run and not yes:
msg = "Pass --yes to close live positions."
raise typer.BadParameter(msg, param_hint="--yes")
export_ctx = _get_export_context(ctx)
client = create_trading_client(config=export_ctx.config)
try:
results = close_open_positions(
client,
symbols=list(symbol) if symbol else None,
tickets=list(ticket) if ticket else None,
dry_run=dry_run,
)
finally:
client.shutdown()
df = _execution_results_to_df(results)
_execute_export(ctx, lambda: df)
@app.command()
def collect_history(
ctx: typer.Context,
+1 -3
View File
@@ -24,9 +24,7 @@ class MT5Client(Mt5CliClient):
"""Public client for generic MT5 data access and order primitives.
Extends the read-only SDK client with optional order check/send helpers and
exposes the same connection lifecycle as :class:`~mt5cli.sdk.Mt5CliClient`.
Downstream applications such as private trading packages should prefer this
type over the legacy ``Mt5CliClient`` name.
exposes the same connection lifecycle as :func:`mt5_session`.
mt5cli intentionally exposes minimal execution primitives only. Trading
decisions, signals, strategies, backtests, and optimization remain the
+8 -47
View File
@@ -1,61 +1,50 @@
"""Stable downstream SDK export names for mt5cli."""
"""Downstream SDK export tier for mt5cli."""
from __future__ import annotations
STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
"AccountSpec",
"MT5Client",
"Mt5CliClient",
"Mt5CliError",
"Mt5Config",
"Mt5ConnectionError",
"Mt5OperationError",
"Mt5RuntimeError",
"Mt5SchemaError",
"Mt5TradingClient",
"Mt5TradingError",
"OrderFillingMode",
"OrderSide",
"OrderTimeMode",
"PositionSide",
"ProjectionMode",
"ExecutionStatus",
"MarginVolume",
"OrderExecutionResult",
"OrderLimits",
"RateTarget",
"ThrottledHistoryUpdater",
"account_info",
"build_config",
"build_rate_targets",
"build_rate_view_name",
"calculate_account_projected_margin_ratio",
"calculate_margin_and_volume",
"calculate_new_position_margin_ratio",
"calculate_projected_margin_ratio",
"calculate_positions_margin",
"calculate_positions_margin_by_symbol",
"calculate_positions_margin_safe",
"calculate_spread_ratio",
"calculate_symbol_group_margin_ratio",
"calculate_trailing_stop_updates",
"calculate_volume_by_margin",
"call_with_normalized_errors",
"close_open_positions",
"collect_history",
"collect_latest_closed_rates_by_granularity",
"collect_latest_closed_rates_for_accounts",
"collect_latest_rates",
"collect_latest_rates_for_accounts",
"collect_latest_rates_for_accounts_with_retries",
"copy_rates_from",
"copy_rates_from_pos",
"copy_rates_range",
"copy_ticks_from",
"copy_ticks_range",
"create_trading_client",
"detect_position_side",
"determine_order_limits",
"drop_forming_rate_bar",
"ensure_symbol_selected",
"estimate_order_margin",
"export_dataframe",
"export_dataframe_to_sqlite",
"extract_tick_price",
"fetch_latest_closed_rates",
"fetch_latest_closed_rates_for_trading_client",
"fetch_latest_closed_rates_indexed",
@@ -63,46 +52,18 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
"get_positions_frame",
"get_symbol_snapshot",
"get_tick_snapshot",
"history_deals",
"history_orders",
"is_recoverable_mt5_error",
"last_error",
"latest_rates",
"load_rate_data",
"load_rate_data_from_connection",
"load_rate_series_by_granularity",
"load_rate_series_from_sqlite",
"market_book",
"minimum_margins",
"mt5_session",
"mt5_summary",
"mt5_summary_as_df",
"mt5_trading_session",
"mt5_version",
"normalize_mt5_exception",
"normalize_order_volume",
"orders",
"place_market_order",
"positions",
"recent_history_deals",
"recent_ticks",
"resolve_account_spec",
"resolve_account_specs",
"resolve_history_datasets",
"resolve_history_tick_flags",
"resolve_history_timeframes",
"resolve_rate_table_name",
"resolve_rate_tables",
"resolve_rate_view_name",
"resolve_rate_view_names",
"substitute_env_placeholders",
"symbol_info",
"symbol_info_tick",
"symbols",
"terminal_info",
"update_history",
"update_history_with_config",
"update_sltp_for_open_positions",
"update_trailing_stop_loss_for_open_positions",
})
__all__ = ["STABLE_SDK_EXPORTS"]
+9 -4
View File
@@ -4,11 +4,16 @@ from __future__ import annotations
from typing import TYPE_CHECKING, TypeVar
from pdmt5 import Mt5RuntimeError, Mt5TradingError
from pdmt5 import Mt5RuntimeError
if TYPE_CHECKING:
from collections.abc import Callable
try:
from pdmt5 import Mt5TradingError
except ImportError: # pragma: no cover
Mt5TradingError = None # type: ignore[assignment]
T = TypeVar("T")
__all__ = [
@@ -22,7 +27,7 @@ __all__ = [
]
_RECOVERABLE_MT5_ERRORS: tuple[type[BaseException], ...] = (
Mt5TradingError,
*([Mt5TradingError] if Mt5TradingError is not None else []), # type: ignore[misc]
Mt5RuntimeError,
)
@@ -50,7 +55,7 @@ def is_recoverable_mt5_error(exc: BaseException) -> bool:
exc: Exception raised by MT5 or pdmt5.
Returns:
True for ``Mt5RuntimeError`` and ``Mt5TradingError``.
True for ``Mt5RuntimeError`` and ``Mt5TradingError`` (if available).
"""
return isinstance(exc, _RECOVERABLE_MT5_ERRORS)
@@ -65,7 +70,7 @@ def normalize_mt5_exception(exc: BaseException) -> Mt5CliError:
``Mt5ConnectionError`` for runtime failures, ``Mt5OperationError`` for
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))
if isinstance(exc, Mt5RuntimeError):
return Mt5ConnectionError(str(exc))
+90 -8
View File
@@ -15,7 +15,12 @@ from pathlib import Path
from typing import TYPE_CHECKING, Self, TypeVar, cast
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 (
create_cash_events_view,
@@ -40,7 +45,7 @@ from .utils import (
from .utils import coerce_login as _coerce_login
if TYPE_CHECKING:
from collections.abc import Callable, Iterator, Sequence
from collections.abc import Callable, Collection, Iterator, Sequence
UpdateHistoryBackend = Callable[..., None]
@@ -49,7 +54,7 @@ T = TypeVar("T")
logger = logging.getLogger(__name__)
_RECOVERABLE_HISTORY_UPDATE_ERRORS: tuple[type[BaseException], ...] = (
Mt5TradingError,
*([Mt5TradingError] if Mt5TradingError is not None else []), # type: ignore[assignment]
Mt5RuntimeError,
sqlite3.Error,
ValueError,
@@ -142,6 +147,7 @@ __all__ = [
"resolve_account_spec",
"resolve_account_specs",
"substitute_env_placeholders",
"substitute_mapping_values",
"symbol_info",
"symbol_info_tick",
"symbols",
@@ -305,7 +311,7 @@ def _fetch_minimum_margins(client: Mt5DataClient, symbol: str) -> pd.DataFrame:
def build_config(
*,
path: str | None = None,
login: int | None = None,
login: int | str | None = None,
password: str | None = None,
server: str | None = None,
timeout: int | None = None,
@@ -315,14 +321,19 @@ def build_config(
Args:
path: Optional terminal executable path.
login: Optional trading account login.
login: Optional trading account login. Integers are preserved. String
values are coerced: empty or whitespace-only strings become
``None``; numeric strings such as ``"12345"`` are converted to
``int``; non-numeric strings raise ``ValueError``. When
``allow_whole_dollar_env=True``, ``$ENV_NAME`` and
``${ENV_NAME}`` placeholders are expanded before coercion.
password: Optional trading account password.
server: Optional trading server name.
timeout: Optional connection timeout in milliseconds.
allow_whole_dollar_env: When ``True``, string parameters that are
exactly ``$ENV_NAME`` are expanded from the environment. Applies
to ``path``, ``password``, and ``server``. Default ``False``
preserves existing behavior.
to ``path``, ``login``, ``password``, and ``server``. Default
``False`` preserves existing behavior.
Returns:
Configured ``Mt5Config`` instance.
@@ -330,6 +341,8 @@ def build_config(
if allow_whole_dollar_env:
if path is not None:
path = substitute_env_placeholders(path, allow_whole_dollar_env=True)
if isinstance(login, str):
login = substitute_env_placeholders(login, allow_whole_dollar_env=True)
if password is not None:
password = substitute_env_placeholders(
password, allow_whole_dollar_env=True
@@ -338,7 +351,7 @@ def build_config(
server = substitute_env_placeholders(server, allow_whole_dollar_env=True)
return Mt5Config(
path=path,
login=login,
login=_coerce_login(login),
password=password,
server=server,
timeout=timeout,
@@ -1442,6 +1455,75 @@ def substitute_env_placeholders(
return "".join(parts)
def substitute_mapping_values(
data: object,
*,
keys: Collection[str],
allow_whole_dollar_env: bool = False,
blank_string_keys_as_none: Collection[str] = (),
) -> object:
"""Recursively substitute environment placeholders for selected mapping keys.
Traverses nested dicts and lists, expanding ``${ENV_VAR}`` (and
``$ENV_NAME`` when ``allow_whole_dollar_env=True``) in string values
whose immediate parent dict key is in ``keys``. Fields whose key is
not in ``keys`` are preserved exactly, including literal dollar signs.
Strings that are direct elements of a list are never substituted;
substitution only applies to strings that are immediate dict values.
This is a generic downstream config utility. Key names such as
``mt5_login`` or ``mt5_password`` must be supplied by the caller;
mt5cli does not hard-code any application-specific key names.
Callers are responsible for ensuring ``data`` has bounded nesting depth;
deeply nested or self-referential structures will hit Python's recursion
limit.
Args:
data: Arbitrarily nested dict/list/scalar value to process.
keys: Mapping keys whose string values receive placeholder
substitution.
allow_whole_dollar_env: When ``True``, a string that is exactly
``$ENV_NAME`` (whole value) is also expanded from the
environment in addition to ``${ENV_NAME}`` placeholders.
Default ``False`` expands ``${ENV_NAME}`` only.
blank_string_keys_as_none: Mapping keys for which blank strings
(after any substitution) are normalised to ``None``. A key
may appear in ``blank_string_keys_as_none`` without also
appearing in ``keys``.
Returns:
The processed value. Dicts and lists are rebuilt into new
containers with selected string values substituted and
blank-normalised. Scalar inputs (non-dict, non-list) are
returned as-is.
"""
keys_set: frozenset[str] = frozenset(keys)
blank_keys_set: frozenset[str] = frozenset(blank_string_keys_as_none)
def _visit(node: object, current_key: str | None) -> object:
if isinstance(node, dict):
typed = cast("dict[object, object]", node)
return {
k: _visit(v, k if isinstance(k, str) else None)
for k, v in typed.items()
}
if isinstance(node, list):
typed_list = cast("list[object]", node)
return [_visit(item, None) for item in typed_list]
if not isinstance(node, str):
return node
text = node
if current_key in keys_set:
text = substitute_env_placeholders(
node, allow_whole_dollar_env=allow_whole_dollar_env
)
if current_key in blank_keys_set and not text.strip():
return None
return text
return _visit(data, None)
def _resolve_field(
override: str | None,
account_value: str | None,
-49
View File
@@ -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",
]
+443 -101
View File
@@ -6,26 +6,85 @@ import logging
from contextlib import contextmanager
from math import floor, isfinite
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
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 .sdk import build_config
from .utils import coerce_login as _coerce_login
from .utils import parse_timeframe
if TYPE_CHECKING:
from collections.abc import Iterator, Mapping, Sequence
from typing import Any
_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"]
OrderSide = Literal["BUY", "SELL"]
OrderFillingMode = Literal["IOC", "FOK", "RETURN"]
OrderTimeMode = Literal["GTC", "DAY", "SPECIFIED", "SPECIFIED_DAY"]
ExecutionStatus = Literal["executed", "dry_run", "skipped", "failed"]
ProjectionMode = Literal["add", "replace_symbol"]
class MarginVolume(TypedDict):
@@ -128,12 +187,17 @@ __all__ = [
"OrderSide",
"OrderTimeMode",
"PositionSide",
"ProjectionMode",
"calculate_account_projected_margin_ratio",
"calculate_margin_and_volume",
"calculate_new_position_margin_ratio",
"calculate_positions_margin",
"calculate_positions_margin_by_symbol",
"calculate_positions_margin_safe",
"calculate_projected_margin_ratio",
"calculate_spread_ratio",
"calculate_symbol_group_margin_ratio",
"calculate_trailing_stop_updates",
"calculate_volume_by_margin",
"close_open_positions",
"create_trading_client",
@@ -141,6 +205,7 @@ __all__ = [
"determine_order_limits",
"ensure_symbol_selected",
"estimate_order_margin",
"extract_tick_price",
"fetch_latest_closed_rates_for_trading_client",
"fetch_latest_closed_rates_indexed",
"get_account_snapshot",
@@ -151,6 +216,7 @@ __all__ = [
"normalize_order_volume",
"place_market_order",
"update_sltp_for_open_positions",
"update_trailing_stop_loss_for_open_positions",
]
@@ -181,7 +247,7 @@ def _validate_protective_prices(
"""Validate SL/TP distances against broker stop-level constraints.
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:
return
@@ -191,37 +257,37 @@ def _validate_protective_prices(
f"Stop loss for {symbol!r} violates broker stop level "
f"(minimum distance {min_distance})."
)
raise Mt5TradingError(msg)
raise Mt5OperationError(msg)
if take_profit is not None and (take_profit - entry) < min_distance:
msg = (
f"Take profit for {symbol!r} violates broker stop level "
f"(minimum distance {min_distance})."
)
raise Mt5TradingError(msg)
raise Mt5OperationError(msg)
return
if stop_loss is not None and (stop_loss - entry) < min_distance:
msg = (
f"Stop loss for {symbol!r} violates broker stop level "
f"(minimum distance {min_distance})."
)
raise Mt5TradingError(msg)
raise Mt5OperationError(msg)
if take_profit is not None and (entry - take_profit) < min_distance:
msg = (
f"Take profit for {symbol!r} violates broker stop level "
f"(minimum distance {min_distance})."
)
raise Mt5TradingError(msg)
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.
Args:
client: Connected ``Mt5TradingClient`` instance.
client: Connected MT5 client instance.
symbol: Symbol to select.
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.
"""
snapshot = get_symbol_snapshot(client, symbol)
@@ -230,13 +296,13 @@ def ensure_symbol_selected(client: Mt5TradingClient, symbol: str) -> None:
select = getattr(client, "symbol_select", None)
if not callable(select):
msg = "MT5 client is missing required method: symbol_select"
raise Mt5TradingError(msg)
raise Mt5OperationError(msg)
if select(symbol, enable=True):
return
last_error = getattr(client, "last_error", None)
detail = f" ({last_error()})" if callable(last_error) else ""
msg = f"Failed to select symbol {symbol!r} in Market Watch{detail}."
raise Mt5TradingError(msg)
raise Mt5OperationError(msg)
def _require_unit_ratio(value: float, name: str) -> None:
@@ -363,7 +429,7 @@ def _snapshot_from_value(value: object, fields: tuple[str, ...]) -> dict[str, ob
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:
method = getattr(client, name, None)
if callable(method):
@@ -387,7 +453,7 @@ def _resolve_mt5_constant(
return cast("int", getattr(mt5, name))
except AttributeError as exc:
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:
@@ -428,7 +494,7 @@ def _optional_price(value: object) -> float | None:
return price
def _valid_tick_price(tick: Mapping[str, object], key: str) -> float | None:
def extract_tick_price(tick: Mapping[str, object], key: str) -> float | None:
"""Return a positive finite float from tick[key], or None if invalid.
Accepts int, float, or numeric string values. Returns None when the key is
@@ -471,7 +537,7 @@ def _order_status_from_retcode(mt5: object, retcode: object) -> ExecutionStatus:
def _calculate_min_volume_if_affordable(
client: Mt5TradingClient,
client: _Mt5ClientProtocol,
symbol: str,
available_margin: float,
order_side: OrderSide,
@@ -488,14 +554,14 @@ def _calculate_min_volume_if_affordable(
or (volume_max > 0 and volume_min > volume_max)
):
msg = f"Invalid volume constraints for {symbol!r}."
raise Mt5TradingError(msg)
raise Mt5OperationError(msg)
side = _normalize_order_side(order_side)
price = _valid_tick_price(
price = extract_tick_price(
get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid"
)
if price is None:
msg = f"Tick price is unavailable for {symbol!r}."
raise Mt5TradingError(msg)
raise Mt5OperationError(msg)
order_type = (
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
)
@@ -512,8 +578,12 @@ def create_trading_client(
path: str | None = None,
timeout: int | None = None,
retry_count: int = 0,
) -> Mt5TradingClient:
"""Return an initialized and logged-in trading client."""
) -> _Mt5ClientProtocol:
"""Return an initialized and logged-in trading client.
Returns:
A client instance supporting the required MT5 trading methods.
"""
mt5_config = _resolve_config(
config=config,
login=login,
@@ -522,7 +592,7 @@ def create_trading_client(
path=path,
timeout=timeout,
)
client = Mt5TradingClient(config=mt5_config, retry_count=retry_count)
client = Mt5DataClient(config=mt5_config, retry_count=retry_count)
try:
client.initialize_and_login_mt5()
except Exception:
@@ -532,13 +602,13 @@ def create_trading_client(
def detect_position_side(
client: Mt5TradingClient,
client: _Mt5ClientProtocol,
symbol: str,
) -> PositionSide | None:
"""Detect the net open position side for a symbol.
Args:
client: Connected ``Mt5TradingClient`` instance.
client: Connected MT5 client instance.
symbol: Symbol to inspect.
Returns:
@@ -562,7 +632,7 @@ def detect_position_side(
def get_account_snapshot(
client: Mt5TradingClient,
client: _Mt5ClientProtocol,
) -> dict[str, float | int | str | None]:
"""Return normalized account state with stable keys."""
value = _call_snapshot_method(client, "account_info_as_dict", "account_info")
@@ -573,7 +643,7 @@ def get_account_snapshot(
def get_symbol_snapshot(
client: Mt5TradingClient,
client: _Mt5ClientProtocol,
symbol: str,
) -> dict[str, float | int | str | bool | None]:
"""Return normalized symbol metadata required for trading decisions."""
@@ -585,7 +655,7 @@ def get_symbol_snapshot(
def get_tick_snapshot(
client: Mt5TradingClient,
client: _Mt5ClientProtocol,
symbol: str,
) -> dict[str, float | int | None]:
"""Return normalized latest tick data, including bid, ask, and timestamp."""
@@ -599,7 +669,7 @@ def get_tick_snapshot(
def get_positions_frame(
client: Mt5TradingClient,
client: _Mt5ClientProtocol,
symbol: str | None = None,
) -> pd.DataFrame:
"""Return open positions as a DataFrame with stable baseline columns."""
@@ -611,7 +681,7 @@ def get_positions_frame(
def _order_side_from_position_type(
client: Mt5TradingClient,
client: _Mt5ClientProtocol,
position_type: object,
) -> OrderSide | None:
if position_type == client.mt5.POSITION_TYPE_BUY:
@@ -633,7 +703,7 @@ def _ensure_rate_time_column(frame: pd.DataFrame) -> pd.DataFrame:
def estimate_order_margin(
client: Mt5TradingClient,
client: _Mt5ClientProtocol,
symbol: str,
order_side: OrderSide | str,
volume: float,
@@ -644,17 +714,17 @@ def estimate_order_margin(
Positive finite margin required for the order at the current quote.
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):
msg = "Volume must be a positive finite number to estimate order margin."
raise Mt5TradingError(msg)
raise Mt5OperationError(msg)
side = _normalize_order_side(order_side)
tick = get_tick_snapshot(client, symbol)
price = _valid_tick_price(tick, "ask" if side == "BUY" else "bid")
price = extract_tick_price(tick, "ask" if side == "BUY" else "bid")
if price is None:
msg = f"Tick price is unavailable for {symbol!r}."
raise Mt5TradingError(msg)
raise Mt5OperationError(msg)
order_type = (
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
)
@@ -663,22 +733,22 @@ def estimate_order_margin(
margin = float(raw_margin)
except (TypeError, ValueError) as exc:
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):
msg = f"Margin estimate is invalid for {symbol!r}."
raise Mt5TradingError(msg)
raise Mt5OperationError(msg)
return margin
def calculate_positions_margin(
client: Mt5TradingClient,
client: _Mt5ClientProtocol,
*,
symbols: Sequence[str] | None = None,
) -> float:
"""Return the sum of estimated current margin for open positions.
Args:
client: Connected ``Mt5TradingClient`` instance.
client: Connected MT5 client instance.
symbols: Optional symbol filter. When omitted, all open positions are
included.
@@ -713,7 +783,7 @@ def calculate_positions_margin(
def calculate_positions_margin_by_symbol(
client: Mt5TradingClient,
client: _Mt5ClientProtocol,
*,
symbols: Sequence[str],
suppress_errors: bool = True,
@@ -725,10 +795,10 @@ def calculate_positions_margin_by_symbol(
first-seen order.
Args:
client: Connected ``Mt5TradingClient`` instance.
client: Connected MT5 client instance.
symbols: Symbols to compute margin for.
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.
Returns:
@@ -737,7 +807,7 @@ def calculate_positions_margin_by_symbol(
with ``suppress_errors=True``.
Raises:
Mt5TradingError: When a symbol raises ``Mt5TradingError`` and
Mt5OperationError: When a symbol raises ``Mt5OperationError`` and
``suppress_errors=False``.
Mt5RuntimeError: When a symbol raises ``Mt5RuntimeError`` and
``suppress_errors=False``.
@@ -748,7 +818,7 @@ def calculate_positions_margin_by_symbol(
for symbol in dict.fromkeys(symbols):
try:
result[symbol] = calculate_positions_margin(client, symbols=[symbol])
except (Mt5TradingError, Mt5RuntimeError, AttributeError) as exc:
except (Mt5OperationError, Mt5RuntimeError, AttributeError) as exc:
if not suppress_errors:
raise
_logger.warning("Skipping margin for %r: %s", symbol, exc)
@@ -756,7 +826,7 @@ def calculate_positions_margin_by_symbol(
def calculate_positions_margin_safe(
client: Mt5TradingClient,
client: _Mt5ClientProtocol,
*,
symbols: Sequence[str],
) -> float:
@@ -766,7 +836,7 @@ def calculate_positions_margin_safe(
``suppress_errors=True``. Failed symbols are silently skipped.
Args:
client: Connected ``Mt5TradingClient`` instance.
client: Connected MT5 client instance.
symbols: Symbols to include.
Returns:
@@ -778,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.
Raises:
Mt5TradingError: If bid or ask is unavailable.
Mt5OperationError: If bid or ask is unavailable.
"""
tick = get_tick_snapshot(client, symbol)
bid = _valid_tick_price(tick, "bid")
ask = _valid_tick_price(tick, "ask")
bid = extract_tick_price(tick, "bid")
ask = extract_tick_price(tick, "ask")
if bid is None or ask is None:
msg = f"Tick bid/ask is unavailable for {symbol!r}."
raise Mt5TradingError(msg)
raise Mt5OperationError(msg)
return (ask - bid) / ((ask + bid) / 2.0)
def calculate_new_position_margin_ratio(
client: Mt5TradingClient,
client: _Mt5ClientProtocol,
*,
symbol: str,
new_position_side: OrderSide | None = None,
@@ -803,22 +873,22 @@ def calculate_new_position_margin_ratio(
"""Return total margin/equity ratio after an optional hypothetical position.
Raises:
Mt5TradingError: If equity or required tick data is invalid.
Mt5OperationError: If equity or required tick data is invalid.
"""
account = get_account_snapshot(client)
equity = float(account.get("equity") or 0.0)
if equity <= 0:
msg = "Account equity must be positive to calculate margin ratio."
raise Mt5TradingError(msg)
raise Mt5OperationError(msg)
margin = float(account.get("margin") or 0.0)
if new_position_side is not None and new_position_volume > 0:
side = _normalize_order_side(new_position_side)
price = _valid_tick_price(
price = extract_tick_price(
get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid"
)
if price is None:
msg = f"Tick price is unavailable for {symbol!r}."
raise Mt5TradingError(msg)
raise Mt5OperationError(msg)
order_type = (
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
)
@@ -828,8 +898,171 @@ def calculate_new_position_margin_ratio(
return margin / equity
def _account_equity(client: _Mt5ClientProtocol) -> float:
account = get_account_snapshot(client)
return _required_account_number(account, "equity", allow_zero=False)
def _required_account_number(
account: Mapping[str, object],
field: str,
*,
allow_zero: bool,
) -> float:
raw_value = account.get(field)
if isinstance(raw_value, bool) or not isinstance(raw_value, Real):
msg = f"Account {field} must be a finite number to calculate margin ratio."
raise Mt5OperationError(msg)
value = float(raw_value)
if (
not isfinite(value)
or (not allow_zero and value <= 0)
or (allow_zero and value < 0)
):
msg = (
f"Account {field} must be a non-negative finite number."
if allow_zero
else f"Account {field} must be a positive finite number."
)
raise Mt5OperationError(msg)
return value
def calculate_account_projected_margin_ratio(
client: _Mt5ClientProtocol,
*,
symbol: str | None = None,
new_position_side: OrderSide | None = None,
new_position_volume: float = 0.0,
) -> float:
"""Return account-wide current plus optional new-position margin over equity.
Current exposure comes from the broker account snapshot ``margin`` field so
unrelated open positions remain in the baseline. Optional projected
exposure is added via :func:`estimate_order_margin` only when a symbol, side,
and positive volume are all supplied.
"""
account = get_account_snapshot(client)
equity = _required_account_number(account, "equity", allow_zero=False)
margin = _required_account_number(account, "margin", allow_zero=True)
if symbol is not None and new_position_side is not None and new_position_volume > 0:
margin += estimate_order_margin(
client,
symbol,
new_position_side,
new_position_volume,
)
return margin / equity
def calculate_projected_margin_ratio(
client: _Mt5ClientProtocol,
*,
symbol: str,
new_position_side: OrderSide | None = None,
new_position_volume: float = 0.0,
) -> float:
"""Return estimated current plus optional new-position margin over equity.
Current exposure is estimated from open positions with
:func:`calculate_positions_margin`. Optional projected exposure is added via
:func:`estimate_order_margin`. Thresholds and guard actions are intentionally
left to downstream applications.
Account equity, position margin, and optional projected margin errors from
the composed MT5 helpers propagate to the caller.
"""
equity = _account_equity(client)
margin = calculate_positions_margin(client, symbols=[symbol])
if new_position_side is not None and new_position_volume > 0:
margin += estimate_order_margin(
client,
symbol,
new_position_side,
new_position_volume,
)
return margin / equity
def _validate_projection_mode(projection_mode: str) -> ProjectionMode:
if projection_mode not in {"add", "replace_symbol"}:
msg = (
f"Unsupported projection mode: {projection_mode!r}. "
"Expected 'add' or 'replace_symbol'."
)
raise ValueError(msg)
return cast("ProjectionMode", projection_mode)
def calculate_symbol_group_margin_ratio(
client: _Mt5ClientProtocol,
*,
symbols: Sequence[str],
new_symbol: str | None = None,
new_position_side: OrderSide | None = None,
new_position_volume: float = 0.0,
suppress_errors: bool = True,
projection_mode: ProjectionMode = "add",
) -> float:
"""Return estimated symbol-group margin over account equity.
Per-symbol current exposure is summed with
:func:`calculate_positions_margin_by_symbol`. When ``new_symbol`` is inside
the input symbol group and candidate side/volume are provided, projected order
margin is applied according to ``projection_mode``:
- ``"add"`` (default): adds candidate margin to the group total.
- ``"replace_symbol"``: subtracts current margin for ``new_symbol``, then
adds candidate margin. Useful for reversal-style projections where the new
order is intended to replace existing exposure for that symbol.
If the candidate margin estimation fails, the subtraction is also skipped so
the operation is atomic. Invalid equity always raises to fail closed.
Raises:
AttributeError: When symbol margin lookup or projected margin lookup
fails and ``suppress_errors`` is ``False``.
Mt5RuntimeError: When symbol margin lookup or projected margin lookup
fails and ``suppress_errors`` is ``False``.
Mt5OperationError: When account equity is invalid, or when symbol margin
lookup or projected margin lookup fails and ``suppress_errors`` is
``False``.
"""
projection_mode = _validate_projection_mode(projection_mode)
equity = _account_equity(client)
unique_symbols = list(dict.fromkeys(symbols))
per_symbol = calculate_positions_margin_by_symbol(
client,
symbols=unique_symbols,
suppress_errors=suppress_errors,
)
margin = sum(per_symbol.values(), 0.0)
if (
new_symbol in unique_symbols
and new_position_side is not None
and new_position_volume > 0
):
try:
candidate_margin = estimate_order_margin(
client,
new_symbol,
new_position_side,
new_position_volume,
)
except (Mt5OperationError, Mt5RuntimeError, AttributeError):
if not suppress_errors:
raise
_logger.warning("Skipping projected margin for %r.", new_symbol)
else:
if projection_mode == "replace_symbol":
margin = max(0.0, margin - per_symbol.get(new_symbol, 0.0))
margin += candidate_margin
return margin / equity
def calculate_margin_and_volume(
client: Mt5TradingClient,
client: _Mt5ClientProtocol,
symbol: str,
unit_margin_ratio: float,
preserved_margin_ratio: float,
@@ -843,7 +1076,7 @@ def calculate_margin_and_volume(
side when the post-reserve margin can afford it.
Args:
client: Connected ``Mt5TradingClient`` instance.
client: Connected MT5 client instance.
symbol: Symbol used for minimum-lot margin and volume calculations.
unit_margin_ratio: Fraction of post-reserve margin to allocate per unit.
preserved_margin_ratio: Fraction of ``margin_free`` to preserve.
@@ -896,7 +1129,7 @@ def calculate_margin_and_volume(
def calculate_volume_by_margin(
client: Mt5TradingClient,
client: _Mt5ClientProtocol,
symbol: str,
available_margin: float,
order_side: OrderSide,
@@ -909,7 +1142,7 @@ def calculate_volume_by_margin(
constraints; ``0.0`` when no affordable step exists.
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:
return 0.0
@@ -919,14 +1152,14 @@ def calculate_volume_by_margin(
volume_step = float(symbol_info.get("volume_step") or volume_min or 0.0)
if volume_min <= 0 or volume_step <= 0:
msg = f"Invalid volume constraints for {symbol!r}."
raise Mt5TradingError(msg)
raise Mt5OperationError(msg)
side = _normalize_order_side(order_side)
price = _valid_tick_price(
price = extract_tick_price(
get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid"
)
if price is None:
msg = f"Tick price is unavailable for {symbol!r}."
raise Mt5TradingError(msg)
raise Mt5OperationError(msg)
order_type = (
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
)
@@ -968,7 +1201,7 @@ def calculate_volume_by_margin(
def determine_order_limits(
client: Mt5TradingClient,
client: _Mt5ClientProtocol,
symbol: str,
side: PositionSide | str,
stop_loss_limit_ratio: float | None = None,
@@ -977,7 +1210,7 @@ def determine_order_limits(
"""Derive entry and protective order prices from current market quotes.
Args:
client: Connected ``Mt5TradingClient`` instance.
client: Connected MT5 client instance.
symbol: Symbol used for the quote lookup.
side: Position side as ``"long"``/``"short"`` (``"buy"``/``"sell"``
aliases are accepted).
@@ -991,7 +1224,7 @@ def determine_order_limits(
Omitted protective levels are returned as ``None``.
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.
"""
stop_loss_ratio = stop_loss_limit_ratio or 0.0
@@ -1001,10 +1234,10 @@ def determine_order_limits(
normalized_side = _position_side_from_order_side(side)
tick = get_tick_snapshot(client, symbol)
entry_key = "ask" if normalized_side == "long" else "bid"
entry = _valid_tick_price(tick, entry_key)
entry = extract_tick_price(tick, entry_key)
if entry is None:
msg = f"Tick price is unavailable for {symbol!r}."
raise Mt5TradingError(msg)
raise Mt5OperationError(msg)
try:
symbol_info = get_symbol_snapshot(client, symbol)
except (AttributeError, KeyError, TypeError, ValueError):
@@ -1048,7 +1281,7 @@ def determine_order_limits(
def place_market_order(
client: Mt5TradingClient,
client: _Mt5ClientProtocol,
*,
symbol: str,
volume: float,
@@ -1062,28 +1295,28 @@ def place_market_order(
) -> OrderExecutionResult:
"""Place one normalized market order or return a dry-run result.
``pdmt5.Mt5TradingClient.order_send()`` raises only when MT5 returns no
response. When MT5 returns a response with a known non-success retcode, this
helper returns ``status="failed"`` and keeps the normalized response
details for callers to inspect.
``order_send()`` raises only when MT5 returns no response. When MT5 returns
a response with a known non-success retcode, this helper returns
``status="failed"`` and keeps the normalized response details for callers
to inspect.
Returns:
Normalized execution result containing request and response details.
Raises:
Mt5TradingError: If volume or required tick data is invalid.
Mt5OperationError: If volume or required tick data is invalid.
"""
if volume <= 0:
msg = "volume must be positive."
raise Mt5TradingError(msg)
raise Mt5OperationError(msg)
side = _normalize_order_side(order_side)
if not dry_run:
ensure_symbol_selected(client, symbol)
tick = get_tick_snapshot(client, symbol)
price = _valid_tick_price(tick, "ask" if side == "BUY" else "bid")
price = extract_tick_price(tick, "ask" if side == "BUY" else "bid")
if price is None:
msg = f"Tick price is unavailable for {symbol!r}."
raise Mt5TradingError(msg)
raise Mt5OperationError(msg)
request = {
"action": client.mt5.TRADE_ACTION_DEAL,
"symbol": symbol,
@@ -1156,7 +1389,7 @@ def _filter_positions(
def close_open_positions(
client: Mt5TradingClient,
client: _Mt5ClientProtocol,
*,
symbols: str | list[str] | None = None,
tickets: list[int] | None = None,
@@ -1188,8 +1421,127 @@ def close_open_positions(
return results
def _symbol_digits(client: _Mt5ClientProtocol, symbol: str) -> int | None:
try:
raw_digits = get_symbol_snapshot(client, symbol).get("digits")
if raw_digits is None:
return None
digits = int(raw_digits)
except (AttributeError, TypeError, ValueError):
return None
return digits if digits >= 0 else None
def _position_ticket(value: object) -> int | None:
ticket = _optional_int(value)
return ticket if ticket is not None and ticket > 0 else None
def _current_stop_loss(value: object) -> float | None:
return _optional_price(value)
def _trailing_stop_loss(
client: _Mt5ClientProtocol,
*,
position_type: object,
current_sl: float | None,
bid: float | None,
ask: float | None,
digits: int,
trailing_stop_ratio: float,
) -> float | None:
next_sl: float | None = None
if position_type == client.mt5.POSITION_TYPE_BUY:
if bid is not None:
next_sl = round(bid * (1.0 - trailing_stop_ratio), digits)
if current_sl is not None and current_sl >= next_sl:
next_sl = None
elif position_type == client.mt5.POSITION_TYPE_SELL and ask is not None:
next_sl = round(ask * (1.0 + trailing_stop_ratio), digits)
if current_sl is not None and current_sl <= next_sl:
next_sl = None
return next_sl
def calculate_trailing_stop_updates(
client: _Mt5ClientProtocol,
*,
symbol: str,
trailing_stop_ratio: float,
) -> dict[int, float]:
"""Return per-ticket trailing stop-loss updates for open symbol positions.
Buy positions trail from bid using ``bid * (1 - trailing_stop_ratio)``.
Sell positions trail from ask using ``ask * (1 + trailing_stop_ratio)``.
Existing stop losses are preserved when they are already more favorable.
Missing symbol metadata returns an empty update map. Positions with a
missing side-specific tick price are skipped.
"""
_require_protective_ratio(trailing_stop_ratio, "trailing_stop_ratio")
positions = get_positions_frame(client, symbol=symbol)
if positions.empty:
return {}
tick = get_tick_snapshot(client, symbol)
bid = extract_tick_price(tick, "bid")
ask = extract_tick_price(tick, "ask")
digits = _symbol_digits(client, symbol)
if digits is None:
return {}
updates: dict[int, float] = {}
for row in positions.to_dict("records"):
ticket = _position_ticket(row.get("ticket"))
if ticket is None:
continue
next_sl = _trailing_stop_loss(
client,
position_type=row.get("type"),
current_sl=_current_stop_loss(row.get("sl")),
bid=bid,
ask=ask,
digits=digits,
trailing_stop_ratio=trailing_stop_ratio,
)
if next_sl is None:
continue
updates[ticket] = next_sl
return updates
def update_trailing_stop_loss_for_open_positions(
client: _Mt5ClientProtocol,
*,
symbol: str,
trailing_stop_ratio: float,
dry_run: bool = False,
) -> list[OrderExecutionResult]:
"""Update open positions whose trailing stop loss should move favorably.
Returns:
Normalized execution results for positions that need an SL update.
"""
updates = calculate_trailing_stop_updates(
client,
symbol=symbol,
trailing_stop_ratio=trailing_stop_ratio,
)
results: list[OrderExecutionResult] = []
for ticket, stop_loss in updates.items():
results.extend(
update_sltp_for_open_positions(
client,
symbol=symbol,
tickets=[ticket],
stop_loss=stop_loss,
dry_run=dry_run,
),
)
return results
def update_sltp_for_open_positions(
client: Mt5TradingClient,
client: _Mt5ClientProtocol,
*,
symbol: str | None = None,
tickets: list[int] | None = None,
@@ -1253,7 +1605,7 @@ def update_sltp_for_open_positions(
def fetch_latest_closed_rates_for_trading_client(
client: Mt5TradingClient,
client: _Mt5ClientProtocol,
*,
symbol: str,
granularity: str,
@@ -1267,25 +1619,16 @@ def fetch_latest_closed_rates_for_trading_client(
Raises:
ValueError: If ``count`` is not positive, rate data is empty or
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:
msg = "count must be positive."
raise ValueError(msg)
fetch_method = getattr(client, "fetch_latest_rates_as_df", None)
if callable(fetch_method):
fetched = fetch_method(symbol, granularity, count + 1)
else:
copy_method = getattr(client, "copy_rates_from_pos_as_df", None)
if not callable(copy_method):
msg = "MT5 trading client cannot fetch rate data."
raise Mt5TradingError(msg)
fetched = copy_method(
symbol=symbol,
timeframe=parse_timeframe(granularity),
start_pos=0,
count=count + 1,
)
if not callable(fetch_method):
msg = "MT5 trading client cannot fetch rate data."
raise Mt5OperationError(msg)
fetched = fetch_method(symbol, granularity, count + 1)
if not isinstance(fetched, pd.DataFrame):
msg = (
f"Malformed rate data for {symbol!r} at granularity {granularity!r}: "
@@ -1347,7 +1690,7 @@ def _rate_time_to_utc(series: pd.Series, symbol: str) -> pd.DatetimeIndex:
def fetch_latest_closed_rates_indexed(
client: Mt5TradingClient,
client: _Mt5ClientProtocol,
*,
symbol: str,
granularity: str,
@@ -1403,13 +1746,13 @@ def mt5_trading_session(
path: str | None = None,
timeout: int | None = None,
retry_count: int = 0,
) -> Iterator[Mt5TradingClient]:
) -> Iterator[_Mt5ClientProtocol]:
"""Open a trading-capable MT5 session and always shut down safely.
Launches the MetaTrader 5 terminal using ``Mt5Config.path`` when set,
initializes and logs in via ``initialize_and_login_mt5()``, yields a
connected :class:`~pdmt5.Mt5TradingClient`, and calls ``shutdown()`` on
exit even when an error is raised inside the context.
connected client supporting required MT5 methods, and calls ``shutdown()``
on exit even when an error is raised inside the context.
Args:
config: MT5 connection configuration. Defaults to an empty config that
@@ -1419,11 +1762,10 @@ def mt5_trading_session(
server: Optional trading server name.
path: Optional terminal executable path.
timeout: Optional connection timeout in milliseconds.
retry_count: Number of initialization retries passed to
``Mt5TradingClient``.
retry_count: Number of initialization retries.
Yields:
Connected ``Mt5TradingClient`` bound to the session.
Connected client supporting required MT5 trading methods.
"""
client = create_trading_client(
config=config,
+13 -6
View File
@@ -10,7 +10,8 @@ from pathlib import Path
from typing import TYPE_CHECKING, Any, TypeGuard
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_timeframe as _parse_timeframe
@@ -23,14 +24,11 @@ if TYPE_CHECKING:
# 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(
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(
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] = {
@@ -314,6 +312,7 @@ def export_dataframe(
table_name: Table name for SQLite3 output.
Raises:
ImportError: If the parquet format is requested but pyarrow is not installed.
ValueError: If the output format is not supported.
"""
if output_format == "csv":
@@ -326,6 +325,14 @@ def export_dataframe(
indent=2,
)
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)
elif output_format == "sqlite3":
export_dataframe_to_sqlite(
+10 -4
View File
@@ -1,6 +1,6 @@
[project]
name = "mt5cli"
version = "0.9.2"
version = "1.0.0"
description = "Generic MT5 data and execution infrastructure for Python applications"
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
@@ -9,9 +9,8 @@ license-files = ["LICENSE"]
readme = "README.md"
requires-python = ">= 3.11, < 3.14"
dependencies = [
"pdmt5>=0.3.0",
"pdmt5>=1.0.0",
"click >= 8.1.0",
"pyarrow >= 19.0.0",
"typer >= 0.15.0",
]
classifiers = [
@@ -25,6 +24,9 @@ classifiers = [
"Topic :: Office/Business :: Financial :: Investment",
]
[project.optional-dependencies]
parquet = ["pyarrow >= 19.0.0"]
[project.scripts]
mt5cli = "mt5cli.cli:main"
@@ -42,6 +44,7 @@ dev = [
"pytest-mock >= 3.12.0",
"pytest-cov >= 5.0.0",
"pandas-stubs >= 2.2.3.250527",
"pyarrow >= 19.0.0",
"mkdocs >= 1.6.1",
"mkdocs-material >= 9.7.6",
"mkdocstrings[python] >= 1.0.4",
@@ -175,7 +178,10 @@ omit = [
[tool.coverage.report]
show_missing = true
fail_under = 100
exclude_lines = ["if TYPE_CHECKING:"]
exclude_also = [
"if TYPE_CHECKING:",
"^\\s+\\.\\.\\.$",
]
[build-system]
requires = ["hatchling"]
+300
View File
@@ -740,6 +740,306 @@ class TestCommands:
assert "must be a JSON object" in normalize_cli_output(result.output)
# ---------------------------------------------------------------------------
# close-positions command
# ---------------------------------------------------------------------------
def _build_mock_trading_client() -> MagicMock:
"""Return a MagicMock Mt5TradingClient with trading constants set."""
client = MagicMock()
client.mt5.POSITION_TYPE_BUY = 0
client.mt5.POSITION_TYPE_SELL = 1
client.mt5.ORDER_TYPE_BUY = 10
client.mt5.ORDER_TYPE_SELL = 11
client.mt5.TRADE_ACTION_DEAL = 20
client.mt5.ORDER_FILLING_IOC = 30
client.mt5.ORDER_TIME_GTC = 40
client.mt5.TRADE_RETCODE_DONE = 10009
client.mt5.TRADE_RETCODE_PLACED = 10008
client.mt5.TRADE_RETCODE_DONE_PARTIAL = 10010
return client
class TestClosePositions:
"""Tests for the close-positions command."""
@pytest.fixture
def trading_client(self, mocker: MockerFixture) -> MagicMock:
"""Patch create_trading_client and return a mock trading client."""
client = _build_mock_trading_client()
client.positions_get_as_df.return_value = pd.DataFrame([
{"ticket": 1, "symbol": "JP225", "type": 0, "volume": 1.0},
{"ticket": 2, "symbol": "EURUSD", "type": 1, "volume": 0.5},
])
client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1}
mocker.patch("mt5cli.cli.create_trading_client", return_value=client)
return client
def test_dry_run_does_not_require_yes(
self,
tmp_path: Path,
trading_client: MagicMock,
) -> None:
"""Test --dry-run mode succeeds without --yes."""
output = tmp_path / "close.json"
result = runner.invoke(
app,
["-o", str(output), "close-positions", "--symbol", "JP225", "--dry-run"],
)
assert result.exit_code == 0, result.output
assert output.exists()
trading_client.order_send.assert_not_called()
trading_client.shutdown.assert_called_once()
def test_live_requires_yes(
self,
tmp_path: Path,
trading_client: MagicMock,
) -> None:
"""Test live close-positions fails without --yes."""
output = tmp_path / "close.json"
result = runner.invoke(
app,
["-o", str(output), "close-positions", "--symbol", "JP225"],
)
assert result.exit_code != 0
assert "Pass --yes" in normalize_cli_output(result.output)
trading_client.order_send.assert_not_called()
def test_live_with_yes_calls_order_send(
self,
tmp_path: Path,
trading_client: MagicMock,
) -> None:
"""Test --yes triggers live execution for matching positions."""
trading_client.order_send.return_value = {"retcode": 10009, "comment": "ok"}
output = tmp_path / "close.json"
result = runner.invoke(
app,
["-o", str(output), "close-positions", "--symbol", "JP225", "--yes"],
)
assert result.exit_code == 0, result.output
trading_client.order_send.assert_called_once()
trading_client.shutdown.assert_called_once()
def test_symbol_filter_passed_through(
self,
tmp_path: Path,
trading_client: MagicMock,
) -> None:
"""Test --symbol values are used to filter positions."""
output = tmp_path / "close.json"
result = runner.invoke(
app,
[
"-o",
str(output),
"close-positions",
"--symbol",
"JP225",
"--dry-run",
],
)
assert result.exit_code == 0, result.output
data = json.loads(output.read_text())
assert len(data) == 1
assert data[0]["symbol"] == "JP225"
trading_client.shutdown.assert_called_once()
def test_multiple_symbols_filter(
self,
tmp_path: Path,
trading_client: MagicMock,
) -> None:
"""Test multiple --symbol options are combined."""
output = tmp_path / "close.json"
result = runner.invoke(
app,
[
"-o",
str(output),
"close-positions",
"--symbol",
"JP225",
"--symbol",
"EURUSD",
"--dry-run",
],
)
assert result.exit_code == 0, result.output
data = json.loads(output.read_text())
assert len(data) == 2
symbols = {row["symbol"] for row in data}
assert symbols == {"JP225", "EURUSD"}
trading_client.shutdown.assert_called_once()
def test_ticket_filter_passed_through(
self,
tmp_path: Path,
trading_client: MagicMock,
) -> None:
"""Test --ticket values are used to filter positions."""
output = tmp_path / "close.json"
result = runner.invoke(
app,
[
"-o",
str(output),
"close-positions",
"--ticket",
"2",
"--dry-run",
],
)
assert result.exit_code == 0, result.output
data = json.loads(output.read_text())
assert len(data) == 1
assert data[0]["symbol"] == "EURUSD"
trading_client.shutdown.assert_called_once()
def test_symbol_and_ticket_combined(
self,
tmp_path: Path,
trading_client: MagicMock,
) -> None:
"""Test --symbol and --ticket apply AND semantics when combined."""
output = tmp_path / "close.json"
result = runner.invoke(
app,
[
"-o",
str(output),
"close-positions",
"--symbol",
"JP225",
"--ticket",
"1",
"--dry-run",
],
)
assert result.exit_code == 0, result.output
data = json.loads(output.read_text())
# symbol=JP225 AND ticket=1 → exactly one match
assert len(data) == 1
assert data[0]["symbol"] == "JP225"
trading_client.shutdown.assert_called_once()
def test_missing_symbol_and_ticket_fails(
self,
tmp_path: Path,
mocker: MockerFixture,
) -> None:
"""Test that omitting both --symbol and --ticket fails closed."""
mocker.patch("mt5cli.cli.create_trading_client")
output = tmp_path / "close.json"
result = runner.invoke(
app,
["-o", str(output), "close-positions", "--dry-run"],
)
assert result.exit_code != 0
assert "symbol" in normalize_cli_output(result.output).lower()
def test_output_export_dry_run(
self,
tmp_path: Path,
trading_client: MagicMock,
) -> None:
"""Test dry-run results export with status=dry_run."""
output = tmp_path / "close.json"
result = runner.invoke(
app,
["-o", str(output), "close-positions", "--symbol", "JP225", "--dry-run"],
)
assert result.exit_code == 0, result.output
trading_client.shutdown.assert_called_once()
data = json.loads(output.read_text())
assert data[0]["status"] == "dry_run"
assert data[0]["dry_run"] is True
assert data[0]["order_side"] == "SELL"
def test_order_send_unchanged(
self,
tmp_path: Path,
mock_client: MagicMock,
) -> None:
"""Test that order-send behavior is unchanged by close-positions addition."""
output = tmp_path / "out.csv"
request = json.dumps({"action": 1, "symbol": "EURUSD", "volume": 0.1})
result = runner.invoke(
app,
["-o", str(output), "order-send", "--request", request, "--yes"],
)
assert result.exit_code == 0, result.output
mock_client.order_send_as_df.assert_called_once()
def test_shutdown_called_on_close_error(
self,
tmp_path: Path,
mocker: MockerFixture,
) -> None:
"""Test that shutdown is called even when close_open_positions raises."""
client = _build_mock_trading_client()
client.positions_get_as_df.side_effect = RuntimeError("connection lost")
mocker.patch("mt5cli.cli.create_trading_client", return_value=client)
output = tmp_path / "close.json"
result = runner.invoke(
app,
["-o", str(output), "close-positions", "--symbol", "JP225", "--dry-run"],
)
assert result.exit_code != 0
client.shutdown.assert_called_once()
def test_dry_run_wins_over_yes(
self,
tmp_path: Path,
trading_client: MagicMock,
) -> None:
"""Test that --dry-run takes precedence when combined with --yes."""
output = tmp_path / "close.json"
result = runner.invoke(
app,
[
"-o",
str(output),
"close-positions",
"--symbol",
"JP225",
"--dry-run",
"--yes",
],
)
assert result.exit_code == 0, result.output
trading_client.order_send.assert_not_called()
trading_client.shutdown.assert_called_once()
def test_no_matching_positions_exports_empty_result(
self,
tmp_path: Path,
trading_client: MagicMock,
) -> None:
"""Test that zero filter matches produces an empty JSON array."""
trading_client.positions_get_as_df.return_value = pd.DataFrame([
{"ticket": 1, "symbol": "JP225", "type": 0, "volume": 1.0},
])
output = tmp_path / "close.json"
result = runner.invoke(
app,
[
"-o",
str(output),
"close-positions",
"--symbol",
"NONEXISTENT",
"--dry-run",
],
)
assert result.exit_code == 0, result.output
trading_client.shutdown.assert_called_once()
assert output.exists()
assert json.loads(output.read_text()) == []
# ---------------------------------------------------------------------------
# Callback / shared options
# ---------------------------------------------------------------------------
+188 -93
View File
@@ -2,11 +2,16 @@
from __future__ import annotations
import importlib
import sqlite3
from datetime import UTC, datetime
from importlib.metadata import requires
from typing import TYPE_CHECKING, get_type_hints
from unittest.mock import MagicMock
if TYPE_CHECKING:
from pathlib import Path
import pandas as pd
import pytest
from pdmt5 import Mt5RuntimeError, Mt5TradingError
@@ -14,13 +19,8 @@ from pytest_mock import MockerFixture # noqa: TC002
import mt5cli
from mt5cli import (
DEDUP_KEYS,
REQUIRED_COLUMNS,
STABLE_SDK_EXPORTS,
TIME_COLUMNS,
AccountSpec,
DataKind,
Dataset,
ExecutionStatus,
MarginVolume,
MT5Client,
@@ -33,44 +33,62 @@ from mt5cli import (
RateTarget,
build_config,
build_rate_targets,
calculate_account_projected_margin_ratio,
calculate_margin_and_volume,
calculate_positions_margin,
call_with_normalized_errors,
detect_format,
calculate_projected_margin_ratio,
calculate_symbol_group_margin_ratio,
calculate_trailing_stop_updates,
drop_forming_rate_bar,
ensure_symbol_selected,
ensure_utc,
export_dataframe,
export_dataframe_to_sqlite,
extract_tick_price,
fetch_latest_closed_rates,
fetch_latest_closed_rates_for_trading_client,
fetch_latest_closed_rates_indexed,
granularity_name,
is_recoverable_mt5_error,
load_rate_data,
load_rate_series_from_sqlite,
mt5_session,
mt5_trading_session,
normalize_dataframe,
normalize_mt5_exception,
normalize_order_volume,
place_market_order,
resolve_account_spec,
resolve_account_specs,
)
from mt5cli.converters import (
ensure_utc,
granularity_name,
normalize_symbol,
normalize_symbols,
parse_date_range,
place_market_order,
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,
)
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,
validate_schema,
)
from mt5cli.history import create_rate_compatibility_views
from mt5cli.retry import retry_with_backoff
from mt5cli.schemas import ensure_utc_columns, normalize_time_columns
if TYPE_CHECKING:
from pathlib import Path
from mt5cli.utils import (
Dataset,
detect_format,
export_dataframe,
export_dataframe_to_sqlite,
)
def _sample_frame(kind: DataKind) -> pd.DataFrame:
@@ -228,16 +246,19 @@ def test_is_recoverable_mt5_error(exc: Exception) -> None:
assert is_recoverable_mt5_error(exc)
def test_normalize_mt5_exception_maps_types() -> None:
@pytest.mark.parametrize(
("exc", "expected_type"),
[
(Mt5RuntimeError("x"), Mt5ConnectionError),
(Mt5TradingError("x"), Mt5OperationError),
],
)
def test_normalize_mt5_exception_maps_types(
exc: Exception,
expected_type: type[Mt5ConnectionError | Mt5OperationError],
) -> None:
"""MT5 exceptions map to stable mt5cli types."""
assert isinstance(
normalize_mt5_exception(Mt5RuntimeError("x")),
Mt5ConnectionError,
)
assert isinstance(
normalize_mt5_exception(Mt5TradingError("x")),
Mt5OperationError,
)
assert isinstance(normalize_mt5_exception(exc), expected_type)
def test_call_with_normalized_errors_reraises_mapped_type() -> None:
@@ -414,26 +435,24 @@ def test_normalize_time_columns_skips_absent_time_fields() -> None:
assert list(result.columns) == ["open"]
def test_normalize_time_columns_converts_unix_seconds() -> None:
"""Numeric MT5 ``time`` values are interpreted as Unix seconds."""
frame = pd.DataFrame({"time": [1704067200]})
result = normalize_time_columns(frame, DataKind.rates)
assert result.loc[0, "time"] == pd.Timestamp("2024-01-01T00:00:00+00:00")
def test_normalize_time_columns_converts_unix_milliseconds() -> None:
"""Numeric MT5 ``time_msc`` values are interpreted as Unix milliseconds."""
frame = pd.DataFrame({"time_msc": [1704067200000]})
result = normalize_time_columns(frame, DataKind.ticks)
assert result.loc[0, "time_msc"] == pd.Timestamp("2024-01-01T00:00:00+00:00")
def test_normalize_time_columns_preserves_utc_datetimes() -> None:
"""Already-converted datetime values remain UTC-normalized."""
aware = datetime(2024, 1, 1, tzinfo=UTC)
frame = pd.DataFrame({"time": [aware]})
result = normalize_time_columns(frame, DataKind.rates)
assert result.loc[0, "time"] == pd.Timestamp("2024-01-01T00:00:00+00:00")
@pytest.mark.parametrize(
("col", "value", "kind"),
[
("time", 1704067200, DataKind.rates),
("time_msc", 1704067200000, DataKind.ticks),
("time", datetime(2024, 1, 1, tzinfo=UTC), DataKind.rates),
("time", "2024-01-01T00:00:00+00:00", DataKind.rates),
],
)
def test_normalize_time_columns_coerces_value(
col: str,
value: object,
kind: DataKind,
) -> None:
"""Time column values are coerced to UTC timestamps regardless of input type."""
frame = pd.DataFrame({col: [value]})
result = normalize_time_columns(frame, kind)
assert result.loc[0, col] == pd.Timestamp("2024-01-01T00:00:00+00:00")
def test_normalize_time_columns_handles_optional_order_times() -> None:
@@ -483,13 +502,6 @@ def test_ensure_utc_columns_skips_missing_columns() -> None:
assert "time" in result.columns
def test_normalize_time_columns_coerces_string_timestamps() -> None:
"""String timestamps are parsed with timezone-aware datetime coercion."""
frame = pd.DataFrame({"time": ["2024-01-01T00:00:00+00:00"]})
result = normalize_time_columns(frame, DataKind.rates)
assert result.loc[0, "time"] == pd.Timestamp("2024-01-01T00:00:00+00:00")
def test_ensure_utc_columns_coerces_non_mt5_columns() -> None:
"""Non-MT5 columns still coerce to UTC datetimes."""
frame = pd.DataFrame({"created_at": ["2024-01-01T00:00:00+00:00"]})
@@ -539,6 +551,12 @@ def test_storage_export_round_trip_sqlite(tmp_path: Path) -> None:
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:
"""Tests for the documented stable downstream SDK contract."""
@@ -547,6 +565,21 @@ class TestStableSdkContract:
missing = sorted(STABLE_SDK_EXPORTS - set(mt5cli.__all__))
assert not missing, f"STABLE_SDK_EXPORTS missing from __all__: {missing}"
def test_stable_exports_cover_root_api(self) -> None:
"""STABLE_SDK_EXPORTS classifies every package-root symbol."""
tier_metadata = {"STABLE_SDK_EXPORTS"}
root_exports = set(mt5cli.__all__)
missing_from_root = sorted(STABLE_SDK_EXPORTS - root_exports)
assert not missing_from_root, (
f"STABLE_SDK_EXPORTS missing from __all__: {missing_from_root}"
)
unclassified = sorted(root_exports - STABLE_SDK_EXPORTS - tier_metadata)
assert not unclassified, (
f"Root exports not in STABLE_SDK_EXPORTS: {unclassified}"
)
@pytest.mark.parametrize("name", sorted(STABLE_SDK_EXPORTS))
def test_stable_exports_are_importable_from_package_root(self, name: str) -> None:
"""Stable SDK names resolve through ``from mt5cli import ...``."""
@@ -615,37 +648,15 @@ class TestStableSdkContract:
assert calculate_positions_margin(client) == 0
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_generic_trading_helpers_from_package_root(self) -> None:
"""New generic trading helpers resolve through the stable surface."""
price = extract_tick_price({"bid": "1.2"}, "bid")
assert price is not None
assert abs(price - 1.2) < 1e-9
assert callable(calculate_trailing_stop_updates)
assert callable(calculate_account_projected_margin_ratio)
assert callable(calculate_projected_margin_ratio)
assert callable(calculate_symbol_group_margin_ratio)
def test_load_rate_series_from_sqlite_requires_managed_views(
self,
@@ -694,7 +705,7 @@ class TestStableSdkContract:
"""Trading session helper initializes and always shuts down."""
mock_client = MagicMock()
mocker.patch(
"mt5cli.trading.Mt5TradingClient",
"mt5cli.trading.Mt5DataClient",
return_value=mock_client,
)
@@ -723,7 +734,7 @@ class TestStableSdkContract:
"""Trading session helper shuts down even when the body raises."""
mock_client = MagicMock()
mocker.patch(
"mt5cli.trading.Mt5TradingClient",
"mt5cli.trading.Mt5DataClient",
return_value=mock_client,
)
@@ -764,3 +775,87 @@ class TestStableSdkContract:
assert result.index.tz is not None
assert "time" not 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"
)
+22 -55
View File
@@ -15,6 +15,8 @@ from pytest_mock import MockerFixture # noqa: TC002
if TYPE_CHECKING:
from pathlib import Path
from pdmt5 import TIMEFRAME_MAP
from mt5cli import history
from mt5cli.history import (
DEFAULT_HISTORY_TIMEFRAMES,
@@ -58,7 +60,7 @@ from mt5cli.history import (
write_rates_dataset,
write_streamed_frame,
)
from mt5cli.utils import TIMEFRAME_MAP, Dataset, IfExists
from mt5cli.utils import Dataset, IfExists
class TestResolveRateViewName:
@@ -705,19 +707,25 @@ class TestIncrementalStart:
assert starts["EURUSD", 1] == datetime(2024, 1, 2, tzinfo=UTC)
assert starts["GBPUSD", 1] == datetime(2024, 1, 3, tzinfo=UTC)
def test_load_incremental_start_datetimes_requires_timeframe_column(
@pytest.mark.parametrize(
("ddl", "missing_col"),
[
("CREATE TABLE rates(symbol TEXT, time TEXT, open REAL)", "timeframe"),
("CREATE TABLE rates(timeframe INTEGER, time TEXT, open REAL)", "symbol"),
("CREATE TABLE rates(symbol TEXT, timeframe INTEGER, open REAL)", "time"),
],
)
def test_load_incremental_start_datetimes_requires_column(
self,
tmp_path: Path,
ddl: str,
missing_col: str,
) -> None:
"""Test rates tables without timeframe fail fast during incremental resume."""
"""Test rates tables missing a required column fail fast."""
fallback = datetime(2024, 1, 1, tzinfo=UTC)
with sqlite3.connect(tmp_path / "rates-without-timeframe.db") as conn:
conn.execute("CREATE TABLE rates(symbol TEXT, time TEXT, open REAL)")
conn.execute(
"INSERT INTO rates(symbol, time, open) VALUES (?, ?, ?)",
("EURUSD", "2024-01-02T00:00:00+00:00", 1.0),
)
with pytest.raises(ValueError, match="missing: timeframe") as exc_info:
with sqlite3.connect(tmp_path / f"rates-no-{missing_col}.db") as conn:
conn.execute(ddl)
with pytest.raises(ValueError, match=f"missing: {missing_col}") as exc_info:
load_incremental_start_datetimes(
conn,
Dataset.rates,
@@ -725,47 +733,7 @@ class TestIncrementalStart:
timeframes=[1],
fallback_start=fallback,
)
assert "timeframe" in str(exc_info.value)
def test_load_incremental_start_datetimes_requires_symbol_column(
self,
tmp_path: Path,
) -> None:
"""Test rates tables without symbol fail fast during incremental resume."""
fallback = datetime(2024, 1, 1, tzinfo=UTC)
with sqlite3.connect(tmp_path / "rates-no-symbol.db") as conn:
conn.execute(
"CREATE TABLE rates(timeframe INTEGER, time TEXT, open REAL)",
)
with pytest.raises(ValueError, match="missing: symbol") as exc_info:
load_incremental_start_datetimes(
conn,
Dataset.rates,
symbols=["EURUSD"],
timeframes=[1],
fallback_start=fallback,
)
assert "symbol" in str(exc_info.value)
def test_load_incremental_start_datetimes_requires_time_column(
self,
tmp_path: Path,
) -> None:
"""Test rates tables without time fail fast during incremental resume."""
fallback = datetime(2024, 1, 1, tzinfo=UTC)
with sqlite3.connect(tmp_path / "rates-no-time.db") as conn:
conn.execute(
"CREATE TABLE rates(symbol TEXT, timeframe INTEGER, open REAL)",
)
with pytest.raises(ValueError, match="missing: time") as exc_info:
load_incremental_start_datetimes(
conn,
Dataset.rates,
symbols=["EURUSD"],
timeframes=[1],
fallback_start=fallback,
)
assert "time" in str(exc_info.value)
assert missing_col in str(exc_info.value)
def test_load_incremental_start_datetimes_rejects_unrelated_rates_columns(
self,
@@ -1800,12 +1768,11 @@ class TestIncrementalIntegration:
)
assert written_tables == set()
def test_resolve_history_tick_flags_invalid(self) -> None:
@pytest.mark.parametrize("flags", ["BAD", 7])
def test_resolve_history_tick_flags_invalid(self, flags: str | int) -> None:
"""Test invalid tick flags raise ValueError."""
with pytest.raises(ValueError, match="Invalid tick flags"):
resolve_history_tick_flags("BAD")
with pytest.raises(ValueError, match="Invalid tick flags"):
resolve_history_tick_flags(7)
resolve_history_tick_flags(flags)
def test_resolve_history_timeframes_invalid(self) -> None:
"""Test invalid timeframes raise ValueError."""
+292 -43
View File
@@ -54,6 +54,7 @@ from mt5cli.sdk import (
resolve_account_spec,
resolve_account_specs,
substitute_env_placeholders,
substitute_mapping_values,
symbol_info,
symbol_info_tick,
symbols,
@@ -1937,29 +1938,28 @@ class TestResolveAccountSpec:
assert [a.server for a in resolved] == ["Shared", "Fixed"]
assert all(a.timeout == 1000 for a in resolved)
def test_resolve_account_spec_with_whole_dollar_env(
@pytest.mark.parametrize(
("allow_whole_dollar_env", "expected"),
[
(True, "secret"),
(False, "$MT5_PASSWORD"),
],
)
def test_resolve_account_spec_whole_dollar_password(
self,
monkeypatch: pytest.MonkeyPatch,
allow_whole_dollar_env: bool,
expected: str,
) -> None:
"""Account spec expands $ENV_NAME when allow_whole_dollar_env=True."""
"""Test resolve_account_spec expands $ENV_NAME password only with opt-in."""
monkeypatch.setenv("MT5_PASSWORD", "secret")
account = AccountSpec(symbols=["EURUSD"], password="$MT5_PASSWORD")
resolved = resolve_account_spec(account, allow_whole_dollar_env=True)
resolved = resolve_account_spec(
account, allow_whole_dollar_env=allow_whole_dollar_env
)
assert resolved.password == "secret" # noqa: S105
def test_resolve_account_spec_whole_dollar_not_expanded_by_default(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test resolve_account_spec leaves $ENV_NAME literal by default."""
monkeypatch.setenv("MT5_PASSWORD", "secret")
account = AccountSpec(symbols=["EURUSD"], password="$MT5_PASSWORD")
resolved = resolve_account_spec(account)
assert resolved.password == "$MT5_PASSWORD" # noqa: S105
assert resolved.password == expected
def test_resolve_account_specs_with_whole_dollar_env(
self,
@@ -1993,38 +1993,27 @@ class TestResolveAccountSpec:
class TestBuildConfigWholeDollarEnv:
"""Tests for build_config with allow_whole_dollar_env."""
def test_build_config_substitutes_server_with_opt_in(
@pytest.mark.parametrize(
("env_var", "field", "env_value"),
[
("MT5_SERVER", "server", "Broker-Demo"),
("MT5_PASSWORD", "password", "secret"),
("MT5_PATH", "path", "/opt/mt5/terminal64.exe"),
],
)
def test_build_config_substitutes_field_with_opt_in(
self,
monkeypatch: pytest.MonkeyPatch,
env_var: str,
field: str,
env_value: str,
) -> None:
"""build_config expands $ENV_NAME server when allow_whole_dollar_env=True."""
monkeypatch.setenv("MT5_SERVER", "Broker-Demo")
"""Test build_config expands $ENV_NAME fields when opt-in is enabled."""
monkeypatch.setenv(env_var, env_value)
config = build_config(server="$MT5_SERVER", allow_whole_dollar_env=True)
config = build_config(**{field: f"${env_var}"}, allow_whole_dollar_env=True) # type: ignore[arg-type]
assert config.server == "Broker-Demo"
def test_build_config_substitutes_password_with_opt_in(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""build_config expands $ENV_NAME password when allow_whole_dollar_env=True."""
monkeypatch.setenv("MT5_PASSWORD", "secret")
config = build_config(password="$MT5_PASSWORD", allow_whole_dollar_env=True)
assert config.password == "secret" # noqa: S105
def test_build_config_substitutes_path_with_opt_in(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test build_config expands $ENV_NAME path when allow_whole_dollar_env=True."""
monkeypatch.setenv("MT5_PATH", "/opt/mt5/terminal64.exe")
config = build_config(path="$MT5_PATH", allow_whole_dollar_env=True)
assert config.path == "/opt/mt5/terminal64.exe"
assert getattr(config, field) == env_value
def test_build_config_leaves_dollar_literal_by_default(
self,
@@ -2436,3 +2425,263 @@ class TestThrottledHistoryUpdater:
updater.update(MagicMock(), ["EURUSD"])
assert updater.last_update_monotonic is None
class TestBuildConfigStringLogin:
"""Tests for build_config() string login coercion (issue #61)."""
@pytest.mark.parametrize(
("login", "expected"),
[
(None, None),
(12345, 12345),
("12345", 12345),
(" 12345 ", 12345),
("", None),
(" ", None),
],
)
def test_coerces_login_from_string(
self,
login: int | str | None,
expected: int | None,
) -> None:
"""Test build_config coerces string login to int or None."""
config = build_config(login=login)
assert config.login == expected
def test_rejects_non_numeric_string_login(self) -> None:
"""Test build_config raises ValueError for non-numeric string login."""
with pytest.raises(ValueError, match="invalid literal"):
build_config(login="abc")
def test_expands_dollar_brace_login_with_opt_in(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test build_config expands ${MT5_LOGIN} and coerces with opt-in."""
monkeypatch.setenv("MT5_LOGIN", "12345")
config = build_config(login="${MT5_LOGIN}", allow_whole_dollar_env=True)
assert config.login == 12345
def test_expands_whole_dollar_login_with_opt_in(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test build_config expands $MT5_LOGIN and coerces with opt-in."""
monkeypatch.setenv("MT5_LOGIN", "99999")
config = build_config(login="$MT5_LOGIN", allow_whole_dollar_env=True)
assert config.login == 99999
def test_missing_env_variable_raises(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test build_config raises ValueError when referenced env var is not set."""
monkeypatch.delenv("MT5_LOGIN", raising=False)
with pytest.raises(ValueError, match="'MT5_LOGIN' is not set"):
build_config(login="${MT5_LOGIN}", allow_whole_dollar_env=True)
def test_env_expands_to_blank_becomes_none(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test build_config coerces blank env-expanded login to None."""
monkeypatch.setenv("MT5_LOGIN", "")
config = build_config(login="${MT5_LOGIN}", allow_whole_dollar_env=True)
assert config.login is None
def test_dollar_brace_login_not_expanded_without_opt_in(self) -> None:
"""Test ${MT5_LOGIN} is not expanded when allow_whole_dollar_env=False."""
with pytest.raises(ValueError, match="invalid literal"):
build_config(login="${MT5_LOGIN}")
def test_integer_login_preserved_backward_compat(self) -> None:
"""Test existing int login callers remain backward-compatible."""
config = build_config(login=54321)
assert config.login == 54321
def test_none_login_preserved_backward_compat(self) -> None:
"""Test existing None login callers remain backward-compatible."""
config = build_config(login=None)
assert config.login is None
class TestSubstituteMappingValues:
"""Tests for substitute_mapping_values() (issue #62)."""
def test_substitutes_selected_keys_in_flat_dict(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test selected keys are substituted in a flat mapping."""
monkeypatch.setenv("MT5_LOGIN", "12345")
data: dict[str, object] = {
"mt5_login": "${MT5_LOGIN}",
"strategy_name": "${MT5_LOGIN}",
}
result = substitute_mapping_values(data, keys={"mt5_login"})
assert result == {"mt5_login": "12345", "strategy_name": "${MT5_LOGIN}"}
def test_preserves_non_selected_literal_dollar_signs(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test literal dollar signs in non-selected fields are preserved exactly."""
monkeypatch.setenv("MT5_PASSWORD", "secret")
data: dict[str, object] = {
"mt5_password": "${MT5_PASSWORD}",
"notes": "$NOT_EXPANDED",
}
result = substitute_mapping_values(data, keys={"mt5_password"})
assert result == {"mt5_password": "secret", "notes": "$NOT_EXPANDED"}
def test_nested_dict_traversal_substitutes_selected_keys(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test selected keys inside nested dicts are substituted."""
monkeypatch.setenv("MT5_SERVER", "Broker-Demo")
data: dict[str, object] = {
"outer": {
"mt5_server": "${MT5_SERVER}",
"other": "${MT5_SERVER}",
}
}
result = substitute_mapping_values(data, keys={"mt5_server"})
assert result == {
"outer": {"mt5_server": "Broker-Demo", "other": "${MT5_SERVER}"}
}
def test_nested_list_traversal_substitutes_selected_keys(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test selected keys inside list elements are substituted."""
monkeypatch.setenv("MT5_LOGIN", "42")
data: dict[str, object] = {
"accounts": [
{"mt5_login": "${MT5_LOGIN}", "name": "${MT5_LOGIN}"},
{"mt5_login": "${MT5_LOGIN}", "name": "fixed"},
]
}
result = substitute_mapping_values(data, keys={"mt5_login"})
assert result == {
"accounts": [
{"mt5_login": "42", "name": "${MT5_LOGIN}"},
{"mt5_login": "42", "name": "fixed"},
]
}
def test_whole_dollar_expanded_with_opt_in(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test $ENV_NAME is expanded when allow_whole_dollar_env=True."""
monkeypatch.setenv("MT5_PASSWORD", "secret")
data: dict[str, object] = {"mt5_password": "$MT5_PASSWORD"}
result = substitute_mapping_values(
data,
keys={"mt5_password"},
allow_whole_dollar_env=True,
)
assert result == {"mt5_password": "secret"}
def test_whole_dollar_not_expanded_by_default(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test $ENV_NAME in a selected key is preserved when opt-in is False."""
monkeypatch.setenv("MT5_PASSWORD", "secret")
data: dict[str, object] = {"mt5_password": "$MT5_PASSWORD"}
result = substitute_mapping_values(data, keys={"mt5_password"})
assert result == {"mt5_password": "$MT5_PASSWORD"}
def test_blank_string_becomes_none_for_blank_keys(self) -> None:
"""Test blank strings are normalised to None for blank_string_keys_as_none."""
data: dict[str, object] = {
"mt5_login": "",
"mt5_password": " ",
"other": "",
}
result = substitute_mapping_values(
data,
keys=set(),
blank_string_keys_as_none={"mt5_login", "mt5_password"},
)
assert result == {"mt5_login": None, "mt5_password": None, "other": ""}
def test_env_expanded_blank_becomes_none(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test env-expanded blank string is normalised to None."""
monkeypatch.setenv("MT5_LOGIN", "")
data: dict[str, object] = {"mt5_login": "${MT5_LOGIN}"}
result = substitute_mapping_values(
data,
keys={"mt5_login"},
blank_string_keys_as_none={"mt5_login"},
)
assert result == {"mt5_login": None}
def test_missing_env_variable_raises_for_selected_key(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test missing env var for a selected key raises ValueError."""
monkeypatch.delenv("MT5_MISSING", raising=False)
data: dict[str, object] = {"mt5_login": "${MT5_MISSING}"}
with pytest.raises(ValueError, match="'MT5_MISSING' is not set"):
substitute_mapping_values(data, keys={"mt5_login"})
def test_non_string_values_preserved(self) -> None:
"""Test non-string values under selected or non-selected keys are preserved."""
data: dict[str, object] = {
"mt5_login": 12345,
"timeout": 5000,
"enabled": True,
"ratio": 1.5,
"nothing": None,
}
result = substitute_mapping_values(
data, keys={"mt5_login", "timeout", "enabled", "ratio", "nothing"}
)
assert result == data
def test_caller_supplied_key_set_substitutes_correctly(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test helper works with any caller-supplied key set."""
monkeypatch.setenv("APP_LOGIN", "77777")
monkeypatch.setenv("APP_PASSWORD", "p4ss")
data: dict[str, object] = {
"app_login": "${APP_LOGIN}",
"app_password": "${APP_PASSWORD}",
"unrelated": "${APP_LOGIN}",
}
credential_keys = {"app_login", "app_password"}
result = substitute_mapping_values(data, keys=credential_keys)
assert result == {
"app_login": "77777",
"app_password": "p4ss",
"unrelated": "${APP_LOGIN}",
}
def test_scalar_data_returned_unchanged(self) -> None:
"""Test a scalar (non-dict, non-list) value is returned as-is."""
assert substitute_mapping_values("hello", keys={"x"}) == "hello"
assert substitute_mapping_values(42, keys={"x"}) == 42
assert substitute_mapping_values(None, keys={"x"}) is None
def test_tuple_container_not_traversed(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test tuple containers are returned as-is without traversal."""
monkeypatch.setenv("MT5_LOGIN", "42")
data: dict[str, object] = {"accounts": ({"mt5_login": "${MT5_LOGIN}"},)}
result = substitute_mapping_values(data, keys={"mt5_login"})
# tuple is returned as-is; inner dict is NOT visited
assert result == {"accounts": ({"mt5_login": "${MT5_LOGIN}"},)}
+1055 -605
View File
File diff suppressed because it is too large Load Diff
+20 -12
View File
@@ -4,21 +4,22 @@ from __future__ import annotations
import json
import sqlite3
import sys
from datetime import UTC, datetime
from typing import TYPE_CHECKING
import pandas as pd
import pytest
import mt5cli.utils
if TYPE_CHECKING:
from pathlib import Path
from mt5cli.utils import (
DATETIME_TYPE,
REQUEST_TYPE,
TICK_FLAG_MAP,
TICK_FLAGS_TYPE,
TIMEFRAME_MAP,
TIMEFRAME_TYPE,
Dataset,
IfExists,
@@ -111,6 +112,17 @@ class TestExportDataframe:
result = pd.read_parquet(output)
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:
"""Test SQLite3 export."""
output = tmp_path / "out.db"
@@ -361,17 +373,13 @@ class TestParseRequest:
class TestConstants:
"""Tests for module constants."""
def test_timeframe_map_has_expected_keys(self) -> None:
"""Test that TIMEFRAME_MAP contains standard timeframes."""
for key in ("M1", "M5", "M15", "M30", "H1", "H4", "D1", "W1", "MN1"):
assert key in TIMEFRAME_MAP
def test_timeframe_map_is_private_in_utils(self) -> None:
"""TIMEFRAME_MAP is a private implementation detail; not a public attribute."""
assert not hasattr(mt5cli.utils, "TIMEFRAME_MAP")
def test_tick_flag_map_has_expected_keys(self) -> None:
"""Test that TICK_FLAG_MAP contains standard flags with MT5 values."""
assert {"ALL", "INFO", "TRADE"} <= set(TICK_FLAG_MAP)
assert TICK_FLAG_MAP["ALL"] == -1
assert TICK_FLAG_MAP["INFO"] == 1
assert TICK_FLAG_MAP["TRADE"] == 2
def test_tick_flag_map_absent_from_utils(self) -> None:
"""TICK_FLAG_MAP is not exposed by mt5cli.utils."""
assert not hasattr(mt5cli.utils, "TICK_FLAG_MAP")
@pytest.mark.parametrize(
("dataset", "expected"),
Generated
+14 -7
View File
@@ -487,21 +487,26 @@ wheels = [
[[package]]
name = "mt5cli"
version = "0.9.2"
version = "1.0.0"
source = { editable = "." }
dependencies = [
{ name = "click" },
{ name = "pdmt5" },
{ name = "pyarrow" },
{ name = "typer" },
]
[package.optional-dependencies]
parquet = [
{ name = "pyarrow" },
]
[package.dev-dependencies]
dev = [
{ name = "mkdocs" },
{ name = "mkdocs-material" },
{ name = "mkdocstrings", extra = ["python"] },
{ name = "pandas-stubs" },
{ name = "pyarrow" },
{ name = "pymdown-extensions" },
{ name = "pyright" },
{ name = "pytest" },
@@ -513,10 +518,11 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "click", specifier = ">=8.1.0" },
{ name = "pdmt5", specifier = ">=0.3.0" },
{ name = "pyarrow", specifier = ">=19.0.0" },
{ name = "pdmt5", specifier = ">=1.0.0" },
{ name = "pyarrow", marker = "extra == 'parquet'", specifier = ">=19.0.0" },
{ name = "typer", specifier = ">=0.15.0" },
]
provides-extras = ["parquet"]
[package.metadata.requires-dev]
dev = [
@@ -524,6 +530,7 @@ dev = [
{ name = "mkdocs-material", specifier = ">=9.7.6" },
{ name = "mkdocstrings", extras = ["python"], specifier = ">=1.0.4" },
{ name = "pandas-stubs", specifier = ">=2.2.3.250527" },
{ name = "pyarrow", specifier = ">=19.0.0" },
{ name = "pymdown-extensions", specifier = ">=10.21.2" },
{ name = "pyright", specifier = ">=1.1.407" },
{ name = "pytest", specifier = ">=9.0.3" },
@@ -684,16 +691,16 @@ wheels = [
[[package]]
name = "pdmt5"
version = "0.3.0"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "metatrader5", marker = "sys_platform == 'win32'" },
{ name = "pandas" },
{ 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 = [
{ 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]]