mt5cli 0.9.3__tar.gz → 0.9.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mt5cli-0.9.3 → mt5cli-0.9.5}/PKG-INFO +8 -3
- {mt5cli-0.9.3 → mt5cli-0.9.5}/README.md +7 -2
- {mt5cli-0.9.3 → mt5cli-0.9.5}/docs/api/public-contract.md +32 -29
- {mt5cli-0.9.3 → mt5cli-0.9.5}/mt5cli/__init__.py +4 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/mt5cli/contract.py +2 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/mt5cli/sdk.py +83 -6
- {mt5cli-0.9.3 → mt5cli-0.9.5}/mt5cli/trading.py +54 -8
- {mt5cli-0.9.3 → mt5cli-0.9.5}/pyproject.toml +1 -1
- {mt5cli-0.9.3 → mt5cli-0.9.5}/tests/test_contracts.py +2 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/tests/test_sdk.py +261 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/tests/test_trading.py +137 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/uv.lock +1 -1
- {mt5cli-0.9.3 → mt5cli-0.9.5}/.agents/skills/local-qa/SKILL.md +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/.agents/skills/local-qa/scripts/qa.sh +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/.agents/skills/mt5cli/SKILL.md +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/.agents/skills/pr-feedback-triage/SKILL.md +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/.claude/agents/codex.md +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/.claude/settings.json +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/.github/FUNDING.yml +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/.github/dependabot.yml +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/.github/renovate.json +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/.github/workflows/ci.yml +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/.github/workflows/claude.yml +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/.github/workflows/release.yml +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/.gitignore +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/AGENTS.md +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/CLAUDE.md +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/LICENSE +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/docs/api/cli.md +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/docs/api/client.md +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/docs/api/converters.md +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/docs/api/exceptions.md +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/docs/api/history.md +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/docs/api/index.md +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/docs/api/schemas.md +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/docs/api/sdk.md +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/docs/api/storage.md +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/docs/api/trading.md +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/docs/api/utils.md +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/docs/index.md +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/mkdocs.yml +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/mt5cli/__main__.py +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/mt5cli/cli.py +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/mt5cli/client.py +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/mt5cli/converters.py +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/mt5cli/exceptions.py +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/mt5cli/history.py +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/mt5cli/retry.py +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/mt5cli/schemas.py +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/mt5cli/storage.py +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/mt5cli/utils.py +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/tests/__init__.py +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/tests/conftest.py +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/tests/test_cli.py +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/tests/test_history.py +0 -0
- {mt5cli-0.9.3 → mt5cli-0.9.5}/tests/test_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mt5cli
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.5
|
|
4
4
|
Summary: Generic MT5 data and execution infrastructure for Python applications
|
|
5
5
|
Project-URL: Repository, https://github.com/dceoy/mt5cli.git
|
|
6
6
|
Author-email: dceoy <dceoy@users.noreply.github.com>
|
|
@@ -116,16 +116,21 @@ Schema contracts live in `mt5cli.schemas` (`DataKind`, `validate_schema`, `norma
|
|
|
116
116
|
Trading applications can depend on `mt5cli` imports only; terminal path,
|
|
117
117
|
credentials, server, and timeout are forwarded to `pdmt5.Mt5Config`, numeric
|
|
118
118
|
login strings are coerced to integers, and empty login strings are treated as
|
|
119
|
-
unset.
|
|
119
|
+
unset. Pass `allow_whole_dollar_env=True` to expand `${ENV_VAR}` and bare
|
|
120
|
+
`$ENV_NAME` placeholders in connection string parameters before coercion.
|
|
120
121
|
|
|
121
122
|
```python
|
|
122
123
|
from mt5cli import (
|
|
124
|
+
build_config,
|
|
123
125
|
calculate_spread_ratio,
|
|
124
126
|
create_trading_client,
|
|
125
127
|
get_account_snapshot,
|
|
126
128
|
mt5_trading_session,
|
|
127
129
|
)
|
|
128
130
|
|
|
131
|
+
# Login from environment — numeric string is coerced to int automatically
|
|
132
|
+
config = build_config(login="$MT5_LOGIN", allow_whole_dollar_env=True)
|
|
133
|
+
|
|
129
134
|
with mt5_trading_session(
|
|
130
135
|
path=r"C:\Program Files\MetaTrader 5\terminal64.exe",
|
|
131
136
|
login="12345",
|
|
@@ -272,7 +277,7 @@ rates = collect_latest_closed_rates_by_granularity(
|
|
|
272
277
|
eurusd_m1 = rates["EURUSD", "M1"] # closed bars only
|
|
273
278
|
```
|
|
274
279
|
|
|
275
|
-
- **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.
|
|
280
|
+
- **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.
|
|
276
281
|
- **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`.
|
|
277
282
|
- **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`.
|
|
278
283
|
- **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.
|
|
@@ -92,16 +92,21 @@ Schema contracts live in `mt5cli.schemas` (`DataKind`, `validate_schema`, `norma
|
|
|
92
92
|
Trading applications can depend on `mt5cli` imports only; terminal path,
|
|
93
93
|
credentials, server, and timeout are forwarded to `pdmt5.Mt5Config`, numeric
|
|
94
94
|
login strings are coerced to integers, and empty login strings are treated as
|
|
95
|
-
unset.
|
|
95
|
+
unset. Pass `allow_whole_dollar_env=True` to expand `${ENV_VAR}` and bare
|
|
96
|
+
`$ENV_NAME` placeholders in connection string parameters before coercion.
|
|
96
97
|
|
|
97
98
|
```python
|
|
98
99
|
from mt5cli import (
|
|
100
|
+
build_config,
|
|
99
101
|
calculate_spread_ratio,
|
|
100
102
|
create_trading_client,
|
|
101
103
|
get_account_snapshot,
|
|
102
104
|
mt5_trading_session,
|
|
103
105
|
)
|
|
104
106
|
|
|
107
|
+
# Login from environment — numeric string is coerced to int automatically
|
|
108
|
+
config = build_config(login="$MT5_LOGIN", allow_whole_dollar_env=True)
|
|
109
|
+
|
|
105
110
|
with mt5_trading_session(
|
|
106
111
|
path=r"C:\Program Files\MetaTrader 5\terminal64.exe",
|
|
107
112
|
login="12345",
|
|
@@ -248,7 +253,7 @@ rates = collect_latest_closed_rates_by_granularity(
|
|
|
248
253
|
eurusd_m1 = rates["EURUSD", "M1"] # closed bars only
|
|
249
254
|
```
|
|
250
255
|
|
|
251
|
-
- **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.
|
|
256
|
+
- **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.
|
|
252
257
|
- **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`.
|
|
253
258
|
- **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`.
|
|
254
259
|
- **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.
|
|
@@ -28,23 +28,25 @@ These names are exported from `mt5cli` and covered by the contract in
|
|
|
28
28
|
|
|
29
29
|
### Session lifecycle and configuration
|
|
30
30
|
|
|
31
|
-
| Symbol | Role
|
|
32
|
-
| ----------------------------------------------- |
|
|
33
|
-
| `MT5Client` | Read-only data client with optional `order_check` / `order_send`
|
|
34
|
-
| `build_config` | Build `pdmt5.Mt5Config` from connection fields
|
|
35
|
-
| `mt5_session` | Context manager: initialize, login, yield client, shutdown
|
|
36
|
-
| `create_trading_client`, `mt5_trading_session` | Trading-capable `pdmt5.Mt5TradingClient` lifecycle
|
|
37
|
-
| `AccountSpec` | Generic account group: symbols plus optional credentials
|
|
38
|
-
| `resolve_account_spec`, `resolve_account_specs` | Merge overrides and expand `${ENV_VAR}` placeholders; opt-in `allow_whole_dollar_env` for bare `$NAME`
|
|
39
|
-
| `substitute_env_placeholders` | Replace `${NAME}` substrings from the environment; opt-in `allow_whole_dollar_env` for whole-value `$NAME`
|
|
31
|
+
| Symbol | Role |
|
|
32
|
+
| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
33
|
+
| `MT5Client` | Read-only data client with optional `order_check` / `order_send` |
|
|
34
|
+
| `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` |
|
|
35
|
+
| `mt5_session` | Context manager: initialize, login, yield client, shutdown |
|
|
36
|
+
| `create_trading_client`, `mt5_trading_session` | Trading-capable `pdmt5.Mt5TradingClient` lifecycle |
|
|
37
|
+
| `AccountSpec` | Generic account group: symbols plus optional credentials |
|
|
38
|
+
| `resolve_account_spec`, `resolve_account_specs` | Merge overrides and expand `${ENV_VAR}` placeholders; opt-in `allow_whole_dollar_env` for bare `$NAME` |
|
|
39
|
+
| `substitute_env_placeholders` | Replace `${NAME}` substrings from the environment; opt-in `allow_whole_dollar_env` for whole-value `$NAME` |
|
|
40
|
+
| `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 |
|
|
40
41
|
|
|
41
42
|
Credential resolution is generic: any environment variable name may appear inside
|
|
42
43
|
`${...}`. mt5cli does not hard-code application-specific keys such as
|
|
43
44
|
`mt5_login` or `mt5_exe`.
|
|
44
45
|
|
|
45
46
|
Pass `allow_whole_dollar_env=True` to `substitute_env_placeholders()`,
|
|
46
|
-
`
|
|
47
|
-
additionally expand strings whose entire value is a bare
|
|
47
|
+
`substitute_mapping_values()`, `resolve_account_spec()`, `resolve_account_specs()`,
|
|
48
|
+
and `build_config()` to additionally expand strings whose entire value is a bare
|
|
49
|
+
`$ENV_NAME` identifier.
|
|
48
50
|
Partial strings such as `"plan$pass"`, `"abc$ENV"`, or `"$ENV-suffix"` are
|
|
49
51
|
**never** expanded — only an exact `$IDENTIFIER` whole-string match qualifies.
|
|
50
52
|
Default is `False` to preserve backward compatibility.
|
|
@@ -90,24 +92,25 @@ diagrams.
|
|
|
90
92
|
These helpers implement broker-facing calculations only. They do not encode
|
|
91
93
|
strategy entries, exits, Kelly sizing, or signal logic.
|
|
92
94
|
|
|
93
|
-
| Symbol | Role
|
|
94
|
-
| ------------------------------------------------------------------------------------------------------------------------------ |
|
|
95
|
-
| `get_account_snapshot`, `get_symbol_snapshot`, `get_tick_snapshot`, `get_positions_frame` | Normalized account/symbol/tick/position views
|
|
96
|
-
| `extract_tick_price` | Positive finite bid/ask extraction from tick mappings
|
|
97
|
-
| `detect_position_side` | Net long / short / flat from open positions
|
|
98
|
-
| `calculate_spread_ratio` | Relative bid-ask spread
|
|
99
|
-
| `calculate_margin_and_volume`, `calculate_volume_by_margin`, `calculate_new_position_margin_ratio` | Margin budget and volume sizing
|
|
100
|
-
| `normalize_order_volume`, `estimate_order_margin`, `calculate_positions_margin` | Broker volume normalization and margin totals
|
|
101
|
-
| `calculate_positions_margin_by_symbol` | Per-symbol margin map (resilient, first-seen order)
|
|
102
|
-
| `calculate_positions_margin_safe` | Summed total margin across symbols (failed symbols skipped)
|
|
103
|
-
| `calculate_projected_margin_ratio` | Estimated symbol margin/equity after optional new exposure
|
|
104
|
-
| `
|
|
105
|
-
| `
|
|
106
|
-
| `
|
|
107
|
-
| `
|
|
108
|
-
| `
|
|
109
|
-
| `
|
|
110
|
-
| `
|
|
95
|
+
| Symbol | Role |
|
|
96
|
+
| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- |
|
|
97
|
+
| `get_account_snapshot`, `get_symbol_snapshot`, `get_tick_snapshot`, `get_positions_frame` | Normalized account/symbol/tick/position views |
|
|
98
|
+
| `extract_tick_price` | Positive finite bid/ask extraction from tick mappings |
|
|
99
|
+
| `detect_position_side` | Net long / short / flat from open positions |
|
|
100
|
+
| `calculate_spread_ratio` | Relative bid-ask spread |
|
|
101
|
+
| `calculate_margin_and_volume`, `calculate_volume_by_margin`, `calculate_new_position_margin_ratio` | Margin budget and volume sizing |
|
|
102
|
+
| `normalize_order_volume`, `estimate_order_margin`, `calculate_positions_margin` | Broker volume normalization and margin totals |
|
|
103
|
+
| `calculate_positions_margin_by_symbol` | Per-symbol margin map (resilient, first-seen order) |
|
|
104
|
+
| `calculate_positions_margin_safe` | Summed total margin across symbols (failed symbols skipped) |
|
|
105
|
+
| `calculate_projected_margin_ratio` | Estimated symbol-scoped margin/equity after optional new exposure |
|
|
106
|
+
| `calculate_account_projected_margin_ratio` | Account snapshot margin/equity after optional new exposure |
|
|
107
|
+
| `calculate_symbol_group_margin_ratio` | Estimated symbol-group margin/equity with optional exposure |
|
|
108
|
+
| `determine_order_limits` | SL/TP price levels from ratios |
|
|
109
|
+
| `calculate_trailing_stop_updates` | Per-ticket generic trailing stop-loss update plan |
|
|
110
|
+
| `ensure_symbol_selected` | Select/verify Market Watch visibility |
|
|
111
|
+
| `place_market_order`, `close_open_positions`, `update_sltp_for_open_positions`, `update_trailing_stop_loss_for_open_positions` | Order execution helpers (`dry_run` supported) |
|
|
112
|
+
| `MarginVolume`, `OrderLimits`, `OrderExecutionResult` | Typed return contracts for order helpers |
|
|
113
|
+
| `OrderSide`, `OrderFillingMode`, `OrderTimeMode`, `PositionSide`, `ExecutionStatus` | Typed enums for order helpers |
|
|
111
114
|
|
|
112
115
|
`MT5Client.order_send()` and CLI `order-send --yes` are live execution paths.
|
|
113
116
|
|
|
@@ -92,6 +92,7 @@ from .sdk import (
|
|
|
92
92
|
resolve_account_spec,
|
|
93
93
|
resolve_account_specs,
|
|
94
94
|
substitute_env_placeholders,
|
|
95
|
+
substitute_mapping_values,
|
|
95
96
|
symbol_info,
|
|
96
97
|
symbol_info_tick,
|
|
97
98
|
symbols,
|
|
@@ -119,6 +120,7 @@ from .trading import (
|
|
|
119
120
|
OrderSide,
|
|
120
121
|
OrderTimeMode,
|
|
121
122
|
PositionSide,
|
|
123
|
+
calculate_account_projected_margin_ratio,
|
|
122
124
|
calculate_margin_and_volume,
|
|
123
125
|
calculate_new_position_margin_ratio,
|
|
124
126
|
calculate_positions_margin,
|
|
@@ -196,6 +198,7 @@ __all__ = [
|
|
|
196
198
|
"build_config",
|
|
197
199
|
"build_rate_targets",
|
|
198
200
|
"build_rate_view_name",
|
|
201
|
+
"calculate_account_projected_margin_ratio",
|
|
199
202
|
"calculate_margin_and_volume",
|
|
200
203
|
"calculate_new_position_margin_ratio",
|
|
201
204
|
"calculate_positions_margin",
|
|
@@ -281,6 +284,7 @@ __all__ = [
|
|
|
281
284
|
"resolve_rate_view_names",
|
|
282
285
|
"schema_columns",
|
|
283
286
|
"substitute_env_placeholders",
|
|
287
|
+
"substitute_mapping_values",
|
|
284
288
|
"symbol_info",
|
|
285
289
|
"symbol_info_tick",
|
|
286
290
|
"symbols",
|
|
@@ -26,6 +26,7 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
|
|
26
26
|
"build_config",
|
|
27
27
|
"build_rate_targets",
|
|
28
28
|
"build_rate_view_name",
|
|
29
|
+
"calculate_account_projected_margin_ratio",
|
|
29
30
|
"calculate_margin_and_volume",
|
|
30
31
|
"calculate_new_position_margin_ratio",
|
|
31
32
|
"calculate_projected_margin_ratio",
|
|
@@ -76,6 +77,7 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
|
|
|
76
77
|
"resolve_rate_view_name",
|
|
77
78
|
"resolve_rate_view_names",
|
|
78
79
|
"substitute_env_placeholders",
|
|
80
|
+
"substitute_mapping_values",
|
|
79
81
|
"update_history",
|
|
80
82
|
"update_history_with_config",
|
|
81
83
|
"update_sltp_for_open_positions",
|
|
@@ -40,7 +40,7 @@ from .utils import (
|
|
|
40
40
|
from .utils import coerce_login as _coerce_login
|
|
41
41
|
|
|
42
42
|
if TYPE_CHECKING:
|
|
43
|
-
from collections.abc import Callable, Iterator, Sequence
|
|
43
|
+
from collections.abc import Callable, Collection, Iterator, Sequence
|
|
44
44
|
|
|
45
45
|
UpdateHistoryBackend = Callable[..., None]
|
|
46
46
|
|
|
@@ -142,6 +142,7 @@ __all__ = [
|
|
|
142
142
|
"resolve_account_spec",
|
|
143
143
|
"resolve_account_specs",
|
|
144
144
|
"substitute_env_placeholders",
|
|
145
|
+
"substitute_mapping_values",
|
|
145
146
|
"symbol_info",
|
|
146
147
|
"symbol_info_tick",
|
|
147
148
|
"symbols",
|
|
@@ -305,7 +306,7 @@ def _fetch_minimum_margins(client: Mt5DataClient, symbol: str) -> pd.DataFrame:
|
|
|
305
306
|
def build_config(
|
|
306
307
|
*,
|
|
307
308
|
path: str | None = None,
|
|
308
|
-
login: int | None = None,
|
|
309
|
+
login: int | str | None = None,
|
|
309
310
|
password: str | None = None,
|
|
310
311
|
server: str | None = None,
|
|
311
312
|
timeout: int | None = None,
|
|
@@ -315,14 +316,19 @@ def build_config(
|
|
|
315
316
|
|
|
316
317
|
Args:
|
|
317
318
|
path: Optional terminal executable path.
|
|
318
|
-
login: Optional trading account login.
|
|
319
|
+
login: Optional trading account login. Integers are preserved. String
|
|
320
|
+
values are coerced: empty or whitespace-only strings become
|
|
321
|
+
``None``; numeric strings such as ``"12345"`` are converted to
|
|
322
|
+
``int``; non-numeric strings raise ``ValueError``. When
|
|
323
|
+
``allow_whole_dollar_env=True``, ``$ENV_NAME`` and
|
|
324
|
+
``${ENV_NAME}`` placeholders are expanded before coercion.
|
|
319
325
|
password: Optional trading account password.
|
|
320
326
|
server: Optional trading server name.
|
|
321
327
|
timeout: Optional connection timeout in milliseconds.
|
|
322
328
|
allow_whole_dollar_env: When ``True``, string parameters that are
|
|
323
329
|
exactly ``$ENV_NAME`` are expanded from the environment. Applies
|
|
324
|
-
to ``path``, ``password``, and ``server``. Default
|
|
325
|
-
preserves existing behavior.
|
|
330
|
+
to ``path``, ``login``, ``password``, and ``server``. Default
|
|
331
|
+
``False`` preserves existing behavior.
|
|
326
332
|
|
|
327
333
|
Returns:
|
|
328
334
|
Configured ``Mt5Config`` instance.
|
|
@@ -330,6 +336,8 @@ def build_config(
|
|
|
330
336
|
if allow_whole_dollar_env:
|
|
331
337
|
if path is not None:
|
|
332
338
|
path = substitute_env_placeholders(path, allow_whole_dollar_env=True)
|
|
339
|
+
if isinstance(login, str):
|
|
340
|
+
login = substitute_env_placeholders(login, allow_whole_dollar_env=True)
|
|
333
341
|
if password is not None:
|
|
334
342
|
password = substitute_env_placeholders(
|
|
335
343
|
password, allow_whole_dollar_env=True
|
|
@@ -338,7 +346,7 @@ def build_config(
|
|
|
338
346
|
server = substitute_env_placeholders(server, allow_whole_dollar_env=True)
|
|
339
347
|
return Mt5Config(
|
|
340
348
|
path=path,
|
|
341
|
-
login=login,
|
|
349
|
+
login=_coerce_login(login),
|
|
342
350
|
password=password,
|
|
343
351
|
server=server,
|
|
344
352
|
timeout=timeout,
|
|
@@ -1442,6 +1450,75 @@ def substitute_env_placeholders(
|
|
|
1442
1450
|
return "".join(parts)
|
|
1443
1451
|
|
|
1444
1452
|
|
|
1453
|
+
def substitute_mapping_values(
|
|
1454
|
+
data: object,
|
|
1455
|
+
*,
|
|
1456
|
+
keys: Collection[str],
|
|
1457
|
+
allow_whole_dollar_env: bool = False,
|
|
1458
|
+
blank_string_keys_as_none: Collection[str] = (),
|
|
1459
|
+
) -> object:
|
|
1460
|
+
"""Recursively substitute environment placeholders for selected mapping keys.
|
|
1461
|
+
|
|
1462
|
+
Traverses nested dicts and lists, expanding ``${ENV_VAR}`` (and
|
|
1463
|
+
``$ENV_NAME`` when ``allow_whole_dollar_env=True``) in string values
|
|
1464
|
+
whose immediate parent dict key is in ``keys``. Fields whose key is
|
|
1465
|
+
not in ``keys`` are preserved exactly, including literal dollar signs.
|
|
1466
|
+
Strings that are direct elements of a list are never substituted;
|
|
1467
|
+
substitution only applies to strings that are immediate dict values.
|
|
1468
|
+
|
|
1469
|
+
This is a generic downstream config utility. Key names such as
|
|
1470
|
+
``mt5_login`` or ``mt5_password`` must be supplied by the caller;
|
|
1471
|
+
mt5cli does not hard-code any application-specific key names.
|
|
1472
|
+
Callers are responsible for ensuring ``data`` has bounded nesting depth;
|
|
1473
|
+
deeply nested or self-referential structures will hit Python's recursion
|
|
1474
|
+
limit.
|
|
1475
|
+
|
|
1476
|
+
Args:
|
|
1477
|
+
data: Arbitrarily nested dict/list/scalar value to process.
|
|
1478
|
+
keys: Mapping keys whose string values receive placeholder
|
|
1479
|
+
substitution.
|
|
1480
|
+
allow_whole_dollar_env: When ``True``, a string that is exactly
|
|
1481
|
+
``$ENV_NAME`` (whole value) is also expanded from the
|
|
1482
|
+
environment in addition to ``${ENV_NAME}`` placeholders.
|
|
1483
|
+
Default ``False`` expands ``${ENV_NAME}`` only.
|
|
1484
|
+
blank_string_keys_as_none: Mapping keys for which blank strings
|
|
1485
|
+
(after any substitution) are normalised to ``None``. A key
|
|
1486
|
+
may appear in ``blank_string_keys_as_none`` without also
|
|
1487
|
+
appearing in ``keys``.
|
|
1488
|
+
|
|
1489
|
+
Returns:
|
|
1490
|
+
The processed value. Dicts and lists are rebuilt into new
|
|
1491
|
+
containers with selected string values substituted and
|
|
1492
|
+
blank-normalised. Scalar inputs (non-dict, non-list) are
|
|
1493
|
+
returned as-is.
|
|
1494
|
+
"""
|
|
1495
|
+
keys_set: frozenset[str] = frozenset(keys)
|
|
1496
|
+
blank_keys_set: frozenset[str] = frozenset(blank_string_keys_as_none)
|
|
1497
|
+
|
|
1498
|
+
def _visit(node: object, current_key: str | None) -> object:
|
|
1499
|
+
if isinstance(node, dict):
|
|
1500
|
+
typed = cast("dict[object, object]", node)
|
|
1501
|
+
return {
|
|
1502
|
+
k: _visit(v, k if isinstance(k, str) else None)
|
|
1503
|
+
for k, v in typed.items()
|
|
1504
|
+
}
|
|
1505
|
+
if isinstance(node, list):
|
|
1506
|
+
typed_list = cast("list[object]", node)
|
|
1507
|
+
return [_visit(item, None) for item in typed_list]
|
|
1508
|
+
if not isinstance(node, str):
|
|
1509
|
+
return node
|
|
1510
|
+
text = node
|
|
1511
|
+
if current_key in keys_set:
|
|
1512
|
+
text = substitute_env_placeholders(
|
|
1513
|
+
node, allow_whole_dollar_env=allow_whole_dollar_env
|
|
1514
|
+
)
|
|
1515
|
+
if current_key in blank_keys_set and not text.strip():
|
|
1516
|
+
return None
|
|
1517
|
+
return text
|
|
1518
|
+
|
|
1519
|
+
return _visit(data, None)
|
|
1520
|
+
|
|
1521
|
+
|
|
1445
1522
|
def _resolve_field(
|
|
1446
1523
|
override: str | None,
|
|
1447
1524
|
account_value: str | None,
|
|
@@ -127,6 +127,7 @@ __all__ = [
|
|
|
127
127
|
"OrderSide",
|
|
128
128
|
"OrderTimeMode",
|
|
129
129
|
"PositionSide",
|
|
130
|
+
"calculate_account_projected_margin_ratio",
|
|
130
131
|
"calculate_margin_and_volume",
|
|
131
132
|
"calculate_new_position_margin_ratio",
|
|
132
133
|
"calculate_positions_margin",
|
|
@@ -834,15 +835,60 @@ def calculate_new_position_margin_ratio(
|
|
|
834
835
|
|
|
835
836
|
def _account_equity(client: Mt5TradingClient) -> float:
|
|
836
837
|
account = get_account_snapshot(client)
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
838
|
+
return _required_account_number(account, "equity", allow_zero=False)
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def _required_account_number(
|
|
842
|
+
account: Mapping[str, object],
|
|
843
|
+
field: str,
|
|
844
|
+
*,
|
|
845
|
+
allow_zero: bool,
|
|
846
|
+
) -> float:
|
|
847
|
+
raw_value = account.get(field)
|
|
848
|
+
if isinstance(raw_value, bool) or not isinstance(raw_value, Real):
|
|
849
|
+
msg = f"Account {field} must be a finite number to calculate margin ratio."
|
|
844
850
|
raise Mt5TradingError(msg)
|
|
845
|
-
|
|
851
|
+
value = float(raw_value)
|
|
852
|
+
if (
|
|
853
|
+
not isfinite(value)
|
|
854
|
+
or (not allow_zero and value <= 0)
|
|
855
|
+
or (allow_zero and value < 0)
|
|
856
|
+
):
|
|
857
|
+
msg = (
|
|
858
|
+
f"Account {field} must be a non-negative finite number."
|
|
859
|
+
if allow_zero
|
|
860
|
+
else f"Account {field} must be a positive finite number."
|
|
861
|
+
)
|
|
862
|
+
raise Mt5TradingError(msg)
|
|
863
|
+
return value
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def calculate_account_projected_margin_ratio(
|
|
867
|
+
client: Mt5TradingClient,
|
|
868
|
+
*,
|
|
869
|
+
symbol: str | None = None,
|
|
870
|
+
new_position_side: OrderSide | None = None,
|
|
871
|
+
new_position_volume: float = 0.0,
|
|
872
|
+
) -> float:
|
|
873
|
+
"""Return account-wide current plus optional new-position margin over equity.
|
|
874
|
+
|
|
875
|
+
Current exposure comes from the broker account snapshot ``margin`` field so
|
|
876
|
+
unrelated open positions remain in the baseline. Optional projected
|
|
877
|
+
exposure is added via :func:`estimate_order_margin` only when a symbol, side,
|
|
878
|
+
and positive volume are all supplied.
|
|
879
|
+
|
|
880
|
+
"""
|
|
881
|
+
account = get_account_snapshot(client)
|
|
882
|
+
equity = _required_account_number(account, "equity", allow_zero=False)
|
|
883
|
+
margin = _required_account_number(account, "margin", allow_zero=True)
|
|
884
|
+
if symbol is not None and new_position_side is not None and new_position_volume > 0:
|
|
885
|
+
margin += estimate_order_margin(
|
|
886
|
+
client,
|
|
887
|
+
symbol,
|
|
888
|
+
new_position_side,
|
|
889
|
+
new_position_volume,
|
|
890
|
+
)
|
|
891
|
+
return margin / equity
|
|
846
892
|
|
|
847
893
|
|
|
848
894
|
def calculate_projected_margin_ratio(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "mt5cli"
|
|
3
|
-
version = "0.9.
|
|
3
|
+
version = "0.9.5"
|
|
4
4
|
description = "Generic MT5 data and execution infrastructure for Python applications"
|
|
5
5
|
authors = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
|
6
6
|
maintainers = [{name = "dceoy", email = "dceoy@users.noreply.github.com"}]
|
|
@@ -37,6 +37,7 @@ from mt5cli import (
|
|
|
37
37
|
RateTarget,
|
|
38
38
|
build_config,
|
|
39
39
|
build_rate_targets,
|
|
40
|
+
calculate_account_projected_margin_ratio,
|
|
40
41
|
calculate_margin_and_volume,
|
|
41
42
|
calculate_positions_margin,
|
|
42
43
|
calculate_projected_margin_ratio,
|
|
@@ -684,6 +685,7 @@ class TestStableSdkContract:
|
|
|
684
685
|
assert price is not None
|
|
685
686
|
assert abs(price - 1.2) < 1e-9
|
|
686
687
|
assert callable(calculate_trailing_stop_updates)
|
|
688
|
+
assert callable(calculate_account_projected_margin_ratio)
|
|
687
689
|
assert callable(calculate_projected_margin_ratio)
|
|
688
690
|
assert callable(calculate_symbol_group_margin_ratio)
|
|
689
691
|
|
|
@@ -54,6 +54,7 @@ from mt5cli.sdk import (
|
|
|
54
54
|
resolve_account_spec,
|
|
55
55
|
resolve_account_specs,
|
|
56
56
|
substitute_env_placeholders,
|
|
57
|
+
substitute_mapping_values,
|
|
57
58
|
symbol_info,
|
|
58
59
|
symbol_info_tick,
|
|
59
60
|
symbols,
|
|
@@ -2436,3 +2437,263 @@ class TestThrottledHistoryUpdater:
|
|
|
2436
2437
|
updater.update(MagicMock(), ["EURUSD"])
|
|
2437
2438
|
|
|
2438
2439
|
assert updater.last_update_monotonic is None
|
|
2440
|
+
|
|
2441
|
+
|
|
2442
|
+
class TestBuildConfigStringLogin:
|
|
2443
|
+
"""Tests for build_config() string login coercion (issue #61)."""
|
|
2444
|
+
|
|
2445
|
+
@pytest.mark.parametrize(
|
|
2446
|
+
("login", "expected"),
|
|
2447
|
+
[
|
|
2448
|
+
(None, None),
|
|
2449
|
+
(12345, 12345),
|
|
2450
|
+
("12345", 12345),
|
|
2451
|
+
(" 12345 ", 12345),
|
|
2452
|
+
("", None),
|
|
2453
|
+
(" ", None),
|
|
2454
|
+
],
|
|
2455
|
+
)
|
|
2456
|
+
def test_coerces_login_from_string(
|
|
2457
|
+
self,
|
|
2458
|
+
login: int | str | None,
|
|
2459
|
+
expected: int | None,
|
|
2460
|
+
) -> None:
|
|
2461
|
+
"""Test build_config coerces string login to int or None."""
|
|
2462
|
+
config = build_config(login=login)
|
|
2463
|
+
assert config.login == expected
|
|
2464
|
+
|
|
2465
|
+
def test_rejects_non_numeric_string_login(self) -> None:
|
|
2466
|
+
"""Test build_config raises ValueError for non-numeric string login."""
|
|
2467
|
+
with pytest.raises(ValueError, match="invalid literal"):
|
|
2468
|
+
build_config(login="abc")
|
|
2469
|
+
|
|
2470
|
+
def test_expands_dollar_brace_login_with_opt_in(
|
|
2471
|
+
self,
|
|
2472
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
2473
|
+
) -> None:
|
|
2474
|
+
"""Test build_config expands ${MT5_LOGIN} and coerces with opt-in."""
|
|
2475
|
+
monkeypatch.setenv("MT5_LOGIN", "12345")
|
|
2476
|
+
config = build_config(login="${MT5_LOGIN}", allow_whole_dollar_env=True)
|
|
2477
|
+
assert config.login == 12345
|
|
2478
|
+
|
|
2479
|
+
def test_expands_whole_dollar_login_with_opt_in(
|
|
2480
|
+
self,
|
|
2481
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
2482
|
+
) -> None:
|
|
2483
|
+
"""Test build_config expands $MT5_LOGIN and coerces with opt-in."""
|
|
2484
|
+
monkeypatch.setenv("MT5_LOGIN", "99999")
|
|
2485
|
+
config = build_config(login="$MT5_LOGIN", allow_whole_dollar_env=True)
|
|
2486
|
+
assert config.login == 99999
|
|
2487
|
+
|
|
2488
|
+
def test_missing_env_variable_raises(
|
|
2489
|
+
self,
|
|
2490
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
2491
|
+
) -> None:
|
|
2492
|
+
"""Test build_config raises ValueError when referenced env var is not set."""
|
|
2493
|
+
monkeypatch.delenv("MT5_LOGIN", raising=False)
|
|
2494
|
+
with pytest.raises(ValueError, match="'MT5_LOGIN' is not set"):
|
|
2495
|
+
build_config(login="${MT5_LOGIN}", allow_whole_dollar_env=True)
|
|
2496
|
+
|
|
2497
|
+
def test_env_expands_to_blank_becomes_none(
|
|
2498
|
+
self,
|
|
2499
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
2500
|
+
) -> None:
|
|
2501
|
+
"""Test build_config coerces blank env-expanded login to None."""
|
|
2502
|
+
monkeypatch.setenv("MT5_LOGIN", "")
|
|
2503
|
+
config = build_config(login="${MT5_LOGIN}", allow_whole_dollar_env=True)
|
|
2504
|
+
assert config.login is None
|
|
2505
|
+
|
|
2506
|
+
def test_dollar_brace_login_not_expanded_without_opt_in(self) -> None:
|
|
2507
|
+
"""Test ${MT5_LOGIN} is not expanded when allow_whole_dollar_env=False."""
|
|
2508
|
+
with pytest.raises(ValueError, match="invalid literal"):
|
|
2509
|
+
build_config(login="${MT5_LOGIN}")
|
|
2510
|
+
|
|
2511
|
+
def test_integer_login_preserved_backward_compat(self) -> None:
|
|
2512
|
+
"""Test existing int login callers remain backward-compatible."""
|
|
2513
|
+
config = build_config(login=54321)
|
|
2514
|
+
assert config.login == 54321
|
|
2515
|
+
|
|
2516
|
+
def test_none_login_preserved_backward_compat(self) -> None:
|
|
2517
|
+
"""Test existing None login callers remain backward-compatible."""
|
|
2518
|
+
config = build_config(login=None)
|
|
2519
|
+
assert config.login is None
|
|
2520
|
+
|
|
2521
|
+
|
|
2522
|
+
class TestSubstituteMappingValues:
|
|
2523
|
+
"""Tests for substitute_mapping_values() (issue #62)."""
|
|
2524
|
+
|
|
2525
|
+
def test_substitutes_selected_keys_in_flat_dict(
|
|
2526
|
+
self,
|
|
2527
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
2528
|
+
) -> None:
|
|
2529
|
+
"""Test selected keys are substituted in a flat mapping."""
|
|
2530
|
+
monkeypatch.setenv("MT5_LOGIN", "12345")
|
|
2531
|
+
data: dict[str, object] = {
|
|
2532
|
+
"mt5_login": "${MT5_LOGIN}",
|
|
2533
|
+
"strategy_name": "${MT5_LOGIN}",
|
|
2534
|
+
}
|
|
2535
|
+
result = substitute_mapping_values(data, keys={"mt5_login"})
|
|
2536
|
+
assert result == {"mt5_login": "12345", "strategy_name": "${MT5_LOGIN}"}
|
|
2537
|
+
|
|
2538
|
+
def test_preserves_non_selected_literal_dollar_signs(
|
|
2539
|
+
self,
|
|
2540
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
2541
|
+
) -> None:
|
|
2542
|
+
"""Test literal dollar signs in non-selected fields are preserved exactly."""
|
|
2543
|
+
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
|
2544
|
+
data: dict[str, object] = {
|
|
2545
|
+
"mt5_password": "${MT5_PASSWORD}",
|
|
2546
|
+
"notes": "$NOT_EXPANDED",
|
|
2547
|
+
}
|
|
2548
|
+
result = substitute_mapping_values(data, keys={"mt5_password"})
|
|
2549
|
+
assert result == {"mt5_password": "secret", "notes": "$NOT_EXPANDED"}
|
|
2550
|
+
|
|
2551
|
+
def test_nested_dict_traversal_substitutes_selected_keys(
|
|
2552
|
+
self,
|
|
2553
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
2554
|
+
) -> None:
|
|
2555
|
+
"""Test selected keys inside nested dicts are substituted."""
|
|
2556
|
+
monkeypatch.setenv("MT5_SERVER", "Broker-Demo")
|
|
2557
|
+
data: dict[str, object] = {
|
|
2558
|
+
"outer": {
|
|
2559
|
+
"mt5_server": "${MT5_SERVER}",
|
|
2560
|
+
"other": "${MT5_SERVER}",
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
result = substitute_mapping_values(data, keys={"mt5_server"})
|
|
2564
|
+
assert result == {
|
|
2565
|
+
"outer": {"mt5_server": "Broker-Demo", "other": "${MT5_SERVER}"}
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
def test_nested_list_traversal_substitutes_selected_keys(
|
|
2569
|
+
self,
|
|
2570
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
2571
|
+
) -> None:
|
|
2572
|
+
"""Test selected keys inside list elements are substituted."""
|
|
2573
|
+
monkeypatch.setenv("MT5_LOGIN", "42")
|
|
2574
|
+
data: dict[str, object] = {
|
|
2575
|
+
"accounts": [
|
|
2576
|
+
{"mt5_login": "${MT5_LOGIN}", "name": "${MT5_LOGIN}"},
|
|
2577
|
+
{"mt5_login": "${MT5_LOGIN}", "name": "fixed"},
|
|
2578
|
+
]
|
|
2579
|
+
}
|
|
2580
|
+
result = substitute_mapping_values(data, keys={"mt5_login"})
|
|
2581
|
+
assert result == {
|
|
2582
|
+
"accounts": [
|
|
2583
|
+
{"mt5_login": "42", "name": "${MT5_LOGIN}"},
|
|
2584
|
+
{"mt5_login": "42", "name": "fixed"},
|
|
2585
|
+
]
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
def test_whole_dollar_expanded_with_opt_in(
|
|
2589
|
+
self,
|
|
2590
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
2591
|
+
) -> None:
|
|
2592
|
+
"""Test $ENV_NAME is expanded when allow_whole_dollar_env=True."""
|
|
2593
|
+
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
|
2594
|
+
data: dict[str, object] = {"mt5_password": "$MT5_PASSWORD"}
|
|
2595
|
+
result = substitute_mapping_values(
|
|
2596
|
+
data,
|
|
2597
|
+
keys={"mt5_password"},
|
|
2598
|
+
allow_whole_dollar_env=True,
|
|
2599
|
+
)
|
|
2600
|
+
assert result == {"mt5_password": "secret"}
|
|
2601
|
+
|
|
2602
|
+
def test_whole_dollar_not_expanded_by_default(
|
|
2603
|
+
self,
|
|
2604
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
2605
|
+
) -> None:
|
|
2606
|
+
"""Test $ENV_NAME in a selected key is preserved when opt-in is False."""
|
|
2607
|
+
monkeypatch.setenv("MT5_PASSWORD", "secret")
|
|
2608
|
+
data: dict[str, object] = {"mt5_password": "$MT5_PASSWORD"}
|
|
2609
|
+
result = substitute_mapping_values(data, keys={"mt5_password"})
|
|
2610
|
+
assert result == {"mt5_password": "$MT5_PASSWORD"}
|
|
2611
|
+
|
|
2612
|
+
def test_blank_string_becomes_none_for_blank_keys(self) -> None:
|
|
2613
|
+
"""Test blank strings are normalised to None for blank_string_keys_as_none."""
|
|
2614
|
+
data: dict[str, object] = {
|
|
2615
|
+
"mt5_login": "",
|
|
2616
|
+
"mt5_password": " ",
|
|
2617
|
+
"other": "",
|
|
2618
|
+
}
|
|
2619
|
+
result = substitute_mapping_values(
|
|
2620
|
+
data,
|
|
2621
|
+
keys=set(),
|
|
2622
|
+
blank_string_keys_as_none={"mt5_login", "mt5_password"},
|
|
2623
|
+
)
|
|
2624
|
+
assert result == {"mt5_login": None, "mt5_password": None, "other": ""}
|
|
2625
|
+
|
|
2626
|
+
def test_env_expanded_blank_becomes_none(
|
|
2627
|
+
self,
|
|
2628
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
2629
|
+
) -> None:
|
|
2630
|
+
"""Test env-expanded blank string is normalised to None."""
|
|
2631
|
+
monkeypatch.setenv("MT5_LOGIN", "")
|
|
2632
|
+
data: dict[str, object] = {"mt5_login": "${MT5_LOGIN}"}
|
|
2633
|
+
result = substitute_mapping_values(
|
|
2634
|
+
data,
|
|
2635
|
+
keys={"mt5_login"},
|
|
2636
|
+
blank_string_keys_as_none={"mt5_login"},
|
|
2637
|
+
)
|
|
2638
|
+
assert result == {"mt5_login": None}
|
|
2639
|
+
|
|
2640
|
+
def test_missing_env_variable_raises_for_selected_key(
|
|
2641
|
+
self,
|
|
2642
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
2643
|
+
) -> None:
|
|
2644
|
+
"""Test missing env var for a selected key raises ValueError."""
|
|
2645
|
+
monkeypatch.delenv("MT5_MISSING", raising=False)
|
|
2646
|
+
data: dict[str, object] = {"mt5_login": "${MT5_MISSING}"}
|
|
2647
|
+
with pytest.raises(ValueError, match="'MT5_MISSING' is not set"):
|
|
2648
|
+
substitute_mapping_values(data, keys={"mt5_login"})
|
|
2649
|
+
|
|
2650
|
+
def test_non_string_values_preserved(self) -> None:
|
|
2651
|
+
"""Test non-string values under selected or non-selected keys are preserved."""
|
|
2652
|
+
data: dict[str, object] = {
|
|
2653
|
+
"mt5_login": 12345,
|
|
2654
|
+
"timeout": 5000,
|
|
2655
|
+
"enabled": True,
|
|
2656
|
+
"ratio": 1.5,
|
|
2657
|
+
"nothing": None,
|
|
2658
|
+
}
|
|
2659
|
+
result = substitute_mapping_values(
|
|
2660
|
+
data, keys={"mt5_login", "timeout", "enabled", "ratio", "nothing"}
|
|
2661
|
+
)
|
|
2662
|
+
assert result == data
|
|
2663
|
+
|
|
2664
|
+
def test_caller_supplied_key_set_substitutes_correctly(
|
|
2665
|
+
self,
|
|
2666
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
2667
|
+
) -> None:
|
|
2668
|
+
"""Test helper works with any caller-supplied key set."""
|
|
2669
|
+
monkeypatch.setenv("APP_LOGIN", "77777")
|
|
2670
|
+
monkeypatch.setenv("APP_PASSWORD", "p4ss")
|
|
2671
|
+
data: dict[str, object] = {
|
|
2672
|
+
"app_login": "${APP_LOGIN}",
|
|
2673
|
+
"app_password": "${APP_PASSWORD}",
|
|
2674
|
+
"unrelated": "${APP_LOGIN}",
|
|
2675
|
+
}
|
|
2676
|
+
credential_keys = {"app_login", "app_password"}
|
|
2677
|
+
result = substitute_mapping_values(data, keys=credential_keys)
|
|
2678
|
+
assert result == {
|
|
2679
|
+
"app_login": "77777",
|
|
2680
|
+
"app_password": "p4ss",
|
|
2681
|
+
"unrelated": "${APP_LOGIN}",
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
def test_scalar_data_returned_unchanged(self) -> None:
|
|
2685
|
+
"""Test a scalar (non-dict, non-list) value is returned as-is."""
|
|
2686
|
+
assert substitute_mapping_values("hello", keys={"x"}) == "hello"
|
|
2687
|
+
assert substitute_mapping_values(42, keys={"x"}) == 42
|
|
2688
|
+
assert substitute_mapping_values(None, keys={"x"}) is None
|
|
2689
|
+
|
|
2690
|
+
def test_tuple_container_not_traversed(
|
|
2691
|
+
self,
|
|
2692
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
2693
|
+
) -> None:
|
|
2694
|
+
"""Test tuple containers are returned as-is without traversal."""
|
|
2695
|
+
monkeypatch.setenv("MT5_LOGIN", "42")
|
|
2696
|
+
data: dict[str, object] = {"accounts": ({"mt5_login": "${MT5_LOGIN}"},)}
|
|
2697
|
+
result = substitute_mapping_values(data, keys={"mt5_login"})
|
|
2698
|
+
# tuple is returned as-is; inner dict is NOT visited
|
|
2699
|
+
assert result == {"accounts": ({"mt5_login": "${MT5_LOGIN}"},)}
|
|
@@ -19,6 +19,7 @@ from mt5cli.trading import (
|
|
|
19
19
|
MarginVolume,
|
|
20
20
|
OrderExecutionResult,
|
|
21
21
|
OrderLimits,
|
|
22
|
+
calculate_account_projected_margin_ratio,
|
|
22
23
|
calculate_margin_and_volume,
|
|
23
24
|
calculate_new_position_margin_ratio,
|
|
24
25
|
calculate_positions_margin,
|
|
@@ -1891,6 +1892,142 @@ class TestVolumeAndExecution:
|
|
|
1891
1892
|
_assert_close(result, 0.024)
|
|
1892
1893
|
client.order_calc_margin.assert_called_once_with(11, "EURUSD", 0.1, 1.1)
|
|
1893
1894
|
|
|
1895
|
+
@pytest.mark.parametrize(
|
|
1896
|
+
("account", "kwargs", "candidate_margin", "expected_ratio"),
|
|
1897
|
+
[
|
|
1898
|
+
({"equity": 10_000.0, "margin": 4500.0}, {}, None, 0.45),
|
|
1899
|
+
(
|
|
1900
|
+
{"equity": 10_000.0, "margin": 4500.0},
|
|
1901
|
+
{
|
|
1902
|
+
"symbol": "EURUSD",
|
|
1903
|
+
"new_position_side": "BUY",
|
|
1904
|
+
"new_position_volume": 0.1,
|
|
1905
|
+
},
|
|
1906
|
+
1000.0,
|
|
1907
|
+
0.55,
|
|
1908
|
+
),
|
|
1909
|
+
({"equity": 10_000.0, "margin": 55.0}, {}, None, 0.0055),
|
|
1910
|
+
],
|
|
1911
|
+
)
|
|
1912
|
+
def test_account_projected_margin_ratio_uses_account_margin_baseline(
|
|
1913
|
+
self,
|
|
1914
|
+
account: dict[str, object],
|
|
1915
|
+
kwargs: dict[str, object],
|
|
1916
|
+
candidate_margin: float | None,
|
|
1917
|
+
expected_ratio: float,
|
|
1918
|
+
mocker: MockerFixture,
|
|
1919
|
+
) -> None:
|
|
1920
|
+
"""Test account-wide exposure uses snapshot margin plus optional candidate."""
|
|
1921
|
+
client = _mock_trade_client()
|
|
1922
|
+
client.account_info_as_dict.return_value = account
|
|
1923
|
+
client.positions_get_as_df.return_value = pd.DataFrame(
|
|
1924
|
+
[{"symbol": "GBPUSD", "type": 0, "volume": 2.0}],
|
|
1925
|
+
)
|
|
1926
|
+
mock_margin = mocker.patch(
|
|
1927
|
+
"mt5cli.trading.estimate_order_margin",
|
|
1928
|
+
return_value=candidate_margin,
|
|
1929
|
+
)
|
|
1930
|
+
|
|
1931
|
+
result = calculate_account_projected_margin_ratio(client, **cast("Any", kwargs))
|
|
1932
|
+
|
|
1933
|
+
_assert_close(result, expected_ratio)
|
|
1934
|
+
if candidate_margin is None:
|
|
1935
|
+
mock_margin.assert_not_called()
|
|
1936
|
+
else:
|
|
1937
|
+
mock_margin.assert_called_once_with(client, "EURUSD", "BUY", 0.1)
|
|
1938
|
+
client.positions_get_as_df.assert_not_called()
|
|
1939
|
+
|
|
1940
|
+
@pytest.mark.parametrize(
|
|
1941
|
+
("kwargs", "expected_ratio"),
|
|
1942
|
+
[
|
|
1943
|
+
({"new_position_side": "BUY", "new_position_volume": 0.1}, 0.45),
|
|
1944
|
+
({"symbol": "EURUSD", "new_position_volume": 0.1}, 0.45),
|
|
1945
|
+
({"symbol": "EURUSD", "new_position_side": "BUY"}, 0.45),
|
|
1946
|
+
(
|
|
1947
|
+
{
|
|
1948
|
+
"symbol": "EURUSD",
|
|
1949
|
+
"new_position_side": "BUY",
|
|
1950
|
+
"new_position_volume": -0.1,
|
|
1951
|
+
},
|
|
1952
|
+
0.45,
|
|
1953
|
+
),
|
|
1954
|
+
],
|
|
1955
|
+
)
|
|
1956
|
+
def test_account_projected_margin_ratio_skips_incomplete_candidate(
|
|
1957
|
+
self,
|
|
1958
|
+
kwargs: dict[str, object],
|
|
1959
|
+
expected_ratio: float,
|
|
1960
|
+
mocker: MockerFixture,
|
|
1961
|
+
) -> None:
|
|
1962
|
+
"""Test candidate margin is added only when symbol, side, and volume exist."""
|
|
1963
|
+
client = _mock_trade_client()
|
|
1964
|
+
client.account_info_as_dict.return_value = {
|
|
1965
|
+
"equity": 10_000.0,
|
|
1966
|
+
"margin": 4500.0,
|
|
1967
|
+
}
|
|
1968
|
+
mock_margin = mocker.patch("mt5cli.trading.estimate_order_margin")
|
|
1969
|
+
|
|
1970
|
+
result = calculate_account_projected_margin_ratio(client, **cast("Any", kwargs))
|
|
1971
|
+
|
|
1972
|
+
_assert_close(result, expected_ratio)
|
|
1973
|
+
mock_margin.assert_not_called()
|
|
1974
|
+
|
|
1975
|
+
@pytest.mark.parametrize(
|
|
1976
|
+
("account", "match"),
|
|
1977
|
+
[
|
|
1978
|
+
({"margin": 4500.0}, "Account equity"),
|
|
1979
|
+
({"equity": None, "margin": 4500.0}, "Account equity"),
|
|
1980
|
+
({"equity": "10000", "margin": 4500.0}, "Account equity"),
|
|
1981
|
+
({"equity": True, "margin": 4500.0}, "Account equity"),
|
|
1982
|
+
({"equity": float("nan"), "margin": 4500.0}, "Account equity"),
|
|
1983
|
+
({"equity": float("inf"), "margin": 4500.0}, "Account equity"),
|
|
1984
|
+
({"equity": 0.0, "margin": 4500.0}, "Account equity"),
|
|
1985
|
+
({"equity": -1.0, "margin": 4500.0}, "Account equity"),
|
|
1986
|
+
({"equity": 10_000.0}, "Account margin"),
|
|
1987
|
+
({"equity": 10_000.0, "margin": None}, "Account margin"),
|
|
1988
|
+
({"equity": 10_000.0, "margin": "4500"}, "Account margin"),
|
|
1989
|
+
({"equity": 10_000.0, "margin": True}, "Account margin"),
|
|
1990
|
+
({"equity": 10_000.0, "margin": False}, "Account margin"),
|
|
1991
|
+
({"equity": 10_000.0, "margin": float("nan")}, "Account margin"),
|
|
1992
|
+
({"equity": 10_000.0, "margin": float("inf")}, "Account margin"),
|
|
1993
|
+
({"equity": 10_000.0, "margin": -1.0}, "Account margin"),
|
|
1994
|
+
],
|
|
1995
|
+
)
|
|
1996
|
+
def test_account_projected_margin_ratio_rejects_invalid_snapshot_fields(
|
|
1997
|
+
self,
|
|
1998
|
+
account: dict[str, object],
|
|
1999
|
+
match: str,
|
|
2000
|
+
) -> None:
|
|
2001
|
+
"""Test invalid account equity and margin fields fail closed."""
|
|
2002
|
+
client = _mock_trade_client()
|
|
2003
|
+
client.account_info_as_dict.return_value = account
|
|
2004
|
+
|
|
2005
|
+
with pytest.raises(Mt5TradingError, match=match):
|
|
2006
|
+
calculate_account_projected_margin_ratio(client)
|
|
2007
|
+
|
|
2008
|
+
def test_account_projected_margin_ratio_propagates_candidate_margin_error(
|
|
2009
|
+
self,
|
|
2010
|
+
mocker: MockerFixture,
|
|
2011
|
+
) -> None:
|
|
2012
|
+
"""Test candidate margin errors are not suppressed."""
|
|
2013
|
+
client = _mock_trade_client()
|
|
2014
|
+
client.account_info_as_dict.return_value = {
|
|
2015
|
+
"equity": 10_000.0,
|
|
2016
|
+
"margin": 4500.0,
|
|
2017
|
+
}
|
|
2018
|
+
mocker.patch(
|
|
2019
|
+
"mt5cli.trading.estimate_order_margin",
|
|
2020
|
+
side_effect=Mt5TradingError("bad tick"),
|
|
2021
|
+
)
|
|
2022
|
+
|
|
2023
|
+
with pytest.raises(Mt5TradingError, match="bad tick"):
|
|
2024
|
+
calculate_account_projected_margin_ratio(
|
|
2025
|
+
client,
|
|
2026
|
+
symbol="EURUSD",
|
|
2027
|
+
new_position_side="BUY",
|
|
2028
|
+
new_position_volume=0.1,
|
|
2029
|
+
)
|
|
2030
|
+
|
|
1894
2031
|
def test_symbol_group_margin_ratio_sums_group_exposure(
|
|
1895
2032
|
self,
|
|
1896
2033
|
mocker: MockerFixture,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|