"""市场总览聚合 API。""" from __future__ import annotations import math import re import time from datetime import date from typing import Any import polars as pl from fastapi import APIRouter, Request from app.services.ext_data import ExtConfig, ExtConfigStore from app.services.screener import ScreenerService router = APIRouter(prefix="/api/overview", tags=["overview"]) _CACHE_TTL = 5.0 _cache: dict[str, Any] | None = None _cache_key: str | None = None _cache_ts: float = 0.0 def invalidate_overview_cache() -> None: """清空总览聚合结果缓存。 清除数据后调用, 避免看板在 TTL 窗口内继续返回旧的聚合结果。 """ global _cache, _cache_key, _cache_ts _cache = None _cache_key = None _cache_ts = 0.0 CORE_INDEX_NAMES = { "000001.SH": "上证指数", "399001.SZ": "深证成指", "399006.SZ": "创业板指", "000680.SH": "科创综指", } CORE_INDEX_SYMBOLS = tuple(CORE_INDEX_NAMES.keys()) _DIMENSION_SEP = re.compile(r"[、,,;;|/\s]+") def _dimension_field(config: ExtConfig, kind: str) -> str | None: candidates = ["概念", "concept", "theme"] if kind == "concept" else ["行业", "industry", "sector"] for candidate in candidates: needle = candidate.lower() for field in config.fields: haystack = f"{field.name} {field.label}".lower() if needle in haystack: return field.name return None def _ext_files(data_dir, config: ExtConfig) -> list[str]: base = data_dir / "ext_data" / config.id if config.mode == "timeseries": root = base / "timeseries" return [str(p) for p in sorted(root.rglob("*.parquet")) if p.is_file()] return [str(p) for p in sorted(base.glob("*.parquet")) if p.is_file()] def _read_ext_rows(data_dir, config: ExtConfig, dimension_field: str) -> list[dict]: files = _ext_files(data_dir, config) if not files: return [] try: df = pl.read_parquet(files, hive_partitioning=True) except TypeError: try: df = pl.read_parquet(files) except Exception: # noqa: BLE001 return [] except Exception: # noqa: BLE001 return [] if df.is_empty() or dimension_field not in df.columns: return [] if config.mode == "timeseries" and "date" in df.columns: latest = df.get_column("date").max() if latest is not None: df = df.filter(pl.col("date") == latest) symbol_cols = ["symbol", "code", "股票代码", "代码"] for mapping in (config.symbol_map, config.code_map): if isinstance(mapping, dict) and mapping.get("type") == "mapped" and mapping.get("col"): symbol_cols.append(str(mapping["col"])) cols = [] for col in [dimension_field, *symbol_cols]: if col in df.columns and col not in cols: cols.append(col) return df.select(cols).to_dicts() def _dimension_values(raw: Any) -> list[str]: if raw is None: return [] values = [v.strip() for v in _DIMENSION_SEP.split(str(raw).strip()) if v.strip()] return values def _symbol_keys(row: dict, config: ExtConfig) -> list[str]: fields = ["symbol", "code", "股票代码", "代码"] for mapping in (config.symbol_map, config.code_map): if isinstance(mapping, dict) and mapping.get("type") == "mapped" and mapping.get("col"): fields.append(str(mapping["col"])) keys: list[str] = [] for field in fields: raw = row.get(field) if raw is None: continue text = str(raw).strip().upper() if not text: continue keys.append(text) if "." in text: keys.append(text.split(".", 1)[0]) return keys def _dimension_rank(rows: list[dict], request: Request, kind: str, limit: int = 5, level: int | None = None) -> dict: if not rows: return {"leading": [], "lagging": []} quote_map: dict[str, dict] = {} for row in rows: symbol = str(row.get("symbol") or "").strip().upper() if not symbol: continue quote_map[symbol] = row quote_map[symbol.split(".", 1)[0]] = row store = ExtConfigStore(request.app.state.repo.store.data_dir) groups: dict[str, dict[str, dict]] = {} for config in store.load_all(): field = _dimension_field(config, kind) if not field: continue for ext_row in _read_ext_rows(request.app.state.repo.store.data_dir, config, field): quote = None for key in _symbol_keys(ext_row, config): quote = quote_map.get(key) if quote: break if not quote: continue symbol = str(quote.get("symbol") or "") for value in _dimension_values(ext_row.get(field)): # 行业按 "-" 拆分级: "银行-银行-股份制银行" → level=2 取"银行"(二级) if level is not None and "-" in value: parts = value.split("-") value = parts[level - 1] if level <= len(parts) else parts[-1] groups.setdefault(value, {})[symbol] = quote items = [] for name, by_symbol in groups.items(): stocks = list(by_symbol.values()) changes = [_finite(s.get("change_pct")) for s in stocks] changes = [v for v in changes if v is not None] if not changes: continue leader = max(stocks, key=lambda s: _finite(s.get("change_pct")) or -999) items.append({ "name": name, "count": len(stocks), "avg_pct": sum(changes) / len(changes), "up_count": sum(1 for v in changes if v > 0), "down_count": sum(1 for v in changes if v < 0), "amount": sum(_finite(s.get("amount")) or 0 for s in stocks), "leader": { "symbol": leader.get("symbol"), "name": leader.get("name"), "change_pct": _finite(leader.get("change_pct")), }, }) leading = sorted(items, key=lambda x: x["avg_pct"], reverse=True)[:limit] lagging = sorted(items, key=lambda x: x["avg_pct"])[:limit] return {"leading": leading, "lagging": lagging} def _finite(v: Any) -> float | None: if v is None: return None try: f = float(v) except (TypeError, ValueError): return None return f if math.isfinite(f) else None def _json_safe(value: Any) -> Any: if isinstance(value, dict): return {k: _json_safe(v) for k, v in value.items()} if isinstance(value, list): return [_json_safe(v) for v in value] if isinstance(value, float) and not math.isfinite(value): return None return value def _board(symbol: str) -> str: if symbol.endswith(".BJ"): return "北交所" if symbol.startswith(("300", "301")): return "创业板" if symbol.startswith(("688", "689")): return "科创板" if symbol.endswith(".SH"): return "沪主板" if symbol.endswith(".SZ"): return "深主板" return "其他" def _score(value: float, low: float, high: float) -> int: if high <= low: return 50 return max(0, min(100, round((value - low) / (high - low) * 100))) def _quote_status(request: Request) -> dict: qs = getattr(request.app.state, "quote_service", None) if not qs: return {"enabled": False, "running": False, "quote_age_ms": None, "is_trading_hours": False} return qs.status() def _index_quotes(request: Request, as_of: date | None = None) -> list[dict]: qs = getattr(request.app.state, "quote_service", None) rows: list[dict] = [] if qs and as_of is None: df = qs.get_index_quotes(list(CORE_INDEX_SYMBOLS)) if not df.is_empty(): rows = df.to_dicts() if not rows: repo = getattr(request.app.state, "repo", None) if repo: placeholders = ", ".join("?" for _ in CORE_INDEX_SYMBOLS) try: db_rows = repo.execute_all( f""" WITH ranked AS ( SELECT symbol, date, close, row_number() OVER (PARTITION BY symbol ORDER BY date DESC) AS rn FROM kline_index_daily WHERE symbol IN ({placeholders}) AND (? IS NULL OR date <= ?) ), latest AS ( SELECT symbol, max(CASE WHEN rn = 1 THEN date END) AS date, max(CASE WHEN rn = 1 THEN close END) AS last_price, max(CASE WHEN rn = 2 THEN close END) AS prev_close FROM ranked WHERE rn <= 2 GROUP BY symbol ) SELECT symbol, date, last_price, prev_close FROM latest """, [*CORE_INDEX_SYMBOLS, as_of, as_of], ) except Exception: # noqa: BLE001 db_rows = [] for symbol, dt, last_price, prev_close in db_rows: change_amount = None change_pct = None lp = _finite(last_price) pc = _finite(prev_close) if lp is not None and pc not in (None, 0): change_amount = lp - pc change_pct = change_amount / pc * 100 rows.append({ "symbol": symbol, "name": CORE_INDEX_NAMES.get(symbol), "date": str(dt) if dt else None, "last_price": lp, "close": lp, "prev_close": pc, "change_amount": change_amount, "change_pct": change_pct, }) by_symbol = {r.get("symbol"): r for r in rows} out = [] for symbol in CORE_INDEX_SYMBOLS: r = by_symbol.get(symbol, {"symbol": symbol}) out.append({ "symbol": symbol, "name": r.get("name") or CORE_INDEX_NAMES[symbol], "last_price": _finite(r.get("last_price") if r.get("last_price") is not None else r.get("close")), "change_pct": _finite(r.get("change_pct")), "change_amount": _finite(r.get("change_amount")), }) return out def _top_rows(rows: list[dict], key: str, descending: bool, limit: int = 8) -> list[dict]: filtered = [r for r in rows if _finite(r.get(key)) is not None] filtered.sort(key=lambda r: _finite(r.get(key)) or 0, reverse=descending) return [ { "symbol": r.get("symbol"), "name": r.get("name"), "close": _finite(r.get("close")), "change_pct": _finite(r.get("change_pct")), "amount": _finite(r.get("amount")), "turnover_rate": _finite(r.get("turnover_rate")), "board": _board(str(r.get("symbol") or "")), } for r in filtered[:limit] ] def _pct_band_rows(values: list[float]) -> list[dict]: bands = [ ("<-5%", None, -0.05), ("-5~-3%", -0.05, -0.03), ("-3~-1%", -0.03, -0.01), ("-1~0%", -0.01, 0), ("0~1%", 0, 0.01), ("1~3%", 0.01, 0.03), ("3~5%", 0.03, 0.05), (">5%", 0.05, None), ] total = len(values) or 1 out = [] for label, low, high in bands: count = 0 for v in values: if low is None and v < high: count += 1 elif high is None and v >= low: count += 1 elif low is not None and high is not None and low <= v < high: count += 1 out.append({"label": label, "count": count, "pct": count / total * 100}) return out def _build_overview(request: Request, as_of: date | None = None) -> dict: repo = request.app.state.repo svc = ScreenerService(repo) as_of = as_of or svc.latest_date() status = _quote_status(request) indices = _index_quotes(request, 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}, "boards": [], "limit": {"limit_up": 0, "broken": 0, "failed": 0, "limit_down": 0, "max_boards": 0, "tiers": []}, "distribution": [], "trend": {"above_ma5": 0, "above_ma20": 0, "above_ma60": 0, "above_ma5_pct": 0, "above_ma20_pct": 0, "above_ma60_pct": 0, "new_high": 0, "new_low": 0}, "activity": {"avg_turnover": 0, "high_turnover": 0, "high_vol_ratio": 0, "vol_ratio": 1}, "radar": [], "emotion": {"score": 50, "label": "暂无"}, "top_gainers": [], "top_losers": [], "turnover_leaders": [], "active_leaders": [], "concept_rank": {"leading": [], "lagging": []}, "industry_rank": {"leading": [], "lagging": []}, } df = svc._load_enriched_for_date(as_of) if df.is_empty(): rows: list[dict] = [] else: cols = [ "symbol", "name", "close", "change_pct", "amount", "turnover_rate", "volume", "vol_ratio_5d", "consecutive_limit_ups", "signal_limit_up", "signal_broken_limit_up", "signal_limit_down", "ma5", "ma20", "ma60", "high_60d", "low_60d", "signal_n_day_high", "signal_n_day_low", ] df = df.select([c for c in cols if c in df.columns]) rows = df.to_dicts() # 过滤真停牌(volume=0 且 change_pct=0),保留有涨跌幅的浮点误差股以对齐同花顺口径 if rows and "volume" in rows[0]: rows = [r for r in rows if (_finite(r.get("volume")) or 0) > 0 or (_finite(r.get("change_pct")) or 0) != 0] total = len(rows) up = sum(1 for r in rows if (_finite(r.get("change_pct")) or 0) > 0) down = sum(1 for r in rows if (_finite(r.get("change_pct")) or 0) < 0) flat = max(0, total - up - down) up_pct = up / total * 100 if total else 0 down_pct = down / total * 100 if total else 0 amounts = [_finite(r.get("amount")) or 0 for r in rows] total_amount = sum(amounts) avg_amount = total_amount / total if total else 0 pct_values = [_finite(r.get("change_pct")) for r in rows] pct_values = [v for v in pct_values if v is not None] avg_pct = sum(pct_values) / len(pct_values) if pct_values else 0 median_pct = sorted(pct_values)[len(pct_values) // 2] if pct_values else 0 strong_up = sum(1 for v in pct_values if v >= 0.03) strong_down = sum(1 for v in pct_values if v <= -0.03) limit_up = sum(1 for r in rows if bool(r.get("signal_limit_up")) or (_finite(r.get("consecutive_limit_ups")) or 0) > 0) broken = sum(1 for r in rows if bool(r.get("signal_broken_limit_up"))) 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 能力) depth_svc = getattr(request.app.state, "depth_service", None) sealed_ready = False fake_up = 0 fake_down = 0 if depth_svc: up_map = depth_svc.get_sealed_map(as_of, is_down=False) down_map = depth_svc.get_sealed_map(as_of, is_down=True) sealed_ready = bool(up_map or down_map) and depth_svc.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: return sum(1 for r in rows if (_finite(r.get("close")) is not None and _finite(r.get(ma_key)) is not None and (_finite(r.get("close")) or 0) >= (_finite(r.get(ma_key)) or 0))) above_ma5 = above_ma_count("ma5") above_ma20 = above_ma_count("ma20") above_ma60 = above_ma_count("ma60") new_high = sum(1 for r in rows if bool(r.get("signal_n_day_high")) or (_finite(r.get("close")) is not None and _finite(r.get("high_60d")) is not None and (_finite(r.get("close")) or 0) >= (_finite(r.get("high_60d")) or 0))) new_low = sum(1 for r in rows if bool(r.get("signal_n_day_low")) or (_finite(r.get("close")) is not None and _finite(r.get("low_60d")) is not None and (_finite(r.get("close")) or 0) <= (_finite(r.get("low_60d")) or 0))) turnovers = [_finite(r.get("turnover_rate")) for r in rows] turnovers = [v for v in turnovers if v is not None] avg_turnover = sum(turnovers) / len(turnovers) if turnovers else 0 high_turnover = sum(1 for v in turnovers if v >= 5) boards_map: dict[str, dict] = {} for r in rows: b = _board(str(r.get("symbol") or "")) item = boards_map.setdefault(b, {"board": b, "count": 0, "up": 0, "down": 0, "amount": 0.0}) item["count"] += 1 change = _finite(r.get("change_pct")) or 0 if change > 0: item["up"] += 1 elif change < 0: item["down"] += 1 item["amount"] += _finite(r.get("amount")) or 0 boards = sorted(boards_map.values(), key=lambda x: x["amount"], reverse=True) for b in boards: count = b["count"] or 1 b["up_pct"] = b["up"] / count * 100 tiers_map: dict[int, int] = {} for r in rows: n = int(_finite(r.get("consecutive_limit_ups")) or 0) if n > 0: tiers_map[n] = tiers_map.get(n, 0) + 1 tiers = [{"boards": k, "count": v} for k, v in sorted(tiers_map.items(), key=lambda item: -item[0])] index_changes = [_finite(r.get("change_pct")) for r in indices] index_changes = [v for v in index_changes if v is not None] avg_index_pct = sum(index_changes) / len(index_changes) if index_changes else 0 vol_ratios = [_finite(r.get("vol_ratio_5d")) for r in rows] vol_ratios = [v for v in vol_ratios if v is not None] avg_vol_ratio = sum(vol_ratios) / len(vol_ratios) if vol_ratios else 1 high_vol_ratio = sum(1 for v in vol_ratios if v >= 1.5) concept_rank = _dimension_rank(rows, request, "concept") industry_rank = _dimension_rank(rows, request, "industry", level=2) strong_diff_pct = (strong_up - strong_down) / total * 100 if total else 0 high_vol_pct = high_vol_ratio / total * 100 if total else 0 strong_down_pct = strong_down / total * 100 if total else 0 tier2_count = sum(t["count"] for t in tiers if t["boards"] >= 2) mainline_items = [*concept_rank["leading"][:3], *industry_rank["leading"][:3]] mainline_avg = max([_finite(item.get("avg_pct")) or 0 for item in mainline_items], default=0) mainline_cover_pct = max([(_finite(item.get("count")) or 0) / total * 100 for item in mainline_items], default=0) if total else 0 mainline_score = round(_score(mainline_avg, -0.005, 0.03) * 0.65 + _score(mainline_cover_pct, 1, 12) * 0.35) if mainline_items else 50 radar = [ {"key": "index", "label": "指数", "value": _score(avg_index_pct, -2.5, 2.5)}, {"key": "profit", "label": "赚钱", "value": round(_score(up_pct, 20, 80) * 0.45 + _score(avg_pct, -0.02, 0.02) * 0.25 + _score(median_pct, -0.02, 0.02) * 0.20 + _score(strong_diff_pct, -8, 8) * 0.10)}, {"key": "money", "label": "量能", "value": round(_score(avg_vol_ratio, 0.6, 1.8) * 0.70 + _score(high_vol_pct, 2, 12) * 0.30)}, {"key": "speculation", "label": "投机", "value": round(_score(limit_up, 5, 90) * 0.25 + _score(seal_rate, 30, 85) * 0.35 + _score(max_boards, 1, 8) * 0.25 + _score(tier2_count, 0, 30) * 0.15)}, {"key": "resilience", "label": "抗跌", "value": 100 - round(_score(down_pct, 20, 80) * 0.55 + _score(strong_down_pct, 1, 12) * 0.45)}, {"key": "mainline", "label": "主线", "value": mainline_score}, ] emotion_score = round(sum(r["value"] for r in radar) / len(radar)) if radar else 50 if emotion_score >= 70: emotion_label = "强势" elif emotion_score >= 55: emotion_label = "偏暖" elif emotion_score >= 45: emotion_label = "震荡" elif emotion_score >= 30: emotion_label = "偏冷" else: emotion_label = "冰点" return _json_safe({ "as_of": str(as_of), "quote_status": status, "indices": indices, "breadth": { "total": total, "up": up, "down": down, "flat": flat, "up_pct": up_pct, "down_pct": down_pct, "avg_pct": avg_pct, "median_pct": median_pct, "strong_up": strong_up, "strong_down": strong_down, }, "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}, "distribution": _pct_band_rows(pct_values), "trend": { "above_ma5": above_ma5, "above_ma20": above_ma20, "above_ma60": above_ma60, "above_ma5_pct": above_ma5 / total * 100 if total else 0, "above_ma20_pct": above_ma20 / total * 100 if total else 0, "above_ma60_pct": above_ma60 / total * 100 if total else 0, "new_high": new_high, "new_low": new_low, }, "activity": { "avg_turnover": avg_turnover, "high_turnover": high_turnover, "high_vol_ratio": high_vol_ratio, "vol_ratio": avg_vol_ratio, }, "radar": radar, "emotion": {"score": emotion_score, "label": emotion_label}, "top_gainers": _top_rows(rows, "change_pct", True), "top_losers": _top_rows(rows, "change_pct", False), "turnover_leaders": _top_rows(rows, "amount", True), "active_leaders": _top_rows(rows, "turnover_rate", True), "concept_rank": concept_rank, "industry_rank": industry_rank, }) @router.get("/market") def market_overview(request: Request, as_of: date | None = None): """总览页单次请求聚合数据,避免前端拉全市场明细后再计算。""" global _cache, _cache_key, _cache_ts now = time.time() cache_key = as_of.isoformat() if as_of else "latest" if _cache is not None and _cache_key == cache_key and (now - _cache_ts) < _CACHE_TTL: return _cache data = _build_overview(request, as_of) _cache = data _cache_key = cache_key _cache_ts = now return data