"""缠论核心计算 单元测试。""" 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, # noqa: E741 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, # noqa: E741 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 def test_fractal_trap_regression(self) -> None: """回归测试:密集交替分型不应卡死笔算法。 场景:持续下跌走势中,分型在相邻 CKline 位置交替出现(mid_gap=1), 导致每个异类型分型与前一个同类型分型共享 2 根 CKline(gap=0)。 修复前:贪心算法用更极端的同类型分型替换 start_fx, 推进 right_kline_index,使后续所有异类型分型 gap 永远为 0,卡死算法。 修复后:存在 pending 异类型分型时不替换 start_fx,保留较早位置使 gap 自然递增。 """ # 下跌锯齿形:分型在连续 CKline 位置交替(ding/di mid_gap=1) # 每个 di 比前一个低,每个 ding 也比前一个低 → 持续下跌 cks = [ _ck(0, "2025-01-02", 145, 145, 150, 140), _ck(1, "2025-01-03", 130, 130, 135, 125), # di _ck(2, "2025-01-06", 140, 140, 145, 135), # ding _ck(3, "2025-01-07", 125, 125, 130, 120), # di _ck(4, "2025-01-08", 133, 133, 138, 128), # ding _ck(5, "2025-01-09", 120, 120, 125, 115), # di _ck(6, "2025-01-10", 127, 127, 132, 122), # ding _ck(7, "2025-01-13", 113, 113, 118, 108), # di _ck(8, "2025-01-14", 121, 121, 126, 116), # ding _ck(9, "2025-01-15", 107, 107, 112, 102), # di (overlap ends) _ck(10, "2025-01-16", 103, 103, 108, 98), _ck(11, "2025-01-17", 113, 113, 118, 108), # ding _ck(12, "2025-01-20", 101, 101, 106, 96), ] fxs = find_fractals(cks) assert len(fxs) >= 6, f"应产生至少6个分型,实际 {len(fxs)}" bis = find_bis(fxs) # 关键断言:密集交替分型不应导致算法卡死 assert len(bis) >= 2, ( f"密集交替分型场景应产出至少2笔,实际只有 {len(bis)} 笔。" f"分型数: {len(fxs)},可能触发了分型陷阱 bug。" ) # 验证方向交替 for i in range(1, len(bis)): assert bis[i].direction != bis[i - 1].direction, ( f"笔 {i - 1} 和笔 {i} 方向相同 ({bis[i].direction.value}),笔的方向应该交替。" ) # ── 中枢计算测试 ────────────────────────────────────────────────────────── 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 # 可视化字段:中枢/买卖点/背驰应携带对应 K 线日期(若该样本产出了它们) import re date_re = re.compile(r"^\d{4}-\d{2}-\d{2}$") for zs in d["zss"]: assert "start_date" in zs assert "end_date" in zs if zs["start_date"] is not None: assert date_re.match(zs["start_date"]) if zs["end_date"] is not None: assert date_re.match(zs["end_date"]) for mmd in d["mmds"]: assert "date" in mmd if mmd["date"] is not None: assert date_re.match(mmd["date"]) for bc in d["bcs"]: assert "curr_date" in bc assert "prev_date" in bc if bc["curr_date"] is not None: assert date_re.match(bc["curr_date"]) if bc["prev_date"] is not None: assert date_re.match(bc["prev_date"]) def test_result_to_dict_with_visual_dates(self) -> None: """可视化字段:足够数据下 zss/mmds/bcs 应携带合法 K 线日期。 用一段振荡+趋势的数据,确保能确定性产出中枢/买卖点/背驰, 从而真正覆盖 to_dict() 的日期输出分支。 """ import math import re import pandas as pd from easy_tdx.chanlun.analyser import ChanlunAnalyser dates = pd.date_range("2025-01-02", periods=40, freq="B") highs = [15 + 5 * math.sin(i / 2) + i * 0.2 for i in range(40)] lows = [highs[i] - 4 for i in range(40)] df = pd.DataFrame( { "datetime": dates, "open": [h - 2 for h in highs], "close": [h - 1 for h in highs], "high": highs, "low": lows, "vol": [1000] * 40, } ) analyser = ChanlunAnalyser(code="SZ000001") d = analyser.process_klines(df).to_dict() date_re = re.compile(r"^\d{4}-\d{2}-\d{2}$") # 中枢必须有起止日期 assert len(d["zss"]) > 0 for zs in d["zss"]: assert zs["start_date"] is not None assert zs["end_date"] is not None assert date_re.match(zs["start_date"]) assert date_re.match(zs["end_date"]) # 买卖点必须有触发日期 assert len(d["mmds"]) > 0 for mmd in d["mmds"]: assert mmd["date"] is not None assert date_re.match(mmd["date"]) # 背驰必须有当前笔 + 对照笔日期 assert len(d["bcs"]) > 0 for bc in d["bcs"]: assert bc["curr_date"] is not None assert bc["prev_date"] is not None assert date_re.match(bc["curr_date"]) assert date_re.match(bc["prev_date"]) def test_result_to_dict_minute_frequency_includes_time(self) -> None: """分钟级别 frequency 下,日期字段应输出完整时分 YYYY-MM-DD HH:MM。 对应网友反馈:分钟/低级别也需要时分用于分时可视化。 覆盖 CLI 原始值(5MIN/30MIN)与 Web 映射值(5min/30min)两种大小写。 """ import math import re import pandas as pd from easy_tdx.chanlun.analyser import ChanlunAnalyser dates = pd.date_range("2025-01-02 09:30", periods=60, freq="5min") highs = [15 + 5 * math.sin(i / 2) + i * 0.01 for i in range(60)] lows = [highs[i] - 1.5 for i in range(60)] df = pd.DataFrame( { "datetime": dates, "open": [h - 0.5 for h in highs], "close": [h - 0.2 for h in highs], "high": highs, "low": lows, "vol": [1000] * 60, } ) datetime_re = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$") # CLI 原始值(大写 5MIN)与 Web 映射值(小写 5min)应行为一致 for freq in ("5MIN", "5min"): d = ChanlunAnalyser(code="SZ000001", frequency=freq).process_klines(df).to_dict() # bis 日期应带时分 assert len(d["bis"]) > 0 for bi in d["bis"]: assert datetime_re.match(bi["start_date"]) assert datetime_re.match(bi["end_date"]) # zss/mmds/bcs 若产出,同样应带时分(有就检查) for zs in d["zss"]: if zs["start_date"] is not None: assert datetime_re.match(zs["start_date"]) if zs["end_date"] is not None: assert datetime_re.match(zs["end_date"]) for mmd in d["mmds"]: if mmd["date"] is not None: assert datetime_re.match(mmd["date"]) for bc in d["bcs"]: if bc["curr_date"] is not None: assert datetime_re.match(bc["curr_date"]) if bc["prev_date"] is not None: assert datetime_re.match(bc["prev_date"]) def test_print_table_with_dates(self) -> None: """CLI table 模式应正确消费 zss/mmds/bcs 的日期字段。 用能确定性产出中枢/买卖点/背驰的数据,调 _print_table 确保不抛异常、 且输出中包含新增的日期标记(→ 表示日期区间)。 """ import contextlib import io import math import pandas as pd from easy_tdx.chanlun.analyser import ChanlunAnalyser from easy_tdx.cli.cmd_chanlun import _print_table dates = pd.date_range("2025-01-02", periods=40, freq="B") highs = [15 + 5 * math.sin(i / 2) + i * 0.2 for i in range(40)] lows = [highs[i] - 4 for i in range(40)] df = pd.DataFrame( { "datetime": dates, "open": [h - 2 for h in highs], "close": [h - 1 for h in highs], "high": highs, "low": lows, "vol": [1000] * 40, } ) d = ChanlunAnalyser(code="SZ000001", frequency="DAILY").process_klines(df).to_dict() buf = io.StringIO() with contextlib.redirect_stdout(buf): _print_table(d) out = buf.getvalue() # 中枢/买卖点/背驰都应出现,且中枢行应含日期区间箭头 assert "── 中枢 ──" in out assert "── 买卖点 ──" in out assert "── 背驰 ──" in out # 中枢行格式:[idx] zg=... assert "→" in out