mt5cli 0.9.4__tar.gz → 0.9.6__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.
Files changed (56) hide show
  1. {mt5cli-0.9.4 → mt5cli-0.9.6}/PKG-INFO +12 -4
  2. {mt5cli-0.9.4 → mt5cli-0.9.6}/README.md +11 -3
  3. {mt5cli-0.9.4 → mt5cli-0.9.6}/docs/api/public-contract.md +27 -12
  4. {mt5cli-0.9.4 → mt5cli-0.9.6}/mt5cli/__init__.py +4 -0
  5. {mt5cli-0.9.4 → mt5cli-0.9.6}/mt5cli/cli.py +93 -2
  6. {mt5cli-0.9.4 → mt5cli-0.9.6}/mt5cli/contract.py +2 -0
  7. {mt5cli-0.9.4 → mt5cli-0.9.6}/mt5cli/sdk.py +83 -6
  8. {mt5cli-0.9.4 → mt5cli-0.9.6}/mt5cli/trading.py +34 -10
  9. {mt5cli-0.9.4 → mt5cli-0.9.6}/pyproject.toml +1 -1
  10. {mt5cli-0.9.4 → mt5cli-0.9.6}/tests/test_cli.py +300 -0
  11. {mt5cli-0.9.4 → mt5cli-0.9.6}/tests/test_contracts.py +30 -36
  12. {mt5cli-0.9.4 → mt5cli-0.9.6}/tests/test_history.py +19 -54
  13. {mt5cli-0.9.4 → mt5cli-0.9.6}/tests/test_sdk.py +292 -43
  14. {mt5cli-0.9.4 → mt5cli-0.9.6}/tests/test_trading.py +469 -589
  15. {mt5cli-0.9.4 → mt5cli-0.9.6}/uv.lock +1 -1
  16. {mt5cli-0.9.4 → mt5cli-0.9.6}/.agents/skills/local-qa/SKILL.md +0 -0
  17. {mt5cli-0.9.4 → mt5cli-0.9.6}/.agents/skills/local-qa/scripts/qa.sh +0 -0
  18. {mt5cli-0.9.4 → mt5cli-0.9.6}/.agents/skills/mt5cli/SKILL.md +0 -0
  19. {mt5cli-0.9.4 → mt5cli-0.9.6}/.agents/skills/pr-feedback-triage/SKILL.md +0 -0
  20. {mt5cli-0.9.4 → mt5cli-0.9.6}/.claude/agents/codex.md +0 -0
  21. {mt5cli-0.9.4 → mt5cli-0.9.6}/.claude/settings.json +0 -0
  22. {mt5cli-0.9.4 → mt5cli-0.9.6}/.github/FUNDING.yml +0 -0
  23. {mt5cli-0.9.4 → mt5cli-0.9.6}/.github/dependabot.yml +0 -0
  24. {mt5cli-0.9.4 → mt5cli-0.9.6}/.github/renovate.json +0 -0
  25. {mt5cli-0.9.4 → mt5cli-0.9.6}/.github/workflows/ci.yml +0 -0
  26. {mt5cli-0.9.4 → mt5cli-0.9.6}/.github/workflows/claude.yml +0 -0
  27. {mt5cli-0.9.4 → mt5cli-0.9.6}/.github/workflows/release.yml +0 -0
  28. {mt5cli-0.9.4 → mt5cli-0.9.6}/.gitignore +0 -0
  29. {mt5cli-0.9.4 → mt5cli-0.9.6}/AGENTS.md +0 -0
  30. {mt5cli-0.9.4 → mt5cli-0.9.6}/CLAUDE.md +0 -0
  31. {mt5cli-0.9.4 → mt5cli-0.9.6}/LICENSE +0 -0
  32. {mt5cli-0.9.4 → mt5cli-0.9.6}/docs/api/cli.md +0 -0
  33. {mt5cli-0.9.4 → mt5cli-0.9.6}/docs/api/client.md +0 -0
  34. {mt5cli-0.9.4 → mt5cli-0.9.6}/docs/api/converters.md +0 -0
  35. {mt5cli-0.9.4 → mt5cli-0.9.6}/docs/api/exceptions.md +0 -0
  36. {mt5cli-0.9.4 → mt5cli-0.9.6}/docs/api/history.md +0 -0
  37. {mt5cli-0.9.4 → mt5cli-0.9.6}/docs/api/index.md +0 -0
  38. {mt5cli-0.9.4 → mt5cli-0.9.6}/docs/api/schemas.md +0 -0
  39. {mt5cli-0.9.4 → mt5cli-0.9.6}/docs/api/sdk.md +0 -0
  40. {mt5cli-0.9.4 → mt5cli-0.9.6}/docs/api/storage.md +0 -0
  41. {mt5cli-0.9.4 → mt5cli-0.9.6}/docs/api/trading.md +0 -0
  42. {mt5cli-0.9.4 → mt5cli-0.9.6}/docs/api/utils.md +0 -0
  43. {mt5cli-0.9.4 → mt5cli-0.9.6}/docs/index.md +0 -0
  44. {mt5cli-0.9.4 → mt5cli-0.9.6}/mkdocs.yml +0 -0
  45. {mt5cli-0.9.4 → mt5cli-0.9.6}/mt5cli/__main__.py +0 -0
  46. {mt5cli-0.9.4 → mt5cli-0.9.6}/mt5cli/client.py +0 -0
  47. {mt5cli-0.9.4 → mt5cli-0.9.6}/mt5cli/converters.py +0 -0
  48. {mt5cli-0.9.4 → mt5cli-0.9.6}/mt5cli/exceptions.py +0 -0
  49. {mt5cli-0.9.4 → mt5cli-0.9.6}/mt5cli/history.py +0 -0
  50. {mt5cli-0.9.4 → mt5cli-0.9.6}/mt5cli/retry.py +0 -0
  51. {mt5cli-0.9.4 → mt5cli-0.9.6}/mt5cli/schemas.py +0 -0
  52. {mt5cli-0.9.4 → mt5cli-0.9.6}/mt5cli/storage.py +0 -0
  53. {mt5cli-0.9.4 → mt5cli-0.9.6}/mt5cli/utils.py +0 -0
  54. {mt5cli-0.9.4 → mt5cli-0.9.6}/tests/__init__.py +0 -0
  55. {mt5cli-0.9.4 → mt5cli-0.9.6}/tests/conftest.py +0 -0
  56. {mt5cli-0.9.4 → mt5cli-0.9.6}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mt5cli
