Compare commits

...

9 Commits

Author SHA1 Message Date
Daichi Narushima 9ac3b885c3 test: add explicit unit tests for calculate_positions_margin_by_symbol and calculate_positions_margin_safe (#50) (#53)
* test: add explicit unit tests for calculate_positions_margin_by_symbol and calculate_positions_margin_safe (#50)

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 07:30:02 +00:00
Daichi Narushima 8e53212a24 fix: always use mt5cli calculate_volume_by_margin to prevent LACK OF FUNDS (#48) 2026-06-23 14:01:46 +09:00
Daichi Narushima b878a61c07 fix: re-verify normalized volume margin in calculate_volume_by_margin (#46)
* fix: re-verify normalized volume margin before returning from calculate_volume_by_margin

For CFDs, index products, and tiered-margin instruments, the initial
min-lot margin estimate can be optimistic; the normalized stepped volume
may require more margin than available_margin.  After computing the
normalized volume, step down by volume_step until order_calc_margin
confirms affordability, or return 0.0 if no step is affordable.

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

* test: fix ruff line-length violations in calculate_volume_by_margin tests

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

* fix: use integer step index and add actual>0 guard in calculate_volume_by_margin

Replace float-subtraction loop with integer step index to eliminate
accumulation rounding error and add `actual > 0` guard so a broker
returning zero/negative margin is never accepted as affordable.
Inline `capped` to keep local-variable count within Ruff PLR0914 limit.
Update docstring to reflect re-verification behaviour and 0.0 fallback.
Tighten test assertion from `volume > 0` to the symbol's valid range.

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

* Bump version to v0.9.1

* perf: replace linear step-down scan with binary search in calculate_volume_by_margin

Resolves the P2 review finding: the previous O(n) loop called
order_calc_margin once per volume step, making sizing appear hung for
symbols with a large step range or small volume_step.

Binary search over the integer step index finds the largest affordable
step in O(log n) IPC calls (≈17 for a 99 999-step range vs up to 99 999
in the worst case). Monotonicity of broker margin with volume is assumed,
which holds for standard linear margin schedules.

To stay within the PLR0914 local-variable limit the steps variable is
inlined into hi and the tick temporary is eliminated by accessing the
snapshot dict directly. Error messages still go via msg to satisfy EM102.

Two existing tests are updated to match the binary-search call sequence.
A new regression test (volume_min=0.01, volume_max=1000.0) configures
a tiered-margin mock with its threshold at step 50000 and asserts that
the total order_calc_margin call count does not exceed 25.

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

* chore: remove obsolete TC003 per-file-ignore for history.py

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

---------

Co-authored-by: agent <agent@localhost>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 04:40:00 +09:00
dceoy 0610ea732c fix: handle NumPy object rate timestamps 2026-06-22 23:01:31 +09:00
Daichi Narushima 82a39731ed feat: add fetch_latest_closed_rates_indexed and allow_whole_dollar_env opt-in (#45)
* feat: add fetch_latest_closed_rates_indexed and allow_whole_dollar_env opt-in (#43, #44)

Closes #43: add fetch_latest_closed_rates_indexed(client, *, symbol,
granularity, count) -> pd.DataFrame to mt5cli/trading.py. Internally
reuses fetch_latest_closed_rates_for_trading_client(), converts the
"time" column to a UTC-aware DatetimeIndex named "time", and drops the
original column. Exported from trading.__all__, mt5cli.__init__, and
STABLE_SDK_EXPORTS.

Closes #44: extend substitute_env_placeholders() with opt-in
allow_whole_dollar_env=False that expands whole-value $ENV_NAME strings
(entire string must be exactly $IDENTIFIER). Threaded through
build_config(), resolve_account_spec(), and resolve_account_specs() with
the same default=False. Partial strings like "plan$pass", "abc$ENV", or
"$ENV-suffix" are never expanded.

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

* chore: align Markdown table columns in docs and skill file

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

* fix: treat numeric (float64) epoch seconds as UTC in _rate_time_to_utc

After DataFrame concat or NA upcast the time column becomes float64, which
is still epoch seconds. Using is_numeric_dtype instead of is_integer_dtype
fixes the silent misalignment. Using series.to_numpy() before passing to
pd.to_datetime avoids the redundant pd.DatetimeIndex() wrapper and aligns
with how existing rate-time normalization in schemas.py handles numeric
timestamps.

Add test_converts_float_epoch_seconds_to_utc_datetime_index to cover the
regression. Add a doc note clarifying that build_config cannot expand
login since that parameter is int | None.

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

* fix: reject NaT values after rate timestamp conversion in _rate_time_to_utc

pd.to_datetime() silently produces NaT for None/NaN inputs rather than
raising, so the function could return a DatetimeIndex containing NaT
despite documenting invalid timestamps as a ValueError. Check any(idx.isna())
after conversion and raise with a clear message.

Add test_raises_on_nat_time_column to cover the regression.

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

* Bump version to v0.9.0

* fix: handle object numeric rate timestamps

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-22 22:52:19 +09:00
13 changed files with 1319 additions and 144 deletions
+2 -2
View File
@@ -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
View File
@@ -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.
+22
View File
@@ -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
View File
@@ -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
+6
View File
@@ -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",
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+30
View File
@@ -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
+185
View File
@@ -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
View File
@@ -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)
Generated
+1 -1
View File
@@ -487,7 +487,7 @@ wheels = [
[[package]]
name = "mt5cli"
version = "0.8.3"
version = "0.9.2"
source = { editable = "." }
dependencies = [
{ name = "click" },