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.
- ashare_data/__init__.py +163 -0
- ashare_data/_convert.py +152 -0
- ashare_data/_default.py +36 -0
- ashare_data/_http.py +86 -0
- ashare_data/_utils.py +87 -0
- ashare_data/async_client.py +260 -0
- ashare_data/client.py +303 -0
- ashare_data/etf.py +61 -0
- ashare_data/exceptions.py +38 -0
- ashare_data/fallback.py +13 -0
- ashare_data/index.py +40 -0
- ashare_data/models.py +127 -0
- ashare_data/py.typed +0 -0
- ashare_data/sources/__init__.py +1 -0
- ashare_data/sources/eastmoney.py +160 -0
- ashare_data/sources/exchanges.py +45 -0
- ashare_data/sources/fred.py +73 -0
- ashare_data/sources/sina.py +87 -0
- ashare_data/sources/tencent.py +234 -0
- ashare_data/sources/twelvedata.py +113 -0
- ashare_data/stock.py +40 -0
- ashare_data_lite-0.1.0.dist-info/METADATA +134 -0
- ashare_data_lite-0.1.0.dist-info/RECORD +26 -0
- ashare_data_lite-0.1.0.dist-info/WHEEL +5 -0
- ashare_data_lite-0.1.0.dist-info/licenses/LICENSE +22 -0
- ashare_data_lite-0.1.0.dist-info/top_level.txt +1 -0
ashare_data/__init__.py
ADDED
|
@@ -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
|
+
]
|
ashare_data/_convert.py
ADDED
|
@@ -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
|
+
]
|
ashare_data/_default.py
ADDED
|
@@ -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))
|