feat(abnormal,dashboard): 盘中异动聚合、维度 source_field 与板块分时端点

- abnormal_moves 新增 build_intraday: enriched 七类当日信号聚合,
  优先级(涨停>炸板>翘板>跌停>新高>新低>放量)+涨跌幅排序, GET /api/abnormal/intraday
- 维度排名项携带 group_source 与 source_field(configId.field),
  前端可精确判定概念/行业而非字符串包含
- ext_data 新增 dimension-intraday: 成分股×当日分钟分区等权聚合,
  prev_close 优先/首根退化基准、成分网格化 ffill、全市场对照线,
  小数制涨跌幅契约、60s 进程内缓存、点击触发不预计算
- 板块分时 7 测 + 盘中异动 4 测
This commit is contained in:
shy3130
2026-08-30 19:05:32 +08:00
parent 3d627aba5e
commit fb7785ceb9
6 changed files with 503 additions and 2 deletions
+16 -2
View File
@@ -1,13 +1,27 @@
"""异动边缘监控 API — 按交易所异动规则口径统计接近触发的个股。"""
"""异动监控 API — 竞价/盘中/偏移三类异动。
- /intraday: 盘中量价信号聚合 (enriched 当日信号列, 零新增采集)
- /overview: 偏移异动边缘总览 (交易所异动规则口径的接近度)
"""
from __future__ import annotations
from fastapi import APIRouter, Query, Request
from app.services.abnormal_moves import build_overview
from app.services.abnormal_moves import build_intraday, build_overview
router = APIRouter(prefix="/api/abnormal", tags=["abnormal"])
@router.get("/intraday")
def abnormal_intraday(
request: Request,
limit: int = Query(500, ge=1, le=2000),
):
"""盘中异动: 涨停/炸板/跌停翘板/跌停/新高/新低/放量 信号命中行。"""
repo = request.app.state.repo
return build_intraday(repo, limit=limit)
@router.get("/overview")
def abnormal_overview(
request: Request,
+193
View File
@@ -7,6 +7,7 @@ import math
import re
import shutil
import tempfile
import time
from datetime import date, datetime
from pathlib import Path
from typing import Literal
@@ -485,6 +486,198 @@ def dimension_members(
}
# ---------------------------------------------------------------------------
# 板块分时 (dimension intraday)
# ---------------------------------------------------------------------------
# 点击触发 + 60s 进程内缓存: 分钟分区是滚动底座 (minute_refresh / 盘后分钟同步),
# 不做后台预计算 — 板块基数大而单次聚合仅几十毫秒。
_DIMENSION_INTRADAY_CACHE: dict[tuple[str, str, str, str | None], tuple[float, dict]] = {}
_DIMENSION_INTRADAY_CACHE_TTL_S = 60.0
# 成分股网格化 ffill 上限: 超大板块退化为逐时间戳可得均值 (内存保护)。
_DIMENSION_INTRADAY_FFILL_CAP = 2000
def _bare_symbol_expr(col: str = "symbol") -> pl.Expr:
"""'000001.SZ''000001'; 已是裸代码则原样。"""
return pl.col(col).cast(pl.String).str.strip_chars().str.split(".").list.first()
def _dimension_member_bares(matched: pl.DataFrame, config: ExtConfig) -> list[str]:
"""成分股裸代码集合 (symbol 列优先级与 dimension-members 端点一致)。"""
if matched.is_empty():
return []
symbol_columns = ["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_columns.append(str(mapping["col"]))
cols = [c for c in dict.fromkeys(symbol_columns) if c in matched.columns]
if not cols:
return []
coalesced = pl.coalesce(
[pl.col(c).cast(pl.String).str.strip_chars().str.split(".").list.first() for c in cols]
)
series = matched.select(coalesced.alias("_bare")).to_series()
return sorted({s for s in series.to_list() if s})
def _prev_daily_close(data_dir: Path, target_date: str) -> pl.DataFrame | None:
"""目标日前最近一个日K分区的收盘价 → (_bare, prev_close); 无则 None。"""
daily = data_dir / "kline_daily"
if not daily.exists():
return None
dates = sorted(
d.name[5:]
for d in daily.iterdir()
if d.is_dir() and d.name.startswith("date=") and (d / "part.parquet").exists()
)
prevs = [d for d in dates if d < target_date]
if not prevs:
return None
path = daily / f"date={prevs[-1]}" / "part.parquet"
if not path.exists():
return None
df = pl.read_parquet(path, columns=["symbol", "close"])
return (
df.with_columns(_bare_symbol_expr().alias("_bare"))
.select([pl.col("_bare"), pl.col("close").cast(pl.Float64).alias("prev_close")])
.unique(subset=["_bare"], keep="last")
)
def _dimension_intraday_compute(
config: ExtConfig,
data_dir: Path,
field: str,
value: str,
snapshot_date: str | None,
) -> dict:
"""板块等权分时: 成分股当日分钟K逐分钟平均涨跌幅 + 全市场对照线。
口径: pct = 分钟close / ref − 1 (小数制, 与快照涨跌幅契约一致, 前端 ×100 显示),
ref 优先前一交易日日K收盘 (prev_close, 开盘跳空体现在曲线起点);
日K缺失的标的退化为当日首根分钟close (混合基准)。
停牌/无成交分钟按成分股 forward-fill 后再平均, 全市场线取逐时间戳可得均值。
"""
minute_dir = data_dir / "kline_minute"
partitions: list[str] = []
if minute_dir.exists():
partitions = sorted(
d.name[5:]
for d in minute_dir.iterdir()
if d.is_dir() and d.name.startswith("date=") and (d / "part.parquet").exists()
)
if snapshot_date:
target = snapshot_date if snapshot_date in partitions else None
else:
target = partitions[-1] if partitions else None
if not target:
return {"status": "no_data", "reason": "minute_missing", "date": snapshot_date, "points": []}
ext_df, _active = _read_ext_dataframe(config, data_dir)
if ext_df.is_empty() or field not in ext_df.columns:
return {"status": "empty", "reason": "no_members", "date": target, "points": []}
member_bares = _dimension_member_bares(_filter_dimension_member_rows(ext_df, field, value), config)
if not member_bares:
return {"status": "empty", "reason": "no_members", "date": target, "points": []}
try:
bars = pl.read_parquet(
minute_dir / f"date={target}" / "part.parquet",
columns=["symbol", "datetime", "close"],
)
except Exception as exc: # noqa: BLE001
logger.warning("dimension-intraday read minute partition failed: %s", exc)
return {"status": "no_data", "reason": "minute_schema", "date": target, "points": []}
bars = bars.drop_nulls(subset=["datetime", "close"])
if bars.is_empty():
return {"status": "no_data", "reason": "minute_empty", "date": target, "points": []}
bars = bars.with_columns(_bare_symbol_expr().alias("_bare"))
prev = _prev_daily_close(data_dir, target)
joined = bars.join(prev, on="_bare", how="left") if prev is not None else bars.with_columns(
pl.lit(None, dtype=pl.Float64).alias("prev_close")
)
refs = joined.group_by("_bare").agg(
pl.col("prev_close").first().alias("_prev"),
pl.col("close").sort_by("datetime").first().alias("_first"),
).with_columns(pl.coalesce(["_prev", "_first"]).alias("_ref"))
n_prev = refs["_prev"].is_not_null().sum()
basis = "prev_close" if n_prev == refs.height else ("first_close" if n_prev == 0 else "mixed")
joined = (
joined.join(refs.select(["_bare", "_ref"]), on="_bare", how="left")
.with_columns((pl.col("close") / pl.col("_ref") - 1.0).alias("_pct"))
)
market = joined.group_by("datetime").agg(pl.col("_pct").mean().alias("_market"))
member_bars = joined.filter(pl.col("_bare").is_in(member_bares))
members_with_minute = member_bars["_bare"].n_unique() if not member_bars.is_empty() else 0
if members_with_minute == 0:
return {
"status": "empty", "reason": "no_member_bars", "date": target,
"member_count": len(member_bares), "members_with_minute": 0, "points": [],
}
if members_with_minute <= _DIMENSION_INTRADAY_FFILL_CAP:
# 网格化 (成分股 × 全时间轴) + 逐股 ffill: 停牌分钟冻结在最后价而非退出均值
grid = (
member_bars.select(pl.col("_bare").unique())
.join(joined.select(pl.col("datetime").unique()), how="cross")
)
member_bars = (
grid.join(member_bars.select(["_bare", "datetime", "_pct"]), on=["_bare", "datetime"], how="left")
.sort(["_bare", "datetime"])
.with_columns(pl.col("_pct").forward_fill().over("_bare"))
)
sector = member_bars.group_by("datetime").agg(pl.col("_pct").mean().alias("_sector"))
combined = market.join(sector, on="datetime", how="left").sort("datetime")
def _r4(v) -> float | None:
return round(float(v), 4) if v is not None and not (isinstance(v, float) and math.isnan(v)) else None
points = [
{
"time": row["datetime"].strftime("%H:%M"),
"sector": _r4(row["_sector"]),
"market": _r4(row["_market"]),
}
for row in combined.iter_rows(named=True)
]
return {
"status": "ok",
"date": target,
"basis": basis,
"member_count": len(member_bares),
"members_with_minute": members_with_minute,
"points": points,
}
@router.get("/{config_id}/dimension-intraday")
def dimension_intraday(
request: Request,
config_id: str,
field: str = Query(..., min_length=1),
value: str = Query(..., min_length=1),
snapshot_date: str | None = Query(None, alias="date"),
):
"""板块分时走势 (等权): 成分股 × 当日分钟K聚合; 60s 缓存, 点击触发不预计算。"""
config = _store(request).get(config_id)
if not config:
raise HTTPException(404, f"配置 '{config_id}' 不存在")
cache_key = (config_id, field, value.strip(), snapshot_date)
now = time.monotonic()
hit = _DIMENSION_INTRADAY_CACHE.get(cache_key)
if hit is not None and now - hit[0] < _DIMENSION_INTRADAY_CACHE_TTL_S:
return hit[1]
payload = _dimension_intraday_compute(config, _data_dir(request), field, value, snapshot_date)
_DIMENSION_INTRADAY_CACHE[cache_key] = (now, payload)
return payload
# ---------------------------------------------------------------------------
# 文件上传
# ---------------------------------------------------------------------------
+57
View File
@@ -223,3 +223,60 @@ def build_overview(
"counts": counts,
"rows": out_rows[:limit],
}
# ================================================================
# 盘中异动 (量价信号聚合, 异动监控「盘中」tab)
#
# 数据源: enriched 最新快照的当日消息号列 (零新增采集):
# 涨停/跌停/跌停翘板/炸板/放量(量比≥2)/创60日新高/新低。
# 行序 = 信号优先级 (涨停 > 炸板 > 翘板 > 跌停 > 新高 > 新低 > 放量),
# 同级按 |今日涨跌| 降序; counts 供前端筛选 chips 展示各类型数量。
# ================================================================
_INTRADAY_SIGNALS: tuple[tuple[str, str], ...] = (
("signal_limit_up", "limit_up"),
("signal_broken_limit_up", "broken"),
("signal_limit_down_recovery", "recovery"),
("signal_limit_down", "limit_down"),
("signal_n_day_high", "new_high"),
("signal_n_day_low", "new_low"),
("signal_volume_surge", "volume_surge"),
)
_INTRADAY_PRIORITY = {key: i for i, (_, key) in enumerate(_INTRADAY_SIGNALS)}
_INTRADAY_COLS = ("symbol", "name", "close", "change_pct", "amplitude",
"vol_ratio_5d", "turnover_rate", "consecutive_limit_ups")
def build_intraday(repo: Any, limit: int = 500) -> dict[str, Any]:
"""enriched 最新快照 → 当日异动信号命中行 (含各类型计数)。"""
df, cache_date = repo.get_enriched_latest()
empty = {"cache_date": cache_date.isoformat() if cache_date else None,
"counts": {}, "rows": []}
if df.is_empty() or "symbol" not in df.columns:
return empty
present = [(c, k) for c, k in _INTRADAY_SIGNALS if c in df.columns]
if not present:
return empty
hits = df.filter(pl.any_horizontal([pl.col(c).fill_null(False) for c, _ in present]))
if hits.is_empty():
return empty
counts = {k: int(hits[c].fill_null(False).sum()) for c, k in present}
sig_cols = {k: hits[c].fill_null(False).to_list() for c, k in present}
base_cols = [c for c in _INTRADAY_COLS if c in hits.columns]
base = hits.select(base_cols).to_dicts()
rows: list[dict[str, Any]] = []
for i, r in enumerate(base):
signals = [k for k, flags in sig_cols.items() if flags[i]]
rows.append({
**{c: r.get(c) for c in base_cols},
"signals": signals,
"_prio": min((_INTRADAY_PRIORITY[s] for s in signals), default=99),
})
rows.sort(key=lambda r: (r["_prio"], -abs(r.get("change_pct") or 0.0)))
for r in rows:
r.pop("_prio", None)
return {"cache_date": cache_date.isoformat() if cache_date else None,
"counts": counts, "rows": rows[:limit]}
@@ -258,10 +258,12 @@ def _dimension_rank(rows: list[dict], repo, kind: str, limit: int = 5, level: in
store = ExtConfigStore(repo.store.data_dir)
groups: dict[str, dict[str, dict]] = {}
group_source: dict[str, str] = {} # 组名 → 首个命中的扩展字段 "configId.field" (看板成分股弹窗用)
for config in store.load_all():
field = _dimension_field(config, kind)
if not field:
continue
source_field = f"{config.id}.{field}"
for ext_row in _read_ext_rows(repo.store.data_dir, config, field):
quote = None
for key in _symbol_keys(ext_row, config):
@@ -277,6 +279,7 @@ def _dimension_rank(rows: list[dict], repo, kind: str, limit: int = 5, level: in
parts = value.split("-")
value = parts[level - 1] if level <= len(parts) else parts[-1]
groups.setdefault(value, {})[symbol] = quote
group_source.setdefault(value, source_field)
items = []
for name, by_symbol in groups.items():
@@ -293,6 +296,7 @@ def _dimension_rank(rows: list[dict], repo, kind: str, limit: int = 5, level: in
"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),
"source_field": group_source.get(name),
"leader": {
"symbol": leader.get("symbol"),
"name": leader.get("name"),
+75
View File
@@ -0,0 +1,75 @@
"""盘中异动聚合测试 (build_intraday, 不依赖真实网络/enriched)。
覆盖: 信号命中过滤、counts 计数、优先级排序 (涨停 > 炸板 > …)、
多信号行、limit 截断、空快照与缺信号列的降级。
"""
from __future__ import annotations
from datetime import date
import polars as pl
from app.services.abnormal_moves import build_intraday
class _FakeRepo:
def __init__(self, df: pl.DataFrame):
self._df = df
def get_enriched_latest(self):
return self._df, date(2026, 8, 28)
def _df(rows: list[dict]) -> pl.DataFrame:
cols = ["symbol", "name", "close", "change_pct", "amplitude", "vol_ratio_5d",
"turnover_rate", "consecutive_limit_ups",
"signal_limit_up", "signal_broken_limit_up", "signal_limit_down_recovery",
"signal_limit_down", "signal_n_day_high", "signal_n_day_low",
"signal_volume_surge"]
base = {c: [] for c in cols}
for r in rows:
for c in cols:
base[c].append(r.get(c))
return pl.DataFrame(base)
def test_counts_filter_and_priority():
repo = _FakeRepo(_df([
{"symbol": "A1", "name": "", "close": 10.0, "change_pct": 0.1,
"signal_limit_up": True, "signal_n_day_high": True},
{"symbol": "B1", "name": "", "close": 5.0, "change_pct": -0.05,
"signal_limit_down": True},
{"symbol": "C1", "name": "", "close": 8.0, "change_pct": 0.02,
"signal_volume_surge": True},
{"symbol": "D1", "name": "", "close": 7.0, "change_pct": None}, # 无信号 → 不出现
]))
out = build_intraday(repo)
assert out["cache_date"] == "2026-08-28"
assert out["counts"] == {"limit_up": 1, "broken": 0, "recovery": 0,
"limit_down": 1, "new_high": 1, "new_low": 0,
"volume_surge": 1}
syms = [r["symbol"] for r in out["rows"]]
assert syms == ["A1", "B1", "C1"] # 优先级: 涨停 > 跌停 > 放量; 无信号被过滤
assert out["rows"][0]["signals"] == ["limit_up", "new_high"] # 多信号按优先级序
def test_limit_truncates():
repo = _FakeRepo(_df([
{"symbol": f"S{i}", "signal_volume_surge": True, "change_pct": 0.01} for i in range(10)
]))
out = build_intraday(repo, limit=3)
assert len(out["rows"]) == 3
assert out["counts"]["volume_surge"] == 10 # counts 不受 limit 影响
def test_empty_snapshot():
repo = _FakeRepo(pl.DataFrame({"symbol": [], "name": []}))
out = build_intraday(repo)
assert out["rows"] == [] and out["counts"] == {}
def test_missing_signal_columns_degrades():
repo = _FakeRepo(pl.DataFrame({"symbol": ["A1"], "name": [""]}))
out = build_intraday(repo)
assert out["rows"] == [] and out["counts"] == {}
+158
View File
@@ -0,0 +1,158 @@
"""板块分时 (dimension-intraday) 纯函数测试。
夹具: snapshot 扩展配置 (所属概念) + kline_minute/kline_daily 分区,
验证等权口径、停牌 ffill、prev_close/首根基准与各降级状态。
"""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
import polars as pl
from app.api.ext_data import _dimension_intraday_compute
from app.services.ext_data import ExtConfig
def _mk_config() -> ExtConfig:
return ExtConfig(id="ext_gn", label="测试概念", mode="snapshot", fields=[])
def _write_ext(data_dir: Path, values: dict[str, str]) -> None:
"""snapshot 扩展数据: symbol → 所属概念 标签串。"""
cfg_dir = data_dir / "ext_data" / "ext_gn"
cfg_dir.mkdir(parents=True, exist_ok=True)
df = pl.DataFrame({
"symbol": list(values.keys()),
"所属概念": list(values.values()),
})
df.write_parquet(cfg_dir / "part.parquet")
def _write_minute(data_dir: Path, day: str, rows: list[tuple[str, str, float]]) -> None:
part = data_dir / "kline_minute" / f"date={day}" / "part.parquet"
part.parent.mkdir(parents=True, exist_ok=True)
df = pl.DataFrame(
{
"symbol": [r[0] for r in rows],
"datetime": [datetime.fromisoformat(r[1]) for r in rows],
"close": [r[2] for r in rows],
},
schema_overrides={"datetime": pl.Datetime("us")},
)
df.write_parquet(part)
def _write_daily(data_dir: Path, day: str, closes: dict[str, float]) -> None:
part = data_dir / "kline_daily" / f"date={day}" / "part.parquet"
part.parent.mkdir(parents=True, exist_ok=True)
pl.DataFrame({
"symbol": list(closes.keys()),
"close": list(closes.values()),
}).write_parquet(part)
def test_dimension_intraday_equal_weight_and_ffill(tmp_path: Path) -> None:
data_dir = tmp_path / "data"
_write_ext(data_dir, {"000001.SZ": "人工智能、芯片", "000002.SZ": "人工智能"})
# 前收: 000001=10.0 (+5%/+6%/+4%), 000002=20.0, 600000 非成分股
_write_daily(data_dir, "2026-08-27", {"000001.SZ": 10.0, "000002.SZ": 20.0, "600000.SH": 5.0})
# 000002 在 09:32 无成交 (停牌分钟) → ffill 沿用 20.4
_write_minute(data_dir, "2026-08-28", [
("000001.SZ", "2026-08-28T09:31:00", 10.5),
("000002.SZ", "2026-08-28T09:31:00", 20.4),
("600000.SH", "2026-08-28T09:31:00", 5.05),
("000001.SZ", "2026-08-28T09:32:00", 10.6),
("600000.SH", "2026-08-28T09:32:00", 5.10),
("000001.SZ", "2026-08-28T09:33:00", 10.4),
("000002.SZ", "2026-08-28T09:33:00", 20.8),
("600000.SH", "2026-08-28T09:33:00", 5.20),
])
payload = _dimension_intraday_compute(_mk_config(), data_dir, "所属概念", "人工智能", None)
assert payload["status"] == "ok"
assert payload["date"] == "2026-08-28"
assert payload["basis"] == "prev_close"
assert payload["member_count"] == 2
assert payload["members_with_minute"] == 2
points = payload["points"]
assert [p["time"] for p in points] == ["09:31", "09:32", "09:33"]
# 09:31: 成分等权 (5% + 2%)/2 = 3.5%; 全市场 (5+2+1)/3 ≈ 2.667% (小数制)
assert points[0]["sector"] == 0.035
assert points[0]["market"] == 0.0267
# 09:32: 000002 ffill 20.4 → (6% + 2%)/2 = 4.0% (无 ffill 会是 6.0%)
assert points[1]["sector"] == 0.04
assert points[1]["market"] == 0.04
# 09:33: (4% + 4%)/2 = 4.0%
assert points[2]["sector"] == 0.04
def test_dimension_intraday_tag_no_partial_match(tmp_path: Path) -> None:
data_dir = tmp_path / "data"
_write_ext(data_dir, {"000001.SZ": "人工智能体"})
_write_minute(data_dir, "2026-08-28", [("000001.SZ", "2026-08-28T09:31:00", 10.5)])
payload = _dimension_intraday_compute(_mk_config(), data_dir, "所属概念", "人工智能", None)
assert payload["status"] == "empty"
assert payload["reason"] == "no_members"
def test_dimension_intraday_no_minute_store(tmp_path: Path) -> None:
data_dir = tmp_path / "data"
_write_ext(data_dir, {"000001.SZ": "人工智能"})
payload = _dimension_intraday_compute(_mk_config(), data_dir, "所属概念", "人工智能", None)
assert payload["status"] == "no_data"
assert payload["reason"] == "minute_missing"
def test_dimension_intraday_requested_date_absent(tmp_path: Path) -> None:
data_dir = tmp_path / "data"
_write_ext(data_dir, {"000001.SZ": "人工智能"})
_write_minute(data_dir, "2026-08-28", [("000001.SZ", "2026-08-28T09:31:00", 10.5)])
payload = _dimension_intraday_compute(_mk_config(), data_dir, "所属概念", "人工智能", "2026-08-27")
assert payload["status"] == "no_data"
assert payload["reason"] == "minute_missing"
def test_dimension_intraday_members_without_bars(tmp_path: Path) -> None:
"""成分股全是 ETF 等无分钟数据的标的 → empty/no_member_bars。"""
data_dir = tmp_path / "data"
_write_ext(data_dir, {"510050.SH": "人工智能"})
_write_daily(data_dir, "2026-08-27", {"510050.SH": 3.0, "000001.SZ": 10.0})
_write_minute(data_dir, "2026-08-28", [("000001.SZ", "2026-08-28T09:31:00", 10.5)])
payload = _dimension_intraday_compute(_mk_config(), data_dir, "所属概念", "人工智能", None)
assert payload["status"] == "empty"
assert payload["reason"] == "no_member_bars"
assert payload["member_count"] == 1
def test_dimension_intraday_first_close_basis(tmp_path: Path) -> None:
"""无前一交易日日K → 基准退化为当日首根 close, 曲线起点 ≈ 0。"""
data_dir = tmp_path / "data"
_write_ext(data_dir, {"000001.SZ": "人工智能"})
_write_minute(data_dir, "2026-08-28", [
("000001.SZ", "2026-08-28T09:31:00", 10.0),
("000001.SZ", "2026-08-28T09:32:00", 10.3),
])
payload = _dimension_intraday_compute(_mk_config(), data_dir, "所属概念", "人工智能", None)
assert payload["status"] == "ok"
assert payload["basis"] == "first_close"
assert payload["points"][0]["sector"] == 0.0
assert payload["points"][1]["sector"] == 0.03
def test_dimension_intraday_explicit_date_uses_that_partition(tmp_path: Path) -> None:
data_dir = tmp_path / "data"
_write_ext(data_dir, {"000001.SZ": "人工智能"})
_write_daily(data_dir, "2026-08-26", {"000001.SZ": 10.0})
_write_daily(data_dir, "2026-08-27", {"000001.SZ": 11.0})
_write_minute(data_dir, "2026-08-27", [("000001.SZ", "2026-08-27T09:31:00", 10.5)])
_write_minute(data_dir, "2026-08-28", [("000001.SZ", "2026-08-28T09:31:00", 12.1)])
# 默认取最新分区 2026-08-28 → prev 为 08-27 的 11.0 → +10%
latest = _dimension_intraday_compute(_mk_config(), data_dir, "所属概念", "人工智能", None)
assert latest["date"] == "2026-08-28"
assert latest["points"][0]["sector"] == 0.1
# 显式指定 08-27 → prev 为 08-26 的 10.0 → +5%
explicit = _dimension_intraday_compute(_mk_config(), data_dir, "所属概念", "人工智能", "2026-08-27")
assert explicit["date"] == "2026-08-27"
assert explicit["points"][0]["sector"] == 0.05