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/models.py ADDED
@@ -0,0 +1,337 @@
1
+ """Plain Python value types returned by :class:`pmt4api.Client`.
2
+
3
+ They are immutable snapshots built from the core's objects, so they can be stored, pickled, compared and handed to other
4
+ threads freely. Times are naive ``datetime`` values in the broker's server time, exactly as the server reports them
5
+ (``None`` when the server leaves the field unset).
6
+
7
+ Fields are read strictly: if the core ever drops or renames one, conversion raises ``AttributeError`` instead of
8
+ returning a quietly wrong value (the binding tests in ``tests/integration`` pin every field used here).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import math
13
+ from dataclasses import dataclass
14
+ from datetime import datetime
15
+ from typing import Any, Optional, Tuple
16
+
17
+ _EPOCH = datetime(1970, 1, 1)
18
+
19
+ _PROFIT_MODES = {0: "Forex", 1: "CFD", 2: "Futures"}
20
+
21
+
22
+ def _time(value: Any) -> Optional[datetime]:
23
+ """A .NET ``DateTime`` (or ``datetime``) as a naive ``datetime``; the unset values (year 1, the Unix epoch) as ``None``."""
24
+ if value is None:
25
+ return None
26
+ if isinstance(value, datetime):
27
+ out = value.replace(tzinfo=None)
28
+ else:
29
+ year = int(value.Year)
30
+ if year <= 1:
31
+ return None
32
+ out = datetime(year, int(value.Month), int(value.Day), int(value.Hour), int(value.Minute),
33
+ int(value.Second), int(value.Millisecond) * 1000)
34
+ return None if out <= _EPOCH else out
35
+
36
+
37
+ def _name(value: Any) -> str:
38
+ """An enum value as its member name."""
39
+ return "" if value is None else str(value)
40
+
41
+
42
+ def _opt_float(value: Any) -> Optional[float]:
43
+ """A double where NaN means "not known"."""
44
+ f = float(value)
45
+ return None if math.isnan(f) else f
46
+
47
+
48
+ def _unset_if_zero(value: Any) -> Optional[float]:
49
+ f = float(value)
50
+ return None if f == 0.0 or math.isnan(f) else f
51
+
52
+
53
+ # ---- market data ------------------------------------------------------------------------------------------------
54
+
55
+ @dataclass(frozen=True)
56
+ class Quote:
57
+ """A price. ``time`` is the server time of the tick (use it to judge how old the price is); ``high`` and ``low``
58
+ are the session's range once the server has sent it, and ``None`` before that."""
59
+ symbol: str
60
+ bid: float
61
+ ask: float
62
+ time: Optional[datetime] = None
63
+ high: Optional[float] = None
64
+ low: Optional[float] = None
65
+
66
+ @property
67
+ def spread(self) -> float:
68
+ return self.ask - self.bid
69
+
70
+ @classmethod
71
+ def _from_core(cls, q: Any) -> "Quote":
72
+ return cls(symbol=str(q.Symbol), bid=float(q.Bid), ask=float(q.Ask), time=_time(q.Time),
73
+ high=_unset_if_zero(q.High), low=_unset_if_zero(q.Low))
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class SymbolInfo:
78
+ """A symbol's specification and trading rules, as the server publishes them.
79
+
80
+ ``contract_size`` and ``digits`` are reliable. ``currency`` is the currency the server lists for the symbol: the
81
+ profit currency of a CFD or futures symbol, the *base* currency of a Forex one; :attr:`profit_currency` gives the
82
+ currency profit is realised in for both. ``raw_tick_value`` and the two ``raw_*_tick_value`` fields are the
83
+ server's own and are often 0: never size a position from them (use :meth:`Client.tick_value` and
84
+ :meth:`Client.lots_for_risk`). ``min_lots`` / ``max_lots`` / ``lots_step`` are 0 when the account's group published
85
+ none.
86
+ """
87
+ name: str
88
+ digits: int
89
+ point: float
90
+ currency: str
91
+ profit_mode: str # Forex, CFD or Futures
92
+ contract_size: float
93
+ margin_divider: float
94
+ tick_size: float
95
+ execution: str # Request, Instant, Market or Exchange
96
+ security_index: int # the account group's security (symbol class) the volume rules are keyed on
97
+ min_lots: float
98
+ max_lots: float
99
+ lots_step: float
100
+ code: int # the feed code the server uses for this symbol
101
+ raw_tick_value: float = 0.0
102
+ raw_bid_tick_value: float = 0.0
103
+ raw_ask_tick_value: float = 0.0
104
+
105
+ @property
106
+ def profit_currency(self) -> str:
107
+ """The currency profit is realised in: the quote currency of a Forex symbol (the last three letters of its
108
+ six-letter name, the way the core prices it), the listed currency otherwise."""
109
+ if self.profit_mode == "Forex" and len(self.name) >= 6 and self.name[:6].isalpha():
110
+ return self.name[3:6].upper()
111
+ return self.currency
112
+
113
+ @classmethod
114
+ def _from_core(cls, s: Any) -> "SymbolInfo":
115
+ digits = int(s.Digits)
116
+ mode = int(s.ProfitMode)
117
+ return cls(name=str(s.Name), digits=digits, point=10.0 ** -digits, currency=str(s.Currency or ""),
118
+ profit_mode=_PROFIT_MODES.get(mode, "Mode%d" % mode), contract_size=float(s.ContractSize),
119
+ margin_divider=float(s.MarginDivider), tick_size=float(s.TickSize), execution=_name(s.ExecutionType),
120
+ security_index=int(s.SecurityIndex), min_lots=float(s.MinLot), max_lots=float(s.MaxLot),
121
+ lots_step=float(s.LotStep), code=int(s.Code), raw_tick_value=float(s.TickValue),
122
+ raw_bid_tick_value=float(s.BidTickValue), raw_ask_tick_value=float(s.AskTickValue))
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class SymbolGroup:
127
+ """One of the broker's configured symbol groups (its name and description)."""
128
+ name: str
129
+ description: str
130
+
131
+ @classmethod
132
+ def _from_core(cls, g: Any) -> "SymbolGroup":
133
+ return cls(name=str(g.Name or ""), description=str(g.Description or ""))
134
+
135
+
136
+ # ---- the account ------------------------------------------------------------------------------------------------
137
+
138
+ @dataclass(frozen=True)
139
+ class AccountInfo:
140
+ """The account at the moment it was read. ``equity``, ``profit``, ``margin``, ``free_margin`` and ``margin_level``
141
+ are derived on the client from the live quotes (the server does not push them), exactly as the terminal does.
142
+ ``priced_from`` is the server time of the oldest tick those figures were priced from (``None`` with no open
143
+ position): minutes old while the market is open means the feed is not delivering — do not trust the equity."""
144
+ login: int
145
+ name: str
146
+ company: str
147
+ group: str
148
+ currency: str
149
+ leverage: int
150
+ balance: float
151
+ credit: float
152
+ equity: float
153
+ profit: float
154
+ margin: float
155
+ free_margin: float
156
+ margin_level: float
157
+ account_type: str # Real, Demo or Contest (a demo server can stamp Real: it is the server's class)
158
+ is_investor: bool = False
159
+ priced_from: Optional[datetime] = None
160
+
161
+
162
+ @dataclass(frozen=True)
163
+ class AccountSummary:
164
+ """Realized totals over a history range (from the closed-order ledger, not the floating P/L of open positions)."""
165
+ balance: float
166
+ credit: float
167
+ realized_profit: float # closed trades: profit + commission + swap
168
+ deposit: float
169
+ withdrawal: float # negative or zero
170
+
171
+ @classmethod
172
+ def _from_core(cls, s: Any) -> "AccountSummary":
173
+ return cls(balance=float(s.Balance), credit=float(s.Credit), realized_profit=float(s.RealizedProfit),
174
+ deposit=float(s.Deposit), withdrawal=float(s.Withdrawal))
175
+
176
+
177
+ @dataclass(frozen=True)
178
+ class EquityPoint:
179
+ """One sample of the balance and equity curve. ``balance_change`` is the change of balance since the first point
180
+ (deposits and withdrawals included, so it is not a trading result: use :class:`AccountSummary`)."""
181
+ time: Optional[datetime]
182
+ balance: float
183
+ equity: float
184
+ balance_change: float
185
+ unrealized_pl: float
186
+ balance_drawdown_raw: float
187
+ balance_drawdown_relative: float
188
+ equity_drawdown_raw: float
189
+ equity_drawdown_relative: float
190
+
191
+ @classmethod
192
+ def _from_core(cls, p: Any) -> "EquityPoint":
193
+ return cls(time=_time(p.Time), balance=float(p.Balance), equity=float(p.Equity),
194
+ balance_change=float(p.BalanceChange), unrealized_pl=float(p.UnrealizedPL),
195
+ balance_drawdown_raw=float(p.BalanceDrawdownRaw),
196
+ balance_drawdown_relative=float(p.BalanceDrawdownRelative),
197
+ equity_drawdown_raw=float(p.EquityDrawdownRaw),
198
+ equity_drawdown_relative=float(p.EquityDrawdownRelative))
199
+
200
+
201
+ @dataclass(frozen=True)
202
+ class LotSizing:
203
+ """The answer of :meth:`Client.lots_for_risk`. If ``ok`` is false, ``lots`` is 0 and ``reason`` says why: do not
204
+ trade it. ``warnings`` can accompany a true ``ok`` (most often that the broker's minimum volume risks more than
205
+ was asked)."""
206
+ ok: bool
207
+ lots: float
208
+ expected_loss_at_stop: float
209
+ risk_requested: float
210
+ minimum_trade_cost: float
211
+ ratio: float
212
+ reason: str
213
+ warnings: Tuple[str, ...]
214
+
215
+ @classmethod
216
+ def _from_core(cls, r: Any) -> "LotSizing":
217
+ return cls(ok=bool(r.Ok), lots=float(r.Lots), expected_loss_at_stop=float(r.ExpectedLossAtStop),
218
+ risk_requested=float(r.RiskRequested), minimum_trade_cost=float(r.MinimumTradeCost),
219
+ ratio=float(r.Ratio), reason=str(r.Reason or ""),
220
+ warnings=tuple(str(w) for w in (r.Warnings or ())))
221
+
222
+
223
+ # ---- orders ------------------------------------------------------------------------------------------------------
224
+
225
+ _MARKET = ("Buy", "Sell")
226
+ _PENDING = ("BuyLimit", "SellLimit", "BuyStop", "SellStop")
227
+
228
+
229
+ @dataclass(frozen=True)
230
+ class Order:
231
+ """One MT4 order: an open position, a pending order, a closed order of the history, or a balance / credit entry.
232
+
233
+ MT4 keeps one record for all of them. ``order_type`` is ``Buy`` or ``Sell`` for a market position (open, or closed
234
+ when ``close_time`` is set), ``BuyLimit`` / ``SellLimit`` / ``BuyStop`` / ``SellStop`` for a pending order, and
235
+ ``Balance`` / ``Credit`` for a ledger entry (the amount is in ``profit``, ``symbol`` is empty).
236
+ ``magic`` is read back from the server; the core cannot set it on an order (see :meth:`Client.buy`).
237
+ """
238
+ ticket: int
239
+ symbol: str
240
+ order_type: str
241
+ lots: float
242
+ open_time: Optional[datetime]
243
+ open_price: float
244
+ stop_loss: float
245
+ take_profit: float
246
+ close_time: Optional[datetime]
247
+ close_price: float
248
+ expiration: Optional[datetime]
249
+ magic: int
250
+ swap: float
251
+ commission: float
252
+ comment: str
253
+ profit: float
254
+ rate_open: float = 0.0
255
+ rate_close: float = 0.0
256
+ rate_margin: float = 0.0
257
+
258
+ @property
259
+ def is_market(self) -> bool:
260
+ """A market position (``Buy`` or ``Sell``)."""
261
+ return self.order_type in _MARKET
262
+
263
+ @property
264
+ def is_pending(self) -> bool:
265
+ """A pending order (a limit or a stop)."""
266
+ return self.order_type in _PENDING
267
+
268
+ @property
269
+ def is_capital_flow(self) -> bool:
270
+ """A balance or credit entry (deposit, withdrawal, bonus), not a trade."""
271
+ return self.order_type in ("Balance", "Credit")
272
+
273
+ @property
274
+ def is_buy(self) -> bool:
275
+ """The order is on the buy side (market or pending)."""
276
+ return self.order_type.startswith("Buy")
277
+
278
+ @classmethod
279
+ def _from_core(cls, o: Any) -> "Order":
280
+ return cls(ticket=int(o.Ticket), symbol=str(o.Symbol or ""), order_type=_name(o.Type), lots=float(o.Lots),
281
+ open_time=_time(o.OpenTime), open_price=float(o.OpenPrice), stop_loss=float(o.StopLoss),
282
+ take_profit=float(o.TakeProfit), close_time=_time(o.CloseTime), close_price=float(o.ClosePrice),
283
+ expiration=_time(o.ExpirationTime), magic=int(o.ExpertId), swap=float(o.Swap),
284
+ commission=float(o.Commission), comment=str(o.Comment or ""), profit=float(o.Profit),
285
+ rate_open=float(o.RateOpen), rate_close=float(o.RateClose), rate_margin=float(o.RateMargin))
286
+
287
+
288
+ @dataclass(frozen=True)
289
+ class OrderUpdate:
290
+ """A change the server pushed: ``action`` is one of ``PositionOpen``, ``PositionClose``, ``PositionModify``,
291
+ ``PendingOpen``, ``PendingClose``, ``PendingModify``, ``PendingFill``, ``Balance``, ``Credit`` (a pending order that
292
+ fills arrives as ``PositionOpen``)."""
293
+ action: str
294
+ order: Order
295
+
296
+ @classmethod
297
+ def _from_core(cls, u: Any) -> "OrderUpdate":
298
+ return cls(action=_name(u.Action), order=Order._from_core(u.Order))
299
+
300
+
301
+ @dataclass(frozen=True)
302
+ class OrderProgress:
303
+ """Progress of a trade request, as the request id (the integer :meth:`Client.buy_async` and friends return)
304
+ advances through the server: ``stage`` is ``InProcess``, ``Accepted``, ``Opened``, ``Closed``, ``Modified``,
305
+ ``PendingDeleted``, ``ClosedBy``, ``MultipleClosedBy``, ``Price`` or ``Exception`` (``Rejected`` and ``Timeout`` are
306
+ defined by the core but never raised by it). ``order`` is set where the server returned one, ``error`` for an
307
+ ``Exception`` stage (a pmt4api error such as :class:`ServerError`)."""
308
+ request_id: int
309
+ stage: str
310
+ order: Optional[Order] = None
311
+ error: Optional[BaseException] = None
312
+
313
+
314
+ @dataclass(frozen=True)
315
+ class ConnectEvent:
316
+ """A session started (``connected`` true: the first connect and every reconnect) or ended (``connected`` false;
317
+ ``error`` is the reason for a drop and ``None`` when you closed the client yourself)."""
318
+ connected: bool
319
+ error: Optional[BaseException] = None
320
+
321
+
322
+ # ---- offline files -----------------------------------------------------------------------------------------------
323
+
324
+ @dataclass(frozen=True)
325
+ class ServerRecord:
326
+ """A broker of a ``servers.ini`` file: its name, company, demo flag, main host and (host, port) access servers."""
327
+ name: str
328
+ company: str
329
+ is_demo: bool
330
+ main_host: str
331
+ access: Tuple[Tuple[str, int], ...]
332
+
333
+ @classmethod
334
+ def _from_core(cls, r: Any) -> "ServerRecord":
335
+ return cls(name=str(r.Name or ""), company=str(r.Company or ""), is_demo=bool(r.IsDemo),
336
+ main_host=str(r.MainHost or ""),
337
+ access=tuple((str(a.Item1), int(a.Item2)) for a in (r.Access or ())))
pmt4api/py.typed ADDED
File without changes
pmt4api/servers.py ADDED
@@ -0,0 +1,40 @@
1
+ """Offline helpers that do not need a connected client."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from typing import Iterable, List, Union
6
+
7
+ from . import _runtime
8
+ from ._util import translated
9
+ from .models import ServerRecord
10
+
11
+
12
+ @translated
13
+ def load_servers_ini(source: Union[str, "os.PathLike[str]", bytes]) -> List[ServerRecord]:
14
+ """The brokers in a terminal's ``servers.ini``: each with its name, company, demo flag, main host and
15
+ ``(host, port)`` access servers. Offline: reads only the file (a path, or the file's bytes)."""
16
+ core = _runtime.load()
17
+ if isinstance(source, (bytes, bytearray)):
18
+ import System # noqa: PLC0415
19
+ records = core.MT4API.LoadServersIni(System.Array[System.Byte](bytes(source)))
20
+ else:
21
+ records = core.MT4API.LoadServersIni(os.fspath(source))
22
+ return [ServerRecord._from_core(r) for r in (records or ())]
23
+
24
+
25
+ @translated
26
+ def save_servers_ini(records: Iterable[ServerRecord]) -> bytes:
27
+ """The bytes of a ``servers.ini`` file holding ``records`` (the inverse of :func:`load_servers_ini`)."""
28
+ core = _runtime.load()
29
+ import System # noqa: PLC0415
30
+ Access = System.ValueTuple[System.String, System.Int32]
31
+ built = []
32
+ for rec in records:
33
+ r = core.Mt4Server()
34
+ r.Name = rec.name
35
+ r.Company = rec.company
36
+ r.IsDemo = bool(rec.is_demo)
37
+ r.MainHost = rec.main_host
38
+ r.Access = System.Array[Access]([Access(host, int(port)) for host, port in rec.access])
39
+ built.append(r)
40
+ return bytes(core.MT4API.SaveServersIni(System.Array[core.Mt4Server](built)))