mt5cli 0.8.2__tar.gz → 0.9.0__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.8.2 → mt5cli-0.9.0}/.agents/skills/pr-feedback-triage/SKILL.md +63 -11
  2. {mt5cli-0.8.2 → mt5cli-0.9.0}/PKG-INFO +1 -1
  3. {mt5cli-0.8.2 → mt5cli-0.9.0}/docs/api/public-contract.md +27 -17
  4. {mt5cli-0.8.2 → mt5cli-0.9.0}/docs/api/sdk.md +26 -3
  5. {mt5cli-0.8.2 → mt5cli-0.9.0}/docs/api/trading.md +45 -7
  6. {mt5cli-0.8.2 → mt5cli-0.9.0}/mt5cli/__init__.py +10 -0
  7. {mt5cli-0.8.2 → mt5cli-0.9.0}/mt5cli/contract.py +5 -0
  8. {mt5cli-0.8.2 → mt5cli-0.9.0}/mt5cli/sdk.py +77 -9
  9. {mt5cli-0.8.2 → mt5cli-0.9.0}/mt5cli/trading.py +294 -2
  10. {mt5cli-0.8.2 → mt5cli-0.9.0}/pyproject.toml +1 -1
  11. {mt5cli-0.8.2 → mt5cli-0.9.0}/tests/test_contracts.py +72 -0
  12. {mt5cli-0.8.2 → mt5cli-0.9.0}/tests/test_sdk.py +185 -0
  13. {mt5cli-0.8.2 → mt5cli-0.9.0}/tests/test_trading.py +967 -0
  14. {mt5cli-0.8.2 → mt5cli-0.9.0}/uv.lock +1 -1
  15. {mt5cli-0.8.2 → mt5cli-0.9.0}/.agents/skills/local-qa/SKILL.md +0 -0
  16. {mt5cli-0.8.2 → mt5cli-0.9.0}/.agents/skills/local-qa/scripts/qa.sh +0 -0
  17. {mt5cli-0.8.2 → mt5cli-0.9.0}/.agents/skills/mt5cli/SKILL.md +0 -0
  18. {mt5cli-0.8.2 → mt5cli-0.9.0}/.claude/agents/codex.md +0 -0
  19. {mt5cli-0.8.2 → mt5cli-0.9.0}/.claude/settings.json +0 -0
  20. {mt5cli-0.8.2 → mt5cli-0.9.0}/.github/FUNDING.yml +0 -0
  21. {mt5cli-0.8.2 → mt5cli-0.9.0}/.github/dependabot.yml +0 -0
  22. {mt5cli-0.8.2 → mt5cli-0.9.0}/.github/renovate.json +0 -0
  23. {mt5cli-0.8.2 → mt5cli-0.9.0}/.github/workflows/ci.yml +0 -0
  24. {mt5cli-0.8.2 → mt5cli-0.9.0}/.github/workflows/claude.yml +0 -0
  25. {mt5cli-0.8.2 → mt5cli-0.9.0}/.github/workflows/release.yml +0 -0
  26. {mt5cli-0.8.2 → mt5cli-0.9.0}/.gitignore +0 -0
  27. {mt5cli-0.8.2 → mt5cli-0.9.0}/AGENTS.md +0 -0
  28. {mt5cli-0.8.2 → mt5cli-0.9.0}/CLAUDE.md +0 -0
  29. {mt5cli-0.8.2 → mt5cli-0.9.0}/LICENSE +0 -0
  30. {mt5cli-0.8.2 → mt5cli-0.9.0}/README.md +0 -0
  31. {mt5cli-0.8.2 → mt5cli-0.9.0}/docs/api/cli.md +0 -0
  32. {mt5cli-0.8.2 → mt5cli-0.9.0}/docs/api/client.md +0 -0
  33. {mt5cli-0.8.2 → mt5cli-0.9.0}/docs/api/converters.md +0 -0
  34. {mt5cli-0.8.2 → mt5cli-0.9.0}/docs/api/exceptions.md +0 -0
  35. {mt5cli-0.8.2 → mt5cli-0.9.0}/docs/api/history.md +0 -0
  36. {mt5cli-0.8.2 → mt5cli-0.9.0}/docs/api/index.md +0 -0
  37. {mt5cli-0.8.2 → mt5cli-0.9.0}/docs/api/schemas.md +0 -0
  38. {mt5cli-0.8.2 → mt5cli-0.9.0}/docs/api/storage.md +0 -0
  39. {mt5cli-0.8.2 → mt5cli-0.9.0}/docs/api/utils.md +0 -0
  40. {mt5cli-0.8.2 → mt5cli-0.9.0}/docs/index.md +0 -0
  41. {mt5cli-0.8.2 → mt5cli-0.9.0}/mkdocs.yml +0 -0
  42. {mt5cli-0.8.2 → mt5cli-0.9.0}/mt5cli/__main__.py +0 -0
  43. {mt5cli-0.8.2 → mt5cli-0.9.0}/mt5cli/cli.py +0 -0
  44. {mt5cli-0.8.2 → mt5cli-0.9.0}/mt5cli/client.py +0 -0
  45. {mt5cli-0.8.2 → mt5cli-0.9.0}/mt5cli/converters.py +0 -0
  46. {mt5cli-0.8.2 → mt5cli-0.9.0}/mt5cli/exceptions.py +0 -0
  47. {mt5cli-0.8.2 → mt5cli-0.9.0}/mt5cli/history.py +0 -0
  48. {mt5cli-0.8.2 → mt5cli-0.9.0}/mt5cli/retry.py +0 -0
  49. {mt5cli-0.8.2 → mt5cli-0.9.0}/mt5cli/schemas.py +0 -0
  50. {mt5cli-0.8.2 → mt5cli-0.9.0}/mt5cli/storage.py +0 -0
  51. {mt5cli-0.8.2 → mt5cli-0.9.0}/mt5cli/utils.py +0 -0
  52. {mt5cli-0.8.2 → mt5cli-0.9.0}/tests/__init__.py +0 -0
  53. {mt5cli-0.8.2 → mt5cli-0.9.0}/tests/conftest.py +0 -0
  54. {mt5cli-0.8.2 → mt5cli-0.9.0}/tests/test_cli.py +0 -0
  55. {mt5cli-0.8.2 → mt5cli-0.9.0}/tests/test_history.py +0 -0
  56. {mt5cli-0.8.2 → mt5cli-0.9.0}/tests/test_utils.py +0 -0
