mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
_reference_index 用墙钟时间差找「>=60 分钟前」的桶。午休 11:30~13:00 没有桶, 13:00~13:59 往前数墙钟 60 分钟会越过午休落到 11:30 桶: 13:00 时只隔 1 个交易 分钟, rank_prev/pct_prev/rank_change、rank_change 与 momentum 维度的自动榜、 timeline 的 rotation 在午后第一个小时都失真 (切入/退潮基本为 0)。 改用 market_time.trading_minutes_elapsed_from_dt 计算交易分钟差, 午休不计, 与 同一函数里活跃度窗口按桶数回看同口径。上午与 14:00 以后结果不变。 Closes #347 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
618 lines
28 KiB
Python
618 lines
28 KiB
Python
"""板块切换(盘中轮动) service 契约测试。
|
|
|
|
不依赖网络与真实 data/: 用 tmp_path 构造
|
|
分钟分区 + 前日日K + 概念扩展表 + 资金流扩展表, 断言:
|
|
聚合口径 (等权/前收基准)、切换信号方向 (rank_change)、轮动强度时间线、
|
|
资金流读取与降级 (flow 不可用退化为纯涨幅)、成分数去重、
|
|
非法参数 fail-closed、无分钟/无成分的明确 no_data。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import polars as pl
|
|
import pytest
|
|
|
|
from app.services import rps_rotation, sector_rotation
|
|
from app.services.ext_data import ExtConfig, ExtConfigStore, ExtField, write_ext_parquet
|
|
|
|
DAY = "2026-09-11"
|
|
PREV = "2026-09-10"
|
|
SYMS_A = ["000001.SZ", "000002.SZ"]
|
|
SYMS_B = ["000003.SZ", "000004.SZ"]
|
|
|
|
|
|
def _reset_caches() -> None:
|
|
# 成分映射 (600s) 与结果缓存 (30s) 都是进程级, 必须逐用例清理防串扰
|
|
rps_rotation._map_cache.clear()
|
|
rps_rotation._map_ts.clear()
|
|
sector_rotation.invalidate_cache()
|
|
|
|
|
|
def _write_minute(data_dir: Path) -> None:
|
|
"""A 组先涨后平 (09:35 起涨到 10:00 后走平), B 组 10:00 后直线拉升 → 领涨从 A 切到 B。"""
|
|
rows = []
|
|
stamps = [datetime.fromisoformat(f"{DAY}T{h:02d}:{m:02d}:00") for h, m in [
|
|
(9, 35), (9, 40), (9, 45), (9, 50), (9, 55),
|
|
(10, 0), (10, 5), (10, 10), (10, 20), (10, 30), (10, 35),
|
|
]]
|
|
for ts in stamps:
|
|
late = ts.hour > 10 or (ts.hour == 10 and ts.minute >= 5)
|
|
for sym in SYMS_A:
|
|
price = 102.0 if not late else 101.5
|
|
rows.append({"symbol": sym, "datetime": ts, "close": price})
|
|
for sym in SYMS_B:
|
|
price = 100.2 if not late else 106.0
|
|
rows.append({"symbol": sym, "datetime": ts, "close": price})
|
|
out = data_dir / "kline_minute" / f"date={DAY}"
|
|
out.mkdir(parents=True)
|
|
pl.DataFrame(rows).write_parquet(out / "part.parquet")
|
|
|
|
|
|
def _write_prev_daily(data_dir: Path) -> None:
|
|
out = data_dir / "kline_daily" / f"date={PREV}"
|
|
out.mkdir(parents=True)
|
|
pl.DataFrame({
|
|
"symbol": SYMS_A + SYMS_B,
|
|
"close": [100.0] * 4,
|
|
}).write_parquet(out / "part.parquet")
|
|
|
|
|
|
def _write_concept_ext(data_dir: Path) -> None:
|
|
config = ExtConfig(
|
|
id="ext_gn",
|
|
label="测试概念",
|
|
mode="snapshot",
|
|
fields=[ExtField("symbol", "string", "代码"), ExtField("所属概念", "string", "所属概念")],
|
|
)
|
|
ExtConfigStore(data_dir).upsert(config)
|
|
write_ext_parquet(
|
|
pl.DataFrame({
|
|
"symbol": SYMS_A + SYMS_B,
|
|
"所属概念": ["A题材", "A题材", "B题材", "B题材"],
|
|
}),
|
|
config,
|
|
data_dir,
|
|
)
|
|
|
|
|
|
def _write_flow_ext(data_dir: Path) -> None:
|
|
config = ExtConfig(
|
|
id="ext_flow",
|
|
label="测试资金流",
|
|
mode="snapshot",
|
|
fields=[ExtField("symbol", "string", "代码"), ExtField("净流入", "float", "净流入")],
|
|
)
|
|
ExtConfigStore(data_dir).upsert(config)
|
|
write_ext_parquet(
|
|
pl.DataFrame({
|
|
"symbol": SYMS_A + SYMS_B,
|
|
"净流入": [1000.0, 500.0, 100.0, 50.0], # A 题材合计 1500, B 题材合计 150
|
|
}),
|
|
config,
|
|
data_dir,
|
|
)
|
|
|
|
|
|
@pytest.fixture()
|
|
def repo(tmp_path: Path):
|
|
_reset_caches()
|
|
data_dir = tmp_path / "data"
|
|
data_dir.mkdir()
|
|
_write_minute(data_dir)
|
|
_write_prev_daily(data_dir)
|
|
_write_concept_ext(data_dir)
|
|
_write_flow_ext(data_dir)
|
|
yield SimpleNamespace(store=SimpleNamespace(data_dir=data_dir))
|
|
_reset_caches()
|
|
|
|
|
|
def test_rotation_switch_direction_and_timeline(repo):
|
|
"""核心口径: 领涨从 A 切到 B → B rank_change>0 (切入), A <0 (退潮);
|
|
末桶相对 1 小时前 top 换血 → rotation 高; 早期桶 rotation 低。"""
|
|
result = sector_rotation.build_sector_rotation(repo, kind="concept", bucket_minutes=5)
|
|
assert result["status"] == "ok"
|
|
assert result["date"] == DAY
|
|
assert result["basis"] == "prev_close"
|
|
assert result["member_count"] == 2
|
|
|
|
sectors = {item["name"]: item for item in result["sectors"]}
|
|
assert set(sectors) == {"A题材", "B题材"}
|
|
b, a = sectors["B题材"], sectors["A题材"]
|
|
# 末桶 (10:35): B +6.0% > A +1.5%; 1 小时前 (09:35): A +2.0% > B +0.2%
|
|
assert b["pct_now"] == pytest.approx(0.06, abs=1e-4)
|
|
assert a["pct_now"] == pytest.approx(0.015, abs=1e-4)
|
|
assert b["rank_now"] == 1 and b["rank_prev"] == 2
|
|
assert a["rank_now"] == 2 and a["rank_prev"] == 1
|
|
assert b["rank_change"] == 1 # 切入
|
|
assert a["rank_change"] == -1 # 退潮
|
|
assert a["n_members"] == 2 and b["n_members"] == 2
|
|
assert a["n_members_with_bars"] == 2 and b["n_members_with_bars"] == 2
|
|
|
|
timeline = result["timeline"]
|
|
times = [point["time"] for point in timeline]
|
|
assert times == sorted(times) and len(times) >= 9
|
|
# 板块总数仅 2 个, top 集合恒为全集 → 集合口径的换血为 0;
|
|
# 领涨易主由 leader 字段与 sectors 的 rank_change 体现
|
|
assert timeline[-1]["rotation"] == 0.0
|
|
assert timeline[-1]["leader"] == "B题材"
|
|
assert timeline[-1]["leader_pct"] == pytest.approx(0.06, abs=1e-4)
|
|
# 早期桶领涨是 A, 且未发生换血
|
|
assert timeline[0]["rotation"] == 0.0
|
|
assert timeline[0]["leader"] == "A题材"
|
|
|
|
|
|
def test_rotation_index_with_wide_universe(tmp_path):
|
|
"""宽板块域 (20 个) 的换血指标: 早段领涨梯队 S1-S10, 末段整体换为 S11-S20,
|
|
两集合不相交 → 末桶 rotation=1; 首桶无对照 → 0。"""
|
|
_reset_caches()
|
|
data_dir = tmp_path / "data"
|
|
data_dir.mkdir()
|
|
rows = []
|
|
stamps = [datetime.fromisoformat(f"{DAY}T{h:02d}:{m:02d}:00") for h, m in [
|
|
(9, 35), (9, 40), (9, 45), (9, 50), (9, 55),
|
|
(10, 0), (10, 5), (10, 10), (10, 20), (10, 30), (10, 35),
|
|
]]
|
|
for index in range(20):
|
|
sym = f"{index + 1:06d}.SZ"
|
|
for ts in stamps:
|
|
late = ts.hour > 10 or (ts.hour == 10 and ts.minute >= 5)
|
|
# 早段 S01-S10 领涨 +2% / S11-S20 平淡 +0.2%; 末段整体对调 (回落 -1% / 拉升 +6%)
|
|
price = (102.0 if not late else 99.0) if index < 10 else (100.2 if not late else 106.0)
|
|
rows.append({"symbol": sym, "datetime": ts, "close": price})
|
|
(data_dir / "kline_minute" / f"date={DAY}").mkdir(parents=True)
|
|
pl.DataFrame(rows).write_parquet(data_dir / "kline_minute" / f"date={DAY}" / "part.parquet")
|
|
(data_dir / "kline_daily" / f"date={PREV}").mkdir(parents=True)
|
|
pl.DataFrame({"symbol": [f"{i + 1:06d}.SZ" for i in range(20)], "close": [100.0] * 20}).write_parquet(
|
|
data_dir / "kline_daily" / f"date={PREV}" / "part.parquet"
|
|
)
|
|
config = ExtConfig(
|
|
id="ext_gn",
|
|
label="宽域概念",
|
|
mode="snapshot",
|
|
fields=[ExtField("symbol", "string", "代码"), ExtField("所属概念", "string", "所属概念")],
|
|
)
|
|
ExtConfigStore(data_dir).upsert(config)
|
|
write_ext_parquet(
|
|
pl.DataFrame({
|
|
"symbol": [f"{i + 1:06d}.SZ" for i in range(20)],
|
|
"所属概念": [f"S{i + 1:02d}" for i in range(20)],
|
|
}),
|
|
config,
|
|
data_dir,
|
|
)
|
|
repo = SimpleNamespace(store=SimpleNamespace(data_dir=data_dir))
|
|
result = sector_rotation.build_sector_rotation(repo, kind="concept", bucket_minutes=5)
|
|
assert result["status"] == "ok"
|
|
assert result["member_count"] == 20
|
|
timeline = result["timeline"]
|
|
assert timeline[0]["rotation"] == 0.0
|
|
# 早段 S01-S10 涨幅并列, 领涨归属由并列内排序决定 → 只断言梯队归属
|
|
assert timeline[0]["leader"] in {f"S{i:02d}" for i in range(1, 11)}
|
|
assert timeline[-1]["rotation"] == 1.0
|
|
assert timeline[-1]["leader"] in {f"S{i:02d}" for i in range(11, 21)}
|
|
# 末段切入者 rank_now=1, 1 小时前在 11 名开外 → rank_change > 0
|
|
top = next(item for item in result["sectors"] if item["rank_now"] == 1)
|
|
assert top["rank_change"] > 0
|
|
fading = next(item for item in result["sectors"] if item["name"] == "S01")
|
|
assert fading["rank_change"] < 0
|
|
|
|
|
|
def test_flow_from_selected_ext_and_score(repo):
|
|
"""资金流按用户选择的扩展列聚合到板块, 与涨幅各占 50%。"""
|
|
result = sector_rotation.build_sector_rotation(
|
|
repo, kind="concept", flow_field="ext_flow.净流入",
|
|
)
|
|
assert result["status"] == "ok"
|
|
assert result["flow_available"] is True
|
|
assert result["flow_field"] == "ext_flow.净流入"
|
|
sectors = {item["name"]: item for item in result["sectors"]}
|
|
assert sectors["A题材"]["flow"] == 1500.0
|
|
assert sectors["B题材"]["flow"] == 150.0
|
|
# 涨幅归一: B=100, A=0; 资金流归一: A=100, B=0 → 两者综合分均为 50
|
|
assert sectors["A题材"]["score"] == pytest.approx(50.0)
|
|
assert sectors["B题材"]["score"] == pytest.approx(50.0)
|
|
|
|
|
|
def test_flow_missing_degrades_to_pure_pct(repo):
|
|
"""资金流指向不存在的表/列 → 不阻断, flow_available=False, score 退化为纯涨幅归一。"""
|
|
result = sector_rotation.build_sector_rotation(
|
|
repo, kind="concept", flow_field="ext_nope.净流入",
|
|
)
|
|
assert result["status"] == "ok"
|
|
assert result["flow_available"] is False
|
|
sectors = {item["name"]: item for item in result["sectors"]}
|
|
assert sectors["B题材"]["flow"] is None
|
|
assert sectors["B题材"]["score"] == pytest.approx(100.0)
|
|
assert sectors["A题材"]["score"] == pytest.approx(0.0)
|
|
|
|
|
|
def test_no_minute_partition_gives_no_data(repo, tmp_path):
|
|
(repo.store.data_dir / "kline_minute" / f"date={DAY}" / "part.parquet").unlink()
|
|
result = sector_rotation.build_sector_rotation(repo, kind="concept")
|
|
assert result["status"] == "no_data"
|
|
assert result["reason"] == "minute_missing"
|
|
|
|
|
|
def test_no_member_map_gives_no_data(repo):
|
|
import shutil
|
|
shutil.rmtree(repo.store.data_dir / "ext_data" / "ext_gn")
|
|
result = sector_rotation.build_sector_rotation(repo, kind="concept")
|
|
assert result["status"] == "no_data"
|
|
assert result["reason"] == "members_missing"
|
|
|
|
|
|
def test_invalid_params_fail_closed(repo):
|
|
with pytest.raises(ValueError):
|
|
sector_rotation.build_sector_rotation(repo, kind="foo")
|
|
with pytest.raises(ValueError):
|
|
sector_rotation.build_sector_rotation(repo, kind="concept", bucket_minutes=3)
|
|
|
|
|
|
def test_result_cache_hit(repo):
|
|
sector_rotation.build_sector_rotation(repo, kind="concept")
|
|
# 第二次直接命中 30s 缓存 (无分钟数据也应返回缓存结果而非重算)
|
|
(repo.store.data_dir / "kline_minute" / f"date={DAY}" / "part.parquet").unlink()
|
|
again = sector_rotation.build_sector_rotation(repo, kind="concept")
|
|
assert again["status"] == "ok"
|
|
|
|
|
|
def test_activity_ranking_and_custom_series(tmp_path):
|
|
"""活跃度 = 近 30 分钟成分股成交额合计: A 题材 6 桶x2 股x100 万 = 1200 万,
|
|
B 题材 120 万 → universe 活跃降序 A 在前; 缺省展示行 = 活跃 Top10 (与
|
|
score 排序的 sectors 区分); series_names 自定义行去重并剔除当日无行情板块。"""
|
|
_reset_caches()
|
|
data_dir = tmp_path / "data"
|
|
data_dir.mkdir()
|
|
rows = []
|
|
stamps = [datetime.fromisoformat(f"{DAY}T{h:02d}:{m:02d}:00") for h, m in [
|
|
(9, 35), (9, 40), (9, 45), (9, 50), (9, 55),
|
|
(10, 0), (10, 5), (10, 10), (10, 20), (10, 30), (10, 35),
|
|
]]
|
|
for ts in stamps:
|
|
late = ts.hour > 10 or (ts.hour == 10 and ts.minute >= 5)
|
|
for sym in SYMS_A:
|
|
rows.append({"symbol": sym, "datetime": ts, "close": 102.0 if not late else 101.5, "amount": 1_000_000.0})
|
|
for sym in SYMS_B:
|
|
rows.append({"symbol": sym, "datetime": ts, "close": 100.2 if not late else 106.0, "amount": 100_000.0})
|
|
(data_dir / "kline_minute" / f"date={DAY}").mkdir(parents=True)
|
|
pl.DataFrame(rows).write_parquet(data_dir / "kline_minute" / f"date={DAY}" / "part.parquet")
|
|
_write_prev_daily(data_dir)
|
|
_write_concept_ext(data_dir)
|
|
repo = SimpleNamespace(store=SimpleNamespace(data_dir=data_dir))
|
|
|
|
result = sector_rotation.build_sector_rotation(repo, kind="concept", bucket_minutes=5)
|
|
assert result["status"] == "ok"
|
|
universe = result["universe"]
|
|
assert universe[0]["name"] == "A题材"
|
|
assert universe[0]["activity"] == pytest.approx(12_000_000.0)
|
|
assert universe[1]["name"] == "B题材"
|
|
assert universe[1]["activity"] == pytest.approx(1_200_000.0)
|
|
# 缺省展示行 = 活跃 Top10 (A 在前), 与 score 排序的 sectors (B 在前) 区分
|
|
assert result["series"]["sectors"] == ["A题材", "B题材"]
|
|
assert next(s["name"] for s in result["sectors"]) == "B题材"
|
|
|
|
# 自定义展示行: 去重 + 当日无行情板块剔除, matrix 与该板块桶涨幅一致
|
|
custom = sector_rotation.build_sector_rotation(
|
|
repo, kind="concept", bucket_minutes=5,
|
|
series_names=["B题材", "不存在的板块", "B题材"],
|
|
)
|
|
assert custom["series"]["sectors"] == ["B题材"]
|
|
assert custom["series"]["matrix"][0][-1] == pytest.approx(0.06, abs=1e-4)
|
|
# 缓存键含 series_names: 自定义与缺省互不串数据
|
|
assert result["series"]["sectors"] == ["A题材", "B题材"]
|
|
|
|
|
|
def test_industry_kind_uses_industry_map(repo):
|
|
"""kind=industry 无行业映射 → 明确 no_data (二选一互不串数据)。"""
|
|
result = sector_rotation.build_sector_rotation(repo, kind="industry")
|
|
assert result["status"] == "no_data"
|
|
assert result["reason"] == "members_missing"
|
|
|
|
|
|
GYMS = ["600901.SH", "600902.SH"] # 名称命中默认黑名单 (融资融券)
|
|
GIANT_ALL = [f"98{i:04d}.SZ" for i in range(400)] # 巨盘概念: 400 成员, 超成员数上限
|
|
GIANT_BARS = GIANT_ALL[:2]
|
|
|
|
|
|
def _write_activity_data(tmp_path: Path):
|
|
"""带量额 + 属性类大桶的活跃度 fixture: 近 30 分钟窗口合计
|
|
融资融券 60M > 巨盘概念 24M > A题材 12M > B题材 1.2M。"""
|
|
_reset_caches()
|
|
data_dir = tmp_path / "data"
|
|
data_dir.mkdir()
|
|
stamps = [datetime.fromisoformat(f"{DAY}T{h:02d}:{m:02d}:00") for h, m in [
|
|
(9, 35), (9, 40), (9, 45), (9, 50), (9, 55),
|
|
(10, 0), (10, 5), (10, 10), (10, 20), (10, 30), (10, 35),
|
|
]]
|
|
rows = []
|
|
for ts in stamps:
|
|
late = ts.hour > 10 or (ts.hour == 10 and ts.minute >= 5)
|
|
for syms, amount, early_close, late_close in (
|
|
(GYMS, 5_000_000.0, 99.0, 101.0), # 融资融券: -1% → +1% (动量 +2.0)
|
|
(GIANT_BARS, 2_000_000.0, 101.0, 99.0), # 巨盘概念: +1% → -1% (动量 -2.0)
|
|
(SYMS_A, 1_000_000.0, 102.0, 101.5), # A题材: +2% → +1.5% (动量 -0.5)
|
|
(SYMS_B, 100_000.0, 100.2, 106.0), # B题材: +0.2% → +6% (动量 +5.8)
|
|
):
|
|
for sym in syms:
|
|
rows.append({
|
|
"symbol": sym, "datetime": ts,
|
|
"close": late_close if late else early_close, "amount": amount,
|
|
})
|
|
(data_dir / "kline_minute" / f"date={DAY}").mkdir(parents=True)
|
|
pl.DataFrame(rows).write_parquet(data_dir / "kline_minute" / f"date={DAY}" / "part.parquet")
|
|
bar_syms = SYMS_A + SYMS_B + GYMS + GIANT_BARS
|
|
(data_dir / "kline_daily" / f"date={PREV}").mkdir(parents=True)
|
|
pl.DataFrame({"symbol": bar_syms, "close": [100.0] * len(bar_syms)}).write_parquet(
|
|
data_dir / "kline_daily" / f"date={PREV}" / "part.parquet"
|
|
)
|
|
config = ExtConfig(
|
|
id="ext_gn",
|
|
label="活跃度测试概念",
|
|
mode="snapshot",
|
|
fields=[ExtField("symbol", "string", "代码"), ExtField("所属概念", "string", "所属概念")],
|
|
)
|
|
ExtConfigStore(data_dir).upsert(config)
|
|
write_ext_parquet(
|
|
pl.DataFrame({
|
|
"symbol": SYMS_A + SYMS_B + GYMS + GIANT_ALL,
|
|
"所属概念": (["A题材"] * len(SYMS_A) + ["B题材"] * len(SYMS_B)
|
|
+ ["融资融券"] * len(GYMS) + ["巨盘概念"] * len(GIANT_ALL)),
|
|
}),
|
|
config,
|
|
data_dir,
|
|
)
|
|
flow_config = ExtConfig(
|
|
id="ext_flow",
|
|
label="活跃度测试资金流",
|
|
mode="snapshot",
|
|
fields=[ExtField("symbol", "string", "代码"), ExtField("净流入", "float", "净流入")],
|
|
)
|
|
ExtConfigStore(data_dir).upsert(flow_config)
|
|
write_ext_parquet(
|
|
pl.DataFrame({
|
|
"symbol": SYMS_A + SYMS_B + GYMS + GIANT_BARS,
|
|
"净流入": [1000.0, 500.0, 100.0, 50.0, 10000.0, 10000.0, 10.0, 10.0],
|
|
}),
|
|
flow_config,
|
|
data_dir,
|
|
)
|
|
return SimpleNamespace(store=SimpleNamespace(data_dir=data_dir))
|
|
|
|
|
|
def test_auto_exclude_default_and_universe_flag(tmp_path):
|
|
"""默认排除名单生效: 融资融券 (名称命中) 与巨盘概念 (成员数>上限) 不进自动活跃榜;
|
|
universe 永不剔除但带 excluded 标记; 响应下发默认名单与上限。"""
|
|
repo = _write_activity_data(tmp_path)
|
|
result = sector_rotation.build_sector_rotation(repo, kind="concept", bucket_minutes=5, auto_rows=2)
|
|
assert result["status"] == "ok"
|
|
# 活跃榜 (60M/24M/12M/1.2M) 未过滤时应为 融资融券/巨盘概念 在前, 过滤后取 A/B
|
|
assert result["series"]["sectors"] == ["A题材", "B题材"]
|
|
assert [item["name"] for item in result["universe"]] == ["融资融券", "巨盘概念", "A题材", "B题材"]
|
|
uni = {item["name"]: item for item in result["universe"]}
|
|
assert uni["融资融券"]["excluded"] is True
|
|
assert uni["巨盘概念"]["excluded"] is True
|
|
assert uni["A题材"]["excluded"] is False
|
|
assert "融资融券" in result["default_exclude_sectors"]
|
|
assert result["max_auto_members"] == 300
|
|
|
|
|
|
def test_exclude_param_replaces_default_and_empty_clears(tmp_path):
|
|
"""exclude_sectors 整体替换默认名单 (替换后融资融券可入选);
|
|
[] = 关闭名称过滤但成员数上限仍生效; 全排除时回退不过滤。"""
|
|
repo = _write_activity_data(tmp_path)
|
|
|
|
# 替换默认名单: 仅排除 A题材 → 活跃第一的融资融券得以入选
|
|
replaced = sector_rotation.build_sector_rotation(
|
|
repo, kind="concept", bucket_minutes=5, exclude_sectors=["A题材"], auto_rows=1,
|
|
)
|
|
assert replaced["series"]["sectors"] == ["融资融券"]
|
|
|
|
# 空数组 = 清空名称过滤: 融资融券入选, 巨盘概念仍被成员数上限挡住
|
|
cleared = sector_rotation.build_sector_rotation(
|
|
repo, kind="concept", bucket_minutes=5, exclude_sectors=[], auto_rows=3,
|
|
)
|
|
assert cleared["series"]["sectors"] == ["融资融券", "A题材", "B题材"]
|
|
|
|
# 全部排除 → 过滤后不足展示行数, 回退不过滤 (避免展示行开天窗)
|
|
all_excluded = sector_rotation.build_sector_rotation(
|
|
repo, kind="concept", bucket_minutes=5,
|
|
exclude_sectors=["A题材", "B题材", "融资融券", "巨盘概念"], auto_rows=2,
|
|
)
|
|
assert all_excluded["series"]["sectors"] == ["融资融券", "巨盘概念"]
|
|
|
|
|
|
def test_custom_series_unaffected_by_exclude(tmp_path):
|
|
"""自定义监控清单不参与任何自动过滤: 黑名单命中的板块仍可手动展示。"""
|
|
repo = _write_activity_data(tmp_path)
|
|
result = sector_rotation.build_sector_rotation(
|
|
repo, kind="concept", bucket_minutes=5, series_names=["融资融券", "不存在的板块", "融资融券"],
|
|
)
|
|
assert result["series"]["sectors"] == ["融资融券"]
|
|
|
|
|
|
def test_zero_cross_up_down_stats(tmp_path):
|
|
"""0 轴穿越统计: B 先跌后涨 → 上穿(转强) 1 次; C 先涨后跌 → 下穿(转弱) 1 次;
|
|
A 恒在 0 轴上方不计; timeline 各桶带计数; cross_events 最新在前。"""
|
|
_reset_caches()
|
|
data_dir = tmp_path / "data"
|
|
data_dir.mkdir()
|
|
stamps = [datetime.fromisoformat(f"{DAY}T{h:02d}:{m:02d}:00") for h, m in [(9, 35), (9, 40), (9, 45)]]
|
|
groups = {
|
|
"A题材": (["000001.SZ", "000002.SZ"], [102.0, 102.0, 102.0]),
|
|
"B题材": (["000003.SZ", "000004.SZ"], [99.0, 101.0, 101.0]),
|
|
"C题材": (["000005.SZ", "000006.SZ"], [101.0, 99.0, 99.0]),
|
|
}
|
|
rows = []
|
|
for index, ts in enumerate(stamps):
|
|
for syms, closes in groups.values():
|
|
for sym in syms:
|
|
rows.append({"symbol": sym, "datetime": ts, "close": closes[index]})
|
|
(data_dir / "kline_minute" / f"date={DAY}").mkdir(parents=True)
|
|
pl.DataFrame(rows).write_parquet(data_dir / "kline_minute" / f"date={DAY}" / "part.parquet")
|
|
(data_dir / "kline_daily" / f"date={PREV}").mkdir(parents=True)
|
|
pl.DataFrame({
|
|
"symbol": [sym for syms, _ in groups.values() for sym in syms],
|
|
"close": [100.0] * 6,
|
|
}).write_parquet(data_dir / "kline_daily" / f"date={PREV}" / "part.parquet")
|
|
config = ExtConfig(
|
|
id="ext_gn", label="穿越测试概念", mode="snapshot",
|
|
fields=[ExtField("symbol", "string", "代码"), ExtField("所属概念", "string", "所属概念")],
|
|
)
|
|
ExtConfigStore(data_dir).upsert(config)
|
|
write_ext_parquet(
|
|
pl.DataFrame({
|
|
"symbol": [sym for syms, _ in groups.values() for sym in syms],
|
|
"所属概念": [name for name, (syms, _) in groups.items() for _ in syms],
|
|
}),
|
|
config,
|
|
data_dir,
|
|
)
|
|
repo = SimpleNamespace(store=SimpleNamespace(data_dir=data_dir))
|
|
|
|
result = sector_rotation.build_sector_rotation(repo, kind="concept", bucket_minutes=5)
|
|
assert result["status"] == "ok"
|
|
assert sum(p.get("cross_up") or 0 for p in result["timeline"]) == 1
|
|
assert sum(p.get("cross_down") or 0 for p in result["timeline"]) == 1
|
|
events = result["cross_events"]
|
|
by_name = {event["name"]: event for event in events}
|
|
assert by_name["B题材"]["dir"] == "up" and by_name["B题材"]["time"] == "09:40"
|
|
assert by_name["C题材"]["dir"] == "down" and by_name["C题材"]["time"] == "09:40"
|
|
assert "A题材" not in by_name
|
|
# 同桶内按涨幅降序先 B(转强) 后 C(转弱), 最新在前 → C 事件排最前
|
|
assert events[0]["name"] == "C题材"
|
|
|
|
|
|
def test_sort_by_modes_select_different_dimensions(repo):
|
|
"""自动榜维度切换 (原 fixture 无量额, activity 退化为综合分):
|
|
pct/score/rank_change/flow(不可用退化) 都是 B(+6%, 切入) 在前;
|
|
非法维度 fail-closed。"""
|
|
base = dict(kind="concept", bucket_minutes=5)
|
|
for mode in ("pct", "score", "rank_change", "momentum", "flow"):
|
|
result = sector_rotation.build_sector_rotation(repo, auto_rows=1, sort_by=mode, **base)
|
|
assert result["series"]["sectors"] == ["B题材"], mode
|
|
with pytest.raises(ValueError):
|
|
sector_rotation.build_sector_rotation(repo, sort_by="nope", **base)
|
|
|
|
|
|
def test_momentum_mode_splits_strong_and_weak(tmp_path):
|
|
"""强弱切换维度对半取样: eligible 超过展示行数时, 前半取动量最高 (走强组),
|
|
后半取动量最低 (走弱组), 中部板块跳过 — 热力图同时呈现对比两端。
|
|
动量: B题材 +5.8 > 融资融券 +2.0 > A题材 -0.5 (巨盘概念 -2.0 被成员数上限剔除)。"""
|
|
repo = _write_activity_data(tmp_path)
|
|
# 清空名称过滤 → eligible = [B题材, 融资融券, A题材] 3 > 2 → 对半:
|
|
# 走强第 1 (B题材) + 走弱第 1 (A题材), 动量第 2 的融资融券 (中部) 被跳过
|
|
cleared = sector_rotation.build_sector_rotation(
|
|
repo, kind="concept", bucket_minutes=5,
|
|
exclude_sectors=[], sort_by="momentum", auto_rows=2,
|
|
)
|
|
assert cleared["series"]["sectors"] == ["B题材", "A题材"]
|
|
assert "融资融券" not in cleared["series"]["sectors"]
|
|
# 默认黑名单: eligible = [B题材, A题材] 2 ≤ 2 → 不触发对半, 普通降序
|
|
default = sector_rotation.build_sector_rotation(
|
|
repo, kind="concept", bucket_minutes=5, sort_by="momentum", auto_rows=2,
|
|
)
|
|
assert default["series"]["sectors"] == ["B题材", "A题材"]
|
|
|
|
|
|
def test_sort_by_flow_respects_exclude_and_member_cap(tmp_path):
|
|
"""flow 维度: 融资融券资金流 20000 最大但被默认黑名单拦截;
|
|
清空名称过滤后入榜第一; 巨盘概念仍被成员数上限挡住; activity 维度不受影响。"""
|
|
repo = _write_activity_data(tmp_path)
|
|
flow = sector_rotation.build_sector_rotation(
|
|
repo, kind="concept", bucket_minutes=5,
|
|
flow_field="ext_flow.净流入", sort_by="flow", auto_rows=2,
|
|
)
|
|
assert flow["flow_available"] is True
|
|
assert flow["series"]["sectors"] == ["A题材", "B题材"]
|
|
|
|
cleared = sector_rotation.build_sector_rotation(
|
|
repo, kind="concept", bucket_minutes=5,
|
|
flow_field="ext_flow.净流入", sort_by="flow", exclude_sectors=[], auto_rows=2,
|
|
)
|
|
assert cleared["series"]["sectors"] == ["融资融券", "A题材"]
|
|
|
|
activity = sector_rotation.build_sector_rotation(
|
|
repo, kind="concept", bucket_minutes=5, auto_rows=2,
|
|
)
|
|
assert activity["series"]["sectors"] == ["A题材", "B题材"]
|
|
|
|
|
|
def test_exclude_cache_isolation_and_auto_rows(tmp_path):
|
|
"""缓存键包含排除名单与展示行数: 不同参数互不串数据, 同参数命中缓存。"""
|
|
repo = _write_activity_data(tmp_path)
|
|
default_first = sector_rotation.build_sector_rotation(repo, kind="concept", bucket_minutes=5, auto_rows=2)
|
|
assert default_first["series"]["sectors"] == ["A题材", "B题材"]
|
|
|
|
replaced = sector_rotation.build_sector_rotation(
|
|
repo, kind="concept", bucket_minutes=5, exclude_sectors=["A题材"], auto_rows=1,
|
|
)
|
|
assert replaced["series"]["sectors"] == ["融资融券"]
|
|
|
|
one_row = sector_rotation.build_sector_rotation(repo, kind="concept", bucket_minutes=5, auto_rows=1)
|
|
assert one_row["series"]["sectors"] == ["A题材"]
|
|
|
|
# 回到默认参数: 命中第一次的缓存, 未被后续调用串改
|
|
(repo.store.data_dir / "kline_minute" / f"date={DAY}" / "part.parquet").unlink()
|
|
default_again = sector_rotation.build_sector_rotation(repo, kind="concept", bucket_minutes=5, auto_rows=2)
|
|
assert default_again["series"]["sectors"] == ["A题材", "B题材"]
|
|
|
|
|
|
def test_series_matrix_aligns_with_timeline_and_sectors(repo):
|
|
"""热力图矩阵: 行=热度降序板块 (与 sectors 同序同截断), 列=时间桶 (与 timeline
|
|
同轴), 值=该桶板块涨幅 (列最大值=该桶领涨涨幅), 供前端按分钟轮动展示。"""
|
|
result = sector_rotation.build_sector_rotation(repo, kind="concept", bucket_minutes=5)
|
|
assert result["status"] == "ok"
|
|
series = result["series"]
|
|
timeline = result["timeline"]
|
|
assert series["buckets"] == [point["time"] for point in timeline]
|
|
assert series["sectors"] == [item["name"] for item in result["sectors"]]
|
|
|
|
matrix = dict(zip(series["sectors"], series["matrix"], strict=True))
|
|
# 末桶 (10:35): B +6.0% / A +1.5%, 与 sectors.pct_now 同口径
|
|
last = len(series["buckets"]) - 1
|
|
assert matrix["B题材"][last] == pytest.approx(0.06, abs=1e-4)
|
|
assert matrix["A题材"][last] == pytest.approx(0.015, abs=1e-4)
|
|
# 每列 (跳过无行情 None) 最大值 = 该桶领涨板块涨幅
|
|
for col, point in enumerate(timeline):
|
|
values = [matrix[name][col] for name in series["sectors"] if matrix[name][col] is not None]
|
|
assert values
|
|
assert max(values) == pytest.approx(point["leader_pct"], abs=1e-4)
|
|
|
|
|
|
def test_rank_reference_counts_trading_minutes_across_lunch(tmp_path):
|
|
"""对照窗口按交易时间回看 1 小时, 午休不计: 13:10 的对照桶是 10:40
|
|
(往前 60 个交易分钟), 不是只隔 1 个交易分钟的 11:30。
|
|
A 早盘领涨, 11:00 起 B 反超并保持到午后 → 13:10 相对 1 个交易小时前 B 切入、A 退潮。"""
|
|
_reset_caches()
|
|
data_dir = tmp_path / "data"
|
|
data_dir.mkdir()
|
|
morning = [(h, m) for h in (9, 10, 11) for m in range(0, 60, 5) if (9, 35) <= (h, m) <= (11, 30)]
|
|
stamps = [datetime.fromisoformat(f"{DAY}T{h:02d}:{m:02d}:00") for h, m in [*morning, (13, 5), (13, 10)]]
|
|
rows = []
|
|
for ts in stamps:
|
|
b_ahead = (ts.hour, ts.minute) >= (11, 0)
|
|
for sym in SYMS_A:
|
|
rows.append({"symbol": sym, "datetime": ts, "close": 101.0 if b_ahead else 102.0})
|
|
for sym in SYMS_B:
|
|
rows.append({"symbol": sym, "datetime": ts, "close": 103.0 if b_ahead else 100.5})
|
|
(data_dir / "kline_minute" / f"date={DAY}").mkdir(parents=True)
|
|
pl.DataFrame(rows).write_parquet(data_dir / "kline_minute" / f"date={DAY}" / "part.parquet")
|
|
_write_prev_daily(data_dir)
|
|
_write_concept_ext(data_dir)
|
|
repo = SimpleNamespace(store=SimpleNamespace(data_dir=data_dir))
|
|
|
|
result = sector_rotation.build_sector_rotation(repo, kind="concept", bucket_minutes=5)
|
|
_reset_caches()
|
|
assert result["status"] == "ok"
|
|
assert result["as_of"] == "13:10"
|
|
sectors = {item["name"]: item for item in result["sectors"]}
|
|
a, b = sectors["A题材"], sectors["B题材"]
|
|
# 对照桶 10:40: A +2.0% 领涨, B +0.5%
|
|
assert b["pct_prev"] == pytest.approx(0.005, abs=1e-4)
|
|
assert a["pct_prev"] == pytest.approx(0.02, abs=1e-4)
|
|
assert b["rank_prev"] == 2 and b["rank_change"] == 1
|
|
assert a["rank_prev"] == 1 and a["rank_change"] == -1
|