"""市场总览数据装配(与 HTTP Request 解耦)。 本模块由 `app.api.overview._build_overview` 抽离而来,目的是让「大盘复盘」 等无 Request 的调用方(定时任务、复盘服务)也能复用同一套聚合逻辑。 公共入口: build_market_overview(repo, as_of) """ from __future__ import annotations import math import re from datetime import date from typing import Any import polars as pl from app.services.ext_data import ExtConfig, ExtConfigStore from app.services.screener import ScreenerService # ================================================================ # 常量(与 overview.py 保持同步;复盘复盘仅 A 股核心指数) # ================================================================ 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 _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))) # ================================================================ # 指数行情(从 kline_index_daily SQL 读取) # ================================================================ def _index_quotes(repo, as_of: date | None = None) -> list[dict]: rows: list[dict] = [] 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 _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], repo, 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(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(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} # ================================================================ # Top 行 / 涨跌幅分桶 # ================================================================ 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_market_overview( repo, as_of: date | None = None, ) -> dict: """装配市场总览(与原 overview._build_overview 行为一致)。 Args: repo: KlineRepository(必填)。 as_of: 指定日期,None 则取最新有数据日。 """ svc = ScreenerService(repo) as_of = as_of or svc.latest_date() indices = _index_quotes(repo, as_of) if not as_of: return { "as_of": None, "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) 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, repo, "concept") industry_rank = _dimension_rank(rows, repo, "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), "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}, "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_pct, "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, })