PythonMetaTrader5 2.3.2__py3-none-any.whl → 3.0.1__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.
- PythonMetaTrader5/__init__.py +73 -535
- PythonMetaTrader5/adapters/__init__.py +1 -0
- PythonMetaTrader5/adapters/mt5_mapping.py +92 -0
- PythonMetaTrader5/adapters/mt5_session.py +48 -0
- PythonMetaTrader5/adapters/mt5_terminal.py +224 -0
- PythonMetaTrader5/adapters/system_clock.py +10 -0
- PythonMetaTrader5/application/__init__.py +1 -0
- PythonMetaTrader5/application/batch.py +60 -0
- PythonMetaTrader5/application/entries.py +163 -0
- PythonMetaTrader5/application/execution.py +108 -0
- PythonMetaTrader5/application/magic_scope.py +75 -0
- PythonMetaTrader5/application/ports.py +75 -0
- PythonMetaTrader5/application/trader.py +275 -0
- PythonMetaTrader5/connection.py +52 -0
- PythonMetaTrader5/domain/__init__.py +1 -0
- PythonMetaTrader5/domain/amounts.py +33 -0
- PythonMetaTrader5/domain/errors.py +53 -0
- PythonMetaTrader5/domain/inputs.py +106 -0
- PythonMetaTrader5/domain/market.py +91 -0
- PythonMetaTrader5/domain/orders.py +64 -0
- PythonMetaTrader5/domain/requests.py +32 -0
- PythonMetaTrader5/domain/retcodes.py +81 -0
- PythonMetaTrader5/domain/stops.py +85 -0
- PythonMetaTrader5/domain/symbol.py +158 -0
- PythonMetaTrader5/legacy/__init__.py +1 -0
- PythonMetaTrader5/legacy/arguments.py +52 -0
- PythonMetaTrader5/legacy/broker.py +284 -0
- PythonMetaTrader5/legacy/mt5_namespace.py +56 -0
- PythonMetaTrader5/legacy/views.py +123 -0
- PythonMetaTrader5/py.typed +0 -0
- PythonMetaTrader5/result.py +41 -0
- pythonmetatrader5-3.0.1.dist-info/METADATA +492 -0
- pythonmetatrader5-3.0.1.dist-info/RECORD +36 -0
- {pythonmetatrader5-2.3.2.dist-info → pythonmetatrader5-3.0.1.dist-info}/WHEEL +1 -1
- pythonmetatrader5-3.0.1.dist-info/licenses/LICENSE +16 -0
- pythonmetatrader5-2.3.2.dist-info/METADATA +0 -267
- pythonmetatrader5-2.3.2.dist-info/RECORD +0 -5
- {pythonmetatrader5-2.3.2.dist-info → pythonmetatrader5-3.0.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""2.x `Broker` facade over the 3.x Trader.
|
|
2
|
+
|
|
3
|
+
Names, arguments and return shapes follow 2.x so old scripts keep running. The
|
|
4
|
+
behaviour that made 2.x dangerous is NOT preserved: no endless retry loops and
|
|
5
|
+
no Ctrl+E keystrokes into whichever window has focus. See MIGRATION.md.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import warnings
|
|
12
|
+
from typing import Any, ClassVar
|
|
13
|
+
|
|
14
|
+
from PythonMetaTrader5.adapters.mt5_terminal import MetaTrader5Terminal
|
|
15
|
+
from PythonMetaTrader5.adapters.system_clock import SystemClock
|
|
16
|
+
from PythonMetaTrader5.application.ports import Clock, Credentials, Terminal, parse_login
|
|
17
|
+
from PythonMetaTrader5.application.trader import Trader
|
|
18
|
+
from PythonMetaTrader5.domain.errors import ErrorKind, TradeError
|
|
19
|
+
from PythonMetaTrader5.domain.inputs import ANY_MAGIC, positive_number
|
|
20
|
+
from PythonMetaTrader5.domain.market import PendingKind, TradeReceipt
|
|
21
|
+
from PythonMetaTrader5.domain.orders import Side
|
|
22
|
+
from PythonMetaTrader5.legacy import views
|
|
23
|
+
from PythonMetaTrader5.legacy.arguments import legacy_stop, no_ticket, number
|
|
24
|
+
from PythonMetaTrader5.legacy.views import LegacyReceipt
|
|
25
|
+
from PythonMetaTrader5.result import Err, Ok, Result
|
|
26
|
+
|
|
27
|
+
log = logging.getLogger("PythonMetaTrader5.legacy")
|
|
28
|
+
|
|
29
|
+
_RELOGIN_DELAY_SECONDS = 1.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Broker:
|
|
33
|
+
RetCodes: ClassVar[dict[int, str]] = {
|
|
34
|
+
10027: "Enable Algo Trading in MetaTrader5 app",
|
|
35
|
+
10018: "Market closed",
|
|
36
|
+
10016: "Wrong SL",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
log: int | str,
|
|
42
|
+
password: Any,
|
|
43
|
+
server: Any,
|
|
44
|
+
*,
|
|
45
|
+
terminal: Terminal | None = None,
|
|
46
|
+
clock: Clock | None = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
warnings.warn(
|
|
49
|
+
"Broker is deprecated; use PythonMetaTrader5.connect() (see MIGRATION.md)",
|
|
50
|
+
DeprecationWarning,
|
|
51
|
+
stacklevel=2,
|
|
52
|
+
)
|
|
53
|
+
self.log = log
|
|
54
|
+
self.server = str(server)
|
|
55
|
+
account = parse_login(log)
|
|
56
|
+
if account is None:
|
|
57
|
+
# Programming error, raised as 2.x did for non-numeric logins; silently
|
|
58
|
+
# coercing True or 12345678.9 would trade on another account.
|
|
59
|
+
raise ValueError(f"login must be a positive integer, got {log!r}")
|
|
60
|
+
self._credentials = Credentials(account, str(password), self.server)
|
|
61
|
+
self._clock = clock or SystemClock()
|
|
62
|
+
self._terminal = terminal or _load_terminal()
|
|
63
|
+
self._trader = Trader(self._terminal, self._clock) if self._terminal else None
|
|
64
|
+
self._connected = self.login()
|
|
65
|
+
|
|
66
|
+
def login(self, ToEffect: bool = False) -> bool:
|
|
67
|
+
"""Log in once, or keep trying every second until it works when ``ToEffect``."""
|
|
68
|
+
if self._terminal is None:
|
|
69
|
+
return False
|
|
70
|
+
while True:
|
|
71
|
+
result = self._terminal.connect(self._credentials)
|
|
72
|
+
if isinstance(result, Ok):
|
|
73
|
+
log.info("%s: logged in to %s", self.log, self.server)
|
|
74
|
+
self._connected = True
|
|
75
|
+
return True
|
|
76
|
+
# The adapter released the session, so nothing may trade until a login works.
|
|
77
|
+
self._connected = False
|
|
78
|
+
log.error("%s: %s", self.log, result.error)
|
|
79
|
+
if not ToEffect:
|
|
80
|
+
return False
|
|
81
|
+
self._clock.sleep(_RELOGIN_DELAY_SECONDS)
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------- queries
|
|
84
|
+
|
|
85
|
+
def GetPositionsBySymbol(self, symbol: str, as_dict: bool = False) -> list[Any]:
|
|
86
|
+
return self.GetPositions(symbol, as_dict=as_dict)
|
|
87
|
+
|
|
88
|
+
def GetPositions(
|
|
89
|
+
self, symbol: str | None = None, *, ticket: int | None = None, as_dict: bool = False
|
|
90
|
+
) -> list[Any]:
|
|
91
|
+
trader = self._ready()
|
|
92
|
+
if trader is None:
|
|
93
|
+
return []
|
|
94
|
+
# 2.x: a ticket selects that position whatever symbol was passed.
|
|
95
|
+
listed = trader.positions(None if ticket is not None else symbol, ticket=ticket)
|
|
96
|
+
if isinstance(listed, Err):
|
|
97
|
+
log.error("%s: %s", self.log, listed.error)
|
|
98
|
+
return []
|
|
99
|
+
convert = views.as_dict if as_dict else views.as_object
|
|
100
|
+
return [convert(p) for p in listed.value]
|
|
101
|
+
|
|
102
|
+
def GetPosition(
|
|
103
|
+
self, symbol: str | None = None, *, ticket: int | None = None, as_dict: bool = False
|
|
104
|
+
) -> Any:
|
|
105
|
+
found = self.GetPositions(symbol, ticket=ticket, as_dict=as_dict)
|
|
106
|
+
return found[0] if found else None
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------- entries
|
|
109
|
+
|
|
110
|
+
def OpenPosition(
|
|
111
|
+
self,
|
|
112
|
+
symbol: str,
|
|
113
|
+
side: str,
|
|
114
|
+
volume: float,
|
|
115
|
+
*,
|
|
116
|
+
sl: float | None = None,
|
|
117
|
+
tp: float | None = None,
|
|
118
|
+
price: float | None = None,
|
|
119
|
+
comment: str | None = None,
|
|
120
|
+
ticket: int | None = None,
|
|
121
|
+
sl_type: str = "absolute",
|
|
122
|
+
tp_type: str = "absolute",
|
|
123
|
+
) -> LegacyReceipt | None:
|
|
124
|
+
trader = self._ready()
|
|
125
|
+
if trader is None or not no_ticket(ticket):
|
|
126
|
+
return None
|
|
127
|
+
stops = legacy_stop(number(sl), sl_type), legacy_stop(number(tp), tp_type)
|
|
128
|
+
if any(isinstance(stop, Err) for stop in stops):
|
|
129
|
+
return None
|
|
130
|
+
stop_loss, take_profit = (s.value for s in stops if isinstance(s, Ok))
|
|
131
|
+
result = trader.open_position(
|
|
132
|
+
symbol,
|
|
133
|
+
side,
|
|
134
|
+
number(volume),
|
|
135
|
+
sl=stop_loss,
|
|
136
|
+
tp=take_profit,
|
|
137
|
+
price=number(price),
|
|
138
|
+
comment=comment,
|
|
139
|
+
)
|
|
140
|
+
return self._receipt(result, f"{side} {symbol}")
|
|
141
|
+
|
|
142
|
+
def Buy(self, symbol: str, volume: float, price: float | None = None, **kw: Any) -> Any:
|
|
143
|
+
return self.OpenPosition(symbol, "buy", volume, price=price, **kw)
|
|
144
|
+
|
|
145
|
+
def BuySL(
|
|
146
|
+
self, symbol: str, volume: float, sl: float, price: float | None = None, **kw: Any
|
|
147
|
+
) -> Any:
|
|
148
|
+
return self.OpenPosition(symbol, "buy", volume, sl=sl, price=price, **kw)
|
|
149
|
+
|
|
150
|
+
def Sell(self, symbol: str, volume: float, price: float | None = None, **kw: Any) -> Any:
|
|
151
|
+
return self.OpenPosition(symbol, "sell", volume, price=price, **kw)
|
|
152
|
+
|
|
153
|
+
def SellSL(
|
|
154
|
+
self, symbol: str, volume: float, sl: float, price: float | None = None, **kw: Any
|
|
155
|
+
) -> Any:
|
|
156
|
+
return self.OpenPosition(symbol, "sell", volume, sl=sl, price=price, **kw)
|
|
157
|
+
|
|
158
|
+
def BuyLimit(
|
|
159
|
+
self,
|
|
160
|
+
symbol: str,
|
|
161
|
+
volume: float,
|
|
162
|
+
stoplimit: float,
|
|
163
|
+
price: float | None = None,
|
|
164
|
+
comment: str | None = None,
|
|
165
|
+
ticket: int | None = None,
|
|
166
|
+
) -> LegacyReceipt | None:
|
|
167
|
+
return self._limit(
|
|
168
|
+
Side.BUY, symbol, volume, stoplimit, price=price, comment=comment, ticket=ticket
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def SellLimit(
|
|
172
|
+
self,
|
|
173
|
+
symbol: str,
|
|
174
|
+
volume: float,
|
|
175
|
+
stoplimit: float,
|
|
176
|
+
price: float | None = None,
|
|
177
|
+
comment: str | None = None,
|
|
178
|
+
ticket: int | None = None,
|
|
179
|
+
) -> LegacyReceipt | None:
|
|
180
|
+
return self._limit(
|
|
181
|
+
Side.SELL, symbol, volume, stoplimit, price=price, comment=comment, ticket=ticket
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# ------------------------------------------------------------------ exits
|
|
185
|
+
|
|
186
|
+
def Close(
|
|
187
|
+
self, symbol: str | None, *, comment: str | None = None, ticket: int | None = None
|
|
188
|
+
) -> bool:
|
|
189
|
+
"""True when at least one position closed and none failed."""
|
|
190
|
+
trader = self._ready()
|
|
191
|
+
if trader is None:
|
|
192
|
+
return False
|
|
193
|
+
if symbol is None and ticket is None:
|
|
194
|
+
# 2.x crashed here without closing anything; 3.x would close the account.
|
|
195
|
+
log.error("%s: Close needs a symbol or ticket", self.log)
|
|
196
|
+
return False
|
|
197
|
+
# 2.x closed every magic; the 3.x own-magic default would skip them.
|
|
198
|
+
result = trader.close_positions(symbol, ticket=ticket, magic=ANY_MAGIC, comment=comment)
|
|
199
|
+
if isinstance(result, Err):
|
|
200
|
+
log.error("%s: closing %s: %s", self.log, symbol, result.error)
|
|
201
|
+
return False
|
|
202
|
+
if not result.value:
|
|
203
|
+
log.info("%s: there are no positions to close on %s", self.log, symbol)
|
|
204
|
+
return bool(result.value)
|
|
205
|
+
|
|
206
|
+
def CancelOrders(self, symbol: str) -> None:
|
|
207
|
+
trader = self._ready()
|
|
208
|
+
if trader is None:
|
|
209
|
+
return
|
|
210
|
+
if not symbol:
|
|
211
|
+
# 2.x matched orders against the symbol and so cancelled nothing here.
|
|
212
|
+
log.error("%s: CancelOrders needs a symbol", self.log)
|
|
213
|
+
return
|
|
214
|
+
# 2.x only ever cancelled limit orders; stop orders stay untouched.
|
|
215
|
+
result = trader.cancel_pending_orders(symbol, magic=ANY_MAGIC, kind=PendingKind.LIMIT)
|
|
216
|
+
if isinstance(result, Err):
|
|
217
|
+
log.error("%s: cancelling orders on %s: %s", self.log, symbol, result.error)
|
|
218
|
+
|
|
219
|
+
def Disconnect(self) -> None:
|
|
220
|
+
if self._trader is not None:
|
|
221
|
+
self._trader.shutdown()
|
|
222
|
+
self._connected = False
|
|
223
|
+
log.info("Disconnected from MT5")
|
|
224
|
+
|
|
225
|
+
# ---------------------------------------------------------------- helpers
|
|
226
|
+
|
|
227
|
+
def _limit(
|
|
228
|
+
self,
|
|
229
|
+
side: Side,
|
|
230
|
+
symbol: str,
|
|
231
|
+
volume: float,
|
|
232
|
+
stoplimit: float,
|
|
233
|
+
*,
|
|
234
|
+
price: float | None,
|
|
235
|
+
comment: str | None,
|
|
236
|
+
ticket: int | None,
|
|
237
|
+
) -> LegacyReceipt | None:
|
|
238
|
+
trader = self._ready()
|
|
239
|
+
if trader is None or self._terminal is None or not no_ticket(ticket):
|
|
240
|
+
return None
|
|
241
|
+
volume = number(volume)
|
|
242
|
+
if price is not None:
|
|
243
|
+
log.error("%s: price has to be None for limit orders", self.log)
|
|
244
|
+
return None
|
|
245
|
+
# Checked before the crossing test below: a level of 0 always "crosses"
|
|
246
|
+
# for a sell and would become a market order.
|
|
247
|
+
level = positive_number(number(stoplimit), ErrorKind.INVALID_PRICE, "stoplimit")
|
|
248
|
+
if isinstance(level, Err):
|
|
249
|
+
log.error("%s: %s", self.log, level.error)
|
|
250
|
+
return None
|
|
251
|
+
stoplimit = level.value
|
|
252
|
+
tick = self._terminal.tick(symbol)
|
|
253
|
+
if isinstance(tick, Err):
|
|
254
|
+
log.error("%s: %s", self.log, tick.error)
|
|
255
|
+
return None
|
|
256
|
+
quote = tick.value.entry_price(side)
|
|
257
|
+
# 2.x semantics: a limit already reached by the market is filled at market.
|
|
258
|
+
crossed = quote <= stoplimit if side is Side.BUY else quote >= stoplimit
|
|
259
|
+
if crossed:
|
|
260
|
+
result = trader.open_position(symbol, side, volume, comment=comment)
|
|
261
|
+
else:
|
|
262
|
+
result = trader.place_limit_order(symbol, side, volume, stoplimit, comment=comment)
|
|
263
|
+
return self._receipt(result, f"{side.value} limit {symbol} @ {stoplimit}")
|
|
264
|
+
|
|
265
|
+
def _ready(self) -> Trader | None:
|
|
266
|
+
if self._trader is None or not self._connected:
|
|
267
|
+
log.error("%s: not connected to MetaTrader 5; call login()", self.log)
|
|
268
|
+
return None
|
|
269
|
+
return self._trader
|
|
270
|
+
|
|
271
|
+
def _receipt(self, result: Result[TradeReceipt, TradeError], what: str) -> LegacyReceipt | None:
|
|
272
|
+
if isinstance(result, Ok):
|
|
273
|
+
log.info("%s: %s done", self.log, what)
|
|
274
|
+
return views.as_receipt(result.value)
|
|
275
|
+
log.error("%s: %s failed: %s", self.log, what, result.error)
|
|
276
|
+
return views.as_receipt(result.error.receipt)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _load_terminal() -> Terminal | None:
|
|
280
|
+
loaded = MetaTrader5Terminal.load()
|
|
281
|
+
if isinstance(loaded, Err):
|
|
282
|
+
log.error("%s", loaded.error)
|
|
283
|
+
return None
|
|
284
|
+
return loaded.value
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""The MetaTrader5 namespace, re-exported lazily for 2.x scripts (ADR 0010).
|
|
2
|
+
|
|
3
|
+
2.x ran ``from MetaTrader5 import *`` in its ``__init__``, and its own README
|
|
4
|
+
called ``symbol_info("EURUSD")`` after ``from PythonMetaTrader5 import *``.
|
|
5
|
+
The package ``__getattr__`` (PEP 562) delegates here, so MetaTrader5 is
|
|
6
|
+
imported only when one of its names is first used: importing the library, or
|
|
7
|
+
only its domain types, never depends on the MT5 wheel.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import warnings
|
|
13
|
+
from types import ModuleType
|
|
14
|
+
|
|
15
|
+
from PythonMetaTrader5.adapters.mt5_session import import_metatrader5, serialized
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def public_names() -> list[str]:
|
|
19
|
+
module = _module_or_warn()
|
|
20
|
+
if module is None:
|
|
21
|
+
return []
|
|
22
|
+
return sorted(n for n in dir(module) if not n.startswith("_"))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def resolve(name: str) -> object:
|
|
26
|
+
"""One MT5 name; functions are wrapped to hold the adapter's session lock.
|
|
27
|
+
|
|
28
|
+
Without the lock a direct ``last_error()`` from a 2.x-style script could run
|
|
29
|
+
between the adapter's ``order_send`` and its own ``last_error()`` read, and
|
|
30
|
+
a lost order would be reported as "never sent" (ADR 0009). Only names not
|
|
31
|
+
defined by the package reach this: a module __getattr__ runs on misses only.
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
module = None if name.startswith("_") else import_metatrader5()
|
|
35
|
+
except ImportError as error:
|
|
36
|
+
# AttributeError, not ImportError: hasattr(), getattr(.., default) and
|
|
37
|
+
# IDE tooling must keep working on a broken install; the cause is kept.
|
|
38
|
+
raise AttributeError(f"PythonMetaTrader5 has no attribute {name!r}: {error}") from error
|
|
39
|
+
if module is None or not hasattr(module, name):
|
|
40
|
+
raise AttributeError(f"module 'PythonMetaTrader5' has no attribute {name!r}")
|
|
41
|
+
value = getattr(module, name)
|
|
42
|
+
if callable(value) and not isinstance(value, type):
|
|
43
|
+
return serialized(value)
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _module_or_warn() -> ModuleType | None:
|
|
48
|
+
try:
|
|
49
|
+
return import_metatrader5()
|
|
50
|
+
except ImportError as error:
|
|
51
|
+
# A star import of a broken install keeps our own names (connect() then
|
|
52
|
+
# reports the failure as Err) and says loudly why the MT5 names are gone.
|
|
53
|
+
# stacklevel 4: past this helper, public_names and the package
|
|
54
|
+
# __getattr__, so the warning names the user's import line.
|
|
55
|
+
warnings.warn(f"{error}; its names are not re-exported", RuntimeWarning, stacklevel=4)
|
|
56
|
+
return None
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Positions and receipts in the exact shapes the 2.x API returned.
|
|
2
|
+
|
|
3
|
+
2.x handed out raw MetaTrader5 objects (TradePosition, OrderSendResult), so
|
|
4
|
+
scripts read any field, index them and call ``_asdict()``. NamedTuples with the
|
|
5
|
+
same field names in the same order keep all three working.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, NamedTuple
|
|
11
|
+
|
|
12
|
+
from PythonMetaTrader5.domain.market import Position, TradeReceipt
|
|
13
|
+
from PythonMetaTrader5.domain.orders import Side
|
|
14
|
+
|
|
15
|
+
# MT5 POSITION_TYPE_BUY / POSITION_TYPE_SELL: 2.x callers compare `p.type == 0`.
|
|
16
|
+
_POSITION_TYPE = {Side.BUY: 0, Side.SELL: 1}
|
|
17
|
+
|
|
18
|
+
# The keys 2.x `as_dict=True` built by hand. A projection of as_object() rather
|
|
19
|
+
# than a second mapping, so the shared fields cannot drift apart; only sl/tp
|
|
20
|
+
# differ on purpose (None instead of 0.0, as in 2.x).
|
|
21
|
+
_V2_DICT_KEYS = (
|
|
22
|
+
"ticket",
|
|
23
|
+
"symbol",
|
|
24
|
+
"type",
|
|
25
|
+
"volume",
|
|
26
|
+
"price_open",
|
|
27
|
+
"sl",
|
|
28
|
+
"tp",
|
|
29
|
+
"price_current",
|
|
30
|
+
"profit",
|
|
31
|
+
"time",
|
|
32
|
+
"magic",
|
|
33
|
+
"comment",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class LegacyPosition(NamedTuple):
|
|
38
|
+
"""Field-for-field and in order the same as MetaTrader5.TradePosition."""
|
|
39
|
+
|
|
40
|
+
ticket: int
|
|
41
|
+
time: int
|
|
42
|
+
time_msc: int
|
|
43
|
+
time_update: int
|
|
44
|
+
time_update_msc: int
|
|
45
|
+
type: int
|
|
46
|
+
magic: int
|
|
47
|
+
identifier: int
|
|
48
|
+
reason: int
|
|
49
|
+
volume: float
|
|
50
|
+
price_open: float
|
|
51
|
+
sl: float
|
|
52
|
+
tp: float
|
|
53
|
+
price_current: float
|
|
54
|
+
swap: float
|
|
55
|
+
profit: float
|
|
56
|
+
symbol: str
|
|
57
|
+
comment: str
|
|
58
|
+
external_id: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class LegacyReceipt(NamedTuple):
|
|
62
|
+
"""Field-for-field and in order the same as MetaTrader5.OrderSendResult."""
|
|
63
|
+
|
|
64
|
+
retcode: int
|
|
65
|
+
deal: int
|
|
66
|
+
order: int
|
|
67
|
+
volume: float
|
|
68
|
+
price: float
|
|
69
|
+
bid: float
|
|
70
|
+
ask: float
|
|
71
|
+
comment: str
|
|
72
|
+
request_id: int
|
|
73
|
+
retcode_external: int
|
|
74
|
+
# The echoed TradeRequest is not kept by 3.x; always None.
|
|
75
|
+
request: None = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def as_object(p: Position) -> LegacyPosition:
|
|
79
|
+
return LegacyPosition(
|
|
80
|
+
ticket=p.ticket,
|
|
81
|
+
time=p.time,
|
|
82
|
+
time_msc=p.time_msc,
|
|
83
|
+
time_update=p.time_update,
|
|
84
|
+
time_update_msc=p.time_update_msc,
|
|
85
|
+
type=_POSITION_TYPE[p.side],
|
|
86
|
+
magic=p.magic,
|
|
87
|
+
identifier=p.identifier,
|
|
88
|
+
reason=p.reason,
|
|
89
|
+
volume=p.volume,
|
|
90
|
+
price_open=p.price_open,
|
|
91
|
+
# Raw MT5 objects report a missing stop as 0.0; 2.x passed them through as is.
|
|
92
|
+
sl=p.sl or 0.0,
|
|
93
|
+
tp=p.tp or 0.0,
|
|
94
|
+
price_current=p.price_current,
|
|
95
|
+
swap=p.swap,
|
|
96
|
+
profit=p.profit,
|
|
97
|
+
symbol=p.symbol,
|
|
98
|
+
comment=p.comment,
|
|
99
|
+
external_id=p.external_id,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def as_dict(p: Position) -> dict[str, Any]:
|
|
104
|
+
row = as_object(p)._asdict()
|
|
105
|
+
# 2.x `as_dict=True` used None for a missing stop, unlike the raw objects.
|
|
106
|
+
return {key: row[key] for key in _V2_DICT_KEYS} | {"sl": p.sl, "tp": p.tp}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def as_receipt(receipt: TradeReceipt | None) -> LegacyReceipt | None:
|
|
110
|
+
if receipt is None:
|
|
111
|
+
return None
|
|
112
|
+
return LegacyReceipt(
|
|
113
|
+
retcode=receipt.retcode,
|
|
114
|
+
deal=receipt.deal,
|
|
115
|
+
order=receipt.order,
|
|
116
|
+
volume=receipt.volume,
|
|
117
|
+
price=receipt.price,
|
|
118
|
+
bid=receipt.bid,
|
|
119
|
+
ask=receipt.ask,
|
|
120
|
+
comment=receipt.comment,
|
|
121
|
+
request_id=receipt.request_id,
|
|
122
|
+
retcode_external=receipt.retcode_external,
|
|
123
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Result type used at every port and use-case boundary.
|
|
2
|
+
|
|
3
|
+
Chosen over exceptions at boundaries because a trading call has many *expected*
|
|
4
|
+
failures (market closed, no money, requote); an exception-based API lets callers
|
|
5
|
+
forget them, while a Result forces the caller to look at the outcome.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Generic, TypeVar, Union
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T")
|
|
14
|
+
E = TypeVar("E")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class Ok(Generic[T]):
|
|
19
|
+
value: T
|
|
20
|
+
|
|
21
|
+
def is_ok(self) -> bool:
|
|
22
|
+
return True
|
|
23
|
+
|
|
24
|
+
def is_err(self) -> bool:
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class Err(Generic[E]):
|
|
30
|
+
error: E
|
|
31
|
+
|
|
32
|
+
def is_ok(self) -> bool:
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
def is_err(self) -> bool:
|
|
36
|
+
return True
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Union (not PEP 604 `|`) because a generic alias built with `|` cannot be
|
|
40
|
+
# subscripted at runtime on Python 3.10.
|
|
41
|
+
Result = Union[Ok[T], Err[E]] # noqa: UP007
|