"""指数 / ETF 数据同步服务。 标的列表优先用免费的 exchanges.get_instruments(type=index/etf) 拉取 (None/Free 档均可用,无需 quote.pool 权限);付费档可额外用 quotes.get_by_universes 作为补充来源。日K统一走 klines.batch。 """ from __future__ import annotations import logging import gc from collections.abc import Callable from datetime import datetime, timedelta import polars as pl from app.indicators.pipeline import compute_enriched from app.services import kline_sync, preferences from app.tickflow.capabilities import Cap, CapabilitySet from app.tickflow.client import get_client from app.tickflow.repository import KlineRepository logger = logging.getLogger(__name__) # exchanges.get_instruments 查询的交易所(沪深京) _EXCHANGES = ["SH", "SZ", "BJ"] def _quotes_to_index_instruments(resp) -> pl.DataFrame: """将 TickFlow quotes 响应(get_by_universes)规范为指数 instruments。 付费档(Starter+)的补充来源,免费档用不到。 """ if resp is None: return pl.DataFrame() if isinstance(resp, pl.DataFrame): df = resp elif hasattr(resp, "columns"): df = pl.from_pandas(resp.reset_index() if hasattr(resp, "reset_index") else resp) else: rows: list[dict] = [] for q in resp or []: item = q if isinstance(q, dict) else {} ext = item.get("ext") or {} symbol = item.get("symbol") if not symbol: continue rows.append({ "symbol": str(symbol), "name": ext.get("name") or item.get("name") or str(symbol), }) df = pl.DataFrame(rows) if df.is_empty() or "symbol" not in df.columns: return pl.DataFrame() rename = {"ts_code": "symbol"} df = df.rename({k: v for k, v in rename.items() if k in df.columns}) if "name" not in df.columns: if "ext" in df.columns: df = df.with_columns(pl.col("symbol").cast(pl.Utf8).alias("name")) else: df = df.with_columns(pl.col("symbol").cast(pl.Utf8).alias("name")) result = df.select([ pl.col("symbol").cast(pl.Utf8), pl.col("name").cast(pl.Utf8), ]).with_columns([ pl.col("symbol").str.split(".").list.first().alias("code"), pl.lit("index").alias("asset_type"), ]) return result.unique(subset=["symbol"], keep="last").sort("symbol") def _fetch_instruments_by_type(instrument_type: str, asset_type_label: str) -> pl.DataFrame: """用免费的 exchanges.get_instruments 拉取指定类型的标的列表。 None/Free 档均可使用(标的信息查询免费开放)。 instrument_type: 'index' / 'etf' asset_type_label: 写入 instruments 表的 asset_type 标记('index' / 'etf') """ tf = get_client() rows: list[dict] = [] for ex in _EXCHANGES: try: items = tf.exchanges.get_instruments(ex, instrument_type=instrument_type) for it in items or []: item = it if isinstance(it, dict) else {} symbol = item.get("symbol") if not symbol: continue rows.append({ "symbol": str(symbol), "name": item.get("name") or str(symbol), }) except Exception as e: # noqa: BLE001 logger.warning("get_instruments(%s, type=%s) failed: %s", ex, instrument_type, e) if not rows: return pl.DataFrame() return ( pl.DataFrame(rows) .with_columns([ pl.col("symbol").str.split(".").list.first().alias("code"), pl.lit(asset_type_label).alias("asset_type"), ]) .unique(subset=["symbol"], keep="last") .sort("symbol") ) def sync_index_instruments( repo: KlineRepository, pull_index: bool = True, pull_etf: bool = True, ) -> int: """同步指数 / ETF 标的维表,返回标的总数。 新版物理分开保存: 指数写 instruments_index, ETF 写 instruments_etf。 读取层仍兼容旧版 instruments_index 中 asset_type='etf' 的历史数据。 """ index_parts: list[pl.DataFrame] = [] etf_parts: list[pl.DataFrame] = [] # 1) 免费通道:按开关分别拉 index / etf if pull_index: index_df = _fetch_instruments_by_type("index", "index") if not index_df.is_empty(): index_parts.append(index_df) if pull_etf: etf_df = _fetch_instruments_by_type("etf", "etf") if not etf_df.is_empty(): etf_parts.append(etf_df) # 2) 付费补充:Starter+ 用 get_by_universes 补指数(仅当开启指数拉取) if pull_index: capset = None try: from app.tickflow import policy capset = policy.detect_capabilities(force=False) except Exception: # noqa: BLE001 pass if capset is not None and capset.has(Cap.QUOTE_POOL): tf = get_client() for kwargs in ( {"universes": ["CN_Index"]}, {"universes": ["CN_Index"], "as_dataframe": False}, ): try: resp = tf.quotes.get_by_universes(**kwargs) if resp is not None and len(resp) > 0: sup = _quotes_to_index_instruments(resp) if not sup.is_empty(): index_parts.append(sup) break except Exception as e: # noqa: BLE001 logger.debug("CN_Index universe supplement failed: %s", e) total = 0 if index_parts: index_inst = pl.concat(index_parts, how="diagonal_relaxed").unique(subset=["symbol"], keep="last").sort("symbol") if not index_inst.is_empty(): repo.save_index_instruments(index_inst) total += index_inst.height if etf_parts: etf_inst = pl.concat(etf_parts, how="diagonal_relaxed").unique(subset=["symbol"], keep="last").sort("symbol") if not etf_inst.is_empty(): repo.save_etf_instruments(etf_inst) total += etf_inst.height if total == 0: logger.warning("指数/ETF 标的列表为空(pull_index=%s, pull_etf=%s)", pull_index, pull_etf) return 0 repo.refresh_index_views() logger.info("指数/ETF 标的同步完成: %d 只", total) return total def sync_etf_instruments(repo: KlineRepository) -> int: """单独同步 ETF 标的维表(返回 ETF 数量)。""" etf_df = _fetch_instruments_by_type("etf", "etf") if etf_df.is_empty(): return 0 repo.save_etf_instruments(etf_df) repo.refresh_index_views() return etf_df.height def sync_and_persist_index_daily( repo: KlineRepository, capset: CapabilitySet, count: int | None = None, start_date: datetime | None = None, end_date: datetime | None = None, symbols_override: list[str] | None = None, on_chunk_done: Callable[[int, int], None] | None = None, ) -> int: """同步指数/ETF 日K到独立 parquet,并计算 enriched。 symbols_override 非空时,只拉这些代码(跳过 instruments 表),用于自定义范围。 否则取 index_instruments 表全量(指数+ETF 合并存储)。 on_chunk_done(current, total) 每个批次完成后回调。 """ if not capset.has(Cap.KLINE_DAILY_BATCH): return 0 if symbols_override: symbols = sorted(set(s for s in symbols_override if s)) if not symbols: return 0 else: instruments = repo.get_index_instruments() if instruments.is_empty(): sync_index_instruments(repo, pull_index=True, pull_etf=False) instruments = repo.get_index_instruments() if not instruments.is_empty() and "asset_type" in instruments.columns: instruments = instruments.filter(pl.col("asset_type") != "etf") if instruments.is_empty() or "symbol" not in instruments.columns: return 0 symbols = sorted(set(instruments["symbol"].to_list())) lim = capset.limits(Cap.KLINE_DAILY_BATCH) batch_size = preferences.get_index_daily_batch_size() if lim and lim.batch: batch_size = min(batch_size, lim.batch) rpm = lim.rpm if lim else None end_time = end_date or datetime.now() start_time = start_date or (end_time - timedelta(days=365)) total_rows = 0 interval = (60.0 / rpm) if rpm else 0 chunks = [symbols[i:i + batch_size] for i in range(0, len(symbols), batch_size)] for i, chunk in enumerate(chunks): if i > 0 and interval > 0 and len(chunks) > rpm: import time time.sleep(interval) raw = kline_sync.sync_daily_batch( chunk, count=count, batch_size=None, start_time=start_time, end_time=end_time, ) if raw.is_empty(): continue repo.append_index_daily(raw) enriched = compute_enriched(raw, factors=None, instruments=None) repo.append_index_enriched(enriched) total_rows += raw.height logger.info("index/etf daily synced: %d/%d chunks, +%d rows", i + 1, len(chunks), raw.height) if on_chunk_done: on_chunk_done(i + 1, len(chunks)) del raw, enriched gc.collect() repo.refresh_index_views() return total_rows def _load_etf_factors(repo: KlineRepository) -> pl.DataFrame: factor_path = repo.store.data_dir / "adj_factor_etf" / "all.parquet" if not factor_path.exists(): return pl.DataFrame() try: return pl.read_parquet(factor_path) except Exception as e: # noqa: BLE001 logger.warning("ETF 复权因子读取失败: %s", e) return pl.DataFrame() def sync_etf_adj_factor( symbols: list[str], repo: KlineRepository, capset: CapabilitySet, start_time: datetime | None = None, end_time: datetime | None = None, on_chunk_done=None, ) -> tuple[int, list[str]]: """同步 ETF 复权因子;失败由调用方降级为 warning。""" return kline_sync.sync_adj_factor( symbols, repo, capset, start_time=start_time, end_time=end_time, on_chunk_done=on_chunk_done, asset_type="etf", ) def sync_and_persist_etf_daily( repo: KlineRepository, capset: CapabilitySet, count: int | None = None, start_date: datetime | None = None, end_date: datetime | None = None, symbols_override: list[str] | None = None, on_chunk_done: Callable[[int, int], None] | None = None, ) -> int: """同步 ETF 日K到独立 kline_etf_* parquet,并计算 ETF enriched。 on_chunk_done(current, total) 每个批次完成后回调。 """ if not capset.has(Cap.KLINE_DAILY_BATCH): return 0 if symbols_override: symbols = sorted(set(s for s in symbols_override if s)) else: instruments = repo.get_etf_instruments() if instruments.is_empty(): sync_etf_instruments(repo) instruments = repo.get_etf_instruments() if instruments.is_empty() or "symbol" not in instruments.columns: return 0 symbols = sorted(set(instruments["symbol"].to_list())) if not symbols: return 0 lim = capset.limits(Cap.KLINE_DAILY_BATCH) batch_size = preferences.get_index_daily_batch_size() if lim and lim.batch: batch_size = min(batch_size, lim.batch) rpm = lim.rpm if lim else None end_time = end_date or datetime.now() start_time = start_date or (end_time - timedelta(days=365)) total_rows = 0 interval = (60.0 / rpm) if rpm else 0 chunks = [symbols[i:i + batch_size] for i in range(0, len(symbols), batch_size)] factors = _load_etf_factors(repo) for i, chunk in enumerate(chunks): if i > 0 and interval > 0 and len(chunks) > rpm: import time time.sleep(interval) raw = kline_sync.sync_daily_batch( chunk, count=count, batch_size=None, start_time=start_time, end_time=end_time, ) if raw.is_empty(): continue repo.append_etf_daily(raw) batch_factors = factors.filter(pl.col("symbol").is_in(chunk)) if not factors.is_empty() else factors # ETF 使用复权和通用技术指标;不传 instruments,避免套用 A股涨跌停/连板逻辑。 enriched = compute_enriched(raw, factors=batch_factors, instruments=None) repo.append_etf_enriched(enriched) total_rows += raw.height logger.info("etf daily synced: %d/%d chunks, +%d rows", i + 1, len(chunks), raw.height) if on_chunk_done: on_chunk_done(i + 1, len(chunks)) del raw, enriched gc.collect() repo.refresh_index_views() return total_rows