PythonMetaTrader5 2.3.2__py3-none-any.whl → 3.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.
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 +65 -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.0.dist-info/METADATA +231 -0
  33. pythonmetatrader5-3.0.0.dist-info/RECORD +36 -0
  34. {pythonmetatrader5-2.3.2.dist-info → pythonmetatrader5-3.0.0.dist-info}/WHEEL +1 -1
  35. pythonmetatrader5-3.0.0.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.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,92 @@
1
+ """Translation between MetaTrader5's raw result objects and domain models.
2
+
3
+ Split out of mt5_terminal.py (300-line limit). Pure functions: they read fields
4
+ of MT5 objects but never call the terminal, so they need no session lock.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from PythonMetaTrader5.domain.market import PendingKind, PendingOrder, Position, TradeReceipt
12
+ from PythonMetaTrader5.domain.orders import Side
13
+
14
+ OrderTypes = dict[tuple[Side, PendingKind | None], int]
15
+
16
+
17
+ def order_type_table(mt5: Any) -> OrderTypes:
18
+ return {
19
+ (Side.BUY, None): mt5.ORDER_TYPE_BUY,
20
+ (Side.SELL, None): mt5.ORDER_TYPE_SELL,
21
+ (Side.BUY, PendingKind.LIMIT): mt5.ORDER_TYPE_BUY_LIMIT,
22
+ (Side.SELL, PendingKind.LIMIT): mt5.ORDER_TYPE_SELL_LIMIT,
23
+ (Side.BUY, PendingKind.STOP): mt5.ORDER_TYPE_BUY_STOP,
24
+ (Side.SELL, PendingKind.STOP): mt5.ORDER_TYPE_SELL_STOP,
25
+ (Side.BUY, PendingKind.STOP_LIMIT): mt5.ORDER_TYPE_BUY_STOP_LIMIT,
26
+ (Side.SELL, PendingKind.STOP_LIMIT): mt5.ORDER_TYPE_SELL_STOP_LIMIT,
27
+ }
28
+
29
+
30
+ def receipt(result: Any) -> TradeReceipt:
31
+ return TradeReceipt(
32
+ retcode=int(result.retcode),
33
+ deal=int(result.deal),
34
+ order=int(result.order),
35
+ volume=float(result.volume),
36
+ price=float(result.price),
37
+ bid=float(result.bid),
38
+ ask=float(result.ask),
39
+ comment=str(result.comment),
40
+ request_id=int(result.request_id),
41
+ retcode_external=int(result.retcode_external),
42
+ )
43
+
44
+
45
+ def position(p: Any, position_buy: int) -> Position:
46
+ return Position(
47
+ ticket=int(p.ticket),
48
+ symbol=str(p.symbol),
49
+ side=Side.BUY if p.type == position_buy else Side.SELL,
50
+ volume=float(p.volume),
51
+ price_open=float(p.price_open),
52
+ # MT5 encodes "no stop" as 0.0, which is also a (useless) price.
53
+ sl=float(p.sl) or None,
54
+ tp=float(p.tp) or None,
55
+ price_current=float(p.price_current),
56
+ profit=float(p.profit),
57
+ time=int(p.time),
58
+ magic=int(p.magic),
59
+ comment=str(p.comment),
60
+ swap=float(p.swap),
61
+ identifier=int(p.identifier),
62
+ reason=int(p.reason),
63
+ time_msc=int(p.time_msc),
64
+ time_update=int(p.time_update),
65
+ time_update_msc=int(p.time_update_msc),
66
+ external_id=str(p.external_id),
67
+ )
68
+
69
+
70
+ def pending_orders(
71
+ raw: tuple[Any, ...], by_type: dict[int, tuple[Side, PendingKind]]
72
+ ) -> list[PendingOrder]:
73
+ orders = []
74
+ for o in raw:
75
+ key = by_type.get(int(o.type))
76
+ # Market orders still being processed and close-by orders are not resting orders.
77
+ if key is None:
78
+ continue
79
+ side, kind = key
80
+ orders.append(
81
+ PendingOrder(
82
+ ticket=int(o.ticket),
83
+ symbol=str(o.symbol),
84
+ side=side,
85
+ kind=kind,
86
+ volume=float(o.volume_current),
87
+ price=float(o.price_open),
88
+ magic=int(o.magic),
89
+ comment=str(o.comment),
90
+ )
91
+ )
92
+ return orders
@@ -0,0 +1,48 @@
1
+ """The one MetaTrader5 session of a process: how it is imported and locked.
2
+
3
+ Shared by the adapter and the 2.x namespace re-export, so both import the
4
+ package the same way and every call through either holds the same lock.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import functools
10
+ import importlib
11
+ import threading
12
+ from collections.abc import Callable
13
+ from types import ModuleType
14
+ from typing import ParamSpec, TypeVar
15
+
16
+ # last_error() is process-global, so a call and the error read that explains it
17
+ # must not interleave with another thread's call. Module-level, not per adapter:
18
+ # connect() builds a new adapter each time, and every adapter and re-exported
19
+ # function in a process talks to the one MT5 session (ADR 0009).
20
+ SESSION = threading.RLock()
21
+ _P = ParamSpec("_P")
22
+ _R = TypeVar("_R")
23
+
24
+
25
+ def serialized(function: Callable[_P, _R]) -> Callable[_P, _R]:
26
+ @functools.wraps(function)
27
+ def locked(*args: _P.args, **kwargs: _P.kwargs) -> _R:
28
+ with SESSION:
29
+ return function(*args, **kwargs)
30
+
31
+ return locked
32
+
33
+
34
+ def import_metatrader5() -> ModuleType | None:
35
+ """The MetaTrader5 module, or None when it is not installed.
36
+
37
+ Only "not installed" becomes None: it is the normal case outside Windows. A
38
+ package that is installed but fails to import raises ImportError, whatever
39
+ it raised itself: a numpy ABI mismatch can surface as ValueError or
40
+ AttributeError, and an AttributeError leaking out of the package
41
+ __getattr__ would read as "no such name" and silently empty the star import.
42
+ """
43
+ try:
44
+ return importlib.import_module("MetaTrader5")
45
+ except Exception as error:
46
+ if isinstance(error, ModuleNotFoundError) and error.name == "MetaTrader5":
47
+ return None
48
+ raise ImportError(f"MetaTrader5 is installed but cannot be imported: {error}") from error
@@ -0,0 +1,224 @@
1
+ """Terminal port implemented with the official `MetaTrader5` package.
2
+
3
+ This is the only module that knows MT5's request dictionaries, numeric order
4
+ types and the shape of its result objects.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ from typing import Any
11
+
12
+ from PythonMetaTrader5.adapters import mt5_mapping
13
+ from PythonMetaTrader5.adapters.mt5_session import import_metatrader5, serialized
14
+ from PythonMetaTrader5.application.ports import Credentials
15
+ from PythonMetaTrader5.domain.errors import ErrorKind, TradeError
16
+ from PythonMetaTrader5.domain.market import PendingOrder, Position, Tick, TradeReceipt
17
+ from PythonMetaTrader5.domain.requests import CancelRequest, OrderRequest
18
+ from PythonMetaTrader5.domain.symbol import Filling, SymbolSpec
19
+ from PythonMetaTrader5.result import Err, Ok, Result
20
+
21
+ # last_error() codes for which order_send provably never left this process
22
+ # (bad arguments, no IPC link, terminal refused). Any other failure (send,
23
+ # receive, timeout) may have reached the server, so its outcome is unknown.
24
+ _NOT_SENT_CODES = (
25
+ "RES_E_INVALID_PARAMS",
26
+ "RES_E_NO_MEMORY",
27
+ "RES_E_NOT_FOUND",
28
+ "RES_E_INVALID_VERSION",
29
+ "RES_E_AUTH_FAILED",
30
+ "RES_E_UNSUPPORTED",
31
+ "RES_E_INTERNAL_FAIL_INIT",
32
+ "RES_E_INTERNAL_FAIL_CONNECT",
33
+ )
34
+
35
+
36
+ class MetaTrader5Terminal:
37
+ def __init__(self, mt5: Any) -> None:
38
+ # `Any`: the MetaTrader5 package ships no type information.
39
+ self._mt5 = mt5
40
+ self._order_types = mt5_mapping.order_type_table(mt5)
41
+ self._pending_by_type = {
42
+ code: (side, kind)
43
+ for (side, kind), code in self._order_types.items()
44
+ if kind is not None
45
+ }
46
+ self._fillings = {
47
+ Filling.FOK: mt5.ORDER_FILLING_FOK,
48
+ Filling.IOC: mt5.ORDER_FILLING_IOC,
49
+ Filling.RETURN: mt5.ORDER_FILLING_RETURN,
50
+ }
51
+ # Every constant is read here, not at first use, so building the adapter
52
+ # against a new MetaTrader5 release fails at once if one was renamed.
53
+ self._deal = mt5.TRADE_ACTION_DEAL
54
+ self._pending = mt5.TRADE_ACTION_PENDING
55
+ self._remove = mt5.TRADE_ACTION_REMOVE
56
+ self._good_till_cancel = mt5.ORDER_TIME_GTC
57
+ self._position_buy = mt5.POSITION_TYPE_BUY
58
+ self._exchange_execution = mt5.SYMBOL_TRADE_EXECUTION_EXCHANGE
59
+ self._not_sent = frozenset(getattr(mt5, name) for name in _NOT_SENT_CODES)
60
+ self._auto_trading_disabled = mt5.RES_E_AUTO_TRADING_DISABLED
61
+ # MT5 reports "nothing found" and "call failed" both as None; only a
62
+ # last_error() of RES_S_OK tells "nothing found" apart.
63
+ self._ok = mt5.RES_S_OK
64
+
65
+ @classmethod
66
+ def load(cls) -> Result[MetaTrader5Terminal, TradeError]:
67
+ try:
68
+ module = import_metatrader5()
69
+ except ImportError as error:
70
+ # import_metatrader5 already says "installed but cannot be imported".
71
+ return Err(TradeError(ErrorKind.CONNECTION_FAILED, str(error)))
72
+ if module is None:
73
+ return Err(
74
+ TradeError(
75
+ ErrorKind.CONNECTION_FAILED,
76
+ "the MetaTrader5 package is not installed (it is available on Windows only)",
77
+ )
78
+ )
79
+ return Ok(cls(module))
80
+
81
+ @serialized
82
+ def connect(self, credentials: Credentials) -> Result[None, TradeError]:
83
+ args = (credentials.path,) if credentials.path else ()
84
+ if self._mt5.initialize(
85
+ *args,
86
+ login=credentials.login,
87
+ password=credentials.password,
88
+ server=credentials.server,
89
+ timeout=credentials.timeout_ms,
90
+ ):
91
+ return Ok(None)
92
+ error = self._last_error()
93
+ # Always released: MT5 has one session per process, so after a failed login
94
+ # it is unusable for everyone, and a half-open one makes every later
95
+ # initialize() fail (login(ToEffect=True) could never recover). A per-adapter
96
+ # "keep the live session" flag was tried and dropped: connect() builds a
97
+ # new adapter each time, so the flag protected nothing.
98
+ self._mt5.shutdown()
99
+ return Err(
100
+ TradeError(
101
+ ErrorKind.CONNECTION_FAILED,
102
+ f"login {credentials.login}@{credentials.server} failed: {error}",
103
+ )
104
+ )
105
+
106
+ @serialized
107
+ def shutdown(self) -> None:
108
+ self._mt5.shutdown()
109
+
110
+ @serialized
111
+ def symbol(self, name: str) -> Result[SymbolSpec, TradeError]:
112
+ info = self._mt5.symbol_info(name)
113
+ if info is None:
114
+ return Err(TradeError(ErrorKind.SYMBOL_NOT_FOUND, f"unknown symbol {name!r}"))
115
+ # Hidden symbols have no quotes; 2.x left users to fix Market Watch by hand.
116
+ if not info.visible and not self._mt5.symbol_select(name, True):
117
+ return Err(
118
+ TradeError(
119
+ ErrorKind.SYMBOL_NOT_FOUND,
120
+ f"{name!r} cannot be added to Market Watch: {self._last_error()}",
121
+ )
122
+ )
123
+ return Ok(
124
+ SymbolSpec(
125
+ name=info.name,
126
+ digits=int(info.digits),
127
+ point=float(info.point),
128
+ tick_size=float(info.trade_tick_size),
129
+ volume_min=float(info.volume_min),
130
+ volume_max=float(info.volume_max),
131
+ volume_step=float(info.volume_step),
132
+ filling_flags=int(info.filling_mode),
133
+ exchange_execution=int(info.trade_exemode) == self._exchange_execution,
134
+ )
135
+ )
136
+
137
+ @serialized
138
+ def tick(self, name: str) -> Result[Tick, TradeError]:
139
+ tick = self._mt5.symbol_info_tick(name)
140
+ # A zero side means no quote yet; trading at price 0 must never be attempted.
141
+ # NaN and inf are refused too: `nan <= 0` is False and would send price NaN.
142
+ if tick is None or not all(math.isfinite(p) and p > 0 for p in (tick.bid, tick.ask)):
143
+ return Err(TradeError(ErrorKind.NO_PRICE, f"no quote for {name!r}"))
144
+ return Ok(Tick(bid=float(tick.bid), ask=float(tick.ask)))
145
+
146
+ @serialized
147
+ def send(self, request: OrderRequest | CancelRequest) -> Result[TradeReceipt, TradeError]:
148
+ payload = self._payload(request)
149
+ result = self._mt5.order_send(payload)
150
+ if result is None:
151
+ return Err(self._send_failure())
152
+ return Ok(mt5_mapping.receipt(result))
153
+
154
+ @serialized
155
+ def positions(
156
+ self, *, symbol: str | None = None, ticket: int | None = None
157
+ ) -> Result[list[Position], TradeError]:
158
+ query: dict[str, object] = {}
159
+ if ticket is not None:
160
+ query["ticket"] = ticket
161
+ elif symbol is not None:
162
+ query["symbol"] = symbol
163
+ raw = self._listing(self._mt5.positions_get(**query), "positions_get")
164
+ if isinstance(raw, Err):
165
+ return raw
166
+ found = [mt5_mapping.position(p, self._position_buy) for p in raw.value]
167
+ # MT5 ignores `symbol` once `ticket` is given; the port promises both apply.
168
+ return Ok([p for p in found if symbol is None or p.symbol == symbol])
169
+
170
+ @serialized
171
+ def pending_orders(
172
+ self, *, symbol: str | None = None
173
+ ) -> Result[list[PendingOrder], TradeError]:
174
+ query = {"symbol": symbol} if symbol is not None else {}
175
+ raw = self._listing(self._mt5.orders_get(**query), "orders_get")
176
+ if isinstance(raw, Err):
177
+ return raw
178
+ return Ok(mt5_mapping.pending_orders(raw.value, self._pending_by_type))
179
+
180
+ # ------------------------------------------------------------------ helpers
181
+
182
+ def _payload(self, request: OrderRequest | CancelRequest) -> dict[str, Any]:
183
+ if isinstance(request, CancelRequest):
184
+ return {"action": self._remove, "order": request.order}
185
+ payload: dict[str, Any] = {
186
+ "action": self._deal if request.pending is None else self._pending,
187
+ "symbol": request.symbol,
188
+ "volume": request.volume,
189
+ "type": self._order_types[(request.side, request.pending)],
190
+ "price": request.price,
191
+ "deviation": request.deviation,
192
+ "magic": request.magic,
193
+ "comment": request.comment,
194
+ "type_filling": self._fillings[request.filling],
195
+ "type_time": self._good_till_cancel,
196
+ }
197
+ optional = {"sl": request.sl, "tp": request.tp, "position": request.position}
198
+ payload.update({key: value for key, value in optional.items() if value is not None})
199
+ return payload
200
+
201
+ def _listing(self, raw: Any, call: str) -> Result[tuple[Any, ...], TradeError]:
202
+ if raw is not None:
203
+ return Ok(tuple(raw))
204
+ code, _ = self._mt5.last_error()
205
+ if code == self._ok:
206
+ return Ok(())
207
+ return Err(TradeError(ErrorKind.TERMINAL_ERROR, f"{call} failed: {self._last_error()}"))
208
+
209
+ def _send_failure(self) -> TradeError:
210
+ code, text = self._error()
211
+ if code == self._auto_trading_disabled:
212
+ kind = ErrorKind.ALGO_TRADING_DISABLED
213
+ elif code in self._not_sent:
214
+ kind = ErrorKind.TERMINAL_ERROR
215
+ else:
216
+ kind = ErrorKind.OUTCOME_UNKNOWN
217
+ return TradeError(kind, f"order_send failed: {text}")
218
+
219
+ def _last_error(self) -> str:
220
+ return self._error()[1]
221
+
222
+ def _error(self) -> tuple[int, str]:
223
+ code, message = self._mt5.last_error()
224
+ return code, f"[{code}] {message}"
@@ -0,0 +1,10 @@
1
+ """Wall-clock adapter for the Clock port."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+
8
+ class SystemClock:
9
+ def sleep(self, seconds: float) -> None:
10
+ time.sleep(seconds)
@@ -0,0 +1 @@
1
+ """Use cases and the ports they need from the outside world."""
@@ -0,0 +1,60 @@
1
+ """Applying one trade action to many positions/orders and reporting per item."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Iterable
6
+ from dataclasses import dataclass, replace
7
+ from typing import Protocol, TypeVar
8
+
9
+ from PythonMetaTrader5.domain.errors import TradeError
10
+ from PythonMetaTrader5.domain.inputs import ANY_MAGIC, AnyMagic
11
+ from PythonMetaTrader5.domain.market import TradeReceipt
12
+ from PythonMetaTrader5.result import Err, Ok, Result
13
+
14
+
15
+ class _Ticketed(Protocol):
16
+ @property
17
+ def ticket(self) -> int: ...
18
+
19
+ @property
20
+ def magic(self) -> int: ...
21
+
22
+
23
+ _T = TypeVar("_T", bound=_Ticketed)
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class BatchFailure:
28
+ """Some items of a batch (close all / cancel all) failed; others may have succeeded."""
29
+
30
+ succeeded: tuple[TradeReceipt, ...]
31
+ errors: tuple[TradeError, ...]
32
+
33
+ def __str__(self) -> str:
34
+ return f"{len(self.errors)} failed, {len(self.succeeded)} succeeded: " + "; ".join(
35
+ str(e) for e in self.errors
36
+ )
37
+
38
+
39
+ def run_batch(
40
+ items: Iterable[_T], action: Callable[[_T], Result[TradeReceipt, TradeError]]
41
+ ) -> Result[list[TradeReceipt], BatchFailure]:
42
+ # Every item is attempted even after a failure: stopping at the first error
43
+ # would leave the remaining positions open while reporting only one problem.
44
+ succeeded: list[TradeReceipt] = []
45
+ errors: list[TradeError] = []
46
+ for item in items:
47
+ match action(item):
48
+ case Ok(receipt):
49
+ succeeded.append(receipt)
50
+ case Err(error):
51
+ errors.append(replace(error, ticket=item.ticket))
52
+ if errors:
53
+ return Err(BatchFailure(tuple(succeeded), tuple(errors)))
54
+ return Ok(succeeded)
55
+
56
+
57
+ def with_magic(items: list[_T], magic: int | AnyMagic | None) -> list[_T]:
58
+ if magic is None or magic is ANY_MAGIC:
59
+ return items
60
+ return [item for item in items if item.magic == magic]
@@ -0,0 +1,163 @@
1
+ """Validated entry parameters shared by market and pending entries.
2
+
3
+ Split out of trader.py (300-line limit): this is the only place that turns raw
4
+ caller input into an entry request, so every check runs before any send.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+
11
+ from PythonMetaTrader5.application.ports import Terminal
12
+ from PythonMetaTrader5.domain.errors import ErrorKind, TradeError
13
+ from PythonMetaTrader5.domain.inputs import positive_number, stop_input
14
+ from PythonMetaTrader5.domain.market import PendingKind
15
+ from PythonMetaTrader5.domain.orders import Side, validate_comment
16
+ from PythonMetaTrader5.domain.requests import CancelRequest, OrderRequest
17
+ from PythonMetaTrader5.domain.stops import Stop, StopKind
18
+ from PythonMetaTrader5.domain.symbol import SymbolSpec
19
+ from PythonMetaTrader5.result import Err, Ok, Result
20
+
21
+ StopInput = Stop | float | None
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class Entry:
26
+ spec: SymbolSpec
27
+ side: Side
28
+ volume: float
29
+ comment: str
30
+ stop_loss: Stop | None
31
+ take_profit: Stop | None
32
+ # The caller's price on the symbol's tick grid; None means "use the quote".
33
+ price: float | None
34
+
35
+ def request(
36
+ self,
37
+ quote: float | None = None,
38
+ *,
39
+ pending: PendingKind | None,
40
+ deviation: int,
41
+ magic: int,
42
+ ) -> Result[OrderRequest | CancelRequest, TradeError]:
43
+ reference = self.price if self.price is not None else quote
44
+ if reference is None:
45
+ return Err(TradeError(ErrorKind.NO_PRICE, f"no price for {self.spec.name}"))
46
+ spec, side = self.spec, self.side
47
+ sl = self.stop_loss.stop_loss(side, reference, spec) if self.stop_loss else Ok(None)
48
+ if isinstance(sl, Err):
49
+ return sl
50
+ tp = self.take_profit.take_profit(side, reference, spec) if self.take_profit else Ok(None)
51
+ if isinstance(tp, Err):
52
+ return tp
53
+ return Ok(
54
+ OrderRequest(
55
+ symbol=spec.name,
56
+ side=side,
57
+ volume=self.volume,
58
+ price=reference,
59
+ filling=spec.market_filling() if pending is None else spec.pending_filling(),
60
+ deviation=deviation,
61
+ magic=magic,
62
+ comment=self.comment,
63
+ sl=sl.value,
64
+ tp=tp.value,
65
+ pending=pending,
66
+ )
67
+ )
68
+
69
+
70
+ def validate_entry(
71
+ terminal: Terminal,
72
+ symbol: str,
73
+ side: Side | str,
74
+ volume: float,
75
+ *,
76
+ comment: str | None,
77
+ sl: StopInput,
78
+ tp: StopInput,
79
+ price: object = None,
80
+ price_required: bool = False,
81
+ ) -> Result[Entry, TradeError]:
82
+ # Every check that needs no terminal, the price included, runs before the
83
+ # symbol lookup, which costs a round trip and may add the symbol to Market
84
+ # Watch.
85
+ values = _caller_values(side, comment, sl, tp, price, price_required=price_required)
86
+ if isinstance(values, Err):
87
+ return values
88
+ amount = positive_number(volume, ErrorKind.INVALID_VOLUME, "volume")
89
+ if isinstance(amount, Err):
90
+ return amount
91
+ spec = _tradable_spec(terminal, symbol)
92
+ if isinstance(spec, Err):
93
+ return spec
94
+ lots = spec.value.validate_volume(amount.value)
95
+ if isinstance(lots, Err):
96
+ return lots
97
+ parsed_side, text, stop_loss, take_profit, level = values.value
98
+ on_grid = _quote_free_levels(spec.value, level, stop_loss, take_profit)
99
+ if isinstance(on_grid, Err):
100
+ return on_grid
101
+ return Ok(
102
+ Entry(spec.value, parsed_side, lots.value, text, stop_loss, take_profit, on_grid.value)
103
+ )
104
+
105
+
106
+ def _tradable_spec(terminal: Terminal, symbol: str) -> Result[SymbolSpec, TradeError]:
107
+ # Checked right after the lookup, before any caller value is judged against
108
+ # the spec: broken broker data must not come back as the caller's
109
+ # INVALID_VOLUME. It covers market orders without stops too, which never
110
+ # reach price_on_grid. Closing never calls this.
111
+ spec = terminal.symbol(symbol)
112
+ if isinstance(spec, Err):
113
+ return spec
114
+ tradable = spec.value.check_tradable()
115
+ return tradable if isinstance(tradable, Err) else spec
116
+
117
+
118
+ def _quote_free_levels(
119
+ spec: SymbolSpec, price: float | None, sl: Stop | None, tp: Stop | None
120
+ ) -> Result[float | None, TradeError]:
121
+ # Absolute stops do not depend on the quote, so they are checked here, before
122
+ # the first tick request: otherwise a stop that can never be valid would sit
123
+ # through the NO_PRICE retries of a symbol without quotes first.
124
+ # The price first, so a bad price is still reported before a bad stop.
125
+ level = None if price is None else spec.price_on_grid(price, ErrorKind.INVALID_PRICE, "price")
126
+ if isinstance(level, Err):
127
+ return level
128
+ for name, stop in (("sl", sl), ("tp", tp)):
129
+ if stop is not None and stop.kind is StopKind.PRICE:
130
+ checked = spec.price_on_grid(stop.value, ErrorKind.INVALID_STOPS, name)
131
+ if isinstance(checked, Err):
132
+ return checked
133
+ return Ok(None if level is None else level.value)
134
+
135
+
136
+ def _caller_values(
137
+ side: Side | str,
138
+ comment: str | None,
139
+ sl: StopInput,
140
+ tp: StopInput,
141
+ price: object,
142
+ *,
143
+ price_required: bool,
144
+ ) -> Result[tuple[Side, str, Stop | None, Stop | None, float | None], TradeError]:
145
+ parsed_side = Side.parse(side)
146
+ if isinstance(parsed_side, Err):
147
+ return parsed_side
148
+ text = validate_comment(comment)
149
+ if isinstance(text, Err):
150
+ return text
151
+ stop_loss, take_profit = stop_input(sl, "sl"), stop_input(tp, "tp")
152
+ if isinstance(stop_loss, Err):
153
+ return stop_loss
154
+ if isinstance(take_profit, Err):
155
+ return take_profit
156
+ level = (
157
+ positive_number(price, ErrorKind.INVALID_PRICE, "price")
158
+ if price is not None or price_required
159
+ else Ok(None)
160
+ )
161
+ if isinstance(level, Err):
162
+ return level
163
+ return Ok((parsed_side.value, text.value, stop_loss.value, take_profit.value, level.value))