Files
easy_tdx_max/src/easy_tdx/web/schemas.py
T
Justin Gu e374a0da28 release: v1.32.6 — 两周改动深度审查全面修复(回测口径三件套/LLM 安全加固/涨停价舍入/时区统一/缓存与竞态等 58 处)
对 v1.21→v1.32.5 的 249 文件 4.2 万行改动做六路专项审查,本轮落地全部发现:

回测正确性:组合收益 fillna(0) 虚增、轮动停牌日过期价成交、单标的 WF 逐窗指标
被预热区稀释(三件套均带先红后绿回归);worst_drawdown 方向、grading 容错、
组合体检品种费率、寻优端点费率透传。

安全:LLM api_url 仅 http/https 且禁 userinfo(封死 file:// 读取与 Key 外送链)、
错误响应不回显原始 body、响应体 2MB 上限、配置原子写、坏配置字段级防御。

数据:涨跌停价整数分币舍入(67/318/90 个价位错 1 分漏判清零)、交易时段/采样/
provisional 统一沪时区、warehouse 增量缺口自动全量重拉、provisional 定点转正、
baostock 真故障抛错 + W/M 去 tradestatus(实测服务端报错,周月兜底此前从未工作)
+ 指数 vol 股→手(实测锚定)、ccpm 结构变更抛错。

Web API:缓存键补 count/vipdoc、NaN 清洗先于缓存、count>800 分页取全量、
submit 透传真实状态、pending 不再被淘汰成幽灵、watchlist/server 入参约束。

公式:FILTER 去副作用、0-1 值域误判收严、递归深度上限、REF 负移位显式禁止。

前端:4 处请求竞态序号守卫、Sparkline viewBox、北交所 market=2 映射、
空数据缓存死角、AI 弹窗卸载中止轮询、量能/资金日历口径修正。

CLI/CI:warehouse sync 失败 exit 1、参数校验干净报错、release 真实发布 SHA256、
CI 超时与缓存、spec 补 baostock 前提。

约 60 条回归测试先红后绿;pytest 1820 全过,ruff/mypy/vue-tsc/node --test 全绿。
2026-09-06 22:16:48 +08:00

189 lines
6.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Pydantic request/response schemas for the Web API."""
from __future__ import annotations
import math
from enum import IntEnum
from typing import Any
from pydantic import BaseModel, Field
def _json_safe(v: Any) -> Any:
"""递归把值清洗为 JSON 原生类型:NaN/±inf → None、datetime → ISO 串、
numpy 标量 → Python 原生、容器逐项处理。
Starlette 的 JSONResponse 以 ``allow_nan=False`` 序列化,任何 NaN/inf
漏出去都会让整个响应 500v1.32 实测:/board-mac/overview 某行
sort_value=NaN → 全端点 500 且带毒 payload 入 15s 缓存)。所有
DictResponse / 缓存写入路径都应先过本函数。
"""
# bool 是 int 子类,须先判
if v is None or isinstance(v, bool | str | int):
return v
if isinstance(v, float):
return None if (math.isnan(v) or math.isinf(v)) else v
if hasattr(v, "isoformat"): # datetime/date/pd.Timestamp
return v.isoformat()
if hasattr(v, "item"): # numpy 标量(含 np.float32 NaN
return _json_safe(v.item())
if isinstance(v, dict):
return {str(k): _json_safe(val) for k, val in v.items()}
if isinstance(v, list | tuple):
return [_json_safe(item) for item in v]
return v
# ---------------------------------------------------------------------------
# Enums — mirror easy_tdx.models.enums but as string-based for REST clarity
# ---------------------------------------------------------------------------
class MarketEnum(IntEnum):
"""Market identifier."""
SZ = 0
SH = 1
BJ = 2
class KlineCategoryEnum(IntEnum):
"""K-line period."""
MIN_5 = 0
MIN_15 = 1
MIN_30 = 2
MIN_60 = 3
DAY = 4
WEEK = 5
MONTH = 6
MIN_1 = 7
YEAR = 9
SEASON = 10
class AdjustEnum(IntEnum):
"""Adjustment type (前复权/后复权)."""
NONE = 0
QFQ = 1 # 前复权
HFQ = 2 # 后复权
# ---------------------------------------------------------------------------
# Request models
# ---------------------------------------------------------------------------
class StockIdentifier(BaseModel):
"""A single stock identified by market + code."""
market: str = Field(..., pattern=r"^(SZ|SH|BJ)$", description="市场代码")
code: str = Field(..., min_length=6, max_length=6, description="6位股票代码")
class QuoteRequest(BaseModel):
"""Batch quote request."""
stocks: list[StockIdentifier] = Field(
..., min_length=1, max_length=80, description="股票列表(最多80只)"
)
class ChanlunRequest(BaseModel):
"""缠论分析请求。"""
market: str = Field(..., pattern=r"^(SZ|SH|BJ)$")
code: str = Field(..., min_length=6, max_length=6)
category: str = Field(default="DAY", description="K线周期")
count: int = Field(default=800, ge=1, le=800)
start: int = Field(default=0, ge=0)
class ComputeIndicatorsRequest(BaseModel):
"""技术指标计算请求。"""
data: list[dict[str, Any]] = Field(..., description="OHLCV records")
indicators: list[str] = Field(..., min_length=1, description="指标名称列表")
params: dict[str, dict[str, int | float]] | None = Field(
default=None, description="指标参数(可选)"
)
keep_ohlcv: bool = Field(default=True, description="保留原始 OHLCV 列")
tail: int | None = Field(default=None, ge=1, description="仅返回末尾 N 行")
# ---------------------------------------------------------------------------
# Response models
# ---------------------------------------------------------------------------
class DataFrameResponse(BaseModel):
"""通用 DataFrame 响应(records 格式)。"""
data: list[dict[str, Any]]
count: int
@classmethod
def from_dataframe(cls, df: Any) -> DataFrameResponse:
"""从 pandas DataFrame 构建响应。"""
import pandas as pd
if isinstance(df, pd.DataFrame):
records = df.to_dict(orient="records")
cleaned: list[dict[str, Any]] = []
for row in records:
clean_row: dict[str, Any] = {}
for k, v in row.items():
assert isinstance(k, str)
if hasattr(v, "isoformat"):
clean_row[k] = v.isoformat()
elif isinstance(v, float) and v != v:
# NaN → null:缺失值(如指数分钟线 vol,pandas 惯例 NaN),
# 而 Starlette JSONResponse 为 allow_nan=False,透传会 500
clean_row[k] = None
elif hasattr(v, "item"):
# numpy scalar → Python native
clean_row[k] = v.item()
else:
clean_row[k] = v
cleaned.append(clean_row)
return cls(data=cleaned, count=len(cleaned))
return cls(data=[], count=0)
class BarsResponse(DataFrameResponse):
"""K 线响应。``source`` 非 None 表示数据来自自动兜底源(如 baostock,
TDX 全部路径失败时启用)——口径透明:调用方可据此展示数据来源。"""
source: str | None = None
@classmethod
def from_dataframe(cls, df: Any) -> BarsResponse:
resp = DataFrameResponse.from_dataframe(df)
return cls(data=resp.data, count=resp.count)
class DictResponse(BaseModel):
"""通用 dict 响应(用于非 DataFrame 返回值)。"""
data: dict[str, Any]
@classmethod
def from_dict(cls, d: dict[str, Any]) -> DictResponse:
"""序列化 dictDataFrame 转 records,值递归清洗(NaN/inf → null 等)。"""
import pandas as pd
cleaned: dict[str, Any] = {}
for k, v in d.items():
if isinstance(v, pd.DataFrame):
cleaned[k] = DataFrameResponse.from_dataframe(v).data
else:
cleaned[k] = _json_safe(v)
return cls(data=cleaned)
class CountResponse(BaseModel):
"""简单计数响应。"""
count: int