@@ -27,7 +27,7 @@ If no PR or review comments are identifiable, ask for the target PR or the copie
27
27
  ## Modes
28
28
 
29
29
  - `dry_run`: inspect review feedback and report the triage only. Do not edit files, run write-mode formatters, commit, push, post replies, or resolve review threads.
30
- - `no_push`: local edits and verification are allowed, but do not push commits or otherwise update the remote branch. Report the local diff or local commits that still need to be pushed.
30
+ - `no_push`: local edits and verification are allowed, but do not push commits or otherwise update the remote branch. Report the local diff or local commits that still need to be pushed. Do not resolve threads whose resolution depends on unpushed local edits.
31
31
  - `no_reply`: do not post replies, submit reviews, or resolve review threads. Provide suggested replies and resolution actions in the final report instead.
32
32
 
33
33
  When a mode disables an action, skip that destructive or externally visible action even if normal workflow text would otherwise allow it.
@@ -46,7 +46,7 @@ Gather the complete feedback set before editing:
46
46
  - Fetch unresolved review threads, requested-change reviews, PR-level summary comments, and copied comments.
47
47
  - Use platform-native APIs/CLI when available. Paginate results; do not inspect only the first page of threads or comments.
48
48
  - For bot reviewers that post both summary comments and inline comments, collect both. Summary comments often contain severity, rationale, and fix instructions; inline comments contain the exact file and line context.
49
- - Preserve each thread/comment identifier needed to reply or resolve later.
49
+ - Preserve every thread/comment identifier needed to reply or resolve later.
50
50
  - Compare each comment with the current diff and file contents because review lines can become outdated.
51
51
 
52
52
  ## Deduplication and Ordering
@@ -60,7 +60,7 @@ Build one triage record per distinct finding:
60
60
  - Preserve the reviewer’s exact issue title and original wording where practical. Do not rename findings in a way that would make replies hard to map back to comments.
61
61
  - Preserve the reviewer’s original ordering unless the user asks for priority reordering. Many review bots already order findings by severity.
62
62
 
63
- Each triage record should track: original title, reviewer, source IDs, location, current applicability, severity/priority if available, disposition, planned action, verification, reply text if any, and resolution decision.
63
+ Each triage record should track: original title, reviewer, source IDs, location, current applicability, severity/priority if available, disposition, planned action, verification, reply text if any, resolution decision, platform action attempted, and final platform state.
64
64
 
65
65
  ## Resolution Policy
66
66
 
@@ -70,6 +70,52 @@ Keep a thread open only when it still needs reviewer, maintainer, or product inp
70
70
 
71
71
  When resolving a thread, add a concise reply first only if it provides useful context, such as what changed, why no code change was needed, why a finding was intentionally deferred, or why the original comment is now outdated. Do not add noisy replies for self-evident fixes unless project norms require them.
72
72
 
73
+ ## Platform Action Contract
74
+
75
+ Do not treat triage as complete until every collected source ID reaches an explicit terminal state:
76
+
77
+ - `resolved`: a platform resolve action succeeded, or a re-check shows the thread is already resolved.
78
+ - `replied_left_open`: a reply or question was posted and the thread is intentionally left unresolved.
79
+ - `not_resolvable`: the source is a PR-level summary comment or copied comment that has no platform-level resolve action; reply or post a PR summary when useful.
80
+ - `skipped_by_mode`: `dry_run`, `no_push`, or `no_reply` prevented the external action.
81
+ - `failed_action`: a reply or resolve action was attempted and failed; include the attempted action and failure in the final summary.
82
+
83
+ In normal mode, build and execute a platform action queue after fixes are verified and pushed when needed:
84
+
85
+ - `reply_then_resolve`: use for handled threads where the reviewer needs context before resolution.
86
+ - `resolve_only`: use for self-evident fixes and already-addressed or outdated threads where an extra reply would add noise.
87
+ - `reply_leave_open`: use only for clarification requests, blocked work, or intentionally open follow-ups.
88
+ - `reply_only`: use for PR-level comments or summaries that cannot be resolved as review threads.
89
+
90
+ For duplicate findings, execute the terminal action for every source thread ID, not only the primary triage record. If one finding is represented by three unresolved inline threads, all three must be resolved or explicitly left open.
91
+
92
+ ## GitHub Action Guidance
93
+
94
+ Prefer platform-native APIs or `gh` commands that expose review-thread resolution state. For GitHub inline review threads, use the thread node ID and the GraphQL `resolveReviewThread` mutation rather than assuming that a reply resolves the conversation.
95
+
96
+ A reliable pattern is:
97
+
98
+ 1. Re-fetch review threads and comments immediately before acting.
99
+ 2. Reply to the thread when the action queue says a reply is needed.
100
+ 3. Resolve the review thread by node ID when the terminal state should be `resolved`.
101
+ 4. Re-fetch unresolved review threads after the action queue completes.
102
+ 5. Retry any expected-to-be-resolved thread that is still unresolved once; if it still remains unresolved, mark it `failed_action` instead of claiming completion.
103
+
104
+ Example GraphQL mutation shape:
105
+
106
+ ```graphql
107
+ mutation ($threadId: ID!) {
108
+ resolveReviewThread(input: { threadId: $threadId }) {
109
+ thread {
110
+ id
111
+ isResolved
112
+ }
113
+ }
114
+ }
115
+ ```
116
+
117
+ A posted reply alone is sufficient only for `reply_leave_open`, `reply_only`, or `not_resolvable` sources. For handled inline review threads, reply and resolve are separate actions.
118
+
73
119
  ## Flow
