pmt4api 1.0.0__tar.gz → 1.1.2__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 (38) hide show
  1. pmt4api-1.1.2/CHANGELOG.md +63 -0
  2. {pmt4api-1.0.0 → pmt4api-1.1.2}/PKG-INFO +43 -15
  3. {pmt4api-1.0.0 → pmt4api-1.1.2}/README.md +42 -14
  4. {pmt4api-1.0.0 → pmt4api-1.1.2}/src/pmt4api/__init__.py +1 -1
  5. {pmt4api-1.0.0 → pmt4api-1.1.2}/src/pmt4api/__main__.py +4 -0
  6. {pmt4api-1.0.0 → pmt4api-1.1.2}/src/pmt4api/_base.py +6 -0
  7. {pmt4api-1.0.0 → pmt4api-1.1.2}/src/pmt4api/_coverage.py +1 -0
  8. {pmt4api-1.0.0 → pmt4api-1.1.2}/src/pmt4api/_events.py +2 -0
  9. {pmt4api-1.0.0 → pmt4api-1.1.2}/src/pmt4api/_risk.py +14 -2
  10. {pmt4api-1.0.0 → pmt4api-1.1.2}/src/pmt4api/_runtime.py +24 -2
  11. {pmt4api-1.0.0 → pmt4api-1.1.2}/src/pmt4api/_session.py +2 -0
  12. {pmt4api-1.0.0 → pmt4api-1.1.2}/src/pmt4api/_streams.py +39 -6
  13. {pmt4api-1.0.0 → pmt4api-1.1.2}/src/pmt4api/_trading.py +5 -3
  14. {pmt4api-1.0.0 → pmt4api-1.1.2}/src/pmt4api/_util.py +5 -3
  15. {pmt4api-1.0.0 → pmt4api-1.1.2}/src/pmt4api/bars.py +7 -0
  16. {pmt4api-1.0.0 → pmt4api-1.1.2}/src/pmt4api/client.py +7 -4
  17. {pmt4api-1.0.0 → pmt4api-1.1.2}/src/pmt4api/docs/API.md +83 -11
  18. {pmt4api-1.0.0 → pmt4api-1.1.2}/src/pmt4api/docs/FROM-CSHARP.md +1 -0
  19. {pmt4api-1.0.0 → pmt4api-1.1.2}/src/pmt4api/errors.py +13 -1
  20. {pmt4api-1.0.0 → pmt4api-1.1.2}/src/pmt4api/models.py +78 -2
  21. pmt4api-1.0.0/CHANGELOG.md +0 -17
  22. {pmt4api-1.0.0 → pmt4api-1.1.2}/LICENSE.txt +0 -0
  23. {pmt4api-1.0.0 → pmt4api-1.1.2}/examples/bars.py +0 -0
  24. {pmt4api-1.0.0 → pmt4api-1.1.2}/examples/connect.py +0 -0
  25. {pmt4api-1.0.0 → pmt4api-1.1.2}/examples/pnl.py +0 -0
  26. {pmt4api-1.0.0 → pmt4api-1.1.2}/examples/servers.py +0 -0
  27. {pmt4api-1.0.0 → pmt4api-1.1.2}/examples/sizing.py +0 -0
  28. {pmt4api-1.0.0 → pmt4api-1.1.2}/examples/stream_async.py +0 -0
  29. {pmt4api-1.0.0 → pmt4api-1.1.2}/examples/stream_quotes.py +0 -0
  30. {pmt4api-1.0.0 → pmt4api-1.1.2}/examples/trade_demo.py +0 -0
  31. {pmt4api-1.0.0 → pmt4api-1.1.2}/hatch_build.py +0 -0
  32. {pmt4api-1.0.0 → pmt4api-1.1.2}/pyproject.toml +0 -0
  33. {pmt4api-1.0.0 → pmt4api-1.1.2}/src/pmt4api/_account.py +0 -0
  34. {pmt4api-1.0.0 → pmt4api-1.1.2}/src/pmt4api/_market.py +0 -0
  35. {pmt4api-1.0.0 → pmt4api-1.1.2}/src/pmt4api/diagnostics.py +0 -0
  36. {pmt4api-1.0.0 → pmt4api-1.1.2}/src/pmt4api/frames.py +0 -0
  37. {pmt4api-1.0.0 → pmt4api-1.1.2}/src/pmt4api/py.typed +0 -0
  38. {pmt4api-1.0.0 → pmt4api-1.1.2}/src/pmt4api/servers.py +0 -0
@@ -0,0 +1,63 @@
1
+ # Changelog — pmt4api
2
+
3
+ ## [1.1.2] — 2026-10-15
4
+
5
+ From the delta re-test of 1.1.1 (no S1 or S2). Python layer only: the cores are unchanged.
6
+
7
+ ### Fixed
8
+ - `Order` money fields (profit, swap, commission) carry no binary noise.
9
+ - README: a connect to an unreachable licence service waits several times `token_timeout` (about 44 s at the default 10: the retry, and each server of the list).
10
+
11
+ ## [1.1.1] — 2026-10-14
12
+
13
+ From the Windows re-test of 1.1.0 (13 notes, one S2). Bundles wmt4api 3.3.1.
14
+
15
+ ### Fixed
16
+ - A licence request no longer parks its connection (core `wmt4api 3.3.1`): repeated `history()` calls held about four
17
+ handles each until the next garbage collection.
18
+ - A missing .NET runtime on a **32-bit** Python says that 32-bit Python needs the 32-bit runtime.
19
+ - A quote-history timeout says how long it waited.
20
+ - README: how to read why a position closed (`[sl]` / `[tp]` in the comment), Ctrl+C during `connect()`, the licence
21
+ request retry.
22
+
23
+ ## [1.1.0] — 2026-10-13
24
+
25
+ Found by a customer-style test on Windows Server 2022 / Python 3.10 (28 findings); every one is addressed. Bundles
26
+ wmt4api 3.3.0. Additive: nothing that worked in 1.0.0 is removed.
27
+
28
+ ### Fixed
29
+ - **Two packages could not start the .NET runtime from two threads at once** (`RuntimeNotFoundError` in one of them,
30
+ every time, on Linux as well). One lock now covers every `pmt*api` package in the process.
31
+ - **`bars()` re-aggregated the whole series after every block** (MT5): a deep request was quadratic and timed out
32
+ (`D1` × 1000 took over two minutes of CPU). It counts as the blocks arrive and aggregates once; the default budget
33
+ is `bars_timeout` plus a second per two weeks of one-minute bars the request needs.
34
+ - **A closed client left a thread behind** for every client that had used callbacks or `quotes()`: the callback
35
+ thread now ends at `disconnect()` (and when the client is dropped) and returns with the next event.
36
+ - **Type checkers saw every method as `(*Any, **Any) -> Any`**: the decorator that translates errors now keeps the
37
+ signature.
38
+ - Messages: a missing .NET runtime names the runtime and where to get it; the no-key text speaks `api_key=`; a bars
39
+ timeout states its real wait; a trade refusal names its code.
40
+ - `python -m pmt4api --connect` / `pmt5api --connect` accept `host:port` in `PMT*_HOST`.
41
+ - The reference gains a meaning for every field of the main data types.
42
+ - `close()`, `modify()` and `cancel()` return the order they acted on, the same on both packages (`pmt5api`: `close()` returned an empty record and `modify()` `None`; `pmt4api`: `cancel()` returned `None`).
43
+ - The core fixes: see wmt4api 3.3.0 — **the account balance and credit now follow closes, deposits and withdrawals in the session** (they stayed at their value from connect).
44
+
45
+ ### Added
46
+ - `lots_for_risk(..., side="buy" | "sell")`: a stop on the wrong side of the entry is refused instead of being sized by its distance.
47
+ - `reconnect_max_backoff` (seconds, default 30): the longest delay between reconnect attempts.
48
+
49
+ ## [1.0.0] — 2026-10-12
50
+ First release: the complete Python interface to wmt4api 3.2.0 (the MetaTrader 4 client library).
51
+
52
+ - Every public member and type of the C# `MT4API` / `OrderClient` classes is mapped to a Python counterpart, or has a
53
+ stated reason for not having one (`pmt4api.COVERAGE`, `pmt4api.TYPES`; a test fails when the core gains an
54
+ unaccounted member).
55
+ - Market data (symbols, quotes, bars on the nine MT4 timeframes with paging), account, sizing and risk, order entry
56
+ (market, pending, modify, partial close, close-by, close-all-by, cancel; synchronous, with request ids and progress
57
+ events, and awaitable), history, summary and equity curve.
58
+ - Callbacks, sync and async iterators, one ordered dispatcher thread per client, with back-pressure accounting.
59
+ - A small error hierarchy; the MT4 trade-return codes by name; requotes carry the new prices.
60
+ - `auto_reconnect`, `servers=` failover, a silence watchdog, `reconnect()`; `disconnect()` is final.
61
+ - Refusals before anything is sent: a magic number the core cannot transmit, a volume the wire cannot carry, a
62
+ comment that would not read back.
63
+ - Wheels: Linux x86-64 with the core and a .NET runtime bundled, and a pure wheel that carries the core.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pmt4api
3
- Version: 1.0.0
3
+ Version: 1.1.2
4
4
  Summary: Complete Python interface to the wmt4api MetaTrader 4 client library (market data, account, sizing, order entry, events).
