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

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) alert_store.append_many(_data_dir(request), events)
# 同步推入 SSE 队列, 让所有连着 SSE 的客户端实时收到 (不依赖轮询)
qs = getattr(request.app.state, "quote_service", None)
if qs:
# 转成 SSE 推送格式 (和 _evaluate_monitors 一致)
sse_alerts = [{
"source": ev["source"],
"type": ev["type"],
"rule_id": ev.get("rule_id"),
"symbol": ev["symbol"],
"name": ev["name"],
"message": ev["message"],
"price": ev["price"],
"change_pct": ev["change_pct"],
"signals": ev["signals"],
"severity": ev.get("severity", "info"),
} for ev in events]
with qs._lock:
qs._pending_alerts.extend(sse_alerts)
qs._alert_event.set()
return {"ok": True, "generated": len(events)} return {"ok": True, "generated": len(events)}
-6
View File
@@ -658,12 +658,6 @@ def clear_data(request: Request):
# - 触发记录 alerts.jsonl # - 触发记录 alerts.jsonl
from app.services import alert_store from app.services import alert_store
alert_store.clear(data_dir) alert_store.clear(data_dir)
# - 待推送的实时通知队列 (进程内存)
qs = getattr(request.app.state, "quote_service", None)
if qs is not None:
with qs._lock:
qs._pending_alerts.clear()
# 清除 Polars 缓存 # 清除 Polars 缓存
# 先 clear_cache 无条件清空内存 (refresh_cache 在磁盘无数据时会提前 return, # 先 clear_cache 无条件清空内存 (refresh_cache 在磁盘无数据时会提前 return,
# 导致 _enriched_cache 等旧数据残留 —— 清数据后看板仍显示旧数据的根因), # 导致 _enriched_cache 等旧数据残留 —— 清数据后看板仍显示旧数据的根因),
-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) logger.debug("单股除权因子拉取失败 %s: %s", symbol, e)
enriched = compute_enriched(raw, factors=factors) enriched = compute_enriched(raw, factors=factors)
rows = enriched.tail(days).to_dicts() rows = enriched.tail(days).to_dicts()
# 即使 live 模式也尝试追加实时蜡烛
rows = _maybe_inject_live_candle(request, symbol, rows)
resp = {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": rows, "source": "live"} resp = {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": rows, "source": "live"}
return _attach_ext(resp, repo, symbol, ext_columns) return _attach_ext(resp, repo, symbol, ext_columns)
rows = df.to_dicts() rows = df.to_dicts()
# 追加/覆盖今日实时蜡烛
rows = _maybe_inject_live_candle(request, symbol, rows)
resp = {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": rows, "source": "enriched"} resp = {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": rows, "source": "enriched"}
return _attach_ext(resp, repo, symbol, ext_columns) return _attach_ext(resp, repo, symbol, ext_columns)
@@ -216,77 +211,6 @@ def _attach_ext(resp: dict, repo, symbol: str, ext_columns: Optional[str]) -> di
return resp return resp
def _maybe_inject_live_candle(request: Request, symbol: str, rows: list[dict]) -> list[dict]:
"""如果 QuoteService 有实时 enriched 数据, 用实时数据生成今日蜡烛并追加/覆盖。"""
qs = getattr(request.app.state, "quote_service", None)
if not qs:
return rows
df_today, enriched_date = qs.get_enriched_today()
if df_today.is_empty():
return rows
# 非交易日(周末/假日)缓存的行情日期 != 今天,跳过注入避免产生重复蜡烛
if not enriched_date or enriched_date != date.today():
return rows
# 查找该 symbol 的实时 enriched 行
import polars as pl
try:
q = df_today.filter(pl.col("symbol") == symbol).to_dicts()
if not q:
return rows
q = q[0]
except Exception: # noqa: BLE001
return rows
close_price = q.get("close")
if not close_price or close_price <= 0:
return rows
today_str = str(enriched_date)
# enriched 行已包含 OHLCV + 全套指标, 直接用它
# 修复: API 在非交易时段可能返回 open/high/low=0, 用 close 填充避免异常蜡烛
raw_open = q.get("open")
raw_high = q.get("high")
raw_low = q.get("low")
live_row: dict = {
"date": today_str,
"symbol": symbol,
"open": raw_open if raw_open and raw_open > 0 else close_price,
"high": raw_high if raw_high and raw_high > 0 else close_price,
"low": raw_low if raw_low and raw_low > 0 else close_price,
"close": close_price,
"volume": q.get("volume"),
"amount": q.get("amount"),
"change_pct": q.get("change_pct"),
"is_live": True,
}
# 补上 enriched 的技术指标字段
for key in ("ma5", "ma10", "ma20", "ma30", "ma60",
"macd_dif", "macd_dea", "macd_hist",
"kdj_k", "kdj_d", "kdj_j",
"boll_upper", "boll_lower",
"rsi_6", "rsi_14", "rsi_24",
"atr_14", "vol_ratio_5d"):
if key in q and q[key] is not None:
live_row[key] = q[key]
# 如果已有今天的 enriched 行, 覆盖; 否则追加
found = False
for i, r in enumerate(rows):
if str(r.get("date")) == today_str:
r.update(live_row)
found = True
break
if not found:
rows.append(live_row)
return rows
class DailyBatchRequest: class DailyBatchRequest:
"""批量日K请求。""" """批量日K请求。"""
symbols: list[str] symbols: list[str]
+1 -3
View File
@@ -46,8 +46,6 @@ async def analyze_market(request: Request, req: AnalyzeRequest):
from datetime import date as date_cls from datetime import date as date_cls
repo = request.app.state.repo repo = request.app.state.repo
quote_service = getattr(request.app.state, "quote_service", None)
depth_service = getattr(request.app.state, "depth_service", None)
as_of = None as_of = None
if req.as_of: if req.as_of:
@@ -57,7 +55,7 @@ async def analyze_market(request: Request, req: AnalyzeRequest):
raise HTTPException(400, f"as_of 格式应为 YYYY-MM-DD,收到: {req.as_of}") raise HTTPException(400, f"as_of 格式应为 YYYY-MM-DD,收到: {req.as_of}")
async def stream_gen(): async def stream_gen():
async for chunk in recap_market_stream(repo, quote_service, depth_service, as_of, req.focus): async for chunk in recap_market_stream(repo, as_of, req.focus):
yield chunk + "\n" yield chunk + "\n"
return StreamingResponse( return StreamingResponse(
-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))) return max(0, min(100, round((value - low) / (high - low) * 100)))
def _quote_status(request: Request) -> dict:
qs = getattr(request.app.state, "quote_service", None)
if not qs:
return {"enabled": False, "running": False, "quote_age_ms": None, "is_trading_hours": False}
return qs.status()
def _index_quotes(request: Request, as_of: date | None = None) -> list[dict]: def _index_quotes(request: Request, as_of: date | None = None) -> list[dict]:
qs = getattr(request.app.state, "quote_service", None)
rows: list[dict] = [] rows: list[dict] = []
if qs and as_of is None:
df = qs.get_index_quotes(list(CORE_INDEX_SYMBOLS))
if not df.is_empty():
rows = df.to_dicts()
if not rows:
repo = getattr(request.app.state, "repo", None) repo = getattr(request.app.state, "repo", None)
if repo: if repo:
placeholders = ", ".join("?" for _ in CORE_INDEX_SYMBOLS) placeholders = ", ".join("?" for _ in CORE_INDEX_SYMBOLS)
@@ -351,8 +337,6 @@ def _build_overview(request: Request, as_of: date | None = None) -> dict:
from app.services.market_overview_builder import build_market_overview from app.services.market_overview_builder import build_market_overview
return build_market_overview( return build_market_overview(
repo=request.app.state.repo, repo=request.app.state.repo,
quote_service=getattr(request.app.state, "quote_service", None),
depth_service=getattr(request.app.state, "depth_service", None),
as_of=as_of, as_of=as_of,
) )
+1 -3
View File
@@ -50,13 +50,11 @@ async def analyze_rotation(request: Request, req: AnalyzeRequest):
{"type":"done"} {"type":"done"}
""" """
repo = request.app.state.repo repo = request.app.state.repo
quote_service = getattr(request.app.state, "quote_service", None)
depth_service = getattr(request.app.state, "depth_service", None)
days = max(7, min(30, req.days)) days = max(7, min(30, req.days))
async def stream_gen(): async def stream_gen():
async for chunk in analyze_rotation_stream( async for chunk in analyze_rotation_stream(
repo, days, req.focus, quote_service, depth_service, repo, days, req.focus,
): ):
yield chunk + "\n" yield chunk + "\n"
+6 -115
View File
@@ -278,32 +278,16 @@ def get_cached(
request: Request, request: Request,
ext_columns: Optional[str] = Query(None, description="逗号分隔: config_id.field_name"), ext_columns: Optional[str] = Query(None, description="逗号分隔: config_id.field_name"),
): ):
"""读取策略结果缓存, 并叠加监控引擎本轮实时算出的结果 """读取策略结果缓存。
- 盘后缓存 (strategy_cache.json): 非监控策略 / 页面秒加载用, run_all 写入。 - 盘后缓存 (strategy_cache.json): run_all 写入, 页面秒加载用
- 监控引擎内存结果 (latest_strategy_results): 实时行情每轮对「加入监控的策略」算出,
不落盘 (避免与 read_cache 的 mtime 校验冲突), 在此直接叠加覆盖盘后结果。
被监控的策略拿到新鲜数据, 非监控策略仍用盘后缓存。
""" """
data_dir = request.app.state.repo.store.data_dir data_dir = request.app.state.repo.store.data_dir
cached = strategy_cache.read_cache(data_dir) cached = strategy_cache.read_cache(data_dir)
if cached is None: if cached is None:
cached = {"as_of": None, "results": {}, "updated_at": None} cached = {"as_of": None, "results": {}, "updated_at": None}
# 叠加监控引擎内存里的实时结果 (若有), 用新鲜数据覆盖同策略的盘后结果 # 无任何数据 → 返回空标记, 前端据此提示
monitor_engine = getattr(request.app.state, "monitor_engine", None)
if monitor_engine is not None:
realtime_results = monitor_engine.latest_strategy_results()
if realtime_results:
results = dict(cached.get("results") or {})
results.update(realtime_results)
cached = dict(cached)
cached["results"] = results
# 有实时数据时, 以最新时间戳为准
import time as _time
cached["updated_at"] = int(_time.time() * 1000)
# 无任何数据 (盘后缓存空 + 无实时结果) → 返回空标记, 前端据此提示
if not cached.get("results") and cached.get("as_of") is None: if not cached.get("results") and cached.get("as_of") is None:
return {"as_of": None, "results": {}, "updated_at": None} return {"as_of": None, "results": {}, "updated_at": None}
@@ -503,34 +487,8 @@ def limit_ladder(
count_up_raw = int(df.filter(pl.col("signal_limit_up").fill_null(False)).height) if "signal_limit_up" in df.columns else 0 count_up_raw = int(df.filter(pl.col("signal_limit_up").fill_null(False)).height) if "signal_limit_up" in df.columns else 0
count_down_raw = int(df.filter(pl.col("signal_limit_down").fill_null(False)).height) if "signal_limit_down" in df.columns else 0 count_down_raw = int(df.filter(pl.col("signal_limit_down").fill_null(False)).height) if "signal_limit_down" in df.columns else 0
# 双方向 sealed 修正: 减去各自的假涨停(假涨停已归炸板, 不计入涨停数) count_up = count_up_raw
depth_svc_global = getattr(request.app.state, "depth_service", None) count_down = count_down_raw
fake_up = 0
fake_down = 0
sealed_up_ready = False
sealed_down_ready = False
if depth_svc_global:
up_map = depth_svc_global.get_sealed_map(as_of, is_down=False)
down_map = depth_svc_global.get_sealed_map(as_of, is_down=True)
sealed_up_ready = bool(up_map) and depth_svc_global.is_sealed_ready(as_of)
sealed_down_ready = bool(down_map) and depth_svc_global.is_sealed_ready(as_of)
if up_map:
fake_up = sum(1 for v in up_map.values() if v.get("sealed") is False)
if down_map:
fake_down = sum(1 for v in down_map.values() if v.get("sealed") is False)
count_up = count_up_raw - fake_up if sealed_up_ready else count_up_raw
count_down = count_down_raw - fake_down if sealed_down_ready else count_down_raw
# 双方向 sealed 明细(供前端弹窗同时显示涨跌停)
def _count_sealed(m: dict, ready: bool):
if not m or not ready:
return {"real": 0, "fake": 0, "pending": 0}
real = sum(1 for v in m.values() if v.get("sealed") is True)
fake = sum(1 for v in m.values() if v.get("sealed") is False)
pending = sum(1 for v in m.values() if v.get("sealed") is None)
return {"real": real, "fake": fake, "pending": pending}
sealed_counts_up = _count_sealed(up_map, sealed_up_ready)
sealed_counts_down = _count_sealed(down_map, sealed_down_ready)
# 加载前一日数据获取 prev consecutive_limit_ups/downs # 加载前一日数据获取 prev consecutive_limit_ups/downs
prev_consec: pl.DataFrame = pl.DataFrame() prev_consec: pl.DataFrame = pl.DataFrame()
@@ -569,65 +527,7 @@ def limit_ladder(
df = df.filter(pl.col("status").is_not_null() & (pl.col("boards") > 0)) df = df.filter(pl.col("status").is_not_null() & (pl.col("boards") > 0))
# ── 五档 sealed 叠加(独立旁路, 不改 signal_limit_up) ── # Add null columns for sealed_status and sealed_vol (no longer used)
# 假涨停(收盘价=涨停价但卖一有量)从 limit 降级为 broken(归炸板视图)
# 真涨停保留 + 附封单量; sealed=null(待确认/降级)保持原状
depth_svc = getattr(request.app.state, "depth_service", None)
sealed_ready = False
sealed_age: float | None = None
if depth_svc:
sealed_map = depth_svc.get_sealed_map(as_of, is_down=is_down)
sealed_ready = bool(sealed_map) and depth_svc.is_sealed_ready(as_of)
sealed_age = depth_svc.get_sealed_age(as_of) if sealed_ready else None
if sealed_map:
# 构建 sealed 列(symbol → sealed bool, vol)
sym_sealed = {s: v.get("sealed") for s, v in sealed_map.items()}
sym_vol = {s: v.get("vol") for s, v in sealed_map.items()}
# JOIN sealed: 对每只 status=main 的票, 看 sealed 值
sealed_rows = pl.DataFrame({
"symbol": list(sym_sealed.keys()),
"_sealed": list(sym_sealed.values()),
"_sealed_vol": list(sym_vol.values()),
}) if sym_sealed else pl.DataFrame()
if not sealed_rows.is_empty():
df = df.join(sealed_rows, on="symbol", how="left")
# 假涨停(main 状态但 sealed=False)→ 降级为 broken
df = df.with_columns(
pl.when(
(pl.col("status") == status_main)
& pl.col("_sealed").is_not_null()
& (pl.col("_sealed") == False) # noqa: E712
).then(pl.lit(status_broken))
.otherwise(pl.col("status")).alias("status"),
# sealed_status: real/fake/pending/null
pl.when(
(pl.col("status") == status_main)
& (pl.col("_sealed") == True) # noqa: E712
).then(pl.lit("real"))
.when(
(pl.col("_sealed") == False) # noqa: E712
).then(pl.lit("fake"))
.when(
(pl.col("status") == status_main)
& pl.col("_sealed").is_null()
).then(pl.lit("pending"))
.otherwise(None).alias("sealed_status"),
pl.col("_sealed_vol").alias("sealed_vol"),
).drop(["_sealed", "_sealed_vol"])
else:
df = df.with_columns(
pl.lit(None).alias("sealed_status"),
pl.lit(None).alias("sealed_vol"),
)
else:
df = df.with_columns(
pl.lit(None).alias("sealed_status"),
pl.lit(None).alias("sealed_vol"),
)
else:
df = df.with_columns( df = df.with_columns(
pl.lit(None).alias("sealed_status"), pl.lit(None).alias("sealed_status"),
pl.lit(None).alias("sealed_vol"), pl.lit(None).alias("sealed_vol"),
@@ -700,15 +600,6 @@ def limit_ladder(
"tiers": tier_list, "tiers": tier_list,
"counts": {"up": count_up, "down": count_down}, "counts": {"up": count_up, "down": count_down},
"counts_raw": {"up": count_up_raw, "down": count_down_raw}, "counts_raw": {"up": count_up_raw, "down": count_down_raw},
"sealed_ready": sealed_ready,
"sealed_age": round(sealed_age, 0) if sealed_age is not None else None,
"sealed_counts": {
"real": sum(1 for t in tier_list for s in t.get("stocks", []) if s.get("sealed_status") == "real"),
"fake": sum(1 for t in tier_list for s in t.get("stocks", []) if s.get("sealed_status") == "fake"),
"pending": sum(1 for t in tier_list for s in t.get("stocks", []) if s.get("sealed_status") == "pending"),
},
"sealed_counts_up": sealed_counts_up,
"sealed_counts_down": sealed_counts_down,
} }
-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): class MinuteSyncPrefs(BaseModel):
minute_sync_enabled: bool minute_sync_enabled: bool
minute_sync_days: int = 5 minute_sync_days: int = 5
@@ -324,17 +318,12 @@ def get_preferences() -> dict:
"""返回用户偏好设置。""" """返回用户偏好设置。"""
from app.services import preferences from app.services import preferences
return { return {
"realtime_quotes_enabled": preferences.get_realtime_quotes_enabled(),
"realtime_allowed": _realtime_allowed(),
"indices_nav_pinned": preferences.get_indices_nav_pinned(), "indices_nav_pinned": preferences.get_indices_nav_pinned(),
"minute_sync_enabled": preferences.get_minute_sync_enabled(), "minute_sync_enabled": preferences.get_minute_sync_enabled(),
"minute_sync_days": preferences.get_minute_sync_days(), "minute_sync_days": preferences.get_minute_sync_days(),
"daily_data_provider": preferences.get_daily_data_provider(), "daily_data_provider": preferences.get_daily_data_provider(),
"adj_factor_provider": preferences.get_adj_factor_provider(), "adj_factor_provider": preferences.get_adj_factor_provider(),
"minute_data_provider": preferences.get_minute_data_provider(), "minute_data_provider": preferences.get_minute_data_provider(),
"realtime_data_provider": preferences.get_realtime_data_provider(),
"realtime_watchlist_symbols": preferences.get_realtime_watchlist_symbols(),
**preferences.get_realtime_quote_scope(),
"pipeline_pull_a_share": preferences.get_pipeline_pull_a_share(), "pipeline_pull_a_share": preferences.get_pipeline_pull_a_share(),
"pipeline_pull_etf": preferences.get_pipeline_pull_etf(), "pipeline_pull_etf": preferences.get_pipeline_pull_etf(),
"pipeline_pull_index": preferences.get_pipeline_pull_index(), "pipeline_pull_index": preferences.get_pipeline_pull_index(),
@@ -345,20 +334,10 @@ def get_preferences() -> dict:
"index_daily_batch_size": preferences.get_index_daily_batch_size(), "index_daily_batch_size": preferences.get_index_daily_batch_size(),
"watchlist_columns": preferences.get_watchlist_columns(), "watchlist_columns": preferences.get_watchlist_columns(),
"screener_result_columns": preferences.get_screener_result_columns(), "screener_result_columns": preferences.get_screener_result_columns(),
"sse_refresh_pages": preferences.get_sse_refresh_pages(),
"strategy_monitor_enabled": preferences.get_strategy_monitor_enabled(),
"strategy_monitor_ids": preferences.get_strategy_monitor_ids(),
"system_notify_enabled": preferences.get_system_notify_enabled(),
"feishu_webhook_url": preferences.get_feishu_webhook_url(),
"feishu_webhook_secret": preferences.get_feishu_webhook_secret(),
"webhook_enabled_default": preferences.get_webhook_enabled_default(),
"sidebar_index_symbols": preferences.get_sidebar_index_symbols(), "sidebar_index_symbols": preferences.get_sidebar_index_symbols(),
"nav_order": preferences.get_nav_order(), "nav_order": preferences.get_nav_order(),
"nav_hidden": preferences.get_nav_hidden(), "nav_hidden": preferences.get_nav_hidden(),
"screener_auto_run": preferences.get_screener_auto_run(), "screener_auto_run": preferences.get_screener_auto_run(),
"limit_ladder_monitor_enabled": preferences.get_limit_ladder_monitor_enabled(),
"depth_polling_interval": preferences.get_depth_polling_interval(),
"depth_finalize_time": preferences.get_depth_finalize_time(),
"review_schedule": preferences.get_review_schedule(), "review_schedule": preferences.get_review_schedule(),
"review_push_channels": preferences.get_review_push_channels(), "review_push_channels": preferences.get_review_push_channels(),
} }
@@ -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): class IndicesNavPinnedPrefs(BaseModel):
indices_nav_pinned: bool indices_nav_pinned: bool
@@ -513,45 +429,6 @@ def update_indices_nav_pinned(req: IndicesNavPinnedPrefs) -> dict:
return {"indices_nav_pinned": req.indices_nav_pinned} return {"indices_nav_pinned": req.indices_nav_pinned}
class RealtimeMonitorConfigIn(BaseModel):
sse_refresh_pages: dict[str, bool] | None = None
strategy_monitor_enabled: bool | None = None
strategy_monitor_ids: list[str] | None = None
sidebar_index_symbols: list[str] | None = None
screener_auto_run: bool | None = None
@router.put("/preferences/realtime-monitor")
def update_realtime_monitor_config(req: RealtimeMonitorConfigIn, request: Request) -> dict:
"""更新实时监控配置。策略监控统一迁移为 MonitorRule,由监控引擎评估。"""
from app.services import preferences
cfg = req.model_dump(exclude_none=True)
result = preferences.set_realtime_monitor_config(cfg)
# 策略监控开关/池变化 → 同步迁移为 type=strategy 规则 + reload 引擎
if req.strategy_monitor_ids is not None or req.strategy_monitor_enabled is not None:
monitor_engine = getattr(request.app.state, "monitor_engine", None)
strategy_engine = getattr(request.app.state, "strategy_engine", None)
data_dir = request.app.state.repo.store.data_dir
if monitor_engine is not None and strategy_engine is not None:
from app.strategy import monitor_rules as mr_store
try:
if preferences.get_strategy_monitor_enabled():
ids = preferences.get_strategy_monitor_ids()
names = {s.id: s.name for s in strategy_engine.list_strategies()}
mr_store.migrate_strategy_monitors(data_dir, ids, names)
else:
# 关闭策略监控: 停用所有策略规则
mr_store.migrate_strategy_monitors(data_dir, [], {})
# reload 规则到引擎
monitor_engine.set_rules(mr_store.load_all(data_dir))
except Exception:
pass
return result
class PipelinePullTypesIn(BaseModel): class PipelinePullTypesIn(BaseModel):
"""盘后管道拉取内容开关(A股 / ETF / 指数 独立控制)。""" """盘后管道拉取内容开关(A股 / ETF / 指数 独立控制)。"""
pipeline_pull_a_share: bool | None = None pipeline_pull_a_share: bool | None = None
@@ -580,97 +457,6 @@ def update_pipeline_index_symbols(req: PipelineIndexSymbolsIn) -> dict:
return {"pipeline_index_symbols": symbols} return {"pipeline_index_symbols": symbols}
class QuoteIntervalIn(BaseModel):
interval: float
class SystemNotifyPrefsIn(BaseModel):
enabled: bool
@router.put("/preferences/system-notify")
def update_system_notify(req: SystemNotifyPrefsIn) -> dict:
"""系统通知开关 — 开启后监控告警同时推送到操作系统通知中心。
纯偏好, 无副作用 (不像策略监控要迁移规则), 直接落盘即可。
quote_service 在每轮告警评估时读此开关决定是否发系统通知。
"""
from app.services import preferences
saved = preferences.set_system_notify_enabled(req.enabled)
return {"system_notify_enabled": saved}
class FeishuWebhookPrefsIn(BaseModel):
url: str
secret: str = ""
@router.put("/preferences/feishu-webhook")
def update_feishu_webhook(req: FeishuWebhookPrefsIn) -> dict:
"""飞书 Webhook 地址 + 签名密钥 — 全局一处配置, 所有启用推送的监控规则共用。
- url: 传入空串表示清空配置; 非空则需为合法的飞书自定义机器人地址。
- secret: 机器人启用了「签名校验」时填密钥, 留空表示不验签。
"""
from app.services import preferences
from app.services import webhook_adapter
url = (req.url or "").strip()
if url and not webhook_adapter.is_valid_feishu_url(url):
raise HTTPException(
status_code=400,
detail="Webhook 地址非法, 需为飞书自定义机器人地址 "
"(https://open.feishu.cn/open-apis/bot/v2/hook/...)",
)
saved_url = preferences.set_feishu_webhook_url(url)
saved_secret = preferences.set_feishu_webhook_secret((req.secret or "").strip())
return {"feishu_webhook_url": saved_url, "feishu_webhook_secret": saved_secret}
class WebhookEnabledDefaultIn(BaseModel):
enabled: bool
@router.put("/preferences/webhook-enabled-default")
def update_webhook_enabled_default(req: WebhookEnabledDefaultIn) -> dict:
"""新建监控规则时是否默认勾选「飞书推送」。
数据模型当前只有飞书一个可用渠道 (QMT/ptrade 待定),故此处仅一个布尔。
单条规则仍可在规则编辑页独立修改此项。
"""
from app.services import preferences
saved = preferences.set_webhook_enabled_default(req.enabled)
return {"webhook_enabled_default": saved}
@router.put("/preferences/quote-interval")
def update_quote_interval(req: QuoteIntervalIn, request: Request) -> dict:
"""更新行情轮询间隔。按档位自动 clamp。"""
qs = getattr(request.app.state, "quote_service", None)
if not qs:
return {"interval": req.interval, "min_interval": qs.get_min_interval(), "max_interval": 60.0}
clamped = qs.set_interval(req.interval)
return {
"interval": clamped,
"min_interval": qs.get_min_interval(),
"max_interval": qs.MAX_INTERVAL,
}
@router.get("/preferences/quote-interval")
def get_quote_interval(request: Request) -> dict:
"""获取当前行情轮询间隔和档位限制。"""
qs = getattr(request.app.state, "quote_service", None)
if not qs:
return {"interval": 10.0, "min_interval": 5.0, "max_interval": 60.0}
return {
"interval": qs._interval,
"min_interval": qs.get_min_interval(),
"max_interval": qs.MAX_INTERVAL,
}
class TestEndpointIn(BaseModel): class TestEndpointIn(BaseModel):
url: str url: str
# 测试轮数;不传时取 endpoints.json 的 testRounds(默认 5) # 测试轮数;不传时取 endpoints.json 的 testRounds(默认 5)
@@ -941,85 +727,6 @@ def update_index_daily_batch_size(req: IndexDailyBatchSizeIn) -> dict:
return {"index_daily_batch_size": size} return {"index_daily_batch_size": size}
# ── 五档盘口 sealed 配置 ──────────────────────────────
class LimitLadderMonitorIn(BaseModel):
enabled: bool
@router.put("/preferences/limit-ladder-monitor")
def update_limit_ladder_monitor(req: LimitLadderMonitorIn, request: Request) -> dict:
"""连板梯队 5 档监控开关。开启→启动 depth 轮询, 关闭→停止。"""
from app.services import preferences
preferences.save({"limit_ladder_monitor_enabled": req.enabled})
# 立即应用: 启停 depth 轮询线程
depth_svc = getattr(request.app.state, "depth_service", None)
if depth_svc:
depth_svc.apply_monitor_toggle(req.enabled)
return {"limit_ladder_monitor_enabled": req.enabled}
@router.post("/preferences/limit-ladder-monitor/run")
def run_limit_ladder_fix(request: Request) -> dict:
"""立即手动修正一次真假板(拉取五档盘口 + 更新缓存)。需 Pro+。"""
from app.tickflow.capabilities import Cap
capset = request.app.state.capabilities
capset.require(Cap.DEPTH5_BATCH) # 无能力抛 CapabilityDenied(403)
depth_svc = getattr(request.app.state, "depth_service", None)
if not depth_svc:
raise HTTPException(status_code=503, detail="depth 服务未初始化")
return depth_svc.run_once()
class DepthPollingIntervalIn(BaseModel):
interval: float
@router.put("/preferences/depth-polling-interval")
def update_depth_polling_interval(req: DepthPollingIntervalIn, request: Request) -> dict:
"""保存五档盘口盘中轮询间隔(秒)。需 Pro+。"""
from app.tickflow.capabilities import Cap
request.app.state.capabilities.require(Cap.DEPTH5_BATCH)
from app.services import preferences
interval = preferences.set_depth_polling_interval(req.interval)
return {"depth_polling_interval": interval}
class DepthFinalizeTimeIn(BaseModel):
hour: int
minute: int
@router.put("/preferences/depth-finalize-time")
def update_depth_finalize_time(req: DepthFinalizeTimeIn, request: Request) -> dict:
"""保存盘后 sealed 定版时间(范围15:01~18:00)并立即 reschedule。需 Pro+。"""
from app.tickflow.capabilities import Cap
request.app.state.capabilities.require(Cap.DEPTH5_BATCH)
from app.services import preferences
sched = preferences.set_depth_finalize_time(req.hour, req.minute)
from apscheduler.triggers.cron import CronTrigger
scheduler = getattr(request.app.state, "scheduler", None)
if scheduler:
scheduler.reschedule_job(
"depth_finalize",
trigger=CronTrigger(
day_of_week="mon-fri",
hour=sched["hour"],
minute=sched["minute"],
timezone="Asia/Shanghai",
),
)
logger.info("depth_finalize rescheduled to %02d:%02d mon-fri", sched["hour"], sched["minute"])
return sched
class ReviewScheduleIn(BaseModel): class ReviewScheduleIn(BaseModel):
enabled: bool enabled: bool
hour: int hour: int
+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.engine import StrategyEngine, StrategyDef
from app.strategy.ai_generator import AIStrategyGenerator from app.strategy.ai_generator import AIStrategyGenerator
from app.strategy.prompt_builder import build_step1, build_step2 from app.strategy.prompt_builder import build_step1, build_step2
from app.strategy.monitor import StrategyMonitorService, StrategyAlert
router = APIRouter(prefix="/api/strategies", tags=["strategies"]) router = APIRouter(prefix="/api/strategies", tags=["strategies"])
@@ -31,13 +30,6 @@ def _get_engine(request: Request) -> StrategyEngine:
return engine return engine
def _get_monitor(request: Request) -> StrategyMonitorService:
mon = getattr(request.app.state, "strategy_monitor", None)
if not mon:
raise HTTPException(status_code=503, detail="策略监控未初始化")
return mon
def _data_dir(request: Request) -> Path: def _data_dir(request: Request) -> Path:
return request.app.state.repo.store.data_dir return request.app.state.repo.store.data_dir
@@ -427,8 +419,7 @@ def delete_strategy(strategy_id: str, request: Request):
# ── 监控 ───────────────────────────────────────────────────────────── # ── 监控 ─────────────────────────────────────────────────────────────
# 注: 策略监控已统一迁移到 MonitorRuleEngine (监控通知页), 旧的 start/stop/status # 注: 策略监控已移除。
# 路由已移除。StrategyMonitorService 类保留 (其 _check_signals 被 MonitorRuleEngine 复用)。
# ── 热重载 ─────────────────────────────────────────────────────────── # ── 热重载 ───────────────────────────────────────────────────────────
-8
View File
@@ -21,7 +21,6 @@ class ProviderCapabilities:
daily: bool = False daily: bool = False
adj_factor: bool = False adj_factor: bool = False
minute: bool = False minute: bool = False
realtime: bool = False
financial: bool = False financial: bool = False
@@ -59,10 +58,3 @@ class MarketDataProvider(Protocol):
freq: str = "1m", freq: str = "1m",
) -> pl.DataFrame: ) -> pl.DataFrame:
"""Return normalized minute K rows. Implementations may return empty.""" """Return normalized minute K rows. Implementations may return empty."""
def get_realtime(
self,
universes: list[str] | None = None,
symbols: list[str] | None = None,
) -> pl.DataFrame:
"""Return normalized realtime quotes. Implementations may return empty."""
@@ -22,7 +22,6 @@ class TickFlowProvider:
daily=True, daily=True,
adj_factor=True, adj_factor=True,
minute=True, minute=True,
realtime=True,
financial=True, financial=True,
) )
@@ -103,18 +102,3 @@ class TickFlowProvider:
# Existing minute sync remains in app.services.kline_sync for now. # Existing minute sync remains in app.services.kline_sync for now.
return pl.DataFrame() return pl.DataFrame()
def get_realtime(
self,
universes: list[str] | None = None,
symbols: list[str] | None = None,
) -> pl.DataFrame:
tf = get_client()
if universes and symbols:
raise ValueError("TickFlow realtime accepts either universes or symbols, not both")
if universes:
resp = tf.quotes.get_by_universes(universes=universes)
elif symbols:
resp = tf.quotes.get(symbols=symbols)
else:
return pl.DataFrame()
return pl.DataFrame(resp or [])
+10 -67
View File
@@ -558,15 +558,11 @@ REVIEW_JOB_ID = "scheduled_review"
async def _run_scheduled_review(repo) -> None: async def _run_scheduled_review(repo) -> None:
"""定时复盘 job: 流式生成复盘 → 实时推 SSE(开着页面可见) → 落盘归档 → 推飞书。 """定时复盘 job: 流式生成复盘 → 落盘归档 → 推飞书。
与手动「生成复盘」体验一致: 流式事件经 quote_service.push_review_event → 与手动「生成复盘」体验一致。LLM 偶发断流(peer closed connection)时自动重试最多 2 次。
/api/intraday/stream 的 review_progress 事件 → 前端 reviewStore, 用户开着复盘页
即可看到报告边生成边显示, 切走再回来也能看到生成中/已生成。
LLM 偶发断流(peer closed connection)时自动重试最多 2 次。
任何异常都吞掉只记日志, 绝不影响调度器主循环。 任何异常都吞掉只记日志, 绝不影响调度器主循环。
""" """
import json
try: try:
from app.services import market_recap_reports from app.services import market_recap_reports
@@ -577,18 +573,9 @@ async def _run_scheduled_review(repo) -> None:
logger.info("scheduled review skipped: AI key not configured") logger.info("scheduled review skipped: AI key not configured")
return return
app_state = _get_app_state() content, meta = await _stream_review_with_retry(repo)
quote_service = getattr(app_state, "quote_service", None) if app_state else None
depth_service = getattr(app_state, "depth_service", None) if app_state else None
content, meta = await _stream_review_with_retry(repo, quote_service, depth_service)
if not content: if not content:
logger.warning("scheduled review produced no content (meta=%s)", meta) logger.warning("scheduled review produced no content (meta=%s)", meta)
# 通知前端进入 error 态(若有页面在听)
if quote_service:
quote_service.push_review_event(json.dumps(
{"type": "error", "message": "复盘生成失败,请稍后手动重试"},
ensure_ascii=False))
return return
# 落盘: 与手动生成完全相同的归档格式 # 落盘: 与手动生成完全相同的归档格式
@@ -602,34 +589,17 @@ async def _run_scheduled_review(repo) -> None:
}) })
logger.info("scheduled review saved: as_of=%s", meta.get("as_of")) logger.info("scheduled review saved: as_of=%s", meta.get("as_of"))
# 通知前端: 生成完成且已归档(archived=true 让前端只刷新列表, 不重复归档)
if quote_service:
quote_service.push_review_event(json.dumps(
{"type": "done", "archived": True}, ensure_ascii=False))
# 推送到飞书(可选): 运行时读取配置, 用户改设置下次触发即生效。 # 推送到飞书(可选): 运行时读取配置, 用户改设置下次触发即生效。
# 失败静默降级, 不影响已归档的报告。 # 失败静默降级, 不影响已归档的报告。
_maybe_push_review(content, meta) _maybe_push_review(content, meta)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
logger.exception("scheduled review failed: %s", e) logger.exception("scheduled review failed: %s", e)
# 兜底: 异常时通知前端停止「生成中」状态, 避免页面卡在 streaming
try:
app_state = _get_app_state()
qs = getattr(app_state, "quote_service", None) if app_state else None
if qs:
import json as _json
qs.push_review_event(_json.dumps(
{"type": "error", "message": "复盘生成异常,请稍后手动重试"},
ensure_ascii=False))
except Exception: # noqa: BLE001
pass
async def _stream_review_with_retry(repo, quote_service, depth_service) -> tuple[str, dict]: async def _stream_review_with_retry(repo) -> tuple[str, dict]:
"""流式生成复盘, 每个事件推 SSE + 累积内容。LLM 断流时最多重试 2 次。 """流式生成复盘, 累积内容。LLM 断流时最多重试 2 次。
返回 (content, meta)。重试时推一个 retry 事件让前端清空已累积内容重新开始 返回 (content, meta)。成功(收到 done/无 error)或耗尽重试后返回
成功(收到 done/无 error)或耗尽重试后返回。
""" """
import asyncio import asyncio
import json import json
@@ -643,14 +613,10 @@ async def _stream_review_with_retry(repo, quote_service, depth_service) -> tuple
content_parts = [] # 每次重试重新累积 content_parts = [] # 每次重试重新累积
failed = False failed = False
try: try:
async for evt_json in recap_market_stream(repo, quote_service, depth_service): async for evt_json in recap_market_stream(repo):
evt = json.loads(evt_json) evt = json.loads(evt_json)
t = evt.get("type") t = evt.get("type")
# 推给前端(让开着页面的用户实时看到, 与手动一致)
if quote_service:
quote_service.push_review_event(evt_json)
if t == "meta": if t == "meta":
last_meta = evt last_meta = evt
elif t == "delta" and evt.get("content"): elif t == "delta" and evt.get("content"):
@@ -675,10 +641,6 @@ async def _stream_review_with_retry(repo, quote_service, depth_service) -> tuple
# 失败: 决定是否重试 # 失败: 决定是否重试
if attempt < max_attempts: if attempt < max_attempts:
logger.info("scheduled review retrying in 3s (attempt %d%d)", attempt, attempt + 1) logger.info("scheduled review retrying in 3s (attempt %d%d)", attempt, attempt + 1)
# 通知前端: 即将重试, 清空已累积内容重新开始
if quote_service:
quote_service.push_review_event(json.dumps(
{"type": "retry", "attempt": attempt + 1}, ensure_ascii=False))
await asyncio.sleep(3) await asyncio.sleep(3)
# 耗尽重试, 返回已累积内容(可能为空)和最后 meta # 耗尽重试, 返回已累积内容(可能为空)和最后 meta
@@ -790,24 +752,6 @@ def start_scheduler(repo: KlineRepository, capset: CapabilitySet) -> AsyncIOSche
replace_existing=True, replace_existing=True,
) )
# 盘后: 五档盘口 sealed 定版(时间由偏好决定, 默认15:02, 范围15:01~18:00)
depth_sched = preferences.get_depth_finalize_time()
def _depth_finalize():
depth_svc = getattr(_get_app_state(), "depth_service", None) if _get_app_state() else None
if depth_svc:
depth_svc.finalize()
scheduler.add_job(
_depth_finalize,
trigger=CronTrigger(day_of_week="mon-fri",
hour=depth_sched["hour"], minute=depth_sched["minute"],
timezone="Asia/Shanghai"),
id="depth_finalize",
misfire_grace_time=3600,
replace_existing=True,
)
# 定时复盘 (AI 大盘复盘报告): 工作日到点自动生成并归档。 # 定时复盘 (AI 大盘复盘报告): 工作日到点自动生成并归档。
# 默认关闭 —— 仅当用户在复盘页开启时才注册 job。 # 默认关闭 —— 仅当用户在复盘页开启时才注册 job。
# 复用 recap_market_once(非流式) + market_recap_reports.save_report(落盘)。 # 复用 recap_market_once(非流式) + market_recap_reports.save_report(落盘)。
@@ -819,9 +763,8 @@ def start_scheduler(repo: KlineRepository, capset: CapabilitySet) -> AsyncIOSche
review_sched["hour"], review_sched["minute"]) review_sched["hour"], review_sched["minute"])
scheduler.start() scheduler.start()
logger.info("scheduler started; instruments@%02d:%02d, pipeline@%02d:%02d, depth@%02d:%02d mon-fri", logger.info("scheduler started; instruments@%02d:%02d, pipeline@%02d:%02d mon-fri",
inst_sched["hour"], inst_sched["minute"], sched["hour"], sched["minute"], inst_sched["hour"], inst_sched["minute"], sched["hour"], sched["minute"])
depth_sched["hour"], depth_sched["minute"])
return scheduler return scheduler
@@ -830,7 +773,7 @@ _app_state_ref = None
def set_app_state(app_state) -> None: def set_app_state(app_state) -> None:
"""lifespan 注册 app.state 引用, 供 scheduled job 访问 depth_service 等单例。""" """lifespan 注册 app.state 引用, 供 scheduled job 访问单例。"""
global _app_state_ref global _app_state_ref
_app_state_ref = app_state _app_state_ref = app_state
+1 -69
View File
@@ -11,11 +11,10 @@ from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from app import __version__ from app import __version__
from app.api import analysis, auth as auth_api, backtest, data, ext_data, financials, indices, intraday, kline, market_recap, monitor_rules, alerts, overview, pipeline, rps, screener, settings as settings_api, signals, stock_analysis, strategy, watchlist from app.api import analysis, auth as auth_api, backtest, data, ext_data, financials, indices, kline, market_recap, alerts, overview, pipeline, rps, screener, settings as settings_api, signals, stock_analysis, strategy, watchlist
from app.api.routes import router as core_router from app.api.routes import router as core_router
from app.config import settings from app.config import settings
from app.jobs import daily_pipeline from app.jobs import daily_pipeline
from app.services.quote_service import QuoteService
from app.tickflow import client as tf_client from app.tickflow import client as tf_client
from app.tickflow.policy import detect_capabilities from app.tickflow.policy import detect_capabilities
from app.tickflow.repository import DataStore, KlineRepository from app.tickflow.repository import DataStore, KlineRepository
@@ -56,42 +55,14 @@ async def lifespan(app: FastAPI):
app.state.capabilities = capset app.state.capabilities = capset
logger.info("ready; %d capabilities active", len(capset.all())) logger.info("ready; %d capabilities active", len(capset.all()))
# 全局行情服务
qs = QuoteService()
app.state.quote_service = qs
qs.set_repo(repo)
qs.boot_check()
# QuoteService 需要访问 strategy_monitor 等单例
# 先创建 strategy_monitor,再注入 app.state
from app.strategy.monitor import StrategyMonitorService
strategy_monitor = StrategyMonitorService()
app.state.strategy_monitor = strategy_monitor
qs.set_app_state(app.state)
# 五档盘口 sealed 服务(真假涨停/跌停, 独立旁路线)
from app.services.depth_service import DepthService
depth_service = DepthService()
depth_service.set_repo(repo)
depth_service.set_app_state(app.state)
app.state.depth_service = depth_service
# 启动调度器(若 enriched 数据为空,首次启动可手动 POST /api/pipeline/run) # 启动调度器(若 enriched 数据为空,首次启动可手动 POST /api/pipeline/run)
try: try:
daily_pipeline.set_app_state(app.state) # 供 depth_finalize job 访问 depth_service
scheduler = daily_pipeline.start_scheduler(repo, capset) scheduler = daily_pipeline.start_scheduler(repo, capset)
app.state.scheduler = scheduler app.state.scheduler = scheduler
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
logger.warning("scheduler not started: %s", e) logger.warning("scheduler not started: %s", e)
app.state.scheduler = None app.state.scheduler = None
# depth sealed: 启动补跑(当天文件不存在) + 盘中轮询(有能力时)
try:
depth_service.boot_check()
depth_service.start_polling()
except Exception as e: # noqa: BLE001
logger.warning("depth_service init failed: %s", e)
# 扩展数据定时拉取 # 扩展数据定时拉取
from app.services.ext_pull import pull_scheduler from app.services.ext_pull import pull_scheduler
pull_scheduler.start(store.data_dir) pull_scheduler.start(store.data_dir)
@@ -114,7 +85,6 @@ async def lifespan(app: FastAPI):
# 策略引擎 # 策略引擎
from app.strategy.engine import StrategyEngine from app.strategy.engine import StrategyEngine
from app.strategy.monitor import StrategyMonitorService
from app.services.screener import ScreenerService from app.services.screener import ScreenerService
_screener_svc = ScreenerService(repo) _screener_svc = ScreenerService(repo)
@@ -131,36 +101,6 @@ async def lifespan(app: FastAPI):
app.state.strategy_engine = strategy_engine app.state.strategy_engine = strategy_engine
logger.info("strategy engine loaded: %d strategies", len(strategy_engine.list_strategies())) logger.info("strategy engine loaded: %d strategies", len(strategy_engine.list_strategies()))
# 通用监控规则引擎: 启动时 reload 规则到内存态 (修复重启后告警失效)
from app.strategy.monitor import MonitorRuleEngine
from app.strategy import monitor_rules as mr_store
from app.services import preferences
monitor_engine = MonitorRuleEngine()
monitor_engine.set_strategy_engine(strategy_engine)
monitor_engine.set_data_dir(store.data_dir)
# 复用 ScreenerService 的历史窗口加载器 (三级缓存, 启动预计算命中 ~0ms),
# 让声明 filter_history 的策略 (如反包) 也能在实时监控里跑选股 → 盘中触发通知。
monitor_engine.set_history_loader(_screener_svc._load_enriched_history)
# 自动迁移: 把旧 strategy_monitor_ids 同步为 type=strategy 规则 (统一到监控页)
try:
if preferences.get_strategy_monitor_enabled():
ids = preferences.get_strategy_monitor_ids()
if ids:
names = {s.id: s.name for s in strategy_engine.list_strategies()}
mr_store.migrate_strategy_monitors(store.data_dir, ids, names)
logger.info("strategy monitor migrated: %d strategies", len(ids))
except Exception as e: # noqa: BLE001
logger.warning("strategy monitor migration failed: %s", e)
try:
rules = mr_store.load_all(store.data_dir)
monitor_engine.set_rules(rules)
logger.info("monitor engine loaded: %d rules", monitor_engine.rule_count)
except Exception as e: # noqa: BLE001
logger.warning("monitor engine load failed: %s", e)
app.state.monitor_engine = monitor_engine
yield yield
if app.state.scheduler: if app.state.scheduler:
@@ -171,12 +111,6 @@ async def lifespan(app: FastAPI):
fsc = getattr(app.state, "financial_scheduler", None) fsc = getattr(app.state, "financial_scheduler", None)
if fsc: if fsc:
fsc.stop() fsc.stop()
qs = getattr(app.state, "quote_service", None)
if qs:
qs.stop()
dsvc = getattr(app.state, "depth_service", None)
if dsvc:
dsvc.stop_polling()
logger.info("shutdown") logger.info("shutdown")
@@ -251,7 +185,6 @@ app.include_router(kline.router)
app.include_router(watchlist.router) app.include_router(watchlist.router)
app.include_router(screener.router) app.include_router(screener.router)
app.include_router(backtest.router) app.include_router(backtest.router)
app.include_router(intraday.router)
app.include_router(indices.router) app.include_router(indices.router)
app.include_router(overview.router) app.include_router(overview.router)
app.include_router(analysis.router) app.include_router(analysis.router)
@@ -264,7 +197,6 @@ app.include_router(market_recap.router)
app.include_router(settings_api.router) app.include_router(settings_api.router)
app.include_router(strategy.router) app.include_router(strategy.router)
app.include_router(signals.router) app.include_router(signals.router)
app.include_router(monitor_rules.router)
app.include_router(alerts.router) app.include_router(alerts.router)
app.include_router(rps.router) app.include_router(rps.router)
@@ -285,8 +285,6 @@ async def analyze_rotation_stream(
repo, repo,
days: int = 12, days: int = 12,
focus: str = "", focus: str = "",
quote_service=None,
depth_service=None,
) -> AsyncIterator[str]: ) -> AsyncIterator[str]:
"""流式概念轮动分析: yield 出每个 NDJSON 事件。 """流式概念轮动分析: yield 出每个 NDJSON 事件。
@@ -294,7 +292,6 @@ async def analyze_rotation_stream(
repo: KlineRepository (必填) repo: KlineRepository (必填)
days: 分析最近 N 个交易日 (7-30) days: 分析最近 N 个交易日 (7-30)
focus: 用户追加的关注点 focus: 用户追加的关注点
quote_service / depth_service: 可选, 大盘背景装配依赖
""" """
from app.services.rps_rotation import build_rps_rotation from app.services.rps_rotation import build_rps_rotation
from app.services.market_overview_builder import build_market_overview from app.services.market_overview_builder import build_market_overview
@@ -316,7 +313,7 @@ async def analyze_rotation_stream(
# 3. 大盘背景 (失败不阻断, 降级为空) # 3. 大盘背景 (失败不阻断, 降级为空)
try: try:
overview = build_market_overview(repo, quote_service, depth_service) overview = build_market_overview(repo)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
logger.warning("rotation analyze: 大盘背景获取失败, 降级为空: %s", e) logger.warning("rotation analyze: 大盘背景获取失败, 降级为空: %s", e)
overview = {} overview = {}
-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)
@@ -3,11 +3,8 @@
本模块由 `app.api.overview._build_overview` 抽离而来,目的是让大盘复盘 本模块由 `app.api.overview._build_overview` 抽离而来,目的是让大盘复盘
等无 Request 的调用方(定时任务复盘服务)也能复用同一套聚合逻辑 等无 Request 的调用方(定时任务复盘服务)也能复用同一套聚合逻辑
行为与原 `_build_overview` 完全一致,仅把对 `request.app.state.{repo,
quote_service,depth_service}` 的依赖改为显式参数
公共入口: 公共入口:
build_market_overview(repo, quote_service, depth_service, as_of) build_market_overview(repo, as_of)
""" """
from __future__ import annotations from __future__ import annotations
@@ -81,24 +78,12 @@ def _score(value: float, low: float, high: float) -> int:
# ================================================================ # ================================================================
# 指数行情(实时 quote_service 优先,回退 kline_index_daily SQL) # 指数行情( kline_index_daily SQL 读取)
# ================================================================ # ================================================================
def _quote_status(quote_service) -> dict: def _index_quotes(repo, as_of: date | None = None) -> list[dict]:
qs = quote_service
if not qs:
return {"enabled": False, "running": False, "quote_age_ms": None, "is_trading_hours": False}
return qs.status()
def _index_quotes(repo, quote_service, as_of: date | None = None) -> list[dict]:
rows: list[dict] = [] rows: list[dict] = []
if quote_service and as_of is None: if repo:
df = quote_service.get_index_quotes(list(CORE_INDEX_SYMBOLS))
if not df.is_empty():
rows = df.to_dicts()
if not rows and repo:
placeholders = ", ".join("?" for _ in CORE_INDEX_SYMBOLS) placeholders = ", ".join("?" for _ in CORE_INDEX_SYMBOLS)
try: try:
db_rows = repo.execute_all( db_rows = repo.execute_all(
@@ -354,27 +339,21 @@ def _pct_band_rows(values: list[float]) -> list[dict]:
def build_market_overview( def build_market_overview(
repo, repo,
quote_service=None,
depth_service=None,
as_of: date | None = None, as_of: date | None = None,
) -> dict: ) -> dict:
"""装配市场总览(与原 overview._build_overview 行为一致)。 """装配市场总览(与原 overview._build_overview 行为一致)。
Args: Args:
repo: KlineRepository(必填) repo: KlineRepository(必填)
quote_service: QuoteService(可选;实时指数行情来源)
depth_service: DepthService(可选;五档封板修正)
as_of: 指定日期,None 则取最新有数据日 as_of: 指定日期,None 则取最新有数据日
""" """
svc = ScreenerService(repo) svc = ScreenerService(repo)
as_of = as_of or svc.latest_date() as_of = as_of or svc.latest_date()
status = _quote_status(quote_service) indices = _index_quotes(repo, as_of)
indices = _index_quotes(repo, quote_service, as_of)
if not as_of: if not as_of:
return { return {
"as_of": None, "as_of": None,
"quote_status": status,
"indices": indices, "indices": indices,
"breadth": {"total": 0, "up": 0, "down": 0, "flat": 0, "up_pct": 0, "down_pct": 0}, "breadth": {"total": 0, "up": 0, "down": 0, "flat": 0, "up_pct": 0, "down_pct": 0},
"amount": {"total": 0, "avg": 0}, "amount": {"total": 0, "avg": 0},
@@ -434,22 +413,6 @@ def build_market_overview(
limit_down = sum(1 for r in rows if bool(r.get("signal_limit_down"))) limit_down = sum(1 for r in rows if bool(r.get("signal_limit_down")))
max_boards = max([int(_finite(r.get("consecutive_limit_ups")) or 0) for r in rows], default=0) max_boards = max([int(_finite(r.get("consecutive_limit_ups")) or 0) for r in rows], default=0)
# 五档 sealed 修正: 假涨停/假跌停不计入(需 Pro+ depth5.batch 能力)
sealed_ready = False
fake_up = 0
fake_down = 0
if depth_service:
up_map = depth_service.get_sealed_map(as_of, is_down=False)
down_map = depth_service.get_sealed_map(as_of, is_down=True)
sealed_ready = bool(up_map or down_map) and depth_service.is_sealed_ready(as_of)
if up_map:
fake_up = sum(1 for v in up_map.values() if v.get("sealed") is False)
if down_map:
fake_down = sum(1 for v in down_map.values() if v.get("sealed") is False)
if sealed_ready:
limit_up = max(0, limit_up - fake_up)
limit_down = max(0, limit_down - fake_down)
seal_rate = limit_up / (limit_up + broken) * 100 if (limit_up + broken) > 0 else 0 seal_rate = limit_up / (limit_up + broken) * 100 if (limit_up + broken) > 0 else 0
def above_ma_count(ma_key: str) -> int: def above_ma_count(ma_key: str) -> int:
@@ -531,7 +494,6 @@ def build_market_overview(
return _json_safe({ return _json_safe({
"as_of": str(as_of), "as_of": str(as_of),
"quote_status": status,
"indices": indices, "indices": indices,
"breadth": { "breadth": {
"total": total, "total": total,
@@ -547,7 +509,7 @@ def build_market_overview(
}, },
"amount": {"total": total_amount, "avg": avg_amount}, "amount": {"total": total_amount, "avg": avg_amount},
"boards": boards, "boards": boards,
"limit": {"limit_up": limit_up, "broken": broken, "failed": 0, "limit_down": limit_down, "max_boards": max_boards, "seal_rate": seal_rate, "tiers": tiers, "sealed_ready": sealed_ready, "fake_up": fake_up, "fake_down": fake_down}, "limit": {"limit_up": limit_up, "broken": broken, "failed": 0, "limit_down": limit_down, "max_boards": max_boards, "seal_rate": seal_rate, "tiers": tiers},
"distribution": _pct_band_rows(pct_values), "distribution": _pct_band_rows(pct_values),
"trend": { "trend": {
"above_ma5": above_ma5, "above_ma5": above_ma5,
+2 -7
View File
@@ -252,8 +252,6 @@ def _recap_summary(overview: dict) -> str:
async def recap_market_stream( async def recap_market_stream(
repo, repo,
quote_service=None,
depth_service=None,
as_of: date | None = None, as_of: date | None = None,
focus: str = "", focus: str = "",
news: list[dict] | None = None, news: list[dict] | None = None,
@@ -262,13 +260,12 @@ async def recap_market_stream(
Args: Args:
repo: KlineRepository(必填) repo: KlineRepository(必填)
quote_service / depth_service: 可选,数据装配依赖
as_of: 复盘日期,None 取最新有数据日 as_of: 复盘日期,None 取最新有数据日
focus: 用户追加的复盘关注点 focus: 用户追加的复盘关注点
news: 预检索的新闻列表(P1 不传, None 走降级说明;P3 news_search 注入) news: 预检索的新闻列表(P1 不传, None 走降级说明;P3 news_search 注入)
""" """
# 1. 装配市场总览 # 1. 装配市场总览
overview = build_market_overview(repo, quote_service, depth_service, as_of) overview = build_market_overview(repo, as_of)
as_of_str = overview.get("as_of") as_of_str = overview.get("as_of")
if not as_of_str: if not as_of_str:
@@ -314,8 +311,6 @@ async def recap_market_stream(
async def recap_market_once( async def recap_market_once(
repo, repo,
quote_service=None,
depth_service=None,
as_of: date | None = None, as_of: date | None = None,
focus: str = "", focus: str = "",
news: list[dict] | None = None, news: list[dict] | None = None,
@@ -327,7 +322,7 @@ async def recap_market_once(
""" """
content_parts: list[str] = [] content_parts: list[str] = []
meta: dict = {"as_of": as_of.isoformat() if as_of else None} meta: dict = {"as_of": as_of.isoformat() if as_of else None}
async for evt in recap_market_stream(repo, quote_service, depth_service, as_of, focus, news): async for evt in recap_market_stream(repo, as_of, focus, news):
try: try:
obj = json.loads(evt) obj = json.loads(evt)
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
-239
View File
@@ -39,53 +39,12 @@ def save(updates: dict) -> dict:
return current return current
def get_realtime_quotes_enabled() -> bool:
return load().get("realtime_quotes_enabled", False)
def get_indices_nav_pinned() -> bool: def get_indices_nav_pinned() -> bool:
"""侧栏指数报价卡片是否固定显示。默认 True(常驻)。 """侧栏指数报价卡片是否固定显示。默认 True(常驻)。
关闭后卡片跟随实时行情开关仅实时开时显示""" 关闭后卡片跟随实时行情开关仅实时开时显示"""
return load().get("indices_nav_pinned", True) return load().get("indices_nav_pinned", True)
def get_realtime_quote_interval() -> float:
return load().get("realtime_quote_interval", 10.0)
def get_realtime_watchlist_symbols() -> list[str]:
"""Free 档自选实时监控标的:直接取自选页前 5 个。"""
try:
from app.services import watchlist
rows = watchlist.list_symbols()
except Exception as e: # noqa: BLE001
logger.warning("load watchlist for realtime failed: %s", e)
return []
out: list[str] = []
for row in rows:
symbol = str((row or {}).get("symbol") or "").strip().upper()
if symbol and symbol not in out:
out.append(symbol)
if len(out) >= 5:
break
return out
def set_realtime_watchlist_symbols(symbols: list[str]) -> list[str]: # noqa: ARG001
"""兼容旧接口: Free 实时标的现在由自选页前 5 个决定。"""
return get_realtime_watchlist_symbols()
def set_realtime_quote_interval(interval: float) -> float:
"""保存行情轮询间隔(不在此做 min/max 校验,由调用方按档位限制)。"""
current = load()
current["realtime_quote_interval"] = interval
_path().write_text(
json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8",
)
return interval
def get_minute_sync_enabled() -> bool: def get_minute_sync_enabled() -> bool:
return load().get("minute_sync_enabled", False) return load().get("minute_sync_enabled", False)
@@ -116,11 +75,6 @@ def get_minute_data_provider() -> str:
return provider if provider in _ALLOWED_DATA_PROVIDERS else "tickflow" return provider if provider in _ALLOWED_DATA_PROVIDERS else "tickflow"
def get_realtime_data_provider() -> str:
# 盘中实时现阶段仅支持 TickFlow。
return "tickflow"
# ===== 盘后管道拉取内容开关 (A股 / ETF / 指数 独立控制) ===== # ===== 盘后管道拉取内容开关 (A股 / ETF / 指数 独立控制) =====
def get_pipeline_pull_a_share() -> bool: def get_pipeline_pull_a_share() -> bool:
@@ -227,44 +181,6 @@ def set_index_daily_batch_size(size: int) -> int:
return size return size
# ── 五档盘口 sealed(真假涨停) 配置 ──────────────────────
def get_limit_ladder_monitor_enabled() -> bool:
"""连板梯队 5 档监控开关。关闭时 depth 不轮询(连板梯队降级显示)。"""
return load().get("limit_ladder_monitor_enabled", False)
def get_depth_polling_interval() -> float:
"""depth 盘中轮询间隔(秒)。默认 20(Pro/Expert 都适用)。"""
return float(load().get("depth_polling_interval", 20.0))
def set_depth_polling_interval(interval: float) -> float:
"""保存 depth 轮询间隔。套餐范围 clamp 由 depth_service 按档位做。"""
interval = max(1.0, min(600.0, float(interval)))
save({"depth_polling_interval": interval})
return interval
def get_depth_finalize_time() -> dict:
"""盘后 sealed 定版时间 {"hour": 15, "minute": 2}。范围 15:01~18:00。"""
d = load().get("depth_finalize_time", {"hour": 15, "minute": 2})
return {"hour": d.get("hour", 15), "minute": d.get("minute", 2)}
def set_depth_finalize_time(hour: int, minute: int) -> dict:
"""保存盘后 sealed 定版时间,强制范围 15:01~18:00。"""
h = max(0, min(23, hour))
m = max(0, min(59, minute))
# 下限 15:01, 上限 18:00
if h * 60 + m < 15 * 60 + 1:
h, m = 15, 1
if h * 60 + m > 18 * 60:
h, m = 18, 0
save({"depth_finalize_time": {"hour": h, "minute": m}})
return {"hour": h, "minute": m}
# 复盘推送可选渠道白名单 (微信等暂未实现, 不在白名单内, 前端仅作占位) # 复盘推送可选渠道白名单 (微信等暂未实现, 不在白名单内, 前端仅作占位)
# 多选: 不推送 = 空数组, 而非 'none' # 多选: 不推送 = 空数组, 而非 'none'
REVIEW_PUSH_CHANNELS = {"feishu"} REVIEW_PUSH_CHANNELS = {"feishu"}
@@ -333,86 +249,9 @@ def set_review_push_channels(channels: list[str]) -> list[str]:
# ===== 实时监控 =====
# 页面 SSE 刷新配置: { "watchlist": true, "monitor": true, ... }
# 可刷新的页面列表及其默认值
SSE_REFRESH_PAGES_DEFAULT = {
"watchlist": True,
"limit-ladder": False,
}
SIDEBAR_INDEX_SYMBOLS_DEFAULT = ["000001.SH", "399001.SZ", "399006.SZ", "000680.SH"] SIDEBAR_INDEX_SYMBOLS_DEFAULT = ["000001.SH", "399001.SZ", "399006.SZ", "000680.SH"]
# ===== 盘中实时行情范围 (独立于盘后管道范围) =====
def get_realtime_pull_stock() -> bool:
return load().get("realtime_pull_stock", True)
def get_realtime_pull_etf() -> bool:
# 老用户兼容: ETF 实时默认关闭,避免升级后请求量/写盘量突然增加。
return load().get("realtime_pull_etf", False)
def get_realtime_pull_index() -> bool:
return load().get("realtime_pull_index", True)
def get_realtime_index_mode() -> str:
mode = str(load().get("realtime_index_mode", "core") or "core").lower()
return mode if mode in {"core", "all"} else "core"
def get_realtime_index_symbols() -> list[str]:
stored = load().get("realtime_index_symbols", SIDEBAR_INDEX_SYMBOLS_DEFAULT)
if isinstance(stored, str):
import re
stored = [s.strip() for s in re.split(r"[,\s]+", stored) if s.strip()]
return [str(s) for s in stored if str(s).strip()]
def set_realtime_quote_scope(cfg: dict) -> dict:
updates = {}
for key in ("realtime_pull_stock", "realtime_pull_etf", "realtime_pull_index"):
if key in cfg and cfg[key] is not None:
updates[key] = bool(cfg[key])
if "realtime_index_mode" in cfg and cfg["realtime_index_mode"] in {"core", "all"}:
updates["realtime_index_mode"] = cfg["realtime_index_mode"]
if "realtime_index_symbols" in cfg and cfg["realtime_index_symbols"] is not None:
updates["realtime_index_symbols"] = cfg["realtime_index_symbols"]
if updates:
save(updates)
return get_realtime_quote_scope()
def get_realtime_quote_scope() -> dict:
return {
"realtime_pull_stock": get_realtime_pull_stock(),
"realtime_pull_etf": get_realtime_pull_etf(),
"realtime_pull_index": get_realtime_pull_index(),
"realtime_index_mode": get_realtime_index_mode(),
"realtime_index_symbols": get_realtime_index_symbols(),
}
def get_sse_refresh_pages() -> dict[str, bool]:
"""返回每个页面的 SSE 刷新开关。"""
stored = load().get("sse_refresh_pages", {})
# 合并默认值 (新增页面自动出现)
result = dict(SSE_REFRESH_PAGES_DEFAULT)
result.update(stored)
return result
def set_sse_refresh_pages(pages: dict[str, bool]) -> dict[str, bool]:
"""保存页面 SSE 刷新配置。"""
save({"sse_refresh_pages": pages})
return get_sse_refresh_pages()
def get_sidebar_index_symbols() -> list[str]: def get_sidebar_index_symbols() -> list[str]:
"""返回左侧菜单显示的指数代码。""" """返回左侧菜单显示的指数代码。"""
stored = load().get("sidebar_index_symbols", SIDEBAR_INDEX_SYMBOLS_DEFAULT) stored = load().get("sidebar_index_symbols", SIDEBAR_INDEX_SYMBOLS_DEFAULT)
@@ -420,22 +259,6 @@ def get_sidebar_index_symbols() -> list[str]:
return [s for s in stored if s in allowed] return [s for s in stored if s in allowed]
def get_strategy_monitor_enabled() -> bool:
"""策略告警评估总开关。"""
return load().get("strategy_monitor_enabled", False)
def get_system_notify_enabled() -> bool:
"""系统通知开关 — 开启后监控告警同时推送到操作系统通知中心。"""
return load().get("system_notify_enabled", False)
def set_system_notify_enabled(enabled: bool) -> bool:
"""保存系统通知开关。"""
save({"system_notify_enabled": bool(enabled)})
return bool(enabled)
def get_feishu_webhook_url() -> str: def get_feishu_webhook_url() -> str:
"""飞书自定义机器人 Webhook 地址 — 全局共用一处, 所有启用推送的规则都推到这一个群。""" """飞书自定义机器人 Webhook 地址 — 全局共用一处, 所有启用推送的规则都推到这一个群。"""
return load().get("feishu_webhook_url", "") return load().get("feishu_webhook_url", "")
@@ -446,73 +269,11 @@ def get_feishu_webhook_secret() -> str:
return load().get("feishu_webhook_secret", "") return load().get("feishu_webhook_secret", "")
def set_feishu_webhook_url(url: str) -> str:
"""保存飞书 Webhook 地址。传入空串表示清空配置。"""
save({"feishu_webhook_url": str(url or "").strip()})
return get_feishu_webhook_url()
def set_feishu_webhook_secret(secret: str) -> str:
"""保存飞书签名密钥。传入空串表示不验签。"""
save({"feishu_webhook_secret": str(secret or "").strip()})
return get_feishu_webhook_secret()
def get_webhook_enabled_default() -> bool:
"""新建监控规则时是否默认勾选「飞书推送」。
数据模型当前只有一个 webhook_enabled 布尔 (即飞书), QMT/ptrade 待定
此默认值供规则编辑器新建规则时预填, 单条规则仍可独立修改
"""
return load().get("webhook_enabled_default", False)
def set_webhook_enabled_default(enabled: bool) -> bool:
"""保存飞书推送默认勾选态。"""
save({"webhook_enabled_default": bool(enabled)})
return get_webhook_enabled_default()
def get_screener_auto_run() -> bool: def get_screener_auto_run() -> bool:
"""选股页进入时是否自动运行所有策略 (获取命中数)。默认开。""" """选股页进入时是否自动运行所有策略 (获取命中数)。默认开。"""
return load().get("screener_auto_run", True) return load().get("screener_auto_run", True)
def get_strategy_monitor_ids() -> list[str]:
"""返回监控池中的策略 ID。"""
return load().get("strategy_monitor_ids", [])
def set_realtime_monitor_config(cfg: dict) -> dict:
"""批量更新实时监控配置。"""
updates = {}
if "sse_refresh_pages" in cfg:
updates["sse_refresh_pages"] = cfg["sse_refresh_pages"]
if "strategy_monitor_enabled" in cfg:
updates["strategy_monitor_enabled"] = cfg["strategy_monitor_enabled"]
if "strategy_monitor_ids" in cfg:
updates["strategy_monitor_ids"] = cfg["strategy_monitor_ids"]
if "sidebar_index_symbols" in cfg:
allowed = set(SIDEBAR_INDEX_SYMBOLS_DEFAULT)
updates["sidebar_index_symbols"] = [s for s in cfg["sidebar_index_symbols"] if s in allowed]
if "screener_auto_run" in cfg:
updates["screener_auto_run"] = bool(cfg["screener_auto_run"])
if updates:
save(updates)
return get_realtime_monitor_config()
def get_realtime_monitor_config() -> dict:
"""返回完整的实时监控配置。"""
return {
"sse_refresh_pages": get_sse_refresh_pages(),
"strategy_monitor_enabled": get_strategy_monitor_enabled(),
"strategy_monitor_ids": get_strategy_monitor_ids(),
"sidebar_index_symbols": get_sidebar_index_symbols(),
"screener_auto_run": get_screener_auto_run(),
}
def get_nav_order() -> list[str]: def get_nav_order() -> list[str]:
"""返回左侧菜单的自定义排序(内置页面 path + 扩展分析菜单 id)。""" """返回左侧菜单的自定义排序(内置页面 path + 扩展分析菜单 id)。"""
return load().get("nav_order", []) return load().get("nav_order", [])
-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)
-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" INTRADAY_BATCH = "intraday.batch"
DEPTH5 = "depth5" DEPTH5 = "depth5"
DEPTH5_BATCH = "depth5.batch" DEPTH5_BATCH = "depth5.batch"
WEBSOCKET = "websocket"
FINANCIAL = "financial" FINANCIAL = "financial"
ADJ_FACTOR = "adj_factor" ADJ_FACTOR = "adj_factor"
+1 -18
View File
@@ -18,7 +18,6 @@ from app import secrets_store
_sync_client: TickFlow | None = None _sync_client: TickFlow | None = None
_async_client: AsyncTickFlow | None = None _async_client: AsyncTickFlow | None = None
_paid_realtime_client: TickFlow | None = None
# ===== 服务器归属判定 ===== # ===== 服务器归属判定 =====
@@ -72,27 +71,11 @@ def get_async_client() -> AsyncTickFlow:
return _async_client return _async_client
def get_paid_realtime_client() -> TickFlow | None:
"""实时行情专用付费服务器客户端。
none/free 的历史日K仍走 get_client() free-api实时行情全部走付费服务器
Free 档如果有有效 key也使用这里的 paid endpoint 调按标的实时接口
"""
global _paid_realtime_client
key = secrets_store.get_tickflow_key()
if not key:
return None
if _paid_realtime_client is None:
_paid_realtime_client = TickFlow(api_key=key, base_url=_base_url())
return _paid_realtime_client
def reset_clients() -> None: def reset_clients() -> None:
"""Key 变化后调用 — 让下一次 get_client() 拿新实例。""" """Key 变化后调用 — 让下一次 get_client() 拿新实例。"""
global _sync_client, _async_client, _paid_realtime_client global _sync_client, _async_client
_sync_client = None _sync_client = None
_async_client = None _async_client = None
_paid_realtime_client = None
def current_mode() -> str: def current_mode() -> str:
+1 -8
View File
@@ -236,12 +236,6 @@ def _probe_real(tiers: dict) -> tuple[CapabilitySet, list[str]]:
defaults(Cap.ADJ_FACTOR)) defaults(Cap.ADJ_FACTOR))
# websocket 不在探测期试连接(成本太高且阻塞),按档位默认推断 # websocket 不在探测期试连接(成本太高且阻塞),按档位默认推断
# 若 expert 的其他 cap 都通,则推断 websocket 也可用
if (Cap.FINANCIAL in available and Cap.INTRADAY_BATCH in available):
available[Cap.WEBSOCKET] = CapabilityLimits(
subscribe=defaults(Cap.WEBSOCKET).get("subscribe", 100),
)
log.append("✓ websocket (inferred from expert tier)")
return CapabilitySet(available), log return CapabilitySet(available), log
@@ -297,7 +291,7 @@ def detect_capabilities(force: bool = False) -> CapabilitySet:
# 拥有**任意一个**即认作该档及以上。自上而下匹配。 # 拥有**任意一个**即认作该档及以上。自上而下匹配。
# 这套设计的好处:单个 capability 探测的 transient 失败不会把整体档位"误降"。 # 这套设计的好处:单个 capability 探测的 transient 失败不会把整体档位"误降"。
TIER_SIGNATURES: dict[str, set[Cap]] = { TIER_SIGNATURES: dict[str, set[Cap]] = {
"expert": {Cap.FINANCIAL, Cap.INTRADAY_BATCH, Cap.WEBSOCKET}, "expert": {Cap.FINANCIAL, Cap.INTRADAY_BATCH},
"pro": {Cap.KLINE_MINUTE_BATCH, Cap.KLINE_MINUTE_BY_SYMBOL, "pro": {Cap.KLINE_MINUTE_BATCH, Cap.KLINE_MINUTE_BY_SYMBOL,
Cap.INTRADAY, Cap.DEPTH5, Cap.DEPTH5_BATCH}, Cap.INTRADAY, Cap.DEPTH5, Cap.DEPTH5_BATCH},
"starter": {Cap.QUOTE_BATCH, Cap.KLINE_DAILY_BATCH, "starter": {Cap.QUOTE_BATCH, Cap.KLINE_DAILY_BATCH,
@@ -359,7 +353,6 @@ _CAP_ALIASES: dict[Cap, str] = {
Cap.INTRADAY_BATCH: "批量分时", Cap.INTRADAY_BATCH: "批量分时",
Cap.DEPTH5: "五档", Cap.DEPTH5: "五档",
Cap.DEPTH5_BATCH: "批量五档", Cap.DEPTH5_BATCH: "批量五档",
Cap.WEBSOCKET: "WS",
Cap.FINANCIAL: "财务", Cap.FINANCIAL: "财务",
Cap.ADJ_FACTOR: "复权", Cap.ADJ_FACTOR: "复权",
Cap.QUOTE_BATCH: "批量行情", Cap.QUOTE_BATCH: "批量行情",
+3 -207
View File
@@ -1,8 +1,7 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { NavLink, Outlet, useNavigate } from 'react-router-dom' import { NavLink, Outlet } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { useQuoteStream } from '@/lib/useQuoteStream'
import { ToastContainer } from '@/components/Toast' import { ToastContainer } from '@/components/Toast'
import { AlertToastContainer } from '@/components/AlertToast' import { AlertToastContainer } from '@/components/AlertToast'
import { AiAnalysisHost } from '@/components/financials/AiAnalysisHost' import { AiAnalysisHost } from '@/components/financials/AiAnalysisHost'
@@ -13,14 +12,9 @@ import {
useCapabilities, useCapabilities,
useSettings, useSettings,
usePreferences, usePreferences,
useQuoteStatus,
useVersion, useVersion,
} from '@/lib/useSharedQueries' } from '@/lib/useSharedQueries'
import {
useToggleRealtimeQuotes,
} from '@/lib/useSharedMutations'
import { QK } from '@/lib/queryKeys' import { QK } from '@/lib/queryKeys'
import { tierRank } from '@/lib/capability-labels'
import { import {
Star, Star,
ScanSearch, ScanSearch,
@@ -42,26 +36,14 @@ import {
RadioTower, RadioTower,
CheckCircle2, CheckCircle2,
BookOpenCheck, BookOpenCheck,
ExternalLink,
X,
} from 'lucide-react' } from 'lucide-react'
import { Logo } from './Logo' import { Logo } from './Logo'
import { api, type IndexQuote } from '@/lib/api' import { api } from '@/lib/api'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
import { setCurrentTotal as setAlertTotal, useUnreadAlerts } from '@/lib/monitorBadge' import { setCurrentTotal as setAlertTotal, useUnreadAlerts } from '@/lib/monitorBadge'
// 品牌色 — 只用于 logo / brand 区域,不影响功能语义色 // 品牌色 — 只用于 logo / brand 区域,不影响功能语义色
const BRAND = '#8B5CF6' const BRAND = '#8B5CF6'
const TICKFLOW_REGISTER_URL = 'https://tickflow.org/auth/register?ref=V3KDKGXPEA'
const CORE_INDEXES = [
{ symbol: '000001.SH', name: '上证指数' },
{ symbol: '399001.SZ', name: '深证成指' },
{ symbol: '399006.SZ', name: '创业板指' },
{ symbol: '000680.SH', name: '科创综指' },
] as const
type CoreIndex = (typeof CORE_INDEXES)[number]
const nav = [ const nav = [
{ to: '/', label: '看板', icon: LayoutDashboard }, { to: '/', label: '看板', icon: LayoutDashboard },
@@ -80,23 +62,6 @@ const nav = [
{ to: '/data', label: '数据', icon: Database }, { to: '/data', label: '数据', icon: Database },
] as const ] as const
function fmtIndexValue(v: number | null | undefined) {
if (v == null || Number.isNaN(Number(v))) return '--'
return Number(v).toFixed(2)
}
function fmtIndexPct(v: number | null | undefined) {
if (v == null || Number.isNaN(Number(v))) return '--'
return `${Number(v) >= 0 ? '+' : ''}${Number(v).toFixed(2)}%`
}
function indexPctClass(v: number | null | undefined) {
if (v == null || Number.isNaN(Number(v))) return 'text-muted'
const n = Number(v)
if (n === 0) return 'text-foreground'
return n > 0 ? 'text-bull' : 'text-bear'
}
/** 监控中心未读徽标 — 仅在非监控页且有未读时显示。 */ /** 监控中心未读徽标 — 仅在非监控页且有未读时显示。 */
function MonitorBadge({ active }: { active: boolean }) { function MonitorBadge({ active }: { active: boolean }) {
const unread = useUnreadAlerts() const unread = useUnreadAlerts()
@@ -112,36 +77,6 @@ function MonitorBadge({ active }: { active: boolean }) {
) )
} }
function SidebarIndexQuotes({ rows, items }: { rows: IndexQuote[] | undefined; items: CoreIndex[] }) {
if (items.length === 0) return null
const quoteBySymbol = new Map((rows ?? []).map(q => [q.symbol, q]))
return (
<div className="mt-2 grid grid-cols-2 gap-1.5">
{items.map(item => {
const q = quoteBySymbol.get(item.symbol)
const value = q?.last_price ?? q?.close
const pct = q?.change_pct
return (
<NavLink
key={item.symbol}
to={`/indices?symbol=${encodeURIComponent(item.symbol)}`}
className="block rounded bg-elevated/60 px-2 py-1.5 transition-colors hover:bg-elevated"
title={`${item.name} ${item.symbol}`}
>
<div className="flex items-center justify-between gap-1">
<span className="text-[10px] text-secondary">{item.name}</span>
<span className={`text-[10px] font-mono ${indexPctClass(pct)}`}>{fmtIndexPct(pct)}</span>
</div>
<div className="mt-0.5 truncate font-mono text-[10px] text-foreground/80">
{fmtIndexValue(value)}
</div>
</NavLink>
)
})}
</div>
)
}
// ===== 档位卡片 ===== // ===== 档位卡片 =====
function TierBadge({ label, hasKey }: { label: string; hasKey?: boolean }) { function TierBadge({ label, hasKey }: { label: string; hasKey?: boolean }) {
const base = label.split(' ')[0].split('+')[0].toLowerCase() const base = label.split(' ')[0].split('+')[0].toLowerCase()
@@ -262,8 +197,6 @@ export function Layout() {
const { data: settingsState } = useSettings() const { data: settingsState } = useSettings()
const { data: versionData } = useVersion() const { data: versionData } = useVersion()
const { data: prefs } = usePreferences() const { data: prefs } = usePreferences()
// poll=true: 全局唯一开启条件轮询 (非交易时段 60s 兜底, 交易时段靠 SSE)
const { data: quoteStatus } = useQuoteStatus({ poll: true })
const { data: analysisMenus } = useQuery({ const { data: analysisMenus } = useQuery({
queryKey: QK.analysisMenus, queryKey: QK.analysisMenus,
queryFn: api.analysisMenus, queryFn: api.analysisMenus,
@@ -293,34 +226,7 @@ export function Layout() {
prevSyncingRef.current = isDataSyncing prevSyncingRef.current = isDataSyncing
}, [isDataSyncing]) }, [isDataSyncing])
const qc = useQueryClient()
const navigate = useNavigate()
const version = versionData?.version const version = versionData?.version
const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false
// Free 档监控限制提示: 可手动关闭, 不持久化 (刷新后恢复显示)
const [dismissFreeHint, setDismissFreeHint] = useState(false)
const indicesPinned = prefs?.indices_nav_pinned ?? true
const sidebarIndexSymbols = prefs?.sidebar_index_symbols ?? CORE_INDEXES.map(p => p.symbol)
const sidebarIndexes = CORE_INDEXES.filter(item => sidebarIndexSymbols.includes(item.symbol))
// 卡片数据:固定显示时也拉取(即使实时行情关闭)
const showSidebarQuotes = indicesPinned || realtimeEnabled
const { data: sidebarIndexQuotes } = useQuery({
queryKey: [...QK.indexQuotes, 'sidebar', sidebarIndexSymbols.join(',')] as const,
queryFn: () => api.indexQuotes(sidebarIndexes.map(p => p.symbol)),
enabled: showSidebarQuotes && sidebarIndexes.length > 0,
placeholderData: (prev) => prev,
})
// SSE: 行情更新时自动刷新相关 queries + 告警通知
useQuoteStream(realtimeEnabled, prefs?.sse_refresh_pages)
const toggleQuote = useToggleRealtimeQuotes()
const isRunning = quoteStatus?.running ?? false
const isTrading = quoteStatus?.is_trading_hours ?? false
const tier = tierRank(caps?.label ?? '')
const isNoneTier = tier < 0
const isWatchlistMode = tier === 0
const realtimeModeLabel = isWatchlistMode ? '自选股' : '全市场'
// 轮询触发记录总数 → 更新监控中心徽标 (每 15 秒) // 轮询触发记录总数 → 更新监控中心徽标 (每 15 秒)
const alertsTotalQuery = useQuery({ const alertsTotalQuery = useQuery({
@@ -358,27 +264,6 @@ export function Layout() {
const hiddenIds = new Set(prefs?.nav_hidden ?? []) const hiddenIds = new Set(prefs?.nav_hidden ?? [])
const visibleNavItems = navItems.filter(n => !hiddenIds.has(n.to) && !hiddenIds.has(n.to.replace(/^\/analysis\//, ''))) const visibleNavItems = navItems.filter(n => !hiddenIds.has(n.to) && !hiddenIds.has(n.to.replace(/^\/analysis\//, '')))
const handleToggle = async (enabled: boolean) => {
// 开启时重新校验档位
if (enabled) {
const fresh = await qc.fetchQuery({
queryKey: QK.capabilities,
queryFn: api.capabilities,
})
const freshTier = tierRank(fresh.label ?? '')
if (freshTier < 0) return
if (freshTier === 0 && (prefs?.realtime_watchlist_symbols?.length ?? 0) === 0) {
navigate('/watchlist')
return
}
}
await toggleQuote.mutateAsync(enabled)
// 仅在交易时段立即获取一次行情
if (enabled && isTrading) {
api.intradayRefresh().catch(() => {})
}
}
return ( return (
<div className="h-screen grid grid-cols-[14rem_1fr] bg-base text-foreground overflow-hidden"> <div className="h-screen grid grid-cols-[14rem_1fr] bg-base text-foreground overflow-hidden">
<aside className="border-r border-border bg-surface flex flex-col h-full min-h-0 overflow-hidden"> <aside className="border-r border-border bg-surface flex flex-col h-full min-h-0 overflow-hidden">
@@ -457,95 +342,6 @@ export function Layout() {
))} ))}
</nav> </nav>
{/* 全局行情开关 */}
<div className="border-t border-border px-3 py-2.5 shrink-0">
{isNoneTier ? (
<div>
<div className="flex items-center justify-between">
<span className="text-xs text-secondary truncate"></span>
<span className="text-[10px] text-accent/70 font-medium bg-accent/10 px-1.5 py-0.5 rounded">
Free+
</span>
</div>
<div className="mt-1.5 text-[10px] leading-snug text-muted">
<a
href={TICKFLOW_REGISTER_URL}
target="_blank"
rel="noreferrer"
className="mx-1 inline-flex items-baseline gap-0.5 text-accent/80 hover:text-accent hover:underline"
>
TickFlow
<ExternalLink className="h-2.5 w-2.5 self-center" />
</a>
</div>
</div>
) : (
/* Starter+ — 开关 + 跳转设置 */
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<span className={`inline-block h-1.5 w-1.5 rounded-full shrink-0 ${
realtimeEnabled && isRunning && isTrading
? 'bg-accent animate-pulse'
: realtimeEnabled
? 'bg-warning/60'
: 'bg-muted'
}`} />
<span className="text-xs text-secondary truncate">
· {realtimeModeLabel}
</span>
<button
onClick={() => navigate('/settings?tab=monitoring')}
className="text-secondary hover:text-foreground transition-colors shrink-0"
title="实时监控设置"
>
<Settings className="h-3 w-3" />
</button>
</div>
<button
onClick={() => handleToggle(!realtimeEnabled)}
disabled={toggleQuote.isPending}
className={`relative inline-flex h-4 w-7 items-center rounded-full shrink-0 transition-colors duration-200 ${
realtimeEnabled
? 'bg-accent shadow-[0_0_6px_rgba(59,130,246,0.3)]'
: 'bg-elevated'
} ${toggleQuote.isPending ? 'opacity-50' : 'cursor-pointer'}`}
>
<span className={`inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform duration-200 ${
realtimeEnabled ? 'translate-x-[14px]' : 'translate-x-0.5'
}`} />
</button>
</div>
)}
{/* 状态提示 */}
{realtimeEnabled && !isNoneTier && (
<div className="mt-1.5 text-[10px] leading-snug space-y-0.5">
{isWatchlistMode && !dismissFreeHint && (
<div className="flex items-start gap-1 text-amber-400/80">
<span className="flex-1"> 5 Starter+</span>
<button
onClick={() => setDismissFreeHint(true)}
className="text-amber-400/50 hover:text-amber-400 shrink-0 transition-colors"
title="关闭提示"
>
<X className="h-2.5 w-2.5" />
</button>
</div>
)}
{isRunning && isTrading ? (
<div className="text-accent"></div>
) : realtimeEnabled && !isTrading ? (
<div className="text-warning/70"></div>
) : null}
</div>
)}
{showSidebarQuotes && !isWatchlistMode && !isNoneTier && (
<SidebarIndexQuotes rows={sidebarIndexQuotes?.rows} items={sidebarIndexes} />
)}
</div>
<div className="border-t border-border px-2 py-3 space-y-0.5 shrink-0"> <div className="border-t border-border px-2 py-3 space-y-0.5 shrink-0">
<NavLink <NavLink
to="/settings" to="/settings"
@@ -1,143 +0,0 @@
import { useState, useEffect } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { usePreferences, useCapabilities } from '@/lib/useSharedQueries'
import { isExpertOrAbove } from '@/lib/capability-labels'
/**
* sealed() (, , Card )
*
* - 轮询间隔: Pro 10~120s / Expert 3~300s
* - 盘后定版时间: 15:01~18:00, 15:02
* - disabled ()
*/
// 注: 文件名保留 DepthConfigCard.tsx, 导出 DepthConfigContent(纯内容无外框)
export function DepthConfigContent({ disabled }: { disabled?: boolean }) {
const qc = useQueryClient()
const prefs = usePreferences()
const caps = useCapabilities()
const hasDepth = !!caps.data?.capabilities?.['depth5.batch']
const tierLabel = caps.data?.label ?? ''
const range = isExpertOrAbove(tierLabel) ? { lo: 3, hi: 300 } : { lo: 10, hi: 120 }
const interval = prefs.data?.depth_polling_interval ?? 20
const finalizeTime = prefs.data?.depth_finalize_time ?? { hour: 15, minute: 2 }
const [intervalInput, setIntervalInput] = useState(String(Math.round(interval)))
const [finalizeHour, setFinalizeHour] = useState(String(finalizeTime.hour))
const [finalizeMinute, setFinalizeMinute] = useState(String(finalizeTime.minute))
useEffect(() => { setIntervalInput(String(Math.round(interval))) }, [interval])
useEffect(() => {
setFinalizeHour(String(finalizeTime.hour))
setFinalizeMinute(String(finalizeTime.minute))
}, [finalizeTime.hour, finalizeTime.minute])
const saveInterval = useMutation({
mutationFn: (v: number) => api.updateDepthPollingInterval(v),
onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }),
})
const saveFinalize = useMutation({
mutationFn: ({ hour, minute }: { hour: number; minute: number }) =>
api.updateDepthFinalizeTime(hour, minute),
onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }),
})
// 无能力: 显示升级提示
if (!hasDepth) {
return (
<p className="text-xs text-muted leading-relaxed">
, <span className="text-accent">Pro </span>
()()
</p>
)
}
const inputCls = `w-16 h-7 bg-elevated border border-border rounded text-xs text-center px-1 focus:outline-none focus:border-accent/50 ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`
return (
<div className="space-y-3">
{/* 盘中轮询间隔 */}
<div className="flex items-center justify-between gap-2">
<div className={disabled ? 'opacity-50' : ''}>
<div className="text-xs text-secondary"></div>
<div className="text-[10px] text-muted"> {range.lo}~{range.hi} · </div>
</div>
<div className="flex items-center gap-1">
<input
type="number"
min={range.lo}
max={range.hi}
value={intervalInput}
disabled={disabled}
onChange={e => setIntervalInput(e.target.value)}
onBlur={() => {
if (disabled) return
let v = Number(intervalInput)
if (!Number.isFinite(v)) v = range.lo
v = Math.max(range.lo, Math.min(range.hi, v))
saveInterval.mutate(v)
}}
className={inputCls}
/>
<span className="text-xs text-muted"></span>
</div>
</div>
{/* 盘后定版时间 */}
<div className="flex items-center justify-between gap-2">
<div className={disabled ? 'opacity-50' : ''}>
<div className="text-xs text-secondary"></div>
<div className="text-[10px] text-muted"> 15:01~18:00 · </div>
</div>
<div className="flex items-center gap-1">
<input
type="number"
min={15}
max={18}
value={finalizeHour}
disabled={disabled}
onChange={e => setFinalizeHour(e.target.value)}
onBlur={() => {
if (disabled) return
let h = Number(finalizeHour)
if (!Number.isFinite(h)) h = 15
h = Math.max(15, Math.min(18, h))
let m = Number(finalizeMinute)
if (!Number.isFinite(m)) m = 2
m = Math.max(0, Math.min(59, m))
if (h * 60 + m < 15 * 60 + 1) { h = 15; m = 1 }
if (h * 60 + m > 18 * 60) { h = 18; m = 0 }
saveFinalize.mutate({ hour: h, minute: m })
}}
className={`w-12 h-7 bg-elevated border border-border rounded text-xs text-center px-1 focus:outline-none focus:border-accent/50 ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
/>
<span className="text-xs text-muted">:</span>
<input
type="number"
min={0}
max={59}
value={finalizeMinute}
disabled={disabled}
onChange={e => setFinalizeMinute(e.target.value)}
onBlur={() => {
if (disabled) return
let h = Number(finalizeHour)
if (!Number.isFinite(h)) h = 15
h = Math.max(15, Math.min(18, h))
let m = Number(finalizeMinute)
if (!Number.isFinite(m)) m = 2
m = Math.max(0, Math.min(59, m))
if (h * 60 + m < 15 * 60 + 1) { h = 15; m = 1 }
if (h * 60 + m > 18 * 60) { h = 18; m = 0 }
saveFinalize.mutate({ hour: h, minute: m })
}}
className={`w-12 h-7 bg-elevated border border-border rounded text-xs text-center px-1 focus:outline-none focus:border-accent/50 ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
/>
</div>
</div>
</div>
)
}
@@ -1,165 +0,0 @@
import { useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { Activity, Settings } from 'lucide-react'
import { Skeleton } from './Skeleton'
export function QuoteConfigCard({ enabled, running, isTrading, lastFetchMs, intervalS, intervalMin, intervalMax, loading, onToggle, toggling, showIntervalEdit, onShowIntervalEdit, onIntervalChange }: {
enabled: boolean
running: boolean
isTrading: boolean
lastFetchMs: number | null
intervalS: number
intervalMin: number
intervalMax: number
loading: boolean
onToggle: (enabled: boolean) => void
toggling: boolean
showIntervalEdit: boolean
onShowIntervalEdit: () => void
onIntervalChange: (v: number) => void
}) {
const statusColor = running && isTrading
? 'bg-accent shadow-[0_0_6px_rgba(61,214,140,0.5)]'
: enabled && running
? 'bg-warning/60'
: 'bg-muted'
const statusText = !enabled
? '已关闭'
: !isTrading
? '非交易时段'
: running
? '行情运行中'
: '已停止'
const lastFetchTime = lastFetchMs
? new Date(lastFetchMs).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
: null
return (
<div className="rounded-card border border-border bg-surface p-4 relative">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-secondary" />
<h3 className="text-sm font-medium text-foreground"></h3>
</div>
<button
onClick={() => onToggle(!enabled)}
disabled={toggling}
className={`relative inline-flex h-4 w-7 items-center rounded-full shrink-0 transition-colors duration-200 ${
enabled
? 'bg-accent shadow-[0_0_6px_rgba(59,130,246,0.3)]'
: 'bg-elevated'
} ${toggling ? 'opacity-50' : 'cursor-pointer'}`}
>
<span className={`inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform duration-200 ${
enabled ? 'translate-x-[14px]' : 'translate-x-0.5'
}`} />
</button>
</div>
{loading ? (
<div className="space-y-2">
<div className="flex items-center justify-between"><Skeleton w="w-8" /><Skeleton w="w-16" /></div>
<div className="flex items-center justify-between"><Skeleton w="w-12" /><Skeleton w="w-20" /></div>
<div className="flex items-center justify-between"><Skeleton w="w-10" /><Skeleton w="w-14" /></div>
<div className="flex items-center justify-between"><Skeleton w="w-12" /><Skeleton w="w-12" /></div>
</div>
) : (
<div className="space-y-2">
<div className="flex items-center justify-between text-[11px]">
<span className="text-muted"></span>
<div className="flex items-center gap-1.5">
<span className={`inline-block h-1.5 w-1.5 rounded-full ${statusColor} ${running && isTrading ? 'animate-pulse' : ''}`} />
<span className="font-mono text-secondary">{statusText}</span>
</div>
</div>
<div className="flex items-center justify-between text-[11px]">
<span className="text-muted"></span>
<span className={`font-mono ${isTrading ? 'text-accent' : 'text-muted'}`}>{isTrading ? '交易中' : '休市'}</span>
</div>
<div className="flex items-center justify-between text-[11px]">
<div className="flex items-center gap-1">
<span className="text-muted"></span>
<button
onClick={() => onShowIntervalEdit()}
className={`p-0.5 rounded hover:bg-elevated transition-colors ${showIntervalEdit ? 'text-accent' : 'text-secondary'}`}
title="设置轮询间隔"
>
<Settings className="h-3 w-3" />
</button>
</div>
<span className="font-mono text-secondary">{intervalS}s</span>
</div>
<div className="flex items-center justify-between text-[11px]">
<span className="text-muted"></span>
<span className="font-mono text-secondary">{lastFetchTime ?? '—'}</span>
</div>
</div>
)}
<AnimatePresence>
{showIntervalEdit && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="overflow-hidden"
>
<IntervalEditor
min={intervalMin}
max={intervalMax}
value={intervalS}
onChange={onIntervalChange}
/>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
function IntervalEditor({ min, max, value, onChange }: {
min: number; max: number; value: number; onChange: (v: number) => void
}) {
const [draft, setDraft] = useState(value)
const clamped = Math.max(min, Math.min(max, draft))
const step = min < 1 ? 0.1 : min < 3 ? 0.5 : 1
const presets = min <= 3 ? [3, 5, 10, 30, 60] : [5, 10, 15, 30, 60]
return (
<div className="mt-2 pt-2 border-t border-border/50">
<div className="text-[10px] text-muted mb-1.5">
<span className="text-muted/60">({min}s ~ {max}s)</span>
</div>
<div className="flex flex-wrap gap-1 mb-2">
{presets.map(p => (
<button
key={p}
onClick={() => { setDraft(p); onChange(p) }}
className={`px-1.5 py-0.5 rounded text-[10px] font-mono transition-colors ${
Math.abs(clamped - p) < 0.01
? 'bg-accent/15 text-accent border border-accent/30'
: 'bg-elevated text-secondary hover:text-foreground border border-transparent'
}`}
>
{p}s
</button>
))}
</div>
<div className="flex items-center gap-2">
<input
type="range"
min={min} max={max} step={step}
value={clamped}
onChange={e => { const v = parseFloat(e.target.value); setDraft(v); onChange(v) }}
className="flex-1 h-1 accent-accent cursor-pointer"
/>
<span className="text-[10px] font-mono text-foreground w-8 text-right">
{clamped < 1 ? clamped.toFixed(1) : clamped.toFixed(0)}s
</span>
</div>
</div>
)
}
@@ -45,14 +45,14 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
const options = useQuery({ queryKey: QK.monitorRuleOptions, queryFn: api.monitorRuleOptions }) const options = useQuery({ queryKey: QK.monitorRuleOptions, queryFn: api.monitorRuleOptions })
const strategies = useQuery({ queryKey: QK.screenerStrategies, queryFn: api.screenerStrategies }) const strategies = useQuery({ queryKey: QK.screenerStrategies, queryFn: api.screenerStrategies })
const { data: prefs } = usePreferences() const { data: prefs } = usePreferences()
const feishuConfigured = !!(prefs?.feishu_webhook_url) const feishuConfigured = !!((prefs as any)?.feishu_webhook_url)
const [editing] = useState(!!rule) const [editing] = useState(!!rule)
// 新建规则: 预填全局「默认推送渠道」(飞书), preset 显式指定时以 preset 为准。 // 新建规则: 预填全局「默认推送渠道」(飞书), preset 显式指定时以 preset 为准。
// 编辑规则: 完全沿用规则自身配置, 不受默认值影响。 // 编辑规则: 完全沿用规则自身配置, 不受默认值影响。
const [draft, setDraft] = useState<MonitorRule>( const [draft, setDraft] = useState<MonitorRule>(
rule rule
? { ...rule, conditions: rule.conditions.map(c => ({ ...c })) } ? { ...rule, conditions: rule.conditions.map(c => ({ ...c })) }
: { ...emptyRule(preset), webhook_enabled: preset?.webhook_enabled ?? !!(prefs?.webhook_enabled_default) }, : { ...emptyRule(preset), webhook_enabled: preset?.webhook_enabled ?? !!((prefs as any)?.webhook_enabled_default) },
) )
const [error, setError] = useState('') const [error, setError] = useState('')
const [symbolQuery, setSymbolQuery] = useState('') const [symbolQuery, setSymbolQuery] = useState('')
-73
View File
@@ -669,20 +669,12 @@ export interface SaveTickflowKeyResult {
} }
export interface Preferences { export interface Preferences {
realtime_quotes_enabled: boolean
indices_nav_pinned: boolean indices_nav_pinned: boolean
minute_sync_enabled: boolean minute_sync_enabled: boolean
minute_sync_days: number minute_sync_days: number
daily_data_provider?: string daily_data_provider?: string
adj_factor_provider?: string adj_factor_provider?: string
minute_data_provider?: string minute_data_provider?: string
realtime_data_provider?: string
realtime_watchlist_symbols?: string[]
realtime_pull_stock?: boolean
realtime_pull_etf?: boolean
realtime_pull_index?: boolean
realtime_index_mode?: 'core' | 'all'
realtime_index_symbols?: string[]
pipeline_pull_a_share: boolean pipeline_pull_a_share: boolean
pipeline_pull_etf: boolean pipeline_pull_etf: boolean
pipeline_pull_index: boolean pipeline_pull_index: boolean
@@ -691,18 +683,9 @@ export interface Preferences {
instruments_schedule: { hour: number; minute: number } instruments_schedule: { hour: number; minute: number }
enriched_batch_size: number enriched_batch_size: number
index_daily_batch_size: number index_daily_batch_size: number
limit_ladder_monitor_enabled: boolean
depth_polling_interval: number
depth_finalize_time: { hour: number; minute: number }
review_schedule: { enabled: boolean; hour: number; minute: number } review_schedule: { enabled: boolean; hour: number; minute: number }
review_push_channels: string[] review_push_channels: string[]
sse_refresh_pages: Record<string, boolean>
strategy_monitor_enabled: boolean
strategy_monitor_ids: string[]
system_notify_enabled: boolean system_notify_enabled: boolean
feishu_webhook_url?: string
feishu_webhook_secret?: string
webhook_enabled_default?: boolean
sidebar_index_symbols: string[] sidebar_index_symbols: string[]
nav_order: string[] nav_order: string[]
nav_hidden: string[] nav_hidden: string[]
@@ -793,67 +776,11 @@ export const api = {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ symbols }), body: JSON.stringify({ symbols }),
}), }),
updateRealtimeQuotes: (enabled: boolean) =>
request<{ realtime_quotes_enabled: boolean; realtime_allowed?: boolean; mode?: string; error?: string }>('/api/settings/preferences/realtime-quotes', {
method: 'PUT',
body: JSON.stringify({ realtime_quotes_enabled: enabled }),
}),
updateRealtimeQuoteScope: (cfg: Partial<Pick<Preferences, 'realtime_pull_stock' | 'realtime_pull_etf' | 'realtime_pull_index' | 'realtime_index_mode' | 'realtime_index_symbols'>>) =>
request<Partial<Preferences>>('/api/settings/preferences/realtime-quote-scope', {
method: 'PUT',
body: JSON.stringify(cfg),
}),
updateIndicesNavPinned: (pinned: boolean) => updateIndicesNavPinned: (pinned: boolean) =>
request<{ indices_nav_pinned: boolean }>('/api/settings/preferences/indices-nav-pinned', { request<{ indices_nav_pinned: boolean }>('/api/settings/preferences/indices-nav-pinned', {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ indices_nav_pinned: pinned }), body: JSON.stringify({ indices_nav_pinned: pinned }),
}), }),
quoteStatus: () =>
request<{
enabled: boolean
running: boolean
mode?: 'none' | 'watchlist' | 'full_market'
realtime_allowed?: boolean
interval_s: number
symbol_count: number
watchlist_symbol_count?: number
index_symbol_count?: number
etf_symbol_count?: number
quote_age_ms: number | null
is_trading_hours: boolean
last_fetch_ms: number | null
}>('/api/intraday/status'),
quoteInterval: () =>
request<{ interval: number; min_interval: number; max_interval: number }>(
'/api/settings/preferences/quote-interval',
),
updateQuoteInterval: (interval: number) =>
request<{ interval: number; min_interval: number; max_interval: number }>(
'/api/settings/preferences/quote-interval',
{ method: 'PUT', body: JSON.stringify({ interval }) },
),
intradayRefresh: () => request<{ status: string }>('/api/intraday/refresh', { method: 'POST' }),
indexQuotes: (symbols?: string[]) =>
request<{ rows: IndexQuote[]; count: number }>(
`/api/intraday/indices${symbols?.length ? `?symbols=${encodeURIComponent(symbols.join(','))}` : ''}`,
),
updateRealtimeMonitorConfig: (cfg: {
sse_refresh_pages?: Record<string, boolean>
strategy_monitor_enabled?: boolean
strategy_monitor_ids?: string[]
sidebar_index_symbols?: string[]
screener_auto_run?: boolean
}) =>
request<{
sse_refresh_pages: Record<string, boolean>
strategy_monitor_enabled: boolean
strategy_monitor_ids: string[]
sidebar_index_symbols: string[]
screener_auto_run: boolean
}>('/api/settings/preferences/realtime-monitor', {
method: 'PUT',
body: JSON.stringify(cfg),
}),
updateSystemNotify: (enabled: boolean) => updateSystemNotify: (enabled: boolean) =>
request<{ system_notify_enabled: boolean }>('/api/settings/preferences/system-notify', { request<{ system_notify_enabled: boolean }>('/api/settings/preferences/system-notify', {
method: 'PUT', method: 'PUT',
+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' }, 'kline.minute.batch': { name: '分钟 K(批量)', hint: '多股分钟 K' },
'depth5': { name: '五档盘口', hint: '买卖五档报价' }, 'depth5': { name: '五档盘口', hint: '买卖五档报价' },
'websocket': { name: '实时推送(WS)', hint: '免轮询的实时行情订阅' },
'financial': { name: '财务数据', hint: '利润表 / 资负表 / 现金流 / 关键指标' }, 'financial': { name: '财务数据', hint: '利润表 / 资负表 / 现金流 / 关键指标' },
'adj_factor': { name: '复权因子', hint: '让 MA/MACD 等指标在分红送转日不失真' }, 'adj_factor': { name: '复权因子', hint: '让 MA/MACD 等指标在分红送转日不失真' },
} }
@@ -63,7 +62,7 @@ const TIER_STYLE: Record<string, TierStyle> = {
labelTextStyle: { background: 'linear-gradient(135deg, #c084fc, #a855f7)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' }, labelTextStyle: { background: 'linear-gradient(135deg, #c084fc, #a855f7)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' },
}, },
expert: { expert: {
desc: 'WebSocket · 财务数据', desc: '财务数据',
tagBg: { background: 'linear-gradient(135deg, rgba(59,130,246,0.2), rgba(168,85,247,0.2), rgba(245,158,11,0.2))' }, tagBg: { background: 'linear-gradient(135deg, rgba(59,130,246,0.2), rgba(168,85,247,0.2), rgba(245,158,11,0.2))' },
dotStyle: { background: 'linear-gradient(135deg, #3b82f6, #a855f7, #f59e0b)' }, dotStyle: { background: 'linear-gradient(135deg, #3b82f6, #a855f7, #f59e0b)' },
labelTextStyle: { background: 'linear-gradient(135deg, #60a5fa, #c084fc, #fbbf24)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' }, labelTextStyle: { background: 'linear-gradient(135deg, #60a5fa, #c084fc, #fbbf24)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' },
+1 -15
View File
@@ -2,7 +2,6 @@
* React Query key * React Query key
* *
* - * -
* - SSE invalidation SSE_INVALIDATE_PREFIXES key useQuoteStream
*/ */
// ===== Query Key 工厂 ===== // ===== Query Key 工厂 =====
@@ -14,10 +13,7 @@ export const QK = {
endpoints: ['endpoints'] as const, endpoints: ['endpoints'] as const,
version: ['version'] as const, version: ['version'] as const,
preferences: ['preferences'] as const, preferences: ['preferences'] as const,
quoteStatus: ['quote-status'] as const,
quoteInterval: ['quote-interval'] as const,
overviewMarket: (asOf?: string) => ['overview-market', asOf ?? 'latest'] as const, overviewMarket: (asOf?: string) => ['overview-market', asOf ?? 'latest'] as const,
indexQuotes: ['index-quotes'] as const,
indexList: ['index-list'] as const, indexList: ['index-list'] as const,
// Watchlist // Watchlist
@@ -78,14 +74,4 @@ export const QK = {
rpsRotation: (days: number) => ['rps-rotation', days] as const, rpsRotation: (days: number) => ['rps-rotation', days] as const,
} as const } as const
// ===== SSE 应该 invalidate 的 key 前缀列表 ===== // SSE invalidation has been removed along with real-time functionality.
// 新增需要 SSE 推送的查询,只需在此加一行
export const SSE_INVALIDATE_PREFIXES = [
'watchlist',
'quote-status',
'index-quotes',
'overview-market',
'limit-ladder',
'screener',
] as const
+2 -58
View File
@@ -35,10 +35,6 @@ const INITIAL: ReviewState = { phase: 'idle', content: '', error: '', meta: null
let state: ReviewState = { ...INITIAL } let state: ReviewState = { ...INITIAL }
let abortCtrl: AbortController | null = null let abortCtrl: AbortController | null = null
// 当前生成来源: 'manual'(手动点生成) | 'sse'(定时任务 SSE 推送) | null(空闲)
// 用于区分两条流, 避免互相丢弃事件或重复归档。
let generatingSource: 'manual' | 'sse' | null = null
// ===== 订阅机制 ===== // ===== 订阅机制 =====
type Listener = () => void type Listener = () => void
const listeners = new Set<Listener>() const listeners = new Set<Listener>()
@@ -80,7 +76,6 @@ export async function startReviewGeneration(
// 已在生成中,不重复启动 // 已在生成中,不重复启动
if (isReviewGenerating()) return if (isReviewGenerating()) return
generatingSource = 'manual'
state = { phase: 'loading', content: '', error: '', meta: null, focus } state = { phase: 'loading', content: '', error: '', meta: null, focus }
notify() notify()
@@ -114,7 +109,7 @@ export async function startReviewGeneration(
if (buf && !failed) { if (buf && !failed) {
state = { ...state, phase: 'done' } state = { ...state, phase: 'done' }
notify() notify()
// 自动归档(仅手动流: 定时流由后端归档, SSE done 不走这里) // 自动归档
if (buf && !failed) { if (buf && !failed) {
onDone?.(buf, doneMeta) onDone?.(buf, doneMeta)
} }
@@ -126,7 +121,6 @@ export async function startReviewGeneration(
} }
} finally { } finally {
abortCtrl = null abortCtrl = null
generatingSource = null
} }
} }
@@ -167,54 +161,4 @@ export function resetReview(): void {
notify() notify()
} }
/** // feedReviewEvent (SSE-only) removed along with real-time SSE channel.
* SSE ()
*
* 用途: 定时复盘在后端流式生成, /api/intraday/stream review_progress
* meta/delta/done , store
* ,
*
* recap_market_stream :
* {type:'meta'|'delta'|'error'|'done'|'retry', ...}
*
* :
* - (isReviewGenerating), SSE (, )
* - done archived=true(): , done
* - retry: 后端 LLM ,
*/
export function feedReviewEvent(evt: any): void {
if (!evt || typeof evt !== 'object') return
const t = evt.type
// 并发控制: 手动流进行中时, SSE 事件一律忽略(手动流优先, 避免两条流抢同一个 store)
// 但若当前是 SSE 流自己在跑(generatingSource==='sse'), 则正常处理后续事件
if (generatingSource === 'manual') return
if (t === 'meta') {
// 定时流的第一个事件: 标记来源为 sse, 进入 streaming 态, 重置 content
generatingSource = 'sse'
state = { phase: 'streaming', content: '', error: '', meta: evt, focus: '' }
notify()
} else if (t === 'delta' && evt.content) {
// 只有 sse 流进行中时才累积(防止 meta 丢失时的孤立 delta)
if (generatingSource !== 'sse') return
state = { ...state, content: state.content + evt.content, phase: 'streaming' }
notify()
} else if (t === 'retry') {
if (generatingSource !== 'sse') return
// 后端重试: 清空已累积内容, 等待新一轮 meta/delta
state = { ...state, content: '', phase: 'streaming' }
notify()
} else if (t === 'error') {
if (generatingSource !== 'sse') return
state = { ...state, error: evt.message ?? '复盘生成失败', phase: 'error' }
notify()
generatingSource = null
} else if (t === 'done') {
if (generatingSource !== 'sse') return
// 定时场景 done 带 archived=true: 后端已归档, 前端只切 done 态, 不调归档接口。
state = { ...state, phase: 'done' }
notify()
generatingSource = null
}
}
+1 -4
View File
@@ -31,10 +31,7 @@ function loadConfig(): QueryConfig {
} }
} }
/** /** 轻量版:只读取当前配置。 */
*
* useQuoteStream 使
*/
export function getQueryConfig(): QueryConfig { export function getQueryConfig(): QueryConfig {
return loadConfig() 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])
}
@@ -5,30 +5,6 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from './api' import { api } from './api'
import { QK } from './queryKeys' 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 共用 */ /** 批量添加自选 — Screener / Intraday 共用 */
export function useWatchlistBatchAdd() { export function useWatchlistBatchAdd() {
const qc = useQueryClient() const qc = useQueryClient()
@@ -1,8 +1,5 @@
/** /**
* query hooks useQuery * query hooks useQuery
*
* SSE invalidation
* 线 SSE refetchInterval
*/ */
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { api } from './api' import { api } from './api'
@@ -34,33 +31,6 @@ export function usePreferences() {
}) })
} }
/** SSE quotes_updated
* poll=true 时启用条件轮询兜底: 仅在非交易时段每 60s ,
* (11:30午休 / 12:55开盘 / 15:05收盘) is_trading_hours
* (SSE ), SSE ,
* (Layout) poll=true, ;
* queryKey ,
*/
export function useQuoteStatus(opts?: { enabled?: boolean; poll?: boolean }) {
return useQuery({
queryKey: QK.quoteStatus,
queryFn: api.quoteStatus,
enabled: opts?.enabled ?? true,
refetchInterval: opts?.poll
? (query) => (query.state.data?.is_trading_hours ? false : 60_000)
: false,
})
}
/** 行情间隔 — Layout / Data 共用 */
export function useQuoteInterval() {
return useQuery({
queryKey: QK.quoteInterval,
queryFn: api.quoteInterval,
})
}
/** 版本号 — Layout 专用 */ /** 版本号 — Layout 专用 */
export function useVersion() { export function useVersion() {
return useQuery({ return useQuery({
+1 -19
View File
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef, type ReactNode } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import { Activity, ArrowDownRight, ArrowUpRight, BarChart3, BellRing, Database, Flame, Gauge, Info, LineChart, Loader2, Play, RefreshCw, Sparkles, Target, Timer } from 'lucide-react' import { Activity, ArrowDownRight, ArrowUpRight, BarChart3, BellRing, Database, Flame, Gauge, LineChart, Loader2, Play, RefreshCw, Sparkles, Target, Timer } from 'lucide-react'
import { DatePicker } from '@/components/DatePicker' import { DatePicker } from '@/components/DatePicker'
import { api, type MarketSnapshotRow, type OverviewDimensionRankItem, type OverviewMarket, type AlertEvent } from '@/lib/api' import { api, type MarketSnapshotRow, type OverviewDimensionRankItem, type OverviewMarket, type AlertEvent } from '@/lib/api'
import { QK } from '@/lib/queryKeys' import { QK } from '@/lib/queryKeys'
@@ -550,7 +550,6 @@ export function Dashboard() {
}).catch(() => { /* 查询失败不阻塞, 用户仍可手动点击获取 */ }) }).catch(() => { /* 查询失败不阻塞, 用户仍可手动点击获取 */ })
}, [hasNoData, fetchJobId]) }, [hasNoData, fetchJobId])
// 手动刷新: 显示旋转动画; SSE 自动刷新: 静默, 无体感
const handleRefresh = () => { const handleRefresh = () => {
setManualFetching(true) setManualFetching(true)
overview.refetch().finally(() => setManualFetching(false)) overview.refetch().finally(() => setManualFetching(false))
@@ -582,10 +581,6 @@ export function Dashboard() {
const strongDown = data.breadth.strong_down ?? 0 const strongDown = data.breadth.strong_down ?? 0
const latestDate = dataStatus.data?.enriched?.latest_date ?? null const latestDate = dataStatus.data?.enriched?.latest_date ?? null
const currentDate = selectedDate ?? data.as_of ?? '' const currentDate = selectedDate ?? data.as_of ?? ''
const quoteRunning = (!selectedDate || selectedDate === latestDate) && data.quote_status?.running
// 实时模式: none / watchlist / full_market。
// watchlist (Free 档) 仅自选 ≤5 只实时, 看板呈现的大盘数据实为盘后快照, 需提示避免误读。
const quoteMode = data.quote_status?.mode as ('none' | 'watchlist' | 'full_market') | undefined
return ( return (
<div className="min-h-full bg-base p-3"> <div className="min-h-full bg-base p-3">
@@ -642,7 +637,6 @@ export function Dashboard() {
<span className="font-mono text-secondary"></span> <span className="font-mono text-secondary"></span>
)} )}
<span className="flex items-center gap-1"><Timer className="h-3 w-3" />{quoteAge(data.quote_status?.quote_age_ms)}</span> <span className="flex items-center gap-1"><Timer className="h-3 w-3" />{quoteAge(data.quote_status?.quote_age_ms)}</span>
<span className={quoteRunning ? 'text-accent' : 'text-warning'}>{quoteRunning ? '实时' : '非实时'}</span>
<button <button
onClick={handleRefresh} onClick={handleRefresh}
disabled={manualFetching} disabled={manualFetching}
@@ -653,18 +647,6 @@ export function Dashboard() {
</div> </div>
</div> </div>
{/* Free 档提示: 大盘看板为盘后数据, 仅自选股实时。避免用户误读为全市场实时。 */}
{quoteMode === 'watchlist' && (
<div className="mb-3 flex items-start gap-2 rounded-card border border-amber-500/30 bg-amber-500/8 px-3 py-2 text-[11px] leading-relaxed">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
<div className="min-w-0 flex-1 text-secondary">
,<strong className="text-foreground"></strong>(),;
({data.quote_status?.watchlist_symbol_count ?? 0} )
<span className="ml-1 text-accent"> Starter+</span>
</div>
</div>
)}
<div className="mb-3 grid grid-cols-4 gap-2"> <div className="mb-3 grid grid-cols-4 gap-2">
{data.indices.map(item => <IndexTicker key={item.symbol} item={item} />)} {data.indices.map(item => <IndexTicker key={item.symbol} item={item} />)}
</div> </div>
+2 -50
View File
@@ -23,11 +23,8 @@ import {
useCapabilities, useCapabilities,
useSettings, useSettings,
usePreferences, usePreferences,
useQuoteStatus,
useQuoteInterval,
useDataStatus, useDataStatus,
} from '@/lib/useSharedQueries' } from '@/lib/useSharedQueries'
import { useToggleRealtimeQuotes, useUpdateQuoteInterval } from '@/lib/useSharedMutations'
import { QK } from '@/lib/queryKeys' import { QK } from '@/lib/queryKeys'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/PageHeader'
import { formatScheduleDatePart, formatScheduleTimePart, isToday } from '@/lib/format' import { formatScheduleDatePart, formatScheduleTimePart, isToday } from '@/lib/format'
@@ -43,7 +40,6 @@ import { EnrichedRebuildPanel } from '@/components/data/EnrichedRebuildPanel'
import { MinuteSyncConfig } from '@/components/data/MinuteSyncConfig' import { MinuteSyncConfig } from '@/components/data/MinuteSyncConfig'
import { PipelineScopeConfig } from '@/components/data/PipelineScopeConfig' import { PipelineScopeConfig } from '@/components/data/PipelineScopeConfig'
import { PageSettingsModal, getCardVisibility, getCardOrder, type CardKey } from '@/components/data/PageSettingsModal' import { PageSettingsModal, getCardVisibility, getCardOrder, type CardKey } from '@/components/data/PageSettingsModal'
import { QuoteConfigCard } from '@/components/data/QuoteConfigCard'
import { EnrichedSchemaModal } from '@/components/data/SchemaModal' import { EnrichedSchemaModal } from '@/components/data/SchemaModal'
import { Skeleton } from '@/components/data/Skeleton' import { Skeleton } from '@/components/data/Skeleton'
import { ExtDataStatCard } from '@/components/ext-data/ExtDataStatCard' import { ExtDataStatCard } from '@/components/ext-data/ExtDataStatCard'
@@ -141,7 +137,6 @@ export function Data() {
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.dataStatus }) qc.invalidateQueries({ queryKey: QK.dataStatus })
qc.invalidateQueries({ queryKey: QK.indexList }) qc.invalidateQueries({ queryKey: QK.indexList })
qc.invalidateQueries({ queryKey: QK.indexQuotes })
qc.invalidateQueries({ queryKey: ['index-daily'] }) qc.invalidateQueries({ queryKey: ['index-daily'] })
}, },
}) })
@@ -161,33 +156,6 @@ export function Data() {
onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }), onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }),
}) })
const [showIntervalEdit, setShowIntervalEdit] = useState(false)
const handleToggleIntervalEdit = useCallback((fromEvent?: boolean) => {
setShowIntervalEdit(v => {
const next = !v
if (!fromEvent) {
window.dispatchEvent(new CustomEvent('quote-interval-editor-toggle', { detail: { source: 'data' } }))
}
return next
})
}, [])
useEffect(() => {
const handler = (e: Event) => {
const ce = e as CustomEvent
if (ce.detail?.source !== 'data') {
setShowIntervalEdit(v => !v)
}
}
window.addEventListener('quote-interval-editor-toggle', handler)
return () => window.removeEventListener('quote-interval-editor-toggle', handler)
}, [])
const quoteInterval = useQuoteInterval()
const updateInterval = useUpdateQuoteInterval()
const realtimeEnabled = prefs.data?.realtime_quotes_enabled ?? false
const quoteStatus = useQuoteStatus()
const toggleQuote = useToggleRealtimeQuotes()
const hasAdjCap = !!caps.data?.capabilities?.['adj_factor'] const hasAdjCap = !!caps.data?.capabilities?.['adj_factor']
const hasDailyBatchCap = !!caps.data?.capabilities?.['kline.daily.batch'] const hasDailyBatchCap = !!caps.data?.capabilities?.['kline.daily.batch']
const hasMinuteCap = !!caps.data?.capabilities?.['kline.minute.batch'] const hasMinuteCap = !!caps.data?.capabilities?.['kline.minute.batch']
@@ -579,24 +547,8 @@ export function Data() {
)} )}
</AnimatePresence> </AnimatePresence>
{/* 实时行情 + 存储 + 调度 */} {/* 自动调度 + 存储 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<QuoteConfigCard
enabled={realtimeEnabled}
running={quoteStatus.data?.running ?? false}
isTrading={quoteStatus.data?.is_trading_hours ?? false}
lastFetchMs={quoteStatus.data?.last_fetch_ms ?? null}
intervalS={quoteInterval.data?.interval ?? quoteStatus.data?.interval_s ?? 10}
intervalMin={quoteInterval.data?.min_interval ?? 5}
intervalMax={quoteInterval.data?.max_interval ?? 60}
loading={quoteStatus.isLoading}
onToggle={(v) => toggleQuote.mutate(v)}
toggling={toggleQuote.isPending}
showIntervalEdit={showIntervalEdit}
onShowIntervalEdit={handleToggleIntervalEdit}
onIntervalChange={(v) => updateInterval.mutate(v)}
/>
{/* 自动调度 */} {/* 自动调度 */}
<div className="rounded-card border border-border bg-surface p-4"> <div className="rounded-card border border-border bg-surface p-4">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
+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 = [ const PINNED_INDEXES = [
{ symbol: '000001.SH', name: '上证指数' }, { symbol: '000001.SH', name: '上证指数' },
{ symbol: '399001.SZ', name: '深证成指' }, { symbol: '399001.SZ', name: '深证成指' },
@@ -112,12 +102,6 @@ export function Indices() {
setSearchParams({ symbol }) setSearchParams({ symbol })
} }
const quotes = useQuery({
queryKey: QK.indexQuotes,
queryFn: () => api.indexQuotes(),
placeholderData: (prev) => prev,
})
const daily = useQuery({ const daily = useQuery({
queryKey: QK.indexDaily(selectedSymbol, range.start, range.end), queryKey: QK.indexDaily(selectedSymbol, range.start, range.end),
queryFn: () => api.indexDaily(selectedSymbol, 180, range), queryFn: () => api.indexDaily(selectedSymbol, 180, range),
@@ -136,7 +120,6 @@ export function Indices() {
mutationFn: api.syncIndexInstruments, mutationFn: api.syncIndexInstruments,
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.indexList }) qc.invalidateQueries({ queryKey: QK.indexList })
qc.invalidateQueries({ queryKey: QK.indexQuotes })
}, },
}) })
@@ -144,20 +127,10 @@ export function Indices() {
mutationFn: () => api.syncIndexDaily(365), mutationFn: () => api.syncIndexDaily(365),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.indexList }) qc.invalidateQueries({ queryKey: QK.indexList })
qc.invalidateQueries({ queryKey: QK.indexQuotes })
qc.invalidateQueries({ queryKey: ['index-daily'] }) qc.invalidateQueries({ queryKey: ['index-daily'] })
}, },
}) })
const quoteBySymbol = useMemo(() => {
const m = new Map<string, any>()
for (const q of quotes.data?.rows ?? []) m.set(q.symbol, q)
return m
}, [quotes.data?.rows])
const selectedQuote = selectedSymbol ? quoteBySymbol.get(selectedSymbol) : null
const selectedQuoteValue = selectedQuote?.last_price ?? selectedQuote?.price ?? selectedQuote?.close
const selectedQuotePct = selectedQuote?.change_pct ?? selectedQuote?.pct
const chartRows = useMemo(() => toOHLC(daily.data?.rows ?? []), [daily.data?.rows]) const chartRows = useMemo(() => toOHLC(daily.data?.rows ?? []), [daily.data?.rows])
const selectedInfo = [...topRows, ...listRows].find(r => r.symbol === selectedSymbol) || daily.data?.index_info const selectedInfo = [...topRows, ...listRows].find(r => r.symbol === selectedSymbol) || daily.data?.index_info
const minuteRows: MinuteKlineRow[] = minute.data?.rows ?? [] const minuteRows: MinuteKlineRow[] = minute.data?.rows ?? []
@@ -179,9 +152,6 @@ export function Indices() {
} }
}, [chartRows, daily.data?.symbol, selectedDate, selectedSymbol]) }, [chartRows, daily.data?.symbol, selectedDate, selectedSymbol])
const renderIndexItem = (item: IndexInstrument) => { const renderIndexItem = (item: IndexInstrument) => {
const q = quoteBySymbol.get(item.symbol)
const pct = q?.change_pct ?? q?.pct
const current = q?.last_price ?? q?.price ?? q?.close
const active = item.symbol === selectedSymbol const active = item.symbol === selectedSymbol
return ( return (
<button <button
@@ -189,14 +159,8 @@ export function Indices() {
onClick={() => selectIndex(item.symbol)} onClick={() => selectIndex(item.symbol)}
className={`w-full rounded-btn px-2 py-2 text-left transition-colors ${active ? 'bg-accent/15 text-foreground' : 'hover:bg-elevated text-secondary'}`} className={`w-full rounded-btn px-2 py-2 text-left transition-colors ${active ? 'bg-accent/15 text-foreground' : 'hover:bg-elevated text-secondary'}`}
> >
<div className="flex items-center justify-between gap-2"> <div className="truncate text-xs font-medium">{item.name || item.symbol}</div>
<span className="truncate text-xs font-medium">{item.name || item.symbol}</span> <div className="mt-0.5 text-[10px] font-mono text-muted">{item.symbol}</div>
<span className={`text-[10px] font-mono ${Number(pct ?? 0) >= 0 ? 'text-bull' : 'text-bear'}`}>{fmtPct(pct)}</span>
</div>
<div className="mt-0.5 flex items-center justify-between text-[10px] font-mono text-muted">
<span>{item.symbol}</span>
<span>{fmtNum(current)}</span>
</div>
</button> </button>
) )
} }
@@ -264,11 +228,9 @@ export function Indices() {
{selectedInfo?.name || selectedSymbol || '未选择指数'} {selectedInfo?.name || selectedSymbol || '未选择指数'}
</h2> </h2>
{selectedSymbol && <span className="font-mono text-xs text-muted">{selectedSymbol}</span>} {selectedSymbol && <span className="font-mono text-xs text-muted">{selectedSymbol}</span>}
{selectedSymbol && <span className="font-mono text-xs text-foreground">{fmtNum(selectedQuoteValue)}</span>}
{selectedSymbol && <span className={`font-mono text-xs ${Number(selectedQuotePct ?? 0) >= 0 ? 'text-bull' : 'text-bear'}`}>{fmtPct(selectedQuotePct)}</span>}
</div> </div>
<div className="mt-1 text-xs text-muted"> <div className="mt-1 text-xs text-muted">
{quotes.data?.count ?? 0} · K来源 {daily.data?.source ?? '--'} K来源 {daily.data?.source ?? '--'}
</div> </div>
</div> </div>
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
+1 -1
View File
@@ -374,7 +374,7 @@ function MonitorMenu({ stock, direction, sealMode, monitorRule, anchorRect, hasD
// 推送外部开关默认值: 取偏好设置中的全局默认 (已有规则沿用其值) // 推送外部开关默认值: 取偏好设置中的全局默认 (已有规则沿用其值)
const { data: prefs } = usePreferences() const { data: prefs } = usePreferences()
const webhookDefault = prefs?.webhook_enabled_default ?? false const webhookDefault: boolean = (prefs as any)?.webhook_enabled_default ?? false
// 单位倍率: 输入值 × 倍率 = 原始单位 (量=手, 额=元) // 单位倍率: 输入值 × 倍率 = 原始单位 (量=手, 额=元)
const VOL_UNITS = [ const VOL_UNITS = [
+1 -1
View File
@@ -103,7 +103,7 @@ export function Review() {
const [showSchedule, setShowSchedule] = useState(false) const [showSchedule, setShowSchedule] = useState(false)
const prefs = usePreferences() const prefs = usePreferences()
const reviewSched = prefs.data?.review_schedule ?? { enabled: false, hour: 15, minute: 10 } const reviewSched = prefs.data?.review_schedule ?? { enabled: false, hour: 15, minute: 10 }
const feishuConfigured = !!(prefs.data?.feishu_webhook_url) const feishuConfigured = !!((prefs.data as any)?.feishu_webhook_url)
// 推送渠道是独立的顶层偏好(多选), 与定时 / 实时行情无关, 常驻可单独设置 // 推送渠道是独立的顶层偏好(多选), 与定时 / 实时行情无关, 常驻可单独设置
// []=不推送, ['feishu']=飞书(微信开发中, 仅占位) // []=不推送, ['feishu']=飞书(微信开发中, 仅占位)
const reviewPushChannels = prefs.data?.review_push_channels ?? [] const reviewPushChannels = prefs.data?.review_push_channels ?? []
-42
View File
@@ -16,7 +16,6 @@ import { MiniCandlestick } from '@/components/stock-table/MiniCandlestick'
import { boardTag, renderBuiltinDataCell } from '@/components/stock-table/primitives' import { boardTag, renderBuiltinDataCell } from '@/components/stock-table/primitives'
import { getSignals, signalCls, getSortValue, UNSORTABLE_KEYS } from '@/lib/stock-table' import { getSignals, signalCls, getSortValue, UNSORTABLE_KEYS } from '@/lib/stock-table'
import { resolveCandleConfig } from '@/lib/list-columns' import { resolveCandleConfig } from '@/lib/list-columns'
import { useQuoteStatus } from '@/lib/useSharedQueries'
import { import {
type ColumnConfig, type ColumnConfig,
BUILTIN_COLUMNS, BUILTIN_COLUMNS,
@@ -296,27 +295,6 @@ function StockSearchBox({
) )
} }
// ===== 实时监控圆点 =====
// 自选页 symbol 列代码后的小圆点, 标识该标的正在被实时行情监控 (Free/低档按自选监控模式)。
// 视觉: 内圈实心点 + 外圈 animate-ping 扩散晕, 语义=「在线/活动」。
// 配色用 accent (电光蓝) 而非绿/红: 项目设计规范规定红绿仅用于价格/K线,
// UI 状态用 accent, 避免与 A 股涨跌色混淆。
// 全市场模式 (Starter+) 不显示 —— 全部都在监控, 标记无信息量。
function RealtimeDot({ title = '实时监控中' }: { title?: string }) {
return (
<span
title={title}
className="relative inline-flex h-2 w-2 shrink-0"
aria-label={title}
>
{/* 外圈: 扩散晕 (ping 动画) */}
<span className="absolute inline-flex h-full w-full rounded-full bg-accent/60 animate-ping motion-reduce:hidden" />
{/* 内圈: 实心点 + 微辉光 */}
<span className="relative inline-flex rounded-full h-2 w-2 bg-accent shadow-[0_0_5px_rgba(61,214,140,0.6)]" />
</span>
)
}
// ===== 卡片组件 ===== // ===== 卡片组件 =====
function StockCard({ function StockCard({
@@ -331,7 +309,6 @@ function StockCard({
extCols, extCols,
expandedCells, expandedCells,
onToggleExpand, onToggleExpand,
isMonitored,
}: { }: {
r: any r: any
candleRows: KlineRow[] candleRows: KlineRow[]
@@ -344,7 +321,6 @@ function StockCard({
extCols: ColumnConfig[] extCols: ColumnConfig[]
expandedCells: Set<string> expandedCells: Set<string>
onToggleExpand: (key: string) => void onToggleExpand: (key: string) => void
isMonitored?: boolean
}) { }) {
const board = boardTag(r.symbol) const board = boardTag(r.symbol)
const price = r.rt_price ?? r.close const price = r.rt_price ?? r.close
@@ -418,7 +394,6 @@ function StockCard({
{r.consecutive_limit_ups === 1 ? '首板' : `${r.consecutive_limit_ups}`} {r.consecutive_limit_ups === 1 ? '首板' : `${r.consecutive_limit_ups}`}
</span> </span>
)} )}
{isMonitored && <span className="ml-auto"><RealtimeDot /></span>}
</div> </div>
{/* 第二行: 大价格 + 涨跌幅胶囊 */} {/* 第二行: 大价格 + 涨跌幅胶囊 */}
@@ -624,7 +599,6 @@ export function Watchlist() {
qc.invalidateQueries({ queryKey: ['watchlist-enriched'] }) qc.invalidateQueries({ queryKey: ['watchlist-enriched'] })
qc.invalidateQueries({ queryKey: ['watchlist-kline-batch'] }) qc.invalidateQueries({ queryKey: ['watchlist-kline-batch'] })
qc.invalidateQueries({ queryKey: QK.preferences }) qc.invalidateQueries({ queryKey: QK.preferences })
qc.invalidateQueries({ queryKey: QK.quoteStatus })
}, },
}) })
@@ -647,20 +621,6 @@ export function Watchlist() {
const allSymbols = list.data?.symbols?.map(s => s.symbol) ?? [] const allSymbols = list.data?.symbols?.map(s => s.symbol) ?? []
const rows = enriched.data?.rows ?? [] const rows = enriched.data?.rows ?? []
// 实时监控圆点: 仅 Free/低档 "按自选股实时监控" 模式 (mode === 'watchlist') 下显示;
// Starter+ 全市场模式 (mode === 'full_market') 全部标的都在监控, 标圆点无意义, 故不显示。
// 后端 Free 档实际只监控自选页前 N 个 (N = watchlist_symbol_count), 顺序与 allSymbols 一致。
const quoteStatus = useQuoteStatus()
const realtimeRunning = quoteStatus.data?.running ?? false
const realtimeMode = quoteStatus.data?.mode
const watchlistMonitoredCount = quoteStatus.data?.watchlist_symbol_count ?? 0
const showRealtimeDot = realtimeRunning && realtimeMode === 'watchlist'
// 真正被监控的标的集合 (自选列表前 watchlistMonitoredCount 个)
const monitoredSymbols = useMemo(
() => showRealtimeDot ? new Set(allSymbols.slice(0, watchlistMonitoredCount)) : new Set<string>(),
[showRealtimeDot, allSymbols, watchlistMonitoredCount],
)
// ===== 筛选 ===== // ===== 筛选 =====
const [filterOpen, setFilterOpen] = useState(false) const [filterOpen, setFilterOpen] = useState(false)
const [filters, setFilters] = useState<Record<string, { min?: string; max?: string; text?: string }>>({}) const [filters, setFilters] = useState<Record<string, { min?: string; max?: string; text?: string }>>({})
@@ -1008,7 +968,6 @@ export function Watchlist() {
{board.label} {board.label}
</span> </span>
) : null} ) : null}
{monitoredSymbols.has(r.symbol) && <span className="ml-2"><RealtimeDot /></span>}
</button> </button>
{/* 删除入口:默认减号图标,二次确认时替换为确定按钮 */} {/* 删除入口:默认减号图标,二次确认时替换为确定按钮 */}
<div className="ml-auto pl-1 shrink-0"> <div className="ml-auto pl-1 shrink-0">
@@ -1119,7 +1078,6 @@ export function Watchlist() {
extCols={visibleExtCols} extCols={visibleExtCols}
expandedCells={expandedCells} expandedCells={expandedCells}
onToggleExpand={handleToggleExpand} onToggleExpand={handleToggleExpand}
isMonitored={monitoredSymbols.has(r.symbol)}
/> />
))} ))}
</div> </div>
@@ -1,572 +1,8 @@
import { useState, useCallback, useEffect, useRef } from 'react' // Real-time monitoring settings have been removed.
import { Link } from 'react-router-dom' export function SettingsMonitoringPanel(_props?: { highlight?: string }) {
import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query'
import {
Activity,
Wifi,
BarChart3,
Flame,
Zap,
Webhook,
ChevronDown,
} from 'lucide-react'
import {
usePreferences,
useQuoteStatus,
useQuoteInterval,
useCapabilities,
} from '@/lib/useSharedQueries'
import { useUpdateQuoteInterval, useToggleRealtimeQuotes } from '@/lib/useSharedMutations'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { tierRank } from '@/lib/capability-labels'
import { toast } from '@/components/Toast'
import { DepthConfigContent } from '@/components/data/DepthConfigCard'
// 页面 → 显示名
const PAGE_LABELS: Record<string, string> = {
'overview-market': '看板',
watchlist: '自选页',
'limit-ladder': '连板梯队',
}
const SIDEBAR_INDEX_OPTIONS = [
{ symbol: '000001.SH', name: '上证指数' },
{ symbol: '399001.SZ', name: '深证成指' },
{ symbol: '399006.SZ', name: '创业板指' },
{ symbol: '000680.SH', name: '科创综指' },
]
// ===== 导出为 Panel 组件 (由 Settings.tsx 嵌入) =====
export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = {}) {
const qc = useQueryClient()
const { data: prefs } = usePreferences()
const { data: caps } = useCapabilities()
const { data: quoteStatus } = useQuoteStatus()
const { data: intervalData } = useQuoteInterval()
const updateInterval = useUpdateQuoteInterval()
const toggleQuote = useToggleRealtimeQuotes()
const tier = tierRank(caps?.label ?? '')
const isNoneTier = tier < 0
const isFreeTier = tier === 0
const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false
const refreshPages = prefs?.sse_refresh_pages ?? {}
const limitLadderMonitor = prefs?.limit_ladder_monitor_enabled ?? false
const hasDepth = !!caps?.capabilities?.['depth5.batch']
// 新建监控规则时是否默认勾选飞书推送 (全局默认值, 单条规则可独立修改)
const webhookDefault = prefs?.webhook_enabled_default ?? false
const sidebarIndexSymbols = prefs?.sidebar_index_symbols ?? SIDEBAR_INDEX_OPTIONS.map(i => i.symbol)
const indicesPinned = prefs?.indices_nav_pinned ?? true
const isRunning = quoteStatus?.running ?? false
const isTrading = quoteStatus?.is_trading_hours ?? false
const interval = intervalData?.interval ?? 10
const minInterval = intervalData?.min_interval ?? 5
const maxInterval = intervalData?.max_interval ?? 60
const [intervalDraft, setIntervalDraft] = useState(interval)
const feishuWebhookUrl = prefs?.feishu_webhook_url ?? ''
const feishuWebhookSecret = prefs?.feishu_webhook_secret ?? ''
const [feishuDraft, setFeishuDraft] = useState(feishuWebhookUrl)
const [feishuSecretDraft, setFeishuSecretDraft] = useState(feishuWebhookSecret)
const [feishuError, setFeishuError] = useState('')
// 飞书渠道配置区展开态 (推送通知卡片内)
const [channelOpen, setChannelOpen] = useState(false)
useEffect(() => {
setFeishuDraft(feishuWebhookUrl)
setFeishuSecretDraft(feishuWebhookSecret)
}, [feishuWebhookUrl, feishuWebhookSecret])
const watchlistSymbols = prefs?.realtime_watchlist_symbols ?? []
const watchlist = useQuery({
queryKey: QK.watchlist,
queryFn: () => api.watchlistList(),
enabled: isFreeTier && watchlistSymbols.length > 0,
})
const watchlistNameBySymbol = new Map(
(watchlist.data?.symbols ?? []).map(row => [row.symbol, row.name] as const),
)
const save = useCallback(async (cfg: Record<string, unknown>) => {
try {
await api.updateRealtimeMonitorConfig(cfg)
qc.invalidateQueries({ queryKey: QK.preferences })
} catch (e) {
// 忽略 — Toast 已在 request 层处理
}
}, [qc])
const handleToggleQuote = useCallback(async (enabled: boolean) => {
await toggleQuote.mutateAsync(enabled)
qc.invalidateQueries({ queryKey: QK.preferences })
qc.invalidateQueries({ queryKey: QK.quoteStatus })
}, [toggleQuote, qc])
const toggleSidebarIndex = useCallback((symbol: string, visible: boolean) => {
const selected = new Set(sidebarIndexSymbols)
if (visible) selected.add(symbol)
else selected.delete(symbol)
const next = SIDEBAR_INDEX_OPTIONS
.map(item => item.symbol)
.filter(s => selected.has(s))
save({ sidebar_index_symbols: next })
}, [save, sidebarIndexSymbols])
const toggleIndicesPin = useCallback((pinned: boolean) => {
api.updateIndicesNavPinned(pinned).then(() => qc.invalidateQueries({ queryKey: QK.preferences }))
}, [qc])
const toggleLimitLadderMonitor = useCallback(async (enabled: boolean) => {
await api.updateLimitLadderMonitor(enabled)
qc.invalidateQueries({ queryKey: QK.preferences })
}, [qc])
const toggleWebhookDefault = useCallback(async (enabled: boolean) => {
await api.updateWebhookDefault(enabled)
qc.invalidateQueries({ queryKey: QK.preferences })
}, [qc])
const saveFeishuWebhook = useMutation({
mutationFn: ({ url, secret }: { url: string; secret: string }) => api.updateFeishuWebhook(url, secret),
onSuccess: () => {
setFeishuError('')
toast('飞书 Webhook 已保存', 'success')
qc.invalidateQueries({ queryKey: QK.preferences })
},
onError: (err: any) => setFeishuError(String(err?.message ?? '保存失败')),
})
const FEISHU_PREFIX = 'https://open.feishu.cn/open-apis/bot/v2/hook/'
const submitFeishu = useCallback(() => {
const url = feishuDraft.trim()
const secret = feishuSecretDraft.trim()
if (url && !url.startsWith(FEISHU_PREFIX)) {
setFeishuError('地址需以 ' + FEISHU_PREFIX + ' 开头')
return
}
saveFeishuWebhook.mutate({ url, secret })
}, [feishuDraft, feishuSecretDraft, saveFeishuWebhook])
const runFix = useMutation({
mutationFn: () => api.runLimitLadderFix(),
onSuccess: (data) => {
toast(data.msg, data.ok ? 'success' : 'error')
// 修正后连板梯队数据变了, 刷新相关缓存
qc.invalidateQueries({ queryKey: ['limit-ladder'] })
},
onError: () => toast('修正请求失败', 'error'),
})
useEffect(() => {
setIntervalDraft(interval)
}, [interval])
useEffect(() => {
if (intervalDraft === interval) return
const t = window.setTimeout(() => {
updateInterval.mutate(intervalDraft)
}, 2000)
return () => window.clearTimeout(t)
}, [intervalDraft, interval, updateInterval])
// highlight=depth-fix 时闪烁高亮连板梯队修正卡片
const [flash, setFlash] = useState(false)
const flashedRef = useRef(false)
useEffect(() => {
if (highlight === 'depth-fix' && !flashedRef.current) {
flashedRef.current = true
// 延迟一帧确保 DOM 已渲染, 再触发闪烁
requestAnimationFrame(() => {
setFlash(true)
const t = setTimeout(() => setFlash(false), 2000)
return () => clearTimeout(t)
})
}
}, [highlight])
if (isNoneTier) {
return ( return (
<div className="flex flex-col items-center justify-center py-20 text-center"> <div className="flex flex-col items-center justify-center py-16 text-center">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl <p className="text-sm text-muted"></p>
bg-gradient-to-br from-purple-500/20 to-blue-500/20 mb-5">
<Activity className="h-7 w-7 text-purple-400" />
</div>
<h2 className="text-lg font-medium text-foreground mb-2"></h2>
<p className="text-sm text-secondary max-w-md mb-6">
Free None 使 free-api 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> </div>
) )
} }
return (
<div className="grid grid-cols-1 lg:grid-cols-[1fr_1fr] gap-6 max-w-5xl">
{/* ========== 左列 ========== */}
<div className="space-y-6">
{/* 行情状态 — 开关 + 间隔 */}
<Card icon={Activity} title="行情轮询">
<ToggleRow
label="实时行情"
desc={isRunning && isTrading ? '运行中' : isRunning ? '运行中 (非交易时段)' : '已关闭'}
checked={realtimeEnabled}
onChange={handleToggleQuote}
/>
<div className="mt-3 pt-3 border-t border-border">
<div className="flex items-center justify-between gap-4 py-1">
<div className="min-w-0">
<div className="text-sm text-foreground"></div>
<div className="text-[11px] text-muted">
{isFreeTier ? '每轮拉取自选股实时行情的时间间隔' : '每轮拉取全市场行情的时间间隔'}
</div>
</div>
<span className="text-[11px] font-mono text-foreground shrink-0 tabular-nums">
{intervalDraft < 1 ? intervalDraft.toFixed(1) : intervalDraft.toFixed(0)}s
</span>
</div>
<div className="flex items-center gap-3 mt-2">
<input
type="range"
min={minInterval}
max={maxInterval}
step={minInterval < 1 ? 0.1 : minInterval < 3 ? 0.5 : 1}
value={intervalDraft}
onChange={(e) => setIntervalDraft(parseFloat(e.target.value))}
className="flex-1 h-1 accent-accent cursor-pointer"
/>
<span className="text-[10px] text-muted shrink-0">
{intervalDraft !== interval ? '2秒后保存' : `${minInterval}s — ${maxInterval}s`}
</span>
</div>
</div>
</Card>
{isFreeTier && (
<Card icon={Activity} title="自选股实时">
<div className="mb-3 rounded-btn border border-accent/25 bg-accent/10 px-3 py-2 text-xs font-medium leading-snug text-accent">
Free 5 6
</div>
{watchlistSymbols.length > 0 ? (
<div className="space-y-1.5">
{watchlistSymbols.map(symbol => {
const name = watchlistNameBySymbol.get(symbol)
return (
<div key={symbol} className="flex items-center justify-between rounded-btn bg-base/50 border border-border px-2 py-1.5">
<div className="min-w-0 flex items-baseline gap-1.5">
<span className="text-xs font-mono text-foreground">{symbol}</span>
{name && <span className="truncate text-[11px] text-secondary">{name}</span>}
</div>
<span className="text-[10px] text-muted shrink-0"></span>
</div>
)
})}
</div>
) : (
<div className="rounded-btn border border-border bg-base/40 px-3 py-3 text-xs text-muted">
Free
</div>
)}
<div className="mt-2 flex items-center justify-between gap-3">
<span className="text-[10px] text-muted"> {watchlistSymbols.length}/5 </span>
<Link
to="/watchlist"
className="px-3 py-1 rounded-btn bg-elevated text-secondary text-xs font-medium hover:text-foreground transition-colors"
>
</Link>
</div>
</Card>
)}
{!isFreeTier && (
<Card icon={Wifi} title="页面实时刷新">
<p className="text-xs text-secondary mb-4">
SSE
</p>
<div className="space-y-2">
{Object.entries(PAGE_LABELS).map(([key, label]) => (
<ToggleRow
key={key}
label={label}
desc={`SSE 推送时刷新 ${label} 数据`}
checked={refreshPages[key] !== false}
onChange={(v) => save({ sse_refresh_pages: { ...refreshPages, [key]: v } })}
/>
))}
</div>
</Card>
)}
{!isFreeTier && (
<Card icon={BarChart3} title="左侧菜单指数">
<p className="text-xs text-secondary mb-4">
</p>
<div className="space-y-2">
{SIDEBAR_INDEX_OPTIONS.map(item => (
<ToggleRow
key={item.symbol}
label={item.name}
desc={item.symbol}
checked={sidebarIndexSymbols.includes(item.symbol)}
onChange={(v) => toggleSidebarIndex(item.symbol, v)}
/>
))}
</div>
<div className="mt-3 pt-3 border-t border-border">
<ToggleRow
label="固定显示"
desc={indicesPinned ? '指数卡片常驻显示(即使实时行情关闭)' : '跟随实时行情开关(仅实时开时显示)'}
checked={indicesPinned}
onChange={toggleIndicesPin}
/>
</div>
</Card>
)}
</div>
{/* ========== 右列 ========== */}
<div className="space-y-6">
{/* 连板梯队降级修正 (移至右列顶部) */}
<div
id="depth-fix"
className={`rounded-card transition-all duration-500 ${flash ? 'ring-2 ring-accent/60 ring-offset-2 ring-offset-base scale-[1.01]' : 'ring-0 ring-transparent'}`}
>
<Card
icon={Flame}
title="连板梯队降级修正"
badge={!hasDepth ? '需 Pro+' : undefined}
right={hasDepth ? (
<button
onClick={() => runFix.mutate()}
disabled={runFix.isPending}
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px]
bg-accent/15 text-accent hover:bg-accent/25 transition-colors
disabled:opacity-50 disabled:cursor-not-allowed"
>
<Zap className="h-3 w-3" />
{runFix.isPending ? '修正中…' : '立即修正'}
</button>
) : undefined}
>
{hasDepth ? (
<>
<p className="text-xs text-secondary mb-4">
/,(=)
,
</p>
<ToggleRow
label="启用真假板修正"
desc="开启后盘中自动拉取五档盘口修正真假板"
checked={limitLadderMonitor}
onChange={toggleLimitLadderMonitor}
/>
<div className="mt-4 pt-3 border-t border-border">
<div className="text-[10px] uppercase tracking-widest text-muted mb-3">
</div>
<DepthConfigContent disabled={!limitLadderMonitor} />
</div>
</>
) : (
<DepthConfigContent disabled />
)}
</Card>
</div>
{/* ()
; , QMT/ptrade
每个渠道合并成一行: 勾选=, */}
<Card icon={Webhook} title="推送通知">
<p className="text-xs text-secondary mb-3">
,<b className="text-foreground/80"></b>,
</p>
{/* 渠道列表 — 每行一个渠道, 勾选默认 + 点行展开地址配置 */}
<div className="space-y-2">
{/* 飞书 (可用): 勾选默认 + 展开地址配置 */}
<div className="rounded-btn border border-border/60 bg-base/40 overflow-hidden">
<div
onClick={() => setChannelOpen(o => !o)}
className="flex items-center gap-2 px-2.5 py-2 cursor-pointer transition-colors hover:bg-base/60"
>
<input
type="checkbox"
checked={webhookDefault}
onChange={e => { e.stopPropagation(); toggleWebhookDefault(e.target.checked) }}
onClick={e => e.stopPropagation()}
title="作为新建规则的默认推送渠道"
className="h-3 w-3 accent-accent cursor-pointer"
/>
<span className="text-[11px] font-medium text-foreground"></span>
<span className="text-[9px] text-muted"></span>
{webhookDefault && (
<span className="rounded bg-accent/15 px-1 py-px text-[9px] text-accent"></span>
)}
<span className={`ml-auto text-[9px] ${feishuWebhookUrl ? 'text-emerald-500' : 'text-warning'}`}>
{feishuWebhookUrl ? '已配置' : '未配置'}
</span>
<ChevronDown className={`h-3 w-3 text-muted transition-transform ${channelOpen ? 'rotate-180' : ''}`} />
</div>
{/* 飞书地址配置 — 行内展开 */}
{channelOpen && (
<div className="border-t border-border/60 bg-base/30 p-3">
<label className="block space-y-1.5">
<span className="text-[11px] text-muted">Webhook </span>
<input
value={feishuDraft}
onChange={e => setFeishuDraft(e.target.value)}
placeholder={FEISHU_PREFIX + 'xxxxxxxx'}
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground focus:outline-none focus:border-accent/50"
/>
</label>
<label className="block mt-2 space-y-1.5">
<span className="text-[11px] text-muted"> ( · )</span>
<input
type="password"
value={feishuSecretDraft}
onChange={e => setFeishuSecretDraft(e.target.value)}
placeholder="机器人未启用签名校验则留空"
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground focus:outline-none focus:border-accent/50"
/>
</label>
{feishuError && (
<div className="mt-2 text-[11px] text-danger">{feishuError}</div>
)}
<div className="mt-2 flex items-center gap-2">
<button
onClick={submitFeishu}
disabled={saveFeishuWebhook.isPending || (feishuDraft.trim() === feishuWebhookUrl && feishuSecretDraft.trim() === feishuWebhookSecret)}
className="px-3 py-1.5 rounded-btn bg-accent text-base text-xs font-medium disabled:opacity-50 cursor-pointer hover:bg-accent/90 transition-colors"
>
{saveFeishuWebhook.isPending ? '保存中…' : '保存'}
</button>
{feishuWebhookUrl && (
<span className="text-[10px] text-emerald-500"> </span>
)}
</div>
<details className="mt-3 text-[10px] text-muted">
<summary className="cursor-pointer hover:text-secondary"> Webhook ?</summary>
<ol className="mt-1.5 space-y-1 pl-4 list-decimal leading-relaxed">
<li>, <b></b></li>
<li> <b></b></li>
<li>, Webhook </li>
<li><b></b>,</li>
<li></li>
</ol>
<p className="mt-1.5 pl-4 text-muted/70">
📖 :
<a href="https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot?lang=zh-CN" target="_blank" rel="noreferrer" className="text-accent hover:text-accent/80">
使
</a>
</p>
</details>
</div>
)}
</div>
{/* 占位渠道 — 不可点 */}
{[
{ name: '微信', hint: '公众号/企业微信', status: '开发中' },
{ name: 'QMT', hint: '量化交易终端', status: '待定' },
{ name: 'ptrade', hint: '量化交易终端', status: '待定' },
].map(ch => (
<div
key={ch.name}
className="flex items-center gap-2 rounded-btn border border-border/40 bg-base/20 px-2.5 py-2 opacity-60"
>
<input type="checkbox" disabled className="h-3 w-3 accent-accent" />
<span className="text-[11px] text-secondary">{ch.name}</span>
<span className="text-[9px] text-muted">{ch.hint}</span>
<span className="ml-auto rounded bg-muted/10 px-1 py-px text-[9px] text-muted">{ch.status}</span>
</div>
))}
</div>
</Card>
</div>
</div>
)
}
// ===== ToggleRow =====
function ToggleRow({
label,
desc,
checked,
onChange,
icon: Icon,
}: {
label: string
desc: string
checked: boolean
onChange: (v: boolean) => void
icon?: React.ComponentType<{ className?: string }>
}) {
return (
<div className="flex items-center justify-between gap-4 py-2">
<div className="min-w-0 flex items-start gap-2">
{Icon && <Icon className="h-3.5 w-3.5 text-secondary shrink-0 mt-0.5" />}
<div className="min-w-0">
<div className="text-sm text-foreground">{label}</div>
<div className="text-[11px] text-muted truncate">{desc}</div>
</div>
</div>
<button
onClick={() => onChange(!checked)}
className={`relative inline-flex h-5 w-9 items-center rounded-full shrink-0 transition-colors duration-200 ${
checked ? 'bg-accent' : 'bg-elevated'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform duration-200 ${
checked ? 'translate-x-[18px]' : 'translate-x-[3px]'
}`}
/>
</button>
</div>
)
}
// ===== 通用卡片 =====
interface CardProps {
icon: React.ComponentType<{ className?: string }>
title: string
badge?: string
right?: React.ReactNode
children: React.ReactNode
}
function Card({ icon: Icon, title, badge, right, children }: CardProps) {
return (
<section className="rounded-card border border-border bg-surface p-5">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2.5">
<Icon className="h-4 w-4 text-secondary" />
<h2 className="text-sm font-medium text-foreground">{title}</h2>
{badge && (
<span className="px-1.5 py-0.5 text-[10px] font-mono rounded bg-elevated text-muted">
{badge}
</span>
)}
</div>
{right}
</div>
{children}
</section>
)
}
+5 -2
View File
@@ -7,7 +7,6 @@ import { useState, useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { Settings2, Trash2, RefreshCw, Bell, Volume2, Info } from 'lucide-react' import { Settings2, Trash2, RefreshCw, Bell, Volume2, Info } from 'lucide-react'
import { usePreferences, useVersion } from '@/lib/useSharedQueries' import { usePreferences, useVersion } from '@/lib/useSharedQueries'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys' import { QK } from '@/lib/queryKeys'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/PageHeader'
import { refreshAlertToastConfig } from '@/components/AlertToast' import { refreshAlertToastConfig } from '@/components/AlertToast'
@@ -40,7 +39,11 @@ export function SettingsSystemPanel() {
const save = useCallback(async (cfg: Record<string, unknown>) => { const save = useCallback(async (cfg: Record<string, unknown>) => {
setSaving(true) setSaving(true)
try { try {
await api.updateRealtimeMonitorConfig(cfg) await fetch('/api/settings/preferences/realtime-monitor', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cfg),
})
qc.invalidateQueries({ queryKey: QK.preferences }) qc.invalidateQueries({ queryKey: QK.preferences })
} finally { } finally {
setSaving(false) setSaving(false)
-10
View File
@@ -16,16 +16,6 @@ export default defineConfig({
// dev 时 /api 转发到 FastAPI // dev 时 /api 转发到 FastAPI
'/api': { '/api': {
target: 'http://localhost:3018', target: 'http://localhost:3018',
// SSE 端点需要禁用缓冲
configure: (proxy) => {
proxy.on('proxyReq', (_proxyReq, req) => {
if (req.url?.includes('/stream')) {
_proxyReq.setHeader('Accept', 'text/event-stream')
_proxyReq.setHeader('Cache-Control', 'no-cache')
_proxyReq.setHeader('Connection', 'keep-alive')
}
})
},
}, },
'/health': 'http://localhost:3018', '/health': 'http://localhost:3018',
}, },