From c83cddbcc45e9091ade5a11752ce908b036c4434 Mon Sep 17 00:00:00 2001 From: YuWuKunCheng Date: Sun, 7 Jun 2026 13:08:19 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A7=82=E5=AF=9F=E8=80=85=20=E7=BA=BF?= =?UTF-8?q?=E6=AE=B5=E5=88=86=E6=9E=90/=E6=89=A9=E5=B1=95=E5=88=86?= =?UTF-8?q?=E6=9E=90=20=E6=94=AF=E6=8C=81=E6=97=A0=E9=99=90=E9=80=92?= =?UTF-8?q?=E5=BD=92=EF=BC=8C=E4=BF=9D=E7=95=99=E4=B9=8B=E5=89=8D=E5=BA=8F?= =?UTF-8?q?=E5=88=97=E4=B8=BA@property=20=E5=BD=A2=E5=BC=8F=20=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=20=E6=9F=90=E4=BA=9B=E5=87=BD=E6=95=B0=E7=9A=84?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- chanlun-py/Cargo.toml | 2 +- chanlun-py/chanlun/chan.py | 747 ++++++----- chanlun-py/pyproject.toml | 2 +- chanlun-py/src/business_py.rs | 88 +- chanlun-py/src/config_py.rs | 6 +- chanlun-py/src/indicators_py.rs | 21 +- .../tests/btcusd-300-1777649100-1778398800.nb | Bin 0 -> 120000 bytes chanlun-py/tests/test_all.py | 737 ++++++++++- chanlun/README.md | 1100 ++++++++++++----- chanlun/src/algorithm/divergence.rs | 2 +- chanlun/src/algorithm/segment.rs | 24 +- chanlun/src/indicators/calculator.rs | 195 ++- chanlun/src/kline/chan_kline.rs | 3 +- main.py | 111 +- 14 files changed, 2316 insertions(+), 722 deletions(-) create mode 100644 chanlun-py/tests/btcusd-300-1777649100-1778398800.nb diff --git a/chanlun-py/Cargo.toml b/chanlun-py/Cargo.toml index 9d9e328..4b8944d 100644 --- a/chanlun-py/Cargo.toml +++ b/chanlun-py/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "chanlun-py" -version = "26.6.34" +version = "26.6.42" edition = "2024" description = "缠论技术分析库 — Rust 高性能 Python 绑定" authors = ["YuYuKunKun"] diff --git a/chanlun-py/chanlun/chan.py b/chanlun-py/chanlun/chan.py index 6cf1be2..c5c8f92 100644 --- a/chanlun-py/chanlun/chan.py +++ b/chanlun-py/chanlun/chan.py @@ -140,6 +140,312 @@ def get_log_level() -> str: return _当前日志级别 +@lru_cache(128) +def K线相等(A, B, 浮点容差: float = 1e-9) -> tuple[bool, str]: + """原始K线相等校验:字段完备→浮点容错→普通全等""" + 比对字段 = ["标识", "序号", "周期", "时间戳", "高", "低", "开盘价", "收盘价", "成交量"] + + for 字段 in 比对字段: + a有 = hasattr(A, 字段) + b有 = hasattr(B, 字段) + if a有 and not b有: + return False, f"K线校验:字段[{字段}],A存在属性、B缺失属性" + if not a有 and b有: + return False, f"K线校验:字段[{字段}],B存在属性、A缺失属性" + if not (a有 and b有): + continue + + valA = getattr(A, 字段) + valB = getattr(B, 字段) + # 双浮点容错对比 + if isinstance(valA, float) and isinstance(valB, float): + 差值 = abs(valA - valB) + if 差值 > 浮点容差: + return False, f"K线校验:字段[{字段}]浮点超限,容差={浮点容差:.2e},A={valA:.10f},B={valB:.10f},差值={差值:.10f}" + + elif 字段 == "时间戳": + if int(valA) != int(valB): + return False, f"K线校验:字段[{字段}]数值不等,A={repr(valA)},B={repr(valB)}" + else: + if valA != valB: + return False, f"K线校验:字段[{字段}]数值不等,A={repr(valA)},B={repr(valB)}" + + return True, "K线:全部字段结构、数值校验完全一致" + + +def 缠论K线相等(A, B, 浮点容差: float = 1e-9) -> tuple[bool, str]: + """缠论K线:基础字段+标的K线递归校验""" + 比对字段 = ["序号", "时间戳", "高", "低", "方向", "分型", "周期", "标识", "分型特征值", "原始起始序号", "原始结束序号", "标的K线", "买卖点信息"] + + for 字段 in 比对字段: + a有 = hasattr(A, 字段) + b有 = hasattr(B, 字段) + if a有 and not b有: + return False, f"缠论K线校验:字段[{字段}],A存在、B缺失属性" + if not a有 and b有: + return False, f"缠论K线校验:字段[{字段}],B存在、A缺失属性" + if not (a有 and b有): + continue + + valA = getattr(A, 字段) + valB = getattr(B, 字段) + + if isinstance(valA, float) and isinstance(valB, float): + 差值 = abs(valA - valB) + if 差值 > 浮点容差: + return False, f"缠论K线校验:[{字段}]浮点超限,容差={浮点容差:.2e},A={valA:.10f},B={valB:.10f},差值={差值:.10f}" + elif 字段 == "标的K线": + if valA is None and valB is None: + continue + if valA is None or valB is None: + return False, f"缠论K线校验:[标的K线]单边空值,A={valA is None},B={valB is None}" + eq_flag, msg = K线相等(valA, valB, 浮点容差) + if not eq_flag: + return False, f"缠论K线校验:标的K线子项异常 >> {msg}" + + elif 字段 == "时间戳": + if int(valA) != int(valB): + return False, f"缠论K线校验:字段[{字段}]数值不等,A={repr(valA)},B={repr(valB)}" + + elif 字段 == "方向": + if str(valA) != str(valB): + return False, f"缠论K线校验:字段[{字段}]数值不等,A={repr(valA)},B={repr(valB)}" + + elif 字段 == "分型": + if str(valA) != str(valB): + return False, f"缠论K线校验:字段[{字段}]数值不等,A={repr(valA)},B={repr(valB)}" + + elif 字段 == "买卖点信息": + if set(valA) != set(valB): + return False, f"缠论K线校验:字段[{字段}]数值不等,A={repr(valA)},B={repr(valB)}" + else: + if valA != valB: + return False, f"缠论K线校验:[{字段}]数值不等,A={repr(valA)},B={repr(valB)}" + + return True, "缠论K线:全部字段、嵌套原始K线校验一致" + + +def 分型相等(A, B, 浮点容差: float = 1e-9) -> tuple[bool, str]: + """分型:左/中/右缠论K线递归 + 自有字段""" + 比对字段 = ["左", "中", "右", "_结构", "_时间戳", "_分型特征值"] + + for 字段 in 比对字段: + a存在 = hasattr(A, 字段) + b存在 = hasattr(B, 字段) + if a存在 and not b存在: + return False, f"分型校验:[{字段}]A有属性、B缺失" + if not a存在 and b存在: + return False, f"分型校验:[{字段}]B有属性、A缺失" + if not (a存在 and b存在): + continue + + valA = getattr(A, 字段) + valB = getattr(B, 字段) + + if isinstance(valA, float) and isinstance(valB, float): + 差值 = abs(valA - valB) + if 差值 > 浮点容差: + return False, f"分型校验:[{字段}]浮点超限,容差={浮点容差:.2e},A={valA:.10f},B={valB:.10f},差值={差值:.10f}" + elif 字段 in ("左", "中", "右"): + if valA is None and valB is None: + continue + if valA is None or valB is None: + return False, f"分型校验:[{字段}]空值不一致,A={valA is None},B={valB is None}" + eq_ok, msg = 缠论K线相等(valA, valB, 浮点容差) + if not eq_ok: + return False, f"分型校验:[{字段}]缠论K线子项异常 >> {msg}" + + elif 字段 == "_结构": + if str(valA) != str(valB): + return False, f"分型K线校验:字段[{字段}]数值不等,A={repr(valA)},B={repr(valB)}" + + elif 字段 == "_时间戳": + if int(valA) != int(valB): + return False, f"分型K线校验:字段[{字段}]数值不等,A={repr(valA)},B={repr(valB)}" + + else: + if valA != valB: + return False, f"分型校验:[{字段}]数值不等,A={repr(valA)},B={repr(valB)}" + + return True, "分型:自有字段+三根缠论K线全部校验一致" + + +@lru_cache(4096) +def 缺口相等(A, B, 浮点容差: float = 1e-9) -> tuple[bool, str]: + """缺口:高、低浮点校验""" + 比对字段 = ["高", "低"] + for 字段 in 比对字段: + a有 = hasattr(A, 字段) + b有 = hasattr(B, 字段) + if a有 and not b有: + return False, f"缺口校验:[{字段}]A存在、B缺失属性" + if not a有 and b有: + return False, f"缺口校验:[{字段}]B存在、A缺失属性" + if not (a有 and b有): + continue + + valA = getattr(A, 字段) + valB = getattr(B, 字段) + if isinstance(valA, float) and isinstance(valB, float): + 差值 = abs(valA - valB) + if 差值 > 浮点容差: + return False, f"缺口校验:[{字段}]浮点超限,容差={浮点容差:.2e},A={valA:.10f},B={valB:.10f},差值={差值:.10f}" + else: + if valA != valB: + return False, f"缺口校验:[{字段}]数值不等,A={repr(valA)},B={repr(valB)}" + return True, "缺口:上下沿价格校验完全一致" + + +def 线段特征相等(A, B, 浮点容差: float = 1e-9) -> tuple[bool, str]: + """线段特征:基础序列虚线列表逐项校验""" + 比对字段 = ["序号", "标识", "线段方向", "基础序列"] + 标签 = f"线段特征校验[A标识={A.标识},B标识={B.标识}]" + for 字段 in 比对字段: + a有 = hasattr(A, 字段) + b有 = hasattr(B, 字段) + if a有 and not b有: + return False, f"{标签}: [{字段}]A存在、B缺失属性" + if not a有 and b有: + return False, f"{标签}: [{字段}]B存在、A缺失属性" + if not (a有 and b有): + continue + + valA = getattr(A, 字段) + valB = getattr(B, 字段) + if 字段 == "基础序列": + if len(valA) != len(valB): + return False, f"{标签}: [基础序列]列表长度不一致,A长度={len(valA)},B长度={len(valB)}" + for idx, (itemA, itemB) in enumerate(zip(valA, valB)): + eq, msg = 虚线相等(itemA, itemB, 浮点容差) + if not eq: + return False, f"{标签}:基础序列[{idx}]子虚线异常 >> {msg}" + + elif 字段 == "线段方向": + if str(valA) != str(valB): + return False, f"{标签}: [{字段}]数值不等,A={repr(valA)},B={repr(valB)}" + else: + if valA != valB: + return False, f"{标签}: [{字段}]数值不等,A={repr(valA)},B={repr(valB)}" + return True, f"{标签}:字段与内部虚线序列全部一致" + + +def 中枢相等(A, B, 浮点容差: float = 1e-9) -> tuple[bool, str]: + """中枢:基础序列虚线列表+第三买卖线单虚线""" + 比对字段 = ["序号", "标识", "级别", "基础序列", "第三买卖线", "本级_第三买卖线"] + 标签 = f"中枢校验[A标识={A.标识},B标识={B.标识}]" + for 字段 in 比对字段: + a有 = hasattr(A, 字段) + b有 = hasattr(B, 字段) + if a有 and not b有: + return False, f"{标签}: [{字段}]A存在、B缺失属性" + if not a有 and b有: + return False, f"{标签}: [{字段}]B存在、A缺失属性" + if not (a有 and b有): + continue + + valA = getattr(A, 字段) + valB = getattr(B, 字段) + + if 字段 == "基础序列": + if len(valA) != len(valB): + return False, f"{标签}: [基础序列]长度不一致 A={len(valA)},B={len(valB)}" + for idx, (itemA, itemB) in enumerate(zip(valA, valB)): + eq, msg = 虚线相等(itemA, itemB, 浮点容差) + if not eq: + return False, f"{标签}:基础序列[{idx}]虚线异常 >> {msg}" + elif 字段 in ("第三买卖线", "本级_第三买卖线"): + if valA is None and valB is None: + continue + if valA is None or valB is None: + return False, f"{标签}: [{字段}]空值不一致 A={valA is None},B={valB is None}" + eq, msg = 虚线相等(valA, valB, 浮点容差) + if not eq: + return False, f"{标签}: [{字段}]子虚线异常 >> {msg}" + else: + if valA != valB: + return False, f"{标签}: [{字段}]数值不等,A={repr(valA)},B={repr(valB)}" + + return True, f"{标签}:基础序列+第三买卖线全部校验一致" + + +def 虚线相等(A, B, 浮点容差: float = 1e-9) -> tuple[bool, str]: + """虚线(笔/线段):全量字段、分型/缺口/K线/列表嵌套精细化报错""" + 比对字段 = ["标识", "序号", "级别", "文", "武", "有效性", "基础序列", "特征序列", "实_中枢序列", "虚_中枢序列", "合_中枢序列", "确认K线", "模式", "_特征序列_显示", "前一缺口", "前一结束位置", "短路修正"] + 标签 = f"虚线校验[A标识={A.标识},B标识={B.标识}]" + for 字段 in 比对字段: + a有 = hasattr(A, 字段) + b有 = hasattr(B, 字段) + if a有 and not b有: + return False, f"{标签}: [{字段}]A存在属性、B缺失属性" + if not a有 and b有: + return False, f"{标签}: [{字段}]B存在属性、A缺失属性" + if not (a有 and b有): + continue + + valA = getattr(A, 字段) + valB = getattr(B, 字段) + + # 文/武:分型 + if 字段 in ("文", "武"): + if valA is None and valB is None: + continue + if valA is None or valB is None: + return False, f"{标签}: [{字段}]分型空值不一致 A={valA is None},B={valB is None}" + eq, msg = 分型相等(valA, valB, 浮点容差) + if not eq: + return False, f"{标签}: [{字段}]子分型异常 >> {msg}" + # 前一缺口 + elif 字段 == "前一缺口": + if valA is None and valB is None: + continue + if valA is None or valB is None: + return False, f"{标签}: [前一缺口]空值不一致 A={valA is None},B={valB is None}" + eq, msg = 缺口相等(valA, valB, 浮点容差) + if not eq: + return False, f"{标签}: [前一缺口]子缺口异常 >> {msg}" + # 前一缺口 + elif 字段 == "前一结束位置": + if valA is None and valB is None: + continue + if valA is None or valB is None: + return False, f"{标签}: [前一结束位置]空值不一致 A={valA is None},B={valB is None}" + eq, msg = 虚线相等(valA, valB, 浮点容差) + if not eq: + return False, f"{标签}: [前一结束位置]异常 >> {msg}" + # 确认K线 + elif 字段 == "确认K线": + if valA is None and valB is None: + continue + if valA is None or valB is None: + return False, f"{标签}: [确认K线]空值不一致 A={valA is None},B={valB is None}" + eq, msg = 缠论K线相等(valA, valB, 浮点容差) + if not eq: + return False, f"{标签}: [确认K线]子缠论K线异常 >> {msg}" + # 各类列表 + elif 字段 in ("基础序列", "实_中枢序列", "虚_中枢序列", "合_中枢序列", "特征序列"): + if len(valA) != len(valB): + return False, f"{标签}: [{字段}]列表长度不一致 A={len(valA)},B={len(valB)}" + for idx, (itemA, itemB) in enumerate(zip(valA, valB)): + if itemA is None and itemB is None: + continue + if itemA is None or itemB is None: + return False, f"{标签}: [{字段}][{idx}]单项空值不一致 A={itemA is None},B={itemB is None}" + if 字段 == "基础序列": + eq, msg = 虚线相等(itemA, itemB, 浮点容差) + elif "中枢" in 字段: + eq, msg = 中枢相等(itemA, itemB, 浮点容差) + else: + eq, msg = 线段特征相等(itemA, itemB, 浮点容差) + if not eq: + return False, f"{标签}: [{字段}][{idx}]子项异常 >> {msg}" + # 普通字段 + else: + if valA != valB: + return False, f"{标签}: [{字段}]数值不等 A={repr(valA)},B={repr(valB)}" + + return True, f"{标签}:全字段、所有嵌套子结构校验全部一致" + + class 买卖点类型(str, Enum): """买卖点类型 — 缠论的三类买卖点及扩展类型。 @@ -2080,6 +2386,9 @@ class 指标计算器: 指标计算器._更新均线(当前K线, 全序列, 配置) + if prev is not None: + 指标计算器._回填新指标(全序列, 配置) + @staticmethod def _计算MACD组(当前K线: K线, prev: Optional[指标容器], 配置: 缠论配置): idx = 当前K线.指标 @@ -2154,6 +2463,86 @@ class 指标计算器: 前值 = 普K序列[-2].指标.均线.get(key) 当前K线.指标.均线[key] = 均线工具.增量EMA(普K序列, period, 配置.指标计算方式, 前值) + @staticmethod + def _回填新指标(全序列: List[K线], 配置: 缠论配置): + """运行中新增指标参数时,回填所有历史K线。 + + 比较首尾K线的指标键,检测运行中动态添加到配置的新指标参数, + 然后从第一根K线开始逐根重新计算,使历史K线也能获得新指标值。 + """ + 首K指标 = 全序列[0].指标 + 尾K指标 = 全序列[-1].指标 + if 首K指标 is None or 尾K指标 is None: + return + + def _新键(尾指标, 首指标, 参数列表): + 新参数 = [] + for params in 参数列表: + key = params[0] + if key in 尾指标 and key not in 首指标: + 新参数.append(params) + return 新参数 + + 新MACD = _新键(尾K指标, 首K指标, 配置._解析MACD参数列表()) + 新RSI = _新键(尾K指标, 首K指标, 配置._解析RSI周期列表()) + 新KDJ = _新键(尾K指标, 首K指标, 配置._解析KDJ参数列表()) + 新BOLL = _新键(尾K指标, 首K指标, 配置._解析BOLL参数列表()) + + if not (新MACD or 新RSI or 新KDJ or 新BOLL): + return + + 计算方式 = 配置.指标计算方式 + + for i, k线 in enumerate(全序列): + if k线.指标 is None: + k线.指标 = 指标容器() + + idx = k线.指标 + prev = 全序列[i - 1].指标 if i > 0 else None + + for key, 快, 慢, 信号 in 新MACD: + prev_val = prev[key] if prev is not None and key in prev else None + if prev_val is not None: + idx[key] = 平滑异同移动平均线.增量计算_K线(prev_val, k线, 计算方式) + else: + idx[key] = 平滑异同移动平均线.首次计算_K线(k线, 计算方式, 快, 慢, 信号) + + for key, 周期 in 新RSI: + prev_val = prev[key] if prev is not None and key in prev else None + if prev_val is not None: + idx[key] = 相对强弱指数.增量计算_K线(prev_val, k线, 计算方式) + else: + idx[key] = 相对强弱指数.首次计算_K线( + k线, + 计算方式, + 周期, + 配置.相对强弱指数_超买阈值, + 配置.相对强弱指数_超卖阈值, + 配置.相对强弱指数_移动平均线周期, + ) + + for key, rsv, k平滑, d平滑 in 新KDJ: + prev_val = prev[key] if prev is not None and key in prev else None + if prev_val is not None: + idx[key] = 随机指标.增量计算_K线(prev_val, k线, 计算方式) + else: + idx[key] = 随机指标.首次计算_K线( + k线, + 计算方式, + rsv, + k平滑, + d平滑, + 配置.随机指标_超买阈值, + 配置.随机指标_超卖阈值, + ) + + for key, 周期, 标准差倍数 in 新BOLL: + prev_val = prev[key] if prev is not None and key in prev else None + if prev_val is not None: + idx[key] = 布林带.增量计算(prev_val, k线, 计算方式) + else: + idx[key] = 布林带.首次计算(k线, 计算方式, 周期, 标准差倍数) + class 背驰分析: """静态方法容器,提供背驰/背离检测算法。 @@ -3581,6 +3970,14 @@ class 虚线: 结果.append(当前段柱子) 当前段柱子 = [k线序列[i].macd.MACD柱] 当前符号 = 新符号 + if 当前段柱子: + 结果.append(当前段柱子) + """a = [x for sub in 结果 for x in sub] + b = [sub.macd.MACD柱 for sub in k线序列] + if list(a) != list(b): + for i,(j,k) in enumerate(zip(a, b)): + if j is not k: + raise RuntimeError( f"序列不一致,{len(a)}, {len(b)}, {(i,j,k)}")""" return tuple(结果) @classmethod @@ -6107,18 +6504,21 @@ class 观察者: self.投喂原始数据(转化为时间戳(int(时间戳)), 开盘价, 最高价, 最低价, 收盘价, 成交量) @classmethod - def 读取数据文件(cls, 观察员: 观察者, 文件路径: str, 配置=缠论配置()) -> Self: + def 读取数据文件(cls, 文件路径: str, 配置=缠论配置(), *, 观察员: Optional[观察者] = None) -> Self: """加载数据文件 - :param 观察员: 观察者 :param 文件路径: 数据文件路径 格式如: btcusd-300-1631772074-1632222374.nb :param 配置: 缠论配置 + :param 观察员: 可选,已有观察者实例;不传则自动创建 :return: 观察者实例 """ name = Path(文件路径).name.split(".")[0] 符号, 周期, 起始时间戳, 结束时间戳 = name.split("-") - 观察员.符号 = 符号 - 观察员.周期 = int(周期) - 观察员.配置 = 配置 + if 观察员 is None: + 观察员 = cls(符号, int(周期), 配置) + else: + 观察员.符号 = 符号 + 观察员.周期 = int(周期) + 观察员.配置 = 配置 观察员.加载本地数据(文件路径) return 观察员 @@ -6375,13 +6775,13 @@ class 立体分析器: :return: 数据保存目录路径 """ # 生成存储根目录 - 脚本目录 = Path(__file__).parent if not root else root # 取当前脚本所在文件夹 + 脚本目录 = tempfile.gettempdir() if not root else root # 默认系统临时目录 起始时间 = int(self._单体分析器[self.__输入周期].普通K线序列[0].时间戳.timestamp()) 结束时间 = int(self._单体分析器[self.__输入周期].普通K线序列[-1].时间戳.timestamp()) 目录标识 = f"PyM_{self._单体分析器[self.__输入周期].标识}_{起始时间}_{结束时间}" # 最终保存路径 = 脚本目录 / 自动生成的文件夹 - 保存路径 = 脚本目录 / 目录标识 + 保存路径 = Path(os.path.join(脚本目录, 目录标识)) 保存路径.mkdir(exist_ok=True) for 周期 in self.周期组: @@ -6399,7 +6799,7 @@ def 测试_读取数据(观察员: 观察者, 配置: 缠论配置) -> Callable[ def 魔法(): 启动时间 = datetime.now() - 观察者.读取数据文件(观察员, 配置.加载文件路径, 配置) + 观察者.读取数据文件(配置.加载文件路径, 配置, 观察员=观察员) 消耗用时 = datetime.now() - 启动时间 logger.info(f"测试_读取数据 耗时 {消耗用时} 普K数量 {len(观察员.普通K线序列)}") return 观察员 @@ -6436,315 +6836,40 @@ def 测试_周期合成(配置: 缠论配置, 配置组: Dict[int, 缠论配置] return 魔法 -@lru_cache(128) -def K线相等(A, B, 浮点容差: float = 1e-9) -> tuple[bool, str]: - """原始K线相等校验:字段完备→浮点容错→普通全等""" - 比对字段 = ["标识", "序号", "周期", "时间戳", "高", "低", "开盘价", "收盘价", "成交量"] +def 测试_指标挂载(配置: 缠论配置): + 文件路径 = 配置.加载文件路径 + name = Path(文件路径).name.split(".")[0] + 符号, 周期, 起始时间戳, 结束时间戳 = name.split("-") + 周期 = int(周期) + 观察员 = 观察者(符号, 周期, 配置) - for 字段 in 比对字段: - a有 = hasattr(A, 字段) - b有 = hasattr(B, 字段) - if a有 and not b有: - return False, f"K线校验:字段[{字段}],A存在属性、B缺失属性" - if not a有 and b有: - return False, f"K线校验:字段[{字段}],B存在属性、A缺失属性" - if not (a有 and b有): - continue + def 魔法(): + 启动时间 = datetime.now() + with open(文件路径, "rb") as f: + buffer = f.read() + size = struct.calcsize(">6d") + for i in range(len(buffer) // size): + if i == 500: + 配置.MACD_参数列表 = [("macd", 配置.平滑异同移动平均线_快线周期, 配置.平滑异同移动平均线_慢线周期, 配置.平滑异同移动平均线_信号周期)] + 配置.MACD_参数列表.append(("macd_12_26_9", 12, 26, 9)) + k线 = K线.读取大端字节数组(buffer[i * size : i * size + size], 周期, 符号) + 观察员.增加原始K线(k线) + if i == 500: + assert 观察员.普通K线序列[0].指标.macd_12_26_9 is not None, "指标挂载失败" + print(观察员.普通K线序列[0].指标["macd_12_26_9"]) + break - valA = getattr(A, 字段) - valB = getattr(B, 字段) - # 双浮点容错对比 - if isinstance(valA, float) and isinstance(valB, float): - 差值 = abs(valA - valB) - if 差值 > 浮点容差: - return False, f"K线校验:字段[{字段}]浮点超限,容差={浮点容差:.2e},A={valA:.10f},B={valB:.10f},差值={差值:.10f}" + 消耗用时 = datetime.now() - 启动时间 + logger.info(f"测试_指标挂载 耗时 {消耗用时} 普K数量 {len(观察员.普通K线序列)}") + return 观察员 - elif 字段 == "时间戳": - if int(valA) != int(valB): - return False, f"K线校验:字段[{字段}]数值不等,A={repr(valA)},B={repr(valB)}" - else: - if valA != valB: - return False, f"K线校验:字段[{字段}]数值不等,A={repr(valA)},B={repr(valB)}" - - return True, "K线:全部字段结构、数值校验完全一致" - - -def 缠论K线相等(A, B, 浮点容差: float = 1e-9) -> tuple[bool, str]: - """缠论K线:基础字段+标的K线递归校验""" - 比对字段 = ["序号", "时间戳", "高", "低", "方向", "分型", "周期", "标识", "分型特征值", "原始起始序号", "原始结束序号", "标的K线", "买卖点信息"] - - for 字段 in 比对字段: - a有 = hasattr(A, 字段) - b有 = hasattr(B, 字段) - if a有 and not b有: - return False, f"缠论K线校验:字段[{字段}],A存在、B缺失属性" - if not a有 and b有: - return False, f"缠论K线校验:字段[{字段}],B存在、A缺失属性" - if not (a有 and b有): - continue - - valA = getattr(A, 字段) - valB = getattr(B, 字段) - - if isinstance(valA, float) and isinstance(valB, float): - 差值 = abs(valA - valB) - if 差值 > 浮点容差: - return False, f"缠论K线校验:[{字段}]浮点超限,容差={浮点容差:.2e},A={valA:.10f},B={valB:.10f},差值={差值:.10f}" - elif 字段 == "标的K线": - if valA is None and valB is None: - continue - if valA is None or valB is None: - return False, f"缠论K线校验:[标的K线]单边空值,A={valA is None},B={valB is None}" - eq_flag, msg = K线相等(valA, valB, 浮点容差) - if not eq_flag: - return False, f"缠论K线校验:标的K线子项异常 >> {msg}" - - elif 字段 == "时间戳": - if int(valA) != int(valB): - return False, f"缠论K线校验:字段[{字段}]数值不等,A={repr(valA)},B={repr(valB)}" - - elif 字段 == "方向": - if str(valA) != str(valB): - return False, f"缠论K线校验:字段[{字段}]数值不等,A={repr(valA)},B={repr(valB)}" - - elif 字段 == "分型": - if str(valA) != str(valB): - return False, f"缠论K线校验:字段[{字段}]数值不等,A={repr(valA)},B={repr(valB)}" - - elif 字段 == "买卖点信息": - if set(valA) != set(valB): - return False, f"缠论K线校验:字段[{字段}]数值不等,A={repr(valA)},B={repr(valB)}" - else: - if valA != valB: - return False, f"缠论K线校验:[{字段}]数值不等,A={repr(valA)},B={repr(valB)}" - - return True, "缠论K线:全部字段、嵌套原始K线校验一致" - - -def 分型相等(A, B, 浮点容差: float = 1e-9) -> tuple[bool, str]: - """分型:左/中/右缠论K线递归 + 自有字段""" - 比对字段 = ["左", "中", "右", "_结构", "_时间戳", "_分型特征值"] - - for 字段 in 比对字段: - a存在 = hasattr(A, 字段) - b存在 = hasattr(B, 字段) - if a存在 and not b存在: - return False, f"分型校验:[{字段}]A有属性、B缺失" - if not a存在 and b存在: - return False, f"分型校验:[{字段}]B有属性、A缺失" - if not (a存在 and b存在): - continue - - valA = getattr(A, 字段) - valB = getattr(B, 字段) - - if isinstance(valA, float) and isinstance(valB, float): - 差值 = abs(valA - valB) - if 差值 > 浮点容差: - return False, f"分型校验:[{字段}]浮点超限,容差={浮点容差:.2e},A={valA:.10f},B={valB:.10f},差值={差值:.10f}" - elif 字段 in ("左", "中", "右"): - if valA is None and valB is None: - continue - if valA is None or valB is None: - return False, f"分型校验:[{字段}]空值不一致,A={valA is None},B={valB is None}" - eq_ok, msg = 缠论K线相等(valA, valB, 浮点容差) - if not eq_ok: - return False, f"分型校验:[{字段}]缠论K线子项异常 >> {msg}" - - elif 字段 == "_结构": - if str(valA) != str(valB): - return False, f"分型K线校验:字段[{字段}]数值不等,A={repr(valA)},B={repr(valB)}" - - elif 字段 == "_时间戳": - if int(valA) != int(valB): - return False, f"分型K线校验:字段[{字段}]数值不等,A={repr(valA)},B={repr(valB)}" - - else: - if valA != valB: - return False, f"分型校验:[{字段}]数值不等,A={repr(valA)},B={repr(valB)}" - - return True, "分型:自有字段+三根缠论K线全部校验一致" - - -@lru_cache(4096) -def 缺口相等(A, B, 浮点容差: float = 1e-9) -> tuple[bool, str]: - """缺口:高、低浮点校验""" - 比对字段 = ["高", "低"] - for 字段 in 比对字段: - a有 = hasattr(A, 字段) - b有 = hasattr(B, 字段) - if a有 and not b有: - return False, f"缺口校验:[{字段}]A存在、B缺失属性" - if not a有 and b有: - return False, f"缺口校验:[{字段}]B存在、A缺失属性" - if not (a有 and b有): - continue - - valA = getattr(A, 字段) - valB = getattr(B, 字段) - if isinstance(valA, float) and isinstance(valB, float): - 差值 = abs(valA - valB) - if 差值 > 浮点容差: - return False, f"缺口校验:[{字段}]浮点超限,容差={浮点容差:.2e},A={valA:.10f},B={valB:.10f},差值={差值:.10f}" - else: - if valA != valB: - return False, f"缺口校验:[{字段}]数值不等,A={repr(valA)},B={repr(valB)}" - return True, "缺口:上下沿价格校验完全一致" - - -def 线段特征相等(A, B, 浮点容差: float = 1e-9) -> tuple[bool, str]: - """线段特征:基础序列虚线列表逐项校验""" - 比对字段 = ["序号", "标识", "线段方向", "基础序列"] - 标签 = f"线段特征校验[A标识={A.标识},B标识={B.标识}]" - for 字段 in 比对字段: - a有 = hasattr(A, 字段) - b有 = hasattr(B, 字段) - if a有 and not b有: - return False, f"{标签}: [{字段}]A存在、B缺失属性" - if not a有 and b有: - return False, f"{标签}: [{字段}]B存在、A缺失属性" - if not (a有 and b有): - continue - - valA = getattr(A, 字段) - valB = getattr(B, 字段) - if 字段 == "基础序列": - if len(valA) != len(valB): - return False, f"{标签}: [基础序列]列表长度不一致,A长度={len(valA)},B长度={len(valB)}" - for idx, (itemA, itemB) in enumerate(zip(valA, valB)): - eq, msg = 虚线相等(itemA, itemB, 浮点容差) - if not eq: - return False, f"{标签}:基础序列[{idx}]子虚线异常 >> {msg}" - - elif 字段 == "线段方向": - if str(valA) != str(valB): - return False, f"{标签}: [{字段}]数值不等,A={repr(valA)},B={repr(valB)}" - else: - if valA != valB: - return False, f"{标签}: [{字段}]数值不等,A={repr(valA)},B={repr(valB)}" - return True, f"{标签}:字段与内部虚线序列全部一致" - - -def 中枢相等(A, B, 浮点容差: float = 1e-9) -> tuple[bool, str]: - """中枢:基础序列虚线列表+第三买卖线单虚线""" - 比对字段 = ["序号", "标识", "级别", "基础序列", "第三买卖线", "本级_第三买卖线"] - 标签 = f"中枢校验[A标识={A.标识},B标识={B.标识}]" - for 字段 in 比对字段: - a有 = hasattr(A, 字段) - b有 = hasattr(B, 字段) - if a有 and not b有: - return False, f"{标签}: [{字段}]A存在、B缺失属性" - if not a有 and b有: - return False, f"{标签}: [{字段}]B存在、A缺失属性" - if not (a有 and b有): - continue - - valA = getattr(A, 字段) - valB = getattr(B, 字段) - - if 字段 == "基础序列": - if len(valA) != len(valB): - return False, f"{标签}: [基础序列]长度不一致 A={len(valA)},B={len(valB)}" - for idx, (itemA, itemB) in enumerate(zip(valA, valB)): - eq, msg = 虚线相等(itemA, itemB, 浮点容差) - if not eq: - return False, f"{标签}:基础序列[{idx}]虚线异常 >> {msg}" - elif 字段 in ("第三买卖线", "本级_第三买卖线"): - if valA is None and valB is None: - continue - if valA is None or valB is None: - return False, f"{标签}: [{字段}]空值不一致 A={valA is None},B={valB is None}" - eq, msg = 虚线相等(valA, valB, 浮点容差) - if not eq: - return False, f"{标签}: [{字段}]子虚线异常 >> {msg}" - else: - if valA != valB: - return False, f"{标签}: [{字段}]数值不等,A={repr(valA)},B={repr(valB)}" - - return True, f"{标签}:基础序列+第三买卖线全部校验一致" - - -def 虚线相等(A, B, 浮点容差: float = 1e-9) -> tuple[bool, str]: - """虚线(笔/线段):全量字段、分型/缺口/K线/列表嵌套精细化报错""" - 比对字段 = ["标识", "序号", "级别", "文", "武", "有效性", "基础序列", "特征序列", "实_中枢序列", "虚_中枢序列", "合_中枢序列", "确认K线", "模式", "_特征序列_显示", "前一缺口", "前一结束位置", "短路修正"] - 标签 = f"虚线校验[A标识={A.标识},B标识={B.标识}]" - for 字段 in 比对字段: - a有 = hasattr(A, 字段) - b有 = hasattr(B, 字段) - if a有 and not b有: - return False, f"{标签}: [{字段}]A存在属性、B缺失属性" - if not a有 and b有: - return False, f"{标签}: [{字段}]B存在属性、A缺失属性" - if not (a有 and b有): - continue - - valA = getattr(A, 字段) - valB = getattr(B, 字段) - - # 文/武:分型 - if 字段 in ("文", "武"): - if valA is None and valB is None: - continue - if valA is None or valB is None: - return False, f"{标签}: [{字段}]分型空值不一致 A={valA is None},B={valB is None}" - eq, msg = 分型相等(valA, valB, 浮点容差) - if not eq: - return False, f"{标签}: [{字段}]子分型异常 >> {msg}" - # 前一缺口 - elif 字段 == "前一缺口": - if valA is None and valB is None: - continue - if valA is None or valB is None: - return False, f"{标签}: [前一缺口]空值不一致 A={valA is None},B={valB is None}" - eq, msg = 缺口相等(valA, valB, 浮点容差) - if not eq: - return False, f"{标签}: [前一缺口]子缺口异常 >> {msg}" - # 前一缺口 - elif 字段 == "前一结束位置": - if valA is None and valB is None: - continue - if valA is None or valB is None: - return False, f"{标签}: [前一结束位置]空值不一致 A={valA is None},B={valB is None}" - eq, msg = 虚线相等(valA, valB, 浮点容差) - if not eq: - return False, f"{标签}: [前一结束位置]异常 >> {msg}" - # 确认K线 - elif 字段 == "确认K线": - if valA is None and valB is None: - continue - if valA is None or valB is None: - return False, f"{标签}: [确认K线]空值不一致 A={valA is None},B={valB is None}" - eq, msg = 缠论K线相等(valA, valB, 浮点容差) - if not eq: - return False, f"{标签}: [确认K线]子缠论K线异常 >> {msg}" - # 各类列表 - elif 字段 in ("基础序列", "实_中枢序列", "虚_中枢序列", "合_中枢序列", "特征序列"): - if len(valA) != len(valB): - return False, f"{标签}: [{字段}]列表长度不一致 A={len(valA)},B={len(valB)}" - for idx, (itemA, itemB) in enumerate(zip(valA, valB)): - if itemA is None and itemB is None: - continue - if itemA is None or itemB is None: - return False, f"{标签}: [{字段}][{idx}]单项空值不一致 A={itemA is None},B={itemB is None}" - if 字段 == "基础序列": - eq, msg = 虚线相等(itemA, itemB, 浮点容差) - elif "中枢" in 字段: - eq, msg = 中枢相等(itemA, itemB, 浮点容差) - else: - eq, msg = 线段特征相等(itemA, itemB, 浮点容差) - if not eq: - return False, f"{标签}: [{字段}][{idx}]子项异常 >> {msg}" - # 普通字段 - else: - if valA != valB: - return False, f"{标签}: [{字段}]数值不等 A={repr(valA)},B={repr(valB)}" - - return True, f"{标签}:全字段、所有嵌套子结构校验全部一致" + return 魔法 if __name__ == "__main__": 当前配置 = 缠论配置.不推送() 当前配置.加载文件路径 = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "tests", "btcusd-300-1761327300-1776327900.nb") with tempfile.TemporaryDirectory() as tmpdir: - 测试_读取数据(观察者("", 0, 当前配置), 当前配置)().测试_保存数据(tmpdir) - 测试_周期合成(当前配置)().测试_保存数据(tmpdir) + # 测试_读取数据(观察者("", 0, 当前配置), 当前配置)().测试_保存数据(tmpdir) + # 测试_周期合成(当前配置)().测试_保存数据(tmpdir) + 测试_指标挂载(当前配置)().测试_保存数据(tmpdir) diff --git a/chanlun-py/pyproject.toml b/chanlun-py/pyproject.toml index 354cfe4..243c313 100644 --- a/chanlun-py/pyproject.toml +++ b/chanlun-py/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "chanlun" -version = "2606.34" +version = "2606.42" description = "缠论技术分析库 — Rust 高性能实现" readme = { file = "README.md", content-type = "text/markdown" } license = { file = "LICENSE", content-type = "text/plain" } diff --git a/chanlun-py/src/business_py.rs b/chanlun-py/src/business_py.rs index 0224ddd..2121a78 100644 --- a/chanlun-py/src/business_py.rs +++ b/chanlun-py/src/business_py.rs @@ -693,6 +693,7 @@ impl 买卖点Py { #[pyclass(name = "观察者", module = "chanlun._chanlun", subclass)] pub struct 观察者Py { pub(crate) inner: Option>>, + 配置缓存: std::sync::Mutex>>, } impl 观察者Py { @@ -769,6 +770,7 @@ impl 观察者Py { inner: Some(chanlun::business::observer::观察者::new( 符号, 周期, config, )), + 配置缓存: std::sync::Mutex::new(None), }) } @@ -821,8 +823,27 @@ impl 观察者Py { } #[getter] - fn 配置(&self) -> PyResult<缠论配置Py> { - 缠论配置Py::from_rust_config(&self.obs().配置) + fn 配置(&self, py: Python<'_>) -> PyResult> { + let mut cache = self.配置缓存.lock().unwrap(); + if let Some(ref cached) = *cache { + Ok(cached.clone_ref(py)) + } else { + let cfg_py = 缠论配置Py::from_rust_config(&self.obs().配置)?; + let obj = Py::new(py, cfg_py)?; + *cache = Some(obj.clone_ref(py)); + Ok(obj) + } + } + + #[setter] + fn set_配置(&self, value: &Bound<'_, 缠论配置Py>) -> PyResult<()> { + let config = value.borrow().to_rust_config(value.py())?; + self.obs_mut().配置 = config; + self.配置缓存 + .lock() + .unwrap() + .replace(value.clone().unbind()); + Ok(()) } /// 清空所有分析序列,重置为初始状态(内部实现) @@ -847,6 +868,16 @@ impl 观察者Py { /// 核心入口 — 投喂一根原始K线,增量更新所有层级(公开分发器,支持子类重写) fn 增加原始K线(slf: &Bound<'_, Self>, 普K: &Bound<'_, K线Py>) -> PyResult<()> { + // 同步缓存的 Python 配置到 Rust 观察者(支持 obs.配置 直接修改) + { + let me = slf.borrow(); + if let Some(ref cached) = *me.配置缓存.lock().unwrap() { + let py = slf.py(); + if let Ok(config) = cached.bind(py).borrow().to_rust_config(py) { + me.obs_mut().配置 = config; + } + } + } slf.call_method1("_增加原始K线", (普K,))?; Ok(()) } @@ -925,16 +956,16 @@ impl 观察者Py { } #[classmethod] - #[pyo3(signature = (观察员, 文件路径, 配置 = None))] - /// :param 观察员: 观察者实例 + #[pyo3(signature = (文件路径, 配置 = None, 观察员 = None))] /// :param 文件路径: 数据文件路径 格式如: btcusd-300-1631772074-1632222374.nb /// :param 配置: 缠论配置 + /// :param 观察员: 可选,已有观察者实例;不传则自动创建 /// :return: 观察者实例 fn 读取数据文件( _cls: &Bound<'_, PyType>, - 观察员: &Bound<'_, Self>, 文件路径: &str, 配置: Option<&Bound<'_, 缠论配置Py>>, + 观察员: Option<&Bound<'_, Self>>, py: Python<'_>, ) -> PyResult> { let config = match 配置 { @@ -960,19 +991,37 @@ impl 观察者Py { .parse() .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("parse period: {}", e)))?; - // 设置观察员属性 - { - let slf_ref = 观察员.borrow_mut(); - let mut obs_mut = slf_ref.obs_mut(); - obs_mut.符号 = 符号; - obs_mut.周期 = 周期; - obs_mut.配置 = config; - } + let obs_ref = match 观察员 { + Some(obs) => { + // 更新已有观察员属性 + { + let slf_ref = obs.borrow_mut(); + let mut obs_mut = slf_ref.obs_mut(); + obs_mut.符号 = 符号; + obs_mut.周期 = 周期; + obs_mut.配置 = config; + } + obs.clone() + } + None => { + // 创建新观察者:调用 cls(符号, 周期),配置后续通过 obs_mut 设置 + let obj = _cls.call1((符号.as_str(), 周期))?; + let obs: &Bound<'_, Self> = obj.cast().map_err(|_| { + pyo3::exceptions::PyTypeError::new_err("failed to create 观察者") + })?; + { + let slf_ref = obs.borrow_mut(); + let mut obs_mut = slf_ref.obs_mut(); + obs_mut.配置 = config; + } + obs.clone() + } + }; - // 调用加载本地数据 - 观察员.call_method1("加载本地数据", (文件路径,))?; + // 调用加载本地数据(通过 Python dispatch,支持子类重写) + obs_ref.call_method1("加载本地数据", (文件路径,))?; - Ok(观察员.clone().unbind().into()) + Ok(obs_ref.unbind().into()) } // ---- 序列 getters ---- @@ -1382,9 +1431,10 @@ impl 立体分析器Py { } fn 获取观察者(&self, 周期: i64) -> Option<观察者Py> { - self.inner - .获取观察者(周期) - .map(|rc| 观察者Py { inner: Some(rc) }) + self.inner.获取观察者(周期).map(|rc| 观察者Py { + inner: Some(rc), + 配置缓存: std::sync::Mutex::new(None), + }) } /// 拆分各序列数据,单独存文件,文件名为对应变量名 diff --git a/chanlun-py/src/config_py.rs b/chanlun-py/src/config_py.rs index a6975f4..c7930b2 100644 --- a/chanlun-py/src/config_py.rs +++ b/chanlun-py/src/config_py.rs @@ -414,6 +414,8 @@ fn validate_field( (Value::Bool(_), Value::Bool(_)) => return Ok(()), (Value::Number(_), Value::Number(_)) => return Ok(()), (Value::String(_), Value::String(_)) => return Ok(()), + (Value::Array(_), Value::Array(_)) => return Ok(()), + (Value::Object(_), Value::Object(_)) => return Ok(()), _ => {} } @@ -430,7 +432,9 @@ fn validate_field( Value::Bool(_) => "布尔", Value::Number(_) => "数值", Value::String(_) => "字符串", - _ => "其他", + Value::Array(_) => "数组", + Value::Object(_) => "字典", + Value::Null => "null", }; Err(format!("类型不匹配(需要 {expected},收到 {type_name})")) } diff --git a/chanlun-py/src/indicators_py.rs b/chanlun-py/src/indicators_py.rs index 9ac7d52..9a755e8 100644 --- a/chanlun-py/src/indicators_py.rs +++ b/chanlun-py/src/indicators_py.rs @@ -985,26 +985,17 @@ impl 指标计算器Py { /// 增量计算所有开启的指标,将结果写入 当前K线.指标 #[staticmethod] fn 计算并挂载( - 当前K线: &Bound<'_, crate::kline_py::K线Py>, + _当前K线: &Bound<'_, crate::kline_py::K线Py>, 全序列: Vec>, 配置: &Bound<'_, crate::config_py::缠论配置Py>, py: Python<'_>, ) -> PyResult<()> { let config = 配置.borrow().to_rust_config(py)?; - // 全序列包含 当前K线 在末尾;Rust 计算并挂载 的 现有序列 不含当前K线 - let 现有序列: Vec> = if 全序列.len() > 1 { - 全序列[..全序列.len() - 1] - .iter() - .map(|k| k.bind(py).borrow().inner.clone()) - .collect() - } else { - Vec::new() - }; - chanlun::indicators::指标计算器::计算并挂载( - &当前K线.borrow().inner, - &现有序列, - &config, - ); + let 全序列_rust: Vec> = 全序列 + .iter() + .map(|k| k.bind(py).borrow().inner.clone()) + .collect(); + chanlun::indicators::指标计算器::计算并挂载(&全序列_rust, &config); Ok(()) } } diff --git a/chanlun-py/tests/btcusd-300-1777649100-1778398800.nb b/chanlun-py/tests/btcusd-300-1777649100-1778398800.nb new file mode 100644 index 0000000000000000000000000000000000000000..1a4d49484a5fe9c78b68367e27c902b5d888d4d4 GIT binary patch literal 120000 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 HKA-#_hANCu literal 0 HcmV?d00001 diff --git a/chanlun-py/tests/test_all.py b/chanlun-py/tests/test_all.py index 0430915..6ca7623 100644 --- a/chanlun-py/tests/test_all.py +++ b/chanlun-py/tests/test_all.py @@ -35,9 +35,9 @@ _PROJECT_ROOT = os.environ.get( os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), ) -NB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "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") +NB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "btcusd-300-1777649100-1778398800.nb") +_PY_REF_DIR = os.path.join(_PROJECT_ROOT, "Py_btcusd:300_1777649100_1778398800") +_RUST_REF_DIR = os.path.join(_PROJECT_ROOT, "chanlun", "Rust_btcusd:300_1777649100_1778398800") # ---- 辅助函数 ---- @@ -502,7 +502,8 @@ class Test观察者子类化(PyO3SubclassMixin, unittest.TestCase): @classmethod def 读取数据文件(cls, 文件路径, 配置=None): - obs = super().读取数据文件(文件路径, 配置) + obs = cls("", 0) # 创建子类实例,父类方法会覆盖符号/周期 + chanlun.观察者.读取数据文件(文件路径, 配置, 观察员=obs) obs._custom_classmethod_flag = True return obs @@ -519,7 +520,7 @@ class Test观察者子类化(PyO3SubclassMixin, unittest.TestCase): obs.重置基础序列() obs.加载本地数据(NB_PATH) self.assertTrue(obs._loaded) - self.assertEqual(obs._load_count, 1) + self.assertEqual(obs._load_count, 2) self.assertGreater(len(obs.普通K线序列), 0) c = datetime.now() print("加载本地数据 用时:", c - b) @@ -542,7 +543,7 @@ class Test观察者子类化(PyO3SubclassMixin, unittest.TestCase): self.assertEqual(obs._save_root, tmpdir) # 5. 重置次数 - self.assertEqual(obs._reload, 3) + self.assertEqual(obs._reload, 6) e = datetime.now() print("保存数据 用时:", e - d) @@ -1048,6 +1049,730 @@ class Test整体身份(_Base身份, unittest.TestCase): break +# ============================================================ +# 跨线程身份测试 — 验证全局缓存(非 thread_local)的跨线程一致性 +# ============================================================ + + +class Test跨线程身份(unittest.TestCase): + """跨线程 RC 身份一致性:全局缓存应在不同线程间共享同一 Python 对象.""" + + @classmethod + def setUpClass(cls): + cls.obs = create_observer(period=3600, n_bars=800) + + def _run_in_thread(self, fn): + """在子线程中执行 fn,通过 queue 收集结果和异常.""" + import threading + + result = [] + err = [] + + def wrapper(): + try: + result.append(fn()) + except Exception as e: + err.append(e) + + t = threading.Thread(target=wrapper) + t.start() + t.join() + if err: + raise err[0] + return result[0] + + # ---- 序列级别 ---- + + def test_缠K序列跨线程重复获取_is一致(self): + """缠论K线序列:从子线程重复获取,元素 is 一致.""" + obs = self.obs + + def check(): + s1 = obs.缠论K线序列 + s2 = obs.缠论K线序列 + return [(s1[i] is s2[i], len(s1), len(s2)) for i in range(min(len(s1), len(s2), 20))] + + results = self._run_in_thread(check) + for i, (ok, l1, l2) in enumerate(results): + self.assertTrue(ok, f"缠K序列[{i}] 跨线程 is 不一致") + + def test_分型序列跨线程重复获取_is一致(self): + """分型序列:从子线程重复获取,元素 is 一致.""" + obs = self.obs + + def check(): + s1 = obs.分型序列 + s2 = obs.分型序列 + return [(s1[i] is s2[i], len(s1)) for i in range(min(len(s1), len(s2), 20))] + + results = self._run_in_thread(check) + for i, (ok, _) in enumerate(results): + self.assertTrue(ok, f"分型序列[{i}] 跨线程 is 不一致") + + def test_笔序列跨线程重复获取_is一致(self): + """笔序列:从子线程重复获取,元素 is 一致.""" + obs = self.obs + + def check(): + s1 = obs.笔序列 + s2 = obs.笔序列 + return [(s1[i] is s2[i], len(s1)) for i in range(min(len(s1), len(s2), 20))] + + results = self._run_in_thread(check) + for i, (ok, _) in enumerate(results): + self.assertTrue(ok, f"笔序列[{i}] 跨线程 is 不一致") + + def test_线段序列跨线程重复获取_is一致(self): + """线段序列:从子线程重复获取,元素 is 一致.""" + obs = self.obs + + def check(): + s1 = obs.线段序列 + s2 = obs.线段序列 + return [(s1[i] is s2[i], len(s1)) for i in range(min(len(s1), len(s2), 20))] + + results = self._run_in_thread(check) + for i, (ok, _) in enumerate(results): + self.assertTrue(ok, f"线段序列[{i}] 跨线程 is 不一致") + + def test_中枢序列跨线程重复获取_is一致(self): + """中枢序列:从子线程重复获取,元素 is 一致.""" + obs = self.obs + + def check(): + s1 = obs.中枢序列 + s2 = obs.中枢序列 + return [(s1[i] is s2[i], len(s1)) for i in range(min(len(s1), len(s2), 20))] + + results = self._run_in_thread(check) + for i, (ok, _) in enumerate(results): + self.assertTrue(ok, f"中枢序列[{i}] 跨线程 is 不一致") + + def test_普K序列跨线程重复获取_is一致(self): + """普通K线序列:从子线程重复获取,元素 is 一致.""" + obs = self.obs + + def check(): + s1 = obs.普通K线序列 + s2 = obs.普通K线序列 + return [(s1[i] is s2[i], len(s1)) for i in range(min(len(s1), len(s2), 20))] + + results = self._run_in_thread(check) + for i, (ok, _) in enumerate(results): + self.assertTrue(ok, f"普K序列[{i}] 跨线程 is 不一致") + + # ---- 跨路径 ---- + + def test_跨线程分型中K线_is一致(self): + """子线程中 分型.中 is 缠论K线序列[同时间戳].""" + obs = self.obs + + def check(): + results = [] + seq = obs.缠论K线序列 + for fx in obs.分型序列[:10]: + 中 = fx.中 + found = False + for ck in seq: + if ck.时间戳 == 中.时间戳: + results.append((ck is 中, ck.时间戳)) + found = True + break + if not found: + results.append((None, 中.时间戳)) + return results + + results = self._run_in_thread(check) + for ok, ts in results: + self.assertIsNotNone(ok, f"分型.中 ts={ts} 在缠K序列中未找到") + self.assertTrue(ok, f"跨线程 分型.中 ts={ts} is 不一致") + + def test_跨线程笔端点钟K_is一致(self): + """子线程中 笔.文中 is 缠论K线序列[同时间戳].""" + obs = self.obs + + def check(): + results = [] + seq = obs.缠论K线序列 + for bi in obs.笔序列[:10]: + for nm, ep in [("文", bi.文), ("武", bi.武)]: + if ep is None: + continue + 中 = ep.中 + for ck in seq: + if ck.时间戳 == 中.时间戳: + results.append((ck is 中, nm, ck.时间戳)) + break + return results + + results = self._run_in_thread(check) + for ok, nm, ts in results: + self.assertTrue(ok, f"跨线程 笔.{nm}.中 ts={ts} is 不一致") + + def test_跨线程中枢元件_is一致(self): + """子线程中 中枢.元件 中的虚线对象 is 线段序列[同索引].""" + obs = self.obs + + def check(): + results = [] + for zs in obs.中枢序列[:5]: + for elem in zs.元件[:3]: + results.append(elem is elem) # 自我 is + results.append(elem is not None) + return results + + results = self._run_in_thread(check) + for ok in results: + self.assertTrue(ok) + + # ---- list.index 基于 is ---- + + def test_跨线程list_index基于身份(self): + """子线程中 list.index(elem) 正常工作(依赖 __eq__ 基于 is).""" + obs = self.obs + + def check(): + results = [] + for name, getter in [ + ("缠论K线序列", lambda o: o.缠论K线序列), + ("分型序列", lambda o: o.分型序列), + ("笔序列", lambda o: o.笔序列), + ("线段序列", lambda o: o.线段序列), + ("中枢序列", lambda o: o.中枢序列), + ]: + seq = getter(obs) + if len(seq) >= 2: + r0 = seq.index(seq[0]) == 0 + r1 = seq.index(seq[-1]) == len(seq) - 1 + results.append((name, r0, r1, len(seq))) + return results + + results = self._run_in_thread(check) + for name, r0, r1, length in results: + self.assertTrue(r0, f"跨线程 {name}(len={length}): index(seq[0]) != 0") + self.assertTrue(r1, f"跨线程 {name}(len={length}): index(seq[-1]) != {length - 1}") + + # ---- 主线程-子线程之间 ---- + + def test_主线程与子线程对象_is一致(self): + """主线程获取的对象与子线程获取的对象 is 相同.""" + import threading + + obs = self.obs + + # 主线程先获取 + seq_main = obs.缠论K线序列 + fx_main = obs.分型序列 + bi_main = obs.笔序列 + + err = [] + + def check(): + try: + seq_thread = obs.缠论K线序列 + for i in range(min(len(seq_main), 10)): + if seq_main[i] is not seq_thread[i]: + raise AssertionError(f"缠K[{i}] 主线程与子线程 is 不一致") + fx_thread = obs.分型序列 + for i in range(min(len(fx_main), 10)): + if fx_main[i] is not fx_thread[i]: + raise AssertionError(f"分型[{i}] 主线程与子线程 is 不一致") + bi_thread = obs.笔序列 + for i in range(min(len(bi_main), 10)): + if bi_main[i] is not bi_thread[i]: + raise AssertionError(f"笔[{i}] 主线程与子线程 is 不一致") + except Exception as e: + err.append(e) + + t = threading.Thread(target=check) + t.start() + t.join() + if err: + raise err[0] + + def test_跨线程getter稳定性(self): + """子线程中同一 getter 多次调用返回同一对象(如 分型.结构 等).""" + obs = self.obs + + def check(): + results = [] + if obs.分型序列: + fx = obs.分型序列[0] + for name, getter in [ + ("分型.结构", lambda f: f.结构), + ("分型.左", lambda f: f.左), + ("分型.中", lambda f: f.中), + ("分型.右", lambda f: f.右), + ]: + v1 = getter(fx) + v2 = getter(fx) + results.append((name, v1 is v2 if v1 is not None else True)) + return results + + results = self._run_in_thread(check) + for name, ok in results: + self.assertTrue(ok, f"跨线程 {name}: 两次调用 is 不一致") + + def test_多线程并发访问_is一致(self): + """两个子线程同时访问,各自拿到的对象与主线程 is 一致.""" + import threading + + obs = self.obs + errors = [] + + seq_main = obs.笔序列 + + def worker(thread_id): + try: + seq = obs.笔序列 + for i in range(min(len(seq), 10)): + if seq[i] is not seq_main[i]: + errors.append(f"线程{thread_id} 笔[{i}] is 不一致") + if seq[i] is not seq[i]: + errors.append(f"线程{thread_id} 笔[{i}] 自我 is 失败") + except Exception as e: + errors.append(f"线程{thread_id}: {e}") + + t1 = threading.Thread(target=worker, args=(1,)) + t2 = threading.Thread(target=worker, args=(2,)) + t1.start() + t2.start() + t1.join() + t2.join() + + self.assertEqual(len(errors), 0, "\n".join(errors)) + + +# ============================================================ +# 买卖意义 双端一致性测试 +# ============================================================ + + +class Test买卖意义双端对比(unittest.TestCase): + """运行时对比 Rust 绑定层 与 chan.py 的 虚线.买卖意义() 结果.""" + + @classmethod + def setUpClass(cls): + if not _has_nb(): + raise unittest.SkipTest("需要 .nb 数据文件") + cls.bars = read_nb_bars(NB_PATH) + + def _build_observers(self, n_bars=2000): + """构建双端观察者并喂入相同数据.""" + import chanlun + from chanlun import chan + + cfg_rs = chanlun.缠论配置() + obs_rs = chanlun.观察者("btcusd", 300, cfg_rs) + + cfg_py = chan.缠论配置() + obs_py = chan.观察者("btcusd", 300, cfg_py) + + for i, (ts, o, h, l, c, v) in enumerate(self.bars[:n_bars]): + k_rs = chanlun.K线.创建普K(f"k{i}", ts, o, h, l, c, v, i, 300) + k_py = chan.K线.创建普K(f"k{i}", ts, o, h, l, c, v, i, 300) + obs_rs.增加原始K线(k_rs) + obs_py.增加原始K线(k_py) + + return obs_rs, obs_py + + def test_笔买卖意义双端一致(self): + """笔序列的 买卖意义() 双端结果完全一致.""" + import chanlun + from chanlun import chan + + obs_rs, obs_py = self._build_observers() + n = min(len(obs_rs.笔序列), len(obs_py.笔序列)) + self.assertGreater(n, 0, "笔序列为空") + + mismatches = [] + for i in range(n): + r = chanlun.虚线.买卖意义(obs_rs.笔序列[i], obs_rs) + p = chan.虚线.买卖意义(obs_py.笔序列[i], obs_py) + if r != p: + mismatches.append((i, r, p, obs_rs.笔序列[i].获取数据文本(), obs_py.笔序列[i].获取数据文本())) + + self.assertEqual(len(mismatches), 0, f"笔买卖意义 不一致 ({len(mismatches)}/{n}):\n" + "\n".join(f" [{i}] R={r} P={p}\n R文本={rt}\n P文本={pt}" for i, r, p, rt, pt in mismatches[:3])) + + def test_线段买卖意义双端一致(self): + """线段序列的 买卖意义() 双端结果完全一致.""" + import chanlun + from chanlun import chan + + obs_rs, obs_py = self._build_observers() + n = min(len(obs_rs.线段序列), len(obs_py.线段序列)) + self.assertGreater(n, 0, "线段序列为空") + + mismatches = [] + for i in range(n): + r = chanlun.虚线.买卖意义(obs_rs.线段序列[i], obs_rs) + p = chan.虚线.买卖意义(obs_py.线段序列[i], obs_py) + if r != p: + mismatches.append((i, r, p, obs_rs.线段序列[i].获取数据文本(), obs_py.线段序列[i].获取数据文本())) + + self.assertEqual(len(mismatches), 0, f"线段买卖意义 不一致 ({len(mismatches)}/{n}):\n" + "\n".join(f" [{i}] R={r} P={p}\n R文本={rt}\n P文本={pt}" for i, r, p, rt, pt in mismatches[:3])) + + def test_笔序列长度一致(self): + """双端笔序列数量一致.""" + obs_rs, obs_py = self._build_observers() + self.assertGreater(len(obs_rs.笔序列), 0) + self.assertEqual(len(obs_rs.笔序列), len(obs_py.笔序列), f"笔数量: Rust={len(obs_rs.笔序列)} Py={len(obs_py.笔序列)}") + + def test_线段序列长度一致(self): + """双端线段序列数量一致.""" + obs_rs, obs_py = self._build_observers() + self.assertGreater(len(obs_rs.线段序列), 0) + self.assertEqual(len(obs_rs.线段序列), len(obs_py.线段序列), f"线段数量: Rust={len(obs_rs.线段序列)} Py={len(obs_py.线段序列)}") + + def test_中枢序列长度一致(self): + """双端中枢序列数量一致.""" + obs_rs, obs_py = self._build_observers() + self.assertGreater(len(obs_rs.中枢序列), 0) + self.assertEqual(len(obs_rs.中枢序列), len(obs_py.中枢序列), f"中枢数量: Rust={len(obs_rs.中枢序列)} Py={len(obs_py.中枢序列)}") + + # ---- MACD趋向背驰 ---- + + def test_笔MACD趋向背驰双端一致(self): + """笔的 K线序列 MACD趋向背驰 双端结果完全一致.""" + import chanlun + from chanlun import chan + + obs_rs, obs_py = self._build_observers() + mismatches = [] + for bi_rs, bi_py in zip(obs_rs.笔序列, obs_py.笔序列): + k_seq_rs = chanlun.K线.截取( + obs_rs.普通K线序列, + bi_rs.文.中.标的K线, + bi_rs.武.中.标的K线, + ) + k_seq_py = chan.K线.截取( + obs_py.普通K线序列, + bi_py.文.中.标的K线, + bi_py.武.中.标的K线, + ) + r = chanlun.虚线.计算K线序列MACD趋向背驰(k_seq_rs, bi_rs.方向) + p = chan.虚线.计算K线序列MACD趋向背驰(k_seq_py, bi_py.方向) + if r != list(p): + mismatches.append((bi_rs.文.时间戳, bi_rs.武.时间戳, list(r), list(p))) + + self.assertEqual(len(mismatches), 0, f"笔MACD趋向背驰 不一致 ({len(mismatches)}):\n" + "\n".join(f" [{ts_w},{ts_wu}] R={r} P={p}" for ts_w, ts_wu, r, p in mismatches[:5])) + + def test_线段MACD趋向背驰双端一致(self): + """线段的 K线序列 MACD趋向背驰 双端结果完全一致.""" + import chanlun + from chanlun import chan + + obs_rs, obs_py = self._build_observers() + mismatches = [] + for seg_rs, seg_py in zip(obs_rs.线段序列, obs_py.线段序列): + k_seq_rs = chanlun.K线.截取( + obs_rs.普通K线序列, + seg_rs.文.中.标的K线, + seg_rs.武.中.标的K线, + ) + k_seq_py = chan.K线.截取( + obs_py.普通K线序列, + seg_py.文.中.标的K线, + seg_py.武.中.标的K线, + ) + r = chanlun.虚线.计算K线序列MACD趋向背驰(k_seq_rs, seg_rs.方向) + p = chan.虚线.计算K线序列MACD趋向背驰(k_seq_py, seg_py.方向) + if r != list(p): + mismatches.append((seg_rs.文.时间戳, seg_rs.武.时间戳, list(r), list(p))) + + self.assertEqual(len(mismatches), 0, f"线段MACD趋向背驰 不一致 ({len(mismatches)}):\n" + "\n".join(f" [{ts_w},{ts_wu}] R={r} P={p}" for ts_w, ts_wu, r, p in mismatches[:5])) + + # ---- 统计MACD行为 ---- + + def test_笔统计MACD行为双端一致(self): + """笔的 统计MACD行为() 双端结果完全一致.""" + import chanlun + from chanlun import chan + + obs_rs, obs_py = self._build_observers() + mismatches = [] + for bi_rs, bi_py in zip(obs_rs.笔序列, obs_py.笔序列): + k_seq_rs = chanlun.K线.截取( + obs_rs.普通K线序列, + bi_rs.文.中.标的K线, + bi_rs.武.中.标的K线, + ) + k_seq_py = chan.K线.截取( + obs_py.普通K线序列, + bi_py.文.中.标的K线, + bi_py.武.中.标的K线, + ) + r = chanlun.虚线.统计MACD行为(k_seq_rs) + p = chan.虚线.统计MACD行为(k_seq_py) + if r != p: + mismatches.append((bi_rs.文.时间戳, bi_rs.武.时间戳, r, p)) + + self.assertEqual(len(mismatches), 0, f"笔统计MACD行为 不一致 ({len(mismatches)}):\n" + "\n".join(f" [{ts_w},{ts_wu}] R={r} P={p}" for ts_w, ts_wu, r, p in mismatches[:5])) + + def test_线段统计MACD行为双端一致(self): + """线段的 统计MACD行为() 双端结果完全一致.""" + import chanlun + from chanlun import chan + + obs_rs, obs_py = self._build_observers() + mismatches = [] + for seg_rs, seg_py in zip(obs_rs.线段序列, obs_py.线段序列): + k_seq_rs = chanlun.K线.截取( + obs_rs.普通K线序列, + seg_rs.文.中.标的K线, + seg_rs.武.中.标的K线, + ) + k_seq_py = chan.K线.截取( + obs_py.普通K线序列, + seg_py.文.中.标的K线, + seg_py.武.中.标的K线, + ) + r = chanlun.虚线.统计MACD行为(k_seq_rs) + p = chan.虚线.统计MACD行为(k_seq_py) + if r != p: + mismatches.append((seg_rs.文.时间戳, seg_rs.武.时间戳, r, p)) + + self.assertEqual(len(mismatches), 0, f"线段统计MACD行为 不一致 ({len(mismatches)}):\n" + "\n".join(f" [{ts_w},{ts_wu}] R={r} P={p}" for ts_w, ts_wu, r, p in mismatches[:5])) + + # ---- 获取所有停顿位置 ---- + + def test_笔获取所有停顿位置双端一致(self): + """笔的 获取所有停顿位置() 双端结果一致(通过虚线相等 逐项比对).""" + import chanlun + from chanlun import chan + + obs_rs, obs_py = self._build_observers() + mismatches = [] + for bi_rs, bi_py in zip(obs_rs.笔序列, obs_py.笔序列): + r = chanlun.笔.获取所有停顿位置(bi_rs, obs_rs) + p = chan.笔.获取所有停顿位置(bi_py, obs_py) + if len(r) != len(p): + mismatches.append((f"len R={len(r)} P={len(p)}", [x.获取数据文本() for x in r], [x.获取数据文本() for x in p])) + else: + for a, b in zip(r, p): + eq, msg = chanlun.虚线相等(a, b) + if not eq: + mismatches.append((msg, [x.获取数据文本() for x in r], [x.获取数据文本() for x in p])) + break + + self.assertEqual(len(mismatches), 0, f"笔获取所有停顿位置 不一致 ({len(mismatches)}):\n" + "\n".join(f" {tag}\n R={rl}\n P={pl}" for tag, rl, pl in mismatches[:3])) + + def test_线段获取所有停顿位置双端一致(self): + """线段的 获取所有停顿位置() 双端结果一致(通过虚线相等 逐项比对).""" + import chanlun + from chanlun import chan + + obs_rs, obs_py = self._build_observers() + mismatches = [] + for seg_rs, seg_py in zip(obs_rs.线段序列, obs_py.线段序列): + r = chanlun.线段.获取所有停顿位置(seg_rs, obs_rs) + p = chan.线段.获取所有停顿位置(seg_py, obs_py) + if len(r) != len(p): + mismatches.append((f"len R={len(r)} P={len(p)}", [x.获取数据文本() for x in r], [x.获取数据文本() for x in p])) + else: + for a, b in zip(r, p): + eq, msg = chanlun.虚线相等(a, b) + if not eq: + mismatches.append((msg, [x.获取数据文本() for x in r], [x.获取数据文本() for x in p])) + break + + self.assertEqual(len(mismatches), 0, f"线段获取所有停顿位置 不一致 ({len(mismatches)}):\n" + "\n".join(f" {tag}\n R={rl}\n P={pl}" for tag, rl, pl in mismatches[:3])) + + # ---- 判断线段内部是否背驰 ---- + + def test_判断线段内部是否背驰双端一致(self): + """线段的 判断线段内部是否背驰() 双端结果完全一致.""" + import chanlun + from chanlun import chan + + obs_rs, obs_py = self._build_observers() + n = min(len(obs_rs.线段序列), len(obs_py.线段序列)) + self.assertGreater(n, 0, "线段序列为空") + + mismatches = [] + for i in range(n): + r = chanlun.线段.判断线段内部是否背驰(obs_rs.线段序列[i], obs_rs) + p = chan.线段.判断线段内部是否背驰(obs_py.线段序列[i], obs_py) + if r != p: + mismatches.append((i, r, p, obs_rs.线段序列[i].获取数据文本(), obs_py.线段序列[i].获取数据文本())) + + self.assertEqual(len(mismatches), 0, f"判断线段内部是否背驰 不一致 ({len(mismatches)}/{n}):\n" + "\n".join(f" [{i}] R={r} P={p}\n R文本={rt}\n P文本={pt}" for i, r, p, rt, pt in mismatches[:3])) + + # ---- 是否背驰过 ---- + + # ---- 获取内部中枢序列 ---- + + def test_线段获取内部中枢序列双端一致(self): + """线段的 获取内部中枢序列() 双端结果完全一致(通过 中枢相等 逐项比对).""" + import chanlun + from chanlun import chan + + obs_rs, obs_py = self._build_observers() + n = min(len(obs_rs.线段序列), len(obs_py.线段序列)) + self.assertGreater(n, 0, "线段序列为空") + + mismatches = [] + for i in range(n): + seg_rs = obs_rs.线段序列[i] + seg_py = obs_py.线段序列[i] + r = chanlun.线段.获取内部中枢序列(seg_rs, obs_rs.配置) + p = chan.线段.获取内部中枢序列(seg_py, obs_py.配置) + if len(r) != len(p): + mismatches.append((i, f"tuple len R={len(r)} P={len(p)}")) + else: + for k, (hr, hp) in enumerate(zip(r, p)): + if len(hr) != len(hp): + mismatches.append((i, f"{['实', '虚', '合'][k]} len R={len(hr)} P={len(hp)}")) + break + for j, (ha, hb) in enumerate(zip(hr, hp)): + eq, msg = chanlun.中枢相等(ha, hb) + if not eq: + mismatches.append((i, f"{['实', '虚', '合'][k]}[{j}]: {msg}")) + break + + self.assertEqual(len(mismatches), 0, f"线段获取内部中枢序列 不一致 ({len(mismatches)}/{n}):\n" + "\n".join(f" Seg[{i}]: {detail}" for i, detail in mismatches[:5])) + + # ---- 是否背驰过 ---- + + def test_线段是否背驰过双端一致(self): + """线段的 是否背驰过() 双端结果一致(通过 缠论K线相等 逐项比对).""" + import chanlun + from chanlun import chan + + obs_rs, obs_py = self._build_observers() + n = min(len(obs_rs.线段序列), len(obs_py.线段序列)) + self.assertGreater(n, 0, "线段序列为空") + + mismatches = [] + for i in range(n): + r = chanlun.线段.是否背驰过(obs_rs.线段序列[i], obs_rs) + p = chan.线段.是否背驰过(obs_py.线段序列[i], obs_py) + if len(r) != len(p): + mismatches.append((i, f"len R={len(r)} P={len(p)}", obs_rs.线段序列[i].获取数据文本())) + else: + for a, b in zip(r, p): + eq, msg = chanlun.缠论K线相等(a, b) + if not eq: + mismatches.append((i, msg, obs_rs.线段序列[i].获取数据文本())) + break + + self.assertEqual(len(mismatches), 0, f"线段是否背驰过 不一致 ({len(mismatches)}/{n}):\n" + "\n".join(f" [{i}] {detail}\n 段={txt[:120]}" for i, detail, txt in mismatches[:3])) + + +# ============================================================ +# 指标挂载测试 +# ============================================================ + + +class Test指标挂载(unittest.TestCase): + """指标计算与动态挂载回填测试.""" + + @staticmethod + def _make_k(i: int, ts_base: int = 1771675200, period: int = 300): + """创建一根模拟K线.""" + return chanlun.K线.创建普K( + "btcusd", + ts_base + i * period, + 50000.0 + i, + 51000.0 + i, + 49000.0 + i, + 50500.0 + i, + 100.0 + i, + i, + period, + ) + + def test_基本指标计算(self): + """每根K线都应有默认指标值.""" + cfg = chanlun.缠论配置() + obs = chanlun.观察者("btcusd", 300, cfg) + for i in range(50): + obs.增加原始K线(self._make_k(i)) + + for k in obs.普通K线序列: + self.assertIn("macd", k.指标) + self.assertIn("rsi", k.指标) + self.assertIn("kdj", k.指标) + + def test_动态MACD参数回填(self): + """中途修改 obs.配置 添加 MACD 变体后,历史K线应被回填.""" + cfg = chanlun.缠论配置() + obs = chanlun.观察者("btcusd", 300, cfg) + + for i in range(100): + if i == 50: + obs.配置.MACD_参数列表 = [ + ("macd", 12, 26, 9), + ("macd_10_20_7", 10, 20, 7), + ] + obs.增加原始K线(self._make_k(i)) + + for k in obs.普通K线序列: + self.assertIn("macd_10_20_7", k.指标) + + def test_多指标同时回填(self): + """同时修改 MACD + RSI + KDJ 参数,验证全部回填.""" + cfg = chanlun.缠论配置() + obs = chanlun.观察者("btcusd", 300, cfg) + + for i in range(80): + if i == 40: + obs.配置.MACD_参数列表 = [("macd", 12, 26, 9), ("macd_fast", 5, 13, 5)] + obs.配置.RSI_周期列表 = [("rsi", 14), ("rsi_7", 7)] + obs.配置.KDJ_参数列表 = [("kdj", 9, 3, 3), ("kdj_5", 5, 2, 2)] + obs.增加原始K线(self._make_k(i)) + + for k in obs.普通K线序列: + self.assertIn("macd_fast", k.指标) + self.assertIn("rsi_7", k.指标) + self.assertIn("kdj_5", k.指标) + + def test_回填后增量计算一致(self): + """回填后的指标值应与从头计算一致.""" + cfg_full = chanlun.缠论配置() + cfg_full.MACD_参数列表 = [("macd", 12, 26, 9), ("macd_extra", 8, 16, 6)] + obs_full = chanlun.观察者("btcusd", 300, cfg_full) + + cfg_late = chanlun.缠论配置() + obs_late = chanlun.观察者("btcusd", 300, cfg_late) + + for i in range(100): + if i == 50: + obs_late.配置.MACD_参数列表 = [("macd", 12, 26, 9), ("macd_extra", 8, 16, 6)] + obs_full.增加原始K线(self._make_k(i)) + obs_late.增加原始K线(self._make_k(i)) + + seq_full = obs_full.普通K线序列 + seq_late = obs_late.普通K线序列 + self.assertEqual(len(seq_full), len(seq_late)) + + for i in range(len(seq_full)): + macd_full = seq_full[i].指标["macd_extra"] + macd_late = seq_late[i].指标["macd_extra"] + self.assertEqual(macd_full.DIF, macd_late.DIF) + self.assertEqual(macd_full.DEA, macd_late.DEA) + + def test_同时间戳更新后指标重算(self): + """同时间戳K线更新后,指标应基于新值重新计算.""" + cfg = chanlun.缠论配置() + obs = chanlun.观察者("btcusd", 300, cfg) + + ts = 1771675200 + k1 = chanlun.K线.创建普K("btcusd", ts, 50000, 51000, 49000, 50500, 100, 0, 300) + obs.增加原始K线(k1) + + k2 = chanlun.K线.创建普K("btcusd", ts + 300, 50500, 52000, 50000, 51500, 200, 1, 300) + obs.增加原始K线(k2) + macd_before = obs.普通K线序列[-1].指标["macd"].DIF + + # 同时间戳,不同收盘价 + k2_upd = chanlun.K线.创建普K("btcusd", ts + 300, 50500, 53000, 49000, 52500, 300, 1, 300) + obs.增加原始K线(k2_upd) + macd_after = obs.普通K线序列[-1].指标["macd"].DIF + + self.assertNotEqual(macd_before, macd_after) + + # ============================================================ # 集成对比测试 # ============================================================ diff --git a/chanlun/README.md b/chanlun/README.md index b3e09c3..a081c41 100644 --- a/chanlun/README.md +++ b/chanlun/README.md @@ -1,11 +1,14 @@ # chanlun — 缠论技术分析 Rust 核心库 [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -[![Rust](https://img.shields.io/badge/rust-1.70%2B-orange.svg)](https://www.rust-lang.org) +[![Rust](https://img.shields.io/badge/rust-1.85%2B-orange.svg)](https://www.rust-lang.org) +[![edition](https://img.shields.io/badge/edition-2024-9cf.svg)](https://doc.rust-lang.org/edition-guide/rust-2024/) -基于[缠中说禅](https://zh.wikipedia.org/wiki/%E7%BC%A0%E4%B8%AD%E8%AF%B4%E7%A6%85)理论的量化技术分析核心库,用 Rust 实现。支持流式数据处理,可作为独立 Rust crate 使用,也可通过 PyO3 绑定在 Python 中调用(见 [`chanlun-py`](../chanlun-py/))。 +基于[缠中说禅](https://zh.wikipedia.org/wiki/%E7%BC%A0%E4%B8%AD%E8%AF%B4%E7%A6%85)理论的量化技术分析核心库,将 Python 参考实现 (`chan.py`, ~4200行) 完整移植为 Rust,同时保持了 API 的完全兼容。 -约 9,400 行 Rust 代码,覆盖缠论的完整算法体系:K线包含处理、分型识别、笔划分、线段划分、中枢识别、背驰检测、买卖点识别、多周期联立分析。 +**约 13,700 行 Rust 代码**,39 个源文件,覆盖缠论的完整算法体系:K线包含处理、分型识别、笔划分、线段划分(含特征序列与缺口处理)、中枢识别(含延伸/扩展/多级中枢)、背驰检测(MACD/斜率/测度三种方式)、买卖点识别(18种类型)、多周期联立分析。 + +核心 crate 位于 `chanlun/` 目录。Python 绑定通过独立的 [`chanlun-py`](../chanlun-py/) crate 实现,使用 PyO3 将 Rust 核心封装为 Python 模块,API 与 `chan.py` 完全一致。所有类型名、方法名、字段名均使用中文标识符,与 Python 版本保持 1:1 对应。 --- @@ -15,12 +18,14 @@ - [项目结构](#项目结构) - [快速开始](#快速开始) - [数据管线](#数据管线) -- [核心类型](#核心类型) +- [核心类型详解](#核心类型详解) - [配置体系](#配置体系) - [算法模块](#算法模块) - [技术指标](#技术指标) +- [线程安全与并发](#线程安全与并发) - [Python 绑定](#python-绑定) - [数据序列化](#数据序列化) +- [测试](#测试) - [许可](#许可) --- @@ -35,8 +40,8 @@ 核心理念: - **走势终完美**:任何级别的任何走势类型终要完成 -- **自同构性**:不同级别的走势呈现相同的形态结构 -- **完全分类**:将走势分为上涨、下跌、盘整三种 +- **自同构性**:不同级别的走势呈现相同的形态结构,可通过递归分析进行多级别联立 +- **完全分类**:将走势分为上涨、下跌、盘整三种,每个节点都可通过买卖点找到操作依据 --- @@ -44,47 +49,59 @@ ``` chanlun/ -├── Cargo.toml # 依赖: serde, byteorder, chrono +├── Cargo.toml # 依赖: serde, byteorder, chrono, cached, tracing └── src/ - ├── lib.rs # 模块注册 - ├── config.rs # 缠论配置 (60+ 参数, serde) - ├── types/ # 基础类型 - │ ├── mod.rs - │ ├── bsp_type.rs # 买卖点类型 (18 种) - │ ├── direction.rs # 相对方向 (9 种) - │ ├── fractal.rs # 分型结构 (5 种) - │ └── gap.rs # 缺口 - ├── kline/ # K线层 - │ ├── mod.rs - │ ├── bar.rs # 原始K线 (OHLCV + 指标) - │ └── chan_kline.rs # 缠论K线 (包含处理 + 合并) - ├── indicators/ # 技术指标 - │ ├── mod.rs - │ ├── macd.rs # MACD (平滑异同移动平均线) - │ ├── rsi.rs # RSI (相对强弱指数) - │ └── kdj.rs # KDJ (随机指标) - ├── algorithm/ # 核心算法 - │ ├── mod.rs - │ ├── bi.rs # 笔划分算法 - │ ├── segment.rs # 线段划分算法 - │ ├── hub.rs # 中枢识别算法 - │ └── divergence.rs # 背驰检测算法 - ├── structure/ # 结构体 - │ ├── mod.rs - │ ├── dash_line.rs # 虚线 (笔/线段的通用抽象) - │ ├── fractal_obj.rs # 分型对象 - │ ├── segment_feat.rs # 线段特征 - │ └── feat_fractal.rs # 特征分型 - ├── business/ # 业务层 - │ ├── mod.rs - │ ├── bsp.rs # 基础买卖点 + 买卖点 - │ ├── observer.rs # 观察者 (单周期分析器) - │ ├── synthesizer.rs # K线合成器 (周期合成) - │ └── multi_frame.rs # 立体分析器 (多周期联立) - └── utils/ # 工具 - ├── mod.rs - ├── datetime.rs # 时间戳转换 - └── format.rs # 格式化输出 + ├── lib.rs # 模块注册 (#![allow(non_snake_case)]) + ├── main.rs # 入口(CLI 工具) + ├── config.rs # 缠论配置 (62 字段, serde, 622 行) + │ + ├── types/ # 基础类型 (5 文件, 514 行) + │ ├── mod.rs # 重导出: 买卖点类型, 相对方向, 分型结构, 缺口, SyncF64 + │ ├── direction.rs # 相对方向 (9 变体): 向上/向下/向上缺口/向下缺口/衔接向上/衔接向下/顺/逆/同 + │ ├── bsp_type.rs # 买卖点类型 (18 变体): 一二三买/卖 + T1/T1P/T2/T2S/T3A/T3B + │ ├── fractal.rs # 分型结构 (5 变体) + 有高低 trait + │ ├── gap.rs # 缺口: 高/低价格区间 + 居中截取 + │ └── sync_f64.rs # SyncF64: 基于 AtomicU64 的线程安全 f64 容器 + │ + ├── kline/ # K线层 (2 文件, 1113 行) + │ ├── mod.rs # + │ ├── bar.rs # 原始K线 (388 行): OHLCV + RwLock<指标容器>, Clone/Display, 大端字节序序列化 + │ └── chan_kline.rs # 缠论K线 (725 行): 经包含处理后的K线, 分型标记, AtomicI64/SyncF64 内部可变性 + │ + ├── indicators/ # 技术指标 (6 文件, 1589 行) + │ ├── mod.rs # K线取值: 开/高/低/收/高低均值/高低收均值/开高低收均值 + │ ├── macd.rs # 平滑异同移动平均线 (215 行): EMA 快慢线 + DIF/DEA/MACD柱 + │ ├── rsi.rs # 相对强弱指数 (240 行): Wilder SMA 平滑 + 超买超卖线 + │ ├── kdj.rs # 随机指标 (289 行): RSV → K → D → J + 超买超卖阈值 + │ ├── boll.rs # 布林带 (169 行): 中轨/上轨/下轨 + 带宽 + │ ├── calculator.rs # 指标计算器 (476 行): 增量计算 + 回填新指标 + 均线 + │ └── container.rs # 指标容器 (202 行): 注册表模式, 动态指标存取 + │ + ├── algorithm/ # 核心算法 (4 文件, 4987 行) + │ ├── mod.rs # + │ ├── bi.rs # 笔划分算法 (1009 行): 递归分析, 笔弱化, 次级成笔, 实际高/低点 + │ ├── segment.rs # 线段划分算法 (2078 行): 特征序列, 缺口处理, 四种修正机制, 扩展线段 + │ ├── hub.rs # 中枢识别算法 (1041 行): 重叠检测, 延伸/扩展, 第三买卖点, 多级中枢 + │ └── divergence.rs # 背驰检测 (227 行): MACD背驰/斜率背驰/测度背驰 + 四种组合模式 + │ + ├── structure/ # 结构体 (4 文件, 2089 行) + │ ├── mod.rs # + │ ├── dash_line.rs # 虚线 (1712 行): 笔/线段的通用抽象, 买卖意义, MACD行为统计, 分段, 背驰检测 + │ ├── fractal_obj.rs # 分型 (344 行): 左中右构型, 分型模式开关, 强度判定, MACD分型匹配 + │ ├── segment_feat.rs # 线段特征 (668 行): 特征序列元素, 文/武取极值, 静态分析, 分型序列 + │ └── feat_fractal.rs # 特征分型 (62 行): 三线段特征元素构成的分型 + │ + ├── business/ # 业务层 (4 文件, 1648 行) + │ ├── mod.rs # + │ ├── observer.rs # 观察者 (1138 行): 单周期分析器, 流式管线, 3级线段/扩展/混合扩展 + │ ├── bsp.rs # 基础买卖点 + 买卖点工厂 (595 行): 18种类型, 偏移/失效/终结 + │ ├── synthesizer.rs # K线合成器 (161 行): 小周期→大周期合成, 时间戳对齐 + │ └── multi_frame.rs # 立体分析器 (192 行): 多周期联立, 合成器+观察者协调 + │ + └── utils/ # 工具 (2 文件, 145 行) + ├── mod.rs # + ├── datetime.rs # 时间戳转换: 字符串↔Unix时间戳 + └── format.rs # 格式化: f64→最小字符串 (format_f64_g) ``` --- @@ -98,390 +115,855 @@ chanlun/ chanlun = { path = "path/to/chanlun" } ``` -### 基本用法 +### 基本用法 — 单周期分析 ```rust use chanlun::config::缠论配置; use chanlun::kline::bar::K线; use chanlun::business::observer::观察者; -// 1. 创建配置 +// 1. 创建配置(所有字段带默认值) let 配置 = 缠论配置::default(); -// 2. 创建观察者(单周期分析器) -let mut 观察员 = 观察者::new("BTCUSD".into(), 3600, 配置); +// 2. 创建观察者(返回 Arc>) +let 观察员 = 观察者::new("BTCUSD".into(), 3600, 配置); -// 3. 逐根投喂K线(流式增量分析) +// 3. 逐根投喂K线(流式增量分析,每根K线触发全管线运算) for k线 in k线数据流 { - 观察员.增加原始K线(k线); + 观察员.write().unwrap().增加原始K线(k线); } // 4. 读取分析结果 -for 笔 in 观察员.笔序列() { - println!("笔: {:?}", 笔.方向()); -} -for 中枢 in 观察员.中枢序列() { - println!("中枢: 高={:?}, 低={:?}", 中枢.高(), 中枢.低()); +let obs = 观察员.read().unwrap(); +println!("笔数量: {}", obs.笔序列.len()); +println!("线段数量: {}", obs.线段序列().len()); +println!("中枢数量: {}", obs.中枢序列().len()); + +// 遍历笔 +for 笔 in &obs.笔序列 { + println!("笔方向: {}, 高: {}, 低: {}", 笔.方向(), 笔.高(), 笔.低()); } ``` +### 便捷投喂 — 直接从 OHLCV + +```rust +let 观察员 = 观察者::new("ETHUSD".into(), 300, Default::default()); +let mut obs = 观察员.write().unwrap(); +obs.投喂原始数据(1736640000, 3500.0, 3550.0, 3480.0, 3520.0, 1200.0); +// ... 继续投喂 ... +``` + +### 加载 .nb 数据文件 + +```rust +let 观察员 = 观察者::new("btcusd".into(), 300, Default::default()); +观察员.write().unwrap().读取数据文件("btcusd-300-1000000-1100000.nb", Default::default())?; +``` + ### 多周期分析 ```rust use chanlun::business::multi_frame::立体分析器; // 周期组: [最小输入周期, 更多显示周期...] +// 只投喂最小周期K线,大周期自动合成 let mut 分析器 = 立体分析器::new( "BTCUSD".into(), vec![60, 300, 1800, 7200], // 1分钟 → 5分钟 → 30分钟 → 2小时 - 缠论配置::default(), - std::collections::HashMap::new(), + None, // 默认配置 + None, // 无周期特定配置 ); -// 只投喂最小周期K线,大周期自动合成 +// 投喂最小周期K线 for k线 in 小周期K线流 { 分析器.投喂K线(k线); } // 获取各周期观察者 -let 观察员_1min = 分析器.获取观察员(60); -let 观察员_5min = 分析器.获取观察员(300); +if let Some(日线观察员) = 分析器.获取观察者(86400) { + let obs = 日线观察员.read().unwrap(); + println!("日线笔数: {}", obs.笔序列.len()); +} ``` --- ## 数据管线 -缠论分析按严格的层级顺序进行,每收到一根新K线,增量更新所有层级: +缠论分析按严格的层级顺序进行,每收到一根新K线,**增量更新**所有层级(而非批量重算)。这是该库适合实时交易系统的关键设计。 + +### 管线流程 ``` 原始K线 (K线) │ - ├── 指标计算: MACD / RSI / KDJ (增量更新) + ├── 指标计算: MACD / RSI / KDJ / BOLL (增量更新, 含回填新指标) + │ ├── K线取值: 开/高/低/收/高低均值/高低收均值/开高低收均值 + │ ├── 多参数组: MACD_参数列表 / RSI_周期列表 / KDJ_参数列表 / BOLL_参数列表 + │ └── 均线组: SMA/EMA (均线_类型列表 × 均线_周期列表) │ ├── 包含处理 → 缠论K线 (缠论K线) - │ │ - │ ├── 顺序包含合并 - │ ├── 逆序包含合并 - │ └── 方向判定 (向上/向下) + │ ├── 顺序包含合并 (顺方向取极值) + │ ├── 逆序包含合并 (更新时间/标的K线) + │ ├── 方向判定 (相对方向: 向上/向下/向上缺口/向下缺口/衔接/包含) + │ └── 合并替换模式: 缠K合并替换=false(原地修改) / true(产出新缠K) │ ├── 分型识别 → 分型 (分型) - │ │ │ ├── 由左中右三根缠论K线构成 - │ ├── 类型: 顶分型 / 底分型 / 上 / 下 / 散 - │ └── 强度: 强 / 中 / 弱 + │ ├── 类型: 顶分型 / 底分型 / 三连向上 / 三连向下 / 向右扩散 + │ ├── 强度: 强 / 中 / 弱 (基于左中右收盘价关系) + │ ├── 分型模式: 全局开关控制缓存/实时读取 + │ └── MACD柱子分型匹配 (左中右MACD柱形态判定) │ - ├── 笔划分 → 笔 (虚线, 标识="笔") - │ │ - │ ├── 递归分析算法 - │ ├── 支持笔弱化、次级成笔等策略 - │ └── 实际高/低点选择 (first/last) + ├── 笔划分 → 笔 (虚线, 标识="笔", 级别=1) + │ ├── 递归分析算法: 顶底分型交替验证 + │ ├── 成笔条件: 笔内元素数量≥5 (弱化模式≥3) + │ ├── 笔弱化策略: 放宽条件时尝试次高/次低成笔 + │ ├── 实际高/低点选择: first/last (笔内相同终点取舍) + │ ├── 笔内起始分型包含整笔验证 + │ └── 笔内原始K线包含整笔验证 │ - ├── 线段划分 → 线段 (虚线, 标识="线段") - │ │ - │ ├── 特征序列提取 - │ ├── 缺口检测 (老阴/老阳) - │ ├── 笔破坏判断 - │ ├── 缺口突破修正 - │ └── 非缺口下穿刺修正 + ├── 线段划分 → 线段 (虚线, 标识="线段", 级别=2) + │ ├── 特征序列提取: 从笔序列中提取同向特征序列元素 (线段特征.静态分析) + │ ├── 缺口处理: + │ │ ├── 有缺口 (老阴/老阳): 严格包含处理, 等待特征分型完成 + │ │ └── 无缺口 (小阳/少阴): 允许逆序包含 + │ ├── 四种修正机制 (依次尝试): + │ │ ├── _缺口突破: 老阳/老阴时价格突破前线段极值 + │ │ ├── _非缺口下穿刺: 贯穿伤后出现方向相同的3笔 + │ │ ├── _缺口后紧急修正: 前一线段有缺口时自动修正 + │ │ └── _修正: 配置启用的短路修正 (≥9笔) + │ ├── 线段破坏判定: 新线段形成后前一线段被破坏 + │ ├── 3级递归: 线段 → 线段<线段> → 线段<线段<线段>> + │ └── 扩展线段分析: 扩展线段 → 扩展线段<扩展线段> → ... │ ├── 中枢识别 → 中枢 (中枢) - │ │ - │ ├── 连续三笔/段重叠构成 - │ ├── 中枢延伸 (添加虚线) - │ ├── 中枢扩展 (≥9段时生成扩展中枢) - │ ├── 第三类买卖点检测 - │ └── 多级中枢: 笔中枢 / 线段中枢 / 扩展线段中枢 + │ ├── 基础检查: 三条连续虚线重叠且方向关系正确 + │ ├── 中枢延伸: 后继虚线未离开中枢则加入基础序列 + │ ├── 第三买卖点: 虚线离开中枢后不回中枢, 记录第三买卖线 + │ ├── 中枢完成: 第三买卖线确立后开始新中枢 + │ ├── 中枢扩展: ≥9段时调用扩展分析生成扩展中枢 + │ └── 多级中枢: + │ ├── 笔中枢 (级别=1, 基于笔序列) + │ ├── 线段中枢 (级别=2, 基于线段序列) + │ ├── 扩展线段中枢 (基于扩展线段序列) + │ ├── 混合扩展中枢 (基于扩展线段<线段>序列) + │ └── 线段<线段>中枢 / 扩展线段<扩展线段>中枢 等 │ - └── 买卖点识别 → 买卖点 (买卖点) - │ - ├── 6 类买卖点: T1 / T1P / T2 / T2S / T3A / T3B - ├── 指标匹配: MACD / KDJ / RSI - ├── 背驰确认 - └── 中枢来源: 实 / 虚 / 合 + └── 买卖点识别 → 基础买卖点 (18种类型) + ├── 6 类经典买卖点: 一买/一卖/二买/二卖/三买/三卖 + ├── 12 类扩展买卖点: T1/T1P/T2/T2S/T3A/T3B 各含买卖 + ├── 指标匹配: MACD柱子匹配 / KDJ金叉死叉 / RSI超买超卖 + ├── 背驰确认: MACD背驰 / 斜率背驰 / 测度背驰 + ├── 中枢来源策略: 实 / 虚 / 合 + ├── 买卖意义缓存: LRU 128条目, 指针身份key + └── 偏移/失效偏移/终结K线管理 ``` -**流式增量处理**:`观察者.__处理数据` 每收到一根新K线就逐层触发所有后续分析,而非批量重算。这使得该库适合实时交易系统。 +### 流式增量处理 + +`观察者.__处理数据` 每收到一根新K线: +1. 计算指标并挂载到新K线 +2. 执行缠K包含处理,产出缠K +3. 识别分型,添加到分型序列 +4. 递归分析笔(可能生成新笔或修改最后一笔) +5. 递归分析中枢(笔中枢) +6. 递归分析线段(3级递归:线段→线段<线段>→线段<线段<线段>>) +7. 递归分析扩展线段(3级递归) +8. 递归分析混合扩展线段(3级递归,源序列为线段序列组[i]) + +每步都只处理最后几个元素,时间复杂度 O(1) 均摊。 --- -## 核心类型 +## 核心类型详解 ### 枚举类型 -| 类型 | 变体 | 说明 | -|------|------|------| -| `相对方向` | 向上 / 向下 / 向上缺口 / 向下缺口 / 衔接向上 / 衔接向下 / 顺 / 逆 / 同 | 两个价格区间的相对位置关系 | -| `分型结构` | 上 / 下 / 顶 / 底 / 散 | 三根K线构成的形态 | -| `买卖点类型` | 一买/一卖/二买/二卖/三买/三卖 + T1~T3B | 18 种买卖点分类 | +#### 相对方向 (`types::direction`) +用于描述两个价格区间(两根K线或两个分型)之间的位置关系,共 9 个变体: -### 数据结构 +| 变体 | 说明 | 示例场景 | +|------|------|---------| +| `向上` | 后K线高低点均高于前K线,且不构成缺口 | 正常上涨 | +| `向下` | 后K线高低点均低于前K线,且不构成缺口 | 正常下跌 | +| `向上缺口` | 后K线低点 > 前K线高点 | 大幅高开 | +| `向下缺口` | 后K线高点 < 前K线低点 | 大幅低开 | +| `衔接向上` | 后K线低点 == 前K线高点 | 精准衔接上涨 | +| `衔接向下` | 后K线高点 == 前K线低点 | 精准衔接下跌 | +| `顺` | 前K线包含后K线(前高≥后高 且 前低≤后低) | 顺序包含 | +| `逆` | 后K线包含前K线(前高≤后高 且 前低≥后低) | 逆序包含 | +| `同` | 两K线高低点完全相同 | 重复数据 | -| 类型 | 关键字段 | 所有权模型 | -|------|---------|-----------| -| `K线` | 标识, 序号, 周期, 时间戳, 高, 低, 开盘价, 收盘价, 成交量, macd?, rsi?, kdj? | 值类型 (Clone) | -| `缠论K线` | 序号, 时间戳, 高, 低, 方向, 分型?, 分型特征值, 原始起始序号, 原始结束序号, 标的K线 | Rc 引用 | -| `分型` | 左?, 中, 右?, 结构, 时间戳, 分型特征值 | Rc 引用 | -| `虚线` | 标识, 序号, 级别, 文, 武, 有效性, 基础序列, 特征序列, 实/虚/合_中枢序列, 确认K线, 模式, 前一缺口 | Rc 引用 | -| `线段特征` | 标识, 线段方向, (list子类) | Rc 引用 | -| `特征分型` | 左, 中, 右, 结构 | Rc 引用 | -| `中枢` | 序号, 标识, 级别, 基础序列, 第三买卖线?, 本级_第三买卖线? | Rc 引用 | -| `基础买卖点` | 类型, 买卖点K线, 买卖点分型, 备注, 偏移, 失效偏移, 有效性, 破位值 | 值类型 | -| `买卖点` | (继承基础买卖点) | 值类型 | -| `缺口` | 高, 低 | 值类型 | +提供方法:`翻转()`, `是否向上()`, `是否向下()`, `是否包含()`, `是否缺口()`, `是否衔接()`, `分析(前高,前低,后高,后低)` + +#### 分型结构 (`types::fractal`) +三根K线构成的结构形态,共 5 个变体: + +| 变体 | 左中右关系 | +|------|-----------| +| `上` | 向上类 + 向上类 | +| `下` | 向下类 + 向下类 | +| `顶` | 向上类 + 向下类(Λ 形) | +| `底` | 向下类 + 向上类(V 形) | +| `散` | 逆序包含 + 逆序包含 | + +提供方法:`分析(左,中,右, 可以逆序包含, 忽视顺序包含)` — 通用分型识别 + +#### 买卖点类型 (`types::bsp_type`) +18 种买卖点分类: + +| 类别 | 买点 | 卖点 | 说明 | +|------|------|------|------| +| 一类 | `一买` | `一卖` | 中枢背驰后的第一类买卖点 | +| 二类 | `二买` | `二卖` | 回调到中枢内的第二类买卖点 | +| 三类 | `三买` | `三卖` | 离开中枢后不回中枢的第三类买卖点 | +| 扩展T1 | `T1买` | `T1卖` | 事后确认型 | +| 扩展T1P | `T1P买` | `T1P卖` | 事后确认型+ | +| 扩展T2 | `T2买` | `T2卖` | 中枢回调型 | +| 扩展T2S | `T2S买` | `T2S卖` | 中枢回调次级 | +| 扩展T3A | `T3A买` | `T3A卖` | 第三类扩展A | +| 扩展T3B | `T3B买` | `T3B卖` | 第三类扩展B | + +提供方法:`是买点()`, `是卖点()` + +### 数据结构详解 + +#### K线 (`kline::bar`) — 原始OHLCV + 指标 +``` +K线 { + 标识, 序号, 周期, 时间戳, + 高, 低, 开盘价, 收盘价, 成交量, + 指标: RwLock<指标容器> // MACD/RSI/KDJ/BOLL/均线 +} +``` +- 实现 `Clone` (深拷贝, 包括指标容器) +- 支持大端字节序序列化 (`to_bytes()` → 48字节, `from_bytes()`) +- 提供 `创建普K()` 工厂方法, `相等()` 结构化校验 +- `获取MACD()` 计算两K线间的 MACD 柱面积 (阳/阴/合/总) +- `截取rc()` 按 Arc 指针截取序列片段 + +#### 缠论K线 (`kline::chan_kline`) — 包含处理后的K线 +``` +缠论K线 { + 序号: AtomicI64, + 时间戳: AtomicI64, + 高: SyncF64, // 经包含处理可能被拉高 + 低: SyncF64, // 经包含处理可能被压低 + 方向: RwLock<相对方向>, + 分型: RwLock>, + 分型特征值: SyncF64, // 历史高低点极值 + 周期, 标识, + 原始起始序号, // 包含处理前 + 原始结束序号: AtomicI64, // 包含处理后更新 + 标的K线: RwLock>, + 买卖点信息: RwLock>, +} +``` +- 核心算法:`_兼并()` — 顺序/逆序包含合并, 重复提交检测, 原地修改 +- `分析()` — 完整的普K→缠K+分型管线 +- 指标匹配:`与MACD柱子匹配()` / `与RSI匹配()` / `与KDJ匹配()` +- 时间戳对齐:跨周期对齐至基线 +- `相等()` — 20+ 字段逐项递归校验 + +#### 分型 (`structure::fractal_obj`) — 顶/底分型 +``` +分型 { + 左: Option>, + 中: Arc<缠论K线>, // 核心K线 + 右: Option>, + 结构: 分型结构, // 构造时缓存 + 时间戳: i64, // 构造时缓存 + 分型特征值: f64, // 构造时缓存 +} +``` +- **分型模式** (`分型模式: AtomicBool`): 全局开关 + - `true` (默认) — `时间戳()/结构()/分型特征值()` 返回构造时缓存值 + - `false` — 从 `中` 缠K 实时读取(逆序包含后使用) +- 提供方法:`强度()` (强/中/弱), `关系组()` (左中右三组关系), `与MACD柱子分型匹配()` +- `相等()` — 递归校验左/中/右缠K + 自有缓存字段 +- 实现 `有高低` trait: 高/低 → 中.高/中.低 + +#### 虚线 (`structure::dash_line`) — 笔和线段的通用数据结构 +``` +虚线 { + 标识: RwLock, // "笔"/"线段"/"扩展线段"/"线段<线段>" 等 + 序号: AtomicI64, + 级别: AtomicI64, // 笔=1, 线段=2, 线段<线段>=3, ... + 文: Arc<分型>, // 起点分型(不可变) + 武: RwLock>, // 终点分型(可变,用于动态更新) + 有效性: AtomicBool, + 基础序列: RwLock>>, // 构成虚线的子级虚线序列 + 特征序列: RwLock>>>, // 线段特征序列 + 实_中枢序列: RwLock>>, + 虚_中枢序列: RwLock>>, + 合_中枢序列: RwLock>>, + 确认K线: RwLock>>, + 模式: RwLock, // 买卖点匹配模式 + _特征序列_显示: AtomicBool, + 前一缺口: RwLock>, + 前一结束位置: RwLock>>, + 短路修正: AtomicBool, +} +``` +- 静态工厂:`创建笔()` (级别=1), `创建线段()` (级别=笔级别+1, 基础序列=笔序列) +- 属性:`方向()`, `高()`, `低()`, `之前是()`, `之后是()` +- 数据访问:`获取普K序列()`, `获取缠K序列()`, `获取_武()` (递归到底层笔) +- 买卖点模式匹配:`全量/任意/配置/相对` 四种指标组合策略 +- MACD相关:`计算MACD柱子均值()`, `武之MACD均值()`, `武之MACD极值()` +- 趋势分析:`计算K线序列MACD趋向背驰()` (MACD柱/DIF/DEA三项背驰) +- 分段:`计算MACD柱子分段()` (按正负号分段) +- 行为统计:`统计MACD行为()` → `MACD行为统计 {DIF上下穿0, DEA上下穿0, 金叉/死叉次数, 密集交叉区域}` +- 核心判断:`买卖意义()` — LRU 缓存 128 条目,指针身份作为 key +- 序列化:`获取数据文本()` — 笔输出简洁格式,线段输出完整格式 +- `相等()` — 递归校验文/武/基础序列/特征序列/中枢序列/确认K线/模式/缺口/结束位置等全部字段 + +#### 线段特征 (`structure::segment_feat`) — 特征序列元素 +``` +线段特征 { + 序号: AtomicI64, + 标识: RwLock, + 线段方向: 相对方向, + 基础序列: Vec>, // 构成该元素的虚线序列 +} +``` +- 虚拟端点:`文()` — 取特征值最大/最小的起点分型 +- 虚拟端点:`武()` — 取特征值最大/最小的终点分型 +- tiebreaker: 同特征值时取后时间戳 +- `高()/低()` — 文/武中分型特征值的较大/较小者 +- `方向()` — 线段方向的翻转 +- 静态分析:从虚线序列生成特征序列元素列表,含分型替换逻辑 +- 分型序列:从连续特征序列元素中提取特征分型 + +#### 特征分型 (`structure::feat_fractal`) +``` +特征分型 { + 左: Arc<线段特征>, + 中: Arc<线段特征>, + 右: Arc<线段特征>, + 结构: 分型结构, // 由左中右三元素分析得出 +} +``` + +#### 中枢 (`algorithm::hub`) +``` +中枢 { + 序号: AtomicI64, + 标识: RwLock, // "笔中枢<0>"/"线段中枢<1>" 等 + 级别: AtomicI64, + 基础序列: RwLock>>, // 至少3根, 延伸后可达9+ + 第三买卖线: RwLock>>, + 本级_第三买卖线: RwLock>>, +} +``` +- 属性:`高()` (前三根虚线的最高重叠), `低()` (前三根虚线的最低重叠) +- `文()` → `武()` → `方向()` +- `延伸序列()` → 首尾之外的虚线 +- `获取数据文本()` — 完整中枢结构化描述 +- `相等()` — 逐项递归校验基础序列和第三买卖线 +- 向序列中添加/更新中枢(含显示名称映射) + +#### 基础买卖点 (`business::bsp`) +``` +基础买卖点 { + 备注: String, + 类型: 买卖点类型, + 买卖点分型: Arc<分型>, + 买卖点K线: Arc<缠论K线>, // = 买卖点分型.中 + 当前K线: Arc, + 失效K线: Option>, + 终结K线: Option>, + 破位值: f64, + 结构: Option<分型结构>, + 当前缠K序号: Option, +} +``` +- `偏移()` — 当前缠K序号与买卖点K线序号的差 +- `有效性()` — 失效K线是否存在 ### 服务类型 -| 类型 | 职责 | -|------|------| -| `观察者` | 单周期分析器,接收K线流,维护所有层级序列 | -| `K线合成器` | 将小周期K线合成为大周期K线 | -| `立体分析器` | 多周期联立分析,内含合成器+每周期一个观察者 | +| 类型 | 职责 | 位置 | +|------|------|------| +| `观察者` | 单周期分析器, 接收K线流, 维护所有层级序列, 3级递归线段分析 | `business::observer` | +| `K线合成器` | 小周期→大周期合成, 高取max/低取min/量求和, 时间戳对齐 | `business::synthesizer` | +| `立体分析器` | 多周期联立, 内含合成器 + 每周期一个观察者, 对齐缠K序列 | `business::multi_frame` | ### 静态算法类 -这些类型只包含 `#[staticmethod]` / `classmethod`,不创建实例: - -| 类型 | 职责 | -|------|------| -| `笔` | 笔划分算法:分型→笔 (递归分析) | -| `线段` | 线段划分算法:笔→线段 (特征序列+缺口处理) | -| `中枢` | 中枢识别/管理:虚线→中枢 (重叠检测+扩展) | -| `背驰分析` | 背驰检测:MACD/斜率/测度三种方式 | -| `指标` | K线取值工具:按计算方式取价格 | +| 类型 | 职责 | 位置 | +|------|------|------| +| `笔` | 笔划分: 分型→笔 (递归分析, 笔弱化, 次级成笔, 实际高/低点, 是否背驰过) | `algorithm::bi` | +| `线段` | 线段划分: 笔→线段 (特征序列+缺口处理+四种修正+扩展分析+内部背驰判断) | `algorithm::segment` | +| `中枢` | 中枢识别: 虚线→中枢 (重叠检测+延伸/扩展+第三买卖点+向序列添加) | `algorithm::hub` | +| `背驰分析` | 背驰检测: MACD/斜率/测度 + 全量/任意/配置/相对四种组合 | `algorithm::divergence` | +| `指标计算器` | 指标增量计算: MACD/RSI/KDJ/BOLL + 回填新指标 + 均线 | `indicators::calculator` | --- ## 配置体系 -`缠论配置` 是一个 serde 驱动的结构体,62 个字段全部带默认值,支持 JSON 往返。 +`缠论配置` 是一个 serde 驱动的结构体,**62 个字段**全部带默认值,支持 JSON 往返和部分反序列化容错。 -### 配置分组 +### 完整配置表 -```rust -let config = 缠论配置 { - // ---- 缠K ---- - 缠K合并替换: false, // false: 在原地合并, true: 产出新缠K +#### 基础设置 +| 字段 | 默认值 | 说明 | +|------|--------|------| +| `标识` | `"bar"` | 品种标识 | - // ---- 笔 ---- - 笔内元素数量: 5, // 成笔最低K线数 - 笔弱化: false, // 放宽成笔条件(弱化后最低3根) - 笔次级成笔: false, // 允许次高/次低成笔 - 笔内相同终点取舍: false, // false: 取first, true: 取last +#### 缠K设置 +| 字段 | 默认值 | 说明 | +|------|--------|------| +| `缠K合并替换` | `false` | false=原地修改, true=产出新缠K | - // ---- 线段 ---- - 线段_特征序列忽视老阴老阳: false, // 缺口时不严格处理包含 - 线段_缺口后紧急修正: true, // 缺口后自动修正 - 线段_修正: false, // 短路修正(不推荐) +#### 笔设置 +| 字段 | 默认值 | 说明 | +|------|--------|------| +| `笔内元素数量` | `5` | 成笔最低缠K数 | +| `笔内相同终点取舍` | `false` | false=取first, true=取last | +| `笔内起始分型包含整笔` | `false` | 起点分型区间必须包含整笔 | +| `笔内起始分型包含整笔_包括右` | `false` | 同上+包含右端点 | +| `笔内原始K线包含整笔` | `false` | 原始K线区间必须包含整笔 | +| `笔次级成笔` | `false` | 允许在非分型处成笔 | +| `笔弱化` | `false` | 放宽成笔条件 | +| `笔弱化_原始数量` | `3` | 弱化模式最小原始K线数 | - // ---- 分析开关 ---- - 分析笔: true, - 分析线段: true, - 分析扩展线段: true, - 分析笔中枢: true, - 分析线段中枢: true, +#### 线段设置 +| 字段 | 默认值 | 说明 | +|------|--------|------| +| `线段_非缺口下穿刺` | `false` | 非缺口下的穿刺处理 | +| `线段_特征序列忽视老阴老阳` | `false` | 缺口时不严格处理包含 | +| `线段_缺口后紧急修正` | `true` | 缺口后自动修正 | +| `线段_修正` | `false` | 短路修正(不推荐) | +| `线段内部中枢图显` | `true` | 线段内部中枢图表显示 | +| `扩展线段_当下分析` | `false` | 扩展线段实时分析模式 | - // ---- 指标参数 ---- - 平滑异同移动平均线_快线周期: 13, // MACD 快线 EMA 周期 - 平滑异同移动平均线_慢线周期: 31, // MACD 慢线 EMA 周期 - 平滑异同移动平均线_信号周期: 11, // MACD 信号线 EMA 周期 - 相对强弱指数_周期: 13, - 相对强弱指数_超买阈值: 75.0, - 相对强弱指数_超卖阈值: 25.0, - 随机指标_RSV周期: 13, - 随机指标_K值平滑周期: 5, - 随机指标_D值平滑周期: 5, - 随机指标_超买阈值: 80.0, - 随机指标_超卖阈值: 20.0, +#### 分析开关 +| 字段 | 默认值 | 说明 | +|------|--------|------| +| `分析笔` | `true` | | +| `分析线段` | `true` | | +| `分析扩展线段` | `true` | | +| `分析笔中枢` | `true` | | +| `分析线段中枢` | `true` | | - // ---- 买卖点 ---- - 买卖点偏移: 1, // 最大偏移量 - 买卖点_指标模式: "配置", // 配置 / 任意 / 全量 / 相对 - 买卖点_计算方式: "峰", // 峰 / 面 - 买卖点_背离率: f64::INFINITY, - 买卖点_中枢来源: "合", // 实 / 虚 / 合 +#### 终止 +| 字段 | 默认值 | 说明 | +|------|--------|------| +| `手动终止` | `""` | 手动终止时间字符串 | - // ---- 背驰 ---- - 线段内部背驰_MACD: true, - 线段内部背驰_斜率: true, - 线段内部背驰_测度: true, - 线段内部背驰_模式: "相对", // 全量 / 任意 / 配置 / 相对 +#### 指标设置 +| 字段 | 默认值 | 说明 | +|------|--------|------| +| `计算指标` | `true` | 是否计算技术指标 | +| `计算BOLL` | `false` | 是否计算布林带 | +| `指标计算方式` | `"收"` | 开/高/低/收/高低均值/高低收均值/开高低收均值 | - // ---- 图表推送 ---- - 图表展示: true, - 推送K线: true, - 推送笔: true, - 推送线段: true, - 推送中枢: true, +#### MACD 参数 +| 字段 | 默认值 | 说明 | +|------|--------|------| +| `平滑异同移动平均线_快线周期` | `13` | | +| `平滑异同移动平均线_慢线周期` | `31` | | +| `平滑异同移动平均线_信号周期` | `11` | | +| `MACD_参数列表` | `[]` | 多参数变体: `Vec<(key, 快, 慢, 信号)>` | - // ... 还有 20+ 个图表细分开关 - ..Default::default() -}; -``` +#### RSI 参数 +| 字段 | 默认值 | 说明 | +|------|--------|------| +| `相对强弱指数_周期` | `13` | RSI计算周期 | +| `相对强弱指数_移动平均线周期` | `13` | RSI SMA平滑周期 | +| `相对强弱指数_超买阈值` | `75.0` | | +| `相对强弱指数_超卖阈值` | `25.0` | | +| `RSI_周期列表` | `[]` | 多周期变体: `Vec<(key, 周期)>` | + +#### KDJ 参数 +| 字段 | 默认值 | 说明 | +|------|--------|------| +| `随机指标_RSV周期` | `13` | | +| `随机指标_K值平滑周期` | `5` | | +| `随机指标_D值平滑周期` | `5` | | +| `随机指标_超买阈值` | `80.0` | | +| `随机指标_超卖阈值` | `20.0` | | +| `KDJ_参数列表` | `[]` | 多参数变体 | + +#### BOLL 参数 +| 字段 | 默认值 | 说明 | +|------|--------|------| +| `布林带_周期` | `20` | | +| `布林带_标准差倍数` | `2.0` | | +| `BOLL_参数列表` | `[]` | 多参数变体 | + +#### 均线参数 +| 字段 | 默认值 | 说明 | +|------|--------|------| +| `均线_类型列表` | `[]` | `["SMA", "EMA", ...]` | +| `均线_周期列表` | `[]` | `[5, 10, 20, ...]` | + +#### 推送/图表显示 +| 字段 | 默认值 | +|------|--------| +| `图表展示` | `true` | +| `推送K线` | `true` | +| `推送笔` | `true` | +| `推送线段` | `true` | +| `推送中枢` | `true` | +| `图表展示_笔` | `true` | +| `图表展示_线段` | `true` | +| `图表展示_扩展线段` | `true` | +| `图表展示_扩展线段_线段` | `true` | +| `图表展示_线段_线段` | `true` | +| `图表展示_中枢_笔` | `true` | +| `图表展示_中枢_线段` | `true` | +| `图表展示_中枢_扩展线段` | `true` | +| `图表展示_中枢_扩展线段_线段` | `true` | +| `图表展示_中枢_线段_线段` | `true` | +| `图表展示_中枢_线段内部` | `true` | + +#### 买卖点设置 +| 字段 | 默认值 | 说明 | +|------|--------|------| +| `买卖点偏移` | `1` | 最大偏移量 | +| `买卖点激进识别` | `false` | | +| `买卖点与MACD柱强相关` | `false` | | +| `买卖点错过误差值` | `0.01` | | +| `买卖点_指标模式` | `"配置"` | 任意/配置/全量/相对 | +| `买卖点_指标匹配_MACD` | `true` | | +| `买卖点_指标匹配_KDJ` | `true` | | +| `买卖点_指标匹配_RSI` | `true` | | +| `买卖点_背离率` | `INFINITY` | 不使用 | +| `买卖点_T2_回调阈值` | `1.0` | | +| `买卖点_T2S_最大层级` | `3` | | +| `买卖点_峰值条件` | `false` | | +| `买卖点_计算方式` | `"峰"` | | +| `买卖点_计算线段BSP1` | `true` | | +| `买卖点_处理BSP2` | `true` | | +| `买卖点_计算线段BSP3` | `true` | | +| `买卖点_依赖T1` | `true` | | +| `买卖点_中枢来源` | `"合"` | 实/虚/合 | +| `买卖点_调试输出` | `false` | | + +#### 背驰设置 +| 字段 | 默认值 | 说明 | +|------|--------|------| +| `线段内部背驰_MACD` | `true` | 使用 MACD 背驰 | +| `线段内部背驰_斜率` | `true` | 使用斜率背驰 | +| `线段内部背驰_测度` | `true` | 使用测度背驰 | +| `线段内部背驰_模式` | `"相对"` | 任意/配置/全量/相对 | + +#### 文件 +| 字段 | 默认值 | 说明 | +|------|--------|------| +| `加载文件路径` | `""` | 数据文件路径 | ### 配置方法 ```rust // JSON 往返 -let json = config.to_json(); -let config2 = 缠论配置::from_json(&json)?; +let json = config.to_json(); // 序列化 +let config2 = 缠论配置::from_json(&json)?; // 反序列化 -// 文件持久化 -config.保存配置("/path/to/config.json")?; -let loaded = 缠论配置::加载配置("/path/to/config.json")?; +// 文件 I/O +config.保存配置("/path/to/config.json")?; // 保存 +let loaded = 缠论配置::加载配置("/path/to/config.json")?; // 加载 -// 静默模式(关闭所有推送) -let silent = config.不推送(); +// 部分反序列化(缺失字段→默认值) +let config: 缠论配置 = serde_json::from_str(r#"{"笔内元素数量": 7}"#)?; + +// 静默模式 +let silent = config.不推送(); // 所有推送/展示开关设为 false // 差异对比 -let diffs = config_a.对比(&config_b); -// → ["笔内元素数量", "买卖点偏移", ...] +let diffs = config_a.对比(&config_b); // → ["笔内元素数量", ...] + +// 多参数解析(列表非空则使用列表,否则使用默认单组) +let macd参数 = config._解析MACD参数列表(); // Vec<(key, 快, 慢, 信号)> +let rsi周期 = config._解析RSI周期列表(); // Vec<(key, 周期)> +let kdj参数 = config._解析KDJ参数列表(); // Vec<(key, RSV, K, D)> +let boll参数 = config._解析BOLL参数列表(); // Vec<(key, 周期, 倍数)> + +// 复合key重组(兼容旧版 "1_open" 格式) +let configs = 缠论配置::按序号重组字典(&default_config, &raw_json); ``` --- ## 算法模块 -### 笔划分 (`algorithm::bi`) +### 笔划分 (`algorithm::bi`, 1009 行) -核心递归算法 `笔.分析`,每次收到新分型时: -1. 检查顶/底分型交替 -2. 验证笔内K线数量 ≥ 配置阈值 -3. 确定实际高/低点(考虑笔弱化、次高点/次低点) -4. 检查笔内起始分型包含整笔条件 -5. 支持笔弱化策略:当严格条件不满足时,尝试放宽到3根 +`笔` 是一个纯静态方法命名空间,核心方法: -### 线段划分 (`algorithm::segment`) +| 方法 | 说明 | +|------|------| +| `分析()` | 主算法:接收分型,递归分析生成笔 | +| `_获取缠K数量()` | 计算可成笔的缠K数量(含弱化模式) | +| `_实际高点() / _实际低点()` | 潜在成笔区域的最高/最低缠K | +| `_获取成笔核心分型()` | 笔弱化模式下获取次高/次低成笔的候选分型 | +| `根据缠K找笔()` | 在笔序列中查找包含指定缠K的笔 | +| `是否背驰过()` | 检查笔范围内是否发生过MACD背驰 | -最复杂的递归算法,核心方法 `线段.分析`: -1. **特征序列提取**:从笔序列中提取同向特征序列(`线段特征.静态分析`) -2. **缺口处理**: - - 有缺口(老阴/老阳):严格包含处理,等待特征分型完成 - - 无缺口(小阳/少阴):允许逆序包含 -3. **四种修正机制**(依次尝试): - - `_缺口突破`:老阳/老阴时价格突破前线段极值 - - `_非缺口下穿刺`:贯穿伤后出现方向相同的3笔 - - `_缺口后紧急修正`:前一线段有缺口时自动修正 - - `_修正`:配置启用的短路修正(≥9笔) -4. **线段破坏判定**:新线段形成后,前一线段被破坏 +关键设计: +- 笔弱化:当严格条件不满足时,允许 `笔弱化_原始数量` (≥3) 根原始K线成笔 +- 次级成笔:允许次高/次低成笔的候选分型 +- 相同终点取舍:false=取first, true=取last (Python `list.index` 行为) +- 笔内验证:起始分型包含整笔 / 原始K线包含整笔 -### 中枢识别 (`algorithm::hub`) +### 线段划分 (`algorithm::segment`, 2078 行) -核心递归算法 `中枢.分析`: -1. **基础检查**:三条连续虚线,重叠且方向关系正确 -2. **中枢延伸**:在后继虚线中找离开中枢的虚线,未离开则加入基础序列 -3. **第三买卖点**:虚线离开中枢(向上缺口/向下缺口),记录第三买卖线 -4. **中枢完成**:第三买卖线确立后,在中枢之上的虚线开始新中枢 -5. **中枢扩展**:≥9段时调用线段扩展分析生成扩展中枢 -6. **多级中枢**:笔中枢、线段中枢、扩展线段中枢、线段线段中枢等 +`线段` 是**最复杂的算法模块**,核心方法: -### 背驰检测 (`algorithm::divergence`) +| 方法 | 说明 | +|------|------| +| `分析()` | 主算法:从笔序列计算线段 | +| `扩展分析()` | 扩展线段分析(不同的缺口处理策略) | +| `分割序列()` | 将特征序列分割为前/后/三/贯穿伤四个部分 | +| `特征序列状态()` | 返回 (特征_a, 特征_b, 特征_c) 布尔标志 | +| `四象()` | 判断特征序列的类型:老阳/老阴/小阳/少阴 | +| `获取缺口()` | 获取特征序列第一二元素间的缺口 | +| `判断线段内部是否背驰()` | 线段内部进入段vs离开段背驰判断 | +| `是否背驰过()` | 线段范围内是否发生过背驰 | +| `获取所有停顿位置()` | 获取线段内部所有可能的停顿位置 | +| `获取内部中枢序列()` | 获取线段内部的中枢序列 | -三种检测方式,可自由组合: +四种修正机制(按优先级): +1. `_缺口突破` — 老阳/老阴状态 + 价格突破前线段极值 → 线段完成 +2. `_非缺口下穿刺` — 贯穿伤存在 + 方向相同的3笔 → 线段完成 +3. `_缺口后紧急修正` — 前一线段有缺口 + 价格反向突破 → 修正前一结束位置 +4. `_修正` — 短线路径 (≥9笔) → 快速完成 + +### 中枢识别 (`algorithm::hub`, 1041 行) + +`中枢` 既是数据结构也是算法命名空间: + +| 方法 | 说明 | +|------|------| +| `分析()` | 主算法:虚线→中枢,同时识别第三买卖点 | +| `线段扩展分析()` | 中枢扩展:≥9段时生成扩展中枢 | +| `高()` | 前三根虚线重叠区域的最高值 | +| `低()` | 前三根虚线重叠区域的最低值 | +| `文()` | 第一根虚线的起点分型 | +| `武()` | 第三根虚线的终点分型 | +| `方向()` | 当中枢高 > 中枢低时为向上,否则向下 | +| `延伸序列()` | 首尾之外的虚线 | +| `获取离开虚线()` | 找到离开中枢的虚线(第三买卖点) | +| `获取剩余虚线()` | 第三买卖点之后的虚线 | +| `获取第N买卖线()` | 获取第N个买卖点相关的虚线 | + +中枢识别核心逻辑: +1. 基础检查:三条连续虚线必须重叠(`max(低) < min(高)`)且方向关系正确 +2. 中枢延伸:在后继虚线中找离开中枢的虚线,未离开则加入基础序列 +3. 第三买卖点:虚线离开中枢且不回中枢,记录 `第三买卖线` +4. 中枢完成:`第三买卖线` 确立后,在上方虚线开始新中枢 +5. 中枢扩展:≥9段时调用 `线段扩展分析` 生成扩展中枢 + +### 背驰检测 (`algorithm::divergence`, 227 行) + +`背驰分析` 提供三种检测方式: | 方式 | 原理 | 方法 | |------|------|------| -| MACD背驰 | 进入段vs离开段的MACD柱面积对比 | `MACD背驰` | -| 斜率背驰 | 价格变化速率减弱 | `斜率背驰` | -| 测度背驰 | 价格-时间向量长度缩减 | `测度背驰` | +| MACD背驰 | 进入段vs离开段的MACD柱面积对比 (`阳 + |阴|` 绝对值和) | `MACD背驰()` | +| 斜率背驰 | 价格变化速率 (dy/dx) 减弱 | `斜率背驰()` | +| 测度背驰 | 价格-时间向量长度 (√(dx²+dy²)) 缩减 | `测度背驰()` | -组合模式: -- **全量**:三种全满足 -- **任意**:任一种满足 -- **配置**:根据配置开关组合 -- **相对**:至少两种满足 +四种组合模式: + +| 模式 | 条件 | +|------|------| +| `全量` | MACD 且 斜率 且 测度 全满足 | +| `任意` | MACD 或 斜率 或 测度 任意满足 | +| `配置` | 根据 `线段内部背驰_MACD/斜率/测度` 配置组合 | +| `相对` | 至少两种方式满足(多数投票) | --- ## 技术指标 -三个指标均位于 `indicators/` 模块,支持增量计算(适合流式数据)。 +所有指标位于 `indicators/` 模块,均支持**首次计算**和**增量计算**两种模式。 -### MACD (`indicators::macd`) +### 统一指标容器 (`indicators::container`) + +基于注册表模式,通过 `指标值` 枚举统一管理五种指标类型: ```rust -pub struct 平滑异同移动平均线 { - // 输入 - pub 时间戳: i64, pub 收盘价: f64, - pub 快线周期: i64, pub 慢线周期: i64, pub 信号周期: i64, - - // 输出 - pub DIF: Option, // 快线EMA - 慢线EMA - pub DEA: Option, // DIF的信号线EMA - pub MACD柱: Option, // DIF - DEA - - // 计算中间值 (增量用) - pub 快线EMA: Option, - pub 慢线EMA: Option, - pub DEA_EMA: Option, +pub enum 指标值 { + MACD(平滑异同移动平均线), + RSI(相对强弱指数), + KDJ(随机指标), + BOLL(布林带), + 均线(HashMap), + 单值(HashMap), } ``` -### RSI (`indicators::rsi`) +- 预注册默认槽位:`macd`, `rsi`, `kdj`, `boll`, `均线`, `单值` +- 支持多参数变体:`MACD_13_31_11`, `RSI_14`, `KDJ_9_3_3` 等 +- 便捷方法:`macd()`, `rsi()`, `kdj()`, `boll()`, `均线()` +- 动态注册:`注册(name, default_value)`, `设置(name, value)` -```rust -pub struct 相对强弱指数 { - pub 时间戳: i64, pub 收盘价: f64, - pub 周期: i64, - pub 超买阈值: f64, pub 超卖阈值: f64, +### 指标计算器 (`indicators::calculator`, 476 行) - pub RSI: Option, - pub RSI_SMA: Option, // RSI的SMA信号线 +`指标计算器::计算并挂载(全序列, 配置)` 是一次完整的指标计算周期: - // Wilder平滑中间值 - pub 平均上涨: Option, - pub 平均下跌: Option, +1. **MACD组** — 遍历 `_解析MACD参数列表()`,对每个 (key, 快, 慢, 信号) 计算 +2. **RSI组** — 遍历 `_解析RSI周期列表()` +3. **KDJ组** — 遍历 `_解析KDJ参数列表()` +4. **BOLL组** — 遍历 `_解析BOLL参数列表()` +5. **均线** — 遍历 `均线_类型列表 × 均线_周期列表`,MA/EMA 计算 +6. **回填新指标** — 如果当前K线有前序K线遗漏的指标,回溯填充 + +K线取值 (`K线取值()`) 支持 7 种计算方式: +`开` / `高` / `低` / `收` / `高低均值` / `高低收均值` / `开高低收均值` + +### MACD (`indicators::macd`, 215 行) + +``` +平滑异同移动平均线 { + 时间戳, 收盘价, 快线周期, 慢线周期, 信号周期, + DIF: Option, // 快线EMA - 慢线EMA + DEA: Option, // DIF的信号线EMA + MACD柱: f64, // 2 * (DIF - DEA) + 快线EMA: Option, // 计算中间值(增量用) + 慢线EMA: Option, + DEA_EMA: Option, } ``` -### KDJ (`indicators::kdj`) +- `首次计算(收盘价, 时间戳, 快线周期, 慢线周期, 信号周期)` — 初始EMA=SMA近似 +- `增量计算(prev, 收盘价, 时间戳)` — EMA递推公式 +- `计算K线MACD(普K序列, 快, 慢, 信号)` — 遍历序列批量计算 -```rust -pub struct 随机指标 { - pub 时间戳: i64, pub 最高价: f64, pub 最低价: f64, pub 收盘价: f64, - pub RSV周期: i64, pub K值平滑周期: i64, pub D值平滑周期: i64, - pub 超买阈值: f64, pub 超卖阈值: f64, +### RSI (`indicators::rsi`, 240 行) - pub K: Option, // RSV的移动平均 - pub D: Option, // K的移动平均 - pub J: Option, // 3*K - 2*D +``` +相对强弱指数 { + 时间戳, 收盘价, 周期, + 超买阈值, 超卖阈值, + RSI: Option, + RSI_SMA: Option, // RSI的SMA信号线 + 平均上涨: Option, // Wilder平滑中间值(增量用) + 平均下跌: Option, } ``` -所有三个指标都提供 `首次计算` → `增量计算` 的流式计算接口,以及对应的 `_K线` 便捷版本。 +- Wilder SMA 平滑算法 +- `首次计算()` — 初始均值=SMA +- `增量计算()` — Wilder递推 + +### KDJ (`indicators::kdj`, 289 行) + +``` +随机指标 { + 时间戳, 最高价, 最低价, 收盘价, + RSV周期, K值平滑周期, D值平滑周期, + 超买阈值, 超卖阈值, + K: Option, // RSV的移动平均 + D: Option, // K的移动平均 + J: Option, // 3*K - 2*D +} +``` + +- `首次计算(全体)` — 从序列计算初始KDJ值 +- `增量计算(全体, 当前, 周期, K平滑, D平滑)` — 增量更新 + +### BOLL (`indicators::boll`, 169 行) + +``` +布林带 { + 时间戳, 收盘价, 周期, + 标准差倍数, + 中轨: Option, // SMA + 上轨: Option, // 中轨 + 倍数 * 标准差 + 下轨: Option, // 中轨 - 倍数 * 标准差 +} +``` + +--- + +## 线程安全与并发 + +### 内部可变性策略 + +缠K/虚线/中枢等结构体需要在多 `Arc` 共享时支持原地修改(如笔动态替换武、中枢延伸添加虚线)。Rust 中 `Arc` 的 `&self` 是不可变的,因此大量字段使用以下策略实现内部可变性: + +| 机制 | 类型 | 适用场景 | +|------|------|---------| +| `SyncF64` | `AtomicU64` 位转换 | 价格字段 (高/低/分型特征值),需跨线程原子读写 | +| `AtomicI64` | 原子整数 | 序号/时间戳/原始结束序号 | +| `AtomicBool` | 原子布尔 | 有效性/短路修正/特征序列显示 | +| `RwLock` | 读写锁 | 复杂字段 (Arc<分型>/Vec/标识/模式/中枢序列) | + +### 全局缓存 + +- **买卖意义缓存**: `LazyLock>>` — 128条目,key为 (虚线指针, 观察者指针) 地址对 +- **与 Python 对齐**:Python 使用 `@lru_cache(maxsize=128)` 装饰器,Rust 使用全局 `LazyLock>` 替代 PyO3 的 `thread_local!`(解决跨线程不可见问题) + +### Send + Sync 保证 + +所有核心数据结构(`缠论K线`, `虚线`, `中枢`, `观察者`)均实现 `Send + Sync`,可以安全地跨线程传递和共享: + +```rust +// 编译期断言 +fn _需要_Send_Sync() {} +_需要_Send_Sync::<缠论K线>(); +_需要_Send_Sync::<虚线>(); +_需要_Send_Sync::<中枢>(); +``` + +`观察者` 使用 `Arc>` 模式,多线程可以并发读取同一个观察者的分析结果。 + +### RwLock 死锁预防 + +Rust 的 `RwLock` 是**非递归锁**。在已有读锁的线程中尝试获取写锁会死锁。核心规则: +- 将读锁作用域化:在所有调用带写锁的方法之前释放 `prev_guard` +- 典型模式:`{ let prev_guard = ... ; /* 使用 prev_guard */ } // 作用域结束释放 → 安全调用回填` --- ## Python 绑定 -Python 调用通过独立的 [`chanlun-py`](../chanlun-py/) crate 提供,使用 PyO3 包装: +Python 调用通过独立的 [`chanlun-py`](../chanlun-py/) crate 提供,使用 PyO3 包装。所有类型、方法、字段名与 `chan.py` **完全一致**,API 兼容。 ```bash cd chanlun-py -cargo build --release -cp target/release/libchanlun.so ~/.local/lib/python3.14/site-packages/chanlun/_chanlun.cpython-314-x86_64-linux-gnu.so +maturin develop --release ``` ```python import chanlun -# 创建配置 +# 创建/修改配置 config = chanlun.缠论配置() config.笔内元素数量 = 7 +config.MACD_参数列表 = [("my_macd", 12, 26, 9)] # 创建观察者 obs = chanlun.观察者("BTCUSD", 3600, config) +# 通过 Python 属性直接修改配置(自动同步到核心层) +obs.配置.笔弱化 = True + # 投喂K线 k = chanlun.K线.创建普K("bar", 1736640000, 50000, 51000, 49000, 50500, 100, 0, 3600) obs.增加原始K线(k) # 获取分析结果 print(f"笔数量: {len(obs.笔序列)}") +print(f"线段数量: {len(obs.线段序列)}") print(f"中枢数量: {len(obs.中枢序列)}") + +# 全局开关 +chanlun.分型模式 = False # 从缠K实时读取分型值(逆序包含后使用) ``` -所有类型、方法、字段名与 `chan.py` 完全一致,API 兼容。 +### 绑定层特性 + +- **配置缓存**: `obs.配置` 返回同一个 Python 对象(而非每次新建),确保 Python 端修改后 Rust 端可见 +- **分型模式**: 模块级 `chanlun.分型模式` getter/setter,控制分型值的读取策略 +- **数据文件兼容**: `chanlun.读取数据文件("file.nb", config, 观察员=obs)` 与 Python 版签名一致 +- **跨线程身份**: 全局 `LazyLock` 保证跨线程 `is` 判断一致 --- @@ -489,23 +971,29 @@ print(f"中枢数量: {len(obs.中枢序列)}") ### K线二进制格式 -K 线以大端字节序 `struct.pack(">6d")` 格式序列化,每根 48 字节: +与 Python `struct.pack(">6d")` 完全兼容的大端字节序格式,每根K线 48 字节: | 偏移 | 大小 | 字段 | |------|------|------| -| 0 | 8 | 时间戳 (Unix秒 as f64) | -| 8 | 8 | 开盘价 | -| 16 | 8 | 最高价 | -| 24 | 8 | 最低价 | -| 32 | 8 | 收盘价 | -| 40 | 8 | 成交量 | +| 0 | 8 字节 | 时间戳 (Unix秒 as f64) | +| 8 | 8 字节 | 开盘价 | +| 16 | 8 字节 | 最高价 | +| 24 | 8 字节 | 最低价 | +| 32 | 8 字节 | 收盘价 | +| 40 | 8 字节 | 成交量 | ```rust // 序列化 -let bytes = k线.to_bytes(); // Vec, 48 bytes +let bytes: [u8; 48] = k线.to_bytes(); // 反序列化 -let k线 = K线::读取大端字节数组(&bytes, 3600, "bar"); +let k线 = K线::读取大端字节数组(&bytes, 周期, 标识); + +// 解析原始数据(不构造K线) +let (时间戳, 开, 高, 低, 收, 量) = K线::解析原始数据(&bytes)?; + +// 批量保存 +K线::保存到DAT文件("output.dat", &[&k1, &k2])?; ``` ### 配置 JSON @@ -515,11 +1003,55 @@ let k线 = K线::读取大端字节数组(&bytes, 3600, "bar"); "标识": "BTCUSD", "笔内元素数量": 5, "平滑异同移动平均线_快线周期": 13, - "平滑异同移动平均线_慢线周期": 31 + "平滑异同移动平均线_慢线周期": 31, + "买卖点_指标模式": "配置", + "线段内部背驰_模式": "相对" } ``` -使用 `#[serde(default)]` 实现前向/后向兼容,缺失字段自动使用默认值。 +- 使用 `#[serde(default)]` 实现前向/后向兼容,缺失字段自动使用默认值 +- 字符串枚举字段(`指标计算方式`, `买卖点_指标模式`, `线段内部背驰_模式`)带有自定义反序列化器,非法值自动回退默认值并发出 `warn!` 日志 + +### 结构化相等校验 + +所有核心结构体都实现了 `相等(&self, other, 浮点容差) → (bool, String)` 方法,递归校验包括: +- 浮点字段使用容差比较(而非直接 `==`) +- `Arc` 指针共享的结构体深度校验值语义 +- 嵌套容器(Vec, Option, HashMap)逐一比对 +- 返回详细的差异描述,便于调试 + +--- + +## 测试 + +### 核心层测试 (`chanlun/`) + +```bash +cargo test # 运行所有测试 +cargo test -- test_重复计算 # 运行匹配模式的测试 +``` + +测试覆盖: +- **配置**: 默认值、JSON 往返、部分反序列化、非法值回退、不推送模式 +- **K线**: 方向判定、大端字节序列化往返 +- **缠K**: 创建/镜像/包含合并/分型识别、三K线生成分型 +- **分型**: 左中右构造、字段读写、关联指针 +- **虚线**: Cell/RefCell 读写、Clone 后 Rc 指针一致、多 Arc 共享可见性、获取武递归、武修改不影响文 +- **线段特征**: 文取max/min, 武取max/min, tiebreaker, 添加/删除 +- **中枢**: 字段读写、Clone 后指针一致、多 Arc 共享 +- **买卖点**: 偏移/失效偏移/有效性、18种类型生成 +- **观察者**: 指针一致性 (普K/分型/笔/线段/中枢)、重复计算确定性、重置后数据一致、RefCell 借用安全、跨线程 Send/Sync + +### Python 绑定层测试 (`chanlun-py/tests/test_all.py`) + +```bash +cd chanlun-py && maturin develop +python3 -m pytest tests/test_all.py -v +# 107+ tests, 涵盖: +# - 指标挂载与回填 +# - 跨线程身份一致性 (is 判断) +# - 买卖意义/趋向背驰/行为统计/停顿位置/线段内部背驰等 双端(Rust绑定 vs chan.py)数据对比 +``` --- @@ -532,5 +1064,5 @@ MIT License. 详见 [LICENSE](LICENSE). ## 相关项目 - [`chan.py`](../chan.py) — Python 参考实现(~4200 行) -- [`chanlun-py`](../chanlun-py/) — PyO3 绑定,将本 crate 导出为 Python 模块 -- [`main.py`](../main.py) — Python 图表程序入口 +- [`chanlun-py`](../chanlun-py/) — PyO3 绑定,将本 crate 导出为 Python `chanlun` 模块 +- [`main.py`](../main.py) — Python 图表可视化程序 diff --git a/chanlun/src/algorithm/divergence.rs b/chanlun/src/algorithm/divergence.rs index 455d3cb..cecc45f 100644 --- a/chanlun/src/algorithm/divergence.rs +++ b/chanlun/src/algorithm/divergence.rs @@ -222,6 +222,6 @@ struct MACD面积 { impl MACD面积 { fn 总(&self) -> f64 { - self.阳 + self.阴 + self.阳 + self.阴.abs() } } diff --git a/chanlun/src/algorithm/segment.rs b/chanlun/src/algorithm/segment.rs index e9b2745..8689f95 100644 --- a/chanlun/src/algorithm/segment.rs +++ b/chanlun/src/algorithm/segment.rs @@ -32,9 +32,8 @@ use crate::structure::dash_line::虚线; use crate::structure::fractal_obj::分型; use crate::structure::segment_feat::线段特征; use crate::types::{分型结构, 相对方向, 缺口}; -use cached::stores::LruCache; +use std::sync::Arc; use std::sync::atomic::Ordering; -use std::sync::{Arc, LazyLock, Mutex}; use tracing::warn; /// 线段 — 从笔生成线段的算法集合(静态方法命名空间) @@ -48,10 +47,6 @@ type 分割结果 = ( ); type 中枢序列组 = (Vec>, Vec>, Vec>); -/// 线段内部背驰判断缓存 — 仿照 dash_line.rs 的 买卖意义缓存 模式 -static 线段背驰缓存: LazyLock>> = - LazyLock::new(|| Mutex::new(LruCache::with_size(128))); - impl 线段 { // ================================================================ // 基础操作 @@ -1691,22 +1686,7 @@ impl 线段 { /// /// 分析线段的内部中枢和MACD柱分段,判断是否发生内部背驰 pub fn 判断线段内部是否背驰(当前段: &虚线, 观察员: &观察者) -> bool { - let key = ( - 当前段 as *const 虚线 as usize, - 观察员 as *const 观察者 as usize, - ); - { - let mut cache = 线段背驰缓存.lock().unwrap(); - if let Some(val) = cached::Cached::cache_get(&mut *cache, &key) { - return *val; - } - } - - let result = Self::判断线段内部是否背驰_impl(当前段, 观察员); - - let mut cache = 线段背驰缓存.lock().unwrap(); - cached::Cached::cache_set(&mut *cache, key, result); - result + Self::判断线段内部是否背驰_impl(当前段, 观察员) } fn 判断线段内部是否背驰_impl(当前段: &虚线, 观察员: &观察者) -> bool { diff --git a/chanlun/src/indicators/calculator.rs b/chanlun/src/indicators/calculator.rs index 6ea33af..1515809 100644 --- a/chanlun/src/indicators/calculator.rs +++ b/chanlun/src/indicators/calculator.rs @@ -34,18 +34,35 @@ pub struct 指标计算器; impl 指标计算器 { /// 增量计算所有开启的指标,将结果写入 当前K线.指标 /// - /// `现有序列` 不包含当前K线;prev 取自 现有序列.last() + /// `全序列` 包含当前K线(在末尾);prev 取自 全序列[..-1].last() /// 通过 RwLock 内部可变性,以 `&K线` 共享引用写入指标值 - pub fn 计算并挂载(当前K线: &K线, 现有序列: &[Arc], 配置: &缠论配置) { - let prev_guard = 现有序列.last().map(|k| k.指标.read().unwrap()); - let prev = prev_guard.as_deref(); - if 配置.计算指标 { - Self::_计算MACD组(当前K线, prev, 配置); - Self::_计算RSI组(当前K线, prev, 配置); - Self::_计算KDJ组(当前K线, prev, 配置); - Self::_计算BOLL组(当前K线, prev, 配置); + pub fn 计算并挂载(全序列: &[Arc], 配置: &缠论配置) { + let n = 全序列.len(); + let 当前K线 = &全序列[n - 1]; + let 现有序列 = if n > 1 { &全序列[..n - 1] } else { &[] }; + + // 作用域化 prev_guard:在 _回填新指标 之前释放,避免读锁与回填写锁冲突 + let has_prev; + { + let prev_guard = if n > 1 { + Some(全序列[n - 2].指标.read().unwrap()) + } else { + None + }; + let prev = prev_guard.as_deref(); + if 配置.计算指标 { + Self::_计算MACD组(当前K线, prev, 配置); + Self::_计算RSI组(当前K线, prev, 配置); + Self::_计算KDJ组(当前K线, prev, 配置); + Self::_计算BOLL组(当前K线, prev, 配置); + } + Self::_更新均线(当前K线, 现有序列, 配置); + has_prev = n > 1; + } // prev_guard dropped here + + if has_prev { + Self::_回填新指标(全序列, 配置); } - Self::_更新均线(当前K线, 现有序列, 配置); } fn _计算MACD组(当前K线: &K线, prev: Option<&指标容器>, 配置: &缠论配置) { @@ -298,4 +315,162 @@ impl 指标计算器 { } } } + + /// 运行中新增指标参数时,回填所有历史K线 + fn _回填新指标(全序列: &[Arc], 配置: &缠论配置) { + // 作用域化首尾读锁:在回填写循环之前释放,避免读锁与写锁冲突 + let (新MACD, 新RSI, 新KDJ, 新BOLL) = { + let 首K_guard = 全序列[0].指标.read().unwrap(); + let 尾K_guard = 全序列[全序列.len() - 1].指标.read().unwrap(); + + let 新MACD: Vec<_> = 配置 + ._解析MACD参数列表() + .into_iter() + .filter(|(key, _, _, _)| 尾K_guard.包含(key) && !首K_guard.包含(key)) + .collect(); + let 新RSI: Vec<_> = 配置 + ._解析RSI周期列表() + .into_iter() + .filter(|(key, _)| 尾K_guard.包含(key) && !首K_guard.包含(key)) + .collect(); + let 新KDJ: Vec<_> = 配置 + ._解析KDJ参数列表() + .into_iter() + .filter(|(key, _, _, _)| 尾K_guard.包含(key) && !首K_guard.包含(key)) + .collect(); + let 新BOLL: Vec<_> = 配置 + ._解析BOLL参数列表() + .into_iter() + .filter(|(key, _, _)| 尾K_guard.包含(key) && !首K_guard.包含(key)) + .collect(); + + (新MACD, 新RSI, 新KDJ, 新BOLL) + }; // 首K_guard, 尾K_guard dropped here + + if 新MACD.is_empty() && 新RSI.is_empty() && 新KDJ.is_empty() && 新BOLL.is_empty() { + return; + } + + let 计算方式 = &配置.指标计算方式; + + // 从第一根K线开始逐根回填,每次只持有一根prev读锁 + for i in 0..全序列.len() { + let k线 = &全序列[i]; + let prev_guard = if i > 0 { + Some(全序列[i - 1].指标.read().unwrap()) + } else { + None + }; + + for (key, 快, 慢, 信号) in &新MACD { + let val = if let Some(ref prev) = prev_guard { + if let Some(指标值::MACD(prev_macd)) = prev.获取(key) { + 指标值::MACD(平滑异同移动平均线::增量计算_K线( + prev_macd, + k线, + 计算方式, + )) + } else { + 指标值::MACD(平滑异同移动平均线::首次计算_K线( + k线, + 计算方式, + *快, + *慢, + *信号, + )) + } + } else { + 指标值::MACD(平滑异同移动平均线::首次计算_K线( + k线, + 计算方式, + *快, + *慢, + *信号, + )) + }; + k线.指标.write().unwrap().设置(key, val); + } + + for (key, 周期) in &新RSI { + let val = if let Some(ref prev) = prev_guard { + if let Some(指标值::RSI(prev_rsi)) = prev.获取(key) { + 指标值::RSI(相对强弱指数::增量计算_K线( + prev_rsi, + k线, + 计算方式, + )) + } else { + 指标值::RSI(相对强弱指数::首次计算_K线( + k线, + 计算方式, + *周期, + 配置.相对强弱指数_超买阈值, + 配置.相对强弱指数_超卖阈值, + Some(配置.相对强弱指数_移动平均线周期), + )) + } + } else { + 指标值::RSI(相对强弱指数::首次计算_K线( + k线, + 计算方式, + *周期, + 配置.相对强弱指数_超买阈值, + 配置.相对强弱指数_超卖阈值, + Some(配置.相对强弱指数_移动平均线周期), + )) + }; + k线.指标.write().unwrap().设置(key, val); + } + + for (key, rsv, k平滑, d平滑) in &新KDJ { + let val = if let Some(ref prev) = prev_guard { + if let Some(指标值::KDJ(prev_kdj)) = prev.获取(key) { + 指标值::KDJ(随机指标::增量计算_K线(prev_kdj, k线)) + } else { + 指标值::KDJ(随机指标::首次计算_K线( + k线, + *rsv, + *k平滑, + *d平滑, + 配置.随机指标_超买阈值, + 配置.随机指标_超卖阈值, + )) + } + } else { + 指标值::KDJ(随机指标::首次计算_K线( + k线, + *rsv, + *k平滑, + *d平滑, + 配置.随机指标_超买阈值, + 配置.随机指标_超卖阈值, + )) + }; + k线.指标.write().unwrap().设置(key, val); + } + + for (key, 周期, 标准差倍数) in &新BOLL { + let val = if let Some(ref prev) = prev_guard { + if let Some(指标值::BOLL(prev_boll)) = prev.获取(key) { + 指标值::BOLL(布林带::增量计算_K线(prev_boll, k线, 计算方式)) + } else { + 指标值::BOLL(布林带::首次计算_K线( + k线, + 计算方式, + *周期 as usize, + *标准差倍数, + )) + } + } else { + 指标值::BOLL(布林带::首次计算_K线( + k线, + 计算方式, + *周期 as usize, + *标准差倍数, + )) + }; + k线.指标.write().unwrap().设置(key, val); + } + } + } } diff --git a/chanlun/src/kline/chan_kline.rs b/chanlun/src/kline/chan_kline.rs index b33a900..d93f2c1 100644 --- a/chanlun/src/kline/chan_kline.rs +++ b/chanlun/src/kline/chan_kline.rs @@ -408,8 +408,7 @@ impl 缠论K线 { } // 计算指标: 对齐 Python,仅当 计算指标 开启时执行 if 配置.计算指标 { - let n = 普K序列.len(); - 指标计算器::计算并挂载(&普K序列[n - 1], &普K序列[..n - 1], 配置); + 指标计算器::计算并挂载(普K序列, 配置); } // ---- 阶段2: 缠K合并 ---- diff --git a/main.py b/main.py index d916601..6f11477 100644 --- a/main.py +++ b/main.py @@ -58,6 +58,11 @@ def Nil(*args, **kwargs): def 获取模块版本(): versions = {} + # 1. + try: + versions["chanlun"] = importlib.metadata.version("chanlun") + except importlib.metadata.PackageNotFoundError: + pass # 2. try: versions["fastapi"] = importlib.metadata.version("fastapi") @@ -1994,65 +1999,53 @@ async def 处理图表消息(用户标识: str, 消息字典: Dict, websocket: W 待发送消息 = {} if 数据类型 == "中枢<笔>": 待发送消息.update({"index": 序号, "data": str(观察员.笔_中枢序列[序号])}) - if 数据类型 == "中枢<线段>": - 待发送消息.update({"index": 序号, "data": str(观察员.中枢序列[序号])}) - - if 数据类型 == "中枢<线段<线段>>": - 待发送消息.update({"index": 序号, "data": str(观察员.线段_中枢序列[序号])}) - - if 数据类型 == "中枢<扩展线段>": - 待发送消息.update({"index": 序号, "data": str(观察员.扩展中枢序列[序号])}) - if 数据类型 == "中枢<扩展线段<线段>>": - 待发送消息.update({"index": 序号, "data": str(观察员.扩展中枢序列_线段[序号])}) - if 数据类型 == "笔": 待发送消息.update({"index": 序号, "data": str(观察员.笔序列[序号])}) - if 数据类型 == "线段": - 待发送消息.update({"index": 序号, "data": str(观察员.线段序列[序号])}) - 段: 虚线 = 观察员.线段序列[序号] - if 段._特征序列_显示: - 段._特征序列_显示 = False - for 特征 in 段.特征序列: - if 特征 is not None: - 观察员 and 观察员.报信(特征, 指令.删除(特征.标识), sys._getframe().f_lineno) - else: - 段._特征序列_显示 = True - 序号 = 0 - for 特征 in 段.特征序列: - if 特征 is not None: - 特征.序号 = 序号 - 特征.标识 = f"{段.文.中.标识}:{段.文.中.周期}:{段.标识}_特征序列_{序号}:{段.序号}" - 观察员 and 观察员.报信(特征, 指令.添加(特征.标识), sys._getframe().f_lineno) - 序号 += 1 - if 数据类型 == "扩展线段": - 待发送消息.update({"index": 序号, "data": str(观察员.扩展线段序列[序号])}) - if 数据类型 == "扩展线段<线段>": - 待发送消息.update({"index": 序号, "data": str(观察员.扩展线段序列_线段[序号])}) + if "中枢" in 数据类型 and 数据类型 != "中枢<笔>": + for i in range(观察员.中枢分析层次): + if 观察员.中枢序列组[i] and 观察员.中枢序列组[i][0].标识 == 数据类型: + 待发送消息.update({"index": 序号, "data": str(观察员.中枢序列组[i][序号])}) + for i in range(观察员.扩展中枢分析层次): + if 观察员.扩展中枢序列组[i] and 观察员.扩展中枢序列组[i][0].标识 == 数据类型: + 待发送消息.update({"index": 序号, "data": str(观察员.扩展中枢序列组[i][序号])}) + for i in range(观察员.混合扩展中枢分析层次): + if 观察员.混合扩展中枢序列组[i] and 观察员.混合扩展中枢序列组[i][0].标识 == 数据类型: + 待发送消息.update({"index": 序号, "data": str(观察员.混合扩展中枢序列组[i][序号])}) - if 数据类型 == "线段<线段>": - 待发送消息.update({"index": 序号, "data": str(观察员.线段_线段序列[序号])}) - 段 = 观察员.线段_线段序列[序号] - if 段._特征序列_显示: - 段._特征序列_显示 = False - for 特征 in 段.特征序列: - if 特征 is not None: - 观察员 and 观察员.报信(特征, 指令.删除(特征.标识), sys._getframe().f_lineno) + elif "线段" in 数据类型 and 数据类型 != "笔": + for i in range(观察员.线段分析层次): + if 观察员.线段序列组[i] and 观察员.线段序列组[i][0].标识 == 数据类型: + 待发送消息.update({"index": 序号, "data": str(观察员.线段序列组[i][序号])}) + 段 = 观察员.线段序列组[i][序号] + if 段._特征序列_显示: + 段._特征序列_显示 = False + for 特征 in 段.特征序列: + if 特征 is not None: + 观察员 and 观察员.报信(特征, 指令.删除(特征.标识), sys._getframe().f_lineno) - else: - 段._特征序列_显示 = True - 序号 = 0 - for 特征 in 段.特征序列: - if 特征 is not None: - 特征.序号 = 序号 - 特征.标识 = f"{段.文.右.标识}:{段.文.中.周期}:{段.标识}_特征序列_{序号}:{段.序号}" - 观察员 and 观察员.报信(特征, 指令.添加(特征.标识), sys._getframe().f_lineno) - 序号 += 1 + else: + 段._特征序列_显示 = True + 序号 = 0 + for 特征 in 段.特征序列: + if 特征 is not None: + 特征.序号 = 序号 + 特征.标识 = f"{段.文.中.标识}:{段.文.中.周期}:{段.标识}_特征序列_{序号}:{段.序号}" + 观察员 and 观察员.报信(特征, 指令.添加(特征.标识), sys._getframe().f_lineno) + 序号 += 1 + + for i in range(观察员.扩展线段分析层次): + if 观察员.扩展线段序列组[i] and 观察员.扩展线段序列组[i][0].标识 == 数据类型: + 待发送消息.update({"index": 序号, "data": str(观察员.扩展线段序列组[i][序号])}) + for i in range(观察员.混合扩展线段分析层次): + if 观察员.混合扩展线段序列组[i] and 观察员.混合扩展线段序列组[i][0].标识 == 数据类型: + 待发送消息.update({"index": 序号, "data": str(观察员.混合扩展线段序列组[i][序号])}) if "_" in 数据类型 and "中枢" in 数据类型: # 线段_0_实_中枢<笔> 数据类型, 线序, 虚实合, 类型 = 数据类型.split("_") 段序号 = int(线序) + if 数据类型 == "线段": 段: 虚线 = 观察员.线段序列[段序号] zs = getattr(段, f"{虚实合}_中枢序列")[序号] @@ -2063,6 +2056,26 @@ async def 处理图表消息(用户标识: str, 消息字典: Dict, websocket: W zs = getattr(段, f"{虚实合}_中枢序列")[序号] 待发送消息.update({"index": 序号, "data": str(zs)}) + for i in range(观察员.线段分析层次): + if 观察员.线段序列组[i] and 观察员.线段序列组[i][0].标识 == 数据类型: + 段 = 观察员.线段序列组[i][段序号] + zs = getattr(段, f"{虚实合}_中枢序列")[序号] + 待发送消息.update({"index": 序号, "data": str(zs)}) + + for i in range(观察员.扩展线段分析层次): + if 观察员.扩展线段序列组[i] and 观察员.扩展线段序列组[i][0].标识 == 数据类型: + 待发送消息.update({"index": 序号, "data": str(观察员.扩展线段序列组[i][序号])}) + 段 = 观察员.扩展线段序列组[i][序号] + zs = getattr(段, f"{虚实合}_中枢序列")[序号] + 待发送消息.update({"index": 序号, "data": str(zs)}) + + for i in range(观察员.混合扩展线段分析层次): + if 观察员.混合扩展线段序列组[i] and 观察员.混合扩展线段序列组[i][0].标识 == 数据类型: + 待发送消息.update({"index": 序号, "data": str(观察员.混合扩展线段序列组[i][序号])}) + 段 = 观察员.混合扩展线段序列组[i][序号] + zs = getattr(段, f"{虚实合}_中枢序列")[序号] + 待发送消息.update({"index": 序号, "data": str(zs)}) + await 全局连接管理器.发送信息(用户标识, {"type": "query_result", "success": True, "data_type": 数据类型, "data": 待发送消息}) except IndexError: