This commit is contained in:
2026-09-04 10:00:35 +08:00
parent 2735ff1fd9
commit 76b422320b
14 changed files with 611 additions and 218 deletions

View File

@@ -5,6 +5,8 @@ pandas 逐股计算(复用 app/indicators指标按 (族, 参数) 去重计
"""
from __future__ import annotations
import asyncio
import time
from dataclasses import dataclass
from datetime import datetime
from typing import Callable
@@ -108,6 +110,10 @@ _IND_SUFFIX = {"kdj_k": "K", "kdj_d": "D", "kdj_j": "J",
"boll_upper": "上轨", "boll_mid": "中轨", "boll_lower": "下轨",
"zhixing_dkx": "多空线", "zhixing_trend": "趋势线"}
# 不消费族参数的指标(展示名不拼参数串):趋势线固定 EMA(EMA(C,10),10)
# m1~m4 只属于多空线,拼上会误导(如「知行 趋势线(14,28,57,114)」)
_IND_PARAMLESS = {"zhixing_trend"}
def _family_of(indicator: str) -> str:
fam = INDICATOR_FAMILY.get(indicator)
@@ -139,6 +145,8 @@ def indicator_label(indicator: str, params: dict) -> str:
fam = FAMILIES[_family_of(indicator)]
suffix = _IND_SUFFIX.get(indicator)
name = f"{fam.label} {suffix}".strip() if suffix else fam.label
if indicator in _IND_PARAMLESS:
return name
return name + _params_str(params)
@@ -352,8 +360,17 @@ def _item_from_row(row) -> dict:
}
async def run_screen(session: AsyncSession, conds: ScreenConditions, limit: int) -> dict:
"""主流程:预筛 -> 逐股指标过滤 -> 组装 items + indicator_labels。"""
async def run_screen_events(session: AsyncSession, conds: ScreenConditions, limit: int):
"""事件流版主流程:预筛 -> 逐股指标过滤 -> 组装 items + indicator_labels。
逐步 yield 进度事件(含累计耗时 ms最后 yield {"type": "result", "result": {...}}
供 /screener/run 流式响应下发;阶段事件结构见 api.py 的 NDJSON 约定。
"""
t0 = time.perf_counter()
def _ev(**kw) -> dict:
return {"ms": round((time.perf_counter() - t0) * 1000), **kw}
if not conds.indicator and not conds.snapshot:
raise ValueError("筛选条件为空")
@@ -362,6 +379,7 @@ async def run_screen(session: AsyncSession, conds: ScreenConditions, limit: int)
)
if target_date is None:
raise DataNotReadyError("全市场数据未同步:请先在选股页点击「同步市场数据」")
yield _ev(type="stage", key="date", msg=f"数据基准日 {target_date:%Y-%m-%d}")
if any(c.field != "close" for c in conds.snapshot):
if await session.scalar(select(func.max(DailySnapshot.trade_date))) is None:
@@ -370,7 +388,9 @@ async def run_screen(session: AsyncSession, conds: ScreenConditions, limit: int)
"市值/市盈率等条件无法使用,纯指标条件不受影响"
)
yield _ev(type="stage", key="prefilter", msg="最新交易日截面预筛…")
cand = await _prefilter(session, conds, target_date)
yield _ev(type="candidates", count=len(cand), msg=f"预筛完成:{len(cand)} 只候选")
labels: dict[str, str] = {}
for c in conds.indicator:
@@ -388,6 +408,7 @@ async def run_screen(session: AsyncSession, conds: ScreenConditions, limit: int)
else:
# 圈定 K 线窗口:按 candles 全量交易日序列回溯 needed 根(不再受同步窗口限制)
need = _max_needed_bars(conds)
yield _ev(type="stage", key="bars", msg=f"载入 K 线窗口(每股回溯 {need} 根)…")
dates_res = await session.execute(
select(Candle.ts).where(Candle.timeframe == "1d").distinct()
.order_by(Candle.ts.desc()).limit(need)
@@ -395,8 +416,12 @@ async def run_screen(session: AsyncSession, conds: ScreenConditions, limit: int)
min_date = min(r[0] for r in dates_res)
bars = await _load_bars(session, cand["ts_code"].tolist(), target_date, min_date)
cand_rows = {r["ts_code"]: r for _, r in cand.iterrows()}
total = len(cand)
for ts_code, g in bars.groupby("ts_code", sort=False):
for i, (ts_code, g) in enumerate(bars.groupby("ts_code", sort=False), 1):
if i % 500 == 0 or i == total:
yield _ev(type="progress", done=i, total=total)
await asyncio.sleep(0) # 让出事件循环,流式响应即时下发
row = cand_rows.get(ts_code)
if row is None:
continue
@@ -415,13 +440,22 @@ async def run_screen(session: AsyncSession, conds: ScreenConditions, limit: int)
ind_values[c.value_indicator] = None if s is None or s.iloc[-1] != s.iloc[-1] else float(s.iloc[-1])
if ok:
items.append(_item_from_row(row) | {"indicators": ind_values})
yield _ev(type="stage", key="filter_done", msg=f"指标过滤完成:{len(items)} 只命中")
# 默认总市值降序(缺失排最后),截断 limit
items.sort(key=lambda x: (x["total_mv"] is None, -(x["total_mv"] or 0)))
return {
yield _ev(type="result", result={
"conditions": conds,
"trade_date": target_date,
"total": len(items),
"items": items[:limit],
"indicator_labels": labels,
}
})
async def run_screen(session: AsyncSession, conds: ScreenConditions, limit: int) -> dict:
"""兼容包装:跑完事件流,返回最终 result一次性拿全量无进度"""
async for ev in run_screen_events(session, conds, limit):
if ev["type"] == "result":
return ev["result"]
raise RuntimeError("run_screen_events 未产出 result") # pragma: no cover