fix: resolve all CI mypy (265→0) and ruff (26→0) errors

- pyproject.toml: add mypy overrides for pandas/tabulate/matplotlib stubs,
  disable strict checking for vendored MyTT library
- config.py: use cast() for dict[str, Any] .get() returns
- beichi.py: widen _calc_bi_force param to BI | XD, import XD
- backtest/cli.py: split combo/single strategy into separate typed variables
- backtest/combo.py: add bool_array() helper for numpy return types
- chanlun/analyser.py: type ignore for pandas row access, fix dict type arg
- unified.py: change fields param from object to Any
- ex/mac_client.py: add type args to list literals
- cli/cmd_offline.py: wrap int market as Market enum before API call
- cli/cmd_chanlun.py: fix dict type arg
- offline/write_*.py: explicit int() cast for struct.unpack returns
- MyTT.py: fix line-too-long comments, UP038 isinstance syntax
- tests: fix E712 (==False → ~mask), E741 (noqa), F841, import sorting
- ruff format applied across codebase

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
GitHub
2026-06-10 15:03:41 +08:00
co-authored by Claude Opus 4.8
parent 5aac7d3a39
commit 4dfd18050e
51 changed files with 548 additions and 335 deletions
+87
View File
@@ -0,0 +1,87 @@
{
"permissions": {
"allow": [
"Skill(update-config)",
"Bash(echo '{\"tool_name\":\"Edit\",\"tool_input\":{\"file_path\":\"D:\\\\\\\\python\\\\\\\\xmtdx\\\\\\\\src\\\\\\\\xmtdx\\\\\\\\__init__.py\"}}')",
"Bash(python -c \"import sys,json,subprocess;d=json.load\\(sys.stdin\\);f=d.get\\('tool_input',{}\\).get\\('file_path',''\\);exec\\('' if not f.endswith\\(\\\\\\\\'.py\\\\\\\\'\\) else 'subprocess.run\\([\\\\\\\\'ruff\\\\\\\\',\\\\\\\\'check\\\\\\\\',\\\\\\\\'--fix\\\\\\\\',f],capture_output=True\\);subprocess.run\\([\\\\\\\\'ruff\\\\\\\\',\\\\\\\\'format\\\\\\\\',f],capture_output=True\\)'\\);print\\(f'done: {f}'\\)\")",
"Bash(python *)",
"Bash(ruff check *)",
"Bash(git add *)",
"Bash(git commit *)",
"Bash(git push *)",
"mcp__zread__get_repo_structure",
"Bash(pip install *)",
"mcp__web-reader__webReader",
"Bash(python3 *)",
"Bash(where mypy *)",
"Bash(pip list *)",
"Bash(uv run *)",
"Bash(git stash *)",
"Bash(dir /s /b src\\\\xmtdx)",
"Bash(git remote *)",
"Bash(uv --version)",
"Bash(git rm *)",
"mcp__plugin_context7_context7__resolve-library-id",
"mcp__plugin_context7_context7__query-docs",
"Bash(grep -E \"\\\\.py$\")",
"Bash(awk '{added+=$1; deleted+=$2} END {print \"+\" added \" -\" deleted}')",
"Bash(find D:/python/easty_tdx/examples/1[1-7]* -name \"*.py\")",
"Bash(easy-tdx ping *)",
"Bash(easy-tdx version *)",
"Bash(easy-tdx kline *)",
"Bash(easy-tdx quote *)",
"Bash(easy-tdx tick *)",
"Bash(easy-tdx transaction *)",
"Bash(easy-tdx auction *)",
"Bash(easy-tdx quote-list *)",
"Bash(easy-tdx board-list *)",
"Bash(easy-tdx board-members *)",
"Bash(easy-tdx belong-board *)",
"Bash(easy-tdx capital-flow *)",
"Bash(echo \"EXIT CODE: $?\")",
"Bash(easy-tdx unusual *)",
"Bash(easy-tdx market-stat *)",
"Bash(easy-tdx server-info *)",
"Bash(easy-tdx symbol-info *)",
"Bash(easy-tdx ex *)",
"WebSearch",
"mcp__zread__search_doc",
"mcp__zread__read_file",
"Bash(git status *)",
"Bash(pip show *)",
"Bash(git tag *)",
"Bash(twine upload *)",
"Bash(pip index *)",
"Bash(curl -s https://pypi.org/pypi/easy-tdx/json)",
"Bash(curl -sI \"https://files.pythonhosted.org/packages/py3/e/easy-tdx/easy_tdx-1.2.0-py3-none-any.whl\")",
"Bash(gh run *)",
"Bash(easy-tdx *)",
"mcp__web-search-prime__web_search_prime",
"mcp__plugin_compound-engineering_context7__resolve-library-id",
"mcp__plugin_compound-engineering_context7__query-docs",
"Bash(pip uninstall *)",
"Bash(xargs grep -l \"Command\\\\|Request\\\\|Response\")",
"Bash(quotes/stock_xdxr_info.go)",
"Bash(mypy src/)",
"Bash(ruff format *)",
"mcp__plugin_episodic-memory_episodic-memory__search",
"Bash(echo \"FK/\")",
"Bash(git pull *)",
"mcp__playwright__browser_navigate",
"mcp__playwright__browser_snapshot",
"Bash(gh auth *)",
"Bash(rm -rf easy_tdx.wiki)",
"Bash(gh repo *)",
"Bash(gh api *)",
"Bash(mypy src/easy_tdx/backtest/types.py)",
"Bash(mypy tests/unit/test_backtest_types.py)",
"Bash(mypy src/easy_tdx/backtest/types.py tests/unit/test_backtest_types.py)",
"Bash(mypy *)",
"Bash(git diff *)",
"Bash(echo \"exit: $?\")",
"Bash(where easy-tdx *)",
"mcp__zai-mcp-server__analyze_image",
"Bash(git ls-remote *)"
]
}
}
+9
View File
@@ -23,6 +23,15 @@ packages = ["src/easy_tdx"]
strict = true strict = true
python_version = "3.10" python_version = "3.10"
[[tool.mypy.overrides]]
module = ["pandas", "tabulate", "matplotlib", "matplotlib.*"]
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "easy_tdx.MyTT"
disallow_untyped_defs = false
disallow_untyped_calls = false
[tool.ruff] [tool.ruff]
target-version = "py310" target-version = "py310"
line-length = 100 line-length = 100
+4 -3
View File
@@ -4,7 +4,8 @@
# V2.1 2021-6-6 新增 BARSLAST函数 SLOPE,FORCAST线性回归预测函数 # V2.1 2021-6-6 新增 BARSLAST函数 SLOPE,FORCAST线性回归预测函数
# V2.3 2021-6-13 新增 TRIX,DPO,BRAR,DMA,MTM,MASS,ROC,VR,ASI等指标 # V2.3 2021-6-13 新增 TRIX,DPO,BRAR,DMA,MTM,MASS,ROC,VR,ASI等指标
# V2.4 2021-6-27 新增 EXPMA,OBV,MFI指标, 改进SMA核心函数(核心函数彻底无循环) # V2.4 2021-6-27 新增 EXPMA,OBV,MFI指标, 改进SMA核心函数(核心函数彻底无循环)
# V2.7 2021-11-21 修正 SLOPE,BARSLAST,函数,新加FILTER,LONGCROSS, 感谢qzhjiang对SLOPE,SMA等函数的指正 # V2.7 2021-11-21 修正 SLOPE,BARSLAST,函数,新加FILTER,LONGCROSS,
# 感谢qzhjiang对SLOPE,SMA等函数的指正
# V2.8 2021-11-23 修正 FORCAST,WMA函数,欢迎qzhjiang,stanene,bcq加入社群,一起来完善myTT库 # V2.8 2021-11-23 修正 FORCAST,WMA函数,欢迎qzhjiang,stanene,bcq加入社群,一起来完善myTT库
# V2.9 2021-11-29 新增 HHVBARS,LLVBARS,CONST, VALUEWHEN功能函数 # V2.9 2021-11-29 新增 HHVBARS,LLVBARS,CONST, VALUEWHEN功能函数
# V2.92 2021-11-30 新增 BARSSINCEN函数,现在可以 pip install MyTT 完成安装 # V2.92 2021-11-30 新增 BARSSINCEN函数,现在可以 pip install MyTT 完成安装
@@ -128,7 +129,7 @@ def WMA(S, N): # 通达信S序列的N日加权移动平均 Yn = (1*X1+2*X2+3*X3
def DMA(S, A): # 求S的动态移动平均,A作平滑因子,必须 0<A<1 (此为核心函数,非指标) def DMA(S, A): # 求S的动态移动平均,A作平滑因子,必须 0<A<1 (此为核心函数,非指标)
if isinstance(A, (int, float)): if isinstance(A, int | float):
return pd.Series(S).ewm(alpha=A, adjust=False).mean().values return pd.Series(S).ewm(alpha=A, adjust=False).mean().values
A = np.array(A) A = np.array(A)
A[np.isnan(A)] = 1.0 A[np.isnan(A)] = 1.0
@@ -164,7 +165,7 @@ def LAST(S, A, B): # 从前A日到前B日一直满足S_BOOL条件, 要求A>B &
) )
# ------------------ 1级:应用层函数(通过0级核心函数实现)使用方法请参考通达信-------------------------------- # -- 1级:应用层函数(通过0级核心函数实现)使用方法请参考通达信 --------------------
def COUNT(S, N): # COUNT(CLOSE>O, N): 最近N天满足S_BOO的天数 True的天数 def COUNT(S, N): # COUNT(CLOSE>O, N): 最近N天满足S_BOO的天数 True的天数
return SUM(S, N) return SUM(S, N)
+10 -11
View File
@@ -79,16 +79,15 @@ def backtest(
# 1. 加载策略(单策略 or 多因子组合) # 1. 加载策略(单策略 or 多因子组合)
is_combo = combo_strategies is not None is_combo = combo_strategies is not None
strategy = None
if is_combo: if is_combo:
strategy = _load_combo_strategies(combo_strategies) assert combo_strategies is not None # narrowed by is_combo
combo_classes = _load_combo_strategies(combo_strategies)
else: else:
strategy = _load_strategy(strategy_str, strategy_file) strategy_cls = _load_strategy(strategy_str, strategy_file)
if strategy_cls is None:
if strategy is None: click.echo("错误: 必须指定 --strategy-file / --combo-strategies / --strategy", err=True)
click.echo("错误: 必须指定 --strategy-file / --combo-strategies / --strategy", err=True) raise SystemExit(1)
raise SystemExit(1)
# 2. 获取数据 # 2. 获取数据
mkt = parse_market(market) mkt = parse_market(market)
@@ -111,21 +110,21 @@ def backtest(
if is_combo: if is_combo:
from ..backtest.combo import CombinationRunner from ..backtest.combo import CombinationRunner
assert strategy is not None # for type checker
runner = CombinationRunner( runner = CombinationRunner(
strategy_classes=strategy, strategy_classes=combo_classes,
df=df, df=df,
cash=cash, cash=cash,
commission=commission, commission=commission,
execution=execution, execution=execution,
) )
result = runner.run_combination( result = runner.run_combination(
indices=list(range(len(strategy))), indices=list(range(len(combo_classes))),
mode=combo_mode.upper(), mode=combo_mode.upper(),
) )
else: else:
assert strategy_cls is not None # guarded above by SystemExit
engine = BacktestEngine( engine = BacktestEngine(
strategy=strategy, strategy=strategy_cls,
cash=cash, cash=cash,
commission=commission, commission=commission,
execution=execution, execution=execution,
+9 -4
View File
@@ -39,6 +39,11 @@ NDArray = np.ndarray
BoolArray = npt.NDArray[np.bool_] BoolArray = npt.NDArray[np.bool_]
def bool_array(x: Any) -> BoolArray:
"""Ensure the result is a BoolArray (not a scalar bool_)."""
return np.asarray(x, dtype=np.bool_)
# ── 数据结构 ──────────────────────────────────────────────────────────────── # ── 数据结构 ────────────────────────────────────────────────────────────────
@@ -200,15 +205,15 @@ def combine_masks(
sell_stack = np.stack(sell_arrays) sell_stack = np.stack(sell_arrays)
if mode == "AND": if mode == "AND":
return np.all(buy_stack, axis=0), np.all(sell_stack, axis=0) return bool_array(np.all(buy_stack, axis=0)), bool_array(np.all(sell_stack, axis=0))
elif mode == "OR": elif mode == "OR":
return np.any(buy_stack, axis=0), np.any(sell_stack, axis=0) return bool_array(np.any(buy_stack, axis=0)), bool_array(np.any(sell_stack, axis=0))
elif mode == "MAJORITY": elif mode == "MAJORITY":
n_factors = len(signals_list) n_factors = len(signals_list)
threshold = n_factors / 2 threshold = n_factors / 2
return ( return (
np.sum(buy_stack, axis=0) > threshold, bool_array(np.sum(buy_stack, axis=0) > threshold),
np.sum(sell_stack, axis=0) > threshold, bool_array(np.sum(sell_stack, axis=0) > threshold),
) )
else: else:
raise ValueError(f"不支持的合并模式: {mode!r}(可选: AND, OR, MAJORITY") raise ValueError(f"不支持的合并模式: {mode!r}(可选: AND, OR, MAJORITY")
+6 -5
View File
@@ -8,6 +8,7 @@ K线合并 → 分型识别 → 笔计算 → 中枢计算 → 线段 → 买卖
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any
import pandas as pd import pandas as pd
@@ -38,10 +39,10 @@ def _df_to_klines(df: pd.DataFrame) -> list[Kline]:
Kline( Kline(
index=i, index=i,
date=dt, date=dt,
open=float(row.open), open=float(row.open), # type: ignore[arg-type]
close=float(row.close), close=float(row.close), # type: ignore[arg-type]
high=float(row.high), high=float(row.high), # type: ignore[arg-type]
low=float(row.low), low=float(row.low), # type: ignore[arg-type]
amount=float(vol), amount=float(vol),
) )
) )
@@ -64,7 +65,7 @@ class ChanlunResult:
bcs: list[BC] = field(default_factory=list) bcs: list[BC] = field(default_factory=list)
macd: dict[str, list[float]] = field(default_factory=dict) macd: dict[str, list[float]] = field(default_factory=dict)
def to_dict(self) -> dict: def to_dict(self) -> dict[str, Any]:
"""将结果转为可序列化的字典(用于 JSON 输出)。""" """将结果转为可序列化的字典(用于 JSON 输出)。"""
return { return {
"code": self.code, "code": self.code,
+2 -2
View File
@@ -9,7 +9,7 @@
from __future__ import annotations from __future__ import annotations
from easy_tdx.chanlun.config import ChanlunConfig from easy_tdx.chanlun.config import ChanlunConfig
from easy_tdx.chanlun.types import BC, BI, ZS, BCType from easy_tdx.chanlun.types import BC, BI, XD, ZS, BCType
def check_bi_beichi( def check_bi_beichi(
@@ -165,7 +165,7 @@ def _check_qs_beichi(bis: list[BI], zss: list[ZS]) -> list[BC]:
return bcs return bcs
def _calc_bi_force(bi: BI) -> float: def _calc_bi_force(bi: BI | XD) -> float:
"""计算笔的力度(简化:用幅度表示)。 """计算笔的力度(简化:用幅度表示)。
真正的力度应用 MACD 面积,这里用幅度作为简化替代。 真正的力度应用 MACD 面积,这里用幅度作为简化替代。
+2 -1
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
from typing import Any
import click import click
@@ -65,7 +66,7 @@ def chanlun(
click.echo(json.dumps(result_dict, ensure_ascii=False)) click.echo(json.dumps(result_dict, ensure_ascii=False))
def _print_table(result: dict) -> None: def _print_table(result: dict[str, Any]) -> None:
"""以表格形式输出缠论分析结果。""" """以表格形式输出缠论分析结果。"""
click.echo(f"标的: {result['code']} 周期: {result['frequency']}") click.echo(f"标的: {result['code']} 周期: {result['frequency']}")
click.echo(f"原始K线: {result['kline_count']} 缠论K线: {result['ckline_count']}") click.echo(f"原始K线: {result['kline_count']} 缠论K线: {result['ckline_count']}")
+2 -1
View File
@@ -57,7 +57,8 @@ def kline(
mkt = parse_ex_market(market) mkt = parse_ex_market(market)
with get_mac_ex_client() as client: with get_mac_ex_client() as client:
df = client.goods_kline( df = client.goods_kline(
mkt, code, mkt,
code,
period=parse_period(period), period=parse_period(period),
start=start, start=start,
count=count, count=count,
+3 -2
View File
@@ -493,8 +493,9 @@ def _fetch_all_daily_bars(
Returns: Returns:
SecurityBar 列表(按日期升序)。 SecurityBar 列表(按日期升序)。
""" """
from ..models.enums import KlineCategory from ..models.enums import KlineCategory, Market
mkt = Market(market)
fetch_fn = client.get_index_bars if is_index else client.get_security_bars fetch_fn = client.get_index_bars if is_index else client.get_security_bars
all_bars: list[SecurityBar] = [] all_bars: list[SecurityBar] = []
@@ -503,7 +504,7 @@ def _fetch_all_daily_bars(
max_pages = 50 if need_full else 1 # 50 页 = 40000 条,足够覆盖 A 股全部历史 max_pages = 50 if need_full else 1 # 50 页 = 40000 条,足够覆盖 A 股全部历史
for _ in range(max_pages): for _ in range(max_pages):
df = fetch_fn(market, code, KlineCategory.DAY, start, page_size) df = fetch_fn(mkt, code, KlineCategory.DAY, start, page_size)
if df.empty: if df.empty:
break break
all_bars.extend(_df_to_bars(df)) all_bars.extend(_df_to_bars(df))
+8 -1
View File
@@ -32,7 +32,14 @@ from .commands.security_list import GetSecurityListCmd
from .commands.security_quotes import GetSecurityQuotesCmd from .commands.security_quotes import GetSecurityQuotesCmd
from .commands.transaction import GetHistoryTransactionDataCmd, GetTransactionDataCmd from .commands.transaction import GetHistoryTransactionDataCmd, GetTransactionDataCmd
from .commands.xdxr_info import GetXdxrInfoCmd from .commands.xdxr_info import GetXdxrInfoCmd
from .config import get_best_host, get_calc_hosts, get_known_hosts, get_port, get_timeout, save_best_host from .config import (
get_best_host,
get_calc_hosts,
get_known_hosts,
get_port,
get_timeout,
save_best_host,
)
from .exceptions import TdxConnectionError from .exceptions import TdxConnectionError
from .models.bar import SecurityBar from .models.bar import SecurityBar
from .models.enums import KlineCategory, Market from .models.enums import KlineCategory, Market
+12 -10
View File
@@ -21,7 +21,7 @@ def parse_block_dat(data: bytes, filename: str = "") -> list["TdxBlock"]:
return [] return []
pos = 384 pos = 384
(count,) = struct.unpack("<H", data[pos:pos+2]) (count,) = struct.unpack("<H", data[pos : pos + 2])
pos += 2 pos += 2
results: list[TdxBlock] = [] results: list[TdxBlock] = []
@@ -40,8 +40,8 @@ def parse_block_dat(data: bytes, filename: str = "") -> list["TdxBlock"]:
break break
# 板块元数据 (9 字节名称 + 2 字节股票数 + 2 字节类型) # 板块元数据 (9 字节名称 + 2 字节股票数 + 2 字节类型)
name_b = data[pos:pos+9] name_b = data[pos : pos + 9]
stock_count, _type = struct.unpack("<HH", data[pos+9:pos+13]) stock_count, _type = struct.unpack("<HH", data[pos + 9 : pos + 13])
name = name_b.decode("gbk", errors="replace").strip("\x00") name = name_b.decode("gbk", errors="replace").strip("\x00")
# 股票代码区 (2800 字节,每只股票 7 字节) # 股票代码区 (2800 字节,每只股票 7 字节)
@@ -51,17 +51,19 @@ def parse_block_dat(data: bytes, filename: str = "") -> list["TdxBlock"]:
actual_count = min(stock_count, 400) actual_count = min(stock_count, 400)
for i in range(actual_count): for i in range(actual_count):
c_start = codes_start + i * 7 c_start = codes_start + i * 7
c_raw = data[c_start:c_start+7] c_raw = data[c_start : c_start + 7]
code = c_raw.decode("ascii", errors="replace").strip("\x00") code = c_raw.decode("ascii", errors="replace").strip("\x00")
if code: if code:
codes.append(code) codes.append(code)
results.append(TdxBlock( results.append(
name=name, TdxBlock(
category=category, name=name,
count=stock_count, category=category,
codes=codes, count=stock_count,
)) codes=codes,
)
)
# 跳过整个 2813 字节的记录块 # 跳过整个 2813 字节的记录块
pos += 2813 pos += 2813
+2 -7
View File
@@ -7,13 +7,10 @@
日线及以上(其余 category):4 字节 YYYYMMDD 整数 日线及以上(其余 category):4 字节 YYYYMMDD 整数
""" """
from .._binary import unpack_from from .._binary import unpack_from
def get_datetime_minute( def get_datetime_minute(data: bytes | bytearray, pos: int) -> tuple[int, int, int, int, int, int]:
data: bytes | bytearray, pos: int
) -> tuple[int, int, int, int, int, int]:
"""解析分钟级时间戳(4 字节)。 """解析分钟级时间戳(4 字节)。
Returns: Returns:
@@ -28,9 +25,7 @@ def get_datetime_minute(
return year, month, day, hour, minute, pos + 4 return year, month, day, hour, minute, pos + 4
def get_datetime_day( def get_datetime_day(data: bytes | bytearray, pos: int) -> tuple[int, int, int, int]:
data: bytes | bytearray, pos: int
) -> tuple[int, int, int, int]:
"""解析日期(4 字节 YYYYMMDD)。 """解析日期(4 字节 YYYYMMDD)。
Returns: Returns:
+3 -3
View File
@@ -25,9 +25,9 @@ _HEADER_FMT = "<IIIHH"
@dataclass(frozen=True) @dataclass(frozen=True)
class FrameHeader: class FrameHeader:
magic: int # 协议魔数,恒为 7654321 magic: int # 协议魔数,恒为 7654321
seq_id: int # ZipFlag(1B) + 请求 bytes 1-4 回显(3B) seq_id: int # ZipFlag(1B) + 请求 bytes 1-4 回显(3B)
method: int # 请求回显(1B) + 保留(1B) + Method(2B) method: int # 请求回显(1B) + 保留(1B) + Method(2B)
zipsize: int zipsize: int
unzipsize: int unzipsize: int
+2 -1
View File
@@ -1,8 +1,9 @@
"""通达信行业配置文件 (tdxhy.cfg) 解析器。""" """通达信行业配置文件 (tdxhy.cfg) 解析器。"""
def parse_tdxhy_cfg(content: bytes) -> dict[str, tuple[str, str]]: def parse_tdxhy_cfg(content: bytes) -> dict[str, tuple[str, str]]:
"""解析 tdxhy.cfg 字节内容。 """解析 tdxhy.cfg 字节内容。
返回字典: { "code": (tdx_industry, sw_industry), ... } 返回字典: { "code": (tdx_industry, sw_industry), ... }
""" """
results = {} results = {}
+2 -3
View File
@@ -9,7 +9,6 @@
警告:此函数专为成交量设计,不可用于价格字段(pytdx Bug #3)。 警告:此函数专为成交量设计,不可用于价格字段(pytdx Bug #3)。
""" """
from .._binary import unpack_from from .._binary import unpack_from
@@ -53,5 +52,5 @@ def _decode_volume(ivol: int) -> float:
def _pow2(exp: int) -> float: def _pow2(exp: int) -> float:
if exp >= 0: if exp >= 0:
return float(1 << exp) if exp < 63 else 2.0 ** exp return float(1 << exp) if exp < 63 else 2.0**exp
return 1.0 / (1 << (-exp)) if -exp < 63 else 2.0 ** exp return 1.0 / (1 << (-exp)) if -exp < 63 else 2.0**exp
+16 -10
View File
@@ -39,12 +39,14 @@ class GetCompanyInfoCategoryCmd(BaseCommand[list[CompanyInfoCategory]]):
raw = b[:nul] if nul != -1 else b raw = b[:nul] if nul != -1 else b
return raw.decode("gbk", errors="replace") return raw.decode("gbk", errors="replace")
results.append(CompanyInfoCategory( results.append(
name=_decode(name_b), CompanyInfoCategory(
filename=_decode(filename_b), name=_decode(name_b),
start=start, filename=_decode(filename_b),
length=length, start=start,
)) length=length,
)
)
return results return results
@@ -52,9 +54,7 @@ class GetCompanyInfoCategoryCmd(BaseCommand[list[CompanyInfoCategory]]):
class GetCompanyInfoContentCmd(BaseCommand[str]): class GetCompanyInfoContentCmd(BaseCommand[str]):
"""按文件名、偏移、长度读取公司信息文本(GBK 编码)。""" """按文件名、偏移、长度读取公司信息文本(GBK 编码)。"""
def __init__( def __init__(self, market: Market, code: str, filename: str, offset: int, length: int) -> None:
self, market: Market, code: str, filename: str, offset: int, length: int
) -> None:
self.market = market self.market = market
self.code = code.encode("utf-8") self.code = code.encode("utf-8")
self.filename = filename.encode("gbk") self.filename = filename.encode("gbk")
@@ -66,7 +66,13 @@ class GetCompanyInfoContentCmd(BaseCommand[str]):
header = bytes.fromhex("0c07109c0001680068 00d002".replace(" ", "")) header = bytes.fromhex("0c07109c0001680068 00d002".replace(" ", ""))
return header + struct.pack( return header + struct.pack(
"<H6sH80sIII", "<H6sH80sIII",
int(self.market), self.code, 0, fname_padded, self.offset, self.length, 0, int(self.market),
self.code,
0,
fname_padded,
self.offset,
self.length,
0,
) )
def parse_response(self, body: bytes) -> str: def parse_response(self, body: bytes) -> str:
+34 -8
View File
@@ -31,15 +31,41 @@ class GetFinanceInfoCmd(BaseCommand[FinanceInfo]):
fields = struct.unpack(_FIN_FMT, slice_bytes(body, pos, _FIN_SIZE, "finance_info body")) fields = struct.unpack(_FIN_FMT, slice_bytes(body, pos, _FIN_SIZE, "finance_info body"))
( (
liutong_guben, province, industry, updated_date, ipo_date, liutong_guben,
zong_guben, guojia_gu, faqiren_faren_gu, faren_gu, b_gu, h_gu, zhigong_gu, province,
zong_zichan, liudong_zichan, guding_zichan, wuxing_zichan, industry,
updated_date,
ipo_date,
zong_guben,
guojia_gu,
faqiren_faren_gu,
faren_gu,
b_gu,
h_gu,
zhigong_gu,
zong_zichan,
liudong_zichan,
guding_zichan,
wuxing_zichan,
gudong_renshu, gudong_renshu,
liudong_fuzhai, changqi_fuzhai, ziben_gongjijin, jing_zichan, liudong_fuzhai,
zhuying_shouru, zhuying_lirun, yingshou_zhangkuan, yingye_lirun, changqi_fuzhai,
touzi_shouyu, jingying_xianjinliu, zong_xianjinliu, ziben_gongjijin,
cunhuo, lirun_zonghe, shuihou_lirun, jing_lirun, weifen_lirun, jing_zichan,
meigujing_zichan, reserve2, zhuying_shouru,
zhuying_lirun,
yingshou_zhangkuan,
yingye_lirun,
touzi_shouyu,
jingying_xianjinliu,
zong_xianjinliu,
cunhuo,
lirun_zonghe,
shuihou_lirun,
jing_lirun,
weifen_lirun,
meigujing_zichan,
reserve2,
) = fields ) = fields
_SCALE = 10000.0 # 财务数据单位:万元/万股 _SCALE = 10000.0 # 财务数据单位:万元/万股
+22 -18
View File
@@ -41,35 +41,39 @@ class GetHistoryFundFlowCmd(BaseCommand[list[HistoricalFundFlow]]):
# 响应格式:9字节头 + 2字节数量 + 每条记录 36 字节 # 响应格式:9字节头 + 2字节数量 + 每条记录 36 字节
if len(body) < 11: if len(body) < 11:
return [] return []
(num,) = struct.unpack("<H", body[9:11]) (num,) = struct.unpack("<H", body[9:11])
pos = 11 pos = 11
results = [] results = []
for _ in range(num): for _ in range(num):
if len(body) < pos + 36: if len(body) < pos + 36:
break break
# 记录格式:4字节日期 + 8个4字节自定义浮点金额 # 记录格式:4字节日期 + 8个4字节自定义浮点金额
# [0]日期, [1..4]流入(超/大/中/小), [5..8]流出(超/大/中/小) # [0]日期, [1..4]流入(超/大/中/小), [5..8]流出(超/大/中/小)
raw_data = struct.unpack("<IIIIIIIII", body[pos:pos+36]) raw_data = struct.unpack("<IIIIIIIII", body[pos : pos + 36])
raw_date = raw_data[0] raw_date = raw_data[0]
year = raw_date // 10000 year = raw_date // 10000
month = (raw_date // 100) % 100 month = (raw_date // 100) % 100
day = raw_date % 100 day = raw_date % 100
results.append(HistoricalFundFlow( results.append(
year=year, month=month, day=day, HistoricalFundFlow(
super_in=_decode_volume(raw_data[1]), year=year,
large_in=_decode_volume(raw_data[2]), month=month,
medium_in=_decode_volume(raw_data[3]), day=day,
small_in=_decode_volume(raw_data[4]), super_in=_decode_volume(raw_data[1]),
super_out=_decode_volume(raw_data[5]), large_in=_decode_volume(raw_data[2]),
large_out=_decode_volume(raw_data[6]), medium_in=_decode_volume(raw_data[3]),
medium_out=_decode_volume(raw_data[7]), small_in=_decode_volume(raw_data[4]),
small_out=_decode_volume(raw_data[8]), super_out=_decode_volume(raw_data[5]),
)) large_out=_decode_volume(raw_data[6]),
medium_out=_decode_volume(raw_data[7]),
small_out=_decode_volume(raw_data[8]),
)
)
pos += 36 pos += 36
return results return results
+2 -2
View File
@@ -39,10 +39,10 @@ class GetSecurityListCmd(BaseCommand[list[SecurityInfo]]):
code_bytes, code_bytes,
volunit, volunit,
name_bytes, name_bytes,
_unknown1, # 4字节,排序/分组字段(非用户可见数据) _unknown1, # 4字节,排序/分组字段(非用户可见数据)
decimal_point, decimal_point,
pre_close_raw, pre_close_raw,
_unknown2, # 4字节,私有时间戳(非用户可见数据) _unknown2, # 4字节,私有时间戳(非用户可见数据)
) = struct.unpack("<6sH8s4sBI4s", raw) ) = struct.unpack("<6sH8s4sBI4s", raw)
code = code_bytes.decode("utf-8", errors="replace").rstrip("\x00") code = code_bytes.decode("utf-8", errors="replace").rstrip("\x00")
+1 -4
View File
@@ -9,10 +9,7 @@ from typing import Final
SETUP_CMD1: Final[bytes] = bytes.fromhex("0c0218930001030003000d0001") SETUP_CMD1: Final[bytes] = bytes.fromhex("0c0218930001030003000d0001")
SETUP_CMD2: Final[bytes] = bytes.fromhex("0c0218940001030003000d0002") SETUP_CMD2: Final[bytes] = bytes.fromhex("0c0218940001030003000d0002")
SETUP_CMD3: Final[bytes] = bytes.fromhex( SETUP_CMD3: Final[bytes] = bytes.fromhex(
"0c031899000120002000db0f" "0c031899000120002000db0fd5d0c9ccd6a4a8af0000008fc22540130000d500c9ccbdf0d7ea00000002"
"d5d0c9ccd6a4a8af0000008f"
"c22540130000d500c9ccbdf0"
"d7ea00000002"
) )
SETUP_COMMANDS: Final[tuple[bytes, ...]] = (SETUP_CMD1, SETUP_CMD2, SETUP_CMD3) SETUP_COMMANDS: Final[tuple[bytes, ...]] = (SETUP_CMD1, SETUP_CMD2, SETUP_CMD3)
+24 -16
View File
@@ -26,8 +26,6 @@ class GetTransactionDataCmd(BaseCommand[list[TransactionRecord]]):
header = bytes.fromhex("0c170801010 10e000e00c50f".replace(" ", "")) header = bytes.fromhex("0c170801010 10e000e00c50f".replace(" ", ""))
return header + struct.pack("<H6sHH", int(self.market), self.code, self.start, self.count) return header + struct.pack("<H6sHH", int(self.market), self.code, self.start, self.count)
def parse_response(self, body: bytes) -> list[TransactionRecord]: def parse_response(self, body: bytes) -> list[TransactionRecord]:
return _parse_transaction_body(body) return _parse_transaction_body(body)
@@ -35,9 +33,7 @@ class GetTransactionDataCmd(BaseCommand[list[TransactionRecord]]):
class GetHistoryTransactionDataCmd(BaseCommand[list[TransactionRecord]]): class GetHistoryTransactionDataCmd(BaseCommand[list[TransactionRecord]]):
"""获取历史某日逐笔成交(date 格式 YYYYMMDD,分页)。""" """获取历史某日逐笔成交(date 格式 YYYYMMDD,分页)。"""
def __init__( def __init__(self, market: Market, code: str, date: int, start: int, count: int = 800) -> None:
self, market: Market, code: str, date: int, start: int, count: int = 800
) -> None:
self.market = market self.market = market
self.code = code.encode("utf-8") self.code = code.encode("utf-8")
self.date = date self.date = date
@@ -72,11 +68,17 @@ def _parse_transaction_body(body: bytes) -> list[TransactionRecord]:
buyorsell, pos = get_price(body, pos) buyorsell, pos = get_price(body, pos)
unknown_last, pos = get_price(body, pos) # Bug #4 修复:不再丢弃 unknown_last, pos = get_price(body, pos) # Bug #4 修复:不再丢弃
last_price += price_diff last_price += price_diff
records.append(TransactionRecord( records.append(
hour=hour, minute=minute, TransactionRecord(
price=last_price / 100.0, vol=vol, buyorsell=buyorsell, hour=hour,
unknown_last=unknown_last, _raw=body[record_start:pos], minute=minute,
)) price=last_price / 100.0,
vol=vol,
buyorsell=buyorsell,
unknown_last=unknown_last,
_raw=body[record_start:pos],
)
)
return records return records
@@ -93,13 +95,19 @@ def _parse_history_transaction_body(body: bytes) -> list[TransactionRecord]:
hour, minute, pos = get_time(body, pos) hour, minute, pos = get_time(body, pos)
price_diff, pos = get_price(body, pos) price_diff, pos = get_price(body, pos)
vol, pos = get_price(body, pos) vol, pos = get_price(body, pos)
buyorsell, pos = get_price(body, pos) # 历史无 num_orders buyorsell, pos = get_price(body, pos) # 历史无 num_orders
unknown_last, pos = get_price(body, pos) unknown_last, pos = get_price(body, pos)
last_price += price_diff last_price += price_diff
records.append(TransactionRecord( records.append(
hour=hour, minute=minute, TransactionRecord(
price=last_price / 100.0, vol=vol, buyorsell=buyorsell, hour=hour,
unknown_last=unknown_last, _raw=body[record_start:pos], minute=minute,
)) price=last_price / 100.0,
vol=vol,
buyorsell=buyorsell,
unknown_last=unknown_last,
_raw=body[record_start:pos],
)
)
return records return records
+12 -12
View File
@@ -27,7 +27,7 @@ import json
import os import os
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, cast
_CONFIG_DIR = Path(os.environ.get("EASY_TDX_CONFIG_DIR", str(Path.home() / ".easy_tdx"))) _CONFIG_DIR = Path(os.environ.get("EASY_TDX_CONFIG_DIR", str(Path.home() / ".easy_tdx")))
_CONFIG_FILE = _CONFIG_DIR / "config.json" _CONFIG_FILE = _CONFIG_DIR / "config.json"
@@ -137,7 +137,7 @@ _FALLBACK_TIMEOUT = 15.0
def _load() -> dict[str, Any]: def _load() -> dict[str, Any]:
try: try:
if _CONFIG_FILE.exists(): if _CONFIG_FILE.exists():
return json.loads(_CONFIG_FILE.read_text("utf-8")) return cast(dict[str, Any], json.loads(_CONFIG_FILE.read_text("utf-8")))
except Exception: except Exception:
pass pass
return {} return {}
@@ -161,7 +161,7 @@ def get_best_host() -> str:
if env: if env:
return env return env
cfg = _load() cfg = _load()
return cfg.get("best_host", _FALLBACK_HOSTS[0]) return cast(str, cfg.get("best_host", _FALLBACK_HOSTS[0]))
def get_known_hosts() -> list[str]: def get_known_hosts() -> list[str]:
@@ -170,25 +170,25 @@ def get_known_hosts() -> list[str]:
if env: if env:
return [h.strip() for h in env.split(",") if h.strip()] return [h.strip() for h in env.split(",") if h.strip()]
cfg = _load() cfg = _load()
return cfg.get("known_hosts", list(_FALLBACK_HOSTS)) return cast(list[str], cfg.get("known_hosts", list(_FALLBACK_HOSTS)))
def get_calc_hosts() -> list[str]: def get_calc_hosts() -> list[str]:
"""返回计算服务器列表。""" """返回计算服务器列表。"""
cfg = _load() cfg = _load()
return cfg.get("calc_hosts", list(_FALLBACK_CALC_HOSTS)) return cast(list[str], cfg.get("calc_hosts", list(_FALLBACK_CALC_HOSTS)))
def get_mac_hosts() -> list[str]: def get_mac_hosts() -> list[str]:
"""返回 MAC 行情服务器列表。""" """返回 MAC 行情服务器列表。"""
cfg = _load() cfg = _load()
return cfg.get("mac_hosts", list(_FALLBACK_MAC_HOSTS)) return cast(list[str], cfg.get("mac_hosts", list(_FALLBACK_MAC_HOSTS)))
def get_ex_hosts() -> list[str]: def get_ex_hosts() -> list[str]:
"""返回扩展行情服务器列表。""" """返回扩展行情服务器列表。"""
cfg = _load() cfg = _load()
return cfg.get("ex_hosts", list(_FALLBACK_EX_HOSTS)) return cast(list[str], cfg.get("ex_hosts", list(_FALLBACK_EX_HOSTS)))
def get_best_ex_host() -> str: def get_best_ex_host() -> str:
@@ -197,13 +197,13 @@ def get_best_ex_host() -> str:
if env: if env:
return env return env
cfg = _load() cfg = _load()
return cfg.get("best_ex_host", _FALLBACK_EX_HOSTS[0]) return cast(str, cfg.get("best_ex_host", _FALLBACK_EX_HOSTS[0]))
def get_mac_ex_hosts() -> list[str]: def get_mac_ex_hosts() -> list[str]:
"""返回 MAC 协议扩展行情服务器列表。""" """返回 MAC 协议扩展行情服务器列表。"""
cfg = _load() cfg = _load()
return cfg.get("mac_ex_hosts", list(_FALLBACK_MAC_EX_HOSTS)) return cast(list[str], cfg.get("mac_ex_hosts", list(_FALLBACK_MAC_EX_HOSTS)))
def get_best_mac_ex_host() -> str: def get_best_mac_ex_host() -> str:
@@ -212,7 +212,7 @@ def get_best_mac_ex_host() -> str:
if env: if env:
return env return env
cfg = _load() cfg = _load()
return cfg.get("best_mac_ex_host", _FALLBACK_MAC_EX_HOSTS[0]) return cast(str, cfg.get("best_mac_ex_host", _FALLBACK_MAC_EX_HOSTS[0]))
def get_port() -> int: def get_port() -> int:
@@ -221,7 +221,7 @@ def get_port() -> int:
if env: if env:
return int(env) return int(env)
cfg = _load() cfg = _load()
return cfg.get("port", _FALLBACK_PORT) return cast(int, cfg.get("port", _FALLBACK_PORT))
def get_timeout() -> float: def get_timeout() -> float:
@@ -230,7 +230,7 @@ def get_timeout() -> float:
if env: if env:
return float(env) return float(env)
cfg = _load() cfg = _load()
return cfg.get("timeout", _FALLBACK_TIMEOUT) return cast(float, cfg.get("timeout", _FALLBACK_TIMEOUT))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+14 -12
View File
@@ -12,18 +12,20 @@ _MSG_ID = 0x2454
_HEAD_FLAG = 0x01 _HEAD_FLAG = 0x01
# 80 字节 Login body,来自 opentdx 参考实现,已通过实际测试验证。 # 80 字节 Login body,来自 opentdx 参考实现,已通过实际测试验证。
_LOGIN_BODY = bytes(bytearray.fromhex( _LOGIN_BODY = bytes(
"e5bb1c2fafe52594" bytearray.fromhex(
"1f32c6e5d53dfb41" "e5bb1c2fafe52594"
"5b734cc9cdbf0ac9" "1f32c6e5d53dfb41"
"2021bfdd1eb06d22" "5b734cc9cdbf0ac9"
"d008884c1611cb13" "2021bfdd1eb06d22"
"78f6abd824d899d2" "d008884c1611cb13"
"1f32c6e5d53dfb41" "78f6abd824d899d2"
"1f32c6e5d53dfb41" "1f32c6e5d53dfb41"
"a9325ac935dc0837" "1f32c6e5d53dfb41"
"335a16e4ce17c1bb" "a9325ac935dc0837"
)) "335a16e4ce17c1bb"
)
)
# EX 协议帧头格式: head_flag(1B) + customize(4B) + version(1B) + zipsize(2B) + unzipsize(2B) # EX 协议帧头格式: head_flag(1B) + customize(4B) + version(1B) + zipsize(2B) + unzipsize(2B)
_EX_HEADER_FMT = "<BIBHH" _EX_HEADER_FMT = "<BIBHH"
+9 -7
View File
@@ -13,18 +13,18 @@ import pandas as pd
from .._df import _to_df from .._df import _to_df
from ..commands.base import BaseCommand from ..commands.base import BaseCommand
from ..config import get_best_mac_ex_host, get_mac_ex_hosts, save_best_mac_ex_host
from ..exceptions import TdxConnectionError from ..exceptions import TdxConnectionError
from .commands.login import MacExLoginCmd
from .commands.get_instrument_count import GetExInstrumentCountCmd
from .commands.get_instrument_info import GetExInstrumentInfoCmd
from ..mac.commands.chart_sampling import ChartSamplingCmd from ..mac.commands.chart_sampling import ChartSamplingCmd
from ..mac.commands.symbol_bar import SymbolBarCmd from ..mac.commands.symbol_bar import SymbolBarCmd
from ..mac.commands.symbol_quotes import SymbolQuotesCmd from ..mac.commands.symbol_quotes import SymbolQuotesCmd
from ..mac.commands.symbol_tick_chart import SymbolTickChartCmd from ..mac.commands.symbol_tick_chart import SymbolTickChartCmd
from ..mac.commands.symbol_transaction import SymbolTransactionCmd from ..mac.commands.symbol_transaction import SymbolTransactionCmd
from ..mac.enums import Adjust, Period, SortOrder, SortType from ..mac.enums import Adjust, Period, SortOrder, SortType
from ..config import get_best_mac_ex_host, get_mac_ex_hosts, save_best_mac_ex_host
from ..mac.models import MacQuoteField from ..mac.models import MacQuoteField
from .commands.get_instrument_count import GetExInstrumentCountCmd
from .commands.get_instrument_info import GetExInstrumentInfoCmd
from .commands.login import MacExLoginCmd
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
@@ -194,7 +194,7 @@ class MacExClient:
return pd.DataFrame() return pd.DataFrame()
total = self._execute(GetExInstrumentCountCmd()) total = self._execute(GetExInstrumentCountCmd())
page_size = 1000 page_size = 1000
collected: list = [] collected: list[Any] = []
skipped = 0 skipped = 0
pos = offset pos = offset
while pos < total and len(collected) < count: while pos < total and len(collected) < count:
@@ -532,7 +532,9 @@ class AsyncMacExClient:
if not self._auto_reconnect: if not self._auto_reconnect:
raise raise
await self._conn.close() await self._conn.close()
self._conn = AsyncExTdxConnection(self._host, self._port, self._timeout, mac_ex_mode=True) self._conn = AsyncExTdxConnection(
self._host, self._port, self._timeout, mac_ex_mode=True
)
await self._conn.connect() await self._conn.connect()
await self._login() await self._login()
return await self._conn.execute(cmd) return await self._conn.execute(cmd)
@@ -571,7 +573,7 @@ class AsyncMacExClient:
return pd.DataFrame() return pd.DataFrame()
total = await self._execute(GetExInstrumentCountCmd()) total = await self._execute(GetExInstrumentCountCmd())
page_size = 1000 page_size = 1000
collected: list = [] collected: list[Any] = []
skipped = 0 skipped = 0
pos = offset pos = offset
while pos < total and len(collected) < count: while pos < total and len(collected) < count:
+1 -2
View File
@@ -5,9 +5,8 @@ from types import TracebackType
from typing import TYPE_CHECKING, TypeVar from typing import TYPE_CHECKING, TypeVar
from ...codec.frame import HEADER_SIZE, decompress_body, parse_header from ...codec.frame import HEADER_SIZE, decompress_body, parse_header
from ...config import get_best_ex_host, get_ex_hosts from ...config import get_best_ex_host
from ...exceptions import TdxConnectionError from ...exceptions import TdxConnectionError
from ..models import KNOWN_EX_HOSTS
if TYPE_CHECKING: if TYPE_CHECKING:
from ...commands.base import BaseCommand from ...commands.base import BaseCommand
-1
View File
@@ -10,7 +10,6 @@ from ...codec.frame import HEADER_SIZE, decompress_body, parse_header
from ...config import get_best_ex_host, get_ex_hosts from ...config import get_best_ex_host, get_ex_hosts
from ...exceptions import TdxConnectionError from ...exceptions import TdxConnectionError
from ..commands.get_instrument_count import GetExInstrumentCountCmd from ..commands.get_instrument_count import GetExInstrumentCountCmd
from ..models import KNOWN_EX_HOSTS
if TYPE_CHECKING: if TYPE_CHECKING:
from ...commands.base import BaseCommand from ...commands.base import BaseCommand
-1
View File
@@ -6,7 +6,6 @@ import warnings
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
import numpy as np
import pandas as pd import pandas as pd
from . import MyTT from . import MyTT
+5 -1
View File
@@ -1189,7 +1189,11 @@ class AsyncMacClient:
fetch_count = max(120 + count, 200) fetch_count = max(120 + count, 200)
df = await self.get_stock_kline( df = await self.get_stock_kline(
market, code, period=period, count=fetch_count, adjust=adjust, market,
code,
period=period,
count=fetch_count,
adjust=adjust,
) )
if df.empty: if df.empty:
return df return df
+4 -1
View File
@@ -59,7 +59,10 @@ class GoodsListCmd(BaseCommand[list[GoodsItem]]):
offset = 2 + i * _RECORD_SIZE offset = 2 + i * _RECORD_SIZE
require_bytes(body, offset, _RECORD_SIZE, f"GoodsListCmd record[{i}]") require_bytes(body, offset, _RECORD_SIZE, f"GoodsListCmd record[{i}]")
category, raw_name, u, index, switch, v1, v2, v3, c1, c2 = unpack_from( category, raw_name, u, index, switch, v1, v2, v3, c1, c2 = unpack_from(
_RECORD_FMT, body, offset, f"GoodsListCmd record[{i}]", _RECORD_FMT,
body,
offset,
f"GoodsListCmd record[{i}]",
) )
name = raw_name.decode("gbk", errors="replace").rstrip("\x00") name = raw_name.decode("gbk", errors="replace").rstrip("\x00")
items.append( items.append(
+1 -3
View File
@@ -131,9 +131,7 @@ class UnusualCmd(BaseCommand[list[UnusualItem]]):
desc, value = _describe_unusual(unusual_type, body[offset + 15 : offset + 28]) desc, value = _describe_unusual(unusual_type, body[offset + 15 : offset + 28])
hour, minute_sec = unpack_from( hour, minute_sec = unpack_from("<BH", body, offset + 29, f"unusual time[{i}]")
"<BH", body, offset + 29, f"unusual time[{i}]"
)
results.append( results.append(
UnusualItem( UnusualItem(
+1 -1
View File
@@ -11,7 +11,7 @@ class SecurityBar:
close: float close: float
high: float high: float
low: float low: float
vol: float # 成交量(股) vol: float # 成交量(股)
amount: float # 成交额(元) amount: float # 成交额(元)
year: int year: int
+55 -55
View File
@@ -18,27 +18,27 @@ class XdxrRecord:
year: int year: int
month: int month: int
day: int day: int
category: int # 事件类型(见下方 CATEGORY_NAMES category: int # 事件类型(见下方 CATEGORY_NAMES
name: str # 事件类型名称 name: str # 事件类型名称
# category == 1(除权除息) # category == 1(除权除息)
fenhong: float | None = None # 每股分红(元;协议原值按每10股) fenhong: float | None = None # 每股分红(元;协议原值按每10股)
peigujia: float | None = None # 配股价(元/股) peigujia: float | None = None # 配股价(元/股)
songzhuangu: float | None = None # 每股送转股比例(协议原值按每10股) songzhuangu: float | None = None # 每股送转股比例(协议原值按每10股)
peigu: float | None = None # 每股配股比例(协议原值按每10股) peigu: float | None = None # 每股配股比例(协议原值按每10股)
# category in [11, 12](扩缩股) # category in [11, 12](扩缩股)
suogu: float | None = None # 缩股比例 suogu: float | None = None # 缩股比例
# category in [13, 14](权证) # category in [13, 14](权证)
xingquanjia: float | None = None # 行权价 xingquanjia: float | None = None # 行权价
fenshu: float | None = None # 分数 fenshu: float | None = None # 分数
# category in [2..10](股本变动类,单位:万股) # category in [2..10](股本变动类,单位:万股)
panqian_liutong: float | None = None # 盘前流通股本(万股) panqian_liutong: float | None = None # 盘前流通股本(万股)
panhou_liutong: float | None = None # 盘后流通股本(万股) panhou_liutong: float | None = None # 盘后流通股本(万股)
qian_zongguben: float | None = None # 前总股本(万股) qian_zongguben: float | None = None # 前总股本(万股)
hou_zongguben: float | None = None # 后总股本(万股) hou_zongguben: float | None = None # 后总股本(万股)
_raw: bytes = field(default=b"", repr=False, compare=False) _raw: bytes = field(default=b"", repr=False, compare=False)
@@ -69,45 +69,45 @@ class FinanceInfo:
code: str code: str
# 股本(万股) # 股本(万股)
liutong_guben: float # 流通股本 liutong_guben: float # 流通股本
zong_guben: float # 总股本 zong_guben: float # 总股本
guojia_gu: float # 国家股 guojia_gu: float # 国家股
faqiren_faren_gu: float # 发起人法人股 faqiren_faren_gu: float # 发起人法人股
faren_gu: float # 法人股 faren_gu: float # 法人股
b_gu: float # B股 b_gu: float # B股
h_gu: float # H股 h_gu: float # H股
zhigong_gu: float # 职工股 zhigong_gu: float # 职工股
# 基本信息 # 基本信息
province: int # 所属省份代码 province: int # 所属省份代码
industry: int # 所属行业代码 industry: int # 所属行业代码
updated_date: int # 财务更新日期 YYYYMMDD updated_date: int # 财务更新日期 YYYYMMDD
ipo_date: int # 上市日期 YYYYMMDD ipo_date: int # 上市日期 YYYYMMDD
gudong_renshu: float # 股东人数 gudong_renshu: float # 股东人数
# 资产负债(元) # 资产负债(元)
zong_zichan: float # 总资产 zong_zichan: float # 总资产
liudong_zichan: float # 流动资产 liudong_zichan: float # 流动资产
guding_zichan: float # 固定资产 guding_zichan: float # 固定资产
wuxing_zichan: float # 无形资产 wuxing_zichan: float # 无形资产
liudong_fuzhai: float # 流动负债 liudong_fuzhai: float # 流动负债
changqi_fuzhai: float # 长期负债 changqi_fuzhai: float # 长期负债
ziben_gongjijin: float # 资本公积金 ziben_gongjijin: float # 资本公积金
jing_zichan: float # 净资产 jing_zichan: float # 净资产
# 利润(元) # 利润(元)
zhuying_shouru: float # 主营收入 zhuying_shouru: float # 主营收入
zhuying_lirun: float # 主营利润 zhuying_lirun: float # 主营利润
yingshou_zhangkuan: float # 应收账款 yingshou_zhangkuan: float # 应收账款
yingye_lirun: float # 营业利润 yingye_lirun: float # 营业利润
touzi_shouyu: float # 投资收益 touzi_shouyu: float # 投资收益
jingying_xianjinliu: float # 经营现金流 jingying_xianjinliu: float # 经营现金流
zong_xianjinliu: float # 总现金流 zong_xianjinliu: float # 总现金流
cunhuo: float # 存货 cunhuo: float # 存货
lirun_zonghe: float # 利润总额 lirun_zonghe: float # 利润总额
shuihou_lirun: float # 税后利润 shuihou_lirun: float # 税后利润
jing_lirun: float # 净利润 jing_lirun: float # 净利润
weifen_lirun: float # 未分配利润 weifen_lirun: float # 未分配利润
# 每股指标 # 每股指标
meigujing_zichan: float # 每股净资产(原 baoliu1 meigujing_zichan: float # 每股净资产(原 baoliu1
@@ -122,36 +122,36 @@ class FinanceInfo:
class CompanyInfoCategory: class CompanyInfoCategory:
"""公司信息文件目录条目""" """公司信息文件目录条目"""
name: str = "" # 目录名(如“最新提示”) name: str = "" # 目录名(如“最新提示”)
filename: str = "" # 文件名(如 '600000.txt' filename: str = "" # 文件名(如 '600000.txt'
start: int = 0 # 内容起始偏移 start: int = 0 # 内容起始偏移
length: int = 0 # 内容长度(字节) length: int = 0 # 内容长度(字节)
@dataclass @dataclass
class FinancialFileInfo: class FinancialFileInfo:
"""财报 zip 文件索引条目(来自 tdxfin/gpcw.txt)。""" """财报 zip 文件索引条目(来自 tdxfin/gpcw.txt)。"""
filename: str # "gpcw20260331.zip" filename: str # "gpcw20260331.zip"
hash: str # MD5 hex digest hash: str # MD5 hex digest
filesize: int # 字节 filesize: int # 字节
@dataclass @dataclass
class FinancialRecord: class FinancialRecord:
"""单只股票的一期历史专业财报记录。""" """单只股票的一期历史专业财报记录。"""
code: str # 6 位股票代码 code: str # 6 位股票代码
market: Market # 市场 market: Market # 市场
report_date: int # 报告期 YYYYMMDD report_date: int # 报告期 YYYYMMDD
fields: list[float] # N 个浮点字段(N = report_size / 4 fields: list[float] # N 个浮点字段(N = report_size / 4
@dataclass @dataclass
class TdxBlock: class TdxBlock:
"""通达信板块信息(行业、概念、风格等)""" """通达信板块信息(行业、概念、风格等)"""
name: str # 板块名称(如“房地产”) name: str # 板块名称(如“房地产”)
category: int # 板块分类(0=行业, 1=地域, 2=概念, 3=风格, 等) category: int # 板块分类(0=行业, 1=地域, 2=概念, 3=风格, 等)
count: int # 板块包含股票数量 count: int # 板块包含股票数量
codes: list[str] # 股票代码列表(6位数字代码) codes: list[str] # 股票代码列表(6位数字代码)
+4 -4
View File
@@ -11,13 +11,13 @@ class SecurityInfo:
market: Market market: Market
code: str code: str
name: str # 股票名称(GBK 解码,截断字节用 replacement char 替代) name: str # 股票名称(GBK 解码,截断字节用 replacement char 替代)
volunit: int # 成交量单位(手 = volunit 股) volunit: int # 成交量单位(手 = volunit 股)
decimal_point: int # 价格小数位数 decimal_point: int # 价格小数位数
pre_close: float # 昨收价(通达信自定义浮点解码) pre_close: float # 昨收价(通达信自定义浮点解码)
# 扩展字段(通过 get_security_list_all 关联 tdxhy.cfg 获得) # 扩展字段(通过 get_security_list_all 关联 tdxhy.cfg 获得)
industry_tdx: str = "" # 通达信行业代码 (如 T1001) industry_tdx: str = "" # 通达信行业代码 (如 T1001)
industry_sw: str = "" # 申万行业代码 (如 X500102) industry_sw: str = "" # 申万行业代码 (如 X500102)
_raw: bytes = field(default=b"", repr=False, compare=False) _raw: bytes = field(default=b"", repr=False, compare=False)
+25 -22
View File
@@ -1,58 +1,61 @@
"""验证市场概况模型。""" """验证市场概况模型。"""
from dataclasses import dataclass from dataclasses import dataclass
@dataclass @dataclass
class MarketStat: class MarketStat:
"""全市场涨跌统计概况。""" """全市场涨跌统计概况。"""
up_count: int # 上涨家数
down_count: int # 下跌家数 up_count: int # 上涨家数
neutral_count: int # 平盘家数 down_count: int # 下跌家数
suspended_count: int # 由 total-(up+down+neutral) 得到的残差项,近似表示停牌/未参与统计家数 neutral_count: int # 平盘家数
total_count: int # 总计(包含停牌) suspended_count: int # 由 total-(up+down+neutral) 得到的残差项,近似表示停牌/未参与统计家数
total_amount: float # 总成交额 total_count: int # 总计(包含停牌)
total_volume: float # 总成交 total_amount: float # 总成交
total_market_cap: float # 总市值(亿元),来自 880001 收盘价,÷100 得万亿 total_volume: float # 总成交量
limit_up_count: int # 涨停家数,来自 880006 close total_market_cap: float # 总市值(亿元),来自 880001 收盘价,÷100 得万亿
limit_down_count: int # 停家数,来自 880006 open limit_up_count: int # 停家数,来自 880006 close
limit_down_count: int # 跌停家数,来自 880006 open
@dataclass @dataclass
class FundFlow: class FundFlow:
"""个股资金流向统计(基于 Tick 数据加权计算)。""" """个股资金流向统计(基于 Tick 数据加权计算)。"""
# 流入项 (Buy) # 流入项 (Buy)
super_in: float # 超大单流入 (>100万) super_in: float # 超大单流入 (>100万)
large_in: float # 大单流入 (>20万 且 <=100万) large_in: float # 大单流入 (>20万 且 <=100万)
medium_in: float # 中单流入 (>4万 且 <=20万) medium_in: float # 中单流入 (>4万 且 <=20万)
small_in: float # 小单流入 (<=4万) small_in: float # 小单流入 (<=4万)
# 流出项 (Sell) # 流出项 (Sell)
super_out: float super_out: float
large_out: float large_out: float
medium_out: float medium_out: float
small_out: float small_out: float
@property @property
def main_net_inflow(self) -> float: def main_net_inflow(self) -> float:
"""主力净流入 (超大单 + 大单)。""" """主力净流入 (超大单 + 大单)。"""
return (self.super_in + self.large_in) - (self.super_out + self.large_out) return (self.super_in + self.large_in) - (self.super_out + self.large_out)
@property @property
def total_net_inflow(self) -> float: def total_net_inflow(self) -> float:
"""全单净流入。""" """全单净流入。"""
return (self.super_in + self.large_in + self.medium_in + self.small_in) - \ return (self.super_in + self.large_in + self.medium_in + self.small_in) - (
(self.super_out + self.large_out + self.medium_out + self.small_out) self.super_out + self.large_out + self.medium_out + self.small_out
)
@dataclass @dataclass
class HistoricalFundFlow: class HistoricalFundFlow:
"""历史日线资金流向条目。""" """历史日线资金流向条目。"""
year: int year: int
month: int month: int
day: int day: int
# 金额项 (单位:元) # 金额项 (单位:元)
super_in: float super_in: float
super_out: float super_out: float
@@ -62,7 +65,7 @@ class HistoricalFundFlow:
medium_out: float medium_out: float
small_in: float small_in: float
small_out: float small_out: float
@property @property
def main_net_inflow(self) -> float: def main_net_inflow(self) -> float:
"""当日主力净流入。""" """当日主力净流入。"""
+1 -1
View File
@@ -69,7 +69,7 @@ def get_last_bar_date(filepath: str | Path) -> int | None:
f.seek(size - _DAILY_FMT.size) f.seek(size - _DAILY_FMT.size)
last_record = f.read(_DAILY_FMT.size) last_record = f.read(_DAILY_FMT.size)
(date_int, *_) = _DAILY_FMT.unpack(last_record) (date_int, *_) = _DAILY_FMT.unpack(last_record)
return date_int return int(date_int)
def _bar_date_int(bar: SecurityBar) -> int: def _bar_date_int(bar: SecurityBar) -> int:
+1 -1
View File
@@ -48,7 +48,7 @@ def get_last_ex_bar_date(filepath: str | Path) -> int | None:
f.seek(size - _EX_DAILY_FMT.size) f.seek(size - _EX_DAILY_FMT.size)
last_record = f.read(_EX_DAILY_FMT.size) last_record = f.read(_EX_DAILY_FMT.size)
(date_int, *_) = _EX_DAILY_FMT.unpack(last_record) (date_int, *_) = _EX_DAILY_FMT.unpack(last_record)
return date_int return int(date_int)
def _bar_date_int(bar: ExDailyBar) -> int: def _bar_date_int(bar: ExDailyBar) -> int:
+8 -1
View File
@@ -8,7 +8,14 @@ from typing import TYPE_CHECKING, TypeVar
from ..codec.frame import HEADER_SIZE, decompress_body, parse_header from ..codec.frame import HEADER_SIZE, decompress_body, parse_header
from ..commands.setup import SETUP_COMMANDS from ..commands.setup import SETUP_COMMANDS
from ..config import get_best_host, get_calc_hosts, get_known_hosts, get_mac_hosts, get_port, get_timeout from ..config import (
get_best_host,
get_calc_hosts,
get_known_hosts,
get_mac_hosts,
get_port,
get_timeout,
)
from ..exceptions import TdxConnectionError from ..exceptions import TdxConnectionError
if TYPE_CHECKING: if TYPE_CHECKING:
+20 -8
View File
@@ -95,7 +95,7 @@ class UnifiedTdxClient:
def get_stock_quotes( def get_stock_quotes(
self, self,
stocks: list[tuple[int, str]], stocks: list[tuple[int, str]],
fields: object = None, fields: Any = None,
) -> pd.DataFrame: ) -> pd.DataFrame:
return self._ensure_mac().get_stock_quotes(stocks, fields) return self._ensure_mac().get_stock_quotes(stocks, fields)
@@ -107,7 +107,7 @@ class UnifiedTdxClient:
sort_type: SortType = SortType.CHANGE_PCT, sort_type: SortType = SortType.CHANGE_PCT,
sort_order: SortOrder = SortOrder.DESC, sort_order: SortOrder = SortOrder.DESC,
exclude_flags: list[FilterType] | None = None, exclude_flags: list[FilterType] | None = None,
fields: object = None, fields: Any = None,
) -> pd.DataFrame: ) -> pd.DataFrame:
return self._ensure_mac().get_stock_quotes_list( return self._ensure_mac().get_stock_quotes_list(
category, start, count, sort_type, sort_order, exclude_flags, fields category, start, count, sort_type, sort_order, exclude_flags, fields
@@ -136,7 +136,13 @@ class UnifiedTdxClient:
params: dict[str, dict[str, int | float]] | None = None, params: dict[str, dict[str, int | float]] | None = None,
) -> pd.DataFrame: ) -> pd.DataFrame:
return self._ensure_mac().get_stock_kline_with_indicators( return self._ensure_mac().get_stock_kline_with_indicators(
market, code, indicators, period, count, adjust, params, market,
code,
indicators,
period,
count,
adjust,
params,
) )
def get_tick_chart( def get_tick_chart(
@@ -185,7 +191,7 @@ class UnifiedTdxClient:
count: int = 100000, count: int = 100000,
sort_type: SortType = SortType.CHANGE_PCT, sort_type: SortType = SortType.CHANGE_PCT,
sort_order: SortOrder = SortOrder.DESC, sort_order: SortOrder = SortOrder.DESC,
fields: object = None, fields: Any = None,
exclude_flags: list[FilterType] | None = None, exclude_flags: list[FilterType] | None = None,
) -> pd.DataFrame: ) -> pd.DataFrame:
return self._ensure_mac().get_board_members( return self._ensure_mac().get_board_members(
@@ -378,7 +384,7 @@ class AsyncUnifiedTdxClient:
async def get_stock_quotes( async def get_stock_quotes(
self, self,
stocks: list[tuple[int, str]], stocks: list[tuple[int, str]],
fields: object = None, fields: Any = None,
) -> pd.DataFrame: ) -> pd.DataFrame:
mac = await self._ensure_mac() mac = await self._ensure_mac()
return await mac.get_stock_quotes(stocks, fields) return await mac.get_stock_quotes(stocks, fields)
@@ -391,7 +397,7 @@ class AsyncUnifiedTdxClient:
sort_type: SortType = SortType.CHANGE_PCT, sort_type: SortType = SortType.CHANGE_PCT,
sort_order: SortOrder = SortOrder.DESC, sort_order: SortOrder = SortOrder.DESC,
exclude_flags: list[FilterType] | None = None, exclude_flags: list[FilterType] | None = None,
fields: object = None, fields: Any = None,
) -> pd.DataFrame: ) -> pd.DataFrame:
mac = await self._ensure_mac() mac = await self._ensure_mac()
return await mac.get_stock_quotes_list( return await mac.get_stock_quotes_list(
@@ -423,7 +429,13 @@ class AsyncUnifiedTdxClient:
) -> pd.DataFrame: ) -> pd.DataFrame:
mac = await self._ensure_mac() mac = await self._ensure_mac()
return await mac.get_stock_kline_with_indicators( return await mac.get_stock_kline_with_indicators(
market, code, indicators, period, count, adjust, params, market,
code,
indicators,
period,
count,
adjust,
params,
) )
async def get_tick_chart( async def get_tick_chart(
@@ -478,7 +490,7 @@ class AsyncUnifiedTdxClient:
count: int = 100000, count: int = 100000,
sort_type: SortType = SortType.CHANGE_PCT, sort_type: SortType = SortType.CHANGE_PCT,
sort_order: SortOrder = SortOrder.DESC, sort_order: SortOrder = SortOrder.DESC,
fields: object = None, fields: Any = None,
exclude_flags: list[FilterType] | None = None, exclude_flags: list[FilterType] | None = None,
) -> pd.DataFrame: ) -> pd.DataFrame:
mac = await self._ensure_mac() mac = await self._ensure_mac()
+1
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import numpy as np import numpy as np
import pandas as pd import pandas as pd
import pytest import pytest
from easy_tdx import MyTT from easy_tdx import MyTT
from easy_tdx.backtest.strategy import Strategy from easy_tdx.backtest.strategy import Strategy
+2 -4
View File
@@ -318,7 +318,7 @@ def test_position_aware_buy_sell_alternation():
engine = BacktestEngine(PositionAwareStrategy, cash=100000) engine = BacktestEngine(PositionAwareStrategy, cash=100000)
result = engine.run(df) result = engine.run(df)
trades = result.trades[result.trades["rejected"] == False] trades = result.trades[~result.trades["rejected"]]
directions = trades["direction"].tolist() directions = trades["direction"].tolist()
# Must have both BUYs and SELLs # Must have both BUYs and SELLs
@@ -339,9 +339,7 @@ def test_position_aware_no_duplicate_buys():
engine = BacktestEngine(PositionAwareStrategy, cash=100000) engine = BacktestEngine(PositionAwareStrategy, cash=100000)
result = engine.run(df) result = engine.run(df)
buy_trades = result.trades[ buy_trades = result.trades[(result.trades["direction"] == "BUY") & (~result.trades["rejected"])]
(result.trades["direction"] == "BUY") & (result.trades["rejected"] == False)
]
# Each BUY's size should be reasonable (not tiny leftover from exhausted cash) # Each BUY's size should be reasonable (not tiny leftover from exhausted cash)
if len(buy_trades) > 1: if len(buy_trades) > 1:
+36 -26
View File
@@ -33,12 +33,14 @@ def _make_equity_curve(n: int = 252, total_return: float = 0.1) -> pd.DataFrame:
drawdown = peak - total drawdown = peak - total
drawdown_pct = np.divide(drawdown, peak, out=np.zeros_like(drawdown), where=(peak != 0)) drawdown_pct = np.divide(drawdown, peak, out=np.zeros_like(drawdown), where=(peak != 0))
return pd.DataFrame({ return pd.DataFrame(
"datetime": np.arange(n), {
"total": total, "datetime": np.arange(n),
"drawdown": drawdown, "total": total,
"drawdown_pct": drawdown_pct, "drawdown": drawdown,
}) "drawdown_pct": drawdown_pct,
}
)
def _make_trades() -> pd.DataFrame: def _make_trades() -> pd.DataFrame:
@@ -48,11 +50,13 @@ def _make_trades() -> pd.DataFrame:
包含 direction, pnl, rejected 的 DataFrame 包含 direction, pnl, rejected 的 DataFrame
4 条交易: BUY@100, SELL@105(pnl=500), BUY@95, SELL@90(pnl=-500) 4 条交易: BUY@100, SELL@105(pnl=500), BUY@95, SELL@90(pnl=-500)
""" """
return pd.DataFrame({ return pd.DataFrame(
"direction": ["BUY", "SELL", "BUY", "SELL"], {
"pnl": [0, 500, 0, -500], "direction": ["BUY", "SELL", "BUY", "SELL"],
"rejected": [False, False, False, False], "pnl": [0, 500, 0, -500],
}) "rejected": [False, False, False, False],
}
)
def test_total_return() -> None: def test_total_return() -> None:
@@ -70,20 +74,24 @@ def test_total_return() -> None:
def test_max_drawdown_never_exceeds_100_pct() -> None: def test_max_drawdown_never_exceeds_100_pct() -> None:
"""测试最大回撤永远不超过 100%(从峰值的跌幅)。""" """测试最大回撤永远不超过 100%(从峰值的跌幅)。"""
# 模拟先涨 5 倍再腰斩的资金曲线 # 模拟先涨 5 倍再腰斩的资金曲线
total = np.concatenate([ total = np.concatenate(
np.linspace(100000, 600000, 126), # 涨到 60 万 [
np.linspace(600000, 300000, 126), # 30 万 np.linspace(100000, 600000, 126), # 60 万
]) np.linspace(600000, 300000, 126), # 跌到 30 万
]
)
peak = np.maximum.accumulate(total) peak = np.maximum.accumulate(total)
drawdown = peak - total drawdown = peak - total
drawdown_pct = np.divide(drawdown, peak, out=np.zeros_like(drawdown), where=(peak != 0)) drawdown_pct = np.divide(drawdown, peak, out=np.zeros_like(drawdown), where=(peak != 0))
equity = pd.DataFrame({ equity = pd.DataFrame(
"datetime": np.arange(252), {
"total": total, "datetime": np.arange(252),
"drawdown": drawdown, "total": total,
"drawdown_pct": drawdown_pct, "drawdown": drawdown,
}) "drawdown_pct": drawdown_pct,
}
)
trades = _make_trades() trades = _make_trades()
analyzer = PerformanceAnalyzer(equity, trades) analyzer = PerformanceAnalyzer(equity, trades)
@@ -287,11 +295,13 @@ def test_rejected_trades() -> None:
equity = _make_equity_curve(n=252, total_return=0.1) equity = _make_equity_curve(n=252, total_return=0.1)
# 创建包含被拒绝交易的记录 # 创建包含被拒绝交易的记录
trades = pd.DataFrame({ trades = pd.DataFrame(
"direction": ["BUY", "SELL", "SELL", "SELL"], {
"pnl": [0, 500, 0, -500], "direction": ["BUY", "SELL", "SELL", "SELL"],
"rejected": [False, False, True, False], "pnl": [0, 500, 0, -500],
}) "rejected": [False, False, True, False],
}
)
analyzer = PerformanceAnalyzer(equity, trades) analyzer = PerformanceAnalyzer(equity, trades)
metrics = analyzer.compute() metrics = analyzer.compute()
+2 -2
View File
@@ -19,7 +19,7 @@ def _k(
o: float, o: float,
c: float, c: float,
h: float, h: float,
l: float, l: float, # noqa: E741
a: float = 0.0, a: float = 0.0,
) -> Kline: ) -> Kline:
"""快速构造 Kline。""" """快速构造 Kline。"""
@@ -40,7 +40,7 @@ def _ck(
o: float, o: float,
c: float, c: float,
h: float, h: float,
l: float, l: float, # noqa: E741
merged_count: int = 1, merged_count: int = 1,
direction: str = "", direction: str = "",
) -> CLKline: ) -> CLKline:
+2 -2
View File
@@ -17,7 +17,7 @@ def _k(
o: float, o: float,
c: float, c: float,
h: float, h: float,
l: float, l: float, # noqa: E741
a: float = 0.0, a: float = 0.0,
) -> Kline: ) -> Kline:
return Kline( return Kline(
@@ -37,7 +37,7 @@ def _ck(
o: float, o: float,
c: float, c: float,
h: float, h: float,
l: float, l: float, # noqa: E741
merged_count: int = 1, merged_count: int = 1,
direction: str = "", direction: str = "",
) -> CLKline: ) -> CLKline:
+4 -7
View File
@@ -13,7 +13,7 @@ from easy_tdx.chanlun.types import CLKline, Kline
# ── helpers ────────────────────────────────────────────────────────────── # ── helpers ──────────────────────────────────────────────────────────────
def _k(idx: int, dt: str, o: float, c: float, h: float, l: float, a: float = 0.0) -> Kline: def _k(idx: int, dt: str, o: float, c: float, h: float, l: float, a: float = 0.0) -> Kline: # noqa: E741
return Kline( return Kline(
index=idx, index=idx,
date=datetime.strptime(dt, "%Y-%m-%d"), date=datetime.strptime(dt, "%Y-%m-%d"),
@@ -55,9 +55,8 @@ class TestMultiLevel:
def test_multi_level_basic(self) -> None: def test_multi_level_basic(self) -> None:
"""多级别分析应返回各级别结果。""" """多级别分析应返回各级别结果。"""
from easy_tdx.chanlun.multi_level import MultiLevelAnalyser
from easy_tdx.chanlun.analyser import ChanlunAnalyser from easy_tdx.chanlun.analyser import ChanlunAnalyser
from easy_tdx.chanlun.multi_level import MultiLevelAnalyser
df_daily = _make_df(100) df_daily = _make_df(100)
df_30min = _make_df(200) df_30min = _make_df(200)
@@ -77,9 +76,8 @@ class TestMultiLevel:
def test_multi_level_low_level_qs(self) -> None: def test_multi_level_low_level_qs(self) -> None:
"""高级别笔对应的低级别趋势信息。""" """高级别笔对应的低级别趋势信息。"""
from easy_tdx.chanlun.multi_level import MultiLevelAnalyser
from easy_tdx.chanlun.analyser import ChanlunAnalyser from easy_tdx.chanlun.analyser import ChanlunAnalyser
from easy_tdx.chanlun.multi_level import MultiLevelAnalyser
df_daily = _make_df(100) df_daily = _make_df(100)
df_30min = _make_df(200) df_30min = _make_df(200)
@@ -158,9 +156,8 @@ class TestZsd:
def test_zsd_from_xds(self) -> None: def test_zsd_from_xds(self) -> None:
"""线段应能组合为走势段。""" """线段应能组合为走势段。"""
from easy_tdx.chanlun.zsd import find_zsds
from easy_tdx.chanlun.xd import find_xds from easy_tdx.chanlun.xd import find_xds
from easy_tdx.chanlun.zsd import find_zsds
cks = [ cks = [
CLKline( CLKline(
+1 -3
View File
@@ -61,9 +61,7 @@ class TestParseFinancialDat:
index_entries: list[bytes] = [] index_entries: list[bytes] = []
for i, (code, market_byte, _) in enumerate(stocks): for i, (code, market_byte, _) in enumerate(stocks):
index_entries.append( index_entries.append(
struct.pack( struct.pack(index_fmt, code.encode("ascii"), bytes([market_byte]), offsets[i])
index_fmt, code.encode("ascii"), bytes([market_byte]), offsets[i]
)
) )
return header + b"".join(index_entries) + b"".join(data_chunks) return header + b"".join(index_entries) + b"".join(data_chunks)
+11 -10
View File
@@ -13,28 +13,29 @@ def test_heartbeat_sends_periodically():
mock_conn = mock_conn_cls.return_value mock_conn = mock_conn_cls.return_value
mock_conn.connect = AsyncMock() mock_conn.connect = AsyncMock()
mock_conn.close = AsyncMock() mock_conn.close = AsyncMock()
# 记录调用次数 # 记录调用次数
call_count = 0 call_count = 0
async def mock_execute(cmd): async def mock_execute(cmd):
nonlocal call_count nonlocal call_count
call_count += 1 call_count += 1
return 5 # 模拟 get_security_count 返回值 return 5 # 模拟 get_security_count 返回值
mock_conn.execute.side_effect = mock_execute mock_conn.execute.side_effect = mock_execute
# 设置非常短的心跳间隔以便测试 # 设置非常短的心跳间隔以便测试
client = AsyncTdxClient("127.0.0.1", heartbeat_interval=0.1) client = AsyncTdxClient("127.0.0.1", heartbeat_interval=0.1)
await client.connect() await client.connect()
# 等待几次心跳周期 # 等待几次心跳周期
await asyncio.sleep(0.35) await asyncio.sleep(0.35)
await client.close() await client.close()
# 0.35s 应该触发约 3 次心跳 (0.1, 0.2, 0.3) # 0.35s 应该触发约 3 次心跳 (0.1, 0.2, 0.3)
assert call_count >= 3 assert call_count >= 3
asyncio.run(run_test()) asyncio.run(run_test())
@@ -45,14 +46,14 @@ def test_heartbeat_stops_on_close():
mock_conn.connect = AsyncMock() mock_conn.connect = AsyncMock()
mock_conn.close = AsyncMock() mock_conn.close = AsyncMock()
mock_conn.execute = AsyncMock(return_value=5) mock_conn.execute = AsyncMock(return_value=5)
client = AsyncTdxClient("127.0.0.1", heartbeat_interval=0.01) client = AsyncTdxClient("127.0.0.1", heartbeat_interval=0.01)
await client.connect() await client.connect()
assert client._heartbeat_task is not None assert client._heartbeat_task is not None
task = client._heartbeat_task task = client._heartbeat_task
await client.close() await client.close()
assert client._heartbeat_task is None assert client._heartbeat_task is None
assert task.done() or task.cancelled() assert task.done() or task.cancelled()
@@ -65,5 +66,5 @@ if __name__ == "__main__":
await test_heartbeat_sends_periodically() await test_heartbeat_sends_periodically()
await test_heartbeat_stops_on_close() await test_heartbeat_stops_on_close()
print("Heartbeat tests passed!") print("Heartbeat tests passed!")
asyncio.run(run()) asyncio.run(run())
+12 -10
View File
@@ -8,7 +8,7 @@ import numpy as np
import pandas as pd import pandas as pd
import pytest import pytest
from easy_tdx.indicator import compute_indicators, list_indicators, _REGISTRY from easy_tdx.indicator import _REGISTRY, compute_indicators, list_indicators
def _make_ohlcv(n: int = 200, seed: int = 42) -> pd.DataFrame: def _make_ohlcv(n: int = 200, seed: int = 42) -> pd.DataFrame:
@@ -18,15 +18,17 @@ def _make_ohlcv(n: int = 200, seed: int = 42) -> pd.DataFrame:
low = close - np.abs(rng.standard_normal(n)) low = close - np.abs(rng.standard_normal(n))
open_ = low + (high - low) * rng.random(n) open_ = low + (high - low) * rng.random(n)
vol = (rng.random(n) * 1e6).astype(float) vol = (rng.random(n) * 1e6).astype(float)
return pd.DataFrame({ return pd.DataFrame(
"datetime": pd.date_range("2024-01-01", periods=n, freq="D"), {
"open": open_, "datetime": pd.date_range("2024-01-01", periods=n, freq="D"),
"high": high, "open": open_,
"low": low, "high": high,
"close": close, "low": low,
"vol": vol, "close": close,
"amount": vol * close, "vol": vol,
}) "amount": vol * close,
}
)
class TestRegistry: class TestRegistry:
+46 -21
View File
@@ -19,8 +19,20 @@ def test_security_bars_exact_layout():
# Payload: 0x052D, 1 (Market.SH), "600000", 4 (KlineCategory.DAY), 1, 0 (start), 10, 0, 0, 0 # Payload: 0x052D, 1 (Market.SH), "600000", 4 (KlineCategory.DAY), 1, 0 (start), 10, 0, 0, 0
expected = struct.pack( expected = struct.pack(
"<HIHHHH6sHHHHIIH", "<HIHHHH6sHHHHIIH",
0x010C, 0x01016408, 0x001C, 0x001C, 0x010C,
0x052D, 1, b"600000", 4, 1, 0, 10, 0, 0, 0 0x01016408,
0x001C,
0x001C,
0x052D,
1,
b"600000",
4,
1,
0,
10,
0,
0,
0,
) )
assert req == expected assert req == expected
assert len(req) == 38 assert len(req) == 38
@@ -34,8 +46,20 @@ def test_history_fund_flow_exact_layout():
# Payload: 0x052D, 1 (Market.SH), "600000", 22, 1, 0, 10, 0, 0, 0 # Payload: 0x052D, 1 (Market.SH), "600000", 22, 1, 0, 10, 0, 0, 0
expected = struct.pack( expected = struct.pack(
"<HIHHHH6sHHHHIIH", "<HIHHHH6sHHHHIIH",
0x010C, 0x01016408, 0x001C, 0x001C, 0x010C,
0x052D, 1, b"600000", 22, 1, 0, 10, 0, 0, 0 0x01016408,
0x001C,
0x001C,
0x052D,
1,
b"600000",
22,
1,
0,
10,
0,
0,
0,
) )
assert req == expected assert req == expected
assert len(req) == 38 assert len(req) == 38
@@ -56,16 +80,16 @@ def test_security_quotes_limit_mapping():
from easy_tdx.codec.price import put_price from easy_tdx.codec.price import put_price
cmd = GetSecurityQuotesCmd([(Market.SH, "600000")]) cmd = GetSecurityQuotesCmd([(Market.SH, "600000")])
# 构造响应报文 # 构造响应报文
body = bytearray(b"\x00\x00") body = bytearray(b"\x00\x00")
body.extend(struct.pack("<H", 1)) body.extend(struct.pack("<H", 1))
# Record: Market(B), Code(6s), Active1(H) + ... # Record: Market(B), Code(6s), Active1(H) + ...
body.extend(struct.pack("<B6sH", 1, b"600000", 0)) body.extend(struct.pack("<B6sH", 1, b"600000", 0))
body.extend(put_price(1010)) # price_raw body.extend(put_price(1010)) # price_raw
body.extend(put_price(-5)) # last_close_diff body.extend(put_price(-5)) # last_close_diff
body.extend(put_price(0)) body.extend(put_price(0))
body.extend(put_price(0)) body.extend(put_price(0))
body.extend(put_price(0)) body.extend(put_price(0))
@@ -86,7 +110,7 @@ def test_security_quotes_limit_mapping():
body.extend(put_price(0)) body.extend(put_price(0))
body.extend(put_price(0)) body.extend(put_price(0))
body.extend(struct.pack("<hH", 0, 0)) body.extend(struct.pack("<hH", 0, 0))
quotes = cmd.parse_response(bytes(body)) quotes = cmd.parse_response(bytes(body))
q = quotes[0] q = quotes[0]
assert q.limit_up is None assert q.limit_up is None
@@ -120,18 +144,19 @@ def test_compute_price_limits_for_indices():
def test_compute_price_limits_for_newly_listed_stocks(): def test_compute_price_limits_for_newly_listed_stocks():
"""上市初期限价窗口应返回 None。""" """上市初期限价窗口应返回 None。"""
assert compute_price_limits( assert compute_price_limits(Market.SH, "600001", "主板新股", 10.0, listed_days=5) == (
Market.SH, "600001", "主板新股", 10.0, listed_days=5 None,
) == (None, None) None,
assert compute_price_limits( )
Market.SH, "600001", "主板新股", 10.0, listed_days=6 assert compute_price_limits(Market.SH, "600001", "主板新股", 10.0, listed_days=6) == (11.0, 9.0)
) == (11.0, 9.0) assert compute_price_limits(Market.BJ, "920002", "北交所新股", 84.36, listed_days=1) == (
assert compute_price_limits( None,
Market.BJ, "920002", "北交所新股", 84.36, listed_days=1 None,
) == (None, None) )
assert compute_price_limits( assert compute_price_limits(Market.BJ, "920002", "北交所新股", 84.36, listed_days=2) == (
Market.BJ, "920002", "北交所新股", 84.36, listed_days=2 109.67,
) == (109.67, 59.05) 59.05,
)
def test_history_fund_flow_uses_uint32_volume_words(): def test_history_fund_flow_uses_uint32_volume_words():
+1 -1
View File
@@ -10,6 +10,7 @@ from unittest.mock import MagicMock, patch
import pandas as pd import pandas as pd
import pytest import pytest
from easy_tdx.models.bar import SecurityBar from easy_tdx.models.bar import SecurityBar
# ── 辅助:构造 SecurityBar ──────────────────────────────────────────────── # ── 辅助:构造 SecurityBar ────────────────────────────────────────────────
@@ -300,7 +301,6 @@ class TestScanOne:
scanner._cash = 100000.0 scanner._cash = 100000.0
scanner._commission = 0.0003 scanner._commission = 0.0003
bars = _make_bars(100)
with patch.object(scanner, "_scan_one") as mock_scan: with patch.object(scanner, "_scan_one") as mock_scan:
# 不产生信号时返回 None # 不产生信号时返回 None
mock_scan.return_value = None mock_scan.return_value = None
+7 -4
View File
@@ -26,10 +26,13 @@ def test_sync_connection_closes_socket_when_setup_fails() -> None:
sock = _FakeSocket() sock = _FakeSocket()
conn = TdxConnection("127.0.0.1", port=7709, timeout=0.2) conn = TdxConnection("127.0.0.1", port=7709, timeout=0.2)
with patch("easy_tdx.transport.sync.socket.socket", return_value=sock), patch.object( with (
TdxConnection, patch("easy_tdx.transport.sync.socket.socket", return_value=sock),
"_send_setup", patch.object(
side_effect=TdxConnectionError("setup failed"), TdxConnection,
"_send_setup",
side_effect=TdxConnectionError("setup failed"),
),
): ):
try: try:
conn.connect() conn.connect()