Files
tick-stock-panel/backend/app/data_providers/custom/config.py
T
shy3130 578a531743 fix(data): 自定义源比例字段单位改为显式声明 pct_unit 未声明 fail-closed
amplitude/turnover_rate 的百分制与小数制数值区间重叠(0.05 既可能是
0.05% 也可能是 5%), 截面中位数启发式不可判, 赌错即整体放大 100 倍,
违反 CONTRIBUTING §3.1 禁止启发式转换的约束。

- realtime 数据集新增 pct_unit: percent|decimal 显式声明, 声明即契约
  (percent 无条件 /100, decimal 无条件透传, 不受数值外观影响)
- 未声明时 change_pct 保留涨跌停 30% 上限的截面判定(物理可判),
  amplitude/turnover_rate 置 None 交 enriched 管道按价格/股本口径重算
  并记录 WARNING; 已配置 transforms 的列视为用户接管单位, 透传
- 配置解析/清洗/序列化全链路校验取值, 非 realtime 数据集声明即报错
- 契约测试重写覆盖声明优先、边界值、fail-closed 与 transforms 兼容
2026-08-30 19:05:20 +08:00

125 lines
4.5 KiB
Python

"""Custom HTTP data source configuration."""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal
import yaml
DatasetName = Literal["daily", "adj_factor", "realtime", "minute", "financial"]
DEFAULT_TIMEOUT = 30.0
MAX_TIMEOUT = 300.0
@dataclass(frozen=True)
class AuthConfig:
type: str = "none"
token_env: str | None = None
header: str = "Authorization"
param: str = "token"
@dataclass(frozen=True)
class DatasetConfig:
url: str
method: str = "GET"
batch: int | None = None
rpm: int | None = None
timeout: float = DEFAULT_TIMEOUT
response_path: str = ""
field_map: dict[str, str] = field(default_factory=dict)
transforms: dict[str, str] = field(default_factory=dict)
params: dict[str, Any] = field(default_factory=dict)
body: dict[str, Any] = field(default_factory=dict)
symbols_param: str = "symbols"
start_param: str = "start_time"
end_param: str = "end_time"
asset_type_param: str | None = None
freq_param: str | None = None
# realtime 比例字段(change_pct/amplitude/turnover_rate)的单位声明:
# "percent"(返回 3.66 表示 3.66%)或 "decimal"(返回 0.0366 表示 3.66%)。
pct_unit: str | None = None
@dataclass(frozen=True)
class CustomSourceConfig:
name: str
display_name: str
auth: AuthConfig = field(default_factory=AuthConfig)
datasets: dict[str, DatasetConfig] = field(default_factory=dict)
path: Path | None = None
def has_dataset(self, name: DatasetName) -> bool:
return name in self.datasets
def _auth_from_dict(raw: dict[str, Any] | None) -> AuthConfig:
raw = raw or {}
return AuthConfig(
type=str(raw.get("type", "none") or "none").lower(),
token_env=raw.get("token_env"),
header=str(raw.get("header", "Authorization") or "Authorization"),
param=str(raw.get("param", "token") or "token"),
)
def _dataset_from_dict(raw: dict[str, Any]) -> DatasetConfig:
timeout_raw = raw.get("timeout")
if timeout_raw is None:
timeout = DEFAULT_TIMEOUT
else:
try:
timeout = float(timeout_raw)
except (TypeError, ValueError) as e:
raise ValueError(
f"timeout must be a number between 0 and {MAX_TIMEOUT:g} seconds"
) from e
if not 0 < timeout <= MAX_TIMEOUT:
raise ValueError(f"timeout must be between 0 and {MAX_TIMEOUT:g} seconds")
pct_unit = str(raw.get("pct_unit") or "").strip().lower() or None
if pct_unit not in (None, "percent", "decimal"):
raise ValueError(f"pct_unit must be 'percent' or 'decimal', got {pct_unit!r}")
return DatasetConfig(
url=str(raw.get("url", "") or ""),
method=str(raw.get("method", "GET") or "GET").upper(),
batch=int(raw["batch"]) if raw.get("batch") is not None else None,
rpm=int(raw["rpm"]) if raw.get("rpm") is not None else None,
timeout=timeout,
response_path=str(raw.get("response_path", "") or ""),
field_map={str(k): str(v) for k, v in (raw.get("field_map") or {}).items()},
transforms={str(k): str(v) for k, v in (raw.get("transforms") or {}).items()},
params=dict(raw.get("params") or {}),
body=dict(raw.get("body") or {}),
symbols_param=str(raw.get("symbols_param", "symbols") or "symbols").strip() or "symbols",
start_param=str(raw.get("start_param", "start_time") or "start_time").strip() or "start_time",
end_param=str(raw.get("end_param", "end_time") or "end_time").strip() or "end_time",
asset_type_param=(str(raw.get("asset_type_param") or "").strip() or None),
freq_param=(str(raw.get("freq_param") or "").strip() or None),
pct_unit=pct_unit,
)
def config_from_dict(raw: dict[str, Any], path: Path | None = None) -> CustomSourceConfig:
datasets = {
name: _dataset_from_dict(cfg)
for name, cfg in (raw.get("datasets") or {}).items()
if name in {"daily", "adj_factor", "realtime", "minute", "financial"} and isinstance(cfg, dict)
}
default_name = path.stem if path else "preview"
name = str(raw.get("name", default_name) or default_name).lower()
return CustomSourceConfig(
name=name,
display_name=str(raw.get("display_name", name) or name),
auth=_auth_from_dict(raw.get("auth")),
datasets=datasets,
path=path,
)
def load_config(path: Path) -> CustomSourceConfig:
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
return config_from_dict(raw, path)