sharetop 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.
- sharetop/__init__.py +38 -0
- sharetop/__version__.py +1 -0
- sharetop/_base_client.py +330 -0
- sharetop/_batch.py +177 -0
- sharetop/_cache.py +135 -0
- sharetop/_exceptions.py +142 -0
- sharetop/_types.py +58 -0
- sharetop/aio_utils/__init__.py +1 -0
- sharetop/aio_utils/aio_quotes.py +248 -0
- sharetop/client.py +126 -0
- sharetop/generated_model.py +88 -0
- sharetop/py.typed +0 -0
- sharetop/resources/__init__.py +15 -0
- sharetop/resources/_base.py +19 -0
- sharetop/resources/financials.py +641 -0
- sharetop/resources/klines.py +362 -0
- sharetop/resources/limit_up_resources/__init__.py +94 -0
- sharetop/resources/limit_up_resources/_common.py +201 -0
- sharetop/resources/limit_up_resources/dragon_tiger_data.py +159 -0
- sharetop/resources/limit_up_resources/limit_up_down_pool.py +226 -0
- sharetop/resources/limit_up_resources/market_situation.py +110 -0
- sharetop/resources/limit_up_resources/sector_quotes_data.py +177 -0
- sharetop/resources/market.py +304 -0
- sharetop/resources/quotes.py +268 -0
- sharetop/utils.py +191 -0
- sharetop-0.1.0.dist-info/METADATA +287 -0
- sharetop-0.1.0.dist-info/RECORD +30 -0
- sharetop-0.1.0.dist-info/WHEEL +5 -0
- sharetop-0.1.0.dist-info/licenses/LICENSE +21 -0
- sharetop-0.1.0.dist-info/top_level.txt +1 -0
sharetop/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""ShareTop Python SDK - 高性能行情数据客户端。
|
|
2
|
+
|
|
3
|
+
支持 A股的基础数据和行情数据查询,提供同步和异步两种接口。
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .__version__ import __version__
|
|
8
|
+
from ._exceptions import (
|
|
9
|
+
APIError,
|
|
10
|
+
AuthenticationError,
|
|
11
|
+
BadRequestError,
|
|
12
|
+
ConnectionError,
|
|
13
|
+
InternalServerError,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
PermissionError,
|
|
16
|
+
RateLimitError,
|
|
17
|
+
ShareTopError,
|
|
18
|
+
TimeoutError,
|
|
19
|
+
)
|
|
20
|
+
from .client import ShareTop
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"__version__",
|
|
25
|
+
# Main clients
|
|
26
|
+
"ShareTop",
|
|
27
|
+
# Exceptions
|
|
28
|
+
"ShareTopError",
|
|
29
|
+
"APIError",
|
|
30
|
+
"AuthenticationError",
|
|
31
|
+
"PermissionError",
|
|
32
|
+
"NotFoundError",
|
|
33
|
+
"BadRequestError",
|
|
34
|
+
"RateLimitError",
|
|
35
|
+
"InternalServerError",
|
|
36
|
+
"ConnectionError",
|
|
37
|
+
"TimeoutError",
|
|
38
|
+
]
|
sharetop/__version__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
sharetop/_base_client.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Base HTTP client implementation with retry support for sync and async operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import random
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Optional, TypeVar, Union
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from ._exceptions import (
|
|
14
|
+
APIError,
|
|
15
|
+
ConnectionError,
|
|
16
|
+
InternalServerError,
|
|
17
|
+
RateLimitError,
|
|
18
|
+
TimeoutError,
|
|
19
|
+
raise_for_status,
|
|
20
|
+
)
|
|
21
|
+
from ._types import NOT_GIVEN, Headers, NotGiven, Query, Timeout
|
|
22
|
+
|
|
23
|
+
__all__ = ["SyncAPIClient"]
|
|
24
|
+
|
|
25
|
+
DEFAULT_BASE_URL = "https://api.sharetop.top/api"
|
|
26
|
+
DEFAULT_TIMEOUT = 30.0
|
|
27
|
+
DEFAULT_MAX_RETRIES = 3
|
|
28
|
+
|
|
29
|
+
T = TypeVar("T")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _should_retry(exception: Exception) -> bool:
|
|
33
|
+
"""Determine if an exception is retryable.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
exception : Exception
|
|
38
|
+
The exception to check.
|
|
39
|
+
|
|
40
|
+
Returns
|
|
41
|
+
-------
|
|
42
|
+
bool
|
|
43
|
+
True if the request should be retried.
|
|
44
|
+
"""
|
|
45
|
+
# Retry on connection errors and timeouts
|
|
46
|
+
if isinstance(exception, (ConnectionError, TimeoutError)):
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
# Retry on server errors (5xx) and rate limits (429)
|
|
50
|
+
if isinstance(exception, (InternalServerError, RateLimitError)):
|
|
51
|
+
return True
|
|
52
|
+
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _calculate_retry_delay(
|
|
57
|
+
attempt: int, base_delay: float = 1.0, max_delay: float = 30.0
|
|
58
|
+
) -> float:
|
|
59
|
+
"""Calculate exponential backoff delay with jitter.
|
|
60
|
+
|
|
61
|
+
Parameters
|
|
62
|
+
----------
|
|
63
|
+
attempt : int
|
|
64
|
+
Current attempt number (0-indexed).
|
|
65
|
+
base_delay : float
|
|
66
|
+
Base delay in seconds.
|
|
67
|
+
max_delay : float
|
|
68
|
+
Maximum delay in seconds.
|
|
69
|
+
|
|
70
|
+
Returns
|
|
71
|
+
-------
|
|
72
|
+
float
|
|
73
|
+
Delay in seconds.
|
|
74
|
+
"""
|
|
75
|
+
# Exponential backoff: 1s, 2s, 4s, 8s, ...
|
|
76
|
+
delay = base_delay * (2**attempt)
|
|
77
|
+
# Add jitter (±25%)
|
|
78
|
+
jitter = delay * 0.25 * (2 * random.random() - 1)
|
|
79
|
+
delay = delay + jitter
|
|
80
|
+
# Cap at max delay
|
|
81
|
+
return min(delay, max_delay)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class BaseClient:
|
|
85
|
+
"""Base class with shared configuration for API clients."""
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
api_key: Optional[str] = None,
|
|
90
|
+
base_url: Optional[str] = None,
|
|
91
|
+
timeout: Timeout = DEFAULT_TIMEOUT,
|
|
92
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
93
|
+
default_headers: Optional[Headers] = None,
|
|
94
|
+
) -> None:
|
|
95
|
+
# Allow None for free tier mode
|
|
96
|
+
if api_key is None or api_key == "":
|
|
97
|
+
self.api_key = os.environ.get("SHARETOP_API_KEY")
|
|
98
|
+
else:
|
|
99
|
+
self.api_key = api_key
|
|
100
|
+
|
|
101
|
+
# Only raise error if we're trying to use the default paid URL without a key
|
|
102
|
+
if not self.api_key:
|
|
103
|
+
effective_base_url = base_url or os.environ.get("SHARETOP_BASE_URL")
|
|
104
|
+
if effective_base_url is None or effective_base_url == DEFAULT_BASE_URL:
|
|
105
|
+
raise ValueError(
|
|
106
|
+
"Token is required for paid API. "
|
|
107
|
+
"无效Token,请关注公众号:“浔溯de小仓鼠”进行获取"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if self.api_key and len(self.api_key) != 50:
|
|
111
|
+
raise ValueError(
|
|
112
|
+
"Invalid API key."
|
|
113
|
+
"无效Token,请关注公众号:“浔溯de小仓鼠”进行获取"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
self.base_url = (
|
|
117
|
+
base_url or os.environ.get("SHARETOP_BASE_URL") or DEFAULT_BASE_URL
|
|
118
|
+
).rstrip("/")
|
|
119
|
+
self.timeout = timeout
|
|
120
|
+
self.max_retries = max_retries
|
|
121
|
+
self._default_headers = dict(default_headers) if default_headers else {}
|
|
122
|
+
|
|
123
|
+
def _build_headers(self, extra_headers: Optional[Headers] = None) -> dict[str, str]:
|
|
124
|
+
"""Build request headers with authentication."""
|
|
125
|
+
headers = {
|
|
126
|
+
"Content-Type": "application/json",
|
|
127
|
+
"Accept": "application/json",
|
|
128
|
+
"User-Agent": f"sharetop-python-sdk/{__version__}",
|
|
129
|
+
**self._default_headers,
|
|
130
|
+
}
|
|
131
|
+
# Only add API key if present (free tier doesn't need it)
|
|
132
|
+
if self.api_key:
|
|
133
|
+
headers["Authorization"] = ' '.join(["Bearer", self.api_key])
|
|
134
|
+
if extra_headers:
|
|
135
|
+
headers.update(extra_headers)
|
|
136
|
+
# print("headers:", headers)
|
|
137
|
+
return headers
|
|
138
|
+
|
|
139
|
+
def _build_url(self, path: str) -> str:
|
|
140
|
+
"""Build full URL from path."""
|
|
141
|
+
return f"{self.base_url}{path}"
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def default_headers(self):
|
|
145
|
+
return self._default_headers
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class SyncAPIClient(BaseClient):
|
|
149
|
+
"""Synchronous HTTP client for ShareTop API with automatic retry.
|
|
150
|
+
|
|
151
|
+
Parameters
|
|
152
|
+
----------
|
|
153
|
+
api_key : str, optional
|
|
154
|
+
API key for authentication. If not provided, reads from SHARETOP_API_KEY
|
|
155
|
+
environment variable.
|
|
156
|
+
base_url : str, optional
|
|
157
|
+
Base URL for the API. Defaults to https://api.sharetop.com.
|
|
158
|
+
timeout : float, optional
|
|
159
|
+
Request timeout in seconds. Defaults to 30.0.
|
|
160
|
+
max_retries : int, optional
|
|
161
|
+
Maximum number of retry attempts for failed requests. Defaults to 3.
|
|
162
|
+
Retries occur on connection errors, timeouts, server errors (5xx),
|
|
163
|
+
and rate limits (429).
|
|
164
|
+
default_headers : dict, optional
|
|
165
|
+
Default headers to include in all requests.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
def __init__(
|
|
169
|
+
self,
|
|
170
|
+
api_key: Optional[str] = None,
|
|
171
|
+
base_url: Optional[str] = None,
|
|
172
|
+
timeout: Timeout = DEFAULT_TIMEOUT,
|
|
173
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
174
|
+
default_headers: Optional[Headers] = None,
|
|
175
|
+
) -> None:
|
|
176
|
+
super().__init__(api_key, base_url, timeout, max_retries, default_headers)
|
|
177
|
+
self._client = httpx.Client(timeout=timeout, verify=False)
|
|
178
|
+
|
|
179
|
+
def __enter__(self) -> "SyncAPIClient":
|
|
180
|
+
return self
|
|
181
|
+
|
|
182
|
+
def __exit__(self, *args: Any) -> None:
|
|
183
|
+
self.close()
|
|
184
|
+
|
|
185
|
+
def close(self) -> None:
|
|
186
|
+
"""Close the underlying HTTP client."""
|
|
187
|
+
self._client.close()
|
|
188
|
+
|
|
189
|
+
def _request(
|
|
190
|
+
self,
|
|
191
|
+
method: str,
|
|
192
|
+
path: str,
|
|
193
|
+
*,
|
|
194
|
+
params: Optional[Query] = None,
|
|
195
|
+
json: Optional[dict[str, Any]] = None,
|
|
196
|
+
extra_headers: Optional[Headers] = None,
|
|
197
|
+
timeout: Union[Timeout, NotGiven] = NOT_GIVEN,
|
|
198
|
+
max_retries: Union[int, NotGiven] = NOT_GIVEN,
|
|
199
|
+
) -> Any:
|
|
200
|
+
"""Make an HTTP request with automatic retry on failures.
|
|
201
|
+
|
|
202
|
+
Parameters
|
|
203
|
+
----------
|
|
204
|
+
method : str
|
|
205
|
+
HTTP method (GET, POST, etc.).
|
|
206
|
+
path : str
|
|
207
|
+
API endpoint path.
|
|
208
|
+
params : dict, optional
|
|
209
|
+
Query parameters.
|
|
210
|
+
json : dict, optional
|
|
211
|
+
JSON request body.
|
|
212
|
+
extra_headers : dict, optional
|
|
213
|
+
Additional headers for this request.
|
|
214
|
+
timeout : float, optional
|
|
215
|
+
Override timeout for this request.
|
|
216
|
+
max_retries : int, optional
|
|
217
|
+
Override max retries for this request.
|
|
218
|
+
|
|
219
|
+
Returns
|
|
220
|
+
-------
|
|
221
|
+
Any
|
|
222
|
+
Parsed JSON response.
|
|
223
|
+
|
|
224
|
+
Raises
|
|
225
|
+
------
|
|
226
|
+
APIError
|
|
227
|
+
If the API returns an error response after all retries.
|
|
228
|
+
ConnectionError
|
|
229
|
+
If there's a network connection issue after all retries.
|
|
230
|
+
TimeoutError
|
|
231
|
+
If the request times out after all retries.
|
|
232
|
+
"""
|
|
233
|
+
url = self._build_url(path)
|
|
234
|
+
headers = self._build_headers(extra_headers)
|
|
235
|
+
request_timeout = timeout if not isinstance(timeout, NotGiven) else self.timeout
|
|
236
|
+
retries = (
|
|
237
|
+
max_retries if not isinstance(max_retries, NotGiven) else self.max_retries
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# Filter out None values from params
|
|
241
|
+
if params:
|
|
242
|
+
params = {k: v for k, v in params.items() if v is not None}
|
|
243
|
+
|
|
244
|
+
last_exception: Optional[Exception] = None
|
|
245
|
+
|
|
246
|
+
for attempt in range(retries + 1):
|
|
247
|
+
try:
|
|
248
|
+
response = self._client.request(
|
|
249
|
+
method,
|
|
250
|
+
url,
|
|
251
|
+
params=params,
|
|
252
|
+
json=json,
|
|
253
|
+
headers=headers,
|
|
254
|
+
timeout=request_timeout,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# Parse response
|
|
258
|
+
try:
|
|
259
|
+
# print("url==:", url)
|
|
260
|
+
# print("json===========:", json)
|
|
261
|
+
response_body = response.json()
|
|
262
|
+
# print("response_body==========:", response_body)
|
|
263
|
+
except Exception:
|
|
264
|
+
response_body = {"message": response.text, "code": "PARSE_ERROR"}
|
|
265
|
+
|
|
266
|
+
# Check for errors (may raise retryable exceptions)
|
|
267
|
+
# raise_for_status(response.status_code, response_body)
|
|
268
|
+
|
|
269
|
+
return response_body
|
|
270
|
+
|
|
271
|
+
except httpx.ConnectError as e:
|
|
272
|
+
last_exception = ConnectionError(f"Failed to connect to {url}: {e}")
|
|
273
|
+
except httpx.TimeoutException as e:
|
|
274
|
+
last_exception = TimeoutError(f"Request to {url} timed out")
|
|
275
|
+
except APIError as e:
|
|
276
|
+
last_exception = e
|
|
277
|
+
if not _should_retry(e):
|
|
278
|
+
raise
|
|
279
|
+
|
|
280
|
+
# Check if we should retry
|
|
281
|
+
if attempt < retries and _should_retry(last_exception):
|
|
282
|
+
delay = _calculate_retry_delay(attempt)
|
|
283
|
+
time.sleep(delay)
|
|
284
|
+
else:
|
|
285
|
+
break
|
|
286
|
+
|
|
287
|
+
# All retries exhausted
|
|
288
|
+
if last_exception:
|
|
289
|
+
raise last_exception
|
|
290
|
+
raise RuntimeError("Unexpected state: no exception but request failed")
|
|
291
|
+
|
|
292
|
+
def get(
|
|
293
|
+
self,
|
|
294
|
+
path: str,
|
|
295
|
+
*,
|
|
296
|
+
params: Optional[Query] = None,
|
|
297
|
+
extra_headers: Optional[Headers] = None,
|
|
298
|
+
timeout: Union[Timeout, NotGiven] = NOT_GIVEN,
|
|
299
|
+
max_retries: Union[int, NotGiven] = NOT_GIVEN,
|
|
300
|
+
) -> Any:
|
|
301
|
+
"""Make a GET request with automatic retry."""
|
|
302
|
+
return self._request(
|
|
303
|
+
"GET",
|
|
304
|
+
path,
|
|
305
|
+
params=params,
|
|
306
|
+
extra_headers=extra_headers,
|
|
307
|
+
timeout=timeout,
|
|
308
|
+
max_retries=max_retries,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
def post(
|
|
312
|
+
self,
|
|
313
|
+
path: str,
|
|
314
|
+
*,
|
|
315
|
+
json: Optional[dict[str, Any]] = None,
|
|
316
|
+
params: Optional[Query] = None,
|
|
317
|
+
extra_headers: Optional[Headers] = None,
|
|
318
|
+
timeout: Union[Timeout, NotGiven] = NOT_GIVEN,
|
|
319
|
+
max_retries: Union[int, NotGiven] = NOT_GIVEN,
|
|
320
|
+
) -> Any:
|
|
321
|
+
"""Make a POST request with automatic retry."""
|
|
322
|
+
return self._request(
|
|
323
|
+
"POST",
|
|
324
|
+
path,
|
|
325
|
+
json=json,
|
|
326
|
+
params=params,
|
|
327
|
+
extra_headers=extra_headers,
|
|
328
|
+
timeout=timeout,
|
|
329
|
+
max_retries=max_retries,
|
|
330
|
+
)
|
sharetop/_batch.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Generic batched GET utilities for splitting large symbol lists across requests.
|
|
2
|
+
|
|
3
|
+
Handles URL length limits by automatically chunking the symbol list and
|
|
4
|
+
merging ``response["data"]`` dicts from each chunk. Both synchronous
|
|
5
|
+
(ThreadPoolExecutor) and asynchronous (asyncio.Semaphore) variants are
|
|
6
|
+
provided so that any resource can reuse the same logic.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import concurrent.futures
|
|
12
|
+
from typing import Any, Callable, Dict, List, Optional, Union
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
from ._base_client import SyncAPIClient
|
|
16
|
+
|
|
17
|
+
DEFAULT_BATCH_SIZE = 100
|
|
18
|
+
DEFAULT_MAX_WORKERS = 5
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _chunk_list(lst: List[str], chunk_size: int) -> List[List[str]]:
|
|
22
|
+
return [lst[i : i + chunk_size] for i in range(0, len(lst), chunk_size)]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_progress_bar(total: int, desc: str, show: bool):
|
|
26
|
+
if show:
|
|
27
|
+
try:
|
|
28
|
+
from tqdm.auto import tqdm
|
|
29
|
+
|
|
30
|
+
return tqdm(total=total, desc=desc, leave=False)
|
|
31
|
+
except ImportError:
|
|
32
|
+
pass
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _default_merge(
|
|
37
|
+
acc: Dict[str, Any], chunk: Union[Dict[str, Any], List], symbols: List[str] = None
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Default merge: if chunk is a dict, update directly; if it's a list, group by ts_code."""
|
|
40
|
+
if isinstance(chunk, dict):
|
|
41
|
+
acc.update(chunk)
|
|
42
|
+
elif isinstance(chunk, list) and symbols:
|
|
43
|
+
# Group list items by ts_code
|
|
44
|
+
for item in chunk:
|
|
45
|
+
ts_code = item.get("ts_code", "")
|
|
46
|
+
if ts_code:
|
|
47
|
+
# Match with provided symbols
|
|
48
|
+
for sym in symbols:
|
|
49
|
+
# Match by code prefix (e.g., "603435" matches "603435.SH")
|
|
50
|
+
code = ts_code.split(".")[0]
|
|
51
|
+
sym_code = sym.split(".")[0]
|
|
52
|
+
if code == sym_code:
|
|
53
|
+
acc.setdefault(sym, []).append(item)
|
|
54
|
+
break
|
|
55
|
+
else:
|
|
56
|
+
# No match, put in empty key
|
|
57
|
+
acc.setdefault("", []).append(item)
|
|
58
|
+
else:
|
|
59
|
+
acc.setdefault("", []).append(item)
|
|
60
|
+
elif isinstance(chunk, list):
|
|
61
|
+
acc.setdefault("", []).extend(chunk)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def batched_get_sync(
|
|
65
|
+
client: "SyncAPIClient",
|
|
66
|
+
endpoint: str,
|
|
67
|
+
symbols: List[str],
|
|
68
|
+
params: Dict[str, Any],
|
|
69
|
+
*,
|
|
70
|
+
symbols_param: str = "symbols",
|
|
71
|
+
batch_size: int = DEFAULT_BATCH_SIZE,
|
|
72
|
+
max_workers: int = DEFAULT_MAX_WORKERS,
|
|
73
|
+
show_progress: bool = False,
|
|
74
|
+
progress_desc: str = "Fetching data",
|
|
75
|
+
merge: Optional[
|
|
76
|
+
Callable[[Dict[str, Any], Union[Dict[str, Any], List], List[str]], None]
|
|
77
|
+
] = None,
|
|
78
|
+
) -> Dict[str, Any]:
|
|
79
|
+
"""Fetch *endpoint* in chunks, merging responses.
|
|
80
|
+
|
|
81
|
+
Parameters
|
|
82
|
+
----------
|
|
83
|
+
merge : callable, optional
|
|
84
|
+
``merge(accumulated, chunk_data, symbols)`` – custom merge strategy.
|
|
85
|
+
Defaults to grouping list responses by ts_code.
|
|
86
|
+
"""
|
|
87
|
+
# If no symbols provided, fetch all data without symbols_param
|
|
88
|
+
if not symbols:
|
|
89
|
+
return client.post(endpoint, json=params)["data"]
|
|
90
|
+
|
|
91
|
+
chunks = _chunk_list(symbols, batch_size)
|
|
92
|
+
_merge = merge or _default_merge
|
|
93
|
+
|
|
94
|
+
if len(chunks) == 1:
|
|
95
|
+
chunk_params = {**params, symbols_param: ",".join(chunks[0])}
|
|
96
|
+
data = client.post(endpoint, json=chunk_params)["data"]
|
|
97
|
+
# Use merge for single chunk too, to convert list to dict
|
|
98
|
+
result: Dict[str, Any] = {}
|
|
99
|
+
_merge(result, data, symbols)
|
|
100
|
+
return result
|
|
101
|
+
|
|
102
|
+
pbar = _get_progress_bar(len(chunks), progress_desc, show_progress)
|
|
103
|
+
all_data: Dict[str, Any] = {}
|
|
104
|
+
|
|
105
|
+
def _fetch(chunk: List[str]) -> Any:
|
|
106
|
+
chunk_params = {**params, symbols_param: ",".join(chunk)}
|
|
107
|
+
return client.post(endpoint, json=chunk_params)["data"]
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as pool:
|
|
111
|
+
futures = {pool.submit(_fetch, c): c for c in chunks}
|
|
112
|
+
for future in concurrent.futures.as_completed(futures):
|
|
113
|
+
_merge(all_data, future.result(), symbols)
|
|
114
|
+
if pbar:
|
|
115
|
+
pbar.update(1)
|
|
116
|
+
finally:
|
|
117
|
+
if pbar:
|
|
118
|
+
pbar.close()
|
|
119
|
+
|
|
120
|
+
return all_data
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# async def batched_get_async(
|
|
124
|
+
# client: "AsyncAPIClient",
|
|
125
|
+
# endpoint: str,
|
|
126
|
+
# symbols: List[str],
|
|
127
|
+
# params: Dict[str, Any],
|
|
128
|
+
# *,
|
|
129
|
+
# symbols_param: str = "symbols",
|
|
130
|
+
# batch_size: int = DEFAULT_BATCH_SIZE,
|
|
131
|
+
# max_concurrency: int = DEFAULT_MAX_WORKERS,
|
|
132
|
+
# show_progress: bool = False,
|
|
133
|
+
# progress_desc: str = "Fetching data",
|
|
134
|
+
# merge: Optional[
|
|
135
|
+
# Callable[[Dict[str, Any], Union[Dict[str, Any], List], List[str]], None]
|
|
136
|
+
# ] = None,
|
|
137
|
+
# ) -> Dict[str, Any]:
|
|
138
|
+
# """Async variant of :func:`batched_get_sync`."""
|
|
139
|
+
# # If no symbols provided, fetch all data without symbols_param
|
|
140
|
+
# if not symbols:
|
|
141
|
+
# return (await client.post(endpoint, json=params))["data"]
|
|
142
|
+
#
|
|
143
|
+
# chunks = _chunk_list(symbols, batch_size)
|
|
144
|
+
# _merge = merge or _default_merge
|
|
145
|
+
#
|
|
146
|
+
# if len(chunks) == 1:
|
|
147
|
+
# chunk_params = {**params, symbols_param: ",".join(chunks[0])}
|
|
148
|
+
# data = (await client.get(endpoint, params=chunk_params))["data"]
|
|
149
|
+
# result: Dict[str, Any] = {}
|
|
150
|
+
# _merge(result, data, symbols)
|
|
151
|
+
# return result
|
|
152
|
+
#
|
|
153
|
+
# pbar = _get_progress_bar(len(chunks), progress_desc, show_progress)
|
|
154
|
+
# sem = asyncio.Semaphore(max_concurrency)
|
|
155
|
+
# all_data: Dict[str, Any] = {}
|
|
156
|
+
#
|
|
157
|
+
# async def _fetch(chunk: List[str]) -> Any:
|
|
158
|
+
# async with sem:
|
|
159
|
+
# chunk_params = {**params, symbols_param: ",".join(chunk)}
|
|
160
|
+
# resp = await client.get(endpoint, params=chunk_params)
|
|
161
|
+
# if pbar:
|
|
162
|
+
# pbar.update(1)
|
|
163
|
+
# return resp["data"]
|
|
164
|
+
#
|
|
165
|
+
# try:
|
|
166
|
+
# results = await asyncio.gather(
|
|
167
|
+
# *[_fetch(c) for c in chunks], return_exceptions=True
|
|
168
|
+
# )
|
|
169
|
+
# for r in results:
|
|
170
|
+
# if isinstance(r, Exception):
|
|
171
|
+
# raise r
|
|
172
|
+
# _merge(all_data, r, symbols)
|
|
173
|
+
# finally:
|
|
174
|
+
# if pbar:
|
|
175
|
+
# pbar.close()
|
|
176
|
+
#
|
|
177
|
+
# return all_data
|
sharetop/_cache.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Local instrument name cache for ShareTop.
|
|
2
|
+
|
|
3
|
+
Caches symbol -> name mappings to avoid frequent API calls.
|
|
4
|
+
Cache directory is configurable via SHARETOP_CACHE_DIR environment variable.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
from ._base_client import SyncAPIClient
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("sharetop.cache")
|
|
20
|
+
|
|
21
|
+
DEFAULT_CACHE_DIR = os.path.join(Path.home(), ".sharetop", "cache")
|
|
22
|
+
CACHE_FILENAME = "instruments.json"
|
|
23
|
+
CACHE_TTL_SECONDS = 24 * 60 * 60 # 24 hours
|
|
24
|
+
MAX_BATCH_SIZE = 500
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _get_cache_dir() -> str:
|
|
28
|
+
return os.environ.get("SHARETOP_CACHE_DIR", DEFAULT_CACHE_DIR)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class InstrumentNameCache:
|
|
32
|
+
"""Thread-safe local cache for instrument names.
|
|
33
|
+
|
|
34
|
+
Backed by an in-memory dict and a JSON file on disk.
|
|
35
|
+
Resolves missing names from the instruments API on demand.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, cache_dir: Optional[str] = None) -> None:
|
|
39
|
+
self._cache_dir = cache_dir or _get_cache_dir()
|
|
40
|
+
self._names: Dict[str, str] = {}
|
|
41
|
+
self._updated_at: float = 0.0
|
|
42
|
+
self._lock = threading.Lock()
|
|
43
|
+
self._load_from_disk()
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def _cache_path(self) -> str:
|
|
47
|
+
return os.path.join(self._cache_dir, CACHE_FILENAME)
|
|
48
|
+
|
|
49
|
+
# ---- public API ----
|
|
50
|
+
|
|
51
|
+
def get_name(self, symbol: str) -> Optional[str]:
|
|
52
|
+
with self._lock:
|
|
53
|
+
return self._names.get(symbol)
|
|
54
|
+
|
|
55
|
+
def get_names(self, symbols: List[str]) -> Dict[str, str]:
|
|
56
|
+
with self._lock:
|
|
57
|
+
return {s: self._names[s] for s in symbols if s in self._names}
|
|
58
|
+
|
|
59
|
+
def missing(self, symbols: List[str]) -> List[str]:
|
|
60
|
+
with self._lock:
|
|
61
|
+
return [s for s in symbols if s not in self._names]
|
|
62
|
+
|
|
63
|
+
def update(self, names: Dict[str, str]) -> None:
|
|
64
|
+
if not names:
|
|
65
|
+
return
|
|
66
|
+
with self._lock:
|
|
67
|
+
self._names.update(names)
|
|
68
|
+
self._updated_at = time.time()
|
|
69
|
+
self._save_to_disk()
|
|
70
|
+
|
|
71
|
+
def resolve_sync(
|
|
72
|
+
self, symbols: List[str], client: "SyncAPIClient"
|
|
73
|
+
) -> Dict[str, str]:
|
|
74
|
+
"""Resolve names for symbols, fetching missing ones via sync client."""
|
|
75
|
+
missing = self.missing(symbols)
|
|
76
|
+
if missing:
|
|
77
|
+
self._fetch_sync(missing, client)
|
|
78
|
+
return self.get_names(symbols)
|
|
79
|
+
|
|
80
|
+
# ---- internal: fetch from API ----
|
|
81
|
+
|
|
82
|
+
def _fetch_sync(self, symbols: List[str], client: "SyncAPIClient") -> None:
|
|
83
|
+
try:
|
|
84
|
+
for i in range(0, len(symbols), MAX_BATCH_SIZE):
|
|
85
|
+
chunk = symbols[i : i + MAX_BATCH_SIZE]
|
|
86
|
+
response = client.post("/v1/instruments", json={"symbols": chunk})
|
|
87
|
+
names = {
|
|
88
|
+
inst["symbol"]: inst["name"]
|
|
89
|
+
for inst in response.get("data", [])
|
|
90
|
+
if inst.get("name")
|
|
91
|
+
}
|
|
92
|
+
self.update(names)
|
|
93
|
+
except Exception as e:
|
|
94
|
+
logger.debug("Failed to fetch instrument names: %s", e)
|
|
95
|
+
|
|
96
|
+
# ---- disk persistence ----
|
|
97
|
+
|
|
98
|
+
def _load_from_disk(self) -> None:
|
|
99
|
+
path = self._cache_path
|
|
100
|
+
if not os.path.exists(path):
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
105
|
+
data = json.load(f)
|
|
106
|
+
|
|
107
|
+
meta = data.get("_meta", {})
|
|
108
|
+
saved_at = meta.get("updated_at", 0)
|
|
109
|
+
|
|
110
|
+
if time.time() - saved_at > CACHE_TTL_SECONDS:
|
|
111
|
+
logger.debug("Cache expired, ignoring disk cache")
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
names = data.get("data", {})
|
|
115
|
+
if isinstance(names, dict):
|
|
116
|
+
with self._lock:
|
|
117
|
+
self._names.update(names)
|
|
118
|
+
self._updated_at = saved_at
|
|
119
|
+
logger.debug("Loaded %d instrument names from cache", len(names))
|
|
120
|
+
except Exception as e:
|
|
121
|
+
logger.debug("Failed to load cache from %s: %s", path, e)
|
|
122
|
+
|
|
123
|
+
def _save_to_disk(self) -> None:
|
|
124
|
+
path = self._cache_path
|
|
125
|
+
try:
|
|
126
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
127
|
+
with self._lock:
|
|
128
|
+
payload = {
|
|
129
|
+
"_meta": {"updated_at": self._updated_at, "version": 1},
|
|
130
|
+
"data": dict(self._names),
|
|
131
|
+
}
|
|
132
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
133
|
+
json.dump(payload, f, ensure_ascii=False)
|
|
134
|
+
except Exception as e:
|
|
135
|
+
logger.debug("Failed to save cache to %s: %s", path, e)
|