litequant 3.0.0__tar.gz → 3.0.2__tar.gz

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.
@@ -16,7 +16,7 @@ LiteQuant 3.0 Client - 独立简洁版
16
16
  save_path='D:/LiteQuant/'
17
17
  )
18
18
 
19
- client.UpdateAllCategory(update_method='full')
19
+ client.UpdateAllCategory(update_method='full')
20
20
  df = client.GetCategory("cn_stock_pivot#open")
21
21
 
22
22
  # 使用 with 语句(自动关闭)
@@ -29,9 +29,10 @@ import io
29
29
  import json
30
30
  import logging
31
31
  import os
32
+ import fnmatch
32
33
  import time
33
- import threading
34
- from typing import Any, Dict, List, Optional, Literal
34
+ import threading
35
+ from typing import Any, Dict, List, Optional, Literal
35
36
  from dataclasses import dataclass
36
37
  from datetime import datetime, timedelta
37
38
 
@@ -41,54 +42,55 @@ import requests
41
42
  from tqdm import tqdm
42
43
 
43
44
  from .ParquetManager import ParquetDataManager
44
- from .exceptions import (
45
- LiteQuantError,
46
- AuthError,
47
- CategoryNotFoundError,
48
- InvalidCategoryError,
49
- RemoteDataError,
50
- SerializationError,
51
- MetaError,
52
- ProtocolError,
53
- TicketExpiredError,
54
- QuotaExceededError,
55
- PermissionDeniedError,
56
- NetworkError,
57
- DataConnectionError,
58
- ServiceUnavailableError,
59
- SyncError,
60
- APIError,
61
- )
45
+ from .exceptions import (
46
+ LiteQuantError,
47
+ AuthError,
48
+ CategoryNotFoundError,
49
+ InvalidCategoryError,
50
+ RemoteDataError,
51
+ SerializationError,
52
+ MetaError,
53
+ ProtocolError,
54
+ TicketExpiredError,
55
+ QuotaExceededError,
56
+ PermissionDeniedError,
57
+ NetworkError,
58
+ DataConnectionError,
59
+ ServiceUnavailableError,
60
+ SyncError,
61
+ APIError,
62
+ )
62
63
  from .log import GetLogger
63
64
 
64
65
 
65
66
  # ============ 数据模型 ============
66
67
 
67
- @dataclass(repr=False)
68
- class DataTicket:
69
- """Data Ticket data returned by the service."""
70
- ticket_id: str
71
- session_id: str
72
- redis_host: str
73
- redis_port: int
74
- redis_password: str
75
- expires_at: datetime
76
- datasets: List[str]
77
- key_patterns: List[str]
78
- redis_username: Optional[str] = None
79
- redis_ssl: bool = False
68
+ @dataclass(repr=False)
69
+ class DataTicket:
70
+ """Data Ticket data returned by the service."""
71
+ ticket_id: str
72
+ session_id: str
73
+ redis_host: str
74
+ redis_port: int
75
+ redis_password: str
76
+ expires_at: datetime
77
+ datasets: List[str]
78
+ key_patterns: List[str]
79
+ category_patterns: Optional[List[str]] = None
80
+ redis_username: Optional[str] = None
81
+ redis_ssl: bool = False
80
82
 
81
83
  @property
82
84
  def is_expired(self) -> bool:
83
85
  return datetime.now() >= self.expires_at
84
86
 
85
87
  @property
86
- def ttl_seconds(self) -> float:
87
- delta = self.expires_at - datetime.now()
88
- return max(0, delta.total_seconds())
89
-
90
- def __repr__(self) -> str:
91
- return "DataTicket(<hidden>)"
88
+ def ttl_seconds(self) -> float:
89
+ delta = self.expires_at - datetime.now()
90
+ return max(0, delta.total_seconds())
91
+
92
+ def __repr__(self) -> str:
93
+ return "DataTicket(<hidden>)"
92
94
 
93
95
 
94
96
  # ============ 核心客户端 ============
