功能更新

This commit is contained in:
2026-08-15 08:57:15 +08:00
parent 50fd032b45
commit c1c43d2ff7
30 changed files with 1908 additions and 888 deletions

View File

@@ -12,7 +12,7 @@ CORS_ORIGINS=http://localhost:5173
# 生产建议 false关闭 /docs 与 /openapi.json。
EXPOSE_API_DOCS=true
# ---- 真实数据源Tushare Pro免费版即可。留空则仅 DEMO 合成数据可用----
# ---- 真实数据源Tushare Pro免费版即可----
TUSHARE_TOKEN=你的token
DATA_ADJUST=qfq # 复权qfq 前复权 / hfq 后复权 / 留空不复权
DATA_DEFAULT_START=20200101

View File

@@ -2,6 +2,7 @@
GET /api/health 健康检查
GET /api/candles/{sym} 取 K 线(支持 1d/1w/1M/1y 周期,日线为基底聚合)
GET /api/stocks 全市场股票列表(基本信息 + 最新行情 + 缓存条数)
POST /api/backtest 跑回测,返回 K线+指标+买卖点+净值+绩效
POST /api/screener/run 智能选股:自然语言 -> 条件 -> 全市场筛选
POST /api/screener/sync 启动全市场数据同步(后台任务)
@@ -9,45 +10,66 @@
"""
from __future__ import annotations
import bisect
import json
import pandas as pd
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from .backtest.engine import BacktestConfig, run_backtest
from .auth import require_user
from .backtest.events import EventEngineError, run_event_backtest
from .backtest.strategies import build_strategy
from .config import settings
from .data import fetcher, repository
from .data.aggregation import bars_per_year, resample_bars
from .data.symbols import plain_code
from .data.synthetic import seed_if_empty
from .db import get_session
from .domain import Bar
from . import indicators as ind
from .models import BacktestRun, DailySnapshot, MarketDaily, StockBasic
from .models import (
AdjFactor,
BacktestRun,
DailySnapshot,
MarketDaily,
ScreenerQuery,
StockBasic,
UserPreference,
WatchlistItem,
)
from .schemas import (
BacktestRequest,
BacktestResponse,
CandleOut,
EquityPoint,
EventBacktestRequest,
EventBacktestResponse,
IndicatorOut,
MetricsOut,
PreferencesOut,
PreferencesUpdate,
PreviewInfoOut,
PreviewResponse,
ScreenerQueryListResponse,
ScreenerQueryOut,
ScreenerRunRequest,
ScreenerRunResponse,
ScreenerSyncRequest,
ScreenerSyncStatus,
SignalOut,
StockListItemOut,
StockListResponse,
StockFacetsResponse,
FacetItemOut,
SyncRequest,
SyncResponse,
WatchlistOp,
)
from .screener import engine, market_sync
from .screener.engine import DataNotReadyError
from .screener.llm import ScreenerError, parse_conditions
from .screener.llm import ScreenerError, parse_conditions, parse_event_spec
router = APIRouter(prefix="/api", dependencies=[Depends(require_user)])
@@ -67,6 +89,41 @@ 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]
_ADJUST_MODES = ("bfq", "qfq", "hfq")
def _adjust_bars(bars: list[Bar], factors, from_mode: str, to_mode: str) -> list[Bar]:
"""按复权因子把 K 线从 from_mode 换算到 to_modebfq/qfq/hfq
相对不复权的乘数bfq=1qfq=f(t)/f(latest)hfq=f(t)。
因子缺失的日期向前沿用最近因子(因子是阶梯函数,除权日之间不变)。
"""
fd = sorted((f.trade_date.date(), float(f.adj_factor)) for f in factors)
fdates = [d for d, _ in fd]
f_latest = fd[-1][1]
def _f_at(d) -> float:
i = bisect.bisect_right(fdates, d) - 1
return fd[i][1] if i >= 0 else fd[0][1]
def _mult(mode: str, f: float) -> float:
if mode == "bfq":
return 1.0
return f / f_latest if mode == "qfq" else f
out: list[Bar] = []
for b in bars:
f = _f_at(b.ts.date())
m = _mult(to_mode, f) / _mult(from_mode, f)
out.append(Bar(
ts=b.ts,
open=round(b.open * m, 3), high=round(b.high * m, 3),
low=round(b.low * m, 3), close=round(b.close * m, 3),
volume=b.volume,
))
return out
@router.get("/candles/{symbol}", response_model=list[CandleOut])
async def get_candles(
symbol: str,
@@ -74,7 +131,6 @@ async def get_candles(
limit: int = 5000,
session: AsyncSession = Depends(get_session),
) -> list[CandleOut]:
await seed_if_empty(session, symbol="DEMO")
# 始终以日线为基底,再聚合到目标周期
rows = await repository.get_candles(session, symbol, "1d", limit=limit)
bars = resample_bars(_rows_to_bars(rows), timeframe)
@@ -93,15 +149,114 @@ async def sync_data(req: SyncRequest, session: AsyncSession = Depends(get_sessio
raise HTTPException(status_code=502, detail=str(e))
# ---------- 股票列表(全市场浏览) ----------
_STOCKS_SQL = text("""
SELECT sb.ts_code, sb.symbol, sb.name, sb.industry, sb.market,
c.close AS close, p.close AS prev_close, c.ts AS last_ts, cnt.n AS bar_count,
CASE WHEN c.close IS NOT NULL AND p.close IS NOT NULL AND p.close <> 0
THEN round(((c.close / p.close - 1) * 100)::numeric, 2) END AS pct_chg,
(w.id IS NOT NULL) AS watched
FROM stock_basic sb
LEFT JOIN LATERAL (
SELECT close, ts FROM candles
WHERE symbol = sb.symbol AND timeframe = '1d'
ORDER BY ts DESC LIMIT 1
) c ON true
LEFT JOIN LATERAL (
SELECT close FROM candles
WHERE symbol = sb.symbol AND timeframe = '1d' AND ts < c.ts
ORDER BY ts DESC LIMIT 1
) p ON c.ts IS NOT NULL
LEFT JOIN LATERAL (
SELECT count(*) AS n FROM candles
WHERE symbol = sb.symbol AND timeframe = '1d'
) cnt ON true
LEFT JOIN watchlist_items w ON w.ts_code = sb.ts_code AND w.user_id = :uid
WHERE sb.list_status = 'L'
AND (:search = '' OR sb.symbol LIKE :psearch OR sb.name LIKE :psearch)
AND (:market = '' OR sb.market = :market)
AND (:industry = '' OR sb.industry = :industry)
AND (:area = '' OR sb.area = :area)
AND (:watched_only = false OR w.id IS NOT NULL)
ORDER BY w.id DESC NULLS LAST, sb.symbol
LIMIT :limit OFFSET :offset
""")
_STOCKS_COUNT_SQL = text("""
SELECT count(*) FROM stock_basic sb
LEFT JOIN watchlist_items w ON w.ts_code = sb.ts_code AND w.user_id = :uid
WHERE sb.list_status = 'L'
AND (:search = '' OR sb.symbol LIKE :psearch OR sb.name LIKE :psearch)
AND (:market = '' OR sb.market = :market)
AND (:industry = '' OR sb.industry = :industry)
AND (:area = '' OR sb.area = :area)
AND (:watched_only = false OR w.id IS NOT NULL)
""")
@router.get("/stocks", response_model=StockListResponse)
async def list_stocks(
search: str = "",
market: str = "",
industry: str = "",
area: str = "",
watched_only: bool = False,
limit: int = 100,
offset: int = 0,
session: AsyncSession = Depends(get_session),
user=Depends(require_user),
) -> StockListResponse:
"""全市场股票列表stock_basic 基本信息 + candles 最新行情(本地缓存,无缓存则行情列为空)。
自选股watchlist_items排最前watched_only=true 只看自选。"""
search = search.strip()
limit = max(1, min(limit, 500))
offset = max(0, offset)
params = {
"search": search,
"psearch": f"%{search}%",
"market": market,
"industry": industry,
"area": area,
"watched_only": watched_only,
"uid": user.id,
"limit": limit,
"offset": offset,
}
total = (await session.execute(_STOCKS_COUNT_SQL, params)).scalar_one()
rows = (await session.execute(_STOCKS_SQL, params)).mappings().all()
return StockListResponse(total=total, items=[StockListItemOut(**r) for r in rows])
@router.get("/stocks/facets", response_model=StockFacetsResponse)
async def stock_facets(session: AsyncSession = Depends(get_session)) -> StockFacetsResponse:
"""看股页筛选项:行业 / 地域(含数量,按数量降序)。"""
industries = (
await session.execute(text("""
SELECT industry AS name, count(*) AS n FROM stock_basic
WHERE list_status = 'L' AND industry IS NOT NULL AND industry <> ''
GROUP BY industry ORDER BY n DESC
"""))
).mappings().all()
areas = (
await session.execute(text("""
SELECT area AS name, count(*) AS n FROM stock_basic
WHERE list_status = 'L' AND area IS NOT NULL AND area <> ''
GROUP BY area ORDER BY n DESC
"""))
).mappings().all()
return StockFacetsResponse(
industries=[FacetItemOut(name=r["name"], count=r["n"]) for r in industries],
areas=[FacetItemOut(name=r["name"], count=r["n"]) for r in areas],
)
@router.post("/backtest", response_model=BacktestResponse)
async def backtest(
req: BacktestRequest,
session: AsyncSession = Depends(get_session),
) -> BacktestResponse:
await seed_if_empty(session, symbol="DEMO")
# 非演示标的:首次自动拉取真实数据并缓存
if req.symbol != "DEMO" and not await fetcher.is_cached(session, req.symbol):
# 真实数据:本地无缓存则先拉取
if not await fetcher.is_cached(session, req.symbol):
try:
await fetcher.sync_symbol(session, req.symbol, source="auto")
except Exception as e: # noqa: BLE001
@@ -179,17 +334,71 @@ async def backtest(
)
@router.post("/backtest/event", response_model=EventBacktestResponse)
async def backtest_event(
req: EventBacktestRequest,
session: AsyncSession = Depends(get_session),
) -> EventBacktestResponse:
"""自然语言事件回测:入场条件命中 -> 次日买入 -> 持有 N 日,单股或全市场汇总统计。
直传 spec 则跳过 LLM前端调参重跑"""
try:
spec = req.spec or await parse_event_spec(req.text)
result = await run_event_backtest(
session, spec,
ts_code=req.ts_code,
start=req.start.date() if req.start else None,
end=req.end.date() if req.end else None,
)
except ScreenerError as e:
raise HTTPException(status_code=502, detail=str(e))
except EventEngineError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e: # noqa: BLE001
raise HTTPException(status_code=500, detail=f"事件回测失败: {e}")
return EventBacktestResponse(
text=req.text,
spec=result["spec"],
universe=result["universe"],
start=result["start"],
end=result["end"],
stats=result["stats"],
trades=result["trades"],
total=result["total"],
)
# ---------- 智能选股 ----------
@router.post("/screener/run", response_model=ScreenerRunResponse)
async def screener_run(
req: ScreenerRunRequest, session: AsyncSession = Depends(get_session)
req: ScreenerRunRequest,
session: AsyncSession = Depends(get_session),
user=Depends(require_user),
) -> ScreenerRunResponse:
"""自然语言 -> LLM 解析条件 -> 全市场筛选。也可直传 conditions 跳过 LLM微调再跑"""
"""自然语言 -> LLM 解析条件 -> 全市场筛选。也可直传 conditions 跳过 LLM微调再跑
成功的提问(含解析出的条件与命中数)记录到 screener_queries供历史一键重跑。"""
try:
conds = req.conditions or await parse_conditions(req.text)
if not conds.indicator and not conds.snapshot:
raise HTTPException(status_code=400, detail="AI 未从描述中解析出任何筛选条件,请换种说法")
result = await engine.run_screen(session, conds, settings.screener_default_limit)
# 相同文本 + 相同条件的上一条不重复记录(一键重跑场景)
exists = (
await session.execute(
select(ScreenerQuery.id).where(
ScreenerQuery.user_id == user.id,
ScreenerQuery.text == req.text.strip(),
ScreenerQuery.conditions_json == json.dumps(conds.model_dump(), ensure_ascii=False),
)
)
).scalar_one_or_none()
if exists is None:
session.add(ScreenerQuery(
user_id=user.id,
text=req.text.strip(),
conditions_json=json.dumps(conds.model_dump(), ensure_ascii=False),
hit_count=result.get("total", 0),
))
await session.commit()
return ScreenerRunResponse(**result)
except HTTPException:
raise
@@ -202,6 +411,148 @@ async def screener_run(
raise HTTPException(status_code=code, detail=str(e))
@router.get("/screener/queries", response_model=ScreenerQueryListResponse)
async def screener_queries(
limit: int = 20,
session: AsyncSession = Depends(get_session),
user=Depends(require_user),
) -> ScreenerQueryListResponse:
"""当前用户的提问历史(最新在前,含解析出的条件与命中数,可一键重跑)。"""
limit = max(1, min(limit, 100))
rows = (
await session.execute(
select(ScreenerQuery)
.where(ScreenerQuery.user_id == user.id)
.order_by(ScreenerQuery.created_at.desc())
.limit(limit)
)
).scalars().all()
items = []
for r in rows:
conds = None
if r.conditions_json:
try:
from .schemas import ScreenConditions
conds = ScreenConditions.model_validate_json(r.conditions_json)
except Exception: # noqa: BLE001 —— 旧格式/解析失败则只展示文本
conds = None
items.append(ScreenerQueryOut(
id=r.id, text=r.text, conditions=conds, hit_count=r.hit_count, created_at=r.created_at
))
return ScreenerQueryListResponse(items=items)
@router.delete("/screener/queries/{query_id}", status_code=204)
async def screener_query_delete(
query_id: int,
session: AsyncSession = Depends(get_session),
user=Depends(require_user),
) -> None:
await session.execute(
text("DELETE FROM screener_queries WHERE id = :i AND user_id = :u"),
{"i": query_id, "u": user.id},
)
await session.commit()
# ---------- 用户偏好 ----------
@router.get("/preferences", response_model=PreferencesOut)
async def get_preferences(
session: AsyncSession = Depends(get_session), user=Depends(require_user)
) -> PreferencesOut:
prefs: dict[str, object] = {}
rows = (
await session.execute(select(UserPreference).where(UserPreference.user_id == user.id))
).scalars().all()
for r in rows:
try:
prefs[r.key] = json.loads(r.value_json)
except Exception: # noqa: BLE001
prefs[r.key] = None
return PreferencesOut(prefs=prefs)
@router.put("/preferences", response_model=PreferencesOut)
async def put_preferences(
req: PreferencesUpdate,
session: AsyncSession = Depends(get_session),
user=Depends(require_user),
) -> PreferencesOut:
"""部分更新:只覆盖出现的 key值为 null 表示删除该 key。返回更新后的全量。"""
for key, value in req.prefs.items():
if not key or len(key) > 64:
continue
if value is None:
await session.execute(
text("DELETE FROM user_preferences WHERE user_id = :u AND key = :k"),
{"u": user.id, "k": key},
)
continue
existing = (
await session.execute(
select(UserPreference).where(
UserPreference.user_id == user.id, UserPreference.key == key
)
)
).scalars().first()
vj = json.dumps(value, ensure_ascii=False)
if existing:
existing.value_json = vj
else:
session.add(UserPreference(user_id=user.id, key=key, value_json=vj))
await session.commit()
return await get_preferences(session=session, user=user)
# ---------- 自选股 ----------
@router.get("/watchlist", response_model=list[str])
async def get_watchlist(
session: AsyncSession = Depends(get_session), user=Depends(require_user)
) -> list[str]:
"""当前用户自选股 ts_code 列表(加入时间倒序)。"""
rows = (
await session.execute(
select(WatchlistItem.ts_code)
.where(WatchlistItem.user_id == user.id)
.order_by(WatchlistItem.created_at.desc(), WatchlistItem.id.desc())
)
).scalars().all()
return list(rows)
@router.post("/watchlist", response_model=list[str])
async def add_watchlist(
req: WatchlistOp,
session: AsyncSession = Depends(get_session),
user=Depends(require_user),
) -> list[str]:
exists = (
await session.execute(
select(WatchlistItem.id).where(
WatchlistItem.user_id == user.id, WatchlistItem.ts_code == req.ts_code
)
)
).scalar_one_or_none()
if exists is None:
session.add(WatchlistItem(user_id=user.id, ts_code=req.ts_code))
await session.commit()
return await get_watchlist(session=session, user=user)
@router.delete("/watchlist/{ts_code}", response_model=list[str])
async def remove_watchlist(
ts_code: str,
session: AsyncSession = Depends(get_session),
user=Depends(require_user),
) -> list[str]:
await session.execute(
text("DELETE FROM watchlist_items WHERE user_id = :u AND ts_code = :c"),
{"u": user.id, "c": ts_code},
)
await session.commit()
return await get_watchlist(session=session, user=user)
@router.post("/screener/sync", response_model=ScreenerSyncStatus)
async def screener_sync_start(
req: ScreenerSyncRequest, session: AsyncSession = Depends(get_session)
@@ -224,10 +575,22 @@ async def screener_sync_status(session: AsyncSession = Depends(get_session)) ->
@router.get("/screener/preview/{ts_code}", response_model=PreviewResponse)
async def screener_preview(
ts_code: str, limit: int = 260, session: AsyncSession = Depends(get_session)
ts_code: str, limit: int = 500, adjust: str = "qfq", timeframe: str = "1d", mas: str = "5,10,20,60",
session: AsyncSession = Depends(get_session),
) -> PreviewResponse:
"""个股详情预览:日线(qfq 全量缓存,未缓存/过期自动拉取,失败退 market_daily 近段)
+ 全套指标indicators.py 单一事实源)+ 最新截面信息卡。"""
"""个股详情预览:日线(candles 不复权底座 + adj_factor 本地换算 bfq/qfq/hfq
未缓存自动拉取,失败退 market_daily 近段)+ 全套指标 + 最新截面信息卡。
timeframe 聚合到周/月/年先复权再聚合mas 指定主图 MA 周期(逗号分隔)。"""
if adjust not in _ADJUST_MODES:
raise HTTPException(status_code=400, detail=f"adjust 仅支持 {'/'.join(_ADJUST_MODES)}")
if timeframe not in ("1d", "1w", "1M", "1y"):
raise HTTPException(status_code=400, detail="timeframe 仅支持 1d/1w/1M/1y")
try:
ma_periods = sorted({int(p) for p in mas.split(",") if p.strip().isdigit() and 1 <= int(p) <= 500})
except ValueError:
raise HTTPException(status_code=400, detail="mas 格式应为逗号分隔的数字,如 5,10,20,60")
if not ma_periods:
ma_periods = [5, 10, 20, 60]
symbol = plain_code(ts_code)
# 先取 market_daily 最新行:既做缓存过期判断,也做信息卡数据源
@@ -237,16 +600,20 @@ async def screener_preview(
)
).scalars().first()
# --- 日线candles(qfq 全量) 优先;未缓存拉取,缓存落后于全市场最新交易日则强制刷新(每日至多一次) ---
# --- 日线candles(不复权底座) 优先;未缓存拉取,缓存落后于全市场最新交易日则强制刷新(每日至多一次) ---
# fetcher 增量拉取写入的是 qfqsettings.data_adjust此时底座模式记为 qfq。
rows = await repository.get_candles(session, symbol, "1d", limit=100000)
source = "qfq"
source = "bfq"
mode = "bfq"
try:
if not rows:
await fetcher.sync_symbol(session, symbol, source="auto")
rows = await repository.get_candles(session, symbol, "1d", limit=100000)
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():
await fetcher.sync_symbol(session, symbol, source="auto", force=True)
rows = await repository.get_candles(session, symbol, "1d", limit=100000)
mode = settings.data_adjust if settings.data_adjust in _ADJUST_MODES else "qfq"
except Exception: # noqa: BLE001 —— tushare/写库失败时回滚会话(否则毒化后兜底查询 500
await session.rollback()
if not rows:
@@ -265,6 +632,22 @@ async def screener_preview(
if not bars:
raise HTTPException(status_code=404, detail=f"无数据: {ts_code}(可先点「同步市场数据」)")
# --- 复权换算:请求模式与底座模式不同时按 adj_factor 本地换算(无因子则维持原样) ---
if adjust != mode:
factors = (
await session.execute(
select(AdjFactor).where(AdjFactor.ts_code == ts_code).order_by(AdjFactor.trade_date)
)
).scalars().all()
if factors:
bars = _adjust_bars(bars, factors, mode, adjust)
mode = adjust
if source != "market":
source = adjust
# --- 周期聚合:复权之后按日历聚合到周/月/年,指标在聚合后的序列上计算 ---
bars = resample_bars(bars, timeframe)
# --- 指标(在全量历史上计算后截尾,保证预热正确) ---
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"]
@@ -272,7 +655,7 @@ async def screener_preview(
kdj = ind.kdj(highs, lows, closes)
boll = ind.bollinger(closes)
indicators: dict[str, dict[str, list[float | None]]] = {
"ma": {f"ma{p}": _series_to_jsonable(ind.ma(closes, p)) for p in (5, 10, 20, 60)},
"ma": {f"ma{p}": _series_to_jsonable(ind.ma(closes, p)) for p in ma_periods},
"macd": {
"dif": _series_to_jsonable(macd["macd"]),
"dea": _series_to_jsonable(macd["signal"]),

View File

@@ -1,6 +1,6 @@
"""数据编排拉取Tushare 主 -> AKShare 兜底)+ 本地缓存。
真实行情落库到 candles 表timeframe='1d'),回测统一从库读,与 DEMO 同路径
真实行情落库到 candles 表timeframe='1d'),回测统一从库读。
"""
from __future__ import annotations

View File

@@ -1,78 +0,0 @@
"""合成数据MVP 零依赖可跑)。
生成随机游走 OHLCV灌入 DB。仅用于让回测链路在没有真实数据源时也能跑通演示。
阶段1 接 Tushare/AKShare 后,这里仅保留为"离线测试夹具"
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import numpy as np
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..domain import Bar
from ..models import Candle
def _trading_days(n: int) -> list[datetime]:
"""粗略生成 n 个工作日跳过周末节假日由阶段1 的交易日历服务处理)。"""
start = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=int(n * 1.6))
days: list[datetime] = []
d = start
while len(days) < n:
if d.weekday() < 5:
days.append(d.replace(hour=15, minute=0, second=0, microsecond=0))
d += timedelta(days=1)
return days
def generate_ohlcv(n: int = 500, seed: int = 42) -> list[Bar]:
"""随机游走 + A 股风格的价格区间5~30 元)。"""
rng = np.random.default_rng(seed)
rets = rng.normal(loc=0.0003, scale=0.018, size=n)
price = 10.0 * np.cumprod(1 + rets)
days = _trading_days(n)
bars: list[Bar] = []
for i in range(n):
close = float(price[i])
op = close * (1 + rng.normal(0, 0.005))
hi = max(op, close) * (1 + abs(rng.normal(0, 0.006)))
lo = min(op, close) * (1 - abs(rng.normal(0, 0.006)))
vol = float(rng.integers(1_000_000, 10_000_000))
bars.append(
Bar(
ts=days[i],
open=round(op, 2),
high=round(hi, 2),
low=round(lo, 2),
close=round(close, 2),
volume=vol,
)
)
return bars
async def seed_if_empty(session: AsyncSession, symbol: str = "DEMO", n: int = 500) -> None:
"""若库中无该 symbol 数据,则灌入合成数据。"""
existing = await session.execute(
select(Candle.id).where(Candle.symbol == symbol).limit(1)
)
if existing.scalars().first() is not None:
return
bars = generate_ohlcv(n=n)
for b in bars:
session.add(
Candle(
symbol=symbol,
timeframe="1d",
ts=b.ts,
open=b.open,
high=b.high,
low=b.low,
close=b.close,
volume=b.volume,
)
)
await session.commit()

