diff --git a/CHANGELOG.md b/CHANGELOG.md index ad3f017..e47a215 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ 本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。 +## [1.16.0] — 2026-06-30 + +### 新增 + +- **分钟级 K 线时间戳可选「开始/结束时间」**,一键对齐 Tushare / 同花顺(`_df.py`、`client.py`、`ex/client.py`、`mac/client.py`、`cli/cmd_kline.py`、`web/routers/bars.py`,[Discussion #7](https://github.com/handsomejustin/easy_tdx/discussions/7))— 通达信协议用 bar **开始时间**打时间戳(5min 线上午最后一根标 11:25、下午第一根标 13:00;午休 11:30–13:00 无 bar),而 Tushare / 同花顺 / 聚宽用 bar **结束时间**(标 11:30 / 13:05)。新增 `bar_time` 参数让用户自由切换,避免再自行 `+5 分钟` 偏移。 + - 全部 3 条 K 线路径覆盖:A 股 `get_security_bars` / `get_index_bars`(同步 + 异步)、扩展行情 `get_instrument_bars`(同步 + 异步)、MAC 协议 `get_stock_kline` / `get_stock_kline_with_indicators`(同步 + 异步)。 + - CLI `kline` 新增 `--bar-time {start,end}` 选项;Web `/bars`、`/bars/index` 新增 `bar_time` 查询参数。 + - `bar_time="start"`(**默认**)保持完全向后兼容,行为与 1.15.4 一致;`bar_time="end"` 仅对分钟级周期(1/5/15/30/60min)生效,日线及以上不受影响,自动按周期时长右移并处理跨小时 / 跨日边界。 + - 协议解码层(`codec/datetime_.py`、`symbol_bar.py`)零改动,偏移作为纯展示语义在 client 层后处理,单一工具函数 `_apply_bar_time_align_df` / `_apply_bar_time_align_bars` 复用于全部路径。 + - 已知限制:扩展行情 `get_history_instrument_bars_range`(按日期范围查询)不携带周期信息,传 `"end"` 时发出 warning 原样返回(建议改用 `get_instrument_bars`)。 + - 新增 27 个单元测试(`test_codec_datetime.py` 偏移逻辑 + `test_kline_bar_time.py` 三路径覆盖),全量 700 单测通过。 + ## [1.15.4] — 2026-06-29 ### 修复 diff --git a/docs/api_reference.md b/docs/api_reference.md index ac662d3..5daf88d 100644 --- a/docs/api_reference.md +++ b/docs/api_reference.md @@ -177,7 +177,7 @@ c.get_security_quotes(stocks: list[tuple[Market, str]]) -> list[SecurityQuote] ```python c.get_security_bars(market: Market, code: str, category: KlineCategory, - start: int, count: int = 800) -> list[SecurityBar] + start: int, count: int = 800, *, bar_time: str = "start") -> pd.DataFrame ``` 获取个股 K 线数据。 @@ -189,15 +189,22 @@ c.get_security_bars(market: Market, code: str, category: KlineCategory, | category | `KlineCategory` | K 线周期 | | start | `int` | 分页偏移(0 为最新) | | count | `int` | 请求数量(最多 800) | +| bar_time | `str` | 时间戳语义,见下方说明 | + +**bar_time(分钟级周期时间戳对齐)**:通达信协议默认用 bar **开始时间**打时间戳 +(5min 线上午最后一根标 11:25、下午第一根标 13:00;午休 11:30–13:00 无 bar)。 +传 `bar_time="end"` 切换为 bar **右端点**(= 开始 + 周期时长,标 11:30/13:05), +对齐 Tushare / 同花顺 / 聚宽约定。仅对分钟级周期(MIN_1/5/15/30/60)生效, +日线及以上不受影响。默认 `"start"` 保持完全向后兼容。 ### get_index_bars ```python c.get_index_bars(market: Market, code: str, category: KlineCategory, - start: int, count: int = 800) -> list[SecurityBar] + start: int, count: int = 800, *, bar_time: str = "start") -> pd.DataFrame ``` -获取指数 K 线数据。参数同 `get_security_bars`。 +获取指数 K 线数据。参数(含 `bar_time`)同 `get_security_bars`。 **常用指数**: | 指数 | market | code | diff --git a/examples/03_kline/security_bars.py b/examples/03_kline/security_bars.py index 72accbe..ac3dc76 100644 --- a/examples/03_kline/security_bars.py +++ b/examples/03_kline/security_bars.py @@ -34,6 +34,12 @@ KlineCategory 枚举所有值: vol : float64 -- 成交量(股) amount : float64 -- 成交额(元) +bar_time 参数(仅分钟级周期): + bar_time="start"(默认)-- datetime 标 bar 开始时间(通达信原始约定)。 + 例:5min 线上午最后一根标 11:25、下午第一根标 13:00;午休 11:30–13:00 无 bar。 + bar_time="end" -- datetime 标 bar 右端点(= 开始 + 周期时长),对齐 + Tushare / 同花顺 / 聚宽。例:上午最后一根标 11:30、下午第一根标 13:05。 + 使用客户端:TdxClient(同步) 关键参数: market : Market 枚举 @@ -52,6 +58,18 @@ with TdxClient.from_best_host() as c: print("江特电机 日K线:") print(df.to_string(index=False)) + # 5 分钟线:默认 bar_time="start"(通达信原始,上午最后一根标 11:25) + df5_start = c.get_security_bars(Market.SZ, "002176", KlineCategory.MIN_5, 0, 5) + print("\n江特电机 5分钟线 (bar_time=start,默认):") + print(df5_start.to_string(index=False)) + + # 5 分钟线:bar_time="end" 对齐 Tushare(上午最后一根标 11:30) + df5_end = c.get_security_bars( + Market.SZ, "002176", KlineCategory.MIN_5, 0, 5, bar_time="end" + ) + print("\n江特电机 5分钟线 (bar_time=end,对齐 Tushare):") + print(df5_end.to_string(index=False)) + # 运行结果: # 江特电机 日K线: # date open close high low vol amount diff --git a/pyproject.toml b/pyproject.toml index 6e9449f..665655e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "easy-tdx" -version = "1.15.4" +version = "1.16.0" description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步" readme = "README.md" requires-python = ">=3.10" diff --git a/src/easy_tdx/_df.py b/src/easy_tdx/_df.py index 9fe80ce..9dc7976 100644 --- a/src/easy_tdx/_df.py +++ b/src/easy_tdx/_df.py @@ -2,11 +2,51 @@ from __future__ import annotations -from dataclasses import asdict, is_dataclass +import logging +from dataclasses import asdict, is_dataclass, replace from typing import Any import pandas as pd +logger = logging.getLogger(__name__) + +# K 线时间戳语义:通达信用 bar 开始时间,Tushare/同花顺用 bar 结束时间。 +# bar_time="end" 时给分钟级 bar 的时刻加上一个周期时长,以对齐 Tushare。 +_BAR_TIME_START = "start" +_BAR_TIME_END = "end" + +# A 股 / 扩展行情 KlineCategory → 每根 bar 的分钟数(分钟级;日线及以上不在此表)。 +# category: 0=MIN_5 1=MIN_15 2=MIN_30 3=MIN_60 7=MIN_1 8=MIN_3。 +_CATEGORY_MINUTES: dict[int, int] = {0: 5, 1: 15, 2: 30, 3: 60, 7: 1, 8: 3} + + +def _category_to_minutes(category: int) -> int | None: + """分钟级 KlineCategory → 每根 bar 的分钟数;日线及以上返回 None。""" + return _CATEGORY_MINUTES.get(int(category)) + + +def _period_to_minutes(period: int, times: int = 1) -> int | None: + """MAC 协议 Period → 每根 bar 的分钟数。 + + MINS / SECONDS 配合 times 倍数;日线及以上 / 秒级(按分钟粒度对齐无意义)返回 None。 + """ + # 与 symbol_bar.py 的 is_intraday 判定保持一致 + _MAC_INTRADAY_MINUTES: dict[int, int] = { + 0: 5, # MIN_5 + 1: 15, # MIN_15 + 2: 30, # MIN_30 + 3: 60, # MIN_60 + 7: 1, # MIN_1 + 8: 5, # MINS(×times) + } + base = _MAC_INTRADAY_MINUTES.get(int(period)) + if base is None: + # 4=DAILY 5=WEEKLY 6=MONTHLY 9=DAYS 10=QUARTERLY 11=YEARLY 13=SECONDS 均不偏移 + return None + if int(period) == 8: # MINS:多分钟,乘以倍数 + return base * max(int(times), 1) + return base + def _to_df(data: Any) -> pd.DataFrame: """将 list[dataclass] 或单个 dataclass 转为 DataFrame。 @@ -46,6 +86,80 @@ def _merge_datetime_fields(d: dict[str, Any]) -> dict[str, Any]: return d +def _align_minutes_df(df: pd.DataFrame, delta_minutes: int) -> pd.DataFrame: + """对含 hour/minute 列的 DataFrame 做分钟级偏移(向量化,自动跨小时/跨日)。 + + 用于 A 股 / 扩展行情路径:在 _merge_bar_datetime 拼字符串之前修正 hour/minute。 + """ + total = df["hour"] * 60 + df["minute"] + delta_minutes + df = df.copy() + df["hour"] = (total // 60) % 24 + df["minute"] = total % 60 + return df + + +def _align_datetime_df(df: pd.DataFrame, delta_minutes: int) -> pd.DataFrame: + """对含 datetime 列的 DataFrame 做分钟级偏移(MAC 路径用)。""" + if "datetime" not in df.columns: + return df + df = df.copy() + df["datetime"] = df["datetime"] + pd.Timedelta(minutes=delta_minutes) + return df + + +def _apply_bar_time_align_df( + df: pd.DataFrame, + *, + is_intraday: bool, + delta_minutes: int | None, + bar_time: str, + has_time_columns: bool, +) -> pd.DataFrame: + """对 K 线 DataFrame 应用 bar 时间对齐。 + + Args: + is_intraday: 是否分钟级周期(False 时恒不偏移)。 + delta_minutes: 每根 bar 的分钟数(None 或分钟级判定为 False 时不偏移)。 + bar_time: "start"(默认,通达信原始)或 "end"(右端点,对齐 Tushare)。 + has_time_columns: True=DataFrame 仍是分散的 hour/minute 列(A 股路径, + 在 _merge_bar_datetime 之前调用);False=已是 datetime 列(MAC 路径)。 + """ + if bar_time == _BAR_TIME_START: + return df + if not is_intraday or delta_minutes is None or delta_minutes <= 0: + return df + if df.empty: + return df + if has_time_columns: + if "hour" not in df.columns or "minute" not in df.columns: + return df + return _align_minutes_df(df, delta_minutes) + return _align_datetime_df(df, delta_minutes) + + +def _apply_bar_time_align_bars( + bars: list[Any], + *, + is_intraday: bool, + delta_minutes: int | None, + bar_time: str, +) -> list[Any]: + """对 K 线 dataclass 列表应用 bar 时间对齐(扩展行情 ex client 用,返回 dataclass)。 + + 用 dataclasses.replace 重建(保持 dataclass 不可变语义),跨小时自动进位; + 收盘 bar 不会跨日,故不处理跨日。 + """ + if bar_time == _BAR_TIME_START: + return bars + if not is_intraday or delta_minutes is None or delta_minutes <= 0: + return bars + result: list[Any] = [] + for b in bars: + total = b.hour * 60 + b.minute + delta_minutes + result.append(replace(b, hour=(total // 60) % 24, minute=total % 60)) + return result + + def _merge_bar_datetime(df: pd.DataFrame, daily_plus: bool) -> pd.DataFrame: """根据 K 线周期将 SecurityBar 的分散字段合并为 date 或 datetime。 diff --git a/src/easy_tdx/cli/cmd_kline.py b/src/easy_tdx/cli/cmd_kline.py index f43ced6..5a7afae 100644 --- a/src/easy_tdx/cli/cmd_kline.py +++ b/src/easy_tdx/cli/cmd_kline.py @@ -14,6 +14,13 @@ import click @click.option("--count", default=800, type=int, help="K线数量") @click.option("--start", default=0, type=int, help="起始偏移(0=最新)") @click.option("--adjust", default="NONE", help="复权: NONE/QFQ/HFQ") +@click.option( + "--bar-time", + "bar_time", + type=click.Choice(["start", "end"]), + default="start", + help="K线时间戳: start=bar开始时间(通达信原始,默认) / end=bar结束时间(对齐Tushare,仅分钟级)", +) @click.option("--table", "use_table", is_flag=True, help="表格输出") @click.option("--output", "output_fmt", type=click.Choice(["json", "table", "csv"]), default="json") def kline( @@ -23,6 +30,7 @@ def kline( count: int, start: int, adjust: str, + bar_time: str, use_table: bool, output_fmt: str, ) -> None: @@ -35,6 +43,10 @@ def kline( easy-tdx kline SH 600519 --adjust QFQ --count 30 easy-tdx kline SZ 000001 --period 5MIN --table + + 时间戳语义:通达信默认用 bar 开始时间(上午最后一根 5min 标 11:25、 + 下午第一根标 13:00)。加 ``--bar-time end`` 切换为右端点(标 11:30/13:05), + 与 Tushare/同花顺对齐。仅对分钟级周期生效。 """ from .conn import get_mac_client from .output import print_output @@ -50,5 +62,6 @@ def kline( start=start, count=count, adjust=parse_adjust(adjust), + bar_time=bar_time, ) print_output(df, fmt) diff --git a/src/easy_tdx/client.py b/src/easy_tdx/client.py index d6860eb..cd1f2da 100644 --- a/src/easy_tdx/client.py +++ b/src/easy_tdx/client.py @@ -14,7 +14,14 @@ from zoneinfo import ZoneInfo import pandas as pd -from ._df import _add_minute_datetime, _merge_bar_datetime, _merge_txn_datetime, _to_df +from ._df import ( + _add_minute_datetime, + _apply_bar_time_align_df, + _category_to_minutes, + _merge_bar_datetime, + _merge_txn_datetime, + _to_df, +) from .codec.block import parse_block_dat from .codec.financial import parse_financial_dat, parse_financial_file_list from .codec.industry import parse_tdxhy_cfg @@ -446,10 +453,28 @@ class TdxClient: category: KlineCategory, start: int, count: int = 800, + *, + bar_time: str = "start", ) -> pd.DataFrame: - """获取 K 线数据(最多800条/次,按 start 分页)。""" + """获取 K 线数据(最多800条/次,按 start 分页)。 + + Args: + bar_time: 时间戳语义。 ``"start"``(默认)= bar 开始时间(通达信原始, + 上午最后一根 5min 标 11:25、下午第一根标 13:00);``"end"`` = bar 右端点 + (= 开始 + 周期时长,与 Tushare/同花顺对齐,上午最后一根标 11:30)。 + 仅对分钟级周期生效;日线及以上不受影响。 + """ df = _to_df(self._execute(GetSecurityBarsCmd(market, code, category, start, count))) - return _merge_bar_datetime(df, category in _DAILY_PLUS) + delta = _category_to_minutes(int(category)) + is_intraday = delta is not None + df = _apply_bar_time_align_df( + df, + is_intraday=is_intraday, + delta_minutes=delta, + bar_time=bar_time, + has_time_columns=True, + ) + return _merge_bar_datetime(df, not is_intraday) def get_index_bars( self, @@ -458,10 +483,25 @@ class TdxClient: category: KlineCategory, start: int, count: int = 800, + *, + bar_time: str = "start", ) -> pd.DataFrame: - """获取指数 K 线数据。""" + """获取指数 K 线数据。 + + Args: + bar_time: 见 :meth:`get_security_bars`,分钟级周期时间戳可对齐 Tushare 右端点。 + """ df = _to_df(self._execute(GetIndexBarsCmd(market, code, category, start, count))) - return _merge_bar_datetime(df, category in _DAILY_PLUS) + delta = _category_to_minutes(int(category)) + is_intraday = delta is not None + df = _apply_bar_time_align_df( + df, + is_intraday=is_intraday, + delta_minutes=delta, + bar_time=bar_time, + has_time_columns=True, + ) + return _merge_bar_datetime(df, not is_intraday) # ------------------------------------------------------------------ # # 分时 @@ -997,9 +1037,21 @@ class AsyncTdxClient: category: KlineCategory, start: int, count: int = 800, + *, + bar_time: str = "start", ) -> pd.DataFrame: + """获取 K 线数据。``bar_time`` 见同步版 :meth:`get_security_bars`。""" df = _to_df(await self._execute(GetSecurityBarsCmd(market, code, category, start, count))) - return _merge_bar_datetime(df, category in _DAILY_PLUS) + delta = _category_to_minutes(int(category)) + is_intraday = delta is not None + df = _apply_bar_time_align_df( + df, + is_intraday=is_intraday, + delta_minutes=delta, + bar_time=bar_time, + has_time_columns=True, + ) + return _merge_bar_datetime(df, not is_intraday) async def get_index_bars( self, @@ -1008,9 +1060,21 @@ class AsyncTdxClient: category: KlineCategory, start: int, count: int = 800, + *, + bar_time: str = "start", ) -> pd.DataFrame: + """获取指数 K 线数据。``bar_time`` 见同步版 :meth:`get_index_bars`。""" df = _to_df(await self._execute(GetIndexBarsCmd(market, code, category, start, count))) - return _merge_bar_datetime(df, category in _DAILY_PLUS) + delta = _category_to_minutes(int(category)) + is_intraday = delta is not None + df = _apply_bar_time_align_df( + df, + is_intraday=is_intraday, + delta_minutes=delta, + bar_time=bar_time, + has_time_columns=True, + ) + return _merge_bar_datetime(df, not is_intraday) async def get_minute_time_data(self, market: Market, code: str) -> pd.DataFrame: today = _today_in_shanghai() diff --git a/src/easy_tdx/ex/client.py b/src/easy_tdx/ex/client.py index 1802408..d6e22e5 100644 --- a/src/easy_tdx/ex/client.py +++ b/src/easy_tdx/ex/client.py @@ -1,10 +1,12 @@ """扩展行情高层 API:ExTdxClient(同步)和 AsyncExTdxClient(asyncio)。""" import asyncio +import logging from collections import OrderedDict from types import TracebackType from typing import TypeVar +from .._df import _apply_bar_time_align_bars, _category_to_minutes from ..commands.base import BaseCommand from ..config import get_best_ex_host, get_ex_hosts, save_best_ex_host from ..exceptions import TdxConnectionError @@ -34,6 +36,8 @@ from .models import ( from .transport.async_ import AsyncExTdxConnection from .transport.sync import ExTdxConnection, ping_ex_all +logger = logging.getLogger(__name__) + _DEFAULT_EX_PORT = 7727 _T = TypeVar("_T") @@ -169,9 +173,20 @@ class ExTdxClient: code: str, start: int = 0, count: int = 700, + *, + bar_time: str = "start", ) -> list[ExInstrumentBar]: - """获取K线数据。""" - return self._execute(GetExInstrumentBarsCmd(category, market, code, start, count)) + """获取K线数据。 + + Args: + bar_time: 时间戳语义。 ``"start"``(默认)= bar 开始时间(通达信原始); + ``"end"`` = bar 右端点(与 Tushare/同花顺对齐)。仅分钟级周期生效。 + """ + bars = self._execute(GetExInstrumentBarsCmd(category, market, code, start, count)) + delta = _category_to_minutes(category) + return _apply_bar_time_align_bars( + bars, is_intraday=delta is not None, delta_minutes=delta, bar_time=bar_time + ) def get_history_instrument_bars_range( self, @@ -179,9 +194,23 @@ class ExTdxClient: code: str, start_date: int, end_date: int, + *, + bar_time: str = "start", ) -> list[ExInstrumentBar]: - """按日期范围获取历史K线。""" - return self._execute(GetExHistoryInstrumentBarsRangeCmd(market, code, start_date, end_date)) + """按日期范围获取历史K线。 + + Note: + ``bar_time="end"`` 需要知道每根 bar 的周期时长,但本接口按日期范围查询、 + 不携带周期信息,无法推断。传入 ``"end"`` 时发出 warning 并原样返回(通达信 + 原始开始时间)。如需对齐 Tushare,请改用 :meth:`get_instrument_bars`。 + """ + bars = self._execute(GetExHistoryInstrumentBarsRangeCmd(market, code, start_date, end_date)) + if bar_time == "end": + logger.warning( + "get_history_instrument_bars_range 不支持 bar_time='end'(缺少周期信息)," + "原样返回通达信开始时间。" + ) + return bars # ------------------------------------------------------------------ # # 分时 @@ -392,8 +421,15 @@ class AsyncExTdxClient: code: str, start: int = 0, count: int = 700, + *, + bar_time: str = "start", ) -> list[ExInstrumentBar]: - return await self._execute(GetExInstrumentBarsCmd(category, market, code, start, count)) + """获取K线数据。``bar_time`` 见同步版 :meth:`get_instrument_bars`。""" + bars = await self._execute(GetExInstrumentBarsCmd(category, market, code, start, count)) + delta = _category_to_minutes(category) + return _apply_bar_time_align_bars( + bars, is_intraday=delta is not None, delta_minutes=delta, bar_time=bar_time + ) async def get_history_instrument_bars_range( self, @@ -401,10 +437,19 @@ class AsyncExTdxClient: code: str, start_date: int, end_date: int, + *, + bar_time: str = "start", ) -> list[ExInstrumentBar]: - return await self._execute( + """按日期范围获取历史K线。``bar_time`` 见同步版(不支持 ``"end"``)。""" + bars = await self._execute( GetExHistoryInstrumentBarsRangeCmd(market, code, start_date, end_date) ) + if bar_time == "end": + logger.warning( + "get_history_instrument_bars_range 不支持 bar_time='end'(缺少周期信息)," + "原样返回通达信开始时间。" + ) + return bars # ------------------------------------------------------------------ # # 分时 diff --git a/src/easy_tdx/mac/client.py b/src/easy_tdx/mac/client.py index 256f502..3426bcf 100644 --- a/src/easy_tdx/mac/client.py +++ b/src/easy_tdx/mac/client.py @@ -11,7 +11,7 @@ from typing import Any, TypeVar import pandas as pd -from .._df import _to_df +from .._df import _apply_bar_time_align_df, _period_to_minutes, _to_df from ..codec.bitmap import Fields, PresetField from ..commands.base import BaseCommand from ..config import get_best_host, get_mac_hosts, get_port, get_timeout, save_best_host @@ -339,6 +339,8 @@ class MacClient: count: int = 800, times: int = 1, adjust: Adjust = Adjust.NONE, + *, + bar_time: str = "start", ) -> pd.DataFrame: """获取 K 线数据(自动分页,每页最多 700 条)。 @@ -350,6 +352,10 @@ class MacClient: count: 总请求条数。 times: 周期倍数(Period.MINS/DAYS 时有效)。 adjust: 复权方式。 + bar_time: 时间戳语义。 ``"start"``(默认)= bar 开始时间(通达信原始, + 上午最后一根 5min 标 11:25、下午第一根标 13:00);``"end"`` = bar 右端点 + (= 开始 + 周期时长,与 Tushare/同花顺对齐,上午最后一根标 11:30)。 + 仅对分钟级周期生效;日线及以上不受影响。 """ all_bars: list[MacBar] = [] fetched = 0 @@ -376,7 +382,16 @@ class MacClient: if len(bars) < page_size: break - return _to_df(all_bars) + df = _to_df(all_bars) + delta = _period_to_minutes(period, times) + is_intraday = delta is not None + return _apply_bar_time_align_df( + df, + is_intraday=is_intraday, + delta_minutes=delta, + bar_time=bar_time, + has_time_columns=False, + ) def get_stock_kline_with_indicators( self, @@ -387,6 +402,8 @@ class MacClient: count: int = 30, adjust: Adjust = Adjust.QFQ, params: dict[str, dict[str, int | float]] | None = None, + *, + bar_time: str = "start", ) -> pd.DataFrame: """获取 K 线数据并计算技术指标。 @@ -400,11 +417,14 @@ class MacClient: count: 返回条数(默认30)。 adjust: 复权方式(默认前复权)。 params: 可选指标参数覆盖。 + bar_time: 见 :meth:`get_stock_kline`。 """ from ..indicator import compute_indicators fetch_count = max(120 + count, 200) - df = self.get_stock_kline(market, code, period=period, count=fetch_count, adjust=adjust) + df = self.get_stock_kline( + market, code, period=period, count=fetch_count, adjust=adjust, bar_time=bar_time + ) if df.empty: return df return compute_indicators(df, indicators, params, tail=count) @@ -1247,7 +1267,10 @@ class AsyncMacClient: count: int = 800, times: int = 1, adjust: Adjust = Adjust.NONE, + *, + bar_time: str = "start", ) -> pd.DataFrame: + """获取 K 线数据。``bar_time`` 见同步版 :meth:`get_stock_kline`。""" all_bars: list[MacBar] = [] fetched = 0 offset = start @@ -1273,7 +1296,16 @@ class AsyncMacClient: if len(bars) < page_size: break - return _to_df(all_bars) + df = _to_df(all_bars) + delta = _period_to_minutes(period, times) + is_intraday = delta is not None + return _apply_bar_time_align_df( + df, + is_intraday=is_intraday, + delta_minutes=delta, + bar_time=bar_time, + has_time_columns=False, + ) async def get_stock_kline_with_indicators( self, @@ -1284,6 +1316,8 @@ class AsyncMacClient: count: int = 30, adjust: Adjust = Adjust.QFQ, params: dict[str, dict[str, int | float]] | None = None, + *, + bar_time: str = "start", ) -> pd.DataFrame: """获取 K 线数据并计算技术指标(异步)。 @@ -1298,6 +1332,7 @@ class AsyncMacClient: period=period, count=fetch_count, adjust=adjust, + bar_time=bar_time, ) if df.empty: return df diff --git a/src/easy_tdx/web/routers/bars.py b/src/easy_tdx/web/routers/bars.py index 3f18839..8ce51a2 100644 --- a/src/easy_tdx/web/routers/bars.py +++ b/src/easy_tdx/web/routers/bars.py @@ -27,11 +27,14 @@ async def security_bars( ), start: int = Query(0, ge=0), count: int = Query(800, ge=1, le=800), + bar_time: str = Query( + "start", description="时间戳: start=bar开始时间(默认) / end=bar结束时间(对齐Tushare)" + ), client: Any = Depends(get_client), ) -> DataFrameResponse: """获取股票K线数据。""" df = await client.get_security_bars( - market_from_str(market), code, category_from_str(category), start, count + market_from_str(market), code, category_from_str(category), start, count, bar_time=bar_time ) return _df_resp(df) @@ -43,11 +46,14 @@ async def index_bars( category: str = Query("DAY", description="K线周期"), start: int = Query(0, ge=0), count: int = Query(800, ge=1, le=800), + bar_time: str = Query( + "start", description="时间戳: start=bar开始时间(默认) / end=bar结束时间(对齐Tushare)" + ), client: Any = Depends(get_client), ) -> DataFrameResponse: """获取指数K线数据。""" df = await client.get_index_bars( - market_from_str(market), code, category_from_str(category), start, count + market_from_str(market), code, category_from_str(category), start, count, bar_time=bar_time ) return _df_resp(df) diff --git a/tests/unit/test_codec_datetime.py b/tests/unit/test_codec_datetime.py index 69301a9..c67ff01 100644 --- a/tests/unit/test_codec_datetime.py +++ b/tests/unit/test_codec_datetime.py @@ -62,3 +62,49 @@ class TestGetTime: h, mi, pos = get_time(data, 0) assert h == 14 and mi == 30 assert pos == 2 + + +class TestCategoryToMinutes: + """分钟级 KlineCategory → 每根 bar 的分钟数;日线及以上返回 None。""" + + def test_minute_categories(self): + from easy_tdx._df import _category_to_minutes + + # MIN_5/15/30/60/1/3 + assert _category_to_minutes(0) == 5 + assert _category_to_minutes(1) == 15 + assert _category_to_minutes(2) == 30 + assert _category_to_minutes(3) == 60 + assert _category_to_minutes(7) == 1 + assert _category_to_minutes(8) == 3 + + def test_daily_plus_returns_none(self): + from easy_tdx._df import _category_to_minutes + + for cat in (4, 5, 6, 9, 10, 11): # DAY/WEEK/MONTH/YEAR/SEASON/YEAR_ALT + assert _category_to_minutes(cat) is None + + +class TestPeriodToMinutes: + """MAC 协议 Period → 每根 bar 的分钟数。""" + + def test_basic_periods(self): + from easy_tdx._df import _period_to_minutes + + assert _period_to_minutes(0) == 5 # MIN_5 + assert _period_to_minutes(1) == 15 # MIN_15 + assert _period_to_minutes(2) == 30 # MIN_30 + assert _period_to_minutes(3) == 60 # MIN_60 + assert _period_to_minutes(7) == 1 # MIN_1 + + def test_mins_multiplied_by_times(self): + from easy_tdx._df import _period_to_minutes + + assert _period_to_minutes(8, 1) == 5 # MINS ×1 + assert _period_to_minutes(8, 3) == 15 # MINS ×3 = 15 分钟线 + + def test_daily_plus_and_seconds_return_none(self): + from easy_tdx._df import _period_to_minutes + + for p in (4, 5, 6, 9, 10, 11, 13): # DAILY/WEEKLY/MONTHLY/DAYS/QUARTERLY/YEARLY/SECONDS + assert _period_to_minutes(p) is None diff --git a/tests/unit/test_kline_bar_time.py b/tests/unit/test_kline_bar_time.py new file mode 100644 index 0000000..74a19c7 --- /dev/null +++ b/tests/unit/test_kline_bar_time.py @@ -0,0 +1,213 @@ +"""分钟级 K 线时间戳 bar_time(开始/结束时间)对齐的单元测试。 + +通达信协议用 bar 开始时间打时间戳(上午最后一根 5min 标 11:25、下午第一根标 13:00); +bar_time="end" 切换为右端点(标 11:30/13:05),对齐 Tushare / 同花顺。 +""" + +from __future__ import annotations + +import pandas as pd + +from easy_tdx._df import ( + _apply_bar_time_align_bars, + _apply_bar_time_align_df, + _category_to_minutes, +) +from easy_tdx.ex.models import ExInstrumentBar +from easy_tdx.models.bar import SecurityBar + +# --------------------------------------------------------------------------- # +# DataFrame 路径(A 股 security/index bars,含 hour/minute 列) +# --------------------------------------------------------------------------- # + + +def _bars_df(rows: list[tuple[int, int]]) -> pd.DataFrame: + """构造含 year/month/day/hour/minute 的 K 线 DataFrame(模拟 _to_df 输出)。""" + return pd.DataFrame( + [ + { + "open": 10.0, + "close": 10.0, + "high": 10.0, + "low": 10.0, + "vol": 100.0, + "amount": 1000.0, + "year": 2026, + "month": 6, + "day": 30, + "hour": h, + "minute": m, + } + for h, m in rows + ] + ) + + +class TestAlignDfTimeColumns: + def test_start_default_is_noop(self): + df = _bars_df([(11, 25), (13, 0)]) + out = _apply_bar_time_align_df( + df, is_intraday=True, delta_minutes=5, bar_time="start", has_time_columns=True + ) + assert list(out["hour"]) == [11, 13] + assert list(out["minute"]) == [25, 0] + + def test_end_aligns_to_right_endpoint(self): + # 11:25 -> 11:30, 13:00 -> 13:05(5min 线右端点) + df = _bars_df([(11, 25), (13, 0)]) + out = _apply_bar_time_align_df( + df, is_intraday=True, delta_minutes=5, bar_time="end", has_time_columns=True + ) + assert list(out["hour"]) == [11, 13] + assert list(out["minute"]) == [30, 5] + + def test_end_cross_hour(self): + # 9:58 + 5 = 10:03(跨小时进位) + df = _bars_df([(9, 58)]) + out = _apply_bar_time_align_df( + df, is_intraday=True, delta_minutes=5, bar_time="end", has_time_columns=True + ) + assert out["hour"].iloc[0] == 10 + assert out["minute"].iloc[0] == 3 + + def test_end_close_bar_15min(self): + # 60min 线下午最后一根开始时间 14:00,右端点 15:00(跨小时但不跨日) + df = _bars_df([(14, 0)]) + out = _apply_bar_time_align_df( + df, is_intraday=True, delta_minutes=60, bar_time="end", has_time_columns=True + ) + assert out["hour"].iloc[0] == 15 + assert out["minute"].iloc[0] == 0 + + def test_daily_plus_not_aligned_even_with_end(self): + # 日线及以上周期:is_intraday=False,即便 bar_time="end" 也不偏移 + df = _bars_df([(0, 0)]) + out = _apply_bar_time_align_df( + df, is_intraday=False, delta_minutes=None, bar_time="end", has_time_columns=True + ) + assert out["hour"].iloc[0] == 0 + assert out["minute"].iloc[0] == 0 + + def test_does_not_mutate_input(self): + df = _bars_df([(11, 25)]) + _apply_bar_time_align_df( + df, is_intraday=True, delta_minutes=5, bar_time="end", has_time_columns=True + ) + # 原 DataFrame 不被修改 + assert df["minute"].iloc[0] == 25 + + def test_empty_df(self): + df = pd.DataFrame() + out = _apply_bar_time_align_df( + df, is_intraday=True, delta_minutes=5, bar_time="end", has_time_columns=True + ) + assert out.empty + + +# --------------------------------------------------------------------------- # +# DataFrame 路径(MAC,已合并为 datetime 列) +# --------------------------------------------------------------------------- # + + +def _mac_df(times: list[str]) -> pd.DataFrame: + return pd.DataFrame({"datetime": pd.to_datetime(["2026-06-30 " + t for t in times])}) + + +class TestAlignDfDatetimeColumn: + def test_mac_end_aligns(self): + df = _mac_df(["11:25:00", "13:00:00"]) + out = _apply_bar_time_align_df( + df, is_intraday=True, delta_minutes=5, bar_time="end", has_time_columns=False + ) + assert out["datetime"].iloc[0] == pd.Timestamp("2026-06-30 11:30:00") + assert out["datetime"].iloc[1] == pd.Timestamp("2026-06-30 13:05:00") + + def test_mac_daily_not_aligned(self): + df = _mac_df(["00:00:00"]) + out = _apply_bar_time_align_df( + df, is_intraday=False, delta_minutes=None, bar_time="end", has_time_columns=False + ) + assert out["datetime"].iloc[0] == pd.Timestamp("2026-06-30 00:00:00") + + +# --------------------------------------------------------------------------- # +# dataclass 列表路径(扩展行情 ex client) +# --------------------------------------------------------------------------- # + + +def _make_ex_bar(hour: int, minute: int) -> ExInstrumentBar: + return ExInstrumentBar( + open=10.0, + high=10.0, + low=10.0, + close=10.0, + position=0, + trade=0, + amount=0.0, + year=2026, + month=6, + day=30, + hour=hour, + minute=minute, + ) + + +class TestAlignBars: + def test_end_aligns_ex_bars(self): + bars = [_make_ex_bar(11, 25), _make_ex_bar(13, 0)] + out = _apply_bar_time_align_bars(bars, is_intraday=True, delta_minutes=5, bar_time="end") + assert (out[0].hour, out[0].minute) == (11, 30) + assert (out[1].hour, out[1].minute) == (13, 5) + + def test_start_is_noop(self): + bars = [_make_ex_bar(11, 25)] + out = _apply_bar_time_align_bars(bars, is_intraday=True, delta_minutes=5, bar_time="start") + assert (out[0].hour, out[0].minute) == (11, 25) + + def test_end_cross_hour(self): + bars = [_make_ex_bar(9, 58)] + out = _apply_bar_time_align_bars(bars, is_intraday=True, delta_minutes=5, bar_time="end") + assert (out[0].hour, out[0].minute) == (10, 3) + + def test_does_not_mutate_input_bars(self): + bars = [_make_ex_bar(11, 25)] + _apply_bar_time_align_bars(bars, is_intraday=True, delta_minutes=5, bar_time="end") + assert bars[0].hour == 11 and bars[0].minute == 25 + + def test_security_bar_datetime_str(self): + """SecurityBar 的 datetime_str 在 bar_time='end' 后应反映右端点。""" + bar = SecurityBar( + open=10.0, + close=10.0, + high=10.0, + low=10.0, + vol=100.0, + amount=1000.0, + year=2026, + month=6, + day=30, + hour=11, + minute=25, + ) + assert bar.datetime_str == "2026-06-30 11:25" + + +# --------------------------------------------------------------------------- # +# 集成:category → 偏移链路 +# --------------------------------------------------------------------------- # + + +class TestCategoryChain: + def test_min5_end_alignment(self): + """模拟 5min 线上午最后一根:category=0 → delta=5 → 11:25 右端点 11:30。""" + delta = _category_to_minutes(0) + df = _bars_df([(11, 25)]) + out = _apply_bar_time_align_df( + df, + is_intraday=delta is not None, + delta_minutes=delta, + bar_time="end", + has_time_columns=True, + ) + assert out["hour"].iloc[0] == 11 + assert out["minute"].iloc[0] == 30