@@ -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 = {}
|
||||
|
||||
@@ -1,586 +0,0 @@
|
||||
"""五档盘口 sealed(真假涨停/跌停) 服务 — 独立旁路线。
|
||||
|
||||
架构(完全解耦):
|
||||
- 只读 enriched(拿涨跌停名单), 不写回 enriched(14列不动)
|
||||
- sealed 存独立 parquet(data/depth5/date=xxx/part.parquet)
|
||||
- limit_ladder API 查询时 LEFT JOIN(同 ext_columns 机制)
|
||||
- signal_limit_up 永远是"价格涨停", sealed 是叠加的真假判定层
|
||||
|
||||
数据流:
|
||||
盘中轮询线程(交易时段, 独立 sleep, 不绑行情轮询):
|
||||
读 enriched 内存缓存(线程安全) → 涨跌停名单 → tf.depth.batch
|
||||
→ 算 sealed → 更新内存缓存(不落盘) → sealed_ready=True
|
||||
盘后定版 job(可配置时间, 默认15:02):
|
||||
最后拉一次 → 落盘 depth5 parquet(定版)
|
||||
|
||||
三层防护节流("设过大设上限, 设过小设最小值"):
|
||||
① 套餐范围 clamp: Pro 10~120s, Expert 3~300s
|
||||
② 限速安全 clamp: safe = 60/((rpm*0.8)/batches), 涨跌停多就自动放慢
|
||||
③ 系统接管通知: 用户设置会超限时, 推 toast 告知已自动调整
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
from datetime import date, datetime, time as dt_time
|
||||
from pathlib import Path
|
||||
|
||||
import polars as pl
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# 套餐 → (轮询间隔下限s, 上限s)
|
||||
TIER_INTERVAL_RANGE: dict[str, tuple[float, float]] = {
|
||||
"pro": (10.0, 120.0),
|
||||
"expert": (3.0, 300.0),
|
||||
}
|
||||
# 兜底: 其他有 DEPTH5_BATCH 的套餐按 pro 范围
|
||||
DEFAULT_RANGE = (10.0, 120.0)
|
||||
|
||||
# 限速余量: 只用 rpm 的 80%, 给系统其他 depth 调用留空间
|
||||
RPM_MARGIN = 0.8
|
||||
# 间隔硬下限/上限(任何套餐)
|
||||
INTERVAL_HARD_MIN = 10.0
|
||||
INTERVAL_HARD_MAX = 300.0
|
||||
|
||||
|
||||
class DepthService:
|
||||
"""五档盘口 sealed 服务 — 单例。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._running = False
|
||||
self._thread: threading.Thread | None = None
|
||||
self._repo = None # 延迟注入(KlineRepository)
|
||||
self._app_state = None # 延迟注入(FastAPI app.state)
|
||||
|
||||
# 内存缓存: {symbol: SealedEntry}
|
||||
# SealedEntry = {sealed_up, sealed_down, ask1_vol, bid1_vol, status, fetched_ts}
|
||||
self._sealed_cache: dict[str, dict] = {}
|
||||
self._sealed_ready = False
|
||||
self._sealed_date: date | None = None # sealed 数据对应的交易日(可能是昨天,如休市)
|
||||
self._sealed_fetched_ts: float = 0.0 # 上次拉取的 perf_counter
|
||||
self._sealed_fetched_at: float = 0.0 # 上次拉取的 wall-clock 时间戳
|
||||
self._persisted_date: date | None = None # 已落盘的日期
|
||||
|
||||
# 系统接管状态(防通知刷屏)
|
||||
self._last_taken_over: bool | None = None
|
||||
self._last_user_interval: float | None = None
|
||||
|
||||
# ================================================================
|
||||
# 注入
|
||||
# ================================================================
|
||||
|
||||
def set_repo(self, repo) -> None:
|
||||
self._repo = repo
|
||||
|
||||
def set_app_state(self, app_state) -> None:
|
||||
self._app_state = app_state
|
||||
|
||||
# ================================================================
|
||||
# 生命周期
|
||||
# ================================================================
|
||||
|
||||
def boot_check(self) -> None:
|
||||
"""启动补跑: 当天 depth5 文件不存在则 finalize 一次; 已存在则恢复内存缓存。"""
|
||||
if not self._has_capability():
|
||||
logger.info("depth sealed: 无 DEPTH5_BATCH 能力, 跳过启动补跑")
|
||||
return
|
||||
today = date.today()
|
||||
if self._persisted_for_date(today):
|
||||
# parquet 已存在: 恢复内存缓存(避免重启后每次查询都读 parquet)
|
||||
self._restore_from_parquet(today)
|
||||
return
|
||||
logger.info("depth sealed: 启动补跑今天定版")
|
||||
try:
|
||||
self.finalize()
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("depth sealed 启动补跑失败: %s", e)
|
||||
|
||||
def _restore_from_parquet(self, d: date) -> None:
|
||||
"""从 parquet 恢复内存缓存(服务重启后)。"""
|
||||
if not self._repo:
|
||||
return
|
||||
out = self._repo.store.data_dir / "depth5" / f"date={d.isoformat()}" / "part.parquet"
|
||||
if not out.exists():
|
||||
return
|
||||
try:
|
||||
df = pl.read_parquet(out)
|
||||
cache: dict[str, dict] = {}
|
||||
for row in df.to_dicts():
|
||||
sym = row.get("symbol")
|
||||
if not sym:
|
||||
continue
|
||||
cache[sym] = {
|
||||
"sealed_up": row.get("sealed_up"),
|
||||
"sealed_down": row.get("sealed_down"),
|
||||
"ask1_vol": row.get("ask1_vol"),
|
||||
"bid1_vol": row.get("bid1_vol"),
|
||||
"status": row.get("status"),
|
||||
"fetched_ts": row.get("fetched_at"),
|
||||
}
|
||||
with self._lock:
|
||||
self._sealed_cache = cache
|
||||
self._sealed_ready = True
|
||||
self._sealed_date = d
|
||||
self._persisted_date = d
|
||||
logger.info("depth sealed: 从 parquet 恢复 %d 只 (日期=%s)", len(cache), d)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("depth sealed 从 parquet 恢复失败: %s", e)
|
||||
|
||||
def start_polling(self) -> None:
|
||||
"""启动盘中轮询线程(连板梯队监控开启 + 有能力 + 交易时段)。"""
|
||||
if self._running:
|
||||
return
|
||||
if not self._has_capability():
|
||||
return
|
||||
from app.services import preferences
|
||||
if not preferences.get_limit_ladder_monitor_enabled():
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
|
||||
self._thread.start()
|
||||
logger.info("depth sealed 盘中轮询已启动")
|
||||
|
||||
def stop_polling(self) -> None:
|
||||
"""停止盘中轮询线程。"""
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=10)
|
||||
self._thread = None
|
||||
logger.info("depth sealed 盘中轮询已停止")
|
||||
|
||||
def apply_monitor_toggle(self, enabled: bool) -> None:
|
||||
"""连板梯队监控开关切换时调用: 开启→启动轮询, 关闭→停止轮询。"""
|
||||
if enabled:
|
||||
self.start_polling()
|
||||
else:
|
||||
self.stop_polling()
|
||||
|
||||
def run_once(self) -> dict:
|
||||
"""手动触发一次修正(立即拉取 depth + 更新内存缓存)。
|
||||
|
||||
不受监控开关限制 — 用户可随时手动修正一次。
|
||||
返回 {"ok": bool, "count": int, "msg": str}
|
||||
"""
|
||||
if not self._has_capability():
|
||||
return {"ok": False, "count": 0, "msg": "无五档盘口能力(需 Pro+)"}
|
||||
try:
|
||||
self._fetch_and_seal(persist=True) # 落盘, 刷新页面不丢
|
||||
with self._lock:
|
||||
count = len(self._sealed_cache)
|
||||
return {"ok": True, "count": count, "msg": f"已修正 {count} 只"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("depth run_once 失败: %s", e)
|
||||
return {"ok": False, "count": 0, "msg": f"修正失败: {e}"}
|
||||
|
||||
# ================================================================
|
||||
# 核心拉取
|
||||
# ================================================================
|
||||
|
||||
def _fetch_and_seal(self, persist: bool = False) -> None:
|
||||
"""拉一次 depth.batch, 算 sealed, 更新内存缓存(可选落盘)。
|
||||
|
||||
persist=True: 盘后定版, 写 depth5 parquet
|
||||
persist=False: 盘中轮询, 只更新内存缓存
|
||||
"""
|
||||
if not self._repo:
|
||||
return
|
||||
|
||||
# 只读 enriched 内存缓存(线程安全, 避免和 quote_service 写盘竞态)
|
||||
enriched, enriched_date = self._repo.get_enriched_latest()
|
||||
if enriched.is_empty():
|
||||
return
|
||||
|
||||
# 筛涨跌停名单(用 fill_null 防止列缺失)
|
||||
syms_up: list[str] = []
|
||||
syms_down: list[str] = []
|
||||
if "signal_limit_up" in enriched.columns:
|
||||
syms_up = enriched.filter(
|
||||
pl.col("signal_limit_up").fill_null(False)
|
||||
)["symbol"].to_list()
|
||||
if "signal_limit_down" in enriched.columns:
|
||||
syms_down = enriched.filter(
|
||||
pl.col("signal_limit_down").fill_null(False)
|
||||
)["symbol"].to_list()
|
||||
|
||||
all_syms = list(dict.fromkeys(syms_up + syms_down)) # 去重保序
|
||||
if not all_syms:
|
||||
logger.debug("depth sealed: 当日无涨跌停股, 跳过")
|
||||
return
|
||||
|
||||
# 拉 depth(涨跌停一次拉, 按 capset batch 切片)
|
||||
depth_data = self._call_depth_batch(all_syms)
|
||||
if not depth_data:
|
||||
logger.warning("depth sealed: depth.batch 返回空")
|
||||
return
|
||||
|
||||
up_set = set(syms_up)
|
||||
down_set = set(syms_down)
|
||||
now_perf = time.perf_counter()
|
||||
now_wall = time.time()
|
||||
|
||||
new_cache: dict[str, dict] = {}
|
||||
for sym, d in depth_data.items():
|
||||
ask_vols = d.get("ask_volumes") or []
|
||||
bid_vols = d.get("bid_volumes") or []
|
||||
ask1 = ask_vols[0] if ask_vols else None
|
||||
bid1 = bid_vols[0] if bid_vols else None
|
||||
# depth 返回的 timestamp(毫秒 epoch), 回退到当前 wall-clock
|
||||
depth_ts = d.get("timestamp")
|
||||
fetched = (depth_ts / 1000.0) if isinstance(depth_ts, (int, float)) and depth_ts else now_wall
|
||||
entry = {
|
||||
# 涨停真封: 涨停价上卖一(主动卖压)为 0
|
||||
"sealed_up": (ask1 == 0) if sym in up_set and ask1 is not None else None,
|
||||
# 跌停真封: 跌停价上买一为 0
|
||||
"sealed_down": (bid1 == 0) if sym in down_set and bid1 is not None else None,
|
||||
"ask1_vol": ask1,
|
||||
"bid1_vol": bid1,
|
||||
"status": "limit_down" if sym in down_set and sym not in up_set else "limit_up",
|
||||
"fetched_ts": fetched,
|
||||
}
|
||||
new_cache[sym] = entry
|
||||
|
||||
with self._lock:
|
||||
self._sealed_cache = new_cache
|
||||
self._sealed_ready = True
|
||||
self._sealed_date = enriched_date # 记录数据对应的交易日(可能是昨天,如休市)
|
||||
self._sealed_fetched_ts = now_perf
|
||||
self._sealed_fetched_at = now_wall
|
||||
|
||||
logger.info("depth sealed: 拉取 %d 只 (涨停%d/跌停%d) 日期=%s%s",
|
||||
len(new_cache), len(syms_up), len(syms_down),
|
||||
enriched_date, " → 落盘" if persist else "")
|
||||
|
||||
# 缓存已更新: 通知 SSE 推 depth_updated, 触发连板梯队刷新封单数据。
|
||||
self._notify_depth_updated(len(new_cache))
|
||||
|
||||
if persist and enriched_date:
|
||||
self._persist(enriched_date)
|
||||
|
||||
def _call_depth_batch(self, symbols: list[str]) -> dict:
|
||||
"""调 tf.depth.batch, 按 capset 的 batch 切片 + 节流。返回 {symbol: MarketDepth}。"""
|
||||
from app.tickflow.client import get_client
|
||||
tf = get_client()
|
||||
|
||||
capset = self._get_capset()
|
||||
lim = capset.limits(__import__("app.tickflow.capabilities", fromlist=["Cap"]).Cap.DEPTH5_BATCH)
|
||||
batch_size = (lim.batch if lim and lim.batch else 100)
|
||||
rpm = (lim.rpm if lim and lim.rpm else 30)
|
||||
# 批间隔 = 60/rpm(匀速)
|
||||
inter_batch = 60.0 / rpm if rpm > 0 else 2.0
|
||||
|
||||
result: dict = {}
|
||||
chunks = [symbols[i:i + batch_size] for i in range(0, len(symbols), batch_size)]
|
||||
for i, chunk in enumerate(chunks):
|
||||
if i > 0:
|
||||
time.sleep(inter_batch)
|
||||
try:
|
||||
# SDK 的 batch 内部已按 batch_size 切, 这里再切一层防单请求过大
|
||||
data = tf.depth.batch(chunk)
|
||||
if isinstance(data, dict):
|
||||
result.update(data)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("depth.batch 第 %d 批失败(%d 只): %s", i + 1, len(chunk), e)
|
||||
# 单批失败不影响其他批
|
||||
return result
|
||||
|
||||
def finalize(self) -> None:
|
||||
"""盘后定版: 拉一次 + 落盘。"""
|
||||
if not self._has_capability():
|
||||
return
|
||||
self._fetch_and_seal(persist=True)
|
||||
|
||||
# ================================================================
|
||||
# 落盘
|
||||
# ================================================================
|
||||
|
||||
def _persist(self, today: date) -> None:
|
||||
"""把内存缓存写 depth5/date=今天/part.parquet。"""
|
||||
with self._lock:
|
||||
cache = dict(self._sealed_cache)
|
||||
if not cache:
|
||||
return
|
||||
|
||||
rows = []
|
||||
for sym, e in cache.items():
|
||||
rows.append({
|
||||
"symbol": sym,
|
||||
"sealed_up": e.get("sealed_up"),
|
||||
"sealed_down": e.get("sealed_down"),
|
||||
"ask1_vol": e.get("ask1_vol"),
|
||||
"bid1_vol": e.get("bid1_vol"),
|
||||
"status": e.get("status"),
|
||||
"fetched_at": e.get("fetched_ts"),
|
||||
})
|
||||
# 显式 schema: sealed_up/sealed_down 是 bool 与 None 混合, 不指定 schema
|
||||
# polars 会按首行推断类型, 后续遇到不一致 (bool vs null) 报
|
||||
# "could not append value: false of type: bool to the builder"。
|
||||
df = pl.DataFrame(rows, schema={
|
||||
"symbol": pl.Utf8,
|
||||
"sealed_up": pl.Boolean,
|
||||
"sealed_down": pl.Boolean,
|
||||
"ask1_vol": pl.Int64,
|
||||
"bid1_vol": pl.Int64,
|
||||
"status": pl.Utf8,
|
||||
"fetched_at": pl.Float64,
|
||||
})
|
||||
ds = today.isoformat()
|
||||
out = self._repo.store.data_dir / "depth5" / f"date={ds}" / "part.parquet"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
df.write_parquet(out)
|
||||
self._persisted_date = today
|
||||
logger.info("depth sealed 落盘: %d 行 → %s", df.height, out)
|
||||
|
||||
def _persisted_for_date(self, d: date) -> bool:
|
||||
"""检查某日 depth5 文件是否已存在。"""
|
||||
if not self._repo:
|
||||
return False
|
||||
out = self._repo.store.data_dir / "depth5" / f"date={d.isoformat()}" / "part.parquet"
|
||||
return out.exists()
|
||||
|
||||
# ================================================================
|
||||
# 查询(供 limit_ladder API 用)
|
||||
# ================================================================
|
||||
|
||||
def get_sealed_map(self, target_date: date, is_down: bool) -> dict:
|
||||
"""返回 {symbol: {sealed, vol, ready, age}} 供 JOIN。
|
||||
|
||||
优先内存缓存(盘中), 回退 parquet(历史/盘后)。
|
||||
sealed: bool | None (None=待确认或降级)
|
||||
vol: 封单量(int) | None
|
||||
ready: sealed 数据是否就绪(False→降级标识)
|
||||
age: 距上次拉取秒数(盘后定版为 None)
|
||||
"""
|
||||
# 内存缓存(sealed 数据对应的交易日 = target_date 时才用)
|
||||
if self._sealed_date and target_date == self._sealed_date and self._sealed_ready and self._sealed_cache:
|
||||
return self._read_from_memory(is_down)
|
||||
# parquet(历史或盘后定版)
|
||||
return self._read_from_parquet(target_date, is_down)
|
||||
|
||||
def _read_from_memory(self, is_down: bool) -> dict:
|
||||
sealed_key = "sealed_down" if is_down else "sealed_up"
|
||||
# 封单量: 涨停=买一量(涨停价买单堆积), 跌停=卖一量(跌停价卖单堆积)
|
||||
vol_key = "ask1_vol" if is_down else "bid1_vol"
|
||||
now = time.perf_counter()
|
||||
with self._lock:
|
||||
cache = dict(self._sealed_cache)
|
||||
fetched_ts = self._sealed_fetched_ts
|
||||
age = (now - fetched_ts) if fetched_ts else 0.0
|
||||
result = {}
|
||||
for sym, e in cache.items():
|
||||
result[sym] = {
|
||||
"sealed": e.get(sealed_key),
|
||||
"vol": e.get(vol_key),
|
||||
"ready": True,
|
||||
"age": age,
|
||||
}
|
||||
return result
|
||||
|
||||
def _read_from_parquet(self, target_date: date, is_down: bool) -> dict:
|
||||
if not self._repo:
|
||||
return {}
|
||||
out = self._repo.store.data_dir / "depth5" / f"date={target_date.isoformat()}" / "part.parquet"
|
||||
if not out.exists():
|
||||
return {}
|
||||
try:
|
||||
df = pl.read_parquet(out)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("depth5 parquet 读取失败: %s", e)
|
||||
return {}
|
||||
sealed_key = "sealed_down" if is_down else "sealed_up"
|
||||
# 封单量: 涨停=买一量, 跌停=卖一量
|
||||
vol_key = "ask1_vol" if is_down else "bid1_vol"
|
||||
result = {}
|
||||
for row in df.to_dicts():
|
||||
sym = row.get("symbol")
|
||||
if not sym:
|
||||
continue
|
||||
result[sym] = {
|
||||
"sealed": row.get(sealed_key),
|
||||
"vol": row.get(vol_key),
|
||||
"ready": True,
|
||||
"age": None, # 盘后定版, 无 age
|
||||
}
|
||||
return result
|
||||
|
||||
def is_sealed_ready(self, target_date: date) -> bool:
|
||||
"""sealed 数据是否就绪(供前端降级判定)。"""
|
||||
# 内存缓存对应的数据日 == 查询日 → 看内存就绪状态
|
||||
if self._sealed_date and target_date == self._sealed_date:
|
||||
return self._sealed_ready
|
||||
# 其他日期: 有 parquet 就 ready
|
||||
return self._persisted_for_date(target_date)
|
||||
|
||||
def get_sealed_age(self, target_date: date) -> float | None:
|
||||
"""返回 sealed 数据 age(秒), 盘后定版为 None。"""
|
||||
if self._sealed_date and target_date == self._sealed_date and self._sealed_ready and self._sealed_fetched_ts:
|
||||
return time.perf_counter() - self._sealed_fetched_ts
|
||||
return None
|
||||
|
||||
# ================================================================
|
||||
# 盘中轮询线程
|
||||
# ================================================================
|
||||
|
||||
def _poll_loop(self) -> None:
|
||||
"""盘中轮询: 按 capset 自适应间隔拉 depth, 更新内存缓存。"""
|
||||
while self._running:
|
||||
try:
|
||||
if self._is_trading_hours():
|
||||
self._poll_once()
|
||||
else:
|
||||
logger.debug("depth sealed: 非交易时段, 跳过")
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("depth sealed 轮询异常: %s", e)
|
||||
|
||||
# 等待下一轮(用 _running 检查保证能及时退出)
|
||||
interval = self._current_sleep_interval()
|
||||
waited = 0.0
|
||||
while self._running and waited < interval:
|
||||
time.sleep(0.5)
|
||||
waited += 0.5
|
||||
|
||||
def _poll_once(self) -> None:
|
||||
"""单次轮询: 算间隔(三层防护) → 拉取 → 检测系统接管通知。"""
|
||||
# 数当前涨跌停股
|
||||
n = self._count_limit_stocks()
|
||||
if n == 0:
|
||||
return
|
||||
|
||||
interval, taken_over, user_interval = self._compute_interval(n)
|
||||
|
||||
# 系统接管通知(状态切换时才推, 防刷屏)
|
||||
if taken_over and (self._last_taken_over is False or self._last_user_interval != user_interval):
|
||||
self._notify_takeover(n, user_interval, interval)
|
||||
self._last_taken_over = taken_over
|
||||
self._last_user_interval = user_interval
|
||||
|
||||
self._fetch_and_seal(persist=False)
|
||||
|
||||
def _current_sleep_interval(self) -> float:
|
||||
"""计算当前 sleep 间隔(供 _poll_loop 等待用)。"""
|
||||
n = self._count_limit_stocks()
|
||||
if n == 0:
|
||||
return 30.0 # 无涨跌停, 慢轮询
|
||||
interval, _, _ = self._compute_interval(n)
|
||||
return interval
|
||||
|
||||
# ================================================================
|
||||
# 三层防护节流
|
||||
# ================================================================
|
||||
|
||||
def _compute_interval(self, n_symbols: int) -> tuple[float, bool, float]:
|
||||
"""三层防护计算实际轮询间隔。
|
||||
|
||||
返回 (actual_interval, taken_over, user_interval)
|
||||
- actual_interval: 实际使用的间隔(秒)
|
||||
- taken_over: 是否被系统接管(用户设置会超限)
|
||||
- user_interval: 用户设置(经套餐 clamp 后)的间隔
|
||||
"""
|
||||
from app.services import preferences
|
||||
from app.tickflow.policy import tier_label
|
||||
|
||||
capset = self._get_capset()
|
||||
lim = capset.limits(__import__("app.tickflow.capabilities", fromlist=["Cap"]).Cap.DEPTH5_BATCH)
|
||||
batch_size = (lim.batch if lim and lim.batch else 100)
|
||||
rpm = (lim.rpm if lim and lim.rpm else 30)
|
||||
|
||||
# ① 套餐范围 clamp
|
||||
tier = tier_label().split()[0].split("+")[0].strip().lower()
|
||||
lo, hi = TIER_INTERVAL_RANGE.get(tier, DEFAULT_RANGE)
|
||||
raw_user = preferences.get_depth_polling_interval()
|
||||
user_interval = max(lo, min(hi, raw_user))
|
||||
|
||||
# ② 限速安全 clamp
|
||||
batches = max(1, math.ceil(n_symbols / batch_size))
|
||||
usable_rpm = rpm * RPM_MARGIN
|
||||
calls_per_min = usable_rpm / batches if batches > 0 else usable_rpm
|
||||
safe_interval = 60.0 / calls_per_min if calls_per_min > 0 else INTERVAL_HARD_MAX
|
||||
|
||||
# 实际: 取用户设置和安全的较大值
|
||||
actual = max(user_interval, safe_interval)
|
||||
# 硬上下限
|
||||
actual = max(INTERVAL_HARD_MIN, min(actual, INTERVAL_HARD_MAX))
|
||||
taken_over = safe_interval > user_interval
|
||||
|
||||
return actual, taken_over, user_interval
|
||||
|
||||
def _count_limit_stocks(self) -> int:
|
||||
"""数当前涨跌停股总数(供节流计算)。"""
|
||||
if not self._repo:
|
||||
return 0
|
||||
enriched, _ = self._repo.get_enriched_latest()
|
||||
if enriched.is_empty():
|
||||
return 0
|
||||
n = 0
|
||||
if "signal_limit_up" in enriched.columns:
|
||||
n += enriched.filter(pl.col("signal_limit_up").fill_null(False)).height
|
||||
if "signal_limit_down" in enriched.columns:
|
||||
n += enriched.filter(pl.col("signal_limit_down").fill_null(False)).height
|
||||
return n
|
||||
|
||||
# ================================================================
|
||||
# 通知
|
||||
# ================================================================
|
||||
|
||||
def _notify_takeover(self, n_stocks: int, user_interval: float, actual_interval: float) -> None:
|
||||
"""系统接管通知: 复用 quote_service 的 _pending_alerts 通道。"""
|
||||
if not self._app_state:
|
||||
return
|
||||
qs = getattr(self._app_state, "quote_service", None)
|
||||
if not qs:
|
||||
return
|
||||
msg = (f"五档轮询: 当前涨跌停 {n_stocks} 只, 您设置的 {user_interval:.0f} 秒间隔会超限, "
|
||||
f"系统已自动调整为 {actual_interval:.0f} 秒")
|
||||
alert = {
|
||||
"source": "depth",
|
||||
"type": "takeover",
|
||||
"message": msg,
|
||||
}
|
||||
try:
|
||||
with qs._lock:
|
||||
qs._pending_alerts.append(alert)
|
||||
qs._alert_event.set()
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("depth 接管通知推送失败: %s", e)
|
||||
|
||||
def _notify_depth_updated(self, count: int) -> None:
|
||||
"""修正完成通知: set quote_service._depth_update_event, SSE 推 depth_updated 刷新连板梯队。"""
|
||||
if not self._app_state:
|
||||
return
|
||||
qs = getattr(self._app_state, "quote_service", None)
|
||||
if not qs:
|
||||
return
|
||||
try:
|
||||
qs.notify_depth_updated()
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("depth 更新通知推送失败: %s", e)
|
||||
|
||||
# ================================================================
|
||||
# 工具
|
||||
# ================================================================
|
||||
|
||||
def _has_capability(self) -> bool:
|
||||
capset = self._get_capset()
|
||||
from app.tickflow.capabilities import Cap
|
||||
return capset.has(Cap.DEPTH5_BATCH)
|
||||
|
||||
def _get_capset(self):
|
||||
"""获取当前 capset(优先 app.state, 回退 detect)。"""
|
||||
if self._app_state:
|
||||
cs = getattr(self._app_state, "capabilities", None)
|
||||
if cs:
|
||||
return cs
|
||||
from app.tickflow.policy import detect_capabilities
|
||||
return detect_capabilities()
|
||||
|
||||
@staticmethod
|
||||
def _is_trading_hours() -> bool:
|
||||
now = datetime.now()
|
||||
t = now.time()
|
||||
morning = dt_time(9, 25) <= t <= dt_time(11, 35)
|
||||
afternoon = dt_time(12, 55) <= t <= dt_time(15, 5)
|
||||
return now.weekday() < 5 and (morning or afternoon)
|
||||
@@ -3,11 +3,8 @@
|
||||
本模块由 `app.api.overview._build_overview` 抽离而来,目的是让「大盘复盘」
|
||||
等无 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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", [])
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user