pmt5api 1.1.2__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 (37) hide show
  1. {pmt5api-1.1.2 → pmt5api-1.2.0}/CHANGELOG.md +54 -0
  2. {pmt5api-1.1.2 → pmt5api-1.2.0}/PKG-INFO +32 -5
  3. {pmt5api-1.1.2 → pmt5api-1.2.0}/README.md +31 -4
  4. {pmt5api-1.1.2 → pmt5api-1.2.0}/examples/pnl.py +6 -4
  5. {pmt5api-1.1.2 → pmt5api-1.2.0}/examples/sizing.py +3 -3
  6. {pmt5api-1.1.2 → pmt5api-1.2.0}/src/pmt5api/__init__.py +1 -1
  7. {pmt5api-1.1.2 → pmt5api-1.2.0}/src/pmt5api/_account.py +58 -29
  8. {pmt5api-1.1.2 → pmt5api-1.2.0}/src/pmt5api/_coverage.py +2 -1
  9. {pmt5api-1.1.2 → pmt5api-1.2.0}/src/pmt5api/_risk.py +4 -2
  10. {pmt5api-1.1.2 → pmt5api-1.2.0}/src/pmt5api/_runtime.py +70 -8
  11. {pmt5api-1.1.2 → pmt5api-1.2.0}/src/pmt5api/_session.py +7 -0
  12. {pmt5api-1.1.2 → pmt5api-1.2.0}/src/pmt5api/_streams.py +5 -4
  13. {pmt5api-1.1.2 → pmt5api-1.2.0}/src/pmt5api/client.py +9 -2
  14. {pmt5api-1.1.2 → pmt5api-1.2.0}/src/pmt5api/diagnostics.py +8 -0
  15. {pmt5api-1.1.2 → pmt5api-1.2.0}/src/pmt5api/docs/API.md +42 -14
  16. {pmt5api-1.1.2 → pmt5api-1.2.0}/src/pmt5api/docs/FROM-CSHARP.md +2 -1
  17. {pmt5api-1.1.2 → pmt5api-1.2.0}/src/pmt5api/errors.py +13 -0
  18. {pmt5api-1.1.2 → pmt5api-1.2.0}/LICENSE.txt +0 -0
  19. {pmt5api-1.1.2 → pmt5api-1.2.0}/examples/bars.py +0 -0
  20. {pmt5api-1.1.2 → pmt5api-1.2.0}/examples/connect.py +0 -0
  21. {pmt5api-1.1.2 → pmt5api-1.2.0}/examples/servers.py +0 -0
  22. {pmt5api-1.1.2 → pmt5api-1.2.0}/examples/stream_async.py +0 -0
  23. {pmt5api-1.1.2 → pmt5api-1.2.0}/examples/stream_quotes.py +0 -0
  24. {pmt5api-1.1.2 → pmt5api-1.2.0}/examples/trade_demo.py +0 -0
  25. {pmt5api-1.1.2 → pmt5api-1.2.0}/hatch_build.py +0 -0
  26. {pmt5api-1.1.2 → pmt5api-1.2.0}/pyproject.toml +0 -0
  27. {pmt5api-1.1.2 → pmt5api-1.2.0}/src/pmt5api/__main__.py +0 -0
  28. {pmt5api-1.1.2 → pmt5api-1.2.0}/src/pmt5api/_base.py +0 -0
  29. {pmt5api-1.1.2 → pmt5api-1.2.0}/src/pmt5api/_events.py +0 -0
  30. {pmt5api-1.1.2 → pmt5api-1.2.0}/src/pmt5api/_market.py +0 -0
  31. {pmt5api-1.1.2 → pmt5api-1.2.0}/src/pmt5api/_trading.py +0 -0
  32. {pmt5api-1.1.2 → pmt5api-1.2.0}/src/pmt5api/_util.py +0 -0
  33. {pmt5api-1.1.2 → pmt5api-1.2.0}/src/pmt5api/bars.py +0 -0
  34. {pmt5api-1.1.2 → pmt5api-1.2.0}/src/pmt5api/frames.py +0 -0
  35. {pmt5api-1.1.2 → pmt5api-1.2.0}/src/pmt5api/models.py +0 -0
  36. {pmt5api-1.1.2 → pmt5api-1.2.0}/src/pmt5api/py.typed +0 -0
  37. {pmt5api-1.1.2 → pmt5api-1.2.0}/src/pmt5api/servers.py +0 -0
@@ -1,5 +1,59 @@
1
1
  # Changelog — pmt5api
2
2
 
3
+ ## [1.2.0] — 2026-11-06
4
+
5
+ Bundles **wmt5api 3.2.1**. 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 read every deal since 1970 for it, on every call). A
11
+ missing or reversed window raises `InvalidRequestError` before anything is sent; `request_history(start, end)`
12
+ likewise.
13
+ - **One call covers at most `max_history_days` (366 by default).** A longer window raises `InvalidRequestError`
14
+ before anything is sent. Ask window by window and add the results up, or set `Client(max_history_days=...)` /
15
+ `c.max_history_days` when you mean one long download (`0` removes the limit; the ends stay required).
16
+ `equity_history(start)` and `calibrate_money_scale(start)` run to now, so their start is at most that long ago (an
17
+ older one makes `calibrate_money_scale` return `False`, the reason in `money_scale_evidence`).
18
+ - What to change: `c.summary()` → `c.summary(start, end)`; `c.history()` → `c.history(start, end)` (for "up to now",
19
+ `end = c.server_time`); a calibration over two years → over at most one.
20
+
21
+ ### Added
22
+ - `Client.max_history_days` (read and write) and the constructor argument of the same name.
23
+ - `Client.server_name`: the name of the trade server the session logged in to (for example `ICMarketsSC-Demo`).
24
+
25
+ ### Changed (core wmt5api 3.2.0 / 3.2.1)
26
+ - `history()` asks the server for your window only (the server answers in whole calendar months; before, three more
27
+ months were asked for on every call). A position closed in the window but held longer than three months before it
28
+ is now listed in `orders` (up to a year back); a position whose deals arrived in different pages is no longer
29
+ dropped.
30
+ - Two `history()` calls at once on one client run one after the other instead of failing with `BusyError`.
31
+
32
+ ### Fixed (this package)
33
+ - **The platform wheel could not start on a machine that also has a newer .NET installed on `PATH`** (for example a
34
+ system .NET 10 next to the bundled .NET 9): the loader under pythonnet asked that `dotnet` for its runtimes, took
35
+ the newest and looked for it in the bundled runtime — `RuntimeNotFoundError` ("needs the .NET 8 (or later)
36
+ runtime"), on 1.1.2 too. The runtime is now started at a version the chosen runtime folder holds, and the message
37
+ says which runtime it tried.
38
+
39
+ ### Fixed (core wmt5api 3.2.0 / 3.2.1)
40
+ - **Events could reach callbacks out of order** — an order's close before its open, an older price after a newer one,
41
+ a disconnect before the connect. Quotes, order updates, order progress, connect progress and history chunks now
42
+ arrive in the order the client received them; a throwing handler no longer drops the rest of a batch.
43
+ - **A market order or close with the default fill policy could use a filling mode the symbol does not accept**, and
44
+ the broker refused it (`UNSUPPORTED_FILLING_MODE`): the symbol's declared modes are a bit mask and were read as one
45
+ value. The default now picks a declared mode (fill-or-kill if declared, else immediate-or-cancel, else return on an
46
+ exchange).
47
+ - `Order.fill_policy` of a market order filled immediate-or-cancel read fill-or-kill; it reports the mode the server
48
+ confirmed.
49
+ - `equity_history()`: the documentation no longer says "empty" for a window with no deal (it is a flat line).
50
+
51
+ ### Security (core wmt5api 3.2.0)
52
+ - `token_url` must be `https` (plain `http` only for a loopback host) and carry no user information, query or
53
+ fragment; redirects from the licence service are not followed (they would re-send the key).
54
+ - A login already bound to another trading account under your licence is refused with `LicenseError` reason
55
+ `AccountLimitReached` and a message that says so.
56
+
3
57
  ## [1.1.2] — 2026-10-15
4
58
 
5
59
  From the delta re-test of 1.1.1 (no S1 or S2). Python layer only: the cores are unchanged.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pmt5api
3
- Version: 1.1.2
3
+ Version: 1.2.0
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
@@ -202,9 +202,9 @@ Do not size a trade from a one-line formula: what a price unit is worth depends
202
202
  from your own closed deals and refuses to size without that evidence:
203
203
 
204
204
  ```python
205
- from datetime import datetime
205
+ from datetime import timedelta
206
206
 
207
- if c.calibrate_money_scale(datetime(2025, 1, 1)): # read-only; places no order
207
+ if c.calibrate_money_scale(c.server_time - timedelta(days=365)): # read-only; places no order; at most a year back
208
208
  r = c.lots_for_risk("EURUSD", entry_price=1.1000, stop_price=1.0950, risk_amount=50.0, side="buy")
209
209
  for w in r.warnings:
210
210
  print("warning:", w) # read these: ok can still be True with a caveat
@@ -228,12 +228,29 @@ to be checked. Also: `required_margin(symbol, lots)`, `order_profit(symbol, open
228
228
  | `account(refresh=True)` | `AccountInfo` — balance, equity, floating P/L (with and without swap/commission), margin, free margin, margin level, leverage, account mode, contact details |
229
229
  | `orders()` / `positions()` / `closed_orders()` | `list[Order]` — working / open positions only / what this session saw close |
230
230
  | `history(start, end)` / `deal_history(year, month)` | `History` — `.orders` and `.deals` (the ledger, including deposits and withdrawals) |
231
- | `summary(start=None, end=None)` | `AccountSummary` — realized profit, deposits, withdrawals, from the deal ledger |
231
+ | `summary(start, end)` | `AccountSummary` — realized profit, deposits, withdrawals, from the deal ledger |
232
232
  | `equity_history(start, "D1")` | `list[EquityPoint]` — balance (exact) and equity (reconstructed) curve with drawdowns |
233
233
  | `request_history(...)`, `request_bars(...)` | without waiting; the data arrives through `on_history` / `on_quote_history` |
234
234
 
235
235
  Times are naive `datetime` values in the **broker's server time**, as the server reports them; an unset time is `None`.
236
236
 
237
+ **History is asked for by window.** `history()` and `summary()` take a start and an end, and one call covers at most
238
+ `max_history_days` (366 by default; set it on the client, `0` removes the limit); `equity_history()` and
239
+ `calibrate_money_scale()` run from their start to now, so the start is at most that long ago. A missing, reversed or longer window raises
240
+ `InvalidRequestError` before anything is sent. For a longer period, ask window by window and add the results up:
241
+
242
+ ```python
243
+ from datetime import timedelta
244
+
245
+ end = c.server_time # server time; "up to now"
246
+ h = c.history(end - timedelta(days=30), end) # .orders and .deals
247
+ year = c.summary(end - timedelta(days=365), end)
248
+ ```
249
+
250
+ The server answers history in whole calendar months; `history()` keeps your window, and lists a position closed in it
251
+ whose opening deal is earlier (up to a year before the window). A window with no deal in it gives a flat equity curve
252
+ at the balance, never an empty one.
253
+
237
254
  `account()` first quotes each open position's symbol once and recalculates profits, so equity, profit and margin
238
255
  level are current (a cold read would otherwise show equity equal to balance). Pass `refresh=False` to skip that.
239
256
 
@@ -241,7 +258,9 @@ level are current (a cold read would otherwise show equity equal to balance). Pa
241
258
 
242
259
  `on_quote`, `on_order_update`, `on_order_progress`, `on_connect_progress`, `on_history`, `on_quote_history`,
243
260
  `on_symbols_update`, `on_symbol_update` — each returns a function that removes the callback. `quotes()` and
244
- `order_updates()` (and the `a…` async versions) turn a stream into an iterator.
261
+ `order_updates()` (and the `a…` async versions) turn a stream into an iterator. Events reach you in the order the
262
+ client received them — an order's open before its close, ticks in time order — and callbacks run one at a time on the
263
+ client's own thread.
245
264
 
246
265
  ## Staying connected
247
266
 
@@ -262,12 +281,20 @@ subscriptions are restored, cached snapshots are not.
262
281
  | Reconnect | `auto_reconnect`, `reconnect_backoff` (3, doubling up to `reconnect_max_backoff`, 30), `reconnect_max_attempts` (0 = no limit) | — |
263
282
  | Proxy | `proxy=Proxy("socks5" \| "socks4" \| "https", host, port, user, password)` — `host` must be an IP address | — |
264
283
  | Client certificate / common key | `certificate=`, `certificate_password=`, `common_key=` | — |
284
+ | History window | `max_history_days=` (366; `0` = no limit) — also a client attribute | — |
265
285
  | .NET runtime location | — | `DOTNET_ROOT` |
266
286
 
267
287
  A core path you give (argument or `WMTAPI_DLL`) is used exactly as given and is an error if it does not exist. The
268
288
  .NET runtime is taken from `DOTNET_ROOT` if you set it, else the runtime bundled in the wheel, else an installed one.
269
289
  The bundled runtime runs in invariant-culture mode on machines without ICU.
270
290
 
291
+ **These two settings decide what code runs in your process.** `WMTAPI_DLL` (or `dll_path=`) names the library that is
292
+ loaded, and `DOTNET_ROOT` (or, without it, the `dotnet` found on `PATH`) names the .NET runtime that is started;
293
+ both run with your rights. Set them only from configuration you control, and do not run with an environment
294
+ someone else can change. With `enable_core_logging()` switched on, the package logs once when either was chosen
295
+ by the environment. The licence service URL must be `https` (plain `http` only for a loopback test service), and
296
+ redirects from it are not followed.
297
+
271
298
  ### Diagnostics
272
299
 
273
300
  ```python
@@ -175,9 +175,9 @@ Do not size a trade from a one-line formula: what a price unit is worth depends
175
175
  from your own closed deals and refuses to size without that evidence:
176
176
 
177
177
  ```python
178
- from datetime import datetime
178
+ from datetime import timedelta
179
179
 
180
- if c.calibrate_money_scale(datetime(2025, 1, 1)): # read-only; places no order
180
+ if c.calibrate_money_scale(c.server_time - timedelta(days=365)): # read-only; places no order; at most a year back
181
181
  r = c.lots_for_risk("EURUSD", entry_price=1.1000, stop_price=1.0950, risk_amount=50.0, side="buy")
182
182
  for w in r.warnings:
183
183
  print("warning:", w) # read these: ok can still be True with a caveat
@@ -201,12 +201,29 @@ to be checked. Also: `required_margin(symbol, lots)`, `order_profit(symbol, open
201
201
  | `account(refresh=True)` | `AccountInfo` — balance, equity, floating P/L (with and without swap/commission), margin, free margin, margin level, leverage, account mode, contact details |
202
202
  | `orders()` / `positions()` / `closed_orders()` | `list[Order]` — working / open positions only / what this session saw close |
203
203
  | `history(start, end)` / `deal_history(year, month)` | `History` — `.orders` and `.deals` (the ledger, including deposits and withdrawals) |
204
- | `summary(start=None, end=None)` | `AccountSummary` — realized profit, deposits, withdrawals, from the deal ledger |
204
+ | `summary(start, end)` | `AccountSummary` — realized profit, deposits, withdrawals, from the deal ledger |
205
205
  | `equity_history(start, "D1")` | `list[EquityPoint]` — balance (exact) and equity (reconstructed) curve with drawdowns |
206
206
  | `request_history(...)`, `request_bars(...)` | without waiting; the data arrives through `on_history` / `on_quote_history` |
207
207
 
208
208
  Times are naive `datetime` values in the **broker's server time**, as the server reports them; an unset time is `None`.
209
209
 
210
+ **History is asked for by window.** `history()` and `summary()` take a start and an end, and one call covers at most
211
+ `max_history_days` (366 by default; set it on the client, `0` removes the limit); `equity_history()` and
212
+ `calibrate_money_scale()` run from their start to now, so the start is at most that long ago. A missing, reversed or longer window raises
213
+ `InvalidRequestError` before anything is sent. For a longer period, ask window by window and add the results up:
214
+
215
+ ```python
216
+ from datetime import timedelta
217
+
218
+ end = c.server_time # server time; "up to now"
219
+ h = c.history(end - timedelta(days=30), end) # .orders and .deals
220
+ year = c.summary(end - timedelta(days=365), end)
221
+ ```
222
+
223
+ The server answers history in whole calendar months; `history()` keeps your window, and lists a position closed in it
224
+ whose opening deal is earlier (up to a year before the window). A window with no deal in it gives a flat equity curve
225
+ at the balance, never an empty one.
226
+
210
227
  `account()` first quotes each open position's symbol once and recalculates profits, so equity, profit and margin
211
228
  level are current (a cold read would otherwise show equity equal to balance). Pass `refresh=False` to skip that.
212
229
 
@@ -214,7 +231,9 @@ level are current (a cold read would otherwise show equity equal to balance). Pa
214
231
 
215
232
  `on_quote`, `on_order_update`, `on_order_progress`, `on_connect_progress`, `on_history`, `on_quote_history`,
216
233
  `on_symbols_update`, `on_symbol_update` — each returns a function that removes the callback. `quotes()` and
217
- `order_updates()` (and the `a…` async versions) turn a stream into an iterator.
234
+ `order_updates()` (and the `a…` async versions) turn a stream into an iterator. Events reach you in the order the
235
+ client received them — an order's open before its close, ticks in time order — and callbacks run one at a time on the
236
+ client's own thread.
218
237
 
219
238
  ## Staying connected
220
239
 
@@ -235,12 +254,20 @@ subscriptions are restored, cached snapshots are not.
235
254
  | Reconnect | `auto_reconnect`, `reconnect_backoff` (3, doubling up to `reconnect_max_backoff`, 30), `reconnect_max_attempts` (0 = no limit) | — |
236
255
  | Proxy | `proxy=Proxy("socks5" \| "socks4" \| "https", host, port, user, password)` — `host` must be an IP address | — |
237
256
  | Client certificate / common key | `certificate=`, `certificate_password=`, `common_key=` | — |
257
+ | History window | `max_history_days=` (366; `0` = no limit) — also a client attribute | — |
238
258
  | .NET runtime location | — | `DOTNET_ROOT` |
239
259
 
240
260
  A core path you give (argument or `WMTAPI_DLL`) is used exactly as given and is an error if it does not exist. The
241
261
  .NET runtime is taken from `DOTNET_ROOT` if you set it, else the runtime bundled in the wheel, else an installed one.
242
262
  The bundled runtime runs in invariant-culture mode on machines without ICU.
243
263
 
264
+ **These two settings decide what code runs in your process.** `WMTAPI_DLL` (or `dll_path=`) names the library that is
265
+ loaded, and `DOTNET_ROOT` (or, without it, the `dotnet` found on `PATH`) names the .NET runtime that is started;
266
+ both run with your rights. Set them only from configuration you control, and do not run with an environment
267
+ someone else can change. With `enable_core_logging()` switched on, the package logs once when either was chosen
268
+ by the environment. The licence service URL must be `https` (plain `http` only for a loopback test service), and
269
+ redirects from it are not followed.
270
+
244
271
  ### Diagnostics
245
272
 
246
273
  ```python
@@ -3,7 +3,7 @@
3
3
  PMT5_LOGIN=... PMT5_PASSWORD=... PMT5_HOST=... python pnl.py
4
4
  """
5
5
  import os
6
- from datetime import datetime, timedelta
6
+ from datetime import timedelta
7
7
 
8
8
  from pmt5api import Client
9
9
 
@@ -13,10 +13,12 @@ with Client(int(os.environ["PMT5_LOGIN"]), os.environ["PMT5_PASSWORD"], os.envir
13
13
  print("balance %.2f equity %.2f floating %.2f (swap %.2f, commission %.2f)"
14
14
  % (a.balance, a.equity, a.floating_pl, a.swap, a.commission))
15
15
 
16
- s = c.summary() # from the deal 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(datetime.now() - timedelta(days=30), "D1")
21
+ curve = c.equity_history(now - timedelta(days=30), "D1")
20
22
  for p in curve[-5:]:
21
23
  flag = " (incomplete)" if p.incomplete else ""
22
24
  print("%s balance %.2f equity %.2f drawdown %.2f%%%s"
@@ -4,7 +4,7 @@
4
4
  """
5
5
  import os
6
6
  import sys
7
- from datetime import datetime, timedelta
7
+ from datetime import timedelta
8
8
 
9
9
  from pmt5api import Client
10
10
 
@@ -19,8 +19,8 @@ with Client(int(os.environ["PMT5_LOGIN"]), os.environ["PMT5_PASSWORD"], os.envir
19
19
  acct = c.account()
20
20
  print("account currency %s, equity %.2f" % (acct.currency, acct.equity))
21
21
 
22
- # 1. measure the account's own conventions from its closed deals (read-only; places no order)
23
- if not c.calibrate_money_scale(datetime.now() - timedelta(days=730)):
22
+ # 1. measure the account's own conventions from its closed deals of the last year (read-only; places no order)
23
+ if not c.calibrate_money_scale(c.server_time - timedelta(days=365)):
24
24
  sys.exit("cannot size yet: " + c.money_scale_evidence)
25
25
  print("money scale %s" % c.money_scale)
26
26
 
@@ -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.1.2"
20
+ __version__ = "1.2.0"
21
21
 
22
22
  __all__ = [
23
23
  "Client", "Proxy",
@@ -1,15 +1,22 @@
1
1
  """The account: balances, positions, orders, history, and the curves derived from them."""
2
2
  from __future__ import annotations
3
3
 
4
- from datetime import datetime, timedelta, timezone
5
- from typing import List, Optional
4
+ from datetime import datetime
5
+ from typing import Any, List, Optional
6
6
 
7
7
  from ._base import _Base
8
8
  from ._util import enum_value, to_core_time, translated
9
- from .errors import NotConnectedError
9
+ from .errors import InvalidRequestError, NotConnectedError
10
10
  from .models import AccountInfo, AccountSummary, EquityPoint, History, Order
11
11
 
12
12
 
13
+ def _history_days(days: Any) -> int:
14
+ """A ``max_history_days`` value: a whole number of days, 0 for no limit."""
15
+ if isinstance(days, bool) or not isinstance(days, int) or days < 0:
16
+ raise ValueError("max_history_days must be a whole number of days, or 0 for no limit (got %r)" % (days,))
17
+ return days
18
+
19
+
13
20
  def _money(value) -> float:
14
21
  """A money figure without the binary noise the core's sums carry (1635341.7899999998 becomes 1635341.79)."""
15
22
  return round(float(value), 8)
@@ -82,22 +89,20 @@ class _AccountMixin(_Base):
82
89
  # ---- history -----------------------------------------------------------------------------------------------
83
90
 
84
91
  @translated
85
- def history(self, start: Optional[datetime] = None, end: Optional[datetime] = None,
86
- timeout: Optional[float] = None) -> History:
87
- """Orders and deals between ``start`` and ``end`` (default: the last 30 days up to now). Blocks until the
88
- server has sent everything, up to ``timeout`` seconds (default: the client's ``history_timeout``).
92
+ def history(self, start: datetime, end: datetime, timeout: Optional[float] = None) -> History:
93
+ """Orders and deals between ``start`` and ``end``. Blocks until the server has sent everything, up to
94
+ ``timeout`` seconds (default: the client's ``history_timeout``).
95
+
96
+ Both ends are required and the window may be at most :attr:`max_history_days` long (366 by default): a missing,
97
+ reversed or longer window raises :class:`InvalidRequestError` before anything is sent. For a longer period,
98
+ call once per window and add the results up. For "up to now", pass ``c.server_time`` as ``end``. ``orders``
99
+ also lists a position closed in the window whose opening deal is earlier (up to a year before the window).
89
100
 
90
101
  Naive datetimes are used exactly as given, in the broker's server time; timezone-aware ones are converted to
91
- it. The default window is computed on the server's clock.
102
+ it. Two calls at once on one client run one after the other (the wait counts toward the second one's timeout).
92
103
  """
93
104
  self._require_connected()
94
- now = self.server_time or datetime.now(timezone.utc).replace(tzinfo=None)
95
- end = self._server_naive(end)
96
- start = self._server_naive(start)
97
- if end is None:
98
- end = now + timedelta(days=1) # nothing the server has booked is later than this
99
- if start is None:
100
- start = now - timedelta(days=30)
105
+ start, end = self._window(start, end, "history")
101
106
  ms = int(timeout * 1000) if timeout is not None else int(self._api.DownloadOrderHistoryTimeoutMs)
102
107
  return History._from_core(self._api.DownloadOrderHistory(to_core_time(start), to_core_time(end), ms))
103
108
 
@@ -110,10 +115,12 @@ class _AccountMixin(_Base):
110
115
  @translated
111
116
  def request_history(self, start: datetime, end: datetime) -> None:
112
117
  """Ask for history without waiting; it arrives through :meth:`on_history`. The server's answer is not trimmed
113
- to ``start`` / ``end`` (it can hold more than you asked for, in pages): filter by time. The blocking
114
- :meth:`history` does trim."""
118
+ to ``start`` / ``end`` (it comes in whole calendar months, in pages): filter by time. The blocking
119
+ :meth:`history` does trim. The window rules of :meth:`history` apply; refused while a :meth:`history`
120
+ download runs on this client (:class:`BusyError`), because the answers would mix."""
115
121
  self._require_connected()
116
- self._api.RequestOrderHistory(to_core_time(self._server_naive(start)), to_core_time(self._server_naive(end)))
122
+ start, end = self._window(start, end, "request_history")
123
+ self._api.RequestOrderHistory(to_core_time(start), to_core_time(end))
117
124
 
118
125
  @translated
119
126
  def request_history_month(self, year: int, month: int) -> None:
@@ -121,32 +128,54 @@ class _AccountMixin(_Base):
121
128
  self._require_connected()
122
129
  self._api.RequestOrderHistory(int(year), int(month))
123
130
 
131
+ @property
132
+ def max_history_days(self) -> int:
133
+ """The longest window, in days, one :meth:`history`, :meth:`request_history`, :meth:`summary`,
134
+ :meth:`equity_history` or :meth:`calibrate_money_scale` call may ask for (default 366). A longer window raises
135
+ :class:`InvalidRequestError` before anything is sent: split the period into several calls, or raise this when
136
+ you mean one long download. ``0`` removes the limit (the start and end are still required)."""
137
+ return int(self._api.MaxHistoryDays)
138
+
139
+ @max_history_days.setter
140
+ def max_history_days(self, days: int) -> None:
141
+ self._api.MaxHistoryDays = _history_days(days)
142
+
143
+ def _window(self, start: Optional[datetime], end: Optional[datetime], what: str) -> tuple:
144
+ """``start`` and ``end`` in server time; both are required (the length is checked by the core)."""
145
+ if start is None or end is None:
146
+ raise InvalidRequestError("%s() needs a start and an end: history is always asked for by window "
147
+ "(at most max_history_days, %d days, per call)" % (what, self.max_history_days))
148
+ return self._server_naive(start), self._server_naive(end)
149
+
124
150
  # ---- realized figures and curves ---------------------------------------------------------------------------
125
151
 
126
152
  @translated
127
- def summary(self, start: Optional[datetime] = None, end: Optional[datetime] = None) -> AccountSummary:
128
- """Realized trading result, deposits and withdrawals, from the deal ledger (all time by default).
153
+ def summary(self, start: datetime, end: datetime) -> AccountSummary:
154
+ """Realized trading result, deposits and withdrawals between ``start`` and ``end``, from the deal ledger. Both
155
+ ends are required; the window may be at most :attr:`max_history_days` long — for a longer period, add up one
156
+ summary per window (``balance`` is the account's current balance in each).
129
157
 
130
158
  Downloads the deal history, so it blocks; call it off your UI thread (or use ``asyncio.to_thread``).
131
159
  """
132
160
  self._require_connected()
133
- if start is None and end is None:
134
- return AccountSummary._from_core(self._api.GetAccountSummary())
135
- if start is None or end is None:
136
- raise ValueError("give both start and end, or neither")
137
- return AccountSummary._from_core(self._api.GetAccountSummary(to_core_time(self._server_naive(start)),
138
- to_core_time(self._server_naive(end))))
161
+ start, end = self._window(start, end, "summary")
162
+ return AccountSummary._from_core(self._api.GetAccountSummary(to_core_time(start), to_core_time(end)))
139
163
 
140
164
  @translated
141
165
  def equity_history(self, start: datetime, timeframe: str = "D1", exclude_same_bars: bool = True) -> List[EquityPoint]:
142
- """The balance and equity curve since ``start`` (``timeframe`` is ``H1``, ``H4`` or ``D1``).
166
+ """The balance and equity curve from ``start`` to now (``timeframe`` is ``H1``, ``H4`` or ``D1``); ``start``
167
+ may be at most :attr:`max_history_days` ago.
143
168
 
144
169
  ``balance`` is exact; the historical ``equity`` is a bar-priced reconstruction and a point with
145
170
  ``incomplete`` set has understated floating P/L. With ``exclude_same_bars`` (the default, as in the C# library)
146
- consecutive points with an unchanged balance and equity are dropped. Downloads deals and per-symbol bars, so it
147
- blocks.
171
+ consecutive points with an unchanged balance and equity are dropped. A window with no deal in it is a flat line
172
+ at the balance (never empty); the last point is the live balance and equity. Downloads deals and per-symbol
173
+ bars, so it blocks.
148
174
  """
149
175
  self._require_connected()
176
+ if start is None:
177
+ raise InvalidRequestError("equity_history() needs a start (at most max_history_days, %d days, ago)"
178
+ % self.max_history_days)
150
179
  tf = enum_value(self._core.EquityTimeframe, timeframe, "equity timeframe")
151
180
  points = self._api.CalculateEquityHistory(to_core_time(self._server_naive(start)), tf, bool(exclude_same_bars))
152
181
  return [EquityPoint._from_core(p) for p in (points or ())]
@@ -36,7 +36,7 @@ COVERAGE: Dict[str, str] = {
36
36
  "User": "Client.login",
37
37
  "Password": "Client(password)",
38
38
  "Host": "Client.host",
39
- "Server": "Client.host",
39
+ "Server": "Client.server_name",
40
40
  "Port": "Client.port",
41
41
  "Id": "not applicable: a free-form tag for the caller's own use; keep it in your own code",
42
42
  "ProcessServerMessagesInThread": "not applicable: documented by the core as reserved and unused",
@@ -52,6 +52,7 @@ COVERAGE: Dict[str, str] = {
52
52
  "ExecutionTimeout": "Client(execution_timeout)",
53
53
  "GetQuoteTimeoutMs": "Client(quote_timeout)",
54
54
  "DownloadOrderHistoryTimeoutMs": "Client(history_timeout)",
55
+ "MaxHistoryDays": "Client.max_history_days",
55
56
  "DownloadQuoteHistoryTimeout": "Client(bars_timeout)",
56
57
  "PingTime": "Client(ping_interval)",
57
58
  "NoServerMessagesTimeout": "Client(no_message_timeout)",
@@ -16,7 +16,9 @@ class _RiskMixin(_Base):
16
16
  def calibrate_money_scale(self, start: datetime, *, min_deals_per_symbol: Optional[int] = None,
17
17
  max_self_scatter: Optional[float] = None, max_disagreement: Optional[float] = None,
18
18
  max_snap_distance: Optional[float] = None) -> bool:
19
- """Measure what a price unit is worth *on this account* from its own closed deals since ``start``.
19
+ """Measure what a price unit is worth *on this account* from its own closed deals from ``start`` to now;
20
+ ``start`` may be at most :attr:`max_history_days` ago (an older one returns ``False``, the reason in
21
+ :attr:`money_scale_evidence`).
20
22
 
21
23
  Read-only; places no order. Cent handling is a per-broker configuration, not a property of the currency, so
22
24
  sizing is refused until this has succeeded. Returns ``False`` when the scale cannot be established (read
@@ -42,7 +44,7 @@ class _RiskMixin(_Base):
42
44
  @property
43
45
  def money_scale_evidence(self) -> str:
44
46
  """Why the scale is what it is, or why it could not be established."""
45
- return str(self._api.MoneyScaleEvidence or "")
47
+ return str(self._api.MoneyScaleEvidence or "").replace("MaxHistoryDays", "max_history_days")
46
48
 
47
49
  @translated
48
50
  def lots_for_risk(self, symbol: str, entry_price: float, stop_price: float, risk_amount: float, *,
@@ -1,15 +1,24 @@
1
- """Locates the .NET runtime and the licensed core library and loads them once per process."""
1
+ """Locates the .NET runtime and the licensed core library and loads them once per process.
2
+
3
+ Trust: whatever this module loads runs inside your Python process with your rights. The core library comes from
4
+ ``dll_path=``, else the ``WMTAPI_DLL`` environment variable, else the wheel; the .NET runtime from ``DOTNET_ROOT``, else
5
+ the wheel, else an installed one found through ``PATH``. Each of these decides which native and managed code is
6
+ executed, so set them only from configuration you control, and do not run with an environment that someone else
7
+ can change. ``enable_core_logging()`` reports once when the core or the runtime was chosen by the environment.
8
+ """
2
9
  from __future__ import annotations
3
10
 
4
11
  import glob
5
12
  import importlib
13
+ import json
6
14
  import os
7
15
  import shutil
8
16
  import struct
9
17
  import sys
18
+ import tempfile
10
19
  import threading
11
20
  from pathlib import Path
12
- from typing import Iterator, Optional
21
+ from typing import Iterator, List, Optional
13
22
 
14
23
  from .errors import CoreNotFoundError, RuntimeNotFoundError
15
24
 
@@ -39,6 +48,8 @@ def _process_lock() -> "threading.RLock":
39
48
  _lock = _process_lock()
40
49
  _core = None
41
50
  _dll: Optional[Path] = None
51
+ _chosen_by_environment: List[str] = [] # what the environment decided at load time (see trust_notes)
52
+ _root_we_set: Optional[str] = None # the DOTNET_ROOT value this module chose itself, if any
42
53
 
43
54
 
44
55
  def find_dll(explicit=None) -> Path:
@@ -94,18 +105,56 @@ def _ensure_dotnet_root() -> None:
94
105
  os.environ.setdefault("DOTNET_SYSTEM_GLOBALIZATION_INVARIANT", "1")
95
106
  if os.environ.get("DOTNET_ROOT"):
96
107
  return
108
+ global _root_we_set
97
109
  if (_BUNDLED_RUNTIME / "shared" / "Microsoft.NETCore.App").is_dir():
98
- os.environ["DOTNET_ROOT"] = str(_BUNDLED_RUNTIME)
110
+ os.environ["DOTNET_ROOT"] = _root_we_set = str(_BUNDLED_RUNTIME)
99
111
  return
100
112
  for root in _dotnet_root_candidates():
101
113
  if (root / "shared" / "Microsoft.NETCore.App").is_dir():
102
- os.environ["DOTNET_ROOT"] = str(root)
114
+ os.environ["DOTNET_ROOT"] = _root_we_set = str(root)
103
115
  return
104
116
  raise RuntimeNotFoundError(
105
117
  "No .NET runtime was found. Install the .NET 8 (or later) runtime, or set DOTNET_ROOT to its install directory."
106
118
  )
107
119
 
108
120
 
121
+ def _runtime_version(root: Path) -> Optional[str]:
122
+ """The newest ``Microsoft.NETCore.App`` version installed under ``root`` (``shared/Microsoft.NETCore.App/<ver>``)."""
123
+ best = None
124
+ try:
125
+ entries = list((root / "shared" / "Microsoft.NETCore.App").iterdir())
126
+ except OSError:
127
+ return None
128
+ for d in entries:
129
+ try:
130
+ key = tuple(int(n) for n in d.name.split("-", 1)[0].split(".")[:3])
131
+ except ValueError:
132
+ continue
133
+ if d.is_dir() and (best is None or key > best[0]):
134
+ best = (key, d.name)
135
+ return best[1] if best else None
136
+
137
+
138
+ def _start_coreclr(start, tmp: str) -> str:
139
+ """Start CoreCLR from the root in ``DOTNET_ROOT`` at a version that root holds; returns what was used.
140
+
141
+ Left to itself the loader asks a ``dotnet`` found on ``PATH`` for the installed runtimes and takes the newest — one
142
+ that only that other installation may have (a system .NET 10 next to the bundled 9 fails with
143
+ FrameworkMissingFailure). The runtime configuration written here names the root's own newest version instead.
144
+ """
145
+ root = Path(os.environ["DOTNET_ROOT"])
146
+ version = _runtime_version(root)
147
+ if version is None: # not a layout we can read: let the loader decide, as before
148
+ start("coreclr")
149
+ return "a runtime chosen by pythonnet"
150
+ cfg = Path(tmp) / "runtimeconfig.json"
151
+ cfg.write_text(json.dumps({"runtimeOptions": {
152
+ "tfm": "net" + ".".join(version.split("-", 1)[0].split(".")[:2]),
153
+ "framework": {"name": "Microsoft.NETCore.App", "version": version}}}), encoding="utf-8")
154
+ start("coreclr", runtime_config=str(cfg), dotnet_root=str(root))
155
+ return "Microsoft.NETCore.App %s in %s" % (version, root)
156
+
157
+
109
158
  def runtime_root() -> Optional[Path]:
110
159
  """The .NET root in use (after ``load``), or None."""
111
160
  root = os.environ.get("DOTNET_ROOT")
@@ -149,17 +198,24 @@ def load(dll_path=None):
149
198
  "so dll_path=%s was not loaded" % (_dll, dll_path))
150
199
  return _core
151
200
  dll = find_dll(dll_path)
201
+ notes = []
202
+ if dll_path is None and os.environ.get(ENV_DLL):
203
+ notes.append("core library %s was named by the %s environment variable" % (dll, ENV_DLL))
204
+ if os.environ.get("DOTNET_ROOT") and os.environ["DOTNET_ROOT"] != _root_we_set:
205
+ notes.append(".NET runtime was taken from DOTNET_ROOT=%s, set by the environment" % os.environ["DOTNET_ROOT"])
152
206
  _ensure_dotnet_root()
207
+ tried = "the runtime in DOTNET_ROOT=%s" % os.environ.get("DOTNET_ROOT")
153
208
  try:
154
209
  from pythonnet import load as _load
155
- _load("coreclr")
156
- import clr # noqa: F401 (provided by pythonnet)
210
+ with tempfile.TemporaryDirectory(prefix="pmt-runtime-") as tmp:
211
+ tried = _start_coreclr(_load, tmp)
212
+ import clr # noqa: F401 (provided by pythonnet; the configuration is read by now)
157
213
  except Exception as e: # noqa: BLE001 — anything here means the runtime could not start
158
214
  raise RuntimeNotFoundError(
159
215
  "could not start the .NET runtime. The core needs the .NET 8 (or later) runtime, Microsoft.NETCore.App: "
160
216
  "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
217
+ "this machine has) or point DOTNET_ROOT at a folder that holds it.%s Tried: %s. Detail: %s: %s"
218
+ % (_BITNESS_HINT if struct.calcsize("P") == 4 else "", tried, type(e).__name__, str(e)[:240])) from e
163
219
  import clr as _clr
164
220
  from System.Reflection import Assembly # available once the runtime is up
165
221
  try:
@@ -169,8 +225,14 @@ def load(dll_path=None):
169
225
  _clr.AddReference("wmt5api") # now by name: registers the namespace for `import`
170
226
  _core = _import_namespace()
171
227
  _dll = dll
228
+ _chosen_by_environment[:] = notes
172
229
  return _core
173
230
 
174
231
 
175
232
  def loaded_dll() -> Optional[Path]:
176
233
  return _dll
234
+
235
+
236
+ def trust_notes() -> List[str]:
237
+ """What the environment (``WMTAPI_DLL``, ``DOTNET_ROOT``) chose when the core was loaded; empty if nothing."""
238
+ return list(_chosen_by_environment)
@@ -103,6 +103,13 @@ class _SessionMixin(_Base):
103
103
  """The access server this client was created for (it may fail over to a cluster member)."""
104
104
  return str(self._api.Host or self._host)
105
105
 
106
+ @property
107
+ def server_name(self) -> Optional[str]:
108
+ """The name of the trade server the session logged in to (for example ``ICMarketsSC-Demo``), as the server
109
+ reports it while connecting; ``None`` before the first connect. The address dialled is :attr:`host`."""
110
+ name = self._api.Server
111
+ return str(name) if name else None
112
+
106
113
  @property
107
114
  def port(self) -> int:
108
115
  """The access-server port."""
@@ -1,9 +1,10 @@
1
1
  """Delivery machinery shared by the event API: one dispatcher thread per client, and the sync / async iterators.
2
2
 
3
- The core delivers each event on its own thread-pool task, so events can arrive concurrently and slightly out of order,
4
- and a slow callback would pile up threads. The client therefore converts every event to a plain value the moment it
5
- arrives (a snapshot — the core's objects are shared and mutable) and hands it to ONE dispatcher thread per client,
6
- which calls the callbacks one at a time, in arrival order. A callback may take its time; if it falls more than
3
+ The core raises each kind of event one at a time, in the order it received them (cores before wmt4api 3.4.0 / wmt5api
4
+ 3.2.0 used one thread-pool task per event, so events could arrive concurrently and out of order). The client converts
5
+ every event to a plain value the moment it arrives (a snapshot — the core's objects are shared and mutable) and hands it
6
+ to ONE dispatcher thread per client, which calls the callbacks one at a time, in arrival order — so a slow callback
7
+ never holds up the core's own delivery. A callback may take its time; if it falls more than
7
8
  ``EVENT_QUEUE`` events behind, the oldest events are dropped (a warning is logged, and ``Client.events_dropped``
8
9
  counts them). An exception raised by a callback is logged (under the package's own logger name) and swallowed so it
9
10
  cannot break the connection.
@@ -61,7 +61,11 @@ class Client(_SessionMixin, _MarketMixin, _AccountMixin, _RiskMixin, _TradingMix
61
61
  or ``"expert"``; a per-call ``placed_type="manual"`` equals the default and so takes the client's value). ``all_order_updates=True`` also delivers the intermediate order states to
62
62
  :meth:`on_order_update`.
63
63
 
64
- Callbacks run on the core's worker threads, not on the thread that created the client.
64
+ History is always asked for by window: :meth:`history` and :meth:`summary` take a start and an end, and one call
65
+ covers at most ``max_history_days`` (default 366; ``0`` removes the limit).
66
+
67
+ Callbacks run on a dispatcher thread of this client, one at a time and in the order the events arrived — not on
68
+ the thread that created the client.
65
69
  """
66
70
 
67
71
  def __init__(self, login: int, password: str, host: str, port: int = 443, *,
@@ -75,7 +79,8 @@ class Client(_SessionMixin, _MarketMixin, _AccountMixin, _RiskMixin, _TradingMix
75
79
  reconnect_max_backoff: Optional[float] = None, reconnect_max_attempts: Optional[int] = None,
76
80
  proxy: Optional[Proxy] = None, certificate: Optional[bytes] = None,
77
81
  certificate_password: Optional[str] = None, common_key: Optional[bytes] = None,
78
- placed_type: Optional[str] = None, all_order_updates: bool = False):
82
+ placed_type: Optional[str] = None, all_order_updates: bool = False,
83
+ max_history_days: Optional[int] = None):
79
84
  core = _runtime.load(dll_path)
80
85
  self._core = core
81
86
  self._login = int(login)
@@ -109,6 +114,8 @@ class Client(_SessionMixin, _MarketMixin, _AccountMixin, _RiskMixin, _TradingMix
109
114
  api.EnableAllOrderUpdates = True
110
115
  if placed_type:
111
116
  api.PlacedType = _placed(core.PlacedType, placed_type)
117
+ if max_history_days is not None:
118
+ self.max_history_days = max_history_days
112
119
  if proxy is not None:
113
120
  api.ProxyEnable = True
114
121
  api.ProxyType = enum_value(core.ProxyTypes, proxy.kind, "proxy kind")
@@ -6,6 +6,8 @@ from typing import Any, Callable
6
6
 
7
7
  from . import _runtime
8
8
 
9
+ _trust_reported = False
10
+
9
11
  _LEVELS = {"Trace": logging.DEBUG, "Debug": logging.DEBUG, "Info": logging.INFO, "Warn": logging.WARNING,
10
12
  "Error": logging.ERROR, "Exception": logging.ERROR}
11
13
 
@@ -27,6 +29,12 @@ def enable_core_logging(logger_name: str = "pmt5api.core") -> Callable[[], None]
27
29
  if logger.isEnabledFor(logging.DEBUG):
28
30
  logger.debug("%s", message)
29
31
 
32
+ global _trust_reported
33
+ if not _trust_reported:
34
+ _trust_reported = True
35
+ for note in _runtime.trust_notes(): # once per process: what the environment chose to load
36
+ logger.info("%s", note)
37
+
30
38
  core.Logger.OnMsgEx += on_msg
31
39
  sink = System.Action[System.String](on_diag)
32
40
  previous = core.Diag.Sink
@@ -5,7 +5,7 @@ Generated from the code (`tools/gen_docs.py`); do not edit by hand. Times are na
5
5
  ## Constructing a client
6
6
 
7
7
  ```python
8
- Client(login, password, host, port=443, *, api_key=None, token_url=None, dll_path=None, connect_timeout=None, cluster_member_timeout=None, token_timeout=None, execution_timeout=None, quote_timeout=None, history_timeout=None, bars_timeout=None, ping_interval=None, no_message_timeout=None, auto_reconnect=False, reconnect_backoff=None, reconnect_max_backoff=None, reconnect_max_attempts=None, proxy=None, certificate=None, certificate_password=None, common_key=None, placed_type=None, all_order_updates=False)
8
+ Client(login, password, host, port=443, *, api_key=None, token_url=None, dll_path=None, connect_timeout=None, cluster_member_timeout=None, token_timeout=None, execution_timeout=None, quote_timeout=None, history_timeout=None, bars_timeout=None, ping_interval=None, no_message_timeout=None, auto_reconnect=False, reconnect_backoff=None, reconnect_max_backoff=None, reconnect_max_attempts=None, proxy=None, certificate=None, certificate_password=None, common_key=None, placed_type=None, all_order_updates=False, max_history_days=None)
9
9
  ```
10
10
 
11
11
  A connection to one MetaTrader 5 account through the licensed core.
@@ -38,7 +38,11 @@ require one. `placed_type` records how orders are placed (`"manual"`
38
38
  or `"expert"`; a per-call `placed_type="manual"` equals the default and so takes the client's value). `all_order_updates=True` also delivers the intermediate order states to
39
39
  `on_order_update`.
40
40
 
41
- Callbacks run on the core's worker threads, not on the thread that created the client.
41
+ History is always asked for by window: `history` and `summary` take a start and an end, and one call
42
+ covers at most `max_history_days` (default 366; `0` removes the limit).
43
+
44
+ Callbacks run on a dispatcher thread of this client, one at a time and in the order the events arrived — not on
45
+ the thread that created the client.
42
46
 
43
47
  ## Connection and session
44
48
 
@@ -131,6 +135,11 @@ The access-server port.
131
135
  The underlying core object, for anything this layer does not wrap. Calls made on it raise the core's own
132
136
  exceptions, not pmt5api's.
133
137
 
138
+ #### `server_name` *(property)*
139
+
140
+ The name of the trade server the session logged in to (for example `ICMarketsSC-Demo`), as the server
141
+ reports it while connecting; `None` before the first connect. The address dialled is `host`.
142
+
134
143
  #### `server_time` *(property)*
135
144
 
136
145
  The server's clock, kept current by incoming quotes (server time, naive); `None` before the first
@@ -273,20 +282,34 @@ One calendar month of history (a convenience over `history`).
273
282
 
274
283
  #### `equity_history(start, timeframe='D1', exclude_same_bars=True)`
275
284
 
276
- The balance and equity curve since `start` (`timeframe` is `H1`, `H4` or `D1`).
285
+ The balance and equity curve from `start` to now (`timeframe` is `H1`, `H4` or `D1`); `start`
286
+ may be at most `max_history_days` ago.
277
287
 
278
288
  `balance` is exact; the historical `equity` is a bar-priced reconstruction and a point with
279
289
  `incomplete` set has understated floating P/L. With `exclude_same_bars` (the default, as in the C# library)
280
- consecutive points with an unchanged balance and equity are dropped. Downloads deals and per-symbol bars, so it
281
- blocks.
290
+ consecutive points with an unchanged balance and equity are dropped. A window with no deal in it is a flat line
291
+ at the balance (never empty); the last point is the live balance and equity. Downloads deals and per-symbol
292
+ bars, so it blocks.
282
293
 
283
- #### `history(start=None, end=None, timeout=None)`
294
+ #### `history(start, end, timeout=None)`
284
295
 
285
- Orders and deals between `start` and `end` (default: the last 30 days up to now). Blocks until the
286
- server has sent everything, up to `timeout` seconds (default: the client's `history_timeout`).
296
+ Orders and deals between `start` and `end`. Blocks until the server has sent everything, up to
297
+ `timeout` seconds (default: the client's `history_timeout`).
298
+
299
+ Both ends are required and the window may be at most `max_history_days` long (366 by default): a missing,
300
+ reversed or longer window raises `InvalidRequestError` before anything is sent. For a longer period,
301
+ call once per window and add the results up. For "up to now", pass `c.server_time` as `end`. `orders`
302
+ also lists a position closed in the window whose opening deal is earlier (up to a year before the window).
287
303
 
288
304
  Naive datetimes are used exactly as given, in the broker's server time; timezone-aware ones are converted to
289
- it. The default window is computed on the server's clock.
305
+ it. Two calls at once on one client run one after the other (the wait counts toward the second one's timeout).
306
+
307
+ #### `max_history_days` *(property)*
308
+
309
+ The longest window, in days, one `history`, `request_history`, `summary`,
310
+ `equity_history` or `calibrate_money_scale` call may ask for (default 366). A longer window raises
311
+ `InvalidRequestError` before anything is sent: split the period into several calls, or raise this when
312
+ you mean one long download. `0` removes the limit (the start and end are still required).
290
313
 
291
314
  #### `orders()`
292
315
 
@@ -302,16 +325,19 @@ order's `state` is not used: the core can leave a freshly opened position in `St
302
325
  #### `request_history(start, end)`
303
326
 
304
327
  Ask for history without waiting; it arrives through `on_history`. The server's answer is not trimmed
305
- to `start` / `end` (it can hold more than you asked for, in pages): filter by time. The blocking
306
- `history` does trim.
328
+ to `start` / `end` (it comes in whole calendar months, in pages): filter by time. The blocking
329
+ `history` does trim. The window rules of `history` apply; refused while a `history`
330
+ download runs on this client (`BusyError`), because the answers would mix.
307
331
 
308
332
  #### `request_history_month(year, month)`
309
333
 
310
334
  Ask for one calendar month of history without waiting; it arrives through `on_history`.
311
335
 
312
- #### `summary(start=None, end=None)`
336
+ #### `summary(start, end)`
313
337
 
314
- Realized trading result, deposits and withdrawals, from the deal ledger (all time by default).
338
+ Realized trading result, deposits and withdrawals between `start` and `end`, from the deal ledger. Both
339
+ ends are required; the window may be at most `max_history_days` long — for a longer period, add up one
340
+ summary per window (`balance` is the account's current balance in each).
315
341
 
316
342
  Downloads the deal history, so it blocks; call it off your UI thread (or use `asyncio.to_thread`).
317
343
 
@@ -324,7 +350,9 @@ use `account(refresh=True)`, which does both).
324
350
 
325
351
  #### `calibrate_money_scale(start, *, min_deals_per_symbol=None, max_self_scatter=None, max_disagreement=None, max_snap_distance=None)`
326
352
 
327
- Measure what a price unit is worth *on this account* from its own closed deals since `start`.
353
+ Measure what a price unit is worth *on this account* from its own closed deals from `start` to now;
354
+ `start` may be at most `max_history_days` ago (an older one returns `False`, the reason in
355
+ `money_scale_evidence`).
328
356
 
329
357
  Read-only; places no order. Cent handling is a per-broker configuration, not a property of the currency, so
330
358
  sizing is refused until this has succeeded. Returns `False` when the scale cannot be established (read
@@ -66,6 +66,7 @@ Every public member of the C# `MT5API` class and where it went. Generated from `
66
66
  | `LastServerMessageTime` | `Client.last_server_message_time` |
67
67
  | `LedgerValuePerUnitLot` | `Client.ledger_value_per_lot` |
68
68
  | `LoadServersDat` | `pmt5api.load_servers_dat` |
69
+ | `MaxHistoryDays` | `Client.max_history_days` |
69
70
  | `MoneyScale` | `Client.money_scale` |
70
71
  | `MoneyScaleEvidence` | `Client.money_scale_evidence` |
71
72
  | `MoneyScaleFromDeal` | `Client.money_scale_from_deal` |
@@ -114,7 +115,7 @@ Every public member of the C# `MT5API` class and where it went. Generated from `
114
115
  | `RequestQuoteHistoryToday` | `Client.request_bars` |
115
116
  | `RequiredMargin` | `Client.required_margin` |
116
117
  | `SaveServersDat` | `pmt5api.save_servers_dat` |
117
- | `Server` | `Client.host` |
118
+ | `Server` | `Client.server_name` |
118
119
  | `ServerSymBuild` | `Client.symbol_build` |
119
120
  | `ServerTime` | `Client.server_time` |
120
121
  | `ServerTimeZoneInMinutes` | `Client.server_utc_offset` |
@@ -120,6 +120,17 @@ _BARE_MESSAGES = (
120
120
  _WRAPPERS = ("AggregateException", "TargetInvocationException")
121
121
 
122
122
 
123
+ def _window_words(text: str) -> str:
124
+ """The core names the history-window arguments as a C# program sees them; say them as this package takes them."""
125
+ for core, ours in (("MaxHistoryDays", "max_history_days"), ("window (from)", "window (start)"),
126
+ ("window (to)", "window (end)"), ("reversed (from ", "reversed (start "),
127
+ (" is after to ", " is after end ")):
128
+ text = text.replace(core, ours)
129
+ for name in ("from", "to"): # .NET appends the parameter name the text already gives
130
+ text = text.replace(" (Parameter '%s')" % name, "")
131
+ return text
132
+
133
+
123
134
  def translate(exc: BaseException) -> Optional[WmtError]:
124
135
  """Return the pmt5api error that corresponds to a core exception, or ``None`` if it is not one of ours.
125
136
 
@@ -151,6 +162,8 @@ def translate(exc: BaseException) -> Optional[WmtError]:
151
162
  return cls(message, str(code) if code is not None else None, value)
152
163
  if cls is ConnectError and message.startswith("Disconnected"):
153
164
  return ConnectionLostError(message)
165
+ if cls is InvalidRequestError:
166
+ message = _window_words(message)
154
167
  return cls(message)
155
168
  if type(exc).__name__ == "Exception" and getattr(type(exc), "__module__", "") == "System":
156
169
  text = str(getattr(exc, "Message", None) or exc)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes