pmt4api 1.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.
- pmt4api/__init__.py +32 -0
- pmt4api/__main__.py +78 -0
- pmt4api/_account.py +253 -0
- pmt4api/_base.py +24 -0
- pmt4api/_coverage.py +186 -0
- pmt4api/_events.py +189 -0
- pmt4api/_market.py +263 -0
- pmt4api/_native/CHANGELOG.md +362 -0
- pmt4api/_native/THIRD-PARTY-NOTICES.txt +7 -0
- pmt4api/_native/docs/ERRORS.md +136 -0
- pmt4api/_native/docs/LICENSING.md +55 -0
- pmt4api/_native/docs/SECURITY.md +38 -0
- pmt4api/_native/wmt4api.dll +0 -0
- pmt4api/_risk.py +66 -0
- pmt4api/_runtime.py +154 -0
- pmt4api/_session.py +171 -0
- pmt4api/_streams.py +238 -0
- pmt4api/_trading.py +384 -0
- pmt4api/_util.py +78 -0
- pmt4api/bars.py +62 -0
- pmt4api/client.py +143 -0
- pmt4api/diagnostics.py +38 -0
- pmt4api/docs/API.md +702 -0
- pmt4api/docs/FROM-CSHARP.md +151 -0
- pmt4api/errors.py +233 -0
- pmt4api/frames.py +21 -0
- pmt4api/models.py +337 -0
- pmt4api/py.typed +0 -0
- pmt4api/servers.py +40 -0
- pmt4api-1.0.0.dist-info/METADATA +389 -0
- pmt4api-1.0.0.dist-info/RECORD +33 -0
- pmt4api-1.0.0.dist-info/WHEEL +4 -0
- pmt4api-1.0.0.dist-info/licenses/LICENSE.txt +29 -0
pmt4api/_risk.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Position sizing, margin and P/L arithmetic that the core computes for the account's own conventions."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from ._base import _Base
|
|
8
|
+
from ._util import enum_value, finite, translated
|
|
9
|
+
from .errors import InvalidRequestError
|
|
10
|
+
from .models import LotSizing
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _RiskMixin(_Base):
|
|
14
|
+
@translated
|
|
15
|
+
def lots_for_risk(self, symbol: str, entry_price: float, stop_price: float, risk_amount: float, *,
|
|
16
|
+
lot_step: Optional[float] = None, min_lot: Optional[float] = None,
|
|
17
|
+
max_ratio: Optional[float] = None, max_equity_share: Optional[float] = None,
|
|
18
|
+
max_lot: Optional[float] = None) -> LotSizing:
|
|
19
|
+
"""The volume that loses ``risk_amount`` (account currency) if ``stop_price`` is hit, with the evidence.
|
|
20
|
+
|
|
21
|
+
The value of a price unit comes from :meth:`tick_value` and the broker's step / minimum / maximum volume from
|
|
22
|
+
the account's own group (pass one to override it); a size that cannot be justified is refused, not guessed.
|
|
23
|
+
The volume is rounded **down** to the step. Check ``ok``: when false, ``lots`` is 0 and ``reason`` says which
|
|
24
|
+
check failed. Amounts are in the currency the account reports (no cent-account scaling is applied: check the
|
|
25
|
+
first result against your terminal).
|
|
26
|
+
"""
|
|
27
|
+
self._require_connected()
|
|
28
|
+
kwargs = {}
|
|
29
|
+
for key, value in (("lotStep", lot_step), ("minLot", min_lot), ("maxRatio", max_ratio),
|
|
30
|
+
("maxEquityShare", max_equity_share), ("maxLot", max_lot)):
|
|
31
|
+
if value is not None:
|
|
32
|
+
kwargs[key] = float(value)
|
|
33
|
+
result = self._api.CalculateLots(symbol, float(entry_price), float(stop_price), float(risk_amount), **kwargs)
|
|
34
|
+
return LotSizing._from_core(result)
|
|
35
|
+
|
|
36
|
+
@translated
|
|
37
|
+
def required_margin(self, symbol: str, lots: float, side: str = "buy", price: float = 0.0) -> float:
|
|
38
|
+
"""The margin needed to open ``lots`` of ``symbol``, computed as the terminal does (``price`` 0 = the live
|
|
39
|
+
quote; Forex uses the account leverage, CFD the symbol's margin divider). Raises :class:`InvalidRequestError`
|
|
40
|
+
when it cannot be computed (a Futures symbol, whose formula is not verified, or no conversion rate yet)."""
|
|
41
|
+
self._require_connected()
|
|
42
|
+
key = str(side).strip().lower()
|
|
43
|
+
if key not in ("buy", "sell"):
|
|
44
|
+
raise ValueError("side must be 'buy' or 'sell'")
|
|
45
|
+
self._spec(symbol)
|
|
46
|
+
kind = enum_value(self._core.OrderType, key, "side")
|
|
47
|
+
value = float(self._api.RequiredMargin(symbol, finite(lots, "lots", positive=True), kind,
|
|
48
|
+
finite(price, "price", non_negative=True)))
|
|
49
|
+
if math.isnan(value) or value <= 0.0:
|
|
50
|
+
raise InvalidRequestError("the margin of %s cannot be computed (a Futures symbol, or no conversion rate "
|
|
51
|
+
"yet)" % symbol)
|
|
52
|
+
return value
|
|
53
|
+
|
|
54
|
+
@translated
|
|
55
|
+
def order_profit(self, symbol: str, open_price: float, close_price: float, lots: float, buy: bool = True) -> float:
|
|
56
|
+
"""The P/L, in account currency, of ``lots`` moving from ``open_price`` to ``close_price``. For reporting a
|
|
57
|
+
completed or hypothetical move; use :meth:`lots_for_risk` to size. Raises :class:`InvalidRequestError` when the
|
|
58
|
+
value cannot be computed (no conversion rate yet)."""
|
|
59
|
+
self._require_connected()
|
|
60
|
+
self._spec(symbol)
|
|
61
|
+
value = float(self._api.CalculateOrderProfit(symbol, finite(open_price, "open_price"),
|
|
62
|
+
finite(close_price, "close_price"),
|
|
63
|
+
finite(lots, "lots", positive=True), bool(buy)))
|
|
64
|
+
if math.isnan(value):
|
|
65
|
+
raise InvalidRequestError("the profit of %s cannot be computed (no conversion rate yet)" % symbol)
|
|
66
|
+
return value
|
pmt4api/_runtime.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Locates the .NET runtime and the licensed core library and loads them once per process."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import glob
|
|
5
|
+
import importlib
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Iterator, Optional
|
|
12
|
+
|
|
13
|
+
from .errors import CoreNotFoundError, RuntimeNotFoundError
|
|
14
|
+
|
|
15
|
+
#: Environment variable that points at wmt4api.dll.
|
|
16
|
+
ENV_DLL = "WMT4API_DLL"
|
|
17
|
+
|
|
18
|
+
_NAMESPACE = "wmt4api" # the .NET namespace of the core; NOT the name of this Python package
|
|
19
|
+
_PKG = Path(__file__).resolve().parent
|
|
20
|
+
_BUNDLED_RUNTIME = _PKG / "_dotnet" # present in the platform wheels: host/fxr + shared/Microsoft.NETCore.App
|
|
21
|
+
_lock = threading.Lock()
|
|
22
|
+
_core = None
|
|
23
|
+
_dll: Optional[Path] = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def find_dll(explicit=None) -> Path:
|
|
27
|
+
"""The core library to load: the caller's path, else ``WMTAPI_DLL``, else the one bundled in the wheel.
|
|
28
|
+
|
|
29
|
+
A path the caller gave explicitly (argument or environment) is never silently replaced by another: if it does not
|
|
30
|
+
exist that is an error.
|
|
31
|
+
"""
|
|
32
|
+
requested = explicit or os.environ.get(ENV_DLL)
|
|
33
|
+
if requested:
|
|
34
|
+
path = Path(requested)
|
|
35
|
+
if path.is_file():
|
|
36
|
+
return path
|
|
37
|
+
raise CoreNotFoundError("wmt4api.dll was not found at %s (from %s)" %
|
|
38
|
+
(path, "dll_path" if explicit else ENV_DLL))
|
|
39
|
+
bundled = _PKG / "_native" / "wmt4api.dll"
|
|
40
|
+
if bundled.is_file():
|
|
41
|
+
return bundled
|
|
42
|
+
raise CoreNotFoundError(
|
|
43
|
+
"wmt4api.dll was not found. Pass dll_path=..., or set the " + ENV_DLL + " environment variable to the DLL "
|
|
44
|
+
"taken from your wmt4api package, or install a pmt4api wheel that bundles it (looked for " + str(bundled) + ")."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _dotnet_root_candidates() -> Iterator[Path]:
|
|
49
|
+
exe = shutil.which("dotnet")
|
|
50
|
+
if exe:
|
|
51
|
+
yield Path(exe).resolve().parent
|
|
52
|
+
home = Path.home()
|
|
53
|
+
yield home / ".dotnet"
|
|
54
|
+
for p in ("/usr/share/dotnet", "/usr/lib/dotnet", "/usr/local/share/dotnet", "/opt/dotnet"):
|
|
55
|
+
yield Path(p)
|
|
56
|
+
pf = os.environ.get("ProgramFiles")
|
|
57
|
+
if pf:
|
|
58
|
+
yield Path(pf) / "dotnet"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _icu_present() -> bool:
|
|
62
|
+
"""True when a system ICU library is installed (the .NET runtime needs one unless globalization is invariant)."""
|
|
63
|
+
patterns = ("/usr/lib*/libicuuc.so*", "/usr/lib/*-linux-gnu/libicuuc.so*", "/lib*/libicuuc.so*",
|
|
64
|
+
"/lib/*-linux-gnu/libicuuc.so*", "/usr/local/lib*/libicuuc.so*", "/opt/homebrew/lib/libicuuc*",
|
|
65
|
+
"/usr/lib/libicuuc*")
|
|
66
|
+
return any(glob.glob(p) for p in patterns)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _ensure_dotnet_root() -> None:
|
|
70
|
+
"""Point the runtime host at a .NET runtime: the caller's ``DOTNET_ROOT``, else the one bundled in this package,
|
|
71
|
+
else an installed one. Say so clearly when there is none."""
|
|
72
|
+
# Without an ICU library the runtime aborts the whole process (uncatchably) at start-up unless globalization is
|
|
73
|
+
# invariant. The core does not depend on culture-specific behaviour, so on a machine with no ICU ask for invariant
|
|
74
|
+
# mode — whichever runtime is used, and including a DOTNET_ROOT inherited from a parent process.
|
|
75
|
+
if sys.platform.startswith("linux") and not _icu_present():
|
|
76
|
+
os.environ.setdefault("DOTNET_SYSTEM_GLOBALIZATION_INVARIANT", "1")
|
|
77
|
+
if os.environ.get("DOTNET_ROOT"):
|
|
78
|
+
return
|
|
79
|
+
if (_BUNDLED_RUNTIME / "shared" / "Microsoft.NETCore.App").is_dir():
|
|
80
|
+
os.environ["DOTNET_ROOT"] = str(_BUNDLED_RUNTIME)
|
|
81
|
+
return
|
|
82
|
+
for root in _dotnet_root_candidates():
|
|
83
|
+
if (root / "shared" / "Microsoft.NETCore.App").is_dir():
|
|
84
|
+
os.environ["DOTNET_ROOT"] = str(root)
|
|
85
|
+
return
|
|
86
|
+
raise RuntimeNotFoundError(
|
|
87
|
+
"No .NET runtime was found. Install the .NET 8 (or later) runtime, or set DOTNET_ROOT to its install directory."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def runtime_root() -> Optional[Path]:
|
|
92
|
+
"""The .NET root in use (after ``load``), or None."""
|
|
93
|
+
root = os.environ.get("DOTNET_ROOT")
|
|
94
|
+
return Path(root) if root else None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _has_entry(path_entry: str, name: str) -> bool:
|
|
98
|
+
base = path_entry or os.getcwd()
|
|
99
|
+
try:
|
|
100
|
+
return os.path.isdir(os.path.join(base, name))
|
|
101
|
+
except OSError:
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _import_namespace():
|
|
106
|
+
"""Import the core's .NET namespace even if a same-named folder is lying around on sys.path."""
|
|
107
|
+
mod = sys.modules.get(_NAMESPACE)
|
|
108
|
+
if mod is not None and hasattr(mod, "MT4API"):
|
|
109
|
+
return mod
|
|
110
|
+
sys.modules.pop(_NAMESPACE, None)
|
|
111
|
+
saved = list(sys.path)
|
|
112
|
+
try:
|
|
113
|
+
sys.path[:] = [p for p in sys.path if not _has_entry(p, _NAMESPACE)]
|
|
114
|
+
importlib.invalidate_caches()
|
|
115
|
+
mod = importlib.import_module(_NAMESPACE)
|
|
116
|
+
finally:
|
|
117
|
+
sys.path[:] = saved
|
|
118
|
+
if not hasattr(mod, "MT4API"):
|
|
119
|
+
raise CoreNotFoundError("the loaded library does not expose MT4API; is it the wmt4api package?")
|
|
120
|
+
return mod
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def load(dll_path=None):
|
|
124
|
+
"""Load the runtime and the core (idempotent, thread-safe). Returns the core's .NET namespace module."""
|
|
125
|
+
global _core, _dll
|
|
126
|
+
with _lock:
|
|
127
|
+
if _core is not None:
|
|
128
|
+
if dll_path is not None and _dll is not None and Path(dll_path).resolve() != _dll.resolve():
|
|
129
|
+
raise CoreNotFoundError(
|
|
130
|
+
"a different core is already loaded in this process (%s); one process can use one core, "
|
|
131
|
+
"so dll_path=%s was not loaded" % (_dll, dll_path))
|
|
132
|
+
return _core
|
|
133
|
+
dll = find_dll(dll_path)
|
|
134
|
+
_ensure_dotnet_root()
|
|
135
|
+
try:
|
|
136
|
+
from pythonnet import load as _load
|
|
137
|
+
_load("coreclr")
|
|
138
|
+
import clr # noqa: F401 (provided by pythonnet)
|
|
139
|
+
except Exception as e: # noqa: BLE001 — anything here means the runtime could not start
|
|
140
|
+
raise RuntimeNotFoundError("could not start the .NET runtime: %s" % e) from e
|
|
141
|
+
import clr as _clr
|
|
142
|
+
from System.Reflection import Assembly # available once the runtime is up
|
|
143
|
+
try:
|
|
144
|
+
Assembly.LoadFrom(str(dll)) # AddReference(<absolute path>) fails on pythonnet 3.0.x
|
|
145
|
+
except Exception as e: # noqa: BLE001
|
|
146
|
+
raise CoreNotFoundError("could not load %s: %s" % (dll, e)) from e
|
|
147
|
+
_clr.AddReference("wmt4api") # now by name: registers the namespace for `import`
|
|
148
|
+
_core = _import_namespace()
|
|
149
|
+
_dll = dll
|
|
150
|
+
return _core
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def loaded_dll() -> Optional[Path]:
|
|
154
|
+
return _dll
|
pmt4api/_session.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Connection lifecycle and session information."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
from typing import Any, List, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
from ._base import _Base
|
|
9
|
+
from ._util import translated
|
|
10
|
+
from .errors import ConnectError, RequestTimeout
|
|
11
|
+
from .models import _time
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _SessionMixin(_Base):
|
|
15
|
+
# ---- lifecycle ---------------------------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
@translated
|
|
18
|
+
def connect(self) -> "_SessionMixin":
|
|
19
|
+
"""Connect, authorise through the licence service and load the account. Returns ``self``. Calling it on a
|
|
20
|
+
client that is already connected does nothing (it never opens a second session).
|
|
21
|
+
|
|
22
|
+
Raises :class:`LicenseError` / :class:`TokenServiceError` when the licence is refused or unreachable,
|
|
23
|
+
:class:`LoginRejectedError` when the broker rejects the account or password, and :class:`ConnectError` when
|
|
24
|
+
no access server could be reached within ``connect_timeout``.
|
|
25
|
+
"""
|
|
26
|
+
if self._api.Connected:
|
|
27
|
+
return self
|
|
28
|
+
try:
|
|
29
|
+
self._api.Connect()
|
|
30
|
+
except Exception as exc: # noqa: BLE001
|
|
31
|
+
# a dial that timed out is a connect failure, not "a request was sent and not confirmed"
|
|
32
|
+
from .errors import translate
|
|
33
|
+
mapped = translate(exc)
|
|
34
|
+
if isinstance(mapped, RequestTimeout):
|
|
35
|
+
raise ConnectError(str(mapped)) from exc
|
|
36
|
+
raise
|
|
37
|
+
return self
|
|
38
|
+
|
|
39
|
+
@translated
|
|
40
|
+
def reconnect(self) -> bool:
|
|
41
|
+
"""Re-establish a dropped session over the primary server, ``servers`` and the list the broker published, and
|
|
42
|
+
restore the quote subscriptions. ``True`` when connected afterwards (also when it already was), ``False`` when
|
|
43
|
+
every server failed — try again later. A refusal retrying cannot cure (an expired licence, a rejected login)
|
|
44
|
+
also returns ``False``: call :meth:`connect` to see the reason."""
|
|
45
|
+
return bool(self._api.Reconnect())
|
|
46
|
+
|
|
47
|
+
@translated
|
|
48
|
+
def disconnect(self) -> None:
|
|
49
|
+
"""Close the session and stop the core's reconnect watchdog for good — also when the link is down and the
|
|
50
|
+
watchdog is mid-way through reconnecting. Harmless when never connected."""
|
|
51
|
+
try:
|
|
52
|
+
self._api.Disconnect()
|
|
53
|
+
except Exception as exc: # noqa: BLE001
|
|
54
|
+
if type(exc).__name__ != "NullReferenceException": # never connected: nothing to close
|
|
55
|
+
raise
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def connected(self) -> bool:
|
|
59
|
+
"""Live: false when the socket died (the core notices a closed or reset connection at once and a silent one
|
|
60
|
+
within ``no_message_timeout``)."""
|
|
61
|
+
return bool(self._api.Connected)
|
|
62
|
+
|
|
63
|
+
def __enter__(self) -> "_SessionMixin":
|
|
64
|
+
return self.connect()
|
|
65
|
+
|
|
66
|
+
def __exit__(self, *exc: Any) -> None:
|
|
67
|
+
self.disconnect()
|
|
68
|
+
|
|
69
|
+
async def aconnect(self) -> "_SessionMixin":
|
|
70
|
+
"""Awaitable :meth:`connect`; the blocking connect runs on a worker thread."""
|
|
71
|
+
return await asyncio.get_running_loop().run_in_executor(None, self.connect)
|
|
72
|
+
|
|
73
|
+
async def adisconnect(self) -> None:
|
|
74
|
+
"""Awaitable :meth:`disconnect`."""
|
|
75
|
+
await asyncio.get_running_loop().run_in_executor(None, self.disconnect)
|
|
76
|
+
|
|
77
|
+
async def __aenter__(self) -> "_SessionMixin":
|
|
78
|
+
return await self.aconnect()
|
|
79
|
+
|
|
80
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
81
|
+
await self.adisconnect()
|
|
82
|
+
|
|
83
|
+
# ---- information -------------------------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def login(self) -> int:
|
|
87
|
+
"""The account login number."""
|
|
88
|
+
return int(self._api.User)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def host(self) -> str:
|
|
92
|
+
"""The access server this client was created for (it may fail over to another of :attr:`server_list`)."""
|
|
93
|
+
return str(self._api.Host or self._host)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def port(self) -> int:
|
|
97
|
+
"""The access-server port."""
|
|
98
|
+
return int(self._api.Port)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def server_list(self) -> List[Tuple[str, int]]:
|
|
102
|
+
"""The access servers the broker published at the last connect, as ``(host, port)``: the pool a reconnect
|
|
103
|
+
fails over across (empty before the first connect)."""
|
|
104
|
+
return [(str(t.Item1), int(t.Item2)) for t in self._api.SrvList]
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def servers(self) -> List[Tuple[str, int]]:
|
|
108
|
+
"""The extra access servers given with ``servers=`` / :meth:`add_server`, tried after the primary one."""
|
|
109
|
+
return [(str(t.Item1), int(t.Item2)) for t in self._api.Servers]
|
|
110
|
+
|
|
111
|
+
@translated
|
|
112
|
+
def add_server(self, host: str, port: int = 443) -> None:
|
|
113
|
+
"""Add a backup access server, tried (in order) when the primary one cannot be dialled. Credential
|
|
114
|
+
rejections do not fail over. Takes effect at the next connect."""
|
|
115
|
+
if not host:
|
|
116
|
+
raise ValueError("host must not be empty")
|
|
117
|
+
import System # noqa: PLC0415
|
|
118
|
+
self._api.Servers.Add(System.ValueTuple[System.String, System.Int32](str(host), int(port)))
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def raw(self) -> Any:
|
|
122
|
+
"""The underlying core object, for anything this layer does not wrap. Calls made on it raise the core's own
|
|
123
|
+
exceptions, not pmt4api's."""
|
|
124
|
+
return self._api
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def core_version(self) -> str:
|
|
128
|
+
"""The version of the licensed core library in use."""
|
|
129
|
+
return str(self._api.GetType().Assembly.GetName().Version)
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def server_time(self) -> Optional[datetime]:
|
|
133
|
+
"""The server's clock (server time, naive): the local UTC clock moved by the server's offset. ``None`` before
|
|
134
|
+
the first connect."""
|
|
135
|
+
if self._api.Account is None:
|
|
136
|
+
return None
|
|
137
|
+
return _time(self._api.ServerTime)
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def server_utc_offset(self) -> Optional[timedelta]:
|
|
141
|
+
"""The server's offset from UTC, in whole hours as the broker configures it; ``None`` before the first
|
|
142
|
+
connect."""
|
|
143
|
+
st = self.server_time
|
|
144
|
+
if st is None:
|
|
145
|
+
return None
|
|
146
|
+
delta = st - datetime.now(timezone.utc).replace(tzinfo=None)
|
|
147
|
+
return timedelta(hours=round(delta.total_seconds() / 3600.0))
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def company(self) -> str:
|
|
151
|
+
"""The broker's company name, as the server reports it (empty before the first connect)."""
|
|
152
|
+
a = self._api.Account
|
|
153
|
+
return str(a.Company or "") if a is not None else ""
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def trade_build(self) -> int:
|
|
157
|
+
"""The server's build number for this session."""
|
|
158
|
+
return int(self._api.ServerTradeBuild)
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def is_investor(self) -> bool:
|
|
162
|
+
"""The login is the read-only investor password, as the account record reports it (the field the C# client
|
|
163
|
+
reads): every trading call then raises :class:`InvestorModeError` before anything is sent. (Verified on
|
|
164
|
+
trading logins only: no investor password was available to exercise the true case.)"""
|
|
165
|
+
return bool(self._api.IsInvestor)
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def can_trade(self) -> bool:
|
|
169
|
+
"""Connected with a trading (not investor) password. The broker may still refuse a particular symbol or
|
|
170
|
+
time; this only says the session is allowed to try. See :attr:`is_investor`."""
|
|
171
|
+
return bool(self._api.Connected) and not bool(self._api.IsInvestor)
|
pmt4api/_streams.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Delivery machinery shared by the event API: one dispatcher thread per client, and the sync / async iterators.
|
|
2
|
+
|
|
3
|
+
The core delivers each event on its own thread-pool task, so events can arrive concurrently and slightly out of order,
|
|
4
|
+
and a slow callback would pile up threads. The client therefore converts every event to a plain value the moment it
|
|
5
|
+
arrives (a snapshot — the core's objects are shared and mutable) and hands it to ONE dispatcher thread per client,
|
|
6
|
+
which calls the callbacks one at a time, in arrival order. A callback may take its time; if it falls more than
|
|
7
|
+
``EVENT_QUEUE`` events behind, the oldest events are dropped (a warning is logged, and ``Client.events_dropped``
|
|
8
|
+
counts them). An exception raised by a callback is logged (under the package's own logger name) and swallowed so it
|
|
9
|
+
cannot break the connection.
|
|
10
|
+
|
|
11
|
+
This file is identical in pmt5api and pmt4api (a repository test keeps them so).
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import logging
|
|
17
|
+
import queue
|
|
18
|
+
import threading
|
|
19
|
+
import weakref
|
|
20
|
+
from typing import Any, Callable, Optional, Tuple
|
|
21
|
+
|
|
22
|
+
_PACKAGE = __name__.split(".")[0]
|
|
23
|
+
log = logging.getLogger(_PACKAGE)
|
|
24
|
+
|
|
25
|
+
#: How many events may wait for the dispatcher before the oldest are dropped.
|
|
26
|
+
EVENT_QUEUE = 20000
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _Dispatcher:
|
|
30
|
+
"""One thread that runs a client's callbacks in arrival order."""
|
|
31
|
+
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
self._q: "queue.Queue[Tuple[Callable[[Any], None], Any, str]]" = queue.Queue(maxsize=EVENT_QUEUE)
|
|
34
|
+
self.dropped = 0
|
|
35
|
+
self._count_lock = threading.Lock()
|
|
36
|
+
self._thread = threading.Thread(target=self._run, name=_PACKAGE + "-events", daemon=True)
|
|
37
|
+
self._thread.start()
|
|
38
|
+
|
|
39
|
+
def submit(self, fn: Callable[[Any], None], arg: Any, event: str) -> None:
|
|
40
|
+
while True:
|
|
41
|
+
try:
|
|
42
|
+
self._q.put_nowait((fn, arg, event))
|
|
43
|
+
return
|
|
44
|
+
except queue.Full:
|
|
45
|
+
try:
|
|
46
|
+
self._q.get_nowait()
|
|
47
|
+
except queue.Empty:
|
|
48
|
+
continue
|
|
49
|
+
with self._count_lock:
|
|
50
|
+
self.dropped += 1
|
|
51
|
+
total = self.dropped
|
|
52
|
+
if total == 1 or total % 1000 == 0:
|
|
53
|
+
log.warning("event queue full: %d event(s) dropped so far; a callback is too slow", total)
|
|
54
|
+
|
|
55
|
+
def _run(self) -> None:
|
|
56
|
+
while True:
|
|
57
|
+
fn, arg, event = self._q.get()
|
|
58
|
+
try:
|
|
59
|
+
fn(arg)
|
|
60
|
+
except Exception: # noqa: BLE001 — keep the dispatcher alive
|
|
61
|
+
log.exception("%s callback raised", event)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_END = object() # put on a queue to wake a consumer that is blocked in it when the iterator is closed
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _QueueIterator:
|
|
68
|
+
"""An iterator over an event stream. It subscribes when it is created (not at the first ``next()``), so nothing
|
|
69
|
+
that happens after the call is missed, and unsubscribes when exhausted or closed."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, register: Callable[[Callable[[Any], None]], Callable[[], None]], timeout: Optional[float],
|
|
72
|
+
maxsize: int, predicate: Optional[Callable[[Any], bool]] = None,
|
|
73
|
+
setup: Optional[Callable[[], None]] = None) -> None:
|
|
74
|
+
self._q: "queue.Queue[Any]" = queue.Queue(maxsize=maxsize)
|
|
75
|
+
self._timeout = timeout
|
|
76
|
+
self._pred = predicate
|
|
77
|
+
self._closed = False
|
|
78
|
+
ref = weakref.ref(self)
|
|
79
|
+
|
|
80
|
+
def put(item: Any) -> None: # weakly bound: an abandoned iterator can be collected, and then detaches
|
|
81
|
+
it = ref()
|
|
82
|
+
if it is not None:
|
|
83
|
+
it._put(item)
|
|
84
|
+
|
|
85
|
+
self._remove = register(put)
|
|
86
|
+
if setup is not None:
|
|
87
|
+
try:
|
|
88
|
+
setup()
|
|
89
|
+
except BaseException:
|
|
90
|
+
self.close()
|
|
91
|
+
raise
|
|
92
|
+
|
|
93
|
+
def _put(self, item: Any) -> None:
|
|
94
|
+
if self._pred is not None and not self._pred(item):
|
|
95
|
+
return
|
|
96
|
+
while True:
|
|
97
|
+
try:
|
|
98
|
+
self._q.put_nowait(item)
|
|
99
|
+
return
|
|
100
|
+
except queue.Full:
|
|
101
|
+
try:
|
|
102
|
+
self._q.get_nowait()
|
|
103
|
+
except queue.Empty:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
def __iter__(self) -> "_QueueIterator":
|
|
107
|
+
return self
|
|
108
|
+
|
|
109
|
+
def __next__(self) -> Any:
|
|
110
|
+
if self._closed:
|
|
111
|
+
raise StopIteration
|
|
112
|
+
try:
|
|
113
|
+
item = self._q.get(timeout=self._timeout)
|
|
114
|
+
except queue.Empty:
|
|
115
|
+
self.close()
|
|
116
|
+
raise StopIteration
|
|
117
|
+
if item is _END:
|
|
118
|
+
raise StopIteration
|
|
119
|
+
return item
|
|
120
|
+
|
|
121
|
+
def close(self) -> None:
|
|
122
|
+
if not self._closed:
|
|
123
|
+
self._closed = True
|
|
124
|
+
self._remove()
|
|
125
|
+
while True: # wake a thread that is blocked in next(), whatever is queued
|
|
126
|
+
try:
|
|
127
|
+
self._q.put_nowait(_END)
|
|
128
|
+
return
|
|
129
|
+
except queue.Full:
|
|
130
|
+
try:
|
|
131
|
+
self._q.get_nowait()
|
|
132
|
+
except queue.Empty:
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
def __enter__(self) -> "_QueueIterator":
|
|
136
|
+
return self
|
|
137
|
+
|
|
138
|
+
def __exit__(self, *exc: Any) -> None:
|
|
139
|
+
self.close()
|
|
140
|
+
|
|
141
|
+
def __del__(self) -> None: # pragma: no cover - best effort
|
|
142
|
+
try:
|
|
143
|
+
self.close()
|
|
144
|
+
except Exception: # noqa: BLE001
|
|
145
|
+
pass
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class _AsyncQueueIterator:
|
|
149
|
+
"""The asyncio counterpart; created inside a running loop, subscribed at creation, ``aclose()`` to stop."""
|
|
150
|
+
|
|
151
|
+
def __init__(self, register: Callable[[Callable[[Any], None]], Callable[[], None]], maxsize: int,
|
|
152
|
+
predicate: Optional[Callable[[Any], bool]] = None,
|
|
153
|
+
setup: Optional[Callable[[], None]] = None) -> None:
|
|
154
|
+
self._loop = asyncio.get_running_loop()
|
|
155
|
+
self._q: "asyncio.Queue[Any]" = asyncio.Queue(maxsize=maxsize)
|
|
156
|
+
self._pred = predicate
|
|
157
|
+
self._closed = False
|
|
158
|
+
ref = weakref.ref(self)
|
|
159
|
+
loop = self._loop
|
|
160
|
+
|
|
161
|
+
def push(item: Any) -> None: # weakly bound, as in the synchronous iterator
|
|
162
|
+
it = ref()
|
|
163
|
+
if it is not None:
|
|
164
|
+
try:
|
|
165
|
+
loop.call_soon_threadsafe(it._push, item)
|
|
166
|
+
except RuntimeError: # the loop is closed
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
self._remove = register(push)
|
|
170
|
+
if setup is not None:
|
|
171
|
+
try:
|
|
172
|
+
setup()
|
|
173
|
+
except BaseException:
|
|
174
|
+
self._finish()
|
|
175
|
+
raise
|
|
176
|
+
|
|
177
|
+
def _push(self, item: Any) -> None:
|
|
178
|
+
if self._closed or (self._pred is not None and not self._pred(item)):
|
|
179
|
+
return
|
|
180
|
+
if self._q.full():
|
|
181
|
+
try:
|
|
182
|
+
self._q.get_nowait()
|
|
183
|
+
except asyncio.QueueEmpty:
|
|
184
|
+
pass
|
|
185
|
+
try:
|
|
186
|
+
self._q.put_nowait(item)
|
|
187
|
+
except asyncio.QueueFull:
|
|
188
|
+
pass
|
|
189
|
+
|
|
190
|
+
def _finish(self) -> None:
|
|
191
|
+
if not self._closed:
|
|
192
|
+
self._closed = True
|
|
193
|
+
self._remove()
|
|
194
|
+
try: # wake a task that is awaiting the next item
|
|
195
|
+
self._loop.call_soon_threadsafe(self._wake)
|
|
196
|
+
except RuntimeError: # the loop is closed: nobody is waiting
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
def _wake(self) -> None:
|
|
200
|
+
if self._q.full():
|
|
201
|
+
try:
|
|
202
|
+
self._q.get_nowait()
|
|
203
|
+
except asyncio.QueueEmpty:
|
|
204
|
+
pass
|
|
205
|
+
try:
|
|
206
|
+
self._q.put_nowait(_END)
|
|
207
|
+
except asyncio.QueueFull:
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
def __aiter__(self) -> "_AsyncQueueIterator":
|
|
211
|
+
return self
|
|
212
|
+
|
|
213
|
+
async def __anext__(self) -> Any:
|
|
214
|
+
if self._closed and self._q.empty():
|
|
215
|
+
raise StopAsyncIteration
|
|
216
|
+
try:
|
|
217
|
+
item = await self._q.get()
|
|
218
|
+
except asyncio.CancelledError:
|
|
219
|
+
self._finish()
|
|
220
|
+
raise
|
|
221
|
+
if item is _END:
|
|
222
|
+
raise StopAsyncIteration
|
|
223
|
+
return item
|
|
224
|
+
|
|
225
|
+
async def aclose(self) -> None:
|
|
226
|
+
self._finish()
|
|
227
|
+
|
|
228
|
+
async def __aenter__(self) -> "_AsyncQueueIterator":
|
|
229
|
+
return self
|
|
230
|
+
|
|
231
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
232
|
+
self._finish()
|
|
233
|
+
|
|
234
|
+
def __del__(self) -> None: # pragma: no cover - best effort when a loop is abandoned without aclose()
|
|
235
|
+
try:
|
|
236
|
+
self._finish()
|
|
237
|
+
except Exception: # noqa: BLE001
|
|
238
|
+
pass
|