Compare commits

..

4 Commits

Author SHA1 Message Date
76b422320b 提交 2026-09-04 10:00:35 +08:00
2735ff1fd9 同步入口移至首页:MarketSyncBar 手动同步A股当日数据并展示最新更新日期;选股页移除数据状态条与同步逻辑 2026-09-02 23:10:35 +08:00
2f9c8bee2b 日K加载提速:adj_factor 覆盖索引 + 冷路径并发 + 热路径进程内缓存直返
- adj_factor 覆盖索引 (ts_code,trade_date) INCLUDE (adj_factor):根治堆碎片化
  (单股 6516 行散 6516 块,满载时位图堆扫 1.5s+),Index Only Scan ~2ms;
  因子查询全部改 2 列投影,lag() 窗口只取变点(6516→32 行)
- preview 冷路径 2 会话 2 波并发,信息卡合并为 LEFT JOIN LATERAL 一条
- 鉴权会话 60s 进程内缓存(登出/全端登出即时失效),全站请求省 ~80ms
- pvj/chipsj/stocksj/facetsj 存 model_dump_json 原串直返(与 response_model
  字节一致),热路径 230-2190ms → 1-2ms;get_version 本地缓存+bump 即时可见
- 连接池 10+20;smoke_test 适配鉴权缓存
2026-09-02 16:52:28 +08:00
bec4e8149c 知行多空线指标接入选股与事件回测;大盘总览走 quicksync 统一入口;candles 批量 upsert 分批防 asyncpg 参数超限 2026-09-02 16:51:52 +08:00
28 changed files with 1132 additions and 769 deletions

View File

@@ -9,8 +9,8 @@ DATA_DEFAULT_START=20200101
# ---- Redis 读缓存(股票列表/筛选项;留空则不缓存直查数据库)----
REDIS_URL=redis://default:26d5c71d57344f37b8b4ddb567f2652f0c7ef41c774284ad@cirry.cn:6379
# ---- LLM智能选股智谱 GLMOpenAI 兼容协议)----
# ---- LLM智能选股DeepSeekOpenAI 兼容协议/anthropic 后缀会被 _endpoint 自动归一----
# key 在 https://bigmodel.cn 控制台获取,格式形如 xxxxxxxx.yyyyyyyyid.secret
LLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4
LLM_API_KEY=ea24bbdd3d2d4dd2b8f03de4c9a5d984.9X1Hz1yKx0VKSnrU
LLM_MODEL=glm-5.2
LLM_BASE_URL=https://api.deepseek.com
LLM_API_KEY=sk-b09acbd6c0ca4f94818b6deb039d6515
LLM_MODEL=deepseek-chat

View File

@@ -0,0 +1,49 @@
"""adj_factor 覆盖索引:因子查询免堆访问(碎片免疫)
Revision ID: 20260902_01
Revises: 20260815_04
Create Date: 2026-09-02
"""
from typing import Sequence, Union
from alembic import op
revision: str = "20260902_01"
down_revision: Union[str, Sequence[str], None] = "20260815_04"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# CONCURRENTLY / VACUUM 不能在事务内执行autocommit_block 内每条语句独立提交
with op.get_context().autocommit_block():
op.create_index(
"ix_adj_code_date_cover",
"adj_factor",
["ts_code", "trade_date"],
postgresql_concurrently=True,
postgresql_include=["adj_factor"],
)
# ts_code 单列索引被覆盖索引取代(同前缀),删掉省写放大
op.drop_index(
"ix_adj_factor_ts_code",
table_name="adj_factor",
postgresql_concurrently=True,
)
# 刷可见性映射Index Only Scan 的前提)+ 更新统计信息低峰执行3.2GB 表数十秒)
op.execute("VACUUM (ANALYZE) adj_factor")
def downgrade() -> None:
with op.get_context().autocommit_block():
op.create_index(
"ix_adj_factor_ts_code",
"adj_factor",
["ts_code"],
postgresql_concurrently=True,
)
op.drop_index(
"ix_adj_code_date_cover",
table_name="adj_factor",
postgresql_concurrently=True,
)

View File

