Compare commits

...

7 Commits

Author SHA1 Message Date
fish db978240da 将 serve/data 加入忽略文件
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 18:06:18 +08:00
fish 0be67be799 修复未使用变量的构建错误
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 17:58:54 +08:00
fish aa76c19aff 隐藏数据与监控中心菜单入口
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 17:57:06 +08:00
fish f8c946e8a9 移除交易功能
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 17:54:41 +08:00
fish a45aa5b033 移除自选和回测功能
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-04 17:41:08 +08:00
fish ab6282b94c 修复 gitignore 匹配 package-lock.json 路径
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-04 17:33:53 +08:00
fish 9904854cc1 删除实时行情、盘口深度和监控规则功能
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-04 17:32:39 +08:00
87 changed files with 160 additions and 16345 deletions
+3 -1
View File
@@ -44,7 +44,7 @@ backend/static/
backend/app/static/
# ===== npm lockfile (不纳入版本控制) =====
frontend/package-lock.json
**/package-lock.json
# ===== IDE =====
.idea/
@@ -58,6 +58,8 @@ backend/data/**
!backend/data/.gitkeep
data/**
!data/.gitkeep
serve/data/**
!serve/data/.gitkeep
# 扩展数据: 全部纳入版本控制(含 config.json 表结构 + part.parquet 数据文件)
# 注意 gitignore 否定规则特性: 需先放行目录才能放行内部文件
!data/ext_data/
+3
View File
@@ -8,6 +8,9 @@ frontend/dist
dist
build
# Project data
data
# Git
.git
.gitignore
-20
View File
@@ -120,25 +120,5 @@ def seed_demo_alerts(request: Request, count: int = 12, recent: bool = True):
})
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)}
-474
View File
@@ -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": "任务不存在或已完成"}
+2 -8
View File
@@ -505,7 +505,7 @@ def _compute_storage(data_dir: Path) -> dict:
stats[f"{key}_size_mb"] = sz
# 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:
d = data_dir / name
if d.exists():
@@ -629,7 +629,7 @@ def clear_data(request: Request):
"kline_daily", "kline_daily_enriched", "kline_index_daily", "kline_index_enriched",
"kline_etf_daily", "kline_etf_enriched", "kline_etf_minute", "kline_minute",
"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
if d.exists():
@@ -658,12 +658,6 @@ def clear_data(request: Request):
# - 触发记录 alerts.jsonl
from app.services import alert_store
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 缓存
# 先 clear_cache 无条件清空内存 (refresh_cache 在磁盘无数据时会提前 return,
# 导致 _enriched_cache 等旧数据残留 —— 清数据后看板仍显示旧数据的根因),
-212
View File
@@ -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"}
+2 -78
View File
@@ -137,16 +137,11 @@ def get_daily(
logger.debug("单股除权因子拉取失败 %s: %s", symbol, e)
enriched = compute_enriched(raw, factors=factors)
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"}
return _attach_ext(resp, repo, symbol, ext_columns)
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"}
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']。
key 形如 "{config_id}__{field_name}",与自选列表 enriched 接口保持一致。
JOIN 逻辑参考 watchlist.watchlist_enriched;任何 ext 表/字段缺失都静默跳过。
JOIN 逻辑;任何 ext 表/字段缺失都静默跳过。
"""
if not ext_columns or not ext_columns.strip():
return resp
@@ -216,77 +211,6 @@ def _attach_ext(resp: dict, repo, symbol: str, ext_columns: Optional[str]) -> di
return resp
def _maybe_inject_live_candle(request: Request, symbol: str, rows: list[dict]) -> list[dict]:
"""如果 QuoteService 有实时 enriched 数据, 用实时数据生成今日蜡烛并追加/覆盖。"""
qs = getattr(request.app.state, "quote_service", None)
if not qs:
return rows
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:
"""批量日K请求。"""
symbols: list[str]
@@ -456,7 +380,7 @@ async def sync_minute(request: Request):
try:
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 全量标的,覆盖北交所、新股等
inst_path = repo.store.data_dir / "instruments" / "instruments.parquet"
if inst_path.exists():
+1 -3
View File
@@ -46,8 +46,6 @@ async def analyze_market(request: Request, req: AnalyzeRequest):
from datetime import date as date_cls
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
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}")
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"
return StreamingResponse(
-499
View File
@@ -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],
}
-16
View File
@@ -222,22 +222,8 @@ def _score(value: float, low: float, high: float) -> int:
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]:
qs = getattr(request.app.state, "quote_service", None)
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)
if repo:
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
return build_market_overview(
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,
)
+1 -3
View File
@@ -50,13 +50,11 @@ async def analyze_rotation(request: Request, req: AnalyzeRequest):
{"type":"done"}
"""
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))
async def stream_gen():
async for chunk in analyze_rotation_stream(
repo, days, req.focus, quote_service, depth_service,
repo, days, req.focus,
):
yield chunk + "\n"
+6 -115
View File
@@ -278,32 +278,16 @@ def get_cached(
request: Request,
ext_columns: Optional[str] = Query(None, description="逗号分隔: config_id.field_name"),
):
"""读取策略结果缓存, 并叠加监控引擎本轮实时算出的结果
"""读取策略结果缓存。
- 盘后缓存 (strategy_cache.json): 非监控策略 / 页面秒加载用, run_all 写入。
- 监控引擎内存结果 (latest_strategy_results): 实时行情每轮对「加入监控的策略」算出,
不落盘 (避免与 read_cache 的 mtime 校验冲突), 在此直接叠加覆盖盘后结果。
被监控的策略拿到新鲜数据, 非监控策略仍用盘后缓存。
- 盘后缓存 (strategy_cache.json): run_all 写入, 页面秒加载用
"""
data_dir = request.app.state.repo.store.data_dir
cached = strategy_cache.read_cache(data_dir)
if cached is 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:
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_down_raw = int(df.filter(pl.col("signal_limit_down").fill_null(False)).height) if "signal_limit_down" in df.columns else 0
# 双方向 sealed 修正: 减去各自的假涨停(假涨停已归炸板, 不计入涨停数)
depth_svc_global = getattr(request.app.state, "depth_service", None)
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)
count_up = count_up_raw
count_down = count_down_raw
# 加载前一日数据获取 prev consecutive_limit_ups/downs
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))
# ── 五档 sealed 叠加(独立旁路, 不改 signal_limit_up) ──
# 假涨停(收盘价=涨停价但卖一有量)从 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:
# Add null columns for sealed_status and sealed_vol (no longer used)
df = df.with_columns(
pl.lit(None).alias("sealed_status"),
pl.lit(None).alias("sealed_vol"),
@@ -700,15 +600,6 @@ def limit_ladder(
"tiers": tier_list,
"counts": {"up": count_up, "down": count_down},
"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,
}
-311
View File
@@ -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):
minute_sync_enabled: bool
minute_sync_days: int = 5
@@ -324,17 +318,12 @@ def get_preferences() -> dict:
"""返回用户偏好设置。"""
from app.services import preferences
return {
"realtime_quotes_enabled": preferences.get_realtime_quotes_enabled(),
"realtime_allowed": _realtime_allowed(),
"indices_nav_pinned": preferences.get_indices_nav_pinned(),
"minute_sync_enabled": preferences.get_minute_sync_enabled(),
"minute_sync_days": preferences.get_minute_sync_days(),
"daily_data_provider": preferences.get_daily_data_provider(),
"adj_factor_provider": preferences.get_adj_factor_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_etf": preferences.get_pipeline_pull_etf(),
"pipeline_pull_index": preferences.get_pipeline_pull_index(),
@@ -343,35 +332,16 @@ def get_preferences() -> dict:
"instruments_schedule": preferences.get_instruments_schedule(),
"enriched_batch_size": preferences.get_enriched_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(),
"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(),
"nav_order": preferences.get_nav_order(),
"nav_hidden": preferences.get_nav_hidden(),
"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_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):
nav_order: list[str]
@@ -396,15 +366,6 @@ def update_nav_hidden(req: NavHiddenIn) -> dict:
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")
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):
indices_nav_pinned: bool
@@ -513,45 +411,6 @@ def update_indices_nav_pinned(req: IndicesNavPinnedPrefs) -> dict:
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):
"""盘后管道拉取内容开关(A股 / ETF / 指数 独立控制)。"""
pipeline_pull_a_share: bool | None = None
@@ -580,97 +439,6 @@ def update_pipeline_index_symbols(req: PipelineIndexSymbolsIn) -> dict:
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):
url: str
# 测试轮数;不传时取 endpoints.json 的 testRounds(默认 5)
@@ -941,85 +709,6 @@ def update_index_daily_batch_size(req: IndexDailyBatchSizeIn) -> dict:
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):
enabled: bool
hour: int
+1 -10
View File
@@ -17,7 +17,6 @@ from app.strategy import config as strategy_config
from app.strategy.engine import StrategyEngine, StrategyDef
from app.strategy.ai_generator import AIStrategyGenerator
from app.strategy.prompt_builder import build_step1, build_step2
from app.strategy.monitor import StrategyMonitorService, StrategyAlert
router = APIRouter(prefix="/api/strategies", tags=["strategies"])
@@ -31,13 +30,6 @@ def _get_engine(request: Request) -> StrategyEngine:
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:
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 复用)。
# 注: 策略监控已移除。
# ── 热重载 ───────────────────────────────────────────────────────────
-222
View File
@@ -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
-4
View File
@@ -1,4 +0,0 @@
"""回测模块 — 因子回测 + 策略回测 + 信号回测。
架构: BacktestEngine (共享) → FactorBacktestService / StrategyBacktestService
"""
File diff suppressed because it is too large Load Diff
-480
View File
@@ -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,
}
-721
View File
@@ -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")))
-2
View File
@@ -93,8 +93,6 @@ class Settings(BaseSettings):
host: str = "0.0.0.0"
port: int = 3018
log_level: str = "INFO"
backtest_range_guard: bool = False
# Auth — 首次启动时预置访问密码(明文, 仅用于初始化, 详见 services/auth.bootstrap_from_env)
# 公网服务器部署时免去 SSH 端口转发设密码的麻烦。写入 auth.json(哈希)后即不再读取。
auth_password: str = ""
-8
View File
@@ -21,7 +21,6 @@ class ProviderCapabilities:
daily: bool = False
adj_factor: bool = False
minute: bool = False
realtime: bool = False
financial: bool = False
@@ -59,10 +58,3 @@ class MarketDataProvider(Protocol):
freq: str = "1m",
) -> pl.DataFrame:
"""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,
adj_factor=True,
minute=True,
realtime=True,
financial=True,
)
@@ -103,18 +102,3 @@ class TickFlowProvider:
# Existing minute sync remains in app.services.kline_sync for now.
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 [])
+12 -70
View File
@@ -44,7 +44,7 @@ def _resolve_universe(capset: CapabilitySet) -> list[str]:
"""解析标的池 — 以 CN_Equity_A (沪深京A股 ~5522只) 为主。
有 batch 能力 → 直接拉 CN_Equity_A universe
其他用户 → 用 instruments parquet + watchlist 兜底
其他用户 → 用 instruments parquet 兜底
"""
if capset.has(Cap.KLINE_DAILY_BATCH):
try:
@@ -54,9 +54,8 @@ def _resolve_universe(capset: CapabilitySet) -> list[str]:
except Exception as e: # noqa: BLE001
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.update(get_pool("watchlist"))
d = Path(settings.data_dir)
inst_path = d / "instruments" / "instruments.parquet"
if inst_path.exists():
@@ -558,15 +557,11 @@ REVIEW_JOB_ID = "scheduled_review"
async def _run_scheduled_review(repo) -> None:
"""定时复盘 job: 流式生成复盘 → 实时推 SSE(开着页面可见) → 落盘归档 → 推飞书。
"""定时复盘 job: 流式生成复盘 → 落盘归档 → 推飞书。
与手动「生成复盘」体验一致: 流式事件经 quote_service.push_review_event →
/api/intraday/stream 的 review_progress 事件 → 前端 reviewStore, 用户开着复盘页
即可看到报告边生成边显示, 切走再回来也能看到生成中/已生成。
LLM 偶发断流(peer closed connection)时自动重试最多 2 次。
与手动「生成复盘」体验一致。LLM 偶发断流(peer closed connection)时自动重试最多 2 次。
任何异常都吞掉只记日志, 绝不影响调度器主循环。
"""
import json
try:
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")
return
app_state = _get_app_state()
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)
content, meta = await _stream_review_with_retry(repo)
if not content:
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
# 落盘: 与手动生成完全相同的归档格式
@@ -602,34 +588,17 @@ async def _run_scheduled_review(repo) -> None:
})
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)
except Exception as e: # noqa: BLE001
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]:
"""流式生成复盘, 每个事件推 SSE + 累积内容。LLM 断流时最多重试 2 次。
async def _stream_review_with_retry(repo) -> tuple[str, dict]:
"""流式生成复盘, 累积内容。LLM 断流时最多重试 2 次。
返回 (content, meta)。重试时推一个 retry 事件让前端清空已累积内容重新开始
成功(收到 done/无 error)或耗尽重试后返回。
返回 (content, meta)。成功(收到 done/无 error)或耗尽重试后返回
"""
import asyncio
import json
@@ -643,14 +612,10 @@ async def _stream_review_with_retry(repo, quote_service, depth_service) -> tuple
content_parts = [] # 每次重试重新累积
failed = False
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)
t = evt.get("type")
# 推给前端(让开着页面的用户实时看到, 与手动一致)
if quote_service:
quote_service.push_review_event(evt_json)
if t == "meta":
last_meta = evt
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:
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)
# 耗尽重试, 返回已累积内容(可能为空)和最后 meta
@@ -790,24 +751,6 @@ def start_scheduler(repo: KlineRepository, capset: CapabilitySet) -> AsyncIOSche
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 大盘复盘报告): 工作日到点自动生成并归档。
# 默认关闭 —— 仅当用户在复盘页开启时才注册 job。
# 复用 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"])
scheduler.start()
logger.info("scheduler started; instruments@%02d:%02d, pipeline@%02d:%02d, depth@%02d:%02d mon-fri",
inst_sched["hour"], inst_sched["minute"], sched["hour"], sched["minute"],
depth_sched["hour"], depth_sched["minute"])
logger.info("scheduler started; instruments@%02d:%02d, pipeline@%02d:%02d mon-fri",
inst_sched["hour"], inst_sched["minute"], sched["hour"], sched["minute"])
return scheduler
@@ -830,7 +772,7 @@ _app_state_ref = None
def set_app_state(app_state) -> None:
"""lifespan 注册 app.state 引用, 供 scheduled job 访问 depth_service 等单例。"""
"""lifespan 注册 app.state 引用, 供 scheduled job 访问单例。"""
global _app_state_ref
_app_state_ref = app_state
+1 -71
View File
@@ -11,11 +11,10 @@ from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
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.config import settings
from app.jobs import daily_pipeline
from app.services.quote_service import QuoteService
from app.tickflow import client as tf_client
from app.tickflow.policy import detect_capabilities
from app.tickflow.repository import DataStore, KlineRepository
@@ -56,42 +55,14 @@ async def lifespan(app: FastAPI):
app.state.capabilities = capset
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)
try:
daily_pipeline.set_app_state(app.state) # 供 depth_finalize job 访问 depth_service
scheduler = daily_pipeline.start_scheduler(repo, capset)
app.state.scheduler = scheduler
except Exception as e: # noqa: BLE001
logger.warning("scheduler not started: %s", e)
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
pull_scheduler.start(store.data_dir)
@@ -114,7 +85,6 @@ async def lifespan(app: FastAPI):
# 策略引擎
from app.strategy.engine import StrategyEngine
from app.strategy.monitor import StrategyMonitorService
from app.services.screener import ScreenerService
_screener_svc = ScreenerService(repo)
@@ -131,36 +101,6 @@ async def lifespan(app: FastAPI):
app.state.strategy_engine = strategy_engine
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
if app.state.scheduler:
@@ -171,12 +111,6 @@ async def lifespan(app: FastAPI):
fsc = getattr(app.state, "financial_scheduler", None)
if fsc:
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")
@@ -248,10 +182,7 @@ async def auth_middleware(request: Request, call_next):
app.include_router(core_router)
app.include_router(auth_api.router)
app.include_router(kline.router)
app.include_router(watchlist.router)
app.include_router(screener.router)
app.include_router(backtest.router)
app.include_router(intraday.router)
app.include_router(indices.router)
app.include_router(overview.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(strategy.router)
app.include_router(signals.router)
app.include_router(monitor_rules.router)
app.include_router(alerts.router)
app.include_router(rps.router)
-392
View File
@@ -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,
days: int = 12,
focus: str = "",
quote_service=None,
depth_service=None,
) -> AsyncIterator[str]:
"""流式概念轮动分析: yield 出每个 NDJSON 事件。
@@ -294,7 +292,6 @@ async def analyze_rotation_stream(
repo: KlineRepository (必填)。
days: 分析最近 N 个交易日 (7-30)。
focus: 用户追加的关注点。
quote_service / depth_service: 可选, 大盘背景装配依赖。
"""
from app.services.rps_rotation import build_rps_rotation
from app.services.market_overview_builder import build_market_overview
@@ -316,7 +313,7 @@ async def analyze_rotation_stream(
# 3. 大盘背景 (失败不阻断, 降级为空)
try:
overview = build_market_overview(repo, quote_service, depth_service)
overview = build_market_overview(repo)
except Exception as e: # noqa: BLE001
logger.warning("rotation analyze: 大盘背景获取失败, 降级为空: %s", e)
overview = {}
-586
View File
@@ -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)
+2 -3
View File
@@ -38,7 +38,7 @@ def _invalidate(table: str | None = None) -> None:
def _resolve_universe(capset: CapabilitySet) -> list[str]:
"""解析标的池 — 与 daily_pipeline 独立的副本"""
"""解析标的池。"""
if capset.has(Cap.KLINE_DAILY_BATCH):
try:
from app.tickflow.pools import get_pool
@@ -48,12 +48,11 @@ def _resolve_universe(capset: CapabilitySet) -> list[str]:
except Exception as 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 pathlib import Path
import polars as pl
base: set[str] = set(DEMO_SYMBOLS)
base.update(_get_pool("watchlist"))
d = Path(settings.data_dir)
inst_path = d / "instruments" / "instruments.parquet"
if inst_path.exists():
@@ -3,11 +3,8 @@
本模块由 `app.api.overview._build_overview` 抽离而来,目的是让「大盘复盘」
等无 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
@@ -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:
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]:
def _index_quotes(repo, as_of: date | None = None) -> list[dict]:
rows: list[dict] = []
if quote_service and as_of is None:
df = quote_service.get_index_quotes(list(CORE_INDEX_SYMBOLS))
if not df.is_empty():
rows = df.to_dicts()
if not rows and repo:
if repo:
placeholders = ", ".join("?" for _ in CORE_INDEX_SYMBOLS)
try:
db_rows = repo.execute_all(
@@ -354,27 +339,21 @@ def _pct_band_rows(values: list[float]) -> list[dict]:
def build_market_overview(
repo,
quote_service=None,
depth_service=None,
as_of: date | None = None,
) -> dict:
"""装配市场总览(与原 overview._build_overview 行为一致)。
Args:
repo: KlineRepository(必填)。
quote_service: QuoteService(可选;实时指数行情来源)。
depth_service: DepthService(可选;五档封板修正)。
as_of: 指定日期,None 则取最新有数据日。
"""
svc = ScreenerService(repo)
as_of = as_of or svc.latest_date()
status = _quote_status(quote_service)
indices = _index_quotes(repo, quote_service, as_of)
indices = _index_quotes(repo, as_of)
if not as_of:
return {
"as_of": None,
"quote_status": status,
"indices": indices,
"breadth": {"total": 0, "up": 0, "down": 0, "flat": 0, "up_pct": 0, "down_pct": 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")))
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
def above_ma_count(ma_key: str) -> int:
@@ -531,7 +494,6 @@ def build_market_overview(
return _json_safe({
"as_of": str(as_of),
"quote_status": status,
"indices": indices,
"breadth": {
"total": total,
@@ -547,7 +509,7 @@ def build_market_overview(
},
"amount": {"total": total_amount, "avg": avg_amount},
"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),
"trend": {
"above_ma5": above_ma5,
+2 -7
View File
@@ -252,8 +252,6 @@ def _recap_summary(overview: dict) -> str:
async def recap_market_stream(
repo,
quote_service=None,
depth_service=None,
as_of: date | None = None,
focus: str = "",
news: list[dict] | None = None,
@@ -262,13 +260,12 @@ async def recap_market_stream(
Args:
repo: KlineRepository(必填)
quote_service / depth_service: 可选,数据装配依赖
as_of: 复盘日期,None 取最新有数据日
focus: 用户追加的复盘关注点
news: 预检索的新闻列表(P1 不传, None 走降级说明;P3 news_search 注入)
"""
# 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")
if not as_of_str:
@@ -314,8 +311,6 @@ async def recap_market_stream(
async def recap_market_once(
repo,
quote_service=None,
depth_service=None,
as_of: date | None = None,
focus: str = "",
news: list[dict] | None = None,
@@ -327,7 +322,7 @@ async def recap_market_once(
"""
content_parts: list[str] = []
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:
obj = json.loads(evt)
except Exception: # noqa: BLE001
-250
View File
@@ -39,53 +39,12 @@ def save(updates: dict) -> dict:
return current
def get_realtime_quotes_enabled() -> bool:
return load().get("realtime_quotes_enabled", False)
def get_indices_nav_pinned() -> bool:
"""侧栏指数报价卡片是否固定显示。默认 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:
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"
def get_realtime_data_provider() -> str:
# 盘中实时现阶段仅支持 TickFlow。
return "tickflow"
# ===== 盘后管道拉取内容开关 (A股 / ETF / 指数 独立控制) =====
def get_pipeline_pull_a_share() -> bool:
@@ -227,44 +181,6 @@ def set_index_daily_batch_size(size: int) -> int:
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'
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"]
# ===== 盘中实时行情范围 (独立于盘后管道范围) =====
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]:
"""返回左侧菜单显示的指数代码。"""
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]
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:
"""飞书自定义机器人 Webhook 地址 — 全局共用一处, 所有启用推送的规则都推到这一个群。"""
return load().get("feishu_webhook_url", "")
@@ -446,73 +269,11 @@ def get_feishu_webhook_secret() -> str:
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:
"""选股页进入时是否自动运行所有策略 (获取命中数)。默认开。"""
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]:
"""返回左侧菜单的自定义排序(内置页面 path + 扩展分析菜单 id)。"""
return load().get("nav_order", [])
@@ -535,17 +296,6 @@ def set_nav_hidden(hidden: list[str]) -> list[str]:
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:
"""返回策略结果列表列配置。"""
return load().get("screener_result_columns")
-971
View File
@@ -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)
-144
View File
@@ -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
-857
View File
@@ -1,857 +0,0 @@
"""策略实时监控 — 订阅行情更新,检查策略买卖信号和提醒条件。
职责: 接收实时行情 DataFrame 检查监控中策略的信号/提醒 推送告警
不知道: 策略加载逻辑AIAPI配置持久化回测
依赖: 外部调用 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)
-258
View File
@@ -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"
DEPTH5 = "depth5"
DEPTH5_BATCH = "depth5.batch"
WEBSOCKET = "websocket"
FINANCIAL = "financial"
ADJ_FACTOR = "adj_factor"
+1 -18
View File
@@ -18,7 +18,6 @@ from app import secrets_store
_sync_client: TickFlow | 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
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:
"""Key 变化后调用 — 让下一次 get_client() 拿新实例。"""
global _sync_client, _async_client, _paid_realtime_client
global _sync_client, _async_client
_sync_client = None
_async_client = None
_paid_realtime_client = None
def current_mode() -> str:
+1 -8
View File
@@ -236,12 +236,6 @@ def _probe_real(tiers: dict) -> tuple[CapabilitySet, list[str]]:
defaults(Cap.ADJ_FACTOR))
# 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
@@ -297,7 +291,7 @@ def detect_capabilities(force: bool = False) -> CapabilitySet:
# 拥有**任意一个**即认作该档及以上。自上而下匹配。
# 这套设计的好处:单个 capability 探测的 transient 失败不会把整体档位"误降"。
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,
Cap.INTRADAY, Cap.DEPTH5, Cap.DEPTH5_BATCH},
"starter": {Cap.QUOTE_BATCH, Cap.KLINE_DAILY_BATCH,
@@ -359,7 +353,6 @@ _CAP_ALIASES: dict[Cap, str] = {
Cap.INTRADAY_BATCH: "批量分时",
Cap.DEPTH5: "五档",
Cap.DEPTH5_BATCH: "批量五档",
Cap.WEBSOCKET: "WS",
Cap.FINANCIAL: "财务",
Cap.ADJ_FACTOR: "复权",
Cap.QUOTE_BATCH: "批量行情",
+1 -16
View File
@@ -3,7 +3,6 @@
Phase 1 实现:
- 常用指数成份(沪深 300 / 中证 500 / 上证 50) TickFlow `quote.pool` 端点拉取并缓存
- A 通过 instruments.batch 获取
- 自选池 = 用户的 watchlist
"""
from __future__ import annotations
@@ -19,7 +18,7 @@ from app.tickflow.client import get_client
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())。
# 没有官方对照表,启动时按名称模糊匹配从 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]:
"""返回标的池里的 symbol 列表。"""
if pool_id == "watchlist":
return _load_watchlist()
cache = _pool_cache_path(pool_id)
if cache.exists() and not refresh:
df = pl.read_parquet(cache)
@@ -132,17 +128,6 @@ def _fetch_pool(pool_id: PoolId) -> list[str]:
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 不至于空白
DEMO_SYMBOLS = [
"600000.SH", # 浦发银行
+2 -3
View File
@@ -56,7 +56,6 @@ class DataStore:
"instruments_ext",
"kline_ext",
"pools",
"backtest_results",
"screener_results",
"ai_cache",
"user_data",
@@ -77,7 +76,7 @@ class DataStore:
背景: 旧版 data_dir = exe_dir.parent / "TickFlowStockPanel_Data" (兄弟目录),
新版改为 exe_dir / "data" (子目录)老用户首次升级时旧数据在兄弟目录,
若不迁移会导致历史行情/策略/回测/监控全部"丢失"(实际还在旧位置)
若不迁移会导致历史行情/策略/监控全部"丢失"(实际还在旧位置)
策略 (仅打包桌面版触发, 开发/Docker 不受影响):
1. 旧目录存在且新 data/ 还基本为空 整目录搬迁 (shutil.move, 跨盘符安全)
@@ -408,7 +407,7 @@ class KlineRepository:
how="left",
)
# 缓存完整历史 (含指标+必要基础信息) 供 filter_history/backtest 直接复用
# 缓存完整历史 (含指标+必要基础信息)
self._enriched_history_cache = df_full
self._enriched_history_start = df_full["date"].min()
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 { COLUMN_GROUPS, type ColumnConfig } from '@/lib/watchlist-columns'
import type { ColumnConfig } from '@/lib/list-columns'
interface ColumnCustomizerProps {
columns: ColumnConfig[]
@@ -12,7 +12,7 @@ export function ColumnCustomizer({ columns, onChange, open, onClose }: ColumnCus
return (
<ListColumnCustomizer
columns={columns}
groups={COLUMN_GROUPS}
groups={[]}
onChange={onChange}
open={open}
onClose={onClose}
+4 -284
View File
@@ -1,8 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { NavLink, Outlet } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { useQuoteStream } from '@/lib/useQuoteStream'
import { ToastContainer } from '@/components/Toast'
import { AlertToastContainer } from '@/components/AlertToast'
import { AiAnalysisHost } from '@/components/financials/AiAnalysisHost'
@@ -13,23 +11,14 @@ import {
useCapabilities,
useSettings,
usePreferences,
useQuoteStatus,
useVersion,
} from '@/lib/useSharedQueries'
import {
useToggleRealtimeQuotes,
} from '@/lib/useSharedMutations'
import { QK } from '@/lib/queryKeys'
import { tierRank } from '@/lib/capability-labels'
import {
Star,
ScanSearch,
History,
FileText,
Settings,
Key,
Database,
Loader2,
LayoutDashboard,
Tags,
TrendingUp,
@@ -38,110 +27,27 @@ import {
Sparkles,
Layers3,
Landmark,
Cable,
RadioTower,
CheckCircle2,
BookOpenCheck,
ExternalLink,
X,
} from 'lucide-react'
import { Logo } from './Logo'
import { api, type IndexQuote } from '@/lib/api'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
import { setCurrentTotal as setAlertTotal, useUnreadAlerts } from '@/lib/monitorBadge'
// 品牌色 — 只用于 logo / brand 区域,不影响功能语义色
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 = [
{ to: '/', label: '看板', icon: LayoutDashboard },
{ to: '/watchlist', label: '自选', icon: Star },
{ to: '/screener', label: '策略', icon: ScanSearch },
{ to: '/backtest', label: '回测', icon: History },
{ to: '/stock-analysis', label: '个股分析', icon: TrendingUp },
{ to: '/limit-ladder', label: '连板梯队', icon: Flame },
{ to: '/concept-analysis', label: '概念分析', icon: Layers3 },
{ to: '/industry-analysis', label: '行业分析', icon: Landmark },
{ to: '/financials', label: '财务分析', icon: FileText },
{ to: '/monitor', label: '监控中心', icon: RadioTower },
{ to: '/review', label: '复盘', icon: BookOpenCheck },
{ to: '/indices', label: '指数', icon: BarChart3 },
{ to: '/trading', label: '交易', icon: Cable },
{ to: '/data', label: '数据', icon: Database },
] 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 }) {
const base = label.split(' ')[0].split('+')[0].toLowerCase()
@@ -262,79 +168,12 @@ export function Layout() {
const { data: settingsState } = useSettings()
const { data: versionData } = useVersion()
const { data: prefs } = usePreferences()
// poll=true: 全局唯一开启条件轮询 (非交易时段 60s 兜底, 交易时段靠 SSE)
const { data: quoteStatus } = useQuoteStatus({ poll: true })
const { data: analysisMenus } = useQuery({
queryKey: QK.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 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 ?? [])
@@ -358,27 +197,6 @@ export function Layout() {
const hiddenIds = new Set(prefs?.nav_hidden ?? [])
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 (
<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">
@@ -432,7 +250,7 @@ export function Layout() {
)
}
>
{({ isActive }) => (
{() => (
<>
<Icon className="h-4 w-4 shrink-0" />
<span className="flex-1">{label}</span>
@@ -442,110 +260,12 @@ export function Layout() {
Beta
</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>
))}
</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">
<NavLink
to="/settings"
@@ -1,9 +1,7 @@
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 { X, RefreshCw, Clock } from 'lucide-react'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { cnSignal } from '@/lib/signals'
import { StockPanel, getDefaultRange } from '@/components/StockPanel'
import { DatePicker } from '@/components/DatePicker'
@@ -44,21 +42,6 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
const [showMonitorEditor, setShowMonitorEditor] = useState(false)
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 关闭
useEffect(() => {
if (!symbol) return
@@ -239,8 +222,6 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
onSelectDate={() => { if (!showIntraday) setShowIntraday(true) }}
dateRange={dateRange}
onMonitor={() => setShowMonitorEditor(true)}
inWatchlist={inWatchlist}
onToggleWatchlist={() => toggleWatchlist.mutate()}
/>
</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 strategies = useQuery({ queryKey: QK.screenerStrategies, queryFn: api.screenerStrategies })
const { data: prefs } = usePreferences()
const feishuConfigured = !!(prefs?.feishu_webhook_url)
const feishuConfigured = !!((prefs as any)?.feishu_webhook_url)
const [editing] = useState(!!rule)
// 新建规则: 预填全局「默认推送渠道」(飞书), preset 显式指定时以 preset 为准。
// 编辑规则: 完全沿用规则自身配置, 不受默认值影响。
const [draft, setDraft] = useState<MonitorRule>(
rule
? { ...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 [symbolQuery, setSymbolQuery] = useState('')
@@ -6,7 +6,7 @@
* scoresignalscandleext //
*/
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 { fmtPrice } from '@/lib/format'
import type { ColumnConfig } from '@/lib/screener-columns'
@@ -22,10 +22,7 @@ interface ScreenerTableProps {
strategyIdToName: Record<string, string>
symbolStrategyMap: Map<string, string[]>
activeStrategy: string | null
watchlistSet: Set<string>
onPreview: (symbol: string, name: string) => void
onToggleWatchlist: (symbol: string, inList: boolean) => void
watchlistPending: boolean
/** symbol → 日k 数据,仅当启用日k列时传入 */
klineData?: Record<string, KlineRow[]>
/** 日k蜡烛图是否显示(表头眼睛开关) */
@@ -114,7 +111,7 @@ function renderExtValue(
export function ScreenerTable({
rows, columns, strategyIdToName, symbolStrategyMap, activeStrategy,
watchlistSet, onPreview, onToggleWatchlist, watchlistPending, klineData = {},
onPreview, klineData = {},
dailyKChartVisible = true, onToggleDailyKChart,
sort, onSortToggle,
}: ScreenerTableProps) {
@@ -164,7 +161,6 @@ export function ScreenerTable({
switch (key) {
case 'symbol': {
const board = boardTag(r.symbol)
const inWatchlist = watchlistSet.has(r.symbol)
return (
<td key={col.id} className="px-4 py-2">
<div className="flex items-center gap-2">
@@ -189,25 +185,10 @@ export function ScreenerTable({
</span>
)}
</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>
) : (
<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>
</td>
@@ -2,25 +2,36 @@
import { motion, AnimatePresence } from 'framer-motion'
import { X, Settings2, RotateCcw, Save, ChevronDown, Filter, Star, TrendingUp, Sparkles } from 'lucide-react'
import { api, type StrategyDetail, type StrategyParamDef } from '@/lib/api'
import { BUILTIN_COLUMNS } from '@/lib/watchlist-columns'
import { color } from '@/lib/colors'
import { SignalPicker } from './SignalPicker'
import { SignalTriggerActions } from '@/components/signals/SignalTriggerActions'
// 内置列名 → 中文标签
const FIELD_LABEL: Record<string, string> = {}
for (const c of BUILTIN_COLUMNS) {
if (c.source.type === 'builtin') FIELD_LABEL[c.source.key] = c.label
}
// enriched 列名别名
Object.assign(FIELD_LABEL, {
// 字段名 → 中文标签
const FIELD_LABEL: Record<string, string> = {
close: '收盘价', open: '开盘价', high: '最高价', low: '最低价',
change_pct: '涨跌幅', consecutive_limit_ups: '连板',
momentum_5d: '5D动量', momentum_10d: '10D动量',
momentum_20d: '20D动量', momentum_30d: '30D动量',
momentum_60d: '60D动量', turnover_rate: '换手率',
change_amount: '涨跌额', amplitude: '振幅',
volume: '成交量', amount: '成交额',
rsi_14: 'RSI14', rsi_6: 'RSI6', rsi_24: 'RSI24',
vol_ratio_5d: '量比', vol_ratio_20d: '20日量比',
vol_ratio: '量比',
macd_dif: 'MACD-DIF', macd_dea: 'MACD-DEA', macd_hist: 'MACD柱',
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 {
strategyId: string | null
-292
View File
@@ -192,23 +192,6 @@ export interface KlineRow {
[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 {
symbol: string
name?: string | null
@@ -505,111 +488,6 @@ export interface LimitLadderResult {
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 =====
/** 端点发现清单 —— 对应 tickflow.org/endpoints.json */
@@ -669,20 +547,12 @@ export interface SaveTickflowKeyResult {
}
export interface Preferences {
realtime_quotes_enabled: boolean
indices_nav_pinned: boolean
minute_sync_enabled: boolean
minute_sync_days: number
daily_data_provider?: string
adj_factor_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_etf: boolean
pipeline_pull_index: boolean
@@ -691,18 +561,9 @@ export interface Preferences {
instruments_schedule: { hour: number; minute: number }
enriched_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_push_channels: string[]
sse_refresh_pages: Record<string, boolean>
strategy_monitor_enabled: boolean
strategy_monitor_ids: string[]
system_notify_enabled: boolean
feishu_webhook_url?: string
feishu_webhook_secret?: string
webhook_enabled_default?: boolean
sidebar_index_symbols: string[]
nav_order: string[]
nav_hidden: string[]
@@ -793,67 +654,11 @@ export const api = {
method: 'PUT',
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) =>
request<{ indices_nav_pinned: boolean }>('/api/settings/preferences/indices-nav-pinned', {
method: 'PUT',
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) =>
request<{ system_notify_enabled: boolean }>('/api/settings/preferences/system-notify', {
method: 'PUT',
@@ -929,15 +734,6 @@ export const api = {
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: () =>
request<{ columns: any[] | null }>('/api/settings/preferences/screener-result-columns'),
@@ -1049,37 +845,6 @@ export const api = {
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'),
screenerRunPreset: (strategy_id: string, pool?: string[], asOf?: string, extColumns?: string) =>
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 }>(
'/api/pipeline/run', { method: 'POST' },
),
-227
View File
@@ -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)
}
+1 -2
View File
@@ -9,7 +9,6 @@ export const CAP_LABELS: Record<string, { name: string; hint: string }> = {
'kline.minute.batch': { name: '分钟 K(批量)', hint: '多股分钟 K' },
'depth5': { name: '五档盘口', hint: '买卖五档报价' },
'websocket': { name: '实时推送(WS)', hint: '免轮询的实时行情订阅' },
'financial': { name: '财务数据', hint: '利润表 / 资负表 / 现金流 / 关键指标' },
'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' },
},
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))' },
dotStyle: { background: 'linear-gradient(135deg, #3b82f6, #a855f7, #f59e0b)' },
labelTextStyle: { background: 'linear-gradient(135deg, #60a5fa, #c084fc, #fbbf24)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' },
+2 -23
View File
@@ -2,7 +2,6 @@
* React Query key
*
* -
* - SSE invalidation SSE_INVALIDATE_PREFIXES key useQuoteStream
*/
// ===== Query Key 工厂 =====
@@ -14,17 +13,10 @@ export const QK = {
endpoints: ['endpoints'] as const,
version: ['version'] 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,
indexQuotes: ['index-quotes'] as const,
indexList: ['index-list'] as const,
// Watchlist
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,
// Search
instrumentSearch: (q: string) => ['instrument-search', q] as const,
// Screener
@@ -35,9 +27,6 @@ export const QK = {
marketSnapshot: ['market-snapshot'] as const,
limitLadder: (asOf?: string) => ['limit-ladder', asOf] as const,
// Backtest
backtestStatus: ['backtest-status'] as const,
// Data / Pipeline
dataStatus: ['data-status'] as const,
pipelineJobs: ['pipeline-jobs'] as const,
@@ -78,14 +67,4 @@ export const QK = {
rpsRotation: (days: number) => ['rps-rotation', days] as const,
} as const
// ===== SSE 应该 invalidate 的 key 前缀列表 =====
// 新增需要 SSE 推送的查询,只需在此加一行
export const SSE_INVALIDATE_PREFIXES = [
'watchlist',
'quote-status',
'index-quotes',
'overview-market',
'limit-ladder',
'screener',
] as const
// SSE invalidation has been removed along with real-time functionality.
+2 -58
View File
@@ -35,10 +35,6 @@ const INITIAL: ReviewState = { phase: 'idle', content: '', error: '', meta: null
let state: ReviewState = { ...INITIAL }
let abortCtrl: AbortController | null = null
// 当前生成来源: 'manual'(手动点生成) | 'sse'(定时任务 SSE 推送) | null(空闲)
// 用于区分两条流, 避免互相丢弃事件或重复归档。
let generatingSource: 'manual' | 'sse' | null = null
// ===== 订阅机制 =====
type Listener = () => void
const listeners = new Set<Listener>()
@@ -80,7 +76,6 @@ export async function startReviewGeneration(
// 已在生成中,不重复启动
if (isReviewGenerating()) return
generatingSource = 'manual'
state = { phase: 'loading', content: '', error: '', meta: null, focus }
notify()
@@ -114,7 +109,7 @@ export async function startReviewGeneration(
if (buf && !failed) {
state = { ...state, phase: 'done' }
notify()
// 自动归档(仅手动流: 定时流由后端归档, SSE done 不走这里)
// 自动归档
if (buf && !failed) {
onDone?.(buf, doneMeta)
}
@@ -126,7 +121,6 @@ export async function startReviewGeneration(
}
} finally {
abortCtrl = null
generatingSource = null
}
}
@@ -167,54 +161,4 @@ export function resetReview(): void {
notify()
}
/**
* 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
}
}
// feedReviewEvent (SSE-only) removed along with real-time SSE channel.
@@ -1,8 +1,5 @@
/**
*
*
* (watchlist-columns)
* // list-columns
*/
import { storage } from '@/lib/storage'
import {
+2 -2
View File
@@ -1,7 +1,7 @@
/**
* /
*
*
* ID backtest/strategy.py:_build_signal_mask
* ID
* (signal_* , csg_ )
*/
-37
View File
@@ -27,27 +27,15 @@ export const storage = {
/** 策略池 (screener) */
strategyPool: kv<string[]>('strategy-pool'),
/** 自选列表列配置 */
watchlistColumns: kv<unknown[]>('watchlist_columns'),
/** 个股日K信息条指标配置 */
stockInfoBarFields: kv<unknown[]>('stock_info_bar_fields'),
/** 策略结果列表列配置 */
screenerResultColumns: kv<unknown[]>('screener_result_columns'),
/** 自选列表视图模式 table | card */
watchlistView: kv<string>('watchlist_view'),
/** 自选列表日K蜡烛图显示状态 */
watchlistCandle: kv<boolean>('watchlist_showCandle'),
/** 策略结果列表日K蜡烛图显示状态 */
screenerCandle: kv<boolean>('screener_showCandle'),
/** 自选列表板块筛选 */
watchlistBoardFilter: kv<string[]>('watchlist_boardFilter'),
/** Screener 卡片尺寸 */
screenerCardSize: kv<string>('screener-card-size'),
@@ -78,31 +66,6 @@ export const storage = {
/** 已保存策略的原始规则(策略ID → 规则文本) */
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'),
+1 -4
View File
@@ -31,10 +31,7 @@ function loadConfig(): QueryConfig {
}
}
/**
*
* useQuoteStream 使
*/
/** 轻量版:只读取当前配置。 */
export function getQueryConfig(): QueryConfig {
return loadConfig()
}
-147
View File
@@ -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
*/
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
*
* SSE invalidation
* 线 SSE refetchInterval
*/
import { useQuery } from '@tanstack/react-query'
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 专用 */
export function useVersion() {
return useQuery({
-166
View File
@@ -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)
}
-63
View File
@@ -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>
)
}
+1 -19
View File
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
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 { api, type MarketSnapshotRow, type OverviewDimensionRankItem, type OverviewMarket, type AlertEvent } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
@@ -550,7 +550,6 @@ export function Dashboard() {
}).catch(() => { /* 查询失败不阻塞, 用户仍可手动点击获取 */ })
}, [hasNoData, fetchJobId])
// 手动刷新: 显示旋转动画; SSE 自动刷新: 静默, 无体感
const handleRefresh = () => {
setManualFetching(true)
overview.refetch().finally(() => setManualFetching(false))
@@ -582,10 +581,6 @@ export function Dashboard() {
const strongDown = data.breadth.strong_down ?? 0
const latestDate = dataStatus.data?.enriched?.latest_date ?? null
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 (
<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="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
onClick={handleRefresh}
disabled={manualFetching}
@@ -653,18 +647,6 @@ export function Dashboard() {
</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">
{data.indices.map(item => <IndexTicker key={item.symbol} item={item} />)}
</div>
+2 -50
View File
@@ -23,11 +23,8 @@ import {
useCapabilities,
useSettings,
usePreferences,
useQuoteStatus,
useQuoteInterval,
useDataStatus,
} from '@/lib/useSharedQueries'
import { useToggleRealtimeQuotes, useUpdateQuoteInterval } from '@/lib/useSharedMutations'
import { QK } from '@/lib/queryKeys'
import { PageHeader } from '@/components/PageHeader'
import { formatScheduleDatePart, formatScheduleTimePart, isToday } from '@/lib/format'
@@ -43,7 +40,6 @@ import { EnrichedRebuildPanel } from '@/components/data/EnrichedRebuildPanel'
import { MinuteSyncConfig } from '@/components/data/MinuteSyncConfig'
import { PipelineScopeConfig } from '@/components/data/PipelineScopeConfig'
import { PageSettingsModal, getCardVisibility, getCardOrder, type CardKey } from '@/components/data/PageSettingsModal'
import { QuoteConfigCard } from '@/components/data/QuoteConfigCard'
import { EnrichedSchemaModal } from '@/components/data/SchemaModal'
import { Skeleton } from '@/components/data/Skeleton'
import { ExtDataStatCard } from '@/components/ext-data/ExtDataStatCard'
@@ -141,7 +137,6 @@ export function Data() {
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.dataStatus })
qc.invalidateQueries({ queryKey: QK.indexList })
qc.invalidateQueries({ queryKey: QK.indexQuotes })
qc.invalidateQueries({ queryKey: ['index-daily'] })
},
})
@@ -161,33 +156,6 @@ export function Data() {
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 hasDailyBatchCap = !!caps.data?.capabilities?.['kline.daily.batch']
const hasMinuteCap = !!caps.data?.capabilities?.['kline.minute.batch']
@@ -579,24 +547,8 @@ export function Data() {
)}
</AnimatePresence>
{/* 实时行情 + 存储 + 调度 */}
<div className="grid grid-cols-1 md:grid-cols-3 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="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 自动调度 */}
<div className="rounded-card border border-border bg-surface p-4">
<div className="flex items-center gap-2 mb-3">
+3 -41
View File
@@ -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 = [
{ symbol: '000001.SH', name: '上证指数' },
{ symbol: '399001.SZ', name: '深证成指' },
@@ -112,12 +102,6 @@ export function Indices() {
setSearchParams({ symbol })
}
const quotes = useQuery({
queryKey: QK.indexQuotes,
queryFn: () => api.indexQuotes(),
placeholderData: (prev) => prev,
})
const daily = useQuery({
queryKey: QK.indexDaily(selectedSymbol, range.start, range.end),
queryFn: () => api.indexDaily(selectedSymbol, 180, range),
@@ -136,7 +120,6 @@ export function Indices() {
mutationFn: api.syncIndexInstruments,
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.indexList })
qc.invalidateQueries({ queryKey: QK.indexQuotes })
},
})
@@ -144,20 +127,10 @@ export function Indices() {
mutationFn: () => api.syncIndexDaily(365),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.indexList })
qc.invalidateQueries({ queryKey: QK.indexQuotes })
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 selectedInfo = [...topRows, ...listRows].find(r => r.symbol === selectedSymbol) || daily.data?.index_info
const minuteRows: MinuteKlineRow[] = minute.data?.rows ?? []
@@ -179,9 +152,6 @@ export function Indices() {
}
}, [chartRows, daily.data?.symbol, selectedDate, selectedSymbol])
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
return (
<button
@@ -189,14 +159,8 @@ export function Indices() {
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'}`}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-xs font-medium">{item.name || item.symbol}</span>
<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>
<div className="truncate text-xs font-medium">{item.name || item.symbol}</div>
<div className="mt-0.5 text-[10px] font-mono text-muted">{item.symbol}</div>
</button>
)
}
@@ -264,11 +228,9 @@ export function Indices() {
{selectedInfo?.name || selectedSymbol || '未选择指数'}
</h2>
{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 className="mt-1 text-xs text-muted">
{quotes.data?.count ?? 0} · K来源 {daily.data?.source ?? '--'}
K来源 {daily.data?.source ?? '--'}
</div>
</div>
<div className="flex items-center gap-2 text-xs">
+2 -2
View File
@@ -12,7 +12,7 @@ import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { useCapabilities, usePreferences } from '@/lib/useSharedQueries'
import { SealedBadge } from '@/components/SealedBadge'
import type { ExtColumnDisplayConfig } from '@/lib/watchlist-columns'
import type { ExtColumnDisplayConfig } from '@/lib/list-columns'
// ===== Ext 字段配置 =====
@@ -374,7 +374,7 @@ function MonitorMenu({ stock, direction, sealMode, monitorRule, anchorRect, hasD
// 推送外部开关默认值: 取偏好设置中的全局默认 (已有规则沿用其值)
const { data: prefs } = usePreferences()
const webhookDefault = prefs?.webhook_enabled_default ?? false
const webhookDefault: boolean = (prefs as any)?.webhook_enabled_default ?? false
// 单位倍率: 输入值 × 倍率 = 原始单位 (量=手, 额=元)
const VOL_UNITS = [
+1 -1
View File
@@ -103,7 +103,7 @@ export function Review() {
const [showSchedule, setShowSchedule] = useState(false)
const prefs = usePreferences()
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']=飞书(微信开发中, 仅占位)
const reviewPushChannels = prefs.data?.review_push_channels ?? []
+1 -59
View File
@@ -1,10 +1,9 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
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 { useDataStatus, usePreferences } from '@/lib/useSharedQueries'
import { useWatchlistBatchAdd } from '@/lib/useSharedMutations'
import { QK } from '@/lib/queryKeys'
import { storage } from '@/lib/storage'
import { PageHeader } from '@/components/PageHeader'
@@ -35,7 +34,6 @@ export function Screener() {
const [activeStrategy, setActiveStrategy] = useState<string | null>(null)
const [result, setResult] = useState<ScreenerResult | null>(null)
const [asOf, setAsOf] = useState<string>('')
const [batchMsg, setBatchMsg] = useState<string>('')
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const [previewName, setPreviewName] = useState<string>('')
const closePreview = useCallback(() => { setPreviewSymbol(null); setPreviewName('') }, [])
@@ -402,28 +400,6 @@ export function Screener() {
const minDate = dataStatus.data?.enriched?.earliest_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({
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 (
<>
<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
onClick={() => setCustomizerOpen(true)}
title="列表配置"
@@ -711,9 +659,6 @@ export function Screener() {
>
<Settings2 className="h-3 w-3" />
</button>
{batchMsg && (
<span className="text-xs text-accent animate-pulse">{batchMsg}</span>
)}
{!showAll && result && result.elapsed_ms > 0 && (
<div className="flex items-center gap-2 text-xs text-muted">
<Clock className="h-3 w-3" />
@@ -749,10 +694,7 @@ export function Screener() {
strategyIdToName={strategyIdToName}
symbolStrategyMap={symbolStrategyMap}
activeStrategy={activeStrategy}
watchlistSet={watchlistSet}
onPreview={(symbol, name) => { setPreviewSymbol(symbol); setPreviewName(name) }}
onToggleWatchlist={(symbol, inList) => toggleWatchlist.mutate({ symbol, inList })}
watchlistPending={toggleWatchlist.isPending}
klineData={klineData}
dailyKChartVisible={dailyKChartVisible}
onToggleDailyKChart={toggleDailyKChart}
-82
View File
@@ -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,
} from '@dnd-kit/sortable'
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 { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
@@ -32,9 +32,7 @@ interface NavEntry {
const BUILTIN_PAGES: NavEntry[] = [
{ id: '/', label: '看板', type: 'builtin', visible: true },
{ id: '/watchlist', 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: '/concept-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: '/financials', 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 ──
function SortableItem({ entry, hidden, onToggleHidden, badgeEnabled, onToggleBadge }: {
function SortableItem({ entry, hidden, onToggleHidden }: {
entry: NavEntry
hidden: boolean
onToggleHidden: (id: string) => void
badgeEnabled?: boolean
onToggleBadge?: (id: string) => void
}) {
const {
attributes,
@@ -76,7 +69,7 @@ function SortableItem({ entry, hidden, onToggleHidden, badgeEnabled, onToggleBad
<div
ref={setNodeRef}
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' : ''
} ${hidden ? 'opacity-50' : ''}`}
>
@@ -135,22 +128,6 @@ function SortableItem({ entry, hidden, onToggleHidden, badgeEnabled, onToggleBad
</Link>
)}
</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>
)
}
@@ -249,16 +226,6 @@ export function SettingsMenuSettingsPanel() {
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 (
<div className="max-w-5xl space-y-6">
@@ -271,13 +238,12 @@ export function SettingsMenuSettingsPanel() {
</section>
<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 className="text-center"></div>
<div className="text-center"></div>
<div className="text-center"></div>
</div>
<DndContext
@@ -295,8 +261,6 @@ export function SettingsMenuSettingsPanel() {
entry={entry}
hidden={hiddenSet.has(entry.id)}
onToggleHidden={toggleHidden}
badgeEnabled={entry.id === '/monitor' ? badgeEnabled : undefined}
onToggleBadge={entry.id === '/monitor' ? toggleBadge : undefined}
/>
))}
</SortableContext>
@@ -1,572 +1,8 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
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) {
// Real-time monitoring settings have been removed.
export function SettingsMonitoringPanel(_props?: { highlight?: string }) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl
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 K1-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>
)
}
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 className="flex flex-col items-center justify-center py-16 text-center">
<p className="text-sm text-muted"></p>
</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>
)
}
+5 -2
View File
@@ -7,7 +7,6 @@ import { useState, useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Settings2, Trash2, RefreshCw, Bell, Volume2, Info } from 'lucide-react'
import { usePreferences, useVersion } from '@/lib/useSharedQueries'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { PageHeader } from '@/components/PageHeader'
import { refreshAlertToastConfig } from '@/components/AlertToast'
@@ -40,7 +39,11 @@ export function SettingsSystemPanel() {
const save = useCallback(async (cfg: Record<string, unknown>) => {
setSaving(true)
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 })
} finally {
setSaving(false)
-6
View File
@@ -1,14 +1,11 @@
import { createBrowserRouter, Navigate } from 'react-router-dom'
import { Layout } from './components/Layout'
import { Watchlist } from './pages/Watchlist'
import { Screener } from './pages/Screener'
import { Backtest } from './pages/Backtest'
import { Financials } from './pages/Financials'
import { Onboarding } from './pages/Onboarding'
import { Auth } from './pages/Auth'
import { Data } from './pages/Data'
import { Monitor } from './pages/Monitor'
import { Trading } from './pages/Trading'
import { Dashboard } from './pages/Dashboard'
import { AnalysisDetail } from './pages/AnalysisDetail'
import { ConceptAnalysis } from './pages/ConceptAnalysis'
@@ -70,13 +67,10 @@ export const router = createBrowserRouter([
{ path: 'industry-analysis', element: <IndustryAnalysis /> },
{ path: 'stock-analysis', element: <StockAnalysis /> },
{ path: 'review', element: <Review /> },
{ path: 'watchlist', element: <Watchlist /> },
{ path: 'screener', element: <Screener /> },
{ path: 'backtest', element: <Backtest /> },
{ path: 'financials', element: <Financials /> },
{ path: 'data', element: <Data /> },
{ path: 'monitor', element: <Monitor /> },
{ path: 'trading', element: <Trading /> },
{ path: 'limit-ladder', element: <LimitUpLadder /> },
{ path: 'indices', element: <Indices /> },
{ path: 'branding', element: <Branding /> },
-10
View File
@@ -16,16 +16,6 @@ export default defineConfig({
// dev 时 /api 转发到 FastAPI
'/api': {
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',
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 653 KiB