Compare commits

..

9 Commits

Author SHA1 Message Date
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
Daichi Narushima 9ac3b885c3 test: add explicit unit tests for calculate_positions_margin_by_symbol and calculate_positions_margin_safe (#50) (#53)
* test: add explicit unit tests for calculate_positions_margin_by_symbol and calculate_positions_margin_safe (#50)

Covers all acceptance criteria: partial failure with warning log, all-fail,
empty symbol list with no-broker-call assertion, duplicate deduplication,
successful aggregation with first-seen key order, suppress_errors=False
propagation, and three calculate_positions_margin_safe cases (partial skip,
all-fail → 0.0, empty list → 0.0).

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

* test: fix warning log assertion and parametrize suppress_errors=False test

- Use record.getMessage() + levelno check instead of record.message, which
  is only populated after formatting and can return an empty string.
- Parametrize test_one_symbol_fails_suppress_errors_false over all three
  exception types caught by the implementation (Mt5TradingError,
  Mt5RuntimeError, AttributeError) so any future narrowing of the except
  tuple would be caught by tests.

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

* style: shorten docstring to fit 88-char line limit

* style: shorten docstring to fit 88-char line limit

---------

Co-authored-by: agent <agent@localhost>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 21:41:30 +09:00
Daichi Narushima 823cb5b0a4 Revert "Bump version to v0.9.3 (#51)" (#52)
This reverts commit f1ada55bce.
2026-06-23 19:17:42 +09:00
agent 1c57be5c44 fix: centralize tick price validation in calculate_spread_ratio and determine_order_limits (#52)
Replaces manual isinstance/<=0 checks in calculate_spread_ratio() and
determine_order_limits() with _valid_tick_price(), ensuring NaN, inf,
-inf, zero, negative, bool, and invalid-string tick values are
consistently rejected across all trading helpers.

Adds regression tests covering numeric-string acceptance and every
invalid-value category for both functions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 09:48:13 +00:00
Daichi Narushima f1ada55bce Bump version to v0.9.3 (#51) 2026-06-23 18:30:18 +09:00
agent d292fbb9d9 feat: centralize tick price validation and add resilient position margin helpers (#49, #50)
Add _valid_tick_price() internal helper that returns a positive finite float
from a tick dict or None for any invalid value (missing, None, NaN, infinite,
zero, negative, or unsupported type). Refactor five existing bid/ask validation
sites in trading.py to use it, removing duplicated isinstance/isfinite checks.

Add calculate_positions_margin_by_symbol() which computes margin per unique
symbol independently using the existing strict calculate_positions_margin(),
with first-seen deduplication and configurable error suppression
(Mt5TradingError, Mt5RuntimeError, AttributeError) via suppress_errors=.

Add calculate_positions_margin_safe() as a thin sum wrapper with
suppress_errors=True, returning 0.0 on empty or fully-failed inputs.

Both new helpers are exported from mt5cli, added to STABLE_SDK_EXPORTS, and
documented in docs/api/public-contract.md. Existing strict behavior of
calculate_positions_margin() is unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 07:30:02 +00:00
Daichi Narushima 8e53212a24 fix: always use mt5cli calculate_volume_by_margin to prevent LACK OF FUNDS (#48) 2026-06-23 14:01:46 +09:00
15 changed files with 1987 additions and 252 deletions
+10 -5
View File
@@ -31,7 +31,7 @@ pip install -U mt5cli 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
@@ -92,16 +92,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",
@@ -248,9 +253,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 `pdmt5.Mt5TradingClient` that initializes/logs in via `Mt5Config.path` and always shuts down safely. Pair with `detect_position_side()`, `calculate_margin_and_volume()`, and `determine_order_limits()` for generic position and sizing utilities. Keep read-only collection on `mt5_session()` / `MT5Client`.
- **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 +322,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
+85 -55
View File
@@ -8,55 +8,49 @@ downstream app -> mt5cli -> pdmt5 -> MetaTrader 5
```
Downstream packages should import from the package root (`from mt5cli import
...`) and treat the symbols listed below as the stable SDK contract. CLI
commands mirror the same behavior but are not importable Python APIs.
...`) and use the public tier sets in `mt5cli.contract` to distinguish API
stability. CLI commands mirror the same behavior but are not importable Python
APIs.
## Public API tiers
mt5cli classifies package-root imports by intended downstream use:
| Tier | Contract set | Meaning |
| ---------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| Stable core | `STABLE_SDK_EXPORTS` | Preferred SDK surface for downstream MT5 infrastructure adapters. Changes require a deliberate compatibility path. |
| Secondary public | `SECONDARY_PUBLIC_EXPORTS` | Public helpers for CLI/export/schema integrations and lower-level MT5 wrappers. Importable, but less central to the downstream trading SDK. |
## Stable downstream SDK API
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.
`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` |
| 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 `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` |
| `substitute_mapping_values` | Recursively traverse a dict/list/scalar structure and substitute `${ENV_VAR}` placeholders for caller-selected mapping keys only; optionally normalise blank strings to `None` for a separate caller-selected key set; does not hard-code any application-specific key names |
Credential resolution is generic: any environment variable name may appear inside
`${...}`. mt5cli does not hard-code application-specific keys such as
`mt5_login` or `mt5_exe`.
Pass `allow_whole_dollar_env=True` to `substitute_env_placeholders()`,
`resolve_account_spec()`, `resolve_account_specs()`, and `build_config()` to
additionally expand strings whose entire value is a bare `$ENV_NAME` identifier.
`substitute_mapping_values()`, `resolve_account_spec()`, `resolve_account_specs()`,
and `build_config()` to additionally expand strings whose entire value is a bare
`$ENV_NAME` identifier.
Partial strings such as `"plan$pass"`, `"abc$ENV"`, or `"$ENV-suffix"` are
**never** expanded — only an exact `$IDENTIFIER` whole-string match qualifies.
Default is `False` to preserve backward compatibility.
### Read-only MT5 data access
Module-level helpers open a transient connection per call. Prefer `mt5_session`
or `MT5Client` when making many requests in one process.
| Area | Symbols |
| -------------------- | ---------------------------------------------------------------------------------------------------- |
| Rates | `copy_rates_from`, `copy_rates_from_pos`, `copy_rates_range`, `latest_rates`, `collect_latest_rates` |
| Ticks | `copy_ticks_from`, `copy_ticks_range`, `recent_ticks` |
| Account / terminal | `account_info`, `terminal_info`, `mt5_version`, `last_error`, `mt5_summary`, `mt5_summary_as_df` |
| Symbols / market | `symbols`, `symbol_info`, `symbol_info_tick`, `market_book`, `minimum_margins` |
| Trading state (read) | `orders`, `positions`, `history_orders`, `history_deals`, `recent_history_deals` |
Use `mt5_version` for MetaTrader 5 terminal version data. The name `version` at
the package root refers to `importlib.metadata.version` (package metadata), not
the MT5 SDK helper.
### Closed-bar rate helpers
MetaTrader 5 returns the still-forming bar as the last row when
@@ -71,7 +65,6 @@ timestamp normalization in downstream apps.
| `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
@@ -99,18 +92,25 @@ 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 |
| `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 |
`MT5Client.order_send()` and CLI `order-send --yes` are live execution paths.
@@ -133,13 +133,43 @@ and returned as `status="failed"` with normalized `request` / `response` details
| `normalize_mt5_exception`, `call_with_normalized_errors`, `is_recoverable_mt5_error` | Error normalization and retry classification |
| `Mt5Config`, `Mt5RuntimeError`, `Mt5TradingClient`, `Mt5TradingError` | Re-exported pdmt5 types for adapter convenience |
### Additional public exports (secondary)
## Secondary public exports
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.
These names remain importable from `mt5cli` and are covered by
`SECONDARY_PUBLIC_EXPORTS`, but they are oriented toward CLI/export/schema
integrations, parsing, and lower-level MT5 access rather than the stable core
SDK surface. Prefer the stable symbols above for downstream infrastructure
adapters.
### Read-only MT5 data wrappers
Module-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` |
| Multi-account rates | `collect_latest_rates_for_accounts` |
Use `mt5_version` for MetaTrader 5 terminal version data. The name `version` at
the package root refers to `importlib.metadata.version` (package metadata), not
the MT5 SDK helper.
### Schema, export, and parser helpers
| Area | Symbols |
| -------------------- | ------------------------------------------------------------------------------------------------------------- |
| Dataset contracts | `DataKind`, `Dataset`, `IfExists`, `DEDUP_KEYS`, `REQUIRED_COLUMNS`, `TIME_COLUMNS`, `KNOWN_MT5_TIME_COLUMNS` |
| Schema normalization | `normalize_dataframe`, `normalize_time_columns`, `schema_columns`, `validate_schema` |
| Export helpers | `detect_format`, `export_dataframe`, `export_dataframe_to_sqlite` |
| Symbol parsing | `normalize_symbol`, `normalize_symbols` |
| Time parsing | `ensure_utc`, `parse_date_range`, `parse_datetime`, `recent_window` |
| MT5 parsing maps | `granularity_name`, `parse_tick_flags`, `parse_timeframe`, `TICK_FLAG_MAP`, `TIMEFRAME_MAP` |
| Trading data shapes | `POSITION_COLUMNS` |
## CLI commands
@@ -188,7 +218,7 @@ 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__`.
`tests/test_contracts.py` asserts that every name in the stable and secondary
tier sets is importable from `mt5cli`, documents key closed-bar, rate-view,
SQLite loading, account-resolution, and trading-session behaviors, and keeps the
tier sets aligned with `__all__`.
+3 -3
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
@@ -170,5 +170,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.
+2 -2
View File
@@ -31,7 +31,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
@@ -194,6 +194,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.
+1 -1
View File
@@ -29,7 +29,7 @@ pip install mt5cli
## 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
+25 -3
View File
@@ -11,7 +11,11 @@ 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 .contract import (
PUBLIC_EXPORT_TIERS,
SECONDARY_PUBLIC_EXPORTS,
STABLE_SDK_EXPORTS,
)
from .converters import (
ensure_utc,
granularity_name,
@@ -59,7 +63,6 @@ from .schemas import (
)
from .sdk import (
AccountSpec,
Mt5CliClient,
ThrottledHistoryUpdater,
account_info,
collect_history,
@@ -89,6 +92,7 @@ from .sdk import (
resolve_account_spec,
resolve_account_specs,
substitute_env_placeholders,
substitute_mapping_values,
symbol_info,
symbol_info_tick,
symbols,
@@ -116,10 +120,16 @@ from .trading import (
OrderSide,
OrderTimeMode,
PositionSide,
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,
@@ -127,6 +137,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,
@@ -137,6 +148,7 @@ from .trading import (
normalize_order_volume,
place_market_order,
update_sltp_for_open_positions,
update_trailing_stop_loss_for_open_positions,
)
from .utils import (
TICK_FLAG_MAP,
@@ -152,7 +164,9 @@ __all__ = [
"DEDUP_KEYS",
"KNOWN_MT5_TIME_COLUMNS",
"POSITION_COLUMNS",
"PUBLIC_EXPORT_TIERS",
"REQUIRED_COLUMNS",
"SECONDARY_PUBLIC_EXPORTS",
"STABLE_SDK_EXPORTS",
"TICK_FLAG_MAP",
"TIMEFRAME_MAP",
@@ -164,7 +178,6 @@ __all__ = [
"IfExists",
"MT5Client",
"MarginVolume",
"Mt5CliClient",
"Mt5CliError",
"Mt5Config",
"Mt5ConnectionError",
@@ -185,10 +198,16 @@ __all__ = [
"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",
@@ -213,6 +232,7 @@ __all__ = [
"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",
@@ -264,6 +284,7 @@ __all__ = [
"resolve_rate_view_names",
"schema_columns",
"substitute_env_placeholders",
"substitute_mapping_values",
"symbol_info",
"symbol_info_tick",
"symbols",
@@ -271,5 +292,6 @@ __all__ = [
"update_history",
"update_history_with_config",
"update_sltp_for_open_positions",
"update_trailing_stop_loss_for_open_positions",
"validate_schema",
]
+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
+73 -29
View File
@@ -1,11 +1,10 @@
"""Stable downstream SDK export names for mt5cli."""
"""Downstream SDK export tiers for mt5cli."""
from __future__ import annotations
STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
"AccountSpec",
"MT5Client",
"Mt5CliClient",
"Mt5CliError",
"Mt5Config",
"Mt5ConnectionError",
@@ -24,36 +23,33 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
"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",
@@ -61,29 +57,16 @@ 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",
@@ -94,13 +77,74 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
"resolve_rate_view_name",
"resolve_rate_view_names",
"substitute_env_placeholders",
"substitute_mapping_values",
"update_history",
"update_history_with_config",
"update_sltp_for_open_positions",
"update_trailing_stop_loss_for_open_positions",
})
SECONDARY_PUBLIC_EXPORTS: frozenset[str] = frozenset({
"DEDUP_KEYS",
"DataKind",
"Dataset",
"IfExists",
"KNOWN_MT5_TIME_COLUMNS",
"POSITION_COLUMNS",
"REQUIRED_COLUMNS",
"TICK_FLAG_MAP",
"TIMEFRAME_MAP",
"TIME_COLUMNS",
"account_info",
"collect_latest_rates",
"collect_latest_rates_for_accounts",
"copy_rates_from",
"copy_rates_from_pos",
"copy_rates_range",
"copy_ticks_from",
"copy_ticks_range",
"detect_format",
"ensure_utc",
"export_dataframe",
"export_dataframe_to_sqlite",
"granularity_name",
"history_deals",
"history_orders",
"last_error",
"latest_rates",
"market_book",
"minimum_margins",
"mt5_summary",
"mt5_summary_as_df",
"mt5_version",
"normalize_dataframe",
"normalize_symbol",
"normalize_symbols",
"normalize_time_columns",
"orders",
"parse_date_range",
"parse_datetime",
"parse_tick_flags",
"parse_timeframe",
"positions",
"recent_history_deals",
"recent_ticks",
"recent_window",
"schema_columns",
"symbol_info",
"symbol_info_tick",
"symbols",
"terminal_info",
"update_history",
"update_history_with_config",
"update_sltp_for_open_positions",
"validate_schema",
})
__all__ = ["STABLE_SDK_EXPORTS"]
PUBLIC_EXPORT_TIERS: dict[str, frozenset[str]] = {
"stable": STABLE_SDK_EXPORTS,
"secondary": SECONDARY_PUBLIC_EXPORTS,
}
__all__ = [
"PUBLIC_EXPORT_TIERS",
"SECONDARY_PUBLIC_EXPORTS",
"STABLE_SDK_EXPORTS",
]
+83 -6
View File
@@ -40,7 +40,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]
@@ -142,6 +142,7 @@ __all__ = [
"resolve_account_spec",
"resolve_account_specs",
"substitute_env_placeholders",
"substitute_mapping_values",
"symbol_info",
"symbol_info_tick",
"symbols",
@@ -305,7 +306,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 +316,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 +336,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 +346,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 +1450,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,
+395 -63
View File
@@ -2,21 +2,23 @@
from __future__ import annotations
import logging
from contextlib import contextmanager
from math import floor, isfinite
from numbers import Integral, Real
from typing import TYPE_CHECKING, Literal, TypedDict, cast
import pandas as pd
from pdmt5 import Mt5Config, Mt5TradingClient, Mt5TradingError
from pdmt5 import Mt5Config, Mt5RuntimeError, Mt5TradingClient, Mt5TradingError
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, Sequence
from collections.abc import Iterator, Mapping, Sequence
_logger = logging.getLogger(__name__)
PositionSide = Literal["long", "short"]
OrderSide = Literal["BUY", "SELL"]
@@ -125,10 +127,16 @@ __all__ = [
"OrderSide",
"OrderTimeMode",
"PositionSide",
"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",
@@ -136,6 +144,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",
@@ -146,6 +155,7 @@ __all__ = [
"normalize_order_volume",
"place_market_order",
"update_sltp_for_open_positions",
"update_trailing_stop_loss_for_open_positions",
]
@@ -423,6 +433,30 @@ def _optional_price(value: object) -> float | None:
return price
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
missing, the value is None, non-numeric, NaN, infinite, zero, or negative.
Booleans are treated as non-numeric and return None.
"""
value = tick.get(key)
if value is None or isinstance(value, bool):
return None
if isinstance(value, int | float):
price = float(value)
elif isinstance(value, str):
try:
price = float(value)
except ValueError:
return None
else:
return None
if not isfinite(price) or price <= 0:
return None
return price
def _success_retcodes(mt5: object) -> frozenset[int]:
values = {
value
@@ -461,9 +495,10 @@ def _calculate_min_volume_if_affordable(
msg = f"Invalid volume constraints for {symbol!r}."
raise Mt5TradingError(msg)
side = _normalize_order_side(order_side)
tick = get_tick_snapshot(client, symbol)
price = tick["ask"] if side == "BUY" else tick["bid"]
if not isinstance(price, int | float) or price <= 0:
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)
order_type = (
@@ -621,14 +656,14 @@ def estimate_order_margin(
raise Mt5TradingError(msg)
side = _normalize_order_side(order_side)
tick = get_tick_snapshot(client, symbol)
price = tick["ask"] if side == "BUY" else tick["bid"]
if not isinstance(price, int | float) or price <= 0 or not isfinite(price):
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)
order_type = (
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
)
raw_margin = client.order_calc_margin(order_type, symbol, volume, float(price))
raw_margin = client.order_calc_margin(order_type, symbol, volume, price)
try:
margin = float(raw_margin)
except (TypeError, ValueError) as exc:
@@ -682,22 +717,85 @@ def calculate_positions_margin(
return total
def calculate_positions_margin_by_symbol(
client: Mt5TradingClient,
*,
symbols: Sequence[str],
suppress_errors: bool = True,
) -> dict[str, float]:
"""Return per-symbol estimated margin for open positions.
Computes margin for each unique input symbol independently using the strict
:func:`calculate_positions_margin` helper. Duplicates are deduplicated in
first-seen order.
Args:
client: Connected ``Mt5TradingClient`` instance.
symbols: Symbols to compute margin for.
suppress_errors: When ``True``, log and skip symbols that raise
``Mt5TradingError``, ``Mt5RuntimeError``, or ``AttributeError``.
When ``False``, re-raise the first failure.
Returns:
Mapping of symbol to margin total in first-seen unique-symbol order.
Returns an empty dict when ``symbols`` is empty or all symbols fail
with ``suppress_errors=True``.
Raises:
Mt5TradingError: When a symbol raises ``Mt5TradingError`` and
``suppress_errors=False``.
Mt5RuntimeError: When a symbol raises ``Mt5RuntimeError`` and
``suppress_errors=False``.
AttributeError: When a symbol raises ``AttributeError`` and
``suppress_errors=False``.
"""
result: dict[str, float] = {}
for symbol in dict.fromkeys(symbols):
try:
result[symbol] = calculate_positions_margin(client, symbols=[symbol])
except (Mt5TradingError, Mt5RuntimeError, AttributeError) as exc:
if not suppress_errors:
raise
_logger.warning("Skipping margin for %r: %s", symbol, exc)
return result
def calculate_positions_margin_safe(
client: Mt5TradingClient,
*,
symbols: Sequence[str],
) -> float:
"""Return the total estimated margin for open positions across symbols.
Internally calls :func:`calculate_positions_margin_by_symbol` with
``suppress_errors=True``. Failed symbols are silently skipped.
Args:
client: Connected ``Mt5TradingClient`` instance.
symbols: Symbols to include.
Returns:
Sum of per-symbol margins; ``0.0`` when no symbols or all fail.
"""
return sum(
calculate_positions_margin_by_symbol(client, symbols=symbols).values(),
0.0,
)
def calculate_spread_ratio(client: Mt5TradingClient, symbol: str) -> float:
"""Return ``(ask - bid) / ((ask + bid) / 2)`` for the latest tick.
Raises:
Mt5TradingError: If bid or ask is unavailable or non-positive.
Mt5TradingError: If bid or ask is unavailable.
"""
tick = get_tick_snapshot(client, symbol)
bid = tick.get("bid")
ask = tick.get("ask")
if not isinstance(bid, int | float) or not isinstance(ask, int | float):
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)
if bid <= 0 or ask <= 0:
msg = f"Tick bid/ask must be positive for {symbol!r}."
raise Mt5TradingError(msg)
return (float(ask) - float(bid)) / ((float(ask) + float(bid)) / 2.0)
return (ask - bid) / ((ask + bid) / 2.0)
def calculate_new_position_margin_ratio(
@@ -720,9 +818,10 @@ def calculate_new_position_margin_ratio(
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)
tick = get_tick_snapshot(client, symbol)
price = tick["ask"] if side == "BUY" else tick["bid"]
if not isinstance(price, int | float) or price <= 0:
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)
order_type = (
@@ -734,6 +833,147 @@ def calculate_new_position_margin_ratio(
return margin / equity
def _account_equity(client: Mt5TradingClient) -> 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 Mt5TradingError(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 Mt5TradingError(msg)
return value
def calculate_account_projected_margin_ratio(
client: Mt5TradingClient,
*,
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: Mt5TradingClient,
*,
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 calculate_symbol_group_margin_ratio(
client: Mt5TradingClient,
*,
symbols: Sequence[str],
new_symbol: str | None = None,
new_position_side: OrderSide | None = None,
new_position_volume: float = 0.0,
suppress_errors: bool = True,
) -> 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, optional projected order margin is added for that
symbol. 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``.
Mt5TradingError: When account equity is invalid, or when symbol margin
lookup or projected margin lookup fails and ``suppress_errors`` is
``False``.
"""
equity = _account_equity(client)
unique_symbols = list(dict.fromkeys(symbols))
margin = sum(
calculate_positions_margin_by_symbol(
client,
symbols=unique_symbols,
suppress_errors=suppress_errors,
).values(),
0.0,
)
if (
new_symbol in unique_symbols
and new_position_side is not None
and new_position_volume > 0
):
try:
margin += estimate_order_margin(
client,
new_symbol,
new_position_side,
new_position_volume,
)
except (Mt5TradingError, Mt5RuntimeError, AttributeError):
if not suppress_errors:
raise
_logger.warning("Skipping projected margin for %r.", new_symbol)
return margin / equity
def calculate_margin_and_volume(
client: Mt5TradingClient,
symbol: str,
@@ -780,28 +1020,8 @@ def calculate_margin_and_volume(
"SELL",
)
else:
native_calculate_volume = getattr(client, "calculate_volume_by_margin", None)
if callable(native_calculate_volume):
buy_volume = float(
cast(
"float | int | str",
native_calculate_volume(symbol, trade_margin, "BUY"),
),
)
sell_volume = float(
cast(
"float | int | str",
native_calculate_volume(symbol, trade_margin, "SELL"),
),
)
else:
buy_volume = calculate_volume_by_margin(client, symbol, trade_margin, "BUY")
sell_volume = calculate_volume_by_margin(
client,
symbol,
trade_margin,
"SELL",
)
buy_volume = calculate_volume_by_margin(client, symbol, trade_margin, "BUY")
sell_volume = calculate_volume_by_margin(client, symbol, trade_margin, "SELL")
try:
symbol_info = get_symbol_snapshot(client, symbol)
volume_min = float(symbol_info.get("volume_min") or 0.0)
@@ -847,8 +1067,10 @@ def calculate_volume_by_margin(
msg = f"Invalid volume constraints for {symbol!r}."
raise Mt5TradingError(msg)
side = _normalize_order_side(order_side)
price = get_tick_snapshot(client, symbol)["ask" if side == "BUY" else "bid"]
if not isinstance(price, int | float) or price <= 0:
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)
order_type = (
@@ -924,11 +1146,11 @@ def determine_order_limits(
_require_protective_ratio(take_profit_ratio, "take_profit_limit_ratio")
normalized_side = _position_side_from_order_side(side)
tick = get_tick_snapshot(client, symbol)
entry_value = tick["ask"] if normalized_side == "long" else tick["bid"]
if not isinstance(entry_value, int | float):
entry_key = "ask" if normalized_side == "long" else "bid"
entry = extract_tick_price(tick, entry_key)
if entry is None:
msg = f"Tick price is unavailable for {symbol!r}."
raise Mt5TradingError(msg)
entry = float(entry_value)
try:
symbol_info = get_symbol_snapshot(client, symbol)
except (AttributeError, KeyError, TypeError, ValueError):
@@ -1004,8 +1226,8 @@ def place_market_order(
if not dry_run:
ensure_symbol_selected(client, symbol)
tick = get_tick_snapshot(client, symbol)
price = tick["ask"] if side == "BUY" else tick["bid"]
if not isinstance(price, int | float) or price <= 0:
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)
request = {
@@ -1015,7 +1237,7 @@ def place_market_order(
"type": (
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
),
"price": float(price),
"price": price,
"type_filling": _resolve_mt5_constant(
client.mt5,
"ORDER_FILLING",
@@ -1112,6 +1334,125 @@ def close_open_positions(
return results
def _symbol_digits(client: Mt5TradingClient, 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: Mt5TradingClient,
*,
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: Mt5TradingClient,
*,
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: Mt5TradingClient,
*,
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,
*,
@@ -1197,19 +1538,10 @@ def fetch_latest_closed_rates_for_trading_client(
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 Mt5TradingError(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}: "
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "mt5cli"
version = "0.9.1"
version = "0.9.5"
description = "Generic MT5 data and execution infrastructure for Python applications"
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
+78 -4
View File
@@ -2,9 +2,11 @@
from __future__ import annotations
import re
import sqlite3
from datetime import UTC, datetime
from typing import TYPE_CHECKING, get_type_hints
from pathlib import Path
from typing import get_type_hints
from unittest.mock import MagicMock
import pandas as pd
@@ -15,7 +17,9 @@ from pytest_mock import MockerFixture # noqa: TC002
import mt5cli
from mt5cli import (
DEDUP_KEYS,
PUBLIC_EXPORT_TIERS,
REQUIRED_COLUMNS,
SECONDARY_PUBLIC_EXPORTS,
STABLE_SDK_EXPORTS,
TIME_COLUMNS,
AccountSpec,
@@ -33,8 +37,12 @@ from mt5cli import (
RateTarget,
build_config,
build_rate_targets,
calculate_account_projected_margin_ratio,
calculate_margin_and_volume,
calculate_positions_margin,
calculate_projected_margin_ratio,
calculate_symbol_group_margin_ratio,
calculate_trailing_stop_updates,
call_with_normalized_errors,
detect_format,
drop_forming_rate_bar,
@@ -42,6 +50,7 @@ from mt5cli import (
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,
@@ -69,9 +78,6 @@ 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
def _sample_frame(kind: DataKind) -> pd.DataFrame:
if kind is DataKind.rates:
@@ -547,11 +553,69 @@ class TestStableSdkContract:
missing = sorted(STABLE_SDK_EXPORTS - set(mt5cli.__all__))
assert not missing, f"STABLE_SDK_EXPORTS missing from __all__: {missing}"
def test_public_export_tiers_are_disjoint_and_complete(self) -> None:
"""Documented public tiers do not overlap and classify root exports."""
assert PUBLIC_EXPORT_TIERS == {
"stable": STABLE_SDK_EXPORTS,
"secondary": SECONDARY_PUBLIC_EXPORTS,
}
assert not (STABLE_SDK_EXPORTS & SECONDARY_PUBLIC_EXPORTS)
tiered_exports = STABLE_SDK_EXPORTS | SECONDARY_PUBLIC_EXPORTS
root_exports = set(mt5cli.__all__)
missing_from_root = sorted(tiered_exports - root_exports)
assert not missing_from_root, (
f"Tiered exports missing from __all__: {missing_from_root}"
)
tier_metadata_exports = {
"PUBLIC_EXPORT_TIERS",
"SECONDARY_PUBLIC_EXPORTS",
"STABLE_SDK_EXPORTS",
}
unclassified_root_exports = sorted(
root_exports - tiered_exports - tier_metadata_exports,
)
assert not unclassified_root_exports, (
f"Root exports missing from public API tiers: {unclassified_root_exports}"
)
def test_stable_docs_do_not_document_nonstable_exports(self) -> None:
"""Stable docs do not promote secondary root exports."""
docs_path = Path("docs/api/public-contract.md")
docs = docs_path.read_text(encoding="utf-8")
stable_section = docs.split("## Stable downstream SDK API", maxsplit=1)[
1
].split(
"## Secondary public exports",
maxsplit=1,
)[0]
documented_symbols = set(
re.findall(r"`([A-Za-z_][A-Za-z0-9_]*)`", stable_section)
)
nonstable_exports = SECONDARY_PUBLIC_EXPORTS
wrongly_stable = sorted(documented_symbols & nonstable_exports)
assert not wrongly_stable, (
f"Non-stable exports documented in stable section: {wrongly_stable}"
)
@pytest.mark.parametrize("name", sorted(STABLE_SDK_EXPORTS))
def test_stable_exports_are_importable_from_package_root(self, name: str) -> None:
"""Stable SDK names resolve through ``from mt5cli import ...``."""
assert hasattr(mt5cli, name), f"{name!r} missing from mt5cli package root"
@pytest.mark.parametrize(
"name",
sorted(SECONDARY_PUBLIC_EXPORTS),
)
def test_secondary_exports_are_importable(
self,
name: str,
) -> None:
"""Non-stable public names remain available from the package root."""
assert hasattr(mt5cli, name), f"{name!r} missing from mt5cli package root"
def test_drop_forming_rate_bar_from_package_root(self) -> None:
"""Closed-bar trimming is available from the stable package surface."""
frame = pd.DataFrame({"time": [1, 2, 3], "close": [1.0, 1.1, 1.2]})
@@ -615,6 +679,16 @@ class TestStableSdkContract:
assert calculate_positions_margin(client) == 0
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_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"
+261
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,
@@ -2436,3 +2437,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}"},)}
+968 -76
View File
File diff suppressed because it is too large Load Diff
Generated
+1 -1
View File
@@ -487,7 +487,7 @@ wheels = [
[[package]]
name = "mt5cli"
version = "0.9.1"
version = "0.9.5"
source = { editable = "." }
dependencies = [
{ name = "click" },