fix(web): /bars MIN_1 被误判为日线,datetime 归一化为 date 00:00:00(issue #49)

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 客户端,在未修复代码上失败、修复后通过)。
This commit is contained in:
GitHub
2026-08-19 18:19:07 +08:00
parent e533b705d4
commit bcaee1404d
2 changed files with 124 additions and 3 deletions
+13 -3
View File
@@ -8,7 +8,7 @@ from typing import Any
import pandas as pd import pandas as pd
from fastapi import APIRouter, Depends, Query 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 ( from easy_tdx.web.convert import (
adjust_from_str, adjust_from_str,
category_from_str, category_from_str,
@@ -31,6 +31,16 @@ def _df_resp(df: Any) -> DataFrameResponse:
return DataFrameResponse.from_dataframe(df) 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: def _normalize_mac_df(df: pd.DataFrame, daily_plus: bool) -> pd.DataFrame:
"""规整 MacClient.get_stock_kline 的输出以匹配旧 /bars 契约。 """规整 MacClient.get_stock_kline 的输出以匹配旧 /bars 契约。
@@ -99,8 +109,8 @@ async def security_bars(
adjust=adjust_from_str(adjust), adjust=adjust_from_str(adjust),
bar_time=bar_time, bar_time=bar_time,
) )
# daily_plus:日线及以上周期DAY=4 及以上)datetime→date # daily_plus:日线及以上周期 datetime→date(枚举值无序,显式查表判定)
df = _normalize_mac_df(df, daily_plus=int(cat) >= int(KlineCategory.DAY)) df = _normalize_mac_df(df, daily_plus=_is_daily_plus(cat))
else: else:
# MAC 不可用:回退标准 TdxClient(无复权),adjust 参数忽略 # MAC 不可用:回退标准 TdxClient(无复权),adjust 参数忽略
_logger.warning( _logger.warning(
+111
View File
@@ -385,6 +385,117 @@ def test_normalize_mac_df_empty_noop():
assert out.empty 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(): 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")