5
5
  Project-URL: Homepage, https://mtapi.one
6
6
  Author: Sen Trading
@@ -39,19 +39,33 @@ both reads alike.
39
39
 
40
40
  **Parity with the C# library is measured, not claimed:** `pmt4api.COVERAGE` maps every public member of the C#
41
41
  `MT4API` and `OrderClient` classes to its Python counterpart (or states why it has none), and the test suite fails
42
- if the core gains a member that is not accounted for. [`docs/FROM-CSHARP.md`](src/pmt4api/docs/FROM-CSHARP.md) is
43
- that table; [`docs/API.md`](src/pmt4api/docs/API.md) is the reference.
42
+ if the core gains a member that is not accounted for. `pmt4api/docs/FROM-CSHARP.md` (inside the installed package) is
43
+ that table; `pmt4api/docs/API.md` (inside the installed package; `python -m pmt4api` prints its folder) is the reference.
44
44
 
45
45
  ## Install
46
46
 
47
- The **Linux x86-64 wheel** carries everything — the core and a .NET runtime — so there is nothing else to install:
48
-
49
47
  ```
50
- pip install pmt4api-<version>-py3-none-manylinux_2_28_x86_64.whl
48
+ pip install pmt4api
51
49
  ```
52
50
 
53
- On any other platform use the `py3-none-any` wheel, which carries the core and needs a .NET 8 (or later) runtime on
54
- the machine (`dotnet --list-runtimes` should list `Microsoft.NETCore.App`).
51
+ pip chooses the wheel for your platform. The **Linux x86-64 wheel** carries everything — the core and a .NET runtime —
52
+ so there is nothing else to install. On any other platform the `py3-none-any` wheel carries the core and needs a .NET 8
53
+ (or later) runtime on the machine (`dotnet --list-runtimes` should list `Microsoft.NETCore.App`). From a downloaded
54
+ file: `pip install pmt4api-<version>-py3-none-<platform>.whl`.
55
+
56
+ **Windows (x64).** Install the .NET 8 runtime once (`winget install Microsoft.DotNet.Runtime.8`, or the installer from
57
+ https://dotnet.microsoft.com/download/dotnet/8.0), then `pip install pmt4api`. Check it with `python -m pmt4api`: it prints
58
+ the versions, finds the runtime and constructs a client offline. Set the key in the shell that runs your program —
59
+ PowerShell: `$env:WMT4API_KEY = "wk_..."`, cmd: `set WMT4API_KEY=wk_...`. A string the broker sent can hold characters your
60
+ console's code page cannot print: set `PYTHONUTF8=1` (or run `chcp 65001`) to avoid a `UnicodeEncodeError`.
61
+
62
+ **Windows (x64).** Use the `py3-none-any` wheel from PyPI — `pip install pmt4api` picks it — and install the .NET 8
63
+ runtime once (`winget install Microsoft.DotNet.Runtime.8`, or the installer from
64
+ https://dotnet.microsoft.com/download/dotnet/8.0); `dotnet --list-runtimes` must list `Microsoft.NETCore.App 8.x` or
65
+ later. Check the install with `python -m pmt4api`: it prints the versions, finds the runtime and constructs a client
66
+ offline. Set the key in the shell that runs your program — PowerShell: `$env:WMT4API_KEY = "wk_..."`, cmd:
67
+ `set WMT4API_KEY=wk_...`. A library string can hold characters your console code page cannot print: set
68
+ `PYTHONUTF8=1` (or run `chcp 65001`) to avoid a `UnicodeEncodeError` when you print what the broker sent.
55
69
 
56
70
  You also need your **API key** (`wk_…`, the same key you use with wmt4api):
57
71
 
@@ -104,6 +118,10 @@ Timeframes: `M1 M5 M15 M30 H1 H4 D1 W1 MN1` — the nine the MT4 server serves;
104
118
  (M1 is typically a few days), so a long request returns what exists; one request carries at most 32 767 bars and
105
119
  longer requests page backwards for you. `volume` is the tick volume.
106
120
 
121
+ **MT4 and MT5 differ here.** The MT4 package returns completed bars only and excludes a bar that opens at `end=`; on
122
+ MT5 `end=` includes the bar stamped exactly `end` and the bar still forming. Code that runs on both has to drop the
123
+ forming MT5 bar itself.
124
+
107
125
  ### Live quotes
108
126
 
109
127
  ```python
@@ -146,14 +164,16 @@ drop the oldest item when the consumer falls behind, and detach on `close()` / `
146
164
  ## Trading
147
165
 
148
166
  ```python
167
+ from datetime import timedelta
168
+
149
169
  o = c.buy("EURUSDm", 0.10, sl=1.0950, tp=1.1100, comment="bot-1") # market order at the ask; sell() at the bid
150
- c.modify(o.ticket, sl=1.0970) # change stops; what you leave out keeps its value
151
- c.close(o.ticket, 0.05) # close half; close(ticket) closes it all
170
+ c.modify(o.ticket, sl=1.0970) # change stops (returns the order as the server holds it)
171
+ c.close(o.ticket, 0.05) # close half (returns the closing record); close(ticket) closes it all
152
172
 
153
173
  p = c.place_order("EURUSDm", "buy_stop", 0.10, 1.1200, sl=1.1150,
154
174
  expiration=timedelta(days=2)) # also buy_limit sell_limit sell_stop; a datetime works too
155
175
  c.modify(p.ticket, price=1.1210) # a pending order's price; its expiration is kept
156
- c.cancel(p.ticket)
176
+ c.cancel(p.ticket) # returns the order that was deleted
157
177
 
158
178
  c.close_by(a.ticket, b.ticket) # close one position against an opposite one (hedged accounts)
159
179
  c.close_all_by("EURUSDm") # ... every opposite pair of the symbol
@@ -192,7 +212,7 @@ remainder of the position may carry a new ticket: read `positions()`.
192
212
  ## Sizing and risk
193
213
 
194
214
  ```python
195
- r = c.lots_for_risk("EURUSDm", entry_price=1.1000, stop_price=1.0950, risk_amount=50.0)
215
+ r = c.lots_for_risk("EURUSDm", entry_price=1.1000, stop_price=1.0950, risk_amount=50.0, side="buy")
196
216
  for w in r.warnings:
197
217
  print("warning:", w) # ok can still be True with a caveat (the broker's minimum volume risks more)
198
218
  if r.ok:
@@ -251,8 +271,8 @@ restored, cached snapshots are not. `disconnect()` is final: it also stops a rec
251
271
  | API key | `api_key=` | `WMT4API_KEY` (then `WMTAPI_KEY`) |
252
272
  | Licence service URL (leave at its default) | `token_url=` | `WMT4API_TOKEN_URL` |
253
273
  | Core library (only if you do not use a wheel that carries it) | `dll_path=` | `WMT4API_DLL` |
254
- | Timeouts (seconds, each > 0) | `connect_timeout` (one attempt on one server), `token_timeout`, `trade_timeout` (≥ 1), `quote_timeout` / `history_timeout` / `bars_timeout` (the defaults of `quote`, `history`, `bars`), `no_message_timeout` | — |
255
- | Reconnect | `auto_reconnect`, `reconnect_backoff`, `reconnect_max_attempts`, `servers=` | — |
274
+ | Timeouts (seconds, each > 0) | `connect_timeout` (one attempt on one server; default 6), `token_timeout` (10), `trade_timeout` (30; ≥ 1), `quote_timeout` (5) / `history_timeout` (30) / `bars_timeout` (30) — the defaults of `quote`, `history`, `bars`, `no_message_timeout` (30; ≥ 10, or 0 for off) | — |
275
+ | Reconnect | `auto_reconnect`, `reconnect_backoff` (3, doubling up to `reconnect_max_backoff`, 30), `reconnect_max_attempts` (0 = no limit), `servers=` | — |
256
276
  | Orders | `placed_type=` (`manual`, `expert`, `dealer` …), `comment_encoding=` (default `latin-1`) | — |
257
277
  | .NET runtime location | — | `DOTNET_ROOT` |
258
278
 
@@ -272,7 +292,15 @@ pmt4api.enable_core_logging() # the core's own messages -> logging.getL
272
292
 
273
293
  `load_servers_ini(path_or_bytes)` / `save_servers_ini(records)` read and write a terminal's `servers.ini`.
274
294
  `python -m pmt4api` prints what is installed and whether it works here (`--connect` also reads an account, using
275
- `PMT4_LOGIN`, `PMT4_PASSWORD`, `PMT4_HOST`); the output holds no secrets.
295
+ `PMT4_LOGIN`, `PMT4_PASSWORD`, `PMT4_HOST` — a bare host, or `host:port`, or the port in `PMT4_PORT`); the output holds
296
+ no secrets.
297
+
298
+ ## What a closed order says about why
299
+
300
+ An MT4 server pushes the same `PositionClose` update for a stop-loss, a take-profit and a close you asked for; the
301
+ reason is in the order's `comment`, which the server suffixes with `[sl]` or `[tp]` for a stop or a take-profit hit.
302
+ Pressing Ctrl+C while `connect()` is dialling is honoured when the dial ends (up to `connect_timeout`), not at once.
303
+ `token_timeout` bounds one licence request; a failed request is tried again and a connect tries each server of the list, so a connect to an unreachable licence service can wait several times that (about 44 s at the default 10).
276
304
 
277
305
  ## Errors
278
306
 
@@ -12,19 +12,33 @@ both reads alike.
12
12
 
13
13
  **Parity with the C# library is measured, not claimed:** `pmt4api.COVERAGE` maps every public member of the C#
14
14
  `MT4API` and `OrderClient` classes to its Python counterpart (or states why it has none), and the test suite fails
15
- if the core gains a member that is not accounted for. [`docs/FROM-CSHARP.md`](src/pmt4api/docs/FROM-CSHARP.md) is
16
- that table; [`docs/API.md`](src/pmt4api/docs/API.md) is the reference.
15
+ if the core gains a member that is not accounted for. `pmt4api/docs/FROM-CSHARP.md` (inside the installed package) is
16
+ that table; `pmt4api/docs/API.md` (inside the installed package; `python -m pmt4api` prints its folder) is the reference.
17
17
 
18
18
  ## Install
19
19
 
20
- The **Linux x86-64 wheel** carries everything — the core and a .NET runtime — so there is nothing else to install:
21
-
22
20
  ```
23
- pip install pmt4api-<version>-py3-none-manylinux_2_28_x86_64.whl
21
+ pip install pmt4api
24
22
  ```
25
23
 
26
- On any other platform use the `py3-none-any` wheel, which carries the core and needs a .NET 8 (or later) runtime on
27
- the machine (`dotnet --list-runtimes` should list `Microsoft.NETCore.App`).
24
+ pip chooses the wheel for your platform. The **Linux x86-64 wheel** carries everything — the core and a .NET runtime —
25
+ so there is nothing else to install. On any other platform the `py3-none-any` wheel carries the core and needs a .NET 8
26
+ (or later) runtime on the machine (`dotnet --list-runtimes` should list `Microsoft.NETCore.App`). From a downloaded
27
+ file: `pip install pmt4api-<version>-py3-none-<platform>.whl`.
28
+
29
+ **Windows (x64).** Install the .NET 8 runtime once (`winget install Microsoft.DotNet.Runtime.8`, or the installer from
30
+ https://dotnet.microsoft.com/download/dotnet/8.0), then `pip install pmt4api`. Check it with `python -m pmt4api`: it prints
31
+ the versions, finds the runtime and constructs a client offline. Set the key in the shell that runs your program —
32
+ PowerShell: `$env:WMT4API_KEY = "wk_..."`, cmd: `set WMT4API_KEY=wk_...`. A string the broker sent can hold characters your
33
+ console's code page cannot print: set `PYTHONUTF8=1` (or run `chcp 65001`) to avoid a `UnicodeEncodeError`.
34
+
35
+ **Windows (x64).** Use the `py3-none-any` wheel from PyPI — `pip install pmt4api` picks it — and install the .NET 8
36
+ runtime once (`winget install Microsoft.DotNet.Runtime.8`, or the installer from
37
+ https://dotnet.microsoft.com/download/dotnet/8.0); `dotnet --list-runtimes` must list `Microsoft.NETCore.App 8.x` or
38
+ later. Check the install with `python -m pmt4api`: it prints the versions, finds the runtime and constructs a client
39
+ offline. Set the key in the shell that runs your program — PowerShell: `$env:WMT4API_KEY = "wk_..."`, cmd:
40
+ `set WMT4API_KEY=wk_...`. A library string can hold characters your console code page cannot print: set
41
+ `PYTHONUTF8=1` (or run `chcp 65001`) to avoid a `UnicodeEncodeError` when you print what the broker sent.
28
42
 
29
43
  You also need your **API key** (`wk_…`, the same key you use with wmt4api):
30
44
 
@@ -77,6 +91,10 @@ Timeframes: `M1 M5 M15 M30 H1 H4 D1 W1 MN1` — the nine the MT4 server serves;
77
91
  (M1 is typically a few days), so a long request returns what exists; one request carries at most 32 767 bars and
78
92
  longer requests page backwards for you. `volume` is the tick volume.
79
93
 
94
+ **MT4 and MT5 differ here.** The MT4 package returns completed bars only and excludes a bar that opens at `end=`; on
95
+ MT5 `end=` includes the bar stamped exactly `end` and the bar still forming. Code that runs on both has to drop the
96
+ forming MT5 bar itself.
97
+
80
98
  ### Live quotes
81
99
 
82
100
  ```python
@@ -119,14 +137,16 @@ drop the oldest item when the consumer falls behind, and detach on `close()` / `
119
137
  ## Trading
120
138
 
121
139
  ```python
140
+ from datetime import timedelta
141
+
122
142
  o = c.buy("EURUSDm", 0.10, sl=1.0950, tp=1.1100, comment="bot-1") # market order at the ask; sell() at the bid
123
- c.modify(o.ticket, sl=1.0970) # change stops; what you leave out keeps its value
124
- c.close(o.ticket, 0.05) # close half; close(ticket) closes it all
143
+ c.modify(o.ticket, sl=1.0970) # change stops (returns the order as the server holds it)
144
+ c.close(o.ticket, 0.05) # close half (returns the closing record); close(ticket) closes it all
125
145
 
126
146
  p = c.place_order("EURUSDm", "buy_stop", 0.10, 1.1200, sl=1.1150,
127
147
  expiration=timedelta(days=2)) # also buy_limit sell_limit sell_stop; a datetime works too
128
148
  c.modify(p.ticket, price=1.1210) # a pending order's price; its expiration is kept
129
- c.cancel(p.ticket)
149
+ c.cancel(p.ticket) # returns the order that was deleted
130
150
 
131
151
  c.close_by(a.ticket, b.ticket) # close one position against an opposite one (hedged accounts)
132
152
  c.close_all_by("EURUSDm") # ... every opposite pair of the symbol
@@ -165,7 +185,7 @@ remainder of the position may carry a new ticket: read `positions()`.
165
185
  ## Sizing and risk
166
186
 
167
187
  ```python
168
- r = c.lots_for_risk("EURUSDm", entry_price=1.1000, stop_price=1.0950, risk_amount=50.0)
188
+ r = c.lots_for_risk("EURUSDm", entry_price=1.1000, stop_price=1.0950, risk_amount=50.0, side="buy")
169
189
  for w in r.warnings:
170
190
  print("warning:", w) # ok can still be True with a caveat (the broker's minimum volume risks more)
171
191
  if r.ok:
@@ -224,8 +244,8 @@ restored, cached snapshots are not. `disconnect()` is final: it also stops a rec
224
244
  | API key | `api_key=` | `WMT4API_KEY` (then `WMTAPI_KEY`) |
225
245
  | Licence service URL (leave at its default) | `token_url=` | `WMT4API_TOKEN_URL` |
226
246
  | Core library (only if you do not use a wheel that carries it) | `dll_path=` | `WMT4API_DLL` |
227
- | Timeouts (seconds, each > 0) | `connect_timeout` (one attempt on one server), `token_timeout`, `trade_timeout` (≥ 1), `quote_timeout` / `history_timeout` / `bars_timeout` (the defaults of `quote`, `history`, `bars`), `no_message_timeout` | — |
228
- | Reconnect | `auto_reconnect`, `reconnect_backoff`, `reconnect_max_attempts`, `servers=` | — |
247
+ | Timeouts (seconds, each > 0) | `connect_timeout` (one attempt on one server; default 6), `token_timeout` (10), `trade_timeout` (30; ≥ 1), `quote_timeout` (5) / `history_timeout` (30) / `bars_timeout` (30) — the defaults of `quote`, `history`, `bars`, `no_message_timeout` (30; ≥ 10, or 0 for off) | — |
248
+ | Reconnect | `auto_reconnect`, `reconnect_backoff` (3, doubling up to `reconnect_max_backoff`, 30), `reconnect_max_attempts` (0 = no limit), `servers=` | — |
229
249
  | Orders | `placed_type=` (`manual`, `expert`, `dealer` …), `comment_encoding=` (default `latin-1`) | — |
230
250
  | .NET runtime location | — | `DOTNET_ROOT` |
231
251
 
@@ -245,7 +265,15 @@ pmt4api.enable_core_logging() # the core's own messages -> logging.getL
245
265
 
246
266
  `load_servers_ini(path_or_bytes)` / `save_servers_ini(records)` read and write a terminal's `servers.ini`.
247
267
  `python -m pmt4api` prints what is installed and whether it works here (`--connect` also reads an account, using
248
- `PMT4_LOGIN`, `PMT4_PASSWORD`, `PMT4_HOST`); the output holds no secrets.
268
+ `PMT4_LOGIN`, `PMT4_PASSWORD`, `PMT4_HOST` — a bare host, or `host:port`, or the port in `PMT4_PORT`); the output holds
269
+ no secrets.
270
+
271
+ ## What a closed order says about why
272
+
273
+ An MT4 server pushes the same `PositionClose` update for a stop-loss, a take-profit and a close you asked for; the
274
+ reason is in the order's `comment`, which the server suffixes with `[sl]` or `[tp]` for a stop or a take-profit hit.
275
+ Pressing Ctrl+C while `connect()` is dialling is honoured when the dial ends (up to `connect_timeout`), not at once.
276
+ `token_timeout` bounds one licence request; a failed request is tried again and a connect tries each server of the list, so a connect to an unreachable licence service can wait several times that (about 44 s at the default 10).
249
277
 
250
278
  ## Errors
251
279
 
@@ -17,7 +17,7 @@ from .models import (AccountInfo, AccountSummary, ConnectEvent, EquityPoint, Lot
17
17
  OrderUpdate, Quote, ServerRecord, SymbolGroup, SymbolInfo)
18
18
  from .servers import load_servers_ini, save_servers_ini
19
19
 
20
- __version__ = "1.0.0"
20
+ __version__ = "1.1.2"
21
21
 
22
22
  __all__ = [
23
23
  "Client",
@@ -62,6 +62,10 @@ def main(argv: Optional[List[str]] = None) -> int:
62
62
  print("connect SKIPPED: environment variable %s is not set" % e)
63
63
  return 0
64
64
  port = int(os.environ.get("PMT4_PORT", "443"))
65
+ if ":" in host: # an account file lists "host:port"
66
+ host, _, given = host.rpartition(":")
67
+ if given.isdigit():
68
+ port = int(given)
65
69
  try:
66
70
  with Client(login, password, host, port, connect_timeout=45) as live:
67
71
  a = live.account()
@@ -11,6 +11,12 @@ class _Base:
11
11
  _api: Any # the core's client object (MT5API / MT4API)
12
12
  _core: Any # the core's namespace (types and enums)
13
13
 
14
+ def _stop_events(self) -> None:
15
+ """End the callback thread (it restarts by itself with the next event): a closed client keeps no thread."""
16
+ d = getattr(self, "_dispatch", None)
17
+ if d is not None:
18
+ d.stop()
19
+
14
20
  def _require_connected(self) -> None:
15
21
  if not self._api.Connected:
16
22
  raise NotConnectedError("not connected; call connect() first")
@@ -47,6 +47,7 @@ COVERAGE: Dict[str, str] = {
47
47
  "TradeTimeout": "Client(trade_timeout)",
48
48
  "AutoReconnect": "Client(auto_reconnect)",
49
49
  "ReconnectBackoffMs": "Client(reconnect_backoff)",
50
+ "ReconnectMaxBackoffMs": "Client(reconnect_max_backoff)",
50
51
  "ReconnectMaxAttempts": "Client(reconnect_max_attempts)",
51
52
  "Encoding": "Client(comment_encoding)",
52
53
  "PlacedType": "Client(placed_type)",
@@ -7,6 +7,7 @@ to a callback that fell too far behind.
7
7
  from __future__ import annotations
8
8
 
9
9
  import logging
10
+ import weakref
10
11
  from typing import Any, AsyncIterator, Callable, Iterator, Optional
11
12
 
12
13
  from ._base import _Base
@@ -59,6 +60,7 @@ class _EventsMixin(_Base):
59
60
  d = getattr(self, "_dispatch", None)
60
61
  if d is None:
61
62
  d = self._dispatch = _Dispatcher()
63
+ weakref.finalize(self, d.stop) # a client that is dropped without disconnect() leaves no thread
62
64
  return d
63
65
 
64
66
  def _wanted(self, symbols: Any) -> Any:
@@ -15,16 +15,28 @@ class _RiskMixin(_Base):
15
15
  def lots_for_risk(self, symbol: str, entry_price: float, stop_price: float, risk_amount: float, *,
16
16
  lot_step: Optional[float] = None, min_lot: Optional[float] = None,
17
17
  max_ratio: Optional[float] = None, max_equity_share: Optional[float] = None,
18
- max_lot: Optional[float] = None) -> LotSizing:
18
+ max_lot: Optional[float] = None, side: Optional[str] = None) -> LotSizing:
19
19
  """The volume that loses ``risk_amount`` (account currency) if ``stop_price`` is hit, with the evidence.
20
20
 
21
21
  The value of a price unit comes from :meth:`tick_value` and the broker's step / minimum / maximum volume from
22
22
  the account's own group (pass one to override it); a size that cannot be justified is refused, not guessed.
23
23
  The volume is rounded **down** to the step. Check ``ok``: when false, ``lots`` is 0 and ``reason`` says which
24
- check failed. Amounts are in the currency the account reports (no cent-account scaling is applied: check the
24
+ check failed. Give ``side`` (``"buy"`` or ``"sell"``) and a stop on the wrong side of the entry is refused instead
25
+ of sized by its distance. Amounts are in the currency the account reports (no cent-account scaling is applied: check the
25
26
  first result against your terminal).
26
27
  """
27
28
  self._require_connected()
29
+ if side is not None:
30
+ key = str(side).strip().lower()
31
+ if key not in ("buy", "sell"):
32
+ raise ValueError("side must be 'buy' or 'sell'")
33
+ wrong = (stop_price >= entry_price) if key == "buy" else (stop_price <= entry_price)
34
+ if wrong: # a stop on the wrong side of the entry is not a stop
35
+ return LotSizing(ok=False, lots=0.0, expected_loss_at_stop=0.0, risk_requested=float(risk_amount),
36
+ minimum_trade_cost=0.0, ratio=0.0,
37
+ reason="a %s needs its stop %s the entry price (entry %g, stop %g)"
38
+ % (key, "below" if key == "buy" else "above", entry_price, stop_price),
39
+ warnings=())
28
40
  kwargs = {}
29
41
  for key, value in (("lotStep", lot_step), ("minLot", min_lot), ("maxRatio", max_ratio),
30
42
  ("maxEquityShare", max_equity_share), ("maxLot", max_lot)):
@@ -5,6 +5,7 @@ import glob
5
5
  import importlib
6
6
  import os
7
7
  import shutil
8
+ import struct
8
9
  import sys
9
10
  import threading
10
11
  from pathlib import Path
@@ -17,8 +18,25 @@ ENV_DLL = "WMT4API_DLL"
17
18
 
18
19
  _NAMESPACE = "wmt4api" # the .NET namespace of the core; NOT the name of this Python package
19
20
  _PKG = Path(__file__).resolve().parent
21
+ _BITNESS_HINT = (" This is a 32-bit Python: it needs the 32-bit (x86) .NET runtime, and 64-bit Python with the 64-bit "
22
+ "runtime is the supported combination.")
20
23
  _BUNDLED_RUNTIME = _PKG / "_dotnet" # present in the platform wheels: host/fxr + shared/Microsoft.NETCore.App
21
- _lock = threading.Lock()
24
+
25
+
26
+ def _process_lock() -> "threading.RLock":
27
+ """ONE lock for every pmt*api package in the process. Each package has its own copy of this module, but the .NET
28
+ runtime is one per process: two packages starting it from two threads at once raced inside pythonnet. The lock
29
+ lives in ``sys.modules`` under a fixed name, which both copies see."""
30
+ holder = sys.modules.get("_pmt_runtime_lock")
31
+ if holder is None:
32
+ import types
33
+ fresh = types.ModuleType("_pmt_runtime_lock")
34
+ fresh.lock = threading.RLock() # type: ignore[attr-defined]
35
+ holder = sys.modules.setdefault("_pmt_runtime_lock", fresh)
36
+ return holder.lock # type: ignore[attr-defined]
37
+
38
+
39
+ _lock = _process_lock()
22
40
  _core = None
23
41
  _dll: Optional[Path] = None
24
42
 
@@ -137,7 +155,11 @@ def load(dll_path=None):
137
155
  _load("coreclr")
138
156
  import clr # noqa: F401 (provided by pythonnet)
139
157
  except Exception as e: # noqa: BLE001 — anything here means the runtime could not start
140
- raise RuntimeNotFoundError("could not start the .NET runtime: %s" % e) from e
158
+ raise RuntimeNotFoundError(
159
+ "could not start the .NET runtime. The core needs the .NET 8 (or later) runtime, Microsoft.NETCore.App: "
160
+ "install it from https://dotnet.microsoft.com/download/dotnet/8.0 (`dotnet --list-runtimes` lists what "
161
+ "this machine has) or point DOTNET_ROOT at a folder that holds it.%s Detail: %s: %s"
162
+ % (_BITNESS_HINT if struct.calcsize("P") == 4 else "", type(e).__name__, str(e)[:240])) from e
141
163
  import clr as _clr
142
164
  from System.Reflection import Assembly # available once the runtime is up
143
165
  try:
@@ -53,6 +53,8 @@ class _SessionMixin(_Base):
53
53
  except Exception as exc: # noqa: BLE001
54
54
  if type(exc).__name__ != "NullReferenceException": # never connected: nothing to close
55
55
  raise
56
+ finally:
57
+ self._stop_events() # the callback thread ends once what is queued is delivered
56
58
 
57
59
  @property
58
60
  def connected(self) -> bool:
@@ -26,21 +26,35 @@ log = logging.getLogger(_PACKAGE)
26
26
  EVENT_QUEUE = 20000
27
27
 
28
28
 
29
+ _STOP = object() # put on the dispatcher's queue: end the thread once everything queued before it is delivered
30
+
31
+
29
32
  class _Dispatcher:
30
- """One thread that runs a client's callbacks in arrival order."""
33
+ """One thread that runs a client's callbacks in arrival order.
34
+
35
+ The thread exists only while there is something to deliver or the client is connected: it is started by the first
36
+ event, ended by :meth:`stop` (a client's ``disconnect()``, or the client being garbage-collected), and started again
37
+ by the next event. A client that is dropped therefore leaves no thread behind."""
31
38
 
32
39
  def __init__(self) -> None:
33
- self._q: "queue.Queue[Tuple[Callable[[Any], None], Any, str]]" = queue.Queue(maxsize=EVENT_QUEUE)
40
+ self._q: "queue.Queue[Any]" = queue.Queue(maxsize=EVENT_QUEUE)
34
41
  self.dropped = 0
35
42
  self._count_lock = threading.Lock()
36
- self._thread = threading.Thread(target=self._run, name=_PACKAGE + "-events", daemon=True)
37
- self._thread.start()
43
+ self._thread_lock = threading.Lock()
44
+ self._thread: Optional[threading.Thread] = None
45
+
46
+ def _ensure_thread(self) -> None:
47
+ with self._thread_lock:
48
+ if self._thread is None:
49
+ t = threading.Thread(target=self._run, name=_PACKAGE + "-events", daemon=True)
50
+ self._thread = t
51
+ t.start()
38
52
 
39
53
  def submit(self, fn: Callable[[Any], None], arg: Any, event: str) -> None:
40
54
  while True:
41
55
  try:
42
56
  self._q.put_nowait((fn, arg, event))
43
- return
57
+ break
44
58
  except queue.Full:
45
59
  try:
46
60
  self._q.get_nowait()
@@ -51,10 +65,29 @@ class _Dispatcher:
51
65
  total = self.dropped
52
66
  if total == 1 or total % 1000 == 0:
53
67
  log.warning("event queue full: %d event(s) dropped so far; a callback is too slow", total)
68
+ self._ensure_thread()
69
+
70
+ def stop(self) -> None:
71
+ """End the thread after it has delivered what is queued. Safe to call at any time and more than once; events
72
+ that arrive afterwards start a new thread."""
73
+ with self._thread_lock:
74
+ running = self._thread is not None
75
+ if running:
76
+ try:
77
+ self._q.put_nowait(_STOP)
78
+ except queue.Full: # a full queue means the thread is busy: it ends when it next finds the queue empty
79
+ pass
54
80
 
55
81
  def _run(self) -> None:
56
82
  while True:
57
- fn, arg, event = self._q.get()
83
+ item = self._q.get()
84
+ if item is _STOP:
85
+ with self._thread_lock:
86
+ if self._q.empty(): # nothing arrived behind the marker: the thread can end
87
+ self._thread = None
88
+ return
89
+ continue # events were queued after stop(): keep going, they need delivering
90
+ fn, arg, event = item
58
91
  try:
59
92
  fn(arg)
60
93
  except Exception: # noqa: BLE001 — keep the dispatcher alive
@@ -243,11 +243,13 @@ class _TradingMixin(_Base):
243
243
  return self._confirmed(self._api.OrderClose(str(o.Symbol), int(o.Ticket), amount, px, int(slippage)))
244
244
 
245
245
  @translated
246
- def cancel(self, ticket: int) -> None:
247
- """Delete a pending order."""
246
+ def cancel(self, ticket: int) -> Order:
247
+ """Delete a pending order. Returns the order that was deleted, as it stood."""
248
248
  self._require_connected()
249
249
  o = self._open_pending(ticket)
250
+ gone = Order._from_core(o)
250
251
  self._api.OrderCancel(int(o.Ticket), o.Type, str(o.Symbol), float(o.Lots), float(o.OpenPrice))
252
+ return gone
251
253
 
252
254
  @translated
253
255
  def close_by(self, ticket: int, opposite_ticket: int) -> None:
@@ -371,7 +373,7 @@ class _TradingMixin(_Base):
371
373
  """Awaitable :meth:`close`."""
372
374
  return await self._run(self.close, *args, **kwargs)
373
375
 
374
- async def acancel(self, *args: Any, **kwargs: Any) -> None:
376
+ async def acancel(self, *args: Any, **kwargs: Any) -> Order:
375
377
  """Awaitable :meth:`cancel`."""
376
378
  return await self._run(self.cancel, *args, **kwargs)
377
379
 
@@ -4,12 +4,14 @@ from __future__ import annotations
4
4
  import functools
5
5
  import math
6
6
  from datetime import datetime
7
- from typing import Any, Callable, Optional
7
+ from typing import Any, Callable, Optional, TypeVar, cast
8
8
 
9
9
  from .errors import WmtError, translate
10
10
 
11
+ F = TypeVar("F", bound=Callable[..., Any])
11
12
 
12
- def translated(fn: Callable) -> Callable:
13
+
14
+ def translated(fn: F) -> F:
13
15
  """Re-raise the core's exceptions as the pmt4api hierarchy (the core exception stays as ``__cause__``)."""
14
16
  @functools.wraps(fn)
15
17
  def wrapper(*args: Any, **kwargs: Any) -> Any:
@@ -22,7 +24,7 @@ def translated(fn: Callable) -> Callable:
22
24
  if mapped is None:
23
25
  raise
24
26
  raise mapped from exc
25
- return wrapper
27
+ return cast(F, wrapper) # the same signature, so type checkers and IDEs keep the real parameters
26
28
 
27
29
 
28
30
  def to_core_time(value: datetime) -> Any:
@@ -60,3 +60,10 @@ class QuoteHistoryEvent:
60
60
  @classmethod
61
61
  def _from_core(cls, e: Any) -> "QuoteHistoryEvent":
62
62
  return cls(symbol=str(e.Symbol), timeframe=str(e.Timeframe), bars=tuple(Bar._from_core(b) for b in (e.Bars or ())))
63
+
64
+
65
+ Bar.FIELD_DOC = {
66
+ "time": "the start of the period, broker server time",
67
+ "open": "first price of the period", "high": "highest price", "low": "lowest price", "close": "last price",
68
+ "volume": "the number of ticks in the period (MT4 reports tick volume only)",
69
+ }
@@ -68,11 +68,13 @@ class Client(_SessionMixin, _MarketMixin, _AccountMixin, _RiskMixin, _TradingMix
68
68
  seconds, so a longer silence means the link stopped carrying traffic). Every other timeout must be positive; the
69
69
  quote, history and bars timeouts are the defaults of :meth:`quote`, :meth:`history` and :meth:`bars`. The
70
70
  handshake with the broker has its own fixed 30 s limit, whatever ``connect_timeout`` says (that one bounds the
71
- TCP dial of each server).
71
+ TCP dial of each server). **Defaults:** ``connect_timeout`` 6 s, ``token_timeout`` 10 s, ``trade_timeout`` 30 s,
72
+ ``quote_timeout`` 5 s, ``history_timeout`` 30 s, ``bars_timeout`` 30 s, ``no_message_timeout`` 30 s,
73
+ ``reconnect_backoff`` 3 s (it doubles up to ``reconnect_max_backoff``, 30 s).
72
74
 
73
75
  Reconnecting: ``auto_reconnect=True`` turns on the core's watchdog (exponential backoff, failover across the
74
76
  primary server, ``servers`` and the broker's published list, re-subscription); tune it with ``reconnect_backoff``
75
- (seconds) and ``reconnect_max_attempts`` (0 = until reconnected or ``disconnect()``). Do not poll ``connected``
77
+ and ``reconnect_max_backoff`` (seconds) and ``reconnect_max_attempts`` (0 = until reconnected or ``disconnect()``). Do not poll ``connected``
76
78
  and call ``connect()`` in a loop. ``servers`` are extra ``(host, port)`` access servers to fall back to.
77
79
 
78
80
  ``comment_encoding`` is the codepage order comments are written in (default ``latin-1``); ``placed_type``
@@ -88,7 +90,7 @@ class Client(_SessionMixin, _MarketMixin, _AccountMixin, _RiskMixin, _TradingMix
88
90
  history_timeout: Optional[float] = None, bars_timeout: Optional[float] = None,
89
91
  no_message_timeout: Optional[float] = None,
90
92
  auto_reconnect: bool = False, reconnect_backoff: Optional[float] = None,
91
- reconnect_max_attempts: Optional[int] = None,
93
+ reconnect_max_backoff: Optional[float] = None, reconnect_max_attempts: Optional[int] = None,
92
94
  servers: Optional[Sequence[Tuple[str, int]]] = None, comment_encoding: str = "latin-1",
93
95
  placed_type: Optional[str] = None):
94
96
  core = _runtime.load(dll_path)
@@ -108,7 +110,8 @@ class Client(_SessionMixin, _MarketMixin, _AccountMixin, _RiskMixin, _TradingMix
108
110
  api.TokenServiceUrl = token_url
109
111
  for attr, value in (("ConnectTimeout", connect_timeout), ("TokenServiceTimeout", token_timeout),
110
112
  ("GetQuoteTimeoutMs", quote_timeout), ("DownloadOrderHistoryTimeoutMs", history_timeout),
111
- ("DownloadQuoteHistoryTimeout", bars_timeout), ("ReconnectBackoffMs", reconnect_backoff)):
113
+ ("DownloadQuoteHistoryTimeout", bars_timeout), ("ReconnectBackoffMs", reconnect_backoff),
114
+ ("ReconnectMaxBackoffMs", reconnect_max_backoff)):
112
115
  ms = seconds_to_ms(value, attr)
113
116
  if ms is not None:
114
117
  setattr(api, attr, ms)
@@ -5,7 +5,7 @@ Generated from the code (`tools/gen_docs.py`); do not edit by hand. Times are na
5
5
  ## Constructing a client
6
6
 
7
7
  ```python
8
- Client(login, password, host, port=443, *, api_key=None, token_url=None, dll_path=None, connect_timeout=None, token_timeout=None, trade_timeout=None, quote_timeout=None, history_timeout=None, bars_timeout=None, no_message_timeout=None, auto_reconnect=False, reconnect_backoff=None, reconnect_max_attempts=None, servers=None, comment_encoding='latin-1', placed_type=None)
8
+ Client(login, password, host, port=443, *, api_key=None, token_url=None, dll_path=None, connect_timeout=None, token_timeout=None, trade_timeout=None, quote_timeout=None, history_timeout=None, bars_timeout=None, no_message_timeout=None, auto_reconnect=False, reconnect_backoff=None, reconnect_max_backoff=None, reconnect_max_attempts=None, servers=None, comment_encoding='latin-1', placed_type=None)
9
9
  ```
10
10
 
11
11
  A connection to one MetaTrader 4 account through the licensed core.
@@ -24,11 +24,13 @@ declared dead and closed: default 30 s, at least 10 s, `0` switches it off; the
24
24
  seconds, so a longer silence means the link stopped carrying traffic). Every other timeout must be positive; the
25
25
  quote, history and bars timeouts are the defaults of `quote`, `history` and `bars`. The
26
26
  handshake with the broker has its own fixed 30 s limit, whatever `connect_timeout` says (that one bounds the
27
- TCP dial of each server).
27
+ TCP dial of each server). **Defaults:** `connect_timeout` 6 s, `token_timeout` 10 s, `trade_timeout` 30 s,
28
+ `quote_timeout` 5 s, `history_timeout` 30 s, `bars_timeout` 30 s, `no_message_timeout` 30 s,
29
+ `reconnect_backoff` 3 s (it doubles up to `reconnect_max_backoff`, 30 s).
28
30
 
