alphafeed 0.1.0.dev0__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.
- alphafeed/__init__.py +53 -0
- alphafeed/__version__.py +3 -0
- alphafeed/_base_client.py +305 -0
- alphafeed/_batch.py +87 -0
- alphafeed/_cache.py +130 -0
- alphafeed/_exceptions.py +140 -0
- alphafeed/_types.py +55 -0
- alphafeed/client.py +140 -0
- alphafeed/models.py +101 -0
- alphafeed/resources/__init__.py +13 -0
- alphafeed/resources/_base.py +17 -0
- alphafeed/resources/depth.py +44 -0
- alphafeed/resources/instruments.py +99 -0
- alphafeed/resources/klines.py +724 -0
- alphafeed/resources/quotes.py +257 -0
- alphafeed/utils.py +53 -0
- alphafeed-0.1.0.dev0.dist-info/METADATA +175 -0
- alphafeed-0.1.0.dev0.dist-info/RECORD +20 -0
- alphafeed-0.1.0.dev0.dist-info/WHEEL +5 -0
- alphafeed-0.1.0.dev0.dist-info/top_level.txt +1 -0
alphafeed/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""AlphaFeed Python SDK - 高性能行情数据客户端。
|
|
2
|
+
|
|
3
|
+
支持 A股、ETF、美股、港股的行情数据查询。
|
|
4
|
+
|
|
5
|
+
Examples
|
|
6
|
+
--------
|
|
7
|
+
>>> from alphafeed import AlphaFeed
|
|
8
|
+
>>> client = AlphaFeed(api_key="your-api-key")
|
|
9
|
+
>>> df = client.klines.get("600000.SH", to_dataframe=True)
|
|
10
|
+
>>> print(df.tail())
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .__version__ import __version__
|
|
14
|
+
from ._exceptions import (
|
|
15
|
+
AlphaFeedError,
|
|
16
|
+
APIError,
|
|
17
|
+
AuthenticationError,
|
|
18
|
+
BadRequestError,
|
|
19
|
+
ConnectionError,
|
|
20
|
+
InternalServerError,
|
|
21
|
+
NotFoundError,
|
|
22
|
+
PermissionError,
|
|
23
|
+
RateLimitError,
|
|
24
|
+
TimeoutError,
|
|
25
|
+
)
|
|
26
|
+
from .client import AlphaFeed
|
|
27
|
+
from .models import (
|
|
28
|
+
AdjustType,
|
|
29
|
+
CompactKlineData,
|
|
30
|
+
Instrument,
|
|
31
|
+
Period,
|
|
32
|
+
Quote,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"__version__",
|
|
37
|
+
"AlphaFeed",
|
|
38
|
+
"AlphaFeedError",
|
|
39
|
+
"APIError",
|
|
40
|
+
"AuthenticationError",
|
|
41
|
+
"PermissionError",
|
|
42
|
+
"NotFoundError",
|
|
43
|
+
"BadRequestError",
|
|
44
|
+
"RateLimitError",
|
|
45
|
+
"InternalServerError",
|
|
46
|
+
"ConnectionError",
|
|
47
|
+
"TimeoutError",
|
|
48
|
+
"AdjustType",
|
|
49
|
+
"CompactKlineData",
|
|
50
|
+
"Instrument",
|
|
51
|
+
"Period",
|
|
52
|
+
"Quote",
|
|
53
|
+
]
|
alphafeed/__version__.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Base HTTP client implementation with retry support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import random
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Optional, 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.alphafeed.org"
|
|
26
|
+
DEFAULT_TIMEOUT = 30.0
|
|
27
|
+
DEFAULT_MAX_RETRIES = 3
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _should_retry(exception: Exception) -> bool:
|
|
31
|
+
"""Determine if an exception is retryable.
|
|
32
|
+
|
|
33
|
+
Parameters
|
|
34
|
+
----------
|
|
35
|
+
exception : Exception
|
|
36
|
+
The exception to check.
|
|
37
|
+
|
|
38
|
+
Returns
|
|
39
|
+
-------
|
|
40
|
+
bool
|
|
41
|
+
True if the request should be retried.
|
|
42
|
+
"""
|
|
43
|
+
if isinstance(exception, (ConnectionError, TimeoutError)):
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
if isinstance(exception, (InternalServerError, RateLimitError)):
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _calculate_retry_delay(
|
|
53
|
+
attempt: int, base_delay: float = 1.0, max_delay: float = 30.0
|
|
54
|
+
) -> float:
|
|
55
|
+
"""Calculate exponential backoff delay with jitter.
|
|
56
|
+
|
|
57
|
+
Parameters
|
|
58
|
+
----------
|
|
59
|
+
attempt : int
|
|
60
|
+
Current attempt number (0-indexed).
|
|
61
|
+
base_delay : float
|
|
62
|
+
Base delay in seconds.
|
|
63
|
+
max_delay : float
|
|
64
|
+
Maximum delay in seconds.
|
|
65
|
+
|
|
66
|
+
Returns
|
|
67
|
+
-------
|
|
68
|
+
float
|
|
69
|
+
Delay in seconds.
|
|
70
|
+
"""
|
|
71
|
+
delay = base_delay * (2**attempt)
|
|
72
|
+
jitter = delay * 0.25 * (2 * random.random() - 1)
|
|
73
|
+
delay = delay + jitter
|
|
74
|
+
return min(delay, max_delay)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class BaseClient:
|
|
78
|
+
"""Base class with shared configuration for API clients."""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
api_key: Optional[str] = None,
|
|
83
|
+
base_url: Optional[str] = None,
|
|
84
|
+
timeout: Timeout = DEFAULT_TIMEOUT,
|
|
85
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
86
|
+
default_headers: Optional[Headers] = None,
|
|
87
|
+
) -> None:
|
|
88
|
+
if api_key is None:
|
|
89
|
+
self.api_key = os.environ.get("ALPHAFEED_API_KEY")
|
|
90
|
+
else:
|
|
91
|
+
self.api_key = api_key
|
|
92
|
+
|
|
93
|
+
if not self.api_key:
|
|
94
|
+
effective_base_url = base_url or os.environ.get("ALPHAFEED_BASE_URL")
|
|
95
|
+
if effective_base_url is None or effective_base_url == DEFAULT_BASE_URL:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
"API key is required. Pass `api_key` or set ALPHAFEED_API_KEY environment variable."
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
self.base_url = (
|
|
101
|
+
base_url or os.environ.get("ALPHAFEED_BASE_URL") or DEFAULT_BASE_URL
|
|
102
|
+
).rstrip("/")
|
|
103
|
+
self.timeout = timeout
|
|
104
|
+
self.max_retries = max_retries
|
|
105
|
+
self._default_headers = dict(default_headers) if default_headers else {}
|
|
106
|
+
|
|
107
|
+
def _build_headers(self, extra_headers: Optional[Headers] = None) -> dict[str, str]:
|
|
108
|
+
"""Build request headers with authentication."""
|
|
109
|
+
headers = {
|
|
110
|
+
"Content-Type": "application/json",
|
|
111
|
+
"Accept": "application/json",
|
|
112
|
+
"User-Agent": f"alphafeed-python/{__version__}",
|
|
113
|
+
**self._default_headers,
|
|
114
|
+
}
|
|
115
|
+
if self.api_key:
|
|
116
|
+
headers["x-api-key"] = self.api_key
|
|
117
|
+
if extra_headers:
|
|
118
|
+
headers.update(extra_headers)
|
|
119
|
+
return headers
|
|
120
|
+
|
|
121
|
+
def _build_url(self, path: str) -> str:
|
|
122
|
+
"""Build full URL from path."""
|
|
123
|
+
return f"{self.base_url}{path}"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class SyncAPIClient(BaseClient):
|
|
127
|
+
"""Synchronous HTTP client for AlphaFeed API with automatic retry.
|
|
128
|
+
|
|
129
|
+
Parameters
|
|
130
|
+
----------
|
|
131
|
+
api_key : str, optional
|
|
132
|
+
API key for authentication. If not provided, reads from ALPHAFEED_API_KEY
|
|
133
|
+
environment variable.
|
|
134
|
+
base_url : str, optional
|
|
135
|
+
Base URL for the API. Defaults to https://api.alphafeed.org.
|
|
136
|
+
timeout : float, optional
|
|
137
|
+
Request timeout in seconds. Defaults to 30.0.
|
|
138
|
+
max_retries : int, optional
|
|
139
|
+
Maximum number of retry attempts for failed requests. Defaults to 3.
|
|
140
|
+
Retries occur on connection errors, timeouts, server errors (5xx),
|
|
141
|
+
and rate limits (429).
|
|
142
|
+
default_headers : dict, optional
|
|
143
|
+
Default headers to include in all requests.
|
|
144
|
+
|
|
145
|
+
Examples
|
|
146
|
+
--------
|
|
147
|
+
>>> client = SyncAPIClient(api_key="your-api-key")
|
|
148
|
+
>>> response = client.get("/v1/klines", params={"symbol": "600000.SH"})
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
def __init__(
|
|
152
|
+
self,
|
|
153
|
+
api_key: Optional[str] = None,
|
|
154
|
+
base_url: Optional[str] = None,
|
|
155
|
+
timeout: Timeout = DEFAULT_TIMEOUT,
|
|
156
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
157
|
+
default_headers: Optional[Headers] = None,
|
|
158
|
+
) -> None:
|
|
159
|
+
super().__init__(api_key, base_url, timeout, max_retries, default_headers)
|
|
160
|
+
self._client = httpx.Client(timeout=timeout)
|
|
161
|
+
|
|
162
|
+
def __enter__(self) -> "SyncAPIClient":
|
|
163
|
+
return self
|
|
164
|
+
|
|
165
|
+
def __exit__(self, *args: Any) -> None:
|
|
166
|
+
self.close()
|
|
167
|
+
|
|
168
|
+
def close(self) -> None:
|
|
169
|
+
"""Close the underlying HTTP client."""
|
|
170
|
+
self._client.close()
|
|
171
|
+
|
|
172
|
+
def _request(
|
|
173
|
+
self,
|
|
174
|
+
method: str,
|
|
175
|
+
path: str,
|
|
176
|
+
*,
|
|
177
|
+
params: Optional[Query] = None,
|
|
178
|
+
json: Optional[dict[str, Any]] = None,
|
|
179
|
+
extra_headers: Optional[Headers] = None,
|
|
180
|
+
timeout: Union[Timeout, NotGiven] = NOT_GIVEN,
|
|
181
|
+
max_retries: Union[int, NotGiven] = NOT_GIVEN,
|
|
182
|
+
) -> Any:
|
|
183
|
+
"""Make an HTTP request with automatic retry on failures.
|
|
184
|
+
|
|
185
|
+
Parameters
|
|
186
|
+
----------
|
|
187
|
+
method : str
|
|
188
|
+
HTTP method (GET, POST, etc.).
|
|
189
|
+
path : str
|
|
190
|
+
API endpoint path.
|
|
191
|
+
params : dict, optional
|
|
192
|
+
Query parameters.
|
|
193
|
+
json : dict, optional
|
|
194
|
+
JSON request body.
|
|
195
|
+
extra_headers : dict, optional
|
|
196
|
+
Additional headers for this request.
|
|
197
|
+
timeout : float, optional
|
|
198
|
+
Override timeout for this request.
|
|
199
|
+
max_retries : int, optional
|
|
200
|
+
Override max retries for this request.
|
|
201
|
+
|
|
202
|
+
Returns
|
|
203
|
+
-------
|
|
204
|
+
Any
|
|
205
|
+
Parsed JSON response.
|
|
206
|
+
|
|
207
|
+
Raises
|
|
208
|
+
------
|
|
209
|
+
APIError
|
|
210
|
+
If the API returns an error response after all retries.
|
|
211
|
+
ConnectionError
|
|
212
|
+
If there's a network connection issue after all retries.
|
|
213
|
+
TimeoutError
|
|
214
|
+
If the request times out after all retries.
|
|
215
|
+
"""
|
|
216
|
+
url = self._build_url(path)
|
|
217
|
+
headers = self._build_headers(extra_headers)
|
|
218
|
+
request_timeout = timeout if not isinstance(timeout, NotGiven) else self.timeout
|
|
219
|
+
retries = (
|
|
220
|
+
max_retries if not isinstance(max_retries, NotGiven) else self.max_retries
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
if params:
|
|
224
|
+
params = {k: v for k, v in params.items() if v is not None}
|
|
225
|
+
|
|
226
|
+
last_exception: Optional[Exception] = None
|
|
227
|
+
|
|
228
|
+
for attempt in range(retries + 1):
|
|
229
|
+
try:
|
|
230
|
+
response = self._client.request(
|
|
231
|
+
method,
|
|
232
|
+
url,
|
|
233
|
+
params=params,
|
|
234
|
+
json=json,
|
|
235
|
+
headers=headers,
|
|
236
|
+
timeout=request_timeout,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
response_body = response.json()
|
|
241
|
+
except Exception:
|
|
242
|
+
response_body = {"message": response.text, "code": "PARSE_ERROR"}
|
|
243
|
+
|
|
244
|
+
raise_for_status(response.status_code, response_body)
|
|
245
|
+
|
|
246
|
+
return response_body
|
|
247
|
+
|
|
248
|
+
except httpx.ConnectError as e:
|
|
249
|
+
last_exception = ConnectionError(f"Failed to connect to {url}: {e}")
|
|
250
|
+
except httpx.TimeoutException as e:
|
|
251
|
+
last_exception = TimeoutError(f"Request to {url} timed out")
|
|
252
|
+
except APIError as e:
|
|
253
|
+
last_exception = e
|
|
254
|
+
if not _should_retry(e):
|
|
255
|
+
raise
|
|
256
|
+
|
|
257
|
+
if attempt < retries and _should_retry(last_exception):
|
|
258
|
+
delay = _calculate_retry_delay(attempt)
|
|
259
|
+
time.sleep(delay)
|
|
260
|
+
else:
|
|
261
|
+
break
|
|
262
|
+
|
|
263
|
+
if last_exception:
|
|
264
|
+
raise last_exception
|
|
265
|
+
raise RuntimeError("Unexpected state: no exception but request failed")
|
|
266
|
+
|
|
267
|
+
def get(
|
|
268
|
+
self,
|
|
269
|
+
path: str,
|
|
270
|
+
*,
|
|
271
|
+
params: Optional[Query] = None,
|
|
272
|
+
extra_headers: Optional[Headers] = None,
|
|
273
|
+
timeout: Union[Timeout, NotGiven] = NOT_GIVEN,
|
|
274
|
+
max_retries: Union[int, NotGiven] = NOT_GIVEN,
|
|
275
|
+
) -> Any:
|
|
276
|
+
"""Make a GET request with automatic retry."""
|
|
277
|
+
return self._request(
|
|
278
|
+
"GET",
|
|
279
|
+
path,
|
|
280
|
+
params=params,
|
|
281
|
+
extra_headers=extra_headers,
|
|
282
|
+
timeout=timeout,
|
|
283
|
+
max_retries=max_retries,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def post(
|
|
287
|
+
self,
|
|
288
|
+
path: str,
|
|
289
|
+
*,
|
|
290
|
+
json: Optional[dict[str, Any]] = None,
|
|
291
|
+
params: Optional[Query] = None,
|
|
292
|
+
extra_headers: Optional[Headers] = None,
|
|
293
|
+
timeout: Union[Timeout, NotGiven] = NOT_GIVEN,
|
|
294
|
+
max_retries: Union[int, NotGiven] = NOT_GIVEN,
|
|
295
|
+
) -> Any:
|
|
296
|
+
"""Make a POST request with automatic retry."""
|
|
297
|
+
return self._request(
|
|
298
|
+
"POST",
|
|
299
|
+
path,
|
|
300
|
+
json=json,
|
|
301
|
+
params=params,
|
|
302
|
+
extra_headers=extra_headers,
|
|
303
|
+
timeout=timeout,
|
|
304
|
+
max_retries=max_retries,
|
|
305
|
+
)
|
alphafeed/_batch.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
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.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import concurrent.futures
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
from tqdm.auto import tqdm
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
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
|
+
return tqdm(total=total, desc=desc, leave=False)
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def batched_get_sync(
|
|
32
|
+
client: "SyncAPIClient",
|
|
33
|
+
endpoint: str,
|
|
34
|
+
symbols: List[str],
|
|
35
|
+
params: Dict[str, Any],
|
|
36
|
+
*,
|
|
37
|
+
symbols_param: str = "symbols",
|
|
38
|
+
batch_size: int = DEFAULT_BATCH_SIZE,
|
|
39
|
+
max_workers: int = DEFAULT_MAX_WORKERS,
|
|
40
|
+
show_progress: bool = False,
|
|
41
|
+
progress_desc: str = "Fetching data",
|
|
42
|
+
merge: Optional[Callable[[Dict[str, Any], Dict[str, Any]], None]] = None,
|
|
43
|
+
) -> Dict[str, Any]:
|
|
44
|
+
"""Fetch *endpoint* in chunks, merging ``response["data"]`` dicts.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
merge : callable, optional
|
|
49
|
+
``merge(accumulated, chunk_data)`` – custom merge strategy.
|
|
50
|
+
Defaults to ``accumulated.update(chunk_data)``.
|
|
51
|
+
"""
|
|
52
|
+
if not symbols:
|
|
53
|
+
return {}
|
|
54
|
+
|
|
55
|
+
if isinstance(symbols, str):
|
|
56
|
+
symbols = symbols.split(",")
|
|
57
|
+
|
|
58
|
+
chunks = _chunk_list(symbols, batch_size)
|
|
59
|
+
_merge = merge or _default_merge
|
|
60
|
+
|
|
61
|
+
if len(chunks) == 1:
|
|
62
|
+
chunk_params = {**params, symbols_param: ",".join(chunks[0])}
|
|
63
|
+
return client.get(endpoint, params=chunk_params)["data"]
|
|
64
|
+
|
|
65
|
+
pbar = _get_progress_bar(len(chunks), progress_desc, show_progress)
|
|
66
|
+
all_data: Dict[str, Any] = {}
|
|
67
|
+
|
|
68
|
+
def _fetch(chunk: List[str]) -> Dict[str, Any]:
|
|
69
|
+
chunk_params = {**params, symbols_param: ",".join(chunk)}
|
|
70
|
+
return client.get(endpoint, params=chunk_params)["data"]
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as pool:
|
|
74
|
+
futures = {pool.submit(_fetch, c): c for c in chunks}
|
|
75
|
+
for future in concurrent.futures.as_completed(futures):
|
|
76
|
+
_merge(all_data, future.result())
|
|
77
|
+
if pbar:
|
|
78
|
+
pbar.update(1)
|
|
79
|
+
finally:
|
|
80
|
+
if pbar:
|
|
81
|
+
pbar.close()
|
|
82
|
+
|
|
83
|
+
return all_data
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _default_merge(acc: Dict[str, Any], chunk: Dict[str, Any]) -> None:
|
|
87
|
+
acc.update(chunk)
|
alphafeed/_cache.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Local instrument name cache for AlphaFeed.
|
|
2
|
+
|
|
3
|
+
Caches symbol -> name mappings to avoid frequent API calls.
|
|
4
|
+
Cache directory is configurable via ALPHAFEED_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 TYPE_CHECKING, Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from ._base_client import SyncAPIClient
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("alphafeed.cache")
|
|
21
|
+
|
|
22
|
+
DEFAULT_CACHE_DIR = os.path.join(Path.home(), ".alphafeed", "cache")
|
|
23
|
+
CACHE_FILENAME = "instruments.json"
|
|
24
|
+
CACHE_TTL_SECONDS = 24 * 60 * 60 # 24 hours
|
|
25
|
+
MAX_BATCH_SIZE = 500
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _get_cache_dir() -> str:
|
|
29
|
+
return os.environ.get("ALPHAFEED_CACHE_DIR", DEFAULT_CACHE_DIR)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class InstrumentNameCache:
|
|
33
|
+
"""Thread-safe local cache for instrument names.
|
|
34
|
+
|
|
35
|
+
Backed by an in-memory dict and a JSON file on disk.
|
|
36
|
+
Resolves missing names from the instruments API on demand.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, cache_dir: Optional[str] = None) -> None:
|
|
40
|
+
self._cache_dir = cache_dir or _get_cache_dir()
|
|
41
|
+
self._names: Dict[str, str] = {}
|
|
42
|
+
self._updated_at: float = 0.0
|
|
43
|
+
self._lock = threading.Lock()
|
|
44
|
+
self._load_from_disk()
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def _cache_path(self) -> str:
|
|
48
|
+
return os.path.join(self._cache_dir, CACHE_FILENAME)
|
|
49
|
+
|
|
50
|
+
def get_name(self, symbol: str) -> Optional[str]:
|
|
51
|
+
with self._lock:
|
|
52
|
+
return self._names.get(symbol)
|
|
53
|
+
|
|
54
|
+
def get_names(self, symbols: List[str]) -> Dict[str, str]:
|
|
55
|
+
with self._lock:
|
|
56
|
+
return {s: self._names[s] for s in symbols if s in self._names}
|
|
57
|
+
|
|
58
|
+
def missing(self, symbols: List[str]) -> List[str]:
|
|
59
|
+
with self._lock:
|
|
60
|
+
return [s for s in symbols if s not in self._names]
|
|
61
|
+
|
|
62
|
+
def update(self, names: Dict[str, str]) -> None:
|
|
63
|
+
if not names:
|
|
64
|
+
return
|
|
65
|
+
with self._lock:
|
|
66
|
+
self._names.update(names)
|
|
67
|
+
self._updated_at = time.time()
|
|
68
|
+
self._save_to_disk()
|
|
69
|
+
|
|
70
|
+
def resolve_sync(
|
|
71
|
+
self, symbols: List[str], client: "SyncAPIClient"
|
|
72
|
+
) -> Dict[str, str]:
|
|
73
|
+
"""Resolve names for symbols, fetching missing ones via sync client."""
|
|
74
|
+
missing = self.missing(symbols)
|
|
75
|
+
if missing:
|
|
76
|
+
self._fetch_sync(missing, client)
|
|
77
|
+
return self.get_names(symbols)
|
|
78
|
+
|
|
79
|
+
def _fetch_sync(self, symbols: List[str], client: "SyncAPIClient") -> None:
|
|
80
|
+
try:
|
|
81
|
+
for i in range(0, len(symbols), MAX_BATCH_SIZE):
|
|
82
|
+
chunk = symbols[i : i + MAX_BATCH_SIZE]
|
|
83
|
+
response = client.post("/v1/instruments", json={"symbols": chunk})
|
|
84
|
+
names = {
|
|
85
|
+
inst["symbol"]: inst["name"]
|
|
86
|
+
for inst in response.get("data", [])
|
|
87
|
+
if inst.get("name")
|
|
88
|
+
}
|
|
89
|
+
self.update(names)
|
|
90
|
+
except Exception as e:
|
|
91
|
+
logger.debug("Failed to fetch instrument names: %s", e)
|
|
92
|
+
|
|
93
|
+
def _load_from_disk(self) -> None:
|
|
94
|
+
path = self._cache_path
|
|
95
|
+
if not os.path.exists(path):
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
100
|
+
data = json.load(f)
|
|
101
|
+
|
|
102
|
+
meta = data.get("_meta", {})
|
|
103
|
+
saved_at = meta.get("updated_at", 0)
|
|
104
|
+
|
|
105
|
+
if time.time() - saved_at > CACHE_TTL_SECONDS:
|
|
106
|
+
logger.debug("Cache expired, ignoring disk cache")
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
names = data.get("data", {})
|
|
110
|
+
if isinstance(names, dict):
|
|
111
|
+
with self._lock:
|
|
112
|
+
self._names.update(names)
|
|
113
|
+
self._updated_at = saved_at
|
|
114
|
+
logger.debug("Loaded %d instrument names from cache", len(names))
|
|
115
|
+
except Exception as e:
|
|
116
|
+
logger.debug("Failed to load cache from %s: %s", path, e)
|
|
117
|
+
|
|
118
|
+
def _save_to_disk(self) -> None:
|
|
119
|
+
path = self._cache_path
|
|
120
|
+
try:
|
|
121
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
122
|
+
with self._lock:
|
|
123
|
+
payload = {
|
|
124
|
+
"_meta": {"updated_at": self._updated_at, "version": 1},
|
|
125
|
+
"data": dict(self._names),
|
|
126
|
+
}
|
|
127
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
128
|
+
json.dump(payload, f, ensure_ascii=False)
|
|
129
|
+
except Exception as e:
|
|
130
|
+
logger.debug("Failed to save cache to %s: %s", path, e)
|