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.
- {litequant-3.0.0 → litequant-3.0.2}/LiteQuantClient.py +475 -465
- {litequant-3.0.0 → litequant-3.0.2}/PKG-INFO +4 -4
- {litequant-3.0.0 → litequant-3.0.2}/__init__.py +1 -1
- {litequant-3.0.0 → litequant-3.0.2}/pyproject.toml +4 -4
- {litequant-3.0.0 → litequant-3.0.2}/LICENSE +0 -0
- {litequant-3.0.0 → litequant-3.0.2}/MANIFEST.in +0 -0
- {litequant-3.0.0 → litequant-3.0.2}/ParquetManager.py +0 -0
- {litequant-3.0.0 → litequant-3.0.2}/README.md +0 -0
- {litequant-3.0.0 → litequant-3.0.2}/exceptions.py +0 -0
- {litequant-3.0.0 → litequant-3.0.2}/litequant.egg-info/SOURCES.txt +0 -0
- {litequant-3.0.0 → litequant-3.0.2}/log.py +0 -0
- {litequant-3.0.0 → litequant-3.0.2}/setup.cfg +0 -0
|
@@ -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
|
-
|
|
79
|
-
|
|
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.
|
|
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
|
-
|
|
334
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
except
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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.
|
|
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("
|
|
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.
|
|
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.
|
|
8
|
-
Project-URL: Documentation, https://litequant.
|
|
9
|
-
Project-URL: Issues, https://litequant.
|
|
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
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "litequant"
|
|
7
|
-
version = "3.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.
|
|
55
|
-
Documentation = "https://litequant.
|
|
56
|
-
Issues = "https://litequant.
|
|
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
|
|
File without changes
|