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,106 @@
|
|
|
1
|
+
"""Checks on raw caller input that MT5 would otherwise misread without an error.
|
|
2
|
+
|
|
3
|
+
Webhook payloads arrive as loosely typed JSON. Each value below has a reading
|
|
4
|
+
in MT5 that is valid but almost never meant: ``sl=0`` is "no stop",
|
|
5
|
+
``ticket=0`` may be "no filter", ``volume=True`` is one lot. They are refused
|
|
6
|
+
here instead of being passed on.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import replace
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from numbers import Integral
|
|
14
|
+
from typing import Final
|
|
15
|
+
|
|
16
|
+
from PythonMetaTrader5.domain.amounts import positive_float
|
|
17
|
+
from PythonMetaTrader5.domain.errors import ErrorKind, TradeError
|
|
18
|
+
from PythonMetaTrader5.domain.stops import Stop, StopKind
|
|
19
|
+
from PythonMetaTrader5.result import Err, Ok, Result
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def positive_number(value: object, kind: ErrorKind, what: str) -> Result[float, TradeError]:
|
|
23
|
+
number = positive_float(value)
|
|
24
|
+
if number is None:
|
|
25
|
+
return Err(TradeError(kind, f"{what} must be a positive finite number, got {value!r}"))
|
|
26
|
+
return Ok(number)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def stop_input(value: object, what: str) -> Result[Stop | None, TradeError]:
|
|
30
|
+
"""``None`` means no stop; a float is a price; zero is refused, never "no stop"."""
|
|
31
|
+
if value is None or isinstance(value, Stop):
|
|
32
|
+
return Ok(value) # a Stop validated itself when it was built
|
|
33
|
+
parsed = Stop.parse(value, StopKind.PRICE)
|
|
34
|
+
if isinstance(parsed, Err):
|
|
35
|
+
return Err(replace(parsed.error, message=f"{what}: {parsed.error.message}"))
|
|
36
|
+
return Ok(parsed.value)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def ticket_filter(ticket: object) -> Result[int | None, TradeError]:
|
|
40
|
+
if ticket is None:
|
|
41
|
+
return Ok(None)
|
|
42
|
+
if isinstance(ticket, bool) or not isinstance(ticket, Integral) or ticket <= 0:
|
|
43
|
+
return Err(
|
|
44
|
+
TradeError(
|
|
45
|
+
ErrorKind.INVALID_ARGUMENT, f"ticket must be a positive integer, got {ticket!r}"
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
return Ok(int(ticket))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def symbol_filter(symbol: str | None) -> Result[str | None, TradeError]:
|
|
52
|
+
# "" is rejected rather than passed on: a missing webhook field arrives as ""
|
|
53
|
+
# and is easily mistaken for "no filter" (2.x and the first 3.0 draft both
|
|
54
|
+
# dropped falsy filters), which would act on the whole account.
|
|
55
|
+
if symbol == "":
|
|
56
|
+
return Err(TradeError(ErrorKind.INVALID_ARGUMENT, "symbol must not be empty"))
|
|
57
|
+
return Ok(symbol)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AnyMagic(Enum):
|
|
61
|
+
"""The type of ``ANY_MAGIC``."""
|
|
62
|
+
|
|
63
|
+
ANY = "any"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# A named value, not None: close-all and cancel-all default to the trader's own
|
|
67
|
+
# magic, and a None read from a missing config key must not widen them to the
|
|
68
|
+
# whole account (ADR 0011).
|
|
69
|
+
ANY_MAGIC: Final = AnyMagic.ANY
|
|
70
|
+
# MT5 stores magic as a ulong; a larger value would overflow in order_send.
|
|
71
|
+
_MAX_MAGIC: Final = 2**64 - 1
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def magic_filter(magic: object) -> Result[int | AnyMagic | None, TradeError]:
|
|
75
|
+
# "42" or True would match no position and report an empty success with
|
|
76
|
+
# everything still open. 0 is valid: it is what manual trades carry.
|
|
77
|
+
if magic is None or magic is ANY_MAGIC:
|
|
78
|
+
return Ok(magic)
|
|
79
|
+
if (
|
|
80
|
+
isinstance(magic, bool)
|
|
81
|
+
or not isinstance(magic, Integral)
|
|
82
|
+
or not 0 <= int(magic) <= _MAX_MAGIC
|
|
83
|
+
):
|
|
84
|
+
return Err(
|
|
85
|
+
TradeError(
|
|
86
|
+
ErrorKind.INVALID_ARGUMENT,
|
|
87
|
+
f"magic must be an integer from 0 to 2**64-1 or ANY_MAGIC, got {magic!r}",
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
return Ok(int(magic))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def own_magic(magic: object) -> Result[int, TradeError]:
|
|
94
|
+
"""The magic a trader stamps on its orders and closes by default."""
|
|
95
|
+
checked = magic_filter(magic)
|
|
96
|
+
# None or ANY_MAGIC here would make the own-magic default mean "every magic",
|
|
97
|
+
# and a missing config key would close the whole account (ADR 0011). One
|
|
98
|
+
# message for every refusal: magic_filter's would suggest ANY_MAGIC.
|
|
99
|
+
if isinstance(checked, Err) or not isinstance(checked.value, int):
|
|
100
|
+
return Err(
|
|
101
|
+
TradeError(
|
|
102
|
+
ErrorKind.INVALID_ARGUMENT,
|
|
103
|
+
f"the trader's own magic must be an integer from 0 to 2**64-1, got {magic!r}",
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
return Ok(checked.value)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Read models returned by the terminal: quotes, positions, orders, receipts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from PythonMetaTrader5.domain.orders import Side
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class Tick:
|
|
14
|
+
bid: float
|
|
15
|
+
ask: float
|
|
16
|
+
|
|
17
|
+
def entry_price(self, side: Side) -> float:
|
|
18
|
+
return self.ask if side is Side.BUY else self.bid
|
|
19
|
+
|
|
20
|
+
def exit_price(self, side: Side) -> float:
|
|
21
|
+
# Closing a long sells at bid, closing a short buys at ask.
|
|
22
|
+
return self.entry_price(side.opposite)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class Position:
|
|
27
|
+
ticket: int
|
|
28
|
+
symbol: str
|
|
29
|
+
side: Side
|
|
30
|
+
volume: float
|
|
31
|
+
price_open: float
|
|
32
|
+
sl: float | None
|
|
33
|
+
tp: float | None
|
|
34
|
+
price_current: float
|
|
35
|
+
profit: float
|
|
36
|
+
time: int
|
|
37
|
+
magic: int
|
|
38
|
+
comment: str
|
|
39
|
+
# Kept although the 3.x API does not use them: 2.x returned the raw MT5
|
|
40
|
+
# object, so its users may read any of them (swap for P&L, identifier on
|
|
41
|
+
# netting accounts).
|
|
42
|
+
swap: float
|
|
43
|
+
identifier: int
|
|
44
|
+
reason: int
|
|
45
|
+
time_msc: int
|
|
46
|
+
time_update: int
|
|
47
|
+
time_update_msc: int
|
|
48
|
+
external_id: str
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> dict[str, Any]:
|
|
51
|
+
data = asdict(self)
|
|
52
|
+
data["side"] = self.side.value
|
|
53
|
+
return data
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PendingKind(Enum):
|
|
57
|
+
LIMIT = "limit"
|
|
58
|
+
STOP = "stop"
|
|
59
|
+
STOP_LIMIT = "stop_limit"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class PendingOrder:
|
|
64
|
+
ticket: int
|
|
65
|
+
symbol: str
|
|
66
|
+
side: Side
|
|
67
|
+
kind: PendingKind
|
|
68
|
+
volume: float
|
|
69
|
+
price: float
|
|
70
|
+
magic: int
|
|
71
|
+
comment: str
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, slots=True)
|
|
75
|
+
class TradeReceipt:
|
|
76
|
+
"""Broker answer to a trade request.
|
|
77
|
+
|
|
78
|
+
Field names mirror MetaTrader5's ``OrderSendResult`` on purpose, so 2.x code
|
|
79
|
+
reading ``result.retcode`` / ``result.comment`` keeps working unchanged.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
retcode: int
|
|
83
|
+
deal: int
|
|
84
|
+
order: int
|
|
85
|
+
volume: float
|
|
86
|
+
price: float
|
|
87
|
+
bid: float
|
|
88
|
+
ask: float
|
|
89
|
+
comment: str
|
|
90
|
+
request_id: int
|
|
91
|
+
retcode_external: int
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Trade direction and the order requests sent through the terminal port."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
from PythonMetaTrader5.domain.errors import ErrorKind, TradeError
|
|
8
|
+
from PythonMetaTrader5.result import Err, Ok, Result
|
|
9
|
+
|
|
10
|
+
_SIDE_ALIASES = {"buy": "BUY", "long": "BUY", "sell": "SELL", "short": "SELL"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Side(Enum):
|
|
14
|
+
BUY = "buy"
|
|
15
|
+
SELL = "sell"
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def opposite(self) -> Side:
|
|
19
|
+
return Side.SELL if self is Side.BUY else Side.BUY
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def sign(self) -> int:
|
|
23
|
+
return 1 if self is Side.BUY else -1
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def parse(cls, value: Side | str) -> Result[Side, TradeError]:
|
|
27
|
+
if isinstance(value, Side):
|
|
28
|
+
return Ok(value)
|
|
29
|
+
name = _SIDE_ALIASES.get(str(value).strip().lower())
|
|
30
|
+
if name is None:
|
|
31
|
+
return Err(
|
|
32
|
+
TradeError(ErrorKind.INVALID_ARGUMENT, f"unknown side {value!r}; use buy/sell")
|
|
33
|
+
)
|
|
34
|
+
return Ok(cls[name])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Measured with order_check, not taken from the docs: MetaTrader5 5.0.6231
|
|
38
|
+
# accepts 29 UTF-16 code units and refuses 30 with [-2] Invalid "comment"
|
|
39
|
+
# argument before sending. The documented 31 would let 30-31 through to a
|
|
40
|
+
# TERMINAL_ERROR that hides the cause, and trimming would change what the user
|
|
41
|
+
# asked to store. tests/integration re-measures it when the package changes.
|
|
42
|
+
MAX_COMMENT_LENGTH = 29
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def validate_comment(comment: object) -> Result[str, TradeError]:
|
|
46
|
+
if comment is None:
|
|
47
|
+
return Ok("")
|
|
48
|
+
# `comment or ""` would store 0 or False as an empty comment without a word.
|
|
49
|
+
if not isinstance(comment, str):
|
|
50
|
+
return Err(TradeError(ErrorKind.INVALID_ARGUMENT, f"comment must be text, not {comment!r}"))
|
|
51
|
+
# The package counts UTF-16 units, not len(): "\U0001f680" * 15 is refused.
|
|
52
|
+
# surrogatepass: the package accepts a lone surrogate (measured), so it is
|
|
53
|
+
# one unit here, not a UnicodeEncodeError.
|
|
54
|
+
units = len(comment.encode("utf-16-le", "surrogatepass")) // 2
|
|
55
|
+
if units > MAX_COMMENT_LENGTH:
|
|
56
|
+
return Err(
|
|
57
|
+
TradeError(
|
|
58
|
+
ErrorKind.INVALID_ARGUMENT,
|
|
59
|
+
f"comment is {units} UTF-16 units (characters outside the BMP, such as "
|
|
60
|
+
f"most emoji, count as 2; combined emoji more); "
|
|
61
|
+
f"the MetaTrader5 package accepts at most {MAX_COMMENT_LENGTH}",
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
return Ok(comment)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Trade requests in domain terms; the terminal adapter translates them to MT5."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from PythonMetaTrader5.domain.market import PendingKind
|
|
8
|
+
from PythonMetaTrader5.domain.orders import Side
|
|
9
|
+
from PythonMetaTrader5.domain.symbol import Filling
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class OrderRequest:
|
|
14
|
+
symbol: str
|
|
15
|
+
side: Side
|
|
16
|
+
volume: float
|
|
17
|
+
price: float
|
|
18
|
+
filling: Filling
|
|
19
|
+
deviation: int
|
|
20
|
+
magic: int
|
|
21
|
+
comment: str = ""
|
|
22
|
+
sl: float | None = None
|
|
23
|
+
tp: float | None = None
|
|
24
|
+
# Ticket of the position being closed; None opens a new position.
|
|
25
|
+
position: int | None = None
|
|
26
|
+
# None means a market deal; a kind means a pending order at `price`.
|
|
27
|
+
pending: PendingKind | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class CancelRequest:
|
|
32
|
+
order: int
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""What a MetaTrader 5 trade-server return code means for the caller.
|
|
2
|
+
|
|
3
|
+
Codes are integers from the MQL5 "Trade Server Return Codes" table; they are
|
|
4
|
+
duplicated here so the domain never imports the MetaTrader5 package.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from enum import Enum
|
|
10
|
+
|
|
11
|
+
from PythonMetaTrader5.domain.errors import ErrorKind, TradeError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Outcome(Enum):
|
|
15
|
+
SUCCESS = "success"
|
|
16
|
+
RETRY = "retry"
|
|
17
|
+
FAIL = "fail"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# PLACED is how the server confirms a pending order; 2.x only accepted DONE and
|
|
21
|
+
# therefore re-sent limit orders that had already been placed.
|
|
22
|
+
PLACED = 10008
|
|
23
|
+
DONE = 10009
|
|
24
|
+
DONE_PARTIAL = 10010
|
|
25
|
+
_SUCCESS = frozenset({PLACED, DONE, DONE_PARTIAL})
|
|
26
|
+
|
|
27
|
+
# Only codes where the server provably did NOT execute the request. TIMEOUT and
|
|
28
|
+
# CONNECTION are deliberately absent: the order may already be live, and an
|
|
29
|
+
# automatic retry would open a second position.
|
|
30
|
+
_RETRY = frozenset({10004, 10020, 10021, 10024})
|
|
31
|
+
|
|
32
|
+
_FAILURES: dict[int, tuple[ErrorKind, str]] = {
|
|
33
|
+
10004: (ErrorKind.REJECTED, "requote"),
|
|
34
|
+
10006: (ErrorKind.REJECTED, "request rejected by the server"),
|
|
35
|
+
10007: (ErrorKind.REJECTED, "request cancelled by the trader"),
|
|
36
|
+
10011: (ErrorKind.REJECTED, "request processing error"),
|
|
37
|
+
10012: (ErrorKind.OUTCOME_UNKNOWN, "request timed out; check positions before retrying"),
|
|
38
|
+
10013: (ErrorKind.INVALID_ARGUMENT, "invalid request"),
|
|
39
|
+
10014: (ErrorKind.INVALID_VOLUME, "invalid volume"),
|
|
40
|
+
10015: (ErrorKind.INVALID_PRICE, "invalid price"),
|
|
41
|
+
10016: (ErrorKind.INVALID_STOPS, "invalid stops; too close to the price or wrong side"),
|
|
42
|
+
10017: (ErrorKind.TRADE_DISABLED, "trading is disabled"),
|
|
43
|
+
10018: (ErrorKind.MARKET_CLOSED, "market is closed"),
|
|
44
|
+
10019: (ErrorKind.NO_MONEY, "not enough money"),
|
|
45
|
+
10020: (ErrorKind.REJECTED, "prices changed"),
|
|
46
|
+
10021: (ErrorKind.NO_PRICE, "no quotes to process the request"),
|
|
47
|
+
10022: (ErrorKind.INVALID_ARGUMENT, "invalid order expiration"),
|
|
48
|
+
10024: (ErrorKind.REJECTED, "too many requests"),
|
|
49
|
+
10026: (ErrorKind.ALGO_TRADING_DISABLED, "autotrading disabled by the server"),
|
|
50
|
+
10027: (ErrorKind.ALGO_TRADING_DISABLED, "enable Algo Trading in the MetaTrader 5 terminal"),
|
|
51
|
+
10028: (ErrorKind.REJECTED, "request locked for processing"),
|
|
52
|
+
10029: (ErrorKind.REJECTED, "order or position frozen"),
|
|
53
|
+
10030: (ErrorKind.INVALID_FILLING, "filling mode not supported by the symbol"),
|
|
54
|
+
10031: (ErrorKind.OUTCOME_UNKNOWN, "no connection to the trade server; check positions"),
|
|
55
|
+
10032: (ErrorKind.TRADE_DISABLED, "operation allowed only on live accounts"),
|
|
56
|
+
10033: (ErrorKind.LIMIT_REACHED, "pending orders limit reached"),
|
|
57
|
+
10034: (ErrorKind.LIMIT_REACHED, "volume limit for the symbol reached"),
|
|
58
|
+
10035: (ErrorKind.INVALID_ARGUMENT, "invalid or prohibited order type"),
|
|
59
|
+
10036: (ErrorKind.REJECTED, "position already closed"),
|
|
60
|
+
10038: (ErrorKind.INVALID_VOLUME, "close volume exceeds position volume"),
|
|
61
|
+
10039: (ErrorKind.REJECTED, "a close order for this position already exists"),
|
|
62
|
+
10040: (ErrorKind.LIMIT_REACHED, "open positions limit reached"),
|
|
63
|
+
10041: (ErrorKind.REJECTED, "pending order activation rejected"),
|
|
64
|
+
10042: (ErrorKind.TRADE_DISABLED, "only long positions are allowed"),
|
|
65
|
+
10043: (ErrorKind.TRADE_DISABLED, "only short positions are allowed"),
|
|
66
|
+
10044: (ErrorKind.TRADE_DISABLED, "only position closing is allowed"),
|
|
67
|
+
10045: (ErrorKind.REJECTED, "FIFO rule: close older positions first"),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def classify(retcode: int) -> Outcome:
|
|
72
|
+
if retcode in _SUCCESS:
|
|
73
|
+
return Outcome.SUCCESS
|
|
74
|
+
if retcode in _RETRY:
|
|
75
|
+
return Outcome.RETRY
|
|
76
|
+
return Outcome.FAIL
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def error_for(retcode: int) -> TradeError:
|
|
80
|
+
kind, message = _FAILURES.get(retcode, (ErrorKind.REJECTED, "unrecognised server retcode"))
|
|
81
|
+
return TradeError(kind, message, retcode=retcode)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Stop-loss / take-profit given either as a price or as a distance in points."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
from PythonMetaTrader5.domain.amounts import positive_float
|
|
9
|
+
from PythonMetaTrader5.domain.errors import ErrorKind, TradeError
|
|
10
|
+
from PythonMetaTrader5.domain.orders import Side
|
|
11
|
+
from PythonMetaTrader5.domain.symbol import SymbolSpec
|
|
12
|
+
from PythonMetaTrader5.result import Err, Ok, Result
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class StopKind(Enum):
|
|
16
|
+
PRICE = "price"
|
|
17
|
+
POINTS = "points"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class Stop:
|
|
22
|
+
value: float
|
|
23
|
+
kind: StopKind
|
|
24
|
+
|
|
25
|
+
def __post_init__(self) -> None:
|
|
26
|
+
# The invariant lives here, so every Stop is valid wherever it is built.
|
|
27
|
+
# Raised, not returned: a Stop of 0 cannot exist (MT5 would read it as "no
|
|
28
|
+
# stop"). Code holding a value that may be bad (an ATR that is NaN during
|
|
29
|
+
# warm-up) uses Stop.parse, which returns a Result instead.
|
|
30
|
+
parsed = _checked(self.value, self.kind)
|
|
31
|
+
if isinstance(parsed, Err):
|
|
32
|
+
raise ValueError(parsed.error.message)
|
|
33
|
+
object.__setattr__(self, "value", parsed.value)
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def parse(cls, value: object, kind: StopKind) -> Result[Stop, TradeError]:
|
|
37
|
+
checked = _checked(value, kind)
|
|
38
|
+
if isinstance(checked, Err):
|
|
39
|
+
return checked
|
|
40
|
+
return Ok(cls(checked.value, kind))
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def at_price(cls, price: float) -> Stop:
|
|
44
|
+
return cls(price, StopKind.PRICE)
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def points(cls, distance: float) -> Stop:
|
|
48
|
+
return cls(distance, StopKind.POINTS)
|
|
49
|
+
|
|
50
|
+
def stop_loss(
|
|
51
|
+
self, side: Side, reference: float, spec: SymbolSpec
|
|
52
|
+
) -> Result[float, TradeError]:
|
|
53
|
+
return self._resolve(-side.sign, reference, spec, "sl")
|
|
54
|
+
|
|
55
|
+
def take_profit(
|
|
56
|
+
self, side: Side, reference: float, spec: SymbolSpec
|
|
57
|
+
) -> Result[float, TradeError]:
|
|
58
|
+
return self._resolve(side.sign, reference, spec, "tp")
|
|
59
|
+
|
|
60
|
+
def _resolve(
|
|
61
|
+
self, direction: int, reference: float, spec: SymbolSpec, what: str
|
|
62
|
+
) -> Result[float, TradeError]:
|
|
63
|
+
# Resolved without a sign check here: price_on_grid is the single place a
|
|
64
|
+
# level below zero (a long points distance) or off the grid is refused.
|
|
65
|
+
level = self.value
|
|
66
|
+
if self.kind is StopKind.POINTS:
|
|
67
|
+
distance = spec.points_to_price(self.value)
|
|
68
|
+
if isinstance(distance, Err):
|
|
69
|
+
return distance
|
|
70
|
+
level = reference + direction * distance.value
|
|
71
|
+
return spec.price_on_grid(level, ErrorKind.INVALID_STOPS, what)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _checked(value: object, kind: StopKind) -> Result[float, TradeError]:
|
|
75
|
+
# A wrong kind is a programming error, raised for every value alike (also by
|
|
76
|
+
# Stop.parse), rather than read as a price.
|
|
77
|
+
if not isinstance(kind, StopKind):
|
|
78
|
+
raise TypeError(f"kind must be a StopKind, got {kind!r}")
|
|
79
|
+
# A points distance drops its sign: direction comes from the trade side, so
|
|
80
|
+
# `-1000` and `1000` both mean "1000 points on the losing side".
|
|
81
|
+
number = positive_float(value, unsigned=kind is StopKind.POINTS)
|
|
82
|
+
if number is None:
|
|
83
|
+
message = f"{kind.value} stop must be a positive finite number, got {value!r}"
|
|
84
|
+
return Err(TradeError(ErrorKind.INVALID_STOPS, message))
|
|
85
|
+
return Ok(number)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Instrument specification and the rules that depend only on it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import Enum
|
|
8
|
+
|
|
9
|
+
from PythonMetaTrader5.domain.errors import ErrorKind, TradeError
|
|
10
|
+
from PythonMetaTrader5.result import Err, Ok, Result
|
|
11
|
+
|
|
12
|
+
# Bit flags of SYMBOL_FILLING_MODE as documented in MQL5. The Python package does
|
|
13
|
+
# not export them, so they are defined here rather than guessed at call sites.
|
|
14
|
+
_FILLING_FOK_FLAG = 1
|
|
15
|
+
_FILLING_IOC_FLAG = 2
|
|
16
|
+
|
|
17
|
+
# Tolerances for float checks: volumes are entered as decimal literals (0.07)
|
|
18
|
+
# that are not exactly representable in binary floating point, so an exact
|
|
19
|
+
# comparison would reject valid lots.
|
|
20
|
+
_EPSILON = 1e-9
|
|
21
|
+
_STEP_TOLERANCE = 1e-6
|
|
22
|
+
# Volume precision sent to the server; well below any broker's volume_step.
|
|
23
|
+
_VOLUME_DECIMALS = 8
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Filling(Enum):
|
|
27
|
+
FOK = "fok"
|
|
28
|
+
IOC = "ioc"
|
|
29
|
+
RETURN = "return"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class SymbolSpec:
|
|
34
|
+
name: str
|
|
35
|
+
digits: int
|
|
36
|
+
point: float
|
|
37
|
+
tick_size: float
|
|
38
|
+
volume_min: float
|
|
39
|
+
volume_max: float
|
|
40
|
+
volume_step: float
|
|
41
|
+
filling_flags: int
|
|
42
|
+
# Required on purpose: a default would quietly treat an exchange symbol as
|
|
43
|
+
# market execution and send limits that cancel instead of resting.
|
|
44
|
+
exchange_execution: bool
|
|
45
|
+
|
|
46
|
+
# Broken terminal data (a point of 0 or inf, a NaN tick size, a volume step
|
|
47
|
+
# of 0) is reported as BROKEN_SYMBOL_DATA: it is the broker's fault, so a bot
|
|
48
|
+
# must not file it as a bad signal, nor resend as after a TERMINAL_ERROR.
|
|
49
|
+
# It is checked on use rather than refused for the whole spec, because
|
|
50
|
+
# closing a position needs only filling_flags and must keep working on such
|
|
51
|
+
# a symbol (a constructor invariant was tried and dropped for exactly that).
|
|
52
|
+
# Entries call check_tradable() first.
|
|
53
|
+
|
|
54
|
+
def check_tradable(self) -> Result[None, TradeError]:
|
|
55
|
+
problem = self._pricing_problem() or self._volume_problem()
|
|
56
|
+
return Ok(None) if problem is None else Err(self._data_error(problem))
|
|
57
|
+
|
|
58
|
+
def price_on_grid(self, price: float, kind: ErrorKind, what: str) -> Result[float, TradeError]:
|
|
59
|
+
"""``price`` on the tick grid, refused if it is not a usable positive level.
|
|
60
|
+
|
|
61
|
+
Every caller-supplied level (an explicit entry price, sl, tp) goes through
|
|
62
|
+
here; quote prices come from the terminal already on the grid. The two
|
|
63
|
+
ways a positive finite input still goes wrong are checked once: 1e305 /
|
|
64
|
+
1e-5 overflows to inf (round(inf) raises), and 1e-6 snaps to 0.0, which
|
|
65
|
+
MT5 reads as "no stop" or "fill at the market".
|
|
66
|
+
"""
|
|
67
|
+
problem = self._pricing_problem()
|
|
68
|
+
if problem is not None:
|
|
69
|
+
return Err(self._data_error(problem))
|
|
70
|
+
# A price must sit on the tick grid, not merely have `digits` decimals:
|
|
71
|
+
# index CFDs often have tick 0.25 with 2 digits and reject 5780.13.
|
|
72
|
+
step = self._step
|
|
73
|
+
if not math.isfinite(price / step):
|
|
74
|
+
return Err(TradeError(kind, f"{what} {price} is out of range for {self.name}"))
|
|
75
|
+
level = round(round(price / step) * step, self.digits)
|
|
76
|
+
if level <= 0:
|
|
77
|
+
return Err(TradeError(kind, f"{what} {price} snaps to {level} on {self.name}"))
|
|
78
|
+
return Ok(level)
|
|
79
|
+
|
|
80
|
+
def points_to_price(self, points: float) -> Result[float, TradeError]:
|
|
81
|
+
# Only points stops need `point`: a symbol with a valid tick size and a
|
|
82
|
+
# point of 0 still trades absolute prices.
|
|
83
|
+
if not _usable(self.point):
|
|
84
|
+
return Err(self._data_error(f"point {self.point!r} cannot measure a points stop"))
|
|
85
|
+
return Ok(points * self.point)
|
|
86
|
+
|
|
87
|
+
def validate_volume(self, volume: float) -> Result[float, TradeError]:
|
|
88
|
+
problem = self._volume_problem()
|
|
89
|
+
if problem is not None:
|
|
90
|
+
return Err(self._data_error(problem))
|
|
91
|
+
if volume < self.volume_min - _EPSILON or volume > self.volume_max + _EPSILON:
|
|
92
|
+
return Err(self._volume_error(volume, "outside"))
|
|
93
|
+
steps = (volume - self.volume_min) / self.volume_step
|
|
94
|
+
if abs(steps - round(steps)) > _STEP_TOLERANCE:
|
|
95
|
+
return Err(self._volume_error(volume, "not a multiple of the step within"))
|
|
96
|
+
snapped = self.volume_min + round(steps) * self.volume_step
|
|
97
|
+
return Ok(round(snapped, _VOLUME_DECIMALS))
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def _step(self) -> float:
|
|
101
|
+
# A tick size of 0 means the terminal did not report one; point is used.
|
|
102
|
+
return self.tick_size if self.tick_size > 0 else self.point
|
|
103
|
+
|
|
104
|
+
def _pricing_problem(self) -> str | None:
|
|
105
|
+
# Checked before _step is used: a NaN or negative tick size would
|
|
106
|
+
# otherwise fall back to point without anyone noticing.
|
|
107
|
+
if not (self.tick_size == 0 or _usable(self.tick_size)):
|
|
108
|
+
return f"tick size {self.tick_size!r} is neither 0 nor positive and finite"
|
|
109
|
+
if not _usable(self._step):
|
|
110
|
+
return f"no usable tick size or point (point {self.point!r})"
|
|
111
|
+
if self.digits < 0:
|
|
112
|
+
return f"digits {self.digits} is negative"
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
def _volume_problem(self) -> str | None:
|
|
116
|
+
limits = (self.volume_min, self.volume_max, self.volume_step)
|
|
117
|
+
if not all(_usable(x) for x in limits):
|
|
118
|
+
return f"volume min/max/step {limits} are not all positive and finite"
|
|
119
|
+
if self.volume_max < self.volume_min:
|
|
120
|
+
return f"volume_max {self.volume_max} is below volume_min {self.volume_min}"
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
def _data_error(self, problem: str) -> TradeError:
|
|
124
|
+
return TradeError(ErrorKind.BROKEN_SYMBOL_DATA, f"{self.name}: {problem}")
|
|
125
|
+
|
|
126
|
+
def market_filling(self) -> Filling:
|
|
127
|
+
# FOK first: a webhook bot asking for 1 lot must get 1 lot or nothing.
|
|
128
|
+
# IOC could silently leave it with a partial fill it never asked for.
|
|
129
|
+
if self.filling_flags & _FILLING_FOK_FLAG:
|
|
130
|
+
return Filling.FOK
|
|
131
|
+
if self.filling_flags & _FILLING_IOC_FLAG:
|
|
132
|
+
return Filling.IOC
|
|
133
|
+
return Filling.RETURN
|
|
134
|
+
|
|
135
|
+
def pending_filling(self) -> Filling:
|
|
136
|
+
# Exchange-execution symbols (stocks, futures) only let a limit rest in the
|
|
137
|
+
# book with RETURN; FOK/IOC there would cancel it unless instantly fillable.
|
|
138
|
+
# Elsewhere the market rule applies: RETURN is refused by many
|
|
139
|
+
# market-execution brokers (10030) that accepted the IOC sent by 2.x.
|
|
140
|
+
return Filling.RETURN if self.exchange_execution else self.market_filling()
|
|
141
|
+
|
|
142
|
+
def _volume_error(self, volume: float, reason: str) -> TradeError:
|
|
143
|
+
return TradeError(
|
|
144
|
+
ErrorKind.INVALID_VOLUME,
|
|
145
|
+
f"{self.name}: volume {volume} is {reason} "
|
|
146
|
+
f"[{self.volume_min}, {self.volume_max}] step {self.volume_step}",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _usable(value: float) -> bool:
|
|
151
|
+
# The type check keeps a hand-built spec (point=None, point=True) on the
|
|
152
|
+
# Result path instead of raising TypeError from math.isfinite.
|
|
153
|
+
return (
|
|
154
|
+
isinstance(value, (int, float))
|
|
155
|
+
and not isinstance(value, bool)
|
|
156
|
+
and math.isfinite(value)
|
|
157
|
+
and value > 0
|
|
158
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Deprecated 2.x API kept so existing scripts run unchanged on 3.x."""
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""2.x argument conventions translated into 3.x values.
|
|
2
|
+
|
|
3
|
+
Split out of broker.py (300-line limit); everything here is pure.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from PythonMetaTrader5.domain.stops import Stop, StopKind
|
|
12
|
+
from PythonMetaTrader5.result import Err, Ok, Result
|
|
13
|
+
|
|
14
|
+
log = logging.getLogger("PythonMetaTrader5.legacy")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def number(value: Any) -> Any:
|
|
18
|
+
"""Convert numeric strings with float() as 2.x did, raising on garbage as it did.
|
|
19
|
+
|
|
20
|
+
Only strings are converted: float(True) would turn a stray bool into one
|
|
21
|
+
lot, so every other type goes on unchanged and is checked by the Trader.
|
|
22
|
+
"""
|
|
23
|
+
return float(value) if isinstance(value, str) else value
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def legacy_stop(value: Any, kind: str) -> Result[Stop | None, str]:
|
|
27
|
+
if value is None:
|
|
28
|
+
return Ok(None)
|
|
29
|
+
match (kind or "absolute").lower():
|
|
30
|
+
case "absolute":
|
|
31
|
+
stop_kind = StopKind.PRICE
|
|
32
|
+
case "offset":
|
|
33
|
+
stop_kind = StopKind.POINTS
|
|
34
|
+
case other:
|
|
35
|
+
# 2.x opened the position with neither SL nor TP here.
|
|
36
|
+
log.error("invalid stop type %r (use 'absolute' or 'offset')", other)
|
|
37
|
+
return Err(other)
|
|
38
|
+
parsed = Stop.parse(value, stop_kind)
|
|
39
|
+
if isinstance(parsed, Err):
|
|
40
|
+
# sl=0 meant "no stop" to MT5 in 2.x; refusing it beats an unprotected entry.
|
|
41
|
+
log.error("%s", parsed.error)
|
|
42
|
+
return Err(parsed.error.message)
|
|
43
|
+
return Ok(parsed.value)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def no_ticket(ticket: int | None) -> bool:
|
|
47
|
+
if ticket is None:
|
|
48
|
+
return True
|
|
49
|
+
# 2.x forwarded it as `position` on an opening deal, which on hedging
|
|
50
|
+
# accounts turns an opposite-side entry into a close of that position.
|
|
51
|
+
log.error("the ticket argument is not supported for entries; use Close(ticket=...)")
|
|
52
|
+
return False
|