diff --git a/serve/backend/app/api/alerts.py b/serve/backend/app/api/alerts.py index fe0e4d4..67a6648 100644 --- a/serve/backend/app/api/alerts.py +++ b/serve/backend/app/api/alerts.py @@ -120,25 +120,5 @@ def seed_demo_alerts(request: Request, count: int = 12, recent: bool = True): }) alert_store.append_many(_data_dir(request), events) - # 同步推入 SSE 队列, 让所有连着 SSE 的客户端实时收到 (不依赖轮询) - qs = getattr(request.app.state, "quote_service", None) - if qs: - # 转成 SSE 推送格式 (和 _evaluate_monitors 一致) - sse_alerts = [{ - "source": ev["source"], - "type": ev["type"], - "rule_id": ev.get("rule_id"), - "symbol": ev["symbol"], - "name": ev["name"], - "message": ev["message"], - "price": ev["price"], - "change_pct": ev["change_pct"], - "signals": ev["signals"], - "severity": ev.get("severity", "info"), - } for ev in events] - with qs._lock: - qs._pending_alerts.extend(sse_alerts) - qs._alert_event.set() - return {"ok": True, "generated": len(events)} diff --git a/serve/backend/app/api/data.py b/serve/backend/app/api/data.py index cbe72d3..2d78a19 100644 --- a/serve/backend/app/api/data.py +++ b/serve/backend/app/api/data.py @@ -658,12 +658,6 @@ def clear_data(request: Request): # - 触发记录 alerts.jsonl from app.services import alert_store alert_store.clear(data_dir) - # - 待推送的实时通知队列 (进程内存) - qs = getattr(request.app.state, "quote_service", None) - if qs is not None: - with qs._lock: - qs._pending_alerts.clear() - # 清除 Polars 缓存 # 先 clear_cache 无条件清空内存 (refresh_cache 在磁盘无数据时会提前 return, # 导致 _enriched_cache 等旧数据残留 —— 清数据后看板仍显示旧数据的根因), diff --git a/serve/backend/app/api/intraday.py b/serve/backend/app/api/intraday.py deleted file mode 100644 index 64b5d8a..0000000 --- a/serve/backend/app/api/intraday.py +++ /dev/null @@ -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"} diff --git a/serve/backend/app/api/kline.py b/serve/backend/app/api/kline.py index 8e0261f..8e09ad9 100644 --- a/serve/backend/app/api/kline.py +++ b/serve/backend/app/api/kline.py @@ -137,16 +137,11 @@ def get_daily( logger.debug("单股除权因子拉取失败 %s: %s", symbol, e) enriched = compute_enriched(raw, factors=factors) rows = enriched.tail(days).to_dicts() - # 即使 live 模式也尝试追加实时蜡烛 - rows = _maybe_inject_live_candle(request, symbol, rows) resp = {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": rows, "source": "live"} return _attach_ext(resp, repo, symbol, ext_columns) rows = df.to_dicts() - # 追加/覆盖今日实时蜡烛 - rows = _maybe_inject_live_candle(request, symbol, rows) - resp = {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": rows, "source": "enriched"} return _attach_ext(resp, repo, symbol, ext_columns) @@ -216,77 +211,6 @@ def _attach_ext(resp: dict, repo, symbol: str, ext_columns: Optional[str]) -> di return resp -def _maybe_inject_live_candle(request: Request, symbol: str, rows: list[dict]) -> list[dict]: - """如果 QuoteService 有实时 enriched 数据, 用实时数据生成今日蜡烛并追加/覆盖。""" - qs = getattr(request.app.state, "quote_service", None) - if not qs: - return rows - - df_today, enriched_date = qs.get_enriched_today() - if df_today.is_empty(): - return rows - - # 非交易日(周末/假日)缓存的行情日期 != 今天,跳过注入避免产生重复蜡烛 - if not enriched_date or enriched_date != date.today(): - return rows - - # 查找该 symbol 的实时 enriched 行 - import polars as pl - try: - q = df_today.filter(pl.col("symbol") == symbol).to_dicts() - if not q: - return rows - q = q[0] - except Exception: # noqa: BLE001 - return rows - - close_price = q.get("close") - if not close_price or close_price <= 0: - return rows - - today_str = str(enriched_date) - - # enriched 行已包含 OHLCV + 全套指标, 直接用它 - # 修复: API 在非交易时段可能返回 open/high/low=0, 用 close 填充避免异常蜡烛 - raw_open = q.get("open") - raw_high = q.get("high") - raw_low = q.get("low") - live_row: dict = { - "date": today_str, - "symbol": symbol, - "open": raw_open if raw_open and raw_open > 0 else close_price, - "high": raw_high if raw_high and raw_high > 0 else close_price, - "low": raw_low if raw_low and raw_low > 0 else close_price, - "close": close_price, - "volume": q.get("volume"), - "amount": q.get("amount"), - "change_pct": q.get("change_pct"), - "is_live": True, - } - # 补上 enriched 的技术指标字段 - for key in ("ma5", "ma10", "ma20", "ma30", "ma60", - "macd_dif", "macd_dea", "macd_hist", - "kdj_k", "kdj_d", "kdj_j", - "boll_upper", "boll_lower", - "rsi_6", "rsi_14", "rsi_24", - "atr_14", "vol_ratio_5d"): - if key in q and q[key] is not None: - live_row[key] = q[key] - - # 如果已有今天的 enriched 行, 覆盖; 否则追加 - found = False - for i, r in enumerate(rows): - if str(r.get("date")) == today_str: - r.update(live_row) - found = True - break - - if not found: - rows.append(live_row) - - return rows - - class DailyBatchRequest: """批量日K请求。""" symbols: list[str] diff --git a/serve/backend/app/api/market_recap.py b/serve/backend/app/api/market_recap.py index 7215d23..8ec5c1d 100644 --- a/serve/backend/app/api/market_recap.py +++ b/serve/backend/app/api/market_recap.py @@ -46,8 +46,6 @@ async def analyze_market(request: Request, req: AnalyzeRequest): from datetime import date as date_cls repo = request.app.state.repo - quote_service = getattr(request.app.state, "quote_service", None) - depth_service = getattr(request.app.state, "depth_service", None) as_of = None if req.as_of: @@ -57,7 +55,7 @@ async def analyze_market(request: Request, req: AnalyzeRequest): raise HTTPException(400, f"as_of 格式应为 YYYY-MM-DD,收到: {req.as_of}") async def stream_gen(): - async for chunk in recap_market_stream(repo, quote_service, depth_service, as_of, req.focus): + async for chunk in recap_market_stream(repo, as_of, req.focus): yield chunk + "\n" return StreamingResponse( diff --git a/serve/backend/app/api/monitor_rules.py b/serve/backend/app/api/monitor_rules.py deleted file mode 100644 index b5e332c..0000000 --- a/serve/backend/app/api/monitor_rules.py +++ /dev/null @@ -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], - } diff --git a/serve/backend/app/api/overview.py b/serve/backend/app/api/overview.py index 3bd7ede..b43e342 100644 --- a/serve/backend/app/api/overview.py +++ b/serve/backend/app/api/overview.py @@ -222,68 +222,54 @@ def _score(value: float, low: float, high: float) -> int: return max(0, min(100, round((value - low) / (high - low) * 100))) -def _quote_status(request: Request) -> dict: - qs = getattr(request.app.state, "quote_service", None) - if not qs: - return {"enabled": False, "running": False, "quote_age_ms": None, "is_trading_hours": False} - return qs.status() - - def _index_quotes(request: Request, as_of: date | None = None) -> list[dict]: - qs = getattr(request.app.state, "quote_service", None) rows: list[dict] = [] - if qs and as_of is None: - df = qs.get_index_quotes(list(CORE_INDEX_SYMBOLS)) - if not df.is_empty(): - rows = df.to_dicts() - - if not rows: - repo = getattr(request.app.state, "repo", None) - if repo: - placeholders = ", ".join("?" for _ in CORE_INDEX_SYMBOLS) - try: - db_rows = repo.execute_all( - f""" - WITH ranked AS ( - SELECT symbol, date, close, - row_number() OVER (PARTITION BY symbol ORDER BY date DESC) AS rn - FROM kline_index_daily - WHERE symbol IN ({placeholders}) - AND (? IS NULL OR date <= ?) - ), latest AS ( - SELECT symbol, - max(CASE WHEN rn = 1 THEN date END) AS date, - max(CASE WHEN rn = 1 THEN close END) AS last_price, - max(CASE WHEN rn = 2 THEN close END) AS prev_close - FROM ranked - WHERE rn <= 2 - GROUP BY symbol - ) - SELECT symbol, date, last_price, prev_close - FROM latest - """, - [*CORE_INDEX_SYMBOLS, as_of, as_of], + repo = getattr(request.app.state, "repo", None) + if repo: + placeholders = ", ".join("?" for _ in CORE_INDEX_SYMBOLS) + try: + db_rows = repo.execute_all( + f""" + WITH ranked AS ( + SELECT symbol, date, close, + row_number() OVER (PARTITION BY symbol ORDER BY date DESC) AS rn + FROM kline_index_daily + WHERE symbol IN ({placeholders}) + AND (? IS NULL OR date <= ?) + ), latest AS ( + SELECT symbol, + max(CASE WHEN rn = 1 THEN date END) AS date, + max(CASE WHEN rn = 1 THEN close END) AS last_price, + max(CASE WHEN rn = 2 THEN close END) AS prev_close + FROM ranked + WHERE rn <= 2 + GROUP BY symbol ) - except Exception: # noqa: BLE001 - db_rows = [] - for symbol, dt, last_price, prev_close in db_rows: - change_amount = None - change_pct = None - lp = _finite(last_price) - pc = _finite(prev_close) - if lp is not None and pc not in (None, 0): - change_amount = lp - pc - change_pct = change_amount / pc * 100 - rows.append({ - "symbol": symbol, - "name": CORE_INDEX_NAMES.get(symbol), - "date": str(dt) if dt else None, - "last_price": lp, - "close": lp, - "prev_close": pc, - "change_amount": change_amount, - "change_pct": change_pct, - }) + SELECT symbol, date, last_price, prev_close + FROM latest + """, + [*CORE_INDEX_SYMBOLS, as_of, as_of], + ) + except Exception: # noqa: BLE001 + db_rows = [] + for symbol, dt, last_price, prev_close in db_rows: + change_amount = None + change_pct = None + lp = _finite(last_price) + pc = _finite(prev_close) + if lp is not None and pc not in (None, 0): + change_amount = lp - pc + change_pct = change_amount / pc * 100 + rows.append({ + "symbol": symbol, + "name": CORE_INDEX_NAMES.get(symbol), + "date": str(dt) if dt else None, + "last_price": lp, + "close": lp, + "prev_close": pc, + "change_amount": change_amount, + "change_pct": change_pct, + }) by_symbol = {r.get("symbol"): r for r in rows} out = [] @@ -351,8 +337,6 @@ def _build_overview(request: Request, as_of: date | None = None) -> dict: from app.services.market_overview_builder import build_market_overview return build_market_overview( repo=request.app.state.repo, - quote_service=getattr(request.app.state, "quote_service", None), - depth_service=getattr(request.app.state, "depth_service", None), as_of=as_of, ) diff --git a/serve/backend/app/api/rps.py b/serve/backend/app/api/rps.py index 558aa9a..2cb59e8 100644 --- a/serve/backend/app/api/rps.py +++ b/serve/backend/app/api/rps.py @@ -50,13 +50,11 @@ async def analyze_rotation(request: Request, req: AnalyzeRequest): {"type":"done"} """ repo = request.app.state.repo - quote_service = getattr(request.app.state, "quote_service", None) - depth_service = getattr(request.app.state, "depth_service", None) days = max(7, min(30, req.days)) async def stream_gen(): async for chunk in analyze_rotation_stream( - repo, days, req.focus, quote_service, depth_service, + repo, days, req.focus, ): yield chunk + "\n" diff --git a/serve/backend/app/api/screener.py b/serve/backend/app/api/screener.py index d35faef..3a40b3b 100644 --- a/serve/backend/app/api/screener.py +++ b/serve/backend/app/api/screener.py @@ -278,32 +278,16 @@ def get_cached( request: Request, ext_columns: Optional[str] = Query(None, description="逗号分隔: config_id.field_name"), ): - """读取策略结果缓存, 并叠加监控引擎本轮实时算出的结果。 + """读取策略结果缓存。 - - 盘后缓存 (strategy_cache.json): 非监控策略 / 页面秒加载用, run_all 写入。 - - 监控引擎内存结果 (latest_strategy_results): 实时行情每轮对「加入监控的策略」算出, - 不落盘 (避免与 read_cache 的 mtime 校验冲突), 在此直接叠加覆盖盘后结果。 - 被监控的策略拿到新鲜数据, 非监控策略仍用盘后缓存。 + - 盘后缓存 (strategy_cache.json): run_all 写入, 页面秒加载用。 """ data_dir = request.app.state.repo.store.data_dir cached = strategy_cache.read_cache(data_dir) if cached is None: cached = {"as_of": None, "results": {}, "updated_at": None} - # 叠加监控引擎内存里的实时结果 (若有), 用新鲜数据覆盖同策略的盘后结果 - monitor_engine = getattr(request.app.state, "monitor_engine", None) - if monitor_engine is not None: - realtime_results = monitor_engine.latest_strategy_results() - if realtime_results: - results = dict(cached.get("results") or {}) - results.update(realtime_results) - cached = dict(cached) - cached["results"] = results - # 有实时数据时, 以最新时间戳为准 - import time as _time - cached["updated_at"] = int(_time.time() * 1000) - - # 无任何数据 (盘后缓存空 + 无实时结果) → 返回空标记, 前端据此提示 + # 无任何数据 → 返回空标记, 前端据此提示 if not cached.get("results") and cached.get("as_of") is None: return {"as_of": None, "results": {}, "updated_at": None} @@ -503,34 +487,8 @@ def limit_ladder( count_up_raw = int(df.filter(pl.col("signal_limit_up").fill_null(False)).height) if "signal_limit_up" in df.columns else 0 count_down_raw = int(df.filter(pl.col("signal_limit_down").fill_null(False)).height) if "signal_limit_down" in df.columns else 0 - # 双方向 sealed 修正: 减去各自的假涨停(假涨停已归炸板, 不计入涨停数) - depth_svc_global = getattr(request.app.state, "depth_service", None) - fake_up = 0 - fake_down = 0 - sealed_up_ready = False - sealed_down_ready = False - if depth_svc_global: - up_map = depth_svc_global.get_sealed_map(as_of, is_down=False) - down_map = depth_svc_global.get_sealed_map(as_of, is_down=True) - sealed_up_ready = bool(up_map) and depth_svc_global.is_sealed_ready(as_of) - sealed_down_ready = bool(down_map) and depth_svc_global.is_sealed_ready(as_of) - if up_map: - fake_up = sum(1 for v in up_map.values() if v.get("sealed") is False) - if down_map: - fake_down = sum(1 for v in down_map.values() if v.get("sealed") is False) - count_up = count_up_raw - fake_up if sealed_up_ready else count_up_raw - count_down = count_down_raw - fake_down if sealed_down_ready else count_down_raw - - # 双方向 sealed 明细(供前端弹窗同时显示涨跌停) - def _count_sealed(m: dict, ready: bool): - if not m or not ready: - return {"real": 0, "fake": 0, "pending": 0} - real = sum(1 for v in m.values() if v.get("sealed") is True) - fake = sum(1 for v in m.values() if v.get("sealed") is False) - pending = sum(1 for v in m.values() if v.get("sealed") is None) - return {"real": real, "fake": fake, "pending": pending} - sealed_counts_up = _count_sealed(up_map, sealed_up_ready) - sealed_counts_down = _count_sealed(down_map, sealed_down_ready) + count_up = count_up_raw + count_down = count_down_raw # 加载前一日数据获取 prev consecutive_limit_ups/downs prev_consec: pl.DataFrame = pl.DataFrame() @@ -569,69 +527,11 @@ def limit_ladder( df = df.filter(pl.col("status").is_not_null() & (pl.col("boards") > 0)) - # ── 五档 sealed 叠加(独立旁路, 不改 signal_limit_up) ── - # 假涨停(收盘价=涨停价但卖一有量)从 limit 降级为 broken(归炸板视图) - # 真涨停保留 + 附封单量; sealed=null(待确认/降级)保持原状 - depth_svc = getattr(request.app.state, "depth_service", None) - sealed_ready = False - sealed_age: float | None = None - if depth_svc: - sealed_map = depth_svc.get_sealed_map(as_of, is_down=is_down) - sealed_ready = bool(sealed_map) and depth_svc.is_sealed_ready(as_of) - sealed_age = depth_svc.get_sealed_age(as_of) if sealed_ready else None - - if sealed_map: - # 构建 sealed 列(symbol → sealed bool, vol) - sym_sealed = {s: v.get("sealed") for s, v in sealed_map.items()} - sym_vol = {s: v.get("vol") for s, v in sealed_map.items()} - - # JOIN sealed: 对每只 status=main 的票, 看 sealed 值 - sealed_rows = pl.DataFrame({ - "symbol": list(sym_sealed.keys()), - "_sealed": list(sym_sealed.values()), - "_sealed_vol": list(sym_vol.values()), - }) if sym_sealed else pl.DataFrame() - - if not sealed_rows.is_empty(): - df = df.join(sealed_rows, on="symbol", how="left") - # 假涨停(main 状态但 sealed=False)→ 降级为 broken - df = df.with_columns( - pl.when( - (pl.col("status") == status_main) - & pl.col("_sealed").is_not_null() - & (pl.col("_sealed") == False) # noqa: E712 - ).then(pl.lit(status_broken)) - .otherwise(pl.col("status")).alias("status"), - # sealed_status: real/fake/pending/null - pl.when( - (pl.col("status") == status_main) - & (pl.col("_sealed") == True) # noqa: E712 - ).then(pl.lit("real")) - .when( - (pl.col("_sealed") == False) # noqa: E712 - ).then(pl.lit("fake")) - .when( - (pl.col("status") == status_main) - & pl.col("_sealed").is_null() - ).then(pl.lit("pending")) - .otherwise(None).alias("sealed_status"), - pl.col("_sealed_vol").alias("sealed_vol"), - ).drop(["_sealed", "_sealed_vol"]) - else: - df = df.with_columns( - pl.lit(None).alias("sealed_status"), - pl.lit(None).alias("sealed_vol"), - ) - else: - df = df.with_columns( - pl.lit(None).alias("sealed_status"), - pl.lit(None).alias("sealed_vol"), - ) - else: - df = df.with_columns( - pl.lit(None).alias("sealed_status"), - pl.lit(None).alias("sealed_vol"), - ) + # Add null columns for sealed_status and sealed_vol (no longer used) + df = df.with_columns( + pl.lit(None).alias("sealed_status"), + pl.lit(None).alias("sealed_vol"), + ) # 动态 JOIN 扩展数据 ext_specs = _parse_ext_columns(ext_columns) if ext_columns else [] @@ -700,15 +600,6 @@ def limit_ladder( "tiers": tier_list, "counts": {"up": count_up, "down": count_down}, "counts_raw": {"up": count_up_raw, "down": count_down_raw}, - "sealed_ready": sealed_ready, - "sealed_age": round(sealed_age, 0) if sealed_age is not None else None, - "sealed_counts": { - "real": sum(1 for t in tier_list for s in t.get("stocks", []) if s.get("sealed_status") == "real"), - "fake": sum(1 for t in tier_list for s in t.get("stocks", []) if s.get("sealed_status") == "fake"), - "pending": sum(1 for t in tier_list for s in t.get("stocks", []) if s.get("sealed_status") == "pending"), - }, - "sealed_counts_up": sealed_counts_up, - "sealed_counts_down": sealed_counts_down, } diff --git a/serve/backend/app/api/settings.py b/serve/backend/app/api/settings.py index 39c51ea..1139af4 100644 --- a/serve/backend/app/api/settings.py +++ b/serve/backend/app/api/settings.py @@ -308,12 +308,6 @@ def clear_ai_settings() -> dict: # ===== 偏好设置 ===== -def _realtime_allowed() -> bool: - """当前档位是否允许实时行情(none/free 不允许)。""" - from app.services.quote_service import QuoteService - return QuoteService.is_realtime_allowed() - - class MinuteSyncPrefs(BaseModel): minute_sync_enabled: bool minute_sync_days: int = 5 @@ -324,17 +318,12 @@ def get_preferences() -> dict: """返回用户偏好设置。""" from app.services import preferences return { - "realtime_quotes_enabled": preferences.get_realtime_quotes_enabled(), - "realtime_allowed": _realtime_allowed(), "indices_nav_pinned": preferences.get_indices_nav_pinned(), "minute_sync_enabled": preferences.get_minute_sync_enabled(), "minute_sync_days": preferences.get_minute_sync_days(), "daily_data_provider": preferences.get_daily_data_provider(), "adj_factor_provider": preferences.get_adj_factor_provider(), "minute_data_provider": preferences.get_minute_data_provider(), - "realtime_data_provider": preferences.get_realtime_data_provider(), - "realtime_watchlist_symbols": preferences.get_realtime_watchlist_symbols(), - **preferences.get_realtime_quote_scope(), "pipeline_pull_a_share": preferences.get_pipeline_pull_a_share(), "pipeline_pull_etf": preferences.get_pipeline_pull_etf(), "pipeline_pull_index": preferences.get_pipeline_pull_index(), @@ -345,20 +334,10 @@ def get_preferences() -> dict: "index_daily_batch_size": preferences.get_index_daily_batch_size(), "watchlist_columns": preferences.get_watchlist_columns(), "screener_result_columns": preferences.get_screener_result_columns(), - "sse_refresh_pages": preferences.get_sse_refresh_pages(), - "strategy_monitor_enabled": preferences.get_strategy_monitor_enabled(), - "strategy_monitor_ids": preferences.get_strategy_monitor_ids(), - "system_notify_enabled": preferences.get_system_notify_enabled(), - "feishu_webhook_url": preferences.get_feishu_webhook_url(), - "feishu_webhook_secret": preferences.get_feishu_webhook_secret(), - "webhook_enabled_default": preferences.get_webhook_enabled_default(), "sidebar_index_symbols": preferences.get_sidebar_index_symbols(), "nav_order": preferences.get_nav_order(), "nav_hidden": preferences.get_nav_hidden(), "screener_auto_run": preferences.get_screener_auto_run(), - "limit_ladder_monitor_enabled": preferences.get_limit_ladder_monitor_enabled(), - "depth_polling_interval": preferences.get_depth_polling_interval(), - "depth_finalize_time": preferences.get_depth_finalize_time(), "review_schedule": preferences.get_review_schedule(), "review_push_channels": preferences.get_review_push_channels(), } @@ -437,69 +416,6 @@ def update_minute_sync(req: MinuteSyncPrefs) -> dict: } -class RealtimeQuotesPrefs(BaseModel): - realtime_quotes_enabled: bool - - -class RealtimeQuoteScopePrefs(BaseModel): - realtime_pull_stock: bool | None = None - realtime_pull_etf: bool | None = None - realtime_pull_index: bool | None = None - realtime_index_mode: str | None = None - realtime_index_symbols: list[str] | None = None - - -@router.put("/preferences/realtime-quotes") -def update_realtime_quotes(req: RealtimeQuotesPrefs, request: Request) -> dict: - """保存全局实时行情开关。 - - none 档无实时行情权限;free 档开启自选股实时;starter+ 开启全市场实时。 - 前端据此把开关置灰 / 回弹。 - """ - from app.services import preferences - qs = getattr(request.app.state, "quote_service", None) - - allowed = qs.is_realtime_allowed() if qs else True - if req.realtime_quotes_enabled and not allowed: - # 当前档位不允许开启实时行情 — 强制关闭 - preferences.save({"realtime_quotes_enabled": False}) - if qs: - qs.disable() - return {"realtime_quotes_enabled": False, "realtime_allowed": False} - if req.realtime_quotes_enabled and qs and qs.realtime_mode() == "watchlist" and not preferences.get_realtime_watchlist_symbols(): - preferences.save({"realtime_quotes_enabled": False}) - return {"realtime_quotes_enabled": False, "realtime_allowed": True, "mode": "watchlist", "error": "watchlist_empty"} - - preferences.save({"realtime_quotes_enabled": req.realtime_quotes_enabled}) - if qs: - if req.realtime_quotes_enabled: - qs.enable() - else: - qs.disable() - - return {"realtime_quotes_enabled": req.realtime_quotes_enabled, "realtime_allowed": allowed} - - -@router.put("/preferences/realtime-quote-scope") -def update_realtime_quote_scope(req: RealtimeQuoteScopePrefs) -> dict: - """保存盘中实时行情范围;独立于盘后管道范围。""" - from app.services import preferences - cfg = req.model_dump(exclude_none=True) - return preferences.set_realtime_quote_scope(cfg) - - -class RealtimeWatchlistPrefs(BaseModel): - symbols: list[str] = [] - - -@router.put("/preferences/realtime-watchlist") -def update_realtime_watchlist(req: RealtimeWatchlistPrefs) -> dict: - """兼容旧入口;Free 实时标的由自选页前 5 个决定。""" - from app.services import preferences - symbols = preferences.set_realtime_watchlist_symbols(req.symbols) - return {"realtime_watchlist_symbols": symbols} - - class IndicesNavPinnedPrefs(BaseModel): indices_nav_pinned: bool @@ -513,45 +429,6 @@ def update_indices_nav_pinned(req: IndicesNavPinnedPrefs) -> dict: return {"indices_nav_pinned": req.indices_nav_pinned} -class RealtimeMonitorConfigIn(BaseModel): - sse_refresh_pages: dict[str, bool] | None = None - strategy_monitor_enabled: bool | None = None - strategy_monitor_ids: list[str] | None = None - sidebar_index_symbols: list[str] | None = None - screener_auto_run: bool | None = None - - -@router.put("/preferences/realtime-monitor") -def update_realtime_monitor_config(req: RealtimeMonitorConfigIn, request: Request) -> dict: - """更新实时监控配置。策略监控统一迁移为 MonitorRule,由监控引擎评估。""" - from app.services import preferences - - cfg = req.model_dump(exclude_none=True) - result = preferences.set_realtime_monitor_config(cfg) - - # 策略监控开关/池变化 → 同步迁移为 type=strategy 规则 + reload 引擎 - if req.strategy_monitor_ids is not None or req.strategy_monitor_enabled is not None: - monitor_engine = getattr(request.app.state, "monitor_engine", None) - strategy_engine = getattr(request.app.state, "strategy_engine", None) - data_dir = request.app.state.repo.store.data_dir - if monitor_engine is not None and strategy_engine is not None: - from app.strategy import monitor_rules as mr_store - try: - if preferences.get_strategy_monitor_enabled(): - ids = preferences.get_strategy_monitor_ids() - names = {s.id: s.name for s in strategy_engine.list_strategies()} - mr_store.migrate_strategy_monitors(data_dir, ids, names) - else: - # 关闭策略监控: 停用所有策略规则 - mr_store.migrate_strategy_monitors(data_dir, [], {}) - # reload 规则到引擎 - monitor_engine.set_rules(mr_store.load_all(data_dir)) - except Exception: - pass - - return result - - class PipelinePullTypesIn(BaseModel): """盘后管道拉取内容开关(A股 / ETF / 指数 独立控制)。""" pipeline_pull_a_share: bool | None = None @@ -580,97 +457,6 @@ def update_pipeline_index_symbols(req: PipelineIndexSymbolsIn) -> dict: return {"pipeline_index_symbols": symbols} -class QuoteIntervalIn(BaseModel): - interval: float - - -class SystemNotifyPrefsIn(BaseModel): - enabled: bool - - -@router.put("/preferences/system-notify") -def update_system_notify(req: SystemNotifyPrefsIn) -> dict: - """系统通知开关 — 开启后监控告警同时推送到操作系统通知中心。 - - 纯偏好, 无副作用 (不像策略监控要迁移规则), 直接落盘即可。 - quote_service 在每轮告警评估时读此开关决定是否发系统通知。 - """ - from app.services import preferences - saved = preferences.set_system_notify_enabled(req.enabled) - return {"system_notify_enabled": saved} - - -class FeishuWebhookPrefsIn(BaseModel): - url: str - secret: str = "" - - -@router.put("/preferences/feishu-webhook") -def update_feishu_webhook(req: FeishuWebhookPrefsIn) -> dict: - """飞书 Webhook 地址 + 签名密钥 — 全局一处配置, 所有启用推送的监控规则共用。 - - - url: 传入空串表示清空配置; 非空则需为合法的飞书自定义机器人地址。 - - secret: 机器人启用了「签名校验」时填密钥, 留空表示不验签。 - """ - from app.services import preferences - from app.services import webhook_adapter - - url = (req.url or "").strip() - if url and not webhook_adapter.is_valid_feishu_url(url): - raise HTTPException( - status_code=400, - detail="Webhook 地址非法, 需为飞书自定义机器人地址 " - "(https://open.feishu.cn/open-apis/bot/v2/hook/...)", - ) - saved_url = preferences.set_feishu_webhook_url(url) - saved_secret = preferences.set_feishu_webhook_secret((req.secret or "").strip()) - return {"feishu_webhook_url": saved_url, "feishu_webhook_secret": saved_secret} - - -class WebhookEnabledDefaultIn(BaseModel): - enabled: bool - - -@router.put("/preferences/webhook-enabled-default") -def update_webhook_enabled_default(req: WebhookEnabledDefaultIn) -> dict: - """新建监控规则时是否默认勾选「飞书推送」。 - - 数据模型当前只有飞书一个可用渠道 (QMT/ptrade 待定),故此处仅一个布尔。 - 单条规则仍可在规则编辑页独立修改此项。 - """ - from app.services import preferences - - saved = preferences.set_webhook_enabled_default(req.enabled) - return {"webhook_enabled_default": saved} - - -@router.put("/preferences/quote-interval") -def update_quote_interval(req: QuoteIntervalIn, request: Request) -> dict: - """更新行情轮询间隔。按档位自动 clamp。""" - qs = getattr(request.app.state, "quote_service", None) - if not qs: - return {"interval": req.interval, "min_interval": qs.get_min_interval(), "max_interval": 60.0} - clamped = qs.set_interval(req.interval) - return { - "interval": clamped, - "min_interval": qs.get_min_interval(), - "max_interval": qs.MAX_INTERVAL, - } - - -@router.get("/preferences/quote-interval") -def get_quote_interval(request: Request) -> dict: - """获取当前行情轮询间隔和档位限制。""" - qs = getattr(request.app.state, "quote_service", None) - if not qs: - return {"interval": 10.0, "min_interval": 5.0, "max_interval": 60.0} - return { - "interval": qs._interval, - "min_interval": qs.get_min_interval(), - "max_interval": qs.MAX_INTERVAL, - } - - class TestEndpointIn(BaseModel): url: str # 测试轮数;不传时取 endpoints.json 的 testRounds(默认 5) @@ -941,85 +727,6 @@ def update_index_daily_batch_size(req: IndexDailyBatchSizeIn) -> dict: return {"index_daily_batch_size": size} -# ── 五档盘口 sealed 配置 ────────────────────────────── - -class LimitLadderMonitorIn(BaseModel): - enabled: bool - - -@router.put("/preferences/limit-ladder-monitor") -def update_limit_ladder_monitor(req: LimitLadderMonitorIn, request: Request) -> dict: - """连板梯队 5 档监控开关。开启→启动 depth 轮询, 关闭→停止。""" - from app.services import preferences - preferences.save({"limit_ladder_monitor_enabled": req.enabled}) - - # 立即应用: 启停 depth 轮询线程 - depth_svc = getattr(request.app.state, "depth_service", None) - if depth_svc: - depth_svc.apply_monitor_toggle(req.enabled) - - return {"limit_ladder_monitor_enabled": req.enabled} - - -@router.post("/preferences/limit-ladder-monitor/run") -def run_limit_ladder_fix(request: Request) -> dict: - """立即手动修正一次真假板(拉取五档盘口 + 更新缓存)。需 Pro+。""" - from app.tickflow.capabilities import Cap - capset = request.app.state.capabilities - capset.require(Cap.DEPTH5_BATCH) # 无能力抛 CapabilityDenied(403) - - depth_svc = getattr(request.app.state, "depth_service", None) - if not depth_svc: - raise HTTPException(status_code=503, detail="depth 服务未初始化") - return depth_svc.run_once() - - -class DepthPollingIntervalIn(BaseModel): - interval: float - - -@router.put("/preferences/depth-polling-interval") -def update_depth_polling_interval(req: DepthPollingIntervalIn, request: Request) -> dict: - """保存五档盘口盘中轮询间隔(秒)。需 Pro+。""" - from app.tickflow.capabilities import Cap - request.app.state.capabilities.require(Cap.DEPTH5_BATCH) - - from app.services import preferences - interval = preferences.set_depth_polling_interval(req.interval) - return {"depth_polling_interval": interval} - - -class DepthFinalizeTimeIn(BaseModel): - hour: int - minute: int - - -@router.put("/preferences/depth-finalize-time") -def update_depth_finalize_time(req: DepthFinalizeTimeIn, request: Request) -> dict: - """保存盘后 sealed 定版时间(范围15:01~18:00)并立即 reschedule。需 Pro+。""" - from app.tickflow.capabilities import Cap - request.app.state.capabilities.require(Cap.DEPTH5_BATCH) - - from app.services import preferences - sched = preferences.set_depth_finalize_time(req.hour, req.minute) - - from apscheduler.triggers.cron import CronTrigger - scheduler = getattr(request.app.state, "scheduler", None) - if scheduler: - scheduler.reschedule_job( - "depth_finalize", - trigger=CronTrigger( - day_of_week="mon-fri", - hour=sched["hour"], - minute=sched["minute"], - timezone="Asia/Shanghai", - ), - ) - logger.info("depth_finalize rescheduled to %02d:%02d mon-fri", sched["hour"], sched["minute"]) - - return sched - - class ReviewScheduleIn(BaseModel): enabled: bool hour: int diff --git a/serve/backend/app/api/strategy.py b/serve/backend/app/api/strategy.py index b355209..c3865c7 100644 --- a/serve/backend/app/api/strategy.py +++ b/serve/backend/app/api/strategy.py @@ -17,7 +17,6 @@ from app.strategy import config as strategy_config from app.strategy.engine import StrategyEngine, StrategyDef from app.strategy.ai_generator import AIStrategyGenerator from app.strategy.prompt_builder import build_step1, build_step2 -from app.strategy.monitor import StrategyMonitorService, StrategyAlert router = APIRouter(prefix="/api/strategies", tags=["strategies"]) @@ -31,13 +30,6 @@ def _get_engine(request: Request) -> StrategyEngine: return engine -def _get_monitor(request: Request) -> StrategyMonitorService: - mon = getattr(request.app.state, "strategy_monitor", None) - if not mon: - raise HTTPException(status_code=503, detail="策略监控未初始化") - return mon - - def _data_dir(request: Request) -> Path: return request.app.state.repo.store.data_dir @@ -427,8 +419,7 @@ def delete_strategy(strategy_id: str, request: Request): # ── 监控 ───────────────────────────────────────────────────────────── -# 注: 策略监控已统一迁移到 MonitorRuleEngine (监控通知页), 旧的 start/stop/status -# 路由已移除。StrategyMonitorService 类保留 (其 _check_signals 被 MonitorRuleEngine 复用)。 +# 注: 策略监控已移除。 # ── 热重载 ─────────────────────────────────────────────────────────── diff --git a/serve/backend/app/data_providers/base.py b/serve/backend/app/data_providers/base.py index c17ca21..98352f9 100644 --- a/serve/backend/app/data_providers/base.py +++ b/serve/backend/app/data_providers/base.py @@ -21,7 +21,6 @@ class ProviderCapabilities: daily: bool = False adj_factor: bool = False minute: bool = False - realtime: bool = False financial: bool = False @@ -59,10 +58,3 @@ class MarketDataProvider(Protocol): freq: str = "1m", ) -> pl.DataFrame: """Return normalized minute K rows. Implementations may return empty.""" - - def get_realtime( - self, - universes: list[str] | None = None, - symbols: list[str] | None = None, - ) -> pl.DataFrame: - """Return normalized realtime quotes. Implementations may return empty.""" diff --git a/serve/backend/app/data_providers/tickflow_provider.py b/serve/backend/app/data_providers/tickflow_provider.py index a086949..6303cc5 100644 --- a/serve/backend/app/data_providers/tickflow_provider.py +++ b/serve/backend/app/data_providers/tickflow_provider.py @@ -22,7 +22,6 @@ class TickFlowProvider: daily=True, adj_factor=True, minute=True, - realtime=True, financial=True, ) @@ -103,18 +102,3 @@ class TickFlowProvider: # Existing minute sync remains in app.services.kline_sync for now. return pl.DataFrame() - def get_realtime( - self, - universes: list[str] | None = None, - symbols: list[str] | None = None, - ) -> pl.DataFrame: - tf = get_client() - if universes and symbols: - raise ValueError("TickFlow realtime accepts either universes or symbols, not both") - if universes: - resp = tf.quotes.get_by_universes(universes=universes) - elif symbols: - resp = tf.quotes.get(symbols=symbols) - else: - return pl.DataFrame() - return pl.DataFrame(resp or []) diff --git a/serve/backend/app/jobs/daily_pipeline.py b/serve/backend/app/jobs/daily_pipeline.py index a8ad488..ec8876e 100644 --- a/serve/backend/app/jobs/daily_pipeline.py +++ b/serve/backend/app/jobs/daily_pipeline.py @@ -558,15 +558,11 @@ REVIEW_JOB_ID = "scheduled_review" async def _run_scheduled_review(repo) -> None: - """定时复盘 job: 流式生成复盘 → 实时推 SSE(开着页面可见) → 落盘归档 → 推飞书。 + """定时复盘 job: 流式生成复盘 → 落盘归档 → 推飞书。 - 与手动「生成复盘」体验一致: 流式事件经 quote_service.push_review_event → - /api/intraday/stream 的 review_progress 事件 → 前端 reviewStore, 用户开着复盘页 - 即可看到报告边生成边显示, 切走再回来也能看到生成中/已生成。 - LLM 偶发断流(peer closed connection)时自动重试最多 2 次。 + 与手动「生成复盘」体验一致。LLM 偶发断流(peer closed connection)时自动重试最多 2 次。 任何异常都吞掉只记日志, 绝不影响调度器主循环。 """ - import json try: from app.services import market_recap_reports @@ -577,18 +573,9 @@ async def _run_scheduled_review(repo) -> None: logger.info("scheduled review skipped: AI key not configured") return - app_state = _get_app_state() - quote_service = getattr(app_state, "quote_service", None) if app_state else None - depth_service = getattr(app_state, "depth_service", None) if app_state else None - - content, meta = await _stream_review_with_retry(repo, quote_service, depth_service) + content, meta = await _stream_review_with_retry(repo) if not content: logger.warning("scheduled review produced no content (meta=%s)", meta) - # 通知前端进入 error 态(若有页面在听) - if quote_service: - quote_service.push_review_event(json.dumps( - {"type": "error", "message": "复盘生成失败,请稍后手动重试"}, - ensure_ascii=False)) return # 落盘: 与手动生成完全相同的归档格式 @@ -602,34 +589,17 @@ async def _run_scheduled_review(repo) -> None: }) logger.info("scheduled review saved: as_of=%s", meta.get("as_of")) - # 通知前端: 生成完成且已归档(archived=true 让前端只刷新列表, 不重复归档) - if quote_service: - quote_service.push_review_event(json.dumps( - {"type": "done", "archived": True}, ensure_ascii=False)) - # 推送到飞书(可选): 运行时读取配置, 用户改设置下次触发即生效。 # 失败静默降级, 不影响已归档的报告。 _maybe_push_review(content, meta) except Exception as e: # noqa: BLE001 logger.exception("scheduled review failed: %s", e) - # 兜底: 异常时通知前端停止「生成中」状态, 避免页面卡在 streaming - try: - app_state = _get_app_state() - qs = getattr(app_state, "quote_service", None) if app_state else None - if qs: - import json as _json - qs.push_review_event(_json.dumps( - {"type": "error", "message": "复盘生成异常,请稍后手动重试"}, - ensure_ascii=False)) - except Exception: # noqa: BLE001 - pass -async def _stream_review_with_retry(repo, quote_service, depth_service) -> tuple[str, dict]: - """流式生成复盘, 每个事件推 SSE + 累积内容。LLM 断流时最多重试 2 次。 +async def _stream_review_with_retry(repo) -> tuple[str, dict]: + """流式生成复盘, 累积内容。LLM 断流时最多重试 2 次。 - 返回 (content, meta)。重试时推一个 retry 事件让前端清空已累积内容重新开始。 - 成功(收到 done/无 error)或耗尽重试后返回。 + 返回 (content, meta)。成功(收到 done/无 error)或耗尽重试后返回。 """ import asyncio import json @@ -643,14 +613,10 @@ async def _stream_review_with_retry(repo, quote_service, depth_service) -> tuple content_parts = [] # 每次重试重新累积 failed = False try: - async for evt_json in recap_market_stream(repo, quote_service, depth_service): + async for evt_json in recap_market_stream(repo): evt = json.loads(evt_json) t = evt.get("type") - # 推给前端(让开着页面的用户实时看到, 与手动一致) - if quote_service: - quote_service.push_review_event(evt_json) - if t == "meta": last_meta = evt elif t == "delta" and evt.get("content"): @@ -675,10 +641,6 @@ async def _stream_review_with_retry(repo, quote_service, depth_service) -> tuple # 失败: 决定是否重试 if attempt < max_attempts: logger.info("scheduled review retrying in 3s (attempt %d → %d)", attempt, attempt + 1) - # 通知前端: 即将重试, 清空已累积内容重新开始 - if quote_service: - quote_service.push_review_event(json.dumps( - {"type": "retry", "attempt": attempt + 1}, ensure_ascii=False)) await asyncio.sleep(3) # 耗尽重试, 返回已累积内容(可能为空)和最后 meta @@ -790,24 +752,6 @@ def start_scheduler(repo: KlineRepository, capset: CapabilitySet) -> AsyncIOSche replace_existing=True, ) - # 盘后: 五档盘口 sealed 定版(时间由偏好决定, 默认15:02, 范围15:01~18:00) - depth_sched = preferences.get_depth_finalize_time() - - def _depth_finalize(): - depth_svc = getattr(_get_app_state(), "depth_service", None) if _get_app_state() else None - if depth_svc: - depth_svc.finalize() - - scheduler.add_job( - _depth_finalize, - trigger=CronTrigger(day_of_week="mon-fri", - hour=depth_sched["hour"], minute=depth_sched["minute"], - timezone="Asia/Shanghai"), - id="depth_finalize", - misfire_grace_time=3600, - replace_existing=True, - ) - # 定时复盘 (AI 大盘复盘报告): 工作日到点自动生成并归档。 # 默认关闭 —— 仅当用户在复盘页开启时才注册 job。 # 复用 recap_market_once(非流式) + market_recap_reports.save_report(落盘)。 @@ -819,9 +763,8 @@ def start_scheduler(repo: KlineRepository, capset: CapabilitySet) -> AsyncIOSche review_sched["hour"], review_sched["minute"]) scheduler.start() - logger.info("scheduler started; instruments@%02d:%02d, pipeline@%02d:%02d, depth@%02d:%02d mon-fri", - inst_sched["hour"], inst_sched["minute"], sched["hour"], sched["minute"], - depth_sched["hour"], depth_sched["minute"]) + logger.info("scheduler started; instruments@%02d:%02d, pipeline@%02d:%02d mon-fri", + inst_sched["hour"], inst_sched["minute"], sched["hour"], sched["minute"]) return scheduler @@ -830,7 +773,7 @@ _app_state_ref = None def set_app_state(app_state) -> None: - """lifespan 注册 app.state 引用, 供 scheduled job 访问 depth_service 等单例。""" + """lifespan 注册 app.state 引用, 供 scheduled job 访问单例。""" global _app_state_ref _app_state_ref = app_state diff --git a/serve/backend/app/main.py b/serve/backend/app/main.py index d863c26..e180569 100644 --- a/serve/backend/app/main.py +++ b/serve/backend/app/main.py @@ -11,11 +11,10 @@ from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from app import __version__ -from app.api import analysis, auth as auth_api, backtest, data, ext_data, financials, indices, intraday, kline, market_recap, monitor_rules, alerts, overview, pipeline, rps, screener, settings as settings_api, signals, stock_analysis, strategy, watchlist +from app.api import analysis, auth as auth_api, 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.config import settings from app.jobs import daily_pipeline -from app.services.quote_service import QuoteService from app.tickflow import client as tf_client from app.tickflow.policy import detect_capabilities from app.tickflow.repository import DataStore, KlineRepository @@ -56,42 +55,14 @@ async def lifespan(app: FastAPI): app.state.capabilities = capset logger.info("ready; %d capabilities active", len(capset.all())) - # 全局行情服务 - qs = QuoteService() - app.state.quote_service = qs - qs.set_repo(repo) - qs.boot_check() - - # QuoteService 需要访问 strategy_monitor 等单例 - # 先创建 strategy_monitor,再注入 app.state - from app.strategy.monitor import StrategyMonitorService - strategy_monitor = StrategyMonitorService() - app.state.strategy_monitor = strategy_monitor - qs.set_app_state(app.state) - - # 五档盘口 sealed 服务(真假涨停/跌停, 独立旁路线) - from app.services.depth_service import DepthService - depth_service = DepthService() - depth_service.set_repo(repo) - depth_service.set_app_state(app.state) - app.state.depth_service = depth_service - # 启动调度器(若 enriched 数据为空,首次启动可手动 POST /api/pipeline/run) try: - daily_pipeline.set_app_state(app.state) # 供 depth_finalize job 访问 depth_service scheduler = daily_pipeline.start_scheduler(repo, capset) app.state.scheduler = scheduler except Exception as e: # noqa: BLE001 logger.warning("scheduler not started: %s", e) app.state.scheduler = None - # depth sealed: 启动补跑(当天文件不存在) + 盘中轮询(有能力时) - try: - depth_service.boot_check() - depth_service.start_polling() - except Exception as e: # noqa: BLE001 - logger.warning("depth_service init failed: %s", e) - # 扩展数据定时拉取 from app.services.ext_pull import pull_scheduler pull_scheduler.start(store.data_dir) @@ -114,7 +85,6 @@ async def lifespan(app: FastAPI): # 策略引擎 from app.strategy.engine import StrategyEngine - from app.strategy.monitor import StrategyMonitorService from app.services.screener import ScreenerService _screener_svc = ScreenerService(repo) @@ -131,36 +101,6 @@ async def lifespan(app: FastAPI): app.state.strategy_engine = strategy_engine logger.info("strategy engine loaded: %d strategies", len(strategy_engine.list_strategies())) - # 通用监控规则引擎: 启动时 reload 规则到内存态 (修复重启后告警失效) - from app.strategy.monitor import MonitorRuleEngine - from app.strategy import monitor_rules as mr_store - from app.services import preferences - monitor_engine = MonitorRuleEngine() - monitor_engine.set_strategy_engine(strategy_engine) - monitor_engine.set_data_dir(store.data_dir) - # 复用 ScreenerService 的历史窗口加载器 (三级缓存, 启动预计算命中 ~0ms), - # 让声明 filter_history 的策略 (如反包) 也能在实时监控里跑选股 → 盘中触发通知。 - monitor_engine.set_history_loader(_screener_svc._load_enriched_history) - - # 自动迁移: 把旧 strategy_monitor_ids 同步为 type=strategy 规则 (统一到监控页) - try: - if preferences.get_strategy_monitor_enabled(): - ids = preferences.get_strategy_monitor_ids() - if ids: - names = {s.id: s.name for s in strategy_engine.list_strategies()} - mr_store.migrate_strategy_monitors(store.data_dir, ids, names) - logger.info("strategy monitor migrated: %d strategies", len(ids)) - except Exception as e: # noqa: BLE001 - logger.warning("strategy monitor migration failed: %s", e) - - try: - rules = mr_store.load_all(store.data_dir) - monitor_engine.set_rules(rules) - logger.info("monitor engine loaded: %d rules", monitor_engine.rule_count) - except Exception as e: # noqa: BLE001 - logger.warning("monitor engine load failed: %s", e) - app.state.monitor_engine = monitor_engine - yield if app.state.scheduler: @@ -171,12 +111,6 @@ async def lifespan(app: FastAPI): fsc = getattr(app.state, "financial_scheduler", None) if fsc: fsc.stop() - qs = getattr(app.state, "quote_service", None) - if qs: - qs.stop() - dsvc = getattr(app.state, "depth_service", None) - if dsvc: - dsvc.stop_polling() logger.info("shutdown") @@ -251,7 +185,6 @@ app.include_router(kline.router) app.include_router(watchlist.router) app.include_router(screener.router) app.include_router(backtest.router) -app.include_router(intraday.router) app.include_router(indices.router) app.include_router(overview.router) app.include_router(analysis.router) @@ -264,7 +197,6 @@ app.include_router(market_recap.router) app.include_router(settings_api.router) app.include_router(strategy.router) app.include_router(signals.router) -app.include_router(monitor_rules.router) app.include_router(alerts.router) app.include_router(rps.router) diff --git a/serve/backend/app/services/concept_rotation_analyzer.py b/serve/backend/app/services/concept_rotation_analyzer.py index e6721a0..9af5f68 100644 --- a/serve/backend/app/services/concept_rotation_analyzer.py +++ b/serve/backend/app/services/concept_rotation_analyzer.py @@ -285,8 +285,6 @@ async def analyze_rotation_stream( repo, days: int = 12, focus: str = "", - quote_service=None, - depth_service=None, ) -> AsyncIterator[str]: """流式概念轮动分析: yield 出每个 NDJSON 事件。 @@ -294,7 +292,6 @@ async def analyze_rotation_stream( repo: KlineRepository (必填)。 days: 分析最近 N 个交易日 (7-30)。 focus: 用户追加的关注点。 - quote_service / depth_service: 可选, 大盘背景装配依赖。 """ from app.services.rps_rotation import build_rps_rotation from app.services.market_overview_builder import build_market_overview @@ -316,7 +313,7 @@ async def analyze_rotation_stream( # 3. 大盘背景 (失败不阻断, 降级为空) try: - overview = build_market_overview(repo, quote_service, depth_service) + overview = build_market_overview(repo) except Exception as e: # noqa: BLE001 logger.warning("rotation analyze: 大盘背景获取失败, 降级为空: %s", e) overview = {} diff --git a/serve/backend/app/services/depth_service.py b/serve/backend/app/services/depth_service.py deleted file mode 100644 index 1e5c423..0000000 --- a/serve/backend/app/services/depth_service.py +++ /dev/null @@ -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) diff --git a/serve/backend/app/services/market_overview_builder.py b/serve/backend/app/services/market_overview_builder.py index 8a1adfd..0854a1b 100644 --- a/serve/backend/app/services/market_overview_builder.py +++ b/serve/backend/app/services/market_overview_builder.py @@ -3,11 +3,8 @@ 本模块由 `app.api.overview._build_overview` 抽离而来,目的是让「大盘复盘」 等无 Request 的调用方(定时任务、复盘服务)也能复用同一套聚合逻辑。 -行为与原 `_build_overview` 完全一致,仅把对 `request.app.state.{repo, -quote_service,depth_service}` 的依赖改为显式参数。 - 公共入口: - build_market_overview(repo, quote_service, depth_service, as_of) + build_market_overview(repo, as_of) """ from __future__ import annotations @@ -81,24 +78,12 @@ def _score(value: float, low: float, high: float) -> int: # ================================================================ -# 指数行情(实时 quote_service 优先,回退 kline_index_daily SQL) +# 指数行情(从 kline_index_daily SQL 读取) # ================================================================ -def _quote_status(quote_service) -> dict: - qs = quote_service - if not qs: - return {"enabled": False, "running": False, "quote_age_ms": None, "is_trading_hours": False} - return qs.status() - - -def _index_quotes(repo, quote_service, as_of: date | None = None) -> list[dict]: +def _index_quotes(repo, as_of: date | None = None) -> list[dict]: rows: list[dict] = [] - if quote_service and as_of is None: - df = quote_service.get_index_quotes(list(CORE_INDEX_SYMBOLS)) - if not df.is_empty(): - rows = df.to_dicts() - - if not rows and repo: + if repo: placeholders = ", ".join("?" for _ in CORE_INDEX_SYMBOLS) try: db_rows = repo.execute_all( @@ -354,27 +339,21 @@ def _pct_band_rows(values: list[float]) -> list[dict]: def build_market_overview( repo, - quote_service=None, - depth_service=None, as_of: date | None = None, ) -> dict: """装配市场总览(与原 overview._build_overview 行为一致)。 Args: repo: KlineRepository(必填)。 - quote_service: QuoteService(可选;实时指数行情来源)。 - depth_service: DepthService(可选;五档封板修正)。 as_of: 指定日期,None 则取最新有数据日。 """ svc = ScreenerService(repo) as_of = as_of or svc.latest_date() - status = _quote_status(quote_service) - indices = _index_quotes(repo, quote_service, as_of) + indices = _index_quotes(repo, as_of) if not as_of: return { "as_of": None, - "quote_status": status, "indices": indices, "breadth": {"total": 0, "up": 0, "down": 0, "flat": 0, "up_pct": 0, "down_pct": 0}, "amount": {"total": 0, "avg": 0}, @@ -434,22 +413,6 @@ def build_market_overview( limit_down = sum(1 for r in rows if bool(r.get("signal_limit_down"))) max_boards = max([int(_finite(r.get("consecutive_limit_ups")) or 0) for r in rows], default=0) - # 五档 sealed 修正: 假涨停/假跌停不计入(需 Pro+ depth5.batch 能力) - sealed_ready = False - fake_up = 0 - fake_down = 0 - if depth_service: - up_map = depth_service.get_sealed_map(as_of, is_down=False) - down_map = depth_service.get_sealed_map(as_of, is_down=True) - sealed_ready = bool(up_map or down_map) and depth_service.is_sealed_ready(as_of) - if up_map: - fake_up = sum(1 for v in up_map.values() if v.get("sealed") is False) - if down_map: - fake_down = sum(1 for v in down_map.values() if v.get("sealed") is False) - if sealed_ready: - limit_up = max(0, limit_up - fake_up) - limit_down = max(0, limit_down - fake_down) - seal_rate = limit_up / (limit_up + broken) * 100 if (limit_up + broken) > 0 else 0 def above_ma_count(ma_key: str) -> int: @@ -531,7 +494,6 @@ def build_market_overview( return _json_safe({ "as_of": str(as_of), - "quote_status": status, "indices": indices, "breadth": { "total": total, @@ -547,7 +509,7 @@ def build_market_overview( }, "amount": {"total": total_amount, "avg": avg_amount}, "boards": boards, - "limit": {"limit_up": limit_up, "broken": broken, "failed": 0, "limit_down": limit_down, "max_boards": max_boards, "seal_rate": seal_rate, "tiers": tiers, "sealed_ready": sealed_ready, "fake_up": fake_up, "fake_down": fake_down}, + "limit": {"limit_up": limit_up, "broken": broken, "failed": 0, "limit_down": limit_down, "max_boards": max_boards, "seal_rate": seal_rate, "tiers": tiers}, "distribution": _pct_band_rows(pct_values), "trend": { "above_ma5": above_ma5, diff --git a/serve/backend/app/services/market_recap.py b/serve/backend/app/services/market_recap.py index 2fe0d7d..7c6b6d0 100644 --- a/serve/backend/app/services/market_recap.py +++ b/serve/backend/app/services/market_recap.py @@ -252,8 +252,6 @@ def _recap_summary(overview: dict) -> str: async def recap_market_stream( repo, - quote_service=None, - depth_service=None, as_of: date | None = None, focus: str = "", news: list[dict] | None = None, @@ -262,13 +260,12 @@ async def recap_market_stream( Args: repo: KlineRepository(必填)。 - quote_service / depth_service: 可选,数据装配依赖。 as_of: 复盘日期,None 取最新有数据日。 focus: 用户追加的复盘关注点。 news: 预检索的新闻列表(P1 不传,留 None 走降级说明;P3 由 news_search 注入)。 """ # 1. 装配市场总览 - overview = build_market_overview(repo, quote_service, depth_service, as_of) + overview = build_market_overview(repo, as_of) as_of_str = overview.get("as_of") if not as_of_str: @@ -314,8 +311,6 @@ async def recap_market_stream( async def recap_market_once( repo, - quote_service=None, - depth_service=None, as_of: date | None = None, focus: str = "", news: list[dict] | None = None, @@ -327,7 +322,7 @@ async def recap_market_once( """ content_parts: list[str] = [] meta: dict = {"as_of": as_of.isoformat() if as_of else None} - async for evt in recap_market_stream(repo, quote_service, depth_service, as_of, focus, news): + async for evt in recap_market_stream(repo, as_of, focus, news): try: obj = json.loads(evt) except Exception: # noqa: BLE001 diff --git a/serve/backend/app/services/preferences.py b/serve/backend/app/services/preferences.py index f4e31db..39908cd 100644 --- a/serve/backend/app/services/preferences.py +++ b/serve/backend/app/services/preferences.py @@ -39,53 +39,12 @@ def save(updates: dict) -> dict: return current -def get_realtime_quotes_enabled() -> bool: - return load().get("realtime_quotes_enabled", False) - - def get_indices_nav_pinned() -> bool: """侧栏指数报价卡片是否固定显示。默认 True(常驻)。 关闭后,卡片跟随实时行情开关(仅实时开时显示)。""" return load().get("indices_nav_pinned", True) -def get_realtime_quote_interval() -> float: - return load().get("realtime_quote_interval", 10.0) - - -def get_realtime_watchlist_symbols() -> list[str]: - """Free 档自选实时监控标的:直接取自选页前 5 个。""" - try: - from app.services import watchlist - rows = watchlist.list_symbols() - except Exception as e: # noqa: BLE001 - logger.warning("load watchlist for realtime failed: %s", e) - return [] - out: list[str] = [] - for row in rows: - symbol = str((row or {}).get("symbol") or "").strip().upper() - if symbol and symbol not in out: - out.append(symbol) - if len(out) >= 5: - break - return out - - -def set_realtime_watchlist_symbols(symbols: list[str]) -> list[str]: # noqa: ARG001 - """兼容旧接口: Free 实时标的现在由自选页前 5 个决定。""" - return get_realtime_watchlist_symbols() - - -def set_realtime_quote_interval(interval: float) -> float: - """保存行情轮询间隔(不在此做 min/max 校验,由调用方按档位限制)。""" - current = load() - current["realtime_quote_interval"] = interval - _path().write_text( - json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8", - ) - return interval - - def get_minute_sync_enabled() -> bool: return load().get("minute_sync_enabled", False) @@ -116,11 +75,6 @@ def get_minute_data_provider() -> str: return provider if provider in _ALLOWED_DATA_PROVIDERS else "tickflow" -def get_realtime_data_provider() -> str: - # 盘中实时现阶段仅支持 TickFlow。 - return "tickflow" - - # ===== 盘后管道拉取内容开关 (A股 / ETF / 指数 独立控制) ===== def get_pipeline_pull_a_share() -> bool: @@ -227,44 +181,6 @@ def set_index_daily_batch_size(size: int) -> int: return size -# ── 五档盘口 sealed(真假涨停) 配置 ────────────────────── - -def get_limit_ladder_monitor_enabled() -> bool: - """连板梯队 5 档监控开关。关闭时 depth 不轮询(连板梯队降级显示)。""" - return load().get("limit_ladder_monitor_enabled", False) - - -def get_depth_polling_interval() -> float: - """depth 盘中轮询间隔(秒)。默认 20(Pro/Expert 都适用)。""" - return float(load().get("depth_polling_interval", 20.0)) - - -def set_depth_polling_interval(interval: float) -> float: - """保存 depth 轮询间隔。套餐范围 clamp 由 depth_service 按档位做。""" - interval = max(1.0, min(600.0, float(interval))) - save({"depth_polling_interval": interval}) - return interval - - -def get_depth_finalize_time() -> dict: - """盘后 sealed 定版时间 {"hour": 15, "minute": 2}。范围 15:01~18:00。""" - d = load().get("depth_finalize_time", {"hour": 15, "minute": 2}) - return {"hour": d.get("hour", 15), "minute": d.get("minute", 2)} - - -def set_depth_finalize_time(hour: int, minute: int) -> dict: - """保存盘后 sealed 定版时间,强制范围 15:01~18:00。""" - h = max(0, min(23, hour)) - m = max(0, min(59, minute)) - # 下限 15:01, 上限 18:00 - if h * 60 + m < 15 * 60 + 1: - h, m = 15, 1 - if h * 60 + m > 18 * 60: - h, m = 18, 0 - save({"depth_finalize_time": {"hour": h, "minute": m}}) - return {"hour": h, "minute": m} - - # 复盘推送可选渠道白名单 (微信等暂未实现, 不在白名单内, 前端仅作占位) # 多选: 不推送 = 空数组, 而非 'none' REVIEW_PUSH_CHANNELS = {"feishu"} @@ -333,86 +249,9 @@ def set_review_push_channels(channels: list[str]) -> list[str]: -# ===== 实时监控 ===== - -# 页面 SSE 刷新配置: { "watchlist": true, "monitor": true, ... } -# 可刷新的页面列表及其默认值 -SSE_REFRESH_PAGES_DEFAULT = { - "watchlist": True, - "limit-ladder": False, -} - SIDEBAR_INDEX_SYMBOLS_DEFAULT = ["000001.SH", "399001.SZ", "399006.SZ", "000680.SH"] -# ===== 盘中实时行情范围 (独立于盘后管道范围) ===== - - -def get_realtime_pull_stock() -> bool: - return load().get("realtime_pull_stock", True) - - -def get_realtime_pull_etf() -> bool: - # 老用户兼容: ETF 实时默认关闭,避免升级后请求量/写盘量突然增加。 - return load().get("realtime_pull_etf", False) - - -def get_realtime_pull_index() -> bool: - return load().get("realtime_pull_index", True) - - -def get_realtime_index_mode() -> str: - mode = str(load().get("realtime_index_mode", "core") or "core").lower() - return mode if mode in {"core", "all"} else "core" - - -def get_realtime_index_symbols() -> list[str]: - stored = load().get("realtime_index_symbols", SIDEBAR_INDEX_SYMBOLS_DEFAULT) - if isinstance(stored, str): - import re - stored = [s.strip() for s in re.split(r"[,\s]+", stored) if s.strip()] - return [str(s) for s in stored if str(s).strip()] - - -def set_realtime_quote_scope(cfg: dict) -> dict: - updates = {} - for key in ("realtime_pull_stock", "realtime_pull_etf", "realtime_pull_index"): - if key in cfg and cfg[key] is not None: - updates[key] = bool(cfg[key]) - if "realtime_index_mode" in cfg and cfg["realtime_index_mode"] in {"core", "all"}: - updates["realtime_index_mode"] = cfg["realtime_index_mode"] - if "realtime_index_symbols" in cfg and cfg["realtime_index_symbols"] is not None: - updates["realtime_index_symbols"] = cfg["realtime_index_symbols"] - if updates: - save(updates) - return get_realtime_quote_scope() - - -def get_realtime_quote_scope() -> dict: - return { - "realtime_pull_stock": get_realtime_pull_stock(), - "realtime_pull_etf": get_realtime_pull_etf(), - "realtime_pull_index": get_realtime_pull_index(), - "realtime_index_mode": get_realtime_index_mode(), - "realtime_index_symbols": get_realtime_index_symbols(), - } - - -def get_sse_refresh_pages() -> dict[str, bool]: - """返回每个页面的 SSE 刷新开关。""" - stored = load().get("sse_refresh_pages", {}) - # 合并默认值 (新增页面自动出现) - result = dict(SSE_REFRESH_PAGES_DEFAULT) - result.update(stored) - return result - - -def set_sse_refresh_pages(pages: dict[str, bool]) -> dict[str, bool]: - """保存页面 SSE 刷新配置。""" - save({"sse_refresh_pages": pages}) - return get_sse_refresh_pages() - - def get_sidebar_index_symbols() -> list[str]: """返回左侧菜单显示的指数代码。""" stored = load().get("sidebar_index_symbols", SIDEBAR_INDEX_SYMBOLS_DEFAULT) @@ -420,22 +259,6 @@ def get_sidebar_index_symbols() -> list[str]: return [s for s in stored if s in allowed] -def get_strategy_monitor_enabled() -> bool: - """策略告警评估总开关。""" - return load().get("strategy_monitor_enabled", False) - - -def get_system_notify_enabled() -> bool: - """系统通知开关 — 开启后监控告警同时推送到操作系统通知中心。""" - return load().get("system_notify_enabled", False) - - -def set_system_notify_enabled(enabled: bool) -> bool: - """保存系统通知开关。""" - save({"system_notify_enabled": bool(enabled)}) - return bool(enabled) - - def get_feishu_webhook_url() -> str: """飞书自定义机器人 Webhook 地址 — 全局共用一处, 所有启用推送的规则都推到这一个群。""" return load().get("feishu_webhook_url", "") @@ -446,73 +269,11 @@ def get_feishu_webhook_secret() -> str: return load().get("feishu_webhook_secret", "") -def set_feishu_webhook_url(url: str) -> str: - """保存飞书 Webhook 地址。传入空串表示清空配置。""" - save({"feishu_webhook_url": str(url or "").strip()}) - return get_feishu_webhook_url() - - -def set_feishu_webhook_secret(secret: str) -> str: - """保存飞书签名密钥。传入空串表示不验签。""" - save({"feishu_webhook_secret": str(secret or "").strip()}) - return get_feishu_webhook_secret() - - -def get_webhook_enabled_default() -> bool: - """新建监控规则时是否默认勾选「飞书推送」。 - - 数据模型当前只有一个 webhook_enabled 布尔 (即飞书), QMT/ptrade 待定。 - 此默认值供规则编辑器新建规则时预填, 单条规则仍可独立修改。 - """ - return load().get("webhook_enabled_default", False) - - -def set_webhook_enabled_default(enabled: bool) -> bool: - """保存飞书推送默认勾选态。""" - save({"webhook_enabled_default": bool(enabled)}) - return get_webhook_enabled_default() - - def get_screener_auto_run() -> bool: """选股页进入时是否自动运行所有策略 (获取命中数)。默认开。""" return load().get("screener_auto_run", True) -def get_strategy_monitor_ids() -> list[str]: - """返回监控池中的策略 ID。""" - return load().get("strategy_monitor_ids", []) - - -def set_realtime_monitor_config(cfg: dict) -> dict: - """批量更新实时监控配置。""" - updates = {} - if "sse_refresh_pages" in cfg: - updates["sse_refresh_pages"] = cfg["sse_refresh_pages"] - if "strategy_monitor_enabled" in cfg: - updates["strategy_monitor_enabled"] = cfg["strategy_monitor_enabled"] - if "strategy_monitor_ids" in cfg: - updates["strategy_monitor_ids"] = cfg["strategy_monitor_ids"] - if "sidebar_index_symbols" in cfg: - allowed = set(SIDEBAR_INDEX_SYMBOLS_DEFAULT) - updates["sidebar_index_symbols"] = [s for s in cfg["sidebar_index_symbols"] if s in allowed] - if "screener_auto_run" in cfg: - updates["screener_auto_run"] = bool(cfg["screener_auto_run"]) - if updates: - save(updates) - return get_realtime_monitor_config() - - -def get_realtime_monitor_config() -> dict: - """返回完整的实时监控配置。""" - return { - "sse_refresh_pages": get_sse_refresh_pages(), - "strategy_monitor_enabled": get_strategy_monitor_enabled(), - "strategy_monitor_ids": get_strategy_monitor_ids(), - "sidebar_index_symbols": get_sidebar_index_symbols(), - "screener_auto_run": get_screener_auto_run(), - } - - def get_nav_order() -> list[str]: """返回左侧菜单的自定义排序(内置页面 path + 扩展分析菜单 id)。""" return load().get("nav_order", []) diff --git a/serve/backend/app/services/quote_service.py b/serve/backend/app/services/quote_service.py deleted file mode 100644 index 2948f9b..0000000 --- a/serve/backend/app/services/quote_service.py +++ /dev/null @@ -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) diff --git a/serve/backend/app/strategy/monitor.py b/serve/backend/app/strategy/monitor.py deleted file mode 100644 index 99d92ca..0000000 --- a/serve/backend/app/strategy/monitor.py +++ /dev/null @@ -1,857 +0,0 @@ -"""策略实时监控 — 订阅行情更新,检查策略买卖信号和提醒条件。 - -职责: 接收实时行情 DataFrame → 检查监控中策略的信号/提醒 → 推送告警。 -不知道: 策略加载逻辑、AI、API、配置持久化、回测。 -依赖: 外部调用 on_quote_update() 传入实时数据。 - -本模块含两个评估器: - 1. StrategyMonitorService — 旧的策略监控 (type=strategy),第二步迁移到 MonitorRuleEngine - 2. MonitorRuleEngine — 通用规则引擎,覆盖 signal/price/market/strategy 四类, - 支持 scope (symbols/all/sector) + 多条件 AND/OR + cooldown 去重 -""" -from __future__ import annotations - -import datetime as _dt -import logging -import time -from dataclasses import dataclass, field -from typing import Any, Callable - -import polars as pl - -from app.strategy.custom_signals import _OP_BUILDERS # type: ignore # 复用运算符构造器 -from app.strategy import config as _strategy_config - -logger = logging.getLogger(__name__) - -# 信号 / 字段中文名映射 — 与前端 lib/signals.ts 对齐, 用于告警 message / 推送文案。 -# signal_* 为内置原子信号, 其余为技术指标/行情字段。 -_SIGNAL_CN: dict[str, str] = { - # 内置信号 - "signal_ma_golden_5_20": "MA5上穿MA20", "signal_ma_dead_5_20": "MA5下穿MA20", - "signal_ma_golden_20_60": "MA20上穿MA60", "signal_macd_golden": "MACD金叉", - "signal_macd_dead": "MACD死叉", "signal_ma20_breakout": "突破MA20", - "signal_ma20_breakdown": "跌破MA20", "signal_n_day_high": "60日新高", - "signal_n_day_low": "60日新低", "signal_boll_breakout_upper": "突破布林上轨", - "signal_boll_breakdown_lower": "跌破布林下轨", "signal_volume_surge": "放量", - "signal_limit_up": "涨停", "signal_limit_down": "跌停", - "signal_limit_down_recovery": "跌停翘板", "signal_broken_limit_up": "炸板", - # 行情字段 - "close": "收盘价", "open": "开盘价", "high": "最高价", "low": "最低价", - "change_pct": "涨跌幅", "change_amount": "涨跌额", "amplitude": "振幅", - "turnover_rate": "换手率", "volume": "成交量", "amount": "成交额", - # 均线 - "ma5": "MA5", "ma10": "MA10", "ma20": "MA20", "ma30": "MA30", "ma60": "MA60", - "ema5": "EMA5", "ema10": "EMA10", "ema20": "EMA20", - # MACD / BOLL / KDJ / RSI - "macd_dif": "MACD-DIF", "macd_dea": "MACD-DEA", "macd_hist": "MACD柱", - "boll_upper": "布林上轨", "boll_lower": "布林下轨", - "kdj_k": "KDJ-K", "kdj_d": "KDJ-D", "kdj_j": "KDJ-J", - "rsi_6": "RSI6", "rsi_14": "RSI14", "rsi_24": "RSI24", - # 量能 / 动量 / 波动 - "vol_ratio_5d": "5日量比", "vol_ratio_20d": "20日量比", - "vol_ma5": "5日均量", "vol_ma10": "10日均量", - "high_60d": "60日最高", "low_60d": "60日最低", - "momentum_5d": "5日动量", "momentum_20d": "20日动量", "momentum_60d": "60日动量", - "atr_14": "ATR14", "annual_vol_20d": "20日年化波动", - "consecutive_limit_ups": "连板数", "consecutive_limit_downs": "跌停连板", -} - - -def _signal_cn_name(name: str) -> str: - """返回信号/字段的中文名, 找不到原样返回 (与前端 cnSignal 对齐)。""" - return _SIGNAL_CN.get(name, name) - - -@dataclass -class StrategyAlert: - """策略告警""" - type: str # "entry" | "exit" | "alert" - strategy_id: str - symbol: str - name: str | None - message: str - price: float | None = None - change_pct: float | None = None - signals: list[str] = field(default_factory=list) - - -class StrategyMonitorService: - """策略实时监控服务""" - - def __init__(self, alert_handler: Callable[[StrategyAlert], None] | None = None): - """ - Args: - alert_handler: 告警回调 (如推 SSE) - """ - self._alert_handler = alert_handler - # strategy_id → 监控配置 - self._watching: dict[str, dict] = {} - - def start(self, strategy_id: str, config: dict) -> None: - """开始监控一个策略 - - config: { - "entry_signals": ["signal_n_day_high", ...], - "exit_signals": ["signal_ma20_breakdown", ...], - "alerts": [{"field": "rsi_14", "op": ">", "value": 80, "message": "..."}], - } - """ - self._watching[strategy_id] = config - logger.info("strategy monitor started: %s", strategy_id) - - def stop(self, strategy_id: str) -> None: - self._watching.pop(strategy_id, None) - logger.info("strategy monitor stopped: %s", strategy_id) - - def stop_all(self) -> None: - self._watching.clear() - - @property - def watching(self) -> dict[str, dict]: - return dict(self._watching) - - def on_quote_update(self, df: pl.DataFrame) -> list[StrategyAlert]: - """行情更新后调用。向量化检查所有监控策略。 - - Args: - df: 实时 enriched 数据 (~5500行) - Returns: - 触发的告警列表 - """ - if not self._watching or df.is_empty(): - return [] - - all_alerts: list[StrategyAlert] = [] - - for strategy_id, cfg in self._watching.items(): - # 买入信号 - entry_sigs = cfg.get("entry_signals", []) - if entry_sigs: - for sym, name, price, pct, hit_sigs in self._check_signals(df, entry_sigs): - alert = StrategyAlert( - type="entry", - strategy_id=strategy_id, - symbol=sym, - name=name, - message=f"买入信号触发", - price=price, - change_pct=pct, - signals=hit_sigs, - ) - all_alerts.append(alert) - self._emit(alert) - - # 卖出信号 - exit_sigs = cfg.get("exit_signals", []) - if exit_sigs: - for sym, name, price, pct, hit_sigs in self._check_signals(df, exit_sigs): - alert = StrategyAlert( - type="exit", - strategy_id=strategy_id, - symbol=sym, - name=name, - message=f"卖出信号触发", - price=price, - change_pct=pct, - signals=hit_sigs, - ) - all_alerts.append(alert) - self._emit(alert) - - # 提醒条件 - for alert_cfg in cfg.get("alerts", []): - for sym, name, price, pct in self._check_alert(df, alert_cfg): - alert = StrategyAlert( - type="alert", - strategy_id=strategy_id, - symbol=sym, - name=name, - message=alert_cfg.get("message", "提醒"), - price=price, - change_pct=pct, - ) - all_alerts.append(alert) - self._emit(alert) - - return all_alerts - - def _emit(self, alert: StrategyAlert) -> None: - if self._alert_handler: - try: - self._alert_handler(alert) - except Exception as e: - logger.warning("alert handler failed: %s", e) - - @staticmethod - def _check_signals( - df: pl.DataFrame, - signals: list[str], - ) -> list[tuple[str, str | None, float | None, float | None, list[str]]]: - """检查信号列,返回 [(symbol, name, price, change_pct, [hit_signals])]。 - 支持内置 signal_ 与自定义 csg_ 前缀。""" - cols = set(df.columns) - resolved: list[tuple[str, str]] = [] # (原值, 列名) - for s in signals: - col = s if (s.startswith("signal_") or s.startswith("csg_")) else f"signal_{s}" - if col in cols: - resolved.append((s, col)) - if not resolved: - return [] - - mask = pl.any_horizontal(pl.col(c).fill_null(False) for _, c in resolved) - hit_df = df.filter(mask) - - results = [] - for row in hit_df.iter_rows(named=True): - sym = row.get("symbol", "") - name = row.get("name") - price = row.get("close") - pct = row.get("change_pct") - hit_sigs = [orig for orig, col in resolved if row.get(col)] - results.append((sym, name, price, pct, hit_sigs)) - return results - - @staticmethod - def _check_alert( - df: pl.DataFrame, - alert: dict, - ) -> list[tuple[str, str | None, float | None, float | None]]: - """检查阈值型提醒条件""" - field = alert.get("field", "") - if field not in df.columns: - return [] - - if "op" in alert: - # 阈值比较 - op = alert["op"] - value = alert["value"] - col = pl.col(field) - ops = { - ">": col > value, - ">=": col >= value, - "<": col < value, - "<=": col <= value, - } - expr = ops.get(op) - if expr is None: - return [] - else: - # 信号列 (布尔) - expr = pl.col(field).fill_null(False) - - hit_df = df.filter(expr) - results = [] - for row in hit_df.iter_rows(named=True): - results.append(( - row.get("symbol", ""), - row.get("name"), - row.get("close"), - row.get("change_pct"), - )) - return results - - -# ================================================================ -# 通用监控规则引擎 MonitorRuleEngine -# ================================================================ - -_SIGNAL_PREFIXES = ("signal_", "csg_") - - -def _is_signal_field(field: str) -> bool: - return any(field.startswith(p) for p in _SIGNAL_PREFIXES) - - -def _build_condition_mask(df: pl.DataFrame, conditions: list[dict], logic: str) -> pl.DataFrame: - """根据 conditions + logic 构建过滤后的命中 DataFrame。 - - conditions: [{"field","op","value"?}] — op=truth 为布尔信号, 否则阈值比较 - logic: "and" | "or" - 返回命中行 (含 symbol/name/close/change_pct + 各信号列) - """ - cols = set(df.columns) - parts: list[pl.Expr] = [] - for c in conditions: - field = c["field"] - if field not in cols: - return df.head(0) # 字段缺失,无法判定 → 空结果 - op = c["op"] - if op == "truth": - parts.append(pl.col(field).fill_null(False)) - elif op in _OP_BUILDERS: - parts.append(_OP_BUILDERS[op](pl.col(field), c["value"])) - else: - return df.head(0) - if not parts: - return df.head(0) - if logic == "or": - mask = pl.any_horizontal(parts) - else: - mask = pl.all_horizontal(parts) - return df.filter(mask) - - -class MonitorRuleEngine: - """通用监控规则引擎 — 接收实时行情 DataFrame,评估所有规则,返回 AlertEvent。 - - 与 StrategyMonitorService 的区别: - - 规则来自 monitor_rules 存储 (用户可配), 而非写死的 strategy config - - 支持 scope (symbols/all/sector) 过滤作用域 - - 支持 conditions + logic (AND/OR) 任意组合 - - ★ cooldown 去重: 同一 (rule_id, symbol) 在冷却期内不重复触发 - """ - - def __init__(self, alert_handler: Callable[[dict], None] | None = None): - self._alert_handler = alert_handler - self._rules: dict[str, dict] = {} # rule_id → rule - # (rule_id, symbol) → 上次触发时间戳(秒)。用于 cooldown 去重。 - self._last_fire: dict[tuple[str, str], float] = {} - self._strategy_engine = None # 延迟注入, type=strategy 规则用它跑选股 - # symbol → 股票名 (enriched DataFrame 已 drop name 列, 触发时从此映射回填) - self._name_map: dict[str, str] = {} - # 策略选股池状态: strategy_id → 上期选股符号集合 (用于 diff 变更) - self._strategy_pools: dict[str, set[str]] = {} - # 数据目录 (用于加载策略 overrides) - self._data_dir = None - # 历史窗口加载器: (target_date, lookback_days) → 多日 enriched DataFrame。 - # 用于声明 filter_history 的策略 (如反包), 实时监控时拼历史窗口 + 今日行情跑选股。 - # 为 None 时, filter_history 策略仍会被跳过 (保持旧行为, 不破坏无历史场景)。 - self._history_loader: Callable[[_dt.date, int], "pl.DataFrame"] | None = None - # 本轮 evaluate() 产出的策略选股结果: strategy_id → {rows, total, as_of} - # 供策略页实时回显复用 (/api/screener/cached 端点直接读取此内存结果), 避免重跑 - self._latest_strategy_results: dict[str, dict] = {} - - def set_strategy_engine(self, engine) -> None: - """注入 StrategyEngine, type=strategy 规则据此跑选股。""" - self._strategy_engine = engine - - def set_data_dir(self, data_dir) -> None: - """注入数据目录, 用于加载策略的用户覆盖配置。""" - self._data_dir = data_dir - - def set_history_loader(self, fn) -> None: - """注入历史窗口加载器, 用于声明 filter_history 的策略跑实时监控。 - - loader 签名: (target_date, lookback_days) → 多日 enriched DataFrame。 - 复用 ScreenerService._load_enriched_history (三级缓存, 命中 ~0ms)。 - 为 None 时 filter_history 策略退回到跳过逻辑 (不破坏无历史场景)。 - """ - self._history_loader = fn - - def set_name_map(self, name_map: dict[str, str]) -> None: - """注入 symbol → 股票名 映射, 用于在告警事件里回填 name 字段。 - - enriched DataFrame 在 pipeline 计算后不含 name 列 (见 indicators/pipeline.py), - 触发时从 instruments 表预构建此映射, 保证 AlertEvent.name 有值。 - """ - self._name_map = name_map or {} - - # ── 规则管理 ─────────────────────────────────────── - def set_rules(self, rules: list[dict]) -> None: - """批量设置规则 (覆盖)。用于启动时 reload。""" - self._rules = {} - for r in rules: - if r.get("enabled") is not False: - self._rules[r["id"]] = r - logger.info("MonitorRuleEngine: 装载 %d 条规则", len(self._rules)) - - def add_rule(self, rule: dict) -> None: - if rule.get("enabled") is not False: - self._rules[rule["id"]] = rule - else: - self._rules.pop(rule["id"], None) - - def remove_rule(self, rule_id: str) -> None: - self._rules.pop(rule_id, None) - # 清理对应的 cooldown 记录 - self._last_fire = {k: v for k, v in self._last_fire.items() if k[0] != rule_id} - - def clear(self) -> None: - self._rules.clear() - self._last_fire.clear() - - @property - def rules(self) -> dict[str, dict]: - return dict(self._rules) - - @property - def rule_count(self) -> int: - return len(self._rules) - - def latest_strategy_results(self) -> dict[str, dict]: - """返回本轮 evaluate() 产出的策略选股结果 (strategy_id → {rows, total, as_of})。 - - 供策略页实时回显复用: /api/screener/cached 端点直接读取此内存结果, - 避免对被监控的策略重跑第二遍。无 type=strategy 规则时返回空 dict。 - """ - return self._latest_strategy_results - - def has_rule_type(self, rtype: str) -> bool: - """是否存在指定类型的 (已启用) 规则。供 quote_service 判断是否需要注入特殊数据。""" - if not self._rules: - return False - return any( - r.get("enabled", True) and r.get("type") == rtype - for r in self._rules.values() - ) - - # ── 评估 ─────────────────────────────────────────── - def evaluate(self, df: pl.DataFrame) -> list[dict]: - """行情更新后评估所有规则。 - - Args: - df: 实时 enriched 数据 (~5500行, 含 signal_/csg_/指标列) - Returns: - 触发的 AlertEvent dict 列表 (含 ts/rule_id/source/type/symbol/...) - """ - if not self._rules or df.is_empty(): - return [] - - now = time.time() - events: list[dict] = [] - # 每轮重置: 只保留本次 evaluate 产出的策略结果 - self._latest_strategy_results = {} - - for rule_id, rule in self._rules.items(): - try: - events.extend(self._evaluate_rule(df, rule, now)) - except Exception as e: - logger.warning("规则评估失败 %s: %s", rule_id, e) - - return events - - def _evaluate_rule(self, df: pl.DataFrame, rule: dict, now: float) -> list[dict]: - """评估单条规则,返回触发的 events。""" - # 1. 按 scope 过滤作用域 - scoped = self._apply_scope(df, rule) - if scoped.is_empty(): - return [] - - # 2. 根据 type 构建命中集 - # 元组格式: (event_type, symbol, name, price, pct, signals) - hit_rows: list[tuple[str, str, Any, Any, Any, list[str]]] = [] - - rtype = rule.get("type", "signal") - if rtype == "strategy": - # 策略类型: 跑策略选股 → 对比上期选股池 → 产出 new_entry/dropped 事件 - hit_rows = self._match_strategy(scoped, rule) - elif rtype == "ladder": - # 连板梯队封单监控: 独立处理 (需带预警封单值, 走专属 message) - return self._evaluate_ladder(scoped, rule, now) - else: - # signal / price / market: 通用条件匹配 - for sym, name, price, pct, hit_sigs in self._match_conditions(scoped, rule): - hit_rows.append((rtype, sym, name, price, pct, hit_sigs)) - - if not hit_rows: - return [] - - # 3. cooldown 去重 + 生成 events - cooldown = rule.get("cooldown_seconds", 3600) - severity = rule.get("severity", "info") - source = rtype - - events: list[dict] = [] - for ev_type, sym, name, price, pct, hit_sigs in hit_rows: - # cooldown 键: 批量事件用特殊键, 单只事件用 (rule_id, symbol) - is_batch = sym == "_batch" - if is_batch: - key = (rule["id"], f"_{ev_type}_batch") - else: - key = (rule["id"], sym) - last = self._last_fire.get(key) - if last is not None and (now - last) < cooldown: - continue # 冷却期内, 跳过 - self._last_fire[key] = now - - # 批量事件: name 存放预构建的消息文本 - if is_batch: - resolved_name = "" - message = name # name 字段即批量消息 - else: - resolved_name = name if name else self._name_map.get(sym) - message = rule.get("message", "") or self._default_message( - rule, ev_type=ev_type, sym=sym, name=resolved_name, - pct=pct, price=price, - conditions=list(rule.get("conditions", [])) if rule.get("type") != "strategy" else None, - ) - - ev = { - "ts": int(now * 1000), - "rule_id": rule["id"], - "rule_name": rule.get("name", ""), - "source": source, - "type": ev_type, - "symbol": "" if is_batch else sym, - "name": resolved_name, - "message": message, - "price": price, - "change_pct": pct, - "signals": hit_sigs, - "severity": severity, - # 触发条件快照 (signal/price/market 类型): 用于触发记录展示 - # 「命中了什么条件」。strategy 类型靠策略选股池 diff, 不写条件。 - "conditions": list(rule.get("conditions", [])) if rtype != "strategy" else [], - "logic": rule.get("logic", "and") if rtype != "strategy" else "and", - } - events.append(ev) - if self._alert_handler: - try: - self._alert_handler(ev) - except Exception as e: - logger.warning("alert handler failed: %s", e) - - return events - - @staticmethod - def _apply_scope(df: pl.DataFrame, rule: dict) -> pl.DataFrame: - """按 scope 过滤 DataFrame。""" - scope = rule.get("scope", "symbols") - if scope == "all": - return df - if scope == "symbols": - syms = rule.get("symbols", []) - if not syms: - return df.head(0) - return df.filter(pl.col("symbol").is_in(syms)) - if scope == "sector": - # sector 过滤: 需 df 含板块列 (后续接入 ext_data JOIN) - # 当前先返回全量, sector 精确过滤第二步完善 - return df - return df - - def _match_strategy( - self, df: pl.DataFrame, rule: dict, - ) -> list[tuple[str, str, Any, Any, Any, list[str]]]: - """策略类型评估: 跑策略选股 → 对比上期选股池 → 产出变更事件。 - - 返回 [(event_type, symbol, name, price, pct, signals)] - event_type: "new_entry" (新入选) | "dropped" (已移出) - 单只变更逐只返回; 同一策略 >5 只合并为一条批量事件 (symbol="_batch") - """ - if self._strategy_engine is None: - return [] - sid = rule.get("strategy_id") - if not sid: - return [] - try: - s = self._strategy_engine.get(sid) - except Exception: - return [] - if s is None: - return [] - - # 运行策略选股: 复用当前 enriched DataFrame 跳过数据加载 - overrides = {} - if self._data_dir: - try: - overrides = _strategy_config.load_override(self._data_dir, sid) - except Exception: - pass - - # 声明 filter_history 的策略 (如反包) 需要多日历史窗口才能判定形态。 - # 旧实现因"实时监控不支持 history loader"直接跳过 → 反包等策略盘中永不触发。 - # 现接入 history_loader, 拼历史窗口 + 今日实时行情, 经 precomputed_history 喂给引擎。 - # loader 为 None (未装配) 时退回跳过, 保持旧行为, 不破坏无历史场景。 - run_kwargs: dict = { - "as_of": _dt.date.today(), - "overrides": overrides, - } - if s.filter_history_fn: - if self._history_loader is None: - logger.debug("策略 %s 需要历史数据但未注入 history_loader, 跳过实时监控", sid) - return [] - try: - today = _dt.date.today() - lookback = max(1, getattr(s, "lookback_days", 30)) - hist_df = self._history_loader(today, lookback) - if hist_df is None or hist_df.is_empty(): - logger.debug("策略 %s 历史数据为空, 跳过本轮实时监控", sid) - return [] - # 历史窗口可能与今日已落盘数据重叠: 排掉 hist_df 中 date==today 的行, - # 今日行情始终以实时 df 为准 (盘中逐轮更新, 最接近收盘真相)。 - # 否则 today 行重复会污染 filter_history 的 .over("symbol") 窗口判定。 - if "date" in hist_df.columns: - hist_df = hist_df.filter(pl.col("date") != today) - # 拼接历史窗口 + 今日实时行情 (filter_history 用 .over("symbol") 窗口, 多日天然可用) - run_kwargs["precomputed_history"] = pl.concat( - [hist_df, df], how="diagonal_relaxed" - ) - except Exception as e: - logger.warning("策略 %s 加载历史窗口失败, 跳过: %s", sid, e) - return [] - else: - # 普通策略: 复用当前 enriched DataFrame 跳过数据加载 - run_kwargs["precomputed"] = df - - try: - result = self._strategy_engine.run(sid, **run_kwargs) - except Exception as e: - logger.warning("策略 %s 选股执行失败: %s", sid, e) - return [] - - # 记录本轮完整选股结果 (供策略页实时回显: /cached 端点直接读取, 不落盘)。 - # 与下面的 diff 事件无关 — 无论是否产生 new_entry/dropped, 结果都该可用于回显。 - try: - import math - self._latest_strategy_results[sid] = { - "total": result.total, - "as_of": str(_dt.date.today()), - "rows": [ - {k: (None if isinstance(v, float) and not math.isfinite(v) else v) - for k, v in row.items()} - for row in result.rows - ], - } - except Exception: # noqa: BLE001 - pass - - current_pool: set[str] = {r["symbol"] for r in result.rows} - prev_pool = self._strategy_pools.get(sid) - - # 首次运行: 仅记录当前选股池, 不产生事件 - if prev_pool is None: - self._strategy_pools[sid] = current_pool - return [] - - new_entries = current_pool - prev_pool - dropped = prev_pool - current_pool - - # 无变更 - if not new_entries and not dropped: - return [] - - # 更新存储 - self._strategy_pools[sid] = current_pool - - sname = s.meta.get("name", "") or s.meta.get("id", sid) - - # 构建查找表 (新入选股票可在 result.rows 中找到; 移出股票需从 df 找) - row_map: dict[str, dict] = {r["symbol"]: r for r in result.rows} - dropped_map: dict[str, dict] = {} - if dropped: - try: - _dd = df.filter(pl.col("symbol").is_in(list(dropped))) - for row in _dd.iter_rows(named=True): - dropped_map[row["symbol"]] = row - except Exception: - pass - - results: list[tuple[str, str, Any, Any, Any, list[str]]] = [] - - # ── 新入选 ── - new_list = sorted(new_entries) - if len(new_list) > 5: - names: list[str] = [] - for sym in new_list: - row = row_map.get(sym, {}) - name = row.get("name") or self._name_map.get(sym, sym) - names.append(str(name)) - message = f"策略「{sname}」进入 {len(new_entries)} 只:{'、'.join(names)}" - results.append(("new_entry", "_batch", message, None, None, [])) - else: - for sym in new_list: - row = row_map.get(sym, {}) - name = row.get("name") or self._name_map.get(sym, sym) - price = row.get("close") - pct = row.get("change_pct") - results.append(("new_entry", sym, name, price, pct, [])) - - # ── 已移出 ── - dropped_list = sorted(dropped) - if len(dropped_list) > 5: - names = [] - for sym in dropped_list: - row = dropped_map.get(sym, {}) - name = row.get("name") or self._name_map.get(sym, sym) - names.append(str(name)) - message = f"策略「{sname}」移出 {len(dropped)} 只:{'、'.join(names)}" - results.append(("dropped", "_batch", message, None, None, [])) - else: - for sym in dropped_list: - row = dropped_map.get(sym, {}) - name = row.get("name") or self._name_map.get(sym, sym) - price = row.get("close") - pct = row.get("change_pct") - results.append(("dropped", sym, name, price, pct, [])) - - return results - - @staticmethod - def _match_conditions( - df: pl.DataFrame, rule: dict, - ) -> list[tuple[str, Any, Any, Any, list[str]]]: - """按 conditions + logic 匹配,返回命中行 [(symbol,name,price,pct,signals)]。""" - conditions = rule.get("conditions", []) - logic = rule.get("logic", "and") - if not conditions: - return [] - hit_df = _build_condition_mask(df, conditions, logic) - results = [] - for row in hit_df.iter_rows(named=True): - sym = row.get("symbol", "") - name = row.get("name") - price = row.get("close") - pct = row.get("change_pct") - # 收集命中的信号列名 (仅 op=truth 且为真的) - hit_sigs = [ - c["field"] for c in conditions - if c.get("op") == "truth" and row.get(c["field"]) - ] - results.append((sym, name, price, pct, hit_sigs)) - return results - - def _evaluate_ladder(self, scoped: pl.DataFrame, rule: dict, now: float) -> list[dict]: - """评估连板梯队封单监控规则。 - - 封单量从注入的临时列 _sealed_vol (手) 读取 (由 quote_service 评估前注入)。 - 命中条件: 封单比较值 <= threshold (且封单 > 0, 排除无 depth 数据的股票)。 - 涨停(direction=up) → 炸板预警; 跌停(direction=down) → 翘板预警。 - """ - if "_sealed_vol" not in scoped.columns: - return [] # 无封单数据 (depth 未拉取), 安全降级 - - metric = rule.get("metric", "sealed_vol") - threshold = rule.get("threshold", 0) - direction = rule.get("direction", "up") - cooldown = rule.get("cooldown_seconds", 600) - severity = rule.get("severity", "warn") - - # 比较值: sealed_vol 直接用 (手), sealed_amount = 手 × 100股 × close - if metric == "sealed_amount": - cmp_expr = pl.col("_sealed_vol") * 100 * pl.col("close") - unit = "元" - else: - cmp_expr = pl.col("_sealed_vol") - unit = "手" - - # 命中: 封单 > 0 (有数据) 且 比较值 <= 阈值 - hit = scoped.filter( - pl.col("_sealed_vol").is_not_null() - & (pl.col("_sealed_vol") > 0) - & (cmp_expr <= threshold) - ) - if hit.is_empty(): - return [] - - warn_label = "炸板预警" if direction == "up" else "翘板预警" - events: list[dict] = [] - for row in hit.iter_rows(named=True): - sym = row.get("symbol", "") - key = (rule["id"], sym) - last = self._last_fire.get(key) - if last is not None and (now - last) < cooldown: - continue - self._last_fire[key] = now - - name = row.get("name") or self._name_map.get(sym) or sym - price = row.get("close") - pct = row.get("change_pct") - sealed_vol = row.get("_sealed_vol") - # 预警封单值 (展示用) - sealed_value = sealed_vol * 100 * (price or 0) if metric == "sealed_amount" else sealed_vol - - # message 体现预警封单量 + 阈值 - if metric == "sealed_amount": - sv_text = f"{sealed_value / 1e4:.0f}万{unit}" - th_text = f"{threshold / 1e4:.0f}万{unit}" - else: - sv_text = f"{sealed_value:,.0f} {unit}" - th_text = f"{threshold:,.0f} {unit}" - message = f"{warn_label} · 封单 {sv_text} ≤ {th_text}" - - events.append({ - "ts": int(now * 1000), - "rule_id": rule["id"], - "rule_name": rule.get("name", ""), - "source": "ladder", - "type": warn_label, - "symbol": sym, - "name": name, - "message": message, - "price": price, - "change_pct": pct, - "signals": [], - "severity": severity, - "conditions": [], - "logic": "and", - "sealed_value": sealed_value, # 预警封单量/额 (飞书+记录展示) - "sealed_metric": metric, - }) - return events - - def _default_message(self, rule: dict, ev_type: str = "", sym: str = "", - name: str = "", pct: Any = None, price: Any = None, - conditions: list[dict] | None = None) -> str: - """生成默认 message。 - - - strategy: 按变更方向生成 (进入/移出 + 涨跌幅) - - signal/price/market: 条件摘要 + 现价 + 涨跌幅 (避免笼统的「信号触发」) - """ - rtype = rule.get("type", "signal") - if rtype == "strategy": - # 从 StrategyEngine 取策略名; 失败则退化为 rule_name 里截取的部分 - sname = "" - sid = rule.get("strategy_id") - if sid and self._strategy_engine is not None: - try: - s = self._strategy_engine.get(sid) - sname = s.meta.get("name", "") or s.meta.get("id", "") - except Exception: # noqa: BLE001 - sname = "" - if not sname: - rn = rule.get("name", "") - sname = rn.split(" · ", 1)[1] if " · " in rn else (rn or "策略") - - if ev_type == "new_entry": - pct_text = "" - if pct is not None: - sign = "+" if pct >= 0 else "" - pct_text = f" {sign}{pct * 100:.1f}%" - return f"策略「{sname}」进入 {name}{pct_text}" - elif ev_type == "dropped": - pct_text = "" - if pct is not None: - sign = "+" if pct >= 0 else "" - pct_text = f" {sign}{pct * 100:.1f}%" - return f"策略「{sname}」移出 {name}{pct_text}" - return f"策略「{sname}」变更" - - # signal / price / market: 条件摘要 + 现价 + 涨跌幅 - # 条件摘要: 把 conditions (truth/比较) 拼成可读串, 如 "MA20金叉 且 量比>2" - cond_text = self._format_conditions_text(rule, conditions) - price_text = f"现价 {price}" if price is not None else "" - pct_text = "" - if pct is not None: - sign = "+" if pct >= 0 else "" - pct_text = f"{sign}{pct * 100:.1f}%" - tail = " · ".join(s for s in (price_text, pct_text) if s) - if cond_text and tail: - return f"{cond_text} · {tail}" - return cond_text or tail or "监控触发" - - @staticmethod - def _format_conditions_text(rule: dict, conditions: list[dict] | None) -> str: - """把 rule.conditions 拼成可读文本 (用于 message / 推送)。 - - op=truth: 直接用信号中文名 (如 "MA20金叉") - op=比较: 字段中文名 + 操作符 + 值 (如 "涨跌幅≥5") - logic: and → "且", or → "或" - """ - conds = conditions if conditions is not None else list(rule.get("conditions", [])) - if not conds: - return "" - logic_word = "且" if rule.get("logic", "and") == "and" else "或" - parts: list[str] = [] - for c in conds: - field = c.get("field", "") - op = c.get("op", "truth") - value = c.get("value") - label = _signal_cn_name(field) or field - if op == "truth": - parts.append(label) - else: - op_map = {"gte": "≥", "lte": "≤", "gt": ">", "lt": "<", "eq": "="} - parts.append(f"{label}{op_map.get(op, op)}{value}") - return f" {logic_word} ".join(parts) diff --git a/serve/backend/app/strategy/monitor_rules.py b/serve/backend/app/strategy/monitor_rules.py deleted file mode 100644 index d074f4e..0000000 --- a/serve/backend/app/strategy/monitor_rules.py +++ /dev/null @@ -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 diff --git a/serve/backend/app/tickflow/capabilities.py b/serve/backend/app/tickflow/capabilities.py index 633cd46..4b82921 100644 --- a/serve/backend/app/tickflow/capabilities.py +++ b/serve/backend/app/tickflow/capabilities.py @@ -22,7 +22,6 @@ class Cap(StrEnum): INTRADAY_BATCH = "intraday.batch" DEPTH5 = "depth5" DEPTH5_BATCH = "depth5.batch" - WEBSOCKET = "websocket" FINANCIAL = "financial" ADJ_FACTOR = "adj_factor" diff --git a/serve/backend/app/tickflow/client.py b/serve/backend/app/tickflow/client.py index d02b608..5a0c087 100644 --- a/serve/backend/app/tickflow/client.py +++ b/serve/backend/app/tickflow/client.py @@ -18,7 +18,6 @@ from app import secrets_store _sync_client: TickFlow | None = None _async_client: AsyncTickFlow | None = None -_paid_realtime_client: TickFlow | None = None # ===== 服务器归属判定 ===== @@ -72,27 +71,11 @@ def get_async_client() -> AsyncTickFlow: return _async_client -def get_paid_realtime_client() -> TickFlow | None: - """实时行情专用付费服务器客户端。 - - none/free 的历史日K仍走 get_client() 的 free-api;实时行情全部走付费服务器。 - Free 档如果有有效 key,也使用这里的 paid endpoint 调按标的实时接口。 - """ - global _paid_realtime_client - key = secrets_store.get_tickflow_key() - if not key: - return None - if _paid_realtime_client is None: - _paid_realtime_client = TickFlow(api_key=key, base_url=_base_url()) - return _paid_realtime_client - - def reset_clients() -> None: """Key 变化后调用 — 让下一次 get_client() 拿新实例。""" - global _sync_client, _async_client, _paid_realtime_client + global _sync_client, _async_client _sync_client = None _async_client = None - _paid_realtime_client = None def current_mode() -> str: diff --git a/serve/backend/app/tickflow/policy.py b/serve/backend/app/tickflow/policy.py index 0442f48..8e20afd 100644 --- a/serve/backend/app/tickflow/policy.py +++ b/serve/backend/app/tickflow/policy.py @@ -236,12 +236,6 @@ def _probe_real(tiers: dict) -> tuple[CapabilitySet, list[str]]: defaults(Cap.ADJ_FACTOR)) # websocket 不在探测期试连接(成本太高且阻塞),按档位默认推断 - # 若 expert 的其他 cap 都通,则推断 websocket 也可用 - if (Cap.FINANCIAL in available and Cap.INTRADAY_BATCH in available): - available[Cap.WEBSOCKET] = CapabilityLimits( - subscribe=defaults(Cap.WEBSOCKET).get("subscribe", 100), - ) - log.append("✓ websocket (inferred from expert tier)") return CapabilitySet(available), log @@ -297,7 +291,7 @@ def detect_capabilities(force: bool = False) -> CapabilitySet: # 拥有**任意一个**即认作该档及以上。自上而下匹配。 # 这套设计的好处:单个 capability 探测的 transient 失败不会把整体档位"误降"。 TIER_SIGNATURES: dict[str, set[Cap]] = { - "expert": {Cap.FINANCIAL, Cap.INTRADAY_BATCH, Cap.WEBSOCKET}, + "expert": {Cap.FINANCIAL, Cap.INTRADAY_BATCH}, "pro": {Cap.KLINE_MINUTE_BATCH, Cap.KLINE_MINUTE_BY_SYMBOL, Cap.INTRADAY, Cap.DEPTH5, Cap.DEPTH5_BATCH}, "starter": {Cap.QUOTE_BATCH, Cap.KLINE_DAILY_BATCH, @@ -359,7 +353,6 @@ _CAP_ALIASES: dict[Cap, str] = { Cap.INTRADAY_BATCH: "批量分时", Cap.DEPTH5: "五档", Cap.DEPTH5_BATCH: "批量五档", - Cap.WEBSOCKET: "WS", Cap.FINANCIAL: "财务", Cap.ADJ_FACTOR: "复权", Cap.QUOTE_BATCH: "批量行情", diff --git a/serve/frontend/src/components/Layout.tsx b/serve/frontend/src/components/Layout.tsx index e46dbbe..73aa5be 100644 --- a/serve/frontend/src/components/Layout.tsx +++ b/serve/frontend/src/components/Layout.tsx @@ -1,8 +1,7 @@ import { useEffect, useRef, useState } from 'react' -import { NavLink, Outlet, useNavigate } from 'react-router-dom' -import { useQuery, useQueryClient } from '@tanstack/react-query' +import { NavLink, Outlet } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' import { motion } from 'framer-motion' -import { useQuoteStream } from '@/lib/useQuoteStream' import { ToastContainer } from '@/components/Toast' import { AlertToastContainer } from '@/components/AlertToast' import { AiAnalysisHost } from '@/components/financials/AiAnalysisHost' @@ -13,14 +12,9 @@ import { useCapabilities, useSettings, usePreferences, - useQuoteStatus, useVersion, } from '@/lib/useSharedQueries' -import { - useToggleRealtimeQuotes, -} from '@/lib/useSharedMutations' import { QK } from '@/lib/queryKeys' -import { tierRank } from '@/lib/capability-labels' import { Star, ScanSearch, @@ -42,26 +36,14 @@ import { RadioTower, CheckCircle2, BookOpenCheck, - ExternalLink, - X, } from 'lucide-react' import { Logo } from './Logo' -import { api, type IndexQuote } from '@/lib/api' +import { api } from '@/lib/api' import { cn } from '@/lib/cn' import { setCurrentTotal as setAlertTotal, useUnreadAlerts } from '@/lib/monitorBadge' // 品牌色 — 只用于 logo / brand 区域,不影响功能语义色 const BRAND = '#8B5CF6' -const TICKFLOW_REGISTER_URL = 'https://tickflow.org/auth/register?ref=V3KDKGXPEA' - -const CORE_INDEXES = [ - { symbol: '000001.SH', name: '上证指数' }, - { symbol: '399001.SZ', name: '深证成指' }, - { symbol: '399006.SZ', name: '创业板指' }, - { symbol: '000680.SH', name: '科创综指' }, -] as const - -type CoreIndex = (typeof CORE_INDEXES)[number] const nav = [ { to: '/', label: '看板', icon: LayoutDashboard }, @@ -80,23 +62,6 @@ const nav = [ { to: '/data', label: '数据', icon: Database }, ] as const -function fmtIndexValue(v: number | null | undefined) { - if (v == null || Number.isNaN(Number(v))) return '--' - return Number(v).toFixed(2) -} - -function fmtIndexPct(v: number | null | undefined) { - if (v == null || Number.isNaN(Number(v))) return '--' - return `${Number(v) >= 0 ? '+' : ''}${Number(v).toFixed(2)}%` -} - -function indexPctClass(v: number | null | undefined) { - if (v == null || Number.isNaN(Number(v))) return 'text-muted' - const n = Number(v) - if (n === 0) return 'text-foreground' - return n > 0 ? 'text-bull' : 'text-bear' -} - /** 监控中心未读徽标 — 仅在非监控页且有未读时显示。 */ function MonitorBadge({ active }: { active: boolean }) { const unread = useUnreadAlerts() @@ -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 ( -