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

@@ -9,8 +9,8 @@ DATA_DEFAULT_START=20200101
# ---- Redis 读缓存(股票列表/筛选项;留空则不缓存直查数据库)----
REDIS_URL=redis://default:26d5c71d57344f37b8b4ddb567f2652f0c7ef41c774284ad@cirry.cn:6379
# ---- LLM智能选股智谱 GLMOpenAI 兼容协议)----
# ---- LLM智能选股DeepSeekOpenAI 兼容协议/anthropic 后缀会被 _endpoint 自动归一----
# key 在 https://bigmodel.cn 控制台获取,格式形如 xxxxxxxx.yyyyyyyyid.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

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

View File

@@ -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

View File

@@ -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> {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>

View File

@@ -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';
/** 火焰山竖列基准宽度pxDetailKLine 据此预留右侧留白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,70 +72,60 @@ 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 }[] = [];
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;
}
}
if (!ext.rows.length) return true;
// 竖列几何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) {
ctx.font = '11px sans-serif';
ctx.textBaseline = 'top';
ctx.textAlign = 'right';
ctx.fillStyle = LABEL_COLOR;
ctx.fillText(`筹码 ${ext.error}`, bounding.right - 4, bounding.top + HEADER_TOP);
}
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);
// 相邻价位的中位像素间距 → 条高(连续火焰形态;缩到极小时并成实心轮廓)
const pitches: number[] = [];
for (let i = 1; i < rows.length; i++) {
const p = Math.abs(yAxis.convertToPixel(rows[i].price) - yAxis.convertToPixel(rows[i - 1].price));
if (p > 0) pitches.push(p);
}
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));
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);
if (maxP > 0) {
// 相邻价位的中位像素间距 → 条高(连续火焰形态;缩到极小时并成实心轮廓)
const pitches: number[] = [];
for (let i = 1; i < rows.length; i++) {
const p = Math.abs(yAxis.convertToPixel(rows[i].price) - yAxis.convertToPixel(rows[i - 1].price));
if (p > 0) pitches.push(p);
}
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) * 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;
},
};

View File

@@ -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';

View File

@@ -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; // 阶段 keyllm/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 返回值落入 resulterror 走异常
}
}, 1200);
result.value = await runScreener({ text, conditions: conditions ?? undefined });
}, ctrl.signal);
} catch (e) {
error.value = e instanceof Error ? e.message : '选股失败';
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 };
});

View File

@@ -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">

View File

@@ -1,17 +1,31 @@
$ vite
VITE v6.4.3 ready in 2144 ms
VITE v6.4.3 ready in 717 ms
➜ Local: http://localhost:5173/
 ➜ Network: use --host to expose
15:58:04 [vite] http proxy error: /api/auth/me
10:03:08 [vite] (client) page reload src/api/types.ts
10:03:24 [vite] (client) page reload src/api/client.ts
10:03:38 [vite] (client) page reload src/api/client.ts
10:03:48 [vite] (client) hmr update /src/style.css, /src/views/ScreenerView.vue
10:04:11 [vite] (client) hmr update /src/views/ScreenerView.vue, /src/style.css
10:04:24 [vite] (client) hmr update /src/views/ScreenerView.vue, /src/style.css
10:04:40 [vite] (client) hmr update /src/views/ScreenerView.vue, /src/style.css
10:15:13 [vite] (client) page reload src/api/client.ts
10:15:27 [vite] (client) hmr update /src/style.css, /src/views/ScreenerView.vue
10:15:48 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\ScreenerForm.vue, /src/style.css
10:15:53 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\ScreenerForm.vue, /src/style.css
10:16:01 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\ScreenerForm.vue, /src/style.css
10:16:06 [vite] (client) hmr update /@fs/D:\Project\stock\frontend\src\components\ScreenerForm.vue, /src/style.css
10:16:11 [vite] (client) hmr update /src/views/ScreenerView.vue, /src/style.css
10:20:01 [vite] http proxy error: /api/screener/run
Error: read ECONNRESET
at TCP.onStreamRead (node:internal/stream_base_commons:216:20)
10:23:16 [vite] http proxy error: /api/auth/me
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1134:18)
at afterConnectMultiple (node:net:1715:7)
10:23:20 [vite] http proxy error: /api/auth/login
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1134:18)
at afterConnectMultiple (node:net:1715:7)
$ vite
Port 5173 is in use, trying another one...
VITE v6.4.3 ready in 579 ms
➜ Local: http://localhost:5174/
 ➜ Network: use --host to expose