提交
This commit is contained in:
@@ -9,8 +9,8 @@ DATA_DEFAULT_START=20200101
|
||||
# ---- Redis 读缓存(股票列表/筛选项;留空则不缓存直查数据库)----
|
||||
REDIS_URL=redis://default:26d5c71d57344f37b8b4ddb567f2652f0c7ef41c774284ad@cirry.cn:6379
|
||||
|
||||
# ---- LLM(智能选股;智谱 GLM,OpenAI 兼容协议)----
|
||||
# ---- LLM(智能选股;DeepSeek,OpenAI 兼容协议;/anthropic 后缀会被 _endpoint 自动归一)----
|
||||
# key 在 https://bigmodel.cn 控制台获取,格式形如 xxxxxxxx.yyyyyyyy(id.secret)
|
||||
LLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4
|
||||
LLM_API_KEY=ea24bbdd3d2d4dd2b8f03de4c9a5d984.9X1Hz1yKx0VKSnrU
|
||||
LLM_MODEL=glm-5.2
|
||||
LLM_BASE_URL=https://api.deepseek.com
|
||||
LLM_API_KEY=sk-b09acbd6c0ca4f94818b6deb039d6515
|
||||
LLM_MODEL=deepseek-chat
|
||||
|
||||
@@ -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,19 +519,53 @@ 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,供历史一键重跑。"""
|
||||
|
||||
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:
|
||||
conds = req.conditions or await parse_conditions(req.text)
|
||||
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:
|
||||
raise HTTPException(status_code=400, detail="AI 未从描述中解析出任何筛选条件,请换种说法")
|
||||
result = await engine.run_screen(session, conds, settings.screener_default_limit)
|
||||
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(
|
||||
@@ -549,16 +584,23 @@ async def screener_run(
|
||||
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))
|
||||
yield _ndjson({"type": "error", "code": 409, "message": str(e)})
|
||||
except ValueError as e: # 未知指标/字段、条件为空
|
||||
raise HTTPException(status_code=400, detail=str(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
|
||||
raise HTTPException(status_code=code, detail=str(e))
|
||||
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
|
||||
|
||||
158
backend_run.log
158
backend_run.log
@@ -1,61 +1,103 @@
|
||||
INFO: Started server process [17808]
|
||||
INFO: Started server process [42884]
|
||||
INFO: Waiting for application startup.
|
||||
INFO: Application startup complete.
|
||||
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
|
||||
INFO: Started server process [47304]
|
||||
INFO: Waiting for application startup.
|
||||
INFO: Application startup complete.
|
||||
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
|
||||
INFO: 127.0.0.1:52358 - "GET /docs HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52494 - "POST /api/auth/login HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52497 - "GET /api/preferences HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52508 - "GET /api/stocks/facets HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52506 - "GET /api/stocks?watched_only=true&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52531 - "GET /api/trades?ts_code=002648.SZ HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52534 - "GET /api/watchlist HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52533 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52537 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-09 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52541 - "GET /api/trades?ts_code=000776.SZ HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52542 - "GET /api/screener/preview/000776.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52544 - "GET /api/screener/preview/000776.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-09 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52547 - "GET /api/trades?ts_code=000783.SZ HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52548 - "GET /api/screener/preview/000783.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52551 - "GET /api/screener/preview/000783.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-09 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52553 - "GET /api/screener/preview/000783.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-09 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52555 - "GET /api/screener/preview/000783.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-09 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52558 - "GET /api/screener/preview/000783.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-09 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52560 - "GET /api/screener/preview/000783.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-09 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52565 - "GET /api/screener/preview/000783.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-09 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52570 - "GET /api/trades?ts_code=002074.SZ HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52572 - "GET /api/screener/preview/002074.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52574 - "GET /api/screener/preview/002074.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-09 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52580 - "GET /api/trades?ts_code=002100.SZ HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52581 - "GET /api/screener/preview/002100.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52583 - "GET /api/screener/preview/002100.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-09 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52594 - "GET /api/trades?ts_code=002648.SZ HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52595 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52597 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-09 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52623 - "GET /api/trades?ts_code=600030.SH HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52624 - "GET /api/screener/preview/600030.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52628 - "GET /api/screener/preview/600030.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-09 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52640 - "GET /api/trades?ts_code=600036.SH HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52642 - "GET /api/screener/preview/600036.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52644 - "GET /api/screener/preview/600036.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-09 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52671 - "GET /api/stocks?market=%E4%B8%BB%E6%9D%BF&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52674 - "GET /api/market/overview HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52708 - "GET /api/auth/me HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52712 - "GET /api/preferences HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52713 - "GET /api/market/overview HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52731 - "GET /api/auth/me HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52734 - "GET /api/market/overview HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52733 - "GET /api/preferences HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52742 - "GET /api/market/overview HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52757 - "GET /api/screener/queries?limit=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52772 - "GET /api/screener/queries?limit=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52752 - "GET /api/screener/sync/status HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:52771 - "POST /api/screener/run HTTP/1.1" 200 OK
|
||||
INFO: Started server process [764]
|
||||
INFO: Waiting for application startup.
|
||||
INFO: Application startup complete.
|
||||
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
|
||||
INFO: 127.0.0.1:60296 - "GET /docs HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:60648 - "GET /api/auth/me HTTP/1.1" 401 Unauthorized
|
||||
INFO: 127.0.0.1:62525 - "POST /api/auth/login HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62531 - "GET /api/preferences HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62573 - "GET /api/screener/queries?limit=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62746 - "POST /api/screener/run HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:62747 - "GET /api/screener/queries?limit=20 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:64917 - "GET /api/watchlist HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:64915 - "GET /api/trades?ts_code=601398.SH HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:64916 - "GET /api/screener/preview/601398.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:64933 - "GET /api/screener/preview/601398.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:64942 - "GET /api/screener/preview/601398.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:64947 - "GET /api/trades?ts_code=601939.SH HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:64949 - "GET /api/screener/preview/601939.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:64958 - "GET /api/trades?ts_code=601288.SH HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:64954 - "GET /api/screener/preview/601939.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:64964 - "GET /api/screener/preview/601288.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:64965 - "GET /api/screener/preview/601288.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:64970 - "GET /api/trades?ts_code=600941.SH HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:64971 - "GET /api/screener/preview/600941.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:64982 - "GET /api/trades?ts_code=601988.SH HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:64978 - "GET /api/screener/preview/600941.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:64983 - "GET /api/screener/preview/601988.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:64987 - "GET /api/screener/preview/601988.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:64992 - "GET /api/trades?ts_code=601857.SH HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:64993 - "GET /api/screener/preview/601857.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65001 - "GET /api/trades?ts_code=600938.SH HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:64997 - "GET /api/screener/preview/601857.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65004 - "GET /api/screener/preview/600938.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65008 - "GET /api/screener/preview/600938.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65014 - "GET /api/trades?ts_code=601628.SH HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65016 - "GET /api/screener/preview/601628.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65023 - "GET /api/screener/preview/601628.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65025 - "GET /api/trades?ts_code=601088.SH HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65028 - "GET /api/screener/preview/601088.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65029 - "GET /api/screener/preview/601088.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-07-29 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65037 - "GET /api/trades?ts_code=600036.SH HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65038 - "GET /api/screener/preview/600036.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65040 - "GET /api/screener/preview/600036.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65046 - "GET /api/trades?ts_code=601318.SH HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65047 - "GET /api/screener/preview/601318.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65057 - "GET /api/screener/preview/601318.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65060 - "GET /api/trades?ts_code=600900.SH HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65063 - "GET /api/screener/preview/600900.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65064 - "GET /api/screener/preview/600900.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65075 - "GET /api/trades?ts_code=688256.SH HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65076 - "GET /api/screener/preview/688256.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65078 - "GET /api/screener/preview/688256.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65085 - "GET /api/trades?ts_code=601658.SH HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65086 - "GET /api/screener/preview/601658.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65090 - "GET /api/screener/preview/601658.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65100 - "GET /api/trades?ts_code=000333.SZ HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65101 - "GET /api/screener/preview/000333.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65104 - "GET /api/screener/preview/000333.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65108 - "GET /api/trades?ts_code=600028.SH HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65110 - "GET /api/screener/preview/600028.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65114 - "GET /api/screener/preview/600028.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65123 - "GET /api/trades?ts_code=601328.SH HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65125 - "GET /api/screener/preview/601328.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65140 - "GET /api/screener/preview/601328.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65144 - "GET /api/trades?ts_code=601728.SH HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65148 - "GET /api/screener/preview/601728.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65149 - "GET /api/screener/preview/601728.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65154 - "GET /api/trades?ts_code=601998.SH HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65155 - "GET /api/screener/preview/601998.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65160 - "GET /api/screener/preview/601998.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65164 - "GET /api/trades?ts_code=002475.SZ HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65168 - "GET /api/screener/preview/002475.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65169 - "GET /api/screener/preview/002475.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65176 - "GET /api/trades?ts_code=600183.SH HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65177 - "GET /api/screener/preview/600183.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65180 - "GET /api/screener/preview/600183.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65187 - "GET /api/trades?ts_code=601319.SH HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65188 - "GET /api/screener/preview/601319.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65190 - "GET /api/screener/preview/601319.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65196 - "GET /api/trades?ts_code=601869.SH HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65197 - "GET /api/screener/preview/601869.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65201 - "GET /api/screener/preview/601869.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65207 - "GET /api/trades?ts_code=601601.SH HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65208 - "GET /api/screener/preview/601601.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65212 - "GET /api/screener/preview/601601.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65217 - "GET /api/trades?ts_code=600000.SH HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65220 - "GET /api/screener/preview/600000.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65221 - "GET /api/screener/preview/600000.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65226 - "GET /api/trades?ts_code=601225.SH HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65227 - "GET /api/screener/preview/601225.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65232 - "GET /api/screener/preview/601225.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65237 - "GET /api/trades?ts_code=688808.SH HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65239 - "GET /api/screener/preview/688808.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65246 - "GET /api/trades?ts_code=002714.SZ HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65247 - "GET /api/screener/preview/002714.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65249 - "GET /api/screener/preview/002714.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65256 - "GET /api/screener/preview/688808.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65255 - "GET /api/trades?ts_code=688808.SH HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65278 - "GET /api/screener/preview/002714.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65277 - "GET /api/trades?ts_code=002714.SZ HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65282 - "GET /api/screener/preview/002714.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65285 - "GET /api/trades?ts_code=600309.SH HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65288 - "GET /api/screener/preview/600309.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:65292 - "GET /api/screener/preview/600309.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
PreviewResponse,
|
||||
ScreenerQueryItem,
|
||||
ScreenerRunRequest,
|
||||
ScreenerStreamEvent,
|
||||
ScreenerRunResponse,
|
||||
ScreenerSyncRequest,
|
||||
ScreenerSyncStatus,
|
||||
@@ -120,10 +121,38 @@ export async function postEventBacktest(req: EventBacktestRequest): Promise<Even
|
||||
}
|
||||
}
|
||||
|
||||
export async function runScreener(req: ScreenerRunRequest): Promise<ScreenerRunResponse> {
|
||||
const res = await apiFetch('/api/screener/run', { method: 'POST', body: JSON.stringify(req) });
|
||||
/** 选股流式执行:逐行回调 NDJSON 事件(阶段/进度/解析条件),最终返回完整结果。
|
||||
* signal 中止后(含读流中途)以 AbortError 拒绝,由调用方决定如何呈现。 */
|
||||
export async function runScreener(
|
||||
req: ScreenerRunRequest,
|
||||
onEvent?: (ev: ScreenerStreamEvent) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ScreenerRunResponse> {
|
||||
const res = await apiFetch('/api/screener/run', { method: 'POST', body: JSON.stringify(req), signal });
|
||||
if (!res.ok) throw new ApiError(await readError(res, `选股失败 (HTTP ${res.status})`), res.status);
|
||||
return (await res.json()) as ScreenerRunResponse;
|
||||
if (!res.body) throw new ApiError('当前环境不支持流式响应', 0);
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
let result: ScreenerRunResponse | null = null;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
let nl: number;
|
||||
while ((nl = buf.indexOf('\n')) >= 0) {
|
||||
const line = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (!line) continue;
|
||||
const ev = JSON.parse(line) as ScreenerStreamEvent;
|
||||
if (ev.type === 'error') throw new ApiError(ev.message, ev.code ?? 0);
|
||||
if (ev.type === 'result') result = ev.result;
|
||||
onEvent?.(ev);
|
||||
}
|
||||
}
|
||||
if (!result) throw new ApiError('选股响应中断(未收到结果事件)', 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function startScreenerSync(req: ScreenerSyncRequest = {}): Promise<ScreenerSyncStatus> {
|
||||
|
||||
@@ -148,6 +148,15 @@ export interface ScreenerRunResponse {
|
||||
indicator_labels: Record<string, string>;
|
||||
}
|
||||
|
||||
// ---------- 选股流式事件(镜像 api.py /screener/run 的 NDJSON 约定) ----------
|
||||
export type ScreenerStreamEvent =
|
||||
| { type: 'stage'; key: string; msg: string; ms?: number }
|
||||
| { type: 'parsed'; conditions: ScreenConditions; ms?: number }
|
||||
| { type: 'candidates'; count: number; msg?: string; ms?: number }
|
||||
| { type: 'progress'; done: number; total: number }
|
||||
| { type: 'result'; result: ScreenerRunResponse; ms?: number }
|
||||
| { type: 'error'; message: string; code?: number };
|
||||
|
||||
export interface ScreenerSyncRequest {
|
||||
days?: number;
|
||||
force?: boolean;
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
threeWaves, triangle, xabcd,
|
||||
} from '@klinecharts/extension';
|
||||
import { useSettingsStore, TOOLTIP_FIELDS, DEFAULT_TOOLTIP_FIELDS } from '@/stores/settings';
|
||||
import { registerCustomIndicators, computeZhixingSeries, DKX_PERIODS, ZHIXING_NAME, CHIPS_NAME, type ChipsExt } from '@/indicators';
|
||||
import { registerCustomIndicators, computeZhixingSeries, DKX_PERIODS, ZHIXING_NAME, CHIPS_NAME, CHIPS_COL_PX, type ChipsExt } from '@/indicators';
|
||||
import type { Candle, TooltipField } from '@/api/types';
|
||||
|
||||
// 通达信导入的自定义主图指标注册表;知行序列走 PV 管道按时间戳取预计算值。
|
||||
@@ -671,11 +671,62 @@ function syncChipsIndicator() {
|
||||
if (want) {
|
||||
if (exists) chart.removeIndicator({ name: CHIPS_NAME });
|
||||
chart.createIndicator({ name: CHIPS_NAME, paneId: 'candle_pane', extendData: props.chips ?? undefined });
|
||||
syncRightOffset(); // 开关即扩留白(不等数据),数据到达时列直接落在已预留的空白里
|
||||
} else if (exists) {
|
||||
chart.removeIndicator({ name: CHIPS_NAME });
|
||||
restoreRightOffset();
|
||||
} else {
|
||||
restoreRightOffset(); // 数据未到即关:同样要还原已扩出去的留白
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 筹码峰竖列的右侧留白管理 ----------
|
||||
// klinecharts 滚动状态唯一变量是 diff(最后一根K距绘图区右缘的 bar 数),
|
||||
// setOffsetRightDistance(px) 会把 diff 直接重置为 px/barSpace 并 relayout——等价于
|
||||
// “视口拽回实时位并留 px 空白”。因此只在「右区」(末根K在视野内,getVisibleRange().to
|
||||
// 达到 data 长度)时调用;用户在看历史时绝不调,滚回右区后由 onScroll 防抖补一次吸附
|
||||
// (同花顺松手吸附同款)。绝不调 setMaxOffsetRightDistance:会把滚动限制切到
|
||||
// 'distance' 模式,默认钳到 50px 且改变滚动边缘行为。
|
||||
const BASE_RIGHT_PX = 28;
|
||||
let syncingRightOffset = false; // 重入守卫:set 会同步派发 onVisibleRangeChange
|
||||
let scrollReanchorTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let managedRightPx = 0; // 筹码开启期间我们 set 过的目标值(关筹码时判断“是否还归我们管”)
|
||||
|
||||
/** 目标右侧留白:开筹码 = 列宽 + 一根K线 + 余量(bar 最宽 50px → 峰值 ~182px) */
|
||||
function targetRightPx(): number {
|
||||
if (!props.showChips) return BASE_RIGHT_PX;
|
||||
const bar = chart?.getBarSpace().bar || 10;
|
||||
return Math.round(CHIPS_COL_PX + bar + 12);
|
||||
}
|
||||
|
||||
/** 开筹码期间保证留白盖住竖列:只扩不缩(用户自己拖出的更大留白不动) */
|
||||
function syncRightOffset(): void {
|
||||
if (!chart || syncingRightOffset || !props.showChips) return;
|
||||
const list = chart.getDataList();
|
||||
if (!list.length || chart.getVisibleRange().to < list.length) return; // 右区守卫
|
||||
const target = targetRightPx();
|
||||
if (chart.getOffsetRightDistance() >= target - 2) return; // 阈值防循环 + 只扩不缩
|
||||
syncingRightOffset = true;
|
||||
try {
|
||||
chart.setOffsetRightDistance(target);
|
||||
managedRightPx = target;
|
||||
} finally {
|
||||
syncingRightOffset = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 关筹码时一次性还原 28px:仅当留白仍在我们管理范围内(用户没手动拖大过) */
|
||||
function restoreRightOffset(): void {
|
||||
if (!chart || managedRightPx === 0) return;
|
||||
const list = chart.getDataList();
|
||||
const cur = chart.getOffsetRightDistance();
|
||||
if (list.length && chart.getVisibleRange().to >= list.length
|
||||
&& cur > BASE_RIGHT_PX + 2 && cur <= managedRightPx + 2) {
|
||||
chart.setOffsetRightDistance(BASE_RIGHT_PX);
|
||||
}
|
||||
managedRightPx = 0;
|
||||
}
|
||||
|
||||
function build() {
|
||||
if (!container.value || props.candles.length === 0) return;
|
||||
UP = settings.upHex;
|
||||
@@ -801,7 +852,23 @@ function build() {
|
||||
const from = (payload as { data?: { from?: unknown } }).data?.from;
|
||||
if (typeof from === 'number' && from < 200) maybePrefetch(myEpoch);
|
||||
});
|
||||
ch.setOffsetRightDistance(28);
|
||||
// 右侧留白:开筹码时按竖列宽度预留(scrollToRealTime 以它为锚点,先后顺序不能换)
|
||||
ch.setOffsetRightDistance(props.showChips ? targetRightPx() : BASE_RIGHT_PX);
|
||||
// 筹码竖列的留白跟随:缩放后 barSpace 变化(onZoom 触发时新值已生效)立即补扩;
|
||||
// 拖拽期间 onScroll 逐帧触发、且拖拽每帧从手势起点快照重算 diff,逐帧同步会与拖拽
|
||||
// “互搏”(从实时位拖向历史会被逐帧拽回),故防抖 150ms 只在手势结束后补一次
|
||||
ch.subscribeAction('onZoom', () => {
|
||||
if (myEpoch !== epoch) return;
|
||||
syncRightOffset();
|
||||
});
|
||||
ch.subscribeAction('onScroll', () => {
|
||||
if (myEpoch !== epoch) return;
|
||||
if (scrollReanchorTimer) clearTimeout(scrollReanchorTimer);
|
||||
scrollReanchorTimer = setTimeout(() => {
|
||||
scrollReanchorTimer = null;
|
||||
if (myEpoch === epoch) syncRightOffset();
|
||||
}, 150);
|
||||
});
|
||||
ch.scrollToRealTime();
|
||||
// 日期跳转:build 尾部的 scrollToRealTime 会把视口重置到最新一根,居中必须放在它之后
|
||||
//(init 数据在 setPeriod 时已同步落入图表,这里可直接定位)。
|
||||
@@ -814,6 +881,8 @@ function build() {
|
||||
|
||||
function teardown() {
|
||||
if (subHTimer) { clearTimeout(subHTimer); subHTimer = null; }
|
||||
if (scrollReanchorTimer) { clearTimeout(scrollReanchorTimer); scrollReanchorTimer = null; }
|
||||
managedRightPx = 0;
|
||||
if (container.value) dispose(container.value);
|
||||
chart = null;
|
||||
hover.value = null;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { deleteScreenerQuery, getScreenerQueries } from '@/api/client';
|
||||
import type { ScreenConditions, ScreenerQueryItem } from '@/api/types';
|
||||
import type { ScreenerQueryItem } from '@/api/types';
|
||||
|
||||
const props = defineProps<{ loading: boolean }>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'run', text: string, conditions?: ScreenConditions | null): void;
|
||||
(e: 'run', text: string): void;
|
||||
(e: 'ran'): void;
|
||||
(e: 'stop'): void;
|
||||
}>();
|
||||
|
||||
const text = ref('');
|
||||
@@ -18,13 +19,14 @@ const examples = [
|
||||
];
|
||||
|
||||
function run() {
|
||||
if (props.loading) return; // 执行中 Ctrl+Enter 不重复触发(要停止点「停止」)
|
||||
if (text.value.trim()) {
|
||||
emit('run', text.value.trim());
|
||||
emit('ran');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 提问历史(入库,可一键重跑 / 删除) ----------
|
||||
// ---------- 提问历史(点击只填入输入框,不触发查询 / 删除) ----------
|
||||
const history = ref<ScreenerQueryItem[]>([]);
|
||||
const historyOpen = ref(false);
|
||||
|
||||
@@ -38,12 +40,9 @@ async function refreshHistory() {
|
||||
} catch { history.value = []; }
|
||||
}
|
||||
|
||||
function rerun(q: ScreenerQueryItem) {
|
||||
function fillQuery(q: ScreenerQueryItem) {
|
||||
text.value = q.text;
|
||||
historyOpen.value = false;
|
||||
// 存过 conditions 的记录直传条件,跳过 LLM 重新解析
|
||||
emit('run', q.text, q.conditions ?? null);
|
||||
emit('ran');
|
||||
}
|
||||
|
||||
async function removeQuery(id: number) {
|
||||
@@ -87,8 +86,8 @@ defineExpose({ refreshHistory });
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 flex-1 text-left"
|
||||
:title="q.conditions ? '点击直传条件重跑(不重新解析)' : '点击填入并重跑'"
|
||||
@click="rerun(q)"
|
||||
title="点击填入输入框(不自动执行)"
|
||||
@click="fillQuery(q)"
|
||||
>
|
||||
<span class="block truncate text-sm text-[#E8EAED]">{{ q.text }}</span>
|
||||
<span class="mt-0.5 block text-xs text-[#9BA3AE]">
|
||||
@@ -134,10 +133,19 @@ defineExpose({ refreshHistory });
|
||||
支持 KDJ / RSI / MACD / 布林 / 均线指标条件,市值 / 市盈率 / 换手率等快照条件,以及「连续 N 天」「近 N 天任一天」时间窗口。
|
||||
按 <kbd class="rounded border border-[#26272E] bg-black px-1">Ctrl</kbd>+<kbd class="rounded border border-[#26272E] bg-black px-1">Enter</kbd> 快速筛选。
|
||||
</p>
|
||||
<button type="button" class="btn-primary shrink-0 disabled:opacity-50" :disabled="loading || !text.trim()" @click="run">
|
||||
<svg v-if="loading" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
|
||||
<svg v-else class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7" /><path d="M21 21l-4.3-4.3" /></svg>
|
||||
{{ loading ? '筛选中…' : '开始筛选' }}
|
||||
<!-- 执行中变为「停止」:中止 LLM 解析与逐股过滤(服务端随连接断开自动取消) -->
|
||||
<button
|
||||
v-if="loading"
|
||||
type="button"
|
||||
class="flex shrink-0 items-center gap-1.5 rounded-lg border border-red-500/50 bg-red-500/15 px-4 py-2 text-sm font-medium text-red-300 transition-colors hover:bg-red-500/25"
|
||||
@click="emit('stop')"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="6" y="6" width="12" height="12" rx="1.5" /></svg>
|
||||
停止
|
||||
</button>
|
||||
<button v-else type="button" class="btn-primary shrink-0 disabled:opacity-50" :disabled="!text.trim()" @click="run">
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7" /><path d="M21 21l-4.3-4.3" /></svg>
|
||||
开始筛选
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
// 个股筹码峰(Tushare cyq_chips / cyq_perf,同花顺式右侧横向线状直方图)
|
||||
// 个股筹码峰(Tushare cyq_chips / cyq_perf,同花顺火焰山式右侧专用竖列)
|
||||
//
|
||||
// 数据不在图表窗口上计算,而是一次性截面(某交易日各价位筹码占比 + 获利比例/平均成本),
|
||||
// 经 extendData 注入、纯 draw 绘制:
|
||||
// - 每个价位一根横线,从主图右缘向左延伸,宽度 ∝ 占比/最大占比(火焰山形态)
|
||||
// - 分界价 = 选中K线的收盘价(refClose):下方为获利盘(红线)、上方为套牢盘(蓝线)
|
||||
// - 平均成本画黄色虚线;右上角小字块:截面日期 / 获利比例 / 平均成本 / 90% 成本区间
|
||||
// - 主图绘图区右缘、价格轴左侧一条【不透明底色专用竖列】,K线不透出(同花顺同款)
|
||||
// - 每个价位一根横条,在列内从右缘向左伸展,宽度 ∝ 占比/最大占比(火焰山形态)
|
||||
// - 分界价 = 选中K线的收盘价(refClose):下方为获利盘(红)、上方为套牢盘(蓝)
|
||||
// - 平均成本画黄色虚线、末根收盘画现价细实线;列内顶部小字:截面日/获利比例/平均成本/90%成本
|
||||
// DetailKLine 依据 CHIPS_COL_PX 在开启筹码时加大右侧留白,保证最新K线不被竖列盖住。
|
||||
// figures 为空、calc 返回空行:指标不产生序列值,也不影响主图价格刻度(筹码是覆盖层)。
|
||||
import type { IndicatorTemplate } from 'klinecharts';
|
||||
|
||||
export const CHIPS_NAME = 'pv-chips';
|
||||
|
||||
/** 火焰山竖列基准宽度(px):DetailKLine 据此预留右侧留白,draw 据此画列 */
|
||||
export const CHIPS_COL_PX = 120;
|
||||
|
||||
/** 经 extendData 注入的筹码截面(父组件按 当前窗口末根/十字线 所在周期取数) */
|
||||
export interface ChipsExt {
|
||||
rows: { price: number; percent: number }[]; // percent 为 0~1 占比
|
||||
@@ -29,16 +34,34 @@ const AVG_COLOR = '#F5C518';
|
||||
const LABEL_COLOR = '#9AA0AA';
|
||||
const VALUE_COLOR = '#E8EAED';
|
||||
|
||||
/** 同花顺筹码峰同款:横线最大占主图宽度的比例与上限(px) */
|
||||
const MAX_WIDTH_RATIO = 0.22;
|
||||
const MAX_WIDTH_PX = 150;
|
||||
/** 横条高度上限(px):极度放大时单价位占比条不至于铺满半个主图 */
|
||||
// 竖列几何:窄图下限 56px,最多占绘图区宽度 35%(实际宽度取两者与基准的钳制值)
|
||||
const COL_MIN_PX = 56;
|
||||
const COL_MAX_RATIO = 0.35;
|
||||
/** 列内左右内边距(横条右缘、统计文字与列缘的呼吸) */
|
||||
const COL_PAD = 5;
|
||||
/** 不透明底色(图表容器纯黑 bg-black,取应用面板色);左缘 1px 分隔线与 y 轴线同色 */
|
||||
const COL_BG = '#101014';
|
||||
const COL_BORDER = '#2A2D34';
|
||||
/** 实心条透明度:不透明列内 0.85 足够“实心”,与底色留一丝层次 */
|
||||
const BAR_ALPHA = 0.85;
|
||||
/** 横条高度上限(px):极度放大时单价位占比条不至于铺满大半个竖列 */
|
||||
const BAR_HEIGHT_CAP = 24;
|
||||
/** 统计文字起始 y(画线工具栏是 HTML 层,占顶部 ~36px,文字块从 44px 起)/ 行高 */
|
||||
const HEADER_TOP = 44;
|
||||
const LINE_H = 13;
|
||||
|
||||
function fmtDate(d?: string): string {
|
||||
return d && d.length === 8 ? `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}` : '—';
|
||||
}
|
||||
|
||||
/** 右对齐数值超宽时截断加省略号(90%成本 “10.50~14.20” 这类长串的兜底) */
|
||||
function fitText(ctx: CanvasRenderingContext2D, text: string, maxW: number): string {
|
||||
if (ctx.measureText(text).width <= maxW) return text;
|
||||
let s = text;
|
||||
while (s.length > 1 && ctx.measureText(`${s}…`).width > maxW) s = s.slice(0, -1);
|
||||
return `${s}…`;
|
||||
}
|
||||
|
||||
export const chipsIndicator: IndicatorTemplate<Record<string, never>, unknown, ChipsExt> = {
|
||||
name: CHIPS_NAME,
|
||||
shortName: '筹码',
|
||||
@@ -49,50 +72,39 @@ export const chipsIndicator: IndicatorTemplate<Record<string, never>, unknown, C
|
||||
const ext = indicator.extendData;
|
||||
if (!ext) return true;
|
||||
|
||||
// 右上角统计小字块(画线工具栏是 HTML 层,占顶部 ~36px,文字块从 44px 起)
|
||||
const lines: { label: string; value: string; color: string }[] = [];
|
||||
// 竖列几何:bounding.right 即 y 轴左缘,列贴着价格轴(预留逻辑见 DetailKLine 的 syncRightOffset)
|
||||
const colW = Math.max(COL_MIN_PX, Math.min(CHIPS_COL_PX, Math.floor(bounding.width * COL_MAX_RATIO)));
|
||||
const colRight = bounding.right;
|
||||
const colLeft = colRight - colW;
|
||||
|
||||
// 无数据/出错:不画底色列(空面板不遮图),错误文案画在原右上角位置
|
||||
if (!ext.rows.length) {
|
||||
if (ext.error) {
|
||||
lines.push({ label: '筹码', value: ext.error, color: LABEL_COLOR });
|
||||
} else if (ext.rows.length) {
|
||||
const w = ext.winner;
|
||||
lines.push({ label: `筹码 ${fmtDate(ext.date)}`, value: '', color: LABEL_COLOR });
|
||||
lines.push({
|
||||
label: '获利比例',
|
||||
value: w == null ? '—' : `${w.toFixed(2)}%`,
|
||||
color: w == null ? VALUE_COLOR : w >= 50 ? UP : DOWN,
|
||||
});
|
||||
lines.push({ label: '平均成本', value: ext.avg == null ? '—' : ext.avg.toFixed(2), color: AVG_COLOR });
|
||||
lines.push({
|
||||
label: '90%成本',
|
||||
value: ext.costLow == null || ext.costHigh == null ? '—' : `${ext.costLow.toFixed(2)}~${ext.costHigh.toFixed(2)}`,
|
||||
color: VALUE_COLOR,
|
||||
});
|
||||
}
|
||||
if (lines.length) {
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.textAlign = 'right';
|
||||
let y = bounding.top + 44;
|
||||
for (const ln of lines) {
|
||||
const text = ln.value ? `${ln.label} ${ln.value}` : ln.label;
|
||||
ctx.fillStyle = ln.color;
|
||||
ctx.fillText(text, bounding.right - 4, y);
|
||||
y += 14;
|
||||
ctx.fillStyle = LABEL_COLOR;
|
||||
ctx.fillText(`筹码 ${ext.error}`, bounding.right - 4, bounding.top + HEADER_TOP);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (!ext.rows.length) return true;
|
||||
|
||||
// 1. 不透明底色列 + 左缘 1px 分隔线(先画,盖住网格/K线/MA/最新价虚线的右端)
|
||||
ctx.fillStyle = COL_BG;
|
||||
ctx.fillRect(colLeft, bounding.top, colW, bounding.height);
|
||||
ctx.fillStyle = COL_BORDER;
|
||||
ctx.fillRect(colLeft, bounding.top, 1, bounding.height);
|
||||
|
||||
const list = chart.getDataList();
|
||||
// 分界价:选中K线的收盘价(父组件随截面注入;缺省退回图表末根收盘),
|
||||
// <=分界价为获利盘(红)、>分界价为套牢盘(蓝)——同花顺同款
|
||||
const list = chart.getDataList();
|
||||
const close = ext.refClose ?? (list.length ? list[list.length - 1].close : undefined);
|
||||
const split = ext.refClose ?? (list.length ? list[list.length - 1].close : undefined);
|
||||
|
||||
// 2. 筹码横条:列右缘向左伸展,宽度 ∝ 占比/最大占比
|
||||
// 价位升序,便于求相邻像素间距(直方图的“条高”)
|
||||
const rows = [...ext.rows].sort((a, b) => a.price - b.price);
|
||||
const maxP = rows.reduce((m, r) => Math.max(m, r.percent), 0);
|
||||
if (maxP <= 0) return true;
|
||||
const maxW = Math.min(bounding.width * MAX_WIDTH_RATIO, MAX_WIDTH_PX);
|
||||
|
||||
if (maxP > 0) {
|
||||
// 相邻价位的中位像素间距 → 条高(连续火焰形态;缩到极小时并成实心轮廓)
|
||||
const pitches: number[] = [];
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
@@ -102,17 +114,18 @@ export const chipsIndicator: IndicatorTemplate<Record<string, never>, unknown, C
|
||||
pitches.sort((a, b) => a - b);
|
||||
const pitch = pitches.length ? pitches[pitches.length >> 1] : 4;
|
||||
const barH = Math.min(BAR_HEIGHT_CAP, Math.max(1, pitch * 0.92));
|
||||
|
||||
const maxBarW = colW - COL_PAD * 2;
|
||||
for (const r of rows) {
|
||||
const y = yAxis.convertToPixel(r.price);
|
||||
if (y < bounding.top - barH || y > bounding.bottom + barH) continue; // 视口外跳过
|
||||
const w = Math.max(1, (r.percent / maxP) * maxW);
|
||||
ctx.fillStyle = close == null ? 'rgba(154,160,170,0.5)'
|
||||
: r.price <= close ? 'rgba(254,53,75,0.55)' : 'rgba(47,123,255,0.55)';
|
||||
ctx.fillRect(bounding.right - w, y - barH / 2, w, barH);
|
||||
const w = Math.max(1, (r.percent / maxP) * maxBarW);
|
||||
ctx.fillStyle = split == null ? 'rgba(154,160,170,0.85)'
|
||||
: r.price <= split ? `rgba(254,53,75,${BAR_ALPHA})` : `rgba(47,123,255,${BAR_ALPHA})`;
|
||||
ctx.fillRect(colRight - COL_PAD - w, y - barH / 2, w, barH);
|
||||
}
|
||||
}
|
||||
|
||||
// 平均成本虚线(横贯直方图区)
|
||||
// 3. 平均成本虚线(横贯竖列)
|
||||
if (ext.avg != null) {
|
||||
const y = yAxis.convertToPixel(ext.avg);
|
||||
if (y >= bounding.top && y <= bounding.bottom) {
|
||||
@@ -121,12 +134,55 @@ export const chipsIndicator: IndicatorTemplate<Record<string, never>, unknown, C
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 3]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(bounding.right - maxW, y + 0.5);
|
||||
ctx.lineTo(bounding.right, y + 0.5);
|
||||
ctx.moveTo(colLeft + 1, Math.round(y) + 0.5);
|
||||
ctx.lineTo(colRight, Math.round(y) + 0.5);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 现价细实线(末根收盘,红/蓝随涨跌,与 y 轴最新价标签同源同色;
|
||||
// 分界价本身已是横条红蓝交界,不再重复画线)
|
||||
if (list.length >= 2) {
|
||||
const last = list[list.length - 1];
|
||||
const y = yAxis.convertToPixel(last.close);
|
||||
if (y >= bounding.top && y <= bounding.bottom) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = last.close >= list[list.length - 2].close ? UP : DOWN;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(colLeft + 1, Math.round(y) + 0.5);
|
||||
ctx.lineTo(colRight, Math.round(y) + 0.5);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 统计小字块:列内顶部,label 左对齐 + value 右对齐,超宽截断
|
||||
//(列宽有限,两列布局比原先的单行右对齐更能放下「90%成本 10.50~14.20」这类长串)
|
||||
const w = ext.winner;
|
||||
const lines: [string, string, string][] = [
|
||||
['筹码', fmtDate(ext.date), VALUE_COLOR],
|
||||
['获利比例', w == null ? '—' : `${w.toFixed(2)}%`, w == null ? VALUE_COLOR : w >= 50 ? UP : DOWN],
|
||||
['平均成本', ext.avg == null ? '—' : ext.avg.toFixed(2), AVG_COLOR],
|
||||
['90%成本', ext.costLow == null || ext.costHigh == null ? '—'
|
||||
: `${ext.costLow.toFixed(2)}~${ext.costHigh.toFixed(2)}`, VALUE_COLOR],
|
||||
];
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.textBaseline = 'top';
|
||||
const labelX = colLeft + COL_PAD;
|
||||
const valueX = colRight - COL_PAD;
|
||||
const valueMaxW = colW - COL_PAD * 2 - 36; // 36 ≈ 左侧标签区预留宽
|
||||
let ty = bounding.top + HEADER_TOP;
|
||||
for (const [label, value, color] of lines) {
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillStyle = LABEL_COLOR;
|
||||
ctx.fillText(label, labelX, ty);
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(fitText(ctx, value, valueMaxW), valueX, ty);
|
||||
ty += LINE_H;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -19,5 +19,5 @@ export function registerCustomIndicators(get: ZhixingGetter) {
|
||||
registerIndicator(chipsIndicator);
|
||||
}
|
||||
|
||||
export { CHIPS_NAME, type ChipsExt } from './chips';
|
||||
export { CHIPS_NAME, CHIPS_COL_PX, type ChipsExt } from './chips';
|
||||
export { ZHIXING_NAME, ZHIXING_TREND_COLOR, DKX_PERIODS, computeZhixingSeries } from './zhixing';
|
||||
|
||||
@@ -3,36 +3,79 @@ import { ref } from 'vue';
|
||||
import { runScreener } from '@/api/client';
|
||||
import type { ScreenConditions, ScreenerRunResponse } from '@/api/types';
|
||||
|
||||
/** 进度面板的一行痕迹:阶段消息(含耗时),或进度条态 */
|
||||
export interface TraceLine {
|
||||
key: string; // 阶段 key(llm/date/prefilter/bars/filter_done/done)
|
||||
msg: string;
|
||||
ms?: number;
|
||||
}
|
||||
|
||||
export const useScreenerStore = defineStore('screener', () => {
|
||||
const loading = ref(false);
|
||||
const stage = ref<'idle' | 'parsing' | 'screening'>('idle');
|
||||
const note = ref<string | null>(null);
|
||||
const error = ref<string | null>(null);
|
||||
const result = ref<ScreenerRunResponse | null>(null);
|
||||
const aborted = ref(false); // 本次执行被手动停止(区别于失败)
|
||||
|
||||
// 流式进度状态(run 期间有效;结束后保留到下一次 run 便于复盘)
|
||||
const trace = ref<TraceLine[]>([]);
|
||||
const progress = ref<{ done: number; total: number } | null>(null);
|
||||
const parsed = ref<ScreenConditions | null>(null);
|
||||
|
||||
let ctrl: AbortController | null = null;
|
||||
|
||||
function pushLine(line: TraceLine) {
|
||||
// 同 key 阶段只留最后一行(如重试的 llm)
|
||||
const i = trace.value.findIndex((l) => l.key === line.key);
|
||||
if (i >= 0) trace.value[i] = line;
|
||||
else trace.value.push(line);
|
||||
}
|
||||
|
||||
/** 中断正在执行的筛选(LLM 解析或逐股过滤阶段均可)。 */
|
||||
function abort() {
|
||||
ctrl?.abort();
|
||||
}
|
||||
|
||||
async function run(text: string, conditions?: ScreenConditions | null) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
aborted.value = false;
|
||||
result.value = null;
|
||||
note.value = conditions ? '全市场筛选中…' : 'AI 解析条件中…';
|
||||
stage.value = conditions ? 'screening' : 'parsing';
|
||||
trace.value = [];
|
||||
progress.value = null;
|
||||
parsed.value = conditions ?? null;
|
||||
if (conditions) pushLine({ key: 'direct', msg: '直传条件,跳过 AI 解析' });
|
||||
ctrl = new AbortController();
|
||||
try {
|
||||
// 条件解析与全市场筛选在后端一气呵成;切到筛选阶段给个过渡提示
|
||||
setTimeout(() => {
|
||||
if (loading.value && stage.value === 'parsing') {
|
||||
stage.value = 'screening';
|
||||
note.value = '全市场筛选中…';
|
||||
result.value = await runScreener({ text, conditions: conditions ?? undefined }, (ev) => {
|
||||
switch (ev.type) {
|
||||
case 'stage':
|
||||
pushLine({ key: ev.key, msg: ev.msg, ms: ev.ms });
|
||||
break;
|
||||
case 'parsed':
|
||||
parsed.value = ev.conditions;
|
||||
break;
|
||||
case 'candidates':
|
||||
pushLine({ key: 'candidates', msg: ev.msg ?? `预筛完成:${ev.count} 只候选`, ms: ev.ms });
|
||||
break;
|
||||
case 'progress':
|
||||
progress.value = { done: ev.done, total: ev.total };
|
||||
break;
|
||||
default:
|
||||
break; // result 由 runScreener 返回值落入 result;error 走异常
|
||||
}
|
||||
}, 1200);
|
||||
result.value = await runScreener({ text, conditions: conditions ?? undefined });
|
||||
}, ctrl.signal);
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'AbortError') {
|
||||
aborted.value = true; // 主动停止,不算失败
|
||||
} else {
|
||||
error.value = e instanceof Error ? e.message : '选股失败';
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
stage.value = 'idle';
|
||||
note.value = null;
|
||||
progress.value = null;
|
||||
ctrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { loading, stage, note, error, result, run };
|
||||
return { loading, error, result, aborted, trace, progress, parsed, run, abort };
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useScreenerStore } from '@/stores/screener';
|
||||
import ScreenerForm from '@/components/ScreenerForm.vue';
|
||||
import ConditionChips from '@/components/ConditionChips.vue';
|
||||
@@ -9,20 +9,59 @@ import StockDetailOverlay from '@/components/StockDetailOverlay.vue';
|
||||
const store = useScreenerStore();
|
||||
const previewCode = ref<string | null>(null);
|
||||
const formRef = ref<InstanceType<typeof ScreenerForm> | null>(null);
|
||||
|
||||
const pct = computed(() => {
|
||||
const p = store.progress;
|
||||
if (!p || p.total <= 0) return 0;
|
||||
return Math.min(100, Math.round((p.done / p.total) * 100));
|
||||
});
|
||||
|
||||
function fmtMs(ms?: number): string {
|
||||
return ms == null ? '' : `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<ScreenerForm ref="formRef" :loading="store.loading" @run="store.run" @ran="formRef?.refreshHistory()" />
|
||||
<ScreenerForm ref="formRef" :loading="store.loading" @run="store.run" @ran="formRef?.refreshHistory()" @stop="store.abort()" />
|
||||
|
||||
<div v-if="store.aborted && !store.loading" class="mt-4 flex items-center gap-2 rounded-xl border border-[#26272E] bg-[#101014] px-4 py-3 text-sm text-[#9BA3AE]">
|
||||
<svg class="h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="6" y="6" width="12" height="12" rx="1.5" /></svg>
|
||||
已停止本次筛选(可修改问题后重新开始)
|
||||
</div>
|
||||
|
||||
<div v-if="store.error" class="mt-4 flex items-start gap-2 rounded-xl border border-red-500/40 bg-red-500/15 px-4 py-3 text-sm text-red-300">
|
||||
<svg class="mt-0.5 h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 3.9L1.8 18a2 2 0 001.7 3h17a2 2 0 001.7-3L13.7 3.9a2 2 0 00-3.4 0z" /><path d="M12 9v4M12 17h.01" /></svg>
|
||||
{{ store.error }}
|
||||
</div>
|
||||
|
||||
<div v-if="store.loading && store.note" class="py-16 text-center text-sm text-[#9BA3AE]">
|
||||
<svg class="mx-auto mb-3 h-6 w-6 animate-spin text-blue-500" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
|
||||
{{ store.note }}
|
||||
<!-- 执行进度面板:加载中实时滚动;失败后保留痕迹便于定位卡在哪一步 -->
|
||||
<div
|
||||
v-if="store.loading || (store.error && store.trace.length)"
|
||||
class="mt-4 rounded-xl border border-[#26272E] bg-[#101014] px-4 py-4"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-sm text-[#E8EAED]">
|
||||
<svg v-if="store.loading" class="h-4 w-4 shrink-0 animate-spin text-blue-500" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
|
||||
<span v-else class="h-2 w-2 shrink-0 rounded-full bg-red-500"></span>
|
||||
{{ store.loading ? (store.trace[store.trace.length - 1]?.msg ?? '处理中…') : '执行中断' }}
|
||||
</div>
|
||||
|
||||
<div v-if="store.progress" class="mt-3">
|
||||
<div class="h-1.5 overflow-hidden rounded bg-[#1E2026]">
|
||||
<div class="h-full rounded bg-blue-600 transition-all duration-150" :style="{ width: pct + '%' }"></div>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-[#9BA3AE]">逐股指标过滤 {{ store.progress.done }} / {{ store.progress.total }}({{ pct }}%)</div>
|
||||
</div>
|
||||
|
||||
<div v-if="store.parsed && (store.parsed.indicator.length || store.parsed.snapshot.length)" class="mt-3">
|
||||
<ConditionChips :conditions="store.parsed" />
|
||||
</div>
|
||||
|
||||
<div class="mt-3 space-y-0.5 font-mono text-xs leading-relaxed text-[#9BA3AE]">
|
||||
<div v-for="(l, i) in store.trace" :key="i">
|
||||
<span v-if="l.ms != null" class="mr-2 inline-block w-12 text-right text-[#5F6672]">{{ fmtMs(l.ms) }}</span>{{ l.msg }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else-if="store.result">
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
$ vite
|
||||
|
||||
[32m[1mVITE[22m v6.4.3[39m [2mready in [0m[1m2144[22m[2m[0m ms[22m
|
||||
[32m[1mVITE[22m v6.4.3[39m [2mready in [0m[1m717[22m[2m[0m ms[22m
|
||||
|
||||
[32m➜[39m [1mLocal[22m: [36mhttp://localhost:[1m5173[22m/[39m
|
||||
[2m [32m➜[39m [1mNetwork[22m[2m: use [22m[1m--host[22m[2m to expose[22m
|
||||
[2m15:58:04[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/auth/me[39m
|
||||
[2m10:03:08[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mpage reload [39m[2msrc/api/types.ts[22m
|
||||
[2m10:03:24[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mpage reload [39m[2msrc/api/client.ts[22m
|
||||
[2m10:03:38[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mpage reload [39m[2msrc/api/client.ts[22m
|
||||
[2m10:03:48[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/style.css, /src/views/ScreenerView.vue[22m
|
||||
[2m10:04:11[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/views/ScreenerView.vue, /src/style.css[22m
|
||||
[2m10:04:24[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/views/ScreenerView.vue, /src/style.css[22m
|
||||
[2m10:04:40[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/views/ScreenerView.vue, /src/style.css[22m
|
||||
[2m10:15:13[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mpage reload [39m[2msrc/api/client.ts[22m
|
||||
[2m10:15:27[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/style.css, /src/views/ScreenerView.vue[22m
|
||||
[2m10:15:48[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/@fs/D:\Project\stock\frontend\src\components\ScreenerForm.vue, /src/style.css[22m
|
||||
[2m10:15:53[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/@fs/D:\Project\stock\frontend\src\components\ScreenerForm.vue, /src/style.css[22m
|
||||
[2m10:16:01[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/@fs/D:\Project\stock\frontend\src\components\ScreenerForm.vue, /src/style.css[22m
|
||||
[2m10:16:06[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/@fs/D:\Project\stock\frontend\src\components\ScreenerForm.vue, /src/style.css[22m
|
||||
[2m10:16:11[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/views/ScreenerView.vue, /src/style.css[22m
|
||||
[2m10:20:01[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/screener/run[39m
|
||||
Error: read ECONNRESET
|
||||
at TCP.onStreamRead (node:internal/stream_base_commons:216:20)
|
||||
[2m10:23:16[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/auth/me[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1134:18)
|
||||
at afterConnectMultiple (node:net:1715:7)
|
||||
[2m10:23:20[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/auth/login[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1134:18)
|
||||
at afterConnectMultiple (node:net:1715:7)
|
||||
$ vite
|
||||
Port 5173 is in use, trying another one...
|
||||
|
||||
[32m[1mVITE[22m v6.4.3[39m [2mready in [0m[1m579[22m[2m[0m ms[22m
|
||||
|
||||
[32m➜[39m [1mLocal[22m: [36mhttp://localhost:[1m5174[22m/[39m
|
||||
[2m [32m➜[39m [1mNetwork[22m[2m: use [22m[1m--host[22m[2m to expose[22m
|
||||
|
||||
Reference in New Issue
Block a user