View File

@@ -17,7 +17,7 @@ def _parse(date_str: str) -> datetime:
def fetch_daily(code: str, start: str = "20200101", end: str | None = None,
adjust: str = "qfq") -> list[Bar]:
import tushare as ts # 延迟导入:未装/无 token 时 DEMO 仍可用
import tushare as ts # 延迟导入:未装无 token 时该数据源不可用
if not settings.tushare_token:
raise RuntimeError("未配置 TUSHARE_TOKEN")

View File

@@ -21,7 +21,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title="Stock Backtest",
description="历史回测 + 回放式模拟平台A 股为主,不做实盘)",
description="股票研究平台:全市场数据 + 智能选股 + 事件回测A 股为主,不做实盘)",
version="0.1.0",
lifespan=lifespan,
docs_url="/docs" if settings.expose_api_docs else None,

View File

@@ -9,7 +9,7 @@ Candle 表设计与 TimescaleDB hypertable 完全兼容:将来在目标 PG 库
"""
from datetime import datetime
from sqlalchemy import BigInteger, Boolean, DateTime, Float, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy import BigInteger, Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .db import Base
@@ -124,6 +124,65 @@ class DailySnapshot(Base):
)
class AdjFactor(Base):
"""复权因子adj_factorTushare 原始值qfq/hfq 本地换算的底座)。
与 candles(不复权日线) 按 ts_code+trade_date 关联:
前复权 qfq = 不复权价 × f(t) / f(latest);后复权 hfq = 不复权价 × f(t)。
"""
__tablename__ = "adj_factor"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
trade_date: Mapped[datetime] = mapped_column(DateTime, index=True)
ts_code: Mapped[str] = mapped_column(String(12), index=True)
adj_factor: Mapped[float] = mapped_column(Float)
__table_args__ = (
UniqueConstraint("ts_code", "trade_date", name="uq_adj_code_date"),
)
class UserPreference(Base):
"""用户偏好键值对(配色/复权口径/MA 周期/副图布局等value 存 JSON 字符串)。"""
__tablename__ = "user_preferences"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), index=True)
key: Mapped[str] = mapped_column(String(64))
value_json: Mapped[str] = mapped_column(Text, default="null")
updated_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, onupdate=_utcnow)
__table_args__ = (
UniqueConstraint("user_id", "key", name="uq_user_pref_key"),
)
class WatchlistItem(Base):
"""自选股(星标置顶)。"""
__tablename__ = "watchlist_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), index=True)
ts_code: Mapped[str] = mapped_column(String(12), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow)
__table_args__ = (
UniqueConstraint("user_id", "ts_code", name="uq_watch_user_code"),
)
class ScreenerQuery(Base):
"""自然语言选股提问历史(文本 + 解析出的条件,便于一键重跑)。"""
__tablename__ = "screener_queries"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), index=True)
text: Mapped[str] = mapped_column(String(500))
conditions_json: Mapped[str | None] = mapped_column(Text)
hit_count: Mapped[int | None] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, index=True)
class TradeCalendar(Base):
"""交易日历缓存trade_cal 拉取一次宽范围后本地维护,低积分 token 限频 1 次/小时)。"""
__tablename__ = "trade_calendar"

View File

@@ -24,7 +24,7 @@ class CandleOut(BaseModel):
# ---------- Backtest ----------
class BacktestRequest(BaseModel):
symbol: str = "DEMO"
symbol: str = "000001"
timeframe: str = "1d"
strategy: str = "macd_cross" # macd_cross | ma_cross | single_ma
params: dict[str, float] = Field(default_factory=dict) # 各策略参数
@@ -82,6 +82,68 @@ class SyncRequest(BaseModel):
force: bool = False # True => 忽略缓存重新拉取
# ---------- Event Backtest自然语言事件回测 ----------
class EventBacktestSpec(BaseModel):
"""事件回测参数entry 条件在信号日 D 收盘确认 -> D+1 买入 -> 持有 N 日卖出。"""
entry: ScreenConditions
entry_timing: Literal["next_open", "next_close"] = "next_open" # 次日开盘/收盘买入
holding_days: int = Field(default=3, ge=1, le=250) # 买入后再持有 N 个交易日
exit_timing: Literal["close", "open"] = "close" # 到期按收盘/开盘卖出
class EventBacktestRequest(BaseModel):
text: str = Field(min_length=2, max_length=500)
spec: EventBacktestSpec | None = None # 直传则跳过 LLM 解析(调参重跑)
ts_code: str | None = None # 指定则只回测该股;空则全市场
start: datetime | None = None
end: datetime | None = None
class EventTradeOut(BaseModel):
ts_code: str
name: str | None = None
entry_date: datetime
entry_price: float
exit_date: datetime
exit_price: float
ret_pct: float # 区间收益率 %(复权校正)
class EventYearStatOut(BaseModel):
year: int
samples: int
mean_pct: float
median_pct: float
win_rate: float
class EventStatsOut(BaseModel):
samples: int
stocks: int
mean_pct: float
median_pct: float
win_rate: float # %
std_pct: float = 0.0
p10_pct: float = 0.0
p25_pct: float = 0.0
p75_pct: float = 0.0
p90_pct: float = 0.0
max_pct: float = 0.0
min_pct: float = 0.0
by_year: list[EventYearStatOut] = Field(default_factory=list)
class EventBacktestResponse(BaseModel):
text: str
spec: EventBacktestSpec
universe: str # "all" 或 ts_code
start: datetime
end: datetime
stats: EventStatsOut
trades: list[EventTradeOut] = Field(default_factory=list) # 最好+最差样本(各 100
total: int
class SyncResponse(BaseModel):
symbol: str
bars: int
@@ -197,7 +259,7 @@ class PreviewInfoOut(BaseModel):
class PreviewResponse(BaseModel):
ts_code: str
symbol: str
source: str # qfq=回测缓存全量前复权 | market=近段未复权兜底
source: str # bfq|qfq|hfq=实际复权口径(本地 adj_factor 换算) | market=近段未复权兜底
info: PreviewInfoOut
candles: list[CandleOut]
indicators: dict[str, dict[str, list[float | None]]] = Field(default_factory=dict)
@@ -220,3 +282,61 @@ class CurrentUserOut(BaseModel):
class LoginResponse(BaseModel):
user: CurrentUserOut
expires_at: datetime
# ---------- 股票列表(全市场浏览) ----------
class StockListItemOut(BaseModel):
ts_code: str
symbol: str
name: str
industry: str | None = None
market: str | None = None
close: float | None = None # 最新收盘candles 未复权)
prev_close: float | None = None
pct_chg: float | None = None # 最新两根日线计算
last_ts: datetime | None = None
bar_count: int | None = None # 本地缓存日线条数
watched: bool = False # 是否自选(当前用户)
class StockListResponse(BaseModel):
total: int
items: list[StockListItemOut]
# ---------- 看股页筛选项 ----------
class FacetItemOut(BaseModel):
name: str
count: int
class StockFacetsResponse(BaseModel):
industries: list[FacetItemOut] = Field(default_factory=list)
areas: list[FacetItemOut] = Field(default_factory=list)
# ---------- 用户偏好 / 自选股 / 提问历史 ----------
class PreferencesOut(BaseModel):
prefs: dict[str, object] = Field(default_factory=dict) # key -> JSON 值
class PreferencesUpdate(BaseModel):
prefs: dict[str, object] # 部分更新:只覆盖出现的 key值为 null 表示删除)
class WatchlistOp(BaseModel):
ts_code: str = Field(min_length=6, max_length=12)
class ScreenerQueryOut(BaseModel):
id: int
text: str
conditions: ScreenConditions | None = None
hit_count: int | None = None
created_at: datetime
model_config = {"from_attributes": True}
class ScreenerQueryListResponse(BaseModel):
items: list[ScreenerQueryOut]

View File

@@ -13,7 +13,7 @@ import re
import httpx
from ..config import settings
from ..schemas import ScreenConditions
from ..schemas import EventBacktestSpec, ScreenConditions
SYSTEM_PROMPT = """你是 A 股选股条件解析器。把用户的自然语言解析成一个 JSON 对象,只输出 JSON不要任何解释、注释或代码块围栏。完全无法理解时输出 {"error": "原因"}。
@@ -149,3 +149,69 @@ async def parse_conditions(text: str) -> ScreenConditions:
except Exception as e: # noqa: BLE001 —— JSON/校验失败,带错误重试
retry_error = str(e)[:300]
raise ScreenerError(f"AI 解析结果两次未通过校验,最后错误:{retry_error}")
# ---------- 事件回测解析(自然语言 -> EventBacktestSpec ----------
EVENT_SYSTEM_PROMPT = """你是 A 股事件回测参数解析器。用户描述一个「入场信号 + 买卖时机 + 持有期」的事件回测需求,把它解析成 JSON只输出 JSON不要任何解释或代码块围栏。完全无法理解时输出 {"error": "原因"}。
输出结构:
{"entry": {"indicator": [...], "snapshot": [], "exclude_st": true, "exclude_delisted": true, "exclude_bj": true}, "entry_timing": "next_open", "holding_days": 3, "exit_timing": "close"}
【entry.indicator 数组】入场信号条件(必填,至少 1 条),元素字段与白名单:
- "indicator": kdj_k / kdj_d / kdj_jKDJ 的 K/D/J 值、rsi、macd_dif / macd_dea / macd_histMACD 的 DIF/DEA/柱、ma收盘价均线、boll_upper / boll_mid / boll_lower布林轨道、close收盘价、pct_chg日涨跌幅%
- "params": 指标参数可选默认KDJ {"n":9,"m1":3,"m2":3}RSI {"period":14}MACD {"fast":12,"slow":26,"signal":9}MA {"period":20}BOLL {"period":20,"std":2}
- "op": "gt" | "ge" | "lt" | "le" | "between""value"between 时为下界)、"value2"(上界)
- "value_indicator": 指标与指标比较时填另一指标名(同白名单),如 "DIF 大于 DEA" -> indicator=macd_dif, op=gt, value_indicator=macd_dea, value=0
- "value_params": 比较对象指标参数不同时指定,如 "MA5 上穿 MA20" -> indicator=ma, params={"period":5}, op=gt, value_indicator=ma, value_params={"period":20}, value=0
- "lookback": 信号需连续/曾经满足的交易日窗口(默认 1
- "match": "all"(窗口内每天满足,默认)或 "any"(窗口内任一天满足)
【时间语义】"连续三天 J 小于 10" -> lookback=3, match="all""近 5 天曾经金叉" -> lookback=5, match="any"
【entry_timing】买入时机"第二天开盘购买/次日开盘买入" -> "next_open"(默认);"第二天收盘买入" -> "next_close"
【holding_days】买入后持有 N 个交易日int默认 3"未来三天的涨幅" -> holding_days=3"持有 10 天" -> 10"持有一个月" -> 20。
【exit_timing】到期卖出价"close"(收盘卖,默认)或 "open"(开盘卖)。
【entry.snapshot】截面过滤条件一般不适用于历史回测除非用户明确说"只回测市值大于 X 亿的股票"才填,其余情况留空数组。
示例:
输入:在连续三天 J 小于 10 的时候第二天开盘购买,之后未来三天的涨幅有多少
输出:{"entry":{"indicator":[{"indicator":"kdj_j","params":{"n":9,"m1":3,"m2":3},"op":"lt","value":10,"lookback":3,"match":"all"}],"snapshot":[],"exclude_st":true,"exclude_delisted":true,"exclude_bj":true},"entry_timing":"next_open","holding_days":3,"exit_timing":"close"}
示例:
输入RSI 低于 30 的第二天开盘买入持有 5 天收盘卖出
输出:{"entry":{"indicator":[{"indicator":"rsi","params":{"period":14},"op":"lt","value":30,"lookback":1,"match":"all"}],"snapshot":[],"exclude_st":true,"exclude_delisted":true,"exclude_bj":true},"entry_timing":"next_open","holding_days":5,"exit_timing":"close"}"""
def _build_event_messages(text: str, retry_error: str | None = None) -> list[dict]:
user = f"解析以下事件回测需求:{text}"
if retry_error:
user += f"\n\n上一次输出无法通过校验,错误:{retry_error}。请修正后重新只输出 JSON。"
return [{"role": "system", "content": EVENT_SYSTEM_PROMPT}, {"role": "user", "content": user}]
async def parse_event_spec(text: str) -> EventBacktestSpec:
"""自然语言 -> EventBacktestSpec。复用 _chat/_extract_json失败带错误重试 1 次。"""
if not settings.llm_api_key:
raise ScreenerError(
"未配置 LLM_API_KEY请在 backend/.env 填入 DeepSeek API Keyplatform.deepseek.com 获取)后重启后端"
)
retry_error: str | None = None
for _ in range(2):
content = await _chat(_build_event_messages(text, retry_error))
try:
obj = _extract_json(content)
if "error" in obj and not obj.get("entry"):
raise ScreenerError(f"AI 无法理解该回测需求:{obj['error']}")
spec = EventBacktestSpec.model_validate(obj)
if not spec.entry.indicator:
raise ValueError("entry.indicator 不能为空")
return spec
except ScreenerError:
raise
except Exception as e: # noqa: BLE001
retry_error = str(e)[:300]
raise ScreenerError(f"AI 解析回测参数两次未通过校验,最后错误:{retry_error}")

