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.
Files changed (38) hide show
  1. PythonMetaTrader5/__init__.py +73 -535
  2. PythonMetaTrader5/adapters/__init__.py +1 -0
  3. PythonMetaTrader5/adapters/mt5_mapping.py +92 -0
  4. PythonMetaTrader5/adapters/mt5_session.py +48 -0
  5. PythonMetaTrader5/adapters/mt5_terminal.py +224 -0
  6. PythonMetaTrader5/adapters/system_clock.py +10 -0
  7. PythonMetaTrader5/application/__init__.py +1 -0
  8. PythonMetaTrader5/application/batch.py +60 -0
  9. PythonMetaTrader5/application/entries.py +163 -0
  10. PythonMetaTrader5/application/execution.py +108 -0
  11. PythonMetaTrader5/application/magic_scope.py +75 -0
  12. PythonMetaTrader5/application/ports.py +75 -0
  13. PythonMetaTrader5/application/trader.py +275 -0
  14. PythonMetaTrader5/connection.py +52 -0
  15. PythonMetaTrader5/domain/__init__.py +1 -0
  16. PythonMetaTrader5/domain/amounts.py +33 -0
  17. PythonMetaTrader5/domain/errors.py +53 -0
  18. PythonMetaTrader5/domain/inputs.py +106 -0
  19. PythonMetaTrader5/domain/market.py +91 -0
  20. PythonMetaTrader5/domain/orders.py +64 -0
  21. PythonMetaTrader5/domain/requests.py +32 -0
  22. PythonMetaTrader5/domain/retcodes.py +81 -0
  23. PythonMetaTrader5/domain/stops.py +85 -0
  24. PythonMetaTrader5/domain/symbol.py +158 -0
  25. PythonMetaTrader5/legacy/__init__.py +1 -0
  26. PythonMetaTrader5/legacy/arguments.py +52 -0
  27. PythonMetaTrader5/legacy/broker.py +284 -0
  28. PythonMetaTrader5/legacy/mt5_namespace.py +56 -0
  29. PythonMetaTrader5/legacy/views.py +123 -0
  30. PythonMetaTrader5/py.typed +0 -0
  31. PythonMetaTrader5/result.py +41 -0
  32. pythonmetatrader5-3.0.1.dist-info/METADATA +492 -0
  33. pythonmetatrader5-3.0.1.dist-info/RECORD +36 -0
  34. {pythonmetatrader5-2.3.2.dist-info → pythonmetatrader5-3.0.1.dist-info}/WHEEL +1 -1
  35. pythonmetatrader5-3.0.1.dist-info/licenses/LICENSE +16 -0
  36. pythonmetatrader5-2.3.2.dist-info/METADATA +0 -267
  37. pythonmetatrader5-2.3.2.dist-info/RECORD +0 -5
  38. {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