feat: 自选/个股分析/K线查询接入 ETF (#58)

* feat: 自选/个股分析/K线查询接入 ETF

数据层此前已完成 ETF 同步与存储 (instruments_etf / kline_etf_* /
adj_factor_etf), 但查询侧仍只走股票路径。本次把已有的 ETF 读取能力
接到面向用户的接口上:

- repository: 新增 get_etf_symbol_set / resolve_asset_type 按 symbol
  判定资产类型; get_minute / get_minute_batch / latest_minute_date
  增加 asset_type 参数切换 ETF 分钟K存储
- kline API: instruments/search 增加 asset_types 参数 (默认 stock,
  既有调用方行为不变), 结果附 asset_type; instruments/names 合并
  ETF 名称; /daily /minute /minute-batch 按资产类型分流
- watchlist API: /enriched 合并 ETF enriched 缓存行, 名称补齐支持 ETF
- 个股分析: /levels 与 AI 分析按资产类型分流, ETF 无财务数据走
  已有兜底提示
- 前端: 自选搜索框传 asset_types=stock,etf 并显示 ETF 标记

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: 修复 ETF 接入的审查发现问题

多智能体代码审查确认的 9 处问题修复:

- watchlist /enriched: 仅自选实际含 ETF 时才加载 ETF enriched 缓存,
  避免无 ETF 用户在缓存冷启动时触发全量懒加载阻塞请求; 恢复股票
  enriched 预热期间 all-or-nothing 旧契约 (不再返回只有 ETF 的部分结果);
  as_of 取股票/ETF 两类缓存中较旧者, 不再把旧 ETF 行标成股票缓存日期
- repository: refresh_cache 失效 _etf_enriched_cache, 盘后管道跑完
  ETF 行不再停留在旧日期; get_etf_symbol_set / get_index_symbol_set
  增加 memo (随 instruments 缓存失效), 热路径不再每请求重建全量集合;
  新增 get_name_map 统一股票+ETF 名称解析, 收敛三处重复合并逻辑
- kline /daily: 实时蜡烛注入改为资产感知, ETF 开启实时拉取时同样
  注入今日 bar (未开启时由"非今日不注入"守卫自然跳过)
- kline search: 空关键词早退提前到数据处理之前; symbol 列 dtype
  归一到 Utf8, 防两份缓存来源 dtype 不一致导致 concat SchemaError

已知限制 (不在本次范围): kline_etf_minute 目前无盘后同步写入方,
ETF 分时依赖"本地缺失 → TickFlow 实时补拉"路径, 与其他未同步分钟K
标的行为一致。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jinfeng Sun
2026-07-06 17:26:40 +08:00
committed by GitHub
co-authored by Claude Fable 5
parent bb562cbdff
commit b0d1f2f742
8 changed files with 195 additions and 60 deletions
+80 -30
View File
@@ -33,16 +33,38 @@ def search_instruments(
request: Request,
q: str = Query("", min_length=0, max_length=50, description="搜索关键词"),
limit: int = Query(20, ge=1, le=50),
asset_types: str = Query("stock", description="逗号分隔的资产类型: stock,etf"),
):
"""模糊搜索标的 (代码 / 名称)。从内存 instruments 缓存中查。"""
repo = request.app.state.repo
df = repo.get_instruments()
if df.is_empty() or not q.strip():
"""模糊搜索标的 (代码 / 名称)。从内存 instruments 缓存中查。
默认只搜股票, 保持既有调用方行为不变; 自选等场景传 asset_types=stock,etf
可一并搜出 ETF, 结果附带 asset_type 字段供前端区分。
"""
if not q.strip():
return {"results": []}
keyword = q.strip().upper()
repo = request.app.state.repo
import polars as pl
types = [t.strip() for t in asset_types.split(",") if t.strip()]
parts: list[pl.DataFrame] = []
for t in types:
df_t = repo.get_instruments_asset(t)
if df_t.is_empty() or "symbol" not in df_t.columns:
continue
# dtype 全部归一到 Utf8: 股票/ETF 两份缓存来源不同 (ETF 含 legacy 合并), 防 concat SchemaError
parts.append(df_t.with_columns([
pl.col("symbol").cast(pl.Utf8).alias("symbol"),
(pl.col("name").cast(pl.Utf8) if "name" in df_t.columns else pl.lit("")).alias("name"),
(pl.col("code").cast(pl.Utf8) if "code" in df_t.columns else pl.lit("")).alias("code"),
pl.lit(t).alias("asset_type"),
]).select(["symbol", "name", "code", "asset_type"]))
if not parts:
return {"results": []}
df = pl.concat(parts, how="vertical")
keyword = q.strip().upper()
# code/symbol 前缀优先,再 name 包含匹配
prefix_mask = (
pl.col("code").str.starts_with(keyword)
@@ -64,23 +86,17 @@ def search_instruments(
prefix_symbols = set(prefix_hits["symbol"].to_list()) if not prefix_hits.is_empty() else set()
contain_hits = df.filter(contains_mask & ~pl.col("symbol").is_in(prefix_symbols)).head(remaining)
matched = pl.concat([prefix_hits, contain_hits]) if not prefix_hits.is_empty() else contain_hits
rows = matched.select(["symbol", "name", "code"]).to_dicts()
rows = matched.select(["symbol", "name", "code", "asset_type"]).to_dicts()
return {"results": rows}
@router.post("/instruments/names")
def instruments_names(request: Request, symbols: list[str]):
"""批量查股票名称。传入 symbol 列表, 返回 {symbol: name}。"""
"""批量查标的名称 (股票 + ETF)。传入 symbol 列表, 返回 {symbol: name}。"""
if not symbols:
return {"names": {}}
repo = request.app.state.repo
df = repo.get_instruments()
if df.is_empty():
return {"names": {}}
import polars as pl
matched = df.filter(pl.col("symbol").is_in(symbols)).select(["symbol", "name"])
names = {row["symbol"]: row["name"] for row in matched.iter_rows(named=True)}
return {"names": names}
return {"names": repo.get_name_map(symbols)}
def _get_stock_info(repo, symbol: str) -> dict:
@@ -101,6 +117,21 @@ def _get_stock_info(repo, symbol: str) -> dict:
}
def _get_asset_info(repo, symbol: str, asset_type: str) -> dict:
"""非股票标的 (ETF / 指数) 的名称信息 — 从对应 instruments 缓存查, 无股本概念。"""
import polars as pl
try:
df = repo.get_instruments_asset(asset_type)
if df.is_empty() or "symbol" not in df.columns or "name" not in df.columns:
return {}
hit = df.filter(pl.col("symbol") == symbol).head(1)
if hit.is_empty():
return {}
return {"name": hit["name"][0]}
except Exception:
return {}
@router.get("/daily")
def get_daily(
request: Request,
@@ -126,11 +157,12 @@ def get_daily(
else:
start = end - timedelta(days=days)
stock_info = _get_stock_info(repo, symbol)
asset_type = repo.resolve_asset_type(symbol)
stock_info = _get_stock_info(repo, symbol) if asset_type == "stock" else _get_asset_info(repo, symbol, asset_type)
stock_name = stock_info.get("name")
# 从 enriched 表读取 (已含前复权 OHLCV + 技术指标 + 信号)
df = repo.get_daily(symbol, start, end)
# 从 enriched 表读取 (已含前复权 OHLCV + 技术指标 + 信号); ETF/指数走独立存储
df = repo.get_daily_asset(asset_type, symbol, start, end)
if df.is_empty():
try:
@@ -151,14 +183,14 @@ def get_daily(
enriched = compute_enriched(raw, factors=factors)
rows = enriched.tail(days).to_dicts()
# 即使 live 模式也尝试追加实时蜡烛
rows = _maybe_inject_live_candle(request, symbol, rows)
rows = _maybe_inject_live_candle(request, symbol, rows, asset_type)
resp = {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": rows, "source": "live"}
return _attach_ext(resp, repo, symbol, ext_columns)
rows = df.to_dicts()
# 追加/覆盖今日实时蜡烛
rows = _maybe_inject_live_candle(request, symbol, rows)
rows = _maybe_inject_live_candle(request, symbol, rows, asset_type)
resp = {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": rows, "source": "enriched"}
return _attach_ext(resp, repo, symbol, ext_columns)
@@ -229,13 +261,21 @@ def _attach_ext(resp: dict, repo, symbol: str, ext_columns: Optional[str]) -> di
return resp
def _maybe_inject_live_candle(request: Request, symbol: str, rows: list[dict]) -> list[dict]:
"""如果 QuoteService 有实时 enriched 数据, 用实时数据生成今日蜡烛并追加/覆盖。"""
qs = getattr(request.app.state, "quote_service", None)
if not qs:
return rows
def _maybe_inject_live_candle(request: Request, symbol: str, rows: list[dict], asset_type: str = "stock") -> list[dict]:
"""如果有当日实时 enriched 数据, 用实时数据生成今日蜡烛并追加/覆盖。
df_today, enriched_date = qs.get_enriched_today()
stock 走 QuoteService 的股票实时缓存; etf 走 ETF enriched 缓存 (开启实时 ETF
拉取时为盘中数据, 否则为磁盘最新日, 由下方"非今日不注入"守卫自然跳过)。
"""
if asset_type == "stock":
qs = getattr(request.app.state, "quote_service", None)
if not qs:
return rows
df_today, enriched_date = qs.get_enriched_today()
elif asset_type == "etf":
df_today, enriched_date = request.app.state.repo.get_enriched_latest_asset("etf")
else:
return rows
if df_today.is_empty():
return rows
@@ -377,8 +417,17 @@ def get_minute_batch(request: Request, body: dict):
if recent_date is not None:
trade_date = recent_date
# Step 1: 本地优先 — 一次 scan 读全部 symbol 当日分钟K
df_local = repo.get_minute_batch(symbols, trade_date)
# Step 1: 本地优先 — 一次 scan 读全部 symbol 当日分钟K (股票 / ETF 分钟数据分开存储)
etf_set = repo.get_etf_symbol_set()
stock_syms = [s for s in symbols if s not in etf_set]
etf_syms = [s for s in symbols if s in etf_set]
df_local = repo.get_minute_batch(stock_syms, trade_date)
if etf_syms:
df_etf = repo.get_minute_batch(etf_syms, trade_date, asset_type="etf")
if df_local.is_empty():
df_local = df_etf
elif not df_etf.is_empty():
df_local = pl.concat([df_local, df_etf], how="diagonal_relaxed")
# 期望条数 (盘中按当前时刻估算, 盘后 240)
now = datetime.now()
@@ -442,11 +491,12 @@ def get_minute(
- 本地无数据或不完整 → 从 TickFlow 实时拉取返回(不写入)
"""
repo = request.app.state.repo
stock_info = _get_stock_info(repo, symbol)
asset_type = repo.resolve_asset_type(symbol)
stock_info = _get_stock_info(repo, symbol) if asset_type == "stock" else _get_asset_info(repo, symbol, asset_type)
stock_name = stock_info.get("name")
if trade_date is None:
trade_date = repo.latest_minute_date(symbol)
trade_date = repo.latest_minute_date(symbol, asset_type=asset_type)
if trade_date is None:
# 本地无任何分钟K,尝试从 TickFlow 拉取当天
trade_date = date.today()
@@ -456,7 +506,7 @@ def get_minute(
"date": str(trade_date), "rows": df.to_dicts(), "source": "live",
}
df = repo.get_minute(symbol, trade_date)
df = repo.get_minute(symbol, trade_date, asset_type=asset_type)
# 完整交易日应有 240 条分钟K;如果是今天(盘中),期望条数按已交易分钟估算
expected = 240
+2 -1
View File
@@ -121,7 +121,8 @@ def get_levels(
repo = request.app.state.repo
end = date.today()
start = end - timedelta(days=days * 2)
df = repo.get_daily(symbol, start, end)
# 按资产类型分流: ETF/指数走独立 enriched 存储, 股票保持原路径
df = repo.get_daily_asset(repo.resolve_asset_type(symbol), symbol, start, end)
if df.is_empty():
return {"levels": {"sr": [], "pivot": [], "extreme": [],
"boll": [], "keltner_s": [], "keltner_m": [], "keltner_l": [],
+35 -13
View File
@@ -31,10 +31,10 @@ def _with_names(rows: list[dict], request: Request) -> list[dict]:
if not rows:
return rows
try:
df_i = request.app.state.repo.get_instruments()
if df_i.is_empty() or "symbol" not in df_i.columns or "name" not in df_i.columns:
# 股票 + ETF 名称统一由 repo.get_name_map 解析, 自选列表可混合持有
name_by_symbol = request.app.state.repo.get_name_map([r.get("symbol") for r in rows])
if not name_by_symbol:
return rows
name_by_symbol = dict(df_i.select(["symbol", "name"]).iter_rows())
return [{**row, "name": name_by_symbol.get(row.get("symbol"))} for row in rows]
except Exception as e: # noqa: BLE001
logger.debug("attach watchlist names failed: %s", e)
@@ -119,20 +119,42 @@ def watchlist_enriched(
if not symbols:
return {"rows": [], "as_of": None, "elapsed_ms": 0}
# 按资产拆分自选 symbol; ETF enriched 是独立缓存, 仅自选真的含 ETF 才去加载
# (避免无 ETF 用户在缓存冷启动时触发 ETF 全量懒加载)
etf_set = repo.get_etf_symbol_set()
stock_symbols = [s for s in symbols if s not in etf_set]
etf_symbols = [s for s in symbols if s in etf_set]
df_e, cache_date = repo.get_enriched_latest()
if df_e.is_empty():
# 保持原契约: 自选含股票但股票 enriched 未就绪 (预热中) → 返回"未就绪"而非部分结果
if stock_symbols and df_e.is_empty():
return {"rows": [], "as_of": None, "elapsed_ms": 0}
# 按 symbol 过滤
df = df_e.filter(pl.col("symbol").is_in(symbols))
if df.is_empty():
return {"rows": [], "as_of": str(cache_date) if cache_date else None, "elapsed_ms": 0}
df = df_e.filter(pl.col("symbol").is_in(stock_symbols)) if stock_symbols else pl.DataFrame()
# JOIN instruments 取 name + float_shares
# ETF 行合并; 缺失列 (换手率/涨跌停信号等) 为 null
etf_date = None
if etf_symbols:
df_etf_all, etf_date = repo.get_enriched_latest_asset("etf")
if not df_etf_all.is_empty():
df_etf = df_etf_all.filter(pl.col("symbol").is_in(etf_symbols))
if not df_etf.is_empty():
df = df_etf if df.is_empty() else pl.concat([df, df_etf], how="diagonal_relaxed")
# as_of 取两类缓存中较旧者, 避免把旧的 ETF 行标成股票缓存日期
dates = [d for d in (cache_date if stock_symbols else None, etf_date) if d is not None]
as_of = min(dates) if dates else None
if df.is_empty():
return {"rows": [], "as_of": str(as_of) if as_of else None, "elapsed_ms": 0}
# JOIN float_shares (仅股票有) + 名称 (股票/ETF 统一走 get_name_map)
df_i = repo.get_instruments()
if not df_i.is_empty() and "name" in df_i.columns:
inst_cols = [c for c in ["symbol", "name", "float_shares"] if c in df_i.columns]
df = df.join(df_i.select(inst_cols), on="symbol", how="left")
if not df_i.is_empty() and "float_shares" in df_i.columns:
df = df.join(df_i.select(["symbol", "float_shares"]), on="symbol", how="left")
name_map = repo.get_name_map(df["symbol"].to_list())
df = df.with_columns(
pl.col("symbol").replace_strict(name_map, default=None, return_dtype=pl.Utf8).alias("name")
)
# 选择内置需要的列
keep = [c for c in _WATCHLIST_COLS + ["name", "float_shares"] if c in df.columns]
@@ -204,7 +226,7 @@ def watchlist_enriched(
rows = df.to_dicts()
elapsed = (time.perf_counter() - t0) * 1000
return {"rows": rows, "as_of": str(cache_date) if cache_date else None, "elapsed_ms": elapsed}
return {"rows": rows, "as_of": str(as_of) if as_of else None, "elapsed_ms": elapsed}
def _parse_ext_columns(ext_columns: str) -> list[tuple[str, str]]:
+2 -1
View File
@@ -45,7 +45,8 @@ def _load_kline(repo, symbol: str) -> pl.DataFrame:
end = date.today()
start = end - timedelta(days=_KLINE_WINDOW * 2) # 多取一些保证交易日够
df = repo.get_daily(symbol, start, end)
# 按资产类型分流: ETF/指数走独立 enriched 存储 (无财务数据, 提示词已有兜底)
df = repo.get_daily_asset(repo.resolve_asset_type(symbol), symbol, start, end)
if df.is_empty():
return df
return df.tail(_KLINE_WINDOW)
+67 -9
View File
@@ -301,6 +301,9 @@ class KlineRepository:
self._etf_live_agg_cache: pl.DataFrame | None = None
self._etf_live_agg_cache_date: date | None = None
self._etf_instruments_cache: pl.DataFrame | None = None
# symbol 集合 memo (随对应 instruments 缓存失效): 供每请求资产分流用
self._index_symbol_set_cache: set[str] | None = None
self._etf_symbol_set_cache: set[str] | None = None
# ---- enriched 后台预热 ----
# 启动时 compute_indicators (107万行, 低配机 50s+) 移出 lifespan 关键路径,
@@ -362,6 +365,11 @@ class KlineRepository:
self._refresh_etf_instruments()
logger.info("cache refresh step done: ETF instruments (%.2fs)", time.perf_counter() - step)
# ETF enriched 只失效不重建: 下次访问时按新数据懒加载,
# 避免自选无 ETF 的用户在管道后白付全量重算成本
self._etf_enriched_cache = None
self._etf_enriched_cache_date = None
if background:
logger.info("cache refresh: enriched 推后台线程预热")
self._start_enriched_warmup()
@@ -438,6 +446,8 @@ class KlineRepository:
self._etf_live_agg_cache = None
self._etf_live_agg_cache_date = None
self._etf_instruments_cache = None
self._index_symbol_set_cache = None
self._etf_symbol_set_cache = None
def _refresh_enriched(self) -> None:
"""从 parquet 加载 enriched 最新日到内存 + 构建聚合表。
@@ -854,6 +864,7 @@ class KlineRepository:
df = pl.scan_parquet(self._index_inst_glob).collect()
if not df.is_empty():
self._index_instruments_cache = df
self._index_symbol_set_cache = None
logger.info("index instruments 缓存已加载: %d", len(df))
except Exception as e: # noqa: BLE001
logger.debug("index instruments 缓存刷新跳过: %s", e)
@@ -878,6 +889,7 @@ class KlineRepository:
if parts:
df_all = pl.concat(parts, how="diagonal_relaxed").unique(subset=["symbol"], keep="last").sort("symbol")
self._etf_instruments_cache = df_all
self._etf_symbol_set_cache = None
logger.info("ETF instruments 缓存已加载: %d", len(df_all))
def get_enriched_latest(self) -> tuple[pl.DataFrame, date | None]:
@@ -1035,11 +1047,50 @@ class KlineRepository:
return pl.DataFrame()
def get_index_symbol_set(self) -> set[str]:
"""返回已缓存指数 symbol 集合。"""
df = self.get_index_instruments()
if df.is_empty() or "symbol" not in df.columns:
return set()
return set(df["symbol"].cast(pl.Utf8).to_list())
"""返回已缓存指数 symbol 集合 (memo, 随 instruments 缓存失效)"""
if self._index_symbol_set_cache is None:
df = self.get_index_instruments()
if df.is_empty() or "symbol" not in df.columns:
return set()
self._index_symbol_set_cache = set(df["symbol"].cast(pl.Utf8).to_list())
return self._index_symbol_set_cache
def get_etf_symbol_set(self) -> set[str]:
"""返回已缓存 ETF symbol 集合 (memo, 随 instruments 缓存失效)。"""
if self._etf_symbol_set_cache is None:
df = self.get_etf_instruments()
if df.is_empty() or "symbol" not in df.columns:
return set()
self._etf_symbol_set_cache = set(df["symbol"].cast(pl.Utf8).to_list())
return self._etf_symbol_set_cache
def resolve_asset_type(self, symbol: str) -> str:
"""按 symbol 判定资产类型: etf / index / stock(默认)。
供 API 层对单标的查询做资产分流 (get_daily_asset 等)。
ETF/指数集合为 memo, 每请求查询成本可忽略。
"""
if symbol in self.get_etf_symbol_set():
return "etf"
if symbol in self.get_index_symbol_set():
return "index"
return "stock"
def get_name_map(self, symbols: list[str] | None = None) -> dict[str, str]:
"""返回 {symbol: name} 映射, 合并股票 + ETF instruments (股票优先去重)。
自选列表/名称批查等场景的统一名称解析入口, 避免各调用方自行合并两份缓存。
symbols 非 None 时只返回命中的条目。
"""
name_map: dict[str, str] = {}
for df in (self.get_instruments(), self.get_etf_instruments()):
if df.is_empty() or "symbol" not in df.columns or "name" not in df.columns:
continue
if symbols is not None:
df = df.filter(pl.col("symbol").is_in(symbols))
for symbol, name in df.select(["symbol", "name"]).iter_rows():
name_map.setdefault(symbol, name)
return name_map
def enriched_latest_date(self) -> date | None:
"""返回缓存中的 enriched 最新日期。"""
@@ -1162,14 +1213,19 @@ class KlineRepository:
return self.get_etf_daily(symbol, start, end, columns)
return pl.DataFrame()
def _minute_glob_for(self, asset_type: str) -> str:
"""按资产类型选择分钟K parquet glob。ETF 分钟数据独立存储于 kline_etf_minute。"""
return self._etf_minute_glob if asset_type == "etf" else self._minute_glob
def get_minute(
self,
symbol: str,
trade_date: date,
asset_type: str = "stock",
) -> pl.DataFrame:
"""分钟K查询 — Polars scan_parquet + predicate pushdown。"""
try:
return pl.scan_parquet(self._minute_glob).filter(
return pl.scan_parquet(self._minute_glob_for(asset_type)).filter(
(pl.col("symbol") == symbol)
& (pl.col("datetime").dt.date() == trade_date)
).sort("datetime").collect()
@@ -1181,6 +1237,7 @@ class KlineRepository:
self,
symbols: list[str],
trade_date: date,
asset_type: str = "stock",
) -> pl.DataFrame:
"""批量分钟K查询 — 多 symbol 一次 scan_parquet。
@@ -1190,7 +1247,7 @@ class KlineRepository:
if not symbols:
return pl.DataFrame()
try:
return pl.scan_parquet(self._minute_glob).filter(
return pl.scan_parquet(self._minute_glob_for(asset_type)).filter(
pl.col("symbol").is_in(symbols)
& (pl.col("datetime").dt.date() == trade_date)
).sort(["symbol", "datetime"]).collect()
@@ -1353,11 +1410,12 @@ class KlineRepository:
# DuckDB 查询 (冷路径: 统计/元数据/自定义SQL)
# ================================================================
def latest_minute_date(self, symbol: str) -> date | None:
def latest_minute_date(self, symbol: str, asset_type: str = "stock") -> date | None:
table = "kline_etf_minute" if asset_type == "etf" else "kline_minute"
try:
with self._lock:
row = self.db.execute(
"SELECT max(CAST(datetime AS DATE)) FROM kline_minute WHERE symbol = ?",
f"SELECT max(CAST(datetime AS DATE)) FROM {table} WHERE symbol = ?",
[symbol],
).fetchone()
if row and row[0]:
+3 -3
View File
@@ -1097,9 +1097,9 @@ export const api = {
method: 'POST',
body: JSON.stringify({ symbols, date }),
}),
instrumentSearch: (q: string, limit = 20) =>
request<{ results: { symbol: string; name: string; code: string }[] }>(
`/api/kline/instruments/search?q=${encodeURIComponent(q)}&limit=${limit}`,
instrumentSearch: (q: string, limit = 20, assetTypes?: string) =>
request<{ results: { symbol: string; name: string; code: string; asset_type?: string }[] }>(
`/api/kline/instruments/search?q=${encodeURIComponent(q)}&limit=${limit}${assetTypes ? `&asset_types=${encodeURIComponent(assetTypes)}` : ''}`,
),
/** 批量查股票名称 (传入 symbol 列表, 返回 {symbol: name}) */
+1 -1
View File
@@ -29,7 +29,7 @@ export const QK = {
// 不用 watchlist- 前缀: 避免被 SSE quotes_updated 高频失效(expert 1s/pro 2s)
// 导致每次都拉 TickFlow 触限流。分时图用固定 refetchInterval 刷新即可。
minuteBatch: (symbols: string) => ['minute-batch', symbols] as const,
instrumentSearch: (q: string) => ['instrument-search', q] as const,
instrumentSearch: (q: string, assetTypes?: string) => ['instrument-search', q, assetTypes ?? 'stock'] as const,
// Screener
screener: ['screener'] as const,
+5 -2
View File
@@ -191,8 +191,8 @@ function StockSearchBox({
const [activeIdx, setActiveIdx] = useState(-1)
const search = useQuery({
queryKey: QK.instrumentSearch(query),
queryFn: () => api.instrumentSearch(query),
queryKey: QK.instrumentSearch(query, 'stock,etf'),
queryFn: () => api.instrumentSearch(query, 20, 'stock,etf'),
enabled: query.trim().length > 0,
staleTime: 30_000,
})
@@ -273,6 +273,9 @@ function StockSearchBox({
>
<span className="font-mono shrink-0 w-[80px]">{r.symbol}</span>
<span className="truncate text-secondary flex-1">{r.name}</span>
{r.asset_type === 'etf' && (
<span className="shrink-0 px-1 py-0.5 rounded text-[10px] leading-none bg-accent/10 text-accent">ETF</span>
)}
</button>
<button
type="button"