View File

@@ -16,7 +16,7 @@ from sqlalchemy import delete, func, insert, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings
from ..models import DailySnapshot, MarketDaily, StockBasic, TradeCalendar
from ..models import AdjFactor, DailySnapshot, MarketDaily, StockBasic, TradeCalendar
from .llm import ScreenerError
# 进程内单例任务状态uvicorn --reload 单进程场景够用)
@@ -160,6 +160,18 @@ def _fetch_basic(pro, d: str) -> list[dict]:
return rows
def _fetch_adj_factor(pro, d: str) -> list[dict]:
"""拉取某交易日全市场复权因子K线 bfq->qfq/hfq 本地换算的底座)。"""
time.sleep(settings.screener_sync_interval)
df = _call_retry(pro.adj_factor, trade_date=d)
if df is None or df.empty:
return []
return [
{"trade_date": _parse_d(d), "ts_code": r["ts_code"], "adj_factor": float(r["adj_factor"])}
for _, r in df.iterrows()
]
def _sync_stock_list_sync(pro) -> list[dict]:
"""拉取在市股票列表。"""
time.sleep(settings.screener_sync_interval)
@@ -243,6 +255,16 @@ async def _run_sync(days: int, force: bool) -> None:
await _replace_day(session, MarketDaily, daily_rows, d)
_sync_state["done_days"] += 1
# 2.5) 复权因子(与日线同窗口增量;历史全量由 scripts/backfill_adj_factor.py 回补)
async with async_session() as session:
have_adj = set() if force else await _existing_dates(session, AdjFactor)
for d in [d for d in dates if d not in have_adj]:
_sync_state["step"] = f"正在同步 {d} 复权因子"
adj_rows = await asyncio.to_thread(_fetch_adj_factor, pro, d)
if adj_rows:
async with async_session() as session:
await _replace_day(session, AdjFactor, adj_rows, d)
# 3) 最新「有数据」交易日的快照daily_basic仅 1 次调用)
# 用 market_daily 实际最大交易日(今天的数据收盘后才生成,日历最新日会拉到空)
async with async_session() as session:

View File

