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,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 @@
|
|
|
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))
|