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
This commit is contained in:
@@ -31,7 +31,7 @@ pip install -U mt5cli MetaTrader5
|
||||
|
||||
## Python API (downstream packages)
|
||||
|
||||
Import `MT5Client` for generic MT5 data access, schema normalization, and optional order primitives. `Mt5CliClient` remains available as a backward-compatible alias.
|
||||
Import `MT5Client` for generic MT5 data access, schema normalization, and optional order primitives.
|
||||
|
||||
```python
|
||||
from datetime import UTC, datetime
|
||||
@@ -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.
|
||||
- **Throttled history updates**: use `ThrottledHistoryUpdater` to wrap `update_history()` with a minimum `interval_seconds` between successful runs (monotonic clock). Call `should_update()` / `update(client, symbols)` from an application loop; errors propagate by default, or pass `suppress_errors=True` to swallow recoverable `Mt5*Error`, `sqlite3.Error`, `ValueError`, `OSError`, and MT5 client capability errors for history API methods without advancing the throttle (other `AttributeError` / `TypeError` values always propagate). Pass `update_backend` to inject a custom history update callable (same keyword arguments as `update_history`) instead of monkey-patching `mt5cli.sdk.update_history`.
|
||||
- **Trading session helpers**: use `mt5_trading_session()` for a trading-capable `pdmt5.Mt5TradingClient` that initializes/logs in via `Mt5Config.path` and always shuts down safely. Pair with `detect_position_side()`, `calculate_margin_and_volume()`, and `determine_order_limits()` for generic position and sizing utilities. The read-only `mt5_session()` / `Mt5CliClient` SDK is unchanged.
|
||||
- **Trading session helpers**: use `mt5_trading_session()` for a trading-capable `pdmt5.Mt5TradingClient` that initializes/logs in via `Mt5Config.path` and always shuts down safely. Pair with `detect_position_side()`, `calculate_margin_and_volume()`, and `determine_order_limits()` for generic position and sizing utilities. Keep read-only collection on `mt5_session()` / `MT5Client`.
|
||||
- **Granularity-keyed rate loading**: `load_rate_series_by_granularity()` builds targets with `build_rate_targets()`, loads them with `load_rate_series_from_sqlite()`, and returns a mapping keyed by `(symbol | None, granularity_name)` such as `("EURUSD", "M1")` to reduce downstream boilerplate.
|
||||
- **MT5 session helper**: use the `mt5_session()` context manager to attach to (or, when `Mt5Config.path` is set, launch) an MT5 terminal, log in, and yield a connected `MT5Client` that shuts down on exit.
|
||||
- **SQLite export helpers**: use `export_dataframe_to_sqlite()` for append mode, optional index export, and post-write deduplication by key columns.
|
||||
@@ -317,7 +317,7 @@ finally:
|
||||
client.shutdown()
|
||||
```
|
||||
|
||||
Read-only collectors can keep using `mt5_session()` and `MT5Client` (or the `Mt5CliClient` alias) without changes.
|
||||
Read-only collectors can keep using `mt5_session()` and `MT5Client`.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
+72
-47
@@ -8,20 +8,29 @@ downstream app -> mt5cli -> pdmt5 -> MetaTrader 5
|
||||
```
|
||||
|
||||
Downstream packages should import from the package root (`from mt5cli import
|
||||
...`) and treat the symbols listed below as the stable SDK contract. CLI
|
||||
commands mirror the same behavior but are not importable Python APIs.
|
||||
...`) and use the public tier sets in `mt5cli.contract` to distinguish API
|
||||
stability. CLI commands mirror the same behavior but are not importable Python
|
||||
APIs.
|
||||
|
||||
## Public API tiers
|
||||
|
||||
mt5cli classifies package-root imports by intended downstream use:
|
||||
|
||||
| Tier | Contract set | Meaning |
|
||||
| ---------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Stable core | `STABLE_SDK_EXPORTS` | Preferred SDK surface for downstream MT5 infrastructure adapters. Changes require a deliberate compatibility path. |
|
||||
| Secondary public | `SECONDARY_PUBLIC_EXPORTS` | Public helpers for CLI/export/schema integrations and lower-level MT5 wrappers. Importable, but less central to the downstream trading SDK. |
|
||||
|
||||
## Stable downstream SDK API
|
||||
|
||||
These names are exported from `mt5cli` and covered by the contract in
|
||||
`mt5cli.STABLE_SDK_EXPORTS` (defined in `mt5cli.contract`). Prefer `MT5Client` over the legacy `Mt5CliClient`
|
||||
alias for new code.
|
||||
`mt5cli.STABLE_SDK_EXPORTS` (defined in `mt5cli.contract`).
|
||||
|
||||
### Session lifecycle and configuration
|
||||
|
||||
| Symbol | Role |
|
||||
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| `MT5Client`, `Mt5CliClient` | Read-only data client with optional `order_check` / `order_send` |
|
||||
| `MT5Client` | 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 |
|
||||
@@ -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.
|
||||
Default is `False` to preserve backward compatibility.
|
||||
|
||||
### Read-only MT5 data access
|
||||
|
||||
Module-level helpers open a transient connection per call. Prefer `mt5_session`
|
||||
or `MT5Client` when making many requests in one process.
|
||||
|
||||
| Area | Symbols |
|
||||
| -------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| Rates | `copy_rates_from`, `copy_rates_from_pos`, `copy_rates_range`, `latest_rates`, `collect_latest_rates` |
|
||||
| Ticks | `copy_ticks_from`, `copy_ticks_range`, `recent_ticks` |
|
||||
| Account / terminal | `account_info`, `terminal_info`, `mt5_version`, `last_error`, `mt5_summary`, `mt5_summary_as_df` |
|
||||
| Symbols / market | `symbols`, `symbol_info`, `symbol_info_tick`, `market_book`, `minimum_margins` |
|
||||
| Trading state (read) | `orders`, `positions`, `history_orders`, `history_deals`, `recent_history_deals` |
|
||||
|
||||
Use `mt5_version` for MetaTrader 5 terminal version data. The name `version` at
|
||||
the package root refers to `importlib.metadata.version` (package metadata), not
|
||||
the MT5 SDK helper.
|
||||
|
||||
### Closed-bar rate helpers
|
||||
|
||||
MetaTrader 5 returns the still-forming bar as the last row when
|
||||
@@ -71,7 +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) |
|
||||
| `collect_latest_closed_rates_for_accounts` | Multi-account closed bars with optional retry wrapper |
|
||||
| `collect_latest_closed_rates_by_granularity` | Same data keyed by `(symbol, granularity_name)` |
|
||||
| `collect_latest_rates_for_accounts` | Latest bars including the forming bar when `start_pos=0` |
|
||||
| `collect_latest_rates_for_accounts_with_retries` | Bounded exponential backoff for transient MT5 errors |
|
||||
|
||||
### SQLite history collection and rate loading
|
||||
@@ -99,20 +90,24 @@ 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 |
|
||||
| `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 |
|
||||
| Symbol | Role |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- |
|
||||
| `get_account_snapshot`, `get_symbol_snapshot`, `get_tick_snapshot`, `get_positions_frame` | Normalized account/symbol/tick/position views |
|
||||
| `extract_tick_price` | Positive finite bid/ask extraction from tick mappings |
|
||||
| `detect_position_side` | Net long / short / flat from open positions |
|
||||
| `calculate_spread_ratio` | Relative bid-ask spread |
|
||||
| `calculate_margin_and_volume`, `calculate_volume_by_margin`, `calculate_new_position_margin_ratio` | Margin budget and volume sizing |
|
||||
| `normalize_order_volume`, `estimate_order_margin`, `calculate_positions_margin` | Broker volume normalization and margin totals |
|
||||
| `calculate_positions_margin_by_symbol` | Per-symbol margin map (resilient, first-seen order) |
|
||||
| `calculate_positions_margin_safe` | Summed total margin across symbols (failed symbols skipped) |
|
||||
| `calculate_projected_margin_ratio` | Estimated symbol margin/equity after optional new exposure |
|
||||
| `calculate_symbol_group_margin_ratio` | Estimated symbol-group margin/equity with optional exposure |
|
||||
| `determine_order_limits` | SL/TP price levels from ratios |
|
||||
| `calculate_trailing_stop_updates` | Per-ticket generic trailing stop-loss update plan |
|
||||
| `ensure_symbol_selected` | Select/verify Market Watch visibility |
|
||||
| `place_market_order`, `close_open_positions`, `update_sltp_for_open_positions`, `update_trailing_stop_loss_for_open_positions` | Order execution helpers (`dry_run` supported) |
|
||||
| `MarginVolume`, `OrderLimits`, `OrderExecutionResult` | Typed return contracts for order helpers |
|
||||
| `OrderSide`, `OrderFillingMode`, `OrderTimeMode`, `PositionSide`, `ExecutionStatus` | Typed enums for order helpers |
|
||||
|
||||
`MT5Client.order_send()` and CLI `order-send --yes` are live execution paths.
|
||||
|
||||
@@ -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 |
|
||||
| `Mt5Config`, `Mt5RuntimeError`, `Mt5TradingClient`, `Mt5TradingError` | Re-exported pdmt5 types for adapter convenience |
|
||||
|
||||
### Additional public exports (secondary)
|
||||
## Secondary public exports
|
||||
|
||||
The package root also exports schema, storage, and parsing helpers (for example
|
||||
`DataKind`, `Dataset`, `normalize_dataframe`, `export_dataframe`,
|
||||
`parse_timeframe`, `TIMEFRAME_MAP`). These are public but oriented toward export
|
||||
pipelines and advanced integration. Prefer the stable symbols above for core
|
||||
infrastructure.
|
||||
These names remain importable from `mt5cli` and are covered by
|
||||
`SECONDARY_PUBLIC_EXPORTS`, but they are oriented toward CLI/export/schema
|
||||
integrations, parsing, and lower-level MT5 access rather than the stable core
|
||||
SDK surface. Prefer the stable symbols above for downstream infrastructure
|
||||
adapters.
|
||||
|
||||
### Read-only MT5 data wrappers
|
||||
|
||||
Module-level helpers open a transient connection per call. Prefer `mt5_session`
|
||||
or `MT5Client` when making many requests in one process.
|
||||
|
||||
| Area | Symbols |
|
||||
| -------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| Rates | `copy_rates_from`, `copy_rates_from_pos`, `copy_rates_range`, `latest_rates`, `collect_latest_rates` |
|
||||
| Ticks | `copy_ticks_from`, `copy_ticks_range`, `recent_ticks` |
|
||||
| Account / terminal | `account_info`, `terminal_info`, `mt5_version`, `last_error`, `mt5_summary`, `mt5_summary_as_df` |
|
||||
| Symbols / market | `symbols`, `symbol_info`, `symbol_info_tick`, `market_book`, `minimum_margins` |
|
||||
| Trading state (read) | `orders`, `positions`, `history_orders`, `history_deals`, `recent_history_deals` |
|
||||
| Multi-account rates | `collect_latest_rates_for_accounts` |
|
||||
|
||||
Use `mt5_version` for MetaTrader 5 terminal version data. The name `version` at
|
||||
the package root refers to `importlib.metadata.version` (package metadata), not
|
||||
the MT5 SDK helper.
|
||||
|
||||
### Schema, export, and parser helpers
|
||||
|
||||
| Area | Symbols |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| Dataset contracts | `DataKind`, `Dataset`, `IfExists`, `DEDUP_KEYS`, `REQUIRED_COLUMNS`, `TIME_COLUMNS`, `KNOWN_MT5_TIME_COLUMNS` |
|
||||
| Schema normalization | `normalize_dataframe`, `normalize_time_columns`, `schema_columns`, `validate_schema` |
|
||||
| Export helpers | `detect_format`, `export_dataframe`, `export_dataframe_to_sqlite` |
|
||||
| Symbol parsing | `normalize_symbol`, `normalize_symbols` |
|
||||
| Time parsing | `ensure_utc`, `parse_date_range`, `parse_datetime`, `recent_window` |
|
||||
| MT5 parsing maps | `granularity_name`, `parse_tick_flags`, `parse_timeframe`, `TICK_FLAG_MAP`, `TIMEFRAME_MAP` |
|
||||
| Trading data shapes | `POSITION_COLUMNS` |
|
||||
|
||||
## CLI commands
|
||||
|
||||
@@ -190,7 +215,7 @@ their own adapter layer.
|
||||
|
||||
## Contract verification
|
||||
|
||||
`tests/test_contracts.py` asserts that every name in `STABLE_SDK_EXPORTS` is
|
||||
importable from `mt5cli`, documents key closed-bar, rate-view, SQLite loading,
|
||||
account-resolution, and trading-session behaviors, and keeps the contract set
|
||||
aligned with `__all__`.
|
||||
`tests/test_contracts.py` asserts that every name in the stable and secondary
|
||||
tier sets is importable from `mt5cli`, documents key closed-bar, rate-view,
|
||||
SQLite loading, account-resolution, and trading-session behaviors, and keeps the
|
||||
tier sets aligned with `__all__`.
|
||||
|
||||
+3
-3
@@ -31,7 +31,7 @@ rates = collect_latest_rates_for_accounts_with_retries(
|
||||
### Latest closed rate bars
|
||||
|
||||
MetaTrader 5 `start_pos=0` includes the still-forming current bar as the last
|
||||
row. `fetch_latest_closed_rates()` handles one connected `Mt5CliClient`; use
|
||||
row. `fetch_latest_closed_rates()` handles one connected `MT5Client`; use
|
||||
`fetch_latest_closed_rates_for_trading_client()` from an active
|
||||
`Mt5TradingClient` session. Multi-account helpers fetch `count + 1` bars, drop
|
||||
that row with `drop_forming_rate_bar()`, and validate each series is non-empty. Returned frames are ordered
|
||||
@@ -170,5 +170,5 @@ resulting `ValueError` is suppressed along with other recoverable errors.
|
||||
## Trading-capable sessions
|
||||
|
||||
For order placement and trading calculations, use the dedicated
|
||||
[Trading module](trading.md). The read-only `Mt5CliClient` and `mt5_session()`
|
||||
helpers in this module are unchanged.
|
||||
[Trading module](trading.md). Use `mt5_session()` / `MT5Client` for read-only
|
||||
collection.
|
||||
|
||||
+2
-2
@@ -31,7 +31,7 @@ finally:
|
||||
`login` accepts `int`, numeric `str`, or an empty string; empty strings are
|
||||
treated as unset. `path`, `password`, `server`, and `timeout` are forwarded to
|
||||
`pdmt5.Mt5Config`, and omitted `timeout` values keep the lower-level default.
|
||||
The read-only `Mt5CliClient` / `mt5_session()` API is unchanged.
|
||||
Use `mt5_session()` / `MT5Client` for read-only data collection.
|
||||
|
||||
## State and order helpers
|
||||
|
||||
@@ -194,6 +194,6 @@ through the stable package root without embedding entry/exit policy.
|
||||
| Local SL/TP price derivation | `determine_order_limits()` |
|
||||
| Throttled SQLite history loop with ad-hoc error handling | `ThrottledHistoryUpdater(suppress_errors=True)` |
|
||||
|
||||
Keep read-only data collection on `mt5_session()` / `Mt5CliClient`; use
|
||||
Keep read-only data collection on `mt5_session()` / `MT5Client`; use
|
||||
`mt5_trading_session()` only where order placement or trading calculations are
|
||||
required.
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ pip install mt5cli
|
||||
|
||||
## Python API for downstream packages
|
||||
|
||||
Import `MT5Client` for generic MT5 data access, schema normalization, and optional order primitives. `Mt5CliClient` remains available as a backward-compatible alias.
|
||||
Import `MT5Client` for generic MT5 data access, schema normalization, and optional order primitives.
|
||||
|
||||
```python
|
||||
from datetime import UTC, datetime
|
||||
|
||||
+17
-3
@@ -11,7 +11,11 @@ from importlib.metadata import version
|
||||
from pdmt5 import Mt5Config, Mt5RuntimeError, Mt5TradingClient, Mt5TradingError
|
||||
|
||||
from .client import MT5Client, build_config, mt5_session
|
||||
from .contract import STABLE_SDK_EXPORTS
|
||||
from .contract import (
|
||||
PUBLIC_EXPORT_TIERS,
|
||||
SECONDARY_PUBLIC_EXPORTS,
|
||||
STABLE_SDK_EXPORTS,
|
||||
)
|
||||
from .converters import (
|
||||
ensure_utc,
|
||||
granularity_name,
|
||||
@@ -59,7 +63,6 @@ from .schemas import (
|
||||
)
|
||||
from .sdk import (
|
||||
AccountSpec,
|
||||
Mt5CliClient,
|
||||
ThrottledHistoryUpdater,
|
||||
account_info,
|
||||
collect_history,
|
||||
@@ -121,7 +124,10 @@ from .trading import (
|
||||
calculate_positions_margin,
|
||||
calculate_positions_margin_by_symbol,
|
||||
calculate_positions_margin_safe,
|
||||
calculate_projected_margin_ratio,
|
||||
calculate_spread_ratio,
|
||||
calculate_symbol_group_margin_ratio,
|
||||
calculate_trailing_stop_updates,
|
||||
calculate_volume_by_margin,
|
||||
close_open_positions,
|
||||
create_trading_client,
|
||||
@@ -129,6 +135,7 @@ from .trading import (
|
||||
determine_order_limits,
|
||||
ensure_symbol_selected,
|
||||
estimate_order_margin,
|
||||
extract_tick_price,
|
||||
fetch_latest_closed_rates_for_trading_client,
|
||||
fetch_latest_closed_rates_indexed,
|
||||
get_account_snapshot,
|
||||
@@ -139,6 +146,7 @@ from .trading import (
|
||||
normalize_order_volume,
|
||||
place_market_order,
|
||||
update_sltp_for_open_positions,
|
||||
update_trailing_stop_loss_for_open_positions,
|
||||
)
|
||||
from .utils import (
|
||||
TICK_FLAG_MAP,
|
||||
@@ -154,7 +162,9 @@ __all__ = [
|
||||
"DEDUP_KEYS",
|
||||
"KNOWN_MT5_TIME_COLUMNS",
|
||||
"POSITION_COLUMNS",
|
||||
"PUBLIC_EXPORT_TIERS",
|
||||
"REQUIRED_COLUMNS",
|
||||
"SECONDARY_PUBLIC_EXPORTS",
|
||||
"STABLE_SDK_EXPORTS",
|
||||
"TICK_FLAG_MAP",
|
||||
"TIMEFRAME_MAP",
|
||||
@@ -166,7 +176,6 @@ __all__ = [
|
||||
"IfExists",
|
||||
"MT5Client",
|
||||
"MarginVolume",
|
||||
"Mt5CliClient",
|
||||
"Mt5CliError",
|
||||
"Mt5Config",
|
||||
"Mt5ConnectionError",
|
||||
@@ -192,7 +201,10 @@ __all__ = [
|
||||
"calculate_positions_margin",
|
||||
"calculate_positions_margin_by_symbol",
|
||||
"calculate_positions_margin_safe",
|
||||
"calculate_projected_margin_ratio",
|
||||
"calculate_spread_ratio",
|
||||
"calculate_symbol_group_margin_ratio",
|
||||
"calculate_trailing_stop_updates",
|
||||
"calculate_volume_by_margin",
|
||||
"call_with_normalized_errors",
|
||||
"close_open_positions",
|
||||
@@ -217,6 +229,7 @@ __all__ = [
|
||||
"estimate_order_margin",
|
||||
"export_dataframe",
|
||||
"export_dataframe_to_sqlite",
|
||||
"extract_tick_price",
|
||||
"fetch_latest_closed_rates",
|
||||
"fetch_latest_closed_rates_for_trading_client",
|
||||
"fetch_latest_closed_rates_indexed",
|
||||
@@ -275,5 +288,6 @@ __all__ = [
|
||||
"update_history",
|
||||
"update_history_with_config",
|
||||
"update_sltp_for_open_positions",
|
||||
"update_trailing_stop_loss_for_open_positions",
|
||||
"validate_schema",
|
||||
]
|
||||
|
||||
+1
-3
@@ -24,9 +24,7 @@ class MT5Client(Mt5CliClient):
|
||||
"""Public client for generic MT5 data access and order primitives.
|
||||
|
||||
Extends the read-only SDK client with optional order check/send helpers and
|
||||
exposes the same connection lifecycle as :class:`~mt5cli.sdk.Mt5CliClient`.
|
||||
Downstream applications such as private trading packages should prefer this
|
||||
type over the legacy ``Mt5CliClient`` name.
|
||||
exposes the same connection lifecycle as :func:`mt5_session`.
|
||||
|
||||
mt5cli intentionally exposes minimal execution primitives only. Trading
|
||||
decisions, signals, strategies, backtests, and optimization remain the
|
||||
|
||||
+69
-29
@@ -1,11 +1,10 @@
|
||||
"""Stable downstream SDK export names for mt5cli."""
|
||||
"""Downstream SDK export tiers for mt5cli."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
||||
"AccountSpec",
|
||||
"MT5Client",
|
||||
"Mt5CliClient",
|
||||
"Mt5CliError",
|
||||
"Mt5Config",
|
||||
"Mt5ConnectionError",
|
||||
@@ -24,38 +23,32 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
||||
"OrderLimits",
|
||||
"RateTarget",
|
||||
"ThrottledHistoryUpdater",
|
||||
"account_info",
|
||||
"build_config",
|
||||
"build_rate_targets",
|
||||
"build_rate_view_name",
|
||||
"calculate_margin_and_volume",
|
||||
"calculate_new_position_margin_ratio",
|
||||
"calculate_projected_margin_ratio",
|
||||
"calculate_positions_margin",
|
||||
"calculate_positions_margin_by_symbol",
|
||||
"calculate_positions_margin_safe",
|
||||
"calculate_spread_ratio",
|
||||
"calculate_symbol_group_margin_ratio",
|
||||
"calculate_trailing_stop_updates",
|
||||
"calculate_volume_by_margin",
|
||||
"call_with_normalized_errors",
|
||||
"close_open_positions",
|
||||
"collect_history",
|
||||
"collect_latest_closed_rates_by_granularity",
|
||||
"collect_latest_closed_rates_for_accounts",
|
||||
"collect_latest_rates",
|
||||
"collect_latest_rates_for_accounts",
|
||||
"collect_latest_rates_for_accounts_with_retries",
|
||||
"copy_rates_from",
|
||||
"copy_rates_from_pos",
|
||||
"copy_rates_range",
|
||||
"copy_ticks_from",
|
||||
"copy_ticks_range",
|
||||
"create_trading_client",
|
||||
"detect_position_side",
|
||||
"determine_order_limits",
|
||||
"drop_forming_rate_bar",
|
||||
"ensure_symbol_selected",
|
||||
"estimate_order_margin",
|
||||
"export_dataframe",
|
||||
"export_dataframe_to_sqlite",
|
||||
"extract_tick_price",
|
||||
"fetch_latest_closed_rates",
|
||||
"fetch_latest_closed_rates_for_trading_client",
|
||||
"fetch_latest_closed_rates_indexed",
|
||||
@@ -63,29 +56,16 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
||||
"get_positions_frame",
|
||||
"get_symbol_snapshot",
|
||||
"get_tick_snapshot",
|
||||
"history_deals",
|
||||
"history_orders",
|
||||
"is_recoverable_mt5_error",
|
||||
"last_error",
|
||||
"latest_rates",
|
||||
"load_rate_data",
|
||||
"load_rate_data_from_connection",
|
||||
"load_rate_series_by_granularity",
|
||||
"load_rate_series_from_sqlite",
|
||||
"market_book",
|
||||
"minimum_margins",
|
||||
"mt5_session",
|
||||
"mt5_summary",
|
||||
"mt5_summary_as_df",
|
||||
"mt5_trading_session",
|
||||
"mt5_version",
|
||||
"normalize_mt5_exception",
|
||||
"normalize_order_volume",
|
||||
"orders",
|
||||
"place_market_order",
|
||||
"positions",
|
||||
"recent_history_deals",
|
||||
"recent_ticks",
|
||||
"resolve_account_spec",
|
||||
"resolve_account_specs",
|
||||
"resolve_history_datasets",
|
||||
@@ -96,13 +76,73 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
||||
"resolve_rate_view_name",
|
||||
"resolve_rate_view_names",
|
||||
"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_tick",
|
||||
"symbols",
|
||||
"terminal_info",
|
||||
"update_history",
|
||||
"update_history_with_config",
|
||||
"update_sltp_for_open_positions",
|
||||
"validate_schema",
|
||||
})
|
||||
|
||||
__all__ = ["STABLE_SDK_EXPORTS"]
|
||||
PUBLIC_EXPORT_TIERS: dict[str, frozenset[str]] = {
|
||||
"stable": STABLE_SDK_EXPORTS,
|
||||
"secondary": SECONDARY_PUBLIC_EXPORTS,
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"PUBLIC_EXPORT_TIERS",
|
||||
"SECONDARY_PUBLIC_EXPORTS",
|
||||
"STABLE_SDK_EXPORTS",
|
||||
]
|
||||
|
||||
+233
-23
@@ -14,7 +14,6 @@ from pdmt5 import Mt5Config, Mt5RuntimeError, Mt5TradingClient, Mt5TradingError
|
||||
from .history import drop_forming_rate_bar
|
||||
from .sdk import build_config
|
||||
from .utils import coerce_login as _coerce_login
|
||||
from .utils import parse_timeframe
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
@@ -133,7 +132,10 @@ __all__ = [
|
||||
"calculate_positions_margin",
|
||||
"calculate_positions_margin_by_symbol",
|
||||
"calculate_positions_margin_safe",
|
||||
"calculate_projected_margin_ratio",
|
||||
"calculate_spread_ratio",
|
||||
"calculate_symbol_group_margin_ratio",
|
||||
"calculate_trailing_stop_updates",
|
||||
"calculate_volume_by_margin",
|
||||
"close_open_positions",
|
||||
"create_trading_client",
|
||||
@@ -141,6 +143,7 @@ __all__ = [
|
||||
"determine_order_limits",
|
||||
"ensure_symbol_selected",
|
||||
"estimate_order_margin",
|
||||
"extract_tick_price",
|
||||
"fetch_latest_closed_rates_for_trading_client",
|
||||
"fetch_latest_closed_rates_indexed",
|
||||
"get_account_snapshot",
|
||||
@@ -151,6 +154,7 @@ __all__ = [
|
||||
"normalize_order_volume",
|
||||
"place_market_order",
|
||||
"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
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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}."
|
||||
raise Mt5TradingError(msg)
|
||||
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"
|
||||
)
|
||||
if price is None:
|
||||
@@ -651,7 +655,7 @@ def estimate_order_margin(
|
||||
raise Mt5TradingError(msg)
|
||||
side = _normalize_order_side(order_side)
|
||||
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:
|
||||
msg = f"Tick price is unavailable for {symbol!r}."
|
||||
raise Mt5TradingError(msg)
|
||||
@@ -785,8 +789,8 @@ def calculate_spread_ratio(client: Mt5TradingClient, symbol: str) -> float:
|
||||
Mt5TradingError: If bid or ask is unavailable.
|
||||
"""
|
||||
tick = get_tick_snapshot(client, symbol)
|
||||
bid = _valid_tick_price(tick, "bid")
|
||||
ask = _valid_tick_price(tick, "ask")
|
||||
bid = extract_tick_price(tick, "bid")
|
||||
ask = extract_tick_price(tick, "ask")
|
||||
if bid is None or ask is None:
|
||||
msg = f"Tick bid/ask is unavailable for {symbol!r}."
|
||||
raise Mt5TradingError(msg)
|
||||
@@ -813,7 +817,7 @@ 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)
|
||||
price = _valid_tick_price(
|
||||
price = extract_tick_price(
|
||||
get_tick_snapshot(client, symbol), "ask" if side == "BUY" else "bid"
|
||||
)
|
||||
if price is None:
|
||||
@@ -828,6 +832,102 @@ def calculate_new_position_margin_ratio(
|
||||
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(
|
||||
client: Mt5TradingClient,
|
||||
symbol: str,
|
||||
@@ -921,7 +1021,7 @@ def calculate_volume_by_margin(
|
||||
msg = f"Invalid volume constraints for {symbol!r}."
|
||||
raise Mt5TradingError(msg)
|
||||
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"
|
||||
)
|
||||
if price is None:
|
||||
@@ -1001,7 +1101,7 @@ def determine_order_limits(
|
||||
normalized_side = _position_side_from_order_side(side)
|
||||
tick = get_tick_snapshot(client, symbol)
|
||||
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:
|
||||
msg = f"Tick price is unavailable for {symbol!r}."
|
||||
raise Mt5TradingError(msg)
|
||||
@@ -1080,7 +1180,7 @@ def place_market_order(
|
||||
if not dry_run:
|
||||
ensure_symbol_selected(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:
|
||||
msg = f"Tick price is unavailable for {symbol!r}."
|
||||
raise Mt5TradingError(msg)
|
||||
@@ -1188,6 +1288,125 @@ def close_open_positions(
|
||||
return results
|
||||
|
||||
|
||||
def _symbol_digits(client: Mt5TradingClient, symbol: str) -> int | None:
|
||||
try:
|
||||
raw_digits = get_symbol_snapshot(client, symbol).get("digits")
|
||||
if raw_digits is None:
|
||||
return None
|
||||
digits = int(raw_digits)
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return None
|
||||
return digits if digits >= 0 else None
|
||||
|
||||
|
||||
def _position_ticket(value: object) -> int | None:
|
||||
ticket = _optional_int(value)
|
||||
return ticket if ticket is not None and ticket > 0 else None
|
||||
|
||||
|
||||
def _current_stop_loss(value: object) -> float | None:
|
||||
return _optional_price(value)
|
||||
|
||||
|
||||
def _trailing_stop_loss(
|
||||
client: Mt5TradingClient,
|
||||
*,
|
||||
position_type: object,
|
||||
current_sl: float | None,
|
||||
bid: float | None,
|
||||
ask: float | None,
|
||||
digits: int,
|
||||
trailing_stop_ratio: float,
|
||||
) -> float | None:
|
||||
next_sl: float | None = None
|
||||
if position_type == client.mt5.POSITION_TYPE_BUY:
|
||||
if bid is not None:
|
||||
next_sl = round(bid * (1.0 - trailing_stop_ratio), digits)
|
||||
if current_sl is not None and current_sl >= next_sl:
|
||||
next_sl = None
|
||||
elif position_type == client.mt5.POSITION_TYPE_SELL and ask is not None:
|
||||
next_sl = round(ask * (1.0 + trailing_stop_ratio), digits)
|
||||
if current_sl is not None and current_sl <= next_sl:
|
||||
next_sl = None
|
||||
return next_sl
|
||||
|
||||
|
||||
def calculate_trailing_stop_updates(
|
||||
client: Mt5TradingClient,
|
||||
*,
|
||||
symbol: str,
|
||||
trailing_stop_ratio: float,
|
||||
) -> dict[int, float]:
|
||||
"""Return per-ticket trailing stop-loss updates for open symbol positions.
|
||||
|
||||
Buy positions trail from bid using ``bid * (1 - trailing_stop_ratio)``.
|
||||
Sell positions trail from ask using ``ask * (1 + trailing_stop_ratio)``.
|
||||
Existing stop losses are preserved when they are already more favorable.
|
||||
Missing symbol metadata returns an empty update map. Positions with a
|
||||
missing side-specific tick price are skipped.
|
||||
"""
|
||||
_require_protective_ratio(trailing_stop_ratio, "trailing_stop_ratio")
|
||||
positions = get_positions_frame(client, symbol=symbol)
|
||||
if positions.empty:
|
||||
return {}
|
||||
tick = get_tick_snapshot(client, symbol)
|
||||
bid = extract_tick_price(tick, "bid")
|
||||
ask = extract_tick_price(tick, "ask")
|
||||
digits = _symbol_digits(client, symbol)
|
||||
if digits is None:
|
||||
return {}
|
||||
|
||||
updates: dict[int, float] = {}
|
||||
for row in positions.to_dict("records"):
|
||||
ticket = _position_ticket(row.get("ticket"))
|
||||
if ticket is None:
|
||||
continue
|
||||
next_sl = _trailing_stop_loss(
|
||||
client,
|
||||
position_type=row.get("type"),
|
||||
current_sl=_current_stop_loss(row.get("sl")),
|
||||
bid=bid,
|
||||
ask=ask,
|
||||
digits=digits,
|
||||
trailing_stop_ratio=trailing_stop_ratio,
|
||||
)
|
||||
if next_sl is None:
|
||||
continue
|
||||
updates[ticket] = next_sl
|
||||
return updates
|
||||
|
||||
|
||||
def update_trailing_stop_loss_for_open_positions(
|
||||
client: Mt5TradingClient,
|
||||
*,
|
||||
symbol: str,
|
||||
trailing_stop_ratio: float,
|
||||
dry_run: bool = False,
|
||||
) -> list[OrderExecutionResult]:
|
||||
"""Update open positions whose trailing stop loss should move favorably.
|
||||
|
||||
Returns:
|
||||
Normalized execution results for positions that need an SL update.
|
||||
"""
|
||||
updates = calculate_trailing_stop_updates(
|
||||
client,
|
||||
symbol=symbol,
|
||||
trailing_stop_ratio=trailing_stop_ratio,
|
||||
)
|
||||
results: list[OrderExecutionResult] = []
|
||||
for ticket, stop_loss in updates.items():
|
||||
results.extend(
|
||||
update_sltp_for_open_positions(
|
||||
client,
|
||||
symbol=symbol,
|
||||
tickets=[ticket],
|
||||
stop_loss=stop_loss,
|
||||
dry_run=dry_run,
|
||||
),
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def update_sltp_for_open_positions(
|
||||
client: Mt5TradingClient,
|
||||
*,
|
||||
@@ -1273,19 +1492,10 @@ def fetch_latest_closed_rates_for_trading_client(
|
||||
msg = "count must be positive."
|
||||
raise ValueError(msg)
|
||||
fetch_method = getattr(client, "fetch_latest_rates_as_df", None)
|
||||
if callable(fetch_method):
|
||||
fetched = fetch_method(symbol, granularity, count + 1)
|
||||
else:
|
||||
copy_method = getattr(client, "copy_rates_from_pos_as_df", None)
|
||||
if not callable(copy_method):
|
||||
msg = "MT5 trading client cannot fetch rate data."
|
||||
raise Mt5TradingError(msg)
|
||||
fetched = copy_method(
|
||||
symbol=symbol,
|
||||
timeframe=parse_timeframe(granularity),
|
||||
start_pos=0,
|
||||
count=count + 1,
|
||||
)
|
||||
if not callable(fetch_method):
|
||||
msg = "MT5 trading client cannot fetch rate data."
|
||||
raise Mt5TradingError(msg)
|
||||
fetched = fetch_method(symbol, granularity, count + 1)
|
||||
if not isinstance(fetched, pd.DataFrame):
|
||||
msg = (
|
||||
f"Malformed rate data for {symbol!r} at granularity {granularity!r}: "
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mt5cli"
|
||||
version = "0.9.2"
|
||||
version = "0.9.3"
|
||||
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"}]
|
||||
|
||||
+76
-4
@@ -2,9 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, get_type_hints
|
||||
from pathlib import Path
|
||||
from typing import get_type_hints
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pandas as pd
|
||||
@@ -15,7 +17,9 @@ from pytest_mock import MockerFixture # noqa: TC002
|
||||
import mt5cli
|
||||
from mt5cli import (
|
||||
DEDUP_KEYS,
|
||||
PUBLIC_EXPORT_TIERS,
|
||||
REQUIRED_COLUMNS,
|
||||
SECONDARY_PUBLIC_EXPORTS,
|
||||
STABLE_SDK_EXPORTS,
|
||||
TIME_COLUMNS,
|
||||
AccountSpec,
|
||||
@@ -35,6 +39,9 @@ from mt5cli import (
|
||||
build_rate_targets,
|
||||
calculate_margin_and_volume,
|
||||
calculate_positions_margin,
|
||||
calculate_projected_margin_ratio,
|
||||
calculate_symbol_group_margin_ratio,
|
||||
calculate_trailing_stop_updates,
|
||||
call_with_normalized_errors,
|
||||
detect_format,
|
||||
drop_forming_rate_bar,
|
||||
@@ -42,6 +49,7 @@ from mt5cli import (
|
||||
ensure_utc,
|
||||
export_dataframe,
|
||||
export_dataframe_to_sqlite,
|
||||
extract_tick_price,
|
||||
fetch_latest_closed_rates,
|
||||
fetch_latest_closed_rates_for_trading_client,
|
||||
fetch_latest_closed_rates_indexed,
|
||||
@@ -69,9 +77,6 @@ from mt5cli.history import create_rate_compatibility_views
|
||||
from mt5cli.retry import retry_with_backoff
|
||||
from mt5cli.schemas import ensure_utc_columns, normalize_time_columns
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _sample_frame(kind: DataKind) -> pd.DataFrame:
|
||||
if kind is DataKind.rates:
|
||||
@@ -547,11 +552,69 @@ class TestStableSdkContract:
|
||||
missing = sorted(STABLE_SDK_EXPORTS - set(mt5cli.__all__))
|
||||
assert not missing, f"STABLE_SDK_EXPORTS missing from __all__: {missing}"
|
||||
|
||||
def test_public_export_tiers_are_disjoint_and_complete(self) -> None:
|
||||
"""Documented public tiers do not overlap and classify root exports."""
|
||||
assert PUBLIC_EXPORT_TIERS == {
|
||||
"stable": STABLE_SDK_EXPORTS,
|
||||
"secondary": SECONDARY_PUBLIC_EXPORTS,
|
||||
}
|
||||
assert not (STABLE_SDK_EXPORTS & SECONDARY_PUBLIC_EXPORTS)
|
||||
tiered_exports = STABLE_SDK_EXPORTS | SECONDARY_PUBLIC_EXPORTS
|
||||
root_exports = set(mt5cli.__all__)
|
||||
|
||||
missing_from_root = sorted(tiered_exports - root_exports)
|
||||
assert not missing_from_root, (
|
||||
f"Tiered exports missing from __all__: {missing_from_root}"
|
||||
)
|
||||
|
||||
tier_metadata_exports = {
|
||||
"PUBLIC_EXPORT_TIERS",
|
||||
"SECONDARY_PUBLIC_EXPORTS",
|
||||
"STABLE_SDK_EXPORTS",
|
||||
}
|
||||
unclassified_root_exports = sorted(
|
||||
root_exports - tiered_exports - tier_metadata_exports,
|
||||
)
|
||||
assert not unclassified_root_exports, (
|
||||
f"Root exports missing from public API tiers: {unclassified_root_exports}"
|
||||
)
|
||||
|
||||
def test_stable_docs_do_not_document_nonstable_exports(self) -> None:
|
||||
"""Stable docs do not promote secondary root exports."""
|
||||
docs_path = Path("docs/api/public-contract.md")
|
||||
docs = docs_path.read_text(encoding="utf-8")
|
||||
stable_section = docs.split("## Stable downstream SDK API", maxsplit=1)[
|
||||
1
|
||||
].split(
|
||||
"## Secondary public exports",
|
||||
maxsplit=1,
|
||||
)[0]
|
||||
documented_symbols = set(
|
||||
re.findall(r"`([A-Za-z_][A-Za-z0-9_]*)`", stable_section)
|
||||
)
|
||||
nonstable_exports = SECONDARY_PUBLIC_EXPORTS
|
||||
|
||||
wrongly_stable = sorted(documented_symbols & nonstable_exports)
|
||||
assert not wrongly_stable, (
|
||||
f"Non-stable exports documented in stable section: {wrongly_stable}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("name", sorted(STABLE_SDK_EXPORTS))
|
||||
def test_stable_exports_are_importable_from_package_root(self, name: str) -> None:
|
||||
"""Stable SDK names resolve through ``from mt5cli import ...``."""
|
||||
assert hasattr(mt5cli, name), f"{name!r} missing from mt5cli package root"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
sorted(SECONDARY_PUBLIC_EXPORTS),
|
||||
)
|
||||
def test_secondary_exports_are_importable(
|
||||
self,
|
||||
name: str,
|
||||
) -> None:
|
||||
"""Non-stable public names remain available from the package root."""
|
||||
assert hasattr(mt5cli, name), f"{name!r} missing from mt5cli package root"
|
||||
|
||||
def test_drop_forming_rate_bar_from_package_root(self) -> None:
|
||||
"""Closed-bar trimming is available from the stable package surface."""
|
||||
frame = pd.DataFrame({"time": [1, 2, 3], "close": [1.0, 1.1, 1.2]})
|
||||
@@ -615,6 +678,15 @@ class TestStableSdkContract:
|
||||
|
||||
assert calculate_positions_margin(client) == 0
|
||||
|
||||
def test_generic_trading_helpers_from_package_root(self) -> None:
|
||||
"""New generic trading helpers resolve through the stable surface."""
|
||||
price = extract_tick_price({"bid": "1.2"}, "bid")
|
||||
assert price is not None
|
||||
assert abs(price - 1.2) < 1e-9
|
||||
assert callable(calculate_trailing_stop_updates)
|
||||
assert callable(calculate_projected_margin_ratio)
|
||||
assert callable(calculate_symbol_group_margin_ratio)
|
||||
|
||||
def test_resolve_rate_view_name_from_package_root(self, tmp_path: Path) -> None:
|
||||
"""Rate view resolution is importable and honors require_existing."""
|
||||
db_path = tmp_path / "rates.db"
|
||||
|
||||
+491
-59
@@ -19,13 +19,15 @@ 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_projected_margin_ratio,
|
||||
calculate_spread_ratio,
|
||||
calculate_symbol_group_margin_ratio,
|
||||
calculate_trailing_stop_updates,
|
||||
calculate_volume_by_margin,
|
||||
close_open_positions,
|
||||
create_trading_client,
|
||||
@@ -33,6 +35,7 @@ from mt5cli.trading import (
|
||||
determine_order_limits,
|
||||
ensure_symbol_selected,
|
||||
estimate_order_margin,
|
||||
extract_tick_price,
|
||||
fetch_latest_closed_rates_for_trading_client,
|
||||
fetch_latest_closed_rates_indexed,
|
||||
get_account_snapshot,
|
||||
@@ -43,6 +46,7 @@ from mt5cli.trading import (
|
||||
normalize_order_volume,
|
||||
place_market_order,
|
||||
update_sltp_for_open_positions,
|
||||
update_trailing_stop_loss_for_open_positions,
|
||||
)
|
||||
|
||||
|
||||
@@ -1831,6 +1835,197 @@ class TestVolumeAndExecution:
|
||||
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:
|
||||
"""Test dry-run market orders return a request without sending."""
|
||||
client = _mock_trade_client()
|
||||
@@ -2199,6 +2394,283 @@ class TestVolumeAndExecution:
|
||||
|
||||
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:
|
||||
"""Test SL/TP updates filter positions and do not send in dry-run mode."""
|
||||
client = _mock_trade_client()
|
||||
@@ -2609,32 +3081,6 @@ class TestFetchLatestClosedRatesForTradingClient:
|
||||
assert list(result["close"]) == [1.0, 1.1]
|
||||
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:
|
||||
"""Test numeric epoch timestamps are preserved in output."""
|
||||
client = MagicMock()
|
||||
@@ -2811,24 +3257,6 @@ 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."""
|
||||
@@ -3078,58 +3506,62 @@ class TestFetchLatestClosedRatesIndexed:
|
||||
assert isinstance(result.index, pd.DatetimeIndex)
|
||||
|
||||
|
||||
class TestValidTickPrice:
|
||||
"""Tests for the _valid_tick_price internal helper (#49)."""
|
||||
class TestExtractTickPrice:
|
||||
"""Tests for the public extract_tick_price helper."""
|
||||
|
||||
def test_valid_positive_float(self) -> None:
|
||||
"""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:
|
||||
"""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 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)
|
||||
_assert_close(extract_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
|
||||
assert extract_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
|
||||
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:
|
||||
"""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:
|
||||
"""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:
|
||||
"""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:
|
||||
"""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:
|
||||
"""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:
|
||||
"""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:
|
||||
"""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:
|
||||
|
||||
Reference in New Issue
Block a user