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

@@ -19,6 +19,7 @@ from datetime import datetime
import pandas as pd
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
from fastapi.responses import StreamingResponse
from sqlalchemy import delete, func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql.elements import TextClause
@@ -518,47 +519,88 @@ async def backtest_event(
# ---------- 智能选股 ----------
@router.post("/screener/run", response_model=ScreenerRunResponse)
@router.post("/screener/run")
async def screener_run(
req: ScreenerRunRequest,
session: AsyncSession = Depends(get_session),
user=Depends(require_user),
) -> ScreenerRunResponse:
) -> StreamingResponse:
"""自然语言 -> LLM 解析条件 -> 全市场筛选。也可直传 conditions 跳过 LLM微调再跑
成功的提问(含解析出的条件与命中数)记录到 screener_queries供历史一键重跑。"""
try:
conds = req.conditions or await parse_conditions(req.text)
if not conds.indicator and not conds.snapshot:
raise HTTPException(status_code=400, detail="AI 未从描述中解析出任何筛选条件,请换种说法")
result = await engine.run_screen(session, conds, settings.screener_default_limit)
# 相同文本 + 相同条件的上一条不重复记录(一键重跑场景)
exists = (
await session.execute(
select(ScreenerQuery.id).where(
ScreenerQuery.user_id == user.id,
ScreenerQuery.text == req.text.strip(),
ScreenerQuery.conditions_json == json.dumps(conds.model_dump(), ensure_ascii=False),
NDJSON 流式响应(每行一个 JSON 事件,前端逐行渲染进度):
{"type":"stage","key":"llm|date|prefilter|bars|filter_done|done","msg":"","ms":123}
{"type":"parsed","conditions":{…},"ms":456} LLM 解析出的结构化条件
{"type":"candidates","count":5400,"msg":"","ms":…} SQL 预筛后的候选数
{"type":"progress","done":500,"total":5400} 逐股指标过滤进度
{"type":"result","result":{…ScreenerRunResponse…},"ms":…}
{"type":"error","message":"","code":400} 流中途失败HTTP 已 200
成功的提问(含解析出的条件与命中数)记录到 screener_queries供历史一键重跑。
"""
limit = settings.screener_default_limit
async def gen():
try:
if req.conditions:
conds = req.conditions
else:
yield _ndjson({"type": "stage", "key": "llm",
"msg": f"AI 解析条件中({settings.llm_model})…"})
conds = await parse_conditions(req.text)
if not conds.indicator and not conds.snapshot:
yield _ndjson({"type": "error", "code": 400,
"message": "AI 未从描述中解析出任何筛选条件,请换种说法"})
return
yield _ndjson({"type": "parsed", "conditions": conds.model_dump()})
result = None
async for ev in engine.run_screen_events(session, conds, limit):
if ev["type"] == "result":
result = ev["result"]
yield _ndjson({"type": "stage", "key": "done", "ms": ev.get("ms"),
"msg": f"筛选完成:{result['total']} 只命中(数据基准 {result['trade_date']:%Y-%m-%d}"})
else:
yield _ndjson(ev)
if result is None:
yield _ndjson({"type": "error", "code": 500, "message": "选股流程未产出结果"})
return
yield _ndjson({"type": "result", "result": ScreenerRunResponse(**result).model_dump(mode="json")})
# 相同文本 + 相同条件的上一条不重复记录(一键重跑场景)
exists = (
await session.execute(
select(ScreenerQuery.id).where(
ScreenerQuery.user_id == user.id,
ScreenerQuery.text == req.text.strip(),
ScreenerQuery.conditions_json == json.dumps(conds.model_dump(), ensure_ascii=False),
)
)
)
).scalar_one_or_none()
if exists is None:
session.add(ScreenerQuery(
user_id=user.id,
text=req.text.strip(),
conditions_json=json.dumps(conds.model_dump(), ensure_ascii=False),
hit_count=result.get("total", 0),
))
await session.commit()
return ScreenerRunResponse(**result)
except HTTPException:
raise
except DataNotReadyError as e:
raise HTTPException(status_code=409, detail=str(e))
except ValueError as e: # 未知指标/字段、条件为空
raise HTTPException(status_code=400, detail=str(e))
except ScreenerError as e:
code = 503 if "未配置 LLM_API_KEY" in str(e) else 502
raise HTTPException(status_code=code, detail=str(e))
).scalar_one_or_none()
if exists is None:
session.add(ScreenerQuery(
user_id=user.id,
text=req.text.strip(),
conditions_json=json.dumps(conds.model_dump(), ensure_ascii=False),
hit_count=result.get("total", 0),
))
await session.commit()
except DataNotReadyError as e:
yield _ndjson({"type": "error", "code": 409, "message": str(e)})
except ValueError as e: # 未知指标/字段、条件为空
yield _ndjson({"type": "error", "code": 400, "message": str(e)})
except ScreenerError as e:
code = 503 if "未配置 LLM_API_KEY" in str(e) else 502
yield _ndjson({"type": "error", "code": code, "message": str(e)})
except Exception as e: # noqa: BLE001
yield _ndjson({"type": "error", "code": 500, "message": f"选股失败: {e}"})
return StreamingResponse(gen(), media_type="application/x-ndjson",
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"})
def _ndjson(obj: dict) -> str:
"""dict -> NDJSON 行json.dumps 保证 default=str 兜底 datetime 等)。"""
return json.dumps(obj, ensure_ascii=False, default=str) + "\n"
@router.get("/screener/queries", response_model=ScreenerQueryListResponse)

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

View File

@@ -23,7 +23,7 @@ SYSTEM_PROMPT = """你是 A 股选股条件解析器。把用户的自然语言
【indicator 数组】技术指标条件,元素字段:
- "indicator": 指标名白名单kdj_k / kdj_d / kdj_jKDJ 的 K/D/J 值、rsi、macd_dif / macd_dea / macd_histMACD 的 DIF/DEA/柱、ma收盘价均线、boll_upper / boll_mid / boll_lower布林轨道、zhixing_dkx知行多空线四条收盘价均线的均值、zhixing_trend知行短期趋势线、close收盘价、pct_chg日涨跌幅%
- "params": 指标参数可选默认KDJ {"n":9,"m1":3,"m2":3}RSI {"period":14}MACD {"fast":12,"slow":26,"signal":9}MA {"period":20}BOLL {"period":20,"std":2};知行多空线 {"m1":14,"m2":28,"m3":57,"m4":114}
- "params": 指标参数可选默认KDJ {"n":9,"m1":3,"m2":3}RSI {"period":14}MACD {"fast":12,"slow":26,"signal":9}MA {"period":20}BOLL {"period":20,"std":2};知行多空线 {"m1":14,"m2":28,"m3":57,"m4":114};知行趋势线无参数(固定算法),不要给它填 params
- "op": "gt" | "ge" | "lt" | "le" | "between"
- "value": 比较数值between 时为下界),"value2": between 上界
- "value_indicator": 可选。指标与指标比较时填另一指标名(同白名单),如 "DIF大于DEA" -> indicator=macd_dif, op=gt, value_indicator=macd_dea, value=0"股价在布林带下轨之下" -> indicator=close, op=lt, value_indicator=boll_lower, value=0
@@ -57,7 +57,11 @@ SYSTEM_PROMPT = """你是 A 股选股条件解析器。把用户的自然语言
示例4
输入:近一个月股价曾经站上知行多空线的股票
输出:{"indicator":[{"indicator":"close","op":"gt","value":0,"value_indicator":"zhixing_dkx","params":{"m1":14,"m2":28,"m3":57,"m4":114},"lookback":20,"match":"any"}],"exclude_st":true,"exclude_delisted":true,"exclude_bj":true}"""
输出:{"indicator":[{"indicator":"close","op":"gt","value":0,"value_indicator":"zhixing_dkx","params":{"m1":14,"m2":28,"m3":57,"m4":114},"lookback":20,"match":"any"}],"exclude_st":true,"exclude_delisted":true,"exclude_bj":true}
示例5
输入:今天股价在知行趋势线上方的股票
输出:{"indicator":[{"indicator":"close","op":"gt","value":0,"value_indicator":"zhixing_trend","lookback":1,"match":"all"}],"exclude_st":true,"exclude_delisted":true,"exclude_bj":true}"""
class ScreenerError(RuntimeError):
@@ -68,10 +72,14 @@ def _endpoint(base_url: str) -> str:
"""归一化 base_url -> 完整 chat/completions URL。
兼容多种写法DeepSeek/OpenAI 的 .../v1、智谱 GLM 的 .../v4、或直接给完整路径。
.../anthropicDeepSeek 的 Anthropic 协议端点)剥后缀走本模块的 OpenAI 兼容协议,
key 两端点通用。
"""
base = base_url.rstrip("/")
if base.endswith("/chat/completions"):
return base
if base.endswith("/anthropic"):
base = base[: -len("/anthropic")]
if not re.search(r"/v\d+$", base): # 未带版本段则补 /v1DeepSeek/OpenAI 惯例)
base += "/v1"
return f"{base}/chat/completions"
@@ -164,7 +172,7 @@ EVENT_SYSTEM_PROMPT = """你是 A 股事件回测参数解析器。用户描述
【entry.indicator 数组】入场信号条件(必填,至少 1 条),元素字段与白名单:
- "indicator": kdj_k / kdj_d / kdj_jKDJ 的 K/D/J 值、rsi、macd_dif / macd_dea / macd_histMACD 的 DIF/DEA/柱、ma收盘价均线、boll_upper / boll_mid / boll_lower布林轨道、zhixing_dkx知行多空线、zhixing_trend知行短期趋势线、close收盘价、pct_chg日涨跌幅%
- "params": 指标参数可选默认KDJ {"n":9,"m1":3,"m2":3}RSI {"period":14}MACD {"fast":12,"slow":26,"signal":9}MA {"period":20}BOLL {"period":20,"std":2};知行多空线 {"m1":14,"m2":28,"m3":57,"m4":114}
- "params": 指标参数可选默认KDJ {"n":9,"m1":3,"m2":3}RSI {"period":14}MACD {"fast":12,"slow":26,"signal":9}MA {"period":20}BOLL {"period":20,"std":2};知行多空线 {"m1":14,"m2":28,"m3":57,"m4":114};知行趋势线无参数(固定算法),不要给它填 params
- "op": "gt" | "ge" | "lt" | "le" | "between""value"between 时为下界)、"value2"(上界)
- "value_indicator": 指标与指标比较时填另一指标名(同白名单),如 "DIF 大于 DEA" -> indicator=macd_dif, op=gt, value_indicator=macd_dea, value=0
- "value_params": 比较对象指标参数不同时指定,如 "MA5 上穿 MA20" -> indicator=ma, params={"period":5}, op=gt, value_indicator=ma, value_params={"period":20}, value=0