Files
tick-stock-panel/backend/app/services/kline_sync.py
T
Jinfeng SunandClaude Opus 4.8 e5a94c42d5 feat: ETF 支持(选股 / 回测 / 监控) (#61)
* feat(screener): 选股引擎支持 ETF

- 12 个内置策略打 asset_types 白名单 + strategy_supports_asset;涨停类
  (连板/断板反包)仅股票,其余 10 个技术类对 ETF 开放
- ScreenerService(repo, asset_type) 分流取数,ETF 复用 kline_etf_enriched,
  跳过股票专用历史缓存与涨停信号;进程级 _history_cache key 含 asset_type
- API /run、/run_preset 透传 asset_type;/strategies 按资产过滤;
  股票专有策略在 ETF 下返回空
- 新增 enriched_dirname(asset_type) 共享 helper;get_enriched_latest_asset
  增 refresh 参数(供轮询线程避免冷缓存同步重算)
- 前端「策略」页加 股票/ETF 切换,ETF 走实时单跑(空日期→用 ETF 自身最新日);
  QK.screenerStrategies 按 asset_type keyed
- 测试:test_screener_etf.py

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(backtest): 回测支持 ETF(个股/因子/策略组合)

- 三条回测路径 + 共用 BacktestEngine 面板加载按 asset_type 路由到
  kline_etf_enriched(复用 enriched_dirname);PanelCache key 隔离资产;
  ETF 跳过股票专用 get_enriched_range 缓存
- 面板 compute_all/名称 JOIN 按 asset_type 取维表(get_instruments_asset),
  修复 ETF 策略回测用错股票维表致名称为空/涨停信号算错
- BacktestConfig/FactorConfig/StrategyBacktestConfig 增 asset_type
- 三个回测 API + SSE stream 透传 asset_type;_make_job_key 纳入 asset_type
  (修复 stream 与 cancel job_key 不对齐致取消失效的回归)
- 前端策略组合页/因子页加 股票/ETF 切换,标的搜索与策略列表跟随资产;
  assetType 持久化
- 测试:test_backtest_etf.py(含 job_key 一致性回归);既有回测测试替身同步

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(monitor): 监控规则支持 ETF

- engine.evaluate(df, asset_type) 按规则 asset_type 分轮评估;quote_service
  增开 ETF 评估轮(用 ETF enriched 快照),股票轮不受影响、不重置其策略结果
- ETF 评估轮独立 try(异常不丢弃已算出的股票告警)+ refresh=False(不在轮询
  线程触发 ETF 冷缓存同步重算)
- ETF 版历史加载器(main.py 注入)+ 按规则 asset_type 选加载器
- _strategy_pools 按 (sid, asset_type) 键,避免同策略股票/ETF 规则互相覆盖
- name_map 仅在有 ETF 规则时补 ETF 维表, setdefault 保股票名优先
- RuleModel/normalize 增 asset_type(默认 stock,持久化往返)
- 前端 RuleEditor 加 股票/ETF 选择,策略列表与标的搜索跟随资产
- 测试:test_monitor_etf.py

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(etf): 前端 API 绑定透传 asset_type + 文档

- api.ts: screener/backtest 绑定加 assetType 参数,MonitorRule 类型加 asset_type
- docs/features.md: 标注选股/回测/监控的 ETF 支持范围与前提

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reliability): 管道并发/原子写/能力探测/监控告警多处加固

后端可靠性专项修复(均带回归测试, backend 全套 64 passed):

并发与数据完整性:
- 盘后管道单飞: JobStore.create() 去重纳入 pending∨running, 关闭"两次快速点击"
  并发双跑窗口; 新增 _heavy_run_lock 执行槽挡住 reap 后僵尸线程并发写 parquet
- adj_factor/minute 全部改走原子写(tmp+replace), 消除 kill/断电致 all.parquet 损坏
- 分块拉取失败聚合 WARNING 可见化(不再静默当成功); 复权失败标的会保持旧价已提示

能力探测:
- 周期重探(60min)热更新 app.state.capabilities, 付费 Key 过期/续费无需重启即可见
- 瞬时探测失败(超时/连接/5xx, 按 _is_transient 判定)不降级、保留旧付费档;
  真 401/无权限仍正常降级回落 free-api

