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,108 @@
1
+ """Sending a request with a bounded, outcome-aware retry loop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass, replace
8
+
9
+ from PythonMetaTrader5.application.ports import Clock, Terminal
10
+ from PythonMetaTrader5.domain.errors import ErrorKind, TradeError
11
+ from PythonMetaTrader5.domain.market import TradeReceipt
12
+ from PythonMetaTrader5.domain.requests import CancelRequest, OrderRequest
13
+ from PythonMetaTrader5.domain.retcodes import DONE, DONE_PARTIAL, Outcome, classify, error_for
14
+ from PythonMetaTrader5.result import Err, Ok, Result
15
+
16
+ log = logging.getLogger("PythonMetaTrader5")
17
+
18
+ RequestFactory = Callable[[], Result[OrderRequest | CancelRequest, TradeError]]
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class RetryPolicy:
23
+ attempts: int = 3
24
+ delay_seconds: float = 0.5
25
+
26
+ def __post_init__(self) -> None:
27
+ # Misconfiguration is a programming error, not a market outcome, so it
28
+ # fails loudly at construction instead of travelling as a Result.
29
+ if self.attempts < 1 or self.delay_seconds < 0:
30
+ raise ValueError(f"invalid retry policy: {self}")
31
+
32
+
33
+ def execute(
34
+ terminal: Terminal, clock: Clock, build: RequestFactory, policy: RetryPolicy
35
+ ) -> Result[TradeReceipt, TradeError]:
36
+ """Rebuild and send the request until it succeeds, fails, or attempts run out.
37
+
38
+ The request is rebuilt on every attempt so a requote is answered with the
39
+ current price instead of re-sending the stale one.
40
+ """
41
+ last: TradeReceipt | None = None
42
+ for attempt in range(1, policy.attempts + 1):
43
+ request = build()
44
+ if isinstance(request, Err):
45
+ # Nothing was sent, so waiting is safe; a symbol just added to Market
46
+ # Watch typically has no quote for the first few hundred milliseconds.
47
+ if request.error.kind is ErrorKind.NO_PRICE and attempt < policy.attempts:
48
+ clock.sleep(policy.delay_seconds)
49
+ continue
50
+ return request
51
+ sent = terminal.send(request.value)
52
+ if isinstance(sent, Err):
53
+ return sent
54
+ last = sent.value
55
+ outcome = classify(last.retcode)
56
+ if outcome is Outcome.SUCCESS:
57
+ return _confirm(request.value, last)
58
+ if outcome is Outcome.FAIL:
59
+ return Err(replace(error_for(last.retcode), receipt=last))
60
+ log.warning("retcode %s on attempt %s/%s", last.retcode, attempt, policy.attempts)
61
+ if attempt < policy.attempts:
62
+ clock.sleep(policy.delay_seconds)
63
+ if last is None: # pragma: no cover - range(1, attempts + 1) is never empty
64
+ raise AssertionError("retry loop ran zero times")
65
+ return Err(
66
+ TradeError(
67
+ ErrorKind.RETRIES_EXHAUSTED,
68
+ f"gave up after {policy.attempts} attempts: {error_for(last.retcode).message}",
69
+ retcode=last.retcode,
70
+ receipt=last,
71
+ )
72
+ )
73
+
74
+
75
+ def _confirm(
76
+ request: OrderRequest | CancelRequest, receipt: TradeReceipt
77
+ ) -> Result[TradeReceipt, TradeError]:
78
+ """Only DONE proves a market deal (entry or close) executed in full.
79
+
80
+ PARTIAL leaves volume unfilled (an entry is smaller than asked, a close leaves
81
+ a remainder open) and PLACED means queued, not executed. Both come back as
82
+ errors carrying the receipt, and neither is retried: part of the deal may
83
+ already exist, so re-sending could double it.
84
+ """
85
+ if (
86
+ not isinstance(request, OrderRequest)
87
+ or request.pending is not None
88
+ or receipt.retcode == DONE
89
+ ):
90
+ return Ok(receipt)
91
+ if receipt.retcode == DONE_PARTIAL:
92
+ return Err(
93
+ TradeError(
94
+ ErrorKind.PARTIAL_FILL,
95
+ f"executed {receipt.volume} of {request.volume} lots; with RETURN "
96
+ "filling the rest may still be working as an order",
97
+ retcode=receipt.retcode,
98
+ receipt=receipt,
99
+ )
100
+ )
101
+ return Err(
102
+ TradeError(
103
+ ErrorKind.OUTCOME_UNKNOWN,
104
+ "accepted but not executed yet; check positions before re-sending",
105
+ retcode=receipt.retcode,
106
+ receipt=receipt,
107
+ )
108
+ )
@@ -0,0 +1,75 @@
1
+ """Which magic a close-all / cancel-all acts on when the caller gives none (ADR 0011).
2
+
3
+ Kept apart from Trader: the rule and the warnings it owes are one decision,
4
+ and they would otherwise push trader.py past the file-length limit.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from collections.abc import Callable
11
+ from typing import TypeVar
12
+
13
+ from PythonMetaTrader5.domain.errors import TradeError
14
+ from PythonMetaTrader5.domain.inputs import ANY_MAGIC, AnyMagic, magic_filter
15
+ from PythonMetaTrader5.result import Ok, Result
16
+
17
+ log = logging.getLogger("PythonMetaTrader5")
18
+ _T = TypeVar("_T")
19
+
20
+
21
+ def resolve(
22
+ own: int, magic: int | AnyMagic | None, ticket: int | None
23
+ ) -> Result[int | AnyMagic, TradeError]:
24
+ """The magic to filter by: the caller's, else the trader's own."""
25
+ checked = magic_filter(magic)
26
+ if not isinstance(checked, Ok):
27
+ return checked
28
+ if checked.value is not None:
29
+ return Ok(checked.value)
30
+ # A named ticket is explicit intent: filtering it out by magic would
31
+ # report Ok([]) for a position the caller asked to close.
32
+ return Ok(ANY_MAGIC if ticket is not None else own)
33
+
34
+
35
+ def warn_about_default(
36
+ own: int,
37
+ magic: object,
38
+ ticket: int | None,
39
+ matched: list[_T],
40
+ everything: Callable[[], Result[list[_T], TradeError]],
41
+ ) -> None:
42
+ """Say what the own-magic default cannot know; quiet once a magic is given."""
43
+ if magic is not None or ticket is not None:
44
+ return
45
+ if matched:
46
+ if own == 0:
47
+ # magic 0 stays allowed (it is the connect() default), but then
48
+ # "own" includes every manual trade.
49
+ log.warning(
50
+ "trader magic is 0, which manual trades share: this close/cancel "
51
+ "includes them; set magic at connect(), or pass magic=0 to confirm"
52
+ )
53
+ return
54
+ # Matched nothing: on netting the bot's deal may have merged into a position
55
+ # that kept another magic, and Ok([]) alone would read as "flat". The extra
56
+ # listing runs only on this path.
57
+ others = everything()
58
+ if not isinstance(others, Ok):
59
+ # Not turned into an Err: the close/cancel itself did what was asked,
60
+ # and failing it would make a bot retry an action that already ran.
61
+ log.warning(
62
+ "nothing with magic %s to close/cancel, and could not check for items "
63
+ "with other magics left open: %s",
64
+ own,
65
+ others.error,
66
+ )
67
+ return
68
+ if others.value:
69
+ log.warning(
70
+ "nothing with magic %s to close/cancel; %d item(s) with other magics "
71
+ "left open (on netting a deal merges into a position that keeps its "
72
+ "first magic); pass magic=ANY_MAGIC to include them",
73
+ own,
74
+ len(others.value),
75
+ )
@@ -0,0 +1,75 @@
1
+ """The only two process boundaries of the library: the MT5 terminal and time."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import operator
6
+ from dataclasses import dataclass, field
7
+ from typing import Protocol
8
+
9
+ from PythonMetaTrader5.domain.errors import TradeError
10
+ from PythonMetaTrader5.domain.market import PendingOrder, Position, Tick, TradeReceipt
11
+ from PythonMetaTrader5.domain.requests import CancelRequest, OrderRequest
12
+ from PythonMetaTrader5.domain.symbol import SymbolSpec
13
+ from PythonMetaTrader5.result import Result
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class Credentials:
18
+ login: int
19
+ # repr=False keeps the password out of logs, tracebacks and debugger dumps.
20
+ password: str = field(repr=False)
21
+ server: str
22
+ path: str | None = None
23
+ timeout_ms: int = 60_000
24
+
25
+ def __post_init__(self) -> None:
26
+ # The type itself enforces the login rule, so no construction path can
27
+ # hand MT5 True or a float that it would turn into another account.
28
+ if parse_login(self.login) != self.login:
29
+ raise ValueError(f"login must be a positive integer, got {self.login!r}")
30
+
31
+
32
+ def parse_login(login: object) -> int | None:
33
+ """Account number from an int-like value or an ASCII digit string, else None.
34
+
35
+ int() is deliberately avoided: int(True) == 1 and int(12345678.9) == 12345678
36
+ would log into another account without any error, and int() accepts
37
+ non-ASCII digits such as "\u0661\u0662\u0663".
38
+ """
39
+ if isinstance(login, bool):
40
+ return None
41
+ if isinstance(login, str):
42
+ text = login.strip()
43
+ number = int(text) if text.isascii() and text.isdecimal() else 0
44
+ else:
45
+ try:
46
+ number = operator.index(login) # type: ignore[arg-type]
47
+ except TypeError:
48
+ return None
49
+ return number if number > 0 else None
50
+
51
+
52
+ class Terminal(Protocol):
53
+ def connect(self, credentials: Credentials) -> Result[None, TradeError]: ...
54
+
55
+ def shutdown(self) -> None: ...
56
+
57
+ def symbol(self, name: str) -> Result[SymbolSpec, TradeError]: ...
58
+
59
+ def tick(self, name: str) -> Result[Tick, TradeError]: ...
60
+
61
+ def send(self, request: OrderRequest | CancelRequest) -> Result[TradeReceipt, TradeError]:
62
+ """Err only when the request could not be delivered; broker refusals are receipts."""
63
+ ...
64
+
65
+ def positions(
66
+ self, *, symbol: str | None = None, ticket: int | None = None
67
+ ) -> Result[list[Position], TradeError]: ...
68
+
69
+ def pending_orders(
70
+ self, *, symbol: str | None = None
71
+ ) -> Result[list[PendingOrder], TradeError]: ...
72
+
73
+
74
+ class Clock(Protocol):
75
+ def sleep(self, seconds: float) -> None: ...
@@ -0,0 +1,275 @@
1
+ """Trading use cases: open, close, place and cancel orders, query state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+ from types import TracebackType
7
+
8
+ from PythonMetaTrader5.application import magic_scope
9
+ from PythonMetaTrader5.application.batch import BatchFailure, run_batch, with_magic
10
+ from PythonMetaTrader5.application.entries import StopInput, validate_entry
11
+ from PythonMetaTrader5.application.execution import RetryPolicy, execute
12
+ from PythonMetaTrader5.application.ports import Clock, Terminal
13
+ from PythonMetaTrader5.domain.errors import TradeError
14
+ from PythonMetaTrader5.domain.inputs import (
15
+ ANY_MAGIC,
16
+ AnyMagic,
17
+ magic_filter,
18
+ own_magic,
19
+ symbol_filter,
20
+ ticket_filter,
21
+ )
22
+ from PythonMetaTrader5.domain.market import PendingKind, PendingOrder, Position, TradeReceipt
23
+ from PythonMetaTrader5.domain.orders import Side, validate_comment
24
+ from PythonMetaTrader5.domain.requests import CancelRequest, OrderRequest
25
+ from PythonMetaTrader5.domain.symbol import SymbolSpec
26
+ from PythonMetaTrader5.result import Err, Ok, Result
27
+
28
+
29
+ class Trader:
30
+ def __init__(
31
+ self,
32
+ terminal: Terminal,
33
+ clock: Clock,
34
+ *,
35
+ magic: int = 0,
36
+ deviation: int = 10,
37
+ retry: RetryPolicy | None = None,
38
+ ) -> None:
39
+ self._terminal = terminal
40
+ self._clock = clock
41
+ own = own_magic(magic)
42
+ if isinstance(own, Err):
43
+ # A constructor cannot return a Result; connect() reports this as Err.
44
+ raise ValueError(own.error.message)
45
+ self._magic = own.value
46
+ self._deviation = deviation
47
+ self._retry = retry or RetryPolicy()
48
+
49
+ # ------------------------------------------------------------------ entries
50
+
51
+ def open_position(
52
+ self,
53
+ symbol: str,
54
+ side: Side | str,
55
+ volume: float,
56
+ *,
57
+ sl: StopInput = None,
58
+ tp: StopInput = None,
59
+ price: float | None = None,
60
+ comment: str | None = None,
61
+ ) -> Result[TradeReceipt, TradeError]:
62
+ """Open a market position; ``sl``/``tp`` floats are prices, ``Stop.points`` distances.
63
+
64
+ Without ``price`` the current quote is used and requotes are retried with a
65
+ fresh quote. An explicit ``price`` is sent once: re-sending the same price
66
+ after a requote cannot succeed.
67
+ """
68
+ entry = validate_entry(
69
+ self._terminal, symbol, side, volume, comment=comment, sl=sl, tp=tp, price=price
70
+ )
71
+ if isinstance(entry, Err):
72
+ return entry
73
+ e = entry.value
74
+
75
+ def build() -> Result[OrderRequest | CancelRequest, TradeError]:
76
+ quote = None
77
+ if e.price is None:
78
+ tick = self._terminal.tick(symbol)
79
+ if isinstance(tick, Err):
80
+ return tick
81
+ quote = tick.value.entry_price(e.side)
82
+ return e.request(quote, pending=None, deviation=self._deviation, magic=self._magic)
83
+
84
+ policy = self._retry if e.price is None else replace(self._retry, attempts=1)
85
+ return execute(self._terminal, self._clock, build, policy)
86
+
87
+ def place_limit_order(
88
+ self,
89
+ symbol: str,
90
+ side: Side | str,
91
+ volume: float,
92
+ price: float,
93
+ *,
94
+ sl: StopInput = None,
95
+ tp: StopInput = None,
96
+ comment: str | None = None,
97
+ ) -> Result[TradeReceipt, TradeError]:
98
+ """Place a BUY/SELL LIMIT at ``price``; point stops are measured from that level.
99
+
100
+ A limit on the wrong side of the market is refused by the server
101
+ (INVALID_PRICE) rather than converted into a market order.
102
+ """
103
+ entry = validate_entry(
104
+ self._terminal,
105
+ symbol,
106
+ side,
107
+ volume,
108
+ comment=comment,
109
+ sl=sl,
110
+ tp=tp,
111
+ price=price,
112
+ price_required=True,
113
+ )
114
+ if isinstance(entry, Err):
115
+ return entry
116
+ request = entry.value.request(
117
+ pending=PendingKind.LIMIT, deviation=self._deviation, magic=self._magic
118
+ )
119
+ if isinstance(request, Err):
120
+ return request
121
+ return execute(self._terminal, self._clock, lambda: request, self._retry)
122
+
123
+ # ------------------------------------------------------------------- exits
124
+
125
+ def close_positions(
126
+ self,
127
+ symbol: str | None = None,
128
+ *,
129
+ ticket: int | None = None,
130
+ magic: int | AnyMagic | None = None,
131
+ comment: str | None = None,
132
+ ) -> Result[list[TradeReceipt], BatchFailure]:
133
+ """Close matching positions; ``ticket`` takes precedence over ``symbol``.
134
+
135
+ Only the trader's own ``magic`` unless another ``magic`` is given;
136
+ ``magic=ANY_MAGIC`` closes every position, manual ones included. A
137
+ ``ticket`` is closed whatever its magic.
138
+ """
139
+ text = validate_comment(comment)
140
+ if isinstance(text, Err):
141
+ return Err(BatchFailure((), (text.error,)))
142
+ own = magic_scope.resolve(self._magic, magic, ticket)
143
+ if isinstance(own, Err):
144
+ return Err(BatchFailure((), (own.error,)))
145
+ listed = self.positions(
146
+ None if ticket is not None else symbol, ticket=ticket, magic=own.value
147
+ )
148
+ if isinstance(listed, Err):
149
+ return Err(BatchFailure((), (listed.error,)))
150
+ scope = None if ticket is not None else symbol
151
+ magic_scope.warn_about_default(
152
+ self._magic, magic, ticket, listed.value, lambda: self.positions(scope, magic=ANY_MAGIC)
153
+ )
154
+ # One spec lookup per symbol, not per position: close-all is time-critical.
155
+ specs: dict[str, Result[SymbolSpec, TradeError]] = {}
156
+
157
+ def close(position: Position) -> Result[TradeReceipt, TradeError]:
158
+ if position.symbol not in specs:
159
+ specs[position.symbol] = self._terminal.symbol(position.symbol)
160
+ return self._close_one(position, specs[position.symbol], text.value)
161
+
162
+ return run_batch(listed.value, close)
163
+
164
+ def cancel_pending_orders(
165
+ self,
166
+ symbol: str | None = None,
167
+ *,
168
+ magic: int | AnyMagic | None = None,
169
+ kind: PendingKind | None = None,
170
+ ) -> Result[list[TradeReceipt], BatchFailure]:
171
+ """Cancel matching pending orders of the trader's own ``magic`` by default.
172
+
173
+ ``magic=ANY_MAGIC`` cancels every order on the account, manual ones included.
174
+ """
175
+ own = magic_scope.resolve(self._magic, magic, None)
176
+ if isinstance(own, Err):
177
+ return Err(BatchFailure((), (own.error,)))
178
+ listed = self.pending_orders(symbol, magic=own.value, kind=kind)
179
+ if isinstance(listed, Err):
180
+ return Err(BatchFailure((), (listed.error,)))
181
+ magic_scope.warn_about_default(
182
+ self._magic,
183
+ magic,
184
+ None,
185
+ listed.value,
186
+ lambda: self.pending_orders(symbol, magic=ANY_MAGIC, kind=kind),
187
+ )
188
+ return run_batch(listed.value, self._cancel_one)
189
+
190
+ # ----------------------------------------------------------------- queries
191
+
192
+ def positions(
193
+ self,
194
+ symbol: str | None = None,
195
+ *,
196
+ ticket: int | None = None,
197
+ magic: int | AnyMagic | None = None,
198
+ ) -> Result[list[Position], TradeError]:
199
+ """Every position by default: a read never needs the own-magic guard."""
200
+ checked = magic_filter(magic)
201
+ if isinstance(checked, Err):
202
+ return checked
203
+ for check in (symbol_filter(symbol), ticket_filter(ticket)):
204
+ if isinstance(check, Err):
205
+ return check
206
+ listed = self._terminal.positions(symbol=symbol, ticket=ticket)
207
+ if isinstance(listed, Err):
208
+ return listed
209
+ return Ok(with_magic(listed.value, checked.value))
210
+
211
+ def pending_orders(
212
+ self,
213
+ symbol: str | None = None,
214
+ *,
215
+ magic: int | AnyMagic | None = None,
216
+ kind: PendingKind | None = None,
217
+ ) -> Result[list[PendingOrder], TradeError]:
218
+ checked = magic_filter(magic)
219
+ if isinstance(checked, Err):
220
+ return checked
221
+ symbol_checked = symbol_filter(symbol)
222
+ if isinstance(symbol_checked, Err):
223
+ return symbol_checked
224
+ listed = self._terminal.pending_orders(symbol=symbol)
225
+ if isinstance(listed, Err):
226
+ return listed
227
+ orders = with_magic(listed.value, checked.value)
228
+ return Ok(orders if kind is None else [o for o in orders if o.kind is kind])
229
+
230
+ def shutdown(self) -> None:
231
+ self._terminal.shutdown()
232
+
233
+ def __enter__(self) -> Trader:
234
+ return self
235
+
236
+ def __exit__(
237
+ self,
238
+ exc_type: type[BaseException] | None,
239
+ exc: BaseException | None,
240
+ tb: TracebackType | None,
241
+ ) -> None:
242
+ self.shutdown()
243
+
244
+ # ----------------------------------------------------------------- helpers
245
+
246
+ def _close_one(
247
+ self, position: Position, spec: Result[SymbolSpec, TradeError], comment: str
248
+ ) -> Result[TradeReceipt, TradeError]:
249
+ if isinstance(spec, Err):
250
+ return spec
251
+ filling = spec.value.market_filling()
252
+
253
+ def build() -> Result[OrderRequest | CancelRequest, TradeError]:
254
+ tick = self._terminal.tick(position.symbol)
255
+ if isinstance(tick, Err):
256
+ return tick
257
+ return Ok(
258
+ OrderRequest(
259
+ symbol=position.symbol,
260
+ side=position.side.opposite,
261
+ volume=position.volume,
262
+ price=tick.value.exit_price(position.side),
263
+ filling=filling,
264
+ deviation=self._deviation,
265
+ magic=self._magic,
266
+ comment=comment,
267
+ position=position.ticket,
268
+ )
269
+ )
270
+
271
+ return execute(self._terminal, self._clock, build, self._retry)
272
+
273
+ def _cancel_one(self, order: PendingOrder) -> Result[TradeReceipt, TradeError]:
274
+ request = CancelRequest(order=order.ticket)
275
+ return execute(self._terminal, self._clock, lambda: Ok(request), self._retry)
@@ -0,0 +1,52 @@
1
+ """Composition root: wires the real adapters into a Trader."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from PythonMetaTrader5.adapters.mt5_terminal import MetaTrader5Terminal
8
+ from PythonMetaTrader5.adapters.system_clock import SystemClock
9
+ from PythonMetaTrader5.application.execution import RetryPolicy
10
+ from PythonMetaTrader5.application.ports import Credentials, parse_login
11
+ from PythonMetaTrader5.application.trader import Trader
12
+ from PythonMetaTrader5.domain.errors import ErrorKind, TradeError
13
+ from PythonMetaTrader5.domain.inputs import own_magic
14
+ from PythonMetaTrader5.result import Err, Ok, Result
15
+
16
+ log = logging.getLogger("PythonMetaTrader5")
17
+
18
+
19
+ def connect(
20
+ login: int | str,
21
+ password: str,
22
+ server: str,
23
+ *,
24
+ path: str | None = None,
25
+ timeout_ms: int = 60_000,
26
+ magic: int = 0,
27
+ deviation: int = 10,
28
+ retry: RetryPolicy | None = None,
29
+ ) -> Result[Trader, TradeError]:
30
+ """Start (or attach to) the MT5 terminal, log in, and return a ready Trader."""
31
+ account = parse_login(login)
32
+ if account is None:
33
+ return Err(
34
+ TradeError(
35
+ ErrorKind.INVALID_ARGUMENT, f"login must be a positive integer, got {login!r}"
36
+ )
37
+ )
38
+ own = own_magic(magic)
39
+ if isinstance(own, Err):
40
+ return own
41
+ loaded = MetaTrader5Terminal.load()
42
+ if isinstance(loaded, Err):
43
+ return loaded
44
+ terminal = loaded.value
45
+ credentials = Credentials(account, str(password), str(server), path, timeout_ms)
46
+ connected = terminal.connect(credentials)
47
+ if isinstance(connected, Err):
48
+ return connected
49
+ # Login and server only: account_info() also carries the owner's name and
50
+ # balance, which do not belong in application logs.
51
+ log.info("connected to %s as %s", server, account)
52
+ return Ok(Trader(terminal, SystemClock(), magic=own.value, deviation=deviation, retry=retry))
@@ -0,0 +1 @@
1
+ """Pure trading rules: no I/O, no MetaTrader5 import."""
@@ -0,0 +1,33 @@
1
+ """The one rule for "a positive finite number", shared by Stop and the input checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from decimal import Decimal
7
+ from numbers import Real
8
+
9
+
10
+ def positive_float(value: object, *, unsigned: bool = False) -> float | None:
11
+ """``value`` as a positive finite float, or None when it is not one.
12
+
13
+ numbers.Real rather than float, so numpy scalars from pandas-based bots
14
+ pass, plus Decimal (not a Real) for bots that keep money as Decimal. bool
15
+ is excluded because it is a Real (True == 1) and would trade one lot.
16
+ NaN Decimals are refused before float(): float(Decimal("sNaN")) raises.
17
+ ``unsigned`` drops the sign first (a distance), after the NaN check, because
18
+ abs(Decimal("sNaN")) raises too.
19
+ """
20
+ if isinstance(value, bool) or not isinstance(value, (Real, Decimal)):
21
+ return None
22
+ if isinstance(value, Decimal) and value.is_nan():
23
+ return None
24
+ try:
25
+ number = abs(float(value)) if unsigned else float(value)
26
+ except ArithmeticError:
27
+ # None rather than raising: a number outside float range (10**400, or a
28
+ # numpy longdouble under np.seterr(over="raise")) is bad input like NaN,
29
+ # and raising would break the Result boundary. ValueError/TypeError are
30
+ # not caught: they are bugs in the caller's own type and must keep
31
+ # their traceback.
32
+ return None
33
+ return number if math.isfinite(number) and number > 0 else None
@@ -0,0 +1,53 @@
1
+ """Typed failure vocabulary shared by every layer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from PythonMetaTrader5.domain.market import TradeReceipt
11
+
12
+
13
+ class ErrorKind(Enum):
14
+ INVALID_ARGUMENT = "invalid_argument"
15
+ INVALID_VOLUME = "invalid_volume"
16
+ INVALID_PRICE = "invalid_price"
17
+ INVALID_STOPS = "invalid_stops"
18
+ INVALID_FILLING = "invalid_filling"
19
+ NO_MONEY = "no_money"
20
+ MARKET_CLOSED = "market_closed"
21
+ TRADE_DISABLED = "trade_disabled"
22
+ ALGO_TRADING_DISABLED = "algo_trading_disabled"
23
+ LIMIT_REACHED = "limit_reached"
24
+ # Only part of the volume was executed; the rest is still open/unfilled.
25
+ PARTIAL_FILL = "partial_fill"
26
+ # The request may or may not have reached the trade server: never auto-retried.
27
+ OUTCOME_UNKNOWN = "outcome_unknown"
28
+ RETRIES_EXHAUSTED = "retries_exhausted"
29
+ REJECTED = "rejected"
30
+ SYMBOL_NOT_FOUND = "symbol_not_found"
31
+ NO_PRICE = "no_price"
32
+ TERMINAL_ERROR = "terminal_error"
33
+ # The terminal reports symbol data that cannot price an order (a point of 0,
34
+ # a NaN tick size, a volume step of 0). Unlike TERMINAL_ERROR it does not
35
+ # clear on a resend, so it has its own kind: a bot must escalate, not retry.
36
+ BROKEN_SYMBOL_DATA = "broken_symbol_data"
37
+ CONNECTION_FAILED = "connection_failed"
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class TradeError:
42
+ kind: ErrorKind
43
+ message: str
44
+ retcode: int | None = None
45
+ # Kept so callers (and the 2.x facade) can inspect the broker's raw answer.
46
+ receipt: TradeReceipt | None = None
47
+ # Set when the failure concerns one position/order of a batch operation.
48
+ ticket: int | None = None
49
+
50
+ def __str__(self) -> str:
51
+ code = f" (retcode {self.retcode})" if self.retcode is not None else ""
52
+ ticket = f" [ticket {self.ticket}]" if self.ticket is not None else ""
53
+ return f"{self.kind.value}: {self.message}{code}{ticket}"