pmt5api 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. pmt5api-1.1.2/CHANGELOG.md +79 -0
  2. {pmt5api-1.0.0 → pmt5api-1.1.2}/PKG-INFO +53 -15
  3. {pmt5api-1.0.0 → pmt5api-1.1.2}/README.md +52 -14
  4. {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/__init__.py +3 -3
  5. {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/__main__.py +4 -0
  6. {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/_account.py +11 -5
  7. {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/_base.py +6 -0
  8. {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/_events.py +2 -0
  9. {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/_market.py +23 -9
  10. {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/_risk.py +14 -2
  11. {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/_runtime.py +24 -2
  12. {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/_session.py +30 -2
  13. {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/_streams.py +39 -6
  14. {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/_trading.py +71 -4
  15. {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/_util.py +5 -3
  16. {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/bars.py +26 -12
  17. {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/client.py +4 -0
  18. {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/docs/API.md +154 -10
  19. {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/errors.py +10 -0
  20. {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/models.py +138 -3
  21. pmt5api-1.0.0/CHANGELOG.md +0 -30
  22. {pmt5api-1.0.0 → pmt5api-1.1.2}/LICENSE.txt +0 -0
  23. {pmt5api-1.0.0 → pmt5api-1.1.2}/examples/bars.py +0 -0
  24. {pmt5api-1.0.0 → pmt5api-1.1.2}/examples/connect.py +0 -0
  25. {pmt5api-1.0.0 → pmt5api-1.1.2}/examples/pnl.py +0 -0
  26. {pmt5api-1.0.0 → pmt5api-1.1.2}/examples/servers.py +0 -0
  27. {pmt5api-1.0.0 → pmt5api-1.1.2}/examples/sizing.py +0 -0
  28. {pmt5api-1.0.0 → pmt5api-1.1.2}/examples/stream_async.py +0 -0
  29. {pmt5api-1.0.0 → pmt5api-1.1.2}/examples/stream_quotes.py +0 -0
  30. {pmt5api-1.0.0 → pmt5api-1.1.2}/examples/trade_demo.py +0 -0
  31. {pmt5api-1.0.0 → pmt5api-1.1.2}/hatch_build.py +0 -0
  32. {pmt5api-1.0.0 → pmt5api-1.1.2}/pyproject.toml +0 -0
  33. {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/_coverage.py +0 -0
  34. {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/diagnostics.py +0 -0
  35. {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/docs/FROM-CSHARP.md +0 -0
  36. {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/frames.py +0 -0
  37. {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/py.typed +0 -0
  38. {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/servers.py +0 -0
@@ -0,0 +1,79 @@
1
+ # Changelog — pmt5api
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
+ - A `bars()` timeout says the budget of the call (`not complete within 0.5 s`), not what was left for the last block.
9
+ - `Order` and `Deal` money fields (profit, swap, commission, fee) carry no binary noise (`1.3900000000000001` is `1.39`).
10
+ - README: a connect to an unreachable licence service waits about twice `token_timeout`.
11
+
12
+ ## [1.1.1] — 2026-10-14
13
+
14
+ From the Windows re-test of 1.1.0 (13 notes, one S2). Bundles wmt5api 3.1.2.
15
+
16
+ ### Fixed
17
+ - **`equity_history()` returned `[]` for a window with no deal** (an idle account): the balance curve is now a flat line
18
+ at the current balance (core `wmt5api 3.1.2`). A script that read `eq[-1]` on an idle account raised `IndexError`.
19
+ - Account money figures carry no binary noise (`1635341.7899999998` is `1635341.79`).
20
+ - A bars timeout states a sub-second wait as it is (`0.5 s`, not `0 s`).
21
+ - A missing .NET runtime on a **32-bit** Python says that 32-bit Python needs the 32-bit runtime.
22
+ - `SymbolInfo` has a meaning for every field in the reference.
23
+ - README: ticks and their `time`, that the server enforces volume min/step, the minute rounding of an expiry, Ctrl+C
24
+ during `connect()`, the licence request retry.
25
+
26
+ ## [1.1.0] — 2026-10-13
27
+
28
+ Found by a customer-style test on Windows Server 2022 / Python 3.10 (28 findings); every one is addressed. Bundles
29
+ wmt5api 3.1.1. Additive: nothing that worked in 1.0.0 is removed.
30
+
31
+ ### Fixed
32
+ - **Two packages could not start the .NET runtime from two threads at once** (`RuntimeNotFoundError` in one of them,
33
+ every time, on Linux as well). One lock now covers every `pmt*api` package in the process.
34
+ - **`bars()` re-aggregated the whole series after every block** (MT5): a deep request was quadratic and timed out
35
+ (`D1` × 1000 took over two minutes of CPU). It counts as the blocks arrive and aggregates once; the default budget
36
+ is `bars_timeout` plus a second per two weeks of one-minute bars the request needs.
37
+ - **A closed client left a thread behind** for every client that had used callbacks or `quotes()`: the callback
38
+ thread now ends at `disconnect()` (and when the client is dropped) and returns with the next event.
39
+ - **Type checkers saw every method as `(*Any, **Any) -> Any`**: the decorator that translates errors now keeps the
40
+ signature.
41
+ - Messages: a missing .NET runtime names the runtime and where to get it; the no-key text speaks `api_key=`; a bars
42
+ timeout states its real wait; a trade refusal names its code.
43
+ - `python -m pmt4api --connect` / `pmt5api --connect` accept `host:port` in `PMT*_HOST`.
44
+ - The reference gains a meaning for every field of the main data types.
45
+ - `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`).
46
+ - The core fixes: see wmt5api 3.1.1 — **`Deal.price` / `Deal.open_price` were swapped on a closing deal**, a pending sell order reported `DealBuy`, and `history()` omitted cancelled and expired pending orders.
47
+
48
+ ### Added
49
+ - `lots_for_risk(..., side="buy" | "sell")`: a stop on the wrong side of the entry is refused instead of being sized by its distance.
50
+ - `LoginRejectedError` (a `ServerError`): the broker refused the login — `connect()` raises it for a wrong account or password instead of a bare `ServerError`; a dial that times out is a `ConnectError`, as on MT4.
51
+
52
+ ## [1.0.0] — 2026-10-12
53
+
54
+ First release. `pmt5api` exposes every public capability of the wmt5api 3.x C# library to Python.
55
+
56
+ ### Capabilities
57
+ - Connection and session: licensing, failover, `auto_reconnect`, proxies (SOCKS4/SOCKS5/HTTPS), client certificate,
58
+ all timeouts, connect-progress events, server time and account mode.
59
+ - Market data: symbols with trading rules and sessions, commissions, quotes and subscriptions (callbacks, iterators,
60
+ asyncio), bars M1–D1 with tick volume, tick value and contract size.
61
+ - Account: balances and margins (with and without swap/commission), orders, positions, closed orders, history and
62
+ deal history (blocking and event-driven), realized summary, balance/equity curve.
63
+ - Sizing and risk: money-scale calibration, lots-for-risk with evidence, required margin, order profit.
64
+ - Order entry: market, pending (limit, stop, stop-limit), expiration, fill policy, modify, partial close, close-by,
65
+ cancel — synchronous, with request ids and progress events, and awaitable; magic number on orders and deals.
66
+ - Offline helpers and administration: `servers.dat` read and write, demo account request, password change.
67
+ - Diagnostics: the core's log into Python `logging`.
68
+ - Parity manifest `pmt5api.COVERAGE`, checked against the DLL by the test suite; generated API reference and a
69
+ C#-to-Python table.
70
+
71
+ ### Packaging
72
+ - Linux x86-64 wheel carrying the core and the .NET 9 runtime (`manylinux_2_28`); a pure wheel carrying the core;
73
+ an sdist.
74
+
75
+ ### Verified
76
+ Against real demo accounts, from clean containers without .NET (Python 3.9, 3.12, 3.13; glibc 2.28 and 2.41):
77
+ read-only and trading suites, reconnects (drops, a refused window, a silent link), every shipped example, and a
78
+ three-hour unattended run (4 recovered drops, no errors, memory flat after warm-up).
79
+ Not verified: Windows, macOS, arm64, musl; netting accounts; client certificates.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pmt5api
3
- Version: 1.0.0
3
+ Version: 1.1.2
4
4
  Summary: Complete Python interface to the wmt5api MetaTrader 5 client library (market data, account, sizing, order entry, events).
5
5
  Project-URL: Homepage, https://mtapi.one
6
6
  Author: Sen Trading
@@ -37,19 +37,25 @@ No MetaTrader terminal is needed — the core talks to your broker directly.
37
37
 
38
38
  **Parity with the C# library is measured, not claimed:** `pmt5api.COVERAGE` maps every public member of the C#
39
39
  `MT5API` class to its Python counterpart (or states why it has none), and the test suite fails if the core gains a
40
- member that is not accounted for. [`docs/FROM-CSHARP.md`](src/pmt5api/docs/FROM-CSHARP.md) is that table;
41
- [`docs/API.md`](src/pmt5api/docs/API.md) is the reference.
40
+ member that is not accounted for. `pmt5api/docs/FROM-CSHARP.md` (inside the installed package) is that table;
41
+ `pmt5api/docs/API.md` (inside the installed package; `python -m pmt5api` prints its folder) is the reference.
42
42
 
43
43
  ## Install
44
44
 
45
- The **Linux x86-64 wheel** carries everything — the core and a .NET runtime — so there is nothing else to install:
46
-
47
45
  ```
48
- pip install pmt5api-<version>-py3-none-manylinux_2_28_x86_64.whl
46
+ pip install pmt5api
49
47
  ```
50
48
 
51
- On any other platform use the `py3-none-any` wheel, which carries the core and needs a .NET 8 (or later) runtime on
52
- the machine (`dotnet --list-runtimes` should list `Microsoft.NETCore.App`).
49
+ pip chooses the wheel for your platform. The **Linux x86-64 wheel** carries everything — the core and a .NET runtime —
50
+ so there is nothing else to install. On any other platform the `py3-none-any` wheel carries the core and needs a .NET 8
51
+ (or later) runtime on the machine (`dotnet --list-runtimes` should list `Microsoft.NETCore.App`). From a downloaded
52
+ file: `pip install pmt5api-<version>-py3-none-<platform>.whl`.
53
+
54
+ **Windows (x64).** Install the .NET 8 runtime once (`winget install Microsoft.DotNet.Runtime.8`, or the installer from
55
+ https://dotnet.microsoft.com/download/dotnet/8.0), then `pip install pmt5api`. Check it with `python -m pmt5api`: it prints
56
+ the versions, finds the runtime and constructs a client offline. Set the key in the shell that runs your program —
57
+ PowerShell: `$env:WMTAPI_KEY = "wk_..."`, cmd: `set WMTAPI_KEY=wk_...`. A string the broker sent can hold characters your
58
+ console's code page cannot print: set `PYTHONUTF8=1` (or run `chcp 65001`) to avoid a `UnicodeEncodeError`.
53
59
 
54
60
  You also need your **API key** (`wk_…`, the same key you use with wmt5api):
55
61
 
@@ -101,6 +107,16 @@ bars; higher timeframes are built from them, aligned to the server's calendar (a
101
107
  bar at 00, 04, 08, … server time, a week on Sunday, a month on the 1st), with `tick_volume` summed. Minutes after
102
108
  `end` are not used; the last bar may still be forming. `spread` is present on M1 bars only.
103
109
 
110
+ **What a request costs.** The server hands the one-minute bars over in blocks of about two weeks, so the time of a
111
+ `bars()` call grows with how far back it reaches, not with the timeframe. Measured from a datacentre against a
112
+ broker's demo server: 5 000 one-minute bars 0.4 s; 100 `D1` bars 3 s; 5 000 `H1` bars 9 s; 500 `D1` bars 16 s;
113
+ 1 000 `D1` bars 32 s (your link and your broker will differ). The default budget is `bars_timeout` plus a second per
114
+ block the request needs, so these finish without a `timeout=`; pass one to bound the call yourself.
115
+
116
+ **MT4 and MT5 differ here.** On MT5 `end=` includes the bar stamped exactly `end` and the bar that is still forming; the
117
+ MT4 package returns completed bars only and excludes a bar that opens at `end`. Code that runs on both has to drop
118
+ the forming MT5 bar itself.
119
+
104
120
  ### Live quotes
105
121
 
106
122
  ```python
@@ -123,19 +139,25 @@ c.subscribe("EURUSD")
123
139
  remove()
124
140
  ```
125
141
 
126
- Callbacks are delivered by **one dispatcher thread per client**, one at a time, in arrival order — you need no locks
142
+ Callbacks are delivered by **one dispatcher thread per client**, one at a time, in the order the events arrived — you need no locks
127
143
  between callbacks, and a slow callback does not stall the connection. If a callback falls more than 20,000 events
128
144
  behind, the oldest events are dropped, a warning is logged and `c.events_dropped` counts them. An exception raised
129
145
  inside a callback is logged (`logging.getLogger("pmt5api")`) and swallowed. Order updates keep the state they had at
130
146
  the moment they arrived (they are snapshots). The iterators attach when you create them, drop the oldest item when the
131
147
  consumer falls behind, and detach on `close()` / `async with` / garbage collection.
132
148
 
149
+ A tick's `time` is the server's: on some brokers about one tick in a hundred carries a time slightly earlier than the
150
+ previous tick of the same symbol (the feed is merged from several sources). Delivery order is arrival order; sort by
151
+ `time` if you need the market's order.
152
+
133
153
  ## Trading
134
154
 
135
155
  ```python
156
+ from datetime import datetime
157
+
136
158
  o = c.buy("EURUSD", 0.10, sl=1.0950, tp=1.1100, magic=424242) # market order at the ask; sell() at the bid
137
- c.modify(o.ticket, sl=1.0970) # change stops; what you leave out keeps its value
138
- c.close(o.ticket, 0.05) # close half; close(ticket) closes it all
159
+ c.modify(o.ticket, sl=1.0970) # change stops (returns the order as the server holds it)
160
+ c.close(o.ticket, 0.05) # close half (returns the closing record); close(ticket) closes it all
139
161
 
140
162
  p = c.place_order("EURUSD", "buy_stop_limit", 0.10, 1.1200, stop_limit=1.1190,
141
163
  expiration=datetime(2026, 12, 31, 23, 0)) # also buy_limit sell_limit buy_stop sell_stop
@@ -159,6 +181,12 @@ rid = c.buy_async("EURUSD", 0.10)
159
181
  `aclose`, `acancel`, `aquote`. Cancelling the `await` (a `wait_for` timeout, a cancelled task) does not recall a request
160
182
  that is already on its way: the order may still be sent, so check `orders()` afterwards.
161
183
 
184
+ **The server, not the library, enforces volume rules.** A volume below the minimum or off the step is sent and refused
185
+ (`ServerError` `INVALID_VOLUME`); `symbol(name)` has `min_lots`, `max_lots` and `lots_step` to check against first. A
186
+ pending order's expiration is stored to the minute (rounded **down**), so an order can expire up to a minute before the
187
+ time you asked for. Pressing Ctrl+C while `connect()` is dialling is honoured when the dial ends (up to
188
+ `connect_timeout`), not at once.
189
+
162
190
  **Test on a demo account first.** Nothing here is ever retried or resent for you: if the server does not confirm in
163
191
  time the call raises `RequestTimeout` and the order **may have been accepted** — look at `orders()` before sending
164
192
  again. `NoQuoteError` is different: no price was available, so nothing was sent. `orders()` can lag a moment behind
@@ -174,8 +202,10 @@ Do not size a trade from a one-line formula: what a price unit is worth depends
174
202
  from your own closed deals and refuses to size without that evidence:
175
203
 
176
204
  ```python
205
+ from datetime import datetime
206
+
177
207
  if c.calibrate_money_scale(datetime(2025, 1, 1)): # read-only; places no order
178
- r = c.lots_for_risk("EURUSD", entry_price=1.1000, stop_price=1.0950, risk_amount=50.0)
208
+ r = c.lots_for_risk("EURUSD", entry_price=1.1000, stop_price=1.0950, risk_amount=50.0, side="buy")
179
209
  for w in r.warnings:
180
210
  print("warning:", w) # read these: ok can still be True with a caveat
181
211
  if r.ok and r.expected_loss_at_stop <= 50.0 * 1.05:
@@ -228,8 +258,8 @@ subscriptions are restored, cached snapshots are not.
228
258
  | API key | `api_key=` | `WMTAPI_KEY` |
229
259
  | Licence service URL (leave at its default) | `token_url=` | `WMTAPI_TOKEN_URL` |
230
260
  | Core library (only if you do not use a wheel that carries it) | `dll_path=` | `WMTAPI_DLL` |
231
- | Timeouts (seconds, each > 0) | `connect_timeout` (one attempt on one server), `cluster_member_timeout` (per server while failing over), `token_timeout`, `execution_timeout`, `quote_timeout` / `history_timeout` / `bars_timeout` (the defaults of `quote`, `history`, `bars`), `ping_interval`, `no_message_timeout` | — |
232
- | Reconnect | `auto_reconnect`, `reconnect_backoff`, `reconnect_max_backoff`, `reconnect_max_attempts` | — |
261
+ | Timeouts (seconds, each > 0) | `connect_timeout` (the whole connect; default 30), `cluster_member_timeout` (per server while failing over; 10), `token_timeout` (10), `execution_timeout` (a trade confirmation; 30), `quote_timeout` (10) / `history_timeout` (30) / `bars_timeout` (10, plus a second per two weeks of one-minute bars a `bars()` call needs) — the defaults of `quote`, `history`, `bars`, `ping_interval` (20), `no_message_timeout` (30) | — |
262
+ | Reconnect | `auto_reconnect`, `reconnect_backoff` (3, doubling up to `reconnect_max_backoff`, 30), `reconnect_max_attempts` (0 = no limit) | — |
233
263
  | Proxy | `proxy=Proxy("socks5" \| "socks4" \| "https", host, port, user, password)` — `host` must be an IP address | — |
234
264
  | Client certificate / common key | `certificate=`, `certificate_password=`, `common_key=` | — |
235
265
  | .NET runtime location | — | `DOTNET_ROOT` |
@@ -252,6 +282,10 @@ pmt5api.enable_core_logging() # the core's own messages -> logging.getL
252
282
  `request_demo_account(DemoAccountRequest(...), host, port)` asks a broker for a demo account (it creates a real
253
283
  account at that broker: use it deliberately); `c.change_password(new, investor=False)`.
254
284
 
285
+ `python -m pmt5api` prints what is installed and whether it works here (`--connect` also reads an account, using
286
+ `PMT5_LOGIN`, `PMT5_PASSWORD`, `PMT5_HOST` — a bare host, or `host:port`, or the port in `PMT5_PORT` — and the key in
287
+ `WMTAPI_KEY`); the output holds no secrets and is what to send when asking for help.
288
+
255
289
  ## Errors
256
290
 
257
291
  Everything the core raises reaches you as one of these, all subclasses of `pmt5api.WmtError`; the original
@@ -260,7 +294,7 @@ exception stays available as `__cause__`.
260
294
  | Exception | Meaning |
261
295
  |---|---|
262
296
  | `LicenseError` | The licence service refused the key. `.reason` is one of `NoKey`, `Invalid`, `Expired`, `Revoked`, `RateLimited`, `LoginNotAllowed`, `AccountLimitReached`, `Unknown`. |
263
- | `TokenServiceError` | The licence service could not be reached or answered unusably — retry, don't re-prompt for a key. |
297
+ | `TokenServiceError` | The licence service could not be reached or answered unusably — retry, don't re-prompt for a key. (`token_timeout` bounds one request and a failed request is tried again once, so a connect to an unreachable licence service can wait about twice that.) |
264
298
  | `ConnectError` | The broker's access server (or the network, or a proxy) could not be reached. |
265
299
  | `ConnectionLostError` | An established connection dropped (the core's `Disconnected`). |
266
300
  | `ServerError` / `InvestorModeError` | The broker refused the request (wrong login or password, a rejected order / a read-only login tried to trade). `.code` is the server's return code by name (`REQUOTE`, `NO_MONEY`, `INVALID_PRICE`, …). |
@@ -304,6 +338,10 @@ moved by 4 MB), threads and file descriptors stayed flat. Every shipped example
304
338
 
305
339
  ## Known limitations
306
340
 
341
+ - **Sizing needs evidence from the account's own closed deals.** On an account whose deals all carry swap or
342
+ commission, or whose deals do not have the shape the calibration can read (a Tickmill demo, for one), `calibrate_money_scale`
343
+ finds no symbol to measure from and `lots_for_risk` refuses: that is the intended answer, not an error. `tick_value()`
344
+ and `required_margin()` still work, and the refusal says what was missing.
307
345
  - **Linux x86-64 is the verified platform.** Windows, macOS, arm64 and musl-based Linux have not been verified.
308
346
  - Netting accounts have not been verified (the available demo accounts are all hedging).
309
347
  - Client certificates (`certificate=`), `common_key=` and `request_demo_account` have been unit-tested but not run
@@ -10,19 +10,25 @@ No MetaTrader terminal is needed — the core talks to your broker directly.
10
10
 
11
11
  **Parity with the C# library is measured, not claimed:** `pmt5api.COVERAGE` maps every public member of the C#
12
12
  `MT5API` class to its Python counterpart (or states why it has none), and the test suite fails if the core gains a
13
- member that is not accounted for. [`docs/FROM-CSHARP.md`](src/pmt5api/docs/FROM-CSHARP.md) is that table;
14
- [`docs/API.md`](src/pmt5api/docs/API.md) is the reference.
13
+ member that is not accounted for. `pmt5api/docs/FROM-CSHARP.md` (inside the installed package) is that table;
14
+ `pmt5api/docs/API.md` (inside the installed package; `python -m pmt5api` prints its folder) is the reference.
15
15
 
16
16
  ## Install
17
17
 
18
- The **Linux x86-64 wheel** carries everything — the core and a .NET runtime — so there is nothing else to install:
19
-
20
18
  ```
21
- pip install pmt5api-<version>-py3-none-manylinux_2_28_x86_64.whl
19
+ pip install pmt5api
22
20
  ```
23
21
 
24
- On any other platform use the `py3-none-any` wheel, which carries the core and needs a .NET 8 (or later) runtime on
25
- the machine (`dotnet --list-runtimes` should list `Microsoft.NETCore.App`).
22
+ pip chooses the wheel for your platform. The **Linux x86-64 wheel** carries everything — the core and a .NET runtime —
23
+ so there is nothing else to install. On any other platform the `py3-none-any` wheel carries the core and needs a .NET 8
24
+ (or later) runtime on the machine (`dotnet --list-runtimes` should list `Microsoft.NETCore.App`). From a downloaded
25
+ file: `pip install pmt5api-<version>-py3-none-<platform>.whl`.
26
+
27
+ **Windows (x64).** Install the .NET 8 runtime once (`winget install Microsoft.DotNet.Runtime.8`, or the installer from
28
+ https://dotnet.microsoft.com/download/dotnet/8.0), then `pip install pmt5api`. Check it with `python -m pmt5api`: it prints
29
+ the versions, finds the runtime and constructs a client offline. Set the key in the shell that runs your program —
30
+ PowerShell: `$env:WMTAPI_KEY = "wk_..."`, cmd: `set WMTAPI_KEY=wk_...`. A string the broker sent can hold characters your
31
+ console's code page cannot print: set `PYTHONUTF8=1` (or run `chcp 65001`) to avoid a `UnicodeEncodeError`.
26
32
 
27
33
  You also need your **API key** (`wk_…`, the same key you use with wmt5api):
28
34
 
@@ -74,6 +80,16 @@ bars; higher timeframes are built from them, aligned to the server's calendar (a
74
80
  bar at 00, 04, 08, … server time, a week on Sunday, a month on the 1st), with `tick_volume` summed. Minutes after
75
81
  `end` are not used; the last bar may still be forming. `spread` is present on M1 bars only.
76
82
 
83
+ **What a request costs.** The server hands the one-minute bars over in blocks of about two weeks, so the time of a
84
+ `bars()` call grows with how far back it reaches, not with the timeframe. Measured from a datacentre against a
85
+ broker's demo server: 5 000 one-minute bars 0.4 s; 100 `D1` bars 3 s; 5 000 `H1` bars 9 s; 500 `D1` bars 16 s;
86
+ 1 000 `D1` bars 32 s (your link and your broker will differ). The default budget is `bars_timeout` plus a second per
87
+ block the request needs, so these finish without a `timeout=`; pass one to bound the call yourself.
88
+
89
+ **MT4 and MT5 differ here.** On MT5 `end=` includes the bar stamped exactly `end` and the bar that is still forming; the
90
+ MT4 package returns completed bars only and excludes a bar that opens at `end`. Code that runs on both has to drop
91
+ the forming MT5 bar itself.
92
+
77
93
  ### Live quotes
78
94
 
79
95
  ```python
@@ -96,19 +112,25 @@ c.subscribe("EURUSD")
96
112
  remove()
97
113
  ```
98
114
 
99
- Callbacks are delivered by **one dispatcher thread per client**, one at a time, in arrival order — you need no locks
115
+ Callbacks are delivered by **one dispatcher thread per client**, one at a time, in the order the events arrived — you need no locks
100
116
  between callbacks, and a slow callback does not stall the connection. If a callback falls more than 20,000 events
101
117
  behind, the oldest events are dropped, a warning is logged and `c.events_dropped` counts them. An exception raised
102
118
  inside a callback is logged (`logging.getLogger("pmt5api")`) and swallowed. Order updates keep the state they had at
103
119
  the moment they arrived (they are snapshots). The iterators attach when you create them, drop the oldest item when the
104
120
  consumer falls behind, and detach on `close()` / `async with` / garbage collection.
105
121
 
122
+ A tick's `time` is the server's: on some brokers about one tick in a hundred carries a time slightly earlier than the
123
+ previous tick of the same symbol (the feed is merged from several sources). Delivery order is arrival order; sort by
124
+ `time` if you need the market's order.
125
+
106
126
  ## Trading
107
127
 
108
128
  ```python
129
+ from datetime import datetime
130
+
109
131
  o = c.buy("EURUSD", 0.10, sl=1.0950, tp=1.1100, magic=424242) # market order at the ask; sell() at the bid
110
- c.modify(o.ticket, sl=1.0970) # change stops; what you leave out keeps its value
111
- c.close(o.ticket, 0.05) # close half; close(ticket) closes it all
132
+ c.modify(o.ticket, sl=1.0970) # change stops (returns the order as the server holds it)
133
+ c.close(o.ticket, 0.05) # close half (returns the closing record); close(ticket) closes it all
112
134
 
113
135
  p = c.place_order("EURUSD", "buy_stop_limit", 0.10, 1.1200, stop_limit=1.1190,
114
136
  expiration=datetime(2026, 12, 31, 23, 0)) # also buy_limit sell_limit buy_stop sell_stop
@@ -132,6 +154,12 @@ rid = c.buy_async("EURUSD", 0.10)
132
154
  `aclose`, `acancel`, `aquote`. Cancelling the `await` (a `wait_for` timeout, a cancelled task) does not recall a request
133
155
  that is already on its way: the order may still be sent, so check `orders()` afterwards.
134
156
 
157
+ **The server, not the library, enforces volume rules.** A volume below the minimum or off the step is sent and refused
158
+ (`ServerError` `INVALID_VOLUME`); `symbol(name)` has `min_lots`, `max_lots` and `lots_step` to check against first. A
159
+ pending order's expiration is stored to the minute (rounded **down**), so an order can expire up to a minute before the
160
+ time you asked for. Pressing Ctrl+C while `connect()` is dialling is honoured when the dial ends (up to
161
+ `connect_timeout`), not at once.
162
+
135
163
  **Test on a demo account first.** Nothing here is ever retried or resent for you: if the server does not confirm in
136
164
  time the call raises `RequestTimeout` and the order **may have been accepted** — look at `orders()` before sending
137
165
  again. `NoQuoteError` is different: no price was available, so nothing was sent. `orders()` can lag a moment behind
@@ -147,8 +175,10 @@ Do not size a trade from a one-line formula: what a price unit is worth depends
147
175
  from your own closed deals and refuses to size without that evidence:
148
176
 
149
177
  ```python
178
+ from datetime import datetime
179
+
150
180
  if c.calibrate_money_scale(datetime(2025, 1, 1)): # read-only; places no order
151
- r = c.lots_for_risk("EURUSD", entry_price=1.1000, stop_price=1.0950, risk_amount=50.0)
181
+ r = c.lots_for_risk("EURUSD", entry_price=1.1000, stop_price=1.0950, risk_amount=50.0, side="buy")
152
182
  for w in r.warnings:
153
183
  print("warning:", w) # read these: ok can still be True with a caveat
154
184
  if r.ok and r.expected_loss_at_stop <= 50.0 * 1.05:
@@ -201,8 +231,8 @@ subscriptions are restored, cached snapshots are not.
201
231
  | API key | `api_key=` | `WMTAPI_KEY` |
202
232
  | Licence service URL (leave at its default) | `token_url=` | `WMTAPI_TOKEN_URL` |
203
233
  | Core library (only if you do not use a wheel that carries it) | `dll_path=` | `WMTAPI_DLL` |
204
- | Timeouts (seconds, each > 0) | `connect_timeout` (one attempt on one server), `cluster_member_timeout` (per server while failing over), `token_timeout`, `execution_timeout`, `quote_timeout` / `history_timeout` / `bars_timeout` (the defaults of `quote`, `history`, `bars`), `ping_interval`, `no_message_timeout` | — |
205
- | Reconnect | `auto_reconnect`, `reconnect_backoff`, `reconnect_max_backoff`, `reconnect_max_attempts` | — |
234
+ | Timeouts (seconds, each > 0) | `connect_timeout` (the whole connect; default 30), `cluster_member_timeout` (per server while failing over; 10), `token_timeout` (10), `execution_timeout` (a trade confirmation; 30), `quote_timeout` (10) / `history_timeout` (30) / `bars_timeout` (10, plus a second per two weeks of one-minute bars a `bars()` call needs) — the defaults of `quote`, `history`, `bars`, `ping_interval` (20), `no_message_timeout` (30) | — |
235
+ | Reconnect | `auto_reconnect`, `reconnect_backoff` (3, doubling up to `reconnect_max_backoff`, 30), `reconnect_max_attempts` (0 = no limit) | — |
206
236
  | Proxy | `proxy=Proxy("socks5" \| "socks4" \| "https", host, port, user, password)` — `host` must be an IP address | — |
207
237
  | Client certificate / common key | `certificate=`, `certificate_password=`, `common_key=` | — |
208
238
  | .NET runtime location | — | `DOTNET_ROOT` |
@@ -225,6 +255,10 @@ pmt5api.enable_core_logging() # the core's own messages -> logging.getL
225
255
  `request_demo_account(DemoAccountRequest(...), host, port)` asks a broker for a demo account (it creates a real
226
256
  account at that broker: use it deliberately); `c.change_password(new, investor=False)`.
227
257
 
258
+ `python -m pmt5api` prints what is installed and whether it works here (`--connect` also reads an account, using
259
+ `PMT5_LOGIN`, `PMT5_PASSWORD`, `PMT5_HOST` — a bare host, or `host:port`, or the port in `PMT5_PORT` — and the key in
260
+ `WMTAPI_KEY`); the output holds no secrets and is what to send when asking for help.
261
+
228
262
  ## Errors
229
263
 
230
264
  Everything the core raises reaches you as one of these, all subclasses of `pmt5api.WmtError`; the original
@@ -233,7 +267,7 @@ exception stays available as `__cause__`.
233
267
  | Exception | Meaning |
234
268
  |---|---|
235
269
  | `LicenseError` | The licence service refused the key. `.reason` is one of `NoKey`, `Invalid`, `Expired`, `Revoked`, `RateLimited`, `LoginNotAllowed`, `AccountLimitReached`, `Unknown`. |
236
- | `TokenServiceError` | The licence service could not be reached or answered unusably — retry, don't re-prompt for a key. |
270
+ | `TokenServiceError` | The licence service could not be reached or answered unusably — retry, don't re-prompt for a key. (`token_timeout` bounds one request and a failed request is tried again once, so a connect to an unreachable licence service can wait about twice that.) |
237
271
  | `ConnectError` | The broker's access server (or the network, or a proxy) could not be reached. |
238
272
  | `ConnectionLostError` | An established connection dropped (the core's `Disconnected`). |
239
273
  | `ServerError` / `InvestorModeError` | The broker refused the request (wrong login or password, a rejected order / a read-only login tried to trade). `.code` is the server's return code by name (`REQUOTE`, `NO_MONEY`, `INVALID_PRICE`, …). |
@@ -277,6 +311,10 @@ moved by 4 MB), threads and file descriptors stayed flat. Every shipped example
277
311
 
278
312
  ## Known limitations
279
313
 
314
+ - **Sizing needs evidence from the account's own closed deals.** On an account whose deals all carry swap or
315
+ commission, or whose deals do not have the shape the calibration can read (a Tickmill demo, for one), `calibrate_money_scale`
316
+ finds no symbol to measure from and `lots_for_risk` refuses: that is the intended answer, not an error. `tick_value()`
317
+ and `required_margin()` still work, and the refusal says what was missing.
280
318
  - **Linux x86-64 is the verified platform.** Windows, macOS, arm64 and musl-based Linux have not been verified.
281
319
  - Netting accounts have not been verified (the available demo accounts are all hedging).
282
320
  - Client certificates (`certificate=`), `common_key=` and `request_demo_account` have been unit-tested but not run
@@ -8,7 +8,7 @@ from ._coverage import COVERAGE, TYPES
8
8
  from .bars import Bar, QuoteHistoryEvent, TIMEFRAMES, aggregate
9
9
  from .client import Client, Proxy
10
10
  from .diagnostics import enable_core_logging
11
- from .errors import (BusyError, ConnectError, NoQuoteError, ConnectionLostError, CoreNotFoundError, InvalidRequestError, InvestorModeError,
11
+ from .errors import (BusyError, ConnectError, NoQuoteError, ConnectionLostError, CoreNotFoundError, InvalidRequestError, InvestorModeError, LoginRejectedError,
12
12
  LicenseError, NotConnectedError, ProxyError, RequestTimeout, RuntimeNotFoundError,
13
13
  ServerError, TokenServiceError, WmtError)
14
14
  from .frames import to_frame
@@ -17,7 +17,7 @@ from .models import (AccountInfo, AccountSummary, BaseInfo, CommissionInfo, Comm
17
17
  OrderUpdate, Quote, ServerRecord, SymbolInfo, SymbolSessions, TradingSession)
18
18
  from .servers import load_servers_dat, request_demo_account, save_servers_dat, tick_value_from_deal
19
19
 
20
- __version__ = "1.0.0"
20
+ __version__ = "1.1.2"
21
21
 
22
22
  __all__ = [
23
23
  "Client", "Proxy",
@@ -28,7 +28,7 @@ __all__ = [
28
28
  "Bar", "TIMEFRAMES", "aggregate", "to_frame",
29
29
  "load_servers_dat", "save_servers_dat", "request_demo_account", "tick_value_from_deal", "enable_core_logging",
30
30
  "WmtError", "LicenseError", "TokenServiceError", "ConnectError", "ConnectionLostError", "NotConnectedError",
31
- "ServerError", "InvestorModeError", "ProxyError", "RequestTimeout", "NoQuoteError", "InvalidRequestError", "BusyError",
31
+ "ServerError", "LoginRejectedError", "InvestorModeError", "ProxyError", "RequestTimeout", "NoQuoteError", "InvalidRequestError", "BusyError",
32
32
  "CoreNotFoundError",
33
33
  "RuntimeNotFoundError", "COVERAGE", "TYPES", "__version__",
34
34
  ]
@@ -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("PMT5_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()
@@ -10,6 +10,11 @@ from .errors import NotConnectedError
10
10
  from .models import AccountInfo, AccountSummary, EquityPoint, History, Order
11
11
 
12
12
 
13
+ def _money(value) -> float:
14
+ """A money figure without the binary noise the core's sums carry (1635341.7899999998 becomes 1635341.79)."""
15
+ return round(float(value), 8)
16
+
17
+
13
18
  class _AccountMixin(_Base):
14
19
  @translated
15
20
  def account(self, refresh: bool = True) -> AccountInfo:
@@ -30,13 +35,14 @@ class _AccountMixin(_Base):
30
35
  raise NotConnectedError("the connection was lost while refreshing the account")
31
36
  api.UpdateProfits()
32
37
  a = api.Account
38
+ m = _money
33
39
  return AccountInfo(
34
40
  login=int(a.Login), name=str(a.UserName or ""), currency=str(api.AccountCurrency or ""),
35
- leverage=int(a.Leverage), balance=float(a.Balance), credit=float(a.Credit),
36
- equity=float(api.AccountEquity), profit=float(api.AccountProfit), margin=float(api.AccountMargin),
37
- free_margin=float(api.AccountFreeMargin), margin_level=float(api.AccountMarginLevel),
38
- floating_pl=float(api.AccountFloatingProfit), swap=float(api.AccountSwap),
39
- commission=float(api.AccountCommission), fee=float(api.AccountFee), blocked=float(a.Blocked),
41
+ leverage=int(a.Leverage), balance=m(a.Balance), credit=m(a.Credit),
42
+ equity=m(api.AccountEquity), profit=m(api.AccountProfit), margin=m(api.AccountMargin),
43
+ free_margin=m(api.AccountFreeMargin), margin_level=m(api.AccountMarginLevel),
44
+ floating_pl=m(api.AccountFloatingProfit), swap=m(api.AccountSwap),
45
+ commission=m(api.AccountCommission), fee=m(api.AccountFee), blocked=m(a.Blocked),
40
46
  company=str(api.AccountCompanyName or ""), method=str(api.AccountMethod), account_type=str(a.Type or ""),
41
47
  trade_flags=int(a.TradeFlags), country=str(a.Country or ""), city=str(a.City or ""),
42
48
  state=str(a.State or ""), zip_code=str(a.ZipCode or ""), address=str(a.UserAddress or ""),
@@ -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")
@@ -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
@@ -34,6 +35,7 @@ class _EventsMixin(_Base):
34
35
  d = getattr(self, "_dispatch", None)
35
36
  if d is None:
36
37
  d = self._dispatch = _Dispatcher()
38
+ weakref.finalize(self, d.stop) # a client that is dropped without disconnect() leaves no thread
37
39
  return d
38
40
 
39
41
  @property
@@ -11,7 +11,7 @@ from typing import Any, Dict, List, Optional, Tuple, Union
11
11
 
12
12
  from ._base import _Base
13
13
  from ._util import translated
14
- from .bars import Bar, aggregate, timeframe_minutes
14
+ from .bars import Bar, _bucket, aggregate, timeframe_minutes
15
15
  from .errors import InvalidRequestError, NoQuoteError, RequestTimeout, translate
16
16
  from .models import (CommissionInfo, Quote, SymbolInfo, SymbolSessions, _opt_float, _symbols_from_core, _time)
17
17
 
@@ -225,7 +225,8 @@ class _MarketMixin(_Base):
225
225
  try:
226
226
  api.RequestQuoteHistoryMonth(symbol, day.year, day.month, day.day)
227
227
  if not done.wait(timeout):
228
- raise RequestTimeout("no bars for %s within %.0f s" % (symbol, timeout))
228
+ raise RequestTimeout("the server sent no bars for %s within %g s of asking (one block of "
229
+ "one-minute bars)" % (symbol, round(timeout, 2)))
229
230
  finally:
230
231
  api.OnQuoteHistory -= handler
231
232
  return [Bar._from_core(b) for b in got]
@@ -252,30 +253,43 @@ class _MarketMixin(_Base):
252
253
  count = 500
253
254
  if not self._api.Symbols.Exist(symbol):
254
255
  raise InvalidRequestError("unknown symbol %r" % symbol)
255
- total = float(timeout) if timeout is not None else self._api.DownloadQuoteHistoryTimeout / 1000.0
256
- deadline = time.monotonic() + total
257
256
  server_now = self.server_time or datetime.now(timezone.utc).replace(tzinfo=None)
258
257
  ref = end if end is not None else server_now + timedelta(days=1)
258
+ if timeout is not None:
259
+ total = float(timeout)
260
+ else:
261
+ # The server hands over one-minute bars in blocks of about two weeks, so a deep request is many round
262
+ # trips: the default budget is the client's bars_timeout plus a second for every block expected.
263
+ span_minutes = (count * minutes * 1.4) if count is not None else \
264
+ max(0.0, (ref - start).total_seconds() / 60.0) if start is not None else 0.0
265
+ blocks = int(span_minutes / (14 * 1440)) + 1
266
+ total = min(self._api.DownloadQuoteHistoryTimeout / 1000.0 + blocks, 900.0)
267
+ deadline = time.monotonic() + total
259
268
  cursor = ref.date()
260
269
  minute_bars: Dict[datetime, Bar] = {}
270
+ buckets = set() # the bars the request would produce, counted as the blocks arrive
261
271
  earliest: Optional[datetime] = None
262
272
  for _ in range(400): # hard stop: a block is ~2 weeks of FX, so this is years of data
263
273
  remaining = deadline - time.monotonic()
264
274
  if remaining <= 0:
265
- raise RequestTimeout("bars for %s not complete within %.0f s" % (symbol, total))
266
- block = self._minute_block(symbol, cursor, remaining)
275
+ raise RequestTimeout("bars for %s not complete within %g s" % (symbol, round(total, 2)))
276
+ try:
277
+ block = self._minute_block(symbol, cursor, remaining)
278
+ except RequestTimeout: # one block ran out of what was left: say what the CALL was given
279
+ raise RequestTimeout("bars for %s not complete within %g s" % (symbol, round(total, 2))) from None
267
280
  if not block:
268
281
  break
269
282
  for b in block:
270
- if end is None or b.time <= end:
271
- minute_bars.setdefault(b.time, b)
283
+ if (end is None or b.time <= end) and b.time not in minute_bars:
284
+ minute_bars[b.time] = b
285
+ buckets.add(_bucket(b.time, minutes))
272
286
  first = min(b.time for b in block)
273
287
  if earliest is not None and first >= earliest:
274
288
  break
275
289
  earliest = first
276
290
  if start is not None and first <= start:
277
291
  break
278
- if count is not None and len(aggregate(minute_bars.values(), minutes)) > count:
292
+ if count is not None and len(buckets) > count:
279
293
  break # one spare: the oldest bucket may be cut by the block edge
280
294
  cursor = first.date() # the same day: nothing before `first` on that day is skipped
281
295
  series = aggregate(minute_bars.values(), minutes)
@@ -48,13 +48,25 @@ class _RiskMixin(_Base):
48
48
  def lots_for_risk(self, symbol: str, entry_price: float, stop_price: float, risk_amount: float, *,
49
49
  lot_step: Optional[float] = None, min_lot: Optional[float] = None,
50
50
  max_ratio: Optional[float] = None, max_equity_share: Optional[float] = None,
51
- max_lot: Optional[float] = None) -> LotSizing:
51
+ max_lot: Optional[float] = None, side: Optional[str] = None) -> LotSizing:
52
52
  """The volume that loses ``risk_amount`` (account currency) if ``stop_price`` is hit, with the evidence.
53
53
 
54
54
  Requires a successful :meth:`calibrate_money_scale`; without it the answer is a refusal. The volume is
55
- rounded **down**. Check ``ok``: when false, ``lots`` is 0 and ``reason`` says which check failed.
55
+ rounded **down**. Check ``ok``: when false, ``lots`` is 0 and ``reason`` says which check failed. Give ``side``
56
+ (``"buy"`` or ``"sell"``) and a stop on the wrong side of the entry is refused instead of sized by its distance.
56
57
  """
57
58
  self._require_connected()
59
+ if side is not None:
60
+ key = str(side).strip().lower()
61
+ if key not in ("buy", "sell"):
62
+ raise ValueError("side must be 'buy' or 'sell'")
63
+ wrong = (stop_price >= entry_price) if key == "buy" else (stop_price <= entry_price)
64
+ if wrong: # a stop on the wrong side of the entry is not a stop
65
+ return LotSizing(ok=False, lots=0.0, expected_loss_at_stop=0.0, risk_requested=float(risk_amount),
66
+ minimum_trade_cost=0.0, ratio=0.0,
67
+ reason="a %s needs its stop %s the entry price (entry %g, stop %g)"
68
+ % (key, "below" if key == "buy" else "above", entry_price, stop_price),
69
+ warnings=())
58
70
  kwargs = {}
59
71
  for key, value in (("lotStep", lot_step), ("minLot", min_lot), ("maxRatio", max_ratio),
60
72
  ("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 = "WMTAPI_DLL"
17
18
 
18
19
  _NAMESPACE = "wmt5api" # 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: