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/__init__.py +32 -0
- pmt4api/__main__.py +78 -0
- pmt4api/_account.py +253 -0
- pmt4api/_base.py +24 -0
- pmt4api/_coverage.py +186 -0
- pmt4api/_events.py +189 -0
- pmt4api/_market.py +263 -0
- pmt4api/_native/CHANGELOG.md +362 -0
- pmt4api/_native/THIRD-PARTY-NOTICES.txt +7 -0
- pmt4api/_native/docs/ERRORS.md +136 -0
- pmt4api/_native/docs/LICENSING.md +55 -0
- pmt4api/_native/docs/SECURITY.md +38 -0
- pmt4api/_native/wmt4api.dll +0 -0
- pmt4api/_risk.py +66 -0
- pmt4api/_runtime.py +154 -0
- pmt4api/_session.py +171 -0
- pmt4api/_streams.py +238 -0
- pmt4api/_trading.py +384 -0
- pmt4api/_util.py +78 -0
- pmt4api/bars.py +62 -0
- pmt4api/client.py +143 -0
- pmt4api/diagnostics.py +38 -0
- pmt4api/docs/API.md +702 -0
- pmt4api/docs/FROM-CSHARP.md +151 -0
- pmt4api/errors.py +233 -0
- pmt4api/frames.py +21 -0
- pmt4api/models.py +337 -0
- pmt4api/py.typed +0 -0
- pmt4api/servers.py +40 -0
- pmt4api-1.0.0.dist-info/METADATA +389 -0
- pmt4api-1.0.0.dist-info/RECORD +33 -0
- pmt4api-1.0.0.dist-info/WHEEL +4 -0
- pmt4api-1.0.0.dist-info/licenses/LICENSE.txt +29 -0
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))
|