29
31
  Reconnecting: `auto_reconnect=True` turns on the core's watchdog (exponential backoff, failover across the
30
32
  primary server, `servers` and the broker's published list, re-subscription); tune it with `reconnect_backoff`
31
- (seconds) and `reconnect_max_attempts` (0 = until reconnected or `disconnect()`). Do not poll `connected`
33
+ and `reconnect_max_backoff` (seconds) and `reconnect_max_attempts` (0 = until reconnected or `disconnect()`). Do not poll `connected`
32
34
  and call `connect()` in a loop. `servers` are extra `(host, port)` access servers to fall back to.
33
35
 
34
36
  `comment_encoding` is the codepage order comments are written in (default `latin-1`); `placed_type`
@@ -336,14 +338,15 @@ Blocks up to `quote_timeout` per symbol.
336
338
 
337
339
  ## Sizing and risk
338
340
 
339
- #### `lots_for_risk(symbol, entry_price, stop_price, risk_amount, *, lot_step=None, min_lot=None, max_ratio=None, max_equity_share=None, max_lot=None)`
341
+ #### `lots_for_risk(symbol, entry_price, stop_price, risk_amount, *, lot_step=None, min_lot=None, max_ratio=None, max_equity_share=None, max_lot=None, side=None)`
340
342
 
