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