功能更新
This commit is contained in:
@@ -12,7 +12,7 @@ CORS_ORIGINS=http://localhost:5173
|
|||||||
# 生产建议 false,关闭 /docs 与 /openapi.json。
|
# 生产建议 false,关闭 /docs 与 /openapi.json。
|
||||||
EXPOSE_API_DOCS=true
|
EXPOSE_API_DOCS=true
|
||||||
|
|
||||||
# ---- 真实数据源(Tushare Pro;免费版即可。留空则仅 DEMO 合成数据可用)----
|
# ---- 真实数据源(Tushare Pro;免费版即可)----
|
||||||
TUSHARE_TOKEN=你的token
|
TUSHARE_TOKEN=你的token
|
||||||
DATA_ADJUST=qfq # 复权:qfq 前复权 / hfq 后复权 / 留空不复权
|
DATA_ADJUST=qfq # 复权:qfq 前复权 / hfq 后复权 / 留空不复权
|
||||||
DATA_DEFAULT_START=20200101
|
DATA_DEFAULT_START=20200101
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
GET /api/health 健康检查
|
GET /api/health 健康检查
|
||||||
GET /api/candles/{sym} 取 K 线(支持 1d/1w/1M/1y 周期,日线为基底聚合)
|
GET /api/candles/{sym} 取 K 线(支持 1d/1w/1M/1y 周期,日线为基底聚合)
|
||||||
|
GET /api/stocks 全市场股票列表(基本信息 + 最新行情 + 缓存条数)
|
||||||
POST /api/backtest 跑回测,返回 K线+指标+买卖点+净值+绩效
|
POST /api/backtest 跑回测,返回 K线+指标+买卖点+净值+绩效
|
||||||
POST /api/screener/run 智能选股:自然语言 -> 条件 -> 全市场筛选
|
POST /api/screener/run 智能选股:自然语言 -> 条件 -> 全市场筛选
|
||||||
POST /api/screener/sync 启动全市场数据同步(后台任务)
|
POST /api/screener/sync 启动全市场数据同步(后台任务)
|
||||||
@@ -9,45 +10,66 @@
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import bisect
|
||||||
import json
|
import json
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from .backtest.engine import BacktestConfig, run_backtest
|
from .backtest.engine import BacktestConfig, run_backtest
|
||||||
from .auth import require_user
|
from .auth import require_user
|
||||||
|
from .backtest.events import EventEngineError, run_event_backtest
|
||||||
from .backtest.strategies import build_strategy
|
from .backtest.strategies import build_strategy
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .data import fetcher, repository
|
from .data import fetcher, repository
|
||||||
from .data.aggregation import bars_per_year, resample_bars
|
from .data.aggregation import bars_per_year, resample_bars
|
||||||
from .data.symbols import plain_code
|
from .data.symbols import plain_code
|
||||||
from .data.synthetic import seed_if_empty
|
|
||||||
from .db import get_session
|
from .db import get_session
|
||||||
from .domain import Bar
|
from .domain import Bar
|
||||||
from . import indicators as ind
|
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 (
|
from .schemas import (
|
||||||
BacktestRequest,
|
BacktestRequest,
|
||||||
BacktestResponse,
|
BacktestResponse,
|
||||||
CandleOut,
|
CandleOut,
|
||||||
EquityPoint,
|
EquityPoint,
|
||||||
|
EventBacktestRequest,
|
||||||
|
EventBacktestResponse,
|
||||||
IndicatorOut,
|
IndicatorOut,
|
||||||
MetricsOut,
|
MetricsOut,
|
||||||
|
PreferencesOut,
|
||||||
|
PreferencesUpdate,
|
||||||
PreviewInfoOut,
|
PreviewInfoOut,
|
||||||
PreviewResponse,
|
PreviewResponse,
|
||||||
|
ScreenerQueryListResponse,
|
||||||
|
ScreenerQueryOut,
|
||||||
ScreenerRunRequest,
|
ScreenerRunRequest,
|
||||||
ScreenerRunResponse,
|
ScreenerRunResponse,
|
||||||
ScreenerSyncRequest,
|
ScreenerSyncRequest,
|
||||||
ScreenerSyncStatus,
|
ScreenerSyncStatus,
|
||||||
SignalOut,
|
SignalOut,
|
||||||
|
StockListItemOut,
|
||||||
|
StockListResponse,
|
||||||
|
StockFacetsResponse,
|
||||||
|
FacetItemOut,
|
||||||
SyncRequest,
|
SyncRequest,
|
||||||
SyncResponse,
|
SyncResponse,
|
||||||
|
WatchlistOp,
|
||||||
)
|
)
|
||||||
from .screener import engine, market_sync
|
from .screener import engine, market_sync
|
||||||
from .screener.engine import DataNotReadyError
|
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)])
|
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]
|
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_mode(bfq/qfq/hfq)。
|
||||||
|
|
||||||
|
相对不复权的乘数:bfq=1,qfq=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])
|
@router.get("/candles/{symbol}", response_model=list[CandleOut])
|
||||||
async def get_candles(
|
async def get_candles(
|
||||||
symbol: str,
|
symbol: str,
|
||||||
@@ -74,7 +131,6 @@ async def get_candles(
|
|||||||
limit: int = 5000,
|
limit: int = 5000,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> list[CandleOut]:
|
) -> list[CandleOut]:
|
||||||
await seed_if_empty(session, symbol="DEMO")
|
|
||||||
# 始终以日线为基底,再聚合到目标周期
|
# 始终以日线为基底,再聚合到目标周期
|
||||||
rows = await repository.get_candles(session, symbol, "1d", limit=limit)
|
rows = await repository.get_candles(session, symbol, "1d", limit=limit)
|
||||||
bars = resample_bars(_rows_to_bars(rows), timeframe)
|
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))
|
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)
|
@router.post("/backtest", response_model=BacktestResponse)
|
||||||
async def backtest(
|
async def backtest(
|
||||||
req: BacktestRequest,
|
req: BacktestRequest,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> BacktestResponse:
|
) -> BacktestResponse:
|
||||||
await seed_if_empty(session, symbol="DEMO")
|
# 真实数据:本地无缓存则先拉取
|
||||||
|
if not await fetcher.is_cached(session, req.symbol):
|
||||||
# 非演示标的:首次自动拉取真实数据并缓存
|
|
||||||
if req.symbol != "DEMO" and not await fetcher.is_cached(session, req.symbol):
|
|
||||||
try:
|
try:
|
||||||
await fetcher.sync_symbol(session, req.symbol, source="auto")
|
await fetcher.sync_symbol(session, req.symbol, source="auto")
|
||||||
except Exception as e: # noqa: BLE001
|
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)
|
@router.post("/screener/run", response_model=ScreenerRunResponse)
|
||||||
async def screener_run(
|
async def screener_run(
|
||||||
req: ScreenerRunRequest, session: AsyncSession = Depends(get_session)
|
req: ScreenerRunRequest,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
user=Depends(require_user),
|
||||||
) -> ScreenerRunResponse:
|
) -> ScreenerRunResponse:
|
||||||
"""自然语言 -> LLM 解析条件 -> 全市场筛选。也可直传 conditions 跳过 LLM(微调再跑)。"""
|
"""自然语言 -> LLM 解析条件 -> 全市场筛选。也可直传 conditions 跳过 LLM(微调再跑)。
|
||||||
|
成功的提问(含解析出的条件与命中数)记录到 screener_queries,供历史一键重跑。"""
|
||||||
try:
|
try:
|
||||||
conds = req.conditions or await parse_conditions(req.text)
|
conds = req.conditions or await parse_conditions(req.text)
|
||||||
if not conds.indicator and not conds.snapshot:
|
if not conds.indicator and not conds.snapshot:
|
||||||
raise HTTPException(status_code=400, detail="AI 未从描述中解析出任何筛选条件,请换种说法")
|
raise HTTPException(status_code=400, detail="AI 未从描述中解析出任何筛选条件,请换种说法")
|
||||||
result = await engine.run_screen(session, conds, settings.screener_default_limit)
|
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)
|
return ScreenerRunResponse(**result)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@@ -202,6 +411,148 @@ async def screener_run(
|
|||||||
raise HTTPException(status_code=code, detail=str(e))
|
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)
|
@router.post("/screener/sync", response_model=ScreenerSyncStatus)
|
||||||
async def screener_sync_start(
|
async def screener_sync_start(
|
||||||
req: ScreenerSyncRequest, session: AsyncSession = Depends(get_session)
|
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)
|
@router.get("/screener/preview/{ts_code}", response_model=PreviewResponse)
|
||||||
async def screener_preview(
|
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:
|
) -> PreviewResponse:
|
||||||
"""个股详情预览:日线(qfq 全量缓存,未缓存/过期自动拉取,失败退 market_daily 近段)
|
"""个股详情预览:日线(candles 不复权底座 + adj_factor 本地换算 bfq/qfq/hfq,
|
||||||
+ 全套指标(indicators.py 单一事实源)+ 最新截面信息卡。"""
|
未缓存自动拉取,失败退 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)
|
symbol = plain_code(ts_code)
|
||||||
|
|
||||||
# 先取 market_daily 最新行:既做缓存过期判断,也做信息卡数据源
|
# 先取 market_daily 最新行:既做缓存过期判断,也做信息卡数据源
|
||||||
@@ -237,16 +600,20 @@ async def screener_preview(
|
|||||||
)
|
)
|
||||||
).scalars().first()
|
).scalars().first()
|
||||||
|
|
||||||
# --- 日线:candles(qfq 全量) 优先;未缓存拉取,缓存落后于全市场最新交易日则强制刷新(每日至多一次) ---
|
# --- 日线:candles(不复权底座) 优先;未缓存拉取,缓存落后于全市场最新交易日则强制刷新(每日至多一次) ---
|
||||||
|
# fetcher 增量拉取写入的是 qfq(settings.data_adjust),此时底座模式记为 qfq。
|
||||||
rows = await repository.get_candles(session, symbol, "1d", limit=100000)
|
rows = await repository.get_candles(session, symbol, "1d", limit=100000)
|
||||||
source = "qfq"
|
source = "bfq"
|
||||||
|
mode = "bfq"
|
||||||
try:
|
try:
|
||||||
if not rows:
|
if not rows:
|
||||||
await fetcher.sync_symbol(session, symbol, source="auto")
|
await fetcher.sync_symbol(session, symbol, source="auto")
|
||||||
rows = await repository.get_candles(session, symbol, "1d", limit=100000)
|
rows = await repository.get_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():
|
elif md is not None and rows and rows[-1].ts.date() < md.trade_date.date():
|
||||||
await fetcher.sync_symbol(session, symbol, source="auto", force=True)
|
await fetcher.sync_symbol(session, symbol, source="auto", force=True)
|
||||||
rows = await repository.get_candles(session, symbol, "1d", limit=100000)
|
rows = await repository.get_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)
|
except Exception: # noqa: BLE001 —— tushare/写库失败时回滚会话(否则毒化后兜底查询 500)
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
if not rows:
|
if not rows:
|
||||||
@@ -265,6 +632,22 @@ async def screener_preview(
|
|||||||
if not bars:
|
if not bars:
|
||||||
raise HTTPException(status_code=404, detail=f"无数据: {ts_code}(可先点「同步市场数据」)")
|
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]})
|
df = pd.DataFrame({"close": [b.close for b in bars], "high": [b.high for b in bars], "low": [b.low for b in bars]})
|
||||||
closes, highs, lows = df["close"], df["high"], df["low"]
|
closes, highs, lows = df["close"], df["high"], df["low"]
|
||||||
@@ -272,7 +655,7 @@ async def screener_preview(
|
|||||||
kdj = ind.kdj(highs, lows, closes)
|
kdj = ind.kdj(highs, lows, closes)
|
||||||
boll = ind.bollinger(closes)
|
boll = ind.bollinger(closes)
|
||||||
indicators: dict[str, dict[str, list[float | None]]] = {
|
indicators: 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": {
|
"macd": {
|
||||||
"dif": _series_to_jsonable(macd["macd"]),
|
"dif": _series_to_jsonable(macd["macd"]),
|
||||||
"dea": _series_to_jsonable(macd["signal"]),
|
"dea": _series_to_jsonable(macd["signal"]),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""数据编排:拉取(Tushare 主 -> AKShare 兜底)+ 本地缓存。
|
"""数据编排:拉取(Tushare 主 -> AKShare 兜底)+ 本地缓存。
|
||||||
|
|
||||||
真实行情落库到 candles 表(timeframe='1d'),回测统一从库读,与 DEMO 同路径。
|
真实行情落库到 candles 表(timeframe='1d'),回测统一从库读。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -17,7 +17,7 @@ def _parse(date_str: str) -> datetime:
|
|||||||
|
|
||||||
def fetch_daily(code: str, start: str = "20200101", end: str | None = None,
|
def fetch_daily(code: str, start: str = "20200101", end: str | None = None,
|
||||||
adjust: str = "qfq") -> list[Bar]:
|
adjust: str = "qfq") -> list[Bar]:
|
||||||
import tushare as ts # 延迟导入:未装/无 token 时 DEMO 仍可用
|
import tushare as ts # 延迟导入:未装或无 token 时该数据源不可用
|
||||||
|
|
||||||
if not settings.tushare_token:
|
if not settings.tushare_token:
|
||||||
raise RuntimeError("未配置 TUSHARE_TOKEN")
|
raise RuntimeError("未配置 TUSHARE_TOKEN")
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Stock Backtest",
|
title="Stock Backtest",
|
||||||
description="历史回测 + 回放式模拟平台(A 股为主,不做实盘)",
|
description="股票研究平台:全市场数据 + 智能选股 + 事件回测(A 股为主,不做实盘)",
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
docs_url="/docs" if settings.expose_api_docs else None,
|
docs_url="/docs" if settings.expose_api_docs else None,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Candle 表设计与 TimescaleDB hypertable 完全兼容:将来在目标 PG 库
|
|||||||
"""
|
"""
|
||||||
from datetime import datetime
|
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 sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from .db import Base
|
from .db import Base
|
||||||
@@ -124,6 +124,65 @@ class DailySnapshot(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AdjFactor(Base):
|
||||||
|
"""复权因子(adj_factor,Tushare 原始值;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):
|
class TradeCalendar(Base):
|
||||||
"""交易日历缓存(trade_cal 拉取一次宽范围后本地维护,低积分 token 限频 1 次/小时)。"""
|
"""交易日历缓存(trade_cal 拉取一次宽范围后本地维护,低积分 token 限频 1 次/小时)。"""
|
||||||
__tablename__ = "trade_calendar"
|
__tablename__ = "trade_calendar"
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class CandleOut(BaseModel):
|
|||||||
|
|
||||||
# ---------- Backtest ----------
|
# ---------- Backtest ----------
|
||||||
class BacktestRequest(BaseModel):
|
class BacktestRequest(BaseModel):
|
||||||
symbol: str = "DEMO"
|
symbol: str = "000001"
|
||||||
timeframe: str = "1d"
|
timeframe: str = "1d"
|
||||||
strategy: str = "macd_cross" # macd_cross | ma_cross | single_ma
|
strategy: str = "macd_cross" # macd_cross | ma_cross | single_ma
|
||||||
params: dict[str, float] = Field(default_factory=dict) # 各策略参数
|
params: dict[str, float] = Field(default_factory=dict) # 各策略参数
|
||||||
@@ -82,6 +82,68 @@ class SyncRequest(BaseModel):
|
|||||||
force: bool = False # True => 忽略缓存重新拉取
|
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):
|
class SyncResponse(BaseModel):
|
||||||
symbol: str
|
symbol: str
|
||||||
bars: int
|
bars: int
|
||||||
@@ -197,7 +259,7 @@ class PreviewInfoOut(BaseModel):
|
|||||||
class PreviewResponse(BaseModel):
|
class PreviewResponse(BaseModel):
|
||||||
ts_code: str
|
ts_code: str
|
||||||
symbol: str
|
symbol: str
|
||||||
source: str # qfq=回测缓存全量前复权 | market=近段未复权兜底
|
source: str # bfq|qfq|hfq=实际复权口径(本地 adj_factor 换算) | market=近段未复权兜底
|
||||||
info: PreviewInfoOut
|
info: PreviewInfoOut
|
||||||
candles: list[CandleOut]
|
candles: list[CandleOut]
|
||||||
indicators: dict[str, dict[str, list[float | None]]] = Field(default_factory=dict)
|
indicators: dict[str, dict[str, list[float | None]]] = Field(default_factory=dict)
|
||||||
@@ -220,3 +282,61 @@ class CurrentUserOut(BaseModel):
|
|||||||
class LoginResponse(BaseModel):
|
class LoginResponse(BaseModel):
|
||||||
user: CurrentUserOut
|
user: CurrentUserOut
|
||||||
expires_at: datetime
|
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]
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import re
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
from ..schemas import ScreenConditions
|
from ..schemas import EventBacktestSpec, ScreenConditions
|
||||||
|
|
||||||
SYSTEM_PROMPT = """你是 A 股选股条件解析器。把用户的自然语言解析成一个 JSON 对象,只输出 JSON,不要任何解释、注释或代码块围栏。完全无法理解时输出 {"error": "原因"}。
|
SYSTEM_PROMPT = """你是 A 股选股条件解析器。把用户的自然语言解析成一个 JSON 对象,只输出 JSON,不要任何解释、注释或代码块围栏。完全无法理解时输出 {"error": "原因"}。
|
||||||
|
|
||||||
@@ -149,3 +149,69 @@ async def parse_conditions(text: str) -> ScreenConditions:
|
|||||||
except Exception as e: # noqa: BLE001 —— JSON/校验失败,带错误重试
|
except Exception as e: # noqa: BLE001 —— JSON/校验失败,带错误重试
|
||||||
retry_error = str(e)[:300]
|
retry_error = str(e)[:300]
|
||||||
raise ScreenerError(f"AI 解析结果两次未通过校验,最后错误:{retry_error}")
|
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_j(KDJ 的 K/D/J 值)、rsi、macd_dif / macd_dea / macd_hist(MACD 的 DIF/DEA/柱)、ma(收盘价均线)、boll_upper / boll_mid / boll_lower(布林轨道)、close(收盘价)、pct_chg(日涨跌幅%)
|
||||||
|
- "params": 指标参数(可选),默认:KDJ {"n":9,"m1":3,"m2":3};RSI {"period":14};MACD {"fast":12,"slow":26,"signal":9};MA {"period":20};BOLL {"period":20,"std":2}
|
||||||
|
- "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 Key(platform.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}")
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from sqlalchemy import delete, func, insert, select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
from ..models import DailySnapshot, MarketDaily, StockBasic, TradeCalendar
|
from ..models import AdjFactor, DailySnapshot, MarketDaily, StockBasic, TradeCalendar
|
||||||
from .llm import ScreenerError
|
from .llm import ScreenerError
|
||||||
|
|
||||||
# 进程内单例任务状态(uvicorn --reload 单进程场景够用)
|
# 进程内单例任务状态(uvicorn --reload 单进程场景够用)
|
||||||
@@ -160,6 +160,18 @@ def _fetch_basic(pro, d: str) -> list[dict]:
|
|||||||
return rows
|
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]:
|
def _sync_stock_list_sync(pro) -> list[dict]:
|
||||||
"""拉取在市股票列表。"""
|
"""拉取在市股票列表。"""
|
||||||
time.sleep(settings.screener_sync_interval)
|
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)
|
await _replace_day(session, MarketDaily, daily_rows, d)
|
||||||
_sync_state["done_days"] += 1
|
_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 次调用)
|
# 3) 最新「有数据」交易日的快照(daily_basic,仅 1 次调用)
|
||||||
# 用 market_daily 实际最大交易日(今天的数据收盘后才生成,日历最新日会拉到空)
|
# 用 market_daily 实际最大交易日(今天的数据收盘后才生成,日历最新日会拉到空)
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ async def main() -> None:
|
|||||||
daily = await client.post(
|
daily = await client.post(
|
||||||
"/api/backtest",
|
"/api/backtest",
|
||||||
json={
|
json={
|
||||||
"symbol": "DEMO",
|
"symbol": "000001",
|
||||||
"strategy": "macd_cross",
|
"strategy": "macd_cross",
|
||||||
"params": {"fast": 12, "slow": 26, "signal": 9},
|
"params": {"fast": 12, "slow": 26, "signal": 9},
|
||||||
"initial_cash": 100000.0,
|
"initial_cash": 100000.0,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>量化选股与回测平台</title>
|
<title>选股训练营</title>
|
||||||
<style>
|
<style>
|
||||||
html, body { background-color: #f8fafc; margin: 0; }
|
html, body { background-color: #f8fafc; margin: 0; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, ref } from 'vue';
|
|||||||
import { RouterView } from 'vue-router';
|
import { RouterView } from 'vue-router';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import SettingsModal from '@/components/SettingsModal.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -10,6 +11,7 @@ const auth = useAuthStore();
|
|||||||
const isHome = computed(() => route.name === 'home');
|
const isHome = computed(() => route.name === 'home');
|
||||||
const isLogin = computed(() => route.name === 'login');
|
const isLogin = computed(() => route.name === 'login');
|
||||||
const loggingOut = ref(false);
|
const loggingOut = ref(false);
|
||||||
|
const showSettings = ref(false);
|
||||||
|
|
||||||
async function signOut() {
|
async function signOut() {
|
||||||
loggingOut.value = true;
|
loggingOut.value = true;
|
||||||
@@ -28,13 +30,42 @@ async function signOut() {
|
|||||||
</div>
|
</div>
|
||||||
<RouterView v-else-if="isLogin" />
|
<RouterView v-else-if="isLogin" />
|
||||||
<div v-else class="min-h-screen">
|
<div v-else class="min-h-screen">
|
||||||
<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>{{ 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">
|
<button type="button" class="font-medium text-slate-500 hover:text-slate-900 disabled:opacity-50" :disabled="loggingOut" @click="signOut">
|
||||||
{{ loggingOut ? '退出中' : '退出' }}
|
{{ loggingOut ? '退出中' : '退出' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 />
|
<RouterView />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AdjustMode,
|
||||||
BacktestRequest,
|
BacktestRequest,
|
||||||
BacktestResponse,
|
BacktestResponse,
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
|
EventBacktestRequest,
|
||||||
|
EventBacktestResponse,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
PreviewResponse,
|
PreviewResponse,
|
||||||
|
ScreenerQueryItem,
|
||||||
ScreenerRunRequest,
|
ScreenerRunRequest,
|
||||||
ScreenerRunResponse,
|
ScreenerRunResponse,
|
||||||
ScreenerSyncRequest,
|
ScreenerSyncRequest,
|
||||||
ScreenerSyncStatus,
|
ScreenerSyncStatus,
|
||||||
|
StockFacets,
|
||||||
|
StockListResponse,
|
||||||
SyncRequest,
|
SyncRequest,
|
||||||
SyncResponse,
|
SyncResponse,
|
||||||
|
Timeframe,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// dev 用 Vite 代理(/api -> :8000);生产构建设 VITE_API_BASE 指向后端地址。
|
// 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;
|
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> {
|
export async function runScreener(req: ScreenerRunRequest): Promise<ScreenerRunResponse> {
|
||||||
const res = await apiFetch('/api/screener/run', { method: 'POST', body: JSON.stringify(req) });
|
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);
|
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;
|
return (await res.json()) as ScreenerSyncStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getStockPreview(tsCode: string, limit = 260): Promise<PreviewResponse> {
|
export async function getStockPreview(
|
||||||
const res = await apiFetch(`/api/screener/preview/${encodeURIComponent(tsCode)}?limit=${limit}`);
|
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);
|
if (!res.ok) throw new ApiError(await readError(res, `获取个股详情失败 (HTTP ${res.status})`), res.status);
|
||||||
return (await res.json()) as PreviewResponse;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -191,8 +191,119 @@ export interface PreviewInfo {
|
|||||||
export interface PreviewResponse {
|
export interface PreviewResponse {
|
||||||
ts_code: string;
|
ts_code: string;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
source: string; // qfq | market
|
source: string; // bfq | qfq | hfq | market(实际复权口径 / 近段未复权兜底)
|
||||||
info: PreviewInfo;
|
info: PreviewInfo;
|
||||||
candles: Candle[];
|
candles: Candle[];
|
||||||
indicators: Record<string, Record<string, (number | null)[]>>;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { dispose, init, registerIndicator, type Chart, type KLineData } from 'klinecharts';
|
import { dispose, init, registerIndicator, type Chart, type KLineData } from 'klinecharts';
|
||||||
|
import { useSettingsStore } from '@/stores/settings';
|
||||||
import type { Candle } from '@/api/types';
|
import type { Candle } from '@/api/types';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -9,41 +10,55 @@ const props = defineProps<{
|
|||||||
indicators: Record<string, Record<string, (number | null)[]>>;
|
indicators: Record<string, Record<string, (number | null)[]>>;
|
||||||
/** 副图指标及顺序('vol' 用内置;其余为后端序列) */
|
/** 副图指标及顺序('vol' 用内置;其余为后端序列) */
|
||||||
subPanes: string[];
|
subPanes: string[];
|
||||||
|
/** 主图 MA 周期(可配置,随用户偏好持久化) */
|
||||||
|
maPeriods: number[];
|
||||||
|
/** 各副图高度 px(可配置,随用户偏好持久化) */
|
||||||
|
subHeights: Record<string, number>;
|
||||||
/** 主图是否叠加布林带 */
|
/** 主图是否叠加布林带 */
|
||||||
showBoll: boolean;
|
showBoll: boolean;
|
||||||
|
/** K线周期标签(仅用于 MA 指标名缓存 key) */
|
||||||
|
timeframe: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// A股语义色(浅色)
|
// A股语义色(浅色);UP/DOWN 跟随设置中的涨跌配色
|
||||||
const UP = '#dc2626';
|
const settings = useSettingsStore();
|
||||||
const DOWN = '#16a34a';
|
let UP = '#dc2626';
|
||||||
const C1 = '#2563eb'; // 蓝
|
let DOWN = '#16a34a';
|
||||||
const C2 = '#f59e0b'; // 橙
|
const MA_COLORS = ['#2563eb', '#f59e0b', '#a855f7', '#10b981', '#ec4899', '#0ea5e9', '#84cc16', '#f97316'];
|
||||||
const C3 = '#a855f7'; // 紫
|
|
||||||
const C4 = '#10b981'; // 绿青
|
|
||||||
|
|
||||||
// ---------- 后端序列注入(单一事实源,按索引对齐) ----------
|
// ---------- 后端序列注入(单一事实源,按索引对齐) ----------
|
||||||
let PV: Record<string, (number | null)[]> = {};
|
let PV: Record<string, (number | null)[]> = {};
|
||||||
const g = (k: string) => (i: number) => PV[k]?.[i] ?? undefined;
|
const g = (k: string) => (i: number) => PV[k]?.[i] ?? undefined;
|
||||||
|
|
||||||
registerIndicator({
|
// 动态 MA:按周期组合注册一次(figures 的 key 必须静态,故按签名建缓存)
|
||||||
name: 'pv-ma',
|
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-${sig}`,
|
||||||
shortName: 'MA',
|
shortName: 'MA',
|
||||||
figures: [
|
figures: periods.map((p, i) => ({
|
||||||
{ key: 'ma5', title: 'MA5', type: 'line', styles: () => ({ color: C1 }) },
|
key: `ma${p}`, title: `MA${p}`, type: 'line',
|
||||||
{ key: 'ma10', title: 'MA10', type: 'line', styles: () => ({ color: C2 }) },
|
styles: () => ({ color: MA_COLORS[i % MA_COLORS.length] }),
|
||||||
{ key: 'ma20', title: 'MA20', type: 'line', styles: () => ({ color: C3 }) },
|
})),
|
||||||
{ key: 'ma60', title: 'MA60', type: 'line', styles: () => ({ color: C4 }) },
|
calc: (d: KLineData[]) => d.map((_, i) => {
|
||||||
],
|
const row: Record<string, number | undefined> = {};
|
||||||
calc: (d: KLineData[]) => d.map((_, i) => ({ ma5: g('ma5')(i), ma10: g('ma10')(i), ma20: g('ma20')(i), ma60: g('ma60')(i) })),
|
for (const p of periods) row[`ma${p}`] = g(`ma${p}`)(i);
|
||||||
});
|
return row;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
_maReg.add(sig);
|
||||||
|
return `pv-ma-${sig}`;
|
||||||
|
}
|
||||||
|
|
||||||
registerIndicator({
|
registerIndicator({
|
||||||
name: 'pv-boll',
|
name: 'pv-boll',
|
||||||
shortName: 'BOLL',
|
shortName: 'BOLL',
|
||||||
figures: [
|
figures: [
|
||||||
{ key: 'upper', title: 'UP', type: 'line', styles: () => ({ color: C3 }) },
|
{ key: 'upper', title: 'UP', type: 'line', styles: () => ({ color: '#a855f7' }) },
|
||||||
{ key: 'mid', title: 'MB', type: 'line', styles: () => ({ color: C2 }) },
|
{ key: 'mid', title: 'MB', type: 'line', styles: () => ({ color: '#f59e0b' }) },
|
||||||
{ key: 'lower', title: 'DN', type: 'line', styles: () => ({ color: C3 }) },
|
{ 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) })),
|
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',
|
name: 'pv-macd',
|
||||||
shortName: 'MACD',
|
shortName: 'MACD',
|
||||||
figures: [
|
figures: [
|
||||||
{ key: 'dif', title: 'DIF', type: 'line', styles: () => ({ color: C1 }) },
|
{ key: 'dif', title: 'DIF', type: 'line', styles: () => ({ color: '#2563eb' }) },
|
||||||
{ key: 'dea', title: 'DEA', type: 'line', styles: () => ({ color: C2 }) },
|
{ key: 'dea', title: 'DEA', type: 'line', styles: () => ({ color: '#f59e0b' }) },
|
||||||
{
|
{
|
||||||
key: 'hist', title: 'HIST', type: 'bar', baseValue: 0, // 零轴柱,缺省会从面板底部画起
|
key: 'hist', title: 'HIST', type: 'bar', baseValue: 0, // 零轴柱,缺省会从面板底部画起
|
||||||
styles: (p) => {
|
styles: (p) => {
|
||||||
@@ -69,9 +84,9 @@ registerIndicator({
|
|||||||
name: 'pv-kdj',
|
name: 'pv-kdj',
|
||||||
shortName: 'KDJ',
|
shortName: 'KDJ',
|
||||||
figures: [
|
figures: [
|
||||||
{ key: 'k', title: 'K', type: 'line', styles: () => ({ color: C1 }) },
|
{ key: 'k', title: 'K', type: 'line', styles: () => ({ color: '#2563eb' }) },
|
||||||
{ key: 'd', title: 'D', type: 'line', styles: () => ({ color: C2 }) },
|
{ key: 'd', title: 'D', type: 'line', styles: () => ({ color: '#f59e0b' }) },
|
||||||
{ key: 'j', title: 'J', type: 'line', styles: () => ({ color: UP }) },
|
{ 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) })),
|
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',
|
name: 'pv-rsi',
|
||||||
shortName: 'RSI',
|
shortName: 'RSI',
|
||||||
figures: [
|
figures: [
|
||||||
{ key: 'rsi6', title: 'RSI6', type: 'line', styles: () => ({ color: C1 }) },
|
{ key: 'rsi6', title: 'RSI6', type: 'line', styles: () => ({ color: '#2563eb' }) },
|
||||||
{ key: 'rsi12', title: 'RSI12', type: 'line', styles: () => ({ color: C2 }) },
|
{ key: 'rsi12', title: 'RSI12', type: 'line', styles: () => ({ color: '#f59e0b' }) },
|
||||||
{ key: 'rsi24', title: 'RSI24', type: 'line', styles: () => ({ color: C3 }) },
|
{ 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) })),
|
calc: (d: KLineData[]) => d.map((_, i) => ({ rsi6: g('rsi6')(i), rsi12: g('rsi12')(i), rsi24: g('rsi24')(i) })),
|
||||||
});
|
});
|
||||||
|
|
||||||
const container = ref<HTMLDivElement | null>(null);
|
const container = ref<HTMLDivElement | null>(null);
|
||||||
let chart: Chart | null = null;
|
let chart: Chart | null = null;
|
||||||
|
let allData: KLineData[] = [];
|
||||||
|
let served = 0; // 已交给图表的 bar 数(从尾部计),backward 分页用
|
||||||
|
|
||||||
const LIGHT_STYLES = {
|
const INIT_BARS = 240; // 初始展示根数(约一年日线)
|
||||||
|
const PAGE_BARS = 500; // 每次向左滚动追加的历史根数
|
||||||
|
|
||||||
|
function lightStyles() {
|
||||||
|
return {
|
||||||
grid: { horizontal: { color: '#eef2f7' }, vertical: { color: '#eef2f7' } },
|
grid: { horizontal: { color: '#eef2f7' }, vertical: { color: '#eef2f7' } },
|
||||||
candle: {
|
candle: {
|
||||||
bar: {
|
bar: {
|
||||||
@@ -110,33 +131,109 @@ const LIGHT_STYLES = {
|
|||||||
vertical: { text: { backgroundColor: '#1e293b' } },
|
vertical: { text: { backgroundColor: '#1e293b' } },
|
||||||
},
|
},
|
||||||
separator: { color: '#e2e8f0' },
|
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() {
|
function build() {
|
||||||
if (!container.value || props.candles.length === 0) return;
|
if (!container.value || props.candles.length === 0) return;
|
||||||
|
UP = settings.upHex;
|
||||||
|
DOWN = settings.downHex;
|
||||||
PV = {};
|
PV = {};
|
||||||
for (const [group, series] of Object.entries(props.indicators)) {
|
for (const [group, series] of Object.entries(props.indicators)) {
|
||||||
for (const [key, arr] of Object.entries(series)) PV[key] = arr;
|
for (const [key, arr] of Object.entries(series)) PV[key] = arr;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ch = init(container.value, { styles: LIGHT_STYLES });
|
const ch = init(container.value, { styles: lightStyles() });
|
||||||
if (!ch) return;
|
if (!ch) return;
|
||||||
chart = ch;
|
chart = ch;
|
||||||
|
allData = props.candles.map((k) => ({
|
||||||
const data: KLineData[] = props.candles.map((k) => ({
|
|
||||||
timestamp: new Date(k.ts).getTime(),
|
timestamp: new Date(k.ts).getTime(),
|
||||||
open: k.open, high: k.high, low: k.low, close: k.close, volume: k.volume,
|
open: k.open, high: k.high, low: k.low, close: k.close, volume: k.volume,
|
||||||
}));
|
}));
|
||||||
|
served = 0;
|
||||||
ch.setDataLoader({
|
ch.setDataLoader({
|
||||||
getBars: ({ type, callback }) => {
|
getBars: ({ type, callback }) => {
|
||||||
if (type === 'update') {
|
if (type === 'update') {
|
||||||
const last = data[data.length - 1];
|
const last = allData[allData.length - 1];
|
||||||
callback(last ? [last] : [], { backward: false, forward: false });
|
callback(last ? [last] : [], { backward: served < allData.length, forward: false });
|
||||||
} else if (type === 'init') {
|
} 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 {
|
} else {
|
||||||
callback([], { backward: false, forward: false });
|
callback([], { backward: false, forward: false });
|
||||||
}
|
}
|
||||||
@@ -146,21 +243,22 @@ function build() {
|
|||||||
ch.setSymbol({ ticker: props.ticker });
|
ch.setSymbol({ ticker: props.ticker });
|
||||||
ch.setPeriod({ type: 'day', span: 1 });
|
ch.setPeriod({ type: 'day', span: 1 });
|
||||||
|
|
||||||
// 主图:MA 恒开,BOLL 可选
|
// 主图:MA(周期可配置)恒开,BOLL 可选
|
||||||
ch.createIndicator({ name: 'pv-ma', paneId: 'candle_pane' });
|
ch.createIndicator({ name: ensureMaIndicator(props.maPeriods), paneId: 'candle_pane' });
|
||||||
if (props.showBoll) ch.createIndicator({ name: 'pv-boll', 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;
|
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) {
|
for (const key of props.subPanes) {
|
||||||
if (key === 'vol') ch.createIndicator('VOL');
|
const name = key === 'vol' ? 'VOL' : `pv-${key}`;
|
||||||
else ch.createIndicator(`pv-${key}`);
|
ch.createIndicator(name);
|
||||||
const paneId = ch.getIndicators().find((i) => i.name === (key === 'vol' ? 'VOL' : `pv-${key}`))?.paneId;
|
const paneId = ch.getIndicators().find((i) => i.name === name)?.paneId;
|
||||||
if (paneId) ch.setPaneOptions({ id: paneId, height: SUB_HEIGHT[key] ?? 90 });
|
if (paneId) ch.setPaneOptions({ id: paneId, height: subH(key) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bindCrosshair(ch);
|
||||||
ch.setOffsetRightDistance(28);
|
ch.setOffsetRightDistance(28);
|
||||||
ch.scrollToRealTime();
|
ch.scrollToRealTime();
|
||||||
}
|
}
|
||||||
@@ -168,13 +266,71 @@ function build() {
|
|||||||
function teardown() {
|
function teardown() {
|
||||||
if (container.value) dispose(container.value);
|
if (container.value) dispose(container.value);
|
||||||
chart = null;
|
chart = null;
|
||||||
|
hover.value = null;
|
||||||
|
activeTool.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(build);
|
onMounted(build);
|
||||||
onBeforeUnmount(teardown);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<div class="relative h-full w-full">
|
||||||
<div ref="container" class="h-full w-full"></div>
|
<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>
|
</template>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
import { deleteScreenerQuery, getScreenerQueries } from '@/api/client';
|
||||||
|
import type { ScreenConditions, ScreenerQueryItem } from '@/api/types';
|
||||||
|
|
||||||
defineProps<{ loading: boolean }>();
|
const props = defineProps<{ loading: boolean }>();
|
||||||
const emit = defineEmits<{ (e: 'run', text: string): void }>();
|
const emit = defineEmits<{
|
||||||
|
(e: 'run', text: string, conditions?: ScreenConditions | null): void;
|
||||||
|
(e: 'ran'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
const text = ref('');
|
const text = ref('');
|
||||||
|
|
||||||
@@ -10,22 +15,105 @@ const text = ref('');
|
|||||||
const examples = [
|
const examples = [
|
||||||
'帮我找出这两天 KDJ 中的 J 小于 10,市值大于 100 亿,小于 200 亿的公司',
|
'帮我找出这两天 KDJ 中的 J 小于 10,市值大于 100 亿,小于 200 亿的公司',
|
||||||
'RSI 低于 30,市盈率 TTM 小于 20 的公司',
|
'RSI 低于 30,市盈率 TTM 小于 20 的公司',
|
||||||
'近 5 天曾经 MACD 金叉(DIF 上穿 DEA),换手率大于 5%,流通市值小于 100 亿',
|
|
||||||
'股价在布林带下轨之下,流通市值小于 50 亿',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function run() {
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="rounded-xl border border-slate-200 bg-white p-5">
|
<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
|
<textarea
|
||||||
v-model="text"
|
v-model="text"
|
||||||
rows="2"
|
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 亿的公司"
|
placeholder="例如:这两天 KDJ 的 J 小于 10,市值 100~200 亿的公司"
|
||||||
@keyup.ctrl.enter="run"
|
@keyup.ctrl.enter="run"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 sortedItems = computed(() => {
|
||||||
const key = sortKey.value;
|
const key = sortKey.value;
|
||||||
const dir = sortDir.value === 'asc' ? 1 : -1;
|
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 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 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 font-medium tabular-nums" :class="toneClass(it.pct_chg)">{{ 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 tabular-nums" :class="toneClass(it.pct_chg)">
|
||||||
{{ it.pct_chg == null ? '—' : (it.pct_chg > 0 ? '+' : '') + it.pct_chg.toFixed(2) }}
|
{{ it.pct_chg == null ? '—' : (it.pct_chg > 0 ? '+' : '') + it.pct_chg.toFixed(2) }}
|
||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap px-3 py-1.5 text-slate-700">{{ fmt2(it.total_mv) }}</td>
|
<td class="whitespace-nowrap px-3 py-1.5 text-slate-700">{{ fmt2(it.total_mv) }}</td>
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { getStockPreview } from '@/api/client';
|
import { addWatchlist, getStockPreview, getWatchlist as getWatchlistApi, removeWatchlist } from '@/api/client';
|
||||||
import type { PreviewResponse, ScreenerItemOut } from '@/api/types';
|
import type { ChartLayoutPrefs, PreviewResponse, ScreenerItemOut, Timeframe } from '@/api/types';
|
||||||
|
import { useSettingsStore, type PriceAdjust } from '@/stores/settings';
|
||||||
import DetailKLine from './DetailKLine.vue';
|
import DetailKLine from './DetailKLine.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
items: ScreenerItemOut[];
|
items: ScreenerItemOut[];
|
||||||
initial: string; // ts_code
|
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);
|
const active = ref(props.initial);
|
||||||
@@ -17,16 +19,123 @@ const loading = ref(false);
|
|||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const filter = ref('');
|
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 = [
|
const SUBS = [
|
||||||
{ key: 'vol', label: 'VOL' },
|
{ key: 'vol', label: 'VOL' },
|
||||||
{ key: 'macd', label: 'MACD' },
|
{ key: 'macd', label: 'MACD' },
|
||||||
{ key: 'kdj', label: 'KDJ' },
|
{ key: 'kdj', label: 'KDJ' },
|
||||||
{ key: 'rsi', label: 'RSI' },
|
{ 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);
|
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 filteredItems = computed(() => {
|
||||||
const q = filter.value.trim().toLowerCase();
|
const q = filter.value.trim().toLowerCase();
|
||||||
if (!q) return props.items;
|
if (!q) return props.items;
|
||||||
@@ -50,7 +159,7 @@ const header = computed(() => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- 数据加载 ----------
|
// ---------- 数据加载(拉全量历史,图表内按需分页展示) ----------
|
||||||
let fetchToken = 0;
|
let fetchToken = 0;
|
||||||
async function load(code: string) {
|
async function load(code: string) {
|
||||||
const token = ++fetchToken;
|
const token = ++fetchToken;
|
||||||
@@ -58,7 +167,12 @@ async function load(code: string) {
|
|||||||
error.value = null;
|
error.value = null;
|
||||||
data.value = null;
|
data.value = null;
|
||||||
try {
|
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;
|
if (token === fetchToken) data.value = res;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (token === fetchToken) error.value = e instanceof Error ? e.message : '加载失败';
|
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(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) {
|
function moveActive(delta: number) {
|
||||||
const list = filteredItems.value;
|
const list = filteredItems.value;
|
||||||
@@ -78,11 +202,10 @@ function moveActive(delta: number) {
|
|||||||
|
|
||||||
// ---------- 键盘 / 滚动锁 ----------
|
// ---------- 键盘 / 滚动锁 ----------
|
||||||
function onKeydown(e: KeyboardEvent) {
|
function onKeydown(e: KeyboardEvent) {
|
||||||
// 输入法组合态 / 焦点在输入框时不拦截(否则搜索框打字会切股、Esc 关浮层)
|
|
||||||
if (e.isComposing) return;
|
if (e.isComposing) return;
|
||||||
const t = e.target as HTMLElement | null;
|
const t = e.target as HTMLElement | null;
|
||||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
|
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
|
||||||
if (e.key === 'Escape') 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 === 'ArrowUp') { e.preventDefault(); moveActive(-1); }
|
||||||
else if (e.key === 'ArrowDown') { e.preventDefault(); moveActive(1); }
|
else if (e.key === 'ArrowDown') { e.preventDefault(); moveActive(1); }
|
||||||
}
|
}
|
||||||
@@ -95,29 +218,28 @@ onBeforeUnmount(() => {
|
|||||||
document.body.style.overflow = '';
|
document.body.style.overflow = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- 副图 chips:开关 + 拖拽排序 ----------
|
// ---------- 右侧信息栏增强:52周高低 / 年初至今(从日线序列算,无数据留空) ----------
|
||||||
let dragKey: string | null = null;
|
const stats = computed(() => {
|
||||||
function toggleSub(key: string) {
|
const bars = timeframe.value === '1d' ? data.value?.candles : null;
|
||||||
subPanes.value = subPanes.value.includes(key)
|
if (!bars || bars.length === 0) return { high52: null, low52: null, ytd: null };
|
||||||
? subPanes.value.filter((k) => k !== key)
|
const last = bars[bars.length - 1];
|
||||||
: [...subPanes.value, key];
|
const lastTs = new Date(last.ts);
|
||||||
}
|
const yearStart = new Date(lastTs.getFullYear(), 0, 1).getTime();
|
||||||
function onDragStart(e: DragEvent, key: string) {
|
let high = -Infinity, low = Infinity;
|
||||||
dragKey = key;
|
let ytdBase: number | null = null;
|
||||||
// Firefox/Safari 要求 dragstart 写入数据才会真正发起拖拽
|
const cutoff = lastTs.getTime() - 365 * 24 * 3600 * 1000;
|
||||||
e.dataTransfer?.setData('text/plain', key);
|
for (const b of bars) {
|
||||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
|
const t = new Date(b.ts).getTime();
|
||||||
}
|
if (t >= cutoff) { high = Math.max(high, b.high); low = Math.min(low, b.low); }
|
||||||
function onDrop(target: string) {
|
// 年初至今基准 = 上一年最后一根收盘
|
||||||
if (!dragKey || dragKey === target) return;
|
if (t < yearStart) ytdBase = b.close;
|
||||||
const arr = [...subPanes.value];
|
}
|
||||||
const from = arr.indexOf(dragKey);
|
return {
|
||||||
if (from >= 0) arr.splice(from, 1);
|
high52: high === -Infinity ? null : high,
|
||||||
const to = arr.indexOf(target);
|
low52: low === Infinity ? null : low,
|
||||||
arr.splice(to >= 0 ? to : arr.length, 0, dragKey);
|
ytd: ytdBase && ytdBase !== 0 ? ((last.close - ytdBase) / ytdBase) * 100 : null,
|
||||||
subPanes.value = arr;
|
};
|
||||||
dragKey = null;
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 格式化 ----------
|
// ---------- 格式化 ----------
|
||||||
const fmt = (v: number | null | undefined, d = 2) => (v == null ? '—' : v.toFixed(d));
|
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>
|
<template>
|
||||||
<div class="fixed inset-0 z-40 flex flex-col bg-slate-100">
|
<div class="fixed inset-0 z-40 flex flex-col bg-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">
|
<div class="flex items-baseline gap-2">
|
||||||
<span class="text-base font-semibold text-slate-900">{{ header.name }}</span>
|
<span class="text-base font-semibold text-slate-900">{{ header.name }}</span>
|
||||||
<span class="text-xs text-slate-400">{{ active }}</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) }}%
|
{{ header.pct > 0 ? '+' : '' }}{{ fmt(header.pct) }}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 v-if="data?.source === 'market'" class="rounded bg-amber-50 px-2 py-0.5 text-[11px] text-amber-600">
|
||||||
近段未复权数据
|
近段未复权数据
|
||||||
</span>
|
</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')">
|
<button type="button" class="btn-ghost !px-2.5 !py-1" title="关闭 (Esc)" @click="emit('close')">
|
||||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12" /></svg>
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12" /></svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -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 class="block text-[11px] text-slate-400">{{ it.ts_code }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="text-right">
|
<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)">
|
<span class="block text-[11px]" :class="pctClass(it.pct_chg)">
|
||||||
{{ it.pct_chg == null ? '—' : (it.pct_chg > 0 ? '+' : '') + it.pct_chg.toFixed(2) + '%' }}
|
{{ it.pct_chg == null ? '—' : (it.pct_chg > 0 ? '+' : '') + it.pct_chg.toFixed(2) + '%' }}
|
||||||
</span>
|
</span>
|
||||||
@@ -187,19 +349,23 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
|
|||||||
|
|
||||||
<!-- 中:K线 + 指标面板 -->
|
<!-- 中:K线 + 指标面板 -->
|
||||||
<section class="flex min-w-0 flex-1 flex-col">
|
<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">
|
<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>
|
<span class="text-[11px] text-slate-400">副图:</span>
|
||||||
<button
|
<div
|
||||||
v-for="s in SUBS"
|
v-for="s in SUBS"
|
||||||
:key="s.key"
|
:key="s.key"
|
||||||
|
class="flex items-center overflow-hidden rounded-md border"
|
||||||
|
:class="subPanes.includes(s.key) ? 'border-blue-600' : 'border-slate-200'"
|
||||||
|
>
|
||||||
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
draggable="true"
|
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)
|
:class="subPanes.includes(s.key)
|
||||||
? 'border-blue-600 bg-blue-600 text-white'
|
? 'bg-blue-600 text-white'
|
||||||
: 'border-slate-200 bg-white text-slate-400 line-through'"
|
: 'bg-white text-slate-400 line-through'"
|
||||||
:title="subPanes.includes(s.key) ? '点击隐藏 · 拖动排序' : '点击显示'"
|
:title="subPanes.includes(s.key) ? '点击隐藏 · 拖动排序 · 右侧按钮调高度' : '点击显示'"
|
||||||
@click="toggleSub(s.key)"
|
@click="toggleSub(s.key)"
|
||||||
@dragstart="onDragStart($event, s.key)"
|
@dragstart="onDragStart($event, s.key)"
|
||||||
@dragover.prevent
|
@dragover.prevent
|
||||||
@@ -207,6 +373,11 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
|
|||||||
>
|
>
|
||||||
{{ s.label }}
|
{{ s.label }}
|
||||||
</button>
|
</button>
|
||||||
|
<template v-if="subPanes.includes(s.key)">
|
||||||
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-md border px-2.5 py-1 text-xs transition-colors"
|
class="rounded-md border px-2.5 py-1 text-xs transition-colors"
|
||||||
@@ -214,14 +385,50 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
|
|||||||
title="主图叠加布林带"
|
title="主图叠加布林带"
|
||||||
@click="showBoll = !showBoll"
|
@click="showBoll = !showBoll"
|
||||||
>BOLL</button>
|
>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>
|
||||||
|
|
||||||
<!-- 图表 -->
|
<!-- 图表 -->
|
||||||
<div class="relative min-h-0 flex-1 bg-white p-1">
|
<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">
|
<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>
|
<svg class="mb-2 h-6 w-6 animate-spin text-blue-500" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
|
||||||
{{ active }} 首次查看需拉取全量日线…
|
{{ active }} 加载日线中…
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="error" class="flex h-full items-center justify-center text-sm text-red-600">{{ error }}</div>
|
<div v-else-if="error" class="flex h-full items-center justify-center text-sm text-red-600">{{ error }}</div>
|
||||||
<DetailKLine
|
<DetailKLine
|
||||||
@@ -230,7 +437,10 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
|
|||||||
:candles="data.candles"
|
:candles="data.candles"
|
||||||
:indicators="data.indicators"
|
:indicators="data.indicators"
|
||||||
:sub-panes="subPanes"
|
:sub-panes="subPanes"
|
||||||
|
:ma-periods="maPeriods"
|
||||||
|
:sub-heights="subHeights"
|
||||||
:show-boll="showBoll"
|
:show-boll="showBoll"
|
||||||
|
:timeframe="timeframe"
|
||||||
/>
|
/>
|
||||||
<div v-else class="flex h-full items-center justify-center text-sm text-slate-400">无数据</div>
|
<div v-else class="flex h-full items-center justify-center text-sm text-slate-400">无数据</div>
|
||||||
</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.pb)],
|
||||||
['总市值', fmt(data.info.total_mv) + ' 亿'],
|
['总市值', fmt(data.info.total_mv) + ' 亿'],
|
||||||
['流通市值', fmt(data.info.circ_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)],
|
['上市日期', fmtListDate(data.info.list_date)],
|
||||||
['数据日期', (data.info.trade_date ?? '').slice(0, 10) || '—'],
|
['数据日期', (data.info.trade_date ?? '').slice(0, 10) || '—'],
|
||||||
]" :key="i">
|
]" :key="i">
|
||||||
@@ -273,6 +486,22 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
|
|||||||
</template>
|
</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="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="mt-4 border-t border-slate-100 pt-3 text-[13px]">
|
||||||
<div class="mb-2 text-xs text-slate-400">归属</div>
|
<div class="mb-2 text-xs text-slate-400">归属</div>
|
||||||
<div class="flex flex-wrap gap-1.5">
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const router = createRouter({
|
|||||||
{ path: '/login', name: 'login', component: () => import('@/views/LoginView.vue'), meta: { public: true } },
|
{ path: '/login', name: 'login', component: () => import('@/views/LoginView.vue'), meta: { public: true } },
|
||||||
{ path: '/', name: 'home', component: HomeView },
|
{ path: '/', name: 'home', component: HomeView },
|
||||||
{ path: '/screener', name: 'screener', component: () => import('@/views/ScreenerView.vue') },
|
{ 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: '/backtest', name: 'backtest', component: () => import('@/views/BacktestView.vue') },
|
||||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { defineStore } from 'pinia';
|
|||||||
|
|
||||||
import { ApiError, getCurrentUser, login as requestLogin, logout as requestLogout } from '@/api/client';
|
import { ApiError, getCurrentUser, login as requestLogin, logout as requestLogout } from '@/api/client';
|
||||||
import type { CurrentUser } from '@/api/types';
|
import type { CurrentUser } from '@/api/types';
|
||||||
|
import { useSettingsStore } from '@/stores/settings';
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const user = ref<CurrentUser | null>(null);
|
const user = ref<CurrentUser | null>(null);
|
||||||
@@ -18,6 +19,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
restorePromise = (async () => {
|
restorePromise = (async () => {
|
||||||
try {
|
try {
|
||||||
user.value = await getCurrentUser();
|
user.value = await getCurrentUser();
|
||||||
|
if (user.value) void useSettingsStore().syncFromServer();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ApiError && error.status !== 401) {
|
if (error instanceof ApiError && error.status !== 401) {
|
||||||
console.warn('Unable to restore login session:', error.message);
|
console.warn('Unable to restore login session:', error.message);
|
||||||
@@ -37,6 +39,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
const result = await requestLogin({ username, password });
|
const result = await requestLogin({ username, password });
|
||||||
user.value = result.user;
|
user.value = result.user;
|
||||||
initialized.value = true;
|
initialized.value = true;
|
||||||
|
void useSettingsStore().syncFromServer();
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { getScreenerSyncStatus, runScreener, startScreenerSync } from '@/api/client';
|
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', () => {
|
export const useScreenerStore = defineStore('screener', () => {
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -12,12 +12,12 @@ export const useScreenerStore = defineStore('screener', () => {
|
|||||||
const syncStatus = ref<ScreenerSyncStatus | null>(null);
|
const syncStatus = ref<ScreenerSyncStatus | null>(null);
|
||||||
let pollTimer: ReturnType<typeof setInterval> | 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;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
result.value = null;
|
result.value = null;
|
||||||
note.value = 'AI 解析条件中…';
|
note.value = conditions ? '全市场筛选中…' : 'AI 解析条件中…';
|
||||||
stage.value = 'parsing';
|
stage.value = conditions ? 'screening' : 'parsing';
|
||||||
try {
|
try {
|
||||||
// 条件解析与全市场筛选在后端一气呵成;切到筛选阶段给个过渡提示
|
// 条件解析与全市场筛选在后端一气呵成;切到筛选阶段给个过渡提示
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -26,7 +26,7 @@ export const useScreenerStore = defineStore('screener', () => {
|
|||||||
note.value = '全市场筛选中…';
|
note.value = '全市场筛选中…';
|
||||||
}
|
}
|
||||||
}, 1200);
|
}, 1200);
|
||||||
result.value = await runScreener({ text });
|
result.value = await runScreener({ text, conditions: conditions ?? undefined });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : '选股失败';
|
error.value = e instanceof Error ? e.message : '选股失败';
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,51 +1,336 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import BacktestForm from '@/components/BacktestForm.vue';
|
import { computed, ref } from 'vue';
|
||||||
import KLineChart from '@/components/KLineChart.vue';
|
import { postEventBacktest } from '@/api/client';
|
||||||
import EquityChart from '@/components/EquityChart.vue';
|
import type { EventBacktestResponse, EventBacktestSpec } from '@/api/types';
|
||||||
import MetricsPanel from '@/components/MetricsPanel.vue';
|
import ConditionChips from '@/components/ConditionChips.vue';
|
||||||
import { useBacktestStore } from '@/stores/backtest';
|
|
||||||
import type { BacktestRequest } from '@/api/types';
|
|
||||||
|
|
||||||
const store = useBacktestStore();
|
const EXAMPLES = [
|
||||||
function onRun(req: BacktestRequest) {
|
'在连续三天 J 小于 10 的时候第二天开盘购买,之后未来三天的涨幅有多少',
|
||||||
store.run(req);
|
'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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BacktestForm :loading="store.loading" @run="onRun" />
|
<div>
|
||||||
|
<!-- 输入区 -->
|
||||||
<div v-if="store.error" class="mt-4 flex items-start gap-2 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-[13px] text-red-700">
|
<div class="rounded-xl border border-slate-200 bg-white p-4">
|
||||||
<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>
|
<div class="flex items-center justify-between">
|
||||||
{{ store.error }}
|
<div class="text-[13px] font-medium text-slate-600">事件回测:描述一个「信号 → 次日买入 → 持有 N 日」的事件,统计历史上全市场(或单只股票)的收益分布</div>
|
||||||
</div>
|
</div>
|
||||||
|
<textarea
|
||||||
<div v-if="store.loading && store.note" class="py-16 text-center text-sm text-slate-400">
|
v-model="text"
|
||||||
<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>
|
rows="2"
|
||||||
{{ store.note }}
|
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"
|
||||||
</div>
|
placeholder="例:在连续三天 J 小于 10 的时候第二天开盘购买,之后未来三天的涨幅有多少"
|
||||||
|
@keydown.ctrl.enter="run()"
|
||||||
<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"
|
|
||||||
/>
|
/>
|
||||||
|
<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>
|
||||||
|
|
||||||
<div class="mt-4 rounded-xl border border-slate-200 bg-white p-3">
|
<div class="mt-3 flex flex-wrap items-end gap-3">
|
||||||
<div class="px-2 py-1 text-[13px] text-slate-500">净值曲线</div>
|
<label class="text-[12px] text-slate-500">
|
||||||
<EquityChart :equity="store.result.equity" />
|
股票范围
|
||||||
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-else-if="!store.loading" class="py-16 text-center text-sm text-slate-400">
|
<div v-else-if="!error && !loading" class="py-16 text-center text-sm text-slate-400">
|
||||||
选好周期与参数,点「开始回测」——先用 DEMO 合成数据跑通。
|
用一句话描述你的想法,例如「连续三天 J 小于 10 时次日开盘买入,未来三天涨多少」
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,20 +1,27 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterLink } from 'vue-router';
|
import { RouterLink } from 'vue-router';
|
||||||
|
|
||||||
// 首页:两大功能入口(智能选股 / 策略回测)
|
// 首页:三大功能入口(看股 / 选股 / 回测)
|
||||||
const features = [
|
const features = [
|
||||||
|
{
|
||||||
|
to: '/stocks',
|
||||||
|
icon: 'M4 6h16M4 12h16M4 18h10',
|
||||||
|
accent: 'bg-amber-50 text-amber-600',
|
||||||
|
title: '看股',
|
||||||
|
desc: '浏览全市场 5,400+ 只股票的信息与历史 K 线数据。',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
to: '/screener',
|
to: '/screener',
|
||||||
icon: 'M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3z',
|
icon: 'M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3z',
|
||||||
accent: 'bg-blue-50 text-blue-600',
|
accent: 'bg-blue-50 text-blue-600',
|
||||||
title: '智能选股',
|
title: '选股',
|
||||||
desc: '用自然语言描述选股条件,快速完成全市场筛选。',
|
desc: '用自然语言描述选股条件,快速完成全市场筛选。',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/backtest',
|
to: '/backtest',
|
||||||
icon: 'M3 17l6-6 4 4 8-8M21 7v6h-6',
|
icon: 'M3 17l6-6 4 4 8-8M21 7v6h-6',
|
||||||
accent: 'bg-emerald-50 text-emerald-600',
|
accent: 'bg-emerald-50 text-emerald-600',
|
||||||
title: '策略回测',
|
title: '回测',
|
||||||
desc: '选择标的与策略参数,查看历史表现和关键绩效指标。',
|
desc: '选择标的与策略参数,查看历史表现和关键绩效指标。',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -22,21 +29,23 @@ const features = [
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="w-full max-w-5xl">
|
<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
|
<RouterLink
|
||||||
v-for="f in features"
|
v-for="f in features"
|
||||||
:key="f.to"
|
:key="f.to"
|
||||||
:to="f.to"
|
:to="f.to"
|
||||||
class="group flex 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">
|
<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" />
|
<path :d="f.icon" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<div class="mt-7 text-2xl font-semibold">{{ f.title }}</div>
|
<div class="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>
|
||||||
<div class="mt-auto flex items-center gap-1.5 pt-8 text-sm font-medium text-blue-600">
|
<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>
|
<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>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import StockDetailOverlay from '@/components/StockDetailOverlay.vue';
|
|||||||
|
|
||||||
const store = useScreenerStore();
|
const store = useScreenerStore();
|
||||||
const previewCode = ref<string | null>(null);
|
const previewCode = ref<string | null>(null);
|
||||||
|
const formRef = ref<InstanceType<typeof ScreenerForm> | null>(null);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 仅查状态;同步由用户点击「同步市场数据」显式触发(避免反复触发接口限频)
|
// 仅查状态;同步由用户点击「同步市场数据」显式触发(避免反复触发接口限频)
|
||||||
@@ -20,7 +21,7 @@ onBeforeUnmount(() => store.stopPolling());
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<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)" />
|
<SyncStatusBar :status="store.syncStatus" @sync="store.startSync(90)" />
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user