mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 02:16:13 +00:00
feat(ext-data): 扩展序列粒度 auto/intraday/daily — 日级近N日序列
扩展表此前只有日内多盘一种时序形态; 无时间列的每日快照表 (板块成员/评分等) 无法画趋势。新增序列粒度: - 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>
This commit is contained in:
+125
-12
@@ -118,6 +118,10 @@ class PullConfigReq(BaseModel):
|
||||
# 增量续传参数名 (如 "since"): 每次拉取自动带上本地目标日分区 time_field 的
|
||||
# 最大值, 源只返回其后数据 (盘中多盘表免全量重拉); 需先配置 time_field
|
||||
since_param: str | None = Field(None, min_length=1, max_length=16, pattern=r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
# 序列粒度: auto=按数据形态自动判断 (时间列值含非零时刻→日内, 否则日级;
|
||||
# 未配 time_field 恒为日级) | intraday=日内多盘 | daily=日级别。
|
||||
# 决定 series 端点口径与列表迷你图形态
|
||||
granularity: Literal["auto", "intraday", "daily"] = "auto"
|
||||
# 鉴权方式; 请求中缺省 (None) = 保留现有配置, {"type":"none"} = 关闭鉴权
|
||||
auth: PullAuthReq | None = None
|
||||
# 单次拉取请求超时 (秒), 默认 30 与历史行为一致; 大响应接口可调高
|
||||
@@ -458,10 +462,41 @@ def _plate_stats(config: ExtConfig, data_dir: Path) -> dict | None:
|
||||
plates=pl.col("_tod").n_unique(),
|
||||
first=pl.col("_tod").min(),
|
||||
last=pl.col("_tod").max(),
|
||||
# 粒度判定 (值形态, 比盘数稳): 存在「含冒号且非 00:00:00」的时刻值
|
||||
# → 日内多盘; 全为日期形态 (无冒号) 或 00:00:00 → 日级别。
|
||||
# 盘后只拉过一两次的日内表不会因此误判 (非零时刻仍在)。
|
||||
intraday=(pl.col("_tod").str.contains(":") & (pl.col("_tod") != "00:00:00")).any(),
|
||||
)
|
||||
.row(0, named=True)
|
||||
)
|
||||
return {"date": parts[-1], "plates": tod["plates"], "from": tod["first"], "to": tod["last"]}
|
||||
return {
|
||||
"date": parts[-1],
|
||||
"plates": tod["plates"],
|
||||
"from": tod["first"],
|
||||
"to": tod["last"],
|
||||
"granularity": "intraday" if tod["intraday"] else "daily",
|
||||
}
|
||||
|
||||
|
||||
def _granularity_effective(config: ExtConfig, data_dir: Path) -> str | None:
|
||||
"""序列粒度生效值: 显式声明 > 数据形态自动判断; snapshot 表 → None。
|
||||
|
||||
判断规则 (auto): 未配 time_field → daily (落盘契约「留空 = 每日快照表」);
|
||||
配了 time_field 但尚无可读分区 → intraday (以配置意图为准, 与历史 UI 行为
|
||||
一致); 有数据 → _plate_stats 的值形态判定。
|
||||
"""
|
||||
if config.mode != "timeseries":
|
||||
return None
|
||||
declared = config.pull.granularity if config.pull else "auto"
|
||||
if declared in ("intraday", "daily"):
|
||||
return declared
|
||||
tf = config.pull.time_field if config.pull else None
|
||||
if not tf:
|
||||
return "daily"
|
||||
stats = _plate_stats(config, data_dir)
|
||||
if stats is None:
|
||||
return "intraday"
|
||||
return stats["granularity"]
|
||||
|
||||
|
||||
@router.get("")
|
||||
@@ -475,6 +510,7 @@ def list_configs(request: Request):
|
||||
d["latest_sync_date"] = _latest_sync_date(c, data_dir)
|
||||
d["date_range"] = _date_range(c, data_dir)
|
||||
d["plate"] = _plate_stats(c, data_dir)
|
||||
d["granularity_effective"] = _granularity_effective(c, data_dir)
|
||||
items.append(d)
|
||||
return {"items": items}
|
||||
|
||||
@@ -1109,6 +1145,7 @@ def configure_pull(request: Request, config_id: str, body: PullConfigReq):
|
||||
date_format=body.date_format,
|
||||
time_field=body.time_field,
|
||||
since_param=body.since_param,
|
||||
granularity=body.granularity,
|
||||
auth=new_auth,
|
||||
timeout_seconds=body.timeout_seconds,
|
||||
page_param=body.page_param,
|
||||
@@ -1292,43 +1329,116 @@ def fix_symbol(request: Request, config_id: str):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _daily_series(
|
||||
config: ExtConfig,
|
||||
data_dir: Path,
|
||||
field: str,
|
||||
date: str | None,
|
||||
symbols: str | None,
|
||||
days: int,
|
||||
) -> dict:
|
||||
"""日级粒度序列: 近 N 个分区, 每标的每日一值 [YYYY-MM-DD, value]。
|
||||
|
||||
分区日期为 x 轴 (无需 time_field); 表自带 date 列时优先用列值 (否则用
|
||||
范围读注入的分区日期)。同日多行 (误配/补写) 取末个非空值 — 日末定型语义。
|
||||
窗口按「实际存在的分区」截取近 N 个, 中途缺日不补点。
|
||||
"""
|
||||
base = _ext_data_dir(config, data_dir)
|
||||
partitions: list[str] = []
|
||||
if base.exists():
|
||||
partitions = sorted(
|
||||
d.name[5:] for d in base.iterdir()
|
||||
if d.is_dir() and d.name.startswith("date=") and (d / "part.parquet").exists()
|
||||
)
|
||||
if date:
|
||||
target = _partition_date(date)
|
||||
partitions = [p for p in partitions if p <= target]
|
||||
empty = {
|
||||
"config_id": config.id, "field": field, "granularity": "daily",
|
||||
"date": partitions[-1] if partitions else None, "items": {},
|
||||
}
|
||||
if not partitions:
|
||||
return empty
|
||||
window = partitions[-days:]
|
||||
df, _ = _read_ext_dataframe(config, data_dir, start_date=window[0], end_date=window[-1])
|
||||
day_col = next((c for c in ("date", "data_date") if c in df.columns), None)
|
||||
if df.is_empty() or field not in df.columns or day_col is None or "symbol" not in df.columns:
|
||||
return empty
|
||||
|
||||
if symbols:
|
||||
wanted = [s.strip() for s in symbols.split(",") if s.strip()]
|
||||
df = df.filter(pl.col("symbol").cast(pl.Utf8).is_in(wanted))
|
||||
|
||||
points = (
|
||||
df.select(
|
||||
pl.col("symbol").cast(pl.Utf8).alias("symbol"),
|
||||
pl.col(day_col).cast(pl.Utf8).alias("d"),
|
||||
pl.col(field).cast(pl.Float64).alias("v"),
|
||||
)
|
||||
.filter(pl.col("v").is_not_null() & pl.col("d").is_not_null())
|
||||
.sort(["symbol", "d"])
|
||||
.group_by(["symbol", "d"], maintain_order=True)
|
||||
.agg(pl.col("v").last())
|
||||
)
|
||||
items: dict[str, list[list[str | float]]] = {}
|
||||
for row in points.iter_rows(named=True):
|
||||
items.setdefault(row["symbol"], []).append([row["d"], float(row["v"])])
|
||||
return {
|
||||
"config_id": config.id,
|
||||
"field": field,
|
||||
"granularity": "daily",
|
||||
"date": f"{window[0]}..{window[-1]}",
|
||||
"days": len(window),
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{config_id}/series")
|
||||
def ext_series(
|
||||
request: Request,
|
||||
config_id: str,
|
||||
field: str = Query(..., min_length=1, description="数值字段名"),
|
||||
date: str | None = Query(None, description="YYYY-MM-DD, 缺省取最新分区"),
|
||||
date: str | None = Query(None, description="YYYY-MM-DD, 缺省取最新分区; 日级粒度时为近N日窗口右端"),
|
||||
symbols: str | None = Query(None, description="逗号分隔标的过滤, 缺省全表"),
|
||||
days: int = Query(10, ge=1, le=60, description="日级粒度的近 N 日窗口"),
|
||||
):
|
||||
"""日内多盘序列 — 每标的一天的 [HH:MM:SS, value] 点列 (列表迷你图列用)。
|
||||
"""扩展列迷你图序列 — 生效粒度 (granularity) 决定口径。
|
||||
|
||||
仅 timeseries 且配置了 time_field 的表有意义 (如集合竞价); 其他表 400。
|
||||
value 为 null 的盘点剔除 (折线/柱状不画空点)。symbols 用于按可见页过滤,
|
||||
控制响应体积; 无该日分区时 items 为空 (fail-closed, 不回退他日序列)。
|
||||
- intraday (日内多盘, 如集合竞价): 每标的一天的 [HH:MM:SS, value] 点列;
|
||||
- daily (日级别, 如主力净流入每日值): 每标的近 N 日、每日一值
|
||||
[YYYY-MM-DD, value] — 分区日期为 x 轴, 无需 time_field; 同日多行取
|
||||
末个非空值 (日末定型语义)。
|
||||
value 为 null 的点剔除 (折线/柱状不画空点)。symbols 用于按可见页过滤,
|
||||
控制响应体积; 无分区时 items 为空 (fail-closed, 不回退其他窗口)。
|
||||
"""
|
||||
config = _store(request).get(config_id)
|
||||
if not config:
|
||||
raise HTTPException(404, f"配置 '{config_id}' 不存在")
|
||||
if config.mode != "timeseries":
|
||||
raise HTTPException(400, "仅时序扩展表支持序列查询")
|
||||
data_dir = _data_dir(request)
|
||||
effective = _granularity_effective(config, data_dir)
|
||||
if effective == "daily":
|
||||
return _daily_series(config, data_dir, field, date, symbols, days)
|
||||
time_field = config.pull.time_field if config.pull else None
|
||||
if config.mode != "timeseries" or not time_field:
|
||||
if not time_field:
|
||||
raise HTTPException(400, "仅配置了时间列 (time_field) 的时序扩展表支持序列查询")
|
||||
|
||||
data_dir = _data_dir(request)
|
||||
if date:
|
||||
day = _partition_date(date)
|
||||
path = data_dir / "ext_data" / config_id / "timeseries" / f"date={day}" / "part.parquet"
|
||||
if not path.exists():
|
||||
return {"config_id": config_id, "field": field, "date": day, "items": {}}
|
||||
return {"config_id": config_id, "field": field, "granularity": "intraday", "date": day, "items": {}}
|
||||
else:
|
||||
df_all, active = _read_ext_dataframe(config, data_dir)
|
||||
day = active
|
||||
if df_all.is_empty():
|
||||
return {"config_id": config_id, "field": field, "date": day, "items": {}}
|
||||
return {"config_id": config_id, "field": field, "granularity": "intraday", "date": day, "items": {}}
|
||||
path = None
|
||||
|
||||
df = pl.read_parquet(path) if path else df_all
|
||||
if df.is_empty() or field not in df.columns or time_field not in df.columns:
|
||||
return {"config_id": config_id, "field": field, "date": day, "items": {}}
|
||||
return {"config_id": config_id, "field": field, "granularity": "intraday", "date": day, "items": {}}
|
||||
|
||||
if symbols:
|
||||
wanted = [s.strip() for s in symbols.split(",") if s.strip()]
|
||||
@@ -1348,7 +1458,7 @@ def ext_series(
|
||||
items: dict[str, list[list[str | float]]] = {}
|
||||
for row in points.select("symbol", "t", "v").iter_rows(named=True):
|
||||
items.setdefault(row["symbol"], []).append([row["t"], float(row["v"])])
|
||||
return {"config_id": config_id, "field": field, "date": day, "items": items}
|
||||
return {"config_id": config_id, "field": field, "granularity": "intraday", "date": day, "items": items}
|
||||
|
||||
|
||||
@router.post("/detect-fields")
|
||||
@@ -1556,6 +1666,9 @@ def discover_all_schemas(request: Request):
|
||||
# 日内多盘表的时间列名 (pull.time_field): 前端据此展示「选盘时刻/迷你图」
|
||||
# 列配置; snapshot 表与未配置时间列的表为 null
|
||||
"time_field": (config.pull.time_field if config.pull else None),
|
||||
# 生效粒度: intraday=日内多盘 (当日逐盘) | daily=日级别 (近N日) |
|
||||
# null=snapshot 表。列表列自定义器据此选择迷你图形态与序列口径
|
||||
"granularity": _granularity_effective(config, data_dir),
|
||||
"columns": columns,
|
||||
})
|
||||
return {"items": result}
|
||||
|
||||
@@ -113,7 +113,7 @@ class PullConfig:
|
||||
"date_format", "time_field", "auth", "timeout_seconds",
|
||||
"page_param", "page_size_param", "page_size", "page_start", "max_pages",
|
||||
"fanout", "schedule_time", "schedule_period", "schedule_day",
|
||||
"schedule_trading_only", "since_param",
|
||||
"schedule_trading_only", "since_param", "granularity",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
@@ -149,6 +149,7 @@ class PullConfig:
|
||||
schedule_day: int | None = None,
|
||||
schedule_trading_only: bool = False,
|
||||
since_param: str | None = None,
|
||||
granularity: str = "auto",
|
||||
) -> None:
|
||||
self.fanout = fanout or None # 两级拉取配方 (见 fanout.py), None=单请求
|
||||
self.url = url
|
||||
@@ -235,6 +236,10 @@ class PullConfig:
|
||||
# 仅交易日: 跳过周六日。法定节假日无法离线判定, 仍会按期尝试 —
|
||||
# 拉取幂等且失败仅记 last_status, 不影响后续调度
|
||||
self.schedule_trading_only = bool(schedule_trading_only)
|
||||
# 序列粒度声明: auto=按数据形态自动判断 (api 层 _granularity_effective) |
|
||||
# intraday=日内多盘 (当日逐盘序列) | daily=日级别 (近 N 日每日一值)。
|
||||
# 决定 series 端点口径与列表迷你图形态; 手改非法值归一 auto (fail-closed)。
|
||||
self.granularity = granularity if granularity in ("auto", "intraday", "daily") else "auto"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -257,6 +262,7 @@ class PullConfig:
|
||||
"date_format": self.date_format,
|
||||
"time_field": self.time_field,
|
||||
"since_param": self.since_param,
|
||||
"granularity": self.granularity,
|
||||
"auth": self.auth,
|
||||
"timeout_seconds": self.timeout_seconds,
|
||||
"page_param": self.page_param,
|
||||
@@ -295,6 +301,7 @@ class PullConfig:
|
||||
date_format=d.get("date_format", "iso"),
|
||||
time_field=d.get("time_field"),
|
||||
since_param=d.get("since_param"),
|
||||
granularity=d.get("granularity", "auto"),
|
||||
auth=d.get("auth"),
|
||||
timeout_seconds=d.get("timeout_seconds", 30),
|
||||
page_param=d.get("page_param"),
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""扩展序列粒度 — 声明/自动判断/生效解析 + series 端点双口径。
|
||||
|
||||
回归背景 (2026-10-27 用户反馈): 主力净流入列设为柱状图后渲染成满柱/塌线 —
|
||||
日内多盘柱状图喂到「同日多行同值」数据时归一化退化。粒度声明让日级表走
|
||||
「近 N 日每日一值」口径, 判断用时间列值形态 (非零时刻→日内) 而非盘数。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import polars as pl
|
||||
import pytest
|
||||
|
||||
from app.api import ext_data as ext_api
|
||||
from app.services.ext_data import ExtConfig, ExtConfigStore, ExtField, PullConfig
|
||||
|
||||
|
||||
def _config(
|
||||
time_field: str | None = None,
|
||||
granularity: str = "auto",
|
||||
mode: str = "timeseries",
|
||||
) -> ExtConfig:
|
||||
return ExtConfig(
|
||||
id="flow", label="资金流", mode=mode, # type: ignore[arg-type]
|
||||
fields=[
|
||||
ExtField("symbol", "string", "标的代码"),
|
||||
ExtField("net", "float", "主力净流入"),
|
||||
ExtField("ts", "string", "时刻"),
|
||||
],
|
||||
pull=PullConfig(url="https://example.test/flow", time_field=time_field, granularity=granularity),
|
||||
)
|
||||
|
||||
|
||||
def _write_part(tmp_path: Path, config_id: str, day: str, df: pl.DataFrame) -> None:
|
||||
part = tmp_path / "ext_data" / config_id / "timeseries" / f"date={day}"
|
||||
part.mkdir(parents=True, exist_ok=True)
|
||||
df.write_parquet(part / "part.parquet")
|
||||
|
||||
|
||||
def _request(tmp_path: Path) -> SimpleNamespace:
|
||||
state = SimpleNamespace(repo=SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path)))
|
||||
return SimpleNamespace(app=SimpleNamespace(state=state))
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def store(tmp_path, monkeypatch):
|
||||
"""挂好 _store/_data_dir 的请求工厂; 配置由用例自行 upsert。"""
|
||||
monkeypatch.setattr(ext_api, "_store", lambda request: ExtConfigStore(tmp_path))
|
||||
monkeypatch.setattr(ext_api, "_data_dir", lambda request: tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
# ---------- PullConfig.granularity ----------
|
||||
|
||||
def test_pull_config_granularity_roundtrip_and_normalize():
|
||||
for ok in ("auto", "intraday", "daily"):
|
||||
assert PullConfig(granularity=ok).granularity == ok
|
||||
assert PullConfig(granularity="bogus").granularity == "auto"
|
||||
assert PullConfig().granularity == "auto"
|
||||
d = PullConfig(granularity="daily").to_dict()
|
||||
assert d["granularity"] == "daily"
|
||||
back = PullConfig.from_dict({"url": "x", "granularity": "intraday"})
|
||||
assert back.granularity == "intraday"
|
||||
# 老配置无该键 → auto (无感兼容)
|
||||
assert PullConfig.from_dict({"url": "x"}).granularity == "auto"
|
||||
|
||||
|
||||
# ---------- _granularity_effective: 判断表 ----------
|
||||
|
||||
def test_effective_snapshot_mode_is_none(store):
|
||||
assert ext_api._granularity_effective(_config(mode="snapshot"), store) is None
|
||||
|
||||
|
||||
def test_effective_no_time_field_is_daily(store):
|
||||
# 落盘契约: 未配 time_field = 每日快照表 (按 symbol 去重) → 日级
|
||||
_write_part(store, "flow", "2026-10-23", pl.DataFrame({
|
||||
"symbol": ["600000.SH"], "net": [1.5], "ts": ["09:25:00"],
|
||||
}))
|
||||
assert ext_api._granularity_effective(_config(), store) == "daily"
|
||||
|
||||
|
||||
def test_effective_time_field_no_data_defaults_intraday(store):
|
||||
# 配了时间列但尚无分区: 以配置意图为准 (与历史 UI gating 行为一致)
|
||||
assert ext_api._granularity_effective(_config(time_field="ts"), store) == "intraday"
|
||||
|
||||
|
||||
def test_effective_nonzero_times_is_intraday(store):
|
||||
_write_part(store, "flow", "2026-10-23", pl.DataFrame({
|
||||
"symbol": ["600000.SH"] * 3,
|
||||
"net": [-100.0, -100.0, -100.0],
|
||||
"ts": ["2026-10-23 09:20:00", "09:25:00", "2026-10-23 09:30:00"],
|
||||
}))
|
||||
assert ext_api._granularity_effective(_config(time_field="ts"), store) == "intraday"
|
||||
|
||||
|
||||
def test_effective_date_form_or_midnight_times_is_daily(store):
|
||||
# 时间列配了日期形态值或 00:00:00 → 日级 (把日期列配成时间列的典型错配被纠正)
|
||||
_write_part(store, "flow", "2026-10-23", pl.DataFrame({
|
||||
"symbol": ["600000.SH"] * 2,
|
||||
"net": [1.0, 2.0],
|
||||
"ts": ["2026-10-23 00:00:00", "2026-10-22"],
|
||||
}))
|
||||
assert ext_api._granularity_effective(_config(time_field="ts"), store) == "daily"
|
||||
|
||||
|
||||
def test_effective_declared_overrides_detection(store):
|
||||
# 数据形态是日内, 但显式声明日级 → 日级 (用户可钉死)
|
||||
_write_part(store, "flow", "2026-10-23", pl.DataFrame({
|
||||
"symbol": ["600000.SH"], "net": [1.0], "ts": ["09:25:00"],
|
||||
}))
|
||||
assert ext_api._granularity_effective(_config(time_field="ts", granularity="daily"), store) == "daily"
|
||||
# 无时间列但显式声明日内 → 日内 (强意图)
|
||||
assert ext_api._granularity_effective(_config(granularity="intraday"), store) == "intraday"
|
||||
|
||||
|
||||
# ---------- series 端点: 日级口径 ----------
|
||||
|
||||
def test_series_daily_near_n_days(store):
|
||||
ExtConfigStore(store).upsert(_config()) # 无 time_field → auto=daily
|
||||
for day, v in [("2026-10-21", 1.0), ("2026-10-22", 2.0), ("2026-10-23", 3.0)]:
|
||||
_write_part(store, "flow", day, pl.DataFrame({
|
||||
"symbol": ["600000.SH", "000001.SZ"], "net": [v, v * 10], "ts": [None, None],
|
||||
}))
|
||||
res = ext_api.ext_series(_request(store), "flow", field="net", days=2, date=None, symbols=None)
|
||||
assert res["granularity"] == "daily"
|
||||
assert res["date"] == "2026-10-22..2026-10-23"
|
||||
# 近 2 日 = 10-22/10-23, 不含 10-21
|
||||
assert res["items"]["600000.SH"] == [["2026-10-22", 2.0], ["2026-10-23", 3.0]]
|
||||
assert res["items"]["000001.SZ"] == [["2026-10-22", 20.0], ["2026-10-23", 30.0]]
|
||||
|
||||
|
||||
def test_series_daily_same_day_multi_row_takes_last_non_null(store):
|
||||
ExtConfigStore(store).upsert(_config())
|
||||
_write_part(store, "flow", "2026-10-23", pl.DataFrame({
|
||||
"symbol": ["600000.SH"] * 3, "net": [1.0, None, 7.0], "ts": [None] * 3,
|
||||
}))
|
||||
res = ext_api.ext_series(_request(store), "flow", field="net", days=10, date=None, symbols=None)
|
||||
# 同日多行 → 末个非空值 (日末定型语义), 每日只有一点
|
||||
assert res["items"]["600000.SH"] == [["2026-10-23", 7.0]]
|
||||
|
||||
|
||||
def test_series_daily_date_param_is_window_right_edge(store):
|
||||
ExtConfigStore(store).upsert(_config())
|
||||
for day in ("2026-10-21", "2026-10-22", "2026-10-23"):
|
||||
_write_part(store, "flow", day, pl.DataFrame({
|
||||
"symbol": ["600000.SH"], "net": [1.0], "ts": [None],
|
||||
}))
|
||||
res = ext_api.ext_series(_request(store), "flow", field="net", days=10, date="2026-10-22", symbols=None)
|
||||
assert res["date"] == "2026-10-21..2026-10-22"
|
||||
assert [p[0] for p in res["items"]["600000.SH"]] == ["2026-10-21", "2026-10-22"]
|
||||
|
||||
|
||||
def test_series_daily_symbols_filter_and_empty(store):
|
||||
ExtConfigStore(store).upsert(_config())
|
||||
_write_part(store, "flow", "2026-10-23", pl.DataFrame({
|
||||
"symbol": ["600000.SH", "000001.SZ"], "net": [1.0, 2.0], "ts": [None, None],
|
||||
}))
|
||||
res = ext_api.ext_series(_request(store), "flow", field="net", days=10, date=None, symbols="600000.SH")
|
||||
assert set(res["items"]) == {"600000.SH"}
|
||||
# 无分区 → items 空 (fail-closed)
|
||||
empty = ext_api.ext_series(_request(store), "flow", field="net", days=10, date="2020-01-01", symbols=None)
|
||||
assert empty["items"] == {}
|
||||
|
||||
|
||||
def test_series_declared_daily_without_time_field_ok(store):
|
||||
# 显式声明日级 + 无时间列: 走近 N 日口径, 不再 400
|
||||
ExtConfigStore(store).upsert(_config(granularity="daily"))
|
||||
_write_part(store, "flow", "2026-10-23", pl.DataFrame({
|
||||
"symbol": ["600000.SH"], "net": [5.0], "ts": [None],
|
||||
}))
|
||||
res = ext_api.ext_series(_request(store), "flow", field="net", days=10, date=None, symbols=None)
|
||||
assert res["items"]["600000.SH"] == [["2026-10-23", 5.0]]
|
||||
|
||||
|
||||
def test_series_declared_intraday_without_time_field_400(store):
|
||||
ExtConfigStore(store).upsert(_config(granularity="intraday"))
|
||||
from fastapi import HTTPException
|
||||
with pytest.raises(HTTPException) as e:
|
||||
ext_api.ext_series(_request(store), "flow", field="net", days=10, date=None, symbols=None)
|
||||
assert "time_field" in str(e.value.detail)
|
||||
|
||||
|
||||
# ---------- series 端点: 日内口径不回归 ----------
|
||||
|
||||
def test_series_intraday_unchanged(store):
|
||||
ExtConfigStore(store).upsert(_config(time_field="ts"))
|
||||
_write_part(store, "flow", "2026-10-23", pl.DataFrame({
|
||||
"symbol": ["600000.SH", "600000.SH", "000001.SZ"],
|
||||
"net": [1.0, 2.0, 3.0],
|
||||
"ts": ["09:20:00", "09:25:00", "09:20:00"],
|
||||
}))
|
||||
res = ext_api.ext_series(
|
||||
_request(store), "flow", field="net", days=10, date="2026-10-23", symbols="600000.SH",
|
||||
)
|
||||
assert res["granularity"] != "daily"
|
||||
assert res["items"]["600000.SH"] == [["09:20:00", 1.0], ["09:25:00", 2.0]]
|
||||
|
||||
|
||||
# ---------- schema / list 透出 ----------
|
||||
|
||||
def test_schema_all_exposes_granularity(store):
|
||||
ExtConfigStore(store).upsert(_config(time_field="ts"))
|
||||
_write_part(store, "flow", "2026-10-23", pl.DataFrame({
|
||||
"symbol": ["600000.SH"], "net": [1.0], "ts": ["09:25:00"],
|
||||
}))
|
||||
res = ext_api.discover_all_schemas(_request(store))
|
||||
item = next(i for i in res["items"] if i["id"] == "flow")
|
||||
assert item["granularity"] == "intraday"
|
||||
|
||||
|
||||
def test_list_configs_exposes_granularity_effective(store):
|
||||
ExtConfigStore(store).upsert(_config())
|
||||
res = ext_api.list_configs(_request(store))
|
||||
item = next(i for i in res["items"] if i["id"] == "flow")
|
||||
assert item["granularity_effective"] == "daily"
|
||||
@@ -140,8 +140,8 @@ async def test_series_endpoint_shape_and_filters(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
def test_series_rejects_table_without_time_field(tmp_path, monkeypatch):
|
||||
from fastapi import HTTPException
|
||||
|
||||
"""契约变更 (粒度声明): 无 time_field 的时序表不再 400 — auto 判为日级,
|
||||
series 走近 N 日口径 (每日一值, 无分区时 items 为空)。"""
|
||||
from app.api import ext_data as ext_api
|
||||
|
||||
class _Req:
|
||||
@@ -151,9 +151,9 @@ def test_series_rejects_table_without_time_field(tmp_path, monkeypatch):
|
||||
"S", (), {"get": lambda self, cid: _auction_config(time_field=None), "data_dir": tmp_path})())
|
||||
monkeypatch.setattr(ext_api, "_data_dir", lambda request: tmp_path)
|
||||
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
ext_api.ext_series(_Req(), "ext_auc", field="auction_price")
|
||||
assert ei.value.status_code == 400
|
||||
res = ext_api.ext_series(_Req(), "ext_auc", field="auction_price", date=None)
|
||||
assert res["granularity"] == "daily"
|
||||
assert res["items"] == {}
|
||||
|
||||
|
||||
def test_schema_all_exposes_time_field(tmp_path):
|
||||
|
||||
@@ -151,6 +151,7 @@ def test_plate_stats(tmp_path):
|
||||
)
|
||||
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
|
||||
@@ -188,5 +189,6 @@ def test_list_configs_plate_field(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
|
||||
|
||||
@@ -227,6 +227,10 @@ timeseries 模式的表 (如人气排行) 默认只能从开启拉取之日起
|
||||
|
||||
扩展数据页源卡片为多盘表显示「多盘」徽章与最新分区盘次概览 (盘数 · 时刻范围); 列表扩展列可按 折线/柱状/文字@时刻 三种形态展示盘次序列 (见选股列配置)。
|
||||
|
||||
##### 序列粒度 (日内多盘 / 日级别)
|
||||
|
||||
列表迷你图列与 `/api/ext-data/{id}/series` 按生效粒度取数: **日内多盘** (`intraday` — 每标的当日逐盘 `[HH:MM:SS, 值]` 点列) | **日级别** (`daily` — 每标的近 N 日每日一值 `[YYYY-MM-DD, 值]`, 同日多行取末个非空值; 分区日期即 x 轴, 无需时间字段)。拉取配置可显式声明 (`granularity`: `auto`/`intraday`/`daily`, 默认 `auto`); `auto` 按时间列数据形态判断 —— 存在非零时刻 → 日内多盘, 日期形态 / `00:00:00` / 未配时间字段 → 日级别, 判断结果显示在拉取设置的粒度下拉里, 判错可手动钉死。`schema-all` 与配置列表透出 `granularity_effective` 供前端分流选形态 (日内: 分时折线/逐盘柱状/文字@时刻; 日级: 近 N 日折线/柱状/最新值文字)。全等值序列 (同值多行) 的迷你图回退为数值文本, 不画伪趋势。
|
||||
|
||||
##### 分页拉取协议
|
||||
|
||||
数据量大的接口 (全市场排行、批量快照) 单次响应装不下时, 拉取配置可声明分页 (仅 GET): **页码参数名**(`page_param`, 如 `page`)、**每页条数参数名**(`page_size_param`, 如 `pageSize`)与**每页条数**(`page_size`)。配置后单次拉取按页循环请求并合并结果, 空页/不足一页自动判停; **最多页数**(`max_pages`, 默认 20, 上限 200)防止接口永远返回数据拖死循环。POST body 分页不支持 (请求体是固定模板)。
|
||||
|
||||
@@ -274,8 +274,19 @@ export function ListColumnCustomizerInner({
|
||||
})
|
||||
const backdrop = useDialogBackdrop(onClose)
|
||||
|
||||
// 多盘表识别用 ref 镜像 (schema 查询数据), 让 addExtColumn 保持稳定引用
|
||||
const extTablesRef = useRef<readonly { id: string; time_field?: string | null }[]>([])
|
||||
/** 时序扩展表的生效粒度: schema 透出值优先; 旧后端无该字段时退回 time_field 判定。
|
||||
* snapshot 表 (无历史分区) 与未知表 → null, 不提供序列形态配置。 */
|
||||
function extGranularityOf(
|
||||
tables: readonly { id: string; mode?: string; time_field?: string | null; granularity?: 'intraday' | 'daily' | null }[],
|
||||
configId: string,
|
||||
): 'intraday' | 'daily' | null {
|
||||
const t = tables.find(x => x.id === configId)
|
||||
if (!t || t.mode !== 'timeseries') return null
|
||||
return t.granularity ?? (t.time_field ? 'intraday' : 'daily')
|
||||
}
|
||||
|
||||
// 多盘表识别用 ref 镜像 (schema 查询数据), 让 addExtColumn 保持稳定引用
|
||||
const extTablesRef = useRef<readonly { id: string; time_field?: string | null; granularity?: 'intraday' | 'daily' | null }[]>([])
|
||||
extTablesRef.current = extSchema.data?.items ?? []
|
||||
|
||||
// columns/onChange 用 ref 镜像,让下方多数 useCallback 空依赖(addExtColumn 例外,
|
||||
@@ -347,10 +358,11 @@ export function ListColumnCustomizerInner({
|
||||
toggleVisible(colId)
|
||||
return
|
||||
}
|
||||
// 日内多盘表 (配置了时间列, 如集合竞价) 的数值列: 添加即默认分时式迷你图
|
||||
// (与「分时」列同尺寸 150×80), 文字形态在列配置里按需切换
|
||||
// 日内多盘表 (生效粒度 intraday, 如集合竞价) 的数值列: 添加即默认分时式迷你图
|
||||
// (与「分时」列同尺寸 150×80), 文字形态在列配置里按需切换; 日级表默认文字值
|
||||
const multiSnapshot = !!extTablesRef.current.find(
|
||||
t => t.id === configId && (t as { time_field?: string | null }).time_field,
|
||||
t => t.id === configId
|
||||
&& (t.granularity === 'intraday' || (t.granularity == null && t.time_field)),
|
||||
)
|
||||
const newCol: ColumnConfig = {
|
||||
id: colId,
|
||||
@@ -621,8 +633,8 @@ export function ListColumnCustomizerInner({
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
{/* 日内多盘表 (配置了时间列, 如集合竞价): 选盘时刻 + 展示形态 */}
|
||||
{col.source.type === 'ext' && (extTables.find(t => t.id === (col.source as { configId: string }).configId) as { time_field?: string | null } | undefined)?.time_field != null && (
|
||||
{/* 时序表的序列形态: 日内多盘 (集合竞价等) / 日级别 (近N日) 按生效粒度分流 */}
|
||||
{col.source.type === 'ext' && extGranularityOf(extTables, (col.source as { configId: string }).configId) === 'intraday' && (
|
||||
<>
|
||||
<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">
|
||||
@@ -674,6 +686,55 @@ export function ListColumnCustomizerInner({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{col.source.type === 'ext' && extGranularityOf(extTables, (col.source as { configId: string }).configId) === 'daily' && (
|
||||
<>
|
||||
<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>
|
||||
<select
|
||||
value={col.extDisplay?.snapshotRender ?? 'text'}
|
||||
onChange={e => updateExtDisplay(col.id, { snapshotRender: e.target.value as 'text' | 'bar' | 'line' })}
|
||||
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="bar">柱状图 (近N日每日值)</option>
|
||||
<option value="line">折线图 (近N日)</option>
|
||||
<option value="text">文字 (最新值)</option>
|
||||
</select>
|
||||
</label>
|
||||
{(col.extDisplay?.snapshotRender ?? 'text') !== 'text' && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<label className="flex items-center gap-1">
|
||||
<span className="text-secondary shrink-0">天数</span>
|
||||
<RangeNumberInput
|
||||
value={col.extDisplay?.chartDays ?? 10}
|
||||
min={3} max={60} step={1}
|
||||
onCommit={v => updateExtDisplay(col.id, { chartDays: v })}
|
||||
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"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-1">
|
||||
<span className="text-secondary shrink-0">宽</span>
|
||||
<RangeNumberInput
|
||||
value={col.extDisplay?.chartWidth ?? 150}
|
||||
min={60} max={300} step={10}
|
||||
onCommit={v => updateExtDisplay(col.id, { chartWidth: v })}
|
||||
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"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-1">
|
||||
<span className="text-secondary shrink-0">高</span>
|
||||
<RangeNumberInput
|
||||
value={col.extDisplay?.chartHeight ?? 80}
|
||||
min={24} max={160} step={4}
|
||||
onCommit={v => updateExtDisplay(col.id, { chartHeight: v })}
|
||||
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"
|
||||
/>
|
||||
</label>
|
||||
<span className="text-[10px] text-muted">px · 近N日全序列</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* 数字格式化配置: 千分位 + 单位换算 + 小数位(仅 number 类型字段) */}
|
||||
{col.source.type === 'ext' && isNumericFieldType(col.source.fieldType) && (
|
||||
<>
|
||||
|
||||
@@ -83,6 +83,7 @@ export function ExtDataPullPanel({ config, onSaved }: {
|
||||
date_format: pull.date_format,
|
||||
time_field: pull.time_field ?? null,
|
||||
since_param: pull.since_param ?? null,
|
||||
granularity: pull.granularity ?? 'auto',
|
||||
timeout_seconds: pull.timeout_seconds,
|
||||
page_param: pull.page_param ?? null,
|
||||
page_size_param: pull.page_size_param ?? null,
|
||||
@@ -268,6 +269,8 @@ function ExtPullConfigDialog({ config, onSaved, onClose }: {
|
||||
const [dateParam, setDateParam] = useState(pull?.date_param ?? '')
|
||||
const [dateFormat, setDateFormat] = useState(pull?.date_format ?? 'iso')
|
||||
const [timeField, setTimeField] = useState(pull?.time_field ?? '')
|
||||
// 序列粒度: auto=按数据形态判断 | intraday=日内多盘 | daily=日级别 (钉死判断)
|
||||
const [granularity, setGranularity] = useState<'auto' | 'intraday' | 'daily'>(pull?.granularity ?? 'auto')
|
||||
const [sinceParam, setSinceParam] = useState(pull?.since_param ?? '')
|
||||
const [timeoutSec, setTimeoutSec] = useState(pull?.timeout_seconds ?? 30)
|
||||
// 分页协议 (仅 GET): 页码参数名配置后按页循环拉取
|
||||
@@ -345,6 +348,7 @@ function ExtPullConfigDialog({ config, onSaved, onClose }: {
|
||||
date_format: dateFormat,
|
||||
time_field: timeField.trim() || null,
|
||||
since_param: sinceParam.trim() || null,
|
||||
granularity,
|
||||
timeout_seconds: effTimeoutSec,
|
||||
page_param: pageParam.trim() || null,
|
||||
page_size_param: pageSizeParam.trim() || null,
|
||||
@@ -811,6 +815,31 @@ function ExtPullConfigDialog({ config, onSaved, onClose }: {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] font-medium text-secondary mb-1.5">
|
||||
序列粒度
|
||||
<span className="ml-1.5 text-[10px] font-normal text-muted">决定列表迷你图口径: 当日逐盘 / 近N日每日一值</span>
|
||||
</div>
|
||||
<select
|
||||
aria-label="序列粒度"
|
||||
value={granularity} onChange={e => setGranularity(e.target.value as 'auto' | 'intraday' | 'daily')}
|
||||
title="列表迷你图列与序列接口按此口径取数; 自动判断依据时间列数据形态"
|
||||
className="h-9 w-full rounded-lg border border-border bg-base px-3 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-accent/25 focus:border-accent/50"
|
||||
>
|
||||
<option value="auto">
|
||||
{`自动判断${config.granularity_effective
|
||||
? `(当前: ${config.granularity_effective === 'intraday' ? '日内多盘' : '日级别'}${config.granularity_effective === 'intraday' && config.plate ? ` · ${config.plate.plates} 盘` : ''})`
|
||||
: '(暂无数据)'}`}
|
||||
</option>
|
||||
<option value="intraday">日内多盘(当日逐盘序列)</option>
|
||||
<option value="daily">日级别(近 N 日每日一值)</option>
|
||||
</select>
|
||||
{granularity === 'auto' && (
|
||||
<div className="mt-1 text-[10px] text-muted/70">
|
||||
按时间列数据形态判断: 存在非零时刻 → 日内多盘; 日期形态 / 00:00:00 / 未配时间字段 → 日级别
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] font-medium text-secondary mb-1.5">
|
||||
字段映射(外部名 → 内部名,JSON,可选)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, expect, it } from 'vitest'
|
||||
import { ExtSparkChart } from './ExtSparkChart'
|
||||
|
||||
// 回归 (2026-10-27 用户反馈): 主力净流入列 (日内多盘柱状图) 渲染成
|
||||
// 「10 根等高满柱 / 塌成基线虚线」 — 同日多行同值时 min-max 归一化分母退化:
|
||||
// 全等负值走 min<0 分支画满柱, 全等正值柱高 max(1,0)=1px。
|
||||
// 契约: 全等值序列必须回退数值文本, 不画伪趋势。
|
||||
|
||||
let root: Root | null = null
|
||||
let host: HTMLElement
|
||||
|
||||
function render(props: Parameters<typeof ExtSparkChart>[0]) {
|
||||
host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
root = createRoot(host)
|
||||
act(() => { root!.render(<ExtSparkChart {...props} />) })
|
||||
}
|
||||
|
||||
beforeEach(() => { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) })
|
||||
afterEach(() => {
|
||||
if (root) act(() => { root!.unmount() })
|
||||
host?.remove()
|
||||
root = null
|
||||
})
|
||||
|
||||
it('全等负值序列回退数值文本, 不再渲染满柱', () => {
|
||||
const points = Array.from({ length: 10 }, (_, i) => [`09:${i}0:00`, -32580312] as [string, number])
|
||||
render({ points, kind: 'bar' })
|
||||
expect(host.querySelector('svg')).toBeNull()
|
||||
expect(host.textContent).toContain('-32580312')
|
||||
})
|
||||
|
||||
it('全等正值序列回退数值文本, 不再渲染塌线', () => {
|
||||
const points: [string, number][] = [['09:20:00', 5], ['09:25:00', 5], ['09:30:00', 5]]
|
||||
render({ points, kind: 'bar' })
|
||||
expect(host.querySelector('svg')).toBeNull()
|
||||
expect(host.textContent).toContain('5')
|
||||
})
|
||||
|
||||
it('单点回退数值, 空序列显示占位符', () => {
|
||||
render({ points: [['09:25:00', 1.5]], kind: 'line' })
|
||||
expect(host.textContent).toContain('1.5')
|
||||
render({ points: [], kind: 'line' })
|
||||
expect(host.textContent).toContain('—')
|
||||
})
|
||||
|
||||
it('值有变化的柱状图正常渲染且柱高不等 (归一化保留差异)', () => {
|
||||
render({ points: [['d1', 10], ['d2', 20], ['d3', 30]], kind: 'bar', width: 120, height: 40 })
|
||||
const rects = [...host.querySelectorAll('svg rect')]
|
||||
expect(rects.length).toBe(3)
|
||||
const heights = rects.map(r => Number(r.getAttribute('height')))
|
||||
// 逐值差异必须映射为柱高差异 — 退化 (全等高) 即回归
|
||||
expect(new Set(heights).size).toBe(3)
|
||||
})
|
||||
|
||||
it('跨零柱状图: 正负值都以 0 轴为基线, 最后一根高亮', () => {
|
||||
render({ points: [['d1', -5], ['d2', 3], ['d3', 0]], kind: 'bar', width: 120, height: 40 })
|
||||
const rects = [...host.querySelectorAll('svg rect')]
|
||||
expect(rects.length).toBe(3)
|
||||
expect(rects[2].getAttribute('opacity')).toBe('1')
|
||||
expect(rects[0].getAttribute('opacity')).toBe('0.55')
|
||||
})
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* 日内多盘扩展列的迷你图单元格 (集合竞价快照序列等)。
|
||||
* 日内多盘/日级扩展列的迷你图单元格 (集合竞价快照序列、近N日每日值等)。
|
||||
*
|
||||
* 纯 SVG 实现, 不引第三方图表库: 点列归一化到单元格宽高,
|
||||
* line=折线 (价格轨迹), bar=柱状 (量/额等逐盘幅度)。
|
||||
* 单点/全空序列回退为文本占位, 不伪造趋势。
|
||||
* line=折线 (价格轨迹), bar=柱状 (量/额等逐盘/逐日幅度)。
|
||||
* 单点/全空/全等值序列回退为文本占位 — 全等值时柱状归一化分母退化,
|
||||
* 负值会渲染成满柱、正值塌成基线虚线, 都是伪趋势, 不如直接显示数值。
|
||||
*/
|
||||
import { useId } from 'react'
|
||||
|
||||
@@ -17,12 +18,12 @@ export function ExtSparkChart({ points, kind, width = 110, height = 36 }: {
|
||||
}) {
|
||||
const gradId = useId()
|
||||
const values = points.map(p => p[1]).filter(Number.isFinite)
|
||||
if (values.length < 2) {
|
||||
return <span className="text-[10px] text-muted tabular-nums">{values.length === 1 ? values[0] : '—'}</span>
|
||||
const min = values.length ? Math.min(...values) : 0
|
||||
const max = values.length ? Math.max(...values) : 0
|
||||
// 全等值 (含单点/全空): 数值无变化可画, 回退文本
|
||||
if (values.length < 2 || min === max) {
|
||||
return <span className="text-[10px] text-muted tabular-nums">{values.length >= 1 ? values[0] : '—'}</span>
|
||||
}
|
||||
|
||||
const min = Math.min(...values)
|
||||
const max = Math.max(...values)
|
||||
const span = max - min || Math.abs(max) || 1
|
||||
const pad = 2
|
||||
const h = height - pad * 2
|
||||
|
||||
+11
-5
@@ -3995,14 +3995,15 @@ export const api = {
|
||||
),
|
||||
|
||||
extDataSchemaAll: () =>
|
||||
request<{ items: { id: string; label: string; mode: string; time_field?: string | null; columns: { name: string; type: string; label: string }[] }[] }>('/api/ext-data/schema-all'),
|
||||
request<{ items: { id: string; label: string; mode: string; time_field?: string | null; granularity?: 'intraday' | 'daily' | null; columns: { name: string; type: string; label: string }[] }[] }>('/api/ext-data/schema-all'),
|
||||
|
||||
/** 日内多盘序列 (列表迷你图列): 每标的一天的 [HH:MM:SS, value] 点列 */
|
||||
extDataSeries: (configId: string, field: string, date?: string | null, symbols?: string[]) =>
|
||||
request<{ config_id: string; field: string; date: string | null; items: Record<string, [string, number][]> }>(
|
||||
extDataSeries: (configId: string, field: string, date?: string | null, symbols?: string[], days?: number) =>
|
||||
request<{ config_id: string; field: string; date: string | null; granularity?: 'intraday' | 'daily'; days?: number; items: Record<string, [string, number][]> }>(
|
||||
`/api/ext-data/${configId}/series?field=${encodeURIComponent(field)}`
|
||||
+ (date ? `&date=${date}` : '')
|
||||
+ (symbols?.length ? `&symbols=${encodeURIComponent(symbols.join(','))}` : ''),
|
||||
+ (symbols?.length ? `&symbols=${encodeURIComponent(symbols.join(','))}` : '')
|
||||
+ (days ? `&days=${days}` : ''),
|
||||
),
|
||||
|
||||
extDataPullConfig: (id: string, body: {
|
||||
@@ -4017,6 +4018,7 @@ export const api = {
|
||||
date_param?: string | null;
|
||||
time_field?: string | null;
|
||||
since_param?: string | null;
|
||||
granularity?: 'auto' | 'intraday' | 'daily';
|
||||
auth?: ExtPullAuth;
|
||||
timeout_seconds?: number;
|
||||
page_param?: string | null;
|
||||
@@ -4979,6 +4981,8 @@ export interface PullConfig {
|
||||
date_format?: string
|
||||
/** 日内序列表时间列名 (如 "ts"): 配置后同 symbol 允许多行 (按 symbol+时间列去重), 用于集合竞价等多盘数据 */
|
||||
time_field?: string | null
|
||||
/** 序列粒度: auto=按数据形态自动判断 | intraday=日内多盘 | daily=日级别 (决定 series 口径与列表迷你图形态) */
|
||||
granularity?: 'auto' | 'intraday' | 'daily'
|
||||
/** 增量续传参数名 (如 "since"): 每次拉取自动带上本地目标日分区时间列最大值, 源只返回其后数据; 需先配置 time_field */
|
||||
since_param?: string | null
|
||||
auth?: ExtPullAuth | null
|
||||
@@ -5074,7 +5078,9 @@ export interface ExtDataConfig {
|
||||
latest_sync_date?: string | null
|
||||
date_range?: string[] | null
|
||||
/** 日内多盘表最新分区的盘次概览; 非多盘表为 null */
|
||||
plate?: { date: string; plates: number; from: string; to: string } | null
|
||||
plate?: { date: string; plates: number; from: string; to: string; granularity: 'intraday' | 'daily' } | null
|
||||
/** 生效序列粒度 (显式声明 > 数据形态判断): intraday | daily; snapshot 表为 null */
|
||||
granularity_effective?: 'intraday' | 'daily' | null
|
||||
pull?: PullConfig | null
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ export interface ExtColumnDisplayConfig {
|
||||
chartWidth?: number
|
||||
/** 迷你图高度 px (仅 bar/line), 默认 36 */
|
||||
chartHeight?: number
|
||||
/** 日级粒度 (近N日) 迷你图的窗口天数, 默认 10 */
|
||||
chartDays?: number
|
||||
}
|
||||
|
||||
/** 日k列渲染配置(builtin: candle 列专用) */
|
||||
|
||||
@@ -657,7 +657,7 @@ export function Screener() {
|
||||
[columns],
|
||||
)
|
||||
const extChartColsKey = extChartColumns
|
||||
.map(c => `${(c.source as { configId: string }).configId}.${(c.source as { fieldName: string }).fieldName}`)
|
||||
.map(c => `${(c.source as { configId: string }).configId}.${(c.source as { fieldName: string }).fieldName}:${c.extDisplay?.chartDays ?? 10}`)
|
||||
.join(',')
|
||||
const extChartSymbolsKey = useMemo(
|
||||
() => [...new Set(displayRows.map((r: any) => r.symbol as string))].sort().join(','),
|
||||
@@ -669,7 +669,7 @@ export function Screener() {
|
||||
const symbols = extChartSymbolsKey ? extChartSymbolsKey.split(',') : []
|
||||
const entries = await Promise.all(extChartColumns.map(async c => {
|
||||
const src = c.source as { type: 'ext'; configId: string; fieldName: string }
|
||||
const r = await api.extDataSeries(src.configId, src.fieldName, asOf || undefined, symbols)
|
||||
const r = await api.extDataSeries(src.configId, src.fieldName, asOf || undefined, symbols, c.extDisplay?.chartDays || 10)
|
||||
return [`${src.configId}.${src.fieldName}`, r.items ?? {}] as const
|
||||
}))
|
||||
return Object.fromEntries(entries) as Record<string, Record<string, [string, number][]>>
|
||||
|
||||
@@ -1345,7 +1345,7 @@ export function Watchlist() {
|
||||
[columns],
|
||||
)
|
||||
const extChartColsKey = extChartColumns
|
||||
.map(c => `${(c.source as { configId: string }).configId}.${(c.source as { fieldName: string }).fieldName}`)
|
||||
.map(c => `${(c.source as { configId: string }).configId}.${(c.source as { fieldName: string }).fieldName}:${c.extDisplay?.chartDays ?? 10}`)
|
||||
.join(',')
|
||||
const extChartSymbolsKey = useMemo(
|
||||
() => sortedRows.map(r => r.symbol as string).sort().join(','),
|
||||
@@ -1357,7 +1357,7 @@ export function Watchlist() {
|
||||
const symbols = extChartSymbolsKey ? extChartSymbolsKey.split(',') : []
|
||||
const entries = await Promise.all(extChartColumns.map(async c => {
|
||||
const src = c.source as { type: 'ext'; configId: string; fieldName: string }
|
||||
const r = await api.extDataSeries(src.configId, src.fieldName, undefined, symbols)
|
||||
const r = await api.extDataSeries(src.configId, src.fieldName, undefined, symbols, c.extDisplay?.chartDays || 10)
|
||||
return [`${src.configId}.${src.fieldName}`, r.items ?? {}] as const
|
||||
}))
|
||||
return Object.fromEntries(entries) as Record<string, Record<string, [string, number][]>>
|
||||
|
||||
Reference in New Issue
Block a user