mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
扩展表此前只有日内多盘一种时序形态; 无时间列的每日快照表 (板块成员/评分等) 无法画趋势。新增序列粒度: - PullConfig.granularity 三态 (auto/intraday/daily, 默认 auto): auto 按时间列 值形态判定 — 含非零 HH:MM:SS 判 intraday, 日期形态/00:00:00/无 time_field 判 daily; 显式声明优先于判定。schema/list 透出 granularity_effective。 - series 端点日级分支: 无需 time_field, 取近 N 日分区按日聚合 (末次非空), 支持标的过滤与 chartDays; 日内分支响应带 granularity 标识。 - 前端拉取配置加「序列粒度」选择 (auto 显示判定结果), 列自定义器按 effective 粒度分流: 日内保持现状, 日级提供近N日柱/线/文本与图宽高配置。 - ExtSparkChart 修全等值退化: min===max 时归一化柱形无意义 (全负值画满格、 全正值画 1px 线), 改回退文本直显。 auto 默认与旧行为完全一致 (向后兼容); 无 time_field 不再 400 而按日级处理。 验证: test_ext_granularity 16 条 + 快照/时间列契约适配; 前端 build + 321 测试。 Signed-off-by: shy3130 <shy3130@qq.com>
195 lines
7.3 KiB
Python
195 lines
7.3 KiB
Python
"""日内序列表 (PullConfig.time_field) 测试: 写入多盘并存 + 默认行为不变。"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
|
|
from app.services.ext_data import (
|
|
PLATE_TS_FIELD,
|
|
ExtConfig,
|
|
ExtField,
|
|
PullConfig,
|
|
collapse_ext_rows_at,
|
|
rows_to_parquet,
|
|
)
|
|
|
|
|
|
def _config(time_field: str | None) -> ExtConfig:
|
|
return ExtConfig(
|
|
id="auction",
|
|
label="集合竞价",
|
|
mode="timeseries",
|
|
fields=[
|
|
ExtField("symbol", "string", "代码"),
|
|
ExtField("ts", "string", "时刻"),
|
|
ExtField("v", "float", "值"),
|
|
],
|
|
pull=PullConfig(url="https://x", time_field=time_field),
|
|
)
|
|
|
|
|
|
def test_pull_config_time_field_roundtrip_and_normalize():
|
|
assert PullConfig().time_field is None # 缺省
|
|
assert PullConfig(time_field=" ").time_field is None # 空白归一 None
|
|
p = PullConfig(time_field="ts")
|
|
assert PullConfig.from_dict(p.to_dict()).time_field == "ts"
|
|
assert PullConfig.from_dict({}).time_field is None # 旧配置文件兼容
|
|
|
|
|
|
def test_rows_to_parquet_time_field_keeps_multiple_disks(tmp_path):
|
|
cfg = _config(time_field="ts")
|
|
day = date(2026, 9, 15)
|
|
first = [
|
|
{"symbol": "000001.SZ", "ts": "09:15:03", "v": 1.0},
|
|
{"symbol": "000001.SZ", "ts": "09:18:00", "v": 2.0},
|
|
]
|
|
assert rows_to_parquet(first, cfg, tmp_path, snapshot_date=day) == 2
|
|
|
|
# 重拉同一日: 同 [symbol, ts] 覆盖 (keep=last), 新盘追加
|
|
second = [
|
|
{"symbol": "000001.SZ", "ts": "09:15:03", "v": 1.5}, # 更新
|
|
{"symbol": "000001.SZ", "ts": "09:21:00", "v": 3.0}, # 新盘
|
|
]
|
|
# 返回值 = 合并去重后的分区总行数 (2 旧 + 2 新 - 1 覆盖 = 3)
|
|
assert rows_to_parquet(second, cfg, tmp_path, snapshot_date=day) == 3
|
|
|
|
import polars as pl
|
|
|
|
out = pl.read_parquet(
|
|
tmp_path / "ext_data" / "auction" / "timeseries"
|
|
/ f"date={day.isoformat()}" / "part.parquet"
|
|
).sort("ts")
|
|
assert out.height == 3
|
|
by_ts = {r["ts"]: r["v"] for r in out.to_dicts()}
|
|
assert by_ts == {"09:15:03": 1.5, "09:18:00": 2.0, "09:21:00": 3.0}
|
|
|
|
|
|
def test_rows_to_parquet_without_time_field_unchanged(tmp_path):
|
|
"""未配置 time_field: 默认按 symbol 去重, 行为与改造前完全一致。"""
|
|
cfg = _config(time_field=None)
|
|
day = date(2026, 9, 15)
|
|
assert rows_to_parquet(
|
|
[{"symbol": "000001.SZ", "ts": "09:15:03", "v": 1.0}], cfg, tmp_path,
|
|
snapshot_date=day,
|
|
) == 1
|
|
assert rows_to_parquet(
|
|
[{"symbol": "000001.SZ", "ts": "09:18:00", "v": 2.0}], cfg, tmp_path,
|
|
snapshot_date=day,
|
|
) == 1
|
|
|
|
import polars as pl
|
|
|
|
out = pl.read_parquet(
|
|
tmp_path / "ext_data" / "auction" / "timeseries"
|
|
/ f"date={day.isoformat()}" / "part.parquet"
|
|
)
|
|
assert out.height == 1
|
|
assert out.row(0, named=True)["v"] == 2.0
|
|
|
|
|
|
# ---------------------------------------------------------------- __pull_ts__ 注入
|
|
def test_plate_ts_field_disables_since_param():
|
|
"""__pull_ts__ 是本地注入的拉取时刻, 源无法按它过滤 → since_param 归一失效。"""
|
|
p = PullConfig(url="https://x", time_field=PLATE_TS_FIELD, since_param="since")
|
|
assert p.time_field == PLATE_TS_FIELD
|
|
assert p.since_param is None
|
|
# 往返不丢 time_field 本身
|
|
assert PullConfig.from_dict(p.to_dict()).time_field == PLATE_TS_FIELD
|
|
|
|
|
|
def test_rows_to_parquet_pull_ts_injection(tmp_path, monkeypatch):
|
|
"""无时间列的每日快照表配 __pull_ts__: 盘中每次拉取各存一盘; 同秒重拉覆盖。"""
|
|
import app.services.ext_data as ext_data_module
|
|
|
|
cfg = _config(time_field=PLATE_TS_FIELD)
|
|
day = date(2026, 9, 15)
|
|
clock = {"now": "2026-09-15T09:30:00+08:00"}
|
|
monkeypatch.setattr(ext_data_module, "_pull_ts_value", lambda: clock["now"])
|
|
|
|
first = [{"symbol": "000001.SZ", "ts": "x", "v": 1.0}]
|
|
assert rows_to_parquet(first, cfg, tmp_path, snapshot_date=day) == 1
|
|
|
|
# 同一秒内重拉: [symbol, __pull_ts__] 同键 → keep=last 覆盖, 不追加盘
|
|
assert rows_to_parquet(
|
|
[{"symbol": "000001.SZ", "ts": "x", "v": 1.5}], cfg, tmp_path, snapshot_date=day
|
|
) == 1
|
|
|
|
# 不同时刻再拉: 新的一盘追加
|
|
clock["now"] = "2026-09-15T14:00:00+08:00"
|
|
assert rows_to_parquet(
|
|
[{"symbol": "000001.SZ", "ts": "x", "v": 2.0}], cfg, tmp_path, snapshot_date=day
|
|
) == 2
|
|
|
|
import polars as pl
|
|
|
|
out = pl.read_parquet(
|
|
tmp_path / "ext_data" / "auction" / "timeseries"
|
|
/ f"date={day.isoformat()}" / "part.parquet"
|
|
).sort(PLATE_TS_FIELD)
|
|
assert out.height == 2
|
|
assert [r["v"] for r in out.to_dicts()] == [1.5, 2.0] # 第一盘被同秒重拉修正为 1.5
|
|
|
|
# collapse 语义与真实时间列一致: @时刻选盘 / 缺省末盘
|
|
at_13 = collapse_ext_rows_at(out, "13:00", PLATE_TS_FIELD)
|
|
assert at_13.height == 1 and at_13.row(0, named=True)["v"] == 1.5
|
|
last = collapse_ext_rows_at(out, None, PLATE_TS_FIELD)
|
|
assert last.height == 1 and last.row(0, named=True)["v"] == 2.0
|
|
|
|
|
|
def test_plate_stats(tmp_path):
|
|
"""盘次概览: 多盘表返回 {date, plates, from, to}; 非多盘表/无分区为 None。"""
|
|
from app.api.ext_data import _plate_stats
|
|
|
|
cfg = _config(time_field="ts")
|
|
day = date(2026, 9, 15)
|
|
rows_to_parquet(
|
|
[
|
|
{"symbol": "000001.SZ", "ts": "2026-09-15T09:15:03+08:00", "v": 1.0},
|
|
{"symbol": "000001.SZ", "ts": "2026-09-15T09:25:05+08:00", "v": 2.0},
|
|
{"symbol": "600519.SH", "ts": "2026-09-15T09:15:03+08:00", "v": 3.0},
|
|
],
|
|
cfg, tmp_path, snapshot_date=day,
|
|
)
|
|
assert _plate_stats(cfg, tmp_path) == {
|
|
"date": "2026-09-15", "plates": 2, "from": "09:15:03", "to": "09:25:05",
|
|
"granularity": "intraday",
|
|
}
|
|
# 未配 time_field 的每日快照表 → None
|
|
assert _plate_stats(_config(time_field=None), tmp_path) is None
|
|
# 多盘表但无任何分区 → None
|
|
assert _plate_stats(_config(time_field="ts"), tmp_path / "empty") is None
|
|
|
|
|
|
def test_list_configs_plate_field(tmp_path):
|
|
"""列表端点为多盘表附带 plate 概览, 非多盘表为 None (源卡片展示契约)。"""
|
|
from types import SimpleNamespace
|
|
|
|
from app.api import ext_data as ext_api
|
|
from app.services.ext_data import ExtConfigStore
|
|
|
|
day = date(2026, 9, 15)
|
|
multi = _config(time_field="ts")
|
|
single = _config(time_field=None)
|
|
single.id = "pop"
|
|
rows_to_parquet(
|
|
[
|
|
{"symbol": "000001.SZ", "ts": "09:15:03", "v": 1.0},
|
|
{"symbol": "000001.SZ", "ts": "09:25:05", "v": 2.0},
|
|
],
|
|
multi, tmp_path, snapshot_date=day,
|
|
)
|
|
rows_to_parquet(
|
|
[{"symbol": "000001.SZ", "ts": "09:25:05", "v": 2.0}],
|
|
single, tmp_path, snapshot_date=day,
|
|
)
|
|
# rows_to_parquet 按传入 config 入库; store 注册两份配置供 load_all
|
|
ExtConfigStore(tmp_path).upsert(multi)
|
|
ExtConfigStore(tmp_path).upsert(single)
|
|
|
|
state = SimpleNamespace(repo=SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path)))
|
|
items = {c["id"]: c for c in ext_api.list_configs(SimpleNamespace(app=SimpleNamespace(state=state)))["items"]}
|
|
assert items["auction"]["plate"] == {
|
|
"date": "2026-09-15", "plates": 2, "from": "09:15:03", "to": "09:25:05",
|
|
"granularity": "intraday",
|
|
}
|
|
assert items["pop"]["plate"] is None
|