05635b76b9
状态: - 9 个 sync task(stock_basic / kline_daily / kline_index / kline_5min / moneyflow / industry_sector / sector_features / share_snapshot / market_regime) - 数据源:baostock + mairui + 雪球(pysnowball) + 新浪(4 个) - 项目级约束:永远不用 akshare(已落实) - kline_5min 改用 DB 快照统一全量/增量逻辑 - 零后端 Chrome 扩展 xueqiu_sync(独立项目)
160 lines
6.0 KiB
Python
160 lines
6.0 KiB
Python
"""Baostock 数据源。
|
||
|
||
提供:
|
||
- 全市场股票基础信息(query_stock_basic)
|
||
- 日 K 线(query_history_k_data_plus,免费,但 QPS 限制需要信号量)
|
||
- 股票-行业映射(query_stock_industry)
|
||
|
||
免费、无 token,但需 bs.login() / bs.logout() 维护会话。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import threading
|
||
from typing import Any, Optional
|
||
|
||
import pandas as pd
|
||
|
||
from app.core.datasource.base import DataSource
|
||
from app.core.datasource.utils import (
|
||
code6_to_baostock,
|
||
normalize_kline,
|
||
)
|
||
|
||
|
||
class BaostockSource(DataSource):
|
||
key = "datasource_baostock"
|
||
name = "Baostock"
|
||
provides = ["kline_daily", "stock_basic", "industry"]
|
||
requires_credential = False
|
||
|
||
# 进程内只 login 一次
|
||
_login_lock = threading.Lock()
|
||
_logged_in = False
|
||
_semaphore = threading.BoundedSemaphore(3) # QPS 限流
|
||
|
||
def _ensure_login(self) -> None:
|
||
if self._logged_in:
|
||
return
|
||
with self._login_lock:
|
||
if self._logged_in:
|
||
return
|
||
import baostock as bs
|
||
bs.login()
|
||
self._logged_in = True
|
||
|
||
def is_available(self) -> tuple[bool, str]:
|
||
try:
|
||
self._ensure_login()
|
||
return True, "ok"
|
||
except Exception as e:
|
||
return False, f"Baostock 登录失败: {e}"
|
||
|
||
def health_check(self) -> dict[str, Any]:
|
||
"""连通性测试:复用 _ensure_login 避免重复 login 阻塞。"""
|
||
try:
|
||
self._ensure_login()
|
||
import baostock as bs
|
||
rs = bs.query_stock_basic(code="sh.600036")
|
||
if rs.error_code != "0":
|
||
return {"success": False, "message": f"Baostock 查询失败: {rs.error_msg}"}
|
||
rows = []
|
||
while rs.next():
|
||
rows.append(rs.get_row_data())
|
||
return {"success": True, "message": f"连接成功,查询到 {len(rows)} 条股票信息"}
|
||
except Exception as e:
|
||
return {"success": False, "message": f"Baostock 连接失败: {e}"}
|
||
|
||
def fetch_stock_basic(self) -> list[dict[str, Any]]:
|
||
"""全市场 A 股基础信息。
|
||
|
||
baostock query_stock_basic 返回字段(按当前接口):
|
||
code, code_name, ipoDate, outDate, type, status
|
||
按字段名索引,防止 Baostock 改顺序。
|
||
"""
|
||
import baostock as bs
|
||
self._ensure_login()
|
||
try:
|
||
rs = bs.query_stock_basic()
|
||
if rs.error_code != "0":
|
||
raise RuntimeError(f"Baostock query_stock_basic 失败: {rs.error_msg}")
|
||
fields = list(rs.fields)
|
||
idx = {name: i for i, name in enumerate(fields)}
|
||
rows = []
|
||
while rs.next():
|
||
row = rs.get_row_data()
|
||
bs_code = row[idx["code"]]
|
||
name = row[idx["code_name"]]
|
||
stype = row[idx["type"]]
|
||
status = row[idx["status"]]
|
||
ipo = row[idx.get("ipoDate", -1)] if "ipoDate" in idx else ""
|
||
if stype != "1" or status != "1":
|
||
continue
|
||
exchange = bs_code.split(".")[0].upper()
|
||
if exchange not in ("SH", "SZ", "BJ"):
|
||
continue
|
||
code6 = bs_code.split(".")[-1]
|
||
rows.append({
|
||
"code": f"{exchange}{code6}",
|
||
"name": name,
|
||
"exchange": exchange,
|
||
"list_date": ipo,
|
||
"listing_status": "st" if (name.startswith("ST") or name.startswith("*ST")) else "normal",
|
||
})
|
||
return rows
|
||
except Exception:
|
||
return []
|
||
|
||
def fetch_kline_daily(self, code6: str, start: str, end: str) -> pd.DataFrame:
|
||
import baostock as bs
|
||
self._ensure_login()
|
||
bs_code = code6_to_baostock(code6)
|
||
with self._semaphore:
|
||
try:
|
||
rs = bs.query_history_k_data_plus(
|
||
bs_code,
|
||
"date,open,high,low,close,volume",
|
||
start_date=start, end_date=end,
|
||
frequency="d", adjustflag="2", # 前复权
|
||
)
|
||
if rs.error_code != "0":
|
||
return pd.DataFrame()
|
||
rows = []
|
||
while rs.next():
|
||
rows.append(rs.get_row_data())
|
||
if not rows:
|
||
return pd.DataFrame()
|
||
df = pd.DataFrame(rows, columns=["trade_date", "open", "high", "low", "close", "volume"])
|
||
return normalize_kline(df)
|
||
except Exception:
|
||
return pd.DataFrame()
|
||
|
||
def fetch_industry_map(self) -> list[dict[str, Any]]:
|
||
"""全市场股票-行业映射。"""
|
||
import baostock as bs
|
||
self._ensure_login()
|
||
try:
|
||
rs = bs.query_stock_industry(code="", date="")
|
||
if rs.error_code != "0":
|
||
raise RuntimeError(f"query_stock_industry 失败: {rs.error_msg}")
|
||
fields = list(rs.fields) # 实际字段顺序:['updateDate', 'code', 'code_name', 'industry', 'industryClassification']
|
||
idx = {name: i for i, name in enumerate(fields)}
|
||
rows = []
|
||
while rs.next():
|
||
row = rs.get_row_data()
|
||
industry = row[idx.get("industry", 3)] if "industry" in idx else ""
|
||
if not industry:
|
||
continue
|
||
bs_code = row[idx.get("code", 1)] if "code" in idx else ""
|
||
code6 = bs_code.split(".")[-1]
|
||
classification = row[idx.get("industryClassification", 4)] if "industryClassification" in idx else ""
|
||
update = row[idx.get("updateDate", 0)] if "updateDate" in idx else ""
|
||
rows.append({
|
||
"code": code6,
|
||
"industry_name": industry.strip(),
|
||
"industry_classification": (classification or "").strip(),
|
||
"update_date": update or "",
|
||
})
|
||
return rows
|
||
except Exception:
|
||
return []
|