ashare-data-lite 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.
@@ -0,0 +1,163 @@
1
+ """轻量证券行情接口,返回可序列化的 dataclass 对象。"""
2
+
3
+ from ._default import configure, get_client
4
+ from .async_client import AsyncMarketClient
5
+ from .client import MarketClient
6
+ from typing import Sequence
7
+
8
+ from .models import Bar, DateLike, FundNav, FundShares, Instrument, Observation, Quote
9
+ from .stock import get_stock_quote, get_stock_quotes, get_stock_history
10
+ from .index import get_index_quote, get_index_quotes, get_index_history
11
+ from .etf import (
12
+ get_etf_quote,
13
+ get_etf_quotes,
14
+ get_etf_history,
15
+ get_fund_nav_history,
16
+ get_etf_catalog,
17
+ get_sse_etf_shares,
18
+ )
19
+ from .exceptions import (
20
+ MarketDataError,
21
+ InvalidCodeError,
22
+ ConfigurationError,
23
+ RequestError,
24
+ DataFormatError,
25
+ QuoteNotFoundError,
26
+ AllSourcesFailedError,
27
+ )
28
+ from .models import OrderLevel
29
+
30
+ __version__ = "0.1.0"
31
+
32
+
33
+ def get_quotes(codes: Sequence[str], *, instrument: Instrument = "auto") -> list[Quote]:
34
+ """批量获取实时行情,按首次出现的代码顺序返回 Quote;缺失项带 error。"""
35
+ return get_client().get_quotes(codes, instrument=instrument)
36
+
37
+
38
+ def get_quote(code: str, *, instrument: Instrument = "auto") -> Quote:
39
+ """获取单只实时行情;缺失时抛出 QuoteNotFoundError。"""
40
+ return get_client().get_quote(code, instrument=instrument)
41
+
42
+
43
+ def get_history(
44
+ code: str,
45
+ start: DateLike | None = None,
46
+ end: DateLike | None = None,
47
+ *,
48
+ instrument: Instrument = "auto",
49
+ sources: Sequence[str] | None = None,
50
+ adjust: str = "none",
51
+ scale: int = 240,
52
+ datalen: int = 5049,
53
+ max_pages: int = 100,
54
+ ) -> list[Bar]:
55
+ """获取升序 K 线;sources 按顺序降级,默认日线腾讯→新浪、分钟线新浪。"""
56
+ return get_client().get_history(
57
+ code,
58
+ start,
59
+ end,
60
+ instrument=instrument,
61
+ sources=sources,
62
+ adjust=adjust,
63
+ scale=scale,
64
+ datalen=datalen,
65
+ max_pages=max_pages,
66
+ )
67
+
68
+
69
+ def get_fred_series(
70
+ series_id: str,
71
+ start: DateLike | None = None,
72
+ end: DateLike | None = None,
73
+ *,
74
+ api_key: str | None = None,
75
+ ) -> list[Observation]:
76
+ """获取升序 FRED 观测值;api_key 优先于客户端配置和环境变量。"""
77
+ return get_client().get_fred_series(series_id, start, end, api_key=api_key)
78
+
79
+
80
+ def get_vix_history(
81
+ start: DateLike | None = None,
82
+ end: DateLike | None = None,
83
+ *,
84
+ api_key: str | None = None,
85
+ ) -> list[Observation]:
86
+ """获取 VIX 日度收盘序列 VIXCLS,返回 Observation 列表。"""
87
+ return get_client().get_vix_history(start, end, api_key=api_key)
88
+
89
+
90
+ def get_twelve_bars(
91
+ symbol: str,
92
+ *,
93
+ interval: str = "1day",
94
+ outputsize: int = 5000,
95
+ start: DateLike | None = None,
96
+ end: DateLike | None = None,
97
+ adjust: str = "all",
98
+ api_key: str | None = None,
99
+ ) -> list[Bar]:
100
+ """获取一页升序海外 K 线,outputsize 为 1~5000,时间使用 UTC。"""
101
+ return get_client().get_twelve_bars(
102
+ symbol,
103
+ interval=interval,
104
+ outputsize=outputsize,
105
+ start=start,
106
+ end=end,
107
+ adjust=adjust,
108
+ api_key=api_key,
109
+ )
110
+
111
+
112
+ def get_twelve_daily_history(
113
+ symbol: str,
114
+ start: DateLike | None = None,
115
+ end: DateLike | None = None,
116
+ *,
117
+ adjust: str = "all",
118
+ api_key: str | None = None,
119
+ max_pages: int = 100,
120
+ ) -> list[Bar]:
121
+ """分页获取指定日期区间的升序海外日线,返回 Bar 列表。"""
122
+ return get_client().get_twelve_daily_history(
123
+ symbol, start, end, adjust=adjust, api_key=api_key, max_pages=max_pages
124
+ )
125
+
126
+
127
+ __all__ = [
128
+ "get_quote",
129
+ "get_quotes",
130
+ "get_history",
131
+ "get_fund_nav_history",
132
+ "get_etf_catalog",
133
+ "get_sse_etf_shares",
134
+ "get_fred_series",
135
+ "get_vix_history",
136
+ "get_twelve_bars",
137
+ "get_twelve_daily_history",
138
+ "get_stock_quote",
139
+ "get_stock_quotes",
140
+ "get_stock_history",
141
+ "get_etf_quote",
142
+ "get_etf_quotes",
143
+ "get_etf_history",
144
+ "get_index_quote",
145
+ "get_index_quotes",
146
+ "get_index_history",
147
+ "configure",
148
+ "MarketClient",
149
+ "AsyncMarketClient",
150
+ "MarketDataError",
151
+ "InvalidCodeError",
152
+ "ConfigurationError",
153
+ "RequestError",
154
+ "DataFormatError",
155
+ "QuoteNotFoundError",
156
+ "AllSourcesFailedError",
157
+ "Quote",
158
+ "OrderLevel",
159
+ "Bar",
160
+ "FundNav",
161
+ "FundShares",
162
+ "Observation",
163
+ ]
@@ -0,0 +1,152 @@
1
+ """将来源数据转换为统一的公开类型与计量单位。"""
2
+
3
+ from datetime import date, datetime, timedelta, timezone
4
+
5
+ from .exceptions import DataFormatError
6
+ from .models import Bar, FundNav, FundShares, Observation, OrderLevel, Quote
7
+
8
+ CHINA_TZ = timezone(timedelta(hours=8))
9
+
10
+
11
+ def scaled(value, factor):
12
+ return value * factor if value is not None else None
13
+
14
+
15
+ def quote_model(row, instrument="auto"):
16
+ kind = row.get("instrument_type", instrument)
17
+ extension = row.get("etf", row.get("stock", {}))
18
+ time = row.get("quote_time")
19
+ book = row.get("order_book", {})
20
+
21
+ def levels(side):
22
+ return tuple(
23
+ OrderLevel(level["price"], scaled(level["volume_lot"], 100))
24
+ for level in book.get(side, [])
25
+ )
26
+
27
+ premium = extension.get("premium_rate")
28
+ if premium is None:
29
+ premium = extension.get("premium_rate_calculated")
30
+ average = row.get("average_price")
31
+ if average is None:
32
+ average = row.get("average_price_calculated")
33
+ return Quote(
34
+ symbol=row["symbol"],
35
+ code=row.get("code", row["symbol"][2:]),
36
+ instrument=kind,
37
+ source=row["source"],
38
+ name=row.get("name"),
39
+ timestamp=datetime.fromisoformat(time).replace(tzinfo=CHINA_TZ)
40
+ if time
41
+ else None,
42
+ currency=row.get("currency"),
43
+ **{
44
+ key: row.get(key)
45
+ for key in (
46
+ "last",
47
+ "prev_close",
48
+ "open",
49
+ "high",
50
+ "low",
51
+ "change",
52
+ "change_pct",
53
+ "pb",
54
+ "limit_up",
55
+ "limit_down",
56
+ "volume_ratio",
57
+ )
58
+ },
59
+ pe=extension.get("pe"),
60
+ volume=scaled(row.get("volume_lot"), 100),
61
+ amount=row.get("amount_yuan"),
62
+ outer_volume=scaled(row.get("outer_volume_lot"), 100),
63
+ inner_volume=scaled(row.get("inner_volume_lot"), 100),
64
+ turnover_rate_pct=row.get("turnover_rate"),
65
+ amplitude_pct=row.get("amplitude"),
66
+ market_cap=scaled(row.get("total_market_cap_100m"), 100000000),
67
+ circulating_market_cap=scaled(
68
+ row.get("circulating_market_cap_100m"), 100000000
69
+ ),
70
+ order_imbalance=scaled(row.get("order_imbalance_calculated"), 100),
71
+ average_price=average,
72
+ is_suspended=row.get("is_suspended", False),
73
+ bids=levels("bids"),
74
+ asks=levels("asks"),
75
+ iopv=extension.get("iopv"),
76
+ premium_rate_pct=premium,
77
+ circulating_shares=extension.get(
78
+ "circulating_units", extension.get("circulating_shares")
79
+ ),
80
+ total_shares=extension.get("total_units", extension.get("total_shares")),
81
+ error=row.get("error"),
82
+ )
83
+
84
+
85
+ def bar_models(rows, symbol, interval):
86
+ result = []
87
+ for row in rows:
88
+ try:
89
+ moment = datetime.fromisoformat(row["datetime"])
90
+ except (KeyError, TypeError, ValueError) as exc:
91
+ raise DataFormatError("K 线时间字段异常") from exc
92
+ if row.get("close") is None:
93
+ raise DataFormatError("K 线收盘价字段异常")
94
+ overseas = row["source"] == "twelvedata"
95
+ if moment.tzinfo is None:
96
+ moment = moment.replace(tzinfo=timezone.utc if overseas else CHINA_TZ)
97
+ volume = row.get("volume")
98
+ if row.get("volume_unit") == "lot":
99
+ volume = scaled(volume, 100)
100
+ result.append(
101
+ Bar(
102
+ symbol=symbol,
103
+ timestamp=moment,
104
+ interval=interval,
105
+ source=row["source"],
106
+ adjust=row.get("adjust") or "none",
107
+ open=row.get("open"),
108
+ high=row.get("high"),
109
+ low=row.get("low"),
110
+ close=row.get("close"),
111
+ volume=volume,
112
+ volume_unit="provider" if overseas else "share",
113
+ currency=row.get("meta", {}).get("currency") if overseas else "CNY",
114
+ )
115
+ )
116
+ return result
117
+
118
+
119
+ def nav_models(rows):
120
+ return [
121
+ FundNav(
122
+ row["code"],
123
+ date.fromisoformat(row["valuation_date"]),
124
+ row["nav"],
125
+ row["source"],
126
+ )
127
+ for row in rows
128
+ ]
129
+
130
+
131
+ def shares_models(rows):
132
+ return [
133
+ FundShares(
134
+ row["code"],
135
+ date.fromisoformat(row["date"][:10]),
136
+ row["shares"],
137
+ row["source"],
138
+ )
139
+ for row in rows
140
+ ]
141
+
142
+
143
+ def observation_models(rows):
144
+ return [
145
+ Observation(
146
+ row["series_id"],
147
+ date.fromisoformat(row["date"]),
148
+ row["value"],
149
+ row["source"],
150
+ )
151
+ for row in rows
152
+ ]
@@ -0,0 +1,36 @@
1
+ """进程级默认客户端,连续调用复用净值限速与冷却状态。"""
2
+
3
+ import threading
4
+
5
+ from .client import MarketClient
6
+
7
+ _client = None
8
+ _lock = threading.Lock()
9
+
10
+
11
+ def get_client():
12
+ global _client
13
+ with _lock:
14
+ if _client is None:
15
+ _client = MarketClient()
16
+ return _client
17
+
18
+
19
+ def configure(
20
+ *,
21
+ timeout: float = 10,
22
+ retries: int = 1,
23
+ fred_api_key: str | None = None,
24
+ twelve_api_key: str | None = None,
25
+ ) -> MarketClient:
26
+ """设置全局请求超时、重试和账户密钥,返回新的默认客户端。"""
27
+ global _client
28
+ client = MarketClient(
29
+ timeout=timeout,
30
+ retries=retries,
31
+ fred_api_key=fred_api_key,
32
+ twelve_api_key=twelve_api_key,
33
+ )
34
+ with _lock:
35
+ _client = client
36
+ return client
ashare_data/_http.py ADDED
@@ -0,0 +1,86 @@
1
+ """使用 urllib 请求上游数据,异常消息仅显示主机和路径。"""
2
+
3
+ import gzip
4
+ import json
5
+ import socket
6
+ import time
7
+ from datetime import datetime, timezone
8
+ from email.utils import parsedate_to_datetime
9
+ from urllib.error import HTTPError, URLError
10
+ from urllib.parse import urlencode, urlsplit
11
+ from urllib.request import Request, urlopen
12
+
13
+ from .exceptions import ConfigurationError, DataFormatError, RequestError
14
+
15
+
16
+ def retry_seconds(value):
17
+ if not value:
18
+ return 0
19
+ try:
20
+ return max(0, float(value))
21
+ except ValueError:
22
+ try:
23
+ return max(
24
+ 0,
25
+ (
26
+ parsedate_to_datetime(value) - datetime.now(timezone.utc)
27
+ ).total_seconds(),
28
+ )
29
+ except (TypeError, ValueError):
30
+ return 0
31
+
32
+
33
+ class HttpClient:
34
+ def __init__(self, timeout=10, retries=1):
35
+ if timeout <= 0 or not isinstance(retries, int) or retries < 0:
36
+ raise ConfigurationError("timeout 应大于 0,retries 应为非负整数")
37
+ self.timeout = timeout
38
+ self.retries = retries
39
+
40
+ def get_bytes(self, url, *, params=None, headers=None, timeout=None):
41
+ query = urlencode(
42
+ {key: value for key, value in (params or {}).items() if value is not None}
43
+ )
44
+ request_url = url + ("&" if "?" in url else "?") + query if query else url
45
+ parts = urlsplit(url)
46
+ endpoint = parts.netloc + parts.path
47
+ request = Request(
48
+ request_url, headers={"User-Agent": "Mozilla/5.0", **(headers or {})}
49
+ )
50
+ for attempt in range(self.retries + 1):
51
+ try:
52
+ with urlopen(
53
+ request, timeout=self.timeout if timeout is None else timeout
54
+ ) as response:
55
+ data = response.read()
56
+ return (
57
+ gzip.decompress(data)
58
+ if response.headers.get("Content-Encoding") == "gzip"
59
+ else data
60
+ )
61
+ except HTTPError as exc:
62
+ status = exc.code
63
+ delay = retry_seconds(exc.headers.get("Retry-After"))
64
+ exc.close()
65
+ error = RequestError(
66
+ f"{endpoint} HTTP {status}", status_code=status, retry_after=delay
67
+ )
68
+ retryable = status in {502, 503, 504}
69
+ except (URLError, OSError, socket.timeout) as exc:
70
+ error = RequestError(f"{endpoint} 网络请求失败({type(exc).__name__})")
71
+ retryable = True
72
+ if attempt == self.retries or not retryable:
73
+ raise error from None
74
+ time.sleep(0.5 * 2**attempt)
75
+
76
+ def get_text(self, url, *, encoding="utf-8", **kwargs):
77
+ try:
78
+ return self.get_bytes(url, **kwargs).decode(encoding)
79
+ except UnicodeError as exc:
80
+ raise DataFormatError("响应文本编码异常") from exc
81
+
82
+ def get_json(self, url, **kwargs):
83
+ try:
84
+ return json.loads(self.get_text(url, **kwargs))
85
+ except (ValueError, TypeError) as exc:
86
+ raise DataFormatError("响应应为 JSON") from exc
ashare_data/_utils.py ADDED
@@ -0,0 +1,87 @@
1
+ """证券代码、日期及数值的共同约定。"""
2
+
3
+ import math
4
+ import re
5
+ from collections.abc import Sequence
6
+ from datetime import date, datetime, timedelta, timezone
7
+
8
+ from .exceptions import ConfigurationError, InvalidCodeError
9
+
10
+
11
+ def number(value):
12
+ try:
13
+ result = float(str(value).replace(",", ""))
14
+ return result if math.isfinite(result) else None
15
+ except (TypeError, ValueError):
16
+ return None
17
+
18
+
19
+ def china_today():
20
+ return datetime.now(timezone(timedelta(hours=8))).date()
21
+
22
+
23
+ def date_text(value):
24
+ if isinstance(value, datetime):
25
+ return value.date().isoformat()
26
+ if isinstance(value, date):
27
+ return value.isoformat()
28
+ try:
29
+ return date.fromisoformat(str(value)).isoformat()
30
+ except ValueError as exc:
31
+ raise ConfigurationError("日期使用 YYYY-MM-DD 或 date 对象") from exc
32
+
33
+
34
+ def date_range(start=None, end=None):
35
+ start = date_text(start) if start is not None else "1970-01-01"
36
+ end = date_text(end) if end is not None else china_today().isoformat()
37
+ if start > end:
38
+ raise ConfigurationError("起始日期应早于或等于截止日期")
39
+ return start, end
40
+
41
+
42
+ def normalize_symbol(code, instrument="auto"):
43
+ if instrument not in {"auto", "stock", "etf", "index"}:
44
+ raise ConfigurationError("instrument 使用 auto、stock、etf 或 index")
45
+ if not isinstance(code, str):
46
+ raise InvalidCodeError("证券代码使用字符串,以保留前导零")
47
+ value = code.strip().lower()
48
+ if re.fullmatch(r"(sh|sz)\d{6}", value):
49
+ return value
50
+ suffix = re.fullmatch(r"(\d{6})\.(sh|sz)", value)
51
+ if suffix:
52
+ return suffix[2] + suffix[1]
53
+ if not re.fullmatch(r"\d{6}", value):
54
+ raise InvalidCodeError(f"证券代码格式错误:{code!r}")
55
+ if instrument == "index":
56
+ if value.startswith("000"):
57
+ return "sh" + value
58
+ if value.startswith("399"):
59
+ return "sz" + value
60
+ raise InvalidCodeError("该指数请使用明确市场前缀,例如 sh000001")
61
+ return ("sh" if value.startswith(("5", "6", "9")) else "sz") + value
62
+
63
+
64
+ def is_index(symbol):
65
+ return symbol.startswith(("sh000", "sz399"))
66
+
67
+
68
+ def fund_code(code):
69
+ symbol = normalize_symbol(code, "etf")
70
+ return symbol[2:]
71
+
72
+
73
+ def sorted_rows(rows, key="datetime"):
74
+ unique = {row[key]: row for row in rows}
75
+ return [unique[value] for value in sorted(unique)]
76
+
77
+
78
+ def source_order(sources, default, allowed):
79
+ """验证并保留来源优先顺序。"""
80
+ values = default if sources is None else sources
81
+ if isinstance(values, str) or not isinstance(values, Sequence):
82
+ raise ConfigurationError("sources 使用按优先级排列的来源列表")
83
+ if not values or any(
84
+ not isinstance(value, str) or value not in allowed for value in values
85
+ ):
86
+ raise ConfigurationError("sources 可用来源:" + ", ".join(allowed))
87
+ return tuple(dict.fromkeys(values))