提交
This commit is contained in:
@@ -9,8 +9,8 @@ DATA_DEFAULT_START=20200101
|
|||||||
# ---- Redis 读缓存(股票列表/筛选项;留空则不缓存直查数据库)----
|
# ---- Redis 读缓存(股票列表/筛选项;留空则不缓存直查数据库)----
|
||||||
REDIS_URL=redis://default:26d5c71d57344f37b8b4ddb567f2652f0c7ef41c774284ad@cirry.cn:6379
|
REDIS_URL=redis://default:26d5c71d57344f37b8b4ddb567f2652f0c7ef41c774284ad@cirry.cn:6379
|
||||||
|
|
||||||
# ---- LLM(智能选股;智谱 GLM,OpenAI 兼容协议)----
|
# ---- LLM(智能选股;DeepSeek,OpenAI 兼容协议;/anthropic 后缀会被 _endpoint 自动归一)----
|
||||||
# key 在 https://bigmodel.cn 控制台获取,格式形如 xxxxxxxx.yyyyyyyy(id.secret)
|
# key 在 https://bigmodel.cn 控制台获取,格式形如 xxxxxxxx.yyyyyyyy(id.secret)
|
||||||
LLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4
|
LLM_BASE_URL=https://api.deepseek.com
|
||||||
LLM_API_KEY=ea24bbdd3d2d4dd2b8f03de4c9a5d984.9X1Hz1yKx0VKSnrU
|
LLM_API_KEY=sk-b09acbd6c0ca4f94818b6deb039d6515
|
||||||
LLM_MODEL=glm-5.2
|
LLM_MODEL=deepseek-chat
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
|
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy import delete, func, select, text
|
from sqlalchemy import delete, func, select, text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.sql.elements import TextClause
|
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(
|
async def screener_run(
|
||||||
req: ScreenerRunRequest,
|
req: ScreenerRunRequest,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
user=Depends(require_user),
|
user=Depends(require_user),
|
||||||
) -> ScreenerRunResponse:
|
) -> StreamingResponse:
|
||||||
"""自然语言 -> LLM 解析条件 -> 全市场筛选。也可直传 conditions 跳过 LLM(微调再跑)。
|
"""自然语言 -> LLM 解析条件 -> 全市场筛选。也可直传 conditions 跳过 LLM(微调再跑)。
|
||||||
成功的提问(含解析出的条件与命中数)记录到 screener_queries,供历史一键重跑。"""
|
|
||||||
try:
|
NDJSON 流式响应(每行一个 JSON 事件,前端逐行渲染进度):
|
||||||
conds = req.conditions or await parse_conditions(req.text)
|
{"type":"stage","key":"llm|date|prefilter|bars|filter_done|done","msg":"…","ms":123}
|
||||||
if not conds.indicator and not conds.snapshot:
|
{"type":"parsed","conditions":{…},"ms":456} LLM 解析出的结构化条件
|
||||||
raise HTTPException(status_code=400, detail="AI 未从描述中解析出任何筛选条件,请换种说法")
|
{"type":"candidates","count":5400,"msg":"…","ms":…} SQL 预筛后的候选数
|
||||||
result = await engine.run_screen(session, conds, settings.screener_default_limit)
|
{"type":"progress","done":500,"total":5400} 逐股指标过滤进度
|
||||||
# 相同文本 + 相同条件的上一条不重复记录(一键重跑场景)
|
{"type":"result","result":{…ScreenerRunResponse…},"ms":…}
|
||||||
exists = (
|
{"type":"error","message":"…","code":400} 流中途失败(HTTP 已 200)
|
||||||
await session.execute(
|
成功的提问(含解析出的条件与命中数)记录到 screener_queries,供历史一键重跑。
|
||||||
select(ScreenerQuery.id).where(
|
"""
|
||||||
ScreenerQuery.user_id == user.id,
|
limit = settings.screener_default_limit
|
||||||
ScreenerQuery.text == req.text.strip(),
|
|
||||||
ScreenerQuery.conditions_json == json.dumps(conds.model_dump(), ensure_ascii=False),
|
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()
|
||||||
).scalar_one_or_none()
|
if exists is None:
|
||||||
if exists is None:
|
session.add(ScreenerQuery(
|
||||||
session.add(ScreenerQuery(
|
user_id=user.id,
|
||||||
user_id=user.id,
|
text=req.text.strip(),
|
||||||
text=req.text.strip(),
|
conditions_json=json.dumps(conds.model_dump(), ensure_ascii=False),
|
||||||
conditions_json=json.dumps(conds.model_dump(), ensure_ascii=False),
|
hit_count=result.get("total", 0),
|
||||||
hit_count=result.get("total", 0),
|
))
|
||||||
))
|
await session.commit()
|
||||||
await session.commit()
|
except DataNotReadyError as e:
|
||||||
return ScreenerRunResponse(**result)
|
yield _ndjson({"type": "error", "code": 409, "message": str(e)})
|
||||||
except HTTPException:
|
except ValueError as e: # 未知指标/字段、条件为空
|
||||||
raise
|
yield _ndjson({"type": "error", "code": 400, "message": str(e)})
|
||||||
except DataNotReadyError as e:
|
except ScreenerError as e:
|
||||||
raise HTTPException(status_code=409, detail=str(e))
|
code = 503 if "未配置 LLM_API_KEY" in str(e) else 502
|
||||||
except ValueError as e: # 未知指标/字段、条件为空
|
yield _ndjson({"type": "error", "code": code, "message": str(e)})
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
except Exception as e: # noqa: BLE001
|
||||||
except ScreenerError as e:
|
yield _ndjson({"type": "error", "code": 500, "message": f"选股失败: {e}"})
|
||||||
code = 503 if "未配置 LLM_API_KEY" in str(e) else 502
|
|
||||||
raise HTTPException(status_code=code, detail=str(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)
|
@router.get("/screener/queries", response_model=ScreenerQueryListResponse)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ pandas 逐股计算(复用 app/indicators,指标按 (族, 参数) 去重计
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Callable
|
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": "下轨",
|
"boll_upper": "上轨", "boll_mid": "中轨", "boll_lower": "下轨",
|
||||||
"zhixing_dkx": "多空线", "zhixing_trend": "趋势线"}
|
"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:
|
def _family_of(indicator: str) -> str:
|
||||||
fam = INDICATOR_FAMILY.get(indicator)
|
fam = INDICATOR_FAMILY.get(indicator)
|
||||||
@@ -139,6 +145,8 @@ def indicator_label(indicator: str, params: dict) -> str:
|
|||||||
fam = FAMILIES[_family_of(indicator)]
|
fam = FAMILIES[_family_of(indicator)]
|
||||||
suffix = _IND_SUFFIX.get(indicator)
|
suffix = _IND_SUFFIX.get(indicator)
|
||||||
name = f"{fam.label} {suffix}".strip() if suffix else fam.label
|
name = f"{fam.label} {suffix}".strip() if suffix else fam.label
|
||||||
|
if indicator in _IND_PARAMLESS:
|
||||||
|
return name
|
||||||
return name + _params_str(params)
|
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:
|
async def run_screen_events(session: AsyncSession, conds: ScreenConditions, limit: int):
|
||||||
"""主流程:预筛 -> 逐股指标过滤 -> 组装 items + indicator_labels。"""
|
"""事件流版主流程:预筛 -> 逐股指标过滤 -> 组装 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:
|
if not conds.indicator and not conds.snapshot:
|
||||||
raise ValueError("筛选条件为空")
|
raise ValueError("筛选条件为空")
|
||||||
|
|
||||||
@@ -362,6 +379,7 @@ async def run_screen(session: AsyncSession, conds: ScreenConditions, limit: int)
|
|||||||
)
|
)
|
||||||
if target_date is None:
|
if target_date is None:
|
||||||
raise DataNotReadyError("全市场数据未同步:请先在选股页点击「同步市场数据」")
|
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 any(c.field != "close" for c in conds.snapshot):
|
||||||
if await session.scalar(select(func.max(DailySnapshot.trade_date))) is None:
|
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)
|
cand = await _prefilter(session, conds, target_date)
|
||||||
|
yield _ev(type="candidates", count=len(cand), msg=f"预筛完成:{len(cand)} 只候选")
|
||||||
|
|
||||||
labels: dict[str, str] = {}
|
labels: dict[str, str] = {}
|
||||||
for c in conds.indicator:
|
for c in conds.indicator:
|
||||||
@@ -388,6 +408,7 @@ async def run_screen(session: AsyncSession, conds: ScreenConditions, limit: int)
|
|||||||
else:
|
else:
|
||||||
# 圈定 K 线窗口:按 candles 全量交易日序列回溯 needed 根(不再受同步窗口限制)
|
# 圈定 K 线窗口:按 candles 全量交易日序列回溯 needed 根(不再受同步窗口限制)
|
||||||
need = _max_needed_bars(conds)
|
need = _max_needed_bars(conds)
|
||||||
|
yield _ev(type="stage", key="bars", msg=f"载入 K 线窗口(每股回溯 {need} 根)…")
|
||||||
dates_res = await session.execute(
|
dates_res = await session.execute(
|
||||||
select(Candle.ts).where(Candle.timeframe == "1d").distinct()
|
select(Candle.ts).where(Candle.timeframe == "1d").distinct()
|
||||||
.order_by(Candle.ts.desc()).limit(need)
|
.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)
|
min_date = min(r[0] for r in dates_res)
|
||||||
bars = await _load_bars(session, cand["ts_code"].tolist(), target_date, min_date)
|
bars = await _load_bars(session, cand["ts_code"].tolist(), target_date, min_date)
|
||||||
cand_rows = {r["ts_code"]: r for _, r in cand.iterrows()}
|
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)
|
row = cand_rows.get(ts_code)
|
||||||
if row is None:
|
if row is None:
|
||||||
continue
|
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])
|
ind_values[c.value_indicator] = None if s is None or s.iloc[-1] != s.iloc[-1] else float(s.iloc[-1])
|
||||||
if ok:
|
if ok:
|
||||||
items.append(_item_from_row(row) | {"indicators": ind_values})
|
items.append(_item_from_row(row) | {"indicators": ind_values})
|
||||||
|
yield _ev(type="stage", key="filter_done", msg=f"指标过滤完成:{len(items)} 只命中")
|
||||||
|
|
||||||
# 默认总市值降序(缺失排最后),截断 limit
|
# 默认总市值降序(缺失排最后),截断 limit
|
||||||
items.sort(key=lambda x: (x["total_mv"] is None, -(x["total_mv"] or 0)))
|
items.sort(key=lambda x: (x["total_mv"] is None, -(x["total_mv"] or 0)))
|
||||||
return {
|
yield _ev(type="result", result={
|
||||||
"conditions": conds,
|
"conditions": conds,
|
||||||
"trade_date": target_date,
|
"trade_date": target_date,
|
||||||
"total": len(items),
|
"total": len(items),
|
||||||
"items": items[:limit],
|
"items": items[:limit],
|
||||||
"indicator_labels": labels,
|
"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 数组】技术指标条件,元素字段:
|
||||||
- "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(日涨跌幅%)
|
- "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"
|
- "op": "gt" | "ge" | "lt" | "le" | "between"
|
||||||
- "value": 比较数值(between 时为下界),"value2": 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
|
- "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:
|
示例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):
|
class ScreenerError(RuntimeError):
|
||||||
@@ -68,10 +72,14 @@ def _endpoint(base_url: str) -> str:
|
|||||||
"""归一化 base_url -> 完整 chat/completions URL。
|
"""归一化 base_url -> 完整 chat/completions URL。
|
||||||
|
|
||||||
兼容多种写法:DeepSeek/OpenAI 的 .../v1、智谱 GLM 的 .../v4、或直接给完整路径。
|
兼容多种写法:DeepSeek/OpenAI 的 .../v1、智谱 GLM 的 .../v4、或直接给完整路径。
|
||||||
|
.../anthropic(DeepSeek 的 Anthropic 协议端点)剥后缀走本模块的 OpenAI 兼容协议,
|
||||||
|
key 两端点通用。
|
||||||
"""
|
"""
|
||||||
base = base_url.rstrip("/")
|
base = base_url.rstrip("/")
|
||||||
if base.endswith("/chat/completions"):
|
if base.endswith("/chat/completions"):
|
||||||
return base
|
return base
|
||||||
|
if base.endswith("/anthropic"):
|
||||||
|
base = base[: -len("/anthropic")]
|
||||||
if not re.search(r"/v\d+$", base): # 未带版本段则补 /v1(DeepSeek/OpenAI 惯例)
|
if not re.search(r"/v\d+$", base): # 未带版本段则补 /v1(DeepSeek/OpenAI 惯例)
|
||||||
base += "/v1"
|
base += "/v1"
|
||||||
return f"{base}/chat/completions"
|
return f"{base}/chat/completions"
|
||||||
@@ -164,7 +172,7 @@ EVENT_SYSTEM_PROMPT = """你是 A 股事件回测参数解析器。用户描述
|
|||||||
|
|
||||||
【entry.indicator 数组】入场信号条件(必填,至少 1 条),元素字段与白名单:
|
【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(日涨跌幅%)
|
- "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"(上界)
|
- "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_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
|
- "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: Waiting for application startup.
|
||||||
INFO: Application startup complete.
|
INFO: Application startup complete.
|
||||||
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
|
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
|
||||||
INFO: Started server process [47304]
|
INFO: 127.0.0.1:60648 - "GET /api/auth/me HTTP/1.1" 401 Unauthorized
|
||||||
INFO: Waiting for application startup.
|
INFO: 127.0.0.1:62525 - "POST /api/auth/login HTTP/1.1" 200 OK
|
||||||
INFO: Application startup complete.
|
INFO: 127.0.0.1:62531 - "GET /api/preferences HTTP/1.1" 200 OK
|
||||||
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
|
INFO: 127.0.0.1:62573 - "GET /api/screener/queries?limit=20 HTTP/1.1" 200 OK
|
||||||
INFO: 127.0.0.1:52358 - "GET /docs 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:52494 - "POST /api/auth/login 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:52497 - "GET /api/preferences HTTP/1.1" 200 OK
|
INFO: 127.0.0.1:64917 - "GET /api/watchlist 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:64915 - "GET /api/trades?ts_code=601398.SH 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:64916 - "GET /api/screener/preview/601398.SH?limit=500&adjust=qfq&timeframe=1d 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: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:52534 - "GET /api/watchlist 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:52533 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d 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: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:64949 - "GET /api/screener/preview/601939.SH?limit=500&adjust=qfq&timeframe=1d 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:64958 - "GET /api/trades?ts_code=601288.SH 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: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: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:64964 - "GET /api/screener/preview/601288.SH?limit=500&adjust=qfq&timeframe=1d 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: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:52548 - "GET /api/screener/preview/000783.SZ?limit=500&adjust=qfq&timeframe=1d 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: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:64971 - "GET /api/screener/preview/600941.SH?limit=500&adjust=qfq&timeframe=1d 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:64982 - "GET /api/trades?ts_code=601988.SH 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: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: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:64983 - "GET /api/screener/preview/601988.SH?limit=500&adjust=qfq&timeframe=1d 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: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: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:64992 - "GET /api/trades?ts_code=601857.SH 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:64993 - "GET /api/screener/preview/601857.SH?limit=500&adjust=qfq&timeframe=1d 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:65001 - "GET /api/trades?ts_code=600938.SH 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: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:52580 - "GET /api/trades?ts_code=002100.SZ 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:52581 - "GET /api/screener/preview/002100.SZ?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: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:65014 - "GET /api/trades?ts_code=601628.SH 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:65016 - "GET /api/screener/preview/601628.SH?limit=500&adjust=qfq&timeframe=1d 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: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: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:65025 - "GET /api/trades?ts_code=601088.SH 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:65028 - "GET /api/screener/preview/601088.SH?limit=500&adjust=qfq&timeframe=1d 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: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: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:65037 - "GET /api/trades?ts_code=600036.SH 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:65038 - "GET /api/screener/preview/600036.SH?limit=500&adjust=qfq&timeframe=1d 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: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: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:65046 - "GET /api/trades?ts_code=601318.SH 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:65047 - "GET /api/screener/preview/601318.SH?limit=500&adjust=qfq&timeframe=1d 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: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:52708 - "GET /api/auth/me 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:52712 - "GET /api/preferences 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:52713 - "GET /api/market/overview 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:52731 - "GET /api/auth/me 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:52734 - "GET /api/market/overview 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:52733 - "GET /api/preferences 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:52742 - "GET /api/market/overview 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:52757 - "GET /api/screener/queries?limit=20 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:52772 - "GET /api/screener/queries?limit=20 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:52752 - "GET /api/screener/sync/status 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:52771 - "POST /api/screener/run 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: Started server process [764]
|
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: Waiting for application startup.
|
INFO: 127.0.0.1:65108 - "GET /api/trades?ts_code=600028.SH HTTP/1.1" 200 OK
|
||||||
INFO: Application startup complete.
|
INFO: 127.0.0.1:65110 - "GET /api/screener/preview/600028.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||||
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
|
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:60296 - "GET /docs 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,
|
PreviewResponse,
|
||||||
ScreenerQueryItem,
|
ScreenerQueryItem,
|
||||||
ScreenerRunRequest,
|
ScreenerRunRequest,
|
||||||
|
ScreenerStreamEvent,
|
||||||
ScreenerRunResponse,
|
ScreenerRunResponse,
|
||||||
ScreenerSyncRequest,
|
ScreenerSyncRequest,
|
||||||
ScreenerSyncStatus,
|
ScreenerSyncStatus,
|
||||||
@@ -120,10 +121,38 @@ export async function postEventBacktest(req: EventBacktestRequest): Promise<Even
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runScreener(req: ScreenerRunRequest): Promise<ScreenerRunResponse> {
|
/** 选股流式执行:逐行回调 NDJSON 事件(阶段/进度/解析条件),最终返回完整结果。
|
||||||
const res = await apiFetch('/api/screener/run', { method: 'POST', body: JSON.stringify(req) });
|
* 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);
|
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> {
|
export async function startScreenerSync(req: ScreenerSyncRequest = {}): Promise<ScreenerSyncStatus> {
|
||||||
|
|||||||
@@ -148,6 +148,15 @@ export interface ScreenerRunResponse {
|
|||||||
indicator_labels: Record<string, string>;
|
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 {
|
export interface ScreenerSyncRequest {
|
||||||
days?: number;
|
days?: number;
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
threeWaves, triangle, xabcd,
|
threeWaves, triangle, xabcd,
|
||||||
} from '@klinecharts/extension';
|
} from '@klinecharts/extension';
|
||||||
import { useSettingsStore, TOOLTIP_FIELDS, DEFAULT_TOOLTIP_FIELDS } from '@/stores/settings';
|
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';
|
import type { Candle, TooltipField } from '@/api/types';
|
||||||
|
|
||||||
// 通达信导入的自定义主图指标注册表;知行序列走 PV 管道按时间戳取预计算值。
|
// 通达信导入的自定义主图指标注册表;知行序列走 PV 管道按时间戳取预计算值。
|
||||||
@@ -671,11 +671,62 @@ function syncChipsIndicator() {
|
|||||||
if (want) {
|
if (want) {
|
||||||
if (exists) chart.removeIndicator({ name: CHIPS_NAME });
|
if (exists) chart.removeIndicator({ name: CHIPS_NAME });
|
||||||
chart.createIndicator({ name: CHIPS_NAME, paneId: 'candle_pane', extendData: props.chips ?? undefined });
|
chart.createIndicator({ name: CHIPS_NAME, paneId: 'candle_pane', extendData: props.chips ?? undefined });
|
||||||
|
syncRightOffset(); // 开关即扩留白(不等数据),数据到达时列直接落在已预留的空白里
|
||||||
} else if (exists) {
|
} else if (exists) {
|
||||||
chart.removeIndicator({ name: CHIPS_NAME });
|
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() {
|
function build() {
|
||||||
if (!container.value || props.candles.length === 0) return;
|
if (!container.value || props.candles.length === 0) return;
|
||||||
UP = settings.upHex;
|
UP = settings.upHex;
|
||||||
@@ -801,7 +852,23 @@ function build() {
|
|||||||
const from = (payload as { data?: { from?: unknown } }).data?.from;
|
const from = (payload as { data?: { from?: unknown } }).data?.from;
|
||||||
if (typeof from === 'number' && from < 200) maybePrefetch(myEpoch);
|
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();
|
ch.scrollToRealTime();
|
||||||
// 日期跳转:build 尾部的 scrollToRealTime 会把视口重置到最新一根,居中必须放在它之后
|
// 日期跳转:build 尾部的 scrollToRealTime 会把视口重置到最新一根,居中必须放在它之后
|
||||||
//(init 数据在 setPeriod 时已同步落入图表,这里可直接定位)。
|
//(init 数据在 setPeriod 时已同步落入图表,这里可直接定位)。
|
||||||
@@ -814,6 +881,8 @@ function build() {
|
|||||||
|
|
||||||
function teardown() {
|
function teardown() {
|
||||||
if (subHTimer) { clearTimeout(subHTimer); subHTimer = null; }
|
if (subHTimer) { clearTimeout(subHTimer); subHTimer = null; }
|
||||||
|
if (scrollReanchorTimer) { clearTimeout(scrollReanchorTimer); scrollReanchorTimer = null; }
|
||||||
|
managedRightPx = 0;
|
||||||
if (container.value) dispose(container.value);
|
if (container.value) dispose(container.value);
|
||||||
chart = null;
|
chart = null;
|
||||||
hover.value = null;
|
hover.value = null;
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { deleteScreenerQuery, getScreenerQueries } from '@/api/client';
|
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 props = defineProps<{ loading: boolean }>();
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'run', text: string, conditions?: ScreenConditions | null): void;
|
(e: 'run', text: string): void;
|
||||||
(e: 'ran'): void;
|
(e: 'ran'): void;
|
||||||
|
(e: 'stop'): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const text = ref('');
|
const text = ref('');
|
||||||
@@ -18,13 +19,14 @@ const examples = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function run() {
|
function run() {
|
||||||
|
if (props.loading) return; // 执行中 Ctrl+Enter 不重复触发(要停止点「停止」)
|
||||||
if (text.value.trim()) {
|
if (text.value.trim()) {
|
||||||
emit('run', text.value.trim());
|
emit('run', text.value.trim());
|
||||||
emit('ran');
|
emit('ran');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 提问历史(入库,可一键重跑 / 删除) ----------
|
// ---------- 提问历史(点击只填入输入框,不触发查询 / 删除) ----------
|
||||||
const history = ref<ScreenerQueryItem[]>([]);
|
const history = ref<ScreenerQueryItem[]>([]);
|
||||||
const historyOpen = ref(false);
|
const historyOpen = ref(false);
|
||||||
|
|
||||||
@@ -38,12 +40,9 @@ async function refreshHistory() {
|
|||||||
} catch { history.value = []; }
|
} catch { history.value = []; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function rerun(q: ScreenerQueryItem) {
|
function fillQuery(q: ScreenerQueryItem) {
|
||||||
text.value = q.text;
|
text.value = q.text;
|
||||||
historyOpen.value = false;
|
historyOpen.value = false;
|
||||||
// 存过 conditions 的记录直传条件,跳过 LLM 重新解析
|
|
||||||
emit('run', q.text, q.conditions ?? null);
|
|
||||||
emit('ran');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeQuery(id: number) {
|
async function removeQuery(id: number) {
|
||||||
@@ -87,8 +86,8 @@ defineExpose({ refreshHistory });
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="min-w-0 flex-1 text-left"
|
class="min-w-0 flex-1 text-left"
|
||||||
:title="q.conditions ? '点击直传条件重跑(不重新解析)' : '点击填入并重跑'"
|
title="点击填入输入框(不自动执行)"
|
||||||
@click="rerun(q)"
|
@click="fillQuery(q)"
|
||||||
>
|
>
|
||||||
<span class="block truncate text-sm text-[#E8EAED]">{{ q.text }}</span>
|
<span class="block truncate text-sm text-[#E8EAED]">{{ q.text }}</span>
|
||||||
<span class="mt-0.5 block text-xs text-[#9BA3AE]">
|
<span class="mt-0.5 block text-xs text-[#9BA3AE]">
|
||||||
@@ -134,10 +133,19 @@ defineExpose({ refreshHistory });
|
|||||||
支持 KDJ / RSI / MACD / 布林 / 均线指标条件,市值 / 市盈率 / 换手率等快照条件,以及「连续 N 天」「近 N 天任一天」时间窗口。
|
支持 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> 快速筛选。
|
按 <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>
|
</p>
|
||||||
<button type="button" class="btn-primary shrink-0 disabled:opacity-50" :disabled="loading || !text.trim()" @click="run">
|
<!-- 执行中变为「停止」:中止 LLM 解析与逐股过滤(服务端随连接断开自动取消) -->
|
||||||
<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>
|
<button
|
||||||
<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>
|
v-if="loading"
|
||||||
{{ 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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
// 个股筹码峰(Tushare cyq_chips / cyq_perf,同花顺式右侧横向线状直方图)
|
// 个股筹码峰(Tushare cyq_chips / cyq_perf,同花顺火焰山式右侧专用竖列)
|
||||||
//
|
//
|
||||||
// 数据不在图表窗口上计算,而是一次性截面(某交易日各价位筹码占比 + 获利比例/平均成本),
|
// 数据不在图表窗口上计算,而是一次性截面(某交易日各价位筹码占比 + 获利比例/平均成本),
|
||||||
// 经 extendData 注入、纯 draw 绘制:
|
// 经 extendData 注入、纯 draw 绘制:
|
||||||
// - 每个价位一根横线,从主图右缘向左延伸,宽度 ∝ 占比/最大占比(火焰山形态)
|
// - 主图绘图区右缘、价格轴左侧一条【不透明底色专用竖列】,K线不透出(同花顺同款)
|
||||||
// - 分界价 = 选中K线的收盘价(refClose):下方为获利盘(红线)、上方为套牢盘(蓝线)
|
// - 每个价位一根横条,在列内从右缘向左伸展,宽度 ∝ 占比/最大占比(火焰山形态)
|
||||||
// - 平均成本画黄色虚线;右上角小字块:截面日期 / 获利比例 / 平均成本 / 90% 成本区间
|
// - 分界价 = 选中K线的收盘价(refClose):下方为获利盘(红)、上方为套牢盘(蓝)
|
||||||
|
// - 平均成本画黄色虚线、末根收盘画现价细实线;列内顶部小字:截面日/获利比例/平均成本/90%成本
|
||||||
|
// DetailKLine 依据 CHIPS_COL_PX 在开启筹码时加大右侧留白,保证最新K线不被竖列盖住。
|
||||||
// figures 为空、calc 返回空行:指标不产生序列值,也不影响主图价格刻度(筹码是覆盖层)。
|
// figures 为空、calc 返回空行:指标不产生序列值,也不影响主图价格刻度(筹码是覆盖层)。
|
||||||
import type { IndicatorTemplate } from 'klinecharts';
|
import type { IndicatorTemplate } from 'klinecharts';
|
||||||
|
|
||||||
export const CHIPS_NAME = 'pv-chips';
|
export const CHIPS_NAME = 'pv-chips';
|
||||||
|
|
||||||
|
/** 火焰山竖列基准宽度(px):DetailKLine 据此预留右侧留白,draw 据此画列 */
|
||||||
|
export const CHIPS_COL_PX = 120;
|
||||||
|
|
||||||
/** 经 extendData 注入的筹码截面(父组件按 当前窗口末根/十字线 所在周期取数) */
|
/** 经 extendData 注入的筹码截面(父组件按 当前窗口末根/十字线 所在周期取数) */
|
||||||
export interface ChipsExt {
|
export interface ChipsExt {
|
||||||
rows: { price: number; percent: number }[]; // percent 为 0~1 占比
|
rows: { price: number; percent: number }[]; // percent 为 0~1 占比
|
||||||
@@ -29,16 +34,34 @@ const AVG_COLOR = '#F5C518';
|
|||||||
const LABEL_COLOR = '#9AA0AA';
|
const LABEL_COLOR = '#9AA0AA';
|
||||||
const VALUE_COLOR = '#E8EAED';
|
const VALUE_COLOR = '#E8EAED';
|
||||||
|
|
||||||
/** 同花顺筹码峰同款:横线最大占主图宽度的比例与上限(px) */
|
// 竖列几何:窄图下限 56px,最多占绘图区宽度 35%(实际宽度取两者与基准的钳制值)
|
||||||
const MAX_WIDTH_RATIO = 0.22;
|
const COL_MIN_PX = 56;
|
||||||
const MAX_WIDTH_PX = 150;
|
const COL_MAX_RATIO = 0.35;
|
||||||
/** 横条高度上限(px):极度放大时单价位占比条不至于铺满半个主图 */
|
/** 列内左右内边距(横条右缘、统计文字与列缘的呼吸) */
|
||||||
|
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;
|
const BAR_HEIGHT_CAP = 24;
|
||||||
|
/** 统计文字起始 y(画线工具栏是 HTML 层,占顶部 ~36px,文字块从 44px 起)/ 行高 */
|
||||||
|
const HEADER_TOP = 44;
|
||||||
|
const LINE_H = 13;
|
||||||
|
|
||||||
function fmtDate(d?: string): string {
|
function fmtDate(d?: string): string {
|
||||||
return d && d.length === 8 ? `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}` : '—';
|
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> = {
|
export const chipsIndicator: IndicatorTemplate<Record<string, never>, unknown, ChipsExt> = {
|
||||||
name: CHIPS_NAME,
|
name: CHIPS_NAME,
|
||||||
shortName: '筹码',
|
shortName: '筹码',
|
||||||
@@ -49,70 +72,60 @@ export const chipsIndicator: IndicatorTemplate<Record<string, never>, unknown, C
|
|||||||
const ext = indicator.extendData;
|
const ext = indicator.extendData;
|
||||||
if (!ext) return true;
|
if (!ext) return true;
|
||||||
|
|
||||||
// 右上角统计小字块(画线工具栏是 HTML 层,占顶部 ~36px,文字块从 44px 起)
|
// 竖列几何:bounding.right 即 y 轴左缘,列贴着价格轴(预留逻辑见 DetailKLine 的 syncRightOffset)
|
||||||
const lines: { label: string; value: string; color: string }[] = [];
|
const colW = Math.max(COL_MIN_PX, Math.min(CHIPS_COL_PX, Math.floor(bounding.width * COL_MAX_RATIO)));
|
||||||
if (ext.error) {
|
const colRight = bounding.right;
|
||||||
lines.push({ label: '筹码', value: ext.error, color: LABEL_COLOR });
|
const colLeft = colRight - colW;
|
||||||
} 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;
|
|
||||||
|
|
||||||
|
// 无数据/出错:不画底色列(空面板不遮图),错误文案画在原右上角位置
|
||||||
|
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线的收盘价(父组件随截面注入;缺省退回图表末根收盘),
|
// 分界价:选中K线的收盘价(父组件随截面注入;缺省退回图表末根收盘),
|
||||||
// <=分界价为获利盘(红)、>分界价为套牢盘(蓝)——同花顺同款
|
// <=分界价为获利盘(红)、>分界价为套牢盘(蓝)——同花顺同款
|
||||||
const list = chart.getDataList();
|
const split = ext.refClose ?? (list.length ? list[list.length - 1].close : undefined);
|
||||||
const close = ext.refClose ?? (list.length ? list[list.length - 1].close : undefined);
|
|
||||||
|
|
||||||
|
// 2. 筹码横条:列右缘向左伸展,宽度 ∝ 占比/最大占比
|
||||||
// 价位升序,便于求相邻像素间距(直方图的“条高”)
|
// 价位升序,便于求相邻像素间距(直方图的“条高”)
|
||||||
const rows = [...ext.rows].sort((a, b) => a.price - b.price);
|
const rows = [...ext.rows].sort((a, b) => a.price - b.price);
|
||||||
const maxP = rows.reduce((m, r) => Math.max(m, r.percent), 0);
|
const maxP = rows.reduce((m, r) => Math.max(m, r.percent), 0);
|
||||||
if (maxP <= 0) return true;
|
if (maxP > 0) {
|
||||||
const maxW = Math.min(bounding.width * MAX_WIDTH_RATIO, MAX_WIDTH_PX);
|
// 相邻价位的中位像素间距 → 条高(连续火焰形态;缩到极小时并成实心轮廓)
|
||||||
|
const pitches: number[] = [];
|
||||||
// 相邻价位的中位像素间距 → 条高(连续火焰形态;缩到极小时并成实心轮廓)
|
for (let i = 1; i < rows.length; i++) {
|
||||||
const pitches: number[] = [];
|
const p = Math.abs(yAxis.convertToPixel(rows[i].price) - yAxis.convertToPixel(rows[i - 1].price));
|
||||||
for (let i = 1; i < rows.length; i++) {
|
if (p > 0) pitches.push(p);
|
||||||
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;
|
||||||
pitches.sort((a, b) => a - b);
|
const barH = Math.min(BAR_HEIGHT_CAP, Math.max(1, pitch * 0.92));
|
||||||
const pitch = pitches.length ? pitches[pitches.length >> 1] : 4;
|
const maxBarW = colW - COL_PAD * 2;
|
||||||
const barH = Math.min(BAR_HEIGHT_CAP, Math.max(1, pitch * 0.92));
|
for (const r of rows) {
|
||||||
|
const y = yAxis.convertToPixel(r.price);
|
||||||
for (const r of rows) {
|
if (y < bounding.top - barH || y > bounding.bottom + barH) continue; // 视口外跳过
|
||||||
const y = yAxis.convertToPixel(r.price);
|
const w = Math.max(1, (r.percent / maxP) * maxBarW);
|
||||||
if (y < bounding.top - barH || y > bounding.bottom + barH) continue; // 视口外跳过
|
ctx.fillStyle = split == null ? 'rgba(154,160,170,0.85)'
|
||||||
const w = Math.max(1, (r.percent / maxP) * maxW);
|
: r.price <= split ? `rgba(254,53,75,${BAR_ALPHA})` : `rgba(47,123,255,${BAR_ALPHA})`;
|
||||||
ctx.fillStyle = close == null ? 'rgba(154,160,170,0.5)'
|
ctx.fillRect(colRight - COL_PAD - w, y - barH / 2, w, barH);
|
||||||
: 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 平均成本虚线(横贯直方图区)
|
// 3. 平均成本虚线(横贯竖列)
|
||||||
if (ext.avg != null) {
|
if (ext.avg != null) {
|
||||||
const y = yAxis.convertToPixel(ext.avg);
|
const y = yAxis.convertToPixel(ext.avg);
|
||||||
if (y >= bounding.top && y <= bounding.bottom) {
|
if (y >= bounding.top && y <= bounding.bottom) {
|
||||||
@@ -121,12 +134,55 @@ export const chipsIndicator: IndicatorTemplate<Record<string, never>, unknown, C
|
|||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
ctx.setLineDash([4, 3]);
|
ctx.setLineDash([4, 3]);
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(bounding.right - maxW, y + 0.5);
|
ctx.moveTo(colLeft + 1, Math.round(y) + 0.5);
|
||||||
ctx.lineTo(bounding.right, y + 0.5);
|
ctx.lineTo(colRight, Math.round(y) + 0.5);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.restore();
|
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;
|
return true;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,5 +19,5 @@ export function registerCustomIndicators(get: ZhixingGetter) {
|
|||||||
registerIndicator(chipsIndicator);
|
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';
|
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 { runScreener } from '@/api/client';
|
||||||
import type { ScreenConditions, ScreenerRunResponse } from '@/api/types';
|
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', () => {
|
export const useScreenerStore = defineStore('screener', () => {
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const stage = ref<'idle' | 'parsing' | 'screening'>('idle');
|
|
||||||
const note = ref<string | null>(null);
|
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const result = ref<ScreenerRunResponse | 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) {
|
async function run(text: string, conditions?: ScreenConditions | null) {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
|
aborted.value = false;
|
||||||
result.value = null;
|
result.value = null;
|
||||||
note.value = conditions ? '全市场筛选中…' : 'AI 解析条件中…';
|
trace.value = [];
|
||||||
stage.value = conditions ? 'screening' : 'parsing';
|
progress.value = null;
|
||||||
|
parsed.value = conditions ?? null;
|
||||||
|
if (conditions) pushLine({ key: 'direct', msg: '直传条件,跳过 AI 解析' });
|
||||||
|
ctrl = new AbortController();
|
||||||
try {
|
try {
|
||||||
// 条件解析与全市场筛选在后端一气呵成;切到筛选阶段给个过渡提示
|
result.value = await runScreener({ text, conditions: conditions ?? undefined }, (ev) => {
|
||||||
setTimeout(() => {
|
switch (ev.type) {
|
||||||
if (loading.value && stage.value === 'parsing') {
|
case 'stage':
|
||||||
stage.value = 'screening';
|
pushLine({ key: ev.key, msg: ev.msg, ms: ev.ms });
|
||||||
note.value = '全市场筛选中…';
|
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);
|
}, ctrl.signal);
|
||||||
result.value = await runScreener({ text, conditions: conditions ?? undefined });
|
|
||||||
} catch (e) {
|
} 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 {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
stage.value = 'idle';
|
progress.value = null;
|
||||||
note.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">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useScreenerStore } from '@/stores/screener';
|
import { useScreenerStore } from '@/stores/screener';
|
||||||
import ScreenerForm from '@/components/ScreenerForm.vue';
|
import ScreenerForm from '@/components/ScreenerForm.vue';
|
||||||
import ConditionChips from '@/components/ConditionChips.vue';
|
import ConditionChips from '@/components/ConditionChips.vue';
|
||||||
@@ -9,20 +9,59 @@ import StockDetailOverlay from '@/components/StockDetailOverlay.vue';
|
|||||||
const store = useScreenerStore();
|
const store = useScreenerStore();
|
||||||
const previewCode = ref<string | null>(null);
|
const previewCode = ref<string | null>(null);
|
||||||
const formRef = ref<InstanceType<typeof ScreenerForm> | 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<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">
|
<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>
|
<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 }}
|
{{ store.error }}
|
||||||
</div>
|
</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>
|
<div
|
||||||
{{ store.note }}
|
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>
|
</div>
|
||||||
|
|
||||||
<template v-else-if="store.result">
|
<template v-else-if="store.result">
|
||||||
|
|||||||
@@ -1,17 +1,31 @@
|
|||||||
$ vite
|
$ 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
|
[32m➜[39m [1mLocal[22m: [36mhttp://localhost:[1m5173[22m/[39m
|
||||||
[2m [32m➜[39m [1mNetwork[22m[2m: use [22m[1m--host[22m[2m to expose[22m
|
[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]:
|
AggregateError [ECONNREFUSED]:
|
||||||
at internalConnectMultiple (node:net:1134:18)
|
at internalConnectMultiple (node:net:1134:18)
|
||||||
at afterConnectMultiple (node:net:1715:7)
|
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