删除实时行情、盘口深度和监控规则功能

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-04 17:32:39 +08:00
parent 0474e5fb46
commit 9904854cc1
47 changed files with 107 additions and 6059 deletions
-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)}
-6
View File
@@ -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"}
-76
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)
@@ -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]
+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],
}
+45 -61
View File
@@ -222,68 +222,54 @@ 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)
try:
db_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
WHERE symbol IN ({placeholders})
AND (? IS NULL OR date <= ?)
), 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 symbol, date, last_price, prev_close
FROM latest
""",
[*CORE_INDEX_SYMBOLS, as_of, as_of],
repo = getattr(request.app.state, "repo", None)
if repo:
placeholders = ", ".join("?" for _ in CORE_INDEX_SYMBOLS)
try:
db_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
WHERE symbol IN ({placeholders})
AND (? IS NULL OR date <= ?)
), 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
)
except Exception: # noqa: BLE001
db_rows = []
for symbol, dt, last_price, prev_close in db_rows:
change_amount = None
change_pct = None
lp = _finite(last_price)
pc = _finite(prev_close)
if lp is not None and pc not in (None, 0):
change_amount = lp - pc
change_pct = change_amount / pc * 100
rows.append({
"symbol": symbol,
"name": CORE_INDEX_NAMES.get(symbol),
"date": str(dt) if dt else None,
"last_price": lp,
"close": lp,
"prev_close": pc,
"change_amount": change_amount,
"change_pct": change_pct,
})
SELECT symbol, date, last_price, prev_close
FROM latest
""",
[*CORE_INDEX_SYMBOLS, as_of, as_of],
)
except Exception: # noqa: BLE001
db_rows = []
for symbol, dt, last_price, prev_close in db_rows:
change_amount = None
change_pct = None
lp = _finite(last_price)
pc = _finite(prev_close)
if lp is not None and pc not in (None, 0):
change_amount = lp - pc
change_pct = change_amount / pc * 100
rows.append({
"symbol": symbol,
"name": CORE_INDEX_NAMES.get(symbol),
"date": str(dt) if dt else None,
"last_price": lp,
"close": lp,
"prev_close": pc,
"change_amount": change_amount,
"change_pct": change_pct,
})
by_symbol = {r.get("symbol"): r for r in rows}
out = []
@@ -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"
+10 -119
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,69 +527,11 @@ 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:
df = df.with_columns(
pl.lit(None).alias("sealed_status"),
pl.lit(None).alias("sealed_vol"),
)
# 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"),
)
# 动态 JOIN 扩展数据
ext_specs = _parse_ext_columns(ext_columns) if ext_columns else []
@@ -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,
}
-293
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(),
@@ -345,20 +334,10 @@ def get_preferences() -> dict:
"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(),
}
@@ -437,69 +416,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 +429,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 +457,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 +727,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 复用)。
# 注: 策略监控已移除。
# ── 热重载 ───────────────────────────────────────────────────────────