Compare commits

...

1 Commits

Author SHA1 Message Date
Daichi Narushima 292fac899a Add generic trading helpers and reduce public API tiers (#58)
* feat: add generic trading helpers and API tiers

* Bump version to v0.9.3

* fix: require symbol digits for trailing stops

* fix: allow side-specific trailing stop ticks

* test: enforce complete public export tiers

* docs: align public contract tiers

* refactor: remove legacy public supports
2026-06-24 01:58:32 +09:00
13 changed files with 970 additions and 179 deletions
+3 -3
View File
@@ -31,7 +31,7 @@ pip install -U mt5cli MetaTrader5
## Python API (downstream packages) ## Python API (downstream packages)
Import `MT5Client` for generic MT5 data access, schema normalization, and optional order primitives. `Mt5CliClient` remains available as a backward-compatible alias. Import `MT5Client` for generic MT5 data access, schema normalization, and optional order primitives.
```python ```python
from datetime import UTC, datetime from datetime import UTC, datetime
@@ -250,7 +250,7 @@ eurusd_m1 = rates["EURUSD", "M1"] # closed bars only
- **Credential resolution**: use `resolve_account_spec()` / `resolve_account_specs()` to merge explicit override values over `AccountSpec` fields and expand `${ENV_VAR}` placeholders (via `substitute_env_placeholders()`), raising `ValueError` for missing variables. This keeps secrets out of plan/config files without coupling to any strategy code. - **Credential resolution**: use `resolve_account_spec()` / `resolve_account_specs()` to merge explicit override values over `AccountSpec` fields and expand `${ENV_VAR}` placeholders (via `substitute_env_placeholders()`), raising `ValueError` for missing variables. This keeps secrets out of plan/config files without coupling to any strategy code.
- **Throttled history updates**: use `ThrottledHistoryUpdater` to wrap `update_history()` with a minimum `interval_seconds` between successful runs (monotonic clock). Call `should_update()` / `update(client, symbols)` from an application loop; errors propagate by default, or pass `suppress_errors=True` to swallow recoverable `Mt5*Error`, `sqlite3.Error`, `ValueError`, `OSError`, and MT5 client capability errors for history API methods without advancing the throttle (other `AttributeError` / `TypeError` values always propagate). Pass `update_backend` to inject a custom history update callable (same keyword arguments as `update_history`) instead of monkey-patching `mt5cli.sdk.update_history`. - **Throttled history updates**: use `ThrottledHistoryUpdater` to wrap `update_history()` with a minimum `interval_seconds` between successful runs (monotonic clock). Call `should_update()` / `update(client, symbols)` from an application loop; errors propagate by default, or pass `suppress_errors=True` to swallow recoverable `Mt5*Error`, `sqlite3.Error`, `ValueError`, `OSError`, and MT5 client capability errors for history API methods without advancing the throttle (other `AttributeError` / `TypeError` values always propagate). Pass `update_backend` to inject a custom history update callable (same keyword arguments as `update_history`) instead of monkey-patching `mt5cli.sdk.update_history`.
- **Trading session helpers**: use `mt5_trading_session()` for a trading-capable `pdmt5.Mt5TradingClient` that initializes/logs in via `Mt5Config.path` and always shuts down safely. Pair with `detect_position_side()`, `calculate_margin_and_volume()`, and `determine_order_limits()` for generic position and sizing utilities. The read-only `mt5_session()` / `Mt5CliClient` SDK is unchanged. - **Trading session helpers**: use `mt5_trading_session()` for a trading-capable `pdmt5.Mt5TradingClient` that initializes/logs in via `Mt5Config.path` and always shuts down safely. Pair with `detect_position_side()`, `calculate_margin_and_volume()`, and `determine_order_limits()` for generic position and sizing utilities. Keep read-only collection on `mt5_session()` / `MT5Client`.
- **Granularity-keyed rate loading**: `load_rate_series_by_granularity()` builds targets with `build_rate_targets()`, loads them with `load_rate_series_from_sqlite()`, and returns a mapping keyed by `(symbol | None, granularity_name)` such as `("EURUSD", "M1")` to reduce downstream boilerplate. - **Granularity-keyed rate loading**: `load_rate_series_by_granularity()` builds targets with `build_rate_targets()`, loads them with `load_rate_series_from_sqlite()`, and returns a mapping keyed by `(symbol | None, granularity_name)` such as `("EURUSD", "M1")` to reduce downstream boilerplate.
- **MT5 session helper**: use the `mt5_session()` context manager to attach to (or, when `Mt5Config.path` is set, launch) an MT5 terminal, log in, and yield a connected `MT5Client` that shuts down on exit. - **MT5 session helper**: use the `mt5_session()` context manager to attach to (or, when `Mt5Config.path` is set, launch) an MT5 terminal, log in, and yield a connected `MT5Client` that shuts down on exit.
- **SQLite export helpers**: use `export_dataframe_to_sqlite()` for append mode, optional index export, and post-write deduplication by key columns. - **SQLite export helpers**: use `export_dataframe_to_sqlite()` for append mode, optional index export, and post-write deduplication by key columns.
@@ -317,7 +317,7 @@ finally:
client.shutdown() client.shutdown()
``` ```
Read-only collectors can keep using `mt5_session()` and `MT5Client` (or the `Mt5CliClient` alias) without changes. Read-only collectors can keep using `mt5_session()` and `MT5Client`.
## Development ## Development
+72 -47
View File
@@ -8,20 +8,29 @@ downstream app -> mt5cli -> pdmt5 -> MetaTrader 5
``` ```
Downstream packages should import from the package root (`from mt5cli import Downstream packages should import from the package root (`from mt5cli import
...`) and treat the symbols listed below as the stable SDK contract. CLI ...`) and use the public tier sets in `mt5cli.contract` to distinguish API
commands mirror the same behavior but are not importable Python APIs. stability. CLI commands mirror the same behavior but are not importable Python
APIs.
## Public API tiers
mt5cli classifies package-root imports by intended downstream use:
| Tier | Contract set | Meaning |
| ---------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| Stable core | `STABLE_SDK_EXPORTS` | Preferred SDK surface for downstream MT5 infrastructure adapters. Changes require a deliberate compatibility path. |
| Secondary public | `SECONDARY_PUBLIC_EXPORTS` | Public helpers for CLI/export/schema integrations and lower-level MT5 wrappers. Importable, but less central to the downstream trading SDK. |
## Stable downstream SDK API ## Stable downstream SDK API
These names are exported from `mt5cli` and covered by the contract in These names are exported from `mt5cli` and covered by the contract in
`mt5cli.STABLE_SDK_EXPORTS` (defined in `mt5cli.contract`). Prefer `MT5Client` over the legacy `Mt5CliClient` `mt5cli.STABLE_SDK_EXPORTS` (defined in `mt5cli.contract`).
alias for new code.
### Session lifecycle and configuration ### Session lifecycle and configuration
| Symbol | Role | | Symbol | Role |
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `MT5Client`, `Mt5CliClient` | Read-only data client with optional `order_check` / `order_send` | | `MT5Client` | Read-only data client with optional `order_check` / `order_send` |
| `build_config` | Build `pdmt5.Mt5Config` from connection fields | | `build_config` | Build `pdmt5.Mt5Config` from connection fields |
| `mt5_session` | Context manager: initialize, login, yield client, shutdown | | `mt5_session` | Context manager: initialize, login, yield client, shutdown |
| `create_trading_client`, `mt5_trading_session` | Trading-capable `pdmt5.Mt5TradingClient` lifecycle | | `create_trading_client`, `mt5_trading_session` | Trading-capable `pdmt5.Mt5TradingClient` lifecycle |
@@ -40,23 +49,6 @@ Partial strings such as `"plan$pass"`, `"abc$ENV"`, or `"$ENV-suffix"` are
**never** expanded — only an exact `$IDENTIFIER` whole-string match qualifies. **never** expanded — only an exact `$IDENTIFIER` whole-string match qualifies.
Default is `False` to preserve backward compatibility. Default is `False` to preserve backward compatibility.
### Read-only MT5 data access
Module-level helpers open a transient connection per call. Prefer `mt5_session`
or `MT5Client` when making many requests in one process.
| Area | Symbols |
| -------------------- | ---------------------------------------------------------------------------------------------------- |
| Rates | `copy_rates_from`, `copy_rates_from_pos`, `copy_rates_range`, `latest_rates`, `collect_latest_rates` |
| Ticks | `copy_ticks_from`, `copy_ticks_range`, `recent_ticks` |
| Account / terminal | `account_info`, `terminal_info`, `mt5_version`, `last_error`, `mt5_summary`, `mt5_summary_as_df` |
| Symbols / market | `symbols`, `symbol_info`, `symbol_info_tick`, `market_book`, `minimum_margins` |
| Trading state (read) | `orders`, `positions`, `history_orders`, `history_deals`, `recent_history_deals` |
Use `mt5_version` for MetaTrader 5 terminal version data. The name `version` at
the package root refers to `importlib.metadata.version` (package metadata), not
the MT5 SDK helper.
### Closed-bar rate helpers ### Closed-bar rate helpers
MetaTrader 5 returns the still-forming bar as the last row when MetaTrader 5 returns the still-forming bar as the last row when
@@ -71,7 +63,6 @@ timestamp normalization in downstream apps.
| `fetch_latest_closed_rates_indexed` | Same as above but returns a UTC `DatetimeIndex` named `"time"` (no time column) | | `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_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_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 | | `collect_latest_rates_for_accounts_with_retries` | Bounded exponential backoff for transient MT5 errors |
### SQLite history collection and rate loading ### SQLite history collection and rate loading
@@ -99,20 +90,24 @@ diagrams.
These helpers implement broker-facing calculations only. They do not encode These helpers implement broker-facing calculations only. They do not encode
strategy entries, exits, Kelly sizing, or signal logic. strategy entries, exits, Kelly sizing, or signal logic.
| Symbol | Role | | Symbol | Role |
| -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- |
| `get_account_snapshot`, `get_symbol_snapshot`, `get_tick_snapshot`, `get_positions_frame` | Normalized account/symbol/tick/position views | | `get_account_snapshot`, `get_symbol_snapshot`, `get_tick_snapshot`, `get_positions_frame` | Normalized account/symbol/tick/position views |
| `detect_position_side` | Net long / short / flat from open positions | | `extract_tick_price` | Positive finite bid/ask extraction from tick mappings |
| `calculate_spread_ratio` | Relative bid-ask spread | | `detect_position_side` | Net long / short / flat from open positions |
| `calculate_margin_and_volume`, `calculate_volume_by_margin`, `calculate_new_position_margin_ratio` | Margin budget and volume sizing | | `calculate_spread_ratio` | Relative bid-ask spread |
| `normalize_order_volume`, `estimate_order_margin`, `calculate_positions_margin` | Broker volume normalization and margin totals | | `calculate_margin_and_volume`, `calculate_volume_by_margin`, `calculate_new_position_margin_ratio` | Margin budget and volume sizing |
| `calculate_positions_margin_by_symbol` | Per-symbol margin map (resilient, first-seen order) | | `normalize_order_volume`, `estimate_order_margin`, `calculate_positions_margin` | Broker volume normalization and margin totals |
| `calculate_positions_margin_safe` | Summed total margin across symbols (failed symbols skipped) | | `calculate_positions_margin_by_symbol` | Per-symbol margin map (resilient, first-seen order) |
| `determine_order_limits` | SL/TP price levels from ratios | | `calculate_positions_margin_safe` | Summed total margin across symbols (failed symbols skipped) |
| `ensure_symbol_selected` | Select/verify Market Watch visibility | | `calculate_projected_margin_ratio` | Estimated symbol margin/equity after optional new exposure |
| `place_market_order`, `close_open_positions`, `update_sltp_for_open_positions` | Order execution helpers (`dry_run` supported) | | `calculate_symbol_group_margin_ratio` | Estimated symbol-group margin/equity with optional exposure |
| `MarginVolume`, `OrderLimits`, `OrderExecutionResult` | Typed return contracts for order helpers | | `determine_order_limits` | SL/TP price levels from ratios |
| `OrderSide`, `OrderFillingMode`, `OrderTimeMode`, `PositionSide`, `ExecutionStatus` | Typed enums for order helpers | | `calculate_trailing_stop_updates` | Per-ticket generic trailing stop-loss update plan |
| `ensure_symbol_selected` | Select/verify Market Watch visibility |
| `place_market_order`, `close_open_positions`, `update_sltp_for_open_positions`, `update_trailing_stop_loss_for_open_positions` | Order execution helpers (`dry_run` supported) |
| `MarginVolume`, `OrderLimits`, `OrderExecutionResult` | Typed return contracts for order helpers |
| `OrderSide`, `OrderFillingMode`, `OrderTimeMode`, `PositionSide`, `ExecutionStatus` | Typed enums for order helpers |
`MT5Client.order_send()` and CLI `order-send --yes` are live execution paths. `MT5Client.order_send()` and CLI `order-send --yes` are live execution paths.
@@ -135,13 +130,43 @@ and returned as `status="failed"` with normalized `request` / `response` details
| `normalize_mt5_exception`, `call_with_normalized_errors`, `is_recoverable_mt5_error` | Error normalization and retry classification | | `normalize_mt5_exception`, `call_with_normalized_errors`, `is_recoverable_mt5_error` | Error normalization and retry classification |
| `Mt5Config`, `Mt5RuntimeError`, `Mt5TradingClient`, `Mt5TradingError` | Re-exported pdmt5 types for adapter convenience | | `Mt5Config`, `Mt5RuntimeError`, `Mt5TradingClient`, `Mt5TradingError` | Re-exported pdmt5 types for adapter convenience |
### Additional public exports (secondary) ## Secondary public exports
The package root also exports schema, storage, and parsing helpers (for example These names remain importable from `mt5cli` and are covered by
`DataKind`, `Dataset`, `normalize_dataframe`, `export_dataframe`, `SECONDARY_PUBLIC_EXPORTS`, but they are oriented toward CLI/export/schema
`parse_timeframe`, `TIMEFRAME_MAP`). These are public but oriented toward export integrations, parsing, and lower-level MT5 access rather than the stable core
pipelines and advanced integration. Prefer the stable symbols above for core SDK surface. Prefer the stable symbols above for downstream infrastructure
infrastructure. adapters.
### Read-only MT5 data wrappers
Module-level helpers open a transient connection per call. Prefer `mt5_session`
or `MT5Client` when making many requests in one process.
| Area | Symbols |
| -------------------- | ---------------------------------------------------------------------------------------------------- |
| Rates | `copy_rates_from`, `copy_rates_from_pos`, `copy_rates_range`, `latest_rates`, `collect_latest_rates` |
| Ticks | `copy_ticks_from`, `copy_ticks_range`, `recent_ticks` |
| Account / terminal | `account_info`, `terminal_info`, `mt5_version`, `last_error`, `mt5_summary`, `mt5_summary_as_df` |
| Symbols / market | `symbols`, `symbol_info`, `symbol_info_tick`, `market_book`, `minimum_margins` |
| Trading state (read) | `orders`, `positions`, `history_orders`, `history_deals`, `recent_history_deals` |
| Multi-account rates | `collect_latest_rates_for_accounts` |
Use `mt5_version` for MetaTrader 5 terminal version data. The name `version` at
the package root refers to `importlib.metadata.version` (package metadata), not
the MT5 SDK helper.
### Schema, export, and parser helpers
| Area | Symbols |
| -------------------- | ------------------------------------------------------------------------------------------------------------- |
| Dataset contracts | `DataKind`, `Dataset`, `IfExists`, `DEDUP_KEYS`, `REQUIRED_COLUMNS`, `TIME_COLUMNS`, `KNOWN_MT5_TIME_COLUMNS` |
| Schema normalization | `normalize_dataframe`, `normalize_time_columns`, `schema_columns`, `validate_schema` |
| Export helpers | `detect_format`, `export_dataframe`, `export_dataframe_to_sqlite` |
| Symbol parsing | `normalize_symbol`, `normalize_symbols` |
| Time parsing | `ensure_utc`, `parse_date_range`, `parse_datetime`, `recent_window` |
| MT5 parsing maps | `granularity_name`, `parse_tick_flags`, `parse_timeframe`, `TICK_FLAG_MAP`, `TIMEFRAME_MAP` |
| Trading data shapes | `POSITION_COLUMNS` |
## CLI commands ## CLI commands
@@ -190,7 +215,7 @@ their own adapter layer.
## Contract verification ## Contract verification
`tests/test_contracts.py` asserts that every name in `STABLE_SDK_EXPORTS` is `tests/test_contracts.py` asserts that every name in the stable and secondary
importable from `mt5cli`, documents key closed-bar, rate-view, SQLite loading, tier sets is importable from `mt5cli`, documents key closed-bar, rate-view,
account-resolution, and trading-session behaviors, and keeps the contract set SQLite loading, account-resolution, and trading-session behaviors, and keeps the
aligned with `__all__`. tier sets aligned with `__all__`.
+3 -3
View File
@@ -31,7 +31,7 @@ rates = collect_latest_rates_for_accounts_with_retries(
### Latest closed rate bars ### Latest closed rate bars
MetaTrader 5 `start_pos=0` includes the still-forming current bar as the last MetaTrader 5 `start_pos=0` includes the still-forming current bar as the last
row. `fetch_latest_closed_rates()` handles one connected `Mt5CliClient`; use row. `fetch_latest_closed_rates()` handles one connected `MT5Client`; use
`fetch_latest_closed_rates_for_trading_client()` from an active `fetch_latest_closed_rates_for_trading_client()` from an active
`Mt5TradingClient` session. Multi-account helpers fetch `count + 1` bars, drop `Mt5TradingClient` session. Multi-account helpers fetch `count + 1` bars, drop
that row with `drop_forming_rate_bar()`, and validate each series is non-empty. Returned frames are ordered that row with `drop_forming_rate_bar()`, and validate each series is non-empty. Returned frames are ordered
@@ -170,5 +170,5 @@ resulting `ValueError` is suppressed along with other recoverable errors.
## Trading-capable sessions ## Trading-capable sessions
For order placement and trading calculations, use the dedicated For order placement and trading calculations, use the dedicated
[Trading module](trading.md). The read-only `Mt5CliClient` and `mt5_session()` [Trading module](trading.md). Use `mt5_session()` / `MT5Client` for read-only
helpers in this module are unchanged. collection.
+2 -2
View File
@@ -31,7 +31,7 @@ finally:
`login` accepts `int`, numeric `str`, or an empty string; empty strings are `login` accepts `int`, numeric `str`, or an empty string; empty strings are
treated as unset. `path`, `password`, `server`, and `timeout` are forwarded to treated as unset. `path`, `password`, `server`, and `timeout` are forwarded to
`pdmt5.Mt5Config`, and omitted `timeout` values keep the lower-level default. `pdmt5.Mt5Config`, and omitted `timeout` values keep the lower-level default.
The read-only `Mt5CliClient` / `mt5_session()` API is unchanged. Use `mt5_session()` / `MT5Client` for read-only data collection.
## State and order helpers ## State and order helpers
@@ -194,6 +194,6 @@ through the stable package root without embedding entry/exit policy.
| Local SL/TP price derivation | `determine_order_limits()` | | Local SL/TP price derivation | `determine_order_limits()` |
| Throttled SQLite history loop with ad-hoc error handling | `ThrottledHistoryUpdater(suppress_errors=True)` | | Throttled SQLite history loop with ad-hoc error handling | `ThrottledHistoryUpdater(suppress_errors=True)` |
Keep read-only data collection on `mt5_session()` / `Mt5CliClient`; use Keep read-only data collection on `mt5_session()` / `MT5Client`; use
`mt5_trading_session()` only where order placement or trading calculations are `mt5_trading_session()` only where order placement or trading calculations are
required. required.
+1 -1
View File
@@ -29,7 +29,7 @@ pip install mt5cli
## Python API for downstream packages ## Python API for downstream packages
Import `MT5Client` for generic MT5 data access, schema normalization, and optional order primitives. `Mt5CliClient` remains available as a backward-compatible alias. Import `MT5Client` for generic MT5 data access, schema normalization, and optional order primitives.
```python ```python
from datetime import UTC, datetime from datetime import UTC, datetime
+17 -3
View File
@@ -11,7 +11,11 @@ from importlib.metadata import version
from pdmt5 import Mt5Config, Mt5RuntimeError, Mt5TradingClient, Mt5TradingError from pdmt5 import Mt5Config, Mt5RuntimeError, Mt5TradingClient, Mt5TradingError
from .client import MT5Client, build_config, mt5_session from .client import MT5Client, build_config, mt5_session
from .contract import STABLE_SDK_EXPORTS from .contract import (
PUBLIC_EXPORT_TIERS,
SECONDARY_PUBLIC_EXPORTS,
STABLE_SDK_EXPORTS,
)
from .converters import ( from .converters import (
ensure_utc, ensure_utc,
granularity_name, granularity_name,
@@ -59,7 +63,6 @@ from .schemas import (
) )
from .sdk import ( from .sdk import (
AccountSpec, AccountSpec,
Mt5CliClient,
ThrottledHistoryUpdater, ThrottledHistoryUpdater,
account_info, account_info,
collect_history, collect_history,
@@ -121,7 +124,10 @@ from .trading import (
calculate_positions_margin, calculate_positions_margin,
calculate_positions_margin_by_symbol, calculate_positions_margin_by_symbol,
calculate_positions_margin_safe, calculate_positions_margin_safe,
calculate_projected_margin_ratio,
calculate_spread_ratio, calculate_spread_ratio,
calculate_symbol_group_margin_ratio,
calculate_trailing_stop_updates,
calculate_volume_by_margin, calculate_volume_by_margin,
close_open_positions, close_open_positions,
create_trading_client, create_trading_client,
@@ -129,6 +135,7 @@ from .trading import (
determine_order_limits, determine_order_limits,
ensure_symbol_selected, ensure_symbol_selected,
estimate_order_margin, estimate_order_margin,
extract_tick_price,
fetch_latest_closed_rates_for_trading_client, fetch_latest_closed_rates_for_trading_client,
fetch_latest_closed_rates_indexed, fetch_latest_closed_rates_indexed,
get_account_snapshot, get_account_snapshot,
@@ -139,6 +146,7 @@ from .trading import (
normalize_order_volume, normalize_order_volume,
place_market_order, place_market_order,
update_sltp_for_open_positions, update_sltp_for_open_positions,
update_trailing_stop_loss_for_open_positions,
) )
from .utils import ( from .utils import (
TICK_FLAG_MAP, TICK_FLAG_MAP,
@@ -154,7 +162,9 @@ __all__ = [
"DEDUP_KEYS", "DEDUP_KEYS",
"KNOWN_MT5_TIME_COLUMNS", "KNOWN_MT5_TIME_COLUMNS",
"POSITION_COLUMNS", "POSITION_COLUMNS",
"PUBLIC_EXPORT_TIERS",
"REQUIRED_COLUMNS", "REQUIRED_COLUMNS",
"SECONDARY_PUBLIC_EXPORTS",
"STABLE_SDK_EXPORTS", "STABLE_SDK_EXPORTS",
"TICK_FLAG_MAP", "TICK_FLAG_MAP",
"TIMEFRAME_MAP", "TIMEFRAME_MAP",
@@ -166,7 +176,6 @@ __all__ = [
"IfExists", "IfExists",
"MT5Client", "MT5Client",
"MarginVolume", "MarginVolume",
"Mt5CliClient",
"Mt5CliError", "Mt5CliError",
"Mt5Config", "Mt5Config",
"Mt5ConnectionError", "Mt5ConnectionError",
@@ -192,7 +201,10 @@ __all__ = [
"calculate_positions_margin", "calculate_positions_margin",
"calculate_positions_margin_by_symbol", "calculate_positions_margin_by_symbol",
"calculate_positions_margin_safe", "calculate_positions_margin_safe",
"calculate_projected_margin_ratio",
"calculate_spread_ratio", "calculate_spread_ratio",
"calculate_symbol_group_margin_ratio",
"calculate_trailing_stop_updates",
"calculate_volume_by_margin", "calculate_volume_by_margin",
"call_with_normalized_errors", "call_with_normalized_errors",
"close_open_positions", "close_open_positions",
@@ -217,6 +229,7 @@ __all__ = [
"estimate_order_margin", "estimate_order_margin",
"export_dataframe", "export_dataframe",
"export_dataframe_to_sqlite", "export_dataframe_to_sqlite",
"extract_tick_price",
"fetch_latest_closed_rates", "fetch_latest_closed_rates",
"fetch_latest_closed_rates_for_trading_client", "fetch_latest_closed_rates_for_trading_client",
"fetch_latest_closed_rates_indexed", "fetch_latest_closed_rates_indexed",
@@ -275,5 +288,6 @@ __all__ = [
"update_history", "update_history",
"update_history_with_config", "update_history_with_config",
"update_sltp_for_open_positions", "update_sltp_for_open_positions",
"update_trailing_stop_loss_for_open_positions",
"validate_schema", "validate_schema",
] ]
+1 -3
View File
@@ -24,9 +24,7 @@ class MT5Client(Mt5CliClient):
"""Public client for generic MT5 data access and order primitives. """Public client for generic MT5 data access and order primitives.
Extends the read-only SDK client with optional order check/send helpers and Extends the read-only SDK client with optional order check/send helpers and
exposes the same connection lifecycle as :class:`~mt5cli.sdk.Mt5CliClient`. exposes the same connection lifecycle as :func:`mt5_session`.
Downstream applications such as private trading packages should prefer this
type over the legacy ``Mt5CliClient`` name.
mt5cli intentionally exposes minimal execution primitives only. Trading mt5cli intentionally exposes minimal execution primitives only. Trading
decisions, signals, strategies, backtests, and optimization remain the decisions, signals, strategies, backtests, and optimization remain the
+69 -29
View File
@@ -1,11 +1,10 @@
"""Stable downstream SDK export names for mt5cli.""" """Downstream SDK export tiers for mt5cli."""
from __future__ import annotations from __future__ import annotations
STABLE_SDK_EXPORTS: frozenset[str] = frozenset({ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
"AccountSpec", "AccountSpec",
"MT5Client", "MT5Client",
"Mt5CliClient",
"Mt5CliError", "Mt5CliError",
"Mt5Config", "Mt5Config",
"Mt5ConnectionError", "Mt5ConnectionError",
@@ -24,38 +23,32 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
"OrderLimits", "OrderLimits",
"RateTarget", "RateTarget",
"ThrottledHistoryUpdater", "ThrottledHistoryUpdater",
"account_info",
"build_config", "build_config",
"build_rate_targets", "build_rate_targets",
"build_rate_view_name", "build_rate_view_name",
"calculate_margin_and_volume", "calculate_margin_and_volume",
"calculate_new_position_margin_ratio", "calculate_new_position_margin_ratio",
"calculate_projected_margin_ratio",
"calculate_positions_margin", "calculate_positions_margin",
"calculate_positions_margin_by_symbol", "calculate_positions_margin_by_symbol",
"calculate_positions_margin_safe", "calculate_positions_margin_safe",
"calculate_spread_ratio", "calculate_spread_ratio",
"calculate_symbol_group_margin_ratio",
"calculate_trailing_stop_updates",
"calculate_volume_by_margin", "calculate_volume_by_margin",
"call_with_normalized_errors", "call_with_normalized_errors",
"close_open_positions", "close_open_positions",
"collect_history", "collect_history",
"collect_latest_closed_rates_by_granularity", "collect_latest_closed_rates_by_granularity",
"collect_latest_closed_rates_for_accounts", "collect_latest_closed_rates_for_accounts",
"collect_latest_rates",
"collect_latest_rates_for_accounts",
"collect_latest_rates_for_accounts_with_retries", "collect_latest_rates_for_accounts_with_retries",
"copy_rates_from",
"copy_rates_from_pos",
"copy_rates_range",
"copy_ticks_from",
"copy_ticks_range",
"create_trading_client", "create_trading_client",
"detect_position_side", "detect_position_side",
"determine_order_limits", "determine_order_limits",
"drop_forming_rate_bar", "drop_forming_rate_bar",
"ensure_symbol_selected", "ensure_symbol_selected",
"estimate_order_margin", "estimate_order_margin",
"export_dataframe", "extract_tick_price",
"export_dataframe_to_sqlite",
"fetch_latest_closed_rates", "fetch_latest_closed_rates",
"fetch_latest_closed_rates_for_trading_client", "fetch_latest_closed_rates_for_trading_client",
"fetch_latest_closed_rates_indexed", "fetch_latest_closed_rates_indexed",
@@ -63,29 +56,16 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
"get_positions_frame", "get_positions_frame",
"get_symbol_snapshot", "get_symbol_snapshot",
"get_tick_snapshot", "get_tick_snapshot",
"history_deals",
"history_orders",
"is_recoverable_mt5_error", "is_recoverable_mt5_error",
"last_error",
"latest_rates",
"load_rate_data", "load_rate_data",
"load_rate_data_from_connection", "load_rate_data_from_connection",
"load_rate_series_by_granularity", "load_rate_series_by_granularity",
"load_rate_series_from_sqlite", "load_rate_series_from_sqlite",
"market_book",
"minimum_margins",
"mt5_session", "mt5_session",
"mt5_summary",
"mt5_summary_as_df",
"mt5_trading_session", "mt5_trading_session",
"mt5_version",
"normalize_mt5_exception", "normalize_mt5_exception",
"normalize_order_volume", "normalize_order_volume",
"orders",
"place_market_order", "place_market_order",
"positions",
"recent_history_deals",
"recent_ticks",
"resolve_account_spec", "resolve_account_spec",
"resolve_account_specs", "resolve_account_specs",
"resolve_history_datasets", "resolve_history_datasets",
@@ -96,13 +76,73 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
"resolve_rate_view_name", "resolve_rate_view_name",
"resolve_rate_view_names", "resolve_rate_view_names",
"substitute_env_placeholders", "substitute_env_placeholders",
"update_history",
"update_history_with_config",
"update_sltp_for_open_positions",
"update_trailing_stop_loss_for_open_positions",
})
SECONDARY_PUBLIC_EXPORTS: frozenset[str] = frozenset({
"DEDUP_KEYS",
"DataKind",
"Dataset",
"IfExists",
"KNOWN_MT5_TIME_COLUMNS",
"POSITION_COLUMNS",
"REQUIRED_COLUMNS",
"TICK_FLAG_MAP",
"TIMEFRAME_MAP",
"TIME_COLUMNS",
"account_info",
"collect_latest_rates",
"collect_latest_rates_for_accounts",
"copy_rates_from",
"copy_rates_from_pos",
"copy_rates_range",
"copy_ticks_from",
"copy_ticks_range",
"detect_format",
"ensure_utc",
"export_dataframe",
"export_dataframe_to_sqlite",
"granularity_name",
"history_deals",
"history_orders",
"last_error",
"latest_rates",
"market_book",
"minimum_margins",
"mt5_summary",
"mt5_summary_as_df",
"mt5_version",
"normalize_dataframe",
"normalize_symbol",
"normalize_symbols",
"normalize_time_columns",
"orders",
"parse_date_range",
"parse_datetime",
"parse_tick_flags",
"parse_timeframe",
"positions",
"recent_history_deals",
"recent_ticks",
"recent_window",
"schema_columns",
"symbol_info", "symbol_info",
"symbol_info_tick", "symbol_info_tick",
"symbols", "symbols",
"terminal_info", "terminal_info",
"update_history", "validate_schema",
"update_history_with_config",
"update_sltp_for_open_positions",
}) })
__all__ = ["STABLE_SDK_EXPORTS"] PUBLIC_EXPORT_TIERS: dict[str, frozenset[str]] = {
"stable": STABLE_SDK_EXPORTS,
"secondary": SECONDARY_PUBLIC_EXPORTS,
}
__all__ = [
"PUBLIC_EXPORT_TIERS",
"SECONDARY_PUBLIC_EXPORTS",
"STABLE_SDK_EXPORTS",
]
+233 -23
View File
@@ -14,7 +14,6 @@ from pdmt5 import Mt5Config, Mt5RuntimeError, Mt5TradingClient, Mt5TradingError
from .history import drop_forming_rate_bar from .history import drop_forming_rate_bar
from .sdk import build_config from .sdk import build_config
from .utils import coerce_login as _coerce_login from .utils import coerce_login as _coerce_login
from .utils import parse_timeframe
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterator, Mapping, Sequence from collections.abc import Iterator, Mapping, Sequence
@@ -133,7 +132,10 @@ __all__ = [
"calculate_positions_margin", "calculate_positions_margin",
"calculate_positions_margin_by_symbol", "calculate_positions_margin_by_symbol",
"calculate_positions_margin_safe", "calculate_positions_margin_safe",
"calculate_projected_margin_ratio",
"calculate_spread_ratio", "calculate_spread_ratio",
"calculate_symbol_group_margin_ratio",
"calculate_trailing_stop_updates",
"calculate_volume_by_margin", "calculate_volume_by_margin",
"close_open_positions", "close_open_positions",
"create_trading_client", "create_trading_client",
@@ -141,6 +143,7 @@ __all__ = [
"determine_order_limits", "determine_order_limits",
"ensure_symbol_selected", "ensure_symbol_selected",
"estimate_order_margin", "estimate_order_margin",
"extract_tick_price",
"fetch_latest_closed_rates_for_trading_client", "fetch_latest_closed_rates_for_trading_client",
"fetch_latest_closed_rates_indexed", "fetch_latest_closed_rates_indexed",
"get_account_snapshot", "get_account_snapshot",
@@ -151,6 +154,7 @@ __all__ = [
"normalize_order_volume", "normalize_order_volume",
"place_market_order", "place_market_order",
"update_sltp_for_open_positions", "update_sltp_for_open_positions",
"update_trailing_stop_loss_for_open_positions",
] ]
@@ -428,7 +432,7 @@ def _optional_price(value: object) -> float | None:
return price return price
def _valid_tick_price(tick: Mapping[str, object], key: str) -> float | None: def extract_tick_price(tick: Mapping[str, object], key: str) -> float | None:
"""Return a positive finite float from tick[key], or None if invalid. """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 Accepts int, float, or numeric string values. Returns None when the key is
@@ -490,7 +494,7 @@ def _calculate_min_volume_if_affordable(
msg = f"Invalid volume constraints for {symbol!r}." msg = f"Invalid volume constraints for {symbol!r}."
raise Mt5TradingError(msg) raise Mt5TradingError(msg)
side = _normalize_order_side(order_side) side = _normalize_order_side(order_side)
price = _valid_tick_price( price = extract_tick_price(
get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid" get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid"
) )
if price is None: if price is None:
@@ -651,7 +655,7 @@ def estimate_order_margin(
raise Mt5TradingError(msg) raise Mt5TradingError(msg)
side = _normalize_order_side(order_side) side = _normalize_order_side(order_side)
tick = get_tick_snapshot(client, symbol) tick = get_tick_snapshot(client, symbol)
price = _valid_tick_price(tick, "ask" if side == "BUY" else "bid") price = extract_tick_price(tick, "ask" if side == "BUY" else "bid")
if price is None: if price is None:
msg = f"Tick price is unavailable for {symbol!r}." msg = f"Tick price is unavailable for {symbol!r}."
raise Mt5TradingError(msg) raise Mt5TradingError(msg)
@@ -785,8 +789,8 @@ def calculate_spread_ratio(client: Mt5TradingClient, symbol: str) -> float:
Mt5TradingError: If bid or ask is unavailable. Mt5TradingError: If bid or ask is unavailable.
""" """
tick = get_tick_snapshot(client, symbol) tick = get_tick_snapshot(client, symbol)
bid = _valid_tick_price(tick, "bid") bid = extract_tick_price(tick, "bid")
ask = _valid_tick_price(tick, "ask") ask = extract_tick_price(tick, "ask")
if bid is None or ask is None: if bid is None or ask is None:
msg = f"Tick bid/ask is unavailable for {symbol!r}." msg = f"Tick bid/ask is unavailable for {symbol!r}."
raise Mt5TradingError(msg) raise Mt5TradingError(msg)
@@ -813,7 +817,7 @@ def calculate_new_position_margin_ratio(
margin = float(account.get("margin") or 0.0) margin = float(account.get("margin") or 0.0)
if new_position_side is not None and new_position_volume > 0: if new_position_side is not None and new_position_volume > 0:
side = _normalize_order_side(new_position_side) side = _normalize_order_side(new_position_side)
price = _valid_tick_price( price = extract_tick_price(
get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid" get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid"
) )
if price is None: if price is None:
@@ -828,6 +832,102 @@ def calculate_new_position_margin_ratio(
return margin / equity return margin / equity
def _account_equity(client: Mt5TradingClient) -> float:
account = get_account_snapshot(client)
try:
equity = float(account.get("equity") or 0.0)
except (TypeError, ValueError) as exc:
msg = "Account equity must be positive to calculate margin ratio."
raise Mt5TradingError(msg) from exc
if equity <= 0 or not isfinite(equity):
msg = "Account equity must be positive to calculate margin ratio."
raise Mt5TradingError(msg)
return equity
def calculate_projected_margin_ratio(
client: Mt5TradingClient,
*,
symbol: str,
new_position_side: OrderSide | None = None,
new_position_volume: float = 0.0,
) -> float:
"""Return estimated current plus optional new-position margin over equity.
Current exposure is estimated from open positions with
:func:`calculate_positions_margin`. Optional projected exposure is added via
:func:`estimate_order_margin`. Thresholds and guard actions are intentionally
left to downstream applications.
Account equity, position margin, and optional projected margin errors from
the composed MT5 helpers propagate to the caller.
"""
equity = _account_equity(client)
margin = calculate_positions_margin(client, symbols=[symbol])
if new_position_side is not None and new_position_volume > 0:
margin += estimate_order_margin(
client,
symbol,
new_position_side,
new_position_volume,
)
return margin / equity
def calculate_symbol_group_margin_ratio(
client: Mt5TradingClient,
*,
symbols: Sequence[str],
new_symbol: str | None = None,
new_position_side: OrderSide | None = None,
new_position_volume: float = 0.0,
suppress_errors: bool = True,
) -> float:
"""Return estimated symbol-group margin over account equity.
Per-symbol current exposure is summed with
:func:`calculate_positions_margin_by_symbol`. When ``new_symbol`` is inside
the input symbol group, optional projected order margin is added for that
symbol. Invalid equity always raises to fail closed.
Raises:
AttributeError: When symbol margin lookup or projected margin lookup
fails and ``suppress_errors`` is ``False``.
Mt5RuntimeError: When symbol margin lookup or projected margin lookup
fails and ``suppress_errors`` is ``False``.
Mt5TradingError: When account equity is invalid, or when symbol margin
lookup or projected margin lookup fails and ``suppress_errors`` is
``False``.
"""
equity = _account_equity(client)
unique_symbols = list(dict.fromkeys(symbols))
margin = sum(
calculate_positions_margin_by_symbol(
client,
symbols=unique_symbols,
suppress_errors=suppress_errors,
).values(),
0.0,
)
if (
new_symbol in unique_symbols
and new_position_side is not None
and new_position_volume > 0
):
try:
margin += estimate_order_margin(
client,
new_symbol,
new_position_side,
new_position_volume,
)
except (Mt5TradingError, Mt5RuntimeError, AttributeError):
if not suppress_errors:
raise
_logger.warning("Skipping projected margin for %r.", new_symbol)
return margin / equity
def calculate_margin_and_volume( def calculate_margin_and_volume(
client: Mt5TradingClient, client: Mt5TradingClient,
symbol: str, symbol: str,
@@ -921,7 +1021,7 @@ def calculate_volume_by_margin(
msg = f"Invalid volume constraints for {symbol!r}." msg = f"Invalid volume constraints for {symbol!r}."
raise Mt5TradingError(msg) raise Mt5TradingError(msg)
side = _normalize_order_side(order_side) side = _normalize_order_side(order_side)
price = _valid_tick_price( price = extract_tick_price(
get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid" get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid"
) )
if price is None: if price is None:
@@ -1001,7 +1101,7 @@ def determine_order_limits(
normalized_side = _position_side_from_order_side(side) normalized_side = _position_side_from_order_side(side)
tick = get_tick_snapshot(client, symbol) tick = get_tick_snapshot(client, symbol)
entry_key = "ask" if normalized_side == "long" else "bid" entry_key = "ask" if normalized_side == "long" else "bid"
entry = _valid_tick_price(tick, entry_key) entry = extract_tick_price(tick, entry_key)
if entry is None: if entry is None:
msg = f"Tick price is unavailable for {symbol!r}." msg = f"Tick price is unavailable for {symbol!r}."
raise Mt5TradingError(msg) raise Mt5TradingError(msg)
@@ -1080,7 +1180,7 @@ def place_market_order(
if not dry_run: if not dry_run:
ensure_symbol_selected(client, symbol) ensure_symbol_selected(client, symbol)
tick = get_tick_snapshot(client, symbol) tick = get_tick_snapshot(client, symbol)
price = _valid_tick_price(tick, "ask" if side == "BUY" else "bid") price = extract_tick_price(tick, "ask" if side == "BUY" else "bid")
if price is None: if price is None:
msg = f"Tick price is unavailable for {symbol!r}." msg = f"Tick price is unavailable for {symbol!r}."
raise Mt5TradingError(msg) raise Mt5TradingError(msg)
@@ -1188,6 +1288,125 @@ def close_open_positions(
return results return results
def _symbol_digits(client: Mt5TradingClient, symbol: str) -> int | None:
try:
raw_digits = get_symbol_snapshot(client, symbol).get("digits")
if raw_digits is None:
return None
digits = int(raw_digits)
except (AttributeError, TypeError, ValueError):
return None
return digits if digits >= 0 else None
def _position_ticket(value: object) -> int | None:
ticket = _optional_int(value)
return ticket if ticket is not None and ticket > 0 else None
def _current_stop_loss(value: object) -> float | None:
return _optional_price(value)
def _trailing_stop_loss(
client: Mt5TradingClient,
*,
position_type: object,
current_sl: float | None,
bid: float | None,
ask: float | None,
digits: int,
trailing_stop_ratio: float,
) -> float | None:
next_sl: float | None = None
if position_type == client.mt5.POSITION_TYPE_BUY:
if bid is not None:
next_sl = round(bid * (1.0 - trailing_stop_ratio), digits)
if current_sl is not None and current_sl >= next_sl:
next_sl = None
elif position_type == client.mt5.POSITION_TYPE_SELL and ask is not None:
next_sl = round(ask * (1.0 + trailing_stop_ratio), digits)
if current_sl is not None and current_sl <= next_sl:
next_sl = None
return next_sl
def calculate_trailing_stop_updates(
client: Mt5TradingClient,
*,
symbol: str,
trailing_stop_ratio: float,
) -> dict[int, float]:
"""Return per-ticket trailing stop-loss updates for open symbol positions.
Buy positions trail from bid using ``bid * (1 - trailing_stop_ratio)``.
Sell positions trail from ask using ``ask * (1 + trailing_stop_ratio)``.
Existing stop losses are preserved when they are already more favorable.
Missing symbol metadata returns an empty update map. Positions with a
missing side-specific tick price are skipped.
"""
_require_protective_ratio(trailing_stop_ratio, "trailing_stop_ratio")
positions = get_positions_frame(client, symbol=symbol)
if positions.empty:
return {}
tick = get_tick_snapshot(client, symbol)
bid = extract_tick_price(tick, "bid")
ask = extract_tick_price(tick, "ask")
digits = _symbol_digits(client, symbol)
if digits is None:
return {}
updates: dict[int, float] = {}
for row in positions.to_dict("records"):
ticket = _position_ticket(row.get("ticket"))
if ticket is None:
continue
next_sl = _trailing_stop_loss(
client,
position_type=row.get("type"),
current_sl=_current_stop_loss(row.get("sl")),
bid=bid,
ask=ask,
digits=digits,
trailing_stop_ratio=trailing_stop_ratio,
)
if next_sl is None:
continue
updates[ticket] = next_sl
return updates
def update_trailing_stop_loss_for_open_positions(
client: Mt5TradingClient,
*,
symbol: str,
trailing_stop_ratio: float,
dry_run: bool = False,
) -> list[OrderExecutionResult]:
"""Update open positions whose trailing stop loss should move favorably.
Returns:
Normalized execution results for positions that need an SL update.
"""
updates = calculate_trailing_stop_updates(
client,
symbol=symbol,
trailing_stop_ratio=trailing_stop_ratio,
)
results: list[OrderExecutionResult] = []
for ticket, stop_loss in updates.items():
results.extend(
update_sltp_for_open_positions(
client,
symbol=symbol,
tickets=[ticket],
stop_loss=stop_loss,
dry_run=dry_run,
),
)
return results
def update_sltp_for_open_positions( def update_sltp_for_open_positions(
client: Mt5TradingClient, client: Mt5TradingClient,
*, *,
@@ -1273,19 +1492,10 @@ def fetch_latest_closed_rates_for_trading_client(
msg = "count must be positive." msg = "count must be positive."
raise ValueError(msg) raise ValueError(msg)
fetch_method = getattr(client, "fetch_latest_rates_as_df", None) fetch_method = getattr(client, "fetch_latest_rates_as_df", None)
if callable(fetch_method): if not callable(fetch_method):
fetched = fetch_method(symbol, granularity, count + 1) msg = "MT5 trading client cannot fetch rate data."
else: raise Mt5TradingError(msg)
copy_method = getattr(client, "copy_rates_from_pos_as_df", None) fetched = fetch_method(symbol, granularity, count + 1)
if not callable(copy_method):
msg = "MT5 trading client cannot fetch rate data."
raise Mt5TradingError(msg)
fetched = copy_method(
symbol=symbol,
timeframe=parse_timeframe(granularity),
start_pos=0,
count=count + 1,
)
if not isinstance(fetched, pd.DataFrame): if not isinstance(fetched, pd.DataFrame):
msg = ( msg = (
f"Malformed rate data for {symbol!r} at granularity {granularity!r}: " f"Malformed rate data for {symbol!r} at granularity {granularity!r}: "
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "mt5cli" name = "mt5cli"
version = "0.9.2" version = "0.9.3"
description = "Generic MT5 data and execution infrastructure for Python applications" description = "Generic MT5 data and execution infrastructure for Python applications"
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}] authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}] maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
+76 -4
View File
@@ -2,9 +2,11 @@
from __future__ import annotations from __future__ import annotations
import re
import sqlite3 import sqlite3
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import TYPE_CHECKING, get_type_hints from pathlib import Path
from typing import get_type_hints
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pandas as pd import pandas as pd
@@ -15,7 +17,9 @@ from pytest_mock import MockerFixture # noqa: TC002
import mt5cli import mt5cli
from mt5cli import ( from mt5cli import (
DEDUP_KEYS, DEDUP_KEYS,
PUBLIC_EXPORT_TIERS,
REQUIRED_COLUMNS, REQUIRED_COLUMNS,
SECONDARY_PUBLIC_EXPORTS,
STABLE_SDK_EXPORTS, STABLE_SDK_EXPORTS,
TIME_COLUMNS, TIME_COLUMNS,
AccountSpec, AccountSpec,
@@ -35,6 +39,9 @@ from mt5cli import (
build_rate_targets, build_rate_targets,
calculate_margin_and_volume, calculate_margin_and_volume,
calculate_positions_margin, calculate_positions_margin,
calculate_projected_margin_ratio,
calculate_symbol_group_margin_ratio,
calculate_trailing_stop_updates,
call_with_normalized_errors, call_with_normalized_errors,
detect_format, detect_format,
drop_forming_rate_bar, drop_forming_rate_bar,
@@ -42,6 +49,7 @@ from mt5cli import (
ensure_utc, ensure_utc,
export_dataframe, export_dataframe,
export_dataframe_to_sqlite, export_dataframe_to_sqlite,
extract_tick_price,
fetch_latest_closed_rates, fetch_latest_closed_rates,
fetch_latest_closed_rates_for_trading_client, fetch_latest_closed_rates_for_trading_client,
fetch_latest_closed_rates_indexed, fetch_latest_closed_rates_indexed,
@@ -69,9 +77,6 @@ from mt5cli.history import create_rate_compatibility_views
from mt5cli.retry import retry_with_backoff from mt5cli.retry import retry_with_backoff
from mt5cli.schemas import ensure_utc_columns, normalize_time_columns from mt5cli.schemas import ensure_utc_columns, normalize_time_columns
if TYPE_CHECKING:
from pathlib import Path
def _sample_frame(kind: DataKind) -> pd.DataFrame: def _sample_frame(kind: DataKind) -> pd.DataFrame:
if kind is DataKind.rates: if kind is DataKind.rates:
@@ -547,11 +552,69 @@ class TestStableSdkContract:
missing = sorted(STABLE_SDK_EXPORTS - set(mt5cli.__all__)) missing = sorted(STABLE_SDK_EXPORTS - set(mt5cli.__all__))
assert not missing, f"STABLE_SDK_EXPORTS missing from __all__: {missing}" assert not missing, f"STABLE_SDK_EXPORTS missing from __all__: {missing}"
def test_public_export_tiers_are_disjoint_and_complete(self) -> None:
"""Documented public tiers do not overlap and classify root exports."""
assert PUBLIC_EXPORT_TIERS == {
"stable": STABLE_SDK_EXPORTS,
"secondary": SECONDARY_PUBLIC_EXPORTS,
}
assert not (STABLE_SDK_EXPORTS & SECONDARY_PUBLIC_EXPORTS)
tiered_exports = STABLE_SDK_EXPORTS | SECONDARY_PUBLIC_EXPORTS
root_exports = set(mt5cli.__all__)
missing_from_root = sorted(tiered_exports - root_exports)
assert not missing_from_root, (
f"Tiered exports missing from __all__: {missing_from_root}"
)
tier_metadata_exports = {
"PUBLIC_EXPORT_TIERS",
"SECONDARY_PUBLIC_EXPORTS",
"STABLE_SDK_EXPORTS",
}
unclassified_root_exports = sorted(
root_exports - tiered_exports - tier_metadata_exports,
)
assert not unclassified_root_exports, (
f"Root exports missing from public API tiers: {unclassified_root_exports}"
)
def test_stable_docs_do_not_document_nonstable_exports(self) -> None:
"""Stable docs do not promote secondary root exports."""
docs_path = Path("docs/api/public-contract.md")
docs = docs_path.read_text(encoding="utf-8")
stable_section = docs.split("## Stable downstream SDK API", maxsplit=1)[
1
].split(
"## Secondary public exports",
maxsplit=1,
)[0]
documented_symbols = set(
re.findall(r"`([A-Za-z_][A-Za-z0-9_]*)`", stable_section)
)
nonstable_exports = SECONDARY_PUBLIC_EXPORTS
wrongly_stable = sorted(documented_symbols & nonstable_exports)
assert not wrongly_stable, (
f"Non-stable exports documented in stable section: {wrongly_stable}"
)
@pytest.mark.parametrize("name", sorted(STABLE_SDK_EXPORTS)) @pytest.mark.parametrize("name", sorted(STABLE_SDK_EXPORTS))
def test_stable_exports_are_importable_from_package_root(self, name: str) -> None: def test_stable_exports_are_importable_from_package_root(self, name: str) -> None:
"""Stable SDK names resolve through ``from mt5cli import ...``.""" """Stable SDK names resolve through ``from mt5cli import ...``."""
assert hasattr(mt5cli, name), f"{name!r} missing from mt5cli package root" assert hasattr(mt5cli, name), f"{name!r} missing from mt5cli package root"
@pytest.mark.parametrize(
"name",
sorted(SECONDARY_PUBLIC_EXPORTS),
)
def test_secondary_exports_are_importable(
self,
name: str,
) -> None:
"""Non-stable public names remain available from the package root."""
assert hasattr(mt5cli, name), f"{name!r} missing from mt5cli package root"
def test_drop_forming_rate_bar_from_package_root(self) -> None: def test_drop_forming_rate_bar_from_package_root(self) -> None:
"""Closed-bar trimming is available from the stable package surface.""" """Closed-bar trimming is available from the stable package surface."""
frame = pd.DataFrame({"time": [1, 2, 3], "close": [1.0, 1.1, 1.2]}) frame = pd.DataFrame({"time": [1, 2, 3], "close": [1.0, 1.1, 1.2]})
@@ -615,6 +678,15 @@ class TestStableSdkContract:
assert calculate_positions_margin(client) == 0 assert calculate_positions_margin(client) == 0
def test_generic_trading_helpers_from_package_root(self) -> None:
"""New generic trading helpers resolve through the stable surface."""
price = extract_tick_price({"bid": "1.2"}, "bid")
assert price is not None
assert abs(price - 1.2) < 1e-9
assert callable(calculate_trailing_stop_updates)
assert callable(calculate_projected_margin_ratio)
assert callable(calculate_symbol_group_margin_ratio)
def test_resolve_rate_view_name_from_package_root(self, tmp_path: Path) -> None: def test_resolve_rate_view_name_from_package_root(self, tmp_path: Path) -> None:
"""Rate view resolution is importable and honors require_existing.""" """Rate view resolution is importable and honors require_existing."""
db_path = tmp_path / "rates.db" db_path = tmp_path / "rates.db"
+491 -59
View File
@@ -19,13 +19,15 @@ from mt5cli.trading import (
MarginVolume, MarginVolume,
OrderExecutionResult, OrderExecutionResult,
OrderLimits, OrderLimits,
_valid_tick_price, # type: ignore[reportPrivateUsage]
calculate_margin_and_volume, calculate_margin_and_volume,
calculate_new_position_margin_ratio, calculate_new_position_margin_ratio,
calculate_positions_margin, calculate_positions_margin,
calculate_positions_margin_by_symbol, calculate_positions_margin_by_symbol,
calculate_positions_margin_safe, calculate_positions_margin_safe,
calculate_projected_margin_ratio,
calculate_spread_ratio, calculate_spread_ratio,
calculate_symbol_group_margin_ratio,
calculate_trailing_stop_updates,
calculate_volume_by_margin, calculate_volume_by_margin,
close_open_positions, close_open_positions,
create_trading_client, create_trading_client,
@@ -33,6 +35,7 @@ from mt5cli.trading import (
determine_order_limits, determine_order_limits,
ensure_symbol_selected, ensure_symbol_selected,
estimate_order_margin, estimate_order_margin,
extract_tick_price,
fetch_latest_closed_rates_for_trading_client, fetch_latest_closed_rates_for_trading_client,
fetch_latest_closed_rates_indexed, fetch_latest_closed_rates_indexed,
get_account_snapshot, get_account_snapshot,
@@ -43,6 +46,7 @@ from mt5cli.trading import (
normalize_order_volume, normalize_order_volume,
place_market_order, place_market_order,
update_sltp_for_open_positions, update_sltp_for_open_positions,
update_trailing_stop_loss_for_open_positions,
) )
@@ -1831,6 +1835,197 @@ class TestVolumeAndExecution:
new_position_volume=0.1, new_position_volume=0.1,
) )
def test_projected_margin_ratio_empty_positions(self) -> None:
"""Test no current or projected exposure returns zero ratio."""
client = _mock_trade_client()
client.account_info_as_dict.return_value = {"equity": 1000.0}
client.positions_get_as_df.return_value = pd.DataFrame()
_assert_close(calculate_projected_margin_ratio(client, symbol="EURUSD"), 0.0)
def test_projected_margin_ratio_current_exposure(self) -> None:
"""Test current position margin is divided by account equity."""
client = _mock_trade_client()
client.account_info_as_dict.return_value = {"equity": 1000.0}
client.positions_get_as_df.return_value = pd.DataFrame(
[{"symbol": "EURUSD", "type": 0, "volume": 0.2}],
)
client.symbol_info_tick_as_dict.return_value = {"ask": 1.101, "bid": 1.1}
client.order_calc_margin.return_value = 50.0
_assert_close(calculate_projected_margin_ratio(client, symbol="EURUSD"), 0.05)
def test_projected_margin_ratio_adds_buy_exposure(self) -> None:
"""Test projected buy margin is added to current symbol exposure."""
client = _mock_trade_client()
client.account_info_as_dict.return_value = {"equity": 1000.0}
client.positions_get_as_df.return_value = pd.DataFrame()
client.symbol_info_tick_as_dict.return_value = {"ask": 1.101, "bid": 1.1}
client.order_calc_margin.return_value = 25.0
result = calculate_projected_margin_ratio(
client,
symbol="EURUSD",
new_position_side="BUY",
new_position_volume=0.1,
)
_assert_close(result, 0.025)
client.order_calc_margin.assert_called_once_with(10, "EURUSD", 0.1, 1.101)
def test_projected_margin_ratio_adds_sell_exposure(self) -> None:
"""Test projected sell margin uses bid pricing."""
client = _mock_trade_client()
client.account_info_as_dict.return_value = {"equity": 1000.0}
client.positions_get_as_df.return_value = pd.DataFrame()
client.symbol_info_tick_as_dict.return_value = {"ask": 1.101, "bid": 1.1}
client.order_calc_margin.return_value = 24.0
result = calculate_projected_margin_ratio(
client,
symbol="EURUSD",
new_position_side="SELL",
new_position_volume=0.1,
)
_assert_close(result, 0.024)
client.order_calc_margin.assert_called_once_with(11, "EURUSD", 0.1, 1.1)
def test_symbol_group_margin_ratio_sums_group_exposure(
self,
mocker: MockerFixture,
) -> None:
"""Test symbol-group exposure sums current per-symbol margins."""
client = _mock_trade_client()
client.account_info_as_dict.return_value = {"equity": 1000.0}
mocker.patch(
"mt5cli.trading.calculate_positions_margin_by_symbol",
return_value={"EURUSD": 25.0, "GBPUSD": 35.0},
)
result = calculate_symbol_group_margin_ratio(
client,
symbols=["EURUSD", "GBPUSD"],
)
_assert_close(result, 0.06)
def test_symbol_group_margin_ratio_adds_projected_group_exposure(
self,
mocker: MockerFixture,
) -> None:
"""Test projected order margin is added when the symbol is in the group."""
client = _mock_trade_client()
client.account_info_as_dict.return_value = {"equity": 1000.0}
client.symbol_info_tick_as_dict.return_value = {"ask": 1.101, "bid": 1.1}
client.order_calc_margin.return_value = 15.0
mocker.patch(
"mt5cli.trading.calculate_positions_margin_by_symbol",
return_value={"EURUSD": 25.0},
)
result = calculate_symbol_group_margin_ratio(
client,
symbols=["EURUSD"],
new_symbol="EURUSD",
new_position_side="BUY",
new_position_volume=0.1,
)
_assert_close(result, 0.04)
def test_symbol_group_margin_ratio_suppresses_per_symbol_failures(
self,
mocker: MockerFixture,
) -> None:
"""Test suppressible per-symbol failures are skipped by the safe map."""
client = _mock_trade_client()
client.account_info_as_dict.return_value = {"equity": 1000.0}
mocker.patch(
"mt5cli.trading.calculate_positions_margin",
side_effect=[Mt5TradingError("bad tick"), 30.0],
)
result = calculate_symbol_group_margin_ratio(
client,
symbols=["EURUSD", "GBPUSD"],
suppress_errors=True,
)
_assert_close(result, 0.03)
def test_symbol_group_margin_ratio_rejects_invalid_equity(self) -> None:
"""Test invalid equity fails closed for exposure helpers."""
client = _mock_trade_client()
client.account_info_as_dict.return_value = {"equity": 0.0}
with pytest.raises(Mt5TradingError, match="Account equity"):
calculate_symbol_group_margin_ratio(client, symbols=["EURUSD"])
def test_projected_margin_ratio_rejects_nonnumeric_equity(self) -> None:
"""Test nonnumeric equity fails closed for exposure helpers."""
client = _mock_trade_client()
client.account_info_as_dict.return_value = {"equity": "invalid"}
with pytest.raises(Mt5TradingError, match="Account equity"):
calculate_projected_margin_ratio(client, symbol="EURUSD")
def test_symbol_group_margin_ratio_suppresses_projected_failure(
self,
mocker: MockerFixture,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test projected margin failures can be skipped for safe group reads."""
client = _mock_trade_client()
client.account_info_as_dict.return_value = {"equity": 1000.0}
mocker.patch(
"mt5cli.trading.calculate_positions_margin_by_symbol",
return_value={},
)
mocker.patch(
"mt5cli.trading.estimate_order_margin",
side_effect=Mt5TradingError("bad tick"),
)
with caplog.at_level(logging.WARNING, logger="mt5cli.trading"):
result = calculate_symbol_group_margin_ratio(
client,
symbols=["EURUSD"],
new_symbol="EURUSD",
new_position_side="BUY",
new_position_volume=0.1,
suppress_errors=True,
)
_assert_close(result, 0.0)
assert "Skipping projected margin" in caplog.text
def test_symbol_group_margin_ratio_reraises_projected_failure(
self,
mocker: MockerFixture,
) -> None:
"""Test projected margin failures raise when suppression is disabled."""
client = _mock_trade_client()
client.account_info_as_dict.return_value = {"equity": 1000.0}
mocker.patch(
"mt5cli.trading.calculate_positions_margin_by_symbol",
return_value={},
)
mocker.patch(
"mt5cli.trading.estimate_order_margin",
side_effect=Mt5TradingError("bad tick"),
)
with pytest.raises(Mt5TradingError, match="bad tick"):
calculate_symbol_group_margin_ratio(
client,
symbols=["EURUSD"],
new_symbol="EURUSD",
new_position_side="BUY",
new_position_volume=0.1,
suppress_errors=False,
)
def test_place_market_order_dry_run_does_not_send(self) -> None: def test_place_market_order_dry_run_does_not_send(self) -> None:
"""Test dry-run market orders return a request without sending.""" """Test dry-run market orders return a request without sending."""
client = _mock_trade_client() client = _mock_trade_client()
@@ -2199,6 +2394,283 @@ class TestVolumeAndExecution:
assert client.order_send.call_args.args[0]["position"] == 9 assert client.order_send.call_args.args[0]["position"] == 9
def test_calculate_trailing_stop_updates_no_positions(self) -> None:
"""Test empty position sets produce no trailing updates."""
client = _mock_trade_client()
client.positions_get_as_df.return_value = pd.DataFrame()
assert (
calculate_trailing_stop_updates(
client,
symbol="EURUSD",
trailing_stop_ratio=0.02,
)
== {}
)
def test_calculate_trailing_stop_updates_buy_positions(self) -> None:
"""Test buy trailing stops use bid and improve only upward."""
client = _mock_trade_client()
client.positions_get_as_df.return_value = pd.DataFrame(
[
{"ticket": 1, "symbol": "EURUSD", "type": 0, "volume": 0.1, "sl": 1.0},
{
"ticket": 2,
"symbol": "EURUSD",
"type": 0,
"volume": 0.1,
"sl": 1.19,
},
],
)
client.symbol_info_tick_as_dict.return_value = {"bid": 1.2, "ask": 1.201}
client.symbol_info_as_dict.return_value = {"digits": 4}
result = calculate_trailing_stop_updates(
client,
symbol="EURUSD",
trailing_stop_ratio=0.01,
)
assert result == {1: 1.188}
def test_calculate_trailing_stop_updates_buy_positions_ignore_invalid_ask(
self,
) -> None:
"""Test buy trailing stops do not require an ask price."""
client = _mock_trade_client()
client.positions_get_as_df.return_value = pd.DataFrame(
[{"ticket": 1, "symbol": "EURUSD", "type": 0, "volume": 0.1, "sl": 1.0}],
)
client.symbol_info_tick_as_dict.return_value = {"bid": 1.2, "ask": 0.0}
client.symbol_info_as_dict.return_value = {"digits": 4}
result = calculate_trailing_stop_updates(
client,
symbol="EURUSD",
trailing_stop_ratio=0.01,
)
assert result == {1: 1.188}
def test_calculate_trailing_stop_updates_sell_positions(self) -> None:
"""Test sell trailing stops use ask and improve only downward."""
client = _mock_trade_client()
client.positions_get_as_df.return_value = pd.DataFrame(
[
{"ticket": 3, "symbol": "EURUSD", "type": 1, "volume": 0.1, "sl": 1.3},
{
"ticket": 4,
"symbol": "EURUSD",
"type": 1,
"volume": 0.1,
"sl": 1.21,
},
],
)
client.symbol_info_tick_as_dict.return_value = {"bid": 1.198, "ask": 1.2}
client.symbol_info_as_dict.return_value = {"digits": 4}
result = calculate_trailing_stop_updates(
client,
symbol="EURUSD",
trailing_stop_ratio=0.01,
)
assert result == {3: 1.212}
def test_calculate_trailing_stop_updates_sell_positions_ignore_invalid_bid(
self,
) -> None:
"""Test sell trailing stops do not require a bid price."""
client = _mock_trade_client()
client.positions_get_as_df.return_value = pd.DataFrame(
[{"ticket": 3, "symbol": "EURUSD", "type": 1, "volume": 0.1, "sl": 1.3}],
)
client.symbol_info_tick_as_dict.return_value = {"bid": 0.0, "ask": 1.2}
client.symbol_info_as_dict.return_value = {"digits": 4}
result = calculate_trailing_stop_updates(
client,
symbol="EURUSD",
trailing_stop_ratio=0.01,
)
assert result == {3: 1.212}
def test_calculate_trailing_stop_updates_invalid_bid_or_ask(self) -> None:
"""Test invalid side-specific tick prices fail safely without updates."""
client = _mock_trade_client()
client.positions_get_as_df.return_value = pd.DataFrame(
[
{"ticket": 1, "symbol": "EURUSD", "type": 0, "volume": 0.1, "sl": 1.0},
{"ticket": 2, "symbol": "EURUSD", "type": 1, "volume": 0.1, "sl": 1.3},
],
)
client.symbol_info_as_dict.return_value = {"digits": 4}
client.symbol_info_tick_as_dict.return_value = {"bid": 0.0, "ask": None}
assert (
calculate_trailing_stop_updates(
client,
symbol="EURUSD",
trailing_stop_ratio=0.01,
)
== {}
)
def test_calculate_trailing_stop_updates_mixed_positions_skip_invalid_side(
self,
) -> None:
"""Test one invalid side price does not block the valid side."""
client = _mock_trade_client()
client.positions_get_as_df.return_value = pd.DataFrame(
[
{"ticket": 1, "symbol": "EURUSD", "type": 0, "volume": 0.1, "sl": 1.0},
{"ticket": 2, "symbol": "EURUSD", "type": 1, "volume": 0.1, "sl": 1.3},
],
)
client.symbol_info_as_dict.return_value = {"digits": 4}
client.symbol_info_tick_as_dict.return_value = {"bid": 1.2, "ask": 0.0}
assert calculate_trailing_stop_updates(
client,
symbol="EURUSD",
trailing_stop_ratio=0.01,
) == {1: 1.188}
client.symbol_info_tick_as_dict.return_value = {"bid": None, "ask": 1.2}
assert calculate_trailing_stop_updates(
client,
symbol="EURUSD",
trailing_stop_ratio=0.01,
) == {2: 1.212}
def test_calculate_trailing_stop_updates_invalid_symbol_digits(self) -> None:
"""Test invalid symbol metadata fails safely without updates."""
client = _mock_trade_client()
client.positions_get_as_df.return_value = pd.DataFrame(
[{"ticket": 1, "symbol": "EURUSD", "type": 0, "volume": 0.1, "sl": 1.0}],
)
client.symbol_info_tick_as_dict.return_value = {"bid": 1.2, "ask": 1.201}
client.symbol_info_as_dict.return_value = {"digits": "bad"}
assert (
calculate_trailing_stop_updates(
client,
symbol="EURUSD",
trailing_stop_ratio=0.01,
)
== {}
)
def test_calculate_trailing_stop_updates_missing_symbol_digits(self) -> None:
"""Test missing symbol digits fail safely without rounded updates."""
client = _mock_trade_client()
client.positions_get_as_df.return_value = pd.DataFrame(
[{"ticket": 1, "symbol": "EURUSD", "type": 0, "volume": 0.1, "sl": 1.0}],
)
client.symbol_info_tick_as_dict.return_value = {"bid": 1.2, "ask": 1.201}
client.symbol_info_as_dict.return_value = {}
assert (
calculate_trailing_stop_updates(
client,
symbol="EURUSD",
trailing_stop_ratio=0.01,
)
== {}
)
client.symbol_info_as_dict.return_value = {"digits": None}
assert (
calculate_trailing_stop_updates(
client,
symbol="EURUSD",
trailing_stop_ratio=0.01,
)
== {}
)
def test_calculate_trailing_stop_updates_skips_invalid_rows(self) -> None:
"""Test invalid tickets and unknown position types are ignored."""
client = _mock_trade_client()
client.positions_get_as_df.return_value = pd.DataFrame(
[
{
"ticket": None,
"symbol": "EURUSD",
"type": 0,
"volume": 0.1,
"sl": 1.0,
},
{
"ticket": "5",
"symbol": "EURUSD",
"type": "unknown",
"volume": 0.1,
"sl": 1.0,
},
],
)
client.symbol_info_tick_as_dict.return_value = {"bid": 1.2, "ask": 1.201}
client.symbol_info_as_dict.return_value = {"digits": 4}
assert (
calculate_trailing_stop_updates(
client,
symbol="EURUSD",
trailing_stop_ratio=0.01,
)
== {}
)
def test_update_trailing_stop_loss_dry_run(self) -> None:
"""Test trailing-stop update wrapper supports dry-run requests."""
client = _mock_trade_client()
client.positions_get_as_df.return_value = pd.DataFrame(
[{"ticket": 1, "symbol": "EURUSD", "type": 0, "volume": 0.1, "sl": 1.0}],
)
client.symbol_info_tick_as_dict.return_value = {"bid": 1.2, "ask": 1.201}
client.symbol_info_as_dict.return_value = {"digits": 4}
result = update_trailing_stop_loss_for_open_positions(
client,
symbol="EURUSD",
trailing_stop_ratio=0.01,
dry_run=True,
)
assert len(result) == 1
assert result[0]["status"] == "dry_run"
_assert_close(_request_from_result(result[0])["sl"], 1.188)
client.order_send.assert_not_called()
def test_update_trailing_stop_loss_sends_changed_sl(self) -> None:
"""Test trailing-stop wrapper sends normalized SL/TP updates."""
client = _mock_trade_client()
client.symbol_select.return_value = True
client.positions_get_as_df.return_value = pd.DataFrame(
[{"ticket": 1, "symbol": "EURUSD", "type": 0, "volume": 0.1, "sl": 1.0}],
)
client.symbol_info_tick_as_dict.return_value = {"bid": 1.2, "ask": 1.201}
client.symbol_info_as_dict.return_value = {"digits": 4, "visible": True}
client.order_send.return_value = pd.DataFrame(
[{"retcode": 10009, "comment": "updated"}],
)
result = update_trailing_stop_loss_for_open_positions(
client,
symbol="EURUSD",
trailing_stop_ratio=0.01,
)
assert result[0]["status"] == "executed"
_assert_close(_request_from_result(result[0])["sl"], 1.188)
client.order_send.assert_called_once()
def test_update_sltp_filters_and_dry_runs(self) -> None: def test_update_sltp_filters_and_dry_runs(self) -> None:
"""Test SL/TP updates filter positions and do not send in dry-run mode.""" """Test SL/TP updates filter positions and do not send in dry-run mode."""
client = _mock_trade_client() client = _mock_trade_client()
@@ -2609,32 +3081,6 @@ class TestFetchLatestClosedRatesForTradingClient:
assert list(result["close"]) == [1.0, 1.1] assert list(result["close"]) == [1.0, 1.1]
assert list(result["time"]) == [1, 2] assert list(result["time"]) == [1, 2]
def test_falls_back_to_copy_rates_from_pos_as_df(self) -> None:
"""Test legacy trading clients without fetch helper still work."""
client = MagicMock(spec=["copy_rates_from_pos_as_df", "mt5"])
del client.fetch_latest_rates_as_df
client.copy_rates_from_pos_as_df.return_value = pd.DataFrame(
{
"time": [1, 2, 3],
"close": [1.0, 1.1, 1.2],
},
)
result = fetch_latest_closed_rates_for_trading_client(
client,
symbol="EURUSD",
granularity="M1",
count=2,
)
client.copy_rates_from_pos_as_df.assert_called_once_with(
symbol="EURUSD",
timeframe=1,
start_pos=0,
count=3,
)
assert list(result["close"]) == [1.0, 1.1]
def test_accepts_numeric_epoch_timestamps(self) -> None: def test_accepts_numeric_epoch_timestamps(self) -> None:
"""Test numeric epoch timestamps are preserved in output.""" """Test numeric epoch timestamps are preserved in output."""
client = MagicMock() client = MagicMock()
@@ -2811,24 +3257,6 @@ class TestFetchLatestClosedRatesForTradingClient:
client.fetch_latest_rates_as_df.assert_not_called() 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: class TestFetchLatestClosedRatesIndexed:
"""Tests for fetch_latest_closed_rates_indexed.""" """Tests for fetch_latest_closed_rates_indexed."""
@@ -3078,58 +3506,62 @@ class TestFetchLatestClosedRatesIndexed:
assert isinstance(result.index, pd.DatetimeIndex) assert isinstance(result.index, pd.DatetimeIndex)
class TestValidTickPrice: class TestExtractTickPrice:
"""Tests for the _valid_tick_price internal helper (#49).""" """Tests for the public extract_tick_price helper."""
def test_valid_positive_float(self) -> None: def test_valid_positive_float(self) -> None:
"""Returns a positive float value unchanged.""" """Returns a positive float value unchanged."""
_assert_close(_valid_tick_price({"bid": 1.1000}, "bid"), 1.1000) _assert_close(extract_tick_price({"bid": 1.1000}, "bid"), 1.1000)
def test_valid_positive_int(self) -> None: def test_valid_positive_int(self) -> None:
"""Accepts an integer value and returns it as float.""" """Accepts an integer value and returns it as float."""
result = _valid_tick_price({"bid": 2}, "bid") result = extract_tick_price({"bid": 2}, "bid")
_assert_close(result, 2.0) _assert_close(result, 2.0)
assert isinstance(result, float) assert isinstance(result, float)
def test_valid_numeric_string(self) -> None: def test_valid_numeric_string(self) -> None:
"""Accepts a numeric string and returns the parsed float.""" """Accepts a numeric string and returns the parsed float."""
_assert_close(_valid_tick_price({"bid": "1.5"}, "bid"), 1.5) _assert_close(extract_tick_price({"bid": "1.5"}, "bid"), 1.5)
def test_missing_key(self) -> None: def test_missing_key(self) -> None:
"""Returns None when the key is absent from the tick dict.""" """Returns None when the key is absent from the tick dict."""
assert _valid_tick_price({}, "bid") is None assert extract_tick_price({}, "bid") is None
def test_none_value(self) -> None: def test_none_value(self) -> None:
"""Returns None when the stored value is None.""" """Returns None when the stored value is None."""
assert _valid_tick_price({"bid": None}, "bid") is None assert extract_tick_price({"bid": None}, "bid") is None
def test_bool_value(self) -> None:
"""Returns None for bool values even though bool is int-like."""
assert extract_tick_price({"bid": True}, "bid") is None
def test_invalid_string(self) -> None: def test_invalid_string(self) -> None:
"""Returns None for a non-numeric string.""" """Returns None for a non-numeric string."""
assert _valid_tick_price({"bid": "not_a_number"}, "bid") is None assert extract_tick_price({"bid": "not_a_number"}, "bid") is None
def test_nan(self) -> None: def test_nan(self) -> None:
"""Returns None for a NaN float.""" """Returns None for a NaN float."""
assert _valid_tick_price({"bid": float("nan")}, "bid") is None assert extract_tick_price({"bid": float("nan")}, "bid") is None
def test_positive_infinity(self) -> None: def test_positive_infinity(self) -> None:
"""Returns None for positive infinity.""" """Returns None for positive infinity."""
assert _valid_tick_price({"bid": float("inf")}, "bid") is None assert extract_tick_price({"bid": float("inf")}, "bid") is None
def test_negative_infinity(self) -> None: def test_negative_infinity(self) -> None:
"""Returns None for negative infinity.""" """Returns None for negative infinity."""
assert _valid_tick_price({"bid": float("-inf")}, "bid") is None assert extract_tick_price({"bid": float("-inf")}, "bid") is None
def test_zero(self) -> None: def test_zero(self) -> None:
"""Returns None for zero (not a valid price).""" """Returns None for zero (not a valid price)."""
assert _valid_tick_price({"bid": 0.0}, "bid") is None assert extract_tick_price({"bid": 0.0}, "bid") is None
def test_negative_value(self) -> None: def test_negative_value(self) -> None:
"""Returns None for a negative price.""" """Returns None for a negative price."""
assert _valid_tick_price({"bid": -1.0}, "bid") is None assert extract_tick_price({"bid": -1.0}, "bid") is None
def test_unsupported_type(self) -> None: def test_unsupported_type(self) -> None:
"""Returns None for unsupported value types such as list.""" """Returns None for unsupported value types such as list."""
assert _valid_tick_price({"bid": [1.0]}, "bid") is None assert extract_tick_price({"bid": [1.0]}, "bid") is None
class TestCalculatePositionsMarginBySymbol: class TestCalculatePositionsMarginBySymbol:
Generated
+1 -1
View File
@@ -487,7 +487,7 @@ wheels = [
[[package]] [[package]]
name = "mt5cli" name = "mt5cli"
version = "0.9.2" version = "0.9.3"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },