pmt4api 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. {pmt4api-1.1.2 → pmt4api-1.2.0}/CHANGELOG.md +39 -0
  2. {pmt4api-1.1.2 → pmt4api-1.2.0}/PKG-INFO +28 -4
  3. {pmt4api-1.1.2 → pmt4api-1.2.0}/README.md +27 -3
  4. {pmt4api-1.1.2 → pmt4api-1.2.0}/examples/pnl.py +5 -3
  5. {pmt4api-1.1.2 → pmt4api-1.2.0}/src/pmt4api/__init__.py +1 -1
  6. {pmt4api-1.1.2 → pmt4api-1.2.0}/src/pmt4api/_account.py +54 -28
  7. {pmt4api-1.1.2 → pmt4api-1.2.0}/src/pmt4api/_coverage.py +4 -0
  8. {pmt4api-1.1.2 → pmt4api-1.2.0}/src/pmt4api/_runtime.py +70 -8
  9. {pmt4api-1.1.2 → pmt4api-1.2.0}/src/pmt4api/_streams.py +5 -4
  10. {pmt4api-1.1.2 → pmt4api-1.2.0}/src/pmt4api/client.py +6 -1
  11. {pmt4api-1.1.2 → pmt4api-1.2.0}/src/pmt4api/diagnostics.py +8 -0
  12. {pmt4api-1.1.2 → pmt4api-1.2.0}/src/pmt4api/docs/API.md +30 -12
  13. {pmt4api-1.1.2 → pmt4api-1.2.0}/src/pmt4api/docs/FROM-CSHARP.md +2 -1
  14. {pmt4api-1.1.2 → pmt4api-1.2.0}/src/pmt4api/errors.py +12 -1
  15. {pmt4api-1.1.2 → pmt4api-1.2.0}/LICENSE.txt +0 -0
  16. {pmt4api-1.1.2 → pmt4api-1.2.0}/examples/bars.py +0 -0
  17. {pmt4api-1.1.2 → pmt4api-1.2.0}/examples/connect.py +0 -0
  18. {pmt4api-1.1.2 → pmt4api-1.2.0}/examples/servers.py +0 -0
  19. {pmt4api-1.1.2 → pmt4api-1.2.0}/examples/sizing.py +0 -0
  20. {pmt4api-1.1.2 → pmt4api-1.2.0}/examples/stream_async.py +0 -0
  21. {pmt4api-1.1.2 → pmt4api-1.2.0}/examples/stream_quotes.py +0 -0
  22. {pmt4api-1.1.2 → pmt4api-1.2.0}/examples/trade_demo.py +0 -0
  23. {pmt4api-1.1.2 → pmt4api-1.2.0}/hatch_build.py +0 -0
  24. {pmt4api-1.1.2 → pmt4api-1.2.0}/pyproject.toml +0 -0
  25. {pmt4api-1.1.2 → pmt4api-1.2.0}/src/pmt4api/__main__.py +0 -0
  26. {pmt4api-1.1.2 → pmt4api-1.2.0}/src/pmt4api/_base.py +0 -0
  27. {pmt4api-1.1.2 → pmt4api-1.2.0}/src/pmt4api/_events.py +0 -0
  28. {pmt4api-1.1.2 → pmt4api-1.2.0}/src/pmt4api/_market.py +0 -0
  29. {pmt4api-1.1.2 → pmt4api-1.2.0}/src/pmt4api/_risk.py +0 -0
  30. {pmt4api-1.1.2 → pmt4api-1.2.0}/src/pmt4api/_session.py +0 -0
  31. {pmt4api-1.1.2 → pmt4api-1.2.0}/src/pmt4api/_trading.py +0 -0
  32. {pmt4api-1.1.2 → pmt4api-1.2.0}/src/pmt4api/_util.py +0 -0
  33. {pmt4api-1.1.2 → pmt4api-1.2.0}/src/pmt4api/bars.py +0 -0
  34. {pmt4api-1.1.2 → pmt4api-1.2.0}/src/pmt4api/frames.py +0 -0
  35. {pmt4api-1.1.2 → pmt4api-1.2.0}/src/pmt4api/models.py +0 -0
  36. {pmt4api-1.1.2 → pmt4api-1.2.0}/src/pmt4api/py.typed +0 -0
  37. {pmt4api-1.1.2 → pmt4api-1.2.0}/src/pmt4api/servers.py +0 -0
@@ -1,5 +1,44 @@
1
1
  # Changelog — pmt4api
2
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
+
3
42
  ## [1.1.2] — 2026-10-15
4
43
 
