PythonMetaTrader5 2.2__py3-none-any.whl → 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +65 -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.0.dist-info/METADATA +231 -0
- pythonmetatrader5-3.0.0.dist-info/RECORD +36 -0
- {pythonmetatrader5-2.2.dist-info → pythonmetatrader5-3.0.0.dist-info}/WHEEL +1 -1
- pythonmetatrader5-3.0.0.dist-info/licenses/LICENSE +16 -0
- pythonmetatrader5-2.2.dist-info/METADATA +0 -29
- pythonmetatrader5-2.2.dist-info/RECORD +0 -5
- {pythonmetatrader5-2.2.dist-info → pythonmetatrader5-3.0.0.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,65 @@
|
|
|
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 isinstance(others, Ok) and others.value:
|
|
59
|
+
log.warning(
|
|
60
|
+
"nothing with magic %s to close/cancel; %d item(s) with other magics "
|
|
61
|
+
"left open (on netting a deal merges into a position that keeps its "
|
|
62
|
+
"first magic); pass magic=ANY_MAGIC to include them",
|
|
63
|
+
own,
|
|
64
|
+
len(others.value),
|
|
65
|
+
)
|
|
@@ -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}"
|