3
- Version: 0.9.4
3
+ Version: 0.9.6
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",
@@ -197,10 +202,13 @@ python -m mt5cli -o account.csv account-info
197
202
  | `recent-history-deals` | Export historical deals from a recent trailing window |
198
203
  | `mt5-summary` | Export terminal/account status summary |
199
204
  | `order-check` | Check funds sufficiency for a trade request |
200
- | `order-send` | Send a trade request to the trade server (`--yes` required) |
205
+ | `order-send` | Send a raw trade request to the trade server (`--yes` required; expert path) |
206
+ | `close-positions` | Close open positions by `--symbol` or `--ticket` (`--yes` required for live; `--dry-run` available) |
201
207
  | `collect-history` | Bundle rates, ticks, history-orders, and history-deals for one or more symbols into a single SQLite database |
202
208
 
203
209
  Use `order-check` to validate a request payload before running `order-send --yes`.
210
+ `close-positions` is the safer high-level alternative that builds correct close
211
+ requests automatically. At least one `--symbol` or `--ticket` must be provided.
204
212
 
205
213
  ### `collect-history`
206
214
 
@@ -272,7 +280,7 @@ rates = collect_latest_closed_rates_by_granularity(
272
280
  eurusd_m1 = rates["EURUSD", "M1"] # closed bars only
273
281
  ```
274
282
 
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.
283
+ - **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
284
  - **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
285
  - **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
286
  - **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",
@@ -173,10 +178,13 @@ python -m mt5cli -o account.csv account-info
173
178
  | `recent-history-deals` | Export historical deals from a recent trailing window |
174
179
  | `mt5-summary` | Export terminal/account status summary |
175
180
  | `order-check` | Check funds sufficiency for a trade request |
176
- | `order-send` | Send a trade request to the trade server (`--yes` required) |
181
+ | `order-send` | Send a raw trade request to the trade server (`--yes` required; expert path) |
182
+ | `close-positions` | Close open positions by `--symbol` or `--ticket` (`--yes` required for live; `--dry-run` available) |
177
183
  | `collect-history` | Bundle rates, ticks, history-orders, and history-deals for one or more symbols into a single SQLite database |
178
184
 
179
185
  Use `order-check` to validate a request payload before running `order-send --yes`.
186
+ `close-positions` is the safer high-level alternative that builds correct close
187
+ requests automatically. At least one `--symbol` or `--ticket` must be provided.
180
188
 
181
189
  ### `collect-history`
182
190
 
@@ -248,7 +256,7 @@ rates = collect_latest_closed_rates_by_granularity(
248
256
  eurusd_m1 = rates["EURUSD", "M1"] # closed bars only
249
257
  ```
250
258
 
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.
259
+ - **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
260
  - **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
261
  - **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
262
  - **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
- `resolve_account_spec()`, `resolve_account_specs()`, and `build_config()` to
47
- additionally expand strings whose entire value is a bare `$ENV_NAME` identifier.
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.
@@ -109,6 +111,14 @@ strategy entries, exits, Kelly sizing, or signal logic.
109
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) |
110
112
  | `MarginVolume`, `OrderLimits`, `OrderExecutionResult` | Typed return contracts for order helpers |
