feat: add chanlun (ChanLun) technical analysis module, bump to v1.7.0

- New chanlun/ subpackage: K-line merge, fractal, bi/xianduan/zhongshu/mmd/beichi
- New 'easy-tdx chanlun' CLI command with JSON/table output
- MACD calculation (pure numpy, no extra dependencies)
- Multi-level analysis (MultiLevelAnalyser)
- Pipeline: DataFrame -> merge -> fractal -> bi -> zhongshu -> xd -> mmd -> beichi
- 49 offline unit tests covering all calculation steps
- Detailed README docs with output explanation
- Bump version: pyproject.toml 1.6.1 -> 1.7.0, cli 1.5.0 -> 1.7.0
This commit is contained in:
Justin Gu
2026-06-07 23:29:52 +08:00
parent 4cab9aa325
commit fd4a1233b4
22 changed files with 3056 additions and 2 deletions
+506
View File
@@ -0,0 +1,506 @@
"""缠论核心计算 单元测试。"""
from __future__ import annotations
from datetime import datetime
from easy_tdx.chanlun.bi import find_bis
from easy_tdx.chanlun.fractal import find_fractals
from easy_tdx.chanlun.kline_merge import merge_klines
from easy_tdx.chanlun.types import CLKline, Direction, FXType, Kline
from easy_tdx.chanlun.zs import find_zss
# ── helpers ──────────────────────────────────────────────────────────────
def _k(
idx: int,
dt: str,
o: float,
c: float,
h: float,
l: float,
a: float = 0.0,
) -> Kline:
"""快速构造 Kline。"""
return Kline(
index=idx,
date=datetime.strptime(dt, "%Y-%m-%d"),
open=o,
close=c,
high=h,
low=l,
amount=a,
)
def _ck(
idx: int,
dt: str,
o: float,
c: float,
h: float,
l: float,
merged_count: int = 1,
direction: str = "",
) -> CLKline:
"""快速构造 CLKline。"""
return CLKline(
k_index=idx,
date=datetime.strptime(dt, "%Y-%m-%d"),
open=o,
close=c,
high=h,
low=l,
amount=0.0,
index=0, # 由 merge_klines 赋值
merged_count=merged_count,
direction=direction,
)
# ── K 线合并测试 ──────────────────────────────────────────────────────────
class TestMergeKlines:
"""merge_klines 测试。"""
def test_no_merge_needed(self) -> None:
"""K 线无包含关系,应原样返回。"""
klines = [
_k(0, "2025-01-02", 10, 12, 13, 9),
_k(1, "2025-01-03", 11, 16, 17, 11), # 高于前一根高点、低于前根低点 → 无包含
_k(2, "2025-01-06", 13, 11, 18, 12), # 继续新高 → 无包含
]
result = merge_klines(klines)
assert len(result) == 3
# 每个 CLKline 没有合并
assert all(ck.merged_count == 1 for ck in result)
def test_upward_include(self) -> None:
"""向上趋势中的包含关系应合并。
K1: h=15 l=10 (向上)
K2: h=13 l=11 ← K2 被 K1 包含 (15>13 and 10<11 => 15>=13 and 10<=11)
合并后取高高:h=15, l=11
"""
klines = [
_k(0, "2025-01-02", 10, 14, 15, 10),
_k(1, "2025-01-03", 11, 13, 13, 11),
]
result = merge_klines(klines)
assert len(result) == 1
assert result[0].high == 15.0
assert result[0].low == 11.0
assert result[0].merged_count == 2
def test_downward_include(self) -> None:
"""向下趋势中的包含关系应合并。
K1: h=10 l=5 (向下)
K2: h=9 l=6 ← K2 被 K1 包含 (10>=9 and 5<=6)
合并后取低低:h=9, l=5
"""
klines = [
_k(0, "2025-01-02", 12, 8, 10, 5),
_k(1, "2025-01-03", 8, 7, 9, 6),
]
result = merge_klines(klines)
assert len(result) == 1
assert result[0].high == 9.0
assert result[0].low == 5.0
assert result[0].merged_count == 2
def test_three_klines_with_two_merges(self) -> None:
"""连续包含:三根 K 线合并为一根。"""
klines = [
_k(0, "2025-01-02", 10, 14, 15, 10), # 大阳线
_k(1, "2025-01-03", 11, 13, 14, 11), # 被包含
_k(2, "2025-01-06", 12, 14, 14, 12), # 被包含
]
result = merge_klines(klines)
assert len(result) == 1
assert result[0].merged_count == 3
# 向上合并:取高高 => h=15, l=12
assert result[0].high == 15.0
assert result[0].low == 12.0
def test_empty_input(self) -> None:
"""空输入返回空列表。"""
assert merge_klines([]) == []
def test_single_kline(self) -> None:
"""单根 K 线返回单个 CLKline。"""
klines = [_k(0, "2025-01-02", 10, 12, 13, 9)]
result = merge_klines(klines)
assert len(result) == 1
assert result[0].high == 13.0
assert result[0].low == 9.0
def test_mixed_merge_and_non_merge(self) -> None:
"""混合场景:部分合并,部分不合并。"""
klines = [
_k(0, "2025-01-02", 10, 14, 15, 10), # 大阳线
_k(1, "2025-01-03", 11, 13, 14, 11), # 被包含,合并
_k(2, "2025-01-06", 16, 18, 19, 15), # 新高,不合并
_k(3, "2025-01-07", 17, 15, 18, 14), # 阴线,不包含
]
result = merge_klines(klines)
assert len(result) == 3
assert result[0].merged_count == 2 # K0+K1 合并
assert result[1].merged_count == 1 # K2 独立
assert result[2].merged_count == 1 # K3 独立
def test_index_assignment(self) -> None:
"""CLKline.index 应从 0 递增。"""
klines = [
_k(0, "2025-01-02", 10, 14, 15, 10),
_k(1, "2025-01-03", 14, 16, 17, 13),
_k(2, "2025-01-06", 16, 12, 17, 11),
]
result = merge_klines(klines)
for i, ck in enumerate(result):
assert ck.index == i
def test_klines_reference_preserved(self) -> None:
"""CLKline.klines 应包含合并前的原始 K 线。"""
klines = [
_k(0, "2025-01-02", 10, 14, 15, 10),
_k(1, "2025-01-03", 11, 13, 14, 11), # 被包含
]
result = merge_klines(klines)
assert len(result[0].klines) == 2
# ── 分型识别测试 ──────────────────────────────────────────────────────────
class TestFindFractals:
"""find_fractals 测试。"""
def test_simple_ding_fx(self) -> None:
"""简单的顶分型:中间高,两边低。"""
cks = [
_ck(0, "2025-01-02", 10, 12, 12, 10),
_ck(1, "2025-01-03", 12, 15, 15, 11),
_ck(2, "2025-01-06", 14, 11, 14, 10),
]
fxs = find_fractals(cks)
assert len(fxs) == 1
assert fxs[0].fx_type == FXType.DING
assert fxs[0].val == 15.0
assert fxs[0].k == cks[1]
def test_simple_di_fx(self) -> None:
"""简单的底分型:中间低,两边高。"""
cks = [
_ck(0, "2025-01-02", 15, 12, 16, 12),
_ck(1, "2025-01-03", 11, 9, 12, 9),
_ck(2, "2025-01-06", 10, 13, 14, 10),
]
fxs = find_fractals(cks)
assert len(fxs) == 1
assert fxs[0].fx_type == FXType.DI
assert fxs[0].val == 9.0
def test_no_fractal(self) -> None:
"""单调序列不应有分型。"""
cks = [
_ck(0, "2025-01-02", 10, 12, 12, 10),
_ck(1, "2025-01-03", 12, 14, 14, 12),
_ck(2, "2025-01-06", 14, 16, 16, 14),
]
fxs = find_fractals(cks)
assert len(fxs) == 0
def test_alternating_ding_di(self) -> None:
"""交替的顶底分型。"""
cks = [
_ck(0, "2025-01-02", 10, 12, 12, 10), # 上升
_ck(1, "2025-01-03", 12, 15, 15, 11), # 顶 (12<15, 14<15)
_ck(2, "2025-01-06", 14, 11, 14, 10), # 下降
_ck(3, "2025-01-07", 10, 8, 11, 8), # 底 (10>8, 9>8)
_ck(4, "2025-01-08", 9, 13, 16, 9), # 大幅上升
_ck(5, "2025-01-09", 15, 10, 15, 10), # 下降 → ck[4] 成为顶
]
fxs = find_fractals(cks)
assert len(fxs) == 3
assert fxs[0].fx_type == FXType.DING # ck[1]
assert fxs[1].fx_type == FXType.DI # ck[3]
assert fxs[2].fx_type == FXType.DING # ck[4]
def test_insufficient_klines(self) -> None:
"""少于3根K线不应有分型。"""
assert find_fractals([]) == []
assert find_fractals([_ck(0, "2025-01-02", 10, 12, 12, 10)]) == []
assert (
find_fractals(
[
_ck(0, "2025-01-02", 10, 12, 12, 10),
_ck(1, "2025-01-03", 12, 15, 15, 11),
]
)
== []
)
def test_equal_highs_no_ding(self) -> None:
"""相等高点不应形成顶分型。"""
cks = [
_ck(0, "2025-01-02", 10, 12, 15, 10),
_ck(1, "2025-01-03", 12, 14, 15, 11),
_ck(2, "2025-01-06", 14, 11, 14, 10),
]
fxs = find_fractals(cks)
assert len(fxs) == 0
def test_equal_lows_no_di(self) -> None:
"""相等低点不应形成底分型。"""
cks = [
_ck(0, "2025-01-02", 15, 12, 16, 9),
_ck(1, "2025-01-03", 11, 10, 12, 9),
_ck(2, "2025-01-06", 10, 13, 14, 10),
]
fxs = find_fractals(cks)
assert len(fxs) == 0
# ── 笔计算测试 ────────────────────────────────────────────────────────────
class TestFindBis:
"""find_bis 测试。"""
def test_simple_up_down_bi(self) -> None:
"""一组顶底分型应产生两笔(向上 + 向下)。"""
cks = [
_ck(0, "2025-01-02", 10, 12, 12, 10),
_ck(1, "2025-01-03", 12, 15, 15, 11),
_ck(2, "2025-01-06", 14, 11, 14, 10),
_ck(3, "2025-01-07", 10, 8, 11, 8),
_ck(4, "2025-01-08", 9, 13, 16, 9),
_ck(5, "2025-01-09", 15, 10, 15, 10),
]
fxs = find_fractals(cks)
bis = find_bis(fxs)
# ding(1) → di(3) 向下笔, di(3) → ding(4) 向上笔
assert len(bis) >= 2
assert bis[0].direction == Direction.DOWN # 顶→底
assert bis[1].direction == Direction.UP # 底→顶
def test_new_bi_rule_needs_gap(self) -> None:
"""新笔规则:分型之间至少1根独立K线。
如果两个分型相邻(中间无独立K线),不构成笔。
"""
# 只有3根K线,产生1个分型,不足以成笔
cks = [
_ck(0, "2025-01-02", 10, 12, 12, 10),
_ck(1, "2025-01-03", 12, 15, 15, 11),
_ck(2, "2025-01-06", 14, 11, 14, 10),
]
fxs = find_fractals(cks)
bis = find_bis(fxs)
assert len(bis) == 0 # 1个分型无法成笔
def test_ding_di_must_alternate(self) -> None:
"""笔的起止分型必须顶底交替:顶→底 或 底→顶。"""
cks = [
_ck(0, "2025-01-02", 10, 8, 11, 8),
_ck(1, "2025-01-03", 9, 15, 16, 9),
_ck(2, "2025-01-06", 14, 11, 14, 10),
_ck(3, "2025-01-07", 10, 7, 11, 7),
_ck(4, "2025-01-08", 8, 13, 14, 8),
_ck(5, "2025-01-09", 13, 10, 14, 10),
]
fxs = find_fractals(cks)
bis = find_bis(fxs)
for bi in bis:
if bi.direction == Direction.UP:
assert bi.start.fx_type == FXType.DI
assert bi.end.fx_type == FXType.DING
else:
assert bi.start.fx_type == FXType.DING
assert bi.end.fx_type == FXType.DI
def test_empty_fractals(self) -> None:
"""空分型列表应返回空笔列表。"""
assert find_bis([]) == []
def test_bi_high_low(self) -> None:
"""笔的 high/low 应正确反映区间最高最低价。"""
cks = [
_ck(0, "2025-01-02", 10, 8, 11, 8),
_ck(1, "2025-01-03", 9, 15, 16, 9),
_ck(2, "2025-01-06", 14, 11, 14, 10),
_ck(3, "2025-01-07", 10, 7, 11, 7),
_ck(4, "2025-01-08", 8, 13, 14, 8),
_ck(5, "2025-01-09", 13, 10, 13, 10),
]
fxs = find_fractals(cks)
bis = find_bis(fxs)
if len(bis) > 0:
# 第一笔:顶→底(向下),high=16, low=7
assert bis[0].high == 16.0
assert bis[0].low == 7.0
def test_full_pipeline_merge_to_bi(self) -> None:
"""完整管道测试:原始K线 → 合并 → 分型 → 笔。"""
klines = [
_k(0, "2025-01-02", 10, 8, 11, 8),
_k(1, "2025-01-03", 8, 12, 13, 7),
_k(2, "2025-01-06", 12, 16, 17, 11),
_k(3, "2025-01-07", 16, 14, 18, 13),
_k(4, "2025-01-08", 14, 10, 15, 9),
_k(5, "2025-01-09", 10, 6, 11, 5),
_k(6, "2025-01-10", 7, 12, 13, 6),
_k(7, "2025-01-13", 12, 9, 14, 8),
]
merged = merge_klines(klines)
fxs = find_fractals(merged)
bis = find_bis(fxs)
assert len(bis) >= 1
# ── 中枢计算测试 ──────────────────────────────────────────────────────────
class TestFindZss:
"""find_zss 测试。"""
def test_three_overlapping_bis_form_zs(self) -> None:
"""三笔重叠形成中枢。"""
cks = [
_ck(0, "2025-01-02", 10, 8, 11, 8),
_ck(1, "2025-01-03", 9, 15, 16, 9),
_ck(2, "2025-01-06", 14, 11, 14, 10),
_ck(3, "2025-01-07", 10, 12, 13, 9),
_ck(4, "2025-01-08", 12, 14, 15, 11),
_ck(5, "2025-01-09", 14, 12, 14, 11),
_ck(6, "2025-01-10", 11, 9, 12, 9),
_ck(7, "2025-01-13", 10, 11, 12, 10),
]
fxs = find_fractals(cks)
bis = find_bis(fxs)
zss = find_zss(bis)
assert len(zss) >= 1
zs = zss[0]
assert zs.zg > zs.zd
assert zs.gg >= zs.zg
assert zs.dd <= zs.zd
def test_no_overlap_no_zs(self) -> None:
"""笔之间无重叠不应形成中枢。"""
cks = [
_ck(0, "2025-01-02", 10, 8, 11, 8),
_ck(1, "2025-01-03", 9, 15, 16, 9),
_ck(2, "2025-01-06", 14, 16, 18, 15),
_ck(3, "2025-01-07", 16, 20, 22, 16),
_ck(4, "2025-01-08", 20, 25, 26, 20),
_ck(5, "2025-01-09", 25, 22, 26, 22),
]
fxs = find_fractals(cks)
bis = find_bis(fxs)
zss = find_zss(bis)
assert len(zss) == 0
def test_empty_bis_no_zs(self) -> None:
"""空笔列表不应有中枢。"""
assert find_zss([]) == []
def test_zs_overlap_properties(self) -> None:
"""中枢应有正确的重叠区间属性。"""
cks = [
_ck(0, "2025-01-02", 10, 12, 13, 10),
_ck(1, "2025-01-03", 12, 15, 16, 11),
_ck(2, "2025-01-06", 14, 11, 14, 10),
_ck(3, "2025-01-07", 10, 13, 14, 9),
_ck(4, "2025-01-08", 12, 14, 15, 11),
_ck(5, "2025-01-09", 14, 12, 14, 11),
_ck(6, "2025-01-10", 11, 9, 12, 8),
_ck(7, "2025-01-13", 10, 11, 12, 9),
_ck(8, "2025-01-14", 11, 6, 12, 5),
_ck(9, "2025-01-15", 7, 8, 9, 6),
]
fxs = find_fractals(cks)
bis = find_bis(fxs)
zss = find_zss(bis)
if len(zss) > 0:
zs = zss[0]
# 中枢基本属性
assert zs.zg > zs.zd
assert zs.gg >= zs.zg
assert zs.dd <= zs.zd
assert zs.line_count >= 3
# ── Analyser 集成测试 ────────────────────────────────────────────────────
class TestChanlunAnalyser:
"""ChanlunAnalyser 完整管道测试。"""
def test_analyse_with_dataframe(self) -> None:
"""使用模拟 DataFrame 测试完整管道。"""
import pandas as pd
from easy_tdx.chanlun.analyser import ChanlunAnalyser
dates = pd.date_range("2025-01-02", periods=20, freq="B")
data = {
"datetime": dates,
"open": [10, 8, 12, 16, 14, 10, 7, 12, 14, 12, 10, 6, 7, 12, 9, 10, 14, 12, 8, 9],
"close": [8, 12, 16, 14, 10, 7, 12, 14, 12, 10, 6, 7, 12, 9, 10, 14, 12, 8, 9, 11],
"high": [11, 13, 17, 18, 15, 11, 13, 15, 14, 13, 11, 8, 13, 12, 11, 15, 14, 13, 9, 12],
"low": [7, 7, 11, 13, 9, 5, 6, 11, 11, 9, 5, 5, 6, 8, 8, 9, 11, 7, 7, 9],
"vol": [1000] * 20,
}
df = pd.DataFrame(data)
analyser = ChanlunAnalyser(code="SZ000001", frequency="DAILY")
result = analyser.process_klines(df)
assert result.code == "SZ000001"
assert result.frequency == "DAILY"
assert len(result.klines) == 20
assert len(result.cklines) > 0
assert len(result.cklines) <= 20
assert len(result.fractals) >= 0
assert len(result.bis) >= 0
def test_empty_dataframe(self) -> None:
"""空 DataFrame 应返回空结果。"""
import pandas as pd
from easy_tdx.chanlun.analyser import ChanlunAnalyser
df = pd.DataFrame(columns=["datetime", "open", "close", "high", "low", "vol"])
analyser = ChanlunAnalyser(code="SZ000001")
result = analyser.process_klines(df)
assert len(result.klines) == 0
assert len(result.bis) == 0
def test_result_to_dict(self) -> None:
"""结果应可序列化为字典。"""
import pandas as pd
from easy_tdx.chanlun.analyser import ChanlunAnalyser
dates = pd.date_range("2025-01-02", periods=10, freq="B")
data = {
"datetime": dates,
"open": [10, 8, 12, 16, 14, 10, 7, 12, 14, 12],
"close": [8, 12, 16, 14, 10, 7, 12, 14, 12, 10],
"high": [11, 13, 17, 18, 15, 11, 13, 15, 14, 13],
"low": [7, 7, 11, 13, 9, 5, 6, 11, 11, 9],
"vol": [1000] * 10,
}
df = pd.DataFrame(data)
analyser = ChanlunAnalyser(code="SZ000001")
result = analyser.process_klines(df)
d = result.to_dict()
assert "code" in d
assert "bi_count" in d
assert "zs_count" in d
assert "bis" in d
assert "zss" in d
+265
View File
@@ -0,0 +1,265 @@
"""缠论 Phase 2 单元测试:MACD、线段、买卖点、背驰。"""
from __future__ import annotations
from datetime import datetime
from easy_tdx.chanlun.bi import find_bis
from easy_tdx.chanlun.fractal import find_fractals
from easy_tdx.chanlun.types import CLKline, Direction, Kline
# ── helpers ──────────────────────────────────────────────────────────────
def _k(
idx: int,
dt: str,
o: float,
c: float,
h: float,
l: float,
a: float = 0.0,
) -> Kline:
return Kline(
index=idx,
date=datetime.strptime(dt, "%Y-%m-%d"),
open=o,
close=c,
high=h,
low=l,
amount=a,
)
def _ck(
idx: int,
dt: str,
o: float,
c: float,
h: float,
l: float,
merged_count: int = 1,
direction: str = "",
) -> CLKline:
return CLKline(
k_index=idx,
date=datetime.strptime(dt, "%Y-%m-%d"),
open=o,
close=c,
high=h,
low=l,
amount=0.0,
index=0,
merged_count=merged_count,
direction=direction,
)
# ── MACD 测试 ────────────────────────────────────────────────────────────
class TestMacd:
"""calc_macd 测试。"""
def test_macd_output_length(self) -> None:
"""MACD 输出长度应与输入一致。"""
from easy_tdx.chanlun.macd import calc_macd
closes = [10.0 + i * 0.5 for i in range(50)]
result = calc_macd(closes)
assert "dif" in result
assert "dea" in result
assert "hist" in result
assert len(result["dif"]) == 50
assert len(result["dea"]) == 50
assert len(result["hist"]) == 50
def test_macd_short_input(self) -> None:
"""输入太短时应返回零数组。"""
from easy_tdx.chanlun.macd import calc_macd
result = calc_macd([10.0])
assert len(result["dif"]) == 1
assert result["dif"][0] == 0.0
def test_macd_uptrend_positive_dif(self) -> None:
"""持续上涨时 DIF 应为正。"""
from easy_tdx.chanlun.macd import calc_macd
closes = [float(i) for i in range(100)]
result = calc_macd(closes)
# 后半段 DIF 应为正
assert result["dif"][-1] > 0
def test_macd_downtrend_negative_dif(self) -> None:
"""持续下跌时 DIF 应为负。"""
from easy_tdx.chanlun.macd import calc_macd
closes = [100.0 - i for i in range(100)]
result = calc_macd(closes)
assert result["dif"][-1] < 0
def test_macd_hist_equals_2x_diff(self) -> None:
"""MACD 柱 = 2 * (DIF - DEA)。"""
from easy_tdx.chanlun.macd import calc_macd
closes = [10.0 + i * 0.3 for i in range(60)]
result = calc_macd(closes)
for i in range(len(closes)):
expected = 2 * (result["dif"][i] - result["dea"][i])
assert abs(result["hist"][i] - expected) < 1e-10
def test_macd_custom_params(self) -> None:
"""支持自定义 fast/slow/signal 参数。"""
from easy_tdx.chanlun.macd import calc_macd
closes = [float(i) for i in range(100)]
r1 = calc_macd(closes, fast=12, slow=26, signal=9)
r2 = calc_macd(closes, fast=6, slow=13, signal=5)
# 不同参数应产生不同结果
assert r1["dif"][-1] != r2["dif"][-1]
# ── 线段测试 ──────────────────────────────────────────────────────────────
class TestFindXds:
"""find_xds 测试。"""
def test_basic_xd_from_bis(self) -> None:
"""多笔应能形成至少一个线段。"""
from easy_tdx.chanlun.xd import find_xds
# 构造足够多的笔来形成线段
# 需要至少5笔(3笔形成中枢 + 2笔进出)
cks = [
_ck(0, "2025-01-02", 10, 8, 11, 8),
_ck(1, "2025-01-03", 9, 15, 16, 9), # 顶 h=16
_ck(2, "2025-01-06", 14, 11, 14, 10),
_ck(3, "2025-01-07", 10, 13, 14, 9), # 底 l=9
_ck(4, "2025-01-08", 12, 14, 15, 11),
_ck(5, "2025-01-09", 14, 12, 14, 11),
_ck(6, "2025-01-10", 11, 9, 12, 8), # 底
_ck(7, "2025-01-13", 10, 11, 12, 9),
_ck(8, "2025-01-14", 11, 6, 12, 5), # 大跌
_ck(9, "2025-01-15", 7, 8, 9, 6),
_ck(10, "2025-01-16", 8, 12, 13, 7),
_ck(11, "2025-01-17", 11, 10, 14, 9),
_ck(12, "2025-01-20", 10, 6, 11, 5),
_ck(13, "2025-01-21", 7, 8, 9, 6),
]
fxs = find_fractals(cks)
bis = find_bis(fxs)
xds = find_xds(bis)
# 有足够的笔时,应能形成线段
if len(bis) >= 5:
assert len(xds) >= 1
def test_empty_bis(self) -> None:
"""空笔列表应返回空线段。"""
from easy_tdx.chanlun.xd import find_xds
assert find_xds([]) == []
def test_xd_direction_alternates(self) -> None:
"""线段方向应与笔的方向一致:向上线段由向上笔主导。"""
from easy_tdx.chanlun.xd import find_xds
cks = [
_ck(0, "2025-01-02", 10, 8, 11, 8),
_ck(1, "2025-01-03", 9, 15, 16, 9),
_ck(2, "2025-01-06", 14, 11, 14, 10),
_ck(3, "2025-01-07", 10, 13, 14, 9),
_ck(4, "2025-01-08", 12, 14, 15, 11),
_ck(5, "2025-01-09", 14, 12, 14, 11),
_ck(6, "2025-01-10", 11, 9, 12, 8),
_ck(7, "2025-01-13", 10, 11, 12, 9),
_ck(8, "2025-01-14", 11, 6, 12, 5),
_ck(9, "2025-01-15", 7, 8, 9, 6),
_ck(10, "2025-01-16", 8, 12, 13, 7),
_ck(11, "2025-01-17", 11, 10, 14, 9),
_ck(12, "2025-01-20", 10, 6, 11, 5),
_ck(13, "2025-01-21", 7, 8, 9, 6),
]
fxs = find_fractals(cks)
bis = find_bis(fxs)
xds = find_xds(bis)
for xd in xds:
assert xd.direction in (Direction.UP, Direction.DOWN)
# ── 买卖点测试 ────────────────────────────────────────────────────────────
class TestFindMmds:
"""find_mmds 测试。"""
def test_first_buy_after_zs(self) -> None:
"""中枢下方出现底背驰应产生一类买点。"""
from easy_tdx.chanlun.mmd import find_mmds
cks = [
_ck(0, "2025-01-02", 10, 8, 11, 8),
_ck(1, "2025-01-03", 9, 15, 16, 9), # 顶
_ck(2, "2025-01-06", 14, 11, 14, 10),
_ck(3, "2025-01-07", 10, 13, 14, 9), # 底
_ck(4, "2025-01-08", 12, 14, 15, 11),
_ck(5, "2025-01-09", 14, 12, 14, 11),
_ck(6, "2025-01-10", 11, 9, 12, 8), # 底
_ck(7, "2025-01-13", 10, 11, 12, 9),
_ck(8, "2025-01-14", 11, 14, 15, 10), # 向上离开
_ck(9, "2025-01-15", 14, 12, 16, 11),
]
fxs = find_fractals(cks)
bis = find_bis(fxs)
# find_mmds 需要笔列表和中枢列表
from easy_tdx.chanlun.zs import find_zss
zss = find_zss(bis)
mmds = find_mmds(bis, zss)
# 结果应为列表(可能为空,取决于是否满足条件)
assert isinstance(mmds, list)
def test_empty_input(self) -> None:
"""空输入应返回空列表。"""
from easy_tdx.chanlun.mmd import find_mmds
assert find_mmds([], []) == []
# ── 背驰测试 ──────────────────────────────────────────────────────────────
class TestBeichi:
"""check_beichi 测试。"""
def test_divergence_detection(self) -> None:
"""力度衰减应被检测为背驰。"""
from easy_tdx.chanlun.beichi import check_bi_beichi
cks = [
_ck(0, "2025-01-02", 10, 8, 11, 8),
_ck(1, "2025-01-03", 9, 15, 16, 9),
_ck(2, "2025-01-06", 14, 11, 14, 10),
_ck(3, "2025-01-07", 10, 13, 14, 9),
_ck(4, "2025-01-08", 12, 14, 15, 11),
_ck(5, "2025-01-09", 14, 12, 14, 11),
_ck(6, "2025-01-10", 11, 9, 12, 8),
_ck(7, "2025-01-13", 10, 11, 12, 9),
_ck(8, "2025-01-14", 11, 14, 15, 10),
_ck(9, "2025-01-15", 14, 12, 16, 11),
]
fxs = find_fractals(cks)
bis = find_bis(fxs)
from easy_tdx.chanlun.zs import find_zss
zss = find_zss(bis)
# 至少不崩溃
result = check_bi_beichi(bis, zss)
assert isinstance(result, list)
def test_empty_input(self) -> None:
"""空输入应返回空列表。"""
from easy_tdx.chanlun.beichi import check_bi_beichi
assert check_bi_beichi([], []) == []
+214
View File
@@ -0,0 +1,214 @@
"""缠论 Phase 3 单元测试:多级别分析、增量更新、走势段。"""
from __future__ import annotations
from datetime import datetime
import pandas as pd
from easy_tdx.chanlun.bi import find_bis
from easy_tdx.chanlun.fractal import find_fractals
from easy_tdx.chanlun.types import CLKline, Kline
# ── helpers ──────────────────────────────────────────────────────────────
def _k(idx: int, dt: str, o: float, c: float, h: float, l: float, a: float = 0.0) -> Kline:
return Kline(
index=idx,
date=datetime.strptime(dt, "%Y-%m-%d"),
open=o,
close=c,
high=h,
low=l,
amount=a,
)
def _make_df(n: int = 50, start_price: float = 10.0, volatility: float = 2.0) -> pd.DataFrame:
"""生成模拟K线 DataFrame。"""
import random
random.seed(42)
prices = [start_price]
for _ in range(n - 1):
change = random.uniform(-volatility, volatility)
prices.append(max(1.0, prices[-1] + change))
dates = pd.date_range("2025-01-02", periods=n, freq="B")
data = {
"datetime": dates,
"open": prices,
"close": [p + random.uniform(-0.5, 0.5) for p in prices],
"high": [p + random.uniform(0, volatility) for p in prices],
"low": [p - random.uniform(0, volatility) for p in prices],
"vol": [1000.0] * n,
}
return pd.DataFrame(data)
# ── 多级别分析测试 ──────────────────────────────────────────────────────
class TestMultiLevel:
"""MultiLevelAnalyser 测试。"""
def test_multi_level_basic(self) -> None:
"""多级别分析应返回各级别结果。"""
from easy_tdx.chanlun.multi_level import MultiLevelAnalyser
from easy_tdx.chanlun.analyser import ChanlunAnalyser
df_daily = _make_df(100)
df_30min = _make_df(200)
mla = MultiLevelAnalyser()
mla.add_level("daily", ChanlunAnalyser(code="SZ000001", frequency="DAILY"))
mla.add_level("30min", ChanlunAnalyser(code="SZ000001", frequency="30MIN"))
mla.process("daily", df_daily)
mla.process("30min", df_30min)
results = mla.results()
assert "daily" in results
assert "30min" in results
assert len(results["daily"].bis) >= 0
assert len(results["30min"].bis) >= 0
def test_multi_level_low_level_qs(self) -> None:
"""高级别笔对应的低级别趋势信息。"""
from easy_tdx.chanlun.multi_level import MultiLevelAnalyser
from easy_tdx.chanlun.analyser import ChanlunAnalyser
df_daily = _make_df(100)
df_30min = _make_df(200)
mla = MultiLevelAnalyser()
mla.add_level("daily", ChanlunAnalyser(code="SZ000001", frequency="DAILY"))
mla.add_level("30min", ChanlunAnalyser(code="SZ000001", frequency="30MIN"))
mla.process("daily", df_daily)
mla.process("30min", df_30min)
daily_result = mla.get_result("daily")
if daily_result and len(daily_result.bis) > 0:
last_bi = daily_result.bis[-1]
qs_info = mla.query_low_level_qs("daily", "30min", last_bi)
assert qs_info is not None
assert "zs_count" in qs_info
assert "bi_count" in qs_info
def test_multi_level_empty(self) -> None:
"""无数据时应返回空结果。"""
from easy_tdx.chanlun.multi_level import MultiLevelAnalyser
mla = MultiLevelAnalyser()
assert mla.results() == {}
# ── 增量更新测试 ────────────────────────────────────────────────────────
class TestIncrementalUpdate:
"""ChanlunAnalyser 增量更新测试。"""
def test_incremental_update(self) -> None:
"""追加 K 线后应重新计算。"""
from easy_tdx.chanlun.analyser import ChanlunAnalyser
df1 = _make_df(30)
analyser = ChanlunAnalyser(code="SZ000001")
analyser.process_klines(df1)
bi_count_1 = len(analyser.result.bis)
# 追加更多数据
df2 = pd.concat([df1, _make_df(30)], ignore_index=True)
# 重新生成 datetime 避免重复
df2["datetime"] = pd.date_range("2025-01-02", periods=len(df2), freq="B")
analyser.process_klines(df2)
bi_count_2 = len(analyser.result.bis)
# 更长数据应有更多或相等的笔
assert bi_count_2 >= bi_count_1
def test_full_replacement(self) -> None:
"""完全替换数据应正常工作。"""
from easy_tdx.chanlun.analyser import ChanlunAnalyser
df1 = _make_df(50)
df2 = _make_df(100)
analyser = ChanlunAnalyser(code="SZ000001")
analyser.process_klines(df1)
count1 = len(analyser.result.klines)
analyser.process_klines(df2)
count2 = len(analyser.result.klines)
assert count2 == 100
assert count2 > count1
# ── 走势段测试 ──────────────────────────────────────────────────────────
class TestZsd:
"""走势段/趋势段 测试。"""
def test_zsd_from_xds(self) -> None:
"""线段应能组合为走势段。"""
from easy_tdx.chanlun.zsd import find_zsds
from easy_tdx.chanlun.xd import find_xds
cks = [
CLKline(
k_index=i,
date=datetime(2025, 1, 2 + i),
open=10,
close=10,
high=10 + i % 5,
low=10 - i % 3,
amount=0.0,
index=i,
)
for i in range(20)
]
# 使用更真实的数据
import random
random.seed(42)
h_vals = [10]
l_vals = [8]
for i in range(1, 20):
h_vals.append(h_vals[-1] + random.uniform(-2, 3))
l_vals.append(l_vals[-1] + random.uniform(-3, 2))
cks = [
CLKline(
k_index=i,
date=datetime(2025, 1, 2) + __import__("datetime").timedelta(days=i),
open=l_vals[i],
close=h_vals[i],
high=max(h_vals[i], l_vals[i]) + 1,
low=min(h_vals[i], l_vals[i]) - 1,
amount=1000.0,
index=i,
)
for i in range(20)
]
fxs = find_fractals(cks)
bis = find_bis(fxs)
xds = find_xds(bis)
zsds = find_zsds(xds)
# 可能没有足够的线段形成走势段
assert isinstance(zsds, list)
def test_empty_xds(self) -> None:
"""空线段列表应返回空走势段。"""
from easy_tdx.chanlun.zsd import find_zsds
assert find_zsds([]) == []