Files
tick-stock-panel/backend/app/services/sector_monitor.py
T
shy3130 3c6ed99922 feat(index): 指数收敛为固定核心四只 + fuyao 指数快照接入
- 新建 app/services/index_const.py 单一权威: 展示层固定四只核心指数
  (上证/深证成指/创业板/科创综指), quote_service/overview/
  market_overview_builder/sector_monitor 四处重复定义全部收敛
- fuyao 插件接入指数快照端点 (/api/a-share-index/prices/snapshot):
  client 增加 index_snapshot (批量上限 627 码), provider 实现可选协议
  get_realtime_indices — .BJ 后缀先行过滤 (未知代码整批 1002 连坐),
  volume 不做股转手; 修复 fuyao 路由下指数冻结在日K兜底的 bug
- quote_service 自定义源分支鸭子类型调用 get_realtime_indices 补拉指数,
  请求清单 = 核心四只 + 启用指数监控规则标的; 未实现的源走日K兜底
- TickFlow 分支指数固定按码显式拉取, 移除 CN_Index 全量 universe 与
  mode core/all 分支; 指数落盘固定 merge 不截断
- 指数偏好全套下线: realtime_index_symbols / sidebar_index_symbols /
  indices_nav_pinned / realtime_pull_index / realtime_index_mode
- /api/index/list 与 /search (全指数浏览搜索) 删除; daily/minute 保留
  供指数详情页, sync_instruments/sync_daily 保留供数据页
- sector_monitor 指数标的恒可监控, catalog 签名不再依赖指数偏好
- 新增 7 个用例: 指数快照映射/.BJ 过滤/软失败 + 自定义源指数补充链路
  (含监控规则并入/无协议源静默/指数失败软降级)
2026-08-31 16:50:21 +08:00

350 lines
14 KiB
Python

"""板块监控目标目录与实时聚合快照。"""
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
from app.services.index_const import CORE_INDEX_NAMES as CORE_INDICES
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
# 指数标的恒可实时监控: quote_service 对核心四只 + 启用规则标的显式拉取
for symbol, name in sorted(index_names.items()):
target = {
"key": f"index:{symbol}",
"kind": "index",
"name": name,
"symbol": symbol,
"available": True,
"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()
]
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()