mt5cli 0.8.2__tar.gz → 0.8.3__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.8.3}/.agents/skills/pr-feedback-triage/SKILL.md +63 -11
  2. {mt5cli-0.8.2 → mt5cli-0.8.3}/PKG-INFO +1 -1
  3. {mt5cli-0.8.2 → mt5cli-0.8.3}/docs/api/public-contract.md +2 -0
  4. {mt5cli-0.8.2 → mt5cli-0.8.3}/docs/api/sdk.md +4 -3
  5. {mt5cli-0.8.2 → mt5cli-0.8.3}/docs/api/trading.md +29 -0
  6. {mt5cli-0.8.2 → mt5cli-0.8.3}/mt5cli/__init__.py +8 -0
  7. {mt5cli-0.8.2 → mt5cli-0.8.3}/mt5cli/contract.py +4 -0
  8. {mt5cli-0.8.2 → mt5cli-0.8.3}/mt5cli/trading.py +206 -1
  9. {mt5cli-0.8.2 → mt5cli-0.8.3}/pyproject.toml +1 -1
  10. {mt5cli-0.8.2 → mt5cli-0.8.3}/tests/test_contracts.py +42 -0
  11. {mt5cli-0.8.2 → mt5cli-0.8.3}/tests/test_trading.py +699 -0
  12. {mt5cli-0.8.2 → mt5cli-0.8.3}/uv.lock +1 -1
  13. {mt5cli-0.8.2 → mt5cli-0.8.3}/.agents/skills/local-qa/SKILL.md +0 -0
  14. {mt5cli-0.8.2 → mt5cli-0.8.3}/.agents/skills/local-qa/scripts/qa.sh +0 -0
  15. {mt5cli-0.8.2 → mt5cli-0.8.3}/.agents/skills/mt5cli/SKILL.md +0 -0
  16. {mt5cli-0.8.2 → mt5cli-0.8.3}/.claude/agents/codex.md +0 -0
  17. {mt5cli-0.8.2 → mt5cli-0.8.3}/.claude/settings.json +0 -0
  18. {mt5cli-0.8.2 → mt5cli-0.8.3}/.github/FUNDING.yml +0 -0
  19. {mt5cli-0.8.2 → mt5cli-0.8.3}/.github/dependabot.yml +0 -0
  20. {mt5cli-0.8.2 → mt5cli-0.8.3}/.github/renovate.json +0 -0
  21. {mt5cli-0.8.2 → mt5cli-0.8.3}/.github/workflows/ci.yml +0 -0
  22. {mt5cli-0.8.2 → mt5cli-0.8.3}/.github/workflows/claude.yml +0 -0
  23. {mt5cli-0.8.2 → mt5cli-0.8.3}/.github/workflows/release.yml +0 -0
  24. {mt5cli-0.8.2 → mt5cli-0.8.3}/.gitignore +0 -0
  25. {mt5cli-0.8.2 → mt5cli-0.8.3}/AGENTS.md +0 -0
  26. {mt5cli-0.8.2 → mt5cli-0.8.3}/CLAUDE.md +0 -0
  27. {mt5cli-0.8.2 → mt5cli-0.8.3}/LICENSE +0 -0
  28. {mt5cli-0.8.2 → mt5cli-0.8.3}/README.md +0 -0
  29. {mt5cli-0.8.2 → mt5cli-0.8.3}/docs/api/cli.md +0 -0
  30. {mt5cli-0.8.2 → mt5cli-0.8.3}/docs/api/client.md +0 -0
  31. {mt5cli-0.8.2 → mt5cli-0.8.3}/docs/api/converters.md +0 -0
  32. {mt5cli-0.8.2 → mt5cli-0.8.3}/docs/api/exceptions.md +0 -0
  33. {mt5cli-0.8.2 → mt5cli-0.8.3}/docs/api/history.md +0 -0
  34. {mt5cli-0.8.2 → mt5cli-0.8.3}/docs/api/index.md +0 -0
  35. {mt5cli-0.8.2 → mt5cli-0.8.3}/docs/api/schemas.md +0 -0
  36. {mt5cli-0.8.2 → mt5cli-0.8.3}/docs/api/storage.md +0 -0
  37. {mt5cli-0.8.2 → mt5cli-0.8.3}/docs/api/utils.md +0 -0
  38. {mt5cli-0.8.2 → mt5cli-0.8.3}/docs/index.md +0 -0
  39. {mt5cli-0.8.2 → mt5cli-0.8.3}/mkdocs.yml +0 -0
  40. {mt5cli-0.8.2 → mt5cli-0.8.3}/mt5cli/__main__.py +0 -0
  41. {mt5cli-0.8.2 → mt5cli-0.8.3}/mt5cli/cli.py +0 -0
  42. {mt5cli-0.8.2 → mt5cli-0.8.3}/mt5cli/client.py +0 -0
  43. {mt5cli-0.8.2 → mt5cli-0.8.3}/mt5cli/converters.py +0 -0
  44. {mt5cli-0.8.2 → mt5cli-0.8.3}/mt5cli/exceptions.py +0 -0
  45. {mt5cli-0.8.2 → mt5cli-0.8.3}/mt5cli/history.py +0 -0
  46. {mt5cli-0.8.2 → mt5cli-0.8.3}/mt5cli/retry.py +0 -0
  47. {mt5cli-0.8.2 → mt5cli-0.8.3}/mt5cli/schemas.py +0 -0
  48. {mt5cli-0.8.2 → mt5cli-0.8.3}/mt5cli/sdk.py +0 -0
  49. {mt5cli-0.8.2 → mt5cli-0.8.3}/mt5cli/storage.py +0 -0
  50. {mt5cli-0.8.2 → mt5cli-0.8.3}/mt5cli/utils.py +0 -0
  51. {mt5cli-0.8.2 → mt5cli-0.8.3}/tests/__init__.py +0 -0
  52. {mt5cli-0.8.2 → mt5cli-0.8.3}/tests/conftest.py +0 -0
  53. {mt5cli-0.8.2 → mt5cli-0.8.3}/tests/test_cli.py +0 -0
  54. {mt5cli-0.8.2 → mt5cli-0.8.3}/tests/test_history.py +0 -0
  55. {mt5cli-0.8.2 → mt5cli-0.8.3}/tests/test_sdk.py +0 -0
  56. {mt5cli-0.8.2 → mt5cli-0.8.3}/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.8.3
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>
@@ -60,6 +60,7 @@ timestamp normalization in downstream apps.
60
60
  | ------------------------------------------------ | ------------------------------------------------------------ |
