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