mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 21:34:16 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user