74
120
 
75
121
  ```mermaid
@@ -80,7 +126,7 @@ flowchart TD
80
126
  D --> E{Classify each triage record}
81
127
  E -->|Fix| F[Implement minimal change]
82
128
  E -->|Answer| G[Prepare concise reply]
83
- E -->|Clarify| H[Leave open with question]
129
+ E -->|Clarify| H[Prepare question and leave open]
84
130
  E -->|Already addressed or Outdated| I[Prepare evidence]
85
131
  E -->|Defer or Won't fix| J[Document reason]
86
132
  F --> K[Verify]
@@ -92,11 +138,13 @@ flowchart TD
92
138
  L -->|dry_run| M[Report triage only]
93
139
  L -->|no_push| N[Report local diff or commits]
94
140
  L -->|no_reply| O[Report suggested replies/actions]
95
- L -->|normal| P[Commit/push if changed, then batch reply and resolve handled threads]
141
+ L -->|normal| P[Commit/push if changed]
142
+ P --> R[Execute reply/resolve action queue]
143
+ R --> S[Re-fetch threads and retry unresolved handled threads once]
96
144
  M --> Q[Final summary]
97
145
  N --> Q
98
146
  O --> Q
99
- P --> Q
147
+ S --> Q
100
148
  ```
101
149
 
102
150
  ## Compact Workflow
@@ -109,7 +157,7 @@ flowchart TD
109
157
  2. **Classify each triage record**
110
158
  - **Fix**: Valid requested change; make the smallest focused edit when not in `dry_run`.
111
159
  - **Answer**: No code change needed; prepare a concise explanation.
112
- - **Clarify**: Ambiguous, conflicting, or missing context; leave open with a question.
160
+ - **Clarify**: Ambiguous, conflicting, or missing context; reply with the question and leave unresolved.
113
161
  - **Already addressed**: Current code already satisfies it; prepare evidence.
114
162
  - **Outdated**: Commented code or issue no longer exists; prepare evidence.
115
163
  - **Defer / Won't fix**: Valid concern intentionally not changed now; document a specific reason.
@@ -118,17 +166,19 @@ flowchart TD
118
166
  - Keep edits scoped to the review feedback.
119
167
  - Follow reviewer-provided fix instructions literally when they are still applicable; deviate only when the current code proves the instruction is stale or unsafe.
120
168
  - In `dry_run`, stop at triage, proposed fixes, suggested replies, and verification plan.
121
- - In `no_push`, local edits are allowed, but do not push or resolve threads for local-only fixes.
169
+ - In `no_push`, local edits are allowed, but do not push or resolve threads whose fix is only local. Reply or resolve non-code, already-addressed, or outdated threads only when the action does not depend on unpushed work and `no_reply` is not set.
122
170
  - In `no_reply`, do not post replies or resolve threads; report suggested replies/actions instead.
123
- - In normal mode, batch replies where practical, then resolve every handled thread by default after the fix, explanation, or deferral reason is available on the PR.
171
+ - In normal mode, commit and push changed code when appropriate, then execute the platform action queue for every collected source ID.
124
172
 
125
173
  4. **Verify before claiming completion**
126
174
  - For fixes, run appropriate checks or explain why they could not run.
127
175
  - Re-inspect the updated diff and comment context to confirm the concern is resolved.
176
+ - Re-fetch review threads after reply/resolve actions and confirm all expected-to-be-resolved thread IDs are resolved.
128
177
  - Do not mark a thread resolved if it still needs reviewer, maintainer, or product input.
178
+ - If a resolve or reply operation fails, retry once when safe; then report `failed_action` with the affected source ID and reason.
129
179
 
130
180
  5. **Finish**
131
- - Normal mode: commit/push changes when appropriate, post useful replies or a summary, and resolve all handled threads by default.
181
+ - Normal mode: commit/push changes when appropriate, post useful replies or a summary, resolve all handled threads by default, and reconcile the final unresolved set.
132
182
  - Safe modes: report the local state and the exact replies/resolution actions a human could take.
133
183
 
134
184
  ## Reply Guidance
@@ -143,7 +193,9 @@ flowchart TD
143
193
 
