diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a81d2b..e2cb4e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 线同源的空数据故障转移。 diff --git a/pyproject.toml b/pyproject.toml index 06a41aa..3725f88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/easy_tdx/web/convert.py b/src/easy_tdx/web/convert.py index dac8046..a5eacfe 100644 --- a/src/easy_tdx/web/convert.py +++ b/src/easy_tdx/web/convert.py @@ -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 一律为 1(KlineCategory 无多周期概念)。 + + 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 diff --git a/src/easy_tdx/web/deps.py b/src/easy_tdx/web/deps.py index a121205..2558634 100644 --- a/src/easy_tdx/web/deps.py +++ b/src/easy_tdx/web/deps.py @@ -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 diff --git a/src/easy_tdx/web/routers/bars.py b/src/easy_tdx/web/routers/bars.py index 8ce51a2..554c192 100644 --- a/src/easy_tdx/web/routers/bars.py +++ b/src/easy_tdx/web/routers/bars.py @@ -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。旧 /bars(SecurityBar 路径)日线返回 ``date`` 列(仅日期)、 + 分钟线返回 ``datetime`` 列,无 float_shares,OHLC 顺序为 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) diff --git a/src/easy_tdx/web/schemas.py b/src/easy_tdx/web/schemas.py index 47e7f2b..4e5d14f 100644 --- a/src/easy_tdx/web/schemas.py +++ b/src/easy_tdx/web/schemas.py @@ -35,6 +35,14 @@ class KlineCategoryEnum(IntEnum): SEASON = 10 +class AdjustEnum(IntEnum): + """Adjustment type (前复权/后复权).""" + + NONE = 0 + QFQ = 1 # 前复权 + HFQ = 2 # 后复权 + + # --------------------------------------------------------------------------- # Request models # --------------------------------------------------------------------------- diff --git a/tests/unit/test_web_api.py b/tests/unit/test_web_api.py index db711df..05d2922 100644 --- a/tests/unit/test_web_api.py +++ b/tests/unit/test_web_api.py @@ -255,6 +255,136 @@ def test_convert_category_invalid_raises_valueerror(): category_from_str("INVALID_PERIOD") +# --------------------------------------------------------------------------- +# /bars 迁移到 MacClient:KlineCategory→(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")