pmt4api 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.
pmt4api/_events.py ADDED
@@ -0,0 +1,189 @@
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 ConnectError, ConnectionLostError, RequestTimeout, translate
16
+ from .models import ConnectEvent, Order, OrderProgress, OrderUpdate, Quote
17
+
18
+ log = logging.getLogger("pmt4api")
19
+
20
+
21
+ def _error(exc: Any) -> Optional[BaseException]:
22
+ if exc is None:
23
+ return None
24
+ mapped = translate(exc)
25
+ return mapped if mapped is not None else exc
26
+
27
+
28
+ def _lost(exc: Any) -> Optional[BaseException]:
29
+ """Why a session ended. A drop is a lost connection whatever raised it: the silence watchdog's timeout and a
30
+ socket error are both :class:`ConnectionLostError`, not a request that timed out."""
31
+ err = _error(exc)
32
+ if err is None or isinstance(err, ConnectionLostError):
33
+ return err
34
+ if isinstance(err, (RequestTimeout, ConnectError)) or type(err).__name__ in (
35
+ "SocketException", "ObjectDisposedException", "IOException"):
36
+ return ConnectionLostError(str(getattr(exc, "Message", None) or err))
37
+ return err
38
+
39
+
40
+ class _EventsMixin(_Base):
41
+ def _event_target(self, event: str) -> Any:
42
+ """The core object an event lives on: the order client carries ``OnOrderProgress``, the client the rest."""
43
+ return self._trader if event == "OnOrderProgress" else self._api
44
+
45
+ def _add_handler(self, event: str, handler: Any) -> None:
46
+ target = self._event_target(event)
47
+ ev = getattr(target, event)
48
+ ev += handler
49
+ setattr(target, event, ev)
50
+
51
+ def _remove_handler(self, event: str, handler: Any) -> None:
52
+ target = self._event_target(event)
53
+ ev = getattr(target, event)
54
+ ev -= handler
55
+ setattr(target, event, ev)
56
+
57
+ def _dispatcher(self) -> _Dispatcher:
58
+ with self._lock:
59
+ d = getattr(self, "_dispatch", None)
60
+ if d is None:
61
+ d = self._dispatch = _Dispatcher()
62
+ return d
63
+
64
+ def _wanted(self, symbols: Any) -> Any:
65
+ """The symbols a stream is about, by the broker's own spelling (ticks carry it). A name the broker does not
66
+ offer raises :class:`InvalidRequestError` now rather than yielding nothing for ever."""
67
+ self._require_connected()
68
+ names = {s for g in symbols for s in ([g] if isinstance(g, str) else g)}
69
+ return {self._canon(n) for n in names}
70
+
71
+ @property
72
+ def events_dropped(self) -> int:
73
+ """How many events were dropped because callbacks fell too far behind (see :mod:`pmt4api._streams`)."""
74
+ d = getattr(self, "_dispatch", None)
75
+ return d.dropped if d is not None else 0
76
+
77
+ def _watch(self, event: str, convert: Optional[Callable[[Any], Any]],
78
+ callback: Callable[..., None]) -> Callable[[], None]:
79
+ """Subscribe ``callback`` to a core event; returns the function that removes it."""
80
+ dispatcher = self._dispatcher()
81
+ active = {"on": True}
82
+
83
+ def deliver(value: Any) -> None:
84
+ if not active["on"]:
85
+ return
86
+ if convert is None:
87
+ callback()
88
+ else:
89
+ callback(value)
90
+
91
+ def handler(*raw: Any) -> None:
92
+ try:
93
+ value = convert(raw[1]) if convert is not None else None # snapshot now, on the core's thread
94
+ except Exception: # noqa: BLE001
95
+ log.exception("%s: could not convert the event", event)
96
+ return
97
+ dispatcher.submit(deliver, value, event)
98
+
99
+ with self._lock:
100
+ self._add_handler(event, handler)
101
+ self._handlers.append((event, handler))
102
+
103
+ def remove() -> None:
104
+ active["on"] = False
105
+ with self._lock:
106
+ if (event, handler) in self._handlers:
107
+ self._handlers.remove((event, handler))
108
+ self._remove_handler(event, handler)
109
+ return remove
110
+
111
+ # ---- callbacks ---------------------------------------------------------------------------------------------
112
+
113
+ def on_quote(self, callback: Callable[[Quote], None]) -> Callable[[], None]:
114
+ """Call ``callback(quote)`` for every tick of every subscribed symbol. Returns a function that removes it."""
115
+ return self._watch("OnQuote", Quote._from_core, callback)
116
+
117
+ def on_order_update(self, callback: Callable[[OrderUpdate], None]) -> Callable[[], None]:
118
+ """Call ``callback(update)`` when the server pushes a change to an order or position (opened, modified,
119
+ closed, a pending order placed, filled or deleted, a balance entry)."""
120
+ return self._watch("OnOrderUpdate", OrderUpdate._from_core, callback)
121
+
122
+ def on_order_progress(self, callback: Callable[[OrderProgress], None]) -> Callable[[], None]:
123
+ """Call ``callback(progress)`` as a trade request advances (see :meth:`buy_async`). Synchronous calls raise
124
+ their outcome instead, but they also report here under an id this client does not return."""
125
+ def convert(p: Any) -> OrderProgress:
126
+ order = p.Order
127
+ return OrderProgress(request_id=int(p.TempID), stage=str(p.Type),
128
+ order=Order._from_core(order) if order is not None else None,
129
+ error=_error(p.Exception))
130
+ return self._watch("OnOrderProgress", convert, callback)
131
+
132
+ def on_connect(self, callback: Callable[[ConnectEvent], None]) -> Callable[[], None]:
133
+ """Call ``callback(event)`` after every successful connect: the first one and each automatic or manual
134
+ reconnect (``event.connected`` is true)."""
135
+ return self._watch("OnConnect", lambda args: ConnectEvent(True, None), callback)
136
+
137
+ def on_disconnect(self, callback: Callable[[ConnectEvent], None]) -> Callable[[], None]:
138
+ """Call ``callback(event)`` once when a session ends. ``event.error`` is why a link dropped, and ``None`` when
139
+ you called :meth:`disconnect`. With ``auto_reconnect`` the client then reconnects by itself and
140
+ :meth:`on_connect` fires again."""
141
+ return self._watch("OnDisconnect", lambda args: ConnectEvent(False, _lost(args.Exception)), callback)
142
+
143
+ def on_quote_history(self, callback: Callable[[QuoteHistoryEvent], None]) -> Callable[[], None]:
144
+ """Call ``callback(event)`` as each chunk of bars of a :meth:`bars` download arrives."""
145
+ return self._watch("OnQuoteHistory", QuoteHistoryEvent._from_core, callback)
146
+
147
+ def on_symbols_update(self, callback: Callable[[], None]) -> Callable[[], None]:
148
+ """Call ``callback()`` when the server pushes a change to the symbol list; read :meth:`symbol` again (the
149
+ core keeps the table it loaded at connect, so a changed specification is seen at the next reconnect)."""
150
+ return self._watch("OnSymbolsUpdate", None, callback)
151
+
152
+ # ---- iterators ---------------------------------------------------------------------------------------------
153
+
154
+ def quotes(self, *symbols: str, timeout: Optional[float] = None, maxsize: int = 10000) -> Iterator[Quote]:
155
+ """Iterate live quotes of ``symbols`` (subscribing them first; with none given, every subscribed symbol).
156
+ Ends when ``timeout`` seconds pass without a tick. The feed is attached when this is called, not at the first
157
+ ``next()``. If the consumer falls behind by more than ``maxsize`` quotes the oldest are dropped."""
158
+ wanted = self._wanted(symbols)
159
+ pred = (lambda q: q.symbol in wanted) if wanted else None
160
+ return _QueueIterator(self.on_quote, timeout, maxsize, pred,
161
+ (lambda: self.subscribe(*sorted(wanted))) if wanted else None) # type: ignore[return-value]
162
+
163
+ def aquotes(self, *symbols: str, maxsize: int = 10000) -> AsyncIterator[Quote]:
164
+ """Async version of :meth:`quotes` (no timeout; ``aclose()`` or cancel the task to stop). Call it inside a
165
+ running event loop."""
166
+ wanted = self._wanted(symbols)
167
+ pred = (lambda q: q.symbol in wanted) if wanted else None
168
+ return _AsyncQueueIterator(self.on_quote, maxsize, pred,
169
+ (lambda: self.subscribe(*sorted(wanted))) if wanted else None) # type: ignore[return-value]
170
+
171
+ def order_updates(self, *, timeout: Optional[float] = None, maxsize: int = 1000) -> Iterator[OrderUpdate]:
172
+ """Iterate order updates (attached when called). Ends when ``timeout`` seconds pass without one."""
173
+ return _QueueIterator(self.on_order_update, timeout, maxsize) # type: ignore[return-value]
174
+
175
+ def aorder_updates(self, *, maxsize: int = 1000) -> AsyncIterator[OrderUpdate]:
176
+ """Async version of :meth:`order_updates`."""
177
+ return _AsyncQueueIterator(self.on_order_update, maxsize) # type: ignore[return-value]
178
+
179
+ def order_progress(self, *, request_id: Optional[int] = None, timeout: Optional[float] = None,
180
+ maxsize: int = 1000) -> Iterator[OrderProgress]:
181
+ """Iterate trade-request progress (attached when called), optionally only that of ``request_id``. Ends when
182
+ ``timeout`` seconds pass without an item."""
183
+ pred = (lambda p: p.request_id == request_id) if request_id is not None else None
184
+ return _QueueIterator(self.on_order_progress, timeout, maxsize, pred) # type: ignore[return-value]
185
+
186
+ def aorder_progress(self, *, request_id: Optional[int] = None, maxsize: int = 1000) -> AsyncIterator[OrderProgress]:
187
+ """Async version of :meth:`order_progress`."""
188
+ pred = (lambda p: p.request_id == request_id) if request_id is not None else None
189
+ return _AsyncQueueIterator(self.on_order_progress, maxsize, pred) # type: ignore[return-value]
pmt4api/_market.py ADDED
@@ -0,0 +1,263 @@
1
+ """Symbols, quotes, subscriptions, bars and per-symbol values."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import functools
6
+ import math
7
+ import time
8
+ from datetime import datetime, timezone
9
+ from typing import Any, Dict, List, Optional, Tuple, Union
10
+
11
+ from ._base import _Base
12
+ from ._util import enum_value, to_core_time, translated
13
+ from .bars import Bar, timeframe_minutes, timeframe_name
14
+ from .errors import InvalidRequestError, NoQuoteError, RequestTimeout, translate
15
+ from .models import Quote, SymbolGroup, SymbolInfo
16
+
17
+ #: The most bars one request can return (the wire carries the count in a signed 16-bit field).
18
+ MAX_BARS_PER_REQUEST = 32767
19
+
20
+
21
+ def _names(groups: Tuple[Any, ...]) -> List[str]:
22
+ return [s for group in groups for s in ([group] if isinstance(group, str) else group)]
23
+
24
+
25
+ class _MarketMixin(_Base):
26
+ # ---- symbols -----------------------------------------------------------------------------------------------
27
+
28
+ def _spec(self, name: str) -> Any:
29
+ spec = self._api.GetSymbolSpec(name)
30
+ if spec is None:
31
+ raise InvalidRequestError("unknown symbol %r" % name)
32
+ return spec
33
+
34
+ def _canon(self, name: str) -> str:
35
+ """The broker's own spelling of a symbol (the lookup ignores case; the wire, the quote cache and the events do
36
+ not), so ``"eurusdm"`` and ``"EURUSDm"`` are the same symbol everywhere."""
37
+ return str(self._spec(name).Name)
38
+
39
+ @translated
40
+ def symbols(self) -> Dict[str, SymbolInfo]:
41
+ """Every symbol the broker offers on this account, by name, with its specification."""
42
+ self._require_connected()
43
+ out: Dict[str, SymbolInfo] = {}
44
+ for name in self._api.GetSymbolNames():
45
+ spec = self._api.GetSymbolSpec(name)
46
+ if spec is not None:
47
+ out[str(spec.Name)] = SymbolInfo._from_core(spec)
48
+ return out
49
+
50
+ @translated
51
+ def symbol(self, name: str) -> SymbolInfo:
52
+ """One symbol's specification. Names carry the broker's suffix (``EURUSDm``, ``XAUUSD.s``): find yours with
53
+ :meth:`find_symbols`. The lookup ignores case."""
54
+ self._require_connected()
55
+ return SymbolInfo._from_core(self._spec(name))
56
+
57
+ @translated
58
+ def has_symbol(self, name: str) -> bool:
59
+ """Whether the broker offers ``name`` (case-insensitive)."""
60
+ self._require_connected()
61
+ return self._api.GetSymbolSpec(name) is not None
62
+
63
+ @translated
64
+ def find_symbols(self, prefix: str) -> List[str]:
65
+ """Every symbol whose name starts with ``prefix`` (case-insensitive; brokers add suffixes such as ``m`` or
66
+ ``.s``), exact match first, then shortest name."""
67
+ self._require_connected()
68
+ key = prefix.lower()
69
+ names = [str(n) for n in self._api.GetSymbolNames()]
70
+ return sorted((n for n in names if n.lower().startswith(key)), key=lambda n: (n.lower() != key, len(n), n))
71
+
72
+ @translated
73
+ def find_symbol(self, prefix: str) -> Optional[str]:
74
+ """The symbol ``prefix`` names: the exact name if the broker has it, else the only symbol that starts with
75
+ it. ``None`` when there is none; an :class:`InvalidRequestError` listing the candidates when several do
76
+ (use :meth:`find_symbols` and choose)."""
77
+ hits = self.find_symbols(prefix)
78
+ if not hits:
79
+ return None
80
+ if hits[0].lower() == prefix.lower() or len(hits) == 1:
81
+ return hits[0]
82
+ raise InvalidRequestError("%r is ambiguous: %s" % (prefix, ", ".join(hits[:8]) + (" ..." if len(hits) > 8 else "")))
83
+
84
+ @translated
85
+ def symbol_groups(self) -> List[SymbolGroup]:
86
+ """The broker's configured symbol groups (name and description). The core does not say which group a symbol
87
+ belongs to; :attr:`SymbolInfo.security_index` is the key of its volume rules."""
88
+ self._require_connected()
89
+ return [SymbolGroup._from_core(g) for g in (self._api.Groups or ())]
90
+
91
+ # ---- quotes and subscriptions ------------------------------------------------------------------------------
92
+
93
+ def _quote_ms(self, timeout: Optional[float]) -> int:
94
+ return int(timeout * 1000) if timeout is not None else int(self._api.GetQuoteTimeoutMs)
95
+
96
+ def _get_quote(self, symbol: str, ms: int) -> Any:
97
+ """The core's quote, or :class:`NoQuoteError` when none arrives in time (nothing is sent by this)."""
98
+ if self._api.GetSymbolSpec(symbol) is None:
99
+ raise InvalidRequestError("unknown symbol %r" % symbol)
100
+ try:
101
+ q = self._api.GetQuote(symbol, ms)
102
+ except Exception as exc: # noqa: BLE001
103
+ mapped = translate(exc)
104
+ if isinstance(mapped, RequestTimeout):
105
+ raise NoQuoteError("no quote for %s within %.1f s" % (symbol, ms / 1000.0)) from exc
106
+ raise
107
+ if q is None:
108
+ raise NoQuoteError("no quote for %s within %.1f s" % (symbol, ms / 1000.0))
109
+ return q
110
+
111
+ @translated
112
+ def quote(self, symbol: str, timeout: Optional[float] = None) -> Quote:
113
+ """The current quote of one symbol, waiting up to ``timeout`` seconds (default: the client's ``quote_timeout``)
114
+ for the first tick; :class:`NoQuoteError` if none arrives. The price is current: a symbol that is not
115
+ subscribed (while others are) is subscribed first and the call waits for its next tick, so it is never a price
116
+ cached before the feed was narrowed. ``time`` is the tick's server time."""
117
+ self._require_connected()
118
+ return Quote._from_core(self._get_quote(self._canon(symbol), self._quote_ms(timeout)))
119
+
120
+ async def aquote(self, symbol: str, timeout: Optional[float] = None) -> Quote:
121
+ """Awaitable :meth:`quote` (runs on a worker thread; never blocks the event loop)."""
122
+ return await asyncio.get_running_loop().run_in_executor(None, functools.partial(self.quote, symbol, timeout))
123
+
124
+ @translated
125
+ def subscribe(self, *symbols: str) -> None:
126
+ """Start the live quote feed for the given symbols (names, or lists of names). A name the broker does not
127
+ offer raises :class:`InvalidRequestError` before anything is subscribed."""
128
+ self._require_connected()
129
+ names = _names(symbols)
130
+ for n in names:
131
+ self._spec(n)
132
+ for n in names:
133
+ self._api.Subscribe(n)
134
+
135
+ @translated
136
+ def unsubscribe(self, *symbols: str) -> None:
137
+ """Stop the live quote feed for the given symbols (names, or lists of names). A symbol with an open position
138
+ stays on the feed (its profit and the account's equity are priced from it); with nothing left subscribed the
139
+ server goes back to streaming its default set."""
140
+ self._require_connected()
141
+ for n in _names(symbols):
142
+ self._api.Unsubscribe(n)
143
+
144
+ @translated
145
+ def is_subscribed(self, symbol: str) -> bool:
146
+ """Whether ``symbol`` was subscribed (by :meth:`subscribe`, a quote request that had to wait, or the core
147
+ itself for the symbols of open positions). Before the first subscription the server streams a default set
148
+ of symbols, so :meth:`quote` also answers for symbols this reports as not subscribed."""
149
+ self._require_connected()
150
+ return bool(self._api.IsSubscribed(symbol))
151
+
152
+ # ---- values per symbol -------------------------------------------------------------------------------------
153
+
154
+ @translated
155
+ def tick_value(self, symbol: str, side: str = "ask", timeout: Optional[float] = None) -> float:
156
+ """The value, in account currency, of one full price unit per 1.0 lot (as the terminal computes it,
157
+ converting through the account currency's cross rates).
158
+
159
+ This is an *input* to position sizing, not a recipe: see :meth:`lots_for_risk`. ``side`` is ``"ask"`` (what a
160
+ long position is priced against) or ``"bid"`` (a short). Waits up to ``timeout`` seconds (default:
161
+ ``quote_timeout``) for a live quote. Raises :class:`InvalidRequestError` when the value cannot be computed (a
162
+ symbol whose profit currency has no conversion rate), never a NaN.
163
+ """
164
+ self._require_connected()
165
+ side_key = str(side).lower()
166
+ if side_key not in ("ask", "bid"):
167
+ raise ValueError("side must be 'ask' or 'bid'")
168
+ self._spec(symbol)
169
+ ms = self._quote_ms(timeout)
170
+ if side_key == "ask":
171
+ value = float(self._api.GetTickValue(symbol, ms))
172
+ else:
173
+ value = float(self._api.GetBidTickValue(self._get_quote(symbol, ms)))
174
+ if math.isnan(value):
175
+ raise InvalidRequestError("the tick value of %s cannot be computed (no conversion rate yet)" % symbol)
176
+ return value
177
+
178
+ @translated
179
+ def contract_size(self, symbol: str) -> float:
180
+ """The contract size of a symbol (units of the instrument per 1.0 lot)."""
181
+ self._require_connected()
182
+ return float(self._spec(symbol).ContractSize)
183
+
184
+ # ---- bars --------------------------------------------------------------------------------------------------
185
+
186
+ def _download_bars(self, symbol: str, tf: Any, end: datetime, count: int, timeout: float) -> List[Bar]:
187
+ """One bar request: up to ``count`` bars ending at ``end``. The core allows one request per symbol and
188
+ timeframe at a time; the call is serialised and bounded by ``timeout`` seconds."""
189
+ with self._bars_lock:
190
+ api = self._api
191
+ previous = int(api.DownloadQuoteHistoryTimeout)
192
+ api.DownloadQuoteHistoryTimeout = max(1, int(timeout * 1000))
193
+ try:
194
+ got = api.DownloadQuoteHistory(symbol, tf, to_core_time(end), int(count))
195
+ finally:
196
+ api.DownloadQuoteHistoryTimeout = previous
197
+ return [Bar._from_core(b) for b in (got or ())]
198
+
199
+ @translated
200
+ def bars(self, symbol: str, timeframe: Union[str, int] = "H1", *, count: Optional[int] = None,
201
+ start: Optional[datetime] = None, end: Optional[datetime] = None,
202
+ timeout: Optional[float] = None) -> List[Bar]:
203
+ """OHLC bars, oldest first, in the broker's server time.
204
+
205
+ Give ``count`` for the latest N bars, or ``start`` for everything from that time; with neither, the latest
206
+ 500. ``end`` (default: now) is the moment the series is read up to: only bars that had completed by then are
207
+ returned (a bar that opens exactly at ``end`` is still forming and is not in it), so there is no look-ahead.
208
+ ``timeframe`` is one of ``M1 M5 M15 M30 H1 H4 D1 W1 MN1`` (or its length in minutes): the server serves each
209
+ itself. The server limits how far back it goes (about a few days of M1), so a long request may return fewer
210
+ bars than asked for; that is the end of the broker's history. ``timeout`` (seconds, default: the client's
211
+ ``bars_timeout``) bounds the whole call.
212
+ """
213
+ self._require_connected()
214
+ minutes = timeframe_minutes(timeframe)
215
+ tf = enum_value(self._core.Timeframe, timeframe_name(timeframe), "timeframe")
216
+ if count is not None and start is not None:
217
+ raise ValueError("give count or start, not both")
218
+ if count is not None and count <= 0:
219
+ raise ValueError("count must be positive")
220
+ if count is None and start is None:
221
+ count = 500
222
+ symbol = self._canon(symbol)
223
+ start = self._server_naive(start)
224
+ end = self._server_naive(end)
225
+ total = float(timeout) if timeout is not None else self._api.DownloadQuoteHistoryTimeout / 1000.0
226
+ if total <= 0:
227
+ raise ValueError("timeout must be positive")
228
+ deadline = time.monotonic() + total
229
+ server_now = self.server_time or datetime.now(timezone.utc).replace(tzinfo=None)
230
+ cursor = end if end is not None else server_now
231
+ have: Dict[datetime, Bar] = {}
232
+ for _ in range(64): # a bound: 64 requests of up to 32767 bars each
233
+ remaining = deadline - time.monotonic()
234
+ if remaining <= 0:
235
+ raise RequestTimeout("bars for %s not complete within %.0f s" % (symbol, total))
236
+ if count is not None:
237
+ want = min(MAX_BARS_PER_REQUEST, max(1, count - len(have)) + 1)
238
+ else:
239
+ span = (cursor - start).total_seconds() / 60.0 # type: ignore[operator]
240
+ want = int(min(MAX_BARS_PER_REQUEST, max(2, span / minutes + 2)))
241
+ page = self._download_bars(symbol, tf, cursor, want, remaining)
242
+ fresh = [b for b in page if b.time not in have and (end is None or b.time <= end)]
243
+ for b in page:
244
+ if end is None or b.time <= end:
245
+ have.setdefault(b.time, b)
246
+ if not page or not fresh:
247
+ break # the server has nothing older: the end of its history
248
+ first = min(b.time for b in page)
249
+ if count is not None and len(have) >= count:
250
+ break
251
+ if start is not None and first <= start:
252
+ break
253
+ cursor = first
254
+ series = [have[t] for t in sorted(have)]
255
+ if start is not None:
256
+ series = [b for b in series if b.time >= start]
257
+ if count is not None:
258
+ series = series[-count:]
259
+ return series
260
+
261
+ async def abars(self, *args: Any, **kwargs: Any) -> List[Bar]:
262
+ """Awaitable :meth:`bars` (runs on a worker thread; never blocks the event loop)."""
263
+ return await asyncio.get_running_loop().run_in_executor(None, functools.partial(self.bars, *args, **kwargs))