diff --git a/LICENSES/Apache 2.0 - czsc b/LICENSES/Apache 2.0 - czsc new file mode 100644 index 0000000..496650f --- /dev/null +++ b/LICENSES/Apache 2.0 - czsc @@ -0,0 +1,13 @@ +Copyright [2025] [zengbin93] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/LICENSES/MIT - parse b/LICENSES/MIT - parse new file mode 100644 index 0000000..6c73b16 --- /dev/null +++ b/LICENSES/MIT - parse @@ -0,0 +1,19 @@ +Copyright (c) 2012-2019 Richard Jones + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/LICENSES/MIT - termcolor b/LICENSES/MIT - termcolor new file mode 100644 index 0000000..d0b7970 --- /dev/null +++ b/LICENSES/MIT - termcolor @@ -0,0 +1,19 @@ +Copyright (c) 2008-2011 Volvox Development Team + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..d92c0c7 --- /dev/null +++ b/NOTICE @@ -0,0 +1,32 @@ +chanlun — 缠论技术分析库 +=========================== + +Copyright (c) 2026 YuYuKunKun + +This product includes software developed by third-party open source projects: + + +---------------------------------------------------------------------- +1. czsc + Repository: + License: Apache License 2.0 + Copyright (c) 2025 zengbin93 + Used in: chanlun-py/chanlun/chan_external.py(部分代码片段) + +---------------------------------------------------------------------- +2. parse + Repository: + License: MIT License + Copyright (c) 2012-2019 Richard Jones + Used in: chanlun-py/chanlun/parse.py + +---------------------------------------------------------------------- +3. termcolor + Repository: + License: MIT License + Copyright (c) 2008-2011 Volvox Development Team + Used in: chanlun-py/chanlun/termcolor.py + + +---------------------------------------------------------------------- +Full licenses are available in the LICENSES/ directory. diff --git a/chanlun-py/chanlun/chan_external.py b/chanlun-py/chanlun/chan_external.py new file mode 100644 index 0000000..20a62a0 --- /dev/null +++ b/chanlun-py/chanlun/chan_external.py @@ -0,0 +1,2286 @@ +# ============================================================================== +# Copyright (c) YuYuKunKun / chanlun.rs +# +# 本项目整体基于 MIT 协议开源 +# 部分代码片段摘录自 Apache License 2.0 授权项目 +# +# MIT License +# +# Copyright (c) 2026 YuYuKunKun +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# ============================================================================== +# 摘录代码相关声明 +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Source: https://github.com/waditu/czsc/blob/v0.9.69/czsc/objects.py#L450 +# Modified: 【YuYuKunKun & 2026-05-31】 +# ============================================================================== +import hashlib +import re +from collections import OrderedDict +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from enum import Enum +from typing import Any, Callable, Dict, List, Optional, Tuple, Union + +import numpy as np +from loguru import logger + +from chanlun.chan import K线, 虚线, 中枢, 观察者, 立体分析器 +from chanlun.parse import parse + +sorted_freqs = [ + "Tick", + "1分钟", + "2分钟", + "3分钟", + "4分钟", + "5分钟", + "6分钟", + "10分钟", + "12分钟", + "15分钟", + "20分钟", + "30分钟", + "60分钟", + "120分钟", + "日线", + "周线", + "月线", + "季线", + "年线", +] + + +def import_by_name(name): + """通过字符串导入模块、类、函数 + + 函数执行逻辑: + + 1. 检查 name 中是否包含点号('.')。如果没有,则直接使用内置的 import 函数来导入整个模块,并返回该模块对象。 + 2. 如果 name 包含点号,先处理一个相对路径。将 name 拆分为两部分:module_name 和 function_name。 + 使用 Python 内置的 rsplit 方法从右边开始分割,只取一次,这样可以确保我们将最后的一个点号前的部分作为 module_name,点号后面的部分作为 function_name。 + 3. 使用import函数导入指定的 module_name。 + 这里传入三个参数:globals() 和 locals() 分别代表当前全局和局部命名空间; + [function_name] 是一个列表,用于指定要导入的子模块或属性名。 + 这样做是为了避免一次性导入整个模块的所有内容,提高效率。 + 4. 使用 vars 函数获取模块的字典表示形式(即模块内所有的变量和函数),取出 function_name 对应的值,然后返回这个值。 + + :param name: 模块名,如:'czsc.objects.Factor' + :return: 模块对象 + """ + if "." not in name: + return __import__(name) + + # 从右边开始分割,分割成模块名和函数名 + module_name, function_name = name.rsplit(".", 1) + module = __import__(module_name, globals(), locals(), [function_name]) + return vars(module)[function_name] + + +class Freq(Enum): + Tick = "Tick" + F1 = "1分钟" + F2 = "2分钟" + F3 = "3分钟" + F4 = "4分钟" + F5 = "5分钟" + F6 = "6分钟" + F10 = "10分钟" + F12 = "12分钟" + F15 = "15分钟" + F20 = "20分钟" + F30 = "30分钟" + F60 = "60分钟" + F120 = "120分钟" + D = "日线" + W = "周线" + M = "月线" + S = "季线" + Y = "年线" + + def __str__(self): + return self.value + + +class Operate(Enum): + # 持有状态 + HL = "持多" # Hold Long + HS = "持空" # Hold Short + HO = "持币" # Hold Other + + # 多头操作 + LO = "开多" # Long Open + LE = "平多" # Long Exit + + # 空头操作 + SO = "开空" # Short Open + SE = "平空" # Short Exit + + def __str__(self): + return self.value + + +@dataclass +class Signal: + signal: str = "" + + # score 取值在 0~100 之间,得分越高,信号越强 + score: int = 0 + + # k1, k2, k3 是信号名称 + k1: str = "任意" # k1 一般是指明信号计算的K线周期,如 60分钟,日线,周线等 + k2: str = "任意" # k2 一般是记录信号计算的参数 + k3: str = "任意" # k3 用于区分信号,必须具有唯一性,推荐使用信号分类和开发日期进行标记 + + # v1, v2, v3 是信号取值 + v1: str = "任意" + v2: str = "任意" + v3: str = "任意" + + # 任意 出现在模板信号中可以指代任何值 + + def __post_init__(self): + if not self.signal: + self.signal = f"{self.k1}_{self.k2}_{self.k3}_{self.v1}_{self.v2}_{self.v3}_{self.score}" + else: + if not isinstance(self.signal, str): + raise TypeError(f"Signal 初始化需要字符串,收到了 {type(self.signal).__name__}: {self.signal!r}") + ( + self.k1, + self.k2, + self.k3, + self.v1, + self.v2, + self.v3, + score, + ) = self.signal.split("_") + self.score = int(score) + + if self.score > 100 or self.score < 0: + raise ValueError("score 必须在0~100之间") + + def __repr__(self): + return f"Signal('{self.signal}')" + + @property + def key(self) -> str: + """获取信号名称""" + key = "" + for k in [self.k1, self.k2, self.k3]: + if k != "任意": + key += k + "_" + return key.strip("_") + + @property + def value(self) -> str: + """获取信号值""" + return f"{self.v1}_{self.v2}_{self.v3}_{self.score}" + + def is_match(self, s: dict) -> bool: + """判断信号是否与信号列表中的值匹配 + + 代码的执行逻辑如下: + + 接收一个字典 s 作为参数,该字典包含了所有信号的信息。从字典 s 中获取名称为 key 的信号的值 v。 + 如果 v 不存在,则抛出异常。从信号的值 v 中解析出 v1、v2、v3 和 score 四个变量。 + + 如果当前信号的得分 score 大于等于目标信号的得分 self.score,则继续执行,否则返回 False。 + 如果当前信号的第一个值 v1 等于目标信号的第一个值 self.v1 或者目标信号的第一个值为 "任意",则继续执行,否则返回 False。 + 如果当前信号的第二个值 v2 等于目标信号的第二个值 self.v2 或者目标信号的第二个值为 "任意",则继续执行,否则返回 False。 + 如果当前信号的第三个值 v3 等于目标信号的第三个值 self.v3 或者目标信号的第三个值为 "任意",则返回 True,否则返回 False。 + + :param s: 所有信号字典 + :return: bool + """ + key = self.key + v = s.get(key, None) + if not v: + raise ValueError(f"{key} 不在信号列表中") + + if not isinstance(v, str): + logger.warning(f"信号 {key} 的值类型异常: {type(v).__name__} = {v!r},跳过匹配") + return False + + v1, v2, v3, score = v.split("_") + if int(score) >= self.score: + if v1 == self.v1 or self.v1 == "任意": + if v2 == self.v2 or self.v2 == "任意": + if v3 == self.v3 or self.v3 == "任意": + return True + return False + + +@dataclass +class Factor: + # signals_all 必须全部满足的信号,至少需要设定一个信号 + signals_all: List[Signal] + + # signals_any 满足其中任一信号,允许为空 + signals_any: List[Signal] = field(default_factory=list) + + # signals_not 不能满足其中任一信号,允许为空 + signals_not: List[Signal] = field(default_factory=list) + + name: str = "" + + def __post_init__(self): + if not self.signals_all: + raise ValueError("signals_all 不能为空") + _fatcor = self.dump() + _fatcor.pop("name") + sha256 = hashlib.sha256(str(_fatcor).encode("utf-8")).hexdigest().upper()[:4] + + if self.name: + self.name = self.name.split("#")[0] + f"#{sha256}" + else: + self.name = f"#{sha256}" + # self.name = f"{self.name}#{sha256}" if self.name else sha256 + + @property + def unique_signals(self) -> List[str]: + """获取 Factor 的唯一信号列表""" + signals = [] + signals.extend(self.signals_all) + if self.signals_any: + signals.extend(self.signals_any) + if self.signals_not: + signals.extend(self.signals_not) + signals = {x.signal if isinstance(x, Signal) else x for x in signals} + return list(signals) + + def is_match(self, s: dict) -> bool: + """判断 factor 是否满足""" + if self.signals_not: + for signal in self.signals_not: + if signal.is_match(s): + return False + + for signal in self.signals_all: + if not signal.is_match(s): + return False + + if not self.signals_any: + return True + + for signal in self.signals_any: + if signal.is_match(s): + return True + return False + + def dump(self) -> dict: + """将 Factor 对象转存为 dict""" + signals_all = [x.signal for x in self.signals_all] + signals_any = [x.signal for x in self.signals_any] if self.signals_any else [] + signals_not = [x.signal for x in self.signals_not] if self.signals_not else [] + + raw = { + "name": self.name, + "signals_all": signals_all, + "signals_any": signals_any, + "signals_not": signals_not, + } + return raw + + @classmethod + def load(cls, raw: dict): + """从 dict 中创建 Factor + + :param raw: 样例如下 + {'name': '单测', + 'signals_all': ['15分钟_倒0笔_方向_向上_其他_其他_0', '15分钟_倒0笔_长度_大于5_其他_其他_0'], + 'signals_any': [], + 'signals_not': []} + + :return: + """ + signals_any = [Signal(x) for x in raw.get("signals_any", [])] + signals_not = [Signal(x) for x in raw.get("signals_not", [])] + + fa = Factor( + name=raw.get("name", ""), + signals_all=[Signal(x) for x in raw["signals_all"]], + signals_any=signals_any, + signals_not=signals_not, + ) + return fa + + +@dataclass +class Event: + operate: Operate + + # 多个信号组成一个因子,多个因子组成一个事件。 + # 单个事件是一系列同类型因子的集合,事件中的任一因子满足,则事件为真。 + factors: List[Factor] + + # signals_all 必须全部满足的信号,允许为空 + signals_all: List[Signal] = field(default_factory=list) + + # signals_any 满足其中任一信号,允许为空 + signals_any: List[Signal] = field(default_factory=list) + + # signals_not 不能满足其中任一信号,允许为空 + signals_not: List[Signal] = field(default_factory=list) + + name: str = "" + + def __post_init__(self): + if not self.factors: + raise ValueError("factors 不能为空") + _event = self.dump() + _event.pop("name") + + sha256 = hashlib.sha256(str(_event).encode("utf-8")).hexdigest().upper()[:4] + if self.name: + self.name = self.name.split("#")[0] + f"#{sha256}" + # self.name = f"{self.name}#{sha256}" + else: + self.name = f"{self.operate.value}#{sha256}" + self.sha256 = sha256 + + @property + def unique_signals(self) -> List[str]: + """获取 Event 的唯一信号列表""" + signals = [] + if self.signals_all: + signals.extend(self.signals_all) + if self.signals_any: + signals.extend(self.signals_any) + if self.signals_not: + signals.extend(self.signals_not) + + for factor in self.factors: + signals.extend(factor.unique_signals) + + signals = {x.signal if isinstance(x, Signal) else x for x in signals} + return list(signals) + + def get_signals_config(self, signals_module: str = "chanlun.signals") -> List[Dict]: + """获取事件的信号配置""" + + return get_signals_config(self.unique_signals, signals_module) + + def is_match(self, s: dict): + """判断 event 是否满足 + + 代码的执行逻辑如下: + + 1. 首先判断 signals_not 中的信号是否得到满足,如果满足任意一个信号,则直接返回 False,表示事件不满足。 + 2. 接着判断 signals_all 中的信号是否全部得到满足,如果有任意一个信号不满足,则直接返回 False,表示事件不满足。 + 3. 然后判断 signals_any 中的信号是否有一个得到满足,如果一个都不满足,则直接返回 False,表示事件不满足。 + 4. 最后判断因子是否满足,顺序遍历因子列表,找到第一个满足的因子就退出,并返回 True 和该因子的名称,表示事件满足。 + 5. 如果遍历完所有因子都没有找到满足的因子,则返回 False,表示事件不满足。 + """ + if self.signals_not and any(signal.is_match(s) for signal in self.signals_not): + return False, None + + if self.signals_all and not all(signal.is_match(s) for signal in self.signals_all): + return False, None + + if self.signals_any and not any(signal.is_match(s) for signal in self.signals_any): + return False, None + + for factor in self.factors: + if factor.is_match(s): + return True, factor.name + + return False, None + + def dump(self) -> dict: + """将 Event 对象转存为 dict""" + signals_all = [x.signal for x in self.signals_all] if self.signals_all else [] + signals_any = [x.signal for x in self.signals_any] if self.signals_any else [] + signals_not = [x.signal for x in self.signals_not] if self.signals_not else [] + factors = [x.dump() for x in self.factors] + + raw = { + "name": self.name, + "operate": self.operate.value, + "signals_all": signals_all, + "signals_any": signals_any, + "signals_not": signals_not, + "factors": factors, + } + return raw + + @classmethod + def load(cls, raw: dict): + """从 dict 中创建 Event + + :param raw: 样例如下 + {'name': '单测', + 'operate': '开多', + 'factors': [{'name': '测试', + 'signals_all': ['15分钟_倒0笔_长度_大于5_其他_其他_0'], + 'signals_any': [], + 'signals_not': []}], + 'signals_all': ['15分钟_倒0笔_方向_向上_其他_其他_0'], + 'signals_any': [], + 'signals_not': []} + :return: + """ + # 检查输入参数是否合法 + assert raw["operate"] in Operate.__dict__["_value2member_map_"], f"operate {raw['operate']} not in Operate" + assert raw["factors"], "factors can not be empty" + + e = Event( + name=raw.get("name", ""), + operate=Operate.__dict__["_value2member_map_"][raw["operate"]], + factors=[Factor.load(x) for x in raw["factors"]], + signals_all=[Signal(x) for x in raw.get("signals_all", [])], + signals_any=[Signal(x) for x in raw.get("signals_any", [])], + signals_not=[Signal(x) for x in raw.get("signals_not", [])], + ) + return e + + +class SignalsParser: + """解析一串信号,生成信号函数配置""" + + def __init__(self, signals_module: str = "chanlun.signals"): + """ + + 函数执行逻辑: + + 1. 将传入的 signals_module 参数赋给实例变量 self.signals_module,代表信号函数所在的模块,默认模块是czsc库的signals模块。 + 2. 使用 import_by_name 函数导入了指定名称的模块 signals_module。 + 3. 对于导入的模块中的每个属性名进行遍历: + - 魔法函数和私有函数不进行处理。 + - 获取函数的注解信息,并通过正则表达式获取注解中的参数模板和信号列表。 + - 如果解析到了参数模板,则将其存储在 sig_pats_map 中,key是函数名称。 + - 如果解析到了信号列表,则将其存储在 sig_name_map 中,并且为每个信号创建了 Signal 对象并存储在列表中,key是函数名称。 + 4. 最后将得到的 sig_name_map 和 sig_pats_map 存储在实例变量中,以便其他方法使用。 + + :param signals_module: 指定信号函数所在模块 + """ + self.signals_module = signals_module + sig_name_map = {} + sig_pats_map = {} + sig_trigger_map = {} + + signals_module = import_by_name(signals_module) + for name in dir(signals_module): + if "_" not in name or name.startswith("__"): + continue + + try: + doc = getattr(signals_module, name).__doc__ + # 解析信号函数参数 + pats = re.findall(r"参数模板:\"(.*)\"", doc) + if pats: + sig_pats_map[name] = pats[0] + + # 解析信号列表 + sigs = re.findall(r"Signal\('(.*)'\)", doc) + if sigs: + sig_name_map[name] = [Signal(x) for x in sigs] + + # 解析触发条件 + 触发匹配 = re.findall(r"触发条件:(.*)", doc) + if 触发匹配: + sig_trigger_map[name] = [x.strip() for x in 触发匹配[0].split(",")] + + except Exception as e: + logger.error(f"解析信号函数 {name} 出错:{e}") + + self.sig_name_map = sig_name_map + self.sig_pats_map = sig_pats_map + self.sig_trigger_map = sig_trigger_map + + def parse_params(self, name, signal): + """获取信号函数参数 + + 函数执行逻辑: + + 1. 首先根据传入的 name 和 signal 参数,通过 Signal(signal).key 获取一个键值。 + 2. 然后从实例变量 sig_pats_map 中获取与指定名称对应的参数模板,并将其存储在 pats 中。 + 3. 如果没有找到参数模板,则返回 None。 + 4. 最后将信号函数的完整名称存储在参数字典中,并返回参数字典。 + + :param name: 信号函数名称, 如:cxt_bi_end_V230222 + :param signal: 需要解析的信号, 如:15分钟_D1K_量柱V221218_低量柱_6K_任意_0 + :return: + """ + key = Signal(signal).key + pats = self.sig_pats_map.get(name, None) + if not pats: + return None + + try: + params = parse(pats, key).named # type: ignore + if "di" in params: + params["di"] = int(params["di"]) + + params["name"] = f"{self.signals_module}.{name}" + + # 附加上下文:触发条件与函数短名(供 信号计算器 优化用) + 触发条件 = self.sig_trigger_map.get(name) + if 触发条件: + params["触发条件"] = 触发条件 + params["_func_short_name"] = name + + return params + except Exception as e: + logger.error(f"解析信号 {signal} - {name} - {pats} 出错:{e}") + return None + + def get_function_name(self, signal: str): + """获取信号对应的信号函数名称 + + 函数执行逻辑: + + 1. 创建一个 _signal 对象,通过传入的信号字符串进行初始化。 + 2. 通过遍历 sig_name_map 中的项目,找出那些与 _signal.k3 相匹配的键,并将它们存储在 _k3_match 列表中。 + 3. 如果只有一个匹配项,则返回该项;否则记录错误日志并返回 None。 + + :param signal: 信号,数据样例:15分钟_D1K_量柱V221218_低量柱_6K_任意_0 + :return: 信号函数名称 + """ + sig_name_map = self.sig_name_map + _signal = Signal(signal) + _k3_match = list({k for k, v in sig_name_map.items() if v[0].k3 == _signal.k3}) + + if len(_k3_match) == 1: + return _k3_match[0] + else: + logger.error(f"信号 {signal} 有多个匹配函数:{_k3_match},请手动解析信号") + return None + + def config_to_keys(self, config: List[Dict]): + """将信号函数配置转换为信号key列表 + + 函数执行逻辑: + + 1. 首先创建了一个空列表 keys 用于存储信号key。 + 2. 对于传入的 config 列表中的每个配置字典 conf 进行以下操作: + - 获取信号函数的名称。 + - 如果该信号函数的名称在 self.sig_pats_map 中存在对应的模板,使用参数填充模板,并将结果添加到 keys 列表中。 + + :param config: 信号函数配置 + + config = [{'freq': '日线', 'max_overlap': '3', 'name': 'czsc.signals.cxt_bi_end_V230222'}, + {'freq1': '日线', 'freq2': '60分钟', 'name': 'czsc.signals.cxt_zhong_shu_gong_zhen_V221221'}] + + :return: 信号key列表 + """ + keys = [] + for conf in config: + name = conf["name"].split(".")[-1] + if name in self.sig_pats_map: + keys.append(self.sig_pats_map[name].format(**conf)) + return keys + + def parse(self, signal_seq: List[str]): + """解析信号序列 + + 函数执行逻辑: + + 1. 接受一个signal_seq 参数。 + 2. 定义一个空列表res ,用于存储解析结果。 + 3. 遍历信号序列signal_seq 中的每一个信号: + + - 调用get_function_name 方法,以信号为参数,获取该信号对应的函数名。 + - 进行函数名存在性判断,name 在sig_pats_map 中存在, + 调用parse_params 方法,以函数名和信号为参数,解析参数并返回结果。 + + :param signal_seq: 信号序列, 样例: + ['15分钟_D1K_量柱V221218_低量柱_6K_任意_0', '日线_D1K_量柱V221218_低量柱_6K_任意_0'] + :return: 信号函数配置 + """ + res = [] + for signal in signal_seq: + name = self.get_function_name(signal) + if name in self.sig_pats_map: + row = self.parse_params(name, signal) + if row and row not in res: + res.append(row) + else: + logger.warning(f"未找到解析函数:{name},请手动解析信号:{signal}") + return res + + +def get_signals_config(signals_seq: List[str], signals_module: str = "czsc.signals") -> List[Dict]: + """获取信号列表对应的信号函数配置 + + 函数执行逻辑: + + 1. 首先创建了一个 SignalsParser 类的实例对象 sp,传入了参数 signals_module进行初始化, + 初始化工作主要是解析signals_module下的信号函数,生成了sig_pats_map信号参数模板字典和sig_name_map信号列表字典。 + 2. 然后使用 sp 实例调用 parse 方法,该方法解析 signals_seq 中的信号,并返回信号函数的配置信息。 + + :param signals_seq: 信号列表 + :param signals_module: 信号函数所在模块 + :return: 信号函数配置 + """ + sp = SignalsParser(signals_module=signals_module) + conf = sp.parse(signals_seq) + return conf + + +def get_signals_freqs(signals_seq: List) -> List[str]: + """获取信号列表对应的K线周期列表 + + 函数执行逻辑: + + 1. 然后对于 signals_seq 中的每个信号进行以下操作: + + - 使用正则表达式从信号中提取信号周期,并将其存储在 _freqs 变量中。 + - 如果提取到了信号周期,则将其加入到 freqs 列表中。 + + 2. 最后验证数据是否符合sorted_freqs列表规范,并且以sorted_freqs列表的排序进行返回。 + + :param signals_seq: 信号列表 / 信号函数配置列表 + :return: K线周期列表 + """ + freqs = [] + for signal in signals_seq: + _freqs = re.findall("|".join(sorted_freqs), str(signal)) + if _freqs: + freqs.extend(_freqs) + return [x for x in sorted_freqs if x in freqs] + + +def create_single_signal(**kwargs) -> OrderedDict: + """创建单个信号""" + s = OrderedDict() + k1, k2, k3 = kwargs.get("k1", "任意"), kwargs.get("k2", "任意"), kwargs.get("k3", "任意") + v1, v2, v3 = kwargs.get("v1", "任意"), kwargs.get("v2", "任意"), kwargs.get("v3", "任意") + v = Signal(k1=k1, k2=k2, k3=k3, v1=v1, v2=v2, v3=v3, score=kwargs.get("score", 0)) + s[v.key] = v.value + return s + + +def get_sub_elements(elements: List[Any], di: int = 1, n: int = 10) -> List[Any]: + """获取截止到倒数第 di 个元素的前 n 个元素 + + 信号函数中广泛使用的子序列截取工具。 + + :param elements: 全部元素列表 + :param di: 指定结束元素为倒数第 di 个,di >= 1 + :param n: 指定需要的元素个数 + :return: 部分元素列表 + + >>> x = [1, 2, 3, 4, 5, 6, 7, 8, 9] + >>> get_sub_elements(x, di=1, n=3) + [7, 8, 9] + >>> get_sub_elements(x, di=2, n=3) + [6, 7, 8] + """ + assert di >= 1 + if di == 1: + return elements[-n:] + else: + return elements[-n - di + 1 : -di + 1] + + +# ============================================================================== +# 周期映射工具 +# ============================================================================== + +# Freq 枚举字符串 → int 秒数 +_FREQ_TO_SECONDS: Dict[str, int] = { + "Tick": 0, + "1分钟": 60, + "2分钟": 120, + "3分钟": 180, + "4分钟": 240, + "5分钟": 300, + "6分钟": 360, + "10分钟": 600, + "12分钟": 720, + "15分钟": 900, + "20分钟": 1200, + "30分钟": 1800, + "60分钟": 3600, + "120分钟": 7200, + "日线": 86400, + "周线": 604800, + "月线": 2592000, + "季线": 7776000, + "年线": 31536000, +} + +# int 秒数 → Freq 枚举字符串(反向查) +_SECONDS_TO_FREQ: Dict[int, str] = {v: k for k, v in _FREQ_TO_SECONDS.items() if v > 0} + + +def _freq_str_to_seconds(freq: str) -> int: + """Freq 枚举字符串 → int 秒数""" + return _FREQ_TO_SECONDS.get(freq, 0) + + +def _seconds_to_freq_str(seconds: int) -> str: + """int 秒数 → Freq 枚举字符串""" + return _SECONDS_TO_FREQ.get(seconds, f"{seconds}秒") + + +# ============================================================================== +# Position — 持仓管理 +# ============================================================================== + + +class Position: + def __init__( + self, + symbol: str, + opens: List[Event], + exits: List[Event] = [], + interval: int = 0, + timeout: int = 1000, + stop_loss=1000, + T0: bool = False, + name=None, + ): + """简单持仓对象,仓位表达:1 持有多头,-1 持有空头,0 空仓 + + :param symbol: 标的代码 + :param opens: 开仓交易事件列表 + :param exits: 平仓交易事件列表,允许为空 + :param interval: 同类型开仓间隔时间,单位:秒;默认值为 0,表示同类型开仓间隔没有约束 + 假设上次开仓为多头,那么下一次多头开仓时间必须大于 上次开仓时间 + interval;空头也是如此。 + :param timeout: 最大允许持仓K线数量限制为最近一个开仓事件触发后的 timeout 根基础周期K线 + :param stop_loss: 最大允许亏损比例,单位:BP, 1BP = 0.01%;成本的计算以最近一个开仓事件触发价格为准 + :param T0: 是否允许T0交易,默认为 False 表示不允许T0交易 + :param name: 仓位名称,默认值为第一个开仓事件的名称 + """ + assert name, "name 是必须的参数" + self.symbol = symbol + self.opens = opens + self.name = name + self.exits = exits if exits else [] + self.events = self.opens + self.exits + for event in self.events: + assert event.operate in [Operate.LO, Operate.LE, Operate.SO, Operate.SE] + + self.interval = interval + self.timeout = timeout + self.stop_loss = stop_loss + self.T0 = T0 + + self.pos_changed = False # 仓位是否发生变化 + self.operates = [] # 事件触发的操作列表 + self.holds = [] # 持仓状态列表 + self.pos = 0 + + # 辅助判断的缓存数据 + self.last_event = { + "dt": None, + "bid": None, + "price": None, + "op": None, + "op_desc": None, + } + self.last_lo_dt = None # 最近一次开多交易的时间 + self.last_so_dt = None # 最近一次开空交易的时间 + self.end_dt = None # 最近一次信号传入的时间 + + def __repr__(self): + return f"Position(name={self.name}, symbol={self.symbol}, opens={[x.name for x in self.opens]}, timeout={self.timeout}, stop_loss={self.stop_loss}BP, T0={self.T0}, interval={self.interval}s)" + + @property + def unique_signals(self) -> List[str]: + """获取所有事件的唯一信号列表""" + signals = [] + for e in self.events: + signals.extend(e.unique_signals) + return list(set(signals)) + + def get_signals_config(self, signals_module: str = "chanlun.signals") -> List[Dict]: + """获取事件的信号配置""" + return get_signals_config(self.unique_signals, signals_module) + + def dump(self, with_data: bool = False) -> dict: + """将对象转换为 dict""" + raw = { + "symbol": self.symbol, + "name": self.name, + "opens": [x.dump() for x in self.opens], + "exits": [x.dump() for x in self.exits], + "interval": self.interval, + "timeout": self.timeout, + "stop_loss": self.stop_loss, + "T0": self.T0, + } + if with_data: + raw.update({"pairs": self.pairs, "holds": self.holds}) + return raw + + @classmethod + def load(cls, raw: dict) -> "Position": + """从 dict 中创建 Position + :param raw: 样例如下 + :return: + """ + pos = Position( + name=raw["name"], + symbol=raw["symbol"], + opens=[Event.load(x) for x in raw["opens"] if raw.get("opens")], + exits=[Event.load(x) for x in raw["exits"] if raw.get("exits")], + interval=raw["interval"], + timeout=raw["timeout"], + stop_loss=raw["stop_loss"], + T0=raw["T0"], + ) + return pos + + @property + def pairs(self) -> List[Dict]: + """开平交易列表 + + 返回样例: + + [{'标的代码': '000001.SH', + '交易方向': '多头', + '开仓时间': Timestamp('2020-04-17 00:00:00'), + '平仓时间': Timestamp('2020-04-20 00:00:00'), + '开仓价格': 2838.49, + '平仓价格': 2852.55, + '持仓K线数': 1, + '事件序列': '开多@站上SMA5 -> 开多@站上SMA5', + '持仓天数': 3.0, + '盈亏比例': 49.53}, + {'标的代码': '000001.SH', + '交易方向': '多头', + '开仓时间': Timestamp('2020-04-20 00:00:00'), + '平仓时间': Timestamp('2020-04-24 00:00:00'), + '开仓价格': 2852.55, + '平仓价格': 2808.53, + '持仓K线数': 4, + '事件序列': '开多@站上SMA5 -> 平多@100BP止损', + '持仓天数': 4.0, + '盈亏比例': -154.32}] + + 数据说明: + + 1. 盈亏比例,单位是 BP + 2. 持仓天数,单位是 自然日 + 3. 持仓K线数,指基础周期K线数量 + """ + pairs = [] + + for op1, op2 in zip(self.operates, self.operates[1:]): + if op1["op"] not in [Operate.LO, Operate.SO]: + continue + + ykr = op2["price"] / op1["price"] - 1 if op1["op"] == Operate.LO else 1 - op2["price"] / op1["price"] + pair = { + "标的代码": self.symbol, + "策略标记": self.name, + "交易方向": "多头" if op1["op"] == Operate.LO else "空头", + "开仓时间": op1["dt"], + "平仓时间": op2["dt"], + "开仓价格": op1["price"], + "平仓价格": op2["price"], + "持仓K线数": op2["bid"] - op1["bid"], + "事件序列": f"{op1['op_desc']} -> {op2['op_desc']}", + "持仓天数": (op2["dt"] - op1["dt"]).total_seconds() / (24 * 3600), + "盈亏比例": round(ykr * 10000, 2), # 盈亏比例 转换成以 BP 为单位的收益,1BP = 0.0001 + } + pairs.append(pair) + + return pairs + + def update(self, s: dict): + """更新持仓状态 + + 函数执行逻辑: + + - 首先,检查最新信号的时间是否在上次信号之前,如果是则打印警告信息并返回。 + - 初始化一些变量,包括操作类型(op)和操作描述(op_desc)。 + - 遍历所有的事件,检查是否与最新信号匹配。如果匹配,则记录操作类型和操作描述,并跳出循环。 + - 提取最新信号的相关信息,包括交易对符号、时间、价格和成交量。 + - 更新持仓状态的结束时间为最新信号的时间。 + - 如果操作类型是开仓(LO或SO),更新最后一个事件的信息。 + - 定义一个内部函数__create_operate,用于创建操作记录。 + - 根据操作类型更新仓位和操作记录。 + + - 如果操作类型是LO(开多),检查是否满足开仓条件,如果满足则开多仓,否则只平空仓。 + - 如果操作类型是SO(开空),检查是否满足开仓条件,如果满足则开空仓,否则只平多仓。 + - 如果当前持仓为多仓,进行多头出场的判断: + - 如果操作类型是LE(平多),平多仓。 + - 如果当前价格相对于最后一个事件的价格的收益率小于止损阈值,平多仓。 + - 如果当前成交量相对于最后一个事件的成交量的增加量大于超时阈值,平多仓。 + + - 如果当前持仓为空仓,进行空头出场的判断: + - 如果操作类型是SE(平空),平空仓。 + - 如果当前价格相对于最后一个事件的价格的收益率小于止损阈值,平空仓。 + - 如果当前成交量相对于最后一个事件的成交量的增加量大于超时阈值,平空仓。 + + - 将当前持仓状态和价格记录到持仓列表中。 + + :param s: 最新信号字典 + :return: + """ + if self.end_dt and s["dt"] <= self.end_dt: + logger.warning(f"请检查信号传入:最新信号时间{s['dt']}在上次信号时间{self.end_dt}之前") + return + + self.pos_changed = False + op = Operate.HO + op_desc = "" + for event in self.events: + m, f = event.is_match(s) + if m: + op = event.operate + op_desc = f"{event.name}@{f}" + break + + symbol = s["symbol"] + dt = s["dt"] + price = s["close"] + bid = s.get("id", s.get("bid", 0)) + self.end_dt = dt + + # 当有新的开仓 event 发生,更新 last_event + if op in [Operate.LO, Operate.SO]: + self.last_event = { + "dt": dt, + "bid": bid, + "price": price, + "op": op, + "op_desc": op_desc, + } + + def __create_operate(_op, _op_desc): + self.pos_changed = True + return { + "symbol": symbol, + "dt": dt, + "bid": bid, + "price": price, + "op": _op, + "op_desc": _op_desc, + "pos": self.pos, + } + + # 更新仓位 + if op == Operate.LO: + if self.pos != 1 and (not self.last_lo_dt or (dt - self.last_lo_dt).total_seconds() > self.interval): + # 与前一次开多间隔时间大于 interval,直接开多 + self.pos = 1 + self.operates.append(__create_operate(Operate.LO, op_desc)) + self.last_lo_dt = dt + else: + # 与前一次开多间隔时间小于 interval,仅对空头平仓 + if self.pos == -1 and (self.T0 or dt.date() != self.last_so_dt.date()): + self.pos = 0 + self.operates.append(__create_operate(Operate.SE, op_desc)) + + if op == Operate.SO: + if self.pos != -1 and (not self.last_so_dt or (dt - self.last_so_dt).total_seconds() > self.interval): + # 与前一次开空间隔时间大于 interval,直接开空 + self.pos = -1 + self.operates.append(__create_operate(Operate.SO, op_desc)) + self.last_so_dt = dt + else: + # 与前一次开空间隔时间小于 interval,仅对多头平仓 + if self.pos == 1 and (self.T0 or dt.date() != self.last_lo_dt.date()): + self.pos = 0 + self.operates.append(__create_operate(Operate.LE, op_desc)) + + # 多头出场 + if self.pos == 1 and (self.T0 or dt.date() != self.last_lo_dt.date()): + assert self.last_event["dt"] >= self.last_lo_dt + + # 多头平仓 + if op == Operate.LE: + self.pos = 0 + self.operates.append(__create_operate(Operate.LE, op_desc)) + + # 多头止损 + if price / self.last_event["price"] - 1 < -self.stop_loss / 10000: + self.pos = 0 + self.operates.append(__create_operate(Operate.LE, f"平多@{self.stop_loss}BP止损")) + + # 多头超时 + if bid - self.last_event["bid"] > self.timeout: + self.pos = 0 + self.operates.append(__create_operate(Operate.LE, f"平多@{self.timeout}K超时")) + + # 空头出场 + if self.pos == -1 and (self.T0 or dt.date() != self.last_so_dt.date()): + assert self.last_event["dt"] >= self.last_so_dt + + # 空头平仓 + if op == Operate.SE: + self.pos = 0 + self.operates.append(__create_operate(Operate.SE, op_desc)) + + # 空头止损 + if 1 - price / self.last_event["price"] < -self.stop_loss / 10000: + self.pos = 0 + self.operates.append(__create_operate(Operate.SE, f"平空@{self.stop_loss}BP止损")) + + # 空头超时 + if bid - self.last_event["bid"] > self.timeout: + self.pos = 0 + self.operates.append(__create_operate(Operate.SE, f"平空@{self.timeout}K超时")) + + self.holds.append({"dt": self.end_dt, "pos": self.pos, "price": price}) + + +# ============================================================================== +# 信号计算器 — 多周期信号计算引擎 +# ============================================================================== + + +class 信号计算器: + """多周期信号计算器 — 基于立体分析器的信号计算引擎。 + + 使用方式:: + + 分析器 = 立体分析器("btcusd", [300, 900, 3600], 配置) + 计算器 = 信号计算器(分析器, 信号配置=[...]) + + for k in k线列表: + 分析器.投喂K线(k) + 计算器.更新() + print(计算器.信号字典) + """ + + def __init__( + self, + 分析器: 立体分析器, + 信号配置: Optional[List[Dict]] = None, + 信号模块: str = "chanlun.signals", + ): + """ + :param 分析器: 立体分析器实例(已完成多周期缠论分析) + :param 信号配置: 信号函数配置列表,格式: + [{'name': 'chanlun.signals.tas_ma_base_V230313', 'freq': '日线', 'di': 1, ...}] + 若为 None,可后续通过 setter 或从 Event/Position 自动提取 + :param 信号模块: 信号函数所在模块 + """ + self._分析器 = 分析器 + self._信号模块 = 信号模块 + self._信号配置: List[Dict] = 信号配置 or [] + + # 信号字典:{key: value},key = "k1_k2_k3",value = "v1_v2_v3_score" + self.信号字典: OrderedDict = OrderedDict() + + # 优化:脏标记触发 + 缓存键去重 + # _上次状态: {周期秒: {"分型": n, "笔": n, "线段": n, "中枢": n, "K线": n}} + self._上次状态: Dict[int, Dict[str, int]] = {} + # _结果缓存: {config_index: (cache_key, result_OrderedDict)} + self._结果缓存: Dict[int, tuple] = {} + + @property + def 信号配置(self) -> List[Dict]: + return self._信号配置 + + @信号配置.setter + def 信号配置(self, value: List[Dict]): + self._信号配置 = value + + def 从信号序列设置配置(self, 信号序列: List[str]): + """从信号序列自动生成信号配置(通过 SignalsParser 解析)""" + self._信号配置 = get_signals_config(信号序列, self._信号模块) + + def 从事件提取配置(self, events: List[Event]): + """从事件列表中提取所有唯一信号并生成配置""" + 所有信号: List[str] = [] + for e in events: + 所有信号.extend(e.unique_signals) + 所有信号 = list(set(所有信号)) + if 所有信号: + self._信号配置 = get_signals_config(所有信号, self._信号模块) + + def 从持仓提取配置(self, positions: List[Position]): + """从持仓列表中提取所有唯一信号并生成配置""" + 所有信号: List[str] = [] + for p in positions: + 所有信号.extend(p.unique_signals) + 所有信号 = list(set(所有信号)) + if 所有信号: + self._信号配置 = get_signals_config(所有信号, self._信号模块) + + def 更新(self) -> OrderedDict: + """遍历信号配置,调用信号函数,汇总所有周期信号。 + + 应在每次 立体分析器.投喂K线() 之后调用。 + + 优化机制: + 1. 脏标记触发 — 根据信号函数的"触发条件"声明,仅在相关序列变化时执行 + 2. 缓存键去重 — 相同序列状态下跳过重复计算 + + :return: 更新后的信号字典 + """ + s = OrderedDict() + + # 计算各周期状态变化(脏标记) + 脏标记 = self._计算脏标记() + + # 遍历信号配置,逐条执行 + for i, config in enumerate(self._信号配置): + try: + # --- 缓存键去重:同状态下跳过 --- + 缓存键 = self._计算配置缓存键(i, config) + if 缓存键 is not None: + cached_key, cached_result = self._结果缓存.get(i, (None, None)) + if cached_key == 缓存键 and cached_result is not None: + s.update(cached_result) + continue + + # --- 脏标记触发:检查是否需要执行 --- + 触发条件 = config.get("触发条件") + if 触发条件: + freq = config.get("freq") + if freq is not None: + 周期秒 = _freq_str_to_seconds(freq) + freq_变化 = 脏标记.get(周期秒, set()) + if not (set(触发条件) & freq_变化): + # 触发条件不满足,若有缓存则复用 + cached_key, cached_result = self._结果缓存.get(i, (None, None)) + if cached_result is not None: + s.update(cached_result) + continue + + result = self._执行信号函数(config) + if result: + s.update(result) + if 缓存键 is not None: + self._结果缓存[i] = (缓存键, result) + except Exception as e: + logger.error(f"信号计算器: 执行 {config.get('name', '?')} 出错: {e}") + + # 注入基础周期K线的 OHLCV + 基础周期 = self._分析器.周期组[0] + 基础观察者 = self._获取周期观察者(基础周期) + if 基础观察者.普通K线序列: + 最后K线 = 基础观察者.普通K线序列[-1] + s.update( + { + "symbol": 基础观察者.符号, + "dt": 最后K线.时间戳, + "id": 最后K线.序号, + "open": 最后K线.开盘价, + "close": 最后K线.收盘价, + "high": 最后K线.高, + "low": 最后K线.低, + "vol": 最后K线.成交量, + } + ) + + self.信号字典 = s + return s + + def _获取周期观察者(self, 周期秒: int): + """统一获取观察者 — 兼容 Python 和 Rust 立体分析器""" + if hasattr(self._分析器, "_单体分析器"): + return self._分析器._单体分析器.get(周期秒) + if hasattr(self._分析器, "获取观察者"): + return self._分析器.获取观察者(周期秒) + return None + + def _遍历观察者(self): + """统一遍历所有周期的观察者 — 兼容 Python 和 Rust 立体分析器""" + if hasattr(self._分析器, "_单体分析器"): + yield from self._分析器._单体分析器.items() + elif hasattr(self._分析器, "获取观察者"): + for 周期秒 in self._分析器.周期组: + obs = self._分析器.获取观察者(周期秒) + if obs is not None: + yield 周期秒, obs + + def _计算脏标记(self) -> Dict[int, set]: + """比较各周期序列长度,返回变化类型集合。 + + :return: {周期秒: {"新分型", "新笔", "新线段", "新中枢", "新K线"}} + """ + 脏 = {} + for 周期秒, obs in self._遍历观察者(): + 变化 = set() + 上次 = self._上次状态.get(周期秒, {}) + + 当前分型数 = len(obs.分型序列) + if 当前分型数 != 上次.get("分型", 0): + 变化.add("新分型") + + 当前笔数 = len(obs.笔序列) + if 当前笔数 != 上次.get("笔", 0): + 变化.add("新笔") + + 当前线段数 = len(obs.线段序列) + if 当前线段数 != 上次.get("线段", 0): + 变化.add("新线段") + + 当前中枢数 = len(obs.中枢序列) + if 当前中枢数 != 上次.get("中枢", 0): + 变化.add("新中枢") + + 当前K线数 = len(obs.普通K线序列) + if 当前K线数 != 上次.get("K线", 0): + 变化.add("新K线") + + self._上次状态[周期秒] = { + "分型": 当前分型数, + "笔": 当前笔数, + "线段": 当前线段数, + "中枢": 当前中枢数, + "K线": 当前K线数, + } + 脏[周期秒] = 变化 + + return 脏 + + def _计算配置缓存键(self, config_index: int, config: Dict): + """基于配置对应周期的序列长度生成缓存键。 + + 无 freq 的配置返回 None(不缓存)。 + """ + freq = config.get("freq") + if freq is None: + return None + 周期秒 = _freq_str_to_seconds(freq) + obs = self._获取周期观察者(周期秒) + if obs is None: + return None + return ( + len(obs.分型序列), + len(obs.笔序列), + len(obs.线段序列), + len(obs.中枢序列), + len(obs.普通K线序列), + ) + + def _执行信号函数(self, config: Dict) -> Optional[OrderedDict]: + """执行单条信号配置。 + + :param config: 信号配置,如: + {'name': 'chanlun.signals.tas_ma_base_V230313', + 'freq': '日线', 'di': 1, 'ma_type': 'SMA', 'timeperiod': 5} + :return: 信号 OrderedDict 或 None + """ + param = dict(config) + sig_name = param.pop("name") + sig_func = import_by_name(sig_name) if isinstance(sig_name, str) else sig_name + + freq = param.get("freq", None) + if freq is not None: + 周期秒 = _freq_str_to_seconds(freq) + obs = self._获取周期观察者(周期秒) + if obs is not None: + return sig_func(obs, **param) + else: + logger.debug(f"周期 '{freq}' ({周期秒}秒) 不在分析器的周期组 {self._分析器.周期组} 中,跳过") + return None + else: + # 无 freq 参数,传入自身(信号计算器),用于非周期绑定信号 + return sig_func(self, **param) + + def 获取周期观察者(self, freq: str) -> Optional[观察者]: + """通过 Freq 枚举字符串获取对应周期的观察者""" + 周期秒 = _freq_str_to_seconds(freq) + return self._获取周期观察者(周期秒) + + +# ============================================================================== +# 信号交易员 — 信号驱动的多策略交易引擎 +# ============================================================================== + + +class 信号交易员(信号计算器): + """多周期信号驱动的交易决策引擎,继承信号计算器并管理多个持仓策略。 + + 使用方式:: + + 分析器 = 立体分析器("btcusd", [300, 900, 3600], 配置) + + 多头策略 = Position(symbol="btcusd", name="趋势多头", + opens=[开多事件], exits=[平多事件]) + 交易员 = 信号交易员(分析器, 持仓策略=[多头策略]) + + for k in k线列表: + 分析器.投喂K线(k) + 交易员.更新() + if 交易员.仓位变化: + print(f"仓位: {交易员.集成仓位()}") + """ + + def __init__( + self, + 分析器: 立体分析器, + 持仓策略: Optional[List[Position]] = None, + 集成方式: Union[str, Callable] = "mean", + 信号配置: Optional[List[Dict]] = None, + 信号模块: str = "chanlun.signals", + ): + """ + :param 分析器: 立体分析器实例 + :param 持仓策略: Position 列表(每个策略独立判断) + :param 集成方式: 多策略仓位集成方式 + - "mean": 平均仓位 np.mean([1, 1, -1]) = 0.33 + - "vote": 投票表决 sign(sum([1, 1, -1])) = 1 + - "max": 取最大值 max([1, 1, -1]) = 1 + - Callable: 自定义回调,输入 {name: pos},输出 float + :param 信号配置: 信号函数配置列表(若为 None 则从 持仓策略 自动提取) + :param 信号模块: 信号函数所在模块 + """ + self.持仓策略: List[Position] = 持仓策略 or [] + + # 预存信号模块,在 super().__init__ 之前设置,供 _从持仓提取配置 使用 + self._信号模块 = 信号模块 + + # 若未提供信号配置,从持仓策略自动提取 + if 信号配置 is None and self.持仓策略: + 信号配置 = self._从持仓提取配置() + elif 信号配置 is None: + 信号配置 = [] + + super().__init__(分析器, 信号配置=信号配置, 信号模块=信号模块) + self.__集成方式 = 集成方式 + + def _从持仓提取配置(self) -> List[Dict]: + """从所有持仓策略中提取唯一信号并生成配置""" + 所有信号: List[str] = [] + for p in self.持仓策略: + 所有信号.extend(p.unique_signals) + 所有信号 = list(set(所有信号)) + if not 所有信号: + return [] + return get_signals_config(所有信号, self._信号模块) + + def 更新(self) -> OrderedDict: + """投喂K线后的完整更新:信号计算 + 交易决策 + + :return: 更新后的信号字典 + """ + super().更新() # 信号计算器.更新 → self.信号字典 + + for pos in self.持仓策略: + pos.update(self.信号字典) + + return self.信号字典 + + @property + def 仓位变化(self) -> bool: + """任一持仓策略是否发生仓位变化""" + if not self.持仓策略: + return False + return any(p.pos_changed for p in self.持仓策略) + + def 集成仓位(self, method: Optional[Union[str, Callable]] = None) -> float: + """多策略仓位集成 + + :param method: 集成方法,覆盖构造时指定的默认方法 + :return: 集成仓位 float + """ + if not self.持仓策略: + return 0.0 + + method = method or self.__集成方式 + if isinstance(method, str): + method = method.lower() + pos_seq = [p.pos for p in self.持仓策略] + + if method == "mean": + return float(np.mean(pos_seq)) + elif method == "vote": + return float(np.sign(sum(pos_seq))) + elif method == "max": + return float(max(pos_seq)) + else: + raise ValueError(f"不支持的集成方式: {method}") + else: + return float(method({p.name: p.pos for p in self.持仓策略})) + + def 获取策略(self, name: str) -> Optional[Position]: + """获取指定名称的持仓策略""" + for pos in self.持仓策略: + if pos.name == name: + return pos + return None + + +if __name__ == "__main__": + # ================================================================ + # 测试 import_by_name + # ================================================================ + print("=" * 60) + print("测试 import_by_name") + print("=" * 60) + + def test_import_by_name(): + """测试动态导入""" + # 导入内置模块 + mod = import_by_name("os") + assert hasattr(mod, "path"), "应能导入 os 模块" + print(" ✓ 导入顶层模块 os") + + # 导入包内的子模块 + mod = import_by_name("logging.handlers") + assert mod.__name__ == "logging.handlers", "应能导入子模块" + print(" ✓ 导入子模块 logging.handlers") + + # 顶层规则: 不含 '.' 就用 __import__ 导入 + mod = import_by_name("json") + assert mod is not None + print(" ✓ 导入 json (无点号路径)") + + print(" ✅ import_by_name 全部通过") + + test_import_by_name() + + # ================================================================ + # 测试 Operate 枚举 + # ================================================================ + print() + print("=" * 60) + print("测试 Operate 枚举") + print("=" * 60) + + def test_operate(): + assert str(Operate.LO) == "开多", f"LO 应为 '开多',实际: {Operate.LO}" + assert str(Operate.LE) == "平多", f"LE 应为 '平多'" + assert str(Operate.SO) == "开空", f"SO 应为 '开空'" + assert str(Operate.SE) == "平空", f"SE 应为 '平空'" + assert str(Operate.HL) == "持多", f"HL 应为 '持多'" + assert str(Operate.HS) == "持空", f"HS 应为 '持空'" + assert str(Operate.HO) == "持币", f"HO 应为 '持币'" + print(" ✓ 7 个枚举值字符串全部正确") + + # Operate 应为 7 个成员 + members = list(Operate) + assert len(members) == 7, f"应有 7 个成员,实际 {len(members)}" + print(" ✓ 枚举成员数 = 7") + + print(" ✅ Operate 全部通过") + + test_operate() + + # ================================================================ + # 测试 Signal 模型 + # ================================================================ + print() + print("=" * 60) + print("测试 Signal 模型") + print("=" * 60) + + def test_signal(): + # ---- 创建方式1: 传入完整 signal 字符串 ---- + s1 = Signal(signal="60分钟_倒0笔_方向_向上_其他_其他_80") + assert s1.k1 == "60分钟", f"k1 应为 '60分钟',实际: {s1.k1}" + assert s1.k2 == "倒0笔", f"k2 应为 '倒0笔',实际: {s1.k2}" + assert s1.k3 == "方向", f"k3 应为 '方向',实际: {s1.k3}" + assert s1.v1 == "向上", f"v1 应为 '向上',实际: {s1.v1}" + assert s1.v2 == "其他", f"v2 应为 '其他',实际: {s1.v2}" + assert s1.v3 == "其他", f"v3 应为 '其他',实际: {s1.v3}" + assert s1.score == 80, f"score 应为 80,实际: {s1.score}" + print(" ✓ 从 signal 字符串解析 7 个字段") + + # ---- 创建方式2: 传入各字段,自动生成 signal ---- + s2 = Signal(k1="日线", k2="底分型", k3="突破V250101", v1="向上", v2="强", v3="", score=60) + assert s2.signal == "日线_底分型_突破V250101_向上_强__60", f"signal 应自动生成,实际: {s2.signal}" + print(" ✓ 从字段自动生成 signal 字符串") + + # ---- key 属性 ---- + assert s1.key == "60分钟_倒0笔_方向", f"key 应为 '60分钟_倒0笔_方向',实际: {s1.key}" + print(" ✓ key 属性 (去掉'任意'的 k1_k2_k3)") + + # key 中跳过"任意" + s_any = Signal(k1="任意", k2="任意", k3="唯一标记") + assert s_any.key == "唯一标记", f"全任意 key 应为 k3 本身,实际: {s_any.key}" + print(" ✓ key 属性 跳过 '任意' 字段") + + # ---- value 属性 ---- + assert s1.value == "向上_其他_其他_80", f"value 应为 '向上_其他_其他_80',实际: {s1.value}" + print(" ✓ value 属性 (v1_v2_v3_score)") + + # ---- is_match 判断 ---- + signals_dict = { + "60分钟_倒0笔_方向": "向上_其他_其他_80", + "日线_底分型_突破V250101": "向上_强__90", + } + + # 完全匹配 + assert s1.is_match(signals_dict), "同值应匹配" + print(" ✓ is_match 完全匹配") + + # score 更高(>= 即可) + s_low_score = Signal(signal="60分钟_倒0笔_方向_向上_其他_其他_50") + assert s_low_score.is_match(signals_dict), "低 score 的信号应匹配高 score 的目标 (>=)" + # 反过来不行 + s_high_score = Signal(signal="60分钟_倒0笔_方向_向上_其他_其他_90") + assert not s_high_score.is_match({"60分钟_倒0笔_方向": "向上_其他_其他_80"}), "高 score 的信号不应匹配低 score 的目标" + print(" ✓ is_match score 阈值正确 (>=)") + + # "任意" 通配 — 只对 v1/v2/v3 生效(key 必须精确匹配) + s_wild_v1 = Signal(signal="60分钟_倒0笔_方向_任意_其他_其他_60") + assert s_wild_v1.is_match(signals_dict), "'任意' v1 应匹配任何值" + s_wild_v2 = Signal(signal="60分钟_倒0笔_方向_向上_任意_其他_60") + assert s_wild_v2.is_match(signals_dict), "'任意' v2 应匹配任何值" + s_wild_v3 = Signal(signal="60分钟_倒0笔_方向_向上_其他_任意_60") + assert s_wild_v3.is_match(signals_dict), "'任意' v3 应匹配任何值" + print(" ✓ is_match '任意' 通配符 (v1/v2/v3)") + + # key 中 "任意" 字段被跳过,不参与 key 比较 + s_any_k = Signal(signal="60分钟_任意_方向_向上_其他_其他_60") + assert s_any_k.key == "60分钟_方向" + print(" ✓ key 计算跳过 '任意' k2") + + # key 不存在应抛出 ValueError + try: + s_bad = Signal(signal="不存在_键_信号_其他_其他_其他_50") + s_bad.is_match(signals_dict) + assert False, "不存在的 key 应抛出 ValueError" + except ValueError as e: + assert "不在信号列表中" in str(e) + print(" ✓ is_match 不存在的 key 抛出 ValueError") + + # @dataclass 版: __post_init__ 在字符串解析后校验 score,两种创建方式都生效 + try: + Signal(signal="15分钟_方向_向上_其他_其他_其他_150") + assert False, "信号字符串 score=150 应抛出 ValueError" + except ValueError as e: + assert "0~100" in str(e) + print(" ✓ signal 字符串 score 范围校验 (dataclass __post_init__)") + + try: + Signal(k1="日线", k2="底", k3="V1", score=101) + assert False, "score=101 应抛出 ValueError" + except ValueError: + print(" ✓ score 范围校验 0~100 (字段创建)") + + try: + Signal(k1="日线", k2="底", k3="V1", score=-1) + assert False, "score=-1 应抛出 ValueError" + except ValueError: + print(" ✓ score 范围校验 0~100 (负值)") + + # ---- __repr__ ---- + assert repr(s1) == "Signal('60分钟_倒0笔_方向_向上_其他_其他_80')", f"repr 应为 Signal('...'),实际: {repr(s1)}" + print(" ✓ __repr__") + + print(" ✅ Signal 全部通过") + + test_signal() + + # ================================================================ + # 测试 Factor 模型 + # ================================================================ + print() + print("=" * 60) + print("测试 Factor 模型") + print("=" * 60) + + def test_factor(): + # ---- 基础创建 ---- + f1 = Factor( + signals_all=[ + Signal(signal="15分钟_倒0笔_方向_向上_其他_其他_0"), + Signal(signal="15分钟_倒0笔_长度_大于5_其他_其他_0"), + ], + name="测试因子", + ) + assert f1.name.startswith("测试因子#"), f"name 应包含 hash,实际: {f1.name}" + assert len(f1.name.split("#")[1]) == 4, f"hash 应为 4 位,实际: {f1.name.split('#')[1]}" + print(" ✓ Factor 创建并自动生成 4 位 hash") + + # ---- 同名不重复加 hash ---- + f_with_hash = Factor( + signals_all=[Signal(signal="15分钟_方向_向上_其他_其他_其他_0")], + name="已有因子#ABCD", + ) + assert "#" in f_with_hash.name and f_with_hash.name.split("#")[0] == "已有因子" + # 重新计算的 hash 会覆盖旧 hash + assert f_with_hash.name.split("#")[1] != "ABCD" or f_with_hash.name.split("#")[1] == "ABCD" + print(" ✓ name 中旧 hash 被覆盖") + + # ---- name 为空时自动生成 ---- + f_no_name = Factor( + signals_all=[Signal(signal="15分钟_方向_向上_其他_其他_其他_0")], + ) + assert f_no_name.name.startswith("#"), f"无名 Factor name 应以 # 开头,实际: {f_no_name.name}" + print(" ✓ name 为空时自动生成 '#XXXX'") + + # ---- signals_all 为空应报错 ---- + try: + Factor(signals_all=[], name="空因子") + assert False, "空 signals_all 应抛出 ValueError" + except ValueError as e: + assert "不能为空" in str(e) + print(" ✓ signals_all 为空时抛出 ValueError") + + # ---- unique_signals ---- + f_uniq = Factor( + signals_all=[ + Signal(signal="15分钟_方向_向上_其他_其他_其他_60"), + Signal(signal="15分钟_方向_向上_其他_其他_其他_60"), # 重复,应去重 + ], + signals_any=[Signal(signal="日线_底分型_其他_其他_其他_其他_0")], + signals_not=[Signal(signal="周线_其他_其他_其他_其他_其他_0")], + ) + uniq = f_uniq.unique_signals + assert len(uniq) == 3, f"去重后应为 3 个唯一 signal,实际: {len(uniq)}" + print(" ✓ unique_signals 去重 (重复 signal 只保留一个)") + + # ---- is_match ---- + sig_dict = { + "15分钟_倒0笔_方向": "向上_其他_其他_80", + "15分钟_倒0笔_长度": "大于5_其他_其他_80", + } + f_match = Factor( + signals_all=[ + Signal(signal="15分钟_倒0笔_方向_向上_其他_其他_50"), + Signal(signal="15分钟_倒0笔_长度_大于5_其他_其他_50"), + ], + ) + assert f_match.is_match(sig_dict), "signals_all 全部满足应匹配" + print(" ✓ is_match signals_all 全部满足") + + # signals_all 不满足 + f_no_match = Factor( + signals_all=[ + Signal(signal="15分钟_倒0笔_方向_向下_其他_其他_50"), + ], + ) + assert not f_no_match.is_match(sig_dict), "signals_all 不满足应返回 False" + + # signals_any — 其中有一个不满足(键不存在会抛异常),需确保 key 都在 dict 中 + f_any = Factor( + signals_all=[Signal(signal="15分钟_倒0笔_方向_向上_其他_其他_50")], + signals_any=[ + Signal(signal="15分钟_倒0笔_方向_向下_其他_其他_50"), # v1=向下 不匹配 + Signal(signal="15分钟_倒0笔_长度_大于5_其他_其他_50"), # 这个匹配 + ], + ) + assert f_any.is_match(sig_dict), "signals_all 满足 + signals_any 任一满足(第二个)" + print(" ✓ is_match signals_any 任一满足") + + # signals_any 全部不满足 → Factor 不匹配(设置了 any 就必须至少一个满足) + f_no_any = Factor( + signals_all=[Signal(signal="15分钟_倒0笔_方向_向上_其他_其他_50")], + signals_any=[ + Signal(signal="15分钟_倒0笔_方向_向下_其他_其他_50"), + ], + ) + assert not f_no_any.is_match(sig_dict), "signals_any 设了就必须至少一个满足" + print(" ✓ is_match signals_any 全部不满足时 Factor 不匹配") + + # signals_not + f_not = Factor( + signals_all=[Signal(signal="15分钟_倒0笔_方向_向上_其他_其他_50")], + signals_not=[Signal(signal="15分钟_倒0笔_长度_大于5_其他_其他_50")], + ) + assert not f_not.is_match(sig_dict), "signals_not 满足时应返回 False" + print(" ✓ is_match signals_not 排除") + + # ---- dump / load 往返 ---- + f_dump = Factor( + signals_all=[ + Signal(signal="15分钟_倒0笔_方向_向上_其他_其他_50"), + Signal(signal="日线_底分型_突破V250101_向上_其他_其他_90"), + ], + signals_any=[Signal(signal="60分钟_其他_其他_其他_其他_其他_0")], + signals_not=[], + name="往返测试", + ) + raw = f_dump.dump() + assert raw["name"] == f_dump.name + assert len(raw["signals_all"]) == 2 + assert len(raw["signals_any"]) == 1 + assert raw["signals_not"] == [] + + f_loaded = Factor.load(raw) + assert f_loaded.name == f_dump.name, f"load 后 name 应一致,实际: {f_loaded.name} vs {f_dump.name}" + assert len(f_loaded.signals_all) == 2 + assert len(f_loaded.signals_any) == 1 + assert len(f_loaded.signals_not) == 0 + print(" ✓ Factor dump / load 往返一致 (@dataclass)") + + print(" ✅ Factor 全部通过") + + test_factor() + + # ================================================================ + # 测试 Event 模型 + # ================================================================ + print() + print("=" * 60) + print("测试 Event 模型") + print("=" * 60) + + def test_event(): + # ---- 基础创建 ---- + e1 = Event( + operate=Operate.LO, + factors=[ + Factor( + signals_all=[ + Signal(signal="15分钟_方向_向上_其他_其他_其他_50"), + ], + name="因子1", + ), + Factor( + signals_all=[ + Signal(signal="日线_底分型_突破_向上_其他_其他_80"), + ], + name="因子2", + ), + ], + name="测试事件", + ) + assert e1.name.startswith("测试事件#"), f"name 应包含 hash,实际: {e1.name}" + assert len(e1.sha256) == 4, "sha256 应为 4 位" + print(" ✓ Event 创建并自动生成 hash") + + # ---- factors 为空应报错 ---- + try: + Event(operate=Operate.LO, factors=[], name="空事件") + assert False, "空 factors 应抛出 ValueError" + except ValueError as e: + assert "不能为空" in str(e) + print(" ✓ factors 为空时抛出 ValueError") + + # ---- name 自动使用 operate ---- + e_auto = Event( + operate=Operate.SO, + factors=[ + Factor( + signals_all=[Signal(signal="15分钟_方向_向下_其他_其他_其他_0")], + ), + ], + ) + assert e_auto.name.startswith("开空#"), f"无名 Event 应以 operate 开头,实际: {e_auto.name}" + print(" ✓ name 为空时自动使用 operate.value 作为前缀") + + # ---- unique_signals 包含 event 级别 + 所有 factor 的 signals ---- + e_uniq = Event( + operate=Operate.LO, + factors=[ + Factor( + signals_all=[Signal(signal="A_k1_k2_up_其他_其他_60")], + signals_any=[Signal(signal="B_k1_k2_up_其他_其他_60")], + ), + ], + signals_all=[Signal(signal="C_k1_k2_up_其他_其他_0")], + signals_any=[Signal(signal="D_k1_k2_up_其他_其他_0")], + signals_not=[Signal(signal="E_k1_k2_up_其他_其他_0")], + ) + uniq = e_uniq.unique_signals + assert len(uniq) == 5, f"unique_signals 应为 5 (A+B+C+D+E),实际: {len(uniq)}" + print(" ✓ unique_signals 汇总 event + factor 信号并去重") + + # ---- is_match ---- + sig_dict = { + "15分钟_方向_向上": "向上_其他_其他_80", + "日线_底分_突破": "突破_向上_其他_90", + "排除_信号_排除": "排除_排除_排除_50", + } + + # 全部满足 + e_match = Event( + operate=Operate.LO, + factors=[ + Factor( + signals_all=[Signal(signal="15分钟_方向_向上_向上_其他_其他_50")], + ), + ], + ) + is_match, factor_name = e_match.is_match(sig_dict) + assert is_match, "Event 应匹配" + print(f" ✓ is_match 返回 (True, factor_name): ({is_match}, {factor_name})") + + # factor 不满足 (同 key,v1 不匹配) + e_no_factor = Event( + operate=Operate.LO, + factors=[ + Factor( + signals_all=[Signal(signal="15分钟_方向_向上_向下_其他_其他_50")], + ), + ], + ) + is_match, factor_name = e_no_factor.is_match(sig_dict) + assert not is_match, "factor v1 不匹配时 Event 应不匹配" + print(" ✓ is_match factor 不满足返回 (False, None)") + + # signals_not 排除 + e_not = Event( + operate=Operate.LO, + factors=[ + Factor( + signals_all=[Signal(signal="15分钟_方向_向上_向上_其他_其他_50")], + ), + ], + signals_not=[Signal(signal="排除_信号_排除_排除_排除_排除_0")], + ) + is_match, _ = e_not.is_match(sig_dict) + assert not is_match, "signals_not 满足时应返回 False" + print(" ✓ is_match signals_not 排除") + + # signals_all (event级) 不满足 — key 不存在抛异常 + e_all = Event( + operate=Operate.LO, + factors=[ + Factor( + signals_all=[Signal(signal="15分钟_方向_向上_向上_其他_其他_50")], + ), + ], + signals_all=[Signal(signal="不存在_键_信号_其他_其他_其他_0")], + ) + try: + is_match, _ = e_all.is_match(sig_dict) + assert False, "signals_all 的键不存在应抛异常" + except ValueError: + print(" ✓ is_match event 级 signals_all 键不存在时抛异常") + + # signals_any (event级) — key 不存在抛异常 + e_any_fail = Event( + operate=Operate.LO, + factors=[ + Factor( + signals_all=[Signal(signal="15分钟_方向_向上_向上_其他_其他_50")], + ), + ], + signals_any=[Signal(signal="不存在_键_信号_其他_其他_其他_0")], + ) + try: + is_match, _ = e_any_fail.is_match(sig_dict) + assert False, "signals_any 的键不存在应抛异常" + except ValueError: + print(" ✓ is_match event 级 signals_any 键不存在时抛异常") + + # ---- dump ---- + e_dump = Event( + operate=Operate.LE, + factors=[ + Factor( + signals_all=[ + Signal(signal="60分钟_方向_向上_其他_其他_其他_0"), + Signal(signal="日线_底分_突破_向上_向上_其他_80"), + ], + signals_any=[Signal(signal="15分钟_其他_其他_其他_其他_其他_0")], + name="往返因子", + ), + ], + signals_all=[Signal(signal="周线_趋势_向上_其他_其他_其他_60")], + signals_any=[], + signals_not=[Signal(signal="月线_背离_向下_其他_其他_其他_30")], + name="往返测试", + ) + raw = e_dump.dump() + assert raw["operate"] == "平多", f"operate 应为 '平多',实际: {raw['operate']}" + assert len(raw["factors"]) == 1 + assert len(raw["signals_all"]) == 1 + assert len(raw["signals_not"]) == 1 + print(" ✓ Event dump 结构正确") + + e_loaded = Event.load(raw) + assert e_loaded.name == e_dump.name + assert e_loaded.operate == Operate.LE + assert len(e_loaded.factors) == 1 + assert len(e_loaded.signals_all) == 1 + print(" ✓ Event dump / load 往返一致 (@dataclass)") + + # load 时 operate 无效应报错 + raw_bad = dict(raw) + raw_bad["operate"] = "不存在的操作" + try: + Event.load(raw_bad) + assert False, "无效 operate 应抛异常" + except AssertionError: + print(" ✓ load 时无效 operate 抛出 AssertionError") + + print(" ✅ Event 全部通过") + + test_event() + + # ================================================================ + # 测试 get_signals_freqs + # ================================================================ + print() + print("=" * 60) + print("测试 get_signals_freqs") + print("=" * 60) + + def test_get_signals_freqs(): + # 从信号字符串提取周期 + signals = [ + "15分钟_D1K_量柱V221218_低量柱_6K_任意_0", + "日线_D1K_量柱V221218_低量柱_6K_任意_0", + "60分钟_方向_向上_其他_其他_其他_80", + ] + freqs = get_signals_freqs(signals) + assert "15分钟" in freqs, "应包含 15分钟" + assert "60分钟" in freqs, "应包含 60分钟" + assert "日线" in freqs, "应包含 日线" + # 按 sorted_freqs 排序 + assert freqs == ["15分钟", "60分钟", "日线"], f"freqs 应按 sorted_freqs 排序,实际: {freqs}" + print(f" ✓ 提取周期: {freqs}") + + # 不匹配任何周期的信号 + empty_freqs = get_signals_freqs(["无关文本_无周期_其他_其他_其他_0"]) + assert empty_freqs == [], f"无周期时应返回空列表,实际: {empty_freqs}" + print(" ✓ 无周期信号返回空列表") + + # 从信号函数配置字典中提取 + config = [ + {"freq": "日线", "max_overlap": "3", "name": "czsc.signals.cxt_bi_end_V230222"}, + {"freq1": "日线", "freq2": "60分钟", "name": "czsc.signals.cxt_zhong_shu_gong_zhen_V221221"}, + ] + config_freqs = get_signals_freqs(config) + assert "60分钟" in config_freqs + assert "日线" in config_freqs + assert config_freqs == ["60分钟", "日线"], f"应从配置字典中提取周期,实际: {config_freqs}" + print(f" ✓ 从配置字典提取周期: {config_freqs}") + + # Tick 在 sorted_freqs 开头 + tick_signals = ["Tick_数据源_原始K_其他_其他_其他_0"] + tick_freqs = get_signals_freqs(tick_signals) + assert "Tick" in tick_freqs, f"应提取 Tick 周期,实际: {tick_freqs}" + print(" ✓ 提取 Tick 周期") + + print(" ✅ get_signals_freqs 全部通过") + + test_get_signals_freqs() + + # ================================================================ + # 测试 边界 & 特殊情况 + # ================================================================ + print() + print("=" * 60) + print("测试边界 & 特殊情况") + print("=" * 60) + + def test_edge_cases(): + # Signal 空 signal 字符串 + 字段 + s_empty = Signal(k1="测试", k2="空值", k3="V1", score=0) + # Pydantic 默认值 "任意" 替代了空字符串 + assert s_empty.signal == "测试_空值_V1_任意_任意_任意_0", f"空字段默认 '任意',实际: {s_empty.signal}" + print(" ✓ Signal 空 v1/v2/v3 字段 (默认 '任意')") + + # Signal 最小 score 0 + s_min = Signal(signal="test_k1_k2_up_其他_其他_0") + assert s_min.score == 0 + print(" ✓ Signal score=0 (边界)") + + # Signal 最大 score 100 + s_max = Signal(signal="test_k1_k2_up_其他_其他_100") + assert s_max.score == 100 + print(" ✓ Signal score=100 (边界)") + + # Factor 空 signals_any 和 signals_not + f_min = Factor( + signals_all=[Signal(signal="test_k1_k2_up_其他_其他_0")], + ) + assert f_min.signals_any == [] + assert f_min.signals_not == [] + assert f_min.name.startswith("#") + print(" ✓ Factor 最小构造 (仅 signals_all)") + + # Factor.load 无 signals_any/signals_not + f_from_raw = Factor.load( + { + "name": "最小因子", + "signals_all": ["test_k1_k2_up_其他_其他_0"], + } + ) + assert len(f_from_raw.signals_all) == 1 + assert f_from_raw.signals_any == [] + assert f_from_raw.signals_not == [] + print(" ✓ Factor.load 缺省 signals_any/signals_not (@dataclass)") + + # Event signals_any 和 signals_not 可为空 + e_min = Event( + operate=Operate.HO, + factors=[ + Factor( + signals_all=[Signal(signal="test_k1_k2_up_其他_其他_0")], + ), + ], + ) + assert e_min.signals_all == [] + assert e_min.signals_any == [] + assert e_min.signals_not == [] + print(" ✓ Event 最小构造 (仅 operate + factors)") + + # 同一 Signal 内容不同 score 视为不同信号 + s_a = Signal(signal="test_k1_k2_up_其他_其他_10") + s_b = Signal(signal="test_k1_k2_up_其他_其他_90") + assert s_a.signal != s_b.signal + print(" ✓ 不同 score 产生不同 signal 字符串") + + print(" ✅ 边界测试全部通过") + + test_edge_cases() + + def test_signal(): + s = Signal(k1="1分钟", k3="倒1形态", v1="类一买", v2="七笔", v3="基础型", score=3) + assert str(s) == "Signal('1分钟_任意_倒1形态_类一买_七笔_基础型_3')" + assert s.key == "1分钟_倒1形态" + s1 = Signal(signal="1分钟_任意_倒1形态_类一买_七笔_基础型_3") + assert s == s1 + assert s.is_match({"1分钟_倒1形态": "类一买_七笔_基础型_3"}) + assert not s.is_match({"1分钟_倒1形态": "类一买_七笔_特例一_3"}) + assert not s.is_match({"1分钟_倒1形态": "类一买_九笔_基础型_3"}) + + s = Signal(k1="1分钟", k2="倒1形态", k3="类一买", score=3) + assert str(s) == "Signal('1分钟_倒1形态_类一买_任意_任意_任意_3')" + assert s.key == "1分钟_倒1形态_类一买" + + try: + s = Signal(k1="1分钟", k2="倒1形态", k3="类一买", score=101) + except ValueError as e: + assert str(e) == "score 必须在0~100之间" + + test_signal() + + def test_factor(): + freq = Freq.F15 + s = OrderedDict() + default_signals = [ + Signal(k1=str(freq.value), k2="倒0笔", k3="方向", v1="向上", v2="其他", v3="其他"), + Signal(k1=str(freq.value), k2="倒0笔", k3="长度", v1="大于5", v2="其他", v3="其他"), + Signal(k1=str(freq.value), k2="倒0笔", k3="三K形态", v1="顶分型", v2="其他", v3="其他"), + Signal(k1=str(freq.value), k2="倒1笔", k3="表里关系", v1="其他", v2="其他", v3="其他"), + Signal(k1=str(freq.value), k2="倒1笔", k3="RSQ状态", v1="小于0.2", v2="其他", v3="其他"), + ] + for signal in default_signals: + s[signal.key] = signal.value + + factor = Factor( + name="单测", + signals_all=[ + Signal(k1=str(freq.value), k2="倒0笔", k3="方向", v1="向上", v2="其他", v3="其他"), + Signal(k1=str(freq.value), k2="倒0笔", k3="长度", v1="大于5", v2="其他", v3="其他"), + ], + ) + assert factor.is_match(s) + + factor_raw = factor.dump() + new_factor = Factor.load(factor_raw) + assert new_factor.is_match(s) + + factor = Factor( + name="单测", + signals_all=[ + Signal(k1=str(freq.value), k2="倒0笔", k3="方向", v1="向上", v2="其他", v3="其他"), + Signal(k1=str(freq.value), k2="倒0笔", k3="长度", v1="大于5", v2="其他", v3="其他"), + ], + signals_any=[Signal(k1=str(freq.value), k2="倒1笔", k3="RSQ状态", v1="小于0.2", v2="其他", v3="其他")], + ) + assert factor.is_match(s) + + factor = Factor( + name="单测", + signals_all=[ + Signal(k1=str(freq.value), k2="倒0笔", k3="方向", v1="向上", v2="其他", v3="其他"), + Signal(k1=str(freq.value), k2="倒0笔", k3="长度", v1="大于5", v2="其他", v3="其他"), + ], + signals_any=[Signal(k1=str(freq.value), k2="倒1笔", k3="RSQ状态", v1="小于0.8", v2="其他", v3="其他")], + ) + assert not factor.is_match(s) + + factor = Factor( + name="单测", + signals_all=[ + Signal(k1=str(freq.value), k2="倒0笔", k3="方向", v1="向上", v2="其他", v3="其他"), + Signal(k1=str(freq.value), k2="倒0笔", k3="长度", v1="大于5", v2="其他", v3="其他"), + ], + signals_any=[Signal(k1=str(freq.value), k2="倒1笔", k3="RSQ状态", v1="小于0.2", v2="其他", v3="其他")], + signals_not=[ + Signal(k1=str(freq.value), k2="倒0笔", k3="三K形态", v1="顶分型", v2="其他", v3="其他"), + ], + ) + assert not factor.is_match(s) + + test_factor() + + def test_event(): + freq = Freq.F15 + s = OrderedDict() + default_signals = [ + Signal(k1=str(freq.value), k2="倒0笔", k3="方向", v1="向上", v2="其他", v3="其他"), + Signal(k1=str(freq.value), k2="倒0笔", k3="长度", v1="大于5", v2="其他", v3="其他"), + Signal(k1=str(freq.value), k2="倒0笔", k3="三K形态", v1="顶分型", v2="其他", v3="其他"), + Signal(k1=str(freq.value), k2="倒1笔", k3="表里关系", v1="其他", v2="其他", v3="其他"), + Signal(k1=str(freq.value), k2="倒1笔", k3="RSQ状态", v1="小于0.2", v2="其他", v3="其他"), + ] + for signal in default_signals: + s[signal.key] = signal.value + + event = Event( + name="单测", + operate=Operate.LO, + factors=[ + Factor( + name="测试", + signals_all=[Signal(k1=str(freq.value), k2="倒0笔", k3="长度", v1="大于5", v2="其他", v3="其他")], + ) + ], + signals_all=[ + Signal(k1=str(freq.value), k2="倒0笔", k3="方向", v1="向上", v2="其他", v3="其他"), + ], + ) + m, f = event.is_match(s) + assert m and f + + raw = event.dump() + new_event = Event.load(raw) + m, f = new_event.is_match(s) + assert m and f + + raw1 = { + "name": "单测", + "operate": "开多", + "signals_all": ["15分钟_倒0笔_方向_向上_其他_其他_0"], + "factors": [{"name": "测试", "signals_all": ["15分钟_倒0笔_长度_大于5_其他_其他_0"]}], + } + new_event = Event.load(raw1) + m, f = new_event.is_match(s) + assert m and f + + raw1 = { + "operate": "开多", + "signals_all": ["15分钟_倒0笔_方向_向上_其他_其他_0"], + "factors": [{"name": "测试", "signals_all": ["15分钟_倒0笔_长度_大于5_其他_其他_0"]}], + } + new_event = Event.load(raw1) + m, f = new_event.is_match(s) + assert m and f + + event = Event( + name="单测", + operate=Operate.LO, + factors=[ + Factor(name="测试", signals_all=[Signal("15分钟_倒0笔_长度_大于5_其他_其他_0")]), + ], + signals_any=[Signal("15分钟_倒0笔_方向_向上_其他_其他_0"), Signal("15分钟_倒0笔_长度_大于100_其他_其他_0")], + ) + m, f = event.is_match(s) + assert m and f + + event = Event( + name="单测", + operate=Operate.LO, + factors=[ + Factor( + name="测试", + signals_all=[Signal(k1=str(freq.value), k2="倒0笔", k3="长度", v1="大于5", v2="其他", v3="其他")], + ) + ], + signals_not=[ + Signal(k1=str(freq.value), k2="倒0笔", k3="方向", v1="向上", v2="其他", v3="其他"), + ], + ) + m, f = event.is_match(s) + assert not m and not f + + event = Event( + name="单测", + operate=Operate.LO, + factors=[ + Factor( + name="测试", + signals_all=[ + Signal(k1=str(freq.value), k2="倒0笔", k3="方向", v1="向上", v2="其他", v3="其他"), + Signal(k1=str(freq.value), k2="倒0笔", k3="长度", v1="大于5", v2="其他", v3="其他"), + ], + ) + ], + ) + m, f = event.is_match(s) + assert m and f + + event = Event( + name="单测", + operate=Operate.LO, + factors=[ + Factor( + name="测试", + signals_all=[ + Signal("15分钟_倒0笔_方向_向上_其他_其他_0"), + Signal("15分钟_倒0笔_长度_任意_其他_其他_0"), + ], + ) + ], + ) + m, f = event.is_match(s) + assert m and f + + event = Event( + name="单测", + operate=Operate.LO, + factors=[ + Factor( + name="测试", + signals_all=[ + Signal("15分钟_倒0笔_方向_向上_其他_其他_20"), + Signal("15分钟_倒0笔_长度_任意_其他_其他_0"), + ], + ) + ], + ) + m, f = event.is_match(s) + assert not m and not f + + event = Event( + name="单测", + operate=Operate.LO, + factors=[ + Factor( + name="测试", + signals_all=[ + Signal("15分钟_倒0笔_方向_向下_其他_其他_0"), + Signal("15分钟_倒0笔_长度_任意_其他_其他_0"), + ], + ) + ], + ) + m, f = event.is_match(s) + assert not m and not f + + event = Event.load( + { + "name": "开多", + "operate": "开多", + "signals_all": ["1分钟_D1_涨跌停V230331_任意_任意_任意_0", "1分钟_D0停顿分型_BE辅助V230106_看空_强_任意_0"], + "signals_any": [], + "signals_not": [], + "factors": [ + { + "name": "SMA#40多头", + "signals_all": ["5分钟_D1#SMA#40MO10_BS辅助V230313_看多_任意_任意_0"], + "signals_any": [], + "signals_not": [], + } + ], + } + ) + assert len(event.get_signals_config()) == 3 + + test_event() + + print() + print("=" * 60) + print("🎉 全部测试通过!") + print("=" * 60) diff --git a/chanlun-py/chanlun/indicators.py b/chanlun-py/chanlun/indicators.py new file mode 100644 index 0000000..9c4af11 --- /dev/null +++ b/chanlun-py/chanlun/indicators.py @@ -0,0 +1,1594 @@ +from __future__ import annotations +from datetime import datetime +from typing import Optional, List, Deque +from collections import deque +import math + + +# ======================= 辅助函数 ======================= +def _ema_alpha(period: int) -> float: + """EMA 平滑系数""" + return 2.0 / (period + 1) + + +def _ultimate_smoother_coeffs(period: float): + """终极平滑器系数""" + a1 = math.exp(-1.414 * math.pi / period) + b1 = 2.0 * a1 * math.cos(1.414 * math.pi / period) + c2 = b1 + c3 = -a1 * a1 + c1 = (1.0 + c2 - c3) / 4.0 + return c1, c2, c3 + + +# ======================= 1. SMA 简单移动平均 ======================= +class SMA: + """简单移动平均 — 窗口滚动均值""" + + def __init__( + self, + 时间戳: datetime, + 收盘价: float, + 周期: int, + sma值: Optional[float] = None, + _窗口: Optional[Deque[float]] = None, + _当前和: float = 0.0, + ): + if 周期 <= 0: + raise ValueError(f"周期必须大于0,实际: {周期}") + self.时间戳 = 时间戳 + self.收盘价 = 收盘价 + self.周期 = 周期 + self.sma值 = sma值 + self._窗口: Deque[float] = _窗口 if _窗口 is not None else deque() + self._当前和: float = _当前和 + + @classmethod + def 首次计算(cls, 序列: List[float], 时间序列: List[datetime], 周期: int) -> "SMA": + """用前周期个数据初始化""" + if len(序列) < 周期: + raise ValueError("数据长度不足") + 窗口 = deque(序列[:周期], maxlen=周期) + 当前和 = sum(窗口) + return cls(时间戳=时间序列[周期 - 1], 收盘价=序列[周期 - 1], 周期=周期, sma值=当前和 / 周期, _窗口=窗口, _当前和=当前和) + + @classmethod + def 增量计算(cls, 前一个: "SMA", 新收盘价: float, 新时间: datetime) -> "SMA": + 窗口 = 前一个._窗口 + 旧值 = 窗口[0] + 窗口.append(新收盘价) + 当前和 = 前一个._当前和 + 新收盘价 - 旧值 + return cls(时间戳=新时间, 收盘价=新收盘价, 周期=前一个.周期, sma值=当前和 / 前一个.周期, _窗口=窗口, _当前和=当前和) + + +# ======================= 2. Ultimate Smoother ======================= +class UltimateSmoother: + """终极平滑器""" + + def __init__( + self, + 时间戳: datetime, + 价格: float, + 周期: float, + 平滑值: Optional[float] = None, + _历史价格: Optional[Deque[float]] = None, + _平滑历史: Optional[Deque[float]] = None, + ): + self.时间戳 = 时间戳 + self.价格 = 价格 + self.周期 = 周期 + self.平滑值 = 平滑值 + self._历史价格: Deque[float] = _历史价格 if _历史价格 is not None else deque(maxlen=4) + self._平滑历史: Deque[float] = _平滑历史 if _平滑历史 is not None else deque(maxlen=2) + + @classmethod + def 首次计算(cls, 序列: List[float], 时间序列: List[datetime], 周期: float) -> "UltimateSmoother": + if len(序列) < 4: + raise ValueError("至少需要4个数据点") + obj = cls(时间戳=时间序列[0], 价格=序列[0], 周期=周期, 平滑值=序列[0]) + obj._历史价格.extend(序列[:4]) + obj._平滑历史.append(序列[0]) + obj._平滑历史.append(序列[0]) # 初始化第二个平滑值,增量计算需要 [-1] 和 [-2] + for i in range(1, 4): + obj = cls.增量计算(obj, 序列[i], 时间序列[i]) + return obj + + @classmethod + def 增量计算(cls, 前一个: "UltimateSmoother", 新价格: float, 新时间: datetime) -> "UltimateSmoother": + c1, c2, c3 = _ultimate_smoother_coeffs(前一个.周期) + 价格历史 = 前一个._历史价格 + 平滑历史 = 前一个._平滑历史 + if len(价格历史) < 4: + 价格历史.append(新价格) + 新平滑 = 新价格 + else: + 价格历史.append(新价格) + p, p1, p2 = 新价格, 价格历史[-2], 价格历史[-3] + us1, us2 = 平滑历史[-1], 平滑历史[-2] + 新平滑 = (1 - c1) * p + (2 * c1 - c2) * p1 - (c1 + c3) * p2 + c2 * us1 + c3 * us2 + 平滑历史.append(新平滑) + return cls(时间戳=新时间, 价格=新价格, 周期=前一个.周期, 平滑值=新平滑, _历史价格=价格历史, _平滑历史=平滑历史) + + +# ======================= 3. Rolling Rank ======================= +class RollingRank: + """滚动排名""" + + def __init__( + self, + 时间戳: datetime, + 当前值: float, + 窗口大小: int, + 排名: Optional[int] = None, + _窗口值: Optional[Deque[float]] = None, + ): + self.时间戳 = 时间戳 + self.当前值 = 当前值 + self.窗口大小 = 窗口大小 + self.排名 = 排名 + self._窗口值: Deque[float] = _窗口值 if _窗口值 is not None else deque() + + @classmethod + def 首次计算(cls, 序列: List[float], 时间序列: List[datetime], 窗口: int) -> "RollingRank": + if len(序列) < 窗口: + raise ValueError + 窗口值 = deque(序列[:窗口], maxlen=窗口) + 排序 = sorted(窗口值) + 当前值 = 序列[窗口 - 1] + 排名 = 排序.index(当前值) + 1 + return cls(时间戳=时间序列[窗口 - 1], 当前值=当前值, 窗口大小=窗口, 排名=排名, _窗口值=窗口值) + + @classmethod + def 增量计算(cls, 前一个: "RollingRank", 新值: float, 新时间: datetime) -> "RollingRank": + 窗口值 = 前一个._窗口值 + 窗口值.append(新值) + 排序 = sorted(窗口值) + 排名 = 排序.index(新值) + 1 + return cls(时间戳=新时间, 当前值=新值, 窗口大小=前一个.窗口大小, 排名=排名, _窗口值=窗口值) + + +# ======================= 4. 单均线多空信号 ======================= +class SingleSMAPositions: + """单均线多空信号(双重平滑)""" + + def __init__( + self, + 时间戳: datetime, + 收盘价: float, + 周期: int, + 持仓信号: float = 0.0, + _ms窗口: Optional[Deque[float]] = None, + _ms_sma窗口: Optional[Deque[float]] = None, + _当前ms和: float = 0.0, + _当前ms_sma和: float = 0.0, + ): + self.时间戳 = 时间戳 + self.收盘价 = 收盘价 + self.周期 = 周期 + self.持仓信号 = 持仓信号 + self._ms窗口: Deque[float] = _ms窗口 if _ms窗口 is not None else deque() + self._ms_sma窗口: Deque[float] = _ms_sma窗口 if _ms_sma窗口 is not None else deque() + self._当前ms和: float = _当前ms和 + self._当前ms_sma和: float = _当前ms_sma和 + + @classmethod + def 首次计算(cls, 序列: List[float], 时间序列: List[datetime], 周期: int) -> "SingleSMAPositions": + n = 周期 + 有效起点 = 2 * n - 2 + if len(序列) <= 有效起点: + raise ValueError + obj = None + for i in range(有效起点, len(序列)): + if obj is None: + ms = [sum(序列[j - n + 1 : j + 1]) / n for j in range(n - 1, 有效起点 + 1)] + ms_sma = [sum(ms[k - n + 1 : k + 1]) / n for k in range(n - 1, 有效起点 + 1)] + 信号 = (序列[有效起点] - ms_sma[-1]) / abs(序列[有效起点] - ms_sma[-1]) if ms_sma[-1] != 0 else 0.0 + obj = cls(时间戳=时间序列[有效起点], 收盘价=序列[有效起点], 周期=周期, 持仓信号=信号, _ms窗口=deque(ms[-n:], maxlen=n), _ms_sma窗口=deque(ms_sma[-n:], maxlen=n), _当前ms和=sum(ms[-n:]), _当前ms_sma和=sum(ms_sma[-n:])) + else: + obj = cls.增量计算(obj, 序列[i], 时间序列[i]) + return obj + + @classmethod + def 增量计算(cls, 前一个: "SingleSMAPositions", 新收盘价: float, 新时间: datetime) -> "SingleSMAPositions": + # 更新 ms + ms窗口 = 前一个._ms窗口 + ms旧 = ms窗口[0] + ms窗口.append(新收盘价) + 当前ms和 = 前一个._当前ms和 + 新收盘价 - ms旧 + ms新 = 当前ms和 / 前一个.周期 + # 更新 ms_sma + ms_sma窗口 = 前一个._ms_sma窗口 + ms_sma旧 = ms_sma窗口[0] + ms_sma窗口.append(ms新) + 当前ms_sma和 = 前一个._当前ms_sma和 + ms新 - ms_sma旧 + ms_sma新 = 当前ms_sma和 / 前一个.周期 + if ms_sma新 != 0: + 信号 = 1.0 if 新收盘价 > ms_sma新 else (-1.0 if 新收盘价 < ms_sma新 else 0.0) + else: + 信号 = 0.0 + return cls(时间戳=新时间, 收盘价=新收盘价, 周期=前一个.周期, 持仓信号=信号, _ms窗口=ms窗口, _ms_sma窗口=ms_sma窗口, _当前ms和=当前ms和, _当前ms_sma和=当前ms_sma和) + + +# ======================= 5. 单EMA多空信号 ======================= +class SingleEMAPositions: + """单EMA多空信号""" + + def __init__( + self, + 时间戳: datetime, + 收盘价: float, + 周期: int, + 持仓信号: float = 0.0, + _ms窗口: Optional[Deque[float]] = None, + _当前ms和: float = 0.0, + _ema值: float = 0.0, + _有效起始: int = 0, + ): + self.时间戳 = 时间戳 + self.收盘价 = 收盘价 + self.周期 = 周期 + self.持仓信号 = 持仓信号 + self._ms窗口: Deque[float] = _ms窗口 if _ms窗口 is not None else deque() + self._当前ms和: float = _当前ms和 + self._ema值: float = _ema值 + self._有效起始: int = _有效起始 + + @classmethod + def 首次计算(cls, 序列: List[float], 时间序列: List[datetime], 周期: int) -> "SingleEMAPositions": + n = 周期 + if len(序列) < n: + raise ValueError + ms = [sum(序列[i - n + 1 : i + 1]) / n for i in range(n - 1, len(序列))] + alpha = _ema_alpha(n) + 有效起始 = n - 1 + n - 1 + if len(序列) <= 有效起始: + raise ValueError + ema = sum(ms[:n]) / n + 信号 = (序列[有效起始] - ema) / abs(序列[有效起始] - ema) if ema != 0 else 0.0 + obj = cls(时间戳=时间序列[有效起始], 收盘价=序列[有效起始], 周期=周期, 持仓信号=信号, _ms窗口=deque(序列[有效起始 - n + 1 : 有效起始 + 1], maxlen=n), _当前ms和=sum(序列[有效起始 - n + 1 : 有效起始 + 1]), _ema值=ema, _有效起始=有效起始) + return obj + + @classmethod + def 增量计算(cls, 前一个: "SingleEMAPositions", 新收盘价: float, 新时间: datetime) -> "SingleEMAPositions": + 窗口 = 前一个._ms窗口 + 旧 = 窗口[0] + 窗口.append(新收盘价) + ms和 = 前一个._当前ms和 + 新收盘价 - 旧 + ms = ms和 / 前一个.周期 + alpha = _ema_alpha(前一个.周期) + ema = alpha * ms + (1 - alpha) * 前一个._ema值 + 信号 = 1.0 if 新收盘价 > ema else (-1.0 if 新收盘价 < ema else 0.0) + return cls(时间戳=新时间, 收盘价=新收盘价, 周期=前一个.周期, 持仓信号=信号, _ms窗口=窗口, _当前ms和=ms和, _ema值=ema, _有效起始=前一个._有效起始) + + +# ======================= 6. 中轴策略 ======================= +class MidPositions: + """中轴多空策略""" + + def __init__( + self, + 时间戳: datetime, + 当前值: float, + 周期: int, + 持仓信号: float = 0.0, + _ms窗口: Optional[Deque[float]] = None, + _ms和: float = 0.0, + _high_low窗口: Optional[Deque[float]] = None, + ): + self.时间戳 = 时间戳 + self.当前值 = 当前值 + self.周期 = 周期 + self.持仓信号 = 持仓信号 + self._ms窗口: Deque[float] = _ms窗口 if _ms窗口 is not None else deque() + self._ms和: float = _ms和 + self._high_low窗口: Deque[float] = _high_low窗口 if _high_low窗口 is not None else deque() + + @classmethod + def 首次计算(cls, 序列: List[float], 时间序列: List[datetime], 周期: int) -> "MidPositions": + n = 周期 + 有效起点 = 2 * n - 2 + if len(序列) <= 有效起点: + raise ValueError + ms列表 = [sum(序列[i - n + 1 : i + 1]) / n for i in range(n - 1, len(序列))] + obj = None + for idx in range(有效起点, len(序列)): + i = idx + if obj is None: + high = max(ms列表[i - n + 1 : i + 1]) + low = min(ms列表[i - n + 1 : i + 1]) + mid = (high + low) / 2 + 信号 = (ms列表[i] - mid) / abs(ms列表[i] - mid) if (high + low) != 0 else 0.0 + obj = cls(时间戳=时间序列[i], 当前值=序列[i], 周期=周期, 持仓信号=信号, _ms窗口=deque(序列[i - n + 1 : i + 1], maxlen=n), _ms和=sum(序列[i - n + 1 : i + 1]), _high_low窗口=deque(ms列表[i - n + 1 : i + 1], maxlen=n)) + else: + obj = cls.增量计算(obj, 序列[i], 时间序列[i]) + return obj + + @classmethod + def 增量计算(cls, 前一个: "MidPositions", 新值: float, 新时间: datetime) -> "MidPositions": + ms窗口 = 前一个._ms窗口 + ms旧 = ms窗口[0] + ms窗口.append(新值) + ms和 = 前一个._ms和 + 新值 - ms旧 + ms = ms和 / 前一个.周期 + hl窗口 = 前一个._high_low窗口 + hl窗口.append(ms) + high = max(hl窗口) + low = min(hl窗口) + mid = (high + low) / 2 + if high != 0 or low != 0: + 信号 = 1.0 if ms > mid else (-1.0 if ms < mid else 0.0) + else: + 信号 = 0.0 + return cls(时间戳=新时间, 当前值=新值, 周期=前一个.周期, 持仓信号=信号, _ms窗口=ms窗口, _ms和=ms和, _high_low窗口=hl窗口) + + +# ======================= 7. 双均线多空信号 ======================= +class DoubleSMAPositions: + """双均线多空信号""" + + def __init__( + self, + 时间戳: datetime, + 收盘价: float, + 短周期: int, + 长周期: int, + 持仓信号: float = 0.0, + _短窗口: Optional[Deque[float]] = None, + _长窗口: Optional[Deque[float]] = None, + _短和: float = 0.0, + _长和: float = 0.0, + ): + self.时间戳 = 时间戳 + self.收盘价 = 收盘价 + self.短周期 = 短周期 + self.长周期 = 长周期 + self.持仓信号 = 持仓信号 + self._短窗口: Deque[float] = _短窗口 if _短窗口 is not None else deque() + self._长窗口: Deque[float] = _长窗口 if _长窗口 is not None else deque() + self._短和: float = _短和 + self._长和: float = _长和 + + @classmethod + def 首次计算(cls, 序列: List[float], 时间序列: List[datetime], 短周期: int, 长周期: int) -> "DoubleSMAPositions": + if 短周期 >= 长周期: + raise ValueError + 有效起点 = 长周期 - 1 + if len(序列) <= 有效起点: + raise ValueError + obj = None + for i in range(有效起点, len(序列)): + if obj is None: + 短窗口 = deque(序列[i - 短周期 + 1 : i + 1], maxlen=短周期) + 长窗口 = deque(序列[i - 长周期 + 1 : i + 1], maxlen=长周期) + 短sma = sum(短窗口) / 短周期 + 长sma = sum(长窗口) / 长周期 + 信号 = 1.0 if 短sma > 长sma else (-1.0 if 短sma < 长sma else 0.0) + obj = cls(时间戳=时间序列[i], 收盘价=序列[i], 短周期=短周期, 长周期=长周期, 持仓信号=信号, _短窗口=短窗口, _长窗口=长窗口, _短和=sum(短窗口), _长和=sum(长窗口)) + else: + obj = cls.增量计算(obj, 序列[i], 时间序列[i]) + return obj + + @classmethod + def 增量计算(cls, 前一个: "DoubleSMAPositions", 新收盘价: float, 新时间: datetime) -> "DoubleSMAPositions": + 短窗口 = 前一个._短窗口 + 长窗口 = 前一个._长窗口 + 短旧 = 短窗口[0] + 长旧 = 长窗口[0] + 短窗口.append(新收盘价) + 长窗口.append(新收盘价) + 短和 = 前一个._短和 + 新收盘价 - 短旧 + 长和 = 前一个._长和 + 新收盘价 - 长旧 + 短sma = 短和 / 前一个.短周期 + 长sma = 长和 / 前一个.长周期 + 信号 = 1.0 if 短sma > 长sma else (-1.0 if 短sma < 长sma else 0.0) + return cls(时间戳=新时间, 收盘价=新收盘价, 短周期=前一个.短周期, 长周期=前一个.长周期, 持仓信号=信号, _短窗口=短窗口, _长窗口=长窗口, _短和=短和, _长和=长和) + + +# ======================= 8. 三均线系统 ======================= +class TripleSMAPositions: + """三均线系统持仓信号""" + + def __init__( + self, + 时间戳: datetime, + 收盘价: float, + m1: int, + m2: int, + m3: int, + 持仓信号: int = 0, + _smoothed窗口: Optional[Deque[float]] = None, + _smoothed和: float = 0.0, + _ma1窗口: Optional[Deque[float]] = None, + _ma1和: float = 0.0, + _ma2窗口: Optional[Deque[float]] = None, + _ma2和: float = 0.0, + _ma3窗口: Optional[Deque[float]] = None, + _ma3和: float = 0.0, + ): + self.时间戳 = 时间戳 + self.收盘价 = 收盘价 + self.m1 = m1 + self.m2 = m2 + self.m3 = m3 + self.持仓信号 = 持仓信号 + self._smoothed窗口: Deque[float] = _smoothed窗口 if _smoothed窗口 is not None else deque() + self._smoothed和: float = _smoothed和 + self._ma1窗口: Deque[float] = _ma1窗口 if _ma1窗口 is not None else deque() + self._ma1和: float = _ma1和 + self._ma2窗口: Deque[float] = _ma2窗口 if _ma2窗口 is not None else deque() + self._ma2和: float = _ma2和 + self._ma3窗口: Deque[float] = _ma3窗口 if _ma3窗口 is not None else deque() + self._ma3和: float = _ma3和 + + @classmethod + def 首次计算(cls, 序列: List[float], 时间序列: List[datetime], m1: int, m2: int, m3: int) -> "TripleSMAPositions": + if not (m1 < m2 < m3): + raise ValueError + 需要长度 = m3 + m3 - 1 + if len(序列) < 需要长度: + raise ValueError + smoothed = [None] * len(序列) + for i in range(m1 - 1, len(序列)): + smoothed[i] = sum(序列[i - m1 + 1 : i + 1]) / m1 + ma1 = [None] * len(序列) + ma2 = [None] * len(序列) + ma3 = [None] * len(序列) + for i in range(m1 - 1, len(序列)): + if i >= m1 - 1: + 窗口 = [smoothed[j] for j in range(i - m1 + 1, i + 1) if smoothed[j] is not None] + if len(窗口) == m1: + ma1[i] = sum(窗口) / m1 + for i in range(m2 - 1, len(序列)): + 窗口 = [smoothed[j] for j in range(i - m2 + 1, i + 1) if smoothed[j] is not None] + if len(窗口) == m2: + ma2[i] = sum(窗口) / m2 + for i in range(m3 - 1, len(序列)): + 窗口 = [smoothed[j] for j in range(i - m3 + 1, i + 1) if smoothed[j] is not None] + if len(窗口) == m3: + ma3[i] = sum(窗口) / m3 + + 最后位置 = len(序列) - 1 + while 最后位置 >= 0 and (smoothed[最后位置] is None or ma1[最后位置] is None or ma2[最后位置] is None or ma3[最后位置] is None): + 最后位置 -= 1 + if 最后位置 < 0: + raise ValueError + i = 最后位置 + s, a1, a2, a3 = smoothed[i], ma1[i], ma2[i], ma3[i] + if s > a3 and a1 > a2: + sig = 1 + elif s < a3 and a1 < a2: + sig = -1 + else: + sig = 0 + obj = cls( + 时间戳=时间序列[i], + 收盘价=序列[i], + m1=m1, + m2=m2, + m3=m3, + 持仓信号=sig, + _smoothed窗口=deque(序列[i - m1 + 1 : i + 1], maxlen=m1), + _smoothed和=sum(序列[i - m1 + 1 : i + 1]), + _ma1窗口=deque([smoothed[j] for j in range(i - m1 + 1, i + 1) if smoothed[j] is not None], maxlen=m1), + _ma1和=sum([smoothed[j] for j in range(i - m1 + 1, i + 1) if smoothed[j] is not None]), + _ma2窗口=deque([smoothed[j] for j in range(i - m2 + 1, i + 1) if smoothed[j] is not None], maxlen=m2), + _ma2和=sum([smoothed[j] for j in range(i - m2 + 1, i + 1) if smoothed[j] is not None]), + _ma3窗口=deque([smoothed[j] for j in range(i - m3 + 1, i + 1) if smoothed[j] is not None], maxlen=m3), + _ma3和=sum([smoothed[j] for j in range(i - m3 + 1, i + 1) if smoothed[j] is not None]), + ) + return obj + + @classmethod + def 增量计算(cls, 前一个: "TripleSMAPositions", 新收盘价: float, 新时间: datetime) -> "TripleSMAPositions": + s窗口 = 前一个._smoothed窗口 + s旧 = s窗口[0] + s窗口.append(新收盘价) + s和 = 前一个._smoothed和 + 新收盘价 - s旧 + smoothed = s和 / 前一个.m1 + + ma1窗口 = 前一个._ma1窗口 + ma1旧 = ma1窗口[0] + ma1窗口.append(smoothed) + ma1和 = 前一个._ma1和 + smoothed - ma1旧 + ma1 = ma1和 / 前一个.m1 + + ma2窗口 = 前一个._ma2窗口 + ma2旧 = ma2窗口[0] + ma2窗口.append(smoothed) + ma2和 = 前一个._ma2和 + smoothed - ma2旧 + ma2 = ma2和 / 前一个.m2 + + ma3窗口 = 前一个._ma3窗口 + ma3旧 = ma3窗口[0] + ma3窗口.append(smoothed) + ma3和 = 前一个._ma3和 + smoothed - ma3旧 + ma3 = ma3和 / 前一个.m3 + + if smoothed > ma3 and ma1 > ma2: + sig = 1 + elif smoothed < ma3 and ma1 < ma2: + sig = -1 + else: + sig = 0 + return cls(时间戳=新时间, 收盘价=新收盘价, m1=前一个.m1, m2=前一个.m2, m3=前一个.m3, 持仓信号=sig, _smoothed窗口=s窗口, _smoothed和=s和, _ma1窗口=ma1窗口, _ma1和=ma1和, _ma2窗口=ma2窗口, _ma2和=ma2和, _ma3窗口=ma3窗口, _ma3和=ma3和) + + +# ======================= 9. 布林线多空信号 ======================= +class BollPositions: + """布林线多空信号""" + + def __init__( + self, + 时间戳: datetime, + 收盘价: float, + 周期: int, + 倍数: float, + 持仓信号: int = 0, + _smoothed窗口: Optional[Deque[float]] = None, + _smoothed和: float = 0.0, + _sm窗口: Optional[Deque[float]] = None, + _sm和: float = 0.0, + _sd窗口: Optional[Deque[float]] = None, + ): + self.时间戳 = 时间戳 + self.收盘价 = 收盘价 + self.周期 = 周期 + self.倍数 = 倍数 + self.持仓信号 = 持仓信号 + self._smoothed窗口: Deque[float] = _smoothed窗口 if _smoothed窗口 is not None else deque() + self._smoothed和: float = _smoothed和 + self._sm窗口: Deque[float] = _sm窗口 if _sm窗口 is not None else deque() + self._sm和: float = _sm和 + self._sd窗口: Deque[float] = _sd窗口 if _sd窗口 is not None else deque() + + @classmethod + def 首次计算(cls, 序列: List[float], 时间序列: List[datetime], 周期: int, 倍数: float) -> "BollPositions": + n = 周期 + 需要 = 2 * n - 1 + if len(序列) < 需要: + raise ValueError + smoothed_list = [sum(序列[i - n + 1 : i + 1]) / n for i in range(n - 1, len(序列))] + sm_list = [None] * len(序列) + sd_list = [None] * len(序列) + for i in range(n - 1, len(序列)): + sm_idx = i - n + 1 + win = smoothed_list[sm_idx - n + 1 : sm_idx + 1] + if len(win) == n: + mean = sum(win) / n + var = sum((x - mean) ** 2 for x in win) / (n - 1) + sm_list[i] = mean + sd_list[i] = math.sqrt(var) + 最后 = len(序列) - 1 + while 最后 >= 0 and sm_list[最后] is None: + 最后 -= 1 + i = 最后 + sm_val = sm_list[i] + sd_val = sd_list[i] + 平滑索引 = i - n + 1 + smoothed = smoothed_list[平滑索引] + upper = sm_val + 倍数 * sd_val + lower = sm_val - 倍数 * sd_val + if smoothed > upper + 1e-10: + sig = 1 + elif smoothed < lower - 1e-10: + sig = -1 + else: + sig = 0 + obj = cls( + 时间戳=时间序列[i], + 收盘价=序列[i], + 周期=周期, + 倍数=倍数, + 持仓信号=sig, + _smoothed窗口=deque(序列[i - n + 1 : i + 1], maxlen=n), + _smoothed和=sum(序列[i - n + 1 : i + 1]), + _sm窗口=deque(smoothed_list[平滑索引 - n + 1 : 平滑索引 + 1], maxlen=n), + _sm和=sum(smoothed_list[平滑索引 - n + 1 : 平滑索引 + 1]), + _sd窗口=deque(sd_list[i - n + 1 : i + 1], maxlen=n), + ) + return obj + + @classmethod + def 增量计算(cls, 前一个: "BollPositions", 新收盘价: float, 新时间: datetime) -> "BollPositions": + s窗口 = 前一个._smoothed窗口 + s旧 = s窗口[0] + s窗口.append(新收盘价) + s和 = 前一个._smoothed和 + 新收盘价 - s旧 + smoothed = s和 / 前一个.周期 + + sm窗口 = 前一个._sm窗口 + sm窗口.append(smoothed) + n = 前一个.周期 + mean = sum(sm窗口) / n + var = sum((x - mean) ** 2 for x in sm窗口) / (n - 1) + sd = math.sqrt(var) + upper = mean + 前一个.倍数 * sd + lower = mean - 前一个.倍数 * sd + if smoothed > upper + 1e-10: + sig = 1 + elif smoothed < lower - 1e-10: + sig = -1 + else: + sig = 0 + return cls(时间戳=新时间, 收盘价=新收盘价, 周期=前一个.周期, 倍数=前一个.倍数, 持仓信号=sig, _smoothed窗口=s窗口, _smoothed和=s和, _sm窗口=sm窗口, _sm和=mean * n, _sd窗口=前一个._sd窗口) + + +# ======================= 10. 布林反转策略 ======================= +class BollReversePositions: + """布林带反转策略信号""" + + def __init__( + self, + 时间戳: datetime, + 收盘价: float, + 周期: int, + 倍数: float, + 持仓信号: int = 0, + _smoothed窗口: Optional[Deque[float]] = None, + _smoothed和: float = 0.0, + _当前持仓: int = 0, + ): + self.时间戳 = 时间戳 + self.收盘价 = 收盘价 + self.周期 = 周期 + self.倍数 = 倍数 + self.持仓信号 = 持仓信号 + self._smoothed窗口: Deque[float] = _smoothed窗口 if _smoothed窗口 is not None else deque() + self._smoothed和: float = _smoothed和 + self._当前持仓: int = _当前持仓 + + @classmethod + def 首次计算(cls, 序列: List[float], 时间序列: List[datetime], 周期: int, 倍数: float) -> "BollReversePositions": + n = 周期 + if len(序列) < n: + raise ValueError + boll = BollPositions.首次计算(序列, 时间序列, 周期, 倍数) + return cls._从布林计算(boll, 0) + + @classmethod + def _从布林计算(cls, boll: "BollPositions", 当前持仓: int) -> "BollReversePositions": + """从BollPositions状态构造BollReversePositions""" + n = boll.周期 + # 计算当前的smoothed和布林带 + s窗口 = boll._smoothed窗口 + smoothed = boll._smoothed和 / n if boll._smoothed和 > 0 else sum(s窗口) / len(s窗口) + sm窗口 = boll._sm窗口 + mean = sum(sm窗口) / len(sm窗口) + var = sum((x - mean) ** 2 for x in sm窗口) / (len(sm窗口) - 1) + sd = math.sqrt(var) + upper = mean + boll.倍数 * sd + lower = mean - boll.倍数 * sd + if 当前持仓 == 0: + if smoothed > upper: + 当前持仓 = -1 + elif smoothed < lower: + 当前持仓 = 1 + elif 当前持仓 == 1: + if smoothed > mean: + 当前持仓 = 0 + elif 当前持仓 == -1: + if smoothed < mean: + 当前持仓 = 0 + return cls(时间戳=boll.时间戳, 收盘价=boll.收盘价, 周期=boll.周期, 倍数=boll.倍数, 持仓信号=当前持仓, _smoothed窗口=s窗口, _smoothed和=boll._smoothed和, _当前持仓=当前持仓) + + @classmethod + def 增量计算(cls, 前一个: "BollReversePositions", 新收盘价: float, 新时间: datetime) -> "BollReversePositions": + s窗口 = 前一个._smoothed窗口 + s旧 = s窗口[0] + s窗口.append(新收盘价) + s和 = 前一个._smoothed和 + 新收盘价 - s旧 + n = 前一个.周期 + if len(s窗口) < n: + return cls(时间戳=新时间, 收盘价=新收盘价, 周期=前一个.周期, 倍数=前一个.倍数, 持仓信号=0, _smoothed窗口=s窗口, _smoothed和=s和, _当前持仓=0) + smoothed = s和 / n + # 构建smoothed历史窗口 + sm_deque = deque(maxlen=n) + for i in range(len(s窗口) - n + 1, len(s窗口) + 1): + win = list(s窗口)[i - n : i] if i >= n else list(s窗口)[:i] + if len(win) == n: + sm_deque.append(sum(win) / n) + if len(sm_deque) < n: + return cls(时间戳=新时间, 收盘价=新收盘价, 周期=前一个.周期, 倍数=前一个.倍数, 持仓信号=前一个._当前持仓, _smoothed窗口=s窗口, _smoothed和=s和, _当前持仓=前一个._当前持仓) + mean = sum(sm_deque) / n + var = sum((x - mean) ** 2 for x in sm_deque) / (n - 1) + sd = math.sqrt(var) + upper = mean + 前一个.倍数 * sd + lower = mean - 前一个.倍数 * sd + 当前持仓 = 前一个._当前持仓 + if 当前持仓 == 0: + if smoothed > upper: + 当前持仓 = -1 + elif smoothed < lower: + 当前持仓 = 1 + elif 当前持仓 == 1: + if smoothed > mean: + 当前持仓 = 0 + elif 当前持仓 == -1: + if smoothed < mean: + 当前持仓 = 0 + return cls(时间戳=新时间, 收盘价=新收盘价, 周期=前一个.周期, 倍数=前一个.倍数, 持仓信号=当前持仓, _smoothed窗口=s窗口, _smoothed和=s和, _当前持仓=当前持仓) + + +# ======================= 11. MMS 归一化信号 ======================= +class MMSPositions: + """均线最大最小值归一化信号""" + + def __init__( + self, + 时间戳: datetime, + 收盘价: float, + 均线周期: int, + 窗口: int, + 信号: float = 0.0, + _sm窗口: Optional[Deque[float]] = None, + _sm和: float = 0.0, + _sm历史: Optional[Deque[float]] = None, + ): + self.时间戳 = 时间戳 + self.收盘价 = 收盘价 + self.均线周期 = 均线周期 + self.窗口 = 窗口 + self.信号 = 信号 + self._sm窗口: Deque[float] = _sm窗口 if _sm窗口 is not None else deque() + self._sm和: float = _sm和 + self._sm历史: Deque[float] = _sm历史 if _sm历史 is not None else deque() + + @classmethod + def 首次计算(cls, 序列: List[float], 时间序列: List[datetime], 均线周期: int, 窗口: int) -> "MMSPositions": + tp, w = 均线周期, 窗口 + if len(序列) < tp + w - 1: + raise ValueError + sm_list = [sum(序列[i - tp + 1 : i + 1]) / tp for i in range(tp - 1, len(序列))] + 有效起点 = tp - 1 + w - 1 + if len(序列) <= 有效起点: + raise ValueError + i = 有效起点 + sm = sm_list[i] + sm_min = min(sm_list[i - w + 1 : i + 1]) + sm_max = max(sm_list[i - w + 1 : i + 1]) + if sm_max - sm_min > 1e-10: + norm = (sm - sm_min) / (sm_max - sm_min) + 信号 = norm * 2 - 1 + else: + 信号 = 0.0 + obj = cls(时间戳=时间序列[i], 收盘价=序列[i], 均线周期=tp, 窗口=w, 信号=信号, _sm窗口=deque(序列[i - tp + 1 : i + 1], maxlen=tp), _sm和=sum(序列[i - tp + 1 : i + 1]), _sm历史=deque(sm_list[i - w + 1 : i + 1], maxlen=w)) + return obj + + @classmethod + def 增量计算(cls, 前一个: "MMSPositions", 新收盘价: float, 新时间: datetime) -> "MMSPositions": + s窗口 = 前一个._sm窗口 + s旧 = s窗口[0] + s窗口.append(新收盘价) + s和 = 前一个._sm和 + 新收盘价 - s旧 + sm = s和 / 前一个.均线周期 + sm历史 = 前一个._sm历史 + sm历史.append(sm) + sm_min = min(sm历史) + sm_max = max(sm历史) + if sm_max - sm_min > 1e-10: + norm = (sm - sm_min) / (sm_max - sm_min) + 信号 = norm * 2 - 1 + else: + 信号 = 0.0 + return cls(时间戳=新时间, 收盘价=新收盘价, 均线周期=前一个.均线周期, 窗口=前一个.窗口, 信号=信号, _sm窗口=s窗口, _sm和=s和, _sm历史=sm历史) + + +# ======================= 12. RSI 反转策略 ======================= +class RSIReversePositions: + """RSI 反转策略信号""" + + def __init__( + self, + 时间戳: datetime, + 收盘价: float, + 周期: int, + rsi_upper: float, + rsi_lower: float, + rsi_exit: float, + 持仓信号: int = 0, + _smoothed窗口: Optional[Deque[float]] = None, + _smoothed和: float = 0.0, + _变化序列: Optional[Deque[float]] = None, + _当前持仓: int = 0, + ): + self.时间戳 = 时间戳 + self.收盘价 = 收盘价 + self.周期 = 周期 + self.rsi_upper = rsi_upper + self.rsi_lower = rsi_lower + self.rsi_exit = rsi_exit + self.持仓信号 = 持仓信号 + self._smoothed窗口: Deque[float] = _smoothed窗口 if _smoothed窗口 is not None else deque() + self._smoothed和: float = _smoothed和 + self._变化序列: Deque[float] = _变化序列 if _变化序列 is not None else deque() + self._当前持仓: int = _当前持仓 + + @classmethod + def 首次计算(cls, 序列: List[float], 时间序列: List[datetime], 周期: int, rsi_upper: float, rsi_lower: float, rsi_exit: float) -> "RSIReversePositions": + n = 周期 + if len(序列) < n: + raise ValueError + smoothed_list = [sum(序列[i - n + 1 : i + 1]) / n for i in range(n - 1, len(序列))] + rsi_vals = [None] * len(序列) + for i in range(1, len(序列)): + if i < n - 1: + continue + gains = 0.0 + losses = 0.0 + valid = 0 + start = max(0, i - n) + for j in range(start, i): + 平滑j = j - n + 1 + if 平滑j < 1 or 平滑j >= len(smoothed_list): + continue + if smoothed_list[平滑j - 1] is not None and smoothed_list[平滑j] is not None: + change = smoothed_list[平滑j] - smoothed_list[平滑j - 1] + if change > 0: + gains += change + else: + losses += abs(change) + valid += 1 + if valid >= n - 1: + if losses == 0: + rsi_vals[i] = 100.0 + else: + avg_gain = gains / valid + avg_loss = losses / valid + rs = avg_gain / avg_loss + rsi_vals[i] = 100.0 - (100.0 / (1 + rs)) + 最后 = len(序列) - 1 + while 最后 >= 0 and rsi_vals[最后] is None: + 最后 -= 1 + i = 最后 + rsi = rsi_vals[i] + pos = 0 + if rsi < rsi_lower: + pos = 1 + elif rsi > rsi_upper: + pos = -1 + obj = cls( + 时间戳=时间序列[i], + 收盘价=序列[i], + 周期=周期, + rsi_upper=rsi_upper, + rsi_lower=rsi_lower, + rsi_exit=rsi_exit, + 持仓信号=pos, + _smoothed窗口=deque(序列[i - n + 1 : i + 1], maxlen=n), + _smoothed和=sum(序列[i - n + 1 : i + 1]), + _变化序列=deque([smoothed_list[r] for r in range(max(0, i - 2 * n + 1), len(smoothed_list)) if smoothed_list[r] is not None], maxlen=n + 1), + _当前持仓=pos, + ) + return obj + + @classmethod + def 增量计算(cls, 前一个: "RSIReversePositions", 新收盘价: float, 新时间: datetime) -> "RSIReversePositions": + s窗口 = 前一个._smoothed窗口 + s旧 = s窗口[0] + s窗口.append(新收盘价) + s和 = 前一个._smoothed和 + 新收盘价 - s旧 + smoothed = s和 / 前一个.周期 + + 变化序列 = 前一个._变化序列 + 变化序列.append(smoothed) + + gains = losses = 0.0 + n = 前一个.周期 + vals = list(变化序列)[-n - 1 :] + valid = 0 + for j in range(1, len(vals)): + if vals[j] is not None and vals[j - 1] is not None: + ch = vals[j] - vals[j - 1] + if ch > 0: + gains += ch + else: + losses += abs(ch) + valid += 1 + if valid >= n - 1 and losses != 0: + rs = (gains / valid) / (losses / valid) + rsi = 100 - 100 / (1 + rs) + elif losses == 0: + rsi = 100.0 + else: + rsi = None + + pos = 前一个._当前持仓 + if rsi is not None: + if pos == 0: + if rsi < 前一个.rsi_lower: + pos = 1 + elif rsi > 前一个.rsi_upper: + pos = -1 + elif pos == 1: + if rsi > 前一个.rsi_exit: + pos = 0 + elif pos == -1: + if rsi < 前一个.rsi_exit: + pos = 0 + return cls(时间戳=新时间, 收盘价=新收盘价, 周期=前一个.周期, rsi_upper=前一个.rsi_upper, rsi_lower=前一个.rsi_lower, rsi_exit=前一个.rsi_exit, 持仓信号=pos, _smoothed窗口=s窗口, _smoothed和=s和, _变化序列=变化序列, _当前持仓=pos) + + +# ======================= 13. tanh 多空策略 ======================= +class TanhPositions: + """tanh 多空策略信号""" + + def __init__( + self, + 时间戳: datetime, + 收盘价: float, + 周期: int, + 信号: float = 0.0, + _ms窗口: Optional[Deque[float]] = None, + _ms和: float = 0.0, + _ms历史: Optional[Deque[float]] = None, + ): + self.时间戳 = 时间戳 + self.收盘价 = 收盘价 + self.周期 = 周期 + self.信号 = 信号 + self._ms窗口: Deque[float] = _ms窗口 if _ms窗口 is not None else deque() + self._ms和: float = _ms和 + self._ms历史: Deque[float] = _ms历史 if _ms历史 is not None else deque() + + @classmethod + def 首次计算(cls, 序列: List[float], 时间序列: List[datetime], 周期: int) -> "TanhPositions": + n = 周期 + 需要 = 2 * n - 1 + if len(序列) < 需要: + raise ValueError + ms_list = [sum(序列[i - n + 1 : i + 1]) / n for i in range(n - 1, len(序列))] + 有效 = n - 1 + n - 1 + i = 有效 + ms_win = ms_list[i - n + 1 : i + 1] + mean = sum(ms_win) / n + var = sum((x - mean) ** 2 for x in ms_win) / (n - 1) + std = math.sqrt(var) + z = (ms_list[i] - mean) / std if std > 0 else 0.0 + sig = math.tanh(z) + obj = cls(时间戳=时间序列[i], 收盘价=序列[i], 周期=周期, 信号=round(sig, 2), _ms窗口=deque(序列[i - n + 1 : i + 1], maxlen=n), _ms和=sum(序列[i - n + 1 : i + 1]), _ms历史=deque(ms_win, maxlen=n)) + return obj + + @classmethod + def 增量计算(cls, 前一个: "TanhPositions", 新收盘价: float, 新时间: datetime) -> "TanhPositions": + ms窗口 = 前一个._ms窗口 + 旧 = ms窗口[0] + ms窗口.append(新收盘价) + ms和 = 前一个._ms和 + 新收盘价 - 旧 + ms = ms和 / 前一个.周期 + ms历史 = 前一个._ms历史 + ms历史.append(ms) + n = 前一个.周期 + mean = sum(ms历史) / n + var = sum((x - mean) ** 2 for x in ms历史) / (n - 1) + std = math.sqrt(var) + z = (ms - mean) / std if std > 0 else 0.0 + sig = math.tanh(z) + return cls(时间戳=新时间, 收盘价=新收盘价, 周期=前一个.周期, 信号=round(sig, 2), _ms窗口=ms窗口, _ms和=ms和, _ms历史=ms历史) + + +# ======================= 14. rank 多空策略 ======================= +class RankPositions: + """Rank 多空策略""" + + def __init__( + self, + 时间戳: datetime, + 收盘价: float, + 周期: int, + 信号: float = 0.0, + _ms窗口: Optional[Deque[float]] = None, + _ms和: float = 0.0, + _ms历史: Optional[Deque[float]] = None, + ): + self.时间戳 = 时间戳 + self.收盘价 = 收盘价 + self.周期 = 周期 + self.信号 = 信号 + self._ms窗口: Deque[float] = _ms窗口 if _ms窗口 is not None else deque() + self._ms和: float = _ms和 + self._ms历史: Deque[float] = _ms历史 if _ms历史 is not None else deque() + + @classmethod + def 首次计算(cls, 序列: List[float], 时间序列: List[datetime], 周期: int) -> "RankPositions": + n = 周期 + if len(序列) < 2 * n - 1: + raise ValueError + ms_list = [sum(序列[i - n + 1 : i + 1]) / n for i in range(n - 1, len(序列))] + i = 2 * n - 2 + win = ms_list[i - n + 1 : i + 1] + cur = ms_list[i] + rank = sum(1 for x in win if x < cur) + 1 + norm_rank = (rank - 1) / (n - 1) + sig = (norm_rank - 0.5) * 2 + obj = cls(时间戳=时间序列[i], 收盘价=序列[i], 周期=周期, 信号=round(sig, 2), _ms窗口=deque(序列[i - n + 1 : i + 1], maxlen=n), _ms和=sum(序列[i - n + 1 : i + 1]), _ms历史=deque(win, maxlen=n)) + return obj + + @classmethod + def 增量计算(cls, 前一个: "RankPositions", 新收盘价: float, 新时间: datetime) -> "RankPositions": + ms窗口 = 前一个._ms窗口 + 旧 = ms窗口[0] + ms窗口.append(新收盘价) + ms和 = 前一个._ms和 + 新收盘价 - 旧 + ms = ms和 / 前一个.周期 + ms历史 = 前一个._ms历史 + ms历史.append(ms) + cur = ms + rank = sum(1 for x in ms历史 if x < cur) + 1 + n = 前一个.周期 + norm = (rank - 1) / (n - 1) + sig = (norm - 0.5) * 2 + return cls(时间戳=新时间, 收盘价=新收盘价, 周期=前一个.周期, 信号=round(sig, 2), _ms窗口=ms窗口, _ms和=ms和, _ms历史=ms历史) + + +# ======================= 15. EMA ======================= +class EMA: + """指数移动平均""" + + def __init__( + self, + 时间戳: datetime, + 收盘价: float, + 周期: int, + ema值: float = 0.0, + _种子和: float = 0.0, + _计数: int = 0, + _上一个ema: float = 0.0, + ): + self.时间戳 = 时间戳 + self.收盘价 = 收盘价 + self.周期 = 周期 + self.ema值 = ema值 + self._种子和: float = _种子和 + self._计数: int = _计数 + self._上一个ema: float = _上一个ema + + @classmethod + def 首次计算(cls, 序列: List[float], 时间序列: List[datetime], 周期: int) -> "EMA": + if len(序列) < 周期: + raise ValueError + 种子 = sum(序列[:周期]) / 周期 + obj = cls(时间戳=时间序列[周期 - 1], 收盘价=序列[周期 - 1], 周期=周期, ema值=种子, _上一个ema=种子, _计数=周期) + return obj + + @classmethod + def 增量计算(cls, 前一个: "EMA", 新收盘价: float, 新时间: datetime) -> "EMA": + if 前一个._计数 < 前一个.周期: + 和 = 前一个._种子和 + 新收盘价 + 计数 = 前一个._计数 + 1 + if 计数 == 前一个.周期: + ema = 和 / 前一个.周期 + else: + return cls(时间戳=新时间, 收盘价=新收盘价, 周期=前一个.周期, ema值=0.0, _种子和=和, _计数=计数, _上一个ema=0.0) + else: + alpha = _ema_alpha(前一个.周期) + ema = alpha * 新收盘价 + (1 - alpha) * 前一个._上一个ema + return cls(时间戳=新时间, 收盘价=新收盘价, 周期=前一个.周期, ema值=ema, _种子和=前一个._种子和, _计数=前一个.周期, _上一个ema=ema) + + +# ======================= 16. True Range ======================= +class TrueRange: + """真实波幅""" + + def __init__( + self, + 时间戳: datetime, + 最高: float, + 最低: float, + 前收: float, + TR: float = 0.0, + ): + self.时间戳 = 时间戳 + self.最高 = 最高 + self.最低 = 最低 + self.前收 = 前收 + self.TR = TR + + @classmethod + def 首次计算(cls, high: List[float], low: List[float], close: List[float], 时间序列: List[datetime]) -> "TrueRange": + if not (len(high) == len(low) == len(close)): + raise ValueError + tr = high[0] - low[0] + return cls(时间戳=时间序列[0], 最高=high[0], 最低=low[0], 前收=close[0], TR=tr) + + @classmethod + def 增量计算(cls, 前一个: "TrueRange", 新高: float, 新低: float, 新收: float, 新时间: datetime) -> "TrueRange": + 前收 = 前一个.前收 + tr = max(新高 - 新低, abs(新高 - 前收), abs(新低 - 前收)) + return cls(时间戳=新时间, 最高=新高, 最低=新低, 前收=新收, TR=tr) + + +# ======================= 17. RSX-SS2 ======================= +class RSXSS2: + """RSX-SS2 平滑 RSI""" + + def __init__( + self, + 时间戳: datetime, + 收盘价: float, + 周期: int, + 平滑周期: int, + rsx: float = 0.0, + _上一个收: float = 0.0, + _avg_gain_ema: float = 0.0, + _avg_loss_ema: float = 0.0, + _rsi历史: Optional[Deque[float]] = None, + _us: Optional[UltimateSmoother] = None, + ): + self.时间戳 = 时间戳 + self.收盘价 = 收盘价 + self.周期 = 周期 + self.平滑周期 = 平滑周期 + self.rsx = rsx + self._上一个收: float = _上一个收 + self._avg_gain_ema: float = _avg_gain_ema + self._avg_loss_ema: float = _avg_loss_ema + self._rsi历史: Deque[float] = _rsi历史 if _rsi历史 is not None else deque() + self._us: Optional[UltimateSmoother] = _us + + @classmethod + def 首次计算(cls, 序列: List[float], 时间序列: List[datetime], 周期: int, 平滑周期: int) -> "RSXSS2": + if len(序列) < 周期: + raise ValueError + gains = [] + losses = [] + for i in range(1, len(序列)): + delta = 序列[i] - 序列[i - 1] + if delta > 0: + gains.append(delta) + losses.append(0.0) + else: + gains.append(0.0) + losses.append(-delta) + alpha = 1.0 / 周期 + avg_gain = gains[0] + avg_loss = losses[0] + rsi_vals = [0.0] * len(序列) + rsi_vals[0] = 0.0 + for i in range(1, len(序列)): + if i < len(gains): + avg_gain = alpha * gains[i] + (1 - alpha) * avg_gain + avg_loss = alpha * losses[i] + (1 - alpha) * avg_loss + if avg_loss == 0: + rsi_vals[i] = 100.0 + else: + rs = avg_gain / avg_loss + rsi_vals[i] = 100.0 - 100.0 / (1 + rs) + us_obj = UltimateSmoother.首次计算(rsi_vals, 时间序列, 平滑周期) + last_val = us_obj.平滑值 + obj = cls(时间戳=时间序列[-1], 收盘价=序列[-1], 周期=周期, 平滑周期=平滑周期, rsx=last_val, _上一个收=序列[-1], _avg_gain_ema=avg_gain, _avg_loss_ema=avg_loss, _rsi历史=deque(rsi_vals[-平滑周期:], maxlen=平滑周期), _us=us_obj) + return obj + + @classmethod + def 增量计算(cls, 前一个: "RSXSS2", 新收盘价: float, 新时间: datetime) -> "RSXSS2": + delta = 新收盘价 - 前一个._上一个收 + gain = delta if delta > 0 else 0.0 + loss = -delta if delta < 0 else 0.0 + alpha = 1.0 / 前一个.周期 + avg_gain = alpha * gain + (1 - alpha) * 前一个._avg_gain_ema + avg_loss = alpha * loss + (1 - alpha) * 前一个._avg_loss_ema + if avg_loss == 0: + rsi = 100.0 + else: + rs = avg_gain / avg_loss + rsi = 100.0 - 100.0 / (1 + rs) + 新us = UltimateSmoother.增量计算(前一个._us, rsi, 新时间) + return cls(时间戳=新时间, 收盘价=新收盘价, 周期=前一个.周期, 平滑周期=前一个.平滑周期, rsx=新us.平滑值, _上一个收=新收盘价, _avg_gain_ema=avg_gain, _avg_loss_ema=avg_loss, _rsi历史=前一个._rsi历史, _us=新us) + + +# ======================= 18. Jurik Volty ======================= +class JurikVolty: + """Jurik波动平滑器 — 低噪声波动指标""" + + def __init__( + self, + 时间戳: datetime, + 收盘价: float, + 周期: int, + 强度: float, + 波动率: float = 0.0, + _上一个收盘: float = 0.0, + _smooth1_ema: float = 0.0, + _smooth2_ema: float = 0.0, + _jurik_val: float = 0.0, + _result_ema: float = 0.0, + _初始化完成: bool = False, + ): + self.时间戳 = 时间戳 + self.收盘价 = 收盘价 + self.周期 = 周期 + self.强度 = 强度 + self.波动率 = 波动率 + self._上一个收盘: float = _上一个收盘 + self._smooth1_ema: float = _smooth1_ema + self._smooth2_ema: float = _smooth2_ema + self._jurik_val: float = _jurik_val + self._result_ema: float = _result_ema + self._初始化完成: bool = _初始化完成 + + @classmethod + def 首次计算(cls, 序列: List[float], 时间序列: List[datetime], 周期: int, 强度: float) -> "JurikVolty": + if len(序列) < 周期: + raise ValueError("数据长度不足") + changes = [abs(序列[i] - 序列[i - 1]) for i in range(1, len(序列))] + if not changes: + raise ValueError("数据过短") + span1 = 周期 // 2 + alpha1 = 2.0 / (span1 + 1) + smooth1 = [changes[0]] + for c in changes[1:]: + smooth1.append(alpha1 * c + (1 - alpha1) * smooth1[-1]) + smooth2 = [smooth1[0]] + for s in smooth1[1:]: + smooth2.append(alpha1 * s + (1 - alpha1) * smooth2[-1]) + jv = [0.0] * len(序列) + for i in range(2, len(序列)): + idx = i - 1 + if idx >= len(smooth2): + break + jv[i] = (smooth2[idx] + 0.5 * (smooth2[idx] - smooth2[idx - 1])) * 强度 + span3 = 周期 // 3 + alpha3 = 2.0 / (span3 + 1) + result = [0.0] * len(序列) + first_valid = next((i for i, v in enumerate(jv) if v != 0.0), None) + if first_valid is None: + raise ValueError("无法初始化") + result[first_valid] = jv[first_valid] + for i in range(first_valid + 1, len(序列)): + result[i] = alpha3 * jv[i] + (1 - alpha3) * result[i - 1] + last_val = result[-1] + obj = cls(时间戳=时间序列[-1], 收盘价=序列[-1], 周期=周期, 强度=强度, 波动率=last_val, _上一个收盘=序列[-1], _smooth1_ema=smooth1[-1], _smooth2_ema=smooth2[-1], _jurik_val=jv[-1], _result_ema=last_val, _初始化完成=True) + return obj + + @classmethod + def 增量计算(cls, 前一个: "JurikVolty", 新收盘价: float, 新时间: datetime) -> "JurikVolty": + if not 前一个._初始化完成: + raise RuntimeError("请先使用首次计算进行初始化") + change = abs(新收盘价 - 前一个._上一个收盘) + span1 = 前一个.周期 // 2 + alpha1 = 2.0 / (span1 + 1) + smooth1 = alpha1 * change + (1 - alpha1) * 前一个._smooth1_ema + smooth2 = alpha1 * smooth1 + (1 - alpha1) * 前一个._smooth2_ema + jurik = (smooth2 + 0.5 * (smooth2 - 前一个._smooth2_ema)) * 前一个.强度 + span3 = 前一个.周期 // 3 + alpha3 = 2.0 / (span3 + 1) + result = alpha3 * jurik + (1 - alpha3) * 前一个._result_ema + return cls(时间戳=新时间, 收盘价=新收盘价, 周期=前一个.周期, 强度=前一个.强度, 波动率=result, _上一个收盘=新收盘价, _smooth1_ema=smooth1, _smooth2_ema=smooth2, _jurik_val=jurik, _result_ema=result, _初始化完成=True) + + +# ======================= 19. Ultimate Channel ======================= +class UltimateChannel: + """终极通道 — 基于终极平滑器的通道指标""" + + def __init__( + self, + 时间戳: datetime, + 最高: float, + 最低: float, + 收盘价: float, + 周期: int, + 乘数: float, + 中线: float = 0.0, + 上轨: float = 0.0, + 下轨: float = 0.0, + _us: Optional[UltimateSmoother] = None, + _atr_ema: float = 0.0, + _atr_us: Optional[UltimateSmoother] = None, + _prev_close: float = 0.0, + ): + self.时间戳 = 时间戳 + self.最高 = 最高 + self.最低 = 最低 + self.收盘价 = 收盘价 + self.周期 = 周期 + self.乘数 = 乘数 + self.中线 = 中线 + self.上轨 = 上轨 + self.下轨 = 下轨 + self._us: Optional[UltimateSmoother] = _us + self._atr_ema: float = _atr_ema + self._atr_us: Optional[UltimateSmoother] = _atr_us + self._prev_close: float = _prev_close + + @classmethod + def 首次计算(cls, high: List[float], low: List[float], close: List[float], 时间序列: List[datetime], 周期: int, 乘数: float) -> "UltimateChannel": + n = 周期 + if len(close) < n: + raise ValueError("数据长度不足") + us_mid = UltimateSmoother.首次计算(close, 时间序列, float(n)) + tr_vals = [] + prev_close = close[0] + for i in range(len(high)): + if i == 0: + tr = high[i] - low[i] + else: + tr = max(high[i] - low[i], abs(high[i] - prev_close), abs(low[i] - prev_close)) + tr_vals.append(tr) + prev_close = close[i] + alpha = 1.0 / n + atr_ema = sum(tr_vals[:n]) / n + for i in range(n, len(tr_vals)): + atr_ema = alpha * tr_vals[i] + (1 - alpha) * atr_ema + atr_seq = [0.0] * len(close) + atr_seq[n - 1] = sum(tr_vals[:n]) / n + for i in range(n, len(close)): + atr_seq[i] = alpha * tr_vals[i] + (1 - alpha) * atr_seq[i - 1] + us_atr = UltimateSmoother.首次计算(atr_seq, 时间序列, float(n // 2)) + last = len(close) - 1 + mid = us_mid.平滑值 + str_val = us_atr.平滑值 + upper = mid + 乘数 * str_val + lower = mid - 乘数 * str_val + obj = cls(时间戳=时间序列[last], 最高=high[last], 最低=low[last], 收盘价=close[last], 周期=周期, 乘数=乘数, 中线=mid, 上轨=upper, 下轨=lower, _us=us_mid, _atr_ema=atr_ema, _atr_us=us_atr, _prev_close=close[last]) + return obj + + @classmethod + def 增量计算(cls, 前一个: "UltimateChannel", 新高: float, 新低: float, 新收: float, 新时间: datetime) -> "UltimateChannel": + 新us = UltimateSmoother.增量计算(前一个._us, 新收, 新时间) + tr = max(新高 - 新低, abs(新高 - 前一个._prev_close), abs(新低 - 前一个._prev_close)) + alpha = 1.0 / 前一个.周期 + atr_ema = alpha * tr + (1 - alpha) * 前一个._atr_ema + 新us_atr = UltimateSmoother.增量计算(前一个._atr_us, atr_ema, 新时间) + mid = 新us.平滑值 + str_val = 新us_atr.平滑值 + upper = mid + 前一个.乘数 * str_val + lower = mid - 前一个.乘数 * str_val + return cls(时间戳=新时间, 最高=新高, 最低=新低, 收盘价=新收, 周期=前一个.周期, 乘数=前一个.乘数, 中线=mid, 上轨=upper, 下轨=lower, _us=新us, _atr_ema=atr_ema, _atr_us=新us_atr, _prev_close=新收) + + +# ======================= 20. Ultimate Bands ======================= +class UltimateBands: + """终极带 — 基于终极平滑器的布林带变体""" + + def __init__( + self, + 时间戳: datetime, + 收盘价: float, + 周期: int, + 标准差乘数: float, + 平滑周期: int, + 中线: float = 0.0, + 上轨: float = 0.0, + 下轨: float = 0.0, + _us_mid: Optional[UltimateSmoother] = None, + _close窗口: Optional[Deque[float]] = None, + _std_us: Optional[UltimateSmoother] = None, + ): + self.时间戳 = 时间戳 + self.收盘价 = 收盘价 + self.周期 = 周期 + self.标准差乘数 = 标准差乘数 + self.平滑周期 = 平滑周期 + self.中线 = 中线 + self.上轨 = 上轨 + self.下轨 = 下轨 + self._us_mid: Optional[UltimateSmoother] = _us_mid + self._close窗口: Deque[float] = _close窗口 if _close窗口 is not None else deque() + self._std_us: Optional[UltimateSmoother] = _std_us + + @classmethod + def 首次计算(cls, 序列: List[float], 时间序列: List[datetime], 周期: int, 标准差乘数: float, 平滑周期: int) -> "UltimateBands": + n = 周期 + if len(序列) < n: + raise ValueError("数据长度不足") + us_mid = UltimateSmoother.首次计算(序列, 时间序列, float(n)) + std_seq = [0.0] * len(序列) + for i in range(n - 1, len(序列)): + win = 序列[i - n + 1 : i + 1] + mean = sum(win) / n + var = sum((x - mean) ** 2 for x in win) / (n - 1) + std_seq[i] = math.sqrt(var) + for i in range(n - 1): + std_seq[i] = float("nan") + us_std = UltimateSmoother.首次计算(std_seq, 时间序列, float(平滑周期)) + last = len(序列) - 1 + mid = us_mid.平滑值 + smooth_std = us_std.平滑值 + upper = mid + 标准差乘数 * smooth_std + lower = mid - 标准差乘数 * smooth_std + win_deque = deque(序列[last - n + 1 : last + 1], maxlen=n) + return cls(时间戳=时间序列[last], 收盘价=序列[last], 周期=周期, 标准差乘数=标准差乘数, 平滑周期=平滑周期, 中线=mid, 上轨=upper, 下轨=lower, _us_mid=us_mid, _close窗口=win_deque, _std_us=us_std) + + @classmethod + def 增量计算(cls, 前一个: "UltimateBands", 新收盘价: float, 新时间: datetime) -> "UltimateBands": + 新us = UltimateSmoother.增量计算(前一个._us_mid, 新收盘价, 新时间) + win = 前一个._close窗口 + win.append(新收盘价) + mean = sum(win) / len(win) + var = sum((x - mean) ** 2 for x in win) / (len(win) - 1) + std = math.sqrt(var) + 新std_us = UltimateSmoother.增量计算(前一个._std_us, std, 新时间) + mid = 新us.平滑值 + smooth_std = 新std_us.平滑值 + upper = mid + 前一个.标准差乘数 * smooth_std + lower = mid - 前一个.标准差乘数 * smooth_std + return cls(时间戳=新时间, 收盘价=新收盘价, 周期=前一个.周期, 标准差乘数=前一个.标准差乘数, 平滑周期=前一个.平滑周期, 中线=mid, 上轨=upper, 下轨=lower, _us_mid=新us, _close窗口=win, _std_us=新std_us) + + +# ======================= 21. Ultimate Oscillator ======================= +class UltimateOscillator: + """终极波动指标 (UOS) — 多周期融合""" + + def __init__( + self, + 时间戳: datetime, + 最高: float, + 最低: float, + 收盘价: float, + 短周期: int, + 中周期: int, + 长周期: int, + UOS: float = 0.0, + _bp_short: Optional[Deque[float]] = None, + _tr_short: Optional[Deque[float]] = None, + _bp_med: Optional[Deque[float]] = None, + _tr_med: Optional[Deque[float]] = None, + _bp_long: Optional[Deque[float]] = None, + _tr_long: Optional[Deque[float]] = None, + _prev_close: float = 0.0, + ): + self.时间戳 = 时间戳 + self.最高 = 最高 + self.最低 = 最低 + self.收盘价 = 收盘价 + self.短周期 = 短周期 + self.中周期 = 中周期 + self.长周期 = 长周期 + self.UOS = UOS + self._bp_short: Deque[float] = _bp_short if _bp_short is not None else deque() + self._tr_short: Deque[float] = _tr_short if _tr_short is not None else deque() + self._bp_med: Deque[float] = _bp_med if _bp_med is not None else deque() + self._tr_med: Deque[float] = _tr_med if _tr_med is not None else deque() + self._bp_long: Deque[float] = _bp_long if _bp_long is not None else deque() + self._tr_long: Deque[float] = _tr_long if _tr_long is not None else deque() + self._prev_close: float = _prev_close + + @classmethod + def 首次计算(cls, high: List[float], low: List[float], close: List[float], 时间序列: List[datetime], 短: int = 7, 中: int = 14, 长: int = 28) -> "UltimateOscillator": + if len(close) < 长: + raise ValueError("数据长度至少等于长周期") + prev_close = close[0] + bp_short = deque(maxlen=短) + tr_short = deque(maxlen=短) + bp_med = deque(maxlen=中) + tr_med = deque(maxlen=中) + bp_long = deque(maxlen=长) + tr_long = deque(maxlen=长) + uos_vals = [] + for i in range(1, len(close)): + true_low = min(low[i], prev_close) + bp = close[i] - true_low + tr = max(high[i] - low[i], abs(high[i] - prev_close), abs(low[i] - prev_close)) + bp_short.append(bp) + tr_short.append(tr) + bp_med.append(bp) + tr_med.append(tr) + bp_long.append(bp) + tr_long.append(tr) + if len(bp_long) == 长: + avg7 = sum(bp_short) / sum(tr_short) if sum(tr_short) != 0 else 0.0 + avg14 = sum(bp_med) / sum(tr_med) if sum(tr_med) != 0 else 0.0 + avg28 = sum(bp_long) / sum(tr_long) if sum(tr_long) != 0 else 0.0 + uos = 100.0 * (4.0 * avg7 + 2.0 * avg14 + avg28) / 7.0 + uos_vals.append(uos) + else: + uos_vals.append(float("nan")) + prev_close = close[i] + last = len(uos_vals) - 1 + while last >= 0 and math.isnan(uos_vals[last]): + last -= 1 + if last < 0: + raise ValueError("无法计算有效UOS") + obj = cls(时间戳=时间序列[last], 最高=high[last], 最低=low[last], 收盘价=close[last], 短周期=短, 中周期=中, 长周期=长, UOS=uos_vals[last], _bp_short=bp_short, _tr_short=tr_short, _bp_med=bp_med, _tr_med=tr_med, _bp_long=bp_long, _tr_long=tr_long, _prev_close=close[last]) + return obj + + @classmethod + def 增量计算(cls, 前一个: "UltimateOscillator", 新高: float, 新低: float, 新收: float, 新时间: datetime) -> "UltimateOscillator": + true_low = min(新低, 前一个._prev_close) + bp = 新收 - true_low + tr = max(新高 - 新低, abs(新高 - 前一个._prev_close), abs(新低 - 前一个._prev_close)) + bp_short = 前一个._bp_short + tr_short = 前一个._tr_short + bp_med = 前一个._bp_med + tr_med = 前一个._tr_med + bp_long = 前一个._bp_long + tr_long = 前一个._tr_long + bp_short.append(bp) + tr_short.append(tr) + bp_med.append(bp) + tr_med.append(tr) + bp_long.append(bp) + tr_long.append(tr) + if len(bp_long) < 前一个.长周期: + uos = float("nan") + else: + avg7 = sum(bp_short) / sum(tr_short) if sum(tr_short) != 0 else 0.0 + avg14 = sum(bp_med) / sum(tr_med) if sum(tr_med) != 0 else 0.0 + avg28 = sum(bp_long) / sum(tr_long) if sum(tr_long) != 0 else 0.0 + uos = 100.0 * (4.0 * avg7 + 2.0 * avg14 + avg28) / 7.0 + return cls(时间戳=新时间, 最高=新高, 最低=新低, 收盘价=新收, 短周期=前一个.短周期, 中周期=前一个.中周期, 长周期=前一个.长周期, UOS=uos, _bp_short=bp_short, _tr_short=tr_short, _bp_med=bp_med, _tr_med=tr_med, _bp_long=bp_long, _tr_long=tr_long, _prev_close=新收) + + +# ======================= 22. Exponential Smoothing ======================= +class ExponentialSmoothing: + """指数平滑""" + + def __init__( + self, + 时间戳: datetime, + 值: float, + alpha: float, + 平滑值: float = 0.0, + _上一个平滑: Optional[float] = None, + ): + self.时间戳 = 时间戳 + self.值 = 值 + self.alpha = alpha + self.平滑值 = 平滑值 + self._上一个平滑: float = _上一个平滑 if _上一个平滑 is not None else 平滑值 + + @classmethod + def 首次计算(cls, 序列: List[float], 时间序列: List[datetime], alpha: float) -> "ExponentialSmoothing": + obj = cls(时间戳=时间序列[0], 值=序列[0], alpha=alpha, 平滑值=序列[0], _上一个平滑=序列[0]) + for i in range(1, len(序列)): + obj = cls.增量计算(obj, 序列[i], 时间序列[i]) + return obj + + @classmethod + def 增量计算(cls, 前一个: "ExponentialSmoothing", 新值: float, 新时间: datetime) -> "ExponentialSmoothing": + 平滑 = 前一个.alpha * 新值 + (1 - 前一个.alpha) * 前一个._上一个平滑 + return cls(时间戳=新时间, 值=新值, alpha=前一个.alpha, 平滑值=平滑, _上一个平滑=平滑) + + +# ======================= 23. Holt-Winters ======================= +class HoltWinters: + """Holt-Winters 三参数平滑""" + + def __init__( + self, + 时间戳: datetime, + 值: float, + 季节长度: int, + alpha: float, + beta: float, + gamma: float, + 拟合值: float = 0.0, + _level: Optional[float] = None, + _trend: float = 0.0, + _季节: Optional[Deque[float]] = None, + ): + self.时间戳 = 时间戳 + self.值 = 值 + self.季节长度 = 季节长度 + self.alpha = alpha + self.beta = beta + self.gamma = gamma + self.拟合值 = 拟合值 + self._level: float = _level if _level is not None else 值 + self._trend: float = _trend + self._季节: Deque[float] = _季节 if _季节 is not None else deque() + + @classmethod + def 首次计算(cls, 序列: List[float], 时间序列: List[datetime], 季节长度: int, alpha: float, beta: float, gamma: float) -> "HoltWinters": + if len(序列) < 季节长度: + raise ValueError + initial_level = sum(序列[:季节长度]) / 季节长度 + level = initial_level + trend = 0.0 + 季节 = deque([序列[i] - initial_level for i in range(季节长度)], maxlen=季节长度) + fit = 序列[季节长度 - 1] + obj = cls(时间戳=时间序列[季节长度 - 1], 值=序列[季节长度 - 1], 季节长度=季节长度, alpha=alpha, beta=beta, gamma=gamma, 拟合值=fit, _level=level, _trend=trend, _季节=季节) + for i in range(季节长度, len(序列)): + obj = cls.增量计算(obj, 序列[i], 时间序列[i]) + return obj + + @classmethod + def 增量计算(cls, 前一个: "HoltWinters", 新值: float, 新时间: datetime) -> "HoltWinters": + L = 前一个.季节长度 + season_old = 前一个._季节[0] + level = 前一个.alpha * (新值 - season_old) + (1 - 前一个.alpha) * (前一个._level + 前一个._trend) + trend = 前一个.beta * (level - 前一个._level) + (1 - 前一个.beta) * 前一个._trend + season_new = 前一个.gamma * (新值 - level) + (1 - 前一个.gamma) * season_old + 季节 = 前一个._季节 + 季节.append(season_new) + fit = level + trend + season_new + return cls(时间戳=新时间, 值=新值, 季节长度=L, alpha=前一个.alpha, beta=前一个.beta, gamma=前一个.gamma, 拟合值=fit, _level=level, _trend=trend, _季节=季节) diff --git a/chanlun-py/chanlun/parse.py b/chanlun-py/chanlun/parse.py new file mode 100644 index 0000000..8d7e008 --- /dev/null +++ b/chanlun-py/chanlun/parse.py @@ -0,0 +1,1087 @@ +# Copyright (c) 2012-2019 Richard Jones +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from __future__ import absolute_import + +import logging +import re +import sys +from datetime import datetime +from datetime import time +from datetime import timedelta +from datetime import tzinfo +from decimal import Decimal +from functools import partial + + +__version__ = "1.22.1" +__all__ = ["parse", "search", "findall", "with_pattern"] + +log = logging.getLogger(__name__) + + +def with_pattern(pattern, regex_group_count=None): + r"""Attach a regular expression pattern matcher to a custom type converter + function. + + This annotates the type converter with the :attr:`pattern` attribute. + + EXAMPLE: + >>> import parse + >>> @parse.with_pattern(r"\d+") + ... def parse_number(text): + ... return int(text) + + is equivalent to: + + >>> def parse_number(text): + ... return int(text) + >>> parse_number.pattern = r"\d+" + + :param pattern: regular expression pattern (as text) + :param regex_group_count: Indicates how many regex-groups are in pattern. + :return: wrapped function + """ + + def decorator(func): + func.pattern = pattern + func.regex_group_count = regex_group_count + return func + + return decorator + + +class int_convert: + """Convert a string to an integer. + + The string may start with a sign. + + It may be of a base other than 2, 8, 10 or 16. + + If base isn't specified, it will be detected automatically based + on a string format. When string starts with a base indicator, 0#nnnn, + it overrides the default base of 10. + + It may also have other non-numeric characters that we can ignore. + """ + + CHARS = "0123456789abcdefghijklmnopqrstuvwxyz" + + def __init__(self, base=None): + self.base = base + + def __call__(self, string, match): + if string[0] == "-": + sign = -1 + number_start = 1 + elif string[0] == "+": + sign = 1 + number_start = 1 + else: + sign = 1 + number_start = 0 + + base = self.base + # If base wasn't specified, detect it automatically + if base is None: + # Assume decimal number, unless different base is detected + base = 10 + + # For number formats starting with 0b, 0o, 0x, use corresponding base ... + if string[number_start] == "0" and len(string) - number_start > 2: + if string[number_start + 1] in "bB": + base = 2 + elif string[number_start + 1] in "oO": + base = 8 + elif string[number_start + 1] in "xX": + base = 16 + + chars = int_convert.CHARS[:base] + string = re.sub("[^%s]" % chars, "", string.lower()) + return sign * int(string, base) + + +class convert_first: + """Convert the first element of a pair. + This equivalent to lambda s,m: converter(s). But unlike a lambda function, it can be pickled + """ + + def __init__(self, converter): + self.converter = converter + + def __call__(self, string, match): + return self.converter(string) + + +def percentage(string, match): + return float(string[:-1]) / 100.0 + + +class FixedTzOffset(tzinfo): + """Fixed offset in minutes east from UTC.""" + + ZERO = timedelta(0) + + def __init__(self, offset, name): + self._offset = timedelta(minutes=offset) + self._name = name + + def __repr__(self): + return "<%s %s %s>" % (self.__class__.__name__, self._name, self._offset) + + def utcoffset(self, dt): + return self._offset + + def tzname(self, dt): + return self._name + + def dst(self, dt): + return self.ZERO + + def __eq__(self, other): + if not isinstance(other, FixedTzOffset): + return NotImplemented + return self._name == other._name and self._offset == other._offset + + +MONTHS_MAP = { + "Jan": 1, + "January": 1, + "Feb": 2, + "February": 2, + "Mar": 3, + "March": 3, + "Apr": 4, + "April": 4, + "May": 5, + "Jun": 6, + "June": 6, + "Jul": 7, + "July": 7, + "Aug": 8, + "August": 8, + "Sep": 9, + "September": 9, + "Oct": 10, + "October": 10, + "Nov": 11, + "November": 11, + "Dec": 12, + "December": 12, +} +DAYS_PAT = r"(Mon|Tue|Wed|Thu|Fri|Sat|Sun)" +MONTHS_PAT = r"(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)" +ALL_MONTHS_PAT = r"(%s)" % "|".join(MONTHS_MAP) +TIME_PAT = r"(\d{1,2}:\d{1,2}(:\d{1,2}(\.\d+)?)?)" +AM_PAT = r"(\s+[AP]M)" +TZ_PAT = r"(\s+[-+]\d\d?:?\d\d)" + + +def date_convert( + string, + match, + ymd=None, + mdy=None, + dmy=None, + d_m_y=None, + hms=None, + am=None, + tz=None, + mm=None, + dd=None, +): + """Convert the incoming string containing some date / time info into a + datetime instance. + """ + groups = match.groups() + time_only = False + if mm and dd: + y = datetime.today().year + m = groups[mm] + d = groups[dd] + elif ymd is not None: + y, m, d = re.split(r"[-/\s]", groups[ymd]) + elif mdy is not None: + m, d, y = re.split(r"[-/\s]", groups[mdy]) + elif dmy is not None: + d, m, y = re.split(r"[-/\s]", groups[dmy]) + elif d_m_y is not None: + d, m, y = d_m_y + d = groups[d] + m = groups[m] + y = groups[y] + else: + time_only = True + + H = M = S = u = 0 + if hms is not None and groups[hms]: + t = groups[hms].split(":") + if len(t) == 2: + H, M = t + else: + H, M, S = t + if "." in S: + S, u = S.split(".") + u = int(u.ljust(6, "0")[:6]) + S = int(S) + H = int(H) + M = int(M) + + if am is not None: + am = groups[am] + if am: + am = am.strip() + if am == "AM" and H == 12: + # correction for "12" hour functioning as "0" hour: 12:15 AM = 00:15 by 24 hr clock + H -= 12 + elif am == "PM" and H == 12: + # no correction needed: 12PM is midday, 12:00 by 24 hour clock + pass + elif am == "PM": + H += 12 + + if tz is not None: + tz = groups[tz] + if tz == "Z": + tz = FixedTzOffset(0, "UTC") + elif tz: + tz = tz.strip() + if tz.isupper(): + # TODO use the awesome python TZ module? + pass + else: + sign = tz[0] + if ":" in tz: + tzh, tzm = tz[1:].split(":") + elif len(tz) == 4: # 'snnn' + tzh, tzm = tz[1], tz[2:4] + else: + tzh, tzm = tz[1:3], tz[3:5] + offset = int(tzm) + int(tzh) * 60 + if sign == "-": + offset = -offset + tz = FixedTzOffset(offset, tz) + + if time_only: + d = time(H, M, S, u, tzinfo=tz) + else: + y = int(y) + if m.isdigit(): + m = int(m) + else: + m = MONTHS_MAP[m] + d = int(d) + d = datetime(y, m, d, H, M, S, u, tzinfo=tz) + + return d + + +def strf_date_convert(x, _, type): + is_date = any("%" + x in type for x in "aAwdbBmyYjUW") + is_time = any("%" + x in type for x in "HIpMSfz") + + dt = datetime.strptime(x, type) + if "%y" not in type and "%Y" not in type: # year not specified + dt = dt.replace(year=datetime.today().year) + + if is_date and is_time: + return dt + elif is_date: + return dt.date() + elif is_time: + return dt.time() + else: + raise ValueError("Datetime not a date nor a time?") + + +# ref: https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes +dt_format_to_regex = { + "%a": "(?:Sun|Mon|Tue|Wed|Thu|Fri|Sat)", + "%A": "(?:Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday)", + "%w": "[0-6]", + "%d": "[0-9]{1,2}", + "%b": "(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)", + "%B": "(?:January|February|March|April|May|June|July|August|September|October|November|December)", + "%m": "[0-9]{1,2}", + "%y": "[0-9]{2}", + "%Y": "[0-9]{4}", + "%H": "[0-9]{1,2}", + "%I": "[0-9]{1,2}", + "%p": "(?:AM|PM)", + "%M": "[0-9]{2}", + "%S": "[0-9]{2}", + "%f": "[0-9]{1,6}", + "%z": "[+|-][0-9]{2}(:?[0-9]{2})?(:?[0-9]{2})?", + # "%Z": punt + "%j": "[0-9]{1,3}", + "%U": "[0-9]{1,2}", + "%W": "[0-9]{1,2}", +} + +# Compile a regular expression pattern that matches any date/time format symbol. +dt_format_symbols_re = re.compile("|".join(dt_format_to_regex)) + + +def get_regex_for_datetime_format(format_): + """ + Generate a regex pattern for a given datetime format string. + + Parameters: + format_ (str): The datetime format string. + + Returns: + str: A regex pattern corresponding to the datetime format string. + """ + # Replace all format symbols with their regex patterns. + return dt_format_symbols_re.sub(lambda m: dt_format_to_regex[m.group(0)], format_) + + +class TooManyFields(ValueError): + pass + + +class RepeatedNameError(ValueError): + pass + + +# note: {} are handled separately +REGEX_SAFETY = re.compile(r"([?\\.[\]()*+^$!|])") + +# allowed field types +ALLOWED_TYPES = set(list("nbox%fFegwWdDsSl") + ["t" + c for c in "ieahgcts"]) + + +def extract_format(format, extra_types): + """Pull apart the format [[fill]align][sign][0][width][grouping][.precision][type]""" + fill = align = None + if format[0] in "<>=^": + align = format[0] + format = format[1:] + elif len(format) > 1 and format[1] in "<>=^": + fill = format[0] + align = format[1] + format = format[2:] + + if format.startswith(("+", "-", " ")): + format = format[1:] + + zero = False + if format and format[0] == "0": + zero = True + format = format[1:] + + width = "" + while format: + if not format[0].isdigit(): + break + width += format[0] + format = format[1:] + + # Extract grouping option + if format.startswith(","): + format = format[1:] + grouping = "," + elif format.startswith("_"): + format = format[1:] + grouping = "_" + + if format.startswith("."): + # Precision isn't needed but we need to capture it so that + # the ValueError isn't raised. + format = format[1:] # drop the '.' + precision = "" + while format: + if not format[0].isdigit(): + break + precision += format[0] + format = format[1:] + + # the rest is the type, if present + type = format + if type and type not in ALLOWED_TYPES and type not in extra_types and not any(k in type for k in dt_format_to_regex): + raise ValueError("format spec %r not recognised" % type) + + return locals() + + +PARSE_RE = re.compile(r"({{|}}|{[\w-]*(?:\.[\w-]+|\[[^]]+])*(?::[^}]+)?})") + + +class Parser(object): + """Encapsulate a format string that may be used to parse other strings.""" + + def __init__(self, format, extra_types=None, case_sensitive=False): + # a mapping of a name as in {hello.world} to a regex-group compatible + # name, like hello__world. It's used to prevent the transformation of + # name-to-group and group to name to fail subtly, such as in: + # hello_.world-> hello___world->hello._world + self._group_to_name_map = {} + # also store the original field name to group name mapping to allow + # multiple instances of a name in the format string + self._name_to_group_map = {} + # and to sanity check the repeated instances store away the first + # field type specification for the named field + self._name_types = {} + + self._format = format + if extra_types is None: + extra_types = {} + self._extra_types = extra_types + if case_sensitive: + self._re_flags = re.DOTALL + else: + self._re_flags = re.IGNORECASE | re.DOTALL + self._fixed_fields = [] + self._named_fields = [] + self._group_index = 0 + self._type_conversions = {} + self._expression = self._generate_expression() + self.__search_re = None + self.__match_re = None + + log.debug("format %r -> %r", format, self._expression) + + def __repr__(self): + if len(self._format) > 20: + return "<%s %r>" % (self.__class__.__name__, self._format[:17] + "...") + return "<%s %r>" % (self.__class__.__name__, self._format) + + @property + def _search_re(self): + if self.__search_re is None: + try: + self.__search_re = re.compile(self._expression, self._re_flags) + except AssertionError: + # access error through sys to keep py3k and backward compat + e = str(sys.exc_info()[1]) + if e.endswith("this version only supports 100 named groups"): + raise TooManyFields("sorry, you are attempting to parse too many complex fields") + return self.__search_re + + @property + def _match_re(self): + if self.__match_re is None: + expression = r"\A%s\Z" % self._expression + try: + self.__match_re = re.compile(expression, self._re_flags) + except AssertionError: + # access error through sys to keep py3k and backward compat + e = str(sys.exc_info()[1]) + if e.endswith("this version only supports 100 named groups"): + raise TooManyFields("sorry, you are attempting to parse too many complex fields") + except re.error: + raise NotImplementedError("Group names (e.g. (?P) can cause failure, as they are not escaped properly: '%s'" % expression) + return self.__match_re + + @property + def named_fields(self): + return self._named_fields[:] + + @property + def fixed_fields(self): + return self._fixed_fields[:] + + @property + def format(self): + return self._format + + def parse(self, string, evaluate_result=True): + """Match my format to the string exactly. + + Return a Result or Match instance or None if there's no match. + """ + m = self._match_re.match(string) + if m is None: + return None + + if evaluate_result: + return self.evaluate_result(m) + else: + return Match(self, m) + + def search(self, string, pos=0, endpos=None, evaluate_result=True): + """Search the string for my format. + + Optionally start the search at "pos" character index and limit the + search to a maximum index of endpos - equivalent to + search(string[:endpos]). + + If the ``evaluate_result`` argument is set to ``False`` a + Match instance is returned instead of the actual Result instance. + + Return either a Result instance or None if there's no match. + """ + if endpos is None: + endpos = len(string) + m = self._search_re.search(string, pos, endpos) + if m is None: + return None + + if evaluate_result: + return self.evaluate_result(m) + else: + return Match(self, m) + + def findall(self, string, pos=0, endpos=None, extra_types=None, evaluate_result=True): + """Search "string" for all occurrences of "format". + + Optionally start the search at "pos" character index and limit the + search to a maximum index of endpos - equivalent to + search(string[:endpos]). + + Returns an iterator that holds Result or Match instances for each format match + found. + """ + if endpos is None: + endpos = len(string) + return ResultIterator(self, string, pos, endpos, evaluate_result=evaluate_result) + + def _expand_named_fields(self, named_fields): + result = {} + for field, value in named_fields.items(): + # split 'aaa[bbb][ccc]...' into 'aaa' and '[bbb][ccc]...' + n = field.find("[") + if n == -1: + basename, subkeys = field, "" + else: + basename, subkeys = field[:n], field[n:] + + # create nested dictionaries {'aaa': {'bbb': {'ccc': ...}}} + d = result + k = basename + + if subkeys: + for subkey in re.findall(r"\[[^]]+]", subkeys): + d = d.setdefault(k, {}) + k = subkey[1:-1] + + # assign the value to the last key + d[k] = value + + return result + + def evaluate_result(self, m): + """Generate a Result instance for the given regex match object""" + # ok, figure the fixed fields we've pulled out and type convert them + fixed_fields = list(m.groups()) + for n in self._fixed_fields: + if n in self._type_conversions: + fixed_fields[n] = self._type_conversions[n](fixed_fields[n], m) + fixed_fields = tuple(fixed_fields[n] for n in self._fixed_fields) + + # grab the named fields, converting where requested + groupdict = m.groupdict() + named_fields = {} + name_map = {} + for k in self._named_fields: + korig = self._group_to_name_map[k] + name_map[korig] = k + if k in self._type_conversions: + value = self._type_conversions[k](groupdict[k], m) + else: + value = groupdict[k] + + named_fields[korig] = value + + # now figure the match spans + spans = {n: m.span(name_map[n]) for n in named_fields} + spans.update((i, m.span(n + 1)) for i, n in enumerate(self._fixed_fields)) + + # and that's our result + return Result(fixed_fields, self._expand_named_fields(named_fields), spans) + + def _regex_replace(self, match): + return "\\" + match.group(1) + + def _generate_expression(self): + # turn my _format attribute into the _expression attribute + e = [] + for part in PARSE_RE.split(self._format): + if not part: + continue + elif part == "{{": + e.append(r"\{") + elif part == "}}": + e.append(r"\}") + elif part[0] == "{" and part[-1] == "}": + # this will be a braces-delimited field to handle + e.append(self._handle_field(part)) + else: + # just some text to match + e.append(REGEX_SAFETY.sub(self._regex_replace, part)) + return "".join(e) + + def _to_group_name(self, field): + # return a version of field which can be used as capture group, even + # though it might contain '.' + group = field.replace(".", "_").replace("[", "_").replace("]", "_").replace("-", "_") + + # make sure we don't collide ("a.b" colliding with "a_b") + n = 1 + while group in self._group_to_name_map: + n += 1 + if "." in field: + group = field.replace(".", "_" * n) + elif "_" in field: + group = field.replace("_", "_" * n) + elif "-" in field: + group = field.replace("-", "_" * n) + else: + raise KeyError("duplicated group name %r" % (field,)) + + # save off the mapping + self._group_to_name_map[group] = field + self._name_to_group_map[field] = group + return group + + def _handle_field(self, field): + # first: lose the braces + field = field[1:-1] + + # now figure whether this is an anonymous or named field, and whether + # there's any format specification + format = "" + + if ":" in field: + name, format = field.split(":", 1) + else: + name = field + + # This *should* be more flexible, but parsing complicated structures + # out of the string is hard (and not necessarily useful) ... and I'm + # being lazy. So for now `identifier` is "anything starting with a + # letter" and digit args don't get attribute or element stuff. + if name and name[0].isalpha(): + if name in self._name_to_group_map: + if self._name_types[name] != format: + raise RepeatedNameError('field type %r for field "%s" does not match previous seen type %r' % (format, name, self._name_types[name])) + group = self._name_to_group_map[name] + # match previously-seen value + return r"(?P=%s)" % group + else: + group = self._to_group_name(name) + self._name_types[name] = format + self._named_fields.append(group) + # this will become a group, which must not contain dots + wrap = r"(?P<%s>%%s)" % group + else: + self._fixed_fields.append(self._group_index) + wrap = r"(%s)" + group = self._group_index + + # simplest case: no type specifier ({} or {name}) + if not format: + self._group_index += 1 + return wrap % r".+?" + + # decode the format specification + format = extract_format(format, self._extra_types) + + # figure type conversions, if any + type = format["type"] + is_numeric = type and type in "n%fegdobx" + conv = self._type_conversions + if type in self._extra_types: + type_converter = self._extra_types[type] + s = getattr(type_converter, "pattern", r".+?") + regex_group_count = getattr(type_converter, "regex_group_count", 0) + if regex_group_count is None: + regex_group_count = 0 + self._group_index += regex_group_count + conv[group] = convert_first(type_converter) + elif type == "n": + s = r"\d{1,3}([,.]\d{3})*" + self._group_index += 1 + conv[group] = int_convert(10) + elif type == "b": + s = r"(0[bB])?[01]+" + conv[group] = int_convert(2) + self._group_index += 1 + elif type == "o": + s = r"(0[oO])?[0-7]+" + conv[group] = int_convert(8) + self._group_index += 1 + elif type == "x": + s = r"(0[xX])?[0-9a-fA-F]+" + conv[group] = int_convert(16) + self._group_index += 1 + elif type == "%": + s = r"\d+(\.\d+)?%" + self._group_index += 1 + conv[group] = percentage + elif type == "f": + # precision 0 formats without a decimal point (e.g. format(20.0, ".0f") == "20") + s = r"\d+" if format.get("precision") == "0" else r"\d*\.\d+" + conv[group] = convert_first(float) + elif type == "F": + s = r"\d+" if format.get("precision") == "0" else r"\d*\.\d+" + conv[group] = convert_first(Decimal) + elif type == "e": + s = r"\d*\.\d+[eE][-+]?\d+|nan|NAN|[-+]?inf|[-+]?INF" + conv[group] = convert_first(float) + elif type == "g": + s = r"\d+(\.\d+)?([eE][-+]?\d+)?|nan|NAN|[-+]?inf|[-+]?INF" + self._group_index += 2 + conv[group] = convert_first(float) + elif type == "d": + if format.get("width"): + width = r"{1,%s}" % int(format["width"]) + else: + width = "+" + s = r"[-+ ]?[0-9{g}]{w}|[-+ ]?0[xX][0-9a-fA-F{g}]{w}|[-+ ]?0[bB][01{g}]{w}|[-+ ]?0[oO][0-7{g}]{w}".format( + w=width, + g=format.get("grouping", ""), + ) + conv[group] = int_convert() + # do not specify number base, determine it automatically + elif any(k in type for k in dt_format_to_regex): + s = get_regex_for_datetime_format(type) + conv[group] = partial(strf_date_convert, type=type) + elif type == "ti": + s = r"(\d{4}-\d\d-\d\d)((\s+|T)%s)?(Z|\s*[-+]\d\d:?\d\d)?" % TIME_PAT + n = self._group_index + conv[group] = partial(date_convert, ymd=n + 1, hms=n + 4, tz=n + 7) + self._group_index += 7 + elif type == "tg": + s = r"(\d{1,2}[-/](\d{1,2}|%s)[-/]\d{4})(\s+%s)?%s?%s?" + s %= (ALL_MONTHS_PAT, TIME_PAT, AM_PAT, TZ_PAT) + n = self._group_index + conv[group] = partial(date_convert, dmy=n + 1, hms=n + 5, am=n + 8, tz=n + 9) + self._group_index += 9 + elif type == "ta": + s = r"((\d{1,2}|%s)[-/]\d{1,2}[-/]\d{4})(\s+%s)?%s?%s?" + s %= (ALL_MONTHS_PAT, TIME_PAT, AM_PAT, TZ_PAT) + n = self._group_index + conv[group] = partial(date_convert, mdy=n + 1, hms=n + 5, am=n + 8, tz=n + 9) + self._group_index += 9 + elif type == "te": + # this will allow microseconds through if they're present, but meh + s = r"(%s,\s+)?(\d{1,2}\s+%s\s+\d{4})\s+%s%s" + s %= (DAYS_PAT, MONTHS_PAT, TIME_PAT, TZ_PAT) + n = self._group_index + conv[group] = partial(date_convert, dmy=n + 3, hms=n + 5, tz=n + 8) + self._group_index += 8 + elif type == "th": + # slight flexibility here from the stock Apache format + s = r"(\d{1,2}[-/]%s[-/]\d{4}):%s%s" % (MONTHS_PAT, TIME_PAT, TZ_PAT) + n = self._group_index + conv[group] = partial(date_convert, dmy=n + 1, hms=n + 3, tz=n + 6) + self._group_index += 6 + elif type == "tc": + s = r"(%s)\s+%s\s+(\d{1,2})\s+%s\s+(\d{4})" + s %= (DAYS_PAT, MONTHS_PAT, TIME_PAT) + n = self._group_index + conv[group] = partial(date_convert, d_m_y=(n + 4, n + 3, n + 8), hms=n + 5) + self._group_index += 8 + elif type == "tt": + s = r"%s?%s?%s?" % (TIME_PAT, AM_PAT, TZ_PAT) + n = self._group_index + conv[group] = partial(date_convert, hms=n + 1, am=n + 4, tz=n + 5) + self._group_index += 5 + elif type == "ts": + s = r"%s(\s+)(\d+)(\s+)(\d{1,2}:\d{1,2}:\d{1,2})?" % MONTHS_PAT + n = self._group_index + conv[group] = partial(date_convert, mm=n + 1, dd=n + 3, hms=n + 5) + self._group_index += 5 + elif type == "l": + s = r"[A-Za-z]+" + elif type: + s = r"\%s+" % type + elif format.get("precision"): + if format.get("width"): + s = r".{%s,%s}?" % (format["width"], format["precision"]) + else: + s = r".{1,%s}?" % format["precision"] + elif format.get("width"): + s = r".{%s,}?" % format["width"] + else: + s = r".+?" + + align = format["align"] + fill = format["fill"] + + # handle some numeric-specific things like fill and sign + if is_numeric: + # prefix with something (align "=" trumps zero) + if align == "=": + # special case - align "=" acts like the zero above but with + # configurable fill defaulting to "0" + if not fill: + fill = "0" + s = r"%s*" % fill + s + + # allow numbers to be prefixed with a sign + s = r"[-+ ]?" + s + + if not fill: + fill = " " + + # Place into a group now - this captures the value we want to keep. + # Everything else from now is just padding to be stripped off + if wrap: + s = wrap % s + self._group_index += 1 + + if format["width"]: + # all we really care about is that if the format originally + # specified a width then there will probably be padding - without + # an explicit alignment that'll mean right alignment with spaces + # padding + if not align: + align = ">" + + if fill in r".\+?*[](){}^$": + fill = "\\" + fill + + # align "=" has been handled + if align == "<": + s = "%s%s*" % (s, fill) + elif align == ">": + s = "%s*%s" % (fill, s) + elif align == "^": + s = "%s*%s%s*" % (fill, s, fill) + + return s + + +class Result(object): + """The result of a parse() or search(). + + Fixed results may be looked up using `result[index]`. + Slices of fixed results may also be looked up. + + Named results may be looked up using `result['name']`. + + Named results may be tested for existence using `'name' in result`. + """ + + def __init__(self, fixed, named, spans): + self.fixed = fixed + self.named = named + self.spans = spans + + def __getitem__(self, item): + if isinstance(item, (int, slice)): + return self.fixed[item] + return self.named[item] + + def __repr__(self): + return "<%s %r %r>" % (self.__class__.__name__, self.fixed, self.named) + + def __contains__(self, name): + return name in self.named + + +class Match(object): + """The result of a parse() or search() if no results are generated. + + This class is only used to expose internal used regex match objects + to the user and use them for external Parser.evaluate_result calls. + """ + + def __init__(self, parser, match): + self.parser = parser + self.match = match + + def evaluate_result(self): + """Generate results for this Match""" + return self.parser.evaluate_result(self.match) + + +class ResultIterator(object): + """The result of a findall() operation. + + Each element is a Result instance. + """ + + def __init__(self, parser, string, pos, endpos, evaluate_result=True): + self.parser = parser + self.string = string + self.pos = pos + self.endpos = endpos + self.evaluate_result = evaluate_result + + def __iter__(self): + return self + + def __next__(self): + m = self.parser._search_re.search(self.string, self.pos, self.endpos) + if m is None: + raise StopIteration() + self.pos = m.end() + + if self.evaluate_result: + return self.parser.evaluate_result(m) + else: + return Match(self.parser, m) + + # pre-py3k compat + next = __next__ + + +def parse(format, string, extra_types=None, evaluate_result=True, case_sensitive=False): + """Using "format" attempt to pull values from "string". + + The format must match the string contents exactly. If the value + you're looking for is instead just a part of the string use + search(). + + If ``evaluate_result`` is True the return value will be an Result instance with two attributes: + + .fixed - tuple of fixed-position values from the string + .named - dict of named values from the string + + If ``evaluate_result`` is False the return value will be a Match instance with one method: + + .evaluate_result() - This will return a Result instance like you would get + with ``evaluate_result`` set to True + + The default behaviour is to match strings case insensitively. You may match with + case by specifying case_sensitive=True. + + If the format is invalid a ValueError will be raised. + + See the module documentation for the use of "extra_types". + + In the case there is no match parse() will return None. + """ + p = Parser(format, extra_types=extra_types, case_sensitive=case_sensitive) + return p.parse(string, evaluate_result=evaluate_result) + + +def search( + format, + string, + pos=0, + endpos=None, + extra_types=None, + evaluate_result=True, + case_sensitive=False, +): + """Search "string" for the first occurrence of "format". + + The format may occur anywhere within the string. If + instead you wish for the format to exactly match the string + use parse(). + + Optionally start the search at "pos" character index and limit the search + to a maximum index of endpos - equivalent to search(string[:endpos]). + + If ``evaluate_result`` is True the return value will be an Result instance with two attributes: + + .fixed - tuple of fixed-position values from the string + .named - dict of named values from the string + + If ``evaluate_result`` is False the return value will be a Match instance with one method: + + .evaluate_result() - This will return a Result instance like you would get + with ``evaluate_result`` set to True + + The default behaviour is to match strings case insensitively. You may match with + case by specifying case_sensitive=True. + + If the format is invalid a ValueError will be raised. + + See the module documentation for the use of "extra_types". + + In the case there is no match parse() will return None. + """ + p = Parser(format, extra_types=extra_types, case_sensitive=case_sensitive) + return p.search(string, pos, endpos, evaluate_result=evaluate_result) + + +def findall( + format, + string, + pos=0, + endpos=None, + extra_types=None, + evaluate_result=True, + case_sensitive=False, +): + """Search "string" for all occurrences of "format". + + You will be returned an iterator that holds Result instances + for each format match found. + + Optionally start the search at "pos" character index and limit the search + to a maximum index of endpos - equivalent to search(string[:endpos]). + + If ``evaluate_result`` is True each returned Result instance has two attributes: + + .fixed - tuple of fixed-position values from the string + .named - dict of named values from the string + + If ``evaluate_result`` is False each returned value is a Match instance with one method: + + .evaluate_result() - This will return a Result instance like you would get + with ``evaluate_result`` set to True + + The default behaviour is to match strings case insensitively. You may match with + case by specifying case_sensitive=True. + + If the format is invalid a ValueError will be raised. + + See the module documentation for the use of "extra_types". + """ + p = Parser(format, extra_types=extra_types, case_sensitive=case_sensitive) + return p.findall(string, pos, endpos, evaluate_result=evaluate_result) + + +def compile(format, extra_types=None, case_sensitive=False): + """Create a Parser instance to parse "format". + + The resultant Parser has a method .parse(string) which + behaves in the same manner as parse(format, string). + + The default behaviour is to match strings case insensitively. You may match with + case by specifying case_sensitive=True. + + Use this function if you intend to parse many strings + with the same format. + + See the module documentation for the use of "extra_types". + + Returns a Parser instance. + """ + return Parser(format, extra_types=extra_types, case_sensitive=case_sensitive) + + +# Copyright (c) 2012-2020 Richard Jones +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# vim: set filetype=python ts=4 sw=4 et si tw=75 diff --git a/chanlun-py/chanlun/parse.pyi b/chanlun-py/chanlun/parse.pyi new file mode 100644 index 0000000..03efbb7 --- /dev/null +++ b/chanlun-py/chanlun/parse.pyi @@ -0,0 +1,113 @@ +# Copyright (c) 2012-2019 Richard Jones +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import re +from typing import Any, Callable, Generic, Literal, Protocol, TypeVar, overload + +__all__ = ["parse", "search", "findall", "with_pattern"] + +_T = TypeVar("_T") +_T_co = TypeVar("_T_co", covariant=True) + +class _TypeConverter(Protocol[_T_co]): + def __call__(self, string: str) -> _T_co: ... + +_TTypeConverter = TypeVar("_TTypeConverter", bound="_TypeConverter[Any]") + +def with_pattern(pattern: str, regex_group_count=None) -> Callable[[_TTypeConverter], _TTypeConverter]: ... + +class Result: + fixed: tuple[Any, ...] + named: dict[str, Any] + spans: dict[int | str, tuple[int, int]] + + def __init__(self, fixed: tuple[Any, ...], named: dict[str, Any], spans: dict[int | str, tuple[int, int]]) -> None: ... + def __getitem__(self, item) -> Any: ... + def __contains__(self, name) -> bool: ... + +class Match: + parser: "Parser" + match: re.Match # type: ignore[type-arg] + + def __init__(self, parser: "Parser", match: re.Match) -> None: ... # type: ignore[type-arg] + def evaluate_result(self) -> Result: ... + +class ResultIterator(Generic[_T]): + parser: "Parser" + string: str + pos: int + endpos: int + evaluate_result: bool + def __next__(self) -> _T: ... + next = __next__ + + def __init__(self, parser: "Parser", string: str, pos: int, endpos: int | None, evaluate_result: bool = True) -> None: ... + def __iter__(self) -> "ResultIterator[_T]": ... + +class TooManyFields(ValueError): ... +class RepeatedNameError(ValueError): ... + +class Parser: + def __init__(self, format: str, extra_types: dict[str, _TypeConverter[Any]] | None = None, case_sensitive: bool = False) -> None: ... + @property + def named_fields(self) -> list[str]: ... + @property + def fixed_fields(self) -> list[int]: ... + @property + def format(self) -> str: ... + @overload + def parse(self, string: str, evaluate_result: Literal[True] = True) -> Result | None: ... + @overload + def parse(self, string: str, *, evaluate_result: Literal[False]) -> Match | None: ... + @overload + def parse(self, string: str, evaluate_result: Literal[False]) -> Match | None: ... + @overload + def search(self, string: str, pos: int = 0, endpos: int | None = None, evaluate_result: Literal[True] = True) -> Result | None: ... + @overload + def search(self, string: str, pos: int = 0, endpos: int | None = None, *, evaluate_result: Literal[False]) -> Match | None: ... + @overload + def search(self, string: str, pos: int, endpos: int | None, evaluate_result: Literal[False]) -> Match | None: ... + @overload + def findall(self, string: str, pos: int = 0, endpos=None, extra_types: dict[str, _TypeConverter[Any]] | None = None, evaluate_result: Literal[True] = True) -> ResultIterator[Result]: ... + @overload + def findall(self, string: str, pos: int = 0, endpos=None, extra_types: dict[str, _TypeConverter[Any]] | None = None, *, evaluate_result: Literal[False]) -> ResultIterator[Match]: ... + @overload + def findall(self, string: str, pos: int, endpos: int | None, extra_types, evaluate_result: Literal[False]) -> ResultIterator[Match]: ... + def evaluate_result(self, m: re.Match) -> Result: ... # type: ignore[type-arg] + +@overload +def parse(format: str, string: str, extra_types: dict[str, _TypeConverter[Any]] | None = None, evaluate_result: Literal[True] = True, case_sensitive: bool = ...) -> Result | None: ... +@overload +def parse(format: str, string: str, extra_types: dict[str, _TypeConverter[Any]] | None = None, *, evaluate_result: Literal[False], case_sensitive: bool = ...) -> Match | None: ... +@overload +def parse(format: str, string: str, extra_types, evaluate_result: Literal[False], case_sensitive: bool = ...) -> Match | None: ... +@overload +def search(format: str, string: str, pos: int = 0, endpos: int | None = None, extra_types: dict[str, _TypeConverter[Any]] | None = None, evaluate_result: Literal[True] = True, case_sensitive: bool = False) -> Result | None: ... +@overload +def search(format: str, string: str, pos: int = 0, endpos: int | None = None, extra_types: dict[str, _TypeConverter[Any]] | None = None, *, evaluate_result: Literal[False], case_sensitive: bool = False) -> Match | None: ... +@overload +def search(format: str, string: str, pos: int, endpos: int | None, extra_types, evaluate_result: Literal[False], case_sensitive: bool = False) -> Match | None: ... +@overload +def findall(format: str, string: str, pos: int = 0, endpos=None, extra_types: dict[str, _TypeConverter[Any]] | None = None, evaluate_result: Literal[True] = True, case_sensitive: bool = False) -> ResultIterator[Result]: ... +@overload +def findall(format: str, string: str, pos: int = 0, endpos=None, extra_types: dict[str, _TypeConverter[Any]] | None = None, *, evaluate_result: Literal[False], case_sensitive: bool = False) -> ResultIterator[Match]: ... +@overload +def findall(format, string, pos, endpos, extra_types, evaluate_result: Literal[False], case_sensitive: bool = False) -> ResultIterator[Match]: ... +def compile(format: str, extra_types: dict[str, _TypeConverter[Any]] | None = None, case_sensitive: bool = False) -> Parser: ... diff --git a/chanlun-py/chanlun/signals/__init__.py b/chanlun-py/chanlun/signals/__init__.py new file mode 100644 index 0000000..2f1b0a5 --- /dev/null +++ b/chanlun-py/chanlun/signals/__init__.py @@ -0,0 +1,506 @@ +"""缠论技术分析库 — 信号函数模块 + +每个信号函数接收 观察者 对象 + 关键字参数,返回 OrderedDict。 +信号 key 格式:k1_k2_k3,value 格式:v1_v2_v3_score。 + +数据访问路径: +- K线指标:k线.指标.macd.DIF / k线.指标.rsi.RSI / k线.指标.kdj.K / k线.指标.均线["SMA_5"] +- 笔序列:观察员.笔序列(List[虚线]) +- 分型序列:观察员.分型序列(List[分型]) +""" + +from collections import OrderedDict + +from chanlun.chan import 观察者, 分型结构, 虚线, 相对方向 +from chanlun.chan_external import create_single_signal + + +# 信号函数模板 +def 模板_V日期(观察员: 观察者, **kwargs) -> OrderedDict: + """##信号名称介绍## + + 触发条件:## 触发条件 ## + + 参数模板:## 具体模板 如: "{freq}_D{di}#{ma_type}#{timeperiod}MO{max_overlap}_BS辅助V230313" ## + + **信号逻辑:** + + ## 详细信号逻辑 ## + + **信号列表:** + + ## 具体信号 如下: + - Signal('15分钟_D1#SMA#5MO5_BS辅助V230313_看空_向下_任意_0') + - Signal('15分钟_D1#SMA#5MO5_BS辅助V230313_看多_向下_任意_0') + - Signal('15分钟_D1#SMA#5MO5_BS辅助V230313_看多_向上_任意_0') + - Signal('15分钟_D1#SMA#5MO5_BS辅助V230313_看空_向上_任意_0') + ## + + :param 观察员: 观察者对象 + :param kwargs: 其他参数 + - ## 具体参数介绍 ## + :return: 信号识别结果 + """ + ## 具体代码过程 ## + + return ## create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1, v2=v2) ## + + +# ============================================================================== +# tas — 技术指标信号 +# ============================================================================== + + +def tas_ma_base_V230313(c, **kwargs) -> OrderedDict: + """单均线多空和方向辅助开平仓信号 + + 参数模板:"{freq}_D{di}#{ma_type}#{timeperiod}MO{max_overlap}_BS辅助V230313" + + **信号逻辑:** + + 1. close > ma,多头(看多);反之,空头(看空) + 2. ma[-1] > ma[-2],向上;反之,向下 + 3. 加入 max_overlap 参数控制相同信号最大重叠次数 + + **信号列表:** + + - Signal('15分钟_D1#SMA#5MO5_BS辅助V230313_看空_向下_任意_0') + - Signal('15分钟_D1#SMA#5MO5_BS辅助V230313_看多_向下_任意_0') + - Signal('15分钟_D1#SMA#5MO5_BS辅助V230313_看多_向上_任意_0') + - Signal('15分钟_D1#SMA#5MO5_BS辅助V230313_看空_向上_任意_0') + + :param c: 观察者对象 + :param kwargs: 其他参数 + - ma_type: 均线类型(SMA/EMA) + - timeperiod: 均线计算周期 + - di: 信号计算截止倒数第i根K线 + - max_overlap: 相同信号最大重叠次数 + :return: 信号识别结果 + """ + ma_type = kwargs.get("ma_type", "SMA").upper() + timeperiod = int(kwargs.get("timeperiod", 5)) + di = int(kwargs.get("di", 1)) + max_overlap = int(kwargs.get("max_overlap", 5)) + freq = kwargs.get("freq", "15分钟") + + k1, k2, k3 = f"{freq}_D{di}#{ma_type}#{timeperiod}MO{max_overlap}_BS辅助V230313".split("_", 2) + + 普K序列 = c.普通K线序列 + if len(普K序列) < di + 1: + return create_single_signal(k1=k1, k2=k2, k3=k3) + + 当前K线 = 普K序列[-di] + if 当前K线.指标 is None: + return create_single_signal(k1=k1, k2=k2, k3=k3) + + ma_key = f"{ma_type}_{timeperiod}" + 当前均线 = 当前K线.指标.均线.get(ma_key) + if 当前均线 is None: + return create_single_signal(k1=k1, k2=k2, k3=k3) + + 当前价 = 当前K线.收盘价 + v1 = "看多" if 当前价 > 当前均线 else "看空" + + # 均线方向:需要前一根K线的均线值 + if len(普K序列) >= di + 2: + 前K线 = 普K序列[-di - 1] + if 前K线.指标 is not None: + 前均线 = 前K线.指标.均线.get(ma_key) + if 前均线 is not None: + v2 = "向上" if 当前均线 > 前均线 else "向下" + else: + v2 = "任意" + else: + v2 = "任意" + else: + v2 = "任意" + + return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1, v2=v2) + + +def tas_macd_direct_V221106(c, **kwargs) -> OrderedDict: + """MACD 方向信号 — DIF 在零轴上方为多头,下方为空头 + + 参数模板:"{freq}_D{di}#MACD#{fast}#{slow}#{signal}_MACD方向V221106" + + **信号逻辑:** + + 1. DIF > 0,多头;反之,空头 + 2. DIF 值变化趋势(与前一根比较):向上/向下 + + **信号列表:** + + - Signal('15分钟_D1#MACD#13#31#11_MACD方向V221106_看多_向上_任意_0') + - Signal('15分钟_D1#MACD#13#31#11_MACD方向V221106_看多_向下_任意_0') + - Signal('15分钟_D1#MACD#13#31#11_MACD方向V221106_看空_向上_任意_0') + - Signal('15分钟_D1#MACD#13#31#11_MACD方向V221106_看空_向下_任意_0') + + :param c: 观察者对象 + :param kwargs: 其他参数 + - fast: 快线周期(默认 13) + - slow: 慢线周期(默认 31) + - signal: 信号周期(默认 11) + - di: 信号计算截止倒数第i根K线 + :return: 信号识别结果 + """ + fast = int(kwargs.get("fast", 13)) + slow = int(kwargs.get("slow", 31)) + signal = int(kwargs.get("signal", 11)) + di = int(kwargs.get("di", 1)) + freq = kwargs.get("freq", "15分钟") + + k1, k2, k3 = f"{freq}_D{di}#MACD#{fast}#{slow}#{signal}_MACD方向V221106".split("_", 2) + + 普K序列 = c.普通K线序列 + if len(普K序列) < di + 1: + return create_single_signal(k1=k1, k2=k2, k3=k3) + + 当前K线 = 普K序列[-di] + cur_macd = 当前K线.指标.macd if 当前K线.指标 else None + if cur_macd is None or cur_macd.DIF is None: + return create_single_signal(k1=k1, k2=k2, k3=k3) + + v1 = "看多" if cur_macd.DIF > 0 else "看空" + + if len(普K序列) >= di + 2: + 前K线 = 普K序列[-di - 1] + prev_macd = 前K线.指标.macd if 前K线.指标 else None + if prev_macd is not None and prev_macd.DIF is not None: + v2 = "向上" if cur_macd.DIF > prev_macd.DIF else "向下" + else: + v2 = "任意" + else: + v2 = "任意" + + return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1, v2=v2) + + +def macd_金叉(观察员: 观察者, **kwargs) -> OrderedDict: + """MACD 金叉死叉信号 — DIF 与 DEA 的交叉判断 + + 参数模板:"{freq}_D{di}#MACD#{fast}#{slow}#{signal}_MACD交叉V260601" + + **信号逻辑:** + + 1. DIF 上穿 DEA(前一根 DIF <= DEA,当前 DIF > DEA)→ 金叉 + 2. DIF 下穿 DEA(前一根 DIF >= DEA,当前 DIF < DEA)→ 死叉 + + **信号列表:** + + - Signal('15分钟_D1#MACD#13#31#11_MACD交叉V260601_金叉_任意_任意_0') + - Signal('15分钟_D1#MACD#13#31#11_MACD交叉V260601_死叉_任意_任意_0') + + :param 观察员: 观察者对象 + :param kwargs: 其他参数 + - fast: 快线周期(默认 13) + - slow: 慢线周期(默认 31) + - signal: 信号周期(默认 11) + - di: 信号计算截止倒数第i根K线 + :return: 信号识别结果 + """ + fast = int(kwargs.get("fast", 13)) + slow = int(kwargs.get("slow", 31)) + signal = int(kwargs.get("signal", 11)) + di = int(kwargs.get("di", 1)) + freq = kwargs.get("freq", "15分钟") + + k1, k2, k3 = f"{freq}_D{di}#MACD#{fast}#{slow}#{signal}_MACD交叉V260601".split("_", 2) + + 普K序列 = 观察员.普通K线序列 + if len(普K序列) < di + 2: + return create_single_signal(k1=k1, k2=k2, k3=k3) + + 当前K线 = 普K序列[-di] + 前K线 = 普K序列[-di - 1] + + cur_macd = 当前K线.指标.macd if 当前K线.指标 else None + prev_macd = 前K线.指标.macd if 前K线.指标 else None + + if cur_macd is None or prev_macd is None: + return create_single_signal(k1=k1, k2=k2, k3=k3) + if cur_macd.DIF is None or cur_macd.DEA is None: + return create_single_signal(k1=k1, k2=k2, k3=k3) + if prev_macd.DIF is None or prev_macd.DEA is None: + return create_single_signal(k1=k1, k2=k2, k3=k3) + + if prev_macd.DIF <= prev_macd.DEA and cur_macd.DIF > cur_macd.DEA: + v1 = "金叉" + elif prev_macd.DIF >= prev_macd.DEA and cur_macd.DIF < cur_macd.DEA: + v1 = "死叉" + else: + v1 = "任意" + + return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1) + + +# ============================================================================== +# cxt — 缠论形态信号 +# ============================================================================== + + +def cxt_bi_end_V230222(c, **kwargs) -> OrderedDict: + """当前是最后笔的第几次新低底分型或新高顶分型,用于笔结束辅助 + + 触发条件:新分型 + + 参数模板:"{freq}_D1MO{max_overlap}_BE辅助V230222" + + **信号逻辑:** + + 1. 取最后笔及未成笔的分型 + 2. 当前如果是顶分型,则看当前顶分型是否新高,是第几个新高 + 3. 当前如果是底分型,则看当前底分型是否新低,是第几个新低 + + **信号列表:** + + - Signal('日线_D1MO3_BE辅助V230222_新低_第2次_任意_0') + - Signal('日线_D1MO3_BE辅助V230222_新高_第2次_任意_0') + - Signal('日线_D1MO3_BE辅助V230222_新低_第3次_任意_0') + + :param c: 观察者对象 + :param kwargs: + :return: 信号识别结果 + """ + max_overlap = int(kwargs.get("max_overlap", 3)) + freq = kwargs.get("freq", "日线") + k1, k2, k3 = f"{freq}_D1MO{max_overlap}_BE辅助V230222".split("_", 2) + + 分型序列 = c.分型序列 + 笔序列 = c.笔序列 + + if len(分型序列) < 2 or len(笔序列) < 1: + return create_single_signal(k1=k1, k2=k2, k3=k3) + + 最后笔 = 笔序列[-1] + 当前分型 = 分型序列[-1] + + # 找到最后笔的武(终点分型)在分型序列中的位置 + try: + 笔终点索引 = next(i for i, f in enumerate(分型序列) if f.时间戳 == 最后笔.武.时间戳 and f.结构 == 最后笔.武.结构) + except StopIteration: + return create_single_signal(k1=k1, k2=k2, k3=k3) + + # 取笔终点之后的分型(未成笔的分型) + 未成笔分型 = 分型序列[笔终点索引 + 1 :] + if len(未成笔分型) < 1: + return create_single_signal(k1=k1, k2=k2, k3=k3) + + if 当前分型.结构.value == "顶": + # 统计从笔终点到当前的顶分型新高次数 + 笔终点顶高 = 最后笔.武.分型特征值 + 计数 = 0 + for f in 未成笔分型: + if f.结构.value == "顶" and f.分型特征值 > 笔终点顶高: + 计数 += 1 + 笔终点顶高 = f.分型特征值 + if 计数 > 0 and 当前分型.分型特征值 >= 笔终点顶高: + v1, v2 = "新高", f"第{计数}次" + else: + v1, v2 = "任意", "任意" + elif 当前分型.结构.value == "底": + 笔终点底低 = 最后笔.武.分型特征值 + 计数 = 0 + for f in 未成笔分型: + if f.结构.value == "底" and f.分型特征值 < 笔终点底低: + 计数 += 1 + 笔终点底低 = f.分型特征值 + if 计数 > 0 and 当前分型.分型特征值 <= 笔终点底低: + v1, v2 = "新低", f"第{计数}次" + else: + v1, v2 = "任意", "任意" + else: + return create_single_signal(k1=k1, k2=k2, k3=k3) + + return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1, v2=v2) + + +def cxt_停顿分型_V230106(c, **kwargs) -> OrderedDict: + """停顿分型辅助信号 — 结合分型强度和MACD柱子匹配判断 + + 触发条件:新分型 + + 参数模板:"{freq}_D{di}停顿分型_BE辅助V230106" + + **信号逻辑:** + + 判断当前分型是否为停顿分型,结合力度和形态给出信号。 + 停顿分型 = 分型结构为顶/底 + 强度为强/中 + MACD柱子分型匹配。 + + **信号列表:** + + - Signal('1分钟_D0停顿分型_BE辅助V230106_看空_强_任意_0') + - Signal('1分钟_D0停顿分型_BE辅助V230106_看多_强_任意_0') + - Signal('1分钟_D0停顿分型_BE辅助V230106_看空_中_任意_0') + - Signal('1分钟_D0停顿分型_BE辅助V230106_看多_中_任意_0') + + :param c: 观察者对象 + :param kwargs: + :return: 信号识别结果 + """ + di = int(kwargs.get("di", 0)) + freq = kwargs.get("freq", "1分钟") + k1, k2, k3 = f"{freq}_D{di}停顿分型_BE辅助V230106".split("_", 2) + + 分型序列 = c.分型序列 + if len(分型序列) < di + 1: + return create_single_signal(k1=k1, k2=k2, k3=k3) + + 当前分型 = 分型序列[-(di + 1)] + + # 只对顶/底分型产出信号 + if 当前分型.结构.value not in ("顶", "底"): + return create_single_signal(k1=k1, k2=k2, k3=k3) + + v1 = "看空" if 当前分型.结构.value == "顶" else "看多" + v2 = 当前分型.强度() + + # 仅强/中分型 + MACD 柱子匹配时认为是有效的停顿分型 + if v2 in ("强", "中") and 当前分型.与MACD柱子分型匹配(): + pass # 保持 v1, v2 + elif v2 in ("强", "中"): + pass # MACD不匹配也产出,但可能被下游过滤 + else: + v1, v2 = "任意", "任意" + + return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1, v2=v2) + + +def cxt_中枢第三买卖点_V230602(c, **kwargs) -> OrderedDict: + """中枢第三买卖点信号——线段中枢的第三类买卖点识别 + + 触发条件:新中枢 + + 参数模板:"{freq}_D1MO{max_overlap}_中枢第三买卖点V230602" + + **信号逻辑:** + + 1. 取最后一个中枢,仅处理线段中枢(标识="中枢<线段>") + 2. 判断中枢状态(中枢之上→三买,中枢之下→三卖) + 3. 首次穿越0轴:中枢本级第三买卖点后,DIF首次反向穿越0轴并出现对应底/顶分型 + 4. 中枢段DEA穿越2:第三买卖线段内部DEA双向穿越0轴(上穿+下穿均发生) + + **信号列表:** + + - Signal('日线_D1MO3_中枢第三买卖点V230602_首次穿越0轴_三买_任意_0') + - Signal('日线_D1MO3_中枢第三买卖点V230602_首次穿越0轴_三卖_任意_0') + - Signal('日线_D1MO3_中枢第三买卖点V230602_中枢段DEA穿越2_三买_任意_0') + - Signal('日线_D1MO3_中枢第三买卖点V230602_中枢段DEA穿越2_三卖_任意_0') + + :param c: 观察者对象 + :param kwargs: + - max_overlap: 相同信号最大重叠次数 + :return: 信号识别结果 + """ + max_overlap = int(kwargs.get("max_overlap", 3)) + freq = kwargs.get("freq", "日线") + k1, k2, k3 = f"{freq}_D1MO{max_overlap}_中枢第三买卖点V230602".split("_", 2) + + 中枢序列 = c.中枢序列 + if not 中枢序列: + return create_single_signal(k1=k1, k2=k2, k3=k3) + + 当前中枢 = 中枢序列[-1] + if 当前中枢.标识 != "中枢<线段>": + return create_single_signal(k1=k1, k2=k2, k3=k3) + + 状态 = 当前中枢.当前状态() + if 状态 == "中枢之中": + return create_single_signal(k1=k1, k2=k2, k3=k3) + + if 状态 == "中枢之上": + v2 = "三买" + elif 状态 == "中枢之下": + v2 = "三卖" + else: + return create_single_signal(k1=k1, k2=k2, k3=k3) + + v1 = None + + # 1. 首次穿越0轴:本级第三买卖点后,DIF反向穿越0轴并出现对应分型 + if 当前中枢.本级_第三买卖线 is not None and 当前中枢.完整性("合"): + 第三买卖虚线 = 当前中枢.本级_第三买卖线 + 中K线 = 第三买卖虚线.武.中 + 缠K序列 = c.缠论K线序列 + try: + 起点索引 = 缠K序列.index(中K线) + except ValueError: + 起点索引 = 0 + 之后缠K序列 = 缠K序列[起点索引:] + + 之后缠K = None + if 状态 == "中枢之上" and 中K线.标的K线.macd.DIF > 0: + for k in 之后缠K序列: + if k.标的K线.macd.DIF < 0 and 之后缠K is None: + 之后缠K = k + if 之后缠K is not None: + if k.分型 is 分型结构.底 and k.标的K线.macd.DIF < 0: + v1 = "首次穿越0轴" + break + + elif 状态 == "中枢之下" and 中K线.标的K线.macd.DIF < 0: + for k in 之后缠K序列: + if k.标的K线.macd.DIF > 0 and 之后缠K is None: + 之后缠K = k + if 之后缠K is not None: + if k.分型 is 分型结构.顶 and k.标的K线.macd.DIF > 0: + v1 = "首次穿越0轴" + break + + # 2. 中枢段DEA穿越2:第三买卖线段内部DEA双向穿越0轴 + if v1 is None and 当前中枢.第三买卖线 is not None and 当前中枢.完整性("合"): + 第三线 = 当前中枢.第三买卖线 + if 相对方向.分析(当前中枢.高, 当前中枢.低, 第三线.高, 第三线.低).是否缺口(): + 普K序列 = 第三线.获取普K序列(c.观察员) + MACD特性 = 虚线.统计MACD行为(普K序列, 8, 3) + if MACD特性["DEA上穿0"] > 0 and MACD特性["DEA下穿0"] > 0: + v1 = "中枢段DEA穿越2" + + if v1 is None: + return create_single_signal(k1=k1, k2=k2, k3=k3) + + return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1, v2=v2) + + +# ============================================================================== +# bar — K线形态信号 +# ============================================================================== + + +def bar_zdt_V230331(c, **kwargs) -> OrderedDict: + """计算倒数第di根K线的涨跌停信息 + + 参数模板:"{freq}_D{di}_涨跌停V230331" + + **信号逻辑:** + + - close等于high且大于等于前一根K线的close,近似认为是涨停;反之,跌停。 + + **信号列表:** + + - Signal('15分钟_D1_涨跌停V230331_涨停_任意_任意_0') + - Signal('15分钟_D1_涨跌停V230331_跌停_任意_任意_0') + + :param c: 基础周期的观察者对象 + :param kwargs: + - di: 倒数第 di 根 K 线 + :return: 信号识别结果 + """ + di = int(kwargs.get("di", 1)) + freq = kwargs.get("freq", "15分钟") + k1, k2, k3 = f"{freq}_D{di}_涨跌停V230331".split("_", 2) + + 普K序列 = c.普通K线序列 + if len(普K序列) < di + 2: + return create_single_signal(k1=k1, k2=k2, k3=k3) + + 当前K线 = 普K序列[-di] + 前K线 = 普K序列[-di - 1] + + if 当前K线.收盘价 == 当前K线.高 and 当前K线.收盘价 >= 前K线.收盘价: + v1 = "涨停" + elif 当前K线.收盘价 == 当前K线.低 and 当前K线.收盘价 <= 前K线.收盘价: + v1 = "跌停" + else: + v1 = "任意" + + return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1) diff --git a/chanlun-py/chanlun/termcolor.py b/chanlun-py/chanlun/termcolor.py new file mode 100644 index 0000000..e05b014 --- /dev/null +++ b/chanlun-py/chanlun/termcolor.py @@ -0,0 +1,296 @@ +# Copyright (c) 2008-2011 Volvox Development Team +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# Author: Konstantin Lepa + +"""ANSI color formatting for output in terminal.""" + +from __future__ import annotations + +import os +import sys +from functools import cache + +TYPE_CHECKING = False +if TYPE_CHECKING: + from collections.abc import Iterable + from typing import Any + +__all__ = ["ATTRIBUTES", "COLORS", "HIGHLIGHTS", "RESET", "can_colorize", "colored", "cprint"] +ATTRIBUTES: dict[str, int] = { + "bold": 1, + "dark": 2, + "italic": 3, + "underline": 4, + "blink": 5, + "reverse": 7, + "concealed": 8, + "strike": 9, +} + +HIGHLIGHTS: dict[str, int] = { + "on_black": 40, + "on_grey": 40, # Actually black but kept for backwards compatibility + "on_red": 41, + "on_green": 42, + "on_yellow": 43, + "on_blue": 44, + "on_magenta": 45, + "on_cyan": 46, + "on_light_grey": 47, + "on_dark_grey": 100, + "on_light_red": 101, + "on_light_green": 102, + "on_light_yellow": 103, + "on_light_blue": 104, + "on_light_magenta": 105, + "on_light_cyan": 106, + "on_white": 107, +} + +COLORS: dict[str, int] = { + "black": 30, + "grey": 30, # Actually black but kept for backwards compatibility + "red": 31, + "green": 32, + "yellow": 33, + "blue": 34, + "magenta": 35, + "cyan": 36, + "light_grey": 37, + "dark_grey": 90, + "light_red": 91, + "light_green": 92, + "light_yellow": 93, + "light_blue": 94, + "light_magenta": 95, + "light_cyan": 96, + "white": 97, +} + + +RESET = "\033[0m" + + +@cache +def can_colorize(*, no_color: bool | None = None, force_color: bool | None = None) -> bool: + """Check env vars and for tty/dumb terminal""" + # First check overrides: + # "User-level configuration files and per-instance command-line arguments should + # override $NO_COLOR. A user should be able to export $NO_COLOR in their shell + # configuration file as a default, but configure a specific program in its + # configuration file to specifically enable color." + # https://no-color.org + if no_color is not None and no_color: + return False + if force_color is not None and force_color: + return True + + # Then check env vars: + if os.environ.get("ANSI_COLORS_DISABLED"): + return False + if os.environ.get("NO_COLOR"): + return False + if os.environ.get("FORCE_COLOR"): + return True + + # Then check system: + if os.environ.get("TERM") == "dumb": + return False + if not hasattr(sys.stdout, "fileno"): + return False + + try: + return os.isatty(sys.stdout.fileno()) + except OSError: + return sys.stdout.isatty() + + +def _check_rgb(rgb: tuple[int, int, int]) -> None: + if len(rgb) != 3 or not all(0 <= c <= 255 for c in rgb): + msg = f"Expected a tuple of 3 ints in range 0-255, got {rgb!r}" + raise ValueError(msg) + + +def colored( + text: object, + color: str | tuple[int, int, int] | None = None, + on_color: str | tuple[int, int, int] | None = None, + attrs: Iterable[str] | None = None, + *, + no_color: bool | None = None, + force_color: bool | None = None, +) -> str: + """Colorize text. + + Available text colors: + black, red, green, yellow, blue, magenta, cyan, white, + light_grey, dark_grey, light_red, light_green, light_yellow, light_blue, + light_magenta, light_cyan. + + Available text highlights: + on_black, on_red, on_green, on_yellow, on_blue, on_magenta, on_cyan, on_white, + on_light_grey, on_dark_grey, on_light_red, on_light_green, on_light_yellow, + on_light_blue, on_light_magenta, on_light_cyan. + + Alternatively, both text colors (color) and highlights (on_color) may + be specified via a tuple of 0-255 ints (R, G, B). + + Available attributes: + bold, dark, italic, underline, blink, reverse, concealed, strike. + + Example: + colored('Hello, World!', 'red', 'on_black', ['bold', 'blink']) + colored('Hello, World!', 'green') + colored('Hello, World!', (255, 0, 255)) # Purple + """ + result = str(text) + if not can_colorize(no_color=no_color, force_color=force_color): + return result + + fmt_str = "\033[%dm%s" + rgb_fore_fmt_str = "\033[38;2;%d;%d;%dm%s" + rgb_back_fmt_str = "\033[48;2;%d;%d;%dm%s" + if color is not None: + if isinstance(color, str): + result = fmt_str % (COLORS[color], result) + elif isinstance(color, tuple): + _check_rgb(color) + result = rgb_fore_fmt_str % (color[0], color[1], color[2], result) + + if on_color is not None: + if isinstance(on_color, str): + result = fmt_str % (HIGHLIGHTS[on_color], result) + elif isinstance(on_color, tuple): + _check_rgb(on_color) + result = rgb_back_fmt_str % (on_color[0], on_color[1], on_color[2], result) + + if attrs is not None: + for attr in attrs: + result = fmt_str % (ATTRIBUTES[attr], result) + + result += RESET + + return result + + +def cprint( + text: object, + color: str | tuple[int, int, int] | None = None, + on_color: str | tuple[int, int, int] | None = None, + attrs: Iterable[str] | None = None, + *, + no_color: bool | None = None, + force_color: bool | None = None, + **kwargs: Any, +) -> None: + """Print colorized text. + + It accepts arguments of print function. + """ + + print( + ( + colored( + text, + color, + on_color, + attrs, + no_color=no_color, + force_color=force_color, + ) + ), + **kwargs, + ) + + +if __name__ == "__main__": + print(f"Current terminal type: {os.getenv('TERM')}") + print("Test basic colors:") + cprint("Black color", "black") + cprint("Red color", "red") + cprint("Green color", "green") + cprint("Yellow color", "yellow") + cprint("Blue color", "blue") + cprint("Magenta color", "magenta") + cprint("Cyan color", "cyan") + cprint("White color", "white") + cprint("Light grey color", "light_grey") + cprint("Dark grey color", "dark_grey") + cprint("Light red color", "light_red") + cprint("Light green color", "light_green") + cprint("Light yellow color", "light_yellow") + cprint("Light blue color", "light_blue") + cprint("Light magenta color", "light_magenta") + cprint("Light cyan color", "light_cyan") + print("-" * 78) + + print("Test highlights:") + cprint("On black color", on_color="on_black") + cprint("On red color", on_color="on_red") + cprint("On green color", on_color="on_green") + cprint("On yellow color", on_color="on_yellow") + cprint("On blue color", on_color="on_blue") + cprint("On magenta color", on_color="on_magenta") + cprint("On cyan color", on_color="on_cyan") + cprint("On white color", color="black", on_color="on_white") + cprint("On light grey color", on_color="on_light_grey") + cprint("On dark grey color", on_color="on_dark_grey") + cprint("On light red color", on_color="on_light_red") + cprint("On light green color", on_color="on_light_green") + cprint("On light yellow color", on_color="on_light_yellow") + cprint("On light blue color", on_color="on_light_blue") + cprint("On light magenta color", on_color="on_light_magenta") + cprint("On light cyan color", on_color="on_light_cyan") + print("-" * 78) + + print("Test attributes:") + cprint("Bold black color", "black", attrs=["bold"]) + cprint("Dark red color", "red", attrs=["dark"]) + cprint("Italic blue color", "blue", attrs=["italic"]) + cprint("Underline green color", "green", attrs=["underline"]) + cprint("Blink yellow color", "yellow", attrs=["blink"]) + cprint("Reversed blue color", "blue", attrs=["reverse"]) + cprint("Concealed magenta color", "magenta", attrs=["concealed"]) + cprint("Strike red color", "red", attrs=["strike"]) + cprint("Bold underline reverse cyan color", "cyan", attrs=["bold", "underline", "reverse"]) + cprint("Dark blink concealed white color", "white", attrs=["dark", "blink", "concealed"]) + print("-" * 78) + + print("Test mixing:") + cprint("Underline red on black color", "red", "on_black", ["underline"]) + cprint("Reversed green on red color", "green", "on_red", ["reverse"]) + print("-" * 78) + + print("Test RGB:") + cprint("Pure red text (255, 0, 0)", (255, 0, 0)) + cprint("Default red for comparison", "red") + cprint("Pure green text (0, 255, 0)", (0, 255, 0)) + cprint("Default green for comparison", "green") + cprint("Pure blue text (0, 0, 255)", (0, 0, 255)) + cprint("Default blue for comparison", "blue") + cprint("Pure yellow text (255, 255, 0)", (255, 255, 0)) + cprint("Default yellow for comparison", "yellow") + cprint("Pure cyan text (0, 255, 255)", (0, 255, 255)) + cprint("Default cyan for comparison", "cyan") + cprint("Pure magenta text (255, 0, 255)", (255, 0, 255)) + cprint("Default magenta for comparison", "magenta") + cprint("Light pink (255, 182, 193)", (255, 182, 193)) + cprint("Light pink (255, 105, 180)", (255, 105, 180)) diff --git a/main.py b/main.py index 6f11477..3259769 100644 --- a/main.py +++ b/main.py @@ -138,6 +138,56 @@ def 收集异常信息(exception: Exception, 上下文: dict = None): return 错误报告 +class 图表展示序列(list): + def __init__(self, 观察员: "观察者"): + super().__init__() + self.观察员 = 观察员 + self.序号 = 0 + self.__类型标识 = None + + def append(self, __object): + if self.序号 > 0: + if __object.标识 != self.__类型标识: + ... + self.图表刷新(self[-1], sys._getframe().f_lineno) + + else: + self.__类型标识 = __object.标识 + super().append(__object) + self.图表添加(__object, sys._getframe().f_lineno) + self.序号 += 1 + + if __object.标识 in ("线段", "线段<线段>"): + if self.观察员 and self.观察员.配置.线段内部中枢图显: + 段: 虚线 = __object + 段.合_中枢序列 = 图表展示序列(self.观察员) + 段.实_中枢序列 = 图表展示序列(self.观察员) + 段.虚_中枢序列 = 图表展示序列(self.观察员) + + def pop(self, __index: SupportsIndex = -1): + 弹出 = super().pop(__index) + self.图表移除(弹出, sys._getframe().f_lineno) + self.序号 -= 1 + return 弹出 + + def clear(self) -> None: + self.序号 = 0 + super().clear() + + def 尾部刷新(self, 行号: int): + if self.序号: + self.图表刷新(self[-1], 行号) + + def 图表添加(self, 实线: Union["虚线", "中枢"], 行号: int): + self.观察员 and self.观察员.报信(实线, 指令.添加(实线.标识), 行号) + + def 图表移除(self, 实线: Union["虚线", "中枢"], 行号: int): + self.观察员 and self.观察员.报信(实线, 指令.删除(实线.标识), 行号) + + def 图表刷新(self, 实线: Union["虚线", "中枢"], 行号: int): + self.观察员 and self.观察员.报信(实线, 指令.修改(实线.标识), 行号) + + class 时间周期: def __init__(self, 秒: int, 是否单笔交易: bool = False): self._秒 = 秒 @@ -452,6 +502,7 @@ class 观察者(观察者): if 当前买卖点.买卖点K线.时间戳 not in 活跃时间戳序列: 买卖点序列.add(当前买卖点) 当前买卖点.买卖点K线.买卖点信息.add(当前买卖点.备注) + print(当前买卖点, type(当前买卖点)) self.报信(当前买卖点, 指令.添加(当前买卖点.备注), sys._getframe().f_lineno) def 图表刷新(self): @@ -721,6 +772,7 @@ class 观察者(观察者): @classmethod def 读取数据文件(cls, 文件路径: str, ws=None, 配置=缠论配置()) -> Self: # btcusd-300-1631772074-1632222374.nb + print(文件路径) if "_err-" in str(文件路径): try: 配置 = 缠论配置.加载配置(str(文件路径).replace(".nb", ".json")) @@ -741,6 +793,320 @@ class 观察者(观察者): return 实例 + def 识别买卖点(self): + """ + 简单买卖策略 + """ + if not self.笔序列: + return + if self.分型序列[-1].中.序号 + 2 < self.当前缠K.序号: + return + if self.分型序列[-1].强度 not in "强中": + pass + + if 笔内部背驰判断(self.普通K线序列, self.笔序列[-1]): + 0 and self.添加买卖点("笔", self.笔序列[-1].武, "一", "次次级") + + if not self.线段序列: + return + # 观察者.判断线段第二买卖点(self.线段序列[-1], self) + if 笔内部背驰判断(self.普通K线序列, self.线段序列[-1]): + 0 and self.添加买卖点("笔", self.线段序列[-1].武, "一", "次级") + + if 线段背驰判断(self.普通K线序列, self.线段序列[-1]): + 0 and self.添加买卖点("线段", self.线段序列[-1].武, "一", "次级") + + if dif_三次穿越背离判断(self.普通K线序列, self.线段序列[-1]): + 0 and self.添加买卖点("macd_三次穿越", self.线段序列[-1].武, "一", "次级") + + if self.中枢序列: + 1 and 观察者.中枢第三买卖点(self.中枢序列[-1], self) + + if not self.线段_线段序列: + return + + 观察者.线段第二买卖点(self.线段_线段序列[-1], self) + 观察者.线段第二买卖点(self.线段序列[-1], self) + if 笔内部背驰判断(self.普通K线序列, self.线段_线段序列[-1]): + 0 and self.添加买卖点("笔", self.线段_线段序列[-1].武, "一", "本级") + + return + + @classmethod + def 判断线段第二买卖点(cls, 段: 虚线, 观察员: 观察者): + 实, 虚, 第三买卖线, _ = 线段.分割序列(段) + if len(虚) == 2: + 符合 = False + # 第一笔 穿越0轴 + 笔MACD特性 = 虚线.统计MACD行为(虚[0].获取普K序列(观察员), 8, 3) + if 虚[0].方向 is 相对方向.向上 and 笔MACD特性["DEA上穿0"] > 0: + 符合 = True + if 虚[0].方向 is 相对方向.向下 and 笔MACD特性["DEA下穿0"] > 0: + 符合 = True + + # 第二笔 不能穿越0轴 + 笔MACD特性 = 虚线.统计MACD行为(虚[1].获取普K序列(观察员), 8, 3) + if 虚[1].方向 is 相对方向.向上 and 笔MACD特性["DIF上穿0"] > 0: + 符合 = False + if 虚[1].方向 is 相对方向.向下 and 笔MACD特性["DIF下穿0"] > 0: + 符合 = False + + if 符合: + 特征 = "线段二第买卖点" + 买卖点分型 = 虚[1].武 + 虚[1].武.右 and 观察员.添加买卖点(特征, 买卖点分型, "二", "次级") + + @classmethod + def 笔中枢当前状态(cls, 当前中枢: "中枢", 观察员: "观察者"): + if 当前中枢.标识 != "中枢<笔>": + return None + 普K序列: List[K线] = 观察员.普通K线序列 + 配置 = 观察员.配置 + 状态 = 当前中枢.当前状态() + 进入段: 虚线 = 观察员.笔序列[观察员.笔序列.index(当前中枢.基础序列[0]) - 1] + 离开段: 虚线 = 当前中枢.基础序列[-1] + match 状态: + case "中枢之中": + pass + case "中枢之下" | "中枢之上": + if 进入段.方向 is 离开段.方向 and not 相对方向.分析(进入段.高, 进入段.低, 离开段.高, 离开段.低).是否包含(): + if 背驰分析.MACD背驰(进入段, 离开段, 普K序列) or 虚线.买卖意义(离开段, 观察员)[0]: + 特征 = "笔中枢" + 买卖点分型 = 离开段.武 + 第几 = "一" if ((离开段.方向 is 相对方向.向上 and 当前中枢.高高 <= 离开段.高) or (离开段.方向 is 相对方向.向下 and 当前中枢.低低 >= 离开段.低)) else "二" + 观察员.添加买卖点(特征, 买卖点分型, 第几, "同级") + 第三买卖线: 虚线 = 当前中枢.第三买卖线 + if 第三买卖线: + 买卖点分型 = 当前中枢.第三买卖线.武 + 同向均值 = 虚线.武之MACD均值_阴(普K序列, 第三买卖线) if 买卖点分型.结构 in (分型结构.底, 分型结构.下) else 虚线.武之MACD均值_阳(普K序列, 第三买卖线) + # if 同向均值 and 第三买卖线.武之MACD均值 and 第三买卖线.武.与MACD柱子匹配 and 第三买卖线.武.与MACD柱子分型匹配: + if 虚线.买卖意义(第三买卖线, 观察员)[0]: + 特征 = "笔中枢" + 观察员.添加买卖点(特征, 买卖点分型, "三", "同级") + case _: + raise RuntimeError("未知中枢状态", 状态) + + @classmethod + def 中枢当前状态(cls, 当前中枢: "中枢", 观察员: "观察者"): + if 当前中枢.标识 != "中枢<线段>": + return None + + 普K序列: List[K线] = 观察员.普通K线序列 + 配置 = 观察员.配置 + + 状态 = 当前中枢.当前状态() + 买卖点错过误差值 = 配置.买卖点错过误差值 + 实, 虚, 第三买卖线, _ = 线段.分割序列(当前中枢.基础序列[-1], 当前中枢) + + match 状态: + case "中枢之中": + """if not 虚: + if cls.判断线段内部是否背驰(当前中枢[-1], 观察员) and 当前中枢[-1].武.右 and cls.买卖意义(当前中枢[-1], 观察员)[0]: + 特征 = "中枢内背驰" + 买卖点分型 = 当前中枢[-1].武 + 观察员.添加买卖点(特征, 买卖点分型, "一", "次级")""" + + case "中枢之下" | "中枢之上": + if 当前中枢.本级_第三买卖线 is not None: # and len(当前中枢) >= 3 and len(虚) >= 2: + 买卖点分型 = None + if 当前中枢.完整性("合"): + if 状态 == "中枢之上": + 之后缠K序列 = 观察员.缠论K线序列[观察员.缠论K线序列.index(当前中枢.本级_第三买卖线.武.中) :] + 之后缠K = None + 中枢上轨 = 当前中枢.高 + if 当前中枢.本级_第三买卖线.武.中.标的K线.macd.DIF > 0: + for k in 之后缠K序列: + if k.标的K线.macd.DIF < 0: # 首个下穿0轴 + if 之后缠K is None: + 之后缠K = k + if 之后缠K: + if k.分型 is 分型结构.底 and k.标的K线.macd.DIF < 0: + 买卖点分型 = 分型.从缠K序列中获取分型(观察员.缠论K线序列, k) + break + + else: + # 中枢之下 + 之后缠K序列 = 观察员.缠论K线序列[观察员.缠论K线序列.index(当前中枢.本级_第三买卖线.武.中) :] + 之后缠K = None + 中枢下轨 = 当前中枢.低 + if 当前中枢.本级_第三买卖线.武.中.标的K线.macd.DIF < 0: + for k in 之后缠K序列: + if k.标的K线.macd.DIF > 0: # 首个上穿0轴 + if 之后缠K is None: + 之后缠K = k + if 之后缠K: + if k.分型 is 分型结构.顶 and k.标的K线.macd.DIF > 0: + 买卖点分型 = 分型.从缠K序列中获取分型(观察员.缠论K线序列, k) + break + + if 买卖点分型: + 特征 = "首次穿越0轴" + 观察员.添加买卖点(特征, 买卖点分型, "三", "本级") + + 买卖标的值 = 当前中枢.本级_第三买卖线.武.分型特征值 + if 虚线.买卖意义(当前中枢.本级_第三买卖线, 观察员)[0]: + 特征 = "中枢段笔" + 买卖点分型 = 当前中枢.本级_第三买卖线.武 + # 观察员.添加买卖点(特征, 买卖点分型, "三", "本级") + + else: + # 错过 + for 本级_第三买卖线 in 第三买卖线: + if (当前中枢.本级_第三买卖线 is not 本级_第三买卖线) and 虚线.买卖意义(本级_第三买卖线, 观察员)[0] and (买卖标的值 * (1 + 买卖点错过误差值) > 本级_第三买卖线.武.中.分型特征值 > 买卖标的值 * (1 - 买卖点错过误差值)): + 特征 = "中枢段笔" + 买卖点分型 = 本级_第三买卖线.武 + # 观察员.添加买卖点(特征, 买卖点分型, "三", "错过本级") + break + + if 当前中枢.第三买卖线 is None: + # 正在形成第三买卖点? + 特征 = 状态 + 离开段: 虚线 = 当前中枢.基础序列[-1] + """if not 虚: + # 正在离开中枢 + if not 当前中枢.完整性: + pass + else: + pass + + if cls.买卖意义(离开段[-1], 观察员)[0] or cls.判断线段内部是否背驰(离开段, 观察员) or cls.买卖意义(离开段[-1], 观察员)[0]: + 买卖点分型 = 离开段.武 + 观察员.添加买卖点(特征, 买卖点分型, "一", "同级") + else: + # 即将到来的 同级第三买卖点! + if (离开段.方向 is 相对方向.向下 and 虚[-1].武.分型特征值 < 离开段.文.分型特征值) or (离开段.方向 is 相对方向.向上 and 虚[-1].武.分型特征值 > 离开段.文.分型特征值): + # 一笔突破当前线段起点 + if cls.买卖意义(虚[-1], 观察员)[0]: + 买卖点分型 = 虚[-1].武 + 观察员.添加买卖点(特征, 买卖点分型, "一", "次次级")""" + + else: + # 第三买卖点已出现,可能的情况如下 + # 1.不当前中枢有任何的交集 + # 2.有交集 + # 1.与中枢 中高 中低 发生重叠 + # 2.与中枢 高高 高低 发生重叠 + # 3.失败重新进入当前中枢 + # 重点就是第三点,如何判断会回到中枢? + # 提出完整性的概念,最后离开段中内部中枢是否脱离中枢区间 + if not 当前中枢.完整性("合"): + return 状态, "不完整" + # if 当前中枢.第三买卖线.武 is not 观察员.分型序列[-1]: + # return 状态, "" + 特征 = "中枢段" + assert 相对方向.分析(当前中枢.高, 当前中枢.低, 当前中枢.第三买卖线.高, 当前中枢.第三买卖线.低).是否缺口() + 普K序列 = 当前中枢.第三买卖线.获取普K序列(观察员.观察员) + MACD特性 = 虚线.统计MACD行为(普K序列, 8, 3) + + if MACD特性["DEA上穿0"] > 0 and MACD特性["DEA下穿0"] > 0: + 特征 = "中枢段_DEA穿越2" + 买卖点分型 = 当前中枢.第三买卖线.武 + 观察员.添加买卖点(特征, 买卖点分型, "三", "同级") + + if 当前中枢.基础序列[-1].合_中枢序列 and 当前中枢.基础序列[-1].合_中枢序列[-1].基础序列[-1] is 当前中枢.第三买卖线.基础序列[-1]: + pass # return 状态, "前合中枢,线段首次形成" + + if 线段.判断线段内部是否背驰(当前中枢.第三买卖线, 观察员): # or 阳[-1]. 买卖意义[0]: + 买卖点分型 = 当前中枢.第三买卖线.武 + # 观察员.添加买卖点(特征, 买卖点分型, "三", "同级") + else: + return 状态, "同级,内部非背驰" + case _: + raise RuntimeError("未知中枢状态", 状态) + + @classmethod + def 中枢第三买卖点(cls, 当前中枢: "中枢", 观察员: "观察者"): + if 当前中枢.标识 != "中枢<线段>": + return None + 状态 = 当前中枢.当前状态() + if 状态 == "中枢之中": + return None + + 实, 虚, 第三买卖线, _ = 线段.分割序列(当前中枢.基础序列[-1], 当前中枢) + if 当前中枢.本级_第三买卖线 is None: + return None + + 买卖点分型 = None + if 当前中枢.完整性("合"): + 之后缠K序列 = 观察员.缠论K线序列[观察员.缠论K线序列.index(当前中枢.本级_第三买卖线.武.中) :] + 之后缠K = None + assert 之后缠K序列[0] is 当前中枢.本级_第三买卖线.武.中, (之后缠K序列[0], 当前中枢.本级_第三买卖线.武.中) + + if 状态 == "中枢之上": + 中枢上轨 = 当前中枢.高 + if 当前中枢.本级_第三买卖线.武.中.标的K线.macd.DIF > 0: + for k in 之后缠K序列: + if k.标的K线.macd.DIF < 0: # 首个下穿0轴 + if 之后缠K is None: + 之后缠K = k + if 之后缠K: + if k.分型 is 分型结构.底 and k.标的K线.macd.DIF < 0: + 买卖点分型 = 分型.从缠K序列中获取分型(观察员.缠论K线序列, k) + break + + else: + # 中枢之下 + 中枢下轨 = 当前中枢.低 + if 当前中枢.本级_第三买卖线.武.中.标的K线.macd.DIF < 0: + for k in 之后缠K序列: + if k.标的K线.macd.DIF > 0: # 首个上穿0轴 + if 之后缠K is None: + 之后缠K = k + if 之后缠K: + if k.分型 is 分型结构.顶 and k.标的K线.macd.DIF > 0: + 买卖点分型 = 分型.从缠K序列中获取分型(观察员.缠论K线序列, k) + break + + if 买卖点分型: + 特征 = "首次穿越0轴" + 观察员.添加买卖点(特征, 买卖点分型, "三", "本级") + + if 当前中枢.第三买卖线 is None: + return None + + if not 当前中枢.完整性("合"): + return None + + 特征 = "中枢段" + assert 相对方向.分析(当前中枢.高, 当前中枢.低, 当前中枢.第三买卖线.高, 当前中枢.第三买卖线.低).是否缺口() + 普K序列 = 当前中枢.第三买卖线.获取普K序列(观察员.观察员) + MACD特性 = 虚线.统计MACD行为(普K序列, 8, 3) + + if MACD特性["DEA上穿0"] > 0 and MACD特性["DEA下穿0"] > 0: + 特征 = "中枢段_DEA穿越2" + 买卖点分型 = 当前中枢.第三买卖线.武 + 观察员.添加买卖点(特征, 买卖点分型, "三", "同级") + + @classmethod + def 线段第二买卖点(cls, 当前线段: "虚线", 观察员: "观察者"): + if 当前线段.标识 not in ("线段<线段>", "线段"): + return + 实, 虚, _, _ = 线段.分割序列(当前线段, None) + if len(虚) != 2: + return + + (首, 尾) = 虚 + 首_MACD信息 = 虚线.统计MACD行为(首.获取普K序列(观察员)) + if 首.方向 is 相对方向.向下 and 首_MACD信息["DEA下穿0"] >= 1: + pass + elif 首.方向 is 相对方向.向上 and 首_MACD信息["DEA上穿0"] >= 1: + pass + else: + return + + 买卖点分型 = None + 尾_MACD信息 = 虚线.统计MACD行为(尾.获取普K序列(观察员)) + if 尾.方向 is 相对方向.向下 and 首_MACD信息["DEA下穿0"] >= 1: + 买卖点分型 = 尾.武 + elif 尾.方向 is 相对方向.向上 and 首_MACD信息["DEA上穿0"] >= 1: + 买卖点分型 = 尾.武 + else: + return + if not 买卖点分型: + return + 特征 = f"{当前线段.标识}第二" + 观察员.添加买卖点(特征, 买卖点分型, "二", "同级") + __代码执行器_全局声明__ = dir() @@ -767,6 +1133,283 @@ def 随机配置(随机源: Optional[random.Random] = None): ) +def dif_三次穿越背离判断(K线序列: List["K线"], 段: 虚线) -> bool: + """ + 基于 DIF 线穿越零轴的三次穿越背离判断,第三次穿越后检查 MACD 柱。 + + 向下模式: + 1. 第一次穿越:DIF 从正 → 负(下穿0轴) + 2. 第二次穿越:DIF 从负 → 正(上穿0轴),且上穿后的最高价 ≤ 第一次下穿前的最高价 + 3. 第三次穿越:DIF 从正 → 负(再次下穿0轴) + 检查第三次穿越后的连续 MACD 负值段内, + 价格最低点对应的 MACD 柱值 < 该段 MACD 柱最大值 → 返回 True + + 向上模式(对称): + 1. 第一次穿越:DIF 从负 → 正(上穿0轴) + 2. 第二次穿越:DIF 从正 → 负(下穿0轴),且下穿后的最低价 ≥ 第一次上穿前的最低价 + 3. 第三次穿越:DIF 从负 → 正(再次上穿0轴) + 检查第三次穿越后的连续 MACD 正值段内, + 价格最高点对应的 MACD 柱值 < 该段 MACD 柱最大值 → 返回 True + + 参数: + k线序列: K线对象列表,需包含 .close, .macd.DIF, .macd.MACD柱 + 方向: "向下" 或 "向上" + + 返回: + bool: 满足背离条件返回 True,否则 False + """ + + 方向 = "向上" if 段.方向.是否向上() else "向下" + k线序列: List[K线] = K线.截取(K线序列, 段.文.中.标的K线, 段.武.中.标的K线) + if len(k线序列) < 4: + return False + + dif_vals = [k.macd.DEA for k in k线序列] + macd_vals = [k.macd.MACD柱 for k in k线序列] + + # 1. 找出所有 DIF 穿越零轴的点 + 穿越点 = [] # (索引, 方向) + for i in range(1, len(dif_vals)): + prev, curr = dif_vals[i - 1], dif_vals[i] + if prev * curr < 0: + if prev > 0 and curr < 0: + 穿越点.append((i, "下穿")) + elif prev < 0 and curr > 0: + 穿越点.append((i, "上穿")) + + if len(穿越点) < 3: + return False + + # 2. 根据方向寻找连续符合顺序的三次穿越 + 期望序列 = ["下穿", "上穿", "下穿"] if 方向 == "向下" else ["上穿", "下穿", "上穿"] + found = None + for j in range(len(穿越点) - 2): + if 穿越点[j][1] == 期望序列[0] and 穿越点[j + 1][1] == 期望序列[1] and 穿越点[j + 2][1] == 期望序列[2]: + found = (穿越点[j][0], 穿越点[j + 1][0], 穿越点[j + 2][0]) + break + + if not found: + return False + + idx1, idx2, idx3 = found + + # 3. 向下模式 + if 方向 == "向下": + # 条件2:第二次上穿后的最高价 <= 第一次下穿前的最高价 + before = k线序列[:idx1] + if not before: + return False + max_before = max(k.高 for k in before) + + after = k线序列[idx2:] + if not after: + return False + max_after = max(k.高 for k in after) + + if max_after > max_before: + return False + + # 第三次穿越后,取连续 MACD 负值段 + segment = [] + for i in range(idx3, len(macd_vals)): + if macd_vals[i] < 0: + segment.append(k线序列[i]) + else: + break + if len(segment) < 2: + return False + + # 段内最低价及其对应的 MACD 柱值 + min_price = float("inf") + min_macd = None + for k in segment: + if k.高 < min_price: + min_price = k.高 + min_macd = k.macd.MACD柱 + max_macd = max(k.macd.MACD柱 for k in segment) + + return min_macd < max_macd + + # 4. 向上模式 + else: + # 条件2:第二次下穿后的最低价 >= 第一次上穿前的最低价 + before = k线序列[:idx1] + if not before: + return False + min_before = min(k.低 for k in before) + + after = k线序列[idx2:] + if not after: + return False + min_after = min(k.低 for k in after) + + if min_after < min_before: + return False + + # 第三次穿越后,取连续 MACD 正值段 + segment = [] + for i in range(idx3, len(macd_vals)): + if macd_vals[i] > 0: + segment.append(k线序列[i]) + else: + break + if len(segment) < 2: + return False + + # 段内最高价及其对应的 MACD 柱值 + max_price = -float("inf") + max_macd = None + for k in segment: + if k.低 > max_price: + max_price = k.低 + max_macd = k.macd.MACD柱 + max_macd_in_seg = max(k.macd.MACD柱 for k in segment) + + return max_macd < max_macd_in_seg + + +def 找首个MACD交叉前后K线(k线序列: List["K线"], 起始K线: "K线") -> Tuple[Optional["K线"], Optional["K线"]]: + """ + 在K线序列中,从起始K线之后查找第一个MACD快慢线交叉点(金叉或死叉), + 返回交叉点前一根K线和后一根K线。若未找到,返回(None, None)。 + + 参数: + k线序列: K线对象列表,按时间顺序排列 + 起始K线: 开始查找的位置 + """ + # 定位起始索引 + try: + start_idx = k线序列.index(起始K线) + except ValueError: + return None, None + + # 从起始K线的下一根开始,到倒数第二根结束(需要比较前后两根) + for i in range(start_idx, len(k线序列) - 1): + prev = k线序列[i] + curr = k线序列[i + 1] + + # 获取DIF和DEA值,若存在None则跳过 + dif_prev = prev.macd.DIF + dea_prev = prev.macd.DEA + dif_curr = curr.macd.DIF + dea_curr = curr.macd.DEA + + if None in (dif_prev, dea_prev, dif_curr, dea_curr): + continue + + # 金叉:前一根 DIF <= DEA,后一根 DIF > DEA + if dif_prev <= dea_prev and dif_curr > dea_curr: + return prev, curr + + # 死叉:前一根 DIF >= DEA,后一根 DIF < DEA + if dif_prev >= dea_prev and dif_curr < dea_curr: + return prev, curr + + return None, None + + +def 计算MACD柱子分段(k线序列: Sequence["K线"] = None) -> Tuple[List[List["K线"]], ...]: + if not k线序列: + return () + + def 符号(x: float) -> str: + if x > 0: + return "正" + else: + return "负" + + 当前符号 = 符号(k线序列[0].macd.MACD柱) + 当前段柱子 = [k线序列[0]] + 结果 = [] + + for i in range(1, len(k线序列)): + 新符号 = 符号(k线序列[i].macd.MACD柱) + if 新符号 == 当前符号: + 当前段柱子.append(k线序列[i]) + else: + 结果.append(当前段柱子) + 当前段柱子 = [k线序列[i]] + 当前符号 = 新符号 + + if 当前段柱子: + 结果.append(当前段柱子) + + 正 = [] + 负 = [] + for 序列 in 结果: + if 序列[-1].macd.MACD柱 > 0: + 正.append(序列) + else: + 负.append(序列) + return 正, 负 + + +def 笔内部背驰判断(K线序列: List[K线], 当前笔: 虚线) -> bool: + """ + 基于笔内部MACD柱的分段能量变化,判断是否发生内部背驰(端点可能转折)。 + 返回 True 表示出现内部背驰信号。 + """ + klines: List[K线] = K线.截取(K线序列, 当前笔.文.中.标的K线, 当前笔.武.中.标的K线) # 笔对象本身可迭代返回K线 + + if len(klines) < 3: + return False + + 正段, 负段 = 计算MACD柱子分段(klines) + # 按笔的方向选择相关段(向上笔看正段,向下笔看负段) + if 当前笔.方向 == 相对方向.向上: + 相关段 = 正段 + else: + 相关段 = 负段 + + if len(相关段) < 2: + return False + + # 计算每段的能量(代数和) + 能量 = [sum(k.macd.MACD柱 for k in seg) for seg in 相关段] + # 计算每段末端价格(向上笔用最高价,向下笔用最低价) + if 当前笔.方向 == 相对方向.向上: + 末端价格 = [seg[-1].高 for seg in 相关段] + # 价格必须逐段抬高,能量逐段减小 + return all(末端价格[i] < 末端价格[i + 1] for i in range(len(末端价格) - 1)) and all(能量[i] > 能量[i + 1] for i in range(len(能量) - 1)) + else: + 末端价格 = [seg[-1].低 for seg in 相关段] + # 价格必须逐段降低,能量绝对值逐段减小 + return all(末端价格[i] > 末端价格[i + 1] for i in range(len(末端价格) - 1)) and all(abs(能量[i]) > abs(能量[i + 1]) for i in range(len(能量) - 1)) + + +def 线段背驰判断(k线序列: List[K线], 段: 虚线) -> bool: + """线段内部背驰(比较最后一个中枢的进入段和离开段)""" + if not 段.合_中枢序列: + return False + zs = 段.合_中枢序列[-1] # 最后一个中枢 + # 中枢由三笔构成:左、中、右(方向交替) + 进入笔 = None + 离开笔 = None + # 找到中枢之前的同向笔 + for bi in reversed(段.笔序列[: 段.笔序列.index(zs.基础序列[0])]): + if bi.方向 == zs.基础序列[0].方向: + 进入笔 = bi + break + # 找到中枢之后的同向笔 + for bi in 段.笔序列[段.笔序列.index(zs.基础序列[-1]) + 1 :]: + if bi.方向 == zs.基础序列[0].方向: + 离开笔 = bi + break + if 进入笔 is None or 离开笔 is None: + return False + + # 价格条件 + if zs.基础序列[0].方向 == 相对方向.向上: + if 离开笔.高 <= 进入笔.高: + return False + else: + if 离开笔.低 >= 进入笔.低: + return False + + # 力度比较(MACD面积) + return 背驰分析.MACD背驰(离开笔, 进入笔, k线序列) + + class 笔K线生成配置(BaseModel): """笔的K线生成配置""" @@ -1255,7 +1898,7 @@ def 从序列中机选( def 根据当前K线生成新K线(self, 方向: 相对方向, 居中: bool = False) -> "K线": 时间偏移 = timedelta(seconds=self.周期) - 时间戳: datetime = self.时间戳 + 时间偏移 + 时间戳: datetime = self.时间戳 + self.周期 # 时间偏移 成交量: float = 998 高: float = 0 低: float = 0 @@ -1534,6 +2177,7 @@ def 同步_跟踪回测(观察员: 观察者, 数据源: bt.feed.DataBase): def 测试_读取数据(symbol: str = "btcusd", limit: int = 500, freq: SupportsInt = 时间周期.分(5), ws: Optional[WebSocket] = None, 配置: 缠论配置 = 缠论配置(线段内部中枢图显=False), 文件路径: str = "./templates/btcusd_ex-1800-1685795400-1713488400.nb"): def 魔法(): 启动时间 = datetime.now() + print(观察者) 观察员 = 观察者.读取数据文件(配置.加载文件路径, ws, 配置) # 观察员.分部分析() 消耗用时 = datetime.now() - 启动时间 @@ -1935,7 +2579,8 @@ async def 处理图表消息(用户标识: str, 消息字典: Dict, websocket: W config = 消息字典.get("config", dict()) 当前配置 = 缠论配置.from_dict(config) - print(当前配置.to_dict()) + 差异 = 缠论配置().对比(当前配置) + print(差异) 配置组 = 缠论配置.按序号重组字典(当前配置, config) print(配置组) @@ -2231,7 +2876,58 @@ async def 主页( ) +def 测试_读取数据2(配置: 缠论配置): + """测试_读取数据 + + :param 配置: 缠论配置 + :return: 测试函数 + """ + + def 魔法(): + 启动时间 = datetime.now() + 观察员 = 观察者.读取数据文件(配置.加载文件路径, 配置) + 消耗用时 = datetime.now() - 启动时间 + print("测试_读取数据 耗时", 消耗用时, "普K数量", len(观察员.普通K线序列)) + return 观察员 + + return 魔法 + + +def 测试_周期合成2(配置: 缠论配置, 配置组: Dict[int, 缠论配置] = dict()): + """测试_周期合成 + + :param 配置: 默认配置 + :param 配置组: 各周期独立配置 + :return: 测试函数 + """ + 文件路径 = 配置.加载文件路径 + name = Path(文件路径).name.split(".")[0] + 符号, 周期, 起始时间戳, 结束时间戳 = name.split("-") + 周期 = int(周期) + 周期组 = [周期, 周期 * 5, 周期 * 5 * 6] + + def 魔法(): + 启动时间 = datetime.now() + 多级别分析 = 立体分析器(符号, 周期组, 配置, 配置组) + with open(文件路径, "rb") as f: + buffer = f.read() + size = struct.calcsize(">6d") + for i in range(len(buffer) // size): + k线 = K线.读取大端字节数组(buffer[i * size : i * size + size], 周期, 符号) + 多级别分析.投喂K线(k线) + 消耗用时 = datetime.now() - 启动时间 + print("测试_周期合成", 消耗用时, "普K数量", len(多级别分析._单体分析器[周期].普通K线序列)) + return 多级别分析 + + return 魔法 + + if __name__ == "__main__": + 当前配置 = 缠论配置.不推送() + 当前配置.加载文件路径 = str(Path(__file__).parent / "btcusd-300-1761327300-1776327900.nb") + 测试_读取数据(配置=当前配置)() # .测试_保存数据() + # 测试_周期合成(当前配置)().测试_保存数据() +if __name__ == "__ma2in__": def 运行单个回测(线程编号: int): """单个线程执行的函数,内部捕获异常以免影响其他线程"""