pmt4api 1.0.0__tar.gz → 1.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. pmt4api-1.2.0/CHANGELOG.md +102 -0
  2. {pmt4api-1.0.0 → pmt4api-1.2.0}/PKG-INFO +70 -18
  3. {pmt4api-1.0.0 → pmt4api-1.2.0}/README.md +69 -17
  4. {pmt4api-1.0.0 → pmt4api-1.2.0}/examples/pnl.py +5 -3
  5. {pmt4api-1.0.0 → pmt4api-1.2.0}/src/pmt4api/__init__.py +1 -1
  6. {pmt4api-1.0.0 → pmt4api-1.2.0}/src/pmt4api/__main__.py +4 -0
  7. {pmt4api-1.0.0 → pmt4api-1.2.0}/src/pmt4api/_account.py +54 -28
  8. {pmt4api-1.0.0 → pmt4api-1.2.0}/src/pmt4api/_base.py +6 -0
  9. {pmt4api-1.0.0 → pmt4api-1.2.0}/src/pmt4api/_coverage.py +5 -0
  10. {pmt4api-1.0.0 → pmt4api-1.2.0}/src/pmt4api/_events.py +2 -0
  11. {pmt4api-1.0.0 → pmt4api-1.2.0}/src/pmt4api/_risk.py +14 -2
  12. {pmt4api-1.0.0 → pmt4api-1.2.0}/src/pmt4api/_runtime.py +92 -8
  13. {pmt4api-1.0.0 → pmt4api-1.2.0}/src/pmt4api/_session.py +2 -0
  14. {pmt4api-1.0.0 → pmt4api-1.2.0}/src/pmt4api/_streams.py +44 -10
  15. {pmt4api-1.0.0 → pmt4api-1.2.0}/src/pmt4api/_trading.py +5 -3
  16. {pmt4api-1.0.0 → pmt4api-1.2.0}/src/pmt4api/_util.py +5 -3
  17. {pmt4api-1.0.0 → pmt4api-1.2.0}/src/pmt4api/bars.py +7 -0
  18. {pmt4api-1.0.0 → pmt4api-1.2.0}/src/pmt4api/client.py +13 -5
  19. {pmt4api-1.0.0 → pmt4api-1.2.0}/src/pmt4api/diagnostics.py +8 -0
  20. {pmt4api-1.0.0 → pmt4api-1.2.0}/src/pmt4api/docs/API.md +112 -22
  21. {pmt4api-1.0.0 → pmt4api-1.2.0}/src/pmt4api/docs/FROM-CSHARP.md +3 -1
  22. {pmt4api-1.0.0 → pmt4api-1.2.0}/src/pmt4api/errors.py +25 -2
  23. {pmt4api-1.0.0 → pmt4api-1.2.0}/src/pmt4api/models.py +78 -2
  24. pmt4api-1.0.0/CHANGELOG.md +0 -17
  25. {pmt4api-1.0.0 → pmt4api-1.2.0}/LICENSE.txt +0 -0
  26. {pmt4api-1.0.0 → pmt4api-1.2.0}/examples/bars.py +0 -0
  27. {pmt4api-1.0.0 → pmt4api-1.2.0}/examples/connect.py +0 -0
  28. {pmt4api-1.0.0 → pmt4api-1.2.0}/examples/servers.py +0 -0
  29. {pmt4api-1.0.0 → pmt4api-1.2.0}/examples/sizing.py +0 -0
  30. {pmt4api-1.0.0 → pmt4api-1.2.0}/examples/stream_async.py +0 -0
  31. {pmt4api-1.0.0 → pmt4api-1.2.0}/examples/stream_quotes.py +0 -0
  32. {pmt4api-1.0.0 → pmt4api-1.2.0}/examples/trade_demo.py +0 -0
  33. {pmt4api-1.0.0 → pmt4api-1.2.0}/hatch_build.py +0 -0
  34. {pmt4api-1.0.0 → pmt4api-1.2.0}/pyproject.toml +0 -0
  35. {pmt4api-1.0.0 → pmt4api-1.2.0}/src/pmt4api/_market.py +0 -0
  36. {pmt4api-1.0.0 → pmt4api-1.2.0}/src/pmt4api/frames.py +0 -0
  37. {pmt4api-1.0.0 → pmt4api-1.2.0}/src/pmt4api/py.typed +0 -0
  38. {pmt4api-1.0.0 → pmt4api-1.2.0}/src/pmt4api/servers.py +0 -0
