Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,9 @@ src/easy_tdx/exchange_margin.py
# 本地文件,不递交到 GitHub
CLAUDE.md

# 本机包管理缓存(沙箱环境下避开 AppData 路径,落在项目内)
.npm-cache/
.uv-cache/

# 代码审计报告(本地产物,不入库)
audit-report-*.html
4 changes: 3 additions & 1 deletion src/easy_tdx/cli/cmd_ex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)


Expand Down
39 changes: 27 additions & 12 deletions src/easy_tdx/ex/mac_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@
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]] = []
Expand Down Expand Up @@ -408,7 +419,7 @@ def goods_tick_chart(
self,
market: int,
code: str,
query_date: date | None = None,
query_date: int | date | None = None,
) -> pd.DataFrame:
"""获取单日分时图。

Expand All @@ -418,10 +429,12 @@ def goods_tick_chart(
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)

Expand Down Expand Up @@ -453,7 +466,7 @@ def goods_transaction(
self,
market: int,
code: str,
query_date: date | None = None,
query_date: int | date | None = None,
start: int = 0,
count: int = 2000,
) -> pd.DataFrame:
Expand Down Expand Up @@ -486,13 +499,13 @@ def goods_transaction(
"""
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,
)
Expand Down Expand Up @@ -824,9 +837,11 @@ async def goods_tick_chart(
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)

Expand All @@ -849,7 +864,7 @@ async def goods_transaction(
self,
market: int,
code: str,
query_date: date | None = None,
query_date: int | date | None = None,
start: int = 0,
count: int = 2000,
) -> pd.DataFrame:
Expand All @@ -859,13 +874,13 @@ async def goods_transaction(
"""
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,
)
Expand Down
258 changes: 258 additions & 0 deletions tests/unit/test_ex_tick_chart_date.py
Original file line number Diff line number Diff line change
@@ -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