Files
stock/backend/app/data/etf_sync.py
2026-09-09 11:35:02 +08:00

343 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""ETF 全市场数据同步列表走东财快照K 线走 Tushare fund_daily
分工:
- 东财 push2 clist全市场 ETF 名单 + 总市值/流通市值/换手率quicksync 镜像上
fund_etf_basic 不存在,规模字段无替代源)-> etf_basic
- Tushare fund_dailyquicksync 可用,与股票 daily 同源同控频)-> candles
不复权底座单位换算与股票一致vol 手->份 ×100、amount 千元->元 ×1000
同步策略全串行Tushare 按分钟限频,并发无意义):
- 逐日模式:交易日历里尚无任何 ETF 日线的日期,一天一调用拿全市场基金日线
(过滤到 etf_basic 符号),日常增量通常只有当天 1 次调用;
- 逐只模式:无任何缓存的 ETF新上市/历史缺口)按 ts_code 全量拉取,每次 1 调用;
full=true 时对所有 ETF 重拉(修数/回补 amount 用)。
- 进程内后台任务(与 screener.market_sync 同款模式),前端轮询 /api/etf/sync/status
- 复权quicksync 无 fund_adj_factorETF 暂无因子qfq/hfq 切换时按无因子
原样返回api._adjust_bars 的既有语义)。
"""
from __future__ import annotations
import asyncio
import time
from datetime import datetime, timedelta
from sqlalchemy import delete, func, select, text
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from .. import cache
from ..config import settings
from . import etf_provider
from .sync_utils import call_retry, get_pro_lazy, utcnow
# 进程内单例任务状态uvicorn 单进程场景够用)
_state: dict = {
"running": False,
"step": None,
"total": 0, # 本次需处理的单元数(缺失交易日 + 需拉取的 ETF 只数)
"done": 0,
"error": None,
"started_at": None,
"finished_at": None,
}
_task: asyncio.Task | None = None
_lock = asyncio.Lock()
_BATCH = 3000 # upsert 分批行数asyncpg 单语句参数上限 3276610 列/行)
# fund_daily 返回全市场基金 ~2100 行,一天一批远小于上限
def _parse_d(s: str) -> datetime:
return datetime.strptime(str(s), "%Y%m%d")
async def _sync_spot(session: AsyncSession) -> int:
"""快照 -> etf_basicupsert + 删除已退市),返回列表行数。"""
from ..models import EtfBasic
async with etf_provider.new_client() as client:
rows = await etf_provider.fetch_etf_spot(client)
now = utcnow()
stmt = pg_insert(EtfBasic).values([{**r, "updated_at": now} for r in rows])
stmt = stmt.on_conflict_do_update(
index_elements=["ts_code"],
set_={
"name": stmt.excluded.name,
"exchange": stmt.excluded.exchange,
"total_mv": stmt.excluded.total_mv,
"circ_mv": stmt.excluded.circ_mv,
"turnover_rate": stmt.excluded.turnover_rate,
"updated_at": stmt.excluded.updated_at,
},
)
await session.execute(stmt)
# 快照外的 ETF 已退市(东财列表不再返回)
await session.execute(delete(EtfBasic).where(EtfBasic.ts_code.not_in({r["ts_code"] for r in rows})))
await session.commit()
return len(rows)
def _fetch_day_sync(pro, d: str) -> list[dict]:
"""拉某交易日全市场场内基金日线fund_daily未生成的日期返回空"""
time.sleep(settings.screener_sync_interval)
df = call_retry(pro.fund_daily, trade_date=d)
if df is None or df.empty:
return []
rows = []
for _, r in df.iterrows():
amt = r.get("amount")
rows.append({
"ts": _parse_d(d),
"ts_code": r["ts_code"],
"open": float(r["open"]), "high": float(r["high"]),
"low": float(r["low"]), "close": float(r["close"]),
"vol": float(r["vol"]), # 手
"amount": (float(amt) if amt is not None and amt == amt else None), # 千元
})
return rows
def _fetch_symbol_sync(pro, ts_code: str, start: str | None, end: str | None) -> list[dict]:
"""按 ts_code 增量/全量拉单只 ETF 日线start=None 即上市以来全量)。"""
time.sleep(settings.screener_sync_interval)
df = call_retry(pro.fund_daily, ts_code=ts_code, start_date=start, end_date=end)
if df is None or df.empty:
return []
df = df.sort_values("trade_date")
rows = []
for _, r in df.iterrows():
amt = r.get("amount")
rows.append({
"ts": _parse_d(r["trade_date"]),
"ts_code": ts_code,
"open": float(r["open"]), "high": float(r["high"]),
"low": float(r["low"]), "close": float(r["close"]),
"vol": float(r["vol"]),
"amount": (float(amt) if amt is not None and amt == amt else None),
})
return rows
def _upsert_candles_stmt(batch: list[dict]):
from ..models import Candle
stmt = pg_insert(Candle).values(batch)
return stmt.on_conflict_do_update(
index_elements=["symbol", "timeframe", "ts"],
set_={"open": stmt.excluded.open, "high": stmt.excluded.high,
"low": stmt.excluded.low, "close": stmt.excluded.close,
"volume": stmt.excluded.volume,
# 增量源缺额时保留库内旧值(与股票底座同语义)
"amount": func.coalesce(stmt.excluded.amount, Candle.amount)},
)
def _day_batch(rows: list[dict], sym_set: set[str], d_str: str) -> list[dict]:
"""某日 fund_daily 行 -> candles 批(过滤到 etf_basic 符号;手->份、千元->元)。"""
return [
{"symbol": r["ts_code"].split(".")[0], "timeframe": "1d",
"ts": _parse_d(d_str),
"open": r["open"], "high": r["high"], "low": r["low"], "close": r["close"],
"volume": r["vol"] * 100.0,
"amount": (r["amount"] * 1000.0) if r["amount"] is not None else None,
"turnover": None}
for r in rows if r["ts_code"].split(".")[0] in sym_set
]
def _symbol_batch(symbol: str, rows: list[dict]) -> list[dict]:
return [
{"symbol": symbol, "timeframe": "1d", "ts": r["ts"],
"open": r["open"], "high": r["high"], "low": r["low"], "close": r["close"],
"volume": r["vol"] * 100.0,
"amount": (r["amount"] * 1000.0) if r["amount"] is not None else None,
"turnover": None}
for r in rows
]
async def _write_batches(session: AsyncSession, batch: list[dict]) -> None:
for i in range(0, len(batch), _BATCH):
await session.execute(_upsert_candles_stmt(batch[i : i + _BATCH]))
await session.commit()
async def _backfill_list_dates(session: AsyncSession, dates: dict[str, str]) -> None:
"""首根 K 线日回填 list_date仅空缺处一条 UPDATE ... FROM (VALUES)。"""
pairs = list(dates.items())
for i in range(0, len(pairs), 1000):
chunk = pairs[i : i + 1000]
vals = ", ".join(f"(:s{j}, :d{j})" for j in range(len(chunk)))
params = {f"s{j}": s for j, (s, _) in enumerate(chunk)}
params.update({f"d{j}": d for j, (_, d) in enumerate(chunk)})
await session.execute(text(
f"UPDATE etf_basic e SET list_date = v.d FROM (VALUES {vals}) AS v(symbol, d) "
"WHERE e.symbol = v.symbol AND e.list_date IS NULL"
), params)
async def _run_sync(full: bool) -> None:
from ..db import async_session
from ..models import Candle, EtfBasic, TradeCalendar
try:
pro = await asyncio.to_thread(get_pro_lazy)
# 1) 快照 -> etf_basic
_state["step"] = "正在拉取 ETF 列表"
async with async_session() as session:
n_list = await _sync_spot(session)
etfs = (await session.execute(
select(EtfBasic.ts_code, EtfBasic.symbol, EtfBasic.exchange, EtfBasic.list_date)
)).all()
# 零缓存 ETF新上市/历史缺口,需逐只全量拉):
# NOT EXISTS 走 (symbol,timeframe,ts) 索引探测1514 次 ms 级;
# 比对「全 ETF 符号 GROUP BY max(ts)」(扫数百万索引行)便宜得多
have_any = set((await session.execute(text(
"SELECT e.symbol FROM etf_basic e WHERE EXISTS (SELECT 1 FROM candles c "
"WHERE c.symbol = e.symbol AND c.timeframe = '1d')"
))).scalars())
# 已落库的 ETF 交易日YYYYMMDD逐日模式的跳过依据。
# 只看近 40 天(索引范围扫)——更早的历史缺口由 full 全量重拉兜底,
# 全表 distinct 对千万行 candles 表要几十秒,不能每次同步都付
fresh = not have_any or full
have_dates: set[str] = set() if fresh else {
r.strftime("%Y%m%d") for r in (await session.execute(
select(func.distinct(func.date(Candle.ts))).where(
Candle.timeframe == "1d",
Candle.symbol.in_(select(EtfBasic.symbol)),
Candle.ts >= datetime.now() - timedelta(days=40),
)
)).scalars() if r is not None
}
cal = (await session.execute(
select(TradeCalendar.trade_date).order_by(TradeCalendar.trade_date.desc())
)).scalars().all()
sym_set = {e.symbol for e in etfs}
today = datetime.now().strftime("%Y%m%d")
since40 = (datetime.now() - timedelta(days=40)).strftime("%Y%m%d")
# 2) 任务编排逐日模式补近窗缺失交易日fresh 库改走逐只全量);
# 逐只模式拉零缓存 ETFfull=true 全量重拉
todo_dates = [] if fresh else [
d for d in cal if since40 <= d <= today and d not in have_dates
]
per_symbol = list(etfs) if fresh else [
e for e in etfs if e.symbol not in have_any
]
_state["total"] = len(todo_dates) + len(per_symbol)
_state["done"] = 0
fails: list[str] = []
new_list_dates: dict[str, str] = {}
written_rows = 0 # 实际写入的 K 线行数(决定是否作废 candles 相关缓存)
# 3) 逐日模式:一天一调用(交易日历空时跳过——由逐只模式兜底)
for d in sorted(todo_dates):
_state["step"] = f"正在同步 {d} 日线({_state['done'] + 1}/{_state['total']}"
try:
rows = await asyncio.to_thread(_fetch_day_sync, pro, d)
if rows:
batch = _day_batch(rows, sym_set, d)
if batch:
written_rows += len(batch)
async with async_session() as session:
await _write_batches(session, batch)
except Exception as ex: # noqa: BLE001 —— 单日失败不拖垮整体
fails.append(f"{d}: {str(ex)[:80]}")
_state["done"] += 1
# 4) 逐只模式:零缓存 ETF 全量拉取start=None 即上市以来full 同理)
for ts_code, symbol, _exch, has_list_date in per_symbol:
_state["step"] = f"正在同步 ETF 日线 {symbol}{_state['done'] + 1}/{_state['total']}"
try:
rows = await asyncio.to_thread(_fetch_symbol_sync, pro, ts_code, None, None)
except Exception as ex: # noqa: BLE001
fails.append(f"{ts_code}: {str(ex)[:80]}")
_state["done"] += 1
continue
if rows:
# 全量拉取的首根 = 真实上市日
if not has_list_date:
new_list_dates[symbol] = rows[0]["ts"].strftime("%Y%m%d")
batch = _symbol_batch(symbol, rows)
written_rows += len(batch)
try:
async with async_session() as session:
await _write_batches(session, batch)
except Exception as ex: # noqa: BLE001
fails.append(f"{ts_code}: {str(ex)[:80]}")
_state["done"] += 1
# 5) list_date 回填(一次 SQL
if new_list_dates:
async with async_session() as session:
await _backfill_list_dates(session, new_list_dates)
await session.commit()
# candles 已更新:作废旧 K 线预览缓存(只在真的写了行时——空跑不作废,
# 免得每次同步都触发一轮 >10s 的统计重聚合etf 版本号作废 ETF 列表缓存
if written_rows:
await cache.bump_version("candles")
await cache.bump_version("etf")
parts = [f"同步完成({n_list} 只 ETF"]
if todo_dates:
parts.append(f"{len(todo_dates)} 个交易日")
if per_symbol:
parts.append(f"{len(per_symbol)} 只逐只补数")
_state["step"] = "".join(parts) + ")" + (f"{len(fails)} 项失败" if fails else "")
if fails:
_state["error"] = "部分失败:" + "".join(fails[:3]) + ("" if len(fails) > 3 else "")
except Exception as e: # noqa: BLE001
_state["error"] = f"同步失败:{str(e)[:300]}"
_state["step"] = "同步失败"
finally:
_state["running"] = False
_state["finished_at"] = datetime.now()
async def start_sync(full: bool = False) -> dict:
"""幂等启动后台同步已在跑则直接返回当前状态。full=true 所有 ETF 全量重拉。"""
global _task
async with _lock:
if _state["running"] and _task and not _task.done():
return dict(_state)
_state.update({
"running": True, "step": "准备同步", "total": 0, "done": 0,
"error": None, "started_at": datetime.now(), "finished_at": None,
})
_task = asyncio.create_task(_run_sync(full))
return dict(_state)
async def get_status(session: AsyncSession) -> dict:
"""任务状态 + DB 实况。行情侧只做单符号 max(ts) 索引探测candles 是千万行表,
全表聚合 >10s绝不能落在轮询热路径上取最早上市且已有日线的一只当「数据更新至」。"""
from ..models import Candle, EtfBasic
etfs = int(await session.scalar(select(func.count()).select_from(EtfBasic)) or 0)
# 探测样本优先最早上市历史最长最稳且已有日线的一只EXISTS 走索引ms 级)
probe = await session.scalar(
text("""
SELECT e.symbol FROM etf_basic e
WHERE EXISTS (SELECT 1 FROM candles c
WHERE c.symbol = e.symbol AND c.timeframe = '1d')
ORDER BY e.list_date NULLS LAST, e.symbol LIMIT 1
""")
)
last = None
if probe:
last = await session.scalar(
select(func.max(Candle.ts)).where(Candle.symbol == probe, Candle.timeframe == "1d")
)
status = dict(_state)
status.update({
"stats": {"etfs": etfs},
"last_trade_date": last,
"last_synced_at": _state.get("finished_at") or _state.get("started_at"),
"ready": etfs > 0 and last is not None,
})
return status