Compare commits
4 Commits
6ef8c585d6
...
76b422320b
| Author | SHA1 | Date | |
|---|---|---|---|
| 76b422320b | |||
| 2735ff1fd9 | |||
| 2f9c8bee2b | |||
| bec4e8149c |
@@ -9,8 +9,8 @@ DATA_DEFAULT_START=20200101
|
||||
# ---- Redis 读缓存(股票列表/筛选项;留空则不缓存直查数据库)----
|
||||
REDIS_URL=redis://default:26d5c71d57344f37b8b4ddb567f2652f0c7ef41c774284ad@cirry.cn:6379
|
||||
|
||||
# ---- LLM(智能选股;智谱 GLM,OpenAI 兼容协议)----
|
||||
# ---- LLM(智能选股;DeepSeek,OpenAI 兼容协议;/anthropic 后缀会被 _endpoint 自动归一)----
|
||||
# key 在 https://bigmodel.cn 控制台获取,格式形如 xxxxxxxx.yyyyyyyy(id.secret)
|
||||
LLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4
|
||||
LLM_API_KEY=ea24bbdd3d2d4dd2b8f03de4c9a5d984.9X1Hz1yKx0VKSnrU
|
||||
LLM_MODEL=glm-5.2
|
||||
LLM_BASE_URL=https://api.deepseek.com
|
||||
LLM_API_KEY=sk-b09acbd6c0ca4f94818b6deb039d6515
|
||||
LLM_MODEL=deepseek-chat
|
||||
|
||||
49
backend/alembic/versions/20260902_01_adj_factor_cover.py
Normal file
49
backend/alembic/versions/20260902_01_adj_factor_cover.py
Normal 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,
|
||||
)
|
||||
@@ -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-core(Rust)序列化:与 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 后仍是 str),Redis 命中顺手晋级本地。"""
|
||||
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 -> None(lightweight-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_mode(bfq/qfq/hfq)。
|
||||
@@ -121,7 +174,7 @@ def _adjust_bars(bars: list[Bar], factors, from_mode: str, to_mode: str) -> list
|
||||
相对不复权的乘数:bfq=1,qfq=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,底座口径恒为 bfq(TDX 全量 + 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 1:candles 窗口(注入 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)+ 信息卡(注入 session,LATERAL 一条)并行 ---
|
||||
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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -6,11 +6,16 @@
|
||||
旧缓存 key 里带着旧版本号,无需 SCAN 批量删除。
|
||||
- 只缓存「读多写少、可容忍短暂陈旧」的聚合数据(股票列表、筛选项等);
|
||||
K线/回测等口径敏感数据不走这里。
|
||||
- Redis 之前还有一层进程内本地缓存(local_get/local_set,0 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)。版本号参与缓存 key:INCR 后旧 key 全部失效。"""
|
||||
"""读版本号(缺省 0)。版本号参与缓存 key:INCR 后旧 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()
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -10,9 +10,12 @@ class Base(DeclarativeBase):
|
||||
|
||||
|
||||
# echo=False;远程 PG 的空闲连接可能被中间层断开,pre_ping + recycle 自动剔除死连接
|
||||
# pool_size/max_overflow:preview 冷路径每请求开 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)
|
||||
|
||||
|
||||
@@ -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))/4,M1~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)
|
||||
|
||||
@@ -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"]),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,8 +22,8 @@ SYSTEM_PROMPT = """你是 A 股选股条件解析器。把用户的自然语言
|
||||
(indicator 与 snapshot 至少一个非空;用户没有提到的条件不要编造)
|
||||
|
||||
【indicator 数组】技术指标条件,元素字段:
|
||||
- "indicator": 指标名,白名单:kdj_k / kdj_d / kdj_j(KDJ 的 K/D/J 值)、rsi、macd_dif / macd_dea / macd_hist(MACD 的 DIF/DEA/柱)、ma(收盘价均线)、boll_upper / boll_mid / boll_lower(布林轨道)、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_j(KDJ 的 K/D/J 值)、rsi、macd_dif / macd_dea / macd_hist(MACD 的 DIF/DEA/柱)、ma(收盘价均线)、boll_upper / boll_mid / boll_lower(布林轨道)、zhixing_dkx(知行多空线,四条收盘价均线的均值)、zhixing_trend(知行短期趋势线)、close(收盘价)、pct_chg(日涨跌幅%)
|
||||
- "params": 指标参数(可选),默认:KDJ {"n":9,"m1":3,"m2":3};RSI {"period":14};MACD {"fast":12,"slow":26,"signal":9};MA {"period":20};BOLL {"period":20,"std":2};知行多空线 {"m1":14,"m2":28,"m3":57,"m4":114};知行趋势线无参数(固定算法),不要给它填 params
|
||||
- "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、或直接给完整路径。
|
||||
.../anthropic(DeepSeek 的 Anthropic 协议端点)剥后缀走本模块的 OpenAI 兼容协议,
|
||||
key 两端点通用。
|
||||
"""
|
||||
base = base_url.rstrip("/")
|
||||
if base.endswith("/chat/completions"):
|
||||
return base
|
||||
if base.endswith("/anthropic"):
|
||||
base = base[: -len("/anthropic")]
|
||||
if not re.search(r"/v\d+$", base): # 未带版本段则补 /v1(DeepSeek/OpenAI 惯例)
|
||||
base += "/v1"
|
||||
return f"{base}/chat/completions"
|
||||
@@ -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_j(KDJ 的 K/D/J 值)、rsi、macd_dif / macd_dea / macd_hist(MACD 的 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_j(KDJ 的 K/D/J 值)、rsi、macd_dif / macd_dea / macd_hist(MACD 的 DIF/DEA/柱)、ma(收盘价均线)、boll_upper / boll_mid / boll_lower(布林轨道)、zhixing_dkx(知行多空线)、zhixing_trend(知行短期趋势线)、close(收盘价)、pct_chg(日涨跌幅%)
|
||||
- "params": 指标参数(可选),默认:KDJ {"n":9,"m1":3,"m2":3};RSI {"period":14};MACD {"fast":12,"slow":26,"signal":9};MA {"period":20};BOLL {"period":20,"std":2};知行多空线 {"m1":14,"m2":28,"m3":57,"m4":114};知行趋势线无参数(固定算法),不要给它填 params
|
||||
- "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
|
||||
|
||||
@@ -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 语句整批渲染为占位符(非 executemany),asyncpg 单语句参数上限 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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
476
backend_run.log
476
backend_run.log
@@ -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
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
PreviewResponse,
|
||||
ScreenerQueryItem,
|
||||
ScreenerRunRequest,
|
||||
ScreenerStreamEvent,
|
||||
ScreenerRunResponse,
|
||||
ScreenerSyncRequest,
|
||||
ScreenerSyncStatus,
|
||||
@@ -120,10 +121,38 @@ export async function postEventBacktest(req: EventBacktestRequest): Promise<Even
|
||||
}
|
||||
}
|
||||
|
||||
export async function runScreener(req: ScreenerRunRequest): Promise<ScreenerRunResponse> {
|
||||
const res = await apiFetch('/api/screener/run', { method: 'POST', body: JSON.stringify(req) });
|
||||
/** 选股流式执行:逐行回调 NDJSON 事件(阶段/进度/解析条件),最终返回完整结果。
|
||||
* signal 中止后(含读流中途)以 AbortError 拒绝,由调用方决定如何呈现。 */
|
||||
export async function runScreener(
|
||||
req: ScreenerRunRequest,
|
||||
onEvent?: (ev: ScreenerStreamEvent) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ScreenerRunResponse> {
|
||||
const res = await apiFetch('/api/screener/run', { method: 'POST', body: JSON.stringify(req), signal });
|
||||
if (!res.ok) throw new ApiError(await readError(res, `选股失败 (HTTP ${res.status})`), res.status);
|
||||
return (await res.json()) as ScreenerRunResponse;
|
||||
if (!res.body) throw new ApiError('当前环境不支持流式响应', 0);
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
let result: ScreenerRunResponse | null = null;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
let nl: number;
|
||||
while ((nl = buf.indexOf('\n')) >= 0) {
|
||||
const line = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (!line) continue;
|
||||
const ev = JSON.parse(line) as ScreenerStreamEvent;
|
||||
if (ev.type === 'error') throw new ApiError(ev.message, ev.code ?? 0);
|
||||
if (ev.type === 'result') result = ev.result;
|
||||
onEvent?.(ev);
|
||||
}
|
||||
}
|
||||
if (!result) throw new ApiError('选股响应中断(未收到结果事件)', 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function startScreenerSync(req: ScreenerSyncRequest = {}): Promise<ScreenerSyncStatus> {
|
||||
|
||||
@@ -148,6 +148,15 @@ export interface ScreenerRunResponse {
|
||||
indicator_labels: Record<string, string>;
|
||||
}
|
||||
|
||||
// ---------- 选股流式事件(镜像 api.py /screener/run 的 NDJSON 约定) ----------
|
||||
export type ScreenerStreamEvent =
|
||||
| { type: 'stage'; key: string; msg: string; ms?: number }
|
||||
| { type: 'parsed'; conditions: ScreenConditions; ms?: number }
|
||||
| { type: 'candidates'; count: number; msg?: string; ms?: number }
|
||||
| { type: 'progress'; done: number; total: number }
|
||||
| { type: 'result'; result: ScreenerRunResponse; ms?: number }
|
||||
| { type: 'error'; message: string; code?: number };
|
||||
|
||||
export interface ScreenerSyncRequest {
|
||||
days?: number;
|
||||
force?: boolean;
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
threeWaves, triangle, xabcd,
|
||||
} from '@klinecharts/extension';
|
||||
import { useSettingsStore, TOOLTIP_FIELDS, DEFAULT_TOOLTIP_FIELDS } from '@/stores/settings';
|
||||
import { registerCustomIndicators, computeZhixingSeries, DKX_PERIODS, ZHIXING_NAME, CHIPS_NAME, type ChipsExt } from '@/indicators';
|
||||
import { registerCustomIndicators, computeZhixingSeries, DKX_PERIODS, ZHIXING_NAME, CHIPS_NAME, CHIPS_COL_PX, type ChipsExt } from '@/indicators';
|
||||
import type { Candle, TooltipField } from '@/api/types';
|
||||
|
||||
// 通达信导入的自定义主图指标注册表;知行序列走 PV 管道按时间戳取预计算值。
|
||||
@@ -671,11 +671,62 @@ function syncChipsIndicator() {
|
||||
if (want) {
|
||||
if (exists) chart.removeIndicator({ name: CHIPS_NAME });
|
||||
chart.createIndicator({ name: CHIPS_NAME, paneId: 'candle_pane', extendData: props.chips ?? undefined });
|
||||
syncRightOffset(); // 开关即扩留白(不等数据),数据到达时列直接落在已预留的空白里
|
||||
} else if (exists) {
|
||||
chart.removeIndicator({ name: CHIPS_NAME });
|
||||
restoreRightOffset();
|
||||
} else {
|
||||
restoreRightOffset(); // 数据未到即关:同样要还原已扩出去的留白
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 筹码峰竖列的右侧留白管理 ----------
|
||||
// klinecharts 滚动状态唯一变量是 diff(最后一根K距绘图区右缘的 bar 数),
|
||||
// setOffsetRightDistance(px) 会把 diff 直接重置为 px/barSpace 并 relayout——等价于
|
||||
// “视口拽回实时位并留 px 空白”。因此只在「右区」(末根K在视野内,getVisibleRange().to
|
||||
// 达到 data 长度)时调用;用户在看历史时绝不调,滚回右区后由 onScroll 防抖补一次吸附
|
||||
// (同花顺松手吸附同款)。绝不调 setMaxOffsetRightDistance:会把滚动限制切到
|
||||
// 'distance' 模式,默认钳到 50px 且改变滚动边缘行为。
|
||||
const BASE_RIGHT_PX = 28;
|
||||
let syncingRightOffset = false; // 重入守卫:set 会同步派发 onVisibleRangeChange
|
||||
let scrollReanchorTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let managedRightPx = 0; // 筹码开启期间我们 set 过的目标值(关筹码时判断“是否还归我们管”)
|
||||
|
||||
/** 目标右侧留白:开筹码 = 列宽 + 一根K线 + 余量(bar 最宽 50px → 峰值 ~182px) */
|
||||
function targetRightPx(): number {
|
||||
if (!props.showChips) return BASE_RIGHT_PX;
|
||||
const bar = chart?.getBarSpace().bar || 10;
|
||||
return Math.round(CHIPS_COL_PX + bar + 12);
|
||||
}
|
||||
|
||||
/** 开筹码期间保证留白盖住竖列:只扩不缩(用户自己拖出的更大留白不动) */
|
||||
function syncRightOffset(): void {
|
||||
if (!chart || syncingRightOffset || !props.showChips) return;
|
||||
const list = chart.getDataList();
|
||||
if (!list.length || chart.getVisibleRange().to < list.length) return; // 右区守卫
|
||||
const target = targetRightPx();
|
||||
if (chart.getOffsetRightDistance() >= target - 2) return; // 阈值防循环 + 只扩不缩
|
||||
syncingRightOffset = true;
|
||||
try {
|
||||
chart.setOffsetRightDistance(target);
|
||||
managedRightPx = target;
|
||||
} finally {
|
||||
syncingRightOffset = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 关筹码时一次性还原 28px:仅当留白仍在我们管理范围内(用户没手动拖大过) */
|
||||
function restoreRightOffset(): void {
|
||||
if (!chart || managedRightPx === 0) return;
|
||||
const list = chart.getDataList();
|
||||
const cur = chart.getOffsetRightDistance();
|
||||
if (list.length && chart.getVisibleRange().to >= list.length
|
||||
&& cur > BASE_RIGHT_PX + 2 && cur <= managedRightPx + 2) {
|
||||
chart.setOffsetRightDistance(BASE_RIGHT_PX);
|
||||
}
|
||||
managedRightPx = 0;
|
||||
}
|
||||
|
||||
function build() {
|
||||
if (!container.value || props.candles.length === 0) return;
|
||||
UP = settings.upHex;
|
||||
@@ -801,7 +852,23 @@ function build() {
|
||||
const from = (payload as { data?: { from?: unknown } }).data?.from;
|
||||
if (typeof from === 'number' && from < 200) maybePrefetch(myEpoch);
|
||||
});
|
||||
ch.setOffsetRightDistance(28);
|
||||
// 右侧留白:开筹码时按竖列宽度预留(scrollToRealTime 以它为锚点,先后顺序不能换)
|
||||
ch.setOffsetRightDistance(props.showChips ? targetRightPx() : BASE_RIGHT_PX);
|
||||
// 筹码竖列的留白跟随:缩放后 barSpace 变化(onZoom 触发时新值已生效)立即补扩;
|
||||
// 拖拽期间 onScroll 逐帧触发、且拖拽每帧从手势起点快照重算 diff,逐帧同步会与拖拽
|
||||
// “互搏”(从实时位拖向历史会被逐帧拽回),故防抖 150ms 只在手势结束后补一次
|
||||
ch.subscribeAction('onZoom', () => {
|
||||
if (myEpoch !== epoch) return;
|
||||
syncRightOffset();
|
||||
});
|
||||
ch.subscribeAction('onScroll', () => {
|
||||
if (myEpoch !== epoch) return;
|
||||
if (scrollReanchorTimer) clearTimeout(scrollReanchorTimer);
|
||||
scrollReanchorTimer = setTimeout(() => {
|
||||
scrollReanchorTimer = null;
|
||||
if (myEpoch === epoch) syncRightOffset();
|
||||
}, 150);
|
||||
});
|
||||
ch.scrollToRealTime();
|
||||
// 日期跳转:build 尾部的 scrollToRealTime 会把视口重置到最新一根,居中必须放在它之后
|
||||
//(init 数据在 setPeriod 时已同步落入图表,这里可直接定位)。
|
||||
@@ -814,6 +881,8 @@ function build() {
|
||||
|
||||
function teardown() {
|
||||
if (subHTimer) { clearTimeout(subHTimer); subHTimer = null; }
|
||||
if (scrollReanchorTimer) { clearTimeout(scrollReanchorTimer); scrollReanchorTimer = null; }
|
||||
managedRightPx = 0;
|
||||
if (container.value) dispose(container.value);
|
||||
chart = null;
|
||||
hover.value = null;
|
||||
|
||||
76
frontend/src/components/MarketSyncBar.vue
Normal file
76
frontend/src/components/MarketSyncBar.vue
Normal 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>
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { deleteScreenerQuery, getScreenerQueries } from '@/api/client';
|
||||
import type { ScreenConditions, ScreenerQueryItem } from '@/api/types';
|
||||
import type { ScreenerQueryItem } from '@/api/types';
|
||||
|
||||
const props = defineProps<{ loading: boolean }>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'run', text: string, conditions?: ScreenConditions | null): void;
|
||||
(e: 'run', text: string): void;
|
||||
(e: 'ran'): void;
|
||||
(e: 'stop'): void;
|
||||
}>();
|
||||
|
||||
const text = ref('');
|
||||
@@ -18,13 +19,14 @@ const examples = [
|
||||
];
|
||||
|
||||
function run() {
|
||||
if (props.loading) return; // 执行中 Ctrl+Enter 不重复触发(要停止点「停止」)
|
||||
if (text.value.trim()) {
|
||||
emit('run', text.value.trim());
|
||||
emit('ran');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 提问历史(入库,可一键重跑 / 删除) ----------
|
||||
// ---------- 提问历史(点击只填入输入框,不触发查询 / 删除) ----------
|
||||
const history = ref<ScreenerQueryItem[]>([]);
|
||||
const historyOpen = ref(false);
|
||||
|
||||
@@ -38,12 +40,9 @@ async function refreshHistory() {
|
||||
} catch { history.value = []; }
|
||||
}
|
||||
|
||||
function rerun(q: ScreenerQueryItem) {
|
||||
function fillQuery(q: ScreenerQueryItem) {
|
||||
text.value = q.text;
|
||||
historyOpen.value = false;
|
||||
// 存过 conditions 的记录直传条件,跳过 LLM 重新解析
|
||||
emit('run', q.text, q.conditions ?? null);
|
||||
emit('ran');
|
||||
}
|
||||
|
||||
async function removeQuery(id: number) {
|
||||
@@ -87,8 +86,8 @@ defineExpose({ refreshHistory });
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 flex-1 text-left"
|
||||
:title="q.conditions ? '点击直传条件重跑(不重新解析)' : '点击填入并重跑'"
|
||||
@click="rerun(q)"
|
||||
title="点击填入输入框(不自动执行)"
|
||||
@click="fillQuery(q)"
|
||||
>
|
||||
<span class="block truncate text-sm text-[#E8EAED]">{{ q.text }}</span>
|
||||
<span class="mt-0.5 block text-xs text-[#9BA3AE]">
|
||||
@@ -134,10 +133,19 @@ defineExpose({ refreshHistory });
|
||||
支持 KDJ / RSI / MACD / 布林 / 均线指标条件,市值 / 市盈率 / 换手率等快照条件,以及「连续 N 天」「近 N 天任一天」时间窗口。
|
||||
按 <kbd class="rounded border border-[#26272E] bg-black px-1">Ctrl</kbd>+<kbd class="rounded border border-[#26272E] bg-black px-1">Enter</kbd> 快速筛选。
|
||||
</p>
|
||||
<button type="button" class="btn-primary shrink-0 disabled:opacity-50" :disabled="loading || !text.trim()" @click="run">
|
||||
<svg v-if="loading" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
|
||||
<svg v-else class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7" /><path d="M21 21l-4.3-4.3" /></svg>
|
||||
{{ loading ? '筛选中…' : '开始筛选' }}
|
||||
<!-- 执行中变为「停止」:中止 LLM 解析与逐股过滤(服务端随连接断开自动取消) -->
|
||||
<button
|
||||
v-if="loading"
|
||||
type="button"
|
||||
class="flex shrink-0 items-center gap-1.5 rounded-lg border border-red-500/50 bg-red-500/15 px-4 py-2 text-sm font-medium text-red-300 transition-colors hover:bg-red-500/25"
|
||||
@click="emit('stop')"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="6" y="6" width="12" height="12" rx="1.5" /></svg>
|
||||
停止
|
||||
</button>
|
||||
<button v-else type="button" class="btn-primary shrink-0 disabled:opacity-50" :disabled="!text.trim()" @click="run">
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7" /><path d="M21 21l-4.3-4.3" /></svg>
|
||||
开始筛选
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,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>
|
||||
@@ -1,15 +1,20 @@
|
||||
// 个股筹码峰(Tushare cyq_chips / cyq_perf,同花顺式右侧横向线状直方图)
|
||||
// 个股筹码峰(Tushare cyq_chips / cyq_perf,同花顺火焰山式右侧专用竖列)
|
||||
//
|
||||
// 数据不在图表窗口上计算,而是一次性截面(某交易日各价位筹码占比 + 获利比例/平均成本),
|
||||
// 经 extendData 注入、纯 draw 绘制:
|
||||
// - 每个价位一根横线,从主图右缘向左延伸,宽度 ∝ 占比/最大占比(火焰山形态)
|
||||
// - 分界价 = 选中K线的收盘价(refClose):下方为获利盘(红线)、上方为套牢盘(蓝线)
|
||||
// - 平均成本画黄色虚线;右上角小字块:截面日期 / 获利比例 / 平均成本 / 90% 成本区间
|
||||
// - 主图绘图区右缘、价格轴左侧一条【不透明底色专用竖列】,K线不透出(同花顺同款)
|
||||
// - 每个价位一根横条,在列内从右缘向左伸展,宽度 ∝ 占比/最大占比(火焰山形态)
|
||||
// - 分界价 = 选中K线的收盘价(refClose):下方为获利盘(红)、上方为套牢盘(蓝)
|
||||
// - 平均成本画黄色虚线、末根收盘画现价细实线;列内顶部小字:截面日/获利比例/平均成本/90%成本
|
||||
// DetailKLine 依据 CHIPS_COL_PX 在开启筹码时加大右侧留白,保证最新K线不被竖列盖住。
|
||||
// figures 为空、calc 返回空行:指标不产生序列值,也不影响主图价格刻度(筹码是覆盖层)。
|
||||
import type { IndicatorTemplate } from 'klinecharts';
|
||||
|
||||
export const CHIPS_NAME = 'pv-chips';
|
||||
|
||||
/** 火焰山竖列基准宽度(px):DetailKLine 据此预留右侧留白,draw 据此画列 */
|
||||
export const CHIPS_COL_PX = 120;
|
||||
|
||||
/** 经 extendData 注入的筹码截面(父组件按 当前窗口末根/十字线 所在周期取数) */
|
||||
export interface ChipsExt {
|
||||
rows: { price: number; percent: number }[]; // percent 为 0~1 占比
|
||||
@@ -29,16 +34,34 @@ const AVG_COLOR = '#F5C518';
|
||||
const LABEL_COLOR = '#9AA0AA';
|
||||
const VALUE_COLOR = '#E8EAED';
|
||||
|
||||
/** 同花顺筹码峰同款:横线最大占主图宽度的比例与上限(px) */
|
||||
const MAX_WIDTH_RATIO = 0.22;
|
||||
const MAX_WIDTH_PX = 150;
|
||||
/** 横条高度上限(px):极度放大时单价位占比条不至于铺满半个主图 */
|
||||
// 竖列几何:窄图下限 56px,最多占绘图区宽度 35%(实际宽度取两者与基准的钳制值)
|
||||
const COL_MIN_PX = 56;
|
||||
const COL_MAX_RATIO = 0.35;
|
||||
/** 列内左右内边距(横条右缘、统计文字与列缘的呼吸) */
|
||||
const COL_PAD = 5;
|
||||
/** 不透明底色(图表容器纯黑 bg-black,取应用面板色);左缘 1px 分隔线与 y 轴线同色 */
|
||||
const COL_BG = '#101014';
|
||||
const COL_BORDER = '#2A2D34';
|
||||
/** 实心条透明度:不透明列内 0.85 足够“实心”,与底色留一丝层次 */
|
||||
const BAR_ALPHA = 0.85;
|
||||
/** 横条高度上限(px):极度放大时单价位占比条不至于铺满大半个竖列 */
|
||||
const BAR_HEIGHT_CAP = 24;
|
||||
/** 统计文字起始 y(画线工具栏是 HTML 层,占顶部 ~36px,文字块从 44px 起)/ 行高 */
|
||||
const HEADER_TOP = 44;
|
||||
const LINE_H = 13;
|
||||
|
||||
function fmtDate(d?: string): string {
|
||||
return d && d.length === 8 ? `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}` : '—';
|
||||
}
|
||||
|
||||
/** 右对齐数值超宽时截断加省略号(90%成本 “10.50~14.20” 这类长串的兜底) */
|
||||
function fitText(ctx: CanvasRenderingContext2D, text: string, maxW: number): string {
|
||||
if (ctx.measureText(text).width <= maxW) return text;
|
||||
let s = text;
|
||||
while (s.length > 1 && ctx.measureText(`${s}…`).width > maxW) s = s.slice(0, -1);
|
||||
return `${s}…`;
|
||||
}
|
||||
|
||||
export const chipsIndicator: IndicatorTemplate<Record<string, never>, unknown, ChipsExt> = {
|
||||
name: CHIPS_NAME,
|
||||
shortName: '筹码',
|
||||
@@ -49,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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
53
frontend/src/stores/marketSync.ts
Normal file
53
frontend/src/stores/marketSync.ts
Normal 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 };
|
||||
});
|
||||
@@ -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; // 阶段 key(llm/date/prefilter/bars/filter_done/done)
|
||||
msg: string;
|
||||
ms?: number;
|
||||
}
|
||||
|
||||
export const useScreenerStore = defineStore('screener', () => {
|
||||
const loading = ref(false);
|
||||
const stage = ref<'idle' | 'parsing' | 'screening'>('idle');
|
||||
const note = ref<string | null>(null);
|
||||
const error = ref<string | null>(null);
|
||||
const result = ref<ScreenerRunResponse | null>(null);
|
||||
const 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 返回值落入 result;error 走异常
|
||||
}
|
||||
}, 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 };
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
$ vite
|
||||
|
||||
[32m[1mVITE[22m v6.4.3[39m [2mready in [0m[1m598[22m[2m[0m ms[22m
|
||||
[32m[1mVITE[22m v6.4.3[39m [2mready in [0m[1m717[22m[2m[0m ms[22m
|
||||
|
||||
[32m➜[39m [1mLocal[22m: [36mhttp://localhost:[1m5173[22m/[39m
|
||||
[2m [32m➜[39m [1mNetwork[22m[2m: use [22m[1m--host[22m[2m to expose[22m
|
||||
[2m00:44:18[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/style.css, /@fs/D:\Project\stock\frontend\src\components\DetailKLine.vue[22m
|
||||
[2m00:44:23[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/@fs/D:\Project\stock\frontend\src\components\DetailKLine.vue, /src/style.css[22m
|
||||
[2m00:44:24[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/style.css, /@fs/D:\Project\stock\frontend\src\components\DetailKLine.vue[22m
|
||||
[2m10:03:08[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mpage reload [39m[2msrc/api/types.ts[22m
|
||||
[2m10:03:24[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mpage reload [39m[2msrc/api/client.ts[22m
|
||||
[2m10:03:38[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mpage reload [39m[2msrc/api/client.ts[22m
|
||||
[2m10:03:48[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/style.css, /src/views/ScreenerView.vue[22m
|
||||
[2m10:04:11[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/views/ScreenerView.vue, /src/style.css[22m
|
||||
[2m10:04:24[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/views/ScreenerView.vue, /src/style.css[22m
|
||||
[2m10:04:40[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/views/ScreenerView.vue, /src/style.css[22m
|
||||
[2m10:15:13[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mpage reload [39m[2msrc/api/client.ts[22m
|
||||
[2m10:15:27[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/style.css, /src/views/ScreenerView.vue[22m
|
||||
[2m10:15:48[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/@fs/D:\Project\stock\frontend\src\components\ScreenerForm.vue, /src/style.css[22m
|
||||
[2m10:15:53[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/@fs/D:\Project\stock\frontend\src\components\ScreenerForm.vue, /src/style.css[22m
|
||||
[2m10:16:01[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/@fs/D:\Project\stock\frontend\src\components\ScreenerForm.vue, /src/style.css[22m
|
||||
[2m10:16:06[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/@fs/D:\Project\stock\frontend\src\components\ScreenerForm.vue, /src/style.css[22m
|
||||
[2m10:16:11[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/views/ScreenerView.vue, /src/style.css[22m
|
||||
[2m10:20:01[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/screener/run[39m
|
||||
Error: read ECONNRESET
|
||||
at TCP.onStreamRead (node:internal/stream_base_commons:216:20)
|
||||
[2m10:23:16[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/auth/me[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1134:18)
|
||||
at afterConnectMultiple (node:net:1715:7)
|
||||
[2m10:23:20[22m [31m[1m[vite][22m[39m [31mhttp proxy error: /api/auth/login[39m
|
||||
AggregateError [ECONNREFUSED]:
|
||||
at internalConnectMultiple (node:net:1134:18)
|
||||
at afterConnectMultiple (node:net:1715:7)
|
||||
|
||||
Reference in New Issue
Block a user