重置项目
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
集中管理全市场行情拉取 + enriched 缓存,供盘中选股、自选股等所有模块复用。
|
||||
|
||||
架构:
|
||||
- 后台线程轮询数据源 get_by_universes(["CN_Equity_A", "CN_Index"])
|
||||
- 后台线程轮询 TickFlow get_by_universes(["CN_Equity_A", "CN_Index"])
|
||||
- 拉取行情 → 写 kline_daily (不复权) + 增量计算 enriched → 写盘 + 更新缓存
|
||||
- _enriched_cache 是唯一的盘中数据源 (OHLCV + 全套技术指标)
|
||||
- _live_agg_cache 是递推状态 (只加载一次, 盘中不变)
|
||||
@@ -43,6 +43,7 @@ class QuoteService:
|
||||
"expert": 1.0,
|
||||
"pro": 2.0,
|
||||
"starter": 3.0,
|
||||
"free": 6.0,
|
||||
}
|
||||
DEFAULT_INTERVAL = 10.0
|
||||
MAX_INTERVAL = 60.0
|
||||
@@ -59,6 +60,10 @@ class QuoteService:
|
||||
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)
|
||||
|
||||
@@ -68,6 +73,7 @@ class QuoteService:
|
||||
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
|
||||
|
||||
# ================================================================
|
||||
@@ -102,11 +108,11 @@ class QuoteService:
|
||||
def enable(self) -> bool:
|
||||
"""开启自动行情 (不立即启动线程,等下一个交易时段)。
|
||||
|
||||
none/free 档无实时行情权限,拒绝开启并返回 False;
|
||||
starter+ 正常启动。返回值表示是否真正开启。
|
||||
none 档无实时行情权限,拒绝开启并返回 False;
|
||||
free 档开启自选股实时,starter+ 开启全市场实时。返回值表示是否真正开启。
|
||||
"""
|
||||
if not self.is_realtime_allowed():
|
||||
logger.warning("实时行情开启被拒:当前档位(none/free)无实时行情权限")
|
||||
logger.warning("实时行情开启被拒:当前档位(none)无实时行情权限")
|
||||
return False
|
||||
self._enabled = True
|
||||
self._save_enabled(True)
|
||||
@@ -126,14 +132,14 @@ class QuoteService:
|
||||
def boot_check(self) -> None:
|
||||
"""启动时检查 preferences,若 enabled 则自动启动。
|
||||
|
||||
none/free 档无实时行情权限:即使 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/free)无实时行情权限")
|
||||
logger.info("实时行情未启动:当前档位(none)无实时行情权限")
|
||||
return
|
||||
if preferences.get_realtime_quotes_enabled():
|
||||
self.start()
|
||||
@@ -188,6 +194,34 @@ class QuoteService:
|
||||
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
|
||||
|
||||
# ================================================================
|
||||
# 档位感知间隔限制
|
||||
# ================================================================
|
||||
@@ -199,13 +233,19 @@ class QuoteService:
|
||||
return tier_label().split()[0].split("+")[0].strip().lower()
|
||||
|
||||
@classmethod
|
||||
def is_realtime_allowed(cls) -> bool:
|
||||
"""当前档位是否允许使用实时行情。
|
||||
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"
|
||||
|
||||
none/free 档走 free-api 服务器,无实时行情权限 → 不允许;
|
||||
starter+ 付费档走付费端点,有实时行情 → 允许。
|
||||
"""
|
||||
return cls._current_tier() not in ("none", "free")
|
||||
@classmethod
|
||||
def is_realtime_allowed(cls) -> bool:
|
||||
"""当前档位是否允许使用实时行情。"""
|
||||
return cls.realtime_mode() != "none"
|
||||
|
||||
@classmethod
|
||||
def _tier_min_interval(cls) -> float:
|
||||
@@ -251,7 +291,7 @@ class QuoteService:
|
||||
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():
|
||||
@@ -262,13 +302,19 @@ class QuoteService:
|
||||
|
||||
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,
|
||||
@@ -299,17 +345,47 @@ class QuoteService:
|
||||
waited += 0.5
|
||||
|
||||
def _fetch_quotes(self) -> None:
|
||||
"""拉取全市场行情 → 写 daily + 计算 enriched + 更新缓存。"""
|
||||
from app.tickflow.client import get_client
|
||||
"""按当前档位拉取行情。"""
|
||||
if self.realtime_mode() == "watchlist":
|
||||
self._fetch_watchlist_quotes()
|
||||
return
|
||||
self._fetch_full_market_quotes()
|
||||
|
||||
tf = get_client()
|
||||
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()
|
||||
all_index_symbols.update(self.CORE_INDEX_SYMBOLS)
|
||||
resp = tf.quotes.get_by_universes(universes=["CN_Equity_A", "CN_Index"])
|
||||
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
|
||||
@@ -349,7 +425,11 @@ class QuoteService:
|
||||
})
|
||||
|
||||
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]
|
||||
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
|
||||
@@ -361,9 +441,10 @@ class QuoteService:
|
||||
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 只指数, 耗时 %.0fms", len(stock_records), len(index_records), fetch_ms)
|
||||
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)
|
||||
@@ -373,12 +454,22 @@ class QuoteService:
|
||||
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)
|
||||
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()
|
||||
@@ -386,6 +477,87 @@ class QuoteService:
|
||||
# ---- 策略监控 + 告警评估 ----
|
||||
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)
|
||||
|
||||
# ================================================================
|
||||
# 工具
|
||||
# ================================================================
|
||||
@@ -495,6 +667,8 @@ class QuoteService:
|
||||
return
|
||||
|
||||
all_alerts: list[dict] = []
|
||||
rule_events: list[dict] = []
|
||||
engine = None
|
||||
|
||||
# 通用监控规则评估 (统一引擎: signal/price/market/strategy)
|
||||
if self._app_state:
|
||||
@@ -511,7 +685,11 @@ class QuoteService:
|
||||
})
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("name_map 构建失败 (不影响监控): %s", e)
|
||||
rule_events = engine.evaluate(enriched_today)
|
||||
# 连板梯队封单监控: 有 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:
|
||||
@@ -535,11 +713,13 @@ class QuoteService:
|
||||
"change_pct": ev["change_pct"],
|
||||
"signals": ev["signals"],
|
||||
"severity": ev.get("severity", "info"),
|
||||
"conditions": ev.get("conditions") or [],
|
||||
"logic": ev.get("logic") or "and",
|
||||
})
|
||||
|
||||
# 刷新策略结果缓存 (实时行情开启时,每轮行情更新后自动重算)
|
||||
if self._enabled and self._app_state:
|
||||
self._refresh_strategy_cache(enriched_today, enriched_date)
|
||||
# 策略页实时回显: 不写文件 (实时行情每轮更新 enriched, 写文件会被 read_cache
|
||||
# 的 mtime 校验判过期, 反复读不到)。监控引擎本轮已算出的结果存在内存
|
||||
# (latest_strategy_results), 由 /api/screener/cached 端点直接叠加读取。
|
||||
|
||||
# 推入待推送队列 + 通知 SSE (含背压保护)
|
||||
if all_alerts:
|
||||
@@ -556,9 +736,95 @@ class QuoteService:
|
||||
# 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 开关控制)。
|
||||
|
||||
@@ -592,95 +858,11 @@ class QuoteService:
|
||||
else:
|
||||
body = message or name
|
||||
|
||||
title = f"Stock Panel · {source_label}"
|
||||
title = f"TickFlow · {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 注入。"""
|
||||
@@ -690,7 +872,7 @@ class QuoteService:
|
||||
# enriched 增量计算
|
||||
# ================================================================
|
||||
|
||||
def _flush_live_enriched(self, daily_df: pl.DataFrame, quote_extra: pl.DataFrame = None) -> None:
|
||||
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 等),
|
||||
@@ -701,11 +883,16 @@ class QuoteService:
|
||||
t0 = time.perf_counter()
|
||||
|
||||
# ---- 尝试增量路径 ----
|
||||
live_agg = self._repo.get_live_agg()
|
||||
prev_enriched, prev_date = self._repo.get_enriched_latest()
|
||||
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 = (
|
||||
not live_agg.is_empty()
|
||||
asset_type == "stock"
|
||||
and not live_agg.is_empty()
|
||||
and not prev_enriched.is_empty()
|
||||
and prev_date is not None
|
||||
)
|
||||
@@ -736,7 +923,8 @@ class QuoteService:
|
||||
"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")
|
||||
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)
|
||||
@@ -753,14 +941,15 @@ class QuoteService:
|
||||
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"
|
||||
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()
|
||||
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)
|
||||
@@ -769,7 +958,10 @@ class QuoteService:
|
||||
return
|
||||
|
||||
# ---- 写盘 + 更新缓存 ----
|
||||
self._repo.flush_live_enriched(enriched_today)
|
||||
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 "全量"
|
||||
|
||||
Reference in New Issue
Block a user