@@ -107,7 +109,7 @@ class LiteQuantClient:
107
109
  """
108
110
 
109
111
  # 常量配置
110
- DEFAULT_API_URL = os.environ.get('LITEQUANT_API_URL', 'https://www.litequant.pro')
112
+ DEFAULT_API_URL = os.environ.get('LITEQUANT_API_URL', 'https://www.litequant.pro')
111
113
  PROTOCOL_VERSION = 1
112
114
  DATA_FORMAT = "parquet"
113
115
 
@@ -116,69 +118,69 @@ class LiteQuantClient:
116
118
  TICKET_REFRESH_THRESHOLD = 30 # 过期前30秒续租
117
119
 
118
120
  # Redis Key 前缀
119
- META_CATEGORIES_KEY = "litequant:meta:categories"
120
- META_CATEGORY_KEY_PREFIX = "litequant:meta:category:"
121
- META_PARTITION_KEY_PREFIX = "litequant:meta:partition:"
122
-
123
- PUBLIC_ERROR_MESSAGES = {
124
- "AUTH_INVALID": "API 凭证无效或已过期,请检查后重试",
125
- "ACCOUNT_UNAVAILABLE": "账号不可用,请联系管理员处理",
126
- "SUBSCRIPTION_UNAVAILABLE": "套餐不可用或已过期,请续费后重试",
127
- "PERMISSION_DENIED": "权限不足:当前账号没有该数据权限",
128
- "CONNECTION_LIMIT": "连接数已达上限,请关闭其他连接后重试",
129
- "CONNECTION_INTERRUPTED": "连接中断,请重新初始化客户端",
130
- "REQUEST_INVALID": "请求参数无效,请检查输入后重试",
131
- "SERVICE_UNAVAILABLE": "服务暂时不可用,请稍后重试",
132
- "DATA_UNAVAILABLE": "数据暂时不可用,请稍后重试",
133
- "DATA_VERIFY_FAILED": "数据校验失败,请重新同步",
134
- }
135
- LEGACY_ERROR_CODE_MAP = {
136
- "Unauthorized": "AUTH_INVALID",
137
- "UserDisabled": "ACCOUNT_UNAVAILABLE",
138
- "SubscriptionInvalid": "SUBSCRIPTION_UNAVAILABLE",
139
- "SubscriptionExpired": "SUBSCRIPTION_UNAVAILABLE",
140
- "NoDatasetAccess": "PERMISSION_DENIED",
141
- "ConnectionLimitExceeded": "CONNECTION_LIMIT",
142
- "InvalidRequest": "REQUEST_INVALID",
143
- "MissingParameters": "REQUEST_INVALID",
144
- "TicketNotFound": "CONNECTION_INTERRUPTED",
145
- "TicketExpired": "CONNECTION_INTERRUPTED",
146
- "SessionNotFound": "CONNECTION_INTERRUPTED",
147
- "SessionExpired": "CONNECTION_INTERRUPTED",
148
- "RenewFailed": "CONNECTION_INTERRUPTED",
149
- "InternalError": "SERVICE_UNAVAILABLE",
150
- }
151
- ERROR_CLASS_MAP = {
152
- "AUTH_INVALID": AuthError,
153
- "ACCOUNT_UNAVAILABLE": PermissionDeniedError,
154
- "SUBSCRIPTION_UNAVAILABLE": PermissionDeniedError,
155
- "PERMISSION_DENIED": PermissionDeniedError,
156
- "CONNECTION_LIMIT": QuotaExceededError,
157
- "CONNECTION_INTERRUPTED": DataConnectionError,
158
- "REQUEST_INVALID": APIError,
159
- "SERVICE_UNAVAILABLE": ServiceUnavailableError,
160
- "DATA_UNAVAILABLE": RemoteDataError,
161
- "DATA_VERIFY_FAILED": SerializationError,
162
- }
121
+ META_CATEGORIES_KEY = "litequant:meta:categories"
122
+ META_CATEGORY_KEY_PREFIX = "litequant:meta:category:"
123
+ META_PARTITION_KEY_PREFIX = "litequant:meta:partition:"
124
+
125
+ PUBLIC_ERROR_MESSAGES = {
126
+ "AUTH_INVALID": "API 凭证无效或已过期,请检查后重试",
127
+ "ACCOUNT_UNAVAILABLE": "账号不可用,请联系管理员处理",
128
+ "SUBSCRIPTION_UNAVAILABLE": "套餐不可用或已过期,请续费后重试",
129
+ "PERMISSION_DENIED": "权限不足:当前账号没有该数据权限",
130
+ "CONNECTION_LIMIT": "连接数已达上限,请关闭其他连接后重试",
131
+ "CONNECTION_INTERRUPTED": "连接中断,请重新初始化客户端",
132
+ "REQUEST_INVALID": "请求参数无效,请检查输入后重试",
133
+ "SERVICE_UNAVAILABLE": "服务暂时不可用,请稍后重试",
134
+ "DATA_UNAVAILABLE": "数据暂时不可用,请稍后重试",
135
+ "DATA_VERIFY_FAILED": "数据校验失败,请重新同步",
136
+ }
137
+ LEGACY_ERROR_CODE_MAP = {
138
+ "Unauthorized": "AUTH_INVALID",
139
+ "UserDisabled": "ACCOUNT_UNAVAILABLE",
140
+ "SubscriptionInvalid": "SUBSCRIPTION_UNAVAILABLE",
141
+ "SubscriptionExpired": "SUBSCRIPTION_UNAVAILABLE",
142
+ "NoDatasetAccess": "PERMISSION_DENIED",
143
+ "ConnectionLimitExceeded": "CONNECTION_LIMIT",
144
+ "InvalidRequest": "REQUEST_INVALID",
145
+ "MissingParameters": "REQUEST_INVALID",
146
+ "TicketNotFound": "CONNECTION_INTERRUPTED",
147
+ "TicketExpired": "CONNECTION_INTERRUPTED",
148
+ "SessionNotFound": "CONNECTION_INTERRUPTED",
149
+ "SessionExpired": "CONNECTION_INTERRUPTED",
150
+ "RenewFailed": "CONNECTION_INTERRUPTED",
151
+ "InternalError": "SERVICE_UNAVAILABLE",
152
+ }
153
+ ERROR_CLASS_MAP = {
154
+ "AUTH_INVALID": AuthError,
155
+ "ACCOUNT_UNAVAILABLE": PermissionDeniedError,
156
+ "SUBSCRIPTION_UNAVAILABLE": PermissionDeniedError,
157
+ "PERMISSION_DENIED": PermissionDeniedError,
158
+ "CONNECTION_LIMIT": QuotaExceededError,
159
+ "CONNECTION_INTERRUPTED": DataConnectionError,
160
+ "REQUEST_INVALID": APIError,
161
+ "SERVICE_UNAVAILABLE": ServiceUnavailableError,
162
+ "DATA_UNAVAILABLE": RemoteDataError,
163
+ "DATA_VERIFY_FAILED": SerializationError,
164
+ }
163
165
 
164
166
  def __init__(
165
167
  self,
166
168
  api_token: str,
167
- save_path: str,
168
- api_url: str = None,
169
- log_level: int = logging.INFO,
170
- display_errors: bool = True,
171
- ):
172
- self.api_token = api_token
173
- self.api_url = (api_url or os.environ.get('LITEQUANT_API_URL', self.DEFAULT_API_URL)).rstrip("/")
174
- self.save_path = os.path.abspath(save_path)
175
- self.display_errors = display_errors
169
+ save_path: str,
170
+ api_url: str = None,
171
+ log_level: int = logging.INFO,
172
+ display_errors: bool = True,
173
+ ):
174
+ self.api_token = api_token
175
+ self.api_url = (api_url or os.environ.get('LITEQUANT_API_URL', self.DEFAULT_API_URL)).rstrip("/")
176
+ self.save_path = os.path.abspath(save_path)
177
+ self.display_errors = display_errors
176
178
 
177
179
  # 确保路径存在
178
180
  os.makedirs(self.save_path, exist_ok=True)
179
181
 
180
182
  # 日志(使用 token 前8位作为标识)
181
- self.logger = GetLogger("litequant", level=log_level)
183
+ self.logger = GetLogger("litequant", level=log_level)
182
184
  self.logger.info(f"初始化 LiteQuantClient")
183
185
 
184
186
  # 数据管理器
@@ -197,17 +199,17 @@ class LiteQuantClient:
197
199
  self._remote_categories_set = set()
198
200
 
199
201
  # 初始化连接
200
- try:
201
- self._init_connection()
202
- self._start_heartbeat()
203
- except LiteQuantError as e:
204
- err = self._public_error_clone(e)
205
- self._display_error(err)
206
- raise err from None
207
- except Exception as e:
208
- err = ServiceUnavailableError()
209
- self._display_error(err)
210
- raise err from None
202
+ try:
203
+ self._init_connection()
204
+ self._start_heartbeat()
205
+ except LiteQuantError as e:
206
+ err = self._public_error_clone(e)
207
+ self._display_error(err)
208
+ raise err from None
209
+ except Exception as e:
210
+ err = ServiceUnavailableError()
211
+ self._display_error(err)
212
+ raise err from None
211
213
 
212
214
  # 启动心跳
213
215
 
@@ -215,93 +217,93 @@ class LiteQuantClient:
215
217
 
216
218
  # ============ 连接管理 ============
217
219
 
218
- def _display_error(self, error: LiteQuantError) -> None:
219
- """Print a privacy-safe user message to the configured SDK logger."""
220
- if self.display_errors:
221
- self.logger.error(error.user_message)
222
-
223
- def _public_error_clone(self, error: LiteQuantError) -> LiteQuantError:
224
- """Return a fresh privacy-safe exception without traceback/cause state."""
225
- return error.__class__(
226
- code=error.code,
227
- user_message=error.user_message,
228
- retryable=error.retryable,
229
- )
230
-
231
- def _as_litequant_error(self, error: Exception) -> LiteQuantError:
232
- if isinstance(error, LiteQuantError):
233
- return self._public_error_clone(error)
234
- return ServiceUnavailableError()
235
-
236
- def _api_post_json(self, endpoint: str, payload: dict, timeout: int) -> dict:
237
- url = f"{self.api_url}{endpoint}"
238
- headers = {
239
- "Authorization": f"Bearer {self.api_token}",
240
- "Content-Type": "application/json",
241
- }
242
- try:
243
- response = requests.post(url, headers=headers, json=payload, timeout=timeout)
244
- except (requests.exceptions.Timeout, requests.exceptions.ConnectionError):
245
- raise NetworkError() from None
246
- except requests.exceptions.RequestException:
247
- raise ServiceUnavailableError() from None
248
-
249
- return self._parse_api_response(response)
250
-
251
- def _parse_api_response(self, response: Any) -> dict:
252
- status_code = getattr(response, "status_code", 200)
253
- ok = getattr(response, "ok", 200 <= status_code < 300)
254
- try:
255
- data = response.json()
256
- except ValueError:
257
- public_code = self._public_code_from_status(status_code)
258
- raise self._build_public_error(public_code) from None
259
-
260
- if not isinstance(data, dict):
261
- raise ServiceUnavailableError(detail="invalid response shape")
262
-
263
- if ok and data.get("success", True):
264
- return data
265
-
266
- public_code = self._normalize_error_code(data.get("error"), status_code)
267
- raise self._build_public_error(public_code) from None
268
-
269
- def _normalize_error_code(self, raw_code: Any, status_code: int = None) -> str:
270
- if raw_code:
271
- raw_code = str(raw_code)
272
- public_code = self.LEGACY_ERROR_CODE_MAP.get(raw_code, raw_code)
273
- if public_code in self.PUBLIC_ERROR_MESSAGES:
274
- return public_code
275
- return self._public_code_from_status(status_code)
276
-
277
- def _public_code_from_status(self, status_code: int = None) -> str:
278
- if status_code == 401:
279
- return "AUTH_INVALID"
280
- if status_code == 403:
281
- return "PERMISSION_DENIED"
282
- if status_code == 429:
283
- return "CONNECTION_LIMIT"
284
- if status_code == 400:
285
- return "REQUEST_INVALID"
286
- if status_code and status_code >= 500:
287
- return "SERVICE_UNAVAILABLE"
288
- return "SERVICE_UNAVAILABLE"
289
-
290
- def _build_public_error(self, public_code: str, detail: Any = None) -> LiteQuantError:
291
- error_cls = self.ERROR_CLASS_MAP.get(public_code, ServiceUnavailableError)
292
- return error_cls(
293
- code=public_code,
294
- user_message=self.PUBLIC_ERROR_MESSAGES.get(public_code, self.PUBLIC_ERROR_MESSAGES["SERVICE_UNAVAILABLE"]),
295
- retryable=public_code in {
296
- "CONNECTION_LIMIT",
297
- "CONNECTION_INTERRUPTED",
298
- "SERVICE_UNAVAILABLE",
299
- "DATA_UNAVAILABLE",
300
- "DATA_VERIFY_FAILED",
301
- },
302
- )
303
-
304
- def _init_connection(self):
220
+ def _display_error(self, error: LiteQuantError) -> None:
221
+ """Print a privacy-safe user message to the configured SDK logger."""
222
+ if self.display_errors:
223
+ self.logger.error(error.user_message)
224
+
225
+ def _public_error_clone(self, error: LiteQuantError) -> LiteQuantError:
226
+ """Return a fresh privacy-safe exception without traceback/cause state."""
227
+ return error.__class__(
228
+ code=error.code,
229
+ user_message=error.user_message,
230
+ retryable=error.retryable,
231
+ )
232
+
233
+ def _as_litequant_error(self, error: Exception) -> LiteQuantError:
234
+ if isinstance(error, LiteQuantError):
235
+ return self._public_error_clone(error)
236
+ return ServiceUnavailableError()
237
+
238
+ def _api_post_json(self, endpoint: str, payload: dict, timeout: int) -> dict:
239
+ url = f"{self.api_url}{endpoint}"
240
+ headers = {
241
+ "Authorization": f"Bearer {self.api_token}",
242
+ "Content-Type": "application/json",
243
+ }
244
+ try:
245
+ response = requests.post(url, headers=headers, json=payload, timeout=timeout)
246
+ except (requests.exceptions.Timeout, requests.exceptions.ConnectionError):
247
+ raise NetworkError() from None
248
+ except requests.exceptions.RequestException:
249
+ raise ServiceUnavailableError() from None
250
+
251
+ return self._parse_api_response(response)
252
+
253
+ def _parse_api_response(self, response: Any) -> dict:
254
+ status_code = getattr(response, "status_code", 200)
255
+ ok = getattr(response, "ok", 200 <= status_code < 300)
256
+ try:
257
+ data = response.json()
258
+ except ValueError:
259
+ public_code = self._public_code_from_status(status_code)
260
+ raise self._build_public_error(public_code) from None
261
+
262
+ if not isinstance(data, dict):
263
+ raise ServiceUnavailableError(detail="invalid response shape")
264
+
265
+ if ok and data.get("success", True):
266
+ return data
267
+
268
+ public_code = self._normalize_error_code(data.get("error"), status_code)
269
+ raise self._build_public_error(public_code) from None
270
+
271
+ def _normalize_error_code(self, raw_code: Any, status_code: int = None) -> str:
272
+ if raw_code:
273
+ raw_code = str(raw_code)
274
+ public_code = self.LEGACY_ERROR_CODE_MAP.get(raw_code, raw_code)
275
+ if public_code in self.PUBLIC_ERROR_MESSAGES:
276
+ return public_code
277
+ return self._public_code_from_status(status_code)
278
+
279
+ def _public_code_from_status(self, status_code: int = None) -> str:
280
+ if status_code == 401:
281
+ return "AUTH_INVALID"
282
+ if status_code == 403:
283
+ return "PERMISSION_DENIED"
284
+ if status_code == 429:
285
+ return "CONNECTION_LIMIT"
286
+ if status_code == 400:
287
+ return "REQUEST_INVALID"
288
+ if status_code and status_code >= 500:
289
+ return "SERVICE_UNAVAILABLE"
290
+ return "SERVICE_UNAVAILABLE"
291
+
292
+ def _build_public_error(self, public_code: str, detail: Any = None) -> LiteQuantError:
293
+ error_cls = self.ERROR_CLASS_MAP.get(public_code, ServiceUnavailableError)
294
+ return error_cls(
295
+ code=public_code,
296
+ user_message=self.PUBLIC_ERROR_MESSAGES.get(public_code, self.PUBLIC_ERROR_MESSAGES["SERVICE_UNAVAILABLE"]),
297
+ retryable=public_code in {
298
+ "CONNECTION_LIMIT",
299
+ "CONNECTION_INTERRUPTED",
300
+ "SERVICE_UNAVAILABLE",
301
+ "DATA_UNAVAILABLE",
302
+ "DATA_VERIFY_FAILED",
303
+ },
304
+ )
305
+
306
+ def _init_connection(self):
305
307
  """初始化连接(获取 Ticket + 连接 Redis)"""
306
308
  self._fetch_ticket()
307
309
  self._connect_redis()
@@ -309,87 +311,88 @@ class LiteQuantClient:
309
311
 
310
312
  def _fetch_ticket(self) -> DataTicket:
311
313
  """从 Django API 获取 Data Ticket"""
312
- try:
313
- data = self._api_post_json(
314
- "/api/v1/data/ticket/",
315
- {"client_version": "3.0.0"},
316
- timeout=30,
317
- )
318
-
319
- redis_info = data["redis"]
320
- scope = data.get("scope", {})
321
- redis_username = redis_info.get("username")
322
- redis_ssl = bool(redis_info.get("ssl", False))
323
-
324
- self._ticket = DataTicket(
325
- ticket_id=data["ticket"],
326
- session_id=data["session_id"],
327
- redis_host=redis_info["host"],
328
- redis_port=redis_info["port"],
329
- redis_password=redis_info["password"],
330
- expires_at=datetime.now() + timedelta(seconds=data["expires_in"]),
331
- datasets=scope.get("datasets", []),
332
- key_patterns=scope.get("key_patterns", []),
333
- redis_username=redis_username,
334
- redis_ssl=redis_ssl,
335
- )
314
+ try:
315
+ data = self._api_post_json(
316
+ "/api/v1/data/ticket/",
317
+ {"client_version": "3.0.2"},
318
+ timeout=30,
319
+ )
320
+
321
+ redis_info = data["redis"]
322
+ scope = data.get("scope", {})
323
+ redis_username = redis_info.get("username")
324
+ redis_ssl = bool(redis_info.get("ssl", False))
325
+
326
+ self._ticket = DataTicket(
327
+ ticket_id=data["ticket"],
328
+ session_id=data["session_id"],
329
+ redis_host=redis_info["host"],
330
+ redis_port=redis_info["port"],
331
+ redis_password=redis_info["password"],
332
+ expires_at=datetime.now() + timedelta(seconds=data["expires_in"]),
333
+ datasets=scope.get("datasets", []),
334
+ key_patterns=scope.get("key_patterns", []),
335
+ category_patterns=scope.get("category_patterns", []),
336
+ redis_username=redis_username,
337
+ redis_ssl=redis_ssl,
338
+ )
339
+
340
+ self.logger.debug("连接凭证已获取")
341
+ return self._ticket
336
342
 
337
- self.logger.debug("连接凭证已获取")
338
- return self._ticket
339
-
340
- except LiteQuantError:
341
- raise
342
- except (KeyError, TypeError, ValueError):
343
- raise ServiceUnavailableError() from None
343
+ except LiteQuantError:
344
+ raise
345
+ except (KeyError, TypeError, ValueError):
346
+ raise ServiceUnavailableError() from None
344
347
 
345
348
  def _connect_redis(self):
346
349
  """使用 Ticket 连接 Redis"""
347
- if not self._ticket:
348
- raise DataConnectionError()
349
-
350
- try:
351
- redis_kwargs = {
352
- "host": self._ticket.redis_host,
353
- "port": self._ticket.redis_port,
354
- "password": self._ticket.redis_password,
355
- "socket_timeout": 20,
356
- "socket_connect_timeout": 5,
357
- "decode_responses": False,
358
- }
359
- if self._ticket.redis_username:
360
- redis_kwargs["username"] = self._ticket.redis_username
361
- redis_kwargs["ssl"] = self._ticket.redis_ssl
362
- else:
363
- # Aliyun Tair can use password formatted as "username:password".
364
- redis_kwargs["db"] = 0
365
-
366
- for attempt in range(6):
367
- self._redis_client = redis.Redis(**redis_kwargs)
368
- try:
369
- if not self._redis_client.ping():
370
- raise DataConnectionError()
371
- break
372
- except redis.AuthenticationError:
373
- try:
374
- self._redis_client.close()
375
- except Exception:
376
- pass
377
- self._redis_client = None
378
- if attempt >= 5:
379
- raise
380
- self.logger.warning(f"数据连接暂未就绪,正在重试 {attempt + 1}/5")
381
- time.sleep(3)
382
- else:
383
- raise DataConnectionError()
384
-
385
- self.logger.info("数据连接已建立")
350
+ if not self._ticket:
351
+ raise DataConnectionError()
352
+
353
+ try:
354
+ redis_kwargs = {
355
+ "host": self._ticket.redis_host,
356
+ "port": self._ticket.redis_port,
357
+ "password": self._ticket.redis_password,
358
+ "socket_timeout": 20,
359
+ "socket_connect_timeout": 5,
360
+ "decode_responses": False,
361
+ }
362
+ if self._ticket.redis_username:
363
+ redis_kwargs["username"] = self._ticket.redis_username
364
+ redis_kwargs["ssl"] = self._ticket.redis_ssl
365
+ else:
366
+ # Aliyun Tair can use password formatted as "username:password".
367
+ redis_kwargs["db"] = 0
368
+
369
+ for attempt in range(6):
370
+ self._redis_client = redis.Redis(**redis_kwargs)
371
+ try:
372
+ if not self._redis_client.ping():
373
+ raise DataConnectionError()
374
+ break
375
+ except redis.AuthenticationError:
376
+ try:
377
+ self._redis_client.close()
378
+ except Exception:
379
+ pass
380
+ self._redis_client = None
381
+ if attempt >= 5:
382
+ raise
383
+ self.logger.warning(f"数据连接暂未就绪,正在重试 {attempt + 1}/5")
384
+ time.sleep(3)
385
+ else:
386
+ raise DataConnectionError()
386
387
 
387
- except redis.AuthenticationError:
388
- raise DataConnectionError() from None
389
- except Exception as e:
390
- if isinstance(e, LiteQuantError):
391
- raise self._public_error_clone(e) from None
392
- raise DataConnectionError() from None
388
+ self.logger.info("数据连接已建立")
389
+
390
+ except redis.AuthenticationError:
391
+ raise DataConnectionError() from None
392
+ except Exception as e:
393
+ if isinstance(e, LiteQuantError):
394
+ raise self._public_error_clone(e) from None
395
+ raise DataConnectionError() from None
393
396
 
394
397
  def _disconnect_server(self):
395
398
  """通知服务器断开连接"""
@@ -415,35 +418,35 @@ class LiteQuantClient:
415
418
  def _ensure_connection(self):
416
419
  """确保连接有效(过期时自动刷新)"""
417
420
  if not self._ticket or self._ticket.is_expired:
418
- self.logger.info("连接已过期,正在重新连接...")
421
+ self.logger.info("连接已过期,正在重新连接...")
419
422
  self._fetch_ticket()
420
423
  self._connect_redis()
421
424
  elif self._ticket.ttl_seconds < self.TICKET_REFRESH_THRESHOLD:
422
425
  self._renew_ticket()
423
426
 
424
- def _renew_ticket(self):
425
- """续租 Ticket"""
426
- if not self._ticket:
427
- raise DataConnectionError()
428
- try:
429
- data = self._api_post_json(
430
- "/api/v1/data/heartbeat/",
431
- {
432
- "ticket": self._ticket.ticket_id,
433
- "session_id": self._ticket.session_id,
434
- },
435
- timeout=10,
436
- )
437
- self._ticket.expires_at = datetime.now() + timedelta(seconds=data.get("expires_in", 60))
438
- self.logger.debug("连接已保持")
439
- except DataConnectionError:
440
- self.logger.warning("连接已中断,正在重新连接...")
441
- self._fetch_ticket()
442
- self._connect_redis()
443
- except LiteQuantError:
444
- raise
445
- except Exception:
446
- raise ServiceUnavailableError() from None
427
+ def _renew_ticket(self):
428
+ """续租 Ticket"""
429
+ if not self._ticket:
430
+ raise DataConnectionError()
431
+ try:
432
+ data = self._api_post_json(
433
+ "/api/v1/data/heartbeat/",
434
+ {
435
+ "ticket": self._ticket.ticket_id,
436
+ "session_id": self._ticket.session_id,
437
+ },
438
+ timeout=10,
439
+ )
440
+ self._ticket.expires_at = datetime.now() + timedelta(seconds=data.get("expires_in", 60))
441
+ self.logger.debug("连接已保持")
442
+ except DataConnectionError:
443
+ self.logger.warning("连接已中断,正在重新连接...")
444
+ self._fetch_ticket()
445
+ self._connect_redis()
446
+ except LiteQuantError:
447
+ raise
448
+ except Exception:
449
+ raise ServiceUnavailableError() from None
447
450
 
448
451
  def _start_heartbeat(self):
449
452
  """启动后台心跳线程"""
@@ -455,45 +458,45 @@ class LiteQuantClient:
455
458
  def _heartbeat_loop(self):
456
459
  """心跳循环"""
457
460
  while not self._stop_heartbeat.wait(self.HEARTBEAT_INTERVAL):
458
- try:
459
- if self._ticket and self._ticket.ttl_seconds < self.TICKET_REFRESH_THRESHOLD:
460
- self._renew_ticket()
461
- except LiteQuantError as e:
462
- self.logger.warning(e.user_message)
463
- except Exception:
464
- self.logger.warning(ServiceUnavailableError().user_message)
461
+ try:
462
+ if self._ticket and self._ticket.ttl_seconds < self.TICKET_REFRESH_THRESHOLD:
463
+ self._renew_ticket()
464
+ except LiteQuantError as e:
465
+ self.logger.warning(e.user_message)
466
+ except Exception:
467
+ self.logger.warning(ServiceUnavailableError().user_message)
465
468
 
466
469
  # ============ 核心用户接口 ============
467
470
 
468
- def UpdateAllCategory(self, update_method: Literal['full', 'incremental'] = 'full') -> None:
469
- """更新所有已授权的数据类别。"""
470
- try:
471
- return self._update_all_category(update_method)
472
- except LiteQuantError as e:
473
- err = self._public_error_clone(e)
474
- self._display_error(err)
475
- raise err from None
476
- except Exception as e:
477
- err = ServiceUnavailableError()
478
- self._display_error(err)
479
- raise err from None
480
-
481
- def _update_all_category(self, update_method: Literal['full', 'incremental'] = 'full') -> None:
471
+ def UpdateAllCategory(self, update_method: Literal['full', 'incremental'] = 'full') -> None:
472
+ """更新所有已授权的数据类别。"""
473
+ try:
474
+ return self._update_all_category(update_method)
475
+ except LiteQuantError as e:
476
+ err = self._public_error_clone(e)
477
+ self._display_error(err)
478
+ raise err from None
479
+ except Exception as e:
480
+ err = ServiceUnavailableError()
481
+ self._display_error(err)
482
+ raise err from None
483
+
484
+ def _update_all_category(self, update_method: Literal['full', 'incremental'] = 'full') -> None:
482
485
  """