监控告警:
- 评估仅在连续竞价(9:30-11:30/13:00-15:00)+ 快照当日新鲜度下进行, 避开集合竞价/
  收盘后陈旧价与节假日误告警
- scope=sector fail-closed(validate 拒绝新建 + _apply_scope 返回空), 修复板块规则
  对全市场刷屏
- 飞书 webhook 加退避重试并移到独立线程池 fire-and-forget, 不再阻塞行情轮询线程

单标的新鲜度: 新增 repo.symbols_lagging() 检测掉队标的并 WARNING + 计入 job 结果

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:12:29 +08:00

788 lines
30 KiB
Python

"""日 K 同步服务(§7.7 Step 1)。
调度器在 capability 允许下,把符号集合的日 K 批量同步到本地 Parquet。
策略:
- 日 K 仅使用 `kline.daily.batch`
- 除权因子仅使用 `adj_factor`
"""
from __future__ import annotations
import logging
from collections.abc import Callable
from datetime import datetime, timedelta
import polars as pl
from app.indicators.pipeline import filter_halt_days
from app.services import preferences
from app.tickflow.capabilities import Cap, CapabilitySet
from app.tickflow.client import get_client
from app.tickflow.rate_limits import chunked, resolve_limit, sleep_between_batches
from app.tickflow.repository import KlineRepository
logger = logging.getLogger(__name__)
def _atomic_write_parquet(df: pl.DataFrame, out) -> None:
"""先写临时文件再原子替换, 避免进程中断留下损坏的 parquet。
与 repository._atomic_write_parquet 同语义。adj_factor 的 all.parquet 是全市场
单文件、每次「读→concat→原地写」, 直接 write_parquet(out) 在进程被 kill
(dev.sh 清端口用 kill -9)、reap 超时或断电时会留下半截文件, 之后复权视图
scan_parquet 整条链路报错、enriched 全市场重算不出。临时文件后缀 .tmp 不匹配
*.parquet glob, 不会被扫描误读。
"""
tmp = out.with_name(out.name + ".tmp")
df.write_parquet(tmp)
tmp.replace(out) # 同目录 rename, POSIX/NTFS 均为原子操作
# 标准列(无论 SDK 返回什么形状,我们把它规范成这套)
CANONICAL_DAILY_COLS = [
"symbol", "date", "open", "high", "low", "close", "volume", "amount",
]
def _normalize_daily(df_in, default_symbol: str | None = None) -> pl.DataFrame:
"""把 SDK 返回的 pandas/任意 DataFrame 规范成 canonical 列。"""
if df_in is None or len(df_in) == 0:
return pl.DataFrame()
if not isinstance(df_in, pl.DataFrame):
df = pl.from_pandas(df_in.reset_index() if hasattr(df_in, "reset_index") else df_in)
else:
df = df_in
# 兼容字段名差异
rename_map = {
"ts_code": "symbol",
"trade_date": "date",
"vol": "volume",
"amt": "amount",
"datetime": "date",
}
df = df.rename({k: v for k, v in rename_map.items() if k in df.columns})
if "symbol" not in df.columns and default_symbol is not None:
df = df.with_columns(pl.lit(default_symbol).alias("symbol"))
# 类型规范
if "date" in df.columns and df.schema["date"] != pl.Date:
df = df.with_columns(pl.col("date").cast(pl.Date, strict=False))
for col in ("open", "high", "low", "close"):
if col in df.columns:
df = df.with_columns(pl.col(col).cast(pl.Float64, strict=False))
for col in ("volume", "amount"):
if col in df.columns:
df = df.with_columns(pl.col(col).cast(pl.Float64, strict=False))
# 过滤停牌日 (open/high 为 0; close 可能被填充为前收盘价, 不能用全零判断)
df = filter_halt_days(df)
# 只保留 canonical 列
keep = [c for c in CANONICAL_DAILY_COLS if c in df.columns]
return df.select(keep)
def sync_daily_batch(symbols: list[str],
count: int | None = None,
batch_size: int | None = None,
rpm: int | None = None,
start_time: datetime | None = None,
end_time: datetime | None = None,
on_chunk_done: Callable[[int, int], None] | None = None,
failed_out: list[str] | None = None) -> pl.DataFrame:
"""批量拉取多股日 K。
优先使用 start_time / end_time 区间 + count=10000,确保覆盖完整时间段。
仅传 count 时按条数回溯。
failed_out: 可选出参。拉取失败的分块标的会追加进该 list, 供上层判定「部分失败」
而非静默当成功(某分块断网 → 这些标的本轮未更新, 保持旧数据)。
"""
tf = get_client()
out: list[pl.DataFrame] = []
chunks = chunked(symbols, batch_size)
failed_syms: list[str] = []
for i, chunk in enumerate(chunks):
sleep_between_batches(i, rpm)
try:
if start_time and end_time:
raw = tf.klines.batch(
chunk, period="1d", adjust="none",
start_time=_datetime_to_ms(start_time),
end_time=_datetime_to_ms(end_time),
count=10000,
as_dataframe=True, show_progress=False,
)
else:
raw = tf.klines.batch(chunk, period="1d", count=count or 250, adjust="none",
as_dataframe=True, show_progress=False)
except Exception as e: # noqa: BLE001
logger.warning("batch fetch failed for %d symbols (chunk %d/%d): %s",
len(chunk), i + 1, len(chunks), e)
failed_syms.extend(chunk)
continue
# 兼容两种形态:dict[sym → df] 和扁平 df
if isinstance(raw, dict):
for sym, sub in raw.items():
if sub is None or len(sub) == 0:
continue
out.append(_normalize_daily(sub, default_symbol=sym))
elif raw is not None and len(raw) > 0:
out.append(_normalize_daily(raw))
if on_chunk_done:
on_chunk_done(i + 1, len(chunks))
# 部分失败可见化: 聚合一条 WARNING(而非只有逐块 debug/warning), 并回传出参。
if failed_syms:
logger.warning("日K批量同步部分失败: %d/%d 标的未获取, 本轮保持旧数据 (样例: %s)",
len(failed_syms), len(symbols), failed_syms[:10])
if failed_out is not None:
failed_out.extend(failed_syms)
if not out:
return pl.DataFrame()
return pl.concat(out, how="diagonal_relaxed")
def sync_and_persist_daily_batch(
symbols: list[str],
repo: KlineRepository,
capset: CapabilitySet,
count: int | None = None,
start_date: datetime | None = None,
end_date: datetime | None = None,
on_chunk_done: Callable[[int, int], None] | None = None,
) -> int:
"""批量同步日 K 并落到 Parquet。返回写入的行数。
start_date/end_date: 外部传入的时间范围(由 pipeline 根据已有数据计算)。
未传入时默认拉最近 1 年。
"""
if not symbols:
return 0
provider_name = preferences.get_daily_data_provider()
if provider_name != "tickflow":
from app.data_providers import custom as custom_sources
if custom_sources.provider_has_dataset(provider_name, "daily"):
provider = custom_sources.get_provider(provider_name)
end_time = end_date or datetime.now()
days = count or 365
start_time = start_date or (end_time - timedelta(days=days))
df = provider.get_daily(
symbols,
start_time=start_time,
end_time=end_time,
on_chunk_done=on_chunk_done,
)
if df.is_empty():
return 0
repo.append_daily(df)
try:
d = repo.store.data_dir.as_posix()
repo.db.execute(
f"""CREATE OR REPLACE VIEW kline_daily AS
SELECT * FROM read_parquet('{d}/kline_daily/**/*.parquet', union_by_name=true)"""
)
except Exception as e: # noqa: BLE001
logger.warning("refresh view failed: %s", e)
return df.height
# 自定义源未配置 daily → 回退 TickFlow
if not capset.has(Cap.KLINE_DAILY_BATCH):
return 0
limit = resolve_limit(capset, Cap.KLINE_DAILY_BATCH, default_batch=100)
end_time = end_date or datetime.now()
start_time = start_date or (end_time - timedelta(days=365))
df = sync_daily_batch(
symbols, count=count, batch_size=limit.batch, rpm=limit.rpm,
start_time=start_time, end_time=end_time,
on_chunk_done=on_chunk_done,
)
if df.is_empty():
return 0
repo.append_daily(df)
try:
d = repo.store.data_dir.as_posix()
repo.db.execute(
f"""CREATE OR REPLACE VIEW kline_daily AS
SELECT * FROM read_parquet('{d}/kline_daily/**/*.parquet', union_by_name=true)"""
)
except Exception as e: # noqa: BLE001
logger.warning("refresh view failed: %s", e)
return df.height
def sync_daily_by_quotes(repo: KlineRepository) -> int:
"""用实时行情接口拉全市场当日数据,覆写 kline_daily 今天分区。
一个请求覆盖 ~5500 只股票,比 batch K-line 快几个数量级。
返回写入的行数。
"""
from datetime import date as _date
from app.tickflow.client import get_client
tf = get_client()
try:
resp = tf.quotes.get_by_universes(universes=["CN_Equity_A"])
except Exception as e:
logger.warning("get_by_universes failed: %s", e)
return 0
if not resp:
logger.warning("get_by_universes returned empty")
return 0
records = []
for q in resp:
ext = q.get("ext") or {}
records.append({
"symbol": q.get("symbol"),
"open": q.get("open"),
"high": q.get("high"),
"low": q.get("low"),
"close": q.get("last_price"),
"volume": q.get("volume"),
"amount": q.get("amount"),
})
df = pl.DataFrame(records)
if df.is_empty():
return 0
today = _date.today()
daily_df = df.with_columns(pl.lit(today).cast(pl.Date).alias("date"))
# 过滤停牌 (open/high 为 0; close 可能被填充为前收盘价, 不能用全零判断)
daily_df = filter_halt_days(daily_df)
repo.flush_live_daily(daily_df)
logger.info("sync_daily_by_quotes: %d symbols flushed for %s", daily_df.height, today)
return daily_df.height
def _normalize_adj_factor(raw) -> pl.DataFrame:
"""Normalize SDK ex_factors response to symbol/trade_date/ex_factor."""
if raw is None or len(raw) == 0:
return pl.DataFrame()
if isinstance(raw, dict):
rows: list[dict] = []
for sym, values in raw.items():
for item in values or []:
row = dict(item or {})
row.setdefault("symbol", sym)
rows.append(row)
df = pl.DataFrame(rows) if rows else pl.DataFrame()
elif isinstance(raw, pl.DataFrame):
df = raw
else:
df = pl.from_pandas(raw.reset_index() if hasattr(raw, "reset_index") else raw)
if df.is_empty():
return df
# rename: timestamp/date → trade_date, adj_factor → ex_factor
# 注意: 新版 SDK 可能同时返回 timestamp 和 trade_date (或 adj_factor 和 ex_factor),
# 直接 rename 会产生重复列报错。仅当目标列不存在时才 rename。
rename_map: dict[str, str] = {}
for src, dst in (("timestamp", "trade_date"), ("date", "trade_date"), ("adj_factor", "ex_factor")):
if src in df.columns and dst not in df.columns:
rename_map[src] = dst
df = df.rename(rename_map)
if "trade_date" in df.columns:
if df.schema["trade_date"] in {pl.Int64, pl.Int32, pl.UInt64, pl.UInt32, pl.Float64, pl.Float32}:
df = df.with_columns(
pl.from_epoch(pl.col("trade_date").cast(pl.Int64), time_unit="ms").dt.date().alias("trade_date")
)
else:
df = df.with_columns(pl.col("trade_date").cast(pl.Date, strict=False))
if "ex_factor" in df.columns:
df = df.with_columns(pl.col("ex_factor").cast(pl.Float64, strict=False))
cols = [c for c in ["symbol", "trade_date", "ex_factor"] if c in df.columns]
if len(cols) < 3:
return pl.DataFrame()
return df.select(cols).drop_nulls()
def sync_adj_factor(symbols: list[str], repo: KlineRepository,
capset: CapabilitySet,
start_time: datetime | None = None,
end_time: datetime | None = None,
on_chunk_done: Callable[[int, int], None] | None = None,
asset_type: str = "stock") -> tuple[int, list[str]]:
"""同步除权因子(Starter+)。SDK 接口:`tf.klines.ex_factors(symbols=...)`。
支持增量: 传 start_time/end_time 只拉取该时间范围内的新除权事件。
返回 (写入行数, 受影响的 symbol 列表) — 供 enriched 局部重算使用。
"""
if not symbols:
return 0, []
provider_name = preferences.get_adj_factor_provider()
if provider_name == "same_as_daily":
provider_name = preferences.get_daily_data_provider()
if provider_name != "tickflow":
from app.data_providers import custom as custom_sources
if custom_sources.provider_has_dataset(provider_name, "adj_factor"):
provider = custom_sources.get_provider(provider_name)
new_data = provider.get_adj_factors(
symbols,
start_time=start_time,
end_time=end_time,
asset_type=asset_type,
on_chunk_done=on_chunk_done,
)
if new_data.is_empty():
return 0, []
affected = new_data["symbol"].unique().to_list()
factor_dir = "adj_factor_etf" if asset_type == "etf" else "adj_factor"
out = repo.store.data_dir / factor_dir / "all.parquet"
out.parent.mkdir(parents=True, exist_ok=True)
if out.exists():
existing = pl.read_parquet(out)
before = existing.height
merged = pl.concat([existing, new_data]).unique(
subset=["symbol", "trade_date"], keep="last",
).sort(["symbol", "trade_date"])
_atomic_write_parquet(merged, out)
return merged.height - before, affected
_atomic_write_parquet(new_data.sort(["symbol", "trade_date"]), out)
return new_data.height, affected
# 自定义源未配置 adj_factor → 回退 TickFlow
if not capset.has(Cap.ADJ_FACTOR):
return 0, []
tf = get_client()
limit = resolve_limit(
capset,
Cap.ADJ_FACTOR,
default_batch=50,
default_rpm=30,
default_rpm_when_unset=False,
)
# 构建 SDK 参数
sdk_kwargs: dict = {"as_dataframe": True, "batch_size": limit.batch, "show_progress": False}
if start_time:
sdk_kwargs["start_time"] = _datetime_to_ms(start_time)
if end_time:
sdk_kwargs["end_time"] = _datetime_to_ms(end_time)
chunks = chunked(symbols, limit.batch)
all_dfs: list[pl.DataFrame] = []
failed_syms: list[str] = []
for i, chunk in enumerate(chunks):
sleep_between_batches(i, limit.rpm)
try:
raw = tf.klines.ex_factors(chunk, **sdk_kwargs)
normalized = _normalize_adj_factor(raw)
if not normalized.is_empty():
all_dfs.append(normalized)
logger.debug("adj_factor chunk %d/%d: %d symbols", i + 1, len(chunks), len(chunk))
except Exception as e: # noqa: BLE001
logger.warning("adj_factor chunk %d/%d failed: %s", i + 1, len(chunks), e)
failed_syms.extend(chunk)
if on_chunk_done:
on_chunk_done(i + 1, len(chunks))
# 部分失败可见化: 失败分块的标的不在 affected 里 → enriched 不会重算它们,
# 它们会保持**旧的前复权价**直到下次成功同步。聚合一条 WARNING 让其可见。
if failed_syms:
logger.warning("adj_factor 同步部分失败: %d/%d 标的未获取复权因子, 将保持旧复权价 (样例: %s)",
len(failed_syms), len(symbols), failed_syms[:10])
if not all_dfs:
return 0, []
new_data = pl.concat(all_dfs, how="diagonal_relaxed") if len(all_dfs) > 1 else all_dfs[0]
# 提取受影响的 symbol 列表(合并前)
affected = new_data["symbol"].unique().to_list()
factor_dir = "adj_factor_etf" if asset_type == "etf" else "adj_factor"
out = repo.store.data_dir / factor_dir / "all.parquet"
out.parent.mkdir(parents=True, exist_ok=True)
if out.exists():
existing = pl.read_parquet(out)
before = existing.height
merged = pl.concat([existing, new_data]).unique(
subset=["symbol", "trade_date"], keep="last",
).sort(["symbol", "trade_date"])
_atomic_write_parquet(merged, out)
added = merged.height - before
logger.info("adj_factor merged: %d total (+%d new), %d/%d symbols",
merged.height, added, new_data.height, len(symbols))
return added, affected
else:
_atomic_write_parquet(new_data.sort(["symbol", "trade_date"]), out)
logger.info("adj_factor synced: %d rows (%d symbols)", new_data.height, len(symbols))
return new_data.height, affected
# ===== 分钟 K 同步 =====
CANONICAL_MINUTE_COLS = [
"symbol", "datetime", "open", "high", "low", "close", "volume", "amount",
]
def _normalize_minute(df_in, default_symbol: str | None = None) -> pl.DataFrame:
"""把 SDK 返回的分钟 K 数据规范成 canonical 列。"""
if df_in is None or len(df_in) == 0:
return pl.DataFrame()
if not isinstance(df_in, pl.DataFrame):
df = pl.from_pandas(df_in.reset_index() if hasattr(df_in, "reset_index") else df_in)
else:
df = df_in
rename_map = {
"ts_code": "symbol",
"vol": "volume",
"amt": "amount",
}
df = df.rename({k: v for k, v in rename_map.items() if k in df.columns})
# datetime 列:优先用 timestamp(毫秒精度),其次 trade_time
if "timestamp" in df.columns:
df = df.with_columns(
pl.from_epoch("timestamp", time_unit="ms").alias("datetime"),
).drop("timestamp")
for drop_col in ("trade_time", "trade_date"):
if drop_col in df.columns:
df = df.drop(drop_col)
elif "trade_time" in df.columns:
df = df.rename({"trade_time": "datetime"})
if "trade_date" in df.columns:
df = df.drop("trade_date")
elif "trade_date" in df.columns:
df = df.rename({"trade_date": "datetime"})
if "symbol" not in df.columns and default_symbol is not None:
df = df.with_columns(pl.lit(default_symbol).alias("symbol"))
# 类型规范:统一转 Datetime('us')
if "datetime" in df.columns:
dt_type = df.schema["datetime"]
if not isinstance(dt_type, pl.Datetime) or dt_type.time_unit != "us":
df = df.with_columns(pl.col("datetime").cast(pl.Datetime("us"), strict=False))
for col in ("open", "high", "low", "close"):
if col in df.columns:
df = df.with_columns(pl.col(col).cast(pl.Float64, strict=False))
for col in ("volume", "amount"):
if col in df.columns:
df = df.with_columns(pl.col(col).cast(pl.Float64, strict=False))
keep = [c for c in CANONICAL_MINUTE_COLS if c in df.columns]
return df.select(keep)
def _datetime_to_ms(dt: datetime) -> int:
"""datetime → 毫秒时间戳 (供 SDK start_time / end_time 使用)。"""
return int(dt.timestamp() * 1000)
def sync_minute_batch(
symbols: list[str],
start_time: datetime | None = None,
end_time: datetime | None = None,
count: int | None = None,
batch_size: int | None = None,
rpm: int | None = None,
on_chunk_done: Callable[[int, int], None] | None = None,
) -> pl.DataFrame:
"""批量拉取多股分钟 K。
优先使用 start_time / end_time 区间, 确保所有标的覆盖同一时间段。
count 仅作为 fallback 保留。
on_chunk_done(current, total) 每个 chunk 完成后回调。
"""
# 自定义数据源分流: minute provider
provider_name = preferences.get_minute_data_provider()
if provider_name != "tickflow":
from app.data_providers import custom as custom_sources
if custom_sources.provider_has_dataset(provider_name, "minute"):
provider = custom_sources.get_provider(provider_name)
return provider.get_minute(
symbols, start_time=start_time, end_time=end_time, on_chunk_done=on_chunk_done,
)
# 未配置 minute → 回退 TickFlow
tf = get_client()
out: list[pl.DataFrame] = []
chunks = chunked(symbols, batch_size)
for i, chunk in enumerate(chunks):
sleep_between_batches(i, rpm)
try:
if start_time and end_time:
raw = tf.klines.batch(
chunk, period="1m",
start_time=_datetime_to_ms(start_time),
end_time=_datetime_to_ms(end_time),
count=10000,
as_dataframe=True, show_progress=False,
)
else:
raw = tf.klines.batch(chunk, period="1m", count=count or 1200,
as_dataframe=True, show_progress=False)
except Exception as e: # noqa: BLE001
logger.warning("minute batch fetch failed for %d symbols: %s", len(chunk), e)
continue
if isinstance(raw, dict):
for sym, sub in raw.items():
if sub is None or len(sub) == 0:
continue
out.append(_normalize_minute(sub, default_symbol=sym))
elif raw is not None and len(raw) > 0:
out.append(_normalize_minute(raw))
if on_chunk_done:
on_chunk_done(i + 1, len(chunks))
if not out:
return pl.DataFrame()
return pl.concat(out, how="diagonal_relaxed")
def fetch_minute_single(symbol: str, trade_date: date) -> pl.DataFrame:
"""从 TickFlow 实时拉取单股单日分钟 K(不写入本地)。"""
from datetime import datetime
start_time = datetime(trade_date.year, trade_date.month, trade_date.day, 9, 25, 0)
end_time = datetime(trade_date.year, trade_date.month, trade_date.day, 15, 5, 0)
tf = get_client()
try:
raw = tf.klines.batch(
[symbol], period="1m",
start_time=_datetime_to_ms(start_time),
end_time=_datetime_to_ms(end_time),
count=10000,
as_dataframe=True, show_progress=False,
)
except Exception as e:
logger.warning("fetch_minute_single(%s, %s) failed: %s", symbol, trade_date, e)
return pl.DataFrame()
if isinstance(raw, dict):
sub = raw.get(symbol)
return _normalize_minute(sub) if sub is not None and len(sub) > 0 else pl.DataFrame()
if raw is not None and len(raw) > 0:
return _normalize_minute(raw)
return pl.DataFrame()
def fetch_adj_factor_single(symbol: str) -> pl.DataFrame:
"""从 TickFlow 实时拉取单股除权因子(不写入本地), 用于单股 K 线即时前复权。
返回结构: symbol, trade_date, ex_factor (空 DataFrame 表示无除权事件或拉取失败)。
与 _apply_adj_factor / compute_enriched 的 factors 参数格式一致。
"""
tf = get_client()
try:
raw = tf.klines.ex_factors([symbol], as_dataframe=True, show_progress=False)
except Exception as e: # noqa: BLE001
logger.warning("fetch_adj_factor_single(%s) failed: %s", symbol, e)
return pl.DataFrame()
return _normalize_adj_factor(raw)
def _latest_minute_datetime(repo: KlineRepository) -> datetime | None:
"""本地分钟 K 数据的最新时间。"""
try:
res = repo.execute_one("SELECT max(datetime) FROM kline_minute")
if res and res[0]:
d = res[0]
if isinstance(d, datetime):
return d
return datetime.fromisoformat(str(d))
except Exception: # noqa: BLE001
pass
return None
def _cleanup_null_datetime_minute(repo: KlineRepository) -> None:
"""检测并清除 datetime 全为 null 的旧版分钟 K 数据(迁移用)。"""
minute_dir = repo.store.data_dir / "kline_minute"
if not minute_dir.exists():
return
try:
row = repo.execute_one(
"SELECT count(*) AS total, count(datetime) AS non_null FROM kline_minute"
)
if row and row[0] > 0 and (row[1] is None or row[1] == 0):
# 全部 datetime 为 null — 清除所有分钟 K parquet
n = 0
for f in minute_dir.rglob("*.parquet"):
f.unlink()
n += 1
logger.info("cleaned %d corrupted minute-K parquet files (null datetime)", n)
except Exception as e: # noqa: BLE001
logger.debug("minute cleanup check failed: %s", e)
def _migrate_symbol_to_date_partition(repo: KlineRepository) -> None:
"""将旧版 symbol= 分区迁移为 date= 分区。迁移完成后删除旧目录。"""
minute_dir = repo.store.data_dir / "kline_minute"
if not minute_dir.exists():
return
old_dirs = [d for d in minute_dir.iterdir() if d.is_dir() and d.name.startswith("symbol=")]
if not old_dirs:
return
logger.info("migrating %d symbol-partitioned minute-K dirs to date partition…", len(old_dirs))
all_frames: list[pl.DataFrame] = []
for sym_dir in old_dirs:
for pq in sym_dir.glob("*.parquet"):
try:
df = pl.read_parquet(pq)
if "datetime" in df.columns:
df = df.filter(pl.col("datetime").is_not_null())
if not df.is_empty():
all_frames.append(df)
except Exception: # noqa: BLE001
pass
if not all_frames:
# 数据全部不可用,直接删旧目录
for d in old_dirs:
d.mkdir(parents=True, exist_ok=True)
for f in d.rglob("*"):
if f.is_file():
f.unlink()
d.rmdir()
return
combined = pl.concat(all_frames, how="diagonal_relaxed")
combined = combined.unique(subset=["symbol", "datetime"], keep="last")
# 按日期写新分区
combined = combined.with_columns(pl.col("datetime").dt.date().alias("_trade_date"))
for day_df in combined.partition_by("_trade_date"):
trade_date = day_df["_trade_date"][0]
out = minute_dir / f"date={trade_date}" / "part.parquet"
out.parent.mkdir(parents=True, exist_ok=True)
day_df = day_df.drop("_trade_date").sort("symbol", "datetime")
_atomic_write_parquet(day_df, out)
# 删旧目录
for d in old_dirs:
for f in d.rglob("*"):
if f.is_file():
f.unlink()
# 移除空目录
try:
d.rmdir()
except OSError:
pass
logger.info("minute-K migration done: %d rows migrated", combined.height)
def sync_and_persist_minute(
symbols: list[str],
repo: KlineRepository,
capset: CapabilitySet,
days: int = 5,
on_chunk_done: Callable[[int, int], None] | None = None,
) -> int:
"""同步分钟 K 并存到 Parquet(仅 raw,不前复权)。返回写入行数。
使用 start_time / end_time 区间拉取, 确保所有标的覆盖同一时间段。
on_chunk_done(current, total) 每个 chunk 完成后回调。
"""
minute_provider = preferences.get_minute_data_provider()
minute_is_custom = False
if minute_provider != "tickflow":
from app.data_providers import custom as custom_sources
minute_is_custom = custom_sources.provider_has_dataset(minute_provider, "minute")
if not symbols:
return 0
if not minute_is_custom and not capset.has(Cap.KLINE_MINUTE_BATCH):
return 0
# 迁移:旧版 _normalize_minute 未转换 timestamp→datetime,导致全部 datetime 为 null
# 检测到后直接清除(这些数据无法使用)
_cleanup_null_datetime_minute(repo)
# 迁移:旧版按 symbol= 分区转为 date= 分区
_migrate_symbol_to_date_partition(repo)
now = datetime.now()
# 计算时间区间: 首次拉取回溯 N 天, 增量从最后数据时间开始
last_dt = _latest_minute_datetime(repo)
if last_dt:
start_time = last_dt
else:
start_time = now - timedelta(days=days)
end_time = now
limit = resolve_limit(
capset,
Cap.KLINE_MINUTE_BATCH,
default_batch=100,
default_rpm=30,
default_rpm_when_unset=False,
)
df = sync_minute_batch(symbols, start_time=start_time, end_time=end_time,
batch_size=limit.batch, rpm=limit.rpm,
on_chunk_done=on_chunk_done)
if df.is_empty():
return 0
# 按日期分区写: data/kline_minute/date={YYYY-MM-DD}/part.parquet
df = df.with_columns(
pl.col("datetime").dt.date().alias("_trade_date")
)
written = 0
for day_df in df.partition_by("_trade_date"):
trade_date = day_df["_trade_date"][0]
out = repo.store.data_dir / "kline_minute" / f"date={trade_date}" / "part.parquet"
out.parent.mkdir(parents=True, exist_ok=True)
if out.exists():
existing = pl.read_parquet(out)
if "datetime" in existing.columns:
existing = existing.filter(pl.col("datetime").is_not_null())
day_df = pl.concat([existing, day_df.drop("_trade_date")]).unique(
subset=["symbol", "datetime"], keep="last",
)
else:
day_df = day_df.drop("_trade_date")
day_df = day_df.sort("symbol", "datetime")
_atomic_write_parquet(day_df, out)
written += day_df.height
# 刷新视图
try:
d = repo.store.data_dir.as_posix()
repo.db.execute(
f"""CREATE OR REPLACE VIEW kline_minute AS
SELECT * FROM read_parquet('{d}/kline_minute/**/*.parquet', union_by_name=true)"""
)
except Exception as e: # noqa: BLE001
logger.warning("refresh kline_minute view failed: %s", e)
logger.info("minute K synced: %d rows (%d symbols)", written, len(symbols))
return written