Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 292fac899a | |||
| 9ac3b885c3 | |||
| 823cb5b0a4 | |||
| 1c57be5c44 | |||
| f1ada55bce | |||
| d292fbb9d9 | |||
| 8e53212a24 | |||
| b878a61c07 | |||
| 0610ea732c | |||
| 82a39731ed |
@@ -104,8 +104,8 @@ A reliable pattern is:
|
|||||||
Example GraphQL mutation shape:
|
Example GraphQL mutation shape:
|
||||||
|
|
||||||
```graphql
|
```graphql
|
||||||
mutation($threadId: ID!) {
|
mutation ($threadId: ID!) {
|
||||||
resolveReviewThread(input: {threadId: $threadId}) {
|
resolveReviewThread(input: { threadId: $threadId }) {
|
||||||
thread {
|
thread {
|
||||||
id
|
id
|
||||||
isResolved
|
isResolved
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ pip install -U mt5cli MetaTrader5
|
|||||||
|
|
||||||
## Python API (downstream packages)
|
## 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
|
```python
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
@@ -250,7 +250,7 @@ eurusd_m1 = rates["EURUSD", "M1"] # closed bars only
|
|||||||
|
|
||||||
- **Credential resolution**: use `resolve_account_spec()` / `resolve_account_specs()` to merge explicit override values over `AccountSpec` fields and expand `${ENV_VAR}` placeholders (via `substitute_env_placeholders()`), raising `ValueError` for missing variables. This keeps secrets out of plan/config files without coupling to any strategy code.
|
- **Credential resolution**: use `resolve_account_spec()` / `resolve_account_specs()` to merge explicit override values over `AccountSpec` fields and expand `${ENV_VAR}` placeholders (via `substitute_env_placeholders()`), raising `ValueError` for missing variables. This keeps secrets out of plan/config files without coupling to any strategy code.
|
||||||
- **Throttled history updates**: use `ThrottledHistoryUpdater` to wrap `update_history()` with a minimum `interval_seconds` between successful runs (monotonic clock). Call `should_update()` / `update(client, symbols)` from an application loop; errors propagate by default, or pass `suppress_errors=True` to swallow recoverable `Mt5*Error`, `sqlite3.Error`, `ValueError`, `OSError`, and MT5 client capability errors for history API methods without advancing the throttle (other `AttributeError` / `TypeError` values always propagate). Pass `update_backend` to inject a custom history update callable (same keyword arguments as `update_history`) instead of monkey-patching `mt5cli.sdk.update_history`.
|
- **Throttled history updates**: use `ThrottledHistoryUpdater` to wrap `update_history()` with a minimum `interval_seconds` between successful runs (monotonic clock). Call `should_update()` / `update(client, symbols)` from an application loop; errors propagate by default, or pass `suppress_errors=True` to swallow recoverable `Mt5*Error`, `sqlite3.Error`, `ValueError`, `OSError`, and MT5 client capability errors for history API methods without advancing the throttle (other `AttributeError` / `TypeError` values always propagate). Pass `update_backend` to inject a custom history update callable (same keyword arguments as `update_history`) instead of monkey-patching `mt5cli.sdk.update_history`.
|
||||||
- **Trading session helpers**: use `mt5_trading_session()` for a trading-capable `pdmt5.Mt5TradingClient` that initializes/logs in via `Mt5Config.path` and always shuts down safely. Pair with `detect_position_side()`, `calculate_margin_and_volume()`, and `determine_order_limits()` for generic position and sizing utilities. 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.
|
- **Granularity-keyed rate loading**: `load_rate_series_by_granularity()` builds targets with `build_rate_targets()`, loads them with `load_rate_series_from_sqlite()`, and returns a mapping keyed by `(symbol | None, granularity_name)` such as `("EURUSD", "M1")` to reduce downstream boilerplate.
|
||||||
- **MT5 session helper**: use the `mt5_session()` context manager to attach to (or, when `Mt5Config.path` is set, launch) an MT5 terminal, log in, and yield a connected `MT5Client` that shuts down on exit.
|
- **MT5 session helper**: use the `mt5_session()` context manager to attach to (or, when `Mt5Config.path` is set, launch) an MT5 terminal, log in, and yield a connected `MT5Client` that shuts down on exit.
|
||||||
- **SQLite export helpers**: use `export_dataframe_to_sqlite()` for append mode, optional index export, and post-write deduplication by key columns.
|
- **SQLite export helpers**: use `export_dataframe_to_sqlite()` for append mode, optional index export, and post-write deduplication by key columns.
|
||||||
@@ -317,7 +317,7 @@ finally:
|
|||||||
client.shutdown()
|
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
|
## Development
|
||||||
|
|
||||||
|
|||||||
+95
-60
@@ -8,47 +8,46 @@ downstream app -> mt5cli -> pdmt5 -> MetaTrader 5
|
|||||||
```
|
```
|
||||||
|
|
||||||
Downstream packages should import from the package root (`from mt5cli import
|
Downstream packages should import from the package root (`from mt5cli import
|
||||||
...`) and treat the symbols listed below as the stable SDK contract. CLI
|
...`) and use the public tier sets in `mt5cli.contract` to distinguish API
|
||||||
commands mirror the same behavior but are not importable Python APIs.
|
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
|
## Stable downstream SDK API
|
||||||
|
|
||||||
These names are exported from `mt5cli` and covered by the contract in
|
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`
|
`mt5cli.STABLE_SDK_EXPORTS` (defined in `mt5cli.contract`).
|
||||||
alias for new code.
|
|
||||||
|
|
||||||
### Session lifecycle and configuration
|
### Session lifecycle and configuration
|
||||||
|
|
||||||
| Symbol | Role |
|
| Symbol | Role |
|
||||||
| ----------------------------------------------- | ---------------------------------------------------------------- |
|
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||||
| `MT5Client`, `Mt5CliClient` | Read-only data client with optional `order_check` / `order_send` |
|
| `MT5Client` | Read-only data client with optional `order_check` / `order_send` |
|
||||||
| `build_config` | Build `pdmt5.Mt5Config` from connection fields |
|
| `build_config` | Build `pdmt5.Mt5Config` from connection fields |
|
||||||
| `mt5_session` | Context manager: initialize, login, yield client, shutdown |
|
| `mt5_session` | Context manager: initialize, login, yield client, shutdown |
|
||||||
| `create_trading_client`, `mt5_trading_session` | Trading-capable `pdmt5.Mt5TradingClient` lifecycle |
|
| `create_trading_client`, `mt5_trading_session` | Trading-capable `pdmt5.Mt5TradingClient` lifecycle |
|
||||||
| `AccountSpec` | Generic account group: symbols plus optional credentials |
|
| `AccountSpec` | Generic account group: symbols plus optional credentials |
|
||||||
| `resolve_account_spec`, `resolve_account_specs` | Merge overrides and expand `${ENV_VAR}` placeholders |
|
| `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 |
|
| `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
|
Credential resolution is generic: any environment variable name may appear inside
|
||||||
`${...}`. mt5cli does not hard-code application-specific keys such as
|
`${...}`. mt5cli does not hard-code application-specific keys such as
|
||||||
`mt5_login` or `mt5_exe`.
|
`mt5_login` or `mt5_exe`.
|
||||||
|
|
||||||
### Read-only MT5 data access
|
Pass `allow_whole_dollar_env=True` to `substitute_env_placeholders()`,
|
||||||
|
`resolve_account_spec()`, `resolve_account_specs()`, and `build_config()` to
|
||||||
Module-level helpers open a transient connection per call. Prefer `mt5_session`
|
additionally expand strings whose entire value is a bare `$ENV_NAME` identifier.
|
||||||
or `MT5Client` when making many requests in one process.
|
Partial strings such as `"plan$pass"`, `"abc$ENV"`, or `"$ENV-suffix"` are
|
||||||
|
**never** expanded — only an exact `$IDENTIFIER` whole-string match qualifies.
|
||||||
| Area | Symbols |
|
Default is `False` to preserve backward compatibility.
|
||||||
| -------------------- | ---------------------------------------------------------------------------------------------------- |
|
|
||||||
| 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
|
### Closed-bar rate helpers
|
||||||
|
|
||||||
@@ -56,15 +55,15 @@ MetaTrader 5 returns the still-forming bar as the last row when
|
|||||||
`start_pos=0`. Use these helpers instead of reimplementing bar trimming or
|
`start_pos=0`. Use these helpers instead of reimplementing bar trimming or
|
||||||
timestamp normalization in downstream apps.
|
timestamp normalization in downstream apps.
|
||||||
|
|
||||||
| Symbol | Role |
|
| Symbol | Role |
|
||||||
| ------------------------------------------------ | ------------------------------------------------------------ |
|
| ------------------------------------------------ | ------------------------------------------------------------------------------- |
|
||||||
| `drop_forming_rate_bar` | Remove the last row from chronologically ordered rate data |
|
| `drop_forming_rate_bar` | Remove the last row from chronologically ordered rate data |
|
||||||
| `fetch_latest_closed_rates` | Single connected client: fetch `count + 1`, drop forming bar |
|
| `fetch_latest_closed_rates` | Single connected client: fetch `count + 1`, drop forming bar |
|
||||||
| `fetch_latest_closed_rates_for_trading_client` | Closed bars from an active `Mt5TradingClient` session |
|
| `fetch_latest_closed_rates_for_trading_client` | Closed bars from an active `Mt5TradingClient` session; returns RangeIndex |
|
||||||
| `collect_latest_closed_rates_for_accounts` | Multi-account closed bars with optional retry wrapper |
|
| `fetch_latest_closed_rates_indexed` | Same as above but returns a UTC `DatetimeIndex` named `"time"` (no time column) |
|
||||||
| `collect_latest_closed_rates_by_granularity` | Same data keyed by `(symbol, granularity_name)` |
|
| `collect_latest_closed_rates_for_accounts` | Multi-account closed bars with optional retry wrapper |
|
||||||
| `collect_latest_rates_for_accounts` | Latest bars including the forming bar when `start_pos=0` |
|
| `collect_latest_closed_rates_by_granularity` | Same data keyed by `(symbol, granularity_name)` |
|
||||||
| `collect_latest_rates_for_accounts_with_retries` | Bounded exponential backoff for transient MT5 errors |
|
| `collect_latest_rates_for_accounts_with_retries` | Bounded exponential backoff for transient MT5 errors |
|
||||||
|
|
||||||
### SQLite history collection and rate loading
|
### SQLite history collection and rate loading
|
||||||
|
|
||||||
@@ -91,18 +90,24 @@ diagrams.
|
|||||||
These helpers implement broker-facing calculations only. They do not encode
|
These helpers implement broker-facing calculations only. They do not encode
|
||||||
strategy entries, exits, Kelly sizing, or signal logic.
|
strategy entries, exits, Kelly sizing, or signal logic.
|
||||||
|
|
||||||
| Symbol | Role |
|
| Symbol | Role |
|
||||||
| -------------------------------------------------------------------------------------------------- | --------------------------------------------- |
|
| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- |
|
||||||
| `get_account_snapshot`, `get_symbol_snapshot`, `get_tick_snapshot`, `get_positions_frame` | Normalized account/symbol/tick/position views |
|
| `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 |
|
| `extract_tick_price` | Positive finite bid/ask extraction from tick mappings |
|
||||||
| `calculate_spread_ratio` | Relative bid-ask spread |
|
| `detect_position_side` | Net long / short / flat from open positions |
|
||||||
| `calculate_margin_and_volume`, `calculate_volume_by_margin`, `calculate_new_position_margin_ratio` | Margin budget and volume sizing |
|
| `calculate_spread_ratio` | Relative bid-ask spread |
|
||||||
| `normalize_order_volume`, `estimate_order_margin`, `calculate_positions_margin` | Broker volume normalization and margin totals |
|
| `calculate_margin_and_volume`, `calculate_volume_by_margin`, `calculate_new_position_margin_ratio` | Margin budget and volume sizing |
|
||||||
| `determine_order_limits` | SL/TP price levels from ratios |
|
| `normalize_order_volume`, `estimate_order_margin`, `calculate_positions_margin` | Broker volume normalization and margin totals |
|
||||||
| `ensure_symbol_selected` | Select/verify Market Watch visibility |
|
| `calculate_positions_margin_by_symbol` | Per-symbol margin map (resilient, first-seen order) |
|
||||||
| `place_market_order`, `close_open_positions`, `update_sltp_for_open_positions` | Order execution helpers (`dry_run` supported) |
|
| `calculate_positions_margin_safe` | Summed total margin across symbols (failed symbols skipped) |
|
||||||
| `MarginVolume`, `OrderLimits`, `OrderExecutionResult` | Typed return contracts for order helpers |
|
| `calculate_projected_margin_ratio` | Estimated symbol margin/equity after optional new exposure |
|
||||||
| `OrderSide`, `OrderFillingMode`, `OrderTimeMode`, `PositionSide`, `ExecutionStatus` | Typed enums for order helpers |
|
| `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.
|
`MT5Client.order_send()` and CLI `order-send --yes` are live execution paths.
|
||||||
|
|
||||||
@@ -125,13 +130,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 |
|
| `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 |
|
| `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
|
These names remain importable from `mt5cli` and are covered by
|
||||||
`DataKind`, `Dataset`, `normalize_dataframe`, `export_dataframe`,
|
`SECONDARY_PUBLIC_EXPORTS`, but they are oriented toward CLI/export/schema
|
||||||
`parse_timeframe`, `TIMEFRAME_MAP`). These are public but oriented toward export
|
integrations, parsing, and lower-level MT5 access rather than the stable core
|
||||||
pipelines and advanced integration. Prefer the stable symbols above for core
|
SDK surface. Prefer the stable symbols above for downstream infrastructure
|
||||||
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
|
## CLI commands
|
||||||
|
|
||||||
@@ -180,7 +215,7 @@ their own adapter layer.
|
|||||||
|
|
||||||
## Contract verification
|
## Contract verification
|
||||||
|
|
||||||
`tests/test_contracts.py` asserts that every name in `STABLE_SDK_EXPORTS` is
|
`tests/test_contracts.py` asserts that every name in the stable and secondary
|
||||||
importable from `mt5cli`, documents key closed-bar, rate-view, SQLite loading,
|
tier sets is importable from `mt5cli`, documents key closed-bar, rate-view,
|
||||||
account-resolution, and trading-session behaviors, and keeps the contract set
|
SQLite loading, account-resolution, and trading-session behaviors, and keeps the
|
||||||
aligned with `__all__`.
|
tier sets aligned with `__all__`.
|
||||||
|
|||||||
+25
-3
@@ -31,7 +31,7 @@ rates = collect_latest_rates_for_accounts_with_retries(
|
|||||||
### Latest closed rate bars
|
### Latest closed rate bars
|
||||||
|
|
||||||
MetaTrader 5 `start_pos=0` includes the still-forming current bar as the last
|
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
|
`fetch_latest_closed_rates_for_trading_client()` from an active
|
||||||
`Mt5TradingClient` session. Multi-account helpers fetch `count + 1` bars, drop
|
`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
|
that row with `drop_forming_rate_bar()`, and validate each series is non-empty. Returned frames are ordered
|
||||||
@@ -86,6 +86,28 @@ resolved = resolve_account_specs(accounts, server="Broker-Demo")
|
|||||||
# resolved[0].login == "12345", resolved[0].server == "Broker-Demo"
|
# resolved[0].login == "12345", resolved[0].server == "Broker-Demo"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Pass `allow_whole_dollar_env=True` to also expand strings whose **entire value**
|
||||||
|
is a bare `$ENV_NAME` identifier (no braces). This opt-in covers
|
||||||
|
`substitute_env_placeholders()`, `resolve_account_spec()`,
|
||||||
|
`resolve_account_specs()`, and `build_config()`. Note: `build_config` cannot
|
||||||
|
expand `login` because that parameter is `int | None`; use
|
||||||
|
`resolve_account_spec` for a string `login` placeholder. Partial strings such as
|
||||||
|
`"plan$pass"`, `"abc$ENV"`, or `"$ENV-suffix"` are never expanded — only an
|
||||||
|
exact `$IDENTIFIER` whole-string match qualifies. The default is `False` to
|
||||||
|
preserve backward compatibility.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
|
||||||
|
from mt5cli import AccountSpec, resolve_account_specs
|
||||||
|
|
||||||
|
os.environ["MT5_PASSWORD"] = "secret"
|
||||||
|
accounts = [AccountSpec(symbols=["EURUSD"], password="$MT5_PASSWORD")]
|
||||||
|
|
||||||
|
resolved = resolve_account_specs(accounts, allow_whole_dollar_env=True)
|
||||||
|
# resolved[0].password == "secret"
|
||||||
|
```
|
||||||
|
|
||||||
### Throttled incremental history updates
|
### Throttled incremental history updates
|
||||||
|
|
||||||
`ThrottledHistoryUpdater` wraps `update_history()` with a minimum interval
|
`ThrottledHistoryUpdater` wraps `update_history()` with a minimum interval
|
||||||
@@ -148,5 +170,5 @@ resulting `ValueError` is suppressed along with other recoverable errors.
|
|||||||
## Trading-capable sessions
|
## Trading-capable sessions
|
||||||
|
|
||||||
For order placement and trading calculations, use the dedicated
|
For order placement and trading calculations, use the dedicated
|
||||||
[Trading module](trading.md). The read-only `Mt5CliClient` and `mt5_session()`
|
[Trading module](trading.md). Use `mt5_session()` / `MT5Client` for read-only
|
||||||
helpers in this module are unchanged.
|
collection.
|
||||||
|
|||||||
+21
-12
@@ -31,7 +31,7 @@ finally:
|
|||||||
`login` accepts `int`, numeric `str`, or an empty string; empty strings are
|
`login` accepts `int`, numeric `str`, or an empty string; empty strings are
|
||||||
treated as unset. `path`, `password`, `server`, and `timeout` are forwarded to
|
treated as unset. `path`, `password`, `server`, and `timeout` are forwarded to
|
||||||
`pdmt5.Mt5Config`, and omitted `timeout` values keep the lower-level default.
|
`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
|
## State and order helpers
|
||||||
|
|
||||||
@@ -48,6 +48,7 @@ from mt5cli import (
|
|||||||
determine_order_limits,
|
determine_order_limits,
|
||||||
estimate_order_margin,
|
estimate_order_margin,
|
||||||
fetch_latest_closed_rates_for_trading_client,
|
fetch_latest_closed_rates_for_trading_client,
|
||||||
|
fetch_latest_closed_rates_indexed,
|
||||||
get_account_snapshot,
|
get_account_snapshot,
|
||||||
get_positions_frame,
|
get_positions_frame,
|
||||||
get_symbol_snapshot,
|
get_symbol_snapshot,
|
||||||
@@ -78,6 +79,14 @@ closed_bars = fetch_latest_closed_rates_for_trading_client(
|
|||||||
granularity="M1",
|
granularity="M1",
|
||||||
count=100,
|
count=100,
|
||||||
)
|
)
|
||||||
|
# Or fetch with a UTC DatetimeIndex instead of a "time" column:
|
||||||
|
indexed_bars = fetch_latest_closed_rates_indexed(
|
||||||
|
client,
|
||||||
|
symbol="EURUSD",
|
||||||
|
granularity="M1",
|
||||||
|
count=100,
|
||||||
|
)
|
||||||
|
# indexed_bars.index is a UTC-aware DatetimeIndex named "time"
|
||||||
sizing = calculate_margin_and_volume(
|
sizing = calculate_margin_and_volume(
|
||||||
client,
|
client,
|
||||||
"EURUSD",
|
"EURUSD",
|
||||||
@@ -174,17 +183,17 @@ through the stable package root without embedding entry/exit policy.
|
|||||||
|
|
||||||
## Migration from application-local helpers
|
## Migration from application-local helpers
|
||||||
|
|
||||||
| Application-local concern | mt5cli replacement |
|
| Application-local concern | mt5cli replacement |
|
||||||
| -------------------------------------------------------- | ----------------------------------------------- |
|
| -------------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||||
| Manual terminal spawn/kill around trading code | `mt5_trading_session()` |
|
| Manual terminal spawn/kill around trading code | `mt5_trading_session()` |
|
||||||
| Local position-side detection | `detect_position_side()` |
|
| Local position-side detection | `detect_position_side()` |
|
||||||
| Local margin/volume sizing | `calculate_margin_and_volume()` |
|
| Local margin/volume sizing | `calculate_margin_and_volume()` |
|
||||||
| Local broker volume step normalization | `normalize_order_volume()` |
|
| Local broker volume step normalization | `normalize_order_volume()` |
|
||||||
| Local order or position margin estimation | `estimate_order_margin()`, `calculate_positions_margin()` |
|
| Local order or position margin estimation | `estimate_order_margin()`, `calculate_positions_margin()` |
|
||||||
| Local closed-bar fetch from a trading session | `fetch_latest_closed_rates_for_trading_client()` |
|
| Local closed-bar fetch from a trading session | `fetch_latest_closed_rates_for_trading_client()`, `fetch_latest_closed_rates_indexed()` |
|
||||||
| Local SL/TP price derivation | `determine_order_limits()` |
|
| Local SL/TP price derivation | `determine_order_limits()` |
|
||||||
| Throttled SQLite history loop with ad-hoc error handling | `ThrottledHistoryUpdater(suppress_errors=True)` |
|
| 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
|
`mt5_trading_session()` only where order placement or trading calculations are
|
||||||
required.
|
required.
|
||||||
|
|||||||
+1
-1
@@ -29,7 +29,7 @@ pip install mt5cli
|
|||||||
|
|
||||||
## Python API for downstream packages
|
## 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
|
```python
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|||||||
+23
-3
@@ -11,7 +11,11 @@ from importlib.metadata import version
|
|||||||
from pdmt5 import Mt5Config, Mt5RuntimeError, Mt5TradingClient, Mt5TradingError
|
from pdmt5 import Mt5Config, Mt5RuntimeError, Mt5TradingClient, Mt5TradingError
|
||||||
|
|
||||||
from .client import MT5Client, build_config, mt5_session
|
from .client import MT5Client, build_config, mt5_session
|
||||||
from .contract import STABLE_SDK_EXPORTS
|
from .contract import (
|
||||||
|
PUBLIC_EXPORT_TIERS,
|
||||||
|
SECONDARY_PUBLIC_EXPORTS,
|
||||||
|
STABLE_SDK_EXPORTS,
|
||||||
|
)
|
||||||
from .converters import (
|
from .converters import (
|
||||||
ensure_utc,
|
ensure_utc,
|
||||||
granularity_name,
|
granularity_name,
|
||||||
@@ -59,7 +63,6 @@ from .schemas import (
|
|||||||
)
|
)
|
||||||
from .sdk import (
|
from .sdk import (
|
||||||
AccountSpec,
|
AccountSpec,
|
||||||
Mt5CliClient,
|
|
||||||
ThrottledHistoryUpdater,
|
ThrottledHistoryUpdater,
|
||||||
account_info,
|
account_info,
|
||||||
collect_history,
|
collect_history,
|
||||||
@@ -119,7 +122,12 @@ from .trading import (
|
|||||||
calculate_margin_and_volume,
|
calculate_margin_and_volume,
|
||||||
calculate_new_position_margin_ratio,
|
calculate_new_position_margin_ratio,
|
||||||
calculate_positions_margin,
|
calculate_positions_margin,
|
||||||
|
calculate_positions_margin_by_symbol,
|
||||||
|
calculate_positions_margin_safe,
|
||||||
|
calculate_projected_margin_ratio,
|
||||||
calculate_spread_ratio,
|
calculate_spread_ratio,
|
||||||
|
calculate_symbol_group_margin_ratio,
|
||||||
|
calculate_trailing_stop_updates,
|
||||||
calculate_volume_by_margin,
|
calculate_volume_by_margin,
|
||||||
close_open_positions,
|
close_open_positions,
|
||||||
create_trading_client,
|
create_trading_client,
|
||||||
@@ -127,7 +135,9 @@ from .trading import (
|
|||||||
determine_order_limits,
|
determine_order_limits,
|
||||||
ensure_symbol_selected,
|
ensure_symbol_selected,
|
||||||
estimate_order_margin,
|
estimate_order_margin,
|
||||||
|
extract_tick_price,
|
||||||
fetch_latest_closed_rates_for_trading_client,
|
fetch_latest_closed_rates_for_trading_client,
|
||||||
|
fetch_latest_closed_rates_indexed,
|
||||||
get_account_snapshot,
|
get_account_snapshot,
|
||||||
get_positions_frame,
|
get_positions_frame,
|
||||||
get_symbol_snapshot,
|
get_symbol_snapshot,
|
||||||
@@ -136,6 +146,7 @@ from .trading import (
|
|||||||
normalize_order_volume,
|
normalize_order_volume,
|
||||||
place_market_order,
|
place_market_order,
|
||||||
update_sltp_for_open_positions,
|
update_sltp_for_open_positions,
|
||||||
|
update_trailing_stop_loss_for_open_positions,
|
||||||
)
|
)
|
||||||
from .utils import (
|
from .utils import (
|
||||||
TICK_FLAG_MAP,
|
TICK_FLAG_MAP,
|
||||||
@@ -151,7 +162,9 @@ __all__ = [
|
|||||||
"DEDUP_KEYS",
|
"DEDUP_KEYS",
|
||||||
"KNOWN_MT5_TIME_COLUMNS",
|
"KNOWN_MT5_TIME_COLUMNS",
|
||||||
"POSITION_COLUMNS",
|
"POSITION_COLUMNS",
|
||||||
|
"PUBLIC_EXPORT_TIERS",
|
||||||
"REQUIRED_COLUMNS",
|
"REQUIRED_COLUMNS",
|
||||||
|
"SECONDARY_PUBLIC_EXPORTS",
|
||||||
"STABLE_SDK_EXPORTS",
|
"STABLE_SDK_EXPORTS",
|
||||||
"TICK_FLAG_MAP",
|
"TICK_FLAG_MAP",
|
||||||
"TIMEFRAME_MAP",
|
"TIMEFRAME_MAP",
|
||||||
@@ -163,7 +176,6 @@ __all__ = [
|
|||||||
"IfExists",
|
"IfExists",
|
||||||
"MT5Client",
|
"MT5Client",
|
||||||
"MarginVolume",
|
"MarginVolume",
|
||||||
"Mt5CliClient",
|
|
||||||
"Mt5CliError",
|
"Mt5CliError",
|
||||||
"Mt5Config",
|
"Mt5Config",
|
||||||
"Mt5ConnectionError",
|
"Mt5ConnectionError",
|
||||||
@@ -187,7 +199,12 @@ __all__ = [
|
|||||||
"calculate_margin_and_volume",
|
"calculate_margin_and_volume",
|
||||||
"calculate_new_position_margin_ratio",
|
"calculate_new_position_margin_ratio",
|
||||||
"calculate_positions_margin",
|
"calculate_positions_margin",
|
||||||
|
"calculate_positions_margin_by_symbol",
|
||||||
|
"calculate_positions_margin_safe",
|
||||||
|
"calculate_projected_margin_ratio",
|
||||||
"calculate_spread_ratio",
|
"calculate_spread_ratio",
|
||||||
|
"calculate_symbol_group_margin_ratio",
|
||||||
|
"calculate_trailing_stop_updates",
|
||||||
"calculate_volume_by_margin",
|
"calculate_volume_by_margin",
|
||||||
"call_with_normalized_errors",
|
"call_with_normalized_errors",
|
||||||
"close_open_positions",
|
"close_open_positions",
|
||||||
@@ -212,8 +229,10 @@ __all__ = [
|
|||||||
"estimate_order_margin",
|
"estimate_order_margin",
|
||||||
"export_dataframe",
|
"export_dataframe",
|
||||||
"export_dataframe_to_sqlite",
|
"export_dataframe_to_sqlite",
|
||||||
|
"extract_tick_price",
|
||||||
"fetch_latest_closed_rates",
|
"fetch_latest_closed_rates",
|
||||||
"fetch_latest_closed_rates_for_trading_client",
|
"fetch_latest_closed_rates_for_trading_client",
|
||||||
|
"fetch_latest_closed_rates_indexed",
|
||||||
"get_account_snapshot",
|
"get_account_snapshot",
|
||||||
"get_positions_frame",
|
"get_positions_frame",
|
||||||
"get_symbol_snapshot",
|
"get_symbol_snapshot",
|
||||||
@@ -269,5 +288,6 @@ __all__ = [
|
|||||||
"update_history",
|
"update_history",
|
||||||
"update_history_with_config",
|
"update_history_with_config",
|
||||||
"update_sltp_for_open_positions",
|
"update_sltp_for_open_positions",
|
||||||
|
"update_trailing_stop_loss_for_open_positions",
|
||||||
"validate_schema",
|
"validate_schema",
|
||||||
]
|
]
|
||||||
|
|||||||
+1
-3
@@ -24,9 +24,7 @@ class MT5Client(Mt5CliClient):
|
|||||||
"""Public client for generic MT5 data access and order primitives.
|
"""Public client for generic MT5 data access and order primitives.
|
||||||
|
|
||||||
Extends the read-only SDK client with optional order check/send helpers and
|
Extends the read-only SDK client with optional order check/send helpers and
|
||||||
exposes the same connection lifecycle as :class:`~mt5cli.sdk.Mt5CliClient`.
|
exposes the same connection lifecycle as :func:`mt5_session`.
|
||||||
Downstream applications such as private trading packages should prefer this
|
|
||||||
type over the legacy ``Mt5CliClient`` name.
|
|
||||||
|
|
||||||
mt5cli intentionally exposes minimal execution primitives only. Trading
|
mt5cli intentionally exposes minimal execution primitives only. Trading
|
||||||
decisions, signals, strategies, backtests, and optimization remain the
|
decisions, signals, strategies, backtests, and optimization remain the
|
||||||
|
|||||||
+72
-29
@@ -1,11 +1,10 @@
|
|||||||
"""Stable downstream SDK export names for mt5cli."""
|
"""Downstream SDK export tiers for mt5cli."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
||||||
"AccountSpec",
|
"AccountSpec",
|
||||||
"MT5Client",
|
"MT5Client",
|
||||||
"Mt5CliClient",
|
|
||||||
"Mt5CliError",
|
"Mt5CliError",
|
||||||
"Mt5Config",
|
"Mt5Config",
|
||||||
"Mt5ConnectionError",
|
"Mt5ConnectionError",
|
||||||
@@ -24,65 +23,49 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
|||||||
"OrderLimits",
|
"OrderLimits",
|
||||||
"RateTarget",
|
"RateTarget",
|
||||||
"ThrottledHistoryUpdater",
|
"ThrottledHistoryUpdater",
|
||||||
"account_info",
|
|
||||||
"build_config",
|
"build_config",
|
||||||
"build_rate_targets",
|
"build_rate_targets",
|
||||||
"build_rate_view_name",
|
"build_rate_view_name",
|
||||||
"calculate_margin_and_volume",
|
"calculate_margin_and_volume",
|
||||||
"calculate_new_position_margin_ratio",
|
"calculate_new_position_margin_ratio",
|
||||||
|
"calculate_projected_margin_ratio",
|
||||||
"calculate_positions_margin",
|
"calculate_positions_margin",
|
||||||
|
"calculate_positions_margin_by_symbol",
|
||||||
|
"calculate_positions_margin_safe",
|
||||||
"calculate_spread_ratio",
|
"calculate_spread_ratio",
|
||||||
|
"calculate_symbol_group_margin_ratio",
|
||||||
|
"calculate_trailing_stop_updates",
|
||||||
"calculate_volume_by_margin",
|
"calculate_volume_by_margin",
|
||||||
"call_with_normalized_errors",
|
"call_with_normalized_errors",
|
||||||
"close_open_positions",
|
"close_open_positions",
|
||||||
"collect_history",
|
"collect_history",
|
||||||
"collect_latest_closed_rates_by_granularity",
|
"collect_latest_closed_rates_by_granularity",
|
||||||
"collect_latest_closed_rates_for_accounts",
|
"collect_latest_closed_rates_for_accounts",
|
||||||
"collect_latest_rates",
|
|
||||||
"collect_latest_rates_for_accounts",
|
|
||||||
"collect_latest_rates_for_accounts_with_retries",
|
"collect_latest_rates_for_accounts_with_retries",
|
||||||
"copy_rates_from",
|
|
||||||
"copy_rates_from_pos",
|
|
||||||
"copy_rates_range",
|
|
||||||
"copy_ticks_from",
|
|
||||||
"copy_ticks_range",
|
|
||||||
"create_trading_client",
|
"create_trading_client",
|
||||||
"detect_position_side",
|
"detect_position_side",
|
||||||
"determine_order_limits",
|
"determine_order_limits",
|
||||||
"drop_forming_rate_bar",
|
"drop_forming_rate_bar",
|
||||||
"ensure_symbol_selected",
|
"ensure_symbol_selected",
|
||||||
"estimate_order_margin",
|
"estimate_order_margin",
|
||||||
"export_dataframe",
|
"extract_tick_price",
|
||||||
"export_dataframe_to_sqlite",
|
|
||||||
"fetch_latest_closed_rates",
|
"fetch_latest_closed_rates",
|
||||||
"fetch_latest_closed_rates_for_trading_client",
|
"fetch_latest_closed_rates_for_trading_client",
|
||||||
|
"fetch_latest_closed_rates_indexed",
|
||||||
"get_account_snapshot",
|
"get_account_snapshot",
|
||||||
"get_positions_frame",
|
"get_positions_frame",
|
||||||
"get_symbol_snapshot",
|
"get_symbol_snapshot",
|
||||||
"get_tick_snapshot",
|
"get_tick_snapshot",
|
||||||
"history_deals",
|
|
||||||
"history_orders",
|
|
||||||
"is_recoverable_mt5_error",
|
"is_recoverable_mt5_error",
|
||||||
"last_error",
|
|
||||||
"latest_rates",
|
|
||||||
"load_rate_data",
|
"load_rate_data",
|
||||||
"load_rate_data_from_connection",
|
"load_rate_data_from_connection",
|
||||||
"load_rate_series_by_granularity",
|
"load_rate_series_by_granularity",
|
||||||
"load_rate_series_from_sqlite",
|
"load_rate_series_from_sqlite",
|
||||||
"market_book",
|
|
||||||
"minimum_margins",
|
|
||||||
"mt5_session",
|
"mt5_session",
|
||||||
"mt5_summary",
|
|
||||||
"mt5_summary_as_df",
|
|
||||||
"mt5_trading_session",
|
"mt5_trading_session",
|
||||||
"mt5_version",
|
|
||||||
"normalize_mt5_exception",
|
"normalize_mt5_exception",
|
||||||
"normalize_order_volume",
|
"normalize_order_volume",
|
||||||
"orders",
|
|
||||||
"place_market_order",
|
"place_market_order",
|
||||||
"positions",
|
|
||||||
"recent_history_deals",
|
|
||||||
"recent_ticks",
|
|
||||||
"resolve_account_spec",
|
"resolve_account_spec",
|
||||||
"resolve_account_specs",
|
"resolve_account_specs",
|
||||||
"resolve_history_datasets",
|
"resolve_history_datasets",
|
||||||
@@ -93,13 +76,73 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
|||||||
"resolve_rate_view_name",
|
"resolve_rate_view_name",
|
||||||
"resolve_rate_view_names",
|
"resolve_rate_view_names",
|
||||||
"substitute_env_placeholders",
|
"substitute_env_placeholders",
|
||||||
|
"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",
|
||||||
"symbol_info_tick",
|
"symbol_info_tick",
|
||||||
"symbols",
|
"symbols",
|
||||||
"terminal_info",
|
"terminal_info",
|
||||||
"update_history",
|
"validate_schema",
|
||||||
"update_history_with_config",
|
|
||||||
"update_sltp_for_open_positions",
|
|
||||||
})
|
})
|
||||||
|
|
||||||
__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",
|
||||||
|
]
|
||||||
|
|||||||
+77
-9
@@ -309,12 +309,33 @@ def build_config(
|
|||||||
password: str | None = None,
|
password: str | None = None,
|
||||||
server: str | None = None,
|
server: str | None = None,
|
||||||
timeout: int | None = None,
|
timeout: int | None = None,
|
||||||
|
allow_whole_dollar_env: bool = False,
|
||||||
) -> Mt5Config:
|
) -> Mt5Config:
|
||||||
"""Build an ``Mt5Config`` from optional connection parameters.
|
"""Build an ``Mt5Config`` from optional connection parameters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Optional terminal executable path.
|
||||||
|
login: Optional trading account login.
|
||||||
|
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.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Configured ``Mt5Config`` instance.
|
Configured ``Mt5Config`` instance.
|
||||||
"""
|
"""
|
||||||
|
if allow_whole_dollar_env:
|
||||||
|
if path is not None:
|
||||||
|
path = substitute_env_placeholders(path, allow_whole_dollar_env=True)
|
||||||
|
if password is not None:
|
||||||
|
password = substitute_env_placeholders(
|
||||||
|
password, allow_whole_dollar_env=True
|
||||||
|
)
|
||||||
|
if server is not None:
|
||||||
|
server = substitute_env_placeholders(server, allow_whole_dollar_env=True)
|
||||||
return Mt5Config(
|
return Mt5Config(
|
||||||
path=path,
|
path=path,
|
||||||
login=login,
|
login=login,
|
||||||
@@ -1376,13 +1397,22 @@ class AccountSpec:
|
|||||||
|
|
||||||
|
|
||||||
_ENV_PLACEHOLDER_PATTERN = re.compile(r"\$\{(?P<name>[A-Za-z_][A-Za-z0-9_]*)\}")
|
_ENV_PLACEHOLDER_PATTERN = re.compile(r"\$\{(?P<name>[A-Za-z_][A-Za-z0-9_]*)\}")
|
||||||
|
_WHOLE_DOLLAR_PATTERN = re.compile(r"^\$(?P<name>[A-Za-z_][A-Za-z0-9_]*)$")
|
||||||
|
|
||||||
|
|
||||||
def substitute_env_placeholders(value: str) -> str:
|
def substitute_env_placeholders(
|
||||||
|
value: str,
|
||||||
|
*,
|
||||||
|
allow_whole_dollar_env: bool = False,
|
||||||
|
) -> str:
|
||||||
"""Replace ``${ENV_VAR}`` placeholders in a string with environment values.
|
"""Replace ``${ENV_VAR}`` placeholders in a string with environment values.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
value: String that may contain one or more ``${ENV_VAR}`` placeholders.
|
value: String that may contain one or more ``${ENV_VAR}`` placeholders.
|
||||||
|
allow_whole_dollar_env: When ``True``, a string that is exactly
|
||||||
|
``$ENV_NAME`` (the whole value and nothing else) is also expanded
|
||||||
|
from the environment. Partial occurrences such as ``"plan$pass"``
|
||||||
|
or ``"$ENV-suffix"`` are left unchanged.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The string with every placeholder replaced by its environment value.
|
The string with every placeholder replaced by its environment value.
|
||||||
@@ -1390,6 +1420,14 @@ def substitute_env_placeholders(value: str) -> str:
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: If a referenced environment variable is not set.
|
ValueError: If a referenced environment variable is not set.
|
||||||
"""
|
"""
|
||||||
|
if allow_whole_dollar_env:
|
||||||
|
m = _WHOLE_DOLLAR_PATTERN.match(value)
|
||||||
|
if m:
|
||||||
|
name = m.group("name")
|
||||||
|
if name not in os.environ:
|
||||||
|
msg = f"Environment variable {name!r} is not set."
|
||||||
|
raise ValueError(msg)
|
||||||
|
return os.environ[name]
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
last_end = 0
|
last_end = 0
|
||||||
for match in _ENV_PLACEHOLDER_PATTERN.finditer(value):
|
for match in _ENV_PLACEHOLDER_PATTERN.finditer(value):
|
||||||
@@ -1404,7 +1442,12 @@ def substitute_env_placeholders(value: str) -> str:
|
|||||||
return "".join(parts)
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_field(override: str | None, account_value: str | None) -> str | None:
|
def _resolve_field(
|
||||||
|
override: str | None,
|
||||||
|
account_value: str | None,
|
||||||
|
*,
|
||||||
|
allow_whole_dollar_env: bool = False,
|
||||||
|
) -> str | None:
|
||||||
"""Resolve a string field from an override or account value with env subst.
|
"""Resolve a string field from an override or account value with env subst.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -1414,12 +1457,16 @@ def _resolve_field(override: str | None, account_value: str | None) -> str | Non
|
|||||||
value = override if override is not None else account_value
|
value = override if override is not None else account_value
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
return substitute_env_placeholders(value)
|
return substitute_env_placeholders(
|
||||||
|
value, allow_whole_dollar_env=allow_whole_dollar_env
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_login(
|
def _resolve_login(
|
||||||
override: int | str | None,
|
override: int | str | None,
|
||||||
account_login: int | str | None,
|
account_login: int | str | None,
|
||||||
|
*,
|
||||||
|
allow_whole_dollar_env: bool = False,
|
||||||
) -> int | str | None:
|
) -> int | str | None:
|
||||||
"""Resolve a login from an override or account value with env substitution.
|
"""Resolve a login from an override or account value with env substitution.
|
||||||
|
|
||||||
@@ -1431,10 +1478,14 @@ def _resolve_login(
|
|||||||
if override is not None:
|
if override is not None:
|
||||||
if isinstance(override, int):
|
if isinstance(override, int):
|
||||||
return override
|
return override
|
||||||
return substitute_env_placeholders(override)
|
return substitute_env_placeholders(
|
||||||
|
override, allow_whole_dollar_env=allow_whole_dollar_env
|
||||||
|
)
|
||||||
if account_login is None or isinstance(account_login, int):
|
if account_login is None or isinstance(account_login, int):
|
||||||
return account_login
|
return account_login
|
||||||
return substitute_env_placeholders(account_login)
|
return substitute_env_placeholders(
|
||||||
|
account_login, allow_whole_dollar_env=allow_whole_dollar_env
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def resolve_account_spec(
|
def resolve_account_spec(
|
||||||
@@ -1445,6 +1496,7 @@ def resolve_account_spec(
|
|||||||
server: str | None = None,
|
server: str | None = None,
|
||||||
path: str | None = None,
|
path: str | None = None,
|
||||||
timeout: int | None = None,
|
timeout: int | None = None,
|
||||||
|
allow_whole_dollar_env: bool = False,
|
||||||
) -> AccountSpec:
|
) -> AccountSpec:
|
||||||
"""Resolve an account's credentials from overrides and ``${ENV_VAR}`` values.
|
"""Resolve an account's credentials from overrides and ``${ENV_VAR}`` values.
|
||||||
|
|
||||||
@@ -1460,6 +1512,9 @@ def resolve_account_spec(
|
|||||||
server: Optional explicit server override.
|
server: Optional explicit server override.
|
||||||
path: Optional explicit terminal path override.
|
path: Optional explicit terminal path override.
|
||||||
timeout: Optional explicit connection timeout override.
|
timeout: Optional explicit connection timeout override.
|
||||||
|
allow_whole_dollar_env: When ``True``, string fields that are exactly
|
||||||
|
``$ENV_NAME`` are also expanded from the environment. Default
|
||||||
|
``False`` preserves existing behavior.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A new :class:`AccountSpec` with resolved credentials and the original
|
A new :class:`AccountSpec` with resolved credentials and the original
|
||||||
@@ -1469,10 +1524,18 @@ def resolve_account_spec(
|
|||||||
"""
|
"""
|
||||||
return AccountSpec(
|
return AccountSpec(
|
||||||
symbols=account.symbols,
|
symbols=account.symbols,
|
||||||
login=_resolve_login(login, account.login),
|
login=_resolve_login(
|
||||||
password=_resolve_field(password, account.password),
|
login, account.login, allow_whole_dollar_env=allow_whole_dollar_env
|
||||||
server=_resolve_field(server, account.server),
|
),
|
||||||
path=_resolve_field(path, account.path),
|
password=_resolve_field(
|
||||||
|
password, account.password, allow_whole_dollar_env=allow_whole_dollar_env
|
||||||
|
),
|
||||||
|
server=_resolve_field(
|
||||||
|
server, account.server, allow_whole_dollar_env=allow_whole_dollar_env
|
||||||
|
),
|
||||||
|
path=_resolve_field(
|
||||||
|
path, account.path, allow_whole_dollar_env=allow_whole_dollar_env
|
||||||
|
),
|
||||||
timeout=timeout if timeout is not None else account.timeout,
|
timeout=timeout if timeout is not None else account.timeout,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1485,6 +1548,7 @@ def resolve_account_specs(
|
|||||||
server: str | None = None,
|
server: str | None = None,
|
||||||
path: str | None = None,
|
path: str | None = None,
|
||||||
timeout: int | None = None,
|
timeout: int | None = None,
|
||||||
|
allow_whole_dollar_env: bool = False,
|
||||||
) -> list[AccountSpec]:
|
) -> list[AccountSpec]:
|
||||||
"""Resolve credentials for multiple accounts.
|
"""Resolve credentials for multiple accounts.
|
||||||
|
|
||||||
@@ -1498,6 +1562,9 @@ def resolve_account_specs(
|
|||||||
server: Optional explicit server override applied to each account.
|
server: Optional explicit server override applied to each account.
|
||||||
path: Optional explicit terminal path override applied to each account.
|
path: Optional explicit terminal path override applied to each account.
|
||||||
timeout: Optional explicit timeout override applied to each account.
|
timeout: Optional explicit timeout override applied to each account.
|
||||||
|
allow_whole_dollar_env: When ``True``, string fields that are exactly
|
||||||
|
``$ENV_NAME`` are also expanded from the environment. Default
|
||||||
|
``False`` preserves existing behavior.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Resolved account specifications in the original order. Raises
|
Resolved account specifications in the original order. Raises
|
||||||
@@ -1512,6 +1579,7 @@ def resolve_account_specs(
|
|||||||
server=server,
|
server=server,
|
||||||
path=path,
|
path=path,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
|
allow_whole_dollar_env=allow_whole_dollar_env,
|
||||||
)
|
)
|
||||||
for account in accounts
|
for account in accounts
|
||||||
]
|
]
|
||||||
|
|||||||
+472
-71
@@ -2,21 +2,23 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from math import floor, isfinite
|
from math import floor, isfinite
|
||||||
from numbers import Integral
|
from numbers import Integral, Real
|
||||||
from typing import TYPE_CHECKING, Literal, TypedDict, cast
|
from typing import TYPE_CHECKING, Literal, TypedDict, cast
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from pdmt5 import Mt5Config, Mt5TradingClient, Mt5TradingError
|
from pdmt5 import Mt5Config, Mt5RuntimeError, Mt5TradingClient, Mt5TradingError
|
||||||
|
|
||||||
from .history import drop_forming_rate_bar
|
from .history import drop_forming_rate_bar
|
||||||
from .sdk import build_config
|
from .sdk import build_config
|
||||||
from .utils import coerce_login as _coerce_login
|
from .utils import coerce_login as _coerce_login
|
||||||
from .utils import parse_timeframe
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Iterator, Sequence
|
from collections.abc import Iterator, Mapping, Sequence
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
PositionSide = Literal["long", "short"]
|
PositionSide = Literal["long", "short"]
|
||||||
OrderSide = Literal["BUY", "SELL"]
|
OrderSide = Literal["BUY", "SELL"]
|
||||||
@@ -128,7 +130,12 @@ __all__ = [
|
|||||||
"calculate_margin_and_volume",
|
"calculate_margin_and_volume",
|
||||||
"calculate_new_position_margin_ratio",
|
"calculate_new_position_margin_ratio",
|
||||||
"calculate_positions_margin",
|
"calculate_positions_margin",
|
||||||
|
"calculate_positions_margin_by_symbol",
|
||||||
|
"calculate_positions_margin_safe",
|
||||||
|
"calculate_projected_margin_ratio",
|
||||||
"calculate_spread_ratio",
|
"calculate_spread_ratio",
|
||||||
|
"calculate_symbol_group_margin_ratio",
|
||||||
|
"calculate_trailing_stop_updates",
|
||||||
"calculate_volume_by_margin",
|
"calculate_volume_by_margin",
|
||||||
"close_open_positions",
|
"close_open_positions",
|
||||||
"create_trading_client",
|
"create_trading_client",
|
||||||
@@ -136,7 +143,9 @@ __all__ = [
|
|||||||
"determine_order_limits",
|
"determine_order_limits",
|
||||||
"ensure_symbol_selected",
|
"ensure_symbol_selected",
|
||||||
"estimate_order_margin",
|
"estimate_order_margin",
|
||||||
|
"extract_tick_price",
|
||||||
"fetch_latest_closed_rates_for_trading_client",
|
"fetch_latest_closed_rates_for_trading_client",
|
||||||
|
"fetch_latest_closed_rates_indexed",
|
||||||
"get_account_snapshot",
|
"get_account_snapshot",
|
||||||
"get_positions_frame",
|
"get_positions_frame",
|
||||||
"get_symbol_snapshot",
|
"get_symbol_snapshot",
|
||||||
@@ -145,6 +154,7 @@ __all__ = [
|
|||||||
"normalize_order_volume",
|
"normalize_order_volume",
|
||||||
"place_market_order",
|
"place_market_order",
|
||||||
"update_sltp_for_open_positions",
|
"update_sltp_for_open_positions",
|
||||||
|
"update_trailing_stop_loss_for_open_positions",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -422,6 +432,30 @@ def _optional_price(value: object) -> float | None:
|
|||||||
return price
|
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]:
|
def _success_retcodes(mt5: object) -> frozenset[int]:
|
||||||
values = {
|
values = {
|
||||||
value
|
value
|
||||||
@@ -460,9 +494,10 @@ def _calculate_min_volume_if_affordable(
|
|||||||
msg = f"Invalid volume constraints for {symbol!r}."
|
msg = f"Invalid volume constraints for {symbol!r}."
|
||||||
raise Mt5TradingError(msg)
|
raise Mt5TradingError(msg)
|
||||||
side = _normalize_order_side(order_side)
|
side = _normalize_order_side(order_side)
|
||||||
tick = get_tick_snapshot(client, symbol)
|
price = extract_tick_price(
|
||||||
price = tick["ask"] if side == "BUY" else tick["bid"]
|
get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid"
|
||||||
if not isinstance(price, int | float) or price <= 0:
|
)
|
||||||
|
if price is None:
|
||||||
msg = f"Tick price is unavailable for {symbol!r}."
|
msg = f"Tick price is unavailable for {symbol!r}."
|
||||||
raise Mt5TradingError(msg)
|
raise Mt5TradingError(msg)
|
||||||
order_type = (
|
order_type = (
|
||||||
@@ -620,14 +655,14 @@ def estimate_order_margin(
|
|||||||
raise Mt5TradingError(msg)
|
raise Mt5TradingError(msg)
|
||||||
side = _normalize_order_side(order_side)
|
side = _normalize_order_side(order_side)
|
||||||
tick = get_tick_snapshot(client, symbol)
|
tick = get_tick_snapshot(client, symbol)
|
||||||
price = tick["ask"] if side == "BUY" else tick["bid"]
|
price = extract_tick_price(tick, "ask" if side == "BUY" else "bid")
|
||||||
if not isinstance(price, int | float) or price <= 0 or not isfinite(price):
|
if price is None:
|
||||||
msg = f"Tick price is unavailable for {symbol!r}."
|
msg = f"Tick price is unavailable for {symbol!r}."
|
||||||
raise Mt5TradingError(msg)
|
raise Mt5TradingError(msg)
|
||||||
order_type = (
|
order_type = (
|
||||||
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
|
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
|
||||||
)
|
)
|
||||||
raw_margin = client.order_calc_margin(order_type, symbol, volume, float(price))
|
raw_margin = client.order_calc_margin(order_type, symbol, volume, price)
|
||||||
try:
|
try:
|
||||||
margin = float(raw_margin)
|
margin = float(raw_margin)
|
||||||
except (TypeError, ValueError) as exc:
|
except (TypeError, ValueError) as exc:
|
||||||
@@ -681,22 +716,85 @@ def calculate_positions_margin(
|
|||||||
return total
|
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:
|
def calculate_spread_ratio(client: Mt5TradingClient, symbol: str) -> float:
|
||||||
"""Return ``(ask - bid) / ((ask + bid) / 2)`` for the latest tick.
|
"""Return ``(ask - bid) / ((ask + bid) / 2)`` for the latest tick.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
Mt5TradingError: If bid or ask is unavailable or non-positive.
|
Mt5TradingError: If bid or ask is unavailable.
|
||||||
"""
|
"""
|
||||||
tick = get_tick_snapshot(client, symbol)
|
tick = get_tick_snapshot(client, symbol)
|
||||||
bid = tick.get("bid")
|
bid = extract_tick_price(tick, "bid")
|
||||||
ask = tick.get("ask")
|
ask = extract_tick_price(tick, "ask")
|
||||||
if not isinstance(bid, int | float) or not isinstance(ask, int | float):
|
if bid is None or ask is None:
|
||||||
msg = f"Tick bid/ask is unavailable for {symbol!r}."
|
msg = f"Tick bid/ask is unavailable for {symbol!r}."
|
||||||
raise Mt5TradingError(msg)
|
raise Mt5TradingError(msg)
|
||||||
if bid <= 0 or ask <= 0:
|
return (ask - bid) / ((ask + bid) / 2.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)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_new_position_margin_ratio(
|
def calculate_new_position_margin_ratio(
|
||||||
@@ -719,9 +817,10 @@ def calculate_new_position_margin_ratio(
|
|||||||
margin = float(account.get("margin") or 0.0)
|
margin = float(account.get("margin") or 0.0)
|
||||||
if new_position_side is not None and new_position_volume > 0:
|
if new_position_side is not None and new_position_volume > 0:
|
||||||
side = _normalize_order_side(new_position_side)
|
side = _normalize_order_side(new_position_side)
|
||||||
tick = get_tick_snapshot(client, symbol)
|
price = extract_tick_price(
|
||||||
price = tick["ask"] if side == "BUY" else tick["bid"]
|
get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid"
|
||||||
if not isinstance(price, int | float) or price <= 0:
|
)
|
||||||
|
if price is None:
|
||||||
msg = f"Tick price is unavailable for {symbol!r}."
|
msg = f"Tick price is unavailable for {symbol!r}."
|
||||||
raise Mt5TradingError(msg)
|
raise Mt5TradingError(msg)
|
||||||
order_type = (
|
order_type = (
|
||||||
@@ -733,6 +832,102 @@ def calculate_new_position_margin_ratio(
|
|||||||
return margin / equity
|
return margin / equity
|
||||||
|
|
||||||
|
|
||||||
|
def _account_equity(client: Mt5TradingClient) -> float:
|
||||||
|
account = get_account_snapshot(client)
|
||||||
|
try:
|
||||||
|
equity = float(account.get("equity") or 0.0)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
msg = "Account equity must be positive to calculate margin ratio."
|
||||||
|
raise Mt5TradingError(msg) from exc
|
||||||
|
if equity <= 0 or not isfinite(equity):
|
||||||
|
msg = "Account equity must be positive to calculate margin ratio."
|
||||||
|
raise Mt5TradingError(msg)
|
||||||
|
return 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(
|
def calculate_margin_and_volume(
|
||||||
client: Mt5TradingClient,
|
client: Mt5TradingClient,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
@@ -779,28 +974,8 @@ def calculate_margin_and_volume(
|
|||||||
"SELL",
|
"SELL",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
native_calculate_volume = getattr(client, "calculate_volume_by_margin", None)
|
buy_volume = calculate_volume_by_margin(client, symbol, trade_margin, "BUY")
|
||||||
if callable(native_calculate_volume):
|
sell_volume = calculate_volume_by_margin(client, symbol, trade_margin, "SELL")
|
||||||
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",
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
symbol_info = get_symbol_snapshot(client, symbol)
|
symbol_info = get_symbol_snapshot(client, symbol)
|
||||||
volume_min = float(symbol_info.get("volume_min") or 0.0)
|
volume_min = float(symbol_info.get("volume_min") or 0.0)
|
||||||
@@ -829,7 +1004,9 @@ def calculate_volume_by_margin(
|
|||||||
"""Calculate max normalized volume affordable for one side.
|
"""Calculate max normalized volume affordable for one side.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Affordable volume rounded down to symbol volume constraints.
|
Largest stepped volume whose actual margin (from ``order_calc_margin``)
|
||||||
|
fits within ``available_margin``, rounded down to symbol volume
|
||||||
|
constraints; ``0.0`` when no affordable step exists.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
Mt5TradingError: If symbol volume constraints or tick data are invalid.
|
Mt5TradingError: If symbol volume constraints or tick data are invalid.
|
||||||
@@ -844,9 +1021,10 @@ def calculate_volume_by_margin(
|
|||||||
msg = f"Invalid volume constraints for {symbol!r}."
|
msg = f"Invalid volume constraints for {symbol!r}."
|
||||||
raise Mt5TradingError(msg)
|
raise Mt5TradingError(msg)
|
||||||
side = _normalize_order_side(order_side)
|
side = _normalize_order_side(order_side)
|
||||||
tick = get_tick_snapshot(client, symbol)
|
price = extract_tick_price(
|
||||||
price = tick["ask"] if side == "BUY" else tick["bid"]
|
get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid"
|
||||||
if not isinstance(price, int | float) or price <= 0:
|
)
|
||||||
|
if price is None:
|
||||||
msg = f"Tick price is unavailable for {symbol!r}."
|
msg = f"Tick price is unavailable for {symbol!r}."
|
||||||
raise Mt5TradingError(msg)
|
raise Mt5TradingError(msg)
|
||||||
order_type = (
|
order_type = (
|
||||||
@@ -855,11 +1033,38 @@ def calculate_volume_by_margin(
|
|||||||
min_margin = float(client.order_calc_margin(order_type, symbol, volume_min, price))
|
min_margin = float(client.order_calc_margin(order_type, symbol, volume_min, price))
|
||||||
if min_margin <= 0 or min_margin > available_margin:
|
if min_margin <= 0 or min_margin > available_margin:
|
||||||
return 0.0
|
return 0.0
|
||||||
raw_volume = available_margin / min_margin * volume_min
|
lo = 0
|
||||||
capped = min(raw_volume, volume_max) if volume_max > 0 else raw_volume
|
hi = int(
|
||||||
steps = floor(((capped - volume_min) / volume_step) + 1e-12)
|
max(
|
||||||
normalized = volume_min + max(0, steps) * volume_step
|
0,
|
||||||
return round(normalized, 10) if normalized >= volume_min else 0.0
|
floor(
|
||||||
|
(
|
||||||
|
(
|
||||||
|
min(available_margin / min_margin * volume_min, volume_max)
|
||||||
|
if volume_max > 0
|
||||||
|
else available_margin / min_margin * volume_min
|
||||||
|
)
|
||||||
|
- volume_min
|
||||||
|
)
|
||||||
|
/ volume_step
|
||||||
|
+ 1e-12
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
best = -1
|
||||||
|
|
||||||
|
while lo <= hi:
|
||||||
|
mid = (lo + hi) // 2
|
||||||
|
normalized = round(volume_min + mid * volume_step, 10)
|
||||||
|
actual = float(client.order_calc_margin(order_type, symbol, normalized, price))
|
||||||
|
|
||||||
|
if actual > 0 and actual <= available_margin:
|
||||||
|
best = mid
|
||||||
|
lo = mid + 1
|
||||||
|
else:
|
||||||
|
hi = mid - 1
|
||||||
|
|
||||||
|
return round(volume_min + best * volume_step, 10) if best >= 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
def determine_order_limits(
|
def determine_order_limits(
|
||||||
@@ -895,11 +1100,11 @@ def determine_order_limits(
|
|||||||
_require_protective_ratio(take_profit_ratio, "take_profit_limit_ratio")
|
_require_protective_ratio(take_profit_ratio, "take_profit_limit_ratio")
|
||||||
normalized_side = _position_side_from_order_side(side)
|
normalized_side = _position_side_from_order_side(side)
|
||||||
tick = get_tick_snapshot(client, symbol)
|
tick = get_tick_snapshot(client, symbol)
|
||||||
entry_value = tick["ask"] if normalized_side == "long" else tick["bid"]
|
entry_key = "ask" if normalized_side == "long" else "bid"
|
||||||
if not isinstance(entry_value, int | float):
|
entry = extract_tick_price(tick, entry_key)
|
||||||
|
if entry is None:
|
||||||
msg = f"Tick price is unavailable for {symbol!r}."
|
msg = f"Tick price is unavailable for {symbol!r}."
|
||||||
raise Mt5TradingError(msg)
|
raise Mt5TradingError(msg)
|
||||||
entry = float(entry_value)
|
|
||||||
try:
|
try:
|
||||||
symbol_info = get_symbol_snapshot(client, symbol)
|
symbol_info = get_symbol_snapshot(client, symbol)
|
||||||
except (AttributeError, KeyError, TypeError, ValueError):
|
except (AttributeError, KeyError, TypeError, ValueError):
|
||||||
@@ -975,8 +1180,8 @@ def place_market_order(
|
|||||||
if not dry_run:
|
if not dry_run:
|
||||||
ensure_symbol_selected(client, symbol)
|
ensure_symbol_selected(client, symbol)
|
||||||
tick = get_tick_snapshot(client, symbol)
|
tick = get_tick_snapshot(client, symbol)
|
||||||
price = tick["ask"] if side == "BUY" else tick["bid"]
|
price = extract_tick_price(tick, "ask" if side == "BUY" else "bid")
|
||||||
if not isinstance(price, int | float) or price <= 0:
|
if price is None:
|
||||||
msg = f"Tick price is unavailable for {symbol!r}."
|
msg = f"Tick price is unavailable for {symbol!r}."
|
||||||
raise Mt5TradingError(msg)
|
raise Mt5TradingError(msg)
|
||||||
request = {
|
request = {
|
||||||
@@ -986,7 +1191,7 @@ def place_market_order(
|
|||||||
"type": (
|
"type": (
|
||||||
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
|
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
|
||||||
),
|
),
|
||||||
"price": float(price),
|
"price": price,
|
||||||
"type_filling": _resolve_mt5_constant(
|
"type_filling": _resolve_mt5_constant(
|
||||||
client.mt5,
|
client.mt5,
|
||||||
"ORDER_FILLING",
|
"ORDER_FILLING",
|
||||||
@@ -1083,6 +1288,125 @@ def close_open_positions(
|
|||||||
return results
|
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(
|
def update_sltp_for_open_positions(
|
||||||
client: Mt5TradingClient,
|
client: Mt5TradingClient,
|
||||||
*,
|
*,
|
||||||
@@ -1168,19 +1492,10 @@ def fetch_latest_closed_rates_for_trading_client(
|
|||||||
msg = "count must be positive."
|
msg = "count must be positive."
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
fetch_method = getattr(client, "fetch_latest_rates_as_df", None)
|
fetch_method = getattr(client, "fetch_latest_rates_as_df", None)
|
||||||
if callable(fetch_method):
|
if not callable(fetch_method):
|
||||||
fetched = fetch_method(symbol, granularity, count + 1)
|
msg = "MT5 trading client cannot fetch rate data."
|
||||||
else:
|
raise Mt5TradingError(msg)
|
||||||
copy_method = getattr(client, "copy_rates_from_pos_as_df", None)
|
fetched = fetch_method(symbol, granularity, count + 1)
|
||||||
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 isinstance(fetched, pd.DataFrame):
|
if not isinstance(fetched, pd.DataFrame):
|
||||||
msg = (
|
msg = (
|
||||||
f"Malformed rate data for {symbol!r} at granularity {granularity!r}: "
|
f"Malformed rate data for {symbol!r} at granularity {granularity!r}: "
|
||||||
@@ -1202,6 +1517,92 @@ def fetch_latest_closed_rates_for_trading_client(
|
|||||||
return closed.tail(count).reset_index(drop=True)
|
return closed.tail(count).reset_index(drop=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_time_to_utc(series: pd.Series, symbol: str) -> pd.DatetimeIndex:
|
||||||
|
"""Convert a rate time series to a UTC-aware DatetimeIndex.
|
||||||
|
|
||||||
|
Handles MT5 epoch seconds (including object-dtype Python numbers), timezone-
|
||||||
|
naive datetime-like values, and timezone-aware datetime-like values.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
UTC-aware DatetimeIndex.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the time data is invalid, unparseable, or contains NaT.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
arr = series.to_numpy()
|
||||||
|
non_null = series.dropna()
|
||||||
|
object_numbers = (
|
||||||
|
pd.api.types.is_object_dtype(series)
|
||||||
|
and non_null.map(
|
||||||
|
lambda value: type(value) is not bool and isinstance(value, Real),
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
numeric_dtype = pd.api.types.is_numeric_dtype(
|
||||||
|
series
|
||||||
|
) and not pd.api.types.is_bool_dtype(
|
||||||
|
series,
|
||||||
|
)
|
||||||
|
if numeric_dtype or object_numbers:
|
||||||
|
idx = pd.to_datetime(arr, unit="s", utc=True)
|
||||||
|
else:
|
||||||
|
idx = pd.to_datetime(arr, utc=True)
|
||||||
|
except Exception as exc:
|
||||||
|
msg = f"Rate data for {symbol!r} has invalid or unparseable time data."
|
||||||
|
raise ValueError(msg) from exc
|
||||||
|
if any(idx.isna()):
|
||||||
|
msg = f"Rate data for {symbol!r} contains missing (NaT) timestamp values."
|
||||||
|
raise ValueError(msg)
|
||||||
|
return idx
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_latest_closed_rates_indexed(
|
||||||
|
client: Mt5TradingClient,
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
granularity: str,
|
||||||
|
count: int,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""Fetch the latest closed bars with a UTC DatetimeIndex from a trading client.
|
||||||
|
|
||||||
|
Internally reuses :func:`fetch_latest_closed_rates_for_trading_client` for
|
||||||
|
closed-bar detection and validation, then converts the ``time`` column to a
|
||||||
|
UTC-aware :class:`~pandas.DatetimeIndex` named ``"time"`` and drops the
|
||||||
|
original column. Intended for downstream time-series consumers that require
|
||||||
|
a datetime index rather than a ``time`` column.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Connected trading client with rate-fetch capability.
|
||||||
|
symbol: Symbol name.
|
||||||
|
granularity: Timeframe string (for example ``"M1"``, ``"H1"``).
|
||||||
|
count: Maximum number of closed bars to return.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Up to ``count`` closed bars ordered oldest to newest, with a
|
||||||
|
UTC-aware ``DatetimeIndex`` named ``"time"``. The original ``time``
|
||||||
|
column is dropped.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If ``count`` is not positive, rate data is empty or
|
||||||
|
malformed, the ``time`` column is missing, or timestamp data
|
||||||
|
is invalid or unparseable.
|
||||||
|
"""
|
||||||
|
frame = fetch_latest_closed_rates_for_trading_client(
|
||||||
|
client,
|
||||||
|
symbol=symbol,
|
||||||
|
granularity=granularity,
|
||||||
|
count=count,
|
||||||
|
)
|
||||||
|
if "time" not in frame.columns:
|
||||||
|
msg = f"Rate data is missing a time column for {symbol!r}."
|
||||||
|
raise ValueError(msg)
|
||||||
|
idx = _rate_time_to_utc(frame["time"], symbol)
|
||||||
|
idx.name = "time"
|
||||||
|
result = frame.drop(columns=["time"])
|
||||||
|
result.index = idx
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def mt5_trading_session(
|
def mt5_trading_session(
|
||||||
config: Mt5Config | None = None,
|
config: Mt5Config | None = None,
|
||||||
|
|||||||
+1
-2
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mt5cli"
|
name = "mt5cli"
|
||||||
version = "0.8.3"
|
version = "0.9.3"
|
||||||
description = "Generic MT5 data and execution infrastructure for Python applications"
|
description = "Generic MT5 data and execution infrastructure for Python applications"
|
||||||
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
||||||
maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
||||||
@@ -124,7 +124,6 @@ ignore = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
"mt5cli/history.py" = ["TC003"]
|
|
||||||
"tests/**/*.py" = [
|
"tests/**/*.py" = [
|
||||||
"DOC201", # Missing return documentation
|
"DOC201", # Missing return documentation
|
||||||
"DOC501", # Raised exception missing from docstring
|
"DOC501", # Raised exception missing from docstring
|
||||||
|
|||||||
+106
-4
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import UTC, datetime
|
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
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
@@ -15,7 +17,9 @@ from pytest_mock import MockerFixture # noqa: TC002
|
|||||||
import mt5cli
|
import mt5cli
|
||||||
from mt5cli import (
|
from mt5cli import (
|
||||||
DEDUP_KEYS,
|
DEDUP_KEYS,
|
||||||
|
PUBLIC_EXPORT_TIERS,
|
||||||
REQUIRED_COLUMNS,
|
REQUIRED_COLUMNS,
|
||||||
|
SECONDARY_PUBLIC_EXPORTS,
|
||||||
STABLE_SDK_EXPORTS,
|
STABLE_SDK_EXPORTS,
|
||||||
TIME_COLUMNS,
|
TIME_COLUMNS,
|
||||||
AccountSpec,
|
AccountSpec,
|
||||||
@@ -35,6 +39,9 @@ from mt5cli import (
|
|||||||
build_rate_targets,
|
build_rate_targets,
|
||||||
calculate_margin_and_volume,
|
calculate_margin_and_volume,
|
||||||
calculate_positions_margin,
|
calculate_positions_margin,
|
||||||
|
calculate_projected_margin_ratio,
|
||||||
|
calculate_symbol_group_margin_ratio,
|
||||||
|
calculate_trailing_stop_updates,
|
||||||
call_with_normalized_errors,
|
call_with_normalized_errors,
|
||||||
detect_format,
|
detect_format,
|
||||||
drop_forming_rate_bar,
|
drop_forming_rate_bar,
|
||||||
@@ -42,8 +49,10 @@ from mt5cli import (
|
|||||||
ensure_utc,
|
ensure_utc,
|
||||||
export_dataframe,
|
export_dataframe,
|
||||||
export_dataframe_to_sqlite,
|
export_dataframe_to_sqlite,
|
||||||
|
extract_tick_price,
|
||||||
fetch_latest_closed_rates,
|
fetch_latest_closed_rates,
|
||||||
fetch_latest_closed_rates_for_trading_client,
|
fetch_latest_closed_rates_for_trading_client,
|
||||||
|
fetch_latest_closed_rates_indexed,
|
||||||
granularity_name,
|
granularity_name,
|
||||||
is_recoverable_mt5_error,
|
is_recoverable_mt5_error,
|
||||||
load_rate_data,
|
load_rate_data,
|
||||||
@@ -68,9 +77,6 @@ from mt5cli.history import create_rate_compatibility_views
|
|||||||
from mt5cli.retry import retry_with_backoff
|
from mt5cli.retry import retry_with_backoff
|
||||||
from mt5cli.schemas import ensure_utc_columns, normalize_time_columns
|
from mt5cli.schemas import ensure_utc_columns, normalize_time_columns
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
def _sample_frame(kind: DataKind) -> pd.DataFrame:
|
def _sample_frame(kind: DataKind) -> pd.DataFrame:
|
||||||
if kind is DataKind.rates:
|
if kind is DataKind.rates:
|
||||||
@@ -546,11 +552,69 @@ class TestStableSdkContract:
|
|||||||
missing = sorted(STABLE_SDK_EXPORTS - set(mt5cli.__all__))
|
missing = sorted(STABLE_SDK_EXPORTS - set(mt5cli.__all__))
|
||||||
assert not missing, f"STABLE_SDK_EXPORTS missing from __all__: {missing}"
|
assert not missing, f"STABLE_SDK_EXPORTS missing from __all__: {missing}"
|
||||||
|
|
||||||
|
def test_public_export_tiers_are_disjoint_and_complete(self) -> None:
|
||||||
|
"""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))
|
@pytest.mark.parametrize("name", sorted(STABLE_SDK_EXPORTS))
|
||||||
def test_stable_exports_are_importable_from_package_root(self, name: str) -> None:
|
def test_stable_exports_are_importable_from_package_root(self, name: str) -> None:
|
||||||
"""Stable SDK names resolve through ``from mt5cli import ...``."""
|
"""Stable SDK names resolve through ``from mt5cli import ...``."""
|
||||||
assert hasattr(mt5cli, name), f"{name!r} missing from mt5cli package root"
|
assert hasattr(mt5cli, name), f"{name!r} missing from mt5cli package root"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"name",
|
||||||
|
sorted(SECONDARY_PUBLIC_EXPORTS),
|
||||||
|
)
|
||||||
|
def test_secondary_exports_are_importable(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
) -> None:
|
||||||
|
"""Non-stable public names remain available from the package root."""
|
||||||
|
assert hasattr(mt5cli, name), f"{name!r} missing from mt5cli package root"
|
||||||
|
|
||||||
def test_drop_forming_rate_bar_from_package_root(self) -> None:
|
def test_drop_forming_rate_bar_from_package_root(self) -> None:
|
||||||
"""Closed-bar trimming is available from the stable package surface."""
|
"""Closed-bar trimming is available from the stable package surface."""
|
||||||
frame = pd.DataFrame({"time": [1, 2, 3], "close": [1.0, 1.1, 1.2]})
|
frame = pd.DataFrame({"time": [1, 2, 3], "close": [1.0, 1.1, 1.2]})
|
||||||
@@ -614,6 +678,15 @@ class TestStableSdkContract:
|
|||||||
|
|
||||||
assert calculate_positions_margin(client) == 0
|
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_projected_margin_ratio)
|
||||||
|
assert callable(calculate_symbol_group_margin_ratio)
|
||||||
|
|
||||||
def test_resolve_rate_view_name_from_package_root(self, tmp_path: Path) -> None:
|
def test_resolve_rate_view_name_from_package_root(self, tmp_path: Path) -> None:
|
||||||
"""Rate view resolution is importable and honors require_existing."""
|
"""Rate view resolution is importable and honors require_existing."""
|
||||||
db_path = tmp_path / "rates.db"
|
db_path = tmp_path / "rates.db"
|
||||||
@@ -734,3 +807,32 @@ class TestStableSdkContract:
|
|||||||
raise RuntimeError(message)
|
raise RuntimeError(message)
|
||||||
|
|
||||||
mock_client.shutdown.assert_called_once()
|
mock_client.shutdown.assert_called_once()
|
||||||
|
|
||||||
|
def test_fetch_latest_closed_rates_indexed_from_package_root(
|
||||||
|
self,
|
||||||
|
mocker: MockerFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Indexed closed-bar helper returns a UTC DatetimeIndex named 'time'."""
|
||||||
|
client = MagicMock()
|
||||||
|
mocker.patch(
|
||||||
|
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
||||||
|
return_value=pd.DataFrame(
|
||||||
|
{
|
||||||
|
"time": [1704067200, 1704153600, 1704240000],
|
||||||
|
"close": [1.0, 1.1, 1.2],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = fetch_latest_closed_rates_indexed(
|
||||||
|
client,
|
||||||
|
symbol="EURUSD",
|
||||||
|
granularity="M1",
|
||||||
|
count=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result.index, pd.DatetimeIndex)
|
||||||
|
assert result.index.name == "time"
|
||||||
|
assert result.index.tz is not None
|
||||||
|
assert "time" not in result.columns
|
||||||
|
assert "close" in result.columns
|
||||||
|
|||||||
@@ -1777,6 +1777,80 @@ class TestSubstituteEnvPlaceholders:
|
|||||||
with pytest.raises(ValueError, match="'MT5_MISSING' is not set"):
|
with pytest.raises(ValueError, match="'MT5_MISSING' is not set"):
|
||||||
substitute_env_placeholders("${MT5_MISSING}")
|
substitute_env_placeholders("${MT5_MISSING}")
|
||||||
|
|
||||||
|
def test_whole_dollar_not_substituted_by_default(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test $ENV_NAME is not expanded without allow_whole_dollar_env=True."""
|
||||||
|
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
||||||
|
|
||||||
|
assert substitute_env_placeholders("$MT5_PASSWORD") == "$MT5_PASSWORD"
|
||||||
|
|
||||||
|
def test_whole_dollar_substituted_with_opt_in(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test $ENV_NAME is expanded when allow_whole_dollar_env=True."""
|
||||||
|
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
||||||
|
|
||||||
|
result = substitute_env_placeholders(
|
||||||
|
"$MT5_PASSWORD", allow_whole_dollar_env=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == "secret"
|
||||||
|
|
||||||
|
def test_whole_dollar_missing_variable_raises_value_error(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test missing $ENV_NAME raises ValueError when opt-in is enabled."""
|
||||||
|
monkeypatch.delenv("MT5_MISSING", raising=False)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="'MT5_MISSING' is not set"):
|
||||||
|
substitute_env_placeholders("$MT5_MISSING", allow_whole_dollar_env=True)
|
||||||
|
|
||||||
|
def test_partial_dollar_not_expanded_with_opt_in(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test $ENV embedded in a larger string is not expanded."""
|
||||||
|
monkeypatch.setenv("pass", "secret")
|
||||||
|
monkeypatch.setenv("ENV", "val")
|
||||||
|
|
||||||
|
assert (
|
||||||
|
substitute_env_placeholders("plan$pass", allow_whole_dollar_env=True)
|
||||||
|
== "plan$pass"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
substitute_env_placeholders("abc$ENV", allow_whole_dollar_env=True)
|
||||||
|
== "abc$ENV"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_dollar_with_suffix_not_expanded_with_opt_in(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test $ENV-suffix is not expanded (not a whole-value placeholder)."""
|
||||||
|
monkeypatch.setenv("ENV", "val")
|
||||||
|
|
||||||
|
assert (
|
||||||
|
substitute_env_placeholders("$ENV-suffix", allow_whole_dollar_env=True)
|
||||||
|
== "$ENV-suffix"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_brace_format_works_with_opt_in(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test ${ENV_VAR} substitution still works when allow_whole_dollar_env=True."""
|
||||||
|
monkeypatch.setenv("MT5_LOGIN", "12345")
|
||||||
|
|
||||||
|
result = substitute_env_placeholders(
|
||||||
|
"${MT5_LOGIN}", allow_whole_dollar_env=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == "12345"
|
||||||
|
|
||||||
|
|
||||||
class TestResolveAccountSpec:
|
class TestResolveAccountSpec:
|
||||||
"""Tests for resolve_account_spec and resolve_account_specs."""
|
"""Tests for resolve_account_spec and resolve_account_specs."""
|
||||||
@@ -1863,6 +1937,117 @@ class TestResolveAccountSpec:
|
|||||||
assert [a.server for a in resolved] == ["Shared", "Fixed"]
|
assert [a.server for a in resolved] == ["Shared", "Fixed"]
|
||||||
assert all(a.timeout == 1000 for a in resolved)
|
assert all(a.timeout == 1000 for a in resolved)
|
||||||
|
|
||||||
|
def test_resolve_account_spec_with_whole_dollar_env(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Account spec expands $ENV_NAME when allow_whole_dollar_env=True."""
|
||||||
|
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
||||||
|
account = AccountSpec(symbols=["EURUSD"], password="$MT5_PASSWORD")
|
||||||
|
|
||||||
|
resolved = resolve_account_spec(account, allow_whole_dollar_env=True)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
def test_resolve_account_specs_with_whole_dollar_env(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test resolve_account_specs threads allow_whole_dollar_env to each account."""
|
||||||
|
monkeypatch.setenv("MT5_SERVER", "Broker-Demo")
|
||||||
|
accounts = [
|
||||||
|
AccountSpec(symbols=["EURUSD"], server="$MT5_SERVER"),
|
||||||
|
AccountSpec(symbols=["GBPUSD"], server="Fixed"),
|
||||||
|
]
|
||||||
|
|
||||||
|
resolved = resolve_account_specs(accounts, allow_whole_dollar_env=True)
|
||||||
|
|
||||||
|
assert resolved[0].server == "Broker-Demo"
|
||||||
|
assert resolved[1].server == "Fixed"
|
||||||
|
|
||||||
|
def test_resolve_account_spec_whole_dollar_login(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test $ENV_NAME login string is expanded when allow_whole_dollar_env=True."""
|
||||||
|
monkeypatch.setenv("MT5_LOGIN", "12345")
|
||||||
|
account = AccountSpec(symbols=["EURUSD"], login="$MT5_LOGIN")
|
||||||
|
|
||||||
|
resolved = resolve_account_spec(account, allow_whole_dollar_env=True)
|
||||||
|
|
||||||
|
assert resolved.login == "12345"
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildConfigWholeDollarEnv:
|
||||||
|
"""Tests for build_config with allow_whole_dollar_env."""
|
||||||
|
|
||||||
|
def test_build_config_substitutes_server_with_opt_in(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""build_config expands $ENV_NAME server when allow_whole_dollar_env=True."""
|
||||||
|
monkeypatch.setenv("MT5_SERVER", "Broker-Demo")
|
||||||
|
|
||||||
|
config = build_config(server="$MT5_SERVER", allow_whole_dollar_env=True)
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
def test_build_config_leaves_dollar_literal_by_default(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test build_config does not substitute $ENV without opt-in."""
|
||||||
|
monkeypatch.setenv("MT5_SERVER", "Broker-Demo")
|
||||||
|
|
||||||
|
config = build_config(server="$MT5_SERVER")
|
||||||
|
|
||||||
|
assert config.server == "$MT5_SERVER"
|
||||||
|
|
||||||
|
def test_build_config_none_params_not_substituted(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch, # noqa: ARG002
|
||||||
|
) -> None:
|
||||||
|
"""Test build_config with None params does not raise even with opt-in."""
|
||||||
|
config = build_config(allow_whole_dollar_env=True)
|
||||||
|
|
||||||
|
assert config.server is None
|
||||||
|
assert config.password is None
|
||||||
|
assert config.path is None
|
||||||
|
|
||||||
|
|
||||||
class TestThrottledHistoryUpdater:
|
class TestThrottledHistoryUpdater:
|
||||||
"""Tests for the throttled incremental history updater."""
|
"""Tests for the throttled incremental history updater."""
|
||||||
|
|||||||
+1143
-59
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user