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/_trading.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""Order entry: market and pending orders, modify, close (also partial and close-by), cancel; synchronous, with a
|
|
2
|
+
request id and progress events, and awaitable.
|
|
3
|
+
|
|
4
|
+
Nothing here is ever retried or resent. If the server does not confirm within the execution timeout the call raises
|
|
5
|
+
``RequestTimeout`` and the order MAY have been accepted: look at ``orders()`` before sending again.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import functools
|
|
11
|
+
from datetime import date, datetime
|
|
12
|
+
from typing import Any, Dict, Optional, Union
|
|
13
|
+
|
|
14
|
+
from ._base import _Base
|
|
15
|
+
from ._util import enum_value, finite, placed_type as _placed, to_core_time, translated
|
|
16
|
+
from .errors import InvalidRequestError, ServerError
|
|
17
|
+
from .models import Order
|
|
18
|
+
|
|
19
|
+
Expiry = Union[None, str, datetime, date]
|
|
20
|
+
|
|
21
|
+
_PENDING = {"buy_limit": "BuyLimit", "sell_limit": "SellLimit", "buy_stop": "BuyStop", "sell_stop": "SellStop",
|
|
22
|
+
"buy_stop_limit": "BuyStopLimit", "sell_stop_limit": "SellStopLimit"}
|
|
23
|
+
_FILL = {"fok": "FillOrKill", "ioc": "ImmediateOrCancel", "flash": "FlashFill", "any": "Any"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _TradingMixin(_Base):
|
|
27
|
+
# ---- helpers -----------------------------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
def _open_order(self, ticket: int) -> Any:
|
|
30
|
+
for o in self._api.GetOpenedOrders():
|
|
31
|
+
if int(o.Ticket) == int(ticket):
|
|
32
|
+
return o
|
|
33
|
+
raise InvalidRequestError("no open order or position with ticket %d" % int(ticket))
|
|
34
|
+
|
|
35
|
+
def _fill(self, value: Optional[str]) -> Any:
|
|
36
|
+
if value is None:
|
|
37
|
+
return None
|
|
38
|
+
policy = enum_value(self._core.FillPolicy, _FILL.get(str(value).lower(), value), "fill policy")
|
|
39
|
+
if str(policy) not in ("Any", "FillOrKill") and self._core_at_least(3, 1, 0) is False:
|
|
40
|
+
raise ValueError("fill_policy %r needs wmt5api 3.1.0 or newer: earlier cores ignore it and send "
|
|
41
|
+
"fill-or-kill" % value)
|
|
42
|
+
return policy
|
|
43
|
+
|
|
44
|
+
def _core_at_least(self, major: int, minor: int, patch: int) -> Optional[bool]:
|
|
45
|
+
try:
|
|
46
|
+
parts = tuple(int(x) for x in self.core_version.split(".")[:3])
|
|
47
|
+
except (ValueError, AttributeError):
|
|
48
|
+
return None
|
|
49
|
+
return parts >= (major, minor, patch)
|
|
50
|
+
|
|
51
|
+
def _expiry(self, value: Expiry) -> Any:
|
|
52
|
+
if value is None:
|
|
53
|
+
return None
|
|
54
|
+
e = self._core.Expiration()
|
|
55
|
+
kinds = self._core.ExpirationType
|
|
56
|
+
if isinstance(value, str):
|
|
57
|
+
key = value.strip().lower()
|
|
58
|
+
if key == "gtc":
|
|
59
|
+
e.Type = kinds.GTC
|
|
60
|
+
elif key == "today":
|
|
61
|
+
e.Type = kinds.Today
|
|
62
|
+
else:
|
|
63
|
+
raise ValueError("expiration must be 'gtc', 'today', a datetime or a date")
|
|
64
|
+
elif isinstance(value, datetime):
|
|
65
|
+
e.Type = kinds.Specified
|
|
66
|
+
e.DateTime = to_core_time(self._server_naive(value))
|
|
67
|
+
elif isinstance(value, date):
|
|
68
|
+
e.Type = kinds.SpecifiedDay
|
|
69
|
+
e.DateTime = to_core_time(datetime(value.year, value.month, value.day))
|
|
70
|
+
else:
|
|
71
|
+
raise ValueError("expiration must be 'gtc', 'today', a datetime or a date")
|
|
72
|
+
return e
|
|
73
|
+
|
|
74
|
+
def _open_position(self, ticket: int) -> Any:
|
|
75
|
+
o = self._open_order(ticket)
|
|
76
|
+
if str(o.OrderType) not in ("Buy", "Sell"):
|
|
77
|
+
raise InvalidRequestError("ticket %d is a pending order (%s), not a position: use cancel()"
|
|
78
|
+
% (int(ticket), o.OrderType))
|
|
79
|
+
return o
|
|
80
|
+
|
|
81
|
+
def _open_pending(self, ticket: int) -> Any:
|
|
82
|
+
o = self._open_order(ticket)
|
|
83
|
+
if str(o.OrderType) in ("Buy", "Sell"):
|
|
84
|
+
raise InvalidRequestError("ticket %d is an open position, not a pending order: use close()" % int(ticket))
|
|
85
|
+
return o
|
|
86
|
+
|
|
87
|
+
def _current_expiry(self, o: Any) -> Any:
|
|
88
|
+
"""The expiration a pending order has now, as the core's ``Expiration``, so a modify that does not mention it
|
|
89
|
+
leaves it alone (an omitted expiration would otherwise become the broker's default)."""
|
|
90
|
+
if str(o.OrderType) in ("Buy", "Sell"):
|
|
91
|
+
return None
|
|
92
|
+
e = self._core.Expiration()
|
|
93
|
+
e.Type = o.ExpirationType
|
|
94
|
+
if o.ExpirationTime is not None:
|
|
95
|
+
e.DateTime = o.ExpirationTime
|
|
96
|
+
return e
|
|
97
|
+
|
|
98
|
+
def _modify_options(self, o: Any, stop_limit: Optional[float], expiration: Expiry,
|
|
99
|
+
comment: Optional[str]) -> Dict[str, Any]:
|
|
100
|
+
"""What a modify must carry besides the new stops: the order's own magic, and everything about a pending order
|
|
101
|
+
that the caller did not change (stop-limit price, expiration)."""
|
|
102
|
+
named = self._options(comment=comment)
|
|
103
|
+
named["stoplimit"] = float(stop_limit) if stop_limit is not None else float(o.StopLimitPrice)
|
|
104
|
+
exp = self._expiry(expiration)
|
|
105
|
+
if exp is None:
|
|
106
|
+
exp = self._current_expiry(o)
|
|
107
|
+
if exp is not None:
|
|
108
|
+
named["expiration"] = exp
|
|
109
|
+
named["expertID"] = int(o.ExpertId)
|
|
110
|
+
return named
|
|
111
|
+
|
|
112
|
+
def _options(self, **given: Any) -> Dict[str, Any]:
|
|
113
|
+
"""The optional named arguments of a core order call that the caller actually set."""
|
|
114
|
+
return {k: v for k, v in given.items() if v is not None}
|
|
115
|
+
|
|
116
|
+
def _market_price(self, symbol: str, side: str) -> float:
|
|
117
|
+
"""The side's current price. May wait up to the client's ``quote_timeout`` for the symbol's first quote;
|
|
118
|
+
:class:`NoQuoteError` (nothing was sent) when none arrives."""
|
|
119
|
+
q = self._get_quote(symbol, int(self._api.GetQuoteTimeoutMs))
|
|
120
|
+
return float(q.Ask) if side == "Buy" else float(q.Bid)
|
|
121
|
+
|
|
122
|
+
def _send_args(self, symbol: str, kind: str, lots: float, price: Optional[float], sl: float, tp: float,
|
|
123
|
+
deviation: int, comment: Optional[str], magic: int, stop_limit: float, expiration: Expiry,
|
|
124
|
+
fill_policy: Optional[str], trade_type: Optional[str], placed_type: Optional[str]):
|
|
125
|
+
lots = finite(lots, "lots", positive=True)
|
|
126
|
+
sl = finite(sl, "sl", non_negative=True)
|
|
127
|
+
tp = finite(tp, "tp", non_negative=True)
|
|
128
|
+
stop_limit = finite(stop_limit, "stop_limit", non_negative=True)
|
|
129
|
+
if kind.endswith("StopLimit") and stop_limit <= 0:
|
|
130
|
+
raise ValueError("a stop-limit order needs stop_limit, the price of the limit order it becomes")
|
|
131
|
+
if price is None:
|
|
132
|
+
price = self._market_price(symbol, kind)
|
|
133
|
+
else:
|
|
134
|
+
price = finite(price, "price", positive=True)
|
|
135
|
+
positional = (symbol, lots, price, getattr(self._core.OrderType, kind))
|
|
136
|
+
named = self._options(
|
|
137
|
+
sl=sl, tp=tp, deviation=int(deviation), comment=comment, expertID=int(magic),
|
|
138
|
+
stoplimit=stop_limit if stop_limit else None, expiration=self._expiry(expiration),
|
|
139
|
+
fillPolicy=self._fill(fill_policy),
|
|
140
|
+
tradeType=enum_value(self._core.TradeType, trade_type, "trade type") if trade_type else None,
|
|
141
|
+
placedType=_placed(self._core.PlacedType, placed_type))
|
|
142
|
+
return positional, named
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def _pending_kind(kind: str) -> str:
|
|
146
|
+
key = str(kind).strip().lower()
|
|
147
|
+
if key not in _PENDING:
|
|
148
|
+
raise ValueError("kind must be one of %s" % ", ".join(_PENDING))
|
|
149
|
+
return _PENDING[key]
|
|
150
|
+
|
|
151
|
+
def _confirmed(self, result: Any) -> Order:
|
|
152
|
+
if result is None:
|
|
153
|
+
raise ServerError("the server did not confirm the order; check orders() before sending again")
|
|
154
|
+
return Order._from_core(result)
|
|
155
|
+
|
|
156
|
+
# ---- synchronous -------------------------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
@translated
|
|
159
|
+
def buy(self, symbol: str, lots: float, *, sl: float = 0.0, tp: float = 0.0, deviation: int = 0,
|
|
160
|
+
comment: Optional[str] = None, magic: int = 0, fill_policy: Optional[str] = None,
|
|
161
|
+
trade_type: Optional[str] = None, placed_type: Optional[str] = None) -> Order:
|
|
162
|
+
"""Buy at market (the current ask). ``sl`` / ``tp`` are prices (0 = none); ``deviation`` is the allowed
|
|
163
|
+
slippage in points; ``magic`` is your own number, readable back on :class:`Order` and :class:`Deal`;
|
|
164
|
+
``fill_policy`` is ``fok``, ``ioc``, ``flash`` or ``any``."""
|
|
165
|
+
self._require_connected()
|
|
166
|
+
pos, named = self._send_args(symbol, "Buy", lots, None, sl, tp, deviation, comment, magic, 0.0, None,
|
|
167
|
+
fill_policy, trade_type, placed_type)
|
|
168
|
+
return self._confirmed(self._api.OrderSend(*pos, **named))
|
|
169
|
+
|
|
170
|
+
@translated
|
|
171
|
+
def sell(self, symbol: str, lots: float, *, sl: float = 0.0, tp: float = 0.0, deviation: int = 0,
|
|
172
|
+
comment: Optional[str] = None, magic: int = 0, fill_policy: Optional[str] = None,
|
|
173
|
+
trade_type: Optional[str] = None, placed_type: Optional[str] = None) -> Order:
|
|
174
|
+
"""Sell at market (the current bid)."""
|
|
175
|
+
self._require_connected()
|
|
176
|
+
pos, named = self._send_args(symbol, "Sell", lots, None, sl, tp, deviation, comment, magic, 0.0, None,
|
|
177
|
+
fill_policy, trade_type, placed_type)
|
|
178
|
+
return self._confirmed(self._api.OrderSend(*pos, **named))
|
|
179
|
+
|
|
180
|
+
@translated
|
|
181
|
+
def place_order(self, symbol: str, kind: str, lots: float, price: float, *, sl: float = 0.0, tp: float = 0.0,
|
|
182
|
+
stop_limit: float = 0.0, expiration: Expiry = None, deviation: int = 0,
|
|
183
|
+
comment: Optional[str] = None, magic: int = 0, fill_policy: Optional[str] = None,
|
|
184
|
+
trade_type: Optional[str] = None, placed_type: Optional[str] = None) -> Order:
|
|
185
|
+
"""Place a pending order. ``kind`` is one of ``buy_limit sell_limit buy_stop sell_stop buy_stop_limit
|
|
186
|
+
sell_stop_limit`` (the two stop-limit kinds also need ``stop_limit``, the limit price). ``expiration`` is
|
|
187
|
+
``None`` (the broker's default), ``"gtc"``, ``"today"``, a ``datetime`` (that moment) or a ``date`` (that day)."""
|
|
188
|
+
self._require_connected()
|
|
189
|
+
pos, named = self._send_args(symbol, self._pending_kind(kind), lots, price, sl, tp, deviation, comment, magic,
|
|
190
|
+
stop_limit, expiration, fill_policy, trade_type, placed_type)
|
|
191
|
+
return self._confirmed(self._api.OrderSend(*pos, **named))
|
|
192
|
+
|
|
193
|
+
@translated
|
|
194
|
+
def modify(self, ticket: int, *, sl: Optional[float] = None, tp: Optional[float] = None,
|
|
195
|
+
price: Optional[float] = None, stop_limit: Optional[float] = None, expiration: Expiry = None,
|
|
196
|
+
comment: Optional[str] = None) -> None:
|
|
197
|
+
"""Change a position's stop-loss / take-profit, or a pending order's price / stops / expiration. Anything
|
|
198
|
+
left as ``None`` keeps its current value."""
|
|
199
|
+
self._require_connected()
|
|
200
|
+
if sl is None and tp is None and price is None and stop_limit is None and expiration is None:
|
|
201
|
+
raise ValueError("nothing to change")
|
|
202
|
+
o = self._open_order(ticket)
|
|
203
|
+
named = self._modify_options(o, stop_limit, expiration, comment)
|
|
204
|
+
self._api.OrderModify(o.Ticket, o.Symbol, o.Lots, float(price) if price is not None else float(o.OpenPrice),
|
|
205
|
+
o.OrderType, float(sl) if sl is not None else float(o.StopLoss),
|
|
206
|
+
float(tp) if tp is not None else float(o.TakeProfit), **named)
|
|
207
|
+
|
|
208
|
+
@translated
|
|
209
|
+
def close(self, ticket: int, lots: Optional[float] = None, *, deviation: int = 0, comment: Optional[str] = None,
|
|
210
|
+
fill_policy: Optional[str] = None, close_by: Optional[int] = None,
|
|
211
|
+
placed_type: Optional[str] = None) -> Order:
|
|
212
|
+
"""Close a position at market, completely or (with ``lots``) in part. ``close_by`` closes it against an
|
|
213
|
+
opposite position of the same symbol (hedging accounts)."""
|
|
214
|
+
self._require_connected()
|
|
215
|
+
o = self._open_position(ticket)
|
|
216
|
+
amount = float(o.Lots) if lots is None else finite(lots, "lots", positive=True)
|
|
217
|
+
if amount > float(o.Lots) + 1e-9:
|
|
218
|
+
raise ValueError("lots must be between 0 and the position's %g" % float(o.Lots))
|
|
219
|
+
q = self._get_quote(str(o.Symbol), int(self._api.GetQuoteTimeoutMs))
|
|
220
|
+
price = float(q.Bid) if str(o.OrderType) == "Buy" else float(q.Ask) # a buy closes at the bid
|
|
221
|
+
# the closing order carries the position's own magic number, so its deals stay attributable to the bot
|
|
222
|
+
named = self._options(deviation=int(deviation) if deviation else None, fillPolicy=self._fill(fill_policy),
|
|
223
|
+
comment=comment, closeByTicket=int(close_by) if close_by else None,
|
|
224
|
+
placedType=_placed(self._core.PlacedType, placed_type))
|
|
225
|
+
named["expertId"] = int(o.ExpertId)
|
|
226
|
+
return self._confirmed(self._api.OrderClose(o.Ticket, o.Symbol, price, amount, o.OrderType, **named))
|
|
227
|
+
|
|
228
|
+
@translated
|
|
229
|
+
def cancel(self, ticket: int, *, fill_policy: Optional[str] = None, comment: Optional[str] = None,
|
|
230
|
+
placed_type: Optional[str] = None) -> Order:
|
|
231
|
+
"""Delete a pending order."""
|
|
232
|
+
self._require_connected()
|
|
233
|
+
o = self._open_pending(ticket)
|
|
234
|
+
named = self._options(fillPolicy=self._fill(fill_policy), comment=comment,
|
|
235
|
+
placedType=_placed(self._core.PlacedType, placed_type))
|
|
236
|
+
named["expertId"] = int(o.ExpertId)
|
|
237
|
+
return self._confirmed(self._api.OrderCancel(o.Ticket, o.Symbol, o.Lots, o.OrderType, **named))
|
|
238
|
+
|
|
239
|
+
# ---- with a request id and progress events ------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
@translated
|
|
242
|
+
def new_request_id(self) -> int:
|
|
243
|
+
"""A unique id for an asynchronous trade call (the ``*_async`` methods take one for you)."""
|
|
244
|
+
return int(self._api.GetRequestId())
|
|
245
|
+
|
|
246
|
+
@translated
|
|
247
|
+
def buy_async(self, symbol: str, lots: float, *, request_id: Optional[int] = None, sl: float = 0.0,
|
|
248
|
+
tp: float = 0.0, deviation: int = 0, comment: Optional[str] = None, magic: int = 0,
|
|
249
|
+
fill_policy: Optional[str] = None, trade_type: Optional[str] = None,
|
|
250
|
+
placed_type: Optional[str] = None) -> int:
|
|
251
|
+
"""Send a market buy and return with its request id as soon as the request is on the wire; the outcome arrives
|
|
252
|
+
through :meth:`on_order_progress` (and :meth:`on_order_update`).
|
|
253
|
+
|
|
254
|
+
A market order needs a price: if the symbol has no quote yet this first waits (up to the client's
|
|
255
|
+
``quote_timeout``) for one, and raises :class:`NoQuoteError` — nothing sent — if none arrives."""
|
|
256
|
+
return self._send_async("Buy", symbol, lots, None, request_id, sl, tp, deviation, comment, magic, 0.0, None,
|
|
257
|
+
fill_policy, trade_type, placed_type)
|
|
258
|
+
|
|
259
|
+
@translated
|
|
260
|
+
def sell_async(self, symbol: str, lots: float, *, request_id: Optional[int] = None, sl: float = 0.0,
|
|
261
|
+
tp: float = 0.0, deviation: int = 0, comment: Optional[str] = None, magic: int = 0,
|
|
262
|
+
fill_policy: Optional[str] = None, trade_type: Optional[str] = None,
|
|
263
|
+
placed_type: Optional[str] = None) -> int:
|
|
264
|
+
"""Send a market sell and return at once with its request id; see :meth:`buy_async`."""
|
|
265
|
+
return self._send_async("Sell", symbol, lots, None, request_id, sl, tp, deviation, comment, magic, 0.0, None,
|
|
266
|
+
fill_policy, trade_type, placed_type)
|
|
267
|
+
|
|
268
|
+
@translated
|
|
269
|
+
def place_order_async(self, symbol: str, kind: str, lots: float, price: float, *,
|
|
270
|
+
request_id: Optional[int] = None, sl: float = 0.0, tp: float = 0.0,
|
|
271
|
+
stop_limit: float = 0.0, expiration: Expiry = None, deviation: int = 0,
|
|
272
|
+
comment: Optional[str] = None, magic: int = 0, fill_policy: Optional[str] = None,
|
|
273
|
+
trade_type: Optional[str] = None, placed_type: Optional[str] = None) -> int:
|
|
274
|
+
"""Place a pending order and return at once with its request id; see :meth:`place_order` and :meth:`buy_async`."""
|
|
275
|
+
return self._send_async(self._pending_kind(kind), symbol, lots, price, request_id, sl, tp, deviation,
|
|
276
|
+
comment, magic, stop_limit, expiration, fill_policy, trade_type, placed_type)
|
|
277
|
+
|
|
278
|
+
def _send_async(self, kind: str, symbol: str, lots: float, price: Optional[float], request_id: Optional[int],
|
|
279
|
+
sl: float, tp: float, deviation: int, comment: Optional[str], magic: int, stop_limit: float,
|
|
280
|
+
expiration: Expiry, fill_policy: Optional[str], trade_type: Optional[str],
|
|
281
|
+
placed_type: Optional[str]) -> int:
|
|
282
|
+
self._require_connected()
|
|
283
|
+
rid = int(request_id) if request_id is not None else int(self._api.GetRequestId())
|
|
284
|
+
pos, named = self._send_args(symbol, kind, lots, price, sl, tp, deviation, comment, magic, stop_limit,
|
|
285
|
+
expiration, fill_policy, trade_type, placed_type)
|
|
286
|
+
self._api.OrderSendAsync(rid, *pos, **named)
|
|
287
|
+
return rid
|
|
288
|
+
|
|
289
|
+
@translated
|
|
290
|
+
def modify_async(self, ticket: int, *, request_id: Optional[int] = None, sl: Optional[float] = None,
|
|
291
|
+
tp: Optional[float] = None, price: Optional[float] = None, stop_limit: Optional[float] = None,
|
|
292
|
+
expiration: Expiry = None, comment: Optional[str] = None) -> int:
|
|
293
|
+
"""Modify a position or pending order and return at once with its request id; see :meth:`modify`."""
|
|
294
|
+
self._require_connected()
|
|
295
|
+
if sl is None and tp is None and price is None and stop_limit is None and expiration is None:
|
|
296
|
+
raise ValueError("nothing to change")
|
|
297
|
+
o = self._open_order(ticket)
|
|
298
|
+
rid = int(request_id) if request_id is not None else int(self._api.GetRequestId())
|
|
299
|
+
named = self._modify_options(o, stop_limit, expiration, comment)
|
|
300
|
+
self._api.OrderModifyAsync(rid, o.Ticket, o.Symbol, o.Lots,
|
|
301
|
+
float(price) if price is not None else float(o.OpenPrice), o.OrderType,
|
|
302
|
+
float(sl) if sl is not None else float(o.StopLoss),
|
|
303
|
+
float(tp) if tp is not None else float(o.TakeProfit), **named)
|
|
304
|
+
return rid
|
|
305
|
+
|
|
306
|
+
@translated
|
|
307
|
+
def close_async(self, ticket: int, lots: Optional[float] = None, *, request_id: Optional[int] = None,
|
|
308
|
+
deviation: int = 0, comment: Optional[str] = None, fill_policy: Optional[str] = None,
|
|
309
|
+
close_by: Optional[int] = None, placed_type: Optional[str] = None) -> int:
|
|
310
|
+
"""Close a position (or part of it) at market and return at once with its request id; see :meth:`close`."""
|
|
311
|
+
self._require_connected()
|
|
312
|
+
o = self._open_position(ticket)
|
|
313
|
+
amount = float(o.Lots) if lots is None else finite(lots, "lots", positive=True)
|
|
314
|
+
if amount > float(o.Lots) + 1e-9:
|
|
315
|
+
raise ValueError("lots must be between 0 and the position's %g" % float(o.Lots))
|
|
316
|
+
q = self._get_quote(str(o.Symbol), int(self._api.GetQuoteTimeoutMs))
|
|
317
|
+
price = float(q.Bid) if str(o.OrderType) == "Buy" else float(q.Ask)
|
|
318
|
+
rid = int(request_id) if request_id is not None else int(self._api.GetRequestId())
|
|
319
|
+
named = self._options(deviation=int(deviation) if deviation else None, fillPolicy=self._fill(fill_policy),
|
|
320
|
+
comment=comment, closeByTicket=int(close_by) if close_by else None,
|
|
321
|
+
placedType=_placed(self._core.PlacedType, placed_type))
|
|
322
|
+
named["expertId"] = int(o.ExpertId)
|
|
323
|
+
self._api.OrderCloseAsync(rid, o.Ticket, o.Symbol, price, amount, o.OrderType, **named)
|
|
324
|
+
return rid
|
|
325
|
+
|
|
326
|
+
@translated
|
|
327
|
+
def cancel_async(self, ticket: int, *, request_id: Optional[int] = None, fill_policy: Optional[str] = None,
|
|
328
|
+
comment: Optional[str] = None, placed_type: Optional[str] = None) -> int:
|
|
329
|
+
"""Delete a pending order and return at once with its request id; see :meth:`cancel`."""
|
|
330
|
+
self._require_connected()
|
|
331
|
+
o = self._open_pending(ticket)
|
|
332
|
+
rid = int(request_id) if request_id is not None else int(self._api.GetRequestId())
|
|
333
|
+
named = self._options(fillPolicy=self._fill(fill_policy), comment=comment,
|
|
334
|
+
placedType=_placed(self._core.PlacedType, placed_type))
|
|
335
|
+
named["expertId"] = int(o.ExpertId)
|
|
336
|
+
self._api.OrderCancelAsync(rid, o.Ticket, o.Symbol, o.Lots, o.OrderType, **named)
|
|
337
|
+
return rid
|
|
338
|
+
|
|
339
|
+
# ---- awaitable ----------------------------------------------------------------------------------------------
|
|
340
|
+
# The synchronous calls, run on a worker thread so they never block the event loop.
|
|
341
|
+
|
|
342
|
+
async def _run(self, fn: Any, *args: Any, **kwargs: Any) -> Any:
|
|
343
|
+
return await asyncio.get_running_loop().run_in_executor(None, functools.partial(fn, *args, **kwargs))
|
|
344
|
+
|
|
345
|
+
async def abuy(self, *args: Any, **kwargs: Any) -> Order:
|
|
346
|
+
"""Awaitable :meth:`buy`."""
|
|
347
|
+
return await self._run(self.buy, *args, **kwargs)
|
|
348
|
+
|
|
349
|
+
async def asell(self, *args: Any, **kwargs: Any) -> Order:
|
|
350
|
+
"""Awaitable :meth:`sell`."""
|
|
351
|
+
return await self._run(self.sell, *args, **kwargs)
|
|
352
|
+
|
|
353
|
+
async def aplace_order(self, *args: Any, **kwargs: Any) -> Order:
|
|
354
|
+
"""Awaitable :meth:`place_order`."""
|
|
355
|
+
return await self._run(self.place_order, *args, **kwargs)
|
|
356
|
+
|
|
357
|
+
async def amodify(self, *args: Any, **kwargs: Any) -> None:
|
|
358
|
+
"""Awaitable :meth:`modify`."""
|
|
359
|
+
return await self._run(self.modify, *args, **kwargs)
|
|
360
|
+
|
|
361
|
+
async def aclose(self, *args: Any, **kwargs: Any) -> Order:
|
|
362
|
+
"""Awaitable :meth:`close`."""
|
|
363
|
+
return await self._run(self.close, *args, **kwargs)
|
|
364
|
+
|
|
365
|
+
async def acancel(self, *args: Any, **kwargs: Any) -> Order:
|
|
366
|
+
"""Awaitable :meth:`cancel`."""
|
|
367
|
+
return await self._run(self.cancel, *args, **kwargs)
|
pmt5api/_util.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Small helpers shared by the client modules."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import functools
|
|
5
|
+
import math
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any, Callable, Optional
|
|
8
|
+
|
|
9
|
+
from .errors import WmtError, translate
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def translated(fn: Callable) -> Callable:
|
|
13
|
+
"""Re-raise the core's exceptions as the pmt5api hierarchy (the core exception stays as ``__cause__``)."""
|
|
14
|
+
@functools.wraps(fn)
|
|
15
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
16
|
+
try:
|
|
17
|
+
return fn(*args, **kwargs)
|
|
18
|
+
except WmtError:
|
|
19
|
+
raise
|
|
20
|
+
except Exception as exc: # noqa: BLE001 — translate() returns None for anything that is not the core's
|
|
21
|
+
mapped = translate(exc)
|
|
22
|
+
if mapped is None:
|
|
23
|
+
raise
|
|
24
|
+
raise mapped from exc
|
|
25
|
+
return wrapper
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def to_core_time(value: datetime) -> Any:
|
|
29
|
+
"""A naive ``datetime`` as a .NET ``DateTime`` (kind unspecified: the value is used exactly as given)."""
|
|
30
|
+
import System # available once the runtime is loaded
|
|
31
|
+
return System.DateTime(value.year, value.month, value.day, value.hour, value.minute, value.second,
|
|
32
|
+
value.microsecond // 1000)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def seconds_to_ms(value: Optional[float], name: str = "timeout") -> Optional[int]:
|
|
36
|
+
"""Seconds to the core's milliseconds. A timeout must be positive and finite: zero would make every call "time
|
|
37
|
+
out" the instant it is made, which for an order means "sent, unconfirmed" — an invitation to resend."""
|
|
38
|
+
if value is None:
|
|
39
|
+
return None
|
|
40
|
+
if not isinstance(value, (int, float)) or isinstance(value, bool) or not math.isfinite(value) or value <= 0:
|
|
41
|
+
raise ValueError("%s must be a positive number of seconds, not %r" % (name, value))
|
|
42
|
+
return max(1, int(round(value * 1000)))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def finite(value: float, name: str, *, positive: bool = False, non_negative: bool = False) -> float:
|
|
46
|
+
"""``value`` as a float, refusing NaN and infinity (which the core would turn into undefined volumes or prices)."""
|
|
47
|
+
v = float(value)
|
|
48
|
+
if not math.isfinite(v):
|
|
49
|
+
raise ValueError("%s must be a finite number, not %r" % (name, value))
|
|
50
|
+
if positive and v <= 0:
|
|
51
|
+
raise ValueError("%s must be positive" % name)
|
|
52
|
+
if non_negative and v < 0:
|
|
53
|
+
raise ValueError("%s must not be negative" % name)
|
|
54
|
+
return v
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def enum_value(enum_type: Any, value: Any, what: str) -> Any:
|
|
58
|
+
"""Look an enum member up by name (case-insensitive, ``-``/``_``/space ignored); ``None`` stays ``None``."""
|
|
59
|
+
if value is None:
|
|
60
|
+
return None
|
|
61
|
+
key = str(value).replace("_", "").replace("-", "").replace(" ", "").lower()
|
|
62
|
+
for name in dir(enum_type):
|
|
63
|
+
if name.startswith("_"):
|
|
64
|
+
continue
|
|
65
|
+
if name.replace("_", "").lower() == key:
|
|
66
|
+
return getattr(enum_type, name)
|
|
67
|
+
raise ValueError("unknown %s %r" % (what, value))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_PLACED = {"manual": "Manually", "manually": "Manually", "expert": "ByExpert", "dealer": "ByDealer",
|
|
71
|
+
"mobile": "Mobile", "web": "Web"}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def placed_type(enum_type: Any, value: Optional[str]) -> Any:
|
|
75
|
+
"""``PlacedType`` by name or by the friendly aliases ``manual`` / ``expert`` / ``dealer``."""
|
|
76
|
+
if value is None:
|
|
77
|
+
return None
|
|
78
|
+
return enum_value(enum_type, _PLACED.get(str(value).strip().lower(), value), "placed type")
|
pmt5api/bars.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""OHLC bars: the value type and the timeframe aggregation.
|
|
2
|
+
|
|
3
|
+
The server delivers one-minute bars. Higher timeframes are built here from those minutes, aligned to the server's
|
|
4
|
+
calendar (the day starts at server midnight, an H4 bar starts at 00, 04, 08, ... server time), with the tick volume
|
|
5
|
+
summed. A bar for the period that is still running is returned as it stands; it is not marked, so do not treat the
|
|
6
|
+
last bar of a live series as final. A weekly bar starts on Sunday 00:00 server time, a monthly bar on the 1st.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime, timedelta
|
|
12
|
+
from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
|
|
13
|
+
|
|
14
|
+
from .models import _time
|
|
15
|
+
|
|
16
|
+
#: Supported timeframes, in minutes. Weekly and monthly bars are calendar-aligned (their minute counts are nominal).
|
|
17
|
+
TIMEFRAMES: Dict[str, int] = {
|
|
18
|
+
"M1": 1, "M2": 2, "M3": 3, "M4": 4, "M5": 5, "M6": 6, "M10": 10, "M12": 12, "M15": 15, "M20": 20, "M30": 30,
|
|
19
|
+
"H1": 60, "H2": 120, "H3": 180, "H4": 240, "H6": 360, "H8": 480, "H12": 720,
|
|
20
|
+
"D1": 1440, "W1": 10080, "MN1": 43200,
|
|
21
|
+
}
|
|
22
|
+
_WEEK, _MONTH = 10080, 43200
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Bar:
|
|
27
|
+
time: datetime # start of the period, server time
|
|
28
|
+
open: float
|
|
29
|
+
high: float
|
|
30
|
+
low: float
|
|
31
|
+
close: float
|
|
32
|
+
tick_volume: int # number of ticks in the period (summed for aggregated bars)
|
|
33
|
+
volume: int # real volume where the broker reports it (0 for most FX symbols)
|
|
34
|
+
spread: Optional[int] = None # points, as the server stored it; present on M1 bars only
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def _from_core(cls, b: Any) -> "Bar":
|
|
38
|
+
return cls(time=_time(b.Time), open=float(b.OpenPrice), high=float(b.HighPrice), low=float(b.LowPrice),
|
|
39
|
+
close=float(b.ClosePrice), tick_volume=int(b.TickVolume), volume=int(b.Volume),
|
|
40
|
+
spread=int(b.Spread))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def timeframe_minutes(timeframe: Union[str, int]) -> int:
|
|
44
|
+
if isinstance(timeframe, str):
|
|
45
|
+
key = timeframe.strip().upper()
|
|
46
|
+
if key in TIMEFRAMES:
|
|
47
|
+
return TIMEFRAMES[key]
|
|
48
|
+
elif isinstance(timeframe, int) and timeframe in TIMEFRAMES.values():
|
|
49
|
+
return timeframe
|
|
50
|
+
raise ValueError("unsupported timeframe %r; use one of %s" % (timeframe, ", ".join(TIMEFRAMES)))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _bucket(t: datetime, minutes: int) -> datetime:
|
|
54
|
+
day = datetime(t.year, t.month, t.day)
|
|
55
|
+
if minutes == _MONTH:
|
|
56
|
+
return datetime(t.year, t.month, 1)
|
|
57
|
+
if minutes == _WEEK:
|
|
58
|
+
return day - timedelta(days=(t.weekday() + 1) % 7) # the week starts on Sunday
|
|
59
|
+
if minutes >= 1440:
|
|
60
|
+
return day
|
|
61
|
+
elapsed = t.hour * 60 + t.minute
|
|
62
|
+
return day + timedelta(minutes=elapsed - elapsed % minutes)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def aggregate(minute_bars: Iterable[Bar], minutes: int) -> List[Bar]:
|
|
66
|
+
"""Build ``minutes``-long bars from one-minute bars (any order; a repeated minute keeps its last value)."""
|
|
67
|
+
by_time: Dict[datetime, Bar] = {b.time: b for b in minute_bars}
|
|
68
|
+
ordered = [by_time[t] for t in sorted(by_time)]
|
|
69
|
+
if minutes == 1:
|
|
70
|
+
return ordered
|
|
71
|
+
out: List[Bar] = []
|
|
72
|
+
current: Optional[Bar] = None
|
|
73
|
+
for b in ordered:
|
|
74
|
+
start = _bucket(b.time, minutes)
|
|
75
|
+
if current is None or current.time != start:
|
|
76
|
+
if current is not None:
|
|
77
|
+
out.append(current)
|
|
78
|
+
current = Bar(time=start, open=b.open, high=b.high, low=b.low, close=b.close,
|
|
79
|
+
tick_volume=b.tick_volume, volume=b.volume, spread=None)
|
|
80
|
+
else:
|
|
81
|
+
current = Bar(time=current.time, open=current.open, high=max(current.high, b.high),
|
|
82
|
+
low=min(current.low, b.low), close=b.close,
|
|
83
|
+
tick_volume=current.tick_volume + b.tick_volume, volume=current.volume + b.volume,
|
|
84
|
+
spread=None)
|
|
85
|
+
if current is not None:
|
|
86
|
+
out.append(current)
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class QuoteHistoryEvent:
|
|
92
|
+
"""One-minute bars the server delivered after :meth:`Client.request_bars`."""
|
|
93
|
+
symbol: str
|
|
94
|
+
bars: Tuple[Bar, ...]
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def _from_core(cls, e: Any) -> "QuoteHistoryEvent":
|
|
98
|
+
return cls(symbol=str(e.Symbol), bars=tuple(Bar._from_core(b) for b in (e.Bars or ())))
|