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/)。
## [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
### 修复
+10 -3
View File
@@ -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:3013: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 |
+18
View File
@@ -34,6 +34,12 @@ KlineCategory 枚举所有值:
vol : 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(同步)
关键参数:
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
+1 -1
View File
@@ -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"
+115 -1
View File
@@ -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。
+13
View File
@@ -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)
+71 -7
View File
@@ -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()
+51 -6
View File
@@ -1,10 +1,12 @@
"""扩展行情高层 APIExTdxClient(同步)和 AsyncExTdxClientasyncio)。"""
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
# ------------------------------------------------------------------ #
# 分时
+39 -4
View File
@@ -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
+8 -2
View File
@@ -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)
+46
View File
@@ -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
+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