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 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
+ ]
@@ -0,0 +1,3 @@
1
+ from importlib.metadata import version
2
+
3
+ __version__ = version(__package__)
@@ -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)