From 349f44f9599c1242e0bae0b5107d266af612d736 Mon Sep 17 00:00:00 2001 From: Harveyliu007 <36503948+Harveyliu007@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:53:51 +0800 Subject: [PATCH] =?UTF-8?q?fix(ex):=20ex=20tick=20--date=20=E6=8E=A5?= =?UTF-8?q?=E5=8F=97=20YYYYMMDD=20=E6=95=B4=E6=95=B0=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20AttributeError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + src/easy_tdx/cli/cmd_ex.py | 4 +- src/easy_tdx/ex/mac_client.py | 39 ++-- tests/unit/test_ex_tick_chart_date.py | 258 ++++++++++++++++++++++++++ 4 files changed, 292 insertions(+), 13 deletions(-) create mode 100644 tests/unit/test_ex_tick_chart_date.py diff --git a/.gitignore b/.gitignore index 2861101..5a24e38 100644 --- a/.gitignore +++ b/.gitignore @@ -26,5 +26,9 @@ src/easy_tdx/exchange_margin.py # 本地文件,不递交到 GitHub CLAUDE.md +# 本机包管理缓存(沙箱环境下避开 AppData 路径,落在项目内) +.npm-cache/ +.uv-cache/ + # 代码审计报告(本地产物,不入库) audit-report-*.html diff --git a/src/easy_tdx/cli/cmd_ex.py b/src/easy_tdx/cli/cmd_ex.py index 125b56b..3628105 100644 --- a/src/easy_tdx/cli/cmd_ex.py +++ b/src/easy_tdx/cli/cmd_ex.py @@ -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) diff --git a/src/easy_tdx/ex/mac_client.py b/src/easy_tdx/ex/mac_client.py index 8b9b266..5803a40 100644 --- a/src/easy_tdx/ex/mac_client.py +++ b/src/easy_tdx/ex/mac_client.py @@ -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 视为 YYYYMMDD,date 原样透传,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, ) diff --git a/tests/unit/test_ex_tick_chart_date.py b/tests/unit/test_ex_tick_chart_date.py new file mode 100644 index 0000000..8606d39 --- /dev/null +++ b/tests/unit/test_ex_tick_chart_date.py @@ -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 统一接受 int(YYYYMMDD)/ 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 日期应转换后传给 SymbolTransactionCmd(0x122F)。""" + 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