Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db978240da | |||
| 0be67be799 | |||
| aa76c19aff | |||
| f8c946e8a9 | |||
| a45aa5b033 | |||
| ab6282b94c | |||
| 9904854cc1 |
+3
-1
@@ -44,7 +44,7 @@ backend/static/
|
|||||||
backend/app/static/
|
backend/app/static/
|
||||||
|
|
||||||
# ===== npm lockfile (不纳入版本控制) =====
|
# ===== npm lockfile (不纳入版本控制) =====
|
||||||
frontend/package-lock.json
|
**/package-lock.json
|
||||||
|
|
||||||
# ===== IDE =====
|
# ===== IDE =====
|
||||||
.idea/
|
.idea/
|
||||||
@@ -58,6 +58,8 @@ backend/data/**
|
|||||||
!backend/data/.gitkeep
|
!backend/data/.gitkeep
|
||||||
data/**
|
data/**
|
||||||
!data/.gitkeep
|
!data/.gitkeep
|
||||||
|
serve/data/**
|
||||||
|
!serve/data/.gitkeep
|
||||||
# 扩展数据: 全部纳入版本控制(含 config.json 表结构 + part.parquet 数据文件)
|
# 扩展数据: 全部纳入版本控制(含 config.json 表结构 + part.parquet 数据文件)
|
||||||
# 注意 gitignore 否定规则特性: 需先放行目录才能放行内部文件
|
# 注意 gitignore 否定规则特性: 需先放行目录才能放行内部文件
|
||||||
!data/ext_data/
|
!data/ext_data/
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ frontend/dist
|
|||||||
dist
|
dist
|
||||||
build
|
build
|
||||||
|
|
||||||
|
# Project data
|
||||||
|
data
|
||||||
|
|
||||||
# Git
|
# Git
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
|
|||||||
@@ -120,25 +120,5 @@ def seed_demo_alerts(request: Request, count: int = 12, recent: bool = True):
|
|||||||
})
|
})
|
||||||
alert_store.append_many(_data_dir(request), events)
|
alert_store.append_many(_data_dir(request), events)
|
||||||
|
|
||||||
# 同步推入 SSE 队列, 让所有连着 SSE 的客户端实时收到 (不依赖轮询)
|
|
||||||
qs = getattr(request.app.state, "quote_service", None)
|
|
||||||
if qs:
|
|
||||||
# 转成 SSE 推送格式 (和 _evaluate_monitors 一致)
|
|
||||||
sse_alerts = [{
|
|
||||||
"source": ev["source"],
|
|
||||||
"type": ev["type"],
|
|
||||||
"rule_id": ev.get("rule_id"),
|
|
||||||
"symbol": ev["symbol"],
|
|
||||||
"name": ev["name"],
|
|
||||||
"message": ev["message"],
|
|
||||||
"price": ev["price"],
|
|
||||||
"change_pct": ev["change_pct"],
|
|
||||||
"signals": ev["signals"],
|
|
||||||
"severity": ev.get("severity", "info"),
|
|
||||||
} for ev in events]
|
|
||||||
with qs._lock:
|
|
||||||
qs._pending_alerts.extend(sse_alerts)
|
|
||||||
qs._alert_event.set()
|
|
||||||
|
|
||||||
return {"ok": True, "generated": len(events)}
|
return {"ok": True, "generated": len(events)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,474 +0,0 @@
|
|||||||
"""回测 API — 信号回测 + 因子回测 + 策略回测。"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import queue
|
|
||||||
import threading
|
|
||||||
from dataclasses import asdict
|
|
||||||
from datetime import date, timedelta
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
|
||||||
from fastapi.responses import StreamingResponse
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
from app.services.backtest import (
|
|
||||||
BacktestConfig,
|
|
||||||
BacktestService,
|
|
||||||
VectorbtUnavailable,
|
|
||||||
is_available,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/backtest", tags=["backtest"])
|
|
||||||
|
|
||||||
FACTOR_DEFAULT_DAYS = 180
|
|
||||||
STRATEGY_DEFAULT_DAYS = 365 * 3
|
|
||||||
BACKTEST_MAX_SERVER_DAYS = 186
|
|
||||||
FACTOR_MAX_SYMBOLS = 1000
|
|
||||||
BACKTEST_SERVER_GUARD_MESSAGE = (
|
|
||||||
"当前服务器内存约 1.8GB,回测区间最多支持 6 个月;"
|
|
||||||
"更长周期容易触发 OOM,建议在 8GB 以上内存环境或本机运行。"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_engine(request: Request):
|
|
||||||
"""获取或创建 BacktestEngine (单例,PanelCache 跨请求生效)。"""
|
|
||||||
from app.backtest.engine import BacktestEngine
|
|
||||||
engine = getattr(request.app.state, "backtest_engine", None)
|
|
||||||
if engine is None:
|
|
||||||
engine = BacktestEngine(request.app.state.repo)
|
|
||||||
request.app.state.backtest_engine = engine
|
|
||||||
return engine
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_start(req: BaseModel, end: date, default_days: int) -> date:
|
|
||||||
"""未传 start 使用默认区间;显式传 null/空值表示全部历史。"""
|
|
||||||
start = getattr(req, "start")
|
|
||||||
if start is not None:
|
|
||||||
return start
|
|
||||||
if "start" in req.model_fields_set:
|
|
||||||
return date(1900, 1, 1)
|
|
||||||
return end - timedelta(days=default_days)
|
|
||||||
|
|
||||||
|
|
||||||
def _guard_server_backtest_range(start: date, end: date):
|
|
||||||
if not settings.backtest_range_guard:
|
|
||||||
return
|
|
||||||
days = (end - start).days + 1
|
|
||||||
if days > BACKTEST_MAX_SERVER_DAYS:
|
|
||||||
raise HTTPException(status_code=400, detail=BACKTEST_SERVER_GUARD_MESSAGE)
|
|
||||||
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# 状态
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
@router.get("/status")
|
|
||||||
def status():
|
|
||||||
"""前端可用此接口判断回测页是否要灰显。"""
|
|
||||||
return {"available": True}
|
|
||||||
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# 信号回测 (现有接口,保持不变)
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
class BacktestRequest(BaseModel):
|
|
||||||
symbols: list[str] = Field(..., min_length=1)
|
|
||||||
start: date | None = None
|
|
||||||
end: date | None = None
|
|
||||||
entries: list[str] = []
|
|
||||||
exits: list[str] = []
|
|
||||||
stop_loss_pct: float | None = None
|
|
||||||
max_hold_days: int | None = None
|
|
||||||
fees_pct: float = 0.0002
|
|
||||||
slippage_bps: float = 5
|
|
||||||
matching: Literal["close_t", "open_t+1"] = "close_t"
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/run")
|
|
||||||
def run(req: BacktestRequest, request: Request):
|
|
||||||
"""信号回测 — 现有接口,向后兼容。"""
|
|
||||||
repo = request.app.state.repo
|
|
||||||
svc = BacktestService(repo)
|
|
||||||
end = req.end or date.today()
|
|
||||||
start = req.start or (end - timedelta(days=365 * 3))
|
|
||||||
|
|
||||||
cfg = BacktestConfig(
|
|
||||||
symbols=req.symbols,
|
|
||||||
start=start,
|
|
||||||
end=end,
|
|
||||||
entries=req.entries,
|
|
||||||
exits=req.exits,
|
|
||||||
stop_loss_pct=req.stop_loss_pct,
|
|
||||||
max_hold_days=req.max_hold_days,
|
|
||||||
fees_pct=req.fees_pct,
|
|
||||||
slippage_bps=req.slippage_bps,
|
|
||||||
matching=req.matching,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
result = svc.run(cfg)
|
|
||||||
except VectorbtUnavailable as e:
|
|
||||||
raise HTTPException(status_code=503, detail=str(e)) from e
|
|
||||||
return asdict(result)
|
|
||||||
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# 因子回测
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
class FactorColumnsResponse(BaseModel):
|
|
||||||
columns: list[dict]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/factor/columns")
|
|
||||||
def factor_columns():
|
|
||||||
"""返回可用的因子列列表。"""
|
|
||||||
from app.backtest.factor import FACTOR_COLUMNS
|
|
||||||
return {"columns": FACTOR_COLUMNS}
|
|
||||||
|
|
||||||
|
|
||||||
class FactorBacktestRequest(BaseModel):
|
|
||||||
factor_name: str
|
|
||||||
symbols: list[str] | None = None
|
|
||||||
start: date | None = None
|
|
||||||
end: date | None = None
|
|
||||||
n_groups: int = 5
|
|
||||||
rebalance: Literal["daily", "weekly", "monthly"] = "monthly"
|
|
||||||
weight: Literal["equal", "factor_weight"] = "equal"
|
|
||||||
fees_pct: float = 0.0002
|
|
||||||
slippage_bps: float = 5.0
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/factor/run")
|
|
||||||
def factor_run(req: FactorBacktestRequest, request: Request):
|
|
||||||
"""因子回测 — IC/IR 分析 + 分层回测。"""
|
|
||||||
from app.backtest.factor import FactorBacktestService, FactorConfig
|
|
||||||
|
|
||||||
engine = _get_engine(request)
|
|
||||||
svc = FactorBacktestService(engine)
|
|
||||||
|
|
||||||
end = req.end or date.today()
|
|
||||||
start = _resolve_start(req, end, STRATEGY_DEFAULT_DAYS)
|
|
||||||
_guard_server_backtest_range(start, end)
|
|
||||||
symbols = req.symbols if req.symbols else None
|
|
||||||
if symbols is not None and len(symbols) > FACTOR_MAX_SYMBOLS:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"指定标的最多支持 {FACTOR_MAX_SYMBOLS} 只,请缩小标的范围。",
|
|
||||||
)
|
|
||||||
|
|
||||||
cfg = FactorConfig(
|
|
||||||
factor_name=req.factor_name,
|
|
||||||
symbols=symbols,
|
|
||||||
start=start,
|
|
||||||
end=end,
|
|
||||||
n_groups=req.n_groups,
|
|
||||||
rebalance=req.rebalance,
|
|
||||||
weight=req.weight,
|
|
||||||
fees_pct=req.fees_pct,
|
|
||||||
slippage_bps=req.slippage_bps,
|
|
||||||
)
|
|
||||||
result = svc.run(cfg)
|
|
||||||
return asdict(result)
|
|
||||||
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# 策略回测
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
class StrategyBacktestRequest(BaseModel):
|
|
||||||
strategy_id: str
|
|
||||||
symbols: list[str] | None = None
|
|
||||||
start: date | None = None
|
|
||||||
end: date | None = None
|
|
||||||
params: dict | None = None
|
|
||||||
overrides: dict | None = None
|
|
||||||
# matching 向后兼容; 显式传 entry_fill/exit_fill 时以二者为准。
|
|
||||||
matching: Literal["close_t", "open_t+1"] = "open_t+1"
|
|
||||||
entry_fill: Literal["close_t", "open_t+1"] | None = None
|
|
||||||
exit_fill: Literal["close_t", "open_t+1"] | None = None
|
|
||||||
fees_pct: float = 0.0002
|
|
||||||
slippage_bps: float = 5.0
|
|
||||||
max_positions: int = 10
|
|
||||||
max_exposure_pct: float = 1.0
|
|
||||||
initial_capital: float = 1_000_000.0
|
|
||||||
position_sizing: Literal["equal", "score_weight"] = "equal"
|
|
||||||
mode: Literal["position", "full"] = "position"
|
|
||||||
holding_days: int = 5
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/strategy/run")
|
|
||||||
def strategy_run(req: StrategyBacktestRequest, request: Request):
|
|
||||||
"""策略回测 — 复用 StrategyDef 体系做全周期回测。"""
|
|
||||||
from app.backtest.strategy import StrategyBacktestService, StrategyBacktestConfig
|
|
||||||
|
|
||||||
engine = _get_engine(request)
|
|
||||||
strategy_engine = request.app.state.strategy_engine
|
|
||||||
svc = StrategyBacktestService(engine, strategy_engine)
|
|
||||||
|
|
||||||
end = req.end or date.today()
|
|
||||||
start = _resolve_start(req, end, FACTOR_DEFAULT_DAYS)
|
|
||||||
_guard_server_backtest_range(start, end)
|
|
||||||
|
|
||||||
cfg = StrategyBacktestConfig(
|
|
||||||
strategy_id=req.strategy_id,
|
|
||||||
symbols=req.symbols if req.symbols else None,
|
|
||||||
start=start,
|
|
||||||
end=end,
|
|
||||||
params=req.params,
|
|
||||||
overrides=req.overrides,
|
|
||||||
matching=req.matching,
|
|
||||||
entry_fill=req.entry_fill,
|
|
||||||
exit_fill=req.exit_fill,
|
|
||||||
fees_pct=req.fees_pct,
|
|
||||||
slippage_bps=req.slippage_bps,
|
|
||||||
max_positions=req.max_positions,
|
|
||||||
max_exposure_pct=req.max_exposure_pct,
|
|
||||||
initial_capital=req.initial_capital,
|
|
||||||
position_sizing=req.position_sizing,
|
|
||||||
mode=req.mode,
|
|
||||||
holding_days=req.holding_days,
|
|
||||||
)
|
|
||||||
result = svc.run(cfg)
|
|
||||||
return asdict(result)
|
|
||||||
|
|
||||||
|
|
||||||
# ── SSE 流式回测 (实时进度 + 可取消 + 支持重连) ───────────────────
|
|
||||||
|
|
||||||
import time
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
|
|
||||||
class _BacktestJob:
|
|
||||||
"""单个回测任务的状态, 存模块级供重连使用。"""
|
|
||||||
__slots__ = ("key", "cancel_event", "progress", "result", "error", "done", "finish_ts")
|
|
||||||
|
|
||||||
def __init__(self, key: str):
|
|
||||||
self.key = key
|
|
||||||
self.cancel_event = threading.Event()
|
|
||||||
self.progress: list[dict] = [] # 进度历史 (新连接可回放)
|
|
||||||
self.result = None # 完成后的结果
|
|
||||||
self.error: str | None = None
|
|
||||||
self.done = False
|
|
||||||
self.finish_ts: float = 0.0
|
|
||||||
|
|
||||||
|
|
||||||
# 模块级任务表: key -> _BacktestJob
|
|
||||||
_running_jobs: dict[str, _BacktestJob] = {}
|
|
||||||
_jobs_lock = threading.Lock()
|
|
||||||
_JOB_TTL = 300 # 完成后保留 5 分钟
|
|
||||||
|
|
||||||
|
|
||||||
def _cleanup_stale_jobs():
|
|
||||||
"""清理过期任务 (完成超过 TTL 的)。"""
|
|
||||||
now = time.time()
|
|
||||||
stale = [k for k, j in _running_jobs.items() if j.done and now - j.finish_ts > _JOB_TTL]
|
|
||||||
for k in stale:
|
|
||||||
_running_jobs.pop(k, None)
|
|
||||||
|
|
||||||
|
|
||||||
def _make_job_key(
|
|
||||||
strategy_id: str, symbols: str | None, start: str | None, end: str | None,
|
|
||||||
matching: str, entry_fill: str | None, exit_fill: str | None,
|
|
||||||
fees_pct: float, slippage_bps: float,
|
|
||||||
max_positions: int, max_exposure_pct: float, initial_capital: float, position_sizing: str,
|
|
||||||
params: str | None, overrides: str | None,
|
|
||||||
mode: str = "position", holding_days: int = 5,
|
|
||||||
) -> str:
|
|
||||||
raw = f"{strategy_id}|{symbols}|{start}|{end}|{matching}|{entry_fill}|{exit_fill}|{fees_pct}|{slippage_bps}|{max_positions}|{max_exposure_pct}|{initial_capital}|{position_sizing}|{params}|{overrides}|{mode}|{holding_days}"
|
|
||||||
return hashlib.md5(raw.encode()).hexdigest()[:12]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/strategy/stream")
|
|
||||||
async def strategy_stream(
|
|
||||||
request: Request,
|
|
||||||
strategy_id: str,
|
|
||||||
symbols: str | None = None,
|
|
||||||
start: str | None = None,
|
|
||||||
end: str | None = None,
|
|
||||||
matching: str = "open_t+1",
|
|
||||||
entry_fill: str | None = None,
|
|
||||||
exit_fill: str | None = None,
|
|
||||||
fees_pct: float = 0.0002,
|
|
||||||
slippage_bps: float = 5.0,
|
|
||||||
max_positions: int = 10,
|
|
||||||
max_exposure_pct: float = 1.0,
|
|
||||||
initial_capital: float = 1_000_000.0,
|
|
||||||
position_sizing: str = "equal",
|
|
||||||
params: str | None = None,
|
|
||||||
overrides: str | None = None,
|
|
||||||
mode: str = "position",
|
|
||||||
holding_days: int = 5,
|
|
||||||
):
|
|
||||||
"""SSE 流式策略回测: 实时推送进度, 完成后推送结果, 支持重连 (刷新/切页后恢复)。
|
|
||||||
|
|
||||||
- 相同参数的任务只启动一次, 多次连接订阅同一个任务
|
|
||||||
- 断开连接不会取消任务 (除非显式调用 cancel)
|
|
||||||
- 结果保留 5 分钟供重连
|
|
||||||
|
|
||||||
事件类型:
|
|
||||||
- progress: {day, total, date, equity}
|
|
||||||
- done: {result} (完整回测结果)
|
|
||||||
- error: {message}
|
|
||||||
"""
|
|
||||||
from app.backtest.strategy import StrategyBacktestService, StrategyBacktestConfig
|
|
||||||
|
|
||||||
engine = _get_engine(request)
|
|
||||||
strategy_engine = request.app.state.strategy_engine
|
|
||||||
svc = StrategyBacktestService(engine, strategy_engine)
|
|
||||||
|
|
||||||
end_date = date.fromisoformat(end) if end else date.today()
|
|
||||||
if start:
|
|
||||||
start_date = date.fromisoformat(start)
|
|
||||||
else:
|
|
||||||
# 空 start = 全部历史: 用本地最早日K日期, 查不到再回退到默认窗口
|
|
||||||
earliest = request.app.state.repo.earliest_daily_date()
|
|
||||||
start_date = earliest or (end_date - timedelta(days=FACTOR_DEFAULT_DAYS))
|
|
||||||
|
|
||||||
# 服务端范围保护
|
|
||||||
guard_violated = False
|
|
||||||
if settings.backtest_range_guard:
|
|
||||||
days = (end_date - start_date).days + 1
|
|
||||||
if days > BACKTEST_MAX_SERVER_DAYS:
|
|
||||||
guard_violated = True
|
|
||||||
|
|
||||||
job_key = _make_job_key(
|
|
||||||
strategy_id, symbols, start, end,
|
|
||||||
matching, entry_fill, exit_fill,
|
|
||||||
fees_pct, slippage_bps, max_positions, max_exposure_pct, initial_capital, position_sizing,
|
|
||||||
params, overrides,
|
|
||||||
mode, holding_days,
|
|
||||||
)
|
|
||||||
|
|
||||||
_cleanup_stale_jobs()
|
|
||||||
|
|
||||||
# 获取或创建任务
|
|
||||||
with _jobs_lock:
|
|
||||||
job = _running_jobs.get(job_key)
|
|
||||||
if job is None:
|
|
||||||
job = _BacktestJob(job_key)
|
|
||||||
_running_jobs[job_key] = job
|
|
||||||
is_new = True
|
|
||||||
else:
|
|
||||||
is_new = False
|
|
||||||
|
|
||||||
async def event_generator():
|
|
||||||
# 范围保护: 直接报错
|
|
||||||
if guard_violated:
|
|
||||||
yield f"event: error\ndata: {json.dumps({'message': BACKTEST_SERVER_GUARD_MESSAGE}, ensure_ascii=False)}\n\n"
|
|
||||||
return
|
|
||||||
|
|
||||||
# 如果是新任务, 启动回测线程
|
|
||||||
if is_new and not job.done:
|
|
||||||
cfg = StrategyBacktestConfig(
|
|
||||||
strategy_id=strategy_id,
|
|
||||||
symbols=[s.strip() for s in symbols.split(",") if s.strip()] if symbols else None,
|
|
||||||
start=start_date,
|
|
||||||
end=end_date,
|
|
||||||
params=json.loads(params) if params else None,
|
|
||||||
overrides=json.loads(overrides) if overrides else None,
|
|
||||||
matching=matching,
|
|
||||||
entry_fill=entry_fill,
|
|
||||||
exit_fill=exit_fill,
|
|
||||||
fees_pct=fees_pct,
|
|
||||||
slippage_bps=slippage_bps,
|
|
||||||
max_positions=int(max_positions),
|
|
||||||
max_exposure_pct=float(max_exposure_pct),
|
|
||||||
initial_capital=float(initial_capital),
|
|
||||||
position_sizing=position_sizing,
|
|
||||||
mode=mode,
|
|
||||||
holding_days=int(holding_days),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _run_backtest():
|
|
||||||
try:
|
|
||||||
result = svc.run(cfg, lambda d: job.progress.append(d), job.cancel_event)
|
|
||||||
job.result = result
|
|
||||||
job.done = True
|
|
||||||
job.finish_ts = time.time()
|
|
||||||
except Exception as e:
|
|
||||||
job.error = str(e)
|
|
||||||
job.done = True
|
|
||||||
job.finish_ts = time.time()
|
|
||||||
|
|
||||||
# 启动后台线程 (不阻塞事件循环)
|
|
||||||
threading.Thread(target=_run_backtest, daemon=True).start()
|
|
||||||
|
|
||||||
# 订阅进度: 用读指针读 job.progress 列表 (多连接互不干扰)
|
|
||||||
cursor = 0
|
|
||||||
tick = 0
|
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
# 已完成: 推送最终结果/错误并退出
|
|
||||||
if job.done:
|
|
||||||
if job.error:
|
|
||||||
yield f"event: error\ndata: {json.dumps({'message': job.error}, ensure_ascii=False)}\n\n"
|
|
||||||
elif job.result is not None:
|
|
||||||
r = job.result
|
|
||||||
if hasattr(r, "error") and r.error == "cancelled":
|
|
||||||
yield f"event: error\ndata: {json.dumps({'message': '回测已取消'}, ensure_ascii=False)}\n\n"
|
|
||||||
elif hasattr(r, "error") and r.error:
|
|
||||||
yield f"event: error\ndata: {json.dumps({'message': r.error}, ensure_ascii=False)}\n\n"
|
|
||||||
else:
|
|
||||||
yield f"event: done\ndata: {json.dumps(asdict(r), ensure_ascii=False, default=str)}\n\n"
|
|
||||||
return
|
|
||||||
|
|
||||||
# 断开检测: 每 4 轮检查一次 (降低 GIL 抢占频率)
|
|
||||||
tick += 1
|
|
||||||
if tick % 4 == 0 and await request.is_disconnected():
|
|
||||||
break
|
|
||||||
|
|
||||||
# 推送新进度 (从 cursor 开始读)
|
|
||||||
prog_list = job.progress
|
|
||||||
while cursor < len(prog_list):
|
|
||||||
msg = prog_list[cursor]
|
|
||||||
cursor += 1
|
|
||||||
yield f"event: progress\ndata: {json.dumps(msg, ensure_ascii=False, default=str)}\n\n"
|
|
||||||
|
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
raise
|
|
||||||
|
|
||||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/strategy/cancel")
|
|
||||||
async def strategy_cancel(request: Request):
|
|
||||||
"""取消正在运行的回测任务 (前端传 query string, 后端算 job_key)。"""
|
|
||||||
body = await request.json()
|
|
||||||
qs = body.get("qs", "")
|
|
||||||
# 解析 qs 得到参数
|
|
||||||
from urllib.parse import parse_qs
|
|
||||||
p = parse_qs(qs)
|
|
||||||
def _get(key: str, default: str = "") -> str:
|
|
||||||
return p.get(key, [default])[0]
|
|
||||||
job_key = _make_job_key(
|
|
||||||
_get("strategy_id"),
|
|
||||||
_get("symbols") or None,
|
|
||||||
_get("start") or None,
|
|
||||||
_get("end") or None,
|
|
||||||
_get("matching", "open_t+1"),
|
|
||||||
_get("entry_fill") or None,
|
|
||||||
_get("exit_fill") or None,
|
|
||||||
float(_get("fees_pct", "0.0002")),
|
|
||||||
float(_get("slippage_bps", "5")),
|
|
||||||
int(_get("max_positions", "10")),
|
|
||||||
float(_get("max_exposure_pct", "1")),
|
|
||||||
float(_get("initial_capital", "1000000")),
|
|
||||||
_get("position_sizing", "equal"),
|
|
||||||
_get("params") or None,
|
|
||||||
_get("overrides") or None,
|
|
||||||
_get("mode", "position"),
|
|
||||||
int(_get("holding_days", "5")),
|
|
||||||
)
|
|
||||||
job = _running_jobs.get(job_key)
|
|
||||||
if job and not job.done:
|
|
||||||
job.cancel_event.set()
|
|
||||||
return {"ok": True}
|
|
||||||
return {"ok": False, "message": "任务不存在或已完成"}
|
|
||||||
|
|
||||||
@@ -505,7 +505,7 @@ def _compute_storage(data_dir: Path) -> dict:
|
|||||||
stats[f"{key}_size_mb"] = sz
|
stats[f"{key}_size_mb"] = sz
|
||||||
|
|
||||||
# total: 再加上其他零散文件(pools, financials, capabilities.json 等)
|
# total: 再加上其他零散文件(pools, financials, capabilities.json 等)
|
||||||
other_dirs = ["pools", "financials", "backtest_results", "screener_results", "ai_cache"]
|
other_dirs = ["pools", "financials", "screener_results", "ai_cache"]
|
||||||
for name in other_dirs:
|
for name in other_dirs:
|
||||||
d = data_dir / name
|
d = data_dir / name
|
||||||
if d.exists():
|
if d.exists():
|
||||||
@@ -629,7 +629,7 @@ def clear_data(request: Request):
|
|||||||
"kline_daily", "kline_daily_enriched", "kline_index_daily", "kline_index_enriched",
|
"kline_daily", "kline_daily_enriched", "kline_index_daily", "kline_index_enriched",
|
||||||
"kline_etf_daily", "kline_etf_enriched", "kline_etf_minute", "kline_minute",
|
"kline_etf_daily", "kline_etf_enriched", "kline_etf_minute", "kline_minute",
|
||||||
"adj_factor", "adj_factor_etf", "instruments", "instruments_index", "instruments_etf", "pools", "financials",
|
"adj_factor", "adj_factor_etf", "instruments", "instruments_index", "instruments_etf", "pools", "financials",
|
||||||
"backtest_results", "screener_results", "ai_cache",
|
"screener_results", "ai_cache",
|
||||||
):
|
):
|
||||||
d = data_dir / sub
|
d = data_dir / sub
|
||||||
if d.exists():
|
if d.exists():
|
||||||
@@ -658,12 +658,6 @@ def clear_data(request: Request):
|
|||||||
# - 触发记录 alerts.jsonl
|
# - 触发记录 alerts.jsonl
|
||||||
from app.services import alert_store
|
from app.services import alert_store
|
||||||
alert_store.clear(data_dir)
|
alert_store.clear(data_dir)
|
||||||
# - 待推送的实时通知队列 (进程内存)
|
|
||||||
qs = getattr(request.app.state, "quote_service", None)
|
|
||||||
if qs is not None:
|
|
||||||
with qs._lock:
|
|
||||||
qs._pending_alerts.clear()
|
|
||||||
|
|
||||||
# 清除 Polars 缓存
|
# 清除 Polars 缓存
|
||||||
# 先 clear_cache 无条件清空内存 (refresh_cache 在磁盘无数据时会提前 return,
|
# 先 clear_cache 无条件清空内存 (refresh_cache 在磁盘无数据时会提前 return,
|
||||||
# 导致 _enriched_cache 等旧数据残留 —— 清数据后看板仍显示旧数据的根因),
|
# 导致 _enriched_cache 等旧数据残留 —— 清数据后看板仍显示旧数据的根因),
|
||||||
|
|||||||
@@ -1,212 +0,0 @@
|
|||||||
"""行情状态 / SSE 推送 API。
|
|
||||||
|
|
||||||
盘中选股相关端点已迁移至策略页面,此处仅保留全局行情基础设施。
|
|
||||||
SSE 推送三种事件 (使用标准 SSE event 字段):
|
|
||||||
- quotes_updated: 行情数据刷新,前端 invalidate 对应 query
|
|
||||||
- strategy_alert: 策略监控/告警触发,前端弹通知
|
|
||||||
- depth_updated: 五档盘口修正完成,前端刷新连板梯队/看板封单数据
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Query, Request
|
|
||||||
from sse_starlette.sse import EventSourceResponse
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/intraday", tags=["quotes"])
|
|
||||||
|
|
||||||
|
|
||||||
def _get_quote_service(request: Request):
|
|
||||||
"""获取全局 QuoteService。"""
|
|
||||||
return getattr(request.app.state, "quote_service", None)
|
|
||||||
|
|
||||||
|
|
||||||
def _fallback_index_quotes_from_daily(request: Request, symbols: list[str] | None = None) -> list[dict]:
|
|
||||||
"""实时指数缓存为空时,从本地指数日 K 取最近收盘价作为兜底。"""
|
|
||||||
repo = getattr(request.app.state, "repo", None)
|
|
||||||
if not repo:
|
|
||||||
return []
|
|
||||||
|
|
||||||
params: list[str] = []
|
|
||||||
symbol_filter = ""
|
|
||||||
if symbols:
|
|
||||||
placeholders = ", ".join("?" for _ in symbols)
|
|
||||||
symbol_filter = f"WHERE symbol IN ({placeholders})"
|
|
||||||
params.extend(symbols)
|
|
||||||
|
|
||||||
try:
|
|
||||||
rows = repo.execute_all(
|
|
||||||
f"""
|
|
||||||
WITH ranked AS (
|
|
||||||
SELECT symbol, date, close,
|
|
||||||
row_number() OVER (PARTITION BY symbol ORDER BY date DESC) AS rn
|
|
||||||
FROM kline_index_daily
|
|
||||||
{symbol_filter}
|
|
||||||
), latest AS (
|
|
||||||
SELECT symbol,
|
|
||||||
max(CASE WHEN rn = 1 THEN date END) AS date,
|
|
||||||
max(CASE WHEN rn = 1 THEN close END) AS last_price,
|
|
||||||
max(CASE WHEN rn = 2 THEN close END) AS prev_close
|
|
||||||
FROM ranked
|
|
||||||
WHERE rn <= 2
|
|
||||||
GROUP BY symbol
|
|
||||||
)
|
|
||||||
SELECT latest.symbol, latest.date, latest.last_price, latest.prev_close
|
|
||||||
FROM latest
|
|
||||||
ORDER BY latest.symbol
|
|
||||||
""",
|
|
||||||
params,
|
|
||||||
)
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
return []
|
|
||||||
|
|
||||||
out: list[dict] = []
|
|
||||||
for symbol, dt, last_price, prev_close in rows:
|
|
||||||
change_amount = None
|
|
||||||
change_pct = None
|
|
||||||
if last_price is not None and prev_close not in (None, 0):
|
|
||||||
change_amount = float(last_price) - float(prev_close)
|
|
||||||
change_pct = change_amount / float(prev_close) * 100
|
|
||||||
out.append({
|
|
||||||
"symbol": symbol,
|
|
||||||
"name": None,
|
|
||||||
"date": str(dt) if dt else None,
|
|
||||||
"last_price": float(last_price) if last_price is not None else None,
|
|
||||||
"close": float(last_price) if last_price is not None else None,
|
|
||||||
"prev_close": float(prev_close) if prev_close is not None else None,
|
|
||||||
"change_amount": change_amount,
|
|
||||||
"change_pct": change_pct,
|
|
||||||
"source": "index_daily",
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
|
||||||
def status(request: Request):
|
|
||||||
"""行情状态 (来自全局 QuoteService)。"""
|
|
||||||
qs = _get_quote_service(request)
|
|
||||||
if qs:
|
|
||||||
return qs.status()
|
|
||||||
return {"enabled": False, "running": False, "symbol_count": 0, "index_symbol_count": 0,
|
|
||||||
"quote_age_ms": None, "is_trading_hours": False, "last_fetch_ms": None}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/indices")
|
|
||||||
def index_quotes(
|
|
||||||
request: Request,
|
|
||||||
symbols: str | None = Query(None, description="逗号分隔的指数 symbol 列表"),
|
|
||||||
):
|
|
||||||
"""返回实时指数行情缓存,不触发 TickFlow 请求。"""
|
|
||||||
symbol_list = [s.strip() for s in symbols.split(",") if s.strip()] if symbols else None
|
|
||||||
qs = _get_quote_service(request)
|
|
||||||
if not qs:
|
|
||||||
rows = _fallback_index_quotes_from_daily(request, symbol_list)
|
|
||||||
return {"rows": rows, "count": len(rows), "source": "index_daily"}
|
|
||||||
df = qs.get_index_quotes(symbol_list)
|
|
||||||
rows = df.to_dicts() if not df.is_empty() else []
|
|
||||||
if not rows:
|
|
||||||
rows = _fallback_index_quotes_from_daily(request, symbol_list)
|
|
||||||
return {"rows": rows, "count": len(rows), "source": "index_daily"}
|
|
||||||
return {"rows": rows, "count": len(rows), "source": "realtime"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stream")
|
|
||||||
async def quote_stream(request: Request):
|
|
||||||
"""SSE 端点: 行情更新 + 告警推送 + 五档修正。
|
|
||||||
|
|
||||||
使用 sse-starlette EventSourceResponse:
|
|
||||||
- 标准 SSE event 字段,前端按 event name 监听
|
|
||||||
- 内置断线检测,客户端断开立即终止 generator
|
|
||||||
- 内置 ping 心跳,保持连接活跃
|
|
||||||
"""
|
|
||||||
qs = _get_quote_service(request)
|
|
||||||
|
|
||||||
async def event_generator():
|
|
||||||
while True:
|
|
||||||
# 同时等待三类信号: 行情更新 / 告警 / 五档修正
|
|
||||||
tasks: dict[str, asyncio.Future] = {
|
|
||||||
"quote": asyncio.ensure_future(
|
|
||||||
asyncio.to_thread(qs.wait_for_update, timeout=5.0) if qs else asyncio.sleep(5)
|
|
||||||
),
|
|
||||||
"alert": asyncio.ensure_future(
|
|
||||||
asyncio.to_thread(qs.wait_for_alert, timeout=5.0) if qs else asyncio.sleep(5)
|
|
||||||
),
|
|
||||||
"depth": asyncio.ensure_future(
|
|
||||||
asyncio.to_thread(qs.wait_for_depth_update, timeout=5.0) if qs else asyncio.sleep(5)
|
|
||||||
),
|
|
||||||
"review": asyncio.ensure_future(
|
|
||||||
asyncio.to_thread(qs.wait_for_review, timeout=5.0) if qs else asyncio.sleep(5)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
done, pending = await asyncio.wait(
|
|
||||||
list(tasks.values()),
|
|
||||||
timeout=30.0,
|
|
||||||
return_when=asyncio.FIRST_COMPLETED,
|
|
||||||
)
|
|
||||||
for t in pending:
|
|
||||||
t.cancel()
|
|
||||||
|
|
||||||
# 先推送告警 (如果有)
|
|
||||||
if qs:
|
|
||||||
alerts = qs.pop_alerts()
|
|
||||||
if alerts:
|
|
||||||
for chunk_start in range(0, len(alerts), 20):
|
|
||||||
chunk = alerts[chunk_start:chunk_start + 20]
|
|
||||||
yield {
|
|
||||||
"event": "strategy_alert",
|
|
||||||
"data": json.dumps({
|
|
||||||
"ts": int(time.time() * 1000),
|
|
||||||
"alerts": chunk,
|
|
||||||
}, ensure_ascii=False),
|
|
||||||
}
|
|
||||||
|
|
||||||
# 推送复盘进度 (定时复盘流式生成时) — 前端 reviewStore 直接消费
|
|
||||||
# 事件已是 recap_market_stream 产出的 JSON 字符串, 逐条转发
|
|
||||||
for evt_json in qs.pop_review_events():
|
|
||||||
yield {
|
|
||||||
"event": "review_progress",
|
|
||||||
"data": evt_json,
|
|
||||||
}
|
|
||||||
|
|
||||||
# 推送行情更新 (行情信号触发)
|
|
||||||
if tasks["quote"] in done:
|
|
||||||
try:
|
|
||||||
update_result = tasks["quote"].result()
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
update_result = False
|
|
||||||
if update_result:
|
|
||||||
yield {
|
|
||||||
"event": "quotes_updated",
|
|
||||||
"data": json.dumps({
|
|
||||||
"ts": int(time.time() * 1000),
|
|
||||||
"symbol_count": qs._symbol_count if qs else 0,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
|
|
||||||
# 推送五档修正完成 (depth 信号触发) — 前端刷新连板梯队封单数据
|
|
||||||
if tasks["depth"] in done:
|
|
||||||
try:
|
|
||||||
depth_result = tasks["depth"].result()
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
depth_result = False
|
|
||||||
if depth_result:
|
|
||||||
yield {
|
|
||||||
"event": "depth_updated",
|
|
||||||
"data": json.dumps({
|
|
||||||
"ts": int(time.time() * 1000),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
|
|
||||||
return EventSourceResponse(event_generator())
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/refresh")
|
|
||||||
def refresh_quotes(request: Request):
|
|
||||||
"""手动刷新一次行情数据。"""
|
|
||||||
qs = _get_quote_service(request)
|
|
||||||
if qs:
|
|
||||||
return qs.refresh()
|
|
||||||
return {"error": "QuoteService not available"}
|
|
||||||
@@ -137,16 +137,11 @@ def get_daily(
|
|||||||
logger.debug("单股除权因子拉取失败 %s: %s", symbol, e)
|
logger.debug("单股除权因子拉取失败 %s: %s", symbol, e)
|
||||||
enriched = compute_enriched(raw, factors=factors)
|
enriched = compute_enriched(raw, factors=factors)
|
||||||
rows = enriched.tail(days).to_dicts()
|
rows = enriched.tail(days).to_dicts()
|
||||||
# 即使 live 模式也尝试追加实时蜡烛
|
|
||||||
rows = _maybe_inject_live_candle(request, symbol, rows)
|
|
||||||
resp = {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": rows, "source": "live"}
|
resp = {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": rows, "source": "live"}
|
||||||
return _attach_ext(resp, repo, symbol, ext_columns)
|
return _attach_ext(resp, repo, symbol, ext_columns)
|
||||||
|
|
||||||
rows = df.to_dicts()
|
rows = df.to_dicts()
|
||||||
|
|
||||||
# 追加/覆盖今日实时蜡烛
|
|
||||||
rows = _maybe_inject_live_candle(request, symbol, rows)
|
|
||||||
|
|
||||||
resp = {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": rows, "source": "enriched"}
|
resp = {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": rows, "source": "enriched"}
|
||||||
return _attach_ext(resp, repo, symbol, ext_columns)
|
return _attach_ext(resp, repo, symbol, ext_columns)
|
||||||
|
|
||||||
@@ -155,7 +150,7 @@ def _attach_ext(resp: dict, repo, symbol: str, ext_columns: Optional[str]) -> di
|
|||||||
"""按 ext_columns 规格为单只股票 LEFT JOIN 扩展数据,平铺到 stock_info['ext']。
|
"""按 ext_columns 规格为单只股票 LEFT JOIN 扩展数据,平铺到 stock_info['ext']。
|
||||||
|
|
||||||
key 形如 "{config_id}__{field_name}",与自选列表 enriched 接口保持一致。
|
key 形如 "{config_id}__{field_name}",与自选列表 enriched 接口保持一致。
|
||||||
JOIN 逻辑参考 watchlist.watchlist_enriched;任何 ext 表/字段缺失都静默跳过。
|
JOIN 逻辑;任何 ext 表/字段缺失都静默跳过。
|
||||||
"""
|
"""
|
||||||
if not ext_columns or not ext_columns.strip():
|
if not ext_columns or not ext_columns.strip():
|
||||||
return resp
|
return resp
|
||||||
@@ -216,77 +211,6 @@ def _attach_ext(resp: dict, repo, symbol: str, ext_columns: Optional[str]) -> di
|
|||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
def _maybe_inject_live_candle(request: Request, symbol: str, rows: list[dict]) -> list[dict]:
|
|
||||||
"""如果 QuoteService 有实时 enriched 数据, 用实时数据生成今日蜡烛并追加/覆盖。"""
|
|
||||||
qs = getattr(request.app.state, "quote_service", None)
|
|
||||||
if not qs:
|
|
||||||
return rows
|
|
||||||
|
|
||||||
df_today, enriched_date = qs.get_enriched_today()
|
|
||||||
if df_today.is_empty():
|
|
||||||
return rows
|
|
||||||
|
|
||||||
# 非交易日(周末/假日)缓存的行情日期 != 今天,跳过注入避免产生重复蜡烛
|
|
||||||
if not enriched_date or enriched_date != date.today():
|
|
||||||
return rows
|
|
||||||
|
|
||||||
# 查找该 symbol 的实时 enriched 行
|
|
||||||
import polars as pl
|
|
||||||
try:
|
|
||||||
q = df_today.filter(pl.col("symbol") == symbol).to_dicts()
|
|
||||||
if not q:
|
|
||||||
return rows
|
|
||||||
q = q[0]
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
return rows
|
|
||||||
|
|
||||||
close_price = q.get("close")
|
|
||||||
if not close_price or close_price <= 0:
|
|
||||||
return rows
|
|
||||||
|
|
||||||
today_str = str(enriched_date)
|
|
||||||
|
|
||||||
# enriched 行已包含 OHLCV + 全套指标, 直接用它
|
|
||||||
# 修复: API 在非交易时段可能返回 open/high/low=0, 用 close 填充避免异常蜡烛
|
|
||||||
raw_open = q.get("open")
|
|
||||||
raw_high = q.get("high")
|
|
||||||
raw_low = q.get("low")
|
|
||||||
live_row: dict = {
|
|
||||||
"date": today_str,
|
|
||||||
"symbol": symbol,
|
|
||||||
"open": raw_open if raw_open and raw_open > 0 else close_price,
|
|
||||||
"high": raw_high if raw_high and raw_high > 0 else close_price,
|
|
||||||
"low": raw_low if raw_low and raw_low > 0 else close_price,
|
|
||||||
"close": close_price,
|
|
||||||
"volume": q.get("volume"),
|
|
||||||
"amount": q.get("amount"),
|
|
||||||
"change_pct": q.get("change_pct"),
|
|
||||||
"is_live": True,
|
|
||||||
}
|
|
||||||
# 补上 enriched 的技术指标字段
|
|
||||||
for key in ("ma5", "ma10", "ma20", "ma30", "ma60",
|
|
||||||
"macd_dif", "macd_dea", "macd_hist",
|
|
||||||
"kdj_k", "kdj_d", "kdj_j",
|
|
||||||
"boll_upper", "boll_lower",
|
|
||||||
"rsi_6", "rsi_14", "rsi_24",
|
|
||||||
"atr_14", "vol_ratio_5d"):
|
|
||||||
if key in q and q[key] is not None:
|
|
||||||
live_row[key] = q[key]
|
|
||||||
|
|
||||||
# 如果已有今天的 enriched 行, 覆盖; 否则追加
|
|
||||||
found = False
|
|
||||||
for i, r in enumerate(rows):
|
|
||||||
if str(r.get("date")) == today_str:
|
|
||||||
r.update(live_row)
|
|
||||||
found = True
|
|
||||||
break
|
|
||||||
|
|
||||||
if not found:
|
|
||||||
rows.append(live_row)
|
|
||||||
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
class DailyBatchRequest:
|
class DailyBatchRequest:
|
||||||
"""批量日K请求。"""
|
"""批量日K请求。"""
|
||||||
symbols: list[str]
|
symbols: list[str]
|
||||||
@@ -456,7 +380,7 @@ async def sync_minute(request: Request):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
progress("sync_minute", 5, "解析标的池…")
|
progress("sync_minute", 5, "解析标的池…")
|
||||||
universe = sorted(set(get_pool("watchlist")) | set(get_pool("CN_Equity_A")))
|
universe = sorted(set(get_pool("CN_Equity_A")))
|
||||||
# 补充 instruments 全量标的,覆盖北交所、新股等
|
# 补充 instruments 全量标的,覆盖北交所、新股等
|
||||||
inst_path = repo.store.data_dir / "instruments" / "instruments.parquet"
|
inst_path = repo.store.data_dir / "instruments" / "instruments.parquet"
|
||||||
if inst_path.exists():
|
if inst_path.exists():
|
||||||
|
|||||||
@@ -46,8 +46,6 @@ async def analyze_market(request: Request, req: AnalyzeRequest):
|
|||||||
from datetime import date as date_cls
|
from datetime import date as date_cls
|
||||||
|
|
||||||
repo = request.app.state.repo
|
repo = request.app.state.repo
|
||||||
quote_service = getattr(request.app.state, "quote_service", None)
|
|
||||||
depth_service = getattr(request.app.state, "depth_service", None)
|
|
||||||
|
|
||||||
as_of = None
|
as_of = None
|
||||||
if req.as_of:
|
if req.as_of:
|
||||||
@@ -57,7 +55,7 @@ async def analyze_market(request: Request, req: AnalyzeRequest):
|
|||||||
raise HTTPException(400, f"as_of 格式应为 YYYY-MM-DD,收到: {req.as_of}")
|
raise HTTPException(400, f"as_of 格式应为 YYYY-MM-DD,收到: {req.as_of}")
|
||||||
|
|
||||||
async def stream_gen():
|
async def stream_gen():
|
||||||
async for chunk in recap_market_stream(repo, quote_service, depth_service, as_of, req.focus):
|
async for chunk in recap_market_stream(repo, as_of, req.focus):
|
||||||
yield chunk + "\n"
|
yield chunk + "\n"
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
|
|||||||
@@ -1,499 +0,0 @@
|
|||||||
"""监控规则 API 路由 — HTTP 请求 → 调用 monitor_rules 模块 → 同步引擎内存态。
|
|
||||||
|
|
||||||
只做胶水: 校验 → 持久化 → 失效引擎内存态。不含评估逻辑。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from app.strategy import monitor_rules
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/monitor-rules", tags=["monitor-rules"])
|
|
||||||
|
|
||||||
|
|
||||||
def _data_dir(request: Request) -> Path:
|
|
||||||
return request.app.state.repo.store.data_dir
|
|
||||||
|
|
||||||
|
|
||||||
def _sync_engine(request: Request) -> None:
|
|
||||||
"""保存/删除后,把最新规则集 reload 到引擎内存态。"""
|
|
||||||
engine = getattr(request.app.state, "monitor_engine", None)
|
|
||||||
if engine is not None:
|
|
||||||
rules = monitor_rules.load_all(_data_dir(request))
|
|
||||||
engine.set_rules(rules)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Pydantic 模型 ───────────────────────────────────────
|
|
||||||
class ConditionModel(BaseModel):
|
|
||||||
field: str
|
|
||||||
op: str # truth | > >= < <= == !=
|
|
||||||
value: float | None = None # op 非 truth 时必填
|
|
||||||
|
|
||||||
|
|
||||||
class RuleModel(BaseModel):
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
enabled: bool = True
|
|
||||||
type: str # strategy | signal | price | market
|
|
||||||
scope: str = "symbols" # symbols | all | sector
|
|
||||||
symbols: list[str] = []
|
|
||||||
sector: str | None = None
|
|
||||||
strategy_id: str | None = None
|
|
||||||
direction: str = "entry" # entry | exit | both
|
|
||||||
conditions: list[ConditionModel] = []
|
|
||||||
logic: str = "and" # and | or
|
|
||||||
cooldown_seconds: int = 3600
|
|
||||||
severity: str = "info" # info | warn | critical
|
|
||||||
webhook_url: str = "" # Webhook 推送地址 (推送到 QMT 等外部软件, 待定)
|
|
||||||
webhook_enabled: bool = False
|
|
||||||
message: str = ""
|
|
||||||
# ladder 专属 (连板梯队封单监控)
|
|
||||||
metric: str = "sealed_vol" # sealed_vol=封单量(手) | sealed_amount=封单额(元)
|
|
||||||
threshold: float = 0 # 封单 <= 此值时报警 (原始单位: 量=手, 额=元)
|
|
||||||
|
|
||||||
|
|
||||||
# ── 字段选项 ─────────────────────────────────────────────
|
|
||||||
@router.get("/options")
|
|
||||||
def get_options(request: Request):
|
|
||||||
"""返回可选字段、信号列、运算符、枚举,供前端表单使用。"""
|
|
||||||
from app.indicators.pipeline import ENRICHED_COLUMNS
|
|
||||||
from app.strategy.custom_signals import ALLOWED_FIELDS, load_all as load_csg
|
|
||||||
|
|
||||||
# 阈值字段 (带中文标签)
|
|
||||||
threshold_fields = [
|
|
||||||
{"key": f, "label": ENRICHED_COLUMNS.get(f, f)}
|
|
||||||
for f in sorted(ALLOWED_FIELDS)
|
|
||||||
]
|
|
||||||
# 内置信号列 (布尔, 用于 op=truth)
|
|
||||||
builtin_signals = [
|
|
||||||
{"key": k, "label": v}
|
|
||||||
for k, v in ENRICHED_COLUMNS.items()
|
|
||||||
if k.startswith("signal_")
|
|
||||||
]
|
|
||||||
# 自定义信号列 (csg_)
|
|
||||||
custom_sigs = []
|
|
||||||
try:
|
|
||||||
for cs in load_csg(_data_dir(request)):
|
|
||||||
if cs.get("enabled") is not False:
|
|
||||||
custom_sigs.append({
|
|
||||||
"key": f"csg_{cs['id']}",
|
|
||||||
"label": cs.get("name", cs["id"]),
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {
|
|
||||||
"threshold_fields": threshold_fields,
|
|
||||||
"builtin_signals": builtin_signals,
|
|
||||||
"custom_signals": custom_sigs,
|
|
||||||
"operators": [">", ">=", "<", "<=", "==", "!="],
|
|
||||||
"types": [
|
|
||||||
{"key": "signal", "label": "个股信号"},
|
|
||||||
{"key": "price", "label": "价格/涨跌"},
|
|
||||||
{"key": "market", "label": "市场异动"},
|
|
||||||
{"key": "strategy", "label": "策略监控"},
|
|
||||||
],
|
|
||||||
"scopes": [
|
|
||||||
{"key": "symbols", "label": "指定股票"},
|
|
||||||
{"key": "all", "label": "全市场"},
|
|
||||||
{"key": "sector", "label": "板块"},
|
|
||||||
],
|
|
||||||
"logics": [
|
|
||||||
{"key": "and", "label": "全部满足 (AND)"},
|
|
||||||
{"key": "or", "label": "任一满足 (OR)"},
|
|
||||||
],
|
|
||||||
"severities": [
|
|
||||||
{"key": "info", "label": "普通"},
|
|
||||||
{"key": "warn", "label": "警告"},
|
|
||||||
{"key": "critical", "label": "重要"},
|
|
||||||
],
|
|
||||||
"directions": [
|
|
||||||
{"key": "entry", "label": "买入"},
|
|
||||||
{"key": "exit", "label": "卖出"},
|
|
||||||
{"key": "both", "label": "买卖都报"},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ── 列表 ───────────────────────────────────────────────
|
|
||||||
@router.get("")
|
|
||||||
def list_rules(request: Request):
|
|
||||||
rules = monitor_rules.load_all(_data_dir(request))
|
|
||||||
# 按 created_at 倒序
|
|
||||||
rules.sort(key=lambda r: r.get("created_at", ""), reverse=True)
|
|
||||||
return {"rules": rules}
|
|
||||||
|
|
||||||
|
|
||||||
# ── 新建 / 更新 ────────────────────────────────────────
|
|
||||||
@router.post("")
|
|
||||||
def save_rule(req: RuleModel, request: Request):
|
|
||||||
rule = monitor_rules.normalize(req.model_dump())
|
|
||||||
# 连板梯队封单监控 (type=ladder) 依赖五档盘口数据, 需 Pro+ (DEPTH5_BATCH 能力)。
|
|
||||||
# 无能力时拒绝创建, 避免规则存了却永远无法触发。
|
|
||||||
if rule.get("type") == "ladder":
|
|
||||||
from app.tickflow.capabilities import Cap
|
|
||||||
capset = getattr(request.app.state, "capabilities", None)
|
|
||||||
if capset is None or not capset.has(Cap.DEPTH5_BATCH):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="封单监控需要 Pro+ 套餐 (批量五档能力),请升级后在「设置」页配置",
|
|
||||||
)
|
|
||||||
# 编辑现有规则时, 保留原 created_at (避免按时间排序时位置跳动)
|
|
||||||
existing = monitor_rules.load_one(_data_dir(request), rule["id"])
|
|
||||||
if existing and existing.get("created_at"):
|
|
||||||
rule["created_at"] = existing["created_at"]
|
|
||||||
try:
|
|
||||||
monitor_rules.validate(rule)
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
monitor_rules.save_one(_data_dir(request), rule)
|
|
||||||
_sync_engine(request)
|
|
||||||
return {"ok": True, "rule": rule}
|
|
||||||
|
|
||||||
|
|
||||||
# ── 删除 ───────────────────────────────────────────────
|
|
||||||
@router.delete("/{rule_id}")
|
|
||||||
def delete_rule(rule_id: str, request: Request):
|
|
||||||
if not monitor_rules.ID_RE.match(rule_id):
|
|
||||||
raise HTTPException(status_code=400, detail="规则 id 非法")
|
|
||||||
deleted = monitor_rules.delete_one(_data_dir(request), rule_id)
|
|
||||||
if not deleted:
|
|
||||||
raise HTTPException(status_code=404, detail="规则不存在")
|
|
||||||
_sync_engine(request)
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
# ── 演示数据生成 (仅 Dev 页用) ─────────────────────────
|
|
||||||
|
|
||||||
import time as _time
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
|
|
||||||
def _demo_rule(rule_id: str, name: str, rtype: str, scope: str, symbols: list[str],
|
|
||||||
conditions: list[dict], logic: str = "or", cooldown: int = 3600,
|
|
||||||
severity: str = "info", message: str = "",
|
|
||||||
strategy_id: str | None = None, direction: str = "entry") -> dict:
|
|
||||||
rule = monitor_rules.normalize({
|
|
||||||
"id": rule_id,
|
|
||||||
"name": name,
|
|
||||||
"type": rtype,
|
|
||||||
"scope": scope,
|
|
||||||
"symbols": symbols,
|
|
||||||
"conditions": conditions,
|
|
||||||
"logic": logic,
|
|
||||||
"cooldown_seconds": cooldown,
|
|
||||||
"severity": severity,
|
|
||||||
"message": message,
|
|
||||||
"enabled": True,
|
|
||||||
})
|
|
||||||
if rtype == "strategy":
|
|
||||||
rule["strategy_id"] = strategy_id
|
|
||||||
rule["direction"] = direction
|
|
||||||
return rule
|
|
||||||
|
|
||||||
|
|
||||||
_DEMO_RULES_TEMPLATE = [
|
|
||||||
("个股信号 · 茅台放量突破", "signal", "symbols", ["600519.SH"],
|
|
||||||
[{"field": "signal_volume_surge", "op": "truth"},
|
|
||||||
{"field": "signal_n_day_high", "op": "truth"}], "or", "info"),
|
|
||||||
("个股信号 · 宁德金叉", "signal", "symbols", ["300750.SZ"],
|
|
||||||
[{"field": "signal_ma_golden_5_20", "op": "truth"}], "or", "info"),
|
|
||||||
("价格 · 平安跌幅监控", "price", "symbols", ["000001.SZ"],
|
|
||||||
[{"field": "change_pct", "op": "<", "value": -0.03}], "or", "warn", "warn"),
|
|
||||||
("价格 · 比亚迪RSI超卖", "price", "symbols", ["002594.SZ"],
|
|
||||||
[{"field": "rsi_14", "op": "<", "value": 30}], "and", "warn", "warn"),
|
|
||||||
("市场异动 · 全市场涨停", "market", "all", [],
|
|
||||||
[{"field": "signal_limit_up", "op": "truth"}], "or", "critical", "critical"),
|
|
||||||
("市场异动 · 全市场炸板", "market", "all", [],
|
|
||||||
[{"field": "signal_broken_limit_up", "op": "truth"}], "or", "warn", "warn"),
|
|
||||||
("市场异动 · 跌幅超5%", "market", "all", [],
|
|
||||||
[{"field": "change_pct", "op": "<", "value": -0.05}], "or", "warn", "warn"),
|
|
||||||
("个股信号 · 茅台跌破MA20", "signal", "symbols", ["600519.SH"],
|
|
||||||
[{"field": "signal_ma20_breakdown", "op": "truth"}], "or", "info"),
|
|
||||||
]
|
|
||||||
|
|
||||||
# 策略类型单独声明 (格式不同: 含 strategy_id + direction)
|
|
||||||
_DEMO_STRATEGY_RULES: list[dict] = [
|
|
||||||
{"name": "策略监控 · 趋势突破", "strategy_id": "trend_breakout", "direction": "entry"},
|
|
||||||
{"name": "策略监控 · MACD金叉", "strategy_id": "macd_golden", "direction": "both"},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/seed")
|
|
||||||
def seed_demo_rules(request: Request):
|
|
||||||
"""生成演示监控规则 (Dev 页用)。覆盖 signal/price/market/strategy 四类。"""
|
|
||||||
ts = int(_time.time() * 1000)
|
|
||||||
created = []
|
|
||||||
i = 0
|
|
||||||
for (name, rtype, scope, symbols, conditions, logic, severity, sev) in _DEMO_RULES_TEMPLATE:
|
|
||||||
rule_id = f"demo_{ts}_{i}"
|
|
||||||
rule = _demo_rule(rule_id, name, rtype, scope, symbols, conditions, logic, 3600, sev)
|
|
||||||
monitor_rules.save_one(_data_dir(request), rule)
|
|
||||||
created.append(rule_id)
|
|
||||||
i += 1
|
|
||||||
# 策略类型规则
|
|
||||||
for sr in _DEMO_STRATEGY_RULES:
|
|
||||||
rule_id = f"demo_{ts}_{i}"
|
|
||||||
rule = _demo_rule(
|
|
||||||
rule_id, sr["name"], "strategy", "all", [], [], "and", 3600, "info",
|
|
||||||
strategy_id=sr["strategy_id"], direction=sr.get("direction", "entry"),
|
|
||||||
)
|
|
||||||
monitor_rules.save_one(_data_dir(request), rule)
|
|
||||||
created.append(rule_id)
|
|
||||||
i += 1
|
|
||||||
_sync_engine(request)
|
|
||||||
return {"ok": True, "generated": len(created), "ids": created}
|
|
||||||
|
|
||||||
|
|
||||||
# ── 封单监控模拟触发 (Dev 调试用) ─────────────────────
|
|
||||||
@router.post("/test-ladder")
|
|
||||||
def test_ladder(request: Request):
|
|
||||||
"""模拟触发所有 ladder 规则, 返回命中结果 (不落盘、不推送飞书)。
|
|
||||||
|
|
||||||
用当前 depth_service 的封单数据 + enriched 最新日 close 构造 mock DataFrame,
|
|
||||||
跑 _evaluate_ladder 判断哪些规则会触发。供 Dev 页面调试验证。
|
|
||||||
"""
|
|
||||||
import polars as pl
|
|
||||||
|
|
||||||
repo = request.app.state.repo
|
|
||||||
depth_svc = getattr(request.app.state, "depth_service", None)
|
|
||||||
engine = getattr(request.app.state, "monitor_engine", None)
|
|
||||||
|
|
||||||
if not depth_svc:
|
|
||||||
raise HTTPException(status_code=503, detail="depth 服务未初始化")
|
|
||||||
if not engine or not engine.has_rule_type("ladder"):
|
|
||||||
raise HTTPException(status_code=400, detail="无 ladder 类型监控规则")
|
|
||||||
|
|
||||||
# 最新交易日
|
|
||||||
latest = repo.enriched_latest_date()
|
|
||||||
if not latest:
|
|
||||||
raise HTTPException(status_code=400, detail="无 enriched 数据")
|
|
||||||
|
|
||||||
# 取涨停+跌停封单 {symbol: vol}
|
|
||||||
sealed: dict[str, int] = {}
|
|
||||||
for is_down in (False, True):
|
|
||||||
m = depth_svc.get_sealed_map(latest, is_down=is_down)
|
|
||||||
for sym, info in m.items():
|
|
||||||
vol = (info or {}).get("vol")
|
|
||||||
if vol and vol > 0:
|
|
||||||
sealed[sym] = vol
|
|
||||||
|
|
||||||
if not sealed:
|
|
||||||
raise HTTPException(status_code=400, detail="无封单数据 (depth 未拉取或无涨停/跌停股)")
|
|
||||||
|
|
||||||
# 取这些 symbol 的 close (算封单额用)
|
|
||||||
enriched_today, _ = repo.get_enriched_latest()
|
|
||||||
cols = ["symbol", "close", "change_pct"]
|
|
||||||
avail = [c for c in cols if c in enriched_today.columns]
|
|
||||||
mock = enriched_today.select(avail).filter(pl.col("symbol").is_in(list(sealed.keys())))
|
|
||||||
|
|
||||||
# 注入 _sealed_vol
|
|
||||||
sealed_df = pl.DataFrame({
|
|
||||||
"symbol": list(sealed.keys()),
|
|
||||||
"_sealed_vol": list(sealed.values()),
|
|
||||||
})
|
|
||||||
mock = mock.join(sealed_df, on="symbol", how="inner")
|
|
||||||
|
|
||||||
# 取所有 ladder 规则, 逐条纯条件判断 (绕过引擎 cooldown, 不污染 _last_fire)
|
|
||||||
ladder_rules = [r for r in engine.rules.values() if r.get("type") == "ladder" and r.get("enabled", True)]
|
|
||||||
all_events = []
|
|
||||||
not_triggered = []
|
|
||||||
|
|
||||||
for rule in ladder_rules:
|
|
||||||
syms = rule.get("symbols", [])
|
|
||||||
sym = syms[0] if syms else None
|
|
||||||
metric = rule.get("metric", "sealed_vol")
|
|
||||||
thr = rule.get("threshold", 0)
|
|
||||||
direction = rule.get("direction", "up")
|
|
||||||
warn_label = "炸板预警" if direction == "up" else "翘板预警"
|
|
||||||
|
|
||||||
# 取该 symbol 的封单数据
|
|
||||||
cur_vol = sealed.get(sym) if sym else None
|
|
||||||
row = mock.filter(pl.col("symbol") == sym) if sym else mock.clear()
|
|
||||||
cur_close = row["close"][0] if len(row) and "close" in row.columns else None
|
|
||||||
cur_amt = (cur_vol * 100 * cur_close) if (cur_vol and cur_close) else None
|
|
||||||
cur_val = cur_amt if metric == "sealed_amount" else cur_vol
|
|
||||||
|
|
||||||
# 条件判断: 封单 > 0 且 比较值 <= 阈值
|
|
||||||
if cur_val is not None and cur_val > 0 and cur_val <= thr:
|
|
||||||
if metric == "sealed_amount":
|
|
||||||
sv_text = f"{cur_val / 1e4:.0f}万元"
|
|
||||||
th_text = f"{thr / 1e4:.0f}万元"
|
|
||||||
else:
|
|
||||||
sv_text = f"{cur_val:,.0f} 手"
|
|
||||||
th_text = f"{thr:,.0f} 手"
|
|
||||||
all_events.append({
|
|
||||||
"rule_id": rule["id"],
|
|
||||||
"rule_name": rule.get("name", ""),
|
|
||||||
"symbol": sym,
|
|
||||||
"name": sym,
|
|
||||||
"type": warn_label,
|
|
||||||
"message": f"{warn_label} · 封单 {sv_text} ≤ {th_text}",
|
|
||||||
"severity": rule.get("severity", "warn"),
|
|
||||||
"sealed_value": cur_val,
|
|
||||||
"sealed_metric": metric,
|
|
||||||
"current_sealed_vol": cur_vol,
|
|
||||||
"current_sealed_amount": cur_amt,
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
reason = "封单数据缺失" if cur_val is None else (
|
|
||||||
f"封单 {cur_val:,.0f} > 阈值 {thr:,.0f}" if cur_val > thr else "封单为 0"
|
|
||||||
)
|
|
||||||
not_triggered.append({
|
|
||||||
"rule_id": rule["id"],
|
|
||||||
"rule_name": rule.get("name", ""),
|
|
||||||
"symbol": sym,
|
|
||||||
"metric": metric,
|
|
||||||
"threshold": thr,
|
|
||||||
"current_value": cur_val,
|
|
||||||
"current_sealed_vol": cur_vol,
|
|
||||||
"current_sealed_amount": cur_amt,
|
|
||||||
"reason": reason,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"as_of": str(latest),
|
|
||||||
"sealed_count": len(sealed),
|
|
||||||
"triggered": all_events,
|
|
||||||
"not_triggered": not_triggered,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/trigger-ladder")
|
|
||||||
def trigger_ladder(request: Request):
|
|
||||||
"""真实触发一次 ladder 预警 (落盘 + 飞书推送 + SSE), 供 Dev 调试验证完整效果。
|
|
||||||
|
|
||||||
与 test-ladder 区别: 本端点会真的把预警写入 alerts.jsonl、推送飞书、触发 SSE,
|
|
||||||
让用户看到真实的预警通知。绕过 cooldown 强制触发。
|
|
||||||
"""
|
|
||||||
import time
|
|
||||||
from app.services import alert_store
|
|
||||||
|
|
||||||
repo = request.app.state.repo
|
|
||||||
depth_svc = getattr(request.app.state, "depth_service", None)
|
|
||||||
engine = getattr(request.app.state, "monitor_engine", None)
|
|
||||||
quote_svc = getattr(request.app.state, "quote_service", None)
|
|
||||||
|
|
||||||
if not depth_svc:
|
|
||||||
raise HTTPException(status_code=503, detail="depth 服务未初始化")
|
|
||||||
if not engine or not engine.has_rule_type("ladder"):
|
|
||||||
raise HTTPException(status_code=400, detail="无 ladder 类型监控规则")
|
|
||||||
|
|
||||||
latest = repo.enriched_latest_date()
|
|
||||||
if not latest:
|
|
||||||
raise HTTPException(status_code=400, detail="无 enriched 数据")
|
|
||||||
|
|
||||||
# 取封单
|
|
||||||
sealed: dict[str, int] = {}
|
|
||||||
for is_down in (False, True):
|
|
||||||
m = depth_svc.get_sealed_map(latest, is_down=is_down)
|
|
||||||
for sym, info in m.items():
|
|
||||||
vol = (info or {}).get("vol")
|
|
||||||
if vol and vol > 0:
|
|
||||||
sealed[sym] = vol
|
|
||||||
if not sealed:
|
|
||||||
raise HTTPException(status_code=400, detail="无封单数据")
|
|
||||||
|
|
||||||
# 构造真实 rule_events (与 _evaluate_ladder 产出格式一致)
|
|
||||||
import polars as pl
|
|
||||||
enriched_today, _ = repo.get_enriched_latest()
|
|
||||||
cols = [c for c in ["symbol", "close", "change_pct"] if c in enriched_today.columns]
|
|
||||||
mock = enriched_today.select(cols).filter(pl.col("symbol").is_in(list(sealed.keys())))
|
|
||||||
sealed_df = pl.DataFrame({"symbol": list(sealed.keys()), "_sealed_vol": list(sealed.values())})
|
|
||||||
mock = mock.join(sealed_df, on="symbol", how="inner")
|
|
||||||
|
|
||||||
now = time.time()
|
|
||||||
rule_events: list[dict] = []
|
|
||||||
name_map = {}
|
|
||||||
try:
|
|
||||||
inst = repo.get_instruments()
|
|
||||||
if not inst.is_empty() and "name" in inst.columns:
|
|
||||||
name_map = {r["symbol"]: r["name"] for r in inst.select(["symbol", "name"]).iter_rows(named=True) if r.get("name")}
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
pass
|
|
||||||
|
|
||||||
for rule in engine.rules.values():
|
|
||||||
if rule.get("type") != "ladder" or not rule.get("enabled", True):
|
|
||||||
continue
|
|
||||||
sym = rule.get("symbols", [""])[0] if rule.get("symbols") else ""
|
|
||||||
metric = rule.get("metric", "sealed_vol")
|
|
||||||
thr = rule.get("threshold", 0)
|
|
||||||
direction = rule.get("direction", "up")
|
|
||||||
warn_label = "炸板预警" if direction == "up" else "翘板预警"
|
|
||||||
|
|
||||||
row = mock.filter(pl.col("symbol") == sym)
|
|
||||||
if row.is_empty():
|
|
||||||
continue
|
|
||||||
cur_vol = row["_sealed_vol"][0]
|
|
||||||
close_v = row["close"][0] if "close" in row.columns else None
|
|
||||||
cur_val = cur_vol * 100 * close_v if metric == "sealed_amount" else cur_vol
|
|
||||||
if not cur_val or cur_val <= 0 or cur_val > thr:
|
|
||||||
continue # 不满足条件, 跳过
|
|
||||||
|
|
||||||
if metric == "sealed_amount":
|
|
||||||
sv_text = f"{cur_val / 1e4:.0f}万元"
|
|
||||||
th_text = f"{thr / 1e4:.0f}万元"
|
|
||||||
else:
|
|
||||||
sv_text = f"{cur_val:,.0f} 手"
|
|
||||||
th_text = f"{thr:,.0f} 手"
|
|
||||||
|
|
||||||
rule_events.append({
|
|
||||||
"ts": int(now * 1000),
|
|
||||||
"rule_id": rule["id"],
|
|
||||||
"rule_name": rule.get("name", ""),
|
|
||||||
"source": "ladder",
|
|
||||||
"type": warn_label,
|
|
||||||
"symbol": sym,
|
|
||||||
"name": name_map.get(sym, sym),
|
|
||||||
"message": f"{warn_label} · 封单 {sv_text} ≤ {th_text}",
|
|
||||||
"price": close_v,
|
|
||||||
"change_pct": row["change_pct"][0] if "change_pct" in row.columns else None,
|
|
||||||
"signals": [],
|
|
||||||
"severity": rule.get("severity", "warn"),
|
|
||||||
"conditions": [],
|
|
||||||
"logic": "and",
|
|
||||||
"sealed_value": cur_val,
|
|
||||||
"sealed_metric": metric,
|
|
||||||
})
|
|
||||||
|
|
||||||
if not rule_events:
|
|
||||||
raise HTTPException(status_code=400, detail="当前无 ladder 规则满足触发条件 (封单均 > 阈值)")
|
|
||||||
|
|
||||||
# 1. 落盘到 alerts.jsonl
|
|
||||||
try:
|
|
||||||
alert_store.append_many(repo.store.data_dir, rule_events)
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
pass # 落盘失败不阻断推送
|
|
||||||
|
|
||||||
# 2. SSE 推送 (入 pending_alerts 队列)
|
|
||||||
if quote_svc:
|
|
||||||
sse_alerts = [{
|
|
||||||
"source": ev["source"], "type": ev["type"], "rule_id": ev["rule_id"],
|
|
||||||
"strategy_id": None, "symbol": ev["symbol"], "name": ev["name"],
|
|
||||||
"message": ev["message"], "price": ev["price"], "change_pct": ev["change_pct"],
|
|
||||||
"signals": ev["signals"], "severity": ev["severity"],
|
|
||||||
"conditions": ev["conditions"], "logic": ev["logic"],
|
|
||||||
} for ev in rule_events]
|
|
||||||
try:
|
|
||||||
with quote_svc._lock:
|
|
||||||
quote_svc._pending_alerts.extend(sse_alerts)
|
|
||||||
quote_svc._alert_event.set()
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 3. 飞书推送
|
|
||||||
if quote_svc:
|
|
||||||
try:
|
|
||||||
quote_svc._maybe_send_webhook(rule_events, engine)
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"triggered": len(rule_events),
|
|
||||||
"events": [{"symbol": ev["symbol"], "name": ev["name"], "message": ev["message"]} for ev in rule_events],
|
|
||||||
}
|
|
||||||
@@ -222,22 +222,8 @@ def _score(value: float, low: float, high: float) -> int:
|
|||||||
return max(0, min(100, round((value - low) / (high - low) * 100)))
|
return max(0, min(100, round((value - low) / (high - low) * 100)))
|
||||||
|
|
||||||
|
|
||||||
def _quote_status(request: Request) -> dict:
|
|
||||||
qs = getattr(request.app.state, "quote_service", None)
|
|
||||||
if not qs:
|
|
||||||
return {"enabled": False, "running": False, "quote_age_ms": None, "is_trading_hours": False}
|
|
||||||
return qs.status()
|
|
||||||
|
|
||||||
|
|
||||||
def _index_quotes(request: Request, as_of: date | None = None) -> list[dict]:
|
def _index_quotes(request: Request, as_of: date | None = None) -> list[dict]:
|
||||||
qs = getattr(request.app.state, "quote_service", None)
|
|
||||||
rows: list[dict] = []
|
rows: list[dict] = []
|
||||||
if qs and as_of is None:
|
|
||||||
df = qs.get_index_quotes(list(CORE_INDEX_SYMBOLS))
|
|
||||||
if not df.is_empty():
|
|
||||||
rows = df.to_dicts()
|
|
||||||
|
|
||||||
if not rows:
|
|
||||||
repo = getattr(request.app.state, "repo", None)
|
repo = getattr(request.app.state, "repo", None)
|
||||||
if repo:
|
if repo:
|
||||||
placeholders = ", ".join("?" for _ in CORE_INDEX_SYMBOLS)
|
placeholders = ", ".join("?" for _ in CORE_INDEX_SYMBOLS)
|
||||||
@@ -351,8 +337,6 @@ def _build_overview(request: Request, as_of: date | None = None) -> dict:
|
|||||||
from app.services.market_overview_builder import build_market_overview
|
from app.services.market_overview_builder import build_market_overview
|
||||||
return build_market_overview(
|
return build_market_overview(
|
||||||
repo=request.app.state.repo,
|
repo=request.app.state.repo,
|
||||||
quote_service=getattr(request.app.state, "quote_service", None),
|
|
||||||
depth_service=getattr(request.app.state, "depth_service", None),
|
|
||||||
as_of=as_of,
|
as_of=as_of,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -50,13 +50,11 @@ async def analyze_rotation(request: Request, req: AnalyzeRequest):
|
|||||||
{"type":"done"}
|
{"type":"done"}
|
||||||
"""
|
"""
|
||||||
repo = request.app.state.repo
|
repo = request.app.state.repo
|
||||||
quote_service = getattr(request.app.state, "quote_service", None)
|
|
||||||
depth_service = getattr(request.app.state, "depth_service", None)
|
|
||||||
days = max(7, min(30, req.days))
|
days = max(7, min(30, req.days))
|
||||||
|
|
||||||
async def stream_gen():
|
async def stream_gen():
|
||||||
async for chunk in analyze_rotation_stream(
|
async for chunk in analyze_rotation_stream(
|
||||||
repo, days, req.focus, quote_service, depth_service,
|
repo, days, req.focus,
|
||||||
):
|
):
|
||||||
yield chunk + "\n"
|
yield chunk + "\n"
|
||||||
|
|
||||||
|
|||||||
@@ -278,32 +278,16 @@ def get_cached(
|
|||||||
request: Request,
|
request: Request,
|
||||||
ext_columns: Optional[str] = Query(None, description="逗号分隔: config_id.field_name"),
|
ext_columns: Optional[str] = Query(None, description="逗号分隔: config_id.field_name"),
|
||||||
):
|
):
|
||||||
"""读取策略结果缓存, 并叠加监控引擎本轮实时算出的结果。
|
"""读取策略结果缓存。
|
||||||
|
|
||||||
- 盘后缓存 (strategy_cache.json): 非监控策略 / 页面秒加载用, run_all 写入。
|
- 盘后缓存 (strategy_cache.json): run_all 写入, 页面秒加载用。
|
||||||
- 监控引擎内存结果 (latest_strategy_results): 实时行情每轮对「加入监控的策略」算出,
|
|
||||||
不落盘 (避免与 read_cache 的 mtime 校验冲突), 在此直接叠加覆盖盘后结果。
|
|
||||||
被监控的策略拿到新鲜数据, 非监控策略仍用盘后缓存。
|
|
||||||
"""
|
"""
|
||||||
data_dir = request.app.state.repo.store.data_dir
|
data_dir = request.app.state.repo.store.data_dir
|
||||||
cached = strategy_cache.read_cache(data_dir)
|
cached = strategy_cache.read_cache(data_dir)
|
||||||
if cached is None:
|
if cached is None:
|
||||||
cached = {"as_of": None, "results": {}, "updated_at": None}
|
cached = {"as_of": None, "results": {}, "updated_at": None}
|
||||||
|
|
||||||
# 叠加监控引擎内存里的实时结果 (若有), 用新鲜数据覆盖同策略的盘后结果
|
# 无任何数据 → 返回空标记, 前端据此提示
|
||||||
monitor_engine = getattr(request.app.state, "monitor_engine", None)
|
|
||||||
if monitor_engine is not None:
|
|
||||||
realtime_results = monitor_engine.latest_strategy_results()
|
|
||||||
if realtime_results:
|
|
||||||
results = dict(cached.get("results") or {})
|
|
||||||
results.update(realtime_results)
|
|
||||||
cached = dict(cached)
|
|
||||||
cached["results"] = results
|
|
||||||
# 有实时数据时, 以最新时间戳为准
|
|
||||||
import time as _time
|
|
||||||
cached["updated_at"] = int(_time.time() * 1000)
|
|
||||||
|
|
||||||
# 无任何数据 (盘后缓存空 + 无实时结果) → 返回空标记, 前端据此提示
|
|
||||||
if not cached.get("results") and cached.get("as_of") is None:
|
if not cached.get("results") and cached.get("as_of") is None:
|
||||||
return {"as_of": None, "results": {}, "updated_at": None}
|
return {"as_of": None, "results": {}, "updated_at": None}
|
||||||
|
|
||||||
@@ -503,34 +487,8 @@ def limit_ladder(
|
|||||||
count_up_raw = int(df.filter(pl.col("signal_limit_up").fill_null(False)).height) if "signal_limit_up" in df.columns else 0
|
count_up_raw = int(df.filter(pl.col("signal_limit_up").fill_null(False)).height) if "signal_limit_up" in df.columns else 0
|
||||||
count_down_raw = int(df.filter(pl.col("signal_limit_down").fill_null(False)).height) if "signal_limit_down" in df.columns else 0
|
count_down_raw = int(df.filter(pl.col("signal_limit_down").fill_null(False)).height) if "signal_limit_down" in df.columns else 0
|
||||||
|
|
||||||
# 双方向 sealed 修正: 减去各自的假涨停(假涨停已归炸板, 不计入涨停数)
|
count_up = count_up_raw
|
||||||
depth_svc_global = getattr(request.app.state, "depth_service", None)
|
count_down = count_down_raw
|
||||||
fake_up = 0
|
|
||||||
fake_down = 0
|
|
||||||
sealed_up_ready = False
|
|
||||||
sealed_down_ready = False
|
|
||||||
if depth_svc_global:
|
|
||||||
up_map = depth_svc_global.get_sealed_map(as_of, is_down=False)
|
|
||||||
down_map = depth_svc_global.get_sealed_map(as_of, is_down=True)
|
|
||||||
sealed_up_ready = bool(up_map) and depth_svc_global.is_sealed_ready(as_of)
|
|
||||||
sealed_down_ready = bool(down_map) and depth_svc_global.is_sealed_ready(as_of)
|
|
||||||
if up_map:
|
|
||||||
fake_up = sum(1 for v in up_map.values() if v.get("sealed") is False)
|
|
||||||
if down_map:
|
|
||||||
fake_down = sum(1 for v in down_map.values() if v.get("sealed") is False)
|
|
||||||
count_up = count_up_raw - fake_up if sealed_up_ready else count_up_raw
|
|
||||||
count_down = count_down_raw - fake_down if sealed_down_ready else count_down_raw
|
|
||||||
|
|
||||||
# 双方向 sealed 明细(供前端弹窗同时显示涨跌停)
|
|
||||||
def _count_sealed(m: dict, ready: bool):
|
|
||||||
if not m or not ready:
|
|
||||||
return {"real": 0, "fake": 0, "pending": 0}
|
|
||||||
real = sum(1 for v in m.values() if v.get("sealed") is True)
|
|
||||||
fake = sum(1 for v in m.values() if v.get("sealed") is False)
|
|
||||||
pending = sum(1 for v in m.values() if v.get("sealed") is None)
|
|
||||||
return {"real": real, "fake": fake, "pending": pending}
|
|
||||||
sealed_counts_up = _count_sealed(up_map, sealed_up_ready)
|
|
||||||
sealed_counts_down = _count_sealed(down_map, sealed_down_ready)
|
|
||||||
|
|
||||||
# 加载前一日数据获取 prev consecutive_limit_ups/downs
|
# 加载前一日数据获取 prev consecutive_limit_ups/downs
|
||||||
prev_consec: pl.DataFrame = pl.DataFrame()
|
prev_consec: pl.DataFrame = pl.DataFrame()
|
||||||
@@ -569,65 +527,7 @@ def limit_ladder(
|
|||||||
|
|
||||||
df = df.filter(pl.col("status").is_not_null() & (pl.col("boards") > 0))
|
df = df.filter(pl.col("status").is_not_null() & (pl.col("boards") > 0))
|
||||||
|
|
||||||
# ── 五档 sealed 叠加(独立旁路, 不改 signal_limit_up) ──
|
# Add null columns for sealed_status and sealed_vol (no longer used)
|
||||||
# 假涨停(收盘价=涨停价但卖一有量)从 limit 降级为 broken(归炸板视图)
|
|
||||||
# 真涨停保留 + 附封单量; sealed=null(待确认/降级)保持原状
|
|
||||||
depth_svc = getattr(request.app.state, "depth_service", None)
|
|
||||||
sealed_ready = False
|
|
||||||
sealed_age: float | None = None
|
|
||||||
if depth_svc:
|
|
||||||
sealed_map = depth_svc.get_sealed_map(as_of, is_down=is_down)
|
|
||||||
sealed_ready = bool(sealed_map) and depth_svc.is_sealed_ready(as_of)
|
|
||||||
sealed_age = depth_svc.get_sealed_age(as_of) if sealed_ready else None
|
|
||||||
|
|
||||||
if sealed_map:
|
|
||||||
# 构建 sealed 列(symbol → sealed bool, vol)
|
|
||||||
sym_sealed = {s: v.get("sealed") for s, v in sealed_map.items()}
|
|
||||||
sym_vol = {s: v.get("vol") for s, v in sealed_map.items()}
|
|
||||||
|
|
||||||
# JOIN sealed: 对每只 status=main 的票, 看 sealed 值
|
|
||||||
sealed_rows = pl.DataFrame({
|
|
||||||
"symbol": list(sym_sealed.keys()),
|
|
||||||
"_sealed": list(sym_sealed.values()),
|
|
||||||
"_sealed_vol": list(sym_vol.values()),
|
|
||||||
}) if sym_sealed else pl.DataFrame()
|
|
||||||
|
|
||||||
if not sealed_rows.is_empty():
|
|
||||||
df = df.join(sealed_rows, on="symbol", how="left")
|
|
||||||
# 假涨停(main 状态但 sealed=False)→ 降级为 broken
|
|
||||||
df = df.with_columns(
|
|
||||||
pl.when(
|
|
||||||
(pl.col("status") == status_main)
|
|
||||||
& pl.col("_sealed").is_not_null()
|
|
||||||
& (pl.col("_sealed") == False) # noqa: E712
|
|
||||||
).then(pl.lit(status_broken))
|
|
||||||
.otherwise(pl.col("status")).alias("status"),
|
|
||||||
# sealed_status: real/fake/pending/null
|
|
||||||
pl.when(
|
|
||||||
(pl.col("status") == status_main)
|
|
||||||
& (pl.col("_sealed") == True) # noqa: E712
|
|
||||||
).then(pl.lit("real"))
|
|
||||||
.when(
|
|
||||||
(pl.col("_sealed") == False) # noqa: E712
|
|
||||||
).then(pl.lit("fake"))
|
|
||||||
.when(
|
|
||||||
(pl.col("status") == status_main)
|
|
||||||
& pl.col("_sealed").is_null()
|
|
||||||
).then(pl.lit("pending"))
|
|
||||||
.otherwise(None).alias("sealed_status"),
|
|
||||||
pl.col("_sealed_vol").alias("sealed_vol"),
|
|
||||||
).drop(["_sealed", "_sealed_vol"])
|
|
||||||
else:
|
|
||||||
df = df.with_columns(
|
|
||||||
pl.lit(None).alias("sealed_status"),
|
|
||||||
pl.lit(None).alias("sealed_vol"),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
df = df.with_columns(
|
|
||||||
pl.lit(None).alias("sealed_status"),
|
|
||||||
pl.lit(None).alias("sealed_vol"),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
df = df.with_columns(
|
df = df.with_columns(
|
||||||
pl.lit(None).alias("sealed_status"),
|
pl.lit(None).alias("sealed_status"),
|
||||||
pl.lit(None).alias("sealed_vol"),
|
pl.lit(None).alias("sealed_vol"),
|
||||||
@@ -700,15 +600,6 @@ def limit_ladder(
|
|||||||
"tiers": tier_list,
|
"tiers": tier_list,
|
||||||
"counts": {"up": count_up, "down": count_down},
|
"counts": {"up": count_up, "down": count_down},
|
||||||
"counts_raw": {"up": count_up_raw, "down": count_down_raw},
|
"counts_raw": {"up": count_up_raw, "down": count_down_raw},
|
||||||
"sealed_ready": sealed_ready,
|
|
||||||
"sealed_age": round(sealed_age, 0) if sealed_age is not None else None,
|
|
||||||
"sealed_counts": {
|
|
||||||
"real": sum(1 for t in tier_list for s in t.get("stocks", []) if s.get("sealed_status") == "real"),
|
|
||||||
"fake": sum(1 for t in tier_list for s in t.get("stocks", []) if s.get("sealed_status") == "fake"),
|
|
||||||
"pending": sum(1 for t in tier_list for s in t.get("stocks", []) if s.get("sealed_status") == "pending"),
|
|
||||||
},
|
|
||||||
"sealed_counts_up": sealed_counts_up,
|
|
||||||
"sealed_counts_down": sealed_counts_down,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -308,12 +308,6 @@ def clear_ai_settings() -> dict:
|
|||||||
|
|
||||||
# ===== 偏好设置 =====
|
# ===== 偏好设置 =====
|
||||||
|
|
||||||
def _realtime_allowed() -> bool:
|
|
||||||
"""当前档位是否允许实时行情(none/free 不允许)。"""
|
|
||||||
from app.services.quote_service import QuoteService
|
|
||||||
return QuoteService.is_realtime_allowed()
|
|
||||||
|
|
||||||
|
|
||||||
class MinuteSyncPrefs(BaseModel):
|
class MinuteSyncPrefs(BaseModel):
|
||||||
minute_sync_enabled: bool
|
minute_sync_enabled: bool
|
||||||
minute_sync_days: int = 5
|
minute_sync_days: int = 5
|
||||||
@@ -324,17 +318,12 @@ def get_preferences() -> dict:
|
|||||||
"""返回用户偏好设置。"""
|
"""返回用户偏好设置。"""
|
||||||
from app.services import preferences
|
from app.services import preferences
|
||||||
return {
|
return {
|
||||||
"realtime_quotes_enabled": preferences.get_realtime_quotes_enabled(),
|
|
||||||
"realtime_allowed": _realtime_allowed(),
|
|
||||||
"indices_nav_pinned": preferences.get_indices_nav_pinned(),
|
"indices_nav_pinned": preferences.get_indices_nav_pinned(),
|
||||||
"minute_sync_enabled": preferences.get_minute_sync_enabled(),
|
"minute_sync_enabled": preferences.get_minute_sync_enabled(),
|
||||||
"minute_sync_days": preferences.get_minute_sync_days(),
|
"minute_sync_days": preferences.get_minute_sync_days(),
|
||||||
"daily_data_provider": preferences.get_daily_data_provider(),
|
"daily_data_provider": preferences.get_daily_data_provider(),
|
||||||
"adj_factor_provider": preferences.get_adj_factor_provider(),
|
"adj_factor_provider": preferences.get_adj_factor_provider(),
|
||||||
"minute_data_provider": preferences.get_minute_data_provider(),
|
"minute_data_provider": preferences.get_minute_data_provider(),
|
||||||
"realtime_data_provider": preferences.get_realtime_data_provider(),
|
|
||||||
"realtime_watchlist_symbols": preferences.get_realtime_watchlist_symbols(),
|
|
||||||
**preferences.get_realtime_quote_scope(),
|
|
||||||
"pipeline_pull_a_share": preferences.get_pipeline_pull_a_share(),
|
"pipeline_pull_a_share": preferences.get_pipeline_pull_a_share(),
|
||||||
"pipeline_pull_etf": preferences.get_pipeline_pull_etf(),
|
"pipeline_pull_etf": preferences.get_pipeline_pull_etf(),
|
||||||
"pipeline_pull_index": preferences.get_pipeline_pull_index(),
|
"pipeline_pull_index": preferences.get_pipeline_pull_index(),
|
||||||
@@ -343,35 +332,16 @@ def get_preferences() -> dict:
|
|||||||
"instruments_schedule": preferences.get_instruments_schedule(),
|
"instruments_schedule": preferences.get_instruments_schedule(),
|
||||||
"enriched_batch_size": preferences.get_enriched_batch_size(),
|
"enriched_batch_size": preferences.get_enriched_batch_size(),
|
||||||
"index_daily_batch_size": preferences.get_index_daily_batch_size(),
|
"index_daily_batch_size": preferences.get_index_daily_batch_size(),
|
||||||
"watchlist_columns": preferences.get_watchlist_columns(),
|
|
||||||
"screener_result_columns": preferences.get_screener_result_columns(),
|
"screener_result_columns": preferences.get_screener_result_columns(),
|
||||||
"sse_refresh_pages": preferences.get_sse_refresh_pages(),
|
|
||||||
"strategy_monitor_enabled": preferences.get_strategy_monitor_enabled(),
|
|
||||||
"strategy_monitor_ids": preferences.get_strategy_monitor_ids(),
|
|
||||||
"system_notify_enabled": preferences.get_system_notify_enabled(),
|
|
||||||
"feishu_webhook_url": preferences.get_feishu_webhook_url(),
|
|
||||||
"feishu_webhook_secret": preferences.get_feishu_webhook_secret(),
|
|
||||||
"webhook_enabled_default": preferences.get_webhook_enabled_default(),
|
|
||||||
"sidebar_index_symbols": preferences.get_sidebar_index_symbols(),
|
"sidebar_index_symbols": preferences.get_sidebar_index_symbols(),
|
||||||
"nav_order": preferences.get_nav_order(),
|
"nav_order": preferences.get_nav_order(),
|
||||||
"nav_hidden": preferences.get_nav_hidden(),
|
"nav_hidden": preferences.get_nav_hidden(),
|
||||||
"screener_auto_run": preferences.get_screener_auto_run(),
|
"screener_auto_run": preferences.get_screener_auto_run(),
|
||||||
"limit_ladder_monitor_enabled": preferences.get_limit_ladder_monitor_enabled(),
|
|
||||||
"depth_polling_interval": preferences.get_depth_polling_interval(),
|
|
||||||
"depth_finalize_time": preferences.get_depth_finalize_time(),
|
|
||||||
"review_schedule": preferences.get_review_schedule(),
|
"review_schedule": preferences.get_review_schedule(),
|
||||||
"review_push_channels": preferences.get_review_push_channels(),
|
"review_push_channels": preferences.get_review_push_channels(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/preferences/watchlist-columns")
|
|
||||||
def get_watchlist_columns() -> dict:
|
|
||||||
"""返回自选列表列配置。"""
|
|
||||||
from app.services import preferences
|
|
||||||
cols = preferences.get_watchlist_columns()
|
|
||||||
return {"columns": cols}
|
|
||||||
|
|
||||||
|
|
||||||
class NavOrderIn(BaseModel):
|
class NavOrderIn(BaseModel):
|
||||||
nav_order: list[str]
|
nav_order: list[str]
|
||||||
|
|
||||||
@@ -396,15 +366,6 @@ def update_nav_hidden(req: NavHiddenIn) -> dict:
|
|||||||
return {"nav_hidden": saved}
|
return {"nav_hidden": saved}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/preferences/watchlist-columns")
|
|
||||||
def update_watchlist_columns(req: dict) -> dict:
|
|
||||||
"""保存自选列表列配置。"""
|
|
||||||
from app.services import preferences
|
|
||||||
columns = req.get("columns", [])
|
|
||||||
saved = preferences.set_watchlist_columns(columns)
|
|
||||||
return {"columns": saved}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/preferences/screener-result-columns")
|
@router.get("/preferences/screener-result-columns")
|
||||||
def get_screener_result_columns() -> dict:
|
def get_screener_result_columns() -> dict:
|
||||||
"""返回策略结果列表列配置。"""
|
"""返回策略结果列表列配置。"""
|
||||||
@@ -437,69 +398,6 @@ def update_minute_sync(req: MinuteSyncPrefs) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class RealtimeQuotesPrefs(BaseModel):
|
|
||||||
realtime_quotes_enabled: bool
|
|
||||||
|
|
||||||
|
|
||||||
class RealtimeQuoteScopePrefs(BaseModel):
|
|
||||||
realtime_pull_stock: bool | None = None
|
|
||||||
realtime_pull_etf: bool | None = None
|
|
||||||
realtime_pull_index: bool | None = None
|
|
||||||
realtime_index_mode: str | None = None
|
|
||||||
realtime_index_symbols: list[str] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/preferences/realtime-quotes")
|
|
||||||
def update_realtime_quotes(req: RealtimeQuotesPrefs, request: Request) -> dict:
|
|
||||||
"""保存全局实时行情开关。
|
|
||||||
|
|
||||||
none 档无实时行情权限;free 档开启自选股实时;starter+ 开启全市场实时。
|
|
||||||
前端据此把开关置灰 / 回弹。
|
|
||||||
"""
|
|
||||||
from app.services import preferences
|
|
||||||
qs = getattr(request.app.state, "quote_service", None)
|
|
||||||
|
|
||||||
allowed = qs.is_realtime_allowed() if qs else True
|
|
||||||
if req.realtime_quotes_enabled and not allowed:
|
|
||||||
# 当前档位不允许开启实时行情 — 强制关闭
|
|
||||||
preferences.save({"realtime_quotes_enabled": False})
|
|
||||||
if qs:
|
|
||||||
qs.disable()
|
|
||||||
return {"realtime_quotes_enabled": False, "realtime_allowed": False}
|
|
||||||
if req.realtime_quotes_enabled and qs and qs.realtime_mode() == "watchlist" and not preferences.get_realtime_watchlist_symbols():
|
|
||||||
preferences.save({"realtime_quotes_enabled": False})
|
|
||||||
return {"realtime_quotes_enabled": False, "realtime_allowed": True, "mode": "watchlist", "error": "watchlist_empty"}
|
|
||||||
|
|
||||||
preferences.save({"realtime_quotes_enabled": req.realtime_quotes_enabled})
|
|
||||||
if qs:
|
|
||||||
if req.realtime_quotes_enabled:
|
|
||||||
qs.enable()
|
|
||||||
else:
|
|
||||||
qs.disable()
|
|
||||||
|
|
||||||
return {"realtime_quotes_enabled": req.realtime_quotes_enabled, "realtime_allowed": allowed}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/preferences/realtime-quote-scope")
|
|
||||||
def update_realtime_quote_scope(req: RealtimeQuoteScopePrefs) -> dict:
|
|
||||||
"""保存盘中实时行情范围;独立于盘后管道范围。"""
|
|
||||||
from app.services import preferences
|
|
||||||
cfg = req.model_dump(exclude_none=True)
|
|
||||||
return preferences.set_realtime_quote_scope(cfg)
|
|
||||||
|
|
||||||
|
|
||||||
class RealtimeWatchlistPrefs(BaseModel):
|
|
||||||
symbols: list[str] = []
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/preferences/realtime-watchlist")
|
|
||||||
def update_realtime_watchlist(req: RealtimeWatchlistPrefs) -> dict:
|
|
||||||
"""兼容旧入口;Free 实时标的由自选页前 5 个决定。"""
|
|
||||||
from app.services import preferences
|
|
||||||
symbols = preferences.set_realtime_watchlist_symbols(req.symbols)
|
|
||||||
return {"realtime_watchlist_symbols": symbols}
|
|
||||||
|
|
||||||
|
|
||||||
class IndicesNavPinnedPrefs(BaseModel):
|
class IndicesNavPinnedPrefs(BaseModel):
|
||||||
indices_nav_pinned: bool
|
indices_nav_pinned: bool
|
||||||
|
|
||||||
@@ -513,45 +411,6 @@ def update_indices_nav_pinned(req: IndicesNavPinnedPrefs) -> dict:
|
|||||||
return {"indices_nav_pinned": req.indices_nav_pinned}
|
return {"indices_nav_pinned": req.indices_nav_pinned}
|
||||||
|
|
||||||
|
|
||||||
class RealtimeMonitorConfigIn(BaseModel):
|
|
||||||
sse_refresh_pages: dict[str, bool] | None = None
|
|
||||||
strategy_monitor_enabled: bool | None = None
|
|
||||||
strategy_monitor_ids: list[str] | None = None
|
|
||||||
sidebar_index_symbols: list[str] | None = None
|
|
||||||
screener_auto_run: bool | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/preferences/realtime-monitor")
|
|
||||||
def update_realtime_monitor_config(req: RealtimeMonitorConfigIn, request: Request) -> dict:
|
|
||||||
"""更新实时监控配置。策略监控统一迁移为 MonitorRule,由监控引擎评估。"""
|
|
||||||
from app.services import preferences
|
|
||||||
|
|
||||||
cfg = req.model_dump(exclude_none=True)
|
|
||||||
result = preferences.set_realtime_monitor_config(cfg)
|
|
||||||
|
|
||||||
# 策略监控开关/池变化 → 同步迁移为 type=strategy 规则 + reload 引擎
|
|
||||||
if req.strategy_monitor_ids is not None or req.strategy_monitor_enabled is not None:
|
|
||||||
monitor_engine = getattr(request.app.state, "monitor_engine", None)
|
|
||||||
strategy_engine = getattr(request.app.state, "strategy_engine", None)
|
|
||||||
data_dir = request.app.state.repo.store.data_dir
|
|
||||||
if monitor_engine is not None and strategy_engine is not None:
|
|
||||||
from app.strategy import monitor_rules as mr_store
|
|
||||||
try:
|
|
||||||
if preferences.get_strategy_monitor_enabled():
|
|
||||||
ids = preferences.get_strategy_monitor_ids()
|
|
||||||
names = {s.id: s.name for s in strategy_engine.list_strategies()}
|
|
||||||
mr_store.migrate_strategy_monitors(data_dir, ids, names)
|
|
||||||
else:
|
|
||||||
# 关闭策略监控: 停用所有策略规则
|
|
||||||
mr_store.migrate_strategy_monitors(data_dir, [], {})
|
|
||||||
# reload 规则到引擎
|
|
||||||
monitor_engine.set_rules(mr_store.load_all(data_dir))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
class PipelinePullTypesIn(BaseModel):
|
class PipelinePullTypesIn(BaseModel):
|
||||||
"""盘后管道拉取内容开关(A股 / ETF / 指数 独立控制)。"""
|
"""盘后管道拉取内容开关(A股 / ETF / 指数 独立控制)。"""
|
||||||
pipeline_pull_a_share: bool | None = None
|
pipeline_pull_a_share: bool | None = None
|
||||||
@@ -580,97 +439,6 @@ def update_pipeline_index_symbols(req: PipelineIndexSymbolsIn) -> dict:
|
|||||||
return {"pipeline_index_symbols": symbols}
|
return {"pipeline_index_symbols": symbols}
|
||||||
|
|
||||||
|
|
||||||
class QuoteIntervalIn(BaseModel):
|
|
||||||
interval: float
|
|
||||||
|
|
||||||
|
|
||||||
class SystemNotifyPrefsIn(BaseModel):
|
|
||||||
enabled: bool
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/preferences/system-notify")
|
|
||||||
def update_system_notify(req: SystemNotifyPrefsIn) -> dict:
|
|
||||||
"""系统通知开关 — 开启后监控告警同时推送到操作系统通知中心。
|
|
||||||
|
|
||||||
纯偏好, 无副作用 (不像策略监控要迁移规则), 直接落盘即可。
|
|
||||||
quote_service 在每轮告警评估时读此开关决定是否发系统通知。
|
|
||||||
"""
|
|
||||||
from app.services import preferences
|
|
||||||
saved = preferences.set_system_notify_enabled(req.enabled)
|
|
||||||
return {"system_notify_enabled": saved}
|
|
||||||
|
|
||||||
|
|
||||||
class FeishuWebhookPrefsIn(BaseModel):
|
|
||||||
url: str
|
|
||||||
secret: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/preferences/feishu-webhook")
|
|
||||||
def update_feishu_webhook(req: FeishuWebhookPrefsIn) -> dict:
|
|
||||||
"""飞书 Webhook 地址 + 签名密钥 — 全局一处配置, 所有启用推送的监控规则共用。
|
|
||||||
|
|
||||||
- url: 传入空串表示清空配置; 非空则需为合法的飞书自定义机器人地址。
|
|
||||||
- secret: 机器人启用了「签名校验」时填密钥, 留空表示不验签。
|
|
||||||
"""
|
|
||||||
from app.services import preferences
|
|
||||||
from app.services import webhook_adapter
|
|
||||||
|
|
||||||
url = (req.url or "").strip()
|
|
||||||
if url and not webhook_adapter.is_valid_feishu_url(url):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Webhook 地址非法, 需为飞书自定义机器人地址 "
|
|
||||||
"(https://open.feishu.cn/open-apis/bot/v2/hook/...)",
|
|
||||||
)
|
|
||||||
saved_url = preferences.set_feishu_webhook_url(url)
|
|
||||||
saved_secret = preferences.set_feishu_webhook_secret((req.secret or "").strip())
|
|
||||||
return {"feishu_webhook_url": saved_url, "feishu_webhook_secret": saved_secret}
|
|
||||||
|
|
||||||
|
|
||||||
class WebhookEnabledDefaultIn(BaseModel):
|
|
||||||
enabled: bool
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/preferences/webhook-enabled-default")
|
|
||||||
def update_webhook_enabled_default(req: WebhookEnabledDefaultIn) -> dict:
|
|
||||||
"""新建监控规则时是否默认勾选「飞书推送」。
|
|
||||||
|
|
||||||
数据模型当前只有飞书一个可用渠道 (QMT/ptrade 待定),故此处仅一个布尔。
|
|
||||||
单条规则仍可在规则编辑页独立修改此项。
|
|
||||||
"""
|
|
||||||
from app.services import preferences
|
|
||||||
|
|
||||||
saved = preferences.set_webhook_enabled_default(req.enabled)
|
|
||||||
return {"webhook_enabled_default": saved}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/preferences/quote-interval")
|
|
||||||
def update_quote_interval(req: QuoteIntervalIn, request: Request) -> dict:
|
|
||||||
"""更新行情轮询间隔。按档位自动 clamp。"""
|
|
||||||
qs = getattr(request.app.state, "quote_service", None)
|
|
||||||
if not qs:
|
|
||||||
return {"interval": req.interval, "min_interval": qs.get_min_interval(), "max_interval": 60.0}
|
|
||||||
clamped = qs.set_interval(req.interval)
|
|
||||||
return {
|
|
||||||
"interval": clamped,
|
|
||||||
"min_interval": qs.get_min_interval(),
|
|
||||||
"max_interval": qs.MAX_INTERVAL,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/preferences/quote-interval")
|
|
||||||
def get_quote_interval(request: Request) -> dict:
|
|
||||||
"""获取当前行情轮询间隔和档位限制。"""
|
|
||||||
qs = getattr(request.app.state, "quote_service", None)
|
|
||||||
if not qs:
|
|
||||||
return {"interval": 10.0, "min_interval": 5.0, "max_interval": 60.0}
|
|
||||||
return {
|
|
||||||
"interval": qs._interval,
|
|
||||||
"min_interval": qs.get_min_interval(),
|
|
||||||
"max_interval": qs.MAX_INTERVAL,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TestEndpointIn(BaseModel):
|
class TestEndpointIn(BaseModel):
|
||||||
url: str
|
url: str
|
||||||
# 测试轮数;不传时取 endpoints.json 的 testRounds(默认 5)
|
# 测试轮数;不传时取 endpoints.json 的 testRounds(默认 5)
|
||||||
@@ -941,85 +709,6 @@ def update_index_daily_batch_size(req: IndexDailyBatchSizeIn) -> dict:
|
|||||||
return {"index_daily_batch_size": size}
|
return {"index_daily_batch_size": size}
|
||||||
|
|
||||||
|
|
||||||
# ── 五档盘口 sealed 配置 ──────────────────────────────
|
|
||||||
|
|
||||||
class LimitLadderMonitorIn(BaseModel):
|
|
||||||
enabled: bool
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/preferences/limit-ladder-monitor")
|
|
||||||
def update_limit_ladder_monitor(req: LimitLadderMonitorIn, request: Request) -> dict:
|
|
||||||
"""连板梯队 5 档监控开关。开启→启动 depth 轮询, 关闭→停止。"""
|
|
||||||
from app.services import preferences
|
|
||||||
preferences.save({"limit_ladder_monitor_enabled": req.enabled})
|
|
||||||
|
|
||||||
# 立即应用: 启停 depth 轮询线程
|
|
||||||
depth_svc = getattr(request.app.state, "depth_service", None)
|
|
||||||
if depth_svc:
|
|
||||||
depth_svc.apply_monitor_toggle(req.enabled)
|
|
||||||
|
|
||||||
return {"limit_ladder_monitor_enabled": req.enabled}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/preferences/limit-ladder-monitor/run")
|
|
||||||
def run_limit_ladder_fix(request: Request) -> dict:
|
|
||||||
"""立即手动修正一次真假板(拉取五档盘口 + 更新缓存)。需 Pro+。"""
|
|
||||||
from app.tickflow.capabilities import Cap
|
|
||||||
capset = request.app.state.capabilities
|
|
||||||
capset.require(Cap.DEPTH5_BATCH) # 无能力抛 CapabilityDenied(403)
|
|
||||||
|
|
||||||
depth_svc = getattr(request.app.state, "depth_service", None)
|
|
||||||
if not depth_svc:
|
|
||||||
raise HTTPException(status_code=503, detail="depth 服务未初始化")
|
|
||||||
return depth_svc.run_once()
|
|
||||||
|
|
||||||
|
|
||||||
class DepthPollingIntervalIn(BaseModel):
|
|
||||||
interval: float
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/preferences/depth-polling-interval")
|
|
||||||
def update_depth_polling_interval(req: DepthPollingIntervalIn, request: Request) -> dict:
|
|
||||||
"""保存五档盘口盘中轮询间隔(秒)。需 Pro+。"""
|
|
||||||
from app.tickflow.capabilities import Cap
|
|
||||||
request.app.state.capabilities.require(Cap.DEPTH5_BATCH)
|
|
||||||
|
|
||||||
from app.services import preferences
|
|
||||||
interval = preferences.set_depth_polling_interval(req.interval)
|
|
||||||
return {"depth_polling_interval": interval}
|
|
||||||
|
|
||||||
|
|
||||||
class DepthFinalizeTimeIn(BaseModel):
|
|
||||||
hour: int
|
|
||||||
minute: int
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/preferences/depth-finalize-time")
|
|
||||||
def update_depth_finalize_time(req: DepthFinalizeTimeIn, request: Request) -> dict:
|
|
||||||
"""保存盘后 sealed 定版时间(范围15:01~18:00)并立即 reschedule。需 Pro+。"""
|
|
||||||
from app.tickflow.capabilities import Cap
|
|
||||||
request.app.state.capabilities.require(Cap.DEPTH5_BATCH)
|
|
||||||
|
|
||||||
from app.services import preferences
|
|
||||||
sched = preferences.set_depth_finalize_time(req.hour, req.minute)
|
|
||||||
|
|
||||||
from apscheduler.triggers.cron import CronTrigger
|
|
||||||
scheduler = getattr(request.app.state, "scheduler", None)
|
|
||||||
if scheduler:
|
|
||||||
scheduler.reschedule_job(
|
|
||||||
"depth_finalize",
|
|
||||||
trigger=CronTrigger(
|
|
||||||
day_of_week="mon-fri",
|
|
||||||
hour=sched["hour"],
|
|
||||||
minute=sched["minute"],
|
|
||||||
timezone="Asia/Shanghai",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
logger.info("depth_finalize rescheduled to %02d:%02d mon-fri", sched["hour"], sched["minute"])
|
|
||||||
|
|
||||||
return sched
|
|
||||||
|
|
||||||
|
|
||||||
class ReviewScheduleIn(BaseModel):
|
class ReviewScheduleIn(BaseModel):
|
||||||
enabled: bool
|
enabled: bool
|
||||||
hour: int
|
hour: int
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ from app.strategy import config as strategy_config
|
|||||||
from app.strategy.engine import StrategyEngine, StrategyDef
|
from app.strategy.engine import StrategyEngine, StrategyDef
|
||||||
from app.strategy.ai_generator import AIStrategyGenerator
|
from app.strategy.ai_generator import AIStrategyGenerator
|
||||||
from app.strategy.prompt_builder import build_step1, build_step2
|
from app.strategy.prompt_builder import build_step1, build_step2
|
||||||
from app.strategy.monitor import StrategyMonitorService, StrategyAlert
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/strategies", tags=["strategies"])
|
router = APIRouter(prefix="/api/strategies", tags=["strategies"])
|
||||||
|
|
||||||
@@ -31,13 +30,6 @@ def _get_engine(request: Request) -> StrategyEngine:
|
|||||||
return engine
|
return engine
|
||||||
|
|
||||||
|
|
||||||
def _get_monitor(request: Request) -> StrategyMonitorService:
|
|
||||||
mon = getattr(request.app.state, "strategy_monitor", None)
|
|
||||||
if not mon:
|
|
||||||
raise HTTPException(status_code=503, detail="策略监控未初始化")
|
|
||||||
return mon
|
|
||||||
|
|
||||||
|
|
||||||
def _data_dir(request: Request) -> Path:
|
def _data_dir(request: Request) -> Path:
|
||||||
return request.app.state.repo.store.data_dir
|
return request.app.state.repo.store.data_dir
|
||||||
|
|
||||||
@@ -427,8 +419,7 @@ def delete_strategy(strategy_id: str, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
# ── 监控 ─────────────────────────────────────────────────────────────
|
# ── 监控 ─────────────────────────────────────────────────────────────
|
||||||
# 注: 策略监控已统一迁移到 MonitorRuleEngine (监控通知页), 旧的 start/stop/status
|
# 注: 策略监控已移除。
|
||||||
# 路由已移除。StrategyMonitorService 类保留 (其 _check_signals 被 MonitorRuleEngine 复用)。
|
|
||||||
|
|
||||||
|
|
||||||
# ── 热重载 ───────────────────────────────────────────────────────────
|
# ── 热重载 ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,222 +0,0 @@
|
|||||||
"""自选股 API。"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import math
|
|
||||||
import time
|
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
import polars as pl
|
|
||||||
from fastapi import APIRouter, Query, Request
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from app.services import watchlist
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/watchlist", tags=["watchlist"])
|
|
||||||
|
|
||||||
|
|
||||||
class AddRequest(BaseModel):
|
|
||||||
symbol: str
|
|
||||||
note: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class BatchAddRequest(BaseModel):
|
|
||||||
symbols: list[str]
|
|
||||||
note: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
def _with_names(rows: list[dict], request: Request) -> list[dict]:
|
|
||||||
if not rows:
|
|
||||||
return rows
|
|
||||||
try:
|
|
||||||
df_i = request.app.state.repo.get_instruments()
|
|
||||||
if df_i.is_empty() or "symbol" not in df_i.columns or "name" not in df_i.columns:
|
|
||||||
return rows
|
|
||||||
name_by_symbol = dict(df_i.select(["symbol", "name"]).iter_rows())
|
|
||||||
return [{**row, "name": name_by_symbol.get(row.get("symbol"))} for row in rows]
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
logger.debug("attach watchlist names failed: %s", e)
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
def list_all(request: Request):
|
|
||||||
return {"symbols": _with_names(watchlist.list_symbols(), request)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
|
||||||
def add_one(req: AddRequest, request: Request):
|
|
||||||
rows = watchlist.add(req.symbol, req.note)
|
|
||||||
return {"symbols": _with_names(rows, request)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/batch")
|
|
||||||
def add_batch(req: BatchAddRequest, request: Request):
|
|
||||||
for sym in req.symbols:
|
|
||||||
watchlist.add(sym, req.note)
|
|
||||||
return {"symbols": _with_names(watchlist.list_symbols(), request), "added": len(req.symbols)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{symbol}/top")
|
|
||||||
def move_one_to_top(symbol: str, request: Request):
|
|
||||||
rows = watchlist.move_to_top(symbol)
|
|
||||||
return {"symbols": _with_names(rows, request)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{symbol}")
|
|
||||||
def remove_one(symbol: str, request: Request):
|
|
||||||
rows = watchlist.remove(symbol)
|
|
||||||
return {"symbols": _with_names(rows, request)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("")
|
|
||||||
def clear_all():
|
|
||||||
"""清空自选列表。"""
|
|
||||||
count = watchlist.clear()
|
|
||||||
return {"removed": count}
|
|
||||||
|
|
||||||
|
|
||||||
# 自选页需要的列
|
|
||||||
_WATCHLIST_COLS = [
|
|
||||||
"symbol", "close", "change_pct", "change_amount", "amount",
|
|
||||||
"turnover_rate",
|
|
||||||
"amplitude", "annual_vol_20d",
|
|
||||||
"vol_ratio_5d",
|
|
||||||
"ma5", "ma10", "ma20", "ma60",
|
|
||||||
"vol_ma5", "vol_ma10",
|
|
||||||
"high_60d", "low_60d",
|
|
||||||
"rsi_6", "rsi_14", "rsi_24",
|
|
||||||
"macd_dif", "macd_dea", "macd_hist",
|
|
||||||
"kdj_k", "kdj_d", "kdj_j",
|
|
||||||
"boll_upper", "boll_lower",
|
|
||||||
"atr_14",
|
|
||||||
"momentum_5d", "momentum_10d", "momentum_20d", "momentum_30d", "momentum_60d",
|
|
||||||
"consecutive_limit_ups", "consecutive_limit_downs",
|
|
||||||
"signal_limit_up", "signal_limit_down", "signal_volume_surge",
|
|
||||||
"signal_ma_golden_5_20", "signal_macd_golden", "signal_n_day_high",
|
|
||||||
"signal_boll_breakout_upper", "signal_ma20_breakout",
|
|
||||||
"signal_ma_dead_5_20", "signal_macd_dead", "signal_n_day_low",
|
|
||||||
"signal_boll_breakdown_lower", "signal_ma20_breakdown",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/enriched")
|
|
||||||
def watchlist_enriched(
|
|
||||||
request: Request,
|
|
||||||
ext_columns: str | None = Query(None, description="逗号分隔的 ext 列: config_id.field_name"),
|
|
||||||
):
|
|
||||||
"""自选股 enriched 数据 — 直接从 enriched 最新日读取, 无即时计算。
|
|
||||||
|
|
||||||
ext_columns 参数示例: "industry_rating.score,fund_flow.net_inflow"
|
|
||||||
会动态 LEFT JOIN 对应的 ext_{config_id} DuckDB view。
|
|
||||||
"""
|
|
||||||
t0 = time.perf_counter()
|
|
||||||
|
|
||||||
repo = request.app.state.repo
|
|
||||||
symbols = [r["symbol"] for r in watchlist.list_symbols()]
|
|
||||||
if not symbols:
|
|
||||||
return {"rows": [], "as_of": None, "elapsed_ms": 0}
|
|
||||||
|
|
||||||
df_e, cache_date = repo.get_enriched_latest()
|
|
||||||
if df_e.is_empty():
|
|
||||||
return {"rows": [], "as_of": None, "elapsed_ms": 0}
|
|
||||||
|
|
||||||
# 按 symbol 过滤
|
|
||||||
df = df_e.filter(pl.col("symbol").is_in(symbols))
|
|
||||||
if df.is_empty():
|
|
||||||
return {"rows": [], "as_of": str(cache_date) if cache_date else None, "elapsed_ms": 0}
|
|
||||||
|
|
||||||
# JOIN instruments 取 name + float_shares
|
|
||||||
df_i = repo.get_instruments()
|
|
||||||
if not df_i.is_empty() and "name" in df_i.columns:
|
|
||||||
inst_cols = [c for c in ["symbol", "name", "float_shares"] if c in df_i.columns]
|
|
||||||
df = df.join(df_i.select(inst_cols), on="symbol", how="left")
|
|
||||||
|
|
||||||
# 选择内置需要的列
|
|
||||||
keep = [c for c in _WATCHLIST_COLS + ["name", "float_shares"] if c in df.columns]
|
|
||||||
df = df.select(keep)
|
|
||||||
|
|
||||||
# 动态 JOIN 扩展数据表
|
|
||||||
ext_specs = _parse_ext_columns(ext_columns) if ext_columns else []
|
|
||||||
if ext_specs:
|
|
||||||
db = repo.store.db
|
|
||||||
data_dir = repo.store.data_dir
|
|
||||||
from app.services.ext_data import ExtConfigStore
|
|
||||||
from app.api.ext_data import _read_ext_dataframe
|
|
||||||
|
|
||||||
ext_store = ExtConfigStore(data_dir)
|
|
||||||
configs = {c.id: c for c in ext_store.load_all()}
|
|
||||||
|
|
||||||
for config_id, field_name in ext_specs:
|
|
||||||
view_name = f"ext_{config_id}"
|
|
||||||
ext_col_name = f"{config_id}__{field_name}"
|
|
||||||
try:
|
|
||||||
# 扩展时序数据必须只取最新分区;否则一个 symbol 会按历史分区数被 JOIN 放大。
|
|
||||||
cfg = configs.get(config_id)
|
|
||||||
if cfg:
|
|
||||||
ext_df, _ = _read_ext_dataframe(cfg, data_dir)
|
|
||||||
else:
|
|
||||||
ext_df = pl.from_arrow(db.query(
|
|
||||||
f"SELECT symbol, \"{field_name}\" FROM {view_name}"
|
|
||||||
).arrow())
|
|
||||||
if not ext_df.is_empty() and "symbol" in ext_df.columns:
|
|
||||||
ext_df = (
|
|
||||||
ext_df
|
|
||||||
.select(["symbol", field_name])
|
|
||||||
.unique(subset=["symbol"], keep="last")
|
|
||||||
.rename({field_name: ext_col_name})
|
|
||||||
)
|
|
||||||
df = df.join(ext_df.select(["symbol", ext_col_name]), on="symbol", how="left")
|
|
||||||
except Exception:
|
|
||||||
# view 不存在或字段不存在,尝试直接读 parquet
|
|
||||||
cfg = configs.get(config_id)
|
|
||||||
if cfg:
|
|
||||||
try:
|
|
||||||
ext_df, _ = _read_ext_dataframe(cfg, data_dir)
|
|
||||||
if not ext_df.is_empty() and "symbol" in ext_df.columns and field_name in ext_df.columns:
|
|
||||||
ext_df = (
|
|
||||||
ext_df
|
|
||||||
.select(["symbol", field_name])
|
|
||||||
.unique(subset=["symbol"], keep="last")
|
|
||||||
.rename({field_name: ext_col_name})
|
|
||||||
)
|
|
||||||
df = df.join(ext_df, on="symbol", how="left")
|
|
||||||
except Exception as e2:
|
|
||||||
logger.debug("ext join fallback failed for %s.%s: %s", config_id, field_name, e2)
|
|
||||||
|
|
||||||
# sanitize NaN / Inf
|
|
||||||
float_cols = [c for c in df.columns if df[c].dtype.is_float()]
|
|
||||||
if float_cols:
|
|
||||||
df = df.with_columns([
|
|
||||||
pl.when(pl.col(c).is_nan() | pl.col(c).is_infinite())
|
|
||||||
.then(None)
|
|
||||||
.otherwise(pl.col(c))
|
|
||||||
.alias(c)
|
|
||||||
for c in float_cols
|
|
||||||
])
|
|
||||||
|
|
||||||
# 按自选添加顺序(新加的在前)重排行
|
|
||||||
order_map = {s: i for i, s in enumerate(symbols)}
|
|
||||||
df = df.with_columns(pl.col("symbol").map_elements(lambda s: order_map.get(s, len(symbols)), return_dtype=pl.Int32).alias("_sort_order"))
|
|
||||||
df = df.sort("_sort_order").drop("_sort_order")
|
|
||||||
|
|
||||||
rows = df.to_dicts()
|
|
||||||
elapsed = (time.perf_counter() - t0) * 1000
|
|
||||||
return {"rows": rows, "as_of": str(cache_date) if cache_date else None, "elapsed_ms": elapsed}
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_ext_columns(ext_columns: str) -> list[tuple[str, str]]:
|
|
||||||
"""解析 'config_id1.field1,config_id2.field2' 为 [(config_id, field_name), ...]"""
|
|
||||||
result = []
|
|
||||||
for part in ext_columns.split(","):
|
|
||||||
part = part.strip()
|
|
||||||
if "." not in part:
|
|
||||||
continue
|
|
||||||
config_id, field_name = part.split(".", 1)
|
|
||||||
config_id = config_id.strip()
|
|
||||||
field_name = field_name.strip()
|
|
||||||
if config_id and field_name:
|
|
||||||
result.append((config_id, field_name))
|
|
||||||
return result
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
"""回测模块 — 因子回测 + 策略回测 + 信号回测。
|
|
||||||
|
|
||||||
架构: BacktestEngine (共享) → FactorBacktestService / StrategyBacktestService
|
|
||||||
"""
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,480 +0,0 @@
|
|||||||
"""因子回测服务 — IC/IR 分析 + 分层回测 + 多空组合。
|
|
||||||
|
|
||||||
纯 Polars 向量化实现,无 pandas 依赖。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from datetime import date, timedelta
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import polars as pl
|
|
||||||
|
|
||||||
from app.backtest.engine import BacktestEngine
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# 可用因子列 (从 ENRICHED_COLUMNS 过滤出数值型指标)
|
|
||||||
FACTOR_COLUMNS: list[dict] = [
|
|
||||||
{"id": "momentum_5d", "label": "5日动量", "group": "动量", "desc": "5日涨跌幅,正值表示上涨趋势"},
|
|
||||||
{"id": "momentum_10d", "label": "10日动量", "group": "动量", "desc": "10日涨跌幅,中短期趋势指标"},
|
|
||||||
{"id": "momentum_20d", "label": "20日动量", "group": "动量", "desc": "月度涨跌幅,常用因子"},
|
|
||||||
{"id": "momentum_30d", "label": "30日动量", "group": "动量", "desc": "30日涨跌幅"},
|
|
||||||
{"id": "momentum_60d", "label": "60日动量", "group": "动量", "desc": "季度涨跌幅,中期动量"},
|
|
||||||
{"id": "rsi_6", "label": "RSI(6)", "group": "超买超卖", "desc": "6日相对强弱指标,敏感度高"},
|
|
||||||
{"id": "rsi_14", "label": "RSI(14)", "group": "超买超卖", "desc": "14日相对强弱指标,经典周期"},
|
|
||||||
{"id": "rsi_24", "label": "RSI(24)", "group": "超买超卖", "desc": "24日相对强弱指标"},
|
|
||||||
{"id": "annual_vol_20d","label": "20日波动率", "group": "波动率", "desc": "20日年化波动率"},
|
|
||||||
{"id": "atr_14", "label": "ATR(14)", "group": "波动率", "desc": "14日平均真实波幅"},
|
|
||||||
{"id": "vol_ratio_5d", "label": "量比(5日)", "group": "量价", "desc": "当日成交量 / 5日均量"},
|
|
||||||
{"id": "turnover_rate", "label": "换手率", "group": "量价", "desc": "当日换手率"},
|
|
||||||
{"id": "macd_hist", "label": "MACD柱", "group": "趋势", "desc": "MACD柱状图值"},
|
|
||||||
{"id": "kdj_k", "label": "KDJ-K", "group": "趋势", "desc": "KDJ指标K值"},
|
|
||||||
{"id": "change_pct", "label": "日涨跌幅", "group": "基础", "desc": "当日涨跌幅"},
|
|
||||||
{"id": "amplitude", "label": "日振幅", "group": "基础", "desc": "当日振幅 (最高-最低)/昨收"},
|
|
||||||
]
|
|
||||||
|
|
||||||
FACTOR_WARMUP_DAYS = 120
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class FactorConfig:
|
|
||||||
factor_name: str
|
|
||||||
symbols: list[str] | None
|
|
||||||
start: date
|
|
||||||
end: date
|
|
||||||
n_groups: int = 5
|
|
||||||
rebalance: Literal["daily", "weekly", "monthly"] = "monthly"
|
|
||||||
weight: Literal["equal", "factor_weight"] = "equal"
|
|
||||||
fees_pct: float = 0.0002
|
|
||||||
slippage_bps: float = 5.0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class GroupStats:
|
|
||||||
group: int
|
|
||||||
label: str
|
|
||||||
total_return: float
|
|
||||||
annual_return: float
|
|
||||||
max_drawdown: float
|
|
||||||
sharpe: float
|
|
||||||
win_rate: float
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class FactorResult:
|
|
||||||
run_id: str
|
|
||||||
config: dict
|
|
||||||
# IC 分析
|
|
||||||
ic_mean: float | None = None
|
|
||||||
ic_std: float | None = None
|
|
||||||
ir: float | None = None
|
|
||||||
ic_win_rate: float | None = None
|
|
||||||
ic_series: list[dict] = field(default_factory=list)
|
|
||||||
# 分层
|
|
||||||
group_stats: list[dict] = field(default_factory=list)
|
|
||||||
group_nav: list[dict] = field(default_factory=list)
|
|
||||||
# 多空
|
|
||||||
long_short_stats: dict = field(default_factory=dict)
|
|
||||||
long_short_nav: list[dict] = field(default_factory=list)
|
|
||||||
# 元信息
|
|
||||||
elapsed_ms: float = 0.0
|
|
||||||
n_symbols: int = 0
|
|
||||||
n_dates: int = 0
|
|
||||||
error: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class FactorBacktestService:
|
|
||||||
def __init__(self, engine: BacktestEngine) -> None:
|
|
||||||
self.engine = engine
|
|
||||||
|
|
||||||
def run(self, config: FactorConfig) -> FactorResult:
|
|
||||||
t0 = time.perf_counter()
|
|
||||||
run_id = uuid.uuid4().hex[:10]
|
|
||||||
|
|
||||||
def _err(msg: str) -> FactorResult:
|
|
||||||
return FactorResult(
|
|
||||||
run_id=run_id,
|
|
||||||
config=self._config_to_dict(config),
|
|
||||||
error=msg,
|
|
||||||
elapsed_ms=(time.perf_counter() - t0) * 1000,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 加载基础面板: 当前 enriched parquet 只持久化基础列, 指标因子可能需要运行时计算。
|
|
||||||
panel_columns = ["symbol", "date", "open", "high", "low", "close", "volume", "turnover_rate"]
|
|
||||||
if config.factor_name not in panel_columns:
|
|
||||||
panel_columns.append(config.factor_name)
|
|
||||||
load_start = config.start
|
|
||||||
if config.factor_name not in {"turnover_rate"}:
|
|
||||||
load_start = config.start - timedelta(days=FACTOR_WARMUP_DAYS)
|
|
||||||
|
|
||||||
panel = self.engine.load_panel(
|
|
||||||
config.symbols,
|
|
||||||
load_start,
|
|
||||||
config.end,
|
|
||||||
columns=panel_columns,
|
|
||||||
)
|
|
||||||
if panel.is_empty():
|
|
||||||
return _err("无数据,请检查日期范围或先运行盘后管道")
|
|
||||||
|
|
||||||
factor_col = config.factor_name
|
|
||||||
if factor_col not in panel.columns:
|
|
||||||
panel = self._compute_missing_factor(panel, factor_col)
|
|
||||||
if factor_col not in panel.columns:
|
|
||||||
return _err(f"因子列 '{factor_col}' 不存在于 enriched 数据中, 且无法从基础行情计算")
|
|
||||||
if "close" not in panel.columns:
|
|
||||||
return _err("enriched 数据缺少收盘价 close")
|
|
||||||
panel = panel.select(["symbol", "date", "close", factor_col])
|
|
||||||
panel = panel.filter((pl.col("date") >= config.start) & (pl.col("date") <= config.end))
|
|
||||||
|
|
||||||
# 过滤有效行
|
|
||||||
panel = panel.filter(
|
|
||||||
pl.col(factor_col).is_not_null()
|
|
||||||
& pl.col("close").is_not_null()
|
|
||||||
& (pl.col("close") > 0)
|
|
||||||
)
|
|
||||||
if panel.is_empty():
|
|
||||||
return _err("过滤后无有效数据")
|
|
||||||
|
|
||||||
n_symbols = panel["symbol"].n_unique()
|
|
||||||
n_dates = panel["date"].n_unique()
|
|
||||||
|
|
||||||
# 计算下期收益
|
|
||||||
# 根据调仓频率计算不同周期的 forward return
|
|
||||||
if config.rebalance == "daily":
|
|
||||||
panel = panel.with_columns(
|
|
||||||
(pl.col("close").shift(-1).over("symbol") / pl.col("close") - 1)
|
|
||||||
.alias("_next_return")
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# weekly/monthly: 计算到下个调仓日的收益
|
|
||||||
panel = self._calc_period_return(panel, config.rebalance)
|
|
||||||
|
|
||||||
# ── 1. IC 分析 ──
|
|
||||||
ic_df = self._calc_ic(panel, factor_col)
|
|
||||||
ic_series = [
|
|
||||||
{"date": str(row["date"]), "ic": round(float(row["ic"]), 4)}
|
|
||||||
for row in ic_df.iter_rows(named=True)
|
|
||||||
if row["ic"] is not None and not np.isnan(float(row["ic"]))
|
|
||||||
]
|
|
||||||
ic_values = [r["ic"] for r in ic_series]
|
|
||||||
ic_mean = float(np.mean(ic_values)) if ic_values else None
|
|
||||||
ic_std = float(np.std(ic_values)) if ic_values else None
|
|
||||||
ir = (ic_mean / ic_std) if (ic_mean is not None and ic_std and ic_std > 1e-8) else None
|
|
||||||
ic_win_rate = (sum(1 for v in ic_values if v > 0) / len(ic_values)) if ic_values else None
|
|
||||||
|
|
||||||
# ── 2. 分层回测 ──
|
|
||||||
panel = self._add_groups(panel, factor_col, config.n_groups)
|
|
||||||
group_nav = self._calc_group_nav(panel, config)
|
|
||||||
group_stats = self._calc_group_stats(group_nav, config.start, config.end)
|
|
||||||
|
|
||||||
# ── 3. 多空组合 ──
|
|
||||||
long_short_nav, long_short_stats = self._calc_long_short(group_nav, config)
|
|
||||||
|
|
||||||
elapsed = (time.perf_counter() - t0) * 1000
|
|
||||||
return FactorResult(
|
|
||||||
run_id=run_id,
|
|
||||||
config=self._config_to_dict(config),
|
|
||||||
ic_mean=round(ic_mean, 4) if ic_mean is not None else None,
|
|
||||||
ic_std=round(ic_std, 4) if ic_std is not None else None,
|
|
||||||
ir=round(ir, 4) if ir is not None else None,
|
|
||||||
ic_win_rate=round(ic_win_rate, 4) if ic_win_rate is not None else None,
|
|
||||||
ic_series=ic_series,
|
|
||||||
group_stats=group_stats,
|
|
||||||
group_nav=group_nav,
|
|
||||||
long_short_stats=long_short_stats,
|
|
||||||
long_short_nav=long_short_nav,
|
|
||||||
elapsed_ms=round(elapsed, 1),
|
|
||||||
n_symbols=n_symbols,
|
|
||||||
n_dates=n_dates,
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _compute_missing_factor(panel: pl.DataFrame, factor_col: str) -> pl.DataFrame:
|
|
||||||
required = {"symbol", "date", "open", "high", "low", "close", "volume"}
|
|
||||||
if not required.issubset(panel.columns):
|
|
||||||
missing = sorted(required - set(panel.columns))
|
|
||||||
logger.warning("factor %s cannot be computed, missing columns: %s", factor_col, missing)
|
|
||||||
return panel
|
|
||||||
|
|
||||||
from app.indicators.pipeline import compute_indicators
|
|
||||||
|
|
||||||
computed = compute_indicators(panel)
|
|
||||||
if factor_col not in computed.columns:
|
|
||||||
return panel
|
|
||||||
return computed.select(["symbol", "date", "close", factor_col])
|
|
||||||
|
|
||||||
# ── IC 计算 ──
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _calc_ic(panel: pl.DataFrame, factor_col: str) -> pl.DataFrame:
|
|
||||||
"""计算截面 Rank IC (因子值 rank vs 下期收益 rank 的相关系数)。"""
|
|
||||||
return (
|
|
||||||
panel.filter(pl.col("_next_return").is_not_null())
|
|
||||||
.group_by("date")
|
|
||||||
.agg(
|
|
||||||
pl.corr(
|
|
||||||
pl.col(factor_col).rank(method="random"),
|
|
||||||
pl.col("_next_return").rank(method="random"),
|
|
||||||
).alias("ic")
|
|
||||||
)
|
|
||||||
.sort("date")
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── 调仓期收益 ──
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _calc_period_return(panel: pl.DataFrame, rebalance: str) -> pl.DataFrame:
|
|
||||||
"""计算到下个调仓日的收益。
|
|
||||||
|
|
||||||
weekly: 下周一的 open / 今日 close - 1
|
|
||||||
monthly: 下月首个交易日的 open / 今日 close - 1
|
|
||||||
只在调仓日标记行有效,其他行为 null。
|
|
||||||
"""
|
|
||||||
import datetime as _dt
|
|
||||||
|
|
||||||
all_dates = sorted(panel["date"].unique().to_list())
|
|
||||||
date_set = set(all_dates)
|
|
||||||
|
|
||||||
if rebalance == "weekly":
|
|
||||||
# 调仓日 = 每周一
|
|
||||||
rebalance_dates = set()
|
|
||||||
for d in all_dates:
|
|
||||||
if hasattr(d, "weekday"):
|
|
||||||
wd = d.weekday()
|
|
||||||
else:
|
|
||||||
wd = _dt.date.fromisoformat(str(d)).weekday()
|
|
||||||
if wd == 0: # Monday
|
|
||||||
rebalance_dates.add(d)
|
|
||||||
else: # monthly
|
|
||||||
# 调仓日 = 每月首个交易日
|
|
||||||
seen_months: set[str] = set()
|
|
||||||
rebalance_dates = set()
|
|
||||||
for d in sorted(all_dates):
|
|
||||||
m = str(d)[:7] # "YYYY-MM"
|
|
||||||
if m not in seen_months:
|
|
||||||
seen_months.add(m)
|
|
||||||
rebalance_dates.add(d)
|
|
||||||
|
|
||||||
if not rebalance_dates:
|
|
||||||
panel = panel.with_columns(pl.lit(None).cast(pl.Float64).alias("_next_return"))
|
|
||||||
return panel
|
|
||||||
|
|
||||||
# 对每个调仓日,找到下一个调仓日
|
|
||||||
sorted_rebalance = sorted(rebalance_dates)
|
|
||||||
next_rebalance_map: dict = {}
|
|
||||||
for i, d in enumerate(sorted_rebalance):
|
|
||||||
if i + 1 < len(sorted_rebalance):
|
|
||||||
next_rebalance_map[d] = sorted_rebalance[i + 1]
|
|
||||||
# 最后一个调仓日没有下一个,不计算收益
|
|
||||||
|
|
||||||
# 构建 (date, symbol) → next_rebalance_date 的 close 价格映射
|
|
||||||
# 简化: 用下个调仓日的 close / 当前 close
|
|
||||||
panel = panel.sort(["symbol", "date"])
|
|
||||||
dates_col = panel["date"].to_list()
|
|
||||||
close_col = panel["close"].to_list()
|
|
||||||
symbol_col = panel["symbol"].to_list()
|
|
||||||
|
|
||||||
# 先找下个调仓日的 close
|
|
||||||
# 建立 (date, symbol) → close 的快速查找
|
|
||||||
price_map: dict[tuple, float] = {}
|
|
||||||
for i in range(len(dates_col)):
|
|
||||||
price_map[(str(dates_col[i]), symbol_col[i])] = close_col[i]
|
|
||||||
|
|
||||||
next_returns = [None] * len(panel)
|
|
||||||
for i in range(len(panel)):
|
|
||||||
d = dates_col[i]
|
|
||||||
d_val = d if isinstance(d, _dt.date) else _dt.date.fromisoformat(str(d))
|
|
||||||
if d not in rebalance_dates:
|
|
||||||
continue
|
|
||||||
next_d = next_rebalance_map.get(d)
|
|
||||||
if next_d is None:
|
|
||||||
continue
|
|
||||||
next_d_str = str(next_d)[:10]
|
|
||||||
d_str = str(d)[:10]
|
|
||||||
sym = symbol_col[i]
|
|
||||||
next_close = price_map.get((next_d_str, sym))
|
|
||||||
cur_close = close_col[i]
|
|
||||||
if next_close is not None and cur_close and cur_close > 0:
|
|
||||||
next_returns[i] = (next_close / cur_close - 1.0)
|
|
||||||
|
|
||||||
panel = panel.with_columns(
|
|
||||||
pl.Series("_next_return", next_returns, dtype=pl.Float64)
|
|
||||||
)
|
|
||||||
return panel
|
|
||||||
|
|
||||||
# ── 分组 ──
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _add_groups(panel: pl.DataFrame, factor_col: str, n_groups: int) -> pl.DataFrame:
|
|
||||||
"""截面分位数分组。"""
|
|
||||||
return panel.with_columns(
|
|
||||||
pl.col(factor_col)
|
|
||||||
.qcut(n_groups, labels=[f"Q{i+1}" for i in range(n_groups)])
|
|
||||||
.over("date")
|
|
||||||
.alias("_group")
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── 分组净值 ──
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _calc_group_nav(panel: pl.DataFrame, config: FactorConfig) -> list[dict]:
|
|
||||||
"""计算分组净值曲线 — 只在调仓日更新净值。"""
|
|
||||||
# 只保留有下期收益的行 (= 调仓日)
|
|
||||||
group_ret = (
|
|
||||||
panel.filter(pl.col("_next_return").is_not_null() & pl.col("_group").is_not_null())
|
|
||||||
.group_by(["date", "_group"])
|
|
||||||
.agg(pl.col("_next_return").mean().alias("group_return"))
|
|
||||||
)
|
|
||||||
|
|
||||||
# pivot: date × group
|
|
||||||
pivot = group_ret.pivot(index="date", columns="_group", values="group_return").sort("date")
|
|
||||||
|
|
||||||
if pivot.is_empty():
|
|
||||||
return []
|
|
||||||
|
|
||||||
group_cols = [c for c in pivot.columns if c != "date"]
|
|
||||||
|
|
||||||
# 累乘净值曲线
|
|
||||||
result: list[dict] = []
|
|
||||||
nav_values: dict[str, float] = {c: 1.0 for c in group_cols}
|
|
||||||
|
|
||||||
for row in pivot.iter_rows(named=True):
|
|
||||||
entry: dict = {"date": str(row["date"])[:10]}
|
|
||||||
for c in group_cols:
|
|
||||||
ret = float(row[c]) if row[c] is not None else 0.0
|
|
||||||
nav_values[c] *= (1 + ret)
|
|
||||||
entry[c] = round(nav_values[c], 4)
|
|
||||||
result.append(entry)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
# ── 分组统计 ──
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _calc_group_stats(
|
|
||||||
group_nav: list[dict], start: date, end: date,
|
|
||||||
) -> list[dict]:
|
|
||||||
if not group_nav:
|
|
||||||
return []
|
|
||||||
|
|
||||||
group_cols = [k for k in group_nav[0] if k != "date"]
|
|
||||||
n_days = max((end - start).days, 1)
|
|
||||||
years = n_days / 365.25
|
|
||||||
|
|
||||||
stats = []
|
|
||||||
for i, c in enumerate(sorted(group_cols)):
|
|
||||||
values = [r[c] for r in group_nav if r.get(c) is not None]
|
|
||||||
if not values:
|
|
||||||
continue
|
|
||||||
total_return = values[-1] - 1.0
|
|
||||||
annual_return = (values[-1]) ** (1 / max(years, 0.01)) - 1 if values[-1] > 0 else 0.0
|
|
||||||
|
|
||||||
# 最大回撤
|
|
||||||
peak = 1.0
|
|
||||||
max_dd = 0.0
|
|
||||||
for v in values:
|
|
||||||
peak = max(peak, v)
|
|
||||||
dd = (v - peak) / peak
|
|
||||||
max_dd = min(max_dd, dd)
|
|
||||||
|
|
||||||
# 日收益序列
|
|
||||||
daily_rets = []
|
|
||||||
for j in range(1, len(values)):
|
|
||||||
if values[j - 1] > 0:
|
|
||||||
daily_rets.append(values[j] / values[j - 1] - 1)
|
|
||||||
|
|
||||||
# 夏普
|
|
||||||
if daily_rets:
|
|
||||||
arr = np.array(daily_rets)
|
|
||||||
sharpe = float(np.mean(arr) / np.std(arr)) * np.sqrt(252) if np.std(arr) > 0 else 0.0
|
|
||||||
win_rate = float(np.mean(arr > 0))
|
|
||||||
else:
|
|
||||||
sharpe = 0.0
|
|
||||||
win_rate = 0.0
|
|
||||||
|
|
||||||
stats.append({
|
|
||||||
"group": i + 1,
|
|
||||||
"label": c,
|
|
||||||
"total_return": round(total_return, 4),
|
|
||||||
"annual_return": round(annual_return, 4),
|
|
||||||
"max_drawdown": round(max_dd, 4),
|
|
||||||
"sharpe": round(sharpe, 2),
|
|
||||||
"win_rate": round(win_rate, 4),
|
|
||||||
})
|
|
||||||
|
|
||||||
return stats
|
|
||||||
|
|
||||||
# ── 多空组合 ──
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _calc_long_short(
|
|
||||||
group_nav: list[dict], config: FactorConfig,
|
|
||||||
) -> tuple[list[dict], dict]:
|
|
||||||
"""多空组合: 做多最高组 + 做空最低组。"""
|
|
||||||
if not group_nav:
|
|
||||||
return [], {}
|
|
||||||
|
|
||||||
group_cols = sorted([k for k in group_nav[0] if k != "date"])
|
|
||||||
if len(group_cols) < 2:
|
|
||||||
return [], {}
|
|
||||||
|
|
||||||
top_col = group_cols[-1] # Q5 (最高)
|
|
||||||
bottom_col = group_cols[0] # Q1 (最低)
|
|
||||||
|
|
||||||
# 独立计算 top 和 bottom 的日收益,然后合成
|
|
||||||
ls_value = 1.0
|
|
||||||
prev_top = 1.0
|
|
||||||
prev_bot = 1.0
|
|
||||||
peak = 1.0
|
|
||||||
max_dd = 0.0
|
|
||||||
ls_nav: list[dict] = []
|
|
||||||
|
|
||||||
for row in group_nav:
|
|
||||||
top_nav = float(row.get(top_col, 1.0)) if row.get(top_col) is not None else 1.0
|
|
||||||
bot_nav = float(row.get(bottom_col, 1.0)) if row.get(bottom_col) is not None else 1.0
|
|
||||||
|
|
||||||
# top 组收益 (做多)
|
|
||||||
top_ret = (top_nav / prev_top - 1) if prev_top > 0 else 0.0
|
|
||||||
# bottom 组收益 (做空 = 取反)
|
|
||||||
bot_ret = -(bot_nav / prev_bot - 1) if prev_bot > 0 else 0.0
|
|
||||||
# 多空组合收益
|
|
||||||
ls_ret = (top_ret + bot_ret) / 2 # 各分配 50% 资金
|
|
||||||
ls_value *= (1 + ls_ret)
|
|
||||||
|
|
||||||
prev_top = top_nav
|
|
||||||
prev_bot = bot_nav
|
|
||||||
|
|
||||||
peak = max(peak, ls_value)
|
|
||||||
dd = (ls_value - peak) / peak if peak > 0 else 0.0
|
|
||||||
max_dd = min(max_dd, dd)
|
|
||||||
|
|
||||||
ls_nav.append({"date": row["date"], "value": round(ls_value, 4)})
|
|
||||||
|
|
||||||
total_ret = ls_value - 1.0
|
|
||||||
ls_stats = {
|
|
||||||
"total_return": round(total_ret, 4),
|
|
||||||
"max_drawdown": round(max_dd, 4),
|
|
||||||
"top_group": top_col,
|
|
||||||
"bottom_group": bottom_col,
|
|
||||||
}
|
|
||||||
|
|
||||||
return ls_nav, ls_stats
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _config_to_dict(c: FactorConfig) -> dict:
|
|
||||||
return {
|
|
||||||
"factor_name": c.factor_name,
|
|
||||||
"symbols": c.symbols,
|
|
||||||
"start": str(c.start),
|
|
||||||
"end": str(c.end),
|
|
||||||
"n_groups": c.n_groups,
|
|
||||||
"rebalance": c.rebalance,
|
|
||||||
"weight": c.weight,
|
|
||||||
"fees_pct": c.fees_pct,
|
|
||||||
"slippage_bps": c.slippage_bps,
|
|
||||||
}
|
|
||||||
@@ -1,721 +0,0 @@
|
|||||||
"""策略回测服务 — 复用 StrategyDef 体系做全周期回测。
|
|
||||||
|
|
||||||
核心优化: 向量化 filter_fn,不逐日调用 StrategyEngine.run()。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from datetime import date, timedelta
|
|
||||||
from typing import Callable, Literal
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import polars as pl
|
|
||||||
|
|
||||||
from app.backtest.engine import BacktestEngine, MatcherConfig, SimResult
|
|
||||||
from app.strategy.engine import StrategyEngine, StrategyDef
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
BENCHMARK_SYMBOL = "000001.SH"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class StrategyBacktestConfig:
|
|
||||||
strategy_id: str
|
|
||||||
symbols: list[str] | None
|
|
||||||
start: date
|
|
||||||
end: date
|
|
||||||
params: dict | None = None
|
|
||||||
overrides: dict | None = None
|
|
||||||
# matching 为向后兼容入口; 显式传 entry_fill/exit_fill 时以二者为准。
|
|
||||||
matching: Literal["close_t", "open_t+1"] = "open_t+1"
|
|
||||||
entry_fill: Literal["close_t", "open_t+1"] | None = None
|
|
||||||
exit_fill: Literal["close_t", "open_t+1"] | None = None
|
|
||||||
fees_pct: float = 0.0002
|
|
||||||
slippage_bps: float = 5.0
|
|
||||||
max_positions: int = 10
|
|
||||||
max_exposure_pct: float = 1.0
|
|
||||||
initial_capital: float = 1_000_000.0
|
|
||||||
position_sizing: Literal["equal", "score_weight"] = "equal"
|
|
||||||
mode: Literal["position", "full"] = "position"
|
|
||||||
holding_days: int = 5
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
if self.entry_fill is None:
|
|
||||||
self.entry_fill = self.matching
|
|
||||||
if self.exit_fill is None:
|
|
||||||
self.exit_fill = self.matching
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class StrategyBacktestResult:
|
|
||||||
run_id: str
|
|
||||||
config: dict
|
|
||||||
stats: dict = field(default_factory=dict)
|
|
||||||
equity_curve: list[dict] = field(default_factory=list)
|
|
||||||
drawdown_curve: list[dict] = field(default_factory=list)
|
|
||||||
benchmark_curve: list[dict] = field(default_factory=list)
|
|
||||||
trades: list[dict] = field(default_factory=list)
|
|
||||||
per_symbol_stats: list[dict] = field(default_factory=list)
|
|
||||||
strategy_info: dict = field(default_factory=dict)
|
|
||||||
elapsed_ms: float = 0.0
|
|
||||||
error: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class StrategyBacktestService:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
engine: BacktestEngine,
|
|
||||||
strategy_engine: StrategyEngine,
|
|
||||||
) -> None:
|
|
||||||
self.engine = engine
|
|
||||||
self.strategy_engine = strategy_engine
|
|
||||||
|
|
||||||
def run(
|
|
||||||
self,
|
|
||||||
config: StrategyBacktestConfig,
|
|
||||||
progress_cb: "Callable[[dict], None] | None" = None,
|
|
||||||
cancel_event: "threading.Event | None" = None,
|
|
||||||
) -> StrategyBacktestResult:
|
|
||||||
t0 = time.perf_counter()
|
|
||||||
run_id = uuid.uuid4().hex[:10]
|
|
||||||
|
|
||||||
def _err(msg: str) -> StrategyBacktestResult:
|
|
||||||
return StrategyBacktestResult(
|
|
||||||
run_id=run_id,
|
|
||||||
config=self._config_to_dict(config),
|
|
||||||
error=msg,
|
|
||||||
elapsed_ms=(time.perf_counter() - t0) * 1000,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 获取策略定义
|
|
||||||
try:
|
|
||||||
s = self.strategy_engine.get(config.strategy_id)
|
|
||||||
except ValueError as e:
|
|
||||||
return _err(str(e))
|
|
||||||
|
|
||||||
params = self._normalize_params(config.params or {}, s)
|
|
||||||
overrides = config.overrides or {}
|
|
||||||
basic_filter = self._effective_basic_filter(s, overrides)
|
|
||||||
entry_signals = self._effective_signals(overrides, "entry_signals", s.entry_signals)
|
|
||||||
exit_signals = self._effective_signals(overrides, "exit_signals", s.exit_signals)
|
|
||||||
stop_loss = self._override_value(overrides, "stop_loss", s.stop_loss)
|
|
||||||
take_profit = self._normalize_pct(
|
|
||||||
self._override_value(overrides, "take_profit", getattr(s, "take_profit", None)),
|
|
||||||
0.01,
|
|
||||||
5.0,
|
|
||||||
)
|
|
||||||
trailing_stop = self._normalize_pct(
|
|
||||||
self._override_value(overrides, "trailing_stop", getattr(s, "trailing_stop", None)),
|
|
||||||
0.005,
|
|
||||||
0.5,
|
|
||||||
)
|
|
||||||
trailing_take_profit_activate = self._normalize_pct(
|
|
||||||
self._override_value(overrides, "trailing_take_profit_activate", getattr(s, "trailing_take_profit_activate", None)),
|
|
||||||
0.01,
|
|
||||||
2.0,
|
|
||||||
)
|
|
||||||
trailing_take_profit_drawdown = self._normalize_pct(
|
|
||||||
self._override_value(overrides, "trailing_take_profit_drawdown", getattr(s, "trailing_take_profit_drawdown", None)),
|
|
||||||
0.005,
|
|
||||||
0.5,
|
|
||||||
)
|
|
||||||
if trailing_take_profit_activate is not None and trailing_take_profit_drawdown is not None:
|
|
||||||
trailing_take_profit_drawdown = min(trailing_take_profit_drawdown, trailing_take_profit_activate)
|
|
||||||
max_hold_days = self._override_value(overrides, "max_hold_days", s.max_hold_days)
|
|
||||||
score_min, score_max = self._normalize_score_range(
|
|
||||||
overrides.get("score_min"),
|
|
||||||
overrides.get("score_max"),
|
|
||||||
)
|
|
||||||
|
|
||||||
timing_ms: dict[str, float] = {}
|
|
||||||
|
|
||||||
# 加载面板 (含 warmup + 全量指标 + 信号)。warmup 只用于指标/形态计算, 不参与正式交易。
|
|
||||||
warmup_days = max(120, int(max(s.lookback_days or 1, 1) * 1.5))
|
|
||||||
load_start = config.start - timedelta(days=warmup_days)
|
|
||||||
|
|
||||||
# 全量模式: entries 只在正式区间触发, exits 需要 end 之后的尾部数据继续执行策略卖点。
|
|
||||||
# 若策略有 max_hold_days, 用它决定尾部窗口;否则 holding_days 只作为兜底观察上限。
|
|
||||||
full_horizon_days = int(max_hold_days or config.holding_days or 5)
|
|
||||||
full_horizon_days = max(full_horizon_days, 1)
|
|
||||||
load_end = config.end
|
|
||||||
if config.mode == "full":
|
|
||||||
fwd_buffer = full_horizon_days + 5 # 多取几天, 容错停牌缺口/open_t+1
|
|
||||||
load_end = config.end + timedelta(days=fwd_buffer * 2) # 日历日放宽, 确保覆盖 N 个交易日
|
|
||||||
|
|
||||||
t_load = time.perf_counter()
|
|
||||||
panel = self.engine.load_panel(config.symbols, load_start, load_end)
|
|
||||||
timing_ms["load_panel"] = round((time.perf_counter() - t_load) * 1000, 1)
|
|
||||||
if panel.is_empty():
|
|
||||||
return _err("无数据,请检查日期范围或先运行盘后管道")
|
|
||||||
|
|
||||||
formal_range = self._date_range_mask(panel, config.start, config.end)
|
|
||||||
if not formal_range.any():
|
|
||||||
return _err("正式回测区间内无数据")
|
|
||||||
|
|
||||||
t_signal = time.perf_counter()
|
|
||||||
|
|
||||||
# basic_filter 只影响买入候选, 不能删除行情 panel, 否则持仓 mark / 卖出 / full forward return 都会失真。
|
|
||||||
basic_mask = pl.Series("_basic", [True] * len(panel), dtype=pl.Boolean)
|
|
||||||
if basic_filter and basic_filter.get("enabled", True):
|
|
||||||
expr = StrategyEngine._basic_filter_expr(panel, basic_filter)
|
|
||||||
if expr is not None:
|
|
||||||
try:
|
|
||||||
basic_mask = panel.select(expr.alias("_basic"))["_basic"].fill_null(False).cast(pl.Boolean)
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
logger.warning("basic_filter mask failed: %s", e)
|
|
||||||
return _err(f"基础过滤计算失败: {e}")
|
|
||||||
|
|
||||||
# 策略候选层用于评分归一化;entry_signals 只是买点层, 不参与 score universe。
|
|
||||||
candidate_filter_mask = self._build_candidate_filter_mask(panel, s, params)
|
|
||||||
candidate_mask = basic_mask & candidate_filter_mask
|
|
||||||
panel = self._apply_score(panel, s, overrides, universe_mask=candidate_mask)
|
|
||||||
|
|
||||||
entry_mask = self._build_entry_mask_from_candidate(panel, candidate_mask, s, entry_signals)
|
|
||||||
entry_mask = entry_mask & formal_range
|
|
||||||
raw_exit_mask = self._build_signal_mask(panel, exit_signals, "_exit")
|
|
||||||
exit_mask = raw_exit_mask & (self._date_range_mask(panel, config.start, load_end) if config.mode == "full" else formal_range)
|
|
||||||
timing_ms["signals_score"] = round((time.perf_counter() - t_signal) * 1000, 1)
|
|
||||||
|
|
||||||
if not entry_mask.any():
|
|
||||||
return _err("在指定区间内未产生买入信号")
|
|
||||||
|
|
||||||
# warmup 之后才交给撮合;full mode 保留 end 之后前瞻段用于 shift(-N)。
|
|
||||||
sim_end = load_end if config.mode == "full" else config.end
|
|
||||||
sim_range = self._date_range_mask(panel, config.start, sim_end)
|
|
||||||
sim_panel = panel.filter(sim_range)
|
|
||||||
sim_entry_mask = entry_mask.filter(sim_range)
|
|
||||||
sim_exit_mask = exit_mask.filter(sim_range)
|
|
||||||
if sim_panel.is_empty():
|
|
||||||
return _err("正式回测区间内无数据")
|
|
||||||
|
|
||||||
t_sim = time.perf_counter()
|
|
||||||
matcher_config = MatcherConfig(
|
|
||||||
matching=config.matching,
|
|
||||||
entry_fill=config.entry_fill,
|
|
||||||
exit_fill=config.exit_fill,
|
|
||||||
fees_pct=config.fees_pct,
|
|
||||||
slippage_bps=config.slippage_bps,
|
|
||||||
stop_loss_pct=stop_loss,
|
|
||||||
take_profit_pct=take_profit,
|
|
||||||
trailing_stop_pct=trailing_stop,
|
|
||||||
trailing_take_profit_activate_pct=trailing_take_profit_activate,
|
|
||||||
trailing_take_profit_drawdown_pct=trailing_take_profit_drawdown,
|
|
||||||
max_hold_days=max_hold_days,
|
|
||||||
max_positions=config.max_positions,
|
|
||||||
max_exposure_pct=config.max_exposure_pct,
|
|
||||||
score_min=score_min,
|
|
||||||
score_max=score_max,
|
|
||||||
initial_capital=config.initial_capital,
|
|
||||||
position_sizing=config.position_sizing,
|
|
||||||
)
|
|
||||||
# 撮合 — full 为全候选独立执行;position 为账户级仓位模拟。
|
|
||||||
if config.mode == "full":
|
|
||||||
result = self.engine.simulate_independent_candidates(
|
|
||||||
sim_panel,
|
|
||||||
sim_entry_mask,
|
|
||||||
sim_exit_mask,
|
|
||||||
matcher_config,
|
|
||||||
progress_cb,
|
|
||||||
cancel_event,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
result = self.engine.simulate_portfolio(sim_panel, sim_entry_mask, sim_exit_mask, matcher_config, progress_cb, cancel_event)
|
|
||||||
timing_ms["simulate"] = round((time.perf_counter() - t_sim) * 1000, 1)
|
|
||||||
|
|
||||||
# 检查是否被取消
|
|
||||||
if cancel_event is not None and cancel_event.is_set():
|
|
||||||
return StrategyBacktestResult(
|
|
||||||
run_id=run_id,
|
|
||||||
config=self._config_to_dict(config),
|
|
||||||
error="cancelled",
|
|
||||||
elapsed_ms=round((time.perf_counter() - t0) * 1000, 1),
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.stats.get("error"):
|
|
||||||
return _err(result.stats["error"])
|
|
||||||
|
|
||||||
timing_ms["total"] = round((time.perf_counter() - t0) * 1000, 1)
|
|
||||||
result.stats["timing_ms"] = timing_ms
|
|
||||||
result.stats["panel_rows"] = int(sim_panel.height)
|
|
||||||
|
|
||||||
benchmark_curve = self._build_benchmark_curve(config.start, config.end)
|
|
||||||
|
|
||||||
# 构建策略信息
|
|
||||||
strategy_info = {
|
|
||||||
"id": s.meta.get("id", config.strategy_id),
|
|
||||||
"name": s.meta.get("name", config.strategy_id),
|
|
||||||
"description": s.meta.get("description", ""),
|
|
||||||
"entry_signals": entry_signals,
|
|
||||||
"exit_signals": exit_signals,
|
|
||||||
"stop_loss": stop_loss,
|
|
||||||
"take_profit": take_profit,
|
|
||||||
"trailing_stop": trailing_stop,
|
|
||||||
"trailing_take_profit_activate": trailing_take_profit_activate,
|
|
||||||
"trailing_take_profit_drawdown": trailing_take_profit_drawdown,
|
|
||||||
"max_hold_days": max_hold_days,
|
|
||||||
"full_horizon_days": full_horizon_days,
|
|
||||||
"score_min": score_min,
|
|
||||||
"score_max": score_max,
|
|
||||||
"source": s.source,
|
|
||||||
}
|
|
||||||
|
|
||||||
elapsed = (time.perf_counter() - t0) * 1000
|
|
||||||
|
|
||||||
return StrategyBacktestResult(
|
|
||||||
run_id=run_id,
|
|
||||||
config=self._config_to_dict(config),
|
|
||||||
stats=result.stats,
|
|
||||||
equity_curve=result.equity_curve,
|
|
||||||
drawdown_curve=result.drawdown_curve,
|
|
||||||
benchmark_curve=benchmark_curve,
|
|
||||||
trades=[self._trade_to_dict(t) for t in result.trades],
|
|
||||||
per_symbol_stats=result.per_symbol_stats,
|
|
||||||
strategy_info=strategy_info,
|
|
||||||
elapsed_ms=round(elapsed, 1),
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── 全量模拟 (选股能力统计, 不建组合不算净值) ──
|
|
||||||
|
|
||||||
def _run_full_simulation(
|
|
||||||
self,
|
|
||||||
panel: pl.DataFrame,
|
|
||||||
entry_mask: pl.Series,
|
|
||||||
holding_days: int,
|
|
||||||
) -> SimResult:
|
|
||||||
"""对 entry_mask 命中的全部候选, 算持有 N 天后的前瞻收益统计。
|
|
||||||
|
|
||||||
不受 max_positions/资金约束, 反映策略选股能力本身。
|
|
||||||
equity_curve 复用为"累计日均超额收益曲线"(基准归零)。
|
|
||||||
"""
|
|
||||||
n = holding_days if holding_days and holding_days > 0 else 5
|
|
||||||
|
|
||||||
df = panel.with_columns([
|
|
||||||
entry_mask.cast(pl.Boolean).alias("_is_candidate"),
|
|
||||||
(pl.col("close").shift(-n).over("symbol") / pl.col("close") - 1).alias("_fwd_return"),
|
|
||||||
]).filter(
|
|
||||||
pl.col("_is_candidate")
|
|
||||||
& pl.col("_fwd_return").is_not_null()
|
|
||||||
& pl.col("_fwd_return").is_not_nan()
|
|
||||||
)
|
|
||||||
|
|
||||||
if df.is_empty():
|
|
||||||
return self.engine._empty_result()
|
|
||||||
|
|
||||||
fwd = df["_fwd_return"].to_numpy()
|
|
||||||
wins = fwd[fwd > 0]
|
|
||||||
losses = fwd[fwd <= 0]
|
|
||||||
avg_win = float(wins.mean()) if wins.size else 0.0
|
|
||||||
avg_loss = abs(float(losses.mean())) if losses.size else 0.0
|
|
||||||
|
|
||||||
# 按日聚合: 当日候选的平均前瞻收益
|
|
||||||
daily = (
|
|
||||||
df.group_by("date").agg(
|
|
||||||
pl.col("_fwd_return").mean().alias("avg_ret"),
|
|
||||||
pl.col("_fwd_return").count().alias("n_cand"),
|
|
||||||
).sort("date")
|
|
||||||
)
|
|
||||||
|
|
||||||
# 累计超额曲线: 每日复利平均收益 (基准归零, 故 equity 即累计策略收益)
|
|
||||||
equity_curve: list[dict] = []
|
|
||||||
equity = 1.0
|
|
||||||
peak = 1.0
|
|
||||||
drawdown_curve: list[dict] = []
|
|
||||||
for row in daily.iter_rows(named=True):
|
|
||||||
ret = float(row["avg_ret"] or 0.0)
|
|
||||||
equity *= (1 + ret)
|
|
||||||
peak = max(peak, equity)
|
|
||||||
dd = (equity - peak) / peak if peak > 0 else 0.0
|
|
||||||
d_str = str(row["date"])[:10]
|
|
||||||
equity_curve.append({
|
|
||||||
"date": d_str,
|
|
||||||
"value": round(equity, 4),
|
|
||||||
"positions": int(row["n_cand"]),
|
|
||||||
})
|
|
||||||
drawdown_curve.append({"date": d_str, "value": round(dd, 4)})
|
|
||||||
|
|
||||||
# 同期上证收益 (用 benchmark close 算)
|
|
||||||
benchmark_curve = self._build_benchmark_curve(
|
|
||||||
daily["date"].min(), daily["date"].max()
|
|
||||||
)
|
|
||||||
benchmark_return = 0.0
|
|
||||||
if benchmark_curve:
|
|
||||||
closes = [b["close"] for b in benchmark_curve if b.get("close")]
|
|
||||||
if len(closes) >= 2 and closes[0] > 0:
|
|
||||||
benchmark_return = closes[-1] / closes[0] - 1
|
|
||||||
|
|
||||||
total_return = equity - 1.0
|
|
||||||
max_dd = min((d["value"] for d in drawdown_curve), default=0.0)
|
|
||||||
|
|
||||||
# 日收益序列算 Sharpe (年化)
|
|
||||||
daily_rets = daily["avg_ret"].to_numpy()
|
|
||||||
sharpe = (
|
|
||||||
float(daily_rets.mean() / daily_rets.std() * np.sqrt(252))
|
|
||||||
if daily_rets.size > 1 and daily_rets.std() > 0 else 0.0
|
|
||||||
)
|
|
||||||
|
|
||||||
# 收益分布直方图: 按 [-20%, +20%] 分 21 档 (每档 2%), 超出归入首尾档
|
|
||||||
lo, hi, nbins = -0.20, 0.20, 20
|
|
||||||
clipped = np.clip(fwd, lo, hi)
|
|
||||||
counts, edges = np.histogram(clipped, bins=nbins, range=(lo, hi))
|
|
||||||
dist = [
|
|
||||||
{
|
|
||||||
"range": f"{(edges[i]*100):+.0f}~{(edges[i+1]*100):+.0f}%",
|
|
||||||
"count": int(counts[i]),
|
|
||||||
"ratio": round(float(counts[i] / fwd.size), 4) if fwd.size else 0.0,
|
|
||||||
}
|
|
||||||
for i in range(nbins)
|
|
||||||
]
|
|
||||||
|
|
||||||
stats = {
|
|
||||||
"mode": "full",
|
|
||||||
"n_candidates": int(fwd.size),
|
|
||||||
"n_days": int(daily.height),
|
|
||||||
"avg_daily_candidates": round(float(daily["n_cand"].mean()), 1),
|
|
||||||
"avg_return": round(float(fwd.mean()), 4),
|
|
||||||
"median_return": round(float(np.median(fwd)), 4),
|
|
||||||
"win_rate": round(float(wins.size / fwd.size), 4) if fwd.size else 0.0,
|
|
||||||
"profit_factor": round(avg_win / avg_loss, 2) if avg_loss > 0 else None,
|
|
||||||
"best": round(float(fwd.max()), 4),
|
|
||||||
"worst": round(float(fwd.min()), 4),
|
|
||||||
"total_return": round(float(total_return), 4),
|
|
||||||
"max_drawdown": round(float(max_dd), 4),
|
|
||||||
"sharpe": round(sharpe, 2),
|
|
||||||
"benchmark_return": round(float(benchmark_return), 4),
|
|
||||||
"excess": round(float(total_return - benchmark_return), 4),
|
|
||||||
"return_distribution": dist,
|
|
||||||
}
|
|
||||||
|
|
||||||
return SimResult(
|
|
||||||
equity_curve=equity_curve,
|
|
||||||
drawdown_curve=drawdown_curve,
|
|
||||||
trades=[],
|
|
||||||
per_symbol_stats=[],
|
|
||||||
stats=stats,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── 向量化信号生成 ──
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _date_range_mask(panel: pl.DataFrame, start: date, end: date) -> pl.Series:
|
|
||||||
return panel.select(
|
|
||||||
((pl.col("date") >= start) & (pl.col("date") <= end)).alias("_range")
|
|
||||||
)["_range"].fill_null(False).cast(pl.Boolean)
|
|
||||||
|
|
||||||
def _build_candidate_filter_mask(
|
|
||||||
self,
|
|
||||||
panel: pl.DataFrame,
|
|
||||||
s: StrategyDef,
|
|
||||||
params: dict,
|
|
||||||
) -> pl.Series:
|
|
||||||
"""生成策略候选层 mask。filter_history/filter 决定候选池, 不包含 entry_signals。"""
|
|
||||||
false_mask = pl.Series("_candidate_filter", [False] * len(panel), dtype=pl.Boolean)
|
|
||||||
true_mask = pl.Series("_candidate_filter", [True] * len(panel), dtype=pl.Boolean)
|
|
||||||
|
|
||||||
history_failed = False
|
|
||||||
# 优先: filter_history_fn 策略 (涨停/反包等多日形态, 与选股路径共用同一逻辑)
|
|
||||||
if s.filter_history_fn:
|
|
||||||
try:
|
|
||||||
hit_df = s.filter_history_fn(panel, params)
|
|
||||||
if hit_df is None or hit_df.is_empty():
|
|
||||||
return false_mask
|
|
||||||
# 命中行 (symbol,date) → 转 panel 等长布尔 mask
|
|
||||||
hits = hit_df.select(["symbol", "date"]).unique()
|
|
||||||
marked = (
|
|
||||||
panel.select(["symbol", "date"])
|
|
||||||
.join(
|
|
||||||
hits.with_columns(pl.lit(True).alias("_hit")),
|
|
||||||
on=["symbol", "date"],
|
|
||||||
how="left",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return marked["_hit"].fill_null(False).cast(pl.Boolean)
|
|
||||||
except Exception as e:
|
|
||||||
history_failed = True
|
|
||||||
logger.warning("strategy filter_history_fn failed: %s", e)
|
|
||||||
# 失败则回退到 filter_fn (若存在)
|
|
||||||
|
|
||||||
# 策略 filter_fn: 候选层 (filter_history 不可用或失败时)
|
|
||||||
if s.filter_fn:
|
|
||||||
try:
|
|
||||||
expr = s.filter_fn(panel, params)
|
|
||||||
if expr is not None:
|
|
||||||
result = panel.select(expr.alias("_candidate_filter"))
|
|
||||||
if not result.is_empty():
|
|
||||||
return result["_candidate_filter"].fill_null(False).cast(pl.Boolean)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("strategy filter_fn failed: %s", e)
|
|
||||||
return false_mask
|
|
||||||
|
|
||||||
if history_failed:
|
|
||||||
return false_mask
|
|
||||||
|
|
||||||
# 没有策略候选层时, 由 entry_signals 直接决定买点。
|
|
||||||
return true_mask
|
|
||||||
|
|
||||||
def _build_entry_mask_from_candidate(
|
|
||||||
self,
|
|
||||||
panel: pl.DataFrame,
|
|
||||||
candidate_mask: pl.Series,
|
|
||||||
s: StrategyDef,
|
|
||||||
entry_signals: list[str],
|
|
||||||
) -> pl.Series:
|
|
||||||
"""向量化生成买入掩码:候选层 AND 买点层;无买点时只用策略候选层。"""
|
|
||||||
signal_mask = self._build_signal_mask(panel, entry_signals, "_entry_signal")
|
|
||||||
if entry_signals:
|
|
||||||
return candidate_mask & signal_mask
|
|
||||||
if s.filter_history_fn or s.filter_fn:
|
|
||||||
return candidate_mask
|
|
||||||
return pl.Series("_entry", [False] * len(panel), dtype=pl.Boolean)
|
|
||||||
|
|
||||||
def _build_entry_mask(
|
|
||||||
self,
|
|
||||||
panel: pl.DataFrame,
|
|
||||||
s: StrategyDef,
|
|
||||||
params: dict,
|
|
||||||
entry_signals: list[str],
|
|
||||||
) -> pl.Series:
|
|
||||||
"""兼容旧调用: 候选层 AND 买点层。"""
|
|
||||||
candidate_mask = self._build_candidate_filter_mask(panel, s, params)
|
|
||||||
return self._build_entry_mask_from_candidate(panel, candidate_mask, s, entry_signals)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _build_signal_mask(panel: pl.DataFrame, signals: list[str], name: str) -> pl.Series:
|
|
||||||
"""向量化合并信号列,多个信号 OR。支持内置 signal_ 与自定义 csg_ 前缀。"""
|
|
||||||
masks: list[pl.Series] = []
|
|
||||||
for sig in signals:
|
|
||||||
# csg_ (自定义信号) 直接用;否则按 signal_ 解析
|
|
||||||
col = sig if (sig.startswith("signal_") or sig.startswith("csg_")) else f"signal_{sig}"
|
|
||||||
if col in panel.columns:
|
|
||||||
masks.append(panel[col].fill_null(False).cast(pl.Boolean))
|
|
||||||
|
|
||||||
if not masks:
|
|
||||||
return pl.Series(name, [False] * len(panel), dtype=pl.Boolean)
|
|
||||||
|
|
||||||
combined = masks[0]
|
|
||||||
for m in masks[1:]:
|
|
||||||
combined = combined | m
|
|
||||||
return combined
|
|
||||||
|
|
||||||
def _build_benchmark_curve(self, start: date, end: date) -> list[dict]:
|
|
||||||
try:
|
|
||||||
df = self.engine.repo.get_index_daily(BENCHMARK_SYMBOL, start, end, columns=["date", "close"])
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("load benchmark %s failed: %s", BENCHMARK_SYMBOL, e)
|
|
||||||
return []
|
|
||||||
|
|
||||||
if df.is_empty() or "close" not in df.columns:
|
|
||||||
return []
|
|
||||||
|
|
||||||
df = df.filter(pl.col("close").is_not_null() & (pl.col("close") > 0)).sort("date")
|
|
||||||
if df.is_empty():
|
|
||||||
return []
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"date": str(row["date"])[:10],
|
|
||||||
"value": round(float(row["close"]), 4),
|
|
||||||
"close": round(float(row["close"]), 4),
|
|
||||||
"name": "上证指数",
|
|
||||||
"symbol": BENCHMARK_SYMBOL,
|
|
||||||
}
|
|
||||||
for row in df.iter_rows(named=True)
|
|
||||||
if row["close"] is not None
|
|
||||||
]
|
|
||||||
|
|
||||||
# ── 工具 ──
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _effective_basic_filter(s: StrategyDef, overrides: dict) -> dict:
|
|
||||||
basic_filter = dict(s.basic_filter or {})
|
|
||||||
override_filter = overrides.get("basic_filter")
|
|
||||||
if isinstance(override_filter, dict):
|
|
||||||
basic_filter.update(override_filter)
|
|
||||||
return basic_filter
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _effective_signals(overrides: dict, key: str, default: list[str]) -> list[str]:
|
|
||||||
value = overrides.get(key)
|
|
||||||
if isinstance(value, list):
|
|
||||||
return [str(v) for v in value if v]
|
|
||||||
return list(default or [])
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _override_value(overrides: dict, key: str, default):
|
|
||||||
if key in overrides:
|
|
||||||
return overrides.get(key)
|
|
||||||
return default
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _normalize_pct(value, min_value: float, max_value: float) -> float | None:
|
|
||||||
if value is None or value == "":
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
pct = abs(float(value))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
return min(max(pct, min_value), max_value)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _normalize_score_range(min_value, max_value) -> tuple[float | None, float | None]:
|
|
||||||
def _bound(value) -> float | None:
|
|
||||||
if value is None or value == "":
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
score = float(value)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
if not np.isfinite(score):
|
|
||||||
return None
|
|
||||||
return min(max(score, 0.0), 100.0)
|
|
||||||
|
|
||||||
score_min = _bound(min_value)
|
|
||||||
score_max = _bound(max_value)
|
|
||||||
if score_min is not None and score_max is not None and score_min > score_max:
|
|
||||||
score_min, score_max = score_max, score_min
|
|
||||||
return score_min, score_max
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _normalize_params(params: dict, s: StrategyDef) -> dict:
|
|
||||||
normalized = dict(params)
|
|
||||||
for param in s.meta.get("params", []):
|
|
||||||
pid = param.get("id")
|
|
||||||
if not pid:
|
|
||||||
continue
|
|
||||||
value = normalized.get(pid, param.get("default"))
|
|
||||||
p_type = param.get("type")
|
|
||||||
if p_type in {"float", "int"}:
|
|
||||||
try:
|
|
||||||
num = float(value)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
num = float(param.get("default", 0) or 0)
|
|
||||||
if param.get("min") is not None:
|
|
||||||
num = max(num, float(param["min"]))
|
|
||||||
if param.get("max") is not None:
|
|
||||||
num = min(num, float(param["max"]))
|
|
||||||
normalized[pid] = int(num) if p_type == "int" else num
|
|
||||||
elif p_type == "select" and param.get("options"):
|
|
||||||
normalized[pid] = value if value in param["options"] else param.get("default")
|
|
||||||
elif p_type == "bool":
|
|
||||||
if isinstance(value, bool):
|
|
||||||
normalized[pid] = value
|
|
||||||
elif isinstance(value, str):
|
|
||||||
normalized[pid] = value.lower() == "true"
|
|
||||||
else:
|
|
||||||
normalized[pid] = bool(param.get("default", False))
|
|
||||||
else:
|
|
||||||
normalized[pid] = value
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _trade_to_dict(t) -> dict:
|
|
||||||
return {
|
|
||||||
"symbol": t.symbol,
|
|
||||||
"name": t.name,
|
|
||||||
"entry_date": str(t.entry_date) if isinstance(t.entry_date, date) else str(t.entry_date),
|
|
||||||
"exit_date": str(t.exit_date) if isinstance(t.exit_date, date) else str(t.exit_date),
|
|
||||||
"entry_price": t.entry_price,
|
|
||||||
"exit_price": t.exit_price,
|
|
||||||
"pnl_pct": t.pnl_pct,
|
|
||||||
"duration": t.duration,
|
|
||||||
"exit_reason": t.exit_reason,
|
|
||||||
"shares": t.shares,
|
|
||||||
"lots": t.lots,
|
|
||||||
"position_pct": t.position_pct,
|
|
||||||
"entry_value": t.entry_value,
|
|
||||||
"exit_value": t.exit_value,
|
|
||||||
"pnl_amount": t.pnl_amount,
|
|
||||||
"entry_score": getattr(t, "entry_score", None),
|
|
||||||
"entry_signal_date": str(t.entry_signal_date) if getattr(t, "entry_signal_date", None) is not None else None,
|
|
||||||
"exit_signal_date": str(t.exit_signal_date) if getattr(t, "exit_signal_date", None) is not None else None,
|
|
||||||
"blocked_exit_days": getattr(t, "blocked_exit_days", 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _config_to_dict(c: StrategyBacktestConfig) -> dict:
|
|
||||||
score_min, score_max = StrategyBacktestService._normalize_score_range(
|
|
||||||
(c.overrides or {}).get("score_min"),
|
|
||||||
(c.overrides or {}).get("score_max"),
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"strategy_id": c.strategy_id,
|
|
||||||
"symbols": c.symbols,
|
|
||||||
"start": str(c.start),
|
|
||||||
"end": str(c.end),
|
|
||||||
"params": c.params,
|
|
||||||
"overrides": c.overrides,
|
|
||||||
"score_min": score_min,
|
|
||||||
"score_max": score_max,
|
|
||||||
"matching": c.matching,
|
|
||||||
"entry_fill": c.entry_fill,
|
|
||||||
"exit_fill": c.exit_fill,
|
|
||||||
"fees_pct": c.fees_pct,
|
|
||||||
"slippage_bps": c.slippage_bps,
|
|
||||||
"max_positions": c.max_positions,
|
|
||||||
"max_exposure_pct": c.max_exposure_pct,
|
|
||||||
"initial_capital": c.initial_capital,
|
|
||||||
"position_sizing": c.position_sizing,
|
|
||||||
"mode": c.mode,
|
|
||||||
"holding_days": c.holding_days,
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _apply_score(
|
|
||||||
panel: pl.DataFrame,
|
|
||||||
s: StrategyDef,
|
|
||||||
overrides: dict | None,
|
|
||||||
universe_mask: pl.Series | None = None,
|
|
||||||
) -> pl.DataFrame:
|
|
||||||
scoring = s.meta.get("scoring", {})
|
|
||||||
scoring_overrides = (overrides or {}).get("scoring")
|
|
||||||
if scoring_overrides:
|
|
||||||
scoring = {**scoring, **scoring_overrides}
|
|
||||||
|
|
||||||
work = panel
|
|
||||||
has_universe = universe_mask is not None and len(universe_mask) == len(panel)
|
|
||||||
if has_universe:
|
|
||||||
work = work.with_columns(universe_mask.rename("_score_universe"))
|
|
||||||
|
|
||||||
def _value_in_universe(col: str) -> pl.Expr:
|
|
||||||
if has_universe:
|
|
||||||
return pl.when(pl.col("_score_universe")).then(pl.col(col)).otherwise(None)
|
|
||||||
return pl.col(col)
|
|
||||||
|
|
||||||
def _finish(df: pl.DataFrame) -> pl.DataFrame:
|
|
||||||
return df.drop("_score_universe") if "_score_universe" in df.columns else df
|
|
||||||
|
|
||||||
if scoring:
|
|
||||||
total_weight = sum(scoring.values())
|
|
||||||
if total_weight > 0:
|
|
||||||
score_parts: list[pl.Expr] = []
|
|
||||||
for col, weight in scoring.items():
|
|
||||||
if col not in work.columns:
|
|
||||||
continue
|
|
||||||
w = weight / total_weight
|
|
||||||
value = _value_in_universe(col)
|
|
||||||
col_min = value.min().over("date")
|
|
||||||
col_max = value.max().over("date")
|
|
||||||
col_range = col_max - col_min
|
|
||||||
normalized = pl.when(col_range > 0).then(
|
|
||||||
(pl.col(col) - col_min) / col_range
|
|
||||||
).otherwise(pl.lit(0.5))
|
|
||||||
if has_universe:
|
|
||||||
normalized = pl.when(pl.col("_score_universe")).then(normalized).otherwise(0.0)
|
|
||||||
score_parts.append(normalized * w)
|
|
||||||
if score_parts:
|
|
||||||
score_expr = score_parts[0]
|
|
||||||
for part in score_parts[1:]:
|
|
||||||
score_expr = score_expr + part
|
|
||||||
return _finish(work.with_columns((score_expr * 100).fill_null(0).alias("score")))
|
|
||||||
|
|
||||||
order_by = s.meta.get("order_by")
|
|
||||||
if order_by and order_by != "score" and order_by in work.columns:
|
|
||||||
direction = 1 if s.meta.get("descending", True) else -1
|
|
||||||
score_expr = pl.col(order_by).fill_null(0) * direction
|
|
||||||
if has_universe:
|
|
||||||
score_expr = pl.when(pl.col("_score_universe")).then(score_expr).otherwise(0.0)
|
|
||||||
return _finish(work.with_columns(score_expr.alias("score")))
|
|
||||||
return _finish(work.with_columns(pl.lit(0.0).alias("score")))
|
|
||||||
@@ -93,8 +93,6 @@ class Settings(BaseSettings):
|
|||||||
host: str = "0.0.0.0"
|
host: str = "0.0.0.0"
|
||||||
port: int = 3018
|
port: int = 3018
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
backtest_range_guard: bool = False
|
|
||||||
|
|
||||||
# Auth — 首次启动时预置访问密码(明文, 仅用于初始化, 详见 services/auth.bootstrap_from_env)
|
# Auth — 首次启动时预置访问密码(明文, 仅用于初始化, 详见 services/auth.bootstrap_from_env)
|
||||||
# 公网服务器部署时免去 SSH 端口转发设密码的麻烦。写入 auth.json(哈希)后即不再读取。
|
# 公网服务器部署时免去 SSH 端口转发设密码的麻烦。写入 auth.json(哈希)后即不再读取。
|
||||||
auth_password: str = ""
|
auth_password: str = ""
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ class ProviderCapabilities:
|
|||||||
daily: bool = False
|
daily: bool = False
|
||||||
adj_factor: bool = False
|
adj_factor: bool = False
|
||||||
minute: bool = False
|
minute: bool = False
|
||||||
realtime: bool = False
|
|
||||||
financial: bool = False
|
financial: bool = False
|
||||||
|
|
||||||
|
|
||||||
@@ -59,10 +58,3 @@ class MarketDataProvider(Protocol):
|
|||||||
freq: str = "1m",
|
freq: str = "1m",
|
||||||
) -> pl.DataFrame:
|
) -> pl.DataFrame:
|
||||||
"""Return normalized minute K rows. Implementations may return empty."""
|
"""Return normalized minute K rows. Implementations may return empty."""
|
||||||
|
|
||||||
def get_realtime(
|
|
||||||
self,
|
|
||||||
universes: list[str] | None = None,
|
|
||||||
symbols: list[str] | None = None,
|
|
||||||
) -> pl.DataFrame:
|
|
||||||
"""Return normalized realtime quotes. Implementations may return empty."""
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ class TickFlowProvider:
|
|||||||
daily=True,
|
daily=True,
|
||||||
adj_factor=True,
|
adj_factor=True,
|
||||||
minute=True,
|
minute=True,
|
||||||
realtime=True,
|
|
||||||
financial=True,
|
financial=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -103,18 +102,3 @@ class TickFlowProvider:
|
|||||||
# Existing minute sync remains in app.services.kline_sync for now.
|
# Existing minute sync remains in app.services.kline_sync for now.
|
||||||
return pl.DataFrame()
|
return pl.DataFrame()
|
||||||
|
|
||||||
def get_realtime(
|
|
||||||
self,
|
|
||||||
universes: list[str] | None = None,
|
|
||||||
symbols: list[str] | None = None,
|
|
||||||
) -> pl.DataFrame:
|
|
||||||
tf = get_client()
|
|
||||||
if universes and symbols:
|
|
||||||
raise ValueError("TickFlow realtime accepts either universes or symbols, not both")
|
|
||||||
if universes:
|
|
||||||
resp = tf.quotes.get_by_universes(universes=universes)
|
|
||||||
elif symbols:
|
|
||||||
resp = tf.quotes.get(symbols=symbols)
|
|
||||||
else:
|
|
||||||
return pl.DataFrame()
|
|
||||||
return pl.DataFrame(resp or [])
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ def _resolve_universe(capset: CapabilitySet) -> list[str]:
|
|||||||
"""解析标的池 — 以 CN_Equity_A (沪深京A股 ~5522只) 为主。
|
"""解析标的池 — 以 CN_Equity_A (沪深京A股 ~5522只) 为主。
|
||||||
|
|
||||||
有 batch 能力 → 直接拉 CN_Equity_A universe
|
有 batch 能力 → 直接拉 CN_Equity_A universe
|
||||||
其他用户 → 用 instruments parquet + watchlist 兜底
|
其他用户 → 用 instruments parquet 兜底
|
||||||
"""
|
"""
|
||||||
if capset.has(Cap.KLINE_DAILY_BATCH):
|
if capset.has(Cap.KLINE_DAILY_BATCH):
|
||||||
try:
|
try:
|
||||||
@@ -54,9 +54,8 @@ def _resolve_universe(capset: CapabilitySet) -> list[str]:
|
|||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
logger.warning("CN_Equity_A pool unavailable, fallback: %s", e)
|
logger.warning("CN_Equity_A pool unavailable, fallback: %s", e)
|
||||||
|
|
||||||
# Free 用户兜底: instruments parquet + watchlist + demo
|
# Free 用户兜底: instruments parquet + demo
|
||||||
base: set[str] = set(DEMO_SYMBOLS)
|
base: set[str] = set(DEMO_SYMBOLS)
|
||||||
base.update(get_pool("watchlist"))
|
|
||||||
d = Path(settings.data_dir)
|
d = Path(settings.data_dir)
|
||||||
inst_path = d / "instruments" / "instruments.parquet"
|
inst_path = d / "instruments" / "instruments.parquet"
|
||||||
if inst_path.exists():
|
if inst_path.exists():
|
||||||
@@ -558,15 +557,11 @@ REVIEW_JOB_ID = "scheduled_review"
|
|||||||
|
|
||||||
|
|
||||||
async def _run_scheduled_review(repo) -> None:
|
async def _run_scheduled_review(repo) -> None:
|
||||||
"""定时复盘 job: 流式生成复盘 → 实时推 SSE(开着页面可见) → 落盘归档 → 推飞书。
|
"""定时复盘 job: 流式生成复盘 → 落盘归档 → 推飞书。
|
||||||
|
|
||||||
与手动「生成复盘」体验一致: 流式事件经 quote_service.push_review_event →
|
与手动「生成复盘」体验一致。LLM 偶发断流(peer closed connection)时自动重试最多 2 次。
|
||||||
/api/intraday/stream 的 review_progress 事件 → 前端 reviewStore, 用户开着复盘页
|
|
||||||
即可看到报告边生成边显示, 切走再回来也能看到生成中/已生成。
|
|
||||||
LLM 偶发断流(peer closed connection)时自动重试最多 2 次。
|
|
||||||
任何异常都吞掉只记日志, 绝不影响调度器主循环。
|
任何异常都吞掉只记日志, 绝不影响调度器主循环。
|
||||||
"""
|
"""
|
||||||
import json
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from app.services import market_recap_reports
|
from app.services import market_recap_reports
|
||||||
@@ -577,18 +572,9 @@ async def _run_scheduled_review(repo) -> None:
|
|||||||
logger.info("scheduled review skipped: AI key not configured")
|
logger.info("scheduled review skipped: AI key not configured")
|
||||||
return
|
return
|
||||||
|
|
||||||
app_state = _get_app_state()
|
content, meta = await _stream_review_with_retry(repo)
|
||||||
quote_service = getattr(app_state, "quote_service", None) if app_state else None
|
|
||||||
depth_service = getattr(app_state, "depth_service", None) if app_state else None
|
|
||||||
|
|
||||||
content, meta = await _stream_review_with_retry(repo, quote_service, depth_service)
|
|
||||||
if not content:
|
if not content:
|
||||||
logger.warning("scheduled review produced no content (meta=%s)", meta)
|
logger.warning("scheduled review produced no content (meta=%s)", meta)
|
||||||
# 通知前端进入 error 态(若有页面在听)
|
|
||||||
if quote_service:
|
|
||||||
quote_service.push_review_event(json.dumps(
|
|
||||||
{"type": "error", "message": "复盘生成失败,请稍后手动重试"},
|
|
||||||
ensure_ascii=False))
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# 落盘: 与手动生成完全相同的归档格式
|
# 落盘: 与手动生成完全相同的归档格式
|
||||||
@@ -602,34 +588,17 @@ async def _run_scheduled_review(repo) -> None:
|
|||||||
})
|
})
|
||||||
logger.info("scheduled review saved: as_of=%s", meta.get("as_of"))
|
logger.info("scheduled review saved: as_of=%s", meta.get("as_of"))
|
||||||
|
|
||||||
# 通知前端: 生成完成且已归档(archived=true 让前端只刷新列表, 不重复归档)
|
|
||||||
if quote_service:
|
|
||||||
quote_service.push_review_event(json.dumps(
|
|
||||||
{"type": "done", "archived": True}, ensure_ascii=False))
|
|
||||||
|
|
||||||
# 推送到飞书(可选): 运行时读取配置, 用户改设置下次触发即生效。
|
# 推送到飞书(可选): 运行时读取配置, 用户改设置下次触发即生效。
|
||||||
# 失败静默降级, 不影响已归档的报告。
|
# 失败静默降级, 不影响已归档的报告。
|
||||||
_maybe_push_review(content, meta)
|
_maybe_push_review(content, meta)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
logger.exception("scheduled review failed: %s", e)
|
logger.exception("scheduled review failed: %s", e)
|
||||||
# 兜底: 异常时通知前端停止「生成中」状态, 避免页面卡在 streaming
|
|
||||||
try:
|
|
||||||
app_state = _get_app_state()
|
|
||||||
qs = getattr(app_state, "quote_service", None) if app_state else None
|
|
||||||
if qs:
|
|
||||||
import json as _json
|
|
||||||
qs.push_review_event(_json.dumps(
|
|
||||||
{"type": "error", "message": "复盘生成异常,请稍后手动重试"},
|
|
||||||
ensure_ascii=False))
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
async def _stream_review_with_retry(repo, quote_service, depth_service) -> tuple[str, dict]:
|
async def _stream_review_with_retry(repo) -> tuple[str, dict]:
|
||||||
"""流式生成复盘, 每个事件推 SSE + 累积内容。LLM 断流时最多重试 2 次。
|
"""流式生成复盘, 累积内容。LLM 断流时最多重试 2 次。
|
||||||
|
|
||||||
返回 (content, meta)。重试时推一个 retry 事件让前端清空已累积内容重新开始。
|
返回 (content, meta)。成功(收到 done/无 error)或耗尽重试后返回。
|
||||||
成功(收到 done/无 error)或耗尽重试后返回。
|
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
@@ -643,14 +612,10 @@ async def _stream_review_with_retry(repo, quote_service, depth_service) -> tuple
|
|||||||
content_parts = [] # 每次重试重新累积
|
content_parts = [] # 每次重试重新累积
|
||||||
failed = False
|
failed = False
|
||||||
try:
|
try:
|
||||||
async for evt_json in recap_market_stream(repo, quote_service, depth_service):
|
async for evt_json in recap_market_stream(repo):
|
||||||
evt = json.loads(evt_json)
|
evt = json.loads(evt_json)
|
||||||
t = evt.get("type")
|
t = evt.get("type")
|
||||||
|
|
||||||
# 推给前端(让开着页面的用户实时看到, 与手动一致)
|
|
||||||
if quote_service:
|
|
||||||
quote_service.push_review_event(evt_json)
|
|
||||||
|
|
||||||
if t == "meta":
|
if t == "meta":
|
||||||
last_meta = evt
|
last_meta = evt
|
||||||
elif t == "delta" and evt.get("content"):
|
elif t == "delta" and evt.get("content"):
|
||||||
@@ -675,10 +640,6 @@ async def _stream_review_with_retry(repo, quote_service, depth_service) -> tuple
|
|||||||
# 失败: 决定是否重试
|
# 失败: 决定是否重试
|
||||||
if attempt < max_attempts:
|
if attempt < max_attempts:
|
||||||
logger.info("scheduled review retrying in 3s (attempt %d → %d)", attempt, attempt + 1)
|
logger.info("scheduled review retrying in 3s (attempt %d → %d)", attempt, attempt + 1)
|
||||||
# 通知前端: 即将重试, 清空已累积内容重新开始
|
|
||||||
if quote_service:
|
|
||||||
quote_service.push_review_event(json.dumps(
|
|
||||||
{"type": "retry", "attempt": attempt + 1}, ensure_ascii=False))
|
|
||||||
await asyncio.sleep(3)
|
await asyncio.sleep(3)
|
||||||
|
|
||||||
# 耗尽重试, 返回已累积内容(可能为空)和最后 meta
|
# 耗尽重试, 返回已累积内容(可能为空)和最后 meta
|
||||||
@@ -790,24 +751,6 @@ def start_scheduler(repo: KlineRepository, capset: CapabilitySet) -> AsyncIOSche
|
|||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 盘后: 五档盘口 sealed 定版(时间由偏好决定, 默认15:02, 范围15:01~18:00)
|
|
||||||
depth_sched = preferences.get_depth_finalize_time()
|
|
||||||
|
|
||||||
def _depth_finalize():
|
|
||||||
depth_svc = getattr(_get_app_state(), "depth_service", None) if _get_app_state() else None
|
|
||||||
if depth_svc:
|
|
||||||
depth_svc.finalize()
|
|
||||||
|
|
||||||
scheduler.add_job(
|
|
||||||
_depth_finalize,
|
|
||||||
trigger=CronTrigger(day_of_week="mon-fri",
|
|
||||||
hour=depth_sched["hour"], minute=depth_sched["minute"],
|
|
||||||
timezone="Asia/Shanghai"),
|
|
||||||
id="depth_finalize",
|
|
||||||
misfire_grace_time=3600,
|
|
||||||
replace_existing=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 定时复盘 (AI 大盘复盘报告): 工作日到点自动生成并归档。
|
# 定时复盘 (AI 大盘复盘报告): 工作日到点自动生成并归档。
|
||||||
# 默认关闭 —— 仅当用户在复盘页开启时才注册 job。
|
# 默认关闭 —— 仅当用户在复盘页开启时才注册 job。
|
||||||
# 复用 recap_market_once(非流式) + market_recap_reports.save_report(落盘)。
|
# 复用 recap_market_once(非流式) + market_recap_reports.save_report(落盘)。
|
||||||
@@ -819,9 +762,8 @@ def start_scheduler(repo: KlineRepository, capset: CapabilitySet) -> AsyncIOSche
|
|||||||
review_sched["hour"], review_sched["minute"])
|
review_sched["hour"], review_sched["minute"])
|
||||||
|
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
logger.info("scheduler started; instruments@%02d:%02d, pipeline@%02d:%02d, depth@%02d:%02d mon-fri",
|
logger.info("scheduler started; instruments@%02d:%02d, pipeline@%02d:%02d mon-fri",
|
||||||
inst_sched["hour"], inst_sched["minute"], sched["hour"], sched["minute"],
|
inst_sched["hour"], inst_sched["minute"], sched["hour"], sched["minute"])
|
||||||
depth_sched["hour"], depth_sched["minute"])
|
|
||||||
return scheduler
|
return scheduler
|
||||||
|
|
||||||
|
|
||||||
@@ -830,7 +772,7 @@ _app_state_ref = None
|
|||||||
|
|
||||||
|
|
||||||
def set_app_state(app_state) -> None:
|
def set_app_state(app_state) -> None:
|
||||||
"""lifespan 注册 app.state 引用, 供 scheduled job 访问 depth_service 等单例。"""
|
"""lifespan 注册 app.state 引用, 供 scheduled job 访问单例。"""
|
||||||
global _app_state_ref
|
global _app_state_ref
|
||||||
_app_state_ref = app_state
|
_app_state_ref = app_state
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,10 @@ from fastapi.responses import FileResponse, JSONResponse
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from app import __version__
|
from app import __version__
|
||||||
from app.api import analysis, auth as auth_api, backtest, data, ext_data, financials, indices, intraday, kline, market_recap, monitor_rules, alerts, overview, pipeline, rps, screener, settings as settings_api, signals, stock_analysis, strategy, watchlist
|
from app.api import analysis, auth as auth_api, data, ext_data, financials, indices, kline, market_recap, alerts, overview, pipeline, rps, screener, settings as settings_api, signals, stock_analysis, strategy
|
||||||
from app.api.routes import router as core_router
|
from app.api.routes import router as core_router
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.jobs import daily_pipeline
|
from app.jobs import daily_pipeline
|
||||||
from app.services.quote_service import QuoteService
|
|
||||||
from app.tickflow import client as tf_client
|
from app.tickflow import client as tf_client
|
||||||
from app.tickflow.policy import detect_capabilities
|
from app.tickflow.policy import detect_capabilities
|
||||||
from app.tickflow.repository import DataStore, KlineRepository
|
from app.tickflow.repository import DataStore, KlineRepository
|
||||||
@@ -56,42 +55,14 @@ async def lifespan(app: FastAPI):
|
|||||||
app.state.capabilities = capset
|
app.state.capabilities = capset
|
||||||
logger.info("ready; %d capabilities active", len(capset.all()))
|
logger.info("ready; %d capabilities active", len(capset.all()))
|
||||||
|
|
||||||
# 全局行情服务
|
|
||||||
qs = QuoteService()
|
|
||||||
app.state.quote_service = qs
|
|
||||||
qs.set_repo(repo)
|
|
||||||
qs.boot_check()
|
|
||||||
|
|
||||||
# QuoteService 需要访问 strategy_monitor 等单例
|
|
||||||
# 先创建 strategy_monitor,再注入 app.state
|
|
||||||
from app.strategy.monitor import StrategyMonitorService
|
|
||||||
strategy_monitor = StrategyMonitorService()
|
|
||||||
app.state.strategy_monitor = strategy_monitor
|
|
||||||
qs.set_app_state(app.state)
|
|
||||||
|
|
||||||
# 五档盘口 sealed 服务(真假涨停/跌停, 独立旁路线)
|
|
||||||
from app.services.depth_service import DepthService
|
|
||||||
depth_service = DepthService()
|
|
||||||
depth_service.set_repo(repo)
|
|
||||||
depth_service.set_app_state(app.state)
|
|
||||||
app.state.depth_service = depth_service
|
|
||||||
|
|
||||||
# 启动调度器(若 enriched 数据为空,首次启动可手动 POST /api/pipeline/run)
|
# 启动调度器(若 enriched 数据为空,首次启动可手动 POST /api/pipeline/run)
|
||||||
try:
|
try:
|
||||||
daily_pipeline.set_app_state(app.state) # 供 depth_finalize job 访问 depth_service
|
|
||||||
scheduler = daily_pipeline.start_scheduler(repo, capset)
|
scheduler = daily_pipeline.start_scheduler(repo, capset)
|
||||||
app.state.scheduler = scheduler
|
app.state.scheduler = scheduler
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
logger.warning("scheduler not started: %s", e)
|
logger.warning("scheduler not started: %s", e)
|
||||||
app.state.scheduler = None
|
app.state.scheduler = None
|
||||||
|
|
||||||
# depth sealed: 启动补跑(当天文件不存在) + 盘中轮询(有能力时)
|
|
||||||
try:
|
|
||||||
depth_service.boot_check()
|
|
||||||
depth_service.start_polling()
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
logger.warning("depth_service init failed: %s", e)
|
|
||||||
|
|
||||||
# 扩展数据定时拉取
|
# 扩展数据定时拉取
|
||||||
from app.services.ext_pull import pull_scheduler
|
from app.services.ext_pull import pull_scheduler
|
||||||
pull_scheduler.start(store.data_dir)
|
pull_scheduler.start(store.data_dir)
|
||||||
@@ -114,7 +85,6 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
# 策略引擎
|
# 策略引擎
|
||||||
from app.strategy.engine import StrategyEngine
|
from app.strategy.engine import StrategyEngine
|
||||||
from app.strategy.monitor import StrategyMonitorService
|
|
||||||
from app.services.screener import ScreenerService
|
from app.services.screener import ScreenerService
|
||||||
|
|
||||||
_screener_svc = ScreenerService(repo)
|
_screener_svc = ScreenerService(repo)
|
||||||
@@ -131,36 +101,6 @@ async def lifespan(app: FastAPI):
|
|||||||
app.state.strategy_engine = strategy_engine
|
app.state.strategy_engine = strategy_engine
|
||||||
logger.info("strategy engine loaded: %d strategies", len(strategy_engine.list_strategies()))
|
logger.info("strategy engine loaded: %d strategies", len(strategy_engine.list_strategies()))
|
||||||
|
|
||||||
# 通用监控规则引擎: 启动时 reload 规则到内存态 (修复重启后告警失效)
|
|
||||||
from app.strategy.monitor import MonitorRuleEngine
|
|
||||||
from app.strategy import monitor_rules as mr_store
|
|
||||||
from app.services import preferences
|
|
||||||
monitor_engine = MonitorRuleEngine()
|
|
||||||
monitor_engine.set_strategy_engine(strategy_engine)
|
|
||||||
monitor_engine.set_data_dir(store.data_dir)
|
|
||||||
# 复用 ScreenerService 的历史窗口加载器 (三级缓存, 启动预计算命中 ~0ms),
|
|
||||||
# 让声明 filter_history 的策略 (如反包) 也能在实时监控里跑选股 → 盘中触发通知。
|
|
||||||
monitor_engine.set_history_loader(_screener_svc._load_enriched_history)
|
|
||||||
|
|
||||||
# 自动迁移: 把旧 strategy_monitor_ids 同步为 type=strategy 规则 (统一到监控页)
|
|
||||||
try:
|
|
||||||
if preferences.get_strategy_monitor_enabled():
|
|
||||||
ids = preferences.get_strategy_monitor_ids()
|
|
||||||
if ids:
|
|
||||||
names = {s.id: s.name for s in strategy_engine.list_strategies()}
|
|
||||||
mr_store.migrate_strategy_monitors(store.data_dir, ids, names)
|
|
||||||
logger.info("strategy monitor migrated: %d strategies", len(ids))
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
logger.warning("strategy monitor migration failed: %s", e)
|
|
||||||
|
|
||||||
try:
|
|
||||||
rules = mr_store.load_all(store.data_dir)
|
|
||||||
monitor_engine.set_rules(rules)
|
|
||||||
logger.info("monitor engine loaded: %d rules", monitor_engine.rule_count)
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
logger.warning("monitor engine load failed: %s", e)
|
|
||||||
app.state.monitor_engine = monitor_engine
|
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
if app.state.scheduler:
|
if app.state.scheduler:
|
||||||
@@ -171,12 +111,6 @@ async def lifespan(app: FastAPI):
|
|||||||
fsc = getattr(app.state, "financial_scheduler", None)
|
fsc = getattr(app.state, "financial_scheduler", None)
|
||||||
if fsc:
|
if fsc:
|
||||||
fsc.stop()
|
fsc.stop()
|
||||||
qs = getattr(app.state, "quote_service", None)
|
|
||||||
if qs:
|
|
||||||
qs.stop()
|
|
||||||
dsvc = getattr(app.state, "depth_service", None)
|
|
||||||
if dsvc:
|
|
||||||
dsvc.stop_polling()
|
|
||||||
logger.info("shutdown")
|
logger.info("shutdown")
|
||||||
|
|
||||||
|
|
||||||
@@ -248,10 +182,7 @@ async def auth_middleware(request: Request, call_next):
|
|||||||
app.include_router(core_router)
|
app.include_router(core_router)
|
||||||
app.include_router(auth_api.router)
|
app.include_router(auth_api.router)
|
||||||
app.include_router(kline.router)
|
app.include_router(kline.router)
|
||||||
app.include_router(watchlist.router)
|
|
||||||
app.include_router(screener.router)
|
app.include_router(screener.router)
|
||||||
app.include_router(backtest.router)
|
|
||||||
app.include_router(intraday.router)
|
|
||||||
app.include_router(indices.router)
|
app.include_router(indices.router)
|
||||||
app.include_router(overview.router)
|
app.include_router(overview.router)
|
||||||
app.include_router(analysis.router)
|
app.include_router(analysis.router)
|
||||||
@@ -264,7 +195,6 @@ app.include_router(market_recap.router)
|
|||||||
app.include_router(settings_api.router)
|
app.include_router(settings_api.router)
|
||||||
app.include_router(strategy.router)
|
app.include_router(strategy.router)
|
||||||
app.include_router(signals.router)
|
app.include_router(signals.router)
|
||||||
app.include_router(monitor_rules.router)
|
|
||||||
app.include_router(alerts.router)
|
app.include_router(alerts.router)
|
||||||
app.include_router(rps.router)
|
app.include_router(rps.router)
|
||||||
|
|
||||||
|
|||||||
@@ -1,392 +0,0 @@
|
|||||||
"""回测服务(§6.7)。
|
|
||||||
|
|
||||||
包 vectorbt — 全项目唯一一处出现 pandas。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from datetime import date
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
import polars as pl
|
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
from app.tickflow.repository import KlineRepository
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# vectorbt 是 optional extras(见 pyproject.toml).未装时只有 backtest 不可用,其他功能正常.
|
|
||||||
_vbt = None
|
|
||||||
_vbt_unavailable_reason: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class VectorbtUnavailable(RuntimeError):
|
|
||||||
"""vectorbt 未安装 — 提示用户 `uv sync --extra backtest`."""
|
|
||||||
|
|
||||||
|
|
||||||
def _get_vbt():
|
|
||||||
global _vbt, _vbt_unavailable_reason
|
|
||||||
if _vbt is not None:
|
|
||||||
return _vbt
|
|
||||||
if _vbt_unavailable_reason is not None:
|
|
||||||
raise VectorbtUnavailable(_vbt_unavailable_reason)
|
|
||||||
try:
|
|
||||||
import vectorbt as vbt
|
|
||||||
_vbt = vbt
|
|
||||||
return _vbt
|
|
||||||
except ImportError as e:
|
|
||||||
_vbt_unavailable_reason = (
|
|
||||||
"vectorbt 未安装 — 它是回测的可选依赖.macOS Intel 用户先 `brew install cmake` "
|
|
||||||
"然后 `uv sync --extra backtest`"
|
|
||||||
)
|
|
||||||
logger.warning("vectorbt unavailable: %s", e)
|
|
||||||
raise VectorbtUnavailable(_vbt_unavailable_reason) from e
|
|
||||||
|
|
||||||
|
|
||||||
def is_available() -> bool:
|
|
||||||
"""供 API 层快速检测."""
|
|
||||||
try:
|
|
||||||
_get_vbt()
|
|
||||||
return True
|
|
||||||
except VectorbtUnavailable:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
SignalKind = Literal[
|
|
||||||
"macd_golden", "macd_dead",
|
|
||||||
"ma_golden_5_20", "ma_dead_5_20",
|
|
||||||
"ma_golden_20_60",
|
|
||||||
"ma20_breakout", "ma20_breakdown",
|
|
||||||
"n_day_high", "n_day_low",
|
|
||||||
"boll_breakout_upper", "boll_breakdown_lower",
|
|
||||||
"volume_surge",
|
|
||||||
"rsi_oversold", "rsi_overbought",
|
|
||||||
"stop_loss", "trailing_stop", "max_hold",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class BacktestConfig:
|
|
||||||
symbols: list[str]
|
|
||||||
start: date
|
|
||||||
end: date
|
|
||||||
# 买入信号(任一触发即买)
|
|
||||||
entries: list[str] = field(default_factory=list)
|
|
||||||
# 卖出信号(任一触发即卖)
|
|
||||||
exits: list[str] = field(default_factory=list)
|
|
||||||
# 其他参数
|
|
||||||
stop_loss_pct: float | None = None # 例 -0.05 = -5%
|
|
||||||
max_hold_days: int | None = None
|
|
||||||
fees_pct: float = 0.0002 # 万二佣金
|
|
||||||
slippage_bps: float = 5 # 5 bps
|
|
||||||
# 撮合
|
|
||||||
matching: Literal["close_t", "open_t+1"] = "close_t"
|
|
||||||
rsi_oversold_threshold: float = 30
|
|
||||||
rsi_overbought_threshold: float = 70
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class BacktestResult:
|
|
||||||
run_id: str
|
|
||||||
config: dict
|
|
||||||
stats: dict
|
|
||||||
equity_curve: list[dict] # [{date, value}]
|
|
||||||
trades: list[dict] # [{symbol, entry_date, exit_date, pnl_pct, ...}]
|
|
||||||
per_symbol_stats: list[dict] # 每只股票的统计
|
|
||||||
|
|
||||||
|
|
||||||
# enriched 表里的信号列名映射
|
|
||||||
_SIGNAL_COLS: dict[SignalKind, str] = {
|
|
||||||
"macd_golden": "signal_macd_golden",
|
|
||||||
"macd_dead": "signal_macd_dead",
|
|
||||||
"ma_golden_5_20": "signal_ma_golden_5_20",
|
|
||||||
"ma_dead_5_20": "signal_ma_dead_5_20",
|
|
||||||
"ma_golden_20_60": "signal_ma_golden_20_60",
|
|
||||||
"ma20_breakout": "signal_ma20_breakout",
|
|
||||||
"ma20_breakdown": "signal_ma20_breakdown",
|
|
||||||
"n_day_high": "signal_n_day_high",
|
|
||||||
"n_day_low": "signal_n_day_low",
|
|
||||||
"boll_breakout_upper": "signal_boll_breakout_upper",
|
|
||||||
"boll_breakdown_lower": "signal_boll_breakdown_lower",
|
|
||||||
"volume_surge": "signal_volume_surge",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class BacktestService:
|
|
||||||
def __init__(self, repo: KlineRepository) -> None:
|
|
||||||
self.repo = repo
|
|
||||||
|
|
||||||
def _load_panel(
|
|
||||||
self,
|
|
||||||
symbols: list[str],
|
|
||||||
start: date,
|
|
||||||
end: date,
|
|
||||||
) -> pd.DataFrame:
|
|
||||||
"""加载 [date × symbol] 价格面板 — Polars scan_parquet + 即时计算指标。
|
|
||||||
|
|
||||||
**全项目唯一从 Polars 转 pandas 的边界**(§7.4 / ADR-19)。
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
enriched_glob = str(self.repo.store.data_dir / "kline_daily_enriched" / "**" / "*.parquet")
|
|
||||||
df = (
|
|
||||||
pl.scan_parquet(enriched_glob)
|
|
||||||
.filter(
|
|
||||||
(pl.col("symbol").is_in(symbols))
|
|
||||||
& (pl.col("date") >= start)
|
|
||||||
& (pl.col("date") <= end)
|
|
||||||
)
|
|
||||||
.sort(["date", "symbol"])
|
|
||||||
.collect()
|
|
||||||
)
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
logger.warning("backtest load failed: %s", e)
|
|
||||||
return pd.DataFrame()
|
|
||||||
|
|
||||||
if df.is_empty():
|
|
||||||
return pd.DataFrame()
|
|
||||||
|
|
||||||
# 即时计算指标 + 信号
|
|
||||||
from app.indicators.pipeline import compute_all
|
|
||||||
df = compute_all(df)
|
|
||||||
|
|
||||||
# 选择需要的列
|
|
||||||
needed_cols = [
|
|
||||||
"date", "symbol", "open", "high", "low", "close", "volume",
|
|
||||||
"rsi_14", "signal_macd_golden", "signal_macd_dead",
|
|
||||||
"signal_ma_golden_5_20", "signal_ma_dead_5_20",
|
|
||||||
"signal_ma_golden_20_60",
|
|
||||||
"signal_ma20_breakout", "signal_ma20_breakdown",
|
|
||||||
"signal_n_day_high", "signal_n_day_low",
|
|
||||||
"signal_boll_breakout_upper", "signal_boll_breakdown_lower",
|
|
||||||
"signal_volume_surge",
|
|
||||||
]
|
|
||||||
existing = [c for c in needed_cols if c in df.columns]
|
|
||||||
df = df.select(existing)
|
|
||||||
|
|
||||||
# to_pandas 边界
|
|
||||||
return df.to_pandas(use_pyarrow_extension_array=False)
|
|
||||||
|
|
||||||
def _build_signal_matrix(
|
|
||||||
self,
|
|
||||||
panel: pd.DataFrame,
|
|
||||||
kinds: list[str],
|
|
||||||
config: BacktestConfig,
|
|
||||||
) -> pd.DataFrame:
|
|
||||||
"""从面板构造 [date × symbol] 的布尔信号矩阵。"""
|
|
||||||
if not kinds or panel.empty:
|
|
||||||
return pd.DataFrame()
|
|
||||||
|
|
||||||
# pivot 成 [date × symbol] 形式
|
|
||||||
result = None
|
|
||||||
for kind in kinds:
|
|
||||||
mat = None
|
|
||||||
if kind in _SIGNAL_COLS:
|
|
||||||
col = _SIGNAL_COLS[kind]
|
|
||||||
mat = panel.pivot(index="date", columns="symbol", values=col).fillna(False).astype(bool)
|
|
||||||
elif kind == "rsi_oversold":
|
|
||||||
mat = (panel.pivot(index="date", columns="symbol", values="rsi_14")
|
|
||||||
< config.rsi_oversold_threshold)
|
|
||||||
elif kind == "rsi_overbought":
|
|
||||||
mat = (panel.pivot(index="date", columns="symbol", values="rsi_14")
|
|
||||||
> config.rsi_overbought_threshold)
|
|
||||||
# stop_loss / trailing / max_hold 通过 vectorbt 参数处理,不参与信号矩阵
|
|
||||||
|
|
||||||
if mat is not None:
|
|
||||||
result = mat if result is None else (result | mat)
|
|
||||||
return result if result is not None else pd.DataFrame()
|
|
||||||
|
|
||||||
def run(self, config: BacktestConfig) -> BacktestResult:
|
|
||||||
vbt = _get_vbt()
|
|
||||||
run_id = uuid.uuid4().hex[:10]
|
|
||||||
|
|
||||||
panel = self._load_panel(config.symbols, config.start, config.end)
|
|
||||||
if panel.empty:
|
|
||||||
return BacktestResult(
|
|
||||||
run_id=run_id,
|
|
||||||
config=_config_to_dict(config),
|
|
||||||
stats={"error": "no data"},
|
|
||||||
equity_curve=[],
|
|
||||||
trades=[],
|
|
||||||
per_symbol_stats=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
# 价格面板
|
|
||||||
close = panel.pivot(index="date", columns="symbol", values="close")
|
|
||||||
|
|
||||||
# 信号矩阵
|
|
||||||
entries = self._build_signal_matrix(panel, config.entries, config)
|
|
||||||
exits = self._build_signal_matrix(panel, config.exits, config)
|
|
||||||
|
|
||||||
# 对齐 index/columns
|
|
||||||
if not entries.empty:
|
|
||||||
entries = entries.reindex_like(close).fillna(False).astype(bool)
|
|
||||||
else:
|
|
||||||
entries = pd.DataFrame(False, index=close.index, columns=close.columns)
|
|
||||||
if not exits.empty:
|
|
||||||
exits = exits.reindex_like(close).fillna(False).astype(bool)
|
|
||||||
else:
|
|
||||||
exits = pd.DataFrame(False, index=close.index, columns=close.columns)
|
|
||||||
|
|
||||||
if not entries.any().any():
|
|
||||||
return BacktestResult(
|
|
||||||
run_id=run_id,
|
|
||||||
config=_config_to_dict(config),
|
|
||||||
stats={"error": "no buy signals"},
|
|
||||||
equity_curve=[],
|
|
||||||
trades=[],
|
|
||||||
per_symbol_stats=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
# T+1 适配:vectorbt 默认信号当根 K 撮合
|
|
||||||
# close_t 撮合:维持默认
|
|
||||||
# open_t+1 撮合:shift 信号 1 根 + 用 open 作为价
|
|
||||||
if config.matching == "open_t+1":
|
|
||||||
entries = entries.shift(1).fillna(False).astype(bool)
|
|
||||||
exits = exits.shift(1).fillna(False).astype(bool)
|
|
||||||
price = panel.pivot(index="date", columns="symbol", values="open")
|
|
||||||
else:
|
|
||||||
price = close
|
|
||||||
|
|
||||||
# 跑回测
|
|
||||||
try:
|
|
||||||
pf_kwargs = dict(
|
|
||||||
close=close,
|
|
||||||
entries=entries,
|
|
||||||
exits=exits,
|
|
||||||
price=price,
|
|
||||||
fees=config.fees_pct,
|
|
||||||
slippage=config.slippage_bps / 10000.0,
|
|
||||||
freq="1D",
|
|
||||||
)
|
|
||||||
if config.stop_loss_pct is not None:
|
|
||||||
pf_kwargs["sl_stop"] = abs(config.stop_loss_pct)
|
|
||||||
if config.max_hold_days is not None:
|
|
||||||
# vectorbt 没有内置 max-hold;用时间退出近似:
|
|
||||||
# 在 max_hold_days 后强制 exit
|
|
||||||
exits_idx = entries.copy()
|
|
||||||
for col in entries.columns:
|
|
||||||
entry_rows = np.where(entries[col].values)[0]
|
|
||||||
for i in entry_rows:
|
|
||||||
end_i = min(i + config.max_hold_days, len(entries) - 1)
|
|
||||||
if end_i > i:
|
|
||||||
exits_idx.iloc[end_i][col] = True
|
|
||||||
pf_kwargs["exits"] = (exits | exits_idx).astype(bool)
|
|
||||||
|
|
||||||
pf = vbt.Portfolio.from_signals(**pf_kwargs)
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
logger.exception("vectorbt backtest failed")
|
|
||||||
return BacktestResult(
|
|
||||||
run_id=run_id,
|
|
||||||
config=_config_to_dict(config),
|
|
||||||
stats={"error": str(e)},
|
|
||||||
equity_curve=[],
|
|
||||||
trades=[],
|
|
||||||
per_symbol_stats=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
# 提取结果
|
|
||||||
try:
|
|
||||||
stats_series = pf.stats(silence_warnings=True)
|
|
||||||
if isinstance(stats_series, pd.DataFrame):
|
|
||||||
# 多列时取 agg
|
|
||||||
stats_dict = stats_series.mean(numeric_only=True).to_dict()
|
|
||||||
else:
|
|
||||||
stats_dict = stats_series.to_dict()
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
stats_dict = {}
|
|
||||||
|
|
||||||
# 净值曲线(组合平均)
|
|
||||||
equity = pf.value().mean(axis=1) if isinstance(pf.value(), pd.DataFrame) else pf.value()
|
|
||||||
equity_curve = [
|
|
||||||
{"date": str(idx.date() if hasattr(idx, "date") else idx), "value": float(v)}
|
|
||||||
for idx, v in equity.items() if pd.notna(v)
|
|
||||||
]
|
|
||||||
|
|
||||||
# 交易记录
|
|
||||||
try:
|
|
||||||
trades_df = pf.trades.records_readable
|
|
||||||
trades = trades_df.to_dict(orient="records") if not trades_df.empty else []
|
|
||||||
# 字段名美化
|
|
||||||
trades = [
|
|
||||||
{
|
|
||||||
"symbol": t.get("Column", t.get("Symbol", "")),
|
|
||||||
"entry_date": str(t.get("Entry Timestamp", t.get("Entry Date", ""))),
|
|
||||||
"exit_date": str(t.get("Exit Timestamp", t.get("Exit Date", ""))),
|
|
||||||
"entry_price": float(t.get("Avg Entry Price", t.get("Avg. Entry Price", 0))),
|
|
||||||
"exit_price": float(t.get("Avg Exit Price", t.get("Avg. Exit Price", 0))),
|
|
||||||
"pnl_pct": float(t.get("Return", t.get("PnL %", 0))),
|
|
||||||
"duration": str(t.get("Duration", "")),
|
|
||||||
}
|
|
||||||
for t in trades
|
|
||||||
]
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
trades = []
|
|
||||||
|
|
||||||
# 每标的统计
|
|
||||||
per_symbol = []
|
|
||||||
try:
|
|
||||||
total_ret = pf.total_return()
|
|
||||||
if isinstance(total_ret, pd.Series):
|
|
||||||
for sym, ret in total_ret.items():
|
|
||||||
if pd.notna(ret):
|
|
||||||
per_symbol.append({"symbol": sym, "total_return": float(ret)})
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
pass
|
|
||||||
|
|
||||||
result = BacktestResult(
|
|
||||||
run_id=run_id,
|
|
||||||
config=_config_to_dict(config),
|
|
||||||
stats={k: _json_safe(v) for k, v in stats_dict.items()},
|
|
||||||
equity_curve=equity_curve,
|
|
||||||
trades=trades,
|
|
||||||
per_symbol_stats=per_symbol,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 落盘
|
|
||||||
self._persist(result)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _persist(self, result: BacktestResult) -> None:
|
|
||||||
out_dir = settings.data_dir / "backtest_results"
|
|
||||||
out_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
# 用 polars 写一份汇总
|
|
||||||
summary = pl.DataFrame({
|
|
||||||
"run_id": [result.run_id],
|
|
||||||
"stats_json": [str(result.stats)],
|
|
||||||
"n_trades": [len(result.trades)],
|
|
||||||
})
|
|
||||||
summary.write_parquet(out_dir / f"run_id={result.run_id}.parquet")
|
|
||||||
|
|
||||||
def get_result(self, run_id: str) -> BacktestResult | None:
|
|
||||||
# Phase 1:只保留近似落盘,完整结果保存在内存的近期 cache 中
|
|
||||||
# 简化:重新 run 比缓存复杂结果代价小,暂不实现 get_result
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _config_to_dict(c: BacktestConfig) -> dict:
|
|
||||||
return {
|
|
||||||
"symbols": c.symbols,
|
|
||||||
"start": str(c.start),
|
|
||||||
"end": str(c.end),
|
|
||||||
"entries": c.entries,
|
|
||||||
"exits": c.exits,
|
|
||||||
"stop_loss_pct": c.stop_loss_pct,
|
|
||||||
"max_hold_days": c.max_hold_days,
|
|
||||||
"fees_pct": c.fees_pct,
|
|
||||||
"slippage_bps": c.slippage_bps,
|
|
||||||
"matching": c.matching,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _json_safe(v):
|
|
||||||
if isinstance(v, (int, float, str, bool)) or v is None:
|
|
||||||
return v
|
|
||||||
if isinstance(v, (np.floating, np.integer)):
|
|
||||||
return float(v) if not np.isnan(float(v)) else None
|
|
||||||
if hasattr(v, "isoformat"):
|
|
||||||
return v.isoformat()
|
|
||||||
return str(v)
|
|
||||||
@@ -285,8 +285,6 @@ async def analyze_rotation_stream(
|
|||||||
repo,
|
repo,
|
||||||
days: int = 12,
|
days: int = 12,
|
||||||
focus: str = "",
|
focus: str = "",
|
||||||
quote_service=None,
|
|
||||||
depth_service=None,
|
|
||||||
) -> AsyncIterator[str]:
|
) -> AsyncIterator[str]:
|
||||||
"""流式概念轮动分析: yield 出每个 NDJSON 事件。
|
"""流式概念轮动分析: yield 出每个 NDJSON 事件。
|
||||||
|
|
||||||
@@ -294,7 +292,6 @@ async def analyze_rotation_stream(
|
|||||||
repo: KlineRepository (必填)。
|
repo: KlineRepository (必填)。
|
||||||
days: 分析最近 N 个交易日 (7-30)。
|
days: 分析最近 N 个交易日 (7-30)。
|
||||||
focus: 用户追加的关注点。
|
focus: 用户追加的关注点。
|
||||||
quote_service / depth_service: 可选, 大盘背景装配依赖。
|
|
||||||
"""
|
"""
|
||||||
from app.services.rps_rotation import build_rps_rotation
|
from app.services.rps_rotation import build_rps_rotation
|
||||||
from app.services.market_overview_builder import build_market_overview
|
from app.services.market_overview_builder import build_market_overview
|
||||||
@@ -316,7 +313,7 @@ async def analyze_rotation_stream(
|
|||||||
|
|
||||||
# 3. 大盘背景 (失败不阻断, 降级为空)
|
# 3. 大盘背景 (失败不阻断, 降级为空)
|
||||||
try:
|
try:
|
||||||
overview = build_market_overview(repo, quote_service, depth_service)
|
overview = build_market_overview(repo)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
logger.warning("rotation analyze: 大盘背景获取失败, 降级为空: %s", e)
|
logger.warning("rotation analyze: 大盘背景获取失败, 降级为空: %s", e)
|
||||||
overview = {}
|
overview = {}
|
||||||
|
|||||||
@@ -1,586 +0,0 @@
|
|||||||
"""五档盘口 sealed(真假涨停/跌停) 服务 — 独立旁路线。
|
|
||||||
|
|
||||||
架构(完全解耦):
|
|
||||||
- 只读 enriched(拿涨跌停名单), 不写回 enriched(14列不动)
|
|
||||||
- sealed 存独立 parquet(data/depth5/date=xxx/part.parquet)
|
|
||||||
- limit_ladder API 查询时 LEFT JOIN(同 ext_columns 机制)
|
|
||||||
- signal_limit_up 永远是"价格涨停", sealed 是叠加的真假判定层
|
|
||||||
|
|
||||||
数据流:
|
|
||||||
盘中轮询线程(交易时段, 独立 sleep, 不绑行情轮询):
|
|
||||||
读 enriched 内存缓存(线程安全) → 涨跌停名单 → tf.depth.batch
|
|
||||||
→ 算 sealed → 更新内存缓存(不落盘) → sealed_ready=True
|
|
||||||
盘后定版 job(可配置时间, 默认15:02):
|
|
||||||
最后拉一次 → 落盘 depth5 parquet(定版)
|
|
||||||
|
|
||||||
三层防护节流("设过大设上限, 设过小设最小值"):
|
|
||||||
① 套餐范围 clamp: Pro 10~120s, Expert 3~300s
|
|
||||||
② 限速安全 clamp: safe = 60/((rpm*0.8)/batches), 涨跌停多就自动放慢
|
|
||||||
③ 系统接管通知: 用户设置会超限时, 推 toast 告知已自动调整
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import math
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from datetime import date, datetime, time as dt_time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import polars as pl
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
# 套餐 → (轮询间隔下限s, 上限s)
|
|
||||||
TIER_INTERVAL_RANGE: dict[str, tuple[float, float]] = {
|
|
||||||
"pro": (10.0, 120.0),
|
|
||||||
"expert": (3.0, 300.0),
|
|
||||||
}
|
|
||||||
# 兜底: 其他有 DEPTH5_BATCH 的套餐按 pro 范围
|
|
||||||
DEFAULT_RANGE = (10.0, 120.0)
|
|
||||||
|
|
||||||
# 限速余量: 只用 rpm 的 80%, 给系统其他 depth 调用留空间
|
|
||||||
RPM_MARGIN = 0.8
|
|
||||||
# 间隔硬下限/上限(任何套餐)
|
|
||||||
INTERVAL_HARD_MIN = 10.0
|
|
||||||
INTERVAL_HARD_MAX = 300.0
|
|
||||||
|
|
||||||
|
|
||||||
class DepthService:
|
|
||||||
"""五档盘口 sealed 服务 — 单例。"""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
self._running = False
|
|
||||||
self._thread: threading.Thread | None = None
|
|
||||||
self._repo = None # 延迟注入(KlineRepository)
|
|
||||||
self._app_state = None # 延迟注入(FastAPI app.state)
|
|
||||||
|
|
||||||
# 内存缓存: {symbol: SealedEntry}
|
|
||||||
# SealedEntry = {sealed_up, sealed_down, ask1_vol, bid1_vol, status, fetched_ts}
|
|
||||||
self._sealed_cache: dict[str, dict] = {}
|
|
||||||
self._sealed_ready = False
|
|
||||||
self._sealed_date: date | None = None # sealed 数据对应的交易日(可能是昨天,如休市)
|
|
||||||
self._sealed_fetched_ts: float = 0.0 # 上次拉取的 perf_counter
|
|
||||||
self._sealed_fetched_at: float = 0.0 # 上次拉取的 wall-clock 时间戳
|
|
||||||
self._persisted_date: date | None = None # 已落盘的日期
|
|
||||||
|
|
||||||
# 系统接管状态(防通知刷屏)
|
|
||||||
self._last_taken_over: bool | None = None
|
|
||||||
self._last_user_interval: float | None = None
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# 注入
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
def set_repo(self, repo) -> None:
|
|
||||||
self._repo = repo
|
|
||||||
|
|
||||||
def set_app_state(self, app_state) -> None:
|
|
||||||
self._app_state = app_state
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# 生命周期
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
def boot_check(self) -> None:
|
|
||||||
"""启动补跑: 当天 depth5 文件不存在则 finalize 一次; 已存在则恢复内存缓存。"""
|
|
||||||
if not self._has_capability():
|
|
||||||
logger.info("depth sealed: 无 DEPTH5_BATCH 能力, 跳过启动补跑")
|
|
||||||
return
|
|
||||||
today = date.today()
|
|
||||||
if self._persisted_for_date(today):
|
|
||||||
# parquet 已存在: 恢复内存缓存(避免重启后每次查询都读 parquet)
|
|
||||||
self._restore_from_parquet(today)
|
|
||||||
return
|
|
||||||
logger.info("depth sealed: 启动补跑今天定版")
|
|
||||||
try:
|
|
||||||
self.finalize()
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
logger.warning("depth sealed 启动补跑失败: %s", e)
|
|
||||||
|
|
||||||
def _restore_from_parquet(self, d: date) -> None:
|
|
||||||
"""从 parquet 恢复内存缓存(服务重启后)。"""
|
|
||||||
if not self._repo:
|
|
||||||
return
|
|
||||||
out = self._repo.store.data_dir / "depth5" / f"date={d.isoformat()}" / "part.parquet"
|
|
||||||
if not out.exists():
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
df = pl.read_parquet(out)
|
|
||||||
cache: dict[str, dict] = {}
|
|
||||||
for row in df.to_dicts():
|
|
||||||
sym = row.get("symbol")
|
|
||||||
if not sym:
|
|
||||||
continue
|
|
||||||
cache[sym] = {
|
|
||||||
"sealed_up": row.get("sealed_up"),
|
|
||||||
"sealed_down": row.get("sealed_down"),
|
|
||||||
"ask1_vol": row.get("ask1_vol"),
|
|
||||||
"bid1_vol": row.get("bid1_vol"),
|
|
||||||
"status": row.get("status"),
|
|
||||||
"fetched_ts": row.get("fetched_at"),
|
|
||||||
}
|
|
||||||
with self._lock:
|
|
||||||
self._sealed_cache = cache
|
|
||||||
self._sealed_ready = True
|
|
||||||
self._sealed_date = d
|
|
||||||
self._persisted_date = d
|
|
||||||
logger.info("depth sealed: 从 parquet 恢复 %d 只 (日期=%s)", len(cache), d)
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
logger.warning("depth sealed 从 parquet 恢复失败: %s", e)
|
|
||||||
|
|
||||||
def start_polling(self) -> None:
|
|
||||||
"""启动盘中轮询线程(连板梯队监控开启 + 有能力 + 交易时段)。"""
|
|
||||||
if self._running:
|
|
||||||
return
|
|
||||||
if not self._has_capability():
|
|
||||||
return
|
|
||||||
from app.services import preferences
|
|
||||||
if not preferences.get_limit_ladder_monitor_enabled():
|
|
||||||
return
|
|
||||||
self._running = True
|
|
||||||
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
|
|
||||||
self._thread.start()
|
|
||||||
logger.info("depth sealed 盘中轮询已启动")
|
|
||||||
|
|
||||||
def stop_polling(self) -> None:
|
|
||||||
"""停止盘中轮询线程。"""
|
|
||||||
self._running = False
|
|
||||||
if self._thread:
|
|
||||||
self._thread.join(timeout=10)
|
|
||||||
self._thread = None
|
|
||||||
logger.info("depth sealed 盘中轮询已停止")
|
|
||||||
|
|
||||||
def apply_monitor_toggle(self, enabled: bool) -> None:
|
|
||||||
"""连板梯队监控开关切换时调用: 开启→启动轮询, 关闭→停止轮询。"""
|
|
||||||
if enabled:
|
|
||||||
self.start_polling()
|
|
||||||
else:
|
|
||||||
self.stop_polling()
|
|
||||||
|
|
||||||
def run_once(self) -> dict:
|
|
||||||
"""手动触发一次修正(立即拉取 depth + 更新内存缓存)。
|
|
||||||
|
|
||||||
不受监控开关限制 — 用户可随时手动修正一次。
|
|
||||||
返回 {"ok": bool, "count": int, "msg": str}
|
|
||||||
"""
|
|
||||||
if not self._has_capability():
|
|
||||||
return {"ok": False, "count": 0, "msg": "无五档盘口能力(需 Pro+)"}
|
|
||||||
try:
|
|
||||||
self._fetch_and_seal(persist=True) # 落盘, 刷新页面不丢
|
|
||||||
with self._lock:
|
|
||||||
count = len(self._sealed_cache)
|
|
||||||
return {"ok": True, "count": count, "msg": f"已修正 {count} 只"}
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
logger.warning("depth run_once 失败: %s", e)
|
|
||||||
return {"ok": False, "count": 0, "msg": f"修正失败: {e}"}
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# 核心拉取
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
def _fetch_and_seal(self, persist: bool = False) -> None:
|
|
||||||
"""拉一次 depth.batch, 算 sealed, 更新内存缓存(可选落盘)。
|
|
||||||
|
|
||||||
persist=True: 盘后定版, 写 depth5 parquet
|
|
||||||
persist=False: 盘中轮询, 只更新内存缓存
|
|
||||||
"""
|
|
||||||
if not self._repo:
|
|
||||||
return
|
|
||||||
|
|
||||||
# 只读 enriched 内存缓存(线程安全, 避免和 quote_service 写盘竞态)
|
|
||||||
enriched, enriched_date = self._repo.get_enriched_latest()
|
|
||||||
if enriched.is_empty():
|
|
||||||
return
|
|
||||||
|
|
||||||
# 筛涨跌停名单(用 fill_null 防止列缺失)
|
|
||||||
syms_up: list[str] = []
|
|
||||||
syms_down: list[str] = []
|
|
||||||
if "signal_limit_up" in enriched.columns:
|
|
||||||
syms_up = enriched.filter(
|
|
||||||
pl.col("signal_limit_up").fill_null(False)
|
|
||||||
)["symbol"].to_list()
|
|
||||||
if "signal_limit_down" in enriched.columns:
|
|
||||||
syms_down = enriched.filter(
|
|
||||||
pl.col("signal_limit_down").fill_null(False)
|
|
||||||
)["symbol"].to_list()
|
|
||||||
|
|
||||||
all_syms = list(dict.fromkeys(syms_up + syms_down)) # 去重保序
|
|
||||||
if not all_syms:
|
|
||||||
logger.debug("depth sealed: 当日无涨跌停股, 跳过")
|
|
||||||
return
|
|
||||||
|
|
||||||
# 拉 depth(涨跌停一次拉, 按 capset batch 切片)
|
|
||||||
depth_data = self._call_depth_batch(all_syms)
|
|
||||||
if not depth_data:
|
|
||||||
logger.warning("depth sealed: depth.batch 返回空")
|
|
||||||
return
|
|
||||||
|
|
||||||
up_set = set(syms_up)
|
|
||||||
down_set = set(syms_down)
|
|
||||||
now_perf = time.perf_counter()
|
|
||||||
now_wall = time.time()
|
|
||||||
|
|
||||||
new_cache: dict[str, dict] = {}
|
|
||||||
for sym, d in depth_data.items():
|
|
||||||
ask_vols = d.get("ask_volumes") or []
|
|
||||||
bid_vols = d.get("bid_volumes") or []
|
|
||||||
ask1 = ask_vols[0] if ask_vols else None
|
|
||||||
bid1 = bid_vols[0] if bid_vols else None
|
|
||||||
# depth 返回的 timestamp(毫秒 epoch), 回退到当前 wall-clock
|
|
||||||
depth_ts = d.get("timestamp")
|
|
||||||
fetched = (depth_ts / 1000.0) if isinstance(depth_ts, (int, float)) and depth_ts else now_wall
|
|
||||||
entry = {
|
|
||||||
# 涨停真封: 涨停价上卖一(主动卖压)为 0
|
|
||||||
"sealed_up": (ask1 == 0) if sym in up_set and ask1 is not None else None,
|
|
||||||
# 跌停真封: 跌停价上买一为 0
|
|
||||||
"sealed_down": (bid1 == 0) if sym in down_set and bid1 is not None else None,
|
|
||||||
"ask1_vol": ask1,
|
|
||||||
"bid1_vol": bid1,
|
|
||||||
"status": "limit_down" if sym in down_set and sym not in up_set else "limit_up",
|
|
||||||
"fetched_ts": fetched,
|
|
||||||
}
|
|
||||||
new_cache[sym] = entry
|
|
||||||
|
|
||||||
with self._lock:
|
|
||||||
self._sealed_cache = new_cache
|
|
||||||
self._sealed_ready = True
|
|
||||||
self._sealed_date = enriched_date # 记录数据对应的交易日(可能是昨天,如休市)
|
|
||||||
self._sealed_fetched_ts = now_perf
|
|
||||||
self._sealed_fetched_at = now_wall
|
|
||||||
|
|
||||||
logger.info("depth sealed: 拉取 %d 只 (涨停%d/跌停%d) 日期=%s%s",
|
|
||||||
len(new_cache), len(syms_up), len(syms_down),
|
|
||||||
enriched_date, " → 落盘" if persist else "")
|
|
||||||
|
|
||||||
# 缓存已更新: 通知 SSE 推 depth_updated, 触发连板梯队刷新封单数据。
|
|
||||||
self._notify_depth_updated(len(new_cache))
|
|
||||||
|
|
||||||
if persist and enriched_date:
|
|
||||||
self._persist(enriched_date)
|
|
||||||
|
|
||||||
def _call_depth_batch(self, symbols: list[str]) -> dict:
|
|
||||||
"""调 tf.depth.batch, 按 capset 的 batch 切片 + 节流。返回 {symbol: MarketDepth}。"""
|
|
||||||
from app.tickflow.client import get_client
|
|
||||||
tf = get_client()
|
|
||||||
|
|
||||||
capset = self._get_capset()
|
|
||||||
lim = capset.limits(__import__("app.tickflow.capabilities", fromlist=["Cap"]).Cap.DEPTH5_BATCH)
|
|
||||||
batch_size = (lim.batch if lim and lim.batch else 100)
|
|
||||||
rpm = (lim.rpm if lim and lim.rpm else 30)
|
|
||||||
# 批间隔 = 60/rpm(匀速)
|
|
||||||
inter_batch = 60.0 / rpm if rpm > 0 else 2.0
|
|
||||||
|
|
||||||
result: dict = {}
|
|
||||||
chunks = [symbols[i:i + batch_size] for i in range(0, len(symbols), batch_size)]
|
|
||||||
for i, chunk in enumerate(chunks):
|
|
||||||
if i > 0:
|
|
||||||
time.sleep(inter_batch)
|
|
||||||
try:
|
|
||||||
# SDK 的 batch 内部已按 batch_size 切, 这里再切一层防单请求过大
|
|
||||||
data = tf.depth.batch(chunk)
|
|
||||||
if isinstance(data, dict):
|
|
||||||
result.update(data)
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
logger.warning("depth.batch 第 %d 批失败(%d 只): %s", i + 1, len(chunk), e)
|
|
||||||
# 单批失败不影响其他批
|
|
||||||
return result
|
|
||||||
|
|
||||||
def finalize(self) -> None:
|
|
||||||
"""盘后定版: 拉一次 + 落盘。"""
|
|
||||||
if not self._has_capability():
|
|
||||||
return
|
|
||||||
self._fetch_and_seal(persist=True)
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# 落盘
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
def _persist(self, today: date) -> None:
|
|
||||||
"""把内存缓存写 depth5/date=今天/part.parquet。"""
|
|
||||||
with self._lock:
|
|
||||||
cache = dict(self._sealed_cache)
|
|
||||||
if not cache:
|
|
||||||
return
|
|
||||||
|
|
||||||
rows = []
|
|
||||||
for sym, e in cache.items():
|
|
||||||
rows.append({
|
|
||||||
"symbol": sym,
|
|
||||||
"sealed_up": e.get("sealed_up"),
|
|
||||||
"sealed_down": e.get("sealed_down"),
|
|
||||||
"ask1_vol": e.get("ask1_vol"),
|
|
||||||
"bid1_vol": e.get("bid1_vol"),
|
|
||||||
"status": e.get("status"),
|
|
||||||
"fetched_at": e.get("fetched_ts"),
|
|
||||||
})
|
|
||||||
# 显式 schema: sealed_up/sealed_down 是 bool 与 None 混合, 不指定 schema
|
|
||||||
# polars 会按首行推断类型, 后续遇到不一致 (bool vs null) 报
|
|
||||||
# "could not append value: false of type: bool to the builder"。
|
|
||||||
df = pl.DataFrame(rows, schema={
|
|
||||||
"symbol": pl.Utf8,
|
|
||||||
"sealed_up": pl.Boolean,
|
|
||||||
"sealed_down": pl.Boolean,
|
|
||||||
"ask1_vol": pl.Int64,
|
|
||||||
"bid1_vol": pl.Int64,
|
|
||||||
"status": pl.Utf8,
|
|
||||||
"fetched_at": pl.Float64,
|
|
||||||
})
|
|
||||||
ds = today.isoformat()
|
|
||||||
out = self._repo.store.data_dir / "depth5" / f"date={ds}" / "part.parquet"
|
|
||||||
out.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
df.write_parquet(out)
|
|
||||||
self._persisted_date = today
|
|
||||||
logger.info("depth sealed 落盘: %d 行 → %s", df.height, out)
|
|
||||||
|
|
||||||
def _persisted_for_date(self, d: date) -> bool:
|
|
||||||
"""检查某日 depth5 文件是否已存在。"""
|
|
||||||
if not self._repo:
|
|
||||||
return False
|
|
||||||
out = self._repo.store.data_dir / "depth5" / f"date={d.isoformat()}" / "part.parquet"
|
|
||||||
return out.exists()
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# 查询(供 limit_ladder API 用)
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
def get_sealed_map(self, target_date: date, is_down: bool) -> dict:
|
|
||||||
"""返回 {symbol: {sealed, vol, ready, age}} 供 JOIN。
|
|
||||||
|
|
||||||
优先内存缓存(盘中), 回退 parquet(历史/盘后)。
|
|
||||||
sealed: bool | None (None=待确认或降级)
|
|
||||||
vol: 封单量(int) | None
|
|
||||||
ready: sealed 数据是否就绪(False→降级标识)
|
|
||||||
age: 距上次拉取秒数(盘后定版为 None)
|
|
||||||
"""
|
|
||||||
# 内存缓存(sealed 数据对应的交易日 = target_date 时才用)
|
|
||||||
if self._sealed_date and target_date == self._sealed_date and self._sealed_ready and self._sealed_cache:
|
|
||||||
return self._read_from_memory(is_down)
|
|
||||||
# parquet(历史或盘后定版)
|
|
||||||
return self._read_from_parquet(target_date, is_down)
|
|
||||||
|
|
||||||
def _read_from_memory(self, is_down: bool) -> dict:
|
|
||||||
sealed_key = "sealed_down" if is_down else "sealed_up"
|
|
||||||
# 封单量: 涨停=买一量(涨停价买单堆积), 跌停=卖一量(跌停价卖单堆积)
|
|
||||||
vol_key = "ask1_vol" if is_down else "bid1_vol"
|
|
||||||
now = time.perf_counter()
|
|
||||||
with self._lock:
|
|
||||||
cache = dict(self._sealed_cache)
|
|
||||||
fetched_ts = self._sealed_fetched_ts
|
|
||||||
age = (now - fetched_ts) if fetched_ts else 0.0
|
|
||||||
result = {}
|
|
||||||
for sym, e in cache.items():
|
|
||||||
result[sym] = {
|
|
||||||
"sealed": e.get(sealed_key),
|
|
||||||
"vol": e.get(vol_key),
|
|
||||||
"ready": True,
|
|
||||||
"age": age,
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _read_from_parquet(self, target_date: date, is_down: bool) -> dict:
|
|
||||||
if not self._repo:
|
|
||||||
return {}
|
|
||||||
out = self._repo.store.data_dir / "depth5" / f"date={target_date.isoformat()}" / "part.parquet"
|
|
||||||
if not out.exists():
|
|
||||||
return {}
|
|
||||||
try:
|
|
||||||
df = pl.read_parquet(out)
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
logger.warning("depth5 parquet 读取失败: %s", e)
|
|
||||||
return {}
|
|
||||||
sealed_key = "sealed_down" if is_down else "sealed_up"
|
|
||||||
# 封单量: 涨停=买一量, 跌停=卖一量
|
|
||||||
vol_key = "ask1_vol" if is_down else "bid1_vol"
|
|
||||||
result = {}
|
|
||||||
for row in df.to_dicts():
|
|
||||||
sym = row.get("symbol")
|
|
||||||
if not sym:
|
|
||||||
continue
|
|
||||||
result[sym] = {
|
|
||||||
"sealed": row.get(sealed_key),
|
|
||||||
"vol": row.get(vol_key),
|
|
||||||
"ready": True,
|
|
||||||
"age": None, # 盘后定版, 无 age
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
|
|
||||||
def is_sealed_ready(self, target_date: date) -> bool:
|
|
||||||
"""sealed 数据是否就绪(供前端降级判定)。"""
|
|
||||||
# 内存缓存对应的数据日 == 查询日 → 看内存就绪状态
|
|
||||||
if self._sealed_date and target_date == self._sealed_date:
|
|
||||||
return self._sealed_ready
|
|
||||||
# 其他日期: 有 parquet 就 ready
|
|
||||||
return self._persisted_for_date(target_date)
|
|
||||||
|
|
||||||
def get_sealed_age(self, target_date: date) -> float | None:
|
|
||||||
"""返回 sealed 数据 age(秒), 盘后定版为 None。"""
|
|
||||||
if self._sealed_date and target_date == self._sealed_date and self._sealed_ready and self._sealed_fetched_ts:
|
|
||||||
return time.perf_counter() - self._sealed_fetched_ts
|
|
||||||
return None
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# 盘中轮询线程
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
def _poll_loop(self) -> None:
|
|
||||||
"""盘中轮询: 按 capset 自适应间隔拉 depth, 更新内存缓存。"""
|
|
||||||
while self._running:
|
|
||||||
try:
|
|
||||||
if self._is_trading_hours():
|
|
||||||
self._poll_once()
|
|
||||||
else:
|
|
||||||
logger.debug("depth sealed: 非交易时段, 跳过")
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
logger.warning("depth sealed 轮询异常: %s", e)
|
|
||||||
|
|
||||||
# 等待下一轮(用 _running 检查保证能及时退出)
|
|
||||||
interval = self._current_sleep_interval()
|
|
||||||
waited = 0.0
|
|
||||||
while self._running and waited < interval:
|
|
||||||
time.sleep(0.5)
|
|
||||||
waited += 0.5
|
|
||||||
|
|
||||||
def _poll_once(self) -> None:
|
|
||||||
"""单次轮询: 算间隔(三层防护) → 拉取 → 检测系统接管通知。"""
|
|
||||||
# 数当前涨跌停股
|
|
||||||
n = self._count_limit_stocks()
|
|
||||||
if n == 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
interval, taken_over, user_interval = self._compute_interval(n)
|
|
||||||
|
|
||||||
# 系统接管通知(状态切换时才推, 防刷屏)
|
|
||||||
if taken_over and (self._last_taken_over is False or self._last_user_interval != user_interval):
|
|
||||||
self._notify_takeover(n, user_interval, interval)
|
|
||||||
self._last_taken_over = taken_over
|
|
||||||
self._last_user_interval = user_interval
|
|
||||||
|
|
||||||
self._fetch_and_seal(persist=False)
|
|
||||||
|
|
||||||
def _current_sleep_interval(self) -> float:
|
|
||||||
"""计算当前 sleep 间隔(供 _poll_loop 等待用)。"""
|
|
||||||
n = self._count_limit_stocks()
|
|
||||||
if n == 0:
|
|
||||||
return 30.0 # 无涨跌停, 慢轮询
|
|
||||||
interval, _, _ = self._compute_interval(n)
|
|
||||||
return interval
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# 三层防护节流
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
def _compute_interval(self, n_symbols: int) -> tuple[float, bool, float]:
|
|
||||||
"""三层防护计算实际轮询间隔。
|
|
||||||
|
|
||||||
返回 (actual_interval, taken_over, user_interval)
|
|
||||||
- actual_interval: 实际使用的间隔(秒)
|
|
||||||
- taken_over: 是否被系统接管(用户设置会超限)
|
|
||||||
- user_interval: 用户设置(经套餐 clamp 后)的间隔
|
|
||||||
"""
|
|
||||||
from app.services import preferences
|
|
||||||
from app.tickflow.policy import tier_label
|
|
||||||
|
|
||||||
capset = self._get_capset()
|
|
||||||
lim = capset.limits(__import__("app.tickflow.capabilities", fromlist=["Cap"]).Cap.DEPTH5_BATCH)
|
|
||||||
batch_size = (lim.batch if lim and lim.batch else 100)
|
|
||||||
rpm = (lim.rpm if lim and lim.rpm else 30)
|
|
||||||
|
|
||||||
# ① 套餐范围 clamp
|
|
||||||
tier = tier_label().split()[0].split("+")[0].strip().lower()
|
|
||||||
lo, hi = TIER_INTERVAL_RANGE.get(tier, DEFAULT_RANGE)
|
|
||||||
raw_user = preferences.get_depth_polling_interval()
|
|
||||||
user_interval = max(lo, min(hi, raw_user))
|
|
||||||
|
|
||||||
# ② 限速安全 clamp
|
|
||||||
batches = max(1, math.ceil(n_symbols / batch_size))
|
|
||||||
usable_rpm = rpm * RPM_MARGIN
|
|
||||||
calls_per_min = usable_rpm / batches if batches > 0 else usable_rpm
|
|
||||||
safe_interval = 60.0 / calls_per_min if calls_per_min > 0 else INTERVAL_HARD_MAX
|
|
||||||
|
|
||||||
# 实际: 取用户设置和安全的较大值
|
|
||||||
actual = max(user_interval, safe_interval)
|
|
||||||
# 硬上下限
|
|
||||||
actual = max(INTERVAL_HARD_MIN, min(actual, INTERVAL_HARD_MAX))
|
|
||||||
taken_over = safe_interval > user_interval
|
|
||||||
|
|
||||||
return actual, taken_over, user_interval
|
|
||||||
|
|
||||||
def _count_limit_stocks(self) -> int:
|
|
||||||
"""数当前涨跌停股总数(供节流计算)。"""
|
|
||||||
if not self._repo:
|
|
||||||
return 0
|
|
||||||
enriched, _ = self._repo.get_enriched_latest()
|
|
||||||
if enriched.is_empty():
|
|
||||||
return 0
|
|
||||||
n = 0
|
|
||||||
if "signal_limit_up" in enriched.columns:
|
|
||||||
n += enriched.filter(pl.col("signal_limit_up").fill_null(False)).height
|
|
||||||
if "signal_limit_down" in enriched.columns:
|
|
||||||
n += enriched.filter(pl.col("signal_limit_down").fill_null(False)).height
|
|
||||||
return n
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# 通知
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
def _notify_takeover(self, n_stocks: int, user_interval: float, actual_interval: float) -> None:
|
|
||||||
"""系统接管通知: 复用 quote_service 的 _pending_alerts 通道。"""
|
|
||||||
if not self._app_state:
|
|
||||||
return
|
|
||||||
qs = getattr(self._app_state, "quote_service", None)
|
|
||||||
if not qs:
|
|
||||||
return
|
|
||||||
msg = (f"五档轮询: 当前涨跌停 {n_stocks} 只, 您设置的 {user_interval:.0f} 秒间隔会超限, "
|
|
||||||
f"系统已自动调整为 {actual_interval:.0f} 秒")
|
|
||||||
alert = {
|
|
||||||
"source": "depth",
|
|
||||||
"type": "takeover",
|
|
||||||
"message": msg,
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
with qs._lock:
|
|
||||||
qs._pending_alerts.append(alert)
|
|
||||||
qs._alert_event.set()
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
logger.debug("depth 接管通知推送失败: %s", e)
|
|
||||||
|
|
||||||
def _notify_depth_updated(self, count: int) -> None:
|
|
||||||
"""修正完成通知: set quote_service._depth_update_event, SSE 推 depth_updated 刷新连板梯队。"""
|
|
||||||
if not self._app_state:
|
|
||||||
return
|
|
||||||
qs = getattr(self._app_state, "quote_service", None)
|
|
||||||
if not qs:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
qs.notify_depth_updated()
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
logger.debug("depth 更新通知推送失败: %s", e)
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# 工具
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
def _has_capability(self) -> bool:
|
|
||||||
capset = self._get_capset()
|
|
||||||
from app.tickflow.capabilities import Cap
|
|
||||||
return capset.has(Cap.DEPTH5_BATCH)
|
|
||||||
|
|
||||||
def _get_capset(self):
|
|
||||||
"""获取当前 capset(优先 app.state, 回退 detect)。"""
|
|
||||||
if self._app_state:
|
|
||||||
cs = getattr(self._app_state, "capabilities", None)
|
|
||||||
if cs:
|
|
||||||
return cs
|
|
||||||
from app.tickflow.policy import detect_capabilities
|
|
||||||
return detect_capabilities()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _is_trading_hours() -> bool:
|
|
||||||
now = datetime.now()
|
|
||||||
t = now.time()
|
|
||||||
morning = dt_time(9, 25) <= t <= dt_time(11, 35)
|
|
||||||
afternoon = dt_time(12, 55) <= t <= dt_time(15, 5)
|
|
||||||
return now.weekday() < 5 and (morning or afternoon)
|
|
||||||
@@ -38,7 +38,7 @@ def _invalidate(table: str | None = None) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_universe(capset: CapabilitySet) -> list[str]:
|
def _resolve_universe(capset: CapabilitySet) -> list[str]:
|
||||||
"""解析标的池 — 与 daily_pipeline 独立的副本。"""
|
"""解析标的池。"""
|
||||||
if capset.has(Cap.KLINE_DAILY_BATCH):
|
if capset.has(Cap.KLINE_DAILY_BATCH):
|
||||||
try:
|
try:
|
||||||
from app.tickflow.pools import get_pool
|
from app.tickflow.pools import get_pool
|
||||||
@@ -48,12 +48,11 @@ def _resolve_universe(capset: CapabilitySet) -> list[str]:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("CN_Equity_A pool unavailable: %s", e)
|
logger.warning("CN_Equity_A pool unavailable: %s", e)
|
||||||
|
|
||||||
from app.tickflow.pools import DEMO_SYMBOLS, get_pool as _get_pool
|
from app.tickflow.pools import DEMO_SYMBOLS
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import polars as pl
|
import polars as pl
|
||||||
base: set[str] = set(DEMO_SYMBOLS)
|
base: set[str] = set(DEMO_SYMBOLS)
|
||||||
base.update(_get_pool("watchlist"))
|
|
||||||
d = Path(settings.data_dir)
|
d = Path(settings.data_dir)
|
||||||
inst_path = d / "instruments" / "instruments.parquet"
|
inst_path = d / "instruments" / "instruments.parquet"
|
||||||
if inst_path.exists():
|
if inst_path.exists():
|
||||||
|
|||||||
@@ -3,11 +3,8 @@
|
|||||||
本模块由 `app.api.overview._build_overview` 抽离而来,目的是让「大盘复盘」
|
本模块由 `app.api.overview._build_overview` 抽离而来,目的是让「大盘复盘」
|
||||||
等无 Request 的调用方(定时任务、复盘服务)也能复用同一套聚合逻辑。
|
等无 Request 的调用方(定时任务、复盘服务)也能复用同一套聚合逻辑。
|
||||||
|
|
||||||
行为与原 `_build_overview` 完全一致,仅把对 `request.app.state.{repo,
|
|
||||||
quote_service,depth_service}` 的依赖改为显式参数。
|
|
||||||
|
|
||||||
公共入口:
|
公共入口:
|
||||||
build_market_overview(repo, quote_service, depth_service, as_of)
|
build_market_overview(repo, as_of)
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -81,24 +78,12 @@ def _score(value: float, low: float, high: float) -> int:
|
|||||||
|
|
||||||
|
|
||||||
# ================================================================
|
# ================================================================
|
||||||
# 指数行情(实时 quote_service 优先,回退 kline_index_daily SQL)
|
# 指数行情(从 kline_index_daily SQL 读取)
|
||||||
# ================================================================
|
# ================================================================
|
||||||
|
|
||||||
def _quote_status(quote_service) -> dict:
|
def _index_quotes(repo, as_of: date | None = None) -> list[dict]:
|
||||||
qs = quote_service
|
|
||||||
if not qs:
|
|
||||||
return {"enabled": False, "running": False, "quote_age_ms": None, "is_trading_hours": False}
|
|
||||||
return qs.status()
|
|
||||||
|
|
||||||
|
|
||||||
def _index_quotes(repo, quote_service, as_of: date | None = None) -> list[dict]:
|
|
||||||
rows: list[dict] = []
|
rows: list[dict] = []
|
||||||
if quote_service and as_of is None:
|
if repo:
|
||||||
df = quote_service.get_index_quotes(list(CORE_INDEX_SYMBOLS))
|
|
||||||
if not df.is_empty():
|
|
||||||
rows = df.to_dicts()
|
|
||||||
|
|
||||||
if not rows and repo:
|
|
||||||
placeholders = ", ".join("?" for _ in CORE_INDEX_SYMBOLS)
|
placeholders = ", ".join("?" for _ in CORE_INDEX_SYMBOLS)
|
||||||
try:
|
try:
|
||||||
db_rows = repo.execute_all(
|
db_rows = repo.execute_all(
|
||||||
@@ -354,27 +339,21 @@ def _pct_band_rows(values: list[float]) -> list[dict]:
|
|||||||
|
|
||||||
def build_market_overview(
|
def build_market_overview(
|
||||||
repo,
|
repo,
|
||||||
quote_service=None,
|
|
||||||
depth_service=None,
|
|
||||||
as_of: date | None = None,
|
as_of: date | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""装配市场总览(与原 overview._build_overview 行为一致)。
|
"""装配市场总览(与原 overview._build_overview 行为一致)。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
repo: KlineRepository(必填)。
|
repo: KlineRepository(必填)。
|
||||||
quote_service: QuoteService(可选;实时指数行情来源)。
|
|
||||||
depth_service: DepthService(可选;五档封板修正)。
|
|
||||||
as_of: 指定日期,None 则取最新有数据日。
|
as_of: 指定日期,None 则取最新有数据日。
|
||||||
"""
|
"""
|
||||||
svc = ScreenerService(repo)
|
svc = ScreenerService(repo)
|
||||||
as_of = as_of or svc.latest_date()
|
as_of = as_of or svc.latest_date()
|
||||||
status = _quote_status(quote_service)
|
indices = _index_quotes(repo, as_of)
|
||||||
indices = _index_quotes(repo, quote_service, as_of)
|
|
||||||
|
|
||||||
if not as_of:
|
if not as_of:
|
||||||
return {
|
return {
|
||||||
"as_of": None,
|
"as_of": None,
|
||||||
"quote_status": status,
|
|
||||||
"indices": indices,
|
"indices": indices,
|
||||||
"breadth": {"total": 0, "up": 0, "down": 0, "flat": 0, "up_pct": 0, "down_pct": 0},
|
"breadth": {"total": 0, "up": 0, "down": 0, "flat": 0, "up_pct": 0, "down_pct": 0},
|
||||||
"amount": {"total": 0, "avg": 0},
|
"amount": {"total": 0, "avg": 0},
|
||||||
@@ -434,22 +413,6 @@ def build_market_overview(
|
|||||||
limit_down = sum(1 for r in rows if bool(r.get("signal_limit_down")))
|
limit_down = sum(1 for r in rows if bool(r.get("signal_limit_down")))
|
||||||
max_boards = max([int(_finite(r.get("consecutive_limit_ups")) or 0) for r in rows], default=0)
|
max_boards = max([int(_finite(r.get("consecutive_limit_ups")) or 0) for r in rows], default=0)
|
||||||
|
|
||||||
# 五档 sealed 修正: 假涨停/假跌停不计入(需 Pro+ depth5.batch 能力)
|
|
||||||
sealed_ready = False
|
|
||||||
fake_up = 0
|
|
||||||
fake_down = 0
|
|
||||||
if depth_service:
|
|
||||||
up_map = depth_service.get_sealed_map(as_of, is_down=False)
|
|
||||||
down_map = depth_service.get_sealed_map(as_of, is_down=True)
|
|
||||||
sealed_ready = bool(up_map or down_map) and depth_service.is_sealed_ready(as_of)
|
|
||||||
if up_map:
|
|
||||||
fake_up = sum(1 for v in up_map.values() if v.get("sealed") is False)
|
|
||||||
if down_map:
|
|
||||||
fake_down = sum(1 for v in down_map.values() if v.get("sealed") is False)
|
|
||||||
if sealed_ready:
|
|
||||||
limit_up = max(0, limit_up - fake_up)
|
|
||||||
limit_down = max(0, limit_down - fake_down)
|
|
||||||
|
|
||||||
seal_rate = limit_up / (limit_up + broken) * 100 if (limit_up + broken) > 0 else 0
|
seal_rate = limit_up / (limit_up + broken) * 100 if (limit_up + broken) > 0 else 0
|
||||||
|
|
||||||
def above_ma_count(ma_key: str) -> int:
|
def above_ma_count(ma_key: str) -> int:
|
||||||
@@ -531,7 +494,6 @@ def build_market_overview(
|
|||||||
|
|
||||||
return _json_safe({
|
return _json_safe({
|
||||||
"as_of": str(as_of),
|
"as_of": str(as_of),
|
||||||
"quote_status": status,
|
|
||||||
"indices": indices,
|
"indices": indices,
|
||||||
"breadth": {
|
"breadth": {
|
||||||
"total": total,
|
"total": total,
|
||||||
@@ -547,7 +509,7 @@ def build_market_overview(
|
|||||||
},
|
},
|
||||||
"amount": {"total": total_amount, "avg": avg_amount},
|
"amount": {"total": total_amount, "avg": avg_amount},
|
||||||
"boards": boards,
|
"boards": boards,
|
||||||
"limit": {"limit_up": limit_up, "broken": broken, "failed": 0, "limit_down": limit_down, "max_boards": max_boards, "seal_rate": seal_rate, "tiers": tiers, "sealed_ready": sealed_ready, "fake_up": fake_up, "fake_down": fake_down},
|
"limit": {"limit_up": limit_up, "broken": broken, "failed": 0, "limit_down": limit_down, "max_boards": max_boards, "seal_rate": seal_rate, "tiers": tiers},
|
||||||
"distribution": _pct_band_rows(pct_values),
|
"distribution": _pct_band_rows(pct_values),
|
||||||
"trend": {
|
"trend": {
|
||||||
"above_ma5": above_ma5,
|
"above_ma5": above_ma5,
|
||||||
|
|||||||
@@ -252,8 +252,6 @@ def _recap_summary(overview: dict) -> str:
|
|||||||
|
|
||||||
async def recap_market_stream(
|
async def recap_market_stream(
|
||||||
repo,
|
repo,
|
||||||
quote_service=None,
|
|
||||||
depth_service=None,
|
|
||||||
as_of: date | None = None,
|
as_of: date | None = None,
|
||||||
focus: str = "",
|
focus: str = "",
|
||||||
news: list[dict] | None = None,
|
news: list[dict] | None = None,
|
||||||
@@ -262,13 +260,12 @@ async def recap_market_stream(
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
repo: KlineRepository(必填)。
|
repo: KlineRepository(必填)。
|
||||||
quote_service / depth_service: 可选,数据装配依赖。
|
|
||||||
as_of: 复盘日期,None 取最新有数据日。
|
as_of: 复盘日期,None 取最新有数据日。
|
||||||
focus: 用户追加的复盘关注点。
|
focus: 用户追加的复盘关注点。
|
||||||
news: 预检索的新闻列表(P1 不传,留 None 走降级说明;P3 由 news_search 注入)。
|
news: 预检索的新闻列表(P1 不传,留 None 走降级说明;P3 由 news_search 注入)。
|
||||||
"""
|
"""
|
||||||
# 1. 装配市场总览
|
# 1. 装配市场总览
|
||||||
overview = build_market_overview(repo, quote_service, depth_service, as_of)
|
overview = build_market_overview(repo, as_of)
|
||||||
as_of_str = overview.get("as_of")
|
as_of_str = overview.get("as_of")
|
||||||
|
|
||||||
if not as_of_str:
|
if not as_of_str:
|
||||||
@@ -314,8 +311,6 @@ async def recap_market_stream(
|
|||||||
|
|
||||||
async def recap_market_once(
|
async def recap_market_once(
|
||||||
repo,
|
repo,
|
||||||
quote_service=None,
|
|
||||||
depth_service=None,
|
|
||||||
as_of: date | None = None,
|
as_of: date | None = None,
|
||||||
focus: str = "",
|
focus: str = "",
|
||||||
news: list[dict] | None = None,
|
news: list[dict] | None = None,
|
||||||
@@ -327,7 +322,7 @@ async def recap_market_once(
|
|||||||
"""
|
"""
|
||||||
content_parts: list[str] = []
|
content_parts: list[str] = []
|
||||||
meta: dict = {"as_of": as_of.isoformat() if as_of else None}
|
meta: dict = {"as_of": as_of.isoformat() if as_of else None}
|
||||||
async for evt in recap_market_stream(repo, quote_service, depth_service, as_of, focus, news):
|
async for evt in recap_market_stream(repo, as_of, focus, news):
|
||||||
try:
|
try:
|
||||||
obj = json.loads(evt)
|
obj = json.loads(evt)
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
|
|||||||
@@ -39,53 +39,12 @@ def save(updates: dict) -> dict:
|
|||||||
return current
|
return current
|
||||||
|
|
||||||
|
|
||||||
def get_realtime_quotes_enabled() -> bool:
|
|
||||||
return load().get("realtime_quotes_enabled", False)
|
|
||||||
|
|
||||||
|
|
||||||
def get_indices_nav_pinned() -> bool:
|
def get_indices_nav_pinned() -> bool:
|
||||||
"""侧栏指数报价卡片是否固定显示。默认 True(常驻)。
|
"""侧栏指数报价卡片是否固定显示。默认 True(常驻)。
|
||||||
关闭后,卡片跟随实时行情开关(仅实时开时显示)。"""
|
关闭后,卡片跟随实时行情开关(仅实时开时显示)。"""
|
||||||
return load().get("indices_nav_pinned", True)
|
return load().get("indices_nav_pinned", True)
|
||||||
|
|
||||||
|
|
||||||
def get_realtime_quote_interval() -> float:
|
|
||||||
return load().get("realtime_quote_interval", 10.0)
|
|
||||||
|
|
||||||
|
|
||||||
def get_realtime_watchlist_symbols() -> list[str]:
|
|
||||||
"""Free 档自选实时监控标的:直接取自选页前 5 个。"""
|
|
||||||
try:
|
|
||||||
from app.services import watchlist
|
|
||||||
rows = watchlist.list_symbols()
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
logger.warning("load watchlist for realtime failed: %s", e)
|
|
||||||
return []
|
|
||||||
out: list[str] = []
|
|
||||||
for row in rows:
|
|
||||||
symbol = str((row or {}).get("symbol") or "").strip().upper()
|
|
||||||
if symbol and symbol not in out:
|
|
||||||
out.append(symbol)
|
|
||||||
if len(out) >= 5:
|
|
||||||
break
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def set_realtime_watchlist_symbols(symbols: list[str]) -> list[str]: # noqa: ARG001
|
|
||||||
"""兼容旧接口: Free 实时标的现在由自选页前 5 个决定。"""
|
|
||||||
return get_realtime_watchlist_symbols()
|
|
||||||
|
|
||||||
|
|
||||||
def set_realtime_quote_interval(interval: float) -> float:
|
|
||||||
"""保存行情轮询间隔(不在此做 min/max 校验,由调用方按档位限制)。"""
|
|
||||||
current = load()
|
|
||||||
current["realtime_quote_interval"] = interval
|
|
||||||
_path().write_text(
|
|
||||||
json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8",
|
|
||||||
)
|
|
||||||
return interval
|
|
||||||
|
|
||||||
|
|
||||||
def get_minute_sync_enabled() -> bool:
|
def get_minute_sync_enabled() -> bool:
|
||||||
return load().get("minute_sync_enabled", False)
|
return load().get("minute_sync_enabled", False)
|
||||||
|
|
||||||
@@ -116,11 +75,6 @@ def get_minute_data_provider() -> str:
|
|||||||
return provider if provider in _ALLOWED_DATA_PROVIDERS else "tickflow"
|
return provider if provider in _ALLOWED_DATA_PROVIDERS else "tickflow"
|
||||||
|
|
||||||
|
|
||||||
def get_realtime_data_provider() -> str:
|
|
||||||
# 盘中实时现阶段仅支持 TickFlow。
|
|
||||||
return "tickflow"
|
|
||||||
|
|
||||||
|
|
||||||
# ===== 盘后管道拉取内容开关 (A股 / ETF / 指数 独立控制) =====
|
# ===== 盘后管道拉取内容开关 (A股 / ETF / 指数 独立控制) =====
|
||||||
|
|
||||||
def get_pipeline_pull_a_share() -> bool:
|
def get_pipeline_pull_a_share() -> bool:
|
||||||
@@ -227,44 +181,6 @@ def set_index_daily_batch_size(size: int) -> int:
|
|||||||
return size
|
return size
|
||||||
|
|
||||||
|
|
||||||
# ── 五档盘口 sealed(真假涨停) 配置 ──────────────────────
|
|
||||||
|
|
||||||
def get_limit_ladder_monitor_enabled() -> bool:
|
|
||||||
"""连板梯队 5 档监控开关。关闭时 depth 不轮询(连板梯队降级显示)。"""
|
|
||||||
return load().get("limit_ladder_monitor_enabled", False)
|
|
||||||
|
|
||||||
|
|
||||||
def get_depth_polling_interval() -> float:
|
|
||||||
"""depth 盘中轮询间隔(秒)。默认 20(Pro/Expert 都适用)。"""
|
|
||||||
return float(load().get("depth_polling_interval", 20.0))
|
|
||||||
|
|
||||||
|
|
||||||
def set_depth_polling_interval(interval: float) -> float:
|
|
||||||
"""保存 depth 轮询间隔。套餐范围 clamp 由 depth_service 按档位做。"""
|
|
||||||
interval = max(1.0, min(600.0, float(interval)))
|
|
||||||
save({"depth_polling_interval": interval})
|
|
||||||
return interval
|
|
||||||
|
|
||||||
|
|
||||||
def get_depth_finalize_time() -> dict:
|
|
||||||
"""盘后 sealed 定版时间 {"hour": 15, "minute": 2}。范围 15:01~18:00。"""
|
|
||||||
d = load().get("depth_finalize_time", {"hour": 15, "minute": 2})
|
|
||||||
return {"hour": d.get("hour", 15), "minute": d.get("minute", 2)}
|
|
||||||
|
|
||||||
|
|
||||||
def set_depth_finalize_time(hour: int, minute: int) -> dict:
|
|
||||||
"""保存盘后 sealed 定版时间,强制范围 15:01~18:00。"""
|
|
||||||
h = max(0, min(23, hour))
|
|
||||||
m = max(0, min(59, minute))
|
|
||||||
# 下限 15:01, 上限 18:00
|
|
||||||
if h * 60 + m < 15 * 60 + 1:
|
|
||||||
h, m = 15, 1
|
|
||||||
if h * 60 + m > 18 * 60:
|
|
||||||
h, m = 18, 0
|
|
||||||
save({"depth_finalize_time": {"hour": h, "minute": m}})
|
|
||||||
return {"hour": h, "minute": m}
|
|
||||||
|
|
||||||
|
|
||||||
# 复盘推送可选渠道白名单 (微信等暂未实现, 不在白名单内, 前端仅作占位)
|
# 复盘推送可选渠道白名单 (微信等暂未实现, 不在白名单内, 前端仅作占位)
|
||||||
# 多选: 不推送 = 空数组, 而非 'none'
|
# 多选: 不推送 = 空数组, 而非 'none'
|
||||||
REVIEW_PUSH_CHANNELS = {"feishu"}
|
REVIEW_PUSH_CHANNELS = {"feishu"}
|
||||||
@@ -333,86 +249,9 @@ def set_review_push_channels(channels: list[str]) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ===== 实时监控 =====
|
|
||||||
|
|
||||||
# 页面 SSE 刷新配置: { "watchlist": true, "monitor": true, ... }
|
|
||||||
# 可刷新的页面列表及其默认值
|
|
||||||
SSE_REFRESH_PAGES_DEFAULT = {
|
|
||||||
"watchlist": True,
|
|
||||||
"limit-ladder": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
SIDEBAR_INDEX_SYMBOLS_DEFAULT = ["000001.SH", "399001.SZ", "399006.SZ", "000680.SH"]
|
SIDEBAR_INDEX_SYMBOLS_DEFAULT = ["000001.SH", "399001.SZ", "399006.SZ", "000680.SH"]
|
||||||
|
|
||||||
|
|
||||||
# ===== 盘中实时行情范围 (独立于盘后管道范围) =====
|
|
||||||
|
|
||||||
|
|
||||||
def get_realtime_pull_stock() -> bool:
|
|
||||||
return load().get("realtime_pull_stock", True)
|
|
||||||
|
|
||||||
|
|
||||||
def get_realtime_pull_etf() -> bool:
|
|
||||||
# 老用户兼容: ETF 实时默认关闭,避免升级后请求量/写盘量突然增加。
|
|
||||||
return load().get("realtime_pull_etf", False)
|
|
||||||
|
|
||||||
|
|
||||||
def get_realtime_pull_index() -> bool:
|
|
||||||
return load().get("realtime_pull_index", True)
|
|
||||||
|
|
||||||
|
|
||||||
def get_realtime_index_mode() -> str:
|
|
||||||
mode = str(load().get("realtime_index_mode", "core") or "core").lower()
|
|
||||||
return mode if mode in {"core", "all"} else "core"
|
|
||||||
|
|
||||||
|
|
||||||
def get_realtime_index_symbols() -> list[str]:
|
|
||||||
stored = load().get("realtime_index_symbols", SIDEBAR_INDEX_SYMBOLS_DEFAULT)
|
|
||||||
if isinstance(stored, str):
|
|
||||||
import re
|
|
||||||
stored = [s.strip() for s in re.split(r"[,\s]+", stored) if s.strip()]
|
|
||||||
return [str(s) for s in stored if str(s).strip()]
|
|
||||||
|
|
||||||
|
|
||||||
def set_realtime_quote_scope(cfg: dict) -> dict:
|
|
||||||
updates = {}
|
|
||||||
for key in ("realtime_pull_stock", "realtime_pull_etf", "realtime_pull_index"):
|
|
||||||
if key in cfg and cfg[key] is not None:
|
|
||||||
updates[key] = bool(cfg[key])
|
|
||||||
if "realtime_index_mode" in cfg and cfg["realtime_index_mode"] in {"core", "all"}:
|
|
||||||
updates["realtime_index_mode"] = cfg["realtime_index_mode"]
|
|
||||||
if "realtime_index_symbols" in cfg and cfg["realtime_index_symbols"] is not None:
|
|
||||||
updates["realtime_index_symbols"] = cfg["realtime_index_symbols"]
|
|
||||||
if updates:
|
|
||||||
save(updates)
|
|
||||||
return get_realtime_quote_scope()
|
|
||||||
|
|
||||||
|
|
||||||
def get_realtime_quote_scope() -> dict:
|
|
||||||
return {
|
|
||||||
"realtime_pull_stock": get_realtime_pull_stock(),
|
|
||||||
"realtime_pull_etf": get_realtime_pull_etf(),
|
|
||||||
"realtime_pull_index": get_realtime_pull_index(),
|
|
||||||
"realtime_index_mode": get_realtime_index_mode(),
|
|
||||||
"realtime_index_symbols": get_realtime_index_symbols(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_sse_refresh_pages() -> dict[str, bool]:
|
|
||||||
"""返回每个页面的 SSE 刷新开关。"""
|
|
||||||
stored = load().get("sse_refresh_pages", {})
|
|
||||||
# 合并默认值 (新增页面自动出现)
|
|
||||||
result = dict(SSE_REFRESH_PAGES_DEFAULT)
|
|
||||||
result.update(stored)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def set_sse_refresh_pages(pages: dict[str, bool]) -> dict[str, bool]:
|
|
||||||
"""保存页面 SSE 刷新配置。"""
|
|
||||||
save({"sse_refresh_pages": pages})
|
|
||||||
return get_sse_refresh_pages()
|
|
||||||
|
|
||||||
|
|
||||||
def get_sidebar_index_symbols() -> list[str]:
|
def get_sidebar_index_symbols() -> list[str]:
|
||||||
"""返回左侧菜单显示的指数代码。"""
|
"""返回左侧菜单显示的指数代码。"""
|
||||||
stored = load().get("sidebar_index_symbols", SIDEBAR_INDEX_SYMBOLS_DEFAULT)
|
stored = load().get("sidebar_index_symbols", SIDEBAR_INDEX_SYMBOLS_DEFAULT)
|
||||||
@@ -420,22 +259,6 @@ def get_sidebar_index_symbols() -> list[str]:
|
|||||||
return [s for s in stored if s in allowed]
|
return [s for s in stored if s in allowed]
|
||||||
|
|
||||||
|
|
||||||
def get_strategy_monitor_enabled() -> bool:
|
|
||||||
"""策略告警评估总开关。"""
|
|
||||||
return load().get("strategy_monitor_enabled", False)
|
|
||||||
|
|
||||||
|
|
||||||
def get_system_notify_enabled() -> bool:
|
|
||||||
"""系统通知开关 — 开启后监控告警同时推送到操作系统通知中心。"""
|
|
||||||
return load().get("system_notify_enabled", False)
|
|
||||||
|
|
||||||
|
|
||||||
def set_system_notify_enabled(enabled: bool) -> bool:
|
|
||||||
"""保存系统通知开关。"""
|
|
||||||
save({"system_notify_enabled": bool(enabled)})
|
|
||||||
return bool(enabled)
|
|
||||||
|
|
||||||
|
|
||||||
def get_feishu_webhook_url() -> str:
|
def get_feishu_webhook_url() -> str:
|
||||||
"""飞书自定义机器人 Webhook 地址 — 全局共用一处, 所有启用推送的规则都推到这一个群。"""
|
"""飞书自定义机器人 Webhook 地址 — 全局共用一处, 所有启用推送的规则都推到这一个群。"""
|
||||||
return load().get("feishu_webhook_url", "")
|
return load().get("feishu_webhook_url", "")
|
||||||
@@ -446,73 +269,11 @@ def get_feishu_webhook_secret() -> str:
|
|||||||
return load().get("feishu_webhook_secret", "")
|
return load().get("feishu_webhook_secret", "")
|
||||||
|
|
||||||
|
|
||||||
def set_feishu_webhook_url(url: str) -> str:
|
|
||||||
"""保存飞书 Webhook 地址。传入空串表示清空配置。"""
|
|
||||||
save({"feishu_webhook_url": str(url or "").strip()})
|
|
||||||
return get_feishu_webhook_url()
|
|
||||||
|
|
||||||
|
|
||||||
def set_feishu_webhook_secret(secret: str) -> str:
|
|
||||||
"""保存飞书签名密钥。传入空串表示不验签。"""
|
|
||||||
save({"feishu_webhook_secret": str(secret or "").strip()})
|
|
||||||
return get_feishu_webhook_secret()
|
|
||||||
|
|
||||||
|
|
||||||
def get_webhook_enabled_default() -> bool:
|
|
||||||
"""新建监控规则时是否默认勾选「飞书推送」。
|
|
||||||
|
|
||||||
数据模型当前只有一个 webhook_enabled 布尔 (即飞书), QMT/ptrade 待定。
|
|
||||||
此默认值供规则编辑器新建规则时预填, 单条规则仍可独立修改。
|
|
||||||
"""
|
|
||||||
return load().get("webhook_enabled_default", False)
|
|
||||||
|
|
||||||
|
|
||||||
def set_webhook_enabled_default(enabled: bool) -> bool:
|
|
||||||
"""保存飞书推送默认勾选态。"""
|
|
||||||
save({"webhook_enabled_default": bool(enabled)})
|
|
||||||
return get_webhook_enabled_default()
|
|
||||||
|
|
||||||
|
|
||||||
def get_screener_auto_run() -> bool:
|
def get_screener_auto_run() -> bool:
|
||||||
"""选股页进入时是否自动运行所有策略 (获取命中数)。默认开。"""
|
"""选股页进入时是否自动运行所有策略 (获取命中数)。默认开。"""
|
||||||
return load().get("screener_auto_run", True)
|
return load().get("screener_auto_run", True)
|
||||||
|
|
||||||
|
|
||||||
def get_strategy_monitor_ids() -> list[str]:
|
|
||||||
"""返回监控池中的策略 ID。"""
|
|
||||||
return load().get("strategy_monitor_ids", [])
|
|
||||||
|
|
||||||
|
|
||||||
def set_realtime_monitor_config(cfg: dict) -> dict:
|
|
||||||
"""批量更新实时监控配置。"""
|
|
||||||
updates = {}
|
|
||||||
if "sse_refresh_pages" in cfg:
|
|
||||||
updates["sse_refresh_pages"] = cfg["sse_refresh_pages"]
|
|
||||||
if "strategy_monitor_enabled" in cfg:
|
|
||||||
updates["strategy_monitor_enabled"] = cfg["strategy_monitor_enabled"]
|
|
||||||
if "strategy_monitor_ids" in cfg:
|
|
||||||
updates["strategy_monitor_ids"] = cfg["strategy_monitor_ids"]
|
|
||||||
if "sidebar_index_symbols" in cfg:
|
|
||||||
allowed = set(SIDEBAR_INDEX_SYMBOLS_DEFAULT)
|
|
||||||
updates["sidebar_index_symbols"] = [s for s in cfg["sidebar_index_symbols"] if s in allowed]
|
|
||||||
if "screener_auto_run" in cfg:
|
|
||||||
updates["screener_auto_run"] = bool(cfg["screener_auto_run"])
|
|
||||||
if updates:
|
|
||||||
save(updates)
|
|
||||||
return get_realtime_monitor_config()
|
|
||||||
|
|
||||||
|
|
||||||
def get_realtime_monitor_config() -> dict:
|
|
||||||
"""返回完整的实时监控配置。"""
|
|
||||||
return {
|
|
||||||
"sse_refresh_pages": get_sse_refresh_pages(),
|
|
||||||
"strategy_monitor_enabled": get_strategy_monitor_enabled(),
|
|
||||||
"strategy_monitor_ids": get_strategy_monitor_ids(),
|
|
||||||
"sidebar_index_symbols": get_sidebar_index_symbols(),
|
|
||||||
"screener_auto_run": get_screener_auto_run(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_nav_order() -> list[str]:
|
def get_nav_order() -> list[str]:
|
||||||
"""返回左侧菜单的自定义排序(内置页面 path + 扩展分析菜单 id)。"""
|
"""返回左侧菜单的自定义排序(内置页面 path + 扩展分析菜单 id)。"""
|
||||||
return load().get("nav_order", [])
|
return load().get("nav_order", [])
|
||||||
@@ -535,17 +296,6 @@ def set_nav_hidden(hidden: list[str]) -> list[str]:
|
|||||||
return get_nav_hidden()
|
return get_nav_hidden()
|
||||||
|
|
||||||
|
|
||||||
def get_watchlist_columns() -> list[dict] | None:
|
|
||||||
"""返回自选列表列配置。"""
|
|
||||||
return load().get("watchlist_columns")
|
|
||||||
|
|
||||||
|
|
||||||
def set_watchlist_columns(columns: list[dict]) -> list[dict]:
|
|
||||||
"""保存自选列表列配置。"""
|
|
||||||
save({"watchlist_columns": columns})
|
|
||||||
return columns
|
|
||||||
|
|
||||||
|
|
||||||
def get_screener_result_columns() -> list[dict] | None:
|
def get_screener_result_columns() -> list[dict] | None:
|
||||||
"""返回策略结果列表列配置。"""
|
"""返回策略结果列表列配置。"""
|
||||||
return load().get("screener_result_columns")
|
return load().get("screener_result_columns")
|
||||||
|
|||||||
@@ -1,971 +0,0 @@
|
|||||||
"""全局实时行情服务。
|
|
||||||
|
|
||||||
集中管理全市场行情拉取 + enriched 缓存,供盘中选股、自选股等所有模块复用。
|
|
||||||
|
|
||||||
架构:
|
|
||||||
- 后台线程轮询 TickFlow get_by_universes(["CN_Equity_A", "CN_Index"])
|
|
||||||
- 拉取行情 → 写 kline_daily (不复权) + 增量计算 enriched → 写盘 + 更新缓存
|
|
||||||
- _enriched_cache 是唯一的盘中数据源 (OHLCV + 全套技术指标)
|
|
||||||
- _live_agg_cache 是递推状态 (只加载一次, 盘中不变)
|
|
||||||
|
|
||||||
数据流 (每轮 ~15s):
|
|
||||||
1. API 拉取 → raw_records (临时变量)
|
|
||||||
2. raw_records → 写 kline_daily (不复权原始价格)
|
|
||||||
3. raw_records → 更新 _enriched_cache 的 OHLCV
|
|
||||||
4. 增量计算 enriched 指标 (~50ms)
|
|
||||||
5. 写 kline_daily_enriched + 替换 _enriched_cache
|
|
||||||
6. 通知 SSE
|
|
||||||
|
|
||||||
生命周期:
|
|
||||||
- 服务启动时读取 preferences,若 enabled 则自动启动线程
|
|
||||||
- 运行中可通过 API 切换开关
|
|
||||||
- 关闭时停止线程
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from datetime import date, datetime, time as dt_time
|
|
||||||
|
|
||||||
import polars as pl
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class QuoteService:
|
|
||||||
"""全局实时行情服务 — 单例。"""
|
|
||||||
|
|
||||||
CORE_INDEX_SYMBOLS = ("000001.SH", "399001.SZ", "399006.SZ", "000680.SH")
|
|
||||||
|
|
||||||
# 档位 → 最小轮询间隔 (秒)
|
|
||||||
TIER_MIN_INTERVAL = {
|
|
||||||
"expert": 1.0,
|
|
||||||
"pro": 2.0,
|
|
||||||
"starter": 3.0,
|
|
||||||
"free": 6.0,
|
|
||||||
}
|
|
||||||
DEFAULT_INTERVAL = 10.0
|
|
||||||
MAX_INTERVAL = 60.0
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
self._running = False
|
|
||||||
self._enabled = False # 全局开关 (持久化到 preferences)
|
|
||||||
self._interval = self.DEFAULT_INTERVAL
|
|
||||||
self._thread: threading.Thread | None = None
|
|
||||||
self._repo = None # 延迟注入, 避免循环导入
|
|
||||||
self._update_event = threading.Event() # SSE 通知: 行情更新后 set
|
|
||||||
self._alert_event = threading.Event() # SSE 通知: 有告警时 set
|
|
||||||
self._depth_update_event = threading.Event() # SSE 通知: depth 五档修正后 set (刷新连板梯队)
|
|
||||||
self._pending_alerts: list[dict] = [] # 待推送的告警
|
|
||||||
self._max_pending_alerts: int = 1000 # 背压上限: 超出丢弃最旧
|
|
||||||
# 复盘进度 SSE 通道: 定时复盘流式生成时, 把 meta/delta/done 事件推给开着页面的前端
|
|
||||||
self._review_event = threading.Event() # SSE 通知: 有复盘进度事件时 set
|
|
||||||
self._pending_review: list[str] = [] # 待推送的复盘事件(JSON 字符串)
|
|
||||||
self._max_pending_review: int = 200 # 背压上限: 超出丢弃最旧
|
|
||||||
self._strategy_monitor = None # 延迟注入
|
|
||||||
self._app_state = None # 延迟注入 (FastAPI app.state)
|
|
||||||
|
|
||||||
# 拉取元信息 (给 SSE / status 用)
|
|
||||||
self._fetch_time: float = 0.0 # perf_counter (用于计算 quote_age_ms)
|
|
||||||
self._fetch_ms: float = 0.0 # 拉取耗时 (毫秒)
|
|
||||||
self._fetched_at: float = 0.0 # 拉取完成的 Unix 时间戳 (毫秒)
|
|
||||||
self._symbol_count: int = 0
|
|
||||||
self._index_symbol_count: int = 0
|
|
||||||
self._etf_symbol_count: int = 0
|
|
||||||
self._index_quotes_cache: pl.DataFrame | None = None
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# 生命周期
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
def start(self, interval: float = 0.0) -> None:
|
|
||||||
"""启动后台行情轮询线程。"""
|
|
||||||
if self._running:
|
|
||||||
return
|
|
||||||
if interval <= 0:
|
|
||||||
from app.services import preferences
|
|
||||||
interval = preferences.get_realtime_quote_interval()
|
|
||||||
self._interval = self._clamp_interval(interval)
|
|
||||||
self._running = True
|
|
||||||
self._enabled = True
|
|
||||||
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
|
|
||||||
self._thread.start()
|
|
||||||
self._save_enabled(True)
|
|
||||||
logger.info("行情服务已启动, 轮询间隔 %.1fs", self._interval)
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
|
||||||
"""停止后台行情轮询线程。"""
|
|
||||||
self._running = False
|
|
||||||
self._enabled = False
|
|
||||||
if self._thread:
|
|
||||||
self._thread.join(timeout=10)
|
|
||||||
self._thread = None
|
|
||||||
self._save_enabled(False)
|
|
||||||
logger.info("行情服务已停止")
|
|
||||||
|
|
||||||
def enable(self) -> bool:
|
|
||||||
"""开启自动行情 (不立即启动线程,等下一个交易时段)。
|
|
||||||
|
|
||||||
none 档无实时行情权限,拒绝开启并返回 False;
|
|
||||||
free 档开启自选股实时,starter+ 开启全市场实时。返回值表示是否真正开启。
|
|
||||||
"""
|
|
||||||
if not self.is_realtime_allowed():
|
|
||||||
logger.warning("实时行情开启被拒:当前档位(none)无实时行情权限")
|
|
||||||
return False
|
|
||||||
self._enabled = True
|
|
||||||
self._save_enabled(True)
|
|
||||||
if not self._running:
|
|
||||||
from app.services import preferences
|
|
||||||
self._interval = self._clamp_interval(preferences.get_realtime_quote_interval())
|
|
||||||
self._running = True
|
|
||||||
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
|
|
||||||
self._thread.start()
|
|
||||||
logger.info("行情服务已启用, 轮询间隔 %.1fs", self._interval)
|
|
||||||
|
|
||||||
def disable(self) -> None:
|
|
||||||
"""关闭自动行情。"""
|
|
||||||
self.stop()
|
|
||||||
logger.info("行情服务已关闭")
|
|
||||||
|
|
||||||
def boot_check(self) -> None:
|
|
||||||
"""启动时检查 preferences,若 enabled 则自动启动。
|
|
||||||
|
|
||||||
none 档无实时行情权限:即使 preferences 标记为 enabled,
|
|
||||||
也不启动,并同步 preferences 为关闭(避免 UI 误显示已开启)。
|
|
||||||
"""
|
|
||||||
from app.services import preferences
|
|
||||||
if not self.is_realtime_allowed():
|
|
||||||
if preferences.get_realtime_quotes_enabled():
|
|
||||||
self._save_enabled(False)
|
|
||||||
logger.info("实时行情未启动:当前档位(none)无实时行情权限")
|
|
||||||
return
|
|
||||||
if preferences.get_realtime_quotes_enabled():
|
|
||||||
self.start()
|
|
||||||
|
|
||||||
def set_repo(self, repo) -> None:
|
|
||||||
"""注入 KlineRepository, 用于实时落盘。"""
|
|
||||||
self._repo = repo
|
|
||||||
|
|
||||||
def set_app_state(self, app_state) -> None:
|
|
||||||
"""注入 FastAPI app.state, 用于获取 strategy_monitor 等单例。"""
|
|
||||||
self._app_state = app_state
|
|
||||||
|
|
||||||
def set_interval(self, interval: float) -> float:
|
|
||||||
"""运行时更新轮询间隔(立即生效)。"""
|
|
||||||
clamped = self._clamp_interval(interval)
|
|
||||||
self._interval = clamped
|
|
||||||
from app.services import preferences
|
|
||||||
preferences.set_realtime_quote_interval(clamped)
|
|
||||||
logger.info("轮询间隔已更新为 %.1fs", clamped)
|
|
||||||
return clamped
|
|
||||||
|
|
||||||
def get_min_interval(self) -> float:
|
|
||||||
"""返回当前档位允许的最小间隔。"""
|
|
||||||
return self._tier_min_interval()
|
|
||||||
|
|
||||||
def wait_for_update(self, timeout: float = 30.0) -> bool:
|
|
||||||
"""阻塞等待下一次行情更新 (供 SSE 线程使用)。"""
|
|
||||||
self._update_event.clear()
|
|
||||||
return self._update_event.wait(timeout=timeout)
|
|
||||||
|
|
||||||
def wait_for_alert(self, timeout: float = 30.0) -> bool:
|
|
||||||
"""阻塞等待告警 (供 SSE 线程使用)。"""
|
|
||||||
self._alert_event.clear()
|
|
||||||
return self._alert_event.wait(timeout=timeout)
|
|
||||||
|
|
||||||
def notify_depth_updated(self) -> None:
|
|
||||||
"""五档盘口修正完成后调用: 通知 SSE 推送 depth_updated, 触发连板梯队刷新。
|
|
||||||
|
|
||||||
与行情/告警通道独立 — 只刷新连板梯队, 不连带刷新 watchlist 等。
|
|
||||||
"""
|
|
||||||
self._depth_update_event.set()
|
|
||||||
|
|
||||||
def wait_for_depth_update(self, timeout: float = 30.0) -> bool:
|
|
||||||
"""阻塞等待 depth 修正 (供 SSE 线程使用)。"""
|
|
||||||
self._depth_update_event.clear()
|
|
||||||
return self._depth_update_event.wait(timeout=timeout)
|
|
||||||
|
|
||||||
def pop_alerts(self) -> list[dict]:
|
|
||||||
"""取走所有待推送的告警 (线程安全)。"""
|
|
||||||
with self._lock:
|
|
||||||
alerts = self._pending_alerts
|
|
||||||
self._pending_alerts = []
|
|
||||||
return alerts
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# 复盘进度 SSE 通道 — 定时复盘流式生成时, 把事件实时推给前端
|
|
||||||
# ================================================================
|
|
||||||
def push_review_event(self, event_json: str) -> None:
|
|
||||||
"""追加一条复盘进度事件(JSON 字符串), 并唤醒 SSE generator。
|
|
||||||
|
|
||||||
事件格式与 recap_market_stream 的产出一致(meta/delta/error/done),
|
|
||||||
前端 reviewStore 直接消费。背压: 超过上限丢弃最旧(复盘流几百条 delta, 200 够用)。
|
|
||||||
"""
|
|
||||||
with self._lock:
|
|
||||||
self._pending_review.append(event_json)
|
|
||||||
if len(self._pending_review) > self._max_pending_review:
|
|
||||||
overflow = len(self._pending_review) - self._max_pending_review
|
|
||||||
self._pending_review = self._pending_review[overflow:]
|
|
||||||
self._review_event.set()
|
|
||||||
|
|
||||||
def wait_for_review(self, timeout: float = 30.0) -> bool:
|
|
||||||
"""阻塞等待复盘进度事件 (供 SSE 线程使用)。"""
|
|
||||||
self._review_event.clear()
|
|
||||||
return self._review_event.wait(timeout=timeout)
|
|
||||||
|
|
||||||
def pop_review_events(self) -> list[str]:
|
|
||||||
"""取走所有待推送的复盘事件 (线程安全)。"""
|
|
||||||
with self._lock:
|
|
||||||
events = self._pending_review
|
|
||||||
self._pending_review = []
|
|
||||||
return events
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# 档位感知间隔限制
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _current_tier() -> str:
|
|
||||||
"""获取当前档位名(小写)。"""
|
|
||||||
from app.tickflow.policy import tier_label
|
|
||||||
return tier_label().split()[0].split("+")[0].strip().lower()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def realtime_mode(cls) -> str:
|
|
||||||
"""当前实时行情模式: none / watchlist / full_market。"""
|
|
||||||
tier = cls._current_tier()
|
|
||||||
if tier == "none":
|
|
||||||
return "none"
|
|
||||||
if tier == "free":
|
|
||||||
return "watchlist"
|
|
||||||
return "full_market"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def is_realtime_allowed(cls) -> bool:
|
|
||||||
"""当前档位是否允许使用实时行情。"""
|
|
||||||
return cls.realtime_mode() != "none"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _tier_min_interval(cls) -> float:
|
|
||||||
tier = cls._current_tier()
|
|
||||||
return cls.TIER_MIN_INTERVAL.get(tier, cls.DEFAULT_INTERVAL)
|
|
||||||
|
|
||||||
def _clamp_interval(self, interval: float) -> float:
|
|
||||||
return max(self._tier_min_interval(), min(self.MAX_INTERVAL, interval))
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# 行情数据访问
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
def get_enriched_today(self) -> tuple[pl.DataFrame, date | None]:
|
|
||||||
"""返回今天 enriched 数据 + 日期 (线程安全)。
|
|
||||||
|
|
||||||
所有页面统一通过此方法获取实时行情 + 技术指标。
|
|
||||||
"""
|
|
||||||
if not self._repo:
|
|
||||||
return pl.DataFrame(), None
|
|
||||||
return self._repo.get_enriched_latest()
|
|
||||||
|
|
||||||
def get_quotes_compat(self) -> pl.DataFrame:
|
|
||||||
"""兼容接口: 返回行情 DataFrame (用于盘中选股等需要 last_price/prev_close 的场景)。
|
|
||||||
|
|
||||||
从 _enriched_cache 取 today 的数据, 只选行情基础列, 补上 last_price 别名。
|
|
||||||
不返回指标列, 避免 JOIN live_agg 时列名冲突。
|
|
||||||
"""
|
|
||||||
df, _ = self.get_enriched_today()
|
|
||||||
if df.is_empty():
|
|
||||||
return df
|
|
||||||
|
|
||||||
# 只取盘中选股需要的行情基础列
|
|
||||||
keep = [c for c in [
|
|
||||||
"symbol", "close", "open", "high", "low", "volume", "amount",
|
|
||||||
"prev_close", "change_pct", "change_amount", "amplitude", "turnover_rate",
|
|
||||||
] if c in df.columns]
|
|
||||||
df = df.select(keep)
|
|
||||||
|
|
||||||
# enriched 的 close 等价于 last_price
|
|
||||||
if "close" in df.columns and "last_price" not in df.columns:
|
|
||||||
df = df.with_columns(pl.col("close").alias("last_price"))
|
|
||||||
return df
|
|
||||||
|
|
||||||
def get_index_quotes(self, symbols: list[str] | None = None) -> pl.DataFrame:
|
|
||||||
"""返回实时指数行情缓存。不会触发 TickFlow 请求。"""
|
|
||||||
with self._lock:
|
|
||||||
df = self._index_quotes_cache.clone() if self._index_quotes_cache is not None else pl.DataFrame()
|
|
||||||
if df.is_empty():
|
|
||||||
return df
|
|
||||||
if symbols:
|
|
||||||
return df.filter(pl.col("symbol").is_in(symbols))
|
|
||||||
return df
|
|
||||||
|
|
||||||
def status(self) -> dict:
|
|
||||||
"""返回行情服务状态。"""
|
|
||||||
from app.services import preferences
|
|
||||||
age = (time.perf_counter() - self._fetch_time) * 1000 if self._fetch_time else -1
|
|
||||||
mode = self.realtime_mode()
|
|
||||||
return {
|
|
||||||
"enabled": self._enabled,
|
|
||||||
"running": self._running,
|
|
||||||
"mode": mode,
|
|
||||||
"realtime_allowed": mode != "none",
|
|
||||||
"watchlist_symbol_count": len(preferences.get_realtime_watchlist_symbols()),
|
|
||||||
"interval_s": self._interval,
|
|
||||||
"symbol_count": self._symbol_count,
|
|
||||||
"index_symbol_count": self._index_symbol_count,
|
|
||||||
"etf_symbol_count": self._etf_symbol_count,
|
|
||||||
"quote_age_ms": round(age, 0) if age >= 0 else None,
|
|
||||||
"is_trading_hours": self._is_trading_hours(),
|
|
||||||
"last_fetch_ms": round(self._fetched_at, 0) if self._fetched_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
def refresh(self) -> dict:
|
|
||||||
"""手动触发一次行情拉取。"""
|
|
||||||
self._fetch_quotes()
|
|
||||||
return self.status()
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# 后台轮询
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
def _poll_loop(self) -> None:
|
|
||||||
while self._running and self._enabled:
|
|
||||||
try:
|
|
||||||
if self._is_trading_hours():
|
|
||||||
self._fetch_quotes()
|
|
||||||
else:
|
|
||||||
logger.debug("非交易时段, 跳过行情轮询")
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
logger.warning("行情轮询异常: %s", e)
|
|
||||||
|
|
||||||
waited = 0.0
|
|
||||||
while self._running and self._enabled and waited < self._interval:
|
|
||||||
time.sleep(0.5)
|
|
||||||
waited += 0.5
|
|
||||||
|
|
||||||
def _fetch_quotes(self) -> None:
|
|
||||||
"""按当前档位拉取行情。"""
|
|
||||||
if self.realtime_mode() == "watchlist":
|
|
||||||
self._fetch_watchlist_quotes()
|
|
||||||
return
|
|
||||||
self._fetch_full_market_quotes()
|
|
||||||
|
|
||||||
def _fetch_full_market_quotes(self) -> None:
|
|
||||||
"""拉取全市场行情 → 写 daily + 计算 enriched + 更新缓存。"""
|
|
||||||
from app.tickflow.client import get_paid_realtime_client
|
|
||||||
|
|
||||||
tf = get_paid_realtime_client()
|
|
||||||
if tf is None:
|
|
||||||
logger.warning("实时行情拉取失败:未配置付费服务器 API Key")
|
|
||||||
return
|
|
||||||
t0 = time.perf_counter()
|
|
||||||
now_ts = time.perf_counter()
|
|
||||||
|
|
||||||
try:
|
|
||||||
from app.services import preferences
|
|
||||||
all_index_symbols = set(self._repo.get_index_symbol_set()) if self._repo else set()
|
|
||||||
core_index_symbols = set(preferences.get_realtime_index_symbols() or self.CORE_INDEX_SYMBOLS)
|
|
||||||
all_index_symbols.update(core_index_symbols)
|
|
||||||
all_etf_symbols = set()
|
|
||||||
if self._repo:
|
|
||||||
etf_inst = self._repo.get_etf_instruments()
|
|
||||||
if not etf_inst.is_empty() and "symbol" in etf_inst.columns:
|
|
||||||
all_etf_symbols = set(etf_inst["symbol"].cast(pl.Utf8).to_list())
|
|
||||||
|
|
||||||
universes: list[str] = []
|
|
||||||
if preferences.get_realtime_pull_stock():
|
|
||||||
universes.append("CN_Equity_A")
|
|
||||||
if preferences.get_realtime_pull_etf() and all_etf_symbols:
|
|
||||||
universes.append("CN_ETF")
|
|
||||||
if preferences.get_realtime_pull_index() and preferences.get_realtime_index_mode() == "all":
|
|
||||||
universes.append("CN_Index")
|
|
||||||
|
|
||||||
resp = []
|
|
||||||
if universes:
|
|
||||||
resp.extend(tf.quotes.get_by_universes(universes=universes) or [])
|
|
||||||
if preferences.get_realtime_pull_index() and preferences.get_realtime_index_mode() == "core":
|
|
||||||
resp.extend(tf.quotes.get(symbols=sorted(core_index_symbols)) or [])
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
logger.warning("行情拉取失败: %s", e)
|
|
||||||
return
|
|
||||||
|
|
||||||
if not resp:
|
|
||||||
logger.warning("行情数据为空")
|
|
||||||
return
|
|
||||||
|
|
||||||
# ---- 解析 API 响应 (临时变量, 用完丢弃) ----
|
|
||||||
records = []
|
|
||||||
for q in resp:
|
|
||||||
ext = q.get("ext") or {}
|
|
||||||
last_price = q.get("last_price")
|
|
||||||
prev_close = q.get("prev_close")
|
|
||||||
change_amount = ext.get("change_amount")
|
|
||||||
change_pct = ext.get("change_pct")
|
|
||||||
if change_amount is None and last_price is not None and prev_close is not None:
|
|
||||||
change_amount = float(last_price) - float(prev_close)
|
|
||||||
if change_pct is None and change_amount is not None and prev_close not in (None, 0):
|
|
||||||
change_pct = float(change_amount) / float(prev_close) * 100
|
|
||||||
records.append({
|
|
||||||
"symbol": q.get("symbol"),
|
|
||||||
"name": q.get("name") or ext.get("name"),
|
|
||||||
"last_price": last_price,
|
|
||||||
"prev_close": prev_close,
|
|
||||||
"open": q.get("open"),
|
|
||||||
"high": q.get("high"),
|
|
||||||
"low": q.get("low"),
|
|
||||||
"volume": q.get("volume"),
|
|
||||||
"amount": q.get("amount"),
|
|
||||||
"change_pct": change_pct,
|
|
||||||
"change_amount": change_amount,
|
|
||||||
"amplitude": ext.get("amplitude"),
|
|
||||||
"turnover_rate": ext.get("turnover_rate"),
|
|
||||||
"timestamp": q.get("timestamp"),
|
|
||||||
"session": q.get("session"),
|
|
||||||
})
|
|
||||||
|
|
||||||
index_records = [r for r in records if r.get("symbol") in all_index_symbols]
|
|
||||||
etf_records = [r for r in records if r.get("symbol") in all_etf_symbols]
|
|
||||||
stock_records = [
|
|
||||||
r for r in records
|
|
||||||
if r.get("symbol") not in all_index_symbols and r.get("symbol") not in all_etf_symbols
|
|
||||||
]
|
|
||||||
|
|
||||||
fetch_ms = (time.perf_counter() - t0) * 1000
|
|
||||||
fetched_at = time.time() * 1000
|
|
||||||
|
|
||||||
# ---- 更新元信息 ----
|
|
||||||
with self._lock:
|
|
||||||
self._fetch_time = now_ts
|
|
||||||
self._fetch_ms = fetch_ms
|
|
||||||
self._fetched_at = fetched_at
|
|
||||||
self._symbol_count = len(stock_records)
|
|
||||||
self._index_symbol_count = len(index_records)
|
|
||||||
self._etf_symbol_count = len(etf_records)
|
|
||||||
self._index_quotes_cache = self._build_index_quotes(index_records)
|
|
||||||
|
|
||||||
logger.info("行情刷新: %d 只股票, %d 只ETF, %d 只指数, 耗时 %.0fms", len(stock_records), len(etf_records), len(index_records), fetch_ms)
|
|
||||||
|
|
||||||
# ---- 写 kline_daily (不复权原始价格, 只有 OHLCV) ----
|
|
||||||
daily_df = self._build_daily(stock_records)
|
|
||||||
if not daily_df.is_empty() and self._repo:
|
|
||||||
try:
|
|
||||||
self._repo.flush_live_daily(daily_df)
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
logger.warning("日K写盘失败: %s", e)
|
|
||||||
|
|
||||||
etf_daily_df = self._build_daily(etf_records)
|
|
||||||
if not etf_daily_df.is_empty() and self._repo:
|
|
||||||
try:
|
|
||||||
self._repo.flush_live_daily_asset("etf", etf_daily_df)
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
logger.warning("ETF 日K写盘失败: %s", e)
|
|
||||||
|
|
||||||
# ---- 构建 API 直接值的补充表 (不写 daily, 只用于 enriched 计算) ----
|
|
||||||
quote_extra = self._build_quote_extra(stock_records)
|
|
||||||
etf_quote_extra = self._build_quote_extra(etf_records)
|
|
||||||
|
|
||||||
# ---- 增量计算 enriched + 写盘 + 更新缓存 ----
|
|
||||||
if not daily_df.is_empty() and self._repo:
|
|
||||||
self._flush_live_enriched(daily_df, quote_extra, asset_type="stock")
|
|
||||||
if not etf_daily_df.is_empty() and self._repo:
|
|
||||||
self._flush_live_enriched(etf_daily_df, etf_quote_extra, asset_type="etf")
|
|
||||||
|
|
||||||
# ---- 通知 SSE ----
|
|
||||||
self._update_event.set()
|
|
||||||
|
|
||||||
# ---- 策略监控 + 告警评估 ----
|
|
||||||
self._evaluate_monitors(daily_df, quote_extra)
|
|
||||||
|
|
||||||
def _fetch_watchlist_quotes(self) -> None:
|
|
||||||
"""Free 档自选股实时: 只拉取最多 5 个 symbols。"""
|
|
||||||
from app.services import preferences
|
|
||||||
from app.tickflow.client import get_paid_realtime_client
|
|
||||||
|
|
||||||
symbols = preferences.get_realtime_watchlist_symbols()
|
|
||||||
if not symbols:
|
|
||||||
logger.info("自选实时未配置标的, 跳过行情拉取")
|
|
||||||
return
|
|
||||||
|
|
||||||
tf = get_paid_realtime_client()
|
|
||||||
if tf is None:
|
|
||||||
logger.warning("自选实时拉取失败:未配置付费服务器 API Key")
|
|
||||||
return
|
|
||||||
|
|
||||||
t0 = time.perf_counter()
|
|
||||||
now_ts = time.perf_counter()
|
|
||||||
try:
|
|
||||||
resp = tf.quotes.get(symbols=symbols) or []
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
logger.warning("自选实时拉取失败: %s", e)
|
|
||||||
return
|
|
||||||
|
|
||||||
if not resp:
|
|
||||||
logger.warning("自选实时行情数据为空")
|
|
||||||
return
|
|
||||||
|
|
||||||
records = []
|
|
||||||
for q in resp:
|
|
||||||
ext = q.get("ext") or {}
|
|
||||||
last_price = q.get("last_price")
|
|
||||||
prev_close = q.get("prev_close")
|
|
||||||
change_amount = ext.get("change_amount")
|
|
||||||
change_pct = ext.get("change_pct")
|
|
||||||
if change_amount is None and last_price is not None and prev_close is not None:
|
|
||||||
change_amount = float(last_price) - float(prev_close)
|
|
||||||
if change_pct is None and change_amount is not None and prev_close not in (None, 0):
|
|
||||||
change_pct = float(change_amount) / float(prev_close) * 100
|
|
||||||
records.append({
|
|
||||||
"symbol": q.get("symbol"),
|
|
||||||
"name": q.get("name") or ext.get("name"),
|
|
||||||
"last_price": last_price,
|
|
||||||
"prev_close": prev_close,
|
|
||||||
"open": q.get("open"),
|
|
||||||
"high": q.get("high"),
|
|
||||||
"low": q.get("low"),
|
|
||||||
"volume": q.get("volume"),
|
|
||||||
"amount": q.get("amount"),
|
|
||||||
"change_pct": change_pct,
|
|
||||||
"change_amount": change_amount,
|
|
||||||
"amplitude": ext.get("amplitude"),
|
|
||||||
"turnover_rate": ext.get("turnover_rate"),
|
|
||||||
"timestamp": q.get("timestamp"),
|
|
||||||
"session": q.get("session"),
|
|
||||||
})
|
|
||||||
|
|
||||||
fetch_ms = (time.perf_counter() - t0) * 1000
|
|
||||||
fetched_at = time.time() * 1000
|
|
||||||
with self._lock:
|
|
||||||
self._fetch_time = now_ts
|
|
||||||
self._fetch_ms = fetch_ms
|
|
||||||
self._fetched_at = fetched_at
|
|
||||||
self._symbol_count = len(records)
|
|
||||||
self._index_symbol_count = 0
|
|
||||||
self._etf_symbol_count = 0
|
|
||||||
self._index_quotes_cache = None
|
|
||||||
|
|
||||||
logger.info("自选实时刷新: %d 只股票, 耗时 %.0fms", len(records), fetch_ms)
|
|
||||||
|
|
||||||
daily_df = self._build_daily(records)
|
|
||||||
quote_extra = self._build_quote_extra(records)
|
|
||||||
if not daily_df.is_empty() and self._repo:
|
|
||||||
try:
|
|
||||||
self._repo.merge_live_daily_asset("stock", daily_df)
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
logger.warning("自选实时日K写盘失败: %s", e)
|
|
||||||
self._flush_live_enriched(daily_df, quote_extra, asset_type="stock", merge=True)
|
|
||||||
|
|
||||||
self._update_event.set()
|
|
||||||
self._evaluate_monitors(daily_df, quote_extra)
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# 工具
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _build_daily(records: list[dict]) -> pl.DataFrame:
|
|
||||||
"""将 API records 转为日K格式 DataFrame (只有 OHLCV, 写 kline_daily 用)。"""
|
|
||||||
if not records:
|
|
||||||
return pl.DataFrame()
|
|
||||||
df = pl.DataFrame(records)
|
|
||||||
cols_map = {
|
|
||||||
"symbol": "symbol",
|
|
||||||
"last_price": "close",
|
|
||||||
"open": "open",
|
|
||||||
"high": "high",
|
|
||||||
"low": "low",
|
|
||||||
"volume": "volume",
|
|
||||||
"amount": "amount",
|
|
||||||
}
|
|
||||||
select_exprs = []
|
|
||||||
for src, dst in cols_map.items():
|
|
||||||
if src in df.columns:
|
|
||||||
select_exprs.append(pl.col(src).alias(dst))
|
|
||||||
if not select_exprs:
|
|
||||||
return pl.DataFrame()
|
|
||||||
result = df.select(select_exprs).with_columns(
|
|
||||||
pl.lit(date.today()).cast(pl.Date).alias("date"),
|
|
||||||
)
|
|
||||||
# 修复: API 在非交易时段可能返回 open/high/low=0 或 null,
|
|
||||||
# 导致蜡烛从 0 开始。用 close 填充这些异常值。
|
|
||||||
for col in ("open", "high", "low"):
|
|
||||||
if col in result.columns:
|
|
||||||
result = result.with_columns(
|
|
||||||
pl.when((pl.col(col) == 0) | pl.col(col).is_null())
|
|
||||||
.then(pl.col("close"))
|
|
||||||
.otherwise(pl.col(col))
|
|
||||||
.alias(col)
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _build_quote_extra(records: list[dict]) -> pl.DataFrame:
|
|
||||||
"""构建 API 直接提供的补充字段 (不写 daily, 只传给 enriched 计算)。
|
|
||||||
|
|
||||||
包含: prev_close, change_pct, change_amount, amplitude, turnover_rate。
|
|
||||||
"""
|
|
||||||
if not records:
|
|
||||||
return pl.DataFrame()
|
|
||||||
df = pl.DataFrame(records)
|
|
||||||
keep = [c for c in [
|
|
||||||
"symbol", "prev_close", "change_pct", "change_amount",
|
|
||||||
"amplitude", "turnover_rate",
|
|
||||||
] if c in df.columns]
|
|
||||||
if not keep or "symbol" not in keep:
|
|
||||||
return pl.DataFrame()
|
|
||||||
return df.select(keep)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _build_index_quotes(records: list[dict]) -> pl.DataFrame:
|
|
||||||
"""构建指数实时行情缓存,不落股票 parquet。
|
|
||||||
|
|
||||||
注意: API 返回的 change_pct/amplitude 是小数 (0.0366 = 3.66%),
|
|
||||||
统一转成百分比输出, 与 _fallback_index_quotes_from_daily 口径一致
|
|
||||||
(前端指数侧不×100, 直接 toFixed(2)% 展示)。
|
|
||||||
"""
|
|
||||||
if not records:
|
|
||||||
return pl.DataFrame()
|
|
||||||
df = pl.DataFrame(records)
|
|
||||||
keep = [c for c in [
|
|
||||||
"symbol", "name", "last_price", "prev_close", "open", "high", "low",
|
|
||||||
"volume", "amount", "change_pct", "change_amount", "amplitude", "timestamp", "session",
|
|
||||||
] if c in df.columns]
|
|
||||||
if not keep or "symbol" not in keep:
|
|
||||||
return pl.DataFrame()
|
|
||||||
df = df.select(keep)
|
|
||||||
# change_pct / amplitude: 小数 → 百分比 (统一指数展示口径)
|
|
||||||
for col in ("change_pct", "amplitude"):
|
|
||||||
if col in df.columns:
|
|
||||||
df = df.with_columns((pl.col(col).cast(pl.Float64) * 100).alias(col))
|
|
||||||
if "last_price" in df.columns and "close" not in df.columns:
|
|
||||||
df = df.with_columns(pl.col("last_price").alias("close"))
|
|
||||||
return df
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _is_trading_hours() -> bool:
|
|
||||||
now = datetime.now()
|
|
||||||
t = now.time()
|
|
||||||
morning = dt_time(9, 15) <= t <= dt_time(11, 35)
|
|
||||||
afternoon = dt_time(12, 55) <= t <= dt_time(15, 5)
|
|
||||||
return now.weekday() < 5 and (morning or afternoon)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _save_enabled(enabled: bool) -> None:
|
|
||||||
from app.services import preferences
|
|
||||||
preferences.save({"realtime_quotes_enabled": enabled})
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# 策略监控
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
def _evaluate_monitors(self, daily_df: pl.DataFrame, quote_extra: pl.DataFrame | None) -> None:
|
|
||||||
"""行情更新后评估统一监控规则引擎,并刷新策略结果缓存。"""
|
|
||||||
try:
|
|
||||||
# 获取 enriched 数据 (刚算好的)
|
|
||||||
enriched_today, enriched_date = self.get_enriched_today()
|
|
||||||
if enriched_today.is_empty():
|
|
||||||
return
|
|
||||||
|
|
||||||
all_alerts: list[dict] = []
|
|
||||||
rule_events: list[dict] = []
|
|
||||||
engine = None
|
|
||||||
|
|
||||||
# 通用监控规则评估 (统一引擎: signal/price/market/strategy)
|
|
||||||
if self._app_state:
|
|
||||||
engine = getattr(self._app_state, "monitor_engine", None)
|
|
||||||
if engine and engine.rule_count > 0:
|
|
||||||
# 预构建 symbol → name 映射 (enriched 已 drop name 列, 引擎触发时回填用)
|
|
||||||
try:
|
|
||||||
inst_df = self._app_state.repo.get_instruments()
|
|
||||||
if not inst_df.is_empty() and "symbol" in inst_df.columns and "name" in inst_df.columns:
|
|
||||||
engine.set_name_map({
|
|
||||||
row["symbol"]: row["name"]
|
|
||||||
for row in inst_df.select(["symbol", "name"]).iter_rows(named=True)
|
|
||||||
if row.get("name")
|
|
||||||
})
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
logger.debug("name_map 构建失败 (不影响监控): %s", e)
|
|
||||||
# 连板梯队封单监控: 有 ladder 规则时, 从 depth_service 注入封单量到 enriched
|
|
||||||
eval_df = enriched_today
|
|
||||||
if engine.has_rule_type("ladder"):
|
|
||||||
eval_df = self._inject_sealed_vol(enriched_today, enriched_date)
|
|
||||||
rule_events = engine.evaluate(eval_df)
|
|
||||||
if rule_events:
|
|
||||||
# 落盘到 alerts.jsonl
|
|
||||||
try:
|
|
||||||
from app.services import alert_store
|
|
||||||
alert_store.append_many(
|
|
||||||
self._app_state.repo.store.data_dir, rule_events,
|
|
||||||
)
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
logger.warning("告警落盘失败: %s", e)
|
|
||||||
# 转为 SSE 推送格式 (兼容旧 alert schema)
|
|
||||||
for ev in rule_events:
|
|
||||||
all_alerts.append({
|
|
||||||
"source": ev["source"],
|
|
||||||
"type": ev["type"],
|
|
||||||
"rule_id": ev.get("rule_id"),
|
|
||||||
"strategy_id": ev.get("rule_id") if ev["source"] == "strategy" else None,
|
|
||||||
"symbol": ev["symbol"],
|
|
||||||
"name": ev["name"],
|
|
||||||
"message": ev["message"],
|
|
||||||
"price": ev["price"],
|
|
||||||
"change_pct": ev["change_pct"],
|
|
||||||
"signals": ev["signals"],
|
|
||||||
"severity": ev.get("severity", "info"),
|
|
||||||
"conditions": ev.get("conditions") or [],
|
|
||||||
"logic": ev.get("logic") or "and",
|
|
||||||
})
|
|
||||||
|
|
||||||
# 策略页实时回显: 不写文件 (实时行情每轮更新 enriched, 写文件会被 read_cache
|
|
||||||
# 的 mtime 校验判过期, 反复读不到)。监控引擎本轮已算出的结果存在内存
|
|
||||||
# (latest_strategy_results), 由 /api/screener/cached 端点直接叠加读取。
|
|
||||||
|
|
||||||
# 推入待推送队列 + 通知 SSE (含背压保护)
|
|
||||||
if all_alerts:
|
|
||||||
with self._lock:
|
|
||||||
self._pending_alerts.extend(all_alerts)
|
|
||||||
# 背压: 超出上限丢弃最旧
|
|
||||||
if len(self._pending_alerts) > self._max_pending_alerts:
|
|
||||||
overflow = len(self._pending_alerts) - self._max_pending_alerts
|
|
||||||
self._pending_alerts = self._pending_alerts[overflow:]
|
|
||||||
self._alert_event.set()
|
|
||||||
logger.info("监控评估完成: %d 条通知", len(all_alerts))
|
|
||||||
|
|
||||||
# 系统通知 (可选通道, 由 preferences 开关控制)。
|
|
||||||
# cooldown 去重已在 MonitorRuleEngine 做过, 这里只负责转发。
|
|
||||||
self._maybe_send_system_notifications(all_alerts)
|
|
||||||
|
|
||||||
# Webhook 推送 (飞书等外部 IM, 由规则 webhook_enabled 开关控制)。
|
|
||||||
# 紧随系统通知, 同样静默降级不阻断主流程。
|
|
||||||
if rule_events:
|
|
||||||
self._maybe_send_webhook(rule_events, engine)
|
|
||||||
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
logger.warning("监控评估失败: %s", e)
|
|
||||||
|
|
||||||
def _inject_sealed_vol(self, enriched_today: pl.DataFrame, enriched_date) -> pl.DataFrame:
|
|
||||||
"""从 depth_service 取封单量, 作为临时列 _sealed_vol 注入 enriched 副本。
|
|
||||||
|
|
||||||
涨停封单(买一量) + 跌停封单(卖一量)合并, 供 ladder 规则评估。
|
|
||||||
depth 未就绪时返回原 df (不注入, ladder 规则安全降级不触发)。
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
depth_svc = getattr(self._app_state, "depth_service", None)
|
|
||||||
if not depth_svc:
|
|
||||||
return enriched_today
|
|
||||||
# enriched_date 可能是 date 或字符串, 统一为 date
|
|
||||||
from datetime import date as date_cls
|
|
||||||
target_date = enriched_date if isinstance(enriched_date, date_cls) else date_cls.fromisoformat(str(enriched_date))
|
|
||||||
# 取涨停 + 跌停封单, 合并 {symbol: vol}
|
|
||||||
up_map = depth_svc.get_sealed_map(target_date, is_down=False)
|
|
||||||
down_map = depth_svc.get_sealed_map(target_date, is_down=True)
|
|
||||||
sealed: dict[str, int] = {}
|
|
||||||
for m in (up_map, down_map):
|
|
||||||
for sym, info in m.items():
|
|
||||||
vol = (info or {}).get("vol")
|
|
||||||
if vol and vol > 0:
|
|
||||||
sealed[sym] = vol # 后者覆盖前者 (同 symbol 不可能在涨跌停都封单)
|
|
||||||
if not sealed:
|
|
||||||
return enriched_today
|
|
||||||
# 构造 (symbol, _sealed_vol) DataFrame, join 到 enriched 副本
|
|
||||||
sealed_df = pl.DataFrame({
|
|
||||||
"symbol": list(sealed.keys()),
|
|
||||||
"_sealed_vol": list(sealed.values()),
|
|
||||||
})
|
|
||||||
# 若已有残留列先移除 (避免重复 join 报错)
|
|
||||||
df = enriched_today.drop("_sealed_vol") if "_sealed_vol" in enriched_today.columns else enriched_today
|
|
||||||
return df.join(sealed_df, on="symbol", how="left")
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
logger.debug("封单注入失败 (ladder 规则将不触发): %s", e)
|
|
||||||
return enriched_today
|
|
||||||
|
|
||||||
def _maybe_send_webhook(self, rule_events: list[dict], engine) -> None:
|
|
||||||
"""把告警通过 Webhook 推送到外部 IM (由规则 webhook_enabled 开关控制)。
|
|
||||||
|
|
||||||
- 全局飞书 URL 未配置: 直接返回
|
|
||||||
- 仅推送 webhook_enabled=True 的规则触发的告警
|
|
||||||
- 失败静默, 不阻断主流程
|
|
||||||
- 去重: 复用 MonitorRuleEngine 的 cooldown, 此处不重复去重
|
|
||||||
|
|
||||||
注意: 用 rule_events (含 rule_id) 而非重建后的 all_alerts,
|
|
||||||
以便反查引擎规则判断是否启用推送。
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from app.services import preferences
|
|
||||||
from app.services import webhook_adapter
|
|
||||||
|
|
||||||
url = preferences.get_feishu_webhook_url()
|
|
||||||
if not url:
|
|
||||||
return
|
|
||||||
secret = preferences.get_feishu_webhook_secret()
|
|
||||||
|
|
||||||
# 反查规则, 过滤出启用推送的事件
|
|
||||||
source_labels = {
|
|
||||||
"strategy": "策略", "signal": "信号",
|
|
||||||
"price": "价格", "market": "异动",
|
|
||||||
}
|
|
||||||
rules = engine.rules if engine is not None else {}
|
|
||||||
pushed = 0
|
|
||||||
for ev in rule_events:
|
|
||||||
rule = rules.get(ev.get("rule_id"))
|
|
||||||
if not rule or not rule.get("webhook_enabled"):
|
|
||||||
continue
|
|
||||||
source = ev.get("source", "")
|
|
||||||
source_label = source_labels.get(source, source or "通知")
|
|
||||||
symbol = ev.get("symbol") or ""
|
|
||||||
name = ev.get("name") or ""
|
|
||||||
message = ev.get("message") or ""
|
|
||||||
title = f"TickFlow · {source_label}"
|
|
||||||
body = f"{symbol} {name} {message}".strip() if symbol else (message or name)
|
|
||||||
if webhook_adapter.send_feishu(url, title, body, secret):
|
|
||||||
pushed += 1
|
|
||||||
if pushed:
|
|
||||||
logger.info("飞书 Webhook 推送: %d 条", pushed)
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
logger.debug("Webhook 推送异常 (不影响告警主流程): %s", e)
|
|
||||||
|
|
||||||
def _maybe_send_system_notifications(self, all_alerts: list[dict]) -> None:
|
|
||||||
"""把告警转发到操作系统通知中心 (由 preferences 开关控制)。
|
|
||||||
|
|
||||||
- 开关关闭: 直接返回
|
|
||||||
- 开关开启: 逐条发系统通知; 失败静默, 不阻断主流程
|
|
||||||
- 去重: 复用 MonitorRuleEngine 的 cooldown, 此处不重复去重
|
|
||||||
- 批量策略事件 (symbol="") 聚合为一条通知, 避免刷屏
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from app.services import preferences
|
|
||||||
from app.services import notify_adapter
|
|
||||||
|
|
||||||
if not preferences.get_system_notify_enabled():
|
|
||||||
return
|
|
||||||
|
|
||||||
for ev in all_alerts:
|
|
||||||
# 通知标题: 用 source 分类 (策略/信号/价格/异动)
|
|
||||||
source = ev.get("source", "")
|
|
||||||
source_label = {
|
|
||||||
"strategy": "策略", "signal": "信号",
|
|
||||||
"price": "价格", "market": "异动",
|
|
||||||
}.get(source, source or "通知")
|
|
||||||
|
|
||||||
name = ev.get("name") or ""
|
|
||||||
symbol = ev.get("symbol") or ""
|
|
||||||
message = ev.get("message") or ""
|
|
||||||
|
|
||||||
# 正文: 优先用现成 message, 拼上 symbol/name 让用户一眼定位
|
|
||||||
if symbol:
|
|
||||||
body = f"{symbol} {name} {message}".strip()
|
|
||||||
else:
|
|
||||||
body = message or name
|
|
||||||
|
|
||||||
title = f"TickFlow · {source_label}"
|
|
||||||
notify_adapter.notify(title, body)
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
logger.debug("系统通知发送异常 (不影响告警主流程): %s", e)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_strategy_monitor():
|
|
||||||
"""获取 StrategyMonitorService — 不再使用, 改用 _app_state 注入。"""
|
|
||||||
return None
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# enriched 增量计算
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
def _flush_live_enriched(self, daily_df: pl.DataFrame, quote_extra: pl.DataFrame = None, asset_type: str = "stock", merge: bool = False) -> None:
|
|
||||||
"""增量计算今天的 enriched: 用昨天的递推状态 + 今天 OHLCV → 只算今天 5500 行。
|
|
||||||
|
|
||||||
quote_extra: API 直接提供的补充字段 (prev_close, change_pct 等),
|
|
||||||
不写 daily, 直接传给 compute_enriched_today 避免重复计算。
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
today = date.today()
|
|
||||||
t0 = time.perf_counter()
|
|
||||||
|
|
||||||
# ---- 尝试增量路径 ----
|
|
||||||
live_agg = self._repo.get_live_agg() if asset_type == "stock" else pl.DataFrame()
|
|
||||||
prev_enriched, prev_date = (
|
|
||||||
self._repo.get_enriched_latest()
|
|
||||||
if asset_type == "stock"
|
|
||||||
else self._repo.get_enriched_latest_asset(asset_type)
|
|
||||||
)
|
|
||||||
|
|
||||||
use_incremental = (
|
|
||||||
asset_type == "stock"
|
|
||||||
and not live_agg.is_empty()
|
|
||||||
and not prev_enriched.is_empty()
|
|
||||||
and prev_date is not None
|
|
||||||
)
|
|
||||||
|
|
||||||
if use_incremental:
|
|
||||||
from app.indicators.pipeline import compute_enriched_today
|
|
||||||
instruments = self._repo.get_instruments()
|
|
||||||
# 将 API 直接提供的补充字段 JOIN 到 daily_df
|
|
||||||
today_ohlcv = daily_df
|
|
||||||
if quote_extra is not None and not quote_extra.is_empty():
|
|
||||||
today_ohlcv = daily_df.join(quote_extra, on="symbol", how="left")
|
|
||||||
enriched_today = compute_enriched_today(
|
|
||||||
live_agg=live_agg,
|
|
||||||
prev_enriched=prev_enriched,
|
|
||||||
today_ohlcv=today_ohlcv,
|
|
||||||
instruments=instruments,
|
|
||||||
)
|
|
||||||
if enriched_today.is_empty():
|
|
||||||
logger.warning("增量计算结果为空, 回退到全量计算")
|
|
||||||
use_incremental = False
|
|
||||||
|
|
||||||
# ---- 全量回退路径 ----
|
|
||||||
if not use_incremental:
|
|
||||||
from datetime import timedelta
|
|
||||||
from app.indicators.pipeline import compute_enriched
|
|
||||||
|
|
||||||
logger.info("enriched 全量计算 (live_agg=%s, 上次日期=%s)",
|
|
||||||
"ok" if not live_agg.is_empty() else "空", prev_date)
|
|
||||||
|
|
||||||
cutoff = today - timedelta(days=90)
|
|
||||||
table = "kline_etf_daily" if asset_type == "etf" else "kline_daily"
|
|
||||||
daily_glob = str(self._repo.store.data_dir / table / "**" / "*.parquet")
|
|
||||||
ohlcv_cols = ["symbol", "date", "open", "high", "low", "close", "volume", "amount"]
|
|
||||||
hist_df = (
|
|
||||||
pl.scan_parquet(daily_glob)
|
|
||||||
.filter(pl.col("date") >= cutoff)
|
|
||||||
.sort(["symbol", "date"])
|
|
||||||
.collect()
|
|
||||||
)
|
|
||||||
if hist_df.is_empty():
|
|
||||||
return
|
|
||||||
|
|
||||||
hist_cols = [c for c in ohlcv_cols if c in hist_df.columns]
|
|
||||||
hist_df = hist_df.select(hist_cols).filter(pl.col("date") != today)
|
|
||||||
daily_ohlcv = daily_df.select([c for c in ohlcv_cols if c in daily_df.columns])
|
|
||||||
full_df = pl.concat([hist_df, daily_ohlcv], how="diagonal_relaxed")
|
|
||||||
full_df = full_df.sort(["symbol", "date"])
|
|
||||||
|
|
||||||
factor_dir = "adj_factor_etf" if asset_type == "etf" else "adj_factor"
|
|
||||||
factor_path = self._repo.store.data_dir / factor_dir / "all.parquet"
|
|
||||||
factors = pl.DataFrame()
|
|
||||||
if factor_path.exists():
|
|
||||||
try:
|
|
||||||
factors = pl.read_parquet(factor_path)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
instruments = self._repo.get_instruments() if asset_type == "stock" else None
|
|
||||||
|
|
||||||
enriched_full = compute_enriched(full_df, factors=factors, instruments=instruments)
|
|
||||||
enriched_today = enriched_full.filter(pl.col("date") == today)
|
|
||||||
|
|
||||||
if enriched_today.is_empty():
|
|
||||||
return
|
|
||||||
|
|
||||||
# ---- 写盘 + 更新缓存 ----
|
|
||||||
if merge:
|
|
||||||
self._repo.merge_live_enriched_asset(asset_type, enriched_today)
|
|
||||||
else:
|
|
||||||
self._repo.flush_live_enriched_asset(asset_type, enriched_today)
|
|
||||||
|
|
||||||
elapsed = time.perf_counter() - t0
|
|
||||||
mode_label = "增量" if use_incremental else "全量"
|
|
||||||
logger.info("enriched %s: %d 只, %s, 耗时 %.0fms",
|
|
||||||
mode_label, len(enriched_today), today, elapsed * 1000)
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
logger.warning("enriched 计算失败: %s", e)
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
"""自选股服务(§6.1)。
|
|
||||||
|
|
||||||
存储:`data/user_data/watchlist.parquet`,字段 symbol + added_at + note。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import polars as pl
|
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
from app.tickflow.capabilities import Cap, CapabilitySet
|
|
||||||
from app.tickflow.client import get_client
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _path() -> Path:
|
|
||||||
p = settings.data_dir / "user_data" / "watchlist.parquet"
|
|
||||||
p.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
return p
|
|
||||||
|
|
||||||
|
|
||||||
def list_symbols() -> list[dict]:
|
|
||||||
p = _path()
|
|
||||||
if not p.exists():
|
|
||||||
return []
|
|
||||||
df = pl.read_parquet(p)
|
|
||||||
if df.is_empty():
|
|
||||||
return []
|
|
||||||
return df.to_dicts()
|
|
||||||
|
|
||||||
|
|
||||||
def add(symbol: str, note: str = "") -> list[dict]:
|
|
||||||
p = _path()
|
|
||||||
if p.exists():
|
|
||||||
df = pl.read_parquet(p)
|
|
||||||
# 已存在则先移除,后面重新插入到最前面
|
|
||||||
if symbol in df["symbol"].to_list():
|
|
||||||
df = df.filter(pl.col("symbol") != symbol)
|
|
||||||
else:
|
|
||||||
df = pl.DataFrame(schema={"symbol": pl.Utf8, "added_at": pl.Utf8, "note": pl.Utf8})
|
|
||||||
|
|
||||||
new_row = pl.DataFrame({
|
|
||||||
"symbol": [symbol],
|
|
||||||
"added_at": [datetime.utcnow().isoformat(timespec="seconds")],
|
|
||||||
"note": [note],
|
|
||||||
})
|
|
||||||
out = pl.concat([new_row, df], how="diagonal_relaxed")
|
|
||||||
out.write_parquet(p)
|
|
||||||
return out.to_dicts()
|
|
||||||
|
|
||||||
|
|
||||||
def remove(symbol: str) -> list[dict]:
|
|
||||||
p = _path()
|
|
||||||
if not p.exists():
|
|
||||||
return []
|
|
||||||
df = pl.read_parquet(p)
|
|
||||||
df = df.filter(pl.col("symbol") != symbol)
|
|
||||||
df.write_parquet(p)
|
|
||||||
return df.to_dicts()
|
|
||||||
|
|
||||||
|
|
||||||
def move_to_top(symbol: str) -> list[dict]:
|
|
||||||
p = _path()
|
|
||||||
if not p.exists():
|
|
||||||
return []
|
|
||||||
df = pl.read_parquet(p)
|
|
||||||
if df.is_empty() or symbol not in df["symbol"].to_list():
|
|
||||||
return df.to_dicts()
|
|
||||||
target = df.filter(pl.col("symbol") == symbol)
|
|
||||||
rest = df.filter(pl.col("symbol") != symbol)
|
|
||||||
out = pl.concat([target, rest], how="diagonal_relaxed")
|
|
||||||
out.write_parquet(p)
|
|
||||||
return out.to_dicts()
|
|
||||||
|
|
||||||
|
|
||||||
def clear() -> int:
|
|
||||||
"""清空自选列表。返回移除的数量。"""
|
|
||||||
p = _path()
|
|
||||||
if not p.exists():
|
|
||||||
return 0
|
|
||||||
df = pl.read_parquet(p)
|
|
||||||
count = df.height
|
|
||||||
if count > 0:
|
|
||||||
pl.DataFrame(schema={"symbol": pl.Utf8, "added_at": pl.Utf8, "note": pl.Utf8}).write_parquet(p)
|
|
||||||
return count
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_quotes(symbols: list[str], capset: CapabilitySet, timeout_s: float = 8.0) -> list[dict]:
|
|
||||||
"""拉取实时行情。
|
|
||||||
|
|
||||||
优先用 quote.batch;否则降级为 quote.by_symbol 单股请求。
|
|
||||||
timeout_s: 单批次请求超时(秒),防止 API 卡死阻塞整个请求。
|
|
||||||
"""
|
|
||||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
|
|
||||||
|
|
||||||
if not symbols:
|
|
||||||
return []
|
|
||||||
|
|
||||||
tf = get_client()
|
|
||||||
quotes: list[dict] = []
|
|
||||||
|
|
||||||
# 走 batch
|
|
||||||
batch_size = 5
|
|
||||||
if capset.has(Cap.QUOTE_BATCH):
|
|
||||||
lim = capset.limits(Cap.QUOTE_BATCH)
|
|
||||||
batch_size = lim.batch if lim and lim.batch else 50
|
|
||||||
elif capset.has(Cap.QUOTE_BY_SYMBOL):
|
|
||||||
lim = capset.limits(Cap.QUOTE_BY_SYMBOL)
|
|
||||||
batch_size = lim.batch if lim and lim.batch else 5
|
|
||||||
else:
|
|
||||||
# 无任何实时行情能力(none/free 档走 free-api 服务器,不提供实时行情)
|
|
||||||
# 提前返回空,避免发起注定失败的请求
|
|
||||||
return []
|
|
||||||
|
|
||||||
chunks = [symbols[i:i + batch_size] for i in range(0, len(symbols), batch_size)]
|
|
||||||
|
|
||||||
# 用线程池为每个批次加超时保护
|
|
||||||
pool = ThreadPoolExecutor(max_workers=1)
|
|
||||||
for chunk in chunks:
|
|
||||||
try:
|
|
||||||
future = pool.submit(tf.quotes.get, symbols=chunk, as_dataframe=True)
|
|
||||||
raw = future.result(timeout=timeout_s)
|
|
||||||
if raw is None or len(raw) == 0:
|
|
||||||
continue
|
|
||||||
df = pl.from_pandas(raw)
|
|
||||||
rename_map = {
|
|
||||||
"last_price": "price",
|
|
||||||
"ext.change_pct": "pct",
|
|
||||||
"ext.name": "name",
|
|
||||||
}
|
|
||||||
df = df.rename({k: v for k, v in rename_map.items() if k in df.columns})
|
|
||||||
quotes.extend(df.to_dicts())
|
|
||||||
except FuturesTimeout:
|
|
||||||
logger.warning("quote fetch timeout (%.1fs) for %d symbols", timeout_s, len(chunk))
|
|
||||||
break # 超时后不再尝试后续批次
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
logger.warning("quote fetch failed for %d symbols: %s", len(chunk), e)
|
|
||||||
pool.shutdown(wait=False)
|
|
||||||
|
|
||||||
return quotes
|
|
||||||
@@ -1,857 +0,0 @@
|
|||||||
"""策略实时监控 — 订阅行情更新,检查策略买卖信号和提醒条件。
|
|
||||||
|
|
||||||
职责: 接收实时行情 DataFrame → 检查监控中策略的信号/提醒 → 推送告警。
|
|
||||||
不知道: 策略加载逻辑、AI、API、配置持久化、回测。
|
|
||||||
依赖: 外部调用 on_quote_update() 传入实时数据。
|
|
||||||
|
|
||||||
本模块含两个评估器:
|
|
||||||
1. StrategyMonitorService — 旧的策略监控 (type=strategy),第二步迁移到 MonitorRuleEngine
|
|
||||||
2. MonitorRuleEngine — 通用规则引擎,覆盖 signal/price/market/strategy 四类,
|
|
||||||
支持 scope (symbols/all/sector) + 多条件 AND/OR + cooldown 去重
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import datetime as _dt
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any, Callable
|
|
||||||
|
|
||||||
import polars as pl
|
|
||||||
|
|
||||||
from app.strategy.custom_signals import _OP_BUILDERS # type: ignore # 复用运算符构造器
|
|
||||||
from app.strategy import config as _strategy_config
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# 信号 / 字段中文名映射 — 与前端 lib/signals.ts 对齐, 用于告警 message / 推送文案。
|
|
||||||
# signal_* 为内置原子信号, 其余为技术指标/行情字段。
|
|
||||||
_SIGNAL_CN: dict[str, str] = {
|
|
||||||
# 内置信号
|
|
||||||
"signal_ma_golden_5_20": "MA5上穿MA20", "signal_ma_dead_5_20": "MA5下穿MA20",
|
|
||||||
"signal_ma_golden_20_60": "MA20上穿MA60", "signal_macd_golden": "MACD金叉",
|
|
||||||
"signal_macd_dead": "MACD死叉", "signal_ma20_breakout": "突破MA20",
|
|
||||||
"signal_ma20_breakdown": "跌破MA20", "signal_n_day_high": "60日新高",
|
|
||||||
"signal_n_day_low": "60日新低", "signal_boll_breakout_upper": "突破布林上轨",
|
|
||||||
"signal_boll_breakdown_lower": "跌破布林下轨", "signal_volume_surge": "放量",
|
|
||||||
"signal_limit_up": "涨停", "signal_limit_down": "跌停",
|
|
||||||
"signal_limit_down_recovery": "跌停翘板", "signal_broken_limit_up": "炸板",
|
|
||||||
# 行情字段
|
|
||||||
"close": "收盘价", "open": "开盘价", "high": "最高价", "low": "最低价",
|
|
||||||
"change_pct": "涨跌幅", "change_amount": "涨跌额", "amplitude": "振幅",
|
|
||||||
"turnover_rate": "换手率", "volume": "成交量", "amount": "成交额",
|
|
||||||
# 均线
|
|
||||||
"ma5": "MA5", "ma10": "MA10", "ma20": "MA20", "ma30": "MA30", "ma60": "MA60",
|
|
||||||
"ema5": "EMA5", "ema10": "EMA10", "ema20": "EMA20",
|
|
||||||
# MACD / BOLL / KDJ / RSI
|
|
||||||
"macd_dif": "MACD-DIF", "macd_dea": "MACD-DEA", "macd_hist": "MACD柱",
|
|
||||||
"boll_upper": "布林上轨", "boll_lower": "布林下轨",
|
|
||||||
"kdj_k": "KDJ-K", "kdj_d": "KDJ-D", "kdj_j": "KDJ-J",
|
|
||||||
"rsi_6": "RSI6", "rsi_14": "RSI14", "rsi_24": "RSI24",
|
|
||||||
# 量能 / 动量 / 波动
|
|
||||||
"vol_ratio_5d": "5日量比", "vol_ratio_20d": "20日量比",
|
|
||||||
"vol_ma5": "5日均量", "vol_ma10": "10日均量",
|
|
||||||
"high_60d": "60日最高", "low_60d": "60日最低",
|
|
||||||
"momentum_5d": "5日动量", "momentum_20d": "20日动量", "momentum_60d": "60日动量",
|
|
||||||
"atr_14": "ATR14", "annual_vol_20d": "20日年化波动",
|
|
||||||
"consecutive_limit_ups": "连板数", "consecutive_limit_downs": "跌停连板",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _signal_cn_name(name: str) -> str:
|
|
||||||
"""返回信号/字段的中文名, 找不到原样返回 (与前端 cnSignal 对齐)。"""
|
|
||||||
return _SIGNAL_CN.get(name, name)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class StrategyAlert:
|
|
||||||
"""策略告警"""
|
|
||||||
type: str # "entry" | "exit" | "alert"
|
|
||||||
strategy_id: str
|
|
||||||
symbol: str
|
|
||||||
name: str | None
|
|
||||||
message: str
|
|
||||||
price: float | None = None
|
|
||||||
change_pct: float | None = None
|
|
||||||
signals: list[str] = field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class StrategyMonitorService:
|
|
||||||
"""策略实时监控服务"""
|
|
||||||
|
|
||||||
def __init__(self, alert_handler: Callable[[StrategyAlert], None] | None = None):
|
|
||||||
"""
|
|
||||||
Args:
|
|
||||||
alert_handler: 告警回调 (如推 SSE)
|
|
||||||
"""
|
|
||||||
self._alert_handler = alert_handler
|
|
||||||
# strategy_id → 监控配置
|
|
||||||
self._watching: dict[str, dict] = {}
|
|
||||||
|
|
||||||
def start(self, strategy_id: str, config: dict) -> None:
|
|
||||||
"""开始监控一个策略
|
|
||||||
|
|
||||||
config: {
|
|
||||||
"entry_signals": ["signal_n_day_high", ...],
|
|
||||||
"exit_signals": ["signal_ma20_breakdown", ...],
|
|
||||||
"alerts": [{"field": "rsi_14", "op": ">", "value": 80, "message": "..."}],
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
self._watching[strategy_id] = config
|
|
||||||
logger.info("strategy monitor started: %s", strategy_id)
|
|
||||||
|
|
||||||
def stop(self, strategy_id: str) -> None:
|
|
||||||
self._watching.pop(strategy_id, None)
|
|
||||||
logger.info("strategy monitor stopped: %s", strategy_id)
|
|
||||||
|
|
||||||
def stop_all(self) -> None:
|
|
||||||
self._watching.clear()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def watching(self) -> dict[str, dict]:
|
|
||||||
return dict(self._watching)
|
|
||||||
|
|
||||||
def on_quote_update(self, df: pl.DataFrame) -> list[StrategyAlert]:
|
|
||||||
"""行情更新后调用。向量化检查所有监控策略。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
df: 实时 enriched 数据 (~5500行)
|
|
||||||
Returns:
|
|
||||||
触发的告警列表
|
|
||||||
"""
|
|
||||||
if not self._watching or df.is_empty():
|
|
||||||
return []
|
|
||||||
|
|
||||||
all_alerts: list[StrategyAlert] = []
|
|
||||||
|
|
||||||
for strategy_id, cfg in self._watching.items():
|
|
||||||
# 买入信号
|
|
||||||
entry_sigs = cfg.get("entry_signals", [])
|
|
||||||
if entry_sigs:
|
|
||||||
for sym, name, price, pct, hit_sigs in self._check_signals(df, entry_sigs):
|
|
||||||
alert = StrategyAlert(
|
|
||||||
type="entry",
|
|
||||||
strategy_id=strategy_id,
|
|
||||||
symbol=sym,
|
|
||||||
name=name,
|
|
||||||
message=f"买入信号触发",
|
|
||||||
price=price,
|
|
||||||
change_pct=pct,
|
|
||||||
signals=hit_sigs,
|
|
||||||
)
|
|
||||||
all_alerts.append(alert)
|
|
||||||
self._emit(alert)
|
|
||||||
|
|
||||||
# 卖出信号
|
|
||||||
exit_sigs = cfg.get("exit_signals", [])
|
|
||||||
if exit_sigs:
|
|
||||||
for sym, name, price, pct, hit_sigs in self._check_signals(df, exit_sigs):
|
|
||||||
alert = StrategyAlert(
|
|
||||||
type="exit",
|
|
||||||
strategy_id=strategy_id,
|
|
||||||
symbol=sym,
|
|
||||||
name=name,
|
|
||||||
message=f"卖出信号触发",
|
|
||||||
price=price,
|
|
||||||
change_pct=pct,
|
|
||||||
signals=hit_sigs,
|
|
||||||
)
|
|
||||||
all_alerts.append(alert)
|
|
||||||
self._emit(alert)
|
|
||||||
|
|
||||||
# 提醒条件
|
|
||||||
for alert_cfg in cfg.get("alerts", []):
|
|
||||||
for sym, name, price, pct in self._check_alert(df, alert_cfg):
|
|
||||||
alert = StrategyAlert(
|
|
||||||
type="alert",
|
|
||||||
strategy_id=strategy_id,
|
|
||||||
symbol=sym,
|
|
||||||
name=name,
|
|
||||||
message=alert_cfg.get("message", "提醒"),
|
|
||||||
price=price,
|
|
||||||
change_pct=pct,
|
|
||||||
)
|
|
||||||
all_alerts.append(alert)
|
|
||||||
self._emit(alert)
|
|
||||||
|
|
||||||
return all_alerts
|
|
||||||
|
|
||||||
def _emit(self, alert: StrategyAlert) -> None:
|
|
||||||
if self._alert_handler:
|
|
||||||
try:
|
|
||||||
self._alert_handler(alert)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("alert handler failed: %s", e)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _check_signals(
|
|
||||||
df: pl.DataFrame,
|
|
||||||
signals: list[str],
|
|
||||||
) -> list[tuple[str, str | None, float | None, float | None, list[str]]]:
|
|
||||||
"""检查信号列,返回 [(symbol, name, price, change_pct, [hit_signals])]。
|
|
||||||
支持内置 signal_ 与自定义 csg_ 前缀。"""
|
|
||||||
cols = set(df.columns)
|
|
||||||
resolved: list[tuple[str, str]] = [] # (原值, 列名)
|
|
||||||
for s in signals:
|
|
||||||
col = s if (s.startswith("signal_") or s.startswith("csg_")) else f"signal_{s}"
|
|
||||||
if col in cols:
|
|
||||||
resolved.append((s, col))
|
|
||||||
if not resolved:
|
|
||||||
return []
|
|
||||||
|
|
||||||
mask = pl.any_horizontal(pl.col(c).fill_null(False) for _, c in resolved)
|
|
||||||
hit_df = df.filter(mask)
|
|
||||||
|
|
||||||
results = []
|
|
||||||
for row in hit_df.iter_rows(named=True):
|
|
||||||
sym = row.get("symbol", "")
|
|
||||||
name = row.get("name")
|
|
||||||
price = row.get("close")
|
|
||||||
pct = row.get("change_pct")
|
|
||||||
hit_sigs = [orig for orig, col in resolved if row.get(col)]
|
|
||||||
results.append((sym, name, price, pct, hit_sigs))
|
|
||||||
return results
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _check_alert(
|
|
||||||
df: pl.DataFrame,
|
|
||||||
alert: dict,
|
|
||||||
) -> list[tuple[str, str | None, float | None, float | None]]:
|
|
||||||
"""检查阈值型提醒条件"""
|
|
||||||
field = alert.get("field", "")
|
|
||||||
if field not in df.columns:
|
|
||||||
return []
|
|
||||||
|
|
||||||
if "op" in alert:
|
|
||||||
# 阈值比较
|
|
||||||
op = alert["op"]
|
|
||||||
value = alert["value"]
|
|
||||||
col = pl.col(field)
|
|
||||||
ops = {
|
|
||||||
">": col > value,
|
|
||||||
">=": col >= value,
|
|
||||||
"<": col < value,
|
|
||||||
"<=": col <= value,
|
|
||||||
}
|
|
||||||
expr = ops.get(op)
|
|
||||||
if expr is None:
|
|
||||||
return []
|
|
||||||
else:
|
|
||||||
# 信号列 (布尔)
|
|
||||||
expr = pl.col(field).fill_null(False)
|
|
||||||
|
|
||||||
hit_df = df.filter(expr)
|
|
||||||
results = []
|
|
||||||
for row in hit_df.iter_rows(named=True):
|
|
||||||
results.append((
|
|
||||||
row.get("symbol", ""),
|
|
||||||
row.get("name"),
|
|
||||||
row.get("close"),
|
|
||||||
row.get("change_pct"),
|
|
||||||
))
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# 通用监控规则引擎 MonitorRuleEngine
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
_SIGNAL_PREFIXES = ("signal_", "csg_")
|
|
||||||
|
|
||||||
|
|
||||||
def _is_signal_field(field: str) -> bool:
|
|
||||||
return any(field.startswith(p) for p in _SIGNAL_PREFIXES)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_condition_mask(df: pl.DataFrame, conditions: list[dict], logic: str) -> pl.DataFrame:
|
|
||||||
"""根据 conditions + logic 构建过滤后的命中 DataFrame。
|
|
||||||
|
|
||||||
conditions: [{"field","op","value"?}] — op=truth 为布尔信号, 否则阈值比较
|
|
||||||
logic: "and" | "or"
|
|
||||||
返回命中行 (含 symbol/name/close/change_pct + 各信号列)
|
|
||||||
"""
|
|
||||||
cols = set(df.columns)
|
|
||||||
parts: list[pl.Expr] = []
|
|
||||||
for c in conditions:
|
|
||||||
field = c["field"]
|
|
||||||
if field not in cols:
|
|
||||||
return df.head(0) # 字段缺失,无法判定 → 空结果
|
|
||||||
op = c["op"]
|
|
||||||
if op == "truth":
|
|
||||||
parts.append(pl.col(field).fill_null(False))
|
|
||||||
elif op in _OP_BUILDERS:
|
|
||||||
parts.append(_OP_BUILDERS[op](pl.col(field), c["value"]))
|
|
||||||
else:
|
|
||||||
return df.head(0)
|
|
||||||
if not parts:
|
|
||||||
return df.head(0)
|
|
||||||
if logic == "or":
|
|
||||||
mask = pl.any_horizontal(parts)
|
|
||||||
else:
|
|
||||||
mask = pl.all_horizontal(parts)
|
|
||||||
return df.filter(mask)
|
|
||||||
|
|
||||||
|
|
||||||
class MonitorRuleEngine:
|
|
||||||
"""通用监控规则引擎 — 接收实时行情 DataFrame,评估所有规则,返回 AlertEvent。
|
|
||||||
|
|
||||||
与 StrategyMonitorService 的区别:
|
|
||||||
- 规则来自 monitor_rules 存储 (用户可配), 而非写死的 strategy config
|
|
||||||
- 支持 scope (symbols/all/sector) 过滤作用域
|
|
||||||
- 支持 conditions + logic (AND/OR) 任意组合
|
|
||||||
- ★ cooldown 去重: 同一 (rule_id, symbol) 在冷却期内不重复触发
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, alert_handler: Callable[[dict], None] | None = None):
|
|
||||||
self._alert_handler = alert_handler
|
|
||||||
self._rules: dict[str, dict] = {} # rule_id → rule
|
|
||||||
# (rule_id, symbol) → 上次触发时间戳(秒)。用于 cooldown 去重。
|
|
||||||
self._last_fire: dict[tuple[str, str], float] = {}
|
|
||||||
self._strategy_engine = None # 延迟注入, type=strategy 规则用它跑选股
|
|
||||||
# symbol → 股票名 (enriched DataFrame 已 drop name 列, 触发时从此映射回填)
|
|
||||||
self._name_map: dict[str, str] = {}
|
|
||||||
# 策略选股池状态: strategy_id → 上期选股符号集合 (用于 diff 变更)
|
|
||||||
self._strategy_pools: dict[str, set[str]] = {}
|
|
||||||
# 数据目录 (用于加载策略 overrides)
|
|
||||||
self._data_dir = None
|
|
||||||
# 历史窗口加载器: (target_date, lookback_days) → 多日 enriched DataFrame。
|
|
||||||
# 用于声明 filter_history 的策略 (如反包), 实时监控时拼历史窗口 + 今日行情跑选股。
|
|
||||||
# 为 None 时, filter_history 策略仍会被跳过 (保持旧行为, 不破坏无历史场景)。
|
|
||||||
self._history_loader: Callable[[_dt.date, int], "pl.DataFrame"] | None = None
|
|
||||||
# 本轮 evaluate() 产出的策略选股结果: strategy_id → {rows, total, as_of}
|
|
||||||
# 供策略页实时回显复用 (/api/screener/cached 端点直接读取此内存结果), 避免重跑
|
|
||||||
self._latest_strategy_results: dict[str, dict] = {}
|
|
||||||
|
|
||||||
def set_strategy_engine(self, engine) -> None:
|
|
||||||
"""注入 StrategyEngine, type=strategy 规则据此跑选股。"""
|
|
||||||
self._strategy_engine = engine
|
|
||||||
|
|
||||||
def set_data_dir(self, data_dir) -> None:
|
|
||||||
"""注入数据目录, 用于加载策略的用户覆盖配置。"""
|
|
||||||
self._data_dir = data_dir
|
|
||||||
|
|
||||||
def set_history_loader(self, fn) -> None:
|
|
||||||
"""注入历史窗口加载器, 用于声明 filter_history 的策略跑实时监控。
|
|
||||||
|
|
||||||
loader 签名: (target_date, lookback_days) → 多日 enriched DataFrame。
|
|
||||||
复用 ScreenerService._load_enriched_history (三级缓存, 命中 ~0ms)。
|
|
||||||
为 None 时 filter_history 策略退回到跳过逻辑 (不破坏无历史场景)。
|
|
||||||
"""
|
|
||||||
self._history_loader = fn
|
|
||||||
|
|
||||||
def set_name_map(self, name_map: dict[str, str]) -> None:
|
|
||||||
"""注入 symbol → 股票名 映射, 用于在告警事件里回填 name 字段。
|
|
||||||
|
|
||||||
enriched DataFrame 在 pipeline 计算后不含 name 列 (见 indicators/pipeline.py),
|
|
||||||
触发时从 instruments 表预构建此映射, 保证 AlertEvent.name 有值。
|
|
||||||
"""
|
|
||||||
self._name_map = name_map or {}
|
|
||||||
|
|
||||||
# ── 规则管理 ───────────────────────────────────────
|
|
||||||
def set_rules(self, rules: list[dict]) -> None:
|
|
||||||
"""批量设置规则 (覆盖)。用于启动时 reload。"""
|
|
||||||
self._rules = {}
|
|
||||||
for r in rules:
|
|
||||||
if r.get("enabled") is not False:
|
|
||||||
self._rules[r["id"]] = r
|
|
||||||
logger.info("MonitorRuleEngine: 装载 %d 条规则", len(self._rules))
|
|
||||||
|
|
||||||
def add_rule(self, rule: dict) -> None:
|
|
||||||
if rule.get("enabled") is not False:
|
|
||||||
self._rules[rule["id"]] = rule
|
|
||||||
else:
|
|
||||||
self._rules.pop(rule["id"], None)
|
|
||||||
|
|
||||||
def remove_rule(self, rule_id: str) -> None:
|
|
||||||
self._rules.pop(rule_id, None)
|
|
||||||
# 清理对应的 cooldown 记录
|
|
||||||
self._last_fire = {k: v for k, v in self._last_fire.items() if k[0] != rule_id}
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
self._rules.clear()
|
|
||||||
self._last_fire.clear()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def rules(self) -> dict[str, dict]:
|
|
||||||
return dict(self._rules)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def rule_count(self) -> int:
|
|
||||||
return len(self._rules)
|
|
||||||
|
|
||||||
def latest_strategy_results(self) -> dict[str, dict]:
|
|
||||||
"""返回本轮 evaluate() 产出的策略选股结果 (strategy_id → {rows, total, as_of})。
|
|
||||||
|
|
||||||
供策略页实时回显复用: /api/screener/cached 端点直接读取此内存结果,
|
|
||||||
避免对被监控的策略重跑第二遍。无 type=strategy 规则时返回空 dict。
|
|
||||||
"""
|
|
||||||
return self._latest_strategy_results
|
|
||||||
|
|
||||||
def has_rule_type(self, rtype: str) -> bool:
|
|
||||||
"""是否存在指定类型的 (已启用) 规则。供 quote_service 判断是否需要注入特殊数据。"""
|
|
||||||
if not self._rules:
|
|
||||||
return False
|
|
||||||
return any(
|
|
||||||
r.get("enabled", True) and r.get("type") == rtype
|
|
||||||
for r in self._rules.values()
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── 评估 ───────────────────────────────────────────
|
|
||||||
def evaluate(self, df: pl.DataFrame) -> list[dict]:
|
|
||||||
"""行情更新后评估所有规则。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
df: 实时 enriched 数据 (~5500行, 含 signal_/csg_/指标列)
|
|
||||||
Returns:
|
|
||||||
触发的 AlertEvent dict 列表 (含 ts/rule_id/source/type/symbol/...)
|
|
||||||
"""
|
|
||||||
if not self._rules or df.is_empty():
|
|
||||||
return []
|
|
||||||
|
|
||||||
now = time.time()
|
|
||||||
events: list[dict] = []
|
|
||||||
# 每轮重置: 只保留本次 evaluate 产出的策略结果
|
|
||||||
self._latest_strategy_results = {}
|
|
||||||
|
|
||||||
for rule_id, rule in self._rules.items():
|
|
||||||
try:
|
|
||||||
events.extend(self._evaluate_rule(df, rule, now))
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("规则评估失败 %s: %s", rule_id, e)
|
|
||||||
|
|
||||||
return events
|
|
||||||
|
|
||||||
def _evaluate_rule(self, df: pl.DataFrame, rule: dict, now: float) -> list[dict]:
|
|
||||||
"""评估单条规则,返回触发的 events。"""
|
|
||||||
# 1. 按 scope 过滤作用域
|
|
||||||
scoped = self._apply_scope(df, rule)
|
|
||||||
if scoped.is_empty():
|
|
||||||
return []
|
|
||||||
|
|
||||||
# 2. 根据 type 构建命中集
|
|
||||||
# 元组格式: (event_type, symbol, name, price, pct, signals)
|
|
||||||
hit_rows: list[tuple[str, str, Any, Any, Any, list[str]]] = []
|
|
||||||
|
|
||||||
rtype = rule.get("type", "signal")
|
|
||||||
if rtype == "strategy":
|
|
||||||
# 策略类型: 跑策略选股 → 对比上期选股池 → 产出 new_entry/dropped 事件
|
|
||||||
hit_rows = self._match_strategy(scoped, rule)
|
|
||||||
elif rtype == "ladder":
|
|
||||||
# 连板梯队封单监控: 独立处理 (需带预警封单值, 走专属 message)
|
|
||||||
return self._evaluate_ladder(scoped, rule, now)
|
|
||||||
else:
|
|
||||||
# signal / price / market: 通用条件匹配
|
|
||||||
for sym, name, price, pct, hit_sigs in self._match_conditions(scoped, rule):
|
|
||||||
hit_rows.append((rtype, sym, name, price, pct, hit_sigs))
|
|
||||||
|
|
||||||
if not hit_rows:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# 3. cooldown 去重 + 生成 events
|
|
||||||
cooldown = rule.get("cooldown_seconds", 3600)
|
|
||||||
severity = rule.get("severity", "info")
|
|
||||||
source = rtype
|
|
||||||
|
|
||||||
events: list[dict] = []
|
|
||||||
for ev_type, sym, name, price, pct, hit_sigs in hit_rows:
|
|
||||||
# cooldown 键: 批量事件用特殊键, 单只事件用 (rule_id, symbol)
|
|
||||||
is_batch = sym == "_batch"
|
|
||||||
if is_batch:
|
|
||||||
key = (rule["id"], f"_{ev_type}_batch")
|
|
||||||
else:
|
|
||||||
key = (rule["id"], sym)
|
|
||||||
last = self._last_fire.get(key)
|
|
||||||
if last is not None and (now - last) < cooldown:
|
|
||||||
continue # 冷却期内, 跳过
|
|
||||||
self._last_fire[key] = now
|
|
||||||
|
|
||||||
# 批量事件: name 存放预构建的消息文本
|
|
||||||
if is_batch:
|
|
||||||
resolved_name = ""
|
|
||||||
message = name # name 字段即批量消息
|
|
||||||
else:
|
|
||||||
resolved_name = name if name else self._name_map.get(sym)
|
|
||||||
message = rule.get("message", "") or self._default_message(
|
|
||||||
rule, ev_type=ev_type, sym=sym, name=resolved_name,
|
|
||||||
pct=pct, price=price,
|
|
||||||
conditions=list(rule.get("conditions", [])) if rule.get("type") != "strategy" else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
ev = {
|
|
||||||
"ts": int(now * 1000),
|
|
||||||
"rule_id": rule["id"],
|
|
||||||
"rule_name": rule.get("name", ""),
|
|
||||||
"source": source,
|
|
||||||
"type": ev_type,
|
|
||||||
"symbol": "" if is_batch else sym,
|
|
||||||
"name": resolved_name,
|
|
||||||
"message": message,
|
|
||||||
"price": price,
|
|
||||||
"change_pct": pct,
|
|
||||||
"signals": hit_sigs,
|
|
||||||
"severity": severity,
|
|
||||||
# 触发条件快照 (signal/price/market 类型): 用于触发记录展示
|
|
||||||
# 「命中了什么条件」。strategy 类型靠策略选股池 diff, 不写条件。
|
|
||||||
"conditions": list(rule.get("conditions", [])) if rtype != "strategy" else [],
|
|
||||||
"logic": rule.get("logic", "and") if rtype != "strategy" else "and",
|
|
||||||
}
|
|
||||||
events.append(ev)
|
|
||||||
if self._alert_handler:
|
|
||||||
try:
|
|
||||||
self._alert_handler(ev)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("alert handler failed: %s", e)
|
|
||||||
|
|
||||||
return events
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _apply_scope(df: pl.DataFrame, rule: dict) -> pl.DataFrame:
|
|
||||||
"""按 scope 过滤 DataFrame。"""
|
|
||||||
scope = rule.get("scope", "symbols")
|
|
||||||
if scope == "all":
|
|
||||||
return df
|
|
||||||
if scope == "symbols":
|
|
||||||
syms = rule.get("symbols", [])
|
|
||||||
if not syms:
|
|
||||||
return df.head(0)
|
|
||||||
return df.filter(pl.col("symbol").is_in(syms))
|
|
||||||
if scope == "sector":
|
|
||||||
# sector 过滤: 需 df 含板块列 (后续接入 ext_data JOIN)
|
|
||||||
# 当前先返回全量, sector 精确过滤第二步完善
|
|
||||||
return df
|
|
||||||
return df
|
|
||||||
|
|
||||||
def _match_strategy(
|
|
||||||
self, df: pl.DataFrame, rule: dict,
|
|
||||||
) -> list[tuple[str, str, Any, Any, Any, list[str]]]:
|
|
||||||
"""策略类型评估: 跑策略选股 → 对比上期选股池 → 产出变更事件。
|
|
||||||
|
|
||||||
返回 [(event_type, symbol, name, price, pct, signals)]
|
|
||||||
event_type: "new_entry" (新入选) | "dropped" (已移出)
|
|
||||||
单只变更逐只返回; 同一策略 >5 只合并为一条批量事件 (symbol="_batch")
|
|
||||||
"""
|
|
||||||
if self._strategy_engine is None:
|
|
||||||
return []
|
|
||||||
sid = rule.get("strategy_id")
|
|
||||||
if not sid:
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
s = self._strategy_engine.get(sid)
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
if s is None:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# 运行策略选股: 复用当前 enriched DataFrame 跳过数据加载
|
|
||||||
overrides = {}
|
|
||||||
if self._data_dir:
|
|
||||||
try:
|
|
||||||
overrides = _strategy_config.load_override(self._data_dir, sid)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 声明 filter_history 的策略 (如反包) 需要多日历史窗口才能判定形态。
|
|
||||||
# 旧实现因"实时监控不支持 history loader"直接跳过 → 反包等策略盘中永不触发。
|
|
||||||
# 现接入 history_loader, 拼历史窗口 + 今日实时行情, 经 precomputed_history 喂给引擎。
|
|
||||||
# loader 为 None (未装配) 时退回跳过, 保持旧行为, 不破坏无历史场景。
|
|
||||||
run_kwargs: dict = {
|
|
||||||
"as_of": _dt.date.today(),
|
|
||||||
"overrides": overrides,
|
|
||||||
}
|
|
||||||
if s.filter_history_fn:
|
|
||||||
if self._history_loader is None:
|
|
||||||
logger.debug("策略 %s 需要历史数据但未注入 history_loader, 跳过实时监控", sid)
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
today = _dt.date.today()
|
|
||||||
lookback = max(1, getattr(s, "lookback_days", 30))
|
|
||||||
hist_df = self._history_loader(today, lookback)
|
|
||||||
if hist_df is None or hist_df.is_empty():
|
|
||||||
logger.debug("策略 %s 历史数据为空, 跳过本轮实时监控", sid)
|
|
||||||
return []
|
|
||||||
# 历史窗口可能与今日已落盘数据重叠: 排掉 hist_df 中 date==today 的行,
|
|
||||||
# 今日行情始终以实时 df 为准 (盘中逐轮更新, 最接近收盘真相)。
|
|
||||||
# 否则 today 行重复会污染 filter_history 的 .over("symbol") 窗口判定。
|
|
||||||
if "date" in hist_df.columns:
|
|
||||||
hist_df = hist_df.filter(pl.col("date") != today)
|
|
||||||
# 拼接历史窗口 + 今日实时行情 (filter_history 用 .over("symbol") 窗口, 多日天然可用)
|
|
||||||
run_kwargs["precomputed_history"] = pl.concat(
|
|
||||||
[hist_df, df], how="diagonal_relaxed"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("策略 %s 加载历史窗口失败, 跳过: %s", sid, e)
|
|
||||||
return []
|
|
||||||
else:
|
|
||||||
# 普通策略: 复用当前 enriched DataFrame 跳过数据加载
|
|
||||||
run_kwargs["precomputed"] = df
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = self._strategy_engine.run(sid, **run_kwargs)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("策略 %s 选股执行失败: %s", sid, e)
|
|
||||||
return []
|
|
||||||
|
|
||||||
# 记录本轮完整选股结果 (供策略页实时回显: /cached 端点直接读取, 不落盘)。
|
|
||||||
# 与下面的 diff 事件无关 — 无论是否产生 new_entry/dropped, 结果都该可用于回显。
|
|
||||||
try:
|
|
||||||
import math
|
|
||||||
self._latest_strategy_results[sid] = {
|
|
||||||
"total": result.total,
|
|
||||||
"as_of": str(_dt.date.today()),
|
|
||||||
"rows": [
|
|
||||||
{k: (None if isinstance(v, float) and not math.isfinite(v) else v)
|
|
||||||
for k, v in row.items()}
|
|
||||||
for row in result.rows
|
|
||||||
],
|
|
||||||
}
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
pass
|
|
||||||
|
|
||||||
current_pool: set[str] = {r["symbol"] for r in result.rows}
|
|
||||||
prev_pool = self._strategy_pools.get(sid)
|
|
||||||
|
|
||||||
# 首次运行: 仅记录当前选股池, 不产生事件
|
|
||||||
if prev_pool is None:
|
|
||||||
self._strategy_pools[sid] = current_pool
|
|
||||||
return []
|
|
||||||
|
|
||||||
new_entries = current_pool - prev_pool
|
|
||||||
dropped = prev_pool - current_pool
|
|
||||||
|
|
||||||
# 无变更
|
|
||||||
if not new_entries and not dropped:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# 更新存储
|
|
||||||
self._strategy_pools[sid] = current_pool
|
|
||||||
|
|
||||||
sname = s.meta.get("name", "") or s.meta.get("id", sid)
|
|
||||||
|
|
||||||
# 构建查找表 (新入选股票可在 result.rows 中找到; 移出股票需从 df 找)
|
|
||||||
row_map: dict[str, dict] = {r["symbol"]: r for r in result.rows}
|
|
||||||
dropped_map: dict[str, dict] = {}
|
|
||||||
if dropped:
|
|
||||||
try:
|
|
||||||
_dd = df.filter(pl.col("symbol").is_in(list(dropped)))
|
|
||||||
for row in _dd.iter_rows(named=True):
|
|
||||||
dropped_map[row["symbol"]] = row
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
results: list[tuple[str, str, Any, Any, Any, list[str]]] = []
|
|
||||||
|
|
||||||
# ── 新入选 ──
|
|
||||||
new_list = sorted(new_entries)
|
|
||||||
if len(new_list) > 5:
|
|
||||||
names: list[str] = []
|
|
||||||
for sym in new_list:
|
|
||||||
row = row_map.get(sym, {})
|
|
||||||
name = row.get("name") or self._name_map.get(sym, sym)
|
|
||||||
names.append(str(name))
|
|
||||||
message = f"策略「{sname}」进入 {len(new_entries)} 只:{'、'.join(names)}"
|
|
||||||
results.append(("new_entry", "_batch", message, None, None, []))
|
|
||||||
else:
|
|
||||||
for sym in new_list:
|
|
||||||
row = row_map.get(sym, {})
|
|
||||||
name = row.get("name") or self._name_map.get(sym, sym)
|
|
||||||
price = row.get("close")
|
|
||||||
pct = row.get("change_pct")
|
|
||||||
results.append(("new_entry", sym, name, price, pct, []))
|
|
||||||
|
|
||||||
# ── 已移出 ──
|
|
||||||
dropped_list = sorted(dropped)
|
|
||||||
if len(dropped_list) > 5:
|
|
||||||
names = []
|
|
||||||
for sym in dropped_list:
|
|
||||||
row = dropped_map.get(sym, {})
|
|
||||||
name = row.get("name") or self._name_map.get(sym, sym)
|
|
||||||
names.append(str(name))
|
|
||||||
message = f"策略「{sname}」移出 {len(dropped)} 只:{'、'.join(names)}"
|
|
||||||
results.append(("dropped", "_batch", message, None, None, []))
|
|
||||||
else:
|
|
||||||
for sym in dropped_list:
|
|
||||||
row = dropped_map.get(sym, {})
|
|
||||||
name = row.get("name") or self._name_map.get(sym, sym)
|
|
||||||
price = row.get("close")
|
|
||||||
pct = row.get("change_pct")
|
|
||||||
results.append(("dropped", sym, name, price, pct, []))
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _match_conditions(
|
|
||||||
df: pl.DataFrame, rule: dict,
|
|
||||||
) -> list[tuple[str, Any, Any, Any, list[str]]]:
|
|
||||||
"""按 conditions + logic 匹配,返回命中行 [(symbol,name,price,pct,signals)]。"""
|
|
||||||
conditions = rule.get("conditions", [])
|
|
||||||
logic = rule.get("logic", "and")
|
|
||||||
if not conditions:
|
|
||||||
return []
|
|
||||||
hit_df = _build_condition_mask(df, conditions, logic)
|
|
||||||
results = []
|
|
||||||
for row in hit_df.iter_rows(named=True):
|
|
||||||
sym = row.get("symbol", "")
|
|
||||||
name = row.get("name")
|
|
||||||
price = row.get("close")
|
|
||||||
pct = row.get("change_pct")
|
|
||||||
# 收集命中的信号列名 (仅 op=truth 且为真的)
|
|
||||||
hit_sigs = [
|
|
||||||
c["field"] for c in conditions
|
|
||||||
if c.get("op") == "truth" and row.get(c["field"])
|
|
||||||
]
|
|
||||||
results.append((sym, name, price, pct, hit_sigs))
|
|
||||||
return results
|
|
||||||
|
|
||||||
def _evaluate_ladder(self, scoped: pl.DataFrame, rule: dict, now: float) -> list[dict]:
|
|
||||||
"""评估连板梯队封单监控规则。
|
|
||||||
|
|
||||||
封单量从注入的临时列 _sealed_vol (手) 读取 (由 quote_service 评估前注入)。
|
|
||||||
命中条件: 封单比较值 <= threshold (且封单 > 0, 排除无 depth 数据的股票)。
|
|
||||||
涨停(direction=up) → 炸板预警; 跌停(direction=down) → 翘板预警。
|
|
||||||
"""
|
|
||||||
if "_sealed_vol" not in scoped.columns:
|
|
||||||
return [] # 无封单数据 (depth 未拉取), 安全降级
|
|
||||||
|
|
||||||
metric = rule.get("metric", "sealed_vol")
|
|
||||||
threshold = rule.get("threshold", 0)
|
|
||||||
direction = rule.get("direction", "up")
|
|
||||||
cooldown = rule.get("cooldown_seconds", 600)
|
|
||||||
severity = rule.get("severity", "warn")
|
|
||||||
|
|
||||||
# 比较值: sealed_vol 直接用 (手), sealed_amount = 手 × 100股 × close
|
|
||||||
if metric == "sealed_amount":
|
|
||||||
cmp_expr = pl.col("_sealed_vol") * 100 * pl.col("close")
|
|
||||||
unit = "元"
|
|
||||||
else:
|
|
||||||
cmp_expr = pl.col("_sealed_vol")
|
|
||||||
unit = "手"
|
|
||||||
|
|
||||||
# 命中: 封单 > 0 (有数据) 且 比较值 <= 阈值
|
|
||||||
hit = scoped.filter(
|
|
||||||
pl.col("_sealed_vol").is_not_null()
|
|
||||||
& (pl.col("_sealed_vol") > 0)
|
|
||||||
& (cmp_expr <= threshold)
|
|
||||||
)
|
|
||||||
if hit.is_empty():
|
|
||||||
return []
|
|
||||||
|
|
||||||
warn_label = "炸板预警" if direction == "up" else "翘板预警"
|
|
||||||
events: list[dict] = []
|
|
||||||
for row in hit.iter_rows(named=True):
|
|
||||||
sym = row.get("symbol", "")
|
|
||||||
key = (rule["id"], sym)
|
|
||||||
last = self._last_fire.get(key)
|
|
||||||
if last is not None and (now - last) < cooldown:
|
|
||||||
continue
|
|
||||||
self._last_fire[key] = now
|
|
||||||
|
|
||||||
name = row.get("name") or self._name_map.get(sym) or sym
|
|
||||||
price = row.get("close")
|
|
||||||
pct = row.get("change_pct")
|
|
||||||
sealed_vol = row.get("_sealed_vol")
|
|
||||||
# 预警封单值 (展示用)
|
|
||||||
sealed_value = sealed_vol * 100 * (price or 0) if metric == "sealed_amount" else sealed_vol
|
|
||||||
|
|
||||||
# message 体现预警封单量 + 阈值
|
|
||||||
if metric == "sealed_amount":
|
|
||||||
sv_text = f"{sealed_value / 1e4:.0f}万{unit}"
|
|
||||||
th_text = f"{threshold / 1e4:.0f}万{unit}"
|
|
||||||
else:
|
|
||||||
sv_text = f"{sealed_value:,.0f} {unit}"
|
|
||||||
th_text = f"{threshold:,.0f} {unit}"
|
|
||||||
message = f"{warn_label} · 封单 {sv_text} ≤ {th_text}"
|
|
||||||
|
|
||||||
events.append({
|
|
||||||
"ts": int(now * 1000),
|
|
||||||
"rule_id": rule["id"],
|
|
||||||
"rule_name": rule.get("name", ""),
|
|
||||||
"source": "ladder",
|
|
||||||
"type": warn_label,
|
|
||||||
"symbol": sym,
|
|
||||||
"name": name,
|
|
||||||
"message": message,
|
|
||||||
"price": price,
|
|
||||||
"change_pct": pct,
|
|
||||||
"signals": [],
|
|
||||||
"severity": severity,
|
|
||||||
"conditions": [],
|
|
||||||
"logic": "and",
|
|
||||||
"sealed_value": sealed_value, # 预警封单量/额 (飞书+记录展示)
|
|
||||||
"sealed_metric": metric,
|
|
||||||
})
|
|
||||||
return events
|
|
||||||
|
|
||||||
def _default_message(self, rule: dict, ev_type: str = "", sym: str = "",
|
|
||||||
name: str = "", pct: Any = None, price: Any = None,
|
|
||||||
conditions: list[dict] | None = None) -> str:
|
|
||||||
"""生成默认 message。
|
|
||||||
|
|
||||||
- strategy: 按变更方向生成 (进入/移出 + 涨跌幅)
|
|
||||||
- signal/price/market: 条件摘要 + 现价 + 涨跌幅 (避免笼统的「信号触发」)
|
|
||||||
"""
|
|
||||||
rtype = rule.get("type", "signal")
|
|
||||||
if rtype == "strategy":
|
|
||||||
# 从 StrategyEngine 取策略名; 失败则退化为 rule_name 里截取的部分
|
|
||||||
sname = ""
|
|
||||||
sid = rule.get("strategy_id")
|
|
||||||
if sid and self._strategy_engine is not None:
|
|
||||||
try:
|
|
||||||
s = self._strategy_engine.get(sid)
|
|
||||||
sname = s.meta.get("name", "") or s.meta.get("id", "")
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
sname = ""
|
|
||||||
if not sname:
|
|
||||||
rn = rule.get("name", "")
|
|
||||||
sname = rn.split(" · ", 1)[1] if " · " in rn else (rn or "策略")
|
|
||||||
|
|
||||||
if ev_type == "new_entry":
|
|
||||||
pct_text = ""
|
|
||||||
if pct is not None:
|
|
||||||
sign = "+" if pct >= 0 else ""
|
|
||||||
pct_text = f" {sign}{pct * 100:.1f}%"
|
|
||||||
return f"策略「{sname}」进入 {name}{pct_text}"
|
|
||||||
elif ev_type == "dropped":
|
|
||||||
pct_text = ""
|
|
||||||
if pct is not None:
|
|
||||||
sign = "+" if pct >= 0 else ""
|
|
||||||
pct_text = f" {sign}{pct * 100:.1f}%"
|
|
||||||
return f"策略「{sname}」移出 {name}{pct_text}"
|
|
||||||
return f"策略「{sname}」变更"
|
|
||||||
|
|
||||||
# signal / price / market: 条件摘要 + 现价 + 涨跌幅
|
|
||||||
# 条件摘要: 把 conditions (truth/比较) 拼成可读串, 如 "MA20金叉 且 量比>2"
|
|
||||||
cond_text = self._format_conditions_text(rule, conditions)
|
|
||||||
price_text = f"现价 {price}" if price is not None else ""
|
|
||||||
pct_text = ""
|
|
||||||
if pct is not None:
|
|
||||||
sign = "+" if pct >= 0 else ""
|
|
||||||
pct_text = f"{sign}{pct * 100:.1f}%"
|
|
||||||
tail = " · ".join(s for s in (price_text, pct_text) if s)
|
|
||||||
if cond_text and tail:
|
|
||||||
return f"{cond_text} · {tail}"
|
|
||||||
return cond_text or tail or "监控触发"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _format_conditions_text(rule: dict, conditions: list[dict] | None) -> str:
|
|
||||||
"""把 rule.conditions 拼成可读文本 (用于 message / 推送)。
|
|
||||||
|
|
||||||
op=truth: 直接用信号中文名 (如 "MA20金叉")
|
|
||||||
op=比较: 字段中文名 + 操作符 + 值 (如 "涨跌幅≥5")
|
|
||||||
logic: and → "且", or → "或"
|
|
||||||
"""
|
|
||||||
conds = conditions if conditions is not None else list(rule.get("conditions", []))
|
|
||||||
if not conds:
|
|
||||||
return ""
|
|
||||||
logic_word = "且" if rule.get("logic", "and") == "and" else "或"
|
|
||||||
parts: list[str] = []
|
|
||||||
for c in conds:
|
|
||||||
field = c.get("field", "")
|
|
||||||
op = c.get("op", "truth")
|
|
||||||
value = c.get("value")
|
|
||||||
label = _signal_cn_name(field) or field
|
|
||||||
if op == "truth":
|
|
||||||
parts.append(label)
|
|
||||||
else:
|
|
||||||
op_map = {"gte": "≥", "lte": "≤", "gt": ">", "lt": "<", "eq": "="}
|
|
||||||
parts.append(f"{label}{op_map.get(op, op)}{value}")
|
|
||||||
return f" {logic_word} ".join(parts)
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
"""监控规则 — 统一的 MonitorRule 模型,覆盖策略/个股信号/个股价格/市场异动四类。
|
|
||||||
|
|
||||||
职责:
|
|
||||||
- 从 data/user_data/monitor_rules/*.json 加载规则定义
|
|
||||||
- 校验规则字段合法性
|
|
||||||
- 提供 CRUD (load_all / save_one / delete_one)
|
|
||||||
|
|
||||||
不知道: 行情评估引擎、API、告警落盘。纯函数 + 文件存储。
|
|
||||||
|
|
||||||
设计 (镜像 custom_signals.py 的写法):
|
|
||||||
- 一对象一文件 + glob 全扫 + 全量重写
|
|
||||||
- 字段白名单复用 custom_signals.ALLOWED_FIELDS (阈值条件) + 信号列清单 (布尔条件)
|
|
||||||
- id 正则与 custom_signals 一致,保证可纳入同一索引体系
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from app.strategy.custom_signals import ALLOWED_FIELDS
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# ── 常量 ────────────────────────────────────────────────
|
|
||||||
ID_RE = re.compile(r"^[a-z0-9_]{1,40}$")
|
|
||||||
RULE_TYPES = {"strategy", "signal", "price", "market", "ladder"}
|
|
||||||
SCOPES = {"symbols", "all", "sector"}
|
|
||||||
LOGICS = {"and", "or"}
|
|
||||||
DIRECTIONS = {"entry", "exit", "both"}
|
|
||||||
SEVERITIES = {"info", "warn", "critical"}
|
|
||||||
OPS = {">", ">=", "<", "<=", "==", "!="}
|
|
||||||
# ladder 规则: 封单监控的指标 (量=手, 额=元)
|
|
||||||
LADDER_METRICS = {"sealed_vol", "sealed_amount"}
|
|
||||||
# ladder 规则: 方向 (up=涨停炸板预警, down=跌停翘板预警)
|
|
||||||
LADDER_DIRECTIONS = {"up", "down"}
|
|
||||||
|
|
||||||
# 布尔信号列前缀 (op=truth 时 field 取这些)
|
|
||||||
_SIGNAL_PREFIXES = ("signal_", "csg_")
|
|
||||||
|
|
||||||
|
|
||||||
# ── 持久化 (镜像 custom_signals.py) ─────────────────────
|
|
||||||
def _dir(data_dir: Path) -> Path:
|
|
||||||
d = data_dir / "user_data" / "monitor_rules"
|
|
||||||
d.mkdir(parents=True, exist_ok=True)
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
def _path(data_dir: Path, rule_id: str) -> Path:
|
|
||||||
return _dir(data_dir) / f"{rule_id}.json"
|
|
||||||
|
|
||||||
|
|
||||||
def load_all(data_dir: Path) -> list[dict]:
|
|
||||||
"""读取全部监控规则。损坏的文件被跳过。"""
|
|
||||||
d = _dir(data_dir)
|
|
||||||
out: list[dict] = []
|
|
||||||
for f in sorted(d.glob("*.json")):
|
|
||||||
try:
|
|
||||||
out.append(json.loads(f.read_text(encoding="utf-8")))
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("monitor rule load failed %s: %s", f.name, e)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def load_one(data_dir: Path, rule_id: str) -> dict | None:
|
|
||||||
p = _path(data_dir, rule_id)
|
|
||||||
if not p.exists():
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return json.loads(p.read_text(encoding="utf-8"))
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("monitor rule load failed %s: %s", rule_id, e)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def save_one(data_dir: Path, rule: dict) -> None:
|
|
||||||
p = _path(data_dir, rule["id"])
|
|
||||||
p.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
p.write_text(json.dumps(rule, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def delete_one(data_dir: Path, rule_id: str) -> bool:
|
|
||||||
p = _path(data_dir, rule_id)
|
|
||||||
if p.exists():
|
|
||||||
p.unlink()
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# ── 校验 ────────────────────────────────────────────────
|
|
||||||
def _is_signal_field(field: str) -> bool:
|
|
||||||
"""判断 field 是否为布尔信号列 (signal_ / csg_ 前缀)。"""
|
|
||||||
return any(field.startswith(p) for p in _SIGNAL_PREFIXES)
|
|
||||||
|
|
||||||
|
|
||||||
def validate(rule: dict) -> None:
|
|
||||||
"""校验一条监控规则,非法则抛 ValueError (含中文信息)。"""
|
|
||||||
rid = rule.get("id", "")
|
|
||||||
if not isinstance(rid, str) or not ID_RE.match(rid):
|
|
||||||
raise ValueError(f"规则 id 非法 (仅小写字母数字下划线, 1-40字符): {rid!r}")
|
|
||||||
if not isinstance(rule.get("name"), str) or not rule["name"].strip():
|
|
||||||
raise ValueError("规则 name 不能为空")
|
|
||||||
if rule.get("type") not in RULE_TYPES:
|
|
||||||
raise ValueError(f"type 必须是 {RULE_TYPES} 之一")
|
|
||||||
|
|
||||||
# 策略类型: 需要 strategy_id + direction,conditions 可空
|
|
||||||
if rule.get("type") == "strategy":
|
|
||||||
if not rule.get("strategy_id"):
|
|
||||||
raise ValueError("策略类型规则必须指定 strategy_id")
|
|
||||||
if rule.get("direction", "entry") not in DIRECTIONS:
|
|
||||||
raise ValueError(f"direction 必须是 {DIRECTIONS} 之一")
|
|
||||||
elif rule.get("type") == "ladder":
|
|
||||||
# 连板梯队封单监控: 需 metric + threshold + direction(up/down), 不用 conditions
|
|
||||||
if rule.get("metric", "sealed_vol") not in LADDER_METRICS:
|
|
||||||
raise ValueError(f"metric 必须是 {LADDER_METRICS} 之一")
|
|
||||||
if rule.get("direction", "up") not in LADDER_DIRECTIONS:
|
|
||||||
raise ValueError(f"direction 必须是 {LADDER_DIRECTIONS} 之一 (up=涨停炸板, down=跌停翘板)")
|
|
||||||
thr = rule.get("threshold")
|
|
||||||
if not isinstance(thr, (int, float)) or thr < 0:
|
|
||||||
raise ValueError("threshold 必须是非负数字 (封单 ≤ 此值时报警)")
|
|
||||||
else:
|
|
||||||
# 信号/价格/市场类型: 需要 conditions
|
|
||||||
conds = rule.get("conditions")
|
|
||||||
if not isinstance(conds, list) or len(conds) == 0:
|
|
||||||
raise ValueError("conditions 不能为空")
|
|
||||||
if len(conds) > 8:
|
|
||||||
raise ValueError("conditions 最多 8 条")
|
|
||||||
if rule.get("logic", "and") not in LOGICS:
|
|
||||||
raise ValueError(f"logic 必须是 {LOGICS} 之一")
|
|
||||||
for i, c in enumerate(conds):
|
|
||||||
if not isinstance(c, dict):
|
|
||||||
raise ValueError(f"第 {i+1} 个条件格式错误")
|
|
||||||
field = c.get("field", "")
|
|
||||||
op = c.get("op", "")
|
|
||||||
if op == "truth":
|
|
||||||
# 布尔信号: field 必须是 signal_/csg_ 前缀
|
|
||||||
if not _is_signal_field(field):
|
|
||||||
raise ValueError(f"第 {i+1} 个条件: op=truth 时 field 必须是信号列 (signal_/csg_ 前缀): {field!r}")
|
|
||||||
elif op in OPS:
|
|
||||||
# 阈值比较: field 必须在白名单, 需要 value
|
|
||||||
if field not in ALLOWED_FIELDS:
|
|
||||||
raise ValueError(f"第 {i+1} 个条件: 阈值字段 {field!r} 不在白名单")
|
|
||||||
if not isinstance(c.get("value"), (int, float)):
|
|
||||||
raise ValueError(f"第 {i+1} 个条件: value 必须是数字")
|
|
||||||
else:
|
|
||||||
raise ValueError(f"第 {i+1} 个条件: op {op!r} 非法 (应为 truth 或 {OPS})")
|
|
||||||
|
|
||||||
# scope 校验
|
|
||||||
if rule.get("scope", "symbols") not in SCOPES:
|
|
||||||
raise ValueError(f"scope 必须是 {SCOPES} 之一")
|
|
||||||
if rule.get("scope") == "symbols":
|
|
||||||
syms = rule.get("symbols")
|
|
||||||
if not isinstance(syms, list) or len(syms) == 0:
|
|
||||||
raise ValueError("scope=symbols 时 symbols 不能为空")
|
|
||||||
|
|
||||||
# 其余枚举
|
|
||||||
if rule.get("severity", "info") not in SEVERITIES:
|
|
||||||
raise ValueError(f"severity 必须是 {SEVERITIES} 之一")
|
|
||||||
cd = rule.get("cooldown_seconds", 3600)
|
|
||||||
if not isinstance(cd, int) or cd < 0:
|
|
||||||
raise ValueError("cooldown_seconds 必须是非负整数")
|
|
||||||
|
|
||||||
|
|
||||||
def normalize(rule: dict) -> dict:
|
|
||||||
"""补全默认字段,返回规范化后的规则 (不校验)。"""
|
|
||||||
r = dict(rule)
|
|
||||||
r.setdefault("enabled", True)
|
|
||||||
r.setdefault("scope", "symbols")
|
|
||||||
r.setdefault("symbols", [])
|
|
||||||
r.setdefault("sector", None)
|
|
||||||
r.setdefault("strategy_id", None)
|
|
||||||
# direction 默认值: ladder 用 "up", 其余用 "entry"
|
|
||||||
r.setdefault("direction", "up" if r.get("type") == "ladder" else "entry")
|
|
||||||
r.setdefault("conditions", [])
|
|
||||||
# ladder 专属默认字段
|
|
||||||
r.setdefault("metric", "sealed_vol")
|
|
||||||
r.setdefault("threshold", 0)
|
|
||||||
r.setdefault("logic", "and")
|
|
||||||
r.setdefault("cooldown_seconds", 3600)
|
|
||||||
r.setdefault("severity", "info")
|
|
||||||
r.setdefault("message", "")
|
|
||||||
r.setdefault("webhook_url", "")
|
|
||||||
r.setdefault("webhook_enabled", False)
|
|
||||||
r.setdefault("created_at", datetime.now(timezone.utc).isoformat())
|
|
||||||
return r
|
|
||||||
|
|
||||||
|
|
||||||
# 策略监控自动迁移的规则 id 前缀 (固定, 保证幂等)
|
|
||||||
STRATEGY_RULE_PREFIX = "mr_strategy_"
|
|
||||||
|
|
||||||
|
|
||||||
def strategy_rule_id(strategy_id: str) -> str:
|
|
||||||
"""策略监控规则 id = mr_strategy_{strategy_id}。"""
|
|
||||||
return f"{STRATEGY_RULE_PREFIX}{strategy_id}"
|
|
||||||
|
|
||||||
|
|
||||||
def migrate_strategy_monitors(data_dir: Path, strategy_ids: list[str], strategy_names: dict[str, str]) -> list[dict]:
|
|
||||||
"""把 preferences.strategy_monitor_ids 里的策略,同步生成/更新 type=strategy 规则。
|
|
||||||
|
|
||||||
幂等: 已存在的策略规则会被更新 (方向/名称),不会重复创建。
|
|
||||||
已从 strategy_ids 移除的策略, 其规则会被停用 (enabled=False) 而非删除 (保留历史触发记录的关联)。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
data_dir: 数据目录
|
|
||||||
strategy_ids: 当前监控池中的策略 id 列表
|
|
||||||
strategy_names: {strategy_id: 策略名} 用于规则显示名
|
|
||||||
Returns:
|
|
||||||
本次生成/更新的规则列表
|
|
||||||
"""
|
|
||||||
desired = set(strategy_ids)
|
|
||||||
existing = load_all(data_dir)
|
|
||||||
# 已存在的策略规则 {strategy_id: rule}
|
|
||||||
existing_strategy_rules: dict[str, dict] = {}
|
|
||||||
for r in existing:
|
|
||||||
rid = r.get("id", "")
|
|
||||||
if rid.startswith(STRATEGY_RULE_PREFIX):
|
|
||||||
sid = rid[len(STRATEGY_RULE_PREFIX):]
|
|
||||||
if sid:
|
|
||||||
existing_strategy_rules[sid] = r
|
|
||||||
|
|
||||||
touched: list[dict] = []
|
|
||||||
# 1. 为当前监控池的策略 upsert 规则
|
|
||||||
for sid in desired:
|
|
||||||
rule_id = strategy_rule_id(sid)
|
|
||||||
name = strategy_names.get(sid, sid)
|
|
||||||
rule = existing_strategy_rules.get(sid)
|
|
||||||
if rule is None:
|
|
||||||
rule = normalize({
|
|
||||||
"id": rule_id,
|
|
||||||
"name": f"策略监控 · {name}",
|
|
||||||
"type": "strategy",
|
|
||||||
"scope": "all",
|
|
||||||
"strategy_id": sid,
|
|
||||||
"direction": "entry",
|
|
||||||
"conditions": [],
|
|
||||||
"cooldown_seconds": 3600,
|
|
||||||
"enabled": True,
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
rule = dict(rule)
|
|
||||||
rule["enabled"] = True
|
|
||||||
rule["strategy_id"] = sid
|
|
||||||
rule["name"] = f"策略监控 · {name}"
|
|
||||||
rule.setdefault("scope", "all")
|
|
||||||
rule.setdefault("direction", "entry")
|
|
||||||
save_one(data_dir, rule)
|
|
||||||
touched.append(rule)
|
|
||||||
|
|
||||||
# 2. 不在监控池的策略 → 停用其规则 (不删除)
|
|
||||||
for sid, rule in existing_strategy_rules.items():
|
|
||||||
if sid not in desired and rule.get("enabled") is not False:
|
|
||||||
rule = dict(rule)
|
|
||||||
rule["enabled"] = False
|
|
||||||
save_one(data_dir, rule)
|
|
||||||
|
|
||||||
return touched
|
|
||||||
@@ -22,7 +22,6 @@ class Cap(StrEnum):
|
|||||||
INTRADAY_BATCH = "intraday.batch"
|
INTRADAY_BATCH = "intraday.batch"
|
||||||
DEPTH5 = "depth5"
|
DEPTH5 = "depth5"
|
||||||
DEPTH5_BATCH = "depth5.batch"
|
DEPTH5_BATCH = "depth5.batch"
|
||||||
WEBSOCKET = "websocket"
|
|
||||||
FINANCIAL = "financial"
|
FINANCIAL = "financial"
|
||||||
ADJ_FACTOR = "adj_factor"
|
ADJ_FACTOR = "adj_factor"
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ from app import secrets_store
|
|||||||
|
|
||||||
_sync_client: TickFlow | None = None
|
_sync_client: TickFlow | None = None
|
||||||
_async_client: AsyncTickFlow | None = None
|
_async_client: AsyncTickFlow | None = None
|
||||||
_paid_realtime_client: TickFlow | None = None
|
|
||||||
|
|
||||||
|
|
||||||
# ===== 服务器归属判定 =====
|
# ===== 服务器归属判定 =====
|
||||||
@@ -72,27 +71,11 @@ def get_async_client() -> AsyncTickFlow:
|
|||||||
return _async_client
|
return _async_client
|
||||||
|
|
||||||
|
|
||||||
def get_paid_realtime_client() -> TickFlow | None:
|
|
||||||
"""实时行情专用付费服务器客户端。
|
|
||||||
|
|
||||||
none/free 的历史日K仍走 get_client() 的 free-api;实时行情全部走付费服务器。
|
|
||||||
Free 档如果有有效 key,也使用这里的 paid endpoint 调按标的实时接口。
|
|
||||||
"""
|
|
||||||
global _paid_realtime_client
|
|
||||||
key = secrets_store.get_tickflow_key()
|
|
||||||
if not key:
|
|
||||||
return None
|
|
||||||
if _paid_realtime_client is None:
|
|
||||||
_paid_realtime_client = TickFlow(api_key=key, base_url=_base_url())
|
|
||||||
return _paid_realtime_client
|
|
||||||
|
|
||||||
|
|
||||||
def reset_clients() -> None:
|
def reset_clients() -> None:
|
||||||
"""Key 变化后调用 — 让下一次 get_client() 拿新实例。"""
|
"""Key 变化后调用 — 让下一次 get_client() 拿新实例。"""
|
||||||
global _sync_client, _async_client, _paid_realtime_client
|
global _sync_client, _async_client
|
||||||
_sync_client = None
|
_sync_client = None
|
||||||
_async_client = None
|
_async_client = None
|
||||||
_paid_realtime_client = None
|
|
||||||
|
|
||||||
|
|
||||||
def current_mode() -> str:
|
def current_mode() -> str:
|
||||||
|
|||||||
@@ -236,12 +236,6 @@ def _probe_real(tiers: dict) -> tuple[CapabilitySet, list[str]]:
|
|||||||
defaults(Cap.ADJ_FACTOR))
|
defaults(Cap.ADJ_FACTOR))
|
||||||
|
|
||||||
# websocket 不在探测期试连接(成本太高且阻塞),按档位默认推断
|
# websocket 不在探测期试连接(成本太高且阻塞),按档位默认推断
|
||||||
# 若 expert 的其他 cap 都通,则推断 websocket 也可用
|
|
||||||
if (Cap.FINANCIAL in available and Cap.INTRADAY_BATCH in available):
|
|
||||||
available[Cap.WEBSOCKET] = CapabilityLimits(
|
|
||||||
subscribe=defaults(Cap.WEBSOCKET).get("subscribe", 100),
|
|
||||||
)
|
|
||||||
log.append("✓ websocket (inferred from expert tier)")
|
|
||||||
|
|
||||||
return CapabilitySet(available), log
|
return CapabilitySet(available), log
|
||||||
|
|
||||||
@@ -297,7 +291,7 @@ def detect_capabilities(force: bool = False) -> CapabilitySet:
|
|||||||
# 拥有**任意一个**即认作该档及以上。自上而下匹配。
|
# 拥有**任意一个**即认作该档及以上。自上而下匹配。
|
||||||
# 这套设计的好处:单个 capability 探测的 transient 失败不会把整体档位"误降"。
|
# 这套设计的好处:单个 capability 探测的 transient 失败不会把整体档位"误降"。
|
||||||
TIER_SIGNATURES: dict[str, set[Cap]] = {
|
TIER_SIGNATURES: dict[str, set[Cap]] = {
|
||||||
"expert": {Cap.FINANCIAL, Cap.INTRADAY_BATCH, Cap.WEBSOCKET},
|
"expert": {Cap.FINANCIAL, Cap.INTRADAY_BATCH},
|
||||||
"pro": {Cap.KLINE_MINUTE_BATCH, Cap.KLINE_MINUTE_BY_SYMBOL,
|
"pro": {Cap.KLINE_MINUTE_BATCH, Cap.KLINE_MINUTE_BY_SYMBOL,
|
||||||
Cap.INTRADAY, Cap.DEPTH5, Cap.DEPTH5_BATCH},
|
Cap.INTRADAY, Cap.DEPTH5, Cap.DEPTH5_BATCH},
|
||||||
"starter": {Cap.QUOTE_BATCH, Cap.KLINE_DAILY_BATCH,
|
"starter": {Cap.QUOTE_BATCH, Cap.KLINE_DAILY_BATCH,
|
||||||
@@ -359,7 +353,6 @@ _CAP_ALIASES: dict[Cap, str] = {
|
|||||||
Cap.INTRADAY_BATCH: "批量分时",
|
Cap.INTRADAY_BATCH: "批量分时",
|
||||||
Cap.DEPTH5: "五档",
|
Cap.DEPTH5: "五档",
|
||||||
Cap.DEPTH5_BATCH: "批量五档",
|
Cap.DEPTH5_BATCH: "批量五档",
|
||||||
Cap.WEBSOCKET: "WS",
|
|
||||||
Cap.FINANCIAL: "财务",
|
Cap.FINANCIAL: "财务",
|
||||||
Cap.ADJ_FACTOR: "复权",
|
Cap.ADJ_FACTOR: "复权",
|
||||||
Cap.QUOTE_BATCH: "批量行情",
|
Cap.QUOTE_BATCH: "批量行情",
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
Phase 1 实现:
|
Phase 1 实现:
|
||||||
- 常用指数成份(沪深 300 / 中证 500 / 上证 50)用 TickFlow `quote.pool` 端点拉取并缓存
|
- 常用指数成份(沪深 300 / 中证 500 / 上证 50)用 TickFlow `quote.pool` 端点拉取并缓存
|
||||||
- 全 A 通过 instruments.batch 获取
|
- 全 A 通过 instruments.batch 获取
|
||||||
- 自选池 = 用户的 watchlist
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -19,7 +18,7 @@ from app.tickflow.client import get_client
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
PoolId = Literal["CSI300", "CSI500", "SSE50", "CN_Equity_A", "CN_Index", "watchlist"]
|
PoolId = Literal["CSI300", "CSI500", "SSE50", "CN_Equity_A", "CN_Index"]
|
||||||
|
|
||||||
# TickFlow universe id 是它内部命名(见 tf.universes.list())。
|
# TickFlow universe id 是它内部命名(见 tf.universes.list())。
|
||||||
# 没有官方对照表,启动时按名称模糊匹配从 universes.list() 里找。
|
# 没有官方对照表,启动时按名称模糊匹配从 universes.list() 里找。
|
||||||
@@ -54,9 +53,6 @@ def _pool_cache_path(pool_id: str) -> Path:
|
|||||||
|
|
||||||
def get_pool(pool_id: PoolId, refresh: bool = False) -> list[str]:
|
def get_pool(pool_id: PoolId, refresh: bool = False) -> list[str]:
|
||||||
"""返回标的池里的 symbol 列表。"""
|
"""返回标的池里的 symbol 列表。"""
|
||||||
if pool_id == "watchlist":
|
|
||||||
return _load_watchlist()
|
|
||||||
|
|
||||||
cache = _pool_cache_path(pool_id)
|
cache = _pool_cache_path(pool_id)
|
||||||
if cache.exists() and not refresh:
|
if cache.exists() and not refresh:
|
||||||
df = pl.read_parquet(cache)
|
df = pl.read_parquet(cache)
|
||||||
@@ -132,17 +128,6 @@ def _fetch_pool(pool_id: PoolId) -> list[str]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _load_watchlist() -> list[str]:
|
|
||||||
"""读取用户自选(由 watchlist service 维护)。"""
|
|
||||||
path = settings.data_dir / "user_data" / "watchlist.parquet"
|
|
||||||
if not path.exists():
|
|
||||||
return []
|
|
||||||
df = pl.read_parquet(path)
|
|
||||||
if df.is_empty() or "symbol" not in df.columns:
|
|
||||||
return []
|
|
||||||
return df["symbol"].to_list()
|
|
||||||
|
|
||||||
|
|
||||||
# 兜底:Free 用户/无 API 时给一个小型可用集合,让 UI 不至于空白
|
# 兜底:Free 用户/无 API 时给一个小型可用集合,让 UI 不至于空白
|
||||||
DEMO_SYMBOLS = [
|
DEMO_SYMBOLS = [
|
||||||
"600000.SH", # 浦发银行
|
"600000.SH", # 浦发银行
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ class DataStore:
|
|||||||
"instruments_ext",
|
"instruments_ext",
|
||||||
"kline_ext",
|
"kline_ext",
|
||||||
"pools",
|
"pools",
|
||||||
"backtest_results",
|
|
||||||
"screener_results",
|
"screener_results",
|
||||||
"ai_cache",
|
"ai_cache",
|
||||||
"user_data",
|
"user_data",
|
||||||
@@ -77,7 +76,7 @@ class DataStore:
|
|||||||
|
|
||||||
背景: 旧版 data_dir = exe_dir.parent / "TickFlowStockPanel_Data" (兄弟目录),
|
背景: 旧版 data_dir = exe_dir.parent / "TickFlowStockPanel_Data" (兄弟目录),
|
||||||
新版改为 exe_dir / "data" (子目录)。老用户首次升级时旧数据在兄弟目录,
|
新版改为 exe_dir / "data" (子目录)。老用户首次升级时旧数据在兄弟目录,
|
||||||
若不迁移会导致历史行情/策略/回测/监控全部"丢失"(实际还在旧位置)。
|
若不迁移会导致历史行情/策略/监控全部"丢失"(实际还在旧位置)。
|
||||||
|
|
||||||
策略 (仅打包桌面版触发, 开发/Docker 不受影响):
|
策略 (仅打包桌面版触发, 开发/Docker 不受影响):
|
||||||
1. 旧目录存在且新 data/ 还基本为空 → 整目录搬迁 (shutil.move, 跨盘符安全)。
|
1. 旧目录存在且新 data/ 还基本为空 → 整目录搬迁 (shutil.move, 跨盘符安全)。
|
||||||
@@ -408,7 +407,7 @@ class KlineRepository:
|
|||||||
how="left",
|
how="left",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 缓存完整历史 (含指标+必要基础信息) 供 filter_history/backtest 直接复用
|
# 缓存完整历史 (含指标+必要基础信息)
|
||||||
self._enriched_history_cache = df_full
|
self._enriched_history_cache = df_full
|
||||||
self._enriched_history_start = df_full["date"].min()
|
self._enriched_history_start = df_full["date"].min()
|
||||||
logger.info("enriched 历史缓存: %d rows, %s ~ %s",
|
logger.info("enriched 历史缓存: %d rows, %s ~ %s",
|
||||||
|
|||||||
@@ -1,422 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date, timedelta
|
|
||||||
|
|
||||||
import polars as pl
|
|
||||||
|
|
||||||
from app.backtest.engine import BacktestEngine, MatcherConfig
|
|
||||||
|
|
||||||
|
|
||||||
def _panel(symbols: list[str], days: int = 4, price: float = 10.0, overrides: dict[tuple[str, int], dict] | None = None) -> pl.DataFrame:
|
|
||||||
overrides = overrides or {}
|
|
||||||
start = date(2024, 1, 1)
|
|
||||||
rows = []
|
|
||||||
for sym in symbols:
|
|
||||||
for i in range(days):
|
|
||||||
patch = overrides.get((sym, i), {})
|
|
||||||
rows.append({
|
|
||||||
"symbol": sym,
|
|
||||||
"name": sym,
|
|
||||||
"date": start + timedelta(days=i),
|
|
||||||
"open": patch.get("open", price),
|
|
||||||
"high": patch.get("high", price),
|
|
||||||
"low": patch.get("low", price),
|
|
||||||
"close": patch.get("close", price),
|
|
||||||
"volume": patch.get("volume", 100_000),
|
|
||||||
"score": patch.get("score", {"A": 4, "B": 3, "C": 2, "D": 1}.get(sym, 0)),
|
|
||||||
"signal_limit_up": patch.get("signal_limit_up", False),
|
|
||||||
"signal_limit_down": patch.get("signal_limit_down", False),
|
|
||||||
})
|
|
||||||
return pl.DataFrame(rows).sort(["symbol", "date"])
|
|
||||||
|
|
||||||
|
|
||||||
def _mask(panel: pl.DataFrame, marks: set[tuple[str, int]]) -> pl.Series:
|
|
||||||
values = []
|
|
||||||
base = date(2024, 1, 1)
|
|
||||||
for row in panel.select(["symbol", "date"]).iter_rows(named=True):
|
|
||||||
day = (row["date"] - base).days
|
|
||||||
values.append((row["symbol"], day) in marks)
|
|
||||||
return pl.Series(values, dtype=pl.Boolean)
|
|
||||||
|
|
||||||
|
|
||||||
def _engine() -> BacktestEngine:
|
|
||||||
return BacktestEngine(repo=None) # simulate_portfolio 不访问 repo
|
|
||||||
|
|
||||||
|
|
||||||
def test_max_exposure_sets_target_position_and_caps_count():
|
|
||||||
panel = _panel(["A", "B", "C", "D"], days=3)
|
|
||||||
entries = _mask(panel, {("A", 0), ("B", 0), ("C", 0), ("D", 0)})
|
|
||||||
exits = _mask(panel, set())
|
|
||||||
|
|
||||||
result = _engine().simulate_portfolio(
|
|
||||||
panel,
|
|
||||||
entries,
|
|
||||||
exits,
|
|
||||||
MatcherConfig(
|
|
||||||
matching="open_t+1",
|
|
||||||
fees_pct=0,
|
|
||||||
slippage_bps=0,
|
|
||||||
max_positions=3,
|
|
||||||
max_exposure_pct=0.6,
|
|
||||||
initial_capital=100_000,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert len(result.trades) == 3
|
|
||||||
assert {t.symbol for t in result.trades} == {"A", "B", "C"}
|
|
||||||
assert all(abs(t.position_pct - 0.2) < 0.001 for t in result.trades)
|
|
||||||
assert result.stats["max_exposure"] <= 0.61
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_price_limit_up_blocks_buy():
|
|
||||||
panel = _panel(
|
|
||||||
["A"],
|
|
||||||
days=3,
|
|
||||||
overrides={
|
|
||||||
("A", 1): {"open": 11, "high": 11, "low": 11, "close": 11, "signal_limit_up": True},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
entries = _mask(panel, {("A", 0)})
|
|
||||||
exits = _mask(panel, set())
|
|
||||||
|
|
||||||
result = _engine().simulate_portfolio(
|
|
||||||
panel,
|
|
||||||
entries,
|
|
||||||
exits,
|
|
||||||
MatcherConfig(matching="open_t+1", fees_pct=0, slippage_bps=0, max_positions=1, initial_capital=100_000),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.trades == []
|
|
||||||
assert result.stats["execution"]["buy_limit_up"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_failed_open_exit_keeps_slot_and_blocks_replacement_buy():
|
|
||||||
panel = _panel(
|
|
||||||
["A", "B", "C", "D"],
|
|
||||||
days=4,
|
|
||||||
overrides={
|
|
||||||
("A", 2): {"open": 9, "high": 9, "low": 9, "close": 9, "signal_limit_down": True},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
entries = _mask(panel, {
|
|
||||||
("A", 0), ("B", 0), ("C", 0),
|
|
||||||
("D", 1),
|
|
||||||
})
|
|
||||||
exits = _mask(panel, {("A", 1)})
|
|
||||||
|
|
||||||
result = _engine().simulate_portfolio(
|
|
||||||
panel,
|
|
||||||
entries,
|
|
||||||
exits,
|
|
||||||
MatcherConfig(
|
|
||||||
matching="open_t+1",
|
|
||||||
fees_pct=0,
|
|
||||||
slippage_bps=0,
|
|
||||||
max_positions=3,
|
|
||||||
max_exposure_pct=0.6,
|
|
||||||
initial_capital=100_000,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert "D" not in {t.symbol for t in result.trades}
|
|
||||||
assert result.stats["execution"]["sell_limit_down"] == 1
|
|
||||||
assert result.stats["execution"]["pending_exit"] == 1
|
|
||||||
assert result.stats["execution"]["buy_no_slot"] >= 1
|
|
||||||
a_trade = next(t for t in result.trades if t.symbol == "A")
|
|
||||||
assert a_trade.blocked_exit_days == 1
|
|
||||||
assert a_trade.exit_reason == "signal"
|
|
||||||
|
|
||||||
|
|
||||||
def test_trailing_stop_uses_high_water_mark():
|
|
||||||
panel = _panel(
|
|
||||||
["A"],
|
|
||||||
days=5,
|
|
||||||
overrides={
|
|
||||||
("A", 2): {"open": 10, "high": 12, "low": 11.8, "close": 12},
|
|
||||||
("A", 3): {"open": 12, "high": 12, "low": 11.3, "close": 11.3},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
entries = _mask(panel, {("A", 0)})
|
|
||||||
exits = _mask(panel, set())
|
|
||||||
|
|
||||||
result = _engine().simulate_portfolio(
|
|
||||||
panel,
|
|
||||||
entries,
|
|
||||||
exits,
|
|
||||||
MatcherConfig(
|
|
||||||
matching="open_t+1",
|
|
||||||
fees_pct=0,
|
|
||||||
slippage_bps=0,
|
|
||||||
max_positions=1,
|
|
||||||
initial_capital=100_000,
|
|
||||||
trailing_stop_pct=0.05,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert len(result.trades) == 1
|
|
||||||
trade = result.trades[0]
|
|
||||||
assert trade.exit_reason == "trailing_stop"
|
|
||||||
assert trade.exit_price == 11.4
|
|
||||||
|
|
||||||
|
|
||||||
def test_trailing_take_profit_requires_activation():
|
|
||||||
panel = _panel(
|
|
||||||
["A"],
|
|
||||||
days=5,
|
|
||||||
overrides={
|
|
||||||
("A", 2): {"open": 10, "high": 10.8, "low": 10.4, "close": 10.8},
|
|
||||||
("A", 3): {"open": 10.8, "high": 10.8, "low": 10.4, "close": 10.4},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
entries = _mask(panel, {("A", 0)})
|
|
||||||
exits = _mask(panel, set())
|
|
||||||
|
|
||||||
result = _engine().simulate_portfolio(
|
|
||||||
panel,
|
|
||||||
entries,
|
|
||||||
exits,
|
|
||||||
MatcherConfig(
|
|
||||||
matching="open_t+1",
|
|
||||||
fees_pct=0,
|
|
||||||
slippage_bps=0,
|
|
||||||
max_positions=1,
|
|
||||||
initial_capital=100_000,
|
|
||||||
trailing_take_profit_activate_pct=0.10,
|
|
||||||
trailing_take_profit_drawdown_pct=0.03,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.trades[0].exit_reason == "end"
|
|
||||||
|
|
||||||
|
|
||||||
def test_trailing_take_profit_exits_after_activation():
|
|
||||||
panel = _panel(
|
|
||||||
["A"],
|
|
||||||
days=5,
|
|
||||||
overrides={
|
|
||||||
("A", 2): {"open": 10, "high": 12, "low": 11.8, "close": 12},
|
|
||||||
("A", 3): {"open": 12, "high": 12, "low": 11.5, "close": 11.5},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
entries = _mask(panel, {("A", 0)})
|
|
||||||
exits = _mask(panel, set())
|
|
||||||
|
|
||||||
result = _engine().simulate_portfolio(
|
|
||||||
panel,
|
|
||||||
entries,
|
|
||||||
exits,
|
|
||||||
MatcherConfig(
|
|
||||||
matching="open_t+1",
|
|
||||||
fees_pct=0,
|
|
||||||
slippage_bps=0,
|
|
||||||
max_positions=1,
|
|
||||||
initial_capital=100_000,
|
|
||||||
trailing_take_profit_activate_pct=0.10,
|
|
||||||
trailing_take_profit_drawdown_pct=0.03,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert len(result.trades) == 1
|
|
||||||
trade = result.trades[0]
|
|
||||||
assert trade.exit_reason == "trailing_take_profit"
|
|
||||||
assert trade.exit_price == 11.7
|
|
||||||
|
|
||||||
|
|
||||||
def test_score_filter_uses_signal_day_score_range():
|
|
||||||
panel = _panel(
|
|
||||||
["A", "B", "C"],
|
|
||||||
days=3,
|
|
||||||
overrides={
|
|
||||||
("A", 0): {"score": 70},
|
|
||||||
("B", 0): {"score": 80},
|
|
||||||
("C", 0): {"score": 90},
|
|
||||||
("A", 1): {"score": 100},
|
|
||||||
("B", 1): {"score": 1},
|
|
||||||
("C", 1): {"score": 1},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
entries = _mask(panel, {("A", 0), ("B", 0), ("C", 0)})
|
|
||||||
exits = _mask(panel, set())
|
|
||||||
|
|
||||||
result = _engine().simulate_portfolio(
|
|
||||||
panel,
|
|
||||||
entries,
|
|
||||||
exits,
|
|
||||||
MatcherConfig(
|
|
||||||
matching="open_t+1",
|
|
||||||
fees_pct=0,
|
|
||||||
slippage_bps=0,
|
|
||||||
max_positions=3,
|
|
||||||
initial_capital=100_000,
|
|
||||||
score_min=71,
|
|
||||||
score_max=85,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert {t.symbol for t in result.trades} == {"B"}
|
|
||||||
assert result.trades[0].entry_score == 80
|
|
||||||
assert result.stats["execution"]["buy_score_filter"] == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_independent_candidates_allow_overlapping_same_symbol_trades():
|
|
||||||
panel = _panel(
|
|
||||||
["A"],
|
|
||||||
days=5,
|
|
||||||
overrides={
|
|
||||||
("A", 0): {"close": 10},
|
|
||||||
("A", 1): {"close": 11},
|
|
||||||
("A", 2): {"close": 12},
|
|
||||||
("A", 3): {"close": 13},
|
|
||||||
("A", 4): {"close": 14},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
entries = _mask(panel, {("A", 0), ("A", 1)})
|
|
||||||
exits = _mask(panel, set())
|
|
||||||
|
|
||||||
result = _engine().simulate_independent_candidates(
|
|
||||||
panel,
|
|
||||||
entries,
|
|
||||||
exits,
|
|
||||||
MatcherConfig(matching="close_t", fees_pct=0, slippage_bps=0, max_hold_days=2),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.stats["full_kind"] == "candidate_execution"
|
|
||||||
assert result.stats["n_candidates"] == 2
|
|
||||||
assert len(result.trades) == 2
|
|
||||||
assert [t.entry_date for t in result.trades] == ["2024-01-01", "2024-01-02"]
|
|
||||||
assert [t.exit_date for t in result.trades] == ["2024-01-03", "2024-01-04"]
|
|
||||||
assert all(t.exit_reason == "max_hold" for t in result.trades)
|
|
||||||
|
|
||||||
|
|
||||||
def test_independent_candidates_apply_stop_loss():
|
|
||||||
panel = _panel(
|
|
||||||
["A"],
|
|
||||||
days=4,
|
|
||||||
overrides={
|
|
||||||
("A", 0): {"close": 10, "low": 10},
|
|
||||||
("A", 1): {"open": 10, "high": 10, "low": 8.9, "close": 9},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
entries = _mask(panel, {("A", 0)})
|
|
||||||
exits = _mask(panel, set())
|
|
||||||
|
|
||||||
result = _engine().simulate_independent_candidates(
|
|
||||||
panel,
|
|
||||||
entries,
|
|
||||||
exits,
|
|
||||||
MatcherConfig(matching="close_t", fees_pct=0, slippage_bps=0, stop_loss_pct=0.1),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert len(result.trades) == 1
|
|
||||||
assert result.trades[0].exit_reason == "stop_loss"
|
|
||||||
assert result.trades[0].exit_price == 9.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_signal_exit_takes_priority_over_max_hold():
|
|
||||||
"""同一日既有卖点信号又到期 → 应按 signal 平仓 (卖点优先于 max_hold 兜底)。"""
|
|
||||||
panel = _panel(
|
|
||||||
["A"],
|
|
||||||
days=4,
|
|
||||||
overrides={
|
|
||||||
# day1 次日开盘买入 (open_t+1), 价 10
|
|
||||||
("A", 1): {"open": 10, "high": 10, "low": 10, "close": 10},
|
|
||||||
# day2 持有 (hold_days 计到 1)
|
|
||||||
("A", 2): {"open": 11, "high": 11, "low": 11, "close": 11},
|
|
||||||
# day3: 既到期 (hold_days=2 >= max_hold_days=2) 又有卖点信号 → signal 优先
|
|
||||||
("A", 3): {"open": 12, "high": 12, "low": 12, "close": 12},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
entries = _mask(panel, {("A", 0)}) # day0 收盘确认 → day1 开盘买
|
|
||||||
exits = _mask(panel, {("A", 2)}) # day2 收盘确认卖点 → day3 开盘卖
|
|
||||||
|
|
||||||
result = _engine().simulate_portfolio(
|
|
||||||
panel,
|
|
||||||
entries,
|
|
||||||
exits,
|
|
||||||
MatcherConfig(
|
|
||||||
matching="open_t+1",
|
|
||||||
fees_pct=0,
|
|
||||||
slippage_bps=0,
|
|
||||||
max_positions=1,
|
|
||||||
max_hold_days=2,
|
|
||||||
initial_capital=100_000,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert len(result.trades) == 1
|
|
||||||
trade = result.trades[0]
|
|
||||||
assert trade.exit_reason == "signal"
|
|
||||||
assert trade.exit_price == 12.0 # 卖点用 day3 开盘 (exit_fill 跟随 matching=open_t+1)
|
|
||||||
|
|
||||||
|
|
||||||
def test_stop_loss_triggers_even_when_expired_in_open_mode():
|
|
||||||
"""open_t+1 模式下仓位到期且当日破止损 → 应按 stop_loss 平仓 (风控优先于 max_hold)。"""
|
|
||||||
panel = _panel(
|
|
||||||
["A"],
|
|
||||||
days=4,
|
|
||||||
overrides={
|
|
||||||
("A", 1): {"open": 10, "high": 10, "low": 10, "close": 10},
|
|
||||||
# day3 开盘跳空跌破止损 (-10%): open=8.9 < 9.0 止损线, low=8.5
|
|
||||||
("A", 3): {"open": 8.9, "high": 8.9, "low": 8.5, "close": 8.7},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
entries = _mask(panel, {("A", 0)})
|
|
||||||
exits = _mask(panel, set())
|
|
||||||
|
|
||||||
result = _engine().simulate_portfolio(
|
|
||||||
panel,
|
|
||||||
entries,
|
|
||||||
exits,
|
|
||||||
MatcherConfig(
|
|
||||||
matching="open_t+1",
|
|
||||||
fees_pct=0,
|
|
||||||
slippage_bps=0,
|
|
||||||
max_positions=1,
|
|
||||||
max_hold_days=2,
|
|
||||||
stop_loss_pct=0.1,
|
|
||||||
initial_capital=100_000,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert len(result.trades) == 1
|
|
||||||
trade = result.trades[0]
|
|
||||||
assert trade.exit_reason == "stop_loss"
|
|
||||||
# 风控盘中触发: 开盘价 8.9 <= 止损线 9.0 → 按开盘价 8.9 成交
|
|
||||||
assert trade.exit_price == 8.9
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_fill_is_buy_open_sell_close():
|
|
||||||
"""拆分口径: 建仓=次日开盘, 清仓=收盘。entry_price 用次日 open, exit_price 用收盘价。"""
|
|
||||||
panel = _panel(
|
|
||||||
["A"],
|
|
||||||
days=4,
|
|
||||||
overrides={
|
|
||||||
# day1: 次日开盘买入, 开盘 10
|
|
||||||
("A", 1): {"open": 10, "high": 10.5, "low": 9.5, "close": 10.2},
|
|
||||||
# day2: 到期 (max_hold_days=1), 收盘卖
|
|
||||||
("A", 2): {"open": 11, "high": 11, "low": 10, "close": 10.8},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
entries = _mask(panel, {("A", 0)}) # day0 收盘确认
|
|
||||||
exits = _mask(panel, set())
|
|
||||||
|
|
||||||
result = _engine().simulate_portfolio(
|
|
||||||
panel,
|
|
||||||
entries,
|
|
||||||
exits,
|
|
||||||
MatcherConfig(
|
|
||||||
entry_fill="open_t+1",
|
|
||||||
exit_fill="close_t",
|
|
||||||
fees_pct=0,
|
|
||||||
slippage_bps=0,
|
|
||||||
max_positions=1,
|
|
||||||
max_hold_days=1,
|
|
||||||
initial_capital=100_000,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert len(result.trades) == 1
|
|
||||||
trade = result.trades[0]
|
|
||||||
assert trade.entry_price == 10.0 # 次日开盘
|
|
||||||
assert trade.exit_price == 10.8 # 到期日收盘
|
|
||||||
assert trade.exit_reason == "max_hold"
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
"""全量模拟 (full mode) 尾部执行回归测试。"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date, timedelta
|
|
||||||
|
|
||||||
import polars as pl
|
|
||||||
|
|
||||||
from app.backtest.engine import BacktestEngine, MatcherConfig
|
|
||||||
|
|
||||||
|
|
||||||
def _panel_with_tail(symbols: list[str], n_data_days: int) -> pl.DataFrame:
|
|
||||||
start = date(2024, 1, 1)
|
|
||||||
rows = []
|
|
||||||
for sym in symbols:
|
|
||||||
for i in range(n_data_days):
|
|
||||||
px = 10.0 + i
|
|
||||||
rows.append({
|
|
||||||
"symbol": sym,
|
|
||||||
"date": start + timedelta(days=i),
|
|
||||||
"open": px,
|
|
||||||
"high": px,
|
|
||||||
"low": px,
|
|
||||||
"close": px,
|
|
||||||
"volume": 100_000,
|
|
||||||
"signal_limit_up": False,
|
|
||||||
"signal_limit_down": False,
|
|
||||||
})
|
|
||||||
return pl.DataFrame(rows).sort(["symbol", "date"])
|
|
||||||
|
|
||||||
|
|
||||||
def test_full_simulation_executes_signal_at_tail():
|
|
||||||
"""信号集中在正式区间最后一天时, tail 数据应允许次日开盘买入并按策略退出。"""
|
|
||||||
n_days = 6
|
|
||||||
panel = _panel_with_tail(["A"], n_days + 3)
|
|
||||||
|
|
||||||
start = date(2024, 1, 1)
|
|
||||||
end = start + timedelta(days=n_days - 1)
|
|
||||||
entry_vals = []
|
|
||||||
for row in panel.select(["symbol", "date"]).iter_rows(named=True):
|
|
||||||
entry_vals.append(row["date"] == end)
|
|
||||||
entry_mask = pl.Series(entry_vals, dtype=pl.Boolean)
|
|
||||||
exit_mask = pl.Series([False] * len(panel), dtype=pl.Boolean)
|
|
||||||
|
|
||||||
result = BacktestEngine(repo=None).simulate_independent_candidates( # type: ignore[arg-type]
|
|
||||||
panel,
|
|
||||||
entry_mask,
|
|
||||||
exit_mask,
|
|
||||||
MatcherConfig(matching="open_t+1", fees_pct=0, slippage_bps=0, max_hold_days=2),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert not result.stats.get("error"), f"unexpected error: {result.stats.get('error')}"
|
|
||||||
assert result.stats.get("full_kind") == "candidate_execution"
|
|
||||||
assert result.stats.get("n_candidates") == 1
|
|
||||||
assert result.stats.get("n_trades") == 1
|
|
||||||
assert len(result.trades) == 1
|
|
||||||
trade = result.trades[0]
|
|
||||||
assert trade.entry_signal_date == str(end)
|
|
||||||
assert trade.entry_date == str(end + timedelta(days=1))
|
|
||||||
assert trade.exit_reason == "max_hold"
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date, timedelta
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import polars as pl
|
|
||||||
|
|
||||||
from app.backtest.engine import BacktestEngine, SimResult
|
|
||||||
from app.backtest.strategy import StrategyBacktestConfig, StrategyBacktestService
|
|
||||||
from app.strategy.engine import StrategyDef
|
|
||||||
|
|
||||||
|
|
||||||
def _strategy(**kwargs) -> StrategyDef:
|
|
||||||
defaults = dict(
|
|
||||||
meta={"id": "test", "name": "test", "scoring": {}, "params": [], "limit": 100},
|
|
||||||
basic_filter={"enabled": True, "amount_min": 100.0},
|
|
||||||
entry_signals=[],
|
|
||||||
exit_signals=[],
|
|
||||||
stop_loss=None,
|
|
||||||
trailing_stop=None,
|
|
||||||
trailing_take_profit_activate=None,
|
|
||||||
trailing_take_profit_drawdown=None,
|
|
||||||
max_hold_days=None,
|
|
||||||
alerts=[],
|
|
||||||
filter_fn=lambda df, params: pl.lit(True),
|
|
||||||
filter_history_fn=None,
|
|
||||||
lookback_days=1,
|
|
||||||
source="custom",
|
|
||||||
file_path=None,
|
|
||||||
)
|
|
||||||
defaults.update(kwargs)
|
|
||||||
return StrategyDef(**defaults)
|
|
||||||
|
|
||||||
|
|
||||||
class _StrategyEngineStub:
|
|
||||||
def __init__(self, strategy: StrategyDef) -> None:
|
|
||||||
self.strategy = strategy
|
|
||||||
|
|
||||||
def get(self, strategy_id: str) -> StrategyDef:
|
|
||||||
return self.strategy
|
|
||||||
|
|
||||||
|
|
||||||
class _RepoStub:
|
|
||||||
def get_index_daily(self, *args, **kwargs) -> pl.DataFrame:
|
|
||||||
return pl.DataFrame()
|
|
||||||
|
|
||||||
|
|
||||||
class _EngineStub:
|
|
||||||
def __init__(self, panel: pl.DataFrame) -> None:
|
|
||||||
self.panel = panel
|
|
||||||
self.repo = _RepoStub()
|
|
||||||
self.load_args = None
|
|
||||||
self.sim_panel: pl.DataFrame | None = None
|
|
||||||
self.sim_entries: pl.Series | None = None
|
|
||||||
|
|
||||||
def load_panel(self, symbols, start: date, end: date) -> pl.DataFrame:
|
|
||||||
self.load_args = (symbols, start, end)
|
|
||||||
return self.panel
|
|
||||||
|
|
||||||
def simulate_portfolio(self, panel, entries, exits, config, progress_cb=None, cancel_event=None) -> SimResult:
|
|
||||||
self.sim_panel = panel
|
|
||||||
self.sim_entries = entries
|
|
||||||
return SimResult(
|
|
||||||
equity_curve=[{"date": "2024-01-01", "value": config.initial_capital}],
|
|
||||||
drawdown_curve=[{"date": "2024-01-01", "value": 0.0}],
|
|
||||||
trades=[],
|
|
||||||
per_symbol_stats=[],
|
|
||||||
stats={"total_return": 0.0, "n_trades": 0},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_basic_filter_only_limits_entries_not_panel_rows():
|
|
||||||
start = date(2024, 1, 1)
|
|
||||||
rows = []
|
|
||||||
for i, amount in enumerate([1000.0, 0.0, 1000.0]):
|
|
||||||
rows.append({
|
|
||||||
"symbol": "A",
|
|
||||||
"name": "A",
|
|
||||||
"date": start + timedelta(days=i),
|
|
||||||
"open": 10.0 + i,
|
|
||||||
"high": 10.0 + i,
|
|
||||||
"low": 10.0 + i,
|
|
||||||
"close": 10.0 + i,
|
|
||||||
"volume": 100_000,
|
|
||||||
"amount": amount,
|
|
||||||
"signal_limit_up": False,
|
|
||||||
"signal_limit_down": False,
|
|
||||||
})
|
|
||||||
panel = pl.DataFrame(rows).sort(["symbol", "date"])
|
|
||||||
engine = _EngineStub(panel)
|
|
||||||
service = StrategyBacktestService(engine=engine, strategy_engine=_StrategyEngineStub(_strategy()))
|
|
||||||
|
|
||||||
result = service.run(StrategyBacktestConfig(
|
|
||||||
strategy_id="test",
|
|
||||||
symbols=None,
|
|
||||||
start=start,
|
|
||||||
end=start + timedelta(days=2),
|
|
||||||
matching="close_t",
|
|
||||||
mode="position",
|
|
||||||
))
|
|
||||||
|
|
||||||
assert result.error is None
|
|
||||||
assert engine.sim_panel is not None
|
|
||||||
assert engine.sim_panel.height == 3
|
|
||||||
assert engine.sim_panel.filter(pl.col("amount") == 0.0).height == 1
|
|
||||||
assert engine.sim_entries is not None
|
|
||||||
assert engine.sim_entries.to_list() == [True, False, True]
|
|
||||||
assert engine.load_args is not None
|
|
||||||
assert engine.load_args[1] < start # warmup 只用于计算, 不参与正式交易
|
|
||||||
|
|
||||||
|
|
||||||
def test_score_normalizes_inside_strategy_candidate_universe():
|
|
||||||
panel = pl.DataFrame({
|
|
||||||
"symbol": ["A", "B", "C"],
|
|
||||||
"date": [date(2024, 1, 1)] * 3,
|
|
||||||
"factor": [10.0, 20.0, 1000.0],
|
|
||||||
})
|
|
||||||
universe = pl.Series([True, True, False], dtype=pl.Boolean)
|
|
||||||
strategy = SimpleNamespace(meta={"scoring": {"factor": 1.0}, "order_by": "score", "descending": True})
|
|
||||||
|
|
||||||
scored = StrategyBacktestService._apply_score(panel, strategy, None, universe_mask=universe)
|
|
||||||
scores = dict(zip(scored["symbol"].to_list(), scored["score"].to_list()))
|
|
||||||
|
|
||||||
assert scores["A"] == 0.0
|
|
||||||
assert scores["B"] == 100.0
|
|
||||||
assert scores["C"] == 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_full_mode_executes_every_candidate_with_strategy_rules():
|
|
||||||
start = date(2024, 1, 1)
|
|
||||||
panel = pl.DataFrame([
|
|
||||||
{"symbol": "A", "name": "A", "date": start, "open": 10.0, "high": 10.0, "low": 10.0, "close": 10.0, "volume": 1, "amount": 1000.0, "signal_limit_up": False, "signal_limit_down": False},
|
|
||||||
{"symbol": "A", "name": "A", "date": start + timedelta(days=1), "open": 11.0, "high": 11.0, "low": 11.0, "close": 11.0, "volume": 1, "amount": 0.0, "signal_limit_up": False, "signal_limit_down": False},
|
|
||||||
{"symbol": "A", "name": "A", "date": start + timedelta(days=2), "open": 20.0, "high": 20.0, "low": 20.0, "close": 20.0, "volume": 1, "amount": 1000.0, "signal_limit_up": False, "signal_limit_down": False},
|
|
||||||
]).sort(["symbol", "date"])
|
|
||||||
|
|
||||||
engine = BacktestEngine(repo=None) # type: ignore[arg-type]
|
|
||||||
engine.load_panel = lambda symbols, s, e: panel # type: ignore[method-assign]
|
|
||||||
strategy = _strategy(
|
|
||||||
filter_fn=lambda df, params: pl.col("date") == start,
|
|
||||||
max_hold_days=1,
|
|
||||||
)
|
|
||||||
service = StrategyBacktestService(engine=engine, strategy_engine=_StrategyEngineStub(strategy))
|
|
||||||
|
|
||||||
result = service.run(StrategyBacktestConfig(
|
|
||||||
strategy_id="test",
|
|
||||||
symbols=None,
|
|
||||||
start=start,
|
|
||||||
end=start,
|
|
||||||
mode="full",
|
|
||||||
matching="open_t+1",
|
|
||||||
fees_pct=0,
|
|
||||||
slippage_bps=0,
|
|
||||||
holding_days=1,
|
|
||||||
))
|
|
||||||
|
|
||||||
assert result.error is None
|
|
||||||
assert result.stats["full_kind"] == "candidate_execution"
|
|
||||||
assert result.stats["n_candidates"] == 1
|
|
||||||
assert result.stats["n_trades"] == 1
|
|
||||||
assert result.trades[0]["entry_date"] == str(start + timedelta(days=1))
|
|
||||||
assert result.trades[0]["exit_reason"] == "max_hold"
|
|
||||||
assert result.stats["avg_return"] == round(20 / 11 - 1, 4)
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ListColumnCustomizer } from '@/components/ListColumnCustomizer'
|
import { ListColumnCustomizer } from '@/components/ListColumnCustomizer'
|
||||||
import { COLUMN_GROUPS, type ColumnConfig } from '@/lib/watchlist-columns'
|
import type { ColumnConfig } from '@/lib/list-columns'
|
||||||
|
|
||||||
interface ColumnCustomizerProps {
|
interface ColumnCustomizerProps {
|
||||||
columns: ColumnConfig[]
|
columns: ColumnConfig[]
|
||||||
@@ -12,7 +12,7 @@ export function ColumnCustomizer({ columns, onChange, open, onClose }: ColumnCus
|
|||||||
return (
|
return (
|
||||||
<ListColumnCustomizer
|
<ListColumnCustomizer
|
||||||
columns={columns}
|
columns={columns}
|
||||||
groups={COLUMN_GROUPS}
|
groups={[]}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { NavLink, Outlet } from 'react-router-dom'
|
||||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { useQuoteStream } from '@/lib/useQuoteStream'
|
|
||||||
import { ToastContainer } from '@/components/Toast'
|
import { ToastContainer } from '@/components/Toast'
|
||||||
import { AlertToastContainer } from '@/components/AlertToast'
|
import { AlertToastContainer } from '@/components/AlertToast'
|
||||||
import { AiAnalysisHost } from '@/components/financials/AiAnalysisHost'
|
import { AiAnalysisHost } from '@/components/financials/AiAnalysisHost'
|
||||||
@@ -13,23 +11,14 @@ import {
|
|||||||
useCapabilities,
|
useCapabilities,
|
||||||
useSettings,
|
useSettings,
|
||||||
usePreferences,
|
usePreferences,
|
||||||
useQuoteStatus,
|
|
||||||
useVersion,
|
useVersion,
|
||||||
} from '@/lib/useSharedQueries'
|
} from '@/lib/useSharedQueries'
|
||||||
import {
|
|
||||||
useToggleRealtimeQuotes,
|
|
||||||
} from '@/lib/useSharedMutations'
|
|
||||||
import { QK } from '@/lib/queryKeys'
|
import { QK } from '@/lib/queryKeys'
|
||||||
import { tierRank } from '@/lib/capability-labels'
|
|
||||||
import {
|
import {
|
||||||
Star,
|
|
||||||
ScanSearch,
|
ScanSearch,
|
||||||
History,
|
|
||||||
FileText,
|
FileText,
|
||||||
Settings,
|
Settings,
|
||||||
Key,
|
Key,
|
||||||
Database,
|
|
||||||
Loader2,
|
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Tags,
|
Tags,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
@@ -38,110 +27,27 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
Layers3,
|
Layers3,
|
||||||
Landmark,
|
Landmark,
|
||||||
Cable,
|
|
||||||
RadioTower,
|
|
||||||
CheckCircle2,
|
|
||||||
BookOpenCheck,
|
BookOpenCheck,
|
||||||
ExternalLink,
|
|
||||||
X,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Logo } from './Logo'
|
import { Logo } from './Logo'
|
||||||
import { api, type IndexQuote } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { cn } from '@/lib/cn'
|
import { cn } from '@/lib/cn'
|
||||||
import { setCurrentTotal as setAlertTotal, useUnreadAlerts } from '@/lib/monitorBadge'
|
|
||||||
|
|
||||||
// 品牌色 — 只用于 logo / brand 区域,不影响功能语义色
|
// 品牌色 — 只用于 logo / brand 区域,不影响功能语义色
|
||||||
const BRAND = '#8B5CF6'
|
const BRAND = '#8B5CF6'
|
||||||
const TICKFLOW_REGISTER_URL = 'https://tickflow.org/auth/register?ref=V3KDKGXPEA'
|
|
||||||
|
|
||||||
const CORE_INDEXES = [
|
|
||||||
{ symbol: '000001.SH', name: '上证指数' },
|
|
||||||
{ symbol: '399001.SZ', name: '深证成指' },
|
|
||||||
{ symbol: '399006.SZ', name: '创业板指' },
|
|
||||||
{ symbol: '000680.SH', name: '科创综指' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
type CoreIndex = (typeof CORE_INDEXES)[number]
|
|
||||||
|
|
||||||
const nav = [
|
const nav = [
|
||||||
{ to: '/', label: '看板', icon: LayoutDashboard },
|
{ to: '/', label: '看板', icon: LayoutDashboard },
|
||||||
{ to: '/watchlist', label: '自选', icon: Star },
|
|
||||||
{ to: '/screener', label: '策略', icon: ScanSearch },
|
{ to: '/screener', label: '策略', icon: ScanSearch },
|
||||||
{ to: '/backtest', label: '回测', icon: History },
|
|
||||||
{ to: '/stock-analysis', label: '个股分析', icon: TrendingUp },
|
{ to: '/stock-analysis', label: '个股分析', icon: TrendingUp },
|
||||||
{ to: '/limit-ladder', label: '连板梯队', icon: Flame },
|
{ to: '/limit-ladder', label: '连板梯队', icon: Flame },
|
||||||
{ to: '/concept-analysis', label: '概念分析', icon: Layers3 },
|
{ to: '/concept-analysis', label: '概念分析', icon: Layers3 },
|
||||||
{ to: '/industry-analysis', label: '行业分析', icon: Landmark },
|
{ to: '/industry-analysis', label: '行业分析', icon: Landmark },
|
||||||
{ to: '/financials', label: '财务分析', icon: FileText },
|
{ to: '/financials', label: '财务分析', icon: FileText },
|
||||||
{ to: '/monitor', label: '监控中心', icon: RadioTower },
|
|
||||||
{ to: '/review', label: '复盘', icon: BookOpenCheck },
|
{ to: '/review', label: '复盘', icon: BookOpenCheck },
|
||||||
{ to: '/indices', label: '指数', icon: BarChart3 },
|
{ to: '/indices', label: '指数', icon: BarChart3 },
|
||||||
{ to: '/trading', label: '交易', icon: Cable },
|
|
||||||
{ to: '/data', label: '数据', icon: Database },
|
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
function fmtIndexValue(v: number | null | undefined) {
|
|
||||||
if (v == null || Number.isNaN(Number(v))) return '--'
|
|
||||||
return Number(v).toFixed(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtIndexPct(v: number | null | undefined) {
|
|
||||||
if (v == null || Number.isNaN(Number(v))) return '--'
|
|
||||||
return `${Number(v) >= 0 ? '+' : ''}${Number(v).toFixed(2)}%`
|
|
||||||
}
|
|
||||||
|
|
||||||
function indexPctClass(v: number | null | undefined) {
|
|
||||||
if (v == null || Number.isNaN(Number(v))) return 'text-muted'
|
|
||||||
const n = Number(v)
|
|
||||||
if (n === 0) return 'text-foreground'
|
|
||||||
return n > 0 ? 'text-bull' : 'text-bear'
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 监控中心未读徽标 — 仅在非监控页且有未读时显示。 */
|
|
||||||
function MonitorBadge({ active }: { active: boolean }) {
|
|
||||||
const unread = useUnreadAlerts()
|
|
||||||
// 尊重用户设置: 可在菜单设置里关闭数字提示
|
|
||||||
const badgeEnabled = (() => {
|
|
||||||
try { return localStorage.getItem('monitor_badge_enabled') !== '0' } catch { return true }
|
|
||||||
})()
|
|
||||||
if (active || unread <= 0 || !badgeEnabled) return null
|
|
||||||
return (
|
|
||||||
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-danger px-1 text-[9px] font-bold text-white animate-pulse">
|
|
||||||
{unread > 99 ? '99+' : unread}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SidebarIndexQuotes({ rows, items }: { rows: IndexQuote[] | undefined; items: CoreIndex[] }) {
|
|
||||||
if (items.length === 0) return null
|
|
||||||
const quoteBySymbol = new Map((rows ?? []).map(q => [q.symbol, q]))
|
|
||||||
return (
|
|
||||||
<div className="mt-2 grid grid-cols-2 gap-1.5">
|
|
||||||
{items.map(item => {
|
|
||||||
const q = quoteBySymbol.get(item.symbol)
|
|
||||||
const value = q?.last_price ?? q?.close
|
|
||||||
const pct = q?.change_pct
|
|
||||||
return (
|
|
||||||
<NavLink
|
|
||||||
key={item.symbol}
|
|
||||||
to={`/indices?symbol=${encodeURIComponent(item.symbol)}`}
|
|
||||||
className="block rounded bg-elevated/60 px-2 py-1.5 transition-colors hover:bg-elevated"
|
|
||||||
title={`${item.name} ${item.symbol}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-1">
|
|
||||||
<span className="text-[10px] text-secondary">{item.name}</span>
|
|
||||||
<span className={`text-[10px] font-mono ${indexPctClass(pct)}`}>{fmtIndexPct(pct)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-0.5 truncate font-mono text-[10px] text-foreground/80">
|
|
||||||
{fmtIndexValue(value)}
|
|
||||||
</div>
|
|
||||||
</NavLink>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 档位卡片 =====
|
// ===== 档位卡片 =====
|
||||||
function TierBadge({ label, hasKey }: { label: string; hasKey?: boolean }) {
|
function TierBadge({ label, hasKey }: { label: string; hasKey?: boolean }) {
|
||||||
const base = label.split(' ')[0].split('+')[0].toLowerCase()
|
const base = label.split(' ')[0].split('+')[0].toLowerCase()
|
||||||
@@ -262,79 +168,12 @@ export function Layout() {
|
|||||||
const { data: settingsState } = useSettings()
|
const { data: settingsState } = useSettings()
|
||||||
const { data: versionData } = useVersion()
|
const { data: versionData } = useVersion()
|
||||||
const { data: prefs } = usePreferences()
|
const { data: prefs } = usePreferences()
|
||||||
// poll=true: 全局唯一开启条件轮询 (非交易时段 60s 兜底, 交易时段靠 SSE)
|
|
||||||
const { data: quoteStatus } = useQuoteStatus({ poll: true })
|
|
||||||
const { data: analysisMenus } = useQuery({
|
const { data: analysisMenus } = useQuery({
|
||||||
queryKey: QK.analysisMenus,
|
queryKey: QK.analysisMenus,
|
||||||
queryFn: api.analysisMenus,
|
queryFn: api.analysisMenus,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 数据同步状态轮询: 有活跃 job 时「数据」菜单项显示转圈
|
|
||||||
const { data: pipelineJobs } = useQuery({
|
|
||||||
queryKey: QK.pipelineJobs,
|
|
||||||
queryFn: () => api.pipelineJobs(1),
|
|
||||||
refetchInterval: (query) => (query.state.data?.active_id ? 2000 : 15000),
|
|
||||||
refetchIntervalInBackground: true,
|
|
||||||
})
|
|
||||||
const isDataSyncing = !!pipelineJobs?.active_id
|
|
||||||
|
|
||||||
// 数据同步完成的"瞬时反馈": isDataSyncing 从 true→false 时显示绿色对勾,
|
|
||||||
// 闪烁约 3 秒后自动消失。
|
|
||||||
const [dataSyncJustDone, setDataSyncJustDone] = useState(false)
|
|
||||||
const prevSyncingRef = useRef(false)
|
|
||||||
useEffect(() => {
|
|
||||||
// 仅在"刚结束"(true→false)且非首次挂载时触发
|
|
||||||
if (prevSyncingRef.current && !isDataSyncing) {
|
|
||||||
setDataSyncJustDone(true)
|
|
||||||
const t = setTimeout(() => setDataSyncJustDone(false), 3000)
|
|
||||||
prevSyncingRef.current = isDataSyncing
|
|
||||||
return () => clearTimeout(t)
|
|
||||||
}
|
|
||||||
prevSyncingRef.current = isDataSyncing
|
|
||||||
}, [isDataSyncing])
|
|
||||||
|
|
||||||
const qc = useQueryClient()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const version = versionData?.version
|
const version = versionData?.version
|
||||||
const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false
|
|
||||||
// Free 档监控限制提示: 可手动关闭, 不持久化 (刷新后恢复显示)
|
|
||||||
const [dismissFreeHint, setDismissFreeHint] = useState(false)
|
|
||||||
const indicesPinned = prefs?.indices_nav_pinned ?? true
|
|
||||||
const sidebarIndexSymbols = prefs?.sidebar_index_symbols ?? CORE_INDEXES.map(p => p.symbol)
|
|
||||||
const sidebarIndexes = CORE_INDEXES.filter(item => sidebarIndexSymbols.includes(item.symbol))
|
|
||||||
// 卡片数据:固定显示时也拉取(即使实时行情关闭)
|
|
||||||
const showSidebarQuotes = indicesPinned || realtimeEnabled
|
|
||||||
const { data: sidebarIndexQuotes } = useQuery({
|
|
||||||
queryKey: [...QK.indexQuotes, 'sidebar', sidebarIndexSymbols.join(',')] as const,
|
|
||||||
queryFn: () => api.indexQuotes(sidebarIndexes.map(p => p.symbol)),
|
|
||||||
enabled: showSidebarQuotes && sidebarIndexes.length > 0,
|
|
||||||
placeholderData: (prev) => prev,
|
|
||||||
})
|
|
||||||
|
|
||||||
// SSE: 行情更新时自动刷新相关 queries + 告警通知
|
|
||||||
useQuoteStream(realtimeEnabled, prefs?.sse_refresh_pages)
|
|
||||||
|
|
||||||
const toggleQuote = useToggleRealtimeQuotes()
|
|
||||||
const isRunning = quoteStatus?.running ?? false
|
|
||||||
const isTrading = quoteStatus?.is_trading_hours ?? false
|
|
||||||
const tier = tierRank(caps?.label ?? '')
|
|
||||||
const isNoneTier = tier < 0
|
|
||||||
const isWatchlistMode = tier === 0
|
|
||||||
const realtimeModeLabel = isWatchlistMode ? '自选股' : '全市场'
|
|
||||||
|
|
||||||
// 轮询触发记录总数 → 更新监控中心徽标 (每 15 秒)
|
|
||||||
const alertsTotalQuery = useQuery({
|
|
||||||
queryKey: ['alerts-total'],
|
|
||||||
queryFn: () => api.alertsList({ days: 7, limit: 1 }),
|
|
||||||
refetchInterval: 15000,
|
|
||||||
refetchIntervalInBackground: true,
|
|
||||||
select: (data) => data.total,
|
|
||||||
})
|
|
||||||
// 只在拿到真实总数时同步徽标 (避免 data=undefined 时传 0 重置 lastSeen)
|
|
||||||
const alertsTotal = alertsTotalQuery.data
|
|
||||||
useEffect(() => {
|
|
||||||
if (alertsTotal != null) setAlertTotal(alertsTotal)
|
|
||||||
}, [alertsTotal])
|
|
||||||
|
|
||||||
// 合并内置页面 + 可见的扩展分析菜单
|
// 合并内置页面 + 可见的扩展分析菜单
|
||||||
const analysisNav = (analysisMenus?.items ?? [])
|
const analysisNav = (analysisMenus?.items ?? [])
|
||||||
@@ -358,27 +197,6 @@ export function Layout() {
|
|||||||
const hiddenIds = new Set(prefs?.nav_hidden ?? [])
|
const hiddenIds = new Set(prefs?.nav_hidden ?? [])
|
||||||
const visibleNavItems = navItems.filter(n => !hiddenIds.has(n.to) && !hiddenIds.has(n.to.replace(/^\/analysis\//, '')))
|
const visibleNavItems = navItems.filter(n => !hiddenIds.has(n.to) && !hiddenIds.has(n.to.replace(/^\/analysis\//, '')))
|
||||||
|
|
||||||
const handleToggle = async (enabled: boolean) => {
|
|
||||||
// 开启时重新校验档位
|
|
||||||
if (enabled) {
|
|
||||||
const fresh = await qc.fetchQuery({
|
|
||||||
queryKey: QK.capabilities,
|
|
||||||
queryFn: api.capabilities,
|
|
||||||
})
|
|
||||||
const freshTier = tierRank(fresh.label ?? '')
|
|
||||||
if (freshTier < 0) return
|
|
||||||
if (freshTier === 0 && (prefs?.realtime_watchlist_symbols?.length ?? 0) === 0) {
|
|
||||||
navigate('/watchlist')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await toggleQuote.mutateAsync(enabled)
|
|
||||||
// 仅在交易时段立即获取一次行情
|
|
||||||
if (enabled && isTrading) {
|
|
||||||
api.intradayRefresh().catch(() => {})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen grid grid-cols-[14rem_1fr] bg-base text-foreground overflow-hidden">
|
<div className="h-screen grid grid-cols-[14rem_1fr] bg-base text-foreground overflow-hidden">
|
||||||
<aside className="border-r border-border bg-surface flex flex-col h-full min-h-0 overflow-hidden">
|
<aside className="border-r border-border bg-surface flex flex-col h-full min-h-0 overflow-hidden">
|
||||||
@@ -432,7 +250,7 @@ export function Layout() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{({ isActive }) => (
|
{() => (
|
||||||
<>
|
<>
|
||||||
<Icon className="h-4 w-4 shrink-0" />
|
<Icon className="h-4 w-4 shrink-0" />
|
||||||
<span className="flex-1">{label}</span>
|
<span className="flex-1">{label}</span>
|
||||||
@@ -442,110 +260,12 @@ export function Layout() {
|
|||||||
Beta
|
Beta
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{/* 数据同步状态: 同步中转圈, 刚完成显示绿色对勾闪烁 3 秒 */}
|
|
||||||
{to === '/data' && isDataSyncing && (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-accent" />
|
|
||||||
)}
|
|
||||||
{to === '/data' && !isDataSyncing && dataSyncJustDone && (
|
|
||||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-bull animate-pulse" />
|
|
||||||
)}
|
|
||||||
{/* 监控中心徽标: 仅非监控页且有未读时显示 */}
|
|
||||||
{to === '/monitor' && <MonitorBadge active={isActive} />}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* 全局行情开关 */}
|
|
||||||
<div className="border-t border-border px-3 py-2.5 shrink-0">
|
|
||||||
{isNoneTier ? (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-xs text-secondary truncate">实时行情</span>
|
|
||||||
<span className="text-[10px] text-accent/70 font-medium bg-accent/10 px-1.5 py-0.5 rounded">
|
|
||||||
Free+
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1.5 text-[10px] leading-snug text-muted">
|
|
||||||
免费注册
|
|
||||||
<a
|
|
||||||
href={TICKFLOW_REGISTER_URL}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="mx-1 inline-flex items-baseline gap-0.5 text-accent/80 hover:text-accent hover:underline"
|
|
||||||
>
|
|
||||||
TickFlow
|
|
||||||
<ExternalLink className="h-2.5 w-2.5 self-center" />
|
|
||||||
</a>
|
|
||||||
开启个股监控
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* Starter+ — 开关 + 跳转设置 */
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<span className={`inline-block h-1.5 w-1.5 rounded-full shrink-0 ${
|
|
||||||
realtimeEnabled && isRunning && isTrading
|
|
||||||
? 'bg-accent animate-pulse'
|
|
||||||
: realtimeEnabled
|
|
||||||
? 'bg-warning/60'
|
|
||||||
: 'bg-muted'
|
|
||||||
}`} />
|
|
||||||
<span className="text-xs text-secondary truncate">
|
|
||||||
实时行情 · {realtimeModeLabel}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => navigate('/settings?tab=monitoring')}
|
|
||||||
className="text-secondary hover:text-foreground transition-colors shrink-0"
|
|
||||||
title="实时监控设置"
|
|
||||||
>
|
|
||||||
<Settings className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleToggle(!realtimeEnabled)}
|
|
||||||
disabled={toggleQuote.isPending}
|
|
||||||
className={`relative inline-flex h-4 w-7 items-center rounded-full shrink-0 transition-colors duration-200 ${
|
|
||||||
realtimeEnabled
|
|
||||||
? 'bg-accent shadow-[0_0_6px_rgba(59,130,246,0.3)]'
|
|
||||||
: 'bg-elevated'
|
|
||||||
} ${toggleQuote.isPending ? 'opacity-50' : 'cursor-pointer'}`}
|
|
||||||
>
|
|
||||||
<span className={`inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform duration-200 ${
|
|
||||||
realtimeEnabled ? 'translate-x-[14px]' : 'translate-x-0.5'
|
|
||||||
}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 状态提示 */}
|
|
||||||
{realtimeEnabled && !isNoneTier && (
|
|
||||||
<div className="mt-1.5 text-[10px] leading-snug space-y-0.5">
|
|
||||||
{isWatchlistMode && !dismissFreeHint && (
|
|
||||||
<div className="flex items-start gap-1 text-amber-400/80">
|
|
||||||
<span className="flex-1">监控自选股前 5 只,全市场监控需 Starter+</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setDismissFreeHint(true)}
|
|
||||||
className="text-amber-400/50 hover:text-amber-400 shrink-0 transition-colors"
|
|
||||||
title="关闭提示"
|
|
||||||
>
|
|
||||||
<X className="h-2.5 w-2.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isRunning && isTrading ? (
|
|
||||||
<div className="text-accent">行情运行中</div>
|
|
||||||
) : realtimeEnabled && !isTrading ? (
|
|
||||||
<div className="text-warning/70">非交易时段,将在交易时间自动开启</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{showSidebarQuotes && !isWatchlistMode && !isNoneTier && (
|
|
||||||
<SidebarIndexQuotes rows={sidebarIndexQuotes?.rows} items={sidebarIndexes} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-border px-2 py-3 space-y-0.5 shrink-0">
|
<div className="border-t border-border px-2 py-3 space-y-0.5 shrink-0">
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/settings"
|
to="/settings"
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { X, RefreshCw, Clock } from 'lucide-react'
|
import { X, RefreshCw, Clock } from 'lucide-react'
|
||||||
import { api } from '@/lib/api'
|
|
||||||
import { QK } from '@/lib/queryKeys'
|
|
||||||
import { cnSignal } from '@/lib/signals'
|
import { cnSignal } from '@/lib/signals'
|
||||||
import { StockPanel, getDefaultRange } from '@/components/StockPanel'
|
import { StockPanel, getDefaultRange } from '@/components/StockPanel'
|
||||||
import { DatePicker } from '@/components/DatePicker'
|
import { DatePicker } from '@/components/DatePicker'
|
||||||
@@ -44,21 +42,6 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
|
|||||||
const [showMonitorEditor, setShowMonitorEditor] = useState(false)
|
const [showMonitorEditor, setShowMonitorEditor] = useState(false)
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
||||||
const watchlist = useQuery({
|
|
||||||
queryKey: QK.watchlist,
|
|
||||||
queryFn: api.watchlistList,
|
|
||||||
enabled: !!symbol,
|
|
||||||
})
|
|
||||||
const inWatchlist = (watchlist.data?.symbols ?? []).some((s: any) => s.symbol === symbol)
|
|
||||||
|
|
||||||
const toggleWatchlist = useMutation({
|
|
||||||
mutationFn: () => inWatchlist ? api.watchlistRemove(symbol!) : api.watchlistAdd(symbol!),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: QK.watchlist })
|
|
||||||
qc.invalidateQueries({ queryKey: QK.watchlistEnriched() })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// ESC 关闭
|
// ESC 关闭
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!symbol) return
|
if (!symbol) return
|
||||||
@@ -239,8 +222,6 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
|
|||||||
onSelectDate={() => { if (!showIntraday) setShowIntraday(true) }}
|
onSelectDate={() => { if (!showIntraday) setShowIntraday(true) }}
|
||||||
dateRange={dateRange}
|
dateRange={dateRange}
|
||||||
onMonitor={() => setShowMonitorEditor(true)}
|
onMonitor={() => setShowMonitorEditor(true)}
|
||||||
inWatchlist={inWatchlist}
|
|
||||||
onToggleWatchlist={() => toggleWatchlist.mutate()}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { api } from '@/lib/api'
|
|
||||||
import { QK } from '@/lib/queryKeys'
|
|
||||||
import { usePreferences, useCapabilities } from '@/lib/useSharedQueries'
|
|
||||||
import { isExpertOrAbove } from '@/lib/capability-labels'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 五档盘口 sealed(真假涨停) 配置内容(纯内容, 无外框, 由父级 Card 包裹)。
|
|
||||||
*
|
|
||||||
* - 轮询间隔: Pro 10~120s / Expert 3~300s
|
|
||||||
* - 盘后定版时间: 15:01~18:00, 默认 15:02
|
|
||||||
* - disabled 时(监控关闭)输入框禁用
|
|
||||||
*/
|
|
||||||
// 注: 文件名保留 DepthConfigCard.tsx, 导出 DepthConfigContent(纯内容无外框)
|
|
||||||
export function DepthConfigContent({ disabled }: { disabled?: boolean }) {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
const prefs = usePreferences()
|
|
||||||
const caps = useCapabilities()
|
|
||||||
|
|
||||||
const hasDepth = !!caps.data?.capabilities?.['depth5.batch']
|
|
||||||
const tierLabel = caps.data?.label ?? ''
|
|
||||||
const range = isExpertOrAbove(tierLabel) ? { lo: 3, hi: 300 } : { lo: 10, hi: 120 }
|
|
||||||
|
|
||||||
const interval = prefs.data?.depth_polling_interval ?? 20
|
|
||||||
const finalizeTime = prefs.data?.depth_finalize_time ?? { hour: 15, minute: 2 }
|
|
||||||
|
|
||||||
const [intervalInput, setIntervalInput] = useState(String(Math.round(interval)))
|
|
||||||
const [finalizeHour, setFinalizeHour] = useState(String(finalizeTime.hour))
|
|
||||||
const [finalizeMinute, setFinalizeMinute] = useState(String(finalizeTime.minute))
|
|
||||||
|
|
||||||
useEffect(() => { setIntervalInput(String(Math.round(interval))) }, [interval])
|
|
||||||
useEffect(() => {
|
|
||||||
setFinalizeHour(String(finalizeTime.hour))
|
|
||||||
setFinalizeMinute(String(finalizeTime.minute))
|
|
||||||
}, [finalizeTime.hour, finalizeTime.minute])
|
|
||||||
|
|
||||||
const saveInterval = useMutation({
|
|
||||||
mutationFn: (v: number) => api.updateDepthPollingInterval(v),
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }),
|
|
||||||
})
|
|
||||||
const saveFinalize = useMutation({
|
|
||||||
mutationFn: ({ hour, minute }: { hour: number; minute: number }) =>
|
|
||||||
api.updateDepthFinalizeTime(hour, minute),
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }),
|
|
||||||
})
|
|
||||||
|
|
||||||
// 无能力: 显示升级提示
|
|
||||||
if (!hasDepth) {
|
|
||||||
return (
|
|
||||||
<p className="text-xs text-muted leading-relaxed">
|
|
||||||
真假涨停判定依赖五档盘口实时快照,需 <span className="text-accent">Pro 及以上套餐</span>。
|
|
||||||
升级后连板梯队将自动区分真封板(显示封单量)与假涨停(归入炸板)。
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputCls = `w-16 h-7 bg-elevated border border-border rounded text-xs text-center px-1 focus:outline-none focus:border-accent/50 ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* 盘中轮询间隔 */}
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div className={disabled ? 'opacity-50' : ''}>
|
|
||||||
<div className="text-xs text-secondary">盘中轮询间隔</div>
|
|
||||||
<div className="text-[10px] text-muted">范围 {range.lo}~{range.hi} 秒 · 涨跌停过多时系统自动放慢</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={range.lo}
|
|
||||||
max={range.hi}
|
|
||||||
value={intervalInput}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={e => setIntervalInput(e.target.value)}
|
|
||||||
onBlur={() => {
|
|
||||||
if (disabled) return
|
|
||||||
let v = Number(intervalInput)
|
|
||||||
if (!Number.isFinite(v)) v = range.lo
|
|
||||||
v = Math.max(range.lo, Math.min(range.hi, v))
|
|
||||||
saveInterval.mutate(v)
|
|
||||||
}}
|
|
||||||
className={inputCls}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-muted">秒</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 盘后定版时间 */}
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div className={disabled ? 'opacity-50' : ''}>
|
|
||||||
<div className="text-xs text-secondary">盘后定版时间</div>
|
|
||||||
<div className="text-[10px] text-muted">范围 15:01~18:00 · 收盘后拉取最终盘口定版</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={15}
|
|
||||||
max={18}
|
|
||||||
value={finalizeHour}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={e => setFinalizeHour(e.target.value)}
|
|
||||||
onBlur={() => {
|
|
||||||
if (disabled) return
|
|
||||||
let h = Number(finalizeHour)
|
|
||||||
if (!Number.isFinite(h)) h = 15
|
|
||||||
h = Math.max(15, Math.min(18, h))
|
|
||||||
let m = Number(finalizeMinute)
|
|
||||||
if (!Number.isFinite(m)) m = 2
|
|
||||||
m = Math.max(0, Math.min(59, m))
|
|
||||||
if (h * 60 + m < 15 * 60 + 1) { h = 15; m = 1 }
|
|
||||||
if (h * 60 + m > 18 * 60) { h = 18; m = 0 }
|
|
||||||
saveFinalize.mutate({ hour: h, minute: m })
|
|
||||||
}}
|
|
||||||
className={`w-12 h-7 bg-elevated border border-border rounded text-xs text-center px-1 focus:outline-none focus:border-accent/50 ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-muted">:</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={59}
|
|
||||||
value={finalizeMinute}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={e => setFinalizeMinute(e.target.value)}
|
|
||||||
onBlur={() => {
|
|
||||||
if (disabled) return
|
|
||||||
let h = Number(finalizeHour)
|
|
||||||
if (!Number.isFinite(h)) h = 15
|
|
||||||
h = Math.max(15, Math.min(18, h))
|
|
||||||
let m = Number(finalizeMinute)
|
|
||||||
if (!Number.isFinite(m)) m = 2
|
|
||||||
m = Math.max(0, Math.min(59, m))
|
|
||||||
if (h * 60 + m < 15 * 60 + 1) { h = 15; m = 1 }
|
|
||||||
if (h * 60 + m > 18 * 60) { h = 18; m = 0 }
|
|
||||||
saveFinalize.mutate({ hour: h, minute: m })
|
|
||||||
}}
|
|
||||||
className={`w-12 h-7 bg-elevated border border-border rounded text-xs text-center px-1 focus:outline-none focus:border-accent/50 ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { AnimatePresence, motion } from 'framer-motion'
|
|
||||||
import { Activity, Settings } from 'lucide-react'
|
|
||||||
import { Skeleton } from './Skeleton'
|
|
||||||
|
|
||||||
export function QuoteConfigCard({ enabled, running, isTrading, lastFetchMs, intervalS, intervalMin, intervalMax, loading, onToggle, toggling, showIntervalEdit, onShowIntervalEdit, onIntervalChange }: {
|
|
||||||
enabled: boolean
|
|
||||||
running: boolean
|
|
||||||
isTrading: boolean
|
|
||||||
lastFetchMs: number | null
|
|
||||||
intervalS: number
|
|
||||||
intervalMin: number
|
|
||||||
intervalMax: number
|
|
||||||
loading: boolean
|
|
||||||
onToggle: (enabled: boolean) => void
|
|
||||||
toggling: boolean
|
|
||||||
showIntervalEdit: boolean
|
|
||||||
onShowIntervalEdit: () => void
|
|
||||||
onIntervalChange: (v: number) => void
|
|
||||||
}) {
|
|
||||||
const statusColor = running && isTrading
|
|
||||||
? 'bg-accent shadow-[0_0_6px_rgba(61,214,140,0.5)]'
|
|
||||||
: enabled && running
|
|
||||||
? 'bg-warning/60'
|
|
||||||
: 'bg-muted'
|
|
||||||
|
|
||||||
const statusText = !enabled
|
|
||||||
? '已关闭'
|
|
||||||
: !isTrading
|
|
||||||
? '非交易时段'
|
|
||||||
: running
|
|
||||||
? '行情运行中'
|
|
||||||
: '已停止'
|
|
||||||
|
|
||||||
const lastFetchTime = lastFetchMs
|
|
||||||
? new Date(lastFetchMs).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
||||||
: null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-card border border-border bg-surface p-4 relative">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Activity className="h-4 w-4 text-secondary" />
|
|
||||||
<h3 className="text-sm font-medium text-foreground">实时行情</h3>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => onToggle(!enabled)}
|
|
||||||
disabled={toggling}
|
|
||||||
className={`relative inline-flex h-4 w-7 items-center rounded-full shrink-0 transition-colors duration-200 ${
|
|
||||||
enabled
|
|
||||||
? 'bg-accent shadow-[0_0_6px_rgba(59,130,246,0.3)]'
|
|
||||||
: 'bg-elevated'
|
|
||||||
} ${toggling ? 'opacity-50' : 'cursor-pointer'}`}
|
|
||||||
>
|
|
||||||
<span className={`inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform duration-200 ${
|
|
||||||
enabled ? 'translate-x-[14px]' : 'translate-x-0.5'
|
|
||||||
}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between"><Skeleton w="w-8" /><Skeleton w="w-16" /></div>
|
|
||||||
<div className="flex items-center justify-between"><Skeleton w="w-12" /><Skeleton w="w-20" /></div>
|
|
||||||
<div className="flex items-center justify-between"><Skeleton w="w-10" /><Skeleton w="w-14" /></div>
|
|
||||||
<div className="flex items-center justify-between"><Skeleton w="w-12" /><Skeleton w="w-12" /></div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between text-[11px]">
|
|
||||||
<span className="text-muted">状态</span>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className={`inline-block h-1.5 w-1.5 rounded-full ${statusColor} ${running && isTrading ? 'animate-pulse' : ''}`} />
|
|
||||||
<span className="font-mono text-secondary">{statusText}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-[11px]">
|
|
||||||
<span className="text-muted">交易时段</span>
|
|
||||||
<span className={`font-mono ${isTrading ? 'text-accent' : 'text-muted'}`}>{isTrading ? '交易中' : '休市'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-[11px]">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span className="text-muted">轮询间隔</span>
|
|
||||||
<button
|
|
||||||
onClick={() => onShowIntervalEdit()}
|
|
||||||
className={`p-0.5 rounded hover:bg-elevated transition-colors ${showIntervalEdit ? 'text-accent' : 'text-secondary'}`}
|
|
||||||
title="设置轮询间隔"
|
|
||||||
>
|
|
||||||
<Settings className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<span className="font-mono text-secondary">{intervalS}s</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-[11px]">
|
|
||||||
<span className="text-muted">最后获取</span>
|
|
||||||
<span className="font-mono text-secondary">{lastFetchTime ?? '—'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{showIntervalEdit && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, height: 0 }}
|
|
||||||
animate={{ opacity: 1, height: 'auto' }}
|
|
||||||
exit={{ opacity: 0, height: 0 }}
|
|
||||||
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
|
||||||
className="overflow-hidden"
|
|
||||||
>
|
|
||||||
<IntervalEditor
|
|
||||||
min={intervalMin}
|
|
||||||
max={intervalMax}
|
|
||||||
value={intervalS}
|
|
||||||
onChange={onIntervalChange}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function IntervalEditor({ min, max, value, onChange }: {
|
|
||||||
min: number; max: number; value: number; onChange: (v: number) => void
|
|
||||||
}) {
|
|
||||||
const [draft, setDraft] = useState(value)
|
|
||||||
const clamped = Math.max(min, Math.min(max, draft))
|
|
||||||
const step = min < 1 ? 0.1 : min < 3 ? 0.5 : 1
|
|
||||||
const presets = min <= 3 ? [3, 5, 10, 30, 60] : [5, 10, 15, 30, 60]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-2 pt-2 border-t border-border/50">
|
|
||||||
<div className="text-[10px] text-muted mb-1.5">
|
|
||||||
轮询间隔 <span className="text-muted/60">({min}s ~ {max}s)</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-1 mb-2">
|
|
||||||
{presets.map(p => (
|
|
||||||
<button
|
|
||||||
key={p}
|
|
||||||
onClick={() => { setDraft(p); onChange(p) }}
|
|
||||||
className={`px-1.5 py-0.5 rounded text-[10px] font-mono transition-colors ${
|
|
||||||
Math.abs(clamped - p) < 0.01
|
|
||||||
? 'bg-accent/15 text-accent border border-accent/30'
|
|
||||||
: 'bg-elevated text-secondary hover:text-foreground border border-transparent'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{p}s
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={min} max={max} step={step}
|
|
||||||
value={clamped}
|
|
||||||
onChange={e => { const v = parseFloat(e.target.value); setDraft(v); onChange(v) }}
|
|
||||||
className="flex-1 h-1 accent-accent cursor-pointer"
|
|
||||||
/>
|
|
||||||
<span className="text-[10px] font-mono text-foreground w-8 text-right">
|
|
||||||
{clamped < 1 ? clamped.toFixed(1) : clamped.toFixed(0)}s
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -45,14 +45,14 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
|
|||||||
const options = useQuery({ queryKey: QK.monitorRuleOptions, queryFn: api.monitorRuleOptions })
|
const options = useQuery({ queryKey: QK.monitorRuleOptions, queryFn: api.monitorRuleOptions })
|
||||||
const strategies = useQuery({ queryKey: QK.screenerStrategies, queryFn: api.screenerStrategies })
|
const strategies = useQuery({ queryKey: QK.screenerStrategies, queryFn: api.screenerStrategies })
|
||||||
const { data: prefs } = usePreferences()
|
const { data: prefs } = usePreferences()
|
||||||
const feishuConfigured = !!(prefs?.feishu_webhook_url)
|
const feishuConfigured = !!((prefs as any)?.feishu_webhook_url)
|
||||||
const [editing] = useState(!!rule)
|
const [editing] = useState(!!rule)
|
||||||
// 新建规则: 预填全局「默认推送渠道」(飞书), preset 显式指定时以 preset 为准。
|
// 新建规则: 预填全局「默认推送渠道」(飞书), preset 显式指定时以 preset 为准。
|
||||||
// 编辑规则: 完全沿用规则自身配置, 不受默认值影响。
|
// 编辑规则: 完全沿用规则自身配置, 不受默认值影响。
|
||||||
const [draft, setDraft] = useState<MonitorRule>(
|
const [draft, setDraft] = useState<MonitorRule>(
|
||||||
rule
|
rule
|
||||||
? { ...rule, conditions: rule.conditions.map(c => ({ ...c })) }
|
? { ...rule, conditions: rule.conditions.map(c => ({ ...c })) }
|
||||||
: { ...emptyRule(preset), webhook_enabled: preset?.webhook_enabled ?? !!(prefs?.webhook_enabled_default) },
|
: { ...emptyRule(preset), webhook_enabled: preset?.webhook_enabled ?? !!((prefs as any)?.webhook_enabled_default) },
|
||||||
)
|
)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [symbolQuery, setSymbolQuery] = useState('')
|
const [symbolQuery, setSymbolQuery] = useState('')
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* score、signals、candle、ext 列。其余纯数据列(价格/指标/财务…)交给共享原语。
|
* score、signals、candle、ext 列。其余纯数据列(价格/指标/财务…)交给共享原语。
|
||||||
*/
|
*/
|
||||||
import { useState, type CSSProperties, type ReactNode } from 'react'
|
import { useState, type CSSProperties, type ReactNode } from 'react'
|
||||||
import { Check, Plus, Eye, EyeOff } from 'lucide-react'
|
import { Eye, EyeOff } from 'lucide-react'
|
||||||
import type { KlineRow } from '@/lib/api'
|
import type { KlineRow } from '@/lib/api'
|
||||||
import { fmtPrice } from '@/lib/format'
|
import { fmtPrice } from '@/lib/format'
|
||||||
import type { ColumnConfig } from '@/lib/screener-columns'
|
import type { ColumnConfig } from '@/lib/screener-columns'
|
||||||
@@ -22,10 +22,7 @@ interface ScreenerTableProps {
|
|||||||
strategyIdToName: Record<string, string>
|
strategyIdToName: Record<string, string>
|
||||||
symbolStrategyMap: Map<string, string[]>
|
symbolStrategyMap: Map<string, string[]>
|
||||||
activeStrategy: string | null
|
activeStrategy: string | null
|
||||||
watchlistSet: Set<string>
|
|
||||||
onPreview: (symbol: string, name: string) => void
|
onPreview: (symbol: string, name: string) => void
|
||||||
onToggleWatchlist: (symbol: string, inList: boolean) => void
|
|
||||||
watchlistPending: boolean
|
|
||||||
/** symbol → 日k 数据,仅当启用日k列时传入 */
|
/** symbol → 日k 数据,仅当启用日k列时传入 */
|
||||||
klineData?: Record<string, KlineRow[]>
|
klineData?: Record<string, KlineRow[]>
|
||||||
/** 日k蜡烛图是否显示(表头眼睛开关) */
|
/** 日k蜡烛图是否显示(表头眼睛开关) */
|
||||||
@@ -114,7 +111,7 @@ function renderExtValue(
|
|||||||
|
|
||||||
export function ScreenerTable({
|
export function ScreenerTable({
|
||||||
rows, columns, strategyIdToName, symbolStrategyMap, activeStrategy,
|
rows, columns, strategyIdToName, symbolStrategyMap, activeStrategy,
|
||||||
watchlistSet, onPreview, onToggleWatchlist, watchlistPending, klineData = {},
|
onPreview, klineData = {},
|
||||||
dailyKChartVisible = true, onToggleDailyKChart,
|
dailyKChartVisible = true, onToggleDailyKChart,
|
||||||
sort, onSortToggle,
|
sort, onSortToggle,
|
||||||
}: ScreenerTableProps) {
|
}: ScreenerTableProps) {
|
||||||
@@ -164,7 +161,6 @@ export function ScreenerTable({
|
|||||||
switch (key) {
|
switch (key) {
|
||||||
case 'symbol': {
|
case 'symbol': {
|
||||||
const board = boardTag(r.symbol)
|
const board = boardTag(r.symbol)
|
||||||
const inWatchlist = watchlistSet.has(r.symbol)
|
|
||||||
return (
|
return (
|
||||||
<td key={col.id} className="px-4 py-2">
|
<td key={col.id} className="px-4 py-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -189,25 +185,10 @@ export function ScreenerTable({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{isExpired ? (
|
{isExpired && (
|
||||||
<span className="shrink-0 inline-flex items-center px-1.5 py-px rounded text-[9px] font-medium leading-tight bg-red-500/10 text-red-400/60 border border-red-500/15">
|
<span className="shrink-0 inline-flex items-center px-1.5 py-px rounded text-[9px] font-medium leading-tight bg-red-500/10 text-red-400/60 border border-red-500/15">
|
||||||
失效
|
失效
|
||||||
</span>
|
</span>
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onToggleWatchlist(r.symbol, inWatchlist)}
|
|
||||||
disabled={watchlistPending}
|
|
||||||
className={`shrink-0 inline-flex items-center justify-center w-5 h-5 rounded-full border transition-colors cursor-pointer
|
|
||||||
disabled:opacity-50
|
|
||||||
${inWatchlist
|
|
||||||
? 'border-accent/40 bg-accent/10 text-accent'
|
|
||||||
: 'border-border text-muted hover:border-accent/40 hover:text-accent'
|
|
||||||
}`}
|
|
||||||
title={inWatchlist ? '移出自选' : '加入自选'}
|
|
||||||
>
|
|
||||||
{inWatchlist ? <Check className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -2,25 +2,36 @@
|
|||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { X, Settings2, RotateCcw, Save, ChevronDown, Filter, Star, TrendingUp, Sparkles } from 'lucide-react'
|
import { X, Settings2, RotateCcw, Save, ChevronDown, Filter, Star, TrendingUp, Sparkles } from 'lucide-react'
|
||||||
import { api, type StrategyDetail, type StrategyParamDef } from '@/lib/api'
|
import { api, type StrategyDetail, type StrategyParamDef } from '@/lib/api'
|
||||||
import { BUILTIN_COLUMNS } from '@/lib/watchlist-columns'
|
|
||||||
import { color } from '@/lib/colors'
|
import { color } from '@/lib/colors'
|
||||||
import { SignalPicker } from './SignalPicker'
|
import { SignalPicker } from './SignalPicker'
|
||||||
import { SignalTriggerActions } from '@/components/signals/SignalTriggerActions'
|
import { SignalTriggerActions } from '@/components/signals/SignalTriggerActions'
|
||||||
|
|
||||||
// 内置列名 → 中文标签
|
// 字段名 → 中文标签
|
||||||
const FIELD_LABEL: Record<string, string> = {}
|
const FIELD_LABEL: Record<string, string> = {
|
||||||
for (const c of BUILTIN_COLUMNS) {
|
close: '收盘价', open: '开盘价', high: '最高价', low: '最低价',
|
||||||
if (c.source.type === 'builtin') FIELD_LABEL[c.source.key] = c.label
|
|
||||||
}
|
|
||||||
// enriched 列名别名
|
|
||||||
Object.assign(FIELD_LABEL, {
|
|
||||||
change_pct: '涨跌幅', consecutive_limit_ups: '连板',
|
change_pct: '涨跌幅', consecutive_limit_ups: '连板',
|
||||||
|
momentum_5d: '5D动量', momentum_10d: '10D动量',
|
||||||
|
momentum_20d: '20D动量', momentum_30d: '30D动量',
|
||||||
momentum_60d: '60D动量', turnover_rate: '换手率',
|
momentum_60d: '60D动量', turnover_rate: '换手率',
|
||||||
|
change_amount: '涨跌额', amplitude: '振幅',
|
||||||
|
volume: '成交量', amount: '成交额',
|
||||||
rsi_14: 'RSI14', rsi_6: 'RSI6', rsi_24: 'RSI24',
|
rsi_14: 'RSI14', rsi_6: 'RSI6', rsi_24: 'RSI24',
|
||||||
vol_ratio_5d: '量比', vol_ratio_20d: '20日量比',
|
vol_ratio_5d: '量比', vol_ratio_20d: '20日量比',
|
||||||
|
vol_ratio: '量比',
|
||||||
macd_dif: 'MACD-DIF', macd_dea: 'MACD-DEA', macd_hist: 'MACD柱',
|
macd_dif: 'MACD-DIF', macd_dea: 'MACD-DEA', macd_hist: 'MACD柱',
|
||||||
boll_upper: '布林上轨', boll_lower: '布林下轨',
|
boll_upper: '布林上轨', boll_lower: '布林下轨',
|
||||||
})
|
ma5: 'MA5', ma10: 'MA10', ma20: 'MA20', ma30: 'MA30', ma60: 'MA60',
|
||||||
|
kdj_k: 'KDJ-K', kdj_d: 'KDJ-D', kdj_j: 'KDJ-J',
|
||||||
|
atr14: 'ATR14', annual_vol: '年化波动',
|
||||||
|
high_60d: '60日高', low_60d: '60日低',
|
||||||
|
eps: 'EPS', bps: 'BPS', roe: 'ROE', pe_ttm: 'PE(TTM)', pb: 'PB',
|
||||||
|
gross_margin: '毛利率', net_margin: '净利率',
|
||||||
|
revenue_yoy: '营收增速', net_income_yoy: '净利增速',
|
||||||
|
debt_ratio: '负债率',
|
||||||
|
score: '评分',
|
||||||
|
limit_ups: '连板', limit_downs: '连跌',
|
||||||
|
float_val: '流通值', price: '现价', pct: '涨跌幅', turnover: '换手率',
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
strategyId: string | null
|
strategyId: string | null
|
||||||
|
|||||||
@@ -192,23 +192,6 @@ export interface KlineRow {
|
|||||||
[key: string]: any
|
[key: string]: any
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Watchlist =====
|
|
||||||
export interface WatchlistEntry {
|
|
||||||
symbol: string
|
|
||||||
added_at: string
|
|
||||||
note?: string
|
|
||||||
name?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Quote {
|
|
||||||
symbol: string
|
|
||||||
price?: number
|
|
||||||
pct?: number
|
|
||||||
close?: number
|
|
||||||
change_pct?: number
|
|
||||||
[key: string]: any
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IndexInstrument {
|
export interface IndexInstrument {
|
||||||
symbol: string
|
symbol: string
|
||||||
name?: string | null
|
name?: string | null
|
||||||
@@ -505,111 +488,6 @@ export interface LimitLadderResult {
|
|||||||
sealed_counts_down?: { real: number; fake: number; pending: number }
|
sealed_counts_down?: { real: number; fake: number; pending: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Backtest =====
|
|
||||||
export interface BacktestResult {
|
|
||||||
run_id: string
|
|
||||||
config: any
|
|
||||||
stats: Record<string, any>
|
|
||||||
equity_curve: { date: string; value: number }[]
|
|
||||||
trades: any[]
|
|
||||||
per_symbol_stats: { symbol: string; total_return: number }[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Factor Backtest =====
|
|
||||||
export interface FactorColumn {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
group: string
|
|
||||||
desc: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GroupStat {
|
|
||||||
group: number
|
|
||||||
label: string
|
|
||||||
total_return: number
|
|
||||||
annual_return: number
|
|
||||||
max_drawdown: number
|
|
||||||
sharpe: number
|
|
||||||
win_rate: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FactorBacktestResult {
|
|
||||||
run_id: string
|
|
||||||
config: Record<string, any>
|
|
||||||
ic_mean: number | null
|
|
||||||
ic_std: number | null
|
|
||||||
ir: number | null
|
|
||||||
ic_win_rate: number | null
|
|
||||||
ic_series: { date: string; ic: number }[]
|
|
||||||
group_stats: GroupStat[]
|
|
||||||
group_nav: Record<string, any>[]
|
|
||||||
long_short_stats: Record<string, any>
|
|
||||||
long_short_nav: { date: string; value: number }[]
|
|
||||||
elapsed_ms: number
|
|
||||||
n_symbols: number
|
|
||||||
n_dates: number
|
|
||||||
error: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Strategy Backtest =====
|
|
||||||
export interface StrategyBacktestTrade {
|
|
||||||
symbol: string
|
|
||||||
name?: string
|
|
||||||
entry_date: string
|
|
||||||
exit_date: string
|
|
||||||
entry_price: number
|
|
||||||
exit_price: number
|
|
||||||
pnl_pct: number
|
|
||||||
duration: number
|
|
||||||
exit_reason: string
|
|
||||||
shares?: number
|
|
||||||
lots?: number
|
|
||||||
position_pct?: number
|
|
||||||
entry_value?: number
|
|
||||||
exit_value?: number
|
|
||||||
pnl_amount?: number
|
|
||||||
entry_score?: number | null
|
|
||||||
entry_signal_date?: string | null
|
|
||||||
exit_signal_date?: string | null
|
|
||||||
blocked_exit_days?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StrategyBacktestResult {
|
|
||||||
run_id: string
|
|
||||||
config: Record<string, any>
|
|
||||||
stats: Record<string, any>
|
|
||||||
equity_curve: { date: string; value: number; cash?: number; positions?: number; exposure?: number }[]
|
|
||||||
drawdown_curve: { date: string; value: number }[]
|
|
||||||
benchmark_curve?: { date: string; value: number; close?: number; name?: string; symbol?: string }[]
|
|
||||||
trades: StrategyBacktestTrade[]
|
|
||||||
per_symbol_stats: {
|
|
||||||
symbol: string
|
|
||||||
n_trades: number
|
|
||||||
total_return: number
|
|
||||||
win_rate: number
|
|
||||||
best: number
|
|
||||||
worst: number
|
|
||||||
}[]
|
|
||||||
strategy_info: {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
entry_signals: string[]
|
|
||||||
exit_signals: string[]
|
|
||||||
stop_loss: number | null
|
|
||||||
take_profit: number | null
|
|
||||||
trailing_stop: number | null
|
|
||||||
trailing_take_profit_activate: number | null
|
|
||||||
trailing_take_profit_drawdown: number | null
|
|
||||||
score_min: number | null
|
|
||||||
score_max: number | null
|
|
||||||
max_hold_days: number | null
|
|
||||||
source: string
|
|
||||||
}
|
|
||||||
elapsed_ms: number
|
|
||||||
error: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Settings =====
|
// ===== Settings =====
|
||||||
|
|
||||||
/** 端点发现清单 —— 对应 tickflow.org/endpoints.json */
|
/** 端点发现清单 —— 对应 tickflow.org/endpoints.json */
|
||||||
@@ -669,20 +547,12 @@ export interface SaveTickflowKeyResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Preferences {
|
export interface Preferences {
|
||||||
realtime_quotes_enabled: boolean
|
|
||||||
indices_nav_pinned: boolean
|
indices_nav_pinned: boolean
|
||||||
minute_sync_enabled: boolean
|
minute_sync_enabled: boolean
|
||||||
minute_sync_days: number
|
minute_sync_days: number
|
||||||
daily_data_provider?: string
|
daily_data_provider?: string
|
||||||
adj_factor_provider?: string
|
adj_factor_provider?: string
|
||||||
minute_data_provider?: string
|
minute_data_provider?: string
|
||||||
realtime_data_provider?: string
|
|
||||||
realtime_watchlist_symbols?: string[]
|
|
||||||
realtime_pull_stock?: boolean
|
|
||||||
realtime_pull_etf?: boolean
|
|
||||||
realtime_pull_index?: boolean
|
|
||||||
realtime_index_mode?: 'core' | 'all'
|
|
||||||
realtime_index_symbols?: string[]
|
|
||||||
pipeline_pull_a_share: boolean
|
pipeline_pull_a_share: boolean
|
||||||
pipeline_pull_etf: boolean
|
pipeline_pull_etf: boolean
|
||||||
pipeline_pull_index: boolean
|
pipeline_pull_index: boolean
|
||||||
@@ -691,18 +561,9 @@ export interface Preferences {
|
|||||||
instruments_schedule: { hour: number; minute: number }
|
instruments_schedule: { hour: number; minute: number }
|
||||||
enriched_batch_size: number
|
enriched_batch_size: number
|
||||||
index_daily_batch_size: number
|
index_daily_batch_size: number
|
||||||
limit_ladder_monitor_enabled: boolean
|
|
||||||
depth_polling_interval: number
|
|
||||||
depth_finalize_time: { hour: number; minute: number }
|
|
||||||
review_schedule: { enabled: boolean; hour: number; minute: number }
|
review_schedule: { enabled: boolean; hour: number; minute: number }
|
||||||
review_push_channels: string[]
|
review_push_channels: string[]
|
||||||
sse_refresh_pages: Record<string, boolean>
|
|
||||||
strategy_monitor_enabled: boolean
|
|
||||||
strategy_monitor_ids: string[]
|
|
||||||
system_notify_enabled: boolean
|
system_notify_enabled: boolean
|
||||||
feishu_webhook_url?: string
|
|
||||||
feishu_webhook_secret?: string
|
|
||||||
webhook_enabled_default?: boolean
|
|
||||||
sidebar_index_symbols: string[]
|
sidebar_index_symbols: string[]
|
||||||
nav_order: string[]
|
nav_order: string[]
|
||||||
nav_hidden: string[]
|
nav_hidden: string[]
|
||||||
@@ -793,67 +654,11 @@ export const api = {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ symbols }),
|
body: JSON.stringify({ symbols }),
|
||||||
}),
|
}),
|
||||||
updateRealtimeQuotes: (enabled: boolean) =>
|
|
||||||
request<{ realtime_quotes_enabled: boolean; realtime_allowed?: boolean; mode?: string; error?: string }>('/api/settings/preferences/realtime-quotes', {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({ realtime_quotes_enabled: enabled }),
|
|
||||||
}),
|
|
||||||
updateRealtimeQuoteScope: (cfg: Partial<Pick<Preferences, 'realtime_pull_stock' | 'realtime_pull_etf' | 'realtime_pull_index' | 'realtime_index_mode' | 'realtime_index_symbols'>>) =>
|
|
||||||
request<Partial<Preferences>>('/api/settings/preferences/realtime-quote-scope', {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(cfg),
|
|
||||||
}),
|
|
||||||
updateIndicesNavPinned: (pinned: boolean) =>
|
updateIndicesNavPinned: (pinned: boolean) =>
|
||||||
request<{ indices_nav_pinned: boolean }>('/api/settings/preferences/indices-nav-pinned', {
|
request<{ indices_nav_pinned: boolean }>('/api/settings/preferences/indices-nav-pinned', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ indices_nav_pinned: pinned }),
|
body: JSON.stringify({ indices_nav_pinned: pinned }),
|
||||||
}),
|
}),
|
||||||
quoteStatus: () =>
|
|
||||||
request<{
|
|
||||||
enabled: boolean
|
|
||||||
running: boolean
|
|
||||||
mode?: 'none' | 'watchlist' | 'full_market'
|
|
||||||
realtime_allowed?: boolean
|
|
||||||
interval_s: number
|
|
||||||
symbol_count: number
|
|
||||||
watchlist_symbol_count?: number
|
|
||||||
index_symbol_count?: number
|
|
||||||
etf_symbol_count?: number
|
|
||||||
quote_age_ms: number | null
|
|
||||||
is_trading_hours: boolean
|
|
||||||
last_fetch_ms: number | null
|
|
||||||
}>('/api/intraday/status'),
|
|
||||||
quoteInterval: () =>
|
|
||||||
request<{ interval: number; min_interval: number; max_interval: number }>(
|
|
||||||
'/api/settings/preferences/quote-interval',
|
|
||||||
),
|
|
||||||
updateQuoteInterval: (interval: number) =>
|
|
||||||
request<{ interval: number; min_interval: number; max_interval: number }>(
|
|
||||||
'/api/settings/preferences/quote-interval',
|
|
||||||
{ method: 'PUT', body: JSON.stringify({ interval }) },
|
|
||||||
),
|
|
||||||
intradayRefresh: () => request<{ status: string }>('/api/intraday/refresh', { method: 'POST' }),
|
|
||||||
indexQuotes: (symbols?: string[]) =>
|
|
||||||
request<{ rows: IndexQuote[]; count: number }>(
|
|
||||||
`/api/intraday/indices${symbols?.length ? `?symbols=${encodeURIComponent(symbols.join(','))}` : ''}`,
|
|
||||||
),
|
|
||||||
updateRealtimeMonitorConfig: (cfg: {
|
|
||||||
sse_refresh_pages?: Record<string, boolean>
|
|
||||||
strategy_monitor_enabled?: boolean
|
|
||||||
strategy_monitor_ids?: string[]
|
|
||||||
sidebar_index_symbols?: string[]
|
|
||||||
screener_auto_run?: boolean
|
|
||||||
}) =>
|
|
||||||
request<{
|
|
||||||
sse_refresh_pages: Record<string, boolean>
|
|
||||||
strategy_monitor_enabled: boolean
|
|
||||||
strategy_monitor_ids: string[]
|
|
||||||
sidebar_index_symbols: string[]
|
|
||||||
screener_auto_run: boolean
|
|
||||||
}>('/api/settings/preferences/realtime-monitor', {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(cfg),
|
|
||||||
}),
|
|
||||||
updateSystemNotify: (enabled: boolean) =>
|
updateSystemNotify: (enabled: boolean) =>
|
||||||
request<{ system_notify_enabled: boolean }>('/api/settings/preferences/system-notify', {
|
request<{ system_notify_enabled: boolean }>('/api/settings/preferences/system-notify', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -929,15 +734,6 @@ export const api = {
|
|||||||
body: JSON.stringify({ size }),
|
body: JSON.stringify({ size }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// 自选列表列配置
|
|
||||||
watchlistColumns: () =>
|
|
||||||
request<{ columns: any[] | null }>('/api/settings/preferences/watchlist-columns'),
|
|
||||||
updateWatchlistColumns: (columns: any[]) =>
|
|
||||||
request<{ columns: any[] }>('/api/settings/preferences/watchlist-columns', {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({ columns }),
|
|
||||||
}),
|
|
||||||
|
|
||||||
// 策略结果列表列配置
|
// 策略结果列表列配置
|
||||||
screenerResultColumns: () =>
|
screenerResultColumns: () =>
|
||||||
request<{ columns: any[] | null }>('/api/settings/preferences/screener-result-columns'),
|
request<{ columns: any[] | null }>('/api/settings/preferences/screener-result-columns'),
|
||||||
@@ -1049,37 +845,6 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
watchlistList: () => request<{ symbols: WatchlistEntry[] }>('/api/watchlist'),
|
|
||||||
watchlistAdd: (symbol: string, note = '') =>
|
|
||||||
request<{ symbols: WatchlistEntry[] }>('/api/watchlist', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ symbol, note }),
|
|
||||||
}),
|
|
||||||
watchlistBatchAdd: (symbols: string[], note = '') =>
|
|
||||||
request<{ symbols: WatchlistEntry[]; added: number }>('/api/watchlist/batch', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ symbols, note }),
|
|
||||||
}),
|
|
||||||
watchlistRemove: (symbol: string) =>
|
|
||||||
request<{ symbols: WatchlistEntry[] }>(
|
|
||||||
`/api/watchlist/${encodeURIComponent(symbol)}`,
|
|
||||||
{ method: 'DELETE' },
|
|
||||||
),
|
|
||||||
watchlistMoveToTop: (symbol: string) =>
|
|
||||||
request<{ symbols: WatchlistEntry[] }>(
|
|
||||||
`/api/watchlist/${encodeURIComponent(symbol)}/top`,
|
|
||||||
{ method: 'POST' },
|
|
||||||
),
|
|
||||||
watchlistClear: () =>
|
|
||||||
request<{ removed: number }>('/api/watchlist', { method: 'DELETE' }),
|
|
||||||
watchlistQuotes: () => request<{ quotes: Quote[] }>('/api/watchlist/quotes'),
|
|
||||||
watchlistEnriched: (extColumns?: string) =>
|
|
||||||
request<{ rows: any[]; as_of: string | null; elapsed_ms: number }>(
|
|
||||||
extColumns
|
|
||||||
? `/api/watchlist/enriched?ext_columns=${encodeURIComponent(extColumns)}`
|
|
||||||
: '/api/watchlist/enriched',
|
|
||||||
),
|
|
||||||
|
|
||||||
screenerStrategies: () => request<{ presets: ScreenerStrategy[] }>('/api/screener/strategies'),
|
screenerStrategies: () => request<{ presets: ScreenerStrategy[] }>('/api/screener/strategies'),
|
||||||
screenerRunPreset: (strategy_id: string, pool?: string[], asOf?: string, extColumns?: string) =>
|
screenerRunPreset: (strategy_id: string, pool?: string[], asOf?: string, extColumns?: string) =>
|
||||||
request<ScreenerResult>('/api/screener/run_preset', {
|
request<ScreenerResult>('/api/screener/run_preset', {
|
||||||
@@ -1120,63 +885,6 @@ export const api = {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
backtestStatus: () => request<{ available: boolean }>('/api/backtest/status'),
|
|
||||||
|
|
||||||
backtestRun: (payload: {
|
|
||||||
symbols: string[]
|
|
||||||
entries: string[]
|
|
||||||
exits: string[]
|
|
||||||
start?: string
|
|
||||||
end?: string
|
|
||||||
stop_loss_pct?: number
|
|
||||||
max_hold_days?: number
|
|
||||||
matching?: 'close_t' | 'open_t+1'
|
|
||||||
}) =>
|
|
||||||
request<BacktestResult>('/api/backtest/run', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
}),
|
|
||||||
|
|
||||||
factorColumns: () =>
|
|
||||||
request<{ columns: FactorColumn[] }>('/api/backtest/factor/columns'),
|
|
||||||
|
|
||||||
factorRun: (payload: {
|
|
||||||
factor_name: string
|
|
||||||
symbols?: string[] | null
|
|
||||||
start?: string | null
|
|
||||||
end?: string | null
|
|
||||||
n_groups?: number
|
|
||||||
rebalance?: 'daily' | 'weekly' | 'monthly'
|
|
||||||
weight?: 'equal' | 'factor_weight'
|
|
||||||
fees_pct?: number
|
|
||||||
slippage_bps?: number
|
|
||||||
}) =>
|
|
||||||
request<FactorBacktestResult>('/api/backtest/factor/run', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
}),
|
|
||||||
|
|
||||||
strategyBacktestRun: (payload: {
|
|
||||||
strategy_id: string
|
|
||||||
symbols?: string[] | null
|
|
||||||
start?: string | null
|
|
||||||
end?: string | null
|
|
||||||
params?: Record<string, any> | null
|
|
||||||
overrides?: Record<string, any> | null
|
|
||||||
matching?: 'close_t' | 'open_t+1'
|
|
||||||
entry_fill?: 'close_t' | 'open_t+1' | null
|
|
||||||
exit_fill?: 'close_t' | 'open_t+1' | null
|
|
||||||
fees_pct?: number
|
|
||||||
slippage_bps?: number
|
|
||||||
max_positions?: number
|
|
||||||
initial_capital?: number
|
|
||||||
position_sizing?: 'equal' | 'score_weight'
|
|
||||||
}) =>
|
|
||||||
request<StrategyBacktestResult>('/api/backtest/strategy/run', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
}),
|
|
||||||
|
|
||||||
pipelineRun: () => request<{ job_id: string; reused: boolean }>(
|
pipelineRun: () => request<{ job_id: string; reused: boolean }>(
|
||||||
'/api/pipeline/run', { method: 'POST' },
|
'/api/pipeline/run', { method: 'POST' },
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,227 +0,0 @@
|
|||||||
import { useSyncExternalStore } from 'react'
|
|
||||||
import type { StrategyBacktestResult } from './api'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 全局回测任务管理 (SSE 模式 + 任务缓存 + 重连支持)。
|
|
||||||
*
|
|
||||||
* 特性:
|
|
||||||
* - 实时进度: EventSource 监听后端 SSE, 推送 day/total/equity
|
|
||||||
* - 可取消: POST /strategy/cancel/{job_key}, 后端 cancel_event
|
|
||||||
* - 切页/刷新保持: 后端按参数 hash 缓存任务, 重连不重启
|
|
||||||
* - 切页: 模块级 store 保持, EventSource 随组件卸载断开, 回来后重连
|
|
||||||
* - 刷新: localStorage 存 job 参数, 刷新后重新连接到同一任务
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface BacktestProgress {
|
|
||||||
day: number
|
|
||||||
total: number
|
|
||||||
date: string
|
|
||||||
equity: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BacktestTask {
|
|
||||||
id: number
|
|
||||||
isPending: boolean
|
|
||||||
result: StrategyBacktestResult | null
|
|
||||||
progress: BacktestProgress | null
|
|
||||||
error: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
let current: BacktestTask | null = null
|
|
||||||
const listeners = new Set<() => void>()
|
|
||||||
let taskSeq = 0
|
|
||||||
let eventSource: EventSource | null = null
|
|
||||||
|
|
||||||
const RECONNECT_KEY = 'backtest_reconnect'
|
|
||||||
|
|
||||||
function emit() {
|
|
||||||
listeners.forEach(fn => fn())
|
|
||||||
}
|
|
||||||
|
|
||||||
function subscribe(fn: () => void) {
|
|
||||||
listeners.add(fn)
|
|
||||||
return () => listeners.delete(fn)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSnapshot() {
|
|
||||||
return current
|
|
||||||
}
|
|
||||||
|
|
||||||
function getServerSnapshot() {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 查询字符串构建 */
|
|
||||||
function buildQuery(params: Record<string, string | number | boolean | undefined | null>): string {
|
|
||||||
const sp = new URLSearchParams()
|
|
||||||
for (const [k, v] of Object.entries(params)) {
|
|
||||||
if (v != null && v !== '') sp.set(k, String(v))
|
|
||||||
}
|
|
||||||
return sp.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 连接 SSE (新建或重连都用这个) */
|
|
||||||
function connectSSE(url: string): void {
|
|
||||||
const id = current?.id ?? ++taskSeq
|
|
||||||
|
|
||||||
// 关闭旧连接
|
|
||||||
if (eventSource) {
|
|
||||||
eventSource.close()
|
|
||||||
eventSource = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const es = new EventSource(url)
|
|
||||||
eventSource = es
|
|
||||||
|
|
||||||
es.addEventListener('progress', (e: MessageEvent) => {
|
|
||||||
if (current?.id !== id) return
|
|
||||||
try {
|
|
||||||
const prog = JSON.parse(e.data) as BacktestProgress
|
|
||||||
current = { ...current, progress: prog }
|
|
||||||
emit()
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
})
|
|
||||||
|
|
||||||
es.addEventListener('done', (e: MessageEvent) => {
|
|
||||||
if (current?.id !== id) return
|
|
||||||
try {
|
|
||||||
const result = JSON.parse(e.data) as StrategyBacktestResult
|
|
||||||
current = { ...current, isPending: false, result, error: null }
|
|
||||||
emit()
|
|
||||||
} catch {
|
|
||||||
current = { ...current, isPending: false, error: '结果解析失败' }
|
|
||||||
emit()
|
|
||||||
}
|
|
||||||
es.close()
|
|
||||||
eventSource = null
|
|
||||||
localStorage.removeItem(RECONNECT_KEY)
|
|
||||||
})
|
|
||||||
|
|
||||||
es.addEventListener('error', (e: MessageEvent) => {
|
|
||||||
if (current?.id !== id) return
|
|
||||||
// SSE error 事件: 有 data 说明是后端主动推送的错误/取消; 无 data 说明是连接断开
|
|
||||||
if (e.data) {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(e.data)?.message ?? '回测出错'
|
|
||||||
current = { ...current, isPending: false, error: msg }
|
|
||||||
emit()
|
|
||||||
} catch {
|
|
||||||
current = { ...current, isPending: false, error: '回测出错' }
|
|
||||||
emit()
|
|
||||||
}
|
|
||||||
es.close()
|
|
||||||
eventSource = null
|
|
||||||
localStorage.removeItem(RECONNECT_KEY)
|
|
||||||
}
|
|
||||||
// 无 data: 连接异常断开, EventSource 会自动重连, 不改变状态
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 启动一次 SSE 回测任务 */
|
|
||||||
export function startBacktest(params: {
|
|
||||||
strategy_id: string
|
|
||||||
symbols?: string[] | null
|
|
||||||
start?: string | null
|
|
||||||
end?: string | null
|
|
||||||
matching?: string
|
|
||||||
entry_fill?: string
|
|
||||||
exit_fill?: string
|
|
||||||
fees_pct?: number
|
|
||||||
slippage_bps?: number
|
|
||||||
max_positions?: number
|
|
||||||
max_exposure_pct?: number
|
|
||||||
initial_capital?: number
|
|
||||||
position_sizing?: string
|
|
||||||
params?: Record<string, any> | null
|
|
||||||
overrides?: Record<string, any> | null
|
|
||||||
mode?: 'position' | 'full'
|
|
||||||
holding_days?: number
|
|
||||||
}): void {
|
|
||||||
// 取消之前的任务状态
|
|
||||||
if (eventSource) {
|
|
||||||
eventSource.close()
|
|
||||||
eventSource = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = ++taskSeq
|
|
||||||
current = { id, isPending: true, result: null, progress: null, error: null }
|
|
||||||
emit()
|
|
||||||
|
|
||||||
const qs = buildQuery({
|
|
||||||
strategy_id: params.strategy_id,
|
|
||||||
symbols: params.symbols?.join(','),
|
|
||||||
start: params.start ?? undefined,
|
|
||||||
end: params.end ?? undefined,
|
|
||||||
matching: params.matching,
|
|
||||||
entry_fill: params.entry_fill,
|
|
||||||
exit_fill: params.exit_fill,
|
|
||||||
fees_pct: params.fees_pct,
|
|
||||||
slippage_bps: params.slippage_bps,
|
|
||||||
max_positions: params.max_positions,
|
|
||||||
max_exposure_pct: params.max_exposure_pct,
|
|
||||||
initial_capital: params.initial_capital,
|
|
||||||
position_sizing: params.position_sizing,
|
|
||||||
params: params.params ? JSON.stringify(params.params) : undefined,
|
|
||||||
overrides: params.overrides ? JSON.stringify(params.overrides) : undefined,
|
|
||||||
mode: params.mode,
|
|
||||||
holding_days: params.holding_days,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 存 reconnect 信息 (刷新后用)
|
|
||||||
localStorage.setItem(RECONNECT_KEY, qs)
|
|
||||||
|
|
||||||
connectSSE(`/api/backtest/strategy/stream?${qs}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 停止当前回测任务 (调后端 cancel, 后端 cancel_event → 停止计算) */
|
|
||||||
export async function stopBacktest(): Promise<void> {
|
|
||||||
// 从 reconnect key 提取 job_key (后端按参数 hash 算 job_key)
|
|
||||||
const qs = localStorage.getItem(RECONNECT_KEY)
|
|
||||||
if (qs) {
|
|
||||||
// 解析出参数, 用 fetch 调 cancel
|
|
||||||
try {
|
|
||||||
// job_key 是后端算的 md5, 前端不知道。用 reconnect URL 里的参数重新请求 stream,
|
|
||||||
// 后端会找到同一个 job 并返回它的 job_key? 不行。
|
|
||||||
// 替代: 前端直接关闭 SSE 连接 + 调一个带参数的 cancel 接口。
|
|
||||||
// 简化: 关闭连接即可, 后端检测断开后 (不取消)。需要 cancel 用 POST。
|
|
||||||
// 这里用 cancel 接口: POST /strategy/cancel, body 带 qs 的参数。
|
|
||||||
await fetch('/api/backtest/strategy/cancel', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ qs }),
|
|
||||||
}).catch(() => {})
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
if (eventSource) {
|
|
||||||
eventSource.close()
|
|
||||||
eventSource = null
|
|
||||||
}
|
|
||||||
if (current?.isPending) {
|
|
||||||
current = { ...current, isPending: false, error: '已取消' }
|
|
||||||
emit()
|
|
||||||
}
|
|
||||||
localStorage.removeItem(RECONNECT_KEY)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 清除任务状态 (隐藏提示) */
|
|
||||||
export function clearBacktest(): void {
|
|
||||||
current = null
|
|
||||||
emit()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 恢复: 从 localStorage 读取 reconnect 信息, 重新连接 (刷新后调用) */
|
|
||||||
export function tryReconnect(): boolean {
|
|
||||||
const qs = localStorage.getItem(RECONNECT_KEY)
|
|
||||||
if (!qs) return false
|
|
||||||
// 有未完成的任务, 重连
|
|
||||||
const id = ++taskSeq
|
|
||||||
current = { id, isPending: true, result: null, progress: null, error: null }
|
|
||||||
emit()
|
|
||||||
connectSSE(`/api/backtest/strategy/stream?${qs}`)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** React hook: 读取当前全局回测任务状态 */
|
|
||||||
export function useBacktestTask(): BacktestTask | null {
|
|
||||||
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ export const CAP_LABELS: Record<string, { name: string; hint: string }> = {
|
|||||||
'kline.minute.batch': { name: '分钟 K(批量)', hint: '多股分钟 K' },
|
'kline.minute.batch': { name: '分钟 K(批量)', hint: '多股分钟 K' },
|
||||||
|
|
||||||
'depth5': { name: '五档盘口', hint: '买卖五档报价' },
|
'depth5': { name: '五档盘口', hint: '买卖五档报价' },
|
||||||
'websocket': { name: '实时推送(WS)', hint: '免轮询的实时行情订阅' },
|
|
||||||
'financial': { name: '财务数据', hint: '利润表 / 资负表 / 现金流 / 关键指标' },
|
'financial': { name: '财务数据', hint: '利润表 / 资负表 / 现金流 / 关键指标' },
|
||||||
'adj_factor': { name: '复权因子', hint: '让 MA/MACD 等指标在分红送转日不失真' },
|
'adj_factor': { name: '复权因子', hint: '让 MA/MACD 等指标在分红送转日不失真' },
|
||||||
}
|
}
|
||||||
@@ -63,7 +62,7 @@ const TIER_STYLE: Record<string, TierStyle> = {
|
|||||||
labelTextStyle: { background: 'linear-gradient(135deg, #c084fc, #a855f7)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' },
|
labelTextStyle: { background: 'linear-gradient(135deg, #c084fc, #a855f7)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' },
|
||||||
},
|
},
|
||||||
expert: {
|
expert: {
|
||||||
desc: 'WebSocket · 财务数据',
|
desc: '财务数据',
|
||||||
tagBg: { background: 'linear-gradient(135deg, rgba(59,130,246,0.2), rgba(168,85,247,0.2), rgba(245,158,11,0.2))' },
|
tagBg: { background: 'linear-gradient(135deg, rgba(59,130,246,0.2), rgba(168,85,247,0.2), rgba(245,158,11,0.2))' },
|
||||||
dotStyle: { background: 'linear-gradient(135deg, #3b82f6, #a855f7, #f59e0b)' },
|
dotStyle: { background: 'linear-gradient(135deg, #3b82f6, #a855f7, #f59e0b)' },
|
||||||
labelTextStyle: { background: 'linear-gradient(135deg, #60a5fa, #c084fc, #fbbf24)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' },
|
labelTextStyle: { background: 'linear-gradient(135deg, #60a5fa, #c084fc, #fbbf24)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' },
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
* 集中管理所有 React Query key。
|
* 集中管理所有 React Query key。
|
||||||
*
|
*
|
||||||
* - 新增查询只需在此加一行,所有消费方自动引用。
|
* - 新增查询只需在此加一行,所有消费方自动引用。
|
||||||
* - SSE invalidation 基于 SSE_INVALIDATE_PREFIXES 列表,新增 key 无需改 useQuoteStream。
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ===== Query Key 工厂 =====
|
// ===== Query Key 工厂 =====
|
||||||
@@ -14,17 +13,10 @@ export const QK = {
|
|||||||
endpoints: ['endpoints'] as const,
|
endpoints: ['endpoints'] as const,
|
||||||
version: ['version'] as const,
|
version: ['version'] as const,
|
||||||
preferences: ['preferences'] as const,
|
preferences: ['preferences'] as const,
|
||||||
quoteStatus: ['quote-status'] as const,
|
|
||||||
quoteInterval: ['quote-interval'] as const,
|
|
||||||
overviewMarket: (asOf?: string) => ['overview-market', asOf ?? 'latest'] as const,
|
overviewMarket: (asOf?: string) => ['overview-market', asOf ?? 'latest'] as const,
|
||||||
indexQuotes: ['index-quotes'] as const,
|
|
||||||
indexList: ['index-list'] as const,
|
indexList: ['index-list'] as const,
|
||||||
|
|
||||||
// Watchlist
|
// Search
|
||||||
watchlist: ['watchlist'] as const,
|
|
||||||
watchlistQuotes: ['watchlist-quotes'] as const,
|
|
||||||
watchlistEnriched: (ext?: string) => ['watchlist-enriched', ext] as const,
|
|
||||||
watchlistKlineBatch: (symbols: string) => ['watchlist-kline-batch', symbols] as const,
|
|
||||||
instrumentSearch: (q: string) => ['instrument-search', q] as const,
|
instrumentSearch: (q: string) => ['instrument-search', q] as const,
|
||||||
|
|
||||||
// Screener
|
// Screener
|
||||||
@@ -35,9 +27,6 @@ export const QK = {
|
|||||||
marketSnapshot: ['market-snapshot'] as const,
|
marketSnapshot: ['market-snapshot'] as const,
|
||||||
limitLadder: (asOf?: string) => ['limit-ladder', asOf] as const,
|
limitLadder: (asOf?: string) => ['limit-ladder', asOf] as const,
|
||||||
|
|
||||||
// Backtest
|
|
||||||
backtestStatus: ['backtest-status'] as const,
|
|
||||||
|
|
||||||
// Data / Pipeline
|
// Data / Pipeline
|
||||||
dataStatus: ['data-status'] as const,
|
dataStatus: ['data-status'] as const,
|
||||||
pipelineJobs: ['pipeline-jobs'] as const,
|
pipelineJobs: ['pipeline-jobs'] as const,
|
||||||
@@ -78,14 +67,4 @@ export const QK = {
|
|||||||
rpsRotation: (days: number) => ['rps-rotation', days] as const,
|
rpsRotation: (days: number) => ['rps-rotation', days] as const,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
// ===== SSE 应该 invalidate 的 key 前缀列表 =====
|
// SSE invalidation has been removed along with real-time functionality.
|
||||||
// 新增需要 SSE 推送的查询,只需在此加一行
|
|
||||||
|
|
||||||
export const SSE_INVALIDATE_PREFIXES = [
|
|
||||||
'watchlist',
|
|
||||||
'quote-status',
|
|
||||||
'index-quotes',
|
|
||||||
'overview-market',
|
|
||||||
'limit-ladder',
|
|
||||||
'screener',
|
|
||||||
] as const
|
|
||||||
|
|||||||
@@ -35,10 +35,6 @@ const INITIAL: ReviewState = { phase: 'idle', content: '', error: '', meta: null
|
|||||||
let state: ReviewState = { ...INITIAL }
|
let state: ReviewState = { ...INITIAL }
|
||||||
let abortCtrl: AbortController | null = null
|
let abortCtrl: AbortController | null = null
|
||||||
|
|
||||||
// 当前生成来源: 'manual'(手动点生成) | 'sse'(定时任务 SSE 推送) | null(空闲)
|
|
||||||
// 用于区分两条流, 避免互相丢弃事件或重复归档。
|
|
||||||
let generatingSource: 'manual' | 'sse' | null = null
|
|
||||||
|
|
||||||
// ===== 订阅机制 =====
|
// ===== 订阅机制 =====
|
||||||
type Listener = () => void
|
type Listener = () => void
|
||||||
const listeners = new Set<Listener>()
|
const listeners = new Set<Listener>()
|
||||||
@@ -80,7 +76,6 @@ export async function startReviewGeneration(
|
|||||||
// 已在生成中,不重复启动
|
// 已在生成中,不重复启动
|
||||||
if (isReviewGenerating()) return
|
if (isReviewGenerating()) return
|
||||||
|
|
||||||
generatingSource = 'manual'
|
|
||||||
state = { phase: 'loading', content: '', error: '', meta: null, focus }
|
state = { phase: 'loading', content: '', error: '', meta: null, focus }
|
||||||
notify()
|
notify()
|
||||||
|
|
||||||
@@ -114,7 +109,7 @@ export async function startReviewGeneration(
|
|||||||
if (buf && !failed) {
|
if (buf && !failed) {
|
||||||
state = { ...state, phase: 'done' }
|
state = { ...state, phase: 'done' }
|
||||||
notify()
|
notify()
|
||||||
// 自动归档(仅手动流: 定时流由后端归档, SSE done 不走这里)
|
// 自动归档
|
||||||
if (buf && !failed) {
|
if (buf && !failed) {
|
||||||
onDone?.(buf, doneMeta)
|
onDone?.(buf, doneMeta)
|
||||||
}
|
}
|
||||||
@@ -126,7 +121,6 @@ export async function startReviewGeneration(
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
abortCtrl = null
|
abortCtrl = null
|
||||||
generatingSource = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,54 +161,4 @@ export function resetReview(): void {
|
|||||||
notify()
|
notify()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// feedReviewEvent (SSE-only) removed along with real-time SSE channel.
|
||||||
* 喂入一条来自 SSE 的复盘事件(定时生成时后端推来的)。
|
|
||||||
*
|
|
||||||
* 用途: 定时复盘在后端流式生成, 通过 /api/intraday/stream 的 review_progress 事件
|
|
||||||
* 把 meta/delta/done 等实时推给前端, 前端调本函数把事件写进 store ——
|
|
||||||
* 这样开着复盘页的用户能看到「边生成边显示」, 和手动点生成完全一致。
|
|
||||||
*
|
|
||||||
* 事件格式与 recap_market_stream 产出一致:
|
|
||||||
* {type:'meta'|'delta'|'error'|'done'|'retry', ...}
|
|
||||||
*
|
|
||||||
* 与手动生成的并发:
|
|
||||||
* - 若手动正在生成(isReviewGenerating), 忽略 SSE 事件(手动流优先, 避免冲突)。
|
|
||||||
* - done 带 archived=true(定时场景后端已归档): 不重复调归档接口, 仅切到 done 态。
|
|
||||||
* - retry: 后端 LLM 断流重试, 清空已累积内容重新开始。
|
|
||||||
*/
|
|
||||||
export function feedReviewEvent(evt: any): void {
|
|
||||||
if (!evt || typeof evt !== 'object') return
|
|
||||||
const t = evt.type
|
|
||||||
|
|
||||||
// 并发控制: 手动流进行中时, SSE 事件一律忽略(手动流优先, 避免两条流抢同一个 store)
|
|
||||||
// 但若当前是 SSE 流自己在跑(generatingSource==='sse'), 则正常处理后续事件
|
|
||||||
if (generatingSource === 'manual') return
|
|
||||||
|
|
||||||
if (t === 'meta') {
|
|
||||||
// 定时流的第一个事件: 标记来源为 sse, 进入 streaming 态, 重置 content
|
|
||||||
generatingSource = 'sse'
|
|
||||||
state = { phase: 'streaming', content: '', error: '', meta: evt, focus: '' }
|
|
||||||
notify()
|
|
||||||
} else if (t === 'delta' && evt.content) {
|
|
||||||
// 只有 sse 流进行中时才累积(防止 meta 丢失时的孤立 delta)
|
|
||||||
if (generatingSource !== 'sse') return
|
|
||||||
state = { ...state, content: state.content + evt.content, phase: 'streaming' }
|
|
||||||
notify()
|
|
||||||
} else if (t === 'retry') {
|
|
||||||
if (generatingSource !== 'sse') return
|
|
||||||
// 后端重试: 清空已累积内容, 等待新一轮 meta/delta
|
|
||||||
state = { ...state, content: '', phase: 'streaming' }
|
|
||||||
notify()
|
|
||||||
} else if (t === 'error') {
|
|
||||||
if (generatingSource !== 'sse') return
|
|
||||||
state = { ...state, error: evt.message ?? '复盘生成失败', phase: 'error' }
|
|
||||||
notify()
|
|
||||||
generatingSource = null
|
|
||||||
} else if (t === 'done') {
|
|
||||||
if (generatingSource !== 'sse') return
|
|
||||||
// 定时场景 done 带 archived=true: 后端已归档, 前端只切 done 态, 不调归档接口。
|
|
||||||
state = { ...state, phase: 'done' }
|
|
||||||
notify()
|
|
||||||
generatingSource = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* 策略结果列表自定义列配置。
|
* 策略结果列表自定义列配置。
|
||||||
*
|
|
||||||
* 内置列与分组与自选页 (watchlist-columns) 保持一致,额外保留策略特有的
|
|
||||||
* 「策略」「评分」两列。通用列模型/合并/扩展列参数来自 list-columns。
|
|
||||||
*/
|
*/
|
||||||
import { storage } from '@/lib/storage'
|
import { storage } from '@/lib/storage'
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 买卖触发器信号定义 — 选股页弹窗 / 回测页共用。
|
* 买卖触发器信号定义 — 选股页弹窗共用。
|
||||||
*
|
*
|
||||||
* 信号 ID 必须与后端 backtest/strategy.py:_build_signal_mask 对齐
|
* 信号 ID 必须与后端对齐
|
||||||
* (signal_* 前缀为内置原子信号, csg_ 前缀为用户自定义信号)。
|
* (signal_* 前缀为内置原子信号, csg_ 前缀为用户自定义信号)。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -27,27 +27,15 @@ export const storage = {
|
|||||||
/** 策略池 (screener) */
|
/** 策略池 (screener) */
|
||||||
strategyPool: kv<string[]>('strategy-pool'),
|
strategyPool: kv<string[]>('strategy-pool'),
|
||||||
|
|
||||||
/** 自选列表列配置 */
|
|
||||||
watchlistColumns: kv<unknown[]>('watchlist_columns'),
|
|
||||||
|
|
||||||
/** 个股日K信息条指标配置 */
|
/** 个股日K信息条指标配置 */
|
||||||
stockInfoBarFields: kv<unknown[]>('stock_info_bar_fields'),
|
stockInfoBarFields: kv<unknown[]>('stock_info_bar_fields'),
|
||||||
|
|
||||||
/** 策略结果列表列配置 */
|
/** 策略结果列表列配置 */
|
||||||
screenerResultColumns: kv<unknown[]>('screener_result_columns'),
|
screenerResultColumns: kv<unknown[]>('screener_result_columns'),
|
||||||
|
|
||||||
/** 自选列表视图模式 table | card */
|
|
||||||
watchlistView: kv<string>('watchlist_view'),
|
|
||||||
|
|
||||||
/** 自选列表日K蜡烛图显示状态 */
|
|
||||||
watchlistCandle: kv<boolean>('watchlist_showCandle'),
|
|
||||||
|
|
||||||
/** 策略结果列表日K蜡烛图显示状态 */
|
/** 策略结果列表日K蜡烛图显示状态 */
|
||||||
screenerCandle: kv<boolean>('screener_showCandle'),
|
screenerCandle: kv<boolean>('screener_showCandle'),
|
||||||
|
|
||||||
/** 自选列表板块筛选 */
|
|
||||||
watchlistBoardFilter: kv<string[]>('watchlist_boardFilter'),
|
|
||||||
|
|
||||||
/** Screener 卡片尺寸 */
|
/** Screener 卡片尺寸 */
|
||||||
screenerCardSize: kv<string>('screener-card-size'),
|
screenerCardSize: kv<string>('screener-card-size'),
|
||||||
|
|
||||||
@@ -78,31 +66,6 @@ export const storage = {
|
|||||||
/** 已保存策略的原始规则(策略ID → 规则文本) */
|
/** 已保存策略的原始规则(策略ID → 规则文本) */
|
||||||
strategyRules: kv<Record<string, string>>('strategy-rules'),
|
strategyRules: kv<Record<string, string>>('strategy-rules'),
|
||||||
|
|
||||||
/** 策略回测快捷区间按钮配置 */
|
|
||||||
strategyBacktestQuickRanges: kv<unknown>('strategy-backtest-quick-ranges'),
|
|
||||||
|
|
||||||
/** 策略回测最后一次成功结果和参数 */
|
|
||||||
strategyBacktestLast: kv<{
|
|
||||||
selectedStrategy: string | null
|
|
||||||
symbols: string
|
|
||||||
start: string
|
|
||||||
end: string
|
|
||||||
matching: 'close_t' | 'open_t+1'
|
|
||||||
entryFill: 'close_t' | 'open_t+1'
|
|
||||||
exitFill: 'close_t' | 'open_t+1'
|
|
||||||
fees: string
|
|
||||||
slippage: string
|
|
||||||
maxPositions: string
|
|
||||||
maxExposure: string
|
|
||||||
initialCapital: string
|
|
||||||
positionSizing: 'equal' | 'score_weight'
|
|
||||||
mode: 'position' | 'full'
|
|
||||||
holdingDays: string
|
|
||||||
params?: Record<string, any>
|
|
||||||
overrides?: Record<string, any>
|
|
||||||
result: any
|
|
||||||
} | null>('strategy-backtest-last'),
|
|
||||||
|
|
||||||
/** 概念分析页面字段配置 */
|
/** 概念分析页面字段配置 */
|
||||||
conceptAnalysisConfig: kv<Record<string, any>>('concept-analysis-config'),
|
conceptAnalysisConfig: kv<Record<string, any>>('concept-analysis-config'),
|
||||||
|
|
||||||
|
|||||||
@@ -31,10 +31,7 @@ function loadConfig(): QueryConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 轻量版:只读取当前配置。 */
|
||||||
* 轻量版:只读取当前配置。
|
|
||||||
* 供 useQuoteStream 使用。
|
|
||||||
*/
|
|
||||||
export function getQueryConfig(): QueryConfig {
|
export function getQueryConfig(): QueryConfig {
|
||||||
return loadConfig()
|
return loadConfig()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
import { useEffect, useRef, useCallback } from 'react'
|
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { SSE_INVALIDATE_PREFIXES, QK } from './queryKeys'
|
|
||||||
import { getQueryConfig } from './useQueryConfig'
|
|
||||||
import { toast } from '@/components/Toast'
|
|
||||||
import { pushAlertToasts } from '@/components/AlertToast'
|
|
||||||
import { feedReviewEvent } from './reviewStore'
|
|
||||||
import type { StrategyAlertEvent } from './api'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 全局 SSE hook: 监听后端行情更新推送 + 策略监控通知。
|
|
||||||
*
|
|
||||||
* - 行情更新 (quotes_updated): 根据 sseRefreshPages 配置过滤 invalidation
|
|
||||||
* - 策略监控通知 (strategy_alert): 通过 onAlert 回调弹 toast
|
|
||||||
*
|
|
||||||
* 应在顶层 Layout 中调用一次。
|
|
||||||
*/
|
|
||||||
export function useQuoteStream(
|
|
||||||
enabled: boolean,
|
|
||||||
sseRefreshPages: Record<string, boolean> | undefined,
|
|
||||||
onAlert?: (alerts: StrategyAlertEvent[]) => void,
|
|
||||||
) {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
const esRef = useRef<EventSource | null>(null)
|
|
||||||
const retryRef = useRef<ReturnType<typeof setTimeout>>()
|
|
||||||
const pagesRef = useRef(sseRefreshPages)
|
|
||||||
pagesRef.current = sseRefreshPages
|
|
||||||
|
|
||||||
const handleAlerts = useCallback((alerts: StrategyAlertEvent[]) => {
|
|
||||||
// depth 系统接管通知: 单独处理, 不走 strategy 回调
|
|
||||||
const depthAlerts = alerts.filter(a => a.source === 'depth')
|
|
||||||
const strategyAlerts = alerts.filter(a => a.source !== 'depth')
|
|
||||||
|
|
||||||
// depth 通知直接 toast(防刷屏: 后端已在状态切换时才推)
|
|
||||||
for (const a of depthAlerts.slice(0, 1)) {
|
|
||||||
toast(a.message, 'success')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监控告警: 用专用 AlertToast (整批只响一声, 每条都弹, 受 maxVisible 上限保护)
|
|
||||||
if (strategyAlerts.length > 0) {
|
|
||||||
// 有 onAlert 回调时走回调, 否则弹 AlertToast
|
|
||||||
if (onAlert) {
|
|
||||||
onAlert(strategyAlerts)
|
|
||||||
}
|
|
||||||
// 批量弹通知 (去掉了 slice(0,2) 截断, 让每只新命中都弹 toast; 声音整批只响一次)
|
|
||||||
pushAlertToasts(strategyAlerts as any)
|
|
||||||
}
|
|
||||||
}, [onAlert])
|
|
||||||
|
|
||||||
const enabledRef = useRef(enabled)
|
|
||||||
enabledRef.current = enabled
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// SSE 始终连接 — 监控告警不依赖实时行情开关
|
|
||||||
// (quotes_updated 行情刷新受 enabled 控制, strategy_alert 始终处理)
|
|
||||||
|
|
||||||
const connect = () => {
|
|
||||||
const es = new EventSource('/api/intraday/stream')
|
|
||||||
esRef.current = es
|
|
||||||
|
|
||||||
// sse-starlette ping 心跳走 SSE comment,不会到达这里
|
|
||||||
|
|
||||||
es.addEventListener('quotes_updated', () => {
|
|
||||||
// 实时行情未开启时不处理行情刷新
|
|
||||||
if (!enabledRef.current) return
|
|
||||||
// 根据用户配置过滤 invalidation
|
|
||||||
const pages = pagesRef.current
|
|
||||||
if (pages) {
|
|
||||||
// 只 invalidate 开启的页面对应的 prefix
|
|
||||||
const activePrefixes = SSE_INVALIDATE_PREFIXES.filter((p) => {
|
|
||||||
// 'quote-status' 始终刷新 (全局状态)
|
|
||||||
if (p === 'quote-status') return true
|
|
||||||
return pages[p] !== false
|
|
||||||
})
|
|
||||||
qc.invalidateQueries({
|
|
||||||
predicate: (query) =>
|
|
||||||
activePrefixes.some(
|
|
||||||
(prefix) => String(query.queryKey[0]).startsWith(prefix),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// 无配置时全部刷新 (向后兼容)
|
|
||||||
qc.invalidateQueries({
|
|
||||||
predicate: (query) =>
|
|
||||||
SSE_INVALIDATE_PREFIXES.some(
|
|
||||||
(prefix) => String(query.queryKey[0]).startsWith(prefix),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
es.addEventListener('depth_updated', () => {
|
|
||||||
// 五档修正完成: 刷新连板梯队 + 看板封单数据。
|
|
||||||
// 不受实时行情开关限制 — 修正轮询独立于行情轮询, 用户开了修正就想看实时封单。
|
|
||||||
qc.invalidateQueries({ queryKey: ['limit-ladder'] })
|
|
||||||
qc.invalidateQueries({ queryKey: ['overview-market'] })
|
|
||||||
})
|
|
||||||
|
|
||||||
es.addEventListener('strategy_alert', (e: MessageEvent) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(e.data)
|
|
||||||
const alerts: StrategyAlertEvent[] = data.alerts || []
|
|
||||||
if (alerts.length > 0) {
|
|
||||||
handleAlerts(alerts)
|
|
||||||
// 实时刷新触发记录列表 + 监控中心徽标
|
|
||||||
qc.invalidateQueries({ queryKey: ['alerts'] })
|
|
||||||
qc.invalidateQueries({ queryKey: ['alerts-total'] })
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 忽略解析错误
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 定时复盘流式进度: 后端到点生成时把 meta/delta/done 推来, 喂进 reviewStore
|
|
||||||
// 开着复盘页可看到「边生成边显示」, 切走再回来也能看到生成中/已生成
|
|
||||||
es.addEventListener('review_progress', (e: MessageEvent) => {
|
|
||||||
try {
|
|
||||||
const evt = JSON.parse(e.data)
|
|
||||||
feedReviewEvent(evt)
|
|
||||||
// done(后端已归档) → 刷新历史列表, 让新报告出现并可查看
|
|
||||||
if (evt.type === 'done') {
|
|
||||||
qc.invalidateQueries({ queryKey: QK.reviewReports })
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 忽略解析错误
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
es.onerror = () => {
|
|
||||||
es.close()
|
|
||||||
esRef.current = null
|
|
||||||
const delay = getQueryConfig().sse.reconnectDelay
|
|
||||||
retryRef.current = setTimeout(connect, delay)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
connect()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearTimeout(retryRef.current)
|
|
||||||
if (esRef.current) {
|
|
||||||
esRef.current.close()
|
|
||||||
esRef.current = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [qc, handleAlerts])
|
|
||||||
}
|
|
||||||
@@ -1,42 +1,3 @@
|
|||||||
/**
|
/**
|
||||||
* 共享 mutation hooks — 消除多页面重复的 useMutation 调用。
|
* 共享 mutation hooks — 消除多页面重复的 useMutation 调用。
|
||||||
*/
|
*/
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { api } from './api'
|
|
||||||
import { QK } from './queryKeys'
|
|
||||||
|
|
||||||
/** 切换实时行情 — Layout / Data 共用 */
|
|
||||||
export function useToggleRealtimeQuotes() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (enabled: boolean) => api.updateRealtimeQuotes(enabled),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: QK.preferences })
|
|
||||||
qc.invalidateQueries({ queryKey: QK.quoteStatus })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 更新行情轮询间隔 — Layout / Data 共用 */
|
|
||||||
export function useUpdateQuoteInterval() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (v: number) => api.updateQuoteInterval(v),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
qc.setQueryData(QK.quoteInterval, data)
|
|
||||||
qc.invalidateQueries({ queryKey: QK.quoteStatus })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 批量添加自选 — Screener / Intraday 共用 */
|
|
||||||
export function useWatchlistBatchAdd() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (symbols: string[]) => api.watchlistBatchAdd(symbols),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: QK.watchlist })
|
|
||||||
qc.invalidateQueries({ queryKey: QK.watchlistEnriched() })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* 共享 query hooks — 消除多页面重复的 useQuery 调用。
|
* 共享 query hooks — 消除多页面重复的 useQuery 调用。
|
||||||
*
|
|
||||||
* 实时数据走 SSE invalidation,无需前端轮询。
|
|
||||||
* 只有管线进度等非 SSE 数据才用 refetchInterval。
|
|
||||||
*/
|
*/
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { api } from './api'
|
import { api } from './api'
|
||||||
@@ -34,33 +31,6 @@ export function usePreferences() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 行情状态 — SSE quotes_updated 自动刷新。
|
|
||||||
|
|
||||||
* poll=true 时启用条件轮询兜底: 仅在非交易时段每 60s 轮询一次,
|
|
||||||
* 用于在交易时段边界 (11:30午休 / 12:55开盘 / 15:05收盘) 同步 is_trading_hours。
|
|
||||||
* 交易时段不轮询 (SSE 已驱动刷新), 非交易时段无 SSE 推送, 需要兜底。
|
|
||||||
* 只应在全局唯一挂载处 (Layout) 传 poll=true, 避免多页面重复轮询;
|
|
||||||
* 其他调用方共享同一 queryKey 缓存, 无需自行轮询。
|
|
||||||
*/
|
|
||||||
export function useQuoteStatus(opts?: { enabled?: boolean; poll?: boolean }) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: QK.quoteStatus,
|
|
||||||
queryFn: api.quoteStatus,
|
|
||||||
enabled: opts?.enabled ?? true,
|
|
||||||
refetchInterval: opts?.poll
|
|
||||||
? (query) => (query.state.data?.is_trading_hours ? false : 60_000)
|
|
||||||
: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 行情间隔 — Layout / Data 共用 */
|
|
||||||
export function useQuoteInterval() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: QK.quoteInterval,
|
|
||||||
queryFn: api.quoteInterval,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 版本号 — Layout 专用 */
|
/** 版本号 — Layout 专用 */
|
||||||
export function useVersion() {
|
export function useVersion() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|||||||
@@ -1,166 +0,0 @@
|
|||||||
/**
|
|
||||||
* 自选列表自定义列配置。
|
|
||||||
*
|
|
||||||
* 自选页只保留业务内置列、分组和偏好持久化;通用列模型/合并/扩展列参数
|
|
||||||
* 来自 list-columns,策略页等其它股票列表可复用同一底座。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { storage } from '@/lib/storage'
|
|
||||||
import {
|
|
||||||
buildExtColumnsParam as buildExtColumnsParamBase,
|
|
||||||
createExtColumn as createExtColumnBase,
|
|
||||||
mergeColumns as mergeColumnsBase,
|
|
||||||
serializeColumns as serializeColumnsBase,
|
|
||||||
type ColumnConfig,
|
|
||||||
type ColumnGroup,
|
|
||||||
type ColumnSource,
|
|
||||||
type ExtColumnDisplayConfig,
|
|
||||||
type CandleColumnConfig,
|
|
||||||
} from '@/lib/list-columns'
|
|
||||||
|
|
||||||
export type { ColumnConfig, ColumnGroup, ColumnSource, ExtColumnDisplayConfig, CandleColumnConfig }
|
|
||||||
|
|
||||||
// ===== 内置列注册表(与当前硬编码一一对应) =====
|
|
||||||
|
|
||||||
export const BUILTIN_COLUMNS: ColumnConfig[] = [
|
|
||||||
// 固定列
|
|
||||||
{ id: 'builtin:symbol', source: { type: 'builtin', key: 'symbol' }, label: '代码/名称', visible: true, pinned: true, align: 'left' },
|
|
||||||
// 价格
|
|
||||||
{ id: 'builtin:price', source: { type: 'builtin', key: 'price' }, label: '现价', visible: true, align: 'center' },
|
|
||||||
{ id: 'builtin:pct', source: { type: 'builtin', key: 'pct' }, label: '涨跌幅', visible: true, align: 'center' },
|
|
||||||
{ id: 'builtin:change_amount', source: { type: 'builtin', key: 'change_amount' }, label: '涨跌额', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:amplitude', source: { type: 'builtin', key: 'amplitude' }, label: '振幅', visible: false, align: 'center' },
|
|
||||||
// 成交
|
|
||||||
{ id: 'builtin:turnover', source: { type: 'builtin', key: 'turnover' }, label: '换手率', visible: true, align: 'center' },
|
|
||||||
{ id: 'builtin:amount', source: { type: 'builtin', key: 'amount' }, label: '成交额', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:float_val', source: { type: 'builtin', key: 'float_val' }, label: '流通值', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:vol_ratio', source: { type: 'builtin', key: 'vol_ratio' }, label: '量比', visible: true, align: 'center' },
|
|
||||||
{ id: 'builtin:annual_vol', source: { type: 'builtin', key: 'annual_vol' }, label: '年化波动', visible: false, align: 'center' },
|
|
||||||
// 均线
|
|
||||||
{ id: 'builtin:ma5', source: { type: 'builtin', key: 'ma5' }, label: 'MA5', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:ma10', source: { type: 'builtin', key: 'ma10' }, label: 'MA10', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:ma20', source: { type: 'builtin', key: 'ma20' }, label: 'MA20', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:ma60', source: { type: 'builtin', key: 'ma60' }, label: 'MA60', visible: false, align: 'center' },
|
|
||||||
// 区间
|
|
||||||
{ id: 'builtin:high_60d', source: { type: 'builtin', key: 'high_60d' }, label: '60日高', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:low_60d', source: { type: 'builtin', key: 'low_60d' }, label: '60日低', visible: false, align: 'center' },
|
|
||||||
// 技术指标
|
|
||||||
{ id: 'builtin:rsi6', source: { type: 'builtin', key: 'rsi6' }, label: 'RSI6', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:rsi14', source: { type: 'builtin', key: 'rsi14' }, label: 'RSI14', visible: true, align: 'center' },
|
|
||||||
{ id: 'builtin:rsi24', source: { type: 'builtin', key: 'rsi24' }, label: 'RSI24', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:macd_dif', source: { type: 'builtin', key: 'macd_dif' }, label: 'MACD-DIF', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:macd_dea', source: { type: 'builtin', key: 'macd_dea' }, label: 'MACD-DEA', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:macd_hist', source: { type: 'builtin', key: 'macd_hist' }, label: 'MACD柱', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:kdj_k', source: { type: 'builtin', key: 'kdj_k' }, label: 'KDJ-K', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:kdj_d', source: { type: 'builtin', key: 'kdj_d' }, label: 'KDJ-D', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:kdj_j', source: { type: 'builtin', key: 'kdj_j' }, label: 'KDJ-J', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:boll_upper', source: { type: 'builtin', key: 'boll_upper' }, label: '布林上轨', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:boll_lower', source: { type: 'builtin', key: 'boll_lower' }, label: '布林下轨', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:atr14', source: { type: 'builtin', key: 'atr14' }, label: 'ATR14', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:vol_ma5', source: { type: 'builtin', key: 'vol_ma5' }, label: '量MA5', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:vol_ma10', source: { type: 'builtin', key: 'vol_ma10' }, label: '量MA10', visible: false, align: 'center' },
|
|
||||||
// 动量
|
|
||||||
{ id: 'builtin:momentum_5d', source: { type: 'builtin', key: 'momentum_5d' }, label: '5D 动量', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:momentum_10d', source: { type: 'builtin', key: 'momentum_10d' }, label: '10D 动量', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:momentum_20d', source: { type: 'builtin', key: 'momentum_20d' }, label: '20D 动量', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:momentum_30d', source: { type: 'builtin', key: 'momentum_30d' }, label: '30D 动量', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:momentum_60d', source: { type: 'builtin', key: 'momentum' }, label: '60D 动量', visible: true, align: 'center' },
|
|
||||||
// 连板
|
|
||||||
{ id: 'builtin:limit_ups', source: { type: 'builtin', key: 'limit_ups' }, label: '连板', visible: true, align: 'center' },
|
|
||||||
{ id: 'builtin:limit_downs', source: { type: 'builtin', key: 'limit_downs' }, label: '连跌', visible: false, align: 'center' },
|
|
||||||
// 信号 & 图表
|
|
||||||
{ id: 'builtin:signals', source: { type: 'builtin', key: 'signals' }, label: '信号', visible: true, align: 'center' },
|
|
||||||
{ id: 'builtin:candle', source: { type: 'builtin', key: 'candle' }, label: '日k', visible: false, align: 'center' },
|
|
||||||
// 财务指标 (需 Expert 套餐 financial capability, 列默认隐藏)
|
|
||||||
{ id: 'builtin:eps', source: { type: 'builtin', key: 'eps' }, label: 'EPS', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:bps', source: { type: 'builtin', key: 'bps' }, label: 'BPS', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:roe', source: { type: 'builtin', key: 'roe' }, label: 'ROE', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:pe_ttm', source: { type: 'builtin', key: 'pe_ttm' }, label: 'PE(TTM)', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:pb', source: { type: 'builtin', key: 'pb' }, label: 'PB', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:gross_margin', source: { type: 'builtin', key: 'gross_margin' }, label: '毛利率', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:net_margin', source: { type: 'builtin', key: 'net_margin' }, label: '净利率', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:revenue_yoy', source: { type: 'builtin', key: 'revenue_yoy' }, label: '营收增速', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:net_income_yoy', source: { type: 'builtin', key: 'net_income_yoy' }, label: '净利增速', visible: false, align: 'center' },
|
|
||||||
{ id: 'builtin:debt_ratio', source: { type: 'builtin', key: 'debt_ratio' }, label: '负债率', visible: false, align: 'center' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const COLUMN_GROUPS: ColumnGroup[] = [
|
|
||||||
{ id: 'price', label: '价格', icon: '💰', keys: ['price', 'pct', 'change_amount', 'amplitude'] },
|
|
||||||
{ id: 'volume', label: '成交', icon: '📊', keys: ['turnover', 'amount', 'float_val', 'vol_ratio', 'annual_vol'] },
|
|
||||||
{ id: 'ma', label: '均线', icon: '📈', keys: ['ma5', 'ma10', 'ma20', 'ma60'] },
|
|
||||||
{ id: 'range', label: '区间', icon: '📏', keys: ['high_60d', 'low_60d'] },
|
|
||||||
{ id: 'tech', label: '技术指标', icon: '🔬', keys: ['rsi6', 'rsi14', 'rsi24', 'macd_dif', 'macd_dea', 'macd_hist', 'kdj_k', 'kdj_d', 'kdj_j', 'boll_upper', 'boll_lower', 'atr14', 'vol_ma5', 'vol_ma10'] },
|
|
||||||
{ id: 'momentum', label: '动量', icon: '🚀', keys: ['momentum_5d', 'momentum_10d', 'momentum_20d', 'momentum_30d', 'momentum_60d'] },
|
|
||||||
{ id: 'limit', label: '连板', icon: '🔥', keys: ['limit_ups', 'limit_downs'] },
|
|
||||||
{ id: 'signal', label: '信号', icon: '📡', keys: ['signals', 'candle'] },
|
|
||||||
{ id: 'finance', label: '财务', icon: '📋', keys: ['eps', 'bps', 'roe', 'pe_ttm', 'pb', 'gross_margin', 'net_margin', 'revenue_yoy', 'net_income_yoy', 'debt_ratio'] },
|
|
||||||
]
|
|
||||||
|
|
||||||
// 操作列(始终显示,不参与自定义)
|
|
||||||
export const ACTION_COLUMN_ID = 'builtin:action'
|
|
||||||
|
|
||||||
// ===== localStorage 持久化 =====
|
|
||||||
|
|
||||||
/** 序列化列配置(只保存用户可自定义的列,排除 pinned 和 action) */
|
|
||||||
export function serializeColumns(columns: ColumnConfig[]): ColumnConfig[] {
|
|
||||||
return serializeColumnsBase(columns, ACTION_COLUMN_ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 序列化并保存到后端 + localStorage */
|
|
||||||
export async function saveColumnConfig(columns: ColumnConfig[]): Promise<void> {
|
|
||||||
const saveable = serializeColumns(columns)
|
|
||||||
// 同时写 localStorage(即时)和后端(持久化)
|
|
||||||
storage.watchlistColumns.set(saveable)
|
|
||||||
try {
|
|
||||||
const { api } = await import('@/lib/api')
|
|
||||||
await api.updateWatchlistColumns(saveable)
|
|
||||||
} catch {
|
|
||||||
// 后端不可用时 localStorage 仍有效
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 加载列配置:优先后端,回退 localStorage,最终用默认值 */
|
|
||||||
export async function loadColumnConfig(): Promise<ColumnConfig[]> {
|
|
||||||
// 1. 尝试从后端加载
|
|
||||||
try {
|
|
||||||
const { api } = await import('@/lib/api')
|
|
||||||
const res = await api.watchlistColumns()
|
|
||||||
if (res.columns && res.columns.length > 0) {
|
|
||||||
const merged = mergeColumns(res.columns, BUILTIN_COLUMNS)
|
|
||||||
// 同步到 localStorage
|
|
||||||
storage.watchlistColumns.set(serializeColumns(merged))
|
|
||||||
return merged
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 后端不可用,继续尝试 localStorage
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 尝试从 localStorage 加载
|
|
||||||
const saved = storage.watchlistColumns.get([]) as ColumnConfig[]
|
|
||||||
if (saved.length > 0) {
|
|
||||||
return mergeColumns(saved, BUILTIN_COLUMNS)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 默认值
|
|
||||||
return [...BUILTIN_COLUMNS]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 合并用户保存的列与默认列 */
|
|
||||||
function mergeColumns(saved: ColumnConfig[], defaults: ColumnConfig[]): ColumnConfig[] {
|
|
||||||
return mergeColumnsBase(saved, defaults, { actionColumnId: ACTION_COLUMN_ID })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 从列配置中提取 ext 列参数,用于后端 enriched 接口 */
|
|
||||||
export function buildExtColumnsParam(columns: ColumnConfig[]): string {
|
|
||||||
return buildExtColumnsParamBase(columns)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 根据 ext schema 数据创建 ext 列配置 */
|
|
||||||
export function createExtColumn(
|
|
||||||
configId: string,
|
|
||||||
configLabel: string,
|
|
||||||
fieldName: string,
|
|
||||||
fieldLabel?: string,
|
|
||||||
): ColumnConfig {
|
|
||||||
return createExtColumnBase(configId, configLabel, fieldName, fieldLabel)
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
|
||||||
import { FactorBacktest } from './backtest/FactorBacktest'
|
|
||||||
import { StrategyBacktest } from './backtest/StrategyBacktest'
|
|
||||||
import { BarChart3, FlaskConical } from 'lucide-react'
|
|
||||||
|
|
||||||
type Tab = 'factor' | 'strategy'
|
|
||||||
|
|
||||||
const MODES: Record<Tab, { title: string; subtitle: string; hint: string }> = {
|
|
||||||
factor: {
|
|
||||||
title: '因子回测',
|
|
||||||
subtitle: '验证单个因子是否有预测能力',
|
|
||||||
hint: '看 IC / IR、分层收益和多空组合,适合先筛掉无效指标。',
|
|
||||||
},
|
|
||||||
strategy: {
|
|
||||||
title: '策略回测',
|
|
||||||
subtitle: '验证完整选股和交易规则',
|
|
||||||
hint: '看净值曲线、回撤、胜率和交易明细,适合判断策略是否可执行。',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Backtest() {
|
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('strategy')
|
|
||||||
|
|
||||||
const modeSwitch = (
|
|
||||||
<div className="inline-flex rounded-btn border border-border bg-surface/80 p-0.5 shadow-sm">
|
|
||||||
{(['factor', 'strategy'] as const).map(tab => {
|
|
||||||
const Icon = tab === 'factor' ? BarChart3 : FlaskConical
|
|
||||||
const active = activeTab === tab
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={tab}
|
|
||||||
onClick={() => setActiveTab(tab)}
|
|
||||||
className={`inline-flex items-center gap-1.5 rounded-[5px] px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer ${
|
|
||||||
active
|
|
||||||
? 'bg-accent text-white shadow-sm'
|
|
||||||
: 'text-secondary hover:bg-elevated hover:text-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon className="h-3.5 w-3.5" />
|
|
||||||
{MODES[tab].title}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-full bg-base flex flex-col">
|
|
||||||
<PageHeader
|
|
||||||
title="回测工作台"
|
|
||||||
subtitle={`${MODES[activeTab].title} · ${MODES[activeTab].hint}`}
|
|
||||||
right={modeSwitch}
|
|
||||||
className="shrink-0 bg-base/95"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<main className="flex-1 min-h-0 px-3 pb-3 pt-3 lg:px-4 lg:pb-4">
|
|
||||||
{activeTab === 'factor' && <FactorBacktest />}
|
|
||||||
{activeTab === 'strategy' && <StrategyBacktest />}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef, type ReactNode } from 'react'
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { Activity, ArrowDownRight, ArrowUpRight, BarChart3, BellRing, Database, Flame, Gauge, Info, LineChart, Loader2, Play, RefreshCw, Sparkles, Target, Timer } from 'lucide-react'
|
import { Activity, ArrowDownRight, ArrowUpRight, BarChart3, BellRing, Database, Flame, Gauge, LineChart, Loader2, Play, RefreshCw, Sparkles, Target, Timer } from 'lucide-react'
|
||||||
import { DatePicker } from '@/components/DatePicker'
|
import { DatePicker } from '@/components/DatePicker'
|
||||||
import { api, type MarketSnapshotRow, type OverviewDimensionRankItem, type OverviewMarket, type AlertEvent } from '@/lib/api'
|
import { api, type MarketSnapshotRow, type OverviewDimensionRankItem, type OverviewMarket, type AlertEvent } from '@/lib/api'
|
||||||
import { QK } from '@/lib/queryKeys'
|
import { QK } from '@/lib/queryKeys'
|
||||||
@@ -550,7 +550,6 @@ export function Dashboard() {
|
|||||||
}).catch(() => { /* 查询失败不阻塞, 用户仍可手动点击获取 */ })
|
}).catch(() => { /* 查询失败不阻塞, 用户仍可手动点击获取 */ })
|
||||||
}, [hasNoData, fetchJobId])
|
}, [hasNoData, fetchJobId])
|
||||||
|
|
||||||
// 手动刷新: 显示旋转动画; SSE 自动刷新: 静默, 无体感
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
setManualFetching(true)
|
setManualFetching(true)
|
||||||
overview.refetch().finally(() => setManualFetching(false))
|
overview.refetch().finally(() => setManualFetching(false))
|
||||||
@@ -582,10 +581,6 @@ export function Dashboard() {
|
|||||||
const strongDown = data.breadth.strong_down ?? 0
|
const strongDown = data.breadth.strong_down ?? 0
|
||||||
const latestDate = dataStatus.data?.enriched?.latest_date ?? null
|
const latestDate = dataStatus.data?.enriched?.latest_date ?? null
|
||||||
const currentDate = selectedDate ?? data.as_of ?? ''
|
const currentDate = selectedDate ?? data.as_of ?? ''
|
||||||
const quoteRunning = (!selectedDate || selectedDate === latestDate) && data.quote_status?.running
|
|
||||||
// 实时模式: none / watchlist / full_market。
|
|
||||||
// watchlist (Free 档) 仅自选 ≤5 只实时, 看板呈现的大盘数据实为盘后快照, 需提示避免误读。
|
|
||||||
const quoteMode = data.quote_status?.mode as ('none' | 'watchlist' | 'full_market') | undefined
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full bg-base p-3">
|
<div className="min-h-full bg-base p-3">
|
||||||
@@ -642,7 +637,6 @@ export function Dashboard() {
|
|||||||
<span className="font-mono text-secondary">—</span>
|
<span className="font-mono text-secondary">—</span>
|
||||||
)}
|
)}
|
||||||
<span className="flex items-center gap-1"><Timer className="h-3 w-3" />{quoteAge(data.quote_status?.quote_age_ms)}</span>
|
<span className="flex items-center gap-1"><Timer className="h-3 w-3" />{quoteAge(data.quote_status?.quote_age_ms)}</span>
|
||||||
<span className={quoteRunning ? 'text-accent' : 'text-warning'}>{quoteRunning ? '实时' : '非实时'}</span>
|
|
||||||
<button
|
<button
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
disabled={manualFetching}
|
disabled={manualFetching}
|
||||||
@@ -653,18 +647,6 @@ export function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Free 档提示: 大盘看板为盘后数据, 仅自选股实时。避免用户误读为全市场实时。 */}
|
|
||||||
{quoteMode === 'watchlist' && (
|
|
||||||
<div className="mb-3 flex items-start gap-2 rounded-card border border-amber-500/30 bg-amber-500/8 px-3 py-2 text-[11px] leading-relaxed">
|
|
||||||
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
|
|
||||||
<div className="min-w-0 flex-1 text-secondary">
|
|
||||||
当前为「自选实时」模式,看板展示的大盘数据为<strong className="text-foreground">盘后快照</strong>(最新有数据日),并非盘中实时;
|
|
||||||
仅自选股({data.quote_status?.watchlist_symbol_count ?? 0} 只)支持实时监控。
|
|
||||||
<span className="ml-1 text-accent">全市场实时需 Starter+</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mb-3 grid grid-cols-4 gap-2">
|
<div className="mb-3 grid grid-cols-4 gap-2">
|
||||||
{data.indices.map(item => <IndexTicker key={item.symbol} item={item} />)}
|
{data.indices.map(item => <IndexTicker key={item.symbol} item={item} />)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,11 +23,8 @@ import {
|
|||||||
useCapabilities,
|
useCapabilities,
|
||||||
useSettings,
|
useSettings,
|
||||||
usePreferences,
|
usePreferences,
|
||||||
useQuoteStatus,
|
|
||||||
useQuoteInterval,
|
|
||||||
useDataStatus,
|
useDataStatus,
|
||||||
} from '@/lib/useSharedQueries'
|
} from '@/lib/useSharedQueries'
|
||||||
import { useToggleRealtimeQuotes, useUpdateQuoteInterval } from '@/lib/useSharedMutations'
|
|
||||||
import { QK } from '@/lib/queryKeys'
|
import { QK } from '@/lib/queryKeys'
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
import { formatScheduleDatePart, formatScheduleTimePart, isToday } from '@/lib/format'
|
import { formatScheduleDatePart, formatScheduleTimePart, isToday } from '@/lib/format'
|
||||||
@@ -43,7 +40,6 @@ import { EnrichedRebuildPanel } from '@/components/data/EnrichedRebuildPanel'
|
|||||||
import { MinuteSyncConfig } from '@/components/data/MinuteSyncConfig'
|
import { MinuteSyncConfig } from '@/components/data/MinuteSyncConfig'
|
||||||
import { PipelineScopeConfig } from '@/components/data/PipelineScopeConfig'
|
import { PipelineScopeConfig } from '@/components/data/PipelineScopeConfig'
|
||||||
import { PageSettingsModal, getCardVisibility, getCardOrder, type CardKey } from '@/components/data/PageSettingsModal'
|
import { PageSettingsModal, getCardVisibility, getCardOrder, type CardKey } from '@/components/data/PageSettingsModal'
|
||||||
import { QuoteConfigCard } from '@/components/data/QuoteConfigCard'
|
|
||||||
import { EnrichedSchemaModal } from '@/components/data/SchemaModal'
|
import { EnrichedSchemaModal } from '@/components/data/SchemaModal'
|
||||||
import { Skeleton } from '@/components/data/Skeleton'
|
import { Skeleton } from '@/components/data/Skeleton'
|
||||||
import { ExtDataStatCard } from '@/components/ext-data/ExtDataStatCard'
|
import { ExtDataStatCard } from '@/components/ext-data/ExtDataStatCard'
|
||||||
@@ -141,7 +137,6 @@ export function Data() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: QK.dataStatus })
|
qc.invalidateQueries({ queryKey: QK.dataStatus })
|
||||||
qc.invalidateQueries({ queryKey: QK.indexList })
|
qc.invalidateQueries({ queryKey: QK.indexList })
|
||||||
qc.invalidateQueries({ queryKey: QK.indexQuotes })
|
|
||||||
qc.invalidateQueries({ queryKey: ['index-daily'] })
|
qc.invalidateQueries({ queryKey: ['index-daily'] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -161,33 +156,6 @@ export function Data() {
|
|||||||
onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const [showIntervalEdit, setShowIntervalEdit] = useState(false)
|
|
||||||
const handleToggleIntervalEdit = useCallback((fromEvent?: boolean) => {
|
|
||||||
setShowIntervalEdit(v => {
|
|
||||||
const next = !v
|
|
||||||
if (!fromEvent) {
|
|
||||||
window.dispatchEvent(new CustomEvent('quote-interval-editor-toggle', { detail: { source: 'data' } }))
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (e: Event) => {
|
|
||||||
const ce = e as CustomEvent
|
|
||||||
if (ce.detail?.source !== 'data') {
|
|
||||||
setShowIntervalEdit(v => !v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener('quote-interval-editor-toggle', handler)
|
|
||||||
return () => window.removeEventListener('quote-interval-editor-toggle', handler)
|
|
||||||
}, [])
|
|
||||||
const quoteInterval = useQuoteInterval()
|
|
||||||
const updateInterval = useUpdateQuoteInterval()
|
|
||||||
|
|
||||||
const realtimeEnabled = prefs.data?.realtime_quotes_enabled ?? false
|
|
||||||
const quoteStatus = useQuoteStatus()
|
|
||||||
const toggleQuote = useToggleRealtimeQuotes()
|
|
||||||
|
|
||||||
const hasAdjCap = !!caps.data?.capabilities?.['adj_factor']
|
const hasAdjCap = !!caps.data?.capabilities?.['adj_factor']
|
||||||
const hasDailyBatchCap = !!caps.data?.capabilities?.['kline.daily.batch']
|
const hasDailyBatchCap = !!caps.data?.capabilities?.['kline.daily.batch']
|
||||||
const hasMinuteCap = !!caps.data?.capabilities?.['kline.minute.batch']
|
const hasMinuteCap = !!caps.data?.capabilities?.['kline.minute.batch']
|
||||||
@@ -579,24 +547,8 @@ export function Data() {
|
|||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* 实时行情 + 存储 + 调度 */}
|
{/* 自动调度 + 存储 */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<QuoteConfigCard
|
|
||||||
enabled={realtimeEnabled}
|
|
||||||
running={quoteStatus.data?.running ?? false}
|
|
||||||
isTrading={quoteStatus.data?.is_trading_hours ?? false}
|
|
||||||
lastFetchMs={quoteStatus.data?.last_fetch_ms ?? null}
|
|
||||||
intervalS={quoteInterval.data?.interval ?? quoteStatus.data?.interval_s ?? 10}
|
|
||||||
intervalMin={quoteInterval.data?.min_interval ?? 5}
|
|
||||||
intervalMax={quoteInterval.data?.max_interval ?? 60}
|
|
||||||
loading={quoteStatus.isLoading}
|
|
||||||
onToggle={(v) => toggleQuote.mutate(v)}
|
|
||||||
toggling={toggleQuote.isPending}
|
|
||||||
showIntervalEdit={showIntervalEdit}
|
|
||||||
onShowIntervalEdit={handleToggleIntervalEdit}
|
|
||||||
onIntervalChange={(v) => updateInterval.mutate(v)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 自动调度 */}
|
{/* 自动调度 */}
|
||||||
<div className="rounded-card border border-border bg-surface p-4">
|
<div className="rounded-card border border-border bg-surface p-4">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
|||||||
@@ -44,16 +44,6 @@ function toOHLC(rows: KlineRow[]): OHLC[] {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtPct(v: number | null | undefined) {
|
|
||||||
if (v == null || Number.isNaN(Number(v))) return '--'
|
|
||||||
return `${Number(v).toFixed(2)}%`
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtNum(v: number | null | undefined, digits = 2) {
|
|
||||||
if (v == null || Number.isNaN(Number(v))) return '--'
|
|
||||||
return Number(v).toFixed(digits)
|
|
||||||
}
|
|
||||||
|
|
||||||
const PINNED_INDEXES = [
|
const PINNED_INDEXES = [
|
||||||
{ symbol: '000001.SH', name: '上证指数' },
|
{ symbol: '000001.SH', name: '上证指数' },
|
||||||
{ symbol: '399001.SZ', name: '深证成指' },
|
{ symbol: '399001.SZ', name: '深证成指' },
|
||||||
@@ -112,12 +102,6 @@ export function Indices() {
|
|||||||
setSearchParams({ symbol })
|
setSearchParams({ symbol })
|
||||||
}
|
}
|
||||||
|
|
||||||
const quotes = useQuery({
|
|
||||||
queryKey: QK.indexQuotes,
|
|
||||||
queryFn: () => api.indexQuotes(),
|
|
||||||
placeholderData: (prev) => prev,
|
|
||||||
})
|
|
||||||
|
|
||||||
const daily = useQuery({
|
const daily = useQuery({
|
||||||
queryKey: QK.indexDaily(selectedSymbol, range.start, range.end),
|
queryKey: QK.indexDaily(selectedSymbol, range.start, range.end),
|
||||||
queryFn: () => api.indexDaily(selectedSymbol, 180, range),
|
queryFn: () => api.indexDaily(selectedSymbol, 180, range),
|
||||||
@@ -136,7 +120,6 @@ export function Indices() {
|
|||||||
mutationFn: api.syncIndexInstruments,
|
mutationFn: api.syncIndexInstruments,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: QK.indexList })
|
qc.invalidateQueries({ queryKey: QK.indexList })
|
||||||
qc.invalidateQueries({ queryKey: QK.indexQuotes })
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -144,20 +127,10 @@ export function Indices() {
|
|||||||
mutationFn: () => api.syncIndexDaily(365),
|
mutationFn: () => api.syncIndexDaily(365),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: QK.indexList })
|
qc.invalidateQueries({ queryKey: QK.indexList })
|
||||||
qc.invalidateQueries({ queryKey: QK.indexQuotes })
|
|
||||||
qc.invalidateQueries({ queryKey: ['index-daily'] })
|
qc.invalidateQueries({ queryKey: ['index-daily'] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const quoteBySymbol = useMemo(() => {
|
|
||||||
const m = new Map<string, any>()
|
|
||||||
for (const q of quotes.data?.rows ?? []) m.set(q.symbol, q)
|
|
||||||
return m
|
|
||||||
}, [quotes.data?.rows])
|
|
||||||
const selectedQuote = selectedSymbol ? quoteBySymbol.get(selectedSymbol) : null
|
|
||||||
const selectedQuoteValue = selectedQuote?.last_price ?? selectedQuote?.price ?? selectedQuote?.close
|
|
||||||
const selectedQuotePct = selectedQuote?.change_pct ?? selectedQuote?.pct
|
|
||||||
|
|
||||||
const chartRows = useMemo(() => toOHLC(daily.data?.rows ?? []), [daily.data?.rows])
|
const chartRows = useMemo(() => toOHLC(daily.data?.rows ?? []), [daily.data?.rows])
|
||||||
const selectedInfo = [...topRows, ...listRows].find(r => r.symbol === selectedSymbol) || daily.data?.index_info
|
const selectedInfo = [...topRows, ...listRows].find(r => r.symbol === selectedSymbol) || daily.data?.index_info
|
||||||
const minuteRows: MinuteKlineRow[] = minute.data?.rows ?? []
|
const minuteRows: MinuteKlineRow[] = minute.data?.rows ?? []
|
||||||
@@ -179,9 +152,6 @@ export function Indices() {
|
|||||||
}
|
}
|
||||||
}, [chartRows, daily.data?.symbol, selectedDate, selectedSymbol])
|
}, [chartRows, daily.data?.symbol, selectedDate, selectedSymbol])
|
||||||
const renderIndexItem = (item: IndexInstrument) => {
|
const renderIndexItem = (item: IndexInstrument) => {
|
||||||
const q = quoteBySymbol.get(item.symbol)
|
|
||||||
const pct = q?.change_pct ?? q?.pct
|
|
||||||
const current = q?.last_price ?? q?.price ?? q?.close
|
|
||||||
const active = item.symbol === selectedSymbol
|
const active = item.symbol === selectedSymbol
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -189,14 +159,8 @@ export function Indices() {
|
|||||||
onClick={() => selectIndex(item.symbol)}
|
onClick={() => selectIndex(item.symbol)}
|
||||||
className={`w-full rounded-btn px-2 py-2 text-left transition-colors ${active ? 'bg-accent/15 text-foreground' : 'hover:bg-elevated text-secondary'}`}
|
className={`w-full rounded-btn px-2 py-2 text-left transition-colors ${active ? 'bg-accent/15 text-foreground' : 'hover:bg-elevated text-secondary'}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="truncate text-xs font-medium">{item.name || item.symbol}</div>
|
||||||
<span className="truncate text-xs font-medium">{item.name || item.symbol}</span>
|
<div className="mt-0.5 text-[10px] font-mono text-muted">{item.symbol}</div>
|
||||||
<span className={`text-[10px] font-mono ${Number(pct ?? 0) >= 0 ? 'text-bull' : 'text-bear'}`}>{fmtPct(pct)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-0.5 flex items-center justify-between text-[10px] font-mono text-muted">
|
|
||||||
<span>{item.symbol}</span>
|
|
||||||
<span>{fmtNum(current)}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -264,11 +228,9 @@ export function Indices() {
|
|||||||
{selectedInfo?.name || selectedSymbol || '未选择指数'}
|
{selectedInfo?.name || selectedSymbol || '未选择指数'}
|
||||||
</h2>
|
</h2>
|
||||||
{selectedSymbol && <span className="font-mono text-xs text-muted">{selectedSymbol}</span>}
|
{selectedSymbol && <span className="font-mono text-xs text-muted">{selectedSymbol}</span>}
|
||||||
{selectedSymbol && <span className="font-mono text-xs text-foreground">{fmtNum(selectedQuoteValue)}</span>}
|
|
||||||
{selectedSymbol && <span className={`font-mono text-xs ${Number(selectedQuotePct ?? 0) >= 0 ? 'text-bull' : 'text-bear'}`}>{fmtPct(selectedQuotePct)}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-muted">
|
<div className="mt-1 text-xs text-muted">
|
||||||
实时缓存 {quotes.data?.count ?? 0} 只指数 · 日K来源 {daily.data?.source ?? '--'}
|
日K来源 {daily.data?.source ?? '--'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { PageHeader } from '@/components/PageHeader'
|
|||||||
import { EmptyState } from '@/components/EmptyState'
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
import { useCapabilities, usePreferences } from '@/lib/useSharedQueries'
|
import { useCapabilities, usePreferences } from '@/lib/useSharedQueries'
|
||||||
import { SealedBadge } from '@/components/SealedBadge'
|
import { SealedBadge } from '@/components/SealedBadge'
|
||||||
import type { ExtColumnDisplayConfig } from '@/lib/watchlist-columns'
|
import type { ExtColumnDisplayConfig } from '@/lib/list-columns'
|
||||||
|
|
||||||
// ===== Ext 字段配置 =====
|
// ===== Ext 字段配置 =====
|
||||||
|
|
||||||
@@ -374,7 +374,7 @@ function MonitorMenu({ stock, direction, sealMode, monitorRule, anchorRect, hasD
|
|||||||
|
|
||||||
// 推送外部开关默认值: 取偏好设置中的全局默认 (已有规则沿用其值)
|
// 推送外部开关默认值: 取偏好设置中的全局默认 (已有规则沿用其值)
|
||||||
const { data: prefs } = usePreferences()
|
const { data: prefs } = usePreferences()
|
||||||
const webhookDefault = prefs?.webhook_enabled_default ?? false
|
const webhookDefault: boolean = (prefs as any)?.webhook_enabled_default ?? false
|
||||||
|
|
||||||
// 单位倍率: 输入值 × 倍率 = 原始单位 (量=手, 额=元)
|
// 单位倍率: 输入值 × 倍率 = 原始单位 (量=手, 额=元)
|
||||||
const VOL_UNITS = [
|
const VOL_UNITS = [
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function Review() {
|
|||||||
const [showSchedule, setShowSchedule] = useState(false)
|
const [showSchedule, setShowSchedule] = useState(false)
|
||||||
const prefs = usePreferences()
|
const prefs = usePreferences()
|
||||||
const reviewSched = prefs.data?.review_schedule ?? { enabled: false, hour: 15, minute: 10 }
|
const reviewSched = prefs.data?.review_schedule ?? { enabled: false, hour: 15, minute: 10 }
|
||||||
const feishuConfigured = !!(prefs.data?.feishu_webhook_url)
|
const feishuConfigured = !!((prefs.data as any)?.feishu_webhook_url)
|
||||||
// 推送渠道是独立的顶层偏好(多选), 与定时 / 实时行情无关, 常驻可单独设置
|
// 推送渠道是独立的顶层偏好(多选), 与定时 / 实时行情无关, 常驻可单独设置
|
||||||
// []=不推送, ['feishu']=飞书(微信开发中, 仅占位)
|
// []=不推送, ['feishu']=飞书(微信开发中, 仅占位)
|
||||||
const reviewPushChannels = prefs.data?.review_push_channels ?? []
|
const reviewPushChannels = prefs.data?.review_push_channels ?? []
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { ScanSearch, Clock, TrendingUp, Star, Filter, Layers, Network, Sparkles, RefreshCw, Settings2, Store } from 'lucide-react'
|
import { ScanSearch, Clock, TrendingUp, Filter, Layers, Network, Sparkles, RefreshCw, Settings2, Store } from 'lucide-react'
|
||||||
import { api, genRuleId, type ScreenerStrategy, type ScreenerResult } from '@/lib/api'
|
import { api, genRuleId, type ScreenerStrategy, type ScreenerResult } from '@/lib/api'
|
||||||
import { useDataStatus, usePreferences } from '@/lib/useSharedQueries'
|
import { useDataStatus, usePreferences } from '@/lib/useSharedQueries'
|
||||||
import { useWatchlistBatchAdd } from '@/lib/useSharedMutations'
|
|
||||||
import { QK } from '@/lib/queryKeys'
|
import { QK } from '@/lib/queryKeys'
|
||||||
import { storage } from '@/lib/storage'
|
import { storage } from '@/lib/storage'
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
@@ -35,7 +34,6 @@ export function Screener() {
|
|||||||
const [activeStrategy, setActiveStrategy] = useState<string | null>(null)
|
const [activeStrategy, setActiveStrategy] = useState<string | null>(null)
|
||||||
const [result, setResult] = useState<ScreenerResult | null>(null)
|
const [result, setResult] = useState<ScreenerResult | null>(null)
|
||||||
const [asOf, setAsOf] = useState<string>('')
|
const [asOf, setAsOf] = useState<string>('')
|
||||||
const [batchMsg, setBatchMsg] = useState<string>('')
|
|
||||||
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
|
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
|
||||||
const [previewName, setPreviewName] = useState<string>('')
|
const [previewName, setPreviewName] = useState<string>('')
|
||||||
const closePreview = useCallback(() => { setPreviewSymbol(null); setPreviewName('') }, [])
|
const closePreview = useCallback(() => { setPreviewSymbol(null); setPreviewName('') }, [])
|
||||||
@@ -402,28 +400,6 @@ export function Screener() {
|
|||||||
const minDate = dataStatus.data?.enriched?.earliest_date ?? ''
|
const minDate = dataStatus.data?.enriched?.earliest_date ?? ''
|
||||||
const maxDate = dataStatus.data?.enriched?.latest_date ?? ''
|
const maxDate = dataStatus.data?.enriched?.latest_date ?? ''
|
||||||
|
|
||||||
const batchAdd = useWatchlistBatchAdd()
|
|
||||||
|
|
||||||
// 自选股列表 (用于判断是否在自选中)
|
|
||||||
const watchlist = useQuery({
|
|
||||||
queryKey: QK.watchlist,
|
|
||||||
queryFn: api.watchlistList,
|
|
||||||
})
|
|
||||||
const watchlistSet = useMemo(() => {
|
|
||||||
const symbols = watchlist.data?.symbols ?? []
|
|
||||||
return new Set(symbols.map((s: any) => s.symbol))
|
|
||||||
}, [watchlist.data])
|
|
||||||
|
|
||||||
// 单只股票加入/移出自选
|
|
||||||
const toggleWatchlist = useMutation({
|
|
||||||
mutationFn: ({ symbol, inList }: { symbol: string; inList: boolean }) =>
|
|
||||||
inList ? api.watchlistRemove(symbol) : api.watchlistAdd(symbol),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: QK.watchlist })
|
|
||||||
qc.invalidateQueries({ queryKey: QK.watchlistEnriched() })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// 重新运行策略:重载策略文件 + 重跑全部策略,刷新符合条件的个股
|
// 重新运行策略:重载策略文件 + 重跑全部策略,刷新符合条件的个股
|
||||||
const reloadStrategies = useMutation({
|
const reloadStrategies = useMutation({
|
||||||
mutationFn: api.strategyReload,
|
mutationFn: api.strategyReload,
|
||||||
@@ -473,22 +449,6 @@ export function Screener() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBatchAdd = () => {
|
|
||||||
if (!displayRows.length) return
|
|
||||||
const symbols = displayRows.map((r: any) => r.symbol)
|
|
||||||
batchAdd.mutate(symbols, {
|
|
||||||
onSuccess: (data) => {
|
|
||||||
setBatchMsg(`已添加 ${data.added} 只到自选`)
|
|
||||||
setTimeout(() => setBatchMsg(''), 3000)
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
setBatchMsg('添加失败')
|
|
||||||
setTimeout(() => setBatchMsg(''), 3000)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -688,18 +648,6 @@ export function Screener() {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{displayRows.length > 0 && (
|
|
||||||
<button
|
|
||||||
onClick={handleBatchAdd}
|
|
||||||
disabled={batchAdd.isPending}
|
|
||||||
className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-btn
|
|
||||||
border border-accent/40 bg-accent/10 text-accent text-xs font-medium
|
|
||||||
hover:bg-accent/20 disabled:opacity-50 transition-colors duration-150 cursor-pointer"
|
|
||||||
>
|
|
||||||
<Star className="h-3 w-3" />
|
|
||||||
{batchAdd.isPending ? '添加中…' : '批量加自选'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setCustomizerOpen(true)}
|
onClick={() => setCustomizerOpen(true)}
|
||||||
title="列表配置"
|
title="列表配置"
|
||||||
@@ -711,9 +659,6 @@ export function Screener() {
|
|||||||
>
|
>
|
||||||
<Settings2 className="h-3 w-3" />
|
<Settings2 className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
{batchMsg && (
|
|
||||||
<span className="text-xs text-accent animate-pulse">{batchMsg}</span>
|
|
||||||
)}
|
|
||||||
{!showAll && result && result.elapsed_ms > 0 && (
|
{!showAll && result && result.elapsed_ms > 0 && (
|
||||||
<div className="flex items-center gap-2 text-xs text-muted">
|
<div className="flex items-center gap-2 text-xs text-muted">
|
||||||
<Clock className="h-3 w-3" />
|
<Clock className="h-3 w-3" />
|
||||||
@@ -749,10 +694,7 @@ export function Screener() {
|
|||||||
strategyIdToName={strategyIdToName}
|
strategyIdToName={strategyIdToName}
|
||||||
symbolStrategyMap={symbolStrategyMap}
|
symbolStrategyMap={symbolStrategyMap}
|
||||||
activeStrategy={activeStrategy}
|
activeStrategy={activeStrategy}
|
||||||
watchlistSet={watchlistSet}
|
|
||||||
onPreview={(symbol, name) => { setPreviewSymbol(symbol); setPreviewName(name) }}
|
onPreview={(symbol, name) => { setPreviewSymbol(symbol); setPreviewName(name) }}
|
||||||
onToggleWatchlist={(symbol, inList) => toggleWatchlist.mutate({ symbol, inList })}
|
|
||||||
watchlistPending={toggleWatchlist.isPending}
|
|
||||||
klineData={klineData}
|
klineData={klineData}
|
||||||
dailyKChartVisible={dailyKChartVisible}
|
dailyKChartVisible={dailyKChartVisible}
|
||||||
onToggleDailyKChart={toggleDailyKChart}
|
onToggleDailyKChart={toggleDailyKChart}
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
import { Cable } from 'lucide-react'
|
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
|
||||||
import { EmptyState } from '@/components/EmptyState'
|
|
||||||
|
|
||||||
// 后续实现计划(本轮为占位):
|
|
||||||
//
|
|
||||||
// 一、信号 → 交易 的桥接
|
|
||||||
// 监控通知产生的买卖信号(StrategyAlert),通过可插拔的输出通道分发到
|
|
||||||
// 支持外部信号的交易软件。核心是在 alert_handler 层做多通道分发。
|
|
||||||
//
|
|
||||||
// 二、支持的交易软件(按接入难度)
|
|
||||||
// 1. QMT / miniQMT(迅投)—— 个人 A 股实盘首选。
|
|
||||||
// XtQuant 的 xttrader.order_stock() 下单,信号来源不限(文件/HTTP)。
|
|
||||||
// 2. 掘金量化(MyQuant)—— 本地终端 + Python SDK,事件驱动接收信号。
|
|
||||||
// 3. Ptrade(恒生)—— 内置策略引擎,外部信号经 API/文件喂入。
|
|
||||||
// 4. vnpy(VeighNa)—— 开源框架,自写策略模块接收信号再调 Gateway 下单。
|
|
||||||
//
|
|
||||||
// 三、信号输出通道(可插拔)
|
|
||||||
// alert_handler 分发:
|
|
||||||
// ├─ SSE → 前端通知(已有)
|
|
||||||
// ├─ 本地文件(JSON/CSV) → QMT 脚本轮询读取 ← 最简单,优先做
|
|
||||||
// ├─ Webhook POST → 外部交易脚本
|
|
||||||
// └─ 直连 xttrader(需本机装 QMT)
|
|
||||||
//
|
|
||||||
// 四、信号 → 交易指令 的字段补全
|
|
||||||
// 现有 StrategyAlert(symbol/type/strategy_id/price)是「信号层」,
|
|
||||||
// 下单还需补:volume(数量,A股100的倍数)、price_type(市价/限价)、account。
|
|
||||||
const PLAN: { title: string; desc: string }[] = [
|
|
||||||
{
|
|
||||||
title: 'QMT / miniQMT',
|
|
||||||
desc: '个人 A 股实盘首选。XtQuant 的 xttrader 下单,信号经文件或 HTTP 喂入即可。国内个人量化实盘事实标准。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '掘金量化 (MyQuant)',
|
|
||||||
desc: '本地终端 + Python SDK,事件驱动接收外部信号下单,本土化程度高。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Ptrade (恒生)',
|
|
||||||
desc: '内置 Python 策略引擎,外部信号经 API/文件喂入,灵活性低于 QMT。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'vnpy (VeighNa)',
|
|
||||||
desc: '开源交易框架,Gateway 丰富(期货/股票/加密货币),需自建执行端,搭建成本较高。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '信号输出通道',
|
|
||||||
desc: 'alert_handler 多通道分发:本地文件(最简,优先)、Webhook POST、直连 xttrader。与具体交易软件解耦。',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export function Trading() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<PageHeader title="交易" subtitle="信号自动下单桥接 · 开发中" />
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto px-5 py-6">
|
|
||||||
<div className="max-w-3xl mx-auto">
|
|
||||||
<EmptyState
|
|
||||||
icon={Cable}
|
|
||||||
title="交易桥接开发中"
|
|
||||||
hint="本页面将把监控产生的买卖信号,自动推送给支持外部信号的交易软件(QMT/掘金/Ptrade 等)执行下单。当前为占位页面,下方为后续实现规划。"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section className="mt-6 rounded-card border border-border bg-surface p-5">
|
|
||||||
<h3 className="text-sm font-semibold text-foreground">后续实现规划</h3>
|
|
||||||
<ul className="mt-3 space-y-3">
|
|
||||||
{PLAN.map((item) => (
|
|
||||||
<li key={item.title} className="flex gap-3">
|
|
||||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-accent" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-foreground">{item.title}</p>
|
|
||||||
<p className="mt-0.5 text-xs leading-relaxed text-secondary">{item.desc}</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,447 +0,0 @@
|
|||||||
import { useState, useMemo } from 'react'
|
|
||||||
import { useQuery, useMutation } from '@tanstack/react-query'
|
|
||||||
import { motion } from 'framer-motion'
|
|
||||||
import { Play, BarChart3, Clock } from 'lucide-react'
|
|
||||||
import { api, type FactorColumn, type FactorBacktestResult, type GroupStat } from '@/lib/api'
|
|
||||||
import { fmtPct, priceColorClass } from '@/lib/format'
|
|
||||||
import { EmptyState } from '@/components/EmptyState'
|
|
||||||
import { DatePicker } from '@/components/DatePicker'
|
|
||||||
import { FactorICChart } from './charts/FactorICChart'
|
|
||||||
import { FactorGroupNavChart } from './charts/FactorGroupNavChart'
|
|
||||||
|
|
||||||
const formatDate = (date: Date) => date.toISOString().slice(0, 10)
|
|
||||||
const monthsAgo = (months: number) => {
|
|
||||||
const date = new Date()
|
|
||||||
date.setMonth(date.getMonth() - months)
|
|
||||||
return formatDate(date)
|
|
||||||
}
|
|
||||||
const TODAY = formatDate(new Date())
|
|
||||||
const THREE_MONTHS_AGO = monthsAgo(3)
|
|
||||||
|
|
||||||
const INPUT_CLS = `w-full px-2.5 py-1.5 rounded-input bg-surface border border-border text-xs
|
|
||||||
focus:outline-none focus:border-accent transition-colors duration-150 ease-smooth`
|
|
||||||
|
|
||||||
function StatCard({ label, value, highlight }: {
|
|
||||||
label: string
|
|
||||||
value: string | null | undefined
|
|
||||||
highlight?: 'bull' | 'bear' | 'neutral'
|
|
||||||
}) {
|
|
||||||
const colorCls = highlight === 'bull'
|
|
||||||
? 'text-bull' : highlight === 'bear' ? 'text-bear' : ''
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="text-[11px] text-muted">{label}</div>
|
|
||||||
<div className={`mt-1 text-lg font-mono font-semibold tracking-tight num ${colorCls}`}>
|
|
||||||
{value ?? '—'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoadingPanel({ symbolsText }: { symbolsText: string }) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="rounded-card border border-accent/25 bg-accent/[0.04] p-4">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-foreground">正在计算因子分析</div>
|
|
||||||
<div className="mt-1 text-xs text-muted">{symbolsText} · 完成后会一次性刷新 IC、分层收益和净值曲线。</div>
|
|
||||||
</div>
|
|
||||||
<div className="h-8 w-8 rounded-full border-2 border-accent/25 border-t-accent animate-spin" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 h-1.5 overflow-hidden rounded-full bg-base">
|
|
||||||
<div className="h-full w-1/2 rounded-full bg-accent/70 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
||||||
{['读取因子', '计算 IC', '分层回测', '汇总指标'].map(item => (
|
|
||||||
<div key={item} className="rounded-btn border border-border bg-surface p-3">
|
|
||||||
<div className="h-2 w-10 rounded bg-accent/30 animate-pulse" />
|
|
||||||
<div className="mt-3 text-xs text-secondary">{item}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-card border border-border bg-surface p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="text-xs font-medium text-secondary">分层净值预览</div>
|
|
||||||
<div className="text-[11px] text-muted">等待后端返回完整结果</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 h-[260px] rounded-btn border border-border bg-base/60 p-4">
|
|
||||||
<div className="flex h-full items-end gap-2 opacity-70">
|
|
||||||
{[46, 38, 54, 50, 64, 58, 74, 68, 84, 78, 90, 86].map((h, i) => (
|
|
||||||
<div key={i} className="flex-1 rounded-t bg-accent/20 animate-pulse" style={{ height: `${h}%` }} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FactorBacktest() {
|
|
||||||
const [factorName, setFactorName] = useState('momentum_20d')
|
|
||||||
const [symbols, setSymbols] = useState('')
|
|
||||||
const [start, setStart] = useState(THREE_MONTHS_AGO)
|
|
||||||
const [end, setEnd] = useState(TODAY)
|
|
||||||
const [nGroups, setNGroups] = useState(5)
|
|
||||||
const [weight, setWeight] = useState<'equal' | 'factor_weight'>('equal')
|
|
||||||
const [fees, setFees] = useState('2')
|
|
||||||
const [result, setResult] = useState<FactorBacktestResult | null>(null)
|
|
||||||
|
|
||||||
const columns = useQuery({
|
|
||||||
queryKey: ['backtest-factor-columns'],
|
|
||||||
queryFn: api.factorColumns,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 按 group 分类的因子
|
|
||||||
const factorGroups = useMemo(() => {
|
|
||||||
const cols = columns.data?.columns ?? []
|
|
||||||
const groups: Record<string, FactorColumn[]> = {}
|
|
||||||
for (const c of cols) {
|
|
||||||
;(groups[c.group] ??= []).push(c)
|
|
||||||
}
|
|
||||||
return groups
|
|
||||||
}, [columns.data])
|
|
||||||
|
|
||||||
// 当前因子描述
|
|
||||||
const factorDesc = useMemo(() => {
|
|
||||||
return columns.data?.columns.find(c => c.id === factorName)?.desc ?? ''
|
|
||||||
}, [columns.data, factorName])
|
|
||||||
|
|
||||||
const run = useMutation({
|
|
||||||
mutationFn: () =>
|
|
||||||
api.factorRun({
|
|
||||||
factor_name: factorName,
|
|
||||||
symbols: symbols ? symbols.split(',').map(s => s.trim()).filter(Boolean) : null,
|
|
||||||
start: start || null,
|
|
||||||
end: end || undefined,
|
|
||||||
n_groups: nGroups,
|
|
||||||
rebalance: 'daily',
|
|
||||||
weight,
|
|
||||||
fees_pct: Number(fees) / 10000,
|
|
||||||
}),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
if (data.error) {
|
|
||||||
setResult(data)
|
|
||||||
} else {
|
|
||||||
setResult(data)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const applyRange = (months: number) => {
|
|
||||||
setStart(monthsAgo(months))
|
|
||||||
setEnd(formatDate(new Date()))
|
|
||||||
}
|
|
||||||
|
|
||||||
const applyAllRange = () => {
|
|
||||||
setStart('')
|
|
||||||
setEnd(formatDate(new Date()))
|
|
||||||
}
|
|
||||||
|
|
||||||
const rangeKey = end === TODAY && start === THREE_MONTHS_AGO
|
|
||||||
? '3m'
|
|
||||||
: end === TODAY && start === monthsAgo(6)
|
|
||||||
? '6m'
|
|
||||||
: end === TODAY && start === monthsAgo(12)
|
|
||||||
? '1y'
|
|
||||||
: end === TODAY && start === ''
|
|
||||||
? 'all'
|
|
||||||
: 'custom'
|
|
||||||
const rangeTitle = rangeKey === '3m'
|
|
||||||
? '近 3 个月'
|
|
||||||
: rangeKey === '6m'
|
|
||||||
? '近 6 个月'
|
|
||||||
: rangeKey === '1y'
|
|
||||||
? '近 1 年'
|
|
||||||
: rangeKey === 'all'
|
|
||||||
? '全部历史'
|
|
||||||
: '自定义区间'
|
|
||||||
const rangeButtonCls = (key: string) => `rounded-btn px-2 py-1 text-[11px] font-medium transition-colors ${rangeKey === key
|
|
||||||
? 'bg-accent/15 text-accent'
|
|
||||||
: 'text-muted hover:bg-elevated/70 hover:text-secondary'
|
|
||||||
}`
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full min-h-0 overflow-hidden rounded-card border border-border bg-surface/80 grid grid-cols-1 xl:grid-cols-[18rem_minmax(0,1fr)]">
|
|
||||||
{/* 配置面板 */}
|
|
||||||
<section className="space-y-3 border-b xl:border-b-0 xl:border-r border-border bg-base/25 px-3 py-3 xl:overflow-y-auto">
|
|
||||||
<div className="border-b border-border/70 pb-2">
|
|
||||||
<div className="text-xs font-semibold text-foreground">因子配置</div>
|
|
||||||
<div className="mt-0.5 text-[10px] leading-4 text-muted">选择因子、区间和分组方式。默认最近 3 个月。</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-medium text-secondary block mb-1.5">因子</label>
|
|
||||||
<select
|
|
||||||
value={factorName}
|
|
||||||
onChange={e => setFactorName(e.target.value)}
|
|
||||||
className={INPUT_CLS}
|
|
||||||
>
|
|
||||||
{Object.entries(factorGroups).map(([group, cols]) => (
|
|
||||||
<optgroup key={group} label={group}>
|
|
||||||
{cols.map(c => (
|
|
||||||
<option key={c.id} value={c.id}>{c.label}</option>
|
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{factorDesc && (
|
|
||||||
<p className="mt-1 text-[11px] text-muted">{factorDesc}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-medium text-secondary block mb-1.5">
|
|
||||||
标的(逗号分隔,留空=全市场)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={symbols}
|
|
||||||
onChange={e => setSymbols(e.target.value)}
|
|
||||||
placeholder="留空则使用全市场,建议最近3个月"
|
|
||||||
className={`w-full px-2.5 py-1.5 rounded-input bg-surface border border-border text-xs font-mono
|
|
||||||
focus:outline-none focus:border-accent transition-colors duration-150 ease-smooth`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-btn border border-border bg-surface p-2.5">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div className="text-xs font-medium text-foreground">回测区间</div>
|
|
||||||
<span className="shrink-0 rounded-full border border-accent/25 bg-accent/10 px-2 py-0.5 text-[10px] font-medium text-accent">
|
|
||||||
{rangeTitle}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
|
||||||
<div>
|
|
||||||
<label className="text-[11px] text-secondary block mb-1">开始</label>
|
|
||||||
<DatePicker
|
|
||||||
value={start}
|
|
||||||
onChange={setStart}
|
|
||||||
max={end || undefined}
|
|
||||||
placeholder="全部历史"
|
|
||||||
className="w-full"
|
|
||||||
buttonClassName="w-full justify-start"
|
|
||||||
align="left"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-[11px] text-secondary block mb-1">结束</label>
|
|
||||||
<DatePicker
|
|
||||||
value={end}
|
|
||||||
onChange={setEnd}
|
|
||||||
min={start || undefined}
|
|
||||||
className="w-full"
|
|
||||||
buttonClassName="w-full justify-start"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-2 flex rounded-input bg-base/60 p-0.5">
|
|
||||||
<button type="button" onClick={() => applyRange(3)} className={`${rangeButtonCls('3m')} flex-1`}>3个月</button>
|
|
||||||
<button type="button" onClick={() => applyRange(6)} className={`${rangeButtonCls('6m')} flex-1`}>6个月</button>
|
|
||||||
<button type="button" onClick={() => applyRange(12)} className={`${rangeButtonCls('1y')} flex-1`}>1年</button>
|
|
||||||
<button type="button" onClick={applyAllRange} className={`${rangeButtonCls('all')} flex-1`}>全部</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-medium text-secondary block mb-1.5">分组数</label>
|
|
||||||
<select value={nGroups} onChange={e => setNGroups(Number(e.target.value))} className={INPUT_CLS}>
|
|
||||||
<option value={3}>3组</option>
|
|
||||||
<option value={5}>5组</option>
|
|
||||||
<option value={10}>10组</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-medium text-secondary block mb-1.5">权重</label>
|
|
||||||
<select value={weight} onChange={e => setWeight(e.target.value as any)} className={INPUT_CLS}>
|
|
||||||
<option value="equal">等权</option>
|
|
||||||
<option value="factor_weight">因子加权</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-medium text-secondary block mb-1.5">佣金(万分之)</label>
|
|
||||||
<input type="number" value={fees} onChange={e => setFees(e.target.value)}
|
|
||||||
className={INPUT_CLS} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => run.mutate()}
|
|
||||||
disabled={run.isPending}
|
|
||||||
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-btn
|
|
||||||
bg-accent text-sm font-medium hover:bg-accent/90
|
|
||||||
transition-colors duration-150 ease-smooth disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Play className="h-3.5 w-3.5" />
|
|
||||||
{run.isPending ? '分析中…' : '开始因子分析'}
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 结果面板 */}
|
|
||||||
<section className="min-w-0 space-y-3 bg-base/15 px-3 py-3 xl:overflow-y-auto">
|
|
||||||
{result?.error && !result.ic_mean && (
|
|
||||||
<div className="text-sm text-danger bg-danger/10 border border-danger/30 rounded-btn px-3 py-2">
|
|
||||||
{result.error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{run.isError && (
|
|
||||||
<div className="text-sm text-danger bg-danger/10 border border-danger/30 rounded-btn px-3 py-2">
|
|
||||||
{String((run.error as any).message)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!result && !run.isPending && (
|
|
||||||
<EmptyState
|
|
||||||
icon={BarChart3}
|
|
||||||
title="选择因子并开始分析"
|
|
||||||
hint="因子回测分析因子的预测能力 ( IC/IR ) 和分层收益差异。服务器建议优先使用最近3个月;长周期建议本机或 8GB 以上内存环境运行。"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{run.isPending && result && (
|
|
||||||
<div className="rounded-card border border-accent/25 bg-accent/[0.04] px-4 py-3 text-xs text-secondary">
|
|
||||||
正在重新计算,当前暂时展示上一次因子分析结果,完成后会自动替换。
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{run.isPending && !result && (
|
|
||||||
<LoadingPanel symbolsText={symbols ? `${symbols.split(',').length} 只标的` : '全市场 · 当前区间'} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{result && result.ic_mean != null && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 8 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
|
|
||||||
className="space-y-4"
|
|
||||||
>
|
|
||||||
{/* IC/IR 指标 */}
|
|
||||||
<div className="rounded-card border border-border bg-surface p-4">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<h3 className="text-sm font-medium text-foreground">因子预测能力</h3>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-[11px] text-muted">
|
|
||||||
Rank IC · 日度调仓
|
|
||||||
</span>
|
|
||||||
{result.elapsed_ms > 0 && (
|
|
||||||
<span className="flex items-center gap-1 text-[11px] text-muted">
|
|
||||||
<Clock className="h-3 w-3" />
|
|
||||||
<span className="num">{result.elapsed_ms.toFixed(0)} ms</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 gap-4">
|
|
||||||
<StatCard
|
|
||||||
label="IC 均值"
|
|
||||||
value={result.ic_mean != null ? fmtPct(result.ic_mean) : null}
|
|
||||||
highlight={result.ic_mean != null
|
|
||||||
? result.ic_mean > 0.03 ? 'bull' : result.ic_mean < -0.03 ? 'bear' : 'neutral'
|
|
||||||
: undefined}
|
|
||||||
/>
|
|
||||||
<StatCard label="IC 标准差" value={result.ic_std != null ? fmtPct(result.ic_std) : null} />
|
|
||||||
<StatCard
|
|
||||||
label="ICIR"
|
|
||||||
value={result.ir != null ? result.ir.toFixed(2) : null}
|
|
||||||
highlight={result.ir != null
|
|
||||||
? Math.abs(result.ir) > 0.5 ? (result.ir > 0 ? 'bull' : 'bear') : 'neutral'
|
|
||||||
: undefined}
|
|
||||||
/>
|
|
||||||
<StatCard label="IC 胜率" value={result.ic_win_rate != null ? fmtPct(result.ic_win_rate) : null} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* IC 时序图 */}
|
|
||||||
{result.ic_series.length > 0 && (
|
|
||||||
<div className="rounded-card border border-border overflow-hidden">
|
|
||||||
<div className="bg-elevated px-4 py-2">
|
|
||||||
<span className="text-xs font-medium text-secondary">IC 时序</span>
|
|
||||||
</div>
|
|
||||||
<div className="p-2">
|
|
||||||
<FactorICChart result={result} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 分层净值 */}
|
|
||||||
{result.group_nav.length > 0 && (
|
|
||||||
<div className="rounded-card border border-border overflow-hidden">
|
|
||||||
<div className="bg-elevated px-4 py-2">
|
|
||||||
<span className="text-xs font-medium text-secondary">分层净值曲线</span>
|
|
||||||
</div>
|
|
||||||
<div className="p-2">
|
|
||||||
<FactorGroupNavChart result={result} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 分层统计表 */}
|
|
||||||
{result.group_stats.length > 0 && (
|
|
||||||
<div className="rounded-card border border-border overflow-hidden">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="bg-elevated">
|
|
||||||
<tr className="text-left text-secondary">
|
|
||||||
<th className="px-4 py-2.5 font-medium">分组</th>
|
|
||||||
<th className="px-4 py-2.5 font-medium text-right">总收益</th>
|
|
||||||
<th className="px-4 py-2.5 font-medium text-right">年化</th>
|
|
||||||
<th className="px-4 py-2.5 font-medium text-right">最大回撤</th>
|
|
||||||
<th className="px-4 py-2.5 font-medium text-right">夏普</th>
|
|
||||||
<th className="px-4 py-2.5 font-medium text-right">胜率</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{result.group_stats.map((g: GroupStat) => (
|
|
||||||
<tr key={g.group} className="border-t border-border hover:bg-elevated/50 transition-colors">
|
|
||||||
<td className="px-4 py-2 text-sm font-medium">{g.label}</td>
|
|
||||||
<td className={`px-4 py-2 text-right num ${priceColorClass(g.total_return)}`}>
|
|
||||||
{fmtPct(g.total_return)}
|
|
||||||
</td>
|
|
||||||
<td className={`px-4 py-2 text-right num ${priceColorClass(g.annual_return)}`}>
|
|
||||||
{fmtPct(g.annual_return)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-right num text-bear">{fmtPct(g.max_drawdown)}</td>
|
|
||||||
<td className="px-4 py-2 text-right num">{g.sharpe?.toFixed(2)}</td>
|
|
||||||
<td className="px-4 py-2 text-right num">{fmtPct(g.win_rate)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{/* 多空行 */}
|
|
||||||
{result.long_short_stats?.total_return != null && (
|
|
||||||
<tr className="border-t-2 border-accent/30 bg-accent/[0.03]">
|
|
||||||
<td className="px-4 py-2 text-sm font-medium text-accent">
|
|
||||||
多空({result.long_short_stats.top_group ?? ''}-{result.long_short_stats.bottom_group ?? ''})
|
|
||||||
</td>
|
|
||||||
<td className={`px-4 py-2 text-right num font-medium ${priceColorClass(result.long_short_stats.total_return)}`}>
|
|
||||||
{fmtPct(result.long_short_stats.total_return as number)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-right num">—</td>
|
|
||||||
<td className="px-4 py-2 text-right num text-bear">
|
|
||||||
{fmtPct(result.long_short_stats.max_drawdown as number)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-right num">—</td>
|
|
||||||
<td className="px-4 py-2 text-right num">—</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 数据概要 */}
|
|
||||||
<div className="flex items-center gap-4 text-[11px] text-muted">
|
|
||||||
<span>{result.n_symbols} 只标的</span>
|
|
||||||
<span>{result.n_dates} 个交易日</span>
|
|
||||||
<span>run_id: {result.run_id}</span>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,124 +0,0 @@
|
|||||||
import { useMemo } from 'react'
|
|
||||||
import { useECharts } from './useECharts'
|
|
||||||
import type { FactorBacktestResult } from '@/lib/api'
|
|
||||||
|
|
||||||
const GROUP_COLORS = [
|
|
||||||
'#6366f1', // Q1 indigo
|
|
||||||
'#8b5cf6', // Q2 violet
|
|
||||||
'#f59e0b', // Q3 amber
|
|
||||||
'#f97316', // Q4 orange
|
|
||||||
'#ef4444', // Q5 red
|
|
||||||
'#ec4899', // Q6
|
|
||||||
'#14b8a6', // Q7
|
|
||||||
'#06b6d4', // Q8
|
|
||||||
'#84cc16', // Q9
|
|
||||||
'#a855f7', // Q10
|
|
||||||
]
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
result: FactorBacktestResult
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FactorGroupNavChart({ result }: Props) {
|
|
||||||
const option = useMemo(() => {
|
|
||||||
if (!result.group_nav.length) return null
|
|
||||||
|
|
||||||
const dates = result.group_nav.map(r => (r.date as string).slice(0, 10))
|
|
||||||
const groupCols = Object.keys(result.group_nav[0]).filter(k => k !== 'date').sort()
|
|
||||||
|
|
||||||
// 多空净值
|
|
||||||
const lsNav = result.long_short_nav
|
|
||||||
const hasLS = lsNav && lsNav.length > 0
|
|
||||||
|
|
||||||
const series = groupCols.map((col, i) => ({
|
|
||||||
name: col,
|
|
||||||
type: 'line',
|
|
||||||
data: result.group_nav.map(r => r[col]),
|
|
||||||
symbol: 'none',
|
|
||||||
lineStyle: { color: GROUP_COLORS[i % GROUP_COLORS.length], width: 1.5 } as any,
|
|
||||||
itemStyle: { color: GROUP_COLORS[i % GROUP_COLORS.length] } as any,
|
|
||||||
}))
|
|
||||||
|
|
||||||
if (hasLS) {
|
|
||||||
series.push({
|
|
||||||
name: '多空',
|
|
||||||
type: 'line',
|
|
||||||
data: lsNav.map(r => r.value),
|
|
||||||
symbol: 'none',
|
|
||||||
lineStyle: { color: '#fbbf24', width: 2, type: 'dashed' },
|
|
||||||
itemStyle: { color: '#fbbf24' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
animation: false,
|
|
||||||
legend: {
|
|
||||||
show: false,
|
|
||||||
},
|
|
||||||
grid: { left: 56, right: 16, top: 12, bottom: 28 },
|
|
||||||
tooltip: {
|
|
||||||
trigger: 'axis',
|
|
||||||
backgroundColor: 'rgba(15,23,42,0.95)',
|
|
||||||
borderColor: 'rgba(148,163,184,0.2)',
|
|
||||||
textStyle: { color: '#e2e8f0', fontSize: 12 },
|
|
||||||
formatter: (params: any) => {
|
|
||||||
const date = params[0]?.axisValue ?? ''
|
|
||||||
let html = `<div style="font-size:11px;color:#94a3b8;margin-bottom:4px">${date}</div>`
|
|
||||||
for (const p of params) {
|
|
||||||
if (p.value == null) continue
|
|
||||||
html += `<div style="display:flex;justify-content:space-between;gap:16px">
|
|
||||||
<span style="display:flex;align-items:center;gap:4px">
|
|
||||||
<span style="width:8px;height:3px;border-radius:1px;background:${p.color};display:inline-block"></span>
|
|
||||||
${p.seriesName}
|
|
||||||
</span>
|
|
||||||
<span style="font-family:monospace">${(p.value as number).toFixed(4)}</span>
|
|
||||||
</div>`
|
|
||||||
}
|
|
||||||
return html
|
|
||||||
},
|
|
||||||
},
|
|
||||||
xAxis: {
|
|
||||||
type: 'category',
|
|
||||||
data: dates,
|
|
||||||
axisLabel: { color: '#64748b', fontSize: 10, interval: Math.floor(dates.length / 6) },
|
|
||||||
axisLine: { lineStyle: { color: '#334155' } },
|
|
||||||
axisTick: { show: false },
|
|
||||||
},
|
|
||||||
yAxis: {
|
|
||||||
type: 'value',
|
|
||||||
scale: true,
|
|
||||||
axisLabel: { color: '#64748b', fontSize: 10 },
|
|
||||||
splitLine: { lineStyle: { color: '#1e293b' } },
|
|
||||||
axisLine: { show: false },
|
|
||||||
},
|
|
||||||
series,
|
|
||||||
} as any
|
|
||||||
}, [result.group_nav, result.long_short_nav, result.run_id])
|
|
||||||
|
|
||||||
const chartRef = useECharts(option, [result.run_id])
|
|
||||||
|
|
||||||
// 图例
|
|
||||||
const groupCols = result.group_nav.length > 0
|
|
||||||
? Object.keys(result.group_nav[0]).filter(k => k !== 'date').sort()
|
|
||||||
: []
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-3 px-4 pb-2">
|
|
||||||
{groupCols.map((col, i) => (
|
|
||||||
<span key={col} className="flex items-center gap-1 text-[10px] text-secondary">
|
|
||||||
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: GROUP_COLORS[i % GROUP_COLORS.length] }} />
|
|
||||||
{col}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{result.long_short_nav?.length > 0 && (
|
|
||||||
<span className="flex items-center gap-1 text-[10px] text-secondary">
|
|
||||||
<span className="w-2 h-0.5 rounded bg-yellow-400" style={{ borderTop: '2px dashed #fbbf24' }} />
|
|
||||||
多空
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div ref={chartRef} className="h-[280px]" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import { useMemo } from 'react'
|
|
||||||
import { useECharts } from './useECharts'
|
|
||||||
import type { FactorBacktestResult } from '@/lib/api'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
result: FactorBacktestResult
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FactorICChart({ result }: Props) {
|
|
||||||
const option = useMemo(() => {
|
|
||||||
if (!result.ic_series.length) return null
|
|
||||||
|
|
||||||
const dates = result.ic_series.map(r => r.date.slice(0, 10))
|
|
||||||
const values = result.ic_series.map(r => r.ic)
|
|
||||||
|
|
||||||
// 12期移动平均
|
|
||||||
const maWindow = 12
|
|
||||||
const ma: (number | null)[] = values.map((_, i) => {
|
|
||||||
if (i < maWindow - 1) return null
|
|
||||||
const slice = values.slice(i - maWindow + 1, i + 1)
|
|
||||||
return slice.reduce((a, b) => a + b, 0) / slice.length
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
animation: false,
|
|
||||||
grid: { left: 50, right: 16, top: 16, bottom: 28 },
|
|
||||||
tooltip: {
|
|
||||||
trigger: 'axis',
|
|
||||||
backgroundColor: 'rgba(15,23,42,0.95)',
|
|
||||||
borderColor: 'rgba(148,163,184,0.2)',
|
|
||||||
textStyle: { color: '#e2e8f0', fontSize: 12 },
|
|
||||||
formatter: (params: any) => {
|
|
||||||
const date = params[0]?.axisValue ?? ''
|
|
||||||
let html = `<div style="font-size:11px;color:#94a3b8;margin-bottom:4px">${date}</div>`
|
|
||||||
for (const p of params) {
|
|
||||||
if (p.value == null) continue
|
|
||||||
html += `<div style="display:flex;justify-content:space-between;gap:16px">
|
|
||||||
<span style="color:${p.color}">${p.seriesName}</span>
|
|
||||||
<span style="font-family:monospace">${(p.value * 100).toFixed(2)}%</span>
|
|
||||||
</div>`
|
|
||||||
}
|
|
||||||
return html
|
|
||||||
},
|
|
||||||
},
|
|
||||||
xAxis: {
|
|
||||||
type: 'category',
|
|
||||||
data: dates,
|
|
||||||
axisLabel: { color: '#64748b', fontSize: 10, interval: Math.floor(dates.length / 6) },
|
|
||||||
axisLine: { lineStyle: { color: '#334155' } },
|
|
||||||
axisTick: { show: false },
|
|
||||||
},
|
|
||||||
yAxis: {
|
|
||||||
type: 'value',
|
|
||||||
axisLabel: { color: '#64748b', fontSize: 10, formatter: (v: number) => `${(v * 100).toFixed(0)}%` },
|
|
||||||
splitLine: { lineStyle: { color: '#1e293b' } },
|
|
||||||
axisLine: { show: false },
|
|
||||||
},
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: 'IC',
|
|
||||||
type: 'bar',
|
|
||||||
data: values.map(v => ({
|
|
||||||
value: v,
|
|
||||||
itemStyle: {
|
|
||||||
color: v >= 0
|
|
||||||
? 'rgba(240,68,56,0.6)'
|
|
||||||
: 'rgba(18,183,106,0.6)',
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
barMaxWidth: 6,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: `MA${maWindow}`,
|
|
||||||
type: 'line',
|
|
||||||
data: ma,
|
|
||||||
smooth: true,
|
|
||||||
symbol: 'none',
|
|
||||||
lineStyle: { color: '#f59e0b', width: 1.5 },
|
|
||||||
z: 10,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} as any
|
|
||||||
}, [result.ic_series])
|
|
||||||
|
|
||||||
const chartRef = useECharts(option, [result.run_id])
|
|
||||||
|
|
||||||
return <div ref={chartRef} className="h-[200px]" />
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { useMemo } from 'react'
|
|
||||||
import { useECharts } from './useECharts'
|
|
||||||
import type { EChartsOption } from 'echarts'
|
|
||||||
|
|
||||||
interface DistBin {
|
|
||||||
range: string
|
|
||||||
count: number
|
|
||||||
ratio: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 收益分布直方图 — 全量模拟专用的候选标的收益分布。
|
|
||||||
* 柱子颜色按收益正负区分(正红负绿),零轴居中。
|
|
||||||
*/
|
|
||||||
export function ReturnDistributionChart({ distribution }: { distribution: DistBin[] }) {
|
|
||||||
const option = useMemo<EChartsOption>(() => {
|
|
||||||
const cats = distribution.map(d => d.range)
|
|
||||||
const vals = distribution.map(d => d.count)
|
|
||||||
// 判断每档是正还是负(按 range 字符串首字符 +/~)
|
|
||||||
const colors = distribution.map(d => {
|
|
||||||
const lo = parseFloat(d.range)
|
|
||||||
// 中心档(跨 0) 用中性色
|
|
||||||
if (lo < 0 && parseFloat(d.range.split('~')[1]) > 0) return '#a1a1aa'
|
|
||||||
return lo >= 0 ? '#ef4444' : '#22c55e'
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
grid: { left: 48, right: 16, top: 24, bottom: 56 },
|
|
||||||
tooltip: {
|
|
||||||
trigger: 'axis',
|
|
||||||
axisPointer: { type: 'shadow' },
|
|
||||||
formatter: (params: any) => {
|
|
||||||
const p = Array.isArray(params) ? params[0] : params
|
|
||||||
const bin = distribution[p.dataIndex]
|
|
||||||
if (!bin) return ''
|
|
||||||
return `${bin.range}<br/>数量: ${bin.count}<br/>占比: ${(bin.ratio * 100).toFixed(1)}%`
|
|
||||||
},
|
|
||||||
},
|
|
||||||
xAxis: {
|
|
||||||
type: 'category',
|
|
||||||
data: cats,
|
|
||||||
axisLabel: { color: '#a1a1aa', fontSize: 10, rotate: 45, interval: 1 },
|
|
||||||
axisLine: { lineStyle: { color: '#3f3f46' } },
|
|
||||||
},
|
|
||||||
yAxis: {
|
|
||||||
type: 'value',
|
|
||||||
axisLabel: { color: '#a1a1aa', fontSize: 10 },
|
|
||||||
splitLine: { lineStyle: { color: '#27272a' } },
|
|
||||||
},
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
type: 'bar',
|
|
||||||
data: vals.map((v, i) => ({ value: v, itemStyle: { color: colors[i] } })),
|
|
||||||
barWidth: '90%',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}, [distribution])
|
|
||||||
|
|
||||||
const chartRef = useECharts(option, [distribution])
|
|
||||||
|
|
||||||
return <div ref={chartRef} className="h-48 w-full" />
|
|
||||||
}
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
import { useMemo } from 'react'
|
|
||||||
import { useECharts } from './useECharts'
|
|
||||||
import type { StrategyBacktestResult } from '@/lib/api'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
result: StrategyBacktestResult
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StrategyNavChart({ result }: Props) {
|
|
||||||
const option = useMemo(() => {
|
|
||||||
if (!result.equity_curve.length) return null
|
|
||||||
|
|
||||||
const moneyFmt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
|
|
||||||
const valueFmt = new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
|
||||||
const axisMoneyFmt = (v: number) => {
|
|
||||||
if (Math.abs(v) >= 100_000_000) return `${(v / 100_000_000).toFixed(1)}亿`
|
|
||||||
if (Math.abs(v) >= 10_000) return `${(v / 10_000).toFixed(0)}万`
|
|
||||||
return moneyFmt.format(v)
|
|
||||||
}
|
|
||||||
const dates = result.equity_curve.map(r => r.date.slice(0, 10))
|
|
||||||
const navValues = result.equity_curve.map(r => r.value)
|
|
||||||
const benchmarkByDate = new Map((result.benchmark_curve ?? []).map(r => [r.date.slice(0, 10), r.close ?? r.value]))
|
|
||||||
const benchmarkValues = dates.map(d => benchmarkByDate.get(d) ?? null)
|
|
||||||
const hasBenchmark = benchmarkValues.some(v => v != null)
|
|
||||||
const ddValues = result.drawdown_curve.map(r => r.value * 100)
|
|
||||||
|
|
||||||
return {
|
|
||||||
animation: false,
|
|
||||||
axisPointer: {
|
|
||||||
link: [{ xAxisIndex: 'all' }],
|
|
||||||
label: { backgroundColor: '#334155' },
|
|
||||||
},
|
|
||||||
grid: [
|
|
||||||
{ left: 64, right: hasBenchmark ? 64 : 16, top: 14, bottom: '40%' },
|
|
||||||
{ left: 64, right: hasBenchmark ? 64 : 16, top: '68%', bottom: 46 },
|
|
||||||
],
|
|
||||||
xAxis: [
|
|
||||||
{
|
|
||||||
type: 'category', data: dates, gridIndex: 0,
|
|
||||||
axisLabel: { show: false }, axisTick: { show: false },
|
|
||||||
axisPointer: { show: true, type: 'line' },
|
|
||||||
axisLine: { lineStyle: { color: '#334155' } },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'category', data: dates, gridIndex: 1,
|
|
||||||
axisLabel: { color: '#64748b', fontSize: 10, interval: Math.floor(dates.length / 6) },
|
|
||||||
axisTick: { show: false },
|
|
||||||
axisPointer: { show: true, type: 'line' },
|
|
||||||
axisLine: { lineStyle: { color: '#334155' } },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
yAxis: [
|
|
||||||
{
|
|
||||||
type: 'value', gridIndex: 0,
|
|
||||||
scale: true,
|
|
||||||
name: hasBenchmark ? '上证点位' : '策略资金',
|
|
||||||
nameTextStyle: { color: hasBenchmark ? 'rgba(148,163,184,0.55)' : '#64748b', fontSize: 10, padding: [0, 0, 4, 0] },
|
|
||||||
axisLabel: {
|
|
||||||
color: hasBenchmark ? 'rgba(148,163,184,0.55)' : '#64748b',
|
|
||||||
fontSize: 10,
|
|
||||||
formatter: hasBenchmark ? ((v: number) => v.toFixed(0)) : axisMoneyFmt,
|
|
||||||
},
|
|
||||||
splitLine: { lineStyle: { color: '#1e293b' } },
|
|
||||||
axisLine: { show: false },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'value', gridIndex: 0,
|
|
||||||
position: 'right',
|
|
||||||
scale: true,
|
|
||||||
name: hasBenchmark ? '策略资金' : '',
|
|
||||||
nameTextStyle: { color: '#64748b', fontSize: 10, padding: [0, 0, 4, 0] },
|
|
||||||
axisLabel: {
|
|
||||||
show: hasBenchmark,
|
|
||||||
color: '#64748b',
|
|
||||||
fontSize: 10,
|
|
||||||
formatter: axisMoneyFmt,
|
|
||||||
},
|
|
||||||
splitLine: { show: false },
|
|
||||||
axisLine: { show: false },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'value', gridIndex: 1,
|
|
||||||
position: 'right',
|
|
||||||
max: 0,
|
|
||||||
axisLabel: {
|
|
||||||
color: '#64748b', fontSize: 10,
|
|
||||||
formatter: (v: number) => `${v.toFixed(1)}%`,
|
|
||||||
},
|
|
||||||
splitLine: { lineStyle: { color: '#1e293b' } },
|
|
||||||
axisLine: { show: false },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
dataZoom: [
|
|
||||||
{
|
|
||||||
type: 'inside',
|
|
||||||
xAxisIndex: [0, 1],
|
|
||||||
filterMode: 'filter',
|
|
||||||
zoomOnMouseWheel: true,
|
|
||||||
moveOnMouseMove: true,
|
|
||||||
moveOnMouseWheel: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'slider',
|
|
||||||
xAxisIndex: [0, 1],
|
|
||||||
filterMode: 'filter',
|
|
||||||
height: 16,
|
|
||||||
bottom: 10,
|
|
||||||
borderColor: 'rgba(148,163,184,0.18)',
|
|
||||||
backgroundColor: 'rgba(15,23,42,0.55)',
|
|
||||||
fillerColor: 'rgba(59,130,246,0.18)',
|
|
||||||
handleStyle: { color: '#64748b', borderColor: '#94a3b8' },
|
|
||||||
textStyle: { color: '#64748b', fontSize: 10 },
|
|
||||||
brushSelect: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
tooltip: {
|
|
||||||
trigger: 'axis',
|
|
||||||
backgroundColor: 'rgba(15,23,42,0.95)',
|
|
||||||
borderColor: 'rgba(148,163,184,0.2)',
|
|
||||||
textStyle: { color: '#e2e8f0', fontSize: 12 },
|
|
||||||
formatter: (params: any) => {
|
|
||||||
const date = params[0]?.axisValue ?? ''
|
|
||||||
let html = `<div style="font-size:11px;color:#94a3b8;margin-bottom:4px">${date}</div>`
|
|
||||||
for (const p of params) {
|
|
||||||
if (p.value == null) continue
|
|
||||||
const isDrawdown = p.seriesName === '回撤'
|
|
||||||
const isBenchmark = p.seriesName === '同期上证指数'
|
|
||||||
html += `<div style="display:flex;justify-content:space-between;gap:16px">
|
|
||||||
<span style="color:${p.color}">${p.seriesName}</span>
|
|
||||||
<span style="font-family:monospace">${
|
|
||||||
isDrawdown
|
|
||||||
? `${(p.value as number).toFixed(2)}%`
|
|
||||||
: isBenchmark
|
|
||||||
? `${valueFmt.format(p.value as number)} 点`
|
|
||||||
: moneyFmt.format(p.value as number)
|
|
||||||
}</span>
|
|
||||||
</div>`
|
|
||||||
}
|
|
||||||
return html
|
|
||||||
},
|
|
||||||
},
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: '净值',
|
|
||||||
type: 'line',
|
|
||||||
xAxisIndex: 0,
|
|
||||||
yAxisIndex: hasBenchmark ? 1 : 0,
|
|
||||||
data: navValues,
|
|
||||||
symbol: 'none',
|
|
||||||
lineStyle: { color: '#3b82f6', width: 2.2 },
|
|
||||||
areaStyle: {
|
|
||||||
color: {
|
|
||||||
type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
|
|
||||||
colorStops: [
|
|
||||||
{ offset: 0, color: 'rgba(59,130,246,0.15)' },
|
|
||||||
{ offset: 1, color: 'rgba(59,130,246,0.01)' },
|
|
||||||
],
|
|
||||||
} as any,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...(hasBenchmark ? [{
|
|
||||||
name: '同期上证指数',
|
|
||||||
type: 'line',
|
|
||||||
xAxisIndex: 0,
|
|
||||||
yAxisIndex: 0,
|
|
||||||
data: benchmarkValues,
|
|
||||||
symbol: 'none',
|
|
||||||
connectNulls: true,
|
|
||||||
lineStyle: { color: 'rgba(148,163,184,0.45)', width: 1, type: 'dashed' },
|
|
||||||
}] : []),
|
|
||||||
{
|
|
||||||
name: '回撤',
|
|
||||||
type: 'line',
|
|
||||||
xAxisIndex: 1,
|
|
||||||
yAxisIndex: 2,
|
|
||||||
data: ddValues,
|
|
||||||
symbol: 'none',
|
|
||||||
lineStyle: { color: 'rgba(240,68,56,0.6)', width: 1 },
|
|
||||||
areaStyle: { color: 'rgba(240,68,56,0.12)' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} as any
|
|
||||||
}, [result.equity_curve, result.drawdown_curve, result.benchmark_curve, result.run_id])
|
|
||||||
|
|
||||||
const chartRef = useECharts(option, [result.run_id])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex flex-wrap items-center gap-4 px-4 pb-2">
|
|
||||||
<span className="flex items-center gap-1.5 text-[10px] text-secondary">
|
|
||||||
<span className="w-3 h-0.5 rounded bg-accent" />
|
|
||||||
策略净值
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1.5 text-[10px] text-secondary">
|
|
||||||
<span className="w-3 h-0.5 rounded bg-red-400/60" />
|
|
||||||
回撤
|
|
||||||
</span>
|
|
||||||
{(result.benchmark_curve?.length ?? 0) > 0 && (
|
|
||||||
<span className="flex items-center gap-1.5 text-[10px] text-secondary">
|
|
||||||
<span className="w-3 h-0.5 rounded border-t border-dashed border-slate-400/60" />
|
|
||||||
同期上证指数
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="ml-auto text-[10px] text-muted">滚轮缩放 · 拖动平移</span>
|
|
||||||
</div>
|
|
||||||
<div ref={chartRef} className="h-[282px]" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
|
||||||
import * as echarts from 'echarts'
|
|
||||||
import type { ECharts, EChartsOption } from 'echarts'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ECharts 实例管理 Hook — 自动初始化/resize/销毁。
|
|
||||||
* 返回 ref 绑定到容器 div,和 setOption 方法。
|
|
||||||
*/
|
|
||||||
export function useECharts(
|
|
||||||
option: EChartsOption | null,
|
|
||||||
deps: any[] = [],
|
|
||||||
) {
|
|
||||||
const chartRef = useRef<HTMLDivElement>(null)
|
|
||||||
const instanceRef = useRef<ECharts | null>(null)
|
|
||||||
|
|
||||||
// 初始化 / 销毁
|
|
||||||
useEffect(() => {
|
|
||||||
if (!chartRef.current) return
|
|
||||||
instanceRef.current = echarts.init(chartRef.current, undefined, { renderer: 'canvas' })
|
|
||||||
const handleResize = () => instanceRef.current?.resize()
|
|
||||||
window.addEventListener('resize', handleResize)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('resize', handleResize)
|
|
||||||
instanceRef.current?.dispose()
|
|
||||||
instanceRef.current = null
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 更新 option
|
|
||||||
useEffect(() => {
|
|
||||||
if (!instanceRef.current || !option) return
|
|
||||||
instanceRef.current.setOption(option, { notMerge: true })
|
|
||||||
}, [option, ...deps])
|
|
||||||
|
|
||||||
return chartRef
|
|
||||||
}
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
|
||||||
import { AnimatePresence, motion } from 'framer-motion'
|
|
||||||
import { Clock, X } from 'lucide-react'
|
|
||||||
import { StockPanel } from '@/components/StockPanel'
|
|
||||||
import type { ChartPriceLine, ChartRange } from '@/components/EChartsCandlestick'
|
|
||||||
import type { StrategyBacktestTrade } from '@/lib/api'
|
|
||||||
import { fmtPct, fmtPrice, priceColorClass } from '@/lib/format'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
trade: StrategyBacktestTrade | null
|
|
||||||
onClose: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function addDays(date: string, days: number): string {
|
|
||||||
const d = new Date(date)
|
|
||||||
d.setDate(d.getDate() + days)
|
|
||||||
return d.toISOString().slice(0, 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtMoney(v: number | null | undefined): string {
|
|
||||||
if (v == null || Number.isNaN(Number(v))) return '—'
|
|
||||||
const n = Number(v)
|
|
||||||
const abs = Math.abs(n)
|
|
||||||
if (abs >= 100_000_000) return `${(n / 100_000_000).toFixed(2)}亿`
|
|
||||||
if (abs >= 10_000) return `${(n / 10_000).toFixed(2)}万`
|
|
||||||
return n.toFixed(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtSignedMoney(v: number | null | undefined): string {
|
|
||||||
if (v == null || Number.isNaN(Number(v))) return '—'
|
|
||||||
const prefix = Number(v) > 0 ? '+' : ''
|
|
||||||
return `${prefix}${fmtMoney(v)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TradeKlineModal({ trade, onClose }: Props) {
|
|
||||||
const [showIntraday, setShowIntraday] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!trade) return
|
|
||||||
const handler = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape') onClose()
|
|
||||||
}
|
|
||||||
document.addEventListener('keydown', handler)
|
|
||||||
return () => document.removeEventListener('keydown', handler)
|
|
||||||
}, [trade, onClose])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (trade) setShowIntraday(false)
|
|
||||||
}, [trade])
|
|
||||||
|
|
||||||
const dateRange = useMemo(() => {
|
|
||||||
if (!trade) return null
|
|
||||||
return {
|
|
||||||
start: addDays(String(trade.entry_date).slice(0, 10), -45),
|
|
||||||
end: addDays(String(trade.exit_date).slice(0, 10), 20),
|
|
||||||
}
|
|
||||||
}, [trade])
|
|
||||||
|
|
||||||
const ranges = useMemo<ChartRange[]>(() => {
|
|
||||||
if (!trade) return []
|
|
||||||
return [{
|
|
||||||
start: String(trade.entry_date).slice(0, 10),
|
|
||||||
end: String(trade.exit_date).slice(0, 10),
|
|
||||||
label: '持仓区间',
|
|
||||||
color: 'rgba(59,130,246,0.07)',
|
|
||||||
}]
|
|
||||||
}, [trade])
|
|
||||||
|
|
||||||
const priceLines = useMemo<ChartPriceLine[]>(() => {
|
|
||||||
if (!trade) return []
|
|
||||||
const start = String(trade.entry_date).slice(0, 10)
|
|
||||||
const end = String(trade.exit_date).slice(0, 10)
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
value: Number(trade.entry_price),
|
|
||||||
label: `买入价 ${fmtPrice(trade.entry_price)}`,
|
|
||||||
color: '#C74040',
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: Number(trade.exit_price),
|
|
||||||
label: `卖出价 ${fmtPrice(trade.exit_price)}`,
|
|
||||||
color: '#2D9B65',
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}, [trade])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AnimatePresence>
|
|
||||||
{trade && dateRange && (
|
|
||||||
<div className="fixed inset-0 z-[70] flex items-center justify-center">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
|
||||||
onClick={onClose}
|
|
||||||
/>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 12 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.97, y: 8 }}
|
|
||||||
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
|
||||||
className="relative flex max-h-[94vh] w-[92vw] max-w-[1120px] flex-col overflow-hidden rounded-card border border-border bg-base shadow-2xl"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-4 border-b border-border px-5 py-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-mono text-sm font-semibold text-foreground">{trade.symbol}</span>
|
|
||||||
<span className="truncate text-sm text-foreground">{trade.name || '交易回放'}</span>
|
|
||||||
<span className="rounded border border-accent/30 bg-accent/10 px-1.5 py-0.5 text-[10px] text-accent">交易回放</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-[11px] text-muted">
|
|
||||||
{String(trade.entry_date).slice(0, 10)} 买入 → {String(trade.exit_date).slice(0, 10)} 卖出 · 持仓 {trade.duration ?? '—'} 天
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 items-center gap-3 text-xs">
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-muted">买 / 卖</div>
|
|
||||||
<div className="num text-foreground">{fmtPrice(trade.entry_price)} / {fmtPrice(trade.exit_price)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-muted">盈亏</div>
|
|
||||||
<div className={`num font-semibold ${priceColorClass(trade.pnl_amount ?? trade.pnl_pct)}`}>
|
|
||||||
{fmtSignedMoney(trade.pnl_amount)} / {fmtPct(trade.pnl_pct)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowIntraday((v) => !v)}
|
|
||||||
className={`inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs transition-colors ${
|
|
||||||
showIntraday
|
|
||||||
? 'border border-accent/30 bg-accent/15 text-accent'
|
|
||||||
: 'border border-border bg-elevated text-secondary hover:border-accent/30'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Clock className="h-3 w-3" />
|
|
||||||
分时
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="rounded-btn p-1 text-secondary transition-colors hover:bg-elevated hover:text-foreground"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto p-4">
|
|
||||||
<StockPanel
|
|
||||||
symbol={trade.symbol}
|
|
||||||
height={520}
|
|
||||||
dateRange={dateRange}
|
|
||||||
ranges={ranges}
|
|
||||||
priceLines={priceLines}
|
|
||||||
showLimitMarkers={false}
|
|
||||||
showMarkerToggle={false}
|
|
||||||
showIntraday={showIntraday}
|
|
||||||
onSelectDate={() => { if (!showIntraday) setShowIntraday(true) }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from '@dnd-kit/sortable'
|
} from '@dnd-kit/sortable'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
import { Eye, EyeOff, ExternalLink, GripVertical, Settings, Bell } from 'lucide-react'
|
import { Eye, EyeOff, ExternalLink, GripVertical, Settings } from 'lucide-react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { QK } from '@/lib/queryKeys'
|
import { QK } from '@/lib/queryKeys'
|
||||||
@@ -32,9 +32,7 @@ interface NavEntry {
|
|||||||
|
|
||||||
const BUILTIN_PAGES: NavEntry[] = [
|
const BUILTIN_PAGES: NavEntry[] = [
|
||||||
{ id: '/', label: '看板', type: 'builtin', visible: true },
|
{ id: '/', label: '看板', type: 'builtin', visible: true },
|
||||||
{ id: '/watchlist', label: '自选', type: 'builtin', visible: true },
|
|
||||||
{ id: '/screener', label: '策略', type: 'builtin', visible: true },
|
{ id: '/screener', label: '策略', type: 'builtin', visible: true },
|
||||||
{ id: '/backtest', label: '回测', type: 'builtin', visible: true },
|
|
||||||
{ id: '/limit-ladder', label: '连板梯队', type: 'builtin', visible: true },
|
{ id: '/limit-ladder', label: '连板梯队', type: 'builtin', visible: true },
|
||||||
{ id: '/concept-analysis', label: '概念分析', type: 'builtin', visible: true },
|
{ id: '/concept-analysis', label: '概念分析', type: 'builtin', visible: true },
|
||||||
{ id: '/industry-analysis', label: '行业分析', type: 'builtin', visible: true },
|
{ id: '/industry-analysis', label: '行业分析', type: 'builtin', visible: true },
|
||||||
@@ -42,19 +40,14 @@ const BUILTIN_PAGES: NavEntry[] = [
|
|||||||
{ id: '/review', label: '复盘', type: 'builtin', visible: true },
|
{ id: '/review', label: '复盘', type: 'builtin', visible: true },
|
||||||
{ id: '/financials', label: '财务分析', type: 'builtin', visible: true },
|
{ id: '/financials', label: '财务分析', type: 'builtin', visible: true },
|
||||||
{ id: '/indices', label: '指数', type: 'builtin', visible: true },
|
{ id: '/indices', label: '指数', type: 'builtin', visible: true },
|
||||||
{ id: '/trading', label: '交易', type: 'builtin', visible: true },
|
|
||||||
{ id: '/monitor', label: '监控中心', type: 'builtin', visible: true },
|
|
||||||
{ id: '/data', label: '数据', type: 'builtin', visible: true },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
// ── Sortable row ──
|
// ── Sortable row ──
|
||||||
|
|
||||||
function SortableItem({ entry, hidden, onToggleHidden, badgeEnabled, onToggleBadge }: {
|
function SortableItem({ entry, hidden, onToggleHidden }: {
|
||||||
entry: NavEntry
|
entry: NavEntry
|
||||||
hidden: boolean
|
hidden: boolean
|
||||||
onToggleHidden: (id: string) => void
|
onToggleHidden: (id: string) => void
|
||||||
badgeEnabled?: boolean
|
|
||||||
onToggleBadge?: (id: string) => void
|
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
@@ -76,7 +69,7 @@ function SortableItem({ entry, hidden, onToggleHidden, badgeEnabled, onToggleBad
|
|||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
style={style}
|
||||||
className={`grid grid-cols-[2.5rem_1fr_4.5rem_3rem_3rem_3rem] items-center border-b border-border/70 px-4 py-3 last:border-b-0 ${
|
className={`grid grid-cols-[2.5rem_1fr_4.5rem_3rem_3rem] items-center border-b border-border/70 px-4 py-3 last:border-b-0 ${
|
||||||
isDragging ? 'bg-elevated rounded-lg shadow-lg' : ''
|
isDragging ? 'bg-elevated rounded-lg shadow-lg' : ''
|
||||||
} ${hidden ? 'opacity-50' : ''}`}
|
} ${hidden ? 'opacity-50' : ''}`}
|
||||||
>
|
>
|
||||||
@@ -135,22 +128,6 @@ function SortableItem({ entry, hidden, onToggleHidden, badgeEnabled, onToggleBad
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* 第 6 列: 徽标开关 (仅监控中心) */}
|
|
||||||
<div className="flex justify-center">
|
|
||||||
{onToggleBadge && (
|
|
||||||
<button
|
|
||||||
onClick={() => onToggleBadge(entry.id)}
|
|
||||||
className={`rounded p-1 transition-colors ${
|
|
||||||
badgeEnabled
|
|
||||||
? 'text-accent hover:bg-accent/10'
|
|
||||||
: 'text-muted hover:text-accent hover:bg-accent/10'
|
|
||||||
}`}
|
|
||||||
title={badgeEnabled ? '关闭数字提示' : '开启数字提示'}
|
|
||||||
>
|
|
||||||
<Bell className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -249,16 +226,6 @@ export function SettingsMenuSettingsPanel() {
|
|||||||
saveNavHidden.mutate([...next])
|
saveNavHidden.mutate([...next])
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监控中心徽标开关 (localStorage)
|
|
||||||
const [badgeEnabled, setBadgeEnabled] = useState(() => {
|
|
||||||
try { return localStorage.getItem('monitor_badge_enabled') !== '0' } catch { return true }
|
|
||||||
})
|
|
||||||
const toggleBadge = (id: string) => {
|
|
||||||
if (id !== '/monitor') return
|
|
||||||
const next = !badgeEnabled
|
|
||||||
setBadgeEnabled(next)
|
|
||||||
try { localStorage.setItem('monitor_badge_enabled', next ? '1' : '0') } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-5xl space-y-6">
|
<div className="max-w-5xl space-y-6">
|
||||||
@@ -271,13 +238,12 @@ export function SettingsMenuSettingsPanel() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-card border border-border bg-surface overflow-hidden">
|
<section className="rounded-card border border-border bg-surface overflow-hidden">
|
||||||
<div className="grid grid-cols-[2.5rem_1fr_4.5rem_3rem_3rem_3rem] items-center border-b border-border px-4 py-2 text-[11px] text-muted">
|
<div className="grid grid-cols-[2.5rem_1fr_4.5rem_3rem_3rem] items-center border-b border-border px-4 py-2 text-[11px] text-muted">
|
||||||
<div />
|
<div />
|
||||||
<div>菜单</div>
|
<div>菜单</div>
|
||||||
<div>类型</div>
|
<div>类型</div>
|
||||||
<div className="text-center">显示</div>
|
<div className="text-center">显示</div>
|
||||||
<div className="text-center">设置</div>
|
<div className="text-center">设置</div>
|
||||||
<div className="text-center">数字</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DndContext
|
<DndContext
|
||||||
@@ -295,8 +261,6 @@ export function SettingsMenuSettingsPanel() {
|
|||||||
entry={entry}
|
entry={entry}
|
||||||
hidden={hiddenSet.has(entry.id)}
|
hidden={hiddenSet.has(entry.id)}
|
||||||
onToggleHidden={toggleHidden}
|
onToggleHidden={toggleHidden}
|
||||||
badgeEnabled={entry.id === '/monitor' ? badgeEnabled : undefined}
|
|
||||||
onToggleBadge={entry.id === '/monitor' ? toggleBadge : undefined}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
|
|||||||
@@ -1,572 +1,8 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
// Real-time monitoring settings have been removed.
|
||||||
import { Link } from 'react-router-dom'
|
export function SettingsMonitoringPanel(_props?: { highlight?: string }) {
|
||||||
import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query'
|
|
||||||
import {
|
|
||||||
Activity,
|
|
||||||
Wifi,
|
|
||||||
BarChart3,
|
|
||||||
Flame,
|
|
||||||
Zap,
|
|
||||||
Webhook,
|
|
||||||
ChevronDown,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import {
|
|
||||||
usePreferences,
|
|
||||||
useQuoteStatus,
|
|
||||||
useQuoteInterval,
|
|
||||||
useCapabilities,
|
|
||||||
} from '@/lib/useSharedQueries'
|
|
||||||
import { useUpdateQuoteInterval, useToggleRealtimeQuotes } from '@/lib/useSharedMutations'
|
|
||||||
import { api } from '@/lib/api'
|
|
||||||
import { QK } from '@/lib/queryKeys'
|
|
||||||
import { tierRank } from '@/lib/capability-labels'
|
|
||||||
import { toast } from '@/components/Toast'
|
|
||||||
import { DepthConfigContent } from '@/components/data/DepthConfigCard'
|
|
||||||
|
|
||||||
// 页面 → 显示名
|
|
||||||
const PAGE_LABELS: Record<string, string> = {
|
|
||||||
'overview-market': '看板',
|
|
||||||
watchlist: '自选页',
|
|
||||||
'limit-ladder': '连板梯队',
|
|
||||||
}
|
|
||||||
|
|
||||||
const SIDEBAR_INDEX_OPTIONS = [
|
|
||||||
{ symbol: '000001.SH', name: '上证指数' },
|
|
||||||
{ symbol: '399001.SZ', name: '深证成指' },
|
|
||||||
{ symbol: '399006.SZ', name: '创业板指' },
|
|
||||||
{ symbol: '000680.SH', name: '科创综指' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// ===== 导出为 Panel 组件 (由 Settings.tsx 嵌入) =====
|
|
||||||
|
|
||||||
export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = {}) {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
const { data: prefs } = usePreferences()
|
|
||||||
const { data: caps } = useCapabilities()
|
|
||||||
const { data: quoteStatus } = useQuoteStatus()
|
|
||||||
const { data: intervalData } = useQuoteInterval()
|
|
||||||
const updateInterval = useUpdateQuoteInterval()
|
|
||||||
const toggleQuote = useToggleRealtimeQuotes()
|
|
||||||
const tier = tierRank(caps?.label ?? '')
|
|
||||||
const isNoneTier = tier < 0
|
|
||||||
const isFreeTier = tier === 0
|
|
||||||
const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false
|
|
||||||
const refreshPages = prefs?.sse_refresh_pages ?? {}
|
|
||||||
const limitLadderMonitor = prefs?.limit_ladder_monitor_enabled ?? false
|
|
||||||
const hasDepth = !!caps?.capabilities?.['depth5.batch']
|
|
||||||
// 新建监控规则时是否默认勾选飞书推送 (全局默认值, 单条规则可独立修改)
|
|
||||||
const webhookDefault = prefs?.webhook_enabled_default ?? false
|
|
||||||
const sidebarIndexSymbols = prefs?.sidebar_index_symbols ?? SIDEBAR_INDEX_OPTIONS.map(i => i.symbol)
|
|
||||||
const indicesPinned = prefs?.indices_nav_pinned ?? true
|
|
||||||
const isRunning = quoteStatus?.running ?? false
|
|
||||||
const isTrading = quoteStatus?.is_trading_hours ?? false
|
|
||||||
const interval = intervalData?.interval ?? 10
|
|
||||||
const minInterval = intervalData?.min_interval ?? 5
|
|
||||||
const maxInterval = intervalData?.max_interval ?? 60
|
|
||||||
const [intervalDraft, setIntervalDraft] = useState(interval)
|
|
||||||
const feishuWebhookUrl = prefs?.feishu_webhook_url ?? ''
|
|
||||||
const feishuWebhookSecret = prefs?.feishu_webhook_secret ?? ''
|
|
||||||
const [feishuDraft, setFeishuDraft] = useState(feishuWebhookUrl)
|
|
||||||
const [feishuSecretDraft, setFeishuSecretDraft] = useState(feishuWebhookSecret)
|
|
||||||
const [feishuError, setFeishuError] = useState('')
|
|
||||||
// 飞书渠道配置区展开态 (推送通知卡片内)
|
|
||||||
const [channelOpen, setChannelOpen] = useState(false)
|
|
||||||
useEffect(() => {
|
|
||||||
setFeishuDraft(feishuWebhookUrl)
|
|
||||||
setFeishuSecretDraft(feishuWebhookSecret)
|
|
||||||
}, [feishuWebhookUrl, feishuWebhookSecret])
|
|
||||||
const watchlistSymbols = prefs?.realtime_watchlist_symbols ?? []
|
|
||||||
const watchlist = useQuery({
|
|
||||||
queryKey: QK.watchlist,
|
|
||||||
queryFn: () => api.watchlistList(),
|
|
||||||
enabled: isFreeTier && watchlistSymbols.length > 0,
|
|
||||||
})
|
|
||||||
const watchlistNameBySymbol = new Map(
|
|
||||||
(watchlist.data?.symbols ?? []).map(row => [row.symbol, row.name] as const),
|
|
||||||
)
|
|
||||||
|
|
||||||
const save = useCallback(async (cfg: Record<string, unknown>) => {
|
|
||||||
try {
|
|
||||||
await api.updateRealtimeMonitorConfig(cfg)
|
|
||||||
qc.invalidateQueries({ queryKey: QK.preferences })
|
|
||||||
} catch (e) {
|
|
||||||
// 忽略 — Toast 已在 request 层处理
|
|
||||||
}
|
|
||||||
}, [qc])
|
|
||||||
|
|
||||||
const handleToggleQuote = useCallback(async (enabled: boolean) => {
|
|
||||||
await toggleQuote.mutateAsync(enabled)
|
|
||||||
qc.invalidateQueries({ queryKey: QK.preferences })
|
|
||||||
qc.invalidateQueries({ queryKey: QK.quoteStatus })
|
|
||||||
}, [toggleQuote, qc])
|
|
||||||
|
|
||||||
const toggleSidebarIndex = useCallback((symbol: string, visible: boolean) => {
|
|
||||||
const selected = new Set(sidebarIndexSymbols)
|
|
||||||
if (visible) selected.add(symbol)
|
|
||||||
else selected.delete(symbol)
|
|
||||||
const next = SIDEBAR_INDEX_OPTIONS
|
|
||||||
.map(item => item.symbol)
|
|
||||||
.filter(s => selected.has(s))
|
|
||||||
save({ sidebar_index_symbols: next })
|
|
||||||
}, [save, sidebarIndexSymbols])
|
|
||||||
|
|
||||||
const toggleIndicesPin = useCallback((pinned: boolean) => {
|
|
||||||
api.updateIndicesNavPinned(pinned).then(() => qc.invalidateQueries({ queryKey: QK.preferences }))
|
|
||||||
}, [qc])
|
|
||||||
|
|
||||||
const toggleLimitLadderMonitor = useCallback(async (enabled: boolean) => {
|
|
||||||
await api.updateLimitLadderMonitor(enabled)
|
|
||||||
qc.invalidateQueries({ queryKey: QK.preferences })
|
|
||||||
}, [qc])
|
|
||||||
|
|
||||||
const toggleWebhookDefault = useCallback(async (enabled: boolean) => {
|
|
||||||
await api.updateWebhookDefault(enabled)
|
|
||||||
qc.invalidateQueries({ queryKey: QK.preferences })
|
|
||||||
}, [qc])
|
|
||||||
|
|
||||||
const saveFeishuWebhook = useMutation({
|
|
||||||
mutationFn: ({ url, secret }: { url: string; secret: string }) => api.updateFeishuWebhook(url, secret),
|
|
||||||
onSuccess: () => {
|
|
||||||
setFeishuError('')
|
|
||||||
toast('飞书 Webhook 已保存', 'success')
|
|
||||||
qc.invalidateQueries({ queryKey: QK.preferences })
|
|
||||||
},
|
|
||||||
onError: (err: any) => setFeishuError(String(err?.message ?? '保存失败')),
|
|
||||||
})
|
|
||||||
const FEISHU_PREFIX = 'https://open.feishu.cn/open-apis/bot/v2/hook/'
|
|
||||||
const submitFeishu = useCallback(() => {
|
|
||||||
const url = feishuDraft.trim()
|
|
||||||
const secret = feishuSecretDraft.trim()
|
|
||||||
if (url && !url.startsWith(FEISHU_PREFIX)) {
|
|
||||||
setFeishuError('地址需以 ' + FEISHU_PREFIX + ' 开头')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
saveFeishuWebhook.mutate({ url, secret })
|
|
||||||
}, [feishuDraft, feishuSecretDraft, saveFeishuWebhook])
|
|
||||||
|
|
||||||
const runFix = useMutation({
|
|
||||||
mutationFn: () => api.runLimitLadderFix(),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
toast(data.msg, data.ok ? 'success' : 'error')
|
|
||||||
// 修正后连板梯队数据变了, 刷新相关缓存
|
|
||||||
qc.invalidateQueries({ queryKey: ['limit-ladder'] })
|
|
||||||
},
|
|
||||||
onError: () => toast('修正请求失败', 'error'),
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIntervalDraft(interval)
|
|
||||||
}, [interval])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (intervalDraft === interval) return
|
|
||||||
const t = window.setTimeout(() => {
|
|
||||||
updateInterval.mutate(intervalDraft)
|
|
||||||
}, 2000)
|
|
||||||
return () => window.clearTimeout(t)
|
|
||||||
}, [intervalDraft, interval, updateInterval])
|
|
||||||
|
|
||||||
// highlight=depth-fix 时闪烁高亮连板梯队修正卡片
|
|
||||||
const [flash, setFlash] = useState(false)
|
|
||||||
const flashedRef = useRef(false)
|
|
||||||
useEffect(() => {
|
|
||||||
if (highlight === 'depth-fix' && !flashedRef.current) {
|
|
||||||
flashedRef.current = true
|
|
||||||
// 延迟一帧确保 DOM 已渲染, 再触发闪烁
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
setFlash(true)
|
|
||||||
const t = setTimeout(() => setFlash(false), 2000)
|
|
||||||
return () => clearTimeout(t)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [highlight])
|
|
||||||
|
|
||||||
if (isNoneTier) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl
|
<p className="text-sm text-muted">实时监控配置已移除</p>
|
||||||
bg-gradient-to-br from-purple-500/20 to-blue-500/20 mb-5">
|
|
||||||
<Activity className="h-7 w-7 text-purple-400" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-lg font-medium text-foreground mb-2">实时监控</h2>
|
|
||||||
<p className="text-sm text-secondary max-w-md mb-6">
|
|
||||||
实时行情需要 Free 及以上档位。None 档可使用 free-api 获取历史日K(当日数据需盘后1-2小时),但不能调用付费服务器实时接口。
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="/settings?tab=account"
|
|
||||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-btn
|
|
||||||
bg-accent text-white text-sm font-medium
|
|
||||||
hover:bg-accent/90 transition-colors"
|
|
||||||
>
|
|
||||||
配置 API Key 升级
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_1fr] gap-6 max-w-5xl">
|
|
||||||
{/* ========== 左列 ========== */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* 行情状态 — 开关 + 间隔 */}
|
|
||||||
<Card icon={Activity} title="行情轮询">
|
|
||||||
<ToggleRow
|
|
||||||
label="实时行情"
|
|
||||||
desc={isRunning && isTrading ? '运行中' : isRunning ? '运行中 (非交易时段)' : '已关闭'}
|
|
||||||
checked={realtimeEnabled}
|
|
||||||
onChange={handleToggleQuote}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-3 pt-3 border-t border-border">
|
|
||||||
<div className="flex items-center justify-between gap-4 py-1">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text-sm text-foreground">轮询间隔</div>
|
|
||||||
<div className="text-[11px] text-muted">
|
|
||||||
{isFreeTier ? '每轮拉取自选股实时行情的时间间隔' : '每轮拉取全市场行情的时间间隔'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="text-[11px] font-mono text-foreground shrink-0 tabular-nums">
|
|
||||||
{intervalDraft < 1 ? intervalDraft.toFixed(1) : intervalDraft.toFixed(0)}s
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 mt-2">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={minInterval}
|
|
||||||
max={maxInterval}
|
|
||||||
step={minInterval < 1 ? 0.1 : minInterval < 3 ? 0.5 : 1}
|
|
||||||
value={intervalDraft}
|
|
||||||
onChange={(e) => setIntervalDraft(parseFloat(e.target.value))}
|
|
||||||
className="flex-1 h-1 accent-accent cursor-pointer"
|
|
||||||
/>
|
|
||||||
<span className="text-[10px] text-muted shrink-0">
|
|
||||||
{intervalDraft !== interval ? '2秒后保存' : `${minInterval}s — ${maxInterval}s`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{isFreeTier && (
|
|
||||||
<Card icon={Activity} title="自选股实时">
|
|
||||||
<div className="mb-3 rounded-btn border border-accent/25 bg-accent/10 px-3 py-2 text-xs font-medium leading-snug text-accent">
|
|
||||||
Free 档开启实时行情时自动监控「自选」页面前 5 个标的,最低 6 秒刷新。
|
|
||||||
</div>
|
|
||||||
{watchlistSymbols.length > 0 ? (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{watchlistSymbols.map(symbol => {
|
|
||||||
const name = watchlistNameBySymbol.get(symbol)
|
|
||||||
return (
|
|
||||||
<div key={symbol} className="flex items-center justify-between rounded-btn bg-base/50 border border-border px-2 py-1.5">
|
|
||||||
<div className="min-w-0 flex items-baseline gap-1.5">
|
|
||||||
<span className="text-xs font-mono text-foreground">{symbol}</span>
|
|
||||||
{name && <span className="truncate text-[11px] text-secondary">{name}</span>}
|
|
||||||
</div>
|
|
||||||
<span className="text-[10px] text-muted shrink-0">自选页</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-btn border border-border bg-base/40 px-3 py-3 text-xs text-muted">
|
|
||||||
自选列表为空,Free 实时行情开启前请先添加自选股。
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="mt-2 flex items-center justify-between gap-3">
|
|
||||||
<span className="text-[10px] text-muted">当前 {watchlistSymbols.length}/5 只</span>
|
|
||||||
<Link
|
|
||||||
to="/watchlist"
|
|
||||||
className="px-3 py-1 rounded-btn bg-elevated text-secondary text-xs font-medium hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
管理自选
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
{!isFreeTier && (
|
|
||||||
<Card icon={Wifi} title="页面实时刷新">
|
|
||||||
<p className="text-xs text-secondary mb-4">
|
|
||||||
选择哪些页面跟随 SSE 实时刷新数据。关闭的页面不会被推送,
|
|
||||||
但行情轮询和策略监控不受影响。
|
|
||||||
</p>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{Object.entries(PAGE_LABELS).map(([key, label]) => (
|
|
||||||
<ToggleRow
|
|
||||||
key={key}
|
|
||||||
label={label}
|
|
||||||
desc={`SSE 推送时刷新 ${label} 数据`}
|
|
||||||
checked={refreshPages[key] !== false}
|
|
||||||
onChange={(v) => save({ sse_refresh_pages: { ...refreshPages, [key]: v } })}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isFreeTier && (
|
|
||||||
<Card icon={BarChart3} title="左侧菜单指数">
|
|
||||||
<p className="text-xs text-secondary mb-4">
|
|
||||||
选择实时行情开启时,左侧菜单底部显示哪些指数点位和涨跌幅。
|
|
||||||
</p>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{SIDEBAR_INDEX_OPTIONS.map(item => (
|
|
||||||
<ToggleRow
|
|
||||||
key={item.symbol}
|
|
||||||
label={item.name}
|
|
||||||
desc={item.symbol}
|
|
||||||
checked={sidebarIndexSymbols.includes(item.symbol)}
|
|
||||||
onChange={(v) => toggleSidebarIndex(item.symbol, v)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 pt-3 border-t border-border">
|
|
||||||
<ToggleRow
|
|
||||||
label="固定显示"
|
|
||||||
desc={indicesPinned ? '指数卡片常驻显示(即使实时行情关闭)' : '跟随实时行情开关(仅实时开时显示)'}
|
|
||||||
checked={indicesPinned}
|
|
||||||
onChange={toggleIndicesPin}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ========== 右列 ========== */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* 连板梯队降级修正 (移至右列顶部) */}
|
|
||||||
<div
|
|
||||||
id="depth-fix"
|
|
||||||
className={`rounded-card transition-all duration-500 ${flash ? 'ring-2 ring-accent/60 ring-offset-2 ring-offset-base scale-[1.01]' : 'ring-0 ring-transparent'}`}
|
|
||||||
>
|
|
||||||
<Card
|
|
||||||
icon={Flame}
|
|
||||||
title="连板梯队降级修正"
|
|
||||||
badge={!hasDepth ? '需 Pro+' : undefined}
|
|
||||||
right={hasDepth ? (
|
|
||||||
<button
|
|
||||||
onClick={() => runFix.mutate()}
|
|
||||||
disabled={runFix.isPending}
|
|
||||||
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px]
|
|
||||||
bg-accent/15 text-accent hover:bg-accent/25 transition-colors
|
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<Zap className="h-3 w-3" />
|
|
||||||
{runFix.isPending ? '修正中…' : '立即修正'}
|
|
||||||
</button>
|
|
||||||
) : undefined}
|
|
||||||
>
|
|
||||||
{hasDepth ? (
|
|
||||||
<>
|
|
||||||
<p className="text-xs text-secondary mb-4">
|
|
||||||
通过五档盘口实时修正真假涨停/跌停。真封板显示封单量,假涨停(收盘价=涨停价但卖一有量)归入炸板。
|
|
||||||
盘中按设定间隔轮询,收盘后自动定版。
|
|
||||||
</p>
|
|
||||||
<ToggleRow
|
|
||||||
label="启用真假板修正"
|
|
||||||
desc="开启后盘中自动拉取五档盘口修正真假板"
|
|
||||||
checked={limitLadderMonitor}
|
|
||||||
onChange={toggleLimitLadderMonitor}
|
|
||||||
/>
|
|
||||||
<div className="mt-4 pt-3 border-t border-border">
|
|
||||||
<div className="text-[10px] uppercase tracking-widest text-muted mb-3">
|
|
||||||
五档盘口配置
|
|
||||||
</div>
|
|
||||||
<DepthConfigContent disabled={!limitLadderMonitor} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<DepthConfigContent disabled />
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 推送通知 — 监控告警的外部推送渠道 (全局配置)。
|
|
||||||
飞书已实现; 微信开发中, QMT/ptrade 待定。
|
|
||||||
每个渠道合并成一行: 勾选=新建规则默认推送, 点行展开地址配置。 */}
|
|
||||||
<Card icon={Webhook} title="推送通知">
|
|
||||||
<p className="text-xs text-secondary mb-3">
|
|
||||||
监控规则命中后,可把告警推送到外部。勾选渠道作为<b className="text-foreground/80">新建规则的默认推送</b>,
|
|
||||||
单条规则仍可在编辑页独立修改。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* 渠道列表 — 每行一个渠道, 勾选默认 + 点行展开地址配置 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
{/* 飞书 (可用): 勾选默认 + 展开地址配置 */}
|
|
||||||
<div className="rounded-btn border border-border/60 bg-base/40 overflow-hidden">
|
|
||||||
<div
|
|
||||||
onClick={() => setChannelOpen(o => !o)}
|
|
||||||
className="flex items-center gap-2 px-2.5 py-2 cursor-pointer transition-colors hover:bg-base/60"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={webhookDefault}
|
|
||||||
onChange={e => { e.stopPropagation(); toggleWebhookDefault(e.target.checked) }}
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
title="作为新建规则的默认推送渠道"
|
|
||||||
className="h-3 w-3 accent-accent cursor-pointer"
|
|
||||||
/>
|
|
||||||
<span className="text-[11px] font-medium text-foreground">飞书</span>
|
|
||||||
<span className="text-[9px] text-muted">群机器人</span>
|
|
||||||
{webhookDefault && (
|
|
||||||
<span className="rounded bg-accent/15 px-1 py-px text-[9px] text-accent">默认</span>
|
|
||||||
)}
|
|
||||||
<span className={`ml-auto text-[9px] ${feishuWebhookUrl ? 'text-emerald-500' : 'text-warning'}`}>
|
|
||||||
{feishuWebhookUrl ? '已配置' : '未配置'}
|
|
||||||
</span>
|
|
||||||
<ChevronDown className={`h-3 w-3 text-muted transition-transform ${channelOpen ? 'rotate-180' : ''}`} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 飞书地址配置 — 行内展开 */}
|
|
||||||
{channelOpen && (
|
|
||||||
<div className="border-t border-border/60 bg-base/30 p-3">
|
|
||||||
<label className="block space-y-1.5">
|
|
||||||
<span className="text-[11px] text-muted">Webhook 地址</span>
|
|
||||||
<input
|
|
||||||
value={feishuDraft}
|
|
||||||
onChange={e => setFeishuDraft(e.target.value)}
|
|
||||||
placeholder={FEISHU_PREFIX + 'xxxxxxxx'}
|
|
||||||
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground focus:outline-none focus:border-accent/50"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="block mt-2 space-y-1.5">
|
|
||||||
<span className="text-[11px] text-muted">签名密钥 (可选 · 启用签名校验时填)</span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={feishuSecretDraft}
|
|
||||||
onChange={e => setFeishuSecretDraft(e.target.value)}
|
|
||||||
placeholder="机器人未启用签名校验则留空"
|
|
||||||
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground focus:outline-none focus:border-accent/50"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{feishuError && (
|
|
||||||
<div className="mt-2 text-[11px] text-danger">{feishuError}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-2 flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={submitFeishu}
|
|
||||||
disabled={saveFeishuWebhook.isPending || (feishuDraft.trim() === feishuWebhookUrl && feishuSecretDraft.trim() === feishuWebhookSecret)}
|
|
||||||
className="px-3 py-1.5 rounded-btn bg-accent text-base text-xs font-medium disabled:opacity-50 cursor-pointer hover:bg-accent/90 transition-colors"
|
|
||||||
>
|
|
||||||
{saveFeishuWebhook.isPending ? '保存中…' : '保存'}
|
|
||||||
</button>
|
|
||||||
{feishuWebhookUrl && (
|
|
||||||
<span className="text-[10px] text-emerald-500">● 已配置</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<details className="mt-3 text-[10px] text-muted">
|
|
||||||
<summary className="cursor-pointer hover:text-secondary">如何获取飞书 Webhook 地址?</summary>
|
|
||||||
<ol className="mt-1.5 space-y-1 pl-4 list-decimal leading-relaxed">
|
|
||||||
<li>打开飞书,进入目标群聊 → 群设置 → <b>群机器人</b></li>
|
|
||||||
<li>点击「添加机器人」→ 选择「<b>自定义机器人</b>」</li>
|
|
||||||
<li>填写机器人名称后添加,复制生成的 Webhook 地址</li>
|
|
||||||
<li>安全设置若启用了「<b>签名校验</b>」,把密钥一并复制填到「签名密钥」框</li>
|
|
||||||
<li>粘贴到上方输入框并保存</li>
|
|
||||||
</ol>
|
|
||||||
<p className="mt-1.5 pl-4 text-muted/70">
|
|
||||||
📖 官方文档:
|
|
||||||
<a href="https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot?lang=zh-CN" target="_blank" rel="noreferrer" className="text-accent hover:text-accent/80">
|
|
||||||
自定义机器人使用指南 ↗
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 占位渠道 — 不可点 */}
|
|
||||||
{[
|
|
||||||
{ name: '微信', hint: '公众号/企业微信', status: '开发中' },
|
|
||||||
{ name: 'QMT', hint: '量化交易终端', status: '待定' },
|
|
||||||
{ name: 'ptrade', hint: '量化交易终端', status: '待定' },
|
|
||||||
].map(ch => (
|
|
||||||
<div
|
|
||||||
key={ch.name}
|
|
||||||
className="flex items-center gap-2 rounded-btn border border-border/40 bg-base/20 px-2.5 py-2 opacity-60"
|
|
||||||
>
|
|
||||||
<input type="checkbox" disabled className="h-3 w-3 accent-accent" />
|
|
||||||
<span className="text-[11px] text-secondary">{ch.name}</span>
|
|
||||||
<span className="text-[9px] text-muted">{ch.hint}</span>
|
|
||||||
<span className="ml-auto rounded bg-muted/10 px-1 py-px text-[9px] text-muted">{ch.status}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ===== ToggleRow =====
|
|
||||||
|
|
||||||
function ToggleRow({
|
|
||||||
label,
|
|
||||||
desc,
|
|
||||||
checked,
|
|
||||||
onChange,
|
|
||||||
icon: Icon,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
desc: string
|
|
||||||
checked: boolean
|
|
||||||
onChange: (v: boolean) => void
|
|
||||||
icon?: React.ComponentType<{ className?: string }>
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between gap-4 py-2">
|
|
||||||
<div className="min-w-0 flex items-start gap-2">
|
|
||||||
{Icon && <Icon className="h-3.5 w-3.5 text-secondary shrink-0 mt-0.5" />}
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text-sm text-foreground">{label}</div>
|
|
||||||
<div className="text-[11px] text-muted truncate">{desc}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => onChange(!checked)}
|
|
||||||
className={`relative inline-flex h-5 w-9 items-center rounded-full shrink-0 transition-colors duration-200 ${
|
|
||||||
checked ? 'bg-accent' : 'bg-elevated'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform duration-200 ${
|
|
||||||
checked ? 'translate-x-[18px]' : 'translate-x-[3px]'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ===== 通用卡片 =====
|
|
||||||
|
|
||||||
interface CardProps {
|
|
||||||
icon: React.ComponentType<{ className?: string }>
|
|
||||||
title: string
|
|
||||||
badge?: string
|
|
||||||
right?: React.ReactNode
|
|
||||||
children: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
function Card({ icon: Icon, title, badge, right, children }: CardProps) {
|
|
||||||
return (
|
|
||||||
<section className="rounded-card border border-border bg-surface p-5">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<Icon className="h-4 w-4 text-secondary" />
|
|
||||||
<h2 className="text-sm font-medium text-foreground">{title}</h2>
|
|
||||||
{badge && (
|
|
||||||
<span className="px-1.5 py-0.5 text-[10px] font-mono rounded bg-elevated text-muted">
|
|
||||||
{badge}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{right}
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { useState, useCallback } from 'react'
|
|||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { Settings2, Trash2, RefreshCw, Bell, Volume2, Info } from 'lucide-react'
|
import { Settings2, Trash2, RefreshCw, Bell, Volume2, Info } from 'lucide-react'
|
||||||
import { usePreferences, useVersion } from '@/lib/useSharedQueries'
|
import { usePreferences, useVersion } from '@/lib/useSharedQueries'
|
||||||
import { api } from '@/lib/api'
|
|
||||||
import { QK } from '@/lib/queryKeys'
|
import { QK } from '@/lib/queryKeys'
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
import { refreshAlertToastConfig } from '@/components/AlertToast'
|
import { refreshAlertToastConfig } from '@/components/AlertToast'
|
||||||
@@ -40,7 +39,11 @@ export function SettingsSystemPanel() {
|
|||||||
const save = useCallback(async (cfg: Record<string, unknown>) => {
|
const save = useCallback(async (cfg: Record<string, unknown>) => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
await api.updateRealtimeMonitorConfig(cfg)
|
await fetch('/api/settings/preferences/realtime-monitor', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(cfg),
|
||||||
|
})
|
||||||
qc.invalidateQueries({ queryKey: QK.preferences })
|
qc.invalidateQueries({ queryKey: QK.preferences })
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import { createBrowserRouter, Navigate } from 'react-router-dom'
|
import { createBrowserRouter, Navigate } from 'react-router-dom'
|
||||||
import { Layout } from './components/Layout'
|
import { Layout } from './components/Layout'
|
||||||
import { Watchlist } from './pages/Watchlist'
|
|
||||||
import { Screener } from './pages/Screener'
|
import { Screener } from './pages/Screener'
|
||||||
import { Backtest } from './pages/Backtest'
|
|
||||||
import { Financials } from './pages/Financials'
|
import { Financials } from './pages/Financials'
|
||||||
import { Onboarding } from './pages/Onboarding'
|
import { Onboarding } from './pages/Onboarding'
|
||||||
import { Auth } from './pages/Auth'
|
import { Auth } from './pages/Auth'
|
||||||
import { Data } from './pages/Data'
|
import { Data } from './pages/Data'
|
||||||
import { Monitor } from './pages/Monitor'
|
import { Monitor } from './pages/Monitor'
|
||||||
import { Trading } from './pages/Trading'
|
|
||||||
import { Dashboard } from './pages/Dashboard'
|
import { Dashboard } from './pages/Dashboard'
|
||||||
import { AnalysisDetail } from './pages/AnalysisDetail'
|
import { AnalysisDetail } from './pages/AnalysisDetail'
|
||||||
import { ConceptAnalysis } from './pages/ConceptAnalysis'
|
import { ConceptAnalysis } from './pages/ConceptAnalysis'
|
||||||
@@ -70,13 +67,10 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'industry-analysis', element: <IndustryAnalysis /> },
|
{ path: 'industry-analysis', element: <IndustryAnalysis /> },
|
||||||
{ path: 'stock-analysis', element: <StockAnalysis /> },
|
{ path: 'stock-analysis', element: <StockAnalysis /> },
|
||||||
{ path: 'review', element: <Review /> },
|
{ path: 'review', element: <Review /> },
|
||||||
{ path: 'watchlist', element: <Watchlist /> },
|
|
||||||
{ path: 'screener', element: <Screener /> },
|
{ path: 'screener', element: <Screener /> },
|
||||||
{ path: 'backtest', element: <Backtest /> },
|
|
||||||
{ path: 'financials', element: <Financials /> },
|
{ path: 'financials', element: <Financials /> },
|
||||||
{ path: 'data', element: <Data /> },
|
{ path: 'data', element: <Data /> },
|
||||||
{ path: 'monitor', element: <Monitor /> },
|
{ path: 'monitor', element: <Monitor /> },
|
||||||
{ path: 'trading', element: <Trading /> },
|
|
||||||
{ path: 'limit-ladder', element: <LimitUpLadder /> },
|
{ path: 'limit-ladder', element: <LimitUpLadder /> },
|
||||||
{ path: 'indices', element: <Indices /> },
|
{ path: 'indices', element: <Indices /> },
|
||||||
{ path: 'branding', element: <Branding /> },
|
{ path: 'branding', element: <Branding /> },
|
||||||
|
|||||||
@@ -16,16 +16,6 @@ export default defineConfig({
|
|||||||
// dev 时 /api 转发到 FastAPI
|
// dev 时 /api 转发到 FastAPI
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:3018',
|
target: 'http://localhost:3018',
|
||||||
// SSE 端点需要禁用缓冲
|
|
||||||
configure: (proxy) => {
|
|
||||||
proxy.on('proxyReq', (_proxyReq, req) => {
|
|
||||||
if (req.url?.includes('/stream')) {
|
|
||||||
_proxyReq.setHeader('Accept', 'text/event-stream')
|
|
||||||
_proxyReq.setHeader('Cache-Control', 'no-cache')
|
|
||||||
_proxyReq.setHeader('Connection', 'keep-alive')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
'/health': 'http://localhost:3018',
|
'/health': 'http://localhost:3018',
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 653 KiB |
Reference in New Issue
Block a user