This commit is contained in:
GitHub
2026-09-02 13:16:41 +08:00
21 changed files with 1378 additions and 55 deletions
+341
View File
@@ -0,0 +1,341 @@
{
"benchmark_comparison": {
"alpha": -0.07509968942791662,
"beta": 0.52345815483129,
"information_ratio": -0.4168372094924506,
"tracking_error": 0.1915582998133785
},
"buy_hold": {
"annual_return": -0.02751052343019722,
"calmar": -0.10790264140303352,
"max_drawdown": 0.2549569044138692,
"sharpe": -0.07276274597183686,
"total_return": -0.043207472350363485,
"volatility": 0.2753412305613209
},
"meta": {
"bars": 400,
"cash": 100000.0,
"note": "regen: EASY_TDX_REGEN_GOLDEN=1 pytest tests/unit/test_golden_backtest.py",
"seed": 20260902,
"tolerance": {
"abs": 1e-06,
"rel": 1e-06
}
},
"rules": {
"bracket_pct": {
"total_return": 0.06621316999999993,
"total_trades": 1,
"trades": [
[
"BUY",
10.3,
20240102
],
[
"SELL",
11.0,
20240105
]
]
},
"stop_loss": {
"total_return": -0.11904648000000007,
"total_trades": 1,
"trades": [
[
"BUY",
10.2,
20240102
],
[
"SELL",
9.0,
20240108
]
]
},
"take_profit": {
"total_return": 0.06621316999999993,
"total_trades": 1,
"trades": [
[
"BUY",
10.3,
20240102
],
[
"SELL",
11.0,
20240105
]
]
},
"trailing_stop": {
"total_return": 0.027762419999999954,
"total_trades": 1,
"trades": [
[
"BUY",
10.2,
20240102
],
[
"SELL",
10.5,
20240112
]
]
}
},
"strategies": {
"atr_breakout": {
"cvar_95": 0.029816014464276553,
"max_consecutive_losses": 2,
"max_consecutive_wins": 1,
"max_drawdown": 0.2338437267295321,
"sharpe": -0.1980068530470557,
"sqn": -0.2643143297795612,
"total_return": -0.051365472018247815,
"total_trades": 5,
"ulcer_index": 0.11610199605370222,
"var_95": 0.023167064579450905,
"win_rate": 0.2
},
"bbi": {
"cvar_95": 0.028403780640227493,
"max_consecutive_losses": 11,
"max_consecutive_wins": 6,
"max_drawdown": 0.17316305538486726,
"sharpe": -0.047920869432565044,
"sqn": 0.2034947102900509,
"total_return": 0.0015367014340572638,
"total_trades": 37,
"ulcer_index": 0.09154821119005127,
"var_95": 0.02185464362709669,
"win_rate": 0.43243243243243246
},
"bias_reversal": {
"cvar_95": 0.031800618199536355,
"max_consecutive_losses": 4,
"max_consecutive_wins": 6,
"max_drawdown": 0.28538313496863427,
"sharpe": -0.8098413586757496,
"sqn": -0.6927111806707453,
"total_return": -0.22014868093324402,
"total_trades": 45,
"ulcer_index": 0.1949515199887598,
"var_95": 0.02439437484336824,
"win_rate": 0.5111111111111111
},
"boll_breakout": {
"cvar_95": 0.025123143703972416,
"max_consecutive_losses": 1,
"max_consecutive_wins": 2,
"max_drawdown": 0.12529045393048568,
"sharpe": -0.08270429911478913,
"sqn": 0.984709712259246,
"total_return": 0.00949379410277551,
"total_trades": 3,
"ulcer_index": 0.039203351555811915,
"var_95": 0.015350573492733575,
"win_rate": 0.6666666666666666
},
"cci": {
"cvar_95": 0.028065506761634905,
"max_consecutive_losses": 1,
"max_consecutive_wins": 3,
"max_drawdown": 0.17514368495494725,
"sharpe": -0.4733373193054346,
"sqn": -0.33416732892726264,
"total_return": -0.1047789450922677,
"total_trades": 11,
"ulcer_index": 0.0895732932132124,
"var_95": 0.019114854583397116,
"win_rate": 0.6363636363636364
},
"dmi": {
"cvar_95": 0.029255190955404537,
"max_consecutive_losses": 5,
"max_consecutive_wins": 3,
"max_drawdown": 0.26277279842089457,
"sharpe": -0.538224272020581,
"sqn": -0.6542541127994779,
"total_return": -0.144868582491816,
"total_trades": 22,
"ulcer_index": 0.14141379126490972,
"var_95": 0.022084559354451774,
"win_rate": 0.45454545454545453
},
"donchian": {
"cvar_95": -0.0,
"max_consecutive_losses": 0,
"max_consecutive_wins": 0,
"max_drawdown": 0.0,
"sharpe": 0.0,
"sqn": 0.0,
"total_return": 0.0,
"total_trades": 0,
"ulcer_index": 0.0,
"var_95": -0.0,
"win_rate": 0.0
},
"dpo": {
"cvar_95": 0.02721297612445824,
"max_consecutive_losses": 6,
"max_consecutive_wins": 4,
"max_drawdown": 0.19787887049273278,
"sharpe": 0.08973761311003028,
"sqn": 0.4060312738886599,
"total_return": 0.04626258446140685,
"total_trades": 40,
"ulcer_index": 0.09456618993344078,
"var_95": 0.019685727992254924,
"win_rate": 0.45
},
"ema_cross": {
"cvar_95": 0.030512787117540286,
"max_consecutive_losses": 5,
"max_consecutive_wins": 1,
"max_drawdown": 0.2959272480843976,
"sharpe": -0.7875446987996052,
"sqn": -1.4136352678571986,
"total_return": -0.21673831411102973,
"total_trades": 8,
"ulcer_index": 0.17424109127336226,
"var_95": 0.02416619544856333,
"win_rate": 0.125
},
"emv": {
"cvar_95": 0.029127493175167347,
"max_consecutive_losses": 6,
"max_consecutive_wins": 3,
"max_drawdown": 0.3223214933637952,
"sharpe": -1.0544258453115651,
"sqn": -1.6556723755607416,
"total_return": -0.2575566534714613,
"total_trades": 28,
"ulcer_index": 0.21540763583933592,
"var_95": 0.02157076164724241,
"win_rate": 0.32142857142857145
},
"fsl": {
"cvar_95": 0.028932815510588083,
"max_consecutive_losses": 5,
"max_consecutive_wins": 2,
"max_drawdown": 0.16041976356729326,
"sharpe": -0.12011672214054765,
"sqn": -0.07772875586670183,
"total_return": -0.026169388111291436,
"total_trades": 14,
"ulcer_index": 0.08345129720689942,
"var_95": 0.022744109351729155,
"win_rate": 0.35714285714285715
},
"kdj_cross": {
"cvar_95": 0.027898201822013535,
"max_consecutive_losses": 3,
"max_consecutive_wins": 6,
"max_drawdown": 0.10762922092720545,
"sharpe": 0.8589847738542853,
"sqn": 1.2700892113941968,
"total_return": 0.33234070489431433,
"total_trades": 28,
"ulcer_index": 0.05288447475839552,
"var_95": 0.019645713150630486,
"win_rate": 0.5357142857142857
},
"keltner": {
"cvar_95": 0.02977493291904796,
"max_consecutive_losses": 2,
"max_consecutive_wins": 1,
"max_drawdown": 0.22976199965457847,
"sharpe": -0.1732213286120034,
"sqn": -0.22326773643387887,
"total_return": -0.04444609288676726,
"total_trades": 4,
"ulcer_index": 0.11754644293259853,
"var_95": 0.02351295669073764,
"win_rate": 0.25
},
"ma_cross": {
"cvar_95": 0.02953584331940908,
"max_consecutive_losses": 3,
"max_consecutive_wins": 4,
"max_drawdown": 0.22131421204998106,
"sharpe": -0.4990885286170613,
"sqn": -1.0839091744378302,
"total_return": -0.1326834663173594,
"total_trades": 11,
"ulcer_index": 0.11891561252900337,
"var_95": 0.022064133071800284,
"win_rate": 0.5454545454545454
},
"macd": {
"cvar_95": 0.02839428517023986,
"max_consecutive_losses": 6,
"max_consecutive_wins": 5,
"max_drawdown": 0.18473872916346823,
"sharpe": -0.38338812036224335,
"sqn": -0.5295484249243184,
"total_return": -0.10413146619958658,
"total_trades": 17,
"ulcer_index": 0.10417476558778298,
"var_95": 0.02189769434012649,
"win_rate": 0.35294117647058826
},
"rsi_reversal": {
"cvar_95": 0.030776659581515254,
"max_consecutive_losses": 1,
"max_consecutive_wins": 1,
"max_drawdown": 0.23599475582323245,
"sharpe": 0.4065196788206701,
"sqn": 0.6504806756551088,
"total_return": 0.16567827342668773,
"total_trades": 2,
"ulcer_index": 0.09450845521195947,
"var_95": 0.024060510820245292,
"win_rate": 0.5
},
"triple_ma": {
"cvar_95": 0.030748053405195853,
"max_consecutive_losses": 2,
"max_consecutive_wins": 1,
"max_drawdown": 0.26736051511671544,
"sharpe": -0.2867281890227494,
"sqn": -2.2739244432234624,
"total_return": -0.07872900419821216,
"total_trades": 4,
"ulcer_index": 0.13253815816514244,
"var_95": 0.023068752460779107,
"win_rate": 0.25
},
"trix": {
"cvar_95": 0.027724285540264466,
"max_consecutive_losses": 3,
"max_consecutive_wins": 4,
"max_drawdown": 0.29063024861099856,
"sharpe": -0.7642482349371779,
"sqn": -0.9463810886485136,
"total_return": -0.1947048613134198,
"total_trades": 12,
"ulcer_index": 0.17538728786286353,
"var_95": 0.021929213323307422,
"win_rate": 0.5
},
"wr_reversal": {
"cvar_95": -0.0,
"max_consecutive_losses": 0,
"max_consecutive_wins": 0,
"max_drawdown": 0.0,
"sharpe": 0.0,
"sqn": 0.0,
"total_return": 0.0,
"total_trades": 0,
"ulcer_index": 0.0,
"var_95": -0.0,
"win_rate": 0.0
}
}
}
+292
View File
@@ -0,0 +1,292 @@
"""黄金测试(golden tests):回测引擎指标快照回归(v1.28 新增)。
借鉴 akquant 的 golden 测试机制:把「内置策略在固定随机种子合成数据上的
全部绩效指标」与「交易规则场景(止损/止盈/移动止损/OCO/费率)的成交明细」
锁定为 JSON 基线(``tests/golden/backtest_metrics.json``),每次引擎改动后
跑一遍比对——撮合、费率、信号时序任何静默漂移都会在这里爆出来。
生成/更新基线::
EASY_TDX_REGEN_GOLDEN=1 python -m pytest tests/unit/test_golden_backtest.py
比对容差:rel=1e-6 / abs=1e-6——紧到能抓住费率或成交时点级别的逻辑漂移
(通常引起 >0.001 的变动),松到容忍跨平台浮点求和顺序的尾数噪声。
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
import numpy as np
import pandas as pd
import pytest
from easy_tdx.backtest.benchmark import (
compute_benchmark_comparison,
run_buy_hold_benchmark,
)
from easy_tdx.backtest.engine import BacktestEngine
from easy_tdx.backtest.strategies import builtin # noqa: F401 # 触发注册
from easy_tdx.backtest.strategies.registry import _REGISTRY
from easy_tdx.backtest.strategy import Strategy
GOLDEN_PATH = Path(__file__).resolve().parents[1] / "golden" / "backtest_metrics.json"
REGEN = os.environ.get("EASY_TDX_REGEN_GOLDEN", "") == "1"
# 与基线 meta 一致的固定参数
SEED = 20260902
BARS = 400
CASH = 100000.0
# 内置策略锁定的指标子集(全部为确定性数值;int 与 float 分开比对)
STRATEGY_METRICS_FLOAT = (
"total_return",
"max_drawdown",
"sharpe",
"win_rate",
"ulcer_index",
"var_95",
"cvar_95",
"sqn",
)
STRATEGY_METRICS_INT = (
"total_trades",
"max_consecutive_wins",
"max_consecutive_losses",
)
def _golden_df() -> pd.DataFrame:
"""固定种子的合成日线(几何随机游走 + 温和上行漂移)。"""
rng = np.random.default_rng(SEED)
close = 20.0 * np.exp(np.cumsum(rng.normal(0.0004, 0.018, BARS)))
high = close * (1 + np.abs(rng.normal(0, 0.008, BARS)))
low = close * (1 - np.abs(rng.normal(0, 0.008, BARS)))
open_ = low + (high - low) * rng.uniform(0, 1, BARS)
vol = rng.uniform(5e5, 5e6, BARS)
return pd.DataFrame(
{
"datetime": pd.date_range("2023-01-02", periods=BARS, freq="B"),
"open": open_,
"high": high,
"low": low,
"close": close,
"vol": vol,
"amount": close * vol,
}
)
# ── 规则场景策略(手工构造行情路径,锁定触发语义本身) ───────────────────────
class _BuyOnce(Strategy):
"""首根买入(可携带 bracket 参数),不再主动交易;无参数时即买入持有。"""
def __init__(self, **bracket: Any) -> None:
super().__init__()
self._bracket: dict[str, Any] = bracket
self._bought = False
def init(self) -> None:
pass
def next(self) -> None:
if not self._bought:
self.buy(**self._bracket)
self._bought = True
def _rule_df(closes: list[float]) -> pd.DataFrame:
"""按收盘价序列构造无随机因素的 OHLChigh/low = close ±1%)。"""
arr = np.asarray(closes, dtype=float)
n = len(arr)
return pd.DataFrame(
{
"datetime": pd.date_range("2024-01-01", periods=n, freq="B"),
"open": arr,
"high": arr * 1.01,
"low": arr * 0.99,
"close": arr,
"vol": [1000.0] * n,
"amount": arr * 1000,
}
)
def _run_rule(closes: list[float], **bracket: Any) -> dict[str, Any]:
"""跑规则场景,返回待锁定的摘要(成交明细 + 关键指标)。"""
result = BacktestEngine(_BuyOnce(**bracket), cash=CASH).run(_rule_df(closes))
trades = [
[t.direction, round(float(t.price), 4), int(pd.Timestamp(t.datetime).strftime("%Y%m%d"))]
for t in result.trades.itertuples()
if not t.rejected
]
return {
"trades": trades,
"total_return": float(result.performance["total_return"]),
"total_trades": int(result.performance["total_trades"]),
}
RULE_SCENARIOS: dict[str, dict[str, Any]] = {
# 跌破固定止损 9.5 → 触发 SELL@9.5,延迟下一根成交
"stop_loss": {
"closes": [10, 10.2, 10.1, 9.8, 9.3, 9.0, 8.8, 8.6, 8.4, 8.2],
"bracket": {"stop_loss": 9.5},
},
# 触及固定止盈 11.0 → OCO 使止损线失效
"take_profit": {
"closes": [10, 10.3, 10.8, 11.2, 11.5, 11.8, 12.0, 12.2, 12.4, 12.6],
"bracket": {"stop_loss": 9.0, "take_profit": 11.0},
},
# 自最高收盘 12 回撤 8% → 11.04 触发移动止损
"trailing_stop": {
"closes": [10, 10.2, 10.5, 11, 11.5, 12, 11.9, 11.5, 11.0, 10.5, 10.0, 9.5],
"bracket": {"trail_stop": 0.08},
},
# 百分比 bracket5% 止损 / 10% 止盈(基准价 = 信号根收盘 10)
"bracket_pct": {
"closes": [10, 10.3, 10.8, 11.2, 11.5, 11.8, 12.0, 12.2, 12.4, 12.6],
"bracket": {"stop_loss_pct": 0.05, "take_profit_pct": 0.10},
},
}
def _build_golden() -> dict[str, Any]:
"""重新计算并返回完整黄金基线。"""
df = _golden_df()
strategies: dict[str, dict[str, Any]] = {}
for name in sorted(_REGISTRY.names()):
reg = _REGISTRY.get(name)
cls = reg.strategy_cls
perf = BacktestEngine(cls, cash=CASH).run(df).performance
entry: dict[str, Any] = {k: float(perf[k]) for k in STRATEGY_METRICS_FLOAT}
entry.update({k: int(perf[k]) for k in STRATEGY_METRICS_INT})
strategies[name] = entry
rules = {
key: _run_rule(spec["closes"], **spec["bracket"]) for key, spec in RULE_SCENARIOS.items()
}
# 买入持有基准 + CAPM 对比(用 ma_cross 做策略侧)
bh = run_buy_hold_benchmark(df, cash=CASH)
ma = _REGISTRY.get("ma_cross").strategy_cls
ma_result = BacktestEngine(ma, cash=CASH).run(df)
comparison = compute_benchmark_comparison(
ma_result.equity_curve,
BacktestEngine(_BuyOnce(), cash=CASH).run(df).equity_curve,
)
return {
"meta": {
"seed": SEED,
"bars": BARS,
"cash": CASH,
"tolerance": {"rel": 1e-6, "abs": 1e-6},
"note": "regen: EASY_TDX_REGEN_GOLDEN=1 pytest tests/unit/test_golden_backtest.py",
},
"strategies": strategies,
"rules": rules,
"buy_hold": {k: float(v) for k, v in bh.items()},
"benchmark_comparison": {k: float(v) for k, v in comparison.items()},
}
def _load_golden() -> dict[str, Any]:
if not GOLDEN_PATH.exists():
pytest.fail(
f"黄金基线缺失: {GOLDEN_PATH}\n"
"首次生成请运行: EASY_TDX_REGEN_GOLDEN=1 python -m pytest "
"tests/unit/test_golden_backtest.py"
)
data = json.loads(GOLDEN_PATH.read_text(encoding="utf-8"))
assert isinstance(data, dict)
return data
def _save_golden(data: dict[str, Any]) -> None:
GOLDEN_PATH.parent.mkdir(parents=True, exist_ok=True)
GOLDEN_PATH.write_text(
json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
@pytest.fixture(scope="module")
def golden() -> dict[str, Any]:
"""加载基线;REGEN=1 时重新计算并写盘后返回。"""
if REGEN:
data = _build_golden()
_save_golden(data)
return data
return _load_golden()
def _assert_metric(actual: Any, expected: Any, label: str) -> None:
"""int 精确比对;float 按 rel=abs=1e-6 容差比对。"""
if isinstance(expected, int) and not isinstance(expected, bool):
assert actual == expected, f"{label}: {actual} != {expected}"
else:
assert float(actual) == pytest.approx(float(expected), rel=1e-6, abs=1e-6), (
f"{label}: {actual} != {expected}"
)
# ── 测试入口 ─────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("name", sorted(_REGISTRY.names()))
def test_golden_builtin_strategies(golden: dict[str, Any], name: str) -> None:
"""全部内置策略在固定数据上的绩效指标与基线一致。"""
perf = BacktestEngine(_REGISTRY.get(name).strategy_cls, cash=CASH).run(_golden_df()).performance
baseline = golden["strategies"][name]
for key in STRATEGY_METRICS_FLOAT:
_assert_metric(perf[key], baseline[key], f"{name}.{key}")
for key in STRATEGY_METRICS_INT:
_assert_metric(perf[key], baseline[key], f"{name}.{key}")
@pytest.mark.parametrize("scenario", sorted(RULE_SCENARIOS))
def test_golden_rule_scenarios(golden: dict[str, Any], scenario: str) -> None:
"""止损/止盈/移动止损/OCO 触发语义(成交价与时点)与基线一致。"""
spec = RULE_SCENARIOS[scenario]
actual = _run_rule(spec["closes"], **spec["bracket"])
baseline = golden["rules"][scenario]
assert actual["total_trades"] == baseline["total_trades"], scenario
assert len(actual["trades"]) == len(baseline["trades"]), f"{scenario}: 成交笔数漂移"
for i, (a, b) in enumerate(zip(actual["trades"], baseline["trades"])):
assert a[0] == b[0], f"{scenario}{i}笔方向漂移: {a} vs {b}"
_assert_metric(a[1], b[1], f"{scenario}.trades[{i}].price")
assert a[2] == b[2], f"{scenario}{i}笔成交日漂移: {a} vs {b}"
_assert_metric(actual["total_return"], baseline["total_return"], f"{scenario}.total_return")
def test_golden_buy_hold(golden: dict[str, Any]) -> None:
"""买入持有基准指标与基线一致。"""
bh = run_buy_hold_benchmark(_golden_df(), cash=CASH)
for key, expected in golden["buy_hold"].items():
_assert_metric(bh[key], expected, f"buy_hold.{key}")
def test_golden_benchmark_comparison(golden: dict[str, Any]) -> None:
"""Alpha/Beta/IR/TE 基准对比指标与基线一致。"""
df = _golden_df()
ma = _REGISTRY.get("ma_cross").strategy_cls
strategy_curve = BacktestEngine(ma, cash=CASH).run(df).equity_curve
bh_curve = BacktestEngine(_BuyOnce(), cash=CASH).run(df).equity_curve
comparison = compute_benchmark_comparison(strategy_curve, bh_curve)
for key, expected in golden["benchmark_comparison"].items():
_assert_metric(comparison[key], expected, f"benchmark.{key}")
def test_golden_meta_frozen(golden: dict[str, Any]) -> None:
"""基线 meta 与测试常量一致(防止改数据参数后忘记重建基线)。"""
meta = golden["meta"]
assert meta["seed"] == SEED
assert meta["bars"] == BARS
assert meta["cash"] == CASH
+1
View File
@@ -72,6 +72,7 @@ _EXPECTED_KIND: dict[str, str] = {
"CALC_HOSTS": "constant",
"MAC_HOSTS": "constant",
"XDXR_CATEGORY_NAMES": "constant",
"UNUSUAL_TYPE_NAMES": "constant",
}
+221
View File
@@ -0,0 +1,221 @@
"""市场异动(0x1237)异动类型解析测试(Issue #62)。
背景实测(2026-09-01 午间,全市场 SH+SZ 共 12871 条):
- 0x15 仅出现在 09:25:00~09:25:02(竞价撮合时刻),v1 为方向档
0x02 拉升 / 0x03 下跌 / 0x01 平稳,±0.5% 分档),v2 为竞价尾段
价格变动(相对昨收,参考时刻收敛于 09:23:30~09:24:00),v3 为竞价
匹配量(手,略小于最终撮合量)。pytdx2 把 0x15 标作"尾盘"与实测矛盾,
系对 PC 推送协议枚举的错误类推。
- 0x16 全天分布,v2 为触发时涨跌幅:09:25 的 49 条样本与当日开盘涨幅
open/pre_close-149/49 精确一致;全时段与收盘涨跌幅符号一致率
97%~100%。v1 为带符号强弱等级(0x01~0x03 强势、0xFD~0xFF 弱势,
六组 v2 区间互不重叠且单调)。
- 0x1D / 0x1E 全天分布,v1 恒为 0x00 / 0x01(方向),v2 恒正 / 恒负
(阈值下限 ±0.6%),v3 恒 0。
"""
import struct
from datetime import time
from easy_tdx import UNUSUAL_TYPE_NAMES
from easy_tdx.mac.commands.unusual import UnusualCmd, _describe_unusual
def _record(
utype: int,
data_hex: str,
hour: int = 9,
minute_sec: int = 2500,
market: int = 1,
code: str = "600551",
) -> bytes:
"""构造一条 32 字节异动记录(<H6sBBBHH> 头 + 13B 数据区 + 保留 + <BH> 时间)。"""
return (
struct.pack("<H6sBBBHH", market, code.encode("gbk"), 0, utype, 0, 1, 0)
+ bytes.fromhex(data_hex)
+ b"\x00" # offset 28:全类型实测恒 0
+ struct.pack("<BH", hour, minute_sec)
)
def _body(records: list[bytes]) -> bytes:
text = ",".join("测试股" for _ in records)
return struct.pack("<H", len(records)) + b"".join(records) + text.encode("gbk")
class TestDescribeUnusualKnownTypes:
"""既有 15 种类型(0x03~0x0C、0x10~0x14)解析不回归。"""
def test_type_0x04(self):
# 真实样本:605365 立达信 2026-09-01 09:35:11
desc, val = _describe_unusual(0x04, bytes.fromhex("00b8d73d3d0000000000000000"))
assert desc == "加速拉升"
assert val == "4.63%"
def test_unknown_type_fallback(self):
desc, val = _describe_unusual(0x42, bytes.fromhex("00" * 13))
assert desc == "异动类型0x42"
assert val == ""
class TestType0x15:
"""0x15 竞价/尾盘异动(Issue #62)。
双时刻信号:开盘竞价 09:25(当日 1191 条)与收盘 15:00:01~04(当日 86 条,
SH 52 / SZ 29 / BJ 5)都触发;desc 按记录小时区分「竞价/尾盘」前缀。
"""
def test_auction_drop(self):
# 真实样本:600551 时代出版 09:25:00v1=0x03 竞价下跌
desc, val = _describe_unusual(0x15, bytes.fromhex("030c9846bc003e1d4700000000"))
assert desc == "竞价下跌"
assert val == "-1.21%/40254手"
def test_auction_rise(self):
# 真实样本:600127 金健米业 09:25:00v1=0x02 竞价拉升(尾段自 10.84 冲至 12.05
desc, val = _describe_unusual(0x15, bytes.fromhex("0213d2cd3d00367b4700000000"))
assert desc == "竞价拉升"
assert val == "10.05%/64310手"
def test_auction_flat(self):
# 真实样本:600410 华胜天成 09:25:01v1=0x01 竞价平稳(尾段价格未动)
desc, val = _describe_unusual(0x15, bytes.fromhex("01000000000098a54500000000"))
assert desc == "竞价平稳"
assert val == "0.00%/5299手"
def test_close_rise_uses_tail_prefix(self):
# 真实样本:600123 15:00:01(收盘撮合时刻),v1=0x02 尾盘拉升
desc, val = _describe_unusual(0x15, bytes.fromhex("027bb4dd3b0004a84500000000"), 15)
assert desc == "尾盘拉升"
assert val == "0.68%/5376手"
def test_close_drop_uses_tail_prefix(self):
# 真实样本:600221 15:00:01v1=0x03 尾盘下跌
desc, val = _describe_unusual(0x15, bytes.fromhex("03c10ffcbb839c274800000000"), 15)
assert desc == "尾盘下跌"
assert val == "-0.77%/171634手"
def test_unknown_sub_type_falls_back(self):
desc, _ = _describe_unusual(0x15, struct.pack("<B2fI", 0x77, 0.0, 100.0, 0))
assert desc == "竞价异动"
desc, _ = _describe_unusual(0x15, struct.pack("<B2fI", 0x77, 0.0, 100.0, 0), 15)
assert desc == "尾盘异动"
class TestType0x16:
"""0x16 盘中强势/弱势(Issue #62 主体)。"""
def test_strong_at_auction(self):
# 真实样本:600551 时代出版 09:25:00v2=+5.82% 与当日开盘涨幅精确一致
desc, val = _describe_unusual(0x16, bytes.fromhex("010f506e3dcb846e3d00000000"))
assert desc == "盘中强势"
assert val == "5.82%"
def test_weak_at_auction(self):
# 真实样本:600683 京投发展 09:25:01v1=0xFF(弱势 1 级),v2=-6.40%
desc, val = _describe_unusual(0x16, bytes.fromhex("ffc71d83bd690383bd00000000"))
assert desc == "盘中弱势"
assert val == "-6.40%"
def test_new_stock_no_limit(self):
# 真实样本:601123 N马矿 09:25:00,新股无涨跌幅限制,v2=+245.86%
desc, val = _describe_unusual(0x16, bytes.fromhex("03775a1d404a5b1d4000000000"))
assert desc == "盘中强势"
assert val == "245.86%"
class TestType0x13:
"""0x13 竞价试盘(2026-09-02 破译)。
v1 为方向:0x00 试买(申报价高于昨收)/ 0x01 试卖(低于昨收)——552 条对照
昨收 549 条一致;v2 为申报价、v3 为竞价量(手)。旧实现一律显示「竞价试买」,
方向相反的一半记录描述错误。
"""
def test_auction_test_buy(self):
# 真实样本:603980 09:15:14,申报价 8.71 高于昨收 7.92(往上试)
desc, val = _describe_unusual(0x13, bytes.fromhex("00295c0b41006c354600000000"))
assert desc == "竞价试买"
assert val == "8.71/11611手"
def test_auction_test_sell(self):
# 真实样本:603900 09:15:17,申报价 6.46 低于昨收 7.17(往下试)
desc, val = _describe_unusual(0x13, bytes.fromhex("0152b8ce400000c94300000000"))
assert desc == "竞价试卖"
assert val == "6.46/402手"
class TestType0x1D0x1E:
"""0x1D 急速拉升 / 0x1E 急速下跌(Issue #62 顺带补齐)。"""
def test_fast_rise(self):
# 真实样本:605365 立达信 09:35:08
desc, val = _describe_unusual(0x1D, bytes.fromhex("009d50843c0000000000000000"))
assert desc == "急速拉升"
assert val == "1.62%"
def test_fast_fall(self):
# 真实样本:601123 N马矿 09:35:03
desc, val = _describe_unusual(0x1E, bytes.fromhex("019cd393bc0000000000000000"))
assert desc == "急速下跌"
assert val == "-1.80%"
class TestUnusualCmdParseResponse:
def test_parse_new_types_end_to_end(self):
body = _body(
[
_record(0x16, "010f506e3dcb846e3d00000000", 9, 2500),
_record(0x15, "030c9846bc003e1d4700000000", 9, 2500),
_record(0x1D, "009d50843c0000000000000000", 9, 3508),
_record(0x1E, "019cd393bc0000000000000000", 9, 3503),
]
)
items = UnusualCmd(1, 0, 600).parse_response(body)
assert len(items) == 4
assert [i.desc for i in items] == ["盘中强势", "竞价下跌", "急速拉升", "急速下跌"]
assert items[0].value == "5.82%"
assert items[1].value == "-1.21%/40254手"
assert items[0].time == time(9, 25, 0)
assert items[2].time == time(9, 35, 8)
assert items[0].unusual_type == 0x16
assert all(i.name == "测试股" for i in items)
def test_parse_close_record_names_tail_prefix(self):
"""15:00 的 0x15 记录端到端应得到「尾盘拉升」(真实收盘样本 600123)。"""
rec = _record(0x15, "027bb4dd3b0004a84500000000", 15, 1)
items = UnusualCmd(1, 0, 600).parse_response(_body([rec]))
assert items[0].desc == "尾盘拉升"
assert items[0].value == "0.68%/5376手"
assert items[0].time == time(15, 0, 1)
def test_record_layout_unchanged(self):
"""记录仍为 32 字节定长,时间槽位于 offset 29。"""
rec = _record(0x16, "010f506e3dcb846e3d00000000", 14, 5701)
assert len(rec) == 32
items = UnusualCmd(1, 0, 600).parse_response(_body([rec]))
assert items[0].time == time(14, 57, 1)
class TestTypeNames:
def test_names_cover_all_described_types(self):
"""映射表应覆盖 _describe_unusual 的全部分支(0x03~0x0C、0x10~0x16、0x1D、0x1E)。"""
assert set(UNUSUAL_TYPE_NAMES) == {
*range(0x03, 0x0D),
*range(0x10, 0x17),
0x1D,
0x1E,
}
def test_mapped_types_produce_named_desc(self):
"""映射表中的类型不应落入"异动类型0x??"兜底分支。"""
zeros = bytes.fromhex("00" * 13)
for utype in UNUSUAL_TYPE_NAMES:
desc, _ = _describe_unusual(utype, zeros)
assert not desc.startswith("异动类型"), f"0x{utype:02X} 未实现解析分支"
def test_top_level_export(self):
import easy_tdx
assert easy_tdx.UNUSUAL_TYPE_NAMES is UNUSUAL_TYPE_NAMES
assert UNUSUAL_TYPE_NAMES[0x16] == "盘中强势弱势"