111
113
  | `OrderSide`, `OrderFillingMode`, `OrderTimeMode`, `PositionSide`, `ExecutionStatus` | Typed enums for order helpers |
114
+ | `ProjectionMode` | Literal type for `calculate_symbol_group_margin_ratio` projection |
115
+
116
+ `calculate_symbol_group_margin_ratio` accepts an optional `projection_mode`
117
+ parameter (`"add"` by default). Pass `projection_mode="replace_symbol"` to
118
+ subtract current exposure for `new_symbol` before adding the candidate margin —
119
+ useful for reversal-style projections. mt5cli only calculates broker-facing
120
+ exposure; downstream applications own thresholds, risk guard actions, and
121
+ strategy policy.
112
122
 
113
123
  `MT5Client.order_send()` and CLI `order-send --yes` are live execution paths.
114
124
 
@@ -180,7 +190,12 @@ The Typer application in `mt5cli.cli` exposes file-export commands documented in
180
190
  - Delegate to the same Python APIs described here; they are not duplicated
181
191
  business logic.
182
192
 
183
- `order-send` requires `--yes` before placing live trades.
193
+ `order-send` is the expert raw-request path; it requires `--yes` and a fully
194
+ constructed request payload. `close-positions` is the safer high-level helper
195
+ that closes open positions by `--symbol` or `--ticket` using
196
+ `close_open_positions()`. Both `order-send --yes` and `close-positions --yes`
197
+ are live execution paths. `close-positions --dry-run` previews close orders
198
+ without placing them and does not require `--yes`.
184
199
 
185
200
  ## Internal helpers (not stable)
186
201
 
@@ -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
+ ProjectionMode,
122
124
  calculate_account_projected_margin_ratio,
123
125
  calculate_margin_and_volume,
124
126
  calculate_new_position_margin_ratio,