5
44
  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: pmt4api
3
- Version: 1.1.2
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
@@ -235,22 +235,38 @@ cent-account scaling, so check the first result against your terminal. Also: `re
235
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) |
236
236
  | `orders()` / `positions()` / `pending_orders()` / `order(ticket)` | `list[Order]` working / open positions / pending / one |
237
237
  | `history(start, end)` | `list[Order]` — closed orders and balance / credit entries, by close time |
238
- | `summary(start=None, end=None)` | `AccountSummary` — realized profit, deposits, withdrawals |
238
+ | `summary(start, end)` | `AccountSummary` — realized profit, deposits, withdrawals |
239
239
  | `equity_history(start, "D1")` | `list[EquityPoint]` — balance (exact) and equity (reconstructed) curve with drawdowns |
240
240
 
241
241
  MT4 keeps one record for open positions, pending orders, closed orders and ledger entries: `Order.order_type` tells
242
242
  them apart (`Buy` / `Sell`, `BuyLimit` … , `Balance` / `Credit`), and `is_market`, `is_pending`, `is_capital_flow`
243
243
  say it directly. Times are naive `datetime` values in the **broker's server time**; an unset time is `None`.
244
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
+
245
258
  `history()`, `summary()` and `equity_history()` each open their own short-lived broker connection and cost one
246
259
  licence request, which the licence service rate-limits per minute: fetch once and reuse the result. `history()`
247
- 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.
248
262
 
249
263
  ## Events
250
264
 
251
265
  `on_quote`, `on_order_update`, `on_order_progress`, `on_connect`, `on_disconnect`, `on_quote_history`,
252
266
  `on_symbols_update` — each returns a function that removes the callback. `quotes()`, `order_updates()` and
253
- `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.
254
270
 
255
271
  ## Staying connected
256
272
 
@@ -274,12 +290,20 @@ restored, cached snapshots are not. `disconnect()` is final: it also stops a rec
274
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) | — |
275
291
  | Reconnect | `auto_reconnect`, `reconnect_backoff` (3, doubling up to `reconnect_max_backoff`, 30), `reconnect_max_attempts` (0 = no limit), `servers=` | — |
276
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 | — |
277
294
  | .NET runtime location | — | `DOTNET_ROOT` |
278
295
 
279
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
280
297
  .NET runtime is taken from `DOTNET_ROOT` if you set it, else the runtime bundled in the wheel, else an installed one.
281
298
  The bundled runtime runs in invariant-culture mode on machines without ICU. The MT4 core has no proxy support.
282
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
+
283
307
  ### Diagnostics
284
308
 
285
309
  ```python
@@ -208,22 +208,38 @@ cent-account scaling, so check the first result against your terminal. Also: `re
208
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) |
209
209
  | `orders()` / `positions()` / `pending_orders()` / `order(ticket)` | `list[Order]` working / open positions / pending / one |
210
210
  | `history(start, end)` | `list[Order]` — closed orders and balance / credit entries, by close time |
211
- | `summary(start=None, end=None)` | `AccountSummary` — realized profit, deposits, withdrawals |
211
+ | `summary(start, end)` | `AccountSummary` — realized profit, deposits, withdrawals |
212
212
  | `equity_history(start, "D1")` | `list[EquityPoint]` — balance (exact) and equity (reconstructed) curve with drawdowns |
213
213
 
214
214
  MT4 keeps one record for open positions, pending orders, closed orders and ledger entries: `Order.order_type` tells
215
215
  them apart (`Buy` / `Sell`, `BuyLimit` … , `Balance` / `Credit`), and `is_market`, `is_pending`, `is_capital_flow`
216
216
  say it directly. Times are naive `datetime` values in the **broker's server time**; an unset time is `None`.
217
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
+
218
231
  `history()`, `summary()` and `equity_history()` each open their own short-lived broker connection and cost one
219
232
  licence request, which the licence service rate-limits per minute: fetch once and reuse the result. `history()`
220
- 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.
221
235
 
222
236
  ## Events
223
237
 
224
238
  `on_quote`, `on_order_update`, `on_order_progress`, `on_connect`, `on_disconnect`, `on_quote_history`,
225
239
  `on_symbols_update` — each returns a function that removes the callback. `quotes()`, `order_updates()` and
