mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 15:44:15 +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:
@@ -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: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
|
## [1.15.4] — 2026-06-29
|
||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|||||||
+10
-3
@@ -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:30–13: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 |
|
||||||
|
|||||||
@@ -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:30–13: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
@@ -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
@@ -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。
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"""扩展行情高层 API:ExTdxClient(同步)和 AsyncExTdxClient(asyncio)。"""
|
"""扩展行情高层 API:ExTdxClient(同步)和 AsyncExTdxClient(asyncio)。"""
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# 分时
|
# 分时
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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