feat(kline): 分钟级K线时间戳可选bar_time对齐Tushare (Discussion #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(默认)保持完全向后兼容;bar_time=end仅对分钟级周期(1/5/15/30/60min)生效,自动按周期时长右移并处理跨小时/跨日边界

- 协议解码层零改动,偏移作为纯展示语义在client层后处理,单一工具函数_apply_bar_time_align_df/_apply_bar_time_align_bars复用于全部路径

- 新增27个单元测试(test_codec_datetime.py偏移逻辑 + test_kline_bar_time.py三路径覆盖),全量700单测通过

- bump 版本号至 1.16.0
This commit is contained in:
GitHub
2026-06-30 15:02:27 +08:00
parent db83e7505d
commit 615994ad72
12 changed files with 597 additions and 24 deletions
+12
View File
@@ -2,6 +2,18 @@
本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。 本文件记录 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:3013: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 ## [1.15.4] — 2026-06-29
### 修复 ### 修复
+10 -3
View File
@@ -177,7 +177,7 @@ c.get_security_quotes(stocks: list[tuple[Market, str]]) -> list[SecurityQuote]
```python ```python
c.get_security_bars(market: Market, code: str, category: KlineCategory, 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 线数据。 获取个股 K 线数据。
@@ -189,15 +189,22 @@ c.get_security_bars(market: Market, code: str, category: KlineCategory,
| category | `KlineCategory` | K 线周期 | | category | `KlineCategory` | K 线周期 |
| start | `int` | 分页偏移(0 为最新) | | start | `int` | 分页偏移(0 为最新) |
| count | `int` | 请求数量(最多 800 | | count | `int` | 请求数量(最多 800 |
| bar_time | `str` | 时间戳语义,见下方说明 |
**bar_time(分钟级周期时间戳对齐)**:通达信协议默认用 bar **开始时间**打时间戳
(5min 线上午最后一根标 11:25、下午第一根标 13:00;午休 11:3013:00 无 bar)。
`bar_time="end"` 切换为 bar **右端点**(= 开始 + 周期时长,标 11:30/13:05),
对齐 Tushare / 同花顺 / 聚宽约定。仅对分钟级周期(MIN_1/5/15/30/60)生效,
日线及以上不受影响。默认 `"start"` 保持完全向后兼容。
### get_index_bars ### get_index_bars
```python ```python
c.get_index_bars(market: Market, code: str, category: KlineCategory, 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 | | 指数 | market | code |
+18
View File
@@ -34,6 +34,12 @@ KlineCategory 枚举所有值:
vol : float64 -- 成交量(股) vol : float64 -- 成交量(股)
amount : float64 -- 成交额(元) amount : float64 -- 成交额(元)
bar_time 参数(仅分钟级周期):
bar_time="start"(默认)-- datetime 标 bar 开始时间(通达信原始约定)。
例:5min 线上午最后一根标 11:25、下午第一根标 13:00;午休 11:3013:00 无 bar。
bar_time="end" -- datetime 标 bar 右端点(= 开始 + 周期时长),对齐
Tushare / 同花顺 / 聚宽。例:上午最后一根标 11:30、下午第一根标 13:05。
使用客户端:TdxClient(同步) 使用客户端:TdxClient(同步)
关键参数: 关键参数:
market : Market 枚举 market : Market 枚举
@@ -52,6 +58,18 @@ with TdxClient.from_best_host() as c:
print("江特电机 日K线:") print("江特电机 日K线:")
print(df.to_string(index=False)) 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线: # 江特电机 日K线:
# date open close high low vol amount # date open close high low vol amount
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "easy-tdx" name = "easy-tdx"
version = "1.15.4" version = "1.16.0"
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步" description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
+115 -1
View File
@@ -2,11 +2,51 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import asdict, is_dataclass import logging
from dataclasses import asdict, is_dataclass, replace
from typing import Any from typing import Any
import pandas as pd 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: def _to_df(data: Any) -> pd.DataFrame:
"""将 list[dataclass] 或单个 dataclass 转为 DataFrame。 """将 list[dataclass] 或单个 dataclass 转为 DataFrame。
@@ -46,6 +86,80 @@ def _merge_datetime_fields(d: dict[str, Any]) -> dict[str, Any]:
return d 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: def _merge_bar_datetime(df: pd.DataFrame, daily_plus: bool) -> pd.DataFrame:
"""根据 K 线周期将 SecurityBar 的分散字段合并为 date 或 datetime。 """根据 K 线周期将 SecurityBar 的分散字段合并为 date 或 datetime。
+13
View File
@@ -14,6 +14,13 @@ import click
@click.option("--count", default=800, type=int, help="K线数量") @click.option("--count", default=800, type=int, help="K线数量")
@click.option("--start", default=0, type=int, help="起始偏移(0=最新)") @click.option("--start", default=0, type=int, help="起始偏移(0=最新)")
@click.option("--adjust", default="NONE", help="复权: NONE/QFQ/HFQ") @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("--table", "use_table", is_flag=True, help="表格输出")
@click.option("--output", "output_fmt", type=click.Choice(["json", "table", "csv"]), default="json") @click.option("--output", "output_fmt", type=click.Choice(["json", "table", "csv"]), default="json")
def kline( def kline(
@@ -23,6 +30,7 @@ def kline(
count: int, count: int,
start: int, start: int,
adjust: str, adjust: str,
bar_time: str,
use_table: bool, use_table: bool,
output_fmt: str, output_fmt: str,
) -> None: ) -> None:
@@ -35,6 +43,10 @@ def kline(
easy-tdx kline SH 600519 --adjust QFQ --count 30 easy-tdx kline SH 600519 --adjust QFQ --count 30
easy-tdx kline SZ 000001 --period 5MIN --table 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 .conn import get_mac_client
from .output import print_output from .output import print_output
@@ -50,5 +62,6 @@ def kline(
start=start, start=start,
count=count, count=count,
adjust=parse_adjust(adjust), adjust=parse_adjust(adjust),
bar_time=bar_time,
) )
print_output(df, fmt) print_output(df, fmt)
+71 -7
View File
@@ -14,7 +14,14 @@ from zoneinfo import ZoneInfo
import pandas as pd 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.block import parse_block_dat
from .codec.financial import parse_financial_dat, parse_financial_file_list from .codec.financial import parse_financial_dat, parse_financial_file_list
from .codec.industry import parse_tdxhy_cfg from .codec.industry import parse_tdxhy_cfg
@@ -446,10 +453,28 @@ class TdxClient:
category: KlineCategory, category: KlineCategory,
start: int, start: int,
count: int = 800, count: int = 800,
*,
bar_time: str = "start",
) -> pd.DataFrame: ) -> 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))) 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( def get_index_bars(
self, self,
@@ -458,10 +483,25 @@ class TdxClient:
category: KlineCategory, category: KlineCategory,
start: int, start: int,
count: int = 800, count: int = 800,
*,
bar_time: str = "start",
) -> pd.DataFrame: ) -> pd.DataFrame:
"""获取指数 K 线数据。""" """获取指数 K 线数据。
Args:
bar_time: 见 :meth:`get_security_bars`,分钟级周期时间戳可对齐 Tushare 右端点。
"""
df = _to_df(self._execute(GetIndexBarsCmd(market, code, category, start, count))) 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, category: KlineCategory,
start: int, start: int,
count: int = 800, count: int = 800,
*,
bar_time: str = "start",
) -> pd.DataFrame: ) -> pd.DataFrame:
"""获取 K 线数据。``bar_time`` 见同步版 :meth:`get_security_bars`。"""
df = _to_df(await self._execute(GetSecurityBarsCmd(market, code, category, start, count))) 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( async def get_index_bars(
self, self,
@@ -1008,9 +1060,21 @@ class AsyncTdxClient:
category: KlineCategory, category: KlineCategory,
start: int, start: int,
count: int = 800, count: int = 800,
*,
bar_time: str = "start",
) -> pd.DataFrame: ) -> pd.DataFrame:
"""获取指数 K 线数据。``bar_time`` 见同步版 :meth:`get_index_bars`。"""
df = _to_df(await self._execute(GetIndexBarsCmd(market, code, category, start, count))) 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: async def get_minute_time_data(self, market: Market, code: str) -> pd.DataFrame:
today = _today_in_shanghai() today = _today_in_shanghai()
+51 -6
View File
@@ -1,10 +1,12 @@
"""扩展行情高层 APIExTdxClient(同步)和 AsyncExTdxClientasyncio)。""" """扩展行情高层 APIExTdxClient(同步)和 AsyncExTdxClientasyncio)。"""
import asyncio import asyncio
import logging
from collections import OrderedDict from collections import OrderedDict
from types import TracebackType from types import TracebackType
from typing import TypeVar from typing import TypeVar
from .._df import _apply_bar_time_align_bars, _category_to_minutes
from ..commands.base import BaseCommand from ..commands.base import BaseCommand
from ..config import get_best_ex_host, get_ex_hosts, save_best_ex_host from ..config import get_best_ex_host, get_ex_hosts, save_best_ex_host
from ..exceptions import TdxConnectionError from ..exceptions import TdxConnectionError
@@ -34,6 +36,8 @@ from .models import (
from .transport.async_ import AsyncExTdxConnection from .transport.async_ import AsyncExTdxConnection
from .transport.sync import ExTdxConnection, ping_ex_all from .transport.sync import ExTdxConnection, ping_ex_all
logger = logging.getLogger(__name__)
_DEFAULT_EX_PORT = 7727 _DEFAULT_EX_PORT = 7727
_T = TypeVar("_T") _T = TypeVar("_T")
@@ -169,9 +173,20 @@ class ExTdxClient:
code: str, code: str,
start: int = 0, start: int = 0,
count: int = 700, count: int = 700,
*,
bar_time: str = "start",
) -> list[ExInstrumentBar]: ) -> list[ExInstrumentBar]:
"""获取K线数据。""" """获取K线数据。
return self._execute(GetExInstrumentBarsCmd(category, market, code, start, count))
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( def get_history_instrument_bars_range(
self, self,
@@ -179,9 +194,23 @@ class ExTdxClient:
code: str, code: str,
start_date: int, start_date: int,
end_date: int, end_date: int,
*,
bar_time: str = "start",
) -> list[ExInstrumentBar]: ) -> list[ExInstrumentBar]:
"""按日期范围获取历史K线。""" """按日期范围获取历史K线。
return self._execute(GetExHistoryInstrumentBarsRangeCmd(market, code, start_date, end_date))
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, code: str,
start: int = 0, start: int = 0,
count: int = 700, count: int = 700,
*,
bar_time: str = "start",
) -> list[ExInstrumentBar]: ) -> 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( async def get_history_instrument_bars_range(
self, self,
@@ -401,10 +437,19 @@ class AsyncExTdxClient:
code: str, code: str,
start_date: int, start_date: int,
end_date: int, end_date: int,
*,
bar_time: str = "start",
) -> list[ExInstrumentBar]: ) -> list[ExInstrumentBar]:
return await self._execute( """按日期范围获取历史K线。``bar_time`` 见同步版(不支持 ``"end"``)。"""
bars = await self._execute(
GetExHistoryInstrumentBarsRangeCmd(market, code, start_date, end_date) GetExHistoryInstrumentBarsRangeCmd(market, code, start_date, end_date)
) )
if bar_time == "end":
logger.warning(
"get_history_instrument_bars_range 不支持 bar_time='end'(缺少周期信息),"
"原样返回通达信开始时间。"
)
return bars
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 分时 # 分时
+39 -4
View File
@@ -11,7 +11,7 @@ from typing import Any, TypeVar
import pandas as pd 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 ..codec.bitmap import Fields, PresetField
from ..commands.base import BaseCommand from ..commands.base import BaseCommand
from ..config import get_best_host, get_mac_hosts, get_port, get_timeout, save_best_host 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, count: int = 800,
times: int = 1, times: int = 1,
adjust: Adjust = Adjust.NONE, adjust: Adjust = Adjust.NONE,
*,
bar_time: str = "start",
) -> pd.DataFrame: ) -> pd.DataFrame:
"""获取 K 线数据(自动分页,每页最多 700 条)。 """获取 K 线数据(自动分页,每页最多 700 条)。
@@ -350,6 +352,10 @@ class MacClient:
count: 总请求条数。 count: 总请求条数。
times: 周期倍数(Period.MINS/DAYS 时有效)。 times: 周期倍数(Period.MINS/DAYS 时有效)。
adjust: 复权方式。 adjust: 复权方式。
bar_time: 时间戳语义。 ``"start"``(默认)= bar 开始时间(通达信原始,
上午最后一根 5min 标 11:25、下午第一根标 13:00);``"end"`` = bar 右端点
(= 开始 + 周期时长,与 Tushare/同花顺对齐,上午最后一根标 11:30)。
仅对分钟级周期生效;日线及以上不受影响。
""" """
all_bars: list[MacBar] = [] all_bars: list[MacBar] = []
fetched = 0 fetched = 0
@@ -376,7 +382,16 @@ class MacClient:
if len(bars) < page_size: if len(bars) < page_size:
break 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( def get_stock_kline_with_indicators(
self, self,
@@ -387,6 +402,8 @@ class MacClient:
count: int = 30, count: int = 30,
adjust: Adjust = Adjust.QFQ, adjust: Adjust = Adjust.QFQ,
params: dict[str, dict[str, int | float]] | None = None, params: dict[str, dict[str, int | float]] | None = None,
*,
bar_time: str = "start",
) -> pd.DataFrame: ) -> pd.DataFrame:
"""获取 K 线数据并计算技术指标。 """获取 K 线数据并计算技术指标。
@@ -400,11 +417,14 @@ class MacClient:
count: 返回条数(默认30)。 count: 返回条数(默认30)。
adjust: 复权方式(默认前复权)。 adjust: 复权方式(默认前复权)。
params: 可选指标参数覆盖。 params: 可选指标参数覆盖。
bar_time: 见 :meth:`get_stock_kline`。
""" """
from ..indicator import compute_indicators from ..indicator import compute_indicators
fetch_count = max(120 + count, 200) 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: if df.empty:
return df return df
return compute_indicators(df, indicators, params, tail=count) return compute_indicators(df, indicators, params, tail=count)
@@ -1247,7 +1267,10 @@ class AsyncMacClient:
count: int = 800, count: int = 800,
times: int = 1, times: int = 1,
adjust: Adjust = Adjust.NONE, adjust: Adjust = Adjust.NONE,
*,
bar_time: str = "start",
) -> pd.DataFrame: ) -> pd.DataFrame:
"""获取 K 线数据。``bar_time`` 见同步版 :meth:`get_stock_kline`。"""
all_bars: list[MacBar] = [] all_bars: list[MacBar] = []
fetched = 0 fetched = 0
offset = start offset = start
@@ -1273,7 +1296,16 @@ class AsyncMacClient:
if len(bars) < page_size: if len(bars) < page_size:
break 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( async def get_stock_kline_with_indicators(
self, self,
@@ -1284,6 +1316,8 @@ class AsyncMacClient:
count: int = 30, count: int = 30,
adjust: Adjust = Adjust.QFQ, adjust: Adjust = Adjust.QFQ,
params: dict[str, dict[str, int | float]] | None = None, params: dict[str, dict[str, int | float]] | None = None,
*,
bar_time: str = "start",
) -> pd.DataFrame: ) -> pd.DataFrame:
"""获取 K 线数据并计算技术指标(异步)。 """获取 K 线数据并计算技术指标(异步)。
@@ -1298,6 +1332,7 @@ class AsyncMacClient:
period=period, period=period,
count=fetch_count, count=fetch_count,
adjust=adjust, adjust=adjust,
bar_time=bar_time,
) )
if df.empty: if df.empty:
return df return df
+8 -2
View File
@@ -27,11 +27,14 @@ async def security_bars(
), ),
start: int = Query(0, ge=0), start: int = Query(0, ge=0),
count: int = Query(800, ge=1, le=800), count: int = Query(800, ge=1, le=800),
bar_time: str = Query(
"start", description="时间戳: start=bar开始时间(默认) / end=bar结束时间(对齐Tushare)"
),
client: Any = Depends(get_client), client: Any = Depends(get_client),
) -> DataFrameResponse: ) -> DataFrameResponse:
"""获取股票K线数据。""" """获取股票K线数据。"""
df = await client.get_security_bars( 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) return _df_resp(df)
@@ -43,11 +46,14 @@ async def index_bars(
category: str = Query("DAY", description="K线周期"), category: str = Query("DAY", description="K线周期"),
start: int = Query(0, ge=0), start: int = Query(0, ge=0),
count: int = Query(800, ge=1, le=800), count: int = Query(800, ge=1, le=800),
bar_time: str = Query(
"start", description="时间戳: start=bar开始时间(默认) / end=bar结束时间(对齐Tushare)"
),
client: Any = Depends(get_client), client: Any = Depends(get_client),
) -> DataFrameResponse: ) -> DataFrameResponse:
"""获取指数K线数据。""" """获取指数K线数据。"""
df = await client.get_index_bars( 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) return _df_resp(df)
+46
View File
@@ -62,3 +62,49 @@ class TestGetTime:
h, mi, pos = get_time(data, 0) h, mi, pos = get_time(data, 0)
assert h == 14 and mi == 30 assert h == 14 and mi == 30
assert pos == 2 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
+213
View File
@@ -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:055min 线右端点)
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