341
343
  The volume that loses `risk_amount` (account currency) if `stop_price` is hit, with the evidence.
342
344
 
343
345
  The value of a price unit comes from `tick_value` and the broker's step / minimum / maximum volume from
344
346
  the account's own group (pass one to override it); a size that cannot be justified is refused, not guessed.
345
347
  The volume is rounded **down** to the step. Check `ok`: when false, `lots` is 0 and `reason` says which
346
- check failed. Amounts are in the currency the account reports (no cent-account scaling is applied: check the
348
+ check failed. Give `side` (`"buy"` or `"sell"`) and a stop on the wrong side of the entry is refused instead
349
+ of sized by its distance. Amounts are in the currency the account reports (no cent-account scaling is applied: check the
347
350
  first result against your terminal).
348
351
 
349
352
  #### `order_profit(symbol, open_price, close_price, lots, buy=True)`
@@ -407,7 +410,7 @@ through `on_order_progress` (and `on_order_update`).
407
410
 
408
411
  #### `cancel(ticket)`
409
412
 
410
- Delete a pending order.
413
+ Delete a pending order. Returns the order that was deleted, as it stood.
411
414
 
412
415
  #### `cancel_async(ticket)`
413
416
 
@@ -566,13 +569,38 @@ A list of bars is indexed by its `time`; anything else keeps a plain integer ind
566
569
  A price. `time` is the server time of the tick (use it to judge how old the price is); `high` and `low`
567
570
  are the session's range once the server has sent it, and `None` before that.
568
571
 
569
- Fields: `symbol`, `bid`, `ask`, `time`, `high`, `low`
572
+ | Field | Type | Meaning |
573
+ |---|---|---|
574
+ | `symbol` | `str` | the symbol as the broker names it |
575
+ | `bid` | `float` | the price a sell is made at |
576
+ | `ask` | `float` | the price a buy is made at |
577
+ | `time` | `Optional[datetime]` | the server time of the tick: how old the price is |
578
+ | `high` | `Optional[float]` | the session's high once the server has sent it (`None` before) |
579
+ | `low` | `Optional[float]` | the session's low once the server has sent it (`None` before) |
570
580
 
571
581
  #### `SymbolInfo`
572
582
 
573
583
  A symbol's specification and trading rules, as the server publishes them.
574
584
 
575
- Fields: `name`, `digits`, `point`, `currency`, `profit_mode`, `contract_size`, `margin_divider`, `tick_size`, `execution`, `security_index`, `min_lots`, `max_lots`, `lots_step`, `code`, `raw_tick_value`, `raw_bid_tick_value`, `raw_ask_tick_value`
585
+ | Field | Type | Meaning |
586
+ |---|---|---|
587
+ | `name` | `str` | the symbol as the broker names it |
588
+ | `digits` | `int` | the price digits |
589
+ | `point` | `float` | the smallest price step: 10 to the power of −digits |
590
+ | `currency` | `str` | the instrument's own currency: the base currency of a forex pair (EUR for EURUSD); profit is realised in `profit_currency` |
591
+ | `profit_mode` | `str` | how profit is calculated: Forex, CFD or Futures |
592
+ | `contract_size` | `float` | units of the instrument per 1.0 lot |
593
+ | `margin_divider` | `float` | the divider of the margin formula of a CFD symbol |
594
+ | `tick_size` | `float` | the smallest price change the server quotes |
595
+ | `execution` | `str` | how orders execute: Request, Instant, Market or Exchange |
596
+ | `security_index` | `int` | the account group's security (symbol class) the volume rules are keyed on |
597
+ | `min_lots` | `float` | the smallest volume |
598
+ | `max_lots` | `float` | the largest volume |
599
+ | `lots_step` | `float` | the volume step |
600
+ | `code` | `int` | the feed code the server uses for the symbol |
601
+ | `raw_tick_value` | `float` | the server's own tick value (brokers often report 0: use `Client.tick_value`) |
602
+ | `raw_bid_tick_value` | `float` | the server's tick value for a sell (often 0) |
603
+ | `raw_ask_tick_value` | `float` | the server's tick value for a buy (often 0) |
576
604
 
577
605
  #### `SymbolGroup`
578
606
 
@@ -587,7 +615,24 @@ are derived on the client from the live quotes (the server does not push them),
587
615
  `priced_from` is the server time of the oldest tick those figures were priced from (`None` with no open
588
616
  position): minutes old while the market is open means the feed is not delivering — do not trust the equity.
589
617
 
590
- Fields: `login`, `name`, `company`, `group`, `currency`, `leverage`, `balance`, `credit`, `equity`, `profit`, `margin`, `free_margin`, `margin_level`, `account_type`, `is_investor`, `priced_from`
618
+ | Field | Type | Meaning |
619
+ |---|---|---|
620
+ | `login` | `int` | the account number |
621
+ | `name` | `str` | the account holder's name as the broker holds it (personal data: keep it out of logs) |
622
+ | `company` | `str` | the broker's company name |
623
+ | `group` | `str` | the account's group on the server |
624
+ | `currency` | `str` | the deposit currency; every money figure below is in it |
625
+ | `leverage` | `int` | the N in 1:N |
626
+ | `balance` | `float` | closed-trade funds, floating profit excluded; it moves with every close and every deposit or withdrawal the session sees |
627
+ | `credit` | `float` | credit the broker granted |
628
+ | `equity` | `float` | balance + credit + floating profit (swap and commission of the open positions included) |
629
+ | `profit` | `float` | floating profit of the open positions including swap and commission |
630
+ | `margin` | `float` | the margin the open positions hold, derived on the client as the terminal does |
631
+ | `free_margin` | `float` | equity − margin |
632
+ | `margin_level` | `float` | equity ÷ margin × 100; 0 when no margin is used |
633
+ | `account_type` | `str` | the server's class of the account: Real, Demo or Contest |
634
+ | `is_investor` | `bool` | the login is the read-only investor password |
635
+ | `priced_from` | `Optional[datetime]` | the server time of the oldest quote the floating figures used (old while the market is open means the feed is not delivering) |
591
636
 
592
637
  #### `AccountSummary`
593
638
 
@@ -614,13 +659,40 @@ Fields: `ok`, `lots`, `expected_loss_at_stop`, `risk_requested`, `minimum_trade_
614
659
 
615
660
  One MT4 order: an open position, a pending order, a closed order of the history, or a balance / credit entry.
616
661
 
617
- Fields: `ticket`, `symbol`, `order_type`, `lots`, `open_time`, `open_price`, `stop_loss`, `take_profit`, `close_time`, `close_price`, `expiration`, `magic`, `swap`, `commission`, `comment`, `profit`, `rate_open`, `rate_close`, `rate_margin`
662
+ | Field | Type | Meaning |
663
+ |---|---|---|
664
+ | `ticket` | `int` | the order's ticket (a partial close leaves the remainder under a new ticket) |
665
+ | `symbol` | `str` | the symbol as the broker names it |
666
+ | `order_type` | `str` | `Buy`, `Sell` (a position), `BuyLimit`, `SellLimit`, `BuyStop`, `SellStop` (pending), or `Balance` / `Credit` for a capital entry |
667
+ | `lots` | `float` | the volume in lots |
668
+ | `open_time` | `Optional[datetime]` | when it opened or was placed, broker server time |
669
+ | `open_price` | `float` | the price the position opened at, or a pending order's price |
670
+ | `stop_loss` | `float` | stop-loss price (0 = none) |
671
+ | `take_profit` | `float` | take-profit price (0 = none) |
672
+ | `close_time` | `Optional[datetime]` | when it closed, broker server time (`None` while open) |
673
+ | `close_price` | `float` | the price it closed at; meaningful on a closed order only |
674
+ | `expiration` | `Optional[datetime]` | when a pending order expires (`None` = good till cancelled) |
675
+ | `magic` | `int` | always 0: the core does not carry a magic number; tag orders with `comment` |
676
+ | `swap` | `float` | swap in the deposit currency |
677
+ | `commission` | `float` | commission in the deposit currency (charged when the position closes) |
678
+ | `comment` | `str` | the order's comment (ASCII, 31 characters at most) |
679
+ | `profit` | `float` | profit in the deposit currency before swap and commission |
680
+ | `rate_open` | `float` | the conversion rate at open, as the server reports it |
681
+ | `rate_close` | `float` | the conversion rate at close, as the server reports it |
682
+ | `rate_margin` | `float` | the conversion rate used for the margin, as the server reports it |
618
683
 
619
684
  #### `Bar`
620
685
 
621
686
  Bar(time: 'datetime', open: 'float', high: 'float', low: 'float', close: 'float', volume: 'float')
622
687
 
623
- Fields: `time`, `open`, `high`, `low`, `close`, `volume`
688
+ | Field | Type | Meaning |
689
+ |---|---|---|
690
+ | `time` | `datetime` | the start of the period, broker server time |
691
+ | `open` | `float` | first price of the period |
692
+ | `high` | `float` | highest price |
693
+ | `low` | `float` | lowest price |
694
+ | `close` | `float` | last price |
695
+ | `volume` | `float` | the number of ticks in the period (MT4 reports tick volume only) |
624
696
 
625
697
  #### `OrderUpdate`
626
698
 
@@ -79,6 +79,7 @@ Every public member of the C# `MT4API` and `OrderClient` classes and where it we
79
79
  | `Reconnect` | `Client.reconnect` | returns False when every server failed; with `auto_reconnect=True` the core does it by itself |
80
80
  | `ReconnectBackoffMs` | `Client(reconnect_backoff)` | |
81
81
  | `ReconnectMaxAttempts` | `Client(reconnect_max_attempts)` | |
82
+ | `ReconnectMaxBackoffMs` | `Client(reconnect_max_backoff)` | |
82
83
  | `RequiredMargin` | `Client.required_margin` | |
83
84
  | `SaveServersIni` | `pmt4api.save_servers_ini` | |
84
85
  | `Servers` | `Client.servers` | give `servers=[(host, port), ...]` to the constructor or call `add_server` |
@@ -163,6 +163,14 @@ _BARE_MESSAGES = (
163
163
  _WRAPPERS = ("AggregateException", "TargetInvocationException")
164
164
 
165
165
 
166
+ def _python_words(text: str) -> str:
167
+ """The core words its configuration for a C# program; say it as this package takes it."""
168
+ return (text.replace("set MT4API.ApiKey or the WMT4API_KEY environment variable",
169
+ "pass api_key= to Client(...) or set the WMT4API_KEY environment variable")
170
+ .replace("set MT4API.TokenServiceUrl or the WMT4API_TOKEN_URL environment variable",
171
+ "pass token_url= to Client(...) or set the WMT4API_TOKEN_URL environment variable"))
172
+
173
+
166
174
  def _status(text: str) -> Optional[int]:
167
175
  """The number in "... rejected (status N)" of the core's bare connect-phase refusals."""
168
176
  if "(status " not in text:
@@ -189,6 +197,7 @@ def translate(exc: BaseException) -> Optional[WmtError]:
189
197
  continue
190
198
  message = str(getattr(exc, "Message", None) or exc)
191
199
  if kind == "license":
200
+ message = _python_words(message)
192
201
  reason = getattr(exc, "Reason", None)
193
202
  status = getattr(exc, "HttpStatus", None)
194
203
  detail = getattr(exc, "ServerError", None)
@@ -209,7 +218,10 @@ def translate(exc: BaseException) -> Optional[WmtError]:
209
218
  if kind == "requote":
210
219
  return RequoteError(message, float(exc.Bid), float(exc.Ask), value if value is not None else 138)
211
220
  cls = {"login": LoginRejectedError, "server": ServerError, "investor": InvestorModeError}[kind]
212
- return cls(message, code_name(value), value)
221
+ name = code_name(value)
222
+ if value is not None and name and ("(code %d)" % value) in message:
223
+ message = message.replace("(code %d)" % value, "(code %d: %s)" % (value, name))
224
+ return cls(message, name, value)
213
225
  if kind == "timeout":
214
226
  return RequestTimeout(message)
215
227
  if kind == "cancelled":
@@ -52,6 +52,11 @@ def _unset_if_zero(value: Any) -> Optional[float]:
52
52
 
53
53
  # ---- market data ------------------------------------------------------------------------------------------------
54
54
 
55
+
56
+ def _m(value: Any) -> float:
57
+ """A money amount without the binary noise of the core's sums (1.3900000000000001 becomes 1.39)."""
58
+ return round(float(value), 8)
59
+
55
60
  @dataclass(frozen=True)
56
61
  class Quote:
57
62
  """A price. ``time`` is the server time of the tick (use it to judge how old the price is); ``high`` and ``low``
@@ -280,8 +285,8 @@ class Order:
280
285
  return cls(ticket=int(o.Ticket), symbol=str(o.Symbol or ""), order_type=_name(o.Type), lots=float(o.Lots),
281
286
  open_time=_time(o.OpenTime), open_price=float(o.OpenPrice), stop_loss=float(o.StopLoss),
282
287
  take_profit=float(o.TakeProfit), close_time=_time(o.CloseTime), close_price=float(o.ClosePrice),
283
- expiration=_time(o.ExpirationTime), magic=int(o.ExpertId), swap=float(o.Swap),
284
- commission=float(o.Commission), comment=str(o.Comment or ""), profit=float(o.Profit),
288
+ expiration=_time(o.ExpirationTime), magic=int(o.ExpertId), swap=_m(o.Swap),
289
+ commission=_m(o.Commission), comment=str(o.Comment or ""), profit=_m(o.Profit),
285
290
  rate_open=float(o.RateOpen), rate_close=float(o.RateClose), rate_margin=float(o.RateMargin))
286
291
 
287
292
 
@@ -335,3 +340,74 @@ class ServerRecord:
335
340
  return cls(name=str(r.Name or ""), company=str(r.Company or ""), is_demo=bool(r.IsDemo),
336
341
  main_host=str(r.MainHost or ""),
337
342
  access=tuple((str(a.Item1), int(a.Item2)) for a in (r.Access or ())))
343
+
344
+
345
+ # ---- what each field means (rendered into docs/API.md by tools/gen_docs.py) ------------------------------------------
346
+ AccountInfo.FIELD_DOC = {
347
+ "login": "the account number",
348
+ "name": "the account holder's name as the broker holds it (personal data: keep it out of logs)",
349
+ "company": "the broker's company name",
350
+ "group": "the account's group on the server",
351
+ "currency": "the deposit currency; every money figure below is in it",
352
+ "leverage": "the N in 1:N",
353
+ "balance": "closed-trade funds, floating profit excluded; it moves with every close and every deposit or "
354
+ "withdrawal the session sees",
355
+ "credit": "credit the broker granted",
356
+ "equity": "balance + credit + floating profit (swap and commission of the open positions included)",
357
+ "profit": "floating profit of the open positions including swap and commission",
358
+ "margin": "the margin the open positions hold, derived on the client as the terminal does",
359
+ "free_margin": "equity − margin",
360
+ "margin_level": "equity ÷ margin × 100; 0 when no margin is used",
361
+ "account_type": "the server's class of the account: Real, Demo or Contest",
362
+ "is_investor": "the login is the read-only investor password",
363
+ "priced_from": "the server time of the oldest quote the floating figures used (old while the market is open means "
364
+ "the feed is not delivering)",
365
+ }
366
+ Order.FIELD_DOC = {
367
+ "ticket": "the order's ticket (a partial close leaves the remainder under a new ticket)",
368
+ "symbol": "the symbol as the broker names it",
369
+ "order_type": "`Buy`, `Sell` (a position), `BuyLimit`, `SellLimit`, `BuyStop`, `SellStop` (pending), or `Balance` / "
370
+ "`Credit` for a capital entry",
371
+ "lots": "the volume in lots",
372
+ "open_time": "when it opened or was placed, broker server time",
373
+ "open_price": "the price the position opened at, or a pending order's price",
374
+ "stop_loss": "stop-loss price (0 = none)",
375
+ "take_profit": "take-profit price (0 = none)",
376
+ "close_time": "when it closed, broker server time (`None` while open)",
377
+ "close_price": "the price it closed at; meaningful on a closed order only",
378
+ "expiration": "when a pending order expires (`None` = good till cancelled)",
379
+ "magic": "always 0: the core does not carry a magic number; tag orders with `comment`",
380
+ "swap": "swap in the deposit currency",
381
+ "commission": "commission in the deposit currency (charged when the position closes)",
382
+ "comment": "the order's comment (ASCII, 31 characters at most)",
383
+ "profit": "profit in the deposit currency before swap and commission",
384
+ "rate_open": "the conversion rate at open, as the server reports it",
385
+ "rate_close": "the conversion rate at close, as the server reports it",
386
+ "rate_margin": "the conversion rate used for the margin, as the server reports it",
387
+ }
388
+ SymbolInfo.FIELD_DOC = {
389
+ "name": "the symbol as the broker names it",
390
+ "digits": "the price digits",
391
+ "point": "the smallest price step: 10 to the power of −digits",
392
+ "currency": "the instrument's own currency: the base currency of a forex pair (EUR for EURUSD); profit is "
393
+ "realised in `profit_currency`",
394
+ "profit_mode": "how profit is calculated: Forex, CFD or Futures",
395
+ "contract_size": "units of the instrument per 1.0 lot",
396
+ "margin_divider": "the divider of the margin formula of a CFD symbol",
397
+ "tick_size": "the smallest price change the server quotes",
398
+ "execution": "how orders execute: Request, Instant, Market or Exchange",
399
+ "security_index": "the account group's security (symbol class) the volume rules are keyed on",
400
+ "min_lots": "the smallest volume", "max_lots": "the largest volume", "lots_step": "the volume step",
401
+ "code": "the feed code the server uses for the symbol",
402
+ "raw_tick_value": "the server's own tick value (brokers often report 0: use `Client.tick_value`)",
403
+ "raw_bid_tick_value": "the server's tick value for a sell (often 0)",
404
+ "raw_ask_tick_value": "the server's tick value for a buy (often 0)",
405
+ }
406
+ Quote.FIELD_DOC = {
407
+ "symbol": "the symbol as the broker names it",
408
+ "bid": "the price a sell is made at",
409
+ "ask": "the price a buy is made at",
410
+ "time": "the server time of the tick: how old the price is",
411
+ "high": "the session's high once the server has sent it (`None` before)",
412
+ "low": "the session's low once the server has sent it (`None` before)",
413
+ }
@@ -1,17 +0,0 @@
1
- # Changelog — pmt4api
2
-
3
- ## [1.0.0] — 2026-10-12
4
- First release: the complete Python interface to wmt4api 3.2.0 (the MetaTrader 4 client library).
5
-
6
- - Every public member and type of the C# `MT4API` / `OrderClient` classes is mapped to a Python counterpart, or has a
7
- stated reason for not having one (`pmt4api.COVERAGE`, `pmt4api.TYPES`; a test fails when the core gains an
8
- unaccounted member).
9
- - Market data (symbols, quotes, bars on the nine MT4 timeframes with paging), account, sizing and risk, order entry
10
- (market, pending, modify, partial close, close-by, close-all-by, cancel; synchronous, with request ids and progress
11
- events, and awaitable), history, summary and equity curve.
12
- - Callbacks, sync and async iterators, one ordered dispatcher thread per client, with back-pressure accounting.
13
- - A small error hierarchy; the MT4 trade-return codes by name; requotes carry the new prices.
14
- - `auto_reconnect`, `servers=` failover, a silence watchdog, `reconnect()`; `disconnect()` is final.
15
- - Refusals before anything is sent: a magic number the core cannot transmit, a volume the wire cannot carry, a
16
- comment that would not read back.
17
- - Wheels: Linux x86-64 with the core and a .NET runtime bundled, and a pure wheel that carries the core.
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes