提交
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,7 +23,7 @@ SYSTEM_PROMPT = """你是 A 股选股条件解析器。把用户的自然语言
|
||||
|
||||
【indicator 数组】技术指标条件,元素字段:
|
||||
- "indicator": 指标名,白名单:kdj_k / kdj_d / kdj_j(KDJ 的 K/D/J 值)、rsi、macd_dif / macd_dea / macd_hist(MACD 的 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、或直接给完整路径。
|
||||
.../anthropic(DeepSeek 的 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): # 未带版本段则补 /v1(DeepSeek/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_j(KDJ 的 K/D/J 值)、rsi、macd_dif / macd_dea / macd_hist(MACD 的 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
|
||||
|
||||
Reference in New Issue
Block a user