@@ -65,7 +65,7 @@ async def main() -> None:
daily = await client.post(
"/api/backtest",
json={
"symbol": "DEMO",
"symbol": "000001",
"strategy": "macd_cross",
"params": {"fast": 12, "slow": 26, "signal": 9},
"initial_cash": 100000.0,

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>量化选股与回测平台</title>
<title>选股训练营</title>
<style>
html, body { background-color: #f8fafc; margin: 0; }
</style>

View File

@@ -3,6 +3,7 @@ import { computed, ref } from 'vue';
import { RouterView } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import SettingsModal from '@/components/SettingsModal.vue';
const route = useRoute();
const router = useRouter();
@@ -10,6 +11,7 @@ const auth = useAuthStore();
const isHome = computed(() => route.name === 'home');
const isLogin = computed(() => route.name === 'login');
const loggingOut = ref(false);
const showSettings = ref(false);
async function signOut() {
loggingOut.value = true;
@@ -28,13 +30,42 @@ async function signOut() {
</div>
<RouterView v-else-if="isLogin" />
<div v-else class="min-h-screen">
<div class="fixed right-5 top-4 z-30 flex items-center gap-3 rounded-md border border-slate-200 bg-white/90 px-3 py-2 text-xs text-slate-400 shadow-sm backdrop-blur">
<header class="sticky top-0 z-30 border-b border-slate-200 bg-white/90 backdrop-blur">
<div class="mx-auto flex h-8 max-w-[1400px] items-center justify-between px-5">
<button
v-if="!isHome"
type="button"
class="flex items-center gap-1.5 text-sm font-medium text-slate-500 transition-colors hover:text-slate-900"
@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>
主页
</button>
<span v-else class="text-sm font-medium text-slate-500">Stock</span>
<div class="flex items-center gap-3 text-xs text-slate-400">
<span>{{ auth.user?.username }}</span>
<span class="h-3 w-px bg-slate-200" aria-hidden="true" />
<button
type="button"
class="rounded p-1 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900"
title="设置"
@click="showSettings = true"
>
<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">
<circle cx="12" cy="12" r="3" />
<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>
</button>
<button type="button" class="font-medium text-slate-500 hover:text-slate-900 disabled:opacity-50" :disabled="loggingOut" @click="signOut">
{{ loggingOut ? '退出中' : '退出' }}
</button>
</div>
<main :class="isHome ? 'flex min-h-screen items-center justify-center px-5 py-10' : 'mx-auto max-w-[1400px] px-5 py-6'">
</div>
</header>
<SettingsModal v-if="showSettings" @close="showSettings = false" />
<main :class="isHome ? 'flex min-h-[calc(100vh-3rem)] items-center justify-center px-5 py-10' : 'mx-auto max-w-[1400px] px-5 py-6'">
<RouterView />
</main>

View File

@@ -1,16 +1,23 @@
import type {
AdjustMode,
BacktestRequest,
BacktestResponse,
CurrentUser,
EventBacktestRequest,
EventBacktestResponse,
LoginRequest,
LoginResponse,
PreviewResponse,
ScreenerQueryItem,
ScreenerRunRequest,
ScreenerRunResponse,
ScreenerSyncRequest,
ScreenerSyncStatus,
StockFacets,
StockListResponse,
SyncRequest,
SyncResponse,
Timeframe,
} from './types';
// dev 用 Vite 代理(/api -> :8000生产构建设 VITE_API_BASE 指向后端地址。
@@ -81,6 +88,28 @@ export async function syncData(req: SyncRequest): Promise<SyncResponse> {
return (await res.json()) as SyncResponse;
}
/** 自然语言事件回测。全市场扫描较慢timeout 放宽到 15 分钟。 */
export async function postEventBacktest(req: EventBacktestRequest): Promise<EventBacktestResponse> {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 15 * 60 * 1000);
try {
const res = await apiFetch('/api/backtest/event', {
method: 'POST',
body: JSON.stringify(req),
signal: ctrl.signal,
});
if (!res.ok) throw new ApiError(await readError(res, `事件回测失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as EventBacktestResponse;
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') {
throw new ApiError('事件回测超时(全市场扫描较慢,可先缩短日期范围或指定单只股票)', 0);
}
throw e;
} finally {
clearTimeout(timer);
}
}
export async function runScreener(req: ScreenerRunRequest): Promise<ScreenerRunResponse> {
const res = await apiFetch('/api/screener/run', { method: 'POST', body: JSON.stringify(req) });
if (!res.ok) throw new ApiError(await readError(res, `选股失败 (HTTP ${res.status})`), res.status);
@@ -99,8 +128,96 @@ export async function getScreenerSyncStatus(): Promise<ScreenerSyncStatus> {
return (await res.json()) as ScreenerSyncStatus;
}
export async function getStockPreview(tsCode: string, limit = 260): Promise<PreviewResponse> {
const res = await apiFetch(`/api/screener/preview/${encodeURIComponent(tsCode)}?limit=${limit}`);
export async function getStockPreview(
tsCode: string,
opts: {
limit?: number;
adjust?: AdjustMode;
timeframe?: Timeframe;
mas?: number[];
} = {},
): Promise<PreviewResponse> {
const { limit = 10000, adjust = 'qfq', timeframe = '1d', mas } = opts;
const q = new URLSearchParams({
limit: String(limit),
adjust,
timeframe,
...(mas?.length ? { mas: mas.join(',') } : {}),
});
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);
return (await res.json()) as PreviewResponse;
}
export async function getStocks(params: {
search?: string;
market?: string;
industry?: string;
area?: string;
watched_only?: boolean;
limit?: number;
offset?: number;
}): Promise<StockListResponse> {
const q = new URLSearchParams();
if (params.search) q.set('search', params.search);
if (params.market) q.set('market', params.market);
if (params.industry) q.set('industry', params.industry);
if (params.area) q.set('area', params.area);
if (params.watched_only) q.set('watched_only', 'true');
q.set('limit', String(params.limit ?? 100));
q.set('offset', String(params.offset ?? 0));
const res = await apiFetch(`/api/stocks?${q.toString()}`);
if (!res.ok) throw new ApiError(await readError(res, `获取股票列表失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as StockListResponse;
}
export async function getStockFacets(): Promise<StockFacets> {
const res = await apiFetch('/api/stocks/facets');
if (!res.ok) throw new ApiError(await readError(res, `获取筛选项失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as StockFacets;
}
// ---------- 用户偏好 / 自选股 / 提问历史 ----------
export async function getPreferences(): Promise<Record<string, unknown>> {
const res = await apiFetch('/api/preferences');
if (!res.ok) throw new ApiError(await readError(res, `获取偏好失败 (HTTP ${res.status})`), res.status);
const data = (await res.json()) as { prefs: Record<string, unknown> };
return data.prefs ?? {};
}
export async function putPreferences(prefs: Record<string, unknown>): Promise<Record<string, unknown>> {
const res = await apiFetch('/api/preferences', { method: 'PUT', body: JSON.stringify({ prefs }) });
if (!res.ok) throw new ApiError(await readError(res, `保存偏好失败 (HTTP ${res.status})`), res.status);
const data = (await res.json()) as { prefs: Record<string, unknown> };
return data.prefs ?? {};
}
export async function getWatchlist(): Promise<string[]> {
const res = await apiFetch('/api/watchlist');
if (!res.ok) throw new ApiError(await readError(res, `获取自选股失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as string[];
}
export async function addWatchlist(tsCode: string): Promise<string[]> {
const res = await apiFetch('/api/watchlist', { method: 'POST', body: JSON.stringify({ ts_code: tsCode }) });
if (!res.ok) throw new ApiError(await readError(res, `加自选失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as string[];
}
export async function removeWatchlist(tsCode: string): Promise<string[]> {
const res = await apiFetch(`/api/watchlist/${encodeURIComponent(tsCode)}`, { method: 'DELETE' });
if (!res.ok) throw new ApiError(await readError(res, `移除自选失败 (HTTP ${res.status})`), res.status);
return (await res.json()) as string[];
}
export async function getScreenerQueries(limit = 20): Promise<ScreenerQueryItem[]> {
const res = await apiFetch(`/api/screener/queries?limit=${limit}`);
if (!res.ok) throw new ApiError(await readError(res, `获取提问历史失败 (HTTP ${res.status})`), res.status);
const data = (await res.json()) as { items: ScreenerQueryItem[] };
return data.items ?? [];
}
export async function deleteScreenerQuery(id: number): Promise<void> {
const res = await apiFetch(`/api/screener/queries/${id}`, { method: 'DELETE' });
if (!res.ok && res.status !== 401) throw new ApiError(`删除失败 (HTTP ${res.status})`, res.status);
}

View File

@@ -191,8 +191,119 @@ export interface PreviewInfo {
export interface PreviewResponse {
ts_code: string;
symbol: string;
source: string; // qfq | market
source: string; // bfq | qfq | hfq | market实际复权口径 / 近段未复权兜底)
info: PreviewInfo;
candles: Candle[];
indicators: Record<string, Record<string, (number | null)[]>>;
}
// ---------- 股票列表(全市场浏览) ----------
export interface StockListItem {
ts_code: string;
symbol: string;
name: string;
industry?: string | null;
market?: string | null;
close?: number | null;
prev_close?: number | null;
pct_chg?: number | null;
last_ts?: string | null;
bar_count?: number | null;
watched: boolean;
}
export interface StockListResponse {
total: number;
items: StockListItem[];
}
export interface FacetItem {
name: string;
count: number;
}
export interface StockFacets {
industries: FacetItem[];
areas: FacetItem[];
}
// ---------- 用户偏好 / 自选股 / 提问历史 ----------
export type Timeframe = '1d' | '1w' | '1M' | '1y';
export type AdjustMode = 'bfq' | 'qfq' | 'hfq';
/** 看股页图表布局偏好(存 user_preferences.chartLayout */
export interface ChartLayoutPrefs {
maPeriods: number[];
subPanes: string[]; // 'vol' | 'macd' | 'kdj' | 'rsi'(顺序即面板顺序)
subHeights: Record<string, number>; // 面板高度 px
timeframe?: Timeframe;
}
export interface ScreenerQueryItem {
id: number;
text: string;
conditions: ScreenConditions | null;
hit_count: number | null;
created_at: string;
}
// ---------- 事件回测(自然语言) ----------
export interface EventBacktestSpec {
entry: ScreenConditions;
entry_timing: 'next_open' | 'next_close';
holding_days: number;
exit_timing: 'close' | 'open';
}
export interface EventBacktestRequest {
text: string;
spec?: EventBacktestSpec | null; // 直传则跳过 LLM调参重跑
ts_code?: string | null;
start?: string | null;
end?: string | null;
}
export interface EventTrade {
ts_code: string;
name: string | null;
entry_date: string;
entry_price: number;
exit_date: string;
exit_price: number;
ret_pct: number;
}
export interface EventYearStat {
year: number;
samples: number;
mean_pct: number;
median_pct: number;
win_rate: number;
}
export interface EventStats {
samples: number;
stocks: number;
mean_pct: number;
median_pct: number;
win_rate: number;
std_pct: number;
p10_pct: number;
p25_pct: number;
p75_pct: number;
p90_pct: number;
max_pct: number;
min_pct: number;
by_year: EventYearStat[];
}
export interface EventBacktestResponse {
text: string;
spec: EventBacktestSpec;
universe: string;
start: string;
end: string;
stats: EventStats;
trades: EventTrade[];
total: number;
}

View File

@@ -1,123 +0,0 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue';
import type { BacktestRequest } from '@/api/types';
defineProps<{ loading: boolean }>();
const emit = defineEmits<{ run: [req: BacktestRequest] }>();
interface ParamDef { k: string; label: string; def: number; }
const STRATS: { id: string; label: string; params: ParamDef[] }[] = [
{ id: 'ma_cross', label: '双均线交叉', params: [{ k: 'fast', label: '快均线', def: 5 }, { k: 'slow', label: '慢均线', def: 20 }] },
{ id: 'single_ma', label: '单均线(价格穿越)', params: [{ k: 'period', label: '均线周期', def: 20 }] },
{ id: 'macd_cross', label: 'MACD 金叉死叉', params: [{ k: 'fast', label: '快线', def: 12 }, { k: 'slow', label: '慢线', def: 26 }, { k: 'signal', label: '信号线', def: 9 }] },
];
const TF_OPTIONS = [
{ label: '日线', value: '1d' },
{ label: '周线', value: '1w' },
{ label: '月线', value: '1M' },
{ label: '年线', value: '1y' },
];
const QUICK = [
{ code: '000001', name: '平安银行' },
{ code: '600519', name: '贵州茅台' },
{ code: '000858', name: '五粮液' },
{ code: '601318', name: '中国平安' },
{ code: 'DEMO', name: '合成数据' },
];
const form = reactive({
symbol: '000001',
timeframe: '1d',
strategy: 'ma_cross',
params: {} as Record<string, number>,
initial_cash: 1000000,
fast_mode: false,
});
function applyDefaults(stratId: string) {
const s = STRATS.find((x) => x.id === stratId)!;
form.params = Object.fromEntries(s.params.map((p) => [p.k, p.def]));
}
watch(() => form.strategy, (id) => applyDefaults(id));
applyDefaults(form.strategy);
const currentParams = computed(() => STRATS.find((x) => x.id === form.strategy)!.params);
function onRun() {
emit('run', {
symbol: form.symbol,
timeframe: form.timeframe,
strategy: form.strategy,
params: { ...form.params },
initial_cash: form.initial_cash,
fast_mode: form.fast_mode,
} satisfies BacktestRequest);
}
</script>
<template>
<div class="rounded-xl border border-slate-200 bg-white p-4">
<div class="flex flex-wrap items-end gap-x-4 gap-y-3">
<div>
<label class="lbl">策略</label>
<select v-model="form.strategy" class="ipt w-40">
<option v-for="s in STRATS" :key="s.id" :value="s.id">{{ s.label }}</option>
</select>
</div>
<div v-for="p in currentParams" :key="p.k">
<label class="lbl">{{ p.label }}</label>
<input v-model.number="form.params[p.k]" type="number" min="1" max="250" class="ipt w-[76px]" />
</div>
<div>
<label class="lbl">周期</label>
<select v-model="form.timeframe" class="ipt w-24">
<option v-for="t in TF_OPTIONS" :key="t.value" :value="t.value">{{ t.label }}</option>
</select>
</div>
<div>
<label class="lbl">标的</label>
<input v-model="form.symbol" type="text" class="ipt w-[110px]" placeholder="如 000001" />
</div>
<div>
<label class="lbl">初始资金</label>
<input v-model.number="form.initial_cash" type="number" min="1000" step="100000" class="ipt w-[140px]" />
</div>
<label class="flex cursor-pointer select-none items-center gap-2 pb-1.5 text-sm text-slate-600">
<input v-model="form.fast_mode" type="checkbox" class="h-4 w-4 rounded border-slate-300 accent-blue-600" />
fast 模式
</label>
<div class="ml-auto">
<button type="button" class="btn-primary" :disabled="loading" @click="onRun">
<svg v-if="loading" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
<svg v-else class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg>
{{ loading ? '回测中…' : '开始回测' }}
</button>
</div>
</div>
<div class="mt-3 flex flex-wrap items-center gap-1.5">
<span class="mr-1 text-xs text-slate-400">快捷</span>
<button
v-for="q in QUICK"
:key="q.code"
type="button"
class="rounded-full border px-3 py-1 text-xs transition-colors"
:class="form.symbol === q.code
? 'border-blue-600 bg-blue-600 text-white'
: 'border-slate-200 bg-slate-50 text-slate-600 hover:border-slate-300 hover:bg-slate-100'"
@click="form.symbol = q.code"
>
{{ q.code }} <span :class="form.symbol === q.code ? 'text-blue-200' : 'text-slate-400'">{{ q.name }}</span>
</button>
</div>
<p class="mt-2 text-xs text-slate-400">
策略可选 双均线 / 单均线 / MACD参数随策略自适应<code class="rounded border border-slate-200 bg-slate-50 px-1">DEMO</code> 为合成数据其余为真实 A 首次自动经 Tushare 拉取并缓存
</p>
</div>
</template>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { dispose, init, registerIndicator, type Chart, type KLineData } from 'klinecharts';
import { useSettingsStore } from '@/stores/settings';
import type { Candle } from '@/api/types';
const props = defineProps<{
@@ -9,41 +10,55 @@ const props = defineProps<{
indicators: Record<string, Record<string, (number | null)[]>>;
/** 副图指标及顺序('vol' 用内置;其余为后端序列) */
subPanes: string[];
/** 主图 MA 周期(可配置,随用户偏好持久化) */
maPeriods: number[];
/** 各副图高度 px可配置随用户偏好持久化 */
subHeights: Record<string, number>;
/** 主图是否叠加布林带 */
showBoll: boolean;
/** K线周期标签仅用于 MA 指标名缓存 key */
timeframe: string;
}>();
// A股语义色浅色
const UP = '#dc2626';
const DOWN = '#16a34a';
const C1 = '#2563eb'; // 蓝
const C2 = '#f59e0b'; // 橙
const C3 = '#a855f7'; // 紫
const C4 = '#10b981'; // 绿青
// A股语义色浅色UP/DOWN 跟随设置中的涨跌配色
const settings = useSettingsStore();
let UP = '#dc2626';
let DOWN = '#16a34a';
const MA_COLORS = ['#2563eb', '#f59e0b', '#a855f7', '#10b981', '#ec4899', '#0ea5e9', '#84cc16', '#f97316'];
// ---------- 后端序列注入(单一事实源,按索引对齐) ----------
let PV: Record<string, (number | null)[]> = {};
const g = (k: string) => (i: number) => PV[k]?.[i] ?? undefined;
// 动态 MA按周期组合注册一次figures 的 key 必须静态,故按签名建缓存)
const _maReg = new Set<string>();
function ensureMaIndicator(periods: number[]) {
const sig = [...periods].sort((a, b) => a - b).join('_');
if (_maReg.has(sig)) return `pv-ma-${sig}`;
registerIndicator({
name: 'pv-ma',
name: `pv-ma-${sig}`,
shortName: 'MA',
figures: [
{ key: 'ma5', title: 'MA5', type: 'line', styles: () => ({ color: C1 }) },
{ key: 'ma10', title: 'MA10', type: 'line', styles: () => ({ color: C2 }) },
{ key: 'ma20', title: 'MA20', type: 'line', styles: () => ({ color: C3 }) },
{ key: 'ma60', title: 'MA60', type: 'line', styles: () => ({ color: C4 }) },
],
calc: (d: KLineData[]) => d.map((_, i) => ({ ma5: g('ma5')(i), ma10: g('ma10')(i), ma20: g('ma20')(i), ma60: g('ma60')(i) })),
figures: periods.map((p, i) => ({
key: `ma${p}`, title: `MA${p}`, type: 'line',
styles: () => ({ color: MA_COLORS[i % MA_COLORS.length] }),
})),
calc: (d: KLineData[]) => d.map((_, i) => {
const row: Record<string, number | undefined> = {};
for (const p of periods) row[`ma${p}`] = g(`ma${p}`)(i);
return row;
}),
});
_maReg.add(sig);
return `pv-ma-${sig}`;
}
registerIndicator({
name: 'pv-boll',
shortName: 'BOLL',
figures: [
{ key: 'upper', title: 'UP', type: 'line', styles: () => ({ color: C3 }) },
{ key: 'mid', title: 'MB', type: 'line', styles: () => ({ color: C2 }) },
{ key: 'lower', title: 'DN', type: 'line', styles: () => ({ color: C3 }) },
{ key: 'upper', title: 'UP', type: 'line', styles: () => ({ color: '#a855f7' }) },
{ key: 'mid', title: 'MB', type: 'line', styles: () => ({ color: '#f59e0b' }) },
{ key: 'lower', title: 'DN', type: 'line', styles: () => ({ color: '#a855f7' }) },
],
calc: (d: KLineData[]) => d.map((_, i) => ({ upper: g('upper')(i), mid: g('mid')(i), lower: g('lower')(i) })),
});
@@ -52,8 +67,8 @@ registerIndicator({
name: 'pv-macd',
shortName: 'MACD',
figures: [
{ key: 'dif', title: 'DIF', type: 'line', styles: () => ({ color: C1 }) },
{ key: 'dea', title: 'DEA', type: 'line', styles: () => ({ color: C2 }) },
{ key: 'dif', title: 'DIF', type: 'line', styles: () => ({ color: '#2563eb' }) },
{ key: 'dea', title: 'DEA', type: 'line', styles: () => ({ color: '#f59e0b' }) },
{
key: 'hist', title: 'HIST', type: 'bar', baseValue: 0, // 零轴柱,缺省会从面板底部画起
styles: (p) => {
@@ -69,9 +84,9 @@ registerIndicator({
name: 'pv-kdj',
shortName: 'KDJ',
figures: [
{ key: 'k', title: 'K', type: 'line', styles: () => ({ color: C1 }) },
{ key: 'd', title: 'D', type: 'line', styles: () => ({ color: C2 }) },
{ key: 'j', title: 'J', type: 'line', styles: () => ({ color: UP }) },
{ key: 'k', title: 'K', type: 'line', styles: () => ({ color: '#2563eb' }) },
{ key: 'd', title: 'D', type: 'line', styles: () => ({ color: '#f59e0b' }) },
{ key: 'j', title: 'J', type: 'line', styles: () => ({ color: '#dc2626' }) },
],
calc: (d: KLineData[]) => d.map((_, i) => ({ k: g('k')(i), d: g('d')(i), j: g('j')(i) })),
});
@@ -80,17 +95,23 @@ registerIndicator({
name: 'pv-rsi',
shortName: 'RSI',
figures: [
{ key: 'rsi6', title: 'RSI6', type: 'line', styles: () => ({ color: C1 }) },
{ key: 'rsi12', title: 'RSI12', type: 'line', styles: () => ({ color: C2 }) },
{ key: 'rsi24', title: 'RSI24', type: 'line', styles: () => ({ color: C3 }) },
{ key: 'rsi6', title: 'RSI6', type: 'line', styles: () => ({ color: '#2563eb' }) },
{ key: 'rsi12', title: 'RSI12', type: 'line', styles: () => ({ color: '#f59e0b' }) },
{ key: 'rsi24', title: 'RSI24', type: 'line', styles: () => ({ color: '#a855f7' }) },
],
calc: (d: KLineData[]) => d.map((_, i) => ({ rsi6: g('rsi6')(i), rsi12: g('rsi12')(i), rsi24: g('rsi24')(i) })),
});
const container = ref<HTMLDivElement | null>(null);
let chart: Chart | null = null;
let allData: KLineData[] = [];
let served = 0; // 已交给图表的 bar 数从尾部计backward 分页用
const LIGHT_STYLES = {
const INIT_BARS = 240; // 初始展示根数(约一年日线)
const PAGE_BARS = 500; // 每次向左滚动追加的历史根数
function lightStyles() {
return {
grid: { horizontal: { color: '#eef2f7' }, vertical: { color: '#eef2f7' } },
candle: {
bar: {
@@ -111,32 +132,108 @@ const LIGHT_STYLES = {
},
separator: { color: '#e2e8f0' },
};
}
// 副图默认高度
const SUB_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);
// ---------- 鼠标跟随信息框(通达信式) ----------
interface HoverInfo {
date: string; open: number; high: number; low: number; close: number;
chg: number | null; amp: number | null; vol: string; amount: string | null;
mas: { label: string; value: number | null; color: string }[];
}
const hover = ref<HoverInfo | null>(null);
function fmtVol(v: number): string {
if (v >= 1e8) return (v / 1e8).toFixed(2) + '亿';
if (v >= 1e4) return (v / 1e4).toFixed(2) + '万';
return String(Math.round(v));
}
function bindCrosshair(ch: Chart) {
ch.subscribeAction('onCrosshairChange', (payload) => {
const k = (payload as { data?: { kLineData?: KLineData } }).data?.kLineData;
if (!k || !allData.length) { hover.value = null; return; }
// 二分定位索引(全量数组与指标序列按索引对齐)
let lo = 0, hi = allData.length - 1, idx = -1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (allData[mid].timestamp === k.timestamp) { idx = mid; break; }
if (allData[mid].timestamp < k.timestamp) lo = mid + 1; else hi = mid - 1;
}
if (idx < 0) { hover.value = null; return; }
const prev = idx > 0 ? allData[idx - 1] : null;
const chg = prev ? ((k.close - prev.close) / prev.close) * 100 : null;
const amp = prev ? ((k.high - k.low) / prev.close) * 100 : null;
hover.value = {
date: new Date(k.timestamp).toLocaleDateString('zh-CN'),
open: k.open, high: k.high, low: k.low, close: k.close,
chg, amp, vol: fmtVol(k.volume ?? 0), amount: null,
mas: props.maPeriods.map((p, i) => ({
label: `MA${p}`,
value: PV[`ma${p}`]?.[idx] ?? null,
color: MA_COLORS[i % MA_COLORS.length],
})),
};
});
}
// ---------- 画图画线(通达信式工具栏) ----------
const TOOLS: { key: string; label: string; title: string }[] = [
{ key: '', label: '光标', title: '光标模式Esc 取消画线)' },
{ key: 'segment', label: '', title: '线段' },
{ key: 'ray', label: '→', title: '射线' },
{ key: 'horizontalLine', label: '─', title: '水平线' },
{ key: 'rect', label: '▭', title: '矩形' },
{ key: 'priceChannelLine', label: '∥', title: '价格通道' },
{ key: 'fibLine', label: 'fib', title: '斐波那契回撤' },
];
const activeTool = ref('');
function pickTool(key: string) {
activeTool.value = key;
if (chart && key) chart.createOverlay({ name: key });
}
function clearOverlays() {
chart?.removeOverlay();
activeTool.value = '';
}
function build() {
if (!container.value || props.candles.length === 0) return;
UP = settings.upHex;
DOWN = settings.downHex;
PV = {};
for (const [group, series] of Object.entries(props.indicators)) {
for (const [key, arr] of Object.entries(series)) PV[key] = arr;
}
const ch = init(container.value, { styles: LIGHT_STYLES });
const ch = init(container.value, { styles: lightStyles() });
if (!ch) return;
chart = ch;
const data: KLineData[] = props.candles.map((k) => ({
allData = props.candles.map((k) => ({
timestamp: new Date(k.ts).getTime(),
open: k.open, high: k.high, low: k.low, close: k.close, volume: k.volume,
}));
served = 0;
ch.setDataLoader({
getBars: ({ type, callback }) => {
if (type === 'update') {
const last = data[data.length - 1];
callback(last ? [last] : [], { backward: false, forward: false });
const last = allData[allData.length - 1];
callback(last ? [last] : [], { backward: served < allData.length, forward: false });
} else if (type === 'init') {
callback(data, { backward: false, forward: false });
// 全量已拉到本地:先给最近 INIT_BARS 根,向左滚动时按页吐更早历史
served = Math.min(INIT_BARS, allData.length);
callback(allData.slice(allData.length - served), { backward: served < allData.length, forward: false });
} else if (type === 'backward') {
const remain = allData.length - served;
const take = Math.min(PAGE_BARS, remain);
const start = allData.length - served - take;
served += take;
callback(allData.slice(start, start + take), { backward: served < allData.length, forward: false });
} else {
callback([], { backward: false, forward: false });
}
@@ -146,21 +243,22 @@ function build() {
ch.setSymbol({ ticker: props.ticker });
ch.setPeriod({ type: 'day', span: 1 });
// 主图MA 恒开BOLL 可选
ch.createIndicator({ name: 'pv-ma', paneId: 'candle_pane' });
// 主图MA(周期可配置)恒开BOLL 可选
ch.createIndicator({ name: ensureMaIndicator(props.maPeriods), paneId: 'candle_pane' });
if (props.showBoll) ch.createIndicator({ name: 'pv-boll', paneId: 'candle_pane' });
// 副图按用户顺序创建,并压矮;主图吃剩余高度
const subHeights = props.subPanes.reduce((s, k) => s + (SUB_HEIGHT[k] ?? 90), 0);
// 副图按用户顺序创建,并设置用户高度;主图吃剩余高度
const subTotal = props.subPanes.reduce((s, k) => s + subH(k), 0);
const total = container.value.clientHeight || 560;
ch.setPaneOptions({ id: 'candle_pane', height: Math.max(220, total - subHeights - 24) });
ch.setPaneOptions({ id: 'candle_pane', height: Math.max(200, total - subTotal - 24) });
for (const key of props.subPanes) {
if (key === 'vol') ch.createIndicator('VOL');
else ch.createIndicator(`pv-${key}`);
const paneId = ch.getIndicators().find((i) => i.name === (key === 'vol' ? 'VOL' : `pv-${key}`))?.paneId;
if (paneId) ch.setPaneOptions({ id: paneId, height: SUB_HEIGHT[key] ?? 90 });
const name = key === 'vol' ? 'VOL' : `pv-${key}`;
ch.createIndicator(name);
const paneId = ch.getIndicators().find((i) => i.name === name)?.paneId;
if (paneId) ch.setPaneOptions({ id: paneId, height: subH(key) });
}
bindCrosshair(ch);
ch.setOffsetRightDistance(28);
ch.scrollToRealTime();
}
@@ -168,13 +266,71 @@ function build() {
function teardown() {
if (container.value) dispose(container.value);
chart = null;
hover.value = null;
activeTool.value = '';
}
onMounted(build);
onBeforeUnmount(teardown);
watch(() => [props.candles, props.indicators, props.subPanes, props.showBoll], () => { teardown(); build(); }, { deep: true });
watch(() => [props.candles, props.indicators, props.subPanes, props.showBoll, props.maPeriods, props.timeframe], () => { teardown(); build(); }, { deep: true });
// 涨跌配色切换:重建图表以应用新颜色
watch(() => settings.priceTone, () => { teardown(); build(); });
// 副图高度变化:仅调 pane 高度,不重建(保留滚动/画线状态)
watch(() => props.subHeights, () => {
if (!chart) return;
const subTotal = props.subPanes.reduce((s, k) => s + subH(k), 0);
const total = container.value?.clientHeight || 560;
chart.setPaneOptions({ id: 'candle_pane', height: Math.max(200, total - subTotal - 24) });
for (const key of props.subPanes) {
const name = key === 'vol' ? 'VOL' : `pv-${key}`;
const paneId = chart.getIndicators().find((i) => i.name === name)?.paneId;
if (paneId) chart.setPaneOptions({ id: paneId, height: subH(key) });
}
}, { deep: true });
</script>
<template>
<div class="relative h-full w-full">
<div ref="container" class="h-full w-full"></div>
<!-- 鼠标跟随信息框通达信式小方块 -->
<div
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"
>
<div class="text-slate-400">{{ hover.date }}</div>
<div> <span :class="hover.chg != null && hover.chg >= 0 ? 'text-red-400' : 'text-emerald-400'">{{ hover.open.toFixed(2) }}</span>
<span class="text-red-400">{{ hover.high.toFixed(2) }}</span>
<span class="text-emerald-400">{{ hover.low.toFixed(2) }}</span>
<span :class="hover.chg != null && hover.chg >= 0 ? 'text-red-400' : 'text-emerald-400'">{{ hover.close.toFixed(2) }}</span></div>
<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>
<span class="text-slate-100">{{ hover.amp == null ? '—' : hover.amp.toFixed(2) + '%' }}</span>
<span class="text-slate-100">{{ hover.vol }}</span></div>
<div v-if="hover.mas.length" class="mt-0.5">
<span v-for="(m, i) in hover.mas" :key="m.label" class="mr-2" :style="{ color: m.color }">
{{ m.label }} {{ m.value == null ? '' : m.value.toFixed(2) }}<span v-if="i < hover.mas.length - 1" class="invisible">,</span>
</span>
</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">
<button
v-for="t in TOOLS"
:key="t.key || 'cursor'"
type="button"
class="min-w-6 rounded px-1 py-0.5 text-[11px] transition-colors"
:class="activeTool === t.key ? 'bg-blue-600 text-white' : 'text-slate-500 hover:bg-slate-100 hover:text-slate-900'"
:title="t.title"
@click="pickTool(t.key)"
>{{ t.label }}</button>
<span class="mx-0.5 h-3 w-px bg-slate-200"></span>
<button
type="button"
class="rounded px-1 py-0.5 text-[11px] text-red-500 transition-colors hover:bg-red-50"
title="清除全部画线"
@click="clearOverlays"
>清除</button>
</div>
</div>
</template>

View File

@@ -1,63 +0,0 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import * as echarts from 'echarts';
import type { EquityPoint } from '@/api/types';
const props = defineProps<{ equity: EquityPoint[] }>();
const container = ref<HTMLDivElement | null>(null);
let chart: echarts.ECharts | null = null;
const UP = '#dc2626';
const DOWN = '#16a34a';
function buildOption() {
const dates = props.equity.map(p => p.ts.slice(0, 10));
const vals = props.equity.map(p => Number(p.value.toFixed(2)));
const first = vals.length ? vals[0] : 0;
const last = vals.length ? vals[vals.length - 1] : 0;
const lineColor = last >= first ? UP : DOWN; // A股盈利红、亏损绿
return {
backgroundColor: 'transparent',
grid: { left: 64, right: 18, top: 14, bottom: 26 },
tooltip: {
trigger: 'axis' as const,
backgroundColor: '#ffffff', borderColor: '#e2e8f0', borderWidth: 1,
textStyle: { color: '#0f172a' },
valueFormatter: (v: number) => (v ?? 0).toLocaleString(undefined, { maximumFractionDigits: 0 }),
},
xAxis: {
type: 'category', data: dates, boundaryGap: false,
axisLine: { lineStyle: { color: '#e2e8f0' } },
axisLabel: { color: '#94a3b8' }, axisTick: { show: false },
},
yAxis: {
type: 'value', scale: true,
splitLine: { lineStyle: { color: '#f1f5f9' } },
axisLabel: { color: '#94a3b8' },
},
series: [{
type: 'line', data: vals, symbol: 'none', smooth: false,
lineStyle: { color: lineColor, width: 2 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: lineColor + '33' },
{ offset: 1, color: lineColor + '00' },
]),
},
}],
};
}
onMounted(() => {
if (container.value) chart = echarts.init(container.value, undefined, { renderer: 'canvas' });
chart?.setOption(buildOption());
});
onBeforeUnmount(() => { chart?.dispose(); chart = null; });
watch(() => props.equity, () => chart?.setOption(buildOption(), true), { deep: true });
</script>
<template>
<div ref="container" class="h-[240px] w-full"></div>
</template>

View File

@@ -1,319 +0,0 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { dispose, init, registerIndicator, type Chart, type Crosshair, type KLineData } from 'klinecharts';
import type { Candle, IndicatorOut, SignalOut } from '@/api/types';
const props = defineProps<{
candles: Candle[];
indicators: IndicatorOut;
signals: SignalOut[];
symbol?: string;
timeframe?: string;
strategy?: string;
}>();
const TF_LABEL: Record<string, string> = { '1d': '日线', '1w': '周线', '1M': '月线', '1y': '年线' };
const STRAT_LABEL: Record<string, string> = { macd_cross: 'MACD', ma_cross: '双均线', single_ma: '单均线' };
const WD = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
// A股语义色浅色主题
const UP = '#dc2626';
const DOWN = '#16a34a';
const DIF_C = '#2563eb';
const DEA_C = '#f59e0b';
const MA_COLORS = ['#2563eb', '#f59e0b', '#a855f7', '#10b981'];
const IND_LABEL: Record<string, string> = { macd: 'DIF', signal: 'DEA', hist: 'MACD', fast: '快线', slow: '慢线', ma: '均线' };
const IND_COLOR: Record<string, string> = {
macd: DIF_C, signal: DEA_C, hist: '#94a3b8', fast: DIF_C, slow: DEA_C, ma: '#a855f7',
};
const isMACD = computed(() => props.strategy === 'macd_cross' || 'hist' in (props.indicators.data ?? {}));
const legendChips = computed(() => {
const keys = Object.keys(props.indicators.data ?? {});
if (isMACD.value) return [{ label: 'DIF', color: DIF_C }, { label: 'DEA', color: DEA_C }];
return keys.map((k, i) => ({ label: IND_LABEL[k] ?? k, color: MA_COLORS[i % MA_COLORS.length] }));
});
// ---------- 后端指标数据注入(单一事实源:不在前端重算指标) ----------
// calc 回调按索引回读这些序列,与 K 线严格对齐
let BE_SERIES: Record<string, (number | null)[]> = {};
registerIndicator({
name: 'be-macd',
shortName: 'MACD',
figures: [
{ key: 'dif', title: 'DIF', type: 'line', styles: () => ({ color: DIF_C }) },
{ key: 'dea', title: 'DEA', type: 'line', styles: () => ({ color: DEA_C }) },
{
key: 'hist', title: 'HIST', type: 'bar', baseValue: 0, // 零轴柱,缺省会从面板底部画起
styles: (p) => {
const v = (p.data.current as { hist?: number } | null)?.hist ?? 0;
return { color: v >= 0 ? UP : DOWN };
},
},
],
calc: (dataList: KLineData[]) =>
dataList.map((_, i) => ({
dif: BE_SERIES.macd?.[i] ?? undefined,
dea: BE_SERIES.signal?.[i] ?? undefined,
hist: BE_SERIES.hist?.[i] ?? undefined,
})),
});
registerIndicator({
name: 'be-lines',
shortName: 'MA',
figures: [
{ key: 'fast', title: '快线', type: 'line', styles: () => ({ color: DIF_C }) },
{ key: 'slow', title: '慢线', type: 'line', styles: () => ({ color: DEA_C }) },
{ key: 'ma', title: '均线', type: 'line', styles: () => ({ color: MA_COLORS[2] }) },
],
calc: (dataList: KLineData[]) =>
dataList.map((_, i) => ({
fast: BE_SERIES.fast?.[i] ?? undefined,
slow: BE_SERIES.slow?.[i] ?? undefined,
ma: BE_SERIES.ma?.[i] ?? undefined,
})),
});
// ---------- 画线工具 ----------
const TOOLS: { name: string | null; label: string; title: string }[] = [
{ name: null, label: '指针', title: '浏览模式(点击已画图形可选中/拖动/编辑)' },
{ name: 'segment', label: '线段', title: '线段' },
{ name: 'horizontalStraightLine', label: '水平线', title: '水平直线' },
{ name: 'verticalStraightLine', label: '垂直线', title: '垂直直线' },
{ name: 'rectangle', label: '矩形', title: '矩形区域' },
{ name: 'fibonacciSegment', label: '斐波那契', title: '斐波那契回调' },
{ name: 'priceChannelLine', label: '通道线', title: '价格通道线' },
];
const activeTool = ref<string | null>(null);
function pickTool(name: string) {
activeTool.value = name;
chart?.createOverlay({ name });
}
function clearDrawings() {
if (!chart) return;
chart.removeOverlay(); // 清除全部(含买卖点标注),随后重建标注
drawSignalAnnotations();
}
// ---------- 图表 ----------
const container = ref<HTMLDivElement | null>(null);
let chart: Chart | null = null;
interface DayRec {
open: number; high: number; low: number; close: number; volume: number;
prevClose: number | null; ind: Record<string, number | null>;
}
let byIndex: DayRec[] = [];
let candleData: KLineData[] = [];
const tip = ref<{ visible: boolean; x: number; y: number }>({ visible: false, x: 0, y: 0 });
const tipData = ref<ReturnType<typeof buildTip> | null>(null);
const fmt2 = (v: number | null) => (v == null ? '—' : v.toFixed(2));
const fmt3 = (v: number | null) => (v == null ? '—' : v.toFixed(3));
function weekdayOf(s: string) { return WD[new Date(s + 'T00:00:00').getDay()] ?? ''; }
function buildTip(rec: DayRec, ts: string) {
const prev = rec.prevClose ?? rec.open;
const change = rec.close - prev;
return {
date: ts.slice(0, 10), weekday: weekdayOf(ts.slice(0, 10)),
open: rec.open, high: rec.high, low: rec.low, close: rec.close,
change, chgPct: prev ? (change / prev) * 100 : 0,
amplitude: prev ? ((rec.high - rec.low) / prev) * 100 : 0,
volLots: Math.round(rec.volume / 100),
ind: rec.ind, up: change >= 0,
};
}
// 浅色主题 + A股红涨绿跌与默认样式深合并
const LIGHT_STYLES = {
grid: { horizontal: { color: '#eef2f7' }, vertical: { color: '#eef2f7' } },
candle: {
bar: {
upColor: UP, downColor: DOWN,
upBorderColor: UP, downBorderColor: DOWN,
upWickColor: UP, downWickColor: DOWN,
},
priceMark: {
high: { color: '#94a3b8' }, low: { color: '#94a3b8' },
last: { upColor: UP, downColor: DOWN },
},
},
xAxis: { axisLine: { color: '#e2e8f0' }, tickText: { color: '#64748b' }, tickLine: { color: '#e2e8f0' } },
yAxis: { axisLine: { color: '#e2e8f0' }, tickText: { color: '#64748b' }, tickLine: { color: '#e2e8f0' } },
crosshair: {
horizontal: { text: { backgroundColor: '#1e293b' } },
vertical: { text: { backgroundColor: '#1e293b' } },
},
separator: { color: '#e2e8f0' },
};
function build() {
if (!container.value || props.candles.length === 0) return;
BE_SERIES = props.indicators.data ?? {};
const ch = init(container.value, { styles: LIGHT_STYLES });
if (!ch) return;
chart = ch;
// 逐日记录(悬停详情)
const c = props.candles;
const keys = Object.keys(BE_SERIES);
byIndex = c.map((k, i) => {
const ind: Record<string, number | null> = {};
keys.forEach((key) => { ind[key] = BE_SERIES[key]?.[i] ?? null; });
return {
open: k.open, high: k.high, low: k.low, close: k.close, volume: k.volume,
prevClose: i > 0 ? c[i - 1].close : null, ind,
};
});
candleData = c.map((k) => ({
timestamp: new Date(k.ts).getTime(),
open: k.open, high: k.high, low: k.low, close: k.close, volume: k.volume,
}));
const tsList = c.map((k) => k.ts);
// v10 数据接入DataLoader 一次性提供全量(回测结果静态数据,无分页)
// 注意v10 要求 symbol+period+dataLoader 三者齐备才触发 'init' 加载,缺一图表空白
ch.setDataLoader({
getBars: ({ type, callback }) => {
if (type === 'update') {
const last = candleData[candleData.length - 1];
callback(last ? [last] : [], { backward: false, forward: false });
} else if (type === 'init') {
callback(candleData, { backward: false, forward: false });
} else {
callback([], { backward: false, forward: false });
}
},
});
ch.setSymbol({ ticker: props.symbol ?? 'BACKTEST' });
ch.setPeriod({ type: 'day', span: 1 });
// 副图/叠加MACD 独立 pane均线叠加主图
ch.createIndicator('VOL');
if (isMACD.value) {
ch.createIndicator('be-macd');
} else {
ch.createIndicator({ name: 'be-lines', paneId: 'candle_pane' });
}
// 副图压矮,主图占大头
for (const ind of ch.getIndicators()) {
if (ind.name === 'VOL') ch.setPaneOptions({ id: ind.paneId, height: 84 });
if (ind.name === 'be-macd') ch.setPaneOptions({ id: ind.paneId, height: 120 });
}
drawSignalAnnotations();
// 悬停详情crosshair 事件自带数据索引与像素坐标
ch.subscribeAction('onCrosshairChange', (d) => {
const data = d as Crosshair | undefined;
const i = data?.dataIndex;
if (data == null || i == null || i < 0 || i >= byIndex.length) {
tip.value.visible = false;
return;
}
tipData.value = buildTip(byIndex[i], tsList[i]);
const el = container.value;
if (el && data.x != null && data.y != null) {
const TW = 224, TH = 196;
let x = data.x + 16; if (x + TW > el.clientWidth) x = data.x - TW - 16; if (x < 4) x = 4;
let y = data.y + 16; if (y + TH > el.clientHeight) y = data.y - TH - 24; if (y < 4) y = 4;
tip.value = { visible: true, x, y };
}
});
}
function drawSignalAnnotations() {
if (!chart) return;
const c = props.candles;
const idxOfTs = new Map<number, number>();
c.forEach((k, i) => idxOfTs.set(new Date(k.ts).getTime(), i));
for (const s of props.signals) {
const i = idxOfTs.get(new Date(s.ts).getTime());
if (i == null) continue;
const k = c[i];
const buy = s.side === 'buy';
chart.createOverlay({
name: 'simpleAnnotation',
points: [{ dataIndex: i, value: buy ? k.low : k.high }],
extendData: buy ? 'B' : 'S',
styles: { text: { color: buy ? UP : DOWN, size: 11, weight: 'bold' } },
});
}
}
function teardown() {
if (container.value) dispose(container.value);
chart = null;
}
onMounted(build);
onBeforeUnmount(teardown);
watch(() => [props.candles, props.indicators, props.signals, props.strategy], () => { teardown(); build(); }, { deep: true });
</script>
<template>
<div class="relative">
<!-- 图例 + 画线工具栏 -->
<div class="mb-1 flex flex-wrap items-center gap-x-3 gap-y-1 px-2">
<span class="text-[13px] font-semibold text-slate-900">
{{ symbol ?? '—' }}
<small class="ml-1.5 font-normal text-slate-400">
{{ TF_LABEL[timeframe ?? '1d'] ?? timeframe }} · {{ STRAT_LABEL[strategy ?? 'macd_cross'] ?? strategy }}
</small>
</span>
<span v-for="(chip, i) in legendChips" :key="i" class="flex items-center gap-1 text-xs text-slate-500">
<i class="inline-block h-0.5 w-3 rounded" :style="{ background: chip.color }"></i>{{ chip.label }}
</span>
<span class="ml-auto flex items-center gap-0.5 rounded-lg border border-slate-200 bg-slate-50 p-0.5">
<span class="px-1.5 text-[10px] text-slate-400">画线</span>
<button
v-for="tool in TOOLS"
:key="tool.label"
type="button"
:title="tool.title"
class="rounded-md px-2 py-1 text-xs transition-colors"
:class="activeTool === tool.name ? 'bg-blue-600 text-white' : 'text-slate-600 hover:bg-white hover:shadow-sm'"
@click="tool.name === null ? (activeTool = null) : pickTool(tool.name)"
>{{ tool.label }}</button>
<button type="button" class="rounded-md px-2 py-1 text-xs text-slate-400 transition-colors hover:bg-white hover:text-red-600" title="清除所有画线与标注" @click="clearDrawings">清除</button>
</span>
</div>
<!-- 悬停详情同花顺式 -->
<div
v-if="tip.visible && tipData"
class="pointer-events-none absolute z-20 w-[224px] rounded-lg border border-slate-200 bg-white/95 px-3 py-2 text-[11.5px] leading-relaxed text-slate-600 shadow-lg"
:style="{ left: tip.x + 'px', top: tip.y + 'px' }"
>
<div class="mb-0.5 font-semibold text-slate-900">{{ tipData.date }} <span class="ml-1 font-normal text-slate-400">{{ tipData.weekday }}</span></div>
<div class="grid grid-cols-2 gap-x-4">
<div> <b :class="tipData.up ? 'text-up' : 'text-down'">{{ fmt2(tipData.open) }}</b></div>
<div> <b class="text-up">{{ fmt2(tipData.high) }}</b></div>
<div> <b class="text-down">{{ fmt2(tipData.low) }}</b></div>
<div> <b :class="tipData.up ? 'text-up' : 'text-down'">{{ fmt2(tipData.close) }}</b></div>
</div>
<div>
涨跌 <b :class="tipData.up ? 'text-up' : 'text-down'">{{ tipData.change >= 0 ? '+' : '' }}{{ fmt2(tipData.change) }}</b>
· 涨幅 <b :class="tipData.up ? 'text-up' : 'text-down'">{{ tipData.chgPct.toFixed(2) }}%</b>
</div>
<div>振幅 {{ tipData.amplitude.toFixed(2) }}% · {{ tipData.volLots.toLocaleString() }} </div>
<div class="mt-1 border-t border-slate-100 pt-1">
<span v-for="(v, k) in tipData.ind" :key="k" class="mr-3" :style="{ color: IND_COLOR[k] ?? '#64748b' }">
{{ IND_LABEL[k] ?? k }} {{ fmt3(v) }}
</span>
</div>
</div>
<div ref="container" class="h-[520px] w-full"></div>
</div>
</template>

View File

@@ -1,39 +0,0 @@
<script setup lang="ts">
import type { MetricsOut } from '@/api/types';
defineProps<{ metrics: MetricsOut }>();
const pct = (x: number) => `${(x * 100).toFixed(2)}%`;
const num = (x: number) => x.toFixed(2);
// A股语义正=红、负=绿
const sign = (x: number) => (x >= 0 ? 'text-up' : 'text-down');
</script>
<template>
<div class="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<div class="text-xs text-slate-400">总收益</div>
<div class="mt-1 text-lg font-semibold" :class="sign(metrics.total_return)">{{ pct(metrics.total_return) }}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<div class="text-xs text-slate-400">最大回撤</div>
<div class="mt-1 text-lg font-semibold text-down">{{ pct(metrics.max_drawdown) }}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<div class="text-xs text-slate-400">夏普比率</div>
<div class="mt-1 text-lg font-semibold" :class="sign(metrics.sharpe)">{{ num(metrics.sharpe) }}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<div class="text-xs text-slate-400">年化波动</div>
<div class="mt-1 text-lg font-semibold text-slate-900">{{ pct(metrics.volatility) }}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<div class="text-xs text-slate-400">胜率</div>
<div class="mt-1 text-lg font-semibold text-slate-900">{{ pct(metrics.win_rate) }}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<div class="text-xs text-slate-400">交易次数</div>
<div class="mt-1 text-lg font-semibold text-slate-900">{{ metrics.num_trades }}</div>
</div>
</div>
</template>

View File

@@ -1,8 +1,13 @@
<script setup lang="ts">
import { ref } from 'vue';
import { deleteScreenerQuery, getScreenerQueries } from '@/api/client';
import type { ScreenConditions, ScreenerQueryItem } from '@/api/types';
defineProps<{ loading: boolean }>();
const emit = defineEmits<{ (e: 'run', text: string): void }>();
const props = defineProps<{ loading: boolean }>();
const emit = defineEmits<{
(e: 'run', text: string, conditions?: ScreenConditions | null): void;
(e: 'ran'): void;
}>();
const text = ref('');
@@ -10,22 +15,105 @@ const text = ref('');
const examples = [
'帮我找出这两天 KDJ 中的 J 小于 10市值大于 100 亿,小于 200 亿的公司',
'RSI 低于 30市盈率 TTM 小于 20 的公司',
'近 5 天曾经 MACD 金叉DIF 上穿 DEA换手率大于 5%,流通市值小于 100 亿',
'股价在布林带下轨之下,流通市值小于 50 亿',
];
function run() {
if (text.value.trim()) emit('run', text.value.trim());
if (text.value.trim()) {
emit('run', text.value.trim());
emit('ran');
}
}
// ---------- 提问历史(入库,可一键重跑 / 删除) ----------
const history = ref<ScreenerQueryItem[]>([]);
const historyOpen = ref(false);
async function loadHistory() {
historyOpen.value = !historyOpen.value;
if (historyOpen.value) await refreshHistory();
}
async function refreshHistory() {
try {
history.value = await getScreenerQueries(20);
} catch { history.value = []; }
}
function rerun(q: ScreenerQueryItem) {
text.value = q.text;
historyOpen.value = false;
// 存过 conditions 的记录直传条件,跳过 LLM 重新解析
emit('run', q.text, q.conditions ?? null);
emit('ran');
}
async function removeQuery(id: number) {
try {
await deleteScreenerQuery(id);
await refreshHistory();
} catch { /* 忽略 */ }
}
function fmtTime(s: string): string {
return s.replace('T', ' ').slice(5, 16);
}
defineExpose({ refreshHistory });
</script>
<template>
<div class="rounded-xl border border-slate-200 bg-white p-5">
<label class="lbl">用一句话描述你的选股条件</label>
<div class="flex items-center justify-between">
<label class="lbl !mb-0">用一句话描述你的选股条件</label>
<!-- 提问历史 -->
<div class="relative">
<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"
@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>
提问历史
</button>
<div
v-if="historyOpen"
class="absolute right-0 top-8 z-30 w-[26rem] rounded-lg border border-slate-200 bg-white shadow-lg"
>
<div v-if="history.length === 0" class="px-4 py-6 text-center text-xs text-slate-400">暂无历史提问</div>
<div v-else class="max-h-80 overflow-y-auto">
<div
v-for="q in history"
: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"
>
<button
type="button"
class="min-w-0 flex-1 text-left"
:title="q.conditions ? '点击直传条件重跑(不重新解析)' : '点击填入并重跑'"
@click="rerun(q)"
>
<span class="block truncate text-[13px] text-slate-700">{{ q.text }}</span>
<span class="mt-0.5 block text-[11px] text-slate-400">
{{ 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>
</button>
<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"
title="删除该记录"
@click.stop="removeQuery(q.id)"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12" /></svg>
</button>
</div>
</div>
</div>
</div>
</div>
<textarea
v-model="text"
rows="2"
class="ipt w-full resize-y leading-relaxed"
class="ipt mt-2 w-full resize-y leading-relaxed"
placeholder="例如:这两天 KDJ 的 J 小于 10市值 100~200 亿的公司"
@keyup.ctrl.enter="run"
/>

View File

@@ -48,6 +48,10 @@ function toggleSort(key: string) {
}
}
// 按当日涨跌着色(跟随设置中的涨跌配色)
const toneClass = (v: number | null | undefined) =>
v == null ? '' : v > 0 ? 'text-up' : v < 0 ? 'text-down' : '';
const sortedItems = computed(() => {
const key = sortKey.value;
const dir = sortDir.value === 'asc' ? 1 : -1;
@@ -112,8 +116,8 @@ function fmtInd(it: ScreenerItemOut, key: string) {
>
<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 text-slate-700">{{ it.name }}</td>
<td class="whitespace-nowrap px-3 py-1.5">{{ fmt2(it.close) }}</td>
<td class="whitespace-nowrap px-3 py-1.5" :class="it.pct_chg != null && FIXED_COLS[3].cls ? FIXED_COLS[3].cls!(it.pct_chg) : ''">
<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)">
{{ it.pct_chg == null ? '—' : (it.pct_chg > 0 ? '+' : '') + it.pct_chg.toFixed(2) }}
</td>
<td class="whitespace-nowrap px-3 py-1.5 text-slate-700">{{ fmt2(it.total_mv) }}</td>

View File

@@ -1,14 +1,16 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { getStockPreview } from '@/api/client';
import type { PreviewResponse, ScreenerItemOut } from '@/api/types';
import { addWatchlist, getStockPreview, getWatchlist as getWatchlistApi, removeWatchlist } from '@/api/client';
import type { ChartLayoutPrefs, PreviewResponse, ScreenerItemOut, Timeframe } from '@/api/types';
import { useSettingsStore, type PriceAdjust } from '@/stores/settings';
import DetailKLine from './DetailKLine.vue';
const props = defineProps<{
items: ScreenerItemOut[];
initial: string; // ts_code
}>();
const emit = defineEmits<{ (e: 'close'): void }>();
const emit = defineEmits<{ (e: 'close'): void; (e: 'watched-change'): void }>();
const settings = useSettingsStore();
// ---------- 状态 ----------
const active = ref(props.initial);
@@ -17,16 +19,123 @@ const loading = ref(false);
const error = ref<string | null>(null);
const filter = ref('');
// 副图指标:点击开关 / 拖拽排序
// 复权切换(持久化到设置;切换即重拉)
const ADJUSTS: { key: PriceAdjust; label: string }[] = [
{ key: 'bfq', label: '不复权' },
{ key: 'qfq', label: '前复权' },
{ key: 'hfq', label: '后复权' },
];
const adjust = computed(() => settings.priceAdjust);
function setAdjust(key: PriceAdjust) {
settings.setPriceAdjust(key);
}
// K线周期切换持久化到用户偏好
const PERIODS: { key: Timeframe; label: string }[] = [
{ key: '1d', label: '日K' },
{ key: '1w', label: '周K' },
{ key: '1M', label: '月K' },
{ key: '1y', label: '年K' },
];
const timeframe = ref<Timeframe>((settings.chartLayout.timeframe as Timeframe) ?? '1d');
function setTimeframe(tf: Timeframe) {
timeframe.value = tf;
settings.setChartLayout({ timeframe: tf });
}
// 数据口径徽标market=近段未复权兜底;其余为实际复权口径(可能因因子缺失与所选不同)
const ADJUST_LABELS: Record<string, string> = { bfq: '不复权', qfq: '前复权', hfq: '后复权' };
const sourceLabel = computed(() =>
data.value ? (ADJUST_LABELS[data.value.source] ?? data.value.source) : '');
// ---------- 副图 / MA / 高度(全部随用户偏好持久化) ----------
const SUBS = [
{ key: 'vol', label: 'VOL' },
{ key: 'macd', label: 'MACD' },
{ key: 'kdj', label: 'KDJ' },
{ key: 'rsi', label: 'RSI' },
];
const subPanes = ref<string[]>(['vol', 'macd', 'kdj', 'rsi']);
const layout = computed<ChartLayoutPrefs>(() => settings.chartLayout);
const subPanes = computed<string[]>(() => layout.value.subPanes);
const maPeriods = computed<number[]>(() => layout.value.maPeriods);
const subHeights = computed(() => layout.value.subHeights);
const showBoll = ref(false);
function toggleSub(key: string) {
const cur = subPanes.value;
settings.setChartLayout({
subPanes: cur.includes(key) ? cur.filter((k) => k !== key) : [...cur, key],
});
}
function adjustHeight(key: string, delta: number) {
const DEFAULTS: Record<string, number> = { vol: 64, macd: 100, kdj: 96, rsi: 84 };
const base = subHeights.value;
const next = Math.max(40, (base[key] ?? DEFAULTS[key] ?? 90) + delta);
settings.setChartLayout({ subHeights: { ...base, [key]: next } });
}
// 副图拖拽排序
let dragKey: string | null = null;
function onDragStart(e: DragEvent, key: string) {
dragKey = key;
e.dataTransfer?.setData('text/plain', key);
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
}
function onDrop(target: string) {
if (!dragKey || dragKey === target) return;
const arr = [...subPanes.value];
const from = arr.indexOf(dragKey);
if (from >= 0) arr.splice(from, 1);
const to = arr.indexOf(target);
arr.splice(to >= 0 ? to : arr.length, 0, dragKey);
settings.setChartLayout({ subPanes: arr });
dragKey = null;
}
// ---------- MA 配置(弹层) ----------
const MA_PRESETS = [5, 10, 20, 30, 60, 120, 250];
const showMaConfig = ref(false);
const customMa = ref('');
function toggleMa(p: number) {
const cur = maPeriods.value;
settings.setChartLayout({
maPeriods: cur.includes(p) ? cur.filter((x) => x !== p) : [...cur, p].sort((a, b) => a - b),
});
}
function addCustomMa() {
const v = parseInt(customMa.value, 10);
if (v >= 1 && v <= 500 && !maPeriods.value.includes(v)) {
settings.setChartLayout({ maPeriods: [...maPeriods.value, v].sort((a, b) => a - b) });
}
customMa.value = '';
showMaConfig.value = false;
}
// ---------- 自选股(星标) ----------
const watched = ref(false);
const watchBusy = ref(false);
const watchedSet = ref<Set<string>>(new Set());
async function refreshWatched() {
try {
watchedSet.value = new Set(await getWatchlistApi());
} catch { /* 未登录等场景忽略 */ }
watched.value = watchedSet.value.has(active.value);
}
async function toggleWatch() {
if (watchBusy.value) return;
watchBusy.value = true;
try {
const list = watched.value
? await removeWatchlist(active.value)
: await addWatchlist(active.value);
watchedSet.value = new Set(list);
watched.value = watchedSet.value.has(active.value);
emit('watched-change');
} catch { /* 忽略 */ } finally {
watchBusy.value = false;
}
}
const filteredItems = computed(() => {
const q = filter.value.trim().toLowerCase();
if (!q) return props.items;
@@ -50,7 +159,7 @@ const header = computed(() => {
};
});
// ---------- 数据加载 ----------
// ---------- 数据加载(拉全量历史,图表内按需分页展示) ----------
let fetchToken = 0;
async function load(code: string) {
const token = ++fetchToken;
@@ -58,7 +167,12 @@ async function load(code: string) {
error.value = null;
data.value = null;
try {
const res = await getStockPreview(code);
const res = await getStockPreview(code, {
limit: 30000,
adjust: adjust.value,
timeframe: timeframe.value,
mas: maPeriods.value,
});
if (token === fetchToken) data.value = res;
} catch (e) {
if (token === fetchToken) error.value = e instanceof Error ? e.message : '加载失败';
@@ -67,6 +181,16 @@ async function load(code: string) {
}
}
watch(active, (code) => load(code), { immediate: true });
watch(adjust, () => load(active.value));
watch(timeframe, () => load(active.value));
// MA 周期变化也要重拉(后端按 mas 计算指标序列)
watch(maPeriods, () => load(active.value));
// 切股时同步自选状态
watch(active, (code) => {
watched.value = watchedSet.value.has(code);
}, { immediate: true });
void refreshWatched();
function moveActive(delta: number) {
const list = filteredItems.value;
@@ -78,11 +202,10 @@ function moveActive(delta: number) {
// ---------- 键盘 / 滚动锁 ----------
function onKeydown(e: KeyboardEvent) {
// 输入法组合态 / 焦点在输入框时不拦截否则搜索框打字会切股、Esc 关浮层)
if (e.isComposing) return;
const t = e.target as HTMLElement | null;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
if (e.key === 'Escape') emit('close');
if (e.key === 'Escape') { if (showMaConfig.value) showMaConfig.value = false; else emit('close'); }
else if (e.key === 'ArrowUp') { e.preventDefault(); moveActive(-1); }
else if (e.key === 'ArrowDown') { e.preventDefault(); moveActive(1); }
}
@@ -95,29 +218,28 @@ onBeforeUnmount(() => {
document.body.style.overflow = '';
});
// ---------- 副图 chips开关 + 拖拽排序 ----------
let dragKey: string | null = null;
function toggleSub(key: string) {
subPanes.value = subPanes.value.includes(key)
? subPanes.value.filter((k) => k !== key)
: [...subPanes.value, key];
}
function onDragStart(e: DragEvent, key: string) {
dragKey = key;
// Firefox/Safari 要求 dragstart 写入数据才会真正发起拖拽
e.dataTransfer?.setData('text/plain', key);
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
}
function onDrop(target: string) {
if (!dragKey || dragKey === target) return;
const arr = [...subPanes.value];
const from = arr.indexOf(dragKey);
if (from >= 0) arr.splice(from, 1);
const to = arr.indexOf(target);
arr.splice(to >= 0 ? to : arr.length, 0, dragKey);
subPanes.value = arr;
dragKey = null;
// ---------- 右侧信息栏增强52周高低 / 年初至今(从日线序列算,无数据留空) ----------
const stats = computed(() => {
const bars = timeframe.value === '1d' ? data.value?.candles : null;
if (!bars || bars.length === 0) return { high52: null, low52: null, ytd: null };
const last = bars[bars.length - 1];
const lastTs = new Date(last.ts);
const yearStart = new Date(lastTs.getFullYear(), 0, 1).getTime();
let high = -Infinity, low = Infinity;
let ytdBase: number | null = null;
const cutoff = lastTs.getTime() - 365 * 24 * 3600 * 1000;
for (const b of bars) {
const t = new Date(b.ts).getTime();
if (t >= cutoff) { high = Math.max(high, b.high); low = Math.min(low, b.low); }
// 年初至今基准 = 上一年最后一根收盘
if (t < yearStart) ytdBase = b.close;
}
return {
high52: high === -Infinity ? null : high,
low52: low === Infinity ? null : low,
ytd: ytdBase && ytdBase !== 0 ? ((last.close - ytdBase) / ytdBase) * 100 : null,
};
});
// ---------- 格式化 ----------
const fmt = (v: number | null | undefined, d = 2) => (v == null ? '—' : v.toFixed(d));
@@ -131,7 +253,20 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
<template>
<div class="fixed inset-0 z-40 flex flex-col bg-slate-100">
<!-- 顶栏 -->
<header class="flex h-12 shrink-0 items-center gap-4 border-b border-slate-200 bg-white px-4">
<header class="flex h-12 shrink-0 items-center gap-3 border-b border-slate-200 bg-white px-4">
<!-- 自选星标 -->
<button
type="button"
class="shrink-0 rounded p-1 transition-colors hover:bg-slate-100 disabled:opacity-50"
:class="watched ? 'text-amber-500' : 'text-slate-300'"
:title="watched ? '移出自选' : '加入自选'"
:disabled="watchBusy"
@click="toggleWatch"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" :fill="watched ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linejoin="round">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
</button>
<div class="flex items-baseline gap-2">
<span class="text-base font-semibold text-slate-900">{{ header.name }}</span>
<span class="text-xs text-slate-400">{{ active }}</span>
@@ -142,12 +277,39 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
{{ header.pct > 0 ? '+' : '' }}{{ fmt(header.pct) }}%
</span>
</div>
<!-- 周期切换 -->
<div class="flex rounded-md border border-slate-200 p-0.5 text-[11px]">
<button
v-for="p in PERIODS"
:key="p.key"
type="button"
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'"
@click="setTimeframe(p.key)"
>{{ p.label }}</button>
</div>
<!-- 复权切换 -->
<div class="flex rounded-md border border-slate-200 p-0.5 text-[11px]">
<button
v-for="a in ADJUSTS"
:key="a.key"
type="button"
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'"
@click="setAdjust(a.key)"
>{{ a.label }}</button>
</div>
<span v-if="data?.source === 'market'" class="rounded bg-amber-50 px-2 py-0.5 text-[11px] text-amber-600">
近段未复权数据
</span>
<span v-else-if="data" class="rounded bg-blue-50 px-2 py-0.5 text-[11px] text-blue-600">前复权</span>
<span
v-else-if="data"
class="rounded px-2 py-0.5 text-[11px]"
:class="data.source === adjust ? 'bg-blue-50 text-blue-600' : 'bg-amber-50 text-amber-600'"
:title="data.source === adjust ? '' : '该股复权因子缺失,暂按此口径显示(可先同步市场数据)'"
>{{ sourceLabel }}</span>
<span class="ml-auto text-xs text-slate-400"> 切换 · Esc 关闭</span>
<span class="ml-auto text-xs text-slate-400"> 切换 · Esc 关闭 · 滚轮缩放 · 左滑加载历史</span>
<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>
</button>
@@ -174,7 +336,7 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
<span class="block text-[11px] text-slate-400">{{ it.ts_code }}</span>
</span>
<span class="text-right">
<span class="block text-[13px]">{{ fmt(it.close) }}</span>
<span class="block text-[13px] font-medium" :class="pctClass(it.pct_chg)">{{ fmt(it.close) }}</span>
<span class="block text-[11px]" :class="pctClass(it.pct_chg)">
{{ it.pct_chg == null ? '—' : (it.pct_chg > 0 ? '+' : '') + it.pct_chg.toFixed(2) + '%' }}
</span>
@@ -187,19 +349,23 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
<!-- K线 + 指标面板 -->
<section class="flex min-w-0 flex-1 flex-col">
<!-- 指标开关 / 排序 -->
<!-- 指标开关 / 排序 / MA 配置 -->
<div class="flex shrink-0 flex-wrap items-center gap-1.5 bg-white px-3 py-2">
<span class="text-[11px] text-slate-400">副图</span>
<button
<div
v-for="s in SUBS"
:key="s.key"
class="flex items-center overflow-hidden rounded-md border"
:class="subPanes.includes(s.key) ? 'border-blue-600' : 'border-slate-200'"
>
<button
type="button"
draggable="true"
class="cursor-grab rounded-md border px-2.5 py-1 text-xs transition-colors active:cursor-grabbing"
class="px-2.5 py-1 text-xs transition-colors"
:class="subPanes.includes(s.key)
? 'border-blue-600 bg-blue-600 text-white'
: 'border-slate-200 bg-white text-slate-400 line-through'"
:title="subPanes.includes(s.key) ? '点击隐藏 · 拖动排序' : '点击显示'"
? 'bg-blue-600 text-white'
: 'bg-white text-slate-400 line-through'"
:title="subPanes.includes(s.key) ? '点击隐藏 · 拖动排序 · 右侧按钮调高度' : '点击显示'"
@click="toggleSub(s.key)"
@dragstart="onDragStart($event, s.key)"
@dragover.prevent
@@ -207,6 +373,11 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
>
{{ s.label }}
</button>
<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-[10px] text-slate-400 hover:bg-slate-100 hover:text-slate-700" title="调矮" @click="adjustHeight(s.key, -20)"></button>
</template>
</div>
<button
type="button"
class="rounded-md border px-2.5 py-1 text-xs transition-colors"
@@ -214,14 +385,50 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
title="主图叠加布林带"
@click="showBoll = !showBoll"
>BOLL</button>
<span class="ml-2 text-[11px] text-slate-400">点击开关副图 · 拖动排序 · 滚轮缩放 · 拖拽平移</span>
<!-- MA 配置 -->
<div class="relative">
<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"
@click="showMaConfig = !showMaConfig"
>MA 设置</button>
<div
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"
>
<div class="mb-2 text-[11px] text-slate-400">勾选主图显示的均线</div>
<div class="grid grid-cols-4 gap-1">
<label
v-for="p in MA_PRESETS"
:key="p"
class="flex cursor-pointer items-center justify-center rounded border px-1 py-1 text-xs"
:class="maPeriods.includes(p) ? 'border-blue-600 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-500'"
>
<input type="checkbox" class="hidden" :checked="maPeriods.includes(p)" @change="toggleMa(p)" />
MA{{ p }}
</label>
</div>
<div class="mt-2 flex items-center gap-1">
<input
v-model="customMa"
type="number" min="1" max="500"
class="ipt w-full !py-1 text-xs"
placeholder="自定义周期"
@keyup.enter="addCustomMa"
/>
<button type="button" class="btn-primary !px-2 !py-1 text-xs" @click="addCustomMa"></button>
</div>
<div class="mt-1.5 text-[11px] text-slate-400">当前{{ maPeriods.map((p: number) => 'MA' + p).join(' / ') || '无' }}</div>
</div>
</div>
<span class="ml-auto text-[11px] text-slate-400">点击开关 · 拖动排序 · 调高度 · 右上工具栏画线</span>
</div>
<!-- 图表 -->
<div class="relative min-h-0 flex-1 bg-white 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">
<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 v-else-if="error" class="flex h-full items-center justify-center text-sm text-red-600">{{ error }}</div>
<DetailKLine
@@ -230,7 +437,10 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
:candles="data.candles"
:indicators="data.indicators"
:sub-panes="subPanes"
:ma-periods="maPeriods"
:sub-heights="subHeights"
:show-boll="showBoll"
:timeframe="timeframe"
/>
<div v-else class="flex h-full items-center justify-center text-sm text-slate-400">无数据</div>
</div>
@@ -265,6 +475,9 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
['市净率', fmt(data.info.pb)],
['总市值', fmt(data.info.total_mv) + ' 亿'],
['流通市值', fmt(data.info.circ_mv) + ' 亿'],
['52周最高', fmt(stats.high52)],
['52周最低', fmt(stats.low52)],
['年初至今', stats.ytd == null ? '—' : (stats.ytd > 0 ? '+' : '') + stats.ytd.toFixed(2) + '%'],
['上市日期', fmtListDate(data.info.list_date)],
['数据日期', (data.info.trade_date ?? '').slice(0, 10) || '—'],
]" :key="i">
@@ -273,6 +486,22 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
</template>
</div>
<!-- 股本/分红/股东(数据未接入前留空占位) -->
<div class="mt-4 border-t border-slate-100 pt-3 text-[13px]">
<div class="mb-2 text-xs text-slate-400">股本 / 分红 / 股东</div>
<div class="grid grid-cols-2 gap-y-2">
<template v-for="(row, i) in [
['股东户数', '—'],
['户均持股', '—'],
['分红率', '—'],
['股息率', '—'],
]" :key="i">
<span class="text-slate-400">{{ row[0] }}</span>
<span class="text-right text-slate-300" title="数据源待接入">{{ row[1] }}</span>
</template>
</div>
</div>
<div class="mt-4 border-t border-slate-100 pt-3 text-[13px]">
<div class="mb-2 text-xs text-slate-400">归属</div>
<div class="flex flex-wrap gap-1.5">

View File

@@ -8,6 +8,7 @@ const router = createRouter({
{ path: '/login', name: 'login', component: () => import('@/views/LoginView.vue'), meta: { public: true } },
{ path: '/', name: 'home', component: HomeView },
{ path: '/screener', name: 'screener', component: () => import('@/views/ScreenerView.vue') },
{ path: '/stocks', name: 'stocks', component: () => import('@/views/StocksView.vue') },
{ path: '/backtest', name: 'backtest', component: () => import('@/views/BacktestView.vue') },
{ path: '/:pathMatch(.*)*', redirect: '/' },
],

View File

@@ -3,6 +3,7 @@ import { defineStore } from 'pinia';
import { ApiError, getCurrentUser, login as requestLogin, logout as requestLogout } from '@/api/client';
import type { CurrentUser } from '@/api/types';
import { useSettingsStore } from '@/stores/settings';
export const useAuthStore = defineStore('auth', () => {
const user = ref<CurrentUser | null>(null);
@@ -18,6 +19,7 @@ export const useAuthStore = defineStore('auth', () => {
restorePromise = (async () => {
try {
user.value = await getCurrentUser();
if (user.value) void useSettingsStore().syncFromServer();
} catch (error) {
if (error instanceof ApiError && error.status !== 401) {
console.warn('Unable to restore login session:', error.message);
@@ -37,6 +39,7 @@ export const useAuthStore = defineStore('auth', () => {
const result = await requestLogin({ username, password });
user.value = result.user;
initialized.value = true;
void useSettingsStore().syncFromServer();
} finally {
loading.value = false;
}

View File

@@ -1,43 +0,0 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { postBacktest, syncData } from '@/api/client';
import type { BacktestRequest, BacktestResponse } from '@/api/types';
export const useBacktestStore = defineStore('backtest', () => {
const loading = ref(false);
const stage = ref<'idle' | 'syncing' | 'backtesting'>('idle');
const note = ref<string | null>(null);
const result = ref<BacktestResponse | null>(null);
const error = ref<string | null>(null);
async function run(req: BacktestRequest) {
loading.value = true;
error.value = null;
result.value = null;
note.value = null;
try {
// 非演示标的:先拉取并缓存真实行情(首次较慢;回测端点也会兜底)
if (req.symbol.trim().toUpperCase() !== 'DEMO') {
stage.value = 'syncing';
note.value = `正在拉取 ${req.symbol} 行情数据(首次较慢,已自动缓存)…`;
try {
await syncData({ symbol: req.symbol, source: 'auto' });
} catch {
/* 忽略:回测端点会兜底拉取或复用缓存 */
}
}
stage.value = 'backtesting';
note.value = '回测中…';
result.value = await postBacktest(req);
} catch (e) {
error.value = e instanceof Error ? e.message : '回测失败';
} finally {
loading.value = false;
stage.value = 'idle';
note.value = null;
}
}
return { loading, stage, note, result, error, run };
});

View File

@@ -1,7 +1,7 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { getScreenerSyncStatus, runScreener, startScreenerSync } from '@/api/client';
import type { ScreenerRunResponse, ScreenerSyncStatus } from '@/api/types';
import type { ScreenConditions, ScreenerRunResponse, ScreenerSyncStatus } from '@/api/types';
export const useScreenerStore = defineStore('screener', () => {
const loading = ref(false);
@@ -12,12 +12,12 @@ export const useScreenerStore = defineStore('screener', () => {
const syncStatus = ref<ScreenerSyncStatus | null>(null);
let pollTimer: ReturnType<typeof setInterval> | null = null;
async function run(text: string) {
async function run(text: string, conditions?: ScreenConditions | null) {
loading.value = true;
error.value = null;
result.value = null;
note.value = 'AI 解析条件中…';
stage.value = 'parsing';
note.value = conditions ? '全市场筛选中…' : 'AI 解析条件中…';
stage.value = conditions ? 'screening' : 'parsing';
try {
// 条件解析与全市场筛选在后端一气呵成;切到筛选阶段给个过渡提示
setTimeout(() => {
@@ -26,7 +26,7 @@ export const useScreenerStore = defineStore('screener', () => {
note.value = '全市场筛选中…';
}
}, 1200);
result.value = await runScreener({ text });
result.value = await runScreener({ text, conditions: conditions ?? undefined });
} catch (e) {
error.value = e instanceof Error ? e.message : '选股失败';
} finally {

View File

@@ -1,51 +1,336 @@
<script setup lang="ts">
import BacktestForm from '@/components/BacktestForm.vue';
import KLineChart from '@/components/KLineChart.vue';
import EquityChart from '@/components/EquityChart.vue';
import MetricsPanel from '@/components/MetricsPanel.vue';
import { useBacktestStore } from '@/stores/backtest';
import type { BacktestRequest } from '@/api/types';
import { computed, ref } from 'vue';
import { postEventBacktest } from '@/api/client';
import type { EventBacktestResponse, EventBacktestSpec } from '@/api/types';
import ConditionChips from '@/components/ConditionChips.vue';
const store = useBacktestStore();
function onRun(req: BacktestRequest) {
store.run(req);
const EXAMPLES = [
'在连续三天 J 小于 10 的时候第二天开盘购买,之后未来三天的涨幅有多少',
'RSI 低于 30 的第二天开盘买入,持有 5 天收盘卖出',
'收盘价跌破布林带下轨的次日开盘买入,持有 10 天',
];
const text = ref(EXAMPLES[0]);
const tsCode = ref('');
const startDate = ref('');
const endDate = ref('');
const loading = ref(false);
const note = ref('');
const error = ref('');
const result = ref<EventBacktestResponse | null>(null);
// 调参重跑的本地 spec首次由后端 LLM 解析返回,后续直接传给后端跳过解析)
const spec = ref<EventBacktestSpec | null>(null);
const holdingDays = ref(3);
const entryTiming = ref<'next_open' | 'next_close'>('next_open');
const exitTiming = ref<'close' | 'open'>('close');
const stats = computed(() => result.value?.stats ?? null);
const bestTrades = computed(() => {
const t = result.value?.trades ?? [];
return t.length > 100 ? t.slice(0, 100) : t;
});
const worstTrades = computed(() => {
const t = result.value?.trades ?? [];
return t.length > 100 ? t.slice(-100) : [];
});
const fmtPct = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(2)}%`;
const fmtDate = (s: string) => s.slice(0, 10);
async function run(specDirect?: EventBacktestSpec | null) {
if (loading.value) return;
const t = text.value.trim();
if (!specDirect && t.length < 2) {
error.value = '请先输入回测需求描述';
return;
}
loading.value = true;
error.value = '';
note.value = specDirect
? '正在按调整后的参数重新回测(全市场扫描可能需要几分钟)…'
: '正在解析回测参数并扫描全市场(可能需要几分钟)…';
try {
const res = await postEventBacktest({
text: t,
spec: specDirect ?? undefined,
ts_code: tsCode.value.trim() || null,
start: startDate.value || null,
end: endDate.value || null,
});
result.value = res;
spec.value = res.spec;
holdingDays.value = res.spec.holding_days;
entryTiming.value = res.spec.entry_timing;
exitTiming.value = res.spec.exit_timing;
} catch (e) {
error.value = e instanceof Error ? e.message : String(e);
result.value = null;
} finally {
loading.value = false;
note.value = '';
}
}
async function rerunAdjusted() {
if (!spec.value) return;
await run({
...spec.value,
holding_days: holdingDays.value,
entry_timing: entryTiming.value,
exit_timing: exitTiming.value,
});
}
</script>
<template>
<BacktestForm :loading="store.loading" @run="onRun" />
<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">
<svg class="mt-0.5 h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 3.9L1.8 18a2 2 0 001.7 3h17a2 2 0 001.7-3L13.7 3.9a2 2 0 00-3.4 0z" /><path d="M12 9v4M12 17h.01" /></svg>
{{ store.error }}
<div>
<!-- 输入区 -->
<div class="rounded-xl border border-slate-200 bg-white p-4">
<div class="flex items-center justify-between">
<div class="text-[13px] font-medium text-slate-600">事件回测描述一个信号 次日买入 持有 N 的事件统计历史上全市场或单只股票的收益分布</div>
</div>
<div v-if="store.loading && store.note" class="py-16 text-center text-sm text-slate-400">
<svg class="mx-auto mb-3 h-6 w-6 animate-spin text-blue-500" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
{{ store.note }}
</div>
<template v-if="store.result">
<MetricsPanel :metrics="store.result.metrics" />
<div class="mt-4 rounded-xl border border-slate-200 bg-white p-3">
<KLineChart
:candles="store.result.candles"
:indicators="store.result.indicators"
:signals="store.result.signals"
:symbol="store.result.symbol"
:timeframe="store.result.timeframe"
:strategy="store.result.strategy"
<textarea
v-model="text"
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"
placeholder="例:在连续三天 J 小于 10 的时候第二天开盘购买,之后未来三天的涨幅有多少"
@keydown.ctrl.enter="run()"
/>
<div class="mt-2 flex flex-wrap items-center gap-2">
<span class="text-[12px] text-slate-400">试试</span>
<button
v-for="ex in EXAMPLES"
: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"
@click="text = ex"
>
{{ ex.length > 26 ? ex.slice(0, 26) + '…' : ex }}
</button>
</div>
<div class="mt-4 rounded-xl border border-slate-200 bg-white p-3">
<div class="px-2 py-1 text-[13px] text-slate-500">净值曲线</div>
<EquityChart :equity="store.result.equity" />
<div class="mt-3 flex flex-wrap items-end gap-3">
<label class="text-[12px] text-slate-500">
股票范围
<div class="mt-1 flex overflow-hidden rounded-lg border border-slate-200 text-[12px]">
<button
class="px-3 py-1.5"
:class="tsCode ? 'bg-white text-slate-600' : 'bg-blue-600 text-white'"
@click="tsCode = ''"
>全市场</button>
<button
class="px-3 py-1.5"
:class="tsCode ? 'bg-blue-600 text-white' : 'bg-white text-slate-600'"
@click="tsCode ||= '000001.SZ'"
>单只股票</button>
</div>
</label>
<label v-if="tsCode" class="text-[12px] text-slate-500">
股票代码
<input
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"
placeholder="000001.SZ"
/>
</label>
<label class="text-[12px] text-slate-500">
开始日期
<input
v-model="startDate"
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"
/>
</label>
<label class="text-[12px] text-slate-500">
结束日期
<input
v-model="endDate"
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"
/>
</label>
<span class="text-[11px] text-slate-400">留空默认最近一年</span>
<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"
:disabled="loading"
@click="run()"
>
{{ loading ? '回测中…' : '开始回测' }}
</button>
</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">
<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 }}
</div>
<!-- 加载中 -->
<div v-if="loading && note" class="py-16 text-center text-sm text-slate-400">
<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 }}
</div>
<!-- 结果 -->
<template v-else-if="result && stats">
<div class="mt-4 rounded-xl border border-slate-200 bg-white px-4 py-3">
<div class="mb-2 text-[12px] text-slate-400">
信号条件{{ result.universe === 'all' ? '全市场' : result.universe }}{{ fmtDate(result.start) }} ~ {{ fmtDate(result.end) }}
</div>
<ConditionChips :conditions="result.spec.entry" />
</div>
<!-- 统计卡片 -->
<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="text-[11px] text-slate-400">样本数</div>
<div class="mt-1 text-xl font-semibold text-slate-800">{{ stats.samples.toLocaleString() }}</div>
<div class="text-[11px] text-slate-400">{{ stats.stocks.toLocaleString() }} 只股票</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<div class="text-[11px] text-slate-400">平均涨幅</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="text-[11px] text-slate-400">中位数 {{ fmtPct(stats.median_pct) }}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<div class="text-[11px] text-slate-400">胜率</div>
<div class="mt-1 text-xl font-semibold text-slate-800">{{ stats.win_rate.toFixed(2) }}%</div>
<div class="text-[11px] text-slate-400">波动 σ {{ stats.std_pct.toFixed(2) }}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<div class="text-[11px] text-slate-400">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="text-[11px] text-slate-400">最差 {{ fmtPct(stats.min_pct) }}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<div class="text-[11px] text-slate-400">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="text-[11px] text-slate-400">最好 {{ fmtPct(stats.max_pct) }}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<div class="text-[11px] text-slate-400">收益口径</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>
</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="text-[12px] font-medium text-slate-600">调整参数重跑不动信号条件</div>
<label class="text-[12px] text-slate-500">
持有天数
<input
v-model.number="holdingDays"
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"
/>
</label>
<label class="text-[12px] text-slate-500">
买入时机
<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">
<option value="next_open">次日开盘</option>
<option value="next_close">次日收盘</option>
</select>
</label>
<label class="text-[12px] text-slate-500">
卖出价
<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">
<option value="close">收盘</option>
<option value="open">开盘</option>
</select>
</label>
<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"
:disabled="loading || !spec"
@click="rerunAdjusted()"
>按新参数重跑</button>
</div>
<!-- 分年统计 -->
<div v-if="stats.by_year.length" class="mt-4 rounded-xl border border-slate-200 bg-white p-4">
<div class="mb-2 text-[13px] font-medium text-slate-600">分年统计</div>
<table class="w-full text-[13px]">
<thead>
<tr class="border-b border-slate-100 text-left text-[12px] text-slate-400">
<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>
</tr>
</thead>
<tbody>
<tr v-for="y in stats.by_year" :key="y.year" class="border-b border-slate-50">
<td class="py-1.5">{{ y.year }}</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" :class="y.median_pct >= 0 ? 'text-red-600' : 'text-green-600'">{{ fmtPct(y.median_pct) }}</td>
<td class="py-1.5">{{ y.win_rate.toFixed(2) }}%</td>
</tr>
</tbody>
</table>
</div>
<!-- 样本明细 -->
<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="mb-2 text-[13px] font-medium text-slate-600">表现最好的样本 {{ bestTrades.length }}</div>
<div class="max-h-96 overflow-y-auto">
<table class="w-full text-[12px]">
<thead class="sticky top-0 bg-white">
<tr class="border-b border-slate-100 text-left text-[11px] text-slate-400">
<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>
</tr>
</thead>
<tbody>
<tr v-for="(t, i) in bestTrades" :key="i" class="border-b border-slate-50">
<td class="py-1.5">{{ t.ts_code }} <span class="text-slate-400">{{ t.name }}</span></td>
<td class="py-1.5 text-slate-500">{{ fmtDate(t.entry_date) }}</td>
<td class="py-1.5">{{ t.entry_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>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="worstTrades.length" class="rounded-xl border border-slate-200 bg-white p-4">
<div class="mb-2 text-[13px] font-medium text-slate-600">表现最差的样本 {{ worstTrades.length }}</div>
<div class="max-h-96 overflow-y-auto">
<table class="w-full text-[12px]">
<thead class="sticky top-0 bg-white">
<tr class="border-b border-slate-100 text-left text-[11px] text-slate-400">
<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>
</tr>
</thead>
<tbody>
<tr v-for="(t, i) in worstTrades" :key="i" class="border-b border-slate-50">
<td class="py-1.5">{{ t.ts_code }} <span class="text-slate-400">{{ t.name }}</span></td>
<td class="py-1.5 text-slate-500">{{ fmtDate(t.entry_date) }}</td>
<td class="py-1.5">{{ t.entry_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>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="mt-2 text-[11px] text-slate-400">
{{ result.total.toLocaleString() }} 个样本收益率已按复权因子校正消除除权除息失真明细仅展示最好/最差各 100
</div>
</template>
<div v-else-if="!store.loading" class="py-16 text-center text-sm text-slate-400">
选好周期与参数开始回测先用 DEMO 合成数据跑通
<div v-else-if="!error && !loading" class="py-16 text-center text-sm text-slate-400">
用一句话描述你的想法例如连续三天 J 小于 10 时次日开盘买入未来三天涨多少
</div>
</div>
</template>

View File

@@ -1,20 +1,27 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router';
// 首页:大功能入口(智能选股 / 策略回测)
// 首页:大功能入口(看股 / 选股 / 回测)
const features = [
{
to: '/stocks',
icon: 'M4 6h16M4 12h16M4 18h10',
accent: 'bg-amber-50 text-amber-600',
title: '看股',
desc: '浏览全市场 5,400+ 只股票的信息与历史 K 线数据。',
},
{
to: '/screener',
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',
title: '智能选股',
title: '选股',
desc: '用自然语言描述选股条件,快速完成全市场筛选。',
},
{
to: '/backtest',
icon: 'M3 17l6-6 4 4 8-8M21 7v6h-6',
accent: 'bg-emerald-50 text-emerald-600',
title: '策略回测',
title: '回测',
desc: '选择标的与策略参数,查看历史表现和关键绩效指标。',
},
];
@@ -22,21 +29,23 @@ const features = [
<template>
<div class="w-full max-w-5xl">
<div class="grid gap-6 sm:grid-cols-2">
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<RouterLink
v-for="f in features"
:key="f.to"
:to="f.to"
class="group flex min-h-72 flex-col rounded-lg border border-slate-200 bg-white p-8 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:min-h-80 sm:p-10"
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"
>
<span :class="['flex h-14 w-14 items-center justify-center rounded-lg', f.accent]">
<div class="flex items-center gap-4">
<span :class="['flex h-14 w-14 shrink-0 items-center justify-center rounded-lg', f.accent]">
<svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path :d="f.icon" />
</svg>
</span>
<div class="mt-7 text-2xl font-semibold">{{ f.title }}</div>
<p class="mt-3 max-w-sm text-sm leading-6 text-slate-500">{{ f.desc }}</p>
<div class="mt-auto flex items-center gap-1.5 pt-8 text-sm font-medium text-blue-600">
<div class="text-2xl font-semibold">{{ f.title }}</div>
</div>
<p class="mt-4 max-w-sm text-sm leading-6 text-slate-500">{{ f.desc }}</p>
<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>
</div>

View File

@@ -9,6 +9,7 @@ import StockDetailOverlay from '@/components/StockDetailOverlay.vue';
const store = useScreenerStore();
const previewCode = ref<string | null>(null);
const formRef = ref<InstanceType<typeof ScreenerForm> | null>(null);
onMounted(() => {
// 仅查状态;同步由用户点击「同步市场数据」显式触发(避免反复触发接口限频)
@@ -20,7 +21,7 @@ onBeforeUnmount(() => store.stopPolling());
<template>
<div>
<ScreenerForm :loading="store.loading" @run="store.run" />
<ScreenerForm ref="formRef" :loading="store.loading" @run="store.run" @ran="formRef?.refreshHistory()" />
<SyncStatusBar :status="store.syncStatus" @sync="store.startSync(90)" />