diff --git a/backend/.env b/backend/.env index 346377d..54ad15e 100644 --- a/backend/.env +++ b/backend/.env @@ -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 diff --git a/backend/app/api.py b/backend/app/api.py index c9e21c1..98919eb 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -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) diff --git a/backend/app/screener/engine.py b/backend/app/screener/engine.py index 91b2a91..99fb1c0 100644 --- a/backend/app/screener/engine.py +++ b/backend/app/screener/engine.py @@ -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 diff --git a/backend/app/screener/llm.py b/backend/app/screener/llm.py index 0adf7ab..48f95ac 100644 --- a/backend/app/screener/llm.py +++ b/backend/app/screener/llm.py @@ -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 diff --git a/backend_run.log b/backend_run.log index c3bfb9e..450e4a0 100644 --- a/backend_run.log +++ b/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 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index af33c6a..c2cb830 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -12,6 +12,7 @@ import type { PreviewResponse, ScreenerQueryItem, ScreenerRunRequest, + ScreenerStreamEvent, ScreenerRunResponse, ScreenerSyncRequest, ScreenerSyncStatus, @@ -120,10 +121,38 @@ export async function postEventBacktest(req: EventBacktestRequest): Promise { - 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 { + 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 { diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 409b1ac..307985d 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -148,6 +148,15 @@ export interface ScreenerRunResponse { indicator_labels: Record; } +// ---------- 选股流式事件(镜像 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; diff --git a/frontend/src/components/DetailKLine.vue b/frontend/src/components/DetailKLine.vue index 5019c29..dce376b 100644 --- a/frontend/src/components/DetailKLine.vue +++ b/frontend/src/components/DetailKLine.vue @@ -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 | 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; diff --git a/frontend/src/components/ScreenerForm.vue b/frontend/src/components/ScreenerForm.vue index 99c3f99..9eaef15 100644 --- a/frontend/src/components/ScreenerForm.vue +++ b/frontend/src/components/ScreenerForm.vue @@ -1,12 +1,13 @@