mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 19:04:15 +08:00
- 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 测
159 lines
7.0 KiB
Python
159 lines
7.0 KiB
Python
"""板块分时 (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
|