看股功能更新

This commit is contained in:
2026-08-15 15:36:11 +08:00
parent c1c43d2ff7
commit 9cce670b74
26 changed files with 957 additions and 427 deletions

View File

@@ -12,6 +12,7 @@ from __future__ import annotations
import bisect import bisect
import json import json
from datetime import datetime
import pandas as pd import pandas as pd
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
@@ -86,7 +87,13 @@ def _series_to_jsonable(s: pd.Series) -> list[float | None]:
def _rows_to_bars(rows) -> list[Bar]: def _rows_to_bars(rows) -> list[Bar]:
return [Bar(ts=r.ts, open=r.open, high=r.high, low=r.low, close=r.close, volume=r.volume) for r in rows] return [
Bar(
ts=r.ts, open=r.open, high=r.high, low=r.low, close=r.close, volume=r.volume,
amount=getattr(r, "amount", None), turnover=getattr(r, "turnover", None),
)
for r in rows
]
_ADJUST_MODES = ("bfq", "qfq", "hfq") _ADJUST_MODES = ("bfq", "qfq", "hfq")
@@ -120,6 +127,8 @@ def _adjust_bars(bars: list[Bar], factors, from_mode: str, to_mode: str) -> list
open=round(b.open * m, 3), high=round(b.high * m, 3), open=round(b.open * m, 3), high=round(b.high * m, 3),
low=round(b.low * m, 3), close=round(b.close * m, 3), low=round(b.low * m, 3), close=round(b.close * m, 3),
volume=b.volume, volume=b.volume,
# 成交额/换手率是名义量,不随复权缩放
amount=b.amount, turnover=b.turnover,
)) ))
return out return out
@@ -131,10 +140,14 @@ async def get_candles(
limit: int = 5000, limit: int = 5000,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> list[CandleOut]: ) -> list[CandleOut]:
# 始终以日线为基底,再聚合到目标周期 # 始终以日线为基底,再聚合到目标周期(取最新 limit 根)
rows = await repository.get_candles(session, symbol, "1d", limit=limit) rows = await repository.get_recent_candles(session, symbol, "1d", limit=limit)
bars = resample_bars(_rows_to_bars(rows), timeframe) bars = resample_bars(_rows_to_bars(rows), timeframe)
return [CandleOut(ts=b.ts, open=b.open, high=b.high, low=b.low, close=b.close, volume=b.volume) for b in bars] return [
CandleOut(ts=b.ts, open=b.open, high=b.high, low=b.low, close=b.close,
volume=b.volume, amount=b.amount, turnover=b.turnover)
for b in bars
]
@router.post("/data/sync", response_model=SyncResponse) @router.post("/data/sync", response_model=SyncResponse)
@@ -305,7 +318,9 @@ async def backtest(
candles = [ candles = [
CandleOut(ts=r["ts"], open=r["open"], high=r["high"], low=r["low"], CandleOut(ts=r["ts"], open=r["open"], high=r["high"], low=r["low"],
close=r["close"], volume=r["volume"]) close=r["close"], volume=r["volume"],
amount=r["amount"] if "amount" in df.columns else None,
turnover=r["turnover"] if "turnover" in df.columns else None)
for _, r in df.iterrows() for _, r in df.iterrows()
] ]
signals = [ signals = [
@@ -576,11 +591,14 @@ async def screener_sync_status(session: AsyncSession = Depends(get_session)) ->
@router.get("/screener/preview/{ts_code}", response_model=PreviewResponse) @router.get("/screener/preview/{ts_code}", response_model=PreviewResponse)
async def screener_preview( async def screener_preview(
ts_code: str, limit: int = 500, adjust: str = "qfq", timeframe: str = "1d", mas: str = "5,10,20,60", ts_code: str, limit: int = 500, adjust: str = "qfq", timeframe: str = "1d", mas: str = "5,10,20,60",
end: str | None = None,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> PreviewResponse: ) -> PreviewResponse:
"""个股详情预览日线candles 不复权底座 + adj_factor 本地换算 bfq/qfq/hfq """个股详情预览日线candles 不复权底座 + adj_factor 本地换算 bfq/qfq/hfq
未缓存自动拉取,失败退 market_daily 近段)+ 全套指标 + 最新截面信息卡。 未缓存自动拉取,失败退 market_daily 近段)+ 全套指标 + 最新截面信息卡。
timeframe 聚合到周/月/年先复权再聚合mas 指定主图 MA 周期(逗号分隔)。""" timeframe 聚合到周/月/年先复权再聚合mas 指定主图 MA 周期(逗号分隔)。
end=YYYY-MM-DD 时为「向前翻页」:返回该日之前最近 limit 根(含预热计算指标),
has_more 标记窗口前是否还有更早历史,前端据此继续向左滚动加载。"""
if adjust not in _ADJUST_MODES: if adjust not in _ADJUST_MODES:
raise HTTPException(status_code=400, detail=f"adjust 仅支持 {'/'.join(_ADJUST_MODES)}") raise HTTPException(status_code=400, detail=f"adjust 仅支持 {'/'.join(_ADJUST_MODES)}")
if timeframe not in ("1d", "1w", "1M", "1y"): if timeframe not in ("1d", "1w", "1M", "1y"):
@@ -591,6 +609,13 @@ async def screener_preview(
raise HTTPException(status_code=400, detail="mas 格式应为逗号分隔的数字,如 5,10,20,60") raise HTTPException(status_code=400, detail="mas 格式应为逗号分隔的数字,如 5,10,20,60")
if not ma_periods: if not ma_periods:
ma_periods = [5, 10, 20, 60] ma_periods = [5, 10, 20, 60]
limit = max(30, min(limit, 5000))
end_dt: datetime | None = None
if end:
try:
end_dt = datetime.strptime(end.strip()[:10], "%Y-%m-%d")
except ValueError:
raise HTTPException(status_code=400, detail="end 格式应为 YYYY-MM-DD")
symbol = plain_code(ts_code) symbol = plain_code(ts_code)
# 先取 market_daily 最新行:既做缓存过期判断,也做信息卡数据源 # 先取 market_daily 最新行:既做缓存过期判断,也做信息卡数据源
@@ -601,36 +626,48 @@ async def screener_preview(
).scalars().first() ).scalars().first()
# --- 日线candles(不复权底座) 优先;未缓存拉取,缓存落后于全市场最新交易日则强制刷新(每日至多一次) --- # --- 日线candles(不复权底座) 优先;未缓存拉取,缓存落后于全市场最新交易日则强制刷新(每日至多一次) ---
# fetcher 增量拉取写入的是 qfqsettings.data_adjust此时底座模式记qfq # fetcher 现在只做「不复权」增量 upsert底座口径恒bfqTDX 全量 + Tushare 增量),
rows = await repository.get_candles(session, symbol, "1d", limit=100000) # 复权qfq/hfq读取时按 adj_factor 表本地换算mode 无需再推断。
# 每次只取「窗口 + 800 根预热」行MA250/MACD EMA 在 800 根内充分收敛),不拉全量:
# 首屏 ~500 根秒开,向左滚动时按 end 参数逐页向前翻。
frame_mult = {"1d": 1, "1w": 6, "1M": 24, "1y": 280}[timeframe]
fetch_n = min(100000, limit * frame_mult + 800)
source = "bfq" source = "bfq"
mode = "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: try:
if not rows: if not rows:
await fetcher.sync_symbol(session, symbol, source="auto") await fetcher.sync_symbol(session, symbol, source="auto")
rows = await repository.get_candles(session, symbol, "1d", limit=100000) rows = await repository.get_recent_candles(session, symbol, "1d", limit=fetch_n)
mode = settings.data_adjust if settings.data_adjust in _ADJUST_MODES else "qfq"
elif md is not None and rows and rows[-1].ts.date() < md.trade_date.date(): elif md is not None and rows and rows[-1].ts.date() < md.trade_date.date():
await fetcher.sync_symbol(session, symbol, source="auto", force=True) await fetcher.sync_symbol(session, symbol, source="auto", force=True)
rows = await repository.get_candles(session, symbol, "1d", limit=100000) rows = await repository.get_recent_candles(session, symbol, "1d", limit=fetch_n)
mode = settings.data_adjust if settings.data_adjust in _ADJUST_MODES else "qfq"
except Exception: # noqa: BLE001 —— tushare/写库失败时回滚会话(否则毒化后兜底查询 500 except Exception: # noqa: BLE001 —— tushare/写库失败时回滚会话(否则毒化后兜底查询 500
await session.rollback() await session.rollback()
if not rows: if not rows:
rows = [] rows = []
bars = _rows_to_bars(rows) bars = _rows_to_bars(rows)
if not bars: if not bars and end_dt is None:
source = "market" source = "market"
res = await session.execute( res = await session.execute(
select(MarketDaily).where(MarketDaily.ts_code == ts_code).order_by(MarketDaily.trade_date) select(MarketDaily).where(MarketDaily.ts_code == ts_code).order_by(MarketDaily.trade_date)
) )
bars = [ bars = [
Bar(ts=r.trade_date, open=r.open, high=r.high, low=r.low, close=r.close, volume=r.vol * 100.0) Bar(
ts=r.trade_date, open=r.open, high=r.high, low=r.low, close=r.close,
volume=r.vol * 100.0, amount=r.amount * 1000.0 if r.amount else None, # 千元 -> 元
)
for r in res.scalars() for r in res.scalars()
] ]
if not bars: if not bars and end_dt is None:
raise HTTPException(status_code=404, detail=f"无数据: {ts_code}(可先点「同步市场数据」)") raise HTTPException(status_code=404, detail=f"无数据: {ts_code}(可先点「同步市场数据」)")
# 翻页到底end 之前无数据):返回空页 + has_more=False前端停止向前翻页
# --- 复权换算:请求模式与底座模式不同时按 adj_factor 本地换算(无因子则维持原样) --- # --- 复权换算:请求模式与底座模式不同时按 adj_factor 本地换算(无因子则维持原样) ---
if adjust != mode: if adjust != mode:
@@ -648,13 +685,16 @@ async def screener_preview(
# --- 周期聚合:复权之后按日历聚合到周/月/年,指标在聚合后的序列上计算 --- # --- 周期聚合:复权之后按日历聚合到周/月/年,指标在聚合后的序列上计算 ---
bars = resample_bars(bars, timeframe) bars = resample_bars(bars, timeframe)
# --- 指标(在全量历史上计算后截尾,保证预热正确) --- # --- 指标(在预热窗口上计算后截尾,保证预热正确;翻页到底的空页跳过 ---
has_more = len(bars) > limit # 返回窗口之前还有更早历史(含预热行)
indicators: dict[str, dict[str, list[float | None]]] = {}
if bars:
df = pd.DataFrame({"close": [b.close for b in bars], "high": [b.high for b in bars], "low": [b.low for b in bars]}) df = pd.DataFrame({"close": [b.close for b in bars], "high": [b.high for b in bars], "low": [b.low for b in bars]})
closes, highs, lows = df["close"], df["high"], df["low"] closes, highs, lows = df["close"], df["high"], df["low"]
macd = ind.macd(closes) macd = ind.macd(closes)
kdj = ind.kdj(highs, lows, closes) kdj = ind.kdj(highs, lows, closes)
boll = ind.bollinger(closes) boll = ind.bollinger(closes)
indicators: dict[str, dict[str, list[float | None]]] = { indicators = {
"ma": {f"ma{p}": _series_to_jsonable(ind.ma(closes, p)) for p in ma_periods}, "ma": {f"ma{p}": _series_to_jsonable(ind.ma(closes, p)) for p in ma_periods},
"macd": { "macd": {
"dif": _series_to_jsonable(macd["macd"]), "dif": _series_to_jsonable(macd["macd"]),
@@ -724,7 +764,8 @@ async def screener_preview(
) )
candles = [ candles = [
CandleOut(ts=b.ts, open=b.open, high=b.high, low=b.low, close=b.close, volume=b.volume) CandleOut(ts=b.ts, open=b.open, high=b.high, low=b.low, close=b.close,
volume=b.volume, amount=b.amount, turnover=b.turnover)
for b in bars[-limit:] for b in bars[-limit:]
] ]
return PreviewResponse(ts_code=ts_code, symbol=symbol, source=source, info=info, candles=candles, indicators=indicators) return PreviewResponse(ts_code=ts_code, symbol=symbol, source=source, info=info, candles=candles, indicators=indicators, has_more=has_more)

View File

@@ -4,6 +4,7 @@
MVP 在应用层用 pandas resample 即可,逻辑等价、便于切换。 MVP 在应用层用 pandas resample 即可,逻辑等价、便于切换。
OHLCV 聚合规则:开=周期内首根开、高=最高、低=最低、收=末根收、量=求和。 OHLCV 聚合规则:开=周期内首根开、高=最高、低=最低、收=末根收、量=求和。
成交额/换手率为名义量:求和(全缺则保持 None不伪造 0
""" """
from __future__ import annotations from __future__ import annotations
@@ -22,6 +23,13 @@ def bars_per_year(timeframe: str) -> int:
return _BARS_PER_YEAR.get(timeframe, 252) return _BARS_PER_YEAR.get(timeframe, 252)
def _sum_or_none(s: pd.Series):
"""求和;全为 NaN 返回 None部分缺失则忽略缺失项求和"""
if s.isna().all():
return None
return float(s.sum())
def resample_bars(bars: list[Bar], timeframe: str) -> list[Bar]: def resample_bars(bars: list[Bar], timeframe: str) -> list[Bar]:
"""把日线 bars 聚合为目标周期;日线或未知周期原样返回。""" """把日线 bars 聚合为目标周期;日线或未知周期原样返回。"""
if not bars or timeframe in ("1d", "d", "day", "", None): if not bars or timeframe in ("1d", "d", "day", "", None):
@@ -31,14 +39,18 @@ def resample_bars(bars: list[Bar], timeframe: str) -> list[Bar]:
return bars return bars
df = pd.DataFrame( df = pd.DataFrame(
[{"ts": b.ts, "open": b.open, "high": b.high, "low": b.low, "close": b.close, "volume": b.volume} [{"ts": b.ts, "open": b.open, "high": b.high, "low": b.low, "close": b.close,
"volume": b.volume,
"amount": b.amount if b.amount is not None else float("nan"),
"turnover": b.turnover if b.turnover is not None else float("nan")}
for b in bars] for b in bars]
).set_index("ts").sort_index() ).set_index("ts").sort_index()
agg = ( agg = (
df.resample(rule) df.resample(rule)
.agg({"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}) .agg({"open": "first", "high": "max", "low": "min", "close": "last",
.dropna() "volume": "sum", "amount": _sum_or_none, "turnover": _sum_or_none})
.dropna(subset=["open"])
) )
return [ return [
@@ -49,6 +61,8 @@ def resample_bars(bars: list[Bar], timeframe: str) -> list[Bar]:
low=float(row["low"]), low=float(row["low"]),
close=float(row["close"]), close=float(row["close"]),
volume=float(row["volume"]), volume=float(row["volume"]),
amount=row["amount"] if row["amount"] == row["amount"] else None, # NaN -> None
turnover=row["turnover"] if row["turnover"] == row["turnover"] else None,
) )
for ts, row in agg.iterrows() for ts, row in agg.iterrows()
] ]

View File

@@ -27,12 +27,14 @@ def fetch_daily(code: str, start: str = "20200101", end: str | None = None,
bars: list[Bar] = [] bars: list[Bar] = []
for _, r in df.iterrows(): for _, r in df.iterrows():
amt = r.get("成交额")
bars.append( bars.append(
Bar( Bar(
ts=datetime.strptime(str(r["日期"]), "%Y-%m-%d"), ts=datetime.strptime(str(r["日期"]), "%Y-%m-%d"),
open=float(r["开盘"]), high=float(r["最高"]), open=float(r["开盘"]), high=float(r["最高"]),
low=float(r["最低"]), close=float(r["收盘"]), low=float(r["最低"]), close=float(r["收盘"]),
volume=float(r["成交量"]) * 100.0, # AKShare 成交量单位为手 -> 股 volume=float(r["成交量"]) * 100.0, # AKShare 成交量单位为手 -> 股
amount=float(amt) if amt is not None and amt == amt else None, # AKShare 成交额单位为元
) )
) )
return bars return bars

View File

@@ -1,12 +1,15 @@
"""数据编排拉取Tushare 主 -> AKShare 兜底)+ 本地缓存。 """数据编排拉取Tushare 主 -> AKShare 兜底)+ 本地缓存。
真实行情落库到 candles 表timeframe='1d'),回测统一从库读。 真实行情落库到 candles 表timeframe='1d'**不复权底座**),回测统一从库读。
复权qfq/hfq在读取时按 adj_factor 表本地换算,见 api._adjust_bars。
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from sqlalchemy import delete, func, select from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings from ..config import settings
@@ -40,6 +43,13 @@ async def is_cached(session: AsyncSession, symbol: str) -> bool:
return await count_cached(session, symbol) > 0 return await count_cached(session, symbol) > 0
async def _last_cached_ts(session: AsyncSession, symbol: str):
res = await session.execute(
select(func.max(Candle.ts)).where(Candle.symbol == symbol, Candle.timeframe == "1d")
)
return res.scalar()
async def sync_symbol( async def sync_symbol(
session: AsyncSession, session: AsyncSession,
code: str, code: str,
@@ -48,34 +58,57 @@ async def sync_symbol(
source: str = "auto", source: str = "auto",
force: bool = False, force: bool = False,
) -> dict: ) -> dict:
"""拉取并缓存某标的日线。已缓存且非 force 时直接返回缓存计数。""" """增量拉取并 upsert 某标的日线(**不复权**底座)。
if not force and await is_cached(session, code):
- 永不删除已有行:按 (symbol, timeframe, ts) 主键 upsert
不会把 TDX 导入的 30 年历史冲掉;
- 已缓存时从最后一根的次日开始增量拉取force 仅跳过「有缓存就返回」
的短路,用于缓存落后于最新交易日时的刷新);
- 拉不到新行时保持原缓存不动。
"""
last_ts = await _last_cached_ts(session, code)
if last_ts is not None and not force and not start:
return {"symbol": code, "bars": await count_cached(session, code), "source": "cache"} return {"symbol": code, "bars": await count_cached(session, code), "source": "cache"}
if last_ts is not None and not start:
# 增量:从缓存最后一根当天开始(重叠一天重新拉取,容忍数据源漏行/盘后修订)
start = last_ts.strftime("%Y%m%d")
start = start or DEFAULT_START start = start or DEFAULT_START
adjust = settings.data_adjust
errors: list[str] = [] errors: list[str] = []
bars: list[Bar] = [] bars: list[Bar] = []
used = None used = None
for name, fn in _providers(source): for name, fn in _providers(source):
try: try:
# tushare/akshare 是同步网络 IO丢到线程池避免阻塞事件循环 # tushare/akshare 是同步网络 IO丢到线程池避免阻塞事件循环
bars = await asyncio.to_thread(fn, code, start, end, adjust) # adjust=None -> 不复权(复权在读取时按 adj_factor 换算)
bars = await asyncio.to_thread(fn, code, start, end, None)
used = name used = name
break break
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
errors.append(f"{name}: {e}") errors.append(f"{name}: {e}")
if not bars: if not bars:
if last_ts is not None:
# 增量失败(如停牌/新股无新行):保留缓存,不算错误
return {"symbol": code, "bars": await count_cached(session, code), "source": "cache"}
raise RuntimeError("所有数据源均失败 -> " + " | ".join(errors) if errors else "无可用数据源") raise RuntimeError("所有数据源均失败 -> " + " | ".join(errors) if errors else "无可用数据源")
# 全量替换该标的日线(避免重复主键 # upsert不 delete避免破坏既有底座TDX 全量历史
await session.execute(delete(Candle).where(Candle.symbol == code, Candle.timeframe == "1d")) stmt = pg_insert(Candle).values([
for b in bars: {"symbol": code, "timeframe": "1d", "ts": b.ts, "open": b.open, "high": b.high,
session.add( "low": b.low, "close": b.close, "volume": b.volume,
Candle(symbol=code, timeframe="1d", ts=b.ts, open=b.open, high=b.high, "amount": b.amount, "turnover": b.turnover}
low=b.low, close=b.close, volume=b.volume) for b in bars
])
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,
# 增量源缺失额/换手时保留库里的旧值(如 TDX 已回补的 30 年成交额)
"amount": func.coalesce(stmt.excluded.amount, Candle.amount),
"turnover": func.coalesce(stmt.excluded.turnover, Candle.turnover)},
) )
await session.execute(stmt)
await session.commit() await session.commit()
return {"symbol": code, "bars": len(bars), "source": used} return {"symbol": code, "bars": len(bars), "source": used}

View File

@@ -32,3 +32,38 @@ async def get_candles(
stmt = stmt.order_by(Candle.ts.asc()).limit(limit) stmt = stmt.order_by(Candle.ts.asc()).limit(limit)
result = await session.execute(stmt) result = await session.execute(stmt)
return list(result.scalars().all()) return list(result.scalars().all())
async def get_recent_candles(
session: AsyncSession,
symbol: str,
timeframe: str = "1d",
limit: int = 5000,
) -> list[Candle]:
"""取最近 limit 根 K 线(含最新交易日),按时间升序返回。"""
stmt = (
select(Candle)
.where(Candle.symbol == symbol, Candle.timeframe == timeframe)
.order_by(Candle.ts.desc())
.limit(limit)
)
result = await session.execute(stmt)
return list(reversed(result.scalars().all()))
async def get_candles_before(
session: AsyncSession,
symbol: str,
timeframe: str,
before: datetime,
limit: int = 5000,
) -> list[Candle]:
"""取 before 之前(不含)的最近 limit 根 K 线,按时间升序返回(历史向前翻页用)。"""
stmt = (
select(Candle)
.where(Candle.symbol == symbol, Candle.timeframe == timeframe, Candle.ts < before)
.order_by(Candle.ts.desc())
.limit(limit)
)
result = await session.execute(stmt)
return list(reversed(result.scalars().all()))

View File

@@ -40,12 +40,14 @@ def fetch_daily(code: str, start: str = "20200101", end: str | None = None,
df = df.sort_values("trade_date") df = df.sort_values("trade_date")
bars: list[Bar] = [] bars: list[Bar] = []
for _, r in df.iterrows(): for _, r in df.iterrows():
amt = r.get("amount")
bars.append( bars.append(
Bar( Bar(
ts=_parse(r["trade_date"]), ts=_parse(r["trade_date"]),
open=float(r["open"]), high=float(r["high"]), open=float(r["open"]), high=float(r["high"]),
low=float(r["low"]), close=float(r["close"]), low=float(r["low"]), close=float(r["close"]),
volume=float(r["vol"]) * 100.0, # Tushare vol 单位为手 -> 股 volume=float(r["vol"]) * 100.0, # Tushare vol 单位为手 -> 股
amount=float(amt) * 1000.0 if amt is not None and amt == amt else None, # 千元 -> 元
) )
) )
return bars return bars

View File

@@ -30,13 +30,19 @@ class Timeframe(str, Enum):
@dataclass(frozen=True) @dataclass(frozen=True)
class Bar: class Bar:
"""一根 K 线OHLCV + 时间戳)。复权标识后续扩展。""" """一根 K 线OHLCV + 时间戳)。复权标识后续扩展。
amount成交额与 turnover换手率 %)是名义量,
不随复权换算缩放;周期聚合时求和。缺数据为 None。
"""
ts: datetime ts: datetime
open: float open: float
high: float high: float
low: float low: float
close: float close: float
volume: float volume: float
amount: float | None = None
turnover: float | None = None
@dataclass(frozen=True) @dataclass(frozen=True)

View File

@@ -33,6 +33,8 @@ class Candle(Base):
low: Mapped[float] = mapped_column(Float) low: Mapped[float] = mapped_column(Float)
close: Mapped[float] = mapped_column(Float) close: Mapped[float] = mapped_column(Float)
volume: Mapped[float] = mapped_column(Float) volume: Mapped[float] = mapped_column(Float)
amount: Mapped[float | None] = mapped_column(Float) # 成交额TDX 原生 float32
turnover: Mapped[float | None] = mapped_column(Float) # 换手率 %daily_basic2000 年起)
__table_args__ = ( __table_args__ = (
UniqueConstraint("symbol", "timeframe", "ts", name="uq_candle_sym_tf_ts"), UniqueConstraint("symbol", "timeframe", "ts", name="uq_candle_sym_tf_ts"),

View File

@@ -18,6 +18,8 @@ class CandleOut(BaseModel):
low: float low: float
close: float close: float
volume: float volume: float
amount: float | None = None # 成交额(元);无数据为 null
turnover: float | None = None # 换手率 %;无数据为 null
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@@ -264,6 +266,7 @@ class PreviewResponse(BaseModel):
candles: list[CandleOut] candles: list[CandleOut]
indicators: dict[str, dict[str, list[float | None]]] = Field(default_factory=dict) indicators: dict[str, dict[str, list[float | None]]] = Field(default_factory=dict)
# indicators 形如 {"ma": {"ma5": [...], ...}, "macd": {"dif": ...}, "kdj": {...}, "rsi": {...}, "boll": {...}} # indicators 形如 {"ma": {"ma5": [...], ...}, "macd": {"dif": ...}, "kdj": {...}, "rsi": {...}, "boll": {...}}
has_more: bool = False # 返回窗口之前是否还有更早历史(前端向左滚动翻页用)
# ---------- Auth ---------- # ---------- Auth ----------

View File

@@ -3,9 +3,11 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#000000" />
<title>选股训练营</title> <title>选股训练营</title>
<style> <style>
html, body { background-color: #f8fafc; margin: 0; } html, body { background-color: #000000; margin: 0; }
</style> </style>
</head> </head>
<body> <body>

View File

@@ -10,6 +10,7 @@
"type-check": "vue-tsc --noEmit" "type-check": "vue-tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@klinecharts/extension": "^0.1.0",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"klinecharts": "^10.0.2", "klinecharts": "^10.0.2",
"pinia": "^2.3.0", "pinia": "^2.3.0",

View File

@@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
'@klinecharts/extension':
specifier: ^0.1.0
version: 0.1.0(klinecharts@10.0.2)
echarts: echarts:
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.1.0 version: 6.1.0
@@ -236,6 +239,11 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@klinecharts/extension@0.1.0':
resolution: {integrity: sha512-erJtbq4MBpa1EGYgjR7TNZQ9Rh0RoNGRt4lHFQlCjNolETsA6Xx6+puj7Q7JLTT4rWS4SZJ/UXh2I/4+aRZsUQ==}
peerDependencies:
klinecharts: '>=10.0.0'
'@napi-rs/lzma-linux-x64-gnu@1.5.1': '@napi-rs/lzma-linux-x64-gnu@1.5.1':
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
engines: {node: ^22.20 || ^24.12 || >=25} engines: {node: ^22.20 || ^24.12 || >=25}
@@ -943,6 +951,10 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@klinecharts/extension@0.1.0(klinecharts@10.0.2)':
dependencies:
klinecharts: 10.0.2
'@napi-rs/lzma-linux-x64-gnu@1.5.1': '@napi-rs/lzma-linux-x64-gnu@1.5.1':
optional: true optional: true

View File

@@ -25,30 +25,30 @@ async function signOut() {
</script> </script>
<template> <template>
<div v-if="!auth.initialized" class="grid min-h-screen place-items-center bg-slate-50" aria-label="正在验证登录状态"> <div v-if="!auth.initialized" class="grid min-h-screen place-items-center bg-black" aria-label="正在验证登录状态">
<span class="login-spinner border-slate-300 border-t-blue-600" aria-hidden="true" /> <span class="login-spinner border-[#33353D] border-t-blue-600" aria-hidden="true" />
</div> </div>
<RouterView v-else-if="isLogin" /> <RouterView v-else-if="isLogin" />
<div v-else class="min-h-screen"> <div v-else class="min-h-screen">
<header class="sticky top-0 z-30 border-b border-slate-200 bg-white/90 backdrop-blur"> <header class="sticky top-0 z-30 border-b border-[#26272E] bg-black/85 backdrop-blur">
<div class="mx-auto flex h-8 max-w-[1400px] items-center justify-between px-5"> <div class="mx-auto flex h-8 max-w-[1400px] items-center justify-between px-5">
<button <button
v-if="!isHome" v-if="!isHome"
type="button" type="button"
class="flex items-center gap-1.5 text-sm font-medium text-slate-500 transition-colors hover:text-slate-900" class="flex items-center gap-1.5 text-sm font-medium text-[#A8AFB8] transition-colors hover:text-white"
@click="router.push({ name: 'home' })" @click="router.push({ name: 'home' })"
> >
<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="M19 12H5M11 18l-6-6 6-6" /></svg> <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="M19 12H5M11 18l-6-6 6-6" /></svg>
主页 主页
</button> </button>
<span v-else class="text-sm font-medium text-slate-500">Stock</span> <span v-else class="text-sm font-medium text-[#A8AFB8]">Stock</span>
<div class="flex items-center gap-3 text-xs text-slate-400"> <div class="flex items-center gap-3 text-[13px] text-[#9BA3AE]">
<span>{{ auth.user?.username }}</span> <span>{{ auth.user?.username }}</span>
<span class="h-3 w-px bg-slate-200" aria-hidden="true" /> <span class="h-3 w-px bg-[#26272E]" aria-hidden="true" />
<button <button
type="button" type="button"
class="rounded p-1 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900" class="rounded p-1 text-[#A8AFB8] transition-colors hover:bg-[#1E2026] hover:text-white"
title="设置" title="设置"
@click="showSettings = true" @click="showSettings = true"
> >
@@ -57,7 +57,7 @@ async function signOut() {
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09a1.65 1.65 0 00-1-1.51 1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09a1.65 1.65 0 001.51-1 1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33h0a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51h0a1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82v0a1.65 1.65 0 001.51 1H21a2 2 0 110 4h-.09a1.65 1.65 0 00-1.51 1z" /> <path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09a1.65 1.65 0 00-1-1.51 1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09a1.65 1.65 0 001.51-1 1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33h0a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51h0a1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82v0a1.65 1.65 0 001.51 1H21a2 2 0 110 4h-.09a1.65 1.65 0 00-1.51 1z" />
</svg> </svg>
</button> </button>
<button type="button" class="font-medium text-slate-500 hover:text-slate-900 disabled:opacity-50" :disabled="loggingOut" @click="signOut"> <button type="button" class="font-medium text-[#A8AFB8] hover:text-white disabled:opacity-50" :disabled="loggingOut" @click="signOut">
{{ loggingOut ? '退出中' : '退出' }} {{ loggingOut ? '退出中' : '退出' }}
</button> </button>
</div> </div>
@@ -69,7 +69,7 @@ async function signOut() {
<RouterView /> <RouterView />
</main> </main>
<footer v-if="!isHome" class="mx-auto max-w-[1400px] px-5 pb-8 pt-2 text-center text-xs text-slate-400"> <footer v-if="!isHome" class="mx-auto max-w-[1400px] px-5 pb-8 pt-2 text-center text-[13px] text-[#9BA3AE]">
数据来源 Tushare · 仅供研究学习不构成投资建议 数据来源 Tushare · 仅供研究学习不构成投资建议
</footer> </footer>
</div> </div>

View File

@@ -135,14 +135,17 @@ export async function getStockPreview(
adjust?: AdjustMode; adjust?: AdjustMode;
timeframe?: Timeframe; timeframe?: Timeframe;
mas?: number[]; mas?: number[];
/** 向前翻页:返回该日期(不含)之前的 limit 根 K 线 + 预热好的指标 */
end?: string;
} = {}, } = {},
): Promise<PreviewResponse> { ): Promise<PreviewResponse> {
const { limit = 10000, adjust = 'qfq', timeframe = '1d', mas } = opts; const { limit = 500, adjust = 'qfq', timeframe = '1d', mas, end } = opts;
const q = new URLSearchParams({ const q = new URLSearchParams({
limit: String(limit), limit: String(limit),
adjust, adjust,
timeframe, timeframe,
...(mas?.length ? { mas: mas.join(',') } : {}), ...(mas?.length ? { mas: mas.join(',') } : {}),
...(end ? { end } : {}),
}); });
const res = await apiFetch(`/api/screener/preview/${encodeURIComponent(tsCode)}?${q.toString()}`); const res = await apiFetch(`/api/screener/preview/${encodeURIComponent(tsCode)}?${q.toString()}`);
if (!res.ok) throw new ApiError(await readError(res, `获取个股详情失败 (HTTP ${res.status})`), res.status); if (!res.ok) throw new ApiError(await readError(res, `获取个股详情失败 (HTTP ${res.status})`), res.status);

View File

@@ -23,6 +23,8 @@ export interface Candle {
low: number; low: number;
close: number; close: number;
volume: number; volume: number;
amount?: number | null; // 成交额TDX/接口缺失时为 null
turnover?: number | null; // 换手率(%daily_basic 缺失时为 null
} }
export interface BacktestRequest { export interface BacktestRequest {
@@ -195,6 +197,7 @@ export interface PreviewResponse {
info: PreviewInfo; info: PreviewInfo;
candles: Candle[]; candles: Candle[];
indicators: Record<string, Record<string, (number | null)[]>>; indicators: Record<string, Record<string, (number | null)[]>>;
has_more?: boolean; // 返回窗口之前是否还有更早历史(前端向左滚动翻页用)
} }
// ---------- 股票列表(全市场浏览) ---------- // ---------- 股票列表(全市场浏览) ----------
@@ -231,12 +234,19 @@ export interface StockFacets {
export type Timeframe = '1d' | '1w' | '1M' | '1y'; export type Timeframe = '1d' | '1w' | '1M' | '1y';
export type AdjustMode = 'bfq' | 'qfq' | 'hfq'; export type AdjustMode = 'bfq' | 'qfq' | 'hfq';
/** K线浮层可选指标鼠标悬停信息框逐行显示 */
export type TooltipField =
| 'open' | 'high' | 'low' | 'close'
| 'diff' | 'chg' | 'amp'
| 'vol' | 'amount' | 'turnover';
/** 看股页图表布局偏好(存 user_preferences.chartLayout */ /** 看股页图表布局偏好(存 user_preferences.chartLayout */
export interface ChartLayoutPrefs { export interface ChartLayoutPrefs {
maPeriods: number[]; maPeriods: number[];
subPanes: string[]; // 'vol' | 'macd' | 'kdj' | 'rsi'(顺序即面板顺序) subPanes: string[]; // 'vol' | 'macd' | 'kdj' | 'rsi'(顺序即面板顺序)
subHeights: Record<string, number>; // 面板高度 px subHeights: Record<string, number>; // 面板高度 px
timeframe?: Timeframe; timeframe?: Timeframe;
tooltipFields?: TooltipField[]; // 浮层显示的指标(顺序即行序;空数组=仅日期头)
} }
export interface ScreenerQueryItem { export interface ScreenerQueryItem {

View File

@@ -23,12 +23,12 @@ function lookbackText(c: { lookback?: number; match?: string }) {
<template> <template>
<div class="flex flex-wrap items-center gap-1.5"> <div class="flex flex-wrap items-center gap-1.5">
<span class="mr-1 text-xs text-slate-400">解析条件</span> <span class="mr-1 text-[13px] text-[#9BA3AE]">解析条件</span>
<span <span
v-for="(c, i) in conditions.indicator" v-for="(c, i) in conditions.indicator"
:key="'i' + i" :key="'i' + i"
class="inline-flex items-center gap-1.5 rounded-full border border-blue-200 bg-blue-50 px-3 py-1 text-xs text-blue-900" class="inline-flex items-center gap-1.5 rounded-full border border-blue-500/30 bg-blue-500/15 px-3 py-1 text-[13px] text-blue-300"
> >
<span class="h-1.5 w-1.5 rounded-full bg-blue-500"></span> <span class="h-1.5 w-1.5 rounded-full bg-blue-500"></span>
{{ c.indicator }}{{ paramsStr(c.params) }} {{ c.indicator }}{{ paramsStr(c.params) }}
@@ -36,13 +36,13 @@ function lookbackText(c: { lookback?: number; match?: string }) {
<template v-if="c.value_indicator">{{ c.value_indicator }}{{ paramsStr(c.value_params) }}</template> <template v-if="c.value_indicator">{{ c.value_indicator }}{{ paramsStr(c.value_params) }}</template>
<template v-else-if="c.op === 'between' && c.value2">{{ c.value }} ~ {{ c.value2 }}</template> <template v-else-if="c.op === 'between' && c.value2">{{ c.value }} ~ {{ c.value2 }}</template>
<template v-else>{{ c.value }}</template> <template v-else>{{ c.value }}</template>
<span class="text-blue-300">· {{ lookbackText(c) }}</span> <span class="text-blue-300/70">· {{ lookbackText(c) }}</span>
</span> </span>
<span <span
v-for="(c, i) in conditions.snapshot" v-for="(c, i) in conditions.snapshot"
:key="'s' + i" :key="'s' + i"
class="inline-flex items-center gap-1.5 rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs text-amber-900" class="inline-flex items-center gap-1.5 rounded-full border border-amber-500/30 bg-amber-500/15 px-3 py-1 text-[13px] text-amber-300"
> >
<span class="h-1.5 w-1.5 rounded-full bg-amber-500"></span> <span class="h-1.5 w-1.5 rounded-full bg-amber-500"></span>
{{ FIELD_TEXT[c.field] ?? c.field }} {{ FIELD_TEXT[c.field] ?? c.field }}
@@ -51,7 +51,7 @@ function lookbackText(c: { lookback?: number; match?: string }) {
<template v-else>{{ c.value }}</template> <template v-else>{{ c.value }}</template>
</span> </span>
<span class="inline-flex items-center rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs text-slate-400"> <span class="inline-flex items-center rounded-full border border-[#26272E] bg-[#1E2026] px-3 py-1 text-[13px] text-[#9BA3AE]">
排除ST · 退市<span v-if="conditions.exclude_bj"> · 北交所</span> 排除ST · 退市<span v-if="conditions.exclude_bj"> · 北交所</span>
</span> </span>
</div> </div>

View File

@@ -1,13 +1,38 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { dispose, init, registerIndicator, type Chart, type KLineData } from 'klinecharts'; import {
import { useSettingsStore } from '@/stores/settings'; dispose, init, registerIndicator, registerOverlay,
import type { Candle } from '@/api/types'; type Chart, type KLineData, type Point,
} from 'klinecharts';
// 官方画线扩展preview.klinecharts.com 同款工具集rect/circle 沿用 v10 内置版,不注册扩展的重名模板
import {
abcd, anyWaves, arrow, eightWaves, fibonacciCircle, fibonacciExtension, fibonacciSegment,
fibonacciSpeedResistanceFan, fibonacciSpiral, fiveWaves, gannBox, measure, parallelogram,
threeWaves, triangle, xabcd,
} from '@klinecharts/extension';
import { useSettingsStore, TOOLTIP_FIELDS, DEFAULT_TOOLTIP_FIELDS } from '@/stores/settings';
import type { Candle, TooltipField } from '@/api/types';
// 扩展画线模板一次性全局注册registerOverlay 是全局的,模块加载时执行一次即可)
for (const t of [
abcd, xabcd, threeWaves, fiveWaves, eightWaves, anyWaves, arrow, triangle, parallelogram,
fibonacciCircle, fibonacciExtension, fibonacciSegment, fibonacciSpeedResistanceFan,
fibonacciSpiral, gannBox, measure,
]) {
registerOverlay(t);
}
const props = defineProps<{ const props = defineProps<{
ticker: string; ticker: string;
candles: Candle[]; candles: Candle[];
indicators: Record<string, Record<string, (number | null)[]>>; indicators: Record<string, Record<string, (number | null)[]>>;
/** 服务端在首屏窗口之前是否还有更早历史(决定左滑是否继续翻页) */
hasMore: boolean;
/** 向左翻页:取某日期(不含)之前 count 根历史,父组件保证口径一致;返回 null 表示无更多/已失效 */
loadOlder: (
end: string,
count: number,
) => Promise<{ candles: Candle[]; indicators: Record<string, Record<string, (number | null)[]>>; hasMore: boolean } | null>;
/** 副图指标及顺序('vol' 用内置;其余为后端序列) */ /** 副图指标及顺序('vol' 用内置;其余为后端序列) */
subPanes: string[]; subPanes: string[];
/** 主图 MA 周期(可配置,随用户偏好持久化) */ /** 主图 MA 周期(可配置,随用户偏好持久化) */
@@ -18,17 +43,35 @@ const props = defineProps<{
showBoll: boolean; showBoll: boolean;
/** K线周期标签仅用于 MA 指标名缓存 key */ /** K线周期标签仅用于 MA 指标名缓存 key */
timeframe: string; timeframe: string;
/** 浮层显示的指标(可选;缺省=目录全开,空数组=仅日期头) */
tooltipFields?: TooltipField[];
}>(); }>();
// A股语义色浅色UP/DOWN 跟随设置中的涨跌配色 // A股语义色黑底高对比UP/DOWN 跟随设置中的涨跌配色
const settings = useSettingsStore(); const settings = useSettingsStore();
let UP = '#dc2626'; let UP = '#FE354B';
let DOWN = '#16a34a'; let DOWN = '#1EBE72';
const MA_COLORS = ['#2563eb', '#f59e0b', '#a855f7', '#10b981', '#ec4899', '#0ea5e9', '#84cc16', '#f97316']; const MA_COLORS = ['#F5C518', '#4DA3FF', '#C77DFF', '#4DD0E1', '#FF8A3D', '#FF6E9C', '#A3E635', '#94A8FF'];
// ---------- 后端序列注入(单一事实源,按索引对齐) ---------- // ---------- 后端序列注入(单一事实源,与 allData 按索引对齐) ----------
// allData 会随向左翻页不断前插,图表只持有其中的后缀窗口——指标取值必须按时间戳
// 定位到 allData 索引,绝不能用图表相对索引(否则翻页后整体错位)。
let PV: Record<string, (number | null)[]> = {}; let PV: Record<string, (number | null)[]> = {};
const g = (k: string) => (i: number) => PV[k]?.[i] ?? undefined; let allData: KLineData[] = [];
function idxOfTs(ts: number): number {
let lo = 0, hi = allData.length - 1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (allData[mid].timestamp === ts) return mid;
if (allData[mid].timestamp < ts) lo = mid + 1; else hi = mid - 1;
}
return -1;
}
const g = (k: string) => (ts: number) => {
const i = idxOfTs(ts);
return i >= 0 ? PV[k]?.[i] ?? undefined : undefined;
};
// 动态 MA按周期组合注册一次figures 的 key 必须静态,故按签名建缓存) // 动态 MA按周期组合注册一次figures 的 key 必须静态,故按签名建缓存)
const _maReg = new Set<string>(); const _maReg = new Set<string>();
@@ -42,9 +85,9 @@ function ensureMaIndicator(periods: number[]) {
key: `ma${p}`, title: `MA${p}`, type: 'line', key: `ma${p}`, title: `MA${p}`, type: 'line',
styles: () => ({ color: MA_COLORS[i % MA_COLORS.length] }), styles: () => ({ color: MA_COLORS[i % MA_COLORS.length] }),
})), })),
calc: (d: KLineData[]) => d.map((_, i) => { calc: (d: KLineData[]) => d.map((bar) => {
const row: Record<string, number | undefined> = {}; const row: Record<string, number | undefined> = {};
for (const p of periods) row[`ma${p}`] = g(`ma${p}`)(i); for (const p of periods) row[`ma${p}`] = g(`ma${p}`)(bar.timestamp);
return row; return row;
}), }),
}); });
@@ -56,19 +99,19 @@ registerIndicator({
name: 'pv-boll', name: 'pv-boll',
shortName: 'BOLL', shortName: 'BOLL',
figures: [ figures: [
{ key: 'upper', title: 'UP', type: 'line', styles: () => ({ color: '#a855f7' }) }, { key: 'upper', title: 'UP', type: 'line', styles: () => ({ color: '#C77DFF' }) },
{ key: 'mid', title: 'MB', type: 'line', styles: () => ({ color: '#f59e0b' }) }, { key: 'mid', title: 'MB', type: 'line', styles: () => ({ color: '#F5C518' }) },
{ key: 'lower', title: 'DN', type: 'line', styles: () => ({ color: '#a855f7' }) }, { key: 'lower', title: 'DN', type: 'line', styles: () => ({ color: '#C77DFF' }) },
], ],
calc: (d: KLineData[]) => d.map((_, i) => ({ upper: g('upper')(i), mid: g('mid')(i), lower: g('lower')(i) })), calc: (d: KLineData[]) => d.map((bar) => ({ upper: g('upper')(bar.timestamp), mid: g('mid')(bar.timestamp), lower: g('lower')(bar.timestamp) })),
}); });
registerIndicator({ registerIndicator({
name: 'pv-macd', name: 'pv-macd',
shortName: 'MACD', shortName: 'MACD',
figures: [ figures: [
{ key: 'dif', title: 'DIF', type: 'line', styles: () => ({ color: '#2563eb' }) }, { key: 'dif', title: 'DIF', type: 'line', styles: () => ({ color: '#4DA3FF' }) },
{ key: 'dea', title: 'DEA', type: 'line', styles: () => ({ color: '#f59e0b' }) }, { key: 'dea', title: 'DEA', type: 'line', styles: () => ({ color: '#F5C518' }) },
{ {
key: 'hist', title: 'HIST', type: 'bar', baseValue: 0, // 零轴柱,缺省会从面板底部画起 key: 'hist', title: 'HIST', type: 'bar', baseValue: 0, // 零轴柱,缺省会从面板底部画起
styles: (p) => { styles: (p) => {
@@ -77,42 +120,115 @@ registerIndicator({
}, },
}, },
], ],
calc: (d: KLineData[]) => d.map((_, i) => ({ dif: g('dif')(i), dea: g('dea')(i), hist: g('hist')(i) })), calc: (d: KLineData[]) => d.map((bar) => ({ dif: g('dif')(bar.timestamp), dea: g('dea')(bar.timestamp), hist: g('hist')(bar.timestamp) })),
}); });
registerIndicator({ registerIndicator({
name: 'pv-kdj', name: 'pv-kdj',
shortName: 'KDJ', shortName: 'KDJ',
figures: [ figures: [
{ key: 'k', title: 'K', type: 'line', styles: () => ({ color: '#2563eb' }) }, { key: 'k', title: 'K', type: 'line', styles: () => ({ color: '#4DA3FF' }) },
{ key: 'd', title: 'D', type: 'line', styles: () => ({ color: '#f59e0b' }) }, { key: 'd', title: 'D', type: 'line', styles: () => ({ color: '#F5C518' }) },
{ key: 'j', title: 'J', type: 'line', styles: () => ({ color: '#dc2626' }) }, { key: 'j', title: 'J', type: 'line', styles: () => ({ color: '#FF6E9C' }) },
], ],
calc: (d: KLineData[]) => d.map((_, i) => ({ k: g('k')(i), d: g('d')(i), j: g('j')(i) })), calc: (d: KLineData[]) => d.map((bar) => ({ k: g('k')(bar.timestamp), d: g('d')(bar.timestamp), j: g('j')(bar.timestamp) })),
}); });
registerIndicator({ registerIndicator({
name: 'pv-rsi', name: 'pv-rsi',
shortName: 'RSI', shortName: 'RSI',
figures: [ figures: [
{ key: 'rsi6', title: 'RSI6', type: 'line', styles: () => ({ color: '#2563eb' }) }, { key: 'rsi6', title: 'RSI6', type: 'line', styles: () => ({ color: '#4DA3FF' }) },
{ key: 'rsi12', title: 'RSI12', type: 'line', styles: () => ({ color: '#f59e0b' }) }, { key: 'rsi12', title: 'RSI12', type: 'line', styles: () => ({ color: '#F5C518' }) },
{ key: 'rsi24', title: 'RSI24', type: 'line', styles: () => ({ color: '#a855f7' }) }, { key: 'rsi24', title: 'RSI24', type: 'line', styles: () => ({ color: '#C77DFF' }) },
], ],
calc: (d: KLineData[]) => d.map((_, i) => ({ rsi6: g('rsi6')(i), rsi12: g('rsi12')(i), rsi24: g('rsi24')(i) })), calc: (d: KLineData[]) => d.map((bar) => ({ rsi6: g('rsi6')(bar.timestamp), rsi12: g('rsi12')(bar.timestamp), rsi24: g('rsi24')(bar.timestamp) })),
}); });
const container = ref<HTMLDivElement | null>(null); const container = ref<HTMLDivElement | null>(null);
let chart: Chart | null = null; let chart: Chart | null = null;
let allData: KLineData[] = [];
let served = 0; // 已交给图表的 bar 数从尾部计backward 分页用
// ---------- 向左滚动按需加载(首屏 INIT_BARS 根,滚到左缘自动向前翻页 + 预取缓冲) ----------
const INIT_BARS = 240; // 初始展示根数(约一年日线) const INIT_BARS = 240; // 初始展示根数(约一年日线)
const PAGE_BARS = 500; // 每次向左滚动追加的历史根数 const SERVE_BARS = 500; // 每次 backward 回调向图表吐出的根数
const FETCH_BARS = 800; // 每次网络翻页拉取的根数(后端含预热计算指标)
const PREFETCH_LEFT = 300; // 本地未吐出的剩余根数低于该值时提前预取下一页
function lightStyles() { let served = 0; // 已交给图表的 bar 数(从尾部计)
let hasMore = false; // 服务端可能还有更早历史
let fetching: Promise<void> | null = null; // 进行中的向前翻页请求
let epoch = 0; // 本轮 build 生命周期标记(重建后丢弃过期回调/数据)
let failCount = 0; // 连续翻页失败次数(超过 3 次才放弃,避免瞬时网络错误永久截断历史)
const canBack = () => allData.length > served || hasMore;
/** 用本地时区把 timestamp 格式化为 YYYY-MM-DDDB 里是 naive 日期toISOString 会因 UTC 偏移提前一天,导致翻页缺一根)。 */
const toDateStr = (ms: number) => {
const d = new Date(ms);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
};
/** 拉取下一页更早历史并前插到 allData / PV索引保持对齐。 */
async function ensureOlder(myEpoch: number): Promise<void> {
if (fetching) return fetching;
if (!hasMore || allData.length === 0) return Promise.resolve();
const end = toDateStr(allData[0].timestamp);
const p = (async () => {
try {
const page = await props.loadOlder(end, FETCH_BARS);
if (myEpoch !== epoch) return; // 期间已切股/切口径/重建,丢弃
if (!page || page.candles.length === 0) { hasMore = false; return; }
recordExtra(page.candles); // 额/换手随翻页累积EX 全局 Map重建时清
const firstTs = allData[0].timestamp;
const keep = page.candles.filter((c) => new Date(c.ts).getTime() < firstTs);
// 同戳边界K周/月K的窗口边界可能切出半周期用服务端完整聚合替换本端首根
const boundary = page.candles.find((c) => new Date(c.ts).getTime() === firstTs);
if (keep.length === 0 && !boundary) { hasMore = false; return; } // 防御:服务端窗口与本端重叠
const head = keep.map((k) => ({
timestamp: new Date(k.ts).getTime(),
open: k.open, high: k.high, low: k.low, close: k.close, volume: k.volume,
}));
allData = boundary
? [...head, { timestamp: firstTs, open: boundary.open, high: boundary.high, low: boundary.low, close: boundary.close, volume: boundary.volume }, ...allData.slice(1)]
: [...head, ...allData];
for (const series of Object.values(page.indicators)) {
for (const [key, arr] of Object.entries(series)) {
// keep 是 page.candles 的前缀(全页严格早于 firstTs指标按前缀对齐
const cut = arr.slice(0, keep.length);
if (boundary) {
const rep = arr[keep.length] ?? null; // 同戳边界K的指标值keep 之后紧邻一根)
PV[key] = [...cut, rep, ...(PV[key] ?? []).slice(1)];
} else {
PV[key] = [...cut, ...(PV[key] ?? [])];
}
}
}
// 注klinecharts v10 数据只经 dataLoader 流入、无 updateData
// 已渲染的半周期首K无法原地刷新替换 allData/PV 保证后续翻页与重建使用完整聚合。
hasMore = page.hasMore;
failCount = 0;
} catch {
// 网络失败:保留 hasMore下次左缘触发时重试连续 3 次失败才放弃
failCount += 1;
if (failCount >= 3) hasMore = false;
}
})();
fetching = p.finally(() => { fetching = null; });
return fetching;
}
/** 缓冲预取:本地剩余不足时提前拉下一页,用户滚到左缘时数据已就位、零等待。 */
function maybePrefetch(myEpoch: number) {
if (hasMore && !fetching && allData.length - served < PREFETCH_LEFT) void ensureOlder(myEpoch);
}
function darkStyles() {
return { return {
grid: { horizontal: { color: '#eef2f7' }, vertical: { color: '#eef2f7' } }, grid: { horizontal: { color: '#1C1E24' }, vertical: { color: '#1C1E24' } },
// 内建 VOL 等指标柱的涨跌色缺省是库默认绿涨红跌,与全站语义相反;
// bars[0] 与库默认项深合并init 时 merge 到 getDefaultStyles只覆盖三个颜色键
// UP/DOWN 在 build() 里随 settings 刷新,故切「绿涨红跌」重建后同样生效
indicator: { bars: [{ upColor: UP, downColor: DOWN, noChangeColor: '#7A818C' }] },
candle: { candle: {
bar: { bar: {
upColor: UP, downColor: DOWN, upColor: UP, downColor: DOWN,
@@ -120,17 +236,17 @@ function lightStyles() {
upWickColor: UP, downWickColor: DOWN, upWickColor: UP, downWickColor: DOWN,
}, },
priceMark: { priceMark: {
high: { color: '#94a3b8' }, low: { color: '#94a3b8' }, high: { color: '#9AA0AA' }, low: { color: '#9AA0AA' },
last: { upColor: UP, downColor: DOWN }, last: { upColor: UP, downColor: DOWN },
}, },
}, },
xAxis: { axisLine: { color: '#e2e8f0' }, tickText: { color: '#64748b' }, tickLine: { color: '#e2e8f0' } }, xAxis: { axisLine: { color: '#2A2D34' }, tickText: { color: '#9AA0AA', size: 12 }, tickLine: { color: '#2A2D34' } },
yAxis: { axisLine: { color: '#e2e8f0' }, tickText: { color: '#64748b' }, tickLine: { color: '#e2e8f0' } }, yAxis: { axisLine: { color: '#2A2D34' }, tickText: { color: '#9AA0AA', size: 12 }, tickLine: { color: '#2A2D34' } },
crosshair: { crosshair: {
horizontal: { text: { backgroundColor: '#1e293b' } }, horizontal: { text: { backgroundColor: '#333A45' } },
vertical: { text: { backgroundColor: '#1e293b' } }, vertical: { text: { backgroundColor: '#333A45' } },
}, },
separator: { color: '#e2e8f0' }, separator: { color: '#23252B' },
}; };
} }
@@ -138,63 +254,173 @@ function lightStyles() {
const SUB_DEFAULT_HEIGHT: Record<string, number> = { vol: 64, macd: 100, kdj: 96, rsi: 84 }; const SUB_DEFAULT_HEIGHT: Record<string, number> = { vol: 64, macd: 100, kdj: 96, rsi: 84 };
const subH = (k: string) => Math.max(40, props.subHeights[k] ?? SUB_DEFAULT_HEIGHT[k] ?? 90); const subH = (k: string) => Math.max(40, props.subHeights[k] ?? SUB_DEFAULT_HEIGHT[k] ?? 90);
// ---------- 鼠标跟随信息框(通达信式) ---------- // ---------- 鼠标跟随信息框(通达信式,浮层贴鼠标,每行一个指标 ----------
interface TipRow { key: string; label: string; text: string; tone: '' | 'up' | 'down' }
interface HoverInfo { interface HoverInfo {
date: string; open: number; high: number; low: number; close: number; date: string; weekday: string;
chg: number | null; amp: number | null; vol: string; amount: string | null; rows: TipRow[];
mas: { label: string; value: number | null; color: string }[];
} }
const hover = ref<HoverInfo | null>(null); const hover = ref<HoverInfo | null>(null);
// tooltip 展示的额/换手不在 KLineData 里,按时间戳从 Candle 源数据另存一份
const EX = new Map<number, { amount: number | null; turnover: number | null }>();
const recordExtra = (candles: Candle[]) => {
for (const c of candles) EX.set(new Date(c.ts).getTime(), { amount: c.amount ?? null, turnover: c.turnover ?? null });
};
const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六'];
function fmtVol(v: number): string { function fmtVol(v: number): string {
if (v >= 1e8) return (v / 1e8).toFixed(2) + '亿'; if (v >= 1e8) return (v / 1e8).toFixed(2) + '亿';
if (v >= 1e4) return (v / 1e4).toFixed(2) + '万'; if (v >= 1e4) return (v / 1e4).toFixed(2) + '万';
return String(Math.round(v)); return String(Math.round(v));
} }
function fmtAmount(v: number): string {
if (v >= 1e8) return (v / 1e8).toFixed(2) + '亿';
if (v >= 1e4) return (v / 1e4).toFixed(2) + '万';
return v.toFixed(0);
}
// 鼠标位置(相对图表容器),浮层跟着走并在右缘/下缘自动翻转
const mx = ref(0);
const my = ref(0);
function onMove(e: MouseEvent) {
const rect = container.value?.getBoundingClientRect();
mx.value = rect ? e.clientX - rect.left : e.clientX;
my.value = rect ? e.clientY - rect.top : e.clientY;
}
const hoverStyle = ref<Record<string, string>>({});
function placeHover(rowCount: number) {
const w = container.value?.clientWidth ?? 800;
const h = container.value?.clientHeight ?? 500;
// 空选时模板仍渲染一行“未选择指标”占位,按至少 1 行估高
const bw = 160, bh = 37 + Math.max(rowCount, 1) * 16, gap = 14;
const x = mx.value + gap + bw > w - 4 ? Math.max(4, mx.value - gap - bw) : mx.value + gap;
const y = my.value + gap + bh > h - 4 ? Math.max(4, my.value - gap - bh) : my.value + gap;
hoverStyle.value = { left: `${x}px`, top: `${y}px` };
}
function bindCrosshair(ch: Chart) { function bindCrosshair(ch: Chart) {
ch.subscribeAction('onCrosshairChange', (payload) => { ch.subscribeAction('onCrosshairChange', (payload) => {
const k = (payload as { data?: { kLineData?: KLineData } }).data?.kLineData; // v10 分发的是裸 crosshair {x, y, paneId}(不含 kLineData
if (!k || !allData.length) { hover.value = null; return; } // 用公共 API 把 x 像素换算回 timestamp 再定位 bar
// 二分定位索引(全量数组与指标序列按索引对齐) const x = (payload as { x?: number }).x;
let lo = 0, hi = allData.length - 1, idx = -1; if (typeof x !== 'number' || allData.length === 0) { hover.value = null; return; }
while (lo <= hi) { const pt = ch.convertFromPixel([{ x }]) as Array<Partial<Point>>;
const mid = (lo + hi) >> 1; const ts = pt?.[0]?.timestamp;
if (allData[mid].timestamp === k.timestamp) { idx = mid; break; } const idx = ts != null ? idxOfTs(ts) : -1; // 全量数组与指标序列按索引对齐
if (allData[mid].timestamp < k.timestamp) lo = mid + 1; else hi = mid - 1;
}
if (idx < 0) { hover.value = null; return; } if (idx < 0) { hover.value = null; return; }
const k = allData[idx];
const prev = idx > 0 ? allData[idx - 1] : null; const prev = idx > 0 ? allData[idx - 1] : null;
const chg = prev ? ((k.close - prev.close) / prev.close) * 100 : null; const diff = prev ? k.close - prev.close : null;
const chg = prev ? (diff! / prev.close) * 100 : null;
const amp = prev ? ((k.high - k.low) / prev.close) * 100 : null; const amp = prev ? ((k.high - k.low) / prev.close) * 100 : null;
hover.value = { const d = new Date(k.timestamp);
date: new Date(k.timestamp).toLocaleDateString('zh-CN'), const ex = EX.get(k.timestamp);
open: k.open, high: k.high, low: k.low, close: k.close, // 每个指标一行配色跟随涨跌色调设置up/down 在 build() 里随 priceTone 刷新)。
chg, amp, vol: fmtVol(k.volume ?? 0), amount: null, // 平盘diff==0为中性色与全站 pctClass 及 klinecharts 的 noChangeColor 三态约定一致
mas: props.maPeriods.map((p, i) => ({ const byDiff: '' | 'up' | 'down' = diff == null || diff === 0 ? '' : diff > 0 ? 'up' : 'down';
label: `MA${p}`, const values: Record<TooltipField, { text: string; tone: '' | 'up' | 'down' }> = {
value: PV[`ma${p}`]?.[idx] ?? null, open: { text: k.open.toFixed(2), tone: byDiff },
color: MA_COLORS[i % MA_COLORS.length], high: { text: k.high.toFixed(2), tone: 'up' },
})), low: { text: k.low.toFixed(2), tone: 'down' },
close: { text: k.close.toFixed(2), tone: byDiff },
diff: { text: diff == null ? '—' : (diff > 0 ? '+' : '') + diff.toFixed(2), tone: byDiff },
chg: { text: chg == null ? '—' : (chg > 0 ? '+' : '') + chg.toFixed(2) + '%', tone: byDiff },
amp: { text: amp == null ? '—' : amp.toFixed(2) + '%', tone: '' },
vol: { text: fmtVol(k.volume ?? 0), tone: '' },
amount: { text: ex?.amount != null ? fmtAmount(ex.amount) : '—', tone: '' },
turnover: { text: ex?.turnover == null ? '—' : ex.turnover.toFixed(2) + '%', tone: '' },
}; };
const selected = new Set(props.tooltipFields ?? DEFAULT_TOOLTIP_FIELDS);
const rows: TipRow[] = TOOLTIP_FIELDS
.filter((f) => selected.has(f.key)) // 目录顺序 = 设置弹层顺序 = 浮层行序
.map((f) => ({ key: f.key, label: f.label, ...values[f.key] }));
hover.value = {
date: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`,
weekday: `星期${WEEKDAYS[d.getDay()]}`,
rows,
};
placeHover(rows.length);
}); });
} }
// ---------- 画图画线(通达信式工具栏 ---------- // ---------- 画图画线(Pro 风格工具集v10 内置 + @klinecharts/extension ----------
const TOOLS: { key: string; label: string; title: string }[] = [ interface Tool { key: string; label: string; title: string }
// 常用工具工具栏一行直出key 即 overlay 名,'' 表示光标模式)
const COMMON_TOOLS: Tool[] = [
{ key: '', label: '光标', title: '光标模式Esc 取消画线)' }, { key: '', label: '光标', title: '光标模式Esc 取消画线)' },
{ key: 'segment', label: '', title: '线段' }, { key: 'segment', label: '', title: '线段' },
{ key: 'ray', label: '→', title: '射线' }, { key: 'rayLine', label: '→', title: '射线' },
{ key: 'horizontalLine', label: '─', title: '水平线' }, { key: 'horizontalStraightLine', label: '─', title: '水平线' },
{ key: 'rect', label: '▭', title: '矩形' }, { key: 'rect', label: '▭', title: '矩形' },
{ key: 'priceChannelLine', label: '∥', title: '价格通道' }, { key: 'priceChannelLine', label: '∥', title: '价格通道' },
{ key: 'fibLine', label: 'fib', title: '斐波那契回撤' }, { key: 'fibonacciLine', label: 'fib', title: '斐波那契回撤' },
{ key: 'measure', label: '⇹', title: '测量(价差/幅度/K线数' },
]; ];
// 更多工具(分组面板展开;均为已注册 overlay
const MORE_GROUPS: { group: string; items: Tool[] }[] = [
{ group: '线', items: [
{ key: 'straightLine', label: '直线', title: '无限延长直线' },
{ key: 'horizontalSegment', label: '水平线段', title: '两点水平线段' },
{ key: 'verticalStraightLine', label: '竖直线', title: '竖直直线' },
{ key: 'parallelStraightLine', label: '平行线', title: '平行直线' },
{ key: 'priceLine', label: '价格线', title: '单点价格线' },
] },
{ group: '斐波那契', items: [
{ key: 'fibonacciSegment', label: '线段fib', title: '斐波那契线段(周期)' },
{ key: 'fibonacciExtension', label: '扩展', title: '斐波那契扩展' },
{ key: 'fibonacciCircle', label: '圆形', title: '斐波那契圆形' },
{ key: 'fibonacciSpiral', label: '螺线', title: '斐波那契螺线' },
{ key: 'fibonacciSpeedResistanceFan', label: '阻力扇', title: '斐波那契速度阻力扇' },
] },
{ group: '波浪 · 江恩 · 谐波', items: [
{ key: 'threeWaves', label: '三浪', title: '三浪' },
{ key: 'fiveWaves', label: '五浪', title: '五浪' },
{ key: 'eightWaves', label: '八浪', title: '八浪' },
{ key: 'anyWaves', label: '自由浪', title: '自由波浪' },
{ key: 'gannBox', label: '江恩箱', title: '甘氏箱(江恩角度线)' },
{ key: 'abcd', label: 'AB=CD', title: 'AB=CD 谐波' },
{ key: 'xabcd', label: 'XABCD', title: 'XABCD 谐波' },
] },
{ group: '形状', items: [
{ key: 'triangle', label: '△', title: '三角形' },
{ key: 'parallelogram', label: '▱', title: '平行四边形' },
{ key: 'circle', label: '◯', title: '圆' },
{ key: 'arrow', label: '➤', title: '箭头' },
{ key: 'brush', label: '✎', title: '自由画笔' },
] },
];
const activeTool = ref(''); const activeTool = ref('');
const showMore = ref(false);
/** 测量工具的气泡文案:价差 / 涨跌幅 / 两点间K线数Point.value 即价格) */
function measureTip(points: Partial<Point>[]): string[] {
const a = points[0], b = points[1];
if (!a?.value || !b?.value) return [];
const diff = b.value - a.value;
const pct = (diff / a.value) * 100;
const out = [
`价差 ${diff >= 0 ? '+' : ''}${diff.toFixed(2)}`,
`幅度 ${pct >= 0 ? '+' : ''}${pct.toFixed(2)}%`,
];
const ia = a.timestamp != null ? idxOfTs(a.timestamp) : -1;
const ib = b.timestamp != null ? idxOfTs(b.timestamp) : -1;
if (ia >= 0 && ib >= 0) out.push(`${Math.abs(ib - ia) + 1} 根K线`);
return out;
}
function pickTool(key: string) { function pickTool(key: string) {
activeTool.value = key; activeTool.value = key;
if (chart && key) chart.createOverlay({ name: key }); showMore.value = false;
if (chart && key) {
// measure 通过 extendData 注入气泡文案,其余直接按名创建
chart.createOverlay(key === 'measure' ? { name: 'measure', extendData: measureTip } : { name: key });
}
} }
function clearOverlays() { function clearOverlays() {
@@ -207,35 +433,67 @@ function build() {
UP = settings.upHex; UP = settings.upHex;
DOWN = settings.downHex; DOWN = settings.downHex;
PV = {}; PV = {};
EX.clear();
recordExtra(props.candles);
for (const [group, series] of Object.entries(props.indicators)) { for (const [group, series] of Object.entries(props.indicators)) {
for (const [key, arr] of Object.entries(series)) PV[key] = arr; for (const [key, arr] of Object.entries(series)) PV[key] = arr;
} }
const ch = init(container.value, { styles: lightStyles() }); const ch = init(container.value, { styles: darkStyles() });
if (!ch) return; if (!ch) return;
chart = ch; chart = ch;
const myEpoch = ++epoch;
allData = props.candles.map((k) => ({ allData = props.candles.map((k) => ({
timestamp: new Date(k.ts).getTime(), timestamp: new Date(k.ts).getTime(),
open: k.open, high: k.high, low: k.low, close: k.close, volume: k.volume, open: k.open, high: k.high, low: k.low, close: k.close, volume: k.volume,
})); }));
served = 0; served = 0;
hasMore = props.hasMore;
fetching = null;
failCount = 0;
ch.setDataLoader({ ch.setDataLoader({
getBars: ({ type, callback }) => { getBars: ({ type, callback }) => {
if (type === 'update') { if (myEpoch !== epoch) return; // 已重建(切股/切口径/改MA旧图表已 dispose无需应答
const last = allData[allData.length - 1]; // klinecharts v10 契约(见 node_modules/klinecharts/dist/index.esm.js _addData
callback(last ? [last] : [], { backward: served < allData.length, forward: false }); // 'init' → callback 数据整体替换
} else if (type === 'init') { // 'forward' → 用户拖到左缘callback 数据【前插】为更旧历史more.forward=false 后左缘不再触发
// 全量已拉到本地:先给最近 INIT_BARS 根,向左滚动时按页吐更早历史 // 'backward'→ 用户拖到右缘callback 数据【追加】为更新端数据;我们已持有最新一根,永远没有
served = Math.min(INIT_BARS, allData.length); // 每次 getBars 必须恰好应答一次 callback否则图表 _loading 卡死、后续不再加载
callback(allData.slice(allData.length - served), { backward: served < allData.length, forward: false }); const serveOlder = (take: number) => {
} else if (type === 'backward') {
const remain = allData.length - served;
const take = Math.min(PAGE_BARS, remain);
const start = allData.length - served - take; const start = allData.length - served - take;
served += take; served += take;
callback(allData.slice(start, start + take), { backward: served < allData.length, forward: false }); callback(allData.slice(start, start + take), { forward: canBack(), backward: false });
};
const answerEmpty = () => callback([], { forward: false, backward: false });
if (type === 'init') {
// 首屏:最近 INIT_BARS 根;更早历史由左滑触发 'forward' 翻页
served = Math.min(INIT_BARS, allData.length);
callback(allData.slice(allData.length - served), { forward: canBack(), backward: false });
maybePrefetch(myEpoch);
} else if (type === 'forward') {
// 左缘:优先吐本地未吐出的(首屏余量或已预取页),本地耗尽再向服务端翻一页更早历史
const step = async () => {
if (allData.length - served > 0) {
serveOlder(Math.min(SERVE_BARS, allData.length - served));
maybePrefetch(myEpoch);
return;
}
if (!hasMore) { answerEmpty(); return; }
await ensureOlder(myEpoch); // 若已在请求中则复用同一 promise
if (myEpoch !== epoch) return; // 期间已重建,由新图表应答
if (allData.length - served > 0) {
serveOlder(Math.min(SERVE_BARS, allData.length - served));
maybePrefetch(myEpoch);
} else { } else {
callback([], { backward: false, forward: false }); answerEmpty();
}
};
void step();
} else {
// 'backward'(右缘更新端)与 'update'(单根刷新走 subscribeBar无更新端数据
// 绝不能把更旧历史从这里给出去——v10 会 concat 到最新一根右侧造成时间轴乱序
callback([], { forward: canBack(), backward: false });
} }
}, },
}); });
@@ -259,6 +517,12 @@ function build() {
} }
bindCrosshair(ch); bindCrosshair(ch);
// 缓冲预取:可视范围接近已加载左缘(<200 根)时提前翻下一页
ch.subscribeAction('onVisibleRangeChange', (payload) => {
if (myEpoch !== epoch) return;
const from = (payload as { data?: { from?: unknown } }).data?.from;
if (typeof from === 'number' && from < 200) maybePrefetch(myEpoch);
});
ch.setOffsetRightDistance(28); ch.setOffsetRightDistance(28);
ch.scrollToRealTime(); ch.scrollToRealTime();
} }
@@ -290,47 +554,73 @@ watch(() => props.subHeights, () => {
</script> </script>
<template> <template>
<div class="relative h-full w-full"> <!-- mousemove captureklinecharts 在内部容器上以冒泡阶段监听并同步触发
onCrosshairChangeplaceHovercapture 先于它更新 mx/my避免用到上一次的坐标 -->
<div class="relative h-full w-full" @mousemove.capture="onMove" @mouseleave="hover = null">
<div ref="container" class="h-full w-full"></div> <div ref="container" class="h-full w-full"></div>
<!-- 鼠标跟随信息框通达信式小方块 --> <!-- 鼠标跟随信息框贴鼠标/下缘自动翻转每行一个指标内容由浮层设置决定 -->
<div <div
v-if="hover" v-if="hover"
class="pointer-events-none absolute left-2 top-2 z-10 rounded border border-slate-700 bg-slate-900/90 px-2.5 py-1.5 font-mono text-[11px] leading-4 text-slate-200 shadow-lg" class="pointer-events-none absolute z-10 w-40 rounded border border-[#33353D] bg-black/90 px-2.5 py-1.5 font-mono text-xs leading-4 text-[#E8EAED] shadow-lg"
:style="hoverStyle"
> >
<div class="text-slate-400">{{ hover.date }}</div> <div class="text-[#9BA3AE]">{{ hover.date }} <span class="text-[#A8AFB8]">{{ hover.weekday }}</span></div>
<div> <span :class="hover.chg != null && hover.chg >= 0 ? 'text-red-400' : 'text-emerald-400'">{{ hover.open.toFixed(2) }}</span> <div class="mt-1 border-t border-[#33353D]/60 pt-1">
<span class="text-red-400">{{ hover.high.toFixed(2) }}</span> <div v-for="r in hover.rows" :key="r.key" class="flex items-baseline justify-between">
<span class="text-emerald-400">{{ hover.low.toFixed(2) }}</span> <span class="text-[#A8AFB8]">{{ r.label }}</span>
<span :class="hover.chg != null && hover.chg >= 0 ? 'text-red-400' : 'text-emerald-400'">{{ hover.close.toFixed(2) }}</span></div> <span
<div> <span :class="hover.chg != null && hover.chg >= 0 ? 'text-red-400' : 'text-emerald-400'">{{ hover.chg == null ? '—' : (hover.chg > 0 ? '+' : '') + hover.chg.toFixed(2) + '%' }}</span> :style="r.tone ? { color: r.tone === 'up' ? UP : DOWN } : undefined"
<span class="text-slate-100">{{ hover.amp == null ? '—' : hover.amp.toFixed(2) + '%' }}</span> :class="r.tone ? '' : 'text-[#E8EAED]'"
<span class="text-slate-100">{{ hover.vol }}</span></div> >{{ r.text }}</span>
<div v-if="hover.mas.length" class="mt-0.5"> </div>
<span v-for="(m, i) in hover.mas" :key="m.label" class="mr-2" :style="{ color: m.color }"> <div v-if="hover.rows.length === 0" class="text-[#A8AFB8]">未选择指标</div>
{{ m.label }} {{ m.value == null ? '' : m.value.toFixed(2) }}<span v-if="i < hover.mas.length - 1" class="invisible">,</span>
</span>
</div> </div>
</div> </div>
<!-- 画图画线工具栏 --> <!-- 画图画线工具栏常用一行 + 更多分组面板 -->
<div class="absolute right-2 top-2 z-10 flex items-center gap-0.5 rounded-md border border-slate-200 bg-white/95 px-1 py-0.5 shadow-sm"> <div class="absolute right-2 top-2 z-10 rounded-md border border-[#26272E] bg-[#101014] shadow-sm">
<div class="flex items-center gap-0.5 px-1 py-0.5">
<button <button
v-for="t in TOOLS" v-for="t in COMMON_TOOLS"
:key="t.key || 'cursor'" :key="t.key || 'cursor'"
type="button" type="button"
class="min-w-6 rounded px-1 py-0.5 text-[11px] transition-colors" class="min-w-6 rounded px-1 py-0.5 text-xs transition-colors"
:class="activeTool === t.key ? 'bg-blue-600 text-white' : 'text-slate-500 hover:bg-slate-100 hover:text-slate-900'" :class="activeTool === t.key ? 'bg-blue-600 text-white' : 'text-[#A8AFB8] hover:bg-[#1E2026] hover:text-[#E8EAED]'"
:title="t.title" :title="t.title"
@click="pickTool(t.key)" @click="pickTool(t.key)"
>{{ t.label }}</button> >{{ t.label }}</button>
<span class="mx-0.5 h-3 w-px bg-slate-200"></span> <span class="mx-0.5 h-3 w-px bg-[#26272E]"></span>
<button <button
type="button" type="button"
class="rounded px-1 py-0.5 text-[11px] text-red-500 transition-colors hover:bg-red-50" class="rounded px-1 py-0.5 text-xs transition-colors"
:class="showMore ? 'bg-blue-500/15 text-blue-300' : 'text-[#A8AFB8] hover:bg-[#1E2026] hover:text-[#E8EAED]'"
title="更多画线工具"
@click="showMore = !showMore"
>更多</button>
<button
type="button"
class="rounded px-1 py-0.5 text-xs text-red-500 transition-colors hover:bg-red-500/15"
title="清除全部画线" title="清除全部画线"
@click="clearOverlays" @click="clearOverlays"
>清除</button> >清除</button>
</div> </div>
<div v-if="showMore" class="max-w-56 border-t border-[#26272E] px-1.5 py-1">
<div v-for="grp in MORE_GROUPS" :key="grp.group" class="mb-1.5 last:mb-0">
<div class="mb-0.5 text-xs leading-3 text-[#9BA3AE]">{{ grp.group }}</div>
<div class="flex flex-wrap gap-0.5">
<button
v-for="t in grp.items"
:key="t.key"
type="button"
class="rounded px-1.5 py-0.5 text-xs transition-colors"
:class="activeTool === t.key ? 'bg-blue-600 text-white' : 'text-[#A8AFB8] hover:bg-[#1E2026] hover:text-[#E8EAED]'"
:title="t.title"
@click="pickTool(t.key)"
>{{ t.label }}</button>
</div>
</div>
</div>
</div>
</div> </div>
</template> </template>

View File

@@ -60,14 +60,14 @@ defineExpose({ refreshHistory });
</script> </script>
<template> <template>
<div class="rounded-xl border border-slate-200 bg-white p-5"> <div class="rounded-xl border border-[#26272E] bg-[#101014] p-5">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<label class="lbl !mb-0">用一句话描述你的选股条件</label> <label class="lbl !mb-0">用一句话描述你的选股条件</label>
<!-- 提问历史 --> <!-- 提问历史 -->
<div class="relative"> <div class="relative">
<button <button
type="button" type="button"
class="flex items-center gap-1 rounded-md border border-slate-200 px-2.5 py-1 text-xs text-slate-500 transition-colors hover:text-slate-900" class="flex items-center gap-1 rounded-md border border-[#26272E] px-2.5 py-1 text-[13px] text-[#A8AFB8] transition-colors hover:text-white"
@click="loadHistory" @click="loadHistory"
> >
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8v4l3 3" /><circle cx="12" cy="12" r="9" /></svg> <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8v4l3 3" /><circle cx="12" cy="12" r="9" /></svg>
@@ -75,14 +75,14 @@ defineExpose({ refreshHistory });
</button> </button>
<div <div
v-if="historyOpen" v-if="historyOpen"
class="absolute right-0 top-8 z-30 w-[26rem] rounded-lg border border-slate-200 bg-white shadow-lg" class="absolute right-0 top-8 z-30 w-[26rem] rounded-lg border border-[#26272E] bg-[#101014] shadow-lg"
> >
<div v-if="history.length === 0" class="px-4 py-6 text-center text-xs text-slate-400">暂无历史提问</div> <div v-if="history.length === 0" class="px-4 py-6 text-center text-[13px] text-[#9BA3AE]">暂无历史提问</div>
<div v-else class="max-h-80 overflow-y-auto"> <div v-else class="max-h-80 overflow-y-auto">
<div <div
v-for="q in history" v-for="q in history"
:key="q.id" :key="q.id"
class="group flex items-start gap-2 border-b border-slate-50 px-3 py-2 last:border-0 hover:bg-slate-50" class="group flex items-start gap-2 border-b border-[#1E2026] px-3 py-2 last:border-0 hover:bg-[#26272E]"
> >
<button <button
type="button" type="button"
@@ -90,15 +90,15 @@ defineExpose({ refreshHistory });
:title="q.conditions ? '点击直传条件重跑(不重新解析)' : '点击填入并重跑'" :title="q.conditions ? '点击直传条件重跑(不重新解析)' : '点击填入并重跑'"
@click="rerun(q)" @click="rerun(q)"
> >
<span class="block truncate text-[13px] text-slate-700">{{ q.text }}</span> <span class="block truncate text-sm text-[#E8EAED]">{{ q.text }}</span>
<span class="mt-0.5 block text-[11px] text-slate-400"> <span class="mt-0.5 block text-xs text-[#9BA3AE]">
{{ fmtTime(q.created_at) }} {{ fmtTime(q.created_at) }}
<span v-if="q.hit_count != null" class="ml-1 rounded bg-slate-100 px-1">命中 {{ q.hit_count }}</span> <span v-if="q.hit_count != null" class="ml-1 rounded bg-[#1E2026] px-1">命中 {{ q.hit_count }}</span>
</span> </span>
</button> </button>
<button <button
type="button" type="button"
class="rounded p-1 text-slate-300 opacity-0 transition hover:bg-red-50 hover:text-red-500 group-hover:opacity-100" class="rounded p-1 text-[#C3C9D2] opacity-0 transition hover:bg-red-500/15 hover:text-red-500 group-hover:opacity-100"
title="删除该记录" title="删除该记录"
@click.stop="removeQuery(q.id)" @click.stop="removeQuery(q.id)"
> >
@@ -119,20 +119,20 @@ defineExpose({ refreshHistory });
/> />
<div class="mt-3 flex flex-wrap items-center gap-1.5"> <div class="mt-3 flex flex-wrap items-center gap-1.5">
<span class="mr-1 text-xs text-slate-400">示例</span> <span class="mr-1 text-[13px] text-[#9BA3AE]">示例</span>
<button <button
v-for="(ex, i) in examples" v-for="(ex, i) in examples"
:key="i" :key="i"
type="button" type="button"
class="max-w-full truncate rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs text-slate-600 transition-colors hover:border-slate-300 hover:bg-slate-100" class="max-w-full truncate rounded-full border border-[#26272E] bg-black px-3 py-1 text-[13px] text-[#A8AFB8] transition-colors hover:border-[#3A3D46] hover:bg-[#1E2026]"
@click="text = ex" @click="text = ex"
>{{ ex }}</button> >{{ ex }}</button>
</div> </div>
<div class="mt-4 flex items-center justify-between gap-3"> <div class="mt-4 flex items-center justify-between gap-3">
<p class="text-xs leading-relaxed text-slate-400"> <p class="text-[13px] leading-relaxed text-[#9BA3AE]">
支持 KDJ / RSI / MACD / 布林 / 均线指标条件市值 / 市盈率 / 换手率等快照条件以及连续 N N 天任一天时间窗口 支持 KDJ / RSI / MACD / 布林 / 均线指标条件市值 / 市盈率 / 换手率等快照条件以及连续 N N 天任一天时间窗口
<kbd class="rounded border border-slate-200 bg-slate-50 px-1">Ctrl</kbd>+<kbd class="rounded border border-slate-200 bg-slate-50 px-1">Enter</kbd> 快速筛选 <kbd class="rounded border border-[#26272E] bg-black px-1">Ctrl</kbd>+<kbd class="rounded border border-[#26272E] bg-black px-1">Enter</kbd> 快速筛选
</p> </p>
<button type="button" class="btn-primary shrink-0 disabled:opacity-50" :disabled="loading || !text.trim()" @click="run"> <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-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>

View File

@@ -75,21 +75,21 @@ function fmtInd(it: ScreenerItemOut, key: string) {
</script> </script>
<template> <template>
<div class="mt-4 overflow-hidden rounded-xl border border-slate-200 bg-white"> <div class="mt-4 overflow-hidden rounded-xl border border-[#26272E] bg-[#101014]">
<div class="border-b border-slate-100 px-4 py-3 text-[13px] text-slate-600"> <div class="border-b border-[#1E2026] px-4 py-3 text-sm text-[#A8AFB8]">
命中 <span class="font-semibold text-slate-900">{{ result.total }}</span> 命中 <span class="font-semibold text-[#E8EAED]">{{ result.total }}</span>
<span v-if="result.total > items.length" class="text-slate-400">仅显示前 {{ items.length }}</span> <span v-if="result.total > items.length" class="text-[#9BA3AE]">仅显示前 {{ items.length }}</span>
<span v-if="result.trade_date" class="ml-2 text-slate-400">· 数据基准 {{ result.trade_date.slice(0, 10) }}</span> <span v-if="result.trade_date" class="ml-2 text-[#9BA3AE]">· 数据基准 {{ result.trade_date.slice(0, 10) }}</span>
</div> </div>
<div class="max-h-[560px] overflow-auto"> <div class="max-h-[560px] overflow-auto">
<table class="w-full border-collapse text-[13px]"> <table class="w-full border-collapse text-sm">
<thead class="sticky top-0 z-10 bg-slate-50 text-slate-500"> <thead class="sticky top-0 z-10 bg-black text-[#A8AFB8]">
<tr class="border-b border-slate-200"> <tr class="border-b border-[#26272E]">
<th <th
v-for="c in FIXED_COLS" v-for="c in FIXED_COLS"
:key="c.key" :key="c.key"
class="cursor-pointer select-none whitespace-nowrap px-3 py-2 text-left font-medium hover:text-slate-900" class="cursor-pointer select-none whitespace-nowrap px-3 py-2 text-left font-medium hover:text-white"
@click="toggleSort(c.key)" @click="toggleSort(c.key)"
> >
{{ c.label }} {{ c.label }}
@@ -98,7 +98,7 @@ function fmtInd(it: ScreenerItemOut, key: string) {
<th <th
v-for="col in indCols" v-for="col in indCols"
:key="col.key" :key="col.key"
class="cursor-pointer select-none whitespace-nowrap px-3 py-2 text-left font-medium hover:text-slate-900" class="cursor-pointer select-none whitespace-nowrap px-3 py-2 text-left font-medium hover:text-white"
@click="toggleSort(col.key)" @click="toggleSort(col.key)"
> >
{{ col.label }} {{ col.label }}
@@ -111,33 +111,33 @@ function fmtInd(it: ScreenerItemOut, key: string) {
<tr <tr
v-for="it in sortedItems" v-for="it in sortedItems"
:key="it.ts_code" :key="it.ts_code"
class="cursor-pointer border-b border-slate-50 transition-colors last:border-0 hover:bg-blue-50/40" class="cursor-pointer border-b border-[#1E2026] transition-colors last:border-0 hover:bg-[#26272E]"
@click="emit('preview', it)" @click="emit('preview', it)"
> >
<td class="whitespace-nowrap px-3 py-1.5 font-medium text-slate-900">{{ it.ts_code }}</td> <td class="whitespace-nowrap px-3 py-1.5 font-medium text-[#E8EAED]">{{ it.ts_code }}</td>
<td class="whitespace-nowrap px-3 py-1.5 text-slate-700">{{ it.name }}</td> <td class="whitespace-nowrap px-3 py-1.5 text-[#E8EAED]">{{ it.name }}</td>
<td class="whitespace-nowrap px-3 py-1.5 font-medium tabular-nums" :class="toneClass(it.pct_chg)">{{ fmt2(it.close) }}</td> <td class="whitespace-nowrap px-3 py-1.5 font-medium tabular-nums" :class="toneClass(it.pct_chg)">{{ fmt2(it.close) }}</td>
<td class="whitespace-nowrap px-3 py-1.5 tabular-nums" :class="toneClass(it.pct_chg)"> <td class="whitespace-nowrap px-3 py-1.5 tabular-nums" :class="toneClass(it.pct_chg)">
{{ it.pct_chg == null ? '—' : (it.pct_chg > 0 ? '+' : '') + it.pct_chg.toFixed(2) }} {{ it.pct_chg == null ? '—' : (it.pct_chg > 0 ? '+' : '') + it.pct_chg.toFixed(2) }}
</td> </td>
<td class="whitespace-nowrap px-3 py-1.5 text-slate-700">{{ fmt2(it.total_mv) }}</td> <td class="whitespace-nowrap px-3 py-1.5 text-[#E8EAED]">{{ fmt2(it.total_mv) }}</td>
<td class="whitespace-nowrap px-3 py-1.5 text-slate-700">{{ fmt2(it.circ_mv) }}</td> <td class="whitespace-nowrap px-3 py-1.5 text-[#E8EAED]">{{ fmt2(it.circ_mv) }}</td>
<td class="whitespace-nowrap px-3 py-1.5 text-slate-700">{{ fmt2(it.pe_ttm) }}</td> <td class="whitespace-nowrap px-3 py-1.5 text-[#E8EAED]">{{ fmt2(it.pe_ttm) }}</td>
<td class="whitespace-nowrap px-3 py-1.5 text-slate-700">{{ fmt2(it.pb) }}</td> <td class="whitespace-nowrap px-3 py-1.5 text-[#E8EAED]">{{ fmt2(it.pb) }}</td>
<td class="whitespace-nowrap px-3 py-1.5 text-slate-700">{{ fmt2(it.turnover_rate) }}</td> <td class="whitespace-nowrap px-3 py-1.5 text-[#E8EAED]">{{ fmt2(it.turnover_rate) }}</td>
<td v-for="col in indCols" :key="col.key" class="whitespace-nowrap px-3 py-1.5 text-slate-700"> <td v-for="col in indCols" :key="col.key" class="whitespace-nowrap px-3 py-1.5 text-[#E8EAED]">
{{ fmtInd(it, col.key) }} {{ fmtInd(it, col.key) }}
</td> </td>
<td class="whitespace-nowrap px-3 py-1.5 text-right"> <td class="whitespace-nowrap px-3 py-1.5 text-right">
<button <button
type="button" type="button"
class="rounded-md border border-slate-200 px-2 py-0.5 text-xs text-blue-600 transition-colors hover:border-blue-300 hover:bg-blue-50" class="rounded-md border border-[#26272E] px-2 py-0.5 text-[13px] text-blue-600 transition-colors hover:border-blue-500 hover:bg-blue-500/15"
@click.stop="emit('preview', it)" @click.stop="emit('preview', it)"
>详情</button> >详情</button>
</td> </td>
</tr> </tr>
<tr v-if="sortedItems.length === 0"> <tr v-if="sortedItems.length === 0">
<td :colspan="FIXED_COLS.length + indCols.length + 1" class="px-3 py-12 text-center text-slate-400">没有符合条件的股票</td> <td :colspan="FIXED_COLS.length + indCols.length + 1" class="px-3 py-12 text-center text-[#9BA3AE]">没有符合条件的股票</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { addWatchlist, getStockPreview, getWatchlist as getWatchlistApi, removeWatchlist } from '@/api/client'; import { addWatchlist, getStockPreview, getWatchlist as getWatchlistApi, removeWatchlist } from '@/api/client';
import type { ChartLayoutPrefs, PreviewResponse, ScreenerItemOut, Timeframe } from '@/api/types'; import type { ChartLayoutPrefs, PreviewResponse, ScreenerItemOut, Timeframe, TooltipField } from '@/api/types';
import { useSettingsStore, type PriceAdjust } from '@/stores/settings'; import { useSettingsStore, DEFAULT_TOOLTIP_FIELDS, TOOLTIP_FIELDS, type PriceAdjust } from '@/stores/settings';
import DetailKLine from './DetailKLine.vue'; import DetailKLine from './DetailKLine.vue';
const props = defineProps<{ const props = defineProps<{
@@ -59,6 +59,7 @@ const layout = computed<ChartLayoutPrefs>(() => settings.chartLayout);
const subPanes = computed<string[]>(() => layout.value.subPanes); const subPanes = computed<string[]>(() => layout.value.subPanes);
const maPeriods = computed<number[]>(() => layout.value.maPeriods); const maPeriods = computed<number[]>(() => layout.value.maPeriods);
const subHeights = computed(() => layout.value.subHeights); const subHeights = computed(() => layout.value.subHeights);
const tooltipFields = computed<TooltipField[]>(() => layout.value.tooltipFields ?? DEFAULT_TOOLTIP_FIELDS);
const showBoll = ref(false); const showBoll = ref(false);
function toggleSub(key: string) { function toggleSub(key: string) {
@@ -111,6 +112,18 @@ function addCustomMa() {
showMaConfig.value = false; showMaConfig.value = false;
} }
// ---------- 浮层(鼠标悬停信息框)指标配置 ----------
const showTipConfig = ref(false);
function toggleTipField(key: TooltipField) {
const cur = tooltipFields.value;
settings.setChartLayout({
tooltipFields: cur.includes(key) ? cur.filter((k) => k !== key) : [...cur, key],
});
}
function resetTipFields() {
settings.setChartLayout({ tooltipFields: [...DEFAULT_TOOLTIP_FIELDS] });
}
// ---------- 自选股(星标) ---------- // ---------- 自选股(星标) ----------
const watched = ref(false); const watched = ref(false);
const watchBusy = ref(false); const watchBusy = ref(false);
@@ -159,7 +172,7 @@ const header = computed(() => {
}; };
}); });
// ---------- 数据加载(拉全量历史,图表内按需分页展示 ---------- // ---------- 数据加载(首屏 ~500 根秒开;图表内向左滚动时按 end 参数逐页向前翻历史 ----------
let fetchToken = 0; let fetchToken = 0;
async function load(code: string) { async function load(code: string) {
const token = ++fetchToken; const token = ++fetchToken;
@@ -168,7 +181,7 @@ async function load(code: string) {
data.value = null; data.value = null;
try { try {
const res = await getStockPreview(code, { const res = await getStockPreview(code, {
limit: 30000, limit: 500,
adjust: adjust.value, adjust: adjust.value,
timeframe: timeframe.value, timeframe: timeframe.value,
mas: maPeriods.value, mas: maPeriods.value,
@@ -180,6 +193,24 @@ async function load(code: string) {
if (token === fetchToken) loading.value = false; if (token === fetchToken) loading.value = false;
} }
} }
/** 向前翻页:取某日期之前的一页历史(复权/周期/MA 口径与首屏一致;切股或切口径后自动失效) */
async function loadOlder(end: string, count: number) {
const token = fetchToken;
try {
const res = await getStockPreview(active.value, {
limit: count,
end,
adjust: adjust.value,
timeframe: timeframe.value,
mas: maPeriods.value,
});
if (token !== fetchToken) return null;
return { candles: res.candles, indicators: res.indicators, hasMore: res.has_more ?? false };
} catch {
return null; // 网络失败:图表停止向前翻页(不中断已渲染内容)
}
}
watch(active, (code) => load(code), { immediate: true }); watch(active, (code) => load(code), { immediate: true });
watch(adjust, () => load(active.value)); watch(adjust, () => load(active.value));
watch(timeframe, () => load(active.value)); watch(timeframe, () => load(active.value));
@@ -205,7 +236,10 @@ function onKeydown(e: KeyboardEvent) {
if (e.isComposing) return; if (e.isComposing) return;
const t = e.target as HTMLElement | null; const t = e.target as HTMLElement | null;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return; if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
if (e.key === 'Escape') { if (showMaConfig.value) showMaConfig.value = false; else emit('close'); } if (e.key === 'Escape') {
if (showMaConfig.value || showTipConfig.value) { showMaConfig.value = false; showTipConfig.value = false; }
else emit('close');
}
else if (e.key === 'ArrowUp') { e.preventDefault(); moveActive(-1); } else if (e.key === 'ArrowUp') { e.preventDefault(); moveActive(-1); }
else if (e.key === 'ArrowDown') { e.preventDefault(); moveActive(1); } else if (e.key === 'ArrowDown') { e.preventDefault(); moveActive(1); }
} }
@@ -251,14 +285,14 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
</script> </script>
<template> <template>
<div class="fixed inset-0 z-40 flex flex-col bg-slate-100"> <div class="fixed inset-0 z-40 flex flex-col bg-black">
<!-- 顶栏 --> <!-- 顶栏 -->
<header class="flex h-12 shrink-0 items-center gap-3 border-b border-slate-200 bg-white px-4"> <header class="flex h-12 shrink-0 items-center gap-3 border-b border-[#26272E] bg-[#101014] px-4">
<!-- 自选星标 --> <!-- 自选星标 -->
<button <button
type="button" type="button"
class="shrink-0 rounded p-1 transition-colors hover:bg-slate-100 disabled:opacity-50" class="shrink-0 rounded p-1 transition-colors hover:bg-[#26272E] hover:text-white disabled:opacity-50"
:class="watched ? 'text-amber-500' : 'text-slate-300'" :class="watched ? 'text-amber-500' : 'text-[#C3C9D2]'"
:title="watched ? '移出自选' : '加入自选'" :title="watched ? '移出自选' : '加入自选'"
:disabled="watchBusy" :disabled="watchBusy"
@click="toggleWatch" @click="toggleWatch"
@@ -268,8 +302,8 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
</svg> </svg>
</button> </button>
<div class="flex items-baseline gap-2"> <div class="flex items-baseline gap-2">
<span class="text-base font-semibold text-slate-900">{{ header.name }}</span> <span class="text-base font-semibold text-[#E8EAED]">{{ header.name }}</span>
<span class="text-xs text-slate-400">{{ active }}</span> <span class="text-[13px] text-[#9BA3AE]">{{ active }}</span>
</div> </div>
<div class="flex items-baseline gap-2"> <div class="flex items-baseline gap-2">
<span class="text-lg font-semibold" :class="pctClass(header.pct)">{{ fmt(header.close) }}</span> <span class="text-lg font-semibold" :class="pctClass(header.pct)">{{ fmt(header.close) }}</span>
@@ -278,38 +312,38 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
</span> </span>
</div> </div>
<!-- 周期切换 --> <!-- 周期切换 -->
<div class="flex rounded-md border border-slate-200 p-0.5 text-[11px]"> <div class="flex rounded-md border border-[#26272E] p-0.5 text-xs">
<button <button
v-for="p in PERIODS" v-for="p in PERIODS"
:key="p.key" :key="p.key"
type="button" type="button"
class="rounded px-2 py-0.5 transition-colors" class="rounded px-2 py-0.5 transition-colors"
:class="timeframe === p.key ? 'bg-blue-600 text-white' : 'text-slate-500 hover:text-slate-900'" :class="timeframe === p.key ? 'bg-blue-600 text-white' : 'text-[#A8AFB8] hover:text-white'"
@click="setTimeframe(p.key)" @click="setTimeframe(p.key)"
>{{ p.label }}</button> >{{ p.label }}</button>
</div> </div>
<!-- 复权切换 --> <!-- 复权切换 -->
<div class="flex rounded-md border border-slate-200 p-0.5 text-[11px]"> <div class="flex rounded-md border border-[#26272E] p-0.5 text-xs">
<button <button
v-for="a in ADJUSTS" v-for="a in ADJUSTS"
:key="a.key" :key="a.key"
type="button" type="button"
class="rounded px-2 py-0.5 transition-colors" class="rounded px-2 py-0.5 transition-colors"
:class="adjust === a.key ? 'bg-blue-600 text-white' : 'text-slate-500 hover:text-slate-900'" :class="adjust === a.key ? 'bg-blue-600 text-white' : 'text-[#A8AFB8] hover:text-white'"
@click="setAdjust(a.key)" @click="setAdjust(a.key)"
>{{ a.label }}</button> >{{ a.label }}</button>
</div> </div>
<span v-if="data?.source === 'market'" class="rounded bg-amber-50 px-2 py-0.5 text-[11px] text-amber-600"> <span v-if="data?.source === 'market'" class="rounded bg-amber-500/15 px-2 py-0.5 text-xs text-amber-300">
近段未复权数据 近段未复权数据
</span> </span>
<span <span
v-else-if="data" v-else-if="data"
class="rounded px-2 py-0.5 text-[11px]" class="rounded px-2 py-0.5 text-xs"
:class="data.source === adjust ? 'bg-blue-50 text-blue-600' : 'bg-amber-50 text-amber-600'" :class="data.source === adjust ? 'bg-blue-500/15 text-blue-300' : 'bg-amber-500/15 text-amber-300'"
:title="data.source === adjust ? '' : '该股复权因子缺失,暂按此口径显示(可先同步市场数据)'" :title="data.source === adjust ? '' : '该股复权因子缺失,暂按此口径显示(可先同步市场数据)'"
>{{ sourceLabel }}</span> >{{ sourceLabel }}</span>
<span class="ml-auto text-xs text-slate-400"> 切换 · Esc 关闭 · 滚轮缩放 · 左滑加载历史</span> <span class="ml-auto text-[13px] text-[#9BA3AE]"> 切换 · Esc 关闭 · 滚轮缩放 · 左滑加载历史</span>
<button type="button" class="btn-ghost !px-2.5 !py-1" title="关闭 (Esc)" @click="emit('close')"> <button type="button" class="btn-ghost !px-2.5 !py-1" title="关闭 (Esc)" @click="emit('close')">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12" /></svg> <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12" /></svg>
</button> </button>
@@ -318,53 +352,53 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
<!-- 三栏主体 --> <!-- 三栏主体 -->
<div class="flex min-h-0 flex-1"> <div class="flex min-h-0 flex-1">
<!-- 命中列表 --> <!-- 命中列表 -->
<aside class="flex w-56 shrink-0 flex-col border-r border-slate-200 bg-white"> <aside class="flex w-56 shrink-0 flex-col border-r border-[#26272E] bg-[#101014]">
<div class="border-b border-slate-100 p-2"> <div class="border-b border-[#1E2026] p-2">
<input v-model="filter" type="text" class="ipt w-full !py-1 text-xs" placeholder="搜索代码 / 名称" /> <input v-model="filter" type="text" class="ipt w-full !py-1 text-[13px]" placeholder="搜索代码 / 名称" />
</div> </div>
<div class="min-h-0 flex-1 overflow-y-auto"> <div class="min-h-0 flex-1 overflow-y-auto">
<button <button
v-for="it in filteredItems" v-for="it in filteredItems"
:key="it.ts_code" :key="it.ts_code"
type="button" type="button"
class="flex w-full items-center gap-2 border-b border-slate-50 px-3 py-2 text-left transition-colors" class="flex w-full items-center gap-2 border-b border-[#1E2026] px-3 py-2 text-left transition-colors"
:class="it.ts_code === active ? 'bg-blue-50' : 'hover:bg-slate-50'" :class="it.ts_code === active ? 'bg-blue-500/15' : 'hover:bg-[#26272E]'"
@click="active = it.ts_code" @click="active = it.ts_code"
> >
<span class="min-w-0 flex-1"> <span class="min-w-0 flex-1">
<span class="block truncate text-[13px] font-medium text-slate-800">{{ it.name }}</span> <span class="block truncate text-sm font-medium text-[#E8EAED]">{{ it.name }}</span>
<span class="block text-[11px] text-slate-400">{{ it.ts_code }}</span> <span class="block text-xs text-[#9BA3AE]">{{ it.ts_code }}</span>
</span> </span>
<span class="text-right"> <span class="text-right">
<span class="block text-[13px] font-medium" :class="pctClass(it.pct_chg)">{{ fmt(it.close) }}</span> <span class="block text-sm font-medium" :class="pctClass(it.pct_chg)">{{ fmt(it.close) }}</span>
<span class="block text-[11px]" :class="pctClass(it.pct_chg)"> <span class="block text-xs" :class="pctClass(it.pct_chg)">
{{ it.pct_chg == null ? '—' : (it.pct_chg > 0 ? '+' : '') + it.pct_chg.toFixed(2) + '%' }} {{ it.pct_chg == null ? '—' : (it.pct_chg > 0 ? '+' : '') + it.pct_chg.toFixed(2) + '%' }}
</span> </span>
</span> </span>
</button> </button>
<div v-if="filteredItems.length === 0" class="px-3 py-8 text-center text-xs text-slate-400">无匹配</div> <div v-if="filteredItems.length === 0" class="px-3 py-8 text-center text-[13px] text-[#9BA3AE]">无匹配</div>
</div> </div>
<div class="border-t border-slate-100 px-3 py-2 text-[11px] text-slate-400"> {{ filteredItems.length }} </div> <div class="border-t border-[#1E2026] px-3 py-2 text-xs text-[#9BA3AE]"> {{ filteredItems.length }} </div>
</aside> </aside>
<!-- K线 + 指标面板 --> <!-- K线 + 指标面板 -->
<section class="flex min-w-0 flex-1 flex-col"> <section class="flex min-w-0 flex-1 flex-col">
<!-- 指标开关 / 排序 / MA 配置 --> <!-- 指标开关 / 排序 / MA 配置 -->
<div class="flex shrink-0 flex-wrap items-center gap-1.5 bg-white px-3 py-2"> <div class="flex shrink-0 flex-wrap items-center gap-1.5 bg-[#101014] px-3 py-2">
<span class="text-[11px] text-slate-400">副图</span> <span class="text-xs text-[#9BA3AE]">副图</span>
<div <div
v-for="s in SUBS" v-for="s in SUBS"
:key="s.key" :key="s.key"
class="flex items-center overflow-hidden rounded-md border" class="flex items-center overflow-hidden rounded-md border"
:class="subPanes.includes(s.key) ? 'border-blue-600' : 'border-slate-200'" :class="subPanes.includes(s.key) ? 'border-blue-500' : 'border-[#26272E]'"
> >
<button <button
type="button" type="button"
draggable="true" draggable="true"
class="px-2.5 py-1 text-xs transition-colors" class="px-2.5 py-1 text-[13px] transition-colors"
:class="subPanes.includes(s.key) :class="subPanes.includes(s.key)
? 'bg-blue-600 text-white' ? 'bg-blue-600 text-white'
: 'bg-white text-slate-400 line-through'" : 'bg-[#101014] text-[#9BA3AE] line-through'"
:title="subPanes.includes(s.key) ? '点击隐藏 · 拖动排序 · 右侧按钮调高度' : '点击显示'" :title="subPanes.includes(s.key) ? '点击隐藏 · 拖动排序 · 右侧按钮调高度' : '点击显示'"
@click="toggleSub(s.key)" @click="toggleSub(s.key)"
@dragstart="onDragStart($event, s.key)" @dragstart="onDragStart($event, s.key)"
@@ -374,14 +408,14 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
{{ s.label }} {{ s.label }}
</button> </button>
<template v-if="subPanes.includes(s.key)"> <template v-if="subPanes.includes(s.key)">
<button type="button" class="border-l px-1 py-1 text-[10px] text-slate-400 hover:bg-slate-100 hover:text-slate-700" title="调高" @click="adjustHeight(s.key, 20)"></button> <button type="button" class="border-l px-1 py-1 text-xs text-[#9BA3AE] hover:bg-[#1E2026] hover:text-[#E8EAED]" title="调高" @click="adjustHeight(s.key, 20)"></button>
<button type="button" class="border-l px-1 py-1 text-[10px] text-slate-400 hover:bg-slate-100 hover:text-slate-700" title="调矮" @click="adjustHeight(s.key, -20)"></button> <button type="button" class="border-l px-1 py-1 text-xs text-[#9BA3AE] hover:bg-[#1E2026] hover:text-[#E8EAED]" title="调矮" @click="adjustHeight(s.key, -20)"></button>
</template> </template>
</div> </div>
<button <button
type="button" type="button"
class="rounded-md border px-2.5 py-1 text-xs transition-colors" class="rounded-md border px-2.5 py-1 text-[13px] transition-colors"
:class="showBoll ? 'border-purple-500 bg-purple-500 text-white' : 'border-slate-200 bg-white text-slate-400'" :class="showBoll ? 'border-purple-500 bg-purple-500 text-white' : 'border-[#26272E] bg-[#101014] text-[#9BA3AE]'"
title="主图叠加布林带" title="主图叠加布林带"
@click="showBoll = !showBoll" @click="showBoll = !showBoll"
>BOLL</button> >BOLL</button>
@@ -389,20 +423,20 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
<div class="relative"> <div class="relative">
<button <button
type="button" type="button"
class="rounded-md border border-slate-200 bg-white px-2.5 py-1 text-xs text-slate-500 transition-colors hover:text-slate-900" class="rounded-md border border-[#26272E] bg-[#101014] px-2.5 py-1 text-[13px] text-[#A8AFB8] transition-colors hover:border-[#3A3D46] hover:text-[#E8EAED]"
@click="showMaConfig = !showMaConfig" @click="showMaConfig = !showMaConfig; showTipConfig = false"
>MA 设置</button> >MA 设置</button>
<div <div
v-if="showMaConfig" v-if="showMaConfig"
class="absolute left-0 top-8 z-20 w-52 rounded-lg border border-slate-200 bg-white p-2.5 shadow-lg" class="absolute left-0 top-8 z-20 w-52 rounded-lg border border-[#33353D] bg-[#16181D] p-2.5 shadow-lg shadow-black/60"
> >
<div class="mb-2 text-[11px] text-slate-400">勾选主图显示的均线</div> <div class="mb-2 text-xs text-[#9BA3AE]">勾选主图显示的均线</div>
<div class="grid grid-cols-4 gap-1"> <div class="grid grid-cols-4 gap-1">
<label <label
v-for="p in MA_PRESETS" v-for="p in MA_PRESETS"
:key="p" :key="p"
class="flex cursor-pointer items-center justify-center rounded border px-1 py-1 text-xs" class="flex cursor-pointer items-center justify-center rounded border px-1 py-1 text-[13px]"
:class="maPeriods.includes(p) ? 'border-blue-600 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-500'" :class="maPeriods.includes(p) ? 'border-blue-500 bg-blue-500/15 text-blue-300' : 'border-[#33353D] text-[#A8AFB8] hover:border-[#3A3D46] hover:text-[#E8EAED]'"
> >
<input type="checkbox" class="hidden" :checked="maPeriods.includes(p)" @change="toggleMa(p)" /> <input type="checkbox" class="hidden" :checked="maPeriods.includes(p)" @change="toggleMa(p)" />
MA{{ p }} MA{{ p }}
@@ -412,47 +446,79 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
<input <input
v-model="customMa" v-model="customMa"
type="number" min="1" max="500" type="number" min="1" max="500"
class="ipt w-full !py-1 text-xs" class="ipt w-full !py-1 text-[13px]"
placeholder="自定义周期" placeholder="自定义周期"
@keyup.enter="addCustomMa" @keyup.enter="addCustomMa"
/> />
<button type="button" class="btn-primary !px-2 !py-1 text-xs" @click="addCustomMa"></button> <button type="button" class="btn-primary !px-2 !py-1 text-[13px]" @click="addCustomMa"></button>
</div> </div>
<div class="mt-1.5 text-[11px] text-slate-400">当前{{ maPeriods.map((p: number) => 'MA' + p).join(' / ') || '无' }}</div> <div class="mt-1.5 text-xs text-[#9BA3AE]">当前{{ maPeriods.map((p: number) => 'MA' + p).join(' / ') || '无' }}</div>
</div> </div>
</div> </div>
<span class="ml-auto text-[11px] text-slate-400">点击开关 · 拖动排序 · 调高度 · 右上工具栏画线</span> <!-- 浮层指标配置鼠标悬停信息框每行显示哪些指标 -->
<div class="relative">
<button
type="button"
class="rounded-md border border-[#26272E] bg-[#101014] px-2.5 py-1 text-[13px] text-[#A8AFB8] transition-colors hover:border-[#3A3D46] hover:text-[#E8EAED]"
@click="showTipConfig = !showTipConfig; showMaConfig = false"
>浮层设置</button>
<div
v-if="showTipConfig"
class="absolute left-0 top-8 z-20 w-56 rounded-lg border border-[#33353D] bg-[#16181D] p-2.5 shadow-lg shadow-black/60"
>
<div class="mb-2 text-xs text-[#9BA3AE]">勾选鼠标浮层里逐行显示的指标</div>
<div class="grid grid-cols-2 gap-1">
<label
v-for="f in TOOLTIP_FIELDS"
:key="f.key"
class="flex cursor-pointer items-center justify-center rounded border px-1 py-1 text-[13px]"
:class="tooltipFields.includes(f.key) ? 'border-blue-500 bg-blue-500/15 text-blue-300' : 'border-[#33353D] text-[#A8AFB8] hover:border-[#3A3D46] hover:text-[#E8EAED]'"
>
<input type="checkbox" class="hidden" :checked="tooltipFields.includes(f.key)" @change="toggleTipField(f.key)" />
{{ f.label }}
</label>
</div>
<div class="mt-2 flex items-center justify-between">
<span class="text-xs text-[#9BA3AE]">已选 {{ tooltipFields.length }}/{{ TOOLTIP_FIELDS.length }}首行日期固定</span>
<button type="button" class="text-xs text-blue-600 hover:underline" @click="resetTipFields">恢复默认</button>
</div>
</div>
</div>
<span class="ml-auto text-xs text-[#9BA3AE]">点击开关 · 拖动排序 · 调高度 · 右上工具栏画线</span>
</div> </div>
<!-- 图表 --> <!-- 图表 -->
<div class="relative min-h-0 flex-1 bg-white p-1"> <div class="relative min-h-0 flex-1 bg-black p-1">
<div v-if="loading" class="absolute inset-0 z-10 flex flex-col items-center justify-center bg-white/80 text-sm text-slate-400"> <div v-if="loading" class="absolute inset-0 z-10 flex flex-col items-center justify-center bg-black/85 text-sm text-[#9BA3AE]">
<svg class="mb-2 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> <svg class="mb-2 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>
{{ active }} 加载日线中 {{ active }} 加载日线中
</div> </div>
<div v-else-if="error" class="flex h-full items-center justify-center text-sm text-red-600">{{ error }}</div> <div v-else-if="error" class="flex h-full items-center justify-center text-sm text-red-400">{{ error }}</div>
<DetailKLine <DetailKLine
v-else-if="data && data.candles.length" v-else-if="data && data.candles.length"
:ticker="data.ts_code" :ticker="data.ts_code"
:candles="data.candles" :candles="data.candles"
:indicators="data.indicators" :indicators="data.indicators"
:has-more="data.has_more ?? false"
:load-older="loadOlder"
:sub-panes="subPanes" :sub-panes="subPanes"
:ma-periods="maPeriods" :ma-periods="maPeriods"
:sub-heights="subHeights" :sub-heights="subHeights"
:show-boll="showBoll" :show-boll="showBoll"
:timeframe="timeframe" :timeframe="timeframe"
:tooltip-fields="tooltipFields"
/> />
<div v-else class="flex h-full items-center justify-center text-sm text-slate-400">无数据</div> <div v-else class="flex h-full items-center justify-center text-sm text-[#9BA3AE]">无数据</div>
</div> </div>
</section> </section>
<!-- 个股信息通达信式 --> <!-- 个股信息通达信式 -->
<aside v-if="data" class="w-72 shrink-0 overflow-y-auto border-l border-slate-200 bg-white p-4"> <aside v-if="data" class="w-72 shrink-0 overflow-y-auto border-l border-[#26272E] bg-[#101014] p-4">
<div class="border-b border-slate-100 pb-3"> <div class="border-b border-[#1E2026] pb-3">
<div class="text-[15px] font-semibold text-slate-900">{{ data.info.name }}</div> <div class="text-[15px] font-semibold text-[#E8EAED]">{{ data.info.name }}</div>
<div class="mt-0.5 text-xs text-slate-400"> <div class="mt-0.5 text-[13px] text-[#9BA3AE]">
{{ data.info.ts_code }} {{ data.info.ts_code }}
<span v-if="data.info.market" class="ml-1 rounded bg-slate-100 px-1.5 py-0.5">{{ data.info.market }}</span> <span v-if="data.info.market" class="ml-1 rounded bg-[#1E2026] px-1.5 py-0.5">{{ data.info.market }}</span>
</div> </div>
<div class="mt-2 flex items-baseline gap-2"> <div class="mt-2 flex items-baseline gap-2">
<span class="text-2xl font-semibold" :class="pctClass(data.info.pct_chg)">{{ fmt(data.info.close) }}</span> <span class="text-2xl font-semibold" :class="pctClass(data.info.pct_chg)">{{ fmt(data.info.close) }}</span>
@@ -462,7 +528,7 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
</div> </div>
</div> </div>
<div class="mt-3 grid grid-cols-2 gap-y-2 text-[13px]"> <div class="mt-3 grid grid-cols-2 gap-y-2 text-sm">
<template v-for="(row, i) in [ <template v-for="(row, i) in [
['今开', fmt(data.info.open)], ['今开', fmt(data.info.open)],
['昨收', fmt(data.info.pre_close)], ['昨收', fmt(data.info.pre_close)],
@@ -481,14 +547,14 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
['上市日期', fmtListDate(data.info.list_date)], ['上市日期', fmtListDate(data.info.list_date)],
['数据日期', (data.info.trade_date ?? '').slice(0, 10) || '—'], ['数据日期', (data.info.trade_date ?? '').slice(0, 10) || '—'],
]" :key="i"> ]" :key="i">
<span class="text-slate-400">{{ row[0] }}</span> <span class="text-[#9BA3AE]">{{ row[0] }}</span>
<span class="text-right text-slate-800">{{ row[1] }}</span> <span class="text-right text-[#E8EAED]">{{ row[1] }}</span>
</template> </template>
</div> </div>
<!-- 股本/分红/股东(数据未接入前留空占位) --> <!-- 股本/分红/股东(数据未接入前留空占位) -->
<div class="mt-4 border-t border-slate-100 pt-3 text-[13px]"> <div class="mt-4 border-t border-[#1E2026] pt-3 text-sm">
<div class="mb-2 text-xs text-slate-400">股本 / 分红 / 股东</div> <div class="mb-2 text-[13px] text-[#9BA3AE]">股本 / 分红 / 股东</div>
<div class="grid grid-cols-2 gap-y-2"> <div class="grid grid-cols-2 gap-y-2">
<template v-for="(row, i) in [ <template v-for="(row, i) in [
['股东户数', '—'], ['股东户数', '—'],
@@ -496,17 +562,17 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
['分红率', '—'], ['分红率', '—'],
['股息率', '—'], ['股息率', '—'],
]" :key="i"> ]" :key="i">
<span class="text-slate-400">{{ row[0] }}</span> <span class="text-[#9BA3AE]">{{ row[0] }}</span>
<span class="text-right text-slate-300" title="数据源待接入">{{ row[1] }}</span> <span class="text-right text-[#C3C9D2]" title="数据源待接入">{{ row[1] }}</span>
</template> </template>
</div> </div>
</div> </div>
<div class="mt-4 border-t border-slate-100 pt-3 text-[13px]"> <div class="mt-4 border-t border-[#1E2026] pt-3 text-sm">
<div class="mb-2 text-xs text-slate-400">归属</div> <div class="mb-2 text-[13px] text-[#9BA3AE]">归属</div>
<div class="flex flex-wrap gap-1.5"> <div class="flex flex-wrap gap-1.5">
<span v-if="data.info.industry" class="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs text-slate-600">{{ data.info.industry }}</span> <span v-if="data.info.industry" class="rounded-full bg-[#1E2026] px-2.5 py-0.5 text-[13px] text-[#A8AFB8]">{{ data.info.industry }}</span>
<span v-if="data.info.area" class="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs text-slate-600">{{ data.info.area }}</span> <span v-if="data.info.area" class="rounded-full bg-[#1E2026] px-2.5 py-0.5 text-[13px] text-[#A8AFB8]">{{ data.info.area }}</span>
</div> </div>
</div> </div>
</aside> </aside>

View File

@@ -24,8 +24,8 @@ const freshness = computed(() => {
</script> </script>
<template> <template>
<div class="mt-4 flex flex-wrap items-center gap-x-4 gap-y-2 rounded-xl border border-slate-200 bg-white px-4 py-3 text-[13px] text-slate-600"> <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-600' : freshness.tone === 'warn' ? 'text-amber-600' : 'text-slate-400'"> <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"> <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'"> <template v-if="freshness.tone === 'ok'">
<path d="M22 11.1V12a10 10 0 11-5.9-9.1" /> <path d="M22 11.1V12a10 10 0 11-5.9-9.1" />
@@ -42,8 +42,8 @@ const freshness = computed(() => {
<template v-if="status && status.running"> <template v-if="status && status.running">
<span class="flex min-w-[200px] flex-1 items-center gap-2"> <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> <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-xs text-slate-500">{{ status.step || '同步中…' }}{{ status.done_days }}/{{ status.total_days }}</span> <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-slate-100"> <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 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>
</span> </span>
@@ -56,7 +56,7 @@ const freshness = computed(() => {
</button> </button>
</template> </template>
<div v-if="status && status.error" class="w-full text-amber-600"> <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> <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 }} {{ status.error }}
</div> </div>

View File

@@ -51,12 +51,15 @@ export const useAuthStore = defineStore('auth', () => {
} finally { } finally {
user.value = null; user.value = null;
initialized.value = true; initialized.value = true;
// 同一标签页换账号登录时settings 需要重新拉取新账号的偏好
useSettingsStore().invalidateSync();
} }
} }
function clear() { function clear() {
user.value = null; user.value = null;
initialized.value = true; initialized.value = true;
useSettingsStore().invalidateSync();
} }
return { user, initialized, loading, isAuthenticated, restore, login, logout, clear }; return { user, initialized, loading, isAuthenticated, restore, login, logout, clear };

View File

@@ -1,15 +1,20 @@
@import "tailwindcss"; @import "tailwindcss";
/* ---------- 主题浅色简洁A股语义红涨绿跌 ---------- */ /* 全局暗色 UA 样式:原生 date 日历面板、select 弹层、滚动条随黑主题渲染 */
:root {
color-scheme: dark;
}
/* ---------- 主题黑色终端A股语义红涨绿跌 ---------- */
@theme { @theme {
--color-up: #dc2626; /* 涨 / 买入 = 红 */ --color-up: #FE354B; /* 涨 / 买入 = 红 */
--color-down: #16a34a; /* 跌 / 卖出 = 绿 */ --color-down: #1EBE72; /* 跌 / 卖出 = 绿 */
--color-accent: #2563eb; --color-accent: #2563eb;
--font-sans: system-ui, -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; --font-sans: system-ui, -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
} }
body { body {
@apply bg-slate-50 text-slate-900 antialiased; @apply bg-black text-[#E8EAED] antialiased;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
@@ -20,57 +25,57 @@ body {
place-items: center; place-items: center;
padding: 24px; padding: 24px;
background: background:
linear-gradient(#e2e8f0 1px, transparent 1px), linear-gradient(#1B1E24 1px, transparent 1px),
linear-gradient(90deg, #e2e8f0 1px, transparent 1px), linear-gradient(90deg, #1B1E24 1px, transparent 1px),
#f8fafc; #060708;
background-size: 40px 40px; background-size: 40px 40px;
} }
.login-panel { .login-panel {
width: min(100%, 420px); width: min(100%, 420px);
border: 1px solid #cbd5e1; border: 1px solid #33353D;
border-top: 3px solid #2563eb; border-top: 3px solid #2563eb;
border-radius: 6px; border-radius: 6px;
background: #fff; background: #101014;
padding: 30px; padding: 30px;
box-shadow: 0 18px 50px rgb(15 23 42 / 10%); box-shadow: 0 18px 50px rgb(0 0 0 / 55%);
} }
.login-brand { display: flex; align-items: center; gap: 12px; } .login-brand { display: flex; align-items: center; gap: 12px; }
.login-brand-mark { .login-brand-mark {
display: grid; place-items: center; width: 38px; height: 38px; border-radius: 5px; display: grid; place-items: center; width: 38px; height: 38px; border-radius: 5px;
background: #0f172a; color: #fff; font-size: 17px; font-weight: 700; background: #2563eb; color: #fff; font-size: 17px; font-weight: 700;
} }
.login-brand-name { color: #0f172a; font-size: 14px; font-weight: 650; } .login-brand-name { color: #E8EAED; font-size: 14px; font-weight: 650; }
.login-brand-meta { margin-top: 2px; color: #64748b; font-size: 9px; font-family: ui-monospace, monospace; } .login-brand-meta { margin-top: 2px; color: #A8AFB8; font-size: 12px; font-family: ui-monospace, monospace; }
.market-track { display: grid; grid-template-columns: repeat(13, 1fr); height: 17px; margin: 26px 0 20px; border-bottom: 1px solid #cbd5e1; } .market-track { display: grid; grid-template-columns: repeat(13, 1fr); height: 17px; margin: 26px 0 20px; border-bottom: 1px solid #33353D; }
.market-track span { width: 1px; height: 5px; align-self: end; background: #94a3b8; } .market-track span { width: 1px; height: 5px; align-self: end; background: #33353D; }
.market-track span.major { height: 10px; background: #2563eb; } .market-track span.major { height: 10px; background: #2563eb; }
.login-heading h1 { margin-top: 7px; color: #0f172a; font-size: 28px; line-height: 1.2; font-weight: 680; } .login-heading h1 { margin-top: 7px; color: #E8EAED; font-size: 28px; line-height: 1.2; font-weight: 680; }
.login-heading > p:last-child { margin-top: 8px; color: #64748b; font-size: 13px; line-height: 1.7; } .login-heading > p:last-child { margin-top: 8px; color: #A8AFB8; font-size: 13px; line-height: 1.7; }
.login-status { display: flex; align-items: center; gap: 7px; color: #475569; font-size: 11px; font-weight: 600; } .login-status { display: flex; align-items: center; gap: 7px; color: #A8AFB8; font-size: 12px; font-weight: 600; }
.login-status span { width: 7px; height: 7px; border-radius: 50%; background: #16a34a; box-shadow: 0 0 0 3px #dcfce7; } .login-status span { width: 7px; height: 7px; border-radius: 50%; background: #1EBE72; box-shadow: 0 0 0 3px rgb(30 190 114 / 20%); }
.login-form { display: grid; gap: 18px; margin-top: 26px; } .login-form { display: grid; gap: 18px; margin-top: 26px; }
.login-form label > span { display: block; margin-bottom: 7px; color: #475569; font-size: 12px; font-weight: 600; } .login-form label > span { display: block; margin-bottom: 7px; color: #A8AFB8; font-size: 13px; font-weight: 600; }
.login-form input { .login-form input {
width: 100%; height: 42px; border: 1px solid #cbd5e1; border-radius: 5px; background: #fff; width: 100%; height: 42px; border: 1px solid #33353D; border-radius: 5px; background: #16181D;
padding: 0 12px; color: #0f172a; font-size: 14px; outline: none; transition: border-color .15s, box-shadow .15s; padding: 0 12px; color: #E8EAED; font-size: 14px; outline: none; transition: border-color .15s, box-shadow .15s;
} }
.login-form input:focus { border-color: #2563eb; box-shadow: 0 0 0 3px #dbeafe; } .login-form input:focus { border-color: #2563eb; box-shadow: 0 0 0 3px rgb(37 99 235 / 25%); }
.password-field { position: relative; } .password-field { position: relative; }
.password-field input { padding-right: 42px; } .password-field input { padding-right: 42px; }
.password-toggle { .password-toggle {
position: absolute; top: 3px; right: 3px; display: grid; place-items: center; width: 36px; height: 36px; position: absolute; top: 3px; right: 3px; display: grid; place-items: center; width: 36px; height: 36px;
border-radius: 4px; color: #64748b; border-radius: 4px; color: #A8AFB8;
} }
.password-toggle:hover { background: #f1f5f9; color: #0f172a; } .password-toggle:hover { background: #1E2026; color: #E8EAED; }
.password-toggle:focus-visible { outline: 2px solid #2563eb; outline-offset: 1px; } .password-toggle:focus-visible { outline: 2px solid #2563eb; outline-offset: 1px; }
.password-toggle svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } .password-toggle svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.login-error { border-left: 3px solid #dc2626; background: #fef2f2; padding: 9px 11px; color: #b91c1c; font-size: 12px; line-height: 1.5; } .login-error { border-left: 3px solid #FE354B; background: rgb(254 53 75 / 12%); padding: 9px 11px; color: #FF7A89; font-size: 13px; line-height: 1.5; }
.login-submit { .login-submit {
display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 42px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 42px;
border-radius: 5px; background: #2563eb; color: #fff; font-size: 14px; font-weight: 650; border-radius: 5px; background: #2563eb; color: #fff; font-size: 14px; font-weight: 650;
@@ -81,7 +86,7 @@ body {
.login-submit:focus-visible { outline: 2px solid #2563eb; outline-offset: 3px; } .login-submit:focus-visible { outline: 2px solid #2563eb; outline-offset: 3px; }
.login-submit:disabled { cursor: not-allowed; opacity: .55; } .login-submit:disabled { cursor: not-allowed; opacity: .55; }
.login-spinner { width: 14px; height: 14px; border: 2px solid rgb(255 255 255 / 45%); border-top-color: #fff; border-radius: 50%; animation: login-spin .7s linear infinite; } .login-spinner { width: 14px; height: 14px; border: 2px solid rgb(255 255 255 / 45%); border-top-color: #fff; border-radius: 50%; animation: login-spin .7s linear infinite; }
.login-footnote { margin-top: 22px; border-top: 1px solid #e2e8f0; padding-top: 16px; color: #94a3b8; font-size: 10px; text-align: center; } .login-footnote { margin-top: 22px; border-top: 1px solid #26272E; padding-top: 16px; color: #7A818C; font-size: 12px; text-align: center; }
@keyframes login-spin { to { transform: rotate(360deg); } } @keyframes login-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .login-spinner { animation: none; } } @media (prefers-reduced-motion: reduce) { .login-spinner { animation: none; } }
@@ -94,11 +99,11 @@ body {
@layer components { @layer components {
/* 表单输入 */ /* 表单输入 */
.ipt { .ipt {
@apply rounded-md border border-slate-300 bg-white px-2.5 py-1.5 text-sm text-slate-900 @apply rounded-md border border-[#33353D] bg-[#16181D] px-2.5 py-1.5 text-sm text-[#E8EAED]
outline-none transition-colors placeholder:text-slate-400 outline-none transition-colors placeholder:text-[#7A818C]
focus:border-blue-500 focus:ring-2 focus:ring-blue-100; focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30;
} }
select.ipt { @apply pr-7 appearance-none bg-[url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%20fill%3D%22%2364748b%22%3E%3Cpath%20d%3D%22M4.5%206l3.5%203.5L11.5%206z%22%2F%3E%3C%2Fsvg%3E')] bg-[length:16px] bg-[right_0.4rem_center] bg-no-repeat; } select.ipt { @apply pr-7 appearance-none bg-[url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%20fill%3D%22%23A8AFB8%22%3E%3Cpath%20d%3D%22M4.5%206l3.5%203.5L11.5%206z%22%2F%3E%3C%2Fsvg%3E')] bg-[length:16px] bg-[right_0.4rem_center] bg-no-repeat; }
/* 主 / 次按钮 */ /* 主 / 次按钮 */
.btn-primary { .btn-primary {
@@ -107,11 +112,11 @@ body {
disabled:cursor-not-allowed disabled:opacity-50; disabled:cursor-not-allowed disabled:opacity-50;
} }
.btn-ghost { .btn-ghost {
@apply inline-flex items-center justify-center gap-1.5 rounded-md border border-slate-300 bg-white @apply inline-flex items-center justify-center gap-1.5 rounded-md border border-[#33353D] bg-[#16181D]
px-3.5 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 px-3.5 py-1.5 text-sm font-medium text-[#E8EAED] transition-colors hover:border-[#3A3D46] hover:bg-[#1E2026]
active:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50; active:bg-[#26272E] disabled:cursor-not-allowed disabled:opacity-50;
} }
/* 字段标签 */ /* 字段标签 */
.lbl { @apply mb-1 block text-xs font-medium tracking-wide text-slate-500; } .lbl { @apply mb-1 block text-[13px] font-medium tracking-wide text-[#A8AFB8]; }
} }

View File

@@ -86,23 +86,23 @@ async function rerunAdjusted() {
<template> <template>
<div> <div>
<!-- 输入区 --> <!-- 输入区 -->
<div class="rounded-xl border border-slate-200 bg-white p-4"> <div class="rounded-xl border border-[#26272E] bg-[#101014] p-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="text-[13px] font-medium text-slate-600">事件回测描述一个信号 次日买入 持有 N 的事件统计历史上全市场或单只股票的收益分布</div> <div class="text-sm font-medium text-[#A8AFB8]">事件回测描述一个信号 次日买入 持有 N 的事件统计历史上全市场或单只股票的收益分布</div>
</div> </div>
<textarea <textarea
v-model="text" v-model="text"
rows="2" rows="2"
class="mt-3 w-full resize-none rounded-lg border border-slate-200 px-3 py-2 text-[13px] text-slate-800 outline-none focus:border-blue-400" class="mt-3 w-full resize-none rounded-lg border border-[#26272E] bg-[#16181D] px-3 py-2 text-sm text-[#E8EAED] outline-none placeholder:text-[#7A818C] focus:border-blue-500"
placeholder="例:在连续三天 J 小于 10 的时候第二天开盘购买,之后未来三天的涨幅有多少" placeholder="例:在连续三天 J 小于 10 的时候第二天开盘购买,之后未来三天的涨幅有多少"
@keydown.ctrl.enter="run()" @keydown.ctrl.enter="run()"
/> />
<div class="mt-2 flex flex-wrap items-center gap-2"> <div class="mt-2 flex flex-wrap items-center gap-2">
<span class="text-[12px] text-slate-400">试试</span> <span class="text-[13px] text-[#9BA3AE]">试试</span>
<button <button
v-for="ex in EXAMPLES" v-for="ex in EXAMPLES"
:key="ex" :key="ex"
class="rounded-full border border-slate-200 px-2.5 py-1 text-[12px] text-slate-600 hover:border-blue-300 hover:text-blue-600" class="rounded-full border border-[#26272E] px-2.5 py-1 text-[13px] text-[#A8AFB8] hover:border-blue-500 hover:text-blue-300"
@click="text = ex" @click="text = ex"
> >
{{ ex.length > 26 ? ex.slice(0, 26) + '…' : ex }} {{ ex.length > 26 ? ex.slice(0, 26) + '…' : ex }}
@@ -110,48 +110,48 @@ async function rerunAdjusted() {
</div> </div>
<div class="mt-3 flex flex-wrap items-end gap-3"> <div class="mt-3 flex flex-wrap items-end gap-3">
<label class="text-[12px] text-slate-500"> <label class="text-[13px] text-[#A8AFB8]">
股票范围 股票范围
<div class="mt-1 flex overflow-hidden rounded-lg border border-slate-200 text-[12px]"> <div class="mt-1 flex overflow-hidden rounded-lg border border-[#26272E] text-[13px]">
<button <button
class="px-3 py-1.5" class="px-3 py-1.5"
:class="tsCode ? 'bg-white text-slate-600' : 'bg-blue-600 text-white'" :class="tsCode ? 'bg-[#101014] text-[#A8AFB8]' : 'bg-blue-600 text-white'"
@click="tsCode = ''" @click="tsCode = ''"
>全市场</button> >全市场</button>
<button <button
class="px-3 py-1.5" class="px-3 py-1.5"
:class="tsCode ? 'bg-blue-600 text-white' : 'bg-white text-slate-600'" :class="tsCode ? 'bg-blue-600 text-white' : 'bg-[#101014] text-[#A8AFB8]'"
@click="tsCode ||= '000001.SZ'" @click="tsCode ||= '000001.SZ'"
>单只股票</button> >单只股票</button>
</div> </div>
</label> </label>
<label v-if="tsCode" class="text-[12px] text-slate-500"> <label v-if="tsCode" class="text-[13px] text-[#A8AFB8]">
股票代码 股票代码
<input <input
v-model="tsCode" v-model="tsCode"
class="mt-1 block w-40 rounded-lg border border-slate-200 px-3 py-1.5 text-[13px] outline-none focus:border-blue-400" class="mt-1 block w-40 rounded-lg border border-[#26272E] bg-[#16181D] px-3 py-1.5 text-sm text-[#E8EAED] outline-none placeholder:text-[#7A818C] focus:border-blue-500"
placeholder="000001.SZ" placeholder="000001.SZ"
/> />
</label> </label>
<label class="text-[12px] text-slate-500"> <label class="text-[13px] text-[#A8AFB8]">
开始日期 开始日期
<input <input
v-model="startDate" v-model="startDate"
type="date" type="date"
class="mt-1 block rounded-lg border border-slate-200 px-3 py-1.5 text-[13px] outline-none focus:border-blue-400" class="mt-1 block rounded-lg border border-[#26272E] bg-[#16181D] px-3 py-1.5 text-sm text-[#E8EAED] outline-none placeholder:text-[#7A818C] focus:border-blue-500"
/> />
</label> </label>
<label class="text-[12px] text-slate-500"> <label class="text-[13px] text-[#A8AFB8]">
结束日期 结束日期
<input <input
v-model="endDate" v-model="endDate"
type="date" type="date"
class="mt-1 block rounded-lg border border-slate-200 px-3 py-1.5 text-[13px] outline-none focus:border-blue-400" class="mt-1 block rounded-lg border border-[#26272E] bg-[#16181D] px-3 py-1.5 text-sm text-[#E8EAED] outline-none placeholder:text-[#7A818C] focus:border-blue-500"
/> />
</label> </label>
<span class="text-[11px] text-slate-400">留空默认最近一年</span> <span class="text-xs text-[#9BA3AE]">留空默认最近一年</span>
<button <button
class="ml-auto rounded-lg bg-blue-600 px-5 py-2 text-[13px] font-medium text-white hover:bg-blue-700 disabled:opacity-50" class="ml-auto rounded-lg bg-blue-600 px-5 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
:disabled="loading" :disabled="loading"
@click="run()" @click="run()"
> >
@@ -161,21 +161,21 @@ async function rerunAdjusted() {
</div> </div>
<!-- 错误 --> <!-- 错误 -->
<div v-if="error" class="mt-4 flex items-start gap-2 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-[13px] text-red-700"> <div v-if="error" class="mt-4 flex items-start gap-2 rounded-xl border border-red-500/40 bg-red-500/15 px-4 py-3 text-sm text-red-300">
<svg class="mt-0.5 h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 3.9L1.8 18a2 2 0 001.7 3h17a2 2 0 001.7-3L13.7 3.9a2 2 0 00-3.4 0z" /><path d="M12 9v4M12 17h.01" /></svg> <svg class="mt-0.5 h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 3.9L1.8 18a2 2 0 001.7 3h17a2 2 0 001.7-3L13.7 3.9a2 2 0 00-3.4 0z" /><path d="M12 9v4M12 17h.01" /></svg>
{{ error }} {{ error }}
</div> </div>
<!-- 加载中 --> <!-- 加载中 -->
<div v-if="loading && note" class="py-16 text-center text-sm text-slate-400"> <div v-if="loading && 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> <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>
{{ note }} {{ note }}
</div> </div>
<!-- 结果 --> <!-- 结果 -->
<template v-else-if="result && stats"> <template v-else-if="result && stats">
<div class="mt-4 rounded-xl border border-slate-200 bg-white px-4 py-3"> <div class="mt-4 rounded-xl border border-[#26272E] bg-[#101014] px-4 py-3">
<div class="mb-2 text-[12px] text-slate-400"> <div class="mb-2 text-[13px] text-[#9BA3AE]">
信号条件{{ result.universe === 'all' ? '全市场' : result.universe }}{{ fmtDate(result.start) }} ~ {{ fmtDate(result.end) }} 信号条件{{ result.universe === 'all' ? '全市场' : result.universe }}{{ fmtDate(result.start) }} ~ {{ fmtDate(result.end) }}
</div> </div>
<ConditionChips :conditions="result.spec.entry" /> <ConditionChips :conditions="result.spec.entry" />
@@ -183,75 +183,75 @@ async function rerunAdjusted() {
<!-- 统计卡片 --> <!-- 统计卡片 -->
<div class="mt-4 grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-6"> <div class="mt-4 grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-6">
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3"> <div class="rounded-xl border border-[#26272E] bg-[#101014] px-4 py-3">
<div class="text-[11px] text-slate-400">样本数</div> <div class="text-xs text-[#9BA3AE]">样本数</div>
<div class="mt-1 text-xl font-semibold text-slate-800">{{ stats.samples.toLocaleString() }}</div> <div class="mt-1 text-xl font-semibold text-[#E8EAED]">{{ stats.samples.toLocaleString() }}</div>
<div class="text-[11px] text-slate-400">{{ stats.stocks.toLocaleString() }} 只股票</div> <div class="text-xs text-[#9BA3AE]">{{ stats.stocks.toLocaleString() }} 只股票</div>
</div> </div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3"> <div class="rounded-xl border border-[#26272E] bg-[#101014] px-4 py-3">
<div class="text-[11px] text-slate-400">平均涨幅</div> <div class="text-xs text-[#9BA3AE]">平均涨幅</div>
<div class="mt-1 text-xl font-semibold" :class="stats.mean_pct >= 0 ? 'text-red-600' : 'text-green-600'">{{ fmtPct(stats.mean_pct) }}</div> <div class="mt-1 text-xl font-semibold" :class="stats.mean_pct >= 0 ? 'text-up' : 'text-down'">{{ fmtPct(stats.mean_pct) }}</div>
<div class="text-[11px] text-slate-400">中位数 {{ fmtPct(stats.median_pct) }}</div> <div class="text-xs text-[#9BA3AE]">中位数 {{ fmtPct(stats.median_pct) }}</div>
</div> </div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3"> <div class="rounded-xl border border-[#26272E] bg-[#101014] px-4 py-3">
<div class="text-[11px] text-slate-400">胜率</div> <div class="text-xs text-[#9BA3AE]">胜率</div>
<div class="mt-1 text-xl font-semibold text-slate-800">{{ stats.win_rate.toFixed(2) }}%</div> <div class="mt-1 text-xl font-semibold text-[#E8EAED]">{{ stats.win_rate.toFixed(2) }}%</div>
<div class="text-[11px] text-slate-400">波动 σ {{ stats.std_pct.toFixed(2) }}</div> <div class="text-xs text-[#9BA3AE]">波动 σ {{ stats.std_pct.toFixed(2) }}</div>
</div> </div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3"> <div class="rounded-xl border border-[#26272E] bg-[#101014] px-4 py-3">
<div class="text-[11px] text-slate-400">P10 / P25</div> <div class="text-xs text-[#9BA3AE]">P10 / P25</div>
<div class="mt-1 text-[15px] font-semibold text-green-700">{{ fmtPct(stats.p10_pct) }} / {{ fmtPct(stats.p25_pct) }}</div> <div class="mt-1 text-[15px] font-semibold text-down">{{ fmtPct(stats.p10_pct) }} / {{ fmtPct(stats.p25_pct) }}</div>
<div class="text-[11px] text-slate-400">最差 {{ fmtPct(stats.min_pct) }}</div> <div class="text-xs text-[#9BA3AE]">最差 {{ fmtPct(stats.min_pct) }}</div>
</div> </div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3"> <div class="rounded-xl border border-[#26272E] bg-[#101014] px-4 py-3">
<div class="text-[11px] text-slate-400">P75 / P90</div> <div class="text-xs text-[#9BA3AE]">P75 / P90</div>
<div class="mt-1 text-[15px] font-semibold text-red-700">{{ fmtPct(stats.p75_pct) }} / {{ fmtPct(stats.p90_pct) }}</div> <div class="mt-1 text-[15px] font-semibold text-up">{{ fmtPct(stats.p75_pct) }} / {{ fmtPct(stats.p90_pct) }}</div>
<div class="text-[11px] text-slate-400">最好 {{ fmtPct(stats.max_pct) }}</div> <div class="text-xs text-[#9BA3AE]">最好 {{ fmtPct(stats.max_pct) }}</div>
</div> </div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3"> <div class="rounded-xl border border-[#26272E] bg-[#101014] px-4 py-3">
<div class="text-[11px] text-slate-400">收益口径</div> <div class="text-xs text-[#9BA3AE]">收益口径</div>
<div class="mt-1 text-[13px] leading-5 text-slate-700">持有 {{ result.spec.holding_days }} 个交易日<br>{{ result.spec.entry_timing === 'next_open' ? '次日开盘' : '次日收盘' }}买入 {{ result.spec.exit_timing === 'close' ? '收盘' : '开盘' }}卖出</div> <div class="mt-1 text-sm leading-5 text-[#E8EAED]">持有 {{ result.spec.holding_days }} 个交易日<br>{{ result.spec.entry_timing === 'next_open' ? '次日开盘' : '次日收盘' }}买入 {{ result.spec.exit_timing === 'close' ? '收盘' : '开盘' }}卖出</div>
</div> </div>
</div> </div>
<!-- 调参重跑 --> <!-- 调参重跑 -->
<div class="mt-4 flex flex-wrap items-end gap-3 rounded-xl border border-slate-200 bg-white px-4 py-3"> <div class="mt-4 flex flex-wrap items-end gap-3 rounded-xl border border-[#26272E] bg-[#101014] px-4 py-3">
<div class="text-[12px] font-medium text-slate-600">调整参数重跑不动信号条件</div> <div class="text-[13px] font-medium text-[#A8AFB8]">调整参数重跑不动信号条件</div>
<label class="text-[12px] text-slate-500"> <label class="text-[13px] text-[#A8AFB8]">
持有天数 持有天数
<input <input
v-model.number="holdingDays" v-model.number="holdingDays"
type="number" min="1" max="250" type="number" min="1" max="250"
class="mt-1 block w-20 rounded-lg border border-slate-200 px-2 py-1.5 text-[13px] outline-none focus:border-blue-400" class="mt-1 block w-20 rounded-lg border border-[#26272E] bg-[#16181D] px-2 py-1.5 text-sm text-[#E8EAED] outline-none focus:border-blue-500"
/> />
</label> </label>
<label class="text-[12px] text-slate-500"> <label class="text-[13px] text-[#A8AFB8]">
买入时机 买入时机
<select v-model="entryTiming" class="mt-1 block rounded-lg border border-slate-200 px-2 py-1.5 text-[13px] outline-none focus:border-blue-400"> <select v-model="entryTiming" class="mt-1 block rounded-lg border border-[#26272E] bg-[#16181D] px-2 py-1.5 text-sm text-[#E8EAED] outline-none focus:border-blue-500">
<option value="next_open">次日开盘</option> <option value="next_open">次日开盘</option>
<option value="next_close">次日收盘</option> <option value="next_close">次日收盘</option>
</select> </select>
</label> </label>
<label class="text-[12px] text-slate-500"> <label class="text-[13px] text-[#A8AFB8]">
卖出价 卖出价
<select v-model="exitTiming" class="mt-1 block rounded-lg border border-slate-200 px-2 py-1.5 text-[13px] outline-none focus:border-blue-400"> <select v-model="exitTiming" class="mt-1 block rounded-lg border border-[#26272E] bg-[#16181D] px-2 py-1.5 text-sm text-[#E8EAED] outline-none focus:border-blue-500">
<option value="close">收盘</option> <option value="close">收盘</option>
<option value="open">开盘</option> <option value="open">开盘</option>
</select> </select>
</label> </label>
<button <button
class="rounded-lg border border-blue-300 px-4 py-1.5 text-[13px] font-medium text-blue-600 hover:bg-blue-50 disabled:opacity-50" class="rounded-lg border border-blue-500 px-4 py-1.5 text-sm font-medium text-blue-300 hover:bg-blue-500/15 disabled:opacity-50"
:disabled="loading || !spec" :disabled="loading || !spec"
@click="rerunAdjusted()" @click="rerunAdjusted()"
>按新参数重跑</button> >按新参数重跑</button>
</div> </div>
<!-- 分年统计 --> <!-- 分年统计 -->
<div v-if="stats.by_year.length" class="mt-4 rounded-xl border border-slate-200 bg-white p-4"> <div v-if="stats.by_year.length" class="mt-4 rounded-xl border border-[#26272E] bg-[#101014] p-4">
<div class="mb-2 text-[13px] font-medium text-slate-600">分年统计</div> <div class="mb-2 text-sm font-medium text-[#A8AFB8]">分年统计</div>
<table class="w-full text-[13px]"> <table class="w-full text-sm">
<thead> <thead>
<tr class="border-b border-slate-100 text-left text-[12px] text-slate-400"> <tr class="border-b border-[#1E2026] text-left text-[13px] text-[#9BA3AE]">
<th class="py-1.5 font-normal">年份</th> <th class="py-1.5 font-normal">年份</th>
<th class="py-1.5 font-normal">样本数</th> <th class="py-1.5 font-normal">样本数</th>
<th class="py-1.5 font-normal">平均涨幅</th> <th class="py-1.5 font-normal">平均涨幅</th>
@@ -260,11 +260,11 @@ async function rerunAdjusted() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="y in stats.by_year" :key="y.year" class="border-b border-slate-50"> <tr v-for="y in stats.by_year" :key="y.year" class="border-b border-[#1E2026]">
<td class="py-1.5">{{ y.year }}</td> <td class="py-1.5">{{ y.year }}</td>
<td class="py-1.5">{{ y.samples.toLocaleString() }}</td> <td class="py-1.5">{{ y.samples.toLocaleString() }}</td>
<td class="py-1.5 font-medium" :class="y.mean_pct >= 0 ? 'text-red-600' : 'text-green-600'">{{ fmtPct(y.mean_pct) }}</td> <td class="py-1.5 font-medium" :class="y.mean_pct >= 0 ? 'text-up' : 'text-down'">{{ fmtPct(y.mean_pct) }}</td>
<td class="py-1.5" :class="y.median_pct >= 0 ? 'text-red-600' : 'text-green-600'">{{ fmtPct(y.median_pct) }}</td> <td class="py-1.5" :class="y.median_pct >= 0 ? 'text-up' : 'text-down'">{{ fmtPct(y.median_pct) }}</td>
<td class="py-1.5">{{ y.win_rate.toFixed(2) }}%</td> <td class="py-1.5">{{ y.win_rate.toFixed(2) }}%</td>
</tr> </tr>
</tbody> </tbody>
@@ -273,12 +273,12 @@ async function rerunAdjusted() {
<!-- 样本明细 --> <!-- 样本明细 -->
<div class="mt-4 grid gap-4 lg:grid-cols-2"> <div class="mt-4 grid gap-4 lg:grid-cols-2">
<div class="rounded-xl border border-slate-200 bg-white p-4"> <div class="rounded-xl border border-[#26272E] bg-[#101014] p-4">
<div class="mb-2 text-[13px] font-medium text-slate-600">表现最好的样本 {{ bestTrades.length }}</div> <div class="mb-2 text-sm font-medium text-[#A8AFB8]">表现最好的样本 {{ bestTrades.length }}</div>
<div class="max-h-96 overflow-y-auto"> <div class="max-h-96 overflow-y-auto">
<table class="w-full text-[12px]"> <table class="w-full text-[13px]">
<thead class="sticky top-0 bg-white"> <thead class="sticky top-0 bg-[#101014]">
<tr class="border-b border-slate-100 text-left text-[11px] text-slate-400"> <tr class="border-b border-[#1E2026] text-left text-xs text-[#9BA3AE]">
<th class="py-1.5 font-normal">代码</th> <th class="py-1.5 font-normal">代码</th>
<th class="py-1.5 font-normal">买入日</th> <th class="py-1.5 font-normal">买入日</th>
<th class="py-1.5 font-normal">买入价</th> <th class="py-1.5 font-normal">买入价</th>
@@ -287,23 +287,23 @@ async function rerunAdjusted() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="(t, i) in bestTrades" :key="i" class="border-b border-slate-50"> <tr v-for="(t, i) in bestTrades" :key="i" class="border-b border-[#1E2026]">
<td class="py-1.5">{{ t.ts_code }} <span class="text-slate-400">{{ t.name }}</span></td> <td class="py-1.5">{{ t.ts_code }} <span class="text-[#9BA3AE]">{{ t.name }}</span></td>
<td class="py-1.5 text-slate-500">{{ fmtDate(t.entry_date) }}</td> <td class="py-1.5 text-[#A8AFB8]">{{ fmtDate(t.entry_date) }}</td>
<td class="py-1.5">{{ t.entry_price }}</td> <td class="py-1.5">{{ t.entry_price }}</td>
<td class="py-1.5">{{ t.exit_price }}</td> <td class="py-1.5">{{ t.exit_price }}</td>
<td class="py-1.5 font-medium text-red-600">{{ fmtPct(t.ret_pct) }}</td> <td class="py-1.5 font-medium text-up">{{ fmtPct(t.ret_pct) }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
<div v-if="worstTrades.length" class="rounded-xl border border-slate-200 bg-white p-4"> <div v-if="worstTrades.length" class="rounded-xl border border-[#26272E] bg-[#101014] p-4">
<div class="mb-2 text-[13px] font-medium text-slate-600">表现最差的样本 {{ worstTrades.length }}</div> <div class="mb-2 text-sm font-medium text-[#A8AFB8]">表现最差的样本 {{ worstTrades.length }}</div>
<div class="max-h-96 overflow-y-auto"> <div class="max-h-96 overflow-y-auto">
<table class="w-full text-[12px]"> <table class="w-full text-[13px]">
<thead class="sticky top-0 bg-white"> <thead class="sticky top-0 bg-[#101014]">
<tr class="border-b border-slate-100 text-left text-[11px] text-slate-400"> <tr class="border-b border-[#1E2026] text-left text-xs text-[#9BA3AE]">
<th class="py-1.5 font-normal">代码</th> <th class="py-1.5 font-normal">代码</th>
<th class="py-1.5 font-normal">买入日</th> <th class="py-1.5 font-normal">买入日</th>
<th class="py-1.5 font-normal">买入价</th> <th class="py-1.5 font-normal">买入价</th>
@@ -312,24 +312,24 @@ async function rerunAdjusted() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="(t, i) in worstTrades" :key="i" class="border-b border-slate-50"> <tr v-for="(t, i) in worstTrades" :key="i" class="border-b border-[#1E2026]">
<td class="py-1.5">{{ t.ts_code }} <span class="text-slate-400">{{ t.name }}</span></td> <td class="py-1.5">{{ t.ts_code }} <span class="text-[#9BA3AE]">{{ t.name }}</span></td>
<td class="py-1.5 text-slate-500">{{ fmtDate(t.entry_date) }}</td> <td class="py-1.5 text-[#A8AFB8]">{{ fmtDate(t.entry_date) }}</td>
<td class="py-1.5">{{ t.entry_price }}</td> <td class="py-1.5">{{ t.entry_price }}</td>
<td class="py-1.5">{{ t.exit_price }}</td> <td class="py-1.5">{{ t.exit_price }}</td>
<td class="py-1.5 font-medium text-green-600">{{ fmtPct(t.ret_pct) }}</td> <td class="py-1.5 font-medium text-down">{{ fmtPct(t.ret_pct) }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
<div class="mt-2 text-[11px] text-slate-400"> <div class="mt-2 text-xs text-[#9BA3AE]">
{{ result.total.toLocaleString() }} 个样本收益率已按复权因子校正消除除权除息失真明细仅展示最好/最差各 100 {{ result.total.toLocaleString() }} 个样本收益率已按复权因子校正消除除权除息失真明细仅展示最好/最差各 100
</div> </div>
</template> </template>
<div v-else-if="!error && !loading" class="py-16 text-center text-sm text-slate-400"> <div v-else-if="!error && !loading" class="py-16 text-center text-sm text-[#9BA3AE]">
用一句话描述你的想法例如连续三天 J 小于 10 时次日开盘买入未来三天涨多少 用一句话描述你的想法例如连续三天 J 小于 10 时次日开盘买入未来三天涨多少
</div> </div>
</div> </div>

View File

@@ -6,21 +6,21 @@ const features = [
{ {
to: '/stocks', to: '/stocks',
icon: 'M4 6h16M4 12h16M4 18h10', icon: 'M4 6h16M4 12h16M4 18h10',
accent: 'bg-amber-50 text-amber-600', accent: 'bg-amber-500/15 text-amber-300',
title: '看股', title: '看股',
desc: '浏览全市场 5,400+ 只股票的信息与历史 K 线数据。', desc: '浏览全市场 5,400+ 只股票的信息与历史 K 线数据。',
}, },
{ {
to: '/screener', to: '/screener',
icon: 'M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3z', icon: 'M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3z',
accent: 'bg-blue-50 text-blue-600', accent: 'bg-blue-500/15 text-blue-300',
title: '选股', title: '选股',
desc: '用自然语言描述选股条件,快速完成全市场筛选。', desc: '用自然语言描述选股条件,快速完成全市场筛选。',
}, },
{ {
to: '/backtest', to: '/backtest',
icon: 'M3 17l6-6 4 4 8-8M21 7v6h-6', icon: 'M3 17l6-6 4 4 8-8M21 7v6h-6',
accent: 'bg-emerald-50 text-emerald-600', accent: 'bg-emerald-500/15 text-emerald-300',
title: '回测', title: '回测',
desc: '选择标的与策略参数,查看历史表现和关键绩效指标。', desc: '选择标的与策略参数,查看历史表现和关键绩效指标。',
}, },
@@ -34,7 +34,7 @@ const features = [
v-for="f in features" v-for="f in features"
:key="f.to" :key="f.to"
:to="f.to" :to="f.to"
class="group flex flex-1 flex-col rounded-lg border border-slate-200 bg-white p-6 transition-all hover:-translate-y-1 hover:border-slate-300 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 sm:p-7" class="group flex flex-1 flex-col rounded-lg border border-[#26272E] bg-[#101014] p-6 transition-all hover:-translate-y-1 hover:border-[#3A3D46] hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-black sm:p-7"
> >
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<span :class="['flex h-14 w-14 shrink-0 items-center justify-center rounded-lg', f.accent]"> <span :class="['flex h-14 w-14 shrink-0 items-center justify-center rounded-lg', f.accent]">
@@ -44,7 +44,7 @@ const features = [
</span> </span>
<div class="text-2xl font-semibold">{{ f.title }}</div> <div class="text-2xl font-semibold">{{ f.title }}</div>
</div> </div>
<p class="mt-4 max-w-sm text-sm leading-6 text-slate-500">{{ f.desc }}</p> <p class="mt-4 max-w-sm text-sm leading-6 text-[#A8AFB8]">{{ f.desc }}</p>
<div class="mt-auto flex items-center justify-end gap-1.5 pt-5 text-sm font-medium text-blue-600"> <div class="mt-auto flex items-center justify-end gap-1.5 pt-5 text-sm font-medium text-blue-600">
进入 进入
<svg class="h-4 w-4 transition-transform group-hover:translate-x-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg> <svg class="h-4 w-4 transition-transform group-hover:translate-x-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>

View File

@@ -25,24 +25,24 @@ onBeforeUnmount(() => store.stopPolling());
<SyncStatusBar :status="store.syncStatus" @sync="store.startSync(90)" /> <SyncStatusBar :status="store.syncStatus" @sync="store.startSync(90)" />
<div v-if="store.error" class="mt-4 flex items-start gap-2 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-[13px] text-red-700"> <div v-if="store.error" class="mt-4 flex items-start gap-2 rounded-xl border border-red-500/40 bg-red-500/15 px-4 py-3 text-sm text-red-300">
<svg class="mt-0.5 h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 3.9L1.8 18a2 2 0 001.7 3h17a2 2 0 001.7-3L13.7 3.9a2 2 0 00-3.4 0z" /><path d="M12 9v4M12 17h.01" /></svg> <svg class="mt-0.5 h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 3.9L1.8 18a2 2 0 001.7 3h17a2 2 0 001.7-3L13.7 3.9a2 2 0 00-3.4 0z" /><path d="M12 9v4M12 17h.01" /></svg>
{{ store.error }} {{ store.error }}
</div> </div>
<div v-if="store.loading && store.note" class="py-16 text-center text-sm text-slate-400"> <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> <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 }} {{ store.note }}
</div> </div>
<template v-else-if="store.result"> <template v-else-if="store.result">
<div class="mt-4 rounded-xl border border-slate-200 bg-white px-4 py-3"> <div class="mt-4 rounded-xl border border-[#26272E] bg-[#101014] px-4 py-3">
<ConditionChips :conditions="store.result.conditions" /> <ConditionChips :conditions="store.result.conditions" />
</div> </div>
<ScreenerTable :result="store.result" @preview="previewCode = $event.ts_code" /> <ScreenerTable :result="store.result" @preview="previewCode = $event.ts_code" />
</template> </template>
<div v-else-if="!store.error" class="py-16 text-center text-sm text-slate-400"> <div v-else-if="!store.error" class="py-16 text-center text-sm text-[#9BA3AE]">
输入选股条件开始筛选即可全市场选股 输入选股条件开始筛选即可全市场选股
</div> </div>