483
486
  更新所有授权的数据类别
484
487
 
485
- Args:
486
- update_method: 更新方式
487
- - 'full' - 全量更新,扫描所有数据(首次使用或需要完整数据时)
488
- - 'incremental' - 增量更新,只扫描最近两个月的数据(日常使用)
489
- """
490
- if update_method not in ('full', 'incremental'):
491
- raise ValueError("update_method must be 'full' or 'incremental'")
492
-
493
- self.logger.info(f"开始更新数据 [方式: {update_method}]")
494
-
495
- # 计算增量更新的日期范围(最近两个月)
496
- incremental_start_date = None
488
+ Args:
489
+ update_method: 更新方式
490
+ - 'full' - 全量更新,扫描所有数据(首次使用或需要完整数据时)
491
+ - 'incremental' - 增量更新,只扫描最近两个月的数据(日常使用)
492
+ """
493
+ if update_method not in ('full', 'incremental'):
494
+ raise ValueError("update_method must be 'full' or 'incremental'")
495
+
496
+ self.logger.info(f"开始更新数据 [方式: {update_method}]")
497
+
498
+ # 计算增量更新的日期范围(最近两个月)
499
+ incremental_start_date = None
497
500
  if update_method == 'incremental':
498
501
  incremental_start_date = datetime.now() - timedelta(days=60)
