8f016f25df
- ORM migration: models/ops 重构 - deploy: 标准化 systemd timer/service 部署体系 - MCP server: 5 个只读工具(任务/状态/数据集) - daily check: 数据一致性巡检 - watch: task_watch 后台监控 - kline_daily: 麦蕊优先,时间门禁15:00 - 删除: task_industry_sector, task_sector_features
229 lines
8.5 KiB
Python
229 lines
8.5 KiB
Python
"""同步任务:全市场股票基础信息 + 股本快照。
|
||
|
||
数据流:
|
||
麦蕊智数 hslt/list(主)→ Baostock.query_stock_basic()(降级)→ 解析 → upsert stocks
|
||
可选:雪球 quote_detail 刷新股本(需要 XUEQIU_TOKEN)
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
import threading
|
||
import time
|
||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||
from typing import Any
|
||
|
||
from app.core.config import settings
|
||
from app.core.db import ops as db_ops
|
||
from app.core.datasource.base import registry as ds_registry
|
||
from app.core.sync.base import SyncTask
|
||
from app.core.sync.registry import mark_sync_blocked
|
||
from app.core.utils.logging import get_logger
|
||
|
||
logger = get_logger("sync.stocks_basic")
|
||
|
||
|
||
def _derive_listing_status(name: str) -> str:
|
||
n = name.strip()
|
||
if n.startswith("*ST") or n.startswith("ST"):
|
||
return "st"
|
||
return "normal"
|
||
|
||
|
||
class SyncStocksBasic(SyncTask):
|
||
dataset_id = "stock_basic"
|
||
|
||
def _run(self, *, trigger_source: str = "manual", with_share: bool = True, **kwargs) -> dict[str, Any]:
|
||
self._progress(message="选择股票基础信息数据源...")
|
||
|
||
rows: list[dict] = []
|
||
source_name = ""
|
||
|
||
# 1. 优先使用麦蕊智数
|
||
mairui = ds_registry.get("datasource_mairui")
|
||
if mairui is not None:
|
||
ok, msg = mairui.is_available()
|
||
if ok:
|
||
self._progress(message="使用麦蕊智数获取股票列表...")
|
||
rows = mairui.fetch_stock_list()
|
||
if rows:
|
||
source_name = "麦蕊智数"
|
||
self._progress(message=f"麦蕊智数返回 {len(rows)} 只股票")
|
||
|
||
# 2. 无数据则报错
|
||
if not rows:
|
||
return {"status": "error", "message": "麦蕊智数未返回股票数据"}
|
||
|
||
self._progress(
|
||
message=f"获取到 {len(rows)} 只 A 股,开始写入...",
|
||
current=0, total=len(rows),
|
||
)
|
||
|
||
new_cnt = upd_cnt = unchanged = 0
|
||
existing = {s["code"]: s for s in db_ops.fetch_all_stocks()}
|
||
|
||
# 准备批量写入(性能:executemany 一次写 500 条,比单条快 10-20 倍)
|
||
bulk_rows = []
|
||
for info in rows:
|
||
code = info["code"]
|
||
old = existing.get(code)
|
||
if old is None:
|
||
new_cnt += 1
|
||
elif (old.get("name") != info["name"]
|
||
or old.get("exchange") != info["exchange"]
|
||
or old.get("listing_status", "normal") != info["listing_status"]):
|
||
upd_cnt += 1
|
||
else:
|
||
unchanged += 1
|
||
bulk_rows.append({
|
||
"code": code,
|
||
"name": info["name"],
|
||
"exchange": info["exchange"],
|
||
"listing_status": info["listing_status"],
|
||
})
|
||
|
||
# 分块 executemany 写入 + 进度更新
|
||
CHUNK = 500
|
||
for i in range(0, len(bulk_rows), CHUNK):
|
||
db_ops.upsert_stocks_bulk(bulk_rows[i: i + CHUNK])
|
||
done = min(i + CHUNK, len(bulk_rows))
|
||
if done % 500 == 0 or done == len(bulk_rows):
|
||
self._progress(
|
||
message=f"写入数据库 {done}/{len(bulk_rows)}...",
|
||
current=done, total=len(bulk_rows),
|
||
current_step=bulk_rows[done - 1]["code"],
|
||
)
|
||
|
||
# 退市检测
|
||
delisted = 0
|
||
api_codes = {r["code"] for r in rows}
|
||
for code, old in existing.items():
|
||
if code not in api_codes and old.get("listing_status") != "delisted":
|
||
db_ops.upsert_stock(
|
||
code=code,
|
||
name=old.get("name", ""),
|
||
exchange=old.get("exchange", ""),
|
||
list_date=old.get("list_date", ""),
|
||
listing_status="delisted",
|
||
)
|
||
delisted += 1
|
||
|
||
msg = f"股票基础信息 {new_cnt}新 {upd_cnt}更 {unchanged}无变化 {delisted}退市"
|
||
self._progress(message=msg, current=len(rows), total=len(rows))
|
||
|
||
# 股本快照(可选 + 需要 token)
|
||
share_msg = ""
|
||
if with_share and settings.xueqiu_token:
|
||
self._progress(message="股票基础信息完成,开始刷新股本快照...")
|
||
xq = ds_registry.get("datasource_xueqiu")
|
||
if xq is not None:
|
||
try:
|
||
share_ok, share_skip, share_fail = self._refresh_shares(
|
||
xq, [r["code"] for r in rows]
|
||
)
|
||
share_msg = f";股本 {share_ok}成 {share_skip}跳 {share_fail}败"
|
||
except Exception as e:
|
||
share_msg = f";股本刷新异常: {e}"
|
||
else:
|
||
if with_share and not settings.xueqiu_token:
|
||
share_msg = ";未配置 XUEQIU_TOKEN,跳过股本刷新"
|
||
mark_sync_blocked(self.dataset_id, message=msg + share_msg)
|
||
|
||
return {
|
||
"status": "ok" if not share_msg.startswith(";未配置") else "blocked",
|
||
"source": source_name,
|
||
"message": f"[{source_name}] {msg}{share_msg}",
|
||
"total": len(rows),
|
||
"new": new_cnt,
|
||
"updated": upd_cnt,
|
||
"delisted": delisted,
|
||
"unchanged": unchanged,
|
||
}
|
||
|
||
def _fetch_with_timeout(self, bs, *, timeout: int) -> list[dict]:
|
||
result: list = [None]
|
||
exc: list = [None]
|
||
|
||
def _do():
|
||
try:
|
||
result[0] = bs.fetch_stock_basic()
|
||
except Exception as e:
|
||
exc[0] = e
|
||
|
||
t = threading.Thread(target=_do, daemon=True)
|
||
t.start()
|
||
t.join(timeout)
|
||
if t.is_alive():
|
||
logger.warning(f"Baostock 拉取超时({timeout}s)")
|
||
return []
|
||
if exc[0]:
|
||
raise exc[0]
|
||
return result[0] or []
|
||
|
||
def _refresh_shares(
|
||
self, xq, codes: list[str], max_workers: int = 10,
|
||
) -> tuple[int, int, int]:
|
||
code6_list = []
|
||
for c in codes:
|
||
c6 = re.sub(r"^(SH|SZ|BJ)", "", c)
|
||
if c6.isdigit() and len(c6) == 6:
|
||
code6_list.append(c6)
|
||
code6_list = sorted(set(code6_list))
|
||
|
||
today = time.strftime("%Y-%m-%d")
|
||
existing = {s["code"]: s for s in db_ops.fetch_all_stocks()}
|
||
todo = []
|
||
for c6 in code6_list:
|
||
old = existing.get(c6, {})
|
||
if str(old.get("share_updated_at", "") or "") == today and old.get("total_share", 0) > 0:
|
||
continue
|
||
todo.append(c6)
|
||
if not todo:
|
||
return 0, len(code6_list), 0
|
||
|
||
ok = skip = fail = 0
|
||
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||
futures = {pool.submit(xq.fetch_share_snapshot, c6): c6 for c6 in todo}
|
||
done = 0
|
||
for future in as_completed(futures):
|
||
c6 = futures[future]
|
||
done += 1
|
||
try:
|
||
r = future.result()
|
||
except Exception as e:
|
||
fail += 1
|
||
logger.warning(f"[share {c6}] 异常: {e}")
|
||
continue
|
||
if r is None:
|
||
fail += 1
|
||
else:
|
||
# trade_date 必须是源数据返回的交易日(数据源层用 effective_market_date
|
||
# 兜底为"最近已完成交易日"),绝不允许用 today 兜底 — 7月9日 早盘跑
|
||
# 时 today=7月9日,但股本快照实际对应 7月8日 收盘。
|
||
trade_date = r.get("trade_date")
|
||
if not trade_date:
|
||
fail += 1
|
||
logger.warning(f"[share {c6}] 源数据未返回 trade_date,跳过(避免用运行日兜底)")
|
||
continue
|
||
db_ops.update_stock_share_snapshot(
|
||
code=self._to_hermes(c6),
|
||
total_share=r["total_share"],
|
||
float_share=r["float_share"],
|
||
trade_date=trade_date,
|
||
)
|
||
ok += 1
|
||
if done % 100 == 0:
|
||
self._progress(
|
||
message=f"刷新股本 {done}/{len(todo)}...",
|
||
current=done, total=len(todo), current_step=c6,
|
||
)
|
||
skip = len(code6_list) - len(todo)
|
||
return ok, skip, fail
|
||
|
||
@staticmethod
|
||
def _to_hermes(code6: str) -> str:
|
||
if code6.startswith(("5", "6", "9")):
|
||
return f"SH{code6}"
|
||
if code6.startswith(("4", "8")):
|
||
return f"BJ{code6}"
|
||
return f"SZ{code6}"
|