fix(ex): ex tick --date 接受 YYYYMMDD 整数,修复 AttributeError

This commit is contained in:
Harveyliu007
2026-08-28 19:53:51 +08:00
parent 31599a6e13
commit 349f44f959
4 changed files with 292 additions and 13 deletions
+4
View File
@@ -26,5 +26,9 @@ src/easy_tdx/exchange_margin.py
# 本地文件,不递交到 GitHub
CLAUDE.md
# 本机包管理缓存(沙箱环境下避开 AppData 路径,落在项目内)
.npm-cache/
.uv-cache/
# 代码审计报告(本地产物,不入库)
audit-report-*.html
+3 -1
View File
@@ -150,6 +150,8 @@ def tick(
easy-tdx ex tick HK_MAIN_BOARD 00700
easy-tdx ex tick US_STOCK AAPL --table
easy-tdx ex tick US_STOCK TSLA --date 20260827 --table
"""
from .conn import get_mac_ex_client
from .output import print_output
@@ -158,7 +160,7 @@ def tick(
fmt = "table" if use_table else output_fmt
mkt = parse_ex_market(market)
with get_mac_ex_client() as client:
df = client.goods_tick_chart(mkt, code, query_date=date) # type: ignore[arg-type]
df = client.goods_tick_chart(mkt, code, query_date=date)
print_output(df, fmt)
+27 -12
View File
@@ -51,6 +51,17 @@ _T = TypeVar("_T")
logger = logging.getLogger(__name__)
def _coerce_query_date(query_date: int | date | None) -> date | None:
"""把查询日期统一为 date 对象。
兼容两种调用方式(与 A 股 MacClient.get_tick_chart 的 YYYYMMDD 整数
语义保持一致):int 视为 YYYYMMDDdate 原样透传,None 表示今天。
"""
if query_date is None or isinstance(query_date, date):
return query_date
return date(query_date // 10000, (query_date % 10000) // 100, query_date % 100)
def _quotes_to_df(result: list[MacQuoteField]) -> pd.DataFrame:
"""将 MacQuoteField 列表展开为 DataFrame。"""
rows: list[dict[str, Any]] = []
@@ -408,7 +419,7 @@ class MacExClient:
self,
market: int,
code: str,
query_date: date | None = None,
query_date: int | date | None = None,
) -> pd.DataFrame:
"""获取单日分时图。
@@ -418,10 +429,12 @@ class MacExClient:
ExMarket 枚举值。
code : str
证券代码。
query_date : date | None
查询日期,None 表示今天。
query_date : int | date | None
查询日期date 对象或 YYYYMMDD 整数None 表示今天。
"""
cmd = SymbolTickChartCmd(market=market, code=code, query_date=query_date)
cmd = SymbolTickChartCmd(
market=market, code=code, query_date=_coerce_query_date(query_date)
)
result = self._execute(cmd)
return _to_df(result)
@@ -453,7 +466,7 @@ class MacExClient:
self,
market: int,
code: str,
query_date: date | None = None,
query_date: int | date | None = None,
start: int = 0,
count: int = 2000,
) -> pd.DataFrame:
@@ -486,13 +499,13 @@ class MacExClient:
"""
if is_hk_stock_market(market):
result = _fetch_hk_transactions_sync(
self._execute, market, code, query_date, start, count
self._execute, market, code, _coerce_query_date(query_date), start, count
)
return _to_df(result)
cmd = SymbolTransactionCmd(
market=market,
code=code,
query_date=query_date,
query_date=_coerce_query_date(query_date),
start=start,
count=count,
)
@@ -824,9 +837,11 @@ class AsyncMacExClient(AsyncHeartbeatMixin):
self,
market: int,
code: str,
query_date: date | None = None,
query_date: int | date | None = None,
) -> pd.DataFrame:
cmd = SymbolTickChartCmd(market=market, code=code, query_date=query_date)
cmd = SymbolTickChartCmd(
market=market, code=code, query_date=_coerce_query_date(query_date)
)
result = await self._execute(cmd)
return _to_df(result)
@@ -849,7 +864,7 @@ class AsyncMacExClient(AsyncHeartbeatMixin):
self,
market: int,
code: str,
query_date: date | None = None,
query_date: int | date | None = None,
start: int = 0,
count: int = 2000,
) -> pd.DataFrame:
@@ -859,13 +874,13 @@ class AsyncMacExClient(AsyncHeartbeatMixin):
"""
if is_hk_stock_market(market):
result = await _fetch_hk_transactions_async(
self._execute, market, code, query_date, start, count
self._execute, market, code, _coerce_query_date(query_date), start, count
)
return _to_df(result)
cmd = SymbolTransactionCmd(
market=market,
code=code,
query_date=query_date,
query_date=_coerce_query_date(query_date),
start=start,
count=count,
)
+258
View File
@@ -0,0 +1,258 @@
"""ex 分时/成交查询日期类型的回归测试。
issue 修复:'easy-tdx ex tick US_STOCK TSLA --date 20260827' 曾因 CLI 传入
'int'YYYYMMDD)而 goods_tick_chart 只接受 datetime.date,直接抛
AttributeError: 'int' object has no attribute 'year'cmd_ex.py 里还遗留
type: ignore[arg-type])。
修复方式:MacExClient / AsyncMacExClient 的 goods_tick_chart /
goods_transaction 统一接受 intYYYYMMDD/ date / None,内部经
_coerce_query_date 归一为 date,与 A 股 MacClient.get_tick_chart 的
YYYYMMDD 整数语义保持一致。
"""
from __future__ import annotations
from datetime import date
import pandas as pd
import pytest
from click.testing import CliRunner
from easy_tdx.ex.mac_client import MacExClient, _coerce_query_date
# ---------------------------------------------------------------------------
# 1. _coerce_query_date 纯函数
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("raw,expected",
[
(20260827, date(2026, 8, 27)),
(20250101, date(2025, 1, 1)),
(20260103, date(2026, 1, 3)), # 月份/日期前导零按整数解析
(date(2026, 8, 27), date(2026, 8, 27)), # date 原样透传
(None, None),
],
)
def test_coerce_query_date(raw: int | date | None, expected: date | None) -> None:
assert _coerce_query_date(raw) == expected
# ---------------------------------------------------------------------------
# 2. MacExClient 同步客户端
# ---------------------------------------------------------------------------
def _fake_sync_client(captured: list) -> MacExClient:
"""构造不联网的 MacExClient(仅替换 _execute,记录命令)。"""
def fake_execute(cmd):
captured.append(cmd)
return []
client = object.__new__(MacExClient)
client._execute = fake_execute # type: ignore[method-assign]
return client
def test_goods_tick_chart_int_date() -> None:
"""int YYYYMMDD 应被转换为 date 后传给 SymbolTickChartCmd。"""
from easy_tdx.mac.models import MacTickChart
captured: list = []
def fake_execute(cmd):
captured.append(cmd)
return MacTickChart(
market=74,
code="TSLA",
name="Tesla",
pre_close=0.0,
open=0.0,
high=0.0,
low=0.0,
close=0.0,
vol=0,
amount=0.0,
turnover=0.0,
avg=0.0,
charts=[],
)
client = object.__new__(MacExClient)
client._execute = fake_execute # type: ignore[method-assign]
df = client.goods_tick_chart(74, "TSLA", query_date=20260827)
assert len(captured) == 1
assert captured[0]._ymd == 20260827 # 归一为 date 后再编码为 YYYYMMDD
assert len(df) == 1 and df.iloc[0]["code"] == "TSLA" # 正常转 DataFrame
def test_goods_tick_chart_date_and_none() -> None:
"""date 对象与 None 保持原语义(不回归)。"""
captured: list = []
client = _fake_sync_client(captured)
client.goods_tick_chart(74, "TSLA", query_date=date(2026, 8, 27))
assert captured[-1]._ymd == 20260827
client.goods_tick_chart(74, "TSLA")
assert captured[-1]._ymd == 0 # None → 今天(协议 0
def test_goods_transaction_int_date_non_hk() -> None:
"""非港股市场(美股 74):int 日期应转换后传给 SymbolTransactionCmd0x122F)。"""
captured: list = []
client = _fake_sync_client(captured)
client.goods_transaction(74, "TSLA", query_date=20260827, count=10)
assert len(captured) == 1
assert captured[0]._ymd == 20260827
def test_goods_transaction_int_date_hk() -> None:
"""港股市场(31):int 日期应转换后走 ex 历史逐笔协议(GetExHistoryTransactionDataCmd)。"""
from easy_tdx.ex.commands.get_transaction import GetExHistoryTransactionDataCmd
captured: list = []
client = _fake_sync_client(captured)
client.goods_transaction(31, "00700", query_date=20260827, count=100)
assert captured
assert all(isinstance(c, GetExHistoryTransactionDataCmd) for c in captured)
assert captured[0].date == 20260827
# ---------------------------------------------------------------------------
# 3. AsyncMacExClient 异步客户端
# ---------------------------------------------------------------------------
async def test_async_goods_tick_chart_int_date() -> None:
"""异步版同样接受 int YYYYMMDD。"""
from easy_tdx.ex.mac_client import AsyncMacExClient
from easy_tdx.mac.models import MacTickChart
captured: list = []
async def fake_execute(cmd):
captured.append(cmd)
return MacTickChart(
market=74,
code="TSLA",
name="Tesla",
pre_close=0.0,
open=0.0,
high=0.0,
low=0.0,
close=0.0,
vol=0,
amount=0.0,
turnover=0.0,
avg=0.0,
charts=[],
)
client = object.__new__(AsyncMacExClient)
client._execute = fake_execute # type: ignore[method-assign]
df = await client.goods_tick_chart(74, "TSLA", query_date=20260827)
assert len(captured) == 1
assert captured[0]._ymd == 20260827
assert len(df) == 1 and df.iloc[0]["code"] == "TSLA"
async def test_async_goods_transaction_int_date() -> None:
"""异步逐笔成交:非港股市场 int 日期转换。"""
from easy_tdx.ex.mac_client import AsyncMacExClient
captured: list = []
async def fake_execute(cmd):
captured.append(cmd)
return []
client = object.__new__(AsyncMacExClient)
client._execute = fake_execute # type: ignore[method-assign]
await client.goods_transaction(74, "TSLA", query_date=20260827, count=10)
assert len(captured) == 1
assert captured[0]._ymd == 20260827
# ---------------------------------------------------------------------------
# 4. CLI 回归:easy-tdx ex tick --date
# ---------------------------------------------------------------------------
class _FakeMacExClient:
"""假客户端:记录 goods_tick_chart 收到的 query_date。"""
def __init__(self, received: dict) -> None:
self._received = received
def goods_tick_chart(self, market: int, code: str, query_date=None) -> pd.DataFrame:
self._received["query_date"] = query_date
return pd.DataFrame([{"market": market, "code": code}])
def connect(self) -> None:
pass
def close(self) -> None:
pass
class _FakeCtx:
def __init__(self, received: dict) -> None:
self._received = received
def __enter__(self) -> _FakeMacExClient:
return _FakeMacExClient(self._received)
def __exit__(self, *args) -> bool:
return False
def _patch_conn(monkeypatch: pytest.MonkeyPatch, received: dict) -> None:
import easy_tdx.cli.conn as conn_mod
monkeypatch.setattr(conn_mod, "get_mac_ex_client", lambda: _FakeCtx(received))
def test_cli_ex_tick_with_date(monkeypatch: pytest.MonkeyPatch) -> None:
"""回归:--date 20260827 不再抛 AttributeError。
CLI 层透传 YYYYMMDD 整数,由 MacExClient.goods_tick_chart 内部归一为
date(与 A 股 MacClient.get_tick_chart 模式一致);转换正确性由上面
的客户端级测试覆盖。
"""
from easy_tdx.cli.cmd_ex import ex
received: dict = {}
_patch_conn(monkeypatch, received)
runner = CliRunner()
result = runner.invoke(ex, ["tick", "US_STOCK", "TSLA", "--date", "20260827"])
assert result.exit_code == 0, result.output
assert received["query_date"] == 20260827
def test_cli_ex_tick_without_date(monkeypatch: pytest.MonkeyPatch) -> None:
"""不带 --date 时默认 None(今天),不回归。"""
from easy_tdx.cli.cmd_ex import ex
received: dict = {}
_patch_conn(monkeypatch, received)
runner = CliRunner()
result = runner.invoke(ex, ["tick", "US_STOCK", "TSLA"])
assert result.exit_code == 0, result.output
assert received["query_date"] is None