perf(backend): 内存优化三项——全市场研究不再占满 8G
1) 数据装配流式+列裁剪:Repository 新增 stream_range_many_columns(只 SELECT 所需列、SQL 侧转 REAL、yield_per 分批),引擎按 required_columns 取数 (LocalEngine 仅 close+因子字段),消除 ORM/Decimal 全量物化; 2) 研究 Job 独立子进程执行(job.mode=subprocess):python -m app.cli.run_job 在子进程内设 RLIMIT_AS 上限,OOM 归档 failed 而非拖垮 API worker; 子进程异常退出由父进程补记 failed;并发上限 2; 3) 服务启动清理:残留 queued/running Job 标记 failed(防永久 running)。 实测同款全市场回测:uvicorn worker RSS 稳定 ~220MB,任务峰值内存由 4.1GB+ 降至 ~470MB,24s 完成并归档(此前 43s 未完成即 OOM)。 新增/更新测试 96 passed,ruff 干净。
This commit is contained in:
@@ -10,6 +10,7 @@ from typing import Protocol
|
||||
import pandas as pd
|
||||
|
||||
from app.domain.entities.research import BacktestResult, FactorTestReport, ResearchSpec
|
||||
from app.quant.factors import FactorError, get_factor
|
||||
from app.quant.local_engine import (
|
||||
TopKBacktestRunner,
|
||||
build_factor_panels,
|
||||
@@ -17,12 +18,30 @@ from app.quant.local_engine import (
|
||||
run_spec_factor_test,
|
||||
)
|
||||
|
||||
# LocalEngine 路径恒需 close(TopK 收盘撮合 / 前瞻收益)
|
||||
_CLOSE = {"close"}
|
||||
|
||||
|
||||
def factor_required_columns(spec: ResearchSpec) -> set[str]:
|
||||
"""spec 因子计算 + 回测撮合所需的行情数值列(含 close)。"""
|
||||
needed = set(_CLOSE)
|
||||
for fs in spec.factors:
|
||||
try:
|
||||
defn, _fn = get_factor(fs.name)
|
||||
except FactorError:
|
||||
continue # 未知因子由执行期统一报错
|
||||
needed.update(defn.requires)
|
||||
return needed
|
||||
|
||||
|
||||
class QuantEngine(Protocol):
|
||||
"""研究引擎端口:因子面板构建 / 因子测试 / 回测。"""
|
||||
|
||||
name: str
|
||||
|
||||
def required_columns(self, spec: ResearchSpec) -> set[str]:
|
||||
"""执行该 spec 所需的行情数值列(数据装配按此裁剪,控制内存)。"""
|
||||
|
||||
def run_factor_test(
|
||||
self, daily: pd.DataFrame, spec: ResearchSpec, horizon_days: int = 21
|
||||
) -> FactorTestReport: ...
|
||||
@@ -35,6 +54,9 @@ class LocalEngine:
|
||||
|
||||
name = "local"
|
||||
|
||||
def required_columns(self, spec: ResearchSpec) -> set[str]:
|
||||
return factor_required_columns(spec)
|
||||
|
||||
def run_factor_test(
|
||||
self, daily: pd.DataFrame, spec: ResearchSpec, horizon_days: int = 21
|
||||
) -> FactorTestReport:
|
||||
|
||||
@@ -39,10 +39,21 @@ class QlibEngine(QuantEngine):
|
||||
|
||||
name = "qlib"
|
||||
|
||||
# qlib 落盘需要 OHLCV(vwap/factor 由本地合成,不来自行情表)
|
||||
_DUMP_COLUMNS = {"open", "high", "low", "close", "volume", "amount"}
|
||||
|
||||
def __init__(self, qlib_dir: Path | None = None) -> None:
|
||||
# 默认落盘到 data/qlib(与 storage.qlib_dir 一致);可注入临时目录便于测试
|
||||
self.qlib_dir = qlib_dir or _default_qlib_dir()
|
||||
|
||||
def required_columns(self, spec: ResearchSpec) -> set[str]:
|
||||
# 回测需把全字段落盘成 qlib 数据集;因子测试走共享实现,只需 close+因子字段
|
||||
if spec.type == "backtest":
|
||||
return set(self._DUMP_COLUMNS)
|
||||
from app.quant.engine import factor_required_columns
|
||||
|
||||
return factor_required_columns(spec)
|
||||
|
||||
def run_factor_test(
|
||||
self, daily: pd.DataFrame, spec: ResearchSpec, horizon_days: int = 21
|
||||
) -> FactorTestReport:
|
||||
|
||||
@@ -2,10 +2,15 @@
|
||||
|
||||
本层是业务入口:API / Agent 只能调用这里的用例(AGENT.md §16/§17),
|
||||
禁止直接拼接引擎配置。数据一律经 Repository 获取(防未来函数由查询层保证)。
|
||||
|
||||
内存优化:大数据面板优先走 Repository 的流式列裁剪查询
|
||||
(stream_range_many_columns,SQL 侧转 REAL、分批拉取),避免 ORM 对象 /
|
||||
Decimal 全量物化;老实现回退到 get_range_many 逐实体路径。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pandas as pd
|
||||
@@ -23,6 +28,9 @@ from app.domain.repositories.market import (
|
||||
)
|
||||
from app.quant.engine import QuantEngine
|
||||
|
||||
# 流式路径每攒多少行落一个 DataFrame 分片(控制 concat 峰值)
|
||||
_FRAME_CHUNK_ROWS = 50_000
|
||||
|
||||
|
||||
def filter_stocks(stocks: list[Stock], universe: UniverseSpec, as_of: date) -> list[Stock]:
|
||||
"""按股票池口径过滤(名称含 ST 判定 —— 名称快照为当日口径,属历史可追溯数据)。"""
|
||||
@@ -55,6 +63,31 @@ def bars_to_daily_df(bars) -> pd.DataFrame:
|
||||
return df
|
||||
|
||||
|
||||
def _frame_from_stream(rows: Iterable[tuple], columns: list[str]) -> pd.DataFrame:
|
||||
"""把流式 (symbol, trade_date_iso, *float_cols) 分批拼成 float 长表。
|
||||
|
||||
全程只保留分批 DataFrame + 最终一份结果,避免整批 tuple/Decimal 同时驻留。
|
||||
返回列:symbol, trade_date(datetime64), *columns(float64)。
|
||||
"""
|
||||
cols = ["symbol", "trade_date", *columns]
|
||||
buf: list[tuple] = []
|
||||
pieces: list[pd.DataFrame] = []
|
||||
for row in rows:
|
||||
buf.append(row)
|
||||
if len(buf) >= _FRAME_CHUNK_ROWS:
|
||||
pieces.append(pd.DataFrame(buf, columns=cols))
|
||||
buf = []
|
||||
if buf:
|
||||
pieces.append(pd.DataFrame(buf, columns=cols))
|
||||
if not pieces:
|
||||
return pd.DataFrame()
|
||||
df = pd.concat(pieces, ignore_index=True)
|
||||
df["trade_date"] = pd.to_datetime(df["trade_date"])
|
||||
for col in columns: # NULL → NaN,统一 float64
|
||||
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||||
return df
|
||||
|
||||
|
||||
class ResearchService:
|
||||
"""研究用例入口(因子测试 / 回测)。依赖注入 Repository 与引擎。"""
|
||||
|
||||
@@ -89,9 +122,22 @@ class ResearchService:
|
||||
stocks = filter_stocks(self._stock_repo.list(), spec.universe, as_of=start)
|
||||
if not stocks:
|
||||
return pd.DataFrame()
|
||||
symbols = [s.symbol for s in stocks]
|
||||
|
||||
# 引擎所需列裁剪(LocalEngine 只取 close + 因子字段;Qlib 回测取全 OHLCV)
|
||||
required = self._engine.required_columns(spec)
|
||||
streamer = getattr(self._daily_repo, "stream_range_many_columns", None)
|
||||
if streamer is not None:
|
||||
try:
|
||||
return _frame_from_stream(
|
||||
streamer(symbols, data_start, end, sorted(required)), sorted(required)
|
||||
)
|
||||
except Exception: # noqa: BLE001 —— 流式路径失败回退旧路径(兼容非 SQL 实现)
|
||||
pass
|
||||
# 旧路径:逐实体(供内存 / Fake 仓储等实现使用)
|
||||
get_many = getattr(self._daily_repo, "get_range_many", None)
|
||||
if get_many is not None:
|
||||
bars = list(get_many([s.symbol for s in stocks], data_start, end))
|
||||
bars = list(get_many(symbols, data_start, end))
|
||||
else: # 兜底:逐只查询
|
||||
bars = []
|
||||
for s in stocks:
|
||||
|
||||
Reference in New Issue
Block a user