@@ -0,0 +1,102 @@
1
+ # Changelog — pmt4api
2
+
3
+ ## [1.2.0] — 2026-11-06
4
+
5
+ Bundles **wmt4api 3.4.0**. A minor release with a **Breaking** section, like the core's: history is always asked for
6
+ by a window you choose.
7
+
8
+ ### Breaking — history is always asked for by window
9
+ - **`history(start, end)` and `summary(start, end)` need both ends.** `history()` no longer defaults to the last 30
10
+ days and `summary()` no longer means "all time" (the core downloaded every order since 1970 for it, on every call).
11
+ A missing or reversed window raises `InvalidRequestError` before anything is sent. `ahistory` likewise.
12
+ - **One call covers at most `max_history_days` (366 by default).** A longer window raises `InvalidRequestError`
13
+ before a connection is opened or a licence request spent. Ask window by window and add the results up, or set
14
+ `Client(max_history_days=...)` / `c.max_history_days` when you mean one long download (`0` removes the limit; the
15
+ ends stay required). `equity_history(start)` runs to now, so its start is at most that long ago.
16
+ - What to change: `c.summary()` → `c.summary(start, end)`; `c.history()` → `c.history(start, end)` (for "up to now",
17
+ `end = c.server_time`).
18
+
19
+ ### Added
20
+ - `Client.max_history_days` (read and write) and the constructor argument of the same name.
21
+
22
+ ### Fixed (this package)
23
+ - **The platform wheel could not start on a machine that also has a newer .NET installed on `PATH`** (for example a
24
+ system .NET 10 next to the bundled .NET 9): the loader under pythonnet asked that `dotnet` for its runtimes, took
25
+ the newest and looked for it in the bundled runtime — `RuntimeNotFoundError` ("needs the .NET 8 (or later)
26
+ runtime"), on 1.1.2 too. The runtime is now started at a version the chosen runtime folder holds, and the message
27
+ says which runtime it tried.
28
+
29
+ ### Fixed (core wmt4api 3.4.0)
30
+ - **Order updates could reach `on_order_update` / `order_updates()` out of order** — a close before its open included
31
+ (seen on IC Markets: a commission modify before the open). They now arrive in the order the server sent them; the
32
+ same holds for quotes, order progress and connect / disconnect.
33
+ - **`equity_history()` returned an empty list for a window with no closed order.** It is a flat line at the balance
34
+ (plus the floating P/L of open positions), the last point the live balance and equity — as in `pmt5api`.
35
+
36
+ ### Security (core wmt4api 3.4.0)
37
+ - `token_url` must be `https` (plain `http` only for a loopback host) and carry no user information, query or
38
+ fragment; redirects from the licence service are not followed (they would re-send the key).
39
+ - A login already bound to another trading account under your licence is refused with `LicenseError` reason
40
+ `AccountLimitReached` and a message that says so.
41
+
42
+ ## [1.1.2] — 2026-10-15
43
+
44
+ From the delta re-test of 1.1.1 (no S1 or S2). Python layer only: the cores are unchanged.
45
+
46
+ ### Fixed
47
+ - `Order` money fields (profit, swap, commission) carry no binary noise.
48
+ - README: a connect to an unreachable licence service waits several times `token_timeout` (about 44 s at the default 10: the retry, and each server of the list).
49
+
50
+ ## [1.1.1] — 2026-10-14
51
+
52
+ From the Windows re-test of 1.1.0 (13 notes, one S2). Bundles wmt4api 3.3.1.
53
+
54
+ ### Fixed
55
+ - A licence request no longer parks its connection (core `wmt4api 3.3.1`): repeated `history()` calls held about four
56
+ handles each until the next garbage collection.
57
+ - A missing .NET runtime on a **32-bit** Python says that 32-bit Python needs the 32-bit runtime.
58
+ - A quote-history timeout says how long it waited.
59
+ - README: how to read why a position closed (`[sl]` / `[tp]` in the comment), Ctrl+C during `connect()`, the licence
60
+ request retry.
61
+
62
+ ## [1.1.0] — 2026-10-13
63
+
64
+ Found by a customer-style test on Windows Server 2022 / Python 3.10 (28 findings); every one is addressed. Bundles
65
+ wmt4api 3.3.0. Additive: nothing that worked in 1.0.0 is removed.
66
+
67
+ ### Fixed
68
+ - **Two packages could not start the .NET runtime from two threads at once** (`RuntimeNotFoundError` in one of them,
69
+ every time, on Linux as well). One lock now covers every `pmt*api` package in the process.
70
+ - **`bars()` re-aggregated the whole series after every block** (MT5): a deep request was quadratic and timed out
71
+ (`D1` × 1000 took over two minutes of CPU). It counts as the blocks arrive and aggregates once; the default budget
72
+ is `bars_timeout` plus a second per two weeks of one-minute bars the request needs.
73
+ - **A closed client left a thread behind** for every client that had used callbacks or `quotes()`: the callback
74
+ thread now ends at `disconnect()` (and when the client is dropped) and returns with the next event.
75
+ - **Type checkers saw every method as `(*Any, **Any) -> Any`**: the decorator that translates errors now keeps the
76
+ signature.
77
+ - Messages: a missing .NET runtime names the runtime and where to get it; the no-key text speaks `api_key=`; a bars
78
+ timeout states its real wait; a trade refusal names its code.
79
+ - `python -m pmt4api --connect` / `pmt5api --connect` accept `host:port` in `PMT*_HOST`.
80
+ - The reference gains a meaning for every field of the main data types.
81
+ - `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`).
82
+ - The core fixes: see wmt4api 3.3.0 — **the account balance and credit now follow closes, deposits and withdrawals in the session** (they stayed at their value from connect).
83
+
84
+ ### Added
85
+ - `lots_for_risk(..., side="buy" | "sell")`: a stop on the wrong side of the entry is refused instead of being sized by its distance.
86
+ - `reconnect_max_backoff` (seconds, default 30): the longest delay between reconnect attempts.
87
+
88
+ ## [1.0.0] — 2026-10-12
89
+ First release: the complete Python interface to wmt4api 3.2.0 (the MetaTrader 4 client library).
90
+
91
+ - Every public member and type of the C# `MT4API` / `OrderClient` classes is mapped to a Python counterpart, or has a
92
+ stated reason for not having one (`pmt4api.COVERAGE`, `pmt4api.TYPES`; a test fails when the core gains an
93
+ unaccounted member).
94
+ - Market data (symbols, quotes, bars on the nine MT4 timeframes with paging), account, sizing and risk, order entry
95
+ (market, pending, modify, partial close, close-by, close-all-by, cancel; synchronous, with request ids and progress
96
+ events, and awaitable), history, summary and equity curve.
97
+ - Callbacks, sync and async iterators, one ordered dispatcher thread per client, with back-pressure accounting.
98
+ - A small error hierarchy; the MT4 trade-return codes by name; requotes carry the new prices.
99
+ - `auto_reconnect`, `servers=` failover, a silence watchdog, `reconnect()`; `disconnect()` is final.
100
+ - Refusals before anything is sent: a magic number the core cannot transmit, a volume the wire cannot carry, a
101
+ comment that would not read back.
102
+ - Wheels: Linux x86-64 with the core and a .NET runtime bundled, and a pure wheel that carries the core.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pmt4api
3
- Version: 1.0.0
3
+ Version: 1.2.0
4
4
  Summary: Complete Python interface to the wmt4api MetaTrader 4 client library (market data, account, sizing, order entry, events).
5
5
  Project-URL: Homepage, https://mtapi.one
6
6
  Author: Sen Trading
@@ -39,19 +39,33 @@ both reads alike.
39
39
 
40
40
  **Parity with the C# library is measured, not claimed:** `pmt4api.COVERAGE` maps every public member of the C#
41
41
  `MT4API` and `OrderClient` classes to its Python counterpart (or states why it has none), and the test suite fails
