Merge pull request #46 from handsomejustin/feat/issue-43-bars-mac-adjust

feat(web): /bars 迁移到 MacClient + 支持复权(issue #43)
This commit is contained in:
毛利哥
2026-08-05 16:44:00 +08:00
committed by GitHub
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/)。
## [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
**资金流空数据故障转移**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]
name = "easy-tdx"
version = "1.20.5"
version = "1.20.6"
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
readme = "README.md"
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
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:
"""将字符串转为 MAC Category 枚举(A/SH/SZ/KCB/BJ/CYB/...)。"""
from easy_tdx.mac.enums import Category
+9
View File
@@ -25,6 +25,15 @@ def get_mac_client(request: Request) -> Any:
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:
"""从 app.state 获取共享的 AsyncExTdxClient 实例(可选)。"""
client: Any | None = request.app.state.ex_client
+80 -6
View File
@@ -2,21 +2,65 @@
from __future__ import annotations
import logging
from typing import Any
import pandas as pd
from fastapi import APIRouter, Depends, Query
from easy_tdx.web.convert import category_from_str, market_from_str
from easy_tdx.web.deps import get_client
from easy_tdx.models.enums import KlineCategory
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
_logger = logging.getLogger(__name__)
router = APIRouter(tags=["bars"])
# 规整后保持的列顺序(匹配旧 SecurityBar 输出契约)
_NORMAL_COLS = ["open", "close", "high", "low", "vol", "amount"]
def _df_resp(df: Any) -> DataFrameResponse:
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)
async def security_bars(
market: str = Query(..., description="市场: SZ, SH, BJ"),
@@ -30,12 +74,42 @@ async def security_bars(
bar_time: str = Query(
"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),
) -> DataFrameResponse:
"""获取股票K线数据"""
df = await client.get_security_bars(
market_from_str(market), code, category_from_str(category), start, count, bar_time=bar_time
)
"""获取股票K线数据MAC 协议,支持复权)。
优先走 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)
+8
View File
@@ -35,6 +35,14 @@ class KlineCategoryEnum(IntEnum):
SEASON = 10
class AdjustEnum(IntEnum):
"""Adjustment type (前复权/后复权)."""
NONE = 0
QFQ = 1 # 前复权
HFQ = 2 # 后复权
# ---------------------------------------------------------------------------
# Request models
# ---------------------------------------------------------------------------
+130
View File
@@ -255,6 +255,136 @@ def test_convert_category_invalid_raises_valueerror():
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():
"""All routers should be mounted and accessible."""
pytest.importorskip("fastapi")