Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8da5ee9242 | |||
| 9dbb46fbb1 | |||
| dfe80ce500 | |||
| 15bfd17db3 | |||
| 37eef16e99 | |||
| 96c75f7852 | |||
| 292fac899a |
@@ -29,9 +29,15 @@ Built on top of [pdmt5](https://github.com/dceoy/pdmt5), a pandas-based data han
|
|||||||
pip install -U mt5cli MetaTrader5
|
pip install -U mt5cli MetaTrader5
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Parquet export is not included by default. To enable it, install the `parquet` extra:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -U "mt5cli[parquet]" 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
|
||||||
@@ -92,16 +98,21 @@ Schema contracts live in `mt5cli.schemas` (`DataKind`, `validate_schema`, `norma
|
|||||||
Trading applications can depend on `mt5cli` imports only; terminal path,
|
Trading applications can depend on `mt5cli` imports only; terminal path,
|
||||||
credentials, server, and timeout are forwarded to `pdmt5.Mt5Config`, numeric
|
credentials, server, and timeout are forwarded to `pdmt5.Mt5Config`, numeric
|
||||||
login strings are coerced to integers, and empty login strings are treated as
|
login strings are coerced to integers, and empty login strings are treated as
|
||||||
unset.
|
unset. Pass `allow_whole_dollar_env=True` to expand `${ENV_VAR}` and bare
|
||||||
|
`$ENV_NAME` placeholders in connection string parameters before coercion.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from mt5cli import (
|
from mt5cli import (
|
||||||
|
build_config,
|
||||||
calculate_spread_ratio,
|
calculate_spread_ratio,
|
||||||
create_trading_client,
|
create_trading_client,
|
||||||
get_account_snapshot,
|
get_account_snapshot,
|
||||||
mt5_trading_session,
|
mt5_trading_session,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Login from environment — numeric string is coerced to int automatically
|
||||||
|
config = build_config(login="$MT5_LOGIN", allow_whole_dollar_env=True)
|
||||||
|
|
||||||
with mt5_trading_session(
|
with mt5_trading_session(
|
||||||
path=r"C:\Program Files\MetaTrader 5\terminal64.exe",
|
path=r"C:\Program Files\MetaTrader 5\terminal64.exe",
|
||||||
login="12345",
|
login="12345",
|
||||||
@@ -173,10 +184,13 @@ python -m mt5cli -o account.csv account-info
|
|||||||
| `recent-history-deals` | Export historical deals from a recent trailing window |
|
| `recent-history-deals` | Export historical deals from a recent trailing window |
|
||||||
| `mt5-summary` | Export terminal/account status summary |
|
| `mt5-summary` | Export terminal/account status summary |
|
||||||
| `order-check` | Check funds sufficiency for a trade request |
|
| `order-check` | Check funds sufficiency for a trade request |
|
||||||
| `order-send` | Send a trade request to the trade server (`--yes` required) |
|
| `order-send` | Send a raw trade request to the trade server (`--yes` required; expert path) |
|
||||||
|
| `close-positions` | Close open positions by `--symbol` or `--ticket` (`--yes` required for live; `--dry-run` available) |
|
||||||
| `collect-history` | Bundle rates, ticks, history-orders, and history-deals for one or more symbols into a single SQLite database |
|
| `collect-history` | Bundle rates, ticks, history-orders, and history-deals for one or more symbols into a single SQLite database |
|
||||||
|
|
||||||
Use `order-check` to validate a request payload before running `order-send --yes`.
|
Use `order-check` to validate a request payload before running `order-send --yes`.
|
||||||
|
`close-positions` is the safer high-level alternative that builds correct close
|
||||||
|
requests automatically. At least one `--symbol` or `--ticket` must be provided.
|
||||||
|
|
||||||
### `collect-history`
|
### `collect-history`
|
||||||
|
|
||||||
@@ -248,9 +262,9 @@ rates = collect_latest_closed_rates_by_granularity(
|
|||||||
eurusd_m1 = rates["EURUSD", "M1"] # closed bars only
|
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. For config dicts or nested structures loaded from YAML/TOML, use `substitute_mapping_values(data, keys={"login", "password"})` to expand placeholders only for caller-specified keys — key names are never hard-coded in mt5cli.
|
||||||
- **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 +331,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
|
||||||
|
|
||||||
|
|||||||
+99
-58
@@ -8,55 +8,49 @@ 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; `login` accepts `int \| str \| None` — numeric strings are coerced to `int`, blank strings are treated as unset, and `${ENV_VAR}` / `$ENV_NAME` placeholders in string parameters are expanded when `allow_whole_dollar_env=True` |
|
||||||
| `mt5_session` | Context manager: initialize, login, yield client, shutdown |
|
| `mt5_session` | Context manager: initialize, login, yield client, shutdown |
|
||||||
| `create_trading_client`, `mt5_trading_session` | Trading-capable `pdmt5.Mt5TradingClient` lifecycle |
|
| `create_trading_client`, `mt5_trading_session` | Trading-capable `pdmt5.Mt5TradingClient` lifecycle |
|
||||||
| `AccountSpec` | Generic account group: symbols plus optional credentials |
|
| `AccountSpec` | Generic account group: symbols plus optional credentials |
|
||||||
| `resolve_account_spec`, `resolve_account_specs` | Merge overrides and expand `${ENV_VAR}` placeholders; opt-in `allow_whole_dollar_env` for bare `$NAME` |
|
| `resolve_account_spec`, `resolve_account_specs` | Merge overrides and expand `${ENV_VAR}` placeholders; opt-in `allow_whole_dollar_env` for bare `$NAME` |
|
||||||
| `substitute_env_placeholders` | Replace `${NAME}` substrings from the environment; opt-in `allow_whole_dollar_env` for whole-value `$NAME` |
|
| `substitute_env_placeholders` | Replace `${NAME}` substrings from the environment; opt-in `allow_whole_dollar_env` for whole-value `$NAME` |
|
||||||
|
| `substitute_mapping_values` | Recursively traverse a dict/list/scalar structure and substitute `${ENV_VAR}` placeholders for caller-selected mapping keys only; optionally normalise blank strings to `None` for a separate caller-selected key set; does not hard-code any application-specific key names |
|
||||||
|
|
||||||
Credential resolution is generic: any environment variable name may appear inside
|
Credential resolution is generic: any environment variable name may appear inside
|
||||||
`${...}`. mt5cli does not hard-code application-specific keys such as
|
`${...}`. mt5cli does not hard-code application-specific keys such as
|
||||||
`mt5_login` or `mt5_exe`.
|
`mt5_login` or `mt5_exe`.
|
||||||
|
|
||||||
Pass `allow_whole_dollar_env=True` to `substitute_env_placeholders()`,
|
Pass `allow_whole_dollar_env=True` to `substitute_env_placeholders()`,
|
||||||
`resolve_account_spec()`, `resolve_account_specs()`, and `build_config()` to
|
`substitute_mapping_values()`, `resolve_account_spec()`, `resolve_account_specs()`,
|
||||||
additionally expand strings whose entire value is a bare `$ENV_NAME` identifier.
|
and `build_config()` to additionally expand strings whose entire value is a bare
|
||||||
|
`$ENV_NAME` identifier.
|
||||||
Partial strings such as `"plan$pass"`, `"abc$ENV"`, or `"$ENV-suffix"` are
|
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 +65,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 +92,33 @@ 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-scoped margin/equity after optional new exposure |
|
||||||
| `place_market_order`, `close_open_positions`, `update_sltp_for_open_positions` | Order execution helpers (`dry_run` supported) |
|
| `calculate_account_projected_margin_ratio` | Account snapshot margin/equity after optional new exposure |
|
||||||
| `MarginVolume`, `OrderLimits`, `OrderExecutionResult` | Typed return contracts for order helpers |
|
| `calculate_symbol_group_margin_ratio` | Estimated symbol-group margin/equity with optional exposure |
|
||||||
| `OrderSide`, `OrderFillingMode`, `OrderTimeMode`, `PositionSide`, `ExecutionStatus` | Typed enums for order helpers |
|
| `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 |
|
||||||
|
| `ProjectionMode` | Literal type for `calculate_symbol_group_margin_ratio` projection |
|
||||||
|
|
||||||
|
`calculate_symbol_group_margin_ratio` accepts an optional `projection_mode`
|
||||||
|
parameter (`"add"` by default). Pass `projection_mode="replace_symbol"` to
|
||||||
|
subtract current exposure for `new_symbol` before adding the candidate margin —
|
||||||
|
useful for reversal-style projections. mt5cli only calculates broker-facing
|
||||||
|
exposure; downstream applications own thresholds, risk guard actions, and
|
||||||
|
strategy policy.
|
||||||
|
|
||||||
`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 +141,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
|
||||||
|
|
||||||
@@ -154,7 +190,12 @@ The Typer application in `mt5cli.cli` exposes file-export commands documented in
|
|||||||
- Delegate to the same Python APIs described here; they are not duplicated
|
- Delegate to the same Python APIs described here; they are not duplicated
|
||||||
business logic.
|
business logic.
|
||||||
|
|
||||||
`order-send` requires `--yes` before placing live trades.
|
`order-send` is the expert raw-request path; it requires `--yes` and a fully
|
||||||
|
constructed request payload. `close-positions` is the safer high-level helper
|
||||||
|
that closes open positions by `--symbol` or `--ticket` using
|
||||||
|
`close_open_positions()`. Both `order-send --yes` and `close-positions --yes`
|
||||||
|
are live execution paths. `close-positions --dry-run` previews close orders
|
||||||
|
without placing them and does not require `--yes`.
|
||||||
|
|
||||||
## Internal helpers (not stable)
|
## Internal helpers (not stable)
|
||||||
|
|
||||||
@@ -190,7 +231,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
@@ -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
@@ -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.
|
||||||
|
|||||||
+7
-1
@@ -27,9 +27,15 @@ mt5cli provides a stable `MT5Client` Python API, standardized dataset schemas, s
|
|||||||
pip install mt5cli
|
pip install mt5cli
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Parquet export is not included by default. To enable it, install the `parquet` extra:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install "mt5cli[parquet]"
|
||||||
|
```
|
||||||
|
|
||||||
## Python API for downstream packages
|
## Python API for downstream packages
|
||||||
|
|
||||||
Import `MT5Client` for generic MT5 data access, schema normalization, and optional order primitives. `Mt5CliClient` remains available as a backward-compatible alias.
|
Import `MT5Client` for generic MT5 data access, schema normalization, and optional order primitives.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|||||||
+23
-3
@@ -11,7 +11,11 @@ from importlib.metadata import version
|
|||||||
from pdmt5 import Mt5Config, Mt5RuntimeError, Mt5TradingClient, Mt5TradingError
|
from pdmt5 import Mt5Config, Mt5RuntimeError, Mt5TradingClient, Mt5TradingError
|
||||||
|
|
||||||
from .client import MT5Client, build_config, mt5_session
|
from .client import MT5Client, build_config, mt5_session
|
||||||
from .contract import STABLE_SDK_EXPORTS
|
from .contract import (
|
||||||
|
PUBLIC_EXPORT_TIERS,
|
||||||
|
SECONDARY_PUBLIC_EXPORTS,
|
||||||
|
STABLE_SDK_EXPORTS,
|
||||||
|
)
|
||||||
from .converters import (
|
from .converters import (
|
||||||
ensure_utc,
|
ensure_utc,
|
||||||
granularity_name,
|
granularity_name,
|
||||||
@@ -59,7 +63,6 @@ from .schemas import (
|
|||||||
)
|
)
|
||||||
from .sdk import (
|
from .sdk import (
|
||||||
AccountSpec,
|
AccountSpec,
|
||||||
Mt5CliClient,
|
|
||||||
ThrottledHistoryUpdater,
|
ThrottledHistoryUpdater,
|
||||||
account_info,
|
account_info,
|
||||||
collect_history,
|
collect_history,
|
||||||
@@ -89,6 +92,7 @@ from .sdk import (
|
|||||||
resolve_account_spec,
|
resolve_account_spec,
|
||||||
resolve_account_specs,
|
resolve_account_specs,
|
||||||
substitute_env_placeholders,
|
substitute_env_placeholders,
|
||||||
|
substitute_mapping_values,
|
||||||
symbol_info,
|
symbol_info,
|
||||||
symbol_info_tick,
|
symbol_info_tick,
|
||||||
symbols,
|
symbols,
|
||||||
@@ -116,12 +120,17 @@ from .trading import (
|
|||||||
OrderSide,
|
OrderSide,
|
||||||
OrderTimeMode,
|
OrderTimeMode,
|
||||||
PositionSide,
|
PositionSide,
|
||||||
|
ProjectionMode,
|
||||||
|
calculate_account_projected_margin_ratio,
|
||||||
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,
|
||||||
@@ -129,6 +138,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 +149,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 +165,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 +179,6 @@ __all__ = [
|
|||||||
"IfExists",
|
"IfExists",
|
||||||
"MT5Client",
|
"MT5Client",
|
||||||
"MarginVolume",
|
"MarginVolume",
|
||||||
"Mt5CliClient",
|
|
||||||
"Mt5CliError",
|
"Mt5CliError",
|
||||||
"Mt5Config",
|
"Mt5Config",
|
||||||
"Mt5ConnectionError",
|
"Mt5ConnectionError",
|
||||||
@@ -181,18 +193,23 @@ __all__ = [
|
|||||||
"OrderSide",
|
"OrderSide",
|
||||||
"OrderTimeMode",
|
"OrderTimeMode",
|
||||||
"PositionSide",
|
"PositionSide",
|
||||||
|
"ProjectionMode",
|
||||||
"RateTarget",
|
"RateTarget",
|
||||||
"ThrottledHistoryUpdater",
|
"ThrottledHistoryUpdater",
|
||||||
"account_info",
|
"account_info",
|
||||||
"build_config",
|
"build_config",
|
||||||
"build_rate_targets",
|
"build_rate_targets",
|
||||||
"build_rate_view_name",
|
"build_rate_view_name",
|
||||||
|
"calculate_account_projected_margin_ratio",
|
||||||
"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",
|
||||||
"call_with_normalized_errors",
|
"call_with_normalized_errors",
|
||||||
"close_open_positions",
|
"close_open_positions",
|
||||||
@@ -217,6 +234,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",
|
||||||
@@ -268,6 +286,7 @@ __all__ = [
|
|||||||
"resolve_rate_view_names",
|
"resolve_rate_view_names",
|
||||||
"schema_columns",
|
"schema_columns",
|
||||||
"substitute_env_placeholders",
|
"substitute_env_placeholders",
|
||||||
|
"substitute_mapping_values",
|
||||||
"symbol_info",
|
"symbol_info",
|
||||||
"symbol_info_tick",
|
"symbol_info_tick",
|
||||||
"symbols",
|
"symbols",
|
||||||
@@ -275,5 +294,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",
|
||||||
]
|
]
|
||||||
|
|||||||
+93
-2
@@ -2,17 +2,20 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime # noqa: TC003
|
from datetime import datetime # noqa: TC003
|
||||||
from pathlib import Path # noqa: TC003
|
from pathlib import Path # noqa: TC003
|
||||||
from typing import TYPE_CHECKING, Annotated, Any, cast
|
from typing import TYPE_CHECKING, Annotated, Any, cast
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
import typer
|
import typer
|
||||||
from pdmt5 import Mt5Config
|
from pdmt5 import Mt5Config
|
||||||
|
|
||||||
from . import sdk
|
from . import sdk
|
||||||
from .client import MT5Client
|
from .client import MT5Client
|
||||||
|
from .trading import OrderExecutionResult, close_open_positions, create_trading_client
|
||||||
from .utils import (
|
from .utils import (
|
||||||
DATETIME_TYPE,
|
DATETIME_TYPE,
|
||||||
REQUEST_TYPE,
|
REQUEST_TYPE,
|
||||||
@@ -29,8 +32,6 @@ from .utils import (
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -600,6 +601,96 @@ def order_send(
|
|||||||
_export_command(ctx, lambda client: client.order_send(request))
|
_export_command(ctx, lambda client: client.order_send(request))
|
||||||
|
|
||||||
|
|
||||||
|
_EXECUTION_RESULT_COLUMNS: list[str] = [
|
||||||
|
"status",
|
||||||
|
"symbol",
|
||||||
|
"order_side",
|
||||||
|
"volume",
|
||||||
|
"retcode",
|
||||||
|
"comment",
|
||||||
|
"request",
|
||||||
|
"response",
|
||||||
|
"dry_run",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _execution_results_to_df(results: list[OrderExecutionResult]) -> pd.DataFrame:
|
||||||
|
if not results:
|
||||||
|
return pd.DataFrame(columns=_EXECUTION_RESULT_COLUMNS)
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
**r,
|
||||||
|
"request": json.dumps(r["request"]),
|
||||||
|
"response": json.dumps(r["response"]),
|
||||||
|
}
|
||||||
|
for r in results
|
||||||
|
]
|
||||||
|
return pd.DataFrame(rows)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def close_positions(
|
||||||
|
ctx: typer.Context,
|
||||||
|
symbol: Annotated[
|
||||||
|
list[str] | None,
|
||||||
|
typer.Option(
|
||||||
|
"--symbol",
|
||||||
|
"-s",
|
||||||
|
help="Symbol to close (repeat for multiple symbols).",
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
ticket: Annotated[
|
||||||
|
list[int] | None,
|
||||||
|
typer.Option(
|
||||||
|
"--ticket",
|
||||||
|
"-t",
|
||||||
|
help="Position ticket to close (repeat for multiple tickets).",
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
dry_run: Annotated[
|
||||||
|
bool,
|
||||||
|
typer.Option("--dry-run", help="Preview close orders without executing them."),
|
||||||
|
] = False,
|
||||||
|
yes: Annotated[
|
||||||
|
bool,
|
||||||
|
typer.Option("--yes", help="Confirm live position closing."),
|
||||||
|
] = False,
|
||||||
|
) -> None:
|
||||||
|
"""Close open positions by symbol or ticket.
|
||||||
|
|
||||||
|
Delegates to :func:`mt5cli.trading.close_open_positions`. At least one
|
||||||
|
``--symbol`` or ``--ticket`` must be provided to avoid accidentally closing
|
||||||
|
all positions. Use ``--dry-run`` to preview without executing; ``--yes`` is
|
||||||
|
required for live execution.
|
||||||
|
|
||||||
|
``order-send`` is the expert raw-request path. ``close-positions`` is the
|
||||||
|
safer high-level helper that builds correct close requests automatically.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
typer.BadParameter: If neither ``--symbol`` nor ``--ticket`` is given,
|
||||||
|
or if ``--yes`` is missing for a live (non-dry-run) run.
|
||||||
|
"""
|
||||||
|
if not symbol and not ticket:
|
||||||
|
msg = "Provide at least one --symbol or --ticket to close positions."
|
||||||
|
raise typer.BadParameter(msg)
|
||||||
|
if not dry_run and not yes:
|
||||||
|
msg = "Pass --yes to close live positions."
|
||||||
|
raise typer.BadParameter(msg, param_hint="--yes")
|
||||||
|
export_ctx = _get_export_context(ctx)
|
||||||
|
client = create_trading_client(config=export_ctx.config)
|
||||||
|
try:
|
||||||
|
results = close_open_positions(
|
||||||
|
client,
|
||||||
|
symbols=list(symbol) if symbol else None,
|
||||||
|
tickets=list(ticket) if ticket else None,
|
||||||
|
dry_run=dry_run,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
client.shutdown()
|
||||||
|
df = _execution_results_to_df(results)
|
||||||
|
_execute_export(ctx, lambda: df)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def collect_history(
|
def collect_history(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
|
|||||||
+1
-3
@@ -24,9 +24,7 @@ class MT5Client(Mt5CliClient):
|
|||||||
"""Public client for generic MT5 data access and order primitives.
|
"""Public client for generic MT5 data access and order primitives.
|
||||||
|
|
||||||
Extends the read-only SDK client with optional order check/send helpers and
|
Extends the read-only SDK client with optional order check/send helpers and
|
||||||
exposes the same connection lifecycle as :class:`~mt5cli.sdk.Mt5CliClient`.
|
exposes the same connection lifecycle as :func:`mt5_session`.
|
||||||
Downstream applications such as private trading packages should prefer this
|
|
||||||
type over the legacy ``Mt5CliClient`` name.
|
|
||||||
|
|
||||||
mt5cli intentionally exposes minimal execution primitives only. Trading
|
mt5cli intentionally exposes minimal execution primitives only. Trading
|
||||||
decisions, signals, strategies, backtests, and optimization remain the
|
decisions, signals, strategies, backtests, and optimization remain the
|
||||||
|
|||||||
+72
-29
@@ -1,11 +1,10 @@
|
|||||||
"""Stable downstream SDK export names for mt5cli."""
|
"""Downstream SDK export tiers for mt5cli."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
||||||
"AccountSpec",
|
"AccountSpec",
|
||||||
"MT5Client",
|
"MT5Client",
|
||||||
"Mt5CliClient",
|
|
||||||
"Mt5CliError",
|
"Mt5CliError",
|
||||||
"Mt5Config",
|
"Mt5Config",
|
||||||
"Mt5ConnectionError",
|
"Mt5ConnectionError",
|
||||||
@@ -18,44 +17,40 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
|||||||
"OrderSide",
|
"OrderSide",
|
||||||
"OrderTimeMode",
|
"OrderTimeMode",
|
||||||
"PositionSide",
|
"PositionSide",
|
||||||
|
"ProjectionMode",
|
||||||
"ExecutionStatus",
|
"ExecutionStatus",
|
||||||
"MarginVolume",
|
"MarginVolume",
|
||||||
"OrderExecutionResult",
|
"OrderExecutionResult",
|
||||||
"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_account_projected_margin_ratio",
|
||||||
"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 +58,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 +78,74 @@ 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",
|
||||||
|
"substitute_mapping_values",
|
||||||
|
"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",
|
||||||
|
]
|
||||||
|
|||||||
+83
-6
@@ -40,7 +40,7 @@ from .utils import (
|
|||||||
from .utils import coerce_login as _coerce_login
|
from .utils import coerce_login as _coerce_login
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable, Iterator, Sequence
|
from collections.abc import Callable, Collection, Iterator, Sequence
|
||||||
|
|
||||||
UpdateHistoryBackend = Callable[..., None]
|
UpdateHistoryBackend = Callable[..., None]
|
||||||
|
|
||||||
@@ -142,6 +142,7 @@ __all__ = [
|
|||||||
"resolve_account_spec",
|
"resolve_account_spec",
|
||||||
"resolve_account_specs",
|
"resolve_account_specs",
|
||||||
"substitute_env_placeholders",
|
"substitute_env_placeholders",
|
||||||
|
"substitute_mapping_values",
|
||||||
"symbol_info",
|
"symbol_info",
|
||||||
"symbol_info_tick",
|
"symbol_info_tick",
|
||||||
"symbols",
|
"symbols",
|
||||||
@@ -305,7 +306,7 @@ def _fetch_minimum_margins(client: Mt5DataClient, symbol: str) -> pd.DataFrame:
|
|||||||
def build_config(
|
def build_config(
|
||||||
*,
|
*,
|
||||||
path: str | None = None,
|
path: str | None = None,
|
||||||
login: int | None = None,
|
login: int | str | None = None,
|
||||||
password: str | None = None,
|
password: str | None = None,
|
||||||
server: str | None = None,
|
server: str | None = None,
|
||||||
timeout: int | None = None,
|
timeout: int | None = None,
|
||||||
@@ -315,14 +316,19 @@ def build_config(
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
path: Optional terminal executable path.
|
path: Optional terminal executable path.
|
||||||
login: Optional trading account login.
|
login: Optional trading account login. Integers are preserved. String
|
||||||
|
values are coerced: empty or whitespace-only strings become
|
||||||
|
``None``; numeric strings such as ``"12345"`` are converted to
|
||||||
|
``int``; non-numeric strings raise ``ValueError``. When
|
||||||
|
``allow_whole_dollar_env=True``, ``$ENV_NAME`` and
|
||||||
|
``${ENV_NAME}`` placeholders are expanded before coercion.
|
||||||
password: Optional trading account password.
|
password: Optional trading account password.
|
||||||
server: Optional trading server name.
|
server: Optional trading server name.
|
||||||
timeout: Optional connection timeout in milliseconds.
|
timeout: Optional connection timeout in milliseconds.
|
||||||
allow_whole_dollar_env: When ``True``, string parameters that are
|
allow_whole_dollar_env: When ``True``, string parameters that are
|
||||||
exactly ``$ENV_NAME`` are expanded from the environment. Applies
|
exactly ``$ENV_NAME`` are expanded from the environment. Applies
|
||||||
to ``path``, ``password``, and ``server``. Default ``False``
|
to ``path``, ``login``, ``password``, and ``server``. Default
|
||||||
preserves existing behavior.
|
``False`` preserves existing behavior.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Configured ``Mt5Config`` instance.
|
Configured ``Mt5Config`` instance.
|
||||||
@@ -330,6 +336,8 @@ def build_config(
|
|||||||
if allow_whole_dollar_env:
|
if allow_whole_dollar_env:
|
||||||
if path is not None:
|
if path is not None:
|
||||||
path = substitute_env_placeholders(path, allow_whole_dollar_env=True)
|
path = substitute_env_placeholders(path, allow_whole_dollar_env=True)
|
||||||
|
if isinstance(login, str):
|
||||||
|
login = substitute_env_placeholders(login, allow_whole_dollar_env=True)
|
||||||
if password is not None:
|
if password is not None:
|
||||||
password = substitute_env_placeholders(
|
password = substitute_env_placeholders(
|
||||||
password, allow_whole_dollar_env=True
|
password, allow_whole_dollar_env=True
|
||||||
@@ -338,7 +346,7 @@ def build_config(
|
|||||||
server = substitute_env_placeholders(server, allow_whole_dollar_env=True)
|
server = substitute_env_placeholders(server, allow_whole_dollar_env=True)
|
||||||
return Mt5Config(
|
return Mt5Config(
|
||||||
path=path,
|
path=path,
|
||||||
login=login,
|
login=_coerce_login(login),
|
||||||
password=password,
|
password=password,
|
||||||
server=server,
|
server=server,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
@@ -1442,6 +1450,75 @@ def substitute_env_placeholders(
|
|||||||
return "".join(parts)
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def substitute_mapping_values(
|
||||||
|
data: object,
|
||||||
|
*,
|
||||||
|
keys: Collection[str],
|
||||||
|
allow_whole_dollar_env: bool = False,
|
||||||
|
blank_string_keys_as_none: Collection[str] = (),
|
||||||
|
) -> object:
|
||||||
|
"""Recursively substitute environment placeholders for selected mapping keys.
|
||||||
|
|
||||||
|
Traverses nested dicts and lists, expanding ``${ENV_VAR}`` (and
|
||||||
|
``$ENV_NAME`` when ``allow_whole_dollar_env=True``) in string values
|
||||||
|
whose immediate parent dict key is in ``keys``. Fields whose key is
|
||||||
|
not in ``keys`` are preserved exactly, including literal dollar signs.
|
||||||
|
Strings that are direct elements of a list are never substituted;
|
||||||
|
substitution only applies to strings that are immediate dict values.
|
||||||
|
|
||||||
|
This is a generic downstream config utility. Key names such as
|
||||||
|
``mt5_login`` or ``mt5_password`` must be supplied by the caller;
|
||||||
|
mt5cli does not hard-code any application-specific key names.
|
||||||
|
Callers are responsible for ensuring ``data`` has bounded nesting depth;
|
||||||
|
deeply nested or self-referential structures will hit Python's recursion
|
||||||
|
limit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Arbitrarily nested dict/list/scalar value to process.
|
||||||
|
keys: Mapping keys whose string values receive placeholder
|
||||||
|
substitution.
|
||||||
|
allow_whole_dollar_env: When ``True``, a string that is exactly
|
||||||
|
``$ENV_NAME`` (whole value) is also expanded from the
|
||||||
|
environment in addition to ``${ENV_NAME}`` placeholders.
|
||||||
|
Default ``False`` expands ``${ENV_NAME}`` only.
|
||||||
|
blank_string_keys_as_none: Mapping keys for which blank strings
|
||||||
|
(after any substitution) are normalised to ``None``. A key
|
||||||
|
may appear in ``blank_string_keys_as_none`` without also
|
||||||
|
appearing in ``keys``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The processed value. Dicts and lists are rebuilt into new
|
||||||
|
containers with selected string values substituted and
|
||||||
|
blank-normalised. Scalar inputs (non-dict, non-list) are
|
||||||
|
returned as-is.
|
||||||
|
"""
|
||||||
|
keys_set: frozenset[str] = frozenset(keys)
|
||||||
|
blank_keys_set: frozenset[str] = frozenset(blank_string_keys_as_none)
|
||||||
|
|
||||||
|
def _visit(node: object, current_key: str | None) -> object:
|
||||||
|
if isinstance(node, dict):
|
||||||
|
typed = cast("dict[object, object]", node)
|
||||||
|
return {
|
||||||
|
k: _visit(v, k if isinstance(k, str) else None)
|
||||||
|
for k, v in typed.items()
|
||||||
|
}
|
||||||
|
if isinstance(node, list):
|
||||||
|
typed_list = cast("list[object]", node)
|
||||||
|
return [_visit(item, None) for item in typed_list]
|
||||||
|
if not isinstance(node, str):
|
||||||
|
return node
|
||||||
|
text = node
|
||||||
|
if current_key in keys_set:
|
||||||
|
text = substitute_env_placeholders(
|
||||||
|
node, allow_whole_dollar_env=allow_whole_dollar_env
|
||||||
|
)
|
||||||
|
if current_key in blank_keys_set and not text.strip():
|
||||||
|
return None
|
||||||
|
return text
|
||||||
|
|
||||||
|
return _visit(data, None)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_field(
|
def _resolve_field(
|
||||||
override: str | None,
|
override: str | None,
|
||||||
account_value: str | None,
|
account_value: str | None,
|
||||||
|
|||||||
+303
-23
@@ -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
|
||||||
@@ -26,6 +25,7 @@ OrderSide = Literal["BUY", "SELL"]
|
|||||||
OrderFillingMode = Literal["IOC", "FOK", "RETURN"]
|
OrderFillingMode = Literal["IOC", "FOK", "RETURN"]
|
||||||
OrderTimeMode = Literal["GTC", "DAY", "SPECIFIED", "SPECIFIED_DAY"]
|
OrderTimeMode = Literal["GTC", "DAY", "SPECIFIED", "SPECIFIED_DAY"]
|
||||||
ExecutionStatus = Literal["executed", "dry_run", "skipped", "failed"]
|
ExecutionStatus = Literal["executed", "dry_run", "skipped", "failed"]
|
||||||
|
ProjectionMode = Literal["add", "replace_symbol"]
|
||||||
|
|
||||||
|
|
||||||
class MarginVolume(TypedDict):
|
class MarginVolume(TypedDict):
|
||||||
@@ -128,12 +128,17 @@ __all__ = [
|
|||||||
"OrderSide",
|
"OrderSide",
|
||||||
"OrderTimeMode",
|
"OrderTimeMode",
|
||||||
"PositionSide",
|
"PositionSide",
|
||||||
|
"ProjectionMode",
|
||||||
|
"calculate_account_projected_margin_ratio",
|
||||||
"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",
|
||||||
@@ -141,6 +146,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 +157,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 +435,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 +497,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 +658,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 +792,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 +820,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 +835,169 @@ def calculate_new_position_margin_ratio(
|
|||||||
return margin / equity
|
return margin / equity
|
||||||
|
|
||||||
|
|
||||||
|
def _account_equity(client: Mt5TradingClient) -> float:
|
||||||
|
account = get_account_snapshot(client)
|
||||||
|
return _required_account_number(account, "equity", allow_zero=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _required_account_number(
|
||||||
|
account: Mapping[str, object],
|
||||||
|
field: str,
|
||||||
|
*,
|
||||||
|
allow_zero: bool,
|
||||||
|
) -> float:
|
||||||
|
raw_value = account.get(field)
|
||||||
|
if isinstance(raw_value, bool) or not isinstance(raw_value, Real):
|
||||||
|
msg = f"Account {field} must be a finite number to calculate margin ratio."
|
||||||
|
raise Mt5TradingError(msg)
|
||||||
|
value = float(raw_value)
|
||||||
|
if (
|
||||||
|
not isfinite(value)
|
||||||
|
or (not allow_zero and value <= 0)
|
||||||
|
or (allow_zero and value < 0)
|
||||||
|
):
|
||||||
|
msg = (
|
||||||
|
f"Account {field} must be a non-negative finite number."
|
||||||
|
if allow_zero
|
||||||
|
else f"Account {field} must be a positive finite number."
|
||||||
|
)
|
||||||
|
raise Mt5TradingError(msg)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_account_projected_margin_ratio(
|
||||||
|
client: Mt5TradingClient,
|
||||||
|
*,
|
||||||
|
symbol: str | None = None,
|
||||||
|
new_position_side: OrderSide | None = None,
|
||||||
|
new_position_volume: float = 0.0,
|
||||||
|
) -> float:
|
||||||
|
"""Return account-wide current plus optional new-position margin over equity.
|
||||||
|
|
||||||
|
Current exposure comes from the broker account snapshot ``margin`` field so
|
||||||
|
unrelated open positions remain in the baseline. Optional projected
|
||||||
|
exposure is added via :func:`estimate_order_margin` only when a symbol, side,
|
||||||
|
and positive volume are all supplied.
|
||||||
|
|
||||||
|
"""
|
||||||
|
account = get_account_snapshot(client)
|
||||||
|
equity = _required_account_number(account, "equity", allow_zero=False)
|
||||||
|
margin = _required_account_number(account, "margin", allow_zero=True)
|
||||||
|
if symbol is not None and 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_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 _validate_projection_mode(projection_mode: str) -> ProjectionMode:
|
||||||
|
if projection_mode not in {"add", "replace_symbol"}:
|
||||||
|
msg = (
|
||||||
|
f"Unsupported projection mode: {projection_mode!r}. "
|
||||||
|
"Expected 'add' or 'replace_symbol'."
|
||||||
|
)
|
||||||
|
raise ValueError(msg)
|
||||||
|
return cast("ProjectionMode", projection_mode)
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
projection_mode: ProjectionMode = "add",
|
||||||
|
) -> 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 and candidate side/volume are provided, projected order
|
||||||
|
margin is applied according to ``projection_mode``:
|
||||||
|
|
||||||
|
- ``"add"`` (default): adds candidate margin to the group total.
|
||||||
|
- ``"replace_symbol"``: subtracts current margin for ``new_symbol``, then
|
||||||
|
adds candidate margin. Useful for reversal-style projections where the new
|
||||||
|
order is intended to replace existing exposure for that symbol.
|
||||||
|
|
||||||
|
If the candidate margin estimation fails, the subtraction is also skipped so
|
||||||
|
the operation is atomic. 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``.
|
||||||
|
"""
|
||||||
|
projection_mode = _validate_projection_mode(projection_mode)
|
||||||
|
equity = _account_equity(client)
|
||||||
|
unique_symbols = list(dict.fromkeys(symbols))
|
||||||
|
per_symbol = calculate_positions_margin_by_symbol(
|
||||||
|
client,
|
||||||
|
symbols=unique_symbols,
|
||||||
|
suppress_errors=suppress_errors,
|
||||||
|
)
|
||||||
|
margin = sum(per_symbol.values(), 0.0)
|
||||||
|
if (
|
||||||
|
new_symbol in unique_symbols
|
||||||
|
and new_position_side is not None
|
||||||
|
and new_position_volume > 0
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
candidate_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)
|
||||||
|
else:
|
||||||
|
if projection_mode == "replace_symbol":
|
||||||
|
margin = max(0.0, margin - per_symbol.get(new_symbol, 0.0))
|
||||||
|
margin += candidate_margin
|
||||||
|
return margin / equity
|
||||||
|
|
||||||
|
|
||||||
def calculate_margin_and_volume(
|
def calculate_margin_and_volume(
|
||||||
client: Mt5TradingClient,
|
client: Mt5TradingClient,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
@@ -921,7 +1091,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 +1171,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 +1250,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 +1358,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 +1562,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}: "
|
||||||
|
|||||||
@@ -314,6 +314,7 @@ def export_dataframe(
|
|||||||
table_name: Table name for SQLite3 output.
|
table_name: Table name for SQLite3 output.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
|
ImportError: If the parquet format is requested but pyarrow is not installed.
|
||||||
ValueError: If the output format is not supported.
|
ValueError: If the output format is not supported.
|
||||||
"""
|
"""
|
||||||
if output_format == "csv":
|
if output_format == "csv":
|
||||||
@@ -326,6 +327,14 @@ def export_dataframe(
|
|||||||
indent=2,
|
indent=2,
|
||||||
)
|
)
|
||||||
elif output_format == "parquet":
|
elif output_format == "parquet":
|
||||||
|
try:
|
||||||
|
__import__("pyarrow")
|
||||||
|
except ImportError as exc:
|
||||||
|
msg = (
|
||||||
|
"Parquet export requires the optional dependency pyarrow. "
|
||||||
|
'Install it with: pip install "mt5cli[parquet]"'
|
||||||
|
)
|
||||||
|
raise ImportError(msg) from exc
|
||||||
df.to_parquet(output_path, index=False)
|
df.to_parquet(output_path, index=False)
|
||||||
elif output_format == "sqlite3":
|
elif output_format == "sqlite3":
|
||||||
export_dataframe_to_sqlite(
|
export_dataframe_to_sqlite(
|
||||||
|
|||||||
+5
-2
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mt5cli"
|
name = "mt5cli"
|
||||||
version = "0.9.2"
|
version = "0.9.7"
|
||||||
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"}]
|
||||||
@@ -11,7 +11,6 @@ requires-python = ">= 3.11, < 3.14"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"pdmt5>=0.3.0",
|
"pdmt5>=0.3.0",
|
||||||
"click >= 8.1.0",
|
"click >= 8.1.0",
|
||||||
"pyarrow >= 19.0.0",
|
|
||||||
"typer >= 0.15.0",
|
"typer >= 0.15.0",
|
||||||
]
|
]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
@@ -25,6 +24,9 @@ classifiers = [
|
|||||||
"Topic :: Office/Business :: Financial :: Investment",
|
"Topic :: Office/Business :: Financial :: Investment",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
parquet = ["pyarrow >= 19.0.0"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
mt5cli = "mt5cli.cli:main"
|
mt5cli = "mt5cli.cli:main"
|
||||||
|
|
||||||
@@ -42,6 +44,7 @@ dev = [
|
|||||||
"pytest-mock >= 3.12.0",
|
"pytest-mock >= 3.12.0",
|
||||||
"pytest-cov >= 5.0.0",
|
"pytest-cov >= 5.0.0",
|
||||||
"pandas-stubs >= 2.2.3.250527",
|
"pandas-stubs >= 2.2.3.250527",
|
||||||
|
"pyarrow >= 19.0.0",
|
||||||
"mkdocs >= 1.6.1",
|
"mkdocs >= 1.6.1",
|
||||||
"mkdocs-material >= 9.7.6",
|
"mkdocs-material >= 9.7.6",
|
||||||
"mkdocstrings[python] >= 1.0.4",
|
"mkdocstrings[python] >= 1.0.4",
|
||||||
|
|||||||
@@ -740,6 +740,306 @@ class TestCommands:
|
|||||||
assert "must be a JSON object" in normalize_cli_output(result.output)
|
assert "must be a JSON object" in normalize_cli_output(result.output)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# close-positions command
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _build_mock_trading_client() -> MagicMock:
|
||||||
|
"""Return a MagicMock Mt5TradingClient with trading constants set."""
|
||||||
|
client = MagicMock()
|
||||||
|
client.mt5.POSITION_TYPE_BUY = 0
|
||||||
|
client.mt5.POSITION_TYPE_SELL = 1
|
||||||
|
client.mt5.ORDER_TYPE_BUY = 10
|
||||||
|
client.mt5.ORDER_TYPE_SELL = 11
|
||||||
|
client.mt5.TRADE_ACTION_DEAL = 20
|
||||||
|
client.mt5.ORDER_FILLING_IOC = 30
|
||||||
|
client.mt5.ORDER_TIME_GTC = 40
|
||||||
|
client.mt5.TRADE_RETCODE_DONE = 10009
|
||||||
|
client.mt5.TRADE_RETCODE_PLACED = 10008
|
||||||
|
client.mt5.TRADE_RETCODE_DONE_PARTIAL = 10010
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
class TestClosePositions:
|
||||||
|
"""Tests for the close-positions command."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def trading_client(self, mocker: MockerFixture) -> MagicMock:
|
||||||
|
"""Patch create_trading_client and return a mock trading client."""
|
||||||
|
client = _build_mock_trading_client()
|
||||||
|
client.positions_get_as_df.return_value = pd.DataFrame([
|
||||||
|
{"ticket": 1, "symbol": "JP225", "type": 0, "volume": 1.0},
|
||||||
|
{"ticket": 2, "symbol": "EURUSD", "type": 1, "volume": 0.5},
|
||||||
|
])
|
||||||
|
client.symbol_info_tick_as_dict.return_value = {"ask": 1.2, "bid": 1.1}
|
||||||
|
mocker.patch("mt5cli.cli.create_trading_client", return_value=client)
|
||||||
|
return client
|
||||||
|
|
||||||
|
def test_dry_run_does_not_require_yes(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
trading_client: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test --dry-run mode succeeds without --yes."""
|
||||||
|
output = tmp_path / "close.json"
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["-o", str(output), "close-positions", "--symbol", "JP225", "--dry-run"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert output.exists()
|
||||||
|
trading_client.order_send.assert_not_called()
|
||||||
|
trading_client.shutdown.assert_called_once()
|
||||||
|
|
||||||
|
def test_live_requires_yes(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
trading_client: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test live close-positions fails without --yes."""
|
||||||
|
output = tmp_path / "close.json"
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["-o", str(output), "close-positions", "--symbol", "JP225"],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "Pass --yes" in normalize_cli_output(result.output)
|
||||||
|
trading_client.order_send.assert_not_called()
|
||||||
|
|
||||||
|
def test_live_with_yes_calls_order_send(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
trading_client: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test --yes triggers live execution for matching positions."""
|
||||||
|
trading_client.order_send.return_value = {"retcode": 10009, "comment": "ok"}
|
||||||
|
output = tmp_path / "close.json"
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["-o", str(output), "close-positions", "--symbol", "JP225", "--yes"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
trading_client.order_send.assert_called_once()
|
||||||
|
trading_client.shutdown.assert_called_once()
|
||||||
|
|
||||||
|
def test_symbol_filter_passed_through(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
trading_client: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test --symbol values are used to filter positions."""
|
||||||
|
output = tmp_path / "close.json"
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"-o",
|
||||||
|
str(output),
|
||||||
|
"close-positions",
|
||||||
|
"--symbol",
|
||||||
|
"JP225",
|
||||||
|
"--dry-run",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
data = json.loads(output.read_text())
|
||||||
|
assert len(data) == 1
|
||||||
|
assert data[0]["symbol"] == "JP225"
|
||||||
|
trading_client.shutdown.assert_called_once()
|
||||||
|
|
||||||
|
def test_multiple_symbols_filter(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
trading_client: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test multiple --symbol options are combined."""
|
||||||
|
output = tmp_path / "close.json"
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"-o",
|
||||||
|
str(output),
|
||||||
|
"close-positions",
|
||||||
|
"--symbol",
|
||||||
|
"JP225",
|
||||||
|
"--symbol",
|
||||||
|
"EURUSD",
|
||||||
|
"--dry-run",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
data = json.loads(output.read_text())
|
||||||
|
assert len(data) == 2
|
||||||
|
symbols = {row["symbol"] for row in data}
|
||||||
|
assert symbols == {"JP225", "EURUSD"}
|
||||||
|
trading_client.shutdown.assert_called_once()
|
||||||
|
|
||||||
|
def test_ticket_filter_passed_through(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
trading_client: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test --ticket values are used to filter positions."""
|
||||||
|
output = tmp_path / "close.json"
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"-o",
|
||||||
|
str(output),
|
||||||
|
"close-positions",
|
||||||
|
"--ticket",
|
||||||
|
"2",
|
||||||
|
"--dry-run",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
data = json.loads(output.read_text())
|
||||||
|
assert len(data) == 1
|
||||||
|
assert data[0]["symbol"] == "EURUSD"
|
||||||
|
trading_client.shutdown.assert_called_once()
|
||||||
|
|
||||||
|
def test_symbol_and_ticket_combined(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
trading_client: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test --symbol and --ticket apply AND semantics when combined."""
|
||||||
|
output = tmp_path / "close.json"
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"-o",
|
||||||
|
str(output),
|
||||||
|
"close-positions",
|
||||||
|
"--symbol",
|
||||||
|
"JP225",
|
||||||
|
"--ticket",
|
||||||
|
"1",
|
||||||
|
"--dry-run",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
data = json.loads(output.read_text())
|
||||||
|
# symbol=JP225 AND ticket=1 → exactly one match
|
||||||
|
assert len(data) == 1
|
||||||
|
assert data[0]["symbol"] == "JP225"
|
||||||
|
trading_client.shutdown.assert_called_once()
|
||||||
|
|
||||||
|
def test_missing_symbol_and_ticket_fails(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
mocker: MockerFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Test that omitting both --symbol and --ticket fails closed."""
|
||||||
|
mocker.patch("mt5cli.cli.create_trading_client")
|
||||||
|
output = tmp_path / "close.json"
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["-o", str(output), "close-positions", "--dry-run"],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "symbol" in normalize_cli_output(result.output).lower()
|
||||||
|
|
||||||
|
def test_output_export_dry_run(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
trading_client: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test dry-run results export with status=dry_run."""
|
||||||
|
output = tmp_path / "close.json"
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["-o", str(output), "close-positions", "--symbol", "JP225", "--dry-run"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
trading_client.shutdown.assert_called_once()
|
||||||
|
data = json.loads(output.read_text())
|
||||||
|
assert data[0]["status"] == "dry_run"
|
||||||
|
assert data[0]["dry_run"] is True
|
||||||
|
assert data[0]["order_side"] == "SELL"
|
||||||
|
|
||||||
|
def test_order_send_unchanged(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
mock_client: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test that order-send behavior is unchanged by close-positions addition."""
|
||||||
|
output = tmp_path / "out.csv"
|
||||||
|
request = json.dumps({"action": 1, "symbol": "EURUSD", "volume": 0.1})
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["-o", str(output), "order-send", "--request", request, "--yes"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
mock_client.order_send_as_df.assert_called_once()
|
||||||
|
|
||||||
|
def test_shutdown_called_on_close_error(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
mocker: MockerFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Test that shutdown is called even when close_open_positions raises."""
|
||||||
|
client = _build_mock_trading_client()
|
||||||
|
client.positions_get_as_df.side_effect = RuntimeError("connection lost")
|
||||||
|
mocker.patch("mt5cli.cli.create_trading_client", return_value=client)
|
||||||
|
output = tmp_path / "close.json"
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["-o", str(output), "close-positions", "--symbol", "JP225", "--dry-run"],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
client.shutdown.assert_called_once()
|
||||||
|
|
||||||
|
def test_dry_run_wins_over_yes(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
trading_client: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test that --dry-run takes precedence when combined with --yes."""
|
||||||
|
output = tmp_path / "close.json"
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"-o",
|
||||||
|
str(output),
|
||||||
|
"close-positions",
|
||||||
|
"--symbol",
|
||||||
|
"JP225",
|
||||||
|
"--dry-run",
|
||||||
|
"--yes",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
trading_client.order_send.assert_not_called()
|
||||||
|
trading_client.shutdown.assert_called_once()
|
||||||
|
|
||||||
|
def test_no_matching_positions_exports_empty_result(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
trading_client: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test that zero filter matches produces an empty JSON array."""
|
||||||
|
trading_client.positions_get_as_df.return_value = pd.DataFrame([
|
||||||
|
{"ticket": 1, "symbol": "JP225", "type": 0, "volume": 1.0},
|
||||||
|
])
|
||||||
|
output = tmp_path / "close.json"
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"-o",
|
||||||
|
str(output),
|
||||||
|
"close-positions",
|
||||||
|
"--symbol",
|
||||||
|
"NONEXISTENT",
|
||||||
|
"--dry-run",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
trading_client.shutdown.assert_called_once()
|
||||||
|
assert output.exists()
|
||||||
|
assert json.loads(output.read_text()) == []
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Callback / shared options
|
# Callback / shared options
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+130
-40
@@ -2,9 +2,12 @@
|
|||||||
|
|
||||||
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 importlib.metadata import requires
|
||||||
|
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 +18,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,
|
||||||
@@ -33,8 +38,12 @@ from mt5cli import (
|
|||||||
RateTarget,
|
RateTarget,
|
||||||
build_config,
|
build_config,
|
||||||
build_rate_targets,
|
build_rate_targets,
|
||||||
|
calculate_account_projected_margin_ratio,
|
||||||
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 +51,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 +79,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:
|
||||||
@@ -228,16 +235,19 @@ def test_is_recoverable_mt5_error(exc: Exception) -> None:
|
|||||||
assert is_recoverable_mt5_error(exc)
|
assert is_recoverable_mt5_error(exc)
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_mt5_exception_maps_types() -> None:
|
@pytest.mark.parametrize(
|
||||||
|
("exc", "expected_type"),
|
||||||
|
[
|
||||||
|
(Mt5RuntimeError("x"), Mt5ConnectionError),
|
||||||
|
(Mt5TradingError("x"), Mt5OperationError),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_normalize_mt5_exception_maps_types(
|
||||||
|
exc: Exception,
|
||||||
|
expected_type: type[Mt5ConnectionError | Mt5OperationError],
|
||||||
|
) -> None:
|
||||||
"""MT5 exceptions map to stable mt5cli types."""
|
"""MT5 exceptions map to stable mt5cli types."""
|
||||||
assert isinstance(
|
assert isinstance(normalize_mt5_exception(exc), expected_type)
|
||||||
normalize_mt5_exception(Mt5RuntimeError("x")),
|
|
||||||
Mt5ConnectionError,
|
|
||||||
)
|
|
||||||
assert isinstance(
|
|
||||||
normalize_mt5_exception(Mt5TradingError("x")),
|
|
||||||
Mt5OperationError,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_call_with_normalized_errors_reraises_mapped_type() -> None:
|
def test_call_with_normalized_errors_reraises_mapped_type() -> None:
|
||||||
@@ -414,26 +424,24 @@ def test_normalize_time_columns_skips_absent_time_fields() -> None:
|
|||||||
assert list(result.columns) == ["open"]
|
assert list(result.columns) == ["open"]
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_time_columns_converts_unix_seconds() -> None:
|
@pytest.mark.parametrize(
|
||||||
"""Numeric MT5 ``time`` values are interpreted as Unix seconds."""
|
("col", "value", "kind"),
|
||||||
frame = pd.DataFrame({"time": [1704067200]})
|
[
|
||||||
result = normalize_time_columns(frame, DataKind.rates)
|
("time", 1704067200, DataKind.rates),
|
||||||
assert result.loc[0, "time"] == pd.Timestamp("2024-01-01T00:00:00+00:00")
|
("time_msc", 1704067200000, DataKind.ticks),
|
||||||
|
("time", datetime(2024, 1, 1, tzinfo=UTC), DataKind.rates),
|
||||||
|
("time", "2024-01-01T00:00:00+00:00", DataKind.rates),
|
||||||
def test_normalize_time_columns_converts_unix_milliseconds() -> None:
|
],
|
||||||
"""Numeric MT5 ``time_msc`` values are interpreted as Unix milliseconds."""
|
)
|
||||||
frame = pd.DataFrame({"time_msc": [1704067200000]})
|
def test_normalize_time_columns_coerces_value(
|
||||||
result = normalize_time_columns(frame, DataKind.ticks)
|
col: str,
|
||||||
assert result.loc[0, "time_msc"] == pd.Timestamp("2024-01-01T00:00:00+00:00")
|
value: object,
|
||||||
|
kind: DataKind,
|
||||||
|
) -> None:
|
||||||
def test_normalize_time_columns_preserves_utc_datetimes() -> None:
|
"""Time column values are coerced to UTC timestamps regardless of input type."""
|
||||||
"""Already-converted datetime values remain UTC-normalized."""
|
frame = pd.DataFrame({col: [value]})
|
||||||
aware = datetime(2024, 1, 1, tzinfo=UTC)
|
result = normalize_time_columns(frame, kind)
|
||||||
frame = pd.DataFrame({"time": [aware]})
|
assert result.loc[0, col] == pd.Timestamp("2024-01-01T00:00:00+00:00")
|
||||||
result = normalize_time_columns(frame, DataKind.rates)
|
|
||||||
assert result.loc[0, "time"] == pd.Timestamp("2024-01-01T00:00:00+00:00")
|
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_time_columns_handles_optional_order_times() -> None:
|
def test_normalize_time_columns_handles_optional_order_times() -> None:
|
||||||
@@ -483,13 +491,6 @@ def test_ensure_utc_columns_skips_missing_columns() -> None:
|
|||||||
assert "time" in result.columns
|
assert "time" in result.columns
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_time_columns_coerces_string_timestamps() -> None:
|
|
||||||
"""String timestamps are parsed with timezone-aware datetime coercion."""
|
|
||||||
frame = pd.DataFrame({"time": ["2024-01-01T00:00:00+00:00"]})
|
|
||||||
result = normalize_time_columns(frame, DataKind.rates)
|
|
||||||
assert result.loc[0, "time"] == pd.Timestamp("2024-01-01T00:00:00+00:00")
|
|
||||||
|
|
||||||
|
|
||||||
def test_ensure_utc_columns_coerces_non_mt5_columns() -> None:
|
def test_ensure_utc_columns_coerces_non_mt5_columns() -> None:
|
||||||
"""Non-MT5 columns still coerce to UTC datetimes."""
|
"""Non-MT5 columns still coerce to UTC datetimes."""
|
||||||
frame = pd.DataFrame({"created_at": ["2024-01-01T00:00:00+00:00"]})
|
frame = pd.DataFrame({"created_at": ["2024-01-01T00:00:00+00:00"]})
|
||||||
@@ -547,11 +548,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 +674,16 @@ 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_account_projected_margin_ratio)
|
||||||
|
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"
|
||||||
@@ -764,3 +833,24 @@ class TestStableSdkContract:
|
|||||||
assert result.index.tz is not None
|
assert result.index.tz is not None
|
||||||
assert "time" not in result.columns
|
assert "time" not in result.columns
|
||||||
assert "close" in result.columns
|
assert "close" in result.columns
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Packaging metadata
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_parquet_extra_declares_pyarrow() -> None:
|
||||||
|
"""Package metadata lists pyarrow under the parquet optional extra."""
|
||||||
|
reqs = requires("mt5cli") or []
|
||||||
|
parquet_reqs = [r for r in reqs if "pyarrow" in r and "parquet" in r]
|
||||||
|
assert parquet_reqs, "pyarrow not found in parquet optional extra"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pyarrow_not_in_core_dependencies() -> None:
|
||||||
|
"""Pyarrow is not a core dependency; it belongs only in the parquet extra."""
|
||||||
|
reqs = requires("mt5cli") or []
|
||||||
|
core_reqs = [r for r in reqs if "extra ==" not in r]
|
||||||
|
assert not any("pyarrow" in r for r in core_reqs), (
|
||||||
|
"pyarrow should not appear in core dependencies"
|
||||||
|
)
|
||||||
|
|||||||
+19
-54
@@ -705,19 +705,25 @@ class TestIncrementalStart:
|
|||||||
assert starts["EURUSD", 1] == datetime(2024, 1, 2, tzinfo=UTC)
|
assert starts["EURUSD", 1] == datetime(2024, 1, 2, tzinfo=UTC)
|
||||||
assert starts["GBPUSD", 1] == datetime(2024, 1, 3, tzinfo=UTC)
|
assert starts["GBPUSD", 1] == datetime(2024, 1, 3, tzinfo=UTC)
|
||||||
|
|
||||||
def test_load_incremental_start_datetimes_requires_timeframe_column(
|
@pytest.mark.parametrize(
|
||||||
|
("ddl", "missing_col"),
|
||||||
|
[
|
||||||
|
("CREATE TABLE rates(symbol TEXT, time TEXT, open REAL)", "timeframe"),
|
||||||
|
("CREATE TABLE rates(timeframe INTEGER, time TEXT, open REAL)", "symbol"),
|
||||||
|
("CREATE TABLE rates(symbol TEXT, timeframe INTEGER, open REAL)", "time"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_load_incremental_start_datetimes_requires_column(
|
||||||
self,
|
self,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
|
ddl: str,
|
||||||
|
missing_col: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test rates tables without timeframe fail fast during incremental resume."""
|
"""Test rates tables missing a required column fail fast."""
|
||||||
fallback = datetime(2024, 1, 1, tzinfo=UTC)
|
fallback = datetime(2024, 1, 1, tzinfo=UTC)
|
||||||
with sqlite3.connect(tmp_path / "rates-without-timeframe.db") as conn:
|
with sqlite3.connect(tmp_path / f"rates-no-{missing_col}.db") as conn:
|
||||||
conn.execute("CREATE TABLE rates(symbol TEXT, time TEXT, open REAL)")
|
conn.execute(ddl)
|
||||||
conn.execute(
|
with pytest.raises(ValueError, match=f"missing: {missing_col}") as exc_info:
|
||||||
"INSERT INTO rates(symbol, time, open) VALUES (?, ?, ?)",
|
|
||||||
("EURUSD", "2024-01-02T00:00:00+00:00", 1.0),
|
|
||||||
)
|
|
||||||
with pytest.raises(ValueError, match="missing: timeframe") as exc_info:
|
|
||||||
load_incremental_start_datetimes(
|
load_incremental_start_datetimes(
|
||||||
conn,
|
conn,
|
||||||
Dataset.rates,
|
Dataset.rates,
|
||||||
@@ -725,47 +731,7 @@ class TestIncrementalStart:
|
|||||||
timeframes=[1],
|
timeframes=[1],
|
||||||
fallback_start=fallback,
|
fallback_start=fallback,
|
||||||
)
|
)
|
||||||
assert "timeframe" in str(exc_info.value)
|
assert missing_col in str(exc_info.value)
|
||||||
|
|
||||||
def test_load_incremental_start_datetimes_requires_symbol_column(
|
|
||||||
self,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test rates tables without symbol fail fast during incremental resume."""
|
|
||||||
fallback = datetime(2024, 1, 1, tzinfo=UTC)
|
|
||||||
with sqlite3.connect(tmp_path / "rates-no-symbol.db") as conn:
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE rates(timeframe INTEGER, time TEXT, open REAL)",
|
|
||||||
)
|
|
||||||
with pytest.raises(ValueError, match="missing: symbol") as exc_info:
|
|
||||||
load_incremental_start_datetimes(
|
|
||||||
conn,
|
|
||||||
Dataset.rates,
|
|
||||||
symbols=["EURUSD"],
|
|
||||||
timeframes=[1],
|
|
||||||
fallback_start=fallback,
|
|
||||||
)
|
|
||||||
assert "symbol" in str(exc_info.value)
|
|
||||||
|
|
||||||
def test_load_incremental_start_datetimes_requires_time_column(
|
|
||||||
self,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test rates tables without time fail fast during incremental resume."""
|
|
||||||
fallback = datetime(2024, 1, 1, tzinfo=UTC)
|
|
||||||
with sqlite3.connect(tmp_path / "rates-no-time.db") as conn:
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE rates(symbol TEXT, timeframe INTEGER, open REAL)",
|
|
||||||
)
|
|
||||||
with pytest.raises(ValueError, match="missing: time") as exc_info:
|
|
||||||
load_incremental_start_datetimes(
|
|
||||||
conn,
|
|
||||||
Dataset.rates,
|
|
||||||
symbols=["EURUSD"],
|
|
||||||
timeframes=[1],
|
|
||||||
fallback_start=fallback,
|
|
||||||
)
|
|
||||||
assert "time" in str(exc_info.value)
|
|
||||||
|
|
||||||
def test_load_incremental_start_datetimes_rejects_unrelated_rates_columns(
|
def test_load_incremental_start_datetimes_rejects_unrelated_rates_columns(
|
||||||
self,
|
self,
|
||||||
@@ -1800,12 +1766,11 @@ class TestIncrementalIntegration:
|
|||||||
)
|
)
|
||||||
assert written_tables == set()
|
assert written_tables == set()
|
||||||
|
|
||||||
def test_resolve_history_tick_flags_invalid(self) -> None:
|
@pytest.mark.parametrize("flags", ["BAD", 7])
|
||||||
|
def test_resolve_history_tick_flags_invalid(self, flags: str | int) -> None:
|
||||||
"""Test invalid tick flags raise ValueError."""
|
"""Test invalid tick flags raise ValueError."""
|
||||||
with pytest.raises(ValueError, match="Invalid tick flags"):
|
with pytest.raises(ValueError, match="Invalid tick flags"):
|
||||||
resolve_history_tick_flags("BAD")
|
resolve_history_tick_flags(flags)
|
||||||
with pytest.raises(ValueError, match="Invalid tick flags"):
|
|
||||||
resolve_history_tick_flags(7)
|
|
||||||
|
|
||||||
def test_resolve_history_timeframes_invalid(self) -> None:
|
def test_resolve_history_timeframes_invalid(self) -> None:
|
||||||
"""Test invalid timeframes raise ValueError."""
|
"""Test invalid timeframes raise ValueError."""
|
||||||
|
|||||||
+292
-43
@@ -54,6 +54,7 @@ from mt5cli.sdk import (
|
|||||||
resolve_account_spec,
|
resolve_account_spec,
|
||||||
resolve_account_specs,
|
resolve_account_specs,
|
||||||
substitute_env_placeholders,
|
substitute_env_placeholders,
|
||||||
|
substitute_mapping_values,
|
||||||
symbol_info,
|
symbol_info,
|
||||||
symbol_info_tick,
|
symbol_info_tick,
|
||||||
symbols,
|
symbols,
|
||||||
@@ -1937,29 +1938,28 @@ class TestResolveAccountSpec:
|
|||||||
assert [a.server for a in resolved] == ["Shared", "Fixed"]
|
assert [a.server for a in resolved] == ["Shared", "Fixed"]
|
||||||
assert all(a.timeout == 1000 for a in resolved)
|
assert all(a.timeout == 1000 for a in resolved)
|
||||||
|
|
||||||
def test_resolve_account_spec_with_whole_dollar_env(
|
@pytest.mark.parametrize(
|
||||||
|
("allow_whole_dollar_env", "expected"),
|
||||||
|
[
|
||||||
|
(True, "secret"),
|
||||||
|
(False, "$MT5_PASSWORD"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_resolve_account_spec_whole_dollar_password(
|
||||||
self,
|
self,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
allow_whole_dollar_env: bool,
|
||||||
|
expected: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Account spec expands $ENV_NAME when allow_whole_dollar_env=True."""
|
"""Test resolve_account_spec expands $ENV_NAME password only with opt-in."""
|
||||||
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
||||||
account = AccountSpec(symbols=["EURUSD"], password="$MT5_PASSWORD")
|
account = AccountSpec(symbols=["EURUSD"], password="$MT5_PASSWORD")
|
||||||
|
|
||||||
resolved = resolve_account_spec(account, allow_whole_dollar_env=True)
|
resolved = resolve_account_spec(
|
||||||
|
account, allow_whole_dollar_env=allow_whole_dollar_env
|
||||||
|
)
|
||||||
|
|
||||||
assert resolved.password == "secret" # noqa: S105
|
assert resolved.password == expected
|
||||||
|
|
||||||
def test_resolve_account_spec_whole_dollar_not_expanded_by_default(
|
|
||||||
self,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
"""Test resolve_account_spec leaves $ENV_NAME literal by default."""
|
|
||||||
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
|
||||||
account = AccountSpec(symbols=["EURUSD"], password="$MT5_PASSWORD")
|
|
||||||
|
|
||||||
resolved = resolve_account_spec(account)
|
|
||||||
|
|
||||||
assert resolved.password == "$MT5_PASSWORD" # noqa: S105
|
|
||||||
|
|
||||||
def test_resolve_account_specs_with_whole_dollar_env(
|
def test_resolve_account_specs_with_whole_dollar_env(
|
||||||
self,
|
self,
|
||||||
@@ -1993,38 +1993,27 @@ class TestResolveAccountSpec:
|
|||||||
class TestBuildConfigWholeDollarEnv:
|
class TestBuildConfigWholeDollarEnv:
|
||||||
"""Tests for build_config with allow_whole_dollar_env."""
|
"""Tests for build_config with allow_whole_dollar_env."""
|
||||||
|
|
||||||
def test_build_config_substitutes_server_with_opt_in(
|
@pytest.mark.parametrize(
|
||||||
|
("env_var", "field", "env_value"),
|
||||||
|
[
|
||||||
|
("MT5_SERVER", "server", "Broker-Demo"),
|
||||||
|
("MT5_PASSWORD", "password", "secret"),
|
||||||
|
("MT5_PATH", "path", "/opt/mt5/terminal64.exe"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_build_config_substitutes_field_with_opt_in(
|
||||||
self,
|
self,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
env_var: str,
|
||||||
|
field: str,
|
||||||
|
env_value: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""build_config expands $ENV_NAME server when allow_whole_dollar_env=True."""
|
"""Test build_config expands $ENV_NAME fields when opt-in is enabled."""
|
||||||
monkeypatch.setenv("MT5_SERVER", "Broker-Demo")
|
monkeypatch.setenv(env_var, env_value)
|
||||||
|
|
||||||
config = build_config(server="$MT5_SERVER", allow_whole_dollar_env=True)
|
config = build_config(**{field: f"${env_var}"}, allow_whole_dollar_env=True) # type: ignore[arg-type]
|
||||||
|
|
||||||
assert config.server == "Broker-Demo"
|
assert getattr(config, field) == env_value
|
||||||
|
|
||||||
def test_build_config_substitutes_password_with_opt_in(
|
|
||||||
self,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
"""build_config expands $ENV_NAME password when allow_whole_dollar_env=True."""
|
|
||||||
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
|
||||||
|
|
||||||
config = build_config(password="$MT5_PASSWORD", allow_whole_dollar_env=True)
|
|
||||||
|
|
||||||
assert config.password == "secret" # noqa: S105
|
|
||||||
|
|
||||||
def test_build_config_substitutes_path_with_opt_in(
|
|
||||||
self,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
"""Test build_config expands $ENV_NAME path when allow_whole_dollar_env=True."""
|
|
||||||
monkeypatch.setenv("MT5_PATH", "/opt/mt5/terminal64.exe")
|
|
||||||
|
|
||||||
config = build_config(path="$MT5_PATH", allow_whole_dollar_env=True)
|
|
||||||
|
|
||||||
assert config.path == "/opt/mt5/terminal64.exe"
|
|
||||||
|
|
||||||
def test_build_config_leaves_dollar_literal_by_default(
|
def test_build_config_leaves_dollar_literal_by_default(
|
||||||
self,
|
self,
|
||||||
@@ -2436,3 +2425,263 @@ class TestThrottledHistoryUpdater:
|
|||||||
updater.update(MagicMock(), ["EURUSD"])
|
updater.update(MagicMock(), ["EURUSD"])
|
||||||
|
|
||||||
assert updater.last_update_monotonic is None
|
assert updater.last_update_monotonic is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildConfigStringLogin:
|
||||||
|
"""Tests for build_config() string login coercion (issue #61)."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("login", "expected"),
|
||||||
|
[
|
||||||
|
(None, None),
|
||||||
|
(12345, 12345),
|
||||||
|
("12345", 12345),
|
||||||
|
(" 12345 ", 12345),
|
||||||
|
("", None),
|
||||||
|
(" ", None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_coerces_login_from_string(
|
||||||
|
self,
|
||||||
|
login: int | str | None,
|
||||||
|
expected: int | None,
|
||||||
|
) -> None:
|
||||||
|
"""Test build_config coerces string login to int or None."""
|
||||||
|
config = build_config(login=login)
|
||||||
|
assert config.login == expected
|
||||||
|
|
||||||
|
def test_rejects_non_numeric_string_login(self) -> None:
|
||||||
|
"""Test build_config raises ValueError for non-numeric string login."""
|
||||||
|
with pytest.raises(ValueError, match="invalid literal"):
|
||||||
|
build_config(login="abc")
|
||||||
|
|
||||||
|
def test_expands_dollar_brace_login_with_opt_in(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test build_config expands ${MT5_LOGIN} and coerces with opt-in."""
|
||||||
|
monkeypatch.setenv("MT5_LOGIN", "12345")
|
||||||
|
config = build_config(login="${MT5_LOGIN}", allow_whole_dollar_env=True)
|
||||||
|
assert config.login == 12345
|
||||||
|
|
||||||
|
def test_expands_whole_dollar_login_with_opt_in(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test build_config expands $MT5_LOGIN and coerces with opt-in."""
|
||||||
|
monkeypatch.setenv("MT5_LOGIN", "99999")
|
||||||
|
config = build_config(login="$MT5_LOGIN", allow_whole_dollar_env=True)
|
||||||
|
assert config.login == 99999
|
||||||
|
|
||||||
|
def test_missing_env_variable_raises(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test build_config raises ValueError when referenced env var is not set."""
|
||||||
|
monkeypatch.delenv("MT5_LOGIN", raising=False)
|
||||||
|
with pytest.raises(ValueError, match="'MT5_LOGIN' is not set"):
|
||||||
|
build_config(login="${MT5_LOGIN}", allow_whole_dollar_env=True)
|
||||||
|
|
||||||
|
def test_env_expands_to_blank_becomes_none(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test build_config coerces blank env-expanded login to None."""
|
||||||
|
monkeypatch.setenv("MT5_LOGIN", "")
|
||||||
|
config = build_config(login="${MT5_LOGIN}", allow_whole_dollar_env=True)
|
||||||
|
assert config.login is None
|
||||||
|
|
||||||
|
def test_dollar_brace_login_not_expanded_without_opt_in(self) -> None:
|
||||||
|
"""Test ${MT5_LOGIN} is not expanded when allow_whole_dollar_env=False."""
|
||||||
|
with pytest.raises(ValueError, match="invalid literal"):
|
||||||
|
build_config(login="${MT5_LOGIN}")
|
||||||
|
|
||||||
|
def test_integer_login_preserved_backward_compat(self) -> None:
|
||||||
|
"""Test existing int login callers remain backward-compatible."""
|
||||||
|
config = build_config(login=54321)
|
||||||
|
assert config.login == 54321
|
||||||
|
|
||||||
|
def test_none_login_preserved_backward_compat(self) -> None:
|
||||||
|
"""Test existing None login callers remain backward-compatible."""
|
||||||
|
config = build_config(login=None)
|
||||||
|
assert config.login is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestSubstituteMappingValues:
|
||||||
|
"""Tests for substitute_mapping_values() (issue #62)."""
|
||||||
|
|
||||||
|
def test_substitutes_selected_keys_in_flat_dict(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test selected keys are substituted in a flat mapping."""
|
||||||
|
monkeypatch.setenv("MT5_LOGIN", "12345")
|
||||||
|
data: dict[str, object] = {
|
||||||
|
"mt5_login": "${MT5_LOGIN}",
|
||||||
|
"strategy_name": "${MT5_LOGIN}",
|
||||||
|
}
|
||||||
|
result = substitute_mapping_values(data, keys={"mt5_login"})
|
||||||
|
assert result == {"mt5_login": "12345", "strategy_name": "${MT5_LOGIN}"}
|
||||||
|
|
||||||
|
def test_preserves_non_selected_literal_dollar_signs(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test literal dollar signs in non-selected fields are preserved exactly."""
|
||||||
|
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
||||||
|
data: dict[str, object] = {
|
||||||
|
"mt5_password": "${MT5_PASSWORD}",
|
||||||
|
"notes": "$NOT_EXPANDED",
|
||||||
|
}
|
||||||
|
result = substitute_mapping_values(data, keys={"mt5_password"})
|
||||||
|
assert result == {"mt5_password": "secret", "notes": "$NOT_EXPANDED"}
|
||||||
|
|
||||||
|
def test_nested_dict_traversal_substitutes_selected_keys(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test selected keys inside nested dicts are substituted."""
|
||||||
|
monkeypatch.setenv("MT5_SERVER", "Broker-Demo")
|
||||||
|
data: dict[str, object] = {
|
||||||
|
"outer": {
|
||||||
|
"mt5_server": "${MT5_SERVER}",
|
||||||
|
"other": "${MT5_SERVER}",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = substitute_mapping_values(data, keys={"mt5_server"})
|
||||||
|
assert result == {
|
||||||
|
"outer": {"mt5_server": "Broker-Demo", "other": "${MT5_SERVER}"}
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_nested_list_traversal_substitutes_selected_keys(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test selected keys inside list elements are substituted."""
|
||||||
|
monkeypatch.setenv("MT5_LOGIN", "42")
|
||||||
|
data: dict[str, object] = {
|
||||||
|
"accounts": [
|
||||||
|
{"mt5_login": "${MT5_LOGIN}", "name": "${MT5_LOGIN}"},
|
||||||
|
{"mt5_login": "${MT5_LOGIN}", "name": "fixed"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
result = substitute_mapping_values(data, keys={"mt5_login"})
|
||||||
|
assert result == {
|
||||||
|
"accounts": [
|
||||||
|
{"mt5_login": "42", "name": "${MT5_LOGIN}"},
|
||||||
|
{"mt5_login": "42", "name": "fixed"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_whole_dollar_expanded_with_opt_in(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test $ENV_NAME is expanded when allow_whole_dollar_env=True."""
|
||||||
|
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
||||||
|
data: dict[str, object] = {"mt5_password": "$MT5_PASSWORD"}
|
||||||
|
result = substitute_mapping_values(
|
||||||
|
data,
|
||||||
|
keys={"mt5_password"},
|
||||||
|
allow_whole_dollar_env=True,
|
||||||
|
)
|
||||||
|
assert result == {"mt5_password": "secret"}
|
||||||
|
|
||||||
|
def test_whole_dollar_not_expanded_by_default(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test $ENV_NAME in a selected key is preserved when opt-in is False."""
|
||||||
|
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
||||||
|
data: dict[str, object] = {"mt5_password": "$MT5_PASSWORD"}
|
||||||
|
result = substitute_mapping_values(data, keys={"mt5_password"})
|
||||||
|
assert result == {"mt5_password": "$MT5_PASSWORD"}
|
||||||
|
|
||||||
|
def test_blank_string_becomes_none_for_blank_keys(self) -> None:
|
||||||
|
"""Test blank strings are normalised to None for blank_string_keys_as_none."""
|
||||||
|
data: dict[str, object] = {
|
||||||
|
"mt5_login": "",
|
||||||
|
"mt5_password": " ",
|
||||||
|
"other": "",
|
||||||
|
}
|
||||||
|
result = substitute_mapping_values(
|
||||||
|
data,
|
||||||
|
keys=set(),
|
||||||
|
blank_string_keys_as_none={"mt5_login", "mt5_password"},
|
||||||
|
)
|
||||||
|
assert result == {"mt5_login": None, "mt5_password": None, "other": ""}
|
||||||
|
|
||||||
|
def test_env_expanded_blank_becomes_none(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test env-expanded blank string is normalised to None."""
|
||||||
|
monkeypatch.setenv("MT5_LOGIN", "")
|
||||||
|
data: dict[str, object] = {"mt5_login": "${MT5_LOGIN}"}
|
||||||
|
result = substitute_mapping_values(
|
||||||
|
data,
|
||||||
|
keys={"mt5_login"},
|
||||||
|
blank_string_keys_as_none={"mt5_login"},
|
||||||
|
)
|
||||||
|
assert result == {"mt5_login": None}
|
||||||
|
|
||||||
|
def test_missing_env_variable_raises_for_selected_key(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test missing env var for a selected key raises ValueError."""
|
||||||
|
monkeypatch.delenv("MT5_MISSING", raising=False)
|
||||||
|
data: dict[str, object] = {"mt5_login": "${MT5_MISSING}"}
|
||||||
|
with pytest.raises(ValueError, match="'MT5_MISSING' is not set"):
|
||||||
|
substitute_mapping_values(data, keys={"mt5_login"})
|
||||||
|
|
||||||
|
def test_non_string_values_preserved(self) -> None:
|
||||||
|
"""Test non-string values under selected or non-selected keys are preserved."""
|
||||||
|
data: dict[str, object] = {
|
||||||
|
"mt5_login": 12345,
|
||||||
|
"timeout": 5000,
|
||||||
|
"enabled": True,
|
||||||
|
"ratio": 1.5,
|
||||||
|
"nothing": None,
|
||||||
|
}
|
||||||
|
result = substitute_mapping_values(
|
||||||
|
data, keys={"mt5_login", "timeout", "enabled", "ratio", "nothing"}
|
||||||
|
)
|
||||||
|
assert result == data
|
||||||
|
|
||||||
|
def test_caller_supplied_key_set_substitutes_correctly(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test helper works with any caller-supplied key set."""
|
||||||
|
monkeypatch.setenv("APP_LOGIN", "77777")
|
||||||
|
monkeypatch.setenv("APP_PASSWORD", "p4ss")
|
||||||
|
data: dict[str, object] = {
|
||||||
|
"app_login": "${APP_LOGIN}",
|
||||||
|
"app_password": "${APP_PASSWORD}",
|
||||||
|
"unrelated": "${APP_LOGIN}",
|
||||||
|
}
|
||||||
|
credential_keys = {"app_login", "app_password"}
|
||||||
|
result = substitute_mapping_values(data, keys=credential_keys)
|
||||||
|
assert result == {
|
||||||
|
"app_login": "77777",
|
||||||
|
"app_password": "p4ss",
|
||||||
|
"unrelated": "${APP_LOGIN}",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_scalar_data_returned_unchanged(self) -> None:
|
||||||
|
"""Test a scalar (non-dict, non-list) value is returned as-is."""
|
||||||
|
assert substitute_mapping_values("hello", keys={"x"}) == "hello"
|
||||||
|
assert substitute_mapping_values(42, keys={"x"}) == 42
|
||||||
|
assert substitute_mapping_values(None, keys={"x"}) is None
|
||||||
|
|
||||||
|
def test_tuple_container_not_traversed(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test tuple containers are returned as-is without traversal."""
|
||||||
|
monkeypatch.setenv("MT5_LOGIN", "42")
|
||||||
|
data: dict[str, object] = {"accounts": ({"mt5_login": "${MT5_LOGIN}"},)}
|
||||||
|
result = substitute_mapping_values(data, keys={"mt5_login"})
|
||||||
|
# tuple is returned as-is; inner dict is NOT visited
|
||||||
|
assert result == {"accounts": ({"mt5_login": "${MT5_LOGIN}"},)}
|
||||||
|
|||||||
+1023
-574
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import sys
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
@@ -111,6 +112,17 @@ class TestExportDataframe:
|
|||||||
result = pd.read_parquet(output)
|
result = pd.read_parquet(output)
|
||||||
pd.testing.assert_frame_equal(result, sample_df)
|
pd.testing.assert_frame_equal(result, sample_df)
|
||||||
|
|
||||||
|
def test_export_parquet_without_pyarrow(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
sample_df: pd.DataFrame,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Test that a clear error is raised when pyarrow is not installed."""
|
||||||
|
monkeypatch.setitem(sys.modules, "pyarrow", None)
|
||||||
|
with pytest.raises(ImportError, match="mt5cli\\[parquet\\]"):
|
||||||
|
export_dataframe(sample_df, tmp_path / "out.parquet", "parquet")
|
||||||
|
|
||||||
def test_export_sqlite3(self, tmp_path: Path, sample_df: pd.DataFrame) -> None:
|
def test_export_sqlite3(self, tmp_path: Path, sample_df: pd.DataFrame) -> None:
|
||||||
"""Test SQLite3 export."""
|
"""Test SQLite3 export."""
|
||||||
output = tmp_path / "out.db"
|
output = tmp_path / "out.db"
|
||||||
|
|||||||
@@ -487,21 +487,26 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mt5cli"
|
name = "mt5cli"
|
||||||
version = "0.9.2"
|
version = "0.9.7"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
{ name = "pdmt5" },
|
{ name = "pdmt5" },
|
||||||
{ name = "pyarrow" },
|
|
||||||
{ name = "typer" },
|
{ name = "typer" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
parquet = [
|
||||||
|
{ name = "pyarrow" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "mkdocs" },
|
{ name = "mkdocs" },
|
||||||
{ name = "mkdocs-material" },
|
{ name = "mkdocs-material" },
|
||||||
{ name = "mkdocstrings", extra = ["python"] },
|
{ name = "mkdocstrings", extra = ["python"] },
|
||||||
{ name = "pandas-stubs" },
|
{ name = "pandas-stubs" },
|
||||||
|
{ name = "pyarrow" },
|
||||||
{ name = "pymdown-extensions" },
|
{ name = "pymdown-extensions" },
|
||||||
{ name = "pyright" },
|
{ name = "pyright" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
@@ -514,9 +519,10 @@ dev = [
|
|||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "click", specifier = ">=8.1.0" },
|
{ name = "click", specifier = ">=8.1.0" },
|
||||||
{ name = "pdmt5", specifier = ">=0.3.0" },
|
{ name = "pdmt5", specifier = ">=0.3.0" },
|
||||||
{ name = "pyarrow", specifier = ">=19.0.0" },
|
{ name = "pyarrow", marker = "extra == 'parquet'", specifier = ">=19.0.0" },
|
||||||
{ name = "typer", specifier = ">=0.15.0" },
|
{ name = "typer", specifier = ">=0.15.0" },
|
||||||
]
|
]
|
||||||
|
provides-extras = ["parquet"]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
@@ -524,6 +530,7 @@ dev = [
|
|||||||
{ name = "mkdocs-material", specifier = ">=9.7.6" },
|
{ name = "mkdocs-material", specifier = ">=9.7.6" },
|
||||||
{ name = "mkdocstrings", extras = ["python"], specifier = ">=1.0.4" },
|
{ name = "mkdocstrings", extras = ["python"], specifier = ">=1.0.4" },
|
||||||
{ name = "pandas-stubs", specifier = ">=2.2.3.250527" },
|
{ name = "pandas-stubs", specifier = ">=2.2.3.250527" },
|
||||||
|
{ name = "pyarrow", specifier = ">=19.0.0" },
|
||||||
{ name = "pymdown-extensions", specifier = ">=10.21.2" },
|
{ name = "pymdown-extensions", specifier = ">=10.21.2" },
|
||||||
{ name = "pyright", specifier = ">=1.1.407" },
|
{ name = "pyright", specifier = ">=1.1.407" },
|
||||||
{ name = "pytest", specifier = ">=9.0.3" },
|
{ name = "pytest", specifier = ">=9.0.3" },
|
||||||
|
|||||||
Reference in New Issue
Block a user