From c9f01ba288a5fa0488c39f7d639ccca83284e683 Mon Sep 17 00:00:00 2001 From: shy3130 Date: Sun, 2 Aug 2026 19:18:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A1=8C=E4=B8=9A=E8=BD=AE=E5=8A=A8?= =?UTF-8?q?=E5=88=86=E6=9E=90(=E5=8F=82=E6=95=B0=E5=8C=96=E5=A4=8D?= =?UTF-8?q?=E7=94=A8=E6=A6=82=E5=BF=B5=E7=89=88)=20+=20=E6=89=A9=E5=B1=95?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=88=97=E6=95=B0=E5=AD=97=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 行业轮动分析(完全对齐概念版) - 概念轮动逻辑参数化支持 industry 维度, 一套代码服务两个页面(复用 _dimension_field) - 后端 build_rps_rotation/analyze_rotation_stream 加 kind 参数; 缓存按 kind+level 隔离 - 行业层级: 1/2/3 级可切换, 默认 2 级(与 _dimension_rank 口径一致) · 原始 257 个 3 级行业 → level=2 合并成 90 个 - AI 分析 prompt 动态化(概念/行业措辞), _SYSTEM_PROMPT 改为 _build_system_prompt(kind) - 前端 RpsRotationDialog 加 kind prop; industry 显示层级选择器(1/2/3级默认2) - IndustryAnalysis 加「涨幅RPS轮动分析」按钮 + dialog(kind=industry) - ConceptAnalysis 不动(默认 kind=concept 向后兼容) 扩展数据列数字格式化(自选/策略列表) - 列配置新增 3 项(仅数字类型字段): 千分位逗号 / 单位换算(万/亿/自动) / 小数位 - 新增 formatExtNumber 共享格式化函数(千分位+换算+小数位, 去尾零) - Watchlist + ScreenerTable 的 number 渲染接 formatExtNumber - ext source 加 fieldType; 旧列无 fieldType 时默认显示数字配置(放宽判断) - ExtColumnDisplayConfig 加 thousandSeparator/unitConvert/unitDecimals 字段 验证 - 后端 583 passed; 行业映射实测 257→90(level=2) - 数字格式化实测: 千分位 1,234,567 / 万换算 123.46万 / 自动 等均正确 - tsc + pnpm build 通过 --- backend/app/api/rps.py | 20 +-- .../app/services/concept_rotation_analyzer.py | 60 +++++---- backend/app/services/rps_rotation.py | 116 +++++++++--------- .../src/components/ListColumnCustomizer.tsx | 66 +++++++++- frontend/src/components/RpsRotationDialog.tsx | 45 +++++-- .../src/components/screener/ScreenerTable.tsx | 9 +- frontend/src/lib/api.ts | 8 +- frontend/src/lib/format.ts | 53 ++++++++ frontend/src/lib/list-columns.ts | 8 +- frontend/src/pages/IndustryAnalysis.tsx | 12 ++ frontend/src/pages/Watchlist.tsx | 10 +- 11 files changed, 299 insertions(+), 108 deletions(-) diff --git a/backend/app/api/rps.py b/backend/app/api/rps.py index 558aa9a..015309e 100644 --- a/backend/app/api/rps.py +++ b/backend/app/api/rps.py @@ -19,26 +19,30 @@ router = APIRouter(prefix="/api/rps", tags=["rps"]) def get_rotation( request: Request, days: int = Query(12, ge=7, le=30, description="最近 N 个交易日(7-30)"), + kind: str = Query("concept", pattern="concept|industry", description="维度: concept 概念 / industry 行业"), + level: int | None = Query(None, ge=1, le=3, description="行业层级(仅 kind=industry): 1/2/3 级"), ) -> dict: - """概念涨幅轮动矩阵。 + """维度涨幅轮动矩阵(概念或行业)。 Returns: dates: 日期字符串列表(最新在最前) - columns: {日期: [[概念名, 涨幅小数], ...]} 每列各自降序 - concept_count: 去重概念总数 + columns: {日期: [[成员名, 涨幅小数], ...]} 每列各自降序 + concept_count: 去重维度成员总数 """ - return rps_rotation.build_rps_rotation(request.app.state.repo, days) + return rps_rotation.build_rps_rotation(request.app.state.repo, days, kind, level) class AnalyzeRequest(BaseModel): - """AI 概念轮动分析请求。""" + """AI 维度轮动分析请求(概念或行业)。""" days: int = 12 # 分析最近 N 个交易日 focus: str = "" # 用户追加的关注点 + kind: str = "concept" # "concept" 概念 / "industry" 行业 + level: int | None = None # 行业层级(1/2/3), 仅 kind=industry 有效 @router.post("/rotation-analyze") async def analyze_rotation(request: Request, req: AnalyzeRequest): - """AI 概念轮动分析 — NDJSON 流式返回。 + """AI 维度轮动分析 — NDJSON 流式返回。 装配轮动矩阵信号 + 大盘背景 → 分析提示词 → 流式调用 LLM → 逐 chunk 以 NDJSON 推给前端(每行一个 JSON)。 @@ -53,10 +57,12 @@ async def analyze_rotation(request: Request, req: AnalyzeRequest): quote_service = getattr(request.app.state, "quote_service", None) depth_service = getattr(request.app.state, "depth_service", None) days = max(7, min(30, req.days)) + kind = "industry" if req.kind == "industry" else "concept" + level = req.level if (kind == "industry" and req.level in (1, 2, 3)) else None async def stream_gen(): async for chunk in analyze_rotation_stream( - repo, days, req.focus, quote_service, depth_service, + repo, days, req.focus, quote_service, depth_service, kind, level, ): yield chunk + "\n" diff --git a/backend/app/services/concept_rotation_analyzer.py b/backend/app/services/concept_rotation_analyzer.py index 9fb0752..d01683e 100644 --- a/backend/app/services/concept_rotation_analyzer.py +++ b/backend/app/services/concept_rotation_analyzer.py @@ -18,16 +18,24 @@ from collections.abc import AsyncIterator logger = logging.getLogger(__name__) +def _dim_label(kind: str) -> str: + """维度中文标签: concept→概念, industry→行业。供 prompt/文案动态化。""" + return "行业" if kind == "industry" else "概念" + + # ================================================================ # System Prompt — 客观轮动分析 + 固定章节模板 # ================================================================ -_SYSTEM_PROMPT = """你是一位专注 A 股题材轮动的研究分析师,拥有 12 年一线研究经验,擅长从概念板块的**涨幅排名矩阵**中客观识别主力资金脉络,区分机构主导的持续性主线与游资驱动的脉冲式轮动,产出一份**客观、中立、不包含任何买卖或操作建议**的轮动分析报告。 +def _build_system_prompt(kind: str = "concept") -> str: + """构建 system prompt(按维度动态替换"概念/行业"措辞)。""" + dim = _dim_label(kind) + return f"""你是一位专注 A 股题材轮动的研究分析师,拥有 12 年一线研究经验,擅长从{dim}板块的**涨幅排名矩阵**中客观识别主力资金脉络,区分机构主导的持续性主线与游资驱动的脉冲式轮动,产出一份**客观、中立、不包含任何买卖或操作建议**的轮动分析报告。 ## 核心红线(务必遵守) - **绝对不输出**"跟踪/规避/追高/低吸/观望/操作建议"等任何交易指令或倾向性措辞 -- 你的角色是**客观陈述**各概念的轮动特征、资金属性(机构 vs 游资)、持续性特征 +- 你的角色是**客观陈述**各{dim}的轮动特征、资金属性(机构 vs 游资)、持续性特征 - 换成"一个中立财经记者能不能写出来"——能写就保留,不能写就删除 ## 输出规范 @@ -35,25 +43,25 @@ _SYSTEM_PROMPT = """你是一位专注 A 股题材轮动的研究分析师,拥 用 **Markdown** 格式输出,严格遵循以下结构。不要输出任何 JSON 或代码块,直接输出 Markdown 正文。 ### 1. 🎯 主线研判(2-3 句) -点名当前最核心的 1-2 条主线题材(连续多日霸榜的强势概念),用一句话概括其逻辑(政策/产业/业绩/事件驱动),并客观判断是**主升期/加速期/扩散期/见顶期**。结尾用【主线强度:强 / 中 / 弱】客观定性。 +点名当前最核心的 1-2 条主线题材(连续多日霸榜的强势{dim}),用一句话概括其逻辑(政策/产业/业绩/事件驱动),并客观判断是**主升期/加速期/扩散期/见顶期**。结尾用【主线强度:强 / 中 / 弱】客观定性。 ### 2. 🆕 新晋强势 -列出排名快速跃升的概念(从榜单中后段冲进前列的),逐个给出: -- 概念名 + 近 N 日排名变化(如 `45→20→8`) +列出排名快速跃升的{dim}(从榜单中后段冲进前列的),逐个给出: +- {dim}名 + 近 N 日排名变化(如 `45→20→8`) - 涨幅加速度(连日递增 = 趋势加强) - 可能的驱动逻辑(从板块属性推断,不要编造具体消息) - 客观判断是**主力切入**还是**消息脉冲** ### 3. 📉 退潮预警 -列出从高位明显滑落的概念(连续排名下滑或涨幅骤降),逐个给出: -- 概念名 + 排名下滑轨迹 +列出从高位明显滑落的{dim}(连续排名下滑或涨幅骤降),逐个给出: +- {dim}名 + 排名下滑轨迹 - 退潮性质(高位分歧/资金撤离/补跌) - 是否扩散风险 ### 4. 🏛️ 机构主线 vs 🎰 游资轮动 基于排名稳定性客观区分两类资金行为: -- **机构主线**:排名标准差小、长期稳居前列的概念 → 持续性特征描述 -- **游资轮动**:排名剧烈波动、脉冲式冲高的概念 → 短线波动特征描述 +- **机构主线**:排名标准差小、长期稳居前列的{dim} → 持续性特征描述 +- **游资轮动**:排名剧烈波动、脉冲式冲高的{dim} → 短线波动特征描述 客观给出当前市场**整体轮动节奏**(快轮动/慢轮动/主线聚焦)的判断。 ### 5. 🌐 结合大盘 @@ -62,10 +70,10 @@ _SYSTEM_PROMPT = """你是一位专注 A 股题材轮动的研究分析师,拥 - 情绪温度与轮动节奏的匹配度(如情绪冰点但题材活跃 = 抱团;情绪火热但轮动快 = 末段) ### 6. 📌 后续观察清单 -- **持续性强(客观特征)**:排名标准差小、多日稳居前列的概念(列出名称+排名数据) -- **波动性大(客观特征)**:排名剧烈跳动的概念(列出名称+排名数据) -- 客观描述各概念的轮动特征,供读者自行观察 -- **不输出**"跟踪/规避/追高/低吸/观望"等操作指令;可客观描述结构变化信号(如"主线概念连续 2 日跌出前 10,主线强度可能减弱") +- **持续性强(客观特征)**:排名标准差小、多日稳居前列的{dim}(列出名称+排名数据) +- **波动性大(客观特征)**:排名剧烈跳动的{dim}(列出名称+排名数据) +- 客观描述各{dim}的轮动特征,供读者自行观察 +- **不输出**"跟踪/规避/追高/低吸/观望"等操作指令;可客观描述结构变化信号(如"主线{dim}连续 2 日跌出前 10,主线强度可能减弱") ### 7. ⚠️ 风险提示 列出需要客观关注的风险(如主线断层、情绪与轮动背离、成交萎缩)。末尾附一行: @@ -81,7 +89,7 @@ _SYSTEM_PROMPT = """你是一位专注 A 股题材轮动的研究分析师,拥 5. **不输出操作指令**:不写"跟踪/规避/追高/低吸/观望"等任何交易指令;客观陈述轮动特征即可。 6. **客观推断**:若无明确消息,从量价异动客观推断可能逻辑并给结论,不要标注"[推断]"或编造具体新闻。 -现在请基于下方概念轮动数据进行分析。""" +现在请基于下方{dim}轮动数据进行分析。""" # ================================================================ @@ -245,13 +253,14 @@ def _build_signal_block(title: str, items: list[dict]) -> str: return "\n".join(lines) -def _build_user_prompt(signals: dict, overview: dict, days: int, dates: list[str], focus: str) -> str: +def _build_user_prompt(signals: dict, overview: dict, days: int, dates: list[str], focus: str, kind: str = "concept") -> str: """组装 user 消息: 大盘背景 + 轮动信号 + focus。""" + dim = _dim_label(kind) dates_asc = list(reversed(dates)) date_range = f"{dates_asc[0]} ~ {dates_asc[-1]}" if dates_asc else "—" parts = [ - f"# 概念涨幅轮动数据 (最近 {days} 个交易日: {date_range})", + f"# {dim}涨幅轮动数据 (最近 {days} 个交易日: {date_range})", "", "## 大盘背景", _build_market_block(overview), @@ -296,27 +305,34 @@ async def analyze_rotation_stream( focus: str = "", quote_service=None, depth_service=None, + kind: str = "concept", + level: int | None = None, ) -> AsyncIterator[str]: - """流式概念轮动分析: yield 出每个 NDJSON 事件。 + """流式维度轮动分析(概念或行业): yield 出每个 NDJSON 事件。 Args: repo: KlineRepository (必填)。 days: 分析最近 N 个交易日 (7-30)。 focus: 用户追加的关注点。 quote_service / depth_service: 可选, 大盘背景装配依赖。 + kind: "concept"(概念) 或 "industry"(行业)。 + level: 行业层级(1/2/3), 仅 kind=industry 有效。 """ from app.services.rps_rotation import build_rps_rotation from app.services.market_overview_builder import build_market_overview + dim = _dim_label(kind) + page = "行业分析" if kind == "industry" else "概念分析" + # 1. 取轮动矩阵 - rotation = build_rps_rotation(repo, days) + rotation = build_rps_rotation(repo, days, kind, level) dates = rotation.get("dates") or [] columns = rotation.get("columns") or {} if not dates or not columns: yield json.dumps({ "type": "error", - "message": "暂无概念轮动数据,请先在「概念分析」页获取概念数据源", + "message": f"暂无{dim}轮动数据,请先在「{page}」页获取{dim}数据源", }, ensure_ascii=False) return @@ -348,10 +364,10 @@ async def analyze_rotation_stream( }, ensure_ascii=False) return - user_prompt = _build_user_prompt(signals, overview, days, dates, focus) + user_prompt = _build_user_prompt(signals, overview, days, dates, focus, kind) async for delta in stream_ai_text( [ - {"role": "system", "content": _SYSTEM_PROMPT}, + {"role": "system", "content": _build_system_prompt(kind)}, {"role": "user", "content": user_prompt}, ], temperature=0.5, @@ -360,7 +376,7 @@ async def analyze_rotation_stream( yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False) except Exception as e: # noqa: BLE001 - logger.exception("AI concept rotation analyze failed: %s", e) + logger.exception("AI %s rotation analyze failed: %s", kind, e) yield json.dumps({"type": "error", "message": f"AI 轮动分析失败: {e}"}, ensure_ascii=False) yield json.dumps({"type": "done"}, ensure_ascii=False) diff --git a/backend/app/services/rps_rotation.py b/backend/app/services/rps_rotation.py index 95921e8..a4161d7 100644 --- a/backend/app/services/rps_rotation.py +++ b/backend/app/services/rps_rotation.py @@ -51,102 +51,97 @@ def _latest_enriched_date(repo) -> date | None: return cache["date"].max() -def _load_concept_map_df(repo) -> tuple[pl.DataFrame, int]: - """构建并缓存 {symbol_upper → 概念} 的已展开 polars 映射表。 +def _load_concept_map_df(repo, kind: str = "concept") -> tuple[pl.DataFrame, int]: + """构建并缓存 {symbol_upper → 维度成员} 的已展开 polars 映射表。 - 复用 market_overview_builder 的概念识别 + 成分股读取逻辑(_dimension_field / - _read_ext_rows / _symbol_keys / _dimension_values), 但要的是「反向映射」 - (symbol → 概念), 且直接产出 polars DataFrame 供 join 使用。 + kind: "concept"(概念) 或 "industry"(行业)。复用 market_overview_builder 的 + _dimension_field(config, kind) 识别维度 —— 该函数两种维度都支持。 - 返回 (map_df, concept_count): - - map_df: 两列 (_sym_up: 大写 symbol, concept: 概念名), 已 explode, 一个 - symbol 属多概念时有多行。无概念数据时返回空 DataFrame。 - - concept_count: 去重概念总数。 + 返回 (map_df, member_count): + - map_df: 两列 (_sym_up: 大写 symbol, : 维度成员名), 已 explode。 + 无数据时返回空 DataFrame。 + - member_count: 去重维度成员总数。 - 缓存: 概念成分股是 snapshot, 进程内不变, 缓存 600s。 - 直接缓存 DataFrame 而非 Python dict —— 后续 join 时省掉每次 ~1s 的 dict→DataFrame - 重建开销(这是结果缓存失效后重算的主要瓶颈)。 + 缓存: 维度成分股是 snapshot, 进程内不变, 缓存 600s。按 kind 分别缓存。 """ - global _concept_map_cache, _concept_map_count, _concept_map_ts now = time.time() - if _concept_map_cache is not None and (now - _concept_map_ts) < 600: - return _concept_map_cache, _concept_map_count + cached = _map_cache.get(kind) + if cached is not None and (now - _map_ts.get(kind, 0)) < 600: + return cached data_dir = repo.store.data_dir store = ExtConfigStore(data_dir) - # 先收集成扁平的 (sym, concept) 行, 再一次性构造 DataFrame(比 list 列快得多) pairs: list[tuple[str, str]] = [] - concepts_seen: set[str] = set() + members_seen: set[str] = set() for config in store.load_all(): - field = _dimension_field(config, "concept") + field = _dimension_field(config, kind) if not field: continue for ext_row in _read_ext_rows(data_dir, config, field): - concepts = _dimension_values(ext_row.get(field)) - if not concepts: + members = _dimension_values(ext_row.get(field)) + if not members: continue keys = _symbol_keys(ext_row, config) for key in keys: - for c in concepts: - pairs.append((key, c)) - concepts_seen.add(c) + for m in members: + pairs.append((key, m)) + members_seen.add(m) if pairs: - # 去重: 同一 (symbol, concept) 对会因多 key 形式(SZ/000001)和 - # 多 config 重复出现, 去重后从 ~48万 行降到 ~14万, join 快 3x+ - _concept_map_cache = pl.DataFrame( - {"_sym_up": [p[0] for p in pairs], "concept": [p[1] for p in pairs]}, - schema={"_sym_up": pl.Utf8, "concept": pl.Utf8}, + map_df = pl.DataFrame( + {"_sym_up": [p[0] for p in pairs], kind: [p[1] for p in pairs]}, + schema={"_sym_up": pl.Utf8, kind: pl.Utf8}, ).unique() - _concept_map_count = len(concepts_seen) else: - _concept_map_cache = pl.DataFrame( - schema={"_sym_up": pl.Utf8, "concept": pl.Utf8} - ) - _concept_map_count = 0 - _concept_map_ts = now - return _concept_map_cache, _concept_map_count + map_df = pl.DataFrame(schema={"_sym_up": pl.Utf8, kind: pl.Utf8}) + _map_cache[kind] = map_df + _map_ts[kind] = now + return map_df, len(members_seen) -_concept_map_cache: pl.DataFrame | None = None -_concept_map_count: int = 0 -_concept_map_ts: float = 0.0 +# 维度映射缓存: {kind: (map_df, count)}。按 kind 隔离(概念/行业分别缓存)。 +_map_cache: dict[str, pl.DataFrame] = {} +_map_ts: dict[str, float] = {} -def build_rps_rotation(repo, days: int = 12) -> dict: - """构建概念涨幅轮动矩阵。 +def build_rps_rotation(repo, days: int = 12, kind: str = "concept", level: int | None = None) -> dict: + """构建维度涨幅轮动矩阵(概念或行业)。 Args: repo: KlineRepository(含 _enriched_history_cache 内存历史)。 days: 取最近 N 个交易日, 范围 [7, 30], 默认 12。 + kind: "concept"(概念) 或 "industry"(行业), 决定维度映射来源。 + level: 行业层级(仅 kind=industry 有效, 1/2/3 级)。None 表示用原始全路径名。 + 行业名形如 "银行-银行-股份制银行", level=2 取第二段"银行", 同级下多个 + 三级会合并聚合(与 _dimension_rank 的 level 口径一致)。 Returns: { "dates": ["2026-06-30", ...], # 最新在最前, 长度 ≤ days - "columns": {"2026-06-30": [[概念, 涨幅], ...], ...}, # 每列各自排序(高→低) - "concept_count": 387, # 去重概念总数(0 表示无概念数据) + "columns": {"2026-06-30": [[成员, 涨幅], ...], ...}, # 每列各自排序(高→低) + "concept_count": 387, # 去重维度成员总数(0 表示无数据) } 涨幅是小数(0.0522 = +5.22%)。无数据时返回空 columns。 + 字段名 concept_count 保留兼容(前端按 kind 显示"X 个概念/行业")。 """ days = max(7, min(30, days)) - # 结果缓存: 同 days(→ 同 start/end)的请求在 TTL 内直接返回 + # 结果缓存: 同 (kind, level, latest) 的请求在 TTL 内直接返回。 latest = _latest_enriched_date(repo) if latest is None: return {"dates": [], "columns": {}, "concept_count": 0} - cache_key = latest.isoformat() + cache_key = f"{kind}|{level}|{latest.isoformat()}" now = time.time() cached = _cache.get(cache_key) if cached and (now - _cache_ts.get(cache_key, 0)) < _CACHE_TTL: - # 缓存的是所有日期, 按需要的 days 截取(避免不同 days 各存一份) return _slice_cached(cached, days) - # 1. 概念映射(symbol → 概念), 已缓存为 polars DataFrame - map_df, concept_count = _load_concept_map_df(repo) + # 1. 维度映射(symbol → 维度成员), 已按 kind 缓存为 polars DataFrame + map_df, member_count = _load_concept_map_df(repo, kind) if map_df.is_empty(): - logger.info("rps_rotation: no concept data (ext_gn_ths not fetched yet)") + logger.info("rps_rotation: no %s data (ext dimension not fetched yet)", kind) return {"dates": [], "columns": {}, "concept_count": 0} # 2. 取最近 N 交易日的个股 change_pct(命中内存缓存) @@ -157,7 +152,7 @@ def build_rps_rotation(repo, days: int = 12) -> dict: if df is None or df.is_empty(): return {"dates": [], "columns": {}, "concept_count": 0} - # 3. 把个股 symbol 映射到概念, 一只股票拆成多行(每个概念一行) + # 3. 把个股 symbol 映射到维度成员, 一只股票拆成多行(每个成员一行) # symbol 大写匹配(map_df 的 _sym_up 已大写) df = df.with_columns(pl.col("symbol").str.to_uppercase().alias("_sym_up")) joined = df.join(map_df, on="_sym_up", how="inner").drop("_sym_up") @@ -165,18 +160,27 @@ def build_rps_rotation(repo, days: int = 12) -> dict: if joined.is_empty(): return {"dates": [], "columns": {}, "concept_count": 0} - # 4. 按 (date, concept) 聚合 avg change_pct —— 与 _dimension_rank:288 的简单平均口径一致 - agg = joined.group_by(["date", "concept"]).agg( + # 行业层级聚合: kind=industry 且指定 level 时, 把 "一级行业-二级行业-三级行业" + # 拆分取对应层级(level=2 → "二级行业"), 同级下多个三级会合并。 + # 与 market_overview_builder._dimension_rank 的 level 口径完全一致。 + if kind == "industry" and level is not None: + # polars: 按 "-" 拆分取第 level 段; 段数不足时取最后一段(兜底) + parts = pl.col(kind).str.split("-") + idx = pl.min_horizontal(pl.lit(level - 1), pl.col(kind).str.count_matches("-")) + joined = joined.with_columns(parts.list.get(idx).alias(kind)) + + # 4. 按 (date, ) 聚合 avg change_pct —— 与 _dimension_rank 的简单平均口径一致 + agg = joined.group_by(["date", kind]).agg( pl.col("change_pct").mean().alias("avg_pct") ) - # 去掉 NaN/Null(停牌等无行情的概念日) + # 去掉 NaN/Null(停牌等无行情的成员日) agg = agg.filter(pl.col("avg_pct").is_not_null() & pl.col("avg_pct").is_not_nan()) - # 5. 每个日期内按 avg_pct 降序排, 再 group_by 把每组的 (concept, avg_pct) + # 5. 每个日期内按 avg_pct 降序排, 再 group_by 把每组的 (成员, avg_pct) # 收集成并行 list —— 一次 polars 操作拿到全部列, 避免 partition_by 的 tuple key 歧义 agg = agg.sort(["date", "avg_pct"], descending=[False, True]) grouped = agg.group_by("date", maintain_order=True).agg( - pl.col("concept"), pl.col("avg_pct") + pl.col(kind), pl.col("avg_pct") ) # 最新日期排最前 grouped = grouped.sort("date", descending=True) @@ -186,12 +190,12 @@ def build_rps_rotation(repo, days: int = 12) -> dict: for row in grouped.iter_rows(named=True): d_str = str(row["date"]) all_dates_sorted.append(d_str) - columns[d_str] = list(zip(row["concept"], row["avg_pct"])) + columns[d_str] = list(zip(row[kind], row["avg_pct"])) full = { "dates": [str(d) for d in all_dates_sorted], "columns": columns, - "concept_count": concept_count, + "concept_count": member_count, } # 写缓存(存全量, 按需 slice) diff --git a/frontend/src/components/ListColumnCustomizer.tsx b/frontend/src/components/ListColumnCustomizer.tsx index 94b9354..0998e08 100644 --- a/frontend/src/components/ListColumnCustomizer.tsx +++ b/frontend/src/components/ListColumnCustomizer.tsx @@ -41,6 +41,19 @@ interface ListColumnCustomizerProps { showStandaloneToggle?: boolean } +/** 判断扩展数据字段类型是否为数字(int/float/double/number/decimal 等)。 + * 旧列 source 无 fieldType 时默认 true(放宽), 让旧列也能配置数字格式 —— + * 若列实际非数字, 渲染时 typeof val==='number' 判断会跳过格式化, 无副作用。 */ +function isNumericFieldType(ft?: string): boolean { + if (!ft) return true + const t = ft.toLowerCase() + // 明确是文本类则不显示 + if (['str', 'string', 'text', 'char', 'varchar', 'date', 'time', 'bool', 'boolean'].some(k => t.includes(k))) { + return false + } + return true +} + function SortableActiveCol({ col, onRemove, onConfig, configOpen, extTableLabel, extConfig, candleConfig: candlePanel, intradayConfig: intradayPanel, strategiesConfig, showStandaloneToggle, onToggleStandalone }: { col: ColumnConfig onRemove: (id: string) => void @@ -191,7 +204,7 @@ export function ListColumnCustomizer({ onChange([...pinnedCols, ...reordered]) }, [columns, onChange]) - const addExtColumn = useCallback((configId: string, fieldName: string, fieldLabel?: string) => { + const addExtColumn = useCallback((configId: string, fieldName: string, fieldLabel?: string, fieldType?: string) => { const colId = `ext:${configId}:${fieldName}` if (columns.some(c => c.id === colId)) { toggleVisible(colId) @@ -199,7 +212,7 @@ export function ListColumnCustomizer({ } const newCol: ColumnConfig = { id: colId, - source: { type: 'ext', configId, fieldName, fieldLabel }, + source: { type: 'ext', configId, fieldName, fieldLabel, fieldType }, label: fieldLabel || fieldName, visible: true, align: extColumnAlign, @@ -408,6 +421,53 @@ export function ListColumnCustomizer({ )} + {/* 数字格式化配置: 千分位 + 单位换算 + 小数位(仅 number 类型字段) */} + {col.source.type === 'ext' && isNumericFieldType(col.source.fieldType) && ( + <> +
数字格式
+ + + {(col.extDisplay?.unitConvert ?? 'none') !== 'none' && ( + + )} + + )} {col.extDisplay && (
+ ))} +
+ )}