From bcaee1404d38842ead9882d6382ebff872fa0d6a Mon Sep 17 00:00:00 2001 From: GitHub Date: Wed, 19 Aug 2026 18:19:07 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20/bars=20MIN=5F1=20=E8=A2=AB?= =?UTF-8?q?=E8=AF=AF=E5=88=A4=E4=B8=BA=E6=97=A5=E7=BA=BF=EF=BC=8Cdatetime?= =?UTF-8?q?=20=E5=BD=92=E4=B8=80=E5=8C=96=E4=B8=BA=20date=2000:00:00?= =?UTF-8?q?=EF=BC=88issue=20#49=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KlineCategory 枚举值不按周期长短排序(MIN_1=7、MIN_3=8 均大于 DAY=4), /bars 的 MAC 路径用 int(cat) >= int(KlineCategory.DAY) 判定"日线及以上", 把 1 分钟线误判为日线,_normalize_mac_df 因此将 datetime 截断为 00:00:00 并把列名改为 date。回退 TdxClient 路径与 MAC symbol_bar 均为显式判定, 仅此一处用整数比较,故只有 MAC 路径复现。 改为 _is_daily_plus() 查表判定(复用 _df._CATEGORY_MINUTES,与回退路径 同一判定源),保证两条路径 date/datetime 语义一致。新增表驱动单测 + 端点级回归测试(假 MAC 客户端,在未修复代码上失败、修复后通过)。 --- src/easy_tdx/web/routers/bars.py | 16 ++++- tests/unit/test_web_api.py | 111 +++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/src/easy_tdx/web/routers/bars.py b/src/easy_tdx/web/routers/bars.py index 554c192..da04f07 100644 --- a/src/easy_tdx/web/routers/bars.py +++ b/src/easy_tdx/web/routers/bars.py @@ -8,7 +8,7 @@ from typing import Any import pandas as pd from fastapi import APIRouter, Depends, Query -from easy_tdx.models.enums import KlineCategory +from easy_tdx._df import _category_to_minutes from easy_tdx.web.convert import ( adjust_from_str, category_from_str, @@ -31,6 +31,16 @@ def _df_resp(df: Any) -> DataFrameResponse: return DataFrameResponse.from_dataframe(df) +def _is_daily_plus(cat: Any) -> bool: + """判断 KlineCategory 是否日线及以上周期(datetime 应归一为 date)。 + + KlineCategory 的枚举值不按周期长短排序(MIN_1=7、MIN_3=8 均大于 DAY=4), + 不能用整数大小判断"日线及以上";与 client.py 的 get_security_bars 路径保持 + 同一判定源:_CATEGORY_MINUTES 查得到=分钟级,查不到=日线及以上。 + """ + return _category_to_minutes(int(cat)) is None + + def _normalize_mac_df(df: pd.DataFrame, daily_plus: bool) -> pd.DataFrame: """规整 MacClient.get_stock_kline 的输出以匹配旧 /bars 契约。 @@ -99,8 +109,8 @@ async def security_bars( 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)) + # daily_plus:日线及以上周期 datetime→date(枚举值无序,显式查表判定) + df = _normalize_mac_df(df, daily_plus=_is_daily_plus(cat)) else: # MAC 不可用:回退标准 TdxClient(无复权),adjust 参数忽略 _logger.warning( diff --git a/tests/unit/test_web_api.py b/tests/unit/test_web_api.py index 05d2922..4eea835 100644 --- a/tests/unit/test_web_api.py +++ b/tests/unit/test_web_api.py @@ -385,6 +385,117 @@ def test_normalize_mac_df_empty_noop(): assert out.empty +def test_is_daily_plus_covers_all_categories(): + """daily_plus 判定必须按显式周期表,不能按枚举整数大小(issue #49)。 + + KlineCategory 值无序(MIN_1=7、MIN_3=8 > DAY=4),整数比较会把 1/3 分钟线 + 误判成日线,导致 datetime 被截断为 00:00:00 且列名变 date。 + """ + pytest.importorskip("fastapi") + from easy_tdx.models.enums import KlineCategory + from easy_tdx.web.routers.bars import _is_daily_plus + + intraday = { + KlineCategory.MIN_1, + KlineCategory.MIN_3, + KlineCategory.MIN_5, + KlineCategory.MIN_15, + KlineCategory.MIN_30, + KlineCategory.MIN_60, + } + for cat in KlineCategory: + assert _is_daily_plus(cat) == (cat not in intraday), f"{cat.name} 判定错误" + + +class _FakeMacClient: + """替身 AsyncMacClient:固定返回 MacClient 风格的 K 线 DataFrame。""" + + def __init__(self, df): + import pandas as pd + + self._df = df if isinstance(df, pd.DataFrame) else pd.DataFrame(df) + self.calls: list[dict] = [] + + async def get_stock_kline(self, market, code, period, start, count, times, **kwargs): + self.calls.append({"period": period, "adjust": kwargs.get("adjust")}) + return self._df + + +def _bars_app(mac_client): + """构造只挂 bars 路由的最小 app(无 lifespan,不触发真实行情连接)。""" + from fastapi import FastAPI + + from easy_tdx.web.routers import bars + + app = FastAPI() + app.include_router(bars.router, prefix="/api/v1") + app.state.tdx_client = object() # mac_client 非 None 时不会被用到 + app.state.mac_client = mac_client + return app + + +def test_bars_min1_endpoint_keeps_datetime(): + """端到端回归(issue #49):/bars MIN_1 必须返回 datetime 列且保留时分。""" + pytest.importorskip("fastapi") + import pandas as pd + from fastapi.testclient import TestClient + + mac_df = pd.DataFrame( + { + "datetime": [pd.Timestamp("2026-08-14 09:31:00"), pd.Timestamp("2026-08-14 09:32:00")], + "open": [10.0, 10.1], + "high": [10.2, 10.3], + "low": [9.9, 10.0], + "close": [10.1, 10.2], + "vol": [1000.0, 1100.0], + "amount": [10100.0, 11220.0], + "float_shares": [0.0, 0.0], + } + ) + fake = _FakeMacClient(mac_df) + with TestClient(_bars_app(fake)) as client: + resp = client.get( + "/api/v1/bars", params={"market": "SH", "code": "603179", "category": "MIN_1", "count": 2} + ) + assert resp.status_code == 200 + rows = resp.json()["data"] + assert len(rows) == 2 + for row in rows: + assert "datetime" in row and "date" not in row + assert rows[0]["datetime"] == "2026-08-14T09:31:00" + assert rows[1]["datetime"] == "2026-08-14T09:32:00" + + +def test_bars_day_endpoint_returns_date(): + """端到端对照:/bars DAY 仍返回 date 列(00:00:00),确认修复无回归。""" + pytest.importorskip("fastapi") + import pandas as pd + from fastapi.testclient import TestClient + + mac_df = pd.DataFrame( + { + "datetime": [pd.Timestamp("2026-08-14 15:00:00")], + "open": [10.0], + "high": [10.2], + "low": [9.9], + "close": [10.1], + "vol": [1000.0], + "amount": [10100.0], + "float_shares": [0.0], + } + ) + fake = _FakeMacClient(mac_df) + with TestClient(_bars_app(fake)) as client: + resp = client.get( + "/api/v1/bars", params={"market": "SH", "code": "603179", "category": "DAY", "count": 1} + ) + assert resp.status_code == 200 + rows = resp.json()["data"] + assert len(rows) == 1 + assert "date" in rows[0] and "datetime" not in rows[0] + assert rows[0]["date"] == "2026-08-14T00:00:00" + + def test_full_app_routes_registered(): """All routers should be mounted and accessible.""" pytest.importorskip("fastapi")