xtb-api-python 0.5.2__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.
xtb_api/__init__.py ADDED
@@ -0,0 +1,70 @@
1
+ """Unofficial Python client for XTB xStation5 trading platform."""
2
+
3
+ from importlib.metadata import PackageNotFoundError
4
+ from importlib.metadata import version as _pkg_version
5
+
6
+ from xtb_api.auth.auth_manager import AuthManager as XTBAuth
7
+ from xtb_api.client import XTBClient
8
+ from xtb_api.exceptions import (
9
+ AuthenticationError,
10
+ CASError,
11
+ InstrumentNotFoundError,
12
+ ProtocolError,
13
+ RateLimitError,
14
+ ReconnectionError,
15
+ TradeError,
16
+ XTBConnectionError,
17
+ XTBError,
18
+ XTBTimeoutError,
19
+ )
20
+ from xtb_api.instruments import InstrumentRegistry
21
+ from xtb_api.types.enums import (
22
+ SocketStatus,
23
+ SubscriptionEid,
24
+ Xs6Side,
25
+ XTBEnvironment,
26
+ )
27
+ from xtb_api.types.instrument import InstrumentSearchResult, Quote
28
+ from xtb_api.types.trading import (
29
+ AccountBalance,
30
+ PendingOrder,
31
+ Position,
32
+ TradeOptions,
33
+ TradeResult,
34
+ )
35
+
36
+ try:
37
+ __version__ = _pkg_version("xtb-api-python")
38
+ except PackageNotFoundError: # pragma: no cover
39
+ __version__ = "0.0.0+unknown"
40
+
41
+ __all__ = [
42
+ # Client
43
+ "XTBClient",
44
+ "XTBAuth",
45
+ "InstrumentRegistry",
46
+ # Exceptions
47
+ "XTBError",
48
+ "XTBConnectionError",
49
+ "AuthenticationError",
50
+ "CASError",
51
+ "ReconnectionError",
52
+ "TradeError",
53
+ "InstrumentNotFoundError",
54
+ "RateLimitError",
55
+ "XTBTimeoutError",
56
+ "ProtocolError",
57
+ # Data models
58
+ "Position",
59
+ "PendingOrder",
60
+ "AccountBalance",
61
+ "TradeResult",
62
+ "TradeOptions",
63
+ "Quote",
64
+ "InstrumentSearchResult",
65
+ # Enums
66
+ "Xs6Side",
67
+ "SocketStatus",
68
+ "XTBEnvironment",
69
+ "SubscriptionEid",
70
+ ]
xtb_api/__main__.py ADDED
@@ -0,0 +1,154 @@
1
+ """CLI entry point for xtb-api-python.
2
+
3
+ Usage:
4
+ python -m xtb_api doctor # Verify installation state
5
+ xtb-api doctor # Same, if the entry point script is on PATH
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import importlib.util
12
+ import platform
13
+ import sys
14
+ from importlib.metadata import PackageNotFoundError
15
+ from importlib.metadata import version as _pkg_version
16
+
17
+
18
+ def _ok(label: str, detail: str = "") -> str:
19
+ return f" [OK] {label}" + (f" — {detail}" if detail else "")
20
+
21
+
22
+ def _fail(label: str, detail: str = "") -> str:
23
+ return f" [FAIL] {label}" + (f" — {detail}" if detail else "")
24
+
25
+
26
+ def _info(label: str, detail: str = "") -> str:
27
+ return f" [--] {label}" + (f" — {detail}" if detail else "")
28
+
29
+
30
+ def _check_python_version() -> tuple[bool, str]:
31
+ major, minor = sys.version_info[:2]
32
+ version_str = f"{major}.{minor}.{sys.version_info[2]}"
33
+ ok = (major, minor) >= (3, 12)
34
+ return ok, version_str
35
+
36
+
37
+ def _check_package_version() -> tuple[bool, str]:
38
+ try:
39
+ return True, _pkg_version("xtb-api-python")
40
+ except PackageNotFoundError:
41
+ return False, "not installed (are you running from a non-installed checkout?)"
42
+
43
+
44
+ def _check_playwright_package() -> tuple[bool, str]:
45
+ spec = importlib.util.find_spec("playwright")
46
+ if spec is None:
47
+ return False, "playwright Python package not installed"
48
+ try:
49
+ ver = _pkg_version("playwright")
50
+ except PackageNotFoundError:
51
+ ver = "unknown"
52
+ return True, f"playwright {ver}"
53
+
54
+
55
+ def _check_chromium_binary() -> tuple[bool, str]:
56
+ """Attempt to locate the Chromium executable without launching it."""
57
+ try:
58
+ from playwright.sync_api import sync_playwright
59
+ except ImportError:
60
+ return False, "playwright not importable"
61
+
62
+ try:
63
+ with sync_playwright() as p:
64
+ exe = p.chromium.executable_path
65
+ # executable_path is a property that returns a string path; does not
66
+ # guarantee the file exists. Probe it.
67
+ from pathlib import Path
68
+
69
+ if exe and Path(exe).exists():
70
+ return True, exe
71
+ return False, f"expected binary at {exe!r} not found"
72
+ except Exception as e: # noqa: BLE001
73
+ return False, f"playwright check failed: {e}"
74
+
75
+
76
+ def _check_pyotp_optional() -> tuple[bool, str]:
77
+ spec = importlib.util.find_spec("pyotp")
78
+ if spec is None:
79
+ return False, "pyotp not installed (install 'xtb-api-python[totp]' for auto-2FA)"
80
+ try:
81
+ ver = _pkg_version("pyotp")
82
+ except PackageNotFoundError:
83
+ ver = "unknown"
84
+ return True, f"pyotp {ver}"
85
+
86
+
87
+ def run_doctor() -> int:
88
+ """Run environment checks and print a status report. Returns 0 on success."""
89
+ print(f"xtb-api-python doctor — {platform.platform()}")
90
+ print()
91
+
92
+ all_ok = True
93
+
94
+ # Required checks
95
+ ok, detail = _check_python_version()
96
+ print(_ok("Python >= 3.12", detail) if ok else _fail("Python >= 3.12", detail))
97
+ all_ok &= ok
98
+
99
+ ok, detail = _check_package_version()
100
+ print(_ok("xtb-api-python", detail) if ok else _fail("xtb-api-python", detail))
101
+ all_ok &= ok
102
+
103
+ ok, detail = _check_playwright_package()
104
+ print(_ok("playwright package", detail) if ok else _fail("playwright package", detail))
105
+ all_ok &= ok
106
+
107
+ ok, detail = _check_chromium_binary()
108
+ if ok:
109
+ print(_ok("Chromium binary", detail))
110
+ else:
111
+ print(_fail("Chromium binary", detail))
112
+ print()
113
+ print(" To install Chromium, run:")
114
+ print(" playwright install chromium")
115
+ print()
116
+ all_ok = False
117
+
118
+ # Optional checks
119
+ ok, detail = _check_pyotp_optional()
120
+ print(_ok("pyotp (optional 2FA)", detail) if ok else _info("pyotp (optional 2FA)", detail))
121
+
122
+ print()
123
+ if all_ok:
124
+ print("All required checks passed.")
125
+ return 0
126
+ print("Some required checks failed. See above for fix instructions.")
127
+ return 1
128
+
129
+
130
+ def main() -> int:
131
+ parser = argparse.ArgumentParser(
132
+ prog="xtb-api",
133
+ description="xtb-api-python CLI",
134
+ )
135
+ subparsers = parser.add_subparsers(dest="command", required=True)
136
+ subparsers.add_parser("doctor", help="Verify the library's installation state")
137
+
138
+ try:
139
+ args = parser.parse_args()
140
+ except SystemExit as e:
141
+ # argparse raises SystemExit on --help or parse errors. Convert to
142
+ # a return value so main() stays testable and honors its -> int contract.
143
+ code = e.code
144
+ if isinstance(code, int):
145
+ return code
146
+ return 2
147
+
148
+ if args.command == "doctor":
149
+ return run_doctor()
150
+ return 2
151
+
152
+
153
+ if __name__ == "__main__":
154
+ sys.exit(main())
@@ -0,0 +1,5 @@
1
+ """CAS authentication module."""
2
+
3
+ from xtb_api.auth.auth_manager import AuthManager as AuthManager
4
+ from xtb_api.auth.cas_client import CASClient as CASClient
5
+ from xtb_api.auth.cas_client import CASClientConfig as CASClientConfig
@@ -0,0 +1,321 @@
1
+ """High-level authentication manager that encapsulates the full TGT auth flow.
2
+
3
+ Handles cached sessions, REST CAS login, browser fallback, and automatic TOTP —
4
+ so consumers don't need to implement the auth chain themselves.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import logging
12
+ import os
13
+ import stat
14
+ import time
15
+ from concurrent.futures import ThreadPoolExecutor
16
+ from datetime import UTC, datetime
17
+ from pathlib import Path
18
+
19
+ from xtb_api.auth.cas_client import CASClient, CASClientConfig
20
+ from xtb_api.types.websocket import (
21
+ CASError,
22
+ CASLoginSuccess,
23
+ CASLoginTwoFactorRequired,
24
+ )
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ # TGT validity: 8 hours, but refresh 5 minutes early to avoid races
29
+ TGT_LIFETIME_SECONDS = 8 * 3600
30
+ TGT_REFRESH_MARGIN_SECONDS = 5 * 60
31
+
32
+
33
+ class AuthManager:
34
+ """Manages the full XTB CAS authentication chain.
35
+
36
+ Auth chain (tried in order):
37
+ 1. Cached TGT from session file (if configured and still valid)
38
+ 2. REST CAS login (fast, no browser)
39
+ 3. Playwright browser fallback (if WAF blocks REST)
40
+ 4. Automatic TOTP if 2FA required and totp_secret provided
41
+
42
+ Example::
43
+
44
+ auth = AuthManager(
45
+ email="user@example.com",
46
+ password="secret",
47
+ totp_secret="BASE32SECRET",
48
+ session_file="~/.xtb_session.json",
49
+ )
50
+ tgt = await auth.get_tgt()
51
+ st = await auth.get_service_ticket()
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ email: str,
57
+ password: str,
58
+ totp_secret: str = "",
59
+ session_file: Path | str | None = None,
60
+ cas_config: CASClientConfig | None = None,
61
+ cookies_file: Path | str | None = None,
62
+ ) -> None:
63
+ """
64
+ Args:
65
+ email: XTB account email.
66
+ password: XTB account password.
67
+ totp_secret: Base32 TOTP secret for auto-2FA (optional).
68
+ If omitted and 2FA is required, raises CASError.
69
+ session_file: Path to cache TGT as JSON (optional).
70
+ If omitted, TGT is only cached in memory.
71
+ cas_config: Custom CAS client configuration (optional).
72
+ cookies_file: Path to persist HTTP cookies as JSON (optional).
73
+ If omitted but ``session_file`` is set, defaults to
74
+ ``<session_file_stem>_cookies.json`` alongside it.
75
+ """
76
+ self._email = email
77
+ self._password = password
78
+ self._totp_secret = totp_secret
79
+ self._session_file = Path(session_file).expanduser() if session_file else None
80
+
81
+ # Derive cookies_file from session_file when not explicitly provided
82
+ if cookies_file is None and self._session_file is not None:
83
+ cookies_file = self._session_file.parent / f"{self._session_file.stem}_cookies.json"
84
+
85
+ # Inject cookies_file into CAS config
86
+ if cookies_file is not None:
87
+ cas_config = (cas_config or CASClientConfig()).model_copy(update={"cookies_file": cookies_file})
88
+
89
+ self._cas = CASClient(cas_config)
90
+ self._cached_tgt: str | None = None
91
+ self._cached_expires_at: float = 0.0
92
+
93
+ async def get_tgt(self) -> str:
94
+ """Get a valid TGT, using the full auth chain as needed.
95
+
96
+ Chain: cached (memory/file) -> REST CAS -> browser fallback -> TOTP.
97
+
98
+ Returns:
99
+ Valid TGT string.
100
+
101
+ Raises:
102
+ CASError: If authentication fails at all stages.
103
+ """
104
+ # 1. Check in-memory cache
105
+ if self._cached_tgt and self._is_tgt_fresh(self._cached_expires_at):
106
+ return self._cached_tgt
107
+
108
+ # 2. Check session file
109
+ if self._session_file:
110
+ cached = self._load_session_file()
111
+ if cached:
112
+ tgt = str(cached["tgt"])
113
+ self._cached_tgt = tgt
114
+ self._cached_expires_at = float(cached["expires_at"])
115
+ return tgt
116
+
117
+ # 3. REST CAS login
118
+ result = await self._login_with_fallback()
119
+
120
+ # 4. Handle 2FA if needed
121
+ if isinstance(result, CASLoginTwoFactorRequired):
122
+ result = await self._handle_two_factor(result)
123
+
124
+ # 5. Cache and return
125
+ self._cache_tgt(result.tgt, result.expires_at)
126
+ return result.tgt
127
+
128
+ def get_tgt_sync(self) -> str:
129
+ """Synchronous wrapper for :meth:`get_tgt`.
130
+
131
+ Uses a dedicated thread with its own event loop to avoid conflicts
132
+ with any running loop in the caller's thread.
133
+ """
134
+ with ThreadPoolExecutor(max_workers=1) as pool:
135
+ return pool.submit(lambda: asyncio.run(self.get_tgt())).result()
136
+
137
+ async def get_service_ticket(self, service: str = "xapi5") -> str:
138
+ """Get a service ticket, obtaining a TGT first if needed.
139
+
140
+ Args:
141
+ service: CAS service name. Use ``'xapi5'`` for WebSocket,
142
+ ``'abigail'`` for REST API.
143
+
144
+ Returns:
145
+ Service ticket string (``ST-...``).
146
+
147
+ Raises:
148
+ CASError: If TGT acquisition or service ticket request fails.
149
+ """
150
+ tgt = await self.get_tgt()
151
+ try:
152
+ st_result = await self._cas.get_service_ticket(tgt, service)
153
+ return st_result.service_ticket
154
+ except CASError as e:
155
+ if e.code == "CAS_TGT_EXPIRED":
156
+ # TGT was cached but expired server-side — clear and retry
157
+ self._invalidate_cache()
158
+ tgt = await self.get_tgt()
159
+ st_result = await self._cas.get_service_ticket(tgt, service)
160
+ return st_result.service_ticket
161
+ raise
162
+
163
+ def invalidate(self) -> None:
164
+ """Clear cached TGT from memory and session file."""
165
+ self._invalidate_cache()
166
+
167
+ async def aclose(self) -> None:
168
+ """Close underlying HTTP clients. Call on shutdown."""
169
+ await self._cas.aclose()
170
+
171
+ # -- Internal helpers --
172
+
173
+ async def _login_with_fallback(self) -> CASLoginSuccess | CASLoginTwoFactorRequired:
174
+ """Try REST CAS login, fall back to browser if WAF blocks."""
175
+ try:
176
+ return await self._cas.login(self._email, self._password)
177
+ except CASError as e:
178
+ # Invalid credentials — don't retry with browser
179
+ if "UNAUTHORIZED" in e.code:
180
+ raise
181
+ logger.info("REST CAS login failed (%s), trying browser fallback", e.code)
182
+ return await self._cas.login_with_browser(self._email, self._password)
183
+ except Exception as e:
184
+ # WAF often returns HTML instead of JSON → httpx decode error
185
+ logger.info("REST CAS login failed (%s), trying browser fallback", e)
186
+ return await self._cas.login_with_browser(self._email, self._password)
187
+
188
+ async def _handle_two_factor(self, challenge: CASLoginTwoFactorRequired) -> CASLoginSuccess:
189
+ """Handle 2FA challenge using TOTP auto-generation or browser OTP."""
190
+ code = await self._generate_totp()
191
+
192
+ # If browser session is active, prefer browser OTP (WAF blocks REST 2FA too)
193
+ if hasattr(self._cas, "_browser_auth") and self._cas._browser_auth:
194
+ logger.info("Submitting TOTP via browser OTP...")
195
+ result = await self._cas.submit_browser_otp(code)
196
+ else:
197
+ # Try REST 2FA submission
198
+ two_factor_type = "TOTP" if "TOTP" in challenge.methods else challenge.two_factor_auth_type
199
+ result = await self._cas.login_with_two_factor(challenge.login_ticket, code, two_factor_type)
200
+
201
+ if isinstance(result, CASLoginTwoFactorRequired):
202
+ raise CASError(
203
+ "AUTH_MANAGER_2FA_LOOP",
204
+ "Server requested 2FA again after submitting code",
205
+ )
206
+
207
+ return result
208
+
209
+ async def _generate_totp(self) -> str:
210
+ """Generate a TOTP code from the stored secret.
211
+
212
+ If fewer than 2 seconds remain in the current 30-second TOTP window,
213
+ waits for the next window before generating the code. Without this,
214
+ roughly 6.6% of logins fail because the code expires in transit before
215
+ the server validates it. Waiting (rather than returning the next
216
+ window's code) avoids relying on server-side window-drift tolerance.
217
+ """
218
+ if not self._totp_secret:
219
+ raise CASError(
220
+ "AUTH_MANAGER_2FA_NO_SECRET",
221
+ "2FA is required but no totp_secret was provided. "
222
+ "Pass totp_secret to AuthManager or disable 2FA on your account.",
223
+ )
224
+ try:
225
+ import pyotp
226
+ except ImportError as e:
227
+ raise CASError(
228
+ "AUTH_MANAGER_PYOTP_MISSING",
229
+ "2FA requires the pyotp package. Install with: pip install 'pyotp>=2.9.0'",
230
+ ) from e
231
+ totp = pyotp.TOTP(self._totp_secret)
232
+ remaining = totp.interval - (time.time() % totp.interval)
233
+ if remaining < 2:
234
+ # Within 2s of window boundary — wait for the next window so the
235
+ # generated code is guaranteed valid for its full lifetime.
236
+ await asyncio.sleep(remaining + 0.1)
237
+ return str(totp.now())
238
+
239
+ def _cache_tgt(self, tgt: str, expires_at: float) -> None:
240
+ """Cache TGT in memory and optionally to session file."""
241
+ self._cached_tgt = tgt
242
+ self._cached_expires_at = expires_at
243
+
244
+ if self._session_file:
245
+ self._save_session_file(tgt, expires_at)
246
+
247
+ def _invalidate_cache(self) -> None:
248
+ """Clear TGT from memory and session file."""
249
+ self._cached_tgt = None
250
+ self._cached_expires_at = 0.0
251
+
252
+ if self._session_file and self._session_file.exists():
253
+ self._session_file.unlink(missing_ok=True)
254
+
255
+ def _load_session_file(self) -> dict | None:
256
+ """Load and validate cached TGT from session file.
257
+
258
+ Returns:
259
+ Dict with 'tgt' and 'expires_at' if valid, None otherwise.
260
+ """
261
+ if not self._session_file or not self._session_file.exists():
262
+ return None
263
+
264
+ try:
265
+ # Fix permissions if file is readable by group/others (TGT is sensitive)
266
+ file_mode = self._session_file.stat().st_mode & 0o777
267
+ if file_mode & 0o077:
268
+ logger.warning(
269
+ "Session file %s has permissive permissions (%o). Fixing to 0600.",
270
+ self._session_file,
271
+ file_mode,
272
+ )
273
+ self._session_file.chmod(stat.S_IRUSR | stat.S_IWUSR)
274
+
275
+ data = json.loads(self._session_file.read_text())
276
+ tgt = data.get("tgt", "")
277
+ expires_at_str = data.get("expires_at", "")
278
+
279
+ if not tgt or not expires_at_str:
280
+ return None
281
+
282
+ expires_at = datetime.fromisoformat(expires_at_str).timestamp()
283
+
284
+ if not self._is_tgt_fresh(expires_at):
285
+ return None
286
+
287
+ return {"tgt": tgt, "expires_at": expires_at}
288
+ except (json.JSONDecodeError, KeyError, ValueError, OSError):
289
+ return None
290
+
291
+ def _save_session_file(self, tgt: str, expires_at: float) -> None:
292
+ """Save TGT to session file as JSON with restricted permissions (0600)."""
293
+ if not self._session_file:
294
+ return
295
+
296
+ extracted_at = datetime.now(UTC)
297
+ expires_at_dt = datetime.fromtimestamp(expires_at, tz=UTC)
298
+
299
+ data = {
300
+ "tgt": tgt,
301
+ "extracted_at": extracted_at.isoformat(),
302
+ "expires_at": expires_at_dt.isoformat(),
303
+ }
304
+
305
+ self._session_file.parent.mkdir(parents=True, exist_ok=True)
306
+ content = json.dumps(data, indent=2)
307
+ # Write with owner-only permissions to protect the TGT
308
+ fd = os.open(
309
+ str(self._session_file),
310
+ os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
311
+ stat.S_IRUSR | stat.S_IWUSR, # 0600
312
+ )
313
+ try:
314
+ os.write(fd, content.encode())
315
+ finally:
316
+ os.close(fd)
317
+
318
+ @staticmethod
319
+ def _is_tgt_fresh(expires_at: float) -> bool:
320
+ """Check if TGT is still valid with a safety margin."""
321
+ return time.time() < (expires_at - TGT_REFRESH_MARGIN_SECONDS)