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 +34 -0
- pmt5api/__main__.py +78 -0
- pmt5api/_account.py +146 -0
- pmt5api/_base.py +24 -0
- pmt5api/_coverage.py +216 -0
- pmt5api/_events.py +147 -0
- pmt5api/_market.py +296 -0
- pmt5api/_native/CHANGELOG.md +284 -0
- pmt5api/_native/THIRD-PARTY-NOTICES.txt +92 -0
- pmt5api/_native/docs/ERRORS.md +143 -0
- pmt5api/_native/docs/LICENSING.md +67 -0
- pmt5api/_native/docs/SECURITY.md +50 -0
- pmt5api/_native/wmt5api.dll +0 -0
- pmt5api/_risk.py +116 -0
- pmt5api/_runtime.py +154 -0
- pmt5api/_session.py +175 -0
- pmt5api/_streams.py +238 -0
- pmt5api/_trading.py +367 -0
- pmt5api/_util.py +78 -0
- pmt5api/bars.py +98 -0
- pmt5api/client.py +129 -0
- pmt5api/diagnostics.py +38 -0
- pmt5api/docs/API.md +717 -0
- pmt5api/docs/FROM-CSHARP.md +131 -0
- pmt5api/errors.py +150 -0
- pmt5api/frames.py +21 -0
- pmt5api/models.py +522 -0
- pmt5api/py.typed +0 -0
- pmt5api/servers.py +73 -0
- pmt5api-1.0.0.dist-info/METADATA +339 -0
- pmt5api-1.0.0.dist-info/RECORD +33 -0
- pmt5api-1.0.0.dist-info/WHEEL +4 -0
- pmt5api-1.0.0.dist-info/licenses/LICENSE.txt +29 -0
pmt5api/_session.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Connection lifecycle and session information."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
from ._base import _Base
|
|
9
|
+
from ._util import translated
|
|
10
|
+
from .models import BaseInfo, _time
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _SessionMixin(_Base):
|
|
14
|
+
# ---- lifecycle ---------------------------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
@translated
|
|
17
|
+
def connect(self) -> "_SessionMixin":
|
|
18
|
+
"""Connect, authorise through the licence service and load the account. Returns ``self``. Calling it on a
|
|
19
|
+
client that is already connected does nothing (it never opens a second session)."""
|
|
20
|
+
if not self._api.Connected:
|
|
21
|
+
self._api.Connect()
|
|
22
|
+
return self
|
|
23
|
+
|
|
24
|
+
@translated
|
|
25
|
+
def disconnect(self) -> None:
|
|
26
|
+
"""Close the session and stop the core's reconnect watchdog for good — also when the link is down and the
|
|
27
|
+
watchdog is mid-way through reconnecting. Harmless when never connected."""
|
|
28
|
+
try:
|
|
29
|
+
self._api.Disconnect()
|
|
30
|
+
except Exception as exc: # noqa: BLE001
|
|
31
|
+
if type(exc).__name__ != "NullReferenceException": # never connected: nothing to close
|
|
32
|
+
raise
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def connected(self) -> bool:
|
|
36
|
+
"""Live: false when the socket died or the server has been silent past ``no_message_timeout``."""
|
|
37
|
+
return bool(self._api.Connected)
|
|
38
|
+
|
|
39
|
+
def __enter__(self) -> "_SessionMixin":
|
|
40
|
+
return self.connect()
|
|
41
|
+
|
|
42
|
+
def __exit__(self, *exc: Any) -> None:
|
|
43
|
+
self.disconnect()
|
|
44
|
+
|
|
45
|
+
async def aconnect(self) -> "_SessionMixin":
|
|
46
|
+
"""Awaitable :meth:`connect`; the blocking connect runs on a worker thread."""
|
|
47
|
+
return await asyncio.get_running_loop().run_in_executor(None, self.connect)
|
|
48
|
+
|
|
49
|
+
async def adisconnect(self) -> None:
|
|
50
|
+
"""Awaitable :meth:`disconnect`."""
|
|
51
|
+
await asyncio.get_running_loop().run_in_executor(None, self.disconnect)
|
|
52
|
+
|
|
53
|
+
async def __aenter__(self) -> "_SessionMixin":
|
|
54
|
+
return await self.aconnect()
|
|
55
|
+
|
|
56
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
57
|
+
await self.adisconnect()
|
|
58
|
+
|
|
59
|
+
@translated
|
|
60
|
+
def change_password(self, new_password: str, investor: bool = False) -> None:
|
|
61
|
+
"""Change the account password; with ``investor=True`` the read-only investor password instead of the main
|
|
62
|
+
one. The new password applies from the next connection."""
|
|
63
|
+
self._require_connected()
|
|
64
|
+
self._api.ChangePassword(new_password, bool(investor))
|
|
65
|
+
|
|
66
|
+
# ---- information -------------------------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def login(self) -> int:
|
|
70
|
+
"""The account login number."""
|
|
71
|
+
return int(self._api.User)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def host(self) -> str:
|
|
75
|
+
"""The access server this client was created for (it may fail over to a cluster member)."""
|
|
76
|
+
return str(self._api.Host or self._host)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def port(self) -> int:
|
|
80
|
+
"""The access-server port."""
|
|
81
|
+
return int(self._api.Port)
|
|
82
|
+
|
|
83
|
+
@translated
|
|
84
|
+
def base_info(self) -> BaseInfo:
|
|
85
|
+
"""The server's own description of itself and of the account mode."""
|
|
86
|
+
self._require_connected()
|
|
87
|
+
return BaseInfo._from_core(self._api.Symbols.Base)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def raw(self) -> Any:
|
|
91
|
+
"""The underlying core object, for anything this layer does not wrap. Calls made on it raise the core's own
|
|
92
|
+
exceptions, not pmt5api's."""
|
|
93
|
+
return self._api
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def core_version(self) -> str:
|
|
97
|
+
"""The version of the licensed core library in use."""
|
|
98
|
+
return str(self._api.GetType().Assembly.GetName().Version)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def server_time(self) -> Optional[datetime]:
|
|
102
|
+
"""The server's clock, kept current by incoming quotes (server time, naive); ``None`` before the first
|
|
103
|
+
connect."""
|
|
104
|
+
try:
|
|
105
|
+
return _time(self._api.ServerTime)
|
|
106
|
+
except Exception as exc: # noqa: BLE001
|
|
107
|
+
if type(exc).__name__ == "NullReferenceException":
|
|
108
|
+
return None
|
|
109
|
+
raise
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def server_utc_offset(self) -> Optional[timedelta]:
|
|
113
|
+
"""The server's offset from UTC; ``None`` before the first connect."""
|
|
114
|
+
try:
|
|
115
|
+
return timedelta(minutes=int(self._api.ServerTimeZoneInMinutes))
|
|
116
|
+
except Exception as exc: # noqa: BLE001
|
|
117
|
+
if type(exc).__name__ == "NullReferenceException":
|
|
118
|
+
return None
|
|
119
|
+
raise
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def company(self) -> str:
|
|
123
|
+
"""The broker's company name, as the server reports it."""
|
|
124
|
+
return str(self._api.AccountCompanyName or "")
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def trade_build(self) -> int:
|
|
128
|
+
"""The server's trade build number."""
|
|
129
|
+
return int(self._api.ServerTradeBuild)
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def symbol_build(self) -> int:
|
|
133
|
+
"""The server's symbol-database build number."""
|
|
134
|
+
return int(self._api.ServerSymBuild)
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def account_method(self) -> str:
|
|
138
|
+
"""``"Hedging"`` or ``"Netting"``."""
|
|
139
|
+
return str(self._api.AccountMethod)
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def is_investor(self) -> bool:
|
|
143
|
+
"""The login is the read-only investor password."""
|
|
144
|
+
return bool(self._api.IsInvestor)
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def can_trade(self) -> bool:
|
|
148
|
+
"""This login may place trades: not investor, not disabled on the server, confirmed."""
|
|
149
|
+
return bool(self._api.IsTradeAllowed)
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def trading_disabled_by_server(self) -> bool:
|
|
153
|
+
"""Trading is disabled for this account on the server side."""
|
|
154
|
+
return bool(self._api.IsTradeDisableOnServer)
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def is_confirmed(self) -> bool:
|
|
158
|
+
"""False while the broker has not yet confirmed the account (trading is blocked until it is)."""
|
|
159
|
+
return not bool(self._api.IsNotConfirmedAccount)
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def connect_time(self) -> Optional[datetime]:
|
|
163
|
+
"""When this session connected, by the local clock."""
|
|
164
|
+
return _time(self._api.ConnectTime)
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def last_quote_time(self) -> Optional[datetime]:
|
|
168
|
+
"""The server timestamp of the last quote received (server time, not the local clock): compare it with
|
|
169
|
+
:attr:`server_time`, not with ``datetime.now()``."""
|
|
170
|
+
return _time(self._api.LastQuoteTime)
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def last_server_message_time(self) -> Optional[datetime]:
|
|
174
|
+
"""When the server last sent anything, by the local clock: a liveness signal."""
|
|
175
|
+
return _time(self._api.LastServerMessageTime)
|
pmt5api/_streams.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Delivery machinery shared by the event API: one dispatcher thread per client, and the sync / async iterators.
|
|
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
|
|
7
|
+
``EVENT_QUEUE`` events behind, the oldest events are dropped (a warning is logged, and ``Client.events_dropped``
|
|
8
|
+
counts them). An exception raised by a callback is logged (under the package's own logger name) and swallowed so it
|
|
9
|
+
cannot break the connection.
|
|
10
|
+
|
|
11
|
+
This file is identical in pmt5api and pmt4api (a repository test keeps them so).
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import logging
|
|
17
|
+
import queue
|
|
18
|
+
import threading
|
|
19
|
+
import weakref
|
|
20
|
+
from typing import Any, Callable, Optional, Tuple
|
|
21
|
+
|
|
22
|
+
_PACKAGE = __name__.split(".")[0]
|
|
23
|
+
log = logging.getLogger(_PACKAGE)
|
|
24
|
+
|
|
25
|
+
#: How many events may wait for the dispatcher before the oldest are dropped.
|
|
26
|
+
EVENT_QUEUE = 20000
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _Dispatcher:
|
|
30
|
+
"""One thread that runs a client's callbacks in arrival order."""
|
|
31
|
+
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
self._q: "queue.Queue[Tuple[Callable[[Any], None], Any, str]]" = queue.Queue(maxsize=EVENT_QUEUE)
|
|
34
|
+
self.dropped = 0
|
|
35
|
+
self._count_lock = threading.Lock()
|
|
36
|
+
self._thread = threading.Thread(target=self._run, name=_PACKAGE + "-events", daemon=True)
|
|
37
|
+
self._thread.start()
|
|
38
|
+
|
|
39
|
+
def submit(self, fn: Callable[[Any], None], arg: Any, event: str) -> None:
|
|
40
|
+
while True:
|
|
41
|
+
try:
|
|
42
|
+
self._q.put_nowait((fn, arg, event))
|
|
43
|
+
return
|
|
44
|
+
except queue.Full:
|
|
45
|
+
try:
|
|
46
|
+
self._q.get_nowait()
|
|
47
|
+
except queue.Empty:
|
|
48
|
+
continue
|
|
49
|
+
with self._count_lock:
|
|
50
|
+
self.dropped += 1
|
|
51
|
+
total = self.dropped
|
|
52
|
+
if total == 1 or total % 1000 == 0:
|
|
53
|
+
log.warning("event queue full: %d event(s) dropped so far; a callback is too slow", total)
|
|
54
|
+
|
|
55
|
+
def _run(self) -> None:
|
|
56
|
+
while True:
|
|
57
|
+
fn, arg, event = self._q.get()
|
|
58
|
+
try:
|
|
59
|
+
fn(arg)
|
|
60
|
+
except Exception: # noqa: BLE001 — keep the dispatcher alive
|
|
61
|
+
log.exception("%s callback raised", event)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_END = object() # put on a queue to wake a consumer that is blocked in it when the iterator is closed
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _QueueIterator:
|
|
68
|
+
"""An iterator over an event stream. It subscribes when it is created (not at the first ``next()``), so nothing
|
|
69
|
+
that happens after the call is missed, and unsubscribes when exhausted or closed."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, register: Callable[[Callable[[Any], None]], Callable[[], None]], timeout: Optional[float],
|
|
72
|
+
maxsize: int, predicate: Optional[Callable[[Any], bool]] = None,
|
|
73
|
+
setup: Optional[Callable[[], None]] = None) -> None:
|
|
74
|
+
self._q: "queue.Queue[Any]" = queue.Queue(maxsize=maxsize)
|
|
75
|
+
self._timeout = timeout
|
|
76
|
+
self._pred = predicate
|
|
77
|
+
self._closed = False
|
|
78
|
+
ref = weakref.ref(self)
|
|
79
|
+
|
|
80
|
+
def put(item: Any) -> None: # weakly bound: an abandoned iterator can be collected, and then detaches
|
|
81
|
+
it = ref()
|
|
82
|
+
if it is not None:
|
|
83
|
+
it._put(item)
|
|
84
|
+
|
|
85
|
+
self._remove = register(put)
|
|
86
|
+
if setup is not None:
|
|
87
|
+
try:
|
|
88
|
+
setup()
|
|
89
|
+
except BaseException:
|
|
90
|
+
self.close()
|
|
91
|
+
raise
|
|
92
|
+
|
|
93
|
+
def _put(self, item: Any) -> None:
|
|
94
|
+
if self._pred is not None and not self._pred(item):
|
|
95
|
+
return
|
|
96
|
+
while True:
|
|
97
|
+
try:
|
|
98
|
+
self._q.put_nowait(item)
|
|
99
|
+
return
|
|
100
|
+
except queue.Full:
|
|
101
|
+
try:
|
|
102
|
+
self._q.get_nowait()
|
|
103
|
+
except queue.Empty:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
def __iter__(self) -> "_QueueIterator":
|
|
107
|
+
return self
|
|
108
|
+
|
|
109
|
+
def __next__(self) -> Any:
|
|
110
|
+
if self._closed:
|
|
111
|
+
raise StopIteration
|
|
112
|
+
try:
|
|
113
|
+
item = self._q.get(timeout=self._timeout)
|
|
114
|
+
except queue.Empty:
|
|
115
|
+
self.close()
|
|
116
|
+
raise StopIteration
|
|
117
|
+
if item is _END:
|
|
118
|
+
raise StopIteration
|
|
119
|
+
return item
|
|
120
|
+
|
|
121
|
+
def close(self) -> None:
|
|
122
|
+
if not self._closed:
|
|
123
|
+
self._closed = True
|
|
124
|
+
self._remove()
|
|
125
|
+
while True: # wake a thread that is blocked in next(), whatever is queued
|
|
126
|
+
try:
|
|
127
|
+
self._q.put_nowait(_END)
|
|
128
|
+
return
|
|
129
|
+
except queue.Full:
|
|
130
|
+
try:
|
|
131
|
+
self._q.get_nowait()
|
|
132
|
+
except queue.Empty:
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
def __enter__(self) -> "_QueueIterator":
|
|
136
|
+
return self
|
|
137
|
+
|
|
138
|
+
def __exit__(self, *exc: Any) -> None:
|
|
139
|
+
self.close()
|
|
140
|
+
|
|
141
|
+
def __del__(self) -> None: # pragma: no cover - best effort
|
|
142
|
+
try:
|
|
143
|
+
self.close()
|
|
144
|
+
except Exception: # noqa: BLE001
|
|
145
|
+
pass
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class _AsyncQueueIterator:
|
|
149
|
+
"""The asyncio counterpart; created inside a running loop, subscribed at creation, ``aclose()`` to stop."""
|
|
150
|
+
|
|
151
|
+
def __init__(self, register: Callable[[Callable[[Any], None]], Callable[[], None]], maxsize: int,
|
|
152
|
+
predicate: Optional[Callable[[Any], bool]] = None,
|
|
153
|
+
setup: Optional[Callable[[], None]] = None) -> None:
|
|
154
|
+
self._loop = asyncio.get_running_loop()
|
|
155
|
+
self._q: "asyncio.Queue[Any]" = asyncio.Queue(maxsize=maxsize)
|
|
156
|
+
self._pred = predicate
|
|
157
|
+
self._closed = False
|
|
158
|
+
ref = weakref.ref(self)
|
|
159
|
+
loop = self._loop
|
|
160
|
+
|
|
161
|
+
def push(item: Any) -> None: # weakly bound, as in the synchronous iterator
|
|
162
|
+
it = ref()
|
|
163
|
+
if it is not None:
|
|
164
|
+
try:
|
|
165
|
+
loop.call_soon_threadsafe(it._push, item)
|
|
166
|
+
except RuntimeError: # the loop is closed
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
self._remove = register(push)
|
|
170
|
+
if setup is not None:
|
|
171
|
+
try:
|
|
172
|
+
setup()
|
|
173
|
+
except BaseException:
|
|
174
|
+
self._finish()
|
|
175
|
+
raise
|
|
176
|
+
|
|
177
|
+
def _push(self, item: Any) -> None:
|
|
178
|
+
if self._closed or (self._pred is not None and not self._pred(item)):
|
|
179
|
+
return
|
|
180
|
+
if self._q.full():
|
|
181
|
+
try:
|
|
182
|
+
self._q.get_nowait()
|
|
183
|
+
except asyncio.QueueEmpty:
|
|
184
|
+
pass
|
|
185
|
+
try:
|
|
186
|
+
self._q.put_nowait(item)
|
|
187
|
+
except asyncio.QueueFull:
|
|
188
|
+
pass
|
|
189
|
+
|
|
190
|
+
def _finish(self) -> None:
|
|
191
|
+
if not self._closed:
|
|
192
|
+
self._closed = True
|
|
193
|
+
self._remove()
|
|
194
|
+
try: # wake a task that is awaiting the next item
|
|
195
|
+
self._loop.call_soon_threadsafe(self._wake)
|
|
196
|
+
except RuntimeError: # the loop is closed: nobody is waiting
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
def _wake(self) -> None:
|
|
200
|
+
if self._q.full():
|
|
201
|
+
try:
|
|
202
|
+
self._q.get_nowait()
|
|
203
|
+
except asyncio.QueueEmpty:
|
|
204
|
+
pass
|
|
205
|
+
try:
|
|
206
|
+
self._q.put_nowait(_END)
|
|
207
|
+
except asyncio.QueueFull:
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
def __aiter__(self) -> "_AsyncQueueIterator":
|
|
211
|
+
return self
|
|
212
|
+
|
|
213
|
+
async def __anext__(self) -> Any:
|
|
214
|
+
if self._closed and self._q.empty():
|
|
215
|
+
raise StopAsyncIteration
|
|
216
|
+
try:
|
|
217
|
+
item = await self._q.get()
|
|
218
|
+
except asyncio.CancelledError:
|
|
219
|
+
self._finish()
|
|
220
|
+
raise
|
|
221
|
+
if item is _END:
|
|
222
|
+
raise StopAsyncIteration
|
|
223
|
+
return item
|
|
224
|
+
|
|
225
|
+
async def aclose(self) -> None:
|
|
226
|
+
self._finish()
|
|
227
|
+
|
|
228
|
+
async def __aenter__(self) -> "_AsyncQueueIterator":
|
|
229
|
+
return self
|
|
230
|
+
|
|
231
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
232
|
+
self._finish()
|
|
233
|
+
|
|
234
|
+
def __del__(self) -> None: # pragma: no cover - best effort when a loop is abandoned without aclose()
|
|
235
|
+
try:
|
|
236
|
+
self._finish()
|
|
237
|
+
except Exception: # noqa: BLE001
|
|
238
|
+
pass
|