@@ -191,6 +193,7 @@ __all__ = [
191
193
  "OrderSide",
192
194
  "OrderTimeMode",
193
195
  "PositionSide",
196
+ "ProjectionMode",
194
197
  "RateTarget",
195
198
  "ThrottledHistoryUpdater",
196
199
  "account_info",
@@ -283,6 +286,7 @@ __all__ = [
283
286
  "resolve_rate_view_names",
284
287
  "schema_columns",
285
288
  "substitute_env_placeholders",
289
+ "substitute_mapping_values",
286
290
  "symbol_info",
287
291
  "symbol_info_tick",
288
292
  "symbols",
@@ -2,17 +2,20 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  import logging
6
7
  from dataclasses import dataclass
7
8
  from datetime import datetime # noqa: TC003
8
9
  from pathlib import Path # noqa: TC003
9
10
  from typing import TYPE_CHECKING, Annotated, Any, cast
10
11
 
12
+ import pandas as pd
11
13
  import typer
12
14
  from pdmt5 import Mt5Config
13
15
 
14
16
  from . import sdk
15
17
  from .client import MT5Client
18
+ from .trading import OrderExecutionResult, close_open_positions, create_trading_client
16
19
  from .utils import (
17
20
  DATETIME_TYPE,
18
21
  REQUEST_TYPE,
@@ -29,8 +32,6 @@ from .utils import (
29
32
  if TYPE_CHECKING:
30
33
  from collections.abc import Callable
31
34
 
32
- import pandas as pd
33
-
34
35
  logger = logging.getLogger(__name__)
35
36
 
36
37
  # ---------------------------------------------------------------------------
@@ -600,6 +601,96 @@ def order_send(
600
601
  _export_command(ctx, lambda client: client.order_send(request))
601
602
 
602
603
 
604
+ _EXECUTION_RESULT_COLUMNS: list[str] = [
605
+ "status",
606
+ "symbol",
607
+ "order_side",
608
+ "volume",
609
+ "retcode",
610
+ "comment",
611
+ "request",
612
+ "response",
613
+ "dry_run",
614
+ ]
615
+
616
+
617
+ def _execution_results_to_df(results: list[OrderExecutionResult]) -> pd.DataFrame:
618
+ if not results:
619
+ return pd.DataFrame(columns=_EXECUTION_RESULT_COLUMNS)
620
+ rows = [
621
+ {
622
+ **r,
623
+ "request": json.dumps(r["request"]),
624
+ "response": json.dumps(r["response"]),
625
+ }
626
+ for r in results
627
+ ]
628
+ return pd.DataFrame(rows)
629
+
630
+
631
+ @app.command()
632
+ def close_positions(
633
+ ctx: typer.Context,
634
+ symbol: Annotated[
635
+ list[str] | None,
636
+ typer.Option(
637
+ "--symbol",
638
+ "-s",
639
+ help="Symbol to close (repeat for multiple symbols).",
640
+ ),
641
+ ] = None,
642
+ ticket: Annotated[
643
+ list[int] | None,
644
+ typer.Option(
645
+ "--ticket",
646
+ "-t",
647
+ help="Position ticket to close (repeat for multiple tickets).",
648
+ ),
649
+ ] = None,
650
+ dry_run: Annotated[
651
+ bool,
652
+ typer.Option("--dry-run", help="Preview close orders without executing them."),
653
+ ] = False,
654
+ yes: Annotated[
655
+ bool,
656
+ typer.Option("--yes", help="Confirm live position closing."),
657
+ ] = False,
658
+ ) -> None:
659
+ """Close open positions by symbol or ticket.
660
+
661
+ Delegates to :func:`mt5cli.trading.close_open_positions`. At least one
662
+ ``--symbol`` or ``--ticket`` must be provided to avoid accidentally closing
663
+ all positions. Use ``--dry-run`` to preview without executing; ``--yes`` is
664
+ required for live execution.
665
+
666
+ ``order-send`` is the expert raw-request path. ``close-positions`` is the
667
+ safer high-level helper that builds correct close requests automatically.
668
+
669
+ Raises:
670
+ typer.BadParameter: If neither ``--symbol`` nor ``--ticket`` is given,
671
+ or if ``--yes`` is missing for a live (non-dry-run) run.
672
+ """
673
+ if not symbol and not ticket:
674
+ msg = "Provide at least one --symbol or --ticket to close positions."
675
+ raise typer.BadParameter(msg)
676
+ if not dry_run and not yes:
677
+ msg = "Pass --yes to close live positions."
678
+ raise typer.BadParameter(msg, param_hint="--yes")
679
+ export_ctx = _get_export_context(ctx)
680
+ client = create_trading_client(config=export_ctx.config)
681
+ try:
682
+ results = close_open_positions(
683
+ client,
684
+ symbols=list(symbol) if symbol else None,
685
+ tickets=list(ticket) if ticket else None,
686
+ dry_run=dry_run,
687
+ )
688
+ finally:
689
+ client.shutdown()
690
+ df = _execution_results_to_df(results)
691
+ _execute_export(ctx, lambda: df)
692
+
693
+
603
694
  @app.command()
604
695
  def collect_history(
605
696
  ctx: typer.Context,
@@ -17,6 +17,7 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
17
17
  "OrderSide",
18
18
  "OrderTimeMode",
19
19
  "PositionSide",
20
+ "ProjectionMode",
20
21
  "ExecutionStatus",
21
22
  "MarginVolume",
22
23
  "OrderExecutionResult",
@@ -77,6 +78,7 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
77
78
  "resolve_rate_view_name",
78
79
  "resolve_rate_view_names",
79
80
  "substitute_env_placeholders",
81
+ "substitute_mapping_values",
80
82
  "update_history",
81
83
  "update_history_with_config",
82
84
  "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 ``False``
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,
@@ -25,6 +25,7 @@ OrderSide = Literal["BUY", "SELL"]
25
25
  OrderFillingMode = Literal["IOC", "FOK", "RETURN"]
26
26
  OrderTimeMode = Literal["GTC", "DAY", "SPECIFIED", "SPECIFIED_DAY"]
27
27
  ExecutionStatus = Literal["executed", "dry_run", "skipped", "failed"]
28
+ ProjectionMode = Literal["add", "replace_symbol"]
28
29
 
29
30
 
30
31
  class MarginVolume(TypedDict):
@@ -127,6 +128,7 @@ __all__ = [
127
128
  "OrderSide",
128
129
  "OrderTimeMode",
129
130
  "PositionSide",
131
+ "ProjectionMode",
130
132
  "calculate_account_projected_margin_ratio",
131
133
  "calculate_margin_and_volume",
132
134
  "calculate_new_position_margin_ratio",
@@ -920,6 +922,16 @@ def calculate_projected_margin_ratio(
920
922
  return margin / equity
921
923
 
922
924
 
925
+ def _validate_projection_mode(projection_mode: str) -> ProjectionMode:
926
+ if projection_mode not in {"add", "replace_symbol"}:
927
+ msg = (
928
+ f"Unsupported projection mode: {projection_mode!r}. "
929
+ "Expected 'add' or 'replace_symbol'."
930
+ )
931
+ raise ValueError(msg)
932
+ return cast("ProjectionMode", projection_mode)
933
+
934
+
923
935
  def calculate_symbol_group_margin_ratio(
924
936
  client: Mt5TradingClient,
925
937
  *,
@@ -928,13 +940,22 @@ def calculate_symbol_group_margin_ratio(
928
940
  new_position_side: OrderSide | None = None,
929
941
  new_position_volume: float = 0.0,
930
942
  suppress_errors: bool = True,
943
+ projection_mode: ProjectionMode = "add",
931
944
  ) -> float:
932
945
  """Return estimated symbol-group margin over account equity.
933
946
 
934
947
  Per-symbol current exposure is summed with
935
948
  :func:`calculate_positions_margin_by_symbol`. When ``new_symbol`` is inside
936
- the input symbol group, optional projected order margin is added for that
937
- symbol. Invalid equity always raises to fail closed.
949
+ the input symbol group and candidate side/volume are provided, projected order
950
+ margin is applied according to ``projection_mode``:
951
+
952
+ - ``"add"`` (default): adds candidate margin to the group total.
953
+ - ``"replace_symbol"``: subtracts current margin for ``new_symbol``, then
954
+ adds candidate margin. Useful for reversal-style projections where the new
955
+ order is intended to replace existing exposure for that symbol.
956
+
957
+ If the candidate margin estimation fails, the subtraction is also skipped so
958
+ the operation is atomic. Invalid equity always raises to fail closed.
938
959
 
939
960
  Raises:
940
961
  AttributeError: When symbol margin lookup or projected margin lookup
@@ -945,23 +966,22 @@ def calculate_symbol_group_margin_ratio(
945
966
  lookup or projected margin lookup fails and ``suppress_errors`` is
946
967
  ``False``.
947
968
  """
969
+ projection_mode = _validate_projection_mode(projection_mode)
948
970
  equity = _account_equity(client)
949
971
  unique_symbols = list(dict.fromkeys(symbols))
950
- margin = sum(
951
- calculate_positions_margin_by_symbol(
952
- client,
953
- symbols=unique_symbols,
954
- suppress_errors=suppress_errors,
955
- ).values(),
956
- 0.0,
972
+ per_symbol = calculate_positions_margin_by_symbol(
973
+ client,
974
+ symbols=unique_symbols,
975
+ suppress_errors=suppress_errors,
957
976
  )
977
+ margin = sum(per_symbol.values(), 0.0)
958
978
  if (
959
979
  new_symbol in unique_symbols
960
980
  and new_position_side is not None
961
981
  and new_position_volume > 0
962
982
  ):
963
983
  try:
964
- margin += estimate_order_margin(
984
+ candidate_margin = estimate_order_margin(
965
985
  client,
966
986
  new_symbol,
967
987
  new_position_side,
@@ -971,6 +991,10 @@ def calculate_symbol_group_margin_ratio(
971
991
  if not suppress_errors:
972
992
  raise
973
993
  _logger.warning("Skipping projected margin for %r.", new_symbol)
994
+ else:
995
+ if projection_mode == "replace_symbol":
996
+ margin = max(0.0, margin - per_symbol.get(new_symbol, 0.0))
997
+ margin += candidate_margin
974
998
  return margin / equity
975
999
 
976
1000
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mt5cli"
3
- version = "0.9.4"
3
+ version = "0.9.6"
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"}]