mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 14:24:15 +08:00
GET /api/alerts 的 days、limit 是裸 int,没有任何约束,越界值不报错而是
静默返回错误结果:
- days=-1 → cutoff 被推到未来,200 但 alerts 为空(total 仍是 3)
- limit=-1 → out[:limit] 变成负数切片,3 条记录只返回 2 条,
静默丢掉最旧一条,调用方无从察觉
- limit=0 → 200 但 alerts 为空
同类列表端点已有正确写法:abnormal.py 用 `limit: int = Query(500, ge=1,
le=2000)`,rps.py 用 `days: int = Query(12, ge=7, le=30)`,越界直接 422。
本次把 /api/alerts 补齐到同一口径,上下限取存储侧保留策略本身
(alert_store.MAX_DAYS / MAX_RECORDS),不新增常量,超出保留窗口的请求
本来也没有可返回的数据。
前端 alertsList 实际只传 days=7、limit=1/10/500,全部落在新区间内;
新增的 tests/test_alerts_query_bounds.py 同时锁住这些合法取值仍返回 200
且记录条数不变。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
159 lines
6.6 KiB
Python
159 lines
6.6 KiB
Python
"""告警触发记录 API — 查询/清空/生成演示数据 alerts.jsonl。"""
|
|
from __future__ import annotations
|
|
|
|
import random
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, HTTPException, Query, Request
|
|
|
|
from app.services import alert_store
|
|
|
|
router = APIRouter(prefix="/api/alerts", tags=["alerts"])
|
|
|
|
|
|
def _data_dir(request: Request) -> Path:
|
|
return request.app.state.repo.store.data_dir
|
|
|
|
|
|
@router.get("")
|
|
def list_alerts(
|
|
request: Request,
|
|
# 上限取存储侧保留策略 (alert_store.MAX_DAYS / MAX_RECORDS): 超出也没有可返回的记录。
|
|
# 无下限时 days=-1 会把 cutoff 推到未来直接返回空, limit=-1 会走负数切片静默丢掉最旧一条。
|
|
days: int = Query(alert_store.MAX_DAYS, ge=1, le=alert_store.MAX_DAYS),
|
|
limit: int = Query(alert_store.MAX_RECORDS, ge=1, le=alert_store.MAX_RECORDS),
|
|
source: str | None = None,
|
|
type: str | None = None,
|
|
ext_columns: str | None = None,
|
|
):
|
|
"""查询触发记录 (时间倒序)。
|
|
|
|
ext_columns: 逗号分隔的 "configId.fieldName", 传入后按 symbol 富化行业/概念等 ext 字段,
|
|
每条记录附带 {configId}__{fieldName} 键 (与 watchlist/screener 一致)。
|
|
"""
|
|
events = alert_store.list_recent(
|
|
_data_dir(request), days=days, limit=limit, source=source, type=type,
|
|
)
|
|
if ext_columns and events:
|
|
try:
|
|
from app.api.screener import _load_ext_value_maps, _rows_with_ext
|
|
repo = request.app.state.repo
|
|
value_maps = _load_ext_value_maps(repo, ext_columns)
|
|
if value_maps:
|
|
events = _rows_with_ext(events, value_maps)
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
total = alert_store.count(_data_dir(request))
|
|
return {"alerts": events, "total": total}
|
|
|
|
|
|
@router.delete("")
|
|
def clear_alerts(request: Request):
|
|
"""清空全部触发记录。"""
|
|
n = alert_store.clear(_data_dir(request))
|
|
return {"ok": True, "cleared": n}
|
|
|
|
|
|
@router.delete("/{ts}")
|
|
def delete_alert(ts: int, request: Request):
|
|
"""删除单条触发记录 (按 ts 毫秒时间戳)。"""
|
|
deleted = alert_store.delete_one(_data_dir(request), ts)
|
|
if not deleted:
|
|
raise HTTPException(status_code=404, detail="记录不存在")
|
|
return {"ok": True}
|
|
|
|
|
|
# ── 演示数据生成 (仅 Dev 页用) ─────────────────────────
|
|
|
|
_DEMO_STOCKS = [
|
|
("600519.SH", "贵州茅台"), ("000001.SZ", "平安银行"), ("300750.SZ", "宁德时代"),
|
|
("002594.SZ", "比亚迪"), ("000858.SZ", "五粮液"), ("601318.SH", "中国平安"),
|
|
("002475.SZ", "立讯精密"), ("600036.SH", "招商银行"), ("000725.SZ", "京东方A"),
|
|
("300059.SZ", "东方财富"),
|
|
]
|
|
_DEMO_TEMPLATES = [
|
|
("signal", "MA金叉触发", ["signal_ma_golden_5_20"], "info"),
|
|
("signal", "放量突破新高", ["signal_volume_surge", "signal_n_day_high"], "warn"),
|
|
("signal", "MACD金叉", ["signal_macd_golden"], "info"),
|
|
("signal", "跌破MA20", ["signal_ma20_breakdown"], "info"),
|
|
("price", "涨幅超 5%", [], "warn"),
|
|
("price", "RSI 极度超卖", [], "warn"),
|
|
("price", "跌幅超 3%", [], "info"),
|
|
("market", "涨停封板", ["signal_limit_up"], "critical"),
|
|
("market", "连板异动", ["signal_limit_up"], "warn"),
|
|
("market", "炸板", ["signal_broken_limit_up"], "warn"),
|
|
# 新策略变更格式
|
|
("strategy", "策略「趋势突破」进入 贵州茅台 +2.3%", ["signal_n_day_high", "signal_volume_surge"], "info"),
|
|
("strategy", "策略「趋势突破」移出 五粮液 -1.5%", ["signal_ma20_breakdown"], "info"),
|
|
("strategy", "策略「新低反转」进入 平安银行 +1.1%", ["signal_n_day_low"], "warn"),
|
|
("strategy", "策略「MACD金叉」移出 比亚迪 -0.8%", ["signal_macd_golden"], "info"),
|
|
# 批量变更
|
|
("strategy", "策略「趋势突破」进入 6 只:平安银行、宁德时代、比亚迪、东方财富、招商银行、立讯精密", [], "info"),
|
|
("strategy", "策略「MACD金叉」移出 7 只:京东方A、平安银行、五粮液、立讯精密、招商银行、东方财富、比亚迪", [], "warn"),
|
|
]
|
|
|
|
|
|
@router.post("/seed")
|
|
def seed_demo_alerts(request: Request, count: int = 12, recent: bool = True):
|
|
"""生成演示触发记录 (Dev 页用)。
|
|
|
|
Args:
|
|
count: 生成条数 (1-50)
|
|
recent: True=时间戳设为"刚刚"(用于测试闪烁效果); False=分散在近3天
|
|
"""
|
|
count = max(1, min(50, count))
|
|
now_ms = int(time.time() * 1000)
|
|
events = []
|
|
for i in range(count):
|
|
source, message, signals, severity = _DEMO_TEMPLATES[i % len(_DEMO_TEMPLATES)]
|
|
sym, name = _DEMO_STOCKS[i % len(_DEMO_STOCKS)]
|
|
# 策略类型按消息推导 type: new_entry / dropped, 否则沿用 source
|
|
if source == "strategy":
|
|
if "进入" in message:
|
|
ev_type = "new_entry"
|
|
elif "移出" in message:
|
|
ev_type = "dropped"
|
|
else:
|
|
ev_type = "strategy"
|
|
else:
|
|
ev_type = source
|
|
# recent 模式: 时间戳从现在往前每条错开 30 秒 (最新在前)
|
|
ts = now_ms - (i * 30000) if recent else now_ms - random.randint(60, 4320) * 60 * 1000
|
|
events.append({
|
|
"ts": ts,
|
|
"rule_id": f"demo_rule_{i}",
|
|
"rule_name": message,
|
|
"source": source,
|
|
"type": ev_type,
|
|
"symbol": "" if source == "strategy" and ("只:" in message) else sym,
|
|
"name": name,
|
|
"message": message,
|
|
"price": round(random.uniform(8, 1800), 2) if not (source == "strategy" and "只:" in message) else None,
|
|
"change_pct": round(random.uniform(-0.06, 0.098), 4) if not (source == "strategy" and "只:" in message) else None,
|
|
"signals": signals,
|
|
"severity": severity,
|
|
})
|
|
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]
|
|
qs.push_alerts(sse_alerts)
|
|
|
|
return {"ok": True, "generated": len(events)}
|
|
|