mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 13:24:15 +08:00
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:
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user