720 lines
27 KiB
Python
720 lines
27 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import urllib.error
|
|
import urllib.request
|
|
from collections.abc import Callable
|
|
from dataclasses import replace
|
|
from datetime import datetime, timedelta
|
|
from typing import Any
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from backend.data.contracts import (
|
|
DataSource,
|
|
DataUsage,
|
|
ObservationMetadata,
|
|
ProviderResult,
|
|
SnapshotState,
|
|
)
|
|
from backend.data.providers.base import ProviderError
|
|
|
|
SHANGHAI = ZoneInfo("Asia/Shanghai")
|
|
|
|
|
|
class TushareProvider:
|
|
source = DataSource.TUSHARE
|
|
url = "http://api.tushare.pro"
|
|
|
|
def __init__(self, token: str | None | Callable[[], str | None], timeout: int = 20) -> None:
|
|
self._token_provider = token if callable(token) else lambda: token
|
|
self._timeout = timeout
|
|
|
|
@property
|
|
def configured(self) -> bool:
|
|
return bool(self._token())
|
|
|
|
def calendar(self, start_date: str, end_date: str) -> ProviderResult:
|
|
result = self._query(
|
|
"trade_cal",
|
|
{"exchange": "SSE", "start_date": _compact(start_date), "end_date": _compact(end_date)},
|
|
"cal_date,is_open,pretrade_date",
|
|
unit="calendar_day",
|
|
)
|
|
days = (
|
|
datetime.fromisoformat(end_date).date() - datetime.fromisoformat(start_date).date()
|
|
).days + 1
|
|
return ProviderResult(
|
|
result.rows,
|
|
replace(result.metadata, coverage=min(len(result.rows) / max(days, 1), 1)),
|
|
)
|
|
|
|
def entities(self) -> ProviderResult:
|
|
rows: list[dict[str, Any]] = []
|
|
for status in ("L", "P", "D"):
|
|
result = self._query(
|
|
"stock_basic",
|
|
{"exchange": "", "list_status": status},
|
|
"ts_code,symbol,name,industry,list_status,list_date,delist_date",
|
|
unit="entity",
|
|
)
|
|
rows.extend(result.rows)
|
|
return ProviderResult(
|
|
tuple(rows), _metadata(self.source, "entity", min(len(rows) / 5300, 1))
|
|
)
|
|
|
|
def daily(self, entity_type: str, identifier: str, end_date: str) -> ProviderResult:
|
|
api_name = "index_daily" if entity_type == "index" else "daily"
|
|
if entity_type in {"sector", "theme"}:
|
|
api_name = "ths_daily"
|
|
end = datetime.strptime(_compact(end_date), "%Y%m%d")
|
|
start = (end - timedelta(days=380)).strftime("%Y%m%d")
|
|
return self._query(
|
|
api_name,
|
|
{"ts_code": identifier, "start_date": start, "end_date": end.strftime("%Y%m%d")},
|
|
"ts_code,trade_date,open,high,low,close,vol,amount,pct_chg",
|
|
unit="yuan/share",
|
|
adjustment="unadjusted",
|
|
)
|
|
|
|
def minute(self, entity_type: str, identifier: str, trade_date: str) -> ProviderResult:
|
|
if entity_type != "stock":
|
|
raise ProviderError("Tushare minute charts only support stocks")
|
|
date = _display(trade_date)
|
|
return self._query(
|
|
"stk_mins",
|
|
{
|
|
"ts_code": identifier,
|
|
"freq": "1min",
|
|
"start_date": f"{date} 09:30:00",
|
|
"end_date": f"{date} 15:00:00",
|
|
},
|
|
"ts_code,trade_time,open,high,low,close,vol,amount",
|
|
unit="yuan/share",
|
|
)
|
|
|
|
def snapshot_inputs(
|
|
self, trade_date: str, previous_trade_date: str
|
|
) -> dict[str, ProviderResult | dict[str, Any]]:
|
|
current = _compact(trade_date)
|
|
previous = _compact(previous_trade_date)
|
|
daily = self._query(
|
|
"daily",
|
|
{"trade_date": current},
|
|
"ts_code,trade_date,open,high,low,close,pre_close,pct_chg,vol,amount",
|
|
unit="mixed",
|
|
)
|
|
event_fields = (
|
|
"trade_date,ts_code,industry,name,close,pct_chg,amount,limit_amount,"
|
|
"float_mv,total_mv,turnover_ratio,fd_amount,first_time,last_time,"
|
|
"open_times,up_stat,limit_times"
|
|
)
|
|
datasets: dict[str, ProviderResult | dict[str, Any]] = {"daily": daily}
|
|
datasets["price_limits"] = self._query(
|
|
"stk_limit",
|
|
{"trade_date": current},
|
|
"ts_code,trade_date,up_limit,down_limit",
|
|
unit="yuan/share",
|
|
)
|
|
for key, limit_type, date in (
|
|
("limit_up", "U", current),
|
|
("limit_down", "D", current),
|
|
("broken", "Z", current),
|
|
("previous_limit_up", "U", previous),
|
|
):
|
|
datasets[key] = self._query(
|
|
"limit_list_d",
|
|
{"trade_date": date, "limit_type": limit_type},
|
|
event_fields,
|
|
unit="mixed",
|
|
empty_is_complete=True,
|
|
)
|
|
return datasets
|
|
|
|
def realtime_market_inputs(
|
|
self,
|
|
trade_date: str,
|
|
previous_trade_date: str,
|
|
identifiers: tuple[str, ...],
|
|
) -> dict[str, ProviderResult | dict[str, Any]]:
|
|
current = _compact(trade_date)
|
|
previous = _compact(previous_trade_date)
|
|
quotes = self._query(
|
|
"rt_k",
|
|
{"ts_code": ",".join(identifiers)},
|
|
"",
|
|
unit="mixed",
|
|
)
|
|
normalized = tuple(
|
|
{
|
|
**row,
|
|
"trade_date": current,
|
|
"pct_chg": _change(row.get("close"), row.get("pre_close")),
|
|
"amount_unit": "yuan",
|
|
}
|
|
for row in quotes.rows
|
|
if _number(row.get("close")) > 0 and _number(row.get("pre_close")) > 0
|
|
)
|
|
daily = ProviderResult(
|
|
normalized,
|
|
replace(
|
|
quotes.metadata,
|
|
coverage=min(len(normalized) / max(len(identifiers), 1), 1),
|
|
state=SnapshotState.REALTIME,
|
|
),
|
|
)
|
|
return {
|
|
"daily": daily,
|
|
"price_limits": self._query(
|
|
"stk_limit",
|
|
{"trade_date": current},
|
|
"ts_code,trade_date,up_limit,down_limit",
|
|
unit="yuan/share",
|
|
),
|
|
"previous_limit_up": self._query(
|
|
"limit_list_d",
|
|
{"trade_date": previous, "limit_type": "U"},
|
|
(
|
|
"trade_date,ts_code,industry,name,close,pct_chg,amount,limit_amount,"
|
|
"float_mv,total_mv,turnover_ratio,fd_amount,first_time,last_time,"
|
|
"open_times,up_stat,limit_times"
|
|
),
|
|
unit="mixed",
|
|
empty_is_complete=True,
|
|
),
|
|
}
|
|
|
|
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
|
|
target = _compact(trade_date)
|
|
industry, members = self._sector_memberships(representative, target)
|
|
sector_code = str(industry.get("l2_code") or "")
|
|
sector_name = str(industry.get("l2_name") or "").strip()
|
|
daily = self._query(
|
|
"daily",
|
|
{"trade_date": target},
|
|
"ts_code,trade_date,open,close,pct_chg,amount",
|
|
unit="mixed",
|
|
)
|
|
quote_map = {str(row.get("ts_code") or ""): row for row in daily.rows}
|
|
rows = []
|
|
for member in members:
|
|
code = str(member.get("ts_code") or "")
|
|
quote = quote_map.get(code) or {}
|
|
rows.append(
|
|
{
|
|
"sector_code": sector_code,
|
|
"sector_name": sector_name,
|
|
"ts_code": code,
|
|
"name": str(member.get("name") or "").strip(),
|
|
"change": _number(quote.get("pct_chg")) if quote else None,
|
|
"open": _number(quote.get("open")) if quote else None,
|
|
"close": _number(quote.get("close")) if quote else None,
|
|
"amount": _number(quote.get("amount")) * 1000 if quote else None,
|
|
"quoted": bool(quote),
|
|
}
|
|
)
|
|
coverage = sum(bool(row["quoted"]) for row in rows) / len(rows)
|
|
return ProviderResult(tuple(rows), _metadata(self.source, "mixed", coverage))
|
|
|
|
def heaven_inputs(
|
|
self, representative: str, trade_date: str
|
|
) -> dict[str, ProviderResult | None]:
|
|
target = _compact(trade_date)
|
|
membership = self.sector_members(representative, trade_date)
|
|
sector_code = str(membership.rows[0].get("sector_code") or "") if membership.rows else ""
|
|
return {
|
|
"members": membership,
|
|
"daily": self._optional_query(
|
|
"daily",
|
|
{"trade_date": target},
|
|
"ts_code,trade_date,open,high,low,close,pre_close,pct_chg,vol,amount",
|
|
),
|
|
"daily_basic": self._optional_query(
|
|
"daily_basic",
|
|
{"trade_date": target},
|
|
"ts_code,trade_date,turnover_rate,volume_ratio,total_mv,circ_mv",
|
|
),
|
|
"sector_daily": self._optional_query(
|
|
"sw_daily",
|
|
{"ts_code": sector_code, "trade_date": target},
|
|
"ts_code,trade_date,name,open,high,low,close,pct_change,vol,amount,pe,pb,float_mv,total_mv",
|
|
),
|
|
"indices": self._index_rows(target),
|
|
}
|
|
|
|
def heaven_realtime_inputs(
|
|
self, representative: str, trade_date: str, previous_trade_date: str
|
|
) -> dict[str, ProviderResult | None]:
|
|
target = _compact(trade_date)
|
|
previous = _compact(previous_trade_date)
|
|
industry, members = self._sector_memberships(representative, target)
|
|
sector_code = str(industry.get("l2_code") or "")
|
|
directory = self._optional_query(
|
|
"stock_basic",
|
|
{"exchange": "", "list_status": "L"},
|
|
"ts_code,name,industry,market,list_date",
|
|
)
|
|
active_codes = tuple(
|
|
str(row.get("ts_code") or "")
|
|
for row in (directory.rows if directory else ())
|
|
if row.get("ts_code")
|
|
)
|
|
realtime_codes = (*active_codes, "000001.SH", "399001.SZ", "399006.SZ")
|
|
member_rows = tuple(
|
|
{
|
|
"sector_code": sector_code,
|
|
"sector_name": str(industry.get("l2_name") or "").strip(),
|
|
"ts_code": str(row.get("ts_code") or ""),
|
|
"name": str(row.get("name") or "").strip(),
|
|
}
|
|
for row in members
|
|
)
|
|
start = (datetime.strptime(target, "%Y%m%d") - timedelta(days=35)).strftime("%Y%m%d")
|
|
return {
|
|
"directory": directory,
|
|
"members": ProviderResult(
|
|
member_rows,
|
|
_metadata(self.source, "membership", 1 if member_rows else 0),
|
|
),
|
|
"realtime": self._optional_query(
|
|
"rt_k",
|
|
{"ts_code": ",".join(realtime_codes)},
|
|
"ts_code,name,trade_time,open,high,low,close,pre_close,vol,amount,num,pct_chg",
|
|
),
|
|
"capital": self._optional_query(
|
|
"daily_basic",
|
|
{"trade_date": previous},
|
|
"ts_code,trade_date,total_share,float_share,free_share,total_mv,circ_mv",
|
|
),
|
|
"stock_history": self._optional_query(
|
|
"daily",
|
|
{"ts_code": representative, "start_date": start, "end_date": previous},
|
|
"ts_code,trade_date,vol,amount",
|
|
),
|
|
"price_limits": self._optional_query(
|
|
"stk_limit",
|
|
{"trade_date": target},
|
|
"ts_code,trade_date,up_limit,down_limit",
|
|
),
|
|
"suspensions": self._optional_query(
|
|
"suspend_d",
|
|
{"suspend_date": target},
|
|
"ts_code,suspend_date,resume_date,suspend_timing,suspend_type",
|
|
),
|
|
"sector_realtime": self._optional_query(
|
|
"rt_sw_k",
|
|
{"ts_code": sector_code},
|
|
"ts_code,name,trade_time,close,pre_close,high,open,low,vol,amount,pct_change",
|
|
),
|
|
}
|
|
|
|
def _index_rows(self, trade_date: str) -> ProviderResult | None:
|
|
rows: list[dict[str, Any]] = []
|
|
completed = 0
|
|
for identifier in ("000001.SH", "399001.SZ", "399006.SZ"):
|
|
result = self._optional_query(
|
|
"index_daily",
|
|
{"ts_code": identifier, "trade_date": trade_date},
|
|
"ts_code,trade_date,close,pre_close,pct_chg",
|
|
)
|
|
if result is not None and result.rows:
|
|
rows.extend(result.rows)
|
|
completed += 1
|
|
if not rows:
|
|
return None
|
|
return ProviderResult(tuple(rows), _metadata(self.source, "percent", completed / 3))
|
|
|
|
def market_insight(
|
|
self,
|
|
kind: str,
|
|
trade_date: str,
|
|
previous_trade_date: str = "",
|
|
identifier: str = "",
|
|
) -> dict[str, ProviderResult | None]:
|
|
current = _compact(trade_date)
|
|
previous = _compact(previous_trade_date) if previous_trade_date else current
|
|
if kind == "auction":
|
|
return {
|
|
"auction": self._optional_query(
|
|
"stk_auction",
|
|
{"trade_date": current},
|
|
"ts_code,trade_date,vol,price,amount,pre_close,turnover_rate,"
|
|
"volume_ratio,float_share",
|
|
),
|
|
"price_limits": self._optional_query(
|
|
"stk_limit",
|
|
{"trade_date": current},
|
|
"trade_date,ts_code,up_limit,down_limit",
|
|
),
|
|
"ths_hot": self._optional_query("ths_hot", {"trade_date": previous}, ""),
|
|
"dc_hot": self._optional_query("dc_hot", {"trade_date": previous}, ""),
|
|
}
|
|
if kind == "themes":
|
|
return {
|
|
"directory": self._optional_query(
|
|
"ths_index", {}, "ts_code,name,count,exchange,list_date,type"
|
|
),
|
|
"daily": self._optional_query(
|
|
"ths_daily",
|
|
{"trade_date": current},
|
|
"ts_code,trade_date,open,high,low,close,pre_close,pct_change,vol,turnover_rate",
|
|
),
|
|
"hot": self._optional_query("ths_hot", {"trade_date": current}, ""),
|
|
}
|
|
if kind == "theme-detail":
|
|
return {
|
|
"members": self._optional_query(
|
|
"ths_member",
|
|
{"ts_code": identifier, "is_new": "Y"},
|
|
"ts_code,con_code,con_name",
|
|
),
|
|
"daily": self._optional_query(
|
|
"daily",
|
|
{"trade_date": current},
|
|
"ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
|
|
),
|
|
}
|
|
if kind == "popularity":
|
|
return {
|
|
"ths": self._optional_query("ths_hot", {"trade_date": current}, ""),
|
|
"dc": self._optional_query("dc_hot", {"trade_date": current}, ""),
|
|
"previous_ths": self._optional_query("ths_hot", {"trade_date": previous}, ""),
|
|
"previous_dc": self._optional_query("dc_hot", {"trade_date": previous}, ""),
|
|
}
|
|
if kind == "dragon-list":
|
|
return {
|
|
"official": self._optional_query(
|
|
"hm_detail",
|
|
{"trade_date": current},
|
|
"trade_date,ts_code,ts_name,buy_amount,sell_amount,net_amount,"
|
|
"hm_name,hm_orgs,tag",
|
|
),
|
|
"profiles": self._optional_query("hm_list", {}, "name,desc,orgs"),
|
|
"stocks": self._optional_query(
|
|
"top_list",
|
|
{"trade_date": current},
|
|
"trade_date,ts_code,name,pct_change,reason",
|
|
),
|
|
"seats": self._optional_query(
|
|
"top_inst",
|
|
{"trade_date": current},
|
|
"trade_date,ts_code,exalter,buy,sell,net_buy,side,reason",
|
|
),
|
|
}
|
|
raise ProviderError("不支持的市场洞察数据集")
|
|
|
|
def realtime_snapshots(
|
|
self, identifiers: tuple[str, ...], start_time: str, end_time: str
|
|
) -> ProviderResult:
|
|
raise ProviderError("Tushare不提供动态竞价快照")
|
|
|
|
def event_reasons(self, trade_date: str) -> ProviderResult:
|
|
raise ProviderError("Tushare涨跌停榜单不提供可用的事件原因字段")
|
|
|
|
def screener_inputs(self, trade_dates: tuple[str, ...]) -> dict[str, ProviderResult | None]:
|
|
if len(trade_dates) < 21:
|
|
raise ProviderError("选股因子至少需要21个交易日")
|
|
compact_dates = tuple(_compact(value) for value in trade_dates)
|
|
current = compact_dates[-1]
|
|
quarters = _quarter_periods(current, 5)
|
|
years = tuple(f"{int(current[:4]) - offset}1231" for offset in range(1, 6))
|
|
return {
|
|
"directory": self._optional_query(
|
|
"stock_basic",
|
|
{"exchange": "", "list_status": "L"},
|
|
"ts_code,symbol,name,industry,market,list_date,list_status",
|
|
),
|
|
"industry": self._optional_query(
|
|
"index_member_all",
|
|
{"is_new": "Y"},
|
|
"l1_code,l1_name,l2_code,l2_name,ts_code,name,in_date,out_date,is_new",
|
|
),
|
|
"daily": self._series_query(
|
|
"daily",
|
|
compact_dates,
|
|
"ts_code,trade_date,open,high,low,close,pre_close,pct_chg,vol,amount",
|
|
),
|
|
"daily_basic": self._series_query(
|
|
"daily_basic",
|
|
compact_dates[-5:],
|
|
"ts_code,trade_date,turnover_rate,volume_ratio,pe_ttm,pb,ps_ttm,dv_ttm,"
|
|
"total_mv,circ_mv",
|
|
),
|
|
"moneyflow": self._series_query(
|
|
"moneyflow",
|
|
compact_dates[-5:],
|
|
"ts_code,trade_date,buy_lg_amount,sell_lg_amount,buy_elg_amount,"
|
|
"sell_elg_amount,net_mf_amount",
|
|
),
|
|
"benchmark": self._optional_query(
|
|
"index_daily",
|
|
{
|
|
"ts_code": "000300.SH",
|
|
"start_date": compact_dates[0],
|
|
"end_date": current,
|
|
},
|
|
"ts_code,trade_date,close,pct_chg",
|
|
),
|
|
"fundamentals": self._period_query(
|
|
"fina_indicator",
|
|
quarters,
|
|
"ts_code,ann_date,end_date,roe,roa,roic,grossprofit_margin,"
|
|
"netprofit_yoy,or_yoy,ocf_to_or",
|
|
),
|
|
"dividends": self._period_query(
|
|
"dividend",
|
|
years,
|
|
"ts_code,end_date,ann_date,div_proc,cash_div_tax,ex_date",
|
|
parameter="end_date",
|
|
),
|
|
"auction": self._optional_query(
|
|
"stk_auction",
|
|
{"trade_date": current},
|
|
"ts_code,trade_date,price,pre_close,amount,turnover_rate,volume_ratio",
|
|
),
|
|
"limit_events": self._series_query(
|
|
"limit_list_d",
|
|
compact_dates[-80:],
|
|
"trade_date,ts_code,name,limit_type,limit_times",
|
|
empty_is_complete=True,
|
|
),
|
|
"forecast": self._period_query(
|
|
"forecast",
|
|
quarters,
|
|
"ts_code,ann_date,end_date,type,p_change_min,p_change_max,"
|
|
"net_profit_min,net_profit_max,last_parent_net",
|
|
),
|
|
"express": self._period_query(
|
|
"express",
|
|
quarters,
|
|
"ts_code,ann_date,end_date,revenue,operate_profit,total_profit,n_income,"
|
|
"total_assets,diluted_roe,yoy_net_profit",
|
|
),
|
|
}
|
|
|
|
def _series_query(
|
|
self,
|
|
api_name: str,
|
|
dates: tuple[str, ...],
|
|
fields: str,
|
|
*,
|
|
empty_is_complete: bool = False,
|
|
) -> ProviderResult | None:
|
|
rows: list[dict[str, Any]] = []
|
|
completed = 0
|
|
for trade_date in dates:
|
|
try:
|
|
result = self._query(
|
|
api_name,
|
|
{"trade_date": trade_date},
|
|
fields,
|
|
unit="mixed",
|
|
empty_is_complete=empty_is_complete,
|
|
)
|
|
except ProviderError:
|
|
continue
|
|
rows.extend(result.rows)
|
|
completed += 1
|
|
if completed == 0:
|
|
return None
|
|
return ProviderResult(
|
|
tuple(rows),
|
|
_metadata(self.source, "mixed", completed / len(dates)),
|
|
)
|
|
|
|
def _period_query(
|
|
self,
|
|
api_name: str,
|
|
periods: tuple[str, ...],
|
|
fields: str,
|
|
*,
|
|
parameter: str = "period",
|
|
) -> ProviderResult | None:
|
|
rows: list[dict[str, Any]] = []
|
|
completed = 0
|
|
for period in periods:
|
|
try:
|
|
result = self._query(
|
|
api_name,
|
|
{parameter: period},
|
|
fields,
|
|
unit="mixed",
|
|
empty_is_complete=True,
|
|
)
|
|
except ProviderError:
|
|
continue
|
|
rows.extend(result.rows)
|
|
completed += 1
|
|
if completed == 0:
|
|
return None
|
|
return ProviderResult(
|
|
tuple(rows),
|
|
_metadata(self.source, "mixed", completed / len(periods)),
|
|
)
|
|
|
|
def _optional_query(
|
|
self, api_name: str, params: dict[str, Any], fields: str
|
|
) -> ProviderResult | None:
|
|
try:
|
|
return self._query(
|
|
api_name,
|
|
params,
|
|
fields,
|
|
unit="mixed",
|
|
empty_is_complete=True,
|
|
)
|
|
except ProviderError:
|
|
return None
|
|
|
|
def _membership_rows(self, params: dict[str, str]) -> list[dict[str, Any]]:
|
|
rows: list[dict[str, Any]] = []
|
|
fields = (
|
|
"l1_code,l1_name,l2_code,l2_name,l3_code,l3_name,ts_code,name,in_date,out_date,is_new"
|
|
)
|
|
for is_new in ("Y", "N"):
|
|
result = self._query(
|
|
"index_member_all",
|
|
{**params, "is_new": is_new},
|
|
fields,
|
|
unit="membership",
|
|
empty_is_complete=True,
|
|
)
|
|
rows.extend(result.rows)
|
|
return rows
|
|
|
|
def _sector_memberships(
|
|
self, representative: str, trade_date: str
|
|
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
active = [
|
|
row
|
|
for row in self._membership_rows({"ts_code": representative})
|
|
if _active_on(row, trade_date)
|
|
]
|
|
if not active:
|
|
raise ProviderError("未找到该股票在目标日期的申万行业")
|
|
industry = max(
|
|
active,
|
|
key=lambda row: (str(row.get("in_date") or ""), str(row.get("l2_code") or "")),
|
|
)
|
|
sector_code = str(industry.get("l2_code") or "")
|
|
if not sector_code:
|
|
raise ProviderError("该股票缺少申万二级行业")
|
|
rows = [
|
|
row
|
|
for row in self._membership_rows({"l2_code": sector_code})
|
|
if _active_on(row, trade_date)
|
|
]
|
|
deduplicated: dict[str, dict[str, Any]] = {}
|
|
for row in rows:
|
|
code = str(row.get("ts_code") or "")
|
|
current = deduplicated.get(code)
|
|
if code and (
|
|
current is None or str(row.get("in_date") or "") > str(current.get("in_date") or "")
|
|
):
|
|
deduplicated[code] = row
|
|
if not deduplicated:
|
|
raise ProviderError("该申万行业没有有效成分股")
|
|
return industry, list(deduplicated.values())
|
|
|
|
def _query(
|
|
self,
|
|
api_name: str,
|
|
params: dict[str, Any],
|
|
fields: str,
|
|
*,
|
|
unit: str,
|
|
adjustment: str = "not_applicable",
|
|
empty_is_complete: bool = False,
|
|
) -> ProviderResult:
|
|
if not self.configured:
|
|
raise ProviderError("行情服务尚未配置")
|
|
token = self._token()
|
|
if not token:
|
|
raise ProviderError("行情服务尚未配置")
|
|
body = json.dumps(
|
|
{"api_name": api_name, "token": token, "params": params, "fields": fields},
|
|
ensure_ascii=False,
|
|
).encode("utf-8")
|
|
request = urllib.request.Request(
|
|
self.url,
|
|
data=body,
|
|
headers={"Content-Type": "application/json", "User-Agent": "XiaobaiReview/2"},
|
|
method="POST",
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=self._timeout) as response:
|
|
payload = json.loads(response.read().decode("utf-8"))
|
|
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
|
|
raise ProviderError("行情服务请求失败") from exc
|
|
if payload.get("code") not in (None, 0):
|
|
raise ProviderError(str(payload.get("msg") or "行情服务拒绝请求"))
|
|
data = payload.get("data") or {}
|
|
columns = data.get("fields") or []
|
|
rows = tuple(dict(zip(columns, item, strict=False)) for item in data.get("items") or [])
|
|
coverage = 1 if rows or empty_is_complete else 0
|
|
return ProviderResult(rows, _metadata(self.source, unit, coverage, adjustment))
|
|
|
|
def _token(self) -> str:
|
|
return str(self._token_provider() or "").strip()
|
|
|
|
|
|
def _metadata(
|
|
source: DataSource, unit: str, coverage: float, adjustment: str = "not_applicable"
|
|
) -> ObservationMetadata:
|
|
return ObservationMetadata(
|
|
source=source,
|
|
observed_at=datetime.now(SHANGHAI),
|
|
unit=unit,
|
|
adjustment=adjustment,
|
|
freshness_seconds=0,
|
|
coverage=coverage,
|
|
state=SnapshotState.ARCHIVE,
|
|
usage=DataUsage.CALCULATION,
|
|
)
|
|
|
|
|
|
def _compact(value: str) -> str:
|
|
normalized = value.replace("-", "")
|
|
if len(normalized) != 8 or not normalized.isdigit():
|
|
raise ProviderError("日期格式无效")
|
|
return normalized
|
|
|
|
|
|
def _display(value: str) -> str:
|
|
compact = _compact(value)
|
|
return f"{compact[:4]}-{compact[4:6]}-{compact[6:]}"
|
|
|
|
|
|
def _change(close: Any, previous: Any) -> float:
|
|
current = _number(close)
|
|
prior = _number(previous)
|
|
return round((current / prior - 1) * 100, 4) if current > 0 and prior > 0 else 0.0
|
|
|
|
|
|
def _quarter_periods(through: str, count: int) -> tuple[str, ...]:
|
|
year = int(through[:4])
|
|
quarter = (int(through[4:6]) - 1) // 3
|
|
periods = []
|
|
for offset in range(count + 4):
|
|
index = year * 4 + quarter - offset
|
|
period_year, period_quarter = divmod(index, 4)
|
|
period = f"{period_year}{('0331', '0630', '0930', '1231')[period_quarter]}"
|
|
if period <= through:
|
|
periods.append(period)
|
|
if len(periods) == count:
|
|
break
|
|
return tuple(reversed(periods))
|
|
|
|
|
|
def _active_on(row: dict[str, Any], trade_date: str) -> bool:
|
|
start = str(row.get("in_date") or "")
|
|
end = str(row.get("out_date") or "")
|
|
return (not start or start <= trade_date) and (not end or end > trade_date)
|
|
|
|
|
|
def _number(value: Any) -> float:
|
|
try:
|
|
number = float(value)
|
|
return number if number == number else 0.0
|
|
except (TypeError, ValueError):
|
|
return 0.0
|