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
+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)