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.
pmt5api/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ """pmt5api — Python access to the wmt5api MetaTrader 5 client library.
2
+
3
+ A typed, translated layer over every public capability of the licensed C# core (loaded in-process): the protocol,
4
+ licensing and trading logic stay in the core; this package adds a Pythonic surface — plain data types, a small error
5
+ hierarchy, context managers, sync and async streams, bars, sizing and order entry.
6
+ """
7
+ from ._coverage import COVERAGE, TYPES
8
+ from .bars import Bar, QuoteHistoryEvent, TIMEFRAMES, aggregate
9
+ from .client import Client, Proxy
10
+ from .diagnostics import enable_core_logging
11
+ from .errors import (BusyError, ConnectError, NoQuoteError, ConnectionLostError, CoreNotFoundError, InvalidRequestError, InvestorModeError,
12
+ LicenseError, NotConnectedError, ProxyError, RequestTimeout, RuntimeNotFoundError,
13
+ ServerError, TokenServiceError, WmtError)
14
+ from .frames import to_frame
15
+ from .models import (AccountInfo, AccountSummary, BaseInfo, CommissionInfo, CommissionTier, ConnectEvent, Deal,
16
+ DemoAccount, DemoAccountRequest, EquityPoint, History, LotSizing, Order, OrderProgress,
17
+ OrderUpdate, Quote, ServerRecord, SymbolInfo, SymbolSessions, TradingSession)
18
+ from .servers import load_servers_dat, request_demo_account, save_servers_dat, tick_value_from_deal
19
+
20
+ __version__ = "1.0.0"
21
+
22
+ __all__ = [
23
+ "Client", "Proxy",
24
+ "Quote", "SymbolInfo", "SymbolSessions", "TradingSession", "CommissionInfo", "CommissionTier", "BaseInfo",
25
+ "AccountInfo", "AccountSummary", "EquityPoint", "LotSizing", "Order", "Deal", "History",
26
+ "OrderUpdate", "OrderProgress", "ConnectEvent", "QuoteHistoryEvent",
27
+ "ServerRecord", "DemoAccountRequest", "DemoAccount",
28
+ "Bar", "TIMEFRAMES", "aggregate", "to_frame",
29
+ "load_servers_dat", "save_servers_dat", "request_demo_account", "tick_value_from_deal", "enable_core_logging",
30
+ "WmtError", "LicenseError", "TokenServiceError", "ConnectError", "ConnectionLostError", "NotConnectedError",
31
+ "ServerError", "InvestorModeError", "ProxyError", "RequestTimeout", "NoQuoteError", "InvalidRequestError", "BusyError",
32
+ "CoreNotFoundError",
33
+ "RuntimeNotFoundError", "COVERAGE", "TYPES", "__version__",
34
+ ]
pmt5api/__main__.py ADDED
@@ -0,0 +1,78 @@
1
+ """``python -m pmt5api`` — what is installed, and whether it works on this machine.
2
+
3
+ python -m pmt5api offline: versions, the core, the .NET runtime, this platform
4
+ python -m pmt5api --connect also connect read-only using PMT5_LOGIN, PMT5_PASSWORD, PMT5_HOST[, PMT5_PORT]
5
+ and WMTAPI_KEY, and print the account's currency, leverage and symbol count
6
+
7
+ The output contains no secrets; it is what to send when asking for help.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import os
13
+ import platform
14
+ import sys
15
+ from typing import List, Optional
16
+
17
+ from . import __version__, _runtime
18
+ from .errors import WmtError
19
+
20
+
21
+ def _key_hint() -> str:
22
+ key = os.environ.get("WMTAPI_KEY", "")
23
+ if not key:
24
+ return "not set (WMTAPI_KEY)"
25
+ return "set (%s…, %d characters)" % (key[:3], len(key))
26
+
27
+
28
+ def main(argv: Optional[List[str]] = None) -> int:
29
+ ap = argparse.ArgumentParser(prog="python -m pmt5api", description=__doc__.split("\n\n")[0])
30
+ ap.add_argument("--connect", action="store_true", help="also connect read-only and read the account")
31
+ args = ap.parse_args(argv)
32
+ ok = True
33
+
34
+ print("pmt5api %s" % __version__)
35
+ print("python %s on %s" % (sys.version.split()[0], platform.platform()))
36
+ try:
37
+ _runtime.load()
38
+ except WmtError as e:
39
+ print("core NOT LOADED: %s: %s" % (type(e).__name__, e))
40
+ return 1
41
+ print("core %s" % _runtime.loaded_dll())
42
+ root = _runtime.runtime_root()
43
+ bundled = root is not None and "_dotnet" in str(root)
44
+ print("runtime %s%s" % (root, " (bundled in the wheel)" if bundled else ""))
45
+ print("icu %s; invariant globalization %s" % (
46
+ "present" if _runtime._icu_present() else "absent",
47
+ os.environ.get("DOTNET_SYSTEM_GLOBALIZATION_INVARIANT", "off")))
48
+ print("key %s" % _key_hint())
49
+ docs = _runtime.loaded_dll().parent / "docs" if _runtime.loaded_dll() is not None else None
50
+ if docs is not None and docs.is_dir():
51
+ print("docs %s (LICENSING.md, SECURITY.md, ERRORS.md)" % docs)
52
+
53
+ from .client import Client
54
+ c = Client(1, "x", "127.0.0.1", 1)
55
+ print("offline construct client: ok (core version %s)" % c.core_version)
56
+
57
+ if args.connect:
58
+ try:
59
+ login, password, host = (int(os.environ["PMT5_LOGIN"]), os.environ["PMT5_PASSWORD"],
60
+ os.environ["PMT5_HOST"])
61
+ except KeyError as e:
62
+ print("connect SKIPPED: environment variable %s is not set" % e)
63
+ return 0
64
+ port = int(os.environ.get("PMT5_PORT", "443"))
65
+ try:
66
+ with Client(login, password, host, port, connect_timeout=45) as live:
67
+ a = live.account()
68
+ print("connect ok: %s, %s, leverage 1:%d, %d symbols, mode %s, trading %s"
69
+ % (live.company, a.currency, a.leverage, len(live.symbols()), live.account_method,
70
+ "allowed" if live.can_trade else "not allowed (investor or disabled)"))
71
+ except WmtError as e:
72
+ ok = False
73
+ print("connect FAILED: %s: %s" % (type(e).__name__, e))
74
+ return 0 if ok else 1
75
+
76
+
77
+ if __name__ == "__main__":
78
+ sys.exit(main())
pmt5api/_account.py ADDED
@@ -0,0 +1,146 @@
1
+ """The account: balances, positions, orders, history, and the curves derived from them."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime, timedelta, timezone
5
+ from typing import List, Optional
6
+
7
+ from ._base import _Base
8
+ from ._util import enum_value, to_core_time, translated
9
+ from .errors import NotConnectedError
10
+ from .models import AccountInfo, AccountSummary, EquityPoint, History, Order
11
+
12
+
13
+ class _AccountMixin(_Base):
14
+ @translated
15
+ def account(self, refresh: bool = True) -> AccountInfo:
16
+ """Account details. With ``refresh`` (the default) the equity, profit and margin figures are brought up to date
17
+ first: every open position's symbol is quoted once and the profits are recalculated."""
18
+ self._require_connected()
19
+ api = self._api
20
+ if refresh:
21
+ symbols = {o.Symbol for o in api.GetOpenedOrders() if o.Symbol}
22
+ for s in symbols:
23
+ if not api.Connected: # never let a refresh trigger a connect of its own
24
+ raise NotConnectedError("the connection was lost while refreshing the account")
25
+ try:
26
+ api.GetQuote(s, 3000)
27
+ except Exception: # noqa: BLE001 — a quiet symbol must not block the account snapshot
28
+ pass
29
+ if not api.Connected:
30
+ raise NotConnectedError("the connection was lost while refreshing the account")
31
+ api.UpdateProfits()
32
+ a = api.Account
33
+ return AccountInfo(
34
+ login=int(a.Login), name=str(a.UserName or ""), currency=str(api.AccountCurrency or ""),
35
+ leverage=int(a.Leverage), balance=float(a.Balance), credit=float(a.Credit),
36
+ equity=float(api.AccountEquity), profit=float(api.AccountProfit), margin=float(api.AccountMargin),
37
+ free_margin=float(api.AccountFreeMargin), margin_level=float(api.AccountMarginLevel),
38
+ floating_pl=float(api.AccountFloatingProfit), swap=float(api.AccountSwap),
39
+ commission=float(api.AccountCommission), fee=float(api.AccountFee), blocked=float(a.Blocked),
40
+ company=str(api.AccountCompanyName or ""), method=str(api.AccountMethod), account_type=str(a.Type or ""),
41
+ trade_flags=int(a.TradeFlags), country=str(a.Country or ""), city=str(a.City or ""),
42
+ state=str(a.State or ""), zip_code=str(a.ZipCode or ""), address=str(a.UserAddress or ""),
43
+ phone=str(a.Phone or ""), email=str(a.Email or ""),
44
+ )
45
+
46
+ @translated
47
+ def update_profits(self) -> None:
48
+ """Recompute floating P/L and used margin from the current quotes. Warm the open positions' quotes first (or
49
+ use ``account(refresh=True)``, which does both)."""
50
+ self._require_connected()
51
+ self._api.UpdateProfits()
52
+
53
+ # ---- orders and positions ----------------------------------------------------------------------------------
54
+
55
+ @translated
56
+ def orders(self) -> List[Order]:
57
+ """Everything currently working: open positions and pending orders."""
58
+ self._require_connected()
59
+ return [Order._from_core(o) for o in self._api.GetOpenedOrders()]
60
+
61
+ def positions(self) -> List[Order]:
62
+ """Open positions only (pending orders excluded).
63
+
64
+ A position is an order of type ``Buy`` or ``Sell``; pending orders are the ``*Limit`` and ``*Stop`` types. The
65
+ order's ``state`` is not used: the core can leave a freshly opened position in ``Started`` for a long time.
66
+ """
67
+ return [o for o in self.orders() if o.order_type in ("Buy", "Sell")]
68
+
69
+ @translated
70
+ def closed_orders(self) -> List[Order]:
71
+ """The orders this session has seen close (a rolling cache of about ten). For anything older use
72
+ :meth:`history`."""
73
+ self._require_connected()
74
+ return [Order._from_core(o) for o in self._api.ClosedOrders()]
75
+
76
+ # ---- history -----------------------------------------------------------------------------------------------
77
+
78
+ @translated
79
+ def history(self, start: Optional[datetime] = None, end: Optional[datetime] = None,
80
+ timeout: Optional[float] = None) -> History:
81
+ """Orders and deals between ``start`` and ``end`` (default: the last 30 days up to now). Blocks until the
82
+ server has sent everything, up to ``timeout`` seconds (default: the client's ``history_timeout``).
83
+
84
+ Naive datetimes are used exactly as given, in the broker's server time; timezone-aware ones are converted to
85
+ it. The default window is computed on the server's clock.
86
+ """
87
+ self._require_connected()
88
+ now = self.server_time or datetime.now(timezone.utc).replace(tzinfo=None)
89
+ end = self._server_naive(end)
90
+ start = self._server_naive(start)
91
+ if end is None:
92
+ end = now + timedelta(days=1) # nothing the server has booked is later than this
93
+ if start is None:
94
+ start = now - timedelta(days=30)
95
+ ms = int(timeout * 1000) if timeout is not None else int(self._api.DownloadOrderHistoryTimeoutMs)
96
+ return History._from_core(self._api.DownloadOrderHistory(to_core_time(start), to_core_time(end), ms))
97
+
98
+ @translated
99
+ def deal_history(self, year: int, month: int) -> History:
100
+ """One calendar month of history (a convenience over :meth:`history`)."""
101
+ self._require_connected()
102
+ return History._from_core(self._api.RequestDealHistory(int(year), int(month)))
103
+
104
+ @translated
105
+ def request_history(self, start: datetime, end: datetime) -> None:
106
+ """Ask for history without waiting; it arrives through :meth:`on_history`. The server's answer is not trimmed
107
+ to ``start`` / ``end`` (it can hold more than you asked for, in pages): filter by time. The blocking
108
+ :meth:`history` does trim."""
109
+ self._require_connected()
110
+ self._api.RequestOrderHistory(to_core_time(self._server_naive(start)), to_core_time(self._server_naive(end)))
111
+
112
+ @translated
113
+ def request_history_month(self, year: int, month: int) -> None:
114
+ """Ask for one calendar month of history without waiting; it arrives through :meth:`on_history`."""
115
+ self._require_connected()
116
+ self._api.RequestOrderHistory(int(year), int(month))
117
+
118
+ # ---- realized figures and curves ---------------------------------------------------------------------------
119
+
120
+ @translated
121
+ def summary(self, start: Optional[datetime] = None, end: Optional[datetime] = None) -> AccountSummary:
122
+ """Realized trading result, deposits and withdrawals, from the deal ledger (all time by default).
123
+
124
+ Downloads the deal history, so it blocks; call it off your UI thread (or use ``asyncio.to_thread``).
125
+ """
126
+ self._require_connected()
127
+ if start is None and end is None:
128
+ return AccountSummary._from_core(self._api.GetAccountSummary())
129
+ if start is None or end is None:
130
+ raise ValueError("give both start and end, or neither")
131
+ return AccountSummary._from_core(self._api.GetAccountSummary(to_core_time(self._server_naive(start)),
132
+ to_core_time(self._server_naive(end))))
133
+
134
+ @translated
135
+ def equity_history(self, start: datetime, timeframe: str = "D1", exclude_same_bars: bool = True) -> List[EquityPoint]:
136
+ """The balance and equity curve since ``start`` (``timeframe`` is ``H1``, ``H4`` or ``D1``).
137
+
138
+ ``balance`` is exact; the historical ``equity`` is a bar-priced reconstruction and a point with
139
+ ``incomplete`` set has understated floating P/L. With ``exclude_same_bars`` (the default, as in the C# library)
140
+ consecutive points with an unchanged balance and equity are dropped. Downloads deals and per-symbol bars, so it
141
+ blocks.
142
+ """
143
+ self._require_connected()
144
+ tf = enum_value(self._core.EquityTimeframe, timeframe, "equity timeframe")
145
+ points = self._api.CalculateEquityHistory(to_core_time(self._server_naive(start)), tf, bool(exclude_same_bars))
146
+ return [EquityPoint._from_core(p) for p in (points or ())]
pmt5api/_base.py ADDED
@@ -0,0 +1,24 @@
1
+ """What every client mixin may rely on: the core object, the core's namespace, and the connection guard."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime, timedelta, timezone
5
+ from typing import Any, Optional
6
+
7
+ from .errors import NotConnectedError
8
+
9
+
10
+ class _Base:
11
+ _api: Any # the core's client object (MT5API / MT4API)
12
+ _core: Any # the core's namespace (types and enums)
13
+
14
+ def _require_connected(self) -> None:
15
+ if not self._api.Connected:
16
+ raise NotConnectedError("not connected; call connect() first")
17
+
18
+ def _server_naive(self, value: Optional[datetime]) -> Optional[datetime]:
19
+ """A datetime as the naive server time the core works in. A naive value is taken to be server time already
20
+ (as documented); a timezone-aware one is converted: to UTC, then by the server's offset from UTC."""
21
+ if value is None or value.tzinfo is None:
22
+ return value
23
+ offset = getattr(self, "server_utc_offset", None) or timedelta(0)
24
+ return value.astimezone(timezone.utc).replace(tzinfo=None) + offset
pmt5api/_coverage.py ADDED
@@ -0,0 +1,216 @@
1
+ """Where every public member and type of the C# SDK went.
2
+
3
+ ``COVERAGE`` maps each public member name of ``MT5API`` to the Python name that provides it:
4
+
5
+ * ``"Client.<attr>"`` a method, property or constructor argument of :class:`pmt5api.Client`
6
+ * ``"Client(<argument>)"`` an argument of the constructor
7
+ * ``"pmt5api.<name>"`` a module-level function
8
+ * ``"not applicable: …"`` deliberately has no Python counterpart, with the reason
9
+
10
+ ``TYPES`` does the same for the public types. The integration test ``test_coverage`` reflects the DLL it is pointed at
11
+ and fails when a public member or type has no entry here, or when an entry names a Python attribute that does not
12
+ exist. A new SDK release therefore cannot add API without a decision being recorded.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from typing import Dict
17
+
18
+ COVERAGE: Dict[str, str] = {
19
+ # ---- lifecycle and session ----
20
+ "Connect": "Client.connect",
21
+ "Disconnect": "Client.disconnect",
22
+ "Connected": "Client.connected",
23
+ "ConnectTime": "Client.connect_time",
24
+ "LastQuoteTime": "Client.last_quote_time",
25
+ "LastServerMessageTime": "Client.last_server_message_time",
26
+ "ServerTime": "Client.server_time",
27
+ "ServerTimeZoneInMinutes": "Client.server_utc_offset",
28
+ "ServerTradeBuild": "Client.trade_build",
29
+ "ServerSymBuild": "Client.symbol_build",
30
+ "AccountCompanyName": "Client.company",
31
+ "AccountMethod": "Client.account_method",
32
+ "IsInvestor": "Client.is_investor",
33
+ "IsTradeAllowed": "Client.can_trade",
34
+ "IsTradeDisableOnServer": "Client.trading_disabled_by_server",
35
+ "IsNotConfirmedAccount": "Client.is_confirmed",
36
+ "User": "Client.login",
37
+ "Password": "Client(password)",
38
+ "Host": "Client.host",
39
+ "Server": "Client.host",
40
+ "Port": "Client.port",
41
+ "Id": "not applicable: a free-form tag for the caller's own use; keep it in your own code",
42
+ "ProcessServerMessagesInThread": "not applicable: documented by the core as reserved and unused",
43
+ "AuthTagsSeen": "not applicable: protocol diagnostics for the ops sentinel, of no use to an application",
44
+ "AuthUnknownTags": "not applicable: protocol diagnostics for the ops sentinel, of no use to an application",
45
+ "ChangePassword": "Client.change_password",
46
+ # ---- configuration ----
47
+ "ApiKey": "Client(api_key)",
48
+ "TokenServiceUrl": "Client(token_url)",
49
+ "TokenServiceTimeout": "Client(token_timeout)",
50
+ "ConnectTimeout": "Client(connect_timeout)",
51
+ "ClusterMemberConnectTimeout": "Client(cluster_member_timeout)",
52
+ "ExecutionTimeout": "Client(execution_timeout)",
53
+ "GetQuoteTimeoutMs": "Client(quote_timeout)",
54
+ "DownloadOrderHistoryTimeoutMs": "Client(history_timeout)",
55
+ "DownloadQuoteHistoryTimeout": "Client(bars_timeout)",
56
+ "PingTime": "Client(ping_interval)",
57
+ "NoServerMessagesTimeout": "Client(no_message_timeout)",
58
+ "ProcessEventTimeoutMs": "not applicable: the core accepts the value but never applies it (events run on the thread pool); "
59
+ "pmt5api delivers events through one queued dispatcher instead — see Client.events_dropped",
60
+ "AutoReconnect": "Client(auto_reconnect)",
61
+ "ReconnectBackoffMs": "Client(reconnect_backoff)",
62
+ "ReconnectMaxBackoffMs": "Client(reconnect_max_backoff)",
63
+ "ReconnectMaxAttempts": "Client(reconnect_max_attempts)",
64
+ "ProxyEnable": "Client(proxy)",
65
+ "ProxyType": "Client(proxy)",
66
+ "ProxyHost": "Client(proxy)",
67
+ "ProxyPort": "Client(proxy)",
68
+ "ProxyUser": "Client(proxy)",
69
+ "ProxyPassword": "Client(proxy)",
70
+ "PfxFile": "Client(certificate)",
71
+ "PfxFilePassword": "Client(certificate_password)",
72
+ "CommonKey": "Client(common_key)",
73
+ "PlacedType": "Client(placed_type)",
74
+ "EnableAllOrderUpdates": "Client(all_order_updates)",
75
+ # ---- symbols and market data ----
76
+ "Symbols": "Client.symbols",
77
+ "Subscribe": "Client.subscribe",
78
+ "Unsubscribe": "Client.unsubscribe",
79
+ "Subscriptions": "Client.subscriptions",
80
+ "IsSubscribed": "Client.is_subscribed",
81
+ "IsQuoteSession": "Client.is_quote_session",
82
+ "IsTradeSession": "Client.is_trade_session",
83
+ "GetQuote": "Client.quote",
84
+ "GetQuoteTask": "Client.aquote",
85
+ "GetTickValue": "Client.tick_value",
86
+ "GetAskTickValue": "Client.tick_value",
87
+ "GetBidTickValue": "Client.tick_value",
88
+ "GetContractSize": "Client.contract_size",
89
+ "LedgerValuePerUnitLot": "Client.ledger_value_per_lot",
90
+ "DownloadQuoteHistoryToday": "Client.bars",
91
+ "DownloadQuoteHistoryMonth": "Client.bars",
92
+ "RequestQuoteHistoryToday": "Client.request_bars",
93
+ "RequestQuoteHistoryMonth": "Client.request_bars",
94
+ "ConvertToTimeframe": "pmt5api.aggregate",
95
+ # ---- account ----
96
+ "Account": "Client.account",
97
+ "AccountCurrency": "Client.account",
98
+ "AccountEquity": "Client.account",
99
+ "AccountProfit": "Client.account",
100
+ "AccountFloatingProfit": "Client.account",
101
+ "AccountSwap": "Client.account",
102
+ "AccountCommission": "Client.account",
103
+ "AccountFee": "Client.account",
104
+ "AccountMargin": "Client.account",
105
+ "AccountFreeMargin": "Client.account",
106
+ "AccountMarginLevel": "Client.account",
107
+ "UpdateProfits": "Client.update_profits",
108
+ "GetOpenedOrders": "Client.orders",
109
+ "ClosedOrders": "Client.closed_orders",
110
+ "DownloadOrderHistory": "Client.history",
111
+ "RequestDealHistory": "Client.deal_history",
112
+ "RequestOrderHistory": "Client.request_history",
113
+ "GetAccountSummary": "Client.summary",
114
+ "CalculateEquityHistory": "Client.equity_history",
115
+ # ---- sizing and risk ----
116
+ "CalibrateMoneyScale": "Client.calibrate_money_scale",
117
+ "MoneyScale": "Client.money_scale",
118
+ "MoneyScaleEvidence": "Client.money_scale_evidence",
119
+ "MoneyScaleFromDeal": "Client.money_scale_from_deal",
120
+ "CalculateLots": "Client.lots_for_risk",
121
+ "RequiredMargin": "Client.required_margin",
122
+ "CalculateOrderProfit": "Client.order_profit",
123
+ "ComputeTickValueFromDeal": "pmt5api.tick_value_from_deal",
124
+ # ---- trading ----
125
+ "OrderSend": "Client.buy",
126
+ "OrderModify": "Client.modify",
127
+ "OrderClose": "Client.close",
128
+ "OrderCancel": "Client.cancel",
129
+ "OrderSendAsync": "Client.buy_async",
130
+ "OrderModifyAsync": "Client.modify_async",
131
+ "OrderCloseAsync": "Client.close_async",
132
+ "OrderCancelAsync": "Client.cancel_async",
133
+ "OrderSendAsyncTask": "Client.abuy",
134
+ "OrderModifyAsyncTask": "Client.amodify",
135
+ "OrderCloseAsyncTask": "Client.aclose",
136
+ "OrderCancelAsyncTask": "Client.acancel",
137
+ "GetRequestId": "Client.new_request_id",
138
+ # ---- events ----
139
+ "OnQuote": "Client.on_quote",
140
+ "OnOrderUpdate": "Client.on_order_update",
141
+ "OnOrderProgress": "Client.on_order_progress",
142
+ "OnConnectProgress": "Client.on_connect_progress",
143
+ "OnOrderHistory": "Client.on_history",
144
+ "OnQuoteHistory": "Client.on_quote_history",
145
+ "OnSymbolsUpdate": "Client.on_symbols_update",
146
+ "OnSymbolUpdate": "Client.on_symbol_update",
147
+ # ---- offline and administration ----
148
+ "LoadServersDat": "pmt5api.load_servers_dat",
149
+ "SaveServersDat": "pmt5api.save_servers_dat",
150
+ "RequestDemoAccount": "pmt5api.request_demo_account",
151
+ }
152
+
153
+ TYPES: Dict[str, str] = {
154
+ "MT5API": "pmt5api.Client",
155
+ "Account": "pmt5api.AccountInfo",
156
+ "AccountAnswer": "pmt5api.DemoAccount",
157
+ "AccountRequest": "pmt5api.DemoAccountRequest",
158
+ "AccountSummary": "pmt5api.AccountSummary",
159
+ "Bar": "pmt5api.Bar",
160
+ "Comission": "pmt5api.CommissionTier",
161
+ "ComissionInfo": "pmt5api.CommissionInfo",
162
+ "ConnectEventArgs": "pmt5api.ConnectEvent",
163
+ "ConnectException": "pmt5api.ConnectError",
164
+ "ConnectionException": "pmt5api.ConnectionLostError",
165
+ "Deal": "pmt5api.Deal",
166
+ "Diag": "pmt5api.enable_core_logging",
167
+ "EquityPoint": "pmt5api.EquityPoint",
168
+ "Expiration": "Client.place_order(expiration)",
169
+ "FromBufReader": "not applicable: a protocol buffer reader; it carries no application meaning",
170
+ "InvestorModeException": "pmt5api.InvestorModeError",
171
+ "LicenseException": "pmt5api.LicenseError",
172
+ "Logger": "pmt5api.enable_core_logging",
173
+ "Logger+OnMsgHandler": "pmt5api.enable_core_logging",
174
+ "Logger+OnMsgHandlerEx": "pmt5api.enable_core_logging",
175
+ "LotSizing": "pmt5api.LotSizing",
176
+ "OnConnectProgress": "Client.on_connect_progress",
177
+ "OnOrderHistory": "Client.on_history",
178
+ "OnOrderProgress": "Client.on_order_progress",
179
+ "OnOrderUpdate": "Client.on_order_update",
180
+ "OnQuote": "Client.on_quote",
181
+ "OnQuoteHistory": "Client.on_quote_history",
182
+ "OnSymbolUpdate": "Client.on_symbol_update",
183
+ "OnSymbolsUpdate": "Client.on_symbols_update",
184
+ "Order": "pmt5api.Order",
185
+ "OrderHistoryEventArgs": "pmt5api.History",
186
+ "OrderProgress": "pmt5api.OrderProgress",
187
+ "OrderProgressEventArgs": "not applicable: superseded by OrderProgress, which carries the request, the result and the update",
188
+ "OrderUpdate": "pmt5api.OrderUpdate",
189
+ "ProxyException": "pmt5api.ProxyError",
190
+ "ProxySocket": "not applicable: the transport the core uses for a proxy; configure it with Client(proxy=Proxy(...))",
191
+ "Quote": "pmt5api.Quote",
192
+ "QuoteHistoryEventArgs": "pmt5api.QuoteHistoryEvent",
193
+ "ServerException": "pmt5api.ServerError",
194
+ "ServerRecord": "pmt5api.ServerRecord",
195
+ "Session": "pmt5api.TradingSession",
196
+ "SymBaseInfo": "pmt5api.BaseInfo",
197
+ "SymGroup": "pmt5api.SymbolInfo",
198
+ "SymbolInfo": "pmt5api.SymbolInfo",
199
+ "SymbolSessions": "pmt5api.SymbolSessions",
200
+ "SymbolUpdate": "Client.on_symbol_update",
201
+ "Symbols": "Client.symbols",
202
+ "TokenServiceException": "pmt5api.TokenServiceError",
203
+ "TradeRequest": "pmt5api.OrderProgress",
204
+ "TradeResult": "pmt5api.OrderProgress",
205
+ "TransactionInfo": "pmt5api.OrderUpdate",
206
+ # enums are exposed as their member names (strings)
207
+ "AccMethod": "enum: str", "CalculationMode": "enum: str", "CommissionPeriod": "enum: str",
208
+ "CommissionType": "enum: str", "ConnectProgress": "enum: str", "DealType": "enum: str", "Direction": "enum: str",
209
+ "EquityTimeframe": "enum: str", "ExecutionType": "enum: str", "ExpirationType": "enum: str",
210
+ "FillPolicy": "enum: str", "GTCMode": "enum: str", "InstantCommissionType": "enum: str",
211
+ "LicenseReason": "enum: str", "Logger+MsgType": "enum: str", "MarginMode": "enum: str", "Msg": "enum: str",
212
+ "OrderState": "enum: str", "OrderType": "enum: str", "PlacedType": "enum: str", "ProgressType": "enum: str",
213
+ "ProxyTypes": "enum: str", "SwapType": "enum: str", "TradeMode": "enum: str", "TradeType": "enum: str",
214
+ "UpdateType": "enum: str", "V3DaysSwap": "enum: str", "vCommissionUnitMode": "enum: str",
215
+ "vCommissionUnitType": "enum: str",
216
+ }
pmt5api/_events.py ADDED
@@ -0,0 +1,147 @@
1
+ """Events: typed callbacks, and sync / async iterators over them.
2
+
3
+ Callbacks are delivered by the per-client dispatcher in :mod:`._streams`: each event is snapshotted on the core's
4
+ thread and called back one at a time, in arrival order, on one thread; ``Client.events_dropped`` counts events lost
5
+ to a callback that fell too far behind.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import Any, AsyncIterator, Callable, Iterator, Optional
11
+
12
+ from ._base import _Base
13
+ from ._streams import _AsyncQueueIterator, _Dispatcher, _QueueIterator
14
+ from .bars import QuoteHistoryEvent
15
+ from .errors import translate
16
+ from .models import ConnectEvent, History, OrderProgress, OrderUpdate, Quote
17
+
18
+ log = logging.getLogger("pmt5api")
19
+
20
+
21
+ class _EventsMixin(_Base):
22
+ def _add_handler(self, event: str, handler: Any) -> None:
23
+ ev = getattr(self._api, event)
24
+ ev += handler
25
+ setattr(self._api, event, ev)
26
+
27
+ def _remove_handler(self, event: str, handler: Any) -> None:
28
+ ev = getattr(self._api, event)
29
+ ev -= handler
30
+ setattr(self._api, event, ev)
31
+
32
+ def _dispatcher(self) -> _Dispatcher:
33
+ with self._lock:
34
+ d = getattr(self, "_dispatch", None)
35
+ if d is None:
36
+ d = self._dispatch = _Dispatcher()
37
+ return d
38
+
39
+ @property
40
+ def events_dropped(self) -> int:
41
+ """How many events were dropped because callbacks fell too far behind (see the module notes)."""
42
+ d = getattr(self, "_dispatch", None)
43
+ return d.dropped if d is not None else 0
44
+
45
+ def _watch(self, event: str, convert: Optional[Callable[[Any], Any]],
46
+ callback: Callable[..., None]) -> Callable[[], None]:
47
+ """Subscribe ``callback`` to a core event; returns the function that removes it."""
48
+ dispatcher = self._dispatcher()
49
+ active = {"on": True}
50
+
51
+ def deliver(value: Any) -> None:
52
+ if not active["on"]:
53
+ return
54
+ if convert is None:
55
+ callback()
56
+ else:
57
+ callback(value)
58
+
59
+ def handler(*raw: Any) -> None:
60
+ try:
61
+ value = convert(raw[1]) if convert is not None else None # snapshot now, on the core's thread
62
+ except Exception: # noqa: BLE001
63
+ log.exception("%s: could not convert the event", event)
64
+ return
65
+ dispatcher.submit(deliver, value, event)
66
+
67
+ with self._lock:
68
+ self._add_handler(event, handler)
69
+ self._handlers.append(handler)
70
+
71
+ def remove() -> None:
72
+ active["on"] = False
73
+ with self._lock:
74
+ if handler in self._handlers:
75
+ self._handlers.remove(handler)
76
+ self._remove_handler(event, handler)
77
+ return remove
78
+
79
+ # ---- callbacks ---------------------------------------------------------------------------------------------
80
+
81
+ def on_quote(self, callback: Callable[[Quote], None]) -> Callable[[], None]:
82
+ """Call ``callback(quote)`` for every tick of every subscribed symbol. Returns a function that removes it."""
83
+ return self._watch("OnQuote", Quote._from_core, callback)
84
+
85
+ def on_order_update(self, callback: Callable[[OrderUpdate], None]) -> Callable[[], None]:
86
+ """Call ``callback(update)`` when the server pushes a change to an order or position (opened, filled,
87
+ modified, closed, stopped out ...). Set ``all_order_updates=True`` on the client to also receive the
88
+ intermediate Started / Filled / Cancelling states."""
89
+ return self._watch("OnOrderUpdate", OrderUpdate._from_core, callback)
90
+
91
+ def on_order_progress(self, callback: Callable[[OrderProgress], None]) -> Callable[[], None]:
92
+ """Call ``callback(progress)`` as an asynchronous trade request advances (see :meth:`buy_async`)."""
93
+ return self._watch("OnOrderProgress", OrderProgress._from_core, callback)
94
+
95
+ def on_connect_progress(self, callback: Callable[[ConnectEvent], None]) -> Callable[[], None]:
96
+ """Call ``callback(event)`` for each connect step and for a drop. This is how to tell a permanent failure from
97
+ a transient one while ``auto_reconnect`` is on."""
98
+ def convert(args: Any) -> ConnectEvent:
99
+ err = args.Exception
100
+ mapped = translate(err) if err is not None else None
101
+ return ConnectEvent(stage=str(args.Progress), error=mapped if mapped is not None else err)
102
+ return self._watch("OnConnectProgress", convert, callback)
103
+
104
+ def on_history(self, callback: Callable[[History], None]) -> Callable[[], None]:
105
+ """Call ``callback(history)`` when history requested with :meth:`request_history` arrives. The server may
106
+ deliver it in pages (``history.more`` says another page follows) and does not trim it to the range you asked
107
+ for: filter by time."""
108
+ return self._watch("OnOrderHistory", History._from_core, callback)
109
+
110
+ def on_quote_history(self, callback: Callable[[QuoteHistoryEvent], None]) -> Callable[[], None]:
111
+ """Call ``callback(event)`` when bars requested with :meth:`request_bars` arrive."""
112
+ return self._watch("OnQuoteHistory", QuoteHistoryEvent._from_core, callback)
113
+
114
+ def on_symbols_update(self, callback: Callable[[], None]) -> Callable[[], None]:
115
+ """Call ``callback()`` when the server pushes a change to the symbol list; read :meth:`symbols` again."""
116
+ return self._watch("OnSymbolsUpdate", None, callback)
117
+
118
+ def on_symbol_update(self, callback: Callable[[], None]) -> Callable[[], None]:
119
+ """Call ``callback()`` when one symbol's data is (re)received; read :meth:`symbols` again."""
120
+ return self._watch("OnSymbolUpdate", None, callback)
121
+
122
+ # ---- iterators ---------------------------------------------------------------------------------------------
123
+
124
+ def quotes(self, *symbols: str, timeout: Optional[float] = None, maxsize: int = 10000) -> Iterator[Quote]:
125
+ """Iterate live quotes of ``symbols`` (subscribing them first; with none given, every subscribed symbol).
126
+ Ends when ``timeout`` seconds pass without a tick. The feed is attached when this is called, not at the first
127
+ ``next()``. If the consumer falls behind by more than ``maxsize`` quotes the oldest are dropped."""
128
+ wanted = {s for g in symbols for s in ([g] if isinstance(g, str) else g)}
129
+ pred = (lambda q: q.symbol in wanted) if wanted else None
130
+ return _QueueIterator(self.on_quote, timeout, maxsize, pred,
131
+ (lambda: self.subscribe(*sorted(wanted))) if wanted else None) # type: ignore[return-value]
132
+
133
+ def aquotes(self, *symbols: str, maxsize: int = 10000) -> AsyncIterator[Quote]:
134
+ """Async version of :meth:`quotes` (no timeout; ``aclose()`` or cancel the task to stop). Call it inside a
135
+ running event loop."""
136
+ wanted = {s for g in symbols for s in ([g] if isinstance(g, str) else g)}
137
+ pred = (lambda q: q.symbol in wanted) if wanted else None
138
+ return _AsyncQueueIterator(self.on_quote, maxsize, pred,
139
+ (lambda: self.subscribe(*sorted(wanted))) if wanted else None) # type: ignore[return-value]
140
+
141
+ def order_updates(self, *, timeout: Optional[float] = None, maxsize: int = 1000) -> Iterator[OrderUpdate]:
142
+ """Iterate order updates (attached when called). Ends when ``timeout`` seconds pass without one."""
143
+ return _QueueIterator(self.on_order_update, timeout, maxsize) # type: ignore[return-value]
144
+
145
+ def aorder_updates(self, *, maxsize: int = 1000) -> AsyncIterator[OrderUpdate]:
146
+ """Async version of :meth:`order_updates`."""
147
+ return _AsyncQueueIterator(self.on_order_update, maxsize) # type: ignore[return-value]