mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
feat(monitor): 新增板块异动监控
This commit is contained in:
@@ -1116,6 +1116,11 @@ class QuoteService:
|
||||
rule_events = engine.evaluate(eval_df, asset_type="stock")
|
||||
if engine.consume_strategy_result_updates():
|
||||
self.notify_strategy_results_updated()
|
||||
if engine.has_rule_type("sector"):
|
||||
rule_events += engine.evaluate_sectors(
|
||||
enriched_today if stock_ready else pl.DataFrame(),
|
||||
self.get_index_quotes(),
|
||||
)
|
||||
# ETF 规则轮: 股票快照不含 ETF, 用 ETF enriched 快照单独评估。
|
||||
# 独立 try —— ETF 轮任何异常都不得丢弃本轮已算出的股票告警。
|
||||
# refresh=False —— 不在轮询线程上触发 ETF 冷缓存的同步重算 (缓存由 ETF 实时
|
||||
@@ -1154,7 +1159,7 @@ class QuoteService:
|
||||
logger.warning("告警落盘失败: %s", e)
|
||||
# 转为 SSE 推送格式 (兼容旧 alert schema)
|
||||
for ev in rule_events:
|
||||
all_alerts.append({
|
||||
alert = {
|
||||
"source": ev["source"],
|
||||
"type": ev["type"],
|
||||
"rule_id": ev.get("rule_id"),
|
||||
@@ -1168,7 +1173,16 @@ class QuoteService:
|
||||
"severity": ev.get("severity", "info"),
|
||||
"conditions": ev.get("conditions") or [],
|
||||
"logic": ev.get("logic") or "and",
|
||||
})
|
||||
}
|
||||
for key in (
|
||||
"sector_kind", "sector_key", "sector_name",
|
||||
"sector_source_field", "sector_value", "sector_level",
|
||||
"window_change_pct", "coverage_ratio", "valid_count",
|
||||
"total_count", "up_count", "down_count", "leader",
|
||||
):
|
||||
if key in ev:
|
||||
alert[key] = ev[key]
|
||||
all_alerts.append(alert)
|
||||
|
||||
# 策略页实时回显: 不写文件 (实时行情每轮更新 enriched, 写文件会被 read_cache
|
||||
# 的 mtime 校验判过期, 反复读不到)。监控引擎本轮已算出的结果存在内存
|
||||
@@ -1339,6 +1353,7 @@ class QuoteService:
|
||||
source_labels = {
|
||||
"strategy": "策略", "signal": "信号",
|
||||
"price": "价格", "market": "异动", "ladder": "连板梯队",
|
||||
"sector": "板块",
|
||||
}
|
||||
rules = engine.rules if engine is not None else {}
|
||||
enqueued = 0
|
||||
@@ -1391,7 +1406,7 @@ class QuoteService:
|
||||
source = ev.get("source", "")
|
||||
source_label = {
|
||||
"strategy": "策略", "signal": "信号",
|
||||
"price": "价格", "market": "异动",
|
||||
"price": "价格", "market": "异动", "sector": "板块",
|
||||
}.get(source, source or "通知")
|
||||
|
||||
name = ev.get("name") or ""
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
"""板块监控目标目录与实时聚合快照。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
import re
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import polars as pl
|
||||
|
||||
from app.services import preferences
|
||||
from app.services.ext_data import ExtConfig, ExtConfigStore
|
||||
|
||||
CORE_INDICES = {
|
||||
"000001.SH": "上证指数",
|
||||
"399001.SZ": "深证成指",
|
||||
"399006.SZ": "创业板指",
|
||||
"000680.SH": "科创综指",
|
||||
}
|
||||
SECTOR_KINDS = {"index", "concept", "industry"}
|
||||
_VALUE_SEP = re.compile(r"[\u3001,\uff0c;\uff1b|]+")
|
||||
_NULL_VALUES = {"nan", "none", "null", "<na>", "n/a", "-"}
|
||||
_HISTORY_SECONDS = 31 * 60
|
||||
_WINDOW_TOLERANCE_SECONDS = 90
|
||||
|
||||
|
||||
def _finite(value: Any) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return number if math.isfinite(number) else None
|
||||
|
||||
|
||||
def _dimension_kind(field_name: str, field_label: str) -> str | None:
|
||||
text = f"{field_name} {field_label}".lower()
|
||||
if any(word in text for word in ("概念", "题材", "concept", "theme")):
|
||||
return "concept"
|
||||
if any(word in text for word in ("行业", "申万", "中信", "industry", "sector")):
|
||||
return "industry"
|
||||
return None
|
||||
|
||||
|
||||
def _target_key(kind: str, source_id: str, field: str, value: str, level: int | None) -> str:
|
||||
raw = f"{kind}\0{source_id}\0{field}\0{level or 0}\0{value}"
|
||||
digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16]
|
||||
return f"{kind}:{digest}"
|
||||
|
||||
|
||||
class SectorMonitorService:
|
||||
"""缓存板块成员关系, 并按启用规则构建轻量实时快照。"""
|
||||
|
||||
def __init__(self, repo) -> None:
|
||||
self._repo = repo
|
||||
self._data_dir: Path = repo.store.data_dir
|
||||
self._catalog_signature: tuple[tuple[str, int, int], ...] | None = None
|
||||
self._catalog: dict[str, list[dict]] = {kind: [] for kind in SECTOR_KINDS}
|
||||
self._targets_by_key: dict[str, dict] = {}
|
||||
self._members_by_key: dict[str, set[str]] = {}
|
||||
self._history: dict[str, deque[tuple[float, float]]] = {}
|
||||
self._history_day: str | None = None
|
||||
|
||||
def list_targets(self) -> dict[str, list[dict]]:
|
||||
self._ensure_catalog()
|
||||
return {kind: [dict(item) for item in self._catalog[kind]] for kind in self._catalog}
|
||||
|
||||
def missing_target_keys(self, targets: list[dict]) -> list[str]:
|
||||
self._ensure_catalog()
|
||||
return [str(target.get("key") or "") for target in targets if target.get("key") not in self._targets_by_key]
|
||||
|
||||
def unavailable_target_keys(self, targets: list[dict]) -> list[str]:
|
||||
self._ensure_catalog()
|
||||
return [
|
||||
str(target.get("key") or "")
|
||||
for target in targets
|
||||
if target.get("key") in self._targets_by_key
|
||||
and not self._targets_by_key[target["key"]].get("available", True)
|
||||
]
|
||||
|
||||
def build_snapshots(
|
||||
self,
|
||||
stock_df: pl.DataFrame,
|
||||
index_df: pl.DataFrame,
|
||||
targets: list[dict],
|
||||
windows: set[int],
|
||||
*,
|
||||
now: float,
|
||||
) -> dict[str, dict]:
|
||||
if not targets:
|
||||
return {}
|
||||
self._ensure_catalog()
|
||||
self._reset_history_for_day(now)
|
||||
|
||||
stock_rows = self._row_map(stock_df, index_values_are_percent=False)
|
||||
index_rows = self._row_map(index_df, index_values_are_percent=True)
|
||||
snapshots: dict[str, dict] = {}
|
||||
|
||||
for raw_target in targets:
|
||||
key = str(raw_target.get("key") or "")
|
||||
target = self._targets_by_key.get(key)
|
||||
if not target:
|
||||
continue
|
||||
if target["kind"] == "index":
|
||||
snapshot = self._index_snapshot(target, index_rows)
|
||||
else:
|
||||
snapshot = self._dimension_snapshot(target, stock_rows)
|
||||
if snapshot is None:
|
||||
continue
|
||||
|
||||
change_pct = snapshot.get("change_pct")
|
||||
history = self._history.setdefault(key, deque())
|
||||
if snapshot["valid"] and change_pct is not None:
|
||||
history.append((now, float(change_pct)))
|
||||
while history and history[0][0] < now - _HISTORY_SECONDS:
|
||||
history.popleft()
|
||||
|
||||
snapshot["window_changes"] = {
|
||||
window: self._window_change(history, now, window, change_pct)
|
||||
for window in windows
|
||||
}
|
||||
snapshots[key] = snapshot
|
||||
return snapshots
|
||||
|
||||
def _ensure_catalog(self) -> None:
|
||||
signature = self._data_signature()
|
||||
if signature == self._catalog_signature:
|
||||
return
|
||||
catalog = {kind: [] for kind in SECTOR_KINDS}
|
||||
targets_by_key: dict[str, dict] = {}
|
||||
members_by_key: dict[str, set[str]] = {}
|
||||
|
||||
index_names = dict(CORE_INDICES)
|
||||
try:
|
||||
indices = self._repo.get_index_instruments()
|
||||
if not indices.is_empty() and "symbol" in indices.columns:
|
||||
for row in indices.to_dicts():
|
||||
if row.get("asset_type") == "etf":
|
||||
continue
|
||||
symbol = str(row.get("symbol") or "").strip().upper()
|
||||
if symbol:
|
||||
index_names[symbol] = str(row.get("name") or symbol)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
realtime_index_enabled = preferences.get_realtime_pull_index()
|
||||
realtime_indices = set(preferences.get_realtime_index_symbols() or CORE_INDICES)
|
||||
all_indices_enabled = preferences.get_realtime_index_mode() == "all"
|
||||
for symbol, name in sorted(index_names.items()):
|
||||
target = {
|
||||
"key": f"index:{symbol}",
|
||||
"kind": "index",
|
||||
"name": name,
|
||||
"symbol": symbol,
|
||||
"available": realtime_index_enabled and (all_indices_enabled or symbol in realtime_indices),
|
||||
"member_count": 1,
|
||||
}
|
||||
catalog["index"].append(target)
|
||||
targets_by_key[target["key"]] = target
|
||||
catalog["index"].sort(key=lambda item: (not item["available"], item["symbol"]))
|
||||
|
||||
for config in ExtConfigStore(self._data_dir).load_all():
|
||||
df = self._read_ext_dataframe(config)
|
||||
if df.is_empty():
|
||||
continue
|
||||
symbol_col = self._symbol_column(config, df)
|
||||
if not symbol_col:
|
||||
continue
|
||||
for field in config.fields:
|
||||
kind = _dimension_kind(field.name, field.label)
|
||||
if kind is None or field.name not in df.columns:
|
||||
continue
|
||||
for row in df.select([symbol_col, field.name]).iter_rows(named=True):
|
||||
symbol = str(row.get(symbol_col) or "").strip().upper()
|
||||
if not symbol:
|
||||
continue
|
||||
for raw_value in self._dimension_values(row.get(field.name)):
|
||||
paths = self._industry_paths(raw_value) if kind == "industry" else [(raw_value, None, raw_value)]
|
||||
for value, level, name in paths:
|
||||
key = _target_key(kind, config.id, field.name, value, level)
|
||||
members_by_key.setdefault(key, set()).add(symbol)
|
||||
if key not in targets_by_key:
|
||||
target = {
|
||||
"key": key,
|
||||
"kind": kind,
|
||||
"name": name,
|
||||
"source_id": config.id,
|
||||
"field": field.name,
|
||||
"source_field": f"{config.id}.{field.name}",
|
||||
"value": value,
|
||||
"level": level,
|
||||
"available": True,
|
||||
}
|
||||
targets_by_key[key] = target
|
||||
catalog[kind].append(target)
|
||||
|
||||
for kind in ("concept", "industry"):
|
||||
for target in catalog[kind]:
|
||||
target["member_count"] = len(members_by_key.get(target["key"], set()))
|
||||
catalog[kind].sort(key=lambda item: (item.get("level") or 0, item["name"], item["value"]))
|
||||
|
||||
self._catalog_signature = signature
|
||||
self._catalog = catalog
|
||||
self._targets_by_key = targets_by_key
|
||||
self._members_by_key = members_by_key
|
||||
self._history.clear()
|
||||
|
||||
def _data_signature(self) -> tuple[tuple[str, int, int], ...]:
|
||||
base = self._data_dir / "ext_data"
|
||||
paths: list[Path] = []
|
||||
for config in ExtConfigStore(self._data_dir).load_all():
|
||||
if not any(_dimension_kind(field.name, field.label) for field in config.fields):
|
||||
continue
|
||||
config_dir = base / config.id
|
||||
paths.extend(config_dir.rglob("config.json"))
|
||||
paths.extend(config_dir.rglob("*.parquet"))
|
||||
signature = [
|
||||
(str(path), path.stat().st_mtime_ns, path.stat().st_size)
|
||||
for path in sorted(paths)
|
||||
if path.is_file()
|
||||
]
|
||||
index_mode = preferences.get_realtime_index_mode()
|
||||
index_enabled = preferences.get_realtime_pull_index()
|
||||
index_symbols = sorted(preferences.get_realtime_index_symbols() or CORE_INDICES)
|
||||
signature.append((f"realtime_indices:{index_enabled}:{index_mode}:{','.join(index_symbols)}", 0, 0))
|
||||
return tuple(signature)
|
||||
|
||||
def _read_ext_dataframe(self, config: ExtConfig) -> pl.DataFrame:
|
||||
base = self._data_dir / "ext_data" / config.id
|
||||
if config.mode == "timeseries":
|
||||
files = sorted((base / "timeseries").rglob("*.parquet"))
|
||||
files = files[-1:] if files else []
|
||||
else:
|
||||
files = sorted(base.glob("*.parquet"))
|
||||
if not files:
|
||||
return pl.DataFrame()
|
||||
try:
|
||||
return pl.read_parquet(files)
|
||||
except Exception:
|
||||
return pl.DataFrame()
|
||||
|
||||
@staticmethod
|
||||
def _symbol_column(config: ExtConfig, df: pl.DataFrame) -> str | None:
|
||||
candidates = ["symbol", "code", "股票代码", "代码"]
|
||||
for mapping in (config.symbol_map, config.code_map):
|
||||
if isinstance(mapping, dict) and mapping.get("type") == "mapped":
|
||||
candidates.append(str(mapping.get("col") or ""))
|
||||
return next((column for column in candidates if column in df.columns), None)
|
||||
|
||||
@staticmethod
|
||||
def _dimension_values(raw: Any) -> list[str]:
|
||||
if raw is None:
|
||||
return []
|
||||
return [
|
||||
value.strip()
|
||||
for value in _VALUE_SEP.split(str(raw))
|
||||
if value.strip() and value.strip().casefold() not in _NULL_VALUES
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _industry_paths(raw: str) -> list[tuple[str, int, str]]:
|
||||
parts = [part.strip() for part in raw.split("-") if part.strip()]
|
||||
if not parts:
|
||||
return []
|
||||
return [
|
||||
("-".join(parts[:level]), level, " / ".join(parts[:level]))
|
||||
for level in range(1, len(parts) + 1)
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _row_map(df: pl.DataFrame, *, index_values_are_percent: bool) -> dict[str, dict]:
|
||||
if df.is_empty() or "symbol" not in df.columns:
|
||||
return {}
|
||||
rows: dict[str, dict] = {}
|
||||
for row in df.to_dicts():
|
||||
symbol = str(row.get("symbol") or "").strip().upper()
|
||||
if not symbol:
|
||||
continue
|
||||
change_pct = _finite(row.get("change_pct"))
|
||||
if change_pct is not None and index_values_are_percent:
|
||||
change_pct /= 100
|
||||
rows[symbol] = {**row, "change_pct": change_pct}
|
||||
return rows
|
||||
|
||||
@staticmethod
|
||||
def _index_snapshot(target: dict, index_rows: dict[str, dict]) -> dict | None:
|
||||
row = index_rows.get(str(target.get("symbol") or "").upper())
|
||||
if not row or row.get("change_pct") is None:
|
||||
return None
|
||||
return {
|
||||
**target,
|
||||
"valid": True,
|
||||
"change_pct": row["change_pct"],
|
||||
"price": _finite(row.get("close") or row.get("last_price")),
|
||||
"coverage_ratio": 1.0,
|
||||
"valid_count": 1,
|
||||
"total_count": 1,
|
||||
"up_count": int(row["change_pct"] > 0),
|
||||
"down_count": int(row["change_pct"] < 0),
|
||||
"leader": None,
|
||||
}
|
||||
|
||||
def _dimension_snapshot(self, target: dict, stock_rows: dict[str, dict]) -> dict | None:
|
||||
members = self._members_by_key.get(target["key"], set())
|
||||
if not members:
|
||||
return None
|
||||
valid_rows = [
|
||||
stock_rows[symbol]
|
||||
for symbol in members
|
||||
if symbol in stock_rows and stock_rows[symbol].get("change_pct") is not None
|
||||
]
|
||||
total_count = len(members)
|
||||
valid_count = len(valid_rows)
|
||||
coverage_ratio = valid_count / total_count if total_count else 0.0
|
||||
valid = total_count >= 5 and coverage_ratio >= 0.8
|
||||
changes = [float(row["change_pct"]) for row in valid_rows]
|
||||
leader = max(valid_rows, key=lambda row: row["change_pct"]) if valid_rows else None
|
||||
return {
|
||||
**target,
|
||||
"valid": valid,
|
||||
"change_pct": sum(changes) / len(changes) if changes else None,
|
||||
"price": None,
|
||||
"coverage_ratio": coverage_ratio,
|
||||
"valid_count": valid_count,
|
||||
"total_count": total_count,
|
||||
"up_count": sum(value > 0 for value in changes),
|
||||
"down_count": sum(value < 0 for value in changes),
|
||||
"leader": {
|
||||
"symbol": leader.get("symbol"),
|
||||
"name": leader.get("name"),
|
||||
"change_pct": leader.get("change_pct"),
|
||||
} if leader else None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _window_change(
|
||||
history: deque[tuple[float, float]],
|
||||
now: float,
|
||||
window: int,
|
||||
current: float | None,
|
||||
) -> float | None:
|
||||
if current is None:
|
||||
return None
|
||||
cutoff = now - window * 60
|
||||
for timestamp, previous in reversed(history):
|
||||
if timestamp <= cutoff:
|
||||
if timestamp < cutoff - _WINDOW_TOLERANCE_SECONDS:
|
||||
return None
|
||||
return current - previous
|
||||
return None
|
||||
|
||||
def _reset_history_for_day(self, now: float) -> None:
|
||||
day = datetime.fromtimestamp(now).date().isoformat()
|
||||
if self._history_day == day:
|
||||
return
|
||||
self._history_day = day
|
||||
self._history.clear()
|
||||
Reference in New Issue
Block a user