tqx-data 0.1.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.
tqx_data/__init__.py ADDED
@@ -0,0 +1,63 @@
1
+ """tqx_data SDK public interface."""
2
+ import warnings
3
+
4
+ warnings.filterwarnings("ignore", message=".*Unable to find acceptable character detection dependency.*")
5
+
6
+ from .client import get_client, get_factory, init, TickStreamClient
7
+ from .auth_manager import (
8
+ is_authenticated,
9
+ auth_remaining_seconds,
10
+ auth_expires_at,
11
+ get_token_ttl,
12
+ auth_info,
13
+ clear_auth,
14
+ )
15
+ from .readers import (
16
+ hk_reader,
17
+ trading_tools_reader,
18
+ us_reader,
19
+ oversea_reader,
20
+ init_token,
21
+ )
22
+
23
+ # 交易工具(交易日历等,exchange 支持 HK/US)
24
+ get_trade_cal = trading_tools_reader.get_trade_cal
25
+ get_prev_trade_date = trading_tools_reader.get_prev_trade_date
26
+ get_last_trade_date = trading_tools_reader.get_last_trade_date
27
+ get_trade_list = trading_tools_reader.get_trade_list
28
+
29
+
30
+
31
+ # 港美股(用 market 参数区分 hk/us;日线/分钟线接口保持分开,见下方港股/美股)
32
+ get_stock_detail = oversea_reader.get_stock_detail
33
+ get_stock_detail_all = oversea_reader.get_stock_detail_all
34
+ get_stock_dividend_event = oversea_reader.get_stock_dividend_event
35
+ get_stock_market_event = oversea_reader.get_stock_market_event
36
+ get_stock_meeting_event = oversea_reader.get_stock_meeting_event
37
+ get_stock_financial_event = oversea_reader.get_stock_financial_event
38
+ get_stock_ir_event = oversea_reader.get_stock_ir_event
39
+ get_stock_investor_concentration = oversea_reader.get_stock_investor_concentration
40
+ get_stock_top20_concentration = oversea_reader.get_stock_top20_concentration
41
+ get_stock_investor_ranking = oversea_reader.get_stock_investor_ranking
42
+ get_stock_insider_trade = oversea_reader.get_stock_insider_trade
43
+ get_stock_shareholder_holding = oversea_reader.get_stock_shareholder_holding
44
+ get_stock_industry_median = oversea_reader.get_stock_industry_median
45
+ get_stock_pv_indicator = oversea_reader.get_stock_pv_indicator
46
+ get_stock_ncycl_consensus = oversea_reader.get_stock_ncycl_consensus
47
+ get_stock_operating_indicator = oversea_reader.get_stock_operating_indicator
48
+ get_stock_mktfin_indicator = oversea_reader.get_stock_mktfin_indicator
49
+ get_financial_statement = oversea_reader.get_financial_statement
50
+
51
+ # 24 合并
52
+
53
+ # 港股(日线/分钟线,不与美股合并)
54
+ get_hk_daily = hk_reader.get_hk_daily
55
+
56
+ # 美股(日线/分钟线,不与港股合并)
57
+ get_us_daily = us_reader.get_us_daily
58
+ init_token = init_token.init_token
59
+ __all__ = [name for name in globals().keys() if name.startswith('get_')] + [
60
+ 'init', 'init_token', 'get_client', 'get_factory', 'TickStreamClient',
61
+ 'is_authenticated', 'auth_remaining_seconds', 'auth_expires_at',
62
+ 'auth_info', 'clear_auth',
63
+ ]
@@ -0,0 +1,563 @@
1
+ """
2
+ auth_manager.py — Centralized authentication state management.
3
+
4
+ Security design:
5
+ - Token lives ONLY in memory (never written to disk).
6
+ - Credentials are encrypted at rest in ``user.json`` using a key derived from
7
+ the machine fingerprint (hostname + MAC + fixed salt).
8
+ - The public API exposes only auth-status / remaining-duration information;
9
+ it never leaks the raw token string or internal user-id.
10
+
11
+ Usage::
12
+
13
+ # Login
14
+ tqx_data.init_token(username, password, base_url)
15
+
16
+ # Check auth status
17
+ tqx_data.is_authenticated() # → True / False
18
+ tqx_data.auth_remaining_seconds() # → 86340 (seconds until expiry)
19
+
20
+ # On subsequent sessions, auto-login happens transparently
21
+ df = tqx_data.get_stock_daily(...) # re-logs in if user.json exists
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import base64
27
+ import hashlib
28
+ import hmac as hmac_mod
29
+ import json
30
+ import os
31
+ import platform
32
+ import secrets
33
+ import socket
34
+ import threading
35
+ import time
36
+ import uuid
37
+ from dataclasses import dataclass
38
+ from typing import Any, Dict, Optional, Tuple
39
+ from urllib.parse import urljoin
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Machine-fingerprint key derivation (standard library only — zero deps)
43
+ # ---------------------------------------------------------------------------
44
+
45
+ _SALT = b"tqx_data_auth_v2" # v2: HMAC-protected encryption
46
+ _KEY_ITERATIONS = 200_000
47
+ _HMAC_SEPARATOR = b"::hmac::"
48
+
49
+
50
+ def _get_pepper() -> bytes:
51
+ """Return the pepper from env var ``TQX_DATA_AUTH_PEPPER``.
52
+
53
+ If not set, encryption still works with machine-fingerprint-derived key.
54
+ Production deployments that need defense-in-depth should set this,
55
+ but the primary security boundary is server-side (Gateway JWT + Redis).
56
+ """
57
+ pepper = os.environ.get("TQX_DATA_AUTH_PEPPER", "").strip()
58
+ return pepper.encode("utf-8") if pepper else b""
59
+
60
+
61
+ def _get_machine_fingerprint() -> str:
62
+ """Return a host-specific string used to encrypt on-disk credentials."""
63
+ try:
64
+ parts = [
65
+ platform.node() or "unknown-host",
66
+ str(uuid.getnode()),
67
+ socket.gethostname() or "unknown-name",
68
+ platform.machine() or "unknown-arch",
69
+ ]
70
+ except Exception:
71
+ parts = ["fallback", str(uuid.getnode())]
72
+ return "|".join(parts)
73
+
74
+
75
+ def _derive_key(fingerprint: str) -> Tuple[bytes, bytes]:
76
+ """Derive encryption + signing keys (each 32 bytes) from fingerprint + pepper.
77
+
78
+ Returns ``(encryption_key, signing_key)``.
79
+ """
80
+ pepper = _get_pepper()
81
+ # HKDF-style: derive 64 bytes, split into enc-key (first 32) and hmac-key (last 32)
82
+ material = fingerprint.encode() + _HMAC_SEPARATOR + pepper
83
+ raw = hashlib.pbkdf2_hmac(
84
+ "sha256", material, _SALT, _KEY_ITERATIONS, dklen=64
85
+ )
86
+ return raw[:32], raw[32:]
87
+
88
+
89
+ def _generate_key_stream(key: bytes, iv: bytes, length: int) -> bytes:
90
+ """Expand *key* + *iv* into a deterministic stream of *length* bytes."""
91
+ result = b""
92
+ counter = 0
93
+ while len(result) < length:
94
+ h = hashlib.sha256(key + iv + counter.to_bytes(4, "big")).digest()
95
+ result += h
96
+ counter += 1
97
+ return result[:length]
98
+
99
+
100
+ def _encrypt(plaintext: str, enc_key: bytes, sign_key: bytes) -> str:
101
+ """Encrypt + HMAC-sign *plaintext*.
102
+
103
+ Output format: ``base64(iv || ciphertext || hmac_sha256)``
104
+ where *iv* is 16 random bytes and *hmac* is 32 bytes.
105
+ Any tampering with the ciphertext will cause decryption to fail.
106
+ """
107
+ iv = secrets.token_bytes(16)
108
+ plain_bytes = plaintext.encode("utf-8")
109
+ key_stream = _generate_key_stream(enc_key, iv, len(plain_bytes))
110
+ ciphertext = bytes(a ^ b for a, b in zip(plain_bytes, key_stream))
111
+
112
+ # HMAC over iv + ciphertext (authenticated encryption)
113
+ payload = iv + ciphertext
114
+ mac = hmac_mod.digest(sign_key, payload, "sha256")
115
+
116
+ return base64.b64encode(payload + mac).decode("ascii")
117
+
118
+
119
+ def _decrypt(encrypted: str, enc_key: bytes, sign_key: bytes) -> str:
120
+ """Verify HMAC then decrypt. Raises ``ValueError`` on integrity failure."""
121
+ data = base64.b64decode(encrypted.encode("ascii"))
122
+
123
+ if len(data) < 16 + 32: # iv + hmac minimum
124
+ raise ValueError("Encrypted data too short — may be corrupted")
125
+
126
+ payload = data[:-32] # iv + ciphertext
127
+ mac = data[-32:] # HMAC tag
128
+
129
+ # Verify integrity before decrypting
130
+ expected_mac = hmac_mod.digest(sign_key, payload, "sha256")
131
+ if not secrets.compare_digest(mac, expected_mac):
132
+ raise ValueError(
133
+ "Credential integrity check failed — the user.json file may have "
134
+ "been tampered with. Delete it and re-login."
135
+ )
136
+
137
+ iv = payload[:16]
138
+ ciphertext = payload[16:]
139
+ key_stream = _generate_key_stream(enc_key, iv, len(ciphertext))
140
+ plain_bytes = bytes(a ^ b for a, b in zip(ciphertext, key_stream))
141
+ return plain_bytes.decode("utf-8")
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # Auth state (module-private globals)
146
+ # ---------------------------------------------------------------------------
147
+
148
+
149
+ @dataclass
150
+ class _AuthState:
151
+ token: Optional[str] = None
152
+ username: Optional[str] = None
153
+ base_url: Optional[str] = None
154
+ login_timestamp: float = 0.0
155
+ token_expires_in_seconds: int = 14400 # default 4 h
156
+
157
+ @property
158
+ def is_valid(self) -> bool:
159
+ return self.token is not None
160
+
161
+ @property
162
+ def expires_at(self) -> float:
163
+ return self.login_timestamp + self.token_expires_in_seconds
164
+
165
+ @property
166
+ def remaining_seconds(self) -> float:
167
+ return max(0.0, self.expires_at - time.time())
168
+
169
+
170
+ _auth_state = _AuthState()
171
+ _auth_lock = threading.Lock()
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # user.json helpers
175
+ # ---------------------------------------------------------------------------
176
+
177
+ # Keep a module-level cache so we don't walk the filesystem on every call.
178
+ _user_json_dir: Optional[str] = None
179
+
180
+
181
+ def _get_user_json_path() -> str:
182
+ global _user_json_dir
183
+ if _user_json_dir is None:
184
+ from tqx_data.utils.common_utils import find_project_root
185
+
186
+ _user_json_dir = find_project_root(os.path.dirname(__file__))
187
+ return os.path.join(_user_json_dir, "user.json")
188
+
189
+
190
+ def _read_persisted_credentials() -> Optional[Dict[str, Any]]:
191
+ """Decrypt and return the credential blob from *user.json*, or *None*."""
192
+ filepath = _get_user_json_path()
193
+ if not os.path.exists(filepath):
194
+ return None
195
+ try:
196
+ with open(filepath, "r", encoding="utf-8") as fh:
197
+ data = json.load(fh)
198
+ except (json.JSONDecodeError, OSError):
199
+ return None
200
+
201
+ enc_creds = data.get("encrypted_credentials")
202
+ if not enc_creds:
203
+ # Legacy / empty file — treat as absent.
204
+ return None
205
+
206
+ try:
207
+ enc_key, sign_key = _derive_key(_get_machine_fingerprint())
208
+ creds_json = _decrypt(enc_creds, enc_key, sign_key)
209
+ creds = json.loads(creds_json)
210
+ except Exception:
211
+ return None
212
+
213
+ return {
214
+ "username": creds.get("username", ""),
215
+ "password": creds.get("password", ""),
216
+ "base_url": data.get("base_url", ""),
217
+ "login_timestamp": data.get("login_timestamp", 0.0),
218
+ "token_expires_in_seconds": data.get("token_expires_in_seconds", 14400),
219
+ }
220
+
221
+
222
+ def _persist_credentials(username: str, password: str, base_url: str, expires_in: int) -> None:
223
+ """Write encrypted credentials + metadata to *user.json* (NO token)."""
224
+ enc_key, sign_key = _derive_key(_get_machine_fingerprint())
225
+ creds_json = json.dumps({"username": username, "password": password})
226
+ encrypted = _encrypt(creds_json, enc_key, sign_key)
227
+
228
+ file_data: Dict[str, Any] = {
229
+ "encrypted_credentials": encrypted,
230
+ "base_url": base_url,
231
+ "login_timestamp": time.time(),
232
+ "token_expires_in_seconds": expires_in,
233
+ "last_verified_at": time.time(),
234
+ }
235
+
236
+ filepath = _get_user_json_path()
237
+ with open(filepath, "w", encoding="utf-8") as fh:
238
+ json.dump(file_data, fh, ensure_ascii=False, indent=2)
239
+
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # Internal HTTP login helper (avoids circular imports from init_token)
243
+ # ---------------------------------------------------------------------------
244
+
245
+
246
+ def _http_login(
247
+ username: str, password: str, base_url: str
248
+ ) -> Tuple[str, int]:
249
+ """POST to the login endpoint and return ``(token, expires_in_seconds)``.
250
+
251
+ This is a self-contained helper that does **not** depend on init_token or
252
+ the global client factory so that auth_manager stays importable even before
253
+ the rest of the SDK is wired.
254
+ """
255
+ from tqx_data.exceptions import ServiceError
256
+ from tqx_data.transport.http import HTTPClient, HTTPClientConfig
257
+
258
+ endpoint = "/dataUser/login"
259
+
260
+ def _service_http_base(url: str) -> str:
261
+ u = url.rstrip("/")
262
+ for cp in ("/tqxData/oversea", "/tqxDataTick", "/tqxData"):
263
+ if u.endswith(cp):
264
+ return u[: -len(cp)]
265
+ return u
266
+
267
+ login_base = _service_http_base(base_url) + "/tqxData"
268
+ full_login_url = urljoin(login_base.rstrip("/") + "/", endpoint.lstrip("/"))
269
+
270
+ http_config = HTTPClientConfig(
271
+ base_url=login_base, username=username, password=password
272
+ )
273
+ http_client = HTTPClient(http_config)
274
+
275
+ payload = {
276
+ "username": username,
277
+ "password": password,
278
+ }
279
+
280
+ try:
281
+ raw_response = http_client.request(
282
+ method="POST", endpoint=endpoint, payload=payload
283
+ )
284
+ except ServiceError as exc:
285
+ msg = str(exc)
286
+ if msg.startswith("HTTP 404"):
287
+ msg = (
288
+ f"{msg}\n"
289
+ f"Login URL: {full_login_url}\n"
290
+ "If this port does not serve the HTTP login endpoint, "
291
+ "specify the correct gateway address."
292
+ )
293
+ raise ServiceError(msg) from exc
294
+ except Exception as exc:
295
+ raise ServiceError(f"Login failed: {exc} (URL: {full_login_url})") from exc
296
+ finally:
297
+ http_client.close()
298
+
299
+ # Parse response: may be {code, data: "<token>"} (legacy format) or
300
+ # {code, data: {token, expires_in}} (extended format).
301
+ expires_in = int(os.environ.get("tqx_AUTH_TOKEN_TTL", "14400")) # fallback 默认
302
+ if isinstance(raw_response, str):
303
+ result = raw_response
304
+ elif isinstance(raw_response, dict):
305
+ code = raw_response.get("code")
306
+ if code is not None and code not in (200, "200"):
307
+ raise ServiceError(
308
+ f"[Error code {code}: {raw_response.get('message', 'unknown')}]"
309
+ )
310
+ data = raw_response.get("data")
311
+ if isinstance(data, dict):
312
+ result = data.get("token", "")
313
+ expires_in = int(data.get("expires_in", 14400))
314
+ elif isinstance(data, str):
315
+ result = data
316
+ elif data is None and "token" in raw_response:
317
+ result = raw_response["token"]
318
+ else:
319
+ result = str(data) if data else ""
320
+ else:
321
+ result = str(raw_response)
322
+
323
+ if not result or not isinstance(result, str):
324
+ raise ServiceError("Login failed: unable to obtain a valid token")
325
+
326
+ # 从 JWT 的 exp 字段提取实际 TTL(覆盖默认值)
327
+ try:
328
+ parts = result.split(".")
329
+ if len(parts) == 3:
330
+ payload_b64 = parts[1] + "=" * (4 - len(parts[1]) % 4)
331
+ jwt_payload = json.loads(base64.b64decode(payload_b64))
332
+ exp = jwt_payload.get("exp", 0)
333
+ if exp > 0:
334
+ expires_in = max(0, int(exp - time.time()))
335
+ except Exception:
336
+ pass # 解析失败就用默认值
337
+
338
+ return result, expires_in
339
+
340
+
341
+ # ---------------------------------------------------------------------------
342
+ # Public API — used by the rest of the SDK
343
+ # ---------------------------------------------------------------------------
344
+
345
+
346
+ def save_auth_state(
347
+ username: str,
348
+ password: str,
349
+ base_url: str,
350
+ token: str,
351
+ expires_in: int = 14400,
352
+ ) -> None:
353
+ """Persist encrypted credentials to disk and cache *token* in memory."""
354
+ global _auth_state
355
+ with _auth_lock:
356
+ _auth_state.token = token
357
+ _auth_state.username = username
358
+ _auth_state.base_url = base_url
359
+ _auth_state.login_timestamp = time.time()
360
+ _auth_state.token_expires_in_seconds = expires_in
361
+
362
+ _persist_credentials(username, password, base_url, expires_in)
363
+
364
+
365
+ def get_token() -> Optional[str]:
366
+ """Return the currently cached token (in-memory), or *None*."""
367
+ with _auth_lock:
368
+ return _auth_state.token
369
+
370
+
371
+ def get_base_url() -> Optional[str]:
372
+ with _auth_lock:
373
+ return _auth_state.base_url
374
+
375
+
376
+ def get_username() -> Optional[str]:
377
+ with _auth_lock:
378
+ return _auth_state.username
379
+
380
+
381
+ def get_password() -> Optional[str]:
382
+ """Return the decrypted password from disk (used for re-login)."""
383
+ creds = _read_persisted_credentials()
384
+ return creds.get("password") if creds else None
385
+
386
+
387
+ def get_decrypted_credentials() -> Optional[Dict[str, Any]]:
388
+ """Return the full decrypted credential dict or *None*."""
389
+ return _read_persisted_credentials()
390
+
391
+
392
+ # -- Public status queries (safe to expose to end users) --
393
+
394
+
395
+ def is_authenticated() -> bool:
396
+ """Return *True* if a valid token is cached in memory."""
397
+ with _auth_lock:
398
+ return _auth_state.is_valid
399
+
400
+
401
+ def auth_remaining_seconds() -> float:
402
+ """Approximate seconds until the current session expires.
403
+
404
+ Returns 0.0 when not authenticated or when the expiry has already passed.
405
+ """
406
+ with _auth_lock:
407
+ return _auth_state.remaining_seconds
408
+
409
+
410
+ def auth_expires_at() -> Optional[float]:
411
+ """Return the POSIX timestamp at which the current session expires, or *None*."""
412
+ with _auth_lock:
413
+ if _auth_state.is_valid:
414
+ return _auth_state.expires_at
415
+ return None
416
+
417
+
418
+ def get_token_ttl() -> int:
419
+ """Return the token's configured TTL in seconds (the full duration, not remaining)."""
420
+ with _auth_lock:
421
+ return _auth_state.token_expires_in_seconds
422
+
423
+
424
+ def auth_info() -> Dict[str, Any]:
425
+ """Return a user-safe dict with auth status and duration (no token / uid)."""
426
+ with _auth_lock:
427
+ return {
428
+ "authenticated": _auth_state.is_valid,
429
+ "remaining_seconds": _auth_state.remaining_seconds,
430
+ "expires_at": _auth_state.expires_at if _auth_state.is_valid else None,
431
+ "username": _auth_state.username,
432
+ }
433
+
434
+
435
+ # -- Auto-login & re-login --
436
+
437
+
438
+ def auto_login_from_disk() -> bool:
439
+ """Attempt to log in using persisted credentials.
440
+
441
+ Returns *True* when login succeeds and the in-memory token is refreshed.
442
+ """
443
+ creds = _read_persisted_credentials()
444
+ if not creds:
445
+ return False
446
+
447
+ username = creds["username"]
448
+ password = creds["password"]
449
+ base_url = creds["base_url"]
450
+
451
+ if not all([username, password, base_url]):
452
+ return False
453
+
454
+ try:
455
+ token, expires_in = _http_login(username, password, base_url)
456
+ save_auth_state(username, password, base_url, token, expires_in)
457
+ return True
458
+ except Exception:
459
+ return False
460
+
461
+
462
+ # 防止并发 re-login 的互斥锁
463
+ _relogin_lock = threading.Lock()
464
+ # 记录最后一次 re-login 尝试的时间戳,避免短时间内重复尝试
465
+ _last_relogin_attempt: float = 0.0
466
+ _RELOGIN_COOLDOWN_SECONDS = 5.0 # 两次 re-login 之间的最小间隔
467
+
468
+
469
+ def re_login(force: bool = False) -> bool:
470
+ """Re-login using persisted credentials (called on 401 or proactive refresh).
471
+
472
+ 线程安全:同一时刻只允许一个线程执行 HTTP 登录;
473
+ 其余线程短暂等待后复用结果,避免惊群效应。
474
+
475
+ ``force=True`` 用于 WebSocket 握手被拒绝后强制重新登录,即使内存中
476
+ 仍缓存着尚未过期的旧 token。
477
+
478
+ Returns *True* on success. On failure the in-memory token is cleared.
479
+ """
480
+ global _last_relogin_attempt
481
+
482
+ # 快速路径:如果 token 仍然有效且不接近过期,直接返回成功
483
+ if not force:
484
+ with _auth_lock:
485
+ if _auth_state.is_valid and _auth_state.remaining_seconds > 60:
486
+ return True
487
+
488
+ # 冷却期检查:避免短时间内重复尝试(比如网关暂时不可达)
489
+ now = time.time()
490
+ if not force and now - _last_relogin_attempt < _RELOGIN_COOLDOWN_SECONDS:
491
+ with _auth_lock:
492
+ return _auth_state.is_valid
493
+
494
+ # 尝试获取 re-login 锁(非阻塞)
495
+ if not _relogin_lock.acquire(blocking=False):
496
+ # 另一个线程正在执行 re-login,等待其完成(最多 5 秒)
497
+ for _ in range(50):
498
+ time.sleep(0.1)
499
+ with _auth_lock:
500
+ if _auth_state.is_valid:
501
+ return True
502
+ # 等待超时 → 返回当前状态
503
+ with _auth_lock:
504
+ return _auth_state.is_valid
505
+
506
+ try:
507
+ # 双重检查:获取锁后再次确认是否需要 re-login
508
+ if not force:
509
+ with _auth_lock:
510
+ if _auth_state.is_valid and _auth_state.remaining_seconds > 60:
511
+ return True
512
+
513
+ _last_relogin_attempt = time.time()
514
+
515
+ creds = _read_persisted_credentials()
516
+ if not creds:
517
+ return False
518
+
519
+ username = creds["username"]
520
+ password = creds["password"]
521
+ base_url = creds["base_url"]
522
+
523
+ if not all([username, password, base_url]):
524
+ return False
525
+
526
+ token, expires_in = _http_login(username, password, base_url)
527
+ save_auth_state(username, password, base_url, token, expires_in)
528
+ return True
529
+ except Exception:
530
+ with _auth_lock:
531
+ _auth_state.token = None
532
+ return False
533
+ finally:
534
+ _relogin_lock.release()
535
+
536
+
537
+ def is_token_near_expiry(threshold_seconds: float = 1800.0) -> bool:
538
+ """Return *True* when the token expires within *threshold_seconds* (default 30 min)."""
539
+ with _auth_lock:
540
+ if not _auth_state.is_valid:
541
+ return False
542
+ return _auth_state.remaining_seconds <= threshold_seconds
543
+
544
+
545
+ def proactive_refresh_if_needed(threshold_seconds: float = 1800.0) -> bool:
546
+ """If the token is near expiry, attempt a proactive re-login.
547
+
548
+ Returns *True* if a refresh was attempted and succeeded.
549
+ """
550
+ if is_token_near_expiry(threshold_seconds):
551
+ return re_login()
552
+ return False # no refresh needed
553
+
554
+
555
+ def clear_auth() -> None:
556
+ """Clear in-memory token and remove *user.json*."""
557
+ global _auth_state
558
+ with _auth_lock:
559
+ _auth_state = _AuthState()
560
+ try:
561
+ os.remove(_get_user_json_path())
562
+ except OSError:
563
+ pass
@@ -0,0 +1,11 @@
1
+ """HTTP 服务客户端与实时 WebSocket 客户端。"""
2
+
3
+ from .service import get_client, get_factory, init
4
+ from .live_market_client import TickStreamClient
5
+
6
+ __all__ = [
7
+ "get_client",
8
+ "get_factory",
9
+ "init",
10
+ "TickStreamClient",
11
+ ]