61
61
  | `drop_forming_rate_bar` | Remove the last row from chronologically ordered rate data |
62
62
  | `fetch_latest_closed_rates` | Single connected client: fetch `count + 1`, drop forming bar |
63
+ | `fetch_latest_closed_rates_for_trading_client` | Closed bars from an active `Mt5TradingClient` session |
63
64
  | `collect_latest_closed_rates_for_accounts` | Multi-account closed bars with optional retry wrapper |
64
65
  | `collect_latest_closed_rates_by_granularity` | Same data keyed by `(symbol, granularity_name)` |
65
66
  | `collect_latest_rates_for_accounts` | Latest bars including the forming bar when `start_pos=0` |
@@ -96,6 +97,7 @@ strategy entries, exits, Kelly sizing, or signal logic.
96
97
  | `detect_position_side` | Net long / short / flat from open positions |
97
98
  | `calculate_spread_ratio` | Relative bid-ask spread |
98
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 |
99
101
  | `determine_order_limits` | SL/TP price levels from ratios |
100
102
  | `ensure_symbol_selected` | Select/verify Market Watch visibility |
101
103
  | `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
 
@@ -40,15 +40,19 @@ 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,
48
51
  get_account_snapshot,
49
52
  get_positions_frame,
50
53
  get_symbol_snapshot,
51
54
  get_tick_snapshot,
55
+ normalize_order_volume,
52
56
  place_market_order,
53
57
  )
54
58
 
@@ -58,6 +62,22 @@ tick = get_tick_snapshot(client, "EURUSD")
58
62
  positions = get_positions_frame(client, "EURUSD")
59
63
  side = detect_position_side(client, "EURUSD")
60
64
  spread_ratio = calculate_spread_ratio(client, "EURUSD")
65
+ volume = normalize_order_volume(
66
+ 0.15,
67
+ volume_min=symbol["volume_min"],
68
+ volume_max=symbol["volume_max"],
69
+ volume_step=symbol["volume_step"],
70
+ )
71
+ buy_margin = (
72
+ estimate_order_margin(client, "EURUSD", "BUY", volume) if volume > 0 else 0.0
73
+ )
74
+ open_margin = calculate_positions_margin(client, symbols=["EURUSD"])
75
+ closed_bars = fetch_latest_closed_rates_for_trading_client(
76
+ client,
77
+ symbol="EURUSD",
78
+ granularity="M1",
79
+ count=100,
80
+ )
61
81
  sizing = calculate_margin_and_volume(
62
82
  client,
63
83
  "EURUSD",
@@ -87,6 +107,12 @@ closed = close_open_positions(client, symbols="EURUSD", dry_run=True)
87
107
  sell-only exposure, and `None` for no positions or mixed long/short exposure.
88
108
  `calculate_spread_ratio()` uses `(ask - bid) / ((ask + bid) / 2)` and raises
89
109
  `Mt5TradingError` when bid or ask is missing or non-positive.
110
+ `normalize_order_volume()` returns `0.0` for invalid constraints or
111
+ sub-minimum requests; check the result before calling `estimate_order_margin()`,
112
+ which requires a positive finite volume. `calculate_positions_margin()` silently
113
+ skips rows with missing symbols, non-positive volumes, non-finite volumes, or
114
+ unsupported position types, but propagates `Mt5TradingError` from `estimate_order_margin()` when a valid row
115
+ encounters invalid tick data or margin results from the broker.
90
116
 
91
117
  SL/TP ratios for `determine_order_limits()` must satisfy `0 <= ratio < 1`; `0`
92
118
  omits that level. SL/TP prices are rounded with symbol `digits` metadata when
@@ -153,6 +179,9 @@ through the stable package root without embedding entry/exit policy.
153
179
  | Manual terminal spawn/kill around trading code | `mt5_trading_session()` |
154
180
  | Local position-side detection | `detect_position_side()` |
155
181
  | Local margin/volume sizing | `calculate_margin_and_volume()` |
182
+ | Local broker volume step normalization | `normalize_order_volume()` |
183
+ | Local order or position margin estimation | `estimate_order_margin()`, `calculate_positions_margin()` |
184
+ | Local closed-bar fetch from a trading session | `fetch_latest_closed_rates_for_trading_client()` |
156
185
  | Local SL/TP price derivation | `determine_order_limits()` |
157
186
  | Throttled SQLite history loop with ad-hoc error handling | `ThrottledHistoryUpdater(suppress_errors=True)` |
158
187
 
@@ -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,14 @@ 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,
128
131
  get_account_snapshot,
129
132
  get_positions_frame,
130
133
  get_symbol_snapshot,
131
134
  get_tick_snapshot,
132
135
  mt5_trading_session,
136
+ normalize_order_volume,
133
137
  place_market_order,
134
138
  update_sltp_for_open_positions,
135
139
  )
@@ -182,6 +186,7 @@ __all__ = [
182
186
  "build_rate_view_name",
183
187
  "calculate_margin_and_volume",
184
188
  "calculate_new_position_margin_ratio",
189
+ "calculate_positions_margin",
185
190
  "calculate_spread_ratio",
186
191
  "calculate_volume_by_margin",
187
192
  "call_with_normalized_errors",
@@ -204,9 +209,11 @@ __all__ = [
204
209
  "drop_forming_rate_bar",
205
210
  "ensure_symbol_selected",
206
211
  "ensure_utc",
212
+ "estimate_order_margin",
207
213
  "export_dataframe",
208
214
  "export_dataframe_to_sqlite",
209
215
  "fetch_latest_closed_rates",
216
+ "fetch_latest_closed_rates_for_trading_client",
210
217
  "get_account_snapshot",
211
218
  "get_positions_frame",
212
219
  "get_symbol_snapshot",
@@ -230,6 +237,7 @@ __all__ = [
230
237
  "mt5_version",
231
238
  "normalize_dataframe",
232
239
  "normalize_mt5_exception",
240
+ "normalize_order_volume",
233
241
  "normalize_symbol",
234
242
  "normalize_symbols",
235
243
  "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,11 @@ 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",
56
59
  "get_account_snapshot",
57
60
  "get_positions_frame",
58
61
  "get_symbol_snapshot",
@@ -74,6 +77,7 @@ STABLE_SDK_EXPORTS: frozenset[str] = frozenset({
74
77
  "mt5_trading_session",
75
78
  "mt5_version",
76
79
  "normalize_mt5_exception",
80
+ "normalize_order_volume",
77
81
  "orders",
78
82
  "place_market_order",
79
83
  "positions",
@@ -10,11 +10,13 @@ from typing import TYPE_CHECKING, Literal, TypedDict, cast
10
10
  import pandas as pd
11
11
  from pdmt5 import Mt5Config, Mt5TradingClient, Mt5TradingError
12
12
 
13
+ from .history import drop_forming_rate_bar
13
14
  from .sdk import build_config
14
15
  from .utils import coerce_login as _coerce_login
16
+ from .utils import parse_timeframe
15
17
 
16
18
  if TYPE_CHECKING:
17
- from collections.abc import Iterator
19
+ from collections.abc import Iterator, Sequence
18
20
 
19
21
  PositionSide = Literal["long", "short"]
20
22
  OrderSide = Literal["BUY", "SELL"]
@@ -125,6 +127,7 @@ __all__ = [
125
127
  "PositionSide",
126
128
  "calculate_margin_and_volume",
127
129
  "calculate_new_position_margin_ratio",
130
+ "calculate_positions_margin",
128
131
  "calculate_spread_ratio",
129
132
  "calculate_volume_by_margin",
130
133
  "close_open_positions",
@@ -132,11 +135,14 @@ __all__ = [
132
135
  "detect_position_side",
133
136
  "determine_order_limits",
134
137
  "ensure_symbol_selected",
138
+ "estimate_order_margin",
139
+ "fetch_latest_closed_rates_for_trading_client",
135
140
  "get_account_snapshot",
136
141
  "get_positions_frame",
137
142
  "get_symbol_snapshot",
138
143
  "get_tick_snapshot",
139
144
  "mt5_trading_session",
145
+ "normalize_order_volume",
140
146
  "place_market_order",
141
147
  "update_sltp_for_open_positions",
142
148
  ]
@@ -276,6 +282,48 @@ def _normalize_order_side(side: str) -> OrderSide:
276
282
  raise ValueError(msg)
277
283
 
278
284
 
285
+ def _is_finite_number(value: object) -> bool:
286
+ return isinstance(value, int | float) and isfinite(value)
287
+
288
+
289
+ def _is_positive_finite_number(value: object) -> bool:
290
+ if not _is_finite_number(value):
291
+ return False
292
+ return float(cast("float | int", value)) > 0
293
+
294
+
295
+ def normalize_order_volume(
296
+ volume: float,
297
+ *,
298
+ volume_min: float,
299
+ volume_max: float,
300
+ volume_step: float,
301
+ ) -> float:
302
+ """Normalize a requested order volume to broker volume constraints.
303
+
304
+ Returns:
305
+ Volume floored to the nearest valid broker step from ``volume_min``,
306
+ capped at ``volume_max`` when finite and positive, and rounded
307
+ deterministically. Returns ``0.0`` when inputs or constraints are
308
+ invalid, non-finite, or the capped request is below ``volume_min``.
309
+ """
310
+ if not _is_finite_number(volume):
311
+ return 0.0
312
+ if not _is_positive_finite_number(volume_min):
313
+ return 0.0
314
+ if not _is_positive_finite_number(volume_step):
315
+ return 0.0
316
+ has_volume_cap = _is_positive_finite_number(volume_max)
317
+ capped = min(volume, volume_max) if has_volume_cap else volume
318
+ if capped < volume_min:
319
+ return 0.0
320
+ steps = floor(((capped - volume_min) / volume_step) + 1e-12)
321
+ normalized = volume_min + max(0, steps) * volume_step
322
+ if has_volume_cap:
323
+ normalized = min(normalized, volume_max)
324
+ return round(normalized, 10)
325
+
326
+
279
327
  def _position_side_from_order_side(side: str) -> PositionSide:
280
328
  normalized = side.lower()
281
329
  if normalized in {"long", "buy"}:
@@ -531,6 +579,108 @@ def get_positions_frame(
531
579
  return frame
532
580
 
533
581
 
582
+ def _order_side_from_position_type(
583
+ client: Mt5TradingClient,
584
+ position_type: object,
585
+ ) -> OrderSide | None:
586
+ if position_type == client.mt5.POSITION_TYPE_BUY:
587
+ return "BUY"
588
+ if position_type == client.mt5.POSITION_TYPE_SELL:
589
+ return "SELL"
590
+ return None
591
+
592
+
593
+ def _ensure_rate_time_column(frame: pd.DataFrame) -> pd.DataFrame:
594
+ if frame.empty or "time" in frame.columns:
595
+ return frame
596
+ if frame.index.name == "time" or isinstance(frame.index, pd.DatetimeIndex):
597
+ normalized = frame.reset_index()
598
+ if "time" not in normalized.columns and not normalized.empty:
599
+ normalized = normalized.rename(columns={normalized.columns[0]: "time"})
600
+ return normalized
601
+ return frame
602
+
603
+
604
+ def estimate_order_margin(
605
+ client: Mt5TradingClient,
606
+ symbol: str,
607
+ order_side: OrderSide | str,
608
+ volume: float,
609
+ ) -> float:
610
+ """Estimate required margin for one order at the current market price.
611
+
612
+ Returns:
613
+ Positive finite margin required for the order at the current quote.
614
+
615
+ Raises:
616
+ Mt5TradingError: If volume, tick data, or margin estimation is invalid.
617
+ """
618
+ if not _is_positive_finite_number(volume):
619
+ msg = "Volume must be a positive finite number to estimate order margin."
620
+ raise Mt5TradingError(msg)
621
+ side = _normalize_order_side(order_side)
622
+ tick = get_tick_snapshot(client, symbol)
623
+ price = tick["ask"] if side == "BUY" else tick["bid"]
624
+ if not isinstance(price, int | float) or price <= 0 or not isfinite(price):
625
+ msg = f"Tick price is unavailable for {symbol!r}."
626
+ raise Mt5TradingError(msg)
627
+ order_type = (
628
+ client.mt5.ORDER_TYPE_BUY if side == "BUY" else client.mt5.ORDER_TYPE_SELL
629
+ )
630
+ raw_margin = client.order_calc_margin(order_type, symbol, volume, float(price))
631
+ try:
632
+ margin = float(raw_margin)
633
+ except (TypeError, ValueError) as exc:
634
+ msg = f"Margin estimate is invalid for {symbol!r}."
635
+ raise Mt5TradingError(msg) from exc
636
+ if margin <= 0 or not isfinite(margin):
637
+ msg = f"Margin estimate is invalid for {symbol!r}."
638
+ raise Mt5TradingError(msg)
639
+ return margin
640
+
641
+
642
+ def calculate_positions_margin(
643
+ client: Mt5TradingClient,
644
+ *,
645
+ symbols: Sequence[str] | None = None,
646
+ ) -> float:
647
+ """Return the sum of estimated current margin for open positions.
648
+
649
+ Args:
650
+ client: Connected ``Mt5TradingClient`` instance.
651
+ symbols: Optional symbol filter. When omitted, all open positions are
652
+ included.
653
+
654
+ Returns:
655
+ Total estimated margin, or ``0.0`` when no matching positions exist.
656
+ """
657
+ frame = get_positions_frame(client)
658
+ if frame.empty or "symbol" not in frame.columns:
659
+ return 0.0
660
+ if symbols is not None:
661
+ frame = frame[frame["symbol"].isin(list(symbols))]
662
+ if frame.empty:
663
+ return 0.0
664
+ grouped_volumes: dict[tuple[str, OrderSide], float] = {}
665
+ for _, row in frame.iterrows():
666
+ symbol = row.get("symbol")
667
+ if not isinstance(symbol, str) or not symbol:
668
+ continue
669
+ volume = row.get("volume")
670
+ if not _is_positive_finite_number(volume):
671
+ continue
672
+ order_side = _order_side_from_position_type(client, row.get("type"))
673
+ if order_side is None:
674
+ continue
675
+ key = (symbol, order_side)
676
+ finite_volume = float(cast("float | int", volume))
677
+ grouped_volumes[key] = grouped_volumes.get(key, 0.0) + finite_volume
678
+ total = 0.0
679
+ for (symbol, order_side), volume in grouped_volumes.items():
680
+ total += estimate_order_margin(client, symbol, order_side, volume)
681
+ return total
682
+
683
+
534
684
  def calculate_spread_ratio(client: Mt5TradingClient, symbol: str) -> float:
535
685
  """Return ``(ask - bid) / ((ask + bid) / 2)`` for the latest tick.
536
686
 
@@ -997,6 +1147,61 @@ def update_sltp_for_open_positions(
997
1147
  return results
998
1148
 
999
1149
 
1150
+ def fetch_latest_closed_rates_for_trading_client(
1151
+ client: Mt5TradingClient,
1152
+ *,
1153
+ symbol: str,
1154
+ granularity: str,
1155
+ count: int,
1156
+ ) -> pd.DataFrame:
1157
+ """Fetch the latest closed bars from a connected trading client.
1158
+
1159
+ Returns:
1160
+ Up to ``count`` closed bars ordered oldest to newest.
1161
+
1162
+ Raises:
1163
+ ValueError: If ``count`` is not positive, rate data is empty or
1164
+ malformed, or the ``time`` column is missing.
1165
+ Mt5TradingError: If the trading client cannot fetch rate data.
1166
+ """
1167
+ if count <= 0:
1168
+ msg = "count must be positive."
1169
+ raise ValueError(msg)
1170
+ fetch_method = getattr(client, "fetch_latest_rates_as_df", None)
1171
+ if callable(fetch_method):
1172
+ fetched = fetch_method(symbol, granularity, count + 1)
1173
+ else:
1174
+ copy_method = getattr(client, "copy_rates_from_pos_as_df", None)
1175
+ if not callable(copy_method):
1176
+ msg = "MT5 trading client cannot fetch rate data."
1177
+ raise Mt5TradingError(msg)
1178
+ fetched = copy_method(
1179
+ symbol=symbol,
1180
+ timeframe=parse_timeframe(granularity),
1181
+ start_pos=0,
1182
+ count=count + 1,
1183
+ )
1184
+ if not isinstance(fetched, pd.DataFrame):
1185
+ msg = (
1186
+ f"Malformed rate data for {symbol!r} at granularity {granularity!r}: "
1187
+ "expected a DataFrame."
1188
+ )
1189
+ raise ValueError(msg) # noqa: TRY004
1190
+ frame = fetched
1191
+ frame = _ensure_rate_time_column(frame)
1192
+ if "time" not in frame.columns:
1193
+ msg = f"Rate data is missing a time column for {symbol!r}."
1194
+ raise ValueError(msg)
1195
+ closed = drop_forming_rate_bar(frame)
1196
+ if closed.empty:
1197
+ msg = (
1198
+ f"Rate data is empty for {symbol!r} at granularity {granularity!r} "
1199
+ f"with count {count}."
1200
+ )
1201
+ raise ValueError(msg)
1202
+ return closed.tail(count).reset_index(drop=True)
1203
+
1204
+
1000
1205
  @contextmanager
1001
1206
  def mt5_trading_session(
1002
1207
  config: Mt5Config | None = None,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mt5cli"
3
- version = "0.8.2"
3
+ version = "0.8.3"
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"}]
@@ -34,6 +34,7 @@ from mt5cli import (
34
34
  build_config,
35
35
  build_rate_targets,
36
36
  calculate_margin_and_volume,
37
+ calculate_positions_margin,
37
38
  call_with_normalized_errors,
38
39
  detect_format,
39
40
  drop_forming_rate_bar,
@@ -42,6 +43,7 @@ from mt5cli import (
42
43
  export_dataframe,
43
44
  export_dataframe_to_sqlite,
44
45
  fetch_latest_closed_rates,
46
+ fetch_latest_closed_rates_for_trading_client,
45
47
  granularity_name,
46
48
  is_recoverable_mt5_error,
47
49
  load_rate_data,
@@ -50,6 +52,7 @@ from mt5cli import (
50
52
  mt5_trading_session,
51
53
  normalize_dataframe,
52
54
  normalize_mt5_exception,
55
+ normalize_order_volume,
53
56
  normalize_symbol,
54
57
  normalize_symbols,
55
58
  parse_date_range,
@@ -572,6 +575,45 @@ class TestStableSdkContract:
572
575
  client.latest_rates.assert_called_once_with("EURUSD", "M1", 3, start_pos=0)
573
576
  assert list(result["close"]) == [1.0, 1.1]
574
577
 
578
+ def test_fetch_latest_closed_rates_for_trading_client_from_package_root(
579
+ self,
580
+ ) -> None:
581
+ """Trading-client closed-bar helper is importable from the stable surface."""
582
+ client = MagicMock()
583
+ client.fetch_latest_rates_as_df.return_value = pd.DataFrame(
584
+ {"time": [1, 2, 3], "close": [1.0, 1.1, 1.2]},
585
+ )
586
+
587
+ result = fetch_latest_closed_rates_for_trading_client(
588
+ client,
589
+ symbol="EURUSD",
590
+ granularity="M1",
591
+ count=2,
592
+ )
593
+
594
+ assert list(result["close"]) == [1.0, 1.1]
595
+
596
+ def test_normalize_order_volume_from_package_root(self) -> None:
597
+ """Volume normalization helper is importable from the stable surface."""
598
+ result = normalize_order_volume(
599
+ 0.25,
600
+ volume_min=0.1,
601
+ volume_max=1.0,
602
+ volume_step=0.1,
603
+ )
604
+ assert abs(result - 0.2) < 1e-9
605
+
606
+ def test_calculate_positions_margin_from_package_root(self) -> None:
607
+ """Position margin helper is importable from the stable surface."""
608
+ client = MagicMock()
609
+ client.mt5.POSITION_TYPE_BUY = 0
610
+ client.mt5.POSITION_TYPE_SELL = 1
611
+ client.mt5.ORDER_TYPE_BUY = 10
612
+ client.mt5.ORDER_TYPE_SELL = 11
613
+ client.positions_get_as_df.return_value = pd.DataFrame()
614
+
615
+ assert calculate_positions_margin(client) == 0
616
+
575
617
  def test_resolve_rate_view_name_from_package_root(self, tmp_path: Path) -> None:
576
618
  """Rate view resolution is importable and honors require_existing."""
577
619
  db_path = tmp_path / "rates.db"