From 104ecf0268789fa0d651af8fda50a5e087179876 Mon Sep 17 00:00:00 2001 From: YuWuKunCheng Date: Tue, 26 May 2026 10:59:28 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AC=AC=E5=9B=9B=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- chan.py | 5 +- chanlun-py/build.sh | 7 +- chanlun-py/src/business_py.rs | 2 +- chanlun-py/test_integration.py | 307 +++++++++++++++++++++++++++- chanlun/src/algorithm/divergence.rs | 6 + chanlun/src/business/multi_frame.rs | 6 +- chanlun/src/business/observer.rs | 4 +- chanlun/src/config.rs | 2 +- chanlun/src/kline/chan_kline.rs | 3 + chanlun/src/structure/dash_line.rs | 12 +- chanlun/src/types/direction.rs | 4 + 11 files changed, 334 insertions(+), 24 deletions(-) diff --git a/chan.py b/chan.py index 9030f78..fc5f216 100644 --- a/chan.py +++ b/chan.py @@ -345,7 +345,7 @@ class 缠论配置(BaseModel): 线段内部背驰_测度: bool = True 线段内部背驰_模式: str = "相对" # 【任意,配置,全量,相对】 - 加载文件路径: str = "./templates/last.nb" + 加载文件路径: str = "" @model_validator(mode="before") def 兼容旧版本配置(cls, values: Dict[str, Any]) -> Dict[str, Any]: @@ -4178,7 +4178,8 @@ def 测试_周期合成(配置: 缠论配置, 配置组: Dict[int, 缠论配置] if __name__ == "__main__": + 当前配置 = 缠论配置.不推送() - 当前配置.加载文件路径 = "./btcusd-300-1761327300-1776327900.nb" + 当前配置.加载文件路径 = str(Path(__file__).parent / "btcusd-300-1761327300-1776327900.nb") 测试_读取数据(当前配置)().测试_保存数据() 测试_周期合成(当前配置)().测试_保存数据() diff --git a/chanlun-py/build.sh b/chanlun-py/build.sh index d2c61bd..cbada4d 100755 --- a/chanlun-py/build.sh +++ b/chanlun-py/build.sh @@ -86,7 +86,12 @@ cmd_test() { local wheel=$(ls -t target/wheels/*.whl 2>/dev/null | head -1) pip install --force-reinstall "$wheel" 2>&1 | tail -3 } - python3 test_integration.py + # 复制到临时目录运行,避免本地 chanlun/ 目录被优先导入 + local tmp_dir=$(mktemp -d 2>/dev/null || echo "${TMPDIR:-/tmp}/chanlun-test-$$") + mkdir -p "$tmp_dir" + cp test_integration.py "$tmp_dir/test_integration.py" + CHANLUN_PROJECT_ROOT="$PROJECT_DIR/.." python3 "$tmp_dir/test_integration.py" "$@" + rm -rf "$tmp_dir" } cmd_sdist() { diff --git a/chanlun-py/src/business_py.rs b/chanlun-py/src/business_py.rs index 1c96980..6a1e56b 100644 --- a/chanlun-py/src/business_py.rs +++ b/chanlun-py/src/business_py.rs @@ -279,7 +279,7 @@ impl 买卖点Py { // ========== 观察者 ========== -#[pyclass(name = "观察者", unsendable)] +#[pyclass(name = "观察者", subclass, unsendable)] pub struct 观察者Py { pub(crate) inner: chanlun::business::observer::观察者, } diff --git a/chanlun-py/test_integration.py b/chanlun-py/test_integration.py index f7ef06e..369df58 100644 --- a/chanlun-py/test_integration.py +++ b/chanlun-py/test_integration.py @@ -4,9 +4,21 @@ import sys import os import struct +import tempfile import chanlun +# 项目根目录(test_integration.py 位于 /chanlun-py/ 下) +# 当脚本被复制到其他路径运行时,通过环境变量 CHANLUN_PROJECT_ROOT 指定 +_PROJECT_ROOT = os.environ.get( + "CHANLUN_PROJECT_ROOT", + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), +) + +NB_PATH = os.path.join(_PROJECT_ROOT, "btcusd-300-1761327300-1776327900.nb") +_PY_REF_DIR = os.path.join(_PROJECT_ROOT, "Py_btcusd:300_1761327300_1776327900") +_RUST_REF_DIR = os.path.join(_PROJECT_ROOT, "chanlun", "Rust_btcusd:300_1761327300_1776327900") + def read_nb_bars(path, max_bars=None): """Read bars from .nb file (48 bytes each: 6 × f64 big-endian).""" @@ -25,14 +37,267 @@ def read_nb_bars(path, max_bars=None): return bars -def main(): - nb_path = "/home/moscow/chanlun.rs/btcusd-300-1761327300-1776327900.nb" - ref_dir = "/home/moscow/chanlun.rs/Py_btcusd:300_1761327300_1776327900" - out_dir = "/tmp/chanlun_py_test_output" +# ============================================================ +# 观察者 子类化 / 方法重写 测试 +# ============================================================ + + +def test_subclass_basic(): + """子类可创建,isinstance 正确.""" + + class Sub(chanlun.观察者): + pass + + obs = Sub("btcusd", 300) + assert isinstance(obs, chanlun.观察者) + assert type(obs).__name__ == "Sub" + assert obs.标识 == "btcusd:300" + assert obs.周期 == 300 + print(" ✓ test_subclass_basic") + + +def test_subclass_init_extra_attrs(): + """子类 __init__ 可添加自定义属性.""" + + class Sub(chanlun.观察者): + def __init__(self, 符号, 周期): + self.tag = "custom" + self.count = 0 + + obs = Sub("btcusd", 300) + assert obs.tag == "custom" + assert obs.count == 0 + # 基类字段不受影响 + assert obs.标识 == "btcusd:300" + print(" ✓ test_subclass_init_extra_attrs") + + +def test_subclass_new_filter_kwargs(): + """__new__ 过滤子类专属参数,只把父类需要的传给 super().__new__.""" + + class Sub(chanlun.观察者): + def __new__(cls, 符号, 周期, *, extra=None, **kwargs): + return super().__new__(cls, 符号, 周期) + + def __init__(self, 符号, 周期, *, extra=None, **kwargs): + self.extra = extra + + obs = Sub("btcusd", 300, extra={"debug": True}) + assert obs.extra == {"debug": True} + assert obs.标识 == "btcusd:300" + + obs2 = Sub("ethusd", 60) + assert obs2.extra is None + print(" ✓ test_subclass_new_filter_kwargs") + + +def test_subclass_new_pass_config(): + """__new__ 透传 配置 参数到父类.""" + cfg = chanlun.缠论配置() + + class Sub(chanlun.观察者): + def __new__(cls, 符号, 周期, 配置=None, *, tag="", **kwargs): + return super().__new__(cls, 符号, 周期, 配置=配置) + + def __init__(self, 符号, 周期, 配置=None, *, tag="", **kwargs): + self.tag = tag + + obs = Sub("btcusd", 300, cfg, tag="test-tag") + assert obs.标识 == "btcusd:300" + assert obs.tag == "test-tag" + print(" ✓ test_subclass_new_pass_config") + + +def test_override_method_super_call(): + """重写 增加原始K线,super() 调用父类,全线管线运行.""" + bars = read_nb_bars(NB_PATH, max_bars=500) + + # 基类对照组 + base_obs = chanlun.观察者("btcusd", 300) + for i, (ts, o, h, l, c, v) in enumerate(bars): + k = chanlun.K线.创建普K(f"base_{i}", ts, o, h, l, c, v, i, 300) + base_obs.增加原始K线(k) + + # 子类实验组 + class Sub(chanlun.观察者): + def __init__(self, 符号, 周期): + self.intercept_count = 0 + self.intercept_timestamps = [] + + def 增加原始K线(self, 普K): + self.intercept_count += 1 + self.intercept_timestamps.append(普K.时间戳) + super().增加原始K线(普K) + + sub_obs = Sub("btcusd", 300) + for i, (ts, o, h, l, c, v) in enumerate(bars): + k = chanlun.K线.创建普K(f"sub_{i}", ts, o, h, l, c, v, i, 300) + sub_obs.增加原始K线(k) + + # 拦截次数 + assert sub_obs.intercept_count == 500 + assert len(sub_obs.intercept_timestamps) == 500 + + # 各层级序列与基类完全一致 + sequences = [ + "普通K线序列", + "缠论K线序列", + "分型序列", + "笔序列", + "线段序列", + "中枢序列", + ] + for attr in sequences: + base_len = len(getattr(base_obs, attr)) + sub_len = len(getattr(sub_obs, attr)) + assert base_len == sub_len, f"{attr}: base={base_len}, sub={sub_len}" + + # 笔时间戳精确对比 + base_pens = base_obs.笔序列 + sub_pens = sub_obs.笔序列 + for j, (bp, sp) in enumerate(zip(base_pens, sub_pens)): + assert bp.文.中.时间戳 == sp.文.中.时间戳, f"笔[{j}] 时间戳不一致" + + print(" ✓ test_override_method_super_call") + + +def test_override_getter(): + """重写 @property getter,super() 取基类值.""" + + class Sub(chanlun.观察者): + @property + def 标识(self): + return f"[MOCKED] {super().标识}" + + obs = Sub("btcusd", 300) + assert obs.标识 == "[MOCKED] btcusd:300" + # 其他 getter 不受影响 + assert obs.周期 == 300 + print(" ✓ test_override_getter") + + +def test_override_str_repr(): + """重写 __str__ / __repr__.""" + + class Sub(chanlun.观察者): + def __str__(self): + return f"Custom({self.标识})" + + def __repr__(self): + return self.__str__() + + obs = Sub("btcusd", 300) + assert str(obs) == "Custom(btcusd:300)" + assert repr(obs) == "Custom(btcusd:300)" + print(" ✓ test_override_str_repr") + + +def test_multi_level_inheritance(): + """多层继承,MRO 调用链完整.""" + + class Level1(chanlun.观察者): + def 增加原始K线(self, 普K): + self.l1_log = getattr(self, "l1_log", []) + self.l1_log.append("L1") + super().增加原始K线(普K) + + class Level2(Level1): + def 增加原始K线(self, 普K): + self.l2_log = getattr(self, "l2_log", []) + self.l2_log.append("L2") + super().增加原始K线(普K) + + obs = Level2("btcusd", 300) + k = chanlun.K线.创建普K("test", 1761327300, 100.0, 105.0, 99.0, 103.0, 1000.0, 0, 300) + obs.增加原始K线(k) + + assert obs.l2_log == ["L2"], f"L2 log: {obs.l2_log}" + assert obs.l1_log == ["L1"], f"L1 log: {obs.l1_log}" + assert len(obs.普通K线序列) == 1 + print(" ✓ test_multi_level_inheritance") + + +def test_unoverridden_method_inherited(): + """未重写的方法从基类直接继承.""" + + class Sub(chanlun.观察者): + pass + + obs = Sub("btcusd", 120) + k = chanlun.K线.创建普K("test", 1761327900, 100.0, 105.0, 99.0, 103.0, 1000.0, 0, 300) + obs.增加原始K线(k) + + assert obs.标识 == "btcusd:120" + assert obs.周期 == 120 + assert len(obs.普通K线序列) == 1 + assert len(obs.缠论K线序列) == 1 + # 静态重新分析 也能正常继承 + obs.静态重新分析() + print(" ✓ test_unoverridden_method_inherited") + + +def test_override_reset(): + """重写 重置基础序列,子类状态也重置.""" + + class Sub(chanlun.观察者): + def __init__(self, 符号, 周期): + self.my_log = [] + + def 重置基础序列(self): + self.my_log.clear() + super().重置基础序列() + + obs = Sub("btcusd", 300) + k = chanlun.K线.创建普K("test", 1761327300, 100.0, 105.0, 99.0, 103.0, 1000.0, 0, 300) + obs.增加原始K线(k) + obs.my_log.append("test") + + assert len(obs.普通K线序列) == 1 + obs.重置基础序列() + assert len(obs.普通K线序列) == 0 + assert obs.my_log == [] + print(" ✓ test_override_reset") + + +def run_subclass_tests(): + print("=== 观察者 子类化/重写 测试 ===") + tests = [ + test_subclass_basic, + test_subclass_init_extra_attrs, + test_subclass_new_filter_kwargs, + test_subclass_new_pass_config, + test_override_method_super_call, + test_override_getter, + test_override_str_repr, + test_multi_level_inheritance, + test_unoverridden_method_inherited, + test_override_reset, + ] + for test in tests: + try: + test() + except Exception as e: + print(f" ✗ {test.__name__} FAILED: {e}") + import traceback + + traceback.print_exc() + return 1 + print(" ✓ 全部通过") + return 0 + + +# ============================================================ +# 集成对比测试 +# ============================================================ + + +def run_integration_test(): + """全量集成测试:喂入 .nb 数据,与 Python 参考输出对比。""" + out_dir = os.path.join(tempfile.gettempdir(), "chanlun_py_test_output") # Read all bars print("Reading bars from .nb file...") - bars = read_nb_bars(nb_path) + bars = read_nb_bars(NB_PATH) print(f" Read {len(bars)} bars") # Create observer (default config) @@ -67,13 +332,13 @@ def main(): # Compare with Python reference print("\nComparing with Python reference...") - ref_files = sorted(os.listdir(ref_dir)) + ref_files = sorted(os.listdir(_PY_REF_DIR)) match_count = 0 diff_count = 0 all_match = True for fname in ref_files: - ref_path = os.path.join(ref_dir, fname) + ref_path = os.path.join(_PY_REF_DIR, fname) out_path = os.path.join(actual_out_dir, fname) if not os.path.exists(out_path): @@ -103,13 +368,12 @@ def main(): all_match = False # Also compare extra files against Rust reference - rust_ref_dir = "/home/moscow/chanlun.rs/chanlun/Rust_btcusd:300_1761327300_1776327900" extra_files = set(out_files) - set(ref_files) if extra_files: print("\nComparing extra files with Rust reference...") for fname in sorted(extra_files): out_path = os.path.join(actual_out_dir, fname) - rust_ref_path = os.path.join(rust_ref_dir, fname) + rust_ref_path = os.path.join(_RUST_REF_DIR, fname) if os.path.exists(rust_ref_path): with open(rust_ref_path) as f: ref_lines = f.readlines() @@ -131,5 +395,28 @@ def main(): return 1 +def main(): + import argparse + + parser = argparse.ArgumentParser(description="chanlun PyO3 集成测试") + parser.add_argument("test", nargs="?", default="all", choices=["all", "subclass", "integration"], help="运行哪组测试 (默认: all)") + args = parser.parse_args() + + exit_code = 0 + + if args.test in ("all", "subclass"): + if run_subclass_tests() != 0: + exit_code = 1 + + if args.test in ("all", "integration"): + if run_integration_test() != 0: + exit_code = 1 + + if exit_code == 0: + print("\n✓ 所有测试通过") + + sys.exit(exit_code) + + if __name__ == "__main__": - sys.exit(main()) + main() diff --git a/chanlun/src/algorithm/divergence.rs b/chanlun/src/algorithm/divergence.rs index 798e623..383dca4 100644 --- a/chanlun/src/algorithm/divergence.rs +++ b/chanlun/src/algorithm/divergence.rs @@ -40,10 +40,16 @@ impl 背驰分析 { /// 斜率背驰 — 价格斜率背驰 pub fn 斜率背驰(进入段: &虚线, 离开段: &虚线) -> bool { let dx = (进入段.武.时间戳 - 进入段.文.时间戳) as f64; + if dx == 0.0 { + return false; + } let dy = 进入段.武.分型特征值 - 进入段.文.分型特征值; let 进入斜率 = dy / dx; let dx = (离开段.武.时间戳 - 离开段.文.时间戳) as f64; + if dx == 0.0 { + return false; + } let dy = 离开段.武.分型特征值 - 离开段.文.分型特征值; let 离开斜率 = dy / dx; diff --git a/chanlun/src/business/multi_frame.rs b/chanlun/src/business/multi_frame.rs index 848646a..2f32d96 100644 --- a/chanlun/src/business/multi_frame.rs +++ b/chanlun/src/business/multi_frame.rs @@ -88,7 +88,9 @@ impl 立体分析器 { /// 测试_保存数据 — 多级别数据拆分保存 /// 创建父目录 PyM_{标识}_{起始时间}_{结束时间},各周期观察者保存到子目录 pub fn 测试_保存数据(&self) { - let 根目录 = std::env::current_dir().unwrap_or_default(); + let 根目录 = std::env::var("CHANLUN_DATA_DIR") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| std::env::temp_dir()); let 起始时间 = self .单体分析器 @@ -124,7 +126,7 @@ impl 立体分析器 { for 周期 in &self.周期组 { if let Some(观察员) = self.单体分析器.get(周期) { - 观察员.测试_保存数据(Some(保存路径.to_str().unwrap())); + 观察员.测试_保存数据(Some(&保存路径.to_string_lossy())); } } diff --git a/chanlun/src/business/observer.rs b/chanlun/src/business/observer.rs index e6170fe..8e85d44 100644 --- a/chanlun/src/business/observer.rs +++ b/chanlun/src/business/observer.rs @@ -376,7 +376,9 @@ impl 观察者 { // 确定根目录 let 根目录 = match root { Some(r) => std::path::PathBuf::from(r), - None => std::env::current_dir().unwrap_or_default(), + None => std::env::var("CHANLUN_DATA_DIR") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| std::env::temp_dir()), }; // 生成子目录名称 diff --git a/chanlun/src/config.rs b/chanlun/src/config.rs index db88409..cd61d0a 100644 --- a/chanlun/src/config.rs +++ b/chanlun/src/config.rs @@ -196,7 +196,7 @@ impl Default for 缠论配置 { 线段内部背驰_斜率: true, 线段内部背驰_测度: true, 线段内部背驰_模式: "相对".into(), - 加载文件路径: "./templates/last.nb".into(), + 加载文件路径: String::new(), } } } diff --git a/chanlun/src/kline/chan_kline.rs b/chanlun/src/kline/chan_kline.rs index 7803c83..7cad5d8 100644 --- a/chanlun/src/kline/chan_kline.rs +++ b/chanlun/src/kline/chan_kline.rs @@ -172,6 +172,9 @@ impl 缠论K线 { 普k: Rc, 之前: Option<&缠论K线>, ) -> Self { + if 高.is_nan() || 低.is_nan() { + panic!("缠K高/低不能为NaN: 高={高}, 低={低}"); + } assert!(高 >= 低, "缠K高必须>=低: 高={高}, 低={低}"); let 周期 = 普k.周期; diff --git a/chanlun/src/structure/dash_line.rs b/chanlun/src/structure/dash_line.rs index a13f447..c6af0c6 100644 --- a/chanlun/src/structure/dash_line.rs +++ b/chanlun/src/structure/dash_line.rs @@ -464,7 +464,7 @@ impl 虚线 { .unwrap() .MACD柱 .partial_cmp(&b.macd.as_ref().unwrap().MACD柱) - .unwrap() + .unwrap_or(std::cmp::Ordering::Equal) }) .unwrap(); let mut 柱对 = vec![Rc::clone(*最高柱子), Rc::clone(最后)]; @@ -481,7 +481,7 @@ impl 虚线 { .max_by(|a, b| { let da = a.macd.as_ref().and_then(|m| m.DIF).unwrap_or(0.0); let db = b.macd.as_ref().and_then(|m| m.DIF).unwrap_or(0.0); - da.partial_cmp(&db).unwrap() + da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal) }) .unwrap(); if let (Some(m0), Some(m1)) = (最高离差值.macd.as_ref(), 最后.macd.as_ref()) { @@ -498,7 +498,7 @@ impl 虚线 { .max_by(|a, b| { let da = a.macd.as_ref().and_then(|m| m.DEA).unwrap_or(0.0); let db = b.macd.as_ref().and_then(|m| m.DEA).unwrap_or(0.0); - da.partial_cmp(&db).unwrap() + da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal) }) .unwrap(); if let (Some(m0), Some(m1)) = (最高信号线.macd.as_ref(), 最后.macd.as_ref()) { @@ -531,7 +531,7 @@ impl 虚线 { .MACD柱 .abs() .partial_cmp(&b.macd.as_ref().unwrap().MACD柱.abs()) - .unwrap() + .unwrap_or(std::cmp::Ordering::Equal) }) .unwrap(); let mut 柱对 = vec![Rc::clone(*最高柱子), Rc::clone(最后)]; @@ -548,7 +548,7 @@ impl 虚线 { .max_by(|a, b| { let da = a.macd.as_ref().and_then(|m| m.DIF).unwrap_or(0.0).abs(); let db = b.macd.as_ref().and_then(|m| m.DIF).unwrap_or(0.0).abs(); - da.partial_cmp(&db).unwrap() + da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal) }) .unwrap(); if let (Some(m0), Some(m1)) = (最高离差值.macd.as_ref(), 最后.macd.as_ref()) { @@ -565,7 +565,7 @@ impl 虚线 { .max_by(|a, b| { let da = a.macd.as_ref().and_then(|m| m.DEA).unwrap_or(0.0).abs(); let db = b.macd.as_ref().and_then(|m| m.DEA).unwrap_or(0.0).abs(); - da.partial_cmp(&db).unwrap() + da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal) }) .unwrap(); if let (Some(m0), Some(m1)) = (最高信号线.macd.as_ref(), 最后.macd.as_ref()) { diff --git a/chanlun/src/types/direction.rs b/chanlun/src/types/direction.rs index cb24132..6087caf 100644 --- a/chanlun/src/types/direction.rs +++ b/chanlun/src/types/direction.rs @@ -60,6 +60,10 @@ impl 相对方向 { /// 分析两个K线之间的相对方向 pub fn 分析(前高: f64, 前低: f64, 后高: f64, 后低: f64) -> Self { + // NaN 值无法判断方向,视为"同"避免 panic + if 前高.is_nan() || 前低.is_nan() || 后高.is_nan() || 后低.is_nan() { + return Self::同; + } if 前高 == 后高 && 前低 == 后低 { return Self::同; }