pmt5api 1.0.0__py3-none-any.whl

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.
@@ -0,0 +1,143 @@
1
+ # Errors & troubleshooting — wmt5api
2
+
3
+ Applies to wmt5api 3.1.0.
4
+
5
+ Errors are **typed** — catch what you care about. A licence error is raised during `Connect()`, before any
6
+ account data is read (the broker login handshake has already taken place).
7
+
8
+ ```csharp
9
+ try
10
+ {
11
+ api.Connect();
12
+ }
13
+ catch (LicenseException ex) { /* licence refused — inspect ex.Reason */ }
14
+ catch (TokenServiceException ex) { /* licence service unreachable or unusable reply — retry */ }
15
+ catch (TimeoutException ex) { /* the host did not reply within ConnectTimeout */ }
16
+ catch (ConnectException ex) { /* cannot connect, or the connection was lost */ }
17
+ catch (ServerException ex) { /* the broker refused the login — ex.Code is the MT5 return code */ }
18
+
19
+ try
20
+ {
21
+ var q = api.GetQuote(symbol, 5000);
22
+ api.OrderSend(symbol, lots, q.Ask, OrderType.Buy);
23
+ }
24
+ catch (TimeoutException ex) { /* no quote in time, or the trade was NOT CONFIRMED in time — it may still
25
+ have been accepted: check GetOpenedOrders() before resending */ }
26
+ catch (InvestorModeException ex) { /* the login is read-only (investor) */ }
27
+ catch (ServerException ex) { /* the broker rejected the request — ex.Code is the MT5 return code */ }
28
+ catch (ConnectException ex) { /* not connected, or the connection was lost */ }
29
+ catch (ArgumentException ex) { /* pre-flight validation, for example an unknown symbol */ }
30
+ ```
31
+
32
+ `TimeoutException` means different things at different steps — see the table below. In particular, **never
33
+ resend an order just because a trade call timed out.**
34
+
35
+ `Connect()` throws the typed exception **directly** — it is never wrapped in an `AggregateException`.
36
+
37
+ ## The exception types
38
+
39
+ | Type | Derives from | Raised when |
40
+ |---|---|---|
41
+ | `LicenseException` | `Exception` | The licence service refused the licence. `.Reason`, `.ServerError`, `.HttpStatus`. |
42
+ | `TokenServiceException` | `Exception` | The licence service could not be reached, timed out, or returned an unusable reply. |
43
+ | `ServerException` | `Exception` | The broker rejected the login or a trading request. `.Code` is the MT5 return code (`Msg`). |
44
+ | `ConnectException` | `Exception` | The client is not connected, a connection could not be established, or it was lost. |
45
+ | `InvestorModeException` | `Exception` | A trading call was made on a read-only (investor) login. |
46
+ | `ProxyException` | `Exception` | The configured SOCKS or HTTPS proxy refused or failed the connection. |
47
+ | `ConnectionException` | `ApplicationException` | A client certificate (`PfxFile`) was supplied but holds no RSA private key. |
48
+ | `ArgumentException` | `SystemException` | Client-side validation: an unknown symbol, an unknown group, a `CommonKey` that is not 15 bytes. |
49
+ | `TimeoutException` | `SystemException` | The host did not reply within `ConnectTimeout`; a quote did not arrive within its timeout; a history download did not return within `DownloadOrderHistoryTimeoutMs`. |
50
+ | `Exception` (plain) | — | A few state errors, listed [below](#plain-systemexception-state-errors). |
51
+
52
+ ## LicenseException — the licence service refused
53
+
54
+ `ex.Reason` is a `LicenseReason`:
55
+
56
+ | Reason | Meaning | What to do |
57
+ |---|---|---|
58
+ | `NoKey` | No API key was supplied | Set `ApiKey` or the `WMTAPI_KEY` environment variable |
59
+ | `Invalid` | The key is not recognised | Check the key; it may be for another product |
60
+ | `Expired` | The licence period ended | Renew the subscription |
61
+ | `Revoked` | The subscription was cancelled or the key revoked | Re-subscribe or obtain a new key |
62
+ | `RateLimited` | Too many requests this minute | Back off and retry |
63
+ | `LoginNotAllowed` | This login is not on the key's allow-list | Use an allowed login, or ask for the key to be widened |
64
+ | `AccountLimitReached` | The licence already has its maximum number of distinct broker logins and this is a new one | Connect with an already-enrolled login, or upgrade the plan |
65
+ | `Unknown` | The service refused for a reason this version does not recognise | Read `ex.ServerError` and `ex.HttpStatus`, and contact support |
66
+
67
+ `ex.ServerError` is the raw error string the service returned (null for a client-side `NoKey`);
68
+ `ex.HttpStatus` is the HTTP status (0 when the failure was detected client-side).
69
+
70
+ ## TokenServiceException — infrastructure, not a licence decision
71
+
72
+ The licence service was unreachable, timed out, or returned a reply that could not be used. Transport
73
+ failures are retried a few times before this is thrown. **Retry**, and check your network — do **not**
74
+ re-prompt the user for a key. `TokenServiceTimeout` (default 10 000 ms) bounds each request.
75
+
76
+ ## ServerException — the broker rejected a login or a trade
77
+
78
+ `ex.Code` is the MT5 return code (the `Msg` enum); the member name is the meaning. The ones you typically
79
+ branch on after an order:
80
+
81
+ | Code | Meaning |
82
+ |---|---|
83
+ | `DONE` / `REQUEST_EXECUTED` | success |
84
+ | `REQUOTE` / `PRICE_CHANGED` | the price moved — re-quote and resend |
85
+ | `NO_MONEY` | insufficient margin |
86
+ | `MARKET_CLOSED` | the symbol's session is closed |
87
+ | `INVALID_VOLUME` / `INVALID_PRICE` / `INVALID_STOPS` | bad request parameters |
88
+ | `TRADE_DISABLED` / `AUTOTRADE_DISABLE_SERVER` | trading is not permitted |
89
+ | `TOO_MANY_TRADE_REQUESTS` / `TOO_FREQUENT_REQUEST` | slow down |
90
+
91
+ On login the codes you will meet include `INVALID_ACCOUNT`, `ACCOUNT_DISABLED`, `UNALLOWED_IP_ADDRESS` and
92
+ `OLD_VERSION`. The full `Msg` enum is in IntelliSense.
93
+
94
+ ## What each call can throw
95
+
96
+ | Call | Typed exceptions |
97
+ |---|---|
98
+ | `Connect()` | `LicenseException`, `TokenServiceException`, `ServerException`, `ConnectException` (including "cannot connect to host:port", with the socket error as `InnerException`), `TimeoutException`, `ProxyException`, `ConnectionException` |
99
+ | `OrderSend`, `OrderClose`, `OrderModify`, `OrderCancel` and their `…AsyncTask` variants | `ArgumentException` (unknown symbol), `ConnectException`, `InvestorModeException`, `ServerException`, `TimeoutException` (the server did not confirm the request within `ExecutionTimeout`, default 30 000 ms — the order may still have been accepted, so check `GetOpenedOrders()` before resending); plain `Exception` (see below). The `…AsyncTask` variants surface these when you `await` the task. |
100
+ | `OrderSendAsync`, `OrderCloseAsync`, `OrderModifyAsync`, `OrderCancelAsync` | The same pre-flight errors are thrown; a broker rejection arrives through `OnOrderProgress`, not as an exception. |
101
+ | `Subscribe`, `GetQuote` | `ArgumentException` (unknown symbol), `TimeoutException` (`GetQuote` with a timeout); both connect first if the client is not connected, so they can also throw the `Connect()` errors. |
102
+ | `Symbols.GetInfo`, `Symbols.GetGroup`, `Symbols.GetGroupByName`, `Symbols.GetComission` | `ConnectException` (symbol list not received yet), `ArgumentException` (no such symbol or group) |
103
+ | `GetTickValue`, `GetAskTickValue`, `GetBidTickValue`, `CalculateOrderProfit` | `double.NaN` when unresolved. `GetTickValue` also throws `ArgumentException`/`ConnectException` from the symbol lookup. |
104
+ | `RequiredMargin` | `double.NaN` if the price does not arrive in time; `ArgumentException` if the broker publishes no route between the two currencies |
105
+ | `CalibrateMoneyScale`, `CalculateLots` | Do not throw for a failed measurement: `CalibrateMoneyScale` returns `false` (read `MoneyScaleEvidence`) and `CalculateLots` returns `Ok = false` with a `Reason`. |
106
+ | `DownloadOrderHistory`, `GetAccountSummary`, `CalculateEquityHistory` | `TimeoutException`; plain `Exception` (see below) |
107
+ | `LoadServersDat` | The usual file errors; `NotImplementedException` if the file holds a record type this version does not know |
108
+ | `RequestDemoAccount` | `ConnectException`/`TimeoutException` from the connection; plain `Exception` carrying the broker's refusal status |
109
+
110
+ ## Plain `System.Exception` state errors
111
+
112
+ These are raised as a plain `System.Exception`, not a typed exception, so you can match them only by type
113
+ `Exception` and message. Treat each as a state error and do not retry blindly.
114
+
115
+ | Message | Raised by | Meaning |
116
+ |---|---|---|
117
+ | `Not connected` | `Unsubscribe`, `Subscriptions`, `RequestOrderHistory(from, to)`, `DownloadOrderHistory` | The client is not connected. Call `Connect()` first. |
118
+ | `Download Order History Running` | `DownloadOrderHistory`, `RequestOrderHistory` | Another history download is in progress on this client. Wait for it to finish. |
119
+ | `Request Lots = 0` | `OrderClose`, `OrderCancel` (and their async variants) | The volume rounds to zero. |
120
+ | `Previous request still running` | `DownloadQuoteHistoryToday`, `DownloadQuoteHistoryMonth` | A download for that symbol is already in progress. |
121
+ | `Cannot DownloadQuoteHistory in N ms` | `DownloadQuoteHistoryToday`, `DownloadQuoteHistoryMonth` | The bars did not arrive within `DownloadQuoteHistoryTimeout`. |
122
+ | `If timeframe > 60 it should be in whole hours` | `ConvertToTimeframe`, `DownloadQuoteHistory…` | Use a timeframe of 60 minutes or less, or a whole number of hours. |
123
+
124
+ ## Common issues
125
+
126
+ - **`Connect()` times out (`TimeoutException`)** → the host and port are wrong or unreachable. `ConnectTimeout`
127
+ bounds the wait. Brokers often publish several access servers — use one that is reachable. The SDK fails
128
+ over across the broker's cluster members automatically, but only **after** it has reached the initial host;
129
+ it cannot fail over away from a dead primary, so pick a live one (try the broker's listed alternatives if the
130
+ first does not answer).
131
+ - **`RateLimited` is a sliding window, not a hard block** → a key has a per-minute request budget, and every
132
+ `Connect()` (including each automatic reconnect) uses one request. The first calls in a fresh minute
133
+ succeed; once the budget is used, further calls get `RateLimited` until the window rolls.
134
+ - **History never returns** → use the bounded `DownloadOrderHistory(from, to, timeoutMs, ct)` overload, or
135
+ leave `DownloadOrderHistoryTimeoutMs` at its default so the call throws `TimeoutException` instead of
136
+ waiting.
137
+ - **`IsInvestor == true`** → you connected with a read-only (investor) password; trading is not permitted and
138
+ trading calls throw `InvestorModeException`. Check `IsTradeAllowed` first.
139
+ - **Equity equals balance and profit reads 0** → the open-position quotes are cold. Subscribe the symbols (or
140
+ call `GetQuote(symbol, timeout)`), then `UpdateProfits()`.
141
+ - **`CalculateLots` returns `Ok = false`, "not calibrated"** → call `CalibrateMoneyScale(from)` first. If that
142
+ returns `false`, `MoneyScaleEvidence` says why (typically: too few closed deals without swap or
143
+ commission).
@@ -0,0 +1,67 @@
1
+ # Licensing & key management — wmt5api
2
+
3
+ Applies to wmt5api 3.1.0.
4
+
5
+ ## How authorization works
6
+
7
+ Every `Connect()` makes a licence request to our hosted **licence service** over HTTPS, authenticated by your
8
+ **API key**. The service checks the key, its validity period, its rate limit and its allowed logins, and
9
+ answers so that the connection can proceed. Your DLL talks to your broker for trading data and to the licence
10
+ service only for this handshake. No key → no connection.
11
+
12
+ What the licence request carries:
13
+
14
+ - your **API key**, in the `X-Api-Key` request header;
15
+ - the name and version of the SDK (for example `wmt5api/3.1.0`);
16
+ - your broker login **number**, and the session handshake data your broker's server issued for this
17
+ connection.
18
+
19
+ It does **not** carry your broker password, and it carries no trading data. See [Security](SECURITY.md).
20
+
21
+ ## Supplying the key
22
+
23
+ Provide the key as a **property** or an **environment variable**. The property wins; the environment variable
24
+ is the fallback. Leave the licence service URL at its default.
25
+
26
+ | What | Property | Environment variable |
27
+ |---|---|---|
28
+ | API key | `api.ApiKey = "wk_…"` | `WMTAPI_KEY` |
29
+ | Licence service URL (optional — leave at its default) | `api.TokenServiceUrl` | `WMTAPI_TOKEN_URL` |
30
+
31
+ ```csharp
32
+ api = new MT5API(user, password, host, port);
33
+ api.ApiKey = Environment.GetEnvironmentVariable("WMTAPI_KEY"); // or set the property directly
34
+ api.Connect();
35
+ ```
36
+
37
+ A key is shown **once**, at issue time. Treat it like a password (see [Security](SECURITY.md)).
38
+
39
+ ## Validity, expiry, renewal
40
+
41
+ - A licence is valid for your **subscription period**. On renewal it is extended automatically — your existing
42
+ key keeps working.
43
+ - If the subscription **lapses or is cancelled**, the key stops authorizing new connections: you get a
44
+ `LicenseException` with `Reason = Revoked` or `Expired`. Open connections are unaffected until they drop; a
45
+ reconnect (including an automatic one) needs a new licence request.
46
+
47
+ ## Revoking / rotating
48
+
49
+ - Cancel a subscription in the dashboard → its keys are revoked on the next connect.
50
+ - To rotate a key, issue a new one and update your deployment; revoke the old one.
51
+
52
+ ## Rate limits, allowed logins and the account limit
53
+
54
+ - Each key has a per-minute request rate. Every `Connect()` — including each automatic reconnect — makes one
55
+ licence request. Exceeding the rate → `Reason = RateLimited`; back off and retry.
56
+ - A key may be restricted to specific broker logins. Connecting with a login outside that list →
57
+ `Reason = LoginNotAllowed`.
58
+ - A plan has a maximum number of distinct broker logins. Connecting with a new login beyond that number →
59
+ `Reason = AccountLimitReached`; connect with an already-enrolled login, or upgrade the plan.
60
+
61
+ ## When the service cannot be reached
62
+
63
+ A licence request that fails on the network (unreachable, timeout, malformed reply) is retried a few times
64
+ and then throws `TokenServiceException` — that is not a licence decision, so retry rather than prompting for
65
+ a new key. `TokenServiceTimeout` (default 10 000 ms) bounds each request.
66
+
67
+ See [Errors & troubleshooting](ERRORS.md) for the full failure matrix.
@@ -0,0 +1,50 @@
1
+ # Security — wmt5api
2
+
3
+ Applies to wmt5api 3.1.0.
4
+
5
+ ## Your API key is a secret
6
+
7
+ Treat your `wk_…` key like a password. Anyone with it can open licensed connections billed to your account.
8
+
9
+ - **Never commit it** to source control, log it, or embed it in client-side or shipped binaries.
10
+ - Prefer the **`WMTAPI_KEY` environment variable** (or a secret manager) over a hard-coded `ApiKey`.
11
+ - The SDK never writes your key to a log or to disk. It is sent only to the licence service, over HTTPS, in
12
+ the `X-Api-Key` request header.
13
+ - Rotate the key if you suspect exposure (issue a new one, update your deployment, revoke the old one).
14
+
15
+ ## Broker credentials
16
+
17
+ - Use an **investor (read-only) password** for read-only integrations — it cannot place trades.
18
+ - Your broker **password** is used only for the handshake with your broker's server. It is never sent to the
19
+ licence service.
20
+ - Your broker login **number** is sent to the licence service (it is how the licence's allowed-logins list and
21
+ account limit are enforced), together with the SDK name and version and session handshake data issued by your
22
+ broker's server.
23
+
24
+ ## Transport
25
+
26
+ - The licence request is made over **HTTPS**. Leave `TokenServiceUrl` at its default; do not point it at a host
27
+ you do not trust.
28
+ - The connection to your broker is the MT5 client protocol to the access server you configure (`host`,
29
+ `port`), optionally through the SOCKS or HTTPS proxy you set with `ProxyEnable`, `ProxyHost` and the related
30
+ fields.
31
+
32
+ ## What leaves your machine
33
+
34
+ | To | What | When |
35
+ |---|---|---|
36
+ | Licence service | Your API key; the SDK name and version; your broker login number; non-secret session handshake data issued by your broker's server | Once per `Connect()` (and per automatic reconnect) |
37
+ | Your broker | Your login and password (used in the standard MT5 handshake), and your trading requests | During the session |
38
+
39
+ The licence service receives **no** broker password and **no** trading data — only the licence handshake.
40
+
41
+ ## Data handling
42
+
43
+ - `Account` exposes your own account holder details (name and, if your broker returns them, address and
44
+ contact fields). This is **your** data from **your** broker; handle it per your privacy obligations if you
45
+ store it.
46
+ - Nothing in the SDK sends data anywhere beyond your broker and the licence handshake.
47
+
48
+ ## Reporting
49
+
50
+ Security concerns: contact your account representative at mtapi.one.
Binary file
pmt5api/_risk.py ADDED
@@ -0,0 +1,116 @@
1
+ """Position sizing, margin and P/L arithmetic that the core computes for the account's own conventions."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ from datetime import datetime
6
+ from typing import Optional
7
+
8
+ from ._base import _Base
9
+ from ._util import enum_value, finite, to_core_time, translated
10
+ from .errors import InvalidRequestError
11
+ from .models import Deal, LotSizing, _opt_float
12
+
13
+
14
+ class _RiskMixin(_Base):
15
+ @translated
16
+ def calibrate_money_scale(self, start: datetime, *, min_deals_per_symbol: Optional[int] = None,
17
+ max_self_scatter: Optional[float] = None, max_disagreement: Optional[float] = None,
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``.
20
+
21
+ Read-only; places no order. Cent handling is a per-broker configuration, not a property of the currency, so
22
+ sizing is refused until this has succeeded. Returns ``False`` when the scale cannot be established (read
23
+ :attr:`money_scale_evidence`); more history usually fixes it.
24
+ """
25
+ self._require_connected()
26
+ kwargs = {}
27
+ if min_deals_per_symbol is not None:
28
+ kwargs["minDealsPerSymbol"] = int(min_deals_per_symbol)
29
+ if max_self_scatter is not None:
30
+ kwargs["maxSelfScatter"] = float(max_self_scatter)
31
+ if max_disagreement is not None:
32
+ kwargs["maxDisagreement"] = float(max_disagreement)
33
+ if max_snap_distance is not None:
34
+ kwargs["maxSnapDistance"] = float(max_snap_distance)
35
+ return bool(self._api.CalibrateMoneyScale(to_core_time(self._server_naive(start)), **kwargs))
36
+
37
+ @property
38
+ def money_scale(self) -> Optional[float]:
39
+ """The measured scale (``None`` until :meth:`calibrate_money_scale` has succeeded)."""
40
+ return _opt_float(self._api.MoneyScale)
41
+
42
+ @property
43
+ def money_scale_evidence(self) -> str:
44
+ """Why the scale is what it is, or why it could not be established."""
45
+ return str(self._api.MoneyScaleEvidence or "")
46
+
47
+ @translated
48
+ def lots_for_risk(self, symbol: str, entry_price: float, stop_price: float, risk_amount: float, *,
49
+ lot_step: Optional[float] = None, min_lot: Optional[float] = None,
50
+ max_ratio: Optional[float] = None, max_equity_share: Optional[float] = None,
51
+ max_lot: Optional[float] = None) -> LotSizing:
52
+ """The volume that loses ``risk_amount`` (account currency) if ``stop_price`` is hit, with the evidence.
53
+
54
+ Requires a successful :meth:`calibrate_money_scale`; without it the answer is a refusal. The volume is
55
+ rounded **down**. Check ``ok``: when false, ``lots`` is 0 and ``reason`` says which check failed.
56
+ """
57
+ self._require_connected()
58
+ kwargs = {}
59
+ for key, value in (("lotStep", lot_step), ("minLot", min_lot), ("maxRatio", max_ratio),
60
+ ("maxEquityShare", max_equity_share), ("maxLot", max_lot)):
61
+ if value is not None:
62
+ kwargs[key] = float(value)
63
+ result = self._api.CalculateLots(symbol, float(entry_price), float(stop_price), float(risk_amount), **kwargs)
64
+ return LotSizing._from_core(result)
65
+
66
+ @translated
67
+ def required_margin(self, symbol: str, lots: float, side: str = "buy", price: float = 0.0) -> float:
68
+ """The margin needed to open ``lots`` of ``symbol``, computed as the terminal does (``price`` 0 = live quote).
69
+ A reference figure, not a sizing input."""
70
+ self._require_connected()
71
+ kind = enum_value(self._core.DealType, "deal" + str(side), "side")
72
+ value = float(self._api.RequiredMargin(symbol, finite(lots, "lots", positive=True), kind,
73
+ finite(price, "price", non_negative=True)))
74
+ if math.isnan(value):
75
+ raise InvalidRequestError("the margin of %s cannot be computed (no quote or conversion rate yet)" % symbol)
76
+ return value
77
+
78
+ @translated
79
+ def order_profit(self, symbol: str, open_price: float, close_price: float, lots: float, buy: bool = True) -> float:
80
+ """The P/L, in account currency, of ``lots`` moving from ``open_price`` to ``close_price`` (cent accounts
81
+ included). For reporting a completed or hypothetical move; use :meth:`lots_for_risk` to size."""
82
+ self._require_connected()
83
+ value = float(self._api.CalculateOrderProfit(symbol, finite(open_price, "open_price"),
84
+ finite(close_price, "close_price"),
85
+ finite(lots, "lots", positive=True), bool(buy)))
86
+ if math.isnan(value):
87
+ raise InvalidRequestError("the profit of %s cannot be computed (no conversion rate yet)" % symbol)
88
+ return value
89
+
90
+ @translated
91
+ def money_scale_from_deal(self, deal: Deal) -> Optional[float]:
92
+ """Re-measure the scale from ONE closed deal — the broker's own accounting of a trade that happened. Compare
93
+ it with :attr:`money_scale`; a disagreement means every size derived from the calibration was wrong."""
94
+ self._require_connected()
95
+ core_deal = self._core.Deal()
96
+ core_deal.Ticket = int(deal.ticket)
97
+ core_deal.OrderTicket = int(deal.order_ticket)
98
+ core_deal.PositionTicket = int(deal.position_ticket)
99
+ core_deal.Symbol = deal.symbol
100
+ core_deal.Type = enum_value(self._core.DealType, deal.type, "deal type")
101
+ core_deal.Direction = enum_value(self._core.Direction, deal.direction, "direction")
102
+ if deal.time is not None:
103
+ core_deal.Time = to_core_time(deal.time)
104
+ core_deal.OpenPrice = float(deal.open_price)
105
+ core_deal.Price = float(deal.price)
106
+ core_deal.StopLoss = float(deal.stop_loss)
107
+ core_deal.TakeProfit = float(deal.take_profit)
108
+ core_deal.Lots = float(deal.lots)
109
+ core_deal.Profit = float(deal.profit)
110
+ core_deal.Commission = float(deal.commission)
111
+ core_deal.Swap = float(deal.swap)
112
+ core_deal.ContractSize = float(deal.contract_size)
113
+ core_deal.Digits = int(deal.digits)
114
+ core_deal.Comment = deal.comment
115
+ core_deal.ExpertId = int(deal.magic)
116
+ return _opt_float(self._api.MoneyScaleFromDeal(core_deal))
pmt5api/_runtime.py ADDED
@@ -0,0 +1,154 @@
1
+ """Locates the .NET runtime and the licensed core library and loads them once per process."""
2
+ from __future__ import annotations
3
+
4
+ import glob
5
+ import importlib
6
+ import os
7
+ import shutil
8
+ import sys
9
+ import threading
10
+ from pathlib import Path
11
+ from typing import Iterator, Optional
12
+
13
+ from .errors import CoreNotFoundError, RuntimeNotFoundError
14
+
15
+ #: Environment variable that points at wmt5api.dll.
16
+ ENV_DLL = "WMTAPI_DLL"
17
+
18
+ _NAMESPACE = "wmt5api" # the .NET namespace of the core; NOT the name of this Python package
19
+ _PKG = Path(__file__).resolve().parent
20
+ _BUNDLED_RUNTIME = _PKG / "_dotnet" # present in the platform wheels: host/fxr + shared/Microsoft.NETCore.App
21
+ _lock = threading.Lock()
22
+ _core = None
23
+ _dll: Optional[Path] = None
24
+
25
+
26
+ def find_dll(explicit=None) -> Path:
27
+ """The core library to load: the caller's path, else ``WMTAPI_DLL``, else the one bundled in the wheel.
28
+
29
+ A path the caller gave explicitly (argument or environment) is never silently replaced by another: if it does not
30
+ exist that is an error.
31
+ """
32
+ requested = explicit or os.environ.get(ENV_DLL)
33
+ if requested:
34
+ path = Path(requested)
35
+ if path.is_file():
36
+ return path
37
+ raise CoreNotFoundError("wmt5api.dll was not found at %s (from %s)" %
38
+ (path, "dll_path" if explicit else ENV_DLL))
39
+ bundled = _PKG / "_native" / "wmt5api.dll"
40
+ if bundled.is_file():
41
+ return bundled
42
+ raise CoreNotFoundError(
43
+ "wmt5api.dll was not found. Pass dll_path=..., or set the " + ENV_DLL + " environment variable to the DLL "
44
+ "taken from your wmt5api package, or install a pmt5api wheel that bundles it (looked for " + str(bundled) + ")."
45
+ )
46
+
47
+
48
+ def _dotnet_root_candidates() -> Iterator[Path]:
49
+ exe = shutil.which("dotnet")
50
+ if exe:
51
+ yield Path(exe).resolve().parent
52
+ home = Path.home()
53
+ yield home / ".dotnet"
54
+ for p in ("/usr/share/dotnet", "/usr/lib/dotnet", "/usr/local/share/dotnet", "/opt/dotnet"):
55
+ yield Path(p)
56
+ pf = os.environ.get("ProgramFiles")
57
+ if pf:
58
+ yield Path(pf) / "dotnet"
59
+
60
+
61
+ def _icu_present() -> bool:
62
+ """True when a system ICU library is installed (the .NET runtime needs one unless globalization is invariant)."""
63
+ patterns = ("/usr/lib*/libicuuc.so*", "/usr/lib/*-linux-gnu/libicuuc.so*", "/lib*/libicuuc.so*",
64
+ "/lib/*-linux-gnu/libicuuc.so*", "/usr/local/lib*/libicuuc.so*", "/opt/homebrew/lib/libicuuc*",
65
+ "/usr/lib/libicuuc*")
66
+ return any(glob.glob(p) for p in patterns)
67
+
68
+
69
+ def _ensure_dotnet_root() -> None:
70
+ """Point the runtime host at a .NET runtime: the caller's ``DOTNET_ROOT``, else the one bundled in this package,
71
+ else an installed one. Say so clearly when there is none."""
72
+ # Without an ICU library the runtime aborts the whole process (uncatchably) at start-up unless globalization is
73
+ # invariant. The core does not depend on culture-specific behaviour, so on a machine with no ICU ask for invariant
74
+ # mode — whichever runtime is used, and including a DOTNET_ROOT inherited from a parent process.
75
+ if sys.platform.startswith("linux") and not _icu_present():
76
+ os.environ.setdefault("DOTNET_SYSTEM_GLOBALIZATION_INVARIANT", "1")
77
+ if os.environ.get("DOTNET_ROOT"):
78
+ return
79
+ if (_BUNDLED_RUNTIME / "shared" / "Microsoft.NETCore.App").is_dir():
80
+ os.environ["DOTNET_ROOT"] = str(_BUNDLED_RUNTIME)
81
+ return
82
+ for root in _dotnet_root_candidates():
83
+ if (root / "shared" / "Microsoft.NETCore.App").is_dir():
84
+ os.environ["DOTNET_ROOT"] = str(root)
85
+ return
86
+ raise RuntimeNotFoundError(
87
+ "No .NET runtime was found. Install the .NET 8 (or later) runtime, or set DOTNET_ROOT to its install directory."
88
+ )
89
+
90
+
91
+ def runtime_root() -> Optional[Path]:
92
+ """The .NET root in use (after ``load``), or None."""
93
+ root = os.environ.get("DOTNET_ROOT")
94
+ return Path(root) if root else None
95
+
96
+
97
+ def _has_entry(path_entry: str, name: str) -> bool:
98
+ base = path_entry or os.getcwd()
99
+ try:
100
+ return os.path.isdir(os.path.join(base, name))
101
+ except OSError:
102
+ return False
103
+
104
+
105
+ def _import_namespace():
106
+ """Import the core's .NET namespace even if a same-named folder is lying around on sys.path."""
107
+ mod = sys.modules.get(_NAMESPACE)
108
+ if mod is not None and hasattr(mod, "MT5API"):
109
+ return mod
110
+ sys.modules.pop(_NAMESPACE, None)
111
+ saved = list(sys.path)
112
+ try:
113
+ sys.path[:] = [p for p in sys.path if not _has_entry(p, _NAMESPACE)]
114
+ importlib.invalidate_caches()
115
+ mod = importlib.import_module(_NAMESPACE)
116
+ finally:
117
+ sys.path[:] = saved
118
+ if not hasattr(mod, "MT5API"):
119
+ raise CoreNotFoundError("the loaded library does not expose MT5API; is it the wmt5api package?")
120
+ return mod
121
+
122
+
123
+ def load(dll_path=None):
124
+ """Load the runtime and the core (idempotent, thread-safe). Returns the core's .NET namespace module."""
125
+ global _core, _dll
126
+ with _lock:
127
+ if _core is not None:
128
+ if dll_path is not None and _dll is not None and Path(dll_path).resolve() != _dll.resolve():
129
+ raise CoreNotFoundError(
130
+ "a different core is already loaded in this process (%s); one process can use one core, "
131
+ "so dll_path=%s was not loaded" % (_dll, dll_path))
132
+ return _core
133
+ dll = find_dll(dll_path)
134
+ _ensure_dotnet_root()
135
+ try:
136
+ from pythonnet import load as _load
137
+ _load("coreclr")
138
+ import clr # noqa: F401 (provided by pythonnet)
139
+ except Exception as e: # noqa: BLE001 — anything here means the runtime could not start
140
+ raise RuntimeNotFoundError("could not start the .NET runtime: %s" % e) from e
141
+ import clr as _clr
142
+ from System.Reflection import Assembly # available once the runtime is up
143
+ try:
144
+ Assembly.LoadFrom(str(dll)) # AddReference(<absolute path>) fails on pythonnet 3.0.x
145
+ except Exception as e: # noqa: BLE001
146
+ raise CoreNotFoundError("could not load %s: %s" % (dll, e)) from e
147
+ _clr.AddReference("wmt5api") # now by name: registers the namespace for `import`
148
+ _core = _import_namespace()
149
+ _dll = dll
150
+ return _core
151
+
152
+
153
+ def loaded_dll() -> Optional[Path]:
154
+ return _dll