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/_trading.py
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
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 trade 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 datetime, timedelta
|
|
12
|
+
from typing import Any, Dict, Optional, Union
|
|
13
|
+
|
|
14
|
+
from ._base import _Base
|
|
15
|
+
from ._util import 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, timedelta]
|
|
20
|
+
|
|
21
|
+
_PENDING = {"buy_limit": "BuyLimit", "sell_limit": "SellLimit", "buy_stop": "BuyStop", "sell_stop": "SellStop"}
|
|
22
|
+
|
|
23
|
+
#: The longest comment the wire carries (32 bytes, the last being the terminator).
|
|
24
|
+
MAX_COMMENT = 31
|
|
25
|
+
|
|
26
|
+
_NO_MAGIC = ("the MT4 core accepts a magic number but does not transmit it, so an order cannot be tagged with one "
|
|
27
|
+
"(its position would come back with magic 0 and a strategy that filters by magic would lose it). Tag "
|
|
28
|
+
"orders with `comment` instead; Order.magic is still read back from orders that carry one.")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _TradingMixin(_Base):
|
|
32
|
+
# ---- helpers -----------------------------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
def _open_order(self, ticket: int) -> Any:
|
|
35
|
+
o = self._api.GetOpenedOrder(int(ticket))
|
|
36
|
+
if o is None:
|
|
37
|
+
raise InvalidRequestError("no open order or position with ticket %d" % int(ticket))
|
|
38
|
+
return o
|
|
39
|
+
|
|
40
|
+
def _open_position(self, ticket: int) -> Any:
|
|
41
|
+
o = self._open_order(ticket)
|
|
42
|
+
if str(o.Type) not in ("Buy", "Sell"):
|
|
43
|
+
raise InvalidRequestError("ticket %d is a pending order (%s), not a position: use cancel()"
|
|
44
|
+
% (int(ticket), o.Type))
|
|
45
|
+
return o
|
|
46
|
+
|
|
47
|
+
def _open_pending(self, ticket: int) -> Any:
|
|
48
|
+
o = self._open_order(ticket)
|
|
49
|
+
if str(o.Type) in ("Buy", "Sell"):
|
|
50
|
+
raise InvalidRequestError("ticket %d is an open position, not a pending order: use close()" % int(ticket))
|
|
51
|
+
return o
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def _centilots(lots: float, name: str = "lots") -> float:
|
|
55
|
+
"""``lots`` checked against what the wire can carry: a positive multiple of 0.01 (the core would round
|
|
56
|
+
anything finer to a different volume, or to zero)."""
|
|
57
|
+
v = finite(lots, name, positive=True)
|
|
58
|
+
cents = round(v * 100.0)
|
|
59
|
+
if cents > 2147483647:
|
|
60
|
+
raise ValueError("%s %r does not fit the wire (at most 21474836.47 lots)" % (name, lots))
|
|
61
|
+
if cents < 1 or abs(v * 100.0 - cents) > 1e-6:
|
|
62
|
+
raise ValueError("%s must be a positive multiple of 0.01 (MT4 carries volume in hundredths of a lot), "
|
|
63
|
+
"not %r" % (name, lots))
|
|
64
|
+
return v
|
|
65
|
+
|
|
66
|
+
def _check_comment(self, comment: Optional[str]) -> None:
|
|
67
|
+
if comment is None:
|
|
68
|
+
return
|
|
69
|
+
if len(comment) > MAX_COMMENT:
|
|
70
|
+
raise ValueError("comment is %d characters; the MT4 wire carries at most %d" % (len(comment), MAX_COMMENT))
|
|
71
|
+
if not comment.isascii():
|
|
72
|
+
raise ValueError("comment must be ASCII: orders are read back through an ASCII-only parser, so any other "
|
|
73
|
+
"character would come back as '?' and the comment could not be used to find the order")
|
|
74
|
+
try:
|
|
75
|
+
comment.encode(self._comment_encoding)
|
|
76
|
+
except UnicodeEncodeError as exc:
|
|
77
|
+
raise ValueError("comment cannot be encoded as %s (the client's comment_encoding): %s"
|
|
78
|
+
% (self._comment_encoding, exc)) from exc
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def _check_magic(magic: int) -> None:
|
|
82
|
+
if magic:
|
|
83
|
+
raise InvalidRequestError("magic=%r: %s" % (magic, _NO_MAGIC))
|
|
84
|
+
|
|
85
|
+
def _expiry(self, value: Expiry) -> Any:
|
|
86
|
+
"""A pending order's expiration as the core's ``DateTime`` (``None``: good till cancelled)."""
|
|
87
|
+
if value is None:
|
|
88
|
+
return None
|
|
89
|
+
if isinstance(value, str):
|
|
90
|
+
if value.strip().lower() == "gtc":
|
|
91
|
+
import System # noqa: PLC0415
|
|
92
|
+
return System.DateTime.MinValue # good till cancelled
|
|
93
|
+
raise ValueError("expiration must be 'gtc', a datetime (server time, or timezone-aware) or a timedelta")
|
|
94
|
+
if isinstance(value, timedelta):
|
|
95
|
+
base = self.server_time
|
|
96
|
+
if base is None:
|
|
97
|
+
raise InvalidRequestError("not connected: the server clock is unknown")
|
|
98
|
+
return to_core_time(base + value)
|
|
99
|
+
if isinstance(value, datetime):
|
|
100
|
+
return to_core_time(self._server_naive(value))
|
|
101
|
+
raise ValueError("expiration must be 'gtc', a datetime (server time, or timezone-aware) or a timedelta from now")
|
|
102
|
+
|
|
103
|
+
def _market_price(self, symbol: str, is_buy: bool) -> float:
|
|
104
|
+
"""The side's current price. May wait up to the client's ``quote_timeout`` for the symbol's first quote;
|
|
105
|
+
:class:`NoQuoteError` (nothing was sent) when none arrives."""
|
|
106
|
+
q = self._get_quote(symbol, int(self._api.GetQuoteTimeoutMs))
|
|
107
|
+
return float(q.Ask) if is_buy else float(q.Bid)
|
|
108
|
+
|
|
109
|
+
def _send_args(self, symbol: str, kind: str, lots: float, price: Optional[float], sl: float, tp: float,
|
|
110
|
+
slippage: int, comment: Optional[str], magic: int, expiration: Expiry,
|
|
111
|
+
placed_type: Optional[str]):
|
|
112
|
+
self._check_magic(magic)
|
|
113
|
+
symbol = self._canon(symbol)
|
|
114
|
+
lots = self._centilots(lots)
|
|
115
|
+
sl = finite(sl, "sl", non_negative=True)
|
|
116
|
+
tp = finite(tp, "tp", non_negative=True)
|
|
117
|
+
if int(slippage) < 0:
|
|
118
|
+
raise ValueError("slippage must not be negative")
|
|
119
|
+
self._check_comment(comment)
|
|
120
|
+
is_pending = kind in _PENDING.values()
|
|
121
|
+
if price is None:
|
|
122
|
+
if is_pending:
|
|
123
|
+
raise ValueError("a pending order needs its price")
|
|
124
|
+
price = self._market_price(symbol, kind == "Buy")
|
|
125
|
+
else:
|
|
126
|
+
price = finite(price, "price", positive=True)
|
|
127
|
+
positional = (symbol, getattr(self._core.OrderType, kind), lots, price)
|
|
128
|
+
named: Dict[str, Any] = {"slippage": int(slippage), "stoploss": sl, "takeprofit": tp}
|
|
129
|
+
if comment is not None:
|
|
130
|
+
named["comment"] = comment
|
|
131
|
+
exp = self._expiry(expiration)
|
|
132
|
+
if exp is not None:
|
|
133
|
+
named["expiration"] = exp
|
|
134
|
+
pt = _placed(self._core.PlacedType, placed_type)
|
|
135
|
+
if pt is not None:
|
|
136
|
+
named["placedType"] = pt
|
|
137
|
+
return positional, named
|
|
138
|
+
|
|
139
|
+
@staticmethod
|
|
140
|
+
def _pending_kind(kind: str) -> str:
|
|
141
|
+
key = str(kind).strip().lower()
|
|
142
|
+
if key not in _PENDING:
|
|
143
|
+
raise ValueError("kind must be one of %s" % ", ".join(_PENDING))
|
|
144
|
+
return _PENDING[key]
|
|
145
|
+
|
|
146
|
+
def _confirmed(self, result: Any) -> Order:
|
|
147
|
+
if result is None:
|
|
148
|
+
raise ServerError("the server did not return the order; check orders() before sending again")
|
|
149
|
+
return Order._from_core(result)
|
|
150
|
+
|
|
151
|
+
def _close_args(self, ticket: int, lots: Optional[float], price: Optional[float], slippage: int):
|
|
152
|
+
o = self._open_position(ticket)
|
|
153
|
+
amount = float(o.Lots) if lots is None else self._centilots(lots)
|
|
154
|
+
if amount > float(o.Lots) + 1e-9:
|
|
155
|
+
raise ValueError("lots must be between 0.01 and the position's %g" % float(o.Lots))
|
|
156
|
+
if int(slippage) < 0:
|
|
157
|
+
raise ValueError("slippage must not be negative")
|
|
158
|
+
if price is None:
|
|
159
|
+
price = self._market_price(str(o.Symbol), str(o.Type) == "Sell") # a buy closes at the bid
|
|
160
|
+
else:
|
|
161
|
+
price = finite(price, "price", positive=True)
|
|
162
|
+
return o, amount, price
|
|
163
|
+
|
|
164
|
+
def _modify_args(self, ticket: int, sl: Optional[float], tp: Optional[float], price: Optional[float],
|
|
165
|
+
expiration: Expiry):
|
|
166
|
+
if sl is None and tp is None and price is None and expiration is None:
|
|
167
|
+
raise ValueError("nothing to change")
|
|
168
|
+
o = self._open_order(ticket)
|
|
169
|
+
pending = str(o.Type) not in ("Buy", "Sell")
|
|
170
|
+
if price is not None and not pending:
|
|
171
|
+
raise ValueError("the open price of a position cannot be changed; only a pending order's can")
|
|
172
|
+
if expiration is not None and not pending:
|
|
173
|
+
raise ValueError("a position has no expiration; only a pending order does")
|
|
174
|
+
new_sl = finite(sl, "sl", non_negative=True) if sl is not None else float(o.StopLoss)
|
|
175
|
+
new_tp = finite(tp, "tp", non_negative=True) if tp is not None else float(o.TakeProfit)
|
|
176
|
+
new_price = finite(price, "price", positive=True) if price is not None else float(o.OpenPrice)
|
|
177
|
+
# an omitted expiration would otherwise become "good till cancelled": keep the one the order has
|
|
178
|
+
exp = self._expiry(expiration) if expiration is not None else (o.ExpirationTime if pending else None)
|
|
179
|
+
args = (int(o.Ticket), str(o.Symbol), o.Type, float(o.Lots), new_price, new_sl, new_tp)
|
|
180
|
+
return args, exp
|
|
181
|
+
|
|
182
|
+
# ---- synchronous -------------------------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
@translated
|
|
185
|
+
def buy(self, symbol: str, lots: float, *, price: Optional[float] = None, slippage: int = 0, sl: float = 0.0,
|
|
186
|
+
tp: float = 0.0, comment: Optional[str] = None, magic: int = 0,
|
|
187
|
+
placed_type: Optional[str] = None) -> Order:
|
|
188
|
+
"""Buy at market. ``price`` defaults to the current ask (an instant-execution symbol fills at it within
|
|
189
|
+
``slippage`` points; a market-execution symbol ignores it). ``sl`` / ``tp`` are prices (0 = none); ``lots`` is
|
|
190
|
+
a multiple of 0.01. ``comment`` (at most 31 characters) is how to tag an order: the core does not transmit a
|
|
191
|
+
magic number, so ``magic`` other than 0 raises :class:`InvalidRequestError` rather than being ignored.
|
|
192
|
+
``placed_type`` is ``manual``, ``expert``, ``dealer`` ... (default: the client's)."""
|
|
193
|
+
self._require_connected()
|
|
194
|
+
pos, named = self._send_args(symbol, "Buy", lots, price, sl, tp, slippage, comment, magic, None, placed_type)
|
|
195
|
+
return self._confirmed(self._api.OrderSend(*pos, **named))
|
|
196
|
+
|
|
197
|
+
@translated
|
|
198
|
+
def sell(self, symbol: str, lots: float, *, price: Optional[float] = None, slippage: int = 0, sl: float = 0.0,
|
|
199
|
+
tp: float = 0.0, comment: Optional[str] = None, magic: int = 0,
|
|
200
|
+
placed_type: Optional[str] = None) -> Order:
|
|
201
|
+
"""Sell at market (``price`` defaults to the current bid); see :meth:`buy`."""
|
|
202
|
+
self._require_connected()
|
|
203
|
+
pos, named = self._send_args(symbol, "Sell", lots, price, sl, tp, slippage, comment, magic, None, placed_type)
|
|
204
|
+
return self._confirmed(self._api.OrderSend(*pos, **named))
|
|
205
|
+
|
|
206
|
+
@translated
|
|
207
|
+
def place_order(self, symbol: str, kind: str, lots: float, price: float, *, sl: float = 0.0, tp: float = 0.0,
|
|
208
|
+
expiration: Expiry = None, slippage: int = 0, comment: Optional[str] = None, magic: int = 0,
|
|
209
|
+
placed_type: Optional[str] = None) -> Order:
|
|
210
|
+
"""Place a pending order. ``kind`` is one of ``buy_limit sell_limit buy_stop sell_stop``. ``expiration`` is
|
|
211
|
+
``None`` or ``"gtc"`` (good till cancelled), a ``datetime`` (server time, or timezone-aware) or a
|
|
212
|
+
``timedelta`` from now."""
|
|
213
|
+
self._require_connected()
|
|
214
|
+
pos, named = self._send_args(symbol, self._pending_kind(kind), lots, price, sl, tp, slippage, comment, magic,
|
|
215
|
+
expiration, placed_type)
|
|
216
|
+
return self._confirmed(self._api.OrderSend(*pos, **named))
|
|
217
|
+
|
|
218
|
+
@translated
|
|
219
|
+
def modify(self, ticket: int, *, sl: Optional[float] = None, tp: Optional[float] = None,
|
|
220
|
+
price: Optional[float] = None, expiration: Expiry = None) -> Order:
|
|
221
|
+
"""Change a position's stop-loss / take-profit, or a pending order's price / stops / expiration. Anything
|
|
222
|
+
left as ``None`` keeps its current value (a pending order's expiration included; ``expiration="gtc"`` clears
|
|
223
|
+
it). Returns the order as the server now holds it."""
|
|
224
|
+
self._require_connected()
|
|
225
|
+
args, exp = self._modify_args(ticket, sl, tp, price, expiration)
|
|
226
|
+
ticket_, symbol, kind, lots, new_price, new_sl, new_tp = args
|
|
227
|
+
if exp is not None:
|
|
228
|
+
result = self._api.OrderModify(ticket_, symbol, kind, lots, new_price, new_sl, new_tp, exp)
|
|
229
|
+
else:
|
|
230
|
+
import System # noqa: PLC0415
|
|
231
|
+
result = self._api.OrderModify(ticket_, symbol, kind, lots, new_price, new_sl, new_tp,
|
|
232
|
+
System.DateTime.MinValue)
|
|
233
|
+
return self._confirmed(result)
|
|
234
|
+
|
|
235
|
+
@translated
|
|
236
|
+
def close(self, ticket: int, lots: Optional[float] = None, *, price: Optional[float] = None,
|
|
237
|
+
slippage: int = 0) -> Order:
|
|
238
|
+
"""Close a position at market, completely or (with ``lots``) in part; ``price`` defaults to the current
|
|
239
|
+
bid (buy) / ask (sell). Returns the closing record. After a partial close the remainder may carry a new
|
|
240
|
+
ticket: read :meth:`positions`."""
|
|
241
|
+
self._require_connected()
|
|
242
|
+
o, amount, px = self._close_args(ticket, lots, price, slippage)
|
|
243
|
+
return self._confirmed(self._api.OrderClose(str(o.Symbol), int(o.Ticket), amount, px, int(slippage)))
|
|
244
|
+
|
|
245
|
+
@translated
|
|
246
|
+
def cancel(self, ticket: int) -> None:
|
|
247
|
+
"""Delete a pending order."""
|
|
248
|
+
self._require_connected()
|
|
249
|
+
o = self._open_pending(ticket)
|
|
250
|
+
self._api.OrderCancel(int(o.Ticket), o.Type, str(o.Symbol), float(o.Lots), float(o.OpenPrice))
|
|
251
|
+
|
|
252
|
+
@translated
|
|
253
|
+
def close_by(self, ticket: int, opposite_ticket: int) -> None:
|
|
254
|
+
"""Close a position against an opposite position of the same symbol (hedged accounts); the result arrives as
|
|
255
|
+
order updates. Both must be open positions of the same symbol and opposite sides."""
|
|
256
|
+
self._require_connected()
|
|
257
|
+
a = self._open_position(ticket)
|
|
258
|
+
b = self._open_position(opposite_ticket)
|
|
259
|
+
if str(a.Symbol).lower() != str(b.Symbol).lower() or str(a.Type) == str(b.Type):
|
|
260
|
+
raise InvalidRequestError("close_by needs two opposite positions of the same symbol (%s %s / %s %s)"
|
|
261
|
+
% (a.Symbol, a.Type, b.Symbol, b.Type))
|
|
262
|
+
self._api.OrderCloseBy(str(a.Symbol), int(a.Ticket), int(b.Ticket))
|
|
263
|
+
|
|
264
|
+
@translated
|
|
265
|
+
def close_all_by(self, symbol: str) -> None:
|
|
266
|
+
"""Close every pair of opposite positions of ``symbol`` against each other (hedged accounts)."""
|
|
267
|
+
self._require_connected()
|
|
268
|
+
self._spec(symbol)
|
|
269
|
+
self._api.OrderMultipleCloseBy(symbol)
|
|
270
|
+
|
|
271
|
+
# ---- with a request id and progress events ------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
@translated
|
|
274
|
+
def buy_async(self, symbol: str, lots: float, *, price: Optional[float] = None, slippage: int = 0,
|
|
275
|
+
sl: float = 0.0, tp: float = 0.0, comment: Optional[str] = None, magic: int = 0,
|
|
276
|
+
placed_type: Optional[str] = None) -> int:
|
|
277
|
+
"""Send a market buy and return its request id as soon as the request is on the wire; the outcome arrives
|
|
278
|
+
through :meth:`on_order_progress` (and :meth:`on_order_update`)."""
|
|
279
|
+
self._require_connected()
|
|
280
|
+
pos, named = self._send_args(symbol, "Buy", lots, price, sl, tp, slippage, comment, magic, None, placed_type)
|
|
281
|
+
return int(self._trader.OrderSendAsync(*pos, **named))
|
|
282
|
+
|
|
283
|
+
@translated
|
|
284
|
+
def sell_async(self, symbol: str, lots: float, *, price: Optional[float] = None, slippage: int = 0,
|
|
285
|
+
sl: float = 0.0, tp: float = 0.0, comment: Optional[str] = None, magic: int = 0,
|
|
286
|
+
placed_type: Optional[str] = None) -> int:
|
|
287
|
+
"""Send a market sell and return its request id at once; see :meth:`buy_async`."""
|
|
288
|
+
self._require_connected()
|
|
289
|
+
pos, named = self._send_args(symbol, "Sell", lots, price, sl, tp, slippage, comment, magic, None, placed_type)
|
|
290
|
+
return int(self._trader.OrderSendAsync(*pos, **named))
|
|
291
|
+
|
|
292
|
+
@translated
|
|
293
|
+
def place_order_async(self, symbol: str, kind: str, lots: float, price: float, *, sl: float = 0.0,
|
|
294
|
+
tp: float = 0.0, expiration: Expiry = None, slippage: int = 0,
|
|
295
|
+
comment: Optional[str] = None, magic: int = 0, placed_type: Optional[str] = None) -> int:
|
|
296
|
+
"""Place a pending order and return its request id at once; see :meth:`place_order`."""
|
|
297
|
+
self._require_connected()
|
|
298
|
+
pos, named = self._send_args(symbol, self._pending_kind(kind), lots, price, sl, tp, slippage, comment, magic,
|
|
299
|
+
expiration, placed_type)
|
|
300
|
+
return int(self._trader.OrderSendAsync(*pos, **named))
|
|
301
|
+
|
|
302
|
+
@translated
|
|
303
|
+
def modify_async(self, ticket: int, *, sl: Optional[float] = None, tp: Optional[float] = None,
|
|
304
|
+
price: Optional[float] = None, expiration: Expiry = None) -> int:
|
|
305
|
+
"""Modify a position or pending order and return its request id at once; see :meth:`modify`."""
|
|
306
|
+
self._require_connected()
|
|
307
|
+
args, exp = self._modify_args(ticket, sl, tp, price, expiration)
|
|
308
|
+
ticket_, symbol, kind, lots, new_price, new_sl, new_tp = args
|
|
309
|
+
if exp is None:
|
|
310
|
+
import System # noqa: PLC0415
|
|
311
|
+
exp = System.DateTime.MinValue
|
|
312
|
+
return int(self._trader.OrderModifyAsync(ticket_, symbol, kind, lots, new_price, new_sl, new_tp, exp))
|
|
313
|
+
|
|
314
|
+
@translated
|
|
315
|
+
def close_async(self, ticket: int, lots: Optional[float] = None, *, price: Optional[float] = None,
|
|
316
|
+
slippage: int = 0) -> int:
|
|
317
|
+
"""Close a position (or part of it) at market and return its request id at once; see :meth:`close`."""
|
|
318
|
+
self._require_connected()
|
|
319
|
+
o, amount, px = self._close_args(ticket, lots, price, slippage)
|
|
320
|
+
return int(self._trader.OrderCloseAsync(str(o.Symbol), int(o.Ticket), amount, px, int(slippage)))
|
|
321
|
+
|
|
322
|
+
@translated
|
|
323
|
+
def cancel_async(self, ticket: int) -> int:
|
|
324
|
+
"""Delete a pending order and return its request id at once; see :meth:`cancel`."""
|
|
325
|
+
self._require_connected()
|
|
326
|
+
o = self._open_pending(ticket)
|
|
327
|
+
return int(self._trader.OrderCancelAsync(int(o.Ticket), o.Type, str(o.Symbol), float(o.Lots),
|
|
328
|
+
float(o.OpenPrice)))
|
|
329
|
+
|
|
330
|
+
@translated
|
|
331
|
+
def close_by_async(self, ticket: int, opposite_ticket: int) -> int:
|
|
332
|
+
""":meth:`close_by`, returning its request id at once."""
|
|
333
|
+
self._require_connected()
|
|
334
|
+
a = self._open_position(ticket)
|
|
335
|
+
b = self._open_position(opposite_ticket)
|
|
336
|
+
if str(a.Symbol).lower() != str(b.Symbol).lower() or str(a.Type) == str(b.Type):
|
|
337
|
+
raise InvalidRequestError("close_by needs two opposite positions of the same symbol (%s %s / %s %s)"
|
|
338
|
+
% (a.Symbol, a.Type, b.Symbol, b.Type))
|
|
339
|
+
return int(self._trader.OrderCloseByAsync(str(a.Symbol), int(a.Ticket), int(b.Ticket)))
|
|
340
|
+
|
|
341
|
+
@translated
|
|
342
|
+
def close_all_by_async(self, symbol: str) -> int:
|
|
343
|
+
""":meth:`close_all_by`, returning its request id at once."""
|
|
344
|
+
self._require_connected()
|
|
345
|
+
self._spec(symbol)
|
|
346
|
+
return int(self._trader.OrderMultipleCloseByAsync(symbol))
|
|
347
|
+
|
|
348
|
+
# ---- awaitable ----------------------------------------------------------------------------------------------
|
|
349
|
+
# The synchronous calls, run on a worker thread so they never block the event loop.
|
|
350
|
+
|
|
351
|
+
async def _run(self, fn: Any, *args: Any, **kwargs: Any) -> Any:
|
|
352
|
+
return await asyncio.get_running_loop().run_in_executor(None, functools.partial(fn, *args, **kwargs))
|
|
353
|
+
|
|
354
|
+
async def abuy(self, *args: Any, **kwargs: Any) -> Order:
|
|
355
|
+
"""Awaitable :meth:`buy`."""
|
|
356
|
+
return await self._run(self.buy, *args, **kwargs)
|
|
357
|
+
|
|
358
|
+
async def asell(self, *args: Any, **kwargs: Any) -> Order:
|
|
359
|
+
"""Awaitable :meth:`sell`."""
|
|
360
|
+
return await self._run(self.sell, *args, **kwargs)
|
|
361
|
+
|
|
362
|
+
async def aplace_order(self, *args: Any, **kwargs: Any) -> Order:
|
|
363
|
+
"""Awaitable :meth:`place_order`."""
|
|
364
|
+
return await self._run(self.place_order, *args, **kwargs)
|
|
365
|
+
|
|
366
|
+
async def amodify(self, *args: Any, **kwargs: Any) -> Order:
|
|
367
|
+
"""Awaitable :meth:`modify`."""
|
|
368
|
+
return await self._run(self.modify, *args, **kwargs)
|
|
369
|
+
|
|
370
|
+
async def aclose(self, *args: Any, **kwargs: Any) -> Order:
|
|
371
|
+
"""Awaitable :meth:`close`."""
|
|
372
|
+
return await self._run(self.close, *args, **kwargs)
|
|
373
|
+
|
|
374
|
+
async def acancel(self, *args: Any, **kwargs: Any) -> None:
|
|
375
|
+
"""Awaitable :meth:`cancel`."""
|
|
376
|
+
return await self._run(self.cancel, *args, **kwargs)
|
|
377
|
+
|
|
378
|
+
async def aclose_by(self, *args: Any, **kwargs: Any) -> None:
|
|
379
|
+
"""Awaitable :meth:`close_by`."""
|
|
380
|
+
return await self._run(self.close_by, *args, **kwargs)
|
|
381
|
+
|
|
382
|
+
async def aclose_all_by(self, *args: Any, **kwargs: Any) -> None:
|
|
383
|
+
"""Awaitable :meth:`close_all_by`."""
|
|
384
|
+
return await self._run(self.close_all_by, *args, **kwargs)
|
pmt4api/_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 pmt4api 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": "Client", "manually": "Client", "expert": "Expert", "dealer": "Dealer", "mobile": "Mobile",
|
|
71
|
+
"web": "Web", "signal": "Signal", "gateway": "Gateway", "api": "Api"}
|
|
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")
|
pmt4api/bars.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""OHLC bars: the value type and the timeframes the MT4 server serves.
|
|
2
|
+
|
|
3
|
+
The server delivers each timeframe itself (it does not build it from minutes here), so only the nine MT4 timeframes
|
|
4
|
+
exist. A series is returned oldest first and holds completed bars: the bar that is still forming is not part of what
|
|
5
|
+
the server sends, so do not look for the current candle in it (use :meth:`Client.quote` for the live price).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Any, Dict, Tuple, Union
|
|
12
|
+
|
|
13
|
+
from .models import _time
|
|
14
|
+
|
|
15
|
+
#: The timeframes the MT4 server serves, in minutes.
|
|
16
|
+
TIMEFRAMES: Dict[str, int] = {"M1": 1, "M5": 5, "M15": 15, "M30": 30, "H1": 60, "H4": 240, "D1": 1440, "W1": 10080,
|
|
17
|
+
"MN1": 43200}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Bar:
|
|
22
|
+
time: datetime # start of the period, server time
|
|
23
|
+
open: float
|
|
24
|
+
high: float
|
|
25
|
+
low: float
|
|
26
|
+
close: float
|
|
27
|
+
volume: float # the number of ticks in the period (MT4 reports tick volume only)
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def _from_core(cls, b: Any) -> "Bar":
|
|
31
|
+
return cls(time=_time(b.Time), open=float(b.Open), high=float(b.High), low=float(b.Low),
|
|
32
|
+
close=float(b.Close), volume=float(b.Volume))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def timeframe_name(timeframe: Union[str, int]) -> str:
|
|
36
|
+
"""The canonical name (``"H1"``) of a timeframe given by name or by its length in minutes."""
|
|
37
|
+
if isinstance(timeframe, str):
|
|
38
|
+
key = timeframe.strip().upper()
|
|
39
|
+
if key in TIMEFRAMES:
|
|
40
|
+
return key
|
|
41
|
+
elif isinstance(timeframe, int) and not isinstance(timeframe, bool):
|
|
42
|
+
for name, minutes in TIMEFRAMES.items():
|
|
43
|
+
if minutes == timeframe:
|
|
44
|
+
return name
|
|
45
|
+
raise ValueError("unsupported timeframe %r; the MT4 server serves %s" % (timeframe, ", ".join(TIMEFRAMES)))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def timeframe_minutes(timeframe: Union[str, int]) -> int:
|
|
49
|
+
"""The length in minutes of a timeframe given by name or by its length in minutes."""
|
|
50
|
+
return TIMEFRAMES[timeframe_name(timeframe)]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class QuoteHistoryEvent:
|
|
55
|
+
"""A chunk of bars the server delivered for a bar download (see :meth:`Client.on_quote_history`)."""
|
|
56
|
+
symbol: str
|
|
57
|
+
timeframe: str
|
|
58
|
+
bars: Tuple[Bar, ...]
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def _from_core(cls, e: Any) -> "QuoteHistoryEvent":
|
|
62
|
+
return cls(symbol=str(e.Symbol), timeframe=str(e.Timeframe), bars=tuple(Bar._from_core(b) for b in (e.Bars or ())))
|
pmt4api/client.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""The Python client: a typed, translated layer over every public capability of the licensed wmt4api core.
|
|
2
|
+
|
|
3
|
+
The class is assembled from topic mixins (session, market data, account, risk, trading, events). Everything the C#
|
|
4
|
+
SDK offers is reachable here by a Pythonic name; ``pmt4api.COVERAGE`` lists, member by member, where each C# member
|
|
5
|
+
went, and a test fails when the core gains a member that has no entry.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import threading
|
|
10
|
+
from typing import Any, List, Optional, Sequence, Tuple
|
|
11
|
+
|
|
12
|
+
from . import _runtime
|
|
13
|
+
from ._account import _AccountMixin
|
|
14
|
+
from ._events import _EventsMixin
|
|
15
|
+
from ._market import _MarketMixin
|
|
16
|
+
from ._risk import _RiskMixin
|
|
17
|
+
from ._session import _SessionMixin
|
|
18
|
+
from ._trading import _TradingMixin
|
|
19
|
+
from ._util import placed_type as _placed, seconds_to_ms
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _net_encoding(name: str) -> Any:
|
|
23
|
+
"""The .NET ``Encoding`` for a Python codec name (``latin-1``, ``cp1251``, ``utf-8`` ...)."""
|
|
24
|
+
import codecs
|
|
25
|
+
import re
|
|
26
|
+
try:
|
|
27
|
+
canonical = codecs.lookup(name).name
|
|
28
|
+
except LookupError as exc:
|
|
29
|
+
raise ValueError("unknown comment_encoding %r" % name) from exc
|
|
30
|
+
import System # noqa: PLC0415 — available once the runtime is loaded
|
|
31
|
+
enc = System.Text.Encoding
|
|
32
|
+
try: # the single-byte code pages are not built in: register them (idempotent)
|
|
33
|
+
import clr # noqa: PLC0415
|
|
34
|
+
clr.AddReference("System.Text.Encoding.CodePages")
|
|
35
|
+
enc.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance)
|
|
36
|
+
except Exception: # noqa: BLE001 — an old runtime without the provider still knows Latin-1, ASCII and UTF-8
|
|
37
|
+
pass
|
|
38
|
+
attempts: List[Any] = []
|
|
39
|
+
m = re.fullmatch(r"cp(\d+)", canonical)
|
|
40
|
+
if m:
|
|
41
|
+
attempts.append(int(m.group(1)))
|
|
42
|
+
m = re.fullmatch(r"iso8859[-_]?(\d+)", canonical)
|
|
43
|
+
if m:
|
|
44
|
+
attempts.append("iso-8859-%s" % m.group(1))
|
|
45
|
+
attempts += [{"ascii": "us-ascii", "utf-8": "utf-8"}.get(canonical, canonical), name]
|
|
46
|
+
for attempt in attempts:
|
|
47
|
+
try:
|
|
48
|
+
return enc.GetEncoding(attempt)
|
|
49
|
+
except Exception: # noqa: BLE001
|
|
50
|
+
continue
|
|
51
|
+
raise ValueError("comment_encoding %r is not available in the .NET runtime" % name)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Client(_SessionMixin, _MarketMixin, _AccountMixin, _RiskMixin, _TradingMixin, _EventsMixin):
|
|
55
|
+
"""A connection to one MetaTrader 4 account through the licensed core.
|
|
56
|
+
|
|
57
|
+
>>> with Client(login, password, host, port, api_key="wk_...") as c:
|
|
58
|
+
... print(c.quote("EURUSDm"))
|
|
59
|
+
|
|
60
|
+
Credentials and library: ``api_key`` falls back to the ``WMT4API_KEY`` environment variable (then ``WMTAPI_KEY``)
|
|
61
|
+
and ``token_url`` to ``WMT4API_TOKEN_URL`` (the core reads these itself); ``dll_path`` falls back to
|
|
62
|
+
``WMT4API_DLL``, then to the core bundled in the wheel.
|
|
63
|
+
|
|
64
|
+
Timeouts are in seconds: ``connect_timeout`` (one connect attempt on one server), ``token_timeout`` (each licence
|
|
65
|
+
request), ``trade_timeout`` (a trade confirmation; at least 1 s), ``quote_timeout`` (waiting for a quote),
|
|
66
|
+
``history_timeout`` / ``bars_timeout`` (downloads), ``no_message_timeout`` (silence after which the session is
|
|
67
|
+
declared dead and closed: default 30 s, at least 10 s, ``0`` switches it off; the server answers a ping every few
|
|
68
|
+
seconds, so a longer silence means the link stopped carrying traffic). Every other timeout must be positive; the
|
|
69
|
+
quote, history and bars timeouts are the defaults of :meth:`quote`, :meth:`history` and :meth:`bars`. The
|
|
70
|
+
handshake with the broker has its own fixed 30 s limit, whatever ``connect_timeout`` says (that one bounds the
|
|
71
|
+
TCP dial of each server).
|
|
72
|
+
|
|
73
|
+
Reconnecting: ``auto_reconnect=True`` turns on the core's watchdog (exponential backoff, failover across the
|
|
74
|
+
primary server, ``servers`` and the broker's published list, re-subscription); tune it with ``reconnect_backoff``
|
|
75
|
+
(seconds) and ``reconnect_max_attempts`` (0 = until reconnected or ``disconnect()``). Do not poll ``connected``
|
|
76
|
+
and call ``connect()`` in a loop. ``servers`` are extra ``(host, port)`` access servers to fall back to.
|
|
77
|
+
|
|
78
|
+
``comment_encoding`` is the codepage order comments are written in (default ``latin-1``); ``placed_type``
|
|
79
|
+
records how orders are placed (``"manual"``, ``"expert"``, ``"dealer"``; a per-call ``placed_type`` overrides it).
|
|
80
|
+
|
|
81
|
+
Callbacks run on a dispatcher thread of this client, not on the thread that created it.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(self, login: int, password: str, host: str, port: int = 443, *,
|
|
85
|
+
api_key: Optional[str] = None, token_url: Optional[str] = None, dll_path: Optional[str] = None,
|
|
86
|
+
connect_timeout: Optional[float] = None, token_timeout: Optional[float] = None,
|
|
87
|
+
trade_timeout: Optional[float] = None, quote_timeout: Optional[float] = None,
|
|
88
|
+
history_timeout: Optional[float] = None, bars_timeout: Optional[float] = None,
|
|
89
|
+
no_message_timeout: Optional[float] = None,
|
|
90
|
+
auto_reconnect: bool = False, reconnect_backoff: Optional[float] = None,
|
|
91
|
+
reconnect_max_attempts: Optional[int] = None,
|
|
92
|
+
servers: Optional[Sequence[Tuple[str, int]]] = None, comment_encoding: str = "latin-1",
|
|
93
|
+
placed_type: Optional[str] = None):
|
|
94
|
+
core = _runtime.load(dll_path)
|
|
95
|
+
self._core = core
|
|
96
|
+
self._login = int(login)
|
|
97
|
+
self._host = host
|
|
98
|
+
self._port = int(port)
|
|
99
|
+
self._api = core.MT4API(self._login, password, host, self._port)
|
|
100
|
+
self._trader = core.OrderClient(self._api)
|
|
101
|
+
self._lock = threading.RLock()
|
|
102
|
+
self._bars_lock = threading.Lock()
|
|
103
|
+
self._handlers: List[Any] = []
|
|
104
|
+
api = self._api
|
|
105
|
+
if api_key:
|
|
106
|
+
api.ApiKey = api_key
|
|
107
|
+
if token_url:
|
|
108
|
+
api.TokenServiceUrl = token_url
|
|
109
|
+
for attr, value in (("ConnectTimeout", connect_timeout), ("TokenServiceTimeout", token_timeout),
|
|
110
|
+
("GetQuoteTimeoutMs", quote_timeout), ("DownloadOrderHistoryTimeoutMs", history_timeout),
|
|
111
|
+
("DownloadQuoteHistoryTimeout", bars_timeout), ("ReconnectBackoffMs", reconnect_backoff)):
|
|
112
|
+
ms = seconds_to_ms(value, attr)
|
|
113
|
+
if ms is not None:
|
|
114
|
+
setattr(api, attr, ms)
|
|
115
|
+
if no_message_timeout is not None:
|
|
116
|
+
if no_message_timeout == 0:
|
|
117
|
+
api.NoServerMessagesTimeout = 0 # switched off: only the operating system reports a dead link
|
|
118
|
+
else:
|
|
119
|
+
ms = seconds_to_ms(no_message_timeout, "no_message_timeout")
|
|
120
|
+
if ms < 10000: # the heartbeat is 4 s: a shorter limit would reconnect a healthy, quiet session
|
|
121
|
+
raise ValueError("no_message_timeout must be at least 10 seconds (or 0 to switch the watchdog off)")
|
|
122
|
+
api.NoServerMessagesTimeout = ms
|
|
123
|
+
if trade_timeout is not None:
|
|
124
|
+
ms = seconds_to_ms(trade_timeout, "trade_timeout")
|
|
125
|
+
if ms < 1000: # the core counts whole seconds: less would give up right after sending
|
|
126
|
+
raise ValueError("trade_timeout must be at least 1 second")
|
|
127
|
+
self._trader.TradeTimeout = ms
|
|
128
|
+
if reconnect_max_attempts is not None:
|
|
129
|
+
if int(reconnect_max_attempts) < 0:
|
|
130
|
+
raise ValueError("reconnect_max_attempts must be 0 (until reconnected) or a positive number")
|
|
131
|
+
api.ReconnectMaxAttempts = int(reconnect_max_attempts)
|
|
132
|
+
if auto_reconnect:
|
|
133
|
+
api.AutoReconnect = True
|
|
134
|
+
if placed_type:
|
|
135
|
+
self._trader.PlacedType = int(_placed(core.PlacedType, placed_type))
|
|
136
|
+
self._comment_encoding = comment_encoding
|
|
137
|
+
api.Encoding = _net_encoding(comment_encoding)
|
|
138
|
+
for h, p in (servers or ()):
|
|
139
|
+
self.add_server(h, int(p))
|
|
140
|
+
|
|
141
|
+
def __repr__(self) -> str:
|
|
142
|
+
return "<pmt4api.Client login=%d host=%s:%d connected=%s>" % (
|
|
143
|
+
self._login, self._host, self._port, self.connected)
|