"""五档盘口 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)