Merge pull request #251 from 0112020179/codex/fix-fuyao-history-oom

修复扶摇历史日K同步内存溢出
This commit is contained in:
wshy
2026-09-06 20:53:22 +08:00
committed by GitHub
5 changed files with 408 additions and 88 deletions
+164 -87
View File
@@ -32,12 +32,13 @@ import logging
import math
import re
import time
from collections.abc import Callable
from collections.abc import Callable, Iterator
from dataclasses import dataclass, field
from datetime import UTC, date, datetime, timedelta
from pathlib import Path
import polars as pl
import pyarrow.parquet as pq
from app.data_providers.normalizer import DAILY_COLS, normalize_daily
from app.indicators.pipeline import filter_halt_days
@@ -69,6 +70,12 @@ _DAILY10_DUMP_KIND = "daily-k-10d"
_DAILY_DUMP_KIND = "daily-k" # 10 年全量日K dump(约 172MB), 深窗口一次下载覆盖全市场
_RECENT_DUMP_DAYS = 12 # 窗口跨度 ≤ 此天数时优先走 10d dump(覆盖 ≈10 个交易日)
_PREV_CLOSE_BACKDAYS = 30 # 推导因子时向前找"除权日前收盘"的回看天数(容忍长期停牌)
_DAILY_DUMP_BATCH_ROWS = 100_000
_HIST_SYMBOL_BATCH = 50
_DAILY_DUMP_COLUMNS = [
"thscode", "adjusted", "date_ms", "open_price", "high_price", "low_price",
"close_price", "volume", "turnover",
]
def get_api_key() -> str:
@@ -444,34 +451,147 @@ class FuyaoProvider:
- 兜底: 单标的 historical 接口(窗口早于 dump 覆盖 / dump 不可用; 10 年自动分片,
逐标的节流 + 进度回调)。
"""
chunks = [
df
for df in self.iter_daily(
symbols,
start_time=start_time,
end_time=end_time,
asset_type=asset_type,
on_chunk_done=on_chunk_done,
)
if not df.is_empty()
]
return pl.concat(chunks, how="diagonal_relaxed") if chunks else pl.DataFrame()
def iter_daily(
self,
symbols: list[str],
start_time: datetime | None,
end_time: datetime | None,
asset_type: str = "stock",
on_chunk_done: Callable[[int, int], None] | None = None,
) -> Iterator[pl.DataFrame]:
"""分批产出日K,供历史同步逐批落盘,避免全市场结果累积在内存。"""
if not symbols or asset_type != "stock":
return pl.DataFrame()
return
end_dt = end_time or datetime.now()
start_dt = start_time or (end_dt - timedelta(days=365))
start_d, end_d = start_dt.date(), end_dt.date()
symset = set(symbols)
if (end_d - start_d).days <= _RECENT_DUMP_DAYS:
try:
dump = self._ensure_dump(_DAILY10_DUMP_KIND, "daily_k_10d")
if _dump_covers(dump, start_d, end_d):
df = self._daily_from_dump(dump, set(symbols), start_d, end_d)
df = self._daily_from_dump(dump, symset, start_d, end_d)
if on_chunk_done:
on_chunk_done(1, 1)
logger.info("扶摇日K(10d dump)完成: %d 行 [%s ~ %s]", df.height, start_d, end_d)
return df
logger.info("扶摇 10d dump 未覆盖窗口 [%s ~ %s], 尝试 10 年 dump", start_d, end_d)
if not df.is_empty():
yield df
return
except FuyaoError as e:
logger.warning("扶摇日K 10d dump 不可用, 尝试 10 年 dump: %s", e)
logger.warning("扶摇 10d dump 不可用: %s", e)
df = self._daily_from_big_dump(set(symbols), start_d, end_d)
if df is not None:
if on_chunk_done:
on_chunk_done(1, 1)
logger.info("扶摇日K(10 年 dump)完成: %d 行 [%s ~ %s]", df.height, start_d, end_d)
return df
df = self._daily_from_api(symbols, start_d, end_d, on_chunk_done)
logger.info("扶摇日K(单标的接口)完成: %d 行 [%s ~ %s]", df.height, start_d, end_d)
return df
dump_info = self._daily_dump_info()
sources: list[tuple[str, date, date]] = []
if dump_info:
_, dump_min, dump_max = dump_info
if start_d < dump_min:
sources.append(("api", start_d, min(end_d, dump_min - timedelta(days=1))))
overlap_start, overlap_end = max(start_d, dump_min), min(end_d, dump_max)
if overlap_start <= overlap_end:
sources.append(("dump", overlap_start, overlap_end))
tail_start = max(start_d, dump_max + timedelta(days=1))
if tail_start <= end_d and not _tail_ok(end_d, dump_max):
try:
ten = self._ensure_dump(_DAILY10_DUMP_KIND, "daily_k_10d")
ten_dates = pl.from_epoch(
ten["date_ms"].cast(pl.Int64) + _SH_MS, time_unit="ms"
).dt.date()
ten_min, ten_max = ten_dates.min(), ten_dates.max()
except (FuyaoError, KeyError):
ten_min = ten_max = None
if (
ten_min is not None
and ten_min <= dump_max + timedelta(days=1)
and _tail_ok(end_d, ten_max)
):
sources.append(("10d", tail_start, end_d))
else:
# 多年 dump 与请求终点之间存在不可验证的缺口,不能返回半段数据。
sources = [("api", start_d, end_d)]
else:
sources.append(("api", start_d, end_d))
dump_batch_count = 0
if dump_info:
dump_rows = pq.ParquetFile(dump_info[0]).metadata.num_rows
dump_batch_count = max(
1, (dump_rows + _DAILY_DUMP_BATCH_ROWS - 1) // _DAILY_DUMP_BATCH_ROWS
)
api_batch_count = (len(symbols) + _HIST_SYMBOL_BATCH - 1) // _HIST_SYMBOL_BATCH
total = sum(
dump_batch_count if kind == "dump" else 1 if kind == "10d" else api_batch_count
for kind, _, _ in sources
)
done = 0
for kind, source_start, source_end in sources:
if source_start > source_end:
continue
if kind == "dump":
path = dump_info[0] # type: ignore[index]
for df in self._iter_big_dump(path, symset, source_start, source_end):
done += 1
if on_chunk_done:
on_chunk_done(done, total)
if not df.is_empty():
yield df
elif kind == "10d":
ten = self._ensure_dump(_DAILY10_DUMP_KIND, "daily_k_10d")
df = self._daily_from_dump(ten, symset, source_start, source_end)
done += 1
if on_chunk_done:
on_chunk_done(done, total)
if not df.is_empty():
yield df
else:
batches = [
symbols[i:i + _HIST_SYMBOL_BATCH]
for i in range(0, len(symbols), _HIST_SYMBOL_BATCH)
]
for batch in batches:
rows: list[dict] = []
for symbol in batch:
rows.extend(_kline_rows(
symbol,
self._historical_bars(symbol, source_start, source_end),
))
time.sleep(_HIST_INTERVAL_S)
df = normalize_daily(rows, source=self.name)
done += 1
if on_chunk_done:
on_chunk_done(done, total)
if not df.is_empty():
yield df
def _daily_dump_info(self) -> tuple[Path, date, date] | None:
"""返回多年 dump 的路径和覆盖范围,不把大文件读入进程内存。"""
path = None
for candidate in sorted(_cache_dir().glob("daily_k__*.parquet"), reverse=True):
try:
dmin, dmax = _dump_date_range(candidate)
except Exception:
continue
if dmin is not None and dmax is not None:
return candidate, dmin, dmax
try:
path = self._ensure_dump_path(_DAILY_DUMP_KIND, "daily_k")
dmin, dmax = _dump_date_range(path)
except FuyaoError as e:
logger.warning("扶摇 10 年 dump 不可用, 回退单标的接口: %s", e)
return None
return (path, dmin, dmax) if dmin is not None and dmax is not None else None
def _daily_from_dump(
self, dump: pl.DataFrame, symset: set[str], start_d: date, end_d: date
@@ -481,6 +601,34 @@ class FuyaoProvider:
)
return self._map_daily_dump(df, symset, start_d, end_d)
def _iter_big_dump(
self, path: Path, symset: set[str], start_d: date, end_d: date
) -> Iterator[pl.DataFrame]:
"""按固定 record batch 读取多年 dump,不做单次全量 collect。"""
parquet = pq.ParquetFile(path)
columns = [name for name in _DAILY_DUMP_COLUMNS if name in parquet.schema.names]
for batch in parquet.iter_batches(
batch_size=_DAILY_DUMP_BATCH_ROWS,
columns=columns,
):
raw = pl.from_arrow(batch)
if raw.is_empty() or "date_ms" not in raw.columns or "thscode" not in raw.columns:
yield pl.DataFrame()
continue
start_ms, end_ms = _ms_of_date(start_d), _ms_of_date(end_d)
raw = raw.filter(
(pl.col("date_ms") >= start_ms)
& (pl.col("date_ms") <= end_ms)
& pl.col("thscode").is_in(sorted(symset))
)
if raw.is_empty():
yield pl.DataFrame()
continue
raw = raw.with_columns(
pl.from_epoch(pl.col("date_ms") + _SH_MS, time_unit="ms").dt.date().alias("date")
)
yield self._map_daily_dump(raw, symset, start_d, end_d)
def _map_daily_dump(
self, df: pl.DataFrame, symset: set[str], start_d: date, end_d: date
) -> pl.DataFrame:
@@ -511,77 +659,6 @@ class FuyaoProvider:
cols = [c for c in DAILY_COLS if c in out.columns]
return out.select(cols).sort(["symbol", "date"]) if not out.is_empty() else out.select(cols)
def _daily_from_big_dump(
self, symset: set[str], start_d: date, end_d: date
) -> pl.DataFrame | None:
"""深窗口主路径: 10 年全量 dump(lazy 按需筛) + 必要时 10d dump 补尾。
覆盖不了(窗口早于 10 年 / dump 拉取失败)返回 None, 由调用方走单标的接口。
"""
path = self._ensure_daily_big_dump(start_d)
if path is None:
return None
_, dmax = _dump_date_range(path)
big_hi = min(end_d, dmax)
# 窗口/标的过滤下推到 lazy 计划, 只物化需要的行(全量 10 年 ≈ 13.6M 行)
window = (
pl.scan_parquet(path)
.with_columns(
pl.from_epoch(pl.col("date_ms") + _SH_MS, time_unit="ms").dt.date().alias("date")
)
.filter(
(pl.col("date") >= start_d)
& (pl.col("date") <= big_hi)
& pl.col("thscode").is_in(sorted(symset))
)
.collect()
)
parts = [self._map_daily_dump(window, symset, start_d, big_hi)]
if not _tail_ok(end_d, dmax):
# 末端缺口(如 10 年 dump 是旧 release, end 是最近交易日): 10d dump 补尾
try:
ten = self._ensure_dump(_DAILY10_DUMP_KIND, "daily_k_10d")
ten_dates = pl.from_epoch(
ten["date_ms"].cast(pl.Int64) + _SH_MS, time_unit="ms"
).dt.date()
ten_min, ten_max = ten_dates.min(), ten_dates.max()
if (
ten_min is not None
and ten_min <= dmax + timedelta(days=1)
and _tail_ok(end_d, ten_max)
):
tail_start = max(start_d, dmax + timedelta(days=1))
parts.append(self._daily_from_dump(ten, symset, tail_start, end_d))
else:
return None # 中段或尾部仍有缺口 → 单标的兜底, 不交缺口数据
except FuyaoError as e:
logger.warning("扶摇 10d dump 补尾失败: %s", e)
return None
non_empty = [p for p in parts if not p.is_empty()]
if not non_empty:
return pl.DataFrame()
out = pl.concat(non_empty, how="vertical_relaxed")
return out.unique(subset=["symbol", "date"], keep="last").sort(["symbol", "date"])
def _daily_from_api(
self,
symbols: list[str],
start_d: date,
end_d: date,
on_chunk_done: Callable[[int, int], None] | None,
) -> pl.DataFrame:
frames: list[pl.DataFrame] = []
for i, sym in enumerate(symbols):
rows = self._historical_bars(sym, start_d, end_d)
time.sleep(_HIST_INTERVAL_S)
if rows:
df = normalize_daily(_kline_rows(sym, rows), default_symbol=sym, source=self.name)
if not df.is_empty():
frames.append(df)
if on_chunk_done:
on_chunk_done(i + 1, len(symbols))
return pl.concat(frames, how="diagonal_relaxed") if frames else pl.DataFrame()
def _historical_bars(self, symbol: str, start_d: date, end_d: date) -> list[dict]:
"""按 ≤10 年窗口分片拉取单标的原始日K。中途失败软返回已得行, 不抛出。"""
out: list[dict] = []
+58
View File
@@ -7,7 +7,11 @@
"""
from __future__ import annotations
import contextlib
import logging
import shutil
import time
import uuid
from collections.abc import Callable
from datetime import date, datetime, timedelta
@@ -177,6 +181,17 @@ def sync_and_persist_daily_batch(
end_time = end_date or datetime.now()
days = count or 365
start_time = start_date or (end_time - timedelta(days=days))
iter_daily = getattr(provider, "iter_daily", None)
if callable(iter_daily):
return _persist_daily_chunks(
iter_daily(
symbols,
start_time=start_time,
end_time=end_time,
on_chunk_done=on_chunk_done,
),
repo,
)
df = provider.get_daily(
symbols,
start_time=start_time,
@@ -228,6 +243,49 @@ def sync_and_persist_daily_batch(
return df.height
def _persist_daily_chunks(chunks, repo: KlineRepository) -> int:
"""先把流式 provider 结果写入私有 staging,完整取数后再提交正式分区。"""
staging_base = repo.store.data_dir / ".daily_sync_staging"
_sweep_stale_daily_staging(staging_base)
root = staging_base / uuid.uuid4().hex
written = 0
try:
for index, df in enumerate(chunks):
if df.is_empty():
continue
for date_df in df.partition_by("date"):
dt = date_df["date"][0]
ds = dt.isoformat() if hasattr(dt, "isoformat") else str(dt)
out = root / f"date={ds}" / f"part-{index}.parquet"
out.parent.mkdir(parents=True, exist_ok=True)
date_df.write_parquet(out)
written += date_df.height
for date_dir in sorted(root.glob("date=*")):
files = sorted(date_dir.glob("*.parquet"))
if files:
repo.append_daily(pl.scan_parquet(files).collect(engine="streaming"))
finally:
shutil.rmtree(root, ignore_errors=True)
with contextlib.suppress(OSError):
root.parent.rmdir()
return written
def _sweep_stale_daily_staging(staging_base, max_age_s: int = 24 * 60 * 60) -> None:
"""清理崩溃遗留的旧同步目录,不碰仍可能活跃的新目录。"""
if not staging_base.exists():
return
cutoff = time.time() - max_age_s
for run_dir in staging_base.iterdir():
try:
if run_dir.is_dir() and run_dir.stat().st_mtime < cutoff:
shutil.rmtree(run_dir)
except OSError:
logger.warning("failed to clean stale daily staging: %s", run_dir)
def sync_daily_by_quotes(repo: KlineRepository) -> int:
"""用实时行情接口拉全市场当日数据,覆写 kline_daily 今天分区。
@@ -0,0 +1,107 @@
"""扶摇流式日K同步: 大历史不得在内存中累积后再写入。"""
from __future__ import annotations
import os
from datetime import date, datetime
import polars as pl
import pytest
from app.services import kline_sync
from app.tickflow.repository import DataStore, KlineRepository
def _daily(symbol: str, day: date) -> pl.DataFrame:
return pl.DataFrame({
"symbol": [symbol], "date": [day], "open": [10.0], "high": [11.0],
"low": [9.0], "close": [10.5], "volume": [100.0], "amount": [1050.0],
})
class _StreamingProvider:
def __init__(self, chunks, error: Exception | None = None):
self.chunks = chunks
self.error = error
self.get_daily_called = False
def iter_daily(self, *args, **kwargs):
yield from self.chunks
if self.error:
raise self.error
def get_daily(self, *args, **kwargs):
self.get_daily_called = True
raise AssertionError("streaming provider must not collect a full daily DataFrame")
@pytest.fixture
def repo(tmp_path):
return KlineRepository(DataStore(tmp_path))
def _route_fuyao(monkeypatch, provider):
monkeypatch.setattr(kline_sync.preferences, "get_daily_data_provider", lambda: "fuyao")
from app.data_providers import custom
monkeypatch.setattr(
custom,
"provider_has_dataset",
lambda name, dataset: name == "fuyao" and dataset == "daily",
)
monkeypatch.setattr(custom, "get_provider", lambda name: provider)
def test_sync_persists_streamed_fuyao_chunks_only_after_fetch(monkeypatch, repo):
provider = _StreamingProvider([
_daily("000001.SZ", date(2020, 1, 2)),
_daily("000002.SZ", date(2020, 1, 2)),
])
_route_fuyao(monkeypatch, provider)
written = kline_sync.sync_and_persist_daily_batch(
["000001.SZ", "000002.SZ"], repo, object(),
start_date=datetime(2020, 1, 1), end_date=datetime(2020, 1, 2),
)
assert written == 2
assert provider.get_daily_called is False
stored = pl.read_parquet(
repo.store.data_dir / "kline_daily" / "date=2020-01-02" / "part.parquet"
)
assert set(stored["symbol"].to_list()) == {"000001.SZ", "000002.SZ"}
assert not (repo.store.data_dir / ".daily_sync_staging").exists()
def test_sync_discards_staging_when_streaming_fails(monkeypatch, repo):
provider = _StreamingProvider(
[_daily("000001.SZ", date(2020, 1, 2))], error=RuntimeError("network lost")
)
_route_fuyao(monkeypatch, provider)
with pytest.raises(RuntimeError, match="network lost"):
kline_sync.sync_and_persist_daily_batch(
["000001.SZ"], repo, object(),
start_date=datetime(2020, 1, 1), end_date=datetime(2020, 1, 2),
)
assert not list((repo.store.data_dir / "kline_daily").glob("date=*"))
assert not (repo.store.data_dir / ".daily_sync_staging").exists()
def test_sync_sweeps_only_stale_staging(monkeypatch, repo):
staging = repo.store.data_dir / ".daily_sync_staging"
stale = staging / "stale"
fresh = staging / "fresh"
stale.mkdir(parents=True)
fresh.mkdir()
os.utime(stale, (1, 1))
provider = _StreamingProvider([])
_route_fuyao(monkeypatch, provider)
written = kline_sync.sync_and_persist_daily_batch(
["000001.SZ"], repo, object(),
start_date=datetime(2020, 1, 1), end_date=datetime(2020, 1, 2),
)
assert written == 0
assert not stale.exists()
assert fresh.exists()
+69 -1
View File
@@ -627,8 +627,9 @@ def _hist_provider(monkeypatch, fake: _FakeHistClient, allow_dumps: bool = False
monkeypatch.setattr(fp, "get_api_key", lambda: "test-key")
monkeypatch.setattr(fp, "_HIST_INTERVAL_S", 0.0)
if not allow_dumps:
# 默认禁用 dump 档(单标的接口路径测试用); 大 dump 测试传 allow_dumps=True
# API 路径测试必须与开发机真实 dump 缓存隔离; dump 测试显式传 allow_dumps=True
p._ensure_daily_big_dump = lambda start_d: None # type: ignore[assignment]
p._daily_dump_info = lambda: None # type: ignore[assignment]
return p
@@ -740,6 +741,23 @@ def test_daily_api_soft_fail_per_symbol(monkeypatch):
assert df["symbol"].unique().to_list() == ["000001.SZ"]
def test_iter_daily_api_is_bounded_by_symbol_batch(monkeypatch):
"""历史 API 每批只产出固定 symbol 集合,不积累全市场 frames。"""
bars = {
"000001.SZ": [_bar(date(2018, 1, 2), 10.0)],
"000002.SZ": [_bar(date(2018, 1, 2), 11.0)],
"000003.SZ": [_bar(date(2018, 1, 2), 12.0)],
}
provider = _hist_provider(monkeypatch, _FakeHistClient(bars))
monkeypatch.setattr(fp, "_HIST_SYMBOL_BATCH", 2)
chunks = list(provider.iter_daily(
list(bars), datetime(2018, 1, 1), datetime(2018, 1, 3)
))
assert [set(df["symbol"].to_list()) for df in chunks] == [
{"000001.SZ", "000002.SZ"}, {"000003.SZ"}
]
def test_daily_empty_symbols_or_non_stock_returns_empty(monkeypatch):
provider = _hist_provider(monkeypatch, _FakeHistClient({}))
assert provider.get_daily([], None, None).is_empty()
@@ -899,6 +917,37 @@ def test_daily_deep_window_uses_big_dump(monkeypatch, tmp_path):
assert df["volume"].to_list() == [975_701.0, 12_345.0, 12_345.0]
def test_iter_daily_big_dump_reads_bounded_record_batches(monkeypatch, tmp_path):
"""多年 dump 分批读取,不经 LazyFrame collect() 物化整份文件。"""
rows = [_dump_bar("000001.SZ", date(2026, 1, 1) + timedelta(days=i), 10.0 + i)
for i in range(5)]
provider = _bigdump_provider(monkeypatch, tmp_path, rows)
monkeypatch.setattr(fp, "_DAILY_DUMP_BATCH_ROWS", 2)
chunks = list(provider.iter_daily(
["000001.SZ"], datetime(2026, 1, 1), datetime(2026, 1, 5)
))
assert [chunk.height for chunk in chunks] == [2, 2, 1]
assert [d for chunk in chunks for d in chunk["date"].to_list()] == [
date(2026, 1, 1), date(2026, 1, 2), date(2026, 1, 3),
date(2026, 1, 4), date(2026, 1, 5),
]
def test_iter_daily_splits_older_history_from_big_dump(monkeypatch, tmp_path):
"""请求跨 dump 起点时,只把 dump 外那段回退到历史 API。"""
provider = _bigdump_provider(
monkeypatch,
tmp_path,
[_dump_bar("000001.SZ", date(2020, 1, 2), 10.0)],
)
provider._client = _FakeHistClient({"000001.SZ": [_bar(date(2019, 12, 31), 9.0)]})
rows = pl.concat(list(provider.iter_daily(
["000001.SZ"], datetime(2019, 12, 30), datetime(2020, 1, 2)
)))
assert rows["date"].to_list() == [date(2019, 12, 31), date(2020, 1, 2)]
assert len(provider._get_client().calls) == 1
def test_daily_big_dump_tail_filled_by_10d(monkeypatch, tmp_path):
"""大 dump 末端缺口(dmax 旧)由 10d dump 补尾, 两段拼接无缝。"""
big_rows = [
@@ -944,6 +993,25 @@ def test_daily_big_dump_tail_filled_by_10d(monkeypatch, tmp_path):
assert df["close"].to_list() == [10.9, 11.0, 11.2, 11.65]
def test_daily_big_dump_weekend_end_needs_no_tail(monkeypatch, tmp_path):
"""多年 dump 覆盖到周五时,紧邻周末不应回退逐标的 API。"""
provider = _bigdump_provider(
monkeypatch,
tmp_path,
[
_dump_bar("000001.SZ", date(2026, 8, 1), 10.9),
_dump_bar("000001.SZ", date(2026, 8, 28), 11.65),
],
)
df = provider.get_daily(
["000001.SZ"], datetime(2026, 8, 1), datetime(2026, 8, 30)
)
assert df["date"].to_list() == [date(2026, 8, 1), date(2026, 8, 28)]
assert provider._get_client().calls == []
def test_daily_big_dump_midgap_falls_back(monkeypatch, tmp_path):
"""大 dump 与 10d dump 之间有中段缺口 → 整体回退, 不拼缺口数据。"""
big_rows = [