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.
- pmt5api-1.1.2/CHANGELOG.md +79 -0
- {pmt5api-1.0.0 → pmt5api-1.1.2}/PKG-INFO +53 -15
- {pmt5api-1.0.0 → pmt5api-1.1.2}/README.md +52 -14
- {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/__init__.py +3 -3
- {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/__main__.py +4 -0
- {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/_account.py +11 -5
- {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/_base.py +6 -0
- {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/_events.py +2 -0
- {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/_market.py +23 -9
- {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/_risk.py +14 -2
- {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/_runtime.py +24 -2
- {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/_session.py +30 -2
- {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/_streams.py +39 -6
- {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/_trading.py +71 -4
- {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/_util.py +5 -3
- {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/bars.py +26 -12
- {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/client.py +4 -0
- {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/docs/API.md +154 -10
- {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/errors.py +10 -0
- {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/models.py +138 -3
- pmt5api-1.0.0/CHANGELOG.md +0 -30
- {pmt5api-1.0.0 → pmt5api-1.1.2}/LICENSE.txt +0 -0
- {pmt5api-1.0.0 → pmt5api-1.1.2}/examples/bars.py +0 -0
- {pmt5api-1.0.0 → pmt5api-1.1.2}/examples/connect.py +0 -0
- {pmt5api-1.0.0 → pmt5api-1.1.2}/examples/pnl.py +0 -0
- {pmt5api-1.0.0 → pmt5api-1.1.2}/examples/servers.py +0 -0
- {pmt5api-1.0.0 → pmt5api-1.1.2}/examples/sizing.py +0 -0
- {pmt5api-1.0.0 → pmt5api-1.1.2}/examples/stream_async.py +0 -0
- {pmt5api-1.0.0 → pmt5api-1.1.2}/examples/stream_quotes.py +0 -0
- {pmt5api-1.0.0 → pmt5api-1.1.2}/examples/trade_demo.py +0 -0
- {pmt5api-1.0.0 → pmt5api-1.1.2}/hatch_build.py +0 -0
- {pmt5api-1.0.0 → pmt5api-1.1.2}/pyproject.toml +0 -0
- {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/_coverage.py +0 -0
- {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/diagnostics.py +0 -0
- {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/docs/FROM-CSHARP.md +0 -0
- {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/frames.py +0 -0
- {pmt5api-1.0.0 → pmt5api-1.1.2}/src/pmt5api/py.typed +0 -0
- {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.
|
|
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.
|
|
41
|
-
|
|
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
|
|
46
|
+
pip install pmt5api
|
|
49
47
|
```
|
|
50
48
|
|
|
51
|
-
|
|
52
|
-
the
|
|
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
|
|
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
|
|
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` (
|
|
232
|
-
| Reconnect | `auto_reconnect`, `reconnect_backoff
|
|
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.
|
|
14
|
-
|
|
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
|
|
19
|
+
pip install pmt5api
|
|
22
20
|
```
|
|
23
21
|
|
|
24
|
-
|
|
25
|
-
the
|
|
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
|
|
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
|
|
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` (
|
|
205
|
-
| Reconnect | `auto_reconnect`, `reconnect_backoff
|
|
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.
|
|
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=
|
|
36
|
-
equity=
|
|
37
|
-
free_margin=
|
|
38
|
-
floating_pl=
|
|
39
|
-
commission=
|
|
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
|
|
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
|
|
266
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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:
|