@@ -18,7 +18,8 @@ import json
from datetime import datetime
import pandas as pd
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
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
@@ -33,7 +34,7 @@ from .data import fetcher, repository, tushare_provider
from .data.aggregation import bars_per_year, resample_bars
from .data.market_overview import MarketOverviewError, fetch_overview
from .data.symbols import plain_code
from .db import get_session
from .db import async_session, get_session
from .domain import Bar
from . import indicators as ind
from .trades import parse_statement
@@ -41,7 +42,6 @@ from .models import (
AdjFactor,
BacktestRun,
Candle,
DailySnapshot,
ScreenerQuery,
StockBasic,
UserPreference,
@@ -89,6 +89,25 @@ from .screener.llm import ScreenerError, parse_conditions, parse_event_spec
router = APIRouter(prefix="/api", dependencies=[Depends(require_user)])
def _raw_json(resp) -> str:
"""pydantic-coreRust序列化与 response_model 直返时的字节完全一致(紧凑分隔符、
非 ASCII 直出、浮点小数形式),且比 stdlib json.dumps 快。大响应preview ~250KB
命中缓存时直接 Response 原样返回,跳过校验/再序列化。"""
return resp.model_dump_json()
async def _cached_json_response(key: str) -> Response | None:
"""两级缓存读(进程内 → Redis命中返回可直接吐给客户端的 Response。
存的均为序列化好的 JSON 字符串Redis 侧 json.loads 后仍是 strRedis 命中顺手晋级本地。"""
raw = cache.local_get(key)
if raw is None:
raw = await cache.cache_get(key)
if not isinstance(raw, str):
return None
cache.local_set(key, raw, ttl=120)
return Response(content=raw, media_type="application/json")
def _series_to_jsonable(s: pd.Series) -> list[float | None]:
"""NaN -> Nonelightweight-charts 的 whitespace data跳过指标预热期"""
out: list[float | None] = []
@@ -114,6 +133,40 @@ _ADJUST_MODES = ("bfq", "qfq", "hfq")
# MA 全量集合(前端已改为本地计算 MA后端始终返回此集合以保证缓存一致
_FULL_MA_SET = (5, 10, 20, 30, 60, 120, 250)
# 信息卡一条 SQL 拿全stock_basic 基本信息 + 「优先与行情同日、缺则最新日」的 daily_snapshot
# LATERAL 单条替换原两条查询语义不变target 为 NULL 时全按最新日兜底)
_INFO_SQL = text(
"""
SELECT sb.ts_code, sb.symbol, sb.name, sb.industry, sb.area, sb.market, sb.list_date,
ds.turnover_rate, ds.pe_ttm, ds.pb, ds.total_mv, ds.circ_mv
FROM stock_basic sb
LEFT JOIN LATERAL (
SELECT turnover_rate, pe_ttm, pb, total_mv, circ_mv
FROM daily_snapshot
WHERE ts_code = sb.ts_code
ORDER BY (trade_date = cast(:target AS timestamp)) DESC, trade_date DESC
LIMIT 1
) ds ON true
WHERE sb.ts_code = :code
"""
)
# 复权因子是阶梯函数(除权日之间不变):只取「变化点」行,把每符号 ~7000 行日级因子压到
# 几十行600118 仅 32 行传输量再降两个数量级lag 窗口在覆盖索引上走 Index Only Scan。
# upto 传全局最新交易日(非分页)或 end 日期(分页);窗口首行 prev 为 NULL 恒被保留(窗口基线因子)。
_FACTOR_STEP_SQL = text(
"""
SELECT trade_date, adj_factor FROM (
SELECT trade_date, adj_factor,
lag(adj_factor) OVER (ORDER BY trade_date) AS prev
FROM adj_factor
WHERE ts_code = :code AND trade_date <= :upto
) t
WHERE adj_factor IS DISTINCT FROM prev
ORDER BY trade_date
"""
)
def _adjust_bars(bars: list[Bar], factors, from_mode: str, to_mode: str) -> list[Bar]:
"""按复权因子把 K 线从 from_mode 换算到 to_modebfq/qfq/hfq
@@ -121,7 +174,7 @@ def _adjust_bars(bars: list[Bar], factors, from_mode: str, to_mode: str) -> list
相对不复权的乘数bfq=1qfq=f(t)/f(latest)hfq=f(t)。
因子缺失的日期向前沿用最近因子(因子是阶梯函数,除权日之间不变)。
"""
fd = sorted((f.trade_date.date(), float(f.adj_factor)) for f in factors)
fd = sorted((f[0].date(), float(f[1])) for f in factors)
fdates = [d for d, _ in fd]
f_latest = fd[-1][1]
@@ -262,26 +315,27 @@ async def list_stocks(
offset: int = 0,
session: AsyncSession = Depends(get_session),
user=Depends(require_user),
) -> StockListResponse:
) -> Response:
"""全市场股票列表stock_basic 基本信息 + candles 最新行情 + daily_snapshot 估值指标
(换手率/PE-TTM/PB/市值,无快照则这些列为空)。
watched_only=true 只看自选(自选有独立的「自选」分类入口,列表不再把自选排最前)。
sort ∈ {symbol,total_mv,circ_mv,pe_ttm,pb,turnover_rate}(白名单,其他值回落 symbol
order ∈ asc/desc快照列排序时缺失值无快照/亏损无 PE恒排末尾。
Redis 缓存:按「用户自选版本 + 查询参数(含排序)」缓存整页(含 total自选增删即时失效"""
缓存:按「用户自选版本 + 查询参数(含排序)」缓存整页(含 total自选增删即时失效
与 preview 同款序列化 JSON 直返j 前缀),命中跳过 pydantic 校验/序列化。"""
search = search.strip()
sort = sort if sort in _STOCKS_SORTS else "symbol"
order = "desc" if order.lower() == "desc" else "asc"
limit = max(1, min(limit, 500))
offset = max(0, offset)
key = (
f"stocks:u{user.id}"
f"stocksj:u{user.id}"
f":v{await cache.get_version(f'watchlist:{user.id}')}"
f":{cache.digest(search, market, industry, area, watched_only, sort, order, limit, offset)}"
)
cached = await cache.cache_get(key)
cached = await _cached_json_response(key)
if cached is not None:
return StockListResponse(**cached)
return cached
params = {
"search": search,
"psearch": f"%{search}%",
@@ -296,16 +350,18 @@ async def list_stocks(
total = (await session.execute(_STOCKS_COUNT_SQL, params)).scalar_one()
rows = (await session.execute(_stocks_sql(sort, order), params)).mappings().all()
resp = StockListResponse(total=total, items=[StockListItemOut(**r) for r in rows])
await cache.cache_set(key, resp.model_dump(mode="json"), settings.stocks_cache_ttl)
return resp
raw = _raw_json(resp)
cache.local_set(key, raw, ttl=min(120, settings.stocks_cache_ttl))
asyncio.create_task(cache.cache_set(key, raw, ttl=settings.stocks_cache_ttl))
return Response(content=raw, media_type="application/json")
@router.get("/stocks/facets", response_model=StockFacetsResponse)
async def stock_facets(session: AsyncSession = Depends(get_session)) -> StockFacetsResponse:
async def stock_facets(session: AsyncSession = Depends(get_session)) -> Response:
"""看股页筛选项:行业 / 地域含数量按数量降序。stock_basic 很少变,长缓存。"""
cached = await cache.cache_get("facets:stocks")
cached = await _cached_json_response("facetsj:stocks")
if cached is not None:
return StockFacetsResponse(**cached)
return cached
industries = (
await session.execute(text("""
SELECT industry AS name, count(*) AS n FROM stock_basic
@@ -324,8 +380,10 @@ async def stock_facets(session: AsyncSession = Depends(get_session)) -> StockFac
industries=[FacetItemOut(name=r["name"], count=r["n"]) for r in industries],
areas=[FacetItemOut(name=r["name"], count=r["n"]) for r in areas],
)
await cache.cache_set("facets:stocks", resp.model_dump(mode="json"), settings.facets_cache_ttl)
return resp
raw = _raw_json(resp)
cache.local_set("facetsj:stocks", raw, ttl=min(120, settings.facets_cache_ttl))
asyncio.create_task(cache.cache_set("facetsj:stocks", raw, ttl=settings.facets_cache_ttl))
return Response(content=raw, media_type="application/json")
@router.get("/market/overview", response_model=MarketOverviewResponse)
@@ -461,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)
@@ -788,7 +887,7 @@ async def screener_preview(
ts_code: str, limit: int = 500, adjust: str = "qfq", timeframe: str = "1d", mas: str = "5,10,20,60",
zx: str = "10,20,30,60", end: str | None = None,
session: AsyncSession = Depends(get_session),
) -> PreviewResponse:
) -> Response:
"""个股详情预览日线candles 不复权底座 + adj_factor 本地换算 bfq/qfq/hfq
未缓存自动拉取,落后全市场最新交易日则强制刷新)+ 全套指标 + 最新截面信息卡。
timeframe 聚合到周/月/年先复权再聚合mas 指定主图 MA 周期(逗号分隔)。
@@ -819,73 +918,92 @@ async def screener_preview(
raise HTTPException(status_code=400, detail="end 格式应为 YYYY-MM-DD")
symbol = plain_code(ts_code)
# --- Redis 读缓存历史窗口end 翻页)只增不改,最新窗口每日由全市场同步推进;
# 键含 ver:candles 版本号同步完成后自增旧缓存全部失效TTL 兜底cache.py
# --- 两级读缓存历史窗口end 翻页)只增不改,最新窗口每日由全市场同步推进;
# 键含 ver:candles 版本号同步完成后自增旧缓存全部失效TTL 兜底cache.py
# 存序列化好的 JSON 直返j: 前缀),跳过 json.loads + pydantic 校验/序列化(热路径数百 ms → 个位数)。
# 注ma_periods 不参与缓存键 —— 前端已改为本地计算 MA后端始终返回全量 MA 集合
cache_key = cache.digest(
"preview", ts_code, timeframe, limit, adjust,
end_dt.strftime("%Y-%m-%d") if end_dt else None,
await cache.get_version("candles"),
)
cached = await cache.cache_get(f"pv:{cache_key}")
cached = await _cached_json_response(f"pvj:{cache_key}")
if cached is not None:
return PreviewResponse.model_validate(cached)
return cached
# --- 日线candles(全量不复权底座);未缓存拉取,落后于全市场最新交易日则强制刷新 ---
# fetcher 只做「不复权」增量 upsert底座口径恒为 bfqTDX 全量 + Tushare 增量),
# 复权qfq/hfq读取时按 adj_factor 表本地换算。
# 每次只取「窗口 + 400 根预热」行MA250/MACD EMA 在 400 根内充分收敛),不拉全量:
# 首屏 ~500 根秒开,向左滚动时按 end 参数逐页向前翻。
global_latest = await session.scalar(
select(func.max(Candle.ts)).where(Candle.timeframe == "1d")
)
frame_mult = {"1d": 1, "1w": 6, "1M": 24, "1y": 280}[timeframe]
fetch_n = min(100000, limit * frame_mult + 400)
source = "bfq"
mode = "bfq"
if end_dt is not None:
# 向前翻页:取 end 之前的历史窗口,不触发同步(历史浏览)
rows = await repository.get_candles_before(session, symbol, "1d", before=end_dt, limit=fetch_n)
else:
# 注意取「最新 fetch_n 根」而非最旧get_candles 是 asc+limit取最旧窗口化后首屏会停在过期日期
rows = await repository.get_recent_candles(session, symbol, "1d", limit=fetch_n)
try:
if not rows:
await fetcher.sync_symbol(session, symbol, source="auto")
rows = await repository.get_recent_candles(session, symbol, "1d", limit=fetch_n)
elif global_latest is not None and rows[-1].ts.date() < global_latest.date():
await fetcher.sync_symbol(session, symbol, source="auto", force=True)
rows = await repository.get_recent_candles(session, symbol, "1d", limit=fetch_n)
except Exception: # noqa: BLE001 —— tushare/写库失败时回滚会话(否则毒化后兜底查询 500
await session.rollback()
if not rows:
rows = []
bars = _rows_to_bars(rows)
# 并发约定:注入 session 与 s2 各占一条连接,每次 gather 里每个 session 恰好跑一条查询
# AsyncSession 单连接非并发安全),把 ~6 次串行 DB RTT 折叠成 2 个波次。
async with async_session() as s2:
if end_dt is not None:
# 向前翻页:取 end 之前的历史窗口不触发同步历史浏览max(ts) 用不到
rows = await repository.get_candles_before(session, symbol, "1d", before=end_dt, limit=fetch_n)
global_latest = None
else:
# Wave 1candles 窗口(注入 session+ 全市场最新交易日s2并行
rows, global_latest = await asyncio.gather(
repository.get_recent_candles(session, symbol, "1d", limit=fetch_n),
s2.scalar(select(func.max(Candle.ts)).where(Candle.timeframe == "1d")),
)
try:
if not rows:
await fetcher.sync_symbol(session, symbol, source="auto")
rows = await repository.get_recent_candles(session, symbol, "1d", limit=fetch_n)
elif global_latest is not None and rows[-1].ts.date() < global_latest.date():
await fetcher.sync_symbol(session, symbol, source="auto", force=True)
rows = await repository.get_recent_candles(session, symbol, "1d", limit=fetch_n)
except Exception: # noqa: BLE001 —— tushare/写库失败时回滚会话(否则毒化后兜底查询 500
await session.rollback()
if not rows:
rows = []
bars = _rows_to_bars(rows)
if not bars and end_dt is None:
raise HTTPException(status_code=404, detail=f"无数据: {ts_code}(可先点「同步市场数据」)")
# 信息卡取未聚合的日线最新 bar聚合后 ts 是周期起点,不适用于「最新交易日」)
last_daily = bars[-1] if bars else None
prev_daily = bars[-2] if len(bars) > 1 else None
# 翻页到底end 之前无数据):返回空页 + has_more=False前端停止向前翻页
if not bars and end_dt is None:
raise HTTPException(status_code=404, detail=f"无数据: {ts_code}(可先点「同步市场数据」)")
# 信息卡取未聚合的日线最新 bar聚合后 ts 是周期起点,不适用于「最新交易日」)
last_daily = bars[-1] if bars else None
prev_daily = bars[-2] if len(bars) > 1 else None
# 翻页到底end 之前无数据):返回空页 + has_more=False前端停止向前翻页
# --- 复权换算:请求模式与底座模式不同时按 adj_factor 本地换算(无因子则维持原样) ---
# 只取窗口内因子qfq 归一还需全局最新因子追加到最后一行即可_adjust_bars 取 f_latest=末项)
if adjust != mode and bars:
fq = select(AdjFactor).where(AdjFactor.ts_code == ts_code)
window_end = end_dt if end_dt is not None else bars[-1].ts
if window_end is not None:
fq = fq.where(AdjFactor.trade_date <= window_end)
factors = list((await session.execute(fq.order_by(AdjFactor.trade_date))).scalars().all())
# --- Wave 2复权因子s2覆盖索引 Index Only Scan+ 信息卡(注入 sessionLATERAL 一条)并行 ---
async def _fetch_factors() -> list | None:
if adjust == mode or not bars:
return None
# 只取因子「变化点」行(覆盖索引 Index Only Scan免堆访问——adj_factor 堆碎片化
# 严重bisect 在阶梯函数上取值与日级序列逐字节一致
if end_dt is not None:
# 分页:窗口 ≤ end 的变化点 + 全局最新因子qfq 以最新因子归一)
win = list((await s2.execute(_FACTOR_STEP_SQL, {"code": ts_code, "upto": end_dt})).all())
if win:
latest_f = (await s2.execute(
select(AdjFactor.trade_date, AdjFactor.adj_factor)
.where(AdjFactor.ts_code == ts_code)
.order_by(AdjFactor.trade_date.desc()).limit(1)
)).first()
if latest_f is not None:
win.append(latest_f)
return win or None
# 非分页:上界 global_latest≥ 最新 bar末项变化点即全局最新因子
# 「窗口 ≤ bars[-1].ts + 单独 latest」少一次查询
return list((await s2.execute(
_FACTOR_STEP_SQL, {"code": ts_code, "upto": global_latest}
)).all()) or None
factors, info_row = await asyncio.gather(
_fetch_factors(),
session.execute(_INFO_SQL, {"code": ts_code, "target": last_daily.ts if last_daily else None}),
)
# --- 复权换算:请求模式与底座模式不同时按 adj_factor 本地换算(无因子则维持原样) ---
if factors:
latest_f = (
await session.execute(
select(AdjFactor).where(AdjFactor.ts_code == ts_code)
.order_by(AdjFactor.trade_date.desc()).limit(1)
)
).scalars().first()
if latest_f is not None:
factors.append(latest_f)
bars = _adjust_bars(bars, factors, mode, adjust)
mode = adjust
source = adjust
@@ -927,24 +1045,19 @@ async def screener_preview(
for key in group:
group[key] = group[key][-limit:]
# --- 信息卡stock_basic + candles 最新日线 bar + 与其对齐的快照(避免混用不同交易日 ---
sb = (await session.execute(select(StockBasic).where(StockBasic.ts_code == ts_code))).scalars().first()
ds = None
if last_daily is not None:
# 优先取与行情同日的快照;缺当日快照时退最新(字段可能与行情差日期,罕见)
ds = (
await session.execute(
select(DailySnapshot).where(
DailySnapshot.ts_code == ts_code, DailySnapshot.trade_date == last_daily.ts
)
)
).scalars().first()
if ds is None:
ds = (
await session.execute(
select(DailySnapshot).where(DailySnapshot.ts_code == ts_code).order_by(DailySnapshot.trade_date.desc()).limit(1)
)
).scalars().first()
# --- 信息卡:Wave 2 已并行取回(stock_basic + 与行情同日对齐的快照、缺则最新日,见 _INFO_SQL ---
row = info_row.first()
if row is not None:
m = row._mapping
sb_name, sb_industry, sb_area, sb_market, sb_list_date = (
m["name"], m["industry"], m["area"], m["market"], m["list_date"]
)
ds_turnover, ds_pe, ds_pb, ds_tmv, ds_cmv = (
m["turnover_rate"], m["pe_ttm"], m["pb"], m["total_mv"], m["circ_mv"]
)
else:
sb_name = sb_industry = sb_area = sb_market = sb_list_date = None
ds_turnover = ds_pe = ds_pb = ds_tmv = ds_cmv = None
def _yi(v) -> float | None:
if v is None:
@@ -955,11 +1068,11 @@ async def screener_preview(
info = PreviewInfoOut(
ts_code=ts_code,
symbol=symbol,
name=sb.name if sb else ts_code,
industry=sb.industry if sb else None,
area=sb.area if sb else None,
market=sb.market if sb else None,
list_date=sb.list_date if sb else None,
name=sb_name or ts_code,
industry=sb_industry,
area=sb_area,
market=sb_market,
list_date=sb_list_date,
trade_date=last_daily.ts if last_daily else None,
open=last_daily.open if last_daily else None,
high=last_daily.high if last_daily else None,
@@ -970,11 +1083,11 @@ async def screener_preview(
if last_daily and prev_daily and prev_daily.close else None,
volume_hand=round(last_daily.volume / 100, 0) if last_daily else None, # 股 -> 手
amount_yi=round(last_daily.amount / 1e8, 2) if last_daily and last_daily.amount else None, # 元 -> 亿元
turnover_rate=ds.turnover_rate if ds else None,
pe_ttm=ds.pe_ttm if ds else None,
pb=ds.pb if ds else None,
total_mv=_yi(ds.total_mv) if ds else None,
circ_mv=_yi(ds.circ_mv) if ds else None,
turnover_rate=ds_turnover,
pe_ttm=ds_pe,
pb=ds_pb,
total_mv=_yi(ds_tmv),
circ_mv=_yi(ds_cmv),
)
candles = [
@@ -984,15 +1097,18 @@ async def screener_preview(
]
resp = PreviewResponse(ts_code=ts_code, symbol=symbol, source=source, info=info,
candles=candles, indicators=indicators, has_more=has_more)
await cache.cache_set(f"pv:{cache_key}", resp.model_dump(mode="json"), ttl=600)
return resp
# 只序列化一次本地同步120s+ Redis后台写600s TTL 兜底跨进程/重启)
raw = _raw_json(resp)
cache.local_set(f"pvj:{cache_key}", raw, ttl=120)
asyncio.create_task(cache.cache_set(f"pvj:{cache_key}", raw, ttl=600))
return Response(content=raw, media_type="application/json")
@router.get("/stock/{ts_code}/chips", response_model=ChipsResponse)
async def stock_chips(
ts_code: str, date: str | None = None, adjust: str = "qfq",
session: AsyncSession = Depends(get_session),
) -> ChipsResponse:
) -> Response:
"""个股筹码峰Tushare cyq_chips + cyq_perf数据自 2018 年起)。
date=YYYY-MM-DD 为参考日(日 K 传当日;周/月 K 由前端传周期末):返回
@@ -1008,11 +1124,12 @@ async def stock_chips(
except ValueError:
raise HTTPException(status_code=400, detail="date 格式应为 YYYY-MM-DD")
# 历史截面不可变;键带 candles 版本号adj_factor 随同步更新后旧缓存失效)
# 历史截面不可变;键带 candles 版本号adj_factor 随同步更新后旧缓存失效)
# 同 preview存序列化 JSON 直返j: 前缀),跳过 pydantic 校验/序列化。
cache_key = cache.digest("chips", ts_code, ref or "latest", adjust, await cache.get_version("candles"))
cached = await cache.cache_get(f"chips:{cache_key}")
cached = await _cached_json_response(f"chipsj:{cache_key}")
if cached is not None:
return ChipsResponse.model_validate(cached)
return cached
try:
perf, rows = await asyncio.to_thread(tushare_provider.fetch_chips, ts_code, ref)
@@ -1023,8 +1140,10 @@ async def stock_chips(
ts_code=ts_code, trade_date=None, adjust=adjust,
error="无筹码数据cyq 数据自 2018 年起,或参考日早于数据起点)",
)
await cache.cache_set(f"chips:{cache_key}", resp.model_dump(mode="json"), ttl=3600)
return resp
raw = _raw_json(resp)
cache.local_set(f"chipsj:{cache_key}", raw, ttl=120)
await cache.cache_set(f"chipsj:{cache_key}", raw, ttl=3600)
return Response(content=raw, media_type="application/json")
d = datetime.strptime(str(perf["trade_date"]), "%Y%m%d")
@@ -1032,16 +1151,17 @@ async def stock_chips(
mult = 1.0
if adjust != "bfq":
factors = (await session.execute(
select(AdjFactor).where(AdjFactor.ts_code == ts_code, AdjFactor.trade_date <= d)
select(AdjFactor.trade_date, AdjFactor.adj_factor)
.where(AdjFactor.ts_code == ts_code, AdjFactor.trade_date <= d)
.order_by(AdjFactor.trade_date)
)).scalars().all()
)).all()
if factors:
latest_f = (await session.execute(
select(AdjFactor).where(AdjFactor.ts_code == ts_code)
select(AdjFactor.trade_date, AdjFactor.adj_factor).where(AdjFactor.ts_code == ts_code)
.order_by(AdjFactor.trade_date.desc()).limit(1)
)).scalars().first()
f_at = float(factors[-1].adj_factor) # <=d 的最近因子(因子是阶梯函数)
f_latest = float(latest_f.adj_factor) if latest_f else f_at
)).first()
f_at = float(factors[-1][1]) # <=d 的最近因子(因子是阶梯函数)
f_latest = float(latest_f[1]) if latest_f else f_at
mult = f_at / f_latest if adjust == "qfq" else f_at
def _px(v) -> float | None:
@@ -1059,5 +1179,7 @@ async def stock_chips(
weight_avg=_px(perf.get("weight_avg")),
winner_rate=_px(perf.get("winner_rate")),
)
await cache.cache_set(f"chips:{cache_key}", resp.model_dump(mode="json"), ttl=21600)
return resp
raw = _raw_json(resp)
cache.local_set(f"chipsj:{cache_key}", raw, ttl=120)
await cache.cache_set(f"chipsj:{cache_key}", raw, ttl=21600)
return Response(content=raw, media_type="application/json")

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import hashlib
import secrets
import time
from datetime import datetime, timedelta, timezone
from argon2 import PasswordHasher
@@ -10,7 +11,7 @@ from argon2.exceptions import InvalidHashError, VerificationError
from fastapi import Cookie, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy.orm import joinedload
from .config import settings
from .db import get_session
@@ -75,7 +76,7 @@ async def get_auth_session(
now = utcnow()
stmt = (
select(AuthSession)
.options(selectinload(AuthSession.user))
.options(joinedload(AuthSession.user))
.where(
AuthSession.token_hash == token_digest(token),
AuthSession.revoked_at.is_(None),
@@ -93,13 +94,44 @@ async def get_auth_session(
return auth_session
# --- 鉴权会话进程内缓存 --------------------------------------------------------
# 原本每个 API 请求都要为鉴权付 ~3 个远程 RTT连接池 pre_ping + 查 AuthSession +
# joinedload User命中后完全跳过 DB。登出/撤销在 auth_api 同进程立即清;
# 其他进程撤销(多 worker 部署)最长 _SESSION_CACHE_TTL 后自然过期。
# 缓存的是脱管 ORM User属性已加载、expire_on_commit=False脱管访问安全
_SESSION_CACHE_TTL = 60.0
_session_cache: dict[str, tuple[float, datetime, User]] = {} # digest -> (mono 到期, 会话到期, user)
_SESSION_CACHE_MAX = 256
def drop_session_cache(digest: str | None = None, user_id: int | None = None) -> None:
"""登出/撤销时清缓存:按 token 或按用户logout-all"""
if digest is not None:
_session_cache.pop(digest, None)
return
if user_id is not None:
for k in [k for k, (_, _, u) in _session_cache.items() if u.id == user_id]:
_session_cache.pop(k)
async def require_user(
stock_session: str | None = Cookie(default=None, alias=settings.auth_cookie_name),
db: AsyncSession = Depends(get_session),
) -> User:
if not stock_session:
raise unauthorized()
d = token_digest(stock_session)
hit = _session_cache.get(d)
if hit is not None:
expires_mono, sess_expires, user = hit
if expires_mono > time.monotonic() and sess_expires > utcnow() and user.is_active:
return user
_session_cache.pop(d, None) # 过期/失效条目顺手清掉
auth_session = await get_auth_session(stock_session, db)
if auth_session is None:
raise unauthorized()
ttl = min(_SESSION_CACHE_TTL, max(1.0, (auth_session.expires_at - utcnow()).total_seconds()))
_session_cache[d] = (time.monotonic() + ttl, auth_session.expires_at, auth_session.user)
while len(_session_cache) > _SESSION_CACHE_MAX:
_session_cache.pop(next(iter(_session_cache)))
return auth_session.user

View File

@@ -8,6 +8,7 @@ from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from .auth import (
drop_session_cache,
get_auth_session,
new_session_token,
require_user,
@@ -131,6 +132,7 @@ async def logout(
.values(revoked_at=utcnow())
)
await db.commit()
drop_session_cache(digest=token_digest(stock_session)) # 同进程立即失效,登出即时生效
clear_session_cookie(response)
response.status_code = status.HTTP_204_NO_CONTENT
response.headers["Cache-Control"] = "no-store"
@@ -149,6 +151,7 @@ async def logout_all(
.values(revoked_at=utcnow())
)
await db.commit()
drop_session_cache(user_id=user.id) # 该用户全部 token 的本地缓存立即失效
clear_session_cookie(response)
response.status_code = status.HTTP_204_NO_CONTENT
response.headers["Cache-Control"] = "no-store"

View File

@@ -6,11 +6,16 @@
旧缓存 key 里带着旧版本号,无需 SCAN 批量删除。
- 只缓存「读多写少、可容忍短暂陈旧」的聚合数据(股票列表、筛选项等);
K线/回测等口径敏感数据不走这里。
- Redis 之前还有一层进程内本地缓存local_get/local_set0 RTT只存已序列化好的
JSON 字符串,命中后接口直接 Response(content=raw) 原样返回,跳过 json.loads +
pydantic 校验/序列化(大响应这两步合计可达数百 ms。本地 TTL 恒 ≤ Redis TTL
多进程部署时本地条目最多比 Redis 多陈旧 120s版本号 bump 在同进程立即生效。
"""
from __future__ import annotations
import hashlib
import json
import time
from typing import Any
import redis.asyncio as aioredis
@@ -48,6 +53,46 @@ def digest(*parts: Any) -> str:
return hashlib.md5(raw.encode()).hexdigest() # noqa: S324
# --- 进程内本地缓存层Redis 之前0 RTT-----------------------------------
# asyncio 单线程dict 读写无锁安全;按插入序淘汰近似 LRU命中不续期足够用
_LOCAL_MAX_BYTES = 64 * 1024 * 1024 # 总字节预算preview 响应 ~250KB留足 1y 大窗口)
_LOCAL_MAX_ENTRIES = 128
_local_store: dict[str, tuple[float, str]] = {} # key -> (expires_monotonic, raw_json)
_local_bytes = 0
def local_get(key: str) -> str | None:
"""命中返回已序列化的 JSON 字符串(调用方直接 Response 原样返回)。"""
global _local_bytes
ent = _local_store.get(key)
if ent is None:
return None
expires, raw = ent
if expires < time.monotonic():
_local_store.pop(key, None)
_local_bytes -= len(raw) + 64
return None
return raw
def local_set(key: str, raw: str, ttl: int) -> None:
"""ttl 为秒;调用方应传 min(本地默认, Redis TTL),保证本地不比 Redis 活得久。"""
global _local_bytes
old = _local_store.pop(key, None)
if old is not None:
_local_bytes -= len(old[1])
_local_store[key] = (time.monotonic() + max(1, min(ttl, 120)), raw)
_local_bytes += len(raw) + 64 # 连同 dict/tuple 开销粗略计入
while _local_store and (len(_local_store) > _LOCAL_MAX_ENTRIES or _local_bytes > _LOCAL_MAX_BYTES):
_local_bytes -= len(_local_store.popitem(last=False)[1][1]) + 64
# --- 版本号本地缓存:热请求连 Redis GET ver:xx 都省掉 ------------------------
# 同进程 bump_version 立即刷新本地;其他进程 bump 后本地最多陈旧 _LOCAL_VERSION_TTL。
_LOCAL_VERSION_TTL = 60.0
_local_versions: dict[str, tuple[int, float]] = {} # name -> (version, fetched_monotonic)
async def cache_get(key: str) -> Any | None:
c = _client()
if c is None:
@@ -72,13 +117,20 @@ async def cache_set(key: str, value: Any, ttl: int) -> None:
async def get_version(name: str) -> int:
"""读版本号(缺省 0。版本号参与缓存 keyINCR 后旧 key 全部失效。"""
"""读版本号(缺省 0。版本号参与缓存 keyINCR 后旧 key 全部失效。
先查本地60s热请求 0 RTT。"""
ent = _local_versions.get(name)
now = time.monotonic()
if ent is not None and ent[1] > now:
return ent[0]
c = _client()
if c is None:
return 0
try:
v = await c.get(f"ver:{name}")
return int(v) if v is not None else 0
val = int(v) if v is not None else 0
_local_versions[name] = (val, now + _LOCAL_VERSION_TTL)
return val
except Exception: # noqa: BLE001
_bail()
return 0
@@ -89,7 +141,9 @@ async def bump_version(name: str) -> None:
if c is None:
return
try:
await c.incr(f"ver:{name}")
v = await c.incr(f"ver:{name}")
# 同进程 bump 立即可见(夜同步跑在本进程时响应缓存零陈旧窗口)
_local_versions[name] = (int(v), time.monotonic() + _LOCAL_VERSION_TTL)
except Exception: # noqa: BLE001
_bail()

View File

@@ -60,10 +60,10 @@ def _d(v) -> str | None:
def _get_pro():
if not settings.tushare_token:
raise MarketOverviewError("未配置 TUSHARE_TOKEN无法获取大盘行情backend/.env")
import tushare as ts
# 走统一入口15000 积分档 token 只认 quicksync 镜像(直连 api.tushare.pro 会 40101
from .tushare_provider import get_pro
ts.set_token(settings.tushare_token)
return ts.pro_api()
return get_pro()
def _fetch_index_sync(pro, ts_code: str) -> pd.DataFrame:

View File

@@ -10,9 +10,12 @@ class Base(DeclarativeBase):
# echo=False远程 PG 的空闲连接可能被中间层断开pre_ping + recycle 自动剔除死连接
# pool_size/max_overflowpreview 冷路径每请求开 2 个会话2 波并发Redis 故障时全请求变冷,
# 默认 5+10 不够并发冷请求用(连接池满了只会排队,不会崩)
engine = create_async_engine(
settings.database_url, echo=False, future=True,
pool_pre_ping=True, pool_recycle=1800,
pool_size=10, max_overflow=20,
)
async_session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)

View File

@@ -64,3 +64,13 @@ def ema2(close: pd.Series, span: int = 10) -> pd.Series:
def avg_ma(close: pd.Series, periods: tuple[int, ...] = (10, 20, 30, 60)) -> pd.Series:
"""知行多空线:(MA(M1)+MA(M2)+MA(M3)+MA(M4))/4。"""
return sum(ma(close, p) for p in periods) / len(periods)
def zhixing_dkx(close: pd.Series, m1: int = 14, m2: int = 28, m3: int = 57, m4: int = 114) -> pd.Series:
"""知行多空线(通达信口径):(MA(M1)+MA(M2)+MA(M3)+MA(M4))/4M1~M4 缺省 14/28/57/114。
MA 为严格窗口(窗口不满为 NaN任一为空则多空线为空与前端 zhixing.ts /
通达信原公式一致(前 m4-1 根无值);区别于 avg_ma 的 min_periods=1 渐进口径。
"""
mas = [close.rolling(p, min_periods=p).mean() for p in (m1, m2, m3, m4)]
return sum(mas) / len(mas)

View File

@@ -9,7 +9,7 @@ Candle 表设计与 TimescaleDB hypertable 完全兼容:将来在目标 PG 库
"""
from datetime import date, datetime
from sqlalchemy import BigInteger, Boolean, Date, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy import BigInteger, Boolean, Date, DateTime, Float, ForeignKey, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .db import Base
@@ -136,11 +136,14 @@ class AdjFactor(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
trade_date: Mapped[datetime] = mapped_column(DateTime, index=True)
ts_code: Mapped[str] = mapped_column(String(12), index=True)
ts_code: Mapped[str] = mapped_column(String(12))
adj_factor: Mapped[float] = mapped_column(Float)
__table_args__ = (
UniqueConstraint("ts_code", "trade_date", name="uq_adj_code_date"),
# 覆盖索引:因子查询只取 (trade_date, adj_factor) 两列时走 Index Only Scan
# 免堆访问adj_factor 堆碎片化严重,见 alembic/versions/20260902_01
Index("ix_adj_code_date_cover", "ts_code", "trade_date", postgresql_include=["adj_factor"]),
)

View File

@@ -5,6 +5,8 @@ pandas 逐股计算(复用 app/indicators指标按 (族, 参数) 去重计
"""
from __future__ import annotations
import asyncio
import time
from dataclasses import dataclass
from datetime import datetime
from typing import Callable
@@ -59,6 +61,12 @@ def _boll(df: pd.DataFrame, p: dict) -> dict[str, pd.Series]:
return {"boll_upper": out["upper"], "boll_mid": out["mid"], "boll_lower": out["lower"]}
def _zhixing(df: pd.DataFrame, p: dict) -> dict[str, pd.Series]:
dk = ind.zhixing_dkx(df["close"], m1=int(p["m1"]), m2=int(p["m2"]),
m3=int(p["m3"]), m4=int(p["m4"]))
return {"zhixing_dkx": dk, "zhixing_trend": ind.ema2(df["close"])}
def _close(df: pd.DataFrame, p: dict) -> dict[str, pd.Series]:
return {"close": df["close"]}
@@ -82,6 +90,8 @@ FAMILIES: dict[str, FamilyDef] = {
"rsi": FamilyDef("RSI", ("period",), {"period": 14}, 25, _rsi),
"ma": FamilyDef("MA", ("period",), {"period": 20}, 25, _ma),
"boll": FamilyDef("BOLL", ("period", "std"), {"period": 20, "std": 2}, 25, _boll),
"zhixing": FamilyDef("知行", ("m1", "m2", "m3", "m4"),
{"m1": 14, "m2": 28, "m3": 57, "m4": 114}, 114, _zhixing),
"close": FamilyDef("收盘价", (), {}, 1, _close),
"pct_chg": FamilyDef("日涨跌幅", (), {}, 1, _pct_chg),
}
@@ -91,12 +101,18 @@ INDICATOR_FAMILY: dict[str, str] = {
"macd_dif": "macd", "macd_dea": "macd", "macd_hist": "macd",
"rsi": "rsi", "ma": "ma",
"boll_upper": "boll", "boll_mid": "boll", "boll_lower": "boll",
"zhixing_dkx": "zhixing", "zhixing_trend": "zhixing",
"close": "close", "pct_chg": "pct_chg",
}
_IND_SUFFIX = {"kdj_k": "K", "kdj_d": "D", "kdj_j": "J",
"macd_dif": "DIF", "macd_dea": "DEA", "macd_hist": "",
"boll_upper": "上轨", "boll_mid": "中轨", "boll_lower": "下轨"}
"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:
@@ -129,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)
@@ -342,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("筛选条件为空")
@@ -352,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:
@@ -360,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:
@@ -378,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)
@@ -385,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
@@ -405,13 +440,22 @@ async def run_screen(session: AsyncSession, conds: ScreenConditions, limit: int)
ind_values[c.value_indicator] = None if s is None or s.iloc[-1] != s.iloc[-1] else float(s.iloc[-1])
if ok:
items.append(_item_from_row(row) | {"indicators": ind_values})
yield _ev(type="stage", key="filter_done", msg=f"指标过滤完成:{len(items)} 只命中")
# 默认总市值降序(缺失排最后),截断 limit
items.sort(key=lambda x: (x["total_mv"] is None, -(x["total_mv"] or 0)))
return {
yield _ev(type="result", result={
"conditions": conds,
"trade_date": target_date,
"total": len(items),
"items": items[:limit],
"indicator_labels": labels,
}
})
async def run_screen(session: AsyncSession, conds: ScreenConditions, limit: int) -> dict:
"""兼容包装:跑完事件流,返回最终 result一次性拿全量无进度"""
async for ev in run_screen_events(session, conds, limit):
if ev["type"] == "result":
return ev["result"]
raise RuntimeError("run_screen_events 未产出 result") # pragma: no cover

View File

@@ -22,8 +22,8 @@ SYSTEM_PROMPT = """你是 A 股选股条件解析器。把用户的自然语言
indicator 与 snapshot 至少一个非空;用户没有提到的条件不要编造)
【indicator 数组】技术指标条件,元素字段:
- "indicator": 指标名白名单kdj_k / kdj_d / kdj_jKDJ 的 K/D/J 值、rsi、macd_dif / macd_dea / macd_histMACD 的 DIF/DEA/柱、ma收盘价均线、boll_upper / boll_mid / boll_lower布林轨道、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}
- "indicator": 指标名白名单kdj_k / kdj_d / kdj_jKDJ 的 K/D/J 值、rsi、macd_dif / macd_dea / macd_histMACD 的 DIF/DEA/柱、ma收盘价均线、boll_upper / boll_mid / boll_lower布林轨道zhixing_dkx知行多空线四条收盘价均线的均值、zhixing_trend知行短期趋势线close收盘价、pct_chg日涨跌幅%
- "params": 指标参数可选默认KDJ {"n":9,"m1":3,"m2":3}RSI {"period":14}MACD {"fast":12,"slow":26,"signal":9}MA {"period":20}BOLL {"period":20,"std":2};知行多空线 {"m1":14,"m2":28,"m3":57,"m4":114};知行趋势线无参数(固定算法),不要给它填 params
- "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
@@ -53,7 +53,15 @@ SYSTEM_PROMPT = """你是 A 股选股条件解析器。把用户的自然语言
示例3
输入:近 5 天曾经 MACD 金叉DIF 上穿 DEA换手率大于 5%,流通市值小于 100 亿
输出:{"indicator":[{"indicator":"macd_dif","params":{"fast":12,"slow":26,"signal":9},"op":"gt","value":0,"value_indicator":"macd_dea","lookback":5,"match":"any"}],"snapshot":[{"field":"turnover_rate","op":"gt","value":5},{"field":"circ_mv","op":"lt","value":100}],"exclude_st":true,"exclude_delisted":true,"exclude_bj":true}"""
输出:{"indicator":[{"indicator":"macd_dif","params":{"fast":12,"slow":26,"signal":9},"op":"gt","value":0,"value_indicator":"macd_dea","lookback":5,"match":"any"}],"snapshot":[{"field":"turnover_rate","op":"gt","value":5},{"field":"circ_mv","op":"lt","value":100}],"exclude_st":true,"exclude_delisted":true,"exclude_bj":true}
示例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}
示例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):
@@ -64,10 +72,14 @@ def _endpoint(base_url: str) -> str:
"""归一化 base_url -> 完整 chat/completions URL。
兼容多种写法DeepSeek/OpenAI 的 .../v1、智谱 GLM 的 .../v4、或直接给完整路径。
.../anthropicDeepSeek 的 Anthropic 协议端点)剥后缀走本模块的 OpenAI 兼容协议,
key 两端点通用。
"""
base = base_url.rstrip("/")
if base.endswith("/chat/completions"):
return base
if base.endswith("/anthropic"):
base = base[: -len("/anthropic")]
if not re.search(r"/v\d+$", base): # 未带版本段则补 /v1DeepSeek/OpenAI 惯例)
base += "/v1"
return f"{base}/chat/completions"
@@ -159,8 +171,8 @@ EVENT_SYSTEM_PROMPT = """你是 A 股事件回测参数解析器。用户描述
{"entry": {"indicator": [...], "snapshot": [], "exclude_st": true, "exclude_delisted": true, "exclude_bj": true}, "entry_timing": "next_open", "holding_days": 3, "exit_timing": "close"}
【entry.indicator 数组】入场信号条件(必填,至少 1 条),元素字段与白名单:
- "indicator": kdj_k / kdj_d / kdj_jKDJ 的 K/D/J 值、rsi、macd_dif / macd_dea / macd_histMACD 的 DIF/DEA/柱、ma收盘价均线、boll_upper / boll_mid / boll_lower布林轨道、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}
- "indicator": kdj_k / kdj_d / kdj_jKDJ 的 K/D/J 值、rsi、macd_dif / macd_dea / macd_histMACD 的 DIF/DEA/柱、ma收盘价均线、boll_upper / boll_mid / boll_lower布林轨道zhixing_dkx知行多空线、zhixing_trend知行短期趋势线close收盘价、pct_chg日涨跌幅%
- "params": 指标参数可选默认KDJ {"n":9,"m1":3,"m2":3}RSI {"period":14}MACD {"fast":12,"slow":26,"signal":9}MA {"period":20}BOLL {"period":20,"std":2};知行多空线 {"m1":14,"m2":28,"m3":57,"m4":114};知行趋势线无参数(固定算法),不要给它填 params
- "op": "gt" | "ge" | "lt" | "le" | "between""value"between 时为下界)、"value2"(上界)
- "value_indicator": 指标与指标比较时填另一指标名(同白名单),如 "DIF 大于 DEA" -> indicator=macd_dif, op=gt, value_indicator=macd_dea, value=0
- "value_params": 比较对象指标参数不同时指定,如 "MA5 上穿 MA20" -> indicator=ma, params={"period":5}, op=gt, value_indicator=ma, value_params={"period":20}, value=0

View File

@@ -245,18 +245,21 @@ async def _upsert_candle_day(session: AsyncSession, rows: list[dict], listed: se
]
if not batch:
return
stmt = pg_insert(Candle).values(batch)
stmt = stmt.on_conflict_do_update(
index_elements=["symbol", "timeframe", "ts"],
set_={
"open": stmt.excluded.open, "high": stmt.excluded.high,
"low": stmt.excluded.low, "close": stmt.excluded.close,
"volume": stmt.excluded.volume,
"amount": func.coalesce(Candle.amount, stmt.excluded.amount),
},
)
await session.execute(stmt)
await session.commit()
# on_conflict 语句整批渲染为占位符(非 executemanyasyncpg 单语句参数上限 32766
# 10 列 x 3000 行 = 30000 参数留出余量
for i in range(0, len(batch), 3000):
stmt = pg_insert(Candle).values(batch[i : i + 3000])
stmt = stmt.on_conflict_do_update(
index_elements=["symbol", "timeframe", "ts"],
set_={
"open": stmt.excluded.open, "high": stmt.excluded.high,
"low": stmt.excluded.low, "close": stmt.excluded.close,
"volume": stmt.excluded.volume,
"amount": func.coalesce(Candle.amount, stmt.excluded.amount),
},
)
await session.execute(stmt)
await session.commit()
async def _run_sync(days: int, force: bool) -> None:

View File

@@ -12,7 +12,7 @@ from datetime import datetime, timedelta, timezone
import httpx
from sqlalchemy import delete, select
from app.auth import hash_password, token_digest
from app.auth import drop_session_cache, hash_password, token_digest
from app.db import async_session, engine
from app.main import app
from app.models import AuthSession, Candle, User
@@ -87,6 +87,9 @@ async def main() -> None:
).scalar_one()
auth_session.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
await db.commit()
# 鉴权会话有 60s 进程内缓存auth.py会掩盖库里的过期态
# 清缓存模拟 TTL 已过,验证 require_user 对过期会话本身的判定
drop_session_cache(digest=token_digest(token))
assert (await client.get("/api/auth/me")).status_code == 401
print("expired session: 401")

View File

@@ -1,379 +1,103 @@
INFO: Will watch for changes in these directories: ['D:\\Project\\stock\\backend']
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO: Started reloader process [40700] using WatchFiles
INFO: Started server process [42044]
INFO: Started server process [42884]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: 127.0.0.1:50860 - "GET /api/health HTTP/1.1" 200 OK
INFO: 127.0.0.1:50992 - "GET /api/market/overview HTTP/1.1" 401 Unauthorized
INFO: 127.0.0.1:50994 - "GET /api/market/overview HTTP/1.1" 401 Unauthorized
WARNING: WatchFiles detected changes in 'app\data\market_overview.py'. Reloading...
INFO: 127.0.0.1:51975 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:52011 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:52012 - "GET /api/market/overview HTTP/1.1" 200 OK
INFO: 127.0.0.1:52240 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:52241 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:52250 - "GET /api/trades?ts_code=000001.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:52256 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:52255 - "GET /api/screener/preview/000001.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:52271 - "GET /api/screener/preview/000001.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:52809 - "GET /api/market/overview HTTP/1.1" 200 OK
INFO: 127.0.0.1:52880 - "GET /api/market/overview HTTP/1.1" 200 OK
INFO: 127.0.0.1:52890 - "GET /api/screener/sync/status HTTP/1.1" 200 OK
INFO: 127.0.0.1:52982 - "GET /api/screener/queries?limit=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:52992 - "DELETE /api/screener/queries/2 HTTP/1.1" 204 No Content
INFO: 127.0.0.1:52998 - "GET /api/screener/queries?limit=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53002 - "DELETE /api/screener/queries/1 HTTP/1.1" 204 No Content
INFO: 127.0.0.1:53005 - "GET /api/screener/queries?limit=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53199 - "GET /api/market/overview HTTP/1.1" 200 OK
INFO: 127.0.0.1:53217 - "GET /api/market/overview HTTP/1.1" 200 OK
INFO: 127.0.0.1:53203 - "GET /api/screener/sync/status HTTP/1.1" 200 OK
INFO: 127.0.0.1:55376 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:55374 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:55381 - "GET /api/trades?ts_code=000001.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:55387 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:55386 - "GET /api/screener/preview/000001.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:55393 - "GET /api/screener/preview/000001.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:55407 - "GET /api/screener/preview/000001.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:55429 - "GET /api/stocks?search=%E5%8D%AB%E6%98%9F&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:55432 - "GET /api/stocks?search=%E5%8D%AB%E6%98%9F%E5%8C%96%E5%AD%A6&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:55443 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:55441 - "GET /api/trades?ts_code=002648.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:55442 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:55456 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:55462 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56504 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56519 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56542 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56581 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56599 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56609 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56625 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60121 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:60143 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:60135 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:60142 - "GET /api/stocks?search=%E5%8D%AB%E6%98%9F%E5%8C%96%E5%AD%A6&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60152 - "GET /api/trades?ts_code=002648.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:60154 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:60153 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:60175 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60183 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60412 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:60410 - "GET /api/trades?ts_code=002648.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:60411 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:60421 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60418 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62422 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62418 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62469 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62430 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62463 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62489 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62486 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62503 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62519 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62527 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62493 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62545 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62552 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62562 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62572 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62589 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62581 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62604 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62619 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62633 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62642 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62513 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62595 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62614 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53139 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:53151 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:53157 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:53156 - "GET /api/stocks?search=%E5%8D%AB%E6%98%9F%E5%8C%96%E5%AD%A6&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53165 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:53163 - "GET /api/trades?ts_code=002648.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:53164 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:53171 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53806 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:53819 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:53820 - "GET /api/stocks?search=%E5%8D%AB%E6%98%9F%E5%8C%96%E5%AD%A6&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53821 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:53823 - "GET /api/trades?ts_code=002648.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:53826 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:53824 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:53830 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53846 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:53853 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:53860 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:53859 - "GET /api/stocks?search=%E5%8D%AB%E6%98%9F%E5%8C%96%E5%AD%A6&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53862 - "GET /api/trades?ts_code=002648.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:53863 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:53865 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:53869 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53890 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:53892 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:53901 - "GET /api/stocks?search=%E5%8D%AB%E6%98%9F%E5%8C%96%E5%AD%A6&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:53903 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:53905 - "GET /api/trades?ts_code=002648.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:53908 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:53911 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:53912 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:54001 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:54083 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:54087 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:54090 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:54096 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:54104 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:54099 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:54109 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:54123 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:54130 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:54141 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:54151 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:54163 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:54219 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:54227 - "PUT /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:54228 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:54209 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:54252 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:54250 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:54248 - "GET /api/trades?ts_code=002648.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:54261 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:54353 - "GET /api/trades?ts_code=002648.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:54370 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:54364 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:54384 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:54682 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:54747 - "GET /api/stock/000001.SZ/chips?adjust=qfq HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:54990 - "GET /api/stock/000001.SZ/chips?adjust=qfq HTTP/1.1" 404 Not Found
INFO: Will watch for changes in these directories: ['D:\\Project\\stock\\backend']
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO: Started reloader process [48632] using WatchFiles
INFO: Started server process [46460]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: 127.0.0.1:55384 - "GET /api/health HTTP/1.1" 200 OK
INFO: 127.0.0.1:55388 - "GET /api/stock/000001.SZ/chips?adjust=qfq HTTP/1.1" 401 Unauthorized
INFO: 127.0.0.1:55749 - "GET /api/stock/000001.SZ/chips?adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:55760 - "GET /api/stock/000001.SZ/chips?adjust=bfq&date=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56017 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:56029 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:56030 - "GET /api/stocks?search=%E5%8D%AB%E6%98%9F%E5%8C%96%E5%AD%A6&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56031 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:56040 - "GET /api/trades?ts_code=002648.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:56044 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:56041 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:56051 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56054 - "GET /api/stock/002648.SZ/chips?date=2026-08-14&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56062 - "GET /api/stock/002648.SZ/chips?date=2026-08-14&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56065 - "GET /api/stock/002648.SZ/chips?date=2026-04-29&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56071 - "GET /api/stock/002648.SZ/chips?date=2026-08-14&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56075 - "GET /api/stock/002648.SZ/chips?date=2026-03-11&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56081 - "GET /api/stock/002648.SZ/chips?date=2026-08-14&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56084 - "GET /api/stock/002648.SZ/chips?date=2026-05-08&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56089 - "GET /api/stock/002648.SZ/chips?date=2026-08-14&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56096 - "GET /api/stock/002648.SZ/chips?date=2026-03-10&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56100 - "GET /api/stock/002648.SZ/chips?date=2025-12-25&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56111 - "GET /api/stock/002648.SZ/chips?date=2025-10-24&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56116 - "GET /api/stock/002648.SZ/chips?date=2025-09-05&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56121 - "GET /api/stock/002648.SZ/chips?date=2025-06-26&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56106 - "GET /api/stock/002648.SZ/chips?date=2025-12-24&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56129 - "GET /api/stock/002648.SZ/chips?date=2025-09-16&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56133 - "GET /api/stock/002648.SZ/chips?date=2025-06-12&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56139 - "GET /api/stock/002648.SZ/chips?date=2025-03-28&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56145 - "GET /api/stock/002648.SZ/chips?date=2024-12-30&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56149 - "GET /api/stock/002648.SZ/chips?date=2024-11-13&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56156 - "GET /api/stock/002648.SZ/chips?date=2024-09-04&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56160 - "GET /api/stock/002648.SZ/chips?date=2024-10-22&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56167 - "GET /api/stock/002648.SZ/chips?date=2025-04-29&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56171 - "GET /api/stock/002648.SZ/chips?date=2025-07-09&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56177 - "GET /api/stock/002648.SZ/chips?date=2025-09-24&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56196 - "GET /api/stock/002648.SZ/chips?date=2025-12-23&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56200 - "GET /api/stock/002648.SZ/chips?date=2026-04-01&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56207 - "GET /api/stock/002648.SZ/chips?date=2026-06-22&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56210 - "GET /api/stock/002648.SZ/chips?date=2026-08-12&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56218 - "GET /api/stock/002648.SZ/chips?date=2026-06-17&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56265 - "GET /api/stock/002648.SZ/chips?date=2026-03-17&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56384 - "GET /api/stock/002648.SZ/chips?date=2026-05-22&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56458 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56501 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56520 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56545 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56564 - "GET /api/trades?ts_code=002648.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:56568 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:56571 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:56577 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56581 - "GET /api/trades?ts_code=002648.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:56584 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:56587 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:56589 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56690 - "GET /api/stock/002648.SZ/chips?date=2026-08-14&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56693 - "GET /api/stock/002648.SZ/chips?date=2026-08-14&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56699 - "GET /api/stock/002648.SZ/chips?date=2026-08-14&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56715 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:56727 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:56731 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:56730 - "GET /api/stocks?search=%E5%8D%AB%E6%98%9F%E5%8C%96%E5%AD%A6&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56733 - "GET /api/trades?ts_code=002648.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:56734 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:56736 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:56739 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56747 - "GET /api/stock/002648.SZ/chips?date=2026-08-14&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56749 - "GET /api/stock/002648.SZ/chips?date=2026-08-14&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56754 - "GET /api/stock/002648.SZ/chips?date=2026-08-14&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56756 - "GET /api/stock/002648.SZ/chips?date=2026-06-11&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56772 - "GET /api/stock/002648.SZ/chips?date=2026-08-14&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56781 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:56793 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:56795 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:56794 - "GET /api/stocks?search=%E5%8D%AB%E6%98%9F%E5%8C%96%E5%AD%A6&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56805 - "GET /api/trades?ts_code=002648.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:56806 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:56809 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:56811 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56818 - "GET /api/stock/002648.SZ/chips?date=2026-08-14&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56828 - "GET /api/stock/002648.SZ/chips?date=2026-08-13&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56835 - "GET /api/stock/002648.SZ/chips?date=2026-03-30&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56863 - "GET /api/stock/002648.SZ/chips?date=2026-07-23&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56882 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56887 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:56874 - "GET /api/market/overview HTTP/1.1" 200 OK
INFO: 127.0.0.1:56900 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:56898 - "GET /api/trades?ts_code=000006.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:56899 - "GET /api/screener/preview/000006.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:56904 - "GET /api/screener/preview/000006.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56907 - "GET /api/stock/000006.SZ/chips?date=2026-08-14&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:56916 - "GET /api/screener/preview/000006.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56923 - "GET /api/screener/preview/000006.SZ?limit=800&adjust=qfq&timeframe=1d&end=2021-04-07 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56929 - "GET /api/screener/preview/000006.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56931 - "GET /api/screener/preview/000006.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56940 - "GET /api/screener/preview/000006.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56943 - "GET /api/screener/preview/000006.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56947 - "GET /api/trades?ts_code=000001.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:56951 - "GET /api/screener/preview/000001.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:56958 - "GET /api/screener/preview/000001.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56970 - "GET /api/screener/preview/000001.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56979 - "GET /api/screener/preview/000001.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56975 - "GET /api/screener/preview/000001.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56985 - "GET /api/screener/preview/000001.SZ?limit=800&adjust=qfq&timeframe=1d&end=2021-04-07 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56990 - "GET /api/screener/preview/000001.SZ?limit=800&adjust=qfq&timeframe=1d&end=2017-12-19 HTTP/1.1" 200 OK
INFO: 127.0.0.1:56998 - "GET /api/trades?ts_code=000006.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:57000 - "GET /api/screener/preview/000006.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:57004 - "GET /api/screener/preview/000006.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:57012 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:57017 - "GET /api/stocks?search=%E5%8C%96%E5%AD%A6%E7%BB%99&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:57019 - "GET /api/stocks?search=%E5%8C%96%E5%AD%A6&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:57024 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:57026 - "GET /api/stocks?search=%E5%8D%AB%E6%98%9F&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:57037 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:57034 - "GET /api/trades?ts_code=002648.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:57035 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:57039 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:57048 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:57061 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:57058 - "GET /api/trades?ts_code=600118.SH HTTP/1.1" 200 OK
INFO: 127.0.0.1:57059 - "GET /api/screener/preview/600118.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:57066 - "GET /api/screener/preview/600118.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:57099 - "GET /api/screener/preview/600118.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:57103 - "GET /api/screener/preview/600118.SH?limit=800&adjust=qfq&timeframe=1d&end=2021-04-07 HTTP/1.1" 200 OK
INFO: 127.0.0.1:57131 - "GET /api/screener/preview/600118.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:57134 - "GET /api/screener/preview/600118.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:57139 - "GET /api/screener/preview/600118.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:57143 - "GET /api/screener/preview/600118.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:57136 - "GET /api/screener/preview/600118.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:57146 - "GET /api/screener/preview/600118.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:57151 - "GET /api/screener/preview/600118.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:57153 - "GET /api/screener/preview/600118.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:57917 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:57927 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:57931 - "GET /api/stocks?search=%E5%8D%AB%E6%98%9F&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:57932 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:57937 - "GET /api/trades?ts_code=600118.SH HTTP/1.1" 200 OK
INFO: 127.0.0.1:57939 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:57938 - "GET /api/screener/preview/600118.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:57944 - "GET /api/screener/preview/600118.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:58070 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:58075 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:58086 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:58085 - "GET /api/stocks?search=%E5%8D%AB%E6%98%9F&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:58088 - "GET /api/trades?ts_code=600118.SH HTTP/1.1" 200 OK
INFO: 127.0.0.1:58091 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:58089 - "GET /api/screener/preview/600118.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:58096 - "GET /api/screener/preview/600118.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:58098 - "GET /api/stock/600118.SH/chips?date=2026-08-14&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58110 - "GET /api/stock/600118.SH/chips?date=2026-03-25&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58114 - "GET /api/stock/600118.SH/chips?date=2026-08-14&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58122 - "GET /api/stock/600118.SH/chips?date=2026-04-28&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58134 - "GET /api/stock/600118.SH/chips?date=2026-06-17&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58127 - "GET /api/stock/600118.SH/chips?date=2026-05-07&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58139 - "GET /api/stock/600118.SH/chips?date=2026-06-30&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58145 - "GET /api/stock/600118.SH/chips?date=2026-08-03&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58150 - "GET /api/stock/600118.SH/chips?date=2026-07-16&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58155 - "GET /api/stock/600118.SH/chips?date=2026-08-13&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58166 - "GET /api/stock/600118.SH/chips?date=2026-07-28&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58161 - "GET /api/stock/600118.SH/chips?date=2026-08-14&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58173 - "GET /api/stock/600118.SH/chips?date=2026-06-12&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58178 - "GET /api/stock/600118.SH/chips?date=2026-05-29&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58186 - "GET /api/stock/600118.SH/chips?date=2026-08-14&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58195 - "GET /api/stock/600118.SH/chips?date=2026-04-28&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58202 - "GET /api/stock/600118.SH/chips?date=2026-06-02&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58207 - "GET /api/screener/preview/600118.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:58210 - "GET /api/screener/preview/600118.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:58213 - "GET /api/screener/preview/600118.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:58226 - "GET /api/stock/600118.SH/chips?date=2026-06-17&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58241 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:58247 - "GET /api/stock/002648.SZ/chips?date=2026-08-14&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58222 - "GET /api/screener/preview/600118.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:58249 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:58237 - "GET /api/stock/600118.SH/chips?date=2025-08-07&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58240 - "GET /api/trades?ts_code=002648.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:58258 - "GET /api/stock/002648.SZ/chips?date=2026-04-27&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58219 - "GET /api/stock/600118.SH/chips?date=2026-06-15&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58270 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:58273 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:58277 - "GET /api/stock/002648.SZ/chips?date=2026-06-16&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58282 - "GET /api/stock/002648.SZ/chips?date=2026-03-02&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58288 - "GET /api/stock/002648.SZ/chips?date=2026-04-14&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58293 - "GET /api/stock/002648.SZ/chips?date=2026-06-01&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58298 - "GET /api/stock/002648.SZ/chips?date=2026-06-16&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58305 - "GET /api/stock/002648.SZ/chips?date=2026-08-14&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58309 - "GET /api/stock/002648.SZ/chips?date=2026-06-09&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58315 - "GET /api/stock/002648.SZ/chips?date=2026-06-05&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58323 - "GET /api/stock/002648.SZ/chips?date=2026-04-03&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58326 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:58329 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:58333 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:58341 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:58346 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:58350 - "GET /api/stock/002648.SZ/chips?date=2026-08-14&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58356 - "GET /api/stock/002648.SZ/chips?date=2026-06-24&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58364 - "GET /api/stock/002648.SZ/chips?date=2026-04-30&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58368 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:58371 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:58374 - "GET /api/stock/002648.SZ/chips?date=2026-03-06&adjust=qfq HTTP/1.1" 200 OK
INFO: 127.0.0.1:58392 - "GET /api/auth/me HTTP/1.1" 200 OK
INFO: 127.0.0.1:58400 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:58410 - "GET /api/stocks/facets HTTP/1.1" 200 OK
INFO: 127.0.0.1:58409 - "GET /api/stocks?search=%E5%8D%AB%E6%98%9F&sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
INFO: 127.0.0.1:58414 - "GET /api/trades?ts_code=002648.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:58417 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:58415 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:58422 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:58424 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:58431 - "GET /api/trades?ts_code=600118.SH HTTP/1.1" 200 OK
INFO: 127.0.0.1:58432 - "GET /api/screener/preview/600118.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:58435 - "GET /api/screener/preview/600118.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:58441 - "GET /api/screener/preview/600118.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:58444 - "GET /api/screener/preview/600118.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:58447 - "GET /api/screener/preview/600118.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:58450 - "GET /api/screener/preview/600118.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:58698 - "GET /api/screener/preview/600118.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:58707 - "GET /api/screener/preview/600118.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:58709 - "GET /api/screener/preview/600118.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:58766 - "GET /api/trades?ts_code=002648.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:58767 - "GET /api/screener/preview/002648.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:58777 - "GET /api/trades?ts_code=600118.SH HTTP/1.1" 200 OK
INFO: 127.0.0.1:58769 - "GET /api/screener/preview/002648.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:58778 - "GET /api/screener/preview/600118.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:58782 - "GET /api/screener/preview/600118.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
INFO: 127.0.0.1:60648 - "GET /api/auth/me HTTP/1.1" 401 Unauthorized
INFO: 127.0.0.1:62525 - "POST /api/auth/login HTTP/1.1" 200 OK
INFO: 127.0.0.1:62531 - "GET /api/preferences HTTP/1.1" 200 OK
INFO: 127.0.0.1:62573 - "GET /api/screener/queries?limit=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62746 - "POST /api/screener/run HTTP/1.1" 200 OK
INFO: 127.0.0.1:62747 - "GET /api/screener/queries?limit=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:64917 - "GET /api/watchlist HTTP/1.1" 200 OK
INFO: 127.0.0.1:64915 - "GET /api/trades?ts_code=601398.SH HTTP/1.1" 200 OK
INFO: 127.0.0.1:64916 - "GET /api/screener/preview/601398.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:64933 - "GET /api/screener/preview/601398.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:64942 - "GET /api/screener/preview/601398.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:64947 - "GET /api/trades?ts_code=601939.SH HTTP/1.1" 200 OK
INFO: 127.0.0.1:64949 - "GET /api/screener/preview/601939.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:64958 - "GET /api/trades?ts_code=601288.SH HTTP/1.1" 200 OK
INFO: 127.0.0.1:64954 - "GET /api/screener/preview/601939.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:64964 - "GET /api/screener/preview/601288.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:64965 - "GET /api/screener/preview/601288.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:64970 - "GET /api/trades?ts_code=600941.SH HTTP/1.1" 200 OK
INFO: 127.0.0.1:64971 - "GET /api/screener/preview/600941.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:64982 - "GET /api/trades?ts_code=601988.SH HTTP/1.1" 200 OK
INFO: 127.0.0.1:64978 - "GET /api/screener/preview/600941.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:64983 - "GET /api/screener/preview/601988.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:64987 - "GET /api/screener/preview/601988.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:64992 - "GET /api/trades?ts_code=601857.SH HTTP/1.1" 200 OK
INFO: 127.0.0.1:64993 - "GET /api/screener/preview/601857.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:65001 - "GET /api/trades?ts_code=600938.SH HTTP/1.1" 200 OK
INFO: 127.0.0.1:64997 - "GET /api/screener/preview/601857.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:65004 - "GET /api/screener/preview/600938.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:65008 - "GET /api/screener/preview/600938.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:65014 - "GET /api/trades?ts_code=601628.SH HTTP/1.1" 200 OK
INFO: 127.0.0.1:65016 - "GET /api/screener/preview/601628.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:65023 - "GET /api/screener/preview/601628.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:65025 - "GET /api/trades?ts_code=601088.SH HTTP/1.1" 200 OK
INFO: 127.0.0.1:65028 - "GET /api/screener/preview/601088.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:65029 - "GET /api/screener/preview/601088.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-07-29 HTTP/1.1" 200 OK
INFO: 127.0.0.1:65037 - "GET /api/trades?ts_code=600036.SH HTTP/1.1" 200 OK
INFO: 127.0.0.1:65038 - "GET /api/screener/preview/600036.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:65040 - "GET /api/screener/preview/600036.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:65046 - "GET /api/trades?ts_code=601318.SH HTTP/1.1" 200 OK
INFO: 127.0.0.1:65047 - "GET /api/screener/preview/601318.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:65057 - "GET /api/screener/preview/601318.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:65060 - "GET /api/trades?ts_code=600900.SH HTTP/1.1" 200 OK
INFO: 127.0.0.1:65063 - "GET /api/screener/preview/600900.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:65064 - "GET /api/screener/preview/600900.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:65075 - "GET /api/trades?ts_code=688256.SH HTTP/1.1" 200 OK
INFO: 127.0.0.1:65076 - "GET /api/screener/preview/688256.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:65078 - "GET /api/screener/preview/688256.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:65085 - "GET /api/trades?ts_code=601658.SH HTTP/1.1" 200 OK
INFO: 127.0.0.1:65086 - "GET /api/screener/preview/601658.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:65090 - "GET /api/screener/preview/601658.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:65100 - "GET /api/trades?ts_code=000333.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:65101 - "GET /api/screener/preview/000333.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:65104 - "GET /api/screener/preview/000333.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:65108 - "GET /api/trades?ts_code=600028.SH HTTP/1.1" 200 OK
INFO: 127.0.0.1:65110 - "GET /api/screener/preview/600028.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:65114 - "GET /api/screener/preview/600028.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:65123 - "GET /api/trades?ts_code=601328.SH HTTP/1.1" 200 OK
INFO: 127.0.0.1:65125 - "GET /api/screener/preview/601328.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:65140 - "GET /api/screener/preview/601328.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:65144 - "GET /api/trades?ts_code=601728.SH HTTP/1.1" 200 OK
INFO: 127.0.0.1:65148 - "GET /api/screener/preview/601728.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:65149 - "GET /api/screener/preview/601728.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:65154 - "GET /api/trades?ts_code=601998.SH HTTP/1.1" 200 OK
INFO: 127.0.0.1:65155 - "GET /api/screener/preview/601998.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:65160 - "GET /api/screener/preview/601998.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:65164 - "GET /api/trades?ts_code=002475.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:65168 - "GET /api/screener/preview/002475.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:65169 - "GET /api/screener/preview/002475.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:65176 - "GET /api/trades?ts_code=600183.SH HTTP/1.1" 200 OK
INFO: 127.0.0.1:65177 - "GET /api/screener/preview/600183.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:65180 - "GET /api/screener/preview/600183.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:65187 - "GET /api/trades?ts_code=601319.SH HTTP/1.1" 200 OK
INFO: 127.0.0.1:65188 - "GET /api/screener/preview/601319.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:65190 - "GET /api/screener/preview/601319.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:65196 - "GET /api/trades?ts_code=601869.SH HTTP/1.1" 200 OK
INFO: 127.0.0.1:65197 - "GET /api/screener/preview/601869.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:65201 - "GET /api/screener/preview/601869.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:65207 - "GET /api/trades?ts_code=601601.SH HTTP/1.1" 200 OK
INFO: 127.0.0.1:65208 - "GET /api/screener/preview/601601.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:65212 - "GET /api/screener/preview/601601.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:65217 - "GET /api/trades?ts_code=600000.SH HTTP/1.1" 200 OK
INFO: 127.0.0.1:65220 - "GET /api/screener/preview/600000.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:65221 - "GET /api/screener/preview/600000.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:65226 - "GET /api/trades?ts_code=601225.SH HTTP/1.1" 200 OK
INFO: 127.0.0.1:65227 - "GET /api/screener/preview/601225.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:65232 - "GET /api/screener/preview/601225.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:65237 - "GET /api/trades?ts_code=688808.SH HTTP/1.1" 200 OK
INFO: 127.0.0.1:65239 - "GET /api/screener/preview/688808.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:65246 - "GET /api/trades?ts_code=002714.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:65247 - "GET /api/screener/preview/002714.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:65249 - "GET /api/screener/preview/002714.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:65256 - "GET /api/screener/preview/688808.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:65255 - "GET /api/trades?ts_code=688808.SH HTTP/1.1" 200 OK
INFO: 127.0.0.1:65278 - "GET /api/screener/preview/002714.SZ?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:65277 - "GET /api/trades?ts_code=002714.SZ HTTP/1.1" 200 OK
INFO: 127.0.0.1:65282 - "GET /api/screener/preview/002714.SZ?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:65285 - "GET /api/trades?ts_code=600309.SH HTTP/1.1" 200 OK
INFO: 127.0.0.1:65288 - "GET /api/screener/preview/600309.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
INFO: 127.0.0.1:65292 - "GET /api/screener/preview/600309.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-08-12 HTTP/1.1" 200 OK

View File

@@ -12,6 +12,7 @@ import type {
PreviewResponse,
ScreenerQueryItem,
ScreenerRunRequest,
ScreenerStreamEvent,
ScreenerRunResponse,
ScreenerSyncRequest,
ScreenerSyncStatus,
@@ -120,10 +121,38 @@ export async function postEventBacktest(req: EventBacktestRequest): Promise<Even
}
}
export async function runScreener(req: ScreenerRunRequest): Promise<ScreenerRunResponse> {
const res = await apiFetch('/api/screener/run', { method: 'POST', body: JSON.stringify(req) });
/** 选股流式执行:逐行回调 NDJSON 事件(阶段/进度/解析条件),最终返回完整结果。
* signal 中止后(含读流中途)以 AbortError 拒绝,由调用方决定如何呈现。 */
export async function runScreener(
req: ScreenerRunRequest,
onEvent?: (ev: ScreenerStreamEvent) => void,
signal?: AbortSignal,
): Promise<ScreenerRunResponse> {
const res = await apiFetch('/api/screener/run', { method: 'POST', body: JSON.stringify(req), signal });
if (!res.ok) throw new ApiError(await readError(res, `选股失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as ScreenerRunResponse;
if (!res.body) throw new ApiError('当前环境不支持流式响应', 0);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
let result: ScreenerRunResponse | null = null;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let nl: number;
while ((nl = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line) continue;
const ev = JSON.parse(line) as ScreenerStreamEvent;
if (ev.type === 'error') throw new ApiError(ev.message, ev.code ?? 0);
if (ev.type === 'result') result = ev.result;
onEvent?.(ev);
}
}
if (!result) throw new ApiError('选股响应中断(未收到结果事件)', 0);
return result;
}
export async function startScreenerSync(req: ScreenerSyncRequest = {}): Promise<ScreenerSyncStatus> {

View File

@@ -148,6 +148,15 @@ export interface ScreenerRunResponse {
indicator_labels: Record<string, string>;
}
// ---------- 选股流式事件(镜像 api.py /screener/run 的 NDJSON 约定) ----------
export type ScreenerStreamEvent =
| { type: 'stage'; key: string; msg: string; ms?: number }
| { type: 'parsed'; conditions: ScreenConditions; ms?: number }
| { type: 'candidates'; count: number; msg?: string; ms?: number }
| { type: 'progress'; done: number; total: number }
| { type: 'result'; result: ScreenerRunResponse; ms?: number }
| { type: 'error'; message: string; code?: number };
export interface ScreenerSyncRequest {
days?: number;
force?: boolean;

View File

@@ -11,7 +11,7 @@ import {
threeWaves, triangle, xabcd,
} from '@klinecharts/extension';
import { useSettingsStore, TOOLTIP_FIELDS, DEFAULT_TOOLTIP_FIELDS } from '@/stores/settings';
import { registerCustomIndicators, computeZhixingSeries, DKX_PERIODS, ZHIXING_NAME, CHIPS_NAME, type ChipsExt } from '@/indicators';
import { registerCustomIndicators, computeZhixingSeries, DKX_PERIODS, ZHIXING_NAME, CHIPS_NAME, CHIPS_COL_PX, type ChipsExt } from '@/indicators';
import type { Candle, TooltipField } from '@/api/types';
// 通达信导入的自定义主图指标注册表;知行序列走 PV 管道按时间戳取预计算值。
@@ -671,11 +671,62 @@ function syncChipsIndicator() {
if (want) {
if (exists) chart.removeIndicator({ name: CHIPS_NAME });
chart.createIndicator({ name: CHIPS_NAME, paneId: 'candle_pane', extendData: props.chips ?? undefined });
syncRightOffset(); // 开关即扩留白(不等数据),数据到达时列直接落在已预留的空白里
} else if (exists) {
chart.removeIndicator({ name: CHIPS_NAME });
restoreRightOffset();
} else {
restoreRightOffset(); // 数据未到即关:同样要还原已扩出去的留白
}
}
// ---------- 筹码峰竖列的右侧留白管理 ----------
// klinecharts 滚动状态唯一变量是 diff最后一根K距绘图区右缘的 bar 数),
// setOffsetRightDistance(px) 会把 diff 直接重置为 px/barSpace 并 relayout——等价于
// “视口拽回实时位并留 px 空白”。因此只在「右区」末根K在视野内getVisibleRange().to
// 达到 data 长度)时调用;用户在看历史时绝不调,滚回右区后由 onScroll 防抖补一次吸附
// (同花顺松手吸附同款)。绝不调 setMaxOffsetRightDistance会把滚动限制切到
// 'distance' 模式,默认钳到 50px 且改变滚动边缘行为。
const BASE_RIGHT_PX = 28;
let syncingRightOffset = false; // 重入守卫set 会同步派发 onVisibleRangeChange
let scrollReanchorTimer: ReturnType<typeof setTimeout> | null = null;
let managedRightPx = 0; // 筹码开启期间我们 set 过的目标值(关筹码时判断“是否还归我们管”)
/** 目标右侧留白:开筹码 = 列宽 + 一根K线 + 余量bar 最宽 50px → 峰值 ~182px */
function targetRightPx(): number {
if (!props.showChips) return BASE_RIGHT_PX;
const bar = chart?.getBarSpace().bar || 10;
return Math.round(CHIPS_COL_PX + bar + 12);
}
/** 开筹码期间保证留白盖住竖列:只扩不缩(用户自己拖出的更大留白不动) */
function syncRightOffset(): void {
if (!chart || syncingRightOffset || !props.showChips) return;
const list = chart.getDataList();
if (!list.length || chart.getVisibleRange().to < list.length) return; // 右区守卫
const target = targetRightPx();
if (chart.getOffsetRightDistance() >= target - 2) return; // 阈值防循环 + 只扩不缩
syncingRightOffset = true;
try {
chart.setOffsetRightDistance(target);
managedRightPx = target;
} finally {
syncingRightOffset = false;
}
}
/** 关筹码时一次性还原 28px仅当留白仍在我们管理范围内用户没手动拖大过 */
function restoreRightOffset(): void {
if (!chart || managedRightPx === 0) return;
const list = chart.getDataList();
const cur = chart.getOffsetRightDistance();
if (list.length && chart.getVisibleRange().to >= list.length
&& cur > BASE_RIGHT_PX + 2 && cur <= managedRightPx + 2) {
chart.setOffsetRightDistance(BASE_RIGHT_PX);
}
managedRightPx = 0;
}
function build() {
if (!container.value || props.candles.length === 0) return;
UP = settings.upHex;
@@ -801,7 +852,23 @@ function build() {
const from = (payload as { data?: { from?: unknown } }).data?.from;
if (typeof from === 'number' && from < 200) maybePrefetch(myEpoch);
});
ch.setOffsetRightDistance(28);
// 右侧留白开筹码时按竖列宽度预留scrollToRealTime 以它为锚点,先后顺序不能换)
ch.setOffsetRightDistance(props.showChips ? targetRightPx() : BASE_RIGHT_PX);
// 筹码竖列的留白跟随:缩放后 barSpace 变化onZoom 触发时新值已生效)立即补扩;
// 拖拽期间 onScroll 逐帧触发、且拖拽每帧从手势起点快照重算 diff逐帧同步会与拖拽
// “互搏”(从实时位拖向历史会被逐帧拽回),故防抖 150ms 只在手势结束后补一次
ch.subscribeAction('onZoom', () => {
if (myEpoch !== epoch) return;
syncRightOffset();
});
ch.subscribeAction('onScroll', () => {
if (myEpoch !== epoch) return;
if (scrollReanchorTimer) clearTimeout(scrollReanchorTimer);
scrollReanchorTimer = setTimeout(() => {
scrollReanchorTimer = null;
if (myEpoch === epoch) syncRightOffset();
}, 150);
});
ch.scrollToRealTime();
// 日期跳转build 尾部的 scrollToRealTime 会把视口重置到最新一根,居中必须放在它之后
//init 数据在 setPeriod 时已同步落入图表,这里可直接定位)。
@@ -814,6 +881,8 @@ function build() {
function teardown() {
if (subHTimer) { clearTimeout(subHTimer); subHTimer = null; }
if (scrollReanchorTimer) { clearTimeout(scrollReanchorTimer); scrollReanchorTimer = null; }
managedRightPx = 0;
if (container.value) dispose(container.value);
chart = null;
hover.value = null;

View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted } from 'vue';
import { useMarketSyncStore } from '@/stores/marketSync';
const store = useMarketSyncStore();
onMounted(async () => {
await store.fetchStatus();
store.pollIfRunning();
});
onBeforeUnmount(() => store.stopPolling());
/** "2026-09-01T00:00:00" / "2026-09-01" -> "2026年09月01日";无数据显示 — */
function fmtDate(s?: string | null): string {
if (!s) return '—';
const d = s.slice(0, 10);
const [y, m, day] = d.split('-');
if (!y || !m || !day) return d;
return `${y}${m}${day}`;
}
const latestDate = computed(() => store.syncStatus?.last_trade_date ?? null);
const running = computed(() => !!store.syncStatus?.running);
const syncing = computed(() => !!store.syncStatus?.total_days);
// 后端任务内错误(如 daily_basic 权限受限)与请求级错误都展示
const errText = computed(() => store.error || store.syncStatus?.error || null);
const progressPct = computed(() => {
const s = store.syncStatus;
if (!s?.total_days) return 0;
return Math.min(100, ((s.done_days ?? 0) / s.total_days) * 100);
});
</script>
<template>
<div class="mb-12 flex flex-wrap items-center gap-x-5 gap-y-3 rounded-xl border border-[#26272E] bg-[#101014] px-5 py-4">
<!-- 最新更新日期 -->
<div class="flex items-center gap-3">
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-blue-500/15 text-blue-300">
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" />
</svg>
</span>
<div>
<div class="text-xs text-[#6B7280]">A股日线数据更新至</div>
<div class="text-base font-semibold tabular-nums text-[#E5E7EB]">{{ fmtDate(latestDate) }}</div>
</div>
</div>
<span class="hidden flex-1 sm:block"></span>
<!-- 同步中进度条 -->
<div v-if="running" class="flex min-w-[220px] flex-1 items-center gap-2">
<svg 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 class="whitespace-nowrap text-[13px] text-[#A8AFB8]">
{{ store.syncStatus?.step || '同步中…' }}<template v-if="syncing">{{ store.syncStatus?.done_days }}/{{ store.syncStatus?.total_days }}</template>
</span>
<span v-if="syncing" class="h-1.5 flex-1 overflow-hidden rounded-full bg-[#26272E]">
<span class="block h-full rounded-full bg-blue-500 transition-all" :style="{ width: progressPct + '%' }" />
</span>
</div>
<!-- 空闲手动同步按钮 -->
<button v-else type="button" class="btn-primary shrink-0" @click="store.startSync()">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-2.6-6.4M21 3v6h-6" /></svg>
同步A股当日数据
</button>
<!-- 错误提示 -->
<div v-if="errText" class="w-full text-sm text-amber-400">
<svg class="mr-1 inline h-4 w-4 align-[-3px]" 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>
{{ errText }}
</div>
</div>
</template>

View File

@@ -1,12 +1,13 @@
<script setup lang="ts">
import { ref } from 'vue';
import { deleteScreenerQuery, getScreenerQueries } from '@/api/client';
import type { ScreenConditions, ScreenerQueryItem } from '@/api/types';
import type { ScreenerQueryItem } from '@/api/types';
const props = defineProps<{ loading: boolean }>();
const emit = defineEmits<{
(e: 'run', text: string, conditions?: ScreenConditions | null): void;
(e: 'run', text: string): void;
(e: 'ran'): void;
(e: 'stop'): void;
}>();
const text = ref('');
@@ -18,13 +19,14 @@ const examples = [
];
function run() {
if (props.loading) return; // 执行中 Ctrl+Enter 不重复触发(要停止点「停止」)
if (text.value.trim()) {
emit('run', text.value.trim());
emit('ran');
}
}
// ---------- 提问历史(入库,可一键重跑 / 删除) ----------
// ---------- 提问历史(点击只填入输入框,不触发查询 / 删除) ----------
const history = ref<ScreenerQueryItem[]>([]);
const historyOpen = ref(false);
@@ -38,12 +40,9 @@ async function refreshHistory() {
} catch { history.value = []; }
}
function rerun(q: ScreenerQueryItem) {
function fillQuery(q: ScreenerQueryItem) {
text.value = q.text;
historyOpen.value = false;
// 存过 conditions 的记录直传条件,跳过 LLM 重新解析
emit('run', q.text, q.conditions ?? null);
emit('ran');
}
async function removeQuery(id: number) {
@@ -87,8 +86,8 @@ defineExpose({ refreshHistory });
<button
type="button"
class="min-w-0 flex-1 text-left"
:title="q.conditions ? '点击直传条件重跑(不重新解析)' : '点击填入并重跑'"
@click="rerun(q)"
title="点击填入输入框(不自动执行)"
@click="fillQuery(q)"
>
<span class="block truncate text-sm text-[#E8EAED]">{{ q.text }}</span>
<span class="mt-0.5 block text-xs text-[#9BA3AE]">
@@ -134,10 +133,19 @@ defineExpose({ refreshHistory });
支持 KDJ / RSI / MACD / 布林 / 均线指标条件市值 / 市盈率 / 换手率等快照条件以及连续 N N 天任一天时间窗口
<kbd class="rounded border border-[#26272E] bg-black px-1">Ctrl</kbd>+<kbd class="rounded border border-[#26272E] bg-black px-1">Enter</kbd> 快速筛选
</p>
<button type="button" class="btn-primary shrink-0 disabled:opacity-50" :disabled="loading || !text.trim()" @click="run">
<svg v-if="loading" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
<svg v-else class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7" /><path d="M21 21l-4.3-4.3" /></svg>
{{ loading ? '筛选中…' : '开始筛选' }}
<!-- 执行中变为停止中止 LLM 解析与逐股过滤服务端随连接断开自动取消 -->
<button
v-if="loading"
type="button"
class="flex shrink-0 items-center gap-1.5 rounded-lg border border-red-500/50 bg-red-500/15 px-4 py-2 text-sm font-medium text-red-300 transition-colors hover:bg-red-500/25"
@click="emit('stop')"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="6" y="6" width="12" height="12" rx="1.5" /></svg>
停止
</button>
<button v-else type="button" class="btn-primary shrink-0 disabled:opacity-50" :disabled="!text.trim()" @click="run">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7" /><path d="M21 21l-4.3-4.3" /></svg>
开始筛选
</button>
</div>
</div>

View File

@@ -1,64 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { ScreenerSyncStatus } from '@/api/types';
const props = defineProps<{ status: ScreenerSyncStatus | null }>();
const emit = defineEmits<{ (e: 'sync'): void }>();
function fmtDate(s?: string | null) {
if (!s) return '—';
return s.slice(0, 10);
}
// 数据是否可筛(有日线)。快照缺失则纯指标仍可用,给黄色提示。
const freshness = computed(() => {
const s = props.status;
if (!s) return { tone: 'none', text: '正在检查数据状态…' };
if (!s.ready) return { tone: 'warn', text: '全市场数据尚未同步,请先点击右侧「同步市场数据」' };
const snapMissing = s.stats.snapshot_rows === 0;
const d = s.stats.dates;
const base = `数据截至 ${fmtDate(s.last_trade_date)} · 共 ${d} 个交易日 · ${s.stats.stocks} 只股票`;
if (snapMissing) return { tone: 'warn', text: `${base}(每日指标暂不可用:市值/市盈率条件不可用,纯指标条件不受影响)` };
return { tone: 'ok', text: base };
});
</script>
<template>
<div class="mt-4 flex flex-wrap items-center gap-x-4 gap-y-2 rounded-xl border border-[#26272E] bg-[#101014] px-4 py-3 text-sm text-[#A8AFB8]">
<span :class="freshness.tone === 'ok' ? 'text-emerald-400' : freshness.tone === 'warn' ? 'text-amber-400' : 'text-[#9BA3AE]'">
<svg class="mr-1 inline h-4 w-4 align-[-3px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<template v-if="freshness.tone === 'ok'">
<path d="M22 11.1V12a10 10 0 11-5.9-9.1" />
<path d="M22 4L12 14l-3-3-7 7" />
</template>
<template v-else>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4M12 8h.01" />
</template>
</svg>
{{ freshness.text }}
</span>
<template v-if="status && status.running">
<span class="flex min-w-[200px] flex-1 items-center gap-2">
<svg 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 class="whitespace-nowrap text-[13px] text-[#A8AFB8]">{{ status.step || '同步中…' }}{{ status.done_days }}/{{ status.total_days }}</span>
<span class="h-1.5 flex-1 overflow-hidden rounded-full bg-[#26272E]">
<span class="block h-full rounded-full bg-blue-500 transition-all" :style="{ width: (status.total_days ? Math.min(100, (status.done_days / status.total_days) * 100) : 0) + '%' }" />
</span>
</span>
</template>
<template v-else>
<span class="flex-1"></span>
<button type="button" class="btn-ghost shrink-0 disabled:opacity-50" :disabled="status?.running" @click="emit('sync')">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-2.6-6.4M21 3v6h-6" /></svg>
同步市场数据
</button>
</template>
<div v-if="status && status.error" class="w-full text-amber-400">
<svg class="mr-1 inline h-4 w-4 align-[-3px]" 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>
{{ status.error }}
</div>
</div>
</template>

View File

@@ -1,15 +1,20 @@
// 个股筹码峰Tushare cyq_chips / cyq_perf同花顺式右侧横向线状直方图
// 个股筹码峰Tushare cyq_chips / cyq_perf同花顺火焰山式右侧专用竖列
//
// 数据不在图表窗口上计算,而是一次性截面(某交易日各价位筹码占比 + 获利比例/平均成本),
// 经 extendData 注入、纯 draw 绘制:
// - 每个价位一根横线,从主图右缘向左延伸,宽度 ∝ 占比/最大占比(火焰山形态
// - 分界价 = 选中K线的收盘价refClose下方为获利盘红线、上方为套牢盘蓝线
// - 平均成本画黄色虚线;右上角小字块:截面日期 / 获利比例 / 平均成本 / 90% 成本区间
// - 主图绘图区右缘、价格轴左侧一条【不透明底色专用竖列】K线不透出同花顺同款
// - 每个价位一根横条,在列内从右缘向左伸展,宽度 ∝ 占比/最大占比(火焰山形态
// - 分界价 = 选中K线的收盘价refClose下方为获利盘、上方为套牢盘
// - 平均成本画黄色虚线、末根收盘画现价细实线;列内顶部小字:截面日/获利比例/平均成本/90%成本
// DetailKLine 依据 CHIPS_COL_PX 在开启筹码时加大右侧留白保证最新K线不被竖列盖住。
// figures 为空、calc 返回空行:指标不产生序列值,也不影响主图价格刻度(筹码是覆盖层)。
import type { IndicatorTemplate } from 'klinecharts';
export const CHIPS_NAME = 'pv-chips';
/** 火焰山竖列基准宽度pxDetailKLine 据此预留右侧留白draw 据此画列 */
export const CHIPS_COL_PX = 120;
/** 经 extendData 注入的筹码截面(父组件按 当前窗口末根/十字线 所在周期取数) */
export interface ChipsExt {
rows: { price: number; percent: number }[]; // percent 为 0~1 占比
@@ -29,16 +34,34 @@ const AVG_COLOR = '#F5C518';
const LABEL_COLOR = '#9AA0AA';
const VALUE_COLOR = '#E8EAED';
/** 同花顺筹码峰同款横线最大占主图宽度的比例与上限px */
const MAX_WIDTH_RATIO = 0.22;
const MAX_WIDTH_PX = 150;
/** 横条高度上限px极度放大时单价位占比条不至于铺满半个主图 */
// 竖列几何:窄图下限 56px最多占绘图区宽度 35%(实际宽度取两者与基准的钳制值)
const COL_MIN_PX = 56;
const COL_MAX_RATIO = 0.35;
/** 列内左右内边距(横条右缘、统计文字与列缘的呼吸) */
const COL_PAD = 5;
/** 不透明底色(图表容器纯黑 bg-black取应用面板色左缘 1px 分隔线与 y 轴线同色 */
const COL_BG = '#101014';
const COL_BORDER = '#2A2D34';
/** 实心条透明度:不透明列内 0.85 足够“实心”,与底色留一丝层次 */
const BAR_ALPHA = 0.85;
/** 横条高度上限px极度放大时单价位占比条不至于铺满大半个竖列 */
const BAR_HEIGHT_CAP = 24;
/** 统计文字起始 y画线工具栏是 HTML 层,占顶部 ~36px文字块从 44px 起)/ 行高 */
const HEADER_TOP = 44;
const LINE_H = 13;
function fmtDate(d?: string): string {
return d && d.length === 8 ? `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}` : '—';
}
/** 右对齐数值超宽时截断加省略号90%成本 “10.50~14.20” 这类长串的兜底) */
function fitText(ctx: CanvasRenderingContext2D, text: string, maxW: number): string {
if (ctx.measureText(text).width <= maxW) return text;
let s = text;
while (s.length > 1 && ctx.measureText(`${s}`).width > maxW) s = s.slice(0, -1);
return `${s}`;
}
export const chipsIndicator: IndicatorTemplate<Record<string, never>, unknown, ChipsExt> = {
name: CHIPS_NAME,
shortName: '筹码',
@@ -49,70 +72,60 @@ export const chipsIndicator: IndicatorTemplate<Record<string, never>, unknown, C
const ext = indicator.extendData;
if (!ext) return true;
// 右上角统计小字块(画线工具栏是 HTML 层,占顶部 ~36px文字块从 44px 起
const lines: { label: string; value: string; color: string }[] = [];
if (ext.error) {
lines.push({ label: '筹码', value: ext.error, color: LABEL_COLOR });
} else if (ext.rows.length) {
const w = ext.winner;
lines.push({ label: `筹码 ${fmtDate(ext.date)}`, value: '', color: LABEL_COLOR });
lines.push({
label: '获利比例',
value: w == null ? '—' : `${w.toFixed(2)}%`,
color: w == null ? VALUE_COLOR : w >= 50 ? UP : DOWN,
});
lines.push({ label: '平均成本', value: ext.avg == null ? '—' : ext.avg.toFixed(2), color: AVG_COLOR });
lines.push({
label: '90%成本',
value: ext.costLow == null || ext.costHigh == null ? '—' : `${ext.costLow.toFixed(2)}~${ext.costHigh.toFixed(2)}`,
color: VALUE_COLOR,
});
}
if (lines.length) {
ctx.font = '11px sans-serif';
ctx.textBaseline = 'top';
ctx.textAlign = 'right';
let y = bounding.top + 44;
for (const ln of lines) {
const text = ln.value ? `${ln.label} ${ln.value}` : ln.label;
ctx.fillStyle = ln.color;
ctx.fillText(text, bounding.right - 4, y);
y += 14;
}
}
if (!ext.rows.length) return true;
// 竖列几何bounding.right 即 y 轴左缘,列贴着价格轴(预留逻辑见 DetailKLine 的 syncRightOffset
const colW = Math.max(COL_MIN_PX, Math.min(CHIPS_COL_PX, Math.floor(bounding.width * COL_MAX_RATIO)));
const colRight = bounding.right;
const colLeft = colRight - colW;
// 无数据/出错:不画底色列(空面板不遮图),错误文案画在原右上角位置
if (!ext.rows.length) {
if (ext.error) {
ctx.font = '11px sans-serif';
ctx.textBaseline = 'top';
ctx.textAlign = 'right';
ctx.fillStyle = LABEL_COLOR;
ctx.fillText(`筹码 ${ext.error}`, bounding.right - 4, bounding.top + HEADER_TOP);
}
return true;
}
// 1. 不透明底色列 + 左缘 1px 分隔线(先画,盖住网格/K线/MA/最新价虚线的右端)
ctx.fillStyle = COL_BG;
ctx.fillRect(colLeft, bounding.top, colW, bounding.height);
ctx.fillStyle = COL_BORDER;
ctx.fillRect(colLeft, bounding.top, 1, bounding.height);
const list = chart.getDataList();
// 分界价选中K线的收盘价父组件随截面注入缺省退回图表末根收盘
// <=分界价为获利盘(红)、>分界价为套牢盘(蓝)——同花顺同款
const list = chart.getDataList();
const close = ext.refClose ?? (list.length ? list[list.length - 1].close : undefined);
const split = ext.refClose ?? (list.length ? list[list.length - 1].close : undefined);
// 2. 筹码横条:列右缘向左伸展,宽度 ∝ 占比/最大占比
// 价位升序,便于求相邻像素间距(直方图的“条高”)
const rows = [...ext.rows].sort((a, b) => a.price - b.price);
const maxP = rows.reduce((m, r) => Math.max(m, r.percent), 0);
if (maxP <= 0) return true;
const maxW = Math.min(bounding.width * MAX_WIDTH_RATIO, MAX_WIDTH_PX);
// 相邻价位的中位像素间距 → 条高(连续火焰形态;缩到极小时并成实心轮廓)
const pitches: number[] = [];
for (let i = 1; i < rows.length; i++) {
const p = Math.abs(yAxis.convertToPixel(rows[i].price) - yAxis.convertToPixel(rows[i - 1].price));
if (p > 0) pitches.push(p);
}
pitches.sort((a, b) => a - b);
const pitch = pitches.length ? pitches[pitches.length >> 1] : 4;
const barH = Math.min(BAR_HEIGHT_CAP, Math.max(1, pitch * 0.92));
for (const r of rows) {
const y = yAxis.convertToPixel(r.price);
if (y < bounding.top - barH || y > bounding.bottom + barH) continue; // 视口外跳过
const w = Math.max(1, (r.percent / maxP) * maxW);
ctx.fillStyle = close == null ? 'rgba(154,160,170,0.5)'
: r.price <= close ? 'rgba(254,53,75,0.55)' : 'rgba(47,123,255,0.55)';
ctx.fillRect(bounding.right - w, y - barH / 2, w, barH);
if (maxP > 0) {
// 相邻价位的中位像素间距 → 条高(连续火焰形态;缩到极小时并成实心轮廓)
const pitches: number[] = [];
for (let i = 1; i < rows.length; i++) {
const p = Math.abs(yAxis.convertToPixel(rows[i].price) - yAxis.convertToPixel(rows[i - 1].price));
if (p > 0) pitches.push(p);
}
pitches.sort((a, b) => a - b);
const pitch = pitches.length ? pitches[pitches.length >> 1] : 4;
const barH = Math.min(BAR_HEIGHT_CAP, Math.max(1, pitch * 0.92));
const maxBarW = colW - COL_PAD * 2;
for (const r of rows) {
const y = yAxis.convertToPixel(r.price);
if (y < bounding.top - barH || y > bounding.bottom + barH) continue; // 视口外跳过
const w = Math.max(1, (r.percent / maxP) * maxBarW);
ctx.fillStyle = split == null ? 'rgba(154,160,170,0.85)'
: r.price <= split ? `rgba(254,53,75,${BAR_ALPHA})` : `rgba(47,123,255,${BAR_ALPHA})`;
ctx.fillRect(colRight - COL_PAD - w, y - barH / 2, w, barH);
}
}
// 平均成本虚线(横贯直方图区
// 3. 平均成本虚线(横贯竖列
if (ext.avg != null) {
const y = yAxis.convertToPixel(ext.avg);
if (y >= bounding.top && y <= bounding.bottom) {
@@ -121,12 +134,55 @@ export const chipsIndicator: IndicatorTemplate<Record<string, never>, unknown, C
ctx.lineWidth = 1;
ctx.setLineDash([4, 3]);
ctx.beginPath();
ctx.moveTo(bounding.right - maxW, y + 0.5);
ctx.lineTo(bounding.right, y + 0.5);
ctx.moveTo(colLeft + 1, Math.round(y) + 0.5);
ctx.lineTo(colRight, Math.round(y) + 0.5);
ctx.stroke();
ctx.restore();
}
}
// 4. 现价细实线(末根收盘,红/蓝随涨跌,与 y 轴最新价标签同源同色;
// 分界价本身已是横条红蓝交界,不再重复画线)
if (list.length >= 2) {
const last = list[list.length - 1];
const y = yAxis.convertToPixel(last.close);
if (y >= bounding.top && y <= bounding.bottom) {
ctx.save();
ctx.strokeStyle = last.close >= list[list.length - 2].close ? UP : DOWN;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(colLeft + 1, Math.round(y) + 0.5);
ctx.lineTo(colRight, Math.round(y) + 0.5);
ctx.stroke();
ctx.restore();
}
}
// 5. 统计小字块列内顶部label 左对齐 + value 右对齐,超宽截断
//列宽有限两列布局比原先的单行右对齐更能放下「90%成本 10.50~14.20」这类长串)
const w = ext.winner;
const lines: [string, string, string][] = [
['筹码', fmtDate(ext.date), VALUE_COLOR],
['获利比例', w == null ? '—' : `${w.toFixed(2)}%`, w == null ? VALUE_COLOR : w >= 50 ? UP : DOWN],
['平均成本', ext.avg == null ? '—' : ext.avg.toFixed(2), AVG_COLOR],
['90%成本', ext.costLow == null || ext.costHigh == null ? '—'
: `${ext.costLow.toFixed(2)}~${ext.costHigh.toFixed(2)}`, VALUE_COLOR],
];
ctx.font = '10px sans-serif';
ctx.textBaseline = 'top';
const labelX = colLeft + COL_PAD;
const valueX = colRight - COL_PAD;
const valueMaxW = colW - COL_PAD * 2 - 36; // 36 ≈ 左侧标签区预留宽
let ty = bounding.top + HEADER_TOP;
for (const [label, value, color] of lines) {
ctx.textAlign = 'left';
ctx.fillStyle = LABEL_COLOR;
ctx.fillText(label, labelX, ty);
ctx.textAlign = 'right';
ctx.fillStyle = color;
ctx.fillText(fitText(ctx, value, valueMaxW), valueX, ty);
ty += LINE_H;
}
return true;
},
};

View File

@@ -19,5 +19,5 @@ export function registerCustomIndicators(get: ZhixingGetter) {
registerIndicator(chipsIndicator);
}
export { CHIPS_NAME, type ChipsExt } from './chips';
export { CHIPS_NAME, CHIPS_COL_PX, type ChipsExt } from './chips';
export { ZHIXING_NAME, ZHIXING_TREND_COLOR, DKX_PERIODS, computeZhixingSeries } from './zhixing';

View File

@@ -0,0 +1,53 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { getScreenerSyncStatus, startScreenerSync } from '@/api/client';
import type { ScreenerSyncStatus } from '@/api/types';
/** 全市场 A 股日线同步(首页手动触发)。状态轮询与选股 store 解耦,独立维护。 */
export const useMarketSyncStore = defineStore('marketSync', () => {
const syncStatus = ref<ScreenerSyncStatus | null>(null);
const error = ref<string | null>(null); // 启动同步的请求级错误
let pollTimer: ReturnType<typeof setInterval> | null = null;
async function fetchStatus() {
try {
syncStatus.value = await getScreenerSyncStatus();
} catch {
/* 静默:状态拉取失败不阻塞页面 */
}
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
/** 正在同步时开始 2s 轮询;空闲则停轮询(进入页面时也调用,承接后台遗留任务)。 */
function pollIfRunning() {
if (syncStatus.value?.running) {
if (!pollTimer) {
pollTimer = setInterval(async () => {
await fetchStatus();
if (!syncStatus.value?.running) stopPolling();
}, 2000);
}
} else {
stopPolling();
}
}
async function startSync(days = 90) {
error.value = null;
try {
await startScreenerSync({ days });
await fetchStatus();
pollIfRunning();
} catch (e) {
error.value = e instanceof Error ? e.message : '启动同步失败';
}
}
return { syncStatus, error, fetchStatus, startSync, stopPolling, pollIfRunning };
});

View File

@@ -1,70 +1,81 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { getScreenerSyncStatus, runScreener, startScreenerSync } from '@/api/client';
import type { ScreenConditions, ScreenerRunResponse, ScreenerSyncStatus } from '@/api/types';
import { runScreener } from '@/api/client';
import type { ScreenConditions, ScreenerRunResponse } from '@/api/types';
/** 进度面板的一行痕迹:阶段消息(含耗时),或进度条态 */
export interface TraceLine {
key: string; // 阶段 keyllm/date/prefilter/bars/filter_done/done
msg: string;
ms?: number;
}
export const useScreenerStore = defineStore('screener', () => {
const loading = ref(false);
const stage = ref<'idle' | 'parsing' | 'screening'>('idle');
const note = ref<string | null>(null);
const error = ref<string | null>(null);
const result = ref<ScreenerRunResponse | null>(null);
const syncStatus = ref<ScreenerSyncStatus | null>(null);
let pollTimer: ReturnType<typeof setInterval> | null = null;
const aborted = ref(false); // 本次执行被手动停止(区别于失败)
// 流式进度状态run 期间有效;结束后保留到下一次 run 便于复盘)
const trace = ref<TraceLine[]>([]);
const progress = ref<{ done: number; total: number } | null>(null);
const parsed = ref<ScreenConditions | null>(null);
let ctrl: AbortController | null = null;
function pushLine(line: TraceLine) {
// 同 key 阶段只留最后一行(如重试的 llm
const i = trace.value.findIndex((l) => l.key === line.key);
if (i >= 0) trace.value[i] = line;
else trace.value.push(line);
}
/** 中断正在执行的筛选LLM 解析或逐股过滤阶段均可)。 */
function abort() {
ctrl?.abort();
}
async function run(text: string, conditions?: ScreenConditions | null) {
loading.value = true;
error.value = null;
aborted.value = false;
result.value = null;
note.value = conditions ? '全市场筛选中…' : 'AI 解析条件中…';
stage.value = conditions ? 'screening' : 'parsing';
trace.value = [];
progress.value = null;
parsed.value = conditions ?? null;
if (conditions) pushLine({ key: 'direct', msg: '直传条件,跳过 AI 解析' });
ctrl = new AbortController();
try {
// 条件解析与全市场筛选在后端一气呵成;切到筛选阶段给个过渡提示
setTimeout(() => {
if (loading.value && stage.value === 'parsing') {
stage.value = 'screening';
note.value = '全市场筛选中…';
result.value = await runScreener({ text, conditions: conditions ?? undefined }, (ev) => {
switch (ev.type) {
case 'stage':
pushLine({ key: ev.key, msg: ev.msg, ms: ev.ms });
break;
case 'parsed':
parsed.value = ev.conditions;
break;
case 'candidates':
pushLine({ key: 'candidates', msg: ev.msg ?? `预筛完成:${ev.count} 只候选`, ms: ev.ms });
break;
case 'progress':
progress.value = { done: ev.done, total: ev.total };
break;
default:
break; // result 由 runScreener 返回值落入 resulterror 走异常
}
}, 1200);
result.value = await runScreener({ text, conditions: conditions ?? undefined });
}, ctrl.signal);
} catch (e) {
error.value = e instanceof Error ? e.message : '选股失败';
if (e instanceof DOMException && e.name === 'AbortError') {
aborted.value = true; // 主动停止,不算失败
} else {
error.value = e instanceof Error ? e.message : '选股失败';
}
} finally {
loading.value = false;
stage.value = 'idle';
note.value = null;
progress.value = null;
ctrl = null;
}
}
async function fetchStatus() {
try {
syncStatus.value = await getScreenerSyncStatus();
} catch {
/* 静默:状态拉取失败不阻塞页面 */
}
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
async function startSync(days = 90) {
error.value = null;
try {
await startScreenerSync({ days });
await fetchStatus();
stopPolling();
pollTimer = setInterval(async () => {
await fetchStatus();
if (syncStatus.value && !syncStatus.value.running) stopPolling();
}, 2000);
} catch (e) {
error.value = e instanceof Error ? e.message : '启动同步失败';
}
}
return { loading, stage, note, error, result, syncStatus, run, fetchStatus, startSync, stopPolling };
return { loading, error, result, aborted, trace, progress, parsed, run, abort };
});

View File

@@ -2,6 +2,7 @@
import { RouterLink } from 'vue-router';
import MarketOverview from '@/components/MarketOverview.vue';
import MarketSyncBar from '@/components/MarketSyncBar.vue';
// 首页:大盘行情总览 + 三大功能入口(看股 / 选股 / 回测)
const features = [
@@ -33,6 +34,8 @@ const features = [
<div class="w-full max-w-5xl">
<MarketOverview />
<MarketSyncBar />
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<RouterLink
v-for="f in features"

View File

@@ -1,9 +1,8 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { computed, ref } from 'vue';
import { useScreenerStore } from '@/stores/screener';
import ScreenerForm from '@/components/ScreenerForm.vue';
import ConditionChips from '@/components/ConditionChips.vue';
import SyncStatusBar from '@/components/SyncStatusBar.vue';
import ScreenerTable from '@/components/ScreenerTable.vue';
import StockDetailOverlay from '@/components/StockDetailOverlay.vue';
@@ -11,28 +10,58 @@ const store = useScreenerStore();
const previewCode = ref<string | null>(null);
const formRef = ref<InstanceType<typeof ScreenerForm> | null>(null);
onMounted(() => {
// 仅查状态;同步由用户点击「同步市场数据」显式触发(避免反复触发接口限频)
store.fetchStatus();
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));
});
onBeforeUnmount(() => store.stopPolling());
function fmtMs(ms?: number): string {
return ms == null ? '' : `${(ms / 1000).toFixed(1)}s`;
}
</script>
<template>
<div>
<ScreenerForm ref="formRef" :loading="store.loading" @run="store.run" @ran="formRef?.refreshHistory()" />
<ScreenerForm ref="formRef" :loading="store.loading" @run="store.run" @ran="formRef?.refreshHistory()" @stop="store.abort()" />
<SyncStatusBar :status="store.syncStatus" @sync="store.startSync(90)" />
<div v-if="store.aborted && !store.loading" class="mt-4 flex items-center gap-2 rounded-xl border border-[#26272E] bg-[#101014] px-4 py-3 text-sm text-[#9BA3AE]">
<svg class="h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="6" y="6" width="12" height="12" rx="1.5" /></svg>
已停止本次筛选可修改问题后重新开始
</div>
<div v-if="store.error" class="mt-4 flex items-start gap-2 rounded-xl border border-red-500/40 bg-red-500/15 px-4 py-3 text-sm text-red-300">
<svg class="mt-0.5 h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 3.9L1.8 18a2 2 0 001.7 3h17a2 2 0 001.7-3L13.7 3.9a2 2 0 00-3.4 0z" /><path d="M12 9v4M12 17h.01" /></svg>
{{ store.error }}
</div>
<div v-if="store.loading && store.note" class="py-16 text-center text-sm text-[#9BA3AE]">
<svg class="mx-auto mb-3 h-6 w-6 animate-spin text-blue-500" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
{{ store.note }}
<!-- 执行进度面板加载中实时滚动失败后保留痕迹便于定位卡在哪一步 -->
<div
v-if="store.loading || (store.error && store.trace.length)"
class="mt-4 rounded-xl border border-[#26272E] bg-[#101014] px-4 py-4"
>
<div class="flex items-center gap-2 text-sm text-[#E8EAED]">
<svg v-if="store.loading" class="h-4 w-4 shrink-0 animate-spin text-blue-500" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
<span v-else class="h-2 w-2 shrink-0 rounded-full bg-red-500"></span>
{{ store.loading ? (store.trace[store.trace.length - 1]?.msg ?? '处理中…') : '执行中断' }}
</div>
<div v-if="store.progress" class="mt-3">
<div class="h-1.5 overflow-hidden rounded bg-[#1E2026]">
<div class="h-full rounded bg-blue-600 transition-all duration-150" :style="{ width: pct + '%' }"></div>
</div>
<div class="mt-1 text-xs text-[#9BA3AE]">逐股指标过滤 {{ store.progress.done }} / {{ store.progress.total }}{{ pct }}%</div>
</div>
<div v-if="store.parsed && (store.parsed.indicator.length || store.parsed.snapshot.length)" class="mt-3">
<ConditionChips :conditions="store.parsed" />
</div>
<div class="mt-3 space-y-0.5 font-mono text-xs leading-relaxed text-[#9BA3AE]">
<div v-for="(l, i) in store.trace" :key="i">
<span v-if="l.ms != null" class="mr-2 inline-block w-12 text-right text-[#5F6672]">{{ fmtMs(l.ms) }}</span>{{ l.msg }}
</div>
</div>
</div>
<template v-else-if="store.result">

View File

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