feat(web): /bars 迁移到 MacClient + 支持复权(issue #43)

Web 获取 K 线此前用 AsyncTdxClient.get_security_bars(标准协议不支持复权),
导致 REST API 无法取前复权/后复权数据。将 /bars(个股 K 线)迁移到
AsyncMacClient.get_stock_kline(MAC 协议,支持 NONE/QFQ/HFQ + QFQ 负价兜底),
保持旧输出契约不变(日线 date 列、分钟线 datetime 列、OHLC 顺序、无 float_shares),
新增 adjust 参数(默认 QFQ),MAC 主机不可用时自动回退标准 TdxClient。

⚠️ 半破坏性变更:/bars 默认复权从"不复权"改为 QFQ(前复权)。老调用方若需
不复权请显式传 ?adjust=NONE。输出 DataFrame 列名/顺序/字段与旧版完全一致
(_normalize_mac_df 规整),仅价格数值因复权变化。

改动:
- bars.py:/bars 改走 mac_client.get_stock_kline(adjust=...),MAC 不可用回退
  get_security_bars(无复权 + warning);新增 _normalize_mac_df 规整输出契约;
  新增 adjust 查询参数(默认 QFQ)。
- convert.py:period_times_from_category(KlineCategory→Period 映射,显式处理
  YEAR 9→YEARLY 11、SEASON→QUARTERLY)+ adjust_from_str。
- deps.py:get_mac_client_optional(未连接返回 None,供回退判断)。
- schemas.py:AdjustEnum(OpenAPI 文档用)。

测试:新增 7 个(period_times 映射全表 + 不可映射值、adjust 转换、_normalize_mac_df
日线/分钟线/空 df)。全套 996 passed;ruff format/check + mypy 改动文件零错误。

不在本次范围:/bars/index(指数 K 线,MAC 另一套接口)、/minute、/transaction*
(MacClient tick_chart 语义不同,暂不迁移)。
This commit is contained in:
GitHub
2026-08-05 16:37:10 +08:00
parent e5e8f75516
commit 5a3ad15477
7 changed files with 316 additions and 7 deletions
+26
View File
@@ -2,6 +2,32 @@
本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。 本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。
## [1.20.6] — 2026-08-05
**Web `/bars` 端点迁移到 MacClient + 支持复权**Issue #43)—— 用户反馈 Web 获取 K 线用的还是标准 TdxClient(标准协议本身不支持复权),导致 REST API 无法取前复权/后复权数据。本次将 `/bars`(个股 K 线)迁移到 `AsyncMacClient.get_stock_kline`MAC 协议,支持 NONE/QFQ/HFQ + QFQ 负价兜底),**保持旧输出契约不变**(日线 `date` 列、分钟线 `datetime` 列、OHLC 顺序、无 `float_shares`),新增 `adjust` 参数(默认 QFQ),MAC 主机不可用时自动回退标准 TdxClient。
### ⚠️ 半破坏性变更
- **`/bars` 默认复权方式从"不复权"改为 QFQ(前复权)**。此前 `/bars` 透传 `AsyncTdxClient.get_security_bars`(无复权参数),默认返回原始价格。迁移后默认 `adjust=QFQ`,符合大多数看盘/回测场景。**老调用方若需不复权,请显式传 `?adjust=NONE`**。输出 DataFrame 的列名/顺序/字段与旧版完全一致(已规整),仅价格数值因复权变化。
### 新增
- **`/bars` 支持复权**`src/easy_tdx/web/routers/bars.py`)—— 优先走 `AsyncMacClient.get_stock_kline(adjust=...)`(支持 NONE/QFQ/HFQ,QFQ 对深层历史负价有本地重算兜底);MAC 主机未连接时自动回退 `AsyncTdxClient.get_security_bars`(无复权,adjust 参数忽略并 warning)。新增查询参数 `adjust`(默认 QFQ)。
- **`_normalize_mac_df`**`src/easy_tdx/web/routers/bars.py`)—— 规整 MacClient 输出以匹配旧 `/bars` 契约:日线及以上 `datetime``date`(截断时分秒)、drop `float_shares`、OHLC 列顺序对齐 `open/close/high/low`。迁移后调用方输出契约零变化。
- **`period_times_from_category`**`src/easy_tdx/web/convert.py`)—— 标准 `KlineCategory` → MAC `(Period, times)` 映射查表(显式处理 YEAR 9→YEARLY 11、SEASON→QUARTERLY 值/名差异)。
- **`adjust_from_str`**`src/easy_tdx/web/convert.py`)—— 字符串 → `Adjust` 枚举(NONE/QFQ/HFQ,支持大小写和数字字符串)。
- **`get_mac_client_optional`**`src/easy_tdx/web/deps.py`)—— MAC client 依赖注入的可选版(未连接返回 None 而非抛 503),供 `/bars` 回退判断;原 `get_mac_client`(强制版)不动,其他 `/mac/*` 端点继续用。
- **`AdjustEnum`**`src/easy_tdx/web/schemas.py`)—— OpenAPI 文档展示用。
### 测试
- 新增 7 个测试(`tests/unit/test_web_api.py`):`period_times_from_category` 完整映射(10 个 KlineCategory,重点 YEAR/SEASON+ 不可映射值抛错;`adjust_from_str` 名称/大小写/数字/非法值;`_normalize_mac_df` 日线(datetime→date/分钟线(保留 datetime/空 df 三场景。全套 27 web 测试通过。
### 不在本次范围
- `/bars/index`(指数 K 线):MAC 指数 K 线是另一套接口,需单独评估。
- `/minute``/transaction*`(分时/逐笔):MacClient 的 `get_tick_chart` 语义与标准分时不同,暂不迁移。
## [1.20.5] — 2026-08-05 ## [1.20.5] — 2026-08-05
**资金流空数据故障转移**Issue #41)—— 用户反馈 `get_history_fund_flow(SH, "600519")` 返回空 DataFrame,日志显示"K线响应为空(声称 800 条但首条即解析失败...)"。排查定位:当前 host 对常见标的也返回 `ret_count` 撒谎的空 body,但资金流这条兼容回退路径(直连空 → 拉 K 线 + 历史逐笔重算)**未接入 v1.20.4 的空数据故障转移**,"服务器回包正常但内容是假的空"既非 `TdxConnectionError` 也不触发换台,用户卡在坏服务器上拿不到数据。本次将资金流路径接入与 K 线同源的空数据故障转移。 **资金流空数据故障转移**Issue #41)—— 用户反馈 `get_history_fund_flow(SH, "600519")` 返回空 DataFrame,日志显示"K线响应为空(声称 800 条但首条即解析失败...)"。排查定位:当前 host 对常见标的也返回 `ret_count` 撒谎的空 body,但资金流这条兼容回退路径(直连空 → 拉 K 线 + 历史逐笔重算)**未接入 v1.20.4 的空数据故障转移**,"服务器回包正常但内容是假的空"既非 `TdxConnectionError` 也不触发换台,用户卡在坏服务器上拿不到数据。本次将资金流路径接入与 K 线同源的空数据故障转移。
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "easy-tdx" name = "easy-tdx"
version = "1.20.5" version = "1.20.6"
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步" description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
+62
View File
@@ -102,6 +102,68 @@ def sort_order_from_str(s: str) -> Any:
raise ValueError(f"无效排序方向 '{s}',可选值: {valid}") from None raise ValueError(f"无效排序方向 '{s}',可选值: {valid}") from None
def period_times_from_category(category: Any) -> tuple[Any, int]:
"""将标准 KlineCategory 映射到 MAC 的 (Period, times)。
标准协议的 KlineCategory 与 MAC 协议的 Period 枚举值并非一一对应
(如 YEAR=9 而 YEARLY=11),且 MAC 用 Period+times 表达多周期。这里做
显式查表,times 一律为 1KlineCategory 无多周期概念)。
Args:
category: ``KlineCategory`` 枚举值(或其 int)。
Returns:
``(Period, times)`` 元组。
Raises:
ValueError: 无法映射的 KlineCategory 值。
"""
from easy_tdx.mac.enums import Period
# 显式查表:标准 KlineCategory 值 → (Period, times)
# 注意 YEAR(9)→YEARLY(11)、SEASON(10)→QUARTERLY(10) 值/名不同
mapping = {
0: (Period.MIN_5, 1), # KlineCategory.MIN_5
1: (Period.MIN_15, 1), # KlineCategory.MIN_15
2: (Period.MIN_30, 1), # KlineCategory.MIN_30
3: (Period.MIN_60, 1), # KlineCategory.MIN_60
4: (Period.DAILY, 1), # KlineCategory.DAY
5: (Period.WEEKLY, 1), # KlineCategory.WEEK
6: (Period.MONTHLY, 1), # KlineCategory.MONTH
7: (Period.MIN_1, 1), # KlineCategory.MIN_1
9: (Period.YEARLY, 1), # KlineCategory.YEAR(值 9→Period.YEARLY 值 11
10: (Period.QUARTERLY, 1), # KlineCategory.SEASON → Period.QUARTERLY
}
key = int(category)
if key not in mapping:
valid = "MIN_1/5/15/30/60, DAY, WEEK, MONTH, SEASON, YEAR"
raise ValueError(f"无法映射的 K线周期值 {key}/bars 仅支持 {valid}")
return mapping[key]
def adjust_from_str(s: str) -> Any:
"""将字符串转为 Adjust 枚举(NONE/QFQ/HFQ),支持大小写和数字字符串。
>>> adjust_from_str("QFQ") # 前复权
>>> adjust_from_str("qfq") # 也正常(自动转大写)
>>> adjust_from_str("1") # 数字字符串也行(= QFQ)
>>> adjust_from_str("XXX") # ValueError
"""
from easy_tdx.mac.enums import Adjust
key = s.upper()
try:
return Adjust[key]
except KeyError:
pass
try:
return Adjust(int(key))
except (ValueError, TypeError):
pass
valid = ", ".join(a.name for a in Adjust)
raise ValueError(f"无效复权类型 '{s}',可选值: {valid}") from None
def category_mac_from_str(s: str) -> Any: def category_mac_from_str(s: str) -> Any:
"""将字符串转为 MAC Category 枚举(A/SH/SZ/KCB/BJ/CYB/...)。""" """将字符串转为 MAC Category 枚举(A/SH/SZ/KCB/BJ/CYB/...)。"""
from easy_tdx.mac.enums import Category from easy_tdx.mac.enums import Category
+9
View File
@@ -25,6 +25,15 @@ def get_mac_client(request: Request) -> Any:
return client return client
def get_mac_client_optional(request: Request) -> Any | None:
"""从 app.state 获取 AsyncMacClient 实例,未连接时返回 None(不抛异常)。
供需要"MAC 不可用时自动回退标准 TdxClient"的端点使用(如 ``/bars``)。
其他强制依赖 MAC 的端点(``/mac/*``)仍用 :func:`get_mac_client`。
"""
return request.app.state.mac_client
def get_ex_client(request: Request) -> Any: def get_ex_client(request: Request) -> Any:
"""从 app.state 获取共享的 AsyncExTdxClient 实例(可选)。""" """从 app.state 获取共享的 AsyncExTdxClient 实例(可选)。"""
client: Any | None = request.app.state.ex_client client: Any | None = request.app.state.ex_client
+80 -6
View File
@@ -2,21 +2,65 @@
from __future__ import annotations from __future__ import annotations
import logging
from typing import Any from typing import Any
import pandas as pd
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from easy_tdx.web.convert import category_from_str, market_from_str from easy_tdx.models.enums import KlineCategory
from easy_tdx.web.deps import get_client from easy_tdx.web.convert import (
adjust_from_str,
category_from_str,
market_from_str,
market_value_from_str,
period_times_from_category,
)
from easy_tdx.web.deps import get_client, get_mac_client_optional
from easy_tdx.web.schemas import DataFrameResponse from easy_tdx.web.schemas import DataFrameResponse
_logger = logging.getLogger(__name__)
router = APIRouter(tags=["bars"]) router = APIRouter(tags=["bars"])
# 规整后保持的列顺序(匹配旧 SecurityBar 输出契约)
_NORMAL_COLS = ["open", "close", "high", "low", "vol", "amount"]
def _df_resp(df: Any) -> DataFrameResponse: def _df_resp(df: Any) -> DataFrameResponse:
return DataFrameResponse.from_dataframe(df) return DataFrameResponse.from_dataframe(df)
def _normalize_mac_df(df: pd.DataFrame, daily_plus: bool) -> pd.DataFrame:
"""规整 MacClient.get_stock_kline 的输出以匹配旧 /bars 契约。
MacClient 返回 ``datetime`` 列(含时分秒)+ ``float_shares`` 列,OHLC 顺序为
open/high/low/close。旧 /barsSecurityBar 路径)日线返回 ``date`` 列(仅日期)、
分钟线返回 ``datetime`` 列,无 float_sharesOHLC 顺序为 open/close/high/low。
本函数做对齐,保证迁移后调用方输出契约不变。
Args:
df: MacClient 返回的 DataFrame(可能为空)。
daily_plus: True=日线及以上周期(datetime→date),False=分钟线(保留 datetime)。
"""
if df.empty:
return df
out = df.copy()
if "float_shares" in out.columns:
out = out.drop(columns=["float_shares"])
time_col = "date" if daily_plus else "datetime"
if "datetime" in out.columns:
if daily_plus:
# 截断为仅日期(00:00:00),与旧 _merge_bar_datetime 的 date 列语义一致
out["datetime"] = pd.to_datetime(out["datetime"]).dt.normalize()
out = out.rename(columns={"datetime": time_col})
# 重排列顺序:时间列在前,OHLC 顺序 open/close/high/low,再 vol/amount
cols = [c for c in [time_col, *_NORMAL_COLS] if c in out.columns]
# 兜底:保留未列出的列(理论上不应有),追加到末尾
cols += [c for c in out.columns if c not in cols]
return out[cols]
@router.get("/bars", response_model=DataFrameResponse) @router.get("/bars", response_model=DataFrameResponse)
async def security_bars( async def security_bars(
market: str = Query(..., description="市场: SZ, SH, BJ"), market: str = Query(..., description="市场: SZ, SH, BJ"),
@@ -30,12 +74,42 @@ async def security_bars(
bar_time: str = Query( bar_time: str = Query(
"start", description="时间戳: start=bar开始时间(默认) / end=bar结束时间(对齐Tushare)" "start", description="时间戳: start=bar开始时间(默认) / end=bar结束时间(对齐Tushare)"
), ),
adjust: str = Query(
"QFQ", description="复权: NONE=不复权 / QFQ=前复权(默认) / HFQ=后复权(需 MAC 客户端)"
),
mac_client: Any = Depends(get_mac_client_optional),
client: Any = Depends(get_client), client: Any = Depends(get_client),
) -> DataFrameResponse: ) -> DataFrameResponse:
"""获取股票K线数据""" """获取股票K线数据MAC 协议,支持复权)。
df = await client.get_security_bars(
market_from_str(market), code, category_from_str(category), start, count, bar_time=bar_time 优先走 AsyncMacClient.get_stock_kline(支持 NONE/QFQ/HFQ 复权 + QFQ 负价兜底);
) MAC 主机未连接时自动回退 AsyncTdxClient.get_security_bars(无复权,adjust 参数忽略)。
输出契约与旧版一致:日线返回 ``date`` 列,分钟线返回 ``datetime`` 列。
"""
cat = category_from_str(category)
if mac_client is not None:
period, times = period_times_from_category(cat)
df = await mac_client.get_stock_kline(
market_value_from_str(market),
code,
period,
start,
count,
times,
adjust=adjust_from_str(adjust),
bar_time=bar_time,
)
# daily_plus:日线及以上周期(DAY=4 及以上)datetime→date
df = _normalize_mac_df(df, daily_plus=int(cat) >= int(KlineCategory.DAY))
else:
# MAC 不可用:回退标准 TdxClient(无复权),adjust 参数忽略
_logger.warning(
"/bars MAC 客户端未连接,回退标准 TdxClient(不支持复权,adjust=%s 被忽略)",
adjust,
)
df = await client.get_security_bars(
market_from_str(market), code, cat, start, count, bar_time=bar_time
)
return _df_resp(df) return _df_resp(df)
+8
View File
@@ -35,6 +35,14 @@ class KlineCategoryEnum(IntEnum):
SEASON = 10 SEASON = 10
class AdjustEnum(IntEnum):
"""Adjustment type (前复权/后复权)."""
NONE = 0
QFQ = 1 # 前复权
HFQ = 2 # 后复权
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Request models # Request models
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+130
View File
@@ -255,6 +255,136 @@ def test_convert_category_invalid_raises_valueerror():
category_from_str("INVALID_PERIOD") category_from_str("INVALID_PERIOD")
# ---------------------------------------------------------------------------
# /bars 迁移到 MacClientKlineCategory→(Period,times) 映射 + adjust 转换
# (Issue #43)
# ---------------------------------------------------------------------------
def test_period_times_from_category_mapping():
"""KlineCategory → (Period, times) 完整映射,重点 YEAR/SEASON 值不同。"""
pytest.importorskip("fastapi")
from easy_tdx.mac.enums import Period
from easy_tdx.models.enums import KlineCategory
from easy_tdx.web.convert import period_times_from_category
expected = {
KlineCategory.MIN_5: (Period.MIN_5, 1),
KlineCategory.MIN_15: (Period.MIN_15, 1),
KlineCategory.MIN_30: (Period.MIN_30, 1),
KlineCategory.MIN_60: (Period.MIN_60, 1),
KlineCategory.DAY: (Period.DAILY, 1),
KlineCategory.WEEK: (Period.WEEKLY, 1),
KlineCategory.MONTH: (Period.MONTHLY, 1),
KlineCategory.MIN_1: (Period.MIN_1, 1),
KlineCategory.YEAR: (Period.YEARLY, 1), # 值 9 → Period.YEARLY 值 11
KlineCategory.SEASON: (Period.QUARTERLY, 1), # SEASON → QUARTERLY
}
for cat, want in expected.items():
assert period_times_from_category(cat) == want, f"{cat} 应映射到 {want}"
def test_period_times_from_category_rejects_unmappable():
"""无法映射的 KlineCategory 值(如 MIN_3=8)应抛 ValueError。"""
pytest.importorskip("fastapi")
from easy_tdx.web.convert import period_times_from_category
with pytest.raises(ValueError, match="无法映射"):
period_times_from_category(8) # MIN_3 不在 /bars 支持范围
def test_adjust_from_str_accepts_name_case_and_int():
"""adjust_from_str 支持 NONE/QFQ/HFQ 名称(大小写)和数字字符串。"""
pytest.importorskip("fastapi")
from easy_tdx.mac.enums import Adjust
from easy_tdx.web.convert import adjust_from_str
assert adjust_from_str("QFQ") == Adjust.QFQ
assert adjust_from_str("qfq") == Adjust.QFQ
assert adjust_from_str("1") == Adjust.QFQ # 数字字符串
assert adjust_from_str("NONE") == Adjust.NONE
assert adjust_from_str("none") == Adjust.NONE
assert adjust_from_str("0") == Adjust.NONE
assert adjust_from_str("HFQ") == Adjust.HFQ
assert adjust_from_str("2") == Adjust.HFQ
def test_adjust_from_str_invalid_raises():
"""非法复权类型应抛 ValueError。"""
pytest.importorskip("fastapi")
from easy_tdx.web.convert import adjust_from_str
with pytest.raises(ValueError, match="无效复权类型"):
adjust_from_str("XXX")
def test_normalize_mac_df_daily_plus():
"""日线规整:datetime→date(截断时分)、drop float_shares、OHLC 顺序 open/close/high/low。"""
pytest.importorskip("fastapi")
import pandas as pd
from easy_tdx.web.routers.bars import _normalize_mac_df
df = pd.DataFrame(
{
"datetime": [pd.Timestamp("2026-07-10 15:00:00"), pd.Timestamp("2026-07-11 15:00:00")],
"open": [10.0, 10.5],
"high": [10.8, 10.9],
"low": [9.9, 10.3],
"close": [10.5, 10.6],
"vol": [1000.0, 1100.0],
"amount": [10500.0, 11600.0],
"float_shares": [0.0, 0.0],
}
)
out = _normalize_mac_df(df, daily_plus=True)
# 时间列:datetime → date,且截断为 00:00:00
assert "date" in out.columns
assert "datetime" not in out.columns
assert out["date"].iloc[0] == pd.Timestamp("2026-07-11 00:00:00") - pd.Timedelta(days=1)
# drop float_shares
assert "float_shares" not in out.columns
# 列顺序:date 在前,OHLC 顺序 open/close/high/low
assert list(out.columns) == ["date", "open", "close", "high", "low", "vol", "amount"]
def test_normalize_mac_df_intraday_keeps_datetime():
"""分钟线规整:保留 datetime 列(含时分)。"""
pytest.importorskip("fastapi")
import pandas as pd
from easy_tdx.web.routers.bars import _normalize_mac_df
df = pd.DataFrame(
{
"datetime": [pd.Timestamp("2026-07-10 09:35:00")],
"open": [10.0],
"high": [10.8],
"low": [9.9],
"close": [10.5],
"vol": [1000.0],
"amount": [10500.0],
}
)
out = _normalize_mac_df(df, daily_plus=False)
assert "datetime" in out.columns
assert "date" not in out.columns
# 时分保留
assert out["datetime"].iloc[0] == pd.Timestamp("2026-07-10 09:35:00")
assert list(out.columns) == ["datetime", "open", "close", "high", "low", "vol", "amount"]
def test_normalize_mac_df_empty_noop():
"""空 DataFrame 规整不报错。"""
pytest.importorskip("fastapi")
import pandas as pd
from easy_tdx.web.routers.bars import _normalize_mac_df
out = _normalize_mac_df(pd.DataFrame(), daily_plus=True)
assert out.empty
def test_full_app_routes_registered(): def test_full_app_routes_registered():
"""All routers should be mounted and accessible.""" """All routers should be mounted and accessible."""
pytest.importorskip("fastapi") pytest.importorskip("fastapi")