226
- `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.
227
243
 
228
244
  ## Staying connected
229
245
 
@@ -247,12 +263,20 @@ restored, cached snapshots are not. `disconnect()` is final: it also stops a rec
247
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) | — |
248
264
  | Reconnect | `auto_reconnect`, `reconnect_backoff` (3, doubling up to `reconnect_max_backoff`, 30), `reconnect_max_attempts` (0 = no limit), `servers=` | — |
249
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 | — |
250
267
  | .NET runtime location | — | `DOTNET_ROOT` |
251
268
 
252
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
253
270
  .NET runtime is taken from `DOTNET_ROOT` if you set it, else the runtime bundled in the wheel, else an installed one.
254
271
  The bundled runtime runs in invariant-culture mode on machines without ICU. The MT4 core has no proxy support.
255
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
+
256
280
  ### Diagnostics
257
281
 
258
282
  ```python
@@ -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.1.2"
20
+ __version__ = "1.2.0"
21
21
 
22
22
  __all__ = [
23
23
  "Client",
@@ -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,
@@ -42,6 +42,7 @@ COVERAGE: Dict[str, str] = {
42
42
  "ConnectTimeout": "Client(connect_timeout)",
43
43
  "GetQuoteTimeoutMs": "Client(quote_timeout)",
44
44
  "DownloadOrderHistoryTimeoutMs": "Client(history_timeout)",
45
+ "MaxHistoryDays": "Client.max_history_days",
45
46
  "DownloadQuoteHistoryTimeout": "Client(bars_timeout)",
46
47
  "NoServerMessagesTimeout": "Client(no_message_timeout)",
47
48
  "TradeTimeout": "Client(trade_timeout)",
@@ -182,6 +183,9 @@ NOTES: Dict[str, str] = {
182
183
  "32767-bar limit; the server's own depth limit applies",
183
184
  "DownloadOrderHistory": "`timeout=` and a `threading.Event` for `cancel=` replace the timeout and cancellation "
184
185
  "token arguments; `ahistory` cancels with the awaiting task",
186
+ "GetAccountSummary": "the window overload only (`summary(start, end)`); the core's all-time overload is "
187
+ "obsolete and throws",
188
+ "MaxHistoryDays": "also a constructor argument, `Client(max_history_days=...)`",
185
189
  "Servers": "give `servers=[(host, port), ...]` to the constructor or call `add_server`",
186
190
  "Reconnect": "returns False when every server failed; with `auto_reconnect=True` the core does it by itself",
187
191
  }
@@ -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 ``WMT4API_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("wmt4api") # 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 (``WMT4API_DLL``, ``DOTNET_ROOT``) chose when the core was loaded; empty if nothing."""
238
+ return list(_chosen_by_environment)
@@ -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.
@@ -80,6 +80,9 @@ class Client(_SessionMixin, _MarketMixin, _AccountMixin, _RiskMixin, _TradingMix
80
80
  ``comment_encoding`` is the codepage order comments are written in (default ``latin-1``); ``placed_type``
81
81
  records how orders are placed (``"manual"``, ``"expert"``, ``"dealer"``; a per-call ``placed_type`` overrides it).
82
82
 
83
+ History is always asked for by window: :meth:`history` and :meth:`summary` take a start and an end, and one call
84
+ covers at most ``max_history_days`` (default 366; ``0`` removes the limit).
85
+
83
86
  Callbacks run on a dispatcher thread of this client, not on the thread that created it.
84
87
  """
85
88
 
@@ -92,7 +95,7 @@ class Client(_SessionMixin, _MarketMixin, _AccountMixin, _RiskMixin, _TradingMix
92
95
  auto_reconnect: bool = False, reconnect_backoff: Optional[float] = None,
93
96
  reconnect_max_backoff: Optional[float] = None, reconnect_max_attempts: Optional[int] = None,
94
97
  servers: Optional[Sequence[Tuple[str, int]]] = None, comment_encoding: str = "latin-1",
95
- placed_type: Optional[str] = None):
98
+ placed_type: Optional[str] = None, max_history_days: Optional[int] = None):
96
99
  core = _runtime.load(dll_path)
97
100
  self._core = core
98
101
  self._login = int(login)
@@ -136,6 +139,8 @@ class Client(_SessionMixin, _MarketMixin, _AccountMixin, _RiskMixin, _TradingMix
136
139
  api.AutoReconnect = True
137
140
  if placed_type:
138
141
  self._trader.PlacedType = int(_placed(core.PlacedType, placed_type))
142
+ if max_history_days is not None:
143
+ self.max_history_days = max_history_days
139
144
  self._comment_encoding = comment_encoding
140
145
  api.Encoding = _net_encoding(comment_encoding)
141
146
  for h, p in (servers or ()):
@@ -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 = "pmt4api.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, token_timeout=None, trade_timeout=None, quote_timeout=None, history_timeout=None, bars_timeout=None, no_message_timeout=None, auto_reconnect=False, reconnect_backoff=None, reconnect_max_backoff=None, reconnect_max_attempts=None, servers=None, comment_encoding='latin-1', placed_type=None)
8
+ Client(login, password, host, port=443, *, api_key=None, token_url=None, dll_path=None, connect_timeout=None, token_timeout=None, trade_timeout=None, quote_timeout=None, history_timeout=None, bars_timeout=None, no_message_timeout=None, auto_reconnect=False, reconnect_backoff=None, reconnect_max_backoff=None, reconnect_max_attempts=None, servers=None, comment_encoding='latin-1', placed_type=None, max_history_days=None)
9
9
  ```
10
10
 
11
11
  A connection to one MetaTrader 4 account through the licensed core.
@@ -36,6 +36,9 @@ and call `connect()` in a loop. `servers` are extra `(host, port)` access server
36
36
  `comment_encoding` is the codepage order comments are written in (default `latin-1`); `placed_type`
37
37
  records how orders are placed (`"manual"`, `"expert"`, `"dealer"`; a per-call `placed_type` overrides it).
38
38
 
39
+ History is always asked for by window: `history` and `summary` take a start and an end, and one call
40
+ covers at most `max_history_days` (default 366; `0` removes the limit).
41
+
39
42
  Callbacks run on a dispatcher thread of this client, not on the thread that created it.
40
43
 
41
44
  ## Connection and session
@@ -236,7 +239,7 @@ Account details. With `refresh` (the default) every open position's symbol is qu
236
239
  derived on the client (the server does not push them), as the terminal does; see `equity` for the cheap
237
240
  read of one figure.
238
241
 
239
- #### `async ahistory(start=None, end=None, timeout=None)`
242
+ #### `async ahistory(start, end, timeout=None)`
240
243
 
241
244
  Awaitable `history`. Cancelling the awaiting task abandons the download (the broker connection it
242
245
  opened is closed), so `asyncio.wait_for(c.ahistory(...), 10)` does not leave a request running.
@@ -257,25 +260,31 @@ quotes.
257
260
 
258
261
  #### `equity_history(start, timeframe='D1', exclude_same_bars=True)`
259
262
 
260
- The balance and equity curve since `start` (`timeframe` is `H1`, `H4` or `D1`).
263
+ The balance and equity curve from `start` to now (`timeframe` is `H1`, `H4` or `D1`); `start`
264
+ may be at most `max_history_days` ago.
261
265
 
262
266
  `balance` is exact; the historical `equity` is a reconstruction priced from the symbols' bars. With
263
267
  `exclude_same_bars` (the default, as in the C# library) consecutive points with an unchanged balance and
264
- equity are dropped. Downloads the history and per-symbol bars, so it blocks and costs a licence request.
268
+ equity are dropped. A window with no closed order in it is a flat line at the balance (never empty); the last
269
+ point is the live balance and equity. Downloads the history and per-symbol bars, so it blocks and costs a
270
+ licence request.
265
271
 
266
272
  #### `free_margin` *(property)*
267
273
 
268
274
  Equity − margin.
269
275
 
270
- #### `history(start=None, end=None, timeout=None, cancel=None)`
276
+ #### `history(start, end, timeout=None, cancel=None)`
277
+
278
+ Closed orders and balance / credit entries between `start` and `end`, sorted by close time. Blocks until
279
+ the server has sent everything, up to `timeout` seconds (default: the client's `history_timeout`); set the
280
+ `cancel` event to abandon the wait early (`RequestCancelled`).
271
281
 
272
- Closed orders and balance / credit entries between `start` and `end` (default: the last 30 days up to
273
- now), sorted by close time. Blocks until the server has sent everything, up to `timeout` seconds (default:
274
- the client's `history_timeout`); set the `cancel` event to abandon the wait early
275
- (`RequestCancelled`).
282
+ Both ends are required and the window may be at most `max_history_days` long (366 by default): a missing,
283
+ reversed or longer window raises `InvalidRequestError` before anything is sent. For a longer period,
284
+ call once per window and add the results up. For "up to now", pass `c.server_time` as `end`.
276
285
 
277
286
  Naive datetimes are used exactly as given, in the broker's server time; timezone-aware ones are converted to
278
- it. The default window is computed on the server's clock.
287
+ it.
279
288
 
280
289
  Each call opens its own short-lived broker connection and costs one licence request, which the licence
281
290
  service rate-limits per minute: fetch once and reuse the result rather than polling.
@@ -299,6 +308,13 @@ conversion quote.
299
308
 
300
309
  Equity / margin × 100, as the terminal shows it; 0 when no margin is used.
301
310
 
311
+ #### `max_history_days` *(property)*
312
+
313
+ The longest window, in days, one `history`, `summary` or `equity_history` call may ask
314
+ for (default 366). A longer window raises `InvalidRequestError` before anything is sent: split the
315
+ period into several calls, or raise this when you mean one long download. `0` removes the limit (the start
316
+ and end are still required).
317
+
302
318
  #### `order(ticket)`
303
319
 
304
320
  One open order or position by ticket, or `None` when it is not open.
@@ -320,9 +336,11 @@ Open market positions only (`Buy` / `Sell`).
320
336
 
321
337
  The floating P/L of the open positions (equity − balance − credit), from cached quotes.
322
338
 
323
- #### `summary(start=None, end=None)`
339
+ #### `summary(start, end)`
324
340
 
325
- Realized trading result, deposits and withdrawals, from the closed-order ledger (all time by default).
341
+ Realized trading result, deposits and withdrawals between `start` and `end`, from the closed-order
342
+ ledger. Both ends are required; the window may be at most `max_history_days` long — for a longer period,
343
+ add up one summary per window (`balance` is the account's current balance in each).
326
344
 
327
345
  Downloads the history, so it blocks and costs one licence request; call it off your UI thread (or use
328
346
  `asyncio.to_thread`).
@@ -29,7 +29,7 @@ Every public member of the C# `MT4API` and `OrderClient` classes and where it we
29
29
  | `DownloadQuoteHistory` | `Client.bars` | `count=` bars ending at `end`, or everything since `start=`; pages past the wire's 32767-bar limit; the server's own depth limit applies |
30
30
  | `DownloadQuoteHistoryTimeout` | `Client(bars_timeout)` | |
31
31
  | `Encoding` | `Client(comment_encoding)` | |
32
- | `GetAccountSummary` | `Client.summary` | |
32
+ | `GetAccountSummary` | `Client.summary` | the window overload only (`summary(start, end)`); the core's all-time overload is obsolete and throws |
33
33
  | `GetAskTickValue` | `Client.tick_value` | |
34
34
  | `GetBidTickValue` | `Client.tick_value` | |
35
35
  | `GetOpenedOrder` | `Client.order` | |
@@ -48,6 +48,7 @@ Every public member of the C# `MT4API` and `OrderClient` classes and where it we
48
48
  | `LiveEquityCached` | `Client.equity` | |
49
49
  | `LiveMarginCached` | `Client.margin` | |
50
50
  | `LoadServersIni` | `pmt4api.load_servers_ini` | |
51
+ | `MaxHistoryDays` | `Client.max_history_days` | also a constructor argument, `Client(max_history_days=...)` |
51
52
  | `NoServerMessagesTimeout` | `Client(no_message_timeout)` | |
52
53
  | `OnConnect` | `Client.on_connect` | |
53
54
  | `OnDisconnect` | `Client.on_disconnect` | |
@@ -171,6 +171,17 @@ def _python_words(text: str) -> str:
171
171
  "pass token_url= to Client(...) or set the WMT4API_TOKEN_URL environment variable"))
172
172
 
173
173
 
174
+ def _window_words(text: str) -> str:
175
+ """The core names the history-window arguments as a C# program sees them; say them as this package takes them."""
176
+ for core, ours in (("MaxHistoryDays", "max_history_days"), ("window (from)", "window (start)"),
177
+ ("window (to)", "window (end)"), ("reversed (from ", "reversed (start "),
178
+ (" is after to ", " is after end ")):
179
+ text = text.replace(core, ours)
180
+ for name in ("from", "to"): # .NET appends the parameter name the text already gives
181
+ text = text.replace(" (Parameter '%s')" % name, "")
182
+ return text
183
+
184
+
174
185
  def _status(text: str) -> Optional[int]:
175
186
  """The number in "... rejected (status N)" of the core's bare connect-phase refusals."""
176
187
  if "(status " not in text:
@@ -227,7 +238,7 @@ def translate(exc: BaseException) -> Optional[WmtError]:
227
238
  if kind == "cancelled":
228
239
  return RequestCancelled(message)
229
240
  if kind == "invalid":
230
- return InvalidRequestError(message)
241
+ return InvalidRequestError(_window_words(message))
231
242
  name = type(exc).__name__
232
243
  module = getattr(type(exc), "__module__", "")
233
244
  if name == "Exception" and module == "System":
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
File without changes
File without changes
File without changes