feat: 行业轮动分析(参数化复用概念版) + 扩展数据列数字格式化

行业轮动分析(完全对齐概念版)
- 概念轮动逻辑参数化支持 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 通过
This commit is contained in:
shy3130
2026-08-02 19:18:07 +08:00
parent ed38304577
commit c9f01ba288
11 changed files with 299 additions and 108 deletions
+13 -7
View File
@@ -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"
@@ -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)
+60 -56
View File
@@ -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, <kind>: 维度成员名), 已 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, <kind>) 聚合 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)
@@ -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({
</div>
</label>
)}
{/* 数字格式化配置: 千分位 + 单位换算 + 小数位(仅 number 类型字段) */}
{col.source.type === 'ext' && isNumericFieldType(col.source.fieldType) && (
<>
<div className="border-t border-border/40 pt-2 mt-1 text-[10px] text-muted"></div>
<label className="flex items-center gap-2 text-xs">
<span className="text-secondary w-16 shrink-0"></span>
<button
type="button"
onClick={() => updateExtDisplay(col.id, { thousandSeparator: !col.extDisplay?.thousandSeparator })}
className={`relative inline-flex h-4 w-7 items-center rounded-full transition-colors duration-200 cursor-pointer ${
col.extDisplay?.thousandSeparator ? 'bg-accent' : 'bg-elevated'
}`}
aria-pressed={!!col.extDisplay?.thousandSeparator}
>
<span className={`inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform duration-200 ${
col.extDisplay?.thousandSeparator ? 'translate-x-[14px]' : 'translate-x-0.5'
}`} />
</button>
<span className="text-[10px] text-muted"> 1,234,567</span>
</label>
<label className="flex items-center gap-2 text-xs">
<span className="text-secondary w-16 shrink-0"></span>
<select
value={col.extDisplay?.unitConvert ?? 'none'}
onChange={e => updateExtDisplay(col.id, { unitConvert: e.target.value as 'none' | 'wan' | 'yi' | 'auto' })}
className="flex-1 h-7 rounded bg-elevated border border-border text-foreground text-xs px-2 focus:outline-none focus:border-accent/50"
>
<option value="none"></option>
<option value="wan"> (÷1)</option>
<option value="yi">亿 (÷1亿)</option>
<option value="auto"> (亿亿, )</option>
</select>
</label>
{(col.extDisplay?.unitConvert ?? 'none') !== 'none' && (
<label className="flex items-center gap-2 text-xs">
<span className="text-secondary w-16 shrink-0"></span>
<input
type="number" min={0} max={6} step={1}
value={col.extDisplay?.unitDecimals ?? 2}
onChange={e => updateExtDisplay(col.id, { unitDecimals: Math.max(0, Math.min(6, Number(e.target.value) || 0)) })}
className="w-16 h-7 rounded bg-elevated border border-border text-foreground text-xs px-2 text-center focus:outline-none focus:border-accent/50"
/>
<span className="text-[10px] text-muted"></span>
</label>
)}
</>
)}
{col.extDisplay && (
<div className="flex justify-end pt-1">
<button onClick={() => resetExtDisplay(col.id)} className="text-[10px] text-muted hover:text-foreground transition-colors">
@@ -645,7 +705,7 @@ export function ListColumnCustomizer({
return (
<button
key={field.name}
onClick={() => addExtColumn(configId, field.name, field.label)}
onClick={() => addExtColumn(configId, field.name, field.label, field.type)}
className="flex items-center gap-2 w-full px-2 py-1.5 rounded hover:bg-elevated/50 text-left group transition-colors"
>
{renderCheckbox(checked)}
+35 -10
View File
@@ -11,6 +11,8 @@ import { Modal } from '@/components/Modal'
interface Props {
onClose: () => void
/** 维度: concept 概念(默认) / industry 行业 */
kind?: 'concept' | 'industry'
}
const DEFAULT_DAYS = 12
@@ -45,10 +47,14 @@ function rankColorClass(rank: number, total: number): string {
return 'text-accent'
}
export function RpsRotationDialog({ onClose }: Props) {
export function RpsRotationDialog({ onClose, kind = 'concept' }: Props) {
// 维度文案: concept→概念, industry→行业
const dimLabel = kind === 'industry' ? '行业' : '概念'
const [days, setDays] = useState(DEFAULT_DAYS)
const [reversed, setReversed] = useState(false) // false=高→低, true=低→高
const [selected, setSelected] = useState<string | null>(null) // 点中的概念名, 高亮追踪
const [selected, setSelected] = useState<string | null>(null) // 点中的成员名, 高亮追踪
// 行业层级(仅 industry): 1/2/3 级, 默认 2 级。concept 时为 null 不生效。
const [level, setLevel] = useState<number>(kind === 'industry' ? 2 : 0)
// ---- AI 轮动分析状态 (组件内, 不建全局 store: 切页即关对话框) ----
const [analysis, setAnalysis] = useState('') // 累积的 Markdown 报告
@@ -63,7 +69,8 @@ export function RpsRotationDialog({ onClose }: Props) {
setAnalysisError('')
setAnalysisMeta(null)
try {
for await (const ev of api.rotationAnalyzeStream(daysParam, focusParam)) {
const lv = kind === 'industry' ? level : undefined
for await (const ev of api.rotationAnalyzeStream(daysParam, focusParam, kind, lv)) {
if (ev.type === 'meta') setAnalysisMeta({ summary: ev.summary })
else if (ev.type === 'delta') setAnalysis(a => a + (ev.content ?? ''))
else if (ev.type === 'error') setAnalysisError(ev.message ?? '未知错误')
@@ -74,12 +81,13 @@ export function RpsRotationDialog({ onClose }: Props) {
} finally {
setAnalyzing(false)
}
}, [])
}, [kind, level])
// 数据请求: React Query 缓存, 同 days 5 分钟内重开秒开
// 数据请求: React Query 缓存, 同 (kind, level, days) 5 分钟内重开秒开
const lvParam = kind === 'industry' ? level : undefined
const { data, isLoading, error } = useQuery({
queryKey: QK.rpsRotation(days),
queryFn: () => api.rpsRotation(days),
queryKey: [...QK.rpsRotation(days), kind, lvParam],
queryFn: () => api.rpsRotation(days, kind, lvParam),
staleTime: 5 * 60 * 1000,
})
@@ -209,10 +217,27 @@ export function RpsRotationDialog({ onClose }: Props) {
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border shrink-0">
<div className="flex items-center gap-2">
<Repeat className="h-4 w-4 text-accent" />
<span id="rps-rotation-title" className="text-sm font-medium text-foreground"></span>
<span id="rps-rotation-title" className="text-sm font-medium text-foreground">{dimLabel}</span>
<span className="text-[11px] text-muted">
{conceptCount > 0 ? `${dates.length} 天 · ${conceptCount}概念` : '暂无数据'}
{conceptCount > 0 ? `${dates.length} 天 · ${conceptCount}${dimLabel}` : '暂无数据'}
</span>
{/* 行业层级选择器: 1/2/3 级, 默认 2 级。仅 kind=industry 显示 */}
{kind === 'industry' && (
<div className="ml-1 flex items-center rounded-btn border border-border bg-base/60 p-0.5">
{[1, 2, 3].map(lv => (
<button
key={lv}
onClick={() => { setLevel(lv); setSelected(null) }}
className={cn(
'h-5 rounded-[5px] px-2 text-[10px] font-medium transition-colors',
level === lv ? 'bg-accent text-white shadow-sm' : 'text-secondary hover:text-foreground',
)}
>
{lv}
</button>
))}
</div>
)}
</div>
<button aria-label="关闭" onClick={onClose} className="p-1 rounded hover:bg-elevated transition-colors cursor-pointer">
<X className="h-4 w-4 text-muted" />
@@ -332,7 +357,7 @@ export function RpsRotationDialog({ onClose }: Props) {
</div>
) : rowCount === 0 ? (
<div className="flex items-center justify-center py-16 text-[11px] text-muted">
,
{dimLabel},{kind === 'industry' ? '行业分析' : '概念分析'}{dimLabel}
</div>
) : (
<div
@@ -8,7 +8,7 @@
import { useState, type CSSProperties, type ReactNode } from 'react'
import { Check, Plus, Eye, EyeOff, RefreshCw } from 'lucide-react'
import type { KlineRow, MinuteKlineRow } from '@/lib/api'
import { fmtPrice } from '@/lib/format'
import { fmtPrice, formatExtNumber } from '@/lib/format'
import type { ColumnConfig } from '@/lib/screener-columns'
import { getSignals, signalCls } from '@/lib/stock-table'
import { boardTag, renderBuiltinDataCell } from '@/components/stock-table/primitives'
@@ -121,7 +121,12 @@ function renderExtValue(
): ReactNode {
if (val == null || Number.isNaN(val)) return <span className="text-muted"></span>
if (typeof val === 'number') {
const displayVal = Number.isInteger(val) ? fmtPrice(val, 0) : fmtPrice(val)
// 数字格式化: 千分位 + 单位换算 + 小数位(由列配置控制)
const cfg = col.extDisplay
const hasNumFmt = cfg?.thousandSeparator || (cfg?.unitConvert && cfg.unitConvert !== 'none')
const displayVal = hasNumFmt
? formatExtNumber(val, { thousandSeparator: cfg?.thousandSeparator, unitConvert: cfg?.unitConvert, unitDecimals: cfg?.unitDecimals })
: (Number.isInteger(val) ? fmtPrice(val, 0) : fmtPrice(val))
return <span className="tabular-nums">{displayVal}</span>
}
if (typeof val === 'boolean') {
+4 -4
View File
@@ -1553,8 +1553,8 @@ export const api = {
overviewMarket: (asOf?: string) => request<OverviewMarket>(`/api/overview/market${asOf ? `?as_of=${asOf}` : ''}`),
// 概念涨幅轮动矩阵: 每列(日期)各自把所有概念按当天涨幅从高到低排序
rpsRotation: (days: number) =>
request<RpsRotationData>(`/api/rps/rotation?days=${days}`),
rpsRotation: (days: number, kind?: 'concept' | 'industry', level?: number) =>
request<RpsRotationData>(`/api/rps/rotation?days=${days}${kind ? `&kind=${kind}` : ''}${level ? `&level=${level}` : ''}`),
// 市场环境(Regime)
regimeHistory: (start?: string, end?: string, limit?: number) => {
@@ -2057,7 +2057,7 @@ export const api = {
},
/** AI 概念轮动分析 — 流式 NDJSON。 */
async *rotationAnalyzeStream(days: number, focus?: string): AsyncGenerator<{
async *rotationAnalyzeStream(days: number, focus?: string, kind?: 'concept' | 'industry', level?: number): AsyncGenerator<{
type: 'meta' | 'delta' | 'error' | 'done'
days?: number
summary?: string
@@ -2067,7 +2067,7 @@ export const api = {
const res = await fetch('/api/rps/rotation-analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ days, focus: focus ?? '' }),
body: JSON.stringify({ days, focus: focus ?? '', kind: kind ?? 'concept', level: level ?? null }),
})
if (!res.ok) {
let detail = ''
+53
View File
@@ -79,3 +79,56 @@ export function formatLogTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
}
/**
* 扩展数据列数字格式化 — 千分位逗号 + 单位换算 + 小数位。
* 供自选/策略列表的扩展数据 number 单元格统一调用。
*
* opts:
* - thousandSeparator: true 则加英文逗号(1,234,567)
* - unitConvert: 'none'(默认不换算) / 'wan'(÷1e4 加"万") / 'yi'(÷1e8 加"亿") / 'auto'(≥1e8用亿, ≥1e4用万)
* - unitDecimals: 换算后保留小数位(默认 2); unitConvert=none 时此值仅控制原值小数位
*/
export function formatExtNumber(
val: number,
opts: { thousandSeparator?: boolean; unitConvert?: 'none' | 'wan' | 'yi' | 'auto'; unitDecimals?: number } = {},
): string {
const { thousandSeparator, unitConvert = 'none', unitDecimals } = opts
if (!Number.isFinite(val)) return '—'
let n = val
let suffix = ''
let decimals = unitDecimals ?? 2
if (unitConvert === 'wan') {
n = val / 1e4
suffix = '万'
} else if (unitConvert === 'yi') {
n = val / 1e8
suffix = '亿'
} else if (unitConvert === 'auto') {
const abs = Math.abs(val)
if (abs >= 1e8) { n = val / 1e8; suffix = '亿' }
else if (abs >= 1e4) { n = val / 1e4; suffix = '万' }
else { decimals = unitDecimals ?? 0 }
} else {
// none: 整数不带小数, 小数保留原精度(最多 4 位去尾零)
decimals = unitDecimals ?? (Number.isInteger(val) ? 0 : 4)
}
// toFixed 后去尾零(none 模式下小数原精度场景)
let str = n.toFixed(decimals)
if (unitConvert === 'none' && !unitDecimals && !Number.isInteger(val)) {
// 去掉 toFixed(4) 产生的尾零, 如 1.2300 → 1.23
str = String(Number(str))
}
// 千分位逗号(仅整数部分)
if (thousandSeparator) {
const [intPart, fracPart] = str.split('.')
const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
str = fracPart != null ? `${grouped}.${fracPart}` : grouped
}
return str + suffix
}
+7 -1
View File
@@ -7,7 +7,7 @@
export type ColumnSource =
| { type: 'builtin'; key: string }
| { type: 'ext'; configId: string; fieldName: string; fieldLabel?: string }
| { type: 'ext'; configId: string; fieldName: string; fieldLabel?: string; fieldType?: string }
| { type: 'computed'; key: string }
/** 扩展列字符串值渲染配置 */
@@ -24,6 +24,12 @@ export interface ExtColumnDisplayConfig {
hiddenIndices?: number[]
/** 标签排列方向: horizontal=横向(默认), vertical=竖向 */
tagLayout?: 'horizontal' | 'vertical'
/** 数字千分位逗号(仅 number 类型有效): true=1,234,567 */
thousandSeparator?: boolean
/** 单位换算(仅 number 类型有效): none=不换算(默认), wan=万, yi=亿, auto=自动 */
unitConvert?: 'none' | 'wan' | 'yi' | 'auto'
/** 单位换算后保留小数位(仅 number 类型 + unitConvert≠none 有效), 默认 2 */
unitDecimals?: number
}
/** 日k列渲染配置(builtin: candle 列专用) */
+12
View File
@@ -6,6 +6,7 @@ import {
Crown,
Layers3,
RefreshCw,
Repeat,
Search,
Settings2,
TrendingDown,
@@ -15,6 +16,7 @@ import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { AnalysisConfigDialog, DimensionHeatmap, PresetFetchState, type AnalysisFieldConfig } from '@/components/analysis-shared'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { RpsRotationDialog } from '@/components/RpsRotationDialog'
import { api, type MarketSnapshotRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { storage } from '@/lib/storage'
@@ -274,6 +276,7 @@ export function IndustryAnalysis() {
const [sortMode, setSortMode] = useState<SortMode>('heat')
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const [previewName, setPreviewName] = useState<string>('')
const [showRps, setShowRps] = useState(false)
const configsQuery = useQuery({ queryKey: QK.extData, queryFn: api.extDataList })
const availableConfigs = configsQuery.data?.items ?? []
@@ -411,6 +414,14 @@ export function IndustryAnalysis() {
subtitle={`${industryLevelLabel} · ${marketQuery.data?.as_of ?? rowsQuery.data?.date ?? '最新'} · ${stats.length} 个行业 · ${totalSymbols} 只标的`}
right={
<div className="flex items-center gap-1">
{/* RPS 轮动: 打开行业涨幅轮动矩阵对话框 */}
<button
onClick={() => setShowRps(true)}
className="inline-flex items-center gap-1 rounded-btn border border-amber-400/40 bg-amber-400/15 px-2.5 py-1.5 text-[11px] text-amber-400 font-medium transition-colors hover:bg-amber-400/25 hover:border-amber-400/60"
title="行业涨幅轮动矩阵"
>
<Repeat className="h-3.5 w-3.5" />RPS轮动分析
</button>
<button
onClick={() => { rowsQuery.refetch(); marketQuery.refetch() }}
disabled={rowsQuery.isFetching || marketQuery.isFetching}
@@ -489,6 +500,7 @@ export function IndustryAnalysis() {
onClose={() => { setPreviewSymbol(null); setPreviewName('') }}
/>
)}
{showRps && <RpsRotationDialog onClose={() => setShowRps(false)} kind="industry" />}
</>
)
}
+7 -3
View File
@@ -6,7 +6,7 @@ import { Trash2, RefreshCw, Star, X, Search, LayoutGrid, List, Settings2, Plus,
import { api, type KlineRow, type MinuteKlineRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { storage } from '@/lib/storage'
import { fmtPrice, fmtPct, fmtBigNum, priceColorClass } from '@/lib/format'
import { fmtPrice, fmtPct, fmtBigNum, priceColorClass, formatExtNumber } from '@/lib/format'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
@@ -78,8 +78,12 @@ function renderExtValue(
): React.ReactNode {
if (val == null || Number.isNaN(val)) return <span className="text-muted"></span>
if (typeof val === 'number') {
// int 类型不显示小数
const displayVal = Number.isInteger(val) ? fmtPrice(val, 0) : fmtPrice(val)
// 数字格式化: 千分位 + 单位换算 + 小数位(由列配置控制)
const cfg = col.extDisplay
const hasNumFmt = cfg?.thousandSeparator || (cfg?.unitConvert && cfg.unitConvert !== 'none')
const displayVal = hasNumFmt
? formatExtNumber(val, { thousandSeparator: cfg?.thousandSeparator, unitConvert: cfg?.unitConvert, unitDecimals: cfg?.unitDecimals })
: (Number.isInteger(val) ? fmtPrice(val, 0) : fmtPrice(val))
return <span className="tabular-nums">{displayVal}</span>
}
if (typeof val === 'boolean') {