Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ac3b885c3 | |||
| 823cb5b0a4 | |||
| 1c57be5c44 | |||
| f1ada55bce | |||
| d292fbb9d9 | |||
| 8e53212a24 | |||
| b878a61c07 | |||
| 0610ea732c | |||
| 82a39731ed |
@@ -104,8 +104,8 @@ A reliable pattern is:
|
||||
Example GraphQL mutation shape:
|
||||
|
||||
```graphql
|
||||
mutation($threadId: ID!) {
|
||||
resolveReviewThread(input: {threadId: $threadId}) {
|
||||
mutation ($threadId: ID!) {
|
||||
resolveReviewThread(input: { threadId: $threadId }) {
|
||||
thread {
|
||||
id
|
||||
isResolved
|
||||
|
||||
+40
-30
@@ -19,20 +19,27 @@ alias for new code.
|
||||
|
||||
### Session lifecycle and configuration
|
||||
|
||||
| Symbol | Role |
|
||||
| ----------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| `MT5Client`, `Mt5CliClient` | Read-only data client with optional `order_check` / `order_send` |
|
||||
| `build_config` | Build `pdmt5.Mt5Config` from connection fields |
|
||||
| `mt5_session` | Context manager: initialize, login, yield client, shutdown |
|
||||
| `create_trading_client`, `mt5_trading_session` | Trading-capable `pdmt5.Mt5TradingClient` lifecycle |
|
||||
| `AccountSpec` | Generic account group: symbols plus optional credentials |
|
||||
| `resolve_account_spec`, `resolve_account_specs` | Merge overrides and expand `${ENV_VAR}` placeholders |
|
||||
| `substitute_env_placeholders` | Replace `${NAME}` substrings from the environment |
|
||||
| Symbol | Role |
|
||||
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| `MT5Client`, `Mt5CliClient` | Read-only data client with optional `order_check` / `order_send` |
|
||||
| `build_config` | Build `pdmt5.Mt5Config` from connection fields |
|
||||
| `mt5_session` | Context manager: initialize, login, yield client, shutdown |
|
||||
| `create_trading_client`, `mt5_trading_session` | Trading-capable `pdmt5.Mt5TradingClient` lifecycle |
|
||||
| `AccountSpec` | Generic account group: symbols plus optional credentials |
|
||||
| `resolve_account_spec`, `resolve_account_specs` | Merge overrides and expand `${ENV_VAR}` placeholders; opt-in `allow_whole_dollar_env` for bare `$NAME` |
|
||||
| `substitute_env_placeholders` | Replace `${NAME}` substrings from the environment; opt-in `allow_whole_dollar_env` for whole-value `$NAME` |
|
||||
|
||||
Credential resolution is generic: any environment variable name may appear inside
|
||||
`${...}`. mt5cli does not hard-code application-specific keys such as
|
||||
`mt5_login` or `mt5_exe`.
|
||||
|
||||
Pass `allow_whole_dollar_env=True` to `substitute_env_placeholders()`,
|
||||
`resolve_account_spec()`, `resolve_account_specs()`, and `build_config()` to
|
||||
additionally expand strings whose entire value is a bare `$ENV_NAME` identifier.
|
||||
Partial strings such as `"plan$pass"`, `"abc$ENV"`, or `"$ENV-suffix"` are
|
||||
**never** expanded — only an exact `$IDENTIFIER` whole-string match qualifies.
|
||||
Default is `False` to preserve backward compatibility.
|
||||
|
||||
### Read-only MT5 data access
|
||||
|
||||
Module-level helpers open a transient connection per call. Prefer `mt5_session`
|
||||
@@ -56,15 +63,16 @@ MetaTrader 5 returns the still-forming bar as the last row when
|
||||
`start_pos=0`. Use these helpers instead of reimplementing bar trimming or
|
||||
timestamp normalization in downstream apps.
|
||||
|
||||
| Symbol | Role |
|
||||
| ------------------------------------------------ | ------------------------------------------------------------ |
|
||||
| `drop_forming_rate_bar` | Remove the last row from chronologically ordered rate data |
|
||||
| `fetch_latest_closed_rates` | Single connected client: fetch `count + 1`, drop forming bar |
|
||||
| `fetch_latest_closed_rates_for_trading_client` | Closed bars from an active `Mt5TradingClient` session |
|
||||
| `collect_latest_closed_rates_for_accounts` | Multi-account closed bars with optional retry wrapper |
|
||||
| `collect_latest_closed_rates_by_granularity` | Same data keyed by `(symbol, granularity_name)` |
|
||||
| `collect_latest_rates_for_accounts` | Latest bars including the forming bar when `start_pos=0` |
|
||||
| `collect_latest_rates_for_accounts_with_retries` | Bounded exponential backoff for transient MT5 errors |
|
||||
| Symbol | Role |
|
||||
| ------------------------------------------------ | ------------------------------------------------------------------------------- |
|
||||
| `drop_forming_rate_bar` | Remove the last row from chronologically ordered rate data |
|
||||
| `fetch_latest_closed_rates` | Single connected client: fetch `count + 1`, drop forming bar |
|
||||
| `fetch_latest_closed_rates_for_trading_client` | Closed bars from an active `Mt5TradingClient` session; returns RangeIndex |
|
||||
| `fetch_latest_closed_rates_indexed` | Same as above but returns a UTC `DatetimeIndex` named `"time"` (no time column) |
|
||||
| `collect_latest_closed_rates_for_accounts` | Multi-account closed bars with optional retry wrapper |
|
||||
| `collect_latest_closed_rates_by_granularity` | Same data keyed by `(symbol, granularity_name)` |
|
||||
| `collect_latest_rates_for_accounts` | Latest bars including the forming bar when `start_pos=0` |
|
||||
| `collect_latest_rates_for_accounts_with_retries` | Bounded exponential backoff for transient MT5 errors |
|
||||
|
||||
### SQLite history collection and rate loading
|
||||
|
||||
@@ -91,18 +99,20 @@ diagrams.
|
||||
These helpers implement broker-facing calculations only. They do not encode
|
||||
strategy entries, exits, Kelly sizing, or signal logic.
|
||||
|
||||
| Symbol | Role |
|
||||
| -------------------------------------------------------------------------------------------------- | --------------------------------------------- |
|
||||
| `get_account_snapshot`, `get_symbol_snapshot`, `get_tick_snapshot`, `get_positions_frame` | Normalized account/symbol/tick/position views |
|
||||
| `detect_position_side` | Net long / short / flat from open positions |
|
||||
| `calculate_spread_ratio` | Relative bid-ask spread |
|
||||
| `calculate_margin_and_volume`, `calculate_volume_by_margin`, `calculate_new_position_margin_ratio` | Margin budget and volume sizing |
|
||||
| `normalize_order_volume`, `estimate_order_margin`, `calculate_positions_margin` | Broker volume normalization and margin totals |
|
||||
| `determine_order_limits` | SL/TP price levels from ratios |
|
||||
| `ensure_symbol_selected` | Select/verify Market Watch visibility |
|
||||
| `place_market_order`, `close_open_positions`, `update_sltp_for_open_positions` | Order execution helpers (`dry_run` supported) |
|
||||
| `MarginVolume`, `OrderLimits`, `OrderExecutionResult` | Typed return contracts for order helpers |
|
||||
| `OrderSide`, `OrderFillingMode`, `OrderTimeMode`, `PositionSide`, `ExecutionStatus` | Typed enums for order helpers |
|
||||
| Symbol | Role |
|
||||
| -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| `get_account_snapshot`, `get_symbol_snapshot`, `get_tick_snapshot`, `get_positions_frame` | Normalized account/symbol/tick/position views |
|
||||
| `detect_position_side` | Net long / short / flat from open positions |
|
||||
| `calculate_spread_ratio` | Relative bid-ask spread |
|
||||
| `calculate_margin_and_volume`, `calculate_volume_by_margin`, `calculate_new_position_margin_ratio` | Margin budget and volume sizing |
|
||||
| `normalize_order_volume`, `estimate_order_margin`, `calculate_positions_margin` | Broker volume normalization and margin totals |
|
||||
| `calculate_positions_margin_by_symbol` | Per-symbol margin map (resilient, first-seen order) |
|
||||
| `calculate_positions_margin_safe` | Summed total margin across symbols (failed symbols skipped) |
|
||||
| `determine_order_limits` | SL/TP price levels from ratios |
|
||||
| `ensure_symbol_selected` | Select/verify Market Watch visibility |
|
||||
| `place_market_order`, `close_open_positions`, `update_sltp_for_open_positions` | Order execution helpers (`dry_run` supported) |
|
||||
| `MarginVolume`, `OrderLimits`, `OrderExecutionResult` | Typed return contracts for order helpers |
|
||||
| `OrderSide`, `OrderFillingMode`, `OrderTimeMode`, `PositionSide`, `ExecutionStatus` | Typed enums for order helpers |
|
||||
|
||||
`MT5Client.order_send()` and CLI `order-send --yes` are live execution paths.
|
||||
|
||||
|
||||
@@ -86,6 +86,28 @@ resolved = resolve_account_specs(accounts, 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
|
||||
|
||||
`ThrottledHistoryUpdater` wraps `update_history()` with a minimum interval
|
||||
|
||||
+19
-10
@@ -48,6 +48,7 @@ from mt5cli import (
|
||||
determine_order_limits,
|
||||
estimate_order_margin,
|
||||
fetch_latest_closed_rates_for_trading_client,
|
||||
fetch_latest_closed_rates_indexed,
|
||||
get_account_snapshot,
|
||||
get_positions_frame,
|
||||
get_symbol_snapshot,
|
||||
@@ -78,6 +79,14 @@ closed_bars = fetch_latest_closed_rates_for_trading_client(
|
||||
granularity="M1",
|
||||
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(
|
||||
client,
|
||||
"EURUSD",
|
||||
@@ -174,16 +183,16 @@ through the stable package root without embedding entry/exit policy.
|
||||
|
||||
## Migration from application-local helpers
|
||||
|
||||
| Application-local concern | mt5cli replacement |
|
||||
| -------------------------------------------------------- | ----------------------------------------------- |
|
||||
| Manual terminal spawn/kill around trading code | `mt5_trading_session()` |
|
||||
| Local position-side detection | `detect_position_side()` |
|
||||
| Local margin/volume sizing | `calculate_margin_and_volume()` |
|
||||
| Local broker volume step normalization | `normalize_order_volume()` |
|
||||
| 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 SL/TP price derivation | `determine_order_limits()` |
|
||||
| Throttled SQLite history loop with ad-hoc error handling | `ThrottledHistoryUpdater(suppress_errors=True)` |
|
||||
| Application-local concern | mt5cli replacement |
|
||||
| -------------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| Manual terminal spawn/kill around trading code | `mt5_trading_session()` |
|
||||
| Local position-side detection | `detect_position_side()` |
|
||||
| Local margin/volume sizing | `calculate_margin_and_volume()` |
|
||||
| Local broker volume step normalization | `normalize_order_volume()` |
|
||||
| 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()`, `fetch_latest_closed_rates_indexed()` |
|
||||
| Local SL/TP price derivation | `determine_order_limits()` |
|
||||
| Throttled SQLite history loop with ad-hoc error handling | `ThrottledHistoryUpdater(suppress_errors=True)` |
|
||||
|
||||
Keep read-only data collection on `mt5_session()` / `Mt5CliClient`; use
|
||||
`mt5_trading_session()` only where order placement or trading calculations are
|
||||
|
||||
@@ -119,6 +119,8 @@ from .trading import (
|
||||
calculate_margin_and_volume,
|
||||
calculate_new_position_margin_ratio,
|
||||
calculate_positions_margin,
|
||||
calculate_positions_margin_by_symbol,
|
||||
calculate_positions_margin_safe,
|
||||
calculate_spread_ratio,
|
||||
calculate_volume_by_margin,
|
||||
close_open_positions,
|
||||
@@ -128,6 +130,7 @@ from .trading import (
|
||||
ensure_symbol_selected,
|
||||
estimate_order_margin,
|
||||
fetch_latest_closed_rates_for_trading_client,
|
||||
fetch_latest_closed_rates_indexed,
|
||||
get_account_snapshot,
|
||||
get_positions_frame,
|
||||
get_symbol_snapshot,
|
||||
@@ -187,6 +190,8 @@ __all__ = [
|
||||
"calculate_margin_and_volume",
|
||||
"calculate_new_position_margin_ratio",
|
||||
"calculate_positions_margin",
|
||||
"calculate_positions_margin_by_symbol",
|
||||
"calculate_positions_margin_safe",
|
||||
"calculate_spread_ratio",
|
||||
"calculate_volume_by_margin",
|
||||
"call_with_normalized_errors",
|
||||
@@ -214,6 +219,7 @@ __all__ = [
|
||||
"export_dataframe_to_sqlite",
|
||||
"fetch_latest_closed_rates",
|
||||
"fetch_latest_closed_rates_for_trading_client",
|
||||
"fetch_latest_closed_rates_indexed",
|
||||
"get_account_snapshot",
|
||||
"get_positions_frame",
|
||||
"get_symbol_snapshot",
|
||||
|
||||
@@ -31,6 +31,8 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
||||
"calculate_margin_and_volume",
|
||||
"calculate_new_position_margin_ratio",
|
||||
"calculate_positions_margin",
|
||||
"calculate_positions_margin_by_symbol",
|
||||
"calculate_positions_margin_safe",
|
||||
"calculate_spread_ratio",
|
||||
"calculate_volume_by_margin",
|
||||
"call_with_normalized_errors",
|
||||
@@ -56,6 +58,7 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
||||
"export_dataframe_to_sqlite",
|
||||
"fetch_latest_closed_rates",
|
||||
"fetch_latest_closed_rates_for_trading_client",
|
||||
"fetch_latest_closed_rates_indexed",
|
||||
"get_account_snapshot",
|
||||
"get_positions_frame",
|
||||
"get_symbol_snapshot",
|
||||
|
||||
+77
-9
@@ -309,12 +309,33 @@ def build_config(
|
||||
password: str | None = None,
|
||||
server: str | None = None,
|
||||
timeout: int | None = None,
|
||||
allow_whole_dollar_env: bool = False,
|
||||
) -> Mt5Config:
|
||||
"""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:
|
||||
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(
|
||||
path=path,
|
||||
login=login,
|
||||
@@ -1376,13 +1397,22 @@ class AccountSpec:
|
||||
|
||||
|
||||
_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.
|
||||
|
||||
Args:
|
||||
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:
|
||||
The string with every placeholder replaced by its environment value.
|
||||
@@ -1390,6 +1420,14 @@ def substitute_env_placeholders(value: str) -> str:
|
||||
Raises:
|
||||
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] = []
|
||||
last_end = 0
|
||||
for match in _ENV_PLACEHOLDER_PATTERN.finditer(value):
|
||||
@@ -1404,7 +1442,12 @@ def substitute_env_placeholders(value: str) -> str:
|
||||
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.
|
||||
|
||||
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
|
||||
if value is None:
|
||||
return None
|
||||
return substitute_env_placeholders(value)
|
||||
return substitute_env_placeholders(
|
||||
value, allow_whole_dollar_env=allow_whole_dollar_env
|
||||
)
|
||||
|
||||
|
||||
def _resolve_login(
|
||||
override: int | str | None,
|
||||
account_login: int | str | None,
|
||||
*,
|
||||
allow_whole_dollar_env: bool = False,
|
||||
) -> int | str | None:
|
||||
"""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 isinstance(override, int):
|
||||
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):
|
||||
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(
|
||||
@@ -1445,6 +1496,7 @@ def resolve_account_spec(
|
||||
server: str | None = None,
|
||||
path: str | None = None,
|
||||
timeout: int | None = None,
|
||||
allow_whole_dollar_env: bool = False,
|
||||
) -> AccountSpec:
|
||||
"""Resolve an account's credentials from overrides and ``${ENV_VAR}`` values.
|
||||
|
||||
@@ -1460,6 +1512,9 @@ def resolve_account_spec(
|
||||
server: Optional explicit server override.
|
||||
path: Optional explicit terminal path 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:
|
||||
A new :class:`AccountSpec` with resolved credentials and the original
|
||||
@@ -1469,10 +1524,18 @@ def resolve_account_spec(
|
||||
"""
|
||||
return AccountSpec(
|
||||
symbols=account.symbols,
|
||||
login=_resolve_login(login, account.login),
|
||||
password=_resolve_field(password, account.password),
|
||||
server=_resolve_field(server, account.server),
|
||||
path=_resolve_field(path, account.path),
|
||||
login=_resolve_login(
|
||||
login, account.login, allow_whole_dollar_env=allow_whole_dollar_env
|
||||
),
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -1485,6 +1548,7 @@ def resolve_account_specs(
|
||||
server: str | None = None,
|
||||
path: str | None = None,
|
||||
timeout: int | None = None,
|
||||
allow_whole_dollar_env: bool = False,
|
||||
) -> list[AccountSpec]:
|
||||
"""Resolve credentials for multiple accounts.
|
||||
|
||||
@@ -1498,6 +1562,9 @@ def resolve_account_specs(
|
||||
server: Optional explicit server override applied to each account.
|
||||
path: Optional explicit terminal path 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:
|
||||
Resolved account specifications in the original order. Raises
|
||||
@@ -1512,6 +1579,7 @@ def resolve_account_specs(
|
||||
server=server,
|
||||
path=path,
|
||||
timeout=timeout,
|
||||
allow_whole_dollar_env=allow_whole_dollar_env,
|
||||
)
|
||||
for account in accounts
|
||||
]
|
||||
|
||||
+248
-57
@@ -2,13 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from math import floor, isfinite
|
||||
from numbers import Integral
|
||||
from numbers import Integral, Real
|
||||
from typing import TYPE_CHECKING, Literal, TypedDict, cast
|
||||
|
||||
import pandas as pd
|
||||
from pdmt5 import Mt5Config, Mt5TradingClient, Mt5TradingError
|
||||
from pdmt5 import Mt5Config, Mt5RuntimeError, Mt5TradingClient, Mt5TradingError
|
||||
|
||||
from .history import drop_forming_rate_bar
|
||||
from .sdk import build_config
|
||||
@@ -16,7 +17,9 @@ from .utils import coerce_login as _coerce_login
|
||||
from .utils import parse_timeframe
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator, Sequence
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
PositionSide = Literal["long", "short"]
|
||||
OrderSide = Literal["BUY", "SELL"]
|
||||
@@ -128,6 +131,8 @@ __all__ = [
|
||||
"calculate_margin_and_volume",
|
||||
"calculate_new_position_margin_ratio",
|
||||
"calculate_positions_margin",
|
||||
"calculate_positions_margin_by_symbol",
|
||||
"calculate_positions_margin_safe",
|
||||
"calculate_spread_ratio",
|
||||
"calculate_volume_by_margin",
|
||||
"close_open_positions",
|
||||
@@ -137,6 +142,7 @@ __all__ = [
|
||||
"ensure_symbol_selected",
|
||||
"estimate_order_margin",
|
||||
"fetch_latest_closed_rates_for_trading_client",
|
||||
"fetch_latest_closed_rates_indexed",
|
||||
"get_account_snapshot",
|
||||
"get_positions_frame",
|
||||
"get_symbol_snapshot",
|
||||
@@ -422,6 +428,30 @@ def _optional_price(value: object) -> float | None:
|
||||
return price
|
||||
|
||||
|
||||
def _valid_tick_price(tick: Mapping[str, object], key: str) -> float | None:
|
||||
"""Return a positive finite float from tick[key], or None if invalid.
|
||||
|
||||
Accepts int, float, or numeric string values. Returns None when the key is
|
||||
missing, the value is None, non-numeric, NaN, infinite, zero, or negative.
|
||||
Booleans are treated as non-numeric and return None.
|
||||
"""
|
||||
value = tick.get(key)
|
||||
if value is None or isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, int | float):
|
||||
price = float(value)
|
||||
elif isinstance(value, str):
|
||||
try:
|
||||
price = float(value)
|
||||
except ValueError:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
if not isfinite(price) or price <= 0:
|
||||
return None
|
||||
return price
|
||||
|
||||
|
||||
def _success_retcodes(mt5: object) -> frozenset[int]:
|
||||
values = {
|
||||
value
|
||||
@@ -460,9 +490,10 @@ def _calculate_min_volume_if_affordable(
|
||||
msg = f"Invalid volume constraints for {symbol!r}."
|
||||
raise Mt5TradingError(msg)
|
||||
side = _normalize_order_side(order_side)
|
||||
tick = get_tick_snapshot(client, symbol)
|
||||
price = tick["ask"] if side == "BUY" else tick["bid"]
|
||||
if not isinstance(price, int | float) or price <= 0:
|
||||
price = _valid_tick_price(
|
||||
get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid"
|
||||
)
|
||||
if price is None:
|
||||
msg = f"Tick price is unavailable for {symbol!r}."
|
||||
raise Mt5TradingError(msg)
|
||||
order_type = (
|
||||
@@ -620,14 +651,14 @@ def estimate_order_margin(
|
||||
raise Mt5TradingError(msg)
|
||||
side = _normalize_order_side(order_side)
|
||||
tick = get_tick_snapshot(client, symbol)
|
||||
price = tick["ask"] if side == "BUY" else tick["bid"]
|
||||
if not isinstance(price, int | float) or price <= 0 or not isfinite(price):
|
||||
price = _valid_tick_price(tick, "ask" if side == "BUY" else "bid")
|
||||
if price is None:
|
||||
msg = f"Tick price is unavailable for {symbol!r}."
|
||||
raise Mt5TradingError(msg)
|
||||
order_type = (
|
||||
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
|
||||
)
|
||||
raw_margin = client.order_calc_margin(order_type, symbol, volume, float(price))
|
||||
raw_margin = client.order_calc_margin(order_type, symbol, volume, price)
|
||||
try:
|
||||
margin = float(raw_margin)
|
||||
except (TypeError, ValueError) as exc:
|
||||
@@ -681,22 +712,85 @@ def calculate_positions_margin(
|
||||
return total
|
||||
|
||||
|
||||
def calculate_positions_margin_by_symbol(
|
||||
client: Mt5TradingClient,
|
||||
*,
|
||||
symbols: Sequence[str],
|
||||
suppress_errors: bool = True,
|
||||
) -> dict[str, float]:
|
||||
"""Return per-symbol estimated margin for open positions.
|
||||
|
||||
Computes margin for each unique input symbol independently using the strict
|
||||
:func:`calculate_positions_margin` helper. Duplicates are deduplicated in
|
||||
first-seen order.
|
||||
|
||||
Args:
|
||||
client: Connected ``Mt5TradingClient`` instance.
|
||||
symbols: Symbols to compute margin for.
|
||||
suppress_errors: When ``True``, log and skip symbols that raise
|
||||
``Mt5TradingError``, ``Mt5RuntimeError``, or ``AttributeError``.
|
||||
When ``False``, re-raise the first failure.
|
||||
|
||||
Returns:
|
||||
Mapping of symbol to margin total in first-seen unique-symbol order.
|
||||
Returns an empty dict when ``symbols`` is empty or all symbols fail
|
||||
with ``suppress_errors=True``.
|
||||
|
||||
Raises:
|
||||
Mt5TradingError: When a symbol raises ``Mt5TradingError`` and
|
||||
``suppress_errors=False``.
|
||||
Mt5RuntimeError: When a symbol raises ``Mt5RuntimeError`` and
|
||||
``suppress_errors=False``.
|
||||
AttributeError: When a symbol raises ``AttributeError`` and
|
||||
``suppress_errors=False``.
|
||||
"""
|
||||
result: dict[str, float] = {}
|
||||
for symbol in dict.fromkeys(symbols):
|
||||
try:
|
||||
result[symbol] = calculate_positions_margin(client, symbols=[symbol])
|
||||
except (Mt5TradingError, Mt5RuntimeError, AttributeError) as exc:
|
||||
if not suppress_errors:
|
||||
raise
|
||||
_logger.warning("Skipping margin for %r: %s", symbol, exc)
|
||||
return result
|
||||
|
||||
|
||||
def calculate_positions_margin_safe(
|
||||
client: Mt5TradingClient,
|
||||
*,
|
||||
symbols: Sequence[str],
|
||||
) -> float:
|
||||
"""Return the total estimated margin for open positions across symbols.
|
||||
|
||||
Internally calls :func:`calculate_positions_margin_by_symbol` with
|
||||
``suppress_errors=True``. Failed symbols are silently skipped.
|
||||
|
||||
Args:
|
||||
client: Connected ``Mt5TradingClient`` instance.
|
||||
symbols: Symbols to include.
|
||||
|
||||
Returns:
|
||||
Sum of per-symbol margins; ``0.0`` when no symbols or all fail.
|
||||
"""
|
||||
return sum(
|
||||
calculate_positions_margin_by_symbol(client, symbols=symbols).values(),
|
||||
0.0,
|
||||
)
|
||||
|
||||
|
||||
def calculate_spread_ratio(client: Mt5TradingClient, symbol: str) -> float:
|
||||
"""Return ``(ask - bid) / ((ask + bid) / 2)`` for the latest tick.
|
||||
|
||||
Raises:
|
||||
Mt5TradingError: If bid or ask is unavailable or non-positive.
|
||||
Mt5TradingError: If bid or ask is unavailable.
|
||||
"""
|
||||
tick = get_tick_snapshot(client, symbol)
|
||||
bid = tick.get("bid")
|
||||
ask = tick.get("ask")
|
||||
if not isinstance(bid, int | float) or not isinstance(ask, int | float):
|
||||
bid = _valid_tick_price(tick, "bid")
|
||||
ask = _valid_tick_price(tick, "ask")
|
||||
if bid is None or ask is None:
|
||||
msg = f"Tick bid/ask is unavailable for {symbol!r}."
|
||||
raise Mt5TradingError(msg)
|
||||
if bid <= 0 or ask <= 0:
|
||||
msg = f"Tick bid/ask must be positive for {symbol!r}."
|
||||
raise Mt5TradingError(msg)
|
||||
return (float(ask) - float(bid)) / ((float(ask) + float(bid)) / 2.0)
|
||||
return (ask - bid) / ((ask + bid) / 2.0)
|
||||
|
||||
|
||||
def calculate_new_position_margin_ratio(
|
||||
@@ -719,9 +813,10 @@ def calculate_new_position_margin_ratio(
|
||||
margin = float(account.get("margin") or 0.0)
|
||||
if new_position_side is not None and new_position_volume > 0:
|
||||
side = _normalize_order_side(new_position_side)
|
||||
tick = get_tick_snapshot(client, symbol)
|
||||
price = tick["ask"] if side == "BUY" else tick["bid"]
|
||||
if not isinstance(price, int | float) or price <= 0:
|
||||
price = _valid_tick_price(
|
||||
get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid"
|
||||
)
|
||||
if price is None:
|
||||
msg = f"Tick price is unavailable for {symbol!r}."
|
||||
raise Mt5TradingError(msg)
|
||||
order_type = (
|
||||
@@ -779,28 +874,8 @@ def calculate_margin_and_volume(
|
||||
"SELL",
|
||||
)
|
||||
else:
|
||||
native_calculate_volume = getattr(client, "calculate_volume_by_margin", None)
|
||||
if callable(native_calculate_volume):
|
||||
buy_volume = float(
|
||||
cast(
|
||||
"float | int | str",
|
||||
native_calculate_volume(symbol, trade_margin, "BUY"),
|
||||
),
|
||||
)
|
||||
sell_volume = float(
|
||||
cast(
|
||||
"float | int | str",
|
||||
native_calculate_volume(symbol, trade_margin, "SELL"),
|
||||
),
|
||||
)
|
||||
else:
|
||||
buy_volume = calculate_volume_by_margin(client, symbol, trade_margin, "BUY")
|
||||
sell_volume = calculate_volume_by_margin(
|
||||
client,
|
||||
symbol,
|
||||
trade_margin,
|
||||
"SELL",
|
||||
)
|
||||
buy_volume = calculate_volume_by_margin(client, symbol, trade_margin, "BUY")
|
||||
sell_volume = calculate_volume_by_margin(client, symbol, trade_margin, "SELL")
|
||||
try:
|
||||
symbol_info = get_symbol_snapshot(client, symbol)
|
||||
volume_min = float(symbol_info.get("volume_min") or 0.0)
|
||||
@@ -829,7 +904,9 @@ def calculate_volume_by_margin(
|
||||
"""Calculate max normalized volume affordable for one side.
|
||||
|
||||
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:
|
||||
Mt5TradingError: If symbol volume constraints or tick data are invalid.
|
||||
@@ -844,9 +921,10 @@ def calculate_volume_by_margin(
|
||||
msg = f"Invalid volume constraints for {symbol!r}."
|
||||
raise Mt5TradingError(msg)
|
||||
side = _normalize_order_side(order_side)
|
||||
tick = get_tick_snapshot(client, symbol)
|
||||
price = tick["ask"] if side == "BUY" else tick["bid"]
|
||||
if not isinstance(price, int | float) or price <= 0:
|
||||
price = _valid_tick_price(
|
||||
get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid"
|
||||
)
|
||||
if price is None:
|
||||
msg = f"Tick price is unavailable for {symbol!r}."
|
||||
raise Mt5TradingError(msg)
|
||||
order_type = (
|
||||
@@ -855,11 +933,38 @@ def calculate_volume_by_margin(
|
||||
min_margin = float(client.order_calc_margin(order_type, symbol, volume_min, price))
|
||||
if min_margin <= 0 or min_margin > available_margin:
|
||||
return 0.0
|
||||
raw_volume = available_margin / min_margin * volume_min
|
||||
capped = min(raw_volume, volume_max) if volume_max > 0 else raw_volume
|
||||
steps = floor(((capped - volume_min) / volume_step) + 1e-12)
|
||||
normalized = volume_min + max(0, steps) * volume_step
|
||||
return round(normalized, 10) if normalized >= volume_min else 0.0
|
||||
lo = 0
|
||||
hi = int(
|
||||
max(
|
||||
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(
|
||||
@@ -895,11 +1000,11 @@ def determine_order_limits(
|
||||
_require_protective_ratio(take_profit_ratio, "take_profit_limit_ratio")
|
||||
normalized_side = _position_side_from_order_side(side)
|
||||
tick = get_tick_snapshot(client, symbol)
|
||||
entry_value = tick["ask"] if normalized_side == "long" else tick["bid"]
|
||||
if not isinstance(entry_value, int | float):
|
||||
entry_key = "ask" if normalized_side == "long" else "bid"
|
||||
entry = _valid_tick_price(tick, entry_key)
|
||||
if entry is None:
|
||||
msg = f"Tick price is unavailable for {symbol!r}."
|
||||
raise Mt5TradingError(msg)
|
||||
entry = float(entry_value)
|
||||
try:
|
||||
symbol_info = get_symbol_snapshot(client, symbol)
|
||||
except (AttributeError, KeyError, TypeError, ValueError):
|
||||
@@ -975,8 +1080,8 @@ def place_market_order(
|
||||
if not dry_run:
|
||||
ensure_symbol_selected(client, symbol)
|
||||
tick = get_tick_snapshot(client, symbol)
|
||||
price = tick["ask"] if side == "BUY" else tick["bid"]
|
||||
if not isinstance(price, int | float) or price <= 0:
|
||||
price = _valid_tick_price(tick, "ask" if side == "BUY" else "bid")
|
||||
if price is None:
|
||||
msg = f"Tick price is unavailable for {symbol!r}."
|
||||
raise Mt5TradingError(msg)
|
||||
request = {
|
||||
@@ -986,7 +1091,7 @@ def place_market_order(
|
||||
"type": (
|
||||
client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
|
||||
),
|
||||
"price": float(price),
|
||||
"price": price,
|
||||
"type_filling": _resolve_mt5_constant(
|
||||
client.mt5,
|
||||
"ORDER_FILLING",
|
||||
@@ -1202,6 +1307,92 @@ def fetch_latest_closed_rates_for_trading_client(
|
||||
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
|
||||
def mt5_trading_session(
|
||||
config: Mt5Config | None = None,
|
||||
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mt5cli"
|
||||
version = "0.8.3"
|
||||
version = "0.9.2"
|
||||
description = "Generic MT5 data and execution infrastructure for Python applications"
|
||||
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
||||
maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
||||
@@ -124,7 +124,6 @@ ignore = [
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"mt5cli/history.py" = ["TC003"]
|
||||
"tests/**/*.py" = [
|
||||
"DOC201", # Missing return documentation
|
||||
"DOC501", # Raised exception missing from docstring
|
||||
|
||||
@@ -44,6 +44,7 @@ from mt5cli import (
|
||||
export_dataframe_to_sqlite,
|
||||
fetch_latest_closed_rates,
|
||||
fetch_latest_closed_rates_for_trading_client,
|
||||
fetch_latest_closed_rates_indexed,
|
||||
granularity_name,
|
||||
is_recoverable_mt5_error,
|
||||
load_rate_data,
|
||||
@@ -734,3 +735,32 @@ class TestStableSdkContract:
|
||||
raise RuntimeError(message)
|
||||
|
||||
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"):
|
||||
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:
|
||||
"""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 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:
|
||||
"""Tests for the throttled incremental history updater."""
|
||||
|
||||
+685
-33
@@ -2,12 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from numpy import float64 as np_float64
|
||||
from numpy import int64 as np_int64
|
||||
from pdmt5 import Mt5RuntimeError, Mt5TradingClient, Mt5TradingError
|
||||
from pytest_mock import MockerFixture # noqa: TC002
|
||||
@@ -17,9 +19,12 @@ from mt5cli.trading import (
|
||||
MarginVolume,
|
||||
OrderExecutionResult,
|
||||
OrderLimits,
|
||||
_valid_tick_price, # type: ignore[reportPrivateUsage]
|
||||
calculate_margin_and_volume,
|
||||
calculate_new_position_margin_ratio,
|
||||
calculate_positions_margin,
|
||||
calculate_positions_margin_by_symbol,
|
||||
calculate_positions_margin_safe,
|
||||
calculate_spread_ratio,
|
||||
calculate_volume_by_margin,
|
||||
close_open_positions,
|
||||
@@ -29,6 +34,7 @@ from mt5cli.trading import (
|
||||
ensure_symbol_selected,
|
||||
estimate_order_margin,
|
||||
fetch_latest_closed_rates_for_trading_client,
|
||||
fetch_latest_closed_rates_indexed,
|
||||
get_account_snapshot,
|
||||
get_positions_frame,
|
||||
get_symbol_snapshot,
|
||||
@@ -120,12 +126,14 @@ class TestDetectPositionSide:
|
||||
class TestCalculateMarginAndVolume:
|
||||
"""Tests for calculate_margin_and_volume."""
|
||||
|
||||
def test_calculates_margin_budget_and_volumes(self) -> None:
|
||||
def test_calculates_margin_budget_and_volumes(self, mocker: MockerFixture) -> None:
|
||||
"""Test margin budget and buy/sell volumes are derived from ratios."""
|
||||
client = MagicMock()
|
||||
client.account_info_as_dict.return_value = {"margin_free": 1000.0}
|
||||
client.calculate_volume_by_margin.side_effect = [0.3, 0.2]
|
||||
client.symbol_info_as_dict.side_effect = AttributeError("missing")
|
||||
mock_calc_vol = mocker.patch(
|
||||
"mt5cli.trading.calculate_volume_by_margin", side_effect=[0.3, 0.2]
|
||||
)
|
||||
|
||||
result = calculate_margin_and_volume(
|
||||
client,
|
||||
@@ -144,8 +152,8 @@ class TestCalculateMarginAndVolume:
|
||||
"volume_max": 0.0,
|
||||
"volume_step": 0.0,
|
||||
}
|
||||
client.calculate_volume_by_margin.assert_any_call("EURUSD", 400.0, "BUY")
|
||||
client.calculate_volume_by_margin.assert_any_call("EURUSD", 400.0, "SELL")
|
||||
mock_calc_vol.assert_any_call(client, "EURUSD", 400.0, "BUY")
|
||||
mock_calc_vol.assert_any_call(client, "EURUSD", 400.0, "SELL")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("account_dict", "expected_margin_free"),
|
||||
@@ -159,11 +167,15 @@ class TestCalculateMarginAndVolume:
|
||||
self,
|
||||
account_dict: dict[str, float | None],
|
||||
expected_margin_free: float,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test missing or zero margin_free yields zero trade margin."""
|
||||
client = MagicMock()
|
||||
client.account_info_as_dict.return_value = account_dict
|
||||
client.calculate_volume_by_margin.return_value = 0.0
|
||||
client.symbol_info_as_dict.side_effect = AttributeError("missing")
|
||||
mock_calc_vol = mocker.patch(
|
||||
"mt5cli.trading.calculate_volume_by_margin", return_value=0.0
|
||||
)
|
||||
|
||||
result = calculate_margin_and_volume(
|
||||
client,
|
||||
@@ -173,14 +185,19 @@ class TestCalculateMarginAndVolume:
|
||||
)
|
||||
|
||||
assert result["margin_free"] == expected_margin_free
|
||||
client.calculate_volume_by_margin.assert_any_call("EURUSD", 0.0, "BUY")
|
||||
client.calculate_volume_by_margin.assert_any_call("EURUSD", 0.0, "SELL")
|
||||
_assert_close(result["buy_volume"], 0.0)
|
||||
_assert_close(result["sell_volume"], 0.0)
|
||||
mock_calc_vol.assert_any_call(client, "EURUSD", 0.0, "BUY")
|
||||
mock_calc_vol.assert_any_call(client, "EURUSD", 0.0, "SELL")
|
||||
|
||||
def test_clamps_negative_margin_free_to_zero(self) -> None:
|
||||
def test_clamps_negative_margin_free_to_zero(self, mocker: MockerFixture) -> None:
|
||||
"""Test negative margin_free is clamped to zero before sizing."""
|
||||
client = MagicMock()
|
||||
client.account_info_as_dict.return_value = {"margin_free": -500.0}
|
||||
client.calculate_volume_by_margin.return_value = 0.0
|
||||
client.symbol_info_as_dict.side_effect = AttributeError("missing")
|
||||
mock_calc_vol = mocker.patch(
|
||||
"mt5cli.trading.calculate_volume_by_margin", return_value=0.0
|
||||
)
|
||||
|
||||
result = calculate_margin_and_volume(
|
||||
client,
|
||||
@@ -189,10 +206,11 @@ class TestCalculateMarginAndVolume:
|
||||
preserved_margin_ratio=0.2,
|
||||
)
|
||||
|
||||
expected_margin_free = 0.0
|
||||
assert result["margin_free"] == expected_margin_free
|
||||
client.calculate_volume_by_margin.assert_any_call("EURUSD", 0.0, "BUY")
|
||||
client.calculate_volume_by_margin.assert_any_call("EURUSD", 0.0, "SELL")
|
||||
_assert_close(result["margin_free"], 0.0)
|
||||
_assert_close(result["buy_volume"], 0.0)
|
||||
_assert_close(result["sell_volume"], 0.0)
|
||||
mock_calc_vol.assert_any_call(client, "EURUSD", 0.0, "BUY")
|
||||
mock_calc_vol.assert_any_call(client, "EURUSD", 0.0, "SELL")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("unit_ratio", "preserved_ratio"),
|
||||
@@ -410,6 +428,55 @@ class TestDetermineOrderLimits:
|
||||
with pytest.raises(Mt5TradingError, match="Tick price is unavailable"):
|
||||
determine_order_limits(client, "EURUSD", "long")
|
||||
|
||||
def test_accepts_numeric_string_entry(self) -> None:
|
||||
"""Test numeric string ask/bid values are accepted as entry prices."""
|
||||
client = MagicMock()
|
||||
client.symbol_info_tick_as_dict.return_value = {
|
||||
"ask": "1.1010",
|
||||
"bid": "1.1000",
|
||||
}
|
||||
client.symbol_info_as_dict.return_value = {"digits": 4}
|
||||
|
||||
result = determine_order_limits(client, "EURUSD", "long")
|
||||
|
||||
_assert_close(result["entry"], 1.1010)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("side", "field", "bad_value"),
|
||||
[
|
||||
("long", "ask", float("nan")),
|
||||
("long", "ask", float("inf")),
|
||||
("long", "ask", float("-inf")),
|
||||
("long", "ask", 0.0),
|
||||
("long", "ask", -1.0),
|
||||
("long", "ask", True),
|
||||
("long", "ask", False),
|
||||
("long", "ask", "invalid"),
|
||||
("short", "bid", float("nan")),
|
||||
("short", "bid", float("inf")),
|
||||
("short", "bid", float("-inf")),
|
||||
("short", "bid", 0.0),
|
||||
("short", "bid", -1.0),
|
||||
("short", "bid", True),
|
||||
("short", "bid", False),
|
||||
("short", "bid", "invalid"),
|
||||
],
|
||||
)
|
||||
def test_rejects_invalid_entry_tick_values(
|
||||
self,
|
||||
side: str,
|
||||
field: str,
|
||||
bad_value: object,
|
||||
) -> None:
|
||||
"""Test invalid entry tick values raise Mt5TradingError."""
|
||||
tick: dict[str, object] = {"ask": 1.1, "bid": 1.0}
|
||||
tick[field] = bad_value
|
||||
client = MagicMock()
|
||||
client.symbol_info_tick_as_dict.return_value = tick
|
||||
|
||||
with pytest.raises(Mt5TradingError, match="Tick price is unavailable"):
|
||||
determine_order_limits(client, "EURUSD", side)
|
||||
|
||||
def test_rejects_stop_loss_inside_broker_stop_level(self) -> None:
|
||||
"""Test stop-loss prices closer than trade_stops_level raise Mt5TradingError."""
|
||||
client = MagicMock()
|
||||
@@ -776,6 +843,50 @@ class TestSnapshotsAndState:
|
||||
with pytest.raises(Mt5TradingError):
|
||||
calculate_spread_ratio(client, "EURUSD")
|
||||
|
||||
def test_calculate_spread_ratio_accepts_numeric_string_tick(self) -> None:
|
||||
"""Test numeric string bid/ask values are accepted."""
|
||||
client = MagicMock()
|
||||
client.symbol_info_tick_as_dict.return_value = {"bid": "99.0", "ask": "101.0"}
|
||||
|
||||
_assert_close(calculate_spread_ratio(client, "EURUSD"), 0.02)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "bad_value"),
|
||||
[
|
||||
("bid", None),
|
||||
("bid", float("nan")),
|
||||
("bid", float("inf")),
|
||||
("bid", float("-inf")),
|
||||
("bid", 0.0),
|
||||
("bid", -1.0),
|
||||
("bid", True),
|
||||
("bid", False),
|
||||
("bid", "invalid"),
|
||||
("ask", None),
|
||||
("ask", float("nan")),
|
||||
("ask", float("inf")),
|
||||
("ask", float("-inf")),
|
||||
("ask", 0.0),
|
||||
("ask", -1.0),
|
||||
("ask", True),
|
||||
("ask", False),
|
||||
("ask", "invalid"),
|
||||
],
|
||||
)
|
||||
def test_calculate_spread_ratio_rejects_invalid_tick_values(
|
||||
self,
|
||||
field: str,
|
||||
bad_value: object,
|
||||
) -> None:
|
||||
"""Test invalid bid/ask values raise Mt5TradingError."""
|
||||
tick: dict[str, object] = {"bid": 100.0, "ask": 100.0}
|
||||
tick[field] = bad_value
|
||||
client = MagicMock()
|
||||
client.symbol_info_tick_as_dict.return_value = tick
|
||||
|
||||
with pytest.raises(Mt5TradingError, match="Tick bid/ask is unavailable"):
|
||||
calculate_spread_ratio(client, "EURUSD")
|
||||
|
||||
|
||||
class TestNormalizeOrderVolume:
|
||||
"""Tests for normalize_order_volume."""
|
||||
@@ -1373,6 +1484,67 @@ class TestVolumeAndExecution:
|
||||
with pytest.raises(Mt5TradingError):
|
||||
calculate_volume_by_margin(client, "EURUSD", 100.0, "SELL")
|
||||
|
||||
def test_calculate_volume_by_margin_steps_down_when_margin_exceeds_budget(
|
||||
self,
|
||||
) -> None:
|
||||
"""Tiered margin: binary search returns the largest affordable step."""
|
||||
client = _mock_trade_client()
|
||||
client.symbol_info_as_dict.return_value = {
|
||||
"volume_min": 0.1,
|
||||
"volume_max": 1.0,
|
||||
"volume_step": 0.1,
|
||||
}
|
||||
client.symbol_info_tick_as_dict.return_value = {"ask": 100.0, "bid": 99.0}
|
||||
# min_margin (0.1): 25.0 -> hi=4.
|
||||
# Search: mid=2 (0.3)->75<=130, mid=3 (0.4)->100<=130, mid=4 (0.5)->150>130.
|
||||
client.order_calc_margin.side_effect = [25.0, 75.0, 100.0, 150.0]
|
||||
|
||||
result = calculate_volume_by_margin(client, "EURUSD", 130.0, "BUY")
|
||||
|
||||
_assert_close(result, 0.4)
|
||||
|
||||
def test_calculate_volume_by_margin_returns_zero_when_all_steps_unaffordable(
|
||||
self,
|
||||
) -> None:
|
||||
"""If all binary-search probes exceed budget, returns zero."""
|
||||
client = _mock_trade_client()
|
||||
client.symbol_info_as_dict.return_value = {
|
||||
"volume_min": 0.1,
|
||||
"volume_max": 0.5,
|
||||
"volume_step": 0.1,
|
||||
}
|
||||
client.symbol_info_tick_as_dict.return_value = {"ask": 100.0, "bid": 99.0}
|
||||
# min_margin (0.1): 10.0 -> hi=4.
|
||||
# Search: mid=2 (0.3)->150>130->hi=1, mid=0 (0.1)->150>130->hi=-1.
|
||||
client.order_calc_margin.side_effect = [10.0, 150.0, 150.0]
|
||||
|
||||
result = calculate_volume_by_margin(client, "EURUSD", 130.0, "BUY")
|
||||
|
||||
_assert_close(result, 0.0)
|
||||
|
||||
def test_calculate_volume_by_margin_binary_search_is_bounded(self) -> None:
|
||||
"""Binary search finds the largest affordable volume in O(log n) MT5 calls."""
|
||||
client = _mock_trade_client()
|
||||
client.symbol_info_as_dict.return_value = {
|
||||
"volume_min": 0.01,
|
||||
"volume_max": 1000.0,
|
||||
"volume_step": 0.01,
|
||||
}
|
||||
client.symbol_info_tick_as_dict.return_value = {"ask": 100.0, "bid": 99.0}
|
||||
# Steps 0-50000 cost 0.001 (affordable); steps 50001+ cost 2000.0 (not).
|
||||
# Total range: 99999 steps. Linear scan: ~50000 calls; binary search: ~17.
|
||||
affordable_step = 50000
|
||||
|
||||
def _margin(_ot: int, _sym: str, volume: float, _px: float) -> float:
|
||||
step = round((volume - 0.01) / 0.01)
|
||||
return 0.001 if step <= affordable_step else 2000.0
|
||||
|
||||
client.order_calc_margin.side_effect = _margin
|
||||
result = calculate_volume_by_margin(client, "EURUSD", 200.0, "BUY")
|
||||
|
||||
_assert_close(result, 500.01) # 0.01 + 50000 * 0.01
|
||||
assert client.order_calc_margin.call_count <= 25
|
||||
|
||||
def test_calculate_margin_and_volume_without_native_helper(self) -> None:
|
||||
"""Test margin helper uses module volume calculation when needed."""
|
||||
|
||||
@@ -1399,7 +1571,7 @@ class TestVolumeAndExecution:
|
||||
) -> float:
|
||||
assert order_type in {10, 11}
|
||||
assert symbol == "EURUSD"
|
||||
_assert_close(volume, 0.1)
|
||||
assert 0.1 <= volume <= 1.0
|
||||
_assert_close(price, 100.0)
|
||||
return 10.0
|
||||
|
||||
@@ -1551,18 +1723,34 @@ class TestVolumeAndExecution:
|
||||
preserved_margin_ratio=0.0,
|
||||
)
|
||||
|
||||
def test_calculate_margin_and_volume_positive_ratio_uses_existing_behavior(
|
||||
def test_calculate_margin_and_volume_positive_ratio_avoids_oversized_native_volume(
|
||||
self,
|
||||
) -> None:
|
||||
"""Test positive unit ratios keep proportional native sizing."""
|
||||
"""Regression: module path is used even when native helper is present.
|
||||
|
||||
The pdmt5 linear estimate (floor(40/10)*0.1 = 0.4) would overstate
|
||||
affordable volume because order_calc_margin returns 45.0 for 0.4 lots,
|
||||
exceeding the 40.0 budget. The module's verified binary search returns
|
||||
0.3 (margin=30.0), which is safe.
|
||||
"""
|
||||
client = _mock_trade_client()
|
||||
client.account_info_as_dict.return_value = {"margin_free": 100.0}
|
||||
client.calculate_volume_by_margin.side_effect = [0.4, 0.3]
|
||||
client.symbol_info_as_dict.return_value = {
|
||||
"volume_min": 0.1,
|
||||
"volume_max": 1.0,
|
||||
"volume_step": 0.1,
|
||||
}
|
||||
client.symbol_info_tick_as_dict.return_value = {"ask": 100.0, "bid": 99.0}
|
||||
client.calculate_volume_by_margin.return_value = 0.4 # pdmt5 linear oversized
|
||||
|
||||
def _mock_calc_margin(
|
||||
_order_type: int, _symbol: str, volume: float, _price: float
|
||||
) -> float:
|
||||
if volume <= 0.3:
|
||||
return round(volume * 100.0, 10) # 0.1→10, 0.2→20, 0.3→30
|
||||
return round(volume * 112.5, 10) # 0.4→45 — exceeds 40.0 budget
|
||||
|
||||
client.order_calc_margin.side_effect = _mock_calc_margin
|
||||
|
||||
result = calculate_margin_and_volume(
|
||||
client,
|
||||
@@ -1572,30 +1760,25 @@ class TestVolumeAndExecution:
|
||||
)
|
||||
|
||||
_assert_close(result["trade_margin"], 40.0)
|
||||
_assert_close(result["buy_volume"], 0.4)
|
||||
_assert_close(result["buy_volume"], 0.3)
|
||||
_assert_close(result["sell_volume"], 0.3)
|
||||
client.calculate_volume_by_margin.assert_any_call("EURUSD", 40.0, "BUY")
|
||||
client.calculate_volume_by_margin.assert_any_call("EURUSD", 40.0, "SELL")
|
||||
client.calculate_volume_by_margin.assert_not_called()
|
||||
|
||||
def test_calculate_margin_and_volume_handles_missing_symbol_snapshot(
|
||||
def test_calculate_margin_and_volume_positive_ratio_raises_on_missing_symbol_info(
|
||||
self,
|
||||
) -> None:
|
||||
"""Test missing symbol metadata falls back to zero volume constraints."""
|
||||
"""Test missing symbol info propagates an error when ratio is positive."""
|
||||
client = MagicMock()
|
||||
client.account_info_as_dict.return_value = {"margin_free": 1000.0}
|
||||
client.calculate_volume_by_margin.side_effect = [0.3, 0.2]
|
||||
client.symbol_info_as_dict.side_effect = AttributeError("missing")
|
||||
|
||||
result = calculate_margin_and_volume(
|
||||
client,
|
||||
"EURUSD",
|
||||
unit_margin_ratio=0.5,
|
||||
preserved_margin_ratio=0.2,
|
||||
)
|
||||
|
||||
_assert_close(result["volume_min"], 0.0)
|
||||
_assert_close(result["volume_max"], 0.0)
|
||||
_assert_close(result["volume_step"], 0.0)
|
||||
with pytest.raises(AttributeError, match="missing"):
|
||||
calculate_margin_and_volume(
|
||||
client,
|
||||
"EURUSD",
|
||||
unit_margin_ratio=0.5,
|
||||
preserved_margin_ratio=0.2,
|
||||
)
|
||||
|
||||
def test_new_position_margin_ratio_adds_hypothetical_margin(self) -> None:
|
||||
"""Test hypothetical order margin is added to account margin."""
|
||||
@@ -2627,3 +2810,472 @@ class TestFetchLatestClosedRatesForTradingClient:
|
||||
)
|
||||
|
||||
client.fetch_latest_rates_as_df.assert_not_called()
|
||||
|
||||
def test_returns_range_index_and_time_column_for_backward_compat(self) -> None:
|
||||
"""Test original helper returns RangeIndex with a time column."""
|
||||
client = MagicMock()
|
||||
client.fetch_latest_rates_as_df.return_value = pd.DataFrame(
|
||||
{"time": [1700000000, 1700003600, 1700007200], "close": [1.1, 1.2, 1.3]},
|
||||
)
|
||||
|
||||
result = fetch_latest_closed_rates_for_trading_client(
|
||||
client,
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=2,
|
||||
)
|
||||
|
||||
assert isinstance(result.index, pd.RangeIndex)
|
||||
assert "time" in result.columns
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
class TestFetchLatestClosedRatesIndexed:
|
||||
"""Tests for fetch_latest_closed_rates_indexed."""
|
||||
|
||||
def test_converts_epoch_seconds_to_utc_datetime_index(
|
||||
self, mocker: MockerFixture
|
||||
) -> None:
|
||||
"""Test integer epoch second timestamps become a UTC DatetimeIndex."""
|
||||
frame = pd.DataFrame(
|
||||
{"time": [1700000000, 1700003600], "close": [1.1, 1.2]},
|
||||
)
|
||||
mocker.patch(
|
||||
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
||||
return_value=frame,
|
||||
)
|
||||
|
||||
result = fetch_latest_closed_rates_indexed(
|
||||
MagicMock(),
|
||||
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 str(result.index.tz) == "UTC"
|
||||
assert "time" not in result.columns
|
||||
assert list(result["close"]) == [1.1, 1.2]
|
||||
|
||||
def test_converts_float_epoch_seconds_to_utc_datetime_index(
|
||||
self, mocker: MockerFixture
|
||||
) -> None:
|
||||
"""Test float64 epoch second timestamps (after concat/NA upcast) become UTC."""
|
||||
frame = pd.DataFrame(
|
||||
{"time": [1700000000.0, 1700003600.0], "close": [1.1, 1.2]},
|
||||
)
|
||||
mocker.patch(
|
||||
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
||||
return_value=frame,
|
||||
)
|
||||
|
||||
result = fetch_latest_closed_rates_indexed(
|
||||
MagicMock(),
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=2,
|
||||
)
|
||||
|
||||
assert isinstance(result.index, pd.DatetimeIndex)
|
||||
assert result.index.tz is not None
|
||||
assert str(result.index.tz) == "UTC"
|
||||
assert result.index[0].year == 2023
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"timestamps",
|
||||
[
|
||||
[1700000000, 1700003600],
|
||||
[1700000000.0, 1700003600.0],
|
||||
[np_int64(1700000000), np_int64(1700003600)],
|
||||
[np_float64(1700000000.0), np_float64(1700003600.0)],
|
||||
],
|
||||
ids=["integers", "floats", "numpy-integers", "numpy-floats"],
|
||||
)
|
||||
def test_converts_object_numeric_epoch_seconds_to_utc_datetime_index(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
timestamps: list[int] | list[float] | list[np_int64] | list[np_float64],
|
||||
) -> None:
|
||||
"""Test object-dtype real numbers are interpreted as epoch seconds."""
|
||||
frame = pd.DataFrame(
|
||||
{
|
||||
"time": pd.Series(timestamps, dtype=object),
|
||||
"close": [1.1, 1.2],
|
||||
},
|
||||
)
|
||||
mocker.patch(
|
||||
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
||||
return_value=frame,
|
||||
)
|
||||
|
||||
result = fetch_latest_closed_rates_indexed(
|
||||
MagicMock(), symbol="EURUSD", granularity="M1", count=2
|
||||
)
|
||||
|
||||
assert list(result.index) == list(
|
||||
pd.to_datetime([1700000000, 1700003600], unit="s", utc=True)
|
||||
)
|
||||
|
||||
def test_parses_mixed_datetime_like_strings(self, mocker: MockerFixture) -> None:
|
||||
"""Test object-dtype datetime strings retain datetime-like parsing."""
|
||||
timestamps = ["2024-01-01T00:00:00Z", "2024-01-01T01:00:00+01:00"]
|
||||
frame = pd.DataFrame({"time": timestamps, "close": [1.1, 1.2]})
|
||||
mocker.patch(
|
||||
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
||||
return_value=frame,
|
||||
)
|
||||
|
||||
result = fetch_latest_closed_rates_indexed(
|
||||
MagicMock(), symbol="EURUSD", granularity="M1", count=2
|
||||
)
|
||||
|
||||
assert list(result.index) == list(pd.to_datetime(timestamps, utc=True))
|
||||
|
||||
def test_does_not_treat_bool_as_epoch_seconds(self, mocker: MockerFixture) -> None:
|
||||
"""Test bool timestamps do not enter the numeric epoch-seconds path."""
|
||||
frame = pd.DataFrame(
|
||||
{"time": pd.Series([True, False], dtype=object), "close": [1.1, 1.2]},
|
||||
)
|
||||
mocker.patch(
|
||||
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
||||
return_value=frame,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="invalid or unparseable time data"):
|
||||
fetch_latest_closed_rates_indexed(
|
||||
MagicMock(), symbol="EURUSD", granularity="M1", count=2
|
||||
)
|
||||
|
||||
def test_converts_naive_datetime_to_utc_datetime_index(
|
||||
self, mocker: MockerFixture
|
||||
) -> None:
|
||||
"""Test timezone-naive datetime values are localized to UTC."""
|
||||
from datetime import datetime # noqa: PLC0415
|
||||
|
||||
frame = pd.DataFrame(
|
||||
{
|
||||
"time": [datetime(2024, 1, 1, 0, 0), datetime(2024, 1, 1, 1, 0)], # noqa: DTZ001
|
||||
"close": [1.1, 1.2],
|
||||
},
|
||||
)
|
||||
mocker.patch(
|
||||
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
||||
return_value=frame,
|
||||
)
|
||||
|
||||
result = fetch_latest_closed_rates_indexed(
|
||||
MagicMock(),
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=2,
|
||||
)
|
||||
|
||||
assert isinstance(result.index, pd.DatetimeIndex)
|
||||
assert result.index.tz is not None
|
||||
assert str(result.index.tz) == "UTC"
|
||||
assert result.index[0].year == 2024
|
||||
|
||||
def test_converts_aware_datetime_to_utc(self, mocker: MockerFixture) -> None:
|
||||
"""Test timezone-aware datetime values are converted to UTC."""
|
||||
from datetime import datetime, timedelta, timezone # noqa: PLC0415
|
||||
|
||||
tz_plus5 = timezone(timedelta(hours=5))
|
||||
frame = pd.DataFrame(
|
||||
{
|
||||
"time": [datetime(2024, 1, 1, 5, 0, tzinfo=tz_plus5)],
|
||||
"close": [1.1],
|
||||
},
|
||||
)
|
||||
mocker.patch(
|
||||
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
||||
return_value=frame,
|
||||
)
|
||||
|
||||
result = fetch_latest_closed_rates_indexed(
|
||||
MagicMock(),
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=1,
|
||||
)
|
||||
|
||||
assert isinstance(result.index, pd.DatetimeIndex)
|
||||
assert str(result.index.tz) == "UTC"
|
||||
assert result.index[0].hour == 0
|
||||
|
||||
def test_raises_on_missing_time_column(self, mocker: MockerFixture) -> None:
|
||||
"""Test missing time column after the underlying fetch raises ValueError."""
|
||||
mocker.patch(
|
||||
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
||||
return_value=pd.DataFrame({"close": [1.1]}),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="missing a time column"):
|
||||
fetch_latest_closed_rates_indexed(
|
||||
MagicMock(),
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=1,
|
||||
)
|
||||
|
||||
def test_raises_on_unparseable_time_column(self, mocker: MockerFixture) -> None:
|
||||
"""Test unparseable time data raises a clear ValueError."""
|
||||
mocker.patch(
|
||||
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
||||
return_value=pd.DataFrame({"time": ["not-a-date"], "close": [1.1]}),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="invalid or unparseable time data"):
|
||||
fetch_latest_closed_rates_indexed(
|
||||
MagicMock(),
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=1,
|
||||
)
|
||||
|
||||
def test_raises_on_nat_time_column(self, mocker: MockerFixture) -> None:
|
||||
"""Test NaT in the time column raises ValueError instead of silently passing."""
|
||||
mocker.patch(
|
||||
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
||||
return_value=pd.DataFrame(
|
||||
{"time": [1700000000, None], "close": [1.1, 1.2]},
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match=r"missing.*NaT.*timestamp"):
|
||||
fetch_latest_closed_rates_indexed(
|
||||
MagicMock(),
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=2,
|
||||
)
|
||||
|
||||
def test_drops_time_column_and_sets_index(self, mocker: MockerFixture) -> None:
|
||||
"""Test the returned DataFrame has the DatetimeIndex and no time column."""
|
||||
frame = pd.DataFrame(
|
||||
{
|
||||
"time": [1700000000, 1700003600],
|
||||
"open": [1.0, 1.1],
|
||||
"close": [1.1, 1.2],
|
||||
},
|
||||
)
|
||||
mocker.patch(
|
||||
"mt5cli.trading.fetch_latest_closed_rates_for_trading_client",
|
||||
return_value=frame,
|
||||
)
|
||||
|
||||
result = fetch_latest_closed_rates_indexed(
|
||||
MagicMock(),
|
||||
symbol="EURUSD",
|
||||
granularity="M1",
|
||||
count=2,
|
||||
)
|
||||
|
||||
assert "time" not in result.columns
|
||||
assert "open" in result.columns
|
||||
assert "close" in result.columns
|
||||
assert isinstance(result.index, pd.DatetimeIndex)
|
||||
|
||||
|
||||
class TestValidTickPrice:
|
||||
"""Tests for the _valid_tick_price internal helper (#49)."""
|
||||
|
||||
def test_valid_positive_float(self) -> None:
|
||||
"""Returns a positive float value unchanged."""
|
||||
_assert_close(_valid_tick_price({"bid": 1.1000}, "bid"), 1.1000)
|
||||
|
||||
def test_valid_positive_int(self) -> None:
|
||||
"""Accepts an integer value and returns it as float."""
|
||||
result = _valid_tick_price({"bid": 2}, "bid")
|
||||
_assert_close(result, 2.0)
|
||||
assert isinstance(result, float)
|
||||
|
||||
def test_valid_numeric_string(self) -> None:
|
||||
"""Accepts a numeric string and returns the parsed float."""
|
||||
_assert_close(_valid_tick_price({"bid": "1.5"}, "bid"), 1.5)
|
||||
|
||||
def test_missing_key(self) -> None:
|
||||
"""Returns None when the key is absent from the tick dict."""
|
||||
assert _valid_tick_price({}, "bid") is None
|
||||
|
||||
def test_none_value(self) -> None:
|
||||
"""Returns None when the stored value is None."""
|
||||
assert _valid_tick_price({"bid": None}, "bid") is None
|
||||
|
||||
def test_invalid_string(self) -> None:
|
||||
"""Returns None for a non-numeric string."""
|
||||
assert _valid_tick_price({"bid": "not_a_number"}, "bid") is None
|
||||
|
||||
def test_nan(self) -> None:
|
||||
"""Returns None for a NaN float."""
|
||||
assert _valid_tick_price({"bid": float("nan")}, "bid") is None
|
||||
|
||||
def test_positive_infinity(self) -> None:
|
||||
"""Returns None for positive infinity."""
|
||||
assert _valid_tick_price({"bid": float("inf")}, "bid") is None
|
||||
|
||||
def test_negative_infinity(self) -> None:
|
||||
"""Returns None for negative infinity."""
|
||||
assert _valid_tick_price({"bid": float("-inf")}, "bid") is None
|
||||
|
||||
def test_zero(self) -> None:
|
||||
"""Returns None for zero (not a valid price)."""
|
||||
assert _valid_tick_price({"bid": 0.0}, "bid") is None
|
||||
|
||||
def test_negative_value(self) -> None:
|
||||
"""Returns None for a negative price."""
|
||||
assert _valid_tick_price({"bid": -1.0}, "bid") is None
|
||||
|
||||
def test_unsupported_type(self) -> None:
|
||||
"""Returns None for unsupported value types such as list."""
|
||||
assert _valid_tick_price({"bid": [1.0]}, "bid") is None
|
||||
|
||||
|
||||
class TestCalculatePositionsMarginBySymbol:
|
||||
"""Tests for calculate_positions_margin_by_symbol (#50)."""
|
||||
|
||||
def test_all_symbols_succeed(self, mocker: MockerFixture) -> None:
|
||||
"""Returns one entry per symbol in first-seen order when all calls succeed."""
|
||||
client = _mock_trade_client()
|
||||
mocker.patch(
|
||||
"mt5cli.trading.calculate_positions_margin",
|
||||
side_effect=[12.5, 30.0],
|
||||
)
|
||||
|
||||
result = calculate_positions_margin_by_symbol(
|
||||
client, symbols=["EURUSD", "GBPUSD"]
|
||||
)
|
||||
|
||||
assert list(result.keys()) == ["EURUSD", "GBPUSD"]
|
||||
_assert_close(result["EURUSD"], 12.5)
|
||||
_assert_close(result["GBPUSD"], 30.0)
|
||||
|
||||
def test_one_symbol_fails_suppress_errors_true(
|
||||
self, mocker: MockerFixture, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Skips the failing symbol, emits a warning, and returns the successful one."""
|
||||
client = _mock_trade_client()
|
||||
mocker.patch(
|
||||
"mt5cli.trading.calculate_positions_margin",
|
||||
side_effect=[Mt5TradingError("tick unavailable"), 30.0],
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="mt5cli.trading"):
|
||||
result = calculate_positions_margin_by_symbol(
|
||||
client, symbols=["EURUSD", "GBPUSD"], suppress_errors=True
|
||||
)
|
||||
|
||||
assert result == {"GBPUSD": 30.0}
|
||||
assert any(
|
||||
"EURUSD" in record.getMessage() and record.levelno == logging.WARNING
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exc",
|
||||
[
|
||||
Mt5TradingError("trading error"),
|
||||
Mt5RuntimeError("runtime error"),
|
||||
AttributeError("missing attr"),
|
||||
],
|
||||
)
|
||||
def test_one_symbol_fails_suppress_errors_false(
|
||||
self, mocker: MockerFixture, exc: Exception
|
||||
) -> None:
|
||||
"""Re-raises the first failure for each caught exception type."""
|
||||
client = _mock_trade_client()
|
||||
mocker.patch(
|
||||
"mt5cli.trading.calculate_positions_margin",
|
||||
side_effect=[exc, 30.0],
|
||||
)
|
||||
|
||||
with pytest.raises(type(exc)):
|
||||
calculate_positions_margin_by_symbol(
|
||||
client, symbols=["EURUSD", "GBPUSD"], suppress_errors=False
|
||||
)
|
||||
|
||||
def test_all_symbols_fail_suppress_errors_true(self, mocker: MockerFixture) -> None:
|
||||
"""Returns an empty dict when all symbols fail with suppress_errors=True."""
|
||||
client = _mock_trade_client()
|
||||
mocker.patch(
|
||||
"mt5cli.trading.calculate_positions_margin",
|
||||
side_effect=[Mt5TradingError("err1"), Mt5TradingError("err2")],
|
||||
)
|
||||
|
||||
result = calculate_positions_margin_by_symbol(
|
||||
client, symbols=["EURUSD", "GBPUSD"], suppress_errors=True
|
||||
)
|
||||
|
||||
assert result == {}
|
||||
|
||||
def test_empty_symbol_list(self, mocker: MockerFixture) -> None:
|
||||
"""Returns an empty dict for an empty input list without any broker calls."""
|
||||
client = _mock_trade_client()
|
||||
mock_calc = mocker.patch("mt5cli.trading.calculate_positions_margin")
|
||||
result = calculate_positions_margin_by_symbol(client, symbols=[])
|
||||
assert result == {}
|
||||
mock_calc.assert_not_called()
|
||||
|
||||
def test_duplicate_symbols_preserve_first_seen_order(
|
||||
self, mocker: MockerFixture
|
||||
) -> None:
|
||||
"""Processes each unique symbol once in first-seen order."""
|
||||
client = _mock_trade_client()
|
||||
mock_calc = mocker.patch(
|
||||
"mt5cli.trading.calculate_positions_margin",
|
||||
return_value=12.5,
|
||||
)
|
||||
|
||||
result = calculate_positions_margin_by_symbol(
|
||||
client, symbols=["EURUSD", "GBPUSD", "EURUSD"]
|
||||
)
|
||||
|
||||
assert list(result.keys()) == ["EURUSD", "GBPUSD"]
|
||||
assert mock_calc.call_count == 2
|
||||
|
||||
|
||||
class TestCalculatePositionsMarginSafe:
|
||||
"""Tests for calculate_positions_margin_safe (#50)."""
|
||||
|
||||
def test_returns_summed_total(self, mocker: MockerFixture) -> None:
|
||||
"""Returns the sum of all per-symbol margins."""
|
||||
client = _mock_trade_client()
|
||||
mocker.patch(
|
||||
"mt5cli.trading.calculate_positions_margin",
|
||||
side_effect=[12.5, 30.0],
|
||||
)
|
||||
|
||||
total = calculate_positions_margin_safe(client, symbols=["EURUSD", "GBPUSD"])
|
||||
|
||||
_assert_close(total, 42.5)
|
||||
|
||||
def test_partial_failure_skips_and_sums(self, mocker: MockerFixture) -> None:
|
||||
"""Sums only successful margins when one symbol raises."""
|
||||
client = _mock_trade_client()
|
||||
mocker.patch(
|
||||
"mt5cli.trading.calculate_positions_margin",
|
||||
side_effect=[Mt5TradingError("tick unavailable"), 30.0],
|
||||
)
|
||||
|
||||
total = calculate_positions_margin_safe(client, symbols=["EURUSD", "GBPUSD"])
|
||||
|
||||
_assert_close(total, 30.0)
|
||||
|
||||
def test_all_symbols_fail_returns_zero(self, mocker: MockerFixture) -> None:
|
||||
"""Returns 0.0 when every symbol raises."""
|
||||
client = _mock_trade_client()
|
||||
mocker.patch(
|
||||
"mt5cli.trading.calculate_positions_margin",
|
||||
side_effect=[Mt5TradingError("err1"), Mt5RuntimeError("err2")],
|
||||
)
|
||||
|
||||
total = calculate_positions_margin_safe(client, symbols=["EURUSD", "GBPUSD"])
|
||||
|
||||
_assert_close(total, 0.0)
|
||||
|
||||
def test_empty_symbols_returns_zero(self) -> None:
|
||||
"""Returns 0.0 for an empty symbol list."""
|
||||
client = _mock_trade_client()
|
||||
total = calculate_positions_margin_safe(client, symbols=[])
|
||||
_assert_close(total, 0.0)
|
||||
|
||||
Reference in New Issue
Block a user