144
194
  - Mode used: `normal`, `dry_run`, `no_push`, or `no_reply`
145
195
  - Counts by disposition: fixed, answered, clarified/left open, already addressed, outdated, deferred/won't-fix
146
- - Threads resolved, intentionally left open, or resolution actions skipped by mode
196
+ - Counts by platform terminal state: resolved, replied-left-open, not-resolvable, skipped-by-mode, failed-action
197
+ - Threads resolved, intentionally left open, already resolved, or resolution actions skipped by mode
198
+ - Any expected-to-be-resolved thread that remained unresolved after retry
147
199
  - Verification run or planned
148
200
  - Commits pushed, local diff/commits, or "none"
149
201
  - Remaining open items and who needs to respond
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mt5cli
3
- Version: 0.8.2
3
+ Version: 0.9.0
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>
@@ -19,20 +19,27 @@ alias for new code.
19
19
 
20
20
  ### Session lifecycle and configuration
21
21
 
22
- | Symbol | Role |
23
- | ----------------------------------------------- | ---------------------------------------------------------------- |
24
- | `MT5Client`, `Mt5CliClient` | Read-only data client with optional `order_check` / `order_send` |
25
- | `build_config` | Build `pdmt5.Mt5Config` from connection fields |
26
- | `mt5_session` | Context manager: initialize, login, yield client, shutdown |
27
- | `create_trading_client`, `mt5_trading_session` | Trading-capable `pdmt5.Mt5TradingClient` lifecycle |
28
- | `AccountSpec` | Generic account group: symbols plus optional credentials |
29
- | `resolve_account_spec`, `resolve_account_specs` | Merge overrides and expand `${ENV_VAR}` placeholders |
30
- | `substitute_env_placeholders` | Replace `${NAME}` substrings from the environment |
22
+ | Symbol | Role |
23
+ | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
24
+ | `MT5Client`, `Mt5CliClient` | Read-only data client with optional `order_check` / `order_send` |
25
+ | `build_config` | Build `pdmt5.Mt5Config` from connection fields |
26
+ | `mt5_session` | Context manager: initialize, login, yield client, shutdown |
27
+ | `create_trading_client`, `mt5_trading_session` | Trading-capable `pdmt5.Mt5TradingClient` lifecycle |
28
+ | `AccountSpec` | Generic account group: symbols plus optional credentials |
29
+ | `resolve_account_spec`, `resolve_account_specs` | Merge overrides and expand `${ENV_VAR}` placeholders; opt-in `allow_whole_dollar_env` for bare `$NAME` |
30
+ | `substitute_env_placeholders` | Replace `${NAME}` substrings from the environment; opt-in `allow_whole_dollar_env` for whole-value `$NAME` |
31
31
 
32
32
  Credential resolution is generic: any environment variable name may appear inside
33
33
  `${...}`. mt5cli does not hard-code application-specific keys such as
34
34
  `mt5_login` or `mt5_exe`.
35
35
 
36
+ Pass `allow_whole_dollar_env=True` to `substitute_env_placeholders()`,
37
+ `resolve_account_spec()`, `resolve_account_specs()`, and `build_config()` to
38
+ additionally expand strings whose entire value is a bare `$ENV_NAME` identifier.
39
+ Partial strings such as `"plan$pass"`, `"abc$ENV"`, or `"$ENV-suffix"` are
40
+ **never** expanded — only an exact `$IDENTIFIER` whole-string match qualifies.
41
+ Default is `False` to preserve backward compatibility.
42
+
36
43
  ### Read-only MT5 data access
37
44
 
38
45
  Module-level helpers open a transient connection per call. Prefer `mt5_session`
@@ -56,14 +63,16 @@ MetaTrader 5 returns the still-forming bar as the last row when
56
63
  `start_pos=0`. Use these helpers instead of reimplementing bar trimming or
57
64
  timestamp normalization in downstream apps.
58
65
 
59
- | Symbol | Role |
60
- | ------------------------------------------------ | ------------------------------------------------------------ |
61
- | `drop_forming_rate_bar` | Remove the last row from chronologically ordered rate data |
62
- | `fetch_latest_closed_rates` | Single connected client: fetch `count + 1`, drop forming bar |
63
- | `collect_latest_closed_rates_for_accounts` | Multi-account closed bars with optional retry wrapper |
64
- | `collect_latest_closed_rates_by_granularity` | Same data keyed by `(symbol, granularity_name)` |
65
- | `collect_latest_rates_for_accounts` | Latest bars including the forming bar when `start_pos=0` |
66
- | `collect_latest_rates_for_accounts_with_retries` | Bounded exponential backoff for transient MT5 errors |
66
+ | Symbol | Role |
67
+ | ------------------------------------------------ | ------------------------------------------------------------------------------- |
68
+ | `drop_forming_rate_bar` | Remove the last row from chronologically ordered rate data |
69
+ | `fetch_latest_closed_rates` | Single connected client: fetch `count + 1`, drop forming bar |
70
+ | `fetch_latest_closed_rates_for_trading_client` | Closed bars from an active `Mt5TradingClient` session; returns RangeIndex |
71
+ | `fetch_latest_closed_rates_indexed` | Same as above but returns a UTC `DatetimeIndex` named `"time"` (no time column) |
72
+ | `collect_latest_closed_rates_for_accounts` | Multi-account closed bars with optional retry wrapper |
73
+ | `collect_latest_closed_rates_by_granularity` | Same data keyed by `(symbol, granularity_name)` |
74
+ | `collect_latest_rates_for_accounts` | Latest bars including the forming bar when `start_pos=0` |
75
+ | `collect_latest_rates_for_accounts_with_retries` | Bounded exponential backoff for transient MT5 errors |
67
76
 
