"""全局实时行情服务。 集中管理全市场行情拉取 + enriched 缓存,供盘中选股、自选股等所有模块复用。 架构: - 后台线程轮询数据源 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, } 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 # 背压上限: 超出丢弃最旧 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._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/free 档无实时行情权限,拒绝开启并返回 False; starter+ 正常启动。返回值表示是否真正开启。 """ if not self.is_realtime_allowed(): logger.warning("实时行情开启被拒:当前档位(none/free)无实时行情权限") 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/free 档无实时行情权限:即使 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/free)无实时行情权限") 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 # ================================================================ # 档位感知间隔限制 # ================================================================ @staticmethod def _current_tier() -> str: """获取当前档位名(小写)。""" from app.tickflow.policy import tier_label return tier_label().split()[0].split("+")[0].strip().lower() @classmethod def is_realtime_allowed(cls) -> bool: """当前档位是否允许使用实时行情。 none/free 档走 free-api 服务器,无实时行情权限 → 不允许; starter+ 付费档走付费端点,有实时行情 → 允许。 """ return cls._current_tier() not in ("none", "free") @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: """返回实时指数行情缓存。不会触发数据源请求。""" 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: """返回行情服务状态。""" age = (time.perf_counter() - self._fetch_time) * 1000 if self._fetch_time else -1 return { "enabled": self._enabled, "running": self._running, "interval_s": self._interval, "symbol_count": self._symbol_count, "index_symbol_count": self._index_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: """拉取全市场行情 → 写 daily + 计算 enriched + 更新缓存。""" from app.tickflow.client import get_client tf = get_client() t0 = time.perf_counter() now_ts = time.perf_counter() try: all_index_symbols = set(self._repo.get_index_symbol_set()) if self._repo else set() all_index_symbols.update(self.CORE_INDEX_SYMBOLS) resp = tf.quotes.get_by_universes(universes=["CN_Equity_A", "CN_Index"]) 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] stock_records = [r for r in records if r.get("symbol") not in all_index_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._index_quotes_cache = self._build_index_quotes(index_records) logger.info("行情刷新: %d 只股票, %d 只指数, 耗时 %.0fms", len(stock_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) # ---- 构建 API 直接值的补充表 (不写 daily, 只用于 enriched 计算) ---- quote_extra = self._build_quote_extra(stock_records) # ---- 增量计算 enriched + 写盘 + 更新缓存 ---- if not daily_df.is_empty() and self._repo: self._flush_live_enriched(daily_df, quote_extra) # ---- 通知 SSE ---- 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] = [] # 通用监控规则评估 (统一引擎: 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) rule_events = engine.evaluate(enriched_today) 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"), }) # 刷新策略结果缓存 (实时行情开启时,每轮行情更新后自动重算) if self._enabled and self._app_state: self._refresh_strategy_cache(enriched_today, enriched_date) # 推入待推送队列 + 通知 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) except Exception as e: # noqa: BLE001 logger.warning("监控评估失败: %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"Stock Panel · {source_label}" notify_adapter.notify(title, body) except Exception as e: # noqa: BLE001 logger.debug("系统通知发送异常 (不影响告警主流程): %s", e) def _refresh_strategy_cache(self, enriched_today: pl.DataFrame, enriched_date: date | None) -> None: """利用已计算好的 enriched 数据,运行策略池并写入缓存。""" import math from dataclasses import asdict from app.services import strategy_cache from app.services.screener import PRESET_STRATEGIES, ScreenerService from app.strategy import config as strategy_config try: if enriched_date is None: return as_of = enriched_date data_dir = self._repo.store.data_dir svc = ScreenerService(self._repo) engine = getattr(self._app_state, "strategy_engine", None) # 确定要运行的策略: 策略监控池中的策略 monitor_ids = self._get_monitor_pool_ids() if not monitor_ids: return # 一次加载所有 override all_overrides = strategy_config.list_overrides(data_dir) # 历史策略: 只在需要时加载 shared_history = None history_strats = [] if engine: id_set = set(monitor_ids) history_strats = [ (sid, s) for sid, s in engine._strategies.items() if s.filter_history_fn and sid in id_set ] if history_strats: max_lb = max(s.lookback_days for _, s in history_strats) shared_history = svc._load_enriched_history(as_of, max(1, max_lb)) results: dict[str, dict] = {} for sid in monitor_ids: try: overrides = all_overrides.get(sid, {}) bf = overrides.get("basic_filter") if overrides else None dl = overrides.get("display_limit") if overrides else None if dl is None and overrides and "display_limit" in overrides: dl = 0 if sid in PRESET_STRATEGIES: r = svc.run_preset(sid, as_of=as_of, precomputed=enriched_today, basic_filter=bf, display_limit=dl) elif engine: r = engine.run( sid, as_of, overrides=overrides or None, precomputed=enriched_today, precomputed_history=shared_history, ) if dl is not None and dl > 0: r.rows = r.rows[:dl] r.total = min(r.total, dl) else: continue # sanitize NaN/Inf rows = [] for row_dict in asdict(r).get("rows", []): for k, v in list(row_dict.items()): if isinstance(v, float) and not math.isfinite(v): row_dict[k] = None rows.append(row_dict) results[sid] = {"total": r.total, "as_of": str(as_of), "rows": rows} except Exception: # noqa: BLE001 continue if results: strategy_cache.write_cache(data_dir, str(as_of), results) except Exception as e: # noqa: BLE001 logger.warning("策略缓存刷新失败: %s", e) def _get_monitor_pool_ids(self) -> list[str]: """获取策略监控池中的策略 ID 列表。""" from app.services import preferences ids = preferences.get_strategy_monitor_ids() if not ids: return [] return [sid for sid in ids if sid] @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) -> 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() prev_enriched, prev_date = self._repo.get_enriched_latest() use_incremental = ( 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) daily_glob = str(self._repo.store.data_dir / "kline_daily" / "**" / "*.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_path = self._repo.store.data_dir / "adj_factor" / "all.parquet" factors = pl.DataFrame() if factor_path.exists(): try: factors = pl.read_parquet(factor_path) except Exception: pass instruments = self._repo.get_instruments() 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 # ---- 写盘 + 更新缓存 ---- self._repo.flush_live_enriched(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)