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/_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