68
77
  ### SQLite history collection and rate loading
69
78
 
@@ -96,6 +105,7 @@ strategy entries, exits, Kelly sizing, or signal logic.
96
105
  | `detect_position_side` | Net long / short / flat from open positions |
97
106
  | `calculate_spread_ratio` | Relative bid-ask spread |
98
107
  | `calculate_margin_and_volume`, `calculate_volume_by_margin`, `calculate_new_position_margin_ratio` | Margin budget and volume sizing |
108
+ | `normalize_order_volume`, `estimate_order_margin`, `calculate_positions_margin` | Broker volume normalization and margin totals |
99
109
  | `determine_order_limits` | SL/TP price levels from ratios |
100
110
  | `ensure_symbol_selected` | Select/verify Market Watch visibility |
101
111
  | `place_market_order`, `close_open_positions`, `update_sltp_for_open_positions` | Order execution helpers (`dry_run` supported) |
@@ -31,9 +31,10 @@ rates = collect_latest_rates_for_accounts_with_retries(
31
31
  ### Latest closed rate bars
32
32
 
33
33
  MetaTrader 5 `start_pos=0` includes the still-forming current bar as the last
34
- row. `fetch_latest_closed_rates()` handles one connected client; multi-account
35
- helpers fetch `count + 1` bars, drop that row with `drop_forming_rate_bar()`,
36
- and validate each series is non-empty. Returned frames are ordered
34
+ row. `fetch_latest_closed_rates()` handles one connected `Mt5CliClient`; use
35
+ `fetch_latest_closed_rates_for_trading_client()` from an active
36
+ `Mt5TradingClient` session. Multi-account helpers fetch `count + 1` bars, drop
37
+ that row with `drop_forming_rate_bar()`, and validate each series is non-empty. Returned frames are ordered
37
38
  oldest-to-newest and may contain fewer than `count` rows only when MT5 returns
38
39
  fewer closed bars.
39
40
 
@@ -85,6 +86,28 @@ resolved = resolve_account_specs(accounts, server="Broker-Demo")
85
86
  # resolved[0].login == "12345", resolved[0].server == "Broker-Demo"
86
87
  ```
87
88
 
89
+ Pass `allow_whole_dollar_env=True` to also expand strings whose **entire value**
90
+ is a bare `$ENV_NAME` identifier (no braces). This opt-in covers
91
+ `substitute_env_placeholders()`, `resolve_account_spec()`,
92
+ `resolve_account_specs()`, and `build_config()`. Note: `build_config` cannot
93
+ expand `login` because that parameter is `int | None`; use
94
+ `resolve_account_spec` for a string `login` placeholder. Partial strings such as
95
+ `"plan$pass"`, `"abc$ENV"`, or `"$ENV-suffix"` are never expanded — only an
96
+ exact `$IDENTIFIER` whole-string match qualifies. The default is `False` to
97
+ preserve backward compatibility.
98
+
99
+ ```python
100
+ import os
101
+
102
+ from mt5cli import AccountSpec, resolve_account_specs
103
+
104
+ os.environ["MT5_PASSWORD"] = "secret"
105
+ accounts = [AccountSpec(symbols=["EURUSD"], password="$MT5_PASSWORD")]
106
+
107
+ resolved = resolve_account_specs(accounts, allow_whole_dollar_env=True)
108
+ # resolved[0].password == "secret"
109
+ ```
110
+
88
111
  ### Throttled incremental history updates
89
112
 
90
113
  `ThrottledHistoryUpdater` wraps `update_history()` with a minimum interval
@@ -40,15 +40,20 @@ betting logic, or scheduling code in downstream applications.
40
40
 
41
41
  ```python
42
42
  from mt5cli import (
43
+ calculate_positions_margin,
43
44
  calculate_spread_ratio,
44
45
  calculate_margin_and_volume,
45
46
  close_open_positions,
46
47
  detect_position_side,
47
48
  determine_order_limits,
49
+ estimate_order_margin,
50
+ fetch_latest_closed_rates_for_trading_client,
51
+ fetch_latest_closed_rates_indexed,
48
52
  get_account_snapshot,
49
53
  get_positions_frame,
50
54
  get_symbol_snapshot,
51
55
  get_tick_snapshot,
56
+ normalize_order_volume,
52
57
  place_market_order,
53
58
  )
54
59
 
@@ -58,6 +63,30 @@ tick = get_tick_snapshot(client, "EURUSD")
58
63
  positions = get_positions_frame(client, "EURUSD")
59
64
  side = detect_position_side(client, "EURUSD")
60
65
  spread_ratio = calculate_spread_ratio(client, "EURUSD")
66
+ volume = normalize_order_volume(
67
+ 0.15,
68
+ volume_min=symbol["volume_min"],
69
+ volume_max=symbol["volume_max"],
70
+ volume_step=symbol["volume_step"],
71
+ )
72
+ buy_margin = (
73
+ estimate_order_margin(client, "EURUSD", "BUY", volume) if volume > 0 else 0.0
74
+ )
75
+ open_margin = calculate_positions_margin(client, symbols=["EURUSD"])
76
+ closed_bars = fetch_latest_closed_rates_for_trading_client(
77
+ client,
78
+ symbol="EURUSD",
79
+ granularity="M1",
80
+ count=100,
81
+ )
82
+ # Or fetch with a UTC DatetimeIndex instead of a "time" column:
83
+ indexed_bars = fetch_latest_closed_rates_indexed(
84
+ client,
85
+ symbol="EURUSD",
86
+ granularity="M1",
87
+ count=100,
88
+ )
89
+ # indexed_bars.index is a UTC-aware DatetimeIndex named "time"
61
90
  sizing = calculate_margin_and_volume(
62
91
  client,
63
92
  "EURUSD",
@@ -87,6 +116,12 @@ closed = close_open_positions(client, symbols="EURUSD", dry_run=True)
87
116
  sell-only exposure, and `None` for no positions or mixed long/short exposure.
88
117
  `calculate_spread_ratio()` uses `(ask - bid) / ((ask + bid) / 2)` and raises
89
118
  `Mt5TradingError` when bid or ask is missing or non-positive.
119
+ `normalize_order_volume()` returns `0.0` for invalid constraints or
120
+ sub-minimum requests; check the result before calling `estimate_order_margin()`,
121
+ which requires a positive finite volume. `calculate_positions_margin()` silently
122
+ skips rows with missing symbols, non-positive volumes, non-finite volumes, or
123
+ unsupported position types, but propagates `Mt5TradingError` from `estimate_order_margin()` when a valid row
124
+ encounters invalid tick data or margin results from the broker.
90
125
 
91
126
  SL/TP ratios for `determine_order_limits()` must satisfy `0 <= ratio < 1`; `0`
92
127
  omits that level. SL/TP prices are rounded with symbol `digits` metadata when
@@ -148,13 +183,16 @@ through the stable package root without embedding entry/exit policy.
148
183
 
149
184
  ## Migration from application-local helpers
150
185
 
151
- | Application-local concern | mt5cli replacement |
152
- | -------------------------------------------------------- | ----------------------------------------------- |
153
- | Manual terminal spawn/kill around trading code | `mt5_trading_session()` |
154
- | Local position-side detection | `detect_position_side()` |
155
- | Local margin/volume sizing | `calculate_margin_and_volume()` |
156
- | Local SL/TP price derivation | `determine_order_limits()` |
157
- | Throttled SQLite history loop with ad-hoc error handling | `ThrottledHistoryUpdater(suppress_errors=True)` |
186
+ | Application-local concern | mt5cli replacement |
187
+ | -------------------------------------------------------- | --------------------------------------------------------------------------------------- |
188
+ | Manual terminal spawn/kill around trading code | `mt5_trading_session()` |
189
+ | Local position-side detection | `detect_position_side()` |
190
+ | Local margin/volume sizing | `calculate_margin_and_volume()` |
191
+ | Local broker volume step normalization | `normalize_order_volume()` |
192
+ | Local order or position margin estimation | `estimate_order_margin()`, `calculate_positions_margin()` |
193
+ | Local closed-bar fetch from a trading session | `fetch_latest_closed_rates_for_trading_client()`, `fetch_latest_closed_rates_indexed()` |
194
+ | Local SL/TP price derivation | `determine_order_limits()` |
195
+ | Throttled SQLite history loop with ad-hoc error handling | `ThrottledHistoryUpdater(suppress_errors=True)` |
158
196
 
159
197
  Keep read-only data collection on `mt5_session()` / `Mt5CliClient`; use
160
198
  `mt5_trading_session()` only where order placement or trading calculations are
@@ -118,6 +118,7 @@ from .trading import (
118
118
  PositionSide,
119
119
  calculate_margin_and_volume,
120
120
  calculate_new_position_margin_ratio,
121
+ calculate_positions_margin,
121
122
  calculate_spread_ratio,
122
123
  calculate_volume_by_margin,
123
124
  close_open_positions,
@@ -125,11 +126,15 @@ from .trading import (
125
126
  detect_position_side,
126
127
  determine_order_limits,
127
128
  ensure_symbol_selected,
129
+ estimate_order_margin,
130
+ fetch_latest_closed_rates_for_trading_client,
131
+ fetch_latest_closed_rates_indexed,
128
132
  get_account_snapshot,
129
133
  get_positions_frame,
130
134
  get_symbol_snapshot,
131
135
  get_tick_snapshot,
132
136
  mt5_trading_session,
137
+ normalize_order_volume,
133
138
  place_market_order,
134
139
  update_sltp_for_open_positions,
135
140
  )
@@ -182,6 +187,7 @@ __all__ = [
182
187
  "build_rate_view_name",
183
188
  "calculate_margin_and_volume",
184
189
  "calculate_new_position_margin_ratio",
190
+ "calculate_positions_margin",
185
191
  "calculate_spread_ratio",
186
192
  "calculate_volume_by_margin",
187
193
  "call_with_normalized_errors",
@@ -204,9 +210,12 @@ __all__ = [
204
210
  "drop_forming_rate_bar",
205
211
  "ensure_symbol_selected",
206
212
  "ensure_utc",
213
+ "estimate_order_margin",
207
214
  "export_dataframe",
208
215
  "export_dataframe_to_sqlite",
209
216
  "fetch_latest_closed_rates",
217
+ "fetch_latest_closed_rates_for_trading_client",
218
+ "fetch_latest_closed_rates_indexed",
210
219
  "get_account_snapshot",
211
220
  "get_positions_frame",
212
221
  "get_symbol_snapshot",
@@ -230,6 +239,7 @@ __all__ = [
230
239
  "mt5_version",
231
240
  "normalize_dataframe",
232
241
  "normalize_mt5_exception",
242
+ "normalize_order_volume",
233
243
  "normalize_symbol",
234
244
  "normalize_symbols",
235
245
  "normalize_time_columns",
@@ -30,6 +30,7 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
30
30
  "build_rate_view_name",
31
31
  "calculate_margin_and_volume",
32
32
  "calculate_new_position_margin_ratio",
33
+ "calculate_positions_margin",
33
34
  "calculate_spread_ratio",
34
35
  "calculate_volume_by_margin",
35
36
  "call_with_normalized_errors",
@@ -50,9 +51,12 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
50
51
  "determine_order_limits",
51
52
  "drop_forming_rate_bar",
52
53
  "ensure_symbol_selected",
54
+ "estimate_order_margin",
53
55
  "export_dataframe",
54
56
  "export_dataframe_to_sqlite",
55
57
  "fetch_latest_closed_rates",
58
+ "fetch_latest_closed_rates_for_trading_client",
59
+ "fetch_latest_closed_rates_indexed",
56
60
  "get_account_snapshot",
57
61
  "get_positions_frame",
58
62
  "get_symbol_snapshot",
@@ -74,6 +78,7 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
74
78
  "mt5_trading_session",
75
79
  "mt5_version",
76
80
  "normalize_mt5_exception",
81
+ "normalize_order_volume",
77
82
  "orders",
78
83
  "place_market_order",
79
84
  "positions",
@@ -309,12 +309,33 @@ def build_config(
309
309
  password: str | None = None,
310
310
  server: str | None = None,
311
311
  timeout: int | None = None,
312
+ allow_whole_dollar_env: bool = False,
312
313
  ) -> Mt5Config:
313
314
  """Build an ``Mt5Config`` from optional connection parameters.
314
315
 
316
+ Args:
317
+ path: Optional terminal executable path.
318
+ login: Optional trading account login.
319
+ password: Optional trading account password.
320
+ server: Optional trading server name.
321
+ timeout: Optional connection timeout in milliseconds.
322
+ allow_whole_dollar_env: When ``True``, string parameters that are
323
+ exactly ``$ENV_NAME`` are expanded from the environment. Applies
324
+ to ``path``, ``password``, and ``server``. Default ``False``
325
+ preserves existing behavior.
326
+
315
327
  Returns:
316
328
  Configured ``Mt5Config`` instance.
317
329
  """
330
+ if allow_whole_dollar_env:
331
+ if path is not None:
332
+ path = substitute_env_placeholders(path, allow_whole_dollar_env=True)
333
+ if password is not None:
334
+ password = substitute_env_placeholders(
335
+ password, allow_whole_dollar_env=True
336
+ )
337
+ if server is not None:
338
+ server = substitute_env_placeholders(server, allow_whole_dollar_env=True)
318
339
  return Mt5Config(
319
340
  path=path,
320
341
  login=login,
@@ -1376,13 +1397,22 @@ class AccountSpec:
1376
1397
 
1377
1398
 
1378
1399
  _ENV_PLACEHOLDER_PATTERN = re.compile(r"\$\{(?P<name>[A-Za-z_][A-Za-z0-9_]*)\}")
1400
+ _WHOLE_DOLLAR_PATTERN = re.compile(r"^\$(?P<name>[A-Za-z_][A-Za-z0-9_]*)$")
1379
1401
 
1380
1402
 
1381
- def substitute_env_placeholders(value: str) -> str:
1403
+ def substitute_env_placeholders(
1404
+ value: str,
1405
+ *,
1406
+ allow_whole_dollar_env: bool = False,
1407
+ ) -> str:
1382
1408
  """Replace ``${ENV_VAR}`` placeholders in a string with environment values.
1383
1409
 
1384
1410
  Args:
1385
1411
  value: String that may contain one or more ``${ENV_VAR}`` placeholders.
1412
+ allow_whole_dollar_env: When ``True``, a string that is exactly
1413
+ ``$ENV_NAME`` (the whole value and nothing else) is also expanded
1414
+ from the environment. Partial occurrences such as ``"plan$pass"``
1415
+ or ``"$ENV-suffix"`` are left unchanged.
1386
1416
 
1387
1417
  Returns:
1388
1418
  The string with every placeholder replaced by its environment value.
@@ -1390,6 +1420,14 @@ def substitute_env_placeholders(value: str) -> str:
1390
1420
  Raises:
1391
1421
  ValueError: If a referenced environment variable is not set.
1392
1422
  """
1423
+ if allow_whole_dollar_env:
1424
+ m = _WHOLE_DOLLAR_PATTERN.match(value)
1425
+ if m:
1426
+ name = m.group("name")
1427
+ if name not in os.environ:
1428
+ msg = f"Environment variable {name!r} is not set."
1429
+ raise ValueError(msg)
1430
+ return os.environ[name]
1393
1431
  parts: list[str] = []
1394
1432
  last_end = 0
1395
1433
  for match in _ENV_PLACEHOLDER_PATTERN.finditer(value):
@@ -1404,7 +1442,12 @@ def substitute_env_placeholders(value: str) -> str:
1404
1442
  return "".join(parts)
1405
1443
 
1406
1444
 
1407
- def _resolve_field(override: str | None, account_value: str | None) -> str | None:
1445
+ def _resolve_field(
1446
+ override: str | None,
1447
+ account_value: str | None,
1448
+ *,
1449
+ allow_whole_dollar_env: bool = False,
1450
+ ) -> str | None:
1408
1451
  """Resolve a string field from an override or account value with env subst.
1409
1452
 
1410
1453
  Returns:
@@ -1414,12 +1457,16 @@ def _resolve_field(override: str | None, account_value: str | None) -> str | Non
1414
1457
  value = override if override is not None else account_value
1415
1458
  if value is None:
1416
1459
  return None
1417
- return substitute_env_placeholders(value)
1460
+ return substitute_env_placeholders(
1461
+ value, allow_whole_dollar_env=allow_whole_dollar_env
1462
+ )
1418
1463
 
1419
1464
 
1420
1465
  def _resolve_login(
1421
1466
  override: int | str | None,
1422
1467
  account_login: int | str | None,
1468
+ *,
1469
+ allow_whole_dollar_env: bool = False,
1423
1470
  ) -> int | str | None:
1424
1471
  """Resolve a login from an override or account value with env substitution.
1425
1472
 
@@ -1431,10 +1478,14 @@ def _resolve_login(
1431
1478
  if override is not None:
1432
1479
  if isinstance(override, int):
1433
1480
  return override
1434
- return substitute_env_placeholders(override)
1481
+ return substitute_env_placeholders(
1482
+ override, allow_whole_dollar_env=allow_whole_dollar_env
1483
+ )
1435
1484
  if account_login is None or isinstance(account_login, int):
1436
1485
  return account_login
1437
- return substitute_env_placeholders(account_login)
1486
+ return substitute_env_placeholders(
1487
+ account_login, allow_whole_dollar_env=allow_whole_dollar_env
1488
+ )
1438
1489
 
1439
1490
 
1440
1491
  def resolve_account_spec(
@@ -1445,6 +1496,7 @@ def resolve_account_spec(
1445
1496
  server: str | None = None,
1446
1497
  path: str | None = None,
1447
1498
  timeout: int | None = None,
1499
+ allow_whole_dollar_env: bool = False,
1448
1500
  ) -> AccountSpec:
1449
1501
  """Resolve an account's credentials from overrides and ``${ENV_VAR}`` values.
1450
1502
 
@@ -1460,6 +1512,9 @@ def resolve_account_spec(
1460
1512
  server: Optional explicit server override.
1461
1513
  path: Optional explicit terminal path override.
1462
1514
  timeout: Optional explicit connection timeout override.
1515
+ allow_whole_dollar_env: When ``True``, string fields that are exactly
1516
+ ``$ENV_NAME`` are also expanded from the environment. Default
1517
+ ``False`` preserves existing behavior.
1463
1518
 
1464
1519
  Returns:
1465
1520
  A new :class:`AccountSpec` with resolved credentials and the original
@@ -1469,10 +1524,18 @@ def resolve_account_spec(
1469
1524
  """
1470
1525
  return AccountSpec(
1471
1526
  symbols=account.symbols,
1472
- login=_resolve_login(login, account.login),
1473
- password=_resolve_field(password, account.password),
1474
- server=_resolve_field(server, account.server),
1475
- path=_resolve_field(path, account.path),
1527
+ login=_resolve_login(
1528
+ login, account.login, allow_whole_dollar_env=allow_whole_dollar_env
1529
+ ),
1530
+ password=_resolve_field(
1531
+ password, account.password, allow_whole_dollar_env=allow_whole_dollar_env
1532
+ ),
1533
+ server=_resolve_field(
1534
+ server, account.server, allow_whole_dollar_env=allow_whole_dollar_env
1535
+ ),
1536
+ path=_resolve_field(
1537
+ path, account.path, allow_whole_dollar_env=allow_whole_dollar_env
1538
+ ),
1476
1539
  timeout=timeout if timeout is not None else account.timeout,
1477
1540
  )
1478
1541
 
@@ -1485,6 +1548,7 @@ def resolve_account_specs(
1485
1548
  server: str | None = None,
1486
1549
  path: str | None = None,
1487
1550
  timeout: int | None = None,
1551
+ allow_whole_dollar_env: bool = False,
1488
1552
  ) -> list[AccountSpec]:
1489
1553
  """Resolve credentials for multiple accounts.
1490
1554
 
@@ -1498,6 +1562,9 @@ def resolve_account_specs(
1498
1562
  server: Optional explicit server override applied to each account.
1499
1563
  path: Optional explicit terminal path override applied to each account.
1500
1564
  timeout: Optional explicit timeout override applied to each account.
1565
+ allow_whole_dollar_env: When ``True``, string fields that are exactly
1566
+ ``$ENV_NAME`` are also expanded from the environment. Default
1567
+ ``False`` preserves existing behavior.
1501
1568
 
1502
1569
  Returns:
1503
1570
  Resolved account specifications in the original order. Raises
@@ -1512,6 +1579,7 @@ def resolve_account_specs(
1512
1579
  server=server,
1513
1580
  path=path,
1514
1581
  timeout=timeout,
1582
+ allow_whole_dollar_env=allow_whole_dollar_env,
1515
1583
  )
1516
1584
  for account in accounts
1517
1585
  ]