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
145 lines
5.8 KiB
Python
145 lines
5.8 KiB
Python
"""同步任务:股本快照(雪球 quote_detail)。
|
||
|
||
写入两张表:
|
||
- stocks.total_share / float_share / share_updated_at(基础信息表)
|
||
- share(独立股本时序表)
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
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.datasource.registry import is_source_ready
|
||
from app.core.datasource.utils import is_a_share_code, to_code6
|
||
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.share")
|
||
|
||
|
||
class SyncShareSnapshot(SyncTask):
|
||
dataset_id = "share_snapshot"
|
||
|
||
def _run(
|
||
self,
|
||
*,
|
||
trigger_source: str = "manual",
|
||
codes: list[str] | None = None,
|
||
max_workers: int = 10,
|
||
**kwargs,
|
||
) -> dict[str, Any]:
|
||
if not settings.xueqiu_token:
|
||
mark_sync_blocked(self.dataset_id, message="未配置 XUEQIU_TOKEN")
|
||
return {"status": "blocked", "message": "未配置 XUEQIU_TOKEN"}
|
||
|
||
xq = ds_registry.get("datasource_xueqiu")
|
||
if xq is None:
|
||
return {"status": "error", "message": "雪球数据源未注册"}
|
||
ok, reason = is_source_ready(xq.key)
|
||
if not ok:
|
||
mark_sync_blocked(self.dataset_id, message=f"雪球未就绪: {reason}")
|
||
return {"status": "blocked", "message": f"雪球未就绪: {reason}"}
|
||
|
||
if codes:
|
||
stock_codes = [to_code6(c) for c in codes]
|
||
else:
|
||
stock_codes = [c for c in db_ops.iter_stock_codes(active_only=True) if is_a_share_code(c)]
|
||
limit_raw = os.environ.get("MARKET_DATA_STOCK_LIMIT", "").strip()
|
||
if limit_raw.isdigit() and int(limit_raw) > 0:
|
||
stock_codes = stock_codes[: int(limit_raw)]
|
||
|
||
if not stock_codes:
|
||
return {"status": "error", "message": "无股票代码"}
|
||
|
||
# 跳过今日已刷过的
|
||
today = time.strftime("%Y-%m-%d")
|
||
existing = {s["code"]: s for s in db_ops.fetch_all_stocks()}
|
||
todo = []
|
||
for c6 in stock_codes:
|
||
old = existing.get(c6, {})
|
||
if str(old.get("share_updated_at", "") or "") == today and old.get("total_share", 0) > 0:
|
||
continue
|
||
todo.append(c6)
|
||
skip = len(stock_codes) - len(todo)
|
||
if not todo:
|
||
return {"status": "ok", "message": f"全部 {skip} 只已最新,跳过"}
|
||
|
||
total = len(todo)
|
||
ok_cnt = fail_cnt = 0
|
||
rows_to_share_table: list[dict] = []
|
||
t0 = time.time()
|
||
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||
futures = {pool.submit(xq.fetch_share_snapshot, c6): c6 for c6 in todo}
|
||
for i, future in enumerate(as_completed(futures), 1):
|
||
c6 = futures[future]
|
||
try:
|
||
r = future.result()
|
||
except Exception as e:
|
||
fail_cnt += 1
|
||
logger.warning(f"[share {c6}] 异常: {e}")
|
||
continue
|
||
if r is None:
|
||
fail_cnt += 1
|
||
continue
|
||
# 写 stocks + share 表 —— 两张表 stock_code 都用 hermes(带 SH/SZ 前缀)
|
||
# 与 stocks.code 保持一致,方便 JOIN。
|
||
# 历史 bug:之前 share 表写的是裸 6 位 c6,导致 share JOIN stocks 失败。
|
||
# 一次性 UPDATE(2026-07-01)已把存量 6.3 万行加上前缀。
|
||
# trade_date 必须用源数据返回的交易日(数据源层负责"最近已完成交易日"
|
||
# 兜底),不允许用 today 兜底 — 7月9日 早盘跑任务时 today=7月9日,
|
||
# 但股本快照实际对应 7月8日 收盘。早期代码用 today 会让 share 表里
|
||
# 同一股本被重复写多行,统计/回溯出错。
|
||
trade_date = r.get("trade_date")
|
||
if not trade_date:
|
||
fail_cnt += 1
|
||
logger.warning(f"[share {c6}] 源数据未返回 trade_date,跳过(避免用运行日兜底)")
|
||
continue
|
||
hermes = self._to_hermes(c6)
|
||
db_ops.update_stock_share_snapshot(
|
||
code=hermes,
|
||
total_share=r["total_share"],
|
||
float_share=r["float_share"],
|
||
trade_date=trade_date,
|
||
)
|
||
rows_to_share_table.append({
|
||
"stock_code": hermes,
|
||
"trade_date": trade_date,
|
||
"total_share": r["total_share"],
|
||
"float_share": r["float_share"],
|
||
})
|
||
ok_cnt += 1
|
||
if i % 50 == 0 or i == total:
|
||
self._progress(
|
||
message=f"股本 {i}/{total} OK:{ok_cnt} FAIL:{fail_cnt}",
|
||
current=i, total=total, current_step=c6,
|
||
)
|
||
# 批量写 share 表
|
||
if rows_to_share_table:
|
||
try:
|
||
db_ops.upsert_share(rows_to_share_table)
|
||
except Exception as e:
|
||
logger.warning(f"写 share 表失败: {e}")
|
||
|
||
elapsed = round(time.time() - t0, 1)
|
||
msg = f"股本 {ok_cnt}成 {fail_cnt}败 {skip}跳, {elapsed}s"
|
||
return {
|
||
"status": "ok" if fail_cnt == 0 else "warning",
|
||
"message": msg,
|
||
"ok": ok_cnt, "fail": fail_cnt, "skip": skip,
|
||
"elapsed_sec": elapsed,
|
||
}
|
||
|
||
@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}"
|