42
- if the core gains a member that is not accounted for. [`docs/FROM-CSHARP.md`](src/pmt4api/docs/FROM-CSHARP.md) is
43
- that table; [`docs/API.md`](src/pmt4api/docs/API.md) is the reference.
42
+ if the core gains a member that is not accounted for. `pmt4api/docs/FROM-CSHARP.md` (inside the installed package) is
43
+ that table; `pmt4api/docs/API.md` (inside the installed package; `python -m pmt4api` prints its folder) is the reference.
44
44
 
45
45
  ## Install
46
46
 
47
- The **Linux x86-64 wheel** carries everything — the core and a .NET runtime — so there is nothing else to install:
48
-
49
47
  ```
50
- pip install pmt4api-<version>-py3-none-manylinux_2_28_x86_64.whl
48
+ pip install pmt4api
51
49
  ```
52
50
 
53
- On any other platform use the `py3-none-any` wheel, which carries the core and needs a .NET 8 (or later) runtime on
54
- the machine (`dotnet --list-runtimes` should list `Microsoft.NETCore.App`).
51
+ pip chooses the wheel for your platform. The **Linux x86-64 wheel** carries everything — the core and a .NET runtime —
52
+ so there is nothing else to install. On any other platform the `py3-none-any` wheel carries the core and needs a .NET 8
53
+ (or later) runtime on the machine (`dotnet --list-runtimes` should list `Microsoft.NETCore.App`). From a downloaded
54
+ file: `pip install pmt4api-<version>-py3-none-<platform>.whl`.
55
+
56
+ **Windows (x64).** Install the .NET 8 runtime once (`winget install Microsoft.DotNet.Runtime.8`, or the installer from
57
+ https://dotnet.microsoft.com/download/dotnet/8.0), then `pip install pmt4api`. Check it with `python -m pmt4api`: it prints
58
+ the versions, finds the runtime and constructs a client offline. Set the key in the shell that runs your program —
59
+ PowerShell: `$env:WMT4API_KEY = "wk_..."`, cmd: `set WMT4API_KEY=wk_...`. A string the broker sent can hold characters your
60
+ console's code page cannot print: set `PYTHONUTF8=1` (or run `chcp 65001`) to avoid a `UnicodeEncodeError`.
61
+
62
+ **Windows (x64).** Use the `py3-none-any` wheel from PyPI — `pip install pmt4api` picks it — and install the .NET 8
63
+ runtime once (`winget install Microsoft.DotNet.Runtime.8`, or the installer from
64
+ https://dotnet.microsoft.com/download/dotnet/8.0); `dotnet --list-runtimes` must list `Microsoft.NETCore.App 8.x` or
65
+ later. Check the install with `python -m pmt4api`: it prints the versions, finds the runtime and constructs a client
66
+ offline. Set the key in the shell that runs your program — PowerShell: `$env:WMT4API_KEY = "wk_..."`, cmd:
67
+ `set WMT4API_KEY=wk_...`. A library string can hold characters your console code page cannot print: set
68
+ `PYTHONUTF8=1` (or run `chcp 65001`) to avoid a `UnicodeEncodeError` when you print what the broker sent.
55
69
 
56
70
  You also need your **API key** (`wk_…`, the same key you use with wmt4api):
57
71
 
@@ -104,6 +118,10 @@ Timeframes: `M1 M5 M15 M30 H1 H4 D1 W1 MN1` — the nine the MT4 server serves;
104
118
  (M1 is typically a few days), so a long request returns what exists; one request carries at most 32 767 bars and
105
119
  longer requests page backwards for you. `volume` is the tick volume.
106
120
 
121
+ **MT4 and MT5 differ here.** The MT4 package returns completed bars only and excludes a bar that opens at `end=`; on
122
+ MT5 `end=` includes the bar stamped exactly `end` and the bar still forming. Code that runs on both has to drop the
123
+ forming MT5 bar itself.
124
+
107
125
  ### Live quotes
108
126
 
109
127
  ```python
@@ -146,14 +164,16 @@ drop the oldest item when the consumer falls behind, and detach on `close()` / `
146
164
  ## Trading
147
165
 
148
166
  ```python
167
+ from datetime import timedelta
168
+
149
169
  o = c.buy("EURUSDm", 0.10, sl=1.0950, tp=1.1100, comment="bot-1") # market order at the ask; sell() at the bid
150
- c.modify(o.ticket, sl=1.0970) # change stops; what you leave out keeps its value
151
- c.close(o.ticket, 0.05) # close half; close(ticket) closes it all
170
+ c.modify(o.ticket, sl=1.0970) # change stops (returns the order as the server holds it)
171
+ c.close(o.ticket, 0.05) # close half (returns the closing record); close(ticket) closes it all
152
172
 
153
173
  p = c.place_order("EURUSDm", "buy_stop", 0.10, 1.1200, sl=1.1150,
154
174
  expiration=timedelta(days=2)) # also buy_limit sell_limit sell_stop; a datetime works too
155
175
  c.modify(p.ticket, price=1.1210) # a pending order's price; its expiration is kept
156
- c.cancel(p.ticket)
176
+ c.cancel(p.ticket) # returns the order that was deleted
157
177
 
158
178
  c.close_by(a.ticket, b.ticket) # close one position against an opposite one (hedged accounts)
159
179
  c.close_all_by("EURUSDm") # ... every opposite pair of the symbol
@@ -192,7 +212,7 @@ remainder of the position may carry a new ticket: read `positions()`.
192
212
  ## Sizing and risk
193
213
 
194
214
  ```python
195
- r = c.lots_for_risk("EURUSDm", entry_price=1.1000, stop_price=1.0950, risk_amount=50.0)
215
+ r = c.lots_for_risk("EURUSDm", entry_price=1.1000, stop_price=1.0950, risk_amount=50.0, side="buy")
196
216
  for w in r.warnings:
197
217
  print("warning:", w) # ok can still be True with a caveat (the broker's minimum volume risks more)
198
218
  if r.ok:
@@ -215,22 +235,38 @@ cent-account scaling, so check the first result against your terminal. Also: `re
215
235
  | `balance`, `equity`, `margin`, `free_margin`, `margin_level`, `profit` | the same figures as cheap reads from the cache (no refresh; the margin may still wait for a conversion pair's first tick) |
216
236
  | `orders()` / `positions()` / `pending_orders()` / `order(ticket)` | `list[Order]` working / open positions / pending / one |
217
237
  | `history(start, end)` | `list[Order]` — closed orders and balance / credit entries, by close time |
218
- | `summary(start=None, end=None)` | `AccountSummary` — realized profit, deposits, withdrawals |
238
+ | `summary(start, end)` | `AccountSummary` — realized profit, deposits, withdrawals |
219
239
  | `equity_history(start, "D1")` | `list[EquityPoint]` — balance (exact) and equity (reconstructed) curve with drawdowns |
220
240
 
221
241
  MT4 keeps one record for open positions, pending orders, closed orders and ledger entries: `Order.order_type` tells
222
242
  them apart (`Buy` / `Sell`, `BuyLimit` … , `Balance` / `Credit`), and `is_market`, `is_pending`, `is_capital_flow`
223
243
  say it directly. Times are naive `datetime` values in the **broker's server time**; an unset time is `None`.
224
244
 
245
+ **History is asked for by window.** `history()` and `summary()` take a start and an end, and one call covers at most
246
+ `max_history_days` (366 by default; set it on the client, `0` removes the limit); `equity_history()` runs from its
247
+ start to now, so its start is at most that long ago. A missing, reversed or longer window raises
248
+ `InvalidRequestError` before anything is sent. For a longer period, ask window by window and add the results up:
249
+
250
+ ```python
251
+ from datetime import timedelta
252
+
253
+ end = c.server_time # server time; "up to now"
254
+ closed = c.history(end - timedelta(days=30), end)
255
+ year = c.summary(end - timedelta(days=365), end)
256
+ ```
257
+
225
258
  `history()`, `summary()` and `equity_history()` each open their own short-lived broker connection and cost one
226
259
  licence request, which the licence service rate-limits per minute: fetch once and reuse the result. `history()`
227
- takes a `threading.Event` as `cancel=` to abandon the wait.
260
+ takes a `threading.Event` as `cancel=` to abandon the wait. A window with no closed order in it gives a flat equity
261
+ curve at the balance, never an empty one.
228
262
 
229
263
  ## Events
230
264
 
231
265
  `on_quote`, `on_order_update`, `on_order_progress`, `on_connect`, `on_disconnect`, `on_quote_history`,
232
266
  `on_symbols_update` — each returns a function that removes the callback. `quotes()`, `order_updates()` and
233
- `order_progress()` (and the `a…` async versions) turn a stream into an iterator.
267
+ `order_progress()` (and the `a…` async versions) turn a stream into an iterator. Events reach you in the order the
268
+ server sent them — a ticket's open before its modifications and its close — and callbacks run one at a time on the
269
+ client's own thread.
234
270
 
235
271
  ## Staying connected
236
272
 
@@ -251,15 +287,23 @@ restored, cached snapshots are not. `disconnect()` is final: it also stops a rec
251
287
  | API key | `api_key=` | `WMT4API_KEY` (then `WMTAPI_KEY`) |
252
288
  | Licence service URL (leave at its default) | `token_url=` | `WMT4API_TOKEN_URL` |
253
289
  | Core library (only if you do not use a wheel that carries it) | `dll_path=` | `WMT4API_DLL` |
254
- | Timeouts (seconds, each > 0) | `connect_timeout` (one attempt on one server), `token_timeout`, `trade_timeout` (≥ 1), `quote_timeout` / `history_timeout` / `bars_timeout` (the defaults of `quote`, `history`, `bars`), `no_message_timeout` | — |
255
- | Reconnect | `auto_reconnect`, `reconnect_backoff`, `reconnect_max_attempts`, `servers=` | — |
290
+ | Timeouts (seconds, each > 0) | `connect_timeout` (one attempt on one server; default 6), `token_timeout` (10), `trade_timeout` (30; ≥ 1), `quote_timeout` (5) / `history_timeout` (30) / `bars_timeout` (30) — the defaults of `quote`, `history`, `bars`, `no_message_timeout` (30; ≥ 10, or 0 for off) | — |
291
+ | Reconnect | `auto_reconnect`, `reconnect_backoff` (3, doubling up to `reconnect_max_backoff`, 30), `reconnect_max_attempts` (0 = no limit), `servers=` | — |
256
292
  | Orders | `placed_type=` (`manual`, `expert`, `dealer` …), `comment_encoding=` (default `latin-1`) | — |
293
+ | History window | `max_history_days=` (366; `0` = no limit) — also a client attribute | — |
257
294
  | .NET runtime location | — | `DOTNET_ROOT` |
258
295
 
259
296
  A core path you give (argument or `WMT4API_DLL`) is used exactly as given and is an error if it does not exist. The
260
297
  .NET runtime is taken from `DOTNET_ROOT` if you set it, else the runtime bundled in the wheel, else an installed one.
261
298
  The bundled runtime runs in invariant-culture mode on machines without ICU. The MT4 core has no proxy support.
262
299
 
300
+ **These two settings decide what code runs in your process.** `WMT4API_DLL` (or `dll_path=`) names the library that is
301
+ loaded, and `DOTNET_ROOT` (or, without it, the `dotnet` found on `PATH`) names the .NET runtime that is started;
302
+ both run with your rights. Set them only from configuration you control, and do not run with an environment
303
+ someone else can change. With `enable_core_logging()` switched on, the package logs once when either was chosen
304
+ by the environment. The licence service URL must be `https` (plain `http` only for a loopback test service), and
305
+ redirects from it are not followed.
306
+
263
307
  ### Diagnostics
264
308
 
265
309
  ```python
@@ -272,7 +316,15 @@ pmt4api.enable_core_logging() # the core's own messages -> logging.getL
272
316
 
273
317
  `load_servers_ini(path_or_bytes)` / `save_servers_ini(records)` read and write a terminal's `servers.ini`.
274
318
  `python -m pmt4api` prints what is installed and whether it works here (`--connect` also reads an account, using
275
- `PMT4_LOGIN`, `PMT4_PASSWORD`, `PMT4_HOST`); the output holds no secrets.
319
+ `PMT4_LOGIN`, `PMT4_PASSWORD`, `PMT4_HOST` — a bare host, or `host:port`, or the port in `PMT4_PORT`); the output holds
320
+ no secrets.
321
+
322
+ ## What a closed order says about why
323
+
324
+ An MT4 server pushes the same `PositionClose` update for a stop-loss, a take-profit and a close you asked for; the
325
+ reason is in the order's `comment`, which the server suffixes with `[sl]` or `[tp]` for a stop or a take-profit hit.
326
+ Pressing Ctrl+C while `connect()` is dialling is honoured when the dial ends (up to `connect_timeout`), not at once.
327
+ `token_timeout` bounds one licence request; a failed request is tried again and a connect tries each server of the list, so a connect to an unreachable licence service can wait several times that (about 44 s at the default 10).
276
328
 
277
329
  ## Errors
278
330
 
@@ -12,19 +12,33 @@ both reads alike.
12
12
 
13
13
  **Parity with the C# library is measured, not claimed:** `pmt4api.COVERAGE` maps every public member of the C#
14
14
  `MT4API` and `OrderClient` classes to its Python counterpart (or states why it has none), and the test suite fails
15
- if the core gains a member that is not accounted for. [`docs/FROM-CSHARP.md`](src/pmt4api/docs/FROM-CSHARP.md) is
16
- that table; [`docs/API.md`](src/pmt4api/docs/API.md) is the reference.
15
+ if the core gains a member that is not accounted for. `pmt4api/docs/FROM-CSHARP.md` (inside the installed package) is
16
+ that table; `pmt4api/docs/API.md` (inside the installed package; `python -m pmt4api` prints its folder) is the reference.
17
17
 
18
18
  ## Install
19
19
 
20
- The **Linux x86-64 wheel** carries everything — the core and a .NET runtime — so there is nothing else to install:
21
-
22
20
  ```
23
- pip install pmt4api-<version>-py3-none-manylinux_2_28_x86_64.whl
21
+ pip install pmt4api
24
22
  ```
25
23
 
26
- On any other platform use the `py3-none-any` wheel, which carries the core and needs a .NET 8 (or later) runtime on
27
- the machine (`dotnet --list-runtimes` should list `Microsoft.NETCore.App`).
24
+ pip chooses the wheel for your platform. The **Linux x86-64 wheel** carries everything — the core and a .NET runtime —
25
+ so there is nothing else to install. On any other platform the `py3-none-any` wheel carries the core and needs a .NET 8
26
+ (or later) runtime on the machine (`dotnet --list-runtimes` should list `Microsoft.NETCore.App`). From a downloaded
27
+ file: `pip install pmt4api-<version>-py3-none-<platform>.whl`.
28
+
29
+ **Windows (x64).** Install the .NET 8 runtime once (`winget install Microsoft.DotNet.Runtime.8`, or the installer from
30
+ https://dotnet.microsoft.com/download/dotnet/8.0), then `pip install pmt4api`. Check it with `python -m pmt4api`: it prints
31
+ the versions, finds the runtime and constructs a client offline. Set the key in the shell that runs your program —
32
+ PowerShell: `$env:WMT4API_KEY = "wk_..."`, cmd: `set WMT4API_KEY=wk_...`. A string the broker sent can hold characters your
33
+ console's code page cannot print: set `PYTHONUTF8=1` (or run `chcp 65001`) to avoid a `UnicodeEncodeError`.
34
+
35
+ **Windows (x64).** Use the `py3-none-any` wheel from PyPI — `pip install pmt4api` picks it — and install the .NET 8
36
+ runtime once (`winget install Microsoft.DotNet.Runtime.8`, or the installer from
37
+ https://dotnet.microsoft.com/download/dotnet/8.0); `dotnet --list-runtimes` must list `Microsoft.NETCore.App 8.x` or
38
+ later. Check the install with `python -m pmt4api`: it prints the versions, finds the runtime and constructs a client
39
+ offline. Set the key in the shell that runs your program — PowerShell: `$env:WMT4API_KEY = "wk_..."`, cmd:
40
+ `set WMT4API_KEY=wk_...`. A library string can hold characters your console code page cannot print: set
41
+ `PYTHONUTF8=1` (or run `chcp 65001`) to avoid a `UnicodeEncodeError` when you print what the broker sent.
28
42
 
29
43
  You also need your **API key** (`wk_…`, the same key you use with wmt4api):
30
44
 
@@ -77,6 +91,10 @@ Timeframes: `M1 M5 M15 M30 H1 H4 D1 W1 MN1` — the nine the MT4 server serves;
77
91
  (M1 is typically a few days), so a long request returns what exists; one request carries at most 32 767 bars and
78
92
  longer requests page backwards for you. `volume` is the tick volume.
79
93
 
94
+ **MT4 and MT5 differ here.** The MT4 package returns completed bars only and excludes a bar that opens at `end=`; on
95
+ MT5 `end=` includes the bar stamped exactly `end` and the bar still forming. Code that runs on both has to drop the
96
+ forming MT5 bar itself.
97
+
80
98
  ### Live quotes
81
99
 
82
100
  ```python
@@ -119,14 +137,16 @@ drop the oldest item when the consumer falls behind, and detach on `close()` / `
119
137
  ## Trading
120
138
 
121
139
  ```python
140
+ from datetime import timedelta
141
+
122
142
  o = c.buy("EURUSDm", 0.10, sl=1.0950, tp=1.1100, comment="bot-1") # market order at the ask; sell() at the bid
123
- c.modify(o.ticket, sl=1.0970) # change stops; what you leave out keeps its value
124
- c.close(o.ticket, 0.05) # close half; close(ticket) closes it all
143
+ c.modify(o.ticket, sl=1.0970) # change stops (returns the order as the server holds it)
144
+ c.close(o.ticket, 0.05) # close half (returns the closing record); close(ticket) closes it all
125
145
 
126
146
  p = c.place_order("EURUSDm", "buy_stop", 0.10, 1.1200, sl=1.1150,
127
147
  expiration=timedelta(days=2)) # also buy_limit sell_limit sell_stop; a datetime works too
128
148
  c.modify(p.ticket, price=1.1210) # a pending order's price; its expiration is kept
129
- c.cancel(p.ticket)
149
+ c.cancel(p.ticket) # returns the order that was deleted
130
150
 
131
151
  c.close_by(a.ticket, b.ticket) # close one position against an opposite one (hedged accounts)
132
152
  c.close_all_by("EURUSDm") # ... every opposite pair of the symbol
@@ -165,7 +185,7 @@ remainder of the position may carry a new ticket: read `positions()`.
165
185
  ## Sizing and risk
166
186
 
167
187
  ```python
168
- r = c.lots_for_risk("EURUSDm", entry_price=1.1000, stop_price=1.0950, risk_amount=50.0)
188
+ r = c.lots_for_risk("EURUSDm", entry_price=1.1000, stop_price=1.0950, risk_amount=50.0, side="buy")
169
189
  for w in r.warnings:
170
190
  print("warning:", w) # ok can still be True with a caveat (the broker's minimum volume risks more)
171
191
  if r.ok:
@@ -188,22 +208,38 @@ cent-account scaling, so check the first result against your terminal. Also: `re
188
208
  | `balance`, `equity`, `margin`, `free_margin`, `margin_level`, `profit` | the same figures as cheap reads from the cache (no refresh; the margin may still wait for a conversion pair's first tick) |
189
209
  | `orders()` / `positions()` / `pending_orders()` / `order(ticket)` | `list[Order]` working / open positions / pending / one |
190
210
  | `history(start, end)` | `list[Order]` — closed orders and balance / credit entries, by close time |
191
- | `summary(start=None, end=None)` | `AccountSummary` — realized profit, deposits, withdrawals |
211
+ | `summary(start, end)` | `AccountSummary` — realized profit, deposits, withdrawals |
192
212
  | `equity_history(start, "D1")` | `list[EquityPoint]` — balance (exact) and equity (reconstructed) curve with drawdowns |
193
213
 
194
214
  MT4 keeps one record for open positions, pending orders, closed orders and ledger entries: `Order.order_type` tells
195
215
  them apart (`Buy` / `Sell`, `BuyLimit` … , `Balance` / `Credit`), and `is_market`, `is_pending`, `is_capital_flow`
196
216
  say it directly. Times are naive `datetime` values in the **broker's server time**; an unset time is `None`.
197
217
 
218
+ **History is asked for by window.** `history()` and `summary()` take a start and an end, and one call covers at most
219
+ `max_history_days` (366 by default; set it on the client, `0` removes the limit); `equity_history()` runs from its
220
+ start to now, so its start is at most that long ago. A missing, reversed or longer window raises
221
+ `InvalidRequestError` before anything is sent. For a longer period, ask window by window and add the results up:
222
+
223
+ ```python
224
+ from datetime import timedelta
225
+
226
+ end = c.server_time # server time; "up to now"
227
+ closed = c.history(end - timedelta(days=30), end)
228
+ year = c.summary(end - timedelta(days=365), end)
229
+ ```
230
+
198
231
  `history()`, `summary()` and `equity_history()` each open their own short-lived broker connection and cost one
199
232
  licence request, which the licence service rate-limits per minute: fetch once and reuse the result. `history()`
200
- takes a `threading.Event` as `cancel=` to abandon the wait.
233
+ takes a `threading.Event` as `cancel=` to abandon the wait. A window with no closed order in it gives a flat equity
234
+ curve at the balance, never an empty one.
201
235
 
202
236
  ## Events
203
237
 
204
238
  `on_quote`, `on_order_update`, `on_order_progress`, `on_connect`, `on_disconnect`, `on_quote_history`,
205
239
  `on_symbols_update` — each returns a function that removes the callback. `quotes()`, `order_updates()` and
206
- `order_progress()` (and the `a…` async versions) turn a stream into an iterator.
240
+ `order_progress()` (and the `a…` async versions) turn a stream into an iterator. Events reach you in the order the
241
+ server sent them — a ticket's open before its modifications and its close — and callbacks run one at a time on the
242
+ client's own thread.
207
243
 
208
244
  ## Staying connected
209
245
 
@@ -224,15 +260,23 @@ restored, cached snapshots are not. `disconnect()` is final: it also stops a rec
224
260
  | API key | `api_key=` | `WMT4API_KEY` (then `WMTAPI_KEY`) |
225
261
  | Licence service URL (leave at its default) | `token_url=` | `WMT4API_TOKEN_URL` |
226
262
  | Core library (only if you do not use a wheel that carries it) | `dll_path=` | `WMT4API_DLL` |
227
- | Timeouts (seconds, each > 0) | `connect_timeout` (one attempt on one server), `token_timeout`, `trade_timeout` (≥ 1), `quote_timeout` / `history_timeout` / `bars_timeout` (the defaults of `quote`, `history`, `bars`), `no_message_timeout` | — |
228
- | Reconnect | `auto_reconnect`, `reconnect_backoff`, `reconnect_max_attempts`, `servers=` | — |
263
+ | Timeouts (seconds, each > 0) | `connect_timeout` (one attempt on one server; default 6), `token_timeout` (10), `trade_timeout` (30; ≥ 1), `quote_timeout` (5) / `history_timeout` (30) / `bars_timeout` (30) — the defaults of `quote`, `history`, `bars`, `no_message_timeout` (30; ≥ 10, or 0 for off) | — |
264
+ | Reconnect | `auto_reconnect`, `reconnect_backoff` (3, doubling up to `reconnect_max_backoff`, 30), `reconnect_max_attempts` (0 = no limit), `servers=` | — |
229
265
  | Orders | `placed_type=` (`manual`, `expert`, `dealer` …), `comment_encoding=` (default `latin-1`) | — |
266
+ | History window | `max_history_days=` (366; `0` = no limit) — also a client attribute | — |
230
267
  | .NET runtime location | — | `DOTNET_ROOT` |
231
268
 
232
269
  A core path you give (argument or `WMT4API_DLL`) is used exactly as given and is an error if it does not exist. The
233
270
  .NET runtime is taken from `DOTNET_ROOT` if you set it, else the runtime bundled in the wheel, else an installed one.
234
271
  The bundled runtime runs in invariant-culture mode on machines without ICU. The MT4 core has no proxy support.
235
272
 
273
+ **These two settings decide what code runs in your process.** `WMT4API_DLL` (or `dll_path=`) names the library that is
274
+ loaded, and `DOTNET_ROOT` (or, without it, the `dotnet` found on `PATH`) names the .NET runtime that is started;
275
+ both run with your rights. Set them only from configuration you control, and do not run with an environment
276
+ someone else can change. With `enable_core_logging()` switched on, the package logs once when either was chosen
277
+ by the environment. The licence service URL must be `https` (plain `http` only for a loopback test service), and
278
+ redirects from it are not followed.
279
+
236
280
  ### Diagnostics
237
281
 
238
282
  ```python
@@ -245,7 +289,15 @@ pmt4api.enable_core_logging() # the core's own messages -> logging.getL
245
289
 
246
290
  `load_servers_ini(path_or_bytes)` / `save_servers_ini(records)` read and write a terminal's `servers.ini`.
247
291
  `python -m pmt4api` prints what is installed and whether it works here (`--connect` also reads an account, using
248
- `PMT4_LOGIN`, `PMT4_PASSWORD`, `PMT4_HOST`); the output holds no secrets.
292
+ `PMT4_LOGIN`, `PMT4_PASSWORD`, `PMT4_HOST` — a bare host, or `host:port`, or the port in `PMT4_PORT`); the output holds
293
+ no secrets.
294
+
295
+ ## What a closed order says about why
296
+
297
+ An MT4 server pushes the same `PositionClose` update for a stop-loss, a take-profit and a close you asked for; the
298
+ reason is in the order's `comment`, which the server suffixes with `[sl]` or `[tp]` for a stop or a take-profit hit.
299
+ Pressing Ctrl+C while `connect()` is dialling is honoured when the dial ends (up to `connect_timeout`), not at once.
300
+ `token_timeout` bounds one licence request; a failed request is tried again and a connect tries each server of the list, so a connect to an unreachable licence service can wait several times that (about 44 s at the default 10).
249
301
 
250
302
  ## Errors
251
303
 
@@ -13,10 +13,12 @@ with Client(int(os.environ["PMT4_LOGIN"]), os.environ["PMT4_PASSWORD"], os.envir
13
13
  print("balance %.2f equity %.2f floating %.2f margin %.2f free %.2f"
14
14
  % (a.balance, a.equity, a.profit, a.margin, a.free_margin))
15
15
 
16
- s = c.summary() # from the closed-order ledger; blocks while it downloads
17
- print("realized %.2f deposits %.2f withdrawals %.2f" % (s.realized_profit, s.deposit, s.withdrawal))
16
+ now = c.server_time # history windows are in the broker's server time
17
+ s = c.summary(now - timedelta(days=365), now) # the last year (at most max_history_days per call); blocks
18
+ print("last 365 days: realized %.2f deposits %.2f withdrawals %.2f"
19
+ % (s.realized_profit, s.deposit, s.withdrawal))
18
20
 
19
- curve = c.equity_history(c.server_time - timedelta(days=30), "D1")
21
+ curve = c.equity_history(now - timedelta(days=30), "D1")
20
22
  for p in curve[-5:]:
21
23
  print("%s balance %.2f equity %.2f drawdown %.2f%%"
22
24
  % (p.time.strftime("%Y-%m-%d %H:%M"), p.balance, p.equity, p.equity_drawdown_relative * 100))
@@ -17,7 +17,7 @@ from .models import (AccountInfo, AccountSummary, ConnectEvent, EquityPoint, Lot
17
17
  OrderUpdate, Quote, ServerRecord, SymbolGroup, SymbolInfo)
18
18
  from .servers import load_servers_ini, save_servers_ini
19
19
 
20
- __version__ = "1.0.0"
20
+ __version__ = "1.2.0"
21
21
 
22
22
  __all__ = [
23
23
  "Client",
@@ -62,6 +62,10 @@ def main(argv: Optional[List[str]] = None) -> int:
62
62
  print("connect SKIPPED: environment variable %s is not set" % e)
63
63
  return 0
64
64
  port = int(os.environ.get("PMT4_PORT", "443"))
65
+ if ":" in host: # an account file lists "host:port"
66
+ host, _, given = host.rpartition(":")
67
+ if given.isdigit():
68
+ port = int(given)
65
69
  try:
66
70
  with Client(login, password, host, port, connect_timeout=45) as live:
67
71
  a = live.account()
@@ -4,15 +4,22 @@ from __future__ import annotations
4
4
  import asyncio
5
5
  import functools
6
6
  import threading
7
- from datetime import datetime, timedelta, timezone
7
+ from datetime import datetime
8
8
  from typing import Any, List, Optional
9
9
 
10
10
  from ._base import _Base
11
11
  from ._util import enum_value, to_core_time, translated
12
- from .errors import NotConnectedError, RequestCancelled
12
+ from .errors import InvalidRequestError, NotConnectedError, RequestCancelled
13
13
  from .models import AccountInfo, AccountSummary, EquityPoint, Order, _time
14
14
 
15
15
 
16
+ def _history_days(days: Any) -> int:
17
+ """A ``max_history_days`` value: a whole number of days, 0 for no limit."""
18
+ if isinstance(days, bool) or not isinstance(days, int) or days < 0:
19
+ raise ValueError("max_history_days must be a whole number of days, or 0 for no limit (got %r)" % (days,))
20
+ return days
21
+
22
+
16
23
  class _Cancellation:
17
24
  """Turns a :class:`threading.Event` into the core's ``CancellationToken`` for the duration of one call."""
18
25
 
@@ -173,27 +180,24 @@ class _AccountMixin(_Base):
173
180
  # ---- history -----------------------------------------------------------------------------------------------
174
181
 
175
182
  @translated
176
- def history(self, start: Optional[datetime] = None, end: Optional[datetime] = None,
177
- timeout: Optional[float] = None, cancel: Optional[threading.Event] = None) -> List[Order]:
178
- """Closed orders and balance / credit entries between ``start`` and ``end`` (default: the last 30 days up to
179
- now), sorted by close time. Blocks until the server has sent everything, up to ``timeout`` seconds (default:
180
- the client's ``history_timeout``); set the ``cancel`` event to abandon the wait early
181
- (:class:`RequestCancelled`).
183
+ def history(self, start: datetime, end: datetime, timeout: Optional[float] = None,
184
+ cancel: Optional[threading.Event] = None) -> List[Order]:
185
+ """Closed orders and balance / credit entries between ``start`` and ``end``, sorted by close time. Blocks until
186
+ the server has sent everything, up to ``timeout`` seconds (default: the client's ``history_timeout``); set the
187
+ ``cancel`` event to abandon the wait early (:class:`RequestCancelled`).
188
+
189
+ Both ends are required and the window may be at most :attr:`max_history_days` long (366 by default): a missing,
190
+ reversed or longer window raises :class:`InvalidRequestError` before anything is sent. For a longer period,
191
+ call once per window and add the results up. For "up to now", pass ``c.server_time`` as ``end``.
182
192
 
183
193
  Naive datetimes are used exactly as given, in the broker's server time; timezone-aware ones are converted to
184
- it. The default window is computed on the server's clock.
194
+ it.
185
195
 
186
196
  Each call opens its own short-lived broker connection and costs one licence request, which the licence
187
197
  service rate-limits per minute: fetch once and reuse the result rather than polling.
188
198
  """
189
199
  self._require_connected()
190
- now = self.server_time or datetime.now(timezone.utc).replace(tzinfo=None)
191
- end = self._server_naive(end)
192
- start = self._server_naive(start)
193
- if end is None:
194
- end = now + timedelta(days=1) # nothing the server has booked is later than this
195
- if start is None:
196
- start = now - timedelta(days=30)
200
+ start, end = self._window(start, end, "history")
197
201
  ms = int(timeout * 1000) if timeout is not None else int(self._api.DownloadOrderHistoryTimeoutMs)
198
202
  if ms <= 0:
199
203
  raise ValueError("timeout must be positive")
@@ -206,8 +210,7 @@ class _AccountMixin(_Base):
206
210
  rows = self._api.DownloadOrderHistory(to_core_time(start), to_core_time(end), ms)
207
211
  return [Order._from_core(o) for o in (rows or ())]
208
212
 
209
- async def ahistory(self, start: Optional[datetime] = None, end: Optional[datetime] = None,
210
- timeout: Optional[float] = None) -> List[Order]:
213
+ async def ahistory(self, start: datetime, end: datetime, timeout: Optional[float] = None) -> List[Order]:
211
214
  """Awaitable :meth:`history`. Cancelling the awaiting task abandons the download (the broker connection it
212
215
  opened is closed), so ``asyncio.wait_for(c.ahistory(...), 10)`` does not leave a request running."""
213
216
  cancel = threading.Event()
@@ -220,32 +223,55 @@ class _AccountMixin(_Base):
220
223
  cancel.set()
221
224
  raise
222
225
 
226
+ @property
227
+ def max_history_days(self) -> int:
228
+ """The longest window, in days, one :meth:`history`, :meth:`summary` or :meth:`equity_history` call may ask
229
+ for (default 366). A longer window raises :class:`InvalidRequestError` before anything is sent: split the
230
+ period into several calls, or raise this when you mean one long download. ``0`` removes the limit (the start
231
+ and end are still required)."""
232
+ return int(self._api.MaxHistoryDays)
233
+
234
+ @max_history_days.setter
235
+ def max_history_days(self, days: int) -> None:
236
+ self._api.MaxHistoryDays = _history_days(days)
237
+
238
+ def _window(self, start: Optional[datetime], end: Optional[datetime], what: str) -> tuple:
239
+ """``start`` and ``end`` in server time; both are required (the length is checked by the core)."""
240
+ if start is None or end is None:
241
+ raise InvalidRequestError("%s() needs a start and an end: history is always asked for by window "
242
+ "(at most max_history_days, %d days, per call)" % (what, self.max_history_days))
243
+ return self._server_naive(start), self._server_naive(end)
244
+
223
245
  # ---- realized figures and curves ---------------------------------------------------------------------------
224
246
 
225
247
  @translated
226
- def summary(self, start: Optional[datetime] = None, end: Optional[datetime] = None) -> AccountSummary:
227
- """Realized trading result, deposits and withdrawals, from the closed-order ledger (all time by default).
248
+ def summary(self, start: datetime, end: datetime) -> AccountSummary:
249
+ """Realized trading result, deposits and withdrawals between ``start`` and ``end``, from the closed-order
250
+ ledger. Both ends are required; the window may be at most :attr:`max_history_days` long — for a longer period,
251
+ add up one summary per window (``balance`` is the account's current balance in each).
228
252
 
229
253
  Downloads the history, so it blocks and costs one licence request; call it off your UI thread (or use
230
254
  ``asyncio.to_thread``).
231
255
  """
232
256
  self._require_connected()
233
- if start is None and end is None:
234
- return AccountSummary._from_core(self._api.GetAccountSummary())
235
- if start is None or end is None:
236
- raise ValueError("give both start and end, or neither")
237
- return AccountSummary._from_core(self._api.GetAccountSummary(to_core_time(self._server_naive(start)),
238
- to_core_time(self._server_naive(end))))
257
+ start, end = self._window(start, end, "summary")
258
+ return AccountSummary._from_core(self._api.GetAccountSummary(to_core_time(start), to_core_time(end)))
239
259
 
240
260
  @translated
241
261
  def equity_history(self, start: datetime, timeframe: str = "D1", exclude_same_bars: bool = True) -> List[EquityPoint]:
242
- """The balance and equity curve since ``start`` (``timeframe`` is ``H1``, ``H4`` or ``D1``).
262
+ """The balance and equity curve from ``start`` to now (``timeframe`` is ``H1``, ``H4`` or ``D1``); ``start``
263
+ may be at most :attr:`max_history_days` ago.
243
264
 
244
265
  ``balance`` is exact; the historical ``equity`` is a reconstruction priced from the symbols' bars. With
245
266
  ``exclude_same_bars`` (the default, as in the C# library) consecutive points with an unchanged balance and
246
- equity are dropped. Downloads the history and per-symbol bars, so it blocks and costs a licence request.
267
+ equity are dropped. A window with no closed order in it is a flat line at the balance (never empty); the last
268
+ point is the live balance and equity. Downloads the history and per-symbol bars, so it blocks and costs a
269
+ licence request.
247
270
  """
248
271
  self._require_connected()
272
+ if start is None:
273
+ raise InvalidRequestError("equity_history() needs a start (at most max_history_days, %d days, ago)"
274
+ % self.max_history_days)
249
275
  tf = enum_value(self._core.EquityTimeframe, timeframe, "equity timeframe")
250
276
  with self._bars_lock: # it downloads bars through the same single-flight request as bars()
251
277
  points = self._api.CalculateEquityHistory(to_core_time(self._server_naive(start)), tf,