mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
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:
+13
-7
@@ -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)
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -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,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 列专用) */
|
||||
|
||||
@@ -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" />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user