499
502
  self.logger.info(f"增量更新起始日期: {incremental_start_date.strftime('%Y-%m-%d')}")
@@ -508,48 +511,48 @@ class LiteQuantClient:
508
511
  self.logger.warning("没有可更新的数据类别")
509
512
  return
510
513
 
511
- self.logger.info(f"发现 {len(self._remote_categories)} 个类别")
512
-
513
- # 同步所有类别
514
- failures = []
515
- for category in self._remote_categories:
516
- try:
517
- self.logger.info(f"正在同步: {category}")
518
- self._sync_category(category, incremental_start_date=incremental_start_date)
519
- except Exception as e:
520
- err = self._as_litequant_error(e)
521
- failures.append((category, err))
522
- self.logger.error(f"{category}: {err.user_message}")
523
-
524
- if failures:
525
- raise SyncError(user_message=f"部分数据更新失败:{len(failures)} 个数据类别未完成")
526
-
527
- self.logger.info("数据更新完成")
528
-
529
- def GetCategory(
530
- self,
531
- category: str,
532
- start_date: Optional[str] = None,
533
- end_date: Optional[str] = None,
534
- ) -> pd.DataFrame:
535
- """读取指定数据类别。"""
536
- try:
537
- return self._get_category(category, start_date, end_date)
538
- except LiteQuantError as e:
539
- err = self._public_error_clone(e)
540
- self._display_error(err)
541
- raise err from None
542
- except Exception as e:
543
- err = ServiceUnavailableError()
544
- self._display_error(err)
545
- raise err from None
546
-
547
- def _get_category(
548
- self,
549
- category: str,
550
- start_date: Optional[str] = None,
551
- end_date: Optional[str] = None,
552
- ) -> pd.DataFrame:
514
+ self.logger.info(f"发现 {len(self._remote_categories)} 个类别")
515
+
516
+ # 同步所有类别
517
+ failures = []
518
+ for category in self._remote_categories:
519
+ try:
520
+ self.logger.info(f"正在同步: {category}")
521
+ self._sync_category(category, incremental_start_date=incremental_start_date)
522
+ except Exception as e:
523
+ err = self._as_litequant_error(e)
524
+ failures.append((category, err))
525
+ self.logger.error(f"{category}: {err.user_message}")
526
+
527
+ if failures:
528
+ raise SyncError(user_message=f"部分数据更新失败:{len(failures)} 个数据类别未完成")
529
+
530
+ self.logger.info("数据更新完成")
531
+
532
+ def GetCategory(
533
+ self,
534
+ category: str,
535
+ start_date: Optional[str] = None,
536
+ end_date: Optional[str] = None,
537
+ ) -> pd.DataFrame:
538
+ """读取指定数据类别。"""
539
+ try:
540
+ return self._get_category(category, start_date, end_date)
541
+ except LiteQuantError as e:
542
+ err = self._public_error_clone(e)
543
+ self._display_error(err)
544
+ raise err from None
545
+ except Exception as e:
546
+ err = ServiceUnavailableError()
547
+ self._display_error(err)
548
+ raise err from None
549
+
550
+ def _get_category(
551
+ self,
552
+ category: str,
553
+ start_date: Optional[str] = None,
554
+ end_date: Optional[str] = None,
555
+ ) -> pd.DataFrame:
553
556
  """
554
557
  读取数据类别
555
558
 
@@ -586,24 +589,24 @@ class LiteQuantClient:
586
589
  else:
587
590
  return self.LQ_db.read_unstack_category(category)
588
591
 
589
- def ListCategories(self) -> List[str]:
590
- """列出所有可用的数据类别(远程)"""
591
- try:
592
- return self._list_categories()
593
- except LiteQuantError as e:
594
- err = self._public_error_clone(e)
595
- self._display_error(err)
596
- raise err from None
597
- except Exception as e:
598
- err = ServiceUnavailableError()
599
- self._display_error(err)
600
- raise err from None
601
-
602
- def _list_categories(self) -> List[str]:
603
- """列出所有可用的数据类别(远程)"""
604
- self._ensure_connection()
605
- self._refresh_categories()
606
- return list(self._remote_categories)
592
+ def ListCategories(self) -> List[str]:
593
+ """列出所有可用的数据类别(远程)"""
594
+ try:
595
+ return self._list_categories()
596
+ except LiteQuantError as e:
597
+ err = self._public_error_clone(e)
598
+ self._display_error(err)
599
+ raise err from None
600
+ except Exception as e:
601
+ err = ServiceUnavailableError()
602
+ self._display_error(err)
603
+ raise err from None
604
+
605
+ def _list_categories(self) -> List[str]:
606
+ """列出所有可用的数据类别(远程)"""
607
+ self._ensure_connection()
608
+ self._refresh_categories()
609
+ return list(self._remote_categories)
607
610
 
608
611
  def ListLocalCategories(self) -> List[str]:
609
612
  """列出本地已存储的数据类别"""
@@ -650,13 +653,20 @@ class LiteQuantClient:
650
653
  all_categories = [str(x) for x in categories]
651
654
 
652
655
  # 过滤:只保留授权数据集的类别
653
- if self._ticket and self._ticket.datasets:
656
+ if self._ticket and self._ticket.category_patterns:
657
+ authorized_patterns = [str(x) for x in self._ticket.category_patterns]
658
+ filtered_categories = [
659
+ cat for cat in all_categories
660
+ if any(fnmatch.fnmatchcase(cat, pattern) for pattern in authorized_patterns)
661
+ ]
662
+ self.logger.debug("??????: %s -> %s", len(all_categories), len(filtered_categories))
663
+ elif self._ticket and self._ticket.datasets:
654
664
  authorized_datasets = set(self._ticket.datasets)
655
665
  filtered_categories = [
656
666
  cat for cat in all_categories
657
667
  if any(cat.startswith(f"{ds}#") for ds in authorized_datasets)
658
668
  ]
659
- self.logger.debug("过滤类别: %s -> %s", len(all_categories), len(filtered_categories))
669
+ self.logger.debug("??????: %s -> %s", len(all_categories), len(filtered_categories))
660
670
  else:
661
671
  filtered_categories = all_categories
662
672
 
@@ -673,17 +683,17 @@ class LiteQuantClient:
673
683
  else:
674
684
  self._sync_unstack_category(category)
675
685
 
676
- def _sync_pivot_category(self, category: str, incremental_start_date: datetime = None):
677
- """同步 Pivot 类别"""
678
- if isinstance(incremental_start_date, str):
679
- incremental_start_date = datetime.strptime(incremental_start_date, "%Y-%m-%d")
680
-
681
- meta = self._get_category_meta(category)
682
- self.LQ_db.set_category_meta(category, meta, merge=True)
683
- partitions = meta.get("partition_keys", [])
684
- partition_mode = meta.get("partition_mode", "monthly")
685
- if partition_mode not in ("daily", "monthly"):
686
- raise ProtocolError()
686
+ def _sync_pivot_category(self, category: str, incremental_start_date: datetime = None):
687
+ """同步 Pivot 类别"""
688
+ if isinstance(incremental_start_date, str):
689
+ incremental_start_date = datetime.strptime(incremental_start_date, "%Y-%m-%d")
690
+
691
+ meta = self._get_category_meta(category)
692
+ self.LQ_db.set_category_meta(category, meta, merge=True)
693
+ partitions = meta.get("partition_keys", [])
694
+ partition_mode = meta.get("partition_mode", "monthly")
695
+ if partition_mode not in ("daily", "monthly"):
696
+ raise ProtocolError()
687
697
 
688
698
  if not partitions:
689
699
  self.logger.info(f"{category}: 没有分区")
@@ -712,53 +722,53 @@ class LiteQuantClient:
712
722
  if remote_hash:
713
723
  self._write_local_hash(category, partition, remote_hash)
714
724
 
715
- def _sync_unstack_category(self, category: str):
716
- """同步 Unstack 类别(unstack类型通常不分区,直接全量同步)"""
717
- partition = "full"
718
- category_meta = self._get_category_meta(category)
719
- self.LQ_db.set_category_meta(category, category_meta, merge=True)
720
- partition_meta = self._get_partition_meta(category, partition)
721
- remote_hash = partition_meta.get("hash")
722
- duplicate_keys = (
723
- partition_meta.get("duplicate_keys")
724
- or category_meta.get("duplicate_keys")
725
- or partition_meta.get("key_columns")
726
- or partition_meta.get("dedupe_keys")
727
- or category_meta.get("key_columns")
728
- or category_meta.get("dedupe_keys")
729
- )
730
-
731
- df = self._download_partition(category, partition, partition_meta)
732
- if not isinstance(duplicate_keys, (list, tuple, str)):
733
- duplicate_keys = None
734
- self.LQ_db.update_unstack_category(category=category, sub_df=df, duplicate_keys=duplicate_keys)
725
+ def _sync_unstack_category(self, category: str):
726
+ """同步 Unstack 类别(unstack类型通常不分区,直接全量同步)"""
727
+ partition = "full"
728
+ category_meta = self._get_category_meta(category)
729
+ self.LQ_db.set_category_meta(category, category_meta, merge=True)
730
+ partition_meta = self._get_partition_meta(category, partition)
731
+ remote_hash = partition_meta.get("hash")
732
+ duplicate_keys = (
733
+ partition_meta.get("duplicate_keys")
734
+ or category_meta.get("duplicate_keys")
735
+ or partition_meta.get("key_columns")
736
+ or partition_meta.get("dedupe_keys")
737
+ or category_meta.get("key_columns")
738
+ or category_meta.get("dedupe_keys")
739
+ )
740
+
741
+ df = self._download_partition(category, partition, partition_meta)
742
+ if not isinstance(duplicate_keys, (list, tuple, str)):
743
+ duplicate_keys = None
744
+ self.LQ_db.update_unstack_category(category=category, sub_df=df, duplicate_keys=duplicate_keys)
735
745
 
736
746
  if remote_hash:
737
747
  self._write_local_hash(category, partition, remote_hash)
738
748
 
739
749
  def _download_partition(self, category: str, partition: str, partition_meta: dict) -> pd.DataFrame:
740
750
  """下载分区数据"""
741
- data_key = partition_meta.get("data_key")
742
- if not data_key:
743
- raise MetaError()
751
+ data_key = partition_meta.get("data_key")
752
+ if not data_key:
753
+ raise MetaError()
744
754
 
745
755
  # 获取数据(带重试)
746
- payload = self._redis_get_with_retry(data_key)
747
- if payload is None:
748
- raise RemoteDataError()
756
+ payload = self._redis_get_with_retry(data_key)
757
+ if payload is None:
758
+ raise RemoteDataError()
749
759
 
750
760
  # 校验 hash
751
761
  remote_hash = partition_meta.get("hash")
752
762
  if remote_hash:
753
- local_hash = "sha256:" + hashlib.sha256(payload).hexdigest()
754
- if local_hash != remote_hash:
755
- raise SerializationError()
763
+ local_hash = "sha256:" + hashlib.sha256(payload).hexdigest()
764
+ if local_hash != remote_hash:
765
+ raise SerializationError()
756
766
 
757
767
  # 解析 Parquet
758
- try:
759
- return pd.read_parquet(io.BytesIO(payload))
760
- except Exception:
761
- raise SerializationError() from None
768
+ try:
769
+ return pd.read_parquet(io.BytesIO(payload))
770
+ except Exception:
771
+ raise SerializationError() from None
762
772
 
763
773
  def _redis_get_with_retry(self, key: str, max_retries: int = 2) -> bytes:
764
774
  """带重试的 Redis GET"""
@@ -766,38 +776,38 @@ class LiteQuantClient:
766
776
  try:
767
777
  self._ensure_connection()
768
778
  return self._redis_client.get(key)
769
- except redis.AuthenticationError:
770
- if attempt < max_retries:
771
- self._fetch_ticket()
772
- self._connect_redis()
773
- else:
774
- raise DataConnectionError()
775
- except Exception as e:
776
- if isinstance(e, LiteQuantError):
777
- raise self._public_error_clone(e) from None
778
- if attempt < max_retries:
779
- time.sleep(0.5)
780
- else:
781
- raise RemoteDataError() from None
779
+ except redis.AuthenticationError:
780
+ if attempt < max_retries:
781
+ self._fetch_ticket()
782
+ self._connect_redis()
783
+ else:
784
+ raise DataConnectionError()
785
+ except Exception as e:
786
+ if isinstance(e, LiteQuantError):
787
+ raise self._public_error_clone(e) from None
788
+ if attempt < max_retries:
789
+ time.sleep(0.5)
790
+ else:
791
+ raise RemoteDataError() from None
782
792
 
783
793
  def _json_get(self, key: str):
784
794
  """获取 JSON 数据"""
785
795
  data = self._redis_get_with_retry(key)
786
796
  if data is None:
787
797
  return None
788
- try:
789
- if isinstance(data, bytes):
790
- data = data.decode("utf-8")
791
- return json.loads(data)
792
- except Exception:
793
- raise SerializationError() from None
798
+ try:
799
+ if isinstance(data, bytes):
800
+ data = data.decode("utf-8")
801
+ return json.loads(data)
802
+ except Exception:
803
+ raise SerializationError() from None
794
804
 
795
805
  def _json_get_required(self, key: str):
796
806
  """获取必需的 JSON 数据"""
797
- obj = self._json_get(key)
798
- if obj is None:
799
- raise MetaError()
800
- return obj
807
+ obj = self._json_get(key)
808
+ if obj is None:
809
+ raise MetaError()
810
+ return obj
801
811
 
802
812
  def _get_category_meta(self, category: str) -> dict:
803
813
  """获取类别元数据"""
@@ -814,19 +824,19 @@ class LiteQuantClient:
814
824
  return meta
815
825
 
816
826
  def _validate_protocol(self, meta: dict):
817
- """验证协议版本"""
818
- if meta.get("protocol_version") != self.PROTOCOL_VERSION:
819
- raise ProtocolError()
820
- if meta.get("data_format") != self.DATA_FORMAT:
821
- raise ProtocolError()
827
+ """验证协议版本"""
828
+ if meta.get("protocol_version") != self.PROTOCOL_VERSION:
829
+ raise ProtocolError()
830
+ if meta.get("data_format") != self.DATA_FORMAT:
831
+ raise ProtocolError()
822
832
 
823
833
  def _validate_category_name(self, category: str) -> str:
824
834
  """验证类别名称"""
825
- if "pivot#" in category:
826
- return "pivot"
827
- if "unstack#" in category:
828
- return "unstack"
829
- raise InvalidCategoryError()
835
+ if "pivot#" in category:
836
+ return "pivot"
837
+ if "unstack#" in category:
838
+ return "unstack"
839
+ raise InvalidCategoryError()
830
840
 
831
841
  def _get_hash_path(self, category: str, partition: str) -> str:
832
842
  """获取 hash 文件路径"""
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: litequant
3
- Version: 3.0.0
3
+ Version: 3.0.2
4
4
  Summary: LiteQuant Python client SDK
5
5
  Author: LiteQuant Team
6
6
  License: MIT
7
- Project-URL: Homepage, https://litequant.com
8
- Project-URL: Documentation, https://litequant.com/docs
9
- Project-URL: Issues, https://litequant.com/support
7
+ Project-URL: Homepage, https://litequant.pro
8
+ Project-URL: Documentation, https://litequant.pro/docs
9
+ Project-URL: Issues, https://litequant.pro/support
10
10
  Keywords: quant,finance,data,parquet,pandas
11
11
  Classifier: Development Status :: 4 - Beta
12
12
  Classifier: Intended Audience :: Developers
@@ -26,7 +26,7 @@ from .exceptions import (
26
26
  APIError,
27
27
  )
28
28
 
29
- __version__ = "3.0.0"
29
+ __version__ = "3.0.2"
30
30
  __all__ = [
31
31
  # Client
32
32
  "LiteQuantClient",
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "litequant"
7
- version = "3.0.0"
7
+ version = "3.0.2"
8
8
  description = "LiteQuant Python client SDK"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -51,9 +51,9 @@ dev = [
51
51
  ]
52
52
 
53
53
  [project.urls]
54
- Homepage = "https://litequant.com"
55
- Documentation = "https://litequant.com/docs"
56
- Issues = "https://litequant.com/support"
54
+ Homepage = "https://litequant.pro"
55
+ Documentation = "https://litequant.pro/docs"
56
+ Issues = "https://litequant.pro/support"
57
57
 
58
58
  [tool.setuptools]
59
59
  packages = ["litequant"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes