diff --git a/CLAUDE.md b/CLAUDE.md index 0bdfec7..874b615 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,44 +6,48 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co 缠论 (Chan Theory) technical analysis system for Freqtrade. Implements Chan Zhong Shui Chan's theory for crypto/stock trading, including fractal (分型), stroke (笔), segment (线段), pivot/center (中枢), and buy/sell point (买卖点) detection. +## Governance + +- ESS 文档:`docs/PROJECT_PROFILE.md`、`docs/ECR/`、`docs/ENGINEERING_SPEC/` +- **正式引擎包**:`chanlun/`;strategies / web 已用 `from chanlun import ...` +- 根目录 `Chan*.py` / `TF_DF.py` 仍为 **兼容 shim**(旧脚本可用) + ## Core Architecture -### Chan Theory Engine (`Chan*.py`) +### Chan Theory Engine (`chanlun/`) + +```text +chanlun/ + core/ # KLU KLC BI SBI SEG ZS BIZS BSP Enum CTime + pipeline/ # orchestrator(ChanLun) + timeframe(TF_DF) + builders/ + indicators/ # ChanMACD* + analysis/ # Zone Classifier Pivot Heng PY Find_Trend ... +``` Data processing pipeline (each step feeds the next): -1. **`ChanKLU.py`** — Raw K-line unit with TA indicators (EMA, MACD, RSI, Bollinger Bands) and candlestick pattern recognition (`Chan_KLU_PATTERN`) -2. **`ChanKLC.py`** — Combined K-line: inclusion processing (包含处理), fractal (分型) detection. Linked-list structure with `.next`/`.pre` pointers -3. **`ChanBI.py`** — Stroke (笔): basic trend unit connecting alternating fractals -4. **`ChanSBI.py`** — Special Stroke: aggregates multiple BI into higher-level units with fractal detection, feeds into SEG -5. **`ChanSEG.py`** — Segment (线段): built from SBI strokes -6. **`ChanZS.py`** / **`ChanBIZS.py`** — Center/pivot (中枢): consolidation zones (segment-level and stroke-level) -7. **`ChanBSP.py`** — Buy/Sell points (买卖点): Type 1/2/3 signals -8. **`ChanLun.py`** — Main orchestrator: ties all steps together, entry point -9. **`TF_DF.py`** — Timeframe-aware DataFrame processor: resamples data, runs the full pipeline per timeframe, handles multi-timeframe analysis - -### Support modules - -- **`ChanEnum.py`** — All enumerations: K-line types, fractal types, MACD states, buy/sell point types, EMA position/semantic states, K-line patterns -- **`ChanCTime.py`** — Chan theory time utility: auto-adaptive day understanding (e.g. crypto 24h vs stock market hours) -- **`ChanMACD.py`** / **`ChanMACDHistSet.py`** / **`ChanMACDSeg.py`** / **`ChanMACDUnitTF.py`** — MACD state analysis and divergence detection -- **`ChanPY.py`** — Consolidation (盘整) analysis -- **`ChanHeng.py`** — Sideways market analysis -- **`Chan_FX_Box.py`** — Fractal box (分型箱体) detection -- **`ChanLun_Classifier.py`** — Standalone classifier script: runs full pipeline and classifies market states +1. **`chanlun.core.ChanKLU`** — Raw K-line unit with TA indicators and pattern recognition +2. **`chanlun.core.ChanKLC`** — Combined K-line: inclusion + fractal; `.next`/`.pre` linked list +3. **`chanlun.core.ChanBI`** — Stroke (笔) +4. **`chanlun.core.ChanSBI`** — Special stroke → SEG +5. **`chanlun.core.ChanSEG`** — Segment (线段) +6. **`chanlun.core.ChanZS`** / **`ChanBIZS`** — Centers (中枢) +7. **`chanlun.core.ChanBSP`** — Buy/Sell points +8. **`chanlun.pipeline.orchestrator.ChanLun`** — Orchestrator +9. **`chanlun.pipeline.timeframe.TF_DF`** — Timeframe facade;实现拆在 `pipeline/builders/` ### Services -- **`data_provider/`** — FastAPI data service: fetches crypto data from Binance via CCXT, caches to CSV, serves REST API + WebSocket. Synthesizes derived timeframes (e.g. 5m/15m/4h from 1m/1h base). Port 9009. -- **`web/`** — Flask web UI for interactive chart visualization with Chan theory overlays. Port 8123. -- **`strategies/`** — Freqtrade trading strategies using the Chan theory engine (53 strategies) -- **`config/`** — Freqtrade JSON config files per pair/timeframe +- **外部 DATA_SERVICE** — 行情服务(env: `DATA_SERVICE_URL`);本仓库可不含 data_provider 源码 +- **`web/`** — Flask UI:`create_app()` + `api/` blueprints + `services/`;前端 `static/js/app/`。默认端口见 `web/config.py`(`FLASK_PORT`,常见 8128) +- **`strategies/`** — Freqtrade strategies(本 ECR 不改) +- **`config/`** — Freqtrade configs(本 ECR 不改) ### Data Flow ``` -Exchange (CCXT) → data_provider (CSV cache) → Freqtrade → Strategy → ChanLun → TF_DF - → KLU → KLC → BI → SBI → SEG → ZS → BSP +Exchange / DATA_SERVICE → Freqtrade Strategy / web → ChanLun → TF_DF + → KLU → KLC → BI → SBI → SEG → ZS → BSP ``` ## Common Commands diff --git a/ChanBI.py b/ChanBI.py index 19aaee3..1ad9e57 100644 --- a/ChanBI.py +++ b/ChanBI.py @@ -1,132 +1,2 @@ -from decimal import Decimal -import ChanKLC -from ChanEnum import Chan_BI_DIR -class ChanBI(): - def __init__(self, klc: ChanKLC, index, ddir=Chan_BI_DIR.UP): - self.start_klc = klc - self.end_klc = klc - self.next = None - self.pre = None - self.dir = ddir - self.index = index - self.is_sure = False - self.high = klc.high - self.low = klc.low - self.sure_time = None - self.klc_list = [] - self.klc_list.append(klc) - self.end_time = klc.end_time - self.start_time = klc.start_time - self.macd_hist = 0 - self.macd_div = 0 - self.seg = None - self.height = 0 - self.width = 0 - self.slop = 0 - self.fib_list = [] - self.seg_index = 0 - self.bi_zs = None - self.seg_zs = None - def set_bi_zs(self, bi_zs): - for klc in self.klc_list: - klc.set_bi_zs(bi_zs) - def set_seg(self, seg): - self.seg = seg - self.seg_index = len(seg.bi_list)-1 - def set_macdhist(self, macd_hist): - self.macd_hist = macd_hist - def set_macd_div(self, macd_div): - self.macd_div = macd_div - def cal_macd_div(self): - self.macd_div = 0.0 - if self.pre and self.pre.pre: - if self.pre.pre.macd_hist == 0: - self.macd_div = 0.0 - else: - self.macd_div = self.macd_hist / self.pre.pre.macd_hist - #print(self.start_time, self.end_time, self.macd_hist, self.pre.pre.macd_hist, self.macd_div) - def cal_macdhist(self): - self.macd_hist = 0 - for klc in self.klc_list: - for klu in klc.klu_list: - if self.dir == Chan_BI_DIR.UP and klu.macdhist > 0: - self.macd_hist += klu.macdhist - if self.dir == Chan_BI_DIR.DOWN and klu.macdhist < 0: - self.macd_hist -= klu.macdhist - def check_bi_zs_overlap(self): - if self.next and self.next.next: - if self.dir == Chan_BI_DIR.UP: - return self.low < self.next.next.high - else: - return self.high > self.next.next.low - else: - return False - def check_overlap(self): - if self.next and self.next.next and self.next.next.is_sure: - if self.dir == Chan_BI_DIR.UP: - return self.high > self.next.low and self.high < self.next.next.high - else: - return self.high > self.next.high and self.low > self.next.next.low - else: - return False - def set_end_klc(self, klc, sure_klc): - if self.dir == Chan_BI_DIR.UP and klc.high > self.high: - self.high = klc.high - if self.dir == Chan_BI_DIR.DOWN and klc.low < self.low: - self.low = klc.low - self.end_klc = klc - self.set_is_sure(True, sure_klc.end_time) - self.end_time = klc.end_time - self.cal_properties() - #print(self.start_time, klc.fx, "This bi is ended", len(self.klc_list), klc.index - self.start_klc.index) - def cal_properties(self): - if self.is_sure: - self.height = float(format(self.high - self.low, ".2f")) - self.width = self.end_klc.index - self.start_klc.index - self.slop = float(format(self.height / self.width, ".2f")) - fib_list = [0.0, 0.236, 0.382, 0.5, 0.618, 0.786, 1.0] - for fib in fib_list: - self.fib_list.append(float(format(self.height * fib + self.low, ".2f"))) - #print(self.end_time, self.height, self.width, self.slop, self.fib_list) - def set_is_sure(self, is_sure, time): - self.is_sure = is_sure - self.sure_time = time - def set_start_klc(self, klc, ddir): - self.start_klc = klc - self.klc_list = [] - self.klc_list.append(klc) - self.high = klc.high - self.low = klc.low - self.dir = ddir - def set_pre(self, bi): - self.pre = bi - def set_next(self, bi): - self.next = bi - def add_klc(self, klc): - added = False - if len(self.klc_list) > 0: - for index in range(0, len(self.klc_list)): - if self.klc_list[index].index == klc.index: - added = True - break - if not added: - self.klc_list.append(klc) - #print(self.start_time, klc.start_time) - #print(klc.end_time, klc.index) - self.end_klc = klc - self.end_time = klc.klu_list[-1].time - self.cal_macdhist() - self.cal_macd_div() - def append_klc_list(self, klc_list): - self.klc_list.append(klc_list) - def get_decimal(self, value): - return Decimal("{:.2f}".format(value)) - def update_bi(self, klc): - self.end_klc = None - if self.dir == Chan_BI_DIR.UP and klc.high > self.high: - self.high = klc.high - if self.dir == Chan_BI_DIR.DOWN and klc.low < self.low: - self.low = klc.low - self.is_sure = False - self.sure_time = None - #print(self.start_time, klc.start_time, klc.fx, "This bi is extended") \ No newline at end of file +"""兼容 shim — 请优先 from chanlun import ...""" +from chanlun.core.ChanBI import * # noqa: F403 diff --git a/ChanBIZS.py b/ChanBIZS.py index 58b1d30..a882217 100644 --- a/ChanBIZS.py +++ b/ChanBIZS.py @@ -1,161 +1,2 @@ -from ChanEnum import Chan_ZS_DIR, Chan_ZS_TYPE, Chan_BI_DIR -import ChanBI -# 中枢 -class ChanBIZS(): - def __init__(self, start_bi: ChanBI, index, ddir: Chan_ZS_DIR): - self.start_klc = start_bi.start_klc - self.start_time = self.start_klc.start_time - self.end_time = None - self.index = index - self.start_bi = start_bi - self.bi_list = [] - self.bi_list.append(start_bi) - self.end_bi = None - self.bi_out = None - self.is_sure = False - self.zg = 0 - self.zd = 0 - self.gg = 0 - self.dd = 0 - self.dir = ddir - self.sure_time = None - self.end_klc = None - self.zs_type = Chan_ZS_TYPE.NORMAL - start_bi.set_bi_zs(self) - def set_end_bi(self, end_bi, sure_time): - self.end_bi = end_bi - self.set_end_time(end_bi.end_klc.end_time) - self.is_sure = True - self.sure_time = sure_time - end_bi.set_bi_zs(self) - #print(self.start_time, self.is_sure, len(self.bi_list), self.dir, self.zs_type) - def set_end_time(self, end_time): - self.end_time = end_time - def set_zg(self, zg): - self.zg = zg - def set_zd(self, zd): - self.zd = zd - def set_gg(self, gg): - self.gg = gg - def set_dd(self, dd): - self.dd = dd - def add_bi(self, bi: ChanBI): - if bi: - self.bi_list.append(bi) - if bi.high > self.gg: - self.gg = bi.high - if bi.low < self.dd: - self.dd = bi.low - bi.set_bi_zs(self) - self.classify_zs() - def set_pre(self, pre): - self.pre = pre - def set_next(self, next): - self.next = next - def classify_zs(self): - """ - 根据中枢内笔的高低点变化趋势,对中枢进行分类 - - 分类逻辑: - - 取中枢内向上笔的高点(peaks)和向下笔的低点(valleys) - - 比较前半段和后半段的均值,判断高点和低点的整体趋势 - - 分类结果: - - RISING 上升中枢:高点抬高 + 低点抬高 → 多方占优,可能向上突破 - - FALLING 下行中枢:高点降低 + 低点降低 → 空方占优,可能向下突破 - - CONVERGING 收敛中枢:高点降低 + 低点抬高 → 区间收窄,即将选择方向 - - DIVERGING 扩散中枢:高点抬高 + 低点降低 → 波动加剧,市场不稳定 - - NORMAL 常规中枢:无明显趋势 → 多空均衡,区间震荡 - """ - if len(self.bi_list) < 3: - self.zs_type = Chan_ZS_TYPE.NORMAL - return - - # 提取向上笔的高点(peaks)和向下笔的低点(valleys) - peaks = [bi.high for bi in self.bi_list if bi.dir == Chan_BI_DIR.UP] - valleys = [bi.low for bi in self.bi_list if bi.dir == Chan_BI_DIR.DOWN] - - high_trend = self._calc_trend(peaks) - low_trend = self._calc_trend(valleys) - - if high_trend > 0 and low_trend > 0: - self.zs_type = Chan_ZS_TYPE.RISING - elif high_trend < 0 and low_trend < 0: - self.zs_type = Chan_ZS_TYPE.FALLING - elif high_trend < 0 and low_trend > 0: - self.zs_type = Chan_ZS_TYPE.CONVERGING - elif high_trend > 0 and low_trend < 0: - self.zs_type = Chan_ZS_TYPE.DIVERGING - else: - self.zs_type = Chan_ZS_TYPE.NORMAL - - def _calc_trend(self, values): - """ - 计算序列的趋势方向 - 将序列分为前后两半,比较均值: - - 后半均值 > 前半均值 → 返回 1(上升趋势) - - 后半均值 < 前半均值 → 返回 -1(下降趋势) - - 相等或数据不足 → 返回 0(无趋势) - - 使用均值比较而非首尾比较,可以过滤单笔异常波动带来的误判 - """ - if len(values) < 2: - return 0 - mid = len(values) // 2 - first_half = values[:mid] if mid > 0 else values[:1] - second_half = values[mid:] - avg_first = sum(first_half) / len(first_half) - avg_second = sum(second_half) / len(second_half) - # 使用中枢区间的一定比例作为阈值,避免微小波动误判 - threshold = abs(avg_first) * 0.005 if avg_first != 0 else 0 - if avg_second - avg_first > threshold: - return 1 - elif avg_first - avg_second > threshold: - return -1 - else: - return 0 - - def is_weakening(self): - """ - 判断中枢是否在衰弱(即将反向突破的信号) - - 衰弱条件: - 1. 中枢内笔数 >= 5(有足够的数据判断) - 2. 最后一笔的MACD面积相比同方向前一笔出现背驰(macd_div < 1) - 3. 中枢类型为收敛型或常规型 - - 返回: True表示中枢力量衰弱,可能反向 - """ - if len(self.bi_list) < 5: - return False - last_bi = self.bi_list[-1] - # 最后一笔与同方向前一笔比较MACD面积是否背驰 - if last_bi.macd_div > 0 and last_bi.macd_div < 1.0: - return True - return False - - def get_zs_strength(self): - """ - 计算中枢强度,用于辅助判断中枢延续还是反向 - - 返回字典包含: - - type: 中枢类型 (Chan_ZS_TYPE) - - bi_count: 中枢内笔数 - - range_ratio: 中枢区间占比 = (zg - zd) / (gg - dd),越小说明中枢越紧密 - - last_bi_div: 最后一笔的MACD背驰比率 - - is_weakening: 是否衰弱 - - is_extending: 是否在延伸(笔数 >= 9 可能升级) - """ - total_range = self.gg - self.dd if self.gg != self.dd else 1 - zs_range = self.zg - self.zd if self.zg != self.zd else 0 - range_ratio = zs_range / total_range if total_range > 0 else 0 - last_bi_div = self.bi_list[-1].macd_div if len(self.bi_list) > 0 else 0 - - return { - 'type': self.zs_type, - 'bi_count': len(self.bi_list), - 'range_ratio': round(range_ratio, 4), - 'last_bi_div': round(last_bi_div, 4), - 'is_weakening': self.is_weakening(), - 'is_extending': len(self.bi_list) >= 9, # 9段可能升级 - } \ No newline at end of file +"""兼容 shim — 请优先 from chanlun import ...""" +from chanlun.core.ChanBIZS import * # noqa: F403 diff --git a/ChanBSP.py b/ChanBSP.py index 568cd14..64493ec 100644 --- a/ChanBSP.py +++ b/ChanBSP.py @@ -1,24 +1,2 @@ -import ChanBI -from ChanEnum import Chan_BSP_TYPE, Chan_BSP_DIR - -class ChanBSP(): - def __init__(self, bi: ChanBI, index, type: Chan_BSP_TYPE, ddir: Chan_BSP_DIR, sure_time, zs_count, zs, seg): - self.bi = bi - self.klc = bi.end_klc - self.index = index - self.type = type - self.start_time = self.klc.start_time - self.end_time = self.klc.end_time - if sure_time: - self.is_sure = True - self.sure_time = sure_time - else: - self.is_sure = False - self.sure_time = None - self.dir = ddir - self.zs_count = zs_count - self.zs = zs - self.seg = bi.seg - def set_sure_time(self, sure_time): - self.is_sure = True - self.sure_time = sure_time \ No newline at end of file +"""兼容 shim — 请优先 from chanlun import ...""" +from chanlun.core.ChanBSP import * # noqa: F403 diff --git a/ChanCTime.py b/ChanCTime.py index 04e1bf6..1a2192c 100644 --- a/ChanCTime.py +++ b/ChanCTime.py @@ -1,44 +1,2 @@ -from datetime import datetime - - -class ChanCTime: - def __init__(self, year, month, day, hour, minute, second=0, auto=True): - self.year = year - self.month = month - self.day = day - self.hour = hour - self.minute = minute - self.second = second - self.auto = auto # 自适应对天的理解 - self.set_timestamp() # set self.ts - - def __str__(self): - if self.hour == 0 and self.minute == 0: - return f"{self.year:04}/{self.month:02}/{self.day:02}" - else: - return f"{self.year:04}/{self.month:02}/{self.day:02} {self.hour:02}:{self.minute:02}" - - def to_str(self): - if self.hour == 0 and self.minute == 0: - return f"{self.year:04}/{self.month:02}/{self.day:02}" - else: - return f"{self.year:04}/{self.month:02}/{self.day:02} {self.hour:02}:{self.minute:02}" - - def toDateStr(self, splt=''): - return f"{self.year:04}{splt}{self.month:02}{splt}{self.day:02}" - - def toDate(self): - return ChanCTime(self.year, self.month, self.day, 0, 0, auto=False) - - def set_timestamp(self): - if self.hour == 0 and self.minute == 0 and self.auto: - date = datetime(self.year, self.month, self.day, 23, 59, self.second) - else: - date = datetime(self.year, self.month, self.day, self.hour, self.minute, self.second) - self.ts = date.timestamp() - - def __gt__(self, t2): - return self.ts > t2.ts - - def __ge__(self, t2): - return self.ts >= t2.ts +"""兼容 shim — 请优先 from chanlun import ...""" +from chanlun.core.ChanCTime import * # noqa: F403 diff --git a/ChanEnum.py b/ChanEnum.py index 9164374..6b84a84 100644 --- a/ChanEnum.py +++ b/ChanEnum.py @@ -1,368 +1,2 @@ -from enum import Enum, auto -from typing import Literal - - -class Chan_DATA_SRC(Enum): - BAO_STOCK = auto() - CCXT = auto() - CSV = auto() - -class Chan_ZS_DIR(Enum): - UP = auto() - DOWN = auto() - -class Chan_ZS_TYPE(Enum): - """中枢类型分类""" - NORMAL = auto() # 常规中枢:高低点无明显趋势,区间震荡 - RISING = auto() # 上升中枢:高点抬高,低点也抬高,重心上移 - FALLING = auto() # 下行中枢:高点降低,低点也降低,重心下移 - CONVERGING = auto() # 收敛中枢:高点降低,低点抬高,区间收窄(三角收敛) - DIVERGING = auto() # 扩散中枢:高点抬高,低点降低,区间扩大(喇叭口) -class Chan_K_DIR(Enum): - BULL = auto() - BEAR = auto() - CROSS = auto() - -class Chan_EMA_POS(Enum): - """K线与任意EMA的位置关系(与趋势方向无关的客观分类,支持threshold容差)""" - ABOVE = auto() # 完全在EMA上方(远离):low > ema + threshold - NEAR_ABOVE = auto() # 在EMA上方但接近:ema < low <= ema + threshold - CROSS_CLOSE_ABOVE = auto() # 跨越EMA,收盘在上方:close > ema, low <= ema(含threshold范围内触碰) - ON_EMA = auto() # 收盘价在EMA附近:abs(close - ema) <= threshold - CROSS_CLOSE_BELOW = auto() # 跨越EMA,收盘在下方:close < ema, high >= ema(含threshold范围内触碰) - NEAR_BELOW = auto() # 在EMA下方但接近:ema - threshold <= high < ema - BELOW = auto() # 完全在EMA下方(远离):high < ema - threshold - UNKNOWN = auto() # 未知(EMA值无效) - -class Chan_EMA_SEMANTIC(Enum): - """K线与EMA结合趋势方向的语义状态(用于交易判断)""" - STRONG_TREND = auto() # 7: 顺势K线完全在EMA趋势侧(强势,远未及EMA) - TREND_SIDE = auto() # 6: 完全在EMA趋势侧(正常趋势运行) - RECOVER = auto() # 5: 逆势后穿越EMA回到趋势侧(收复EMA,趋势恢复) - TOUCH_FAIL = auto() # 4: 逆势触碰EMA但未穿越(反弹/反抽力度不足) - DEEP_COUNTER = auto() # 3: 完全在EMA逆势侧(深度回调/反抽) - BREAK = auto() # 2: 穿越EMA,收盘在逆势侧(支撑/压力失败) - TOUCH_HOLD = auto() # 1: 触碰EMA,收盘守住趋势侧(支撑/压力有效) - WEAK_COUNTER = auto() # 8: 逆势K线完全在EMA逆势侧(弱势,远未到EMA) - APPROACHING = auto() # 9: K线接近EMA但未触碰(即将测试支撑/压力) - NEUTRAL = auto() # 0: 盘整/无法判断 -class Chan_KL_TYPE(Enum): - K_1S = auto() - K_1M = auto() - K_DAY = auto() - K_WEEK = auto() - K_MON = auto() - K_YEAR = auto() - K_5M = auto() - K_15M = auto() - K_30M = auto() - K_60M = auto() - K_1H = auto() - K_2H = auto() - K_4H = auto() - K_6H = auto() - K_8H = auto() - K_12H = auto() - K_1D = auto() - K_3D = auto() - K_3M = auto() - K_QUARTER = auto() - - -class Chan_KLINE_DIR(Enum): - UP = auto() - DOWN = auto() - COMBINE = auto() - INCLUDED = auto() -class Chan_KLU_TYPE(Enum): - BigBull = auto() - MiddleBull = auto() - SmallBull = auto() - BigBear = auto() - MiddleBear = auto() - SmallBear = auto() - Cross = auto() - -class Chan_KLU_PATTERN(Enum): - # 单根K线形态 - HAMMER = auto() # 锤子线 - INVERTED_HAMMER = auto() # 倒锤子线 - SHOOTING_STAR = auto() # 射击之星 - HANGING_MAN = auto() # 上吊线 - DOJI = auto() # 十字星 - LONG_LEGGED_DOJI = auto() # 长腿十字星 - GRAVESTONE_DOJI = auto() # 墓碑十字星 - DRAGONFLY_DOJI = auto() # 蜻蜓十字星 - MARUBOZU = auto() # 光头光脚 - SPINNING_TOP = auto() # 纺锤线 - - # 双根K线形态 - BULLISH_ENGULFING = auto() # 看涨吞没 - BEARISH_ENGULFING = auto() # 看跌吞没 - PIERCING_LINE = auto() # 刺透形态 - DARK_CLOUD_COVER = auto() # 乌云盖顶 - TWEEZER_TOP = auto() # 镊子顶 - TWEEZER_BOTTOM = auto() # 镊子底 - HARAMI = auto() # 孕线 - BULLISH_HARAMI = auto() # 看涨孕线 - BEARISH_HARAMI = auto() # 看跌孕线 - - # 三根K线形态 - MORNING_STAR = auto() # 早晨之星 - EVENING_STAR = auto() # 黄昏之星 - THREE_WHITE_SOLDIERS = auto() # 红三兵 - THREE_BLACK_CROWS = auto() # 三只乌鸦 - THREE_INNER_UP = auto() # 上升三法 - THREE_INNER_DOWN = auto() # 下降三法 - ABANDONED_BABY = auto() # 弃婴形态 - - # 多根K线形态 - DOUBLE_TOP = auto() # 双顶 - DOUBLE_BOTTOM = auto() # 双底 - TRIPLE_TOP = auto() # 三顶 - TRIPLE_BOTTOM = auto() # 三底 - HEAD_AND_SHOULDERS = auto() # 头肩顶 - INVERSE_HEAD_SHOULDERS = auto() # 头肩底 - ROUNDING_BOTTOM = auto() # 圆弧底 - ROUNDING_TOP = auto() # 圆弧顶 - - # 缺口形态 - BREAKAWAY_GAP = auto() # 突破缺口 - RUNAWAY_GAP = auto() # 持续缺口 - EXHAUSTION_GAP = auto() # 衰竭缺口 - - # 特殊形态 - ISLAND_REVERSAL = auto() # 岛形反转 - KEY_REVERSAL = auto() # 关键反转 - INSIDE_BAR = auto() # 内包线 - OUTSIDE_BAR = auto() # 外包线 - - # 趋势形态 - HIGHER_HIGH = auto() # 更高高点 - HIGHER_LOW = auto() # 更高低点 - LOWER_HIGH = auto() # 更低高点 - LOWER_LOW = auto() # 更低低点 - - # 支撑阻力形态 - SUPPORT_BOUNCE = auto() # 支撑反弹 - RESISTANCE_REJECTION = auto() # 阻力拒绝 - BREAKOUT = auto() # 突破 - BREAKDOWN = auto() # 跌破 - - # 成交量相关形态 - VOLUME_SPIKE = auto() # 成交量激增 - VOLUME_DECLINE = auto() # 成交量萎缩 - - # 未知/无形态 - UNKNOWN = auto() # 未知形态 - - -class Chan_FX_TYPE(Enum): - BOTTOM = auto() - TOP = auto() - UNKNOWN = auto() - UP = auto() - DOWN = auto() - TT = auto() - BB = auto() - PTOP = auto() - PBOTTOM = auto() -class Chan_FX(Enum): - CONTINUATION = auto() - REVERSAL = auto() - UNKNOWN = auto() -class Chan_PRICE_TREND(Enum): - UP = auto() - DOWN = auto() - FLAT = auto() - UNKNOWN = auto() -class Chan_KLC_FX(Enum): - TOP0 = auto() - TOP1 = auto() - TOP2 = auto() - TOP3 = auto() - TOP4 = auto() - TOP5 = auto() - TOP6 = auto() - TOP7 = auto() - TOP8 = auto() - BOTTOM0 = auto() - BOTTOM1 = auto() - BOTTOM2 = auto() - BOTTOM3 = auto() - BOTTOM4 = auto() - BOTTOM5 = auto() - BOTTOM6 = auto() - BOTTOM7 = auto() - BOTTOM8 = auto() - UNKNOWN = auto() -# 统一的MACD状态枚举,包含所有可能的状态 -class Chan_MACD_STATE(Enum): - """MACD状态枚举 - 包含所有可能的状态""" - # 穿越状态 - CROSS0_UP = auto() # 穿零轴后快速向上,能量柱呈现一根比一根长的排列方式 - CROSS0_DOWN = auto() # 穿零轴后快速向下,能量柱呈现一根比一根短的排列方式 - - CROSS_OS = auto() # 穿零轴后缠绕/粘合,黄白线沿着能量柱运行,黄白线在运行的过程中没有释放出反向能量柱 - CROSS_REV = auto() # 穿零轴后倒挂,MACD黄白线在穿零轴的时候与零轴的距离比较近,同时黄白线沿着能量柱运行,在运行的过程中,能量柱衰减导致它跟黄白线之间形成夹角空位,同时黄白线产生交叉并释放反向能量柱。 - - # 趋势状态 - NEAR0 = auto() - NEAR0_52 = auto() # 价格在EMA52附近/价格接触EMA52并马上离开,需要观察离开强度 - NEAR0_DIFF = auto() # MACD白线接近零轴,价格未到EMA52 - NEAR0_PERFECT = auto() # MACD白线接近零轴和价格接触或短暂击穿EMA52,而MACD黄线不穿零轴,完美形态 - NEAR0_24 = auto() # MACD黄白线接近零轴和价格在EMA24附近 - # 位置状态 - HIGH = auto() # 高位:MACD黄白线离开能量柱到高点,能量柱最大开始减弱 - HIGH_EMPTY = auto() # 高位空:MACD黄白线处于高位,能量柱衰减,与黄白线形成空间夹角 - RETURN_ZERO = auto() # 归零轴:能量柱呈现一根比一根短的排列方式 - RZ_UP = auto() # 归零轴后的零轴上涨 - RZ_DOWN = auto() # 归零轴后的零轴下跌 - UP = auto() # 穿零轴后向上 - DOWN = auto() # 穿零轴后向下 - PEAK = auto() # 峰值:MACD白线处于高位 - # 基础状态 - UNKNOWN = auto() # 未知 - START = auto() # 开始 -class Chan_MACDSEG_DIR(Enum): - ABOVE = auto() - UNDER = auto() -class Chan_MACDUNITTF_TYPE(Enum): - START = auto() - CROSS0 = auto() - NEAR0 = auto() -class Chan_MACDUNITTF_JUMP(Enum): - CONTUNE = auto() - DISCRETE = auto() -class Chan_MACDUNITTF_DIV(Enum): - CONTUNE = auto() - DISCRETE = auto() - UNDIV = auto() -class Chan_MACDHISTSET_DIR(Enum): - ABOVE = auto() - UNDER = auto() -class Chan_MACDUNITTF_DIR(Enum): - ABOVE = auto() - UNDER = auto() -class Chan_MACDHIST_STATE(Enum): - UP = auto() - DOWN = auto() - PEAK = auto() - UNKNOWN = auto() - -class Chan_BI_DIR(Enum): - UP = auto() - DOWN = auto() - -class Chan_SEG_DIR(Enum): - UP = auto() - DOWN = auto() - -class Chan_BI_TYPE(Enum): - UNKNOWN = auto() - STRICT = auto() - SUB_VALUE = auto() # 次高低点成笔 - TIAOKONG_THRED = auto() - DAHENG = auto() - TUIBI = auto() - UNSTRICT = auto() - TIAOKONG_VALUE = auto() - - -Chan_BSP_MAIN_TYPE = Literal['1', '2', '3'] - -class Chan_BSP_DIR(Enum): - BUY = auto() - SELL = auto() -class Chan_BSP_TYPE(Enum): - B1 = auto() - B2 = auto() - B3 = auto() - S1 = auto() - S2 = auto() - S3 = auto() - NONE = auto() -""" -class Chan_BSP_TYPE(Enum): - T1 = '1' - T1P = '1p' - T2 = '2' - T2S = '2s' - T3A = '3a' # 中枢在1类后面 - T3B = '3b' # 中枢在1类前面 - T3 = '3' - T3E ='3e' # T3退出点 - QJT = 'qjt' # 区间套突破 - QJT1 = 'qjt1' # 区间套一类买点 - QJT2 = 'qjt2' # 区间套一类卖点 - QJT3 = 'qjt3' # 区间套三类买点 - def main_type(self) -> Chan_BSP_MAIN_TYPE: - return self.value[0] # type: ignore - -""" -class Chan_AUTYPE(Enum): - QFQ = auto() - HFQ = auto() - NONE = auto() - - -class Chan_TREND_TYPE(Enum): - MEAN = "mean" - MAX = "max" - MIN = "min" - - -class Chan_TREND_LINE_SIDE(Enum): - INSIDE = auto() - OUTSIDE = auto() - - -class Chan_LEFT_SEG_METHOD(Enum): - ALL = auto() - PEAK = auto() - - -class Chan_FX_CHECK_METHOD(Enum): - STRICT = auto() - LOSS = auto() - HALF = auto() - TOTALLY = auto() - - -class Chan_SEG_TYPE(Enum): - BI = auto() - SEG = auto() - - -class Chan_MACD_ALGO(Enum): - AREA = auto() - PEAK = auto() - FULL_AREA = auto() - DIFF = auto() - SLOPE = auto() - AMP = auto() - VOLUMN = auto() - AMOUNT = auto() - VOLUMN_AVG = auto() - AMOUNT_AVG = auto() - TURNRATE_AVG = auto() - RSI = auto() - - -class Chan_DATA_FIELD: - FIELD_TIME = "time_key" - FIELD_OPEN = "open" - FIELD_HIGH = "high" - FIELD_LOW = "low" - FIELD_CLOSE = "close" - FIELD_VOLUME = "volume" # 成交量 - FIELD_TURNOVER = "turnover" # 成交额 - FIELD_TURNRATE = "turnover_rate" # 换手率 - -class Chan_KLC_STATE: - """笔当下状态(缠论笔定理)。任意时刻必属其一。""" - S10 = "(1, 0)" # 顶分型构造中 (1,0) - S_10 = "(-1, 0)" # 底分型构造中 (-1,0) - S11 = "(1,1)" # 向上笔延续中 - S_11 = "(-1,1)" # 向下笔延续中 - UNKNOWN = "Unknown" # 初始状态 +"""兼容 shim — 请优先 from chanlun import ...""" +from chanlun.core.ChanEnum import * # noqa: F403 diff --git a/ChanHeng.py b/ChanHeng.py index 973837a..03f3e5e 100644 --- a/ChanHeng.py +++ b/ChanHeng.py @@ -1,414 +1,2 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -""" -使用 ccxt 获取币安交易所所有 `*/USDT` 交易对最新 100 根 1 小时 K 线数据,并筛选出长期横盘的币种。 - -横盘判定基于以下三项指标(均可通过命令行参数调整): -1. 价格振幅占均价的比例(默认 ≤ 5%) -2. 收盘价线性回归斜率占均价的比例(默认 ≤ 0.05%) -3. 收盘价标准差占均价的比例(默认 ≤ 1.5%) - -满足以上全部条件的交易对会被视为长期横盘。 -""" - -import argparse -import csv -import logging -import math -import statistics -import sys -import time -from dataclasses import dataclass -from typing import Iterable, List, Optional, Sequence - -import ccxt - -# python ChanHeng.py --range-threshold 5 --slope-threshold 5 --std-threshold 0.015 - -DEFAULT_LIMIT = 100 -DEFAULT_TIMEFRAME = "1h" -STABLECOINS = { - "USDT", - "USDC", - "BUSD", - "TUSD", - "USDP", - "DAI", - "FDUSD", - "SUSD", - "UST", - "USTC", - "EUR", - "TRY", - "BFUSD", - "USDE", - "XUSD", - "USD1", - "XUSD" -} - - -@dataclass -class SidewaysMetrics: - symbol: str - price_range_pct: float - slope_pct: float - std_pct: float - mean_close: float - last_close: float - data_points: int - - -def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="筛选币安长期横盘币种(默认 500 根 1 小时 K 线)" - ) - parser.add_argument( - "--timeframe", - default=DEFAULT_TIMEFRAME, - help="K 线周期(默认:1h)", - ) - parser.add_argument( - "--limit", - type=int, - default=DEFAULT_LIMIT, - help="每个交易对获取的 K 线数量(默认:500)", - ) - parser.add_argument( - "--range-threshold", - type=float, - default=0.05, - help="最大价格振幅占均价比例阈值(默认:0.05,表示 5%%)", - ) - parser.add_argument( - "--slope-threshold", - type=float, - default=0.0005, - help="线性回归斜率占均价比例阈值(默认:0.0005,约 0.05%%)", - ) - parser.add_argument( - "--std-threshold", - type=float, - default=0.015, - help="标准差占均价比例阈值(默认:0.015,表示 1.5%%)", - ) - parser.add_argument( - "--quote", - action="append", - default=[], - help="只保留指定计价货币的交易对,可重复指定(示例:--quote USDT --quote FDUSD)", - ) - parser.add_argument( - "--symbol", - action="append", - default=[], - help="仅检测指定交易对,可重复(不指定则遍历所有符合条件的现货交易对)", - ) - parser.add_argument( - "--max-symbols", - type=int, - default=None, - help="限制最多检测的交易对数量(用于调试)", - ) - parser.add_argument( - "--sleep", - type=float, - default=0.35, - help="请求失败后的基础重试等待秒数(默认:0.35)", - ) - parser.add_argument( - "--retries", - type=int, - default=3, - help="单个交易对请求失败后的最大重试次数(默认:3)", - ) - parser.add_argument( - "--include-inactive", - action="store_true", - help="包含已下架/不可交易的交易对(默认不包含)", - ) - parser.add_argument( - "--export", - type=str, - default=None, - help="将筛选结果导出为 CSV 文件的路径", - ) - parser.add_argument( - "--verbose", - action="store_true", - help="输出更详细的日志信息", - ) - return parser.parse_args(argv) - - -def setup_logging(verbose: bool) -> None: - level = logging.DEBUG if verbose else logging.INFO - logging.basicConfig( - level=level, - format="%(asctime)s [%(levelname)s] %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - ) - - -def create_exchange() -> ccxt.binance: - exchange = ccxt.binance({"enableRateLimit": True}) - exchange.options["defaultType"] = "spot" - return exchange - - -def iter_target_symbols( - exchange: ccxt.binance, - quotes: Sequence[str], - includes: Sequence[str], - include_inactive: bool, -) -> List[str]: - markets = exchange.load_markets() - filtered = [] - - quote_set = {quote.upper() for quote in quotes} - include_set = {sym.upper() for sym in includes} - - for symbol, meta in markets.items(): - if not meta.get("spot", False): - continue - if not include_inactive and meta.get("active") is False: - continue - - normalized_symbol = symbol.upper() - - if include_set and normalized_symbol not in include_set: - continue - - parts = symbol.split("/") - if len(parts) != 2: - continue - - base_asset, quote_asset = parts[0].upper(), parts[1].upper() - - target_quote = quote_set or {"USDT"} - if quote_asset not in target_quote: - continue - - if base_asset in STABLECOINS: - continue - - filtered.append(symbol) - - filtered.sort() - logging.info( - "已筛选 %s 个目标交易对(quote 过滤:%s,专门列表:%s)", - len(filtered), - ",".join(sorted(quote_set or {"USDT"})), - ",".join(sorted(include_set)) or "无", - ) - return filtered - - -def fetch_ohlcv_with_retry( - exchange: ccxt.binance, - symbol: str, - timeframe: str, - limit: int, - retries: int, - base_sleep: float, -) -> List[List[float]]: - attempt = 0 - while True: - try: - return exchange.fetch_ohlcv(symbol, timeframe=timeframe, limit=limit) - except ccxt.RateLimitExceeded as exc: - wait_time = max(exchange.rateLimit / 1000.0 if exchange.rateLimit else 0, base_sleep) - logging.debug("触发限频,等待 %.2f 秒后重试 %s:%s", wait_time, symbol, exc) - time.sleep(wait_time) - except (ccxt.NetworkError, ccxt.ExchangeError) as exc: - attempt += 1 - if attempt > retries: - logging.warning("多次获取失败,跳过 %s:%s", symbol, exc) - return [] - wait_time = base_sleep * attempt - logging.debug("请求失败,等待 %.2f 秒后重试 %s(第 %d 次):%s", wait_time, symbol, attempt, exc) - time.sleep(wait_time) - - -def linear_regression_slope(values: Sequence[float]) -> float: - n = len(values) - if n < 2: - return 0.0 - mean_x = (n - 1) / 2.0 - mean_y = sum(values) / n - numerator = 0.0 - denominator = 0.0 - for idx, value in enumerate(values): - dx = idx - mean_x - numerator += dx * (value - mean_y) - denominator += dx * dx - - if denominator == 0: - return 0.0 - return numerator / denominator - - -def compute_sideways_metrics(closes: Sequence[float], symbol: str) -> Optional[SidewaysMetrics]: - if not closes: - return None - - mean_close = sum(closes) / len(closes) - if math.isclose(mean_close, 0.0): - return None - - max_close = max(closes) - min_close = min(closes) - price_range_pct = (max_close - min_close) / mean_close - - slope = linear_regression_slope(closes) - slope_pct = slope / mean_close - - std_dev = statistics.pstdev(closes) if len(closes) > 1 else 0.0 - std_pct = std_dev / mean_close - - return SidewaysMetrics( - symbol=symbol, - price_range_pct=price_range_pct, - slope_pct=slope_pct, - std_pct=std_pct, - mean_close=mean_close, - last_close=closes[-1], - data_points=len(closes), - ) - - -def is_sideways(metrics: SidewaysMetrics, range_threshold: float, slope_threshold: float, std_threshold: float) -> bool: - return ( - metrics.price_range_pct <= range_threshold - and abs(metrics.slope_pct) <= slope_threshold - and metrics.std_pct <= std_threshold - ) - - -def export_results(path: str, results: Sequence[SidewaysMetrics]) -> None: - fieldnames = [ - "symbol", - "price_range_pct", - "slope_pct", - "std_pct", - "mean_close", - "last_close", - "data_points", - ] - with open(path, "w", newline="", encoding="utf-8") as fp: - writer = csv.DictWriter(fp, fieldnames=fieldnames) - writer.writeheader() - for item in results: - writer.writerow( - { - "symbol": item.symbol, - "price_range_pct": f"{item.price_range_pct:.6f}", - "slope_pct": f"{item.slope_pct:.6f}", - "std_pct": f"{item.std_pct:.6f}", - "mean_close": f"{item.mean_close:.8f}", - "last_close": f"{item.last_close:.8f}", - "data_points": item.data_points, - } - ) - logging.info("结果已导出至 %s", path) - - -def run(argv: Optional[Sequence[str]] = None) -> int: - args = parse_args(argv) - if not args.quote: - args.quote = ["USDT"] - setup_logging(args.verbose) - - exchange = create_exchange() - symbols = iter_target_symbols( - exchange=exchange, - quotes=args.quote, - includes=args.symbol, - include_inactive=args.include_inactive, - ) - - if args.max_symbols is not None: - symbols = symbols[: args.max_symbols] - logging.info("出于调试目的,仅检测前 %d 个交易对。", len(symbols)) - - if not symbols: - logging.error("未找到任何满足条件的交易对,请检查过滤条件。") - return 1 - - sideways_results: List[SidewaysMetrics] = [] - total = len(symbols) - - for idx, symbol in enumerate(symbols, start=1): - logging.info("(%d/%d) 正在获取 %s 的 %s K 线(limit=%d)", idx, total, symbol, args.timeframe, args.limit) - ohlcv = fetch_ohlcv_with_retry( - exchange=exchange, - symbol=symbol, - timeframe=args.timeframe, - limit=args.limit, - retries=args.retries, - base_sleep=args.sleep, - ) - - if len(ohlcv) < max(100, args.limit // 2): - logging.debug("交易对 %s 返回数据不足(%d 根),跳过。", symbol, len(ohlcv)) - continue - - closes = [entry[4] for entry in ohlcv if entry[4] is not None] - metrics = compute_sideways_metrics(closes, symbol) - if not metrics: - continue - - if is_sideways(metrics, args.range_threshold, args.slope_threshold, args.std_threshold): - sideways_results.append(metrics) - logging.info( - "识别为横盘:%s | 振幅 %.2f%% | 斜率 %.4f%% | 标准差 %.2f%%", - symbol, - metrics.price_range_pct * 100, - metrics.slope_pct * 100, - metrics.std_pct * 100, - ) - else: - logging.debug( - "未满足条件:%s | 振幅 %.2f%% | 斜率 %.4f%% | 标准差 %.2f%%", - symbol, - metrics.price_range_pct * 100, - metrics.slope_pct * 100, - metrics.std_pct * 100, - ) - - if not sideways_results: - logging.warning("未检测到满足定义的长期横盘交易对。") - return 0 - - sideways_results.sort(key=lambda item: (item.price_range_pct, abs(item.slope_pct), item.std_pct)) - print("=" * 88) - print( - f"共识别 {len(sideways_results)} 个长期横盘交易对(阈值:振幅≤{args.range_threshold:.2%}," - f"斜率≤{args.slope_threshold:.2%},标准差≤{args.std_threshold:.2%})" - ) - print("=" * 88) - header = f"{'Symbol':15s} {'Range%':>10s} {'Slope%':>10s} {'STD%':>10s} {'Mean':>14s} {'Last':>14s} {'Count':>6s}" - print(header) - print("-" * len(header)) - for item in sideways_results: - print( - f"{item.symbol:15s}" - f" {item.price_range_pct * 100:10.4f}" - f" {item.slope_pct * 100:10.4f}" - f" {item.std_pct * 100:10.4f}" - f" {item.mean_close:14.8f}" - f" {item.last_close:14.8f}" - f" {item.data_points:6d}" - ) - - if args.export: - export_results(args.export, sideways_results) - - logging.info("任务完成。") - return 0 - - -if __name__ == "__main__": - sys.exit(run()) - +"""兼容 shim — 请优先 from chanlun import ...""" +from chanlun.analysis.ChanHeng import * # noqa: F403 diff --git a/ChanKLC.py b/ChanKLC.py index 7dcafcb..04ff66a 100644 --- a/ChanKLC.py +++ b/ChanKLC.py @@ -1,620 +1,2 @@ -import copy -from typing import Dict, Optional - -from ChanEnum import Chan_FX_TYPE, Chan_KLINE_DIR, Chan_BI_DIR, Chan_KLC_FX -from ChanEnum import Chan_K_DIR, Chan_MACD_STATE, Chan_PRICE_TREND, Chan_EMA_POS -from ChanEnum import Chan_EMA_SEMANTIC, Chan_BSP_TYPE, Chan_KLC_STATE, Chan_FX -import ChanKLU -import ChanCTime -import Chan_FX_Box -# 根据结合律合并K线后的K线 -class ChanKLC(): - def __init__(self, klu: ChanKLU, index, ddir=Chan_KLINE_DIR.UP): - self.start_time = klu.time - self.end_time = None - self.high = klu.high - self.low = klu.low - self.dir = ddir - self.index = index - self.klu_list = [] - self.add_klu(klu) - self.fx = Chan_FX_TYPE.UNKNOWN - self.next = None - self.pre = None - self.start_klu = klu - self.end_klu = None - self.state = "00" - self.klc_state = Chan_KLC_STATE.UNKNOWN - self.open = klu.open - self.close = klu.close - self.volume = klu.volume - self.bi = None - self.distance = 0 - self.klc_fx_type = Chan_KLC_FX.UNKNOWN - self.rsi = klu.rsi - self.volume_ratio = klu.volume_ratio - self.macdhist = klu.macdhist - self.body = klu.body - self.upper_shadow = klu.upper_shadow - self.lower_shadow = klu.lower_shadow - self.body_ratio = klu.body_ratio - self.upper_shadow_ratio = klu.upper_shadow_ratio - self.lower_shadow_ratio = klu.lower_shadow_ratio - self.candle_dir = klu.candle_dir - self.range = klu.range - self.bb_out = True - self.macd = klu.macd - self.signal = klu.signal - self.state = Chan_MACD_STATE.UNKNOWN - self.continue_div = False - self.separate_div = False - self.ema24 = klu.ema24 - self.ema26 = klu.ema26 - self.ema52 = klu.ema52 - self.ema104 = klu.ema104 - self.ema156 = klu.ema156 - self.ema208 = klu.ema208 - self.ema13 = klu.ema13 - self.ema7 = klu.ema7 - self.trend = Chan_PRICE_TREND.UNKNOWN - self.exception = klu.exception - self.klc_dir = Chan_KLINE_DIR.UP if klu.close > klu.open else Chan_KLINE_DIR.DOWN - self.ema_dir = klu.ema_dir - self.bsp = False - self.bsp_type = Chan_BSP_TYPE.NONE - # EMA状态字典:key为EMA名称,value为 {'pos': Chan_EMA_POS, 'semantic': Chan_EMA_SEMANTIC} - self.ema_status = {} - # 向后兼容:保留 ema52_status 和 ema52_pos - self.ema52_status = 0 - self.ema52_pos = Chan_EMA_POS.UNKNOWN - self.bb2633upper = klu.bb2633upper - self.bb2633lower = klu.bb2633lower - self.bb2633middle = klu.bb2633middle - self.ema5 = klu.ema5 - self.ma5 = klu.ma5 - self.fx_box = None - self.in_fx = False - self.fx_confirmed = False - self.ema52_dis = klu.high - klu.ema52 if klu.close > klu.ema52 else klu.ema52 - klu.low - self.ema26_dis = klu.high - klu.ema26 if klu.close > klu.ema26 else klu.ema26 - klu.low - self.macd_signal_dis = abs(klu.macd - klu.signal) - self.ema52_ema26_dis = abs(klu.ema52 - klu.ema26) - self.fx_type = Chan_FX.UNKNOWN - self.bi_zs = None - self.seg_zs = None - self.last_bi_zs = None - # ==================== EMA 通用计算方法 ==================== - - @staticmethod - def cal_ema_pos(high, low, close, ema_value, threshold=0): - """ - 计算K线与任意EMA的客观位置关系(与趋势方向无关,支持threshold容差) - - 参数: - high, low, close: K线的高低收盘价 - ema_value: EMA的值 - threshold: 容差值(绝对值),在此范围内视为"接近/触碰" - 例如 BTC 价格 $100,000 时 threshold=100 表示差100点视为触碰 - 返回: - Chan_EMA_POS 枚举值 - - 判断逻辑(以threshold=100, ema=97000为例): - ema_zone = [96900, 97100] (EMA上下各扩展threshold) - - ABOVE: low > 97100 K线完全在zone上方(远离EMA) - NEAR_ABOVE: 97000 < low <= 97100 K线在上方但下影线进入zone(接近EMA) - CROSS_CLOSE_ABOVE: close > 97000, low <= 97000 K线穿越EMA,收盘在上方 - ON_EMA: abs(close - 97000) <= 100 收盘价在zone内 - CROSS_CLOSE_BELOW: close < 97000, high >= 97000 K线穿越EMA,收盘在下方 - NEAR_BELOW: 96900 <= high < 97000 K线在下方但上影线进入zone(接近EMA) - BELOW: high < 96900 K线完全在zone下方(远离EMA) - """ - if ema_value is None or ema_value == 0: - return Chan_EMA_POS.UNKNOWN - - ema_upper = ema_value + threshold # EMA zone 上界 - ema_lower = ema_value - threshold # EMA zone 下界 - - # 1. 收盘价在EMA附近(zone内) - if threshold > 0 and abs(close - ema_value) <= threshold: - # 收盘价在zone内,但还需要看是否有实际穿越 - if low <= ema_value and close >= ema_value: - return Chan_EMA_POS.CROSS_CLOSE_ABOVE # 实际穿越了精确EMA线 - elif high >= ema_value and close <= ema_value: - return Chan_EMA_POS.CROSS_CLOSE_BELOW - return Chan_EMA_POS.ON_EMA - - # 2. K线实际穿越了精确的EMA线 - if close > ema_value and low <= ema_value: - return Chan_EMA_POS.CROSS_CLOSE_ABOVE - if close < ema_value and high >= ema_value: - return Chan_EMA_POS.CROSS_CLOSE_BELOW - if close == ema_value: - return Chan_EMA_POS.ON_EMA - - # 3. 没有实际穿越,检查是否"接近"(在threshold zone内) - if close > ema_value: - # K线在EMA上方 - if threshold > 0 and low <= ema_upper: - return Chan_EMA_POS.NEAR_ABOVE # 下影线进入zone,接近但未触碰 - return Chan_EMA_POS.ABOVE # 远离EMA - else: - # K线在EMA下方 - if threshold > 0 and high >= ema_lower: - return Chan_EMA_POS.NEAR_BELOW # 上影线进入zone,接近但未触碰 - return Chan_EMA_POS.BELOW # 远离EMA - - @staticmethod - def cal_ema_semantic(ema_pos, kline_dir, ema_dir): - """ - 根据客观位置 + K线方向 + 趋势方向,计算语义状态 - - 参数: - ema_pos: Chan_EMA_POS 客观位置 - kline_dir: Chan_KLINE_DIR K线方向 (UP/DOWN/COMBINE/INCLUDED) - ema_dir: int 趋势方向 (1=多头, -1=空头, 0=盘整) - 返回: - Chan_EMA_SEMANTIC 枚举值 - - 语义含义(以多头为例,空头完全对称): - TOUCH_HOLD: 触碰EMA,收盘守住趋势侧(支撑/压力有效) - BREAK: 穿越EMA,收盘在逆势侧(支撑/压力失败) - DEEP_COUNTER: 完全在EMA逆势侧(深度回调/反抽) - TOUCH_FAIL: 逆势触碰EMA但未穿越(反弹/反抽力度不足) - RECOVER: 逆势后穿越EMA回到趋势侧(收复EMA) - TREND_SIDE: 完全在EMA趋势侧(正常运行) - STRONG_TREND: 顺势K线完全在EMA趋势侧(强势,远未及EMA) - WEAK_COUNTER: 逆势K线完全在EMA逆势侧(弱势,远未到EMA) - """ - if ema_pos == Chan_EMA_POS.UNKNOWN: - return Chan_EMA_SEMANTIC.NEUTRAL - - # 统一处理:将多头/盘整和空头映射到同一套逻辑 - # is_bull=True 时,"趋势侧"=上方,"逆势侧"=下方 - # is_bull=False时,"趋势侧"=下方,"逆势侧"=上方 - is_bull = ema_dir >= 0 # 多头和盘整都按多头逻辑处理 - - # K线是否是顺势方向(多头下UP为顺势,空头下DOWN为顺势) - is_trend_kline = (kline_dir == Chan_KLINE_DIR.UP) if is_bull else (kline_dir == Chan_KLINE_DIR.DOWN) - is_counter_kline = (kline_dir == Chan_KLINE_DIR.DOWN) if is_bull else (kline_dir == Chan_KLINE_DIR.UP) - - # 位置映射:多头下 ABOVE=趋势侧, BELOW=逆势侧; 空头反过来 - trend_side = Chan_EMA_POS.ABOVE if is_bull else Chan_EMA_POS.BELOW - counter_side = Chan_EMA_POS.BELOW if is_bull else Chan_EMA_POS.ABOVE - near_trend = Chan_EMA_POS.NEAR_ABOVE if is_bull else Chan_EMA_POS.NEAR_BELOW - near_counter = Chan_EMA_POS.NEAR_BELOW if is_bull else Chan_EMA_POS.NEAR_ABOVE - cross_to_trend = Chan_EMA_POS.CROSS_CLOSE_ABOVE if is_bull else Chan_EMA_POS.CROSS_CLOSE_BELOW - cross_to_counter = Chan_EMA_POS.CROSS_CLOSE_BELOW if is_bull else Chan_EMA_POS.CROSS_CLOSE_ABOVE - - # COMBINE / INCLUDED 方向:只看位置,不区分强弱 - if not is_trend_kline and not is_counter_kline: - if ema_pos == trend_side: - return Chan_EMA_SEMANTIC.TREND_SIDE - elif ema_pos in (near_trend, cross_to_trend, Chan_EMA_POS.ON_EMA): - return Chan_EMA_SEMANTIC.APPROACHING - elif ema_pos in (near_counter, cross_to_counter): - return Chan_EMA_SEMANTIC.APPROACHING - elif ema_pos == counter_side: - return Chan_EMA_SEMANTIC.DEEP_COUNTER - return Chan_EMA_SEMANTIC.NEUTRAL - - # 逆势K线(多头下的下跌K线 / 空头下的上涨K线) - if is_counter_kline: - if ema_pos == trend_side: - return Chan_EMA_SEMANTIC.STRONG_TREND # 逆势K线仍在趋势侧(回调很浅) - elif ema_pos == near_trend: - return Chan_EMA_SEMANTIC.APPROACHING # 接近EMA,即将测试支撑/压力 - elif ema_pos == cross_to_trend: - return Chan_EMA_SEMANTIC.TOUCH_HOLD # 触碰EMA后守住趋势侧 - elif ema_pos == Chan_EMA_POS.ON_EMA: - return Chan_EMA_SEMANTIC.TOUCH_HOLD # 收盘在EMA附近,视为守住 - elif ema_pos == cross_to_counter: - return Chan_EMA_SEMANTIC.BREAK # 穿越EMA到逆势侧 - elif ema_pos == near_counter: - return Chan_EMA_SEMANTIC.BREAK # 接近EMA但收盘在逆势侧,也视为击穿 - elif ema_pos == counter_side: - return Chan_EMA_SEMANTIC.DEEP_COUNTER # 完全在逆势侧 - - # 顺势K线(多头下的上涨K线 / 空头下的下跌K线) - if is_trend_kline: - if ema_pos == counter_side: - return Chan_EMA_SEMANTIC.WEAK_COUNTER # 顺势K线却在逆势侧(弱势) - elif ema_pos == near_counter: - return Chan_EMA_SEMANTIC.APPROACHING # 从逆势侧接近EMA - elif ema_pos == cross_to_counter: - return Chan_EMA_SEMANTIC.TOUCH_FAIL # 触碰EMA但未穿越回趋势侧 - elif ema_pos == Chan_EMA_POS.ON_EMA: - return Chan_EMA_SEMANTIC.TOUCH_FAIL # 收盘在EMA附近,未确认突破 - elif ema_pos == cross_to_trend: - return Chan_EMA_SEMANTIC.RECOVER # 从逆势侧穿越回趋势侧 - elif ema_pos == near_trend: - return Chan_EMA_SEMANTIC.RECOVER # 接近趋势侧(刚收复EMA附近) - elif ema_pos == trend_side: - return Chan_EMA_SEMANTIC.TREND_SIDE # 完全在趋势侧(正常) - - return Chan_EMA_SEMANTIC.NEUTRAL - - @staticmethod - def semantic_to_int(semantic): - """将 Chan_EMA_SEMANTIC 枚举转换为整数,兼容旧的 ema52_status 数值""" - mapping = { - Chan_EMA_SEMANTIC.TOUCH_HOLD: 1, - Chan_EMA_SEMANTIC.BREAK: 2, - Chan_EMA_SEMANTIC.DEEP_COUNTER: 3, - Chan_EMA_SEMANTIC.TOUCH_FAIL: 4, - Chan_EMA_SEMANTIC.RECOVER: 5, - Chan_EMA_SEMANTIC.TREND_SIDE: 6, - Chan_EMA_SEMANTIC.STRONG_TREND: 7, - Chan_EMA_SEMANTIC.WEAK_COUNTER: 8, - Chan_EMA_SEMANTIC.APPROACHING: 9, - Chan_EMA_SEMANTIC.NEUTRAL: 0, - } - return mapping.get(semantic, 0) - - # threshold_pct: 阈值百分比,用于自动计算绝对阈值 - # 例如 0.001 表示 EMA 值的 0.1%,BTC $100,000 时 threshold = $100 - threshold_pct = 0.001 - def set_bsp_type(self, bsp_type): - if bsp_type and bsp_type != Chan_BSP_TYPE.NONE: - self.bsp_type = bsp_type - self.bsp = True - def cal_all_ema_status(self): - """ - 统一计算所有EMA与K线的位置关系和语义状态 - - threshold 自动按 EMA 值的百分比计算(cls.threshold_pct,默认0.1%) - - BTC $100,000 时:threshold ≈ $100 - - ETH $3,000 时:threshold ≈ $3 - - SOL $200 时:threshold ≈ $0.2 - - 结果存储在 self.ema_status 字典中,格式: - { - 'ema24': {'pos': Chan_EMA_POS, 'semantic': Chan_EMA_SEMANTIC, 'value': float, 'threshold': float}, - 'ema52': {...}, - ... - } - - 同时保持向后兼容:self.ema52_pos 和 self.ema52_status - """ - ema_configs = { - 'ema24': self.ema24, - 'ema52': self.ema52, - 'ema104': self.ema104, - 'ema156': self.ema156, - 'ema208': self.ema208, - } - self.ema_status = {} - for name, value in ema_configs.items(): - # 按 EMA 值的百分比自动计算阈值 - threshold = abs(value) * self.threshold_pct if value and self.threshold_pct > 0 else 0 - pos = ChanKLC.cal_ema_pos(self.high, self.low, self.close, value, threshold) - semantic = ChanKLC.cal_ema_semantic(pos, self.dir, self.ema_dir) - self.ema_status[name] = { - 'pos': pos, - 'semantic': semantic, - 'value': value, - 'threshold': threshold, - } - # 向后兼容 - self.ema52_pos = self.ema_status['ema52']['pos'] - self.ema52_status = ChanKLC.semantic_to_int(self.ema_status['ema52']['semantic']) - def get_ema_pos(self, ema_name): - """获取指定EMA的客观位置,如 klc.get_ema_pos('ema24')""" - if ema_name in self.ema_status: - return self.ema_status[ema_name]['pos'] - return Chan_EMA_POS.UNKNOWN - def check_ema_pos(self): - if len(self.ema_status) > 0: - for ema_name, pos in self.ema_status.items(): - #print(self.end_time, ema_name, pos['pos']) - if ((self.klc_fx_type == Chan_KLC_FX.TOP1 or self.klc_fx_type == Chan_KLC_FX.TOP2) and pos['pos'] == Chan_EMA_POS.CROSS_CLOSE_BELOW) or ((self.klc_fx_type == Chan_KLC_FX.BOTTOM1 or self.klc_fx_type == Chan_KLC_FX.BOTTOM2) and pos['pos'] == Chan_EMA_POS.CROSS_CLOSE_ABOVE): - #print("---------------------") - return ema_name - return None - def get_ema_semantic(self, ema_name): - """获取指定EMA的语义状态,如 klc.get_ema_semantic('ema52')""" - if ema_name in self.ema_status: - return self.ema_status[ema_name]['semantic'] - return Chan_EMA_SEMANTIC.NEUTRAL - def set_trend(self, trend): - self.trend = trend - def to_string(self): - out = "" - start = self.start_time if self.start_time is not None else "" - end = self.end_time if self.end_time is not None else "" - price_diff = getattr(self, 'price_diff', None) - out += str(start) + " " + str(end) + " " + str(self.close) + " " + str(self.ema24) + " " + str(self.ema52) + " " + str(self.trend) + " " + str(self.close - self.ema52) - return out - def set_bi_zs(self, bi_zs): - if bi_zs: - self.bi_zs = bi_zs - def set_klc_fx_type(self, klc_fx_type): - #print(self.start_time, klc_fx_type, self.get_feature_data()['klu_macd'], self.get_feature_data()['klu_macdhist'], self.get_feature_data()['klu_rsi']) - self.klc_fx_type = klc_fx_type - #self.cal_fx() - ema_name = self.check_ema_pos() - hist_div = abs(self.macdhist - self.next.macdhist) - #print(self.end_time, self.dir, abs(self.macdhist), hist_div) - #if ema_name: - #print(self.end_time, ema_name, self.ema_status[ema_name]['semantic'], hist_div) - #self.cal_bb_out() - #print(self.pre.start_time, self.next.end_time, self.klc_fx_type) - if klc_fx_type == Chan_KLC_FX.TOP1 or klc_fx_type == Chan_KLC_FX.TOP2 or klc_fx_type == Chan_KLC_FX.BOTTOM1 or klc_fx_type == Chan_KLC_FX.BOTTOM2: - self.cal_fx_box() - self.cal_fx_type() - def cal_fx_type(self): - if self.fx == Chan_FX_TYPE.TOP and self.next: - if self.ema52_dis > self.ema26_dis: - if self.pre.macd < self.macd and self.macd < self.next.macd: - self.fx_type = Chan_FX.CONTINUATION - else: - self.fx_type = Chan_FX.REVERSAL - elif self.fx == Chan_FX_TYPE.BOTTOM and self.next: - if self.ema52_dis < self.ema26_dis: - if self.pre.macd > self.macd and self.macd > self.next.macd: - self.fx_type = Chan_FX.CONTINUATION - else: - self.fx_type = Chan_FX.REVERSAL - #if self.fx_type != Chan_FX.UNKNOWN and self.fx_type != Chan_FX.CONTINUATION: - #print(self.end_time, self.fx_type) - def cal_fx_box(self): - # 每次重算前先清空,避免旧box残留 - self.fx_box = None - start_time = None - end_time = None - high = 0 - low = 0 - display = False - if self.pre and self.next and self.next.end_time: - self.next.in_fx = True - if self.fx == Chan_FX_TYPE.TOP: - start_time = self.pre.end_time - end_time = self.next.end_time - high = self.high - low = self.pre.low if self.pre.low < self.next.low else self.next.low - if self.next.close < self.pre.low or True: - display = True - elif self.fx == Chan_FX_TYPE.BOTTOM: - start_time = self.pre.end_time - end_time = self.next.end_time - high = self.pre.high if self.pre.high > self.next.high else self.next.high - low = self.low - if self.next.close > self.pre.high or True: - display = True - if high > 0 and self.next.end_time and display: - #print(start_time, end_time, high, low) - # Chan_FX_BOX 这里导入的是模块,类名在模块内部为 Chan_FX_Box - self.fx_confirmed = True - self.fx_box = Chan_FX_Box.Chan_FX_Box(start_time, end_time, high, low) - def check_fx_confirmed(self, last_top, last_bottom): - if last_top and last_bottom and False: - if last_top.index > last_bottom.index: - if self.in_fx == False and last_top.fx_confirmed == False: - pre = last_top.pre - if pre.low > self.close: - last_top.fx_confirmed = True - if last_top.fx_box: - last_top.fx_box.end_time = self.end_time - #print(self.end_time, "fx_confirmed top") - else: - high = last_top.high - low = self.low - last_top.fx_box = Chan_FX_Box.Chan_FX_Box(last_top.pre.start_time, self.end_time, high, low) - #print(self.end_time, "fx_confirmed new box top") - elif self.in_fx == False and last_bottom.fx_confirmed == False: - pre = last_bottom.pre - if pre.high < self.close: - last_bottom.fx_confirmed = True - if last_bottom.fx_box: - last_bottom.fx_box.end_time = self.end_time - #print(self.end_time, "fx_confirmed bottom") - else: - high = self.high - low = last_bottom.low - last_bottom.fx_box = Chan_FX_Box.Chan_FX_Box(last_bottom.pre.start_time, self.end_time, high, low) - #print(self.end_time, "fx_confirmed new box bottom") - def add_klu(self, klu): - self.klu_list.append(klu) - def check_klc_state(self, last_fx_klc): - if last_fx_klc and last_fx_klc.fx == Chan_FX_TYPE.TOP: - if self.high > last_fx_klc.high: - self.klc_state = Chan_KLC_STATE.S11 - else: - self.klc_state = Chan_KLC_STATE.S_11 - elif last_fx_klc and last_fx_klc.fx == Chan_FX_TYPE.BOTTOM: - if self.low < last_fx_klc.low: - self.klc_state = Chan_KLC_STATE.S_11 - else: - self.klc_state = Chan_KLC_STATE.S11 - if self.pre and self.pre.fx == Chan_FX_TYPE.TOP: - self.klc_state = Chan_KLC_STATE.S10 - elif self.pre and self.pre.fx == Chan_FX_TYPE.BOTTOM: - self.klc_state = Chan_KLC_STATE.S_10 - #print(self.end_time, self.klc_state) - def set_end_klu(self, klu): - self.end_klu = klu - self.end_time = klu.time - self.close = klu.close - for klu in self.klu_list: - if klu.exception: - self.exception = True - print(klu.time, "exception") - if klu.separate_div > 0: - self.separate_div = True - if klu.continue_div: - self.continue_div = klu.continue_div - if klu.macd_state != Chan_MACD_STATE.UNKNOWN: - self.state = klu.macd_state - klu.set_klc(self) - self.klc_dir = Chan_KLINE_DIR.UP if self.close > self.open else Chan_KLINE_DIR.DOWN - self.cal_indicators() - self.cal_all_ema_status() - if self.open > self.high: - self.open = self.high - if self.close > self.high: - self.close = self.high - if self.close < self.low: - self.close = self.low - if self.open < self.low: - self.open = self.low - #print(self.end_time, self.open, self.close, self.high, self.low) - #print(klu.time, klu.open, klu.close, klu.high, klu.low) - def cal_fx(self): - if self.klc_fx_type == Chan_KLC_FX.TOP1 or self.klc_fx_type == Chan_KLC_FX.TOP2: - #print(self.end_time, self.fx, self.macd, self.macdhist, len(self.klu_list)) - if self.state == Chan_MACD_STATE.HIGH_EMPTY and self.macd > 0: - #print(self.end_time, self.state, self.macd, self.klc_fx_type) - self.klc_fx_type = Chan_KLC_FX.TOP6 - if self.separate_div or self.continue_div: - self.klc_fx_type = Chan_KLC_FX.TOP7 - if self.signal > 0 and self.macd > self.signal: - self.klc_fx_type = Chan_KLC_FX.TOP8 - else: - if self.klc_fx_type == Chan_KLC_FX.BOTTOM1 or self.klc_fx_type == Chan_KLC_FX.BOTTOM2: - if self.macdhist > 0 and self.macd < 0: - self.klc_fx_type = Chan_KLC_FX.BOTTOM5 - return - if self.state == Chan_MACD_STATE.HIGH_EMPTY and self.macd < 0: - self.klc_fx_type = Chan_KLC_FX.BOTTOM6 - #print(self.end_time, self.state, self.macd, self.klc_fx_type) - if self.separate_div or self.continue_div: - self.klc_fx_type = Chan_KLC_FX.BOTTOM7 - if self.signal < 0 and self.macd < self.signal: - self.klc_fx_type = Chan_KLC_FX.BOTTOM8 - def cal_bb_out(self): - for klu in self.klu_list: - if self.klc_fx_type == Chan_KLC_FX.TOP1 or self.klc_fx_type == Chan_KLC_FX.TOP2: - #print(self.start_time, self.klc_fx_type, klu.high, klu.bb52upper, self.macd, self.next.macd, klu.time) - if self.high >= klu.bb52upper and klu.bb52upper > 0 and self.next and self.high > self.next.high: - self.klc_fx_type = Chan_KLC_FX.TOP4 - print(self.end_time, self.klc_fx_type) - if self.klc_fx_type == Chan_KLC_FX.BOTTOM1 or self.klc_fx_type == Chan_KLC_FX.BOTTOM2: - #print(self.start_time, self.klc_fx_type, klu.low, klu.bb52lower, self.macd, self.next.macd, klu.time) - if self.low <= klu.bb52lower and klu.bb52lower > 0 and self.next and self.low < self.next.low: - self.klc_fx_type = Chan_KLC_FX.BOTTOM4 - print(self.end_time, self.klc_fx_type) - def cal_indicators(self): - for index in range(1, len(self.klu_list)): - self.volume += self.klu_list[index].volume - self.rsi += self.klu_list[index].rsi - self.volume_ratio += self.klu_list[index].volume_ratio - self.macdhist += self.klu_list[index].macdhist - self.ema26 += self.klu_list[index].ema26 - self.ema24 += self.klu_list[index].ema24 - self.ema52 += self.klu_list[index].ema52 - self.ema104 += self.klu_list[index].ema104 - self.ema156 += self.klu_list[index].ema156 - self.ema208 += self.klu_list[index].ema208 - self.ema13 += self.klu_list[index].ema13 - self.ema7 += self.klu_list[index].ema7 - self.bb2633upper += self.klu_list[index].bb2633upper - self.bb2633lower += self.klu_list[index].bb2633lower - self.bb2633middle += self.klu_list[index].bb2633middle - self.ma5 += self.klu_list[index].ma5 - self.ema5 += self.klu_list[index].ema5 - if self.ema_dir != self.klu_list[index].ema_dir: - self.ema_dir = 0 - n = len(self.klu_list) - self.rsi = self.rsi / n - self.volume_ratio = self.volume_ratio / n - self.volume = self.volume / n - self.macdhist = self.macdhist / n - self.ema26 = self.ema26 / n - self.ema24 = self.ema24 / n - self.ema52 = self.ema52 / n - self.ema104 = self.ema104 / n - self.ema156 = self.ema156 / n - self.ema208 = self.ema208 / n - self.ema13 = self.ema13 / n - self.ema7 = self.ema7 / n - self.ma5 = self.ma5 / n - self.ema5 = self.ema5 / n - self.bb2633upper = self.bb2633upper / n - self.bb2633lower = self.bb2633lower / n - self.bb2633middle = self.bb2633middle / n - if len(self.klu_list) > 0: - self.macd = self.klu_list[-1].macd - self.signal = self.klu_list[-1].signal - self.body = abs(self.close - self.open) - self.upper_shadow = self.high - max(self.close, self.open) - self.lower_shadow = min(self.close, self.open) - self.low - self.body_ratio = self.body / self.open - self.upper_shadow_ratio = self.upper_shadow / self.open - self.lower_shadow_ratio = self.lower_shadow / self.open - self.candle_dir = Chan_K_DIR.CROSS if self.close == self.open else Chan_K_DIR.BULL if self.close > self.open else Chan_K_DIR.BEAR - self.range = self.high - self.low - def set_next(self, klc): - self.next = klc - def set_pre(self, klc): - self.pre = klc - def set_state(self, state): - self.state = state - def check_klu_included(self, klu): - if self.high >= klu.high: - # high大于,low小于,左包含 - if self.low <= klu.low: - self.add_klu(klu=klu) - # gn>gn-1 - if self.dir == Chan_KLINE_DIR.UP: - # UP -> max(dn) - self.low = klu.low - else: - # DOWN -> min(gn) - self.high = klu.high - #self.print(klu, "Z") - return True - # high大于,low大于,不包含 - else: - # if self.low > klu.low - # high相等,右包含 - if self.high == klu.high: - self.add_klu(klu=klu) - # UP -> max(gn) - if self.dir == Chan_KLINE_DIR.UP: - self.high = klu.high - else: - # DOWN -> min(dn) - self.low = klu.low - return True - else: - return False - else: - # high小于,low大于,右包含 - if self.low >= klu.low: - self.add_klu(klu=klu) - # gn>gn-1 - if self.dir == Chan_KLINE_DIR.UP: - # UP -> max(gn) - self.high = klu.high - else: - # DOWN -> min(dn) - self.low = klu.low - #self.print(klu, "Y") - return True - else: - # high小于,low小于,不包含 - return False - def set_fx(self, fx: Chan_FX_TYPE): - self.fx = fx - def cal_invisible(self): - if self.fx == Chan_FX_TYPE.TOP: - if self.macdhist < 0 and self.macd > 0: - self.klc_fx_type = Chan_KLC_FX.TOP5 - else: - if self.fx == Chan_FX_TYPE.BOTTOM: - if self.macdhist > 0 and self.macd < 0: - self.klc_fx_type = Chan_KLC_FX.BOTTOM5 - def set_pre_fx(self): - if self.pre and self.pre.pre: - self.pre.fx = self.check_fx(self.pre.pre, self.pre) - def check_fx(self, k1, k2): - if k2.high > k1.high and k2.high > self.high: - return Chan_FX_TYPE.TOP - elif k2.low < k1.low and k2.low < self.low: - return Chan_FX_TYPE.BOTTOM - else: - return Chan_FX_TYPE.UNKNOWN - def set_bi(self, bi): - self.bi = bi - self.distance = self.index - bi.start_klc.index - #print(self.start_time, self.distance, bi.index, bi.dir) \ No newline at end of file +"""兼容 shim — 请优先 from chanlun import ...""" +from chanlun.core.ChanKLC import * # noqa: F403 diff --git a/ChanKLU.py b/ChanKLU.py index 3a4855b..dd01db8 100644 --- a/ChanKLU.py +++ b/ChanKLU.py @@ -1,388 +1,2 @@ -from ChanEnum import Chan_FX_TYPE, Chan_KLU_TYPE, Chan_K_DIR, Chan_MACD_STATE, Chan_MACDHIST_STATE, Chan_PRICE_TREND, Chan_KLU_PATTERN, Chan_KLC_FX -class ChanKLU: - def __init__(self, time, open, high, low, close, volume): - # _time, _close, _open, _high, _low, _extra_info={} - self.kl_type = None - self.time = time - self.close = close - self.open = open - self.high = high - self.low = low - self.volume = volume - self.idx = 0 - self.index = 0 - self.macd = 0 - self.signal = 0 - self.macdhist = 0 - self.klc = None - self.rsi = 0 - self.volume_ratio = 0 - self.bb52upper = 0 - self.bb52lower = 0 - # === 新增:K线类型 === - self.kline_type = None # K线类型:大阳线、大阴线、小阳线、小阴线 - self.pattern = Chan_KLU_PATTERN.UNKNOWN - - # === 新增:实时分型相关属性 === - self.pre = None # 前一根K线 - self.next = None # 后一根K线 - self.fx_type = Chan_FX_TYPE.UNKNOWN # 分型类型:0=无分型,1=顶分型,-1=底分型 - self.fx_strength = 0 # 分型强度:0-100 - self.fx_confirmed = False # 分型是否确认 - self.klu_type = None - self.range = self.high - self.low - self.body = abs(self.close - self.open) - self.upper_shadow = self.high - max(self.close, self.open) - self.lower_shadow = min(self.close, self.open) - self.low - self.body_ratio = self.body / self.range if self.range != 0 else 0 - self.upper_shadow_ratio = self.upper_shadow / self.body if self.body != 0 else float('inf') - self.lower_shadow_ratio = self.lower_shadow / self.body if self.body != 0 else float('inf') - self.exception = False - #self.cal_exception() - self.candle_dir = Chan_K_DIR.CROSS if self.close == self.open else Chan_K_DIR.BULL if self.close > self.open else Chan_K_DIR.BEAR - - self.continue_div = 0 - self.separate_div = 0 - self.near0_return = 0 - self.ema52 = 0 - self.ema24 = 0 - self.ema26 = 0 - self.ema104 = 0 - self.ema156 = 0 - self.ema208 = 0 - self.macd_slop = 0 - self.signal_slop = 0 - self.hist_slop = 0 - self.hist_state = Chan_MACDHIST_STATE.UNKNOWN - self.macd_state = Chan_MACD_STATE.UNKNOWN - self.macd_hist_gap = 0 - self.trend = Chan_PRICE_TREND.UNKNOWN - self.seg_histset_index = 0 - # === 归零轴细化与模式/背离 === - self.zero_axis = False # 是否归零轴(穿越或接近) - self.zero_axis_state = "none" # {none,crossing,near} - self.zero_axis_side = 0 # 1:above, -1:under, 0:none - self.zero_axis_score = 0 # 0-100 综合评分 - self.mode1_touch_ema52 = False # 单边后触碰EMA52 - self.mode2_fast_to_zero = False # 快线向零收敛 - self.mode3_double_tf = False # 双周期归零(近似占位,由上层填充高周期确认) - self.mode3_dir = "none" # {long_strong_rebound, short_strong_rebound, none} - self.mode4_touch52_no_zero = False # 先触碰EMA52但黄白线未归零 - self.div_type = "none" # {bearish, bullish, hidden_bearish, hidden_bullish, none} - self.div_score = 0.0 # 背离强度(0-100) - self.ema_dir = 0 - self.get_ema_dir() - self.bb2633upper = 0 - self.bb2633lower = 0 - self.bb2633middle = 0 - self.ma5 = 0 - self.ema5 = 0 - #print(self.open, self.close, self.high, self.low, self.candle_dir, self.strength) - def set_macd_state(self, state): - self.macd_state = state - def set_pattern(self, pattern): - self.pattern = pattern - def set_seg_histset_index(self, seg_histset_index): - self.seg_histset_index = seg_histset_index - #print(self.time, self.seg_histset_index) - def to_string(self): - return f"{self.time} {self.candle_dir} {self.pattern}" - def cal_exception(self): - if self.upper_shadow_ratio > 5 or self.lower_shadow_ratio > 5: - self.exception = True - #print(self.time, self.upper_shadow_ratio, self.lower_shadow_ratio, self.body, self.lower_shadow, self.upper_shadow, self.high, self.low, self.close, self.open) - #self.exception = False - def set_trend(self, trend): - self.trend = trend - def set_separate_div(self, separate_div): - self.separate_div = separate_div - if self.klc and self.klc.pre and self.klc.next: - fx = self.check_fx_dir(self.klc.pre, self.klc.next) - if fx == Chan_FX_TYPE.TOP: - if self.macdhist > 0: - self.separate_div = separate_div - else: - self.separate_div = 0 - elif fx == Chan_FX_TYPE.BOTTOM: - if self.macdhist < 0: - self.separate_div = separate_div - else: - self.separate_div = 0 - def check_fx_dir(self, pre, next): - fx = Chan_FX_TYPE.UNKNOWN - if pre.klc_fx_type == Chan_KLC_FX.TOP1 or pre.klc_fx_type == Chan_KLC_FX.TOP2 or next.klc_fx_type == Chan_KLC_FX.TOP1 or next.klc_fx_type == Chan_KLC_FX.TOP2 or self.klc.klc_fx_type == Chan_KLC_FX.TOP1 or self.klc.klc_fx_type == Chan_KLC_FX.TOP2: - fx = Chan_FX_TYPE.TOP - elif pre.klc_fx_type == Chan_KLC_FX.BOTTOM1 or pre.klc_fx_type == Chan_KLC_FX.BOTTOM2 or next.klc_fx_type == Chan_KLC_FX.BOTTOM1 or next.klc_fx_type == Chan_KLC_FX.BOTTOM2 or self.klc.klc_fx_type == Chan_KLC_FX.BOTTOM1 or self.klc.klc_fx_type == Chan_KLC_FX.BOTTOM2: - fx = Chan_FX_TYPE.BOTTOM - return fx - def set_next(self, next): - self.next = next - #if self.fx_type != Chan_FX_TYPE.UNKNOWN and self.fx_strength > 1: - #print(self.index, self.time, self.fx_type, self.fx_confirmed, self.fx_strength) - def set_pre(self, pre): - self.pre = pre - def set_klc(self, klc): - self.klc = klc - def set_histset(self, histset): - """设置HistSet关联""" - self.histset = histset - - def set_seg(self, seg): - """设置Seg关联""" - self.seg = seg - - def set_unittf(self, unittf): - """设置UnitTF关联""" - self.unittf = unittf - def set_idx(self, idx): - self.idx = idx - self.index = idx - def check_price_ema156(self): - if self.check_indicators(): - if self.close > self.ema156: - return 1 - elif self.close < self.ema156: - return -1 - else: - return 0 - else: - return 0 - def get_ema_dir(self): - if self.check_indicators(): - if self.ema24 > self.ema52 and self.ema52 > self.ema104 and self.ema104 > self.ema156: - self.ema_dir = 1 - elif self.ema24 < self.ema52 and self.ema52 < self.ema104 and self.ema104 < self.ema156: - self.ema_dir = -1 - else: - self.ema_dir = 0 - def check_indicators(self): - if self.ema156 == 0: - return False - else: - return True - def set_indicators(self, item): - self.macd = float(item['macd']) if 'macd' in item and item['macd'] else 0 - self.signal = float(item['macdsignal']) if 'macdsignal' in item and item['macdsignal'] else 0 - self.macdhist = float(item['macdhist']) if 'macdhist' in item and item['macdhist'] else 0 - self.ema26 = float(item['ema26']) if 'ema26' in item and item['ema26'] else 0 - self.ema52 = float(item['ema52']) if 'ema52' in item and item['ema52'] else 0 - self.ema24 = float(item['ema24']) if 'ema24' in item and item['ema24'] else 0 - self.ema104 = float(item['ema104']) if 'ema104' in item and item['ema104'] else 0 - self.ema156 = float(item['ema156']) if 'ema156' in item and item['ema156'] else 0 - self.ema208 = float(item['ema208']) if 'ema208' in item and item['ema208'] else 0 - self.ema13 = float(item['ema13']) if 'ema13' in item and item['ema13'] else 0 - self.ema7 = float(item['ema7']) if 'ema7' in item and item['ema7'] else 0 - self.rsi = float(item['rsi']) if 'rsi' in item and item['rsi'] else 0 - self.volume_ratio = float(item['volume_ratio']) if 'volume_ratio' in item and item['volume_ratio'] else 0 - self.bb52upper = float(item['bb52upper']) if 'bb52upper' in item and item['bb52upper'] else 0 - self.bb52lower = float(item['bb52lower']) if 'bb52lower' in item and item['bb52lower'] else 0 - self.bb2633upper = float(item['bb2633upper']) if 'bb2633upper' in item and item['bb2633upper'] else 0 - self.bb2633lower = float(item['bb2633lower']) if 'bb2633lower' in item and item['bb2633lower'] else 0 - self.bb2633middle = float(item['bb2633middle']) if 'bb2633middle' in item and item['bb2633middle'] else 0 - self.ma5 = float(item['ma5']) if 'ma5' in item and item['ma5'] else 0 - self.ema5 = float(item['ema5']) if 'ema5' in item and item['ema5'] else 0 - def cal_macd_state(self): - # 按定义精简实现:优先级 CROSS0 > 位置(HIGH/HE/RETURN_ZERO) > NEAR0 > UNKNOWN - # 首条或缺前一根 - if not hasattr(self, 'pre') or self.pre is None: - self.macd_state = Chan_MACD_STATE.START - return self.macd_state - - # 基本校验 - if (self.macd == 0 and self.signal == 0 and self.macdhist == 0) or self.ema52 == 0: - self.macd_state = Chan_MACD_STATE.UNKNOWN - return self.macd_state - # 归零轴判断 - if self.signal > 0: - if self.macd < self.signal: - if 0 < self.low - self.ema52 < 100: - self.near0_return = 0 - elif self.close > self.ema52 and self.low < self.ema52 and self.open > self.ema52: - self.near0_return = 0 - elif self.close < self.ema52 and self.open > self.ema52 and self.high > self.ema52 and self.low < self.ema52: - self.near0_return = 0 - elif self.close < self.ema52 and self.open < self.ema52 and self.high > self.ema52 and self.low < self.ema52: - self.near0_return = 0 - elif self.close > self.ema52 and self.open > self.ema52 and self.high > self.ema52 and self.low < self.ema52: - self.near0_return = 0 - elif self.close > self.ema52 and self.high > self.ema52 and self.low < self.ema52: - self.near0_return = 0 - else: - if self.macd > self.signal: - if 0 < self.ema52 - self.high < 100: - self.near0_return = 0 - elif self.close < self.ema52 and self.high > self.ema52 and self.open < self.ema52: - self.near0_return = 0 - elif self.close < self.ema52 and self.open > self.ema52 and self.high > self.ema52 and self.low < self.ema52: - self.near0_return = 0 - elif self.close > self.ema52 and self.open < self.ema52 and self.high > self.ema52 and self.low < self.ema52: - self.near0_return = 0 - elif self.close > self.ema52 and self.high > self.ema52 and self.open >= self.ema52 and self.low < self.ema52: - self.near0_return = 0 - elif self.close > self.ema52 and self.high > self.ema52 and self.low < self.ema52: - self.near0_return = 0 - # 向上穿越EMA52 7 - if self.close > self.ema52 and self.open < self.ema52: - self.near0_return = 0 - # 向下穿越EMA52 8 - elif self.close < self.ema52 and self.open > self.ema52: - self.near0_return = 0 - if self.pre.near0_return == 7: - # 向上穿越后的一根价格再EMA52上方 9 - if self.low > self.ema52 and self.close > self.open: - self.near0_return = 9 - if self.pre.near0_return == 8: - # 向下穿越后的一根价格再EMA52下方 10 - if self.high < self.ema52 and self.close < self.open: - self.near0_return = 10 - # CROSS0 仅以 Signal 穿越零轴判定 - if self.pre.signal >= 0 and self.signal < 0: - self.macd_state = Chan_MACD_STATE.CROSS0_DOWN - return self.macd_state - if self.pre.signal <= 0 and self.signal > 0: - self.macd_state = Chan_MACD_STATE.CROSS0_UP - return self.macd_state - # 穿零轴后的形态:缠绕/倒挂(基于前一状态为CROSS0_*) - if self.pre.macd_state == Chan_MACD_STATE.CROSS0_UP or self.pre.macd_state == Chan_MACD_STATE.CROSS0_DOWN: - direction = 1 if self.pre.macd_state == Chan_MACD_STATE.CROSS0_UP else -1 - hist_same_dir = (self.macdhist * direction) > 0 - hist_decreasing = abs(self.macdhist) < abs(self.pre.macdhist) - lines_tight = abs(self.macd - self.signal) <= 12 - # 倒挂:能量柱衰减且黄白线相对方向不利/出现反向能量释放 - if hist_decreasing and (((self.macd - self.signal) * direction) < 0 or not hist_same_dir): - self.macd_state = Chan_MACD_STATE.CROSS_REV - return self.macd_state - # 缠绕/粘合:紧贴能量柱运行,无反向能量释放 - if lines_tight and hist_same_dir: - self.macd_state = Chan_MACD_STATE.CROSS_OS - return self.macd_state - # 趋近零轴:细化 NEAR0_* 判定 - NEAR0_EPS = 15 - lines_near_zero = abs(self.macd) <= NEAR0_EPS or abs(self.signal) <= NEAR0_EPS - touch_52 = (self.ema52 != 0) and ((abs(self.close - self.ema52) <= NEAR0_EPS) or (self.low <= self.ema52 <= self.high)) - touch_24 = (self.ema24 != 0) and ((abs(self.close - self.ema24) <= NEAR0_EPS) or (self.low <= self.ema24 <= self.high)) - # 完美形态:白线接近零轴 + 价格触碰/轻破EMA52 + 黄线不穿零轴 - if abs(self.macd) <= NEAR0_EPS and touch_52 and (not (self.pre.signal >= 0 and self.signal < 0)) and (not (self.pre.signal <= 0 and self.signal > 0)): - self.macd_state = Chan_MACD_STATE.NEAR0_PERFECT - #self.near0_return = 1 - return self.macd_state - # EMA24 附近 - if lines_near_zero and touch_24: - self.macd_state = Chan_MACD_STATE.NEAR0_24 - #self.near0_return = 2 - return self.macd_state - # EMA52 附近 - if lines_near_zero and touch_52: - self.macd_state = Chan_MACD_STATE.NEAR0_52 - #self.near0_return = 3 - return self.macd_state - # 白线接近零轴但价格未至EMA52 - if abs(self.macd) <= NEAR0_EPS and not touch_52: - self.macd_state = Chan_MACD_STATE.NEAR0_DIFF - #self.near0_return = 4 - return self.macd_state - # 一般近零轴 - if lines_near_zero or touch_52: - self.macd_state = Chan_MACD_STATE.NEAR0 - #self.near0_return = 5 - return self.macd_state - - # 穿零轴后离开零轴 - if self.pre.macd_state == Chan_MACD_STATE.CROSS0_UP and ((self.macd >= self.pre.macd and self.signal >= self.pre.signal) or (abs(self.macdhist) >= abs(self.pre.macdhist))): - self.macd_state = Chan_MACD_STATE.UP - return self.macd_state - if self.pre.macd_state == Chan_MACD_STATE.CROSS0_DOWN and ((self.macd <= self.pre.macd and self.signal <= self.pre.signal) or (abs(self.macdhist) >= abs(self.pre.macdhist))): - self.macd_state = Chan_MACD_STATE.DOWN - return self.macd_state - if self.pre.macd_state == Chan_MACD_STATE.UP and self.macd > self.pre.macd and self.signal > self.pre.signal: - self.macd_state = Chan_MACD_STATE.UP - return self.macd_state - if self.pre.macd_state == Chan_MACD_STATE.DOWN and self.macd < self.pre.macd and self.signal < self.pre.signal: - self.macd_state = Chan_MACD_STATE.DOWN - return self.macd_state - # 趋势兜底:强势同步上行/下行直接进入 UP/DOWN - if self.macd > 0 and self.signal > 0 and (self.macd >= self.pre.macd and self.signal >= self.pre.signal): - self.macd_state = Chan_MACD_STATE.UP - return self.macd_state - if self.macd < 0 and self.signal < 0 and (self.macd <= self.pre.macd and self.signal <= self.pre.signal): - self.macd_state = Chan_MACD_STATE.DOWN - return self.macd_state - # 峰值:白线高位出现局部顶 - if hasattr(self.pre, 'pre') and self.pre and self.pre.pre and self.macd > 0: - if self.pre.macd > self.pre.pre.macd and self.pre.macd > self.macd: - self.macd_state = Chan_MACD_STATE.PEAK - return self.macd_state - # 高位状态的位置状态, 高位,高位空,归零轴 - if (self.pre.macd_state == Chan_MACD_STATE.UP or self.pre.macd_state == Chan_MACD_STATE.HIGH or self.pre.macd_state == Chan_MACD_STATE.RZ_UP or self.pre.macd_state == Chan_MACD_STATE.PEAK or self.pre.macd_state == Chan_MACD_STATE.HIGH_EMPTY) and self.macd > 0: - # 高位空(正区间):能量柱衰减且黄白线间距较大 - if abs(self.pre.macdhist) > 0 and abs(self.macdhist) < abs(self.pre.macdhist) and abs(self.macd - self.signal) > 5: - self.macd_state = Chan_MACD_STATE.HIGH_EMPTY - return self.macd_state - if abs(self.pre.macd - self.macd) < 10: - self.macd_state = Chan_MACD_STATE.HIGH - return self.macd_state - else: - if self.macd > self.pre.macd and self.signal > self.pre.signal: - self.macd_state = Chan_MACD_STATE.UP - return self.macd_state - elif self.macd < self.pre.macd and self.signal < self.pre.signal: - self.macd_state = Chan_MACD_STATE.RETURN_ZERO - return self.macd_state - if (self.pre.macd_state == Chan_MACD_STATE.DOWN or self.pre.macd_state == Chan_MACD_STATE.HIGH or self.pre.macd_state == Chan_MACD_STATE.RZ_DOWN or self.pre.macd_state == Chan_MACD_STATE.PEAK or self.pre.macd_state == Chan_MACD_STATE.HIGH_EMPTY) and self.macd < 0: - # 高位空(负区间):能量柱衰减且黄白线间距较大 - if abs(self.pre.macdhist) > 0 and abs(self.macdhist) < abs(self.pre.macdhist) and abs(self.macd - self.signal) > 5: - self.macd_state = Chan_MACD_STATE.HIGH_EMPTY - return self.macd_state - if abs(self.pre.macd - self.macd) < 10: - self.macd_state = Chan_MACD_STATE.HIGH - return self.macd_state - else: - if self.macd > self.pre.macd and self.signal > self.pre.signal: - self.macd_state = Chan_MACD_STATE.RETURN_ZERO - return self.macd_state - elif self.macd < self.pre.macd and self.signal < self.pre.signal: - self.macd_state = Chan_MACD_STATE.DOWN - return self.macd_state - - # 离开0轴开始上涨或者下跌阶段,高位之前的 - if self.macd > 0 and self.pre: - if (self.pre.macd_state == Chan_MACD_STATE.NEAR0 or self.pre.macd_state == Chan_MACD_STATE.RZ_UP or self.pre.macd_state == Chan_MACD_STATE.CROSS0_UP) and (self.signal > self.pre.signal or self.close > self.ema52): - self.macd_state = Chan_MACD_STATE.RZ_UP - return self.macd_state - elif self.macd < 0 and self.pre: - if (self.pre.macd_state == Chan_MACD_STATE.NEAR0 or self.pre.macd_state == Chan_MACD_STATE.RZ_DOWN or self.pre.macd_state == Chan_MACD_STATE.CROSS0_DOWN) and (self.signal < self.pre.signal or self.close < self.ema52): - self.macd_state = Chan_MACD_STATE.RZ_DOWN - return self.macd_state - # 归零轴走势 - if self.pre.macd_state == Chan_MACD_STATE.RETURN_ZERO: - if self.macd > 0: - if self.pre.macd > self.macd or abs(self.macdhist) <= abs(self.pre.macdhist) or self.signal <= self.pre.signal: - self.macd_state = Chan_MACD_STATE.RETURN_ZERO - return self.macd_state - else: - if self.pre.macd < self.macd or abs(self.macdhist) <= abs(self.pre.macdhist) or self.signal >= self.pre.signal: - self.macd_state = Chan_MACD_STATE.RETURN_ZERO - return self.macd_state - # 从 NEAR0 收敛到零轴的归零轴承接(正负两侧) - if self.pre.macd_state == Chan_MACD_STATE.NEAR0: - # 正区间朝零轴收敛 - if self.macd > 0 and self.pre.macd > 0 and self.macd <= self.pre.macd and self.signal <= self.pre.signal: - self.macd_state = Chan_MACD_STATE.RETURN_ZERO - return self.macd_state - # 负区间朝零轴收敛 - if self.macd < 0 and self.pre.macd < 0 and self.macd >= self.pre.macd and self.signal >= self.pre.signal: - self.macd_state = Chan_MACD_STATE.RETURN_ZERO - return self.macd_state - # 其余情况 - if self.pre.macd_state == Chan_MACD_STATE.UNKNOWN: - if self.macd > 0 and self.close > self.ema52 and self.pre.pre and (self.pre.pre.macd_state == Chan_MACD_STATE.UP or self.pre.pre.macd_state == Chan_MACD_STATE.RZ_UP): - self.macd_state = self.pre.pre.macd_state - return self.macd_state - elif self.macd < 0 and self.close < self.ema52 and self.pre.pre and (self.pre.pre.macd_state == Chan_MACD_STATE.DOWN or self.pre.pre.macd_state == Chan_MACD_STATE.RZ_DOWN): - self.macd_state = self.pre.pre.macd_state - return self.macd_state - else: - self.macd_state = Chan_MACD_STATE.UNKNOWN - return self.macd_state - return self.macd_state - \ No newline at end of file +"""兼容 shim — 请优先 from chanlun import ...""" +from chanlun.core.ChanKLU import * # noqa: F403 diff --git a/ChanLun.py b/ChanLun.py index 35154b7..f36cf06 100644 --- a/ChanLun.py +++ b/ChanLun.py @@ -1,188 +1,3 @@ -import warnings - -# 抑制 Docker 内 technical.util 的 fillna/ffill/bfill 的 pandas FutureWarning(pandas 2.x 弃用 object 静默 downcast) -warnings.filterwarnings( - "ignore", - category=FutureWarning, - message=".*Downcasting object dtype arrays on \\.fillna.*", -) - -from datetime import timedelta -from pandas import DataFrame -from ChanEnum import Chan_FX_TYPE, Chan_KLINE_DIR, Chan_BI_DIR, Chan_SEG_DIR, Chan_ZS_DIR, Chan_BSP_DIR, Chan_BSP_TYPE, Chan_KLC_FX, Chan_MACD_STATE, Chan_PRICE_TREND, Chan_KLU_PATTERN -from ChanKLU import ChanKLU -from ChanKLC import ChanKLC -from ChanBI import ChanBI -from ChanSBI import ChanSBI -from ChanSEG import ChanSEG -from ChanZS import ChanZS -from ChanBSP import ChanBSP -import talib.abstract as ta -import pandas as pd -from technical.util import resample_to_interval -from decimal import Decimal -import numpy as np -from ChanMACD import ChanMACD -from TF_DF import TF_DF -from ChanZone import StructureZone, StructureZoneConfig, analyze_structure_zones - -class ChanLun(): - def __init__(self): - self.time2m = 2 - self.time3m = 3 - self.time5m = 5 - self.time10m = 10 - self.time20m = 20 - self.time_m_intervals = [2, 3, 5, 10, 20] - self.time_m_symbols = ['2m', '3m', '5m', '10m', '20m'] - self.time30m = 30 - self.time45m = 45 - self.time_m15_intervals = [30, 45] - self.time_m15_symbols = ['30m', '45m'] - self.time2h = 2*60 - self.time4h = 4*60 - self.time6h = 6*60 - self.time8h = 8*60 - self.time12h = 12*60 - self.time16h = 16*60 - self.time_h_intervals = [2*60, 4*60, 6*60, 8*60, 12*60, 16*60] - self.time_h_symbols = ['2h', '4h', '6h', '8h', '12h', '16h'] - self.time2d = 2*24*60 - self.time3d = 3*24*60 - self.time_d_intervals = [2*24*60, 3*24*60] - self.time_d_symbols = ['2d', '3d'] - self.time1w = 7*24*60 - self.time2w = 14*24*60 - self.time_w_intervals = [14*24*60] - self.time_w_symbols = ['2w'] - self.time2M = 2*30*24*60 - self.time3M = 3*30*24*60 - self.time6M = 6*30*24*60 - self.time1y = 12*30*24*60 - self.time_M_intervals = [2*30*24*60, 3*30*24*60, 6*30*24*60, 12*30*24*60] - self.time_M_symbols = ['2M', '3M', '6M', '1y'] - self.time_symbols = ['1m', '2m', '3m', '5m', '10m', '15m', '20m', '30m', '45m','1h', '2h', '4h', '6h', '8h', '12h', '16h', '1d', '2d', '3d'] - self.tf_df_dict = {} - self.ema_symbols = ['5m', '15m', '30m', '45m', '1h', '2h', '4h', '8h', '12h', '1d', '2d', '3d'] - self.tf_df = TF_DF() - def init_data(self, dataframe, intervals, timeframes): - for index in range(0, len(intervals)): - timeframe = timeframes[index] - interval = intervals[index] - self.tf_df_dict[timeframe] = TF_DF(dataframe, interval, timeframe) - def init_dataframes(self, dataframe_m=None, dataframe_15m=None, dataframe_h=None, dataframe_d=None, dataframe_w=None, dataframe_M=None): - self.tf_df_dict = {} - if dataframe_m is not None: - self.tf_df_dict['1m'] = TF_DF(dataframe_m, 1, '1m') - self.init_data(dataframe_m, self.time_m_intervals, self.time_m_symbols) - if dataframe_15m is not None: - self.tf_df_dict['15m'] = TF_DF(dataframe_15m, 1, '15m') - self.init_data(dataframe_15m, self.time_m15_intervals, self.time_m15_symbols) - if dataframe_h is not None: - self.tf_df_dict['1h'] = TF_DF(dataframe_h, 1, '1h') - self.init_data(dataframe_h, self.time_h_intervals, self.time_h_symbols) - if dataframe_d is not None: - self.tf_df_dict['1d'] = TF_DF(dataframe_d, 1, '1d') - self.init_data(dataframe_d, self.time_d_intervals, self.time_d_symbols) - if dataframe_w is not None and False: - self.tf_df_dict['1w'] = TF_DF(dataframe_w, 1, '1w') - self.init_data(dataframe_w, self.time_w_intervals, self.time_w_symbols) - if dataframe_M is not None and False: - self.tf_df_dict['1M'] = TF_DF(dataframe_M, 1, '1M') - self.init_data(dataframe_M, self.time_M_intervals, self.time_M_symbols) - def get_ema52_dict(self): - if len(self.tf_df_dict) > 0: - return {key: self.tf_df_dict[key].get_ema52() for key in self.ema_symbols} - return None - def get_ema24_dict(self): - if len(self.tf_df_dict) > 0: - return {key: self.tf_df_dict[key].get_ema24() for key in self.ema_symbols} - return None - def get_current_klc_dict(self): - if len(self.tf_df_dict) > 0: - return {key: self.tf_df_dict[key].get_current_klc() for key in self.ema_symbols} - return None - def get_tf_df_by_timeframe(self, timeframe): - if timeframe in self.tf_df_dict: - return self.tf_df_dict[timeframe] - return None - def check_price_ema52(self, price): - key_list = [] - if len(self.tf_df_dict) > 0: - ema52_dict = self.get_ema52_dict() - for key in self.ema_symbols: - if ema52_dict[key] is not None: - if abs(price - ema52_dict[key]) < 100: - key_list.append(key) - return key_list - def get_ema_bsp(self, long_tf='1h', short_tf='15m'): - if long_tf in self.tf_df_dict and short_tf in self.tf_df_dict: - long_df = self.tf_df_dict[long_tf] - short_df = self.tf_df_dict[short_tf] - return long_df.get_ema_bsp(short_df) - return None - - - - - - def get_bsp_state(self, dataframe): - return self.tf_df.get_bsp_state(dataframe) - - def get_structure_zones(self, current_price=None, config=None): - if config is None: - config = StructureZoneConfig() - return analyze_structure_zones( - self.tf_df_dict, - self.ema_symbols, - current_price=current_price, - config=config, - ) - # TF_DF methods ------------------------------------------ - def get_ema_state(self, dataframe): - return self.tf_df.get_ema_state(dataframe) - def get_klu_state(self, dataframe): - return self.tf_df.get_klu_state(dataframe) - def check_fx(self, klc): - return self.tf_df.check_fx(klc) - def add_indicators1(self, df): - return self.tf_df.add_indicators(df) - def get_bi_list(self, dataframe): - return self.tf_df.get_bi_list(dataframe) - def get_kl_data(self, dataframe:DataFrame): - return self.tf_df.cal_kl_data(dataframe) - def cal_volume_ratio(self, dataframe, window=10): - return self.tf_df.cal_volume_ratio(dataframe, window) - def calculate_seg_zs(self, bi_list, seg_list): - return self.get_seg_zs_list(bi_list, seg_list) - def get_seg_list(self, bi_list): - return self.tf_df.get_seg_list(bi_list) - def cal_trend(self, klc_list): - return self.tf_df.cal_trend(klc_list) - def check_top_fx(self, last_bottom, klc): - return self.tf_df.check_top_fx(last_bottom, klc) - def check_bottom_fx(self, last_top, klc): - return self.tf_df.check_bottom_fx(last_top, klc) - def cal_bi_list(self, klc_list): - return self.tf_df.cal_bi_list(klc_list) - def find_first_bsp(self, bi_list, bi_zs_list): - return self.tf_df.find_first_bsp(bi_list, bi_zs_list) - def find_second_bsp(self, bi_list, first_bsp_list): - return self.tf_df.find_second_bsp(bi_list, first_bsp_list) - def find_all_bsp(self, bi_list, bi_zs_list): - return self.tf_df.find_all_bsp(bi_list, bi_zs_list) - def get_zs_list(self, bi_list, seg_list): - return self.tf_df.get_zs_list(bi_list, seg_list) - def cal_bi_zs(self, seg_list): - return self.tf_df.cal_bi_zs(seg_list) - def cal_bi_zs_list(self, bi_list): - #return self.tf_df.cal_bi_zs(bi_list) - return self.tf_df.cal_bi_zs_list(bi_list) - def get_bi_zs_list(self, bi_list): - return self.tf_df.get_bi_zs_list(bi_list) - def get_decimal(self, value): - return Decimal("{:.2f}".format(value)) - def get_klc_list(self, klu_list): - return self.tf_df.get_klc_list(klu_list) - def get_klu_list(self, dataframe): - return self.tf_df.cal_klu_pattern(self.get_kl_data(dataframe)) \ No newline at end of file +"""兼容 shim — 请优先 from chanlun import ...""" +from chanlun.pipeline.orchestrator import ChanLun # noqa: F401 +from chanlun.pipeline.timeframe import TF_DF # noqa: F401 diff --git a/ChanLun_Classifier.py b/ChanLun_Classifier.py index 3933036..6c3e45e 100644 --- a/ChanLun_Classifier.py +++ b/ChanLun_Classifier.py @@ -1,529 +1,2 @@ -import sys -import os -#sys.setrecursionlimit(1000000) #例如这里设置为一百万 -#sys.path.append(os.path.abspath("/freqtrade/user_data/Chan")) -sys.path.append(os.path.abspath("/Users/jack/Project/freqtrade/user_data/Chan")) -import numpy as np -from datetime import timedelta -from pandas import DataFrame -from ChanEnum import Chan_FX_TYPE, Chan_KLINE_DIR, Chan_BI_DIR, Chan_SEG_DIR, Chan_ZS_DIR, Chan_BSP_DIR, Chan_BSP_TYPE, Chan_KLC_FX -from ChanKLU import ChanKLU -from ChanKLC import ChanKLC -from ChanBI import ChanBI -from ChanSBI import ChanSBI -from ChanSEG import ChanSEG -from ChanZS import ChanZS -from ChanBSP import ChanBSP -import talib.abstract as ta -import pandas as pd -import matplotlib.pyplot as plt -from matplotlib.dates import DateFormatter, date2num -import matplotlib.patches as patches -from technical.util import resample_to_interval -from decimal import Decimal -from ChanLun import ChanLun -import xgboost as xgb -from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, classification_report - -class ChanLunClassifier: - def __init__(self, dataframe: DataFrame): - self.dataframe = dataframe - self.model = None - chan = ChanLun() - - def train_model(self, dataframe=None, data_file_path=None, model_file_path='chan_xgb_model.json', use_cv=False, custom_params=None, model_name=None): - """ - 使用dataframe前80%的数据训练XGBoost模型 - :param dataframe: 输入的DataFrame,如果为None则使用初始化时的dataframe - :param data_file_path: 特征数据保存路径,可选 - :param model_file_path: 模型保存路径 - :param use_cv: 是否使用交叉验证寻找最佳参数 - :param custom_params: 自定义模型参数 - :return: 训练好的模型 - """ - if dataframe is None: - dataframe = self.dataframe - - # 分割数据集,前80%用于训练 - train_size = int(len(dataframe) * 0.8) - train_df = dataframe.iloc[:train_size].copy() - - # 获取训练集特征和标签 - save_csv = True if data_file_path else False - X_train, y_train = self.get_feature_data(train_df, save_csv=save_csv, csv_path=data_file_path if data_file_path else 'feature_data.csv') - - if len(X_train) == 0: - print("没有提取到足够的特征数据进行训练") - return None - - # 保存特征数据的步骤已经移到get_feature_data方法中处理 - # 以下是原有代码 - #{'eta': 0.03, 'max_depth': 4, 'subsample': 0.8, 'colsample_bytree': 0.8, 'gamma': 0.1, 'min_child_weight': 3, 'alpha': 1, 'lambda': 3}, - # 默认XGBoost参数 - default_params = { - 'objective': 'binary:logistic', - 'max_depth': 8, - 'eta': 0.01, - 'subsample': 0.8, - 'colsample_bytree': 0.8, - 'eval_metric': 'auc', - 'gamma': 0.0, - 'min_child_weight': 1, - 'alpha': 0, # L1正则化 - 'lambda': 0.5, # L2正则化 - 'scale_pos_weight': 1 - } - - # 使用自定义参数覆盖默认参数 - if custom_params: - for key, value in custom_params.items(): - default_params[key] = value - - params = default_params - dtrain = xgb.DMatrix(X_train, label=y_train) - - # 如果使用交叉验证寻找最佳参数 - if use_cv: - from sklearn.model_selection import GridSearchCV, RandomizedSearchCV - from sklearn.metrics import make_scorer, accuracy_score, f1_score - import numpy as np - - # 转换为sklearn兼容格式 - xgb_model = xgb.XGBClassifier( - objective=params['objective'], - max_depth=params['max_depth'], - learning_rate=params['eta'], - subsample=params['subsample'], - colsample_bytree=params['colsample_bytree'], - gamma=params['gamma'], - min_child_weight=params['min_child_weight'], - reg_alpha=params['alpha'], - reg_lambda=params['lambda'], - scale_pos_weight=params['scale_pos_weight'], - use_label_encoder=False, - eval_metric='auc' - ) - - # 参数网格 - param_grid = { - 'max_depth': [3, 5, 7, 9], - 'learning_rate': [0.01, 0.05, 0.1, 0.2], - 'subsample': [0.6, 0.8, 1.0], - 'colsample_bytree': [0.6, 0.8, 1.0], - 'min_child_weight': [1, 3, 5], - 'gamma': [0, 0.1, 0.2], - 'n_estimators': [50, 100, 200] - } - - # 使用随机搜索寻找最佳参数(比网格搜索快) - random_search = RandomizedSearchCV( - estimator=xgb_model, - param_distributions=param_grid, - n_iter=10, # 随机尝试的参数组合数 - scoring=make_scorer(f1_score), - cv=5, - verbose=1, - n_jobs=-1, - random_state=42 - ) - - print("进行交叉验证参数搜索...") - random_search.fit(X_train, y_train) - - # 获取最佳参数 - best_params = random_search.best_params_ - print(f"最佳参数: {best_params}") - - # 使用最佳参数更新模型参数 - params['max_depth'] = best_params['max_depth'] - params['eta'] = best_params['learning_rate'] - params['subsample'] = best_params['subsample'] - params['colsample_bytree'] = best_params['colsample_bytree'] - params['min_child_weight'] = best_params['min_child_weight'] - params['gamma'] = best_params['gamma'] - num_round = best_params['n_estimators'] - - # 使用最佳参数训练最终模型 - self.model = xgb.train(params, dtrain, num_round) - else: - # 标准训练(不使用交叉验证) - # 使用早停机制避免过拟合 - # 分割训练集为训练和验证 - eval_size = int(len(X_train) * 0.2) - X_eval = X_train[-eval_size:] - y_eval = y_train[-eval_size:] - X_train_part = X_train[:-eval_size] - y_train_part = y_train[:-eval_size] - - dtrain_part = xgb.DMatrix(X_train_part, label=y_train_part) - deval = xgb.DMatrix(X_eval, label=y_eval) - - # 评估列表 - evallist = [(dtrain_part, 'train'), (deval, 'eval')] - - # 训练模型,使用早停 - num_round = 1000 # 设置较大的轮数,让早停机制决定何时停止 - self.model = xgb.train( - params, - dtrain_part, - num_round, - evallist, - early_stopping_rounds=50, # 50轮内评估指标无改善则停止 - verbose_eval=True - ) - - # 使用全部训练数据重新训练最终模型,使用最佳轮数 - # best_rounds = self.model.best_ntree_limit - # 兼容新版本的XGBoost - if hasattr(self.model, 'best_ntree_limit'): - best_rounds = self.model.best_ntree_limit - elif hasattr(self.model, 'best_iteration'): - best_rounds = self.model.best_iteration - elif hasattr(self.model, 'best_ntree_idx'): - best_rounds = self.model.best_ntree_idx - else: - # 如果都不存在,使用默认值 - best_rounds = num_round - print(f"最佳轮数: {best_rounds}") - - # 使用全部训练数据和最佳轮数训练最终模型 - self.model = xgb.train(params, dtrain, best_rounds) - - # 保存模型 - if model_file_path: - self.model.save_model(model_name + model_file_path) - - # 特征重要性分析 - if hasattr(self.model, 'get_score'): - importance = self.model.get_score(importance_type='gain') - print("\n特征重要性 (gain):") - for key, value in sorted(importance.items(), key=lambda x: x[1], reverse=True): - print(f"{key}: {value}") - - return self.model - def load_model(self, model_name=None, model_file_path='chan_xgb_model.json'): - if model_name: - self.model = xgb.Booster() - self.model.load_model(model_name + model_file_path) - else: - self.model = xgb.Booster() - self.model.load_model(model_file_path) - def find_best_params(self, dataframe=None, save_csv=False, csv_path_prefix='param_', model_name=None): - """ - 寻找最佳参数组合 - :param dataframe: 输入的DataFrame,如果为None则使用初始化时的dataframe - :param save_csv: 是否保存特征数据到CSV文件 - :param csv_path_prefix: CSV文件保存路径前缀,会自动添加参数信息 - :return: 最佳参数 - """ - # 不同参数组合 - param_combinations = [ - # 低学习率,深树 - {'eta': 0.01, 'max_depth': 8, 'subsample': 0.8, 'colsample_bytree': 0.8, 'gamma': 0, 'min_child_weight': 1}, - # 中等学习率,中等树深度 - {'eta': 0.05, 'max_depth': 5, 'subsample': 0.7, 'colsample_bytree': 0.7, 'gamma': 0.1, 'min_child_weight': 3}, - # 高学习率,浅树 - {'eta': 0.1, 'max_depth': 3, 'subsample': 0.6, 'colsample_bytree': 0.6, 'gamma': 0.2, 'min_child_weight': 5}, - # 正则化较强 best here - {'eta': 0.03, 'max_depth': 4, 'subsample': 0.8, 'colsample_bytree': 0.8, 'gamma': 0.1, 'min_child_weight': 3, 'alpha': 1, 'lambda': 3}, - # 正则化较弱 - {'eta': 0.08, 'max_depth': 6, 'subsample': 0.9, 'colsample_bytree': 0.9, 'gamma': 0, 'min_child_weight': 1, 'alpha': 0, 'lambda': 0.5}, - ] - - best_score = 0 - best_params = None - best_model = None - - for i, params in enumerate(param_combinations): - print(f"\n尝试参数组合: {params}") - # 生成CSV文件名,包含一些参数信息 - param_info = f"eta{params['eta']}_depth{params['max_depth']}" - train_csv_path = f"{csv_path_prefix}train_{param_info}.csv" if save_csv else None - - model = self.train_model(dataframe=dataframe, data_file_path=train_csv_path, custom_params=params, model_name=model_name) - - # 分割数据集,后20%用于测试 - if dataframe is None: - dataframe = self.dataframe - - train_size = int(len(dataframe) * 0.8) - test_df = dataframe.iloc[train_size:].copy() - - # 获取测试集特征和标签 - test_csv_path = f"{csv_path_prefix}test_{param_info}.csv" if save_csv else None - X_test, y_test = self.get_validate_feature_data(test_df, save_csv=save_csv, csv_path=test_csv_path) - - if len(X_test) == 0: - print("没有提取到足够的测试特征数据") - continue - - # 预测 - dtest = xgb.DMatrix(X_test) - y_pred_prob = model.predict(dtest) - y_pred = [1 if p > 0.5 else 0 for p in y_pred_prob] - - # 计算F1分数 - f1 = f1_score(y_test, y_pred, zero_division=0) - print(f"F1分数: {f1:.4f}") - - if f1 > best_score: - best_score = f1 - best_params = params - best_model = model - - print(f"\n最佳参数组合 (F1={best_score:.4f}):") - print(best_params) - self.model = best_model - - return best_params - - def get_feature_data(self, dataframe, save_csv=False, csv_path='feature_data.csv'): - """ - 从dataframe提取特征数据 - :param dataframe: 输入的DataFrame - :param save_csv: 是否保存特征数据到CSV文件 - :param csv_path: CSV文件保存路径 - :return: 特征矩阵X和标签y - """ - # 使用ChanLun获取bi_list - klc_list = self.chan.get_klc_list(dataframe) - bi_list = self.chan.cal_bi_list(klc_list) - # 筛选方向为UP的bi的起始klc - feature_data = [] - labels = [] - feature_keys = [] # 用于保存特征名称 - - bi_index = 1 - sample_list = [] - for klc in klc_list: - if klc.klc_fx_type != Chan_KLC_FX.UNKNOWN: - sample_list.append(klc) - klc_count = 0 - print('Processing data...') - for klc in sample_list: - if bi_index >= len(bi_list): - bi_index = len(bi_list) - 1 - #bi = bi_list[bi_index] - #if klc.end_klu and bi.end_klc and klc.start_klu.index >= bi.start_klc.start_klu.index and klc.end_klu.index <= bi.end_klc.end_klu.index: - #klc.set_bi(bi) - - # 提取特征 - features = klc.get_feature_data() - - # 保存第一个样本的特征名称,用于CSV列名 - if len(feature_keys) == 0: - feature_keys = list(features.keys()) - # 将特征转换为模型可用的格式 - feature_vec = [] - for key, value in features.items(): - if isinstance(value, (int, float)): - feature_vec.append(value) - else: - feature_vec.append(0) - - # 判断这个bi是否赚钱(这里简单定义为:如果bi的结束价格高于起始价格,则标记为1,否则为0) - # 这个标签定义可以根据实际需求修改 - matched = False - for bi in bi_list: - if bi.end_klc and bi.end_klc.index == klc.index: - #print(bi.start_time, bi.start_klc.start_time, bi.dir) - label = 1 - matched = True - break - if not matched: - label = 0 - - feature_data.append(feature_vec) - labels.append(label) - klc_count += 1 - percent = klc_count/len(sample_list)*100 - if percent % 10 == 0: - print('Data processed:', percent, '%') - for index, key in enumerate(feature_keys): - print(index, key, feature_data[0][index]) - # 如果需要保存到CSV - if save_csv: - # 创建DataFrame保存特征数据 - # 只保留数值型特征 - numeric_feature_keys = [key for i, key in enumerate(feature_keys) - if i < len(feature_data[0]) if isinstance(feature_data[0][i], (int, float))] - - # 创建特征数据的DataFrame - df_features = pd.DataFrame(feature_data, columns=numeric_feature_keys) - # 添加标签列 - df_features['label'] = labels - # 添加时间信息便于分析 - if len(sample_list) > 0: - times = [klc.start_time for klc in sample_list] - df_features['time'] = times - - # 保存到CSV - df_features.to_csv(csv_path, index=False) - print(f"特征数据已保存到 {csv_path}") - - # 在return前添加 - positive_count = np.sum(labels) - print(f"正样本数量: {positive_count}, 负样本数量: {len(labels) - positive_count}") - print("Trainning data: ", len(feature_data), klc_list[-1].start_time, klc_list[-1].klc_fx_type , "---------------------") - return np.array(feature_data), np.array(labels) - def get_validate_feature_data(self, dataframe, save_csv=False, csv_path='validate_feature_data.csv'): - """ - 从dataframe提取特征数据 - :param dataframe: 输入的DataFrame - :param save_csv: 是否保存特征数据到CSV文件 - :param csv_path: CSV文件保存路径 - :return: 特征矩阵X和标签y - """ - # 使用ChanLun获取bi_list - klc_list = self.chan.get_klc_list(dataframe) - bi_list = self.chan.cal_bi_list(klc_list) - seg_list = self.chan.get_seg_list(bi_list) - # 筛选方向为UP的bi的起始klc - feature_data = [] - labels = [] - feature_keys = [] # 用于保存特征名称 - - bi_index = 1 - sample_list = [] - for klc in klc_list: - if klc.klc_fx_type != Chan_KLC_FX.UNKNOWN: - sample_list.append(klc) - for klc in sample_list: - if bi_index >= len(bi_list): - bi_index = len(bi_list) - 1 - bi = bi_list[bi_index] - # 提取特征 - features = klc.get_feature_data() - - # 保存第一个样本的特征名称,用于CSV列名 - if len(feature_keys) == 0: - feature_keys = list(features.keys()) - - # 将特征转换为模型可用的格式 - feature_vec = [] - # 与get_feature_data保持一致,只使用相同的特征集 - for key, value in features.items(): - if isinstance(value, (int, float)): - feature_vec.append(value) - else: - feature_vec.append(0) - seg = seg_list[bi_index] - matched = False - for bi in bi_list: - if bi.end_klc and bi.end_klc.index == klc.index: - label = 1 - matched = True - break - if not matched: - label = 0 - - feature_data.append(feature_vec) - labels.append(label) - - # 如果需要保存到CSV - if save_csv: - # 创建DataFrame保存特征数据 - # 只保留数值型特征 - numeric_feature_keys = [key for i, key in enumerate(feature_keys) - if i < len(feature_data[0]) if isinstance(feature_data[0][i], (int, float))] - - # 创建特征数据的DataFrame - df_features = pd.DataFrame(feature_data, columns=numeric_feature_keys) - # 添加标签列 - df_features['label'] = labels - # 添加时间信息便于分析 - if len(sample_list) > 0: - times = [klc.start_time for klc in sample_list] - df_features['time'] = times - - # 保存到CSV - df_features.to_csv(csv_path, index=False) - print(f"验证特征数据已保存到 {csv_path}") - - print("Validating data: ", len(feature_data), klc_list[-1].start_time, klc_list[-1].klc_fx_type , "---------------------") - return np.array(feature_data), np.array(labels) - def validate_model(self, dataframe=None, save_csv=False, csv_path='validate_feature_data.csv'): - """ - 使用dataframe后20%的数据验证模型 - :param dataframe: 输入的DataFrame,如果为None则使用初始化时的dataframe - :param save_csv: 是否保存特征数据到CSV文件 - :param csv_path: CSV文件保存路径 - :return: 验证结果 - """ - if self.model is None: - print("模型尚未训练,请先调用train_model方法") - return None - - if dataframe is None: - dataframe = self.dataframe - - # 分割数据集,后20%用于测试 - train_size = int(len(dataframe) * 0.8) - test_df = dataframe.iloc[train_size:].copy() - - # 获取测试集特征和标签 - X_test, y_test = self.get_validate_feature_data(test_df, save_csv=save_csv, csv_path=csv_path) - - if len(X_test) == 0: - print("没有提取到足够的测试特征数据") - return None - - # 预测 - dtest = xgb.DMatrix(X_test) - y_pred_prob = self.model.predict(dtest) - y_pred = [1 if p > 0.5 else 0 for p in y_pred_prob] - - # 计算评估指标 - accuracy = accuracy_score(y_test, y_pred) - precision = precision_score(y_test, y_pred, zero_division=0) - recall = recall_score(y_test, y_pred, zero_division=0) - f1 = f1_score(y_test, y_pred, zero_division=0) - - # 打印评估报告 - print("模型评估结果:") - print(f"准确率: {accuracy:.4f}") - print(f"精确率: {precision:.4f}") - print(f"召回率: {recall:.4f}") - print(f"F1分数: {f1:.4f}") - print("\n分类报告:") - print(classification_report(y_test, y_pred, zero_division=0)) - - return { - 'accuracy': accuracy, - 'precision': precision, - 'recall': recall, - 'f1': f1, - 'y_test': y_test, - 'y_pred': y_pred, - 'y_pred_prob': y_pred_prob - } - - def predict(self, klc): - """ - 使用训练好的模型预测单个KLC - :param klc: 需要预测的ChanKLC对象 - :return: 预测结果(概率值) - """ - if self.model is None: - print("模型尚未训练,请先调用train_model方法") - return None - - # 提取特征 - features = klc.get_feature_data() - feature_vec = [] - # 与get_feature_data保持一致,只使用相同的特征集 - for key, value in features.items(): - if isinstance(value, (int, float)): - feature_vec.append(value) - else: - feature_vec.append(0) - - # 转换为模型输入格式 - dtest = xgb.DMatrix(np.array([feature_vec])) - - # 预测 - return self.get_decimal(self.model.predict(dtest)[0]) - - def get_decimal(self, value): - return Decimal("{:.4f}".format(value)) - +"""兼容 shim — 请优先 from chanlun import ...""" +from chanlun.analysis.ChanLun_Classifier import * # noqa: F403 diff --git a/ChanMACD.py b/ChanMACD.py index 86d5d3c..d75e47e 100644 --- a/ChanMACD.py +++ b/ChanMACD.py @@ -1,274 +1,2 @@ -from ChanKLU import ChanKLU -from ChanEnum import Chan_MACD_STATE, Chan_MACDSEG_DIR, Chan_MACDHISTSET_DIR, Chan_MACDUNITTF_DIR, Chan_MACDUNITTF_TYPE -from ChanMACDSeg import ChanMACDSeg -from ChanMACDUnitTF import ChanMACDUnitTF -from ChanMACDHistSet import ChanMACDHistSet - -class ChanMACD(): - def __init__(self, klu_list: list[ChanKLU]): - self.klu_list = klu_list - self.seg_list = [] - self.unittf_list = [] - self.histset_list = [] - # 状态标记列表 - self.high_position_list = [] # 高位列表 - self.high_empty_list = [] # 高位空列表 - self.return_zero_list = [] # 归零轴列表 - self.cross0_up_list = [] # 向上穿越零轴列表 - self.cross0_down_list = [] # 向下穿越零轴列表 - # 计算段 / UnitTF / HistSet 及状态标记 - self.cal_macd_state() - self.get_klu_sd_list() - def get_klu_sd(self): - if self.klu_list: - sd = self.klu_list[-1].separate_div - if sd > 1: - print(self.klu_list[-1].time, sd) - return True - return False - def get_klu_sd_list(self): - sd_list = [] - if self.klu_list: - for klu in self.klu_list: - hist = klu.macdhist - signal = False - if klu.pre and klu.next: - if klu.signal > 0: - signal = klu.pre.signal > klu.signal and klu.next.signal < klu.signal - else: - signal = klu.pre.signal < klu.signal and klu.next.signal > klu.signal - sd = klu.separate_div - if sd > 1 and ((hist > 0 and hist < 200) or (hist < 0 and hist > -200)): - sd_list.append(klu.time) - #print(klu.time, sd) - return sd_list - def cal_macd_state(self): - last_seg = None - last_unittf = None - last_histset = None - last_klu = None - for klu in self.klu_list: - # initialise first histset - if klu.macd == 0 and klu.signal == 0 and klu.macdhist == 0: - continue - if last_histset is None: - if klu.macdhist > 0: - last_histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, None, Chan_MACDHISTSET_DIR.ABOVE) - self.histset_list.append(last_histset) - else: - last_histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, None, Chan_MACDHISTSET_DIR.UNDER) - self.histset_list.append(last_histset) - else: - # initialise first seg and unittf - if last_seg is None: - # create histset afterwards - if last_histset.histset_dir == Chan_MACDHISTSET_DIR.ABOVE: - if klu.macdhist > 0: - last_histset.add_klu(klu) - else: - histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.UNDER) - self.histset_list.append(histset) - last_histset.set_next(histset) - histset.set_pre(last_histset) - last_histset.set_end_klu(last_klu) - last_histset = histset - else: - if klu.macdhist < 0: - last_histset.add_klu(klu) - else: - histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.ABOVE) - self.histset_list.append(histset) - last_histset.set_next(histset) - histset.set_pre(last_histset) - last_histset.set_end_klu(last_klu) - last_histset = histset - if last_klu.signal >= 0 and klu.signal < 0: - last_unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, None, Chan_MACDUNITTF_DIR.UNDER, Chan_MACDUNITTF_TYPE.CROSS0, last_histset) - self.unittf_list.append(last_unittf) - last_seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, None, Chan_MACDSEG_DIR.UNDER, last_unittf) - self.seg_list.append(last_seg) - elif last_klu.signal <= 0 and klu.signal > 0: - last_unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, None, Chan_MACDUNITTF_DIR.ABOVE, Chan_MACDUNITTF_TYPE.CROSS0, last_histset) - self.unittf_list.append(last_unittf) - last_seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, None, Chan_MACDSEG_DIR.ABOVE, last_unittf) - self.seg_list.append(last_seg) - # after the first seg and unittf - else: - # create histset afterwards - if last_histset.histset_dir == Chan_MACDHISTSET_DIR.ABOVE: - if klu.macdhist > 0: - last_histset.add_klu(klu) - else: - histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.UNDER) - self.histset_list.append(histset) - last_histset.set_next(histset) - histset.set_pre(last_histset) - last_histset.set_end_klu(last_klu) - last_histset = histset - if last_unittf: - last_unittf.add_histset(last_histset) - else: - if klu.macdhist < 0: - last_histset.add_klu(klu) - else: - histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.ABOVE) - self.histset_list.append(histset) - last_histset.set_next(histset) - histset.set_pre(last_histset) - last_histset.set_end_klu(last_klu) - last_histset = histset - if last_unittf: - last_unittf.add_histset(last_histset) - if last_klu.signal >= 0 and klu.signal < 0: - last_unittf.set_end_klu(last_klu, Chan_MACDUNITTF_TYPE.CROSS0) - unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, Chan_MACDUNITTF_DIR.UNDER, Chan_MACDUNITTF_TYPE.CROSS0, last_histset) - self.unittf_list.append(unittf) - last_unittf.set_next(unittf) - last_seg.set_end_klu(last_klu) - seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, last_seg, Chan_MACDSEG_DIR.UNDER, unittf) - self.seg_list.append(seg) - last_seg.set_next(seg) - last_seg = seg - last_unittf = unittf - elif last_klu.signal <= 0 and klu.signal > 0: - last_unittf.set_end_klu(last_klu, Chan_MACDUNITTF_TYPE.CROSS0) - unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, Chan_MACDUNITTF_DIR.ABOVE, Chan_MACDUNITTF_TYPE.CROSS0, last_histset) - self.unittf_list.append(unittf) - last_unittf.set_next(unittf) - last_seg.set_end_klu(last_klu) - seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, last_seg, Chan_MACDSEG_DIR.ABOVE, unittf) - self.seg_list.append(seg) - last_seg.set_next(seg) - last_seg = seg - last_unittf = unittf - elif last_unittf.is_end and last_klu.macd < klu.macd and klu.macd > klu.signal: - unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, Chan_MACDUNITTF_DIR.ABOVE, Chan_MACDUNITTF_TYPE.NEAR0, last_histset) - self.unittf_list.append(unittf) - last_unittf.set_next(unittf) - last_seg.add_unittf(unittf) - last_unittf = unittf - last_seg.add_klu(klu) - else: - if not last_unittf.is_end: - last_unittf.add_klu(klu) - last_seg.add_klu(klu) - last_klu = klu - klu.cal_macd_state() - #print(klu.time, klu.macd_state, klu.continue_div, klu.separate_div, klu.macd, klu.signal, klu.macdhist, klu.ema24, klu.ema52, klu.close) - return self.klu_list - def cal_macd(self): - last_seg = None - last_unittf = None - last_histset = None - histset = None - last_klu = None - for klu in self.klu_list: - klu.cal_macd_state() - print(klu.time, klu.macd_state) - # 1) 只有当 MACD 已可用(非 UNKNOWN)时,才开始初始化段/单元 - if last_seg is None: - if klu.macd_state != Chan_MACD_STATE.UNKNOWN: - # 初始化首个直方图集合(根据当前柱体正负) - if klu.macdhist >= 0: - histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, None, Chan_MACDHISTSET_DIR.ABOVE) - else: - histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, None, Chan_MACDHISTSET_DIR.UNDER) - self.histset_list.append(histset) - last_histset = histset - - # 初始化首段 - seg_dir = Chan_MACDSEG_DIR.ABOVE if klu.signal >= 0 else Chan_MACDSEG_DIR.UNDER - seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, None, seg_dir, last_unittf) - self.seg_list.append(seg) - last_seg = seg - - # 初始化首个UnitTF - unittf_dir = Chan_MACDUNITTF_DIR.ABOVE if klu.signal >= 0 else Chan_MACDUNITTF_DIR.UNDER - unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, None, unittf_dir, Chan_MACDUNITTF_TYPE.START, histset) - self.unittf_list.append(unittf) - last_unittf = unittf - last_seg.add_unittf(unittf) - # 未就绪则继续等下一根;已就绪亦已完成首个结构初始化,继续下一根 - last_klu = klu - continue - # 3) 直方图集合(基于当前 unittf) - if klu.macdhist >= 0: - if last_histset and last_histset.histset_dir == Chan_MACDHISTSET_DIR.ABOVE: - last_histset.add_klu(klu) - else: - # 结束旧 histset(以前一根结束更合理) - if last_histset and last_klu: - last_histset.set_end_klu(last_klu) - histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.ABOVE if klu.macdhist >= 0 else Chan_MACDHISTSET_DIR.UNDER) - self.histset_list.append(histset) - if last_histset: - last_histset.set_next(histset) - last_histset = histset - if last_unittf: - last_unittf.add_histset(histset) - else: - if last_histset and last_histset.histset_dir == Chan_MACDHISTSET_DIR.UNDER: - last_histset.add_klu(klu) - else: - # 结束旧 histset(以前一根结束更合理) - if last_histset and last_klu: - last_histset.set_end_klu(last_klu) - histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.ABOVE if klu.macdhist >= 0 else Chan_MACDHISTSET_DIR.UNDER) - self.histset_list.append(histset) - if last_histset: - last_histset.set_next(histset) - last_histset = histset - if last_unittf: - last_unittf.add_histset(histset) - # 2) 过零切段(使用KLU中的穿越状态) - if (klu.macd_state == Chan_MACD_STATE.CROSS0_UP or - klu.macd_state == Chan_MACD_STATE.CROSS0_DOWN): - # 结束旧 unittf - last_unittf.set_end_klu(last_klu, Chan_MACDUNITTF_TYPE.CROSS0) - # 新的单位时间周期 - new_dir = Chan_MACDUNITTF_DIR.ABOVE if klu.signal >= 0 else Chan_MACDUNITTF_DIR.UNDER - unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, new_dir, Chan_MACDUNITTF_TYPE.CROSS0, histset) - self.unittf_list.append(unittf) - last_unittf.set_next(unittf) - last_unittf = unittf - # 收尾旧段 - last_seg.set_end_klu(last_klu) - # 新段方向取反 - new_dir = Chan_MACDSEG_DIR.UNDER if last_seg.seg_dir == Chan_MACDSEG_DIR.ABOVE else Chan_MACDSEG_DIR.ABOVE - seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, last_seg, new_dir, last_unittf) - self.seg_list.append(seg) - last_seg.set_next(seg) - last_seg = seg - last_seg.add_unittf(unittf) - else: - # 4) UnitTF 状态机:用黄线Signal的归零轴 - if last_klu.macd_state == Chan_MACD_STATE.NEAR0 and last_unittf.div_count > 1: - #print(klu.time, klu.macd_state) - if klu.macd_state == Chan_MACD_STATE.RZ_UP: - last_unittf.set_end_klu(last_klu, Chan_MACDUNITTF_TYPE.NEAR0) - new_dir = Chan_MACDUNITTF_DIR.ABOVE if klu.signal >= 0 else Chan_MACDUNITTF_DIR.UNDER - unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, new_dir, Chan_MACDUNITTF_TYPE.NEAR0, histset) - self.unittf_list.append(unittf) - last_unittf.set_next(unittf) - last_unittf = unittf - last_seg.add_unittf(unittf) - elif klu.macd_state == Chan_MACD_STATE.RZ_DOWN: - last_unittf.set_end_klu(last_klu, Chan_MACDUNITTF_TYPE.NEAR0) - new_dir = Chan_MACDUNITTF_DIR.ABOVE if klu.signal >= 0 else Chan_MACDUNITTF_DIR.UNDER - unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, new_dir, Chan_MACDUNITTF_TYPE.NEAR0, histset) - self.unittf_list.append(unittf) - last_unittf.set_next(unittf) - last_unittf = unittf - last_seg.add_unittf(unittf) - else: - last_unittf.add_klu(klu) - last_seg.add_klu(klu) - else: - last_unittf.add_klu(klu) - last_seg.add_klu(klu) - - last_klu = klu - last_histset.set_end_klu(last_klu) - last_unittf.set_end_klu(last_klu, None) - last_seg.set_end_klu(last_klu) - return self.klu_list \ No newline at end of file +"""兼容 shim — 请优先 from chanlun import ...""" +from chanlun.indicators.ChanMACD import * # noqa: F403 diff --git a/ChanMACDHistSet.py b/ChanMACDHistSet.py index 484fc94..6278d01 100644 --- a/ChanMACDHistSet.py +++ b/ChanMACDHistSet.py @@ -1,117 +1,2 @@ -from ChanEnum import Chan_MACDHISTSET_DIR, Chan_MACDUNITTF_DIV, Chan_MACD_STATE - -class ChanMACDHistSet(): - def __init__(self, index, start_time, start_klu, pre_histset, dir): - self.index = index - self.start_time = start_time - self.end_time = None - self.klu_list = [] - self.klu_list.append(start_klu) - self.histset_dir = dir - self.next = None - self.pre = pre_histset - self.peak_klu = None - self.area = start_klu.macdhist - self.unittf_div = Chan_MACDUNITTF_DIV.UNDIV - self.middle_klu = None - self.div_count = 0 - self.last_klu = start_klu - self.start_klu = start_klu - self.peak_div_list = [] - self.middle_area = 0 - self.total_macdhist = 0 - def set_next(self, next_histset): - self.next = next_histset - def set_pre(self, pre_histset): - self.pre = pre_histset - def set_middle_klu(self, middle_klu): - self.middle_klu = middle_klu - #self.middle_area = abs(middle_klu.macdhist) - #self.middle_klu = None - def set_unittf_div(self, unittf_div): - self.unittf_div = unittf_div - def add_klu(self, klu): - klu.set_histset(self) - self.klu_list.append(klu) - self.area += abs(klu.macdhist) - if self.middle_klu: - self.middle_area += abs(klu.macdhist) - if self.middle_klu and self.middle_klu.index + 1 == klu.index: - self.low_klu = None - self.peak_klu = None - self.div_count = 0 - self.peak_div_list = [] - else: - if self.last_klu: - self.cal_macdhist_klu(klu) - self.last_klu = klu - def cal_macdhist_klu(self, klu): - if self.middle_klu: - if klu.index >= self.middle_klu.index + 2: - if klu.pre.pre: - if abs(klu.pre.macdhist) > abs(klu.pre.pre.macdhist) and abs(klu.pre.macdhist) > abs(klu.macdhist): - if self.peak_klu: - if abs(klu.pre.macdhist) > abs(self.peak_klu.macdhist): - self.peak_klu = klu.pre - #self.div_count = 0 - #self.peak_div_list = [] - else: - if klu.pre.macd * klu.pre.macdhist > 0: - self.peak_div_list.append(klu.pre) - self.div_count += 1 - klu.pre.continue_div = True - else: - self.peak_klu = klu.pre - else: - if len(self.klu_list) >= 3: - if klu.pre.pre: - if abs(klu.pre.macdhist) > abs(klu.pre.pre.macdhist) and abs(klu.pre.macdhist) > abs(klu.macdhist): - if self.peak_klu: - if abs(klu.pre.macdhist) > abs(self.peak_klu.macdhist): - self.peak_klu = klu.pre - #self.div_count = 0 - #self.peak_div_list = [] - else: - if klu.pre.macd * klu.pre.macdhist > 0 and klu.pre.signal * klu.pre.macdhist > 0: - self.peak_div_list.append(klu.pre) - self.div_count += 1 - klu.pre.continue_div = True - else: - self.peak_klu = klu.pre - def set_end_klu(self, end_klu): - self.end_klu = end_klu - self.end_time = end_klu.time - #if len(self.peak_div_list) > 0: - #klu = self.peak_div_list[-1] - #if klu.macd * klu.macdhist > 0: - #end_klu.continue_div = True - #print(end_klu.time, "Continue Div") - if self.start_klu.index == end_klu.index: - self.peak_klu = self.start_klu - if self.start_klu.index + 1 == end_klu.index: - if abs(self.start_klu.macdhist) > abs(end_klu.macdhist): - self.peak_klu = self.start_klu - else: - self.peak_klu = end_klu - if len(self.klu_list) >= 3 and self.peak_klu == None: - self.peak_klu = self.klu_list[0] - for klu in self.klu_list: - if abs(klu.macdhist) > abs(self.peak_klu.macdhist): - self.peak_klu = klu - peak_str = "" - state_str = "" - for peak_div in self.peak_div_list: - peak_str += f"{peak_div.time}, " - state_str += f"{peak_div.macd_state}, " - total_macdhist = 0 - first_klu = self.klu_list[0] - last_klu = self.klu_list[-1] - if (first_klu.macd > 0 and last_klu.macd > 0 and first_klu.macdhist > 0) or (first_klu.macd < 0 and last_klu.macd < 0 and first_klu.macdhist < 0): - for klu in self.klu_list: - self.total_macdhist += klu.macdhist - if abs(self.total_macdhist) < 150: - #print(self.end_time, "Total MACDHist: ", self.total_macdhist) - last_klu.separate_div = 99999 - #if self.peak_klu and len(self.peak_div_list) > 0: - #print("Continue Div: ",self.start_time, "Peak:", self.peak_klu.time, "Div: ", peak_str, state_str) - \ No newline at end of file +"""兼容 shim — 请优先 from chanlun import ...""" +from chanlun.indicators.ChanMACDHistSet import * # noqa: F403 diff --git a/ChanMACDSeg.py b/ChanMACDSeg.py index b013b3f..c4e9cca 100644 --- a/ChanMACDSeg.py +++ b/ChanMACDSeg.py @@ -1,49 +1,2 @@ -from ChanEnum import Chan_MACDSEG_DIR - - -class ChanMACDSeg(): - def __init__(self, index, start_time, start_klu, pre_seg, seg_dir, start_unittf): - self.index = index - self.start_time = start_time - self.end_time = None - self.start_klu = start_klu - self.end_klu = None - self.klu_list = [] - self.klu_list.append(start_klu) - self.unittf_list = [] - self.seg_dir = seg_dir - self.pre = pre_seg - self.next = None - self.high_klu = start_klu - self.low_klu = start_klu - self.ref_klu = None - self.unittf_list.append(start_unittf) - def set_next(self, next_seg): - self.next = next_seg - def set_pre(self, pre_seg): - self.pre = pre_seg - def add_klu(self, klu): - if klu: - self.klu_list.append(klu) - klu.set_seg(self) - if self.seg_dir == Chan_MACDSEG_DIR.ABOVE: - if klu.macdhist > self.high_klu.macdhist: - self.high_klu = klu - else: - if klu.macdhist < self.low_klu.macdhist: - self.low_klu = klu - else: - if klu.macdhist < self.high_klu.macdhist: - self.high_klu = klu - else: - if klu.macdhist > self.low_klu.macdhist: - self.low_klu = klu - if self.high_klu.index != self.start_klu.index: - self.ref_klu = self.high_klu - def add_unittf(self, unittf): - self.unittf_list.append(unittf) - unittf.set_next(self) - def set_end_klu(self, end_klu): - self.add_klu(end_klu) - self.end_klu = end_klu - self.end_time = end_klu.time \ No newline at end of file +"""兼容 shim — 请优先 from chanlun import ...""" +from chanlun.indicators.ChanMACDSeg import * # noqa: F403 diff --git a/ChanMACDUnitTF.py b/ChanMACDUnitTF.py index 4f13fff..e586b1b 100644 --- a/ChanMACDUnitTF.py +++ b/ChanMACDUnitTF.py @@ -1,141 +1,2 @@ -from ChanEnum import Chan_MACD_STATE, Chan_MACDUNITTF_DIR, Chan_MACDHISTSET_DIR, Chan_MACDUNITTF_DIV, Chan_MACDUNITTF_TYPE - - -class ChanMACDUnitTF(): - def __init__(self, index, start_time, start_klu, pre_unittf, unittf_dir, start_type, start_histset): - self.index = index - self.start_time = start_time - self.end_time = None - self.start_klu = start_klu - self.end_klu = None - self.klu_list = [] - self.klu_list.append(start_klu) - self.histset_list = [] - self.histset_list.append(start_histset) - self.div_count = 0 - start_histset.set_middle_klu(start_klu) - self.next = None - self.pre = pre_unittf - self.unittf_dir = unittf_dir - self.start_type = start_type - self.end_type = None - self.peak_klu = None - self.div_type = Chan_MACDUNITTF_DIV.UNDIV - self.div_peak_list = [] - self.is_end = False - def set_next(self, next_unittf): - self.next = next_unittf - def set_pre(self, pre_unittf): - self.pre = pre_unittf - def add_histset(self, histset): - self.histset_list.append(histset) - def add_klu(self, klu): - self.klu_list.append(klu) - self.cal_peak_div() - self.cal_macd_state() - def cal_peak_div(self): - self.div_count = 0 - self.div_peak_list = [] - self.peak_klu = None - if len(self.histset_list) == 0: - return - if len(self.histset_list) == 1: - self.div_type = self.histset_list[0].unittf_div - self.peak_klu = self.histset_list[0].peak_klu - else: - - for index in range(0, len(self.histset_list)): - histset = self.histset_list[index] - if self.same_dir(histset): - #if histset.peak_klu: - #print("Unittf: ", self.start_klu.time, len(self.histset_list), histset.peak_klu.time) - if self.peak_klu: - if histset.peak_klu: - if abs(histset.peak_klu.macdhist) >= abs(self.peak_klu.macdhist): - self.peak_klu = histset.peak_klu - self.div_type = Chan_MACDUNITTF_DIV.UNDIV - self.div_count = 0 - else: - self.div_type = Chan_MACDUNITTF_DIV.DISCRETE - self.div_count += 1 - self.div_peak_list.append(histset.peak_klu) - #print("Unittf: ", self.start_klu.time) - if histset.peak_klu.macd > 0 and histset.histset_dir == Chan_MACDHISTSET_DIR.ABOVE: - histset.peak_klu.set_separate_div(self.div_count) - elif histset.peak_klu.macd < 0 and histset.histset_dir == Chan_MACDHISTSET_DIR.UNDER: - histset.peak_klu.set_separate_div(self.div_count) - else: - if histset.peak_klu: - self.peak_klu = histset.peak_klu - def cal_macd_state(self): - if len(self.klu_list) > 0: - last_klu = self.klu_list[0] - macd_peak_klu = None - signal_peak_klu = None - for index in range(1, len(self.klu_list)): - klu = self.klu_list[index] - if klu.macd == 0 and klu.signal == 0 and klu.macdhist == 0: - continue - if self.start_type == Chan_MACDUNITTF_TYPE.CROSS0 or self.start_type == Chan_MACDUNITTF_TYPE.NEAR0: - if self.unittf_dir == Chan_MACDUNITTF_DIR.ABOVE: - if macd_peak_klu is None: - if last_klu.macd < klu.macd: - if last_klu.signal < last_klu.macdhist: - last_klu.set_macd_state(Chan_MACD_STATE.UP) - else: - last_klu.set_macd_state(Chan_MACD_STATE.HIGH) - else: - macd_peak_klu = last_klu - last_klu.set_macd_state(Chan_MACD_STATE.PEAK) - elif klu.macd > macd_peak_klu.macd: - macd_peak_klu = None - last_klu.set_macd_state(Chan_MACD_STATE.HIGH) - elif last_klu.signal < klu.signal: - last_klu.set_macd_state(Chan_MACD_STATE.HIGH_EMPTY) - elif signal_peak_klu is None: - signal_peak_klu = last_klu - last_klu.set_macd_state(Chan_MACD_STATE.HIGH_EMPTY) - elif klu.signal > signal_peak_klu.signal: - signal_peak_klu = None - last_klu.set_macd_state(Chan_MACD_STATE.HIGH) - elif last_klu.macd < last_klu.signal: - last_klu.set_macd_state(Chan_MACD_STATE.RETURN_ZERO) - if self.return_zero(last_klu, klu): - self.end_type = Chan_MACDUNITTF_TYPE.NEAR0 - self.is_end = True - self.end_klu = klu - self.end_time = klu.time - klu.set_macd_state(Chan_MACD_STATE.NEAR0) - #print(self.index, last_klu.time, last_klu.macd, last_klu.signal, last_klu.macd_state, klu.macd_state) - break - #print(self.index, last_klu.time, last_klu.macd, last_klu.signal, last_klu.macd_state) - last_klu = klu - - def return_zero(self, last_klu, klu): - return_zero = False - if last_klu.close < last_klu.ema52 and klu.close > klu.ema52: - return_zero = True - return_zero = False - return return_zero - def same_dir(self, histset): - if self.unittf_dir == Chan_MACDUNITTF_DIR.ABOVE: - return histset.histset_dir == Chan_MACDHISTSET_DIR.ABOVE - else: - return histset.histset_dir == Chan_MACDHISTSET_DIR.UNDER - def set_end_klu(self, end_klu, end_type): - self.end_type = end_type - self.end_klu = end_klu - self.end_time = end_klu.time - self.is_end = True - self.cal_macd_state() - div_time = "" - for div in self.div_peak_list: - div_time += f"{div.time}, " - histset_time = "" - for histset in self.histset_list: - histset_time += f"{histset.start_time}, " - - #if self.peak_klu and self.div_type == Chan_MACDUNITTF_DIV.DISCRETE: - #print("Cross Div: ", self.start_klu.time, self.peak_klu.time, self.div_count, self.div_type, self.unittf_dir, div_time, len(self.histset_list)) - - \ No newline at end of file +"""兼容 shim — 请优先 from chanlun import ...""" +from chanlun.indicators.ChanMACDUnitTF import * # noqa: F403 diff --git a/ChanPY.py b/ChanPY.py index 0096f3c..c5f568f 100644 --- a/ChanPY.py +++ b/ChanPY.py @@ -1,402 +1,2 @@ -import sys -import os -#sys.path.append(os.path.abspath("/Users/jack/Documents/GitHub/chan.py")) -sys.path.append(os.path.abspath("/Users/jack/Project/chan.py")) -from Chan import CChan -from BuySellPoint.BS_Point import CBS_Point -from ChanConfig import CChanConfig -from Common.CEnum import AUTYPE, DATA_SRC, KL_TYPE, DATA_FIELD, BSP_TYPE, FX_TYPE, BI_DIR, KLINE_DIR, SEG_DIR -from KLine.KLine_Unit import CKLine_Unit -from Common.CTime import CTime -from Common.func_util import kltype_lt_day, str2float -from Bi.Bi import CBi -from typing import Dict, List -from functools import reduce -from pandas import DataFrame -from datetime import datetime, timedelta, timezone - - -def GetColumnNameFromFieldList(fileds: str): - _dict = { - "time": DATA_FIELD.FIELD_TIME, - "open": DATA_FIELD.FIELD_OPEN, - "high": DATA_FIELD.FIELD_HIGH, - "low": DATA_FIELD.FIELD_LOW, - "close": DATA_FIELD.FIELD_CLOSE, - "volume": DATA_FIELD.FIELD_VOLUME - } - return [_dict[x] for x in fileds.split(",")] -class ChanPY(): - k_type = KL_TYPE.K_5M - config = CChanConfig({ - "bi_strict": True, - "bi_algo": "normal", - "trigger_step": True, - "skip_step": 0, - "divergence_rate": float("inf"), - "bsp2_follow_1": False, - "bsp3_follow_1": False, - "min_zs_cnt": 1, - "bs1_peak": False, - "macd_algo": "peak", - "bs_type": '1,2,3a,1p,2s,3b', - "print_warning": True, - "zs_algo": "normal", - }) - chan = CChan( - code="BTC/USDT:USDT", - data_src=DATA_SRC.CCXT, - lv_list=[k_type], - config=config, - autype=AUTYPE.QFQ, - ) - klu_list = [] - bsps = [] - chanIn = True - #def __init__(self, dataframe): - #self.klu_list = self.get_kl_data(dataframe) - #for klu in self.klu_list: - #self.chan.trigger_load({self.k_type: [klu]}) - def add_klu(self, klu): - if klu: - self.chan.trigger_load({self.k_type: [klu]}) - self.klu_list.append(klu) - def add_klu_from_dataframe(self, dataframe): - if len(dataframe) > len(self.klu_list) and len(dataframe) - len(self.klu_list) == 1: - klu = self.get_last_klu(dataframe) - self.chan.trigger_load({self.k_type: [klu]}) - self.klu_list.append(klu) - def parse_time_column(self, inp): - if len(inp) == 10: - year = int(inp[:4]) - month = int(inp[5:7]) - day = int(inp[8:10]) - hour = minute = 0 - elif len(inp) == 17: - year = int(inp[:4]) - month = int(inp[4:6]) - day = int(inp[6:8]) - hour = int(inp[8:10]) - minute = int(inp[10:12]) - elif len(inp) == 19: - year = int(inp[:4]) - month = int(inp[5:7]) - day = int(inp[8:10]) - hour = int(inp[11:13]) - minute = int(inp[14:16]) - else: - raise Exception(f"unknown time column from TradingView:{inp}") - return CTime(year, month, day, hour, minute, auto=not kltype_lt_day(self.k_type)) - - def create_item_dict(self, data, column_name): - for i in range(len(data)): - data[i] = self.parse_time_column(data[i]) if i == 0 else str2float(data[i]) - return dict(zip(column_name, data)) - def get_last_klu(self, dataframe:DataFrame): - fields = "time,open,high,low,close,volume" - item = dataframe.iloc[-1] - date = item['date'] - o = item['open'] - h = item['high'] - l = item['low'] - c = item['close'] - v = item['volume'] - #time_obj = date.fromtimestamp(date) - time_str = date.strftime('%Y-%m-%d %H:%M:%S') - item_data = [ - time_str, - o, - h, - l, - c, - v - ] - klu = CKLine_Unit(self.create_item_dict(item_data, GetColumnNameFromFieldList(fields)), autofix=True) - klu.set_idx(len(dataframe)-1) - return klu - def get_kl_data(self, dataframe:DataFrame): - fields = "time,open,high,low,close,volume" - klu_list = [] - for i in range(0, len(dataframe)): - item = dataframe.iloc[i] - date = item['date'] - o = item['open'] - h = item['high'] - l = item['low'] - c = item['close'] - v = item['volume'] - #time_obj = date.fromtimestamp(date) - time_str = date.strftime('%Y-%m-%d %H:%M:%S') - item_data = [ - time_str, - o, - h, - l, - c, - v - ] - klu = CKLine_Unit(self.create_item_dict(item_data, GetColumnNameFromFieldList(fields)), autofix=True) - klu.set_idx(i) - klu_list.append(klu) - return klu_list - def get_bsp_type(self, bsp_type, is_buy): - if is_buy: - if bsp_type == BSP_TYPE.T1: - return 1 - if bsp_type == BSP_TYPE.T1P: - return 2 - if bsp_type == BSP_TYPE.T2: - return 3 - if bsp_type == BSP_TYPE.T2S: - return 4 - if bsp_type == BSP_TYPE.T3A: - return 5 - if bsp_type == BSP_TYPE.T3B: - return 6 - else: - if bsp_type == BSP_TYPE.T1: - return -1 - if bsp_type == BSP_TYPE.T1P: - return -2 - if bsp_type == BSP_TYPE.T2: - return -3 - if bsp_type == BSP_TYPE.T2S: - return -4 - if bsp_type == BSP_TYPE.T3A: - return -5 - if bsp_type == BSP_TYPE.T3B: - return -6 - def get_bsps(self, dataframe:DataFrame): - fields = "time,open,high,low,close,volume" - bsps = [] - updown = [] - bi_sure = [] - if self.chanIn: - kl_data = self.get_kl_data(dataframe) - bsp_list = [] - bsp_list_pre_len = 0 - last_bsp_value = 0 - last_updown = -1 - bi_list_pre_len = 0 - pre_bi = None - zs_list_pre_len = 0 - pre_zs = None - for klu in kl_data: # 获取单根K线 - self.chan.trigger_load({self.k_type: [klu]}) # 喂给CChan新增k线 - self.last_kline = klu - bsp_list = self.chan.get_bsp() - kl_datas = self.chan.kl_datas[self.k_type] - bi_list = kl_datas.bi_list - lst = kl_datas.lst - if len(bsp_list) > 0: - last_bsp = bsp_list[-1] - #print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value, lst[-2].fx, bi_list[-1].dir, bi_list[-1].is_sure,klu.close) - if bsp_list_pre_len > len(bsp_list): - if abs(last_bsp_value) == 1 or abs(last_bsp_value) == 2: - bsps.append(1) - #print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value, 98) - else: - bsps.append(99) - else: - if bsp_list_pre_len == len(bsp_list): - if klu.idx == last_bsp.klu.idx: - last_bsp_value = self.get_bsp_type(last_bsp.type[0], last_bsp.is_buy) - bsps.append(last_bsp_value) - #print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value) - else: - bsps.append(0) - else: - last_bsp_value = self.get_bsp_type(last_bsp.type[0], last_bsp.is_buy) - bsps.append(last_bsp_value) - #print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value) - else: - bsps.append(0) - bsp_list_pre_len = len(bsp_list) - - #Check zs ----------------------------------- - zs_list = kl_datas.zs_list - if len(zs_list) > 0: - zs = zs_list[-1] - #if zs_list_pre_len > len(zs_list): - #print("No zs", zs.begin.time) - #if len(zs_list) > zs_list_pre_len: - #print(zs.begin.time, zs.end.time, zs.end.idx, zs.high, zs.low, zs.peak_high, zs.peak_low) - zs_list_pre_len = len(zs_list) - pre_zs = zs - - #Check Bi ----------------------------------- - if len(bi_list) > 0: - last_bi = bi_list[-1] - if len(bi_list) == 1: - if last_bi.dir == BI_DIR.UP: - updown.append(1) - last_updown = 1 - else: - updown.append(-1) - last_updown = -1 - else: - if last_updown == 1: - if last_bi.dir == BI_DIR.UP: - updown.append(0) - else: - updown.append(-1) - last_updown = -1 - else: - if last_bi.dir == BI_DIR.DOWN: - updown.append(0) - else: - updown.append(1) - last_updown = 1 - else: - updown.append(0) - bi_list = kl_datas.bi_list - if len(bi_list) > 0: - last_bi = bi_list[-1] - #if bi_list_pre_len > len(bi_list): - #print("Bi ", klu.time, pre_bi.idx, pre_bi.is_sure, bi_list[-1].idx, bi_list[-1].is_sure) - if last_bi.is_sure: - bi_sure.append(1) - #print(klu.time, last_bi.is_sure) - else: - bi_sure.append(0) - pre_bi = bi_list[-1] - bi_list_pre_len = len(bi_list) - else: - bi_sure.append(0) - #if bsps[-1] != 0 or updown[-1] != 0: - #print(klu.time, bsps[-1], updown[-1], bi_list[-1].is_sure) - self.chanIn = False - else: - klu = self.get_last_klu(dataframe) - if self.last_kline.time < klu.time: - self.chan.trigger_load({self.k_type: [klu]}) # 喂给CChan新增k线 - self.last_kline = klu - for index in range(0, len(bsps)): - if not (abs(bsps[index]) == 1 or abs(bsps[index]) == 2): - bsps[index] = 0 - else: - if bsps[index] == 2: - bsps[index] = 1 - else: - if bsps[index] == -2: - bsps[index] = -1 - else: - bsps[index] = 0 - #print(bsps) - #print(updown) - kl_datas = self.chan.kl_datas[self.k_type] - #for zs in kl_datas.zs_list: - #print(zs.begin.time, zs.end.time) - return bsps, updown, bi_sure - - def get_bsp_state1(self, dataframe:DataFrame): - fields = "time,open,high,low,close,volume" - bsps = [] - if self.chanIn: - kl_data = self.get_kl_data(dataframe) - self.chan.trigger_load({self.k_type: kl_data}) - bsp_list = self.chan.get_bsp() - bsp_index = 0 - for klu in kl_data: - if bsp_index >= len(bsp_list): - bsp_index = len(bsp_list) - 1 - bsp = bsp_list[bsp_index] - if klu.idx == bsp.klu.idx: - bsp_type = self.get_bsp_type(bsp.type[0], bsp.is_buy) - if abs(bsp_type) == 1 or abs(bsp_type) == 10: - bsps.append(1) - else: - bsps.append(0) - bsp_index = bsp_index + 1 - else: - bsps.append(0) - self.chanIn = False - else: - klu = CKLine_Unit(self.create_item_dict(self.get_last_item_data(dataframe), GetColumnNameFromFieldList(fields)), autofix=True) - if self.last_kline.time < klu.time: - self.chan.trigger_load({self.k_type: [klu]}) # 喂给CChan新增k线 - self.last_kline = klu - return bsps - def get_bsp_state(self, dataframe:DataFrame): - fields = "time,open,high,low,close,volume" - if self.chanIn: - kl_data = self.get_kl_data(dataframe) - bsp_list = [] - bsp_list_pre_len = 0 - last_bsp_value = 0 - last_bsp_index = 0 - for klu in kl_data: # 获取单根K线 - self.chan.trigger_load({self.k_type: [klu]}) # 喂给CChan新增k线 - self.last_kline = klu - bsp_list = self.chan.get_bsp() - kl_datas = self.chan.kl_datas[self.k_type] - bi_list = kl_datas.bi_list - lst = kl_datas.lst - if len(bsp_list) > 0: - last_bsp = bsp_list[-1] - #print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value, lst[-2].fx, bi_list[-1].dir, bi_list[-1].is_sure,klu.close) - if bsp_list_pre_len > len(bsp_list): - if abs(last_bsp_value) == 1: - self.bsps.append(1) - #print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value, 98) - else: - self.bsps.append(99) - else: - if bsp_list_pre_len == len(bsp_list): - if klu.idx == last_bsp.klu.idx: - if last_bsp.klu.idx - last_bsp_index > 3: - last_bsp_value = self.get_bsp_type(last_bsp.type[0], last_bsp.is_buy) - self.bsps.append(last_bsp_value) - else: - self.bsps.append(0) - last_bsp_index = last_bsp.klu.idx - #if abs(last_bsp_value) == 1 or abs(last_bsp_value) == 2: - #print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value, "Knonw") - else: - self.bsps.append(0) - else: - if klu.idx == last_bsp.klu.idx: - if last_bsp.klu.idx - last_bsp_index > 3: - last_bsp_value = self.get_bsp_type(last_bsp.type[0], last_bsp.is_buy) - self.bsps.append(last_bsp_value) - else: - self.bsps.append(0) - last_bsp_index = last_bsp.klu.idx - #if abs(last_bsp_value) == 1 or abs(last_bsp_value) == 2: - #print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value, "Knonw") - else: - self.bsps.append(0) - else: - self.bsps.append(0) - bsp_list_pre_len = len(bsp_list) - self.chanIn = False - else: - klu = self.get_last_klu(dataframe) - if self.last_kline.time < klu.time: - self.chan.trigger_load({self.k_type: [klu]}) # 喂给CChan新增k线 - self.last_kline = klu - bsp_list = self.chan.get_bsp() - last_bsp = bsp_list[-1] - if last_bsp.klu.idx == klu.idx: - self.bsps.append(self.get_bsp_type(last_bsp.type[0], last_bsp.is_buy)) - else: - self.bsps.append(0) - for index in range(0, len(self.bsps)): - if not (abs(self.bsps[index]) == 1 or abs(self.bsps[index]) == 2): - self.bsps[index] = 0 - else: - if self.bsps[index] == 2: - self.bsps[index] = 10 - else: - if self.bsps[index] == -2: - self.bsps[index] = -10 - else: - if self.bsps[index] == 1: - self.bsps[index] = 1 - else: - if self.bsps[index] == -1: - self.bsps[index] = -1 - else: - self.bsps[index] = 0 - return self.bsps - +"""兼容 shim — 请优先 from chanlun import ...""" +from chanlun.analysis.ChanPY import * # noqa: F403 diff --git a/ChanPivotClassifier.py b/ChanPivotClassifier.py index 52b1af0..d46d56b 100644 --- a/ChanPivotClassifier.py +++ b/ChanPivotClassifier.py @@ -1,292 +1,2 @@ -""" -中枢结构特征提取 + 标签化 -Market Structure Dataset Builder — Phase 1 - -定位: 训练数据集构建工具,不是交易信号生成器。 -Feature 描述中枢内部结构,Label 记录中枢后实际演化。 -""" - -import math -import json -from typing import Optional -from ChanEnum import Chan_BI_DIR - - -class ChanPivotClassifier: - """ - 中枢结构特征提取 + 标签化 - 输入: bi_zs_list (list[ChanBIZS]) - 输出: 结构化数据集 (list[dict]) - """ - - DATASET_VERSION = "pivot_v1" - FEATURE_SCHEMA = ["duration_norm", "contraction", "shift_norm"] - LABEL_SCHEMA = {"name": "break_direction", "values": ["up", "down", "none"]} - - def __init__(self, bi_zs_list: list, symbol: str = "", timeframe: str = ""): - self.bi_zs_list = bi_zs_list - self.symbol = symbol - self.timeframe = timeframe - - # ------------------------------------------------------------------ - # Feature extraction - # ------------------------------------------------------------------ - - @staticmethod - def calc_duration(zs) -> int: - """持续时间: 第一笔首K → 最后一笔末K 的 index 差""" - bi_list = zs.bi_list - start_idx = bi_list[0].start_klc.index - end_idx = bi_list[-1].end_klc.index - return end_idx - start_idx - - @staticmethod - def calc_contraction(zs) -> float: - """收敛率: 后窗口振幅均值 / 前窗口振幅均值""" - bi_list = zs.bi_list - if len(bi_list) < 4: - return 1.0 - - n = min(3, len(bi_list) // 2) - first_ranges = [bi.high - bi.low for bi in bi_list[:n]] - last_ranges = [bi.high - bi.low for bi in bi_list[-n:]] - - first_mean = sum(first_ranges) / len(first_ranges) - last_mean = sum(last_ranges) / len(last_ranges) - - if first_mean == 0: - return 1.0 - return last_mean / first_mean - - @staticmethod - def calc_shift(zs) -> tuple[float, float]: - """重心漂移: 前后半段重心均值差 (原始值, 归一化值)""" - bi_list = zs.bi_list - mid = len(bi_list) // 2 - - first_centers = [(bi.high + bi.low) / 2 for bi in bi_list[:mid]] - last_centers = [(bi.high + bi.low) / 2 for bi in bi_list[mid:]] - - shift_raw = ( - sum(last_centers) / len(last_centers) - - sum(first_centers) / len(first_centers) - ) - - zs_height = zs.zg - zs.zd - if zs_height == 0: - shift_norm = 0.0 - else: - shift_norm = shift_raw / zs_height - - return shift_raw, shift_norm - - @staticmethod - def compute_duration_norm(duration_raw: int, historical_durations: list) -> float: - """用历史窗口均值归一化 duration""" - if not historical_durations: - return 1.0 - avg = sum(historical_durations) / len(historical_durations) - if avg == 0: - return 1.0 - return duration_raw / avg - - @staticmethod - def compute_features(zs, historical_durations: Optional[list] = None): - """计算单个中枢的全部结构特征(实时友好)""" - duration_raw = ChanPivotClassifier.calc_duration(zs) - contraction = ChanPivotClassifier.calc_contraction(zs) - shift_raw, shift_norm = ChanPivotClassifier.calc_shift(zs) - - if historical_durations is not None and len(historical_durations) > 0: - duration_norm = ChanPivotClassifier.compute_duration_norm( - duration_raw, historical_durations - ) - else: - duration_norm = 1.0 - - return { - "duration_raw": duration_raw, - "duration_norm": round(duration_norm, 4), - "contraction": round(contraction, 4), - "shift_raw": round(shift_raw, 6), - "shift_norm": round(shift_norm, 4), - "zs_height": round(zs.zg - zs.zd, 6), - } - - # ------------------------------------------------------------------ - # Label computation - # ------------------------------------------------------------------ - - @staticmethod - def _clamp(x: float, lo: float = 0.0, hi: float = 1.0) -> float: - return max(lo, min(hi, x)) - - def _compute_label(self, zs, contraction: float, shift_norm: float) -> dict: - """计算标签: up / down / none + 连续置信度""" - bi_out = zs.bi_out - - if bi_out is None: - return { - "label": "none", - "label_confidence": 0.0, - "label_detail": { - "bi_out_dir": "none", - "score_breakout": 0.0, - "score_shift": 0.0, - "score_contraction": 0.0, - }, - } - - zs_height = zs.zg - zs.zd - if zs_height == 0: - zs_height = 1e-8 - - # ---- 向上突破分数 ---- - if bi_out.dir == Chan_BI_DIR.UP: - raw_breakout = (bi_out.high - zs.gg) / zs_height - score_breakout_up = self._clamp(raw_breakout) - score_shift_up = math.tanh(self._clamp(shift_norm, -3.0, 3.0)) - score_contraction_up = max(0.0, 1.0 - contraction) - else: - score_breakout_up = 0.0 - score_shift_up = 0.0 - score_contraction_up = 0.0 - - up_score = ( - score_breakout_up * 0.5 - + score_shift_up * 0.3 - + score_contraction_up * 0.2 - ) - - # ---- 向下突破分数 ---- - if bi_out.dir == Chan_BI_DIR.DOWN: - raw_breakout = (zs.dd - bi_out.low) / zs_height - score_breakout_down = self._clamp(raw_breakout) - score_shift_down = math.tanh(self._clamp(-shift_norm, -3.0, 3.0)) - score_contraction_down = max(0.0, 1.0 - contraction) - else: - score_breakout_down = 0.0 - score_shift_down = 0.0 - score_contraction_down = 0.0 - - down_score = ( - score_breakout_down * 0.5 - + score_shift_down * 0.3 - + score_contraction_down * 0.2 - ) - - # ---- 判定 ---- - threshold = 0.15 - - if up_score > down_score and up_score > threshold: - label = "up" - confidence = up_score - detail = { - "bi_out_dir": "up", - "score_breakout": round(score_breakout_up, 4), - "score_shift": round(score_shift_up, 4), - "score_contraction": round(score_contraction_up, 4), - } - elif down_score > up_score and down_score > threshold: - label = "down" - confidence = down_score - detail = { - "bi_out_dir": "down", - "score_breakout": round(score_breakout_down, 4), - "score_shift": round(score_shift_down, 4), - "score_contraction": round(score_contraction_down, 4), - } - else: - label = "none" - confidence = max(up_score, down_score) - bi_dir = "up" if bi_out.dir == Chan_BI_DIR.UP else "down" - detail = { - "bi_out_dir": bi_dir, - "score_breakout": round(max(score_breakout_up, score_breakout_down), 4), - "score_shift": round(max(score_shift_up, score_shift_down), 4), - "score_contraction": round(max(score_contraction_up, score_contraction_down), 4), - } - - return { - "label": label, - "label_confidence": round(confidence, 4), - "label_detail": detail, - } - - # ------------------------------------------------------------------ - # Public API - # ------------------------------------------------------------------ - - def extract(self) -> list[dict]: - """主入口:对每个中枢提取 3 特征 + 1 标签""" - - # 第一遍:计算原始值 - raw = [] - for i, zs in enumerate(self.bi_zs_list): - if not zs.is_sure or len(zs.bi_list) < 3: - continue - - duration_raw = ChanPivotClassifier.calc_duration(zs) - contraction = ChanPivotClassifier.calc_contraction(zs) - shift_raw, shift_norm = ChanPivotClassifier.calc_shift(zs) - - raw.append({ - "zs": zs, - "zs_index": i, - "duration_raw": duration_raw, - "contraction": contraction, - "shift_raw": shift_raw, - "shift_norm": shift_norm, - "zs_height": zs.zg - zs.zd, - }) - - # 第二遍:组装输出 + 计算 label - result = [] - for r in raw: - zs = r["zs"] - historical = [x["duration_raw"] for x in raw] - duration_norm = ChanPivotClassifier.compute_duration_norm( - r["duration_raw"], historical - ) - label_info = self._compute_label(zs, r["contraction"], r["shift_norm"]) - - # 时间处理 - start_time = None - end_time = None - if hasattr(zs, "start_time") and zs.start_time is not None: - start_time = str(zs.start_time) - if hasattr(zs, "end_time") and zs.end_time is not None: - end_time = str(zs.end_time) - - result.append({ - "dataset_version": self.DATASET_VERSION, - "feature_schema": self.FEATURE_SCHEMA, - "label_schema": self.LABEL_SCHEMA, - - "symbol": self.symbol, - "timeframe": self.timeframe, - "zs_index": r["zs_index"], - "zs_start_time": start_time, - "zs_end_time": end_time, - - "duration_norm": round(duration_norm, 4), - "contraction": round(r["contraction"], 4), - "shift_norm": round(r["shift_norm"], 4), - - "label": label_info["label"], - "label_confidence": label_info["label_confidence"], - "label_detail": label_info["label_detail"], - - "duration_raw": r["duration_raw"], - "shift_raw": round(r["shift_raw"], 6), - "zs_height": round(r["zs_height"], 6), - }) - - return result - - def export_json(self, path: str): - """导出为 JSON 文件""" - data = self.extract() - with open(path, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2, ensure_ascii=False, default=str) - return len(data) +"""兼容 shim — 请优先 from chanlun import ...""" +from chanlun.analysis.ChanPivotClassifier import * # noqa: F403 diff --git a/ChanPivotMonitor.py b/ChanPivotMonitor.py index 778ae2a..85a8ca4 100644 --- a/ChanPivotMonitor.py +++ b/ChanPivotMonitor.py @@ -1,145 +1,2 @@ -""" -实时中枢特征跟踪器 -Real-time Pivot Feature Tracker - -定位: 观察者 — 不修改管线,只观察 bi_zs_list 中当前中枢的特征变化。 -每次管线重算后调用 update(),检测 bi_count 是否增长,若增长则重新计算 -shift / contraction / duration。 -""" - -from collections import deque -from typing import Optional -from ChanPivotClassifier import ChanPivotClassifier - - -class ChanPivotMonitor: - """ - 实时追踪当前中枢的结构特征。 - - update() 每次管线重算后调用,对比 bi_count 判断是否有新笔加入中枢。 - 若 bi_count 增长则重新计算 3 个结构特征并返回最新值。 - """ - - def __init__(self, window_size: int = 10): - self._window_size = window_size - self._duration_history: deque[int] = deque(maxlen=window_size) - self._current_zs_id: Optional[tuple] = None - self._current_bi_count: int = 0 - self._current_is_sure: bool = False - self._current_state: Optional[dict] = None - self._duration_added_for_zs: set = set() # 已加入窗口的中枢 ID(上限 200) - - # ------------------------------------------------------------------ - # Public API - # ------------------------------------------------------------------ - - def update(self, bi_zs_list: list) -> Optional[dict]: - """ - 主入口:检测当前中枢特征变化。 - - 参数: - bi_zs_list: 当前管线产出的笔中枢列表 - - 返回: - 特征 dict(有变化时),无变化返回 None - """ - if not bi_zs_list: - self._current_zs_id = None - self._current_bi_count = 0 - self._current_is_sure = False - self._current_state = None - return None - - zs = self._find_current_zs(bi_zs_list) - if zs is None: - return None - - zs_id = self._make_zs_id(zs) - bi_count = len(zs.bi_list) - is_sure = zs.is_sure - - # 无变化 → 跳过 - if (zs_id == self._current_zs_id - and bi_count == self._current_bi_count - and is_sure == self._current_is_sure): - return None - - # 中枢切换 → 将旧中枢 duration 加入窗口 - if zs_id != self._current_zs_id: - self._maybe_add_to_history() - - self._current_zs_id = zs_id - self._current_bi_count = bi_count - self._current_is_sure = is_sure - - features = ChanPivotClassifier.compute_features( - zs, list(self._duration_history) - ) - - self._current_state = { - "zs_id": zs_id, - "zs_index": zs.index, - "zs_dir": str(zs.dir), - "bi_count": bi_count, - "is_sure": zs.is_sure, - "zg": round(zs.zg, 6), - "zd": round(zs.zd, 6), - "gg": round(zs.gg, 6), - "dd": round(zs.dd, 6), - **features, - "start_time": str(t) if (t := getattr(zs, "start_time", None)) else None, - } - - # 中枢刚变为已确认时,将其 duration 加入滚动窗口 - if is_sure and zs_id not in self._duration_added_for_zs: - self._add_duration(features["duration_raw"]) - self._duration_added_for_zs.add(zs_id) - - return self._current_state - - def get_current(self) -> Optional[dict]: - """返回当前中枢的最新特征""" - return self._current_state - - def get_duration_history(self) -> list[int]: - """返回用于归一化的 duration 滚动窗口""" - return list(self._duration_history) - - # ------------------------------------------------------------------ - # Internal - # ------------------------------------------------------------------ - - @staticmethod - def _make_zs_id(zs) -> tuple: - """生成中枢的稳定标识(基于首笔首K线时间戳,不随 DataFrame 窗口偏移而变化)""" - bi0 = zs.bi_list[0] - return (bi0.start_klc.start_time,) - - @staticmethod - def _find_current_zs(bi_zs_list: list): - """ - 找到当前活跃中枢: - 优先取最后一个 is_sure=False(形成中)的中枢, - 没有则取最后一个 is_sure=True 的中枢。 - """ - forming = None - last_sure = None - for zs in bi_zs_list: - if len(zs.bi_list) < 3: - continue - if not zs.is_sure: - forming = zs - else: - last_sure = zs - return forming if forming is not None else last_sure - - def _add_duration(self, duration_raw: int): - """将已确认中枢的 duration 加入滚动窗口""" - self._duration_history.append(duration_raw) - - def _maybe_add_to_history(self): - """旧中枢切换前,若已确认且未记录过,则将其 duration 加入窗口""" - if (self._current_state and self._current_state["is_sure"] - and self._current_zs_id not in self._duration_added_for_zs): - self._add_duration(self._current_state["duration_raw"]) - self._duration_added_for_zs.add(self._current_zs_id) +"""兼容 shim — 请优先 from chanlun import ...""" +from chanlun.analysis.ChanPivotMonitor import * # noqa: F403 diff --git a/ChanSBI.py b/ChanSBI.py index 855b621..163ec66 100644 --- a/ChanSBI.py +++ b/ChanSBI.py @@ -1,91 +1,2 @@ -import copy -from typing import Dict, Optional - -from ChanEnum import Chan_FX_TYPE, Chan_KLINE_DIR, Chan_BI_DIR -import ChanKLU -from ChanBI import ChanBI - -class ChanSBI(): - def __init__(self, start_bi: ChanBI, index, dir=Chan_BI_DIR.UP): - self.start_bi = start_bi - self.end_bi = None - self.index = index - self.dir = dir - self.high = start_bi.high - self.low = start_bi.low - self.pre = None - self.next = None - self.fx = Chan_FX_TYPE.UNKNOWN - self.bi_list = [] - self.bi_list.append(start_bi) - self.has_fx_gap = False - def set_fx(self, fx): - self.fx = fx - def set_end_bi(self, bi): - self.end_bi = bi - def set_pre(self, sbi): - self.pre = sbi - def set_next(self, sbi): - self.next = sbi - def add_bi(self, bi): - self.bi_list.append(bi) - def check_fx(self): - if self.pre and self.next: - #print(self.pre.start_bi.start_time, self.start_bi.start_time, self.end_bi.end_time, self.next.start_bi.start_time, self.pre.high, self.high, self.next.high, self.pre.low, self.low, self.next.low, self.dir) - if self.high > self.pre.high and self.high > self.next.high: - self.fx = Chan_FX_TYPE.TOP - #print(self.start_bi.start_time, self.pre.start_bi.start_time, self.next.start_bi.start_time, self.fx) - if self.low > self.pre.high: - self.has_fx_gap = True - #print(self.start_bi.start_time, self.end_bi.end_time, self.pre.start_bi.start_time, self.next.start_bi.start_time, self.dir, self.has_fx_gap, self.fx) - return Chan_FX_TYPE.TOP - else: - if self.low < self.pre.low and self.low < self.next.low: - self.fx = Chan_FX_TYPE.BOTTOM - #print(self.start_bi.start_time, self.pre.start_bi.start_time, self.next.start_bi.start_time, self.fx) - if self.high < self.pre.low: - self.has_fx_gap = True - #print(self.start_bi.start_time, self.end_bi.end_time, self.pre.start_bi.start_time, self.next.start_bi.start_time, self.dir, self.has_fx_gap, self.fx) - return Chan_FX_TYPE.BOTTOM - return Chan_FX_TYPE.UNKNOWN - def check_seg_bi_broken(self): - broken = False - if self.fx == Chan_FX_TYPE.TOP: - if self.next.low < self.pre.high: - broken = True - elif self.fx == Chan_FX_TYPE.BOTTOM: - if self.next.high > self.pre.low: - broken = True - return broken - def check_bi_included(self, bi): - included = False - if self.high > bi.high: - # high大于,low小于,左包含 - if self.low < bi.low: - included = True - # high大于,low大于,不包含 - else: - # if self.low > bi.low - # high相等,右包含 - included = False - else: - included = False - # high小于,low大于,右包含 - #if self.low > bi.low: - #included = True - if included: - if self.pre: - if self.high > self.pre.high and self.low < self.pre.low: - included = True - if included: - self.add_bi(bi) - # gn>gn-1 - if self.dir == Chan_BI_DIR.DOWN: - # UP -> max(dn) - self.low = bi.low - else: - # DOWN -> min(gn) - self.high = bi.high - #self.print(bi, "Z") - #print(self.start_bi.start_time, bi.start_time, included) - return included \ No newline at end of file +"""兼容 shim — 请优先 from chanlun import ...""" +from chanlun.core.ChanSBI import * # noqa: F403 diff --git a/ChanSEG.py b/ChanSEG.py index 21f52cd..7ebabde 100644 --- a/ChanSEG.py +++ b/ChanSEG.py @@ -1,197 +1,2 @@ -import copy -from typing import Dict, Optional - -from ChanEnum import Chan_FX_TYPE, Chan_KLINE_DIR, Chan_SEG_DIR, Chan_BI_DIR, Chan_ZS_DIR -import ChanCTime -from ChanBI import ChanBI -from ChanBIZS import ChanBIZS -class ChanSEG(): - def __init__(self, start_bi: ChanBI, index, ddir=Chan_SEG_DIR.UP, pre_end_bi: ChanBI = None): - self.start_bi = start_bi - self.start_time = start_bi.start_time - self.end_time = None - self.end_bi = None - self.dir = ddir - self.low = 0 - self.high = 0 - if self.dir == Chan_SEG_DIR.UP and start_bi: - self.low = start_bi.low - else: - if start_bi: - self.high = start_bi.high - self.index = index - self.pre = None - self.next = None - self.bi_list = [] - self.bi_list.append(start_bi) - self.is_sure = False - self.sure_time = None - self.macd_hist = 0 - self.macd_div = 0 - self.start_bi.set_seg(self) - self.pre_end_bi = pre_end_bi - if self.pre_end_bi: - self.ini_seg() - def ini_seg(self): - next_bi = self.start_bi.next - for index in range(self.start_bi.index+1, self.pre_end_bi.index): - if next_bi: - self.bi_list.append(next_bi) - next_bi.set_seg(self) - next_bi = next_bi.next - def set_macdhist(self, macd_hist): - self.macd_hist = macd_hist - def set_macd_div(self, macd_div): - self.macd_div = macd_div - def set_end_bi(self, bi: ChanBI, sure_bi: ChanBI): - self.end_bi = bi - if bi and bi.is_sure: - if self.dir == Chan_SEG_DIR.UP: - self.high = bi.high - else: - self.low = bi.low - self.is_sure = True - self.end_time = bi.end_klc.end_time - if sure_bi.is_sure: - self.sure_time = sure_bi.sure_time - self.format_bi_list() - def pre_set_end_bi(self, bi: ChanBI): - self.end_bi = bi - if bi and bi.is_sure: - if self.dir == Chan_SEG_DIR.UP: - self.high = bi.high - else: - self.low = bi.low - self.end_time = bi.end_klc.end_time - self.format_bi_list() - def set_pre(self, seg): - self.pre = seg - def set_next(self, seg): - self.next = seg - def set_sure(self, sure_bi): - if sure_bi.is_sure: - self.sure_time = sure_bi.sure_time - self.is_sure = True - self.format_bi_list() - def format_bi_list(self): - self.bi_list = [] - self.bi_list.append(self.start_bi) - if self.end_bi: - next_bi = self.start_bi.next - for i in range(self.start_bi.index, self.end_bi.index): - if next_bi: - self.bi_list.append(next_bi) - next_bi.set_seg(self) - next_bi = next_bi.next - def add_bi(self, bi: ChanBI): - if len(self.bi_list) > 0: - self.bi_list.append(bi) - bi.set_seg(self) - self.end_time = bi.end_time - self.end_bi = bi - def cal_bi_zs(self): - zs_list = [] - if len(self.bi_list) > 3: - last_zs = None - if self.dir == Chan_SEG_DIR.UP: - for index in range(1, len(self.bi_list)): - bi = self.bi_list[index] - #print(bi.end_time, bi.next,"UP SEG BI ZS Index") - if bi.next == None or bi.next.next == None: - if last_zs and (bi.low > last_zs.zg or bi.high < last_zs.zd): - last_zs.set_end_bi(last_zs.bi_list[-1], last_zs.bi_list[-1].sure_time) - continue - bi2 = bi.next - bi3 = bi.next.next - if len(zs_list) == 0 or (last_zs and last_zs.is_sure): - if bi3.is_sure and bi3.index <= self.bi_list[-1].index and bi.check_bi_zs_overlap() and bi.dir == Chan_BI_DIR.DOWN: - zg = min(bi.high, bi2.high, bi3.high) - zd = max(bi.low, bi2.low, bi3.low) - gg = max(bi.high, bi2.high, bi3.high) - dd = min(bi.low, bi2.low, bi3.low) - zs = ChanBIZS(bi, len(zs_list), Chan_ZS_DIR.UP) - zs.set_zg(zg) - zs.set_zd(zd) - zs.set_gg(gg) - zs.set_dd(dd) - zs.add_bi(bi2) - zs.add_bi(bi3) - zs_list.append(zs) - last_zs = zs - else: - if bi.index > last_zs.bi_list[-1].index and bi.dir == Chan_BI_DIR.DOWN and bi.is_sure: - if bi.low > last_zs.zg or bi.high < last_zs.zd: - #print(bi.end_time, "UP SEG BI ZS End") - last_zs.set_end_bi(last_zs.bi_list[-1], last_zs.bi_list[-1].sure_time) - if bi3.is_sure and bi3.index <= self.bi_list[-1].index and bi.check_bi_zs_overlap() and bi.dir == Chan_BI_DIR.DOWN: - zg = min(bi.high, bi2.high, bi3.high) - zd = max(bi.low, bi2.low, bi3.low) - gg = max(bi.high, bi2.high, bi3.high) - dd = min(bi.low, bi2.low, bi3.low) - zs = ChanBIZS(bi, len(zs_list), Chan_ZS_DIR.UP) - zs.set_zg(zg) - zs.set_zd(zd) - zs.set_gg(gg) - zs.set_dd(dd) - zs.add_bi(bi2) - zs.add_bi(bi3) - zs_list.append(zs) - last_zs = zs - else: - last_zs.add_bi(bi.pre) - last_zs.add_bi(bi) - if index == len(self.bi_list) - 1 and last_zs and not last_zs.is_sure: - #print(bi.start_time, "BI", last_zs.is_sure) - last_zs.set_end_bi(last_zs.bi_list[-1], last_zs.bi_list[-1].sure_time) - else: - for index in range(1, len(self.bi_list)): - bi = self.bi_list[index] - if bi.next == None or bi.next.next == None: - if last_zs and (bi.low > last_zs.zg or bi.high < last_zs.zd): - last_zs.set_end_bi(last_zs.bi_list[-1], last_zs.bi_list[-1].sure_time) - continue - bi2 = bi.next - bi3 = bi.next.next - if len(zs_list) == 0 or (last_zs and last_zs.is_sure): - if bi3.is_sure and bi3.index <= self.bi_list[-1].index and bi.check_bi_zs_overlap() and bi.dir == Chan_BI_DIR.UP: - zg = min(bi.high, bi2.high, bi3.high) - zd = max(bi.low, bi2.low, bi3.low) - gg = max(bi.high, bi2.high, bi3.high) - dd = min(bi.low, bi2.low, bi3.low) - zs = ChanBIZS(bi, len(zs_list), Chan_ZS_DIR.DOWN) - zs.set_zg(zg) - zs.set_zd(zd) - zs.set_gg(gg) - zs.set_dd(dd) - zs.add_bi(bi2) - zs.add_bi(bi3) - zs_list.append(zs) - last_zs = zs - else: - if bi.index > last_zs.bi_list[-1].index and bi.dir == Chan_BI_DIR.UP and bi.is_sure: - if bi.low > last_zs.zg or bi.high < last_zs.zd: - last_zs.set_end_bi(last_zs.bi_list[-1], last_zs.bi_list[-1].sure_time) - if bi3.is_sure and bi3.index <= self.bi_list[-1].index and bi.check_bi_zs_overlap() and bi.dir == Chan_BI_DIR.UP: - zg = min(bi.high, bi2.high, bi3.high) - zd = max(bi.low, bi2.low, bi3.low) - gg = max(bi.high, bi2.high, bi3.high) - dd = min(bi.low, bi2.low, bi3.low) - zs = ChanBIZS(bi, len(zs_list), Chan_ZS_DIR.DOWN) - zs.set_zg(zg) - zs.set_zd(zd) - zs.set_gg(gg) - zs.set_dd(dd) - zs.add_bi(bi2) - zs.add_bi(bi3) - zs_list.append(zs) - last_zs = zs - else: - last_zs.add_bi(bi.pre) - last_zs.add_bi(bi) - if index == len(self.bi_list) - 1 and last_zs and not last_zs.is_sure: - #print(bi.start_time, "BI", last_zs.is_sure) - last_zs.set_end_bi(last_zs.bi_list[-1], last_zs.bi_list[-1].sure_time) - - #print(self.start_time, len(zs_list)) - #print(self.bi_list[-1].end_time, "end_bi") - return zs_list \ No newline at end of file +"""兼容 shim — 请优先 from chanlun import ...""" +from chanlun.core.ChanSEG import * # noqa: F403 diff --git a/ChanZS.py b/ChanZS.py index 4a54bed..83a2999 100644 --- a/ChanZS.py +++ b/ChanZS.py @@ -1,108 +1,2 @@ -from typing import Dict, Optional - -import ChanKLC, ChanSEG -import ChanCTime -from ChanEnum import Chan_ZS_DIR -# 中枢 -class ChanZS(): - def __init__(self, start_seg: ChanSEG, index, ddir: Chan_ZS_DIR): - self.start_klc = start_seg.start_bi.start_klc - self.start_time = self.start_klc.start_time - self.end_time = None - self.index = index - self.next = None - self.pre = None - self.start_seg = start_seg - self.seg_list = [] - self.seg_list.append(start_seg) - self.end_seg = None - self.last_bi_in = None - self.bi_out = None - self.is_sure = False - self.zg = 0 - self.zd = 0 - self.gg = 0 - self.dd = 0 - self.dir = ddir - self.sure_time = None - self.end_klc = None - self.bi_out_count = 0 - self.bi_out_list = [] - self.bi_out_seg_list = [] - self.bi_out_seg = None - self.is_extended = False - def set_last_bi_in(self, last_bi_in): - self.last_bi_in = last_bi_in - def set_bi_out(self, bi_out, bi_out_seg): - if bi_out: - #print(bi_out.start_klc.start_time, bi_out.sure_time, bi_out.dir, bi_out_seg.dir, len(self.bi_out_list)) - if len(self.bi_out_list) > 0: - last_bi = self.bi_out_list[-1] - if last_bi.index != bi_out.index: - self.bi_out_list.append(bi_out) - self.bi_out_seg_list.append(bi_out_seg) - else: - self.bi_out_list.append(bi_out) - self.bi_out_seg_list.append(bi_out_seg) - self.bi_out = bi_out - self.bi_out_seg = bi_out_seg - def set_end_klc(self, end_klc, sure_time, bi_out_count, seg): - self.end_klc = end_klc - self.set_end_time(end_klc.end_time) - self.is_sure = True - self.sure_time = sure_time - self.bi_out_count = bi_out_count - self.end_seg = seg - def set_end_seg(self, end_seg): - self.end_seg = end_seg - def set_pre(self, pre): - self.pre = pre - def set_next(self, next): - self.next = next - def set_end_time(self, end_time): - self.end_time = end_time - def add_klc(self, klc): - self.klc_list.append(klc) - def add_seg(self, seg): - self.seg_list.append(seg) - self.end_time = seg.end_time - self.end_seg = seg - def set_zg(self, zg): - self.zg = zg - def set_zd(self, zd): - self.zd = zd - def set_gg(self, gg): - self.gg = gg - def set_dd(self, dd): - self.dd = dd - def extend_zs(self, seg_list): - self.is_sure = False - self.end_seg = None - self.end_klc = None - self.sure_time = None - for seg in seg_list: - if seg.end_bi.high > self.gg: - self.set_gg(seg.end_bi.high) - if seg.end_bi.low < self.dd: - self.set_dd(seg.end_bi.low) - self.seg_list.append(seg) - self.is_extended = True - #print(self.start_time, "extend zs", seg_list[-1].end_time) - -# 大级别中枢:由多个区间重叠(扩张)的笔/线段中枢合并而成,用于显示更大级别的震荡区间 -class ChanZS_Big(): - def __init__(self, zs_list): - assert len(zs_list) >= 1 - self.zs_list = list(zs_list) - first = self.zs_list[0] - last = self.zs_list[-1] - self.start_time = first.start_time - self.end_time = last.end_time if last.end_time else None - self.start_klc = first.start_klc - self.end_klc = last.end_klc - # 大级别区间取并集:包住所有子中枢 - self.zd = min(zs.zd for zs in self.zs_list) - self.zg = max(zs.zg for zs in self.zs_list) - self.dd = min(zs.dd for zs in self.zs_list) - self.gg = max(zs.gg for zs in self.zs_list) - self.index = 0 # 由外部设置 \ No newline at end of file +"""兼容 shim — 请优先 from chanlun import ...""" +from chanlun.core.ChanZS import * # noqa: F403 diff --git a/ChanZone.py b/ChanZone.py index 2245d76..0afd973 100644 --- a/ChanZone.py +++ b/ChanZone.py @@ -1,566 +1,2 @@ -""" -结构价值区 (Structure Zone) 系统 - -将多时间周期的 Chan 中枢边界 (ZD/ZG/GG/DD) 和 EMA52 统一表示为带强度评分的价值区对象。 -""" - -from dataclasses import dataclass, field -from typing import List, Dict, Optional, Any -from datetime import datetime - - -# ============================================================ -# Dataclasses -# ============================================================ - -@dataclass -class RawZonePoint: - """内部中间结构:从 Chan 中枢提取的单个价格点""" - price: float - timeframe: str # '5m', '1h', '4h' 等 - structure_type: str # 'bi_zhongshu' | 'xd_zhongshu' | 'ema52' - boundary_type: str # 'ZD' | 'ZG' | 'GG' | 'DD' | 'EMA52' - source_zs_id: int # 来源 ZS 在列表中的 index(调试用) - is_sure: bool # 来源 ZS 是否已完成 - candle_time: Optional[str] = None # 来源 ZS 的 end_time(用于 recency 计算) - - -@dataclass -class StructureZone: - """统一的价值区对象""" - id: int - lower: float - upper: float - center: float # (lower + upper) / 2 - width_pct: float # (upper - lower) / center * 100 - zone_type: str # 'support' | 'resistance' | 'neutral' - timeframes: List[str] # 参与形成此区间的时间周期 - structure_types: List[str] # 参与形成的结构类型 - boundary_types: List[str] # 参与形成的边界类型 - overlap_count: int # 聚类中的原始点数 - touch_count: int # MVP: 等于 overlap_count - recency_score: float # 0.0 - 1.0, 1.0 = 最近 - ema52_distance_pct: float # 到最近 EMA52 的距离百分比 - ema52_aligned: bool # 是否有 EMA52 落在区间内 - strength_score: float # 0-100 综合评分 - confidence: float # 0.0 - 1.0 - first_seen: Optional[str] # 最早的 candle_time - last_seen: Optional[str] # 最晚的 candle_time - metadata: Dict[str, Any] = field(default_factory=dict) - - -@dataclass -class StructureZoneConfig: - """StructureZone 提取与评分配置""" - cluster_radius_pct: float = 0.5 # 价格聚类半径(百分比) - min_overlap_for_zone: int = 2 # 最少重叠点数才能形成区间 - max_zones: int = 20 # 返回的最大区间数 - recency_halflife_bars: int = 50 # recency 衰减半衰期(K线数) - zone_timeframes: List[str] = field(default_factory=lambda: ['4h', '1h', '30m', '15m', '5m']) - kl_lines_per_tf: int = 500 # 每个时间周期使用最近多少根K线 - structure_weights: Dict[str, float] = field(default_factory=lambda: { - 'bi_zhongshu': 1.0, # 笔中枢 — 最直接的价格行为 - 'xd_zhongshu': 0.8, # 线段中枢 — 较高级别但粒度较粗 - 'ema52': 0.4, # EMA — 趋势参考,弱于结构 - }) - - -# ============================================================ -# Extraction -# ============================================================ - -def extract_raw_points_from_tf_df( - tf_df_dict: Dict[str, Any], - ema_symbols: List[str], - config: StructureZoneConfig, -) -> List[RawZonePoint]: - """ - 从 ChanLun.tf_df_dict 中提取所有原始价格点。 - 仅处理 config.zone_timeframes 中存在的时间周期。 - """ - points: List[RawZonePoint] = [] - - for tf_name in config.zone_timeframes: - if tf_name not in tf_df_dict: - continue - - tf_df = tf_df_dict[tf_name] - - # 1. 笔中枢 (ChanBIZS) - try: - if hasattr(tf_df, 'seg_list') and tf_df.seg_list: - bi_zs_result = tf_df.cal_bi_zs(tf_df.seg_list) - if bi_zs_result: - _extract_from_zs_objects( - points, tf_name, 'bi_zhongshu', bi_zs_result, config.kl_lines_per_tf - ) - except Exception: - pass - - # 2. 线段中枢 (ChanZS) - try: - zs_list = getattr(tf_df, 'zs_list', None) - if zs_list: - _extract_from_zs_objects( - points, tf_name, 'xd_zhongshu', zs_list, config.kl_lines_per_tf - ) - except Exception: - pass - - # 3. EMA52 值 - for tf_name in config.zone_timeframes: - if tf_name in tf_df_dict: - try: - ema_val = tf_df_dict[tf_name].get_ema52() - if ema_val is not None and ema_val > 0: - points.append(RawZonePoint( - price=float(ema_val), - timeframe=tf_name, - structure_type='ema52', - boundary_type='EMA52', - source_zs_id=-1, - is_sure=True, - candle_time=None, - )) - except Exception: - pass - - return points - - -def _extract_from_zs_objects( - points: List[RawZonePoint], - tf_name: str, - structure_type: str, - zs_list, - kl_limit: int, -): - """从 ZS 链表中提取 ZD/ZG/GG/DD 点""" - count = 0 - node = zs_list - while hasattr(node, 'next'): - node = node.next - # 从链表头开始遍历 - head = zs_list - # 收集所有节点 - all_nodes = [] - cur = head - while cur is not None and hasattr(cur, 'next'): - all_nodes.append(cur) - cur = cur.next - # 只取最近 kl_limit 根K线内的 ZS - all_nodes = all_nodes[-kl_limit:] if len(all_nodes) > kl_limit else all_nodes - - for idx, zs in enumerate(all_nodes): - if not getattr(zs, 'is_sure', False): - continue - try: - zg = float(zs.zg) - zd = float(zs.zd) - gg = float(zs.gg) if getattr(zs, 'gg', 0) else zg - dd = float(zs.dd) if getattr(zs, 'dd', 0) else zd - end_time = str(zs.end_time) if hasattr(zs, 'end_time') and zs.end_time else None - except (ValueError, TypeError, AttributeError): - continue - - if zg <= 0 or zd <= 0: - continue - - zs_id = getattr(zs, 'index', idx) - points.append(RawZonePoint(price=zg, timeframe=tf_name, structure_type=structure_type, - boundary_type='ZG', source_zs_id=zs_id, is_sure=True, - candle_time=end_time)) - points.append(RawZonePoint(price=zd, timeframe=tf_name, structure_type=structure_type, - boundary_type='ZD', source_zs_id=zs_id, is_sure=True, - candle_time=end_time)) - points.append(RawZonePoint(price=gg, timeframe=tf_name, structure_type=structure_type, - boundary_type='GG', source_zs_id=zs_id, is_sure=True, - candle_time=end_time)) - points.append(RawZonePoint(price=dd, timeframe=tf_name, structure_type=structure_type, - boundary_type='DD', source_zs_id=zs_id, is_sure=True, - candle_time=end_time)) - - -def extract_raw_points_from_serialized( - analyses: Dict[str, Dict], - ema52_dict: Dict[str, Optional[float]], - config: StructureZoneConfig, -) -> List[RawZonePoint]: - """ - 从已序列化的分析结果中提取价格点(用于 web API,避免重复计算)。 - analyses: {'5m': {'zs_list': [...], 'bi_zs_list': [...]}, '15m': {...}, ...} - ema52_dict: {'5m': 123.45, '15m': None, ...} - """ - points: List[RawZonePoint] = [] - - for tf_name in config.zone_timeframes: - if tf_name not in analyses: - continue - - analysis = analyses[tf_name] - - # 笔中枢 - bi_zs_items = analysis.get('bi_zs_list', []) - for idx, zs in enumerate(bi_zs_items): - if not zs.get('is_sure', False): - continue - try: - zg = float(zs['zg']); zd = float(zs['zd']) - gg = float(zs.get('gg', zg)); dd = float(zs.get('dd', zd)) - end_time = zs.get('end_time') - except (ValueError, KeyError): - continue - if zg <= 0 or zd <= 0: - continue - points.append(RawZonePoint(price=zg, timeframe=tf_name, structure_type='bi_zhongshu', - boundary_type='ZG', source_zs_id=idx, is_sure=True, - candle_time=str(end_time) if end_time else None)) - points.append(RawZonePoint(price=zd, timeframe=tf_name, structure_type='bi_zhongshu', - boundary_type='ZD', source_zs_id=idx, is_sure=True, - candle_time=str(end_time) if end_time else None)) - points.append(RawZonePoint(price=gg, timeframe=tf_name, structure_type='bi_zhongshu', - boundary_type='GG', source_zs_id=idx, is_sure=True, - candle_time=str(end_time) if end_time else None)) - points.append(RawZonePoint(price=dd, timeframe=tf_name, structure_type='bi_zhongshu', - boundary_type='DD', source_zs_id=idx, is_sure=True, - candle_time=str(end_time) if end_time else None)) - - # 线段中枢 - zs_items = analysis.get('zs_list', []) - for idx, zs in enumerate(zs_items): - if not zs.get('is_sure', False): - continue - try: - zg = float(zs['zg']); zd = float(zs['zd']) - gg = float(zs.get('gg', zg)); dd = float(zs.get('dd', zd)) - end_time = zs.get('end_time') - except (ValueError, KeyError): - continue - if zg <= 0 or zd <= 0: - continue - points.append(RawZonePoint(price=zg, timeframe=tf_name, structure_type='xd_zhongshu', - boundary_type='ZG', source_zs_id=idx, is_sure=True, - candle_time=str(end_time) if end_time else None)) - points.append(RawZonePoint(price=zd, timeframe=tf_name, structure_type='xd_zhongshu', - boundary_type='ZD', source_zs_id=idx, is_sure=True, - candle_time=str(end_time) if end_time else None)) - points.append(RawZonePoint(price=gg, timeframe=tf_name, structure_type='xd_zhongshu', - boundary_type='GG', source_zs_id=idx, is_sure=True, - candle_time=str(end_time) if end_time else None)) - points.append(RawZonePoint(price=dd, timeframe=tf_name, structure_type='xd_zhongshu', - boundary_type='DD', source_zs_id=idx, is_sure=True, - candle_time=str(end_time) if end_time else None)) - - # EMA52 - for tf_name in config.zone_timeframes: - ema_val = ema52_dict.get(tf_name) - if ema_val is not None and ema_val > 0: - points.append(RawZonePoint( - price=float(ema_val), - timeframe=tf_name, - structure_type='ema52', - boundary_type='EMA52', - source_zs_id=-1, - is_sure=True, - candle_time=None, - )) - - return points - - -# ============================================================ -# Clustering -# ============================================================ - -def cluster_raw_points( - points: List[RawZonePoint], - config: StructureZoneConfig, -) -> List[List[RawZonePoint]]: - """ - 贪心单通聚类:将价格相近的 RawZonePoint 归为一组。 - 仅在 1D 价格轴上操作,O(n log n)。 - """ - if not points: - return [] - - sorted_points = sorted(points, key=lambda p: p.price) - clusters: List[List[RawZonePoint]] = [] - - for p in sorted_points: - placed = False - for cluster in reversed(clusters): - # 检查是否可以放入当前聚类(与聚类均价比较) - avg_price = sum(pt.price for pt in cluster) / len(cluster) - if abs(p.price - avg_price) / avg_price * 100 <= config.cluster_radius_pct: - cluster.append(p) - placed = True - break - if not placed: - clusters.append([p]) - - # 过滤点数不足的聚类 - return [c for c in clusters if len(c) >= config.min_overlap_for_zone] - - -# ============================================================ -# Scoring & Building -# ============================================================ - -def build_structure_zones( - clusters: List[List[RawZonePoint]], - current_price: float, - ema52_values: Dict[str, Optional[float]], - latest_candle_time: Optional[str], - config: StructureZoneConfig, -) -> List[StructureZone]: - """ - 从聚类构建 StructureZone 列表,计算所有字段和评分。 - """ - zones: List[StructureZone] = [] - - # 收集所有 EMA52 值 - ema_prices = [v for v in ema52_values.values() if v is not None and v > 0] - - for zone_id, cluster in enumerate(clusters): - prices = [p.price for p in cluster] - lower = min(prices) - upper = max(prices) - center = (lower + upper) / 2 - width_pct = (upper - lower) / center * 100 if center > 0 else 0.0 - - # 区间类型 - if upper < current_price: - zone_type = 'support' # 区间在当前价格下方 → 支撑 - elif lower > current_price: - zone_type = 'resistance' # 区间在当前价格上方 → 阻力 - else: - zone_type = 'neutral' # 区间跨越当前价格 - - timeframes = sorted(set(p.timeframe for p in cluster)) - structure_types = sorted(set(p.structure_type for p in cluster)) - boundary_types = sorted(set(p.boundary_type for p in cluster)) - overlap_count = len(cluster) - - # Recency - times = [p.candle_time for p in cluster if p.candle_time] - first_seen = min(times) if times else None - last_seen = max(times) if times else None - recency_score = _calc_recency(last_seen, latest_candle_time, config.recency_halflife_bars) - - # EMA52 alignment - ema52_distance_pct = 999.0 - ema52_aligned = False - if ema_prices: - distances = [abs(center - ep) / ep * 100 for ep in ema_prices] - ema52_distance_pct = round(min(distances), 2) - ema52_aligned = any(lower <= ep <= upper for ep in ema_prices) - - # Strength score - strength_score = _calc_strength(cluster, config, recency_score, ema52_aligned, ema52_distance_pct, width_pct) - - # Confidence - confidence = _calc_confidence(overlap_count, len(timeframes), cluster) - - zones.append(StructureZone( - id=zone_id + 1, - lower=round(lower, 2), - upper=round(upper, 2), - center=round(center, 2), - width_pct=round(width_pct, 2), - zone_type=zone_type, - timeframes=timeframes, - structure_types=structure_types, - boundary_types=boundary_types, - overlap_count=overlap_count, - touch_count=overlap_count, # MVP: 等于 overlap_count - recency_score=round(recency_score, 3), - ema52_distance_pct=ema52_distance_pct, - ema52_aligned=ema52_aligned, - strength_score=round(strength_score, 1), - confidence=round(confidence, 2), - first_seen=first_seen, - last_seen=last_seen, - )) - - # 按强度降序排列 - zones.sort(key=lambda z: z.strength_score, reverse=True) - - # 截断 - if config.max_zones > 0 and len(zones) > config.max_zones: - zones = zones[:config.max_zones] - - return zones - - -def _calc_recency( - last_seen: Optional[str], - latest_time: Optional[str], - halflife_bars: int, -) -> float: - """计算 recency 分数:越近越高""" - if not last_seen or not latest_time: - return 0.5 - - try: - # 尝试解析 ISO 格式时间 - from dateutil import parser - t_last = parser.parse(last_seen) - t_latest = parser.parse(latest_time) - offset_seconds = (t_latest - t_last).total_seconds() - if offset_seconds < 0: - return 1.0 - # 假设每根K线平均 5 分钟 - bar_seconds = 300 - offset_bars = offset_seconds / bar_seconds - # 指数衰减: 2 ^ (-offset / halflife) - score = 2.0 ** (-offset_bars / halflife_bars) - return float(score) - except Exception: - return 0.5 - - -def _calc_strength( - cluster: List[RawZonePoint], - config: StructureZoneConfig, - recency_score: float, - ema52_aligned: bool, - ema52_distance_pct: float, - width_pct: float, -) -> float: - """计算综合强度评分 (0-100)""" - - # 组件 1: 结构类型多样性 (0-40) - structure_type_counts: Dict[str, int] = {} - for p in cluster: - structure_type_counts[p.structure_type] = structure_type_counts.get(p.structure_type, 0) + 1 - total = sum(structure_type_counts.values()) - structure_score = 0.0 - for st, count in structure_type_counts.items(): - weight = config.structure_weights.get(st, 0.5) - structure_score += weight * count - structure_score = min(structure_score / max(1, total), 1.0) - c1 = structure_score * 40 - - # 组件 2: 多周期确认 (0-25) - tf_set = set(p.timeframe for p in cluster) - tf_diversity = len(tf_set) - c2 = min(tf_diversity / 5, 1.0) * 25 - - # 组件 3: 区间紧密度 (0-15) — 越窄越强 - tightness = max(0.0, 1.0 - (width_pct / 3.0)) - c3 = tightness * 15 - - # 组件 4: Recency (0-10) - c4 = recency_score * 10 - - # 组件 5: EMA52 共振 (0-10) - if ema52_aligned: - ema_proximity = max(0.0, 1.0 - (ema52_distance_pct / 2.0)) - c5 = ema_proximity * 10 - else: - c5 = 0.0 - - return c1 + c2 + c3 + c4 + c5 - - -def _calc_confidence( - overlap_count: int, - tf_count: int, - cluster: List[RawZonePoint], -) -> float: - """计算置信度 (0-1)""" - base = min(overlap_count / 6.0, 0.85) - # 多周期加分 - tf_bonus = min(tf_count / 5.0, 0.1) - # 是否所有点都来自 sure 的 ZS - all_sure = all(p.is_sure for p in cluster) - sure_bonus = 0.05 if all_sure else 0.0 - return min(base + tf_bonus + sure_bonus, 1.0) - - -# ============================================================ -# Top-level pipeline -# ============================================================ - -def analyze_structure_zones( - tf_df_dict: Dict[str, Any], - ema_symbols: List[str], - current_price: Optional[float] = None, - config: Optional[StructureZoneConfig] = None, -) -> List[StructureZone]: - """ - 一站式分析:提取 → 聚类 → 评分 → 返回排序后的 StructureZone 列表。 - """ - if config is None: - config = StructureZoneConfig() - - # 提取 - raw_points = extract_raw_points_from_tf_df(tf_df_dict, ema_symbols, config) - - if not raw_points: - return [] - - # 获取当前价格 - if current_price is None: - for tf_name in config.zone_timeframes: - if tf_name in tf_df_dict: - try: - ema_val = tf_df_dict[tf_name].get_ema52() - if ema_val and ema_val > 0: - current_price = float(ema_val) - break - except Exception: - pass - if current_price is None: - current_price = 0.0 - - # EMA52 值 - ema52_values = {} - for tf_name in config.zone_timeframes: - if tf_name in tf_df_dict: - try: - ema52_values[tf_name] = tf_df_dict[tf_name].get_ema52() - except Exception: - ema52_values[tf_name] = None - - # 最晚时间 - latest_time = None - times = [p.candle_time for p in raw_points if p.candle_time] - if times: - latest_time = max(times) - - # 聚类 - clusters = cluster_raw_points(raw_points, config) - - # 构建 & 评分 - return build_structure_zones(clusters, current_price, ema52_values, latest_time, config) - - -def analyze_structure_zones_from_serialized( - analyses: Dict[str, Dict], - ema52_dict: Dict[str, Optional[float]], - current_price: float, - config: Optional[StructureZoneConfig] = None, -) -> List[StructureZone]: - """ - 从已序列化的分析结果构建 StructureZone(用于 web API)。 - """ - if config is None: - config = StructureZoneConfig() - - raw_points = extract_raw_points_from_serialized(analyses, ema52_dict, config) - - if not raw_points: - return [] - - # 最晚时间 - latest_time = None - times = [p.candle_time for p in raw_points if p.candle_time] - if times: - latest_time = max(times) - - # EMA52 值(用于 alignment 检测) - ema_values = {tf: v for tf, v in ema52_dict.items() if v is not None and v > 0} - - clusters = cluster_raw_points(raw_points, config) - return build_structure_zones(clusters, current_price, ema_values, latest_time, config) +"""兼容 shim — 请优先 from chanlun import ...""" +from chanlun.analysis.ChanZone import * # noqa: F403 diff --git a/Chan_FX_Box.py b/Chan_FX_Box.py index 8a9d9ec..2fe5b57 100644 --- a/Chan_FX_Box.py +++ b/Chan_FX_Box.py @@ -1,7 +1,2 @@ -class Chan_FX_Box(): - def __init__(self, start_time, end_time, high, low): - self.start_time = start_time - self.end_time = end_time - self.high = high - self.low = low - \ No newline at end of file +"""兼容 shim — 请优先 from chanlun import ...""" +from chanlun.core.Chan_FX_Box import * # noqa: F403 diff --git a/Find_Trend.py b/Find_Trend.py index 8d392a3..1fd6779 100644 --- a/Find_Trend.py +++ b/Find_Trend.py @@ -1,448 +1,2 @@ -import ccxt -import pandas as pd -import numpy as np -import mplfinance as mpf -from talib import MACD, SMA -from datetime import datetime, timedelta -import logging -import datetime as dt - -# Configure logging -logging.basicConfig( - filename='chanlun_trading.log', - level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s' -) - -# Configuration (user to modify) -BINANCE_API_KEY = 'your_api_key' # Replace with your Binance API key -BINANCE_API_SECRET = 'your_api_secret' # Replace with your Binance API secret -SIMULATION_MODE = True # Set to False for live trading - -# 1. Fetch K-line data from Binance (multi-timeframe support) -def fetch_binance_data(symbol='BTC/USDT', timeframe='5m', limit=500): - try: - exchange = ccxt.binance({ - 'apiKey': BINANCE_API_KEY if not SIMULATION_MODE else '', - 'secret': BINANCE_API_SECRET if not SIMULATION_MODE else '', - 'enableRateLimit': True, - 'options': {'defaultType': 'spot'} - }) - since = exchange.parse8601((datetime.now(dt.UTC) - timedelta(days=7)).isoformat()) - ohlcv = exchange.fetch_ohlcv(symbol, timeframe, since, limit) - df = pd.DataFrame(ohlcv, columns=['Date', 'Open', 'High', 'Low', 'Close', 'Volume']) - df['Date'] = pd.to_datetime(df['Date'], unit='ms') - df.set_index('Date', inplace=True) - logging.info(f"Fetched {len(df)} K-lines for {symbol} ({timeframe})") - return df - except Exception as e: - logging.error(f"Failed to fetch data: {e}") - raise - -# 2. K-line merging (vectorized) -def merge_kline(df): - try: - df = df.copy() - merged_data = [] - trend = np.sign(df['Close'].diff().shift(-1)) # 1: up, -1: down, 0: neutral - - # Detect inclusion - is_included = ((df['High'].shift(-1) <= df['High']) & (df['Low'].shift(-1) >= df['Low'])) | \ - ((df['High'].shift(-1) >= df['High']) & (df['Low'].shift(-1) <= df['Low'])) - - i = 0 - while i < len(df) - 1: - if is_included.iloc[i]: - current_k = df.iloc[i] - next_k = df.iloc[i + 1] - high = max(current_k['High'], next_k['High']) - low = min(current_k['Low'], next_k['Low']) - open_price = current_k['Open'] - close_price = next_k['Close'] if trend.iloc[i] >= 0 else next_k['Close'] - volume = current_k['Volume'] + next_k['Volume'] - - merged_data.append({ - 'Date': next_k.name, - 'Open': open_price, - 'High': high, - 'Low': low, - 'Close': close_price, - 'Volume': volume - }) - i += 2 - else: - current_k = df.iloc[i] - merged_data.append({ - 'Date': current_k.name, - 'Open': current_k['Open'], - 'High': current_k['High'], - 'Low': current_k['Low'], - 'Close': current_k['Close'], - 'Volume': current_k['Volume'] - }) - i += 1 - - if i == len(df) - 1: - last_k = df.iloc[i] - merged_data.append({ - 'Date': last_k.name, - 'Open': last_k['Open'], - 'High': last_k['High'], - 'Low': last_k['Low'], - 'Close': last_k['Close'], - 'Volume': last_k['Volume'] - }) - - merged_df = pd.DataFrame(merged_data) - merged_df['Date'] = pd.to_datetime(merged_df['Date']) - merged_df.set_index('Date', inplace=True) - logging.info(f"Merged K-lines: {len(df)} -> {len(merged_df)}") - return merged_df - except Exception as e: - logging.error(f"K-line merging failed: {e}") - raise - -# 3. Detect fractals (vectorized) -def detect_fractals(df): - try: - df = df.copy() - df['is_top'] = (df['High'] > df['High'].shift(1)) & (df['High'] > df['High'].shift(-1)) & \ - (df['High'] > df['High'].shift(2)) & (df['High'] > df['High'].shift(-2)) - df['is_bottom'] = (df['Low'] < df['Low'].shift(1)) & (df['Low'] < df['Low'].shift(-1)) & \ - (df['Low'] < df['Low'].shift(2)) & (df['Low'] < df['Low'].shift(-2)) - df['is_top'] = df['is_top'].fillna(False) - df['is_bottom'] = df['is_bottom'].fillna(False) - logging.info(f"Detected {df['is_top'].sum()} top fractals and {df['is_bottom'].sum()} bottom fractals") - return df - except Exception as e: - logging.error(f"Fractal detection failed: {e}") - raise - -# 4. Detect strokes -def detect_strokes(df): - try: - strokes = [] - last_fractal = None - last_price = None - last_index = None - - for i in range(len(df)): - if df['is_top'].iloc[i] or df['is_bottom'].iloc[i]: - current_fractal = 'top' if df['is_top'].iloc[i] else 'bottom' - current_price = df['High'].iloc[i] if current_fractal == 'top' else df['Low'].iloc[i] - - if last_fractal is None: - last_fractal = current_fractal - last_price = current_price - last_index = df.index[i] - continue - - if (last_fractal == 'top' and current_fractal == 'bottom' and current_price < last_price) or \ - (last_fractal == 'bottom' and current_fractal == 'top' and current_price > last_price): - strokes.append({ - 'start_time': last_index, - 'end_time': df.index[i], - 'start_price': last_price, - 'end_price': current_price, - 'type': 'down' if current_fractal == 'bottom' else 'up', - 'volume': df['Volume'].loc[last_index:df.index[i]].sum() - }) - - last_fractal = current_fractal - last_price = current_price - last_index = df.index[i] - - logging.info(f"Detected {len(strokes)} strokes") - return strokes - except Exception as e: - logging.error(f"Stroke detection failed: {e}") - raise - -# 5. Detect segments -def detect_segments(strokes): - try: - segments = [] - if len(strokes) < 3: - return segments - - i = 0 - while i < len(strokes) - 2: - stroke1, stroke2, stroke3 = strokes[i], strokes[i+1], strokes[i+2] - - if stroke1['type'] == 'up' and stroke2['type'] == 'down' and stroke3['type'] == 'up': - if stroke3['end_price'] > stroke1['end_price']: - segments.append({ - 'start_time': stroke1['start_time'], - 'end_time': stroke3['end_time'], - 'start_price': stroke1['start_price'], - 'end_price': stroke3['end_price'], - 'type': 'up' - }) - i += 3 - else: - i += 1 - elif stroke1['type'] == 'down' and stroke2['type'] == 'up' and stroke3['type'] == 'down': - if stroke3['end_price'] < stroke1['end_price']: - segments.append({ - 'start_time': stroke1['start_time'], - 'end_time': stroke3['end_time'], - 'start_price': stroke1['start_price'], - 'end_price': stroke3['end_price'], - 'type': 'down' - }) - i += 3 - else: - i += 1 - else: - i += 1 - - logging.info(f"Detected {len(segments)} segments") - return segments - except Exception as e: - logging.error(f"Segment detection failed: {e}") - raise - -# 6. Detect pivots (midlines) -def detect_pivots(strokes): - try: - pivots = [] - if len(strokes) < 3: - return pivots - - for i in range(len(strokes) - 2): - s1, s2, s3 = strokes[i:i+3] - high = min(s1['start_price'], s1['end_price'], s2['start_price'], s2['end_price'], - s3['start_price'], s3['end_price']) - low = max(s1['start_price'], s1['end_price'], s2['start_price'], s2['end_price'], - s3['start_price'], s3['end_price']) - - if high > low: - pivots.append({ - 'start_time': s1['start_time'], - 'end_time': s3['end_time'], - 'high': high, - 'low': low - }) - - logging.info(f"Detected {len(pivots)} pivots") - return pivots - except Exception as e: - logging.error(f"Pivot detection failed: {e}") - raise - -# 7. Analyze higher timeframe (30m) -def analyze_higher_timeframe(df_30m): - try: - df_30m = detect_fractals(df_30m) - strokes_30m = detect_strokes(df_30m) - - if not strokes_30m: - return 'neutral' - - last_stroke = strokes_30m[-1] - logging.info(f"30m trend: {last_stroke['type']}") - return last_stroke['type'] - except Exception as e: - logging.error(f"Higher timeframe analysis failed: {e}") - raise - -# 8. Back-divergence detection (enhanced) -def detect_back_divergence(df, strokes, higher_trend): - try: - macd, signal, hist = MACD(df['Close'], fastperiod=12, slowperiod=26, signalperiod=9) - sma20 = SMA(df['Close'], timeperiod=20) - df['macd'] = macd - df['hist'] = hist - df['sma20'] = sma20 - df['buy_signal'] = False - df['sell_signal'] = False - - stroke_metrics = [] - for stroke in strokes: - start_idx = df.index.get_loc(stroke['start_time']) - end_idx = df.index.get_loc(stroke['end_time']) - hist_segment = df['hist'].iloc[start_idx:end_idx+1] - price_change = abs(stroke['end_price'] - stroke['start_price']) - hist_area = sum(abs(h) for h in hist_segment if not np.isnan(h)) - volume = stroke['volume'] - stroke_metrics.append({ - 'start_time': stroke['start_time'], - 'end_time': stroke['end_time'], - 'type': stroke['type'], - 'price_change': price_change, - 'hist_area': hist_area, - 'volume': volume - }) - - for i in range(2, len(stroke_metrics)): - current_stroke = stroke_metrics[i] - prev_stroke = stroke_metrics[i-2] - - if current_stroke['type'] != prev_stroke['type']: - continue - - current_end_idx = df.index.get_loc(current_stroke['end_time']) - - # Uptrend back-divergence (sell signal) - if current_stroke['type'] == 'up': - price_increase = df['High'].loc[current_stroke['end_time']] > df['High'].loc[prev_stroke['end_time']] - hist_decrease = current_stroke['hist_area'] < prev_stroke['hist_area'] - volume_decrease = current_stroke['volume'] < prev_stroke['volume'] - is_top_fractal = df['is_top'].loc[current_stroke['end_time']] - hist_positive = df['hist'].iloc[current_end_idx] > 0 or \ - (df['hist'].iloc[current_end_idx] < 0 and df['hist'].iloc[current_end_idx-1] > 0) - sma_trend = df['Close'].iloc[current_end_idx] > df['sma20'].iloc[current_end_idx] - trend_match = higher_trend in ['up', 'neutral'] - - if price_increase and hist_decrease and volume_decrease and is_top_fractal and \ - hist_positive and sma_trend and trend_match: - df.loc[df.index[current_end_idx], 'sell_signal'] = True - - # Downtrend back-divergence (buy signal) - elif current_stroke['type'] == 'down': - price_decrease = df['Low'].loc[current_stroke['end_time']] < df['Low'].loc[prev_stroke['end_time']] - hist_decrease = current_stroke['hist_area'] < prev_stroke['hist_area'] - volume_decrease = current_stroke['volume'] < prev_stroke['volume'] - is_bottom_fractal = df['is_bottom'].loc[current_stroke['end_time']] - hist_negative = df['hist'].iloc[current_end_idx] < 0 or \ - (df['hist'].iloc[current_end_idx] > 0 and df['hist'].iloc[current_end_idx-1] < 0) - sma_trend = df['Close'].iloc[current_end_idx] < df['sma20'].iloc[current_end_idx] - trend_match = higher_trend in ['down', 'neutral'] - - if price_decrease and hist_decrease and volume_decrease and is_bottom_fractal and \ - hist_negative and sma_trend and trend_match: - df.loc[df.index[current_end_idx], 'buy_signal'] = True - - logging.info(f"Detected {df['buy_signal'].sum()} buy signals and {df['sell_signal'].sum()} sell signals") - return df - except Exception as e: - logging.error(f"Back-divergence detection failed: {e}") - raise - -# 9. Execute trade -def execute_trade(exchange, symbol, signal, amount=0.001): - try: - if SIMULATION_MODE: - msg = f"[SIMULATION] {'Buy' if signal == 'buy' else 'Sell'} {amount} {symbol} at {datetime.now(dt.UTC)}" - print(msg) - logging.info(msg) - return - - if signal == 'buy': - order = exchange.create_market_buy_order(symbol, amount) - msg = f"Buy order executed: {order}" - print(msg) - logging.info(msg) - elif signal == 'sell': - order = exchange.create_market_sell_order(symbol, amount) - msg = f"Sell order executed: {order}" - print(msg) - logging.info(msg) - except Exception as e: - msg = f"Trade execution failed: {e}" - print(msg) - logging.error(msg) - -# 10. Plot chart -def plot_chart(df, strokes, segments, pivots): - try: - # Initialize additional plots - apds = [] - alines = [] # For line segments - - # Plot strokes as line segments - for stroke in strokes: - alines.append([(stroke['start_time'], stroke['start_price']), - (stroke['end_time'], stroke['end_price'])]) - - # Plot segments as line segments - for segment in segments: - alines.append([(segment['start_time'], segment['start_price']), - (segment['end_time'], segment['end_price'])]) - - # Plot pivots as horizontal lines - for pivot in pivots: - alines.append([(pivot['start_time'], pivot['high']), - (pivot['end_time'], pivot['high'])]) - alines.append([(pivot['start_time'], pivot['low']), - (pivot['end_time'], pivot['low'])]) - - # Add alines to plot (single color for simplicity, can customize) - if alines: - apds.append(mpf.make_addplot( - None, # No y-data needed for alines - alines=alines, - type='line', - color=['blue' if i < len(strokes) else 'purple' if i < len(strokes) + len(segments) else 'orange' - for i in range(len(alines))], - linestyle=['--' if i < len(strokes) else '-' if i < len(strokes) + len(segments) else ':' - for i in range(len(alines))] - )) - - # Plot buy/sell signals - buy_signals = df[df['buy_signal']]['Close'] - sell_signals = df[df['sell_signal']]['Close'] - apds.append(mpf.make_addplot(buy_signals, type='scatter', markersize=100, marker='^', color='green')) - apds.append(mpf.make_addplot(sell_signals, type='scatter', markersize=100, marker='v', color='red')) - - # Plot K-line chart - mpf.plot(df, type='candle', addplot=apds, title='Chanlun Advanced Analysis', style='yahoo') - logging.info("Chart plotted successfully") - except Exception as e: - logging.error(f"Chart plotting failed: {e}") - raise - -# 11. Main function -def main(): - try: - # Initialize exchange - exchange = ccxt.binance({ - 'apiKey': BINANCE_API_KEY if not SIMULATION_MODE else '', - 'secret': BINANCE_API_SECRET if not SIMULATION_MODE else '', - 'enableRateLimit': True, - 'options': {'defaultType': 'spot'} - }) - - # Fetch data - df_5m = fetch_binance_data(symbol='BTC/USDT', timeframe='5m', limit=500) - df_30m = fetch_binance_data(symbol='BTC/USDT', timeframe='30m', limit=200) - - # Merge 5m K-lines - df_5m = merge_kline(df_5m) - - # Detect fractals, strokes, segments, pivots - df_5m = detect_fractals(df_5m) - strokes = detect_strokes(df_5m) - segments = detect_segments(strokes) - pivots = detect_pivots(strokes) - - # Analyze 30m trend - higher_trend = analyze_higher_timeframe(df_30m) - print(f"30m Trend: {higher_trend}") - - # Detect back-divergence - df_5m = detect_back_divergence(df_5m, strokes, higher_trend) - - # Plot chart - plot_chart(df_5m, strokes, segments, pivots) - - # Output and execute trades - print("Buy Signals:") - buy_signals = df_5m[df_5m['buy_signal']][['Close']] - print(buy_signals) - for idx, row in buy_signals.iterrows(): - execute_trade(exchange, 'BTC/USDT', 'buy', amount=0.001) - - print("Sell Signals:") - sell_signals = df_5m[df_5m['sell_signal']][['Close']] - print(sell_signals) - for idx, row in sell_signals.iterrows(): - execute_trade(exchange, 'BTC/USDT', 'sell', amount=0.001) - - logging.info("Main function completed successfully") - except Exception as e: - logging.error(f"Main function failed: {e}") - raise - -if __name__ == "__main__": - main() \ No newline at end of file +"""兼容 shim — 请优先 from chanlun import ...""" +from chanlun.analysis.Find_Trend import * # noqa: F403 diff --git a/TF_DF.py b/TF_DF.py index 955a0b5..ace07a2 100644 --- a/TF_DF.py +++ b/TF_DF.py @@ -1,2583 +1,2 @@ -from datetime import timedelta -from pandas import DataFrame -from ChanEnum import Chan_FX_TYPE, Chan_KLINE_DIR, Chan_BI_DIR, Chan_SEG_DIR, Chan_ZS_DIR, Chan_BSP_DIR, Chan_BSP_TYPE, Chan_KLC_FX, Chan_PRICE_TREND, Chan_KLU_PATTERN, Chan_K_DIR, Chan_KLC_STATE -from ChanKLU import ChanKLU -from ChanKLC import ChanKLC -from ChanBI import ChanBI -from ChanSBI import ChanSBI -from ChanSEG import ChanSEG -from ChanZS import ChanZS, ChanZS_Big -from ChanBIZS import ChanBIZS -from ChanBSP import ChanBSP -import talib.abstract as ta -import pandas as pd -from technical.util import resample_to_interval -from decimal import Decimal -import numpy as np -from ChanMACD import ChanMACD - -class TF_DF(): - def __init__(self, df=None, interval=0, timeframe=None): - if df is not None: - self.init_TF_DF(df, interval, timeframe) - def init_TF_DF(self, df, interval, timeframe): - self.timeframe = timeframe - self.interval = interval - # 检查 DataFrame 是否为空或没有 date 列 - if df is None or df.empty: - raise ValueError(f"DataFrame for {timeframe} is empty. Please download data first.") - if 'date' not in df.columns: - raise ValueError(f"DataFrame for {timeframe} missing 'date' column. Columns: {df.columns.tolist()}") - # interval=1 时不需要重采样 - if interval == 1: - self.dataframe = df.copy() - else: - self.dataframe = resample_to_interval(df, interval) - #print(self.timeframe, len(self.dataframe)) - self.dataframe = self.add_indicators(self.dataframe) - self.klu_list = [] - self.klc_list = [] - self.bi_list = [] - self.zs_list = [] - self.bsp_list = [] - self.seg_list = [] - self.klc_fx_list = [] - self.klu_list = self.cal_kl_data(self.dataframe) - self.klc_list = self.get_klc_list(self.klu_list) - self.bi_list = self.cal_bi_list(self.klc_list) - self.seg_list = self.get_seg_list(self.bi_list) - self.zs_list = self.get_zs_list(self.bi_list, self.seg_list) - self.big_zs_list = self.get_big_zs_list(self.zs_list) - self.chanmacd = ChanMACD(self.klu_list) - self.klu_list = self.chanmacd.cal_macd_state() - - - def get_ema52(self, index=-1): - if self.klu_list: - ema52_value = self.klu_list[index].ema52 - # 处理NaN值 - if pd.isna(ema52_value) or ema52_value is None: - return None - return float(ema52_value) - return None - def get_ema24(self, index=-1): - if self.klu_list: - ema24_value = self.klu_list[index].ema24 - # 处理NaN值 - if pd.isna(ema24_value) or ema24_value is None: - return None - return float(ema24_value) - return None - def get_current_klc(self): - if len(self.klc_list) > 0: - return self.klc_list[-2] - return None - def add_indicators(self, df): - fast = 26 - slow = 52 - period = 9 - macd = ta.MACD(df, fastperiod=fast, slowperiod=slow, signalperiod=period) - bb365 = ta.BBANDS(df, timeperiod=365, nbdevup=3.0, nbdevdn=3.0, matype=0) - bb120 = ta.BBANDS(df, timeperiod=120, nbdevup=3.0, nbdevdn=3.0, matype=0) - bb30 = ta.BBANDS(df, timeperiod=41, nbdevup=2.3, nbdevdn=2.3, matype=0) - bb302 = ta.BBANDS(df, timeperiod=41, nbdevup=2.0, nbdevdn=2.0, matype=0) - bb30 = ta.BBANDS(df, timeperiod=20, nbdevup=2.0, nbdevdn=2.0, matype=0) - bb302 = ta.BBANDS(df, timeperiod=20, nbdevup=2.0, nbdevdn=2.0, matype=0) - bb2633 = ta.BBANDS(df, timeperiod=26, nbdevup=3.0, nbdevdn=3.0, matype=0) - # 计算布林带中轨(移动平均线) - bb30_middle = ta.SMA(df, timeperiod=90) - - # 手动计算布林带 %B 指标 (BBP) - # %B = (Price - Lower Band) / (Upper Band - Lower Band) - bbp365 = (df['close'] - bb365['lowerband']) / (bb365['upperband'] - bb365['lowerband']) - bbp120 = (df['close'] - bb120['lowerband']) / (bb120['upperband'] - bb120['lowerband']) - bbp30 = (df['close'] - bb30['lowerband']) / (bb30['upperband'] - bb30['lowerband']) - bbp302 = (df['close'] - bb302['lowerband']) / (bb302['upperband'] - bb302['lowerband']) - bbp2633 = (df['close'] - bb2633['lowerband']) / (bb2633['upperband'] - bb2633['lowerband']) - df['bb2633upper'] = bb2633['upperband'] - df['bb2633lower'] = bb2633['lowerband'] - df['bbp2633'] = bbp2633 - df['bb2633middle'] = bb2633['middleband'] - df['atr'] = ta.ATR(df, timeperiod=14) - df['bbup365'] = bb365['upperband'] - df['bblow365'] = bb365['lowerband'] - df['bbp365'] = bbp365 - df['bbup120'] = bb120['upperband'] - df['bblow120'] = bb120['lowerband'] - df['bbp120'] = bbp120 - df['bbup30'] = bb30['upperband'] - df['bblow30'] = bb30['lowerband'] - df['bbmiddle30'] = bb30_middle # 添加bb30中轨 - df['bbp30'] = bbp30 - df['bbup302'] = bb302['upperband'] - df['bblow302'] = bb302['lowerband'] - df['bbp302'] = bbp302 - df['macd'] = macd['macd'] - df['macdsignal'] = macd['macdsignal'] - df['macdhist'] = macd['macdhist'] - df['ema5'] = ta.EMA(df, timeperiod=5) - df['ema10'] = ta.EMA(df, timeperiod=10) - df['ema24'] = ta.EMA(df, timeperiod=24) - df['ema52'] = ta.EMA(df, timeperiod=52) - df['ema104'] = ta.EMA(df, timeperiod=104) - df['ema156'] = ta.EMA(df, timeperiod=156) - df['ema208'] = ta.EMA(df, timeperiod=208) - df['ema26'] = ta.EMA(df, timeperiod=26) - df['ema13'] = ta.EMA(df, timeperiod=13) - df['ema7'] = ta.EMA(df, timeperiod=7) - df['rsi'] = ta.RSI(df, timeperiod=14) - df['volume_ratio'] = self.cal_volume_ratio(df) - return df - def get_klu_state(self, dataframe): - klc_list = self.get_klc_list(self.get_klu_list(dataframe)) - bi_list = self.cal_bi_list(klc_list) - klu_state_list = [] - klc_index = 0 - for index in range(0, len(dataframe)): - if klc_index == len(klc_list): - klc_index = len(klc_list) - 1 - klc = klc_list[klc_index] - if klc.end_klu and klc.end_klu.idx == index: - if klc.klc_state == Chan_KLC_STATE.S10: - klu_state_list.append("10") - #print(klc.end_time, klc.klc_fx_type) - elif klc.klc_state == Chan_KLC_STATE.S_10: - klu_state_list.append("-10") - #print(klc.end_time, klc.klc_fx_type) - elif klc.klc_state == Chan_KLC_STATE.S11: - klu_state_list.append("11") - #print(klc.end_time, klc.klc_fx_type) - elif klc.klc_state == Chan_KLC_STATE.S_11: - klu_state_list.append("-11") - #print(klc.end_time, klc.klc_fx_type) - else: - klu_state_list.append("00") - klc_index += 1 - else: - klu_state_list.append("00") - print(klu_state_list[:20]) - return klu_state_list - - def get_bsp_state(self, dataframe): - klu_list = self.get_klu_list(dataframe) - klc_list = self.get_klc_list(klu_list) - bi_list = self.cal_bi_list(klc_list) - seg_list = self.get_seg_list(bi_list) - bi_zs_list = self.cal_bi_zs(seg_list) - bsp_list = self.find_all_bsp(bi_list, bi_zs_list) - bsp_state_list = [0] * len(dataframe) - klc_index = 0 - for index in range(0, len(dataframe)): - if klc_index == len(klc_list): - klc_index = len(klc_list) - 1 - klc = klc_list[klc_index] - if klc.end_klu and klc.end_klu.idx == index: - if klc.klc_fx_type == Chan_KLC_FX.TOP2: - bi = klc.bi.pre - if bi and bi.is_sure and bi.end_klc.bsp_type == Chan_BSP_TYPE.B3: - # 第三类买点 - bsp_state_list[index] = -1 - #print(klc.end_time, "B3") - else: - bsp_state_list[index] = 0 - elif klc.klc_fx_type == Chan_KLC_FX.BOTTOM2: - bi = klc.bi.pre - if bi and bi.is_sure and bi.end_klc.bsp_type == Chan_BSP_TYPE.S3: - # 第三类卖点 - bsp_state_list[index] = 1 - #print(klc.end_time, "S3") - else: - bsp_state_list[index] = 0 - klc_index += 1 - else: - bsp_state_list[index] = 0 - return bsp_state_list - def get_ema_state(self, dataframe): - klu_list = self.get_klu_list(dataframe) - klc_list = self.get_klc_list(klu_list) - bi_list = self.cal_bi_list(klc_list) - klu_state_list = [] - for klu in klu_list: - if klu.near0_return == 1: - klu_state_list.append("1") - elif klu.near0_return == 9: - klu_state_list.append("-1") - elif klu.candle_dir == Chan_K_DIR.BULL: - klu_state_list.append("2") - elif klu.candle_dir == Chan_K_DIR.BEAR: - klu_state_list.append("-2") - else: - klu_state_list.append("0") - return klu_state_list - def check_fx1(self, klc): - if klc.pre and klc.next: - if klc.high > klc.pre.high and klc.high > klc.next.high and klc.low > klc.pre.low and klc.low > klc.next.low: - if klc.pre.pre and klc.next.next: - if klc.high > klc.pre.pre.high and klc.high > klc.next.next.high: - #if (klc.close > klc.ema52 or klc.next.close > klc.next.ema52) and klc.macd > 0: - klc.set_fx(Chan_FX_TYPE.TOP) - #print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "TOP") - return Chan_FX_TYPE.TOP - elif klc.low < klc.pre.low and klc.low < klc.next.low and klc.high < klc.pre.high and klc.high < klc.next.high: - #if (klc.close < klc.ema52 or klc.next.close < klc.next.ema52) and klc.macd < 0: - if klc.pre.pre and klc.next.next: - if klc.low < klc.pre.pre.low and klc.low < klc.next.next.low: - klc.set_fx(Chan_FX_TYPE.BOTTOM) - #print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "BOTTOM") - return Chan_FX_TYPE.BOTTOM - return Chan_FX_TYPE.UNKNOWN - def check_fx(self, klc): - if klc.pre and klc.next: - if klc.high > klc.pre.high and klc.high > klc.next.high and klc.low > klc.pre.low and klc.low > klc.next.low: - #if (klc.close > klc.ema52 or klc.next.close > klc.next.ema52) and klc.macd > 0: - klc.set_fx(Chan_FX_TYPE.TOP) - #print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "TOP") - return Chan_FX_TYPE.TOP - elif klc.low < klc.pre.low and klc.low < klc.next.low and klc.high < klc.pre.high and klc.high < klc.next.high: - #if (klc.close < klc.ema52 or klc.next.close < klc.next.ema52) and klc.macd < 0: - klc.set_fx(Chan_FX_TYPE.BOTTOM) - #print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "BOTTOM") - return Chan_FX_TYPE.BOTTOM - return Chan_FX_TYPE.UNKNOWN - def check_fx2(self, klc): - if klc.pre and klc.next: - if klc.high > klc.pre.close and klc.close > klc.next.close and klc.close > klc.pre.close and klc.close > klc.next.close: - #if (klc.close > klc.ema52 or klc.next.close > klc.next.ema52) and klc.macd > 0: - klc.set_fx(Chan_FX_TYPE.TOP) - #print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "TOP") - return Chan_FX_TYPE.TOP - elif klc.low < klc.pre.close and klc.close < klc.next.close and klc.close < klc.pre.close and klc.close < klc.next.close: - #if (klc.close < klc.ema52 or klc.next.close < klc.next.ema52) and klc.macd < 0: - klc.set_fx(Chan_FX_TYPE.BOTTOM) - #print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "BOTTOM") - return Chan_FX_TYPE.BOTTOM - return Chan_FX_TYPE.UNKNOWN - def check_fx_pattern(self, klc): - klu_list = klc.pre.klu_list + klc.klu_list + klc.next.klu_list - - self.cal_klu_pattern(klu_list) - p = "" - for klu in klu_list: - p += klu.to_string() - #print(p) - def cal_volume_ratio(self, dataframe, window=10): - df = dataframe.copy() - # 计算过去N根K线的平均成交量 - df['avg_volume'] = df['volume'].rolling(window=window).mean() - # 计算量比 - df['volume_ratio'] = df['volume'] / df['avg_volume'] - # 填充缺失值(前N根K线) - df['volume_ratio'] = df['volume_ratio'].fillna(1.0) - return df['volume_ratio'] - def cal_trend(self, klc_list): - """ - 基于价格与EMA24/EMA52的位置关系、以及MACD/Signal/Hist的方向, - 为每个KLC打上趋势标签:'UP' / 'DOWN' / 'FLAT'。 - 仅设置 klc.trend,不影响其它字段。 - """ - if not klc_list: - return klc_list - last_trend = Chan_PRICE_TREND.UNKNOWN - # 趋势延续性:参考近 N 根已完成的KLC - lookback_n = 5 - prev_klcs = [] - for klc in klc_list: - price = getattr(klc, 'close', None) - ema24 = getattr(klc, 'ema24', None) - ema52 = getattr(klc, 'ema52', None) - macd_raw = getattr(klc, 'macd', None) - signal_raw = getattr(klc, 'signal', None) - hist_raw = getattr(klc, 'macdhist', None) - macd = macd_raw if macd_raw is not None else 0 - signal = signal_raw if signal_raw is not None else 0 - hist = hist_raw if hist_raw is not None else 0 - rsi = getattr(klc, 'rsi', None) - macd_ready = macd_raw is not None and signal_raw is not None - hist_ready = hist_raw is not None - trend = Chan_PRICE_TREND.UNKNOWN - score = 0 - try: - # 有效性 - price_valid = price is not None and price != 0 - ema24_valid = ema24 is not None and ema24 != 0 - ema52_valid = ema52 is not None and ema52 != 0 - # 多因子投票 - - # 1) 均线结构 + 价位 - if ema24_valid or ema52_valid: - ma_votes = 0 - if ema24_valid and ema52_valid: - ma_votes += 1 if ema24 > ema52 else -1 - if price_valid and ema24_valid: - ma_votes += 1 if price > ema24 else 0 - if price_valid and ema52_valid: - ma_votes += 1 if price > ema52 else -1 - # 限幅,避免相关因子重复计分 - score += max(-2, min(2, ma_votes)) - # 2) MACD结构 - if macd_ready: - score += 1 if macd >= signal else -1 - if hist_ready and hist != 0: - score += 1 if hist > 0 else -1 - # 3) 动量与均线差分斜率 - pre = getattr(klc, 'pre', None) - pre_hist = getattr(pre, 'macdhist', None) if pre else None - if pre: - pre_close = getattr(pre, 'close', None) - if price_valid and pre_close is not None: - score += 1 if price >= pre_close else -1 - pre_ema24 = getattr(pre, 'ema24', None) - pre_ema52 = getattr(pre, 'ema52', None) - if ema24_valid and ema52_valid and pre_ema24 not in (None, 0) and pre_ema52 not in (None, 0): - spread_now = ema24 - ema52 - spread_pre = pre_ema24 - pre_ema52 - score += 1 if spread_now >= spread_pre else -1 - # 3.1) MACD柱体动量趋势:考虑 macdhist 的斜率与过零 - if hist_ready and pre_hist is not None: - # 柱体斜率:上升加分,下降减分 - if hist > pre_hist: - score += 1 - elif hist < pre_hist: - score -= 1 - # 过零加权:负转正更偏多,正转负更偏空 - if pre_hist < 0 and hist > 0: - score += 1 - elif pre_hist > 0 and hist < 0: - score -= 1 - # 3.2) EMA52 突破/跌破加权 - if ema52_valid and price_valid and pre_close is not None and pre_ema52 not in (None, 0): - # 看多突破:从均线下方上破且动量配合 - if pre_close <= pre_ema52 and price > ema52 and (hist is None or pre_hist is None or hist >= pre_hist): - score += 1 - # 看空跌破:从均线上方下破且动量配合 - if pre_close >= pre_ema52 and price < ema52 and (hist is None or pre_hist is None or hist <= pre_hist): - score -= 1 - # 3.3) EMA52 支撑/阻力触碰(非强穿越) - if ema52_valid and price_valid: - low_v = getattr(klc, 'low', None) - high_v = getattr(klc, 'high', None) - if low_v is not None and high_v is not None and ema52 not in (None, 0): - # 触碰容差(相对EMA52的0.15%) - touch_tol = 0.0015 - # 作为支撑:收盘在上,最低靠近EMA52 - near_support_touch = (price > ema52) and (abs(low_v - ema52) / abs(ema52) <= touch_tol) - # 作为阻力:收盘在下,最高靠近EMA52 - near_resistance_touch = (price < ema52) and (abs(high_v - ema52) / abs(ema52) <= touch_tol) - if near_support_touch: - # 若动量不弱,则更偏多 - score += 1 if (hist is None or pre_hist is None or hist >= pre_hist) else 0 - if near_resistance_touch: - # 若动量不强,则更偏空 - score -= 1 if (hist is None or pre_hist is None or hist <= pre_hist) else 0 - # 3.4) 多次对 EMA52 的"拒绝"配合 MACD 逆向:易形成压/支并反向 - # 统计近窗口内的上/下拒绝次数: - # - 上拒绝:价格位于 EMA52 下方,最高触及/越过 EMA52 但收盘仍在下方 - # - 下拒绝:价格位于 EMA52 上方,最低触及/跌破 EMA52 但收盘仍在上方 - recent_up_rejects = 0 - recent_down_rejects = 0 - if ema52_valid: - window_rej = prev_klcs[-lookback_n:] if len(prev_klcs) > 0 else [] - rej_tol = 0.0015 - for wk in window_rej: - wk_close = getattr(wk, 'close', None) - wk_ema52 = getattr(wk, 'ema52', None) - wk_high = getattr(wk, 'high', None) - wk_low = getattr(wk, 'low', None) - if wk_close is None or wk_ema52 in (None, 0): - continue - # 上拒绝(阻力):下方多次试图上破但未站上 - if wk_close < wk_ema52 and wk_high is not None: - if wk_high >= wk_ema52 or abs(wk_high - wk_ema52) / abs(wk_ema52) <= rej_tol: - recent_up_rejects += 1 - # 下拒绝(支撑):上方多次试图下破但未跌破 - if wk_close > wk_ema52 and wk_low is not None: - if wk_low <= wk_ema52 or abs(wk_low - wk_ema52) / abs(wk_ema52) <= rej_tol: - recent_down_rejects += 1 - # 定义 MACD 的方向偏好 - macd_bias_up = macd_ready and (macd >= signal) and (not hist_ready or pre_hist is None or hist >= pre_hist) - macd_bias_down = macd_ready and (macd <= signal) and (not hist_ready or pre_hist is None or hist <= pre_hist) - # 若多次上拒绝且 MACD 偏空,则更偏向下行;若多次下拒绝且 MACD 偏多,则更偏向上行 - if recent_up_rejects >= 2 and macd_bias_down: - score -= 2 - if recent_down_rejects >= 2 and macd_bias_up: - score += 2 - # 4) RSI 辅助 - if rsi is not None: - if rsi >= 55: - score += 1 - elif rsi <= 45: - score -= 1 - # 5) 指标未就绪回退(EMA/MACD缺失时,用动量与RSI辅助,延续趋势) - has_full_ind = ema24_valid and ema52_valid and not (macd == 0 and signal == 0 and hist == 0) - if not has_full_ind: - # 仅根据价动量/RSI做轻量判断,默认延续 last_trend,除非出现强反向 - strong_up = False - strong_down = False - pre = getattr(klc, 'pre', None) - if pre: - pre_close = getattr(pre, 'close', None) - if price_valid and pre_close is not None: - strong_up = (price >= pre_close) - strong_down = (price < pre_close) - if rsi is not None: - if rsi >= 60: - strong_up = True - elif rsi <= 40: - strong_down = True - if last_trend == Chan_PRICE_TREND.UP and not strong_down: - trend = Chan_PRICE_TREND.UP - elif last_trend == Chan_PRICE_TREND.DOWN and not strong_up: - trend = Chan_PRICE_TREND.DOWN - else: - trend = Chan_PRICE_TREND.UP if strong_up and not strong_down else (Chan_PRICE_TREND.DOWN if strong_down and not strong_up else Chan_PRICE_TREND.FLAT) - else: - # 6) 震荡过滤(仅当极近EMA52且MACD贴合时判作震荡) - near_flat = False - if price_valid and ema52_valid: - near_ema52 = abs(price - ema52) / abs(ema52) <= 0.0005 # 0.05% - if macd_ready: - macd_scale = max(abs(macd), abs(signal), 1e-6) - near_macd = abs(macd - signal) / macd_scale <= 0.05 - else: - near_macd = False - near_flat = near_ema52 and near_macd - # 7) 动态阈值 + 趋势记忆(更强粘滞:趋势中容忍小幅反分) - # 引入过去 N 根KLC 的趋势延续性来动态调整翻转阈值,并结合 EMA52 支撑/阻力触碰强化门槛 - force_flip_down = False - force_flip_up = False - if near_flat: - trend = Chan_PRICE_TREND.FLAT - else: - # 计算过去窗口的趋势一致性 - window = prev_klcs[-lookback_n:] if len(prev_klcs) > 0 else [] - persist_up = 0 - persist_down = 0 - for wk in window: - if getattr(wk, 'trend', None) == Chan_PRICE_TREND.UP: - persist_up += 1 - elif getattr(wk, 'trend', None) == Chan_PRICE_TREND.DOWN: - persist_down += 1 - persist_ratio_up = (persist_up / len(window)) if len(window) > 0 else 0 - persist_ratio_down = (persist_down / len(window)) if len(window) > 0 else 0 - # 基准阈值 - down_flip_threshold = -2 - up_flip_threshold = 2 - # 若最近多为UP,则从UP翻转需更强反向信号;同理对DOWN - if last_trend == Chan_PRICE_TREND.UP and persist_ratio_up >= 0.6: - down_flip_threshold = -3 - elif last_trend == Chan_PRICE_TREND.DOWN and persist_ratio_down >= 0.6: - up_flip_threshold = 3 - # EMA52 触碰强化门槛:UP时若出现支撑触碰,下翻更难;DOWN时若出现阻力触碰,上翻更难 - if ema52_valid and price_valid: - low_v = getattr(klc, 'low', None) - high_v = getattr(klc, 'high', None) - if low_v is not None and high_v is not None and ema52 not in (None, 0): - touch_tol = 0.0015 - near_support_touch = (price > ema52) and (abs(low_v - ema52) / abs(ema52) <= touch_tol) - near_resistance_touch = (price < ema52) and (abs(high_v - ema52) / abs(ema52) <= touch_tol) - if last_trend == Chan_PRICE_TREND.UP and near_support_touch: - # 强化维持UP:进一步降低向下翻转阈值 - down_flip_threshold = min(down_flip_threshold - 1, -3) - if last_trend == Chan_PRICE_TREND.DOWN and near_resistance_touch: - # 强化维持DOWN:进一步提高向上翻转阈值 - up_flip_threshold = max(up_flip_threshold + 1, 3) - # 7.1) 复合拐头信号:MACD/Signal 同向拐头 + hist 连续减弱 + 多次未能越过 EMA52 - pre_macd = getattr(pre, 'macd', None) if pre else None - pre_signal = getattr(pre, 'signal', None) if pre else None - macd_slope = (macd - pre_macd) if (macd_ready and pre_macd is not None) else 0 - signal_slope = (signal - pre_signal) if (macd_ready and pre_signal is not None) else 0 - # hist 连续减弱(绝对值缩小) - hist_seq = [] - for wk in prev_klcs[-2:]: - val = getattr(wk, 'macdhist', None) - if val is not None: - hist_seq.append(val) - if hist is not None: - hist_seq.append(hist) - weaken_steps = 0 - for i in range(1, len(hist_seq)): - if abs(hist_seq[i]) < abs(hist_seq[i-1]): - weaken_steps += 1 - # 近窗口对 EMA52 的"未能站上/跌破"统计(放宽窗口与条件) - window_ema = prev_klcs[-4:] if len(prev_klcs) > 0 else [] - no_up_break = False - no_down_break = False - if ema52_valid: - # 未能有效上破:最近若干根收盘大多数不在 EMA52 上方,且高点多次触及/接近 - cnt_touch_up = 0 - cnt_close_above = 0 - for wk in window_ema: - wk_close = getattr(wk, 'close', None) - wk_high = getattr(wk, 'high', None) - wk_ema = getattr(wk, 'ema52', None) - if wk_close is not None and wk_ema not in (None, 0): - if wk_close > wk_ema: - cnt_close_above += 1 - if wk_high is not None and (wk_high >= wk_ema or abs(wk_high - wk_ema) / abs(wk_ema) <= 0.0015): - cnt_touch_up += 1 - no_up_break = (cnt_close_above <= 1 and cnt_touch_up >= 1 and price <= ema52) - # 未能有效下破:最近若干根收盘大多数不在 EMA52 下方,且低点多次触及/接近 - cnt_touch_down = 0 - cnt_close_below = 0 - for wk in window_ema: - wk_close = getattr(wk, 'close', None) - wk_low = getattr(wk, 'low', None) - wk_ema = getattr(wk, 'ema52', None) - if wk_close is not None and wk_ema not in (None, 0): - if wk_close < wk_ema: - cnt_close_below += 1 - if wk_low is not None and (wk_low <= wk_ema or abs(wk_low - wk_ema) / abs(wk_ema) <= 0.0015): - cnt_touch_down += 1 - no_down_break = (cnt_close_below <= 1 and cnt_touch_down >= 1 and price >= ema52) - # 若当前为UP趋势,出现明显拐头+hist减弱+未能上破EMA52,则加速看空 - if last_trend == Chan_PRICE_TREND.UP and macd_slope < 0 and signal_slope < 0 and weaken_steps >= 1 and no_up_break and macd_bias_down: - score -= 3 - down_flip_threshold = max(down_flip_threshold, 0) - force_flip_down = True - # 若当前为DOWN趋势,出现明显拐头+hist减弱+未能下破EMA52,则加速看多 - if last_trend == Chan_PRICE_TREND.DOWN and macd_slope > 0 and signal_slope > 0 and weaken_steps >= 1 and no_down_break and macd_bias_up: - score += 3 - up_flip_threshold = min(up_flip_threshold, 0) - force_flip_up = True - # 多次对 EMA52 的拒绝配合 MACD 逆向:加速反向翻转(降低相反方向阈值) - if recent_up_rejects >= 2 and macd_bias_down: - # 从 UP 向 DOWN 的翻转更容易 - down_flip_threshold = max(down_flip_threshold, -1) - if recent_down_rejects >= 2 and macd_bias_up: - # 从 DOWN 向 UP 的翻转更容易 - up_flip_threshold = min(up_flip_threshold, 1) - if force_flip_down: - trend = Chan_PRICE_TREND.DOWN - elif force_flip_up: - trend = Chan_PRICE_TREND.UP - elif last_trend == Chan_PRICE_TREND.UP: - if score <= down_flip_threshold: - trend = Chan_PRICE_TREND.DOWN - else: - trend = Chan_PRICE_TREND.UP - elif last_trend == Chan_PRICE_TREND.DOWN: - if score >= up_flip_threshold: - trend = Chan_PRICE_TREND.UP - else: - trend = Chan_PRICE_TREND.DOWN - else: - # 初始无记忆时,降低进入门槛 - if score >= 1: - trend = Chan_PRICE_TREND.UP - elif score <= -1: - trend = Chan_PRICE_TREND.DOWN - else: - trend = Chan_PRICE_TREND.FLAT - except Exception: - trend = Chan_PRICE_TREND.UNKNOWN - # 写回趋势 - if klc.end_time is None: - trend = Chan_PRICE_TREND.FLAT - if hasattr(klc, 'set_trend'): - klc.set_trend(trend) - else: - setattr(klc, 'trend', trend) - last_trend = trend - # 更新滑窗:仅向后看 - prev_klcs.append(klc) - price_diff = klc.close - klc.pre.close if klc.pre else 0 - #if klc.index > len(klc_list) - 10: - #print(klc.start_time, klc.end_time, klc.close, klc.ema24, klc.ema52, klc.macd, klc.signal, klc.macdhist, klc.trend, price_diff, score) - #print(klc.start_time, klc.end_time, klc.trend, price_diff, score) - return klc_list - def cal_kl_data(self, dataframe:DataFrame): - fields = "time,open,high,low,close,volume" - klu_list = [] - last_klu = None - for i in range(0, len(dataframe)): - item = dataframe.iloc[i] - date = item['date'] - o = item['open'] - h = item['high'] - l = item['low'] - c = item['close'] - v = item['volume'] - # time_obj = date.fromtimestamp(date) - # date = date + timedelta(hours=8) - time_str = date.strftime('%Y-%m-%d %H:%M:%S') - item_data = [ - time_str, - o, - h, - l, - c, - v - ] - # klu = KLU(self.create_item_dict(item_data, GetColumnNameFromFieldList(fields))) - klu = ChanKLU(time_str, o, h, l, c, v) - # print(klu.time, klu.open, klu.high, klu.low, klu.close, klu.volume) - klu.set_idx(i) - klu_list.append(klu) - if last_klu: - last_klu.set_next(klu) - klu.set_pre(last_klu) - last_klu = klu - if 'macd' in item: - klu.set_indicators(item) - return klu_list - def get_bi_list(self, dataframe): - bi_list = self.cal_bi_list(self.get_klc_list(dataframe)) - #bi_list = self.cal_bi_list_chanlun(self.get_klc_list(dataframe)) - return bi_list - def get_kl_data(self, dataframe:DataFrame): - return self.cal_kl_data(dataframe) - def get_klc_list(self, klu_list): - klc_list = [] - last_klu = None - macd = ChanMACD(klu_list) - klu_list = macd.cal_macd_state() - ema_up_list = [] - ema_down_list = [] - ema_up_count = 0 - ema_down_count = 0 - last_klu = None - for klu in klu_list: - ema = klu.ema52 - last_ema = last_klu.ema52 if last_klu else 0 - if klu.close >= ema: - ema_up_count += 1 - elif klu.close < ema: - ema_down_count += 1 - if last_klu and last_klu.close >= last_ema and klu.close < ema: - ema_up_list.append(ema_up_count) - #print(last_klu.time, ema_up_count, "UP END") - ema_up_count = 0 - elif last_klu and last_klu.close < last_ema and klu.close >= ema: - ema_down_list.append(ema_down_count) - #print(last_klu.time, ema_down_count, "DOWN END") - ema_down_count = 0 - if len(klc_list) > 0: - last_klc = klc_list[-1] - if klu.exception: - ddir = Chan_KLINE_DIR.DOWN - if last_klc.high < klu.high: - ddir = Chan_KLINE_DIR.UP - klc = ChanKLC(klu, index=len(klc_list), ddir=ddir) - klc.high = klu.close if klu.close > klu.open else klu.open - klc.low = klu.open if klu.close > klu.open else klu.close - klc_list.append(klc) - last_klc.set_next(klc) - klc.set_pre(last_klc) - last_klc.set_end_klu(last_klu) - klc.set_pre_fx() - #print(klu.time, klu.high, klu.low, klu.close, klu.open, klu.exception) - else: - included = last_klc.check_klu_included(klu) - if not included: - ddir = Chan_KLINE_DIR.DOWN - if last_klc.high < klu.high: - ddir = Chan_KLINE_DIR.UP - klc = ChanKLC(klu, index=len(klc_list), ddir=ddir) - klc_list.append(klc) - last_klc.set_next(klc) - klc.set_pre(last_klc) - last_klc.set_end_klu(last_klu) - klc.set_pre_fx() - else: - last_klc.add_klu(klu) - else: - ddir = Chan_KLINE_DIR.UP - if klu.open > klu.close: - ddir = Chan_KLINE_DIR.DOWN - klc = ChanKLC(klu, 0, ddir) - klc_list.append(klc) - last_klu = klu - klc_list = self.cal_trend(klc_list) - #print(ema52_up_list, ema52_down_list) - return klc_list - - def get_seg_list(self, bi_list): - seg_list = [] - up_bi_list = [] - down_bi_list = [] - last_up_bi = None - last_down_bi = None - last_up_sbi = None - last_down_sbi = None - last_seg = None - up_sbi_list = [] - down_sbi_list = [] - look_for_bottom = False - look_for_top = False - for bi in bi_list: - #print(len(up_sbi_list), len(down_sbi_list)) - if len(seg_list) > 0: - # Last seg is up - if last_seg.dir == Chan_SEG_DIR.UP: - if bi.dir == Chan_BI_DIR.DOWN: - if len(down_sbi_list) > 1: - # Check down sbi inclusion - included = last_down_sbi.check_bi_included(bi) - if not included: - down_sbi = ChanSBI(bi, len(down_sbi_list), bi.dir) - last_down_sbi.set_next(down_sbi) - last_down_sbi.set_end_bi(last_down_bi) - down_sbi.set_pre(last_down_sbi) - down_sbi_list.append(down_sbi) - fx = last_down_sbi.check_fx() - # Found top - if fx == Chan_FX_TYPE.TOP: - if look_for_top: - seg_list[-2].set_sure(bi) - look_for_top = False - #print(bi.start_time, look_for_top, "UP 1") - # Has gap and search for bottom fx - if last_down_sbi.has_fx_gap: - look_for_bottom = True - last_seg.pre_set_end_bi(bi_list[last_down_sbi.start_bi.index - 1]) - seg = ChanSEG(last_down_sbi.start_bi, len(seg_list), Chan_SEG_DIR.DOWN, bi) - seg_list.append(seg) - last_seg.set_next(seg) - seg.set_pre(last_seg) - last_seg = seg - up_sbi_list = [] - last_up_sbi = ChanSBI(last_up_bi, len(up_sbi_list), last_up_bi.dir) - up_sbi_list.append(last_up_sbi) - #up_sbi_list.append(last_up_sbi) - #print(last_up_bi.start_time, last_up_sbi.start_bi.start_time, "Reset up sbi list 1") - #print(bi.start_time, look_for_top, "UP 2") - # No gap end SEG - else: - if look_for_bottom: - look_for_bottom = False - last_seg.set_start_bi(last_down_sbi.start_bi) - seg_list[-2].set_end_bi(bi_list[last_down_sbi.start_bi.index - 1], bi) - up_sbi_list = [] - last_up_sbi = ChanSBI(last_up_bi, len(up_sbi_list), last_up_bi.dir) - up_sbi_list.append(last_up_sbi) - last_seg.add_bi(bi) - #up_sbi_list.append(last_up_sbi) - #print(last_up_bi.start_time, last_up_sbi.start_bi.start_time, "Reset up sbi list 2") - #print(bi.start_time, look_for_top, "UP 3") - else: - last_seg.set_end_bi(bi_list[last_down_sbi.start_bi.index - 1], bi) - seg = ChanSEG(last_down_sbi.start_bi, len(seg_list), Chan_SEG_DIR.DOWN, bi) - seg_list.append(seg) - last_seg.set_next(seg) - seg.set_pre(last_seg) - last_seg = seg - #print(last_down_sbi.end_bi.start_time, "Normal UP SEG", last_up_sbi.start_bi.start_time, bi.start_time) - #l_up_sbi = up_sbi_list[-1] - up_sbi_list = [] - last_up_sbi = ChanSBI(last_up_bi, len(up_sbi_list), last_up_bi.dir) - up_sbi_list.append(last_up_sbi) - #up_sbi_list.append(last_up_sbi) - #print(last_up_bi.start_time, last_up_sbi.start_bi.start_time, "Reset up sbi list 3") - last_down_sbi = down_sbi - last_seg.add_bi(bi) - else: - if len(down_sbi_list) == 1: - included = last_down_sbi.check_bi_included(bi) - if not included: - down_sbi = ChanSBI(bi, len(down_sbi_list), bi.dir) - last_down_sbi.set_next(down_sbi) - last_down_sbi.set_end_bi(last_down_bi) - down_sbi.set_pre(last_down_sbi) - down_sbi_list.append(down_sbi) - last_down_sbi = down_sbi - #print(bi.start_time, look_for_top, "UP 4") - last_seg.add_bi(bi) - - else: - last_down_sbi = ChanSBI(bi, len(down_sbi_list), bi.dir) - down_sbi_list.append(last_down_sbi) - last_seg.add_bi(bi) - #print(bi.start_time, look_for_top, "UP 5") - else: - if last_up_sbi: - included = last_up_sbi.check_bi_included(bi) - if not included: - up_sbi = ChanSBI(bi, len(up_sbi_list), bi.dir) - last_up_sbi.set_next(up_sbi) - last_up_sbi.set_end_bi(last_up_bi) - up_sbi.set_pre(last_up_sbi) - up_sbi_list.append(up_sbi) - last_up_sbi = up_sbi - #print(bi.start_time, look_for_top, "UP 6") - last_seg.add_bi(bi) - - # Last seg is down - else: - if bi.dir == Chan_BI_DIR.UP: - if len(up_sbi_list) > 1: - # Check down sbi inclusion - included = last_up_sbi.check_bi_included(bi) - if not included: - up_sbi = ChanSBI(bi, len(up_sbi_list), bi.dir) - last_up_sbi.set_next(up_sbi) - last_up_sbi.set_end_bi(last_up_bi) - up_sbi.set_pre(last_up_sbi) - up_sbi_list.append(up_sbi) - fx = last_up_sbi.check_fx() - # Found bottom - if fx == Chan_FX_TYPE.BOTTOM: - if look_for_bottom: - seg_list[-2].set_sure(bi) - look_for_bottom = False - #print(bi.start_time, look_for_top, "DOWN 1") - # Has gap and search for bottom fx - if last_up_sbi.has_fx_gap: - look_for_top = True - last_seg.pre_set_end_bi(bi_list[last_up_sbi.start_bi.index - 1]) - seg = ChanSEG(last_up_sbi.start_bi, len(seg_list), Chan_SEG_DIR.UP, bi) - seg_list.append(seg) - last_seg.set_next(seg) - seg.set_pre(last_seg) - last_seg = seg - down_sbi_list = [] - last_down_sbi = ChanSBI(last_down_bi, len(down_sbi_list), last_down_bi.dir) - down_sbi_list.append(last_down_sbi) - #down_sbi_list.append(last_down_sbi) - #print(last_down_bi.start_time, last_down_sbi.start_bi.start_time, "Reset down sbi list 1") - #print(bi.start_time, look_for_top, "DOWN 2") - # No gap end SEG - else: - if look_for_top: - look_for_top = False - last_seg.set_start_bi(last_up_sbi.start_bi) - seg_list[-2].set_end_bi(bi_list[last_up_sbi.start_bi.index - 1], bi) - down_sbi_list = [] - last_down_sbi = ChanSBI(last_down_bi, len(down_sbi_list), last_down_bi.dir) - down_sbi_list.append(last_down_sbi) - last_seg.add_bi(bi) - #down_sbi_list.append(last_down_sbi) - #print(last_down_bi.start_time, last_down_sbi.start_bi.start_time, "Reset down sbi list 2") - #print(bi.start_time, look_for_top, "DOWN 3") - else: - last_seg.set_end_bi(bi_list[last_up_sbi.start_bi.index - 1], bi) - seg = ChanSEG(last_up_sbi.start_bi, len(seg_list), Chan_SEG_DIR.UP, bi) - #print(last_up_sbi.start_bi.start_time) - last_seg.set_next(seg) - seg.set_pre(last_seg) - seg_list.append(seg) - last_seg = seg - #print(last_up_sbi.end_bi.start_time, "Normal DOWN SEG", last_down_sbi.start_bi.start_time, bi.start_time) - down_sbi_list = [] - last_down_sbi = ChanSBI(last_down_bi, len(down_sbi_list), last_down_bi.dir) - down_sbi_list.append(last_down_sbi) - #down_sbi_list.append(last_down_sbi) - #print(last_down_bi.start_time, last_down_sbi.start_bi.start_time, "Reset down sbi list 3") - last_up_sbi = up_sbi - last_seg.add_bi(bi) - else: - if len(up_sbi_list) == 1: - #last_up_sbi = up_sbi_list[-1] - included = last_up_sbi.check_bi_included(bi) - if not included: - up_sbi = ChanSBI(bi, len(up_sbi_list), bi.dir) - last_up_sbi.set_next(up_sbi) - last_up_sbi.set_end_bi(last_up_bi) - up_sbi.set_pre(last_up_sbi) - up_sbi_list.append(up_sbi) - last_up_sbi = up_sbi - last_seg.add_bi(bi) - #print(bi.start_time, look_for_top, "DOWN 4") - else: - last_up_sbi = ChanSBI(bi, len(up_sbi_list), bi.dir) - up_sbi_list.append(last_up_sbi) - last_seg.add_bi(bi) - #print(bi.start_time, look_for_top, "DOWN 5") - else: - if last_down_sbi: - included = last_down_sbi.check_bi_included(bi) - if not included: - down_sbi = ChanSBI(bi, len(down_sbi_list), bi.dir) - last_down_sbi.set_next(down_sbi) - last_down_sbi.set_end_bi(last_down_bi) - down_sbi.set_pre(last_down_sbi) - down_sbi_list.append(down_sbi) - last_down_sbi = down_sbi - last_seg.add_bi(bi) - #print(bi.start_time, look_for_top, look_for_bottom, "DOWN 6") - # len(seg_list) = 0 - else: - if bi.check_overlap(): - if bi.dir == Chan_BI_DIR.UP: - seg = ChanSEG(bi, len(seg_list), Chan_SEG_DIR.UP, bi) - last_up_bi = bi - last_up_sbi = ChanSBI(bi, len(up_sbi_list), bi.dir) - seg_list.append(seg) - last_seg = seg - #print(bi.start_time, 'Create first UP SEG') - else: - seg = ChanSEG(bi, len(seg_list), Chan_SEG_DIR.DOWN, bi) - last_down_bi = bi - last_down_sbi = ChanSBI(bi, len(down_sbi_list), bi.dir) - seg_list.append(seg) - last_seg = seg - #print(bi.start_time, 'Create first DOWN SEG') - if bi.dir == Chan_BI_DIR.UP: - last_up_bi = bi - up_bi_list.append(bi) - else: - last_down_bi = bi - down_bi_list.append(bi) - """ - if len(seg_list) > 1: - seg = seg_list[-1] - last_seg = seg_list[-2] - last_seg_bi = last_seg.bi_list[-3] - bi_index = seg.start_bi.index - for i in range(bi_index, len(bi_list) - 1): - # last seg is down - if seg.dir == Chan_SEG_DIR.UP: - if bi_list[i].dir == Chan_BI_DIR.UP: - last_seg_peak = last_seg_bi.high - if bi_list[i].high > last_seg_peak: - # The confirmed - print("Last UP seg is broken, create a new seg. 1") - seg.pre_set_end_bi(bi_list[i]) - seg = ChanSEG(bi_list[i+1], len(seg_list), Chan_SEG_DIR.DOWN, bi) - seg_list.append(seg) - last_seg = seg_list[-2] - if len(last_seg.bi_list) > 3: - last_seg_bi = last_seg.bi_list[-3] - - else: - if bi_list[i].dir == Chan_BI_DIR.DOWN: - last_seg_peak = last_seg_bi.low - if bi_list[i].low < last_seg_peak: - print("Last DOWN seg is broken, create a new seg. 1") - seg.pre_set_end_bi(bi_list[i]) - seg = ChanSEG(bi_list[i+1], len(seg_list), Chan_SEG_DIR.UP, bi) - seg_list.append(seg) - last_seg = seg_list[-2] - if len(last_seg.bi_list) > 3: - last_seg_bi = last_seg.bi_list[-3] - else: - if len(seg_list) == 1: - last_seg = seg_list[-1] - bi_index = last_seg.bi_list[0].index - for i in range(bi_index, len(bi_list) - 1): - if i > bi_index + 2: - last_seg_peak = bi_list[i-2].high - # last seg is down - if last_seg.dir == Chan_SEG_DIR.DOWN: - if bi_list[i].dir == Chan_BI_DIR.UP: - if bi_list[i].high > last_seg_peak: - print("Last seg is broken, create a new seg. 2") - last_seg.pre_set_end_bi(bi_list[i-1]) - seg = ChanSEG(bi_list[i], len(seg_list), Chan_SEG_DIR.UP, bi) - seg_list.append(seg) - last_seg = seg - last_seg_bi = bi_list[i] - break - """ - #self.cal_bi_zs(seg_list) - return seg_list - def get_zs_state(self, df): - bi_list = self.cal_bi_list(self.get_klc_list(self.get_kl_data(df))) - seg_list = self.get_seg_list(bi_list) - zs_list = self.calculate_zs(seg_list) - for zs in zs_list: - last_zs = zs - return zs_list - def cal_bi_list(self, klc_list): - bi_list = [] - last_top = None - last_bottom = None - bi_klc_min = 4 - last_fx_klc = None - for klc in klc_list: - if last_fx_klc: - klc.check_klc_state(last_fx_klc) - klc.check_fx_confirmed(last_top, last_bottom) - fx = self.check_fx(klc) - if fx == Chan_FX_TYPE.TOP: - if last_bottom: - if self.check_top_fx(last_bottom, klc) == False: - fx = Chan_FX_TYPE.UNKNOWN - if fx == Chan_FX_TYPE.BOTTOM: - if last_top: - if self.check_bottom_fx(last_top, klc) == False: - #print(klc.end_time, last_top.end_time, "---") - fx = Chan_FX_TYPE.UNKNOWN - # Do nothing - if fx == Chan_FX_TYPE.UNKNOWN: - if len(bi_list) > 0: - bi_list[-1].add_klc(klc) - klc.set_bi(bi_list[-1]) - #continue - if len(bi_list) > 0 and klc.end_klu: - last_bi = bi_list[-1] - #print(klc.start_time, last_bi.start_time, last_bi.end_time, last_bi.dir, last_bi.high, last_bi.low, last_bottom.end_time, "last bi") - if last_top and last_bi.dir == Chan_BI_DIR.DOWN: - if last_bottom and klc.high > last_bi.high: - #print(klc.end_time, "Top 7, 1", last_bi.start_time, klc.high, last_bi.high) - #klc.klc_fx_type = Chan_KLC_FX.TOP7 - #klc.fx = Chan_FX_TYPE.TOP - """ - last_bi.set_end_klc(last_bottom, klc) - bi = ChanBI(last_bottom, len(bi_list), Chan_BI_DIR.UP) - #klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM7) - #klc.bb_out = True - last_bi.set_next(bi) - bi.set_pre(last_bi) - for klc_index in range(last_bi.end_klc.index, len(klc_list)): - bi.add_klc(klc_list[klc_index]) - bi_list.append(bi) - last_top = klc - klc.set_bi(bi) - #print(klc.start_time, bi.start_time, bi.end_time, bi.dir, bi.high, bi.low, bi.is_sure) - """ - else: - if last_bottom and last_bi.dir == Chan_BI_DIR.UP: - if last_top and klc.low < last_bi.low: - #print(klc.end_time, "Bottom 8, 2", last_bi.start_time) - #klc.klc_fx_type = Chan_KLC_FX.BOTTOM8 - #klc.fx = Chan_FX_TYPE.BOTTOM - """ - last_bi.set_end_klc(last_top, klc) - bi = ChanBI(last_top, len(bi_list), Chan_BI_DIR.DOWN) - #klc.set_klc_fx_type(Chan_KLC_FX.TOP6) - #klc.bb_out = True - last_bi.set_next(bi) - bi.set_pre(last_bi) - for klc_index in range(last_bi.end_klc.index, len(klc_list)): - bi.add_klc(klc_list[klc_index]) - bi_list.append(bi) - last_bottom = klc - klc.set_bi(bi) - #print(klc.start_time, bi.start_time, bi.end_time, bi.dir, bi.high, bi.low, bi.is_sure) - """ - else: - last_fx_klc = klc - if fx == Chan_FX_TYPE.TOP: - #print(klc.end_time, fx, klc.pre.high, klc.high, klc.pre.start_time, klc.pre.end_time) - if last_top: - if last_bottom: - #print(klc.start_time, last_bottom.start_time, last_top.start_time) - if last_bottom.index < last_top.index: - # Second top lower to be second sell point - if last_top.high > klc.high: - bi_list[-1].add_klc(klc) - klc.set_bi(bi_list[-1]) - #klc.set_klc_fx_type(Chan_KLC_FX.TOP3) - #print(klc.end_time, klc.fx, "二类卖点Sell 1") - else: - # A new top found - #last_top.set_fx(Chan_FX_TYPE.UNKNOWN) - last_top = klc - #print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Top Change 1") - klc.set_klc_fx_type(Chan_KLC_FX.TOP1) - self.check_fx_pattern(klc) - #print(klc.end_time, klc.fx, "一类卖点Sell 1") - bi_list[-1].add_klc(klc) - klc.set_bi(bi_list[-1]) - # 不满足结合律的分型 - else: - #klc.set_klc_fx_type(Chan_KLC_FX.TOP0) - #print(klc.end_time, klc.klc_fx_type) - if last_bottom.index + bi_klc_min > klc.index: - if last_top.high > klc.high: - #print(klc.start_time, klc.fx, "二类卖点Sell 1") - #klc.set_klc_fx_type(Chan_KLC_FX.TOP8) - bi_list[-1].add_klc(klc) - klc.set_bi(bi_list[-1]) - # New TOP Found前面的UKNOWN可能出现TOP7,但是这里的也可能出现TOP8分型 - else: - # 顶分型在出现2之前超过前一个笔的顶 TOP8 - if last_top.index + bi_klc_min < klc.index and len(bi_list) > 1: - pre_last_bi = bi_list[-2] - last_bi = bi_list[-1] - if pre_last_bi.is_sure and not last_bi.is_sure and pre_last_bi.dir == Chan_BI_DIR.UP and False: - pre_last_bi.update_bi(klc) - bi_list.remove(last_bi) - pre_last_bi.set_next(None) - #last_top.set_fx(Chan_FX_TYPE.PTOP) - last_top = klc - last_bottom = pre_last_bi.start_klc - #print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Top Bottom Change 1") - klc.set_klc_fx_type(Chan_KLC_FX.TOP2) - #print(klc.start_time, last_bi.start_klc.start_time, "New TOP Found reset last bi") - #klc.set_state("10") - #print(klc.start_time, klc.fx, "笔卖点Sell 1") - ###klc.set_klc_fx_type(Chan_KLC_FX.TOP2) # when bi is down but the fx is top - bi_list[-1].add_klc(klc) - klc.set_bi(bi_list[-1]) - #klc.set_klc_fx_type(Chan_KLC_FX.TOP8) - #print(klc.start_time, last_bi.start_klc.start_time, "New TOP Found reset last bi") - else: - #klc.set_fx(Chan_FX_TYPE.PTOP) - bi_list[-1].add_klc(klc) - klc.set_bi(bi_list[-1]) - #print(klc.end_time, klc.fx, "无效顶分型") - # 满足结合律 - else: - # New Temp TOP and last bottom confirmed ***** confirm last down bi(last bottom and last top) - last_bi = bi_list[-1] - if not last_bi.is_sure: - last_bi.set_end_klc(last_bottom, klc) - bi = ChanBI(last_bottom, len(bi_list), Chan_BI_DIR.UP) - last_bi.set_next(bi) - bi.set_pre(last_bi) - bi.add_klc(klc) - bi_list.append(bi) - last_top = klc - #print(klc.end_time, klc.fx, bi_list[-1].dir, "Last Top Change 2") - klc.set_klc_fx_type(Chan_KLC_FX.TOP2) - self.check_fx_pattern(klc) - #bi_list[-1].add_klc(klc) - klc.set_bi(bi_list[-1]) - #print(klc.start_time, last_bottom.start_time, "Normal TOP Found, Confirm down bi 4") - # last bottom = None 初始化的时候用,其他时间不用 - else: - # 初始化的时候用,其他时间不用 - if last_top.high < klc.high: - last_bi = bi_list[-1] - last_bi.set_start_klc(klc, Chan_BI_DIR.DOWN) - last_top = klc - #print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Top Change 3") - bi_list[-1].add_klc(klc) - klc.set_bi(bi_list[-1]) - # 初始化的时候用,其他时间不用 - else: - #klc.set_fx(Chan_FX_TYPE.TT) - #print(klc.start_time, klc.fx, "二类卖点Sell 2") - bi_list[-1].add_klc(klc) - klc.set_bi(bi_list[-1]) - # last_top == None 初始化的时候用,其他时间不用 - else: - if last_bottom: - # 不满足结合律的分型 - if last_bottom.index + bi_klc_min > klc.index: - #klc.set_fx(Chan_FX_TYPE.PTOP) - bi_list[-1].add_klc(klc) - klc.set_bi(bi_list[-1]) - #print(klc.start_time, klc.fx, "中枢卖点Sell 1") - else: - # First temp top and last bottom confirmed - last_top = klc - #print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Top Change 4") - bi_list[-1].add_klc(klc) - klc.set_bi(bi_list[-1]) - # Last top = None, last bottom = None, create first down bi 初始化的时候用,其他时间不用 - else: - # First temp top - last_top = klc - bi = ChanBI(klc, len(bi_list), Chan_BI_DIR.DOWN) - bi_list.append(bi) - bi.add_klc(klc) - klc.set_bi(bi_list[-1]) - #print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Top Change 5") - #klc.fx = Bottom ======================== - else: - if last_bottom: - if last_top: - # Bottom after top and find a new bottom - if last_top.index < last_bottom.index: - # Second bottom uppper to be second buy point and confirm last bi - if last_bottom.low < klc.low: - bi_list[-1].add_klc(klc) - klc.set_bi(bi_list[-1]) - #klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM3) - #print(last_bottom.start_time, last_bottom.end_time, "--------------------------------1") - #print(klc.end_time, klc.fx, "二类买点Buy 1") - else: - # A new bottom found - last_bottom = klc - #print(klc.end_time, klc.fx, bi_list[-1].dir, "Last Bottom Change 1") - klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM1) - self.check_fx_pattern(klc) - #print(klc.end_time, klc.fx, "一类买点Buy 1") - bi_list[-1].add_klc(klc) - klc.set_bi(bi_list[-1]) - # 不满足结合律的分型 - else: - #klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM0) - #print(klc.end_time, klc.klc_fx_type) - if last_top.index + bi_klc_min > klc.index: - if last_bottom.low < klc.low: - #print(klc.end_time, klc.fx, "中枢买点Buy 1") - bi_list[-1].add_klc(klc) - klc.set_bi(bi_list[-1]) - #klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM8) - # Found new bottom没有意义,上面UNKNOWN的时候已经是笔破坏了 - else: - #print(klc.end_time, last_bottom.end_time, "Found a new bottom") - if last_bottom.index + bi_klc_min < klc.index and len(bi_list) > 1: - pre_last_bi = bi_list[-2] - last_bi = bi_list[-1] - if pre_last_bi.is_sure and not last_bi.is_sure and pre_last_bi.dir == Chan_BI_DIR.DOWN and False: - pre_last_bi.update_bi(klc) - bi_list.remove(last_bi) - pre_last_bi.set_next(None) - last_bottom = klc - last_top = pre_last_bi.start_klc - #print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Top Bottom Change 2") - klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM2) - #print(klc.start_time, last_bi.start_klc.start_time, "New BOTTOM Found reset last bi") - #print(klc.start_time, klc.fx, "笔买点Buy 1") - ###klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM2) # when bi is up but the fx is bottom - bi_list[-1].add_klc(klc) - klc.set_bi(bi_list[-1]) - #klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM8) - else: - #klc.set_fx(Chan_FX_TYPE.UNKNOWN) - bi_list[-1].add_klc(klc) - klc.set_bi(bi_list[-1]) - print(klc.end_time, klc.fx, "无效底分型") - # 满足结合律的分型 - else: - # New Temp Bottom and last top confirmed ***** confirm last up bi(last bottom and last top) - last_bi = bi_list[-1] - if not last_bi.is_sure: - last_bi.set_end_klc(last_top, klc) - bi = ChanBI(last_top, len(bi_list), Chan_BI_DIR.DOWN) - last_bi.set_next(bi) - bi.set_pre(last_bi) - bi.add_klc(klc) - bi_list.append(bi) - last_bottom = klc - #print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Bottom Change 2") - klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM2) - self.check_fx_pattern(klc) - #bi_list[-1].add_klc(klc) - klc.set_bi(bi_list[-1]) - #print(klc.start_time, last_top.start_time, "Normal Bottom Found, Confirm up bi 6") - # last_top = None 初始化的时候用,其他时间不用 - else: - if last_bottom.low > klc.low: - last_bi = bi_list[-1] - last_bi.set_start_klc(klc, Chan_BI_DIR.UP) - #last_bottom.set_fx(Chan_FX_TYPE.UNKNOWN) - last_bottom = klc - #print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Bottom Change 3") - bi_list[-1].add_klc(klc) - klc.set_bi(bi_list[-1]) - #print(klc.start_time, klc.fx, "笔买点Buy 3") - else: - #klc.set_fx(Chan_FX_TYPE.BB) - #klc.set_state('-20') - #print(klc.start_time, klc.fx, "二类买点Buy 2") - bi_list[-1].add_klc(klc) - klc.set_bi(bi_list[-1]) - # last_bottom = None 初始化的时候用,其他时间不用 - else: - if last_top: - # 不满足结合律的分型 - if last_top.index + bi_klc_min > klc.index: - #klc.set_fx(Chan_FX_TYPE.PBOTTOM) - bi_list[-1].add_klc(klc) - klc.set_bi(bi_list[-1]) - #print(klc.start_time, klc.fx, "中枢买点Buy 1") - else: - # First temp bottom and last top confirmed - last_bottom = klc - #print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Bottom Change 4") - bi_list[-1].add_klc(klc) - klc.set_bi(bi_list[-1]) - #print(klc.start_time, klc.fx, "一类买点Buy 1") - # Last top = None, last bottom = None, create first up bi - else: - # First temp bottom and no top yet - last_bottom = klc - bi = ChanBI(klc, len(bi_list), Chan_BI_DIR.UP) - #klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM7) - bi_list.append(bi) - bi_list[-1].add_klc(klc) - klc.set_bi(bi_list[-1]) - #print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Bottom Change 5") - #print(klc.start_time, klc.fx, "笔买点Buy 4") - self.get_above_zero_bsp(klc_list) - #print(bi_list[-1].start_time, bi_list[-1].end_time, len(bi_list[-1].klc_list)) - return bi_list - def get_above_zero_bsp(self, klc_list): - buy_bsp_list = [] - sell_bsp_list = [] - above_zero = False - buy_bsp = None - sell_bsp = None - for klc in klc_list: - if klc.pre and klc.pre.signal < 0 and klc.signal > 0: - above_zero = True - if klc.pre and klc.pre.signal > 0 and klc.signal < 0: - above_zero = False - if above_zero and klc.klc_fx_type == Chan_KLC_FX.BOTTOM2 and klc.macd > 0: - buy_bsp = klc - buy_bsp_list.append(klc) - #print(klc.end_time, "MACD 0轴上穿,回调笔底分型做多") - if buy_bsp and klc.pre and klc.pre.macdhist > 0 and klc.macdhist < 0: - sell_bsp = klc - sell_bsp_list.append(klc) - buy_bsp = None - #print(klc.end_time, "Sell BSP Found") - return buy_bsp_list - def check_top_fx(self, last_bottom, klc): - if (last_bottom.high > klc.pre.low or last_bottom.high > klc.next.low) and (klc.index - last_bottom.index < 100): - return False - return True - - def check_bottom_fx(self, last_top, klc): - if (last_top.low < klc.pre.high or last_top.low < klc.next.high) and (klc.index - last_top.index < 100): - return False - return True - # 线段内的中枢 - def cal_bi_zs(self, seg_list): - bi_zs_list = [] - for seg in seg_list: - zs_list = seg.cal_bi_zs() - if len(zs_list) > 0: - bi_zs_list = list(bi_zs_list) + list(zs_list) - return bi_zs_list - # 跨段不相连的中枢 - def cal_bi_zs_list(self, bi_list): - """ - 根据缠论笔中枢定义计算中枢(参照 get_zs_list 线段中枢判断规则) - 从第4根笔开始(索引3),每3根笔为一组检查 - 上涨中枢:后中枢 zd > 前中枢 zg(不重叠上移) - 下跌中枢:后中枢 zg < 前中枢 zd(不重叠下移) - 中枢可按两笔一组继续扩展到5根、7根... - """ - bi_zs_list = [] - if len(bi_list) < 3: - return bi_zs_list - - last_zs = None - start_idx = 3 - - while start_idx < len(bi_list): - if start_idx + 2 >= len(bi_list): - break - - bi1 = bi_list[start_idx] - bi2 = bi_list[start_idx + 1] - bi3 = bi_list[start_idx + 2] - - if not (bi1.is_sure and bi2.is_sure and bi3.is_sure): - start_idx += 1 - continue - - zg = min(bi1.high, bi2.high, bi3.high) - zd = max(bi1.low, bi2.low, bi3.low) - - if zg <= zd: - start_idx += 1 - continue - - valid = False - if last_zs is None: - if bi1.dir == Chan_BI_DIR.DOWN: - zs_dir = Chan_ZS_DIR.UP - valid = (bi2.dir == Chan_BI_DIR.UP and bi3.dir == Chan_BI_DIR.DOWN) - else: - zs_dir = Chan_ZS_DIR.DOWN - valid = (bi2.dir == Chan_BI_DIR.DOWN and bi3.dir == Chan_BI_DIR.UP) - else: - is_up_zs = zg > last_zs.zg - is_down_zs = zd < last_zs.zd - - if is_up_zs: - zs_dir = Chan_ZS_DIR.UP - valid = (bi1.dir == Chan_BI_DIR.DOWN and bi2.dir == Chan_BI_DIR.UP and bi3.dir == Chan_BI_DIR.DOWN) - elif is_down_zs: - zs_dir = Chan_ZS_DIR.DOWN - valid = (bi1.dir == Chan_BI_DIR.UP and bi2.dir == Chan_BI_DIR.DOWN and bi3.dir == Chan_BI_DIR.UP) - - if not valid: - start_idx += 1 - continue - gg = max(bi1.high, bi2.high, bi3.high) - dd = min(bi1.low, bi2.low, bi3.low) - zs = ChanBIZS(bi1, len(bi_zs_list), zs_dir) - zs.set_zg(zg) - zs.set_zd(zd) - zs.set_gg(gg) - zs.set_dd(dd) - zs.is_sure = False - zs.bi_list = [bi1, bi2, bi3] - - added_after_leave = [] - leave_index = start_idx + 4 - while leave_index < len(bi_list): - b = bi_list[leave_index] - if not b.is_sure: - break - if b.high >= zs.zd and b.low <= zs.zg: - added_after_leave.append(b.pre) - added_after_leave.append(b) - else: - break - leave_index += 2 - - if added_after_leave: - bis_for_zs = list(zs.bi_list) + list(added_after_leave) - bi_highs = [bi.high for bi in bis_for_zs] - bi_lows = [bi.low for bi in bis_for_zs] - zs.set_gg(max(bi_highs)) - zs.set_dd(min(bi_lows)) - zs.bi_list = bis_for_zs - bi = bis_for_zs[-1] - if bi.is_sure: - zs.set_end_bi(bi, bi.sure_time) - - start_idx = start_idx + len(added_after_leave) - else: - zs.set_end_bi(bi3, bi3.sure_time) - - if last_zs: - last_zs.set_next(zs) - zs.set_pre(last_zs) - - bi_zs_list.append(zs) - last_zs = zs - - start_idx += 4 - - if last_zs: - last_zs.is_sure = bi_list[-1].is_sure - - if last_zs and not last_zs.is_sure: - if last_zs.bi_list and len(last_zs.bi_list) > 0: - last_bi_of_zs = last_zs.bi_list[-1] - last_bi_idx = -1 - for i, bi in enumerate(bi_list): - if bi == last_bi_of_zs: - last_bi_idx = i - break - - has_leave = False - if last_bi_idx >= 0 and last_bi_idx + 1 < len(bi_list): - for i in range(last_bi_idx + 1, len(bi_list)): - bi = bi_list[i] - if bi.is_sure: - leave = (bi.low > last_zs.zg and bi.high > last_zs.zg) or \ - (bi.high < last_zs.zd and bi.low < last_zs.zd) - if leave: - has_leave = True - break - - if has_leave: - if last_bi_of_zs.is_sure: - last_zs.set_end_bi(last_bi_of_zs, last_bi_of_zs.sure_time) - return bi_zs_list - def get_bi_zs_list(self, bi_list): - """ - 根据缠论笔中枢定义计算中枢(完全参照 get_seg_zs_list 线段中枢判断规则) - 从第4根笔开始(索引3),每3根笔为一组检查 - 上涨中枢:后中枢 zd > 前中枢 zg(不重叠上移) - 下跌中枢:后中枢 zg < 前中枢 zd(不重叠下移) - 盘整/扩张:后中枢与前中枢整体区间有交集 → 合并扩展 - 中枢可按两笔一组继续扩展到5根、7根... - """ - bi_zs_list = [] - if len(bi_list) < 3: - return bi_zs_list - - last_zs = None - start_idx = 3 - - while start_idx < len(bi_list): - if start_idx + 2 >= len(bi_list): - break - - bi1 = bi_list[start_idx] - bi2 = bi_list[start_idx + 1] - bi3 = bi_list[start_idx + 2] - - if not (bi1.is_sure and bi2.is_sure and bi3.is_sure): - start_idx += 1 - continue - - zg = min(bi1.high, bi2.high, bi3.high) - zd = max(bi1.low, bi2.low, bi3.low) - - if zg <= zd: - start_idx += 1 - continue - - valid = False - if last_zs is None: - if bi1.dir == Chan_BI_DIR.DOWN: - zs_dir = Chan_ZS_DIR.UP - valid = (bi2.dir == Chan_BI_DIR.UP and bi3.dir == Chan_BI_DIR.DOWN) - else: - zs_dir = Chan_ZS_DIR.DOWN - valid = (bi2.dir == Chan_BI_DIR.DOWN and bi3.dir == Chan_BI_DIR.UP) - else: - is_up_zs = zd > last_zs.zg - is_down_zs = zg < last_zs.zd - - if is_up_zs: - zs_dir = Chan_ZS_DIR.UP - valid = (bi1.dir == Chan_BI_DIR.DOWN and bi2.dir == Chan_BI_DIR.UP and bi3.dir == Chan_BI_DIR.DOWN) - elif is_down_zs: - zs_dir = Chan_ZS_DIR.DOWN - valid = (bi1.dir == Chan_BI_DIR.UP and bi2.dir == Chan_BI_DIR.DOWN and bi3.dir == Chan_BI_DIR.UP) - - create_new_zs = False - if not valid: - # 如果新中枢和前一个中枢的中枢区间有重叠,不形成新中枢,合并扩展 - if last_zs is not None: - is_in_last_zs = (zd > last_zs.zd and zd < last_zs.zg) or \ - (zg < last_zs.zg and zg > last_zs.zd) or \ - (zg > last_zs.zg and zd < last_zs.zd) or \ - (zg < last_zs.zg and zd > last_zs.zd) - if is_in_last_zs: - # 扩展当前中枢:将 bi1-bi3 加入 last_zs - for bi in [bi1, bi2, bi3]: - if bi not in last_zs.bi_list: - last_zs.add_bi(bi) - create_new_zs = False - else: - start_idx += 1 - continue - else: - start_idx += 1 - continue - else: - create_new_zs = True - - # 新中枢形成时确认前一个中枢 - if last_zs and create_new_zs: - last_bi = last_zs.bi_list[-1] - if last_bi and last_bi.is_sure: - last_zs.is_sure = True - last_zs.set_end_bi(last_bi, last_bi.sure_time) - - zs = last_zs - if create_new_zs: - gg = max(bi1.high, bi2.high, bi3.high) - dd = min(bi1.low, bi2.low, bi3.low) - zs = ChanBIZS(bi1, len(bi_zs_list), zs_dir) - zs.set_zg(zg) - zs.set_zd(zd) - zs.set_gg(gg) - zs.set_dd(dd) - zs.is_sure = False - zs.bi_list = [bi1, bi2, bi3] - - # 离开后回抽扩展检查 - added_after_leave = [] - leave_index = start_idx + 4 - while leave_index < len(bi_list): - b = bi_list[leave_index] - if not b.is_sure: - break - if b.high >= zs.zd and b.low <= zs.zg: - added_after_leave.append(b.pre) - added_after_leave.append(b) - else: - break - leave_index += 2 - - if added_after_leave: - bis_for_zs = list(zs.bi_list) + list(added_after_leave) - bi_highs = [bi.high for bi in bis_for_zs] - bi_lows = [bi.low for bi in bis_for_zs] - zs.set_gg(max(bi_highs)) - zs.set_dd(min(bi_lows)) - zs.bi_list = bis_for_zs - bi = bis_for_zs[-1] - if bi.is_sure: - zs.set_end_bi(bi, bi.sure_time) - start_idx = start_idx + len(added_after_leave) - else: - if create_new_zs: - zs.set_end_bi(bi3, bi3.sure_time) - - if create_new_zs: - if last_zs: - last_zs.set_next(zs) - zs.set_pre(last_zs) - bi_zs_list.append(zs) - last_zs = zs - - start_idx += 4 - - # 最后一个中枢:根据 bi_list 最后一笔确认状态 - if last_zs: - last_zs.is_sure = bi_list[-1].is_sure - - if last_zs and not last_zs.is_sure: - if last_zs.bi_list and len(last_zs.bi_list) > 0: - last_bi_of_zs = last_zs.bi_list[-1] - last_bi_idx = -1 - for i, bi in enumerate(bi_list): - if bi == last_bi_of_zs: - last_bi_idx = i - break - - has_leave = False - if last_bi_idx >= 0 and last_bi_idx + 1 < len(bi_list): - for i in range(last_bi_idx + 1, len(bi_list)): - bi = bi_list[i] - if bi.is_sure: - leave = (bi.low > last_zs.zg and bi.high > last_zs.zg) or \ - (bi.high < last_zs.zd and bi.low < last_zs.zd) - if leave: - has_leave = True - break - - if has_leave: - if last_bi_of_zs.is_sure: - last_zs.set_end_bi(last_bi_of_zs, last_bi_of_zs.sure_time) - - return bi_zs_list - - def cal_bi_zs_list_pure(self, bi_list): - bi_zs_list = [] - if len(bi_list) < 3: - return bi_zs_list - - def get_zs_range(bis): - zg = min(bi.high for bi in bis) - zd = max(bi.low for bi in bis) - return zg, zd - - def is_bi_overlap_range(bi, zg, zd): - return bi.high >= zd and bi.low <= zg - - def check_zs_position_filter(last_zs, zg, zd, bis): - if last_zs is None: - return True - if zg <= last_zs.zd: - return bis[0].dir == Chan_BI_DIR.UP and bis[-1].dir == Chan_BI_DIR.UP - if zd >= last_zs.zg: - return bis[0].dir == Chan_BI_DIR.DOWN and bis[-1].dir == Chan_BI_DIR.DOWN - return True - - def set_zs_bi_list(zs, bis): - zs.bi_list = list(bis) - for bi in zs.bi_list: - bi.set_bi_zs(zs) - zs.set_gg(max(bi.high for bi in zs.bi_list)) - zs.set_dd(min(bi.low for bi in zs.bi_list)) - zs.classify_zs() - - last_zs = None - start_idx = 0 - while start_idx + 2 < len(bi_list): - bi1 = bi_list[start_idx] - bi2 = bi_list[start_idx + 1] - bi3 = bi_list[start_idx + 2] - - if not (bi1.is_sure and bi2.is_sure and bi3.is_sure): - start_idx += 1 - continue - - if not (bi1.dir != bi2.dir and bi1.dir == bi3.dir): - start_idx += 1 - continue - - zg, zd = get_zs_range([bi1, bi2, bi3]) - if zg <= zd: - start_idx += 1 - continue - - bis_for_zs = [bi1, bi2, bi3] - extend_idx = start_idx + 3 - while extend_idx + 1 < len(bi_list): - leave_bi = bi_list[extend_idx] - back_bi = bi_list[extend_idx + 1] - if not (leave_bi.is_sure and back_bi.is_sure): - break - if not is_bi_overlap_range(back_bi, zg, zd): - break - bis_for_zs.append(leave_bi) - bis_for_zs.append(back_bi) - extend_idx += 2 - - if not check_zs_position_filter(last_zs, zg, zd, bis_for_zs): - start_idx += 1 - continue - - zs_dir = Chan_ZS_DIR.UP if bi1.dir == Chan_BI_DIR.DOWN else Chan_ZS_DIR.DOWN - zs = ChanBIZS(bi1, len(bi_zs_list), zs_dir) - zs.set_zg(zg) - zs.set_zd(zd) - - set_zs_bi_list(zs, bis_for_zs) - zs.set_end_bi(bis_for_zs[-1], bis_for_zs[-1].sure_time) - - if last_zs: - last_zs.set_next(zs) - zs.set_pre(last_zs) - - bi_zs_list.append(zs) - last_zs = zs - start_idx = start_idx + len(bis_for_zs) - - # 与 cal_bi_zs_list 一致:最后一笔未确认时末中枢标为未完成;若其后已出现确认的离开笔,仍按离开前最后一笔确认中枢结束 - if last_zs: - last_zs.is_sure = bi_list[-1].is_sure - - if last_zs and not last_zs.is_sure: - if last_zs.bi_list and len(last_zs.bi_list) > 0: - last_bi_of_zs = last_zs.bi_list[-1] - last_bi_idx = -1 - for i, bi in enumerate(bi_list): - if bi == last_bi_of_zs: - last_bi_idx = i - break - - has_leave = False - if last_bi_idx >= 0 and last_bi_idx + 1 < len(bi_list): - for i in range(last_bi_idx + 1, len(bi_list)): - bi = bi_list[i] - if bi.is_sure: - leave = (bi.low > last_zs.zg and bi.high > last_zs.zg) or \ - (bi.high < last_zs.zd and bi.low < last_zs.zd) - if leave: - has_leave = True - break - - if has_leave: - if last_bi_of_zs.is_sure: - last_zs.set_end_bi(last_bi_of_zs, last_bi_of_zs.sure_time) - - return bi_zs_list - def find_all_bsp(self, bi_list, bi_zs_list): - """ - 笔中枢的三类买卖点识别 - - 三类买点:中枢形成后,一笔向上离开中枢(低点 > zg), - 随后回拉的一笔低点不跌回中枢(低点 >= zg),确认支撑有效。 - 三类卖点:中枢形成后,一笔向下离开中枢(高点 < zd), - 随后反弹的一笔高点不回到中枢(高点 <= zd),确认压力有效。 - - 参数: - bi_list: 笔列表 - bi_zs_list: 笔中枢列表(二维列表,每个seg内的中枢列表) - - 返回: - bsp_list: ChanBSP 列表,包含所有识别到的三类买卖点 - """ - bsp_list = [] - if len(bi_list) < 4 or len(bi_zs_list) == 0: - return bsp_list - - for zs in bi_zs_list: - if not zs.is_sure or len(zs.bi_list) < 3: - continue - #print(zs.start_time, zs.end_time, zs.dir, zs.is_sure, len(zs.bi_list)) - # 中枢结束后的第一笔(离开笔) - last_zs_bi = zs.bi_list[-1] - if last_zs_bi.dir == Chan_BI_DIR.UP: - if last_zs_bi.is_sure and last_zs_bi.end_klc.high <= zs.zg or (last_zs_bi.next and last_zs_bi.next.is_sure and last_zs_bi.next.end_klc.low < zs.zd): - leave_bi = last_zs_bi.next - else: - leave_bi = last_zs_bi - else: - if last_zs_bi.is_sure and last_zs_bi.end_klc.low >= zs.zd or (last_zs_bi.next and last_zs_bi.next.is_sure and last_zs_bi.next.end_klc.high > zs.zg): - leave_bi = last_zs_bi.next - else: - leave_bi = last_zs_bi - #print(zs.zg, zs.zd) - if leave_bi is None or not leave_bi.is_sure: - continue - if (zs.dir == Chan_ZS_DIR.UP and leave_bi.dir == Chan_BI_DIR.UP and leave_bi.end_klc.high < zs.zg and leave_bi.end_klc.high > zs.zd) or (zs.dir == Chan_ZS_DIR.DOWN and leave_bi.dir == Chan_BI_DIR.DOWN and leave_bi.end_klc.low < zs.zg and leave_bi.end_klc.low > zs.zd): - #print("--------------------", leave_bi.dir, leave_bi.end_klc.high, leave_bi.end_klc.low, zs.zg, zs.zd) - leave_bi = leave_bi.next - # 三类买点:向上离开中枢后回拉不破 zg - #print("Leave bi:", leave_bi.start_time, leave_bi.end_time, leave_bi.dir, leave_bi.is_sure, leave_bi.low, leave_bi.high) - if leave_bi.dir == Chan_BI_DIR.UP: - first_bsp_bi_div = self.check_bi_div(zs, leave_bi) - # 确认一类卖点:离开断能量小于进入段能量 - if first_bsp_bi_div: - bsp = ChanBSP( - leave_bi, len(bsp_list), - Chan_BSP_TYPE.S1, - Chan_BSP_DIR.SELL, - leave_bi.sure_time, - zs.index+1, zs, None - ) - leave_bi.end_klc.set_bsp_type(Chan_BSP_TYPE.S1) - bsp_list.append(bsp) - # 回拉笔 - pullback_bi = leave_bi.next - #print(pullback_bi.start_klc.start_time, pullback_bi.dir, pullback_bi.is_sure, pullback_bi.low, pullback_bi.high) - if pullback_bi and pullback_bi.is_sure and pullback_bi.dir == Chan_BI_DIR.DOWN: - if pullback_bi.low >= zs.zg: - # 确认三类买点:回拉笔的低点不跌回中枢 - bsp = ChanBSP( - pullback_bi, len(bsp_list), - Chan_BSP_TYPE.B3, - Chan_BSP_DIR.BUY, - pullback_bi.sure_time, - zs.index+1, zs, None - ) - pullback_bi.end_klc.set_bsp_type(Chan_BSP_TYPE.B3) - bsp_list.append(bsp) - # 二类卖点 - if first_bsp_bi_div: - second_bsp_bi = pullback_bi.next - if second_bsp_bi and second_bsp_bi.is_sure and second_bsp_bi.end_klc.high < leave_bi.end_klc.high: - # 确认二类卖点:一类卖点后回拉不超过一类卖点高点 - bsp = ChanBSP( - second_bsp_bi, len(bsp_list), - Chan_BSP_TYPE.S2, - Chan_BSP_DIR.SELL, - second_bsp_bi.sure_time, - zs.index+1, zs, None - ) - second_bsp_bi.end_klc.set_bsp_type(Chan_BSP_TYPE.B2) - bsp_list.append(bsp) - # 三类卖点:向下离开中枢后反弹不破 zd - elif leave_bi.dir == Chan_BI_DIR.DOWN: - first_bsp_bi_div = self.check_bi_div(zs, leave_bi) - # 确认一类买点:离开段能量小于进入段 - if first_bsp_bi_div: - bsp = ChanBSP( - leave_bi, len(bsp_list), - Chan_BSP_TYPE.B1, - Chan_BSP_DIR.BUY, - leave_bi.sure_time, - zs.index+1, zs, None - ) - leave_bi.end_klc.set_bsp_type(Chan_BSP_TYPE.B1) - bsp_list.append(bsp) - # 反弹笔 - bounce_bi = leave_bi.next - #print(bounce_bi.start_klc.start_time, bounce_bi.dir, bounce_bi.is_sure, bounce_bi.low, bounce_bi.high) - if bounce_bi and bounce_bi.is_sure and bounce_bi.dir == Chan_BI_DIR.UP: - if bounce_bi.high <= zs.zd: - # 确认三类卖点:反弹笔的高点不回到中枢 - bsp = ChanBSP( - bounce_bi, len(bsp_list), - Chan_BSP_TYPE.S3, - Chan_BSP_DIR.SELL, - bounce_bi.sure_time, - zs.index+1, zs, None - ) - bounce_bi.end_klc.set_bsp_type(Chan_BSP_TYPE.S3) - bsp_list.append(bsp) - # 二类卖点 - if first_bsp_bi_div: - second_bsp_bi = bounce_bi.next - if second_bsp_bi and second_bsp_bi.is_sure and second_bsp_bi.end_klc.low > leave_bi.end_klc.low: - # 确认二类买点:一类买点后回拉不超过一类卖点高点 - bsp = ChanBSP( - second_bsp_bi, len(bsp_list), - Chan_BSP_TYPE.B2, - Chan_BSP_DIR.BUY, - second_bsp_bi.sure_time, - zs.index+1, zs, None - ) - second_bsp_bi.end_klc.set_bsp_type(Chan_BSP_TYPE.B2) - bsp_list.append(bsp) - return bsp_list - def check_bi_div(self, zs, leave_bi): - enter_bi = zs.bi_list[0].pre - macdhist_div = 0 - if enter_bi and enter_bi.dir == leave_bi.dir: - macdhist_div = abs(leave_bi.macd_hist) - abs(enter_bi.macd_hist) - #print(enter_bi.end_time, leave_bi.end_time, macdhist_div < 0) - return macdhist_div < 0 - def find_first_bsp(self, bi_list, bi_zs_list): - """ - 笔中枢的一类买卖点识别 - - 一类买点:下跌趋势中,最后一个中枢完成后,向下离开中枢的笔创新低, - 但该笔与进入中枢前的最后一笔下跌形成底背驰(力度减弱), - 即趋势力竭的转折点。 - 一类卖点:上涨趋势中,最后一个中枢完成后,向上离开中枢的笔创新高, - 但该笔与进入中枢前的最后一笔上涨形成顶背驰(力度减弱), - 即趋势力竭的转折点。 - - 简化判断:中枢形成后,离开中枢的笔(突破笔)本身即为一类买卖点的触发笔。 - - 参数: - bi_list: 笔列表 - bi_zs_list: 笔中枢列表(扁平列表,每个元素是一个中枢对象) - - 返回: - bsp_list: ChanBSP 列表,包含所有识别到的一类买卖点 - """ - bsp_list = [] - if len(bi_list) < 4 or len(bi_zs_list) == 0: - return bsp_list - - for zs in bi_zs_list: - if not zs.is_sure or len(zs.bi_list) < 3: - continue - - # 找到中枢的最后一笔 - last_zs_bi = zs.bi_list[-1] - - # 确定离开笔:中枢最后一笔之后的第一笔 - if last_zs_bi.dir == Chan_BI_DIR.UP: - # 中枢最后一笔向上,如果没有真正离开中枢,取下一笔 - if last_zs_bi.is_sure and last_zs_bi.end_klc.high <= zs.zg: - leave_bi = last_zs_bi.next - else: - leave_bi = last_zs_bi - else: - # 中枢最后一笔向下,如果没有真正离开中枢,取下一笔 - if last_zs_bi.is_sure and last_zs_bi.end_klc.low >= zs.zd: - leave_bi = last_zs_bi.next - else: - leave_bi = last_zs_bi - - if leave_bi is None or not leave_bi.is_sure: - continue - - # 一类买点:向下离开中枢(leave_bi向下,低点 < zd),趋势力竭 - if leave_bi.dir == Chan_BI_DIR.DOWN and leave_bi.low < zs.zd: - # 背驰判断:比较离开笔与中枢内最后一笔同向笔的MACD柱状累积面积 - # 缠论原文:两段同向走势的MACD柱状面积比较,面积缩小即为背驰 - compare_bi = None - for bi in reversed(zs.bi_list): - if bi.dir == Chan_BI_DIR.DOWN and bi is not leave_bi: - compare_bi = bi - break - - is_divergence = False - if compare_bi: - # 笔的macd_hist是该笔内所有KLU的macdhist累积面积 - leave_macd_area = abs(leave_bi.macd_hist) - compare_macd_area = abs(compare_bi.macd_hist) - - # 价格创新低但MACD面积缩小 = 底背驰 - if leave_bi.low <= compare_bi.low and leave_macd_area < compare_macd_area: - is_divergence = True - # 即使没创新低,MACD面积明显缩小也算背驰 - elif leave_macd_area < compare_macd_area * 0.5: - is_divergence = True - else: - # 没有对比笔时,只要离开中枢就算一类买点 - is_divergence = True - - if is_divergence: - bsp = ChanBSP( - leave_bi, len(bsp_list), - Chan_BSP_TYPE.T1, - Chan_BSP_DIR.BUY, - leave_bi.sure_time, - 1, zs, None - ) - bsp_list.append(bsp) - - # 一类卖点:向上离开中枢(leave_bi向上,高点 > zg),趋势力竭 - elif leave_bi.dir == Chan_BI_DIR.UP and leave_bi.high > zs.zg: - # 背驰判断:比较离开笔与中枢内最后一笔同向笔的MACD柱状累积面积 - compare_bi = None - for bi in reversed(zs.bi_list): - if bi.dir == Chan_BI_DIR.UP and bi is not leave_bi: - compare_bi = bi - break - - is_divergence = False - if compare_bi: - leave_macd_area = abs(leave_bi.macd_hist) - compare_macd_area = abs(compare_bi.macd_hist) - - # 价格创新高但MACD面积缩小 = 顶背驰 - if leave_bi.high >= compare_bi.high and leave_macd_area < compare_macd_area: - is_divergence = True - # 即使没创新高,MACD面积明显缩小也算背驰 - elif leave_macd_area < compare_macd_area * 0.5: - is_divergence = True - else: - is_divergence = True - - if is_divergence: - bsp = ChanBSP( - leave_bi, len(bsp_list), - Chan_BSP_TYPE.T1, - Chan_BSP_DIR.SELL, - leave_bi.sure_time, - 1, zs, None - ) - bsp_list.append(bsp) - - return bsp_list - - def find_second_bsp(self, bi_list, first_bsp_list): - """ - 笔中枢的二类买卖点识别 - - 二类买点:一类买点出现后,价格向上反弹一笔,再回落一笔, - 回落笔的低点不跌破一类买点的低点,确认底部成立。 - 二类卖点:一类卖点出现后,价格向下回落一笔,再反弹一笔, - 反弹笔的高点不超过一类卖点的高点,确认顶部成立。 - - 参数: - bi_list: 笔列表 - first_bsp_list: 一类买卖点列表(find_first_bsp 的返回值) - - 返回: - bsp_list: ChanBSP 列表,包含所有识别到的二类买卖点 - """ - bsp_list = [] - if not first_bsp_list or len(bi_list) < 4: - return bsp_list - - for first_bsp in first_bsp_list: - trigger_bi = first_bsp.bi # 一类买卖点的触发笔 - - if first_bsp.dir == Chan_BSP_DIR.BUY: - # 一买之后:trigger_bi 向下 -> 反弹笔(向上) -> 回落笔(向下) - # 回落笔的低点 > trigger_bi 的低点 => 二类买点 - bounce_bi = trigger_bi.next # 反弹笔(向上) - if bounce_bi and bounce_bi.is_sure and bounce_bi.dir == Chan_BI_DIR.UP: - pullback_bi = bounce_bi.next # 回落笔(向下) - if pullback_bi and pullback_bi.is_sure and pullback_bi.dir == Chan_BI_DIR.DOWN: - if pullback_bi.low > trigger_bi.low: - bsp = ChanBSP( - pullback_bi, len(bsp_list), - Chan_BSP_TYPE.T2, - Chan_BSP_DIR.BUY, - pullback_bi.sure_time, - 1, first_bsp.zs, None - ) - bsp_list.append(bsp) - - elif first_bsp.dir == Chan_BSP_DIR.SELL: - # 一卖之后:trigger_bi 向上 -> 回落笔(向下) -> 反弹笔(向上) - # 反弹笔的高点 < trigger_bi 的高点 => 二类卖点 - drop_bi = trigger_bi.next # 回落笔(向下) - if drop_bi and drop_bi.is_sure and drop_bi.dir == Chan_BI_DIR.DOWN: - bounce_bi = drop_bi.next # 反弹笔(向上) - if bounce_bi and bounce_bi.is_sure and bounce_bi.dir == Chan_BI_DIR.UP: - if bounce_bi.high < trigger_bi.high: - bsp = ChanBSP( - bounce_bi, len(bsp_list), - Chan_BSP_TYPE.T2, - Chan_BSP_DIR.SELL, - bounce_bi.sure_time, - 1, first_bsp.zs, None - ) - bsp_list.append(bsp) - - return bsp_list - def calculate_seg_zs(self, seg_list): - return self.get_seg_zs_list(seg_list) - def get_seg_zs_list(self, seg_list): - """ - 根据缠论线段中枢定义计算中枢 - 从第4根线段开始(索引3),每3根线段为一组检查 - 上涨中枢:后中枢 zd > 前中枢 zg(不重叠上移) - 下跌中枢:后中枢 zg < 前中枢 zd(不重叠下移) - 盘整/扩张:后中枢与前中枢整体区间(GG/DD)有交集 - 中枢可按两段一组继续扩展到5根、7根... - """ - zs_list = [] - if len(seg_list) < 3: - return zs_list - - last_zs = None - - # 从第4根线段开始(索引3),每3根为一组 - start_idx = 3 - - while start_idx < len(seg_list): - # 取连续3个线段 - if start_idx + 2 >= len(seg_list): - break - - seg1 = seg_list[start_idx] - seg2 = seg_list[start_idx + 1] - seg3 = seg_list[start_idx + 2] - - # 三个线段都必须是已确认的 - if not (seg1.is_sure and seg2.is_sure and seg3.is_sure): - start_idx += 1 - continue - - # 计算这3个线段的中枢区间 - zg = min(seg1.high, seg2.high, seg3.high) - zd = max(seg1.low, seg2.low, seg3.low) - - if zg <= zd: - start_idx += 1 - #print(seg1.start_bi.start_klc.end_time, "not valid", zg, zd) - continue - - # 判断中枢类型(按注释定义) - # 上涨中枢:后中枢 zd > 前中枢 zg(不重叠上移) - # 下跌中枢:后中枢 zg < 前中枢 zd(不重叠下移) - # 盘整/扩张:后中枢与前中枢区间有交集 - if last_zs is None: - # 第一个中枢仅按线段形态判定方向 - if seg1.dir == Chan_SEG_DIR.DOWN: - # 下跌+上涨+下跌,对应上涨中枢 - zs_dir = Chan_ZS_DIR.UP - valid = (seg2.dir == Chan_SEG_DIR.UP and seg3.dir == Chan_SEG_DIR.DOWN) - else: - # 上涨+下跌+上涨,对应下跌中枢 - zs_dir = Chan_ZS_DIR.DOWN - valid = (seg2.dir == Chan_SEG_DIR.DOWN and seg3.dir == Chan_SEG_DIR.UP) - else: - is_up_zs = zd > last_zs.zg - is_down_zs = zg < last_zs.zd - - if is_up_zs: - # 不重叠上移 - zs_dir = Chan_ZS_DIR.UP - valid = (seg1.dir == Chan_SEG_DIR.DOWN and seg2.dir == Chan_SEG_DIR.UP and seg3.dir == Chan_SEG_DIR.DOWN) - elif is_down_zs: - # 不重叠下移 - zs_dir = Chan_ZS_DIR.DOWN - valid = (seg1.dir == Chan_SEG_DIR.UP and seg2.dir == Chan_SEG_DIR.DOWN and seg3.dir == Chan_SEG_DIR.UP) - create_new_zs = False - # 验证是否有效 - if not valid: - # 如果新中枢和前一个中枢的中枢区间有重叠,不行成新中枢需要合并两个中枢 - is_in_last_zs = (zd > last_zs.zd and zd < last_zs.zg) or (zg < last_zs.zg and zg > last_zs.zd) or (zg > last_zs.zg and zd < last_zs.zd) or (zg < last_zs.zg and zd > last_zs.zd) - if is_in_last_zs: - #print(seg1.start_time, "New zs is in last zs, not valid") - last_zs.extend_zs(seg_list[last_zs.seg_list[-1].index:(seg3.index + 1)]) - create_new_zs = False - else: - start_idx += 1 - continue - else: - create_new_zs = True - if last_zs and create_new_zs: - last_seg = last_zs.seg_list[-1] - last_bi = last_seg.end_bi - if last_bi: - last_zs.is_sure = True - last_zs.set_end_klc(last_bi.end_klc, last_bi.sure_time, 0, last_seg) - last_zs.set_end_seg(last_seg) - zs = last_zs - if create_new_zs: - # 创建新中枢 - gg = max(seg1.high, seg2.high, seg3.high) - dd = min(seg1.low, seg2.low, seg3.low) - - zs = ChanZS(seg1, len(zs_list), zs_dir) - zs.set_zg(zg) - zs.set_zd(zd) - zs.set_gg(gg) - zs.set_dd(dd) - zs.is_sure = False - zs.seg_list = [seg1, seg2, seg3] - # 若第二线段与 [zd,zg] 重叠(如离开后回抽回到前中枢)则并入扩展 - added_after_leave = [] - leave_index = start_idx + 4 - is_break = False - while leave_index < len(seg_list): - s = seg_list[leave_index] - if not s.is_sure: - break - sh = max(s.start_bi.high, s.end_bi.high) if s.end_bi else s.start_bi.high - sl = min(s.start_bi.low, s.end_bi.low) if s.end_bi else s.start_bi.low - if sh >= zs.zd and sl <= zs.zg: - added_after_leave.append(s.pre) - added_after_leave.append(s) - leave_index += 2 - else: - next_seg = s.next - if next_seg and next_seg.is_sure: - if next_seg.dir == Chan_SEG_DIR.UP: - if next_seg.high <= zs.zg and next_seg.low >= zs.zd: - leave_index += 2 - continue - else: - is_break = True - else: - if next_seg.low >= zs.zd and next_seg.low <= zs.zg: - leave_index += 2 - continue - else: - is_break = True - else: - break - if is_break: - break - if added_after_leave: - #print(len(added_after_leave)) - segs_for_zs = list(zs.seg_list) + list(added_after_leave) - seg_highs = [s.high for s in segs_for_zs] - seg_lows = [s.low for s in segs_for_zs] - zs.set_gg(max(seg_highs)) - zs.set_dd(min(seg_lows)) - zs.seg_list = segs_for_zs - seg = segs_for_zs[-1] - #if seg.end_bi: - #zs.set_end_klc(seg.end_bi.end_klc, seg.sure_time, 0, seg) - #zs.set_end_seg(seg) - #zs.is_sure = True - start_idx = start_idx + len(added_after_leave) - if last_zs and last_zs.index != zs.index: - last_zs.set_next(zs) - zs.set_pre(last_zs) - - zs_list.append(zs) - last_zs = zs - - # 移动到下一组 - start_idx += 4 - if last_zs: - last_zs.is_sure = seg_list[-1].is_sure - """ - # 处理最后一个未确认的中枢 - 不自动扩展,保持未完成状态 - if last_zs and not last_zs.is_sure: - # 获取中枢最后一个线段的索引 - if last_zs.seg_list and len(last_zs.seg_list) > 0: - last_seg_of_zs = last_zs.seg_list[-1] - # 找到这个线段在seg_list中的索引 - last_seg_idx = -1 - for i, seg in enumerate(seg_list): - if seg == last_seg_of_zs: - last_seg_idx = i - break - - # 从中枢最后一个线段之后检查是否有离开 - has_leave = False - if last_seg_idx >= 0 and last_seg_idx + 1 < len(seg_list): - for i in range(last_seg_idx + 1, len(seg_list)): - seg = seg_list[i] - if seg.is_sure: - # 检查是否离开中枢 - leave = (seg.low > last_zs.zg and seg.high > last_zs.zg) or \ - (seg.high < last_zs.zd and seg.low < last_zs.zd) - if leave: - has_leave = True - break - - if not has_leave: - # 没有离开,保持未完成状态 - pass - else: - # 有离开,确认中枢 - if last_seg_of_zs.end_bi: - #print(last_seg_of_zs.start_time, "last_seg_of_zs.end_time", last_seg_of_zs.end_time) - last_zs.set_end_klc(last_seg_of_zs.end_bi.end_klc, last_seg_of_zs.sure_time, 0, last_seg_of_zs) - last_zs.set_end_seg(last_seg_of_zs) - last_zs.is_sure = True - """ - return zs_list - - def get_big_zs_list(self, zs_list): - """ - 中枢扩张:将区间重叠的连续中枢合并为大级别中枢,便于显示更大级别的震荡区间。 - 重叠定义:两中枢 [zd,zg] 有交集,即 (zs_i.zg >= zs_j.zd and zs_i.zd <= zs_j.zg)。 - """ - big_list = [] - if len(zs_list) < 2: - return big_list - i = 0 - while i < len(zs_list): - group = [zs_list[i]] - j = i + 1 - while j < len(zs_list): - cur = zs_list[j] - # 与当前组内任一中枢有重叠即算扩张(通常只需与组内最后一个比) - last_in_group = group[-1] - overlap = (last_in_group.zg >= cur.zd and last_in_group.zd <= cur.zg) - if overlap: - group.append(cur) - j += 1 - else: - break - if len(group) >= 2: - big = ChanZS_Big(group) - big.index = len(big_list) - big_list.append(big) - i = j if len(group) >= 2 else i + 1 - return big_list - - def get_klu_list(self, dataframe): - klu_list = self.get_kl_data(dataframe) - #klu_list = self.cal_klu_pattern(klu_list) - return klu_list - def cal_klu_pattern(self, klu_list): - """ - 计算裸K的pattern - 识别反转形态 - """ - if not klu_list or len(klu_list) < 3: - return klu_list - - for i, klu in enumerate(klu_list): - # 单根K线反转模式识别 - self._detect_single_reversal_pattern(klu) - - # 双根K线形态识别 - if i >= 1: - self._detect_double_pattern(klu_list[i-1], klu) - - # 三根K线形态识别 - if i >= 2: - self._detect_triple_pattern(klu_list[i-2], klu_list[i-1], klu) - - #if klu.pattern != Chan_KLU_PATTERN.UNKNOWN: - #print(klu.time, klu.pattern, klu.lower_shadow_ratio, klu.upper_shadow_ratio, klu.body_ratio, klu.lower_shadow_ratio/klu.body_ratio, klu.upper_shadow_ratio/klu.body_ratio) - return klu_list - - def _detect_single_reversal_pattern(self, klu): - """检测单根K线反转模式""" - body = abs(klu.close - klu.open) - upper_shadow = klu.high - max(klu.close, klu.open) - lower_shadow = min(klu.close, klu.open) - klu.low - total_range = klu.high - klu.low - - # 避免除零 - if total_range == 0: - return - - body_ratio = body / total_range - upper_ratio = upper_shadow / total_range - lower_ratio = lower_shadow / total_range - #print(klu.time, upper_ratio, lower_ratio, body_ratio, upper_ratio/body_ratio, lower_ratio/body_ratio) - # 避免body_ratio为0时的除零错误 - if body_ratio == 0: - return - # 锤子线/上吊线 - 反转信号 - if lower_ratio / body_ratio >= 2: - # 锤子线:底部反转,需要前面一段 - if klu.close > klu.open and klu.pre: - klu.set_pattern(Chan_KLU_PATTERN.HAMMER) # 底部反转 - # 上吊线:顶部反转,需要前一根是上涨趋势 - elif klu.close < klu.open and klu.pre: - klu.set_pattern(Chan_KLU_PATTERN.HANGING_MAN) # 顶部反转 - - # 倒锤子线/射击之星 - 反转信号 - elif upper_ratio / body_ratio >= 2: - # 倒锤子线:底部反转,需要前一根是下跌趋势 - if klu.close > klu.open and klu.pre: - klu.set_pattern(Chan_KLU_PATTERN.INVERTED_HAMMER) # 底部反转 - # 射击之星:顶部反转,需要前一根是上涨趋势 - elif klu.close < klu.open and klu.pre: - klu.set_pattern(Chan_KLU_PATTERN.SHOOTING_STAR) # 顶部反转 - - # 十字星 - 反转信号 - elif body_ratio <= 0.1: - if upper_ratio > 0.4 and lower_ratio > 0.4: - klu.set_pattern(Chan_KLU_PATTERN.LONG_LEGGED_DOJI) # 强烈反转信号 - elif upper_ratio > 0.4 and lower_ratio <= 0.1: - # 墓碑十字星:顶部反转,需要前一根是上涨趋势 - if klu.pre and klu.pre.close > klu.pre.open: - klu.set_pattern(Chan_KLU_PATTERN.GRAVESTONE_DOJI) # 顶部反转 - elif lower_ratio > 0.4 and upper_ratio <= 0.1: - # 蜻蜓十字星:底部反转,需要前一根是下跌趋势 - if klu.pre and klu.pre.close < klu.pre.open: - klu.set_pattern(Chan_KLU_PATTERN.DRAGONFLY_DOJI) # 底部反转 - else: - klu.set_pattern(Chan_KLU_PATTERN.DOJI) # 一般反转信号 - - def _detect_double_pattern(self, prev_klu, curr_klu): - """检测两根K线形成的形态 - 包括:吞没形态(看涨/看跌)、乌云盖顶、曙光初现 - """ - # 如果前一根K线已经有形态,不再识别双K线形态 - if prev_klu.pattern != Chan_KLU_PATTERN.UNKNOWN: - return - - # 计算K线实体 - prev_body = abs(prev_klu.close - prev_klu.open) - curr_body = abs(curr_klu.close - curr_klu.open) - - # 判断K线颜色(阴阳) - prev_bullish = prev_klu.close > prev_klu.open - curr_bullish = curr_klu.close > curr_klu.open - - # 检查是否存在长期趋势(至少需要5根K线的趋势) - def check_long_trend(klu, bullish_trend=True, min_bars=5): - """检查是否存在长期趋势 - bullish_trend=True: 检查上涨趋势 - bullish_trend=False: 检查下跌趋势 - min_bars: 最少需要多少根K线形成趋势 - """ - if not klu or not klu.pre: - return False - return True - - # 使用EMA指标判断长期趋势 - if klu.ema52 > 0: - if bullish_trend and klu.close < klu.ema52: - return False - if not bullish_trend and klu.close > klu.ema52: - return False - - # 检查连续的K线方向 - count = 0 - current = klu.pre - - while current and count < min_bars: - if not current.pre: - break - - if bullish_trend: - # 上涨趋势:当前收盘价高于前一根收盘价 - if current.close <= current.pre.close: - break - else: - # 下跌趋势:当前收盘价低于前一根收盘价 - if current.close >= current.pre.close: - break - - count += 1 - current = current.pre - - return count >= min_bars - - # 1. 看涨吞没形态:前阴后阳,后者完全吞没前者 - # 要求前面有明显的下跌趋势 - if not prev_bullish and curr_bullish and \ - abs(curr_klu.open - prev_klu.close) < 10 and \ - curr_klu.close > prev_klu.open and \ - check_long_trend(prev_klu, bullish_trend=False, min_bars=5): - curr_klu.set_pattern(Chan_KLU_PATTERN.BULLISH_ENGULFING) - return - - # 2. 看跌吞没形态:前阳后阴,后者完全吞没前者 - # 要求前面有明显的上涨趋势 - if prev_bullish and not curr_bullish and \ - abs(curr_klu.open - prev_klu.close) < 10 and \ - curr_klu.close < prev_klu.open and \ - check_long_trend(prev_klu, bullish_trend=True, min_bars=5): - curr_klu.set_pattern(Chan_KLU_PATTERN.BEARISH_ENGULFING) - return - - # 3. 乌云盖顶:前阳后阴,后者开盘价高于前者最高价,收盘价在前者实体中部以下 - # 要求前面有明显的上涨趋势 - if prev_bullish and not curr_bullish and \ - curr_klu.open > prev_klu.high and \ - curr_klu.close < (prev_klu.open + prev_klu.close) / 2 and \ - curr_klu.close > prev_klu.open and \ - check_long_trend(prev_klu, bullish_trend=True, min_bars=5): - curr_klu.set_pattern(Chan_KLU_PATTERN.DARK_CLOUD_COVER) - return - - # 4. 曙光初现:前阴后阳,后者开盘价低于前者最低价,收盘价在前者实体中部以上 - # 要求前面有明显的下跌趋势 - if not prev_bullish and curr_bullish and \ - curr_klu.open < prev_klu.low and \ - curr_klu.close > (prev_klu.open + prev_klu.close) / 2 and \ - curr_klu.close < prev_klu.open and \ - check_long_trend(prev_klu, bullish_trend=False, min_bars=5): - curr_klu.set_pattern(Chan_KLU_PATTERN.PIERCING_LINE) - return - - # 平顶和平底移至三根K线形态中判断 - - def _detect_triple_pattern(self, first_klu, second_klu, third_klu): - """检测三根K线形成的形态 - 包括:早晨之星、黄昏之星、平顶、平底 - """ - # 如果前两根K线已经有形态,不再识别三K线形态 - if first_klu.pattern != Chan_KLU_PATTERN.UNKNOWN or \ - second_klu.pattern != Chan_KLU_PATTERN.UNKNOWN: - return - - # 判断K线颜色(阴阳) - first_bullish = first_klu.close > first_klu.open - second_bullish = second_klu.close > second_klu.open - third_bullish = third_klu.close > third_klu.open - - # 计算实体大小 - first_body = abs(first_klu.close - first_klu.open) - second_body = abs(second_klu.close - second_klu.open) - third_body = abs(third_klu.close - third_klu.open) - - # 检查是否存在长期趋势(至少需要5根K线的趋势) - def check_long_trend(klu, bullish_trend=True, min_bars=5): - """检查是否存在长期趋势 - bullish_trend=True: 检查上涨趋势 - bullish_trend=False: 检查下跌趋势 - min_bars: 最少需要多少根K线形成趋势 - """ - if not klu or not klu.pre: - return False - - # 使用EMA指标判断长期趋势 - if klu.ema52 > 0: - if bullish_trend and klu.close < klu.ema52: - return False - if not bullish_trend and klu.close > klu.ema52: - return False - - # 检查连续的K线方向 - count = 0 - current = klu.pre - - while current and count < min_bars: - if not current.pre: - break - - if bullish_trend: - # 上涨趋势:当前收盘价高于前一根收盘价 - if current.close <= current.pre.close: - break - else: - # 下跌趋势:当前收盘价低于前一根收盘价 - if current.close >= current.pre.close: - break - - count += 1 - current = current.pre - - return count >= min_bars - - # 1. 早晨之星:第一根阴线,第二根十字星或小实体,第三根阳线 - # 要求前面有明显的下跌趋势 - if not first_bullish and third_bullish and \ - second_body < first_body * 0.3 and \ - third_body > first_body * 0.5 and \ - max(second_klu.open, second_klu.close) < first_klu.close and \ - min(second_klu.open, second_klu.close) < third_klu.open and \ - third_klu.close > (first_klu.open + first_klu.close) / 2 and \ - check_long_trend(first_klu, bullish_trend=False, min_bars=7): - third_klu.set_pattern(Chan_KLU_PATTERN.MORNING_STAR) - return - - # 2. 黄昏之星:第一根阳线,第二根十字星或小实体,第三根阴线 - # 要求前面有明显的上涨趋势 - if first_bullish and not third_bullish and \ - second_body < first_body * 0.3 and \ - third_body > first_body * 0.5 and \ - min(second_klu.open, second_klu.close) > first_klu.close and \ - max(second_klu.open, second_klu.close) > third_klu.open and \ - third_klu.close < (first_klu.open + first_klu.close) / 2 and \ - check_long_trend(first_klu, bullish_trend=True, min_bars=7): - third_klu.set_pattern(Chan_KLU_PATTERN.EVENING_STAR) - return - - # 3. 平顶:三根K线的最高点几乎相同(上升趋势中更有意义) - # 要求前面有明显的上涨趋势 - if (abs(first_klu.high - second_klu.high) / first_klu.high < 0.0002 and - abs(second_klu.high - third_klu.high) / second_klu.high < 0.0002 and - check_long_trend(first_klu, bullish_trend=True, min_bars=7)): - # 额外确认:价格接近阻力位或关键技术指标 - is_near_resistance = False - - # 检查是否接近EMA52阻力位 - if first_klu.ema52 > 0: - resistance_level = first_klu.ema52 - if abs(first_klu.high - resistance_level) / resistance_level < 0.01: - is_near_resistance = True - - # 检查是否有成交量确认(成交量减少表示上涨动能减弱) - volume_confirmation = False - if (first_klu.volume > 0 and second_klu.volume > 0 and third_klu.volume > 0 and - third_klu.volume < second_klu.volume and second_klu.volume < first_klu.volume): - volume_confirmation = True - - if is_near_resistance or volume_confirmation: - third_klu.set_pattern(Chan_KLU_PATTERN.TWEEZER_TOP) - return - - # 4. 平底:三根K线的最低点几乎相同(下降趋势中更有意义) - # 要求前面有明显的下跌趋势 - if (abs(first_klu.low - second_klu.low) / first_klu.low < 0.0002 and - abs(second_klu.low - third_klu.low) / second_klu.low < 0.0002 and - check_long_trend(first_klu, bullish_trend=False, min_bars=7)): - # 额外确认:价格接近支撑位或关键技术指标 - is_near_support = False - - # 检查是否接近EMA52支撑位 - if first_klu.ema52 > 0: - support_level = first_klu.ema52 - if abs(first_klu.low - support_level) / support_level < 0.01: - is_near_support = True - - # 检查是否有成交量确认(成交量减少表示下跌动能减弱) - volume_confirmation = False - if (first_klu.volume > 0 and second_klu.volume > 0 and third_klu.volume > 0 and - third_klu.volume < second_klu.volume and second_klu.volume < first_klu.volume): - volume_confirmation = True - - if is_near_support or volume_confirmation: - third_klu.set_pattern(Chan_KLU_PATTERN.TWEEZER_BOTTOM) - return - - def get_decimal(self, value): - return Decimal("{:.2f}".format(value)) \ No newline at end of file +"""兼容 shim — 请优先 from chanlun import ...""" +from chanlun.pipeline.timeframe import TF_DF # noqa: F401 diff --git a/chanlun/__init__.py b/chanlun/__init__.py new file mode 100644 index 0000000..bee24ef --- /dev/null +++ b/chanlun/__init__.py @@ -0,0 +1,11 @@ +"""缠论引擎正式包。 + +推荐:: + from chanlun import ChanLun, TF_DF + from chanlun.core.ChanEnum import Chan_BI_DIR +""" + +from chanlun.pipeline.orchestrator import ChanLun +from chanlun.pipeline.timeframe import TF_DF + +__all__ = ["ChanLun", "TF_DF"] diff --git a/chanlun/analysis/ChanHeng.py b/chanlun/analysis/ChanHeng.py new file mode 100644 index 0000000..973837a --- /dev/null +++ b/chanlun/analysis/ChanHeng.py @@ -0,0 +1,414 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +""" +使用 ccxt 获取币安交易所所有 `*/USDT` 交易对最新 100 根 1 小时 K 线数据,并筛选出长期横盘的币种。 + +横盘判定基于以下三项指标(均可通过命令行参数调整): +1. 价格振幅占均价的比例(默认 ≤ 5%) +2. 收盘价线性回归斜率占均价的比例(默认 ≤ 0.05%) +3. 收盘价标准差占均价的比例(默认 ≤ 1.5%) + +满足以上全部条件的交易对会被视为长期横盘。 +""" + +import argparse +import csv +import logging +import math +import statistics +import sys +import time +from dataclasses import dataclass +from typing import Iterable, List, Optional, Sequence + +import ccxt + +# python ChanHeng.py --range-threshold 5 --slope-threshold 5 --std-threshold 0.015 + +DEFAULT_LIMIT = 100 +DEFAULT_TIMEFRAME = "1h" +STABLECOINS = { + "USDT", + "USDC", + "BUSD", + "TUSD", + "USDP", + "DAI", + "FDUSD", + "SUSD", + "UST", + "USTC", + "EUR", + "TRY", + "BFUSD", + "USDE", + "XUSD", + "USD1", + "XUSD" +} + + +@dataclass +class SidewaysMetrics: + symbol: str + price_range_pct: float + slope_pct: float + std_pct: float + mean_close: float + last_close: float + data_points: int + + +def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="筛选币安长期横盘币种(默认 500 根 1 小时 K 线)" + ) + parser.add_argument( + "--timeframe", + default=DEFAULT_TIMEFRAME, + help="K 线周期(默认:1h)", + ) + parser.add_argument( + "--limit", + type=int, + default=DEFAULT_LIMIT, + help="每个交易对获取的 K 线数量(默认:500)", + ) + parser.add_argument( + "--range-threshold", + type=float, + default=0.05, + help="最大价格振幅占均价比例阈值(默认:0.05,表示 5%%)", + ) + parser.add_argument( + "--slope-threshold", + type=float, + default=0.0005, + help="线性回归斜率占均价比例阈值(默认:0.0005,约 0.05%%)", + ) + parser.add_argument( + "--std-threshold", + type=float, + default=0.015, + help="标准差占均价比例阈值(默认:0.015,表示 1.5%%)", + ) + parser.add_argument( + "--quote", + action="append", + default=[], + help="只保留指定计价货币的交易对,可重复指定(示例:--quote USDT --quote FDUSD)", + ) + parser.add_argument( + "--symbol", + action="append", + default=[], + help="仅检测指定交易对,可重复(不指定则遍历所有符合条件的现货交易对)", + ) + parser.add_argument( + "--max-symbols", + type=int, + default=None, + help="限制最多检测的交易对数量(用于调试)", + ) + parser.add_argument( + "--sleep", + type=float, + default=0.35, + help="请求失败后的基础重试等待秒数(默认:0.35)", + ) + parser.add_argument( + "--retries", + type=int, + default=3, + help="单个交易对请求失败后的最大重试次数(默认:3)", + ) + parser.add_argument( + "--include-inactive", + action="store_true", + help="包含已下架/不可交易的交易对(默认不包含)", + ) + parser.add_argument( + "--export", + type=str, + default=None, + help="将筛选结果导出为 CSV 文件的路径", + ) + parser.add_argument( + "--verbose", + action="store_true", + help="输出更详细的日志信息", + ) + return parser.parse_args(argv) + + +def setup_logging(verbose: bool) -> None: + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig( + level=level, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + +def create_exchange() -> ccxt.binance: + exchange = ccxt.binance({"enableRateLimit": True}) + exchange.options["defaultType"] = "spot" + return exchange + + +def iter_target_symbols( + exchange: ccxt.binance, + quotes: Sequence[str], + includes: Sequence[str], + include_inactive: bool, +) -> List[str]: + markets = exchange.load_markets() + filtered = [] + + quote_set = {quote.upper() for quote in quotes} + include_set = {sym.upper() for sym in includes} + + for symbol, meta in markets.items(): + if not meta.get("spot", False): + continue + if not include_inactive and meta.get("active") is False: + continue + + normalized_symbol = symbol.upper() + + if include_set and normalized_symbol not in include_set: + continue + + parts = symbol.split("/") + if len(parts) != 2: + continue + + base_asset, quote_asset = parts[0].upper(), parts[1].upper() + + target_quote = quote_set or {"USDT"} + if quote_asset not in target_quote: + continue + + if base_asset in STABLECOINS: + continue + + filtered.append(symbol) + + filtered.sort() + logging.info( + "已筛选 %s 个目标交易对(quote 过滤:%s,专门列表:%s)", + len(filtered), + ",".join(sorted(quote_set or {"USDT"})), + ",".join(sorted(include_set)) or "无", + ) + return filtered + + +def fetch_ohlcv_with_retry( + exchange: ccxt.binance, + symbol: str, + timeframe: str, + limit: int, + retries: int, + base_sleep: float, +) -> List[List[float]]: + attempt = 0 + while True: + try: + return exchange.fetch_ohlcv(symbol, timeframe=timeframe, limit=limit) + except ccxt.RateLimitExceeded as exc: + wait_time = max(exchange.rateLimit / 1000.0 if exchange.rateLimit else 0, base_sleep) + logging.debug("触发限频,等待 %.2f 秒后重试 %s:%s", wait_time, symbol, exc) + time.sleep(wait_time) + except (ccxt.NetworkError, ccxt.ExchangeError) as exc: + attempt += 1 + if attempt > retries: + logging.warning("多次获取失败,跳过 %s:%s", symbol, exc) + return [] + wait_time = base_sleep * attempt + logging.debug("请求失败,等待 %.2f 秒后重试 %s(第 %d 次):%s", wait_time, symbol, attempt, exc) + time.sleep(wait_time) + + +def linear_regression_slope(values: Sequence[float]) -> float: + n = len(values) + if n < 2: + return 0.0 + mean_x = (n - 1) / 2.0 + mean_y = sum(values) / n + numerator = 0.0 + denominator = 0.0 + for idx, value in enumerate(values): + dx = idx - mean_x + numerator += dx * (value - mean_y) + denominator += dx * dx + + if denominator == 0: + return 0.0 + return numerator / denominator + + +def compute_sideways_metrics(closes: Sequence[float], symbol: str) -> Optional[SidewaysMetrics]: + if not closes: + return None + + mean_close = sum(closes) / len(closes) + if math.isclose(mean_close, 0.0): + return None + + max_close = max(closes) + min_close = min(closes) + price_range_pct = (max_close - min_close) / mean_close + + slope = linear_regression_slope(closes) + slope_pct = slope / mean_close + + std_dev = statistics.pstdev(closes) if len(closes) > 1 else 0.0 + std_pct = std_dev / mean_close + + return SidewaysMetrics( + symbol=symbol, + price_range_pct=price_range_pct, + slope_pct=slope_pct, + std_pct=std_pct, + mean_close=mean_close, + last_close=closes[-1], + data_points=len(closes), + ) + + +def is_sideways(metrics: SidewaysMetrics, range_threshold: float, slope_threshold: float, std_threshold: float) -> bool: + return ( + metrics.price_range_pct <= range_threshold + and abs(metrics.slope_pct) <= slope_threshold + and metrics.std_pct <= std_threshold + ) + + +def export_results(path: str, results: Sequence[SidewaysMetrics]) -> None: + fieldnames = [ + "symbol", + "price_range_pct", + "slope_pct", + "std_pct", + "mean_close", + "last_close", + "data_points", + ] + with open(path, "w", newline="", encoding="utf-8") as fp: + writer = csv.DictWriter(fp, fieldnames=fieldnames) + writer.writeheader() + for item in results: + writer.writerow( + { + "symbol": item.symbol, + "price_range_pct": f"{item.price_range_pct:.6f}", + "slope_pct": f"{item.slope_pct:.6f}", + "std_pct": f"{item.std_pct:.6f}", + "mean_close": f"{item.mean_close:.8f}", + "last_close": f"{item.last_close:.8f}", + "data_points": item.data_points, + } + ) + logging.info("结果已导出至 %s", path) + + +def run(argv: Optional[Sequence[str]] = None) -> int: + args = parse_args(argv) + if not args.quote: + args.quote = ["USDT"] + setup_logging(args.verbose) + + exchange = create_exchange() + symbols = iter_target_symbols( + exchange=exchange, + quotes=args.quote, + includes=args.symbol, + include_inactive=args.include_inactive, + ) + + if args.max_symbols is not None: + symbols = symbols[: args.max_symbols] + logging.info("出于调试目的,仅检测前 %d 个交易对。", len(symbols)) + + if not symbols: + logging.error("未找到任何满足条件的交易对,请检查过滤条件。") + return 1 + + sideways_results: List[SidewaysMetrics] = [] + total = len(symbols) + + for idx, symbol in enumerate(symbols, start=1): + logging.info("(%d/%d) 正在获取 %s 的 %s K 线(limit=%d)", idx, total, symbol, args.timeframe, args.limit) + ohlcv = fetch_ohlcv_with_retry( + exchange=exchange, + symbol=symbol, + timeframe=args.timeframe, + limit=args.limit, + retries=args.retries, + base_sleep=args.sleep, + ) + + if len(ohlcv) < max(100, args.limit // 2): + logging.debug("交易对 %s 返回数据不足(%d 根),跳过。", symbol, len(ohlcv)) + continue + + closes = [entry[4] for entry in ohlcv if entry[4] is not None] + metrics = compute_sideways_metrics(closes, symbol) + if not metrics: + continue + + if is_sideways(metrics, args.range_threshold, args.slope_threshold, args.std_threshold): + sideways_results.append(metrics) + logging.info( + "识别为横盘:%s | 振幅 %.2f%% | 斜率 %.4f%% | 标准差 %.2f%%", + symbol, + metrics.price_range_pct * 100, + metrics.slope_pct * 100, + metrics.std_pct * 100, + ) + else: + logging.debug( + "未满足条件:%s | 振幅 %.2f%% | 斜率 %.4f%% | 标准差 %.2f%%", + symbol, + metrics.price_range_pct * 100, + metrics.slope_pct * 100, + metrics.std_pct * 100, + ) + + if not sideways_results: + logging.warning("未检测到满足定义的长期横盘交易对。") + return 0 + + sideways_results.sort(key=lambda item: (item.price_range_pct, abs(item.slope_pct), item.std_pct)) + print("=" * 88) + print( + f"共识别 {len(sideways_results)} 个长期横盘交易对(阈值:振幅≤{args.range_threshold:.2%}," + f"斜率≤{args.slope_threshold:.2%},标准差≤{args.std_threshold:.2%})" + ) + print("=" * 88) + header = f"{'Symbol':15s} {'Range%':>10s} {'Slope%':>10s} {'STD%':>10s} {'Mean':>14s} {'Last':>14s} {'Count':>6s}" + print(header) + print("-" * len(header)) + for item in sideways_results: + print( + f"{item.symbol:15s}" + f" {item.price_range_pct * 100:10.4f}" + f" {item.slope_pct * 100:10.4f}" + f" {item.std_pct * 100:10.4f}" + f" {item.mean_close:14.8f}" + f" {item.last_close:14.8f}" + f" {item.data_points:6d}" + ) + + if args.export: + export_results(args.export, sideways_results) + + logging.info("任务完成。") + return 0 + + +if __name__ == "__main__": + sys.exit(run()) + diff --git a/chanlun/analysis/ChanLun_Classifier.py b/chanlun/analysis/ChanLun_Classifier.py new file mode 100644 index 0000000..f4a40a3 --- /dev/null +++ b/chanlun/analysis/ChanLun_Classifier.py @@ -0,0 +1,529 @@ +import sys +import os +#sys.setrecursionlimit(1000000) #例如这里设置为一百万 +#sys.path.append(os.path.abspath("/freqtrade/user_data/Chan")) +sys.path.append(os.path.abspath("/Users/jack/Project/freqtrade/user_data/Chan")) +import numpy as np +from datetime import timedelta +from pandas import DataFrame +from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLINE_DIR, Chan_BI_DIR, Chan_SEG_DIR, Chan_ZS_DIR, Chan_BSP_DIR, Chan_BSP_TYPE, Chan_KLC_FX +from chanlun.core.ChanKLU import ChanKLU +from chanlun.core.ChanKLC import ChanKLC +from chanlun.core.ChanBI import ChanBI +from chanlun.core.ChanSBI import ChanSBI +from chanlun.core.ChanSEG import ChanSEG +from chanlun.core.ChanZS import ChanZS +from chanlun.core.ChanBSP import ChanBSP +import talib.abstract as ta +import pandas as pd +import matplotlib.pyplot as plt +from matplotlib.dates import DateFormatter, date2num +import matplotlib.patches as patches +from technical.util import resample_to_interval +from decimal import Decimal +from chanlun.pipeline.orchestrator import ChanLun +import xgboost as xgb +from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, classification_report + +class ChanLunClassifier: + def __init__(self, dataframe: DataFrame): + self.dataframe = dataframe + self.model = None + chan = ChanLun() + + def train_model(self, dataframe=None, data_file_path=None, model_file_path='chan_xgb_model.json', use_cv=False, custom_params=None, model_name=None): + """ + 使用dataframe前80%的数据训练XGBoost模型 + :param dataframe: 输入的DataFrame,如果为None则使用初始化时的dataframe + :param data_file_path: 特征数据保存路径,可选 + :param model_file_path: 模型保存路径 + :param use_cv: 是否使用交叉验证寻找最佳参数 + :param custom_params: 自定义模型参数 + :return: 训练好的模型 + """ + if dataframe is None: + dataframe = self.dataframe + + # 分割数据集,前80%用于训练 + train_size = int(len(dataframe) * 0.8) + train_df = dataframe.iloc[:train_size].copy() + + # 获取训练集特征和标签 + save_csv = True if data_file_path else False + X_train, y_train = self.get_feature_data(train_df, save_csv=save_csv, csv_path=data_file_path if data_file_path else 'feature_data.csv') + + if len(X_train) == 0: + print("没有提取到足够的特征数据进行训练") + return None + + # 保存特征数据的步骤已经移到get_feature_data方法中处理 + # 以下是原有代码 + #{'eta': 0.03, 'max_depth': 4, 'subsample': 0.8, 'colsample_bytree': 0.8, 'gamma': 0.1, 'min_child_weight': 3, 'alpha': 1, 'lambda': 3}, + # 默认XGBoost参数 + default_params = { + 'objective': 'binary:logistic', + 'max_depth': 8, + 'eta': 0.01, + 'subsample': 0.8, + 'colsample_bytree': 0.8, + 'eval_metric': 'auc', + 'gamma': 0.0, + 'min_child_weight': 1, + 'alpha': 0, # L1正则化 + 'lambda': 0.5, # L2正则化 + 'scale_pos_weight': 1 + } + + # 使用自定义参数覆盖默认参数 + if custom_params: + for key, value in custom_params.items(): + default_params[key] = value + + params = default_params + dtrain = xgb.DMatrix(X_train, label=y_train) + + # 如果使用交叉验证寻找最佳参数 + if use_cv: + from sklearn.model_selection import GridSearchCV, RandomizedSearchCV + from sklearn.metrics import make_scorer, accuracy_score, f1_score + import numpy as np + + # 转换为sklearn兼容格式 + xgb_model = xgb.XGBClassifier( + objective=params['objective'], + max_depth=params['max_depth'], + learning_rate=params['eta'], + subsample=params['subsample'], + colsample_bytree=params['colsample_bytree'], + gamma=params['gamma'], + min_child_weight=params['min_child_weight'], + reg_alpha=params['alpha'], + reg_lambda=params['lambda'], + scale_pos_weight=params['scale_pos_weight'], + use_label_encoder=False, + eval_metric='auc' + ) + + # 参数网格 + param_grid = { + 'max_depth': [3, 5, 7, 9], + 'learning_rate': [0.01, 0.05, 0.1, 0.2], + 'subsample': [0.6, 0.8, 1.0], + 'colsample_bytree': [0.6, 0.8, 1.0], + 'min_child_weight': [1, 3, 5], + 'gamma': [0, 0.1, 0.2], + 'n_estimators': [50, 100, 200] + } + + # 使用随机搜索寻找最佳参数(比网格搜索快) + random_search = RandomizedSearchCV( + estimator=xgb_model, + param_distributions=param_grid, + n_iter=10, # 随机尝试的参数组合数 + scoring=make_scorer(f1_score), + cv=5, + verbose=1, + n_jobs=-1, + random_state=42 + ) + + print("进行交叉验证参数搜索...") + random_search.fit(X_train, y_train) + + # 获取最佳参数 + best_params = random_search.best_params_ + print(f"最佳参数: {best_params}") + + # 使用最佳参数更新模型参数 + params['max_depth'] = best_params['max_depth'] + params['eta'] = best_params['learning_rate'] + params['subsample'] = best_params['subsample'] + params['colsample_bytree'] = best_params['colsample_bytree'] + params['min_child_weight'] = best_params['min_child_weight'] + params['gamma'] = best_params['gamma'] + num_round = best_params['n_estimators'] + + # 使用最佳参数训练最终模型 + self.model = xgb.train(params, dtrain, num_round) + else: + # 标准训练(不使用交叉验证) + # 使用早停机制避免过拟合 + # 分割训练集为训练和验证 + eval_size = int(len(X_train) * 0.2) + X_eval = X_train[-eval_size:] + y_eval = y_train[-eval_size:] + X_train_part = X_train[:-eval_size] + y_train_part = y_train[:-eval_size] + + dtrain_part = xgb.DMatrix(X_train_part, label=y_train_part) + deval = xgb.DMatrix(X_eval, label=y_eval) + + # 评估列表 + evallist = [(dtrain_part, 'train'), (deval, 'eval')] + + # 训练模型,使用早停 + num_round = 1000 # 设置较大的轮数,让早停机制决定何时停止 + self.model = xgb.train( + params, + dtrain_part, + num_round, + evallist, + early_stopping_rounds=50, # 50轮内评估指标无改善则停止 + verbose_eval=True + ) + + # 使用全部训练数据重新训练最终模型,使用最佳轮数 + # best_rounds = self.model.best_ntree_limit + # 兼容新版本的XGBoost + if hasattr(self.model, 'best_ntree_limit'): + best_rounds = self.model.best_ntree_limit + elif hasattr(self.model, 'best_iteration'): + best_rounds = self.model.best_iteration + elif hasattr(self.model, 'best_ntree_idx'): + best_rounds = self.model.best_ntree_idx + else: + # 如果都不存在,使用默认值 + best_rounds = num_round + print(f"最佳轮数: {best_rounds}") + + # 使用全部训练数据和最佳轮数训练最终模型 + self.model = xgb.train(params, dtrain, best_rounds) + + # 保存模型 + if model_file_path: + self.model.save_model(model_name + model_file_path) + + # 特征重要性分析 + if hasattr(self.model, 'get_score'): + importance = self.model.get_score(importance_type='gain') + print("\n特征重要性 (gain):") + for key, value in sorted(importance.items(), key=lambda x: x[1], reverse=True): + print(f"{key}: {value}") + + return self.model + def load_model(self, model_name=None, model_file_path='chan_xgb_model.json'): + if model_name: + self.model = xgb.Booster() + self.model.load_model(model_name + model_file_path) + else: + self.model = xgb.Booster() + self.model.load_model(model_file_path) + def find_best_params(self, dataframe=None, save_csv=False, csv_path_prefix='param_', model_name=None): + """ + 寻找最佳参数组合 + :param dataframe: 输入的DataFrame,如果为None则使用初始化时的dataframe + :param save_csv: 是否保存特征数据到CSV文件 + :param csv_path_prefix: CSV文件保存路径前缀,会自动添加参数信息 + :return: 最佳参数 + """ + # 不同参数组合 + param_combinations = [ + # 低学习率,深树 + {'eta': 0.01, 'max_depth': 8, 'subsample': 0.8, 'colsample_bytree': 0.8, 'gamma': 0, 'min_child_weight': 1}, + # 中等学习率,中等树深度 + {'eta': 0.05, 'max_depth': 5, 'subsample': 0.7, 'colsample_bytree': 0.7, 'gamma': 0.1, 'min_child_weight': 3}, + # 高学习率,浅树 + {'eta': 0.1, 'max_depth': 3, 'subsample': 0.6, 'colsample_bytree': 0.6, 'gamma': 0.2, 'min_child_weight': 5}, + # 正则化较强 best here + {'eta': 0.03, 'max_depth': 4, 'subsample': 0.8, 'colsample_bytree': 0.8, 'gamma': 0.1, 'min_child_weight': 3, 'alpha': 1, 'lambda': 3}, + # 正则化较弱 + {'eta': 0.08, 'max_depth': 6, 'subsample': 0.9, 'colsample_bytree': 0.9, 'gamma': 0, 'min_child_weight': 1, 'alpha': 0, 'lambda': 0.5}, + ] + + best_score = 0 + best_params = None + best_model = None + + for i, params in enumerate(param_combinations): + print(f"\n尝试参数组合: {params}") + # 生成CSV文件名,包含一些参数信息 + param_info = f"eta{params['eta']}_depth{params['max_depth']}" + train_csv_path = f"{csv_path_prefix}train_{param_info}.csv" if save_csv else None + + model = self.train_model(dataframe=dataframe, data_file_path=train_csv_path, custom_params=params, model_name=model_name) + + # 分割数据集,后20%用于测试 + if dataframe is None: + dataframe = self.dataframe + + train_size = int(len(dataframe) * 0.8) + test_df = dataframe.iloc[train_size:].copy() + + # 获取测试集特征和标签 + test_csv_path = f"{csv_path_prefix}test_{param_info}.csv" if save_csv else None + X_test, y_test = self.get_validate_feature_data(test_df, save_csv=save_csv, csv_path=test_csv_path) + + if len(X_test) == 0: + print("没有提取到足够的测试特征数据") + continue + + # 预测 + dtest = xgb.DMatrix(X_test) + y_pred_prob = model.predict(dtest) + y_pred = [1 if p > 0.5 else 0 for p in y_pred_prob] + + # 计算F1分数 + f1 = f1_score(y_test, y_pred, zero_division=0) + print(f"F1分数: {f1:.4f}") + + if f1 > best_score: + best_score = f1 + best_params = params + best_model = model + + print(f"\n最佳参数组合 (F1={best_score:.4f}):") + print(best_params) + self.model = best_model + + return best_params + + def get_feature_data(self, dataframe, save_csv=False, csv_path='feature_data.csv'): + """ + 从dataframe提取特征数据 + :param dataframe: 输入的DataFrame + :param save_csv: 是否保存特征数据到CSV文件 + :param csv_path: CSV文件保存路径 + :return: 特征矩阵X和标签y + """ + # 使用ChanLun获取bi_list + klc_list = self.chan.get_klc_list(dataframe) + bi_list = self.chan.cal_bi_list(klc_list) + # 筛选方向为UP的bi的起始klc + feature_data = [] + labels = [] + feature_keys = [] # 用于保存特征名称 + + bi_index = 1 + sample_list = [] + for klc in klc_list: + if klc.klc_fx_type != Chan_KLC_FX.UNKNOWN: + sample_list.append(klc) + klc_count = 0 + print('Processing data...') + for klc in sample_list: + if bi_index >= len(bi_list): + bi_index = len(bi_list) - 1 + #bi = bi_list[bi_index] + #if klc.end_klu and bi.end_klc and klc.start_klu.index >= bi.start_klc.start_klu.index and klc.end_klu.index <= bi.end_klc.end_klu.index: + #klc.set_bi(bi) + + # 提取特征 + features = klc.get_feature_data() + + # 保存第一个样本的特征名称,用于CSV列名 + if len(feature_keys) == 0: + feature_keys = list(features.keys()) + # 将特征转换为模型可用的格式 + feature_vec = [] + for key, value in features.items(): + if isinstance(value, (int, float)): + feature_vec.append(value) + else: + feature_vec.append(0) + + # 判断这个bi是否赚钱(这里简单定义为:如果bi的结束价格高于起始价格,则标记为1,否则为0) + # 这个标签定义可以根据实际需求修改 + matched = False + for bi in bi_list: + if bi.end_klc and bi.end_klc.index == klc.index: + #print(bi.start_time, bi.start_klc.start_time, bi.dir) + label = 1 + matched = True + break + if not matched: + label = 0 + + feature_data.append(feature_vec) + labels.append(label) + klc_count += 1 + percent = klc_count/len(sample_list)*100 + if percent % 10 == 0: + print('Data processed:', percent, '%') + for index, key in enumerate(feature_keys): + print(index, key, feature_data[0][index]) + # 如果需要保存到CSV + if save_csv: + # 创建DataFrame保存特征数据 + # 只保留数值型特征 + numeric_feature_keys = [key for i, key in enumerate(feature_keys) + if i < len(feature_data[0]) if isinstance(feature_data[0][i], (int, float))] + + # 创建特征数据的DataFrame + df_features = pd.DataFrame(feature_data, columns=numeric_feature_keys) + # 添加标签列 + df_features['label'] = labels + # 添加时间信息便于分析 + if len(sample_list) > 0: + times = [klc.start_time for klc in sample_list] + df_features['time'] = times + + # 保存到CSV + df_features.to_csv(csv_path, index=False) + print(f"特征数据已保存到 {csv_path}") + + # 在return前添加 + positive_count = np.sum(labels) + print(f"正样本数量: {positive_count}, 负样本数量: {len(labels) - positive_count}") + print("Trainning data: ", len(feature_data), klc_list[-1].start_time, klc_list[-1].klc_fx_type , "---------------------") + return np.array(feature_data), np.array(labels) + def get_validate_feature_data(self, dataframe, save_csv=False, csv_path='validate_feature_data.csv'): + """ + 从dataframe提取特征数据 + :param dataframe: 输入的DataFrame + :param save_csv: 是否保存特征数据到CSV文件 + :param csv_path: CSV文件保存路径 + :return: 特征矩阵X和标签y + """ + # 使用ChanLun获取bi_list + klc_list = self.chan.get_klc_list(dataframe) + bi_list = self.chan.cal_bi_list(klc_list) + seg_list = self.chan.get_seg_list(bi_list) + # 筛选方向为UP的bi的起始klc + feature_data = [] + labels = [] + feature_keys = [] # 用于保存特征名称 + + bi_index = 1 + sample_list = [] + for klc in klc_list: + if klc.klc_fx_type != Chan_KLC_FX.UNKNOWN: + sample_list.append(klc) + for klc in sample_list: + if bi_index >= len(bi_list): + bi_index = len(bi_list) - 1 + bi = bi_list[bi_index] + # 提取特征 + features = klc.get_feature_data() + + # 保存第一个样本的特征名称,用于CSV列名 + if len(feature_keys) == 0: + feature_keys = list(features.keys()) + + # 将特征转换为模型可用的格式 + feature_vec = [] + # 与get_feature_data保持一致,只使用相同的特征集 + for key, value in features.items(): + if isinstance(value, (int, float)): + feature_vec.append(value) + else: + feature_vec.append(0) + seg = seg_list[bi_index] + matched = False + for bi in bi_list: + if bi.end_klc and bi.end_klc.index == klc.index: + label = 1 + matched = True + break + if not matched: + label = 0 + + feature_data.append(feature_vec) + labels.append(label) + + # 如果需要保存到CSV + if save_csv: + # 创建DataFrame保存特征数据 + # 只保留数值型特征 + numeric_feature_keys = [key for i, key in enumerate(feature_keys) + if i < len(feature_data[0]) if isinstance(feature_data[0][i], (int, float))] + + # 创建特征数据的DataFrame + df_features = pd.DataFrame(feature_data, columns=numeric_feature_keys) + # 添加标签列 + df_features['label'] = labels + # 添加时间信息便于分析 + if len(sample_list) > 0: + times = [klc.start_time for klc in sample_list] + df_features['time'] = times + + # 保存到CSV + df_features.to_csv(csv_path, index=False) + print(f"验证特征数据已保存到 {csv_path}") + + print("Validating data: ", len(feature_data), klc_list[-1].start_time, klc_list[-1].klc_fx_type , "---------------------") + return np.array(feature_data), np.array(labels) + def validate_model(self, dataframe=None, save_csv=False, csv_path='validate_feature_data.csv'): + """ + 使用dataframe后20%的数据验证模型 + :param dataframe: 输入的DataFrame,如果为None则使用初始化时的dataframe + :param save_csv: 是否保存特征数据到CSV文件 + :param csv_path: CSV文件保存路径 + :return: 验证结果 + """ + if self.model is None: + print("模型尚未训练,请先调用train_model方法") + return None + + if dataframe is None: + dataframe = self.dataframe + + # 分割数据集,后20%用于测试 + train_size = int(len(dataframe) * 0.8) + test_df = dataframe.iloc[train_size:].copy() + + # 获取测试集特征和标签 + X_test, y_test = self.get_validate_feature_data(test_df, save_csv=save_csv, csv_path=csv_path) + + if len(X_test) == 0: + print("没有提取到足够的测试特征数据") + return None + + # 预测 + dtest = xgb.DMatrix(X_test) + y_pred_prob = self.model.predict(dtest) + y_pred = [1 if p > 0.5 else 0 for p in y_pred_prob] + + # 计算评估指标 + accuracy = accuracy_score(y_test, y_pred) + precision = precision_score(y_test, y_pred, zero_division=0) + recall = recall_score(y_test, y_pred, zero_division=0) + f1 = f1_score(y_test, y_pred, zero_division=0) + + # 打印评估报告 + print("模型评估结果:") + print(f"准确率: {accuracy:.4f}") + print(f"精确率: {precision:.4f}") + print(f"召回率: {recall:.4f}") + print(f"F1分数: {f1:.4f}") + print("\n分类报告:") + print(classification_report(y_test, y_pred, zero_division=0)) + + return { + 'accuracy': accuracy, + 'precision': precision, + 'recall': recall, + 'f1': f1, + 'y_test': y_test, + 'y_pred': y_pred, + 'y_pred_prob': y_pred_prob + } + + def predict(self, klc): + """ + 使用训练好的模型预测单个KLC + :param klc: 需要预测的ChanKLC对象 + :return: 预测结果(概率值) + """ + if self.model is None: + print("模型尚未训练,请先调用train_model方法") + return None + + # 提取特征 + features = klc.get_feature_data() + feature_vec = [] + # 与get_feature_data保持一致,只使用相同的特征集 + for key, value in features.items(): + if isinstance(value, (int, float)): + feature_vec.append(value) + else: + feature_vec.append(0) + + # 转换为模型输入格式 + dtest = xgb.DMatrix(np.array([feature_vec])) + + # 预测 + return self.get_decimal(self.model.predict(dtest)[0]) + + def get_decimal(self, value): + return Decimal("{:.4f}".format(value)) + diff --git a/chanlun/analysis/ChanPY.py b/chanlun/analysis/ChanPY.py new file mode 100644 index 0000000..0096f3c --- /dev/null +++ b/chanlun/analysis/ChanPY.py @@ -0,0 +1,402 @@ +import sys +import os +#sys.path.append(os.path.abspath("/Users/jack/Documents/GitHub/chan.py")) +sys.path.append(os.path.abspath("/Users/jack/Project/chan.py")) +from Chan import CChan +from BuySellPoint.BS_Point import CBS_Point +from ChanConfig import CChanConfig +from Common.CEnum import AUTYPE, DATA_SRC, KL_TYPE, DATA_FIELD, BSP_TYPE, FX_TYPE, BI_DIR, KLINE_DIR, SEG_DIR +from KLine.KLine_Unit import CKLine_Unit +from Common.CTime import CTime +from Common.func_util import kltype_lt_day, str2float +from Bi.Bi import CBi +from typing import Dict, List +from functools import reduce +from pandas import DataFrame +from datetime import datetime, timedelta, timezone + + +def GetColumnNameFromFieldList(fileds: str): + _dict = { + "time": DATA_FIELD.FIELD_TIME, + "open": DATA_FIELD.FIELD_OPEN, + "high": DATA_FIELD.FIELD_HIGH, + "low": DATA_FIELD.FIELD_LOW, + "close": DATA_FIELD.FIELD_CLOSE, + "volume": DATA_FIELD.FIELD_VOLUME + } + return [_dict[x] for x in fileds.split(",")] +class ChanPY(): + k_type = KL_TYPE.K_5M + config = CChanConfig({ + "bi_strict": True, + "bi_algo": "normal", + "trigger_step": True, + "skip_step": 0, + "divergence_rate": float("inf"), + "bsp2_follow_1": False, + "bsp3_follow_1": False, + "min_zs_cnt": 1, + "bs1_peak": False, + "macd_algo": "peak", + "bs_type": '1,2,3a,1p,2s,3b', + "print_warning": True, + "zs_algo": "normal", + }) + chan = CChan( + code="BTC/USDT:USDT", + data_src=DATA_SRC.CCXT, + lv_list=[k_type], + config=config, + autype=AUTYPE.QFQ, + ) + klu_list = [] + bsps = [] + chanIn = True + #def __init__(self, dataframe): + #self.klu_list = self.get_kl_data(dataframe) + #for klu in self.klu_list: + #self.chan.trigger_load({self.k_type: [klu]}) + def add_klu(self, klu): + if klu: + self.chan.trigger_load({self.k_type: [klu]}) + self.klu_list.append(klu) + def add_klu_from_dataframe(self, dataframe): + if len(dataframe) > len(self.klu_list) and len(dataframe) - len(self.klu_list) == 1: + klu = self.get_last_klu(dataframe) + self.chan.trigger_load({self.k_type: [klu]}) + self.klu_list.append(klu) + def parse_time_column(self, inp): + if len(inp) == 10: + year = int(inp[:4]) + month = int(inp[5:7]) + day = int(inp[8:10]) + hour = minute = 0 + elif len(inp) == 17: + year = int(inp[:4]) + month = int(inp[4:6]) + day = int(inp[6:8]) + hour = int(inp[8:10]) + minute = int(inp[10:12]) + elif len(inp) == 19: + year = int(inp[:4]) + month = int(inp[5:7]) + day = int(inp[8:10]) + hour = int(inp[11:13]) + minute = int(inp[14:16]) + else: + raise Exception(f"unknown time column from TradingView:{inp}") + return CTime(year, month, day, hour, minute, auto=not kltype_lt_day(self.k_type)) + + def create_item_dict(self, data, column_name): + for i in range(len(data)): + data[i] = self.parse_time_column(data[i]) if i == 0 else str2float(data[i]) + return dict(zip(column_name, data)) + def get_last_klu(self, dataframe:DataFrame): + fields = "time,open,high,low,close,volume" + item = dataframe.iloc[-1] + date = item['date'] + o = item['open'] + h = item['high'] + l = item['low'] + c = item['close'] + v = item['volume'] + #time_obj = date.fromtimestamp(date) + time_str = date.strftime('%Y-%m-%d %H:%M:%S') + item_data = [ + time_str, + o, + h, + l, + c, + v + ] + klu = CKLine_Unit(self.create_item_dict(item_data, GetColumnNameFromFieldList(fields)), autofix=True) + klu.set_idx(len(dataframe)-1) + return klu + def get_kl_data(self, dataframe:DataFrame): + fields = "time,open,high,low,close,volume" + klu_list = [] + for i in range(0, len(dataframe)): + item = dataframe.iloc[i] + date = item['date'] + o = item['open'] + h = item['high'] + l = item['low'] + c = item['close'] + v = item['volume'] + #time_obj = date.fromtimestamp(date) + time_str = date.strftime('%Y-%m-%d %H:%M:%S') + item_data = [ + time_str, + o, + h, + l, + c, + v + ] + klu = CKLine_Unit(self.create_item_dict(item_data, GetColumnNameFromFieldList(fields)), autofix=True) + klu.set_idx(i) + klu_list.append(klu) + return klu_list + def get_bsp_type(self, bsp_type, is_buy): + if is_buy: + if bsp_type == BSP_TYPE.T1: + return 1 + if bsp_type == BSP_TYPE.T1P: + return 2 + if bsp_type == BSP_TYPE.T2: + return 3 + if bsp_type == BSP_TYPE.T2S: + return 4 + if bsp_type == BSP_TYPE.T3A: + return 5 + if bsp_type == BSP_TYPE.T3B: + return 6 + else: + if bsp_type == BSP_TYPE.T1: + return -1 + if bsp_type == BSP_TYPE.T1P: + return -2 + if bsp_type == BSP_TYPE.T2: + return -3 + if bsp_type == BSP_TYPE.T2S: + return -4 + if bsp_type == BSP_TYPE.T3A: + return -5 + if bsp_type == BSP_TYPE.T3B: + return -6 + def get_bsps(self, dataframe:DataFrame): + fields = "time,open,high,low,close,volume" + bsps = [] + updown = [] + bi_sure = [] + if self.chanIn: + kl_data = self.get_kl_data(dataframe) + bsp_list = [] + bsp_list_pre_len = 0 + last_bsp_value = 0 + last_updown = -1 + bi_list_pre_len = 0 + pre_bi = None + zs_list_pre_len = 0 + pre_zs = None + for klu in kl_data: # 获取单根K线 + self.chan.trigger_load({self.k_type: [klu]}) # 喂给CChan新增k线 + self.last_kline = klu + bsp_list = self.chan.get_bsp() + kl_datas = self.chan.kl_datas[self.k_type] + bi_list = kl_datas.bi_list + lst = kl_datas.lst + if len(bsp_list) > 0: + last_bsp = bsp_list[-1] + #print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value, lst[-2].fx, bi_list[-1].dir, bi_list[-1].is_sure,klu.close) + if bsp_list_pre_len > len(bsp_list): + if abs(last_bsp_value) == 1 or abs(last_bsp_value) == 2: + bsps.append(1) + #print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value, 98) + else: + bsps.append(99) + else: + if bsp_list_pre_len == len(bsp_list): + if klu.idx == last_bsp.klu.idx: + last_bsp_value = self.get_bsp_type(last_bsp.type[0], last_bsp.is_buy) + bsps.append(last_bsp_value) + #print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value) + else: + bsps.append(0) + else: + last_bsp_value = self.get_bsp_type(last_bsp.type[0], last_bsp.is_buy) + bsps.append(last_bsp_value) + #print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value) + else: + bsps.append(0) + bsp_list_pre_len = len(bsp_list) + + #Check zs ----------------------------------- + zs_list = kl_datas.zs_list + if len(zs_list) > 0: + zs = zs_list[-1] + #if zs_list_pre_len > len(zs_list): + #print("No zs", zs.begin.time) + #if len(zs_list) > zs_list_pre_len: + #print(zs.begin.time, zs.end.time, zs.end.idx, zs.high, zs.low, zs.peak_high, zs.peak_low) + zs_list_pre_len = len(zs_list) + pre_zs = zs + + #Check Bi ----------------------------------- + if len(bi_list) > 0: + last_bi = bi_list[-1] + if len(bi_list) == 1: + if last_bi.dir == BI_DIR.UP: + updown.append(1) + last_updown = 1 + else: + updown.append(-1) + last_updown = -1 + else: + if last_updown == 1: + if last_bi.dir == BI_DIR.UP: + updown.append(0) + else: + updown.append(-1) + last_updown = -1 + else: + if last_bi.dir == BI_DIR.DOWN: + updown.append(0) + else: + updown.append(1) + last_updown = 1 + else: + updown.append(0) + bi_list = kl_datas.bi_list + if len(bi_list) > 0: + last_bi = bi_list[-1] + #if bi_list_pre_len > len(bi_list): + #print("Bi ", klu.time, pre_bi.idx, pre_bi.is_sure, bi_list[-1].idx, bi_list[-1].is_sure) + if last_bi.is_sure: + bi_sure.append(1) + #print(klu.time, last_bi.is_sure) + else: + bi_sure.append(0) + pre_bi = bi_list[-1] + bi_list_pre_len = len(bi_list) + else: + bi_sure.append(0) + #if bsps[-1] != 0 or updown[-1] != 0: + #print(klu.time, bsps[-1], updown[-1], bi_list[-1].is_sure) + self.chanIn = False + else: + klu = self.get_last_klu(dataframe) + if self.last_kline.time < klu.time: + self.chan.trigger_load({self.k_type: [klu]}) # 喂给CChan新增k线 + self.last_kline = klu + for index in range(0, len(bsps)): + if not (abs(bsps[index]) == 1 or abs(bsps[index]) == 2): + bsps[index] = 0 + else: + if bsps[index] == 2: + bsps[index] = 1 + else: + if bsps[index] == -2: + bsps[index] = -1 + else: + bsps[index] = 0 + #print(bsps) + #print(updown) + kl_datas = self.chan.kl_datas[self.k_type] + #for zs in kl_datas.zs_list: + #print(zs.begin.time, zs.end.time) + return bsps, updown, bi_sure + + def get_bsp_state1(self, dataframe:DataFrame): + fields = "time,open,high,low,close,volume" + bsps = [] + if self.chanIn: + kl_data = self.get_kl_data(dataframe) + self.chan.trigger_load({self.k_type: kl_data}) + bsp_list = self.chan.get_bsp() + bsp_index = 0 + for klu in kl_data: + if bsp_index >= len(bsp_list): + bsp_index = len(bsp_list) - 1 + bsp = bsp_list[bsp_index] + if klu.idx == bsp.klu.idx: + bsp_type = self.get_bsp_type(bsp.type[0], bsp.is_buy) + if abs(bsp_type) == 1 or abs(bsp_type) == 10: + bsps.append(1) + else: + bsps.append(0) + bsp_index = bsp_index + 1 + else: + bsps.append(0) + self.chanIn = False + else: + klu = CKLine_Unit(self.create_item_dict(self.get_last_item_data(dataframe), GetColumnNameFromFieldList(fields)), autofix=True) + if self.last_kline.time < klu.time: + self.chan.trigger_load({self.k_type: [klu]}) # 喂给CChan新增k线 + self.last_kline = klu + return bsps + def get_bsp_state(self, dataframe:DataFrame): + fields = "time,open,high,low,close,volume" + if self.chanIn: + kl_data = self.get_kl_data(dataframe) + bsp_list = [] + bsp_list_pre_len = 0 + last_bsp_value = 0 + last_bsp_index = 0 + for klu in kl_data: # 获取单根K线 + self.chan.trigger_load({self.k_type: [klu]}) # 喂给CChan新增k线 + self.last_kline = klu + bsp_list = self.chan.get_bsp() + kl_datas = self.chan.kl_datas[self.k_type] + bi_list = kl_datas.bi_list + lst = kl_datas.lst + if len(bsp_list) > 0: + last_bsp = bsp_list[-1] + #print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value, lst[-2].fx, bi_list[-1].dir, bi_list[-1].is_sure,klu.close) + if bsp_list_pre_len > len(bsp_list): + if abs(last_bsp_value) == 1: + self.bsps.append(1) + #print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value, 98) + else: + self.bsps.append(99) + else: + if bsp_list_pre_len == len(bsp_list): + if klu.idx == last_bsp.klu.idx: + if last_bsp.klu.idx - last_bsp_index > 3: + last_bsp_value = self.get_bsp_type(last_bsp.type[0], last_bsp.is_buy) + self.bsps.append(last_bsp_value) + else: + self.bsps.append(0) + last_bsp_index = last_bsp.klu.idx + #if abs(last_bsp_value) == 1 or abs(last_bsp_value) == 2: + #print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value, "Knonw") + else: + self.bsps.append(0) + else: + if klu.idx == last_bsp.klu.idx: + if last_bsp.klu.idx - last_bsp_index > 3: + last_bsp_value = self.get_bsp_type(last_bsp.type[0], last_bsp.is_buy) + self.bsps.append(last_bsp_value) + else: + self.bsps.append(0) + last_bsp_index = last_bsp.klu.idx + #if abs(last_bsp_value) == 1 or abs(last_bsp_value) == 2: + #print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value, "Knonw") + else: + self.bsps.append(0) + else: + self.bsps.append(0) + bsp_list_pre_len = len(bsp_list) + self.chanIn = False + else: + klu = self.get_last_klu(dataframe) + if self.last_kline.time < klu.time: + self.chan.trigger_load({self.k_type: [klu]}) # 喂给CChan新增k线 + self.last_kline = klu + bsp_list = self.chan.get_bsp() + last_bsp = bsp_list[-1] + if last_bsp.klu.idx == klu.idx: + self.bsps.append(self.get_bsp_type(last_bsp.type[0], last_bsp.is_buy)) + else: + self.bsps.append(0) + for index in range(0, len(self.bsps)): + if not (abs(self.bsps[index]) == 1 or abs(self.bsps[index]) == 2): + self.bsps[index] = 0 + else: + if self.bsps[index] == 2: + self.bsps[index] = 10 + else: + if self.bsps[index] == -2: + self.bsps[index] = -10 + else: + if self.bsps[index] == 1: + self.bsps[index] = 1 + else: + if self.bsps[index] == -1: + self.bsps[index] = -1 + else: + self.bsps[index] = 0 + return self.bsps + diff --git a/chanlun/analysis/ChanPivotClassifier.py b/chanlun/analysis/ChanPivotClassifier.py new file mode 100644 index 0000000..5dea9bb --- /dev/null +++ b/chanlun/analysis/ChanPivotClassifier.py @@ -0,0 +1,292 @@ +""" +中枢结构特征提取 + 标签化 +Market Structure Dataset Builder — Phase 1 + +定位: 训练数据集构建工具,不是交易信号生成器。 +Feature 描述中枢内部结构,Label 记录中枢后实际演化。 +""" + +import math +import json +from typing import Optional +from chanlun.core.ChanEnum import Chan_BI_DIR + + +class ChanPivotClassifier: + """ + 中枢结构特征提取 + 标签化 + 输入: bi_zs_list (list[ChanBIZS]) + 输出: 结构化数据集 (list[dict]) + """ + + DATASET_VERSION = "pivot_v1" + FEATURE_SCHEMA = ["duration_norm", "contraction", "shift_norm"] + LABEL_SCHEMA = {"name": "break_direction", "values": ["up", "down", "none"]} + + def __init__(self, bi_zs_list: list, symbol: str = "", timeframe: str = ""): + self.bi_zs_list = bi_zs_list + self.symbol = symbol + self.timeframe = timeframe + + # ------------------------------------------------------------------ + # Feature extraction + # ------------------------------------------------------------------ + + @staticmethod + def calc_duration(zs) -> int: + """持续时间: 第一笔首K → 最后一笔末K 的 index 差""" + bi_list = zs.bi_list + start_idx = bi_list[0].start_klc.index + end_idx = bi_list[-1].end_klc.index + return end_idx - start_idx + + @staticmethod + def calc_contraction(zs) -> float: + """收敛率: 后窗口振幅均值 / 前窗口振幅均值""" + bi_list = zs.bi_list + if len(bi_list) < 4: + return 1.0 + + n = min(3, len(bi_list) // 2) + first_ranges = [bi.high - bi.low for bi in bi_list[:n]] + last_ranges = [bi.high - bi.low for bi in bi_list[-n:]] + + first_mean = sum(first_ranges) / len(first_ranges) + last_mean = sum(last_ranges) / len(last_ranges) + + if first_mean == 0: + return 1.0 + return last_mean / first_mean + + @staticmethod + def calc_shift(zs) -> tuple[float, float]: + """重心漂移: 前后半段重心均值差 (原始值, 归一化值)""" + bi_list = zs.bi_list + mid = len(bi_list) // 2 + + first_centers = [(bi.high + bi.low) / 2 for bi in bi_list[:mid]] + last_centers = [(bi.high + bi.low) / 2 for bi in bi_list[mid:]] + + shift_raw = ( + sum(last_centers) / len(last_centers) + - sum(first_centers) / len(first_centers) + ) + + zs_height = zs.zg - zs.zd + if zs_height == 0: + shift_norm = 0.0 + else: + shift_norm = shift_raw / zs_height + + return shift_raw, shift_norm + + @staticmethod + def compute_duration_norm(duration_raw: int, historical_durations: list) -> float: + """用历史窗口均值归一化 duration""" + if not historical_durations: + return 1.0 + avg = sum(historical_durations) / len(historical_durations) + if avg == 0: + return 1.0 + return duration_raw / avg + + @staticmethod + def compute_features(zs, historical_durations: Optional[list] = None): + """计算单个中枢的全部结构特征(实时友好)""" + duration_raw = ChanPivotClassifier.calc_duration(zs) + contraction = ChanPivotClassifier.calc_contraction(zs) + shift_raw, shift_norm = ChanPivotClassifier.calc_shift(zs) + + if historical_durations is not None and len(historical_durations) > 0: + duration_norm = ChanPivotClassifier.compute_duration_norm( + duration_raw, historical_durations + ) + else: + duration_norm = 1.0 + + return { + "duration_raw": duration_raw, + "duration_norm": round(duration_norm, 4), + "contraction": round(contraction, 4), + "shift_raw": round(shift_raw, 6), + "shift_norm": round(shift_norm, 4), + "zs_height": round(zs.zg - zs.zd, 6), + } + + # ------------------------------------------------------------------ + # Label computation + # ------------------------------------------------------------------ + + @staticmethod + def _clamp(x: float, lo: float = 0.0, hi: float = 1.0) -> float: + return max(lo, min(hi, x)) + + def _compute_label(self, zs, contraction: float, shift_norm: float) -> dict: + """计算标签: up / down / none + 连续置信度""" + bi_out = zs.bi_out + + if bi_out is None: + return { + "label": "none", + "label_confidence": 0.0, + "label_detail": { + "bi_out_dir": "none", + "score_breakout": 0.0, + "score_shift": 0.0, + "score_contraction": 0.0, + }, + } + + zs_height = zs.zg - zs.zd + if zs_height == 0: + zs_height = 1e-8 + + # ---- 向上突破分数 ---- + if bi_out.dir == Chan_BI_DIR.UP: + raw_breakout = (bi_out.high - zs.gg) / zs_height + score_breakout_up = self._clamp(raw_breakout) + score_shift_up = math.tanh(self._clamp(shift_norm, -3.0, 3.0)) + score_contraction_up = max(0.0, 1.0 - contraction) + else: + score_breakout_up = 0.0 + score_shift_up = 0.0 + score_contraction_up = 0.0 + + up_score = ( + score_breakout_up * 0.5 + + score_shift_up * 0.3 + + score_contraction_up * 0.2 + ) + + # ---- 向下突破分数 ---- + if bi_out.dir == Chan_BI_DIR.DOWN: + raw_breakout = (zs.dd - bi_out.low) / zs_height + score_breakout_down = self._clamp(raw_breakout) + score_shift_down = math.tanh(self._clamp(-shift_norm, -3.0, 3.0)) + score_contraction_down = max(0.0, 1.0 - contraction) + else: + score_breakout_down = 0.0 + score_shift_down = 0.0 + score_contraction_down = 0.0 + + down_score = ( + score_breakout_down * 0.5 + + score_shift_down * 0.3 + + score_contraction_down * 0.2 + ) + + # ---- 判定 ---- + threshold = 0.15 + + if up_score > down_score and up_score > threshold: + label = "up" + confidence = up_score + detail = { + "bi_out_dir": "up", + "score_breakout": round(score_breakout_up, 4), + "score_shift": round(score_shift_up, 4), + "score_contraction": round(score_contraction_up, 4), + } + elif down_score > up_score and down_score > threshold: + label = "down" + confidence = down_score + detail = { + "bi_out_dir": "down", + "score_breakout": round(score_breakout_down, 4), + "score_shift": round(score_shift_down, 4), + "score_contraction": round(score_contraction_down, 4), + } + else: + label = "none" + confidence = max(up_score, down_score) + bi_dir = "up" if bi_out.dir == Chan_BI_DIR.UP else "down" + detail = { + "bi_out_dir": bi_dir, + "score_breakout": round(max(score_breakout_up, score_breakout_down), 4), + "score_shift": round(max(score_shift_up, score_shift_down), 4), + "score_contraction": round(max(score_contraction_up, score_contraction_down), 4), + } + + return { + "label": label, + "label_confidence": round(confidence, 4), + "label_detail": detail, + } + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def extract(self) -> list[dict]: + """主入口:对每个中枢提取 3 特征 + 1 标签""" + + # 第一遍:计算原始值 + raw = [] + for i, zs in enumerate(self.bi_zs_list): + if not zs.is_sure or len(zs.bi_list) < 3: + continue + + duration_raw = ChanPivotClassifier.calc_duration(zs) + contraction = ChanPivotClassifier.calc_contraction(zs) + shift_raw, shift_norm = ChanPivotClassifier.calc_shift(zs) + + raw.append({ + "zs": zs, + "zs_index": i, + "duration_raw": duration_raw, + "contraction": contraction, + "shift_raw": shift_raw, + "shift_norm": shift_norm, + "zs_height": zs.zg - zs.zd, + }) + + # 第二遍:组装输出 + 计算 label + result = [] + for r in raw: + zs = r["zs"] + historical = [x["duration_raw"] for x in raw] + duration_norm = ChanPivotClassifier.compute_duration_norm( + r["duration_raw"], historical + ) + label_info = self._compute_label(zs, r["contraction"], r["shift_norm"]) + + # 时间处理 + start_time = None + end_time = None + if hasattr(zs, "start_time") and zs.start_time is not None: + start_time = str(zs.start_time) + if hasattr(zs, "end_time") and zs.end_time is not None: + end_time = str(zs.end_time) + + result.append({ + "dataset_version": self.DATASET_VERSION, + "feature_schema": self.FEATURE_SCHEMA, + "label_schema": self.LABEL_SCHEMA, + + "symbol": self.symbol, + "timeframe": self.timeframe, + "zs_index": r["zs_index"], + "zs_start_time": start_time, + "zs_end_time": end_time, + + "duration_norm": round(duration_norm, 4), + "contraction": round(r["contraction"], 4), + "shift_norm": round(r["shift_norm"], 4), + + "label": label_info["label"], + "label_confidence": label_info["label_confidence"], + "label_detail": label_info["label_detail"], + + "duration_raw": r["duration_raw"], + "shift_raw": round(r["shift_raw"], 6), + "zs_height": round(r["zs_height"], 6), + }) + + return result + + def export_json(self, path: str): + """导出为 JSON 文件""" + data = self.extract() + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False, default=str) + return len(data) diff --git a/chanlun/analysis/ChanPivotMonitor.py b/chanlun/analysis/ChanPivotMonitor.py new file mode 100644 index 0000000..2c8055b --- /dev/null +++ b/chanlun/analysis/ChanPivotMonitor.py @@ -0,0 +1,145 @@ +""" +实时中枢特征跟踪器 +Real-time Pivot Feature Tracker + +定位: 观察者 — 不修改管线,只观察 bi_zs_list 中当前中枢的特征变化。 +每次管线重算后调用 update(),检测 bi_count 是否增长,若增长则重新计算 +shift / contraction / duration。 +""" + +from collections import deque +from typing import Optional +from chanlun.analysis.ChanPivotClassifier import ChanPivotClassifier + + +class ChanPivotMonitor: + """ + 实时追踪当前中枢的结构特征。 + + update() 每次管线重算后调用,对比 bi_count 判断是否有新笔加入中枢。 + 若 bi_count 增长则重新计算 3 个结构特征并返回最新值。 + """ + + def __init__(self, window_size: int = 10): + self._window_size = window_size + self._duration_history: deque[int] = deque(maxlen=window_size) + self._current_zs_id: Optional[tuple] = None + self._current_bi_count: int = 0 + self._current_is_sure: bool = False + self._current_state: Optional[dict] = None + self._duration_added_for_zs: set = set() # 已加入窗口的中枢 ID(上限 200) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def update(self, bi_zs_list: list) -> Optional[dict]: + """ + 主入口:检测当前中枢特征变化。 + + 参数: + bi_zs_list: 当前管线产出的笔中枢列表 + + 返回: + 特征 dict(有变化时),无变化返回 None + """ + if not bi_zs_list: + self._current_zs_id = None + self._current_bi_count = 0 + self._current_is_sure = False + self._current_state = None + return None + + zs = self._find_current_zs(bi_zs_list) + if zs is None: + return None + + zs_id = self._make_zs_id(zs) + bi_count = len(zs.bi_list) + is_sure = zs.is_sure + + # 无变化 → 跳过 + if (zs_id == self._current_zs_id + and bi_count == self._current_bi_count + and is_sure == self._current_is_sure): + return None + + # 中枢切换 → 将旧中枢 duration 加入窗口 + if zs_id != self._current_zs_id: + self._maybe_add_to_history() + + self._current_zs_id = zs_id + self._current_bi_count = bi_count + self._current_is_sure = is_sure + + features = ChanPivotClassifier.compute_features( + zs, list(self._duration_history) + ) + + self._current_state = { + "zs_id": zs_id, + "zs_index": zs.index, + "zs_dir": str(zs.dir), + "bi_count": bi_count, + "is_sure": zs.is_sure, + "zg": round(zs.zg, 6), + "zd": round(zs.zd, 6), + "gg": round(zs.gg, 6), + "dd": round(zs.dd, 6), + **features, + "start_time": str(t) if (t := getattr(zs, "start_time", None)) else None, + } + + # 中枢刚变为已确认时,将其 duration 加入滚动窗口 + if is_sure and zs_id not in self._duration_added_for_zs: + self._add_duration(features["duration_raw"]) + self._duration_added_for_zs.add(zs_id) + + return self._current_state + + def get_current(self) -> Optional[dict]: + """返回当前中枢的最新特征""" + return self._current_state + + def get_duration_history(self) -> list[int]: + """返回用于归一化的 duration 滚动窗口""" + return list(self._duration_history) + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + @staticmethod + def _make_zs_id(zs) -> tuple: + """生成中枢的稳定标识(基于首笔首K线时间戳,不随 DataFrame 窗口偏移而变化)""" + bi0 = zs.bi_list[0] + return (bi0.start_klc.start_time,) + + @staticmethod + def _find_current_zs(bi_zs_list: list): + """ + 找到当前活跃中枢: + 优先取最后一个 is_sure=False(形成中)的中枢, + 没有则取最后一个 is_sure=True 的中枢。 + """ + forming = None + last_sure = None + for zs in bi_zs_list: + if len(zs.bi_list) < 3: + continue + if not zs.is_sure: + forming = zs + else: + last_sure = zs + return forming if forming is not None else last_sure + + def _add_duration(self, duration_raw: int): + """将已确认中枢的 duration 加入滚动窗口""" + self._duration_history.append(duration_raw) + + def _maybe_add_to_history(self): + """旧中枢切换前,若已确认且未记录过,则将其 duration 加入窗口""" + if (self._current_state and self._current_state["is_sure"] + and self._current_zs_id not in self._duration_added_for_zs): + self._add_duration(self._current_state["duration_raw"]) + self._duration_added_for_zs.add(self._current_zs_id) diff --git a/chanlun/analysis/ChanZone.py b/chanlun/analysis/ChanZone.py new file mode 100644 index 0000000..2245d76 --- /dev/null +++ b/chanlun/analysis/ChanZone.py @@ -0,0 +1,566 @@ +""" +结构价值区 (Structure Zone) 系统 + +将多时间周期的 Chan 中枢边界 (ZD/ZG/GG/DD) 和 EMA52 统一表示为带强度评分的价值区对象。 +""" + +from dataclasses import dataclass, field +from typing import List, Dict, Optional, Any +from datetime import datetime + + +# ============================================================ +# Dataclasses +# ============================================================ + +@dataclass +class RawZonePoint: + """内部中间结构:从 Chan 中枢提取的单个价格点""" + price: float + timeframe: str # '5m', '1h', '4h' 等 + structure_type: str # 'bi_zhongshu' | 'xd_zhongshu' | 'ema52' + boundary_type: str # 'ZD' | 'ZG' | 'GG' | 'DD' | 'EMA52' + source_zs_id: int # 来源 ZS 在列表中的 index(调试用) + is_sure: bool # 来源 ZS 是否已完成 + candle_time: Optional[str] = None # 来源 ZS 的 end_time(用于 recency 计算) + + +@dataclass +class StructureZone: + """统一的价值区对象""" + id: int + lower: float + upper: float + center: float # (lower + upper) / 2 + width_pct: float # (upper - lower) / center * 100 + zone_type: str # 'support' | 'resistance' | 'neutral' + timeframes: List[str] # 参与形成此区间的时间周期 + structure_types: List[str] # 参与形成的结构类型 + boundary_types: List[str] # 参与形成的边界类型 + overlap_count: int # 聚类中的原始点数 + touch_count: int # MVP: 等于 overlap_count + recency_score: float # 0.0 - 1.0, 1.0 = 最近 + ema52_distance_pct: float # 到最近 EMA52 的距离百分比 + ema52_aligned: bool # 是否有 EMA52 落在区间内 + strength_score: float # 0-100 综合评分 + confidence: float # 0.0 - 1.0 + first_seen: Optional[str] # 最早的 candle_time + last_seen: Optional[str] # 最晚的 candle_time + metadata: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class StructureZoneConfig: + """StructureZone 提取与评分配置""" + cluster_radius_pct: float = 0.5 # 价格聚类半径(百分比) + min_overlap_for_zone: int = 2 # 最少重叠点数才能形成区间 + max_zones: int = 20 # 返回的最大区间数 + recency_halflife_bars: int = 50 # recency 衰减半衰期(K线数) + zone_timeframes: List[str] = field(default_factory=lambda: ['4h', '1h', '30m', '15m', '5m']) + kl_lines_per_tf: int = 500 # 每个时间周期使用最近多少根K线 + structure_weights: Dict[str, float] = field(default_factory=lambda: { + 'bi_zhongshu': 1.0, # 笔中枢 — 最直接的价格行为 + 'xd_zhongshu': 0.8, # 线段中枢 — 较高级别但粒度较粗 + 'ema52': 0.4, # EMA — 趋势参考,弱于结构 + }) + + +# ============================================================ +# Extraction +# ============================================================ + +def extract_raw_points_from_tf_df( + tf_df_dict: Dict[str, Any], + ema_symbols: List[str], + config: StructureZoneConfig, +) -> List[RawZonePoint]: + """ + 从 ChanLun.tf_df_dict 中提取所有原始价格点。 + 仅处理 config.zone_timeframes 中存在的时间周期。 + """ + points: List[RawZonePoint] = [] + + for tf_name in config.zone_timeframes: + if tf_name not in tf_df_dict: + continue + + tf_df = tf_df_dict[tf_name] + + # 1. 笔中枢 (ChanBIZS) + try: + if hasattr(tf_df, 'seg_list') and tf_df.seg_list: + bi_zs_result = tf_df.cal_bi_zs(tf_df.seg_list) + if bi_zs_result: + _extract_from_zs_objects( + points, tf_name, 'bi_zhongshu', bi_zs_result, config.kl_lines_per_tf + ) + except Exception: + pass + + # 2. 线段中枢 (ChanZS) + try: + zs_list = getattr(tf_df, 'zs_list', None) + if zs_list: + _extract_from_zs_objects( + points, tf_name, 'xd_zhongshu', zs_list, config.kl_lines_per_tf + ) + except Exception: + pass + + # 3. EMA52 值 + for tf_name in config.zone_timeframes: + if tf_name in tf_df_dict: + try: + ema_val = tf_df_dict[tf_name].get_ema52() + if ema_val is not None and ema_val > 0: + points.append(RawZonePoint( + price=float(ema_val), + timeframe=tf_name, + structure_type='ema52', + boundary_type='EMA52', + source_zs_id=-1, + is_sure=True, + candle_time=None, + )) + except Exception: + pass + + return points + + +def _extract_from_zs_objects( + points: List[RawZonePoint], + tf_name: str, + structure_type: str, + zs_list, + kl_limit: int, +): + """从 ZS 链表中提取 ZD/ZG/GG/DD 点""" + count = 0 + node = zs_list + while hasattr(node, 'next'): + node = node.next + # 从链表头开始遍历 + head = zs_list + # 收集所有节点 + all_nodes = [] + cur = head + while cur is not None and hasattr(cur, 'next'): + all_nodes.append(cur) + cur = cur.next + # 只取最近 kl_limit 根K线内的 ZS + all_nodes = all_nodes[-kl_limit:] if len(all_nodes) > kl_limit else all_nodes + + for idx, zs in enumerate(all_nodes): + if not getattr(zs, 'is_sure', False): + continue + try: + zg = float(zs.zg) + zd = float(zs.zd) + gg = float(zs.gg) if getattr(zs, 'gg', 0) else zg + dd = float(zs.dd) if getattr(zs, 'dd', 0) else zd + end_time = str(zs.end_time) if hasattr(zs, 'end_time') and zs.end_time else None + except (ValueError, TypeError, AttributeError): + continue + + if zg <= 0 or zd <= 0: + continue + + zs_id = getattr(zs, 'index', idx) + points.append(RawZonePoint(price=zg, timeframe=tf_name, structure_type=structure_type, + boundary_type='ZG', source_zs_id=zs_id, is_sure=True, + candle_time=end_time)) + points.append(RawZonePoint(price=zd, timeframe=tf_name, structure_type=structure_type, + boundary_type='ZD', source_zs_id=zs_id, is_sure=True, + candle_time=end_time)) + points.append(RawZonePoint(price=gg, timeframe=tf_name, structure_type=structure_type, + boundary_type='GG', source_zs_id=zs_id, is_sure=True, + candle_time=end_time)) + points.append(RawZonePoint(price=dd, timeframe=tf_name, structure_type=structure_type, + boundary_type='DD', source_zs_id=zs_id, is_sure=True, + candle_time=end_time)) + + +def extract_raw_points_from_serialized( + analyses: Dict[str, Dict], + ema52_dict: Dict[str, Optional[float]], + config: StructureZoneConfig, +) -> List[RawZonePoint]: + """ + 从已序列化的分析结果中提取价格点(用于 web API,避免重复计算)。 + analyses: {'5m': {'zs_list': [...], 'bi_zs_list': [...]}, '15m': {...}, ...} + ema52_dict: {'5m': 123.45, '15m': None, ...} + """ + points: List[RawZonePoint] = [] + + for tf_name in config.zone_timeframes: + if tf_name not in analyses: + continue + + analysis = analyses[tf_name] + + # 笔中枢 + bi_zs_items = analysis.get('bi_zs_list', []) + for idx, zs in enumerate(bi_zs_items): + if not zs.get('is_sure', False): + continue + try: + zg = float(zs['zg']); zd = float(zs['zd']) + gg = float(zs.get('gg', zg)); dd = float(zs.get('dd', zd)) + end_time = zs.get('end_time') + except (ValueError, KeyError): + continue + if zg <= 0 or zd <= 0: + continue + points.append(RawZonePoint(price=zg, timeframe=tf_name, structure_type='bi_zhongshu', + boundary_type='ZG', source_zs_id=idx, is_sure=True, + candle_time=str(end_time) if end_time else None)) + points.append(RawZonePoint(price=zd, timeframe=tf_name, structure_type='bi_zhongshu', + boundary_type='ZD', source_zs_id=idx, is_sure=True, + candle_time=str(end_time) if end_time else None)) + points.append(RawZonePoint(price=gg, timeframe=tf_name, structure_type='bi_zhongshu', + boundary_type='GG', source_zs_id=idx, is_sure=True, + candle_time=str(end_time) if end_time else None)) + points.append(RawZonePoint(price=dd, timeframe=tf_name, structure_type='bi_zhongshu', + boundary_type='DD', source_zs_id=idx, is_sure=True, + candle_time=str(end_time) if end_time else None)) + + # 线段中枢 + zs_items = analysis.get('zs_list', []) + for idx, zs in enumerate(zs_items): + if not zs.get('is_sure', False): + continue + try: + zg = float(zs['zg']); zd = float(zs['zd']) + gg = float(zs.get('gg', zg)); dd = float(zs.get('dd', zd)) + end_time = zs.get('end_time') + except (ValueError, KeyError): + continue + if zg <= 0 or zd <= 0: + continue + points.append(RawZonePoint(price=zg, timeframe=tf_name, structure_type='xd_zhongshu', + boundary_type='ZG', source_zs_id=idx, is_sure=True, + candle_time=str(end_time) if end_time else None)) + points.append(RawZonePoint(price=zd, timeframe=tf_name, structure_type='xd_zhongshu', + boundary_type='ZD', source_zs_id=idx, is_sure=True, + candle_time=str(end_time) if end_time else None)) + points.append(RawZonePoint(price=gg, timeframe=tf_name, structure_type='xd_zhongshu', + boundary_type='GG', source_zs_id=idx, is_sure=True, + candle_time=str(end_time) if end_time else None)) + points.append(RawZonePoint(price=dd, timeframe=tf_name, structure_type='xd_zhongshu', + boundary_type='DD', source_zs_id=idx, is_sure=True, + candle_time=str(end_time) if end_time else None)) + + # EMA52 + for tf_name in config.zone_timeframes: + ema_val = ema52_dict.get(tf_name) + if ema_val is not None and ema_val > 0: + points.append(RawZonePoint( + price=float(ema_val), + timeframe=tf_name, + structure_type='ema52', + boundary_type='EMA52', + source_zs_id=-1, + is_sure=True, + candle_time=None, + )) + + return points + + +# ============================================================ +# Clustering +# ============================================================ + +def cluster_raw_points( + points: List[RawZonePoint], + config: StructureZoneConfig, +) -> List[List[RawZonePoint]]: + """ + 贪心单通聚类:将价格相近的 RawZonePoint 归为一组。 + 仅在 1D 价格轴上操作,O(n log n)。 + """ + if not points: + return [] + + sorted_points = sorted(points, key=lambda p: p.price) + clusters: List[List[RawZonePoint]] = [] + + for p in sorted_points: + placed = False + for cluster in reversed(clusters): + # 检查是否可以放入当前聚类(与聚类均价比较) + avg_price = sum(pt.price for pt in cluster) / len(cluster) + if abs(p.price - avg_price) / avg_price * 100 <= config.cluster_radius_pct: + cluster.append(p) + placed = True + break + if not placed: + clusters.append([p]) + + # 过滤点数不足的聚类 + return [c for c in clusters if len(c) >= config.min_overlap_for_zone] + + +# ============================================================ +# Scoring & Building +# ============================================================ + +def build_structure_zones( + clusters: List[List[RawZonePoint]], + current_price: float, + ema52_values: Dict[str, Optional[float]], + latest_candle_time: Optional[str], + config: StructureZoneConfig, +) -> List[StructureZone]: + """ + 从聚类构建 StructureZone 列表,计算所有字段和评分。 + """ + zones: List[StructureZone] = [] + + # 收集所有 EMA52 值 + ema_prices = [v for v in ema52_values.values() if v is not None and v > 0] + + for zone_id, cluster in enumerate(clusters): + prices = [p.price for p in cluster] + lower = min(prices) + upper = max(prices) + center = (lower + upper) / 2 + width_pct = (upper - lower) / center * 100 if center > 0 else 0.0 + + # 区间类型 + if upper < current_price: + zone_type = 'support' # 区间在当前价格下方 → 支撑 + elif lower > current_price: + zone_type = 'resistance' # 区间在当前价格上方 → 阻力 + else: + zone_type = 'neutral' # 区间跨越当前价格 + + timeframes = sorted(set(p.timeframe for p in cluster)) + structure_types = sorted(set(p.structure_type for p in cluster)) + boundary_types = sorted(set(p.boundary_type for p in cluster)) + overlap_count = len(cluster) + + # Recency + times = [p.candle_time for p in cluster if p.candle_time] + first_seen = min(times) if times else None + last_seen = max(times) if times else None + recency_score = _calc_recency(last_seen, latest_candle_time, config.recency_halflife_bars) + + # EMA52 alignment + ema52_distance_pct = 999.0 + ema52_aligned = False + if ema_prices: + distances = [abs(center - ep) / ep * 100 for ep in ema_prices] + ema52_distance_pct = round(min(distances), 2) + ema52_aligned = any(lower <= ep <= upper for ep in ema_prices) + + # Strength score + strength_score = _calc_strength(cluster, config, recency_score, ema52_aligned, ema52_distance_pct, width_pct) + + # Confidence + confidence = _calc_confidence(overlap_count, len(timeframes), cluster) + + zones.append(StructureZone( + id=zone_id + 1, + lower=round(lower, 2), + upper=round(upper, 2), + center=round(center, 2), + width_pct=round(width_pct, 2), + zone_type=zone_type, + timeframes=timeframes, + structure_types=structure_types, + boundary_types=boundary_types, + overlap_count=overlap_count, + touch_count=overlap_count, # MVP: 等于 overlap_count + recency_score=round(recency_score, 3), + ema52_distance_pct=ema52_distance_pct, + ema52_aligned=ema52_aligned, + strength_score=round(strength_score, 1), + confidence=round(confidence, 2), + first_seen=first_seen, + last_seen=last_seen, + )) + + # 按强度降序排列 + zones.sort(key=lambda z: z.strength_score, reverse=True) + + # 截断 + if config.max_zones > 0 and len(zones) > config.max_zones: + zones = zones[:config.max_zones] + + return zones + + +def _calc_recency( + last_seen: Optional[str], + latest_time: Optional[str], + halflife_bars: int, +) -> float: + """计算 recency 分数:越近越高""" + if not last_seen or not latest_time: + return 0.5 + + try: + # 尝试解析 ISO 格式时间 + from dateutil import parser + t_last = parser.parse(last_seen) + t_latest = parser.parse(latest_time) + offset_seconds = (t_latest - t_last).total_seconds() + if offset_seconds < 0: + return 1.0 + # 假设每根K线平均 5 分钟 + bar_seconds = 300 + offset_bars = offset_seconds / bar_seconds + # 指数衰减: 2 ^ (-offset / halflife) + score = 2.0 ** (-offset_bars / halflife_bars) + return float(score) + except Exception: + return 0.5 + + +def _calc_strength( + cluster: List[RawZonePoint], + config: StructureZoneConfig, + recency_score: float, + ema52_aligned: bool, + ema52_distance_pct: float, + width_pct: float, +) -> float: + """计算综合强度评分 (0-100)""" + + # 组件 1: 结构类型多样性 (0-40) + structure_type_counts: Dict[str, int] = {} + for p in cluster: + structure_type_counts[p.structure_type] = structure_type_counts.get(p.structure_type, 0) + 1 + total = sum(structure_type_counts.values()) + structure_score = 0.0 + for st, count in structure_type_counts.items(): + weight = config.structure_weights.get(st, 0.5) + structure_score += weight * count + structure_score = min(structure_score / max(1, total), 1.0) + c1 = structure_score * 40 + + # 组件 2: 多周期确认 (0-25) + tf_set = set(p.timeframe for p in cluster) + tf_diversity = len(tf_set) + c2 = min(tf_diversity / 5, 1.0) * 25 + + # 组件 3: 区间紧密度 (0-15) — 越窄越强 + tightness = max(0.0, 1.0 - (width_pct / 3.0)) + c3 = tightness * 15 + + # 组件 4: Recency (0-10) + c4 = recency_score * 10 + + # 组件 5: EMA52 共振 (0-10) + if ema52_aligned: + ema_proximity = max(0.0, 1.0 - (ema52_distance_pct / 2.0)) + c5 = ema_proximity * 10 + else: + c5 = 0.0 + + return c1 + c2 + c3 + c4 + c5 + + +def _calc_confidence( + overlap_count: int, + tf_count: int, + cluster: List[RawZonePoint], +) -> float: + """计算置信度 (0-1)""" + base = min(overlap_count / 6.0, 0.85) + # 多周期加分 + tf_bonus = min(tf_count / 5.0, 0.1) + # 是否所有点都来自 sure 的 ZS + all_sure = all(p.is_sure for p in cluster) + sure_bonus = 0.05 if all_sure else 0.0 + return min(base + tf_bonus + sure_bonus, 1.0) + + +# ============================================================ +# Top-level pipeline +# ============================================================ + +def analyze_structure_zones( + tf_df_dict: Dict[str, Any], + ema_symbols: List[str], + current_price: Optional[float] = None, + config: Optional[StructureZoneConfig] = None, +) -> List[StructureZone]: + """ + 一站式分析:提取 → 聚类 → 评分 → 返回排序后的 StructureZone 列表。 + """ + if config is None: + config = StructureZoneConfig() + + # 提取 + raw_points = extract_raw_points_from_tf_df(tf_df_dict, ema_symbols, config) + + if not raw_points: + return [] + + # 获取当前价格 + if current_price is None: + for tf_name in config.zone_timeframes: + if tf_name in tf_df_dict: + try: + ema_val = tf_df_dict[tf_name].get_ema52() + if ema_val and ema_val > 0: + current_price = float(ema_val) + break + except Exception: + pass + if current_price is None: + current_price = 0.0 + + # EMA52 值 + ema52_values = {} + for tf_name in config.zone_timeframes: + if tf_name in tf_df_dict: + try: + ema52_values[tf_name] = tf_df_dict[tf_name].get_ema52() + except Exception: + ema52_values[tf_name] = None + + # 最晚时间 + latest_time = None + times = [p.candle_time for p in raw_points if p.candle_time] + if times: + latest_time = max(times) + + # 聚类 + clusters = cluster_raw_points(raw_points, config) + + # 构建 & 评分 + return build_structure_zones(clusters, current_price, ema52_values, latest_time, config) + + +def analyze_structure_zones_from_serialized( + analyses: Dict[str, Dict], + ema52_dict: Dict[str, Optional[float]], + current_price: float, + config: Optional[StructureZoneConfig] = None, +) -> List[StructureZone]: + """ + 从已序列化的分析结果构建 StructureZone(用于 web API)。 + """ + if config is None: + config = StructureZoneConfig() + + raw_points = extract_raw_points_from_serialized(analyses, ema52_dict, config) + + if not raw_points: + return [] + + # 最晚时间 + latest_time = None + times = [p.candle_time for p in raw_points if p.candle_time] + if times: + latest_time = max(times) + + # EMA52 值(用于 alignment 检测) + ema_values = {tf: v for tf, v in ema52_dict.items() if v is not None and v > 0} + + clusters = cluster_raw_points(raw_points, config) + return build_structure_zones(clusters, current_price, ema_values, latest_time, config) diff --git a/chanlun/analysis/Find_Trend.py b/chanlun/analysis/Find_Trend.py new file mode 100644 index 0000000..8d392a3 --- /dev/null +++ b/chanlun/analysis/Find_Trend.py @@ -0,0 +1,448 @@ +import ccxt +import pandas as pd +import numpy as np +import mplfinance as mpf +from talib import MACD, SMA +from datetime import datetime, timedelta +import logging +import datetime as dt + +# Configure logging +logging.basicConfig( + filename='chanlun_trading.log', + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) + +# Configuration (user to modify) +BINANCE_API_KEY = 'your_api_key' # Replace with your Binance API key +BINANCE_API_SECRET = 'your_api_secret' # Replace with your Binance API secret +SIMULATION_MODE = True # Set to False for live trading + +# 1. Fetch K-line data from Binance (multi-timeframe support) +def fetch_binance_data(symbol='BTC/USDT', timeframe='5m', limit=500): + try: + exchange = ccxt.binance({ + 'apiKey': BINANCE_API_KEY if not SIMULATION_MODE else '', + 'secret': BINANCE_API_SECRET if not SIMULATION_MODE else '', + 'enableRateLimit': True, + 'options': {'defaultType': 'spot'} + }) + since = exchange.parse8601((datetime.now(dt.UTC) - timedelta(days=7)).isoformat()) + ohlcv = exchange.fetch_ohlcv(symbol, timeframe, since, limit) + df = pd.DataFrame(ohlcv, columns=['Date', 'Open', 'High', 'Low', 'Close', 'Volume']) + df['Date'] = pd.to_datetime(df['Date'], unit='ms') + df.set_index('Date', inplace=True) + logging.info(f"Fetched {len(df)} K-lines for {symbol} ({timeframe})") + return df + except Exception as e: + logging.error(f"Failed to fetch data: {e}") + raise + +# 2. K-line merging (vectorized) +def merge_kline(df): + try: + df = df.copy() + merged_data = [] + trend = np.sign(df['Close'].diff().shift(-1)) # 1: up, -1: down, 0: neutral + + # Detect inclusion + is_included = ((df['High'].shift(-1) <= df['High']) & (df['Low'].shift(-1) >= df['Low'])) | \ + ((df['High'].shift(-1) >= df['High']) & (df['Low'].shift(-1) <= df['Low'])) + + i = 0 + while i < len(df) - 1: + if is_included.iloc[i]: + current_k = df.iloc[i] + next_k = df.iloc[i + 1] + high = max(current_k['High'], next_k['High']) + low = min(current_k['Low'], next_k['Low']) + open_price = current_k['Open'] + close_price = next_k['Close'] if trend.iloc[i] >= 0 else next_k['Close'] + volume = current_k['Volume'] + next_k['Volume'] + + merged_data.append({ + 'Date': next_k.name, + 'Open': open_price, + 'High': high, + 'Low': low, + 'Close': close_price, + 'Volume': volume + }) + i += 2 + else: + current_k = df.iloc[i] + merged_data.append({ + 'Date': current_k.name, + 'Open': current_k['Open'], + 'High': current_k['High'], + 'Low': current_k['Low'], + 'Close': current_k['Close'], + 'Volume': current_k['Volume'] + }) + i += 1 + + if i == len(df) - 1: + last_k = df.iloc[i] + merged_data.append({ + 'Date': last_k.name, + 'Open': last_k['Open'], + 'High': last_k['High'], + 'Low': last_k['Low'], + 'Close': last_k['Close'], + 'Volume': last_k['Volume'] + }) + + merged_df = pd.DataFrame(merged_data) + merged_df['Date'] = pd.to_datetime(merged_df['Date']) + merged_df.set_index('Date', inplace=True) + logging.info(f"Merged K-lines: {len(df)} -> {len(merged_df)}") + return merged_df + except Exception as e: + logging.error(f"K-line merging failed: {e}") + raise + +# 3. Detect fractals (vectorized) +def detect_fractals(df): + try: + df = df.copy() + df['is_top'] = (df['High'] > df['High'].shift(1)) & (df['High'] > df['High'].shift(-1)) & \ + (df['High'] > df['High'].shift(2)) & (df['High'] > df['High'].shift(-2)) + df['is_bottom'] = (df['Low'] < df['Low'].shift(1)) & (df['Low'] < df['Low'].shift(-1)) & \ + (df['Low'] < df['Low'].shift(2)) & (df['Low'] < df['Low'].shift(-2)) + df['is_top'] = df['is_top'].fillna(False) + df['is_bottom'] = df['is_bottom'].fillna(False) + logging.info(f"Detected {df['is_top'].sum()} top fractals and {df['is_bottom'].sum()} bottom fractals") + return df + except Exception as e: + logging.error(f"Fractal detection failed: {e}") + raise + +# 4. Detect strokes +def detect_strokes(df): + try: + strokes = [] + last_fractal = None + last_price = None + last_index = None + + for i in range(len(df)): + if df['is_top'].iloc[i] or df['is_bottom'].iloc[i]: + current_fractal = 'top' if df['is_top'].iloc[i] else 'bottom' + current_price = df['High'].iloc[i] if current_fractal == 'top' else df['Low'].iloc[i] + + if last_fractal is None: + last_fractal = current_fractal + last_price = current_price + last_index = df.index[i] + continue + + if (last_fractal == 'top' and current_fractal == 'bottom' and current_price < last_price) or \ + (last_fractal == 'bottom' and current_fractal == 'top' and current_price > last_price): + strokes.append({ + 'start_time': last_index, + 'end_time': df.index[i], + 'start_price': last_price, + 'end_price': current_price, + 'type': 'down' if current_fractal == 'bottom' else 'up', + 'volume': df['Volume'].loc[last_index:df.index[i]].sum() + }) + + last_fractal = current_fractal + last_price = current_price + last_index = df.index[i] + + logging.info(f"Detected {len(strokes)} strokes") + return strokes + except Exception as e: + logging.error(f"Stroke detection failed: {e}") + raise + +# 5. Detect segments +def detect_segments(strokes): + try: + segments = [] + if len(strokes) < 3: + return segments + + i = 0 + while i < len(strokes) - 2: + stroke1, stroke2, stroke3 = strokes[i], strokes[i+1], strokes[i+2] + + if stroke1['type'] == 'up' and stroke2['type'] == 'down' and stroke3['type'] == 'up': + if stroke3['end_price'] > stroke1['end_price']: + segments.append({ + 'start_time': stroke1['start_time'], + 'end_time': stroke3['end_time'], + 'start_price': stroke1['start_price'], + 'end_price': stroke3['end_price'], + 'type': 'up' + }) + i += 3 + else: + i += 1 + elif stroke1['type'] == 'down' and stroke2['type'] == 'up' and stroke3['type'] == 'down': + if stroke3['end_price'] < stroke1['end_price']: + segments.append({ + 'start_time': stroke1['start_time'], + 'end_time': stroke3['end_time'], + 'start_price': stroke1['start_price'], + 'end_price': stroke3['end_price'], + 'type': 'down' + }) + i += 3 + else: + i += 1 + else: + i += 1 + + logging.info(f"Detected {len(segments)} segments") + return segments + except Exception as e: + logging.error(f"Segment detection failed: {e}") + raise + +# 6. Detect pivots (midlines) +def detect_pivots(strokes): + try: + pivots = [] + if len(strokes) < 3: + return pivots + + for i in range(len(strokes) - 2): + s1, s2, s3 = strokes[i:i+3] + high = min(s1['start_price'], s1['end_price'], s2['start_price'], s2['end_price'], + s3['start_price'], s3['end_price']) + low = max(s1['start_price'], s1['end_price'], s2['start_price'], s2['end_price'], + s3['start_price'], s3['end_price']) + + if high > low: + pivots.append({ + 'start_time': s1['start_time'], + 'end_time': s3['end_time'], + 'high': high, + 'low': low + }) + + logging.info(f"Detected {len(pivots)} pivots") + return pivots + except Exception as e: + logging.error(f"Pivot detection failed: {e}") + raise + +# 7. Analyze higher timeframe (30m) +def analyze_higher_timeframe(df_30m): + try: + df_30m = detect_fractals(df_30m) + strokes_30m = detect_strokes(df_30m) + + if not strokes_30m: + return 'neutral' + + last_stroke = strokes_30m[-1] + logging.info(f"30m trend: {last_stroke['type']}") + return last_stroke['type'] + except Exception as e: + logging.error(f"Higher timeframe analysis failed: {e}") + raise + +# 8. Back-divergence detection (enhanced) +def detect_back_divergence(df, strokes, higher_trend): + try: + macd, signal, hist = MACD(df['Close'], fastperiod=12, slowperiod=26, signalperiod=9) + sma20 = SMA(df['Close'], timeperiod=20) + df['macd'] = macd + df['hist'] = hist + df['sma20'] = sma20 + df['buy_signal'] = False + df['sell_signal'] = False + + stroke_metrics = [] + for stroke in strokes: + start_idx = df.index.get_loc(stroke['start_time']) + end_idx = df.index.get_loc(stroke['end_time']) + hist_segment = df['hist'].iloc[start_idx:end_idx+1] + price_change = abs(stroke['end_price'] - stroke['start_price']) + hist_area = sum(abs(h) for h in hist_segment if not np.isnan(h)) + volume = stroke['volume'] + stroke_metrics.append({ + 'start_time': stroke['start_time'], + 'end_time': stroke['end_time'], + 'type': stroke['type'], + 'price_change': price_change, + 'hist_area': hist_area, + 'volume': volume + }) + + for i in range(2, len(stroke_metrics)): + current_stroke = stroke_metrics[i] + prev_stroke = stroke_metrics[i-2] + + if current_stroke['type'] != prev_stroke['type']: + continue + + current_end_idx = df.index.get_loc(current_stroke['end_time']) + + # Uptrend back-divergence (sell signal) + if current_stroke['type'] == 'up': + price_increase = df['High'].loc[current_stroke['end_time']] > df['High'].loc[prev_stroke['end_time']] + hist_decrease = current_stroke['hist_area'] < prev_stroke['hist_area'] + volume_decrease = current_stroke['volume'] < prev_stroke['volume'] + is_top_fractal = df['is_top'].loc[current_stroke['end_time']] + hist_positive = df['hist'].iloc[current_end_idx] > 0 or \ + (df['hist'].iloc[current_end_idx] < 0 and df['hist'].iloc[current_end_idx-1] > 0) + sma_trend = df['Close'].iloc[current_end_idx] > df['sma20'].iloc[current_end_idx] + trend_match = higher_trend in ['up', 'neutral'] + + if price_increase and hist_decrease and volume_decrease and is_top_fractal and \ + hist_positive and sma_trend and trend_match: + df.loc[df.index[current_end_idx], 'sell_signal'] = True + + # Downtrend back-divergence (buy signal) + elif current_stroke['type'] == 'down': + price_decrease = df['Low'].loc[current_stroke['end_time']] < df['Low'].loc[prev_stroke['end_time']] + hist_decrease = current_stroke['hist_area'] < prev_stroke['hist_area'] + volume_decrease = current_stroke['volume'] < prev_stroke['volume'] + is_bottom_fractal = df['is_bottom'].loc[current_stroke['end_time']] + hist_negative = df['hist'].iloc[current_end_idx] < 0 or \ + (df['hist'].iloc[current_end_idx] > 0 and df['hist'].iloc[current_end_idx-1] < 0) + sma_trend = df['Close'].iloc[current_end_idx] < df['sma20'].iloc[current_end_idx] + trend_match = higher_trend in ['down', 'neutral'] + + if price_decrease and hist_decrease and volume_decrease and is_bottom_fractal and \ + hist_negative and sma_trend and trend_match: + df.loc[df.index[current_end_idx], 'buy_signal'] = True + + logging.info(f"Detected {df['buy_signal'].sum()} buy signals and {df['sell_signal'].sum()} sell signals") + return df + except Exception as e: + logging.error(f"Back-divergence detection failed: {e}") + raise + +# 9. Execute trade +def execute_trade(exchange, symbol, signal, amount=0.001): + try: + if SIMULATION_MODE: + msg = f"[SIMULATION] {'Buy' if signal == 'buy' else 'Sell'} {amount} {symbol} at {datetime.now(dt.UTC)}" + print(msg) + logging.info(msg) + return + + if signal == 'buy': + order = exchange.create_market_buy_order(symbol, amount) + msg = f"Buy order executed: {order}" + print(msg) + logging.info(msg) + elif signal == 'sell': + order = exchange.create_market_sell_order(symbol, amount) + msg = f"Sell order executed: {order}" + print(msg) + logging.info(msg) + except Exception as e: + msg = f"Trade execution failed: {e}" + print(msg) + logging.error(msg) + +# 10. Plot chart +def plot_chart(df, strokes, segments, pivots): + try: + # Initialize additional plots + apds = [] + alines = [] # For line segments + + # Plot strokes as line segments + for stroke in strokes: + alines.append([(stroke['start_time'], stroke['start_price']), + (stroke['end_time'], stroke['end_price'])]) + + # Plot segments as line segments + for segment in segments: + alines.append([(segment['start_time'], segment['start_price']), + (segment['end_time'], segment['end_price'])]) + + # Plot pivots as horizontal lines + for pivot in pivots: + alines.append([(pivot['start_time'], pivot['high']), + (pivot['end_time'], pivot['high'])]) + alines.append([(pivot['start_time'], pivot['low']), + (pivot['end_time'], pivot['low'])]) + + # Add alines to plot (single color for simplicity, can customize) + if alines: + apds.append(mpf.make_addplot( + None, # No y-data needed for alines + alines=alines, + type='line', + color=['blue' if i < len(strokes) else 'purple' if i < len(strokes) + len(segments) else 'orange' + for i in range(len(alines))], + linestyle=['--' if i < len(strokes) else '-' if i < len(strokes) + len(segments) else ':' + for i in range(len(alines))] + )) + + # Plot buy/sell signals + buy_signals = df[df['buy_signal']]['Close'] + sell_signals = df[df['sell_signal']]['Close'] + apds.append(mpf.make_addplot(buy_signals, type='scatter', markersize=100, marker='^', color='green')) + apds.append(mpf.make_addplot(sell_signals, type='scatter', markersize=100, marker='v', color='red')) + + # Plot K-line chart + mpf.plot(df, type='candle', addplot=apds, title='Chanlun Advanced Analysis', style='yahoo') + logging.info("Chart plotted successfully") + except Exception as e: + logging.error(f"Chart plotting failed: {e}") + raise + +# 11. Main function +def main(): + try: + # Initialize exchange + exchange = ccxt.binance({ + 'apiKey': BINANCE_API_KEY if not SIMULATION_MODE else '', + 'secret': BINANCE_API_SECRET if not SIMULATION_MODE else '', + 'enableRateLimit': True, + 'options': {'defaultType': 'spot'} + }) + + # Fetch data + df_5m = fetch_binance_data(symbol='BTC/USDT', timeframe='5m', limit=500) + df_30m = fetch_binance_data(symbol='BTC/USDT', timeframe='30m', limit=200) + + # Merge 5m K-lines + df_5m = merge_kline(df_5m) + + # Detect fractals, strokes, segments, pivots + df_5m = detect_fractals(df_5m) + strokes = detect_strokes(df_5m) + segments = detect_segments(strokes) + pivots = detect_pivots(strokes) + + # Analyze 30m trend + higher_trend = analyze_higher_timeframe(df_30m) + print(f"30m Trend: {higher_trend}") + + # Detect back-divergence + df_5m = detect_back_divergence(df_5m, strokes, higher_trend) + + # Plot chart + plot_chart(df_5m, strokes, segments, pivots) + + # Output and execute trades + print("Buy Signals:") + buy_signals = df_5m[df_5m['buy_signal']][['Close']] + print(buy_signals) + for idx, row in buy_signals.iterrows(): + execute_trade(exchange, 'BTC/USDT', 'buy', amount=0.001) + + print("Sell Signals:") + sell_signals = df_5m[df_5m['sell_signal']][['Close']] + print(sell_signals) + for idx, row in sell_signals.iterrows(): + execute_trade(exchange, 'BTC/USDT', 'sell', amount=0.001) + + logging.info("Main function completed successfully") + except Exception as e: + logging.error(f"Main function failed: {e}") + raise + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/chanlun/analysis/__init__.py b/chanlun/analysis/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chanlun/analysis/fx_strength_config.py b/chanlun/analysis/fx_strength_config.py new file mode 100644 index 0000000..41e9faa --- /dev/null +++ b/chanlun/analysis/fx_strength_config.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +分型强度检测配置文件 +用于调整分型强度计算的各项参数和权重 +""" + +class FxStrengthConfig: + """分型强度检测配置类""" + + def __init__(self): + # ===== 权重配置 (总分100分) ===== + self.price_difference_weight = 40 # 价格差异强度权重 + self.breakthrough_weight = 20 # 突破历史点位权重 + self.volume_weight = 15 # 成交量确认权重 + self.rsi_divergence_weight = 15 # RSI背离权重 + self.macd_divergence_weight = 10 # MACD背离权重 + + # ===== 价格差异参数 ===== + self.price_diff_multiplier = 1000 # 价格差异放大倍数 + self.max_price_score = 20 # 价格差异最高得分 + + # ===== 突破检测参数 ===== + self.breakthrough_lookback = 10 # 回看K线数量 + self.breakthrough_multiplier = 500 # 突破幅度放大倍数 + self.max_breakthrough_score = 20 # 突破最高得分 + + # ===== 成交量参数 ===== + self.volume_lookback = 5 # 计算平均成交量的回看期数 + self.volume_multiplier = 10 # 成交量放大倍数 + self.max_volume_score = 15 # 成交量最高得分 + self.min_volume_ratio = 1.0 # 最小成交量比率 + + # ===== RSI背离参数 ===== + self.rsi_divergence_divisor = 2 # RSI背离除数 + self.max_rsi_score = 15 # RSI最高得分 + + # ===== MACD背离参数 ===== + self.macd_divergence_multiplier = 100 # MACD背离放大倍数 + self.max_macd_score = 10 # MACD最高得分 + + # ===== 强度等级阈值 ===== + self.extreme_threshold = 80 # 极强分型阈值 + self.strong_threshold = 60 # 强分型阈值 + self.medium_threshold = 40 # 中等分型阈值 + self.weak_threshold = 20 # 弱分型阈值 + + # ===== 其他参数 ===== + self.min_strength = 0 # 最小强度分数 + self.max_strength = 100 # 最大强度分数 + + def get_strength_level_name(self, strength): + """根据强度分数获取等级名称""" + if strength >= self.extreme_threshold: + return "极强" + elif strength >= self.strong_threshold: + return "强" + elif strength >= self.medium_threshold: + return "中等" + elif strength >= self.weak_threshold: + return "弱" + else: + return "极弱" + + def is_strong_fractal(self, strength, custom_threshold=None): + """判断是否为强分型""" + threshold = custom_threshold if custom_threshold is not None else self.strong_threshold + return strength >= threshold + + def validate_config(self): + """验证配置参数的合理性""" + total_weight = (self.price_difference_weight + + self.breakthrough_weight + + self.volume_weight + + self.rsi_divergence_weight + + self.macd_divergence_weight) + + if total_weight != 100: + print(f"警告: 权重总和为{total_weight},不等于100") + + if not (0 <= self.extreme_threshold <= 100): + print(f"警告: 极强阈值{self.extreme_threshold}不在合理范围内") + + if not (self.weak_threshold < self.medium_threshold < + self.strong_threshold < self.extreme_threshold): + print("警告: 强度阈值设置不合理") + + return True + + def print_config(self): + """打印当前配置""" + print("=== 分型强度检测配置 ===") + print(f"价格差异权重: {self.price_difference_weight}分") + print(f"突破点位权重: {self.breakthrough_weight}分") + print(f"成交量权重: {self.volume_weight}分") + print(f"RSI背离权重: {self.rsi_divergence_weight}分") + print(f"MACD背离权重: {self.macd_divergence_weight}分") + print() + print("=== 强度等级阈值 ===") + print(f"极强: >={self.extreme_threshold}分") + print(f"强: {self.strong_threshold}-{self.extreme_threshold-1}分") + print(f"中等: {self.medium_threshold}-{self.strong_threshold-1}分") + print(f"弱: {self.weak_threshold}-{self.medium_threshold-1}分") + print(f"极弱: <{self.weak_threshold}分") + + +# 默认配置实例 +DEFAULT_CONFIG = FxStrengthConfig() + +# 保守配置 (更严格的分型识别) +CONSERVATIVE_CONFIG = FxStrengthConfig() +CONSERVATIVE_CONFIG.price_difference_weight = 50 +CONSERVATIVE_CONFIG.breakthrough_weight = 25 +CONSERVATIVE_CONFIG.volume_weight = 15 +CONSERVATIVE_CONFIG.rsi_divergence_weight = 10 +CONSERVATIVE_CONFIG.macd_divergence_weight = 0 +CONSERVATIVE_CONFIG.strong_threshold = 70 +CONSERVATIVE_CONFIG.extreme_threshold = 85 + +# 激进配置 (更宽松的分型识别) +AGGRESSIVE_CONFIG = FxStrengthConfig() +AGGRESSIVE_CONFIG.price_difference_weight = 30 +AGGRESSIVE_CONFIG.breakthrough_weight = 15 +AGGRESSIVE_CONFIG.volume_weight = 20 +AGGRESSIVE_CONFIG.rsi_divergence_weight = 20 +AGGRESSIVE_CONFIG.macd_divergence_weight = 15 +AGGRESSIVE_CONFIG.strong_threshold = 50 +AGGRESSIVE_CONFIG.extreme_threshold = 70 + +# 技术指标重点配置 (重视技术指标背离) +TECHNICAL_CONFIG = FxStrengthConfig() +TECHNICAL_CONFIG.price_difference_weight = 25 +TECHNICAL_CONFIG.breakthrough_weight = 15 +TECHNICAL_CONFIG.volume_weight = 10 +TECHNICAL_CONFIG.rsi_divergence_weight = 25 +TECHNICAL_CONFIG.macd_divergence_weight = 25 + + +if __name__ == "__main__": + print("=== 分型强度配置演示 ===\n") + + configs = { + "默认配置": DEFAULT_CONFIG, + "保守配置": CONSERVATIVE_CONFIG, + "激进配置": AGGRESSIVE_CONFIG, + "技术指标配置": TECHNICAL_CONFIG + } + + for name, config in configs.items(): + print(f"=== {name} ===") + config.print_config() + config.validate_config() + print() \ No newline at end of file diff --git a/chanlun/core/ChanBI.py b/chanlun/core/ChanBI.py new file mode 100644 index 0000000..9152918 --- /dev/null +++ b/chanlun/core/ChanBI.py @@ -0,0 +1,132 @@ +from decimal import Decimal +import chanlun.core.ChanKLC as ChanKLC +from chanlun.core.ChanEnum import Chan_BI_DIR +class ChanBI(): + def __init__(self, klc: ChanKLC, index, ddir=Chan_BI_DIR.UP): + self.start_klc = klc + self.end_klc = klc + self.next = None + self.pre = None + self.dir = ddir + self.index = index + self.is_sure = False + self.high = klc.high + self.low = klc.low + self.sure_time = None + self.klc_list = [] + self.klc_list.append(klc) + self.end_time = klc.end_time + self.start_time = klc.start_time + self.macd_hist = 0 + self.macd_div = 0 + self.seg = None + self.height = 0 + self.width = 0 + self.slop = 0 + self.fib_list = [] + self.seg_index = 0 + self.bi_zs = None + self.seg_zs = None + def set_bi_zs(self, bi_zs): + for klc in self.klc_list: + klc.set_bi_zs(bi_zs) + def set_seg(self, seg): + self.seg = seg + self.seg_index = len(seg.bi_list)-1 + def set_macdhist(self, macd_hist): + self.macd_hist = macd_hist + def set_macd_div(self, macd_div): + self.macd_div = macd_div + def cal_macd_div(self): + self.macd_div = 0.0 + if self.pre and self.pre.pre: + if self.pre.pre.macd_hist == 0: + self.macd_div = 0.0 + else: + self.macd_div = self.macd_hist / self.pre.pre.macd_hist + #print(self.start_time, self.end_time, self.macd_hist, self.pre.pre.macd_hist, self.macd_div) + def cal_macdhist(self): + self.macd_hist = 0 + for klc in self.klc_list: + for klu in klc.klu_list: + if self.dir == Chan_BI_DIR.UP and klu.macdhist > 0: + self.macd_hist += klu.macdhist + if self.dir == Chan_BI_DIR.DOWN and klu.macdhist < 0: + self.macd_hist -= klu.macdhist + def check_bi_zs_overlap(self): + if self.next and self.next.next: + if self.dir == Chan_BI_DIR.UP: + return self.low < self.next.next.high + else: + return self.high > self.next.next.low + else: + return False + def check_overlap(self): + if self.next and self.next.next and self.next.next.is_sure: + if self.dir == Chan_BI_DIR.UP: + return self.high > self.next.low and self.high < self.next.next.high + else: + return self.high > self.next.high and self.low > self.next.next.low + else: + return False + def set_end_klc(self, klc, sure_klc): + if self.dir == Chan_BI_DIR.UP and klc.high > self.high: + self.high = klc.high + if self.dir == Chan_BI_DIR.DOWN and klc.low < self.low: + self.low = klc.low + self.end_klc = klc + self.set_is_sure(True, sure_klc.end_time) + self.end_time = klc.end_time + self.cal_properties() + #print(self.start_time, klc.fx, "This bi is ended", len(self.klc_list), klc.index - self.start_klc.index) + def cal_properties(self): + if self.is_sure: + self.height = float(format(self.high - self.low, ".2f")) + self.width = self.end_klc.index - self.start_klc.index + self.slop = float(format(self.height / self.width, ".2f")) + fib_list = [0.0, 0.236, 0.382, 0.5, 0.618, 0.786, 1.0] + for fib in fib_list: + self.fib_list.append(float(format(self.height * fib + self.low, ".2f"))) + #print(self.end_time, self.height, self.width, self.slop, self.fib_list) + def set_is_sure(self, is_sure, time): + self.is_sure = is_sure + self.sure_time = time + def set_start_klc(self, klc, ddir): + self.start_klc = klc + self.klc_list = [] + self.klc_list.append(klc) + self.high = klc.high + self.low = klc.low + self.dir = ddir + def set_pre(self, bi): + self.pre = bi + def set_next(self, bi): + self.next = bi + def add_klc(self, klc): + added = False + if len(self.klc_list) > 0: + for index in range(0, len(self.klc_list)): + if self.klc_list[index].index == klc.index: + added = True + break + if not added: + self.klc_list.append(klc) + #print(self.start_time, klc.start_time) + #print(klc.end_time, klc.index) + self.end_klc = klc + self.end_time = klc.klu_list[-1].time + self.cal_macdhist() + self.cal_macd_div() + def append_klc_list(self, klc_list): + self.klc_list.append(klc_list) + def get_decimal(self, value): + return Decimal("{:.2f}".format(value)) + def update_bi(self, klc): + self.end_klc = None + if self.dir == Chan_BI_DIR.UP and klc.high > self.high: + self.high = klc.high + if self.dir == Chan_BI_DIR.DOWN and klc.low < self.low: + self.low = klc.low + self.is_sure = False + self.sure_time = None + #print(self.start_time, klc.start_time, klc.fx, "This bi is extended") \ No newline at end of file diff --git a/chanlun/core/ChanBIZS.py b/chanlun/core/ChanBIZS.py new file mode 100644 index 0000000..79c0949 --- /dev/null +++ b/chanlun/core/ChanBIZS.py @@ -0,0 +1,161 @@ +from chanlun.core.ChanEnum import Chan_ZS_DIR, Chan_ZS_TYPE, Chan_BI_DIR +import chanlun.core.ChanBI as ChanBI +# 中枢 +class ChanBIZS(): + def __init__(self, start_bi: ChanBI, index, ddir: Chan_ZS_DIR): + self.start_klc = start_bi.start_klc + self.start_time = self.start_klc.start_time + self.end_time = None + self.index = index + self.start_bi = start_bi + self.bi_list = [] + self.bi_list.append(start_bi) + self.end_bi = None + self.bi_out = None + self.is_sure = False + self.zg = 0 + self.zd = 0 + self.gg = 0 + self.dd = 0 + self.dir = ddir + self.sure_time = None + self.end_klc = None + self.zs_type = Chan_ZS_TYPE.NORMAL + start_bi.set_bi_zs(self) + def set_end_bi(self, end_bi, sure_time): + self.end_bi = end_bi + self.set_end_time(end_bi.end_klc.end_time) + self.is_sure = True + self.sure_time = sure_time + end_bi.set_bi_zs(self) + #print(self.start_time, self.is_sure, len(self.bi_list), self.dir, self.zs_type) + def set_end_time(self, end_time): + self.end_time = end_time + def set_zg(self, zg): + self.zg = zg + def set_zd(self, zd): + self.zd = zd + def set_gg(self, gg): + self.gg = gg + def set_dd(self, dd): + self.dd = dd + def add_bi(self, bi: ChanBI): + if bi: + self.bi_list.append(bi) + if bi.high > self.gg: + self.gg = bi.high + if bi.low < self.dd: + self.dd = bi.low + bi.set_bi_zs(self) + self.classify_zs() + def set_pre(self, pre): + self.pre = pre + def set_next(self, next): + self.next = next + def classify_zs(self): + """ + 根据中枢内笔的高低点变化趋势,对中枢进行分类 + + 分类逻辑: + - 取中枢内向上笔的高点(peaks)和向下笔的低点(valleys) + - 比较前半段和后半段的均值,判断高点和低点的整体趋势 + + 分类结果: + - RISING 上升中枢:高点抬高 + 低点抬高 → 多方占优,可能向上突破 + - FALLING 下行中枢:高点降低 + 低点降低 → 空方占优,可能向下突破 + - CONVERGING 收敛中枢:高点降低 + 低点抬高 → 区间收窄,即将选择方向 + - DIVERGING 扩散中枢:高点抬高 + 低点降低 → 波动加剧,市场不稳定 + - NORMAL 常规中枢:无明显趋势 → 多空均衡,区间震荡 + """ + if len(self.bi_list) < 3: + self.zs_type = Chan_ZS_TYPE.NORMAL + return + + # 提取向上笔的高点(peaks)和向下笔的低点(valleys) + peaks = [bi.high for bi in self.bi_list if bi.dir == Chan_BI_DIR.UP] + valleys = [bi.low for bi in self.bi_list if bi.dir == Chan_BI_DIR.DOWN] + + high_trend = self._calc_trend(peaks) + low_trend = self._calc_trend(valleys) + + if high_trend > 0 and low_trend > 0: + self.zs_type = Chan_ZS_TYPE.RISING + elif high_trend < 0 and low_trend < 0: + self.zs_type = Chan_ZS_TYPE.FALLING + elif high_trend < 0 and low_trend > 0: + self.zs_type = Chan_ZS_TYPE.CONVERGING + elif high_trend > 0 and low_trend < 0: + self.zs_type = Chan_ZS_TYPE.DIVERGING + else: + self.zs_type = Chan_ZS_TYPE.NORMAL + + def _calc_trend(self, values): + """ + 计算序列的趋势方向 + 将序列分为前后两半,比较均值: + - 后半均值 > 前半均值 → 返回 1(上升趋势) + - 后半均值 < 前半均值 → 返回 -1(下降趋势) + - 相等或数据不足 → 返回 0(无趋势) + + 使用均值比较而非首尾比较,可以过滤单笔异常波动带来的误判 + """ + if len(values) < 2: + return 0 + mid = len(values) // 2 + first_half = values[:mid] if mid > 0 else values[:1] + second_half = values[mid:] + avg_first = sum(first_half) / len(first_half) + avg_second = sum(second_half) / len(second_half) + # 使用中枢区间的一定比例作为阈值,避免微小波动误判 + threshold = abs(avg_first) * 0.005 if avg_first != 0 else 0 + if avg_second - avg_first > threshold: + return 1 + elif avg_first - avg_second > threshold: + return -1 + else: + return 0 + + def is_weakening(self): + """ + 判断中枢是否在衰弱(即将反向突破的信号) + + 衰弱条件: + 1. 中枢内笔数 >= 5(有足够的数据判断) + 2. 最后一笔的MACD面积相比同方向前一笔出现背驰(macd_div < 1) + 3. 中枢类型为收敛型或常规型 + + 返回: True表示中枢力量衰弱,可能反向 + """ + if len(self.bi_list) < 5: + return False + last_bi = self.bi_list[-1] + # 最后一笔与同方向前一笔比较MACD面积是否背驰 + if last_bi.macd_div > 0 and last_bi.macd_div < 1.0: + return True + return False + + def get_zs_strength(self): + """ + 计算中枢强度,用于辅助判断中枢延续还是反向 + + 返回字典包含: + - type: 中枢类型 (Chan_ZS_TYPE) + - bi_count: 中枢内笔数 + - range_ratio: 中枢区间占比 = (zg - zd) / (gg - dd),越小说明中枢越紧密 + - last_bi_div: 最后一笔的MACD背驰比率 + - is_weakening: 是否衰弱 + - is_extending: 是否在延伸(笔数 >= 9 可能升级) + """ + total_range = self.gg - self.dd if self.gg != self.dd else 1 + zs_range = self.zg - self.zd if self.zg != self.zd else 0 + range_ratio = zs_range / total_range if total_range > 0 else 0 + last_bi_div = self.bi_list[-1].macd_div if len(self.bi_list) > 0 else 0 + + return { + 'type': self.zs_type, + 'bi_count': len(self.bi_list), + 'range_ratio': round(range_ratio, 4), + 'last_bi_div': round(last_bi_div, 4), + 'is_weakening': self.is_weakening(), + 'is_extending': len(self.bi_list) >= 9, # 9段可能升级 + } \ No newline at end of file diff --git a/chanlun/core/ChanBSP.py b/chanlun/core/ChanBSP.py new file mode 100644 index 0000000..684953a --- /dev/null +++ b/chanlun/core/ChanBSP.py @@ -0,0 +1,24 @@ +import chanlun.core.ChanBI as ChanBI +from chanlun.core.ChanEnum import Chan_BSP_TYPE, Chan_BSP_DIR + +class ChanBSP(): + def __init__(self, bi: ChanBI, index, type: Chan_BSP_TYPE, ddir: Chan_BSP_DIR, sure_time, zs_count, zs, seg): + self.bi = bi + self.klc = bi.end_klc + self.index = index + self.type = type + self.start_time = self.klc.start_time + self.end_time = self.klc.end_time + if sure_time: + self.is_sure = True + self.sure_time = sure_time + else: + self.is_sure = False + self.sure_time = None + self.dir = ddir + self.zs_count = zs_count + self.zs = zs + self.seg = bi.seg + def set_sure_time(self, sure_time): + self.is_sure = True + self.sure_time = sure_time \ No newline at end of file diff --git a/chanlun/core/ChanCTime.py b/chanlun/core/ChanCTime.py new file mode 100644 index 0000000..04e1bf6 --- /dev/null +++ b/chanlun/core/ChanCTime.py @@ -0,0 +1,44 @@ +from datetime import datetime + + +class ChanCTime: + def __init__(self, year, month, day, hour, minute, second=0, auto=True): + self.year = year + self.month = month + self.day = day + self.hour = hour + self.minute = minute + self.second = second + self.auto = auto # 自适应对天的理解 + self.set_timestamp() # set self.ts + + def __str__(self): + if self.hour == 0 and self.minute == 0: + return f"{self.year:04}/{self.month:02}/{self.day:02}" + else: + return f"{self.year:04}/{self.month:02}/{self.day:02} {self.hour:02}:{self.minute:02}" + + def to_str(self): + if self.hour == 0 and self.minute == 0: + return f"{self.year:04}/{self.month:02}/{self.day:02}" + else: + return f"{self.year:04}/{self.month:02}/{self.day:02} {self.hour:02}:{self.minute:02}" + + def toDateStr(self, splt=''): + return f"{self.year:04}{splt}{self.month:02}{splt}{self.day:02}" + + def toDate(self): + return ChanCTime(self.year, self.month, self.day, 0, 0, auto=False) + + def set_timestamp(self): + if self.hour == 0 and self.minute == 0 and self.auto: + date = datetime(self.year, self.month, self.day, 23, 59, self.second) + else: + date = datetime(self.year, self.month, self.day, self.hour, self.minute, self.second) + self.ts = date.timestamp() + + def __gt__(self, t2): + return self.ts > t2.ts + + def __ge__(self, t2): + return self.ts >= t2.ts diff --git a/chanlun/core/ChanEnum.py b/chanlun/core/ChanEnum.py new file mode 100644 index 0000000..9164374 --- /dev/null +++ b/chanlun/core/ChanEnum.py @@ -0,0 +1,368 @@ +from enum import Enum, auto +from typing import Literal + + +class Chan_DATA_SRC(Enum): + BAO_STOCK = auto() + CCXT = auto() + CSV = auto() + +class Chan_ZS_DIR(Enum): + UP = auto() + DOWN = auto() + +class Chan_ZS_TYPE(Enum): + """中枢类型分类""" + NORMAL = auto() # 常规中枢:高低点无明显趋势,区间震荡 + RISING = auto() # 上升中枢:高点抬高,低点也抬高,重心上移 + FALLING = auto() # 下行中枢:高点降低,低点也降低,重心下移 + CONVERGING = auto() # 收敛中枢:高点降低,低点抬高,区间收窄(三角收敛) + DIVERGING = auto() # 扩散中枢:高点抬高,低点降低,区间扩大(喇叭口) +class Chan_K_DIR(Enum): + BULL = auto() + BEAR = auto() + CROSS = auto() + +class Chan_EMA_POS(Enum): + """K线与任意EMA的位置关系(与趋势方向无关的客观分类,支持threshold容差)""" + ABOVE = auto() # 完全在EMA上方(远离):low > ema + threshold + NEAR_ABOVE = auto() # 在EMA上方但接近:ema < low <= ema + threshold + CROSS_CLOSE_ABOVE = auto() # 跨越EMA,收盘在上方:close > ema, low <= ema(含threshold范围内触碰) + ON_EMA = auto() # 收盘价在EMA附近:abs(close - ema) <= threshold + CROSS_CLOSE_BELOW = auto() # 跨越EMA,收盘在下方:close < ema, high >= ema(含threshold范围内触碰) + NEAR_BELOW = auto() # 在EMA下方但接近:ema - threshold <= high < ema + BELOW = auto() # 完全在EMA下方(远离):high < ema - threshold + UNKNOWN = auto() # 未知(EMA值无效) + +class Chan_EMA_SEMANTIC(Enum): + """K线与EMA结合趋势方向的语义状态(用于交易判断)""" + STRONG_TREND = auto() # 7: 顺势K线完全在EMA趋势侧(强势,远未及EMA) + TREND_SIDE = auto() # 6: 完全在EMA趋势侧(正常趋势运行) + RECOVER = auto() # 5: 逆势后穿越EMA回到趋势侧(收复EMA,趋势恢复) + TOUCH_FAIL = auto() # 4: 逆势触碰EMA但未穿越(反弹/反抽力度不足) + DEEP_COUNTER = auto() # 3: 完全在EMA逆势侧(深度回调/反抽) + BREAK = auto() # 2: 穿越EMA,收盘在逆势侧(支撑/压力失败) + TOUCH_HOLD = auto() # 1: 触碰EMA,收盘守住趋势侧(支撑/压力有效) + WEAK_COUNTER = auto() # 8: 逆势K线完全在EMA逆势侧(弱势,远未到EMA) + APPROACHING = auto() # 9: K线接近EMA但未触碰(即将测试支撑/压力) + NEUTRAL = auto() # 0: 盘整/无法判断 +class Chan_KL_TYPE(Enum): + K_1S = auto() + K_1M = auto() + K_DAY = auto() + K_WEEK = auto() + K_MON = auto() + K_YEAR = auto() + K_5M = auto() + K_15M = auto() + K_30M = auto() + K_60M = auto() + K_1H = auto() + K_2H = auto() + K_4H = auto() + K_6H = auto() + K_8H = auto() + K_12H = auto() + K_1D = auto() + K_3D = auto() + K_3M = auto() + K_QUARTER = auto() + + +class Chan_KLINE_DIR(Enum): + UP = auto() + DOWN = auto() + COMBINE = auto() + INCLUDED = auto() +class Chan_KLU_TYPE(Enum): + BigBull = auto() + MiddleBull = auto() + SmallBull = auto() + BigBear = auto() + MiddleBear = auto() + SmallBear = auto() + Cross = auto() + +class Chan_KLU_PATTERN(Enum): + # 单根K线形态 + HAMMER = auto() # 锤子线 + INVERTED_HAMMER = auto() # 倒锤子线 + SHOOTING_STAR = auto() # 射击之星 + HANGING_MAN = auto() # 上吊线 + DOJI = auto() # 十字星 + LONG_LEGGED_DOJI = auto() # 长腿十字星 + GRAVESTONE_DOJI = auto() # 墓碑十字星 + DRAGONFLY_DOJI = auto() # 蜻蜓十字星 + MARUBOZU = auto() # 光头光脚 + SPINNING_TOP = auto() # 纺锤线 + + # 双根K线形态 + BULLISH_ENGULFING = auto() # 看涨吞没 + BEARISH_ENGULFING = auto() # 看跌吞没 + PIERCING_LINE = auto() # 刺透形态 + DARK_CLOUD_COVER = auto() # 乌云盖顶 + TWEEZER_TOP = auto() # 镊子顶 + TWEEZER_BOTTOM = auto() # 镊子底 + HARAMI = auto() # 孕线 + BULLISH_HARAMI = auto() # 看涨孕线 + BEARISH_HARAMI = auto() # 看跌孕线 + + # 三根K线形态 + MORNING_STAR = auto() # 早晨之星 + EVENING_STAR = auto() # 黄昏之星 + THREE_WHITE_SOLDIERS = auto() # 红三兵 + THREE_BLACK_CROWS = auto() # 三只乌鸦 + THREE_INNER_UP = auto() # 上升三法 + THREE_INNER_DOWN = auto() # 下降三法 + ABANDONED_BABY = auto() # 弃婴形态 + + # 多根K线形态 + DOUBLE_TOP = auto() # 双顶 + DOUBLE_BOTTOM = auto() # 双底 + TRIPLE_TOP = auto() # 三顶 + TRIPLE_BOTTOM = auto() # 三底 + HEAD_AND_SHOULDERS = auto() # 头肩顶 + INVERSE_HEAD_SHOULDERS = auto() # 头肩底 + ROUNDING_BOTTOM = auto() # 圆弧底 + ROUNDING_TOP = auto() # 圆弧顶 + + # 缺口形态 + BREAKAWAY_GAP = auto() # 突破缺口 + RUNAWAY_GAP = auto() # 持续缺口 + EXHAUSTION_GAP = auto() # 衰竭缺口 + + # 特殊形态 + ISLAND_REVERSAL = auto() # 岛形反转 + KEY_REVERSAL = auto() # 关键反转 + INSIDE_BAR = auto() # 内包线 + OUTSIDE_BAR = auto() # 外包线 + + # 趋势形态 + HIGHER_HIGH = auto() # 更高高点 + HIGHER_LOW = auto() # 更高低点 + LOWER_HIGH = auto() # 更低高点 + LOWER_LOW = auto() # 更低低点 + + # 支撑阻力形态 + SUPPORT_BOUNCE = auto() # 支撑反弹 + RESISTANCE_REJECTION = auto() # 阻力拒绝 + BREAKOUT = auto() # 突破 + BREAKDOWN = auto() # 跌破 + + # 成交量相关形态 + VOLUME_SPIKE = auto() # 成交量激增 + VOLUME_DECLINE = auto() # 成交量萎缩 + + # 未知/无形态 + UNKNOWN = auto() # 未知形态 + + +class Chan_FX_TYPE(Enum): + BOTTOM = auto() + TOP = auto() + UNKNOWN = auto() + UP = auto() + DOWN = auto() + TT = auto() + BB = auto() + PTOP = auto() + PBOTTOM = auto() +class Chan_FX(Enum): + CONTINUATION = auto() + REVERSAL = auto() + UNKNOWN = auto() +class Chan_PRICE_TREND(Enum): + UP = auto() + DOWN = auto() + FLAT = auto() + UNKNOWN = auto() +class Chan_KLC_FX(Enum): + TOP0 = auto() + TOP1 = auto() + TOP2 = auto() + TOP3 = auto() + TOP4 = auto() + TOP5 = auto() + TOP6 = auto() + TOP7 = auto() + TOP8 = auto() + BOTTOM0 = auto() + BOTTOM1 = auto() + BOTTOM2 = auto() + BOTTOM3 = auto() + BOTTOM4 = auto() + BOTTOM5 = auto() + BOTTOM6 = auto() + BOTTOM7 = auto() + BOTTOM8 = auto() + UNKNOWN = auto() +# 统一的MACD状态枚举,包含所有可能的状态 +class Chan_MACD_STATE(Enum): + """MACD状态枚举 - 包含所有可能的状态""" + # 穿越状态 + CROSS0_UP = auto() # 穿零轴后快速向上,能量柱呈现一根比一根长的排列方式 + CROSS0_DOWN = auto() # 穿零轴后快速向下,能量柱呈现一根比一根短的排列方式 + + CROSS_OS = auto() # 穿零轴后缠绕/粘合,黄白线沿着能量柱运行,黄白线在运行的过程中没有释放出反向能量柱 + CROSS_REV = auto() # 穿零轴后倒挂,MACD黄白线在穿零轴的时候与零轴的距离比较近,同时黄白线沿着能量柱运行,在运行的过程中,能量柱衰减导致它跟黄白线之间形成夹角空位,同时黄白线产生交叉并释放反向能量柱。 + + # 趋势状态 + NEAR0 = auto() + NEAR0_52 = auto() # 价格在EMA52附近/价格接触EMA52并马上离开,需要观察离开强度 + NEAR0_DIFF = auto() # MACD白线接近零轴,价格未到EMA52 + NEAR0_PERFECT = auto() # MACD白线接近零轴和价格接触或短暂击穿EMA52,而MACD黄线不穿零轴,完美形态 + NEAR0_24 = auto() # MACD黄白线接近零轴和价格在EMA24附近 + # 位置状态 + HIGH = auto() # 高位:MACD黄白线离开能量柱到高点,能量柱最大开始减弱 + HIGH_EMPTY = auto() # 高位空:MACD黄白线处于高位,能量柱衰减,与黄白线形成空间夹角 + RETURN_ZERO = auto() # 归零轴:能量柱呈现一根比一根短的排列方式 + RZ_UP = auto() # 归零轴后的零轴上涨 + RZ_DOWN = auto() # 归零轴后的零轴下跌 + UP = auto() # 穿零轴后向上 + DOWN = auto() # 穿零轴后向下 + PEAK = auto() # 峰值:MACD白线处于高位 + # 基础状态 + UNKNOWN = auto() # 未知 + START = auto() # 开始 +class Chan_MACDSEG_DIR(Enum): + ABOVE = auto() + UNDER = auto() +class Chan_MACDUNITTF_TYPE(Enum): + START = auto() + CROSS0 = auto() + NEAR0 = auto() +class Chan_MACDUNITTF_JUMP(Enum): + CONTUNE = auto() + DISCRETE = auto() +class Chan_MACDUNITTF_DIV(Enum): + CONTUNE = auto() + DISCRETE = auto() + UNDIV = auto() +class Chan_MACDHISTSET_DIR(Enum): + ABOVE = auto() + UNDER = auto() +class Chan_MACDUNITTF_DIR(Enum): + ABOVE = auto() + UNDER = auto() +class Chan_MACDHIST_STATE(Enum): + UP = auto() + DOWN = auto() + PEAK = auto() + UNKNOWN = auto() + +class Chan_BI_DIR(Enum): + UP = auto() + DOWN = auto() + +class Chan_SEG_DIR(Enum): + UP = auto() + DOWN = auto() + +class Chan_BI_TYPE(Enum): + UNKNOWN = auto() + STRICT = auto() + SUB_VALUE = auto() # 次高低点成笔 + TIAOKONG_THRED = auto() + DAHENG = auto() + TUIBI = auto() + UNSTRICT = auto() + TIAOKONG_VALUE = auto() + + +Chan_BSP_MAIN_TYPE = Literal['1', '2', '3'] + +class Chan_BSP_DIR(Enum): + BUY = auto() + SELL = auto() +class Chan_BSP_TYPE(Enum): + B1 = auto() + B2 = auto() + B3 = auto() + S1 = auto() + S2 = auto() + S3 = auto() + NONE = auto() +""" +class Chan_BSP_TYPE(Enum): + T1 = '1' + T1P = '1p' + T2 = '2' + T2S = '2s' + T3A = '3a' # 中枢在1类后面 + T3B = '3b' # 中枢在1类前面 + T3 = '3' + T3E ='3e' # T3退出点 + QJT = 'qjt' # 区间套突破 + QJT1 = 'qjt1' # 区间套一类买点 + QJT2 = 'qjt2' # 区间套一类卖点 + QJT3 = 'qjt3' # 区间套三类买点 + def main_type(self) -> Chan_BSP_MAIN_TYPE: + return self.value[0] # type: ignore + +""" +class Chan_AUTYPE(Enum): + QFQ = auto() + HFQ = auto() + NONE = auto() + + +class Chan_TREND_TYPE(Enum): + MEAN = "mean" + MAX = "max" + MIN = "min" + + +class Chan_TREND_LINE_SIDE(Enum): + INSIDE = auto() + OUTSIDE = auto() + + +class Chan_LEFT_SEG_METHOD(Enum): + ALL = auto() + PEAK = auto() + + +class Chan_FX_CHECK_METHOD(Enum): + STRICT = auto() + LOSS = auto() + HALF = auto() + TOTALLY = auto() + + +class Chan_SEG_TYPE(Enum): + BI = auto() + SEG = auto() + + +class Chan_MACD_ALGO(Enum): + AREA = auto() + PEAK = auto() + FULL_AREA = auto() + DIFF = auto() + SLOPE = auto() + AMP = auto() + VOLUMN = auto() + AMOUNT = auto() + VOLUMN_AVG = auto() + AMOUNT_AVG = auto() + TURNRATE_AVG = auto() + RSI = auto() + + +class Chan_DATA_FIELD: + FIELD_TIME = "time_key" + FIELD_OPEN = "open" + FIELD_HIGH = "high" + FIELD_LOW = "low" + FIELD_CLOSE = "close" + FIELD_VOLUME = "volume" # 成交量 + FIELD_TURNOVER = "turnover" # 成交额 + FIELD_TURNRATE = "turnover_rate" # 换手率 + +class Chan_KLC_STATE: + """笔当下状态(缠论笔定理)。任意时刻必属其一。""" + S10 = "(1, 0)" # 顶分型构造中 (1,0) + S_10 = "(-1, 0)" # 底分型构造中 (-1,0) + S11 = "(1,1)" # 向上笔延续中 + S_11 = "(-1,1)" # 向下笔延续中 + UNKNOWN = "Unknown" # 初始状态 diff --git a/chanlun/core/ChanKLC.py b/chanlun/core/ChanKLC.py new file mode 100644 index 0000000..03cc4e8 --- /dev/null +++ b/chanlun/core/ChanKLC.py @@ -0,0 +1,620 @@ +import copy +from typing import Dict, Optional + +from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLINE_DIR, Chan_BI_DIR, Chan_KLC_FX +from chanlun.core.ChanEnum import Chan_K_DIR, Chan_MACD_STATE, Chan_PRICE_TREND, Chan_EMA_POS +from chanlun.core.ChanEnum import Chan_EMA_SEMANTIC, Chan_BSP_TYPE, Chan_KLC_STATE, Chan_FX +import chanlun.core.ChanKLU as ChanKLU +import chanlun.core.ChanCTime as ChanCTime +import chanlun.core.Chan_FX_Box as Chan_FX_Box +# 根据结合律合并K线后的K线 +class ChanKLC(): + def __init__(self, klu: ChanKLU, index, ddir=Chan_KLINE_DIR.UP): + self.start_time = klu.time + self.end_time = None + self.high = klu.high + self.low = klu.low + self.dir = ddir + self.index = index + self.klu_list = [] + self.add_klu(klu) + self.fx = Chan_FX_TYPE.UNKNOWN + self.next = None + self.pre = None + self.start_klu = klu + self.end_klu = None + self.state = "00" + self.klc_state = Chan_KLC_STATE.UNKNOWN + self.open = klu.open + self.close = klu.close + self.volume = klu.volume + self.bi = None + self.distance = 0 + self.klc_fx_type = Chan_KLC_FX.UNKNOWN + self.rsi = klu.rsi + self.volume_ratio = klu.volume_ratio + self.macdhist = klu.macdhist + self.body = klu.body + self.upper_shadow = klu.upper_shadow + self.lower_shadow = klu.lower_shadow + self.body_ratio = klu.body_ratio + self.upper_shadow_ratio = klu.upper_shadow_ratio + self.lower_shadow_ratio = klu.lower_shadow_ratio + self.candle_dir = klu.candle_dir + self.range = klu.range + self.bb_out = True + self.macd = klu.macd + self.signal = klu.signal + self.state = Chan_MACD_STATE.UNKNOWN + self.continue_div = False + self.separate_div = False + self.ema24 = klu.ema24 + self.ema26 = klu.ema26 + self.ema52 = klu.ema52 + self.ema104 = klu.ema104 + self.ema156 = klu.ema156 + self.ema208 = klu.ema208 + self.ema13 = klu.ema13 + self.ema7 = klu.ema7 + self.trend = Chan_PRICE_TREND.UNKNOWN + self.exception = klu.exception + self.klc_dir = Chan_KLINE_DIR.UP if klu.close > klu.open else Chan_KLINE_DIR.DOWN + self.ema_dir = klu.ema_dir + self.bsp = False + self.bsp_type = Chan_BSP_TYPE.NONE + # EMA状态字典:key为EMA名称,value为 {'pos': Chan_EMA_POS, 'semantic': Chan_EMA_SEMANTIC} + self.ema_status = {} + # 向后兼容:保留 ema52_status 和 ema52_pos + self.ema52_status = 0 + self.ema52_pos = Chan_EMA_POS.UNKNOWN + self.bb2633upper = klu.bb2633upper + self.bb2633lower = klu.bb2633lower + self.bb2633middle = klu.bb2633middle + self.ema5 = klu.ema5 + self.ma5 = klu.ma5 + self.fx_box = None + self.in_fx = False + self.fx_confirmed = False + self.ema52_dis = klu.high - klu.ema52 if klu.close > klu.ema52 else klu.ema52 - klu.low + self.ema26_dis = klu.high - klu.ema26 if klu.close > klu.ema26 else klu.ema26 - klu.low + self.macd_signal_dis = abs(klu.macd - klu.signal) + self.ema52_ema26_dis = abs(klu.ema52 - klu.ema26) + self.fx_type = Chan_FX.UNKNOWN + self.bi_zs = None + self.seg_zs = None + self.last_bi_zs = None + # ==================== EMA 通用计算方法 ==================== + + @staticmethod + def cal_ema_pos(high, low, close, ema_value, threshold=0): + """ + 计算K线与任意EMA的客观位置关系(与趋势方向无关,支持threshold容差) + + 参数: + high, low, close: K线的高低收盘价 + ema_value: EMA的值 + threshold: 容差值(绝对值),在此范围内视为"接近/触碰" + 例如 BTC 价格 $100,000 时 threshold=100 表示差100点视为触碰 + 返回: + Chan_EMA_POS 枚举值 + + 判断逻辑(以threshold=100, ema=97000为例): + ema_zone = [96900, 97100] (EMA上下各扩展threshold) + + ABOVE: low > 97100 K线完全在zone上方(远离EMA) + NEAR_ABOVE: 97000 < low <= 97100 K线在上方但下影线进入zone(接近EMA) + CROSS_CLOSE_ABOVE: close > 97000, low <= 97000 K线穿越EMA,收盘在上方 + ON_EMA: abs(close - 97000) <= 100 收盘价在zone内 + CROSS_CLOSE_BELOW: close < 97000, high >= 97000 K线穿越EMA,收盘在下方 + NEAR_BELOW: 96900 <= high < 97000 K线在下方但上影线进入zone(接近EMA) + BELOW: high < 96900 K线完全在zone下方(远离EMA) + """ + if ema_value is None or ema_value == 0: + return Chan_EMA_POS.UNKNOWN + + ema_upper = ema_value + threshold # EMA zone 上界 + ema_lower = ema_value - threshold # EMA zone 下界 + + # 1. 收盘价在EMA附近(zone内) + if threshold > 0 and abs(close - ema_value) <= threshold: + # 收盘价在zone内,但还需要看是否有实际穿越 + if low <= ema_value and close >= ema_value: + return Chan_EMA_POS.CROSS_CLOSE_ABOVE # 实际穿越了精确EMA线 + elif high >= ema_value and close <= ema_value: + return Chan_EMA_POS.CROSS_CLOSE_BELOW + return Chan_EMA_POS.ON_EMA + + # 2. K线实际穿越了精确的EMA线 + if close > ema_value and low <= ema_value: + return Chan_EMA_POS.CROSS_CLOSE_ABOVE + if close < ema_value and high >= ema_value: + return Chan_EMA_POS.CROSS_CLOSE_BELOW + if close == ema_value: + return Chan_EMA_POS.ON_EMA + + # 3. 没有实际穿越,检查是否"接近"(在threshold zone内) + if close > ema_value: + # K线在EMA上方 + if threshold > 0 and low <= ema_upper: + return Chan_EMA_POS.NEAR_ABOVE # 下影线进入zone,接近但未触碰 + return Chan_EMA_POS.ABOVE # 远离EMA + else: + # K线在EMA下方 + if threshold > 0 and high >= ema_lower: + return Chan_EMA_POS.NEAR_BELOW # 上影线进入zone,接近但未触碰 + return Chan_EMA_POS.BELOW # 远离EMA + + @staticmethod + def cal_ema_semantic(ema_pos, kline_dir, ema_dir): + """ + 根据客观位置 + K线方向 + 趋势方向,计算语义状态 + + 参数: + ema_pos: Chan_EMA_POS 客观位置 + kline_dir: Chan_KLINE_DIR K线方向 (UP/DOWN/COMBINE/INCLUDED) + ema_dir: int 趋势方向 (1=多头, -1=空头, 0=盘整) + 返回: + Chan_EMA_SEMANTIC 枚举值 + + 语义含义(以多头为例,空头完全对称): + TOUCH_HOLD: 触碰EMA,收盘守住趋势侧(支撑/压力有效) + BREAK: 穿越EMA,收盘在逆势侧(支撑/压力失败) + DEEP_COUNTER: 完全在EMA逆势侧(深度回调/反抽) + TOUCH_FAIL: 逆势触碰EMA但未穿越(反弹/反抽力度不足) + RECOVER: 逆势后穿越EMA回到趋势侧(收复EMA) + TREND_SIDE: 完全在EMA趋势侧(正常运行) + STRONG_TREND: 顺势K线完全在EMA趋势侧(强势,远未及EMA) + WEAK_COUNTER: 逆势K线完全在EMA逆势侧(弱势,远未到EMA) + """ + if ema_pos == Chan_EMA_POS.UNKNOWN: + return Chan_EMA_SEMANTIC.NEUTRAL + + # 统一处理:将多头/盘整和空头映射到同一套逻辑 + # is_bull=True 时,"趋势侧"=上方,"逆势侧"=下方 + # is_bull=False时,"趋势侧"=下方,"逆势侧"=上方 + is_bull = ema_dir >= 0 # 多头和盘整都按多头逻辑处理 + + # K线是否是顺势方向(多头下UP为顺势,空头下DOWN为顺势) + is_trend_kline = (kline_dir == Chan_KLINE_DIR.UP) if is_bull else (kline_dir == Chan_KLINE_DIR.DOWN) + is_counter_kline = (kline_dir == Chan_KLINE_DIR.DOWN) if is_bull else (kline_dir == Chan_KLINE_DIR.UP) + + # 位置映射:多头下 ABOVE=趋势侧, BELOW=逆势侧; 空头反过来 + trend_side = Chan_EMA_POS.ABOVE if is_bull else Chan_EMA_POS.BELOW + counter_side = Chan_EMA_POS.BELOW if is_bull else Chan_EMA_POS.ABOVE + near_trend = Chan_EMA_POS.NEAR_ABOVE if is_bull else Chan_EMA_POS.NEAR_BELOW + near_counter = Chan_EMA_POS.NEAR_BELOW if is_bull else Chan_EMA_POS.NEAR_ABOVE + cross_to_trend = Chan_EMA_POS.CROSS_CLOSE_ABOVE if is_bull else Chan_EMA_POS.CROSS_CLOSE_BELOW + cross_to_counter = Chan_EMA_POS.CROSS_CLOSE_BELOW if is_bull else Chan_EMA_POS.CROSS_CLOSE_ABOVE + + # COMBINE / INCLUDED 方向:只看位置,不区分强弱 + if not is_trend_kline and not is_counter_kline: + if ema_pos == trend_side: + return Chan_EMA_SEMANTIC.TREND_SIDE + elif ema_pos in (near_trend, cross_to_trend, Chan_EMA_POS.ON_EMA): + return Chan_EMA_SEMANTIC.APPROACHING + elif ema_pos in (near_counter, cross_to_counter): + return Chan_EMA_SEMANTIC.APPROACHING + elif ema_pos == counter_side: + return Chan_EMA_SEMANTIC.DEEP_COUNTER + return Chan_EMA_SEMANTIC.NEUTRAL + + # 逆势K线(多头下的下跌K线 / 空头下的上涨K线) + if is_counter_kline: + if ema_pos == trend_side: + return Chan_EMA_SEMANTIC.STRONG_TREND # 逆势K线仍在趋势侧(回调很浅) + elif ema_pos == near_trend: + return Chan_EMA_SEMANTIC.APPROACHING # 接近EMA,即将测试支撑/压力 + elif ema_pos == cross_to_trend: + return Chan_EMA_SEMANTIC.TOUCH_HOLD # 触碰EMA后守住趋势侧 + elif ema_pos == Chan_EMA_POS.ON_EMA: + return Chan_EMA_SEMANTIC.TOUCH_HOLD # 收盘在EMA附近,视为守住 + elif ema_pos == cross_to_counter: + return Chan_EMA_SEMANTIC.BREAK # 穿越EMA到逆势侧 + elif ema_pos == near_counter: + return Chan_EMA_SEMANTIC.BREAK # 接近EMA但收盘在逆势侧,也视为击穿 + elif ema_pos == counter_side: + return Chan_EMA_SEMANTIC.DEEP_COUNTER # 完全在逆势侧 + + # 顺势K线(多头下的上涨K线 / 空头下的下跌K线) + if is_trend_kline: + if ema_pos == counter_side: + return Chan_EMA_SEMANTIC.WEAK_COUNTER # 顺势K线却在逆势侧(弱势) + elif ema_pos == near_counter: + return Chan_EMA_SEMANTIC.APPROACHING # 从逆势侧接近EMA + elif ema_pos == cross_to_counter: + return Chan_EMA_SEMANTIC.TOUCH_FAIL # 触碰EMA但未穿越回趋势侧 + elif ema_pos == Chan_EMA_POS.ON_EMA: + return Chan_EMA_SEMANTIC.TOUCH_FAIL # 收盘在EMA附近,未确认突破 + elif ema_pos == cross_to_trend: + return Chan_EMA_SEMANTIC.RECOVER # 从逆势侧穿越回趋势侧 + elif ema_pos == near_trend: + return Chan_EMA_SEMANTIC.RECOVER # 接近趋势侧(刚收复EMA附近) + elif ema_pos == trend_side: + return Chan_EMA_SEMANTIC.TREND_SIDE # 完全在趋势侧(正常) + + return Chan_EMA_SEMANTIC.NEUTRAL + + @staticmethod + def semantic_to_int(semantic): + """将 Chan_EMA_SEMANTIC 枚举转换为整数,兼容旧的 ema52_status 数值""" + mapping = { + Chan_EMA_SEMANTIC.TOUCH_HOLD: 1, + Chan_EMA_SEMANTIC.BREAK: 2, + Chan_EMA_SEMANTIC.DEEP_COUNTER: 3, + Chan_EMA_SEMANTIC.TOUCH_FAIL: 4, + Chan_EMA_SEMANTIC.RECOVER: 5, + Chan_EMA_SEMANTIC.TREND_SIDE: 6, + Chan_EMA_SEMANTIC.STRONG_TREND: 7, + Chan_EMA_SEMANTIC.WEAK_COUNTER: 8, + Chan_EMA_SEMANTIC.APPROACHING: 9, + Chan_EMA_SEMANTIC.NEUTRAL: 0, + } + return mapping.get(semantic, 0) + + # threshold_pct: 阈值百分比,用于自动计算绝对阈值 + # 例如 0.001 表示 EMA 值的 0.1%,BTC $100,000 时 threshold = $100 + threshold_pct = 0.001 + def set_bsp_type(self, bsp_type): + if bsp_type and bsp_type != Chan_BSP_TYPE.NONE: + self.bsp_type = bsp_type + self.bsp = True + def cal_all_ema_status(self): + """ + 统一计算所有EMA与K线的位置关系和语义状态 + + threshold 自动按 EMA 值的百分比计算(cls.threshold_pct,默认0.1%) + - BTC $100,000 时:threshold ≈ $100 + - ETH $3,000 时:threshold ≈ $3 + - SOL $200 时:threshold ≈ $0.2 + + 结果存储在 self.ema_status 字典中,格式: + { + 'ema24': {'pos': Chan_EMA_POS, 'semantic': Chan_EMA_SEMANTIC, 'value': float, 'threshold': float}, + 'ema52': {...}, + ... + } + + 同时保持向后兼容:self.ema52_pos 和 self.ema52_status + """ + ema_configs = { + 'ema24': self.ema24, + 'ema52': self.ema52, + 'ema104': self.ema104, + 'ema156': self.ema156, + 'ema208': self.ema208, + } + self.ema_status = {} + for name, value in ema_configs.items(): + # 按 EMA 值的百分比自动计算阈值 + threshold = abs(value) * self.threshold_pct if value and self.threshold_pct > 0 else 0 + pos = ChanKLC.cal_ema_pos(self.high, self.low, self.close, value, threshold) + semantic = ChanKLC.cal_ema_semantic(pos, self.dir, self.ema_dir) + self.ema_status[name] = { + 'pos': pos, + 'semantic': semantic, + 'value': value, + 'threshold': threshold, + } + # 向后兼容 + self.ema52_pos = self.ema_status['ema52']['pos'] + self.ema52_status = ChanKLC.semantic_to_int(self.ema_status['ema52']['semantic']) + def get_ema_pos(self, ema_name): + """获取指定EMA的客观位置,如 klc.get_ema_pos('ema24')""" + if ema_name in self.ema_status: + return self.ema_status[ema_name]['pos'] + return Chan_EMA_POS.UNKNOWN + def check_ema_pos(self): + if len(self.ema_status) > 0: + for ema_name, pos in self.ema_status.items(): + #print(self.end_time, ema_name, pos['pos']) + if ((self.klc_fx_type == Chan_KLC_FX.TOP1 or self.klc_fx_type == Chan_KLC_FX.TOP2) and pos['pos'] == Chan_EMA_POS.CROSS_CLOSE_BELOW) or ((self.klc_fx_type == Chan_KLC_FX.BOTTOM1 or self.klc_fx_type == Chan_KLC_FX.BOTTOM2) and pos['pos'] == Chan_EMA_POS.CROSS_CLOSE_ABOVE): + #print("---------------------") + return ema_name + return None + def get_ema_semantic(self, ema_name): + """获取指定EMA的语义状态,如 klc.get_ema_semantic('ema52')""" + if ema_name in self.ema_status: + return self.ema_status[ema_name]['semantic'] + return Chan_EMA_SEMANTIC.NEUTRAL + def set_trend(self, trend): + self.trend = trend + def to_string(self): + out = "" + start = self.start_time if self.start_time is not None else "" + end = self.end_time if self.end_time is not None else "" + price_diff = getattr(self, 'price_diff', None) + out += str(start) + " " + str(end) + " " + str(self.close) + " " + str(self.ema24) + " " + str(self.ema52) + " " + str(self.trend) + " " + str(self.close - self.ema52) + return out + def set_bi_zs(self, bi_zs): + if bi_zs: + self.bi_zs = bi_zs + def set_klc_fx_type(self, klc_fx_type): + #print(self.start_time, klc_fx_type, self.get_feature_data()['klu_macd'], self.get_feature_data()['klu_macdhist'], self.get_feature_data()['klu_rsi']) + self.klc_fx_type = klc_fx_type + #self.cal_fx() + ema_name = self.check_ema_pos() + hist_div = abs(self.macdhist - self.next.macdhist) + #print(self.end_time, self.dir, abs(self.macdhist), hist_div) + #if ema_name: + #print(self.end_time, ema_name, self.ema_status[ema_name]['semantic'], hist_div) + #self.cal_bb_out() + #print(self.pre.start_time, self.next.end_time, self.klc_fx_type) + if klc_fx_type == Chan_KLC_FX.TOP1 or klc_fx_type == Chan_KLC_FX.TOP2 or klc_fx_type == Chan_KLC_FX.BOTTOM1 or klc_fx_type == Chan_KLC_FX.BOTTOM2: + self.cal_fx_box() + self.cal_fx_type() + def cal_fx_type(self): + if self.fx == Chan_FX_TYPE.TOP and self.next: + if self.ema52_dis > self.ema26_dis: + if self.pre.macd < self.macd and self.macd < self.next.macd: + self.fx_type = Chan_FX.CONTINUATION + else: + self.fx_type = Chan_FX.REVERSAL + elif self.fx == Chan_FX_TYPE.BOTTOM and self.next: + if self.ema52_dis < self.ema26_dis: + if self.pre.macd > self.macd and self.macd > self.next.macd: + self.fx_type = Chan_FX.CONTINUATION + else: + self.fx_type = Chan_FX.REVERSAL + #if self.fx_type != Chan_FX.UNKNOWN and self.fx_type != Chan_FX.CONTINUATION: + #print(self.end_time, self.fx_type) + def cal_fx_box(self): + # 每次重算前先清空,避免旧box残留 + self.fx_box = None + start_time = None + end_time = None + high = 0 + low = 0 + display = False + if self.pre and self.next and self.next.end_time: + self.next.in_fx = True + if self.fx == Chan_FX_TYPE.TOP: + start_time = self.pre.end_time + end_time = self.next.end_time + high = self.high + low = self.pre.low if self.pre.low < self.next.low else self.next.low + if self.next.close < self.pre.low or True: + display = True + elif self.fx == Chan_FX_TYPE.BOTTOM: + start_time = self.pre.end_time + end_time = self.next.end_time + high = self.pre.high if self.pre.high > self.next.high else self.next.high + low = self.low + if self.next.close > self.pre.high or True: + display = True + if high > 0 and self.next.end_time and display: + #print(start_time, end_time, high, low) + # Chan_FX_BOX 这里导入的是模块,类名在模块内部为 Chan_FX_Box + self.fx_confirmed = True + self.fx_box = Chan_FX_Box.Chan_FX_Box(start_time, end_time, high, low) + def check_fx_confirmed(self, last_top, last_bottom): + if last_top and last_bottom and False: + if last_top.index > last_bottom.index: + if self.in_fx == False and last_top.fx_confirmed == False: + pre = last_top.pre + if pre.low > self.close: + last_top.fx_confirmed = True + if last_top.fx_box: + last_top.fx_box.end_time = self.end_time + #print(self.end_time, "fx_confirmed top") + else: + high = last_top.high + low = self.low + last_top.fx_box = Chan_FX_Box.Chan_FX_Box(last_top.pre.start_time, self.end_time, high, low) + #print(self.end_time, "fx_confirmed new box top") + elif self.in_fx == False and last_bottom.fx_confirmed == False: + pre = last_bottom.pre + if pre.high < self.close: + last_bottom.fx_confirmed = True + if last_bottom.fx_box: + last_bottom.fx_box.end_time = self.end_time + #print(self.end_time, "fx_confirmed bottom") + else: + high = self.high + low = last_bottom.low + last_bottom.fx_box = Chan_FX_Box.Chan_FX_Box(last_bottom.pre.start_time, self.end_time, high, low) + #print(self.end_time, "fx_confirmed new box bottom") + def add_klu(self, klu): + self.klu_list.append(klu) + def check_klc_state(self, last_fx_klc): + if last_fx_klc and last_fx_klc.fx == Chan_FX_TYPE.TOP: + if self.high > last_fx_klc.high: + self.klc_state = Chan_KLC_STATE.S11 + else: + self.klc_state = Chan_KLC_STATE.S_11 + elif last_fx_klc and last_fx_klc.fx == Chan_FX_TYPE.BOTTOM: + if self.low < last_fx_klc.low: + self.klc_state = Chan_KLC_STATE.S_11 + else: + self.klc_state = Chan_KLC_STATE.S11 + if self.pre and self.pre.fx == Chan_FX_TYPE.TOP: + self.klc_state = Chan_KLC_STATE.S10 + elif self.pre and self.pre.fx == Chan_FX_TYPE.BOTTOM: + self.klc_state = Chan_KLC_STATE.S_10 + #print(self.end_time, self.klc_state) + def set_end_klu(self, klu): + self.end_klu = klu + self.end_time = klu.time + self.close = klu.close + for klu in self.klu_list: + if klu.exception: + self.exception = True + print(klu.time, "exception") + if klu.separate_div > 0: + self.separate_div = True + if klu.continue_div: + self.continue_div = klu.continue_div + if klu.macd_state != Chan_MACD_STATE.UNKNOWN: + self.state = klu.macd_state + klu.set_klc(self) + self.klc_dir = Chan_KLINE_DIR.UP if self.close > self.open else Chan_KLINE_DIR.DOWN + self.cal_indicators() + self.cal_all_ema_status() + if self.open > self.high: + self.open = self.high + if self.close > self.high: + self.close = self.high + if self.close < self.low: + self.close = self.low + if self.open < self.low: + self.open = self.low + #print(self.end_time, self.open, self.close, self.high, self.low) + #print(klu.time, klu.open, klu.close, klu.high, klu.low) + def cal_fx(self): + if self.klc_fx_type == Chan_KLC_FX.TOP1 or self.klc_fx_type == Chan_KLC_FX.TOP2: + #print(self.end_time, self.fx, self.macd, self.macdhist, len(self.klu_list)) + if self.state == Chan_MACD_STATE.HIGH_EMPTY and self.macd > 0: + #print(self.end_time, self.state, self.macd, self.klc_fx_type) + self.klc_fx_type = Chan_KLC_FX.TOP6 + if self.separate_div or self.continue_div: + self.klc_fx_type = Chan_KLC_FX.TOP7 + if self.signal > 0 and self.macd > self.signal: + self.klc_fx_type = Chan_KLC_FX.TOP8 + else: + if self.klc_fx_type == Chan_KLC_FX.BOTTOM1 or self.klc_fx_type == Chan_KLC_FX.BOTTOM2: + if self.macdhist > 0 and self.macd < 0: + self.klc_fx_type = Chan_KLC_FX.BOTTOM5 + return + if self.state == Chan_MACD_STATE.HIGH_EMPTY and self.macd < 0: + self.klc_fx_type = Chan_KLC_FX.BOTTOM6 + #print(self.end_time, self.state, self.macd, self.klc_fx_type) + if self.separate_div or self.continue_div: + self.klc_fx_type = Chan_KLC_FX.BOTTOM7 + if self.signal < 0 and self.macd < self.signal: + self.klc_fx_type = Chan_KLC_FX.BOTTOM8 + def cal_bb_out(self): + for klu in self.klu_list: + if self.klc_fx_type == Chan_KLC_FX.TOP1 or self.klc_fx_type == Chan_KLC_FX.TOP2: + #print(self.start_time, self.klc_fx_type, klu.high, klu.bb52upper, self.macd, self.next.macd, klu.time) + if self.high >= klu.bb52upper and klu.bb52upper > 0 and self.next and self.high > self.next.high: + self.klc_fx_type = Chan_KLC_FX.TOP4 + print(self.end_time, self.klc_fx_type) + if self.klc_fx_type == Chan_KLC_FX.BOTTOM1 or self.klc_fx_type == Chan_KLC_FX.BOTTOM2: + #print(self.start_time, self.klc_fx_type, klu.low, klu.bb52lower, self.macd, self.next.macd, klu.time) + if self.low <= klu.bb52lower and klu.bb52lower > 0 and self.next and self.low < self.next.low: + self.klc_fx_type = Chan_KLC_FX.BOTTOM4 + print(self.end_time, self.klc_fx_type) + def cal_indicators(self): + for index in range(1, len(self.klu_list)): + self.volume += self.klu_list[index].volume + self.rsi += self.klu_list[index].rsi + self.volume_ratio += self.klu_list[index].volume_ratio + self.macdhist += self.klu_list[index].macdhist + self.ema26 += self.klu_list[index].ema26 + self.ema24 += self.klu_list[index].ema24 + self.ema52 += self.klu_list[index].ema52 + self.ema104 += self.klu_list[index].ema104 + self.ema156 += self.klu_list[index].ema156 + self.ema208 += self.klu_list[index].ema208 + self.ema13 += self.klu_list[index].ema13 + self.ema7 += self.klu_list[index].ema7 + self.bb2633upper += self.klu_list[index].bb2633upper + self.bb2633lower += self.klu_list[index].bb2633lower + self.bb2633middle += self.klu_list[index].bb2633middle + self.ma5 += self.klu_list[index].ma5 + self.ema5 += self.klu_list[index].ema5 + if self.ema_dir != self.klu_list[index].ema_dir: + self.ema_dir = 0 + n = len(self.klu_list) + self.rsi = self.rsi / n + self.volume_ratio = self.volume_ratio / n + self.volume = self.volume / n + self.macdhist = self.macdhist / n + self.ema26 = self.ema26 / n + self.ema24 = self.ema24 / n + self.ema52 = self.ema52 / n + self.ema104 = self.ema104 / n + self.ema156 = self.ema156 / n + self.ema208 = self.ema208 / n + self.ema13 = self.ema13 / n + self.ema7 = self.ema7 / n + self.ma5 = self.ma5 / n + self.ema5 = self.ema5 / n + self.bb2633upper = self.bb2633upper / n + self.bb2633lower = self.bb2633lower / n + self.bb2633middle = self.bb2633middle / n + if len(self.klu_list) > 0: + self.macd = self.klu_list[-1].macd + self.signal = self.klu_list[-1].signal + self.body = abs(self.close - self.open) + self.upper_shadow = self.high - max(self.close, self.open) + self.lower_shadow = min(self.close, self.open) - self.low + self.body_ratio = self.body / self.open + self.upper_shadow_ratio = self.upper_shadow / self.open + self.lower_shadow_ratio = self.lower_shadow / self.open + self.candle_dir = Chan_K_DIR.CROSS if self.close == self.open else Chan_K_DIR.BULL if self.close > self.open else Chan_K_DIR.BEAR + self.range = self.high - self.low + def set_next(self, klc): + self.next = klc + def set_pre(self, klc): + self.pre = klc + def set_state(self, state): + self.state = state + def check_klu_included(self, klu): + if self.high >= klu.high: + # high大于,low小于,左包含 + if self.low <= klu.low: + self.add_klu(klu=klu) + # gn>gn-1 + if self.dir == Chan_KLINE_DIR.UP: + # UP -> max(dn) + self.low = klu.low + else: + # DOWN -> min(gn) + self.high = klu.high + #self.print(klu, "Z") + return True + # high大于,low大于,不包含 + else: + # if self.low > klu.low + # high相等,右包含 + if self.high == klu.high: + self.add_klu(klu=klu) + # UP -> max(gn) + if self.dir == Chan_KLINE_DIR.UP: + self.high = klu.high + else: + # DOWN -> min(dn) + self.low = klu.low + return True + else: + return False + else: + # high小于,low大于,右包含 + if self.low >= klu.low: + self.add_klu(klu=klu) + # gn>gn-1 + if self.dir == Chan_KLINE_DIR.UP: + # UP -> max(gn) + self.high = klu.high + else: + # DOWN -> min(dn) + self.low = klu.low + #self.print(klu, "Y") + return True + else: + # high小于,low小于,不包含 + return False + def set_fx(self, fx: Chan_FX_TYPE): + self.fx = fx + def cal_invisible(self): + if self.fx == Chan_FX_TYPE.TOP: + if self.macdhist < 0 and self.macd > 0: + self.klc_fx_type = Chan_KLC_FX.TOP5 + else: + if self.fx == Chan_FX_TYPE.BOTTOM: + if self.macdhist > 0 and self.macd < 0: + self.klc_fx_type = Chan_KLC_FX.BOTTOM5 + def set_pre_fx(self): + if self.pre and self.pre.pre: + self.pre.fx = self.check_fx(self.pre.pre, self.pre) + def check_fx(self, k1, k2): + if k2.high > k1.high and k2.high > self.high: + return Chan_FX_TYPE.TOP + elif k2.low < k1.low and k2.low < self.low: + return Chan_FX_TYPE.BOTTOM + else: + return Chan_FX_TYPE.UNKNOWN + def set_bi(self, bi): + self.bi = bi + self.distance = self.index - bi.start_klc.index + #print(self.start_time, self.distance, bi.index, bi.dir) \ No newline at end of file diff --git a/chanlun/core/ChanKLU.py b/chanlun/core/ChanKLU.py new file mode 100644 index 0000000..36ae434 --- /dev/null +++ b/chanlun/core/ChanKLU.py @@ -0,0 +1,388 @@ +from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLU_TYPE, Chan_K_DIR, Chan_MACD_STATE, Chan_MACDHIST_STATE, Chan_PRICE_TREND, Chan_KLU_PATTERN, Chan_KLC_FX +class ChanKLU: + def __init__(self, time, open, high, low, close, volume): + # _time, _close, _open, _high, _low, _extra_info={} + self.kl_type = None + self.time = time + self.close = close + self.open = open + self.high = high + self.low = low + self.volume = volume + self.idx = 0 + self.index = 0 + self.macd = 0 + self.signal = 0 + self.macdhist = 0 + self.klc = None + self.rsi = 0 + self.volume_ratio = 0 + self.bb52upper = 0 + self.bb52lower = 0 + # === 新增:K线类型 === + self.kline_type = None # K线类型:大阳线、大阴线、小阳线、小阴线 + self.pattern = Chan_KLU_PATTERN.UNKNOWN + + # === 新增:实时分型相关属性 === + self.pre = None # 前一根K线 + self.next = None # 后一根K线 + self.fx_type = Chan_FX_TYPE.UNKNOWN # 分型类型:0=无分型,1=顶分型,-1=底分型 + self.fx_strength = 0 # 分型强度:0-100 + self.fx_confirmed = False # 分型是否确认 + self.klu_type = None + self.range = self.high - self.low + self.body = abs(self.close - self.open) + self.upper_shadow = self.high - max(self.close, self.open) + self.lower_shadow = min(self.close, self.open) - self.low + self.body_ratio = self.body / self.range if self.range != 0 else 0 + self.upper_shadow_ratio = self.upper_shadow / self.body if self.body != 0 else float('inf') + self.lower_shadow_ratio = self.lower_shadow / self.body if self.body != 0 else float('inf') + self.exception = False + #self.cal_exception() + self.candle_dir = Chan_K_DIR.CROSS if self.close == self.open else Chan_K_DIR.BULL if self.close > self.open else Chan_K_DIR.BEAR + + self.continue_div = 0 + self.separate_div = 0 + self.near0_return = 0 + self.ema52 = 0 + self.ema24 = 0 + self.ema26 = 0 + self.ema104 = 0 + self.ema156 = 0 + self.ema208 = 0 + self.macd_slop = 0 + self.signal_slop = 0 + self.hist_slop = 0 + self.hist_state = Chan_MACDHIST_STATE.UNKNOWN + self.macd_state = Chan_MACD_STATE.UNKNOWN + self.macd_hist_gap = 0 + self.trend = Chan_PRICE_TREND.UNKNOWN + self.seg_histset_index = 0 + # === 归零轴细化与模式/背离 === + self.zero_axis = False # 是否归零轴(穿越或接近) + self.zero_axis_state = "none" # {none,crossing,near} + self.zero_axis_side = 0 # 1:above, -1:under, 0:none + self.zero_axis_score = 0 # 0-100 综合评分 + self.mode1_touch_ema52 = False # 单边后触碰EMA52 + self.mode2_fast_to_zero = False # 快线向零收敛 + self.mode3_double_tf = False # 双周期归零(近似占位,由上层填充高周期确认) + self.mode3_dir = "none" # {long_strong_rebound, short_strong_rebound, none} + self.mode4_touch52_no_zero = False # 先触碰EMA52但黄白线未归零 + self.div_type = "none" # {bearish, bullish, hidden_bearish, hidden_bullish, none} + self.div_score = 0.0 # 背离强度(0-100) + self.ema_dir = 0 + self.get_ema_dir() + self.bb2633upper = 0 + self.bb2633lower = 0 + self.bb2633middle = 0 + self.ma5 = 0 + self.ema5 = 0 + #print(self.open, self.close, self.high, self.low, self.candle_dir, self.strength) + def set_macd_state(self, state): + self.macd_state = state + def set_pattern(self, pattern): + self.pattern = pattern + def set_seg_histset_index(self, seg_histset_index): + self.seg_histset_index = seg_histset_index + #print(self.time, self.seg_histset_index) + def to_string(self): + return f"{self.time} {self.candle_dir} {self.pattern}" + def cal_exception(self): + if self.upper_shadow_ratio > 5 or self.lower_shadow_ratio > 5: + self.exception = True + #print(self.time, self.upper_shadow_ratio, self.lower_shadow_ratio, self.body, self.lower_shadow, self.upper_shadow, self.high, self.low, self.close, self.open) + #self.exception = False + def set_trend(self, trend): + self.trend = trend + def set_separate_div(self, separate_div): + self.separate_div = separate_div + if self.klc and self.klc.pre and self.klc.next: + fx = self.check_fx_dir(self.klc.pre, self.klc.next) + if fx == Chan_FX_TYPE.TOP: + if self.macdhist > 0: + self.separate_div = separate_div + else: + self.separate_div = 0 + elif fx == Chan_FX_TYPE.BOTTOM: + if self.macdhist < 0: + self.separate_div = separate_div + else: + self.separate_div = 0 + def check_fx_dir(self, pre, next): + fx = Chan_FX_TYPE.UNKNOWN + if pre.klc_fx_type == Chan_KLC_FX.TOP1 or pre.klc_fx_type == Chan_KLC_FX.TOP2 or next.klc_fx_type == Chan_KLC_FX.TOP1 or next.klc_fx_type == Chan_KLC_FX.TOP2 or self.klc.klc_fx_type == Chan_KLC_FX.TOP1 or self.klc.klc_fx_type == Chan_KLC_FX.TOP2: + fx = Chan_FX_TYPE.TOP + elif pre.klc_fx_type == Chan_KLC_FX.BOTTOM1 or pre.klc_fx_type == Chan_KLC_FX.BOTTOM2 or next.klc_fx_type == Chan_KLC_FX.BOTTOM1 or next.klc_fx_type == Chan_KLC_FX.BOTTOM2 or self.klc.klc_fx_type == Chan_KLC_FX.BOTTOM1 or self.klc.klc_fx_type == Chan_KLC_FX.BOTTOM2: + fx = Chan_FX_TYPE.BOTTOM + return fx + def set_next(self, next): + self.next = next + #if self.fx_type != Chan_FX_TYPE.UNKNOWN and self.fx_strength > 1: + #print(self.index, self.time, self.fx_type, self.fx_confirmed, self.fx_strength) + def set_pre(self, pre): + self.pre = pre + def set_klc(self, klc): + self.klc = klc + def set_histset(self, histset): + """设置HistSet关联""" + self.histset = histset + + def set_seg(self, seg): + """设置Seg关联""" + self.seg = seg + + def set_unittf(self, unittf): + """设置UnitTF关联""" + self.unittf = unittf + def set_idx(self, idx): + self.idx = idx + self.index = idx + def check_price_ema156(self): + if self.check_indicators(): + if self.close > self.ema156: + return 1 + elif self.close < self.ema156: + return -1 + else: + return 0 + else: + return 0 + def get_ema_dir(self): + if self.check_indicators(): + if self.ema24 > self.ema52 and self.ema52 > self.ema104 and self.ema104 > self.ema156: + self.ema_dir = 1 + elif self.ema24 < self.ema52 and self.ema52 < self.ema104 and self.ema104 < self.ema156: + self.ema_dir = -1 + else: + self.ema_dir = 0 + def check_indicators(self): + if self.ema156 == 0: + return False + else: + return True + def set_indicators(self, item): + self.macd = float(item['macd']) if 'macd' in item and item['macd'] else 0 + self.signal = float(item['macdsignal']) if 'macdsignal' in item and item['macdsignal'] else 0 + self.macdhist = float(item['macdhist']) if 'macdhist' in item and item['macdhist'] else 0 + self.ema26 = float(item['ema26']) if 'ema26' in item and item['ema26'] else 0 + self.ema52 = float(item['ema52']) if 'ema52' in item and item['ema52'] else 0 + self.ema24 = float(item['ema24']) if 'ema24' in item and item['ema24'] else 0 + self.ema104 = float(item['ema104']) if 'ema104' in item and item['ema104'] else 0 + self.ema156 = float(item['ema156']) if 'ema156' in item and item['ema156'] else 0 + self.ema208 = float(item['ema208']) if 'ema208' in item and item['ema208'] else 0 + self.ema13 = float(item['ema13']) if 'ema13' in item and item['ema13'] else 0 + self.ema7 = float(item['ema7']) if 'ema7' in item and item['ema7'] else 0 + self.rsi = float(item['rsi']) if 'rsi' in item and item['rsi'] else 0 + self.volume_ratio = float(item['volume_ratio']) if 'volume_ratio' in item and item['volume_ratio'] else 0 + self.bb52upper = float(item['bb52upper']) if 'bb52upper' in item and item['bb52upper'] else 0 + self.bb52lower = float(item['bb52lower']) if 'bb52lower' in item and item['bb52lower'] else 0 + self.bb2633upper = float(item['bb2633upper']) if 'bb2633upper' in item and item['bb2633upper'] else 0 + self.bb2633lower = float(item['bb2633lower']) if 'bb2633lower' in item and item['bb2633lower'] else 0 + self.bb2633middle = float(item['bb2633middle']) if 'bb2633middle' in item and item['bb2633middle'] else 0 + self.ma5 = float(item['ma5']) if 'ma5' in item and item['ma5'] else 0 + self.ema5 = float(item['ema5']) if 'ema5' in item and item['ema5'] else 0 + def cal_macd_state(self): + # 按定义精简实现:优先级 CROSS0 > 位置(HIGH/HE/RETURN_ZERO) > NEAR0 > UNKNOWN + # 首条或缺前一根 + if not hasattr(self, 'pre') or self.pre is None: + self.macd_state = Chan_MACD_STATE.START + return self.macd_state + + # 基本校验 + if (self.macd == 0 and self.signal == 0 and self.macdhist == 0) or self.ema52 == 0: + self.macd_state = Chan_MACD_STATE.UNKNOWN + return self.macd_state + # 归零轴判断 + if self.signal > 0: + if self.macd < self.signal: + if 0 < self.low - self.ema52 < 100: + self.near0_return = 0 + elif self.close > self.ema52 and self.low < self.ema52 and self.open > self.ema52: + self.near0_return = 0 + elif self.close < self.ema52 and self.open > self.ema52 and self.high > self.ema52 and self.low < self.ema52: + self.near0_return = 0 + elif self.close < self.ema52 and self.open < self.ema52 and self.high > self.ema52 and self.low < self.ema52: + self.near0_return = 0 + elif self.close > self.ema52 and self.open > self.ema52 and self.high > self.ema52 and self.low < self.ema52: + self.near0_return = 0 + elif self.close > self.ema52 and self.high > self.ema52 and self.low < self.ema52: + self.near0_return = 0 + else: + if self.macd > self.signal: + if 0 < self.ema52 - self.high < 100: + self.near0_return = 0 + elif self.close < self.ema52 and self.high > self.ema52 and self.open < self.ema52: + self.near0_return = 0 + elif self.close < self.ema52 and self.open > self.ema52 and self.high > self.ema52 and self.low < self.ema52: + self.near0_return = 0 + elif self.close > self.ema52 and self.open < self.ema52 and self.high > self.ema52 and self.low < self.ema52: + self.near0_return = 0 + elif self.close > self.ema52 and self.high > self.ema52 and self.open >= self.ema52 and self.low < self.ema52: + self.near0_return = 0 + elif self.close > self.ema52 and self.high > self.ema52 and self.low < self.ema52: + self.near0_return = 0 + # 向上穿越EMA52 7 + if self.close > self.ema52 and self.open < self.ema52: + self.near0_return = 0 + # 向下穿越EMA52 8 + elif self.close < self.ema52 and self.open > self.ema52: + self.near0_return = 0 + if self.pre.near0_return == 7: + # 向上穿越后的一根价格再EMA52上方 9 + if self.low > self.ema52 and self.close > self.open: + self.near0_return = 9 + if self.pre.near0_return == 8: + # 向下穿越后的一根价格再EMA52下方 10 + if self.high < self.ema52 and self.close < self.open: + self.near0_return = 10 + # CROSS0 仅以 Signal 穿越零轴判定 + if self.pre.signal >= 0 and self.signal < 0: + self.macd_state = Chan_MACD_STATE.CROSS0_DOWN + return self.macd_state + if self.pre.signal <= 0 and self.signal > 0: + self.macd_state = Chan_MACD_STATE.CROSS0_UP + return self.macd_state + # 穿零轴后的形态:缠绕/倒挂(基于前一状态为CROSS0_*) + if self.pre.macd_state == Chan_MACD_STATE.CROSS0_UP or self.pre.macd_state == Chan_MACD_STATE.CROSS0_DOWN: + direction = 1 if self.pre.macd_state == Chan_MACD_STATE.CROSS0_UP else -1 + hist_same_dir = (self.macdhist * direction) > 0 + hist_decreasing = abs(self.macdhist) < abs(self.pre.macdhist) + lines_tight = abs(self.macd - self.signal) <= 12 + # 倒挂:能量柱衰减且黄白线相对方向不利/出现反向能量释放 + if hist_decreasing and (((self.macd - self.signal) * direction) < 0 or not hist_same_dir): + self.macd_state = Chan_MACD_STATE.CROSS_REV + return self.macd_state + # 缠绕/粘合:紧贴能量柱运行,无反向能量释放 + if lines_tight and hist_same_dir: + self.macd_state = Chan_MACD_STATE.CROSS_OS + return self.macd_state + # 趋近零轴:细化 NEAR0_* 判定 + NEAR0_EPS = 15 + lines_near_zero = abs(self.macd) <= NEAR0_EPS or abs(self.signal) <= NEAR0_EPS + touch_52 = (self.ema52 != 0) and ((abs(self.close - self.ema52) <= NEAR0_EPS) or (self.low <= self.ema52 <= self.high)) + touch_24 = (self.ema24 != 0) and ((abs(self.close - self.ema24) <= NEAR0_EPS) or (self.low <= self.ema24 <= self.high)) + # 完美形态:白线接近零轴 + 价格触碰/轻破EMA52 + 黄线不穿零轴 + if abs(self.macd) <= NEAR0_EPS and touch_52 and (not (self.pre.signal >= 0 and self.signal < 0)) and (not (self.pre.signal <= 0 and self.signal > 0)): + self.macd_state = Chan_MACD_STATE.NEAR0_PERFECT + #self.near0_return = 1 + return self.macd_state + # EMA24 附近 + if lines_near_zero and touch_24: + self.macd_state = Chan_MACD_STATE.NEAR0_24 + #self.near0_return = 2 + return self.macd_state + # EMA52 附近 + if lines_near_zero and touch_52: + self.macd_state = Chan_MACD_STATE.NEAR0_52 + #self.near0_return = 3 + return self.macd_state + # 白线接近零轴但价格未至EMA52 + if abs(self.macd) <= NEAR0_EPS and not touch_52: + self.macd_state = Chan_MACD_STATE.NEAR0_DIFF + #self.near0_return = 4 + return self.macd_state + # 一般近零轴 + if lines_near_zero or touch_52: + self.macd_state = Chan_MACD_STATE.NEAR0 + #self.near0_return = 5 + return self.macd_state + + # 穿零轴后离开零轴 + if self.pre.macd_state == Chan_MACD_STATE.CROSS0_UP and ((self.macd >= self.pre.macd and self.signal >= self.pre.signal) or (abs(self.macdhist) >= abs(self.pre.macdhist))): + self.macd_state = Chan_MACD_STATE.UP + return self.macd_state + if self.pre.macd_state == Chan_MACD_STATE.CROSS0_DOWN and ((self.macd <= self.pre.macd and self.signal <= self.pre.signal) or (abs(self.macdhist) >= abs(self.pre.macdhist))): + self.macd_state = Chan_MACD_STATE.DOWN + return self.macd_state + if self.pre.macd_state == Chan_MACD_STATE.UP and self.macd > self.pre.macd and self.signal > self.pre.signal: + self.macd_state = Chan_MACD_STATE.UP + return self.macd_state + if self.pre.macd_state == Chan_MACD_STATE.DOWN and self.macd < self.pre.macd and self.signal < self.pre.signal: + self.macd_state = Chan_MACD_STATE.DOWN + return self.macd_state + # 趋势兜底:强势同步上行/下行直接进入 UP/DOWN + if self.macd > 0 and self.signal > 0 and (self.macd >= self.pre.macd and self.signal >= self.pre.signal): + self.macd_state = Chan_MACD_STATE.UP + return self.macd_state + if self.macd < 0 and self.signal < 0 and (self.macd <= self.pre.macd and self.signal <= self.pre.signal): + self.macd_state = Chan_MACD_STATE.DOWN + return self.macd_state + # 峰值:白线高位出现局部顶 + if hasattr(self.pre, 'pre') and self.pre and self.pre.pre and self.macd > 0: + if self.pre.macd > self.pre.pre.macd and self.pre.macd > self.macd: + self.macd_state = Chan_MACD_STATE.PEAK + return self.macd_state + # 高位状态的位置状态, 高位,高位空,归零轴 + if (self.pre.macd_state == Chan_MACD_STATE.UP or self.pre.macd_state == Chan_MACD_STATE.HIGH or self.pre.macd_state == Chan_MACD_STATE.RZ_UP or self.pre.macd_state == Chan_MACD_STATE.PEAK or self.pre.macd_state == Chan_MACD_STATE.HIGH_EMPTY) and self.macd > 0: + # 高位空(正区间):能量柱衰减且黄白线间距较大 + if abs(self.pre.macdhist) > 0 and abs(self.macdhist) < abs(self.pre.macdhist) and abs(self.macd - self.signal) > 5: + self.macd_state = Chan_MACD_STATE.HIGH_EMPTY + return self.macd_state + if abs(self.pre.macd - self.macd) < 10: + self.macd_state = Chan_MACD_STATE.HIGH + return self.macd_state + else: + if self.macd > self.pre.macd and self.signal > self.pre.signal: + self.macd_state = Chan_MACD_STATE.UP + return self.macd_state + elif self.macd < self.pre.macd and self.signal < self.pre.signal: + self.macd_state = Chan_MACD_STATE.RETURN_ZERO + return self.macd_state + if (self.pre.macd_state == Chan_MACD_STATE.DOWN or self.pre.macd_state == Chan_MACD_STATE.HIGH or self.pre.macd_state == Chan_MACD_STATE.RZ_DOWN or self.pre.macd_state == Chan_MACD_STATE.PEAK or self.pre.macd_state == Chan_MACD_STATE.HIGH_EMPTY) and self.macd < 0: + # 高位空(负区间):能量柱衰减且黄白线间距较大 + if abs(self.pre.macdhist) > 0 and abs(self.macdhist) < abs(self.pre.macdhist) and abs(self.macd - self.signal) > 5: + self.macd_state = Chan_MACD_STATE.HIGH_EMPTY + return self.macd_state + if abs(self.pre.macd - self.macd) < 10: + self.macd_state = Chan_MACD_STATE.HIGH + return self.macd_state + else: + if self.macd > self.pre.macd and self.signal > self.pre.signal: + self.macd_state = Chan_MACD_STATE.RETURN_ZERO + return self.macd_state + elif self.macd < self.pre.macd and self.signal < self.pre.signal: + self.macd_state = Chan_MACD_STATE.DOWN + return self.macd_state + + # 离开0轴开始上涨或者下跌阶段,高位之前的 + if self.macd > 0 and self.pre: + if (self.pre.macd_state == Chan_MACD_STATE.NEAR0 or self.pre.macd_state == Chan_MACD_STATE.RZ_UP or self.pre.macd_state == Chan_MACD_STATE.CROSS0_UP) and (self.signal > self.pre.signal or self.close > self.ema52): + self.macd_state = Chan_MACD_STATE.RZ_UP + return self.macd_state + elif self.macd < 0 and self.pre: + if (self.pre.macd_state == Chan_MACD_STATE.NEAR0 or self.pre.macd_state == Chan_MACD_STATE.RZ_DOWN or self.pre.macd_state == Chan_MACD_STATE.CROSS0_DOWN) and (self.signal < self.pre.signal or self.close < self.ema52): + self.macd_state = Chan_MACD_STATE.RZ_DOWN + return self.macd_state + # 归零轴走势 + if self.pre.macd_state == Chan_MACD_STATE.RETURN_ZERO: + if self.macd > 0: + if self.pre.macd > self.macd or abs(self.macdhist) <= abs(self.pre.macdhist) or self.signal <= self.pre.signal: + self.macd_state = Chan_MACD_STATE.RETURN_ZERO + return self.macd_state + else: + if self.pre.macd < self.macd or abs(self.macdhist) <= abs(self.pre.macdhist) or self.signal >= self.pre.signal: + self.macd_state = Chan_MACD_STATE.RETURN_ZERO + return self.macd_state + # 从 NEAR0 收敛到零轴的归零轴承接(正负两侧) + if self.pre.macd_state == Chan_MACD_STATE.NEAR0: + # 正区间朝零轴收敛 + if self.macd > 0 and self.pre.macd > 0 and self.macd <= self.pre.macd and self.signal <= self.pre.signal: + self.macd_state = Chan_MACD_STATE.RETURN_ZERO + return self.macd_state + # 负区间朝零轴收敛 + if self.macd < 0 and self.pre.macd < 0 and self.macd >= self.pre.macd and self.signal >= self.pre.signal: + self.macd_state = Chan_MACD_STATE.RETURN_ZERO + return self.macd_state + # 其余情况 + if self.pre.macd_state == Chan_MACD_STATE.UNKNOWN: + if self.macd > 0 and self.close > self.ema52 and self.pre.pre and (self.pre.pre.macd_state == Chan_MACD_STATE.UP or self.pre.pre.macd_state == Chan_MACD_STATE.RZ_UP): + self.macd_state = self.pre.pre.macd_state + return self.macd_state + elif self.macd < 0 and self.close < self.ema52 and self.pre.pre and (self.pre.pre.macd_state == Chan_MACD_STATE.DOWN or self.pre.pre.macd_state == Chan_MACD_STATE.RZ_DOWN): + self.macd_state = self.pre.pre.macd_state + return self.macd_state + else: + self.macd_state = Chan_MACD_STATE.UNKNOWN + return self.macd_state + return self.macd_state + \ No newline at end of file diff --git a/chanlun/core/ChanSBI.py b/chanlun/core/ChanSBI.py new file mode 100644 index 0000000..36fbc50 --- /dev/null +++ b/chanlun/core/ChanSBI.py @@ -0,0 +1,91 @@ +import copy +from typing import Dict, Optional + +from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLINE_DIR, Chan_BI_DIR +import chanlun.core.ChanKLU as ChanKLU +from chanlun.core.ChanBI import ChanBI + +class ChanSBI(): + def __init__(self, start_bi: ChanBI, index, dir=Chan_BI_DIR.UP): + self.start_bi = start_bi + self.end_bi = None + self.index = index + self.dir = dir + self.high = start_bi.high + self.low = start_bi.low + self.pre = None + self.next = None + self.fx = Chan_FX_TYPE.UNKNOWN + self.bi_list = [] + self.bi_list.append(start_bi) + self.has_fx_gap = False + def set_fx(self, fx): + self.fx = fx + def set_end_bi(self, bi): + self.end_bi = bi + def set_pre(self, sbi): + self.pre = sbi + def set_next(self, sbi): + self.next = sbi + def add_bi(self, bi): + self.bi_list.append(bi) + def check_fx(self): + if self.pre and self.next: + #print(self.pre.start_bi.start_time, self.start_bi.start_time, self.end_bi.end_time, self.next.start_bi.start_time, self.pre.high, self.high, self.next.high, self.pre.low, self.low, self.next.low, self.dir) + if self.high > self.pre.high and self.high > self.next.high: + self.fx = Chan_FX_TYPE.TOP + #print(self.start_bi.start_time, self.pre.start_bi.start_time, self.next.start_bi.start_time, self.fx) + if self.low > self.pre.high: + self.has_fx_gap = True + #print(self.start_bi.start_time, self.end_bi.end_time, self.pre.start_bi.start_time, self.next.start_bi.start_time, self.dir, self.has_fx_gap, self.fx) + return Chan_FX_TYPE.TOP + else: + if self.low < self.pre.low and self.low < self.next.low: + self.fx = Chan_FX_TYPE.BOTTOM + #print(self.start_bi.start_time, self.pre.start_bi.start_time, self.next.start_bi.start_time, self.fx) + if self.high < self.pre.low: + self.has_fx_gap = True + #print(self.start_bi.start_time, self.end_bi.end_time, self.pre.start_bi.start_time, self.next.start_bi.start_time, self.dir, self.has_fx_gap, self.fx) + return Chan_FX_TYPE.BOTTOM + return Chan_FX_TYPE.UNKNOWN + def check_seg_bi_broken(self): + broken = False + if self.fx == Chan_FX_TYPE.TOP: + if self.next.low < self.pre.high: + broken = True + elif self.fx == Chan_FX_TYPE.BOTTOM: + if self.next.high > self.pre.low: + broken = True + return broken + def check_bi_included(self, bi): + included = False + if self.high > bi.high: + # high大于,low小于,左包含 + if self.low < bi.low: + included = True + # high大于,low大于,不包含 + else: + # if self.low > bi.low + # high相等,右包含 + included = False + else: + included = False + # high小于,low大于,右包含 + #if self.low > bi.low: + #included = True + if included: + if self.pre: + if self.high > self.pre.high and self.low < self.pre.low: + included = True + if included: + self.add_bi(bi) + # gn>gn-1 + if self.dir == Chan_BI_DIR.DOWN: + # UP -> max(dn) + self.low = bi.low + else: + # DOWN -> min(gn) + self.high = bi.high + #self.print(bi, "Z") + #print(self.start_bi.start_time, bi.start_time, included) + return included \ No newline at end of file diff --git a/chanlun/core/ChanSEG.py b/chanlun/core/ChanSEG.py new file mode 100644 index 0000000..c6e257c --- /dev/null +++ b/chanlun/core/ChanSEG.py @@ -0,0 +1,197 @@ +import copy +from typing import Dict, Optional + +from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLINE_DIR, Chan_SEG_DIR, Chan_BI_DIR, Chan_ZS_DIR +import chanlun.core.ChanCTime as ChanCTime +from chanlun.core.ChanBI import ChanBI +from chanlun.core.ChanBIZS import ChanBIZS +class ChanSEG(): + def __init__(self, start_bi: ChanBI, index, ddir=Chan_SEG_DIR.UP, pre_end_bi: ChanBI = None): + self.start_bi = start_bi + self.start_time = start_bi.start_time + self.end_time = None + self.end_bi = None + self.dir = ddir + self.low = 0 + self.high = 0 + if self.dir == Chan_SEG_DIR.UP and start_bi: + self.low = start_bi.low + else: + if start_bi: + self.high = start_bi.high + self.index = index + self.pre = None + self.next = None + self.bi_list = [] + self.bi_list.append(start_bi) + self.is_sure = False + self.sure_time = None + self.macd_hist = 0 + self.macd_div = 0 + self.start_bi.set_seg(self) + self.pre_end_bi = pre_end_bi + if self.pre_end_bi: + self.ini_seg() + def ini_seg(self): + next_bi = self.start_bi.next + for index in range(self.start_bi.index+1, self.pre_end_bi.index): + if next_bi: + self.bi_list.append(next_bi) + next_bi.set_seg(self) + next_bi = next_bi.next + def set_macdhist(self, macd_hist): + self.macd_hist = macd_hist + def set_macd_div(self, macd_div): + self.macd_div = macd_div + def set_end_bi(self, bi: ChanBI, sure_bi: ChanBI): + self.end_bi = bi + if bi and bi.is_sure: + if self.dir == Chan_SEG_DIR.UP: + self.high = bi.high + else: + self.low = bi.low + self.is_sure = True + self.end_time = bi.end_klc.end_time + if sure_bi.is_sure: + self.sure_time = sure_bi.sure_time + self.format_bi_list() + def pre_set_end_bi(self, bi: ChanBI): + self.end_bi = bi + if bi and bi.is_sure: + if self.dir == Chan_SEG_DIR.UP: + self.high = bi.high + else: + self.low = bi.low + self.end_time = bi.end_klc.end_time + self.format_bi_list() + def set_pre(self, seg): + self.pre = seg + def set_next(self, seg): + self.next = seg + def set_sure(self, sure_bi): + if sure_bi.is_sure: + self.sure_time = sure_bi.sure_time + self.is_sure = True + self.format_bi_list() + def format_bi_list(self): + self.bi_list = [] + self.bi_list.append(self.start_bi) + if self.end_bi: + next_bi = self.start_bi.next + for i in range(self.start_bi.index, self.end_bi.index): + if next_bi: + self.bi_list.append(next_bi) + next_bi.set_seg(self) + next_bi = next_bi.next + def add_bi(self, bi: ChanBI): + if len(self.bi_list) > 0: + self.bi_list.append(bi) + bi.set_seg(self) + self.end_time = bi.end_time + self.end_bi = bi + def cal_bi_zs(self): + zs_list = [] + if len(self.bi_list) > 3: + last_zs = None + if self.dir == Chan_SEG_DIR.UP: + for index in range(1, len(self.bi_list)): + bi = self.bi_list[index] + #print(bi.end_time, bi.next,"UP SEG BI ZS Index") + if bi.next == None or bi.next.next == None: + if last_zs and (bi.low > last_zs.zg or bi.high < last_zs.zd): + last_zs.set_end_bi(last_zs.bi_list[-1], last_zs.bi_list[-1].sure_time) + continue + bi2 = bi.next + bi3 = bi.next.next + if len(zs_list) == 0 or (last_zs and last_zs.is_sure): + if bi3.is_sure and bi3.index <= self.bi_list[-1].index and bi.check_bi_zs_overlap() and bi.dir == Chan_BI_DIR.DOWN: + zg = min(bi.high, bi2.high, bi3.high) + zd = max(bi.low, bi2.low, bi3.low) + gg = max(bi.high, bi2.high, bi3.high) + dd = min(bi.low, bi2.low, bi3.low) + zs = ChanBIZS(bi, len(zs_list), Chan_ZS_DIR.UP) + zs.set_zg(zg) + zs.set_zd(zd) + zs.set_gg(gg) + zs.set_dd(dd) + zs.add_bi(bi2) + zs.add_bi(bi3) + zs_list.append(zs) + last_zs = zs + else: + if bi.index > last_zs.bi_list[-1].index and bi.dir == Chan_BI_DIR.DOWN and bi.is_sure: + if bi.low > last_zs.zg or bi.high < last_zs.zd: + #print(bi.end_time, "UP SEG BI ZS End") + last_zs.set_end_bi(last_zs.bi_list[-1], last_zs.bi_list[-1].sure_time) + if bi3.is_sure and bi3.index <= self.bi_list[-1].index and bi.check_bi_zs_overlap() and bi.dir == Chan_BI_DIR.DOWN: + zg = min(bi.high, bi2.high, bi3.high) + zd = max(bi.low, bi2.low, bi3.low) + gg = max(bi.high, bi2.high, bi3.high) + dd = min(bi.low, bi2.low, bi3.low) + zs = ChanBIZS(bi, len(zs_list), Chan_ZS_DIR.UP) + zs.set_zg(zg) + zs.set_zd(zd) + zs.set_gg(gg) + zs.set_dd(dd) + zs.add_bi(bi2) + zs.add_bi(bi3) + zs_list.append(zs) + last_zs = zs + else: + last_zs.add_bi(bi.pre) + last_zs.add_bi(bi) + if index == len(self.bi_list) - 1 and last_zs and not last_zs.is_sure: + #print(bi.start_time, "BI", last_zs.is_sure) + last_zs.set_end_bi(last_zs.bi_list[-1], last_zs.bi_list[-1].sure_time) + else: + for index in range(1, len(self.bi_list)): + bi = self.bi_list[index] + if bi.next == None or bi.next.next == None: + if last_zs and (bi.low > last_zs.zg or bi.high < last_zs.zd): + last_zs.set_end_bi(last_zs.bi_list[-1], last_zs.bi_list[-1].sure_time) + continue + bi2 = bi.next + bi3 = bi.next.next + if len(zs_list) == 0 or (last_zs and last_zs.is_sure): + if bi3.is_sure and bi3.index <= self.bi_list[-1].index and bi.check_bi_zs_overlap() and bi.dir == Chan_BI_DIR.UP: + zg = min(bi.high, bi2.high, bi3.high) + zd = max(bi.low, bi2.low, bi3.low) + gg = max(bi.high, bi2.high, bi3.high) + dd = min(bi.low, bi2.low, bi3.low) + zs = ChanBIZS(bi, len(zs_list), Chan_ZS_DIR.DOWN) + zs.set_zg(zg) + zs.set_zd(zd) + zs.set_gg(gg) + zs.set_dd(dd) + zs.add_bi(bi2) + zs.add_bi(bi3) + zs_list.append(zs) + last_zs = zs + else: + if bi.index > last_zs.bi_list[-1].index and bi.dir == Chan_BI_DIR.UP and bi.is_sure: + if bi.low > last_zs.zg or bi.high < last_zs.zd: + last_zs.set_end_bi(last_zs.bi_list[-1], last_zs.bi_list[-1].sure_time) + if bi3.is_sure and bi3.index <= self.bi_list[-1].index and bi.check_bi_zs_overlap() and bi.dir == Chan_BI_DIR.UP: + zg = min(bi.high, bi2.high, bi3.high) + zd = max(bi.low, bi2.low, bi3.low) + gg = max(bi.high, bi2.high, bi3.high) + dd = min(bi.low, bi2.low, bi3.low) + zs = ChanBIZS(bi, len(zs_list), Chan_ZS_DIR.DOWN) + zs.set_zg(zg) + zs.set_zd(zd) + zs.set_gg(gg) + zs.set_dd(dd) + zs.add_bi(bi2) + zs.add_bi(bi3) + zs_list.append(zs) + last_zs = zs + else: + last_zs.add_bi(bi.pre) + last_zs.add_bi(bi) + if index == len(self.bi_list) - 1 and last_zs and not last_zs.is_sure: + #print(bi.start_time, "BI", last_zs.is_sure) + last_zs.set_end_bi(last_zs.bi_list[-1], last_zs.bi_list[-1].sure_time) + + #print(self.start_time, len(zs_list)) + #print(self.bi_list[-1].end_time, "end_bi") + return zs_list \ No newline at end of file diff --git a/chanlun/core/ChanZS.py b/chanlun/core/ChanZS.py new file mode 100644 index 0000000..df24b1f --- /dev/null +++ b/chanlun/core/ChanZS.py @@ -0,0 +1,109 @@ +from typing import Dict, Optional + +import chanlun.core.ChanKLC as ChanKLC +import chanlun.core.ChanSEG as ChanSEG +import chanlun.core.ChanCTime as ChanCTime +from chanlun.core.ChanEnum import Chan_ZS_DIR +# 中枢 +class ChanZS(): + def __init__(self, start_seg: ChanSEG, index, ddir: Chan_ZS_DIR): + self.start_klc = start_seg.start_bi.start_klc + self.start_time = self.start_klc.start_time + self.end_time = None + self.index = index + self.next = None + self.pre = None + self.start_seg = start_seg + self.seg_list = [] + self.seg_list.append(start_seg) + self.end_seg = None + self.last_bi_in = None + self.bi_out = None + self.is_sure = False + self.zg = 0 + self.zd = 0 + self.gg = 0 + self.dd = 0 + self.dir = ddir + self.sure_time = None + self.end_klc = None + self.bi_out_count = 0 + self.bi_out_list = [] + self.bi_out_seg_list = [] + self.bi_out_seg = None + self.is_extended = False + def set_last_bi_in(self, last_bi_in): + self.last_bi_in = last_bi_in + def set_bi_out(self, bi_out, bi_out_seg): + if bi_out: + #print(bi_out.start_klc.start_time, bi_out.sure_time, bi_out.dir, bi_out_seg.dir, len(self.bi_out_list)) + if len(self.bi_out_list) > 0: + last_bi = self.bi_out_list[-1] + if last_bi.index != bi_out.index: + self.bi_out_list.append(bi_out) + self.bi_out_seg_list.append(bi_out_seg) + else: + self.bi_out_list.append(bi_out) + self.bi_out_seg_list.append(bi_out_seg) + self.bi_out = bi_out + self.bi_out_seg = bi_out_seg + def set_end_klc(self, end_klc, sure_time, bi_out_count, seg): + self.end_klc = end_klc + self.set_end_time(end_klc.end_time) + self.is_sure = True + self.sure_time = sure_time + self.bi_out_count = bi_out_count + self.end_seg = seg + def set_end_seg(self, end_seg): + self.end_seg = end_seg + def set_pre(self, pre): + self.pre = pre + def set_next(self, next): + self.next = next + def set_end_time(self, end_time): + self.end_time = end_time + def add_klc(self, klc): + self.klc_list.append(klc) + def add_seg(self, seg): + self.seg_list.append(seg) + self.end_time = seg.end_time + self.end_seg = seg + def set_zg(self, zg): + self.zg = zg + def set_zd(self, zd): + self.zd = zd + def set_gg(self, gg): + self.gg = gg + def set_dd(self, dd): + self.dd = dd + def extend_zs(self, seg_list): + self.is_sure = False + self.end_seg = None + self.end_klc = None + self.sure_time = None + for seg in seg_list: + if seg.end_bi.high > self.gg: + self.set_gg(seg.end_bi.high) + if seg.end_bi.low < self.dd: + self.set_dd(seg.end_bi.low) + self.seg_list.append(seg) + self.is_extended = True + #print(self.start_time, "extend zs", seg_list[-1].end_time) + +# 大级别中枢:由多个区间重叠(扩张)的笔/线段中枢合并而成,用于显示更大级别的震荡区间 +class ChanZS_Big(): + def __init__(self, zs_list): + assert len(zs_list) >= 1 + self.zs_list = list(zs_list) + first = self.zs_list[0] + last = self.zs_list[-1] + self.start_time = first.start_time + self.end_time = last.end_time if last.end_time else None + self.start_klc = first.start_klc + self.end_klc = last.end_klc + # 大级别区间取并集:包住所有子中枢 + self.zd = min(zs.zd for zs in self.zs_list) + self.zg = max(zs.zg for zs in self.zs_list) + self.dd = min(zs.dd for zs in self.zs_list) + self.gg = max(zs.gg for zs in self.zs_list) + self.index = 0 # 由外部设置 \ No newline at end of file diff --git a/chanlun/core/Chan_FX_Box.py b/chanlun/core/Chan_FX_Box.py new file mode 100644 index 0000000..8a9d9ec --- /dev/null +++ b/chanlun/core/Chan_FX_Box.py @@ -0,0 +1,7 @@ +class Chan_FX_Box(): + def __init__(self, start_time, end_time, high, low): + self.start_time = start_time + self.end_time = end_time + self.high = high + self.low = low + \ No newline at end of file diff --git a/chanlun/core/__init__.py b/chanlun/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chanlun/indicators/ChanMACD.py b/chanlun/indicators/ChanMACD.py new file mode 100644 index 0000000..9661148 --- /dev/null +++ b/chanlun/indicators/ChanMACD.py @@ -0,0 +1,274 @@ +from chanlun.core.ChanKLU import ChanKLU +from chanlun.core.ChanEnum import Chan_MACD_STATE, Chan_MACDSEG_DIR, Chan_MACDHISTSET_DIR, Chan_MACDUNITTF_DIR, Chan_MACDUNITTF_TYPE +from chanlun.indicators.ChanMACDSeg import ChanMACDSeg +from chanlun.indicators.ChanMACDUnitTF import ChanMACDUnitTF +from chanlun.indicators.ChanMACDHistSet import ChanMACDHistSet + +class ChanMACD(): + def __init__(self, klu_list: list[ChanKLU]): + self.klu_list = klu_list + self.seg_list = [] + self.unittf_list = [] + self.histset_list = [] + # 状态标记列表 + self.high_position_list = [] # 高位列表 + self.high_empty_list = [] # 高位空列表 + self.return_zero_list = [] # 归零轴列表 + self.cross0_up_list = [] # 向上穿越零轴列表 + self.cross0_down_list = [] # 向下穿越零轴列表 + # 计算段 / UnitTF / HistSet 及状态标记 + self.cal_macd_state() + self.get_klu_sd_list() + def get_klu_sd(self): + if self.klu_list: + sd = self.klu_list[-1].separate_div + if sd > 1: + print(self.klu_list[-1].time, sd) + return True + return False + def get_klu_sd_list(self): + sd_list = [] + if self.klu_list: + for klu in self.klu_list: + hist = klu.macdhist + signal = False + if klu.pre and klu.next: + if klu.signal > 0: + signal = klu.pre.signal > klu.signal and klu.next.signal < klu.signal + else: + signal = klu.pre.signal < klu.signal and klu.next.signal > klu.signal + sd = klu.separate_div + if sd > 1 and ((hist > 0 and hist < 200) or (hist < 0 and hist > -200)): + sd_list.append(klu.time) + #print(klu.time, sd) + return sd_list + def cal_macd_state(self): + last_seg = None + last_unittf = None + last_histset = None + last_klu = None + for klu in self.klu_list: + # initialise first histset + if klu.macd == 0 and klu.signal == 0 and klu.macdhist == 0: + continue + if last_histset is None: + if klu.macdhist > 0: + last_histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, None, Chan_MACDHISTSET_DIR.ABOVE) + self.histset_list.append(last_histset) + else: + last_histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, None, Chan_MACDHISTSET_DIR.UNDER) + self.histset_list.append(last_histset) + else: + # initialise first seg and unittf + if last_seg is None: + # create histset afterwards + if last_histset.histset_dir == Chan_MACDHISTSET_DIR.ABOVE: + if klu.macdhist > 0: + last_histset.add_klu(klu) + else: + histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.UNDER) + self.histset_list.append(histset) + last_histset.set_next(histset) + histset.set_pre(last_histset) + last_histset.set_end_klu(last_klu) + last_histset = histset + else: + if klu.macdhist < 0: + last_histset.add_klu(klu) + else: + histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.ABOVE) + self.histset_list.append(histset) + last_histset.set_next(histset) + histset.set_pre(last_histset) + last_histset.set_end_klu(last_klu) + last_histset = histset + if last_klu.signal >= 0 and klu.signal < 0: + last_unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, None, Chan_MACDUNITTF_DIR.UNDER, Chan_MACDUNITTF_TYPE.CROSS0, last_histset) + self.unittf_list.append(last_unittf) + last_seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, None, Chan_MACDSEG_DIR.UNDER, last_unittf) + self.seg_list.append(last_seg) + elif last_klu.signal <= 0 and klu.signal > 0: + last_unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, None, Chan_MACDUNITTF_DIR.ABOVE, Chan_MACDUNITTF_TYPE.CROSS0, last_histset) + self.unittf_list.append(last_unittf) + last_seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, None, Chan_MACDSEG_DIR.ABOVE, last_unittf) + self.seg_list.append(last_seg) + # after the first seg and unittf + else: + # create histset afterwards + if last_histset.histset_dir == Chan_MACDHISTSET_DIR.ABOVE: + if klu.macdhist > 0: + last_histset.add_klu(klu) + else: + histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.UNDER) + self.histset_list.append(histset) + last_histset.set_next(histset) + histset.set_pre(last_histset) + last_histset.set_end_klu(last_klu) + last_histset = histset + if last_unittf: + last_unittf.add_histset(last_histset) + else: + if klu.macdhist < 0: + last_histset.add_klu(klu) + else: + histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.ABOVE) + self.histset_list.append(histset) + last_histset.set_next(histset) + histset.set_pre(last_histset) + last_histset.set_end_klu(last_klu) + last_histset = histset + if last_unittf: + last_unittf.add_histset(last_histset) + if last_klu.signal >= 0 and klu.signal < 0: + last_unittf.set_end_klu(last_klu, Chan_MACDUNITTF_TYPE.CROSS0) + unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, Chan_MACDUNITTF_DIR.UNDER, Chan_MACDUNITTF_TYPE.CROSS0, last_histset) + self.unittf_list.append(unittf) + last_unittf.set_next(unittf) + last_seg.set_end_klu(last_klu) + seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, last_seg, Chan_MACDSEG_DIR.UNDER, unittf) + self.seg_list.append(seg) + last_seg.set_next(seg) + last_seg = seg + last_unittf = unittf + elif last_klu.signal <= 0 and klu.signal > 0: + last_unittf.set_end_klu(last_klu, Chan_MACDUNITTF_TYPE.CROSS0) + unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, Chan_MACDUNITTF_DIR.ABOVE, Chan_MACDUNITTF_TYPE.CROSS0, last_histset) + self.unittf_list.append(unittf) + last_unittf.set_next(unittf) + last_seg.set_end_klu(last_klu) + seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, last_seg, Chan_MACDSEG_DIR.ABOVE, unittf) + self.seg_list.append(seg) + last_seg.set_next(seg) + last_seg = seg + last_unittf = unittf + elif last_unittf.is_end and last_klu.macd < klu.macd and klu.macd > klu.signal: + unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, Chan_MACDUNITTF_DIR.ABOVE, Chan_MACDUNITTF_TYPE.NEAR0, last_histset) + self.unittf_list.append(unittf) + last_unittf.set_next(unittf) + last_seg.add_unittf(unittf) + last_unittf = unittf + last_seg.add_klu(klu) + else: + if not last_unittf.is_end: + last_unittf.add_klu(klu) + last_seg.add_klu(klu) + last_klu = klu + klu.cal_macd_state() + #print(klu.time, klu.macd_state, klu.continue_div, klu.separate_div, klu.macd, klu.signal, klu.macdhist, klu.ema24, klu.ema52, klu.close) + return self.klu_list + def cal_macd(self): + last_seg = None + last_unittf = None + last_histset = None + histset = None + last_klu = None + for klu in self.klu_list: + klu.cal_macd_state() + print(klu.time, klu.macd_state) + # 1) 只有当 MACD 已可用(非 UNKNOWN)时,才开始初始化段/单元 + if last_seg is None: + if klu.macd_state != Chan_MACD_STATE.UNKNOWN: + # 初始化首个直方图集合(根据当前柱体正负) + if klu.macdhist >= 0: + histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, None, Chan_MACDHISTSET_DIR.ABOVE) + else: + histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, None, Chan_MACDHISTSET_DIR.UNDER) + self.histset_list.append(histset) + last_histset = histset + + # 初始化首段 + seg_dir = Chan_MACDSEG_DIR.ABOVE if klu.signal >= 0 else Chan_MACDSEG_DIR.UNDER + seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, None, seg_dir, last_unittf) + self.seg_list.append(seg) + last_seg = seg + + # 初始化首个UnitTF + unittf_dir = Chan_MACDUNITTF_DIR.ABOVE if klu.signal >= 0 else Chan_MACDUNITTF_DIR.UNDER + unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, None, unittf_dir, Chan_MACDUNITTF_TYPE.START, histset) + self.unittf_list.append(unittf) + last_unittf = unittf + last_seg.add_unittf(unittf) + # 未就绪则继续等下一根;已就绪亦已完成首个结构初始化,继续下一根 + last_klu = klu + continue + # 3) 直方图集合(基于当前 unittf) + if klu.macdhist >= 0: + if last_histset and last_histset.histset_dir == Chan_MACDHISTSET_DIR.ABOVE: + last_histset.add_klu(klu) + else: + # 结束旧 histset(以前一根结束更合理) + if last_histset and last_klu: + last_histset.set_end_klu(last_klu) + histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.ABOVE if klu.macdhist >= 0 else Chan_MACDHISTSET_DIR.UNDER) + self.histset_list.append(histset) + if last_histset: + last_histset.set_next(histset) + last_histset = histset + if last_unittf: + last_unittf.add_histset(histset) + else: + if last_histset and last_histset.histset_dir == Chan_MACDHISTSET_DIR.UNDER: + last_histset.add_klu(klu) + else: + # 结束旧 histset(以前一根结束更合理) + if last_histset and last_klu: + last_histset.set_end_klu(last_klu) + histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.ABOVE if klu.macdhist >= 0 else Chan_MACDHISTSET_DIR.UNDER) + self.histset_list.append(histset) + if last_histset: + last_histset.set_next(histset) + last_histset = histset + if last_unittf: + last_unittf.add_histset(histset) + # 2) 过零切段(使用KLU中的穿越状态) + if (klu.macd_state == Chan_MACD_STATE.CROSS0_UP or + klu.macd_state == Chan_MACD_STATE.CROSS0_DOWN): + # 结束旧 unittf + last_unittf.set_end_klu(last_klu, Chan_MACDUNITTF_TYPE.CROSS0) + # 新的单位时间周期 + new_dir = Chan_MACDUNITTF_DIR.ABOVE if klu.signal >= 0 else Chan_MACDUNITTF_DIR.UNDER + unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, new_dir, Chan_MACDUNITTF_TYPE.CROSS0, histset) + self.unittf_list.append(unittf) + last_unittf.set_next(unittf) + last_unittf = unittf + # 收尾旧段 + last_seg.set_end_klu(last_klu) + # 新段方向取反 + new_dir = Chan_MACDSEG_DIR.UNDER if last_seg.seg_dir == Chan_MACDSEG_DIR.ABOVE else Chan_MACDSEG_DIR.ABOVE + seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, last_seg, new_dir, last_unittf) + self.seg_list.append(seg) + last_seg.set_next(seg) + last_seg = seg + last_seg.add_unittf(unittf) + else: + # 4) UnitTF 状态机:用黄线Signal的归零轴 + if last_klu.macd_state == Chan_MACD_STATE.NEAR0 and last_unittf.div_count > 1: + #print(klu.time, klu.macd_state) + if klu.macd_state == Chan_MACD_STATE.RZ_UP: + last_unittf.set_end_klu(last_klu, Chan_MACDUNITTF_TYPE.NEAR0) + new_dir = Chan_MACDUNITTF_DIR.ABOVE if klu.signal >= 0 else Chan_MACDUNITTF_DIR.UNDER + unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, new_dir, Chan_MACDUNITTF_TYPE.NEAR0, histset) + self.unittf_list.append(unittf) + last_unittf.set_next(unittf) + last_unittf = unittf + last_seg.add_unittf(unittf) + elif klu.macd_state == Chan_MACD_STATE.RZ_DOWN: + last_unittf.set_end_klu(last_klu, Chan_MACDUNITTF_TYPE.NEAR0) + new_dir = Chan_MACDUNITTF_DIR.ABOVE if klu.signal >= 0 else Chan_MACDUNITTF_DIR.UNDER + unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, new_dir, Chan_MACDUNITTF_TYPE.NEAR0, histset) + self.unittf_list.append(unittf) + last_unittf.set_next(unittf) + last_unittf = unittf + last_seg.add_unittf(unittf) + else: + last_unittf.add_klu(klu) + last_seg.add_klu(klu) + else: + last_unittf.add_klu(klu) + last_seg.add_klu(klu) + + last_klu = klu + last_histset.set_end_klu(last_klu) + last_unittf.set_end_klu(last_klu, None) + last_seg.set_end_klu(last_klu) + return self.klu_list \ No newline at end of file diff --git a/chanlun/indicators/ChanMACDHistSet.py b/chanlun/indicators/ChanMACDHistSet.py new file mode 100644 index 0000000..7af4b3b --- /dev/null +++ b/chanlun/indicators/ChanMACDHistSet.py @@ -0,0 +1,117 @@ +from chanlun.core.ChanEnum import Chan_MACDHISTSET_DIR, Chan_MACDUNITTF_DIV, Chan_MACD_STATE + +class ChanMACDHistSet(): + def __init__(self, index, start_time, start_klu, pre_histset, dir): + self.index = index + self.start_time = start_time + self.end_time = None + self.klu_list = [] + self.klu_list.append(start_klu) + self.histset_dir = dir + self.next = None + self.pre = pre_histset + self.peak_klu = None + self.area = start_klu.macdhist + self.unittf_div = Chan_MACDUNITTF_DIV.UNDIV + self.middle_klu = None + self.div_count = 0 + self.last_klu = start_klu + self.start_klu = start_klu + self.peak_div_list = [] + self.middle_area = 0 + self.total_macdhist = 0 + def set_next(self, next_histset): + self.next = next_histset + def set_pre(self, pre_histset): + self.pre = pre_histset + def set_middle_klu(self, middle_klu): + self.middle_klu = middle_klu + #self.middle_area = abs(middle_klu.macdhist) + #self.middle_klu = None + def set_unittf_div(self, unittf_div): + self.unittf_div = unittf_div + def add_klu(self, klu): + klu.set_histset(self) + self.klu_list.append(klu) + self.area += abs(klu.macdhist) + if self.middle_klu: + self.middle_area += abs(klu.macdhist) + if self.middle_klu and self.middle_klu.index + 1 == klu.index: + self.low_klu = None + self.peak_klu = None + self.div_count = 0 + self.peak_div_list = [] + else: + if self.last_klu: + self.cal_macdhist_klu(klu) + self.last_klu = klu + def cal_macdhist_klu(self, klu): + if self.middle_klu: + if klu.index >= self.middle_klu.index + 2: + if klu.pre.pre: + if abs(klu.pre.macdhist) > abs(klu.pre.pre.macdhist) and abs(klu.pre.macdhist) > abs(klu.macdhist): + if self.peak_klu: + if abs(klu.pre.macdhist) > abs(self.peak_klu.macdhist): + self.peak_klu = klu.pre + #self.div_count = 0 + #self.peak_div_list = [] + else: + if klu.pre.macd * klu.pre.macdhist > 0: + self.peak_div_list.append(klu.pre) + self.div_count += 1 + klu.pre.continue_div = True + else: + self.peak_klu = klu.pre + else: + if len(self.klu_list) >= 3: + if klu.pre.pre: + if abs(klu.pre.macdhist) > abs(klu.pre.pre.macdhist) and abs(klu.pre.macdhist) > abs(klu.macdhist): + if self.peak_klu: + if abs(klu.pre.macdhist) > abs(self.peak_klu.macdhist): + self.peak_klu = klu.pre + #self.div_count = 0 + #self.peak_div_list = [] + else: + if klu.pre.macd * klu.pre.macdhist > 0 and klu.pre.signal * klu.pre.macdhist > 0: + self.peak_div_list.append(klu.pre) + self.div_count += 1 + klu.pre.continue_div = True + else: + self.peak_klu = klu.pre + def set_end_klu(self, end_klu): + self.end_klu = end_klu + self.end_time = end_klu.time + #if len(self.peak_div_list) > 0: + #klu = self.peak_div_list[-1] + #if klu.macd * klu.macdhist > 0: + #end_klu.continue_div = True + #print(end_klu.time, "Continue Div") + if self.start_klu.index == end_klu.index: + self.peak_klu = self.start_klu + if self.start_klu.index + 1 == end_klu.index: + if abs(self.start_klu.macdhist) > abs(end_klu.macdhist): + self.peak_klu = self.start_klu + else: + self.peak_klu = end_klu + if len(self.klu_list) >= 3 and self.peak_klu == None: + self.peak_klu = self.klu_list[0] + for klu in self.klu_list: + if abs(klu.macdhist) > abs(self.peak_klu.macdhist): + self.peak_klu = klu + peak_str = "" + state_str = "" + for peak_div in self.peak_div_list: + peak_str += f"{peak_div.time}, " + state_str += f"{peak_div.macd_state}, " + total_macdhist = 0 + first_klu = self.klu_list[0] + last_klu = self.klu_list[-1] + if (first_klu.macd > 0 and last_klu.macd > 0 and first_klu.macdhist > 0) or (first_klu.macd < 0 and last_klu.macd < 0 and first_klu.macdhist < 0): + for klu in self.klu_list: + self.total_macdhist += klu.macdhist + if abs(self.total_macdhist) < 150: + #print(self.end_time, "Total MACDHist: ", self.total_macdhist) + last_klu.separate_div = 99999 + #if self.peak_klu and len(self.peak_div_list) > 0: + #print("Continue Div: ",self.start_time, "Peak:", self.peak_klu.time, "Div: ", peak_str, state_str) + \ No newline at end of file diff --git a/chanlun/indicators/ChanMACDSeg.py b/chanlun/indicators/ChanMACDSeg.py new file mode 100644 index 0000000..5d1bd4e --- /dev/null +++ b/chanlun/indicators/ChanMACDSeg.py @@ -0,0 +1,49 @@ +from chanlun.core.ChanEnum import Chan_MACDSEG_DIR + + +class ChanMACDSeg(): + def __init__(self, index, start_time, start_klu, pre_seg, seg_dir, start_unittf): + self.index = index + self.start_time = start_time + self.end_time = None + self.start_klu = start_klu + self.end_klu = None + self.klu_list = [] + self.klu_list.append(start_klu) + self.unittf_list = [] + self.seg_dir = seg_dir + self.pre = pre_seg + self.next = None + self.high_klu = start_klu + self.low_klu = start_klu + self.ref_klu = None + self.unittf_list.append(start_unittf) + def set_next(self, next_seg): + self.next = next_seg + def set_pre(self, pre_seg): + self.pre = pre_seg + def add_klu(self, klu): + if klu: + self.klu_list.append(klu) + klu.set_seg(self) + if self.seg_dir == Chan_MACDSEG_DIR.ABOVE: + if klu.macdhist > self.high_klu.macdhist: + self.high_klu = klu + else: + if klu.macdhist < self.low_klu.macdhist: + self.low_klu = klu + else: + if klu.macdhist < self.high_klu.macdhist: + self.high_klu = klu + else: + if klu.macdhist > self.low_klu.macdhist: + self.low_klu = klu + if self.high_klu.index != self.start_klu.index: + self.ref_klu = self.high_klu + def add_unittf(self, unittf): + self.unittf_list.append(unittf) + unittf.set_next(self) + def set_end_klu(self, end_klu): + self.add_klu(end_klu) + self.end_klu = end_klu + self.end_time = end_klu.time \ No newline at end of file diff --git a/chanlun/indicators/ChanMACDUnitTF.py b/chanlun/indicators/ChanMACDUnitTF.py new file mode 100644 index 0000000..5cbe542 --- /dev/null +++ b/chanlun/indicators/ChanMACDUnitTF.py @@ -0,0 +1,141 @@ +from chanlun.core.ChanEnum import Chan_MACD_STATE, Chan_MACDUNITTF_DIR, Chan_MACDHISTSET_DIR, Chan_MACDUNITTF_DIV, Chan_MACDUNITTF_TYPE + + +class ChanMACDUnitTF(): + def __init__(self, index, start_time, start_klu, pre_unittf, unittf_dir, start_type, start_histset): + self.index = index + self.start_time = start_time + self.end_time = None + self.start_klu = start_klu + self.end_klu = None + self.klu_list = [] + self.klu_list.append(start_klu) + self.histset_list = [] + self.histset_list.append(start_histset) + self.div_count = 0 + start_histset.set_middle_klu(start_klu) + self.next = None + self.pre = pre_unittf + self.unittf_dir = unittf_dir + self.start_type = start_type + self.end_type = None + self.peak_klu = None + self.div_type = Chan_MACDUNITTF_DIV.UNDIV + self.div_peak_list = [] + self.is_end = False + def set_next(self, next_unittf): + self.next = next_unittf + def set_pre(self, pre_unittf): + self.pre = pre_unittf + def add_histset(self, histset): + self.histset_list.append(histset) + def add_klu(self, klu): + self.klu_list.append(klu) + self.cal_peak_div() + self.cal_macd_state() + def cal_peak_div(self): + self.div_count = 0 + self.div_peak_list = [] + self.peak_klu = None + if len(self.histset_list) == 0: + return + if len(self.histset_list) == 1: + self.div_type = self.histset_list[0].unittf_div + self.peak_klu = self.histset_list[0].peak_klu + else: + + for index in range(0, len(self.histset_list)): + histset = self.histset_list[index] + if self.same_dir(histset): + #if histset.peak_klu: + #print("Unittf: ", self.start_klu.time, len(self.histset_list), histset.peak_klu.time) + if self.peak_klu: + if histset.peak_klu: + if abs(histset.peak_klu.macdhist) >= abs(self.peak_klu.macdhist): + self.peak_klu = histset.peak_klu + self.div_type = Chan_MACDUNITTF_DIV.UNDIV + self.div_count = 0 + else: + self.div_type = Chan_MACDUNITTF_DIV.DISCRETE + self.div_count += 1 + self.div_peak_list.append(histset.peak_klu) + #print("Unittf: ", self.start_klu.time) + if histset.peak_klu.macd > 0 and histset.histset_dir == Chan_MACDHISTSET_DIR.ABOVE: + histset.peak_klu.set_separate_div(self.div_count) + elif histset.peak_klu.macd < 0 and histset.histset_dir == Chan_MACDHISTSET_DIR.UNDER: + histset.peak_klu.set_separate_div(self.div_count) + else: + if histset.peak_klu: + self.peak_klu = histset.peak_klu + def cal_macd_state(self): + if len(self.klu_list) > 0: + last_klu = self.klu_list[0] + macd_peak_klu = None + signal_peak_klu = None + for index in range(1, len(self.klu_list)): + klu = self.klu_list[index] + if klu.macd == 0 and klu.signal == 0 and klu.macdhist == 0: + continue + if self.start_type == Chan_MACDUNITTF_TYPE.CROSS0 or self.start_type == Chan_MACDUNITTF_TYPE.NEAR0: + if self.unittf_dir == Chan_MACDUNITTF_DIR.ABOVE: + if macd_peak_klu is None: + if last_klu.macd < klu.macd: + if last_klu.signal < last_klu.macdhist: + last_klu.set_macd_state(Chan_MACD_STATE.UP) + else: + last_klu.set_macd_state(Chan_MACD_STATE.HIGH) + else: + macd_peak_klu = last_klu + last_klu.set_macd_state(Chan_MACD_STATE.PEAK) + elif klu.macd > macd_peak_klu.macd: + macd_peak_klu = None + last_klu.set_macd_state(Chan_MACD_STATE.HIGH) + elif last_klu.signal < klu.signal: + last_klu.set_macd_state(Chan_MACD_STATE.HIGH_EMPTY) + elif signal_peak_klu is None: + signal_peak_klu = last_klu + last_klu.set_macd_state(Chan_MACD_STATE.HIGH_EMPTY) + elif klu.signal > signal_peak_klu.signal: + signal_peak_klu = None + last_klu.set_macd_state(Chan_MACD_STATE.HIGH) + elif last_klu.macd < last_klu.signal: + last_klu.set_macd_state(Chan_MACD_STATE.RETURN_ZERO) + if self.return_zero(last_klu, klu): + self.end_type = Chan_MACDUNITTF_TYPE.NEAR0 + self.is_end = True + self.end_klu = klu + self.end_time = klu.time + klu.set_macd_state(Chan_MACD_STATE.NEAR0) + #print(self.index, last_klu.time, last_klu.macd, last_klu.signal, last_klu.macd_state, klu.macd_state) + break + #print(self.index, last_klu.time, last_klu.macd, last_klu.signal, last_klu.macd_state) + last_klu = klu + + def return_zero(self, last_klu, klu): + return_zero = False + if last_klu.close < last_klu.ema52 and klu.close > klu.ema52: + return_zero = True + return_zero = False + return return_zero + def same_dir(self, histset): + if self.unittf_dir == Chan_MACDUNITTF_DIR.ABOVE: + return histset.histset_dir == Chan_MACDHISTSET_DIR.ABOVE + else: + return histset.histset_dir == Chan_MACDHISTSET_DIR.UNDER + def set_end_klu(self, end_klu, end_type): + self.end_type = end_type + self.end_klu = end_klu + self.end_time = end_klu.time + self.is_end = True + self.cal_macd_state() + div_time = "" + for div in self.div_peak_list: + div_time += f"{div.time}, " + histset_time = "" + for histset in self.histset_list: + histset_time += f"{histset.start_time}, " + + #if self.peak_klu and self.div_type == Chan_MACDUNITTF_DIV.DISCRETE: + #print("Cross Div: ", self.start_klu.time, self.peak_klu.time, self.div_count, self.div_type, self.unittf_dir, div_time, len(self.histset_list)) + + \ No newline at end of file diff --git a/chanlun/indicators/__init__.py b/chanlun/indicators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chanlun/pipeline/__init__.py b/chanlun/pipeline/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chanlun/pipeline/builders/__init__.py b/chanlun/pipeline/builders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chanlun/pipeline/builders/bi.py b/chanlun/pipeline/builders/bi.py new file mode 100644 index 0000000..3ee9b99 --- /dev/null +++ b/chanlun/pipeline/builders/bi.py @@ -0,0 +1,682 @@ +"""TF_DF builder mixin — 由 split_tfdf_builders 自动生成,逻辑与原 TF_DF 一致。""" +from __future__ import annotations + +from datetime import timedelta +from decimal import Decimal + +import numpy as np +import pandas as pd +import talib.abstract as ta +from pandas import DataFrame +from technical.util import resample_to_interval + +from chanlun.core.ChanBI import ChanBI +from chanlun.core.ChanBIZS import ChanBIZS +from chanlun.core.ChanBSP import ChanBSP +from chanlun.core.ChanEnum import ( + Chan_BI_DIR, + Chan_BSP_DIR, + Chan_BSP_TYPE, + Chan_FX_TYPE, + Chan_K_DIR, + Chan_KLC_FX, + Chan_KLC_STATE, + Chan_KLINE_DIR, + Chan_KLU_PATTERN, + Chan_PRICE_TREND, + Chan_SEG_DIR, + Chan_ZS_DIR, +) +from chanlun.core.ChanKLC import ChanKLC +from chanlun.core.ChanKLU import ChanKLU +from chanlun.core.ChanSBI import ChanSBI +from chanlun.core.ChanSEG import ChanSEG +from chanlun.core.ChanZS import ChanZS, ChanZS_Big +from chanlun.indicators.ChanMACD import ChanMACD + + +class BiBuilderMixin: + def cal_trend(self, klc_list): + """ + 基于价格与EMA24/EMA52的位置关系、以及MACD/Signal/Hist的方向, + 为每个KLC打上趋势标签:'UP' / 'DOWN' / 'FLAT'。 + 仅设置 klc.trend,不影响其它字段。 + """ + if not klc_list: + return klc_list + last_trend = Chan_PRICE_TREND.UNKNOWN + # 趋势延续性:参考近 N 根已完成的KLC + lookback_n = 5 + prev_klcs = [] + for klc in klc_list: + price = getattr(klc, 'close', None) + ema24 = getattr(klc, 'ema24', None) + ema52 = getattr(klc, 'ema52', None) + macd_raw = getattr(klc, 'macd', None) + signal_raw = getattr(klc, 'signal', None) + hist_raw = getattr(klc, 'macdhist', None) + macd = macd_raw if macd_raw is not None else 0 + signal = signal_raw if signal_raw is not None else 0 + hist = hist_raw if hist_raw is not None else 0 + rsi = getattr(klc, 'rsi', None) + macd_ready = macd_raw is not None and signal_raw is not None + hist_ready = hist_raw is not None + trend = Chan_PRICE_TREND.UNKNOWN + score = 0 + try: + # 有效性 + price_valid = price is not None and price != 0 + ema24_valid = ema24 is not None and ema24 != 0 + ema52_valid = ema52 is not None and ema52 != 0 + # 多因子投票 + + # 1) 均线结构 + 价位 + if ema24_valid or ema52_valid: + ma_votes = 0 + if ema24_valid and ema52_valid: + ma_votes += 1 if ema24 > ema52 else -1 + if price_valid and ema24_valid: + ma_votes += 1 if price > ema24 else 0 + if price_valid and ema52_valid: + ma_votes += 1 if price > ema52 else -1 + # 限幅,避免相关因子重复计分 + score += max(-2, min(2, ma_votes)) + # 2) MACD结构 + if macd_ready: + score += 1 if macd >= signal else -1 + if hist_ready and hist != 0: + score += 1 if hist > 0 else -1 + # 3) 动量与均线差分斜率 + pre = getattr(klc, 'pre', None) + pre_hist = getattr(pre, 'macdhist', None) if pre else None + if pre: + pre_close = getattr(pre, 'close', None) + if price_valid and pre_close is not None: + score += 1 if price >= pre_close else -1 + pre_ema24 = getattr(pre, 'ema24', None) + pre_ema52 = getattr(pre, 'ema52', None) + if ema24_valid and ema52_valid and pre_ema24 not in (None, 0) and pre_ema52 not in (None, 0): + spread_now = ema24 - ema52 + spread_pre = pre_ema24 - pre_ema52 + score += 1 if spread_now >= spread_pre else -1 + # 3.1) MACD柱体动量趋势:考虑 macdhist 的斜率与过零 + if hist_ready and pre_hist is not None: + # 柱体斜率:上升加分,下降减分 + if hist > pre_hist: + score += 1 + elif hist < pre_hist: + score -= 1 + # 过零加权:负转正更偏多,正转负更偏空 + if pre_hist < 0 and hist > 0: + score += 1 + elif pre_hist > 0 and hist < 0: + score -= 1 + # 3.2) EMA52 突破/跌破加权 + if ema52_valid and price_valid and pre_close is not None and pre_ema52 not in (None, 0): + # 看多突破:从均线下方上破且动量配合 + if pre_close <= pre_ema52 and price > ema52 and (hist is None or pre_hist is None or hist >= pre_hist): + score += 1 + # 看空跌破:从均线上方下破且动量配合 + if pre_close >= pre_ema52 and price < ema52 and (hist is None or pre_hist is None or hist <= pre_hist): + score -= 1 + # 3.3) EMA52 支撑/阻力触碰(非强穿越) + if ema52_valid and price_valid: + low_v = getattr(klc, 'low', None) + high_v = getattr(klc, 'high', None) + if low_v is not None and high_v is not None and ema52 not in (None, 0): + # 触碰容差(相对EMA52的0.15%) + touch_tol = 0.0015 + # 作为支撑:收盘在上,最低靠近EMA52 + near_support_touch = (price > ema52) and (abs(low_v - ema52) / abs(ema52) <= touch_tol) + # 作为阻力:收盘在下,最高靠近EMA52 + near_resistance_touch = (price < ema52) and (abs(high_v - ema52) / abs(ema52) <= touch_tol) + if near_support_touch: + # 若动量不弱,则更偏多 + score += 1 if (hist is None or pre_hist is None or hist >= pre_hist) else 0 + if near_resistance_touch: + # 若动量不强,则更偏空 + score -= 1 if (hist is None or pre_hist is None or hist <= pre_hist) else 0 + # 3.4) 多次对 EMA52 的"拒绝"配合 MACD 逆向:易形成压/支并反向 + # 统计近窗口内的上/下拒绝次数: + # - 上拒绝:价格位于 EMA52 下方,最高触及/越过 EMA52 但收盘仍在下方 + # - 下拒绝:价格位于 EMA52 上方,最低触及/跌破 EMA52 但收盘仍在上方 + recent_up_rejects = 0 + recent_down_rejects = 0 + if ema52_valid: + window_rej = prev_klcs[-lookback_n:] if len(prev_klcs) > 0 else [] + rej_tol = 0.0015 + for wk in window_rej: + wk_close = getattr(wk, 'close', None) + wk_ema52 = getattr(wk, 'ema52', None) + wk_high = getattr(wk, 'high', None) + wk_low = getattr(wk, 'low', None) + if wk_close is None or wk_ema52 in (None, 0): + continue + # 上拒绝(阻力):下方多次试图上破但未站上 + if wk_close < wk_ema52 and wk_high is not None: + if wk_high >= wk_ema52 or abs(wk_high - wk_ema52) / abs(wk_ema52) <= rej_tol: + recent_up_rejects += 1 + # 下拒绝(支撑):上方多次试图下破但未跌破 + if wk_close > wk_ema52 and wk_low is not None: + if wk_low <= wk_ema52 or abs(wk_low - wk_ema52) / abs(wk_ema52) <= rej_tol: + recent_down_rejects += 1 + # 定义 MACD 的方向偏好 + macd_bias_up = macd_ready and (macd >= signal) and (not hist_ready or pre_hist is None or hist >= pre_hist) + macd_bias_down = macd_ready and (macd <= signal) and (not hist_ready or pre_hist is None or hist <= pre_hist) + # 若多次上拒绝且 MACD 偏空,则更偏向下行;若多次下拒绝且 MACD 偏多,则更偏向上行 + if recent_up_rejects >= 2 and macd_bias_down: + score -= 2 + if recent_down_rejects >= 2 and macd_bias_up: + score += 2 + # 4) RSI 辅助 + if rsi is not None: + if rsi >= 55: + score += 1 + elif rsi <= 45: + score -= 1 + # 5) 指标未就绪回退(EMA/MACD缺失时,用动量与RSI辅助,延续趋势) + has_full_ind = ema24_valid and ema52_valid and not (macd == 0 and signal == 0 and hist == 0) + if not has_full_ind: + # 仅根据价动量/RSI做轻量判断,默认延续 last_trend,除非出现强反向 + strong_up = False + strong_down = False + pre = getattr(klc, 'pre', None) + if pre: + pre_close = getattr(pre, 'close', None) + if price_valid and pre_close is not None: + strong_up = (price >= pre_close) + strong_down = (price < pre_close) + if rsi is not None: + if rsi >= 60: + strong_up = True + elif rsi <= 40: + strong_down = True + if last_trend == Chan_PRICE_TREND.UP and not strong_down: + trend = Chan_PRICE_TREND.UP + elif last_trend == Chan_PRICE_TREND.DOWN and not strong_up: + trend = Chan_PRICE_TREND.DOWN + else: + trend = Chan_PRICE_TREND.UP if strong_up and not strong_down else (Chan_PRICE_TREND.DOWN if strong_down and not strong_up else Chan_PRICE_TREND.FLAT) + else: + # 6) 震荡过滤(仅当极近EMA52且MACD贴合时判作震荡) + near_flat = False + if price_valid and ema52_valid: + near_ema52 = abs(price - ema52) / abs(ema52) <= 0.0005 # 0.05% + if macd_ready: + macd_scale = max(abs(macd), abs(signal), 1e-6) + near_macd = abs(macd - signal) / macd_scale <= 0.05 + else: + near_macd = False + near_flat = near_ema52 and near_macd + # 7) 动态阈值 + 趋势记忆(更强粘滞:趋势中容忍小幅反分) + # 引入过去 N 根KLC 的趋势延续性来动态调整翻转阈值,并结合 EMA52 支撑/阻力触碰强化门槛 + force_flip_down = False + force_flip_up = False + if near_flat: + trend = Chan_PRICE_TREND.FLAT + else: + # 计算过去窗口的趋势一致性 + window = prev_klcs[-lookback_n:] if len(prev_klcs) > 0 else [] + persist_up = 0 + persist_down = 0 + for wk in window: + if getattr(wk, 'trend', None) == Chan_PRICE_TREND.UP: + persist_up += 1 + elif getattr(wk, 'trend', None) == Chan_PRICE_TREND.DOWN: + persist_down += 1 + persist_ratio_up = (persist_up / len(window)) if len(window) > 0 else 0 + persist_ratio_down = (persist_down / len(window)) if len(window) > 0 else 0 + # 基准阈值 + down_flip_threshold = -2 + up_flip_threshold = 2 + # 若最近多为UP,则从UP翻转需更强反向信号;同理对DOWN + if last_trend == Chan_PRICE_TREND.UP and persist_ratio_up >= 0.6: + down_flip_threshold = -3 + elif last_trend == Chan_PRICE_TREND.DOWN and persist_ratio_down >= 0.6: + up_flip_threshold = 3 + # EMA52 触碰强化门槛:UP时若出现支撑触碰,下翻更难;DOWN时若出现阻力触碰,上翻更难 + if ema52_valid and price_valid: + low_v = getattr(klc, 'low', None) + high_v = getattr(klc, 'high', None) + if low_v is not None and high_v is not None and ema52 not in (None, 0): + touch_tol = 0.0015 + near_support_touch = (price > ema52) and (abs(low_v - ema52) / abs(ema52) <= touch_tol) + near_resistance_touch = (price < ema52) and (abs(high_v - ema52) / abs(ema52) <= touch_tol) + if last_trend == Chan_PRICE_TREND.UP and near_support_touch: + # 强化维持UP:进一步降低向下翻转阈值 + down_flip_threshold = min(down_flip_threshold - 1, -3) + if last_trend == Chan_PRICE_TREND.DOWN and near_resistance_touch: + # 强化维持DOWN:进一步提高向上翻转阈值 + up_flip_threshold = max(up_flip_threshold + 1, 3) + # 7.1) 复合拐头信号:MACD/Signal 同向拐头 + hist 连续减弱 + 多次未能越过 EMA52 + pre_macd = getattr(pre, 'macd', None) if pre else None + pre_signal = getattr(pre, 'signal', None) if pre else None + macd_slope = (macd - pre_macd) if (macd_ready and pre_macd is not None) else 0 + signal_slope = (signal - pre_signal) if (macd_ready and pre_signal is not None) else 0 + # hist 连续减弱(绝对值缩小) + hist_seq = [] + for wk in prev_klcs[-2:]: + val = getattr(wk, 'macdhist', None) + if val is not None: + hist_seq.append(val) + if hist is not None: + hist_seq.append(hist) + weaken_steps = 0 + for i in range(1, len(hist_seq)): + if abs(hist_seq[i]) < abs(hist_seq[i-1]): + weaken_steps += 1 + # 近窗口对 EMA52 的"未能站上/跌破"统计(放宽窗口与条件) + window_ema = prev_klcs[-4:] if len(prev_klcs) > 0 else [] + no_up_break = False + no_down_break = False + if ema52_valid: + # 未能有效上破:最近若干根收盘大多数不在 EMA52 上方,且高点多次触及/接近 + cnt_touch_up = 0 + cnt_close_above = 0 + for wk in window_ema: + wk_close = getattr(wk, 'close', None) + wk_high = getattr(wk, 'high', None) + wk_ema = getattr(wk, 'ema52', None) + if wk_close is not None and wk_ema not in (None, 0): + if wk_close > wk_ema: + cnt_close_above += 1 + if wk_high is not None and (wk_high >= wk_ema or abs(wk_high - wk_ema) / abs(wk_ema) <= 0.0015): + cnt_touch_up += 1 + no_up_break = (cnt_close_above <= 1 and cnt_touch_up >= 1 and price <= ema52) + # 未能有效下破:最近若干根收盘大多数不在 EMA52 下方,且低点多次触及/接近 + cnt_touch_down = 0 + cnt_close_below = 0 + for wk in window_ema: + wk_close = getattr(wk, 'close', None) + wk_low = getattr(wk, 'low', None) + wk_ema = getattr(wk, 'ema52', None) + if wk_close is not None and wk_ema not in (None, 0): + if wk_close < wk_ema: + cnt_close_below += 1 + if wk_low is not None and (wk_low <= wk_ema or abs(wk_low - wk_ema) / abs(wk_ema) <= 0.0015): + cnt_touch_down += 1 + no_down_break = (cnt_close_below <= 1 and cnt_touch_down >= 1 and price >= ema52) + # 若当前为UP趋势,出现明显拐头+hist减弱+未能上破EMA52,则加速看空 + if last_trend == Chan_PRICE_TREND.UP and macd_slope < 0 and signal_slope < 0 and weaken_steps >= 1 and no_up_break and macd_bias_down: + score -= 3 + down_flip_threshold = max(down_flip_threshold, 0) + force_flip_down = True + # 若当前为DOWN趋势,出现明显拐头+hist减弱+未能下破EMA52,则加速看多 + if last_trend == Chan_PRICE_TREND.DOWN and macd_slope > 0 and signal_slope > 0 and weaken_steps >= 1 and no_down_break and macd_bias_up: + score += 3 + up_flip_threshold = min(up_flip_threshold, 0) + force_flip_up = True + # 多次对 EMA52 的拒绝配合 MACD 逆向:加速反向翻转(降低相反方向阈值) + if recent_up_rejects >= 2 and macd_bias_down: + # 从 UP 向 DOWN 的翻转更容易 + down_flip_threshold = max(down_flip_threshold, -1) + if recent_down_rejects >= 2 and macd_bias_up: + # 从 DOWN 向 UP 的翻转更容易 + up_flip_threshold = min(up_flip_threshold, 1) + if force_flip_down: + trend = Chan_PRICE_TREND.DOWN + elif force_flip_up: + trend = Chan_PRICE_TREND.UP + elif last_trend == Chan_PRICE_TREND.UP: + if score <= down_flip_threshold: + trend = Chan_PRICE_TREND.DOWN + else: + trend = Chan_PRICE_TREND.UP + elif last_trend == Chan_PRICE_TREND.DOWN: + if score >= up_flip_threshold: + trend = Chan_PRICE_TREND.UP + else: + trend = Chan_PRICE_TREND.DOWN + else: + # 初始无记忆时,降低进入门槛 + if score >= 1: + trend = Chan_PRICE_TREND.UP + elif score <= -1: + trend = Chan_PRICE_TREND.DOWN + else: + trend = Chan_PRICE_TREND.FLAT + except Exception: + trend = Chan_PRICE_TREND.UNKNOWN + # 写回趋势 + if klc.end_time is None: + trend = Chan_PRICE_TREND.FLAT + if hasattr(klc, 'set_trend'): + klc.set_trend(trend) + else: + setattr(klc, 'trend', trend) + last_trend = trend + # 更新滑窗:仅向后看 + prev_klcs.append(klc) + price_diff = klc.close - klc.pre.close if klc.pre else 0 + #if klc.index > len(klc_list) - 10: + #print(klc.start_time, klc.end_time, klc.close, klc.ema24, klc.ema52, klc.macd, klc.signal, klc.macdhist, klc.trend, price_diff, score) + #print(klc.start_time, klc.end_time, klc.trend, price_diff, score) + return klc_list + + def get_bi_list(self, dataframe): + bi_list = self.cal_bi_list(self.get_klc_list(dataframe)) + #bi_list = self.cal_bi_list_chanlun(self.get_klc_list(dataframe)) + return bi_list + + def cal_bi_list(self, klc_list): + bi_list = [] + last_top = None + last_bottom = None + bi_klc_min = 4 + last_fx_klc = None + for klc in klc_list: + if last_fx_klc: + klc.check_klc_state(last_fx_klc) + klc.check_fx_confirmed(last_top, last_bottom) + fx = self.check_fx(klc) + if fx == Chan_FX_TYPE.TOP: + if last_bottom: + if self.check_top_fx(last_bottom, klc) == False: + fx = Chan_FX_TYPE.UNKNOWN + if fx == Chan_FX_TYPE.BOTTOM: + if last_top: + if self.check_bottom_fx(last_top, klc) == False: + #print(klc.end_time, last_top.end_time, "---") + fx = Chan_FX_TYPE.UNKNOWN + # Do nothing + if fx == Chan_FX_TYPE.UNKNOWN: + if len(bi_list) > 0: + bi_list[-1].add_klc(klc) + klc.set_bi(bi_list[-1]) + #continue + if len(bi_list) > 0 and klc.end_klu: + last_bi = bi_list[-1] + #print(klc.start_time, last_bi.start_time, last_bi.end_time, last_bi.dir, last_bi.high, last_bi.low, last_bottom.end_time, "last bi") + if last_top and last_bi.dir == Chan_BI_DIR.DOWN: + if last_bottom and klc.high > last_bi.high: + #print(klc.end_time, "Top 7, 1", last_bi.start_time, klc.high, last_bi.high) + #klc.klc_fx_type = Chan_KLC_FX.TOP7 + #klc.fx = Chan_FX_TYPE.TOP + """ + last_bi.set_end_klc(last_bottom, klc) + bi = ChanBI(last_bottom, len(bi_list), Chan_BI_DIR.UP) + #klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM7) + #klc.bb_out = True + last_bi.set_next(bi) + bi.set_pre(last_bi) + for klc_index in range(last_bi.end_klc.index, len(klc_list)): + bi.add_klc(klc_list[klc_index]) + bi_list.append(bi) + last_top = klc + klc.set_bi(bi) + #print(klc.start_time, bi.start_time, bi.end_time, bi.dir, bi.high, bi.low, bi.is_sure) + """ + else: + if last_bottom and last_bi.dir == Chan_BI_DIR.UP: + if last_top and klc.low < last_bi.low: + #print(klc.end_time, "Bottom 8, 2", last_bi.start_time) + #klc.klc_fx_type = Chan_KLC_FX.BOTTOM8 + #klc.fx = Chan_FX_TYPE.BOTTOM + """ + last_bi.set_end_klc(last_top, klc) + bi = ChanBI(last_top, len(bi_list), Chan_BI_DIR.DOWN) + #klc.set_klc_fx_type(Chan_KLC_FX.TOP6) + #klc.bb_out = True + last_bi.set_next(bi) + bi.set_pre(last_bi) + for klc_index in range(last_bi.end_klc.index, len(klc_list)): + bi.add_klc(klc_list[klc_index]) + bi_list.append(bi) + last_bottom = klc + klc.set_bi(bi) + #print(klc.start_time, bi.start_time, bi.end_time, bi.dir, bi.high, bi.low, bi.is_sure) + """ + else: + last_fx_klc = klc + if fx == Chan_FX_TYPE.TOP: + #print(klc.end_time, fx, klc.pre.high, klc.high, klc.pre.start_time, klc.pre.end_time) + if last_top: + if last_bottom: + #print(klc.start_time, last_bottom.start_time, last_top.start_time) + if last_bottom.index < last_top.index: + # Second top lower to be second sell point + if last_top.high > klc.high: + bi_list[-1].add_klc(klc) + klc.set_bi(bi_list[-1]) + #klc.set_klc_fx_type(Chan_KLC_FX.TOP3) + #print(klc.end_time, klc.fx, "二类卖点Sell 1") + else: + # A new top found + #last_top.set_fx(Chan_FX_TYPE.UNKNOWN) + last_top = klc + #print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Top Change 1") + klc.set_klc_fx_type(Chan_KLC_FX.TOP1) + self.check_fx_pattern(klc) + #print(klc.end_time, klc.fx, "一类卖点Sell 1") + bi_list[-1].add_klc(klc) + klc.set_bi(bi_list[-1]) + # 不满足结合律的分型 + else: + #klc.set_klc_fx_type(Chan_KLC_FX.TOP0) + #print(klc.end_time, klc.klc_fx_type) + if last_bottom.index + bi_klc_min > klc.index: + if last_top.high > klc.high: + #print(klc.start_time, klc.fx, "二类卖点Sell 1") + #klc.set_klc_fx_type(Chan_KLC_FX.TOP8) + bi_list[-1].add_klc(klc) + klc.set_bi(bi_list[-1]) + # New TOP Found前面的UKNOWN可能出现TOP7,但是这里的也可能出现TOP8分型 + else: + # 顶分型在出现2之前超过前一个笔的顶 TOP8 + if last_top.index + bi_klc_min < klc.index and len(bi_list) > 1: + pre_last_bi = bi_list[-2] + last_bi = bi_list[-1] + if pre_last_bi.is_sure and not last_bi.is_sure and pre_last_bi.dir == Chan_BI_DIR.UP and False: + pre_last_bi.update_bi(klc) + bi_list.remove(last_bi) + pre_last_bi.set_next(None) + #last_top.set_fx(Chan_FX_TYPE.PTOP) + last_top = klc + last_bottom = pre_last_bi.start_klc + #print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Top Bottom Change 1") + klc.set_klc_fx_type(Chan_KLC_FX.TOP2) + #print(klc.start_time, last_bi.start_klc.start_time, "New TOP Found reset last bi") + #klc.set_state("10") + #print(klc.start_time, klc.fx, "笔卖点Sell 1") + ###klc.set_klc_fx_type(Chan_KLC_FX.TOP2) # when bi is down but the fx is top + bi_list[-1].add_klc(klc) + klc.set_bi(bi_list[-1]) + #klc.set_klc_fx_type(Chan_KLC_FX.TOP8) + #print(klc.start_time, last_bi.start_klc.start_time, "New TOP Found reset last bi") + else: + #klc.set_fx(Chan_FX_TYPE.PTOP) + bi_list[-1].add_klc(klc) + klc.set_bi(bi_list[-1]) + #print(klc.end_time, klc.fx, "无效顶分型") + # 满足结合律 + else: + # New Temp TOP and last bottom confirmed ***** confirm last down bi(last bottom and last top) + last_bi = bi_list[-1] + if not last_bi.is_sure: + last_bi.set_end_klc(last_bottom, klc) + bi = ChanBI(last_bottom, len(bi_list), Chan_BI_DIR.UP) + last_bi.set_next(bi) + bi.set_pre(last_bi) + bi.add_klc(klc) + bi_list.append(bi) + last_top = klc + #print(klc.end_time, klc.fx, bi_list[-1].dir, "Last Top Change 2") + klc.set_klc_fx_type(Chan_KLC_FX.TOP2) + self.check_fx_pattern(klc) + #bi_list[-1].add_klc(klc) + klc.set_bi(bi_list[-1]) + #print(klc.start_time, last_bottom.start_time, "Normal TOP Found, Confirm down bi 4") + # last bottom = None 初始化的时候用,其他时间不用 + else: + # 初始化的时候用,其他时间不用 + if last_top.high < klc.high: + last_bi = bi_list[-1] + last_bi.set_start_klc(klc, Chan_BI_DIR.DOWN) + last_top = klc + #print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Top Change 3") + bi_list[-1].add_klc(klc) + klc.set_bi(bi_list[-1]) + # 初始化的时候用,其他时间不用 + else: + #klc.set_fx(Chan_FX_TYPE.TT) + #print(klc.start_time, klc.fx, "二类卖点Sell 2") + bi_list[-1].add_klc(klc) + klc.set_bi(bi_list[-1]) + # last_top == None 初始化的时候用,其他时间不用 + else: + if last_bottom: + # 不满足结合律的分型 + if last_bottom.index + bi_klc_min > klc.index: + #klc.set_fx(Chan_FX_TYPE.PTOP) + bi_list[-1].add_klc(klc) + klc.set_bi(bi_list[-1]) + #print(klc.start_time, klc.fx, "中枢卖点Sell 1") + else: + # First temp top and last bottom confirmed + last_top = klc + #print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Top Change 4") + bi_list[-1].add_klc(klc) + klc.set_bi(bi_list[-1]) + # Last top = None, last bottom = None, create first down bi 初始化的时候用,其他时间不用 + else: + # First temp top + last_top = klc + bi = ChanBI(klc, len(bi_list), Chan_BI_DIR.DOWN) + bi_list.append(bi) + bi.add_klc(klc) + klc.set_bi(bi_list[-1]) + #print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Top Change 5") + #klc.fx = Bottom ======================== + else: + if last_bottom: + if last_top: + # Bottom after top and find a new bottom + if last_top.index < last_bottom.index: + # Second bottom uppper to be second buy point and confirm last bi + if last_bottom.low < klc.low: + bi_list[-1].add_klc(klc) + klc.set_bi(bi_list[-1]) + #klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM3) + #print(last_bottom.start_time, last_bottom.end_time, "--------------------------------1") + #print(klc.end_time, klc.fx, "二类买点Buy 1") + else: + # A new bottom found + last_bottom = klc + #print(klc.end_time, klc.fx, bi_list[-1].dir, "Last Bottom Change 1") + klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM1) + self.check_fx_pattern(klc) + #print(klc.end_time, klc.fx, "一类买点Buy 1") + bi_list[-1].add_klc(klc) + klc.set_bi(bi_list[-1]) + # 不满足结合律的分型 + else: + #klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM0) + #print(klc.end_time, klc.klc_fx_type) + if last_top.index + bi_klc_min > klc.index: + if last_bottom.low < klc.low: + #print(klc.end_time, klc.fx, "中枢买点Buy 1") + bi_list[-1].add_klc(klc) + klc.set_bi(bi_list[-1]) + #klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM8) + # Found new bottom没有意义,上面UNKNOWN的时候已经是笔破坏了 + else: + #print(klc.end_time, last_bottom.end_time, "Found a new bottom") + if last_bottom.index + bi_klc_min < klc.index and len(bi_list) > 1: + pre_last_bi = bi_list[-2] + last_bi = bi_list[-1] + if pre_last_bi.is_sure and not last_bi.is_sure and pre_last_bi.dir == Chan_BI_DIR.DOWN and False: + pre_last_bi.update_bi(klc) + bi_list.remove(last_bi) + pre_last_bi.set_next(None) + last_bottom = klc + last_top = pre_last_bi.start_klc + #print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Top Bottom Change 2") + klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM2) + #print(klc.start_time, last_bi.start_klc.start_time, "New BOTTOM Found reset last bi") + #print(klc.start_time, klc.fx, "笔买点Buy 1") + ###klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM2) # when bi is up but the fx is bottom + bi_list[-1].add_klc(klc) + klc.set_bi(bi_list[-1]) + #klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM8) + else: + #klc.set_fx(Chan_FX_TYPE.UNKNOWN) + bi_list[-1].add_klc(klc) + klc.set_bi(bi_list[-1]) + print(klc.end_time, klc.fx, "无效底分型") + # 满足结合律的分型 + else: + # New Temp Bottom and last top confirmed ***** confirm last up bi(last bottom and last top) + last_bi = bi_list[-1] + if not last_bi.is_sure: + last_bi.set_end_klc(last_top, klc) + bi = ChanBI(last_top, len(bi_list), Chan_BI_DIR.DOWN) + last_bi.set_next(bi) + bi.set_pre(last_bi) + bi.add_klc(klc) + bi_list.append(bi) + last_bottom = klc + #print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Bottom Change 2") + klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM2) + self.check_fx_pattern(klc) + #bi_list[-1].add_klc(klc) + klc.set_bi(bi_list[-1]) + #print(klc.start_time, last_top.start_time, "Normal Bottom Found, Confirm up bi 6") + # last_top = None 初始化的时候用,其他时间不用 + else: + if last_bottom.low > klc.low: + last_bi = bi_list[-1] + last_bi.set_start_klc(klc, Chan_BI_DIR.UP) + #last_bottom.set_fx(Chan_FX_TYPE.UNKNOWN) + last_bottom = klc + #print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Bottom Change 3") + bi_list[-1].add_klc(klc) + klc.set_bi(bi_list[-1]) + #print(klc.start_time, klc.fx, "笔买点Buy 3") + else: + #klc.set_fx(Chan_FX_TYPE.BB) + #klc.set_state('-20') + #print(klc.start_time, klc.fx, "二类买点Buy 2") + bi_list[-1].add_klc(klc) + klc.set_bi(bi_list[-1]) + # last_bottom = None 初始化的时候用,其他时间不用 + else: + if last_top: + # 不满足结合律的分型 + if last_top.index + bi_klc_min > klc.index: + #klc.set_fx(Chan_FX_TYPE.PBOTTOM) + bi_list[-1].add_klc(klc) + klc.set_bi(bi_list[-1]) + #print(klc.start_time, klc.fx, "中枢买点Buy 1") + else: + # First temp bottom and last top confirmed + last_bottom = klc + #print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Bottom Change 4") + bi_list[-1].add_klc(klc) + klc.set_bi(bi_list[-1]) + #print(klc.start_time, klc.fx, "一类买点Buy 1") + # Last top = None, last bottom = None, create first up bi + else: + # First temp bottom and no top yet + last_bottom = klc + bi = ChanBI(klc, len(bi_list), Chan_BI_DIR.UP) + #klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM7) + bi_list.append(bi) + bi_list[-1].add_klc(klc) + klc.set_bi(bi_list[-1]) + #print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Bottom Change 5") + #print(klc.start_time, klc.fx, "笔买点Buy 4") + self.get_above_zero_bsp(klc_list) + #print(bi_list[-1].start_time, bi_list[-1].end_time, len(bi_list[-1].klc_list)) + return bi_list + + def check_top_fx(self, last_bottom, klc): + if (last_bottom.high > klc.pre.low or last_bottom.high > klc.next.low) and (klc.index - last_bottom.index < 100): + return False + return True + + + def check_bottom_fx(self, last_top, klc): + if (last_top.low < klc.pre.high or last_top.low < klc.next.high) and (klc.index - last_top.index < 100): + return False + return True + # 线段内的中枢 diff --git a/chanlun/pipeline/builders/bsp.py b/chanlun/pipeline/builders/bsp.py new file mode 100644 index 0000000..dcf1183 --- /dev/null +++ b/chanlun/pipeline/builders/bsp.py @@ -0,0 +1,412 @@ +"""TF_DF builder mixin — 由 split_tfdf_builders 自动生成,逻辑与原 TF_DF 一致。""" +from __future__ import annotations + +from datetime import timedelta +from decimal import Decimal + +import numpy as np +import pandas as pd +import talib.abstract as ta +from pandas import DataFrame +from technical.util import resample_to_interval + +from chanlun.core.ChanBI import ChanBI +from chanlun.core.ChanBIZS import ChanBIZS +from chanlun.core.ChanBSP import ChanBSP +from chanlun.core.ChanEnum import ( + Chan_BI_DIR, + Chan_BSP_DIR, + Chan_BSP_TYPE, + Chan_FX_TYPE, + Chan_K_DIR, + Chan_KLC_FX, + Chan_KLC_STATE, + Chan_KLINE_DIR, + Chan_KLU_PATTERN, + Chan_PRICE_TREND, + Chan_SEG_DIR, + Chan_ZS_DIR, +) +from chanlun.core.ChanKLC import ChanKLC +from chanlun.core.ChanKLU import ChanKLU +from chanlun.core.ChanSBI import ChanSBI +from chanlun.core.ChanSEG import ChanSEG +from chanlun.core.ChanZS import ChanZS, ChanZS_Big +from chanlun.indicators.ChanMACD import ChanMACD + + +class BspBuilderMixin: + def get_bsp_state(self, dataframe): + klu_list = self.get_klu_list(dataframe) + klc_list = self.get_klc_list(klu_list) + bi_list = self.cal_bi_list(klc_list) + seg_list = self.get_seg_list(bi_list) + bi_zs_list = self.cal_bi_zs(seg_list) + bsp_list = self.find_all_bsp(bi_list, bi_zs_list) + bsp_state_list = [0] * len(dataframe) + klc_index = 0 + for index in range(0, len(dataframe)): + if klc_index == len(klc_list): + klc_index = len(klc_list) - 1 + klc = klc_list[klc_index] + if klc.end_klu and klc.end_klu.idx == index: + if klc.klc_fx_type == Chan_KLC_FX.TOP2: + bi = klc.bi.pre + if bi and bi.is_sure and bi.end_klc.bsp_type == Chan_BSP_TYPE.B3: + # 第三类买点 + bsp_state_list[index] = -1 + #print(klc.end_time, "B3") + else: + bsp_state_list[index] = 0 + elif klc.klc_fx_type == Chan_KLC_FX.BOTTOM2: + bi = klc.bi.pre + if bi and bi.is_sure and bi.end_klc.bsp_type == Chan_BSP_TYPE.S3: + # 第三类卖点 + bsp_state_list[index] = 1 + #print(klc.end_time, "S3") + else: + bsp_state_list[index] = 0 + klc_index += 1 + else: + bsp_state_list[index] = 0 + return bsp_state_list + + def get_above_zero_bsp(self, klc_list): + buy_bsp_list = [] + sell_bsp_list = [] + above_zero = False + buy_bsp = None + sell_bsp = None + for klc in klc_list: + if klc.pre and klc.pre.signal < 0 and klc.signal > 0: + above_zero = True + if klc.pre and klc.pre.signal > 0 and klc.signal < 0: + above_zero = False + if above_zero and klc.klc_fx_type == Chan_KLC_FX.BOTTOM2 and klc.macd > 0: + buy_bsp = klc + buy_bsp_list.append(klc) + #print(klc.end_time, "MACD 0轴上穿,回调笔底分型做多") + if buy_bsp and klc.pre and klc.pre.macdhist > 0 and klc.macdhist < 0: + sell_bsp = klc + sell_bsp_list.append(klc) + buy_bsp = None + #print(klc.end_time, "Sell BSP Found") + return buy_bsp_list + + def find_all_bsp(self, bi_list, bi_zs_list): + """ + 笔中枢的三类买卖点识别 + + 三类买点:中枢形成后,一笔向上离开中枢(低点 > zg), + 随后回拉的一笔低点不跌回中枢(低点 >= zg),确认支撑有效。 + 三类卖点:中枢形成后,一笔向下离开中枢(高点 < zd), + 随后反弹的一笔高点不回到中枢(高点 <= zd),确认压力有效。 + + 参数: + bi_list: 笔列表 + bi_zs_list: 笔中枢列表(二维列表,每个seg内的中枢列表) + + 返回: + bsp_list: ChanBSP 列表,包含所有识别到的三类买卖点 + """ + bsp_list = [] + if len(bi_list) < 4 or len(bi_zs_list) == 0: + return bsp_list + + for zs in bi_zs_list: + if not zs.is_sure or len(zs.bi_list) < 3: + continue + #print(zs.start_time, zs.end_time, zs.dir, zs.is_sure, len(zs.bi_list)) + # 中枢结束后的第一笔(离开笔) + last_zs_bi = zs.bi_list[-1] + if last_zs_bi.dir == Chan_BI_DIR.UP: + if last_zs_bi.is_sure and last_zs_bi.end_klc.high <= zs.zg or (last_zs_bi.next and last_zs_bi.next.is_sure and last_zs_bi.next.end_klc.low < zs.zd): + leave_bi = last_zs_bi.next + else: + leave_bi = last_zs_bi + else: + if last_zs_bi.is_sure and last_zs_bi.end_klc.low >= zs.zd or (last_zs_bi.next and last_zs_bi.next.is_sure and last_zs_bi.next.end_klc.high > zs.zg): + leave_bi = last_zs_bi.next + else: + leave_bi = last_zs_bi + #print(zs.zg, zs.zd) + if leave_bi is None or not leave_bi.is_sure: + continue + if (zs.dir == Chan_ZS_DIR.UP and leave_bi.dir == Chan_BI_DIR.UP and leave_bi.end_klc.high < zs.zg and leave_bi.end_klc.high > zs.zd) or (zs.dir == Chan_ZS_DIR.DOWN and leave_bi.dir == Chan_BI_DIR.DOWN and leave_bi.end_klc.low < zs.zg and leave_bi.end_klc.low > zs.zd): + #print("--------------------", leave_bi.dir, leave_bi.end_klc.high, leave_bi.end_klc.low, zs.zg, zs.zd) + leave_bi = leave_bi.next + # 三类买点:向上离开中枢后回拉不破 zg + #print("Leave bi:", leave_bi.start_time, leave_bi.end_time, leave_bi.dir, leave_bi.is_sure, leave_bi.low, leave_bi.high) + if leave_bi.dir == Chan_BI_DIR.UP: + first_bsp_bi_div = self.check_bi_div(zs, leave_bi) + # 确认一类卖点:离开断能量小于进入段能量 + if first_bsp_bi_div: + bsp = ChanBSP( + leave_bi, len(bsp_list), + Chan_BSP_TYPE.S1, + Chan_BSP_DIR.SELL, + leave_bi.sure_time, + zs.index+1, zs, None + ) + leave_bi.end_klc.set_bsp_type(Chan_BSP_TYPE.S1) + bsp_list.append(bsp) + # 回拉笔 + pullback_bi = leave_bi.next + #print(pullback_bi.start_klc.start_time, pullback_bi.dir, pullback_bi.is_sure, pullback_bi.low, pullback_bi.high) + if pullback_bi and pullback_bi.is_sure and pullback_bi.dir == Chan_BI_DIR.DOWN: + if pullback_bi.low >= zs.zg: + # 确认三类买点:回拉笔的低点不跌回中枢 + bsp = ChanBSP( + pullback_bi, len(bsp_list), + Chan_BSP_TYPE.B3, + Chan_BSP_DIR.BUY, + pullback_bi.sure_time, + zs.index+1, zs, None + ) + pullback_bi.end_klc.set_bsp_type(Chan_BSP_TYPE.B3) + bsp_list.append(bsp) + # 二类卖点 + if first_bsp_bi_div: + second_bsp_bi = pullback_bi.next + if second_bsp_bi and second_bsp_bi.is_sure and second_bsp_bi.end_klc.high < leave_bi.end_klc.high: + # 确认二类卖点:一类卖点后回拉不超过一类卖点高点 + bsp = ChanBSP( + second_bsp_bi, len(bsp_list), + Chan_BSP_TYPE.S2, + Chan_BSP_DIR.SELL, + second_bsp_bi.sure_time, + zs.index+1, zs, None + ) + second_bsp_bi.end_klc.set_bsp_type(Chan_BSP_TYPE.B2) + bsp_list.append(bsp) + # 三类卖点:向下离开中枢后反弹不破 zd + elif leave_bi.dir == Chan_BI_DIR.DOWN: + first_bsp_bi_div = self.check_bi_div(zs, leave_bi) + # 确认一类买点:离开段能量小于进入段 + if first_bsp_bi_div: + bsp = ChanBSP( + leave_bi, len(bsp_list), + Chan_BSP_TYPE.B1, + Chan_BSP_DIR.BUY, + leave_bi.sure_time, + zs.index+1, zs, None + ) + leave_bi.end_klc.set_bsp_type(Chan_BSP_TYPE.B1) + bsp_list.append(bsp) + # 反弹笔 + bounce_bi = leave_bi.next + #print(bounce_bi.start_klc.start_time, bounce_bi.dir, bounce_bi.is_sure, bounce_bi.low, bounce_bi.high) + if bounce_bi and bounce_bi.is_sure and bounce_bi.dir == Chan_BI_DIR.UP: + if bounce_bi.high <= zs.zd: + # 确认三类卖点:反弹笔的高点不回到中枢 + bsp = ChanBSP( + bounce_bi, len(bsp_list), + Chan_BSP_TYPE.S3, + Chan_BSP_DIR.SELL, + bounce_bi.sure_time, + zs.index+1, zs, None + ) + bounce_bi.end_klc.set_bsp_type(Chan_BSP_TYPE.S3) + bsp_list.append(bsp) + # 二类卖点 + if first_bsp_bi_div: + second_bsp_bi = bounce_bi.next + if second_bsp_bi and second_bsp_bi.is_sure and second_bsp_bi.end_klc.low > leave_bi.end_klc.low: + # 确认二类买点:一类买点后回拉不超过一类卖点高点 + bsp = ChanBSP( + second_bsp_bi, len(bsp_list), + Chan_BSP_TYPE.B2, + Chan_BSP_DIR.BUY, + second_bsp_bi.sure_time, + zs.index+1, zs, None + ) + second_bsp_bi.end_klc.set_bsp_type(Chan_BSP_TYPE.B2) + bsp_list.append(bsp) + return bsp_list + + def check_bi_div(self, zs, leave_bi): + enter_bi = zs.bi_list[0].pre + macdhist_div = 0 + if enter_bi and enter_bi.dir == leave_bi.dir: + macdhist_div = abs(leave_bi.macd_hist) - abs(enter_bi.macd_hist) + #print(enter_bi.end_time, leave_bi.end_time, macdhist_div < 0) + return macdhist_div < 0 + + def find_first_bsp(self, bi_list, bi_zs_list): + """ + 笔中枢的一类买卖点识别 + + 一类买点:下跌趋势中,最后一个中枢完成后,向下离开中枢的笔创新低, + 但该笔与进入中枢前的最后一笔下跌形成底背驰(力度减弱), + 即趋势力竭的转折点。 + 一类卖点:上涨趋势中,最后一个中枢完成后,向上离开中枢的笔创新高, + 但该笔与进入中枢前的最后一笔上涨形成顶背驰(力度减弱), + 即趋势力竭的转折点。 + + 简化判断:中枢形成后,离开中枢的笔(突破笔)本身即为一类买卖点的触发笔。 + + 参数: + bi_list: 笔列表 + bi_zs_list: 笔中枢列表(扁平列表,每个元素是一个中枢对象) + + 返回: + bsp_list: ChanBSP 列表,包含所有识别到的一类买卖点 + """ + bsp_list = [] + if len(bi_list) < 4 or len(bi_zs_list) == 0: + return bsp_list + + for zs in bi_zs_list: + if not zs.is_sure or len(zs.bi_list) < 3: + continue + + # 找到中枢的最后一笔 + last_zs_bi = zs.bi_list[-1] + + # 确定离开笔:中枢最后一笔之后的第一笔 + if last_zs_bi.dir == Chan_BI_DIR.UP: + # 中枢最后一笔向上,如果没有真正离开中枢,取下一笔 + if last_zs_bi.is_sure and last_zs_bi.end_klc.high <= zs.zg: + leave_bi = last_zs_bi.next + else: + leave_bi = last_zs_bi + else: + # 中枢最后一笔向下,如果没有真正离开中枢,取下一笔 + if last_zs_bi.is_sure and last_zs_bi.end_klc.low >= zs.zd: + leave_bi = last_zs_bi.next + else: + leave_bi = last_zs_bi + + if leave_bi is None or not leave_bi.is_sure: + continue + + # 一类买点:向下离开中枢(leave_bi向下,低点 < zd),趋势力竭 + if leave_bi.dir == Chan_BI_DIR.DOWN and leave_bi.low < zs.zd: + # 背驰判断:比较离开笔与中枢内最后一笔同向笔的MACD柱状累积面积 + # 缠论原文:两段同向走势的MACD柱状面积比较,面积缩小即为背驰 + compare_bi = None + for bi in reversed(zs.bi_list): + if bi.dir == Chan_BI_DIR.DOWN and bi is not leave_bi: + compare_bi = bi + break + + is_divergence = False + if compare_bi: + # 笔的macd_hist是该笔内所有KLU的macdhist累积面积 + leave_macd_area = abs(leave_bi.macd_hist) + compare_macd_area = abs(compare_bi.macd_hist) + + # 价格创新低但MACD面积缩小 = 底背驰 + if leave_bi.low <= compare_bi.low and leave_macd_area < compare_macd_area: + is_divergence = True + # 即使没创新低,MACD面积明显缩小也算背驰 + elif leave_macd_area < compare_macd_area * 0.5: + is_divergence = True + else: + # 没有对比笔时,只要离开中枢就算一类买点 + is_divergence = True + + if is_divergence: + bsp = ChanBSP( + leave_bi, len(bsp_list), + Chan_BSP_TYPE.T1, + Chan_BSP_DIR.BUY, + leave_bi.sure_time, + 1, zs, None + ) + bsp_list.append(bsp) + + # 一类卖点:向上离开中枢(leave_bi向上,高点 > zg),趋势力竭 + elif leave_bi.dir == Chan_BI_DIR.UP and leave_bi.high > zs.zg: + # 背驰判断:比较离开笔与中枢内最后一笔同向笔的MACD柱状累积面积 + compare_bi = None + for bi in reversed(zs.bi_list): + if bi.dir == Chan_BI_DIR.UP and bi is not leave_bi: + compare_bi = bi + break + + is_divergence = False + if compare_bi: + leave_macd_area = abs(leave_bi.macd_hist) + compare_macd_area = abs(compare_bi.macd_hist) + + # 价格创新高但MACD面积缩小 = 顶背驰 + if leave_bi.high >= compare_bi.high and leave_macd_area < compare_macd_area: + is_divergence = True + # 即使没创新高,MACD面积明显缩小也算背驰 + elif leave_macd_area < compare_macd_area * 0.5: + is_divergence = True + else: + is_divergence = True + + if is_divergence: + bsp = ChanBSP( + leave_bi, len(bsp_list), + Chan_BSP_TYPE.T1, + Chan_BSP_DIR.SELL, + leave_bi.sure_time, + 1, zs, None + ) + bsp_list.append(bsp) + + return bsp_list + + + def find_second_bsp(self, bi_list, first_bsp_list): + """ + 笔中枢的二类买卖点识别 + + 二类买点:一类买点出现后,价格向上反弹一笔,再回落一笔, + 回落笔的低点不跌破一类买点的低点,确认底部成立。 + 二类卖点:一类卖点出现后,价格向下回落一笔,再反弹一笔, + 反弹笔的高点不超过一类卖点的高点,确认顶部成立。 + + 参数: + bi_list: 笔列表 + first_bsp_list: 一类买卖点列表(find_first_bsp 的返回值) + + 返回: + bsp_list: ChanBSP 列表,包含所有识别到的二类买卖点 + """ + bsp_list = [] + if not first_bsp_list or len(bi_list) < 4: + return bsp_list + + for first_bsp in first_bsp_list: + trigger_bi = first_bsp.bi # 一类买卖点的触发笔 + + if first_bsp.dir == Chan_BSP_DIR.BUY: + # 一买之后:trigger_bi 向下 -> 反弹笔(向上) -> 回落笔(向下) + # 回落笔的低点 > trigger_bi 的低点 => 二类买点 + bounce_bi = trigger_bi.next # 反弹笔(向上) + if bounce_bi and bounce_bi.is_sure and bounce_bi.dir == Chan_BI_DIR.UP: + pullback_bi = bounce_bi.next # 回落笔(向下) + if pullback_bi and pullback_bi.is_sure and pullback_bi.dir == Chan_BI_DIR.DOWN: + if pullback_bi.low > trigger_bi.low: + bsp = ChanBSP( + pullback_bi, len(bsp_list), + Chan_BSP_TYPE.T2, + Chan_BSP_DIR.BUY, + pullback_bi.sure_time, + 1, first_bsp.zs, None + ) + bsp_list.append(bsp) + + elif first_bsp.dir == Chan_BSP_DIR.SELL: + # 一卖之后:trigger_bi 向上 -> 回落笔(向下) -> 反弹笔(向上) + # 反弹笔的高点 < trigger_bi 的高点 => 二类卖点 + drop_bi = trigger_bi.next # 回落笔(向下) + if drop_bi and drop_bi.is_sure and drop_bi.dir == Chan_BI_DIR.DOWN: + bounce_bi = drop_bi.next # 反弹笔(向上) + if bounce_bi and bounce_bi.is_sure and bounce_bi.dir == Chan_BI_DIR.UP: + if bounce_bi.high < trigger_bi.high: + bsp = ChanBSP( + bounce_bi, len(bsp_list), + Chan_BSP_TYPE.T2, + Chan_BSP_DIR.SELL, + bounce_bi.sure_time, + 1, first_bsp.zs, None + ) + bsp_list.append(bsp) + + return bsp_list diff --git a/chanlun/pipeline/builders/indicators.py b/chanlun/pipeline/builders/indicators.py new file mode 100644 index 0000000..900175e --- /dev/null +++ b/chanlun/pipeline/builders/indicators.py @@ -0,0 +1,133 @@ +"""TF_DF builder mixin — 由 split_tfdf_builders 自动生成,逻辑与原 TF_DF 一致。""" +from __future__ import annotations + +from datetime import timedelta +from decimal import Decimal + +import numpy as np +import pandas as pd +import talib.abstract as ta +from pandas import DataFrame +from technical.util import resample_to_interval + +from chanlun.core.ChanBI import ChanBI +from chanlun.core.ChanBIZS import ChanBIZS +from chanlun.core.ChanBSP import ChanBSP +from chanlun.core.ChanEnum import ( + Chan_BI_DIR, + Chan_BSP_DIR, + Chan_BSP_TYPE, + Chan_FX_TYPE, + Chan_K_DIR, + Chan_KLC_FX, + Chan_KLC_STATE, + Chan_KLINE_DIR, + Chan_KLU_PATTERN, + Chan_PRICE_TREND, + Chan_SEG_DIR, + Chan_ZS_DIR, +) +from chanlun.core.ChanKLC import ChanKLC +from chanlun.core.ChanKLU import ChanKLU +from chanlun.core.ChanSBI import ChanSBI +from chanlun.core.ChanSEG import ChanSEG +from chanlun.core.ChanZS import ChanZS, ChanZS_Big +from chanlun.indicators.ChanMACD import ChanMACD + + +class IndicatorsBuilderMixin: + def get_ema52(self, index=-1): + if self.klu_list: + ema52_value = self.klu_list[index].ema52 + # 处理NaN值 + if pd.isna(ema52_value) or ema52_value is None: + return None + return float(ema52_value) + return None + + def get_ema24(self, index=-1): + if self.klu_list: + ema24_value = self.klu_list[index].ema24 + # 处理NaN值 + if pd.isna(ema24_value) or ema24_value is None: + return None + return float(ema24_value) + return None + + def add_indicators(self, df): + fast = 26 + slow = 52 + period = 9 + macd = ta.MACD(df, fastperiod=fast, slowperiod=slow, signalperiod=period) + bb365 = ta.BBANDS(df, timeperiod=365, nbdevup=3.0, nbdevdn=3.0, matype=0) + bb120 = ta.BBANDS(df, timeperiod=120, nbdevup=3.0, nbdevdn=3.0, matype=0) + bb30 = ta.BBANDS(df, timeperiod=41, nbdevup=2.3, nbdevdn=2.3, matype=0) + bb302 = ta.BBANDS(df, timeperiod=41, nbdevup=2.0, nbdevdn=2.0, matype=0) + bb30 = ta.BBANDS(df, timeperiod=20, nbdevup=2.0, nbdevdn=2.0, matype=0) + bb302 = ta.BBANDS(df, timeperiod=20, nbdevup=2.0, nbdevdn=2.0, matype=0) + bb2633 = ta.BBANDS(df, timeperiod=26, nbdevup=3.0, nbdevdn=3.0, matype=0) + # 计算布林带中轨(移动平均线) + bb30_middle = ta.SMA(df, timeperiod=90) + + # 手动计算布林带 %B 指标 (BBP) + # %B = (Price - Lower Band) / (Upper Band - Lower Band) + bbp365 = (df['close'] - bb365['lowerband']) / (bb365['upperband'] - bb365['lowerband']) + bbp120 = (df['close'] - bb120['lowerband']) / (bb120['upperband'] - bb120['lowerband']) + bbp30 = (df['close'] - bb30['lowerband']) / (bb30['upperband'] - bb30['lowerband']) + bbp302 = (df['close'] - bb302['lowerband']) / (bb302['upperband'] - bb302['lowerband']) + bbp2633 = (df['close'] - bb2633['lowerband']) / (bb2633['upperband'] - bb2633['lowerband']) + df['bb2633upper'] = bb2633['upperband'] + df['bb2633lower'] = bb2633['lowerband'] + df['bbp2633'] = bbp2633 + df['bb2633middle'] = bb2633['middleband'] + df['atr'] = ta.ATR(df, timeperiod=14) + df['bbup365'] = bb365['upperband'] + df['bblow365'] = bb365['lowerband'] + df['bbp365'] = bbp365 + df['bbup120'] = bb120['upperband'] + df['bblow120'] = bb120['lowerband'] + df['bbp120'] = bbp120 + df['bbup30'] = bb30['upperband'] + df['bblow30'] = bb30['lowerband'] + df['bbmiddle30'] = bb30_middle # 添加bb30中轨 + df['bbp30'] = bbp30 + df['bbup302'] = bb302['upperband'] + df['bblow302'] = bb302['lowerband'] + df['bbp302'] = bbp302 + df['macd'] = macd['macd'] + df['macdsignal'] = macd['macdsignal'] + df['macdhist'] = macd['macdhist'] + df['ema5'] = ta.EMA(df, timeperiod=5) + df['ema10'] = ta.EMA(df, timeperiod=10) + df['ema24'] = ta.EMA(df, timeperiod=24) + df['ema52'] = ta.EMA(df, timeperiod=52) + df['ema104'] = ta.EMA(df, timeperiod=104) + df['ema156'] = ta.EMA(df, timeperiod=156) + df['ema208'] = ta.EMA(df, timeperiod=208) + df['ema26'] = ta.EMA(df, timeperiod=26) + df['ema13'] = ta.EMA(df, timeperiod=13) + df['ema7'] = ta.EMA(df, timeperiod=7) + df['rsi'] = ta.RSI(df, timeperiod=14) + df['volume_ratio'] = self.cal_volume_ratio(df) + return df + + def get_ema_state(self, dataframe): + klu_list = self.get_klu_list(dataframe) + klc_list = self.get_klc_list(klu_list) + bi_list = self.cal_bi_list(klc_list) + klu_state_list = [] + for klu in klu_list: + if klu.near0_return == 1: + klu_state_list.append("1") + elif klu.near0_return == 9: + klu_state_list.append("-1") + elif klu.candle_dir == Chan_K_DIR.BULL: + klu_state_list.append("2") + elif klu.candle_dir == Chan_K_DIR.BEAR: + klu_state_list.append("-2") + else: + klu_state_list.append("0") + return klu_state_list + + def get_decimal(self, value): + return Decimal("{:.2f}".format(value)) diff --git a/chanlun/pipeline/builders/kline.py b/chanlun/pipeline/builders/kline.py new file mode 100644 index 0000000..74a7d6b --- /dev/null +++ b/chanlun/pipeline/builders/kline.py @@ -0,0 +1,547 @@ +"""TF_DF builder mixin — 由 split_tfdf_builders 自动生成,逻辑与原 TF_DF 一致。""" +from __future__ import annotations + +from datetime import timedelta +from decimal import Decimal + +import numpy as np +import pandas as pd +import talib.abstract as ta +from pandas import DataFrame +from technical.util import resample_to_interval + +from chanlun.core.ChanBI import ChanBI +from chanlun.core.ChanBIZS import ChanBIZS +from chanlun.core.ChanBSP import ChanBSP +from chanlun.core.ChanEnum import ( + Chan_BI_DIR, + Chan_BSP_DIR, + Chan_BSP_TYPE, + Chan_FX_TYPE, + Chan_K_DIR, + Chan_KLC_FX, + Chan_KLC_STATE, + Chan_KLINE_DIR, + Chan_KLU_PATTERN, + Chan_PRICE_TREND, + Chan_SEG_DIR, + Chan_ZS_DIR, +) +from chanlun.core.ChanKLC import ChanKLC +from chanlun.core.ChanKLU import ChanKLU +from chanlun.core.ChanSBI import ChanSBI +from chanlun.core.ChanSEG import ChanSEG +from chanlun.core.ChanZS import ChanZS, ChanZS_Big +from chanlun.indicators.ChanMACD import ChanMACD + + +class KlineBuilderMixin: + def get_klu_state(self, dataframe): + klc_list = self.get_klc_list(self.get_klu_list(dataframe)) + bi_list = self.cal_bi_list(klc_list) + klu_state_list = [] + klc_index = 0 + for index in range(0, len(dataframe)): + if klc_index == len(klc_list): + klc_index = len(klc_list) - 1 + klc = klc_list[klc_index] + if klc.end_klu and klc.end_klu.idx == index: + if klc.klc_state == Chan_KLC_STATE.S10: + klu_state_list.append("10") + #print(klc.end_time, klc.klc_fx_type) + elif klc.klc_state == Chan_KLC_STATE.S_10: + klu_state_list.append("-10") + #print(klc.end_time, klc.klc_fx_type) + elif klc.klc_state == Chan_KLC_STATE.S11: + klu_state_list.append("11") + #print(klc.end_time, klc.klc_fx_type) + elif klc.klc_state == Chan_KLC_STATE.S_11: + klu_state_list.append("-11") + #print(klc.end_time, klc.klc_fx_type) + else: + klu_state_list.append("00") + klc_index += 1 + else: + klu_state_list.append("00") + print(klu_state_list[:20]) + return klu_state_list + + + def check_fx1(self, klc): + if klc.pre and klc.next: + if klc.high > klc.pre.high and klc.high > klc.next.high and klc.low > klc.pre.low and klc.low > klc.next.low: + if klc.pre.pre and klc.next.next: + if klc.high > klc.pre.pre.high and klc.high > klc.next.next.high: + #if (klc.close > klc.ema52 or klc.next.close > klc.next.ema52) and klc.macd > 0: + klc.set_fx(Chan_FX_TYPE.TOP) + #print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "TOP") + return Chan_FX_TYPE.TOP + elif klc.low < klc.pre.low and klc.low < klc.next.low and klc.high < klc.pre.high and klc.high < klc.next.high: + #if (klc.close < klc.ema52 or klc.next.close < klc.next.ema52) and klc.macd < 0: + if klc.pre.pre and klc.next.next: + if klc.low < klc.pre.pre.low and klc.low < klc.next.next.low: + klc.set_fx(Chan_FX_TYPE.BOTTOM) + #print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "BOTTOM") + return Chan_FX_TYPE.BOTTOM + return Chan_FX_TYPE.UNKNOWN + + def check_fx(self, klc): + if klc.pre and klc.next: + if klc.high > klc.pre.high and klc.high > klc.next.high and klc.low > klc.pre.low and klc.low > klc.next.low: + #if (klc.close > klc.ema52 or klc.next.close > klc.next.ema52) and klc.macd > 0: + klc.set_fx(Chan_FX_TYPE.TOP) + #print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "TOP") + return Chan_FX_TYPE.TOP + elif klc.low < klc.pre.low and klc.low < klc.next.low and klc.high < klc.pre.high and klc.high < klc.next.high: + #if (klc.close < klc.ema52 or klc.next.close < klc.next.ema52) and klc.macd < 0: + klc.set_fx(Chan_FX_TYPE.BOTTOM) + #print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "BOTTOM") + return Chan_FX_TYPE.BOTTOM + return Chan_FX_TYPE.UNKNOWN + + def check_fx2(self, klc): + if klc.pre and klc.next: + if klc.high > klc.pre.close and klc.close > klc.next.close and klc.close > klc.pre.close and klc.close > klc.next.close: + #if (klc.close > klc.ema52 or klc.next.close > klc.next.ema52) and klc.macd > 0: + klc.set_fx(Chan_FX_TYPE.TOP) + #print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "TOP") + return Chan_FX_TYPE.TOP + elif klc.low < klc.pre.close and klc.close < klc.next.close and klc.close < klc.pre.close and klc.close < klc.next.close: + #if (klc.close < klc.ema52 or klc.next.close < klc.next.ema52) and klc.macd < 0: + klc.set_fx(Chan_FX_TYPE.BOTTOM) + #print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "BOTTOM") + return Chan_FX_TYPE.BOTTOM + return Chan_FX_TYPE.UNKNOWN + + def check_fx_pattern(self, klc): + klu_list = klc.pre.klu_list + klc.klu_list + klc.next.klu_list + + self.cal_klu_pattern(klu_list) + p = "" + for klu in klu_list: + p += klu.to_string() + #print(p) + + def cal_volume_ratio(self, dataframe, window=10): + df = dataframe.copy() + # 计算过去N根K线的平均成交量 + df['avg_volume'] = df['volume'].rolling(window=window).mean() + # 计算量比 + df['volume_ratio'] = df['volume'] / df['avg_volume'] + # 填充缺失值(前N根K线) + df['volume_ratio'] = df['volume_ratio'].fillna(1.0) + return df['volume_ratio'] + + def cal_kl_data(self, dataframe:DataFrame): + fields = "time,open,high,low,close,volume" + klu_list = [] + last_klu = None + for i in range(0, len(dataframe)): + item = dataframe.iloc[i] + date = item['date'] + o = item['open'] + h = item['high'] + l = item['low'] + c = item['close'] + v = item['volume'] + # time_obj = date.fromtimestamp(date) + # date = date + timedelta(hours=8) + time_str = date.strftime('%Y-%m-%d %H:%M:%S') + item_data = [ + time_str, + o, + h, + l, + c, + v + ] + # klu = KLU(self.create_item_dict(item_data, GetColumnNameFromFieldList(fields))) + klu = ChanKLU(time_str, o, h, l, c, v) + # print(klu.time, klu.open, klu.high, klu.low, klu.close, klu.volume) + klu.set_idx(i) + klu_list.append(klu) + if last_klu: + last_klu.set_next(klu) + klu.set_pre(last_klu) + last_klu = klu + if 'macd' in item: + klu.set_indicators(item) + return klu_list + + def get_kl_data(self, dataframe:DataFrame): + return self.cal_kl_data(dataframe) + + def get_klc_list(self, klu_list): + klc_list = [] + last_klu = None + macd = ChanMACD(klu_list) + klu_list = macd.cal_macd_state() + ema_up_list = [] + ema_down_list = [] + ema_up_count = 0 + ema_down_count = 0 + last_klu = None + for klu in klu_list: + ema = klu.ema52 + last_ema = last_klu.ema52 if last_klu else 0 + if klu.close >= ema: + ema_up_count += 1 + elif klu.close < ema: + ema_down_count += 1 + if last_klu and last_klu.close >= last_ema and klu.close < ema: + ema_up_list.append(ema_up_count) + #print(last_klu.time, ema_up_count, "UP END") + ema_up_count = 0 + elif last_klu and last_klu.close < last_ema and klu.close >= ema: + ema_down_list.append(ema_down_count) + #print(last_klu.time, ema_down_count, "DOWN END") + ema_down_count = 0 + if len(klc_list) > 0: + last_klc = klc_list[-1] + if klu.exception: + ddir = Chan_KLINE_DIR.DOWN + if last_klc.high < klu.high: + ddir = Chan_KLINE_DIR.UP + klc = ChanKLC(klu, index=len(klc_list), ddir=ddir) + klc.high = klu.close if klu.close > klu.open else klu.open + klc.low = klu.open if klu.close > klu.open else klu.close + klc_list.append(klc) + last_klc.set_next(klc) + klc.set_pre(last_klc) + last_klc.set_end_klu(last_klu) + klc.set_pre_fx() + #print(klu.time, klu.high, klu.low, klu.close, klu.open, klu.exception) + else: + included = last_klc.check_klu_included(klu) + if not included: + ddir = Chan_KLINE_DIR.DOWN + if last_klc.high < klu.high: + ddir = Chan_KLINE_DIR.UP + klc = ChanKLC(klu, index=len(klc_list), ddir=ddir) + klc_list.append(klc) + last_klc.set_next(klc) + klc.set_pre(last_klc) + last_klc.set_end_klu(last_klu) + klc.set_pre_fx() + else: + last_klc.add_klu(klu) + else: + ddir = Chan_KLINE_DIR.UP + if klu.open > klu.close: + ddir = Chan_KLINE_DIR.DOWN + klc = ChanKLC(klu, 0, ddir) + klc_list.append(klc) + last_klu = klu + klc_list = self.cal_trend(klc_list) + #print(ema52_up_list, ema52_down_list) + return klc_list + + + def get_klu_list(self, dataframe): + klu_list = self.get_kl_data(dataframe) + #klu_list = self.cal_klu_pattern(klu_list) + return klu_list + + def cal_klu_pattern(self, klu_list): + """ + 计算裸K的pattern - 识别反转形态 + """ + if not klu_list or len(klu_list) < 3: + return klu_list + + for i, klu in enumerate(klu_list): + # 单根K线反转模式识别 + self._detect_single_reversal_pattern(klu) + + # 双根K线形态识别 + if i >= 1: + self._detect_double_pattern(klu_list[i-1], klu) + + # 三根K线形态识别 + if i >= 2: + self._detect_triple_pattern(klu_list[i-2], klu_list[i-1], klu) + + #if klu.pattern != Chan_KLU_PATTERN.UNKNOWN: + #print(klu.time, klu.pattern, klu.lower_shadow_ratio, klu.upper_shadow_ratio, klu.body_ratio, klu.lower_shadow_ratio/klu.body_ratio, klu.upper_shadow_ratio/klu.body_ratio) + return klu_list + + + def _detect_single_reversal_pattern(self, klu): + """检测单根K线反转模式""" + body = abs(klu.close - klu.open) + upper_shadow = klu.high - max(klu.close, klu.open) + lower_shadow = min(klu.close, klu.open) - klu.low + total_range = klu.high - klu.low + + # 避免除零 + if total_range == 0: + return + + body_ratio = body / total_range + upper_ratio = upper_shadow / total_range + lower_ratio = lower_shadow / total_range + #print(klu.time, upper_ratio, lower_ratio, body_ratio, upper_ratio/body_ratio, lower_ratio/body_ratio) + # 避免body_ratio为0时的除零错误 + if body_ratio == 0: + return + # 锤子线/上吊线 - 反转信号 + if lower_ratio / body_ratio >= 2: + # 锤子线:底部反转,需要前面一段 + if klu.close > klu.open and klu.pre: + klu.set_pattern(Chan_KLU_PATTERN.HAMMER) # 底部反转 + # 上吊线:顶部反转,需要前一根是上涨趋势 + elif klu.close < klu.open and klu.pre: + klu.set_pattern(Chan_KLU_PATTERN.HANGING_MAN) # 顶部反转 + + # 倒锤子线/射击之星 - 反转信号 + elif upper_ratio / body_ratio >= 2: + # 倒锤子线:底部反转,需要前一根是下跌趋势 + if klu.close > klu.open and klu.pre: + klu.set_pattern(Chan_KLU_PATTERN.INVERTED_HAMMER) # 底部反转 + # 射击之星:顶部反转,需要前一根是上涨趋势 + elif klu.close < klu.open and klu.pre: + klu.set_pattern(Chan_KLU_PATTERN.SHOOTING_STAR) # 顶部反转 + + # 十字星 - 反转信号 + elif body_ratio <= 0.1: + if upper_ratio > 0.4 and lower_ratio > 0.4: + klu.set_pattern(Chan_KLU_PATTERN.LONG_LEGGED_DOJI) # 强烈反转信号 + elif upper_ratio > 0.4 and lower_ratio <= 0.1: + # 墓碑十字星:顶部反转,需要前一根是上涨趋势 + if klu.pre and klu.pre.close > klu.pre.open: + klu.set_pattern(Chan_KLU_PATTERN.GRAVESTONE_DOJI) # 顶部反转 + elif lower_ratio > 0.4 and upper_ratio <= 0.1: + # 蜻蜓十字星:底部反转,需要前一根是下跌趋势 + if klu.pre and klu.pre.close < klu.pre.open: + klu.set_pattern(Chan_KLU_PATTERN.DRAGONFLY_DOJI) # 底部反转 + else: + klu.set_pattern(Chan_KLU_PATTERN.DOJI) # 一般反转信号 + + + def _detect_double_pattern(self, prev_klu, curr_klu): + """检测两根K线形成的形态 + 包括:吞没形态(看涨/看跌)、乌云盖顶、曙光初现 + """ + # 如果前一根K线已经有形态,不再识别双K线形态 + if prev_klu.pattern != Chan_KLU_PATTERN.UNKNOWN: + return + + # 计算K线实体 + prev_body = abs(prev_klu.close - prev_klu.open) + curr_body = abs(curr_klu.close - curr_klu.open) + + # 判断K线颜色(阴阳) + prev_bullish = prev_klu.close > prev_klu.open + curr_bullish = curr_klu.close > curr_klu.open + + # 检查是否存在长期趋势(至少需要5根K线的趋势) + def check_long_trend(klu, bullish_trend=True, min_bars=5): + """检查是否存在长期趋势 + bullish_trend=True: 检查上涨趋势 + bullish_trend=False: 检查下跌趋势 + min_bars: 最少需要多少根K线形成趋势 + """ + if not klu or not klu.pre: + return False + return True + + # 使用EMA指标判断长期趋势 + if klu.ema52 > 0: + if bullish_trend and klu.close < klu.ema52: + return False + if not bullish_trend and klu.close > klu.ema52: + return False + + # 检查连续的K线方向 + count = 0 + current = klu.pre + + while current and count < min_bars: + if not current.pre: + break + + if bullish_trend: + # 上涨趋势:当前收盘价高于前一根收盘价 + if current.close <= current.pre.close: + break + else: + # 下跌趋势:当前收盘价低于前一根收盘价 + if current.close >= current.pre.close: + break + + count += 1 + current = current.pre + + return count >= min_bars + + # 1. 看涨吞没形态:前阴后阳,后者完全吞没前者 + # 要求前面有明显的下跌趋势 + if not prev_bullish and curr_bullish and \ + abs(curr_klu.open - prev_klu.close) < 10 and \ + curr_klu.close > prev_klu.open and \ + check_long_trend(prev_klu, bullish_trend=False, min_bars=5): + curr_klu.set_pattern(Chan_KLU_PATTERN.BULLISH_ENGULFING) + return + + # 2. 看跌吞没形态:前阳后阴,后者完全吞没前者 + # 要求前面有明显的上涨趋势 + if prev_bullish and not curr_bullish and \ + abs(curr_klu.open - prev_klu.close) < 10 and \ + curr_klu.close < prev_klu.open and \ + check_long_trend(prev_klu, bullish_trend=True, min_bars=5): + curr_klu.set_pattern(Chan_KLU_PATTERN.BEARISH_ENGULFING) + return + + # 3. 乌云盖顶:前阳后阴,后者开盘价高于前者最高价,收盘价在前者实体中部以下 + # 要求前面有明显的上涨趋势 + if prev_bullish and not curr_bullish and \ + curr_klu.open > prev_klu.high and \ + curr_klu.close < (prev_klu.open + prev_klu.close) / 2 and \ + curr_klu.close > prev_klu.open and \ + check_long_trend(prev_klu, bullish_trend=True, min_bars=5): + curr_klu.set_pattern(Chan_KLU_PATTERN.DARK_CLOUD_COVER) + return + + # 4. 曙光初现:前阴后阳,后者开盘价低于前者最低价,收盘价在前者实体中部以上 + # 要求前面有明显的下跌趋势 + if not prev_bullish and curr_bullish and \ + curr_klu.open < prev_klu.low and \ + curr_klu.close > (prev_klu.open + prev_klu.close) / 2 and \ + curr_klu.close < prev_klu.open and \ + check_long_trend(prev_klu, bullish_trend=False, min_bars=5): + curr_klu.set_pattern(Chan_KLU_PATTERN.PIERCING_LINE) + return + + # 平顶和平底移至三根K线形态中判断 + + + def _detect_triple_pattern(self, first_klu, second_klu, third_klu): + """检测三根K线形成的形态 + 包括:早晨之星、黄昏之星、平顶、平底 + """ + # 如果前两根K线已经有形态,不再识别三K线形态 + if first_klu.pattern != Chan_KLU_PATTERN.UNKNOWN or \ + second_klu.pattern != Chan_KLU_PATTERN.UNKNOWN: + return + + # 判断K线颜色(阴阳) + first_bullish = first_klu.close > first_klu.open + second_bullish = second_klu.close > second_klu.open + third_bullish = third_klu.close > third_klu.open + + # 计算实体大小 + first_body = abs(first_klu.close - first_klu.open) + second_body = abs(second_klu.close - second_klu.open) + third_body = abs(third_klu.close - third_klu.open) + + # 检查是否存在长期趋势(至少需要5根K线的趋势) + def check_long_trend(klu, bullish_trend=True, min_bars=5): + """检查是否存在长期趋势 + bullish_trend=True: 检查上涨趋势 + bullish_trend=False: 检查下跌趋势 + min_bars: 最少需要多少根K线形成趋势 + """ + if not klu or not klu.pre: + return False + + # 使用EMA指标判断长期趋势 + if klu.ema52 > 0: + if bullish_trend and klu.close < klu.ema52: + return False + if not bullish_trend and klu.close > klu.ema52: + return False + + # 检查连续的K线方向 + count = 0 + current = klu.pre + + while current and count < min_bars: + if not current.pre: + break + + if bullish_trend: + # 上涨趋势:当前收盘价高于前一根收盘价 + if current.close <= current.pre.close: + break + else: + # 下跌趋势:当前收盘价低于前一根收盘价 + if current.close >= current.pre.close: + break + + count += 1 + current = current.pre + + return count >= min_bars + + # 1. 早晨之星:第一根阴线,第二根十字星或小实体,第三根阳线 + # 要求前面有明显的下跌趋势 + if not first_bullish and third_bullish and \ + second_body < first_body * 0.3 and \ + third_body > first_body * 0.5 and \ + max(second_klu.open, second_klu.close) < first_klu.close and \ + min(second_klu.open, second_klu.close) < third_klu.open and \ + third_klu.close > (first_klu.open + first_klu.close) / 2 and \ + check_long_trend(first_klu, bullish_trend=False, min_bars=7): + third_klu.set_pattern(Chan_KLU_PATTERN.MORNING_STAR) + return + + # 2. 黄昏之星:第一根阳线,第二根十字星或小实体,第三根阴线 + # 要求前面有明显的上涨趋势 + if first_bullish and not third_bullish and \ + second_body < first_body * 0.3 and \ + third_body > first_body * 0.5 and \ + min(second_klu.open, second_klu.close) > first_klu.close and \ + max(second_klu.open, second_klu.close) > third_klu.open and \ + third_klu.close < (first_klu.open + first_klu.close) / 2 and \ + check_long_trend(first_klu, bullish_trend=True, min_bars=7): + third_klu.set_pattern(Chan_KLU_PATTERN.EVENING_STAR) + return + + # 3. 平顶:三根K线的最高点几乎相同(上升趋势中更有意义) + # 要求前面有明显的上涨趋势 + if (abs(first_klu.high - second_klu.high) / first_klu.high < 0.0002 and + abs(second_klu.high - third_klu.high) / second_klu.high < 0.0002 and + check_long_trend(first_klu, bullish_trend=True, min_bars=7)): + # 额外确认:价格接近阻力位或关键技术指标 + is_near_resistance = False + + # 检查是否接近EMA52阻力位 + if first_klu.ema52 > 0: + resistance_level = first_klu.ema52 + if abs(first_klu.high - resistance_level) / resistance_level < 0.01: + is_near_resistance = True + + # 检查是否有成交量确认(成交量减少表示上涨动能减弱) + volume_confirmation = False + if (first_klu.volume > 0 and second_klu.volume > 0 and third_klu.volume > 0 and + third_klu.volume < second_klu.volume and second_klu.volume < first_klu.volume): + volume_confirmation = True + + if is_near_resistance or volume_confirmation: + third_klu.set_pattern(Chan_KLU_PATTERN.TWEEZER_TOP) + return + + # 4. 平底:三根K线的最低点几乎相同(下降趋势中更有意义) + # 要求前面有明显的下跌趋势 + if (abs(first_klu.low - second_klu.low) / first_klu.low < 0.0002 and + abs(second_klu.low - third_klu.low) / second_klu.low < 0.0002 and + check_long_trend(first_klu, bullish_trend=False, min_bars=7)): + # 额外确认:价格接近支撑位或关键技术指标 + is_near_support = False + + # 检查是否接近EMA52支撑位 + if first_klu.ema52 > 0: + support_level = first_klu.ema52 + if abs(first_klu.low - support_level) / support_level < 0.01: + is_near_support = True + + # 检查是否有成交量确认(成交量减少表示下跌动能减弱) + volume_confirmation = False + if (first_klu.volume > 0 and second_klu.volume > 0 and third_klu.volume > 0 and + third_klu.volume < second_klu.volume and second_klu.volume < first_klu.volume): + volume_confirmation = True + + if is_near_support or volume_confirmation: + third_klu.set_pattern(Chan_KLU_PATTERN.TWEEZER_BOTTOM) + return + diff --git a/chanlun/pipeline/builders/seg.py b/chanlun/pipeline/builders/seg.py new file mode 100644 index 0000000..b1b700d --- /dev/null +++ b/chanlun/pipeline/builders/seg.py @@ -0,0 +1,317 @@ +"""TF_DF builder mixin — 由 split_tfdf_builders 自动生成,逻辑与原 TF_DF 一致。""" +from __future__ import annotations + +from datetime import timedelta +from decimal import Decimal + +import numpy as np +import pandas as pd +import talib.abstract as ta +from pandas import DataFrame +from technical.util import resample_to_interval + +from chanlun.core.ChanBI import ChanBI +from chanlun.core.ChanBIZS import ChanBIZS +from chanlun.core.ChanBSP import ChanBSP +from chanlun.core.ChanEnum import ( + Chan_BI_DIR, + Chan_BSP_DIR, + Chan_BSP_TYPE, + Chan_FX_TYPE, + Chan_K_DIR, + Chan_KLC_FX, + Chan_KLC_STATE, + Chan_KLINE_DIR, + Chan_KLU_PATTERN, + Chan_PRICE_TREND, + Chan_SEG_DIR, + Chan_ZS_DIR, +) +from chanlun.core.ChanKLC import ChanKLC +from chanlun.core.ChanKLU import ChanKLU +from chanlun.core.ChanSBI import ChanSBI +from chanlun.core.ChanSEG import ChanSEG +from chanlun.core.ChanZS import ChanZS, ChanZS_Big +from chanlun.indicators.ChanMACD import ChanMACD + + +class SegBuilderMixin: + def get_seg_list(self, bi_list): + seg_list = [] + up_bi_list = [] + down_bi_list = [] + last_up_bi = None + last_down_bi = None + last_up_sbi = None + last_down_sbi = None + last_seg = None + up_sbi_list = [] + down_sbi_list = [] + look_for_bottom = False + look_for_top = False + for bi in bi_list: + #print(len(up_sbi_list), len(down_sbi_list)) + if len(seg_list) > 0: + # Last seg is up + if last_seg.dir == Chan_SEG_DIR.UP: + if bi.dir == Chan_BI_DIR.DOWN: + if len(down_sbi_list) > 1: + # Check down sbi inclusion + included = last_down_sbi.check_bi_included(bi) + if not included: + down_sbi = ChanSBI(bi, len(down_sbi_list), bi.dir) + last_down_sbi.set_next(down_sbi) + last_down_sbi.set_end_bi(last_down_bi) + down_sbi.set_pre(last_down_sbi) + down_sbi_list.append(down_sbi) + fx = last_down_sbi.check_fx() + # Found top + if fx == Chan_FX_TYPE.TOP: + if look_for_top: + seg_list[-2].set_sure(bi) + look_for_top = False + #print(bi.start_time, look_for_top, "UP 1") + # Has gap and search for bottom fx + if last_down_sbi.has_fx_gap: + look_for_bottom = True + last_seg.pre_set_end_bi(bi_list[last_down_sbi.start_bi.index - 1]) + seg = ChanSEG(last_down_sbi.start_bi, len(seg_list), Chan_SEG_DIR.DOWN, bi) + seg_list.append(seg) + last_seg.set_next(seg) + seg.set_pre(last_seg) + last_seg = seg + up_sbi_list = [] + last_up_sbi = ChanSBI(last_up_bi, len(up_sbi_list), last_up_bi.dir) + up_sbi_list.append(last_up_sbi) + #up_sbi_list.append(last_up_sbi) + #print(last_up_bi.start_time, last_up_sbi.start_bi.start_time, "Reset up sbi list 1") + #print(bi.start_time, look_for_top, "UP 2") + # No gap end SEG + else: + if look_for_bottom: + look_for_bottom = False + last_seg.set_start_bi(last_down_sbi.start_bi) + seg_list[-2].set_end_bi(bi_list[last_down_sbi.start_bi.index - 1], bi) + up_sbi_list = [] + last_up_sbi = ChanSBI(last_up_bi, len(up_sbi_list), last_up_bi.dir) + up_sbi_list.append(last_up_sbi) + last_seg.add_bi(bi) + #up_sbi_list.append(last_up_sbi) + #print(last_up_bi.start_time, last_up_sbi.start_bi.start_time, "Reset up sbi list 2") + #print(bi.start_time, look_for_top, "UP 3") + else: + last_seg.set_end_bi(bi_list[last_down_sbi.start_bi.index - 1], bi) + seg = ChanSEG(last_down_sbi.start_bi, len(seg_list), Chan_SEG_DIR.DOWN, bi) + seg_list.append(seg) + last_seg.set_next(seg) + seg.set_pre(last_seg) + last_seg = seg + #print(last_down_sbi.end_bi.start_time, "Normal UP SEG", last_up_sbi.start_bi.start_time, bi.start_time) + #l_up_sbi = up_sbi_list[-1] + up_sbi_list = [] + last_up_sbi = ChanSBI(last_up_bi, len(up_sbi_list), last_up_bi.dir) + up_sbi_list.append(last_up_sbi) + #up_sbi_list.append(last_up_sbi) + #print(last_up_bi.start_time, last_up_sbi.start_bi.start_time, "Reset up sbi list 3") + last_down_sbi = down_sbi + last_seg.add_bi(bi) + else: + if len(down_sbi_list) == 1: + included = last_down_sbi.check_bi_included(bi) + if not included: + down_sbi = ChanSBI(bi, len(down_sbi_list), bi.dir) + last_down_sbi.set_next(down_sbi) + last_down_sbi.set_end_bi(last_down_bi) + down_sbi.set_pre(last_down_sbi) + down_sbi_list.append(down_sbi) + last_down_sbi = down_sbi + #print(bi.start_time, look_for_top, "UP 4") + last_seg.add_bi(bi) + + else: + last_down_sbi = ChanSBI(bi, len(down_sbi_list), bi.dir) + down_sbi_list.append(last_down_sbi) + last_seg.add_bi(bi) + #print(bi.start_time, look_for_top, "UP 5") + else: + if last_up_sbi: + included = last_up_sbi.check_bi_included(bi) + if not included: + up_sbi = ChanSBI(bi, len(up_sbi_list), bi.dir) + last_up_sbi.set_next(up_sbi) + last_up_sbi.set_end_bi(last_up_bi) + up_sbi.set_pre(last_up_sbi) + up_sbi_list.append(up_sbi) + last_up_sbi = up_sbi + #print(bi.start_time, look_for_top, "UP 6") + last_seg.add_bi(bi) + + # Last seg is down + else: + if bi.dir == Chan_BI_DIR.UP: + if len(up_sbi_list) > 1: + # Check down sbi inclusion + included = last_up_sbi.check_bi_included(bi) + if not included: + up_sbi = ChanSBI(bi, len(up_sbi_list), bi.dir) + last_up_sbi.set_next(up_sbi) + last_up_sbi.set_end_bi(last_up_bi) + up_sbi.set_pre(last_up_sbi) + up_sbi_list.append(up_sbi) + fx = last_up_sbi.check_fx() + # Found bottom + if fx == Chan_FX_TYPE.BOTTOM: + if look_for_bottom: + seg_list[-2].set_sure(bi) + look_for_bottom = False + #print(bi.start_time, look_for_top, "DOWN 1") + # Has gap and search for bottom fx + if last_up_sbi.has_fx_gap: + look_for_top = True + last_seg.pre_set_end_bi(bi_list[last_up_sbi.start_bi.index - 1]) + seg = ChanSEG(last_up_sbi.start_bi, len(seg_list), Chan_SEG_DIR.UP, bi) + seg_list.append(seg) + last_seg.set_next(seg) + seg.set_pre(last_seg) + last_seg = seg + down_sbi_list = [] + last_down_sbi = ChanSBI(last_down_bi, len(down_sbi_list), last_down_bi.dir) + down_sbi_list.append(last_down_sbi) + #down_sbi_list.append(last_down_sbi) + #print(last_down_bi.start_time, last_down_sbi.start_bi.start_time, "Reset down sbi list 1") + #print(bi.start_time, look_for_top, "DOWN 2") + # No gap end SEG + else: + if look_for_top: + look_for_top = False + last_seg.set_start_bi(last_up_sbi.start_bi) + seg_list[-2].set_end_bi(bi_list[last_up_sbi.start_bi.index - 1], bi) + down_sbi_list = [] + last_down_sbi = ChanSBI(last_down_bi, len(down_sbi_list), last_down_bi.dir) + down_sbi_list.append(last_down_sbi) + last_seg.add_bi(bi) + #down_sbi_list.append(last_down_sbi) + #print(last_down_bi.start_time, last_down_sbi.start_bi.start_time, "Reset down sbi list 2") + #print(bi.start_time, look_for_top, "DOWN 3") + else: + last_seg.set_end_bi(bi_list[last_up_sbi.start_bi.index - 1], bi) + seg = ChanSEG(last_up_sbi.start_bi, len(seg_list), Chan_SEG_DIR.UP, bi) + #print(last_up_sbi.start_bi.start_time) + last_seg.set_next(seg) + seg.set_pre(last_seg) + seg_list.append(seg) + last_seg = seg + #print(last_up_sbi.end_bi.start_time, "Normal DOWN SEG", last_down_sbi.start_bi.start_time, bi.start_time) + down_sbi_list = [] + last_down_sbi = ChanSBI(last_down_bi, len(down_sbi_list), last_down_bi.dir) + down_sbi_list.append(last_down_sbi) + #down_sbi_list.append(last_down_sbi) + #print(last_down_bi.start_time, last_down_sbi.start_bi.start_time, "Reset down sbi list 3") + last_up_sbi = up_sbi + last_seg.add_bi(bi) + else: + if len(up_sbi_list) == 1: + #last_up_sbi = up_sbi_list[-1] + included = last_up_sbi.check_bi_included(bi) + if not included: + up_sbi = ChanSBI(bi, len(up_sbi_list), bi.dir) + last_up_sbi.set_next(up_sbi) + last_up_sbi.set_end_bi(last_up_bi) + up_sbi.set_pre(last_up_sbi) + up_sbi_list.append(up_sbi) + last_up_sbi = up_sbi + last_seg.add_bi(bi) + #print(bi.start_time, look_for_top, "DOWN 4") + else: + last_up_sbi = ChanSBI(bi, len(up_sbi_list), bi.dir) + up_sbi_list.append(last_up_sbi) + last_seg.add_bi(bi) + #print(bi.start_time, look_for_top, "DOWN 5") + else: + if last_down_sbi: + included = last_down_sbi.check_bi_included(bi) + if not included: + down_sbi = ChanSBI(bi, len(down_sbi_list), bi.dir) + last_down_sbi.set_next(down_sbi) + last_down_sbi.set_end_bi(last_down_bi) + down_sbi.set_pre(last_down_sbi) + down_sbi_list.append(down_sbi) + last_down_sbi = down_sbi + last_seg.add_bi(bi) + #print(bi.start_time, look_for_top, look_for_bottom, "DOWN 6") + # len(seg_list) = 0 + else: + if bi.check_overlap(): + if bi.dir == Chan_BI_DIR.UP: + seg = ChanSEG(bi, len(seg_list), Chan_SEG_DIR.UP, bi) + last_up_bi = bi + last_up_sbi = ChanSBI(bi, len(up_sbi_list), bi.dir) + seg_list.append(seg) + last_seg = seg + #print(bi.start_time, 'Create first UP SEG') + else: + seg = ChanSEG(bi, len(seg_list), Chan_SEG_DIR.DOWN, bi) + last_down_bi = bi + last_down_sbi = ChanSBI(bi, len(down_sbi_list), bi.dir) + seg_list.append(seg) + last_seg = seg + #print(bi.start_time, 'Create first DOWN SEG') + if bi.dir == Chan_BI_DIR.UP: + last_up_bi = bi + up_bi_list.append(bi) + else: + last_down_bi = bi + down_bi_list.append(bi) + """ + if len(seg_list) > 1: + seg = seg_list[-1] + last_seg = seg_list[-2] + last_seg_bi = last_seg.bi_list[-3] + bi_index = seg.start_bi.index + for i in range(bi_index, len(bi_list) - 1): + # last seg is down + if seg.dir == Chan_SEG_DIR.UP: + if bi_list[i].dir == Chan_BI_DIR.UP: + last_seg_peak = last_seg_bi.high + if bi_list[i].high > last_seg_peak: + # The confirmed + print("Last UP seg is broken, create a new seg. 1") + seg.pre_set_end_bi(bi_list[i]) + seg = ChanSEG(bi_list[i+1], len(seg_list), Chan_SEG_DIR.DOWN, bi) + seg_list.append(seg) + last_seg = seg_list[-2] + if len(last_seg.bi_list) > 3: + last_seg_bi = last_seg.bi_list[-3] + + else: + if bi_list[i].dir == Chan_BI_DIR.DOWN: + last_seg_peak = last_seg_bi.low + if bi_list[i].low < last_seg_peak: + print("Last DOWN seg is broken, create a new seg. 1") + seg.pre_set_end_bi(bi_list[i]) + seg = ChanSEG(bi_list[i+1], len(seg_list), Chan_SEG_DIR.UP, bi) + seg_list.append(seg) + last_seg = seg_list[-2] + if len(last_seg.bi_list) > 3: + last_seg_bi = last_seg.bi_list[-3] + else: + if len(seg_list) == 1: + last_seg = seg_list[-1] + bi_index = last_seg.bi_list[0].index + for i in range(bi_index, len(bi_list) - 1): + if i > bi_index + 2: + last_seg_peak = bi_list[i-2].high + # last seg is down + if last_seg.dir == Chan_SEG_DIR.DOWN: + if bi_list[i].dir == Chan_BI_DIR.UP: + if bi_list[i].high > last_seg_peak: + print("Last seg is broken, create a new seg. 2") + last_seg.pre_set_end_bi(bi_list[i-1]) + seg = ChanSEG(bi_list[i], len(seg_list), Chan_SEG_DIR.UP, bi) + seg_list.append(seg) + last_seg = seg + last_seg_bi = bi_list[i] + break + """ + #self.cal_bi_zs(seg_list) + return seg_list diff --git a/chanlun/pipeline/builders/zs.py b/chanlun/pipeline/builders/zs.py new file mode 100644 index 0000000..c1a1a67 --- /dev/null +++ b/chanlun/pipeline/builders/zs.py @@ -0,0 +1,699 @@ +"""TF_DF builder mixin — 由 split_tfdf_builders 自动生成,逻辑与原 TF_DF 一致。""" +from __future__ import annotations + +from datetime import timedelta +from decimal import Decimal + +import numpy as np +import pandas as pd +import talib.abstract as ta +from pandas import DataFrame +from technical.util import resample_to_interval + +from chanlun.core.ChanBI import ChanBI +from chanlun.core.ChanBIZS import ChanBIZS +from chanlun.core.ChanBSP import ChanBSP +from chanlun.core.ChanEnum import ( + Chan_BI_DIR, + Chan_BSP_DIR, + Chan_BSP_TYPE, + Chan_FX_TYPE, + Chan_K_DIR, + Chan_KLC_FX, + Chan_KLC_STATE, + Chan_KLINE_DIR, + Chan_KLU_PATTERN, + Chan_PRICE_TREND, + Chan_SEG_DIR, + Chan_ZS_DIR, +) +from chanlun.core.ChanKLC import ChanKLC +from chanlun.core.ChanKLU import ChanKLU +from chanlun.core.ChanSBI import ChanSBI +from chanlun.core.ChanSEG import ChanSEG +from chanlun.core.ChanZS import ChanZS, ChanZS_Big +from chanlun.indicators.ChanMACD import ChanMACD + + +class ZsBuilderMixin: + def get_zs_state(self, df): + bi_list = self.cal_bi_list(self.get_klc_list(self.get_kl_data(df))) + seg_list = self.get_seg_list(bi_list) + zs_list = self.calculate_zs(seg_list) + for zs in zs_list: + last_zs = zs + return zs_list + + def cal_bi_zs(self, seg_list): + bi_zs_list = [] + for seg in seg_list: + zs_list = seg.cal_bi_zs() + if len(zs_list) > 0: + bi_zs_list = list(bi_zs_list) + list(zs_list) + return bi_zs_list + # 跨段不相连的中枢 + + def cal_bi_zs_list(self, bi_list): + """ + 根据缠论笔中枢定义计算中枢(参照 get_zs_list 线段中枢判断规则) + 从第4根笔开始(索引3),每3根笔为一组检查 + 上涨中枢:后中枢 zd > 前中枢 zg(不重叠上移) + 下跌中枢:后中枢 zg < 前中枢 zd(不重叠下移) + 中枢可按两笔一组继续扩展到5根、7根... + """ + bi_zs_list = [] + if len(bi_list) < 3: + return bi_zs_list + + last_zs = None + start_idx = 3 + + while start_idx < len(bi_list): + if start_idx + 2 >= len(bi_list): + break + + bi1 = bi_list[start_idx] + bi2 = bi_list[start_idx + 1] + bi3 = bi_list[start_idx + 2] + + if not (bi1.is_sure and bi2.is_sure and bi3.is_sure): + start_idx += 1 + continue + + zg = min(bi1.high, bi2.high, bi3.high) + zd = max(bi1.low, bi2.low, bi3.low) + + if zg <= zd: + start_idx += 1 + continue + + valid = False + if last_zs is None: + if bi1.dir == Chan_BI_DIR.DOWN: + zs_dir = Chan_ZS_DIR.UP + valid = (bi2.dir == Chan_BI_DIR.UP and bi3.dir == Chan_BI_DIR.DOWN) + else: + zs_dir = Chan_ZS_DIR.DOWN + valid = (bi2.dir == Chan_BI_DIR.DOWN and bi3.dir == Chan_BI_DIR.UP) + else: + is_up_zs = zg > last_zs.zg + is_down_zs = zd < last_zs.zd + + if is_up_zs: + zs_dir = Chan_ZS_DIR.UP + valid = (bi1.dir == Chan_BI_DIR.DOWN and bi2.dir == Chan_BI_DIR.UP and bi3.dir == Chan_BI_DIR.DOWN) + elif is_down_zs: + zs_dir = Chan_ZS_DIR.DOWN + valid = (bi1.dir == Chan_BI_DIR.UP and bi2.dir == Chan_BI_DIR.DOWN and bi3.dir == Chan_BI_DIR.UP) + + if not valid: + start_idx += 1 + continue + gg = max(bi1.high, bi2.high, bi3.high) + dd = min(bi1.low, bi2.low, bi3.low) + zs = ChanBIZS(bi1, len(bi_zs_list), zs_dir) + zs.set_zg(zg) + zs.set_zd(zd) + zs.set_gg(gg) + zs.set_dd(dd) + zs.is_sure = False + zs.bi_list = [bi1, bi2, bi3] + + added_after_leave = [] + leave_index = start_idx + 4 + while leave_index < len(bi_list): + b = bi_list[leave_index] + if not b.is_sure: + break + if b.high >= zs.zd and b.low <= zs.zg: + added_after_leave.append(b.pre) + added_after_leave.append(b) + else: + break + leave_index += 2 + + if added_after_leave: + bis_for_zs = list(zs.bi_list) + list(added_after_leave) + bi_highs = [bi.high for bi in bis_for_zs] + bi_lows = [bi.low for bi in bis_for_zs] + zs.set_gg(max(bi_highs)) + zs.set_dd(min(bi_lows)) + zs.bi_list = bis_for_zs + bi = bis_for_zs[-1] + if bi.is_sure: + zs.set_end_bi(bi, bi.sure_time) + + start_idx = start_idx + len(added_after_leave) + else: + zs.set_end_bi(bi3, bi3.sure_time) + + if last_zs: + last_zs.set_next(zs) + zs.set_pre(last_zs) + + bi_zs_list.append(zs) + last_zs = zs + + start_idx += 4 + + if last_zs: + last_zs.is_sure = bi_list[-1].is_sure + + if last_zs and not last_zs.is_sure: + if last_zs.bi_list and len(last_zs.bi_list) > 0: + last_bi_of_zs = last_zs.bi_list[-1] + last_bi_idx = -1 + for i, bi in enumerate(bi_list): + if bi == last_bi_of_zs: + last_bi_idx = i + break + + has_leave = False + if last_bi_idx >= 0 and last_bi_idx + 1 < len(bi_list): + for i in range(last_bi_idx + 1, len(bi_list)): + bi = bi_list[i] + if bi.is_sure: + leave = (bi.low > last_zs.zg and bi.high > last_zs.zg) or \ + (bi.high < last_zs.zd and bi.low < last_zs.zd) + if leave: + has_leave = True + break + + if has_leave: + if last_bi_of_zs.is_sure: + last_zs.set_end_bi(last_bi_of_zs, last_bi_of_zs.sure_time) + return bi_zs_list + + def get_bi_zs_list(self, bi_list): + """ + 根据缠论笔中枢定义计算中枢(完全参照 get_seg_zs_list 线段中枢判断规则) + 从第4根笔开始(索引3),每3根笔为一组检查 + 上涨中枢:后中枢 zd > 前中枢 zg(不重叠上移) + 下跌中枢:后中枢 zg < 前中枢 zd(不重叠下移) + 盘整/扩张:后中枢与前中枢整体区间有交集 → 合并扩展 + 中枢可按两笔一组继续扩展到5根、7根... + """ + bi_zs_list = [] + if len(bi_list) < 3: + return bi_zs_list + + last_zs = None + start_idx = 3 + + while start_idx < len(bi_list): + if start_idx + 2 >= len(bi_list): + break + + bi1 = bi_list[start_idx] + bi2 = bi_list[start_idx + 1] + bi3 = bi_list[start_idx + 2] + + if not (bi1.is_sure and bi2.is_sure and bi3.is_sure): + start_idx += 1 + continue + + zg = min(bi1.high, bi2.high, bi3.high) + zd = max(bi1.low, bi2.low, bi3.low) + + if zg <= zd: + start_idx += 1 + continue + + valid = False + if last_zs is None: + if bi1.dir == Chan_BI_DIR.DOWN: + zs_dir = Chan_ZS_DIR.UP + valid = (bi2.dir == Chan_BI_DIR.UP and bi3.dir == Chan_BI_DIR.DOWN) + else: + zs_dir = Chan_ZS_DIR.DOWN + valid = (bi2.dir == Chan_BI_DIR.DOWN and bi3.dir == Chan_BI_DIR.UP) + else: + is_up_zs = zd > last_zs.zg + is_down_zs = zg < last_zs.zd + + if is_up_zs: + zs_dir = Chan_ZS_DIR.UP + valid = (bi1.dir == Chan_BI_DIR.DOWN and bi2.dir == Chan_BI_DIR.UP and bi3.dir == Chan_BI_DIR.DOWN) + elif is_down_zs: + zs_dir = Chan_ZS_DIR.DOWN + valid = (bi1.dir == Chan_BI_DIR.UP and bi2.dir == Chan_BI_DIR.DOWN and bi3.dir == Chan_BI_DIR.UP) + + create_new_zs = False + if not valid: + # 如果新中枢和前一个中枢的中枢区间有重叠,不形成新中枢,合并扩展 + if last_zs is not None: + is_in_last_zs = (zd > last_zs.zd and zd < last_zs.zg) or \ + (zg < last_zs.zg and zg > last_zs.zd) or \ + (zg > last_zs.zg and zd < last_zs.zd) or \ + (zg < last_zs.zg and zd > last_zs.zd) + if is_in_last_zs: + # 扩展当前中枢:将 bi1-bi3 加入 last_zs + for bi in [bi1, bi2, bi3]: + if bi not in last_zs.bi_list: + last_zs.add_bi(bi) + create_new_zs = False + else: + start_idx += 1 + continue + else: + start_idx += 1 + continue + else: + create_new_zs = True + + # 新中枢形成时确认前一个中枢 + if last_zs and create_new_zs: + last_bi = last_zs.bi_list[-1] + if last_bi and last_bi.is_sure: + last_zs.is_sure = True + last_zs.set_end_bi(last_bi, last_bi.sure_time) + + zs = last_zs + if create_new_zs: + gg = max(bi1.high, bi2.high, bi3.high) + dd = min(bi1.low, bi2.low, bi3.low) + zs = ChanBIZS(bi1, len(bi_zs_list), zs_dir) + zs.set_zg(zg) + zs.set_zd(zd) + zs.set_gg(gg) + zs.set_dd(dd) + zs.is_sure = False + zs.bi_list = [bi1, bi2, bi3] + + # 离开后回抽扩展检查 + added_after_leave = [] + leave_index = start_idx + 4 + while leave_index < len(bi_list): + b = bi_list[leave_index] + if not b.is_sure: + break + if b.high >= zs.zd and b.low <= zs.zg: + added_after_leave.append(b.pre) + added_after_leave.append(b) + else: + break + leave_index += 2 + + if added_after_leave: + bis_for_zs = list(zs.bi_list) + list(added_after_leave) + bi_highs = [bi.high for bi in bis_for_zs] + bi_lows = [bi.low for bi in bis_for_zs] + zs.set_gg(max(bi_highs)) + zs.set_dd(min(bi_lows)) + zs.bi_list = bis_for_zs + bi = bis_for_zs[-1] + if bi.is_sure: + zs.set_end_bi(bi, bi.sure_time) + start_idx = start_idx + len(added_after_leave) + else: + if create_new_zs: + zs.set_end_bi(bi3, bi3.sure_time) + + if create_new_zs: + if last_zs: + last_zs.set_next(zs) + zs.set_pre(last_zs) + bi_zs_list.append(zs) + last_zs = zs + + start_idx += 4 + + # 最后一个中枢:根据 bi_list 最后一笔确认状态 + if last_zs: + last_zs.is_sure = bi_list[-1].is_sure + + if last_zs and not last_zs.is_sure: + if last_zs.bi_list and len(last_zs.bi_list) > 0: + last_bi_of_zs = last_zs.bi_list[-1] + last_bi_idx = -1 + for i, bi in enumerate(bi_list): + if bi == last_bi_of_zs: + last_bi_idx = i + break + + has_leave = False + if last_bi_idx >= 0 and last_bi_idx + 1 < len(bi_list): + for i in range(last_bi_idx + 1, len(bi_list)): + bi = bi_list[i] + if bi.is_sure: + leave = (bi.low > last_zs.zg and bi.high > last_zs.zg) or \ + (bi.high < last_zs.zd and bi.low < last_zs.zd) + if leave: + has_leave = True + break + + if has_leave: + if last_bi_of_zs.is_sure: + last_zs.set_end_bi(last_bi_of_zs, last_bi_of_zs.sure_time) + + return bi_zs_list + + + def cal_bi_zs_list_pure(self, bi_list): + bi_zs_list = [] + if len(bi_list) < 3: + return bi_zs_list + + def get_zs_range(bis): + zg = min(bi.high for bi in bis) + zd = max(bi.low for bi in bis) + return zg, zd + + def is_bi_overlap_range(bi, zg, zd): + return bi.high >= zd and bi.low <= zg + + def check_zs_position_filter(last_zs, zg, zd, bis): + if last_zs is None: + return True + if zg <= last_zs.zd: + return bis[0].dir == Chan_BI_DIR.UP and bis[-1].dir == Chan_BI_DIR.UP + if zd >= last_zs.zg: + return bis[0].dir == Chan_BI_DIR.DOWN and bis[-1].dir == Chan_BI_DIR.DOWN + return True + + def set_zs_bi_list(zs, bis): + zs.bi_list = list(bis) + for bi in zs.bi_list: + bi.set_bi_zs(zs) + zs.set_gg(max(bi.high for bi in zs.bi_list)) + zs.set_dd(min(bi.low for bi in zs.bi_list)) + zs.classify_zs() + + last_zs = None + start_idx = 0 + while start_idx + 2 < len(bi_list): + bi1 = bi_list[start_idx] + bi2 = bi_list[start_idx + 1] + bi3 = bi_list[start_idx + 2] + + if not (bi1.is_sure and bi2.is_sure and bi3.is_sure): + start_idx += 1 + continue + + if not (bi1.dir != bi2.dir and bi1.dir == bi3.dir): + start_idx += 1 + continue + + zg, zd = get_zs_range([bi1, bi2, bi3]) + if zg <= zd: + start_idx += 1 + continue + + bis_for_zs = [bi1, bi2, bi3] + extend_idx = start_idx + 3 + while extend_idx + 1 < len(bi_list): + leave_bi = bi_list[extend_idx] + back_bi = bi_list[extend_idx + 1] + if not (leave_bi.is_sure and back_bi.is_sure): + break + if not is_bi_overlap_range(back_bi, zg, zd): + break + bis_for_zs.append(leave_bi) + bis_for_zs.append(back_bi) + extend_idx += 2 + + if not check_zs_position_filter(last_zs, zg, zd, bis_for_zs): + start_idx += 1 + continue + + zs_dir = Chan_ZS_DIR.UP if bi1.dir == Chan_BI_DIR.DOWN else Chan_ZS_DIR.DOWN + zs = ChanBIZS(bi1, len(bi_zs_list), zs_dir) + zs.set_zg(zg) + zs.set_zd(zd) + + set_zs_bi_list(zs, bis_for_zs) + zs.set_end_bi(bis_for_zs[-1], bis_for_zs[-1].sure_time) + + if last_zs: + last_zs.set_next(zs) + zs.set_pre(last_zs) + + bi_zs_list.append(zs) + last_zs = zs + start_idx = start_idx + len(bis_for_zs) + + # 与 cal_bi_zs_list 一致:最后一笔未确认时末中枢标为未完成;若其后已出现确认的离开笔,仍按离开前最后一笔确认中枢结束 + if last_zs: + last_zs.is_sure = bi_list[-1].is_sure + + if last_zs and not last_zs.is_sure: + if last_zs.bi_list and len(last_zs.bi_list) > 0: + last_bi_of_zs = last_zs.bi_list[-1] + last_bi_idx = -1 + for i, bi in enumerate(bi_list): + if bi == last_bi_of_zs: + last_bi_idx = i + break + + has_leave = False + if last_bi_idx >= 0 and last_bi_idx + 1 < len(bi_list): + for i in range(last_bi_idx + 1, len(bi_list)): + bi = bi_list[i] + if bi.is_sure: + leave = (bi.low > last_zs.zg and bi.high > last_zs.zg) or \ + (bi.high < last_zs.zd and bi.low < last_zs.zd) + if leave: + has_leave = True + break + + if has_leave: + if last_bi_of_zs.is_sure: + last_zs.set_end_bi(last_bi_of_zs, last_bi_of_zs.sure_time) + + return bi_zs_list + + def get_zs_list(self, bi_list, seg_list): + """兼容历史 API:线段中枢列表。""" + return self.get_seg_zs_list(seg_list) + + def calculate_seg_zs(self, seg_list): + return self.get_seg_zs_list(seg_list) + + def get_seg_zs_list(self, seg_list): + """ + 根据缠论线段中枢定义计算中枢 + 从第4根线段开始(索引3),每3根线段为一组检查 + 上涨中枢:后中枢 zd > 前中枢 zg(不重叠上移) + 下跌中枢:后中枢 zg < 前中枢 zd(不重叠下移) + 盘整/扩张:后中枢与前中枢整体区间(GG/DD)有交集 + 中枢可按两段一组继续扩展到5根、7根... + """ + zs_list = [] + if len(seg_list) < 3: + return zs_list + + last_zs = None + + # 从第4根线段开始(索引3),每3根为一组 + start_idx = 3 + + while start_idx < len(seg_list): + # 取连续3个线段 + if start_idx + 2 >= len(seg_list): + break + + seg1 = seg_list[start_idx] + seg2 = seg_list[start_idx + 1] + seg3 = seg_list[start_idx + 2] + + # 三个线段都必须是已确认的 + if not (seg1.is_sure and seg2.is_sure and seg3.is_sure): + start_idx += 1 + continue + + # 计算这3个线段的中枢区间 + zg = min(seg1.high, seg2.high, seg3.high) + zd = max(seg1.low, seg2.low, seg3.low) + + if zg <= zd: + start_idx += 1 + #print(seg1.start_bi.start_klc.end_time, "not valid", zg, zd) + continue + + # 判断中枢类型(按注释定义) + # 上涨中枢:后中枢 zd > 前中枢 zg(不重叠上移) + # 下跌中枢:后中枢 zg < 前中枢 zd(不重叠下移) + # 盘整/扩张:后中枢与前中枢区间有交集 + if last_zs is None: + # 第一个中枢仅按线段形态判定方向 + if seg1.dir == Chan_SEG_DIR.DOWN: + # 下跌+上涨+下跌,对应上涨中枢 + zs_dir = Chan_ZS_DIR.UP + valid = (seg2.dir == Chan_SEG_DIR.UP and seg3.dir == Chan_SEG_DIR.DOWN) + else: + # 上涨+下跌+上涨,对应下跌中枢 + zs_dir = Chan_ZS_DIR.DOWN + valid = (seg2.dir == Chan_SEG_DIR.DOWN and seg3.dir == Chan_SEG_DIR.UP) + else: + is_up_zs = zd > last_zs.zg + is_down_zs = zg < last_zs.zd + + if is_up_zs: + # 不重叠上移 + zs_dir = Chan_ZS_DIR.UP + valid = (seg1.dir == Chan_SEG_DIR.DOWN and seg2.dir == Chan_SEG_DIR.UP and seg3.dir == Chan_SEG_DIR.DOWN) + elif is_down_zs: + # 不重叠下移 + zs_dir = Chan_ZS_DIR.DOWN + valid = (seg1.dir == Chan_SEG_DIR.UP and seg2.dir == Chan_SEG_DIR.DOWN and seg3.dir == Chan_SEG_DIR.UP) + create_new_zs = False + # 验证是否有效 + if not valid: + # 如果新中枢和前一个中枢的中枢区间有重叠,不行成新中枢需要合并两个中枢 + is_in_last_zs = (zd > last_zs.zd and zd < last_zs.zg) or (zg < last_zs.zg and zg > last_zs.zd) or (zg > last_zs.zg and zd < last_zs.zd) or (zg < last_zs.zg and zd > last_zs.zd) + if is_in_last_zs: + #print(seg1.start_time, "New zs is in last zs, not valid") + last_zs.extend_zs(seg_list[last_zs.seg_list[-1].index:(seg3.index + 1)]) + create_new_zs = False + else: + start_idx += 1 + continue + else: + create_new_zs = True + if last_zs and create_new_zs: + last_seg = last_zs.seg_list[-1] + last_bi = last_seg.end_bi + if last_bi: + last_zs.is_sure = True + last_zs.set_end_klc(last_bi.end_klc, last_bi.sure_time, 0, last_seg) + last_zs.set_end_seg(last_seg) + zs = last_zs + if create_new_zs: + # 创建新中枢 + gg = max(seg1.high, seg2.high, seg3.high) + dd = min(seg1.low, seg2.low, seg3.low) + + zs = ChanZS(seg1, len(zs_list), zs_dir) + zs.set_zg(zg) + zs.set_zd(zd) + zs.set_gg(gg) + zs.set_dd(dd) + zs.is_sure = False + zs.seg_list = [seg1, seg2, seg3] + # 若第二线段与 [zd,zg] 重叠(如离开后回抽回到前中枢)则并入扩展 + added_after_leave = [] + leave_index = start_idx + 4 + is_break = False + while leave_index < len(seg_list): + s = seg_list[leave_index] + if not s.is_sure: + break + sh = max(s.start_bi.high, s.end_bi.high) if s.end_bi else s.start_bi.high + sl = min(s.start_bi.low, s.end_bi.low) if s.end_bi else s.start_bi.low + if sh >= zs.zd and sl <= zs.zg: + added_after_leave.append(s.pre) + added_after_leave.append(s) + leave_index += 2 + else: + next_seg = s.next + if next_seg and next_seg.is_sure: + if next_seg.dir == Chan_SEG_DIR.UP: + if next_seg.high <= zs.zg and next_seg.low >= zs.zd: + leave_index += 2 + continue + else: + is_break = True + else: + if next_seg.low >= zs.zd and next_seg.low <= zs.zg: + leave_index += 2 + continue + else: + is_break = True + else: + break + if is_break: + break + if added_after_leave: + #print(len(added_after_leave)) + segs_for_zs = list(zs.seg_list) + list(added_after_leave) + seg_highs = [s.high for s in segs_for_zs] + seg_lows = [s.low for s in segs_for_zs] + zs.set_gg(max(seg_highs)) + zs.set_dd(min(seg_lows)) + zs.seg_list = segs_for_zs + seg = segs_for_zs[-1] + #if seg.end_bi: + #zs.set_end_klc(seg.end_bi.end_klc, seg.sure_time, 0, seg) + #zs.set_end_seg(seg) + #zs.is_sure = True + start_idx = start_idx + len(added_after_leave) + if last_zs and last_zs.index != zs.index: + last_zs.set_next(zs) + zs.set_pre(last_zs) + + zs_list.append(zs) + last_zs = zs + + # 移动到下一组 + start_idx += 4 + if last_zs: + last_zs.is_sure = seg_list[-1].is_sure + """ + # 处理最后一个未确认的中枢 - 不自动扩展,保持未完成状态 + if last_zs and not last_zs.is_sure: + # 获取中枢最后一个线段的索引 + if last_zs.seg_list and len(last_zs.seg_list) > 0: + last_seg_of_zs = last_zs.seg_list[-1] + # 找到这个线段在seg_list中的索引 + last_seg_idx = -1 + for i, seg in enumerate(seg_list): + if seg == last_seg_of_zs: + last_seg_idx = i + break + + # 从中枢最后一个线段之后检查是否有离开 + has_leave = False + if last_seg_idx >= 0 and last_seg_idx + 1 < len(seg_list): + for i in range(last_seg_idx + 1, len(seg_list)): + seg = seg_list[i] + if seg.is_sure: + # 检查是否离开中枢 + leave = (seg.low > last_zs.zg and seg.high > last_zs.zg) or \ + (seg.high < last_zs.zd and seg.low < last_zs.zd) + if leave: + has_leave = True + break + + if not has_leave: + # 没有离开,保持未完成状态 + pass + else: + # 有离开,确认中枢 + if last_seg_of_zs.end_bi: + #print(last_seg_of_zs.start_time, "last_seg_of_zs.end_time", last_seg_of_zs.end_time) + last_zs.set_end_klc(last_seg_of_zs.end_bi.end_klc, last_seg_of_zs.sure_time, 0, last_seg_of_zs) + last_zs.set_end_seg(last_seg_of_zs) + last_zs.is_sure = True + """ + return zs_list + + + def get_big_zs_list(self, zs_list): + """ + 中枢扩张:将区间重叠的连续中枢合并为大级别中枢,便于显示更大级别的震荡区间。 + 重叠定义:两中枢 [zd,zg] 有交集,即 (zs_i.zg >= zs_j.zd and zs_i.zd <= zs_j.zg)。 + """ + big_list = [] + if len(zs_list) < 2: + return big_list + i = 0 + while i < len(zs_list): + group = [zs_list[i]] + j = i + 1 + while j < len(zs_list): + cur = zs_list[j] + # 与当前组内任一中枢有重叠即算扩张(通常只需与组内最后一个比) + last_in_group = group[-1] + overlap = (last_in_group.zg >= cur.zd and last_in_group.zd <= cur.zg) + if overlap: + group.append(cur) + j += 1 + else: + break + if len(group) >= 2: + big = ChanZS_Big(group) + big.index = len(big_list) + big_list.append(big) + i = j if len(group) >= 2 else i + 1 + return big_list + diff --git a/chanlun/pipeline/orchestrator.py b/chanlun/pipeline/orchestrator.py new file mode 100644 index 0000000..abde109 --- /dev/null +++ b/chanlun/pipeline/orchestrator.py @@ -0,0 +1,188 @@ +import warnings + +# 抑制 Docker 内 technical.util 的 fillna/ffill/bfill 的 pandas FutureWarning(pandas 2.x 弃用 object 静默 downcast) +warnings.filterwarnings( + "ignore", + category=FutureWarning, + message=".*Downcasting object dtype arrays on \\.fillna.*", +) + +from datetime import timedelta +from pandas import DataFrame +from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLINE_DIR, Chan_BI_DIR, Chan_SEG_DIR, Chan_ZS_DIR, Chan_BSP_DIR, Chan_BSP_TYPE, Chan_KLC_FX, Chan_MACD_STATE, Chan_PRICE_TREND, Chan_KLU_PATTERN +from chanlun.core.ChanKLU import ChanKLU +from chanlun.core.ChanKLC import ChanKLC +from chanlun.core.ChanBI import ChanBI +from chanlun.core.ChanSBI import ChanSBI +from chanlun.core.ChanSEG import ChanSEG +from chanlun.core.ChanZS import ChanZS +from chanlun.core.ChanBSP import ChanBSP +import talib.abstract as ta +import pandas as pd +from technical.util import resample_to_interval +from decimal import Decimal +import numpy as np +from chanlun.indicators.ChanMACD import ChanMACD +from chanlun.pipeline.timeframe import TF_DF +from chanlun.analysis.ChanZone import StructureZone, StructureZoneConfig, analyze_structure_zones + +class ChanLun(): + def __init__(self): + self.time2m = 2 + self.time3m = 3 + self.time5m = 5 + self.time10m = 10 + self.time20m = 20 + self.time_m_intervals = [2, 3, 5, 10, 20] + self.time_m_symbols = ['2m', '3m', '5m', '10m', '20m'] + self.time30m = 30 + self.time45m = 45 + self.time_m15_intervals = [30, 45] + self.time_m15_symbols = ['30m', '45m'] + self.time2h = 2*60 + self.time4h = 4*60 + self.time6h = 6*60 + self.time8h = 8*60 + self.time12h = 12*60 + self.time16h = 16*60 + self.time_h_intervals = [2*60, 4*60, 6*60, 8*60, 12*60, 16*60] + self.time_h_symbols = ['2h', '4h', '6h', '8h', '12h', '16h'] + self.time2d = 2*24*60 + self.time3d = 3*24*60 + self.time_d_intervals = [2*24*60, 3*24*60] + self.time_d_symbols = ['2d', '3d'] + self.time1w = 7*24*60 + self.time2w = 14*24*60 + self.time_w_intervals = [14*24*60] + self.time_w_symbols = ['2w'] + self.time2M = 2*30*24*60 + self.time3M = 3*30*24*60 + self.time6M = 6*30*24*60 + self.time1y = 12*30*24*60 + self.time_M_intervals = [2*30*24*60, 3*30*24*60, 6*30*24*60, 12*30*24*60] + self.time_M_symbols = ['2M', '3M', '6M', '1y'] + self.time_symbols = ['1m', '2m', '3m', '5m', '10m', '15m', '20m', '30m', '45m','1h', '2h', '4h', '6h', '8h', '12h', '16h', '1d', '2d', '3d'] + self.tf_df_dict = {} + self.ema_symbols = ['5m', '15m', '30m', '45m', '1h', '2h', '4h', '8h', '12h', '1d', '2d', '3d'] + self.tf_df = TF_DF() + def init_data(self, dataframe, intervals, timeframes): + for index in range(0, len(intervals)): + timeframe = timeframes[index] + interval = intervals[index] + self.tf_df_dict[timeframe] = TF_DF(dataframe, interval, timeframe) + def init_dataframes(self, dataframe_m=None, dataframe_15m=None, dataframe_h=None, dataframe_d=None, dataframe_w=None, dataframe_M=None): + self.tf_df_dict = {} + if dataframe_m is not None: + self.tf_df_dict['1m'] = TF_DF(dataframe_m, 1, '1m') + self.init_data(dataframe_m, self.time_m_intervals, self.time_m_symbols) + if dataframe_15m is not None: + self.tf_df_dict['15m'] = TF_DF(dataframe_15m, 1, '15m') + self.init_data(dataframe_15m, self.time_m15_intervals, self.time_m15_symbols) + if dataframe_h is not None: + self.tf_df_dict['1h'] = TF_DF(dataframe_h, 1, '1h') + self.init_data(dataframe_h, self.time_h_intervals, self.time_h_symbols) + if dataframe_d is not None: + self.tf_df_dict['1d'] = TF_DF(dataframe_d, 1, '1d') + self.init_data(dataframe_d, self.time_d_intervals, self.time_d_symbols) + if dataframe_w is not None and False: + self.tf_df_dict['1w'] = TF_DF(dataframe_w, 1, '1w') + self.init_data(dataframe_w, self.time_w_intervals, self.time_w_symbols) + if dataframe_M is not None and False: + self.tf_df_dict['1M'] = TF_DF(dataframe_M, 1, '1M') + self.init_data(dataframe_M, self.time_M_intervals, self.time_M_symbols) + def get_ema52_dict(self): + if len(self.tf_df_dict) > 0: + return {key: self.tf_df_dict[key].get_ema52() for key in self.ema_symbols} + return None + def get_ema24_dict(self): + if len(self.tf_df_dict) > 0: + return {key: self.tf_df_dict[key].get_ema24() for key in self.ema_symbols} + return None + def get_current_klc_dict(self): + if len(self.tf_df_dict) > 0: + return {key: self.tf_df_dict[key].get_current_klc() for key in self.ema_symbols} + return None + def get_tf_df_by_timeframe(self, timeframe): + if timeframe in self.tf_df_dict: + return self.tf_df_dict[timeframe] + return None + def check_price_ema52(self, price): + key_list = [] + if len(self.tf_df_dict) > 0: + ema52_dict = self.get_ema52_dict() + for key in self.ema_symbols: + if ema52_dict[key] is not None: + if abs(price - ema52_dict[key]) < 100: + key_list.append(key) + return key_list + def get_ema_bsp(self, long_tf='1h', short_tf='15m'): + if long_tf in self.tf_df_dict and short_tf in self.tf_df_dict: + long_df = self.tf_df_dict[long_tf] + short_df = self.tf_df_dict[short_tf] + return long_df.get_ema_bsp(short_df) + return None + + + + + + def get_bsp_state(self, dataframe): + return self.tf_df.get_bsp_state(dataframe) + + def get_structure_zones(self, current_price=None, config=None): + if config is None: + config = StructureZoneConfig() + return analyze_structure_zones( + self.tf_df_dict, + self.ema_symbols, + current_price=current_price, + config=config, + ) + # TF_DF methods ------------------------------------------ + def get_ema_state(self, dataframe): + return self.tf_df.get_ema_state(dataframe) + def get_klu_state(self, dataframe): + return self.tf_df.get_klu_state(dataframe) + def check_fx(self, klc): + return self.tf_df.check_fx(klc) + def add_indicators1(self, df): + return self.tf_df.add_indicators(df) + def get_bi_list(self, dataframe): + return self.tf_df.get_bi_list(dataframe) + def get_kl_data(self, dataframe:DataFrame): + return self.tf_df.cal_kl_data(dataframe) + def cal_volume_ratio(self, dataframe, window=10): + return self.tf_df.cal_volume_ratio(dataframe, window) + def calculate_seg_zs(self, bi_list, seg_list): + return self.get_seg_zs_list(bi_list, seg_list) + def get_seg_list(self, bi_list): + return self.tf_df.get_seg_list(bi_list) + def cal_trend(self, klc_list): + return self.tf_df.cal_trend(klc_list) + def check_top_fx(self, last_bottom, klc): + return self.tf_df.check_top_fx(last_bottom, klc) + def check_bottom_fx(self, last_top, klc): + return self.tf_df.check_bottom_fx(last_top, klc) + def cal_bi_list(self, klc_list): + return self.tf_df.cal_bi_list(klc_list) + def find_first_bsp(self, bi_list, bi_zs_list): + return self.tf_df.find_first_bsp(bi_list, bi_zs_list) + def find_second_bsp(self, bi_list, first_bsp_list): + return self.tf_df.find_second_bsp(bi_list, first_bsp_list) + def find_all_bsp(self, bi_list, bi_zs_list): + return self.tf_df.find_all_bsp(bi_list, bi_zs_list) + def get_zs_list(self, bi_list, seg_list): + return self.tf_df.get_zs_list(bi_list, seg_list) + def cal_bi_zs(self, seg_list): + return self.tf_df.cal_bi_zs(seg_list) + def cal_bi_zs_list(self, bi_list): + #return self.tf_df.cal_bi_zs(bi_list) + return self.tf_df.cal_bi_zs_list(bi_list) + def get_bi_zs_list(self, bi_list): + return self.tf_df.get_bi_zs_list(bi_list) + def get_decimal(self, value): + return Decimal("{:.2f}".format(value)) + def get_klc_list(self, klu_list): + return self.tf_df.get_klc_list(klu_list) + def get_klu_list(self, dataframe): + return self.tf_df.cal_klu_pattern(self.get_kl_data(dataframe)) \ No newline at end of file diff --git a/chanlun/pipeline/timeframe.py b/chanlun/pipeline/timeframe.py new file mode 100644 index 0000000..e00cfd5 --- /dev/null +++ b/chanlun/pipeline/timeframe.py @@ -0,0 +1,78 @@ +from datetime import timedelta + +import numpy as np +import pandas as pd +import talib.abstract as ta +from pandas import DataFrame +from technical.util import resample_to_interval + +from chanlun.core.ChanBI import ChanBI +from chanlun.core.ChanBIZS import ChanBIZS +from chanlun.core.ChanBSP import ChanBSP +from chanlun.core.ChanEnum import ( + Chan_BI_DIR, + Chan_BSP_DIR, + Chan_BSP_TYPE, + Chan_FX_TYPE, + Chan_K_DIR, + Chan_KLC_FX, + Chan_KLC_STATE, + Chan_KLINE_DIR, + Chan_KLU_PATTERN, + Chan_PRICE_TREND, + Chan_SEG_DIR, + Chan_ZS_DIR, +) +from chanlun.core.ChanKLC import ChanKLC +from chanlun.core.ChanKLU import ChanKLU +from chanlun.core.ChanSBI import ChanSBI +from chanlun.core.ChanSEG import ChanSEG +from chanlun.core.ChanZS import ChanZS, ChanZS_Big +from chanlun.indicators.ChanMACD import ChanMACD +from chanlun.pipeline.builders.bi import BiBuilderMixin +from chanlun.pipeline.builders.bsp import BspBuilderMixin +from chanlun.pipeline.builders.indicators import IndicatorsBuilderMixin +from chanlun.pipeline.builders.kline import KlineBuilderMixin +from chanlun.pipeline.builders.seg import SegBuilderMixin +from chanlun.pipeline.builders.zs import ZsBuilderMixin + +class TF_DF(IndicatorsBuilderMixin, KlineBuilderMixin, BiBuilderMixin, SegBuilderMixin, ZsBuilderMixin, BspBuilderMixin): + def __init__(self, df=None, interval=0, timeframe=None): + if df is not None: + self.init_TF_DF(df, interval, timeframe) + def init_TF_DF(self, df, interval, timeframe): + self.timeframe = timeframe + self.interval = interval + # 检查 DataFrame 是否为空或没有 date 列 + if df is None or df.empty: + raise ValueError(f"DataFrame for {timeframe} is empty. Please download data first.") + if 'date' not in df.columns: + raise ValueError(f"DataFrame for {timeframe} missing 'date' column. Columns: {df.columns.tolist()}") + # interval=1 时不需要重采样 + if interval == 1: + self.dataframe = df.copy() + else: + self.dataframe = resample_to_interval(df, interval) + #print(self.timeframe, len(self.dataframe)) + self.dataframe = self.add_indicators(self.dataframe) + self.klu_list = [] + self.klc_list = [] + self.bi_list = [] + self.zs_list = [] + self.bsp_list = [] + self.seg_list = [] + self.klc_fx_list = [] + self.klu_list = self.cal_kl_data(self.dataframe) + self.klc_list = self.get_klc_list(self.klu_list) + self.bi_list = self.cal_bi_list(self.klc_list) + self.seg_list = self.get_seg_list(self.bi_list) + self.zs_list = self.get_zs_list(self.bi_list, self.seg_list) + self.big_zs_list = self.get_big_zs_list(self.zs_list) + self.chanmacd = ChanMACD(self.klu_list) + self.klu_list = self.chanmacd.cal_macd_state() + + + def get_current_klc(self): + if len(self.klc_list) > 0: + return self.klc_list[-2] + return None diff --git a/docs/ADR/ADR-001-package-layout.md b/docs/ADR/ADR-001-package-layout.md new file mode 100644 index 0000000..bd9393a --- /dev/null +++ b/docs/ADR/ADR-001-package-layout.md @@ -0,0 +1,22 @@ +# ADR-001: 包布局与兼容 shim + +**Status:** Accepted +**Date:** 2026-08-05 +**ECR:** ECR-001 + +## Context + +根目录扁平模块被 strategies 与 web 通过模块名直接 import;完全改名会破坏 Freqtrade 策略。需要正式包边界,同时零改 `strategies/`。 + +## Decision + +1. 正式包名:`chanlun`(`core` / `pipeline` / `indicators` / `analysis`)。 +2. 根目录保留同名 shim 文件,再导出公共符号。 +3. Web 使用 Flask blueprints + services;前端 JS 模块化,不引入 TS 构建。 +4. `TF_DF` 保留门面类名与公开方法,内部委托 builders。 + +## Consequences + +- 策略无需修改。 +- 长期可逐步引导新代码 `from chanlun import ...`。 +- shim 需保持至策略侧显式迁移(另立 ECR)。 diff --git a/docs/CHANGELOG/CHANGELOG.md b/docs/CHANGELOG/CHANGELOG.md new file mode 100644 index 0000000..4e6361d --- /dev/null +++ b/docs/CHANGELOG/CHANGELOG.md @@ -0,0 +1,30 @@ +# CHANGELOG + +## 2026-08-05 — ECR-001 + +### Added + +- 正式包 `chanlun/`(core / pipeline / builders / indicators / analysis) +- ESS 文档树 `docs/`(PROFILE / ECR-001 / SPEC / ADR / HANDOFF) +- Web `config.py`、`services/`、`api/` blueprints +- 前端 `web/static/js/app/` 模块 +- Golden 回归 `tests/generate_golden.py` + fixtures + +### Changed + +- 根目录 `Chan*.py` / `TF_DF.py` 改为兼容 shim +- `TF_DF` 实现拆至 builders,门面签名保持 +- `web/app.py` 瘦身为 `create_app()` +- `index.html` 去掉巨型 inline 业务 JS +- HTTP 代理改为环境变量配置 + +### Fixed + +- 恢复缺失的 `TF_DF.get_zs_list`(委托 `get_seg_zs_list`) + +### 2026-08-05 — ECR-001 follow-through + +- strategies / web / tests / examples → `from chanlun...` 导入;绝对 `sys.path` 改为相对仓库根 +- `chart.js` → `chart_format/view/tv/sync/tables.js` +- `chan_engine` / `datafeed` / `chan_indicator` 迁入 `static/js/app/`,`chan_tv` 与主站共用 +- 根目录 shim 保留作兼容;正式入口为 `chanlun` 包 diff --git a/docs/ECR/ECR-001-chan-web-restructure.md b/docs/ECR/ECR-001-chan-web-restructure.md new file mode 100644 index 0000000..92cb438 --- /dev/null +++ b/docs/ECR/ECR-001-chan-web-restructure.md @@ -0,0 +1,70 @@ +# ECR-001 + +**Title:** 缠论引擎包化 + Web 分层重构(行为冻结) +**Status:** Approved +**Date:** 2026-08-05 +**Change Level:** L3 + +## Change + +将根目录扁平 `Chan*.py` / `TF_DF.py` 包化为 `chanlun/`,拆分 `web/app.py` 与巨型 `index.html` inline JS;算法与 `/api/analyze` 契约冻结;`config/` / `strategies/` 不动。 + +## Motivation + +根目录与 Web 单体过大、职责混杂,难以维护与测试;需在不影响 Freqtrade 策略导入的前提下重整结构。 + +## Scope + +### Allowed + +- 创建 `chanlun/`(core / pipeline / indicators / analysis)与根目录兼容 shim +- 拆分 `TF_DF` 为 builders + 门面(公开方法签名不变) +- Web:`config` + `services` + `api` blueprints;配置外置(proxy / DATA_SERVICE) +- 前端:`index.html` 业务 JS 外置到 `static/js/app/`;隔离未接线 TS/TSX +- 示例与笔记迁入 `examples/` / `docs/notes/` +- Golden / 契约回归测试 + +### Forbidden + +- 修改笔 / 线段 / 中枢 / 买卖点算法语义 +- 破坏 `/api/analyze` JSON 字段(可增不可删) +- 修改 `config/`、`strategies/` +- 引入 Vite/React/TS 构建 +- 重做 UI 视觉或更换 TradingView + +## Risk + +| Risk | Mitigation | +|------|------------| +| 策略 import 断裂 | 根 shim + 冒烟导入 | +| 拆文件改算法 | 仅搬移;golden fixture | +| 前端事件遗漏 | 按块抽取 + 手工/冒烟 | +| API 字段漂移 | analyze 契约测试 | + +## Acceptance Criteria + +- [x] `from ChanLun import ChanLun` / `from ChanEnum import ...` 仍可用 +- [x] Golden:同一 fixture 下 bi/seg/zs/bsp 序列化结果与基线一致 +- [x] `/api/analyze` 关键字段集合兼容(契约冒烟) +- [x] `web/app.py` 瘦身为 factory;业务在 services/api +- [x] `index.html` 不再含大体量业务 inline JS +- [x] ESS docs 齐全;TEST_REPORT / IMPLEMENTATION_REPORT / CHANGELOG +- [x] `config/`、`strategies/` 无内容变更 + +**Status:** Done(待 Reviewer 签核) + +## Rollback + +单分支 / 单 PR 回滚;shim 期可整体 `git revert`。 + +## Risk Review + +- Path: `docs/RISK_REVIEW/ECR-001.md` — N/A(不改交易语义) + +## Linked + +- PRD / PRODUCT_SPEC: `docs/PRODUCT_SPEC/ECR-001-restructure.md` +- ENGINEERING_SPEC: `docs/ENGINEERING_SPEC/ECR-001-restructure.md` +- ADR: `docs/ADR/ADR-001-package-layout.md` +- EXPERIMENT: N/A +- TRACEABILITY: Yes diff --git a/docs/ENGINEERING_SPEC/ECR-001-restructure.md b/docs/ENGINEERING_SPEC/ECR-001-restructure.md new file mode 100644 index 0000000..c35932c --- /dev/null +++ b/docs/ENGINEERING_SPEC/ECR-001-restructure.md @@ -0,0 +1,105 @@ +# Engineering Spec: ECR-001 引擎 + Web 重构 + +## Related + +| Doc | Link | +|-----|------| +| ECR | `docs/ECR/ECR-001-chan-web-restructure.md` | +| PRODUCT_SPEC | `docs/PRODUCT_SPEC/ECR-001-restructure.md` | +| ADR | `docs/ADR/ADR-001-package-layout.md` | + +## Module Design + +### `chanlun/core/*` + +| 项 | 值 | +|----|-----| +| Responsibility | K 线单元、合并 K、笔/段/中枢/买卖点数据结构与枚举 | +| Can | 表达缠论基础对象 | +| Cannot | 拉行情、写 Flask 路由 | +| Layer | domain | + +### `chanlun/pipeline/*` + +| 项 | 值 | +|----|-----| +| Responsibility | `ChanLun` 编排、`TF_DF` 门面、builders | +| Can | 从 DataFrame 构建结构 | +| Cannot | 改公开方法语义 | +| Layer | application | + +### `chanlun/indicators/*` / `chanlun/analysis/*` + +| 项 | 值 | +|----|-----| +| Responsibility | MACD 状态、Zone/Classifier/Trend 等分析 | +| Layer | domain / application | + +### Root shims (`ChanLun.py`, `ChanEnum.py`, …) + +| 项 | 值 | +|----|-----| +| Responsibility | 转发到 `chanlun.*`,兼容 strategies | +| Cannot | 含业务逻辑 | + +### `web/config.py` + `web/services/*` + `web/api/*` + +| 项 | 值 | +|----|-----| +| Responsibility | 配置、行情、分析、序列化、HTTP | +| Cannot | 修改缠论算法 | + +### `web/static/js/app/*` + +| 项 | 值 | +|----|-----| +| Responsibility | 图表、overlay、UI、API 客户端 | +| Cannot | 本轮引入 React 构建 | + +## Interfaces + +```python +class TF_DF: + # 公开方法签名保持与重构前一致 + def get_kl_data(self, dataframe): ... + def get_bi_list(self, dataframe): ... + # ... 门面委托 builders + +class ChanLun: + def init_dataframes(...): ... +``` + +`/api/analyze`:查询参数与 JSON 顶层字段兼容。 + +## Algorithm Sketch + +1. 物理迁移模块到 `chanlun/`,修正 import +2. 根 shim 再导出 +3. `TF_DF` 方法体迁移到 builders,门面调用 +4. Web 按职责拆文件,路由注册不变 +5. HTML 抽 JS;未接线 TS 入 `_unused` + +## Error Handling + +| Case | Behavior | +|------|----------| +| 行情失败 | 保持现有 JSON error | +| 分析异常 | 保持现有日志与错误返回 | + +## Config / Constants + +| Name | Source | +|------|--------| +| DATA_SERVICE_URL | env / config | +| HTTP(S)_PROXY | env / config(替代硬编码 7897) | +| MACD periods | config | + +## Test Plan Pointer + +- `tests/test_golden_pipeline.py` + `tests/fixtures/golden_*` +- `web/tests/` analyze 契约冒烟 +- `python -c "from ChanLun import ChanLun"` + +## Migration / API Impact + +无破坏性 API 变更;仅内部路径变化。 diff --git a/docs/HANDOFF/ECR-001-architect-to-engineer.md b/docs/HANDOFF/ECR-001-architect-to-engineer.md new file mode 100644 index 0000000..f505a2d --- /dev/null +++ b/docs/HANDOFF/ECR-001-architect-to-engineer.md @@ -0,0 +1,38 @@ +# Handoff + +**From:** ARCHITECT +**To:** ENGINEER +**ECR:** ECR-001 +**State:** design → coding +**Date:** 2026-08-05 + +## Artifacts + +- [x] ECR (Approved) +- [x] PRODUCT_SPEC +- [x] ENGINEERING_SPEC +- [x] RISK_REVIEW (N/A) +- [x] ADR-001 +- [x] TRACEABILITY +- [ ] TEST_REPORT / CODE_REVIEW / EXP (engineer / reviewer) + +## Restrictions — Do not modify + +- `config/`、`strategies/` 内容 +- 缠论算法语义、`/api/analyze` 破坏性变更 +- 计划文件本身 + +## Goal for receiver + +按 ENGINEERING_SPEC 完成包化、TF_DF 拆分、Web 分层、前端模块化、golden 测试与文档收尾。 + +## Done for this hop + +- [x] ESS docs 落盘 +- [x] ECR Approved(计划实施授权) + +## References + +- `docs/ECR/ECR-001-chan-web-restructure.md` +- `docs/ENGINEERING_SPEC/ECR-001-restructure.md` +- Plan: ESS Chan Refactor(1B+2B) diff --git a/docs/HANDOFF/ECR-001-engineer-to-reviewer.md b/docs/HANDOFF/ECR-001-engineer-to-reviewer.md new file mode 100644 index 0000000..eee9eef --- /dev/null +++ b/docs/HANDOFF/ECR-001-engineer-to-reviewer.md @@ -0,0 +1,25 @@ +# Handoff + +**From:** ENGINEER +**To:** REVIEWER +**ECR:** ECR-001 +**State:** coding → review +**Date:** 2026-08-05 + +## Artifacts + +- [x] ECR (Approved / Done pending review) +- [x] PRODUCT_SPEC / ENGINEERING_SPEC / ADR / RISK N/A +- [x] IMPLEMENTATION_REPORT +- [x] TEST_REPORT +- [ ] CODE_REVIEW + +## Goal for receiver + +对照 ECR-001 Acceptance 做代码审阅;确认 strategies/config 无 diff;golden 与路由契约通过。 + +## References + +- `docs/IMPLEMENTATION_REPORT/ECR-001.md` +- `docs/TEST_REPORT/ECR-001.md` +- `docs/CHANGELOG/CHANGELOG.md` diff --git a/docs/IDEA/IDEA-001-restructure.md b/docs/IDEA/IDEA-001-restructure.md new file mode 100644 index 0000000..e99d928 --- /dev/null +++ b/docs/IDEA/IDEA-001-restructure.md @@ -0,0 +1,3 @@ +# IDEA: 引擎与 Web 结构重整 + +根目录与 `web/app.py` / `index.html` 过于臃肿,需在行为冻结前提下包化与分层。见 ECR-001。 diff --git a/docs/IMPLEMENTATION_REPORT/ECR-001.md b/docs/IMPLEMENTATION_REPORT/ECR-001.md new file mode 100644 index 0000000..ac67d84 --- /dev/null +++ b/docs/IMPLEMENTATION_REPORT/ECR-001.md @@ -0,0 +1,31 @@ +# IMPLEMENTATION_REPORT — ECR-001 + +**Date:** 2026-08-05 +**Role:** ENGINEER +**Status:** Complete + +## Summary + +完成缠论引擎包化、`TF_DF` builders 拆分、Web services/blueprints 分层、前端 `index.html` JS 外置模块化;算法与 `/api/analyze` 契约冻结;`config/` / `strategies/` 无改动。 + +## Changes + +| Area | Result | +|------|--------| +| `chanlun/` | core / pipeline(+builders) / indicators / analysis | +| Root shims | `ChanLun.py` `TF_DF.py` `ChanEnum.py` 等再导出 | +| L1 fix | 恢复 `TF_DF.get_zs_list` → `get_seg_zs_list` | +| Web | `config.py` `services/*` `api/*` `create_app()` | +| Frontend | `static/js/app/*`;未接线 TSX/TS → `_unused/` | +| Examples/notes | `examples/` `docs/notes/` | +| Tests | golden pipeline + analyze contract smoke | + +## Config + +- 代理:`CHAN_HTTP_PROXY` / `HTTP_PROXY`(默认不强制 7897) +- `DATA_SERVICE_URL` / `FLASK_PORT` + +## Follow-ups(非本 ECR) + +- (已完成)策略与 web/tests 改为 `from chanlun import ...`;根 shim 仅兼容旧脚本 +- (已完成)`chart.js` 拆为 format/view/tv/sync/tables;chan_tv 共用 `static/js/app/` diff --git a/docs/PRODUCT_SPEC/ECR-001-restructure.md b/docs/PRODUCT_SPEC/ECR-001-restructure.md new file mode 100644 index 0000000..984beb8 --- /dev/null +++ b/docs/PRODUCT_SPEC/ECR-001-restructure.md @@ -0,0 +1,22 @@ +# PRODUCT_SPEC: ECR-001 结构重构 + +## Goal + +在不改变缠论计算结果与 Web 分析 API 对外语义的前提下,提升代码可维护性。 + +## User-visible behavior + +| 项 | 期望 | +|----|------| +| 图表页 /chan_tv / 首页 | 功能与交互保持 | +| `/api/analyze` | 字段兼容;可增不可删 | +| Freqtrade 策略 | 无需改 import 路径即可加载引擎 | + +## Non-goals + +- 新买卖点规则、新 UI、新行情后端、策略超参优化 + +## Acceptance(产品视角) + +1. 用户打开 Web 图表仍能看到笔/段/中枢/买卖点叠加。 +2. 既有策略代码不因本次重构而修改。 diff --git a/docs/PROJECT_PROFILE.md b/docs/PROJECT_PROFILE.md new file mode 100644 index 0000000..f300c79 --- /dev/null +++ b/docs/PROJECT_PROFILE.md @@ -0,0 +1,39 @@ +# Project Profile — chan (缠论) + +> Agent 第一次读这个文件。不要重新猜技术栈;偏离见 Forbidden + ADR。 + +## Type +Trading System(缠论分析引擎 + 可视化 Web;Freqtrade 策略目录独立、本 ECR 不改) + +## Stack Lock + +| Layer | Choice | +|-------|--------| +| Language | Python 3 | +| Engine package | `chanlun/` | +| Backend | Flask | +| Realtime | 无(请求式分析) | +| Database | 无(行情外部 DATA_SERVICE / CCXT / A 股接口) | +| Frontend | TradingView Charting Library + 原生 JS | +| Deployment | gunicorn / systemd(web) | +| Architecture Pattern | 包化引擎 + Web services/blueprints + 根目录兼容 shim | + +## Forbidden(无 ADR / 无 ECR 禁止) + +- 无 ADR 修改笔 / 线段 / 中枢 / 买卖点算法语义 +- 无 ECR 修改 `config/`、`strategies/` 策略逻辑或参数 +- 无 ECR 破坏 `/api/analyze` JSON 契约(可增不可删) +- 引入 Kafka / MongoDB / 微服务拆分(除非新 ADR) +- 本轮引入 Vite/React/TS 构建流水线 + +## Active anchors + +- ECR: ECR-001 +- EXP: N/A(本变更不改交易行为语义) +- TRACEABILITY: `docs/TRACEABILITY.md` + +## Pointers + +- Rules: `PROJECT_RULES.md` +- Stack detail: `TECH_STACK.md` +- Memory: `AGENT_MEMORY.md`(若存在) diff --git a/docs/PROJECT_RULES.md b/docs/PROJECT_RULES.md new file mode 100644 index 0000000..d1b3c7b --- /dev/null +++ b/docs/PROJECT_RULES.md @@ -0,0 +1,24 @@ +# Project Rules — chan + +## Scope boundaries + +1. `config/`、`strategies/`:Freqtrade 策略资产,默认只读;任何改动需独立 ECR。 +2. `chanlun/`:缠论引擎正式包;算法变更需 L2+ ECR + 回归基线。 +3. 根目录 `Chan*.py` / `TF_DF.py`:兼容 shim,保持 `from ChanLun import ChanLun` 可用。 +4. `web/`:可视化与 API;契约冻结于 ECR-001。 + +## Change levels + +- L0 docs / L1 bugfix / L2 behavior / L3 architecture — 见 ESS CHANGE_MANAGEMENT。 +- 结构重构默认 L3;若触及缠论识别结果 → 升为 L2 并要求 RISK_REVIEW + EXP。 + +## Compatibility + +- 正式导入:`from chanlun import ChanLun, TF_DF`;`from chanlun.core.ChanEnum import ...` +- 根目录 `Chan*.py` / `TF_DF.py` 为兼容 shim(旧脚本可用);新代码与 strategies 应使用 `chanlun` 包 +- Web URL 与 `/api/analyze` 字段名/结构保持兼容 +- 策略文件通过 `sys.path.append(仓库根)` 保证能找到 `chanlun` 包 + +## Language + +- 代码注释与提交说明优先中文。 diff --git a/docs/RISK_REVIEW/ECR-001.md b/docs/RISK_REVIEW/ECR-001.md new file mode 100644 index 0000000..eb14e04 --- /dev/null +++ b/docs/RISK_REVIEW/ECR-001.md @@ -0,0 +1,6 @@ +# RISK_REVIEW: ECR-001 + +**Status:** N/A +**Reason:** 本变更仅软件结构重构;缠论识别与买卖点语义冻结,不改变交易决策逻辑。无需 Experiment / Live Promote 门禁。 + +若后续发现 golden 差异表明算法被意外改动,升级为 L2 并重开 RISK_REVIEW。 diff --git a/docs/STATE/CURRENT.md b/docs/STATE/CURRENT.md new file mode 100644 index 0000000..5007735 --- /dev/null +++ b/docs/STATE/CURRENT.md @@ -0,0 +1,10 @@ +# STATE + +**owner:** reviewer +**active_ecr:** ECR-001 +**phase:** review +**updated:** 2026-08-05 + +## Notes + +Engineer hop complete. See IMPLEMENTATION_REPORT + TEST_REPORT. diff --git a/docs/TASKS/TASK-001-ECR001.yaml b/docs/TASKS/TASK-001-ECR001.yaml new file mode 100644 index 0000000..124ed15 --- /dev/null +++ b/docs/TASKS/TASK-001-ECR001.yaml @@ -0,0 +1,29 @@ +task_id: ECR-001 +role: engineer +phase: coding +tool: cursor + +input: + - docs/PROJECT_PROFILE.md + - docs/PROJECT_RULES.md + - docs/TECH_STACK.md + - docs/ECR/ECR-001-chan-web-restructure.md + - docs/ENGINEERING_SPEC/ECR-001-restructure.md + - docs/ADR/ADR-001-package-layout.md + - docs/HANDOFF/ECR-001-architect-to-engineer.md + +output: + - chanlun/ + - root shims + - web/services web/api web/config + - web/static/js/app/ + - tests/ + docs/TEST_REPORT + IMPLEMENTATION_REPORT + CHANGELOG + +forbidden: + - redesign_scope + - modify config/ or strategies/ + - change chan algorithm semantics + - break /api/analyze contract + +next_agent: reviewer +notes: "1B+2B; behavior freeze; shim for strategies" diff --git a/docs/TECH_STACK.md b/docs/TECH_STACK.md new file mode 100644 index 0000000..85b25a9 --- /dev/null +++ b/docs/TECH_STACK.md @@ -0,0 +1,20 @@ +# Tech Stack — chan + +## Engine + +- Python, pandas, numpy, TA-Lib / technical +- 包名:`chanlun` +- 流水线:KLU → KLC → BI → SBI → SEG → ZS → BSP(`ChanLun` / `TF_DF` 门面) + +## Web + +- Flask + Jinja2 templates +- TradingView Charting Library(`web/charting_library/`) +- 前端运行时:原生 JS(`web/static/js/app/`) +- 行情:`DATA_SERVICE_URL` / CCXT / A 股数据服务 + +## Out of scope this release + +- data_provider 仓库内重建 +- React/TS 构建 +- Freqtrade config/strategies 重构 diff --git a/docs/TEST_REPORT/ECR-001.md b/docs/TEST_REPORT/ECR-001.md new file mode 100644 index 0000000..7cdf030 --- /dev/null +++ b/docs/TEST_REPORT/ECR-001.md @@ -0,0 +1,26 @@ +# TEST_REPORT — ECR-001 + +**Date:** 2026-08-05 + +## Commands + +```bash +python tests/generate_golden.py --check +python -m pytest tests/test_golden_pipeline.py web/tests/test_analyze_contract.py -q +python -c "from ChanLun import ChanLun, TF_DF; from ChanEnum import Chan_BI_DIR" +``` + +## Results + +| Case | Result | +|------|--------| +| Golden pipeline | PASS(counts klu=400 klc=208 bi=14 seg=2 zs=0 bsp=3) | +| Shim imports | PASS | +| Analyze contract keys + routes | PASS | +| pytest suite above | **5 passed** | +| `git diff config strategies` | empty(无改动) | + +## Notes + +- Golden 经 CSV 往返 + 浮点 round(10) 稳定化 +- 行情服务不可达时 metadata refresh 会警告,不影响路由注册测试 diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md new file mode 100644 index 0000000..a03924c --- /dev/null +++ b/docs/TRACEABILITY.md @@ -0,0 +1,9 @@ +# TRACEABILITY — ECR-001 + +| ECR | Requirement | Spec | Code | Test | +|-----|-------------|------|------|------| +| ECR-001 | 引擎包化 + shim | ENG-001 / ADR-001 | `chanlun/` + root shims | import smoke + golden | +| ECR-001 | TF_DF 门面拆分 | ENG-001 | `chanlun/pipeline/` | golden pipeline | +| ECR-001 | Web 分层 | ENG-001 | `web/services` `web/api` | analyze contract | +| ECR-001 | 前端模块化 | ENG-001 | `web/static/js/app/` | manual / smoke | +| ECR-001 | 策略零改动 | PROFILE | no edits under strategies/ | git diff empty | diff --git a/K线动能理论.txt b/docs/notes/K线动能理论.txt similarity index 100% rename from K线动能理论.txt rename to docs/notes/K线动能理论.txt diff --git a/chanlun.txt b/docs/notes/chanlun.txt similarity index 100% rename from chanlun.txt rename to docs/notes/chanlun.txt diff --git a/操作策略.txt b/docs/notes/操作策略.txt similarity index 100% rename from 操作策略.txt rename to docs/notes/操作策略.txt diff --git a/缠论.txt b/docs/notes/缠论.txt similarity index 100% rename from 缠论.txt rename to docs/notes/缠论.txt diff --git a/fx_strength_example.py b/examples/fx_strength_example.py similarity index 96% rename from fx_strength_example.py rename to examples/fx_strength_example.py index 40814af..5b35e22 100644 --- a/fx_strength_example.py +++ b/examples/fx_strength_example.py @@ -1,3 +1,5 @@ +import sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ @@ -5,8 +7,8 @@ 该文件展示如何使用ChanKLC类中新增的分型强度检测功能 """ -from ChanKLC import ChanKLC -from ChanEnum import Chan_FX_TYPE +from chanlun.core.ChanKLC import ChanKLC +from chanlun.core.ChanEnum import Chan_FX_TYPE import ChanKLU diff --git a/realtime_fx_example.py b/examples/realtime_fx_example.py similarity index 97% rename from realtime_fx_example.py rename to examples/realtime_fx_example.py index 8c95897..07bfb51 100644 --- a/realtime_fx_example.py +++ b/examples/realtime_fx_example.py @@ -1,3 +1,5 @@ +import sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ @@ -5,8 +7,8 @@ 解决KLC滞后问题,提供即时的分型信号 """ -from ChanKLU import ChanKLU -from ChanEnum import Chan_FX_TYPE +from chanlun.core.ChanKLU import ChanKLU +from chanlun.core.ChanEnum import Chan_FX_TYPE import pandas as pd from datetime import datetime, timedelta diff --git a/fx_strength_config.py b/fx_strength_config.py index 41e9faa..1c7bbf7 100644 --- a/fx_strength_config.py +++ b/fx_strength_config.py @@ -1,153 +1,2 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -分型强度检测配置文件 -用于调整分型强度计算的各项参数和权重 -""" - -class FxStrengthConfig: - """分型强度检测配置类""" - - def __init__(self): - # ===== 权重配置 (总分100分) ===== - self.price_difference_weight = 40 # 价格差异强度权重 - self.breakthrough_weight = 20 # 突破历史点位权重 - self.volume_weight = 15 # 成交量确认权重 - self.rsi_divergence_weight = 15 # RSI背离权重 - self.macd_divergence_weight = 10 # MACD背离权重 - - # ===== 价格差异参数 ===== - self.price_diff_multiplier = 1000 # 价格差异放大倍数 - self.max_price_score = 20 # 价格差异最高得分 - - # ===== 突破检测参数 ===== - self.breakthrough_lookback = 10 # 回看K线数量 - self.breakthrough_multiplier = 500 # 突破幅度放大倍数 - self.max_breakthrough_score = 20 # 突破最高得分 - - # ===== 成交量参数 ===== - self.volume_lookback = 5 # 计算平均成交量的回看期数 - self.volume_multiplier = 10 # 成交量放大倍数 - self.max_volume_score = 15 # 成交量最高得分 - self.min_volume_ratio = 1.0 # 最小成交量比率 - - # ===== RSI背离参数 ===== - self.rsi_divergence_divisor = 2 # RSI背离除数 - self.max_rsi_score = 15 # RSI最高得分 - - # ===== MACD背离参数 ===== - self.macd_divergence_multiplier = 100 # MACD背离放大倍数 - self.max_macd_score = 10 # MACD最高得分 - - # ===== 强度等级阈值 ===== - self.extreme_threshold = 80 # 极强分型阈值 - self.strong_threshold = 60 # 强分型阈值 - self.medium_threshold = 40 # 中等分型阈值 - self.weak_threshold = 20 # 弱分型阈值 - - # ===== 其他参数 ===== - self.min_strength = 0 # 最小强度分数 - self.max_strength = 100 # 最大强度分数 - - def get_strength_level_name(self, strength): - """根据强度分数获取等级名称""" - if strength >= self.extreme_threshold: - return "极强" - elif strength >= self.strong_threshold: - return "强" - elif strength >= self.medium_threshold: - return "中等" - elif strength >= self.weak_threshold: - return "弱" - else: - return "极弱" - - def is_strong_fractal(self, strength, custom_threshold=None): - """判断是否为强分型""" - threshold = custom_threshold if custom_threshold is not None else self.strong_threshold - return strength >= threshold - - def validate_config(self): - """验证配置参数的合理性""" - total_weight = (self.price_difference_weight + - self.breakthrough_weight + - self.volume_weight + - self.rsi_divergence_weight + - self.macd_divergence_weight) - - if total_weight != 100: - print(f"警告: 权重总和为{total_weight},不等于100") - - if not (0 <= self.extreme_threshold <= 100): - print(f"警告: 极强阈值{self.extreme_threshold}不在合理范围内") - - if not (self.weak_threshold < self.medium_threshold < - self.strong_threshold < self.extreme_threshold): - print("警告: 强度阈值设置不合理") - - return True - - def print_config(self): - """打印当前配置""" - print("=== 分型强度检测配置 ===") - print(f"价格差异权重: {self.price_difference_weight}分") - print(f"突破点位权重: {self.breakthrough_weight}分") - print(f"成交量权重: {self.volume_weight}分") - print(f"RSI背离权重: {self.rsi_divergence_weight}分") - print(f"MACD背离权重: {self.macd_divergence_weight}分") - print() - print("=== 强度等级阈值 ===") - print(f"极强: >={self.extreme_threshold}分") - print(f"强: {self.strong_threshold}-{self.extreme_threshold-1}分") - print(f"中等: {self.medium_threshold}-{self.strong_threshold-1}分") - print(f"弱: {self.weak_threshold}-{self.medium_threshold-1}分") - print(f"极弱: <{self.weak_threshold}分") - - -# 默认配置实例 -DEFAULT_CONFIG = FxStrengthConfig() - -# 保守配置 (更严格的分型识别) -CONSERVATIVE_CONFIG = FxStrengthConfig() -CONSERVATIVE_CONFIG.price_difference_weight = 50 -CONSERVATIVE_CONFIG.breakthrough_weight = 25 -CONSERVATIVE_CONFIG.volume_weight = 15 -CONSERVATIVE_CONFIG.rsi_divergence_weight = 10 -CONSERVATIVE_CONFIG.macd_divergence_weight = 0 -CONSERVATIVE_CONFIG.strong_threshold = 70 -CONSERVATIVE_CONFIG.extreme_threshold = 85 - -# 激进配置 (更宽松的分型识别) -AGGRESSIVE_CONFIG = FxStrengthConfig() -AGGRESSIVE_CONFIG.price_difference_weight = 30 -AGGRESSIVE_CONFIG.breakthrough_weight = 15 -AGGRESSIVE_CONFIG.volume_weight = 20 -AGGRESSIVE_CONFIG.rsi_divergence_weight = 20 -AGGRESSIVE_CONFIG.macd_divergence_weight = 15 -AGGRESSIVE_CONFIG.strong_threshold = 50 -AGGRESSIVE_CONFIG.extreme_threshold = 70 - -# 技术指标重点配置 (重视技术指标背离) -TECHNICAL_CONFIG = FxStrengthConfig() -TECHNICAL_CONFIG.price_difference_weight = 25 -TECHNICAL_CONFIG.breakthrough_weight = 15 -TECHNICAL_CONFIG.volume_weight = 10 -TECHNICAL_CONFIG.rsi_divergence_weight = 25 -TECHNICAL_CONFIG.macd_divergence_weight = 25 - - -if __name__ == "__main__": - print("=== 分型强度配置演示 ===\n") - - configs = { - "默认配置": DEFAULT_CONFIG, - "保守配置": CONSERVATIVE_CONFIG, - "激进配置": AGGRESSIVE_CONFIG, - "技术指标配置": TECHNICAL_CONFIG - } - - for name, config in configs.items(): - print(f"=== {name} ===") - config.print_config() - config.validate_config() - print() \ No newline at end of file +"""兼容 shim — 请优先 from chanlun import ...""" +from chanlun.analysis.fx_strength_config import * # noqa: F403 diff --git a/scripts/migrate_imports_to_chanlun.py b/scripts/migrate_imports_to_chanlun.py new file mode 100644 index 0000000..7d7294c --- /dev/null +++ b/scripts/migrate_imports_to_chanlun.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""将 strategies / web / tests 的根 shim 导入改为 chanlun 包导入。""" +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +REPLACEMENTS = [ + (r"from ChanLun import ", "from chanlun import "), + (r"from TF_DF import ", "from chanlun import "), # TF_DF also exported from chanlun + (r"from ChanEnum import ", "from chanlun.core.ChanEnum import "), + (r"from ChanLun_Classifier import ", "from chanlun.analysis.ChanLun_Classifier import "), + (r"from ChanPY import ", "from chanlun.analysis.ChanPY import "), + (r"from ChanKLU import ", "from chanlun.core.ChanKLU import "), + (r"from ChanKLC import ", "from chanlun.core.ChanKLC import "), + (r"from ChanBI import ", "from chanlun.core.ChanBI import "), + (r"from ChanCTime import ", "from chanlun.core.ChanCTime import "), + (r"from ChanMACD import ", "from chanlun.indicators.ChanMACD import "), + (r"from ChanZone import ", "from chanlun.analysis.ChanZone import "), + (r"from ChanBSP import ", "from chanlun.core.ChanBSP import "), + (r"from ChanSEG import ", "from chanlun.core.ChanSEG import "), + (r"from ChanZS import ", "from chanlun.core.ChanZS import "), + (r"from ChanBIZS import ", "from chanlun.core.ChanBIZS import "), + (r"from ChanSBI import ", "from chanlun.core.ChanSBI import "), + (r"from ChanPivotClassifier import ", "from chanlun.analysis.ChanPivotClassifier import "), + (r"from ChanPivotMonitor import ", "from chanlun.analysis.ChanPivotMonitor import "), + (r"from fx_strength_config import ", "from chanlun.analysis.fx_strength_config import "), +] + + +def rewrite_file(path: Path) -> bool: + text = path.read_text(encoding="utf-8") + orig = text + for pat, repl in REPLACEMENTS: + text = re.sub(pat, repl, text) + # Fix: from chanlun import TF_DF only cases that were `from TF_DF import TF_DF` + # and `from ChanLun import ChanLun, TF_DF` already becomes from chanlun import ChanLun, TF_DF — good + # Special: from chanlun import TF_DF when file only imported TF_DF — need TF_DF in chanlun.__init__ + if text == orig: + return False + path.write_text(text, encoding="utf-8") + return True + + +def main(): + changed = [] + targets = [] + targets += list((ROOT / "strategies").glob("*.py")) + targets += list((ROOT / "web").rglob("*.py")) + targets += list((ROOT / "tests").rglob("*.py")) + for p in targets: + if "charting_library" in str(p) or "__pycache__" in str(p): + continue + if rewrite_file(p): + changed.append(str(p.relative_to(ROOT))) + print(f"updated {len(changed)} files") + for c in changed: + print(" -", c) + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_to_chanlun.py b/scripts/migrate_to_chanlun.py new file mode 100644 index 0000000..5f4f668 --- /dev/null +++ b/scripts/migrate_to_chanlun.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""一次性迁移:根目录引擎模块 → chanlun/ 包 + 根 shim。""" +from __future__ import annotations + +import re +import shutil +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +CORE = [ + "ChanEnum.py", + "ChanCTime.py", + "ChanKLU.py", + "ChanKLC.py", + "ChanBI.py", + "ChanSBI.py", + "ChanSEG.py", + "ChanZS.py", + "ChanBIZS.py", + "ChanBSP.py", + "Chan_FX_Box.py", +] +INDICATORS = [ + "ChanMACD.py", + "ChanMACDHistSet.py", + "ChanMACDSeg.py", + "ChanMACDUnitTF.py", +] +ANALYSIS = [ + "ChanZone.py", + "ChanLun_Classifier.py", + "ChanPivotClassifier.py", + "ChanPivotMonitor.py", + "ChanHeng.py", + "ChanPY.py", + "Find_Trend.py", + "fx_strength_config.py", +] +PIPELINE = { + "ChanLun.py": "orchestrator.py", + "TF_DF.py": "timeframe.py", +} + +MODULE_PKG: dict[str, str] = {} +for name in CORE: + MODULE_PKG[name[:-3]] = "chanlun.core" +for name in INDICATORS: + MODULE_PKG[name[:-3]] = "chanlun.indicators" +for name in ANALYSIS: + MODULE_PKG[name[:-3]] = "chanlun.analysis" + +IMPORT_TARGET = { + **{k: f"{v}.{k}" for k, v in MODULE_PKG.items()}, + "ChanLun": "chanlun.pipeline.orchestrator", + "TF_DF": "chanlun.pipeline.timeframe", +} +KNOWN = set(IMPORT_TARGET) + + +def rewrite_imports(text: str) -> str: + """只改写已知缠论模块;绝不拆分 typing/talib 等标准 from-import。""" + out_lines = [] + for line in text.splitlines(keepends=True): + nl = "\n" if line.endswith("\n") else "" + raw = line[:-1] if nl else line + indent_m = re.match(r"^(\s*)", raw) + indent = indent_m.group(1) if indent_m else "" + stripped = raw[len(indent) :] + + if stripped.startswith("from ") and " import " in stripped: + m = re.match(r"^from (\S+) import (.*)$", stripped) + if m and m.group(1) in KNOWN: + raw = f"{indent}from {IMPORT_TARGET[m.group(1)]} import {m.group(2)}" + out_lines.append(raw + nl) + continue + + if stripped.startswith("import "): + rest = stripped[len("import ") :] + # 跳过 import x as y 复合以外的非已知模块整行 + parts = [p.strip() for p in rest.split(",")] + if not any(p.split(" as ")[0].strip() in KNOWN for p in parts): + out_lines.append(line) + continue + new_parts = [] + for p in parts: + base = p.split(" as ")[0].strip() + if base not in KNOWN: + new_parts.append(f"import {p}") + continue + target = IMPORT_TARGET[base] + if " as " in p: + new_parts.append( + f"import {target} as {p.split(' as ', 1)[1].strip()}" + ) + elif base in ("ChanLun", "TF_DF"): + new_parts.append(f"from {target} import {base}") + else: + new_parts.append(f"import {target} as {base}") + # 多模块拆成多行,保持可读 + out_lines.append(nl.join(indent + x for x in new_parts) + nl) + continue + + out_lines.append(line) + return "".join(out_lines) + + +def write_shim(mod_name: str): + path = ROOT / f"{mod_name}.py" + if mod_name == "ChanLun": + body = ( + '"""兼容 shim — 请优先 from chanlun import ..."""\n' + "from chanlun.pipeline.orchestrator import ChanLun # noqa: F401\n" + "from chanlun.pipeline.timeframe import TF_DF # noqa: F401\n" + ) + elif mod_name == "TF_DF": + body = ( + '"""兼容 shim — 请优先 from chanlun import ..."""\n' + "from chanlun.pipeline.timeframe import TF_DF # noqa: F401\n" + ) + else: + target = IMPORT_TARGET[mod_name] + body = ( + f'"""兼容 shim — 请优先 from chanlun import ..."""\n' + f"from {target} import * # noqa: F403\n" + ) + path.write_text(body, encoding="utf-8") + + +def main(): + for sub in ("core", "indicators", "analysis", "pipeline", "pipeline/builders"): + d = ROOT / "chanlun" / sub + d.mkdir(parents=True, exist_ok=True) + (d / "__init__.py").write_text("", encoding="utf-8") + + moved = [] + + def move_list(names, dest_pkg: Path): + for name in names: + src = ROOT / name + if not src.exists(): + print("skip missing", name) + continue + dst = dest_pkg / name + text = rewrite_imports(src.read_text(encoding="utf-8")) + dst.write_text(text, encoding="utf-8") + src.unlink() + moved.append(name) + + move_list(CORE, ROOT / "chanlun" / "core") + move_list(INDICATORS, ROOT / "chanlun" / "indicators") + move_list(ANALYSIS, ROOT / "chanlun" / "analysis") + + for src_name, dst_name in PIPELINE.items(): + src = ROOT / src_name + if not src.exists(): + continue + dst = ROOT / "chanlun" / "pipeline" / dst_name + text = rewrite_imports(src.read_text(encoding="utf-8")) + dst.write_text(text, encoding="utf-8") + src.unlink() + moved.append(src_name) + + # 恢复 get_zs_list(受控 L1) + tf_path = ROOT / "chanlun" / "pipeline" / "timeframe.py" + tf_text = tf_path.read_text(encoding="utf-8") + if "def get_zs_list" not in tf_text: + needle = "\tdef calculate_seg_zs(self, seg_list):\n\t\treturn self.get_seg_zs_list(seg_list)\n" + insert = ( + "\tdef get_zs_list(self, bi_list, seg_list):\n" + "\t\t\"\"\"兼容历史 API:线段中枢列表。\"\"\"\n" + "\t\treturn self.get_seg_zs_list(seg_list)\n" + + needle + ) + if needle in tf_text: + tf_path.write_text(tf_text.replace(needle, insert), encoding="utf-8") + else: + tf_path.write_text( + tf_text + + "\n\tdef get_zs_list(self, bi_list, seg_list):\n" + + "\t\treturn self.get_seg_zs_list(seg_list)\n", + encoding="utf-8", + ) + print("patched get_zs_list") + + for name in CORE + INDICATORS + ANALYSIS: + write_shim(name[:-3]) + write_shim("ChanLun") + write_shim("TF_DF") + + (ROOT / "chanlun" / "__init__.py").write_text( + '"""缠论引擎正式包。"""\n' + "from chanlun.pipeline.orchestrator import ChanLun\n" + "from chanlun.pipeline.timeframe import TF_DF\n" + '__all__ = ["ChanLun", "TF_DF"]\n', + encoding="utf-8", + ) + + examples = ROOT / "examples" + examples.mkdir(exist_ok=True) + notes = ROOT / "docs" / "notes" + notes.mkdir(parents=True, exist_ok=True) + for f in ("fx_strength_example.py", "realtime_fx_example.py"): + p = ROOT / f + if p.exists(): + dest = examples / f + if dest.exists(): + dest.unlink() + shutil.move(str(p), str(dest)) + for f in ("缠论.txt", "操作策略.txt", "chanlun.txt", "K线动能理论.txt"): + p = ROOT / f + if p.exists(): + dest = notes / f + if dest.exists(): + dest.unlink() + shutil.move(str(p), str(dest)) + tc = ROOT / "test_classifier.py" + if tc.exists(): + dest = ROOT / "tests" / "test_classifier.py" + if dest.exists(): + dest.unlink() + shutil.move(str(tc), str(dest)) + + print("moved", len(moved), "modules") + + +if __name__ == "__main__": + main() diff --git a/scripts/split_tfdf_builders.py b/scripts/split_tfdf_builders.py new file mode 100644 index 0000000..1419a56 --- /dev/null +++ b/scripts/split_tfdf_builders.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""将 timeframe.TF_DF 拆为 mixin builders(方法体原样搬移)。""" +from __future__ import annotations + +import ast +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "chanlun" / "pipeline" / "timeframe.py" +BUILDERS = ROOT / "chanlun" / "pipeline" / "builders" + +# method name -> builder module +ASSIGN = { + "get_ema52": "indicators", + "get_ema24": "indicators", + "add_indicators": "indicators", + "get_klu_state": "kline", + "get_ema_state": "indicators", + "check_fx1": "kline", + "check_fx": "kline", + "check_fx2": "kline", + "check_fx_pattern": "kline", + "cal_volume_ratio": "kline", + "cal_kl_data": "kline", + "get_kl_data": "kline", + "get_klc_list": "kline", + "get_klu_list": "kline", + "cal_klu_pattern": "kline", + "_detect_single_reversal_pattern": "kline", + "_detect_double_pattern": "kline", + "_detect_triple_pattern": "kline", + "cal_trend": "bi", + "get_bi_list": "bi", + "cal_bi_list": "bi", + "check_top_fx": "bi", + "check_bottom_fx": "bi", + "get_bsp_state": "bsp", + "get_above_zero_bsp": "bsp", + "find_all_bsp": "bsp", + "check_bi_div": "bsp", + "find_first_bsp": "bsp", + "find_second_bsp": "bsp", + "get_zs_state": "zs", + "cal_bi_zs": "zs", + "cal_bi_zs_list": "zs", + "get_bi_zs_list": "zs", + "cal_bi_zs_list_pure": "zs", + "calculate_seg_zs": "zs", + "get_seg_zs_list": "zs", + "get_big_zs_list": "zs", + "get_zs_list": "zs", + "get_seg_list": "seg", + "get_decimal": "indicators", +} + +HEADER = '''\ +"""TF_DF builder mixin — 由 split_tfdf_builders 自动生成,逻辑与原 TF_DF 一致。""" +from __future__ import annotations + +from datetime import timedelta +from decimal import Decimal + +import numpy as np +import pandas as pd +import talib.abstract as ta +from pandas import DataFrame +from technical.util import resample_to_interval + +from chanlun.core.ChanBI import ChanBI +from chanlun.core.ChanBIZS import ChanBIZS +from chanlun.core.ChanBSP import ChanBSP +from chanlun.core.ChanEnum import ( + Chan_BI_DIR, + Chan_BSP_DIR, + Chan_BSP_TYPE, + Chan_FX_TYPE, + Chan_K_DIR, + Chan_KLC_FX, + Chan_KLC_STATE, + Chan_KLINE_DIR, + Chan_KLU_PATTERN, + Chan_PRICE_TREND, + Chan_SEG_DIR, + Chan_ZS_DIR, +) +from chanlun.core.ChanKLC import ChanKLC +from chanlun.core.ChanKLU import ChanKLU +from chanlun.core.ChanSBI import ChanSBI +from chanlun.core.ChanSEG import ChanSEG +from chanlun.core.ChanZS import ChanZS, ChanZS_Big +from chanlun.indicators.ChanMACD import ChanMACD + + +''' + + +def main(): + source = SRC.read_text(encoding="utf-8") + # Use tabs; extract method bodies by line scanning + lines = source.splitlines(keepends=True) + # find class TF_DF + class_start = None + for i, line in enumerate(lines): + if line.startswith("class TF_DF"): + class_start = i + break + if class_start is None: + raise SystemExit("TF_DF not found") + + # collect methods: name -> (start, end exclusive) + methods = [] + i = class_start + 1 + while i < len(lines): + line = lines[i] + m = re.match(r"^\tdef ([A-Za-z_][\w]*)\(", line) + if m: + name = m.group(1) + start = i + i += 1 + while i < len(lines): + if re.match(r"^\tdef [A-Za-z_]", lines[i]) or ( + lines[i] and not lines[i].startswith("\t") and not lines[i].startswith(" ") and lines[i].strip() + ): + break + # nested def inside method starts with \t\tdef + i += 1 + methods.append((name, start, i)) + continue + i += 1 + + by_mod: dict[str, list[str]] = {k: [] for k in ("indicators", "kline", "bi", "seg", "zs", "bsp")} + keep_in_facade = [] # __init__, init_TF_DF, get_current_klc + + for name, start, end in methods: + body = "".join(lines[start:end]) + mod = ASSIGN.get(name) + if mod is None: + keep_in_facade.append((name, body)) + else: + by_mod[mod].append(body) + + BUILDERS.mkdir(parents=True, exist_ok=True) + (BUILDERS / "__init__.py").write_text("", encoding="utf-8") + + mixin_classes = [] + for mod, bodies in by_mod.items(): + class_name = "".join(p.title() for p in mod.split("_")) + "BuilderMixin" + mixin_classes.append(class_name) + content = HEADER + f"class {class_name}:\n" + if not bodies: + content += "\tpass\n" + else: + content += "\n".join(bodies) + if not content.endswith("\n"): + content += "\n" + (BUILDERS / f"{mod}.py").write_text(content, encoding="utf-8") + print(f"wrote {mod}.py methods={len(bodies)} class={class_name}") + + # rewrite timeframe.py facade + facade_imports = '''\ +from datetime import timedelta + +import numpy as np +import pandas as pd +import talib.abstract as ta +from pandas import DataFrame +from technical.util import resample_to_interval + +from chanlun.core.ChanBI import ChanBI +from chanlun.core.ChanBIZS import ChanBIZS +from chanlun.core.ChanBSP import ChanBSP +from chanlun.core.ChanEnum import ( + Chan_BI_DIR, + Chan_BSP_DIR, + Chan_BSP_TYPE, + Chan_FX_TYPE, + Chan_K_DIR, + Chan_KLC_FX, + Chan_KLC_STATE, + Chan_KLINE_DIR, + Chan_KLU_PATTERN, + Chan_PRICE_TREND, + Chan_SEG_DIR, + Chan_ZS_DIR, +) +from chanlun.core.ChanKLC import ChanKLC +from chanlun.core.ChanKLU import ChanKLU +from chanlun.core.ChanSBI import ChanSBI +from chanlun.core.ChanSEG import ChanSEG +from chanlun.core.ChanZS import ChanZS, ChanZS_Big +from chanlun.indicators.ChanMACD import ChanMACD +from chanlun.pipeline.builders.bi import BiBuilderMixin +from chanlun.pipeline.builders.bsp import BspBuilderMixin +from chanlun.pipeline.builders.indicators import IndicatorsBuilderMixin +from chanlun.pipeline.builders.kline import KlineBuilderMixin +from chanlun.pipeline.builders.seg import SegBuilderMixin +from chanlun.pipeline.builders.zs import ZsBuilderMixin + +''' + bases = ", ".join( + [ + "IndicatorsBuilderMixin", + "KlineBuilderMixin", + "BiBuilderMixin", + "SegBuilderMixin", + "ZsBuilderMixin", + "BspBuilderMixin", + ] + ) + facade = facade_imports + f"class TF_DF({bases}):\n" + for name, body in keep_in_facade: + facade += body + if not body.endswith("\n"): + facade += "\n" + SRC.write_text(facade, encoding="utf-8") + print("rewrote timeframe.py facade, kept", [n for n, _ in keep_in_facade]) + + +if __name__ == "__main__": + main() diff --git a/strategies/BTC_Perpetual_Futures.py b/strategies/BTC_Perpetual_Futures.py index b4dfbdc..bdb3e27 100644 --- a/strategies/BTC_Perpetual_Futures.py +++ b/strategies/BTC_Perpetual_Futures.py @@ -3,8 +3,8 @@ from freqtrade.strategy import IStrategy, stoploss_from_absolute import sys import os sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from ChanLun import ChanLun -from ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR +from chanlun import ChanLun +from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR # -------------------------------- from technical.util import resample_to_interval, resampled_merge import talib.abstract as ta diff --git a/strategies/ChanLun_BTC.py b/strategies/ChanLun_BTC.py index e799199..4d7051a 100644 --- a/strategies/ChanLun_BTC.py +++ b/strategies/ChanLun_BTC.py @@ -5,8 +5,8 @@ import sys import os # 添加父目录到系统路径 sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from ChanLun import ChanLun -from ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX +from chanlun import ChanLun +from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX # -------------------------------- from technical.util import resample_to_interval, resampled_merge import talib.abstract as ta diff --git a/strategies/ChanLun_BTC_15.py b/strategies/ChanLun_BTC_15.py index 83eba63..1522f85 100644 --- a/strategies/ChanLun_BTC_15.py +++ b/strategies/ChanLun_BTC_15.py @@ -5,7 +5,7 @@ import sys import os # 添加父目录到系统路径 sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX +from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX # -------------------------------- from technical.util import resample_to_interval, resampled_merge import talib.abstract as ta @@ -15,7 +15,7 @@ from freqtrade.persistence import Trade from typing import Optional import logging logger = logging.getLogger(__name__) -from TF_DF import TF_DF +from chanlun import TF_DF ### Now you can use logger.info('asfd') to log # freqtrade plot-dataframe --strategy ChanLun_BTC_15 --datadir user_data/data/binance -c ./user_data/ChanLun_SOL_15.json --timerange=20250309- diff --git a/strategies/ChanLun_BTC_15_back.py b/strategies/ChanLun_BTC_15_back.py index 876e1a8..410ab56 100644 --- a/strategies/ChanLun_BTC_15_back.py +++ b/strategies/ChanLun_BTC_15_back.py @@ -5,9 +5,9 @@ import sys import os # 添加父目录到系统路径 sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from ChanLun import ChanLun -from ChanLun_Classifier import ChanLunClassifier -from ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX +from chanlun import ChanLun +from chanlun.analysis.ChanLun_Classifier import ChanLunClassifier +from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX # -------------------------------- from technical.util import resample_to_interval, resampled_merge import talib.abstract as ta diff --git a/strategies/ChanLun_BTC_1m.py b/strategies/ChanLun_BTC_1m.py index 2848540..4877726 100644 --- a/strategies/ChanLun_BTC_1m.py +++ b/strategies/ChanLun_BTC_1m.py @@ -5,8 +5,8 @@ import sys import os # 添加父目录到系统路径 sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from ChanLun import ChanLun -from ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX, Chan_BSP_TYPE +from chanlun import ChanLun +from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX, Chan_BSP_TYPE # -------------------------------- from technical.util import resample_to_interval, resampled_merge import talib.abstract as ta diff --git a/strategies/ChanLun_BTC_1m_old.py b/strategies/ChanLun_BTC_1m_old.py index 7da8c53..a1dbde8 100644 --- a/strategies/ChanLun_BTC_1m_old.py +++ b/strategies/ChanLun_BTC_1m_old.py @@ -5,8 +5,8 @@ import sys import os # 添加父目录到系统路径 sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from ChanLun import ChanLun -from ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX, Chan_BSP_TYPE +from chanlun import ChanLun +from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX, Chan_BSP_TYPE # -------------------------------- from technical.util import resample_to_interval, resampled_merge import talib.abstract as ta diff --git a/strategies/ChanLun_BTC_30.py b/strategies/ChanLun_BTC_30.py index 5a6efe6..afdd9a6 100644 --- a/strategies/ChanLun_BTC_30.py +++ b/strategies/ChanLun_BTC_30.py @@ -5,8 +5,8 @@ import sys import os # 添加父目录到系统路径 sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from ChanLun import ChanLun -from ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX +from chanlun import ChanLun +from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX # -------------------------------- from technical.util import resample_to_interval, resampled_merge import talib.abstract as ta diff --git a/strategies/ChanLun_BTC_5m.py b/strategies/ChanLun_BTC_5m.py index 027c670..20c8cb5 100644 --- a/strategies/ChanLun_BTC_5m.py +++ b/strategies/ChanLun_BTC_5m.py @@ -5,8 +5,8 @@ import sys import os # 添加父目录到系统路径 sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from ChanLun import ChanLun -from ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX, Chan_BSP_TYPE +from chanlun import ChanLun +from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX, Chan_BSP_TYPE # -------------------------------- from technical.util import resample_to_interval, resampled_merge import talib.abstract as ta diff --git a/strategies/ChanLun_BTC_60.py b/strategies/ChanLun_BTC_60.py index 4301bf0..aafc640 100644 --- a/strategies/ChanLun_BTC_60.py +++ b/strategies/ChanLun_BTC_60.py @@ -5,8 +5,8 @@ import sys import os # 添加父目录到系统路径 sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from ChanLun import ChanLun -from ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX +from chanlun import ChanLun +from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX # -------------------------------- from technical.util import resample_to_interval, resampled_merge import talib.abstract as ta diff --git a/strategies/ChanLun_BTC_K.py b/strategies/ChanLun_BTC_K.py index ba514ef..63ec77e 100644 --- a/strategies/ChanLun_BTC_K.py +++ b/strategies/ChanLun_BTC_K.py @@ -5,10 +5,10 @@ import sys import os # 添加父目录到系统路径 sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from ChanLun import ChanLun -from ChanLun_Classifier import ChanLunClassifier -from ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX -from ChanPY import ChanPY +from chanlun import ChanLun +from chanlun.analysis.ChanLun_Classifier import ChanLunClassifier +from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX +from chanlun.analysis.ChanPY import ChanPY # -------------------------------- from technical.util import resample_to_interval, resampled_merge import talib.abstract as ta diff --git a/strategies/ChanLun_EMA52.py b/strategies/ChanLun_EMA52.py index 6af06e7..91ea62a 100644 --- a/strategies/ChanLun_EMA52.py +++ b/strategies/ChanLun_EMA52.py @@ -5,8 +5,8 @@ import sys import os # 添加父目录到系统路径 sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from ChanLun import ChanLun -from ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX +from chanlun import ChanLun +from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX # -------------------------------- from technical.util import resample_to_interval, resampled_merge import talib.abstract as ta diff --git a/strategies/ChanLun_EMA_Align.py b/strategies/ChanLun_EMA_Align.py index 5e2cca1..96f9733 100644 --- a/strategies/ChanLun_EMA_Align.py +++ b/strategies/ChanLun_EMA_Align.py @@ -5,8 +5,8 @@ import sys import os # 添加父目录到系统路径 sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from ChanLun import ChanLun -from ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX +from chanlun import ChanLun +from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX # -------------------------------- from technical.util import resample_to_interval, resampled_merge import talib.abstract as ta diff --git a/strategies/ChanLun_ETH_60.py b/strategies/ChanLun_ETH_60.py index fa63b89..dd91b75 100644 --- a/strategies/ChanLun_ETH_60.py +++ b/strategies/ChanLun_ETH_60.py @@ -5,10 +5,10 @@ import sys import os # 添加父目录到系统路径 sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from ChanLun import ChanLun -from ChanLun_Classifier import ChanLunClassifier -from ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX -from ChanPY import ChanPY +from chanlun import ChanLun +from chanlun.analysis.ChanLun_Classifier import ChanLunClassifier +from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX +from chanlun.analysis.ChanPY import ChanPY # -------------------------------- from technical.util import resample_to_interval, resampled_merge import talib.abstract as ta diff --git a/strategies/ChanLun_MACD.py b/strategies/ChanLun_MACD.py index 70fe50b..de04bae 100644 --- a/strategies/ChanLun_MACD.py +++ b/strategies/ChanLun_MACD.py @@ -5,10 +5,10 @@ import sys import os # 添加父目录到系统路径 sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from ChanLun import ChanLun -from ChanLun_Classifier import ChanLunClassifier -from ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX -from ChanPY import ChanPY +from chanlun import ChanLun +from chanlun.analysis.ChanLun_Classifier import ChanLunClassifier +from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX +from chanlun.analysis.ChanPY import ChanPY # -------------------------------- from technical.util import resample_to_interval, resampled_merge import talib.abstract as ta @@ -112,8 +112,8 @@ class ChanLun_MACD(IStrategy): # ====== 结构:基于 ChanMACD 的 UnitTF 结束点(与零轴定义一致) ====== try: - from ChanKLU import ChanKLU - from ChanMACD import ChanMACD + from chanlun.core.ChanKLU import ChanKLU + from chanlun.indicators.ChanMACD import ChanMACD klu_list = [] prev = None for idx, row in dataframe.iterrows(): diff --git a/strategies/ChanLun_SOL.py b/strategies/ChanLun_SOL.py index 8823435..0ca625c 100644 --- a/strategies/ChanLun_SOL.py +++ b/strategies/ChanLun_SOL.py @@ -8,10 +8,10 @@ import freqtrade.vendor.qtpylib.indicators as qtpylib import sys import os #sys.setrecursionlimit(1000000) #例如这里设置为一百万 -#sys.path.append(os.path.abspath("/freqtrade/user_data/Chan")) -#sys.path.append(os.path.abspath("/Users/jack/Project/freqtrade/user_data/Chan")) -sys.path.append(os.path.abspath("/Users/jack/Documents/GitHub/freqtrade/user_data/Chan")) -from ChanLun import ChanLun +#sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +#sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from chanlun import ChanLun # -------------------------------- from technical.util import resample_to_interval, resampled_merge import talib.abstract as ta diff --git a/strategies/ChanLun_SOL_1.py b/strategies/ChanLun_SOL_1.py index f42b21b..c6107d9 100644 --- a/strategies/ChanLun_SOL_1.py +++ b/strategies/ChanLun_SOL_1.py @@ -8,10 +8,10 @@ import freqtrade.vendor.qtpylib.indicators as qtpylib import sys import os #sys.setrecursionlimit(1000000) #例如这里设置为一百万 -#sys.path.append(os.path.abspath("/freqtrade/user_data/Chan")) -#sys.path.append(os.path.abspath("/Users/jack/Project/freqtrade/user_data/Chan")) -sys.path.append(os.path.abspath("/Users/jack/Documents/GitHub/freqtrade/user_data/Chan")) -from ChanLun import ChanLun +#sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +#sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from chanlun import ChanLun # -------------------------------- from technical.util import resample_to_interval, resampled_merge import talib.abstract as ta diff --git a/strategies/ChanLun_SOL_15.py b/strategies/ChanLun_SOL_15.py index 40a77f6..3ed8380 100644 --- a/strategies/ChanLun_SOL_15.py +++ b/strategies/ChanLun_SOL_15.py @@ -4,9 +4,9 @@ import sys import os # 添加父目录到系统路径 sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from ChanLun import ChanLun -from ChanLun_Classifier import ChanLunClassifier -from ChanEnum import Chan_FX_TYPE, Chan_KLC_FX +from chanlun import ChanLun +from chanlun.analysis.ChanLun_Classifier import ChanLunClassifier +from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLC_FX # -------------------------------- from technical.util import resample_to_interval, resampled_merge import talib.abstract as ta diff --git a/strategies/ChanLun_SOL_5.py b/strategies/ChanLun_SOL_5.py index 4c152ec..f7c0feb 100644 --- a/strategies/ChanLun_SOL_5.py +++ b/strategies/ChanLun_SOL_5.py @@ -5,9 +5,9 @@ import sys import os # 添加父目录到系统路径 sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from ChanLun import ChanLun -from ChanLun_Classifier import ChanLunClassifier -from ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX +from chanlun import ChanLun +from chanlun.analysis.ChanLun_Classifier import ChanLunClassifier +from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX # -------------------------------- from technical.util import resample_to_interval, resampled_merge import talib.abstract as ta diff --git a/strategies/ChanLun_SOL_Optimized.py b/strategies/ChanLun_SOL_Optimized.py index b347656..ade035f 100644 --- a/strategies/ChanLun_SOL_Optimized.py +++ b/strategies/ChanLun_SOL_Optimized.py @@ -12,9 +12,9 @@ import sys import os # 添加父目录到系统路径 sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from ChanLun import ChanLun -from ChanLun_Classifier import ChanLunClassifier -from ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX +from chanlun import ChanLun +from chanlun.analysis.ChanLun_Classifier import ChanLunClassifier +from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX # -------------------------------- from technical.util import resample_to_interval, resampled_merge import talib.abstract as ta diff --git a/strategies/Chan_SOL_60.py b/strategies/Chan_SOL_60.py index fd4bc14..c2b55f8 100644 --- a/strategies/Chan_SOL_60.py +++ b/strategies/Chan_SOL_60.py @@ -8,15 +8,15 @@ import freqtrade.vendor.qtpylib.indicators as qtpylib import sys import os #sys.setrecursionlimit(1000000) #例如这里设置为一百万 -#sys.path.append(os.path.abspath("/freqtrade/user_data/Chan")) -sys.path.append(os.path.abspath("/Users/jack/Project/freqtrade/user_data/Chan")) -#sys.path.append(os.path.abspath("/Users/jack/Documents/GitHub/freqtrade/user_data/Chan")) +#sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +#sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from ChanEnum import Chan_AUTYPE, Chan_DATA_FIELD, Chan_FX_TYPE, Chan_KLINE_DIR, Chan_KL_TYPE, Chan_BI_DIR -from ChanKLU import ChanKLU -from ChanCTime import ChanCTime -from ChanKLC import ChanKLC -from ChanBI import ChanBI +from chanlun.core.ChanEnum import Chan_AUTYPE, Chan_DATA_FIELD, Chan_FX_TYPE, Chan_KLINE_DIR, Chan_KL_TYPE, Chan_BI_DIR +from chanlun.core.ChanKLU import ChanKLU +from chanlun.core.ChanCTime import ChanCTime +from chanlun.core.ChanKLC import ChanKLC +from chanlun.core.ChanBI import ChanBI # -------------------------------- from technical.util import resample_to_interval, resampled_merge import talib.abstract as ta diff --git a/strategies/Deepseek_BTC.py b/strategies/Deepseek_BTC.py index fa66db6..cabd385 100644 --- a/strategies/Deepseek_BTC.py +++ b/strategies/Deepseek_BTC.py @@ -8,10 +8,10 @@ import freqtrade.vendor.qtpylib.indicators as qtpylib import sys import os #sys.setrecursionlimit(1000000) #例如这里设置为一百万 -#sys.path.append(os.path.abspath("/freqtrade/user_data/Chan")) -sys.path.append(os.path.abspath("/Users/jack/Project/freqtrade/user_data/Chan")) -#sys.path.append(os.path.abspath("/Users/jack/Documents/GitHub/freqtrade/user_data/Chan")) -from ChanLun import ChanLun +#sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +#sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from chanlun import ChanLun # -------------------------------- from technical.util import resample_to_interval, resampled_merge import talib.abstract as ta @@ -19,7 +19,7 @@ import freqtrade.vendor.qtpylib.indicators as qtpylib from datetime import datetime, timedelta, timezone from freqtrade.persistence import Trade, Order from typing import Optional -from ChanPY import ChanPY +from chanlun.analysis.ChanPY import ChanPY import logging logger = logging.getLogger(__name__) from openai import OpenAI diff --git a/strategies/EMA26_EMA52_Cross.py b/strategies/EMA26_EMA52_Cross.py index 8254bcf..987e965 100644 --- a/strategies/EMA26_EMA52_Cross.py +++ b/strategies/EMA26_EMA52_Cross.py @@ -5,8 +5,8 @@ import sys import os # 添加父目录到系统路径 sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from ChanLun import ChanLun -from ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX +from chanlun import ChanLun +from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX # -------------------------------- from technical.util import resample_to_interval, resampled_merge import talib.abstract as ta diff --git a/strategies/EMA_Cross.py b/strategies/EMA_Cross.py index a875058..113dbf2 100644 --- a/strategies/EMA_Cross.py +++ b/strategies/EMA_Cross.py @@ -5,8 +5,8 @@ import sys import os # 添加父目录到系统路径 sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from ChanLun import ChanLun -from ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX +from chanlun import ChanLun +from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX # -------------------------------- from technical.util import resample_to_interval, resampled_merge import talib.abstract as ta diff --git a/strategies/ElliottWaveBTCStrategy.py b/strategies/ElliottWaveBTCStrategy.py index c2fefb4..beeff2b 100644 --- a/strategies/ElliottWaveBTCStrategy.py +++ b/strategies/ElliottWaveBTCStrategy.py @@ -20,7 +20,7 @@ import sys import os sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from ChanLun import ChanLun +from chanlun import ChanLun from freqtrade.strategy import IStrategy from pandas import DataFrame diff --git a/strategies/ElliottWaveBTCStrategyV2.py b/strategies/ElliottWaveBTCStrategyV2.py index 59d89dc..7cfa5b9 100644 --- a/strategies/ElliottWaveBTCStrategyV2.py +++ b/strategies/ElliottWaveBTCStrategyV2.py @@ -21,7 +21,7 @@ Elliott Wave Strategy for BTC Perpetual Futures V2 import sys import os sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from ChanLun import ChanLun +from chanlun import ChanLun from freqtrade.strategy import IStrategy from pandas import DataFrame diff --git a/strategies/Template.py b/strategies/Template.py index 1840e66..431908a 100644 --- a/strategies/Template.py +++ b/strategies/Template.py @@ -5,8 +5,8 @@ import sys import os # 添加父目录到系统路径 sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from ChanLun import ChanLun -from ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX, Chan_BSP_TYPE +from chanlun import ChanLun +from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX, Chan_BSP_TYPE # -------------------------------- from technical.util import resample_to_interval, resampled_merge import talib.abstract as ta diff --git a/tests/fixtures/analyze_contract_keys.json b/tests/fixtures/analyze_contract_keys.json new file mode 100644 index 0000000..e2f11f4 --- /dev/null +++ b/tests/fixtures/analyze_contract_keys.json @@ -0,0 +1,17 @@ +[ + "bi_list", + "bi_zs_list", + "bsp_list", + "chan_macd", + "klc_fx_info", + "klc_list", + "klc_trend", + "kline_data", + "macd", + "seg_list", + "timezone", + "uncompleted_bi_list", + "uncompleted_seg_list", + "uncompleted_zs_list", + "zs_list" +] \ No newline at end of file diff --git a/tests/fixtures/golden_pipeline.json b/tests/fixtures/golden_pipeline.json new file mode 100644 index 0000000..01d3309 --- /dev/null +++ b/tests/fixtures/golden_pipeline.json @@ -0,0 +1,175 @@ +{ + "counts": { + "klu": 400, + "klc": 208, + "bi": 14, + "seg": 2, + "zs": 0, + "bsp": 3 + }, + "bi_list": [ + { + "idx": 0, + "dir": "UP", + "high": 101.3972385599, + "low": 99.1098750909, + "is_sure": true, + "begin": null, + "end": "2024-01-01 04:20:00" + }, + { + "idx": 1, + "dir": "DOWN", + "high": 101.3972385599, + "low": 97.8029894896, + "is_sure": true, + "begin": null, + "end": "2024-01-01 11:30:00" + }, + { + "idx": 2, + "dir": "UP", + "high": 98.5916795432, + "low": 97.8029894896, + "is_sure": true, + "begin": null, + "end": "2024-01-01 12:30:00" + }, + { + "idx": 3, + "dir": "DOWN", + "high": 98.5916795432, + "low": 97.3576825668, + "is_sure": true, + "begin": null, + "end": "2024-01-01 14:35:00" + }, + { + "idx": 4, + "dir": "UP", + "high": 99.415466699, + "low": 97.3576825668, + "is_sure": true, + "begin": null, + "end": "2024-01-01 16:05:00" + }, + { + "idx": 5, + "dir": "DOWN", + "high": 99.415466699, + "low": 97.7965753597, + "is_sure": true, + "begin": null, + "end": "2024-01-01 17:55:00" + }, + { + "idx": 6, + "dir": "UP", + "high": 99.3416062486, + "low": 97.7965753597, + "is_sure": true, + "begin": null, + "end": "2024-01-01 19:00:00" + }, + { + "idx": 7, + "dir": "DOWN", + "high": 99.3416062486, + "low": 96.6499037558, + "is_sure": true, + "begin": null, + "end": "2024-01-01 20:35:00" + }, + { + "idx": 8, + "dir": "UP", + "high": 98.843249774, + "low": 96.6499037558, + "is_sure": true, + "begin": null, + "end": "2024-01-01 23:00:00" + }, + { + "idx": 9, + "dir": "DOWN", + "high": 98.843249774, + "low": 97.2919520243, + "is_sure": true, + "begin": null, + "end": "2024-01-02 01:30:00" + }, + { + "idx": 10, + "dir": "UP", + "high": 100.0485357245, + "low": 97.2919520243, + "is_sure": true, + "begin": null, + "end": "2024-01-02 03:10:00" + }, + { + "idx": 11, + "dir": "DOWN", + "high": 100.0485357245, + "low": 98.6419182856, + "is_sure": true, + "begin": null, + "end": "2024-01-02 04:35:00" + }, + { + "idx": 12, + "dir": "UP", + "high": 101.0661695528, + "low": 98.6419182856, + "is_sure": true, + "begin": null, + "end": "2024-01-02 07:50:00" + }, + { + "idx": 13, + "dir": "DOWN", + "high": 101.0661695528, + "low": 100.519558471, + "is_sure": false, + "begin": null, + "end": null + } + ], + "seg_list": [ + { + "idx": 0, + "dir": "DOWN", + "is_sure": true, + "high": null, + "low": null + }, + { + "idx": 1, + "dir": "UP", + "is_sure": false, + "high": null, + "low": null + } + ], + "zs_list": [], + "bsp_list": [ + { + "idx": 0, + "type": "B1", + "dir": "BUY", + "price": 0.0 + }, + { + "idx": 1, + "type": "B2", + "dir": "BUY", + "price": 0.0 + }, + { + "idx": 2, + "type": "S1", + "dir": "SELL", + "price": 0.0 + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/ohlcv_5m.csv b/tests/fixtures/ohlcv_5m.csv new file mode 100644 index 0000000..46b3183 --- /dev/null +++ b/tests/fixtures/ohlcv_5m.csv @@ -0,0 +1,401 @@ +date,open,high,low,close,volume +2024-01-01 00:00:00+00:00,100.06096199022369,100.08792012650228,100.0340038539451,100.06096199022369,649.6931199118765 +2024-01-01 00:05:00+00:00,100.06096199022369,100.09043503172813,99.8235816237381,99.85305466524254,598.1867560806503 +2024-01-01 00:10:00+00:00,99.85305466524254,100.1261198890587,99.72997165616017,100.00303687997634,456.549850053324 +2024-01-01 00:15:00+00:00,100.00303687997634,100.25050676389708,99.94386270086832,100.19133258478907,709.8586872398147 +2024-01-01 00:20:00+00:00,100.19133258478907,100.26935221473649,99.72312109703776,99.80114072698518,753.1926712438905 +2024-01-01 00:25:00+00:00,99.80114072698518,99.84083373942485,99.50186788037432,99.54156089281399,610.2440157874481 +2024-01-01 00:30:00+00:00,99.54156089281399,99.58456999740181,99.5240059087054,99.56701501329323,782.5800134662787 +2024-01-01 00:35:00+00:00,99.56701501329323,99.6908257824129,99.3802494933664,99.50406026248608,985.0753683936439 +2024-01-01 00:40:00+00:00,99.50406026248608,99.80152666566057,99.20325034870918,99.50071675188367,477.3451491107246 +2024-01-01 00:45:00+00:00,99.50071675188367,99.69388679436543,99.13793447280293,99.33110451528468,563.1247466969778 +2024-01-01 00:50:00+00:00,99.33110451528468,99.72719080767014,99.10987509090344,99.5059613832889,111.22289014709192 +2024-01-01 00:55:00+00:00,99.5059613832889,100.00972703374063,99.1571060581828,99.66087170863453,816.4257006793406 +2024-01-01 01:00:00+00:00,99.66087170863453,99.77544196071695,99.55946367940071,99.67403393148312,568.2070603413945 +2024-01-01 01:05:00+00:00,99.67403393148312,100.011302331334,99.56173238487762,99.89900078472849,467.16528732450865 +2024-01-01 01:10:00+00:00,99.89900078472849,100.03518128394334,99.85627140027145,99.99245189948631,184.6647667804562 +2024-01-01 01:15:00+00:00,99.99245189948631,100.02206723214417,99.79113862730854,99.8207539599664,900.6907795078575 +2024-01-01 01:20:00+00:00,99.8207539599664,100.05760916684883,99.65754386902107,99.89439907590351,455.5065160365008 +2024-01-01 01:25:00+00:00,99.89439907590351,100.09296052880468,99.50444720021875,99.70300865311992,714.3234383882983 +2024-01-01 01:30:00+00:00,99.70300865311992,99.88868896816784,99.69265058083687,99.87833089588479,234.4367407313357 +2024-01-01 01:35:00+00:00,99.87833089588479,100.08110149692156,99.66558775942869,99.86835836046546,965.3946665869292 +2024-01-01 01:40:00+00:00,99.86835836046546,99.88215406611751,99.8176456782537,99.83144138390574,260.59758563964783 +2024-01-01 01:45:00+00:00,99.83144138390574,99.95666873326755,99.57035021343472,99.69557756279653,279.58779360954134 +2024-01-01 01:50:00+00:00,99.69557756279653,100.02874598261694,99.60647132720017,99.93963974702058,873.0845745019424 +2024-01-01 01:55:00+00:00,99.93963974702058,100.16151759163316,99.68687943336927,99.90875727798185,921.3244697277381 +2024-01-01 02:00:00+00:00,99.90875727798185,100.04174183163802,99.69022197248495,99.82320652614112,290.9326667709713 +2024-01-01 02:05:00+00:00,99.82320652614112,99.8767763460813,99.69935925588345,99.75292907582363,522.8207993573451 +2024-01-01 02:10:00+00:00,99.75292907582363,99.97955244433295,99.63256105884385,99.85918442735317,760.1454141065468 +2024-01-01 02:15:00+00:00,99.85918442735317,100.19013746319197,99.60124396271729,99.9321969985561,890.5291604297215 +2024-01-01 02:20:00+00:00,99.9321969985561,100.22207935697483,99.72483924934834,100.01472160776707,441.1474255335747 +2024-01-01 02:25:00+00:00,100.01472160776707,100.15991922627346,99.95573801209575,100.10093563060214,565.3175381644862 +2024-01-01 02:30:00+00:00,100.10093563060214,100.68752684355941,99.94402584418427,100.53061705714154,767.5447694677614 +2024-01-01 02:35:00+00:00,100.53061705714154,100.60214128329594,100.37741172715462,100.44893595330902,758.0223789875158 +2024-01-01 02:40:00+00:00,100.44893595330902,100.46866700064875,100.32634912791981,100.34608017525954,804.6641170234764 +2024-01-01 02:45:00+00:00,100.34608017525954,100.62121834496719,99.90775703018988,100.18289519989753,612.9404367773737 +2024-01-01 02:50:00+00:00,100.18289519989753,100.44606364345671,100.04322401636408,100.30639245992326,194.1535871242977 +2024-01-01 02:55:00+00:00,100.30639245992326,100.62436855211519,100.21515853275977,100.5331346249517,913.575961790236 +2024-01-01 03:00:00+00:00,100.5331346249517,100.61362827557863,100.42973259457436,100.5102262452013,879.0152853889253 +2024-01-01 03:05:00+00:00,100.5102262452013,100.67123705454976,100.18046861413035,100.34147942347882,818.2876922364998 +2024-01-01 03:10:00+00:00,100.34147942347882,100.43979479929793,100.07784106093861,100.17615643675772,189.87622944465653 +2024-01-01 03:15:00+00:00,100.17615643675772,100.3709693925374,100.11177609087007,100.30658904664975,283.9576303732863 +2024-01-01 03:20:00+00:00,100.30658904664975,100.48432254643213,100.27807300730625,100.45580650708862,769.2403285968867 +2024-01-01 03:25:00+00:00,100.45580650708862,100.61456963831785,100.40622866974138,100.5649918009706,120.8260851237518 +2024-01-01 03:30:00+00:00,100.5649918009706,100.61951418471102,100.37670450231396,100.43122688605438,981.3021703365516 +2024-01-01 03:35:00+00:00,100.43122688605438,100.67691611263095,100.23218098043706,100.47787020701364,439.4302123201127 +2024-01-01 03:40:00+00:00,100.47787020701364,100.55299731832245,100.42619451521793,100.50132162652675,747.3812803217412 +2024-01-01 03:45:00+00:00,100.50132162652675,100.76802486910897,100.27858498422958,100.5452882268118,898.813979639239 +2024-01-01 03:50:00+00:00,100.5452882268118,100.8819141833338,100.38405118013299,100.72067713665498,455.16689933507405 +2024-01-01 03:55:00+00:00,100.72067713665498,100.81583257014054,100.6705731658798,100.76572859936536,387.21923973969945 +2024-01-01 04:00:00+00:00,100.76572859936536,101.0713419586238,100.59703061245223,100.90264397171067,647.8670160084221 +2024-01-01 04:05:00+00:00,100.90264397171067,100.97431619666604,100.84461048200473,100.91628270696009,622.8904363732456 +2024-01-01 04:10:00+00:00,100.91628270696009,100.99451564131914,100.89642035699144,100.97465329135049,468.22597986231744 +2024-01-01 04:15:00+00:00,100.97465329135049,101.15511505332249,100.92176026453446,101.10222202650645,641.5972802809376 +2024-01-01 04:20:00+00:00,101.10222202650645,101.39723855989791,100.51299103520756,100.80800756859902,941.8432326098452 +2024-01-01 04:25:00+00:00,100.80800756859902,101.121871241413,100.4297136576763,100.74357733049028,520.8762309639369 +2024-01-01 04:30:00+00:00,100.74357733049028,100.75405202746052,100.63837315092076,100.64884784789099,277.0679626755682 +2024-01-01 04:35:00+00:00,100.64884784789099,100.6730014637252,100.49617172104573,100.52032533687994,439.4853845947035 +2024-01-01 04:40:00+00:00,100.52032533687994,100.68251207962197,100.30283903356448,100.46502577630652,454.6962843338067 +2024-01-01 04:45:00+00:00,100.46502577630652,100.89367454146424,100.33720514218207,100.76585390733979,217.9978237235979 +2024-01-01 04:50:00+00:00,100.76585390733979,100.81610998615457,100.54125639890057,100.59151247771536,246.5843504990994 +2024-01-01 04:55:00+00:00,100.59151247771536,100.79041232864428,100.5876025387347,100.78650238966362,716.1058660543096 +2024-01-01 05:00:00+00:00,100.78650238966362,100.83379949777645,100.40055439007439,100.44785149818722,405.52997878801244 +2024-01-01 05:05:00+00:00,100.44785149818722,100.5733325856554,100.2551159722159,100.38059705968408,959.4085526702269 +2024-01-01 05:10:00+00:00,100.38059705968408,100.65269739906563,100.14117653845162,100.41327687783317,319.23707082274666 +2024-01-01 05:15:00+00:00,100.41327687783317,100.84367381043053,100.10067799813247,100.53107493072983,189.03205459434008 +2024-01-01 05:20:00+00:00,100.53107493072983,100.8429150197466,100.36233734105684,100.67417743007361,778.1541067002321 +2024-01-01 05:25:00+00:00,100.67417743007361,100.9034185117613,100.60480230470085,100.83404338638854,892.9324467260831 +2024-01-01 05:30:00+00:00,100.83404338638854,100.87835800812158,100.71942656551474,100.76374118724777,350.3020211843391 +2024-01-01 05:35:00+00:00,100.76374118724777,101.05627454470189,100.37807430413554,100.67060766158966,281.8792914809302 +2024-01-01 05:40:00+00:00,100.67060766158966,101.01080023156376,100.50330929489849,100.8435018648726,267.18952138397844 +2024-01-01 05:45:00+00:00,100.8435018648726,100.98897715004824,100.65945036387856,100.80492564905421,569.8110712298611 +2024-01-01 05:50:00+00:00,100.80492564905421,100.8573677658858,100.49562041837031,100.5480625352019,521.5749771220939 +2024-01-01 05:55:00+00:00,100.5480625352019,100.60931992524642,100.25916355858526,100.32042094862979,333.4958558690808 +2024-01-01 06:00:00+00:00,100.32042094862979,100.3631335818133,100.09339815103868,100.1361107842222,140.65580529048376 +2024-01-01 06:05:00+00:00,100.1361107842222,100.26359216538746,100.10824640720169,100.23572778836696,533.3422062936527 +2024-01-01 06:10:00+00:00,100.23572778836696,100.35740527317114,100.14260666515216,100.26428414995634,963.3991128720476 +2024-01-01 06:15:00+00:00,100.26428414995634,100.45393561332503,100.21319037610618,100.40284183947487,687.2672806773969 +2024-01-01 06:20:00+00:00,100.40284183947487,100.56292556439581,100.15700000040212,100.31708372532306,545.9559009657567 +2024-01-01 06:25:00+00:00,100.31708372532306,100.52078561026717,100.1451953627455,100.3488972476896,199.6228666266545 +2024-01-01 06:30:00+00:00,100.3488972476896,100.4754858100157,100.3479418762182,100.47453043854429,327.1926614946285 +2024-01-01 06:35:00+00:00,100.47453043854429,100.86578836694159,100.02112883941643,100.41238676781373,365.2965546170885 +2024-01-01 06:40:00+00:00,100.41238676781373,100.5377911283965,100.37875610443977,100.50416046502255,788.3231973465109 +2024-01-01 06:45:00+00:00,100.50416046502255,100.71994064598309,100.15541569419875,100.37119587515929,889.0397175381573 +2024-01-01 06:50:00+00:00,100.37119587515929,100.38496485835898,100.28457304753533,100.29834203073503,911.4776682097946 +2024-01-01 06:55:00+00:00,100.29834203073503,100.38565181600173,100.13448611402885,100.22179589929554,986.1117474256787 +2024-01-01 07:00:00+00:00,100.22179589929554,100.23031187800834,99.97386793926691,99.9823839179797,984.1300147940792 +2024-01-01 07:05:00+00:00,99.9823839179797,100.10539022970947,99.9568023807957,100.07980869252546,957.6992194911833 +2024-01-01 07:10:00+00:00,100.07980869252546,100.1967145629285,99.86899151839097,99.98589738879402,164.63992645259594 +2024-01-01 07:15:00+00:00,99.98589738879402,100.05293360530466,99.92135967484596,99.9883958913566,224.01418233502466 +2024-01-01 07:20:00+00:00,99.9883958913566,100.21241891207012,99.8605572780739,100.08458029878742,374.0198738284296 +2024-01-01 07:25:00+00:00,100.08458029878742,100.2740134982886,99.98456879360386,100.17400199310504,597.6084286580153 +2024-01-01 07:30:00+00:00,100.17400199310504,100.47069278785851,100.01070851771965,100.30739931247312,187.28009983493138 +2024-01-01 07:35:00+00:00,100.30739931247312,100.36253716918584,100.23250575583162,100.28764361254434,861.2172620219283 +2024-01-01 07:40:00+00:00,100.28764361254434,100.3306679892926,100.15975198450901,100.20277636125728,655.0805611695781 +2024-01-01 07:45:00+00:00,100.20277636125728,100.27099840044109,100.11857962346295,100.18680166264676,588.1279620719845 +2024-01-01 07:50:00+00:00,100.18680166264676,100.23303283350002,99.80304305324042,99.84927422409368,248.78019103056204 +2024-01-01 07:55:00+00:00,99.84927422409368,99.98898982221316,99.42099015825252,99.560705756372,327.9299269601776 +2024-01-01 08:00:00+00:00,99.560705756372,99.83348730834878,99.02489445331881,99.29767600529559,244.28082248403226 +2024-01-01 08:05:00+00:00,99.29767600529559,99.34756395236508,99.04993684553908,99.09982479260857,866.7398420153188 +2024-01-01 08:10:00+00:00,99.09982479260857,99.47526197665864,98.80365440480767,99.17909158885774,625.8050740550964 +2024-01-01 08:15:00+00:00,99.17909158885774,99.40110732589821,98.77762920571249,98.99964494275295,761.61648476767 +2024-01-01 08:20:00+00:00,98.99964494275295,99.20202463476615,98.72241764170957,98.92479733372276,366.4295766190232 +2024-01-01 08:25:00+00:00,98.92479733372276,99.31536298580556,98.79161773215567,99.18218338423847,434.12737337944947 +2024-01-01 08:30:00+00:00,99.18218338423847,99.289146573477,99.00457528920144,99.11153847843997,464.36946798388044 +2024-01-01 08:35:00+00:00,99.11153847843997,99.48154182518535,98.88783560933398,99.25783895607935,784.0134745696314 +2024-01-01 08:40:00+00:00,99.25783895607935,99.69839465535838,98.63211843709091,99.07267413636994,795.1176889071113 +2024-01-01 08:45:00+00:00,99.07267413636994,99.15340928751763,98.95124085026828,99.03197600141597,286.13217476262855 +2024-01-01 08:50:00+00:00,99.03197600141597,99.39084121559682,98.48512431240201,98.84398952658286,947.3849710642891 +2024-01-01 08:55:00+00:00,98.84398952658286,98.90842436524444,98.712554642113,98.77698948077459,208.58980369931777 +2024-01-01 09:00:00+00:00,98.77698948077459,99.02618402602592,98.69394072618547,98.9431352714368,906.5098741812673 +2024-01-01 09:05:00+00:00,98.9431352714368,99.01192196828318,98.53312531927205,98.60191201611842,190.35608108482836 +2024-01-01 09:10:00+00:00,98.60191201611842,98.91869014313467,98.3708411207156,98.68761924773185,338.08583003617997 +2024-01-01 09:15:00+00:00,98.68761924773185,98.77858766020894,98.64358511352864,98.73455352600574,861.8306824611612 +2024-01-01 09:20:00+00:00,98.73455352600574,98.74926882524957,98.60258164734468,98.61729694658851,261.5428651114787 +2024-01-01 09:25:00+00:00,98.61729694658851,98.62999668463485,98.31979661153996,98.3324963495863,472.2111381789728 +2024-01-01 09:30:00+00:00,98.3324963495863,98.4633424600423,98.21583661146968,98.34668272192569,504.8733699497136 +2024-01-01 09:35:00+00:00,98.34668272192569,98.3974709814855,98.19180188543058,98.24259014499039,320.7330757208887 +2024-01-01 09:40:00+00:00,98.24259014499039,98.3868511718407,98.14405718448532,98.28831821133564,739.2206812023879 +2024-01-01 09:45:00+00:00,98.28831821133564,98.39410679293364,98.18682534487314,98.29261392647115,866.2011844619834 +2024-01-01 09:50:00+00:00,98.29261392647115,98.74080259098525,98.15981624716765,98.60800491168176,887.1176258700146 +2024-01-01 09:55:00+00:00,98.60800491168176,98.84882893271207,98.3199874257951,98.56081144682541,405.3899311629049 +2024-01-01 10:00:00+00:00,98.56081144682541,98.7039462363658,98.21612952387422,98.3592643134146,577.765354859033 +2024-01-01 10:05:00+00:00,98.3592643134146,98.52555408097747,98.22824770821966,98.39453747578253,323.56576347908634 +2024-01-01 10:10:00+00:00,98.39453747578253,98.6350775324653,98.1972998887321,98.43783994541488,320.3168513951309 +2024-01-01 10:15:00+00:00,98.43783994541488,98.73412509998572,98.4095098049387,98.70579495950953,245.1286565667317 +2024-01-01 10:20:00+00:00,98.70579495950953,99.07898875411783,98.49759955737238,98.87079335198068,946.0171097267632 +2024-01-01 10:25:00+00:00,98.87079335198068,99.00706443678307,98.8051157064218,98.94138679122419,899.189945745022 +2024-01-01 10:30:00+00:00,98.94138679122419,99.44795261337768,98.72480753497835,99.23137335713184,799.622841994923 +2024-01-01 10:35:00+00:00,99.23137335713184,99.25089815844782,98.97620361186695,98.99572841318293,565.8953691589561 +2024-01-01 10:40:00+00:00,98.99572841318293,99.03402470728905,98.83084778089474,98.86914407500086,541.5471239606848 +2024-01-01 10:45:00+00:00,98.86914407500086,99.1007679401171,98.454470331389,98.68609419650524,576.7710995642482 +2024-01-01 10:50:00+00:00,98.68609419650524,98.7396050352617,98.55567572708665,98.60918656584312,582.9112122193803 +2024-01-01 10:55:00+00:00,98.60918656584312,98.748008736539,98.1992300310269,98.33805220172279,491.10677622792133 +2024-01-01 11:00:00+00:00,98.33805220172279,98.5293010921504,98.27180170125972,98.46305059168733,218.5793496028607 +2024-01-01 11:05:00+00:00,98.46305059168733,98.52982817710051,98.3525212802868,98.41929886569997,213.0974675816824 +2024-01-01 11:10:00+00:00,98.41929886569997,98.64977127215556,97.89974040873983,98.13021281519542,957.024744261115 +2024-01-01 11:15:00+00:00,98.13021281519542,98.22385515251219,97.83745478133173,97.9310971186485,533.8276836277155 +2024-01-01 11:20:00+00:00,97.9310971186485,98.07171522443434,97.85190377845011,97.99252188423596,957.8849724146701 +2024-01-01 11:25:00+00:00,97.99252188423596,98.3259185372893,97.823523251129,98.15691990418235,247.21918993909898 +2024-01-01 11:30:00+00:00,98.15691990418235,98.90361996049911,97.80298948957237,98.54968954588914,598.9748968359402 +2024-01-01 11:35:00+00:00,98.54968954588914,99.24264008419634,98.43273623691252,99.12568677521972,286.9455280163853 +2024-01-01 11:40:00+00:00,99.12568677521972,99.45884446079879,98.87472038514304,99.2078780707221,327.8661455139414 +2024-01-01 11:45:00+00:00,99.2078780707221,99.33058768680309,98.88902265808802,99.011732274169,127.04912088506092 +2024-01-01 11:50:00+00:00,99.011732274169,99.04835850790747,98.55380971096606,98.59043594470452,207.0579796661902 +2024-01-01 11:55:00+00:00,98.59043594470452,98.66975690054038,98.56391670279103,98.64323765862689,925.1635668662761 +2024-01-01 12:00:00+00:00,98.64323765862689,98.68066772787068,98.44555561676499,98.48298568600879,389.37969233409854 +2024-01-01 12:05:00+00:00,98.48298568600879,98.50648166156567,98.37771243572176,98.40120841127865,647.3162907231324 +2024-01-01 12:10:00+00:00,98.40120841127865,98.4311921558742,98.25083624170335,98.28081998629891,518.5127187903456 +2024-01-01 12:15:00+00:00,98.28081998629891,98.42945775050244,98.1045120304618,98.25314979466533,460.4061207207505 +2024-01-01 12:20:00+00:00,98.25314979466533,98.56724271687825,98.14875215463863,98.46284507685155,578.6974822114613 +2024-01-01 12:25:00+00:00,98.46284507685155,98.59167954321879,98.36494236554967,98.4937768319169,268.51505015380735 +2024-01-01 12:30:00+00:00,98.4937768319169,98.55064454770778,98.40566498432176,98.46253270011263,989.7435086676189 +2024-01-01 12:35:00+00:00,98.46253270011263,98.54455916046682,98.1767711286243,98.25879758897848,836.4770818334766 +2024-01-01 12:40:00+00:00,98.25879758897848,98.30234001945401,97.88670102467864,97.93024345515417,767.4531694583637 +2024-01-01 12:45:00+00:00,97.93024345515417,98.22889567595286,97.53638903558323,97.83504125638191,521.8833673125572 +2024-01-01 12:50:00+00:00,97.83504125638191,97.84782117436197,97.8117382682132,97.82451818619326,237.58940991370238 +2024-01-01 12:55:00+00:00,97.82451818619326,98.21624421860041,97.779298175102,98.17102420750915,928.5992724462111 +2024-01-01 13:00:00+00:00,98.17102420750915,98.30759668268223,98.0600334312372,98.19660590641028,407.24836064355486 +2024-01-01 13:05:00+00:00,98.19660590641028,98.54214524683952,98.0442597312892,98.38979907171844,145.0960207272404 +2024-01-01 13:10:00+00:00,98.38979907171844,98.57328075071472,98.10811524554715,98.29159692454343,408.1633091295658 +2024-01-01 13:15:00+00:00,98.29159692454343,98.42232869842294,97.92820092344977,98.05893269732928,815.0113274462959 +2024-01-01 13:20:00+00:00,98.05893269732928,98.06930891364755,97.85946239832482,97.86983861464309,660.4378704893874 +2024-01-01 13:25:00+00:00,97.86983861464309,97.91884359909909,97.67898101448007,97.72798599893608,775.7547262703737 +2024-01-01 13:30:00+00:00,97.72798599893608,98.15242386561995,97.7204570009962,98.14489486768008,814.2718454761397 +2024-01-01 13:35:00+00:00,98.14489486768008,98.25740994797937,97.87128232836083,97.98379740866012,290.75914997248833 +2024-01-01 13:40:00+00:00,97.98379740866012,98.28077928815672,97.85127009653857,98.14825197603517,931.4049844838622 +2024-01-01 13:45:00+00:00,98.14825197603517,98.25691366348089,97.86250878003798,97.9711704674837,494.19064262833535 +2024-01-01 13:50:00+00:00,97.9711704674837,98.17738036440483,97.94766531741638,98.1538752143375,675.1122843377482 +2024-01-01 13:55:00+00:00,98.1538752143375,98.32567658366325,98.05767180096531,98.22947317029106,102.0778953280678 +2024-01-01 14:00:00+00:00,98.22947317029106,98.31025553776865,98.11792270618416,98.19870507366176,994.0316485700603 +2024-01-01 14:05:00+00:00,98.19870507366176,98.22639096079513,98.16301385828616,98.19069974541954,352.8067473677396 +2024-01-01 14:10:00+00:00,98.19069974541954,98.40370955366585,97.8491859743543,98.06219578260061,155.8567958861397 +2024-01-01 14:15:00+00:00,98.06219578260061,98.15972882291227,98.05218741791829,98.14972045822995,512.4358373828625 +2024-01-01 14:20:00+00:00,98.14972045822995,98.1882634819471,98.02190505531355,98.0604480790307,216.12705146383757 +2024-01-01 14:25:00+00:00,98.0604480790307,98.19246080184942,97.68836280998693,97.82037553280564,237.09403914181814 +2024-01-01 14:30:00+00:00,97.82037553280564,97.84816025566346,97.54289337675404,97.57067809961185,669.0545317271891 +2024-01-01 14:35:00+00:00,97.57067809961185,97.8173584860294,97.35768256678648,97.60436295320403,453.63465241320995 +2024-01-01 14:40:00+00:00,97.60436295320403,98.10934780703305,97.40811776311529,97.9131026169443,929.6672051394955 +2024-01-01 14:45:00+00:00,97.9131026169443,98.12778282090598,97.72975797669798,97.94443818065966,387.24084514726303 +2024-01-01 14:50:00+00:00,97.94443818065966,97.98152838163554,97.88411080821687,97.92120100919276,753.562115545511 +2024-01-01 14:55:00+00:00,97.92120100919276,98.03060924382895,97.86778565500002,97.97719388963621,514.9894871394168 +2024-01-01 15:00:00+00:00,97.97719388963621,98.58854758628648,97.62209148366186,98.23344518031213,693.9561710163509 +2024-01-01 15:05:00+00:00,98.23344518031213,98.4470188336756,98.06298238186672,98.27655603523019,639.7258210443913 +2024-01-01 15:10:00+00:00,98.27655603523019,98.31982782825861,98.15254839724848,98.1958201902769,525.5055754830984 +2024-01-01 15:15:00+00:00,98.1958201902769,98.57159484667464,98.03755192451311,98.41332658091085,955.0549851619934 +2024-01-01 15:20:00+00:00,98.41332658091085,98.60330314205132,98.30777690797281,98.49775346911328,408.4422784794603 +2024-01-01 15:25:00+00:00,98.49775346911328,99.09675720799875,98.2017518595253,98.80075559841077,259.5119511243453 +2024-01-01 15:30:00+00:00,98.80075559841077,99.01140918530764,98.62631604846564,98.83696963536251,710.2846547918455 +2024-01-01 15:35:00+00:00,98.83696963536251,98.96082446840745,98.47136532087612,98.59522015392106,861.4068051771179 +2024-01-01 15:40:00+00:00,98.59522015392106,98.62994612803882,98.29107504127056,98.32580101538832,136.2283659459314 +2024-01-01 15:45:00+00:00,98.32580101538832,98.8894025320248,98.08739369820404,98.65099521484052,504.6694195804125 +2024-01-01 15:50:00+00:00,98.65099521484052,99.17317205764556,98.46948791261164,98.99166475541668,903.2389207185937 +2024-01-01 15:55:00+00:00,98.99166475541668,99.02863023150454,98.91916384744648,98.95612932353434,774.6567570305808 +2024-01-01 16:00:00+00:00,98.95612932353434,99.22626524579061,98.61018498560122,98.8803209078575,992.6269448478654 +2024-01-01 16:05:00+00:00,98.8803209078575,99.41546669902547,98.63461407074551,99.16975986191349,578.2724304375195 +2024-01-01 16:10:00+00:00,99.16975986191349,99.35990338878179,98.76028832188543,98.95043184875372,693.9961469210361 +2024-01-01 16:15:00+00:00,98.95043184875372,99.01319352403941,98.71076125603885,98.77352293132454,372.23225302413533 +2024-01-01 16:20:00+00:00,98.77352293132454,98.9779218643299,98.69629309988098,98.90069203288634,952.7820396993931 +2024-01-01 16:25:00+00:00,98.90069203288634,99.02114717548935,98.70221424300829,98.82266938561129,429.7019397008827 +2024-01-01 16:30:00+00:00,98.82266938561129,98.85849120655374,98.78583525677074,98.82165707771318,764.6596414179658 +2024-01-01 16:35:00+00:00,98.82165707771318,99.08467812379075,98.5263379146815,98.78935896075907,462.8261837071737 +2024-01-01 16:40:00+00:00,98.78935896075907,98.93250620505718,98.71293178347773,98.85607902777583,605.6370370566995 +2024-01-01 16:45:00+00:00,98.85607902777583,99.22062859809742,98.77019777025978,99.13474734058137,747.8042815193916 +2024-01-01 16:50:00+00:00,99.13474734058137,99.34225653274358,98.94519999915977,99.15270919132197,558.0407365762188 +2024-01-01 16:55:00+00:00,99.15270919132197,99.37394880253764,99.05924839599493,99.2804880072106,916.3776942185576 +2024-01-01 17:00:00+00:00,99.2804880072106,99.37490528305348,98.77982001106719,98.87423728691007,477.3708891350292 +2024-01-01 17:05:00+00:00,98.87423728691007,98.95448543034172,98.7843556231504,98.86460376658205,682.3646183289926 +2024-01-01 17:10:00+00:00,98.86460376658205,98.9775527584209,98.5850640354684,98.69801302730725,408.1261899700487 +2024-01-01 17:15:00+00:00,98.69801302730725,98.76419128796323,98.39153890673953,98.4577171673955,467.34100140791173 +2024-01-01 17:20:00+00:00,98.4577171673955,98.70622043234283,98.03644391049026,98.2849471754376,496.0450974101972 +2024-01-01 17:25:00+00:00,98.2849471754376,98.36421521687382,98.14002246443827,98.2192905058745,213.22631648105673 +2024-01-01 17:30:00+00:00,98.2192905058745,98.55203688740177,98.06662760867782,98.39937399020509,182.55131115310604 +2024-01-01 17:35:00+00:00,98.39937399020509,98.43400852338847,98.10405295639306,98.13868748957644,700.7409308320821 +2024-01-01 17:40:00+00:00,98.13868748957644,98.3542979261436,97.92908950613594,98.1446999427031,689.965964508747 +2024-01-01 17:45:00+00:00,98.1446999427031,98.2103425774753,97.98406597973916,98.04970861451136,696.6187427890858 +2024-01-01 17:50:00+00:00,98.04970861451136,98.16826107744735,97.86692069925775,97.98547316219374,117.78980080842251 +2024-01-01 17:55:00+00:00,97.98547316219374,98.37107954981511,97.79657535967286,98.18218174729422,393.8921584046019 +2024-01-01 18:00:00+00:00,98.18218174729422,98.39314515284123,98.07694191830497,98.28790532385197,278.0288625332772 +2024-01-01 18:05:00+00:00,98.28790532385197,98.58687887771835,98.25218380293171,98.55115735679809,800.3362802896112 +2024-01-01 18:10:00+00:00,98.55115735679809,98.64189150113566,98.42997449018677,98.52070863452434,874.5673612565171 +2024-01-01 18:15:00+00:00,98.52070863452434,98.73486709582245,98.16951604469752,98.38367450599563,322.0123395035326 +2024-01-01 18:20:00+00:00,98.38367450599563,98.44867490427644,98.27463586084976,98.33963625913057,701.0642429631877 +2024-01-01 18:25:00+00:00,98.33963625913057,98.39208040220599,98.33489777608476,98.38734191916018,216.426523112447 +2024-01-01 18:30:00+00:00,98.38734191916018,98.46179375836122,98.34764138253618,98.42209322173723,347.637273041542 +2024-01-01 18:35:00+00:00,98.42209322173723,98.51337825759286,98.11758399935498,98.20886903521061,224.78904661007758 +2024-01-01 18:40:00+00:00,98.20886903521061,98.29606164764378,98.13945182944742,98.2266444418806,355.1686899393063 +2024-01-01 18:45:00+00:00,98.2266444418806,98.3501256186083,98.14800970567471,98.27149088240242,707.2628926143501 +2024-01-01 18:50:00+00:00,98.27149088240242,98.82851520587742,98.21050613161346,98.76753045508846,919.2942191667272 +2024-01-01 18:55:00+00:00,98.76753045508846,99.3416062486384,98.56489397388155,99.13896976743149,282.2701671317674 +2024-01-01 19:00:00+00:00,99.13896976743149,99.29344986793654,98.81545460252106,98.96993470302611,587.8711314556473 +2024-01-01 19:05:00+00:00,98.96993470302611,99.32791795473044,98.55508317083047,98.9130664225348,731.2519911190891 +2024-01-01 19:10:00+00:00,98.9130664225348,99.15138195581828,98.38566708114497,98.62398261442846,957.6302216370092 +2024-01-01 19:15:00+00:00,98.62398261442846,99.00067463571943,98.13084363616512,98.5075356574561,649.0544345984565 +2024-01-01 19:20:00+00:00,98.5075356574561,98.6296551535398,98.44761473177701,98.5697342278607,329.59623792464265 +2024-01-01 19:25:00+00:00,98.5697342278607,99.0948043488913,98.28267233641164,98.80774245744223,662.9303412225427 +2024-01-01 19:30:00+00:00,98.80774245744223,98.85369273649162,98.61781891634226,98.66376919539165,388.44204551021454 +2024-01-01 19:35:00+00:00,98.66376919539165,98.7060735652575,98.49246811989546,98.53477248976131,978.515551622537 +2024-01-01 19:40:00+00:00,98.53477248976131,98.56272334819239,98.0845637180741,98.11251457650518,459.7085216472732 +2024-01-01 19:45:00+00:00,98.11251457650518,98.27631722835598,97.91679799124408,98.08060064309488,716.8849486849128 +2024-01-01 19:50:00+00:00,98.08060064309488,98.16568521817236,97.78733283591652,97.87241741099399,120.90482411208264 +2024-01-01 19:55:00+00:00,97.87241741099399,97.94933812529193,97.69191651254947,97.76883722684741,460.05530152127983 +2024-01-01 20:00:00+00:00,97.76883722684741,97.98761266870854,97.37875272570442,97.59752816756554,887.5968519902605 +2024-01-01 20:05:00+00:00,97.59752816756554,97.69986868106473,97.47678980376158,97.57913031726076,649.2046999899212 +2024-01-01 20:10:00+00:00,97.57913031726076,97.87852477980357,96.93730289598784,97.23669735853065,499.5485977563787 +2024-01-01 20:15:00+00:00,97.23669735853065,97.26170515783876,96.92680643096381,96.95181423027192,942.3437494921533 +2024-01-01 20:20:00+00:00,96.95181423027192,97.4148291016076,96.90254844863172,97.36556331996739,868.5859385325062 +2024-01-01 20:25:00+00:00,97.36556331996739,97.38624932252861,97.09449854930357,97.11518455186479,389.99410644064443 +2024-01-01 20:30:00+00:00,97.11518455186479,97.20461432767992,96.81295918478109,96.90238896059623,590.0935618372057 +2024-01-01 20:35:00+00:00,96.90238896059623,97.51153153138739,96.64990375581198,97.25904632660314,540.8418304832164 +2024-01-01 20:40:00+00:00,97.25904632660314,97.84990172724936,97.23492385367176,97.82577925431798,668.9822501033761 +2024-01-01 20:45:00+00:00,97.82577925431798,97.8829413191329,97.53966668819373,97.59682875300865,400.70014026288004 +2024-01-01 20:50:00+00:00,97.59682875300865,97.86714238628322,97.25466172217249,97.52497535544705,319.7845006892994 +2024-01-01 20:55:00+00:00,97.52497535544705,97.6171152070585,97.49947865695893,97.59161850857038,921.4046517299824 +2024-01-01 21:00:00+00:00,97.59161850857038,98.17461865624334,97.34661512046922,97.92961526814219,931.4870495486557 +2024-01-01 21:05:00+00:00,97.92961526814219,98.0914289316742,97.57470715588751,97.73652081941952,858.9905971857811 +2024-01-01 21:10:00+00:00,97.73652081941952,97.8225735970578,97.6025345931626,97.68858737080087,980.3839876881052 +2024-01-01 21:15:00+00:00,97.68858737080087,97.88745496917541,97.64171191044397,97.84057950881851,770.4923732017197 +2024-01-01 21:20:00+00:00,97.84057950881851,98.05334511658849,97.71292642907869,97.92569203684867,788.2005057057996 +2024-01-01 21:25:00+00:00,97.92569203684867,97.95172989681595,97.82601119443156,97.85204905439883,786.3310827663767 +2024-01-01 21:30:00+00:00,97.85204905439883,98.02997260656268,97.6479393041291,97.82586285629294,532.5468678480759 +2024-01-01 21:35:00+00:00,97.82586285629294,97.87324518973438,97.50984929448677,97.5572316279282,510.8069750628445 +2024-01-01 21:40:00+00:00,97.5572316279282,97.80470850296517,97.26329467712269,97.51077155215965,316.20364853410285 +2024-01-01 21:45:00+00:00,97.51077155215965,97.51333903104737,97.45626661062555,97.45883408951326,680.3372749233614 +2024-01-01 21:50:00+00:00,97.45883408951326,97.63608353690192,97.32684916387765,97.50409861126631,360.3881224921011 +2024-01-01 21:55:00+00:00,97.50409861126631,97.55411249922412,97.34585147949024,97.39586536744804,332.027568732119 +2024-01-01 22:00:00+00:00,97.39586536744804,97.49969121107169,97.38393465402214,97.4877604976458,627.3652366864493 +2024-01-01 22:05:00+00:00,97.4877604976458,97.93534141036767,97.23783447979083,97.68541539251271,468.9841494025389 +2024-01-01 22:10:00+00:00,97.68541539251271,97.95259945763877,97.44860240457314,97.7157864696992,296.97094791516895 +2024-01-01 22:15:00+00:00,97.7157864696992,97.85526300126945,97.64507843323646,97.78455496480672,834.450415077548 +2024-01-01 22:20:00+00:00,97.78455496480672,97.87162993538506,97.70787609084051,97.79495106141886,874.5129273649384 +2024-01-01 22:25:00+00:00,97.79495106141886,98.17119620244902,97.41872242682658,97.79496756785674,250.92956771000286 +2024-01-01 22:30:00+00:00,97.79496756785674,97.90993223638593,97.53897519449879,97.65393986302797,106.75632951999988 +2024-01-01 22:35:00+00:00,97.65393986302797,97.75569548594119,97.6140176311565,97.71577325406972,402.51697167643636 +2024-01-01 22:40:00+00:00,97.71577325406972,97.82038814249202,97.59214734484246,97.69676223326476,817.7597521126772 +2024-01-01 22:45:00+00:00,97.69676223326476,98.30039567444355,97.50297760926112,98.10661105043991,855.256479610892 +2024-01-01 22:50:00+00:00,98.10661105043991,98.53919537746842,97.9832259833705,98.41581031039901,224.4961631044443 +2024-01-01 22:55:00+00:00,98.41581031039901,98.54339867435725,98.36419806004254,98.49178642400078,796.9023110647461 +2024-01-01 23:00:00+00:00,98.49178642400078,98.84324977396192,97.99012797519026,98.3415913251514,204.1337505784865 +2024-01-01 23:05:00+00:00,98.3415913251514,98.40343660037146,98.06119662823748,98.12304190345755,451.0563311408763 +2024-01-01 23:10:00+00:00,98.12304190345755,98.41427772464944,98.06584188147154,98.35707770266343,998.7485635107242 +2024-01-01 23:15:00+00:00,98.35707770266343,98.4334185379049,98.33243694229506,98.40877777753653,273.3317409078188 +2024-01-01 23:20:00+00:00,98.40877777753653,98.62400652349866,98.28809507093746,98.5033238168996,238.56722431986378 +2024-01-01 23:25:00+00:00,98.5033238168996,98.59536157541879,98.06818993035499,98.16022768887419,480.52029929211415 +2024-01-01 23:30:00+00:00,98.16022768887419,98.52711839889777,97.97558109183643,98.34247180186001,657.7016733728753 +2024-01-01 23:35:00+00:00,98.34247180186001,98.50886226810351,98.26549960153433,98.43189006777783,939.610704171928 +2024-01-01 23:40:00+00:00,98.43189006777783,98.49603442451681,98.14938469345118,98.21352905019016,982.7390309797308 +2024-01-01 23:45:00+00:00,98.21352905019016,98.28404413938816,98.05043738925461,98.12095247845261,162.67657293225517 +2024-01-01 23:50:00+00:00,98.12095247845261,98.28917122753313,98.00449974631414,98.17271849539466,224.1977656131148 +2024-01-01 23:55:00+00:00,98.17271849539466,98.40369304528208,97.95204610246974,98.18302065235716,811.6641765063945 +2024-01-02 00:00:00+00:00,98.18302065235716,98.25055669507577,98.05812886982555,98.12566491254417,343.56893704377444 +2024-01-02 00:05:00+00:00,98.12566491254417,98.1881737800519,98.04284843647893,98.10535730398666,896.3641535557246 +2024-01-02 00:10:00+00:00,98.10535730398666,98.15155300497945,98.00973339738663,98.05592909837941,697.1079315061781 +2024-01-02 00:15:00+00:00,98.05592909837941,98.12201157068759,98.01977050884138,98.08585298114956,200.66032214260946 +2024-01-02 00:20:00+00:00,98.08585298114956,98.51543059332559,97.94536564499144,98.37494325716747,849.2763564316065 +2024-01-02 00:25:00+00:00,98.37494325716747,98.70551963568968,97.54067104291059,97.8712474214328,258.72074200726826 +2024-01-02 00:30:00+00:00,97.8712474214328,97.99255592520493,97.70358823501954,97.82489673879167,481.1458552116884 +2024-01-02 00:35:00+00:00,97.82489673879167,97.97428768753052,97.71004650534714,97.85943745408599,596.966435703397 +2024-01-02 00:40:00+00:00,97.85943745408599,98.25819100925335,97.51863266035251,97.91738621551987,550.6895341722504 +2024-01-02 00:45:00+00:00,97.91738621551987,98.05881641307344,97.70314929180942,97.844579489363,717.5466240536354 +2024-01-02 00:50:00+00:00,97.844579489363,97.97842300862497,97.36756776600117,97.50141128526315,687.4180613823297 +2024-01-02 00:55:00+00:00,97.50141128526315,97.59482359534304,97.47198000349182,97.56539231357172,992.8378049752494 +2024-01-02 01:00:00+00:00,97.56539231357172,98.06647851349388,97.40194820659498,97.90303440651714,999.479718301769 +2024-01-02 01:05:00+00:00,97.90303440651714,97.9389124456903,97.56727720379668,97.60315524296985,564.8134623720769 +2024-01-02 01:10:00+00:00,97.60315524296985,97.92310263271696,97.45197827931435,97.77192566906146,185.6791782647776 +2024-01-02 01:15:00+00:00,97.77192566906146,97.78027301538667,97.69935833554109,97.7077056818663,755.0589690718465 +2024-01-02 01:20:00+00:00,97.7077056818663,97.86145534463432,97.5419730316954,97.69572269446343,978.9976703949303 +2024-01-02 01:25:00+00:00,97.69572269446343,97.83844234198645,97.34749214315345,97.49021179067647,386.49374886611827 +2024-01-02 01:30:00+00:00,97.49021179067647,97.62328095671865,97.29195202430836,97.42502119035053,515.0879972538164 +2024-01-02 01:35:00+00:00,97.42502119035053,97.76050201412669,97.34318371571779,97.67866453949394,482.2992323339103 +2024-01-02 01:40:00+00:00,97.67866453949394,97.82505373320153,97.64616766470324,97.79255685841083,146.27723910123643 +2024-01-02 01:45:00+00:00,97.79255685841083,98.22726724273899,97.69724844689202,98.13195883122017,705.0379881011004 +2024-01-02 01:50:00+00:00,98.13195883122017,98.36532811088304,98.12994531570831,98.36331459537118,418.00392556162103 +2024-01-02 01:55:00+00:00,98.36331459537118,98.55335047484063,98.25969669760097,98.44973257707042,808.717487168352 +2024-01-02 02:00:00+00:00,98.44973257707042,98.94710069466552,98.2963437636108,98.79371188120591,848.9080677537875 +2024-01-02 02:05:00+00:00,98.79371188120591,98.8822819019739,98.79191947692787,98.88048949769586,839.1077079380609 +2024-01-02 02:10:00+00:00,98.88048949769586,99.07567039215593,98.84918800843575,99.04436890289581,474.9993982358921 +2024-01-02 02:15:00+00:00,99.04436890289581,99.22490251293134,98.80510534632285,98.98563895635837,389.3911140168096 +2024-01-02 02:20:00+00:00,98.98563895635837,99.23098768710338,98.75346526254289,98.9988139932879,773.9387017003494 +2024-01-02 02:25:00+00:00,98.9988139932879,99.10050456324896,98.75913142189437,98.86082199185543,821.2504904584507 +2024-01-02 02:30:00+00:00,98.86082199185543,99.10882417182604,98.80867572622677,99.05667790619738,542.1317017522296 +2024-01-02 02:35:00+00:00,99.05667790619738,99.20821590241962,98.67197706956128,98.82351506578352,903.7279175826727 +2024-01-02 02:40:00+00:00,98.82351506578352,98.99254476133638,98.80923562927734,98.97826532483019,229.51782647070246 +2024-01-02 02:45:00+00:00,98.97826532483019,99.14567550343467,98.77312171869232,98.94053189729681,891.1619016930641 +2024-01-02 02:50:00+00:00,98.94053189729681,99.51185114185192,98.60125194280204,99.17257118735715,186.48570967469314 +2024-01-02 02:55:00+00:00,99.17257118735715,99.54458714369329,98.9495983313273,99.32161428766344,238.1729698491525 +2024-01-02 03:00:00+00:00,99.32161428766344,99.82192799633904,99.18361886289578,99.68393257157139,580.4560119465643 +2024-01-02 03:05:00+00:00,99.68393257157139,100.04853572446638,99.46512892938792,99.8297320822829,160.7666321686735 +2024-01-02 03:10:00+00:00,99.8297320822829,99.87191512089201,99.47416923241644,99.51635227102555,147.52639126009933 +2024-01-02 03:15:00+00:00,99.51635227102555,99.63087785350454,99.38850170963649,99.50302729211548,100.46686974913497 +2024-01-02 03:20:00+00:00,99.50302729211548,99.672803063073,99.10028813524545,99.27006390620298,492.68499372483683 +2024-01-02 03:25:00+00:00,99.27006390620298,99.43659579329118,99.00068598520365,99.16721787229184,797.1614931630069 +2024-01-02 03:30:00+00:00,99.16721787229184,99.53421422510243,99.10040357321142,99.46739992602201,131.3545152519241 +2024-01-02 03:35:00+00:00,99.46739992602201,99.60301416592826,99.45869423848468,99.59430847839093,687.0318458192698 +2024-01-02 03:40:00+00:00,99.59430847839093,99.67617086095326,99.37332436947263,99.45518675203496,840.8145856745679 +2024-01-02 03:45:00+00:00,99.45518675203496,99.48312732705523,99.22581154381398,99.25375211883424,251.200763720084 +2024-01-02 03:50:00+00:00,99.25375211883424,99.30167275144294,99.21233919099996,99.26025982360865,232.17736133926383 +2024-01-02 03:55:00+00:00,99.26025982360865,99.28374502389376,98.99555604619717,99.01904124648227,874.7208762584825 +2024-01-02 04:00:00+00:00,99.01904124648227,99.13440696112207,98.77085336047433,98.88621907511413,840.8121489550853 +2024-01-02 04:05:00+00:00,98.88621907511413,99.06772292305745,98.76644135821998,98.9479452061633,585.0475896377682 +2024-01-02 04:10:00+00:00,98.9479452061633,99.41782173551684,98.70696452518855,99.1768410545421,837.2798913645822 +2024-01-02 04:15:00+00:00,99.1768410545421,99.63238745107579,98.8421182753881,99.2976646719218,183.48046968204528 +2024-01-02 04:20:00+00:00,99.2976646719218,99.44618895364664,98.69514203178836,98.84366631351321,455.033366144879 +2024-01-02 04:25:00+00:00,98.84366631351321,99.08005996157236,98.66746042955077,98.90385407760992,764.0178004957356 +2024-01-02 04:30:00+00:00,98.90385407760992,99.06954104091282,98.75241693677937,98.91810390008227,331.70676339156705 +2024-01-02 04:35:00+00:00,98.91810390008227,99.27620589876403,98.64191828562204,99.0000202843038,777.0960229901975 +2024-01-02 04:40:00+00:00,99.0000202843038,99.33530191613104,98.98526599531891,99.32054762714615,553.7607439517325 +2024-01-02 04:45:00+00:00,99.32054762714615,99.45865360222813,98.7734421847089,98.91154815979087,775.2393891534098 +2024-01-02 04:50:00+00:00,98.91154815979087,99.17793733222479,98.52829417034513,98.79468334277905,518.2116740715967 +2024-01-02 04:55:00+00:00,98.79468334277905,98.98811082644674,98.71808168472425,98.91150916839194,431.11390619963584 +2024-01-02 05:00:00+00:00,98.91150916839194,98.9664855814508,98.54415129867877,98.59912771173762,297.2702948307691 +2024-01-02 05:05:00+00:00,98.59912771173762,99.02309564488041,98.46664436069726,98.89061229384005,287.5414072915135 +2024-01-02 05:10:00+00:00,98.89061229384005,98.96519304837449,98.8889124041027,98.96349315863714,776.3417295142564 +2024-01-02 05:15:00+00:00,98.96349315863714,99.17569661329989,98.91899345620315,99.13119691086591,207.01931323056445 +2024-01-02 05:20:00+00:00,99.13119691086591,99.28196200045318,98.86729976852922,99.01806485811649,177.38411509985062 +2024-01-02 05:25:00+00:00,99.01806485811649,99.48414206546728,98.71327347537621,99.179350682727,258.6622285199171 +2024-01-02 05:30:00+00:00,99.179350682727,99.65766382280131,98.91320478687125,99.39151792694555,257.81186030852973 +2024-01-02 05:35:00+00:00,99.39151792694555,99.6072702171905,99.22206861863683,99.43782090888178,840.7968605262098 +2024-01-02 05:40:00+00:00,99.43782090888178,99.62186390646481,99.3004054679638,99.48444846554683,458.65596112679117 +2024-01-02 05:45:00+00:00,99.48444846554683,99.66591360062759,99.35678777093469,99.53825290601544,991.3496116244341 +2024-01-02 05:50:00+00:00,99.53825290601544,99.63358909069683,99.27119326202138,99.36652944670277,223.49013201155114 +2024-01-02 05:55:00+00:00,99.36652944670277,99.43247132139506,99.27127307105413,99.33721494574641,688.185843124279 +2024-01-02 06:00:00+00:00,99.33721494574641,99.52336555362422,99.1207666320058,99.3069172398836,503.85655648112555 +2024-01-02 06:05:00+00:00,99.3069172398836,99.47781149208166,99.2121995150816,99.38309376727965,453.62635902736656 +2024-01-02 06:10:00+00:00,99.38309376727965,99.69256202286928,99.27255559428629,99.58202384987592,887.5757791486741 +2024-01-02 06:15:00+00:00,99.58202384987592,99.67695927687716,99.27648909779482,99.37142452479605,978.0360812455597 +2024-01-02 06:20:00+00:00,99.37142452479605,99.42220921763673,99.29579828665962,99.3465829795003,885.5730628967609 +2024-01-02 06:25:00+00:00,99.3465829795003,99.90795677449431,99.08000078248568,99.6413745774797,273.0706111316197 +2024-01-02 06:30:00+00:00,99.6413745774797,99.65385418294213,99.48082079904349,99.49330040450592,298.71812908804066 +2024-01-02 06:35:00+00:00,99.49330040450592,99.57617007368161,99.24694845952176,99.32981812869745,690.466491185557 +2024-01-02 06:40:00+00:00,99.32981812869745,99.56078306027717,99.13905140339716,99.37001633497688,360.17522347437256 +2024-01-02 06:45:00+00:00,99.37001633497688,99.7890781755096,99.11890941351706,99.53797125404978,761.2526177025219 +2024-01-02 06:50:00+00:00,99.53797125404978,99.79840286499287,99.27981432187708,99.54024593282017,609.7785857286801 +2024-01-02 06:55:00+00:00,99.54024593282017,100.00865383631938,99.33676007232711,99.80516797582632,595.8178916413626 +2024-01-02 07:00:00+00:00,99.80516797582632,100.01461248222101,99.76689502100302,99.97633952739771,845.6922323549072 +2024-01-02 07:05:00+00:00,99.97633952739771,100.3476926183096,99.77345239236976,100.14480548328164,739.47949456841 +2024-01-02 07:10:00+00:00,100.14480548328164,100.25766338524313,100.14299288046784,100.25585078242933,123.91998849756253 +2024-01-02 07:15:00+00:00,100.25585078242933,100.75430024626863,100.22521105632033,100.72366052015963,144.51322343141413 +2024-01-02 07:20:00+00:00,100.72366052015963,100.8888004041612,100.51719984157961,100.68233972558119,641.4026072104141 +2024-01-02 07:25:00+00:00,100.68233972558119,100.74205498391298,100.21999306286264,100.27970832119443,537.572868060048 +2024-01-02 07:30:00+00:00,100.27970832119443,100.6110857740584,100.27059590585054,100.6019733587145,334.16618980869225 +2024-01-02 07:35:00+00:00,100.6019733587145,100.79836755770856,100.31353036491033,100.50992456390439,476.7904785833215 +2024-01-02 07:40:00+00:00,100.50992456390439,100.53933340657449,100.5022041715555,100.5316130142256,782.1192868595745 +2024-01-02 07:45:00+00:00,100.5316130142256,100.8073151519984,100.51955847097484,100.79526060874764,843.950336192137 +2024-01-02 07:50:00+00:00,100.79526060874764,101.06616955278395,100.20186830670428,100.47277725074059,605.1007611010398 +2024-01-02 07:55:00+00:00,100.47277725074059,100.60720645815937,100.08714964279876,100.22157885021754,446.83253277139266 +2024-01-02 08:00:00+00:00,100.22157885021754,100.22329396861478,99.89941193203421,99.90112705043146,343.62708534559636 +2024-01-02 08:05:00+00:00,99.90112705043146,99.93834908369092,99.70536073545807,99.74258276871754,569.7266236923281 +2024-01-02 08:10:00+00:00,99.74258276871754,99.83694292164478,99.73596216478268,99.83032231770991,383.2746567079429 +2024-01-02 08:15:00+00:00,99.83032231770991,99.96545420565727,99.79990499287865,99.935036880826,607.6856233425005 +2024-01-02 08:20:00+00:00,99.935036880826,100.15261936314106,99.77268859748949,99.99027107980454,708.9871442586739 +2024-01-02 08:25:00+00:00,99.99027107980454,100.01286273338651,99.68555250580499,99.70814415938696,159.47128982745534 +2024-01-02 08:30:00+00:00,99.70814415938696,99.81921789720195,99.13746073211006,99.24853446992505,100.96071852047251 +2024-01-02 08:35:00+00:00,99.24853446992505,99.44548229668237,99.06237625699765,99.25932408375498,291.04942495666864 +2024-01-02 08:40:00+00:00,99.25932408375498,99.33536759653352,99.08966840146392,99.16571191424246,905.0603479469029 +2024-01-02 08:45:00+00:00,99.16571191424246,99.31511797113664,99.10745835928971,99.25686441618389,681.6616869057033 +2024-01-02 08:50:00+00:00,99.25686441618389,99.6626979089578,98.99047621644429,99.3963097092182,254.76431674332056 +2024-01-02 08:55:00+00:00,99.3963097092182,99.44209154337507,99.37801305086741,99.42379488502428,901.2375656412569 +2024-01-02 09:00:00+00:00,99.42379488502428,99.7237810083638,99.27507434645801,99.57506046979753,522.960346258606 +2024-01-02 09:05:00+00:00,99.57506046979753,99.77899935192647,99.41677952506333,99.62071840719227,528.4825810107945 +2024-01-02 09:10:00+00:00,99.62071840719227,99.87983976142729,99.46726390687934,99.72638526111436,941.9877494583895 +2024-01-02 09:15:00+00:00,99.72638526111436,99.73219807326696,99.58012240900484,99.58593522115744,153.538022965006 diff --git a/tests/generate_golden.py b/tests/generate_golden.py new file mode 100644 index 0000000..debd113 --- /dev/null +++ b/tests/generate_golden.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +"""生成 / 校验缠论流水线 golden(行为冻结基线)。""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + + +def make_ohlcv(n: int = 400, seed: int = 42) -> pd.DataFrame: + rng = np.random.default_rng(seed) + dates = pd.date_range("2024-01-01", periods=n, freq="5min", tz="UTC") + # 随机游走 + 波动,保证高低开收合法 + rets = rng.normal(0, 0.002, size=n) + close = 100 * np.exp(np.cumsum(rets)) + open_ = np.roll(close, 1) + open_[0] = close[0] + spread = np.abs(rng.normal(0, 0.0015, size=n)) * close + high = np.maximum(open_, close) + spread + low = np.minimum(open_, close) - spread + volume = rng.uniform(100, 1000, size=n) + return pd.DataFrame( + { + "date": dates, + "open": open_, + "high": high, + "low": low, + "close": close, + "volume": volume, + } + ) + + +def _enum_name(v): + if v is None: + return None + name = getattr(v, "name", None) + if name: + return name + s = str(v) + return s.split(".")[-1] if "." in s else s + + +def _t(obj, attr="time"): + t = getattr(obj, attr, None) + if t is None: + return None + if hasattr(t, "strftime"): + return t.strftime("%Y-%m-%d %H:%M:%S") + return str(t) + + +def _f(v, nd=10): + if v is None: + return None + try: + return round(float(v), nd) + except (TypeError, ValueError): + return None + + +def serialize_pipeline(tf) -> dict: + bi_list = [ + { + "idx": getattr(b, "idx", i), + "dir": _enum_name(getattr(b, "dir", None)), + "high": _f(b.get_high() if hasattr(b, "get_high") else getattr(b, "high", 0)), + "low": _f(b.get_low() if hasattr(b, "get_low") else getattr(b, "low", 0)), + "is_sure": bool(getattr(b, "is_sure", True)), + "begin": _t(getattr(b, "begin_klc", None), "end_time") + or _t(getattr(b, "begin_klc", None), "time"), + "end": _t(getattr(b, "end_klc", None), "end_time") + or _t(getattr(b, "end_klc", None), "time"), + } + for i, b in enumerate(getattr(tf, "bi_list", []) or []) + ] + seg_list = [ + { + "idx": getattr(s, "idx", i), + "dir": _enum_name(getattr(s, "dir", None)), + "is_sure": bool(getattr(s, "is_sure", True)), + "high": _f(s.get_high()) if hasattr(s, "get_high") else None, + "low": _f(s.get_low()) if hasattr(s, "get_low") else None, + } + for i, s in enumerate(getattr(tf, "seg_list", []) or []) + ] + zs_list = [ + { + "idx": getattr(z, "idx", i), + "dir": _enum_name(getattr(z, "dir", None)), + "zg": _f(getattr(z, "zg", 0) or 0), + "zd": _f(getattr(z, "zd", 0) or 0), + "is_sure": bool(getattr(z, "is_sure", True)), + } + for i, z in enumerate(getattr(tf, "zs_list", []) or []) + ] + bsp_list = [] + for i, p in enumerate(getattr(tf, "bsp_list", []) or []): + bsp_list.append( + { + "idx": getattr(p, "idx", i), + "type": _enum_name(getattr(p, "type", None) or getattr(p, "bsp_type", None)), + "dir": _enum_name(getattr(p, "dir", None)), + "price": _f(getattr(p, "price", 0) or getattr(p, "val", 0) or 0), + } + ) + return { + "counts": { + "klu": len(getattr(tf, "klu_list", []) or []), + "klc": len(getattr(tf, "klc_list", []) or []), + "bi": len(bi_list), + "seg": len(seg_list), + "zs": len(zs_list), + "bsp": len(bsp_list), + }, + "bi_list": bi_list, + "seg_list": seg_list, + "zs_list": zs_list, + "bsp_list": bsp_list, + } + + +def analyze_contract_keys() -> list: + """文档化 /api/analyze 主周期关键字段(契约冒烟用)。""" + return sorted( + [ + "timezone", + "kline_data", + "klc_list", + "bi_list", + "uncompleted_bi_list", + "seg_list", + "uncompleted_seg_list", + "zs_list", + "uncompleted_zs_list", + "bi_zs_list", + "bsp_list", + "klc_fx_info", + "macd", + "chan_macd", + "klc_trend", + ] + ) + + +def run_pipeline(df: pd.DataFrame): + """与 web.analyze_chan 同序,避免依赖 TF_DF.__init__ 历史缺口。""" + from chanlun import TF_DF + + chan = TF_DF() + df = chan.add_indicators(df.copy()) + klu_list = chan.get_kl_data(df) + klc_list = chan.get_klc_list(klu_list) + bi_list = chan.cal_bi_list(klc_list) + seg_list = chan.get_seg_list(bi_list) + zs_list = chan.calculate_seg_zs(seg_list) + bi_zs_list = chan.cal_bi_zs(seg_list) + bsp_list = chan.find_all_bsp(bi_list, bi_zs_list) if bi_zs_list else [] + # 挂到伪对象供 serialize_pipeline 使用 + chan.klu_list = klu_list + chan.klc_list = klc_list + chan.bi_list = bi_list + chan.seg_list = seg_list + chan.zs_list = zs_list + chan.bsp_list = bsp_list + chan.bi_zs_list = bi_zs_list + return chan + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--generate", action="store_true") + parser.add_argument("--check", action="store_true") + parser.add_argument( + "--fixture-dir", + type=Path, + default=ROOT / "tests" / "fixtures", + ) + args = parser.parse_args() + args.fixture_dir.mkdir(parents=True, exist_ok=True) + ohlcv_path = args.fixture_dir / "ohlcv_5m.csv" + golden_path = args.fixture_dir / "golden_pipeline.json" + keys_path = args.fixture_dir / "analyze_contract_keys.json" + + if args.generate: + df = make_ohlcv() + df.to_csv(ohlcv_path, index=False) + # 与 --check 同一路径:经 CSV 往返,避免浮点路径差异 + df = pd.read_csv(ohlcv_path, parse_dates=["date"]) + tf = run_pipeline(df) + payload = serialize_pipeline(tf) + golden_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + keys_path.write_text(json.dumps(analyze_contract_keys(), indent=2), encoding="utf-8") + print(f"wrote {ohlcv_path}") + print(f"wrote {golden_path} counts={payload['counts']}") + return 0 + + if args.check: + df = pd.read_csv(ohlcv_path, parse_dates=["date"]) + tf = run_pipeline(df) + actual = serialize_pipeline(tf) + expected = json.loads(golden_path.read_text(encoding="utf-8")) + if actual != expected: + print("GOLDEN MISMATCH") + print("expected counts", expected.get("counts")) + print("actual counts", actual.get("counts")) + diff_path = args.fixture_dir / "golden_pipeline.actual.json" + diff_path.write_text(json.dumps(actual, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"wrote actual to {diff_path}") + return 1 + print("GOLDEN OK", actual["counts"]) + return 0 + + parser.print_help() + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test_classifier.py b/tests/test_classifier.py similarity index 98% rename from test_classifier.py rename to tests/test_classifier.py index 1b26570..d618a73 100644 --- a/test_classifier.py +++ b/tests/test_classifier.py @@ -12,9 +12,9 @@ import os # Ensure Chan module is importable sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from TF_DF import TF_DF -from ChanPivotClassifier import ChanPivotClassifier -from ChanEnum import Chan_BI_DIR +from chanlun import TF_DF +from chanlun.analysis.ChanPivotClassifier import ChanPivotClassifier +from chanlun.core.ChanEnum import Chan_BI_DIR # Monkey-patch: TF_DF.init_TF_DF calls self.get_zs_list() which was removed. # Add it back as an alias for get_bi_zs_list. diff --git a/tests/test_golden_pipeline.py b/tests/test_golden_pipeline.py new file mode 100644 index 0000000..8f94d36 --- /dev/null +++ b/tests/test_golden_pipeline.py @@ -0,0 +1,46 @@ +"""Golden 回归:缠论流水线序列化结果应与基线一致。""" +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def test_golden_pipeline(): + r = subprocess.run( + [sys.executable, str(ROOT / "tests" / "generate_golden.py"), "--check"], + cwd=str(ROOT), + capture_output=True, + text=True, + ) + assert r.returncode == 0, r.stdout + r.stderr + + +def test_package_imports(): + from chanlun import ChanLun, TF_DF + from chanlun.core.ChanEnum import Chan_BI_DIR + + assert ChanLun is not None and TF_DF is not None and Chan_BI_DIR is not None + + +def test_compat_shim_still_works(): + """根目录 shim 保留给外部旧脚本。""" + from ChanLun import ChanLun as C1 + from TF_DF import TF_DF as T1 + from ChanEnum import Chan_BI_DIR + from chanlun import ChanLun as C2, TF_DF as T2 + + assert C1 is C2 and T1 is T2 and Chan_BI_DIR is not None + + +def test_analyze_contract_keys_file(): + keys = json.loads( + (ROOT / "tests" / "fixtures" / "analyze_contract_keys.json").read_text( + encoding="utf-8" + ) + ) + for k in ("kline_data", "bi_list", "seg_list", "zs_list", "bsp_list"): + assert k in keys diff --git a/web/api/__init__.py b/web/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/api/analyze.py b/web/api/analyze.py new file mode 100644 index 0000000..3660f1f --- /dev/null +++ b/web/api/analyze.py @@ -0,0 +1,660 @@ +"""分析 API。""" +from flask import Blueprint, jsonify, request +from services.runtime import * # noqa: F403 +from services import runtime as R + +bp = Blueprint("analyze", __name__) + +@bp.route('/api/analyze') +def analyze(): + """分析接口""" + symbol = request.args.get('symbol', 'SOL/USDT:USDT') + timeframe = request.args.get('timeframe', '5m') + + # 验证交易对不为空 + if not symbol or symbol.strip() == '': + return jsonify({'error': '交易对不能为空'}) + + # 获取时间范围参数 + start_time = request.args.get('start_time') + end_time = request.args.get('end_time') + + # 获取客户端请求的时区 + client_timezone = request.args.get('timezone', 'Asia/Shanghai') + + # 获取分形元素时间周期与次次周期 + element_timeframe = request.args.get('element_timeframe') + sub_sub_timeframe = request.args.get('sub_sub_timeframe') + + # 获取是否只需要分形元素数据的参数 + elements_only_param = request.args.get('elements_only') + elements_only = elements_only_param == 'true' + + # 验证小周期是否小于主周期 + if element_timeframe and not is_smaller_or_equal_timeframe(element_timeframe, timeframe): + return jsonify({'error': '分形元素时间周期必须小于或等于主图表时间周期'}) + # 验证次次周期是否小于等于次周期 + if sub_sub_timeframe and element_timeframe and not is_smaller_or_equal_timeframe(sub_sub_timeframe, element_timeframe): + return jsonify({'error': '次次周期必须小于或等于次周期'}) + + # 获取数据 + df = get_kl_data(symbol, timeframe, start_time=start_time, end_time=end_time) + if df is None: + return jsonify({'error': '获取数据失败'}) + + if len(df) == 0: + return jsonify({'error': '所选时间范围内没有数据'}) + + # 使用客户端指定的时区 + client_tz = timezone(client_timezone) + + # 如果只需要分形元素数据而不需要主周期数据,则初始化一个空结果 + result = { + 'timezone': client_timezone + } + + # 如果不是只需要分形元素数据,则添加主周期数据 + if not elements_only: + # 添加技术指标(包括布林带) + df = add_indicators(df) + + # 进行缠论分析 + analysis_result = analyze_chan(df, symbol, timeframe) + + # 计算MACD + macd_data = calculate_macd(df) + + # 基于已有 KLC 列表生成趋势标记(不做额外计算) + klc_trend = [] + try: + for klc in analysis_result.get('klc_list', []): + trend_val = getattr(klc, 'trend', None) + t_obj = getattr(klc, 'end_time', None) or getattr(klc, 'start_time', None) + if trend_val is None or t_obj is None: + continue + # 统一成字符串:UP/DOWN/FLAT/UNKNOWN + trend_name = str(trend_val) + if '.' in trend_name: + trend_name = trend_name.split('.')[-1] + time_str = format_time_safely(t_obj, client_tz) + if time_str: + klc_trend.append({'time': time_str, 'trend': trend_name}) + except Exception: + klc_trend = [] + + # 添加主周期分析结果到返回数据 + result.update({ + 'kline_data': clean_dataframe_for_json(df).to_dict('records'), + 'klc_list': [{ + 'date': klc.end_time if isinstance(klc.end_time, str) else klc.end_time.astimezone(client_tz).isoformat(), + 'open': float(klc.open), + 'high': float(klc.high), + 'low': float(klc.low), + 'close': float(klc.close), + 'volume': float(klc.volume) if hasattr(klc, 'volume') else 0, + 'direction': str(klc.dir).replace('Chan_KLINE_DIR.', ''), + 'fx_type': str(klc.fx).replace('Chan_FX_TYPE.', ''), + 'klc_fx_type': str(klc.klc_fx_type).replace('Chan_KLC_FX.', ''), + 'trend': str(klc.trend).replace('Chan_PRICE_TREND.', '') + } for klc in analysis_result['klc_list'] if hasattr(klc, 'end_time') and klc.end_time], + 'bi_list': [{ + 'start_time': bi.start_klc.end_time if isinstance(bi.start_klc.end_time, str) else bi.start_klc.end_time.astimezone(client_tz).isoformat(), + 'end_time': (bi.end_klc.end_time if isinstance(bi.end_klc.end_time, str) else bi.end_klc.end_time.astimezone(client_tz).isoformat()) if bi.end_klc else None, + 'sure_time': format_time_safely(bi.sure_time, client_tz) if bi.sure_time else None, + 'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high, + 'end_price': bi.end_klc.high if convert_direction(bi.dir) == 1 else bi.end_klc.low if bi.end_klc else None, + 'direction': convert_direction(bi.dir), + 'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0 + } for bi in analysis_result['bi_list'] if bi.is_sure], + # 添加未完成笔列表 + 'uncompleted_bi_list': [{ + 'start_time': bi.start_klc.end_time if isinstance(bi.start_klc.end_time, str) else bi.start_klc.end_time.astimezone(client_tz).isoformat(), + 'end_time': bi.end_time, # 未完成笔没有结束时间 + 'sure_time': format_time_safely(bi.sure_time, client_tz) if bi.sure_time else None, + 'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high, + 'end_price': bi.end_klc.low if convert_direction(bi.dir) == 1 else bi.end_klc.high, # 未完成笔没有结束价格 + 'direction': convert_direction(bi.dir), + 'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0 + } for bi in analysis_result['bi_list'] if not bi.is_sure], + 'seg_list': [{ + 'start_time': seg.start_bi.start_klc.end_time if isinstance(seg.start_bi.start_klc.end_time, str) else seg.start_bi.start_klc.end_time.astimezone(client_tz).isoformat(), + 'end_time': (seg.end_bi.end_klc.end_time if isinstance(seg.end_bi.end_klc.end_time, str) else seg.end_bi.end_klc.end_time.astimezone(client_tz).isoformat()) if seg.end_bi else None, + 'sure_time': format_time_safely(seg.sure_time, client_tz) if seg.sure_time else None, + 'start_price': seg.start_bi.start_klc.low if convert_direction(seg.dir) == 1 else seg.start_bi.start_klc.high, + 'end_price': seg.end_bi.end_klc.high if convert_direction(seg.dir) == 1 else seg.end_bi.end_klc.low if seg.end_bi else None, + 'direction': convert_direction(seg.dir) + } for seg in analysis_result['seg_list'] if seg.is_sure], + # 添加未完成线段列表 + 'uncompleted_seg_list': get_uncompleted_seg_list(analysis_result['seg_list'], client_tz), + 'zs_list': [{ + 'start_time': zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat(), + 'end_time': (zs.end_klc.end_time if isinstance(zs.end_klc.end_time, str) else zs.end_klc.end_time.astimezone(client_tz).isoformat()) if zs.end_klc else None, + 'zg': zs.zg, + 'zd': zs.zd, + 'gg': zs.gg, + 'dd': zs.dd, + 'is_sure': zs.is_sure # 添加中枢是否完成的标志 + } for zs in analysis_result['zs_list'] if zs.is_sure], + # 添加主周期BI中枢列表(已完成) + 'bi_zs_list': [{ + 'start_time': ( + (zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat()) + if getattr(zs.start_klc, 'end_time', None) else + (zs.start_klc.start_time if isinstance(zs.start_klc.start_time, str) else zs.start_klc.start_time.astimezone(client_tz).isoformat()) + ), + 'end_time': (zs.end_time if isinstance(zs.end_time, str) else zs.end_time.astimezone(client_tz).isoformat()) if getattr(zs, 'end_time', None) else None, + 'zg': zs.zg, + 'zd': zs.zd, + 'gg': zs.gg, + 'dd': zs.dd, + 'is_sure': bool(getattr(zs, 'is_sure', False)) + } for zs in analysis_result.get('bi_zs_list', []) if getattr(zs, 'is_sure', False)], + # 添加未完成中枢列表 + 'uncompleted_zs_list': [{ + 'start_time': zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat(), + 'end_time': None, # 未完成中枢没有结束时间 + 'zg': zs.zg, + 'zd': zs.zd, + 'gg': zs.gg, + 'dd': zs.dd, + 'is_sure': zs.is_sure # 未完成中枢的is_sure为False + } for zs in analysis_result['zs_list'] if not zs.is_sure], + # 添加未完成BI中枢列表 + 'uncompleted_bi_zs_list': [{ + 'start_time': ( + (zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat()) + if getattr(zs.start_klc, 'end_time', None) else + (zs.start_klc.start_time if isinstance(zs.start_klc.start_time, str) else zs.start_klc.start_time.astimezone(client_tz).isoformat()) + ), + 'end_time': None, + 'zg': zs.zg, + 'zd': zs.zd, + 'gg': zs.gg, + 'dd': zs.dd, + 'is_sure': bool(getattr(zs, 'is_sure', False)) + } for zs in analysis_result.get('bi_zs_list', []) if not getattr(zs, 'is_sure', False)], + + 'macd': macd_data, + # 添加布林带数据 + 'bollinger': { + 'upper': df['bb_upper'].tolist(), + 'middle': df['bb_middle'].tolist(), + 'lower': df['bb_lower'].tolist() + }, + 'element_bollinger': { + 'upper': df['element_bb_upper'].tolist(), + 'middle': df['element_bb_middle'].tolist(), + 'lower': df['element_bb_lower'].tolist() + }, + # 添加ATR数据 + 'atr': df['atr'].tolist(), + # 添加K线分型信息 + 'klc_fx_info': [{ + 'time': format_time_safely(point['time'], client_tz), + 'start_time': format_time_safely(point['start_time'], client_tz), + 'end_time': format_time_safely(point['end_time'], client_tz), + 'price': float(point['price']), + 'fx_type': point['fx_type'], + 'is_bottom': bool(point['is_bottom']), + 'fx_strength': float(point['fx_strength']), # 分型强度分数 + 'fx_strength_level': str(point['fx_strength_level']), # 分型强度等级 + 'is_strong_fx': bool(point['is_strong_fx']), # 是否为强分型 + # 分型框(虚线矩形)用到的高低价 + 'high': float(point['high']) if point.get('high') is not None else None, + 'low': float(point['low']) if point.get('low') is not None else None + } for point in analysis_result['klc_fx_info']], + # 添加ChanMACD分析数据 + 'chan_macd': serialize_chan_macd_data(analysis_result.get('chan_macd', {}), client_tz), + # 添加多时间周期EMA52数据 + 'ema52_dict': analysis_result.get('ema52_dict', {}), + # 直接输出KLC趋势标记(使用已有trend字段) + 'klc_trend': klc_trend, + # 添加主周期买卖点列表 + # 注意:部分枚举在转为字符串时可能形如 "Chan_BSP_TYPE.BSP1(1)", + # 这里进行健壮的解析,确保前端拿到的始终是 "BSP1" / "BUY" 这种简洁形式, + # 以便与前端的 BSP_STYLE 键(如 "BSP1_BUY")正确匹配。 + 'bsp_list': [{ + 'time': format_time_safely(bsp.end_time, client_tz), + 'price': float(bsp.klc.low if 'BUY' in str(bsp.dir) else bsp.klc.high), + # -- 规范化 type 名称,例如: + # "Chan_BSP_TYPE.BSP1" -> "BSP1" + # "Chan_BSP_TYPE.BSP1(1)" -> "BSP1" + # "BSP1" -> "BSP1" + 'type': ( + lambda raw: ( + (raw.split('.')[-1] if '.' in raw else raw).split('(')[0] + ) + )(str(bsp.type)), + # -- 规范化 dir 名称,例如: + # "Chan_BSP_DIR.BUY" -> "BUY" + # "Chan_BSP_DIR.BUY(1)" -> "BUY" + # "BUY" -> "BUY" + 'dir': ( + lambda raw: ( + (raw.split('.')[-1] if '.' in raw else raw).split('(')[0] + ) + )(str(bsp.dir)), + 'is_sure': bool(bsp.is_sure), + 'sure_time': format_time_safely(bsp.sure_time, client_tz) if bsp.sure_time else None, + 'zs_count': int(bsp.zs_count) if hasattr(bsp, 'zs_count') else 0 + } for bsp in analysis_result.get('bsp_list', [])] + }) + + + # 如果有指定分形元素时间周期,获取小周期数据 + if element_timeframe: + # 获取小周期数据,使用与主周期相同的时间范围 + element_df = get_kl_data(symbol, element_timeframe, start_time=start_time, end_time=end_time) + + if element_df is not None and len(element_df) > 0: + # 添加小周期技术指标(包括布林带) + element_df = add_indicators(element_df) + + # 对小周期数据进行缠论分析 + element_analysis = analyze_chan(element_df, symbol, element_timeframe) + + # 计算小周期MACD数据 + element_macd_data = calculate_macd(element_df) + + # 组装小周期 KLC 趋势(仅提取已有 trend,不做重算) + try: + element_klc_trend = [] + for klc in element_analysis.get('klc_list', []): + trend_val = getattr(klc, 'trend', None) + t_obj = getattr(klc, 'end_time', None) or getattr(klc, 'start_time', None) + if trend_val is None or t_obj is None: + continue + trend_name = str(trend_val) + if '.' in trend_name: + trend_name = trend_name.split('.')[-1] + time_str = format_time_safely(t_obj, client_tz) + if time_str: + element_klc_trend.append({'time': time_str, 'trend': trend_name}) + except Exception: + element_klc_trend = [] + + # 添加小周期分析结果到返回数据 + result['element_timeframe'] = element_timeframe + result['element_macd'] = element_macd_data # 添加小周期MACD数据 + + # 添加小周期布林带数据 + result['element_bollinger'] = { + 'upper': element_df['bb_upper'].tolist(), + 'middle': element_df['bb_middle'].tolist(), + 'lower': element_df['bb_lower'].tolist() + } + result['element_element_bollinger'] = { + 'upper': element_df['element_bb_upper'].tolist(), + 'middle': element_df['element_bb_middle'].tolist(), + 'lower': element_df['element_bb_lower'].tolist() + } + + # 添加小周期ATR数据 + result['element_atr'] = element_df['atr'].tolist() + + result['element_bi_list'] = [{ + 'start_time': bi.start_klc.end_time if isinstance(bi.start_klc.end_time, str) else bi.start_klc.end_time.astimezone(client_tz).isoformat(), + 'end_time': (bi.end_klc.end_time if isinstance(bi.end_klc.end_time, str) else bi.end_klc.end_time.astimezone(client_tz).isoformat()) if bi.end_klc else None, + 'sure_time': format_time_safely(bi.sure_time, client_tz) if bi.sure_time else None, + 'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high, + 'end_price': bi.end_klc.high if convert_direction(bi.dir) == 1 else bi.end_klc.low if bi.end_klc else None, + 'direction': convert_direction(bi.dir), + 'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0 + } for bi in element_analysis['bi_list'] if bi.is_sure] + # 添加次周期未完成笔列表 + result['element_uncompleted_bi_list'] = [{ + 'start_time': bi.start_klc.end_time if isinstance(bi.start_klc.end_time, str) else bi.start_klc.end_time.astimezone(client_tz).isoformat(), + 'end_time': bi.end_time, # 未完成笔没有结束时间 + 'sure_time': format_time_safely(bi.sure_time, client_tz) if bi.sure_time else None, + 'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high, + 'end_price': bi.end_klc.low if convert_direction(bi.dir) == 1 else bi.end_klc.high, # 未完成笔没有结束价格 + 'direction': convert_direction(bi.dir), + 'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0 + } for bi in element_analysis['bi_list'] if not bi.is_sure] + + # 添加小周期K线数据 + result['element_kline_data'] = clean_dataframe_for_json(element_df).to_dict('records') + + # 添加小周期KLC列表 + result['element_klc_list'] = [{ + 'date': klc.end_time if isinstance(klc.end_time, str) else klc.end_time.astimezone(client_tz).isoformat(), + 'open': float(klc.open), + 'high': float(klc.high), + 'low': float(klc.low), + 'close': float(klc.close), + 'volume': float(klc.volume) if hasattr(klc, 'volume') else 0, + 'direction': str(klc.dir).replace('Chan_KLINE_DIR.', ''), + 'fx_type': str(klc.fx).replace('Chan_FX_TYPE.', ''), + 'klc_fx_type': str(klc.klc_fx_type).replace('Chan_KLC_FX.', ''), + 'trend': str(klc.trend).replace('Chan_PRICE_TREND.', '') + } for klc in element_analysis['klc_list'] if hasattr(klc, 'end_time') and klc.end_time] + + result['element_seg_list'] = [{ + 'start_time': seg.start_bi.start_klc.end_time if isinstance(seg.start_bi.start_klc.end_time, str) else seg.start_bi.start_klc.end_time.astimezone(client_tz).isoformat(), + 'end_time': (seg.end_bi.end_klc.end_time if isinstance(seg.end_bi.end_klc.end_time, str) else seg.end_bi.end_klc.end_time.astimezone(client_tz).isoformat()) if seg.end_bi else None, + 'sure_time': format_time_safely(seg.sure_time, client_tz) if seg.sure_time else None, + 'start_price': seg.start_bi.start_klc.low if convert_direction(seg.dir) == 1 else seg.start_bi.start_klc.high, + 'end_price': seg.end_bi.end_klc.high if convert_direction(seg.dir) == 1 else seg.end_bi.end_klc.low if seg.end_bi else None, + 'direction': convert_direction(seg.dir) + } for seg in element_analysis['seg_list'] if seg.is_sure] + # 添加次周期未完成线段列表 + result['element_uncompleted_seg_list'] = get_uncompleted_seg_list(element_analysis['seg_list'], client_tz) + + result['element_zs_list'] = [{ + 'start_time': zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat(), + 'end_time': (zs.end_klc.end_time if isinstance(zs.end_klc.end_time, str) else zs.end_klc.end_time.astimezone(client_tz).isoformat()) if zs.end_klc else None, + 'zg': zs.zg, + 'zd': zs.zd, + 'gg': zs.gg, + 'dd': zs.dd, + 'is_sure': zs.is_sure # 添加中枢是否完成的标志 + } for zs in element_analysis['zs_list'] if zs.end_klc] + + result['element_uncompleted_zs_list'] = [{ + 'start_time': zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat(), + 'end_time': None, # 未完成中枢没有结束时间 + 'zg': zs.zg, + 'zd': zs.zd, + 'gg': zs.gg, + 'dd': zs.dd, + 'is_sure': zs.is_sure # 未完成中枢的is_sure为False + } for zs in element_analysis['zs_list'] if not zs.is_sure] + + # 添加次周期 BI 中枢(已完成/未完成) + result['element_bi_zs_list'] = [{ + 'start_time': ( + (zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat()) + if getattr(zs.start_klc, 'end_time', None) else + (zs.start_klc.start_time if isinstance(zs.start_klc.start_time, str) else zs.start_klc.start_time.astimezone(client_tz).isoformat()) + ), + 'end_time': (zs.end_time if isinstance(zs.end_time, str) else zs.end_time.astimezone(client_tz).isoformat()) if getattr(zs, 'end_time', None) else None, + 'zg': zs.zg, + 'zd': zs.zd, + 'gg': zs.gg, + 'dd': zs.dd, + 'is_sure': bool(getattr(zs, 'is_sure', False)) + } for zs in element_analysis.get('bi_zs_list', []) if getattr(zs, 'is_sure', False)] + result['element_uncompleted_bi_zs_list'] = [{ + 'start_time': ( + (zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat()) + if getattr(zs.start_klc, 'end_time', None) else + (zs.start_klc.start_time if isinstance(zs.start_klc.start_time, str) else zs.start_klc.start_time.astimezone(client_tz).isoformat()) + ), + 'end_time': None, + 'zg': zs.zg, + 'zd': zs.zd, + 'gg': zs.gg, + 'dd': zs.dd, + 'is_sure': bool(getattr(zs, 'is_sure', False)) + } for zs in element_analysis.get('bi_zs_list', []) if not getattr(zs, 'is_sure', False)] + + + # 添加小周期分型信息 + result['element_klc_fx_info'] = [{ + 'time': format_time_safely(point['time'], client_tz), + 'start_time': format_time_safely(point['start_time'], client_tz), + 'end_time': format_time_safely(point['end_time'], client_tz), + 'price': float(point['price']), + 'fx_type': point['fx_type'], + 'is_bottom': bool(point['is_bottom']), + 'fx_strength': float(point['fx_strength']), # 分型强度分数 + 'fx_strength_level': str(point['fx_strength_level']), # 分型强度等级 + 'is_strong_fx': bool(point['is_strong_fx']), # 是否为强分型 + # 分型框(虚线矩形)用到的高低价 + 'high': float(point['high']) if point.get('high') is not None else None, + 'low': float(point['low']) if point.get('low') is not None else None + } for point in element_analysis['klc_fx_info']] + + # 添加次周期ChanMACD分析数据 + result['element_chan_macd'] = serialize_chan_macd_data(element_analysis.get('chan_macd', {}), client_tz) + + # 添加小周期 KLC 趋势标记 + result['element_klc_trend'] = element_klc_trend + + # 添加次周期买卖点列表 + result['element_bsp_list'] = [{ + 'time': format_time_safely(bsp.end_time, client_tz), + 'price': float(bsp.klc.low if str(bsp.dir) == 'Chan_BSP_DIR.BUY' else bsp.klc.high), + 'type': str(bsp.type).replace('Chan_BSP_TYPE.', ''), + 'dir': str(bsp.dir).replace('Chan_BSP_DIR.', ''), + 'is_sure': bool(bsp.is_sure), + 'sure_time': format_time_safely(bsp.sure_time, client_tz) if bsp.sure_time else None, + 'zs_count': int(bsp.zs_count) if hasattr(bsp, 'zs_count') else 0 + } for bsp in element_analysis.get('bsp_list', [])] + + # 次次周期:仅当已指定次周期且次次周期有效时获取 + if sub_sub_timeframe and is_smaller_or_equal_timeframe(sub_sub_timeframe, element_timeframe): + sub_sub_df = get_kl_data(symbol, sub_sub_timeframe, start_time=start_time, end_time=end_time) + if sub_sub_df is not None and len(sub_sub_df) > 0: + sub_sub_df = add_indicators(sub_sub_df) + sub_sub_analysis = analyze_chan(sub_sub_df, symbol, sub_sub_timeframe) + result['sub_sub_timeframe'] = sub_sub_timeframe + result['sub_sub_kline_data'] = clean_dataframe_for_json(sub_sub_df).to_dict('records') + result['sub_sub_atr'] = sub_sub_df['atr'].tolist() + result['sub_sub_macd'] = calculate_macd(sub_sub_df) + result['sub_sub_bi_list'] = [{ + 'start_time': bi.start_klc.end_time if isinstance(bi.start_klc.end_time, str) else bi.start_klc.end_time.astimezone(client_tz).isoformat(), + 'end_time': (bi.end_klc.end_time if isinstance(bi.end_klc.end_time, str) else bi.end_klc.end_time.astimezone(client_tz).isoformat()) if bi.end_klc else None, + 'sure_time': format_time_safely(bi.sure_time, client_tz) if bi.sure_time else None, + 'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high, + 'end_price': bi.end_klc.high if convert_direction(bi.dir) == 1 else bi.end_klc.low if bi.end_klc else None, + 'direction': convert_direction(bi.dir), + 'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0 + } for bi in sub_sub_analysis['bi_list'] if bi.is_sure] + result['sub_sub_uncompleted_bi_list'] = [{ + 'start_time': bi.start_klc.end_time if isinstance(bi.start_klc.end_time, str) else bi.start_klc.end_time.astimezone(client_tz).isoformat(), + 'end_time': bi.end_time, + 'sure_time': format_time_safely(bi.sure_time, client_tz) if bi.sure_time else None, + 'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high, + 'end_price': bi.end_klc.low if convert_direction(bi.dir) == 1 else bi.end_klc.high, + 'direction': convert_direction(bi.dir), + 'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0 + } for bi in sub_sub_analysis['bi_list'] if not bi.is_sure] + # 次次周期 KLC 列表 + result['sub_sub_klc_list'] = [{ + 'date': klc.end_time if isinstance(klc.end_time, str) else klc.end_time.astimezone(client_tz).isoformat(), + 'open': float(klc.open), + 'high': float(klc.high), + 'low': float(klc.low), + 'close': float(klc.close), + 'volume': float(klc.volume) if hasattr(klc, 'volume') else 0, + 'direction': str(klc.dir).replace('Chan_KLINE_DIR.', ''), + 'fx_type': str(klc.fx).replace('Chan_FX_TYPE.', ''), + 'klc_fx_type': str(klc.klc_fx_type).replace('Chan_KLC_FX.', ''), + 'trend': str(klc.trend).replace('Chan_PRICE_TREND.', '') + } for klc in sub_sub_analysis.get('klc_list', []) if hasattr(klc, 'end_time') and klc.end_time] + + result['sub_sub_seg_list'] = [{ + 'start_time': seg.start_bi.start_klc.end_time if isinstance(seg.start_bi.start_klc.end_time, str) else seg.start_bi.start_klc.end_time.astimezone(client_tz).isoformat(), + 'end_time': (seg.end_bi.end_klc.end_time if isinstance(seg.end_bi.end_klc.end_time, str) else seg.end_bi.end_klc.end_time.astimezone(client_tz).isoformat()) if seg.end_bi else None, + 'sure_time': format_time_safely(seg.sure_time, client_tz) if seg.sure_time else None, + 'start_price': seg.start_bi.start_klc.low if convert_direction(seg.dir) == 1 else seg.start_bi.start_klc.high, + 'end_price': seg.end_bi.end_klc.high if convert_direction(seg.dir) == 1 else seg.end_bi.end_klc.low if seg.end_bi else None, + 'direction': convert_direction(seg.dir) + } for seg in sub_sub_analysis['seg_list'] if seg.is_sure] + result['sub_sub_uncompleted_seg_list'] = get_uncompleted_seg_list(sub_sub_analysis['seg_list'], client_tz) + result['sub_sub_zs_list'] = [{ + 'start_time': zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat(), + 'end_time': (zs.end_klc.end_time if isinstance(zs.end_klc.end_time, str) else zs.end_klc.end_time.astimezone(client_tz).isoformat()) if zs.end_klc else None, + 'zg': zs.zg, 'zd': zs.zd, 'gg': zs.gg, 'dd': zs.dd, 'is_sure': zs.is_sure + } for zs in sub_sub_analysis['zs_list'] if zs.end_klc] + result['sub_sub_uncompleted_zs_list'] = [{ + 'start_time': zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat(), + 'end_time': None, 'zg': zs.zg, 'zd': zs.zd, 'gg': zs.gg, 'dd': zs.dd, 'is_sure': zs.is_sure + } for zs in sub_sub_analysis['zs_list'] if not zs.is_sure] + result['sub_sub_bi_zs_list'] = [{ + 'start_time': ( + (zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat()) + if getattr(zs.start_klc, 'end_time', None) else + (zs.start_klc.start_time if isinstance(zs.start_klc.start_time, str) else zs.start_klc.start_time.astimezone(client_tz).isoformat()) + ), + 'end_time': (zs.end_time if isinstance(zs.end_time, str) else zs.end_time.astimezone(client_tz).isoformat()) if getattr(zs, 'end_time', None) else None, + 'zg': zs.zg, 'zd': zs.zd, 'gg': zs.gg, 'dd': zs.dd, 'is_sure': bool(getattr(zs, 'is_sure', False)) + } for zs in sub_sub_analysis.get('bi_zs_list', []) if getattr(zs, 'is_sure', False)] + result['sub_sub_uncompleted_bi_zs_list'] = [{ + 'start_time': ( + (zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat()) + if getattr(zs.start_klc, 'end_time', None) else + (zs.start_klc.start_time if isinstance(zs.start_klc.start_time, str) else zs.start_klc.start_time.astimezone(client_tz).isoformat()) + ), + 'end_time': None, 'zg': zs.zg, 'zd': zs.zd, 'gg': zs.gg, 'dd': zs.dd, 'is_sure': bool(getattr(zs, 'is_sure', False)) + } for zs in sub_sub_analysis.get('bi_zs_list', []) if not getattr(zs, 'is_sure', False)] + result['sub_sub_klc_fx_info'] = [{ + 'time': format_time_safely(point['time'], client_tz), + 'start_time': format_time_safely(point['start_time'], client_tz), + 'end_time': format_time_safely(point['end_time'], client_tz), + 'price': float(point['price']), + 'fx_type': point['fx_type'], + 'is_bottom': bool(point['is_bottom']), + 'fx_strength': float(point['fx_strength']), + 'fx_strength_level': str(point['fx_strength_level']), + 'is_strong_fx': bool(point['is_strong_fx']), + # 分型框(虚线矩形)用到的高低价 + 'high': float(point['high']) if point.get('high') is not None else None, + 'low': float(point['low']) if point.get('low') is not None else None + } for point in sub_sub_analysis['klc_fx_info']] + result['sub_sub_bsp_list'] = [{ + 'time': format_time_safely(bsp.end_time, client_tz), + 'price': float(bsp.klc.low if str(bsp.dir) == 'Chan_BSP_DIR.BUY' else bsp.klc.high), + 'type': str(bsp.type).replace('Chan_BSP_TYPE.', ''), + 'dir': str(bsp.dir).replace('Chan_BSP_DIR.', ''), + 'is_sure': bool(bsp.is_sure), + 'sure_time': format_time_safely(bsp.sure_time, client_tz) if bsp.sure_time else None, + 'zs_count': int(bsp.zs_count) if hasattr(bsp, 'zs_count') else 0 + } for bsp in sub_sub_analysis.get('bsp_list', [])] + result['sub_sub_chan_macd'] = serialize_chan_macd_data(sub_sub_analysis.get('chan_macd', {}), client_tz) + try: + sub_sub_klc_trend = [] + for klc in sub_sub_analysis.get('klc_list', []): + trend_val = getattr(klc, 'trend', None) + t_obj = getattr(klc, 'end_time', None) or getattr(klc, 'start_time', None) + if trend_val is None or t_obj is None: + continue + trend_name = str(trend_val) + if '.' in trend_name: + trend_name = trend_name.split('.')[-1] + time_str = format_time_safely(t_obj, client_tz) + if time_str: + sub_sub_klc_trend.append({'time': time_str, 'trend': trend_name}) + result['sub_sub_klc_trend'] = sub_sub_klc_trend + except Exception: + result['sub_sub_klc_trend'] = [] + + pass + + # 结构价值区分析(Structure Zone)—— 按需拉取:仅当 include_structure_zones 为真时执行多周期拉取(默认跳过以减轻负载) + include_zones_param = request.args.get('include_structure_zones', '') + include_structure_zones = str(include_zones_param).lower() in ('1', 'true', 'yes') + if include_structure_zones: + zone_timeframes_str = request.args.get('zone_timeframes', '') + zone_kl_lines = int(request.args.get('zone_kl_lines', 1000)) + try: + zone_config = StructureZoneConfig(kl_lines_per_tf=zone_kl_lines) + if zone_timeframes_str: + zone_config.zone_timeframes = [t.strip() for t in zone_timeframes_str.split(',') if t.strip()] + analyses = {} + ema52_dict = {} + latest_close = 0.0 + now = time.time() + + def _fetch_single_tf_zone(tf_name): + """单个时间周期的结构区数据拉取(线程安全)""" + cache_key = f"{symbol}:{tf_name}:{zone_kl_lines}" + cached = _zone_cache.get(cache_key) + if cached and cached['expires'] > now: + print(f" 结构区缓存命中: {tf_name}") + return { + 'tf_name': tf_name, + 'analyses': cached['analyses'], + 'ema52': cached['ema52'], + 'close': cached.get('close', 0.0), + 'cached': True, + } + + try: + tf_df = get_kl_data(symbol, tf_name, limit=zone_kl_lines) + if tf_df is None or len(tf_df) == 0: + return None + tf_df = add_indicators(tf_df) + tf_analysis = analyze_chan(tf_df, symbol, tf_name) + zs_serialized = [{ + 'start_time': (zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat()) if zs.start_klc else None, + 'end_time': (zs.end_klc.end_time if isinstance(zs.end_klc.end_time, str) else zs.end_klc.end_time.astimezone(client_tz).isoformat()) if zs.end_klc else None, + 'zg': zs.zg, 'zd': zs.zd, 'gg': zs.gg, 'dd': zs.dd, + 'is_sure': zs.is_sure + } for zs in tf_analysis.get('zs_list', []) if zs.is_sure] + bi_zs_serialized = [{ + 'start_time': ((zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat()) if getattr(zs.start_klc, 'end_time', None) else (zs.start_klc.start_time if isinstance(zs.start_klc.start_time, str) else zs.start_klc.start_time.astimezone(client_tz).isoformat())), + 'end_time': (zs.end_time if isinstance(zs.end_time, str) else zs.end_time.astimezone(client_tz).isoformat()) if getattr(zs, 'end_time', None) else None, + 'zg': zs.zg, 'zd': zs.zd, 'gg': zs.gg, 'dd': zs.dd, + 'is_sure': bool(getattr(zs, 'is_sure', False)) + } for zs in tf_analysis.get('bi_zs_list', []) if getattr(zs, 'is_sure', False)] + last_ema = tf_df['ema52'].iloc[-1] if 'ema52' in tf_df.columns else 0 + ema_val = float(last_ema) if last_ema and last_ema > 0 else None + last_close = float(tf_df['close'].iloc[-1]) + tf_result = { + 'tf_name': tf_name, + 'analyses': {'zs_list': zs_serialized, 'bi_zs_list': bi_zs_serialized}, + 'ema52': ema_val, + 'close': last_close, + 'cached': False, + } + # 写入缓存 + _zone_cache[cache_key] = { + 'analyses': tf_result['analyses'], + 'ema52': ema_val, + 'close': last_close, + 'expires': now + _zone_cache_ttl(tf_name), + } + print(f" 结构区数据: {tf_name} -> zs={len(zs_serialized)}, bi_zs={len(bi_zs_serialized)}, ema52={ema_val}") + return tf_result + except Exception as e: + print(f" 结构区 {tf_name} 拉取失败: {e}") + return None + + with ThreadPoolExecutor(max_workers=len(zone_config.zone_timeframes)) as executor: + futures = {executor.submit(_fetch_single_tf_zone, tf): tf for tf in zone_config.zone_timeframes} + for future in as_completed(futures): + tf_result = future.result() + if tf_result is None: + continue + tf_name = tf_result['tf_name'] + analyses[tf_name] = tf_result['analyses'] + ema52_dict[tf_name] = tf_result['ema52'] + if tf_result['close'] and (not latest_close or latest_close == 0.0): + latest_close = tf_result['close'] + + structure_zones = analyze_structure_zones_from_serialized( + analyses, ema52_dict, latest_close, config=zone_config + ) + result['structure_zones'] = [{ + 'id': z.id, + 'lower': z.lower, + 'upper': z.upper, + 'center': z.center, + 'width_pct': z.width_pct, + 'zone_type': z.zone_type, + 'timeframes': z.timeframes, + 'structure_types': z.structure_types, + 'boundary_types': z.boundary_types, + 'overlap_count': z.overlap_count, + 'touch_count': z.touch_count, + 'recency_score': z.recency_score, + 'ema52_distance_pct': z.ema52_distance_pct, + 'ema52_aligned': z.ema52_aligned, + 'strength_score': z.strength_score, + 'confidence': z.confidence, + 'first_seen': z.first_seen, + 'last_seen': z.last_seen, + 'metadata': z.metadata, + } for z in structure_zones] + except Exception as e: + print(f"StructureZone 分析出错: {e}") + import traceback + traceback.print_exc() + result['structure_zones'] = [] + else: + result['structure_zones'] = [] + + return jsonify(result) + diff --git a/web/api/pages.py b/web/api/pages.py new file mode 100644 index 0000000..1dacb01 --- /dev/null +++ b/web/api/pages.py @@ -0,0 +1,73 @@ +"""页面路由。""" +from flask import Blueprint, render_template, send_from_directory +from services.runtime import * # noqa: F403 +from services import runtime as R + +bp = Blueprint("pages", __name__) + +@bp.route('/chan_tv') +def chan_tv(): + """缠论 TradingView 高级图表页面""" + return render_template('chan_tv.html') + +@bp.route('/charting_library/') +def serve_charting_library(filename): + """提供 TradingView Charting Library 静态文件""" + return send_from_directory('charting_library', filename) + +@bp.route('/') +def index(): + """主页""" + refresh_data_service_metadata() + tf_map = TIMEFRAMES if TIMEFRAMES else DEFAULT_TIMEFRAME_LABELS.copy() + default_main, default_element, default_sub_sub, timeframe_keys = compute_timeframe_defaults(OrderedDict(tf_map)) + symbols = SYMBOLS if SYMBOLS else DEFAULT_SYMBOLS + + default_symbol = 'BTC/USDT:USDT' if 'BTC/USDT:USDT' in symbols else (symbols[0] if symbols else '') + + return render_template( + 'index.html', + timeframes=tf_map, + symbols=symbols, + a_stock_symbols=A_STOCK_SYMBOLS, + default_main_timeframe=default_main, + default_element_timeframe=default_element, + default_sub_sub_timeframe=default_sub_sub, + default_symbol=default_symbol, + timeframe_keys_json=json.dumps(timeframe_keys), + data_service_available=DATA_SERVICE_AVAILABLE, + ) + + +@bp.route('/api/chart_metadata') +def api_chart_metadata(): + """ + 按数据源返回图表用 K 线周期(中文标签)及主/次/次次默认周期。 + crypto:强制刷新 DATA_SERVICE_URL /health 元信息; + a_stock:读取 ASHARE_DP_URL 的 /api/v1/klines/available-freqs,不修改全局加密货币 TIMEFRAMES。 + """ + source = (request.args.get('source') or 'crypto').strip().lower() + if source not in ('crypto', 'a_stock'): + source = 'crypto' + try: + if source == 'a_stock': + raw = china_stock.get_available_kline_freqs() + labels_od = build_timeframe_labels(raw) + else: + refresh_data_service_metadata(force=True) + labels_od = OrderedDict(TIMEFRAMES if TIMEFRAMES else DEFAULT_TIMEFRAME_LABELS.copy()) + + default_main, default_element, default_sub_sub, keys = compute_timeframe_defaults(labels_od) + return jsonify({ + 'source': source, + 'timeframes': {k: v for k, v in labels_od.items()}, + 'timeframe_keys': keys, + 'default_main': default_main, + 'default_element': default_element, + 'default_sub_sub': default_sub_sub, + }) + except Exception as exc: + logger.exception('chart_metadata 失败: %s', exc) + return jsonify({'error': str(exc)}), 500 + + diff --git a/web/api/symbols.py b/web/api/symbols.py new file mode 100644 index 0000000..2f54779 --- /dev/null +++ b/web/api/symbols.py @@ -0,0 +1,101 @@ +"""交易对 / A股 / MACD 配置 API。""" +from flask import Blueprint, jsonify, request +from services.runtime import * # noqa: F403 + +bp = Blueprint("symbols", __name__) + +@bp.route('/api/symbols') +def get_symbols(): + """获取可用交易对""" + refresh_data_service_metadata() + if SYMBOLS: + return jsonify(SYMBOLS) + try: + markets = exchange.load_markets() + # 合约交易对通常是以USDT结尾的永续合约 + symbols = [symbol for symbol in markets.keys() if '/USDT' in symbol and ':USDT' in symbol] + return jsonify(symbols) + except Exception as e: + return jsonify(DEFAULT_SYMBOLS) + +@bp.route('/api/a_stocks') +def get_a_stocks(): + """获取A股股票列表""" + try: + stock_list = china_stock.get_stock_list() + return jsonify(stock_list) + except Exception as e: + return jsonify({'error': str(e)}) + +@bp.route('/api/popular_a_stocks') +def get_popular_a_stocks(): + """获取热门A股股票""" + try: + return jsonify(china_stock.get_popular_stocks()) + except Exception as e: + return jsonify({'error': str(e)}) + +@bp.route('/api/sectors') +def get_sectors(): + """获取所有行业分类""" + try: + sectors = china_stock.get_all_sectors() + return jsonify(sectors) + except Exception as e: + return jsonify({'error': str(e)}) + +@bp.route('/api/stocks_by_sector') +def get_stocks_by_sector(): + """根据行业获取股票""" + try: + sector = request.args.get('sector') + if sector: + stocks = china_stock.get_stock_by_sector(sector) + return jsonify(stocks) + else: + # 返回所有行业的股票分组 + all_sectors = china_stock.get_stock_by_sector() + return jsonify(all_sectors) + except Exception as e: + return jsonify({'error': str(e)}) + +@bp.route('/api/search_stock') +def search_stock(): + """搜索股票 - 增强版""" + try: + keyword = request.args.get('keyword', '') + if not keyword: + return jsonify({'error': '搜索关键词不能为空'}) + + results = china_stock.search_stock(keyword) + return jsonify(results) + except Exception as e: + return jsonify({'error': str(e)}) + + +@bp.route('/api/macd_config', methods=['GET', 'POST']) +def macd_config(): + """获取或设置MACD参数""" + global macd_fast_period, macd_slow_period, macd_signal_period + if request.method == 'GET': + return jsonify({ + 'fast': macd_fast_period, + 'slow': macd_slow_period, + 'signal': macd_signal_period + }) + else: + data = request.get_json(silent=True) or {} + fast = data.get('fast') + slow = data.get('slow') + signal = data.get('signal') + if fast is not None: + macd_fast_period = int(fast) + if slow is not None: + macd_slow_period = int(slow) + if signal is not None: + macd_signal_period = int(signal) + return jsonify({ + 'fast': macd_fast_period, + 'slow': macd_slow_period, + 'signal': macd_signal_period + }) diff --git a/web/api/trend.py b/web/api/trend.py new file mode 100644 index 0000000..5fdee84 --- /dev/null +++ b/web/api/trend.py @@ -0,0 +1,126 @@ +"""趋势相关 API。""" +from flask import Blueprint, jsonify, request +from services.runtime import * # noqa: F403 +from services import runtime as R + +bp = Blueprint("trend", __name__) + +@bp.route('/api/trend_filter', methods=['GET']) +def trend_filter(): + """趋势筛选接口(币对) + 参数: + timeframe: K线周期 + start_time, end_time: 毫秒时间戳,可选 + direction: bull/bear/sideways 可选 + stage: early/mid/late 可选 + min_strength: 0-100 可选 + symbols: 逗号分隔列表,可选;不传则自动加载部分USDT币对 + 返回符合条件的币对与简要统计 + """ + timeframe = request.args.get('timeframe', '1h') + start_time = request.args.get('start_time') + end_time = request.args.get('end_time') + want_direction = request.args.get('direction') # 可为 None + want_stage = request.args.get('stage') # 可为 None + try: + min_strength = float(request.args.get('min_strength', '0')) + except ValueError: + min_strength = 0.0 + + symbols_param = request.args.get('symbols') + if symbols_param: + symbols_list = [s.strip() for s in symbols_param.split(',') if s.strip()] + else: + symbols_list = load_crypto_symbols(limit=150) + + results = [] + for sym in symbols_list: + try: + df = get_crypto_kl_data(sym, timeframe, start_time=start_time, end_time=end_time) + if df is None or len(df) < 60: + continue + df = add_indicators(df) + direction, stage, strength = classify_trend_stage(df) + + if want_direction and direction != want_direction: + continue + if want_stage and stage != want_stage: + continue + if strength < min_strength: + continue + + last_row = df.iloc[-1] + results.append({ + 'symbol': sym, + 'time': int(last_row['timestamp']), + 'close': float(last_row['close']), + 'direction': direction, + 'stage': stage, + 'strength': float(round(strength, 2)), + 'ema5': float(last_row['ema5']), + 'ema10': float(last_row['ema10']), + 'ema24': float(last_row['ema24']), + 'ema52': float(last_row['ema52']) + }) + except Exception: + continue + + # 按强度降序 + results.sort(key=lambda x: x['strength'], reverse=True) + return jsonify({ + 'count': len(results), + 'results': results + }) + + +@bp.route('/api/trend_detail', methods=['GET']) +def trend_detail(): + """返回单个币对的K线与EMA、用于前端绘制趋势线 + 参数: symbol, timeframe, start_time, end_time + """ + symbol = request.args.get('symbol') + timeframe = request.args.get('timeframe', '1h') + start_time = request.args.get('start_time') + end_time = request.args.get('end_time') + timezone_name = request.args.get('timezone', 'Asia/Shanghai') + + if not symbol: + return jsonify({'error': 'symbol不能为空'}) + + df = get_crypto_kl_data(symbol, timeframe, start_time=start_time, end_time=end_time) + if df is None or len(df) == 0: + return jsonify({'error': '获取数据失败'}) + + df = add_indicators(df) + direction, stage, strength = classify_trend_stage(df) + + # 简单趋势线: 用最近N根收盘价做线性拟合 + N = min(80, len(df)) + sub = df.tail(N) + y = sub['close'].values + x = np.arange(len(y)) + denom = np.dot(x - x.mean(), x - x.mean()) + if denom != 0: + m = float(np.dot(y - y.mean(), x - x.mean()) / denom) + b = float(y.mean() - m * x.mean()) + else: + m, b = 0.0, float(y[-1]) + + client_tz = timezone(timezone_name) + + return jsonify({ + 'symbol': symbol, + 'timeframe': timeframe, + 'timezone': timezone_name, + 'direction': direction, + 'stage': stage, + 'strength': float(round(strength, 2)), + 'kline_data': clean_dataframe_for_json(df)[['timestamp','open','high','low','close','volume','ema5','ema10','ema24','ema52']].to_dict('records'), + 'trend_line': { + 'offset': int(df.index[-N]), + 'slope': m, + 'intercept': b, + 'length': int(N) + } + }) + diff --git a/web/app.py b/web/app.py index ab277e0..8a46e31 100644 --- a/web/app.py +++ b/web/app.py @@ -1,2109 +1,32 @@ -from flask import Flask, render_template, jsonify, request, send_from_directory -from collections import OrderedDict -import json -import logging -import ccxt -import pandas as pd -import requests -from datetime import datetime, timedelta +"""Flask 应用入口(瘦身 factory)。""" +from __future__ import annotations + import sys import os -import io -import base64 -import time -import traceback -from concurrent.futures import ThreadPoolExecutor, as_completed -from pytz import timezone -import talib.abstract as ta -import numpy as np -# 添加父目录到系统路径 -sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from ChanLun import ChanLun, TF_DF -from ChanEnum import Chan_BI_DIR, Chan_SEG_DIR, Chan_KLC_FX, Chan_FX_TYPE, Chan_MACDSEG_DIR, Chan_MACDHISTSET_DIR -from cn_stock_data import ChinaStockData -from ChanMACD import ChanMACD -from ChanZone import StructureZoneConfig, analyze_structure_zones_from_serialized +_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _ROOT not in sys.path: + sys.path.append(_ROOT) -# 添加买卖点枚举类型 -class TRADE_POINT_TYPE: - BUY1 = 1 # 一类买点 - BUY2 = 2 # 二类买点 - BUY3 = 3 # 三类买点 - SELL1 = -1 # 一类卖点 - SELL2 = -2 # 二类卖点 - SELL3 = -3 # 三类卖点 +from flask import Flask -app = Flask(__name__) -macd_factor = 1 -smooth_factor = 1 -macd_fast_period = 12 * macd_factor -macd_slow_period = 26 * macd_factor -macd_signal_period = 9 * smooth_factor -# 初始化交易所 -exchange = ccxt.binance({ - 'enableRateLimit': True, - 'proxies': { - 'http': 'http://127.0.0.1:7897', - 'https': 'http://127.0.0.1:7897', - }, -}) +from config import FLASK_HOST, FLASK_PORT +from api.analyze import bp as analyze_bp +from api.pages import bp as pages_bp +from api.symbols import bp as symbols_bp +from api.trend import bp as trend_bp -# 初始化 A 股数据获取器(K 线优先请求 A-Share Data Platform,默认 http://103.179.242.166:8000 ,见 /api/v1/klines 文档;ASHARE_DP_URL 覆盖,置空则仅用 AKShare) -china_stock = ChinaStockData() -logger = logging.getLogger(__name__) +def create_app() -> Flask: + app = Flask(__name__) + app.register_blueprint(pages_bp) + app.register_blueprint(analyze_bp) + app.register_blueprint(symbols_bp) + app.register_blueprint(trend_bp) + return app -# 结构价值区缓存: {tf_name: {'data': ..., 'expires': timestamp}} -_zone_cache = {} -def _zone_cache_ttl(tf_name: str) -> int: - """根据时间周期返回缓存过期时间(秒)""" - minutes = timeframe_to_minutes(tf_name) or 5 - if minutes <= 5: - return 120 # 5m及以下: 2分钟 - elif minutes <= 15: - return 300 # 15m: 5分钟 - elif minutes <= 60: - return 600 # 1h: 10分钟 - else: - return 1800 # 4h+: 30分钟 +app = create_app() -# 加密货币本地/自建行情服务(与 A 股 ASHARE_DP_URL 端口可不同) -DATA_SERVICE_URL = os.environ.get("DATA_SERVICE_URL", os.environ.get("DATASVC_URL", "http://103.179.242.166")) - -DEFAULT_TIMEFRAME_LABELS = OrderedDict([ - ("1m", "1分钟"), - ("3m", "3分钟"), - ("5m", "5分钟"), - ("15m", "15分钟"), - ("30m", "30分钟"), - ("1h", "1小时"), - ("2h", "2小时"), - ("4h", "4小时"), - ("6h", "6小时"), - ("8h", "8小时"), - ("12h", "12小时"), - ("1d", "日线"), - ("3d", "3日线"), - ("1w", "周线"), - ("1M", "月线"), -]) - -DEFAULT_SYMBOLS = [ - 'SOL/USDT:USDT', 'BTC/USDT:USDT', 'ETH/USDT:USDT', 'BNB/USDT:USDT', 'XRP/USDT:USDT', 'WIF/USDT:USDT', - 'ADA/USDT:USDT', 'DOGE/USDT:USDT', 'AVAX/USDT:USDT', 'DOT/USDT:USDT', 'MATIC/USDT:USDT' -] - -TIMEFRAMES = DEFAULT_TIMEFRAME_LABELS.copy() -SYMBOLS = DEFAULT_SYMBOLS.copy() -DATA_SERVICE_AVAILABLE = False -SERVICE_METADATA_LAST_REFRESH = 0 - - -def timeframe_to_minutes(tf: str): - """将时间周期转换为分钟数,用于排序。""" - if not tf: - return None - unit = tf[-1] - try: - value = int(tf[:-1]) - except (ValueError, TypeError): - return None - multiplier = { - 'm': 1, - 'h': 60, - 'd': 1440, - 'w': 10080, - 'M': 43200, # 30天近似 - }.get(unit) - if multiplier is None: - return None - return value * multiplier - - -def format_timeframe_label(tf: str) -> str: - """将时间周期转换为可读标签。""" - if not tf: - return tf - unit = tf[-1] - try: - value = int(tf[:-1]) - except (ValueError, TypeError): - return tf - if unit == 'm': - return f"{value}分钟" - if unit == 'h': - return f"{value}小时" - if unit == 'd': - return "日线" if value == 1 else f"{value}日线" - if unit == 'w': - return "周线" if value == 1 else f"{value}周线" - if unit == 'M': - return "月线" if value == 1 else f"{value}月线" - return tf - - -def build_timeframe_labels(timeframes): - ordered = sorted( - timeframes, - key=lambda tf: timeframe_to_minutes(tf) if timeframe_to_minutes(tf) is not None else float('inf'), - ) - labels = OrderedDict() - for tf in ordered: - labels[tf] = format_timeframe_label(tf) - return labels - - -def compute_timeframe_defaults(labels_ordered): - """ - 根据已排序的「周期 → 中文标签」映射,计算主 / 次 / 次次周期默认值。 - labels_ordered: OrderedDict 或按插入顺序排列的 dict。 - """ - if not labels_ordered: - labels_ordered = DEFAULT_TIMEFRAME_LABELS.copy() - timeframe_keys = list(labels_ordered.keys()) - preferred_main = next((tf for tf in ['5m', '15m', '1h'] if tf in labels_ordered), None) - default_main = preferred_main or (timeframe_keys[0] if timeframe_keys else '1m') - if default_main not in labels_ordered and timeframe_keys: - default_main = timeframe_keys[0] - - if timeframe_keys: - try: - idx = timeframe_keys.index(default_main) - default_element = timeframe_keys[idx - 1] if idx > 0 else timeframe_keys[0] - except ValueError: - default_element = timeframe_keys[0] - else: - default_element = default_main - - if timeframe_keys: - try: - idx_el = timeframe_keys.index(default_element) - default_sub_sub = timeframe_keys[idx_el - 1] if idx_el > 0 else timeframe_keys[0] - except ValueError: - default_sub_sub = timeframe_keys[0] - else: - default_sub_sub = default_element - - return default_main, default_element, default_sub_sub, timeframe_keys - - -def _parse_time_input(value): - if value in (None, '', 0): - return None - try: - return int(float(value)) - except (ValueError, TypeError): - return None - - -def refresh_data_service_metadata(force=False): - """刷新数据服务提供的交易对与周期元信息。""" - global DATA_SERVICE_AVAILABLE, TIMEFRAMES, SYMBOLS, SERVICE_METADATA_LAST_REFRESH - now = time.time() - if not force and DATA_SERVICE_AVAILABLE and now - SERVICE_METADATA_LAST_REFRESH < 60: - return True - try: - resp = requests.get(f"{DATA_SERVICE_URL}/health", timeout=5) - resp.raise_for_status() - payload = resp.json() - service_symbols = payload.get("symbols") or payload.get("symbol_list") or [] - base_timeframes = payload.get("timeframes") or payload.get("base_timeframes") or [] - derived = payload.get("derived_timeframes") or [] - service_timeframes = list(base_timeframes) - for tf in derived: - if tf not in service_timeframes: - service_timeframes.append(tf) - if service_symbols: - SYMBOLS[:] = service_symbols - if service_timeframes: - TIMEFRAMES.clear() - TIMEFRAMES.update(build_timeframe_labels(service_timeframes)) - DATA_SERVICE_AVAILABLE = True - SERVICE_METADATA_LAST_REFRESH = now - return True - except Exception as exc: - logger.warning("无法加载数据服务元信息: %s", exc) - if not DATA_SERVICE_AVAILABLE: - TIMEFRAMES.clear() - TIMEFRAMES.update(DEFAULT_TIMEFRAME_LABELS) - SYMBOLS[:] = DEFAULT_SYMBOLS - DATA_SERVICE_AVAILABLE = False - return False - - -def _fetch_kl_from_datasvc(symbol, timeframe, start_ms=None, end_ms=None, limit=None): - params = {"symbol": symbol, "tf": timeframe} - if start_ms is not None: - params["start"] = int(start_ms) - if end_ms is not None: - params["end"] = int(end_ms) - if limit is not None: - params["limit"] = limit - resp = requests.get(f"{DATA_SERVICE_URL}/api/candles", params=params, timeout=10) - resp.raise_for_status() - data = resp.json() - if not data: - return None - df = pd.DataFrame(data) - if df.empty or "timestamp" not in df.columns: - return None - numeric_cols = ["open", "high", "low", "close", "volume"] - df["timestamp"] = pd.to_numeric(df["timestamp"], errors="coerce") - df = df.dropna(subset=["timestamp"]) - df["timestamp"] = df["timestamp"].astype("int64") - for col in numeric_cols: - if col in df.columns: - df[col] = pd.to_numeric(df[col], errors="coerce") - df = df.dropna(subset=numeric_cols) - df = df.sort_values("timestamp") - if limit and len(df) > limit: - df = df.tail(limit) - df = df.reset_index(drop=True) - df["date"] = pd.to_datetime(df["timestamp"], unit='ms', utc=True).dt.tz_convert('Asia/Shanghai') - return df - - -# 模块加载时尝试预取一次元信息,但失败不阻塞后续流程 -refresh_data_service_metadata(force=True) - -# A股热门股票 -# 模板中 A 股下拉仅放默认一项;用户切换到「A股」时由前端请求 /api/a_stocks 填充全市场(约 5500+) -A_STOCK_SYMBOLS = [{'symbol': '000001', 'name': '平安银行'}] - -def detect_symbol_type(symbol): - """检测交易对类型:crypto 或 a_stock""" - if '/' in symbol and 'USDT' in symbol: - return 'crypto' - elif len(symbol) == 6 and symbol.isdigit(): - return 'a_stock' - else: - return 'unknown' - -def get_kl_data(symbol, timeframe, limit=100000, start_time=None, end_time=None): - """获取K线数据,支持加密货币和A股""" - symbol_type = detect_symbol_type(symbol) - - if symbol_type == 'crypto': - return get_crypto_kl_data(symbol, timeframe, limit, start_time, end_time) - elif symbol_type == 'a_stock': - return get_a_stock_kl_data(symbol, timeframe, limit, start_time, end_time) - else: - return None - -def _get_crypto_kl_data_via_ccxt(symbol, timeframe, limit=100000, start_time=None, end_time=None): - """获取加密货币K线数据,支持分页加载确保获取指定时间范围内的所有数据""" - try: - # 初始化参数 - since = None - if start_time: - try: - since = int(start_time) - except ValueError: - pass - - # 结束时间处理 - until = None - if end_time: - try: - until = int(end_time) - except ValueError: - pass - - # 根据时间周期调整每次请求的数据量 - batch_size = 1000 # 默认批次大小 - if timeframe in ['1m', '3m', '5m']: - batch_size = 1000 # 分钟级数据减少批次大小 - elif timeframe in ['15m', '30m', '1h']: - batch_size = 1000 - else: - batch_size = 1500 # 日线及以上可以获取更多 - batch_size = 1500 # 默认批次大小 - # 初始化存储所有K线数据的列表 - all_ohlcv = [] - - # 初始化当前查询的开始时间 - current_since = since - - # 添加请求计数和最大限制 - request_count = 0 - max_requests = 300 # 最大请求次数,防止无限循环 - - # 分页加载数据 - while request_count < max_requests: - request_count += 1 - - try: - # 获取当前页的数据 - ohlcv = exchange.fetch_ohlcv(symbol, timeframe, since=current_since, limit=batch_size) - - # 如果没有获取到数据,结束循环 - if not ohlcv or len(ohlcv) == 0: - break - - # 将获取到的数据添加到总列表中 - all_ohlcv.extend(ohlcv) - - # 获取最后一条数据的时间戳 - last_timestamp = ohlcv[-1][0] - - # 如果已达到结束时间,结束循环 - if until and last_timestamp >= until: - break - - # 如果获取的数据条数小于限制数,说明已经获取完所有数据 - if len(ohlcv) < batch_size: - break - - # 更新下一页的开始时间(加1毫秒避免重复) - current_since = last_timestamp + 1 - - except Exception as e: - # 如果单个批次失败,继续尝试下一个批次 - if current_since: - # 尝试增加时间跳过可能的问题时间点 - current_since += 60000 # 跳过1分钟 - else: - break - - # 防止API请求过于频繁 - time.sleep(0.3) # 减少到0.3秒提高效率 - - # 数据为空的情况 - if not all_ohlcv or len(all_ohlcv) == 0: - return None - - # 转换为DataFrame - df = pd.DataFrame(all_ohlcv, columns=['timestamp', 'open', 'high', 'low', 'close', 'volume']) - df['date'] = pd.to_datetime(df['timestamp'], unit='ms').dt.tz_localize('UTC').dt.tz_convert('Asia/Shanghai') - - # 在客户端进行结束时间过滤 - if until: - df = df[df['timestamp'] <= until] - - # 去除重复数据 - df = df.drop_duplicates(subset=['timestamp']) - - # 按时间排序 - df = df.sort_values('timestamp') - - # 限制数据条数的逻辑 - 优先考虑时间范围 - if start_time and end_time: - # 如果指定了明确的时间范围,返回该时间范围内的所有数据 - if len(df) > 100000: # 防止数据量过大,设置一个合理的上限 - df = df.tail(100000).reset_index(drop=True) - elif limit and len(df) > limit: - # 如果没有指定明确时间范围,使用默认的limit限制 - df = df.tail(limit).reset_index(drop=True) - - # 如果过滤后没有数据,返回None - if len(df) == 0: - return None - return df - - except Exception as e: - return None - - -def get_crypto_kl_data(symbol, timeframe, limit=100000, start_time=None, end_time=None): - """优先通过本地数据服务获取加密货币K线,失败时回退至交易所API。""" - start_ms = _parse_time_input(start_time) - end_ms = _parse_time_input(end_time) - - refresh_data_service_metadata() - if DATA_SERVICE_AVAILABLE: - try: - df = _fetch_kl_from_datasvc( - symbol=symbol, - timeframe=timeframe, - start_ms=start_ms, - end_ms=end_ms, - limit=limit, - ) - if df is not None and not df.empty: - return df - except Exception as exc: - logger.warning("数据服务请求失败,准备回退至交易所 API:%s", exc) - - return _get_crypto_kl_data_via_ccxt(symbol, timeframe, limit, start_time, end_time) - - -def get_a_stock_kl_data(symbol, timeframe, limit=100000, start_time=None, end_time=None): - """获取A股K线数据""" - try: - # 处理时间戳参数转换为日期字符串 - start_date = None - end_date = None - - if start_time: - try: - # 尝试解析时间戳(毫秒) - start_timestamp = int(start_time) - start_date = datetime.fromtimestamp(start_timestamp / 1000).strftime('%Y-%m-%d') - except (ValueError, TypeError): - # 如果不是时间戳,尝试解析datetime-local格式 (YYYY-MM-DDTHH:MM) - try: - if 'T' in str(start_time): - # datetime-local格式:2025-05-19T06:07 - start_date = str(start_time).split('T')[0] # 只取日期部分 - else: - start_date = str(start_time) - except: - start_date = start_time - - if end_time: - try: - # 尝试解析时间戳(毫秒) - end_timestamp = int(end_time) - end_date = datetime.fromtimestamp(end_timestamp / 1000).strftime('%Y-%m-%d') - except (ValueError, TypeError): - # 如果不是时间戳,尝试解析datetime-local格式 - try: - if 'T' in str(end_time): - # datetime-local格式:2025-05-26T06:07 - end_date = str(end_time).split('T')[0] # 只取日期部分 - else: - end_date = str(end_time) - except: - end_date = end_time - - # 如果用户指定了时间范围,优先获取该范围内的所有数据 - actual_limit = limit - if start_date and end_date: - actual_limit = None # 不限制数据条数,获取完整时间范围数据 - - # 调用A股数据获取器 - df = china_stock.get_kl_data(symbol, timeframe, start_date, end_date, actual_limit) - - if df is None: - return None - return df - - except Exception as e: - return None - -def add_indicators(df): - global macd_fast_period, macd_slow_period, macd_signal_period - macd = ta.MACD(df, fastperiod=macd_fast_period, slowperiod=macd_slow_period, signalperiod=macd_signal_period) - - df['macd'] = macd['macd'] - df['macdsignal'] = macd['macdsignal'] - df['macdhist'] = macd['macdhist'] - df['ma5'] = (ta.MA(df, timeperiod=5)).fillna(0) - df['ma10'] = (ta.MA(df, timeperiod=10)).fillna(0) - df['ma30'] = (ta.EMA(df, timeperiod=30)).fillna(0) - df['ma250'] = (ta.MA(df, timeperiod=250)).fillna(0) - # 新增 EMA 指标 - df['ema5'] = (ta.EMA(df, timeperiod=5)).fillna(0) - df['ema10'] = (ta.EMA(df, timeperiod=10)).fillna(0) - df['ema24'] = (ta.EMA(df, timeperiod=24)).fillna(0) - df['ema52'] = (ta.EMA(df, timeperiod=52)).fillna(0) - df['ema26'] = (ta.EMA(df, timeperiod=26)).fillna(0) - df['ema13'] = (ta.EMA(df, timeperiod=13)).fillna(0) - df['ema7'] = (ta.EMA(df, timeperiod=7)).fillna(0) - df['ema104'] = (ta.EMA(df, timeperiod=104)).fillna(0) - df['ema156'] = (ta.EMA(df, timeperiod=156)).fillna(0) - df['ema208'] = (ta.EMA(df, timeperiod=208)).fillna(0) - # 常用SMA 24/52 - try: - df['sma24'] = (ta.SMA(df, timeperiod=24)).fillna(0) - df['sma52'] = (ta.SMA(df, timeperiod=52)).fillna(0) - except Exception: - df['sma24'] = 0 - df['sma52'] = 0 - df['rsi'] = ta.RSI(df, timeperiod=14) - - # 计算布林带 (当前周期 - 20周期,2标准差) - bb = ta.BBANDS(df, timeperiod=365, nbdevup=3.0, nbdevdn=3.0, matype=0) - df['bb_upper'] = bb['upperband'].fillna(0) - df['bb_middle'] = bb['middleband'].fillna(0) - df['bb_lower'] = bb['lowerband'].fillna(0) - bb30 = ta.BBANDS(df, timeperiod=41, nbdevup=2.3, nbdevdn=2.3, matype=0) - #bb30 = ta.BBANDS(df, timeperiod=20, nbdevup=2.0, nbdevdn=2.0, matype=0) - df['bbup30'] = bb30['upperband'].fillna(0) - df['bblow30'] = bb30['lowerband'].fillna(0) - bb302 = ta.BBANDS(df, timeperiod=41, nbdevup=2.0, nbdevdn=2.0, matype=0) - #bb302 = ta.BBANDS(df, timeperiod=20, nbdevup=2.0, nbdevdn=2.0, matype=0) - df['bbup302'] = bb302['upperband'].fillna(0) - df['bblow302'] = bb302['lowerband'].fillna(0) - # 计算次周期布林带 (14周期,2标准差) - bb_element = ta.BBANDS(df, timeperiod=14, nbdevup=2.0, nbdevdn=2.0, matype=0) - df['element_bb_upper'] = bb_element['upperband'].fillna(0) - df['element_bb_middle'] = bb_element['middleband'].fillna(0) - df['element_bb_lower'] = bb_element['lowerband'].fillna(0) - - df['macd'] = df['macd'].fillna(0) - df['macdsignal'] = df['macdsignal'].fillna(0) - df['macdhist'] = df['macdhist'].fillna(0) - df['ma5'] = df['ma5'].fillna(0) - df['ma10'] = df['ma10'].fillna(0) - df['ma30'] = df['ma30'].fillna(0) - df['ma250'] = df['ma250'].fillna(0) - df['ema5'] = df['ema5'].fillna(0) - df['ema10'] = df['ema10'].fillna(0) - df['ema24'] = df['ema24'].fillna(0) - df['ema52'] = df['ema52'].fillna(0) - df['sma24'] = df['sma24'].fillna(0) - df['sma52'] = df['sma52'].fillna(0) - df['rsi'] = df['rsi'].fillna(0) - df['avg_volume'] = df['volume'].rolling(10).mean() - # 计算量比,避免产生Infinity值 - df['volume_ratio'] = df['volume'] / df['avg_volume'] - # 填充缺失值(前N根K线) - df['volume_ratio'] = df['volume_ratio'].fillna(1.0) - df['avg_volume'] = df['avg_volume'].fillna(0) - - # 处理Infinity和-Infinity值 - df['volume_ratio'] = df['volume_ratio'].replace([float('inf'), float('-inf')], 1.0) - - # 计算ATR (Average True Range) - 14周期 - df['atr'] = ta.ATR(df, timeperiod=14) - df['atr'] = df['atr'].fillna(0) - bb2633 = ta.BBANDS(df, timeperiod=26, nbdevup=3.0, nbdevdn=3.0, matype=0) - bbp2633 = (df['close'] - bb2633['lowerband']) / (bb2633['upperband'] - bb2633['lowerband']) - df['bb2633upper'] = bb2633['upperband'].fillna(0) - df['bb2633lower'] = bb2633['lowerband'].fillna(0) - df['bbp2633'] = bbp2633.fillna(0) - df['bb2633middle'] = bb2633['middleband'].fillna(0) - return df - -def calculate_macd(df): - """计算MACD指标""" - global macd_fast_period, macd_slow_period, macd_signal_period - exp1 = df['close'].ewm(span=macd_fast_period, adjust=False).mean() - exp2 = df['close'].ewm(span=macd_slow_period, adjust=False).mean() - macd = exp1 - exp2 - signal = macd.ewm(span=macd_signal_period, adjust=False).mean() - histogram = macd - signal - - return { - 'macd': macd.tolist(), - 'signal': signal.tolist(), - 'histogram': histogram.tolist() - } - -def analyze_chan(df, symbol=None, timeframe=None): - """进行缠论分析""" - chan = TF_DF() - - # 初始化多时间周期数据以获取EMA52 - ema52_dict = None - # 获取分析结果 - klu_list = chan.get_kl_data(df) - klc_list = chan.get_klc_list(klu_list) - bi_list = chan.cal_bi_list(klc_list) - #for index in range(0, 10): - #print(bi_list[index].start_time, bi_list[index].start_klc.end_time, bi_list[index].dir) - seg_list = chan.get_seg_list(bi_list) - zs_list = chan.calculate_seg_zs(seg_list) - # 计算笔中枢(BI中枢)并拍平成列表 - - #bi_zs_list = chan.cal_bi_zs_list_pure(bi_list) - bi_zs_list = chan.cal_bi_zs(seg_list) - bsp_list = [] - if len(bi_zs_list) > 0: - bsp_list = chan.find_all_bsp(bi_list, bi_zs_list) - #bsp_state_list = chan.get_bsp_state(df) - #for bsp in bsp_list: - #print(bsp.end_time, bsp.type, bsp.dir) - # 添加买卖点识别 - for bi in bi_list: - bi.cal_macdhist() - for bi in bi_list: - bi.cal_macd_div() - #print(bi.start_time, bi.macd_hist, bi.macd_div) - - # 添加ChanMACD分析 - chan_macd = None - chan_macd_data = {} - try: - - if klu_list and len(klu_list) > 0: - print(f"获取到KLU列表,长度: {len(klu_list)}") - chan_macd = ChanMACD(klu_list) - chan_macd_data = { - 'seg_list': chan_macd.seg_list, - 'unittf_list': chan_macd.unittf_list, - 'histset_list': chan_macd.histset_list, - 'klu_list': chan_macd.klu_list, - 'high_position_list': chan_macd.high_position_list, - 'high_empty_list': chan_macd.high_empty_list, - 'low_position_list': getattr(chan_macd, 'low_position_list', []), - 'low_empty_list': getattr(chan_macd, 'low_empty_list', []), - 'return_zero_list': chan_macd.return_zero_list, - 'cross0_up_list': chan_macd.cross0_up_list, - 'cross0_down_list': chan_macd.cross0_down_list - } - print(f"ChanMACD分析完成: seg={len(chan_macd.seg_list)}, unittf={len(chan_macd.unittf_list)}, histset={len(chan_macd.histset_list)}") - else: - print("未能获取KLU列表或列表为空") - chan_macd_data = { - 'seg_list': [], - 'unittf_list': [], - 'histset_list': [], - 'high_position_list': [], - 'high_empty_list': [], - 'return_zero_list': [], - 'cross0_up_list': [], - 'cross0_down_list': [] - } - except Exception as e: - print(f"ChanMACD分析出错: {e}") - import traceback - traceback.print_exc() - chan_macd_data = { - 'seg_list': [], - 'unittf_list': [], - 'histset_list': [], - 'high_position_list': [], - 'high_empty_list': [], - 'low_position_list': [], - 'low_empty_list': [], - 'return_zero_list': [], - 'cross0_up_list': [], - 'cross0_down_list': [] - } - - # 提取K线分型信息 - klc_fx_info = [] - for klc in klc_list: - if hasattr(klc, 'klc_fx_type') and klc.klc_fx_type != Chan_KLC_FX.UNKNOWN: - try: - # 计算分型强度 - fx_strength = 0 - fx_strength_level = "" - is_strong_fx = False - - # 统一使用cal_fx_strength函数 - if hasattr(klc, 'cal_fx_strength'): - fx_strength = klc.cal_fx_strength(5) - - # 尝试获取分型强度等级 - if hasattr(klc, 'get_fx_strength_level'): - fx_strength_level = klc.get_fx_strength_level() - - # 尝试判断是否为强分型 - if hasattr(klc, 'is_strong_fx'): - is_strong_fx = klc.is_strong_fx() - - # 如果分型强度小于1,设为0 - if fx_strength < 1: - fx_strength = 0 - - # KLC 分型框(起止时间+高低价): - # 仅使用 cal_fx_box 通过 display 条件后生成的 klc.fx_box。 - # 若无 fx_box,则前端不应绘制分型框。 - fx_box = getattr(klc, 'fx_box', None) - box_start_time = getattr(fx_box, 'start_time', None) if fx_box else None - box_end_time = getattr(fx_box, 'end_time', None) if fx_box else None - box_high = getattr(fx_box, 'high', None) if fx_box else None - box_low = getattr(fx_box, 'low', None) if fx_box else None - - if klc.bb_out: - klc_fx_info.append({ - 'time': klc.end_time, - 'price': klc.low if klc.fx == Chan_FX_TYPE.BOTTOM else klc.high, - 'fx_type': str(klc.klc_fx_type).replace("Chan_KLC_FX.", ""), - 'is_bottom': klc.fx == Chan_FX_TYPE.BOTTOM, - 'fx_strength': fx_strength, # 分型强度分数 (0-100) - 'fx_strength_level': fx_strength_level, # 分型强度等级 (极强/强/中等/弱/极弱) - 'is_strong_fx': is_strong_fx, # 是否为强分型 - - # 虚线分型框信息(给前端画框用) - 'start_time': box_start_time, - 'end_time': box_end_time, - 'high': float(box_high) if box_high is not None else None, - 'low': float(box_low) if box_low is not None else None, - }) - except Exception as e: - # 如果出错,仍然添加基本信息,但分型强度为0 - fx_box = getattr(klc, 'fx_box', None) - box_start_time = getattr(fx_box, 'start_time', None) if fx_box else None - box_end_time = getattr(fx_box, 'end_time', None) if fx_box else None - box_high = getattr(fx_box, 'high', None) if fx_box else None - box_low = getattr(fx_box, 'low', None) if fx_box else None - - klc_fx_info.append({ - 'time': klc.end_time, - 'price': klc.low if klc.fx == Chan_FX_TYPE.BOTTOM else klc.high, - 'fx_type': str(klc.klc_fx_type).replace("Chan_KLC_FX.", ""), - 'is_bottom': klc.fx == Chan_FX_TYPE.BOTTOM, - 'fx_strength': 0, - 'fx_strength_level': "", - 'is_strong_fx': False, - - # 虚线分型框信息(给前端画框用) - 'start_time': box_start_time, - 'end_time': box_end_time, - 'high': float(box_high) if box_high is not None else None, - 'low': float(box_low) if box_low is not None else None, - }) - - - return { - 'klc_list': klc_list, - 'klu_list': klu_list, # 添加KLU列表 - 'bi_list': bi_list, - 'seg_list': seg_list, - 'zs_list': zs_list, - 'bi_zs_list': bi_zs_list, # 添加BI中枢列表 - 'bsp_list': bsp_list, # 添加买卖点列表 - 'klc_fx_info': klc_fx_info, # KLC分型信息 - 'chan_macd': chan_macd_data, # 添加ChanMACD分析数据 - 'ema52_dict': ema52_dict # 添加多时间周期EMA52数据 - } - -# 辅助函数,转换缠论方向枚举为整数 -def convert_direction(direction): - """转换方向枚举为数字""" - if direction == Chan_BI_DIR.UP or direction == Chan_SEG_DIR.UP: - return 1 - elif direction == Chan_BI_DIR.DOWN or direction == Chan_SEG_DIR.DOWN: - return -1 - else: - return 0 - -def format_time_safely(time_obj, client_tz): - """安全地格式化时间对象,处理字符串和datetime两种情况""" - if time_obj is None: - return None - - if isinstance(time_obj, str): - # 尝试将字符串解析为datetime - try: - from dateutil import parser - time_obj = parser.parse(time_obj) - return time_obj.astimezone(client_tz).isoformat() - except: - return time_obj - else: - # 已经是datetime对象 - return time_obj.astimezone(client_tz).isoformat() - -def serialize_chan_macd_data(chan_macd_data, client_tz): - """序列化ChanMACD数据为JSON可序列化格式""" - serialized_data = { - 'seg_list': [], - 'unittf_list': [], - 'histset_list': [], - # 状态标记数据 - 'high_position_list': [], - 'high_empty_list': [], - 'low_position_list': [], - 'low_empty_list': [], - 'return_zero_list': [], - 'cross0_up_list': [], - 'cross0_down_list': [], - # 新增:输出KLU的继续背驰/分离背驰标志 - 'klu_list': [] - } - - # 序列化seg_list - for seg in chan_macd_data.get('seg_list', []): - try: - seg_data = { - 'start_time': format_time_safely(seg.start_time, client_tz), - 'end_time': format_time_safely(seg.end_time, client_tz) if seg.end_time else None, - 'seg_dir': 'ABOVE' if seg.seg_dir == Chan_MACDSEG_DIR.ABOVE else 'UNDER', - 'klu_count': len(seg.klu_list) if hasattr(seg, 'klu_list') else 0, - 'unittf_count': len(seg.unittf_list) if hasattr(seg, 'unittf_list') else 0, - 'histset_count': len(seg.hist_set) if hasattr(seg, 'hist_set') else 0 - } - serialized_data['seg_list'].append(seg_data) - except Exception as e: - print(f"序列化seg出错: {e}") - continue - - # 序列化unittf_list(兼容新结构与枚举类型) - for unittf in chan_macd_data.get('unittf_list', []): - try: - dir_value = getattr(unittf, 'uinttf_dir', None) - dir_name = getattr(dir_value, 'name', dir_value if isinstance(dir_value, str) else None) - start_t = getattr(unittf, 'start_type', None) - start_type = getattr(start_t, 'name', start_t) - end_t = getattr(unittf, 'end_type', None) - end_type = getattr(end_t, 'name', end_t) - peak_abs = getattr(unittf, 'peak_abs', None) - if peak_abs is None: - peak_abs = getattr(unittf, 'peak_hist', None) - length = getattr(unittf, 'length', None) - if length is None: - length = len(unittf.klu_list) if hasattr(unittf, 'klu_list') else None - - unittf_data = { - 'start_time': format_time_safely(getattr(unittf, 'start_time', None), client_tz), - 'end_time': format_time_safely(getattr(unittf, 'end_time', None), client_tz) if getattr(unittf, 'end_time', None) else None, - 'dir': dir_name, # 'ABOVE' | 'UNDER' | None - 'start_type': start_type, # e.g. 'START' | 'CROSS0' | 'NEAR0_UP' | 'NEAR0_DOWN' - 'end_type': end_type, - 'invalid': getattr(unittf, 'invalid', False), - 'peak_abs': peak_abs, - 'length': length, - 'klu_count': len(unittf.klu_list) if hasattr(unittf, 'klu_list') else 0, - 'histset_count': len(unittf.histset_list) if hasattr(unittf, 'histset_list') else 0 - } - serialized_data['unittf_list'].append(unittf_data) - except Exception as e: - print(f"序列化unittf出错: {e}") - continue - - # 序列化histset_list - for histset in chan_macd_data.get('histset_list', []): - try: - histset_data = { - 'start_time': format_time_safely(getattr(histset, 'start_time', None), client_tz), - 'end_time': format_time_safely(getattr(histset, 'end_time', None), client_tz), - 'histset_dir': 'ABOVE' if histset.histset_dir == Chan_MACDHISTSET_DIR.ABOVE else 'UNDER', - 'klu_count': len(histset.klu_list) if hasattr(histset, 'klu_list') else 0 - } - serialized_data['histset_list'].append(histset_data) - except Exception as e: - print(f"序列化histset出错: {e}") - continue - - # 序列化状态标记数据 - # 序列化高位列表 - for high_pos in chan_macd_data.get('high_position_list', []): - try: - high_pos_data = { - 'time': format_time_safely(high_pos['time'], client_tz), - 'end_time': format_time_safely(high_pos.get('end_time'), client_tz) if high_pos.get('end_time') else None, - 'type': high_pos.get('type', 'start'), - 'macd': high_pos.get('macd'), - 'signal': high_pos.get('signal'), - 'macdhist': high_pos.get('macdhist'), - 'end_macd': high_pos.get('end_macd'), - 'end_signal': high_pos.get('end_signal'), - 'end_macdhist': high_pos.get('end_macdhist') - } - serialized_data['high_position_list'].append(high_pos_data) - except Exception as e: - print(f"序列化high_position出错: {e}") - continue - - # 序列化高位空列表 - for high_empty in chan_macd_data.get('high_empty_list', []): - try: - high_empty_data = { - 'time': format_time_safely(high_empty['time'], client_tz), - 'end_time': format_time_safely(high_empty.get('end_time'), client_tz) if high_empty.get('end_time') else None, - 'type': high_empty.get('type', 'start'), - 'macd': high_empty.get('macd'), - 'signal': high_empty.get('signal'), - 'macdhist': high_empty.get('macdhist'), - 'end_macd': high_empty.get('end_macd'), - 'end_signal': high_empty.get('end_signal'), - 'end_macdhist': high_empty.get('end_macdhist') - } - serialized_data['high_empty_list'].append(high_empty_data) - except Exception as e: - print(f"序列化high_empty出错: {e}") - continue - - # 序列化低位与低位空 - for low_pos in chan_macd_data.get('low_position_list', []): - try: - low_pos_data = { - 'time': format_time_safely(low_pos['time'], client_tz), - 'end_time': format_time_safely(low_pos.get('end_time'), client_tz) if low_pos.get('end_time') else None, - 'type': low_pos.get('type', 'start'), - 'macd': low_pos.get('macd'), - 'signal': low_pos.get('signal'), - 'macdhist': low_pos.get('macdhist'), - 'end_macd': low_pos.get('end_macd'), - 'end_signal': low_pos.get('end_signal'), - 'end_macdhist': low_pos.get('end_macdhist') - } - serialized_data['low_position_list'].append(low_pos_data) - except Exception as e: - print(f"序列化low_position出错: {e}") - continue - - for low_empty in chan_macd_data.get('low_empty_list', []): - try: - low_empty_data = { - 'time': format_time_safely(low_empty['time'], client_tz), - 'end_time': format_time_safely(low_empty.get('end_time'), client_tz) if low_empty.get('end_time') else None, - 'type': low_empty.get('type', 'start'), - 'macd': low_empty.get('macd'), - 'signal': low_empty.get('signal'), - 'macdhist': low_empty.get('macdhist'), - 'end_macd': low_empty.get('end_macd'), - 'end_signal': low_empty.get('end_signal'), - 'end_macdhist': low_empty.get('end_macdhist') - } - serialized_data['low_empty_list'].append(low_empty_data) - except Exception as e: - print(f"序列化low_empty出错: {e}") - continue - - # 序列化归零轴列表 - for return_zero in chan_macd_data.get('return_zero_list', []): - try: - return_zero_data = { - 'time': format_time_safely(return_zero['time'], client_tz), - 'end_time': format_time_safely(return_zero.get('end_time'), client_tz) if return_zero.get('end_time') else None, - 'type': return_zero.get('type', 'start'), - 'macd': return_zero.get('macd'), - 'signal': return_zero.get('signal'), - 'macdhist': return_zero.get('macdhist'), - 'end_macd': return_zero.get('end_macd'), - 'end_signal': return_zero.get('end_signal'), - 'end_macdhist': return_zero.get('end_macdhist') - } - serialized_data['return_zero_list'].append(return_zero_data) - except Exception as e: - print(f"序列化return_zero出错: {e}") - continue - - # 序列化穿越零轴列表 - for cross0_up in chan_macd_data.get('cross0_up_list', []): - try: - cross0_up_data = { - 'time': format_time_safely(cross0_up['time'], client_tz), - 'type': cross0_up.get('type', 'start'), - 'macd': cross0_up.get('macd'), - 'signal': cross0_up.get('signal'), - 'macdhist': cross0_up.get('macdhist') - } - serialized_data['cross0_up_list'].append(cross0_up_data) - except Exception as e: - print(f"序列化cross0_up出错: {e}") - continue - - for cross0_down in chan_macd_data.get('cross0_down_list', []): - try: - cross0_down_data = { - 'time': format_time_safely(cross0_down['time'], client_tz), - 'type': cross0_down.get('type', 'start'), - 'macd': cross0_down.get('macd'), - 'signal': cross0_down.get('signal'), - 'macdhist': cross0_down.get('macdhist') - } - serialized_data['cross0_down_list'].append(cross0_down_data) - except Exception as e: - print(f"序列化cross0_down出错: {e}") - continue - - # 序列化 KLU 列表(仅导出需要的时间与背驰标志) - for klu in chan_macd_data.get('klu_list', []): - try: - serialized_data['klu_list'].append({ - 'time': format_time_safely(getattr(klu, 'time', None), client_tz), - 'continue_div': bool(getattr(klu, 'continue_div', False)), - 'separate_div': int(getattr(klu, 'separate_div', 0)) if getattr(klu, 'separate_div', 0) is not None else 0, - 'near0_return': int(getattr(klu, 'near0_return', 0)) if getattr(klu, 'near0_return', 0) is not None else 0 - }) - except Exception as e: - print(f"序列化klu出错: {e}") - continue - - return serialized_data - -def is_smaller_timeframe(tf1, tf2): - """判断时间周期tf1是否小于tf2""" - tf1_value = timeframe_to_minutes(tf1) - tf2_value = timeframe_to_minutes(tf2) - if tf1_value is None or tf2_value is None: - return False - return tf1_value < tf2_value - -def is_smaller_or_equal_timeframe(tf1, tf2): - """判断时间周期tf1是否小于等于tf2""" - tf1_value = timeframe_to_minutes(tf1) - tf2_value = timeframe_to_minutes(tf2) - if tf1_value is None or tf2_value is None: - return False - return tf1_value <= tf2_value - -def clean_dataframe_for_json(df): - """清理DataFrame数据用于JSON序列化""" - # 创建副本避免修改原始数据 - clean_df = df.copy() - - # 替换NaN值为None - clean_df = clean_df.where(pd.notnull(clean_df), None) - - return clean_df - -# ====== 趋势判定与趋势筛选(币对) ====== - -def classify_trend_stage(df): - """根据 EMA 斜率与多空排列判断趋势方向与阶段 - 返回: direction in {"bull","bear","sideways"}, stage in {"early","mid","late"}, strength_score (0-100) - """ - if df is None or len(df) < 60: - return "sideways", "early", 0 - - # 使用 EMA5/10/24/52 - closes = df['close'].values - ema5 = df['ema5'].values if 'ema5' in df else ta.EMA(df, timeperiod=5) - ema10 = df['ema10'].values if 'ema10' in df else ta.EMA(df, timeperiod=10) - ema24 = df['ema24'].values if 'ema24' in df else ta.EMA(df, timeperiod=24) - ema52 = df['ema52'].values if 'ema52' in df else ta.EMA(df, timeperiod=52) - - # 最近N根用于斜率与排列判定 - lookback = min(30, len(df) - 1) - if lookback <= 5: - return "sideways", "early", 0 - - # 简单斜率: 最近k根的线性变化率近似 - def slope(arr, k=10): - k = min(k, len(arr) - 1) - if k < 2: - return 0.0 - y = arr[-k:] - x = np.arange(k) - # 最小二乘拟合斜率 - denom = np.dot(x - x.mean(), x - x.mean()) - if denom == 0: - return 0.0 - m = np.dot(y - y.mean(), x - x.mean()) / denom - return float(m) - - k_slope = 12 # 斜率窗口 - s5 = slope(ema5, k_slope) - s10 = slope(ema10, k_slope) - s24 = slope(ema24, k_slope) - s52 = slope(ema52, k_slope) - - # 多空排列 - last5, last10, last24, last52 = ema5[-1], ema10[-1], ema24[-1], ema52[-1] - bull_stack = last5 > last10 > last24 > last52 - bear_stack = last5 < last10 < last24 < last52 - - # 波动性与动量增强: MACD 柱体最近均值 - macdhist = df['macdhist'].values if 'macdhist' in df else calculate_macd(df)['histogram'] - hist_recent = macdhist[-lookback:] - hist_power = float(np.mean(np.abs(hist_recent))) if len(hist_recent) else 0.0 - - # 方向 - if bull_stack and s24 > 0 and s52 > 0: - direction = "bull" - elif bear_stack and s24 < 0 and s52 < 0: - direction = "bear" - else: - # 用价格相对 EMA52 辅助 - if closes[-1] > last52 and (s24 + s52) > 0: - direction = "bull" - elif closes[-1] < last52 and (s24 + s52) < 0: - direction = "bear" - else: - direction = "sideways" - - # 阶段: 依据(斜率大小、与EMA52距离、MACD柱体扩张/收敛) - dist52 = float((closes[-1] - last52) / last52) if last52 else 0.0 - slope_score = max(0.0, (abs(s24) + abs(s52)) * 1000.0) # 归一化 - dist_score = min(50.0, abs(dist52) * 200.0) - hist_score = min(30.0, hist_power * 10.0) - strength = float(min(100.0, slope_score + dist_score + hist_score)) - - # 简单阶段判定 - if direction == "sideways": - stage = "early" - strength = min(strength, 30.0) - else: - # 查看最近 hist 是否在扩大或收敛 - if len(hist_recent) >= 6: - recent_growth = np.mean(np.abs(hist_recent[-3:])) - np.mean(np.abs(hist_recent[-6:-3])) - else: - recent_growth = 0.0 - - if recent_growth > 0 and abs(dist52) < 0.05: - stage = "early" - elif recent_growth > 0 and abs(dist52) >= 0.05: - stage = "mid" - else: - stage = "late" - - return direction, stage, strength - - -def load_crypto_symbols(limit=200): - """加载常见USDT永续合约交易对,返回列表""" - refresh_data_service_metadata() - if SYMBOLS: - return SYMBOLS[:limit] - try: - markets = exchange.load_markets() - symbols = [s for s in markets.keys() if '/USDT' in s and ':USDT' in s] - return symbols[:limit] - except Exception: - return DEFAULT_SYMBOLS[:limit] - - -@app.route('/api/trend_filter', methods=['GET']) -def trend_filter(): - """趋势筛选接口(币对) - 参数: - timeframe: K线周期 - start_time, end_time: 毫秒时间戳,可选 - direction: bull/bear/sideways 可选 - stage: early/mid/late 可选 - min_strength: 0-100 可选 - symbols: 逗号分隔列表,可选;不传则自动加载部分USDT币对 - 返回符合条件的币对与简要统计 - """ - timeframe = request.args.get('timeframe', '1h') - start_time = request.args.get('start_time') - end_time = request.args.get('end_time') - want_direction = request.args.get('direction') # 可为 None - want_stage = request.args.get('stage') # 可为 None - try: - min_strength = float(request.args.get('min_strength', '0')) - except ValueError: - min_strength = 0.0 - - symbols_param = request.args.get('symbols') - if symbols_param: - symbols_list = [s.strip() for s in symbols_param.split(',') if s.strip()] - else: - symbols_list = load_crypto_symbols(limit=150) - - results = [] - for sym in symbols_list: - try: - df = get_crypto_kl_data(sym, timeframe, start_time=start_time, end_time=end_time) - if df is None or len(df) < 60: - continue - df = add_indicators(df) - direction, stage, strength = classify_trend_stage(df) - - if want_direction and direction != want_direction: - continue - if want_stage and stage != want_stage: - continue - if strength < min_strength: - continue - - last_row = df.iloc[-1] - results.append({ - 'symbol': sym, - 'time': int(last_row['timestamp']), - 'close': float(last_row['close']), - 'direction': direction, - 'stage': stage, - 'strength': float(round(strength, 2)), - 'ema5': float(last_row['ema5']), - 'ema10': float(last_row['ema10']), - 'ema24': float(last_row['ema24']), - 'ema52': float(last_row['ema52']) - }) - except Exception: - continue - - # 按强度降序 - results.sort(key=lambda x: x['strength'], reverse=True) - return jsonify({ - 'count': len(results), - 'results': results - }) - - -@app.route('/api/trend_detail', methods=['GET']) -def trend_detail(): - """返回单个币对的K线与EMA、用于前端绘制趋势线 - 参数: symbol, timeframe, start_time, end_time - """ - symbol = request.args.get('symbol') - timeframe = request.args.get('timeframe', '1h') - start_time = request.args.get('start_time') - end_time = request.args.get('end_time') - timezone_name = request.args.get('timezone', 'Asia/Shanghai') - - if not symbol: - return jsonify({'error': 'symbol不能为空'}) - - df = get_crypto_kl_data(symbol, timeframe, start_time=start_time, end_time=end_time) - if df is None or len(df) == 0: - return jsonify({'error': '获取数据失败'}) - - df = add_indicators(df) - direction, stage, strength = classify_trend_stage(df) - - # 简单趋势线: 用最近N根收盘价做线性拟合 - N = min(80, len(df)) - sub = df.tail(N) - y = sub['close'].values - x = np.arange(len(y)) - denom = np.dot(x - x.mean(), x - x.mean()) - if denom != 0: - m = float(np.dot(y - y.mean(), x - x.mean()) / denom) - b = float(y.mean() - m * x.mean()) - else: - m, b = 0.0, float(y[-1]) - - client_tz = timezone(timezone_name) - - return jsonify({ - 'symbol': symbol, - 'timeframe': timeframe, - 'timezone': timezone_name, - 'direction': direction, - 'stage': stage, - 'strength': float(round(strength, 2)), - 'kline_data': clean_dataframe_for_json(df)[['timestamp','open','high','low','close','volume','ema5','ema10','ema24','ema52']].to_dict('records'), - 'trend_line': { - 'offset': int(df.index[-N]), - 'slope': m, - 'intercept': b, - 'length': int(N) - } - }) - -@app.route('/chan_tv') -def chan_tv(): - """缠论 TradingView 高级图表页面""" - return render_template('chan_tv.html') - -@app.route('/charting_library/') -def serve_charting_library(filename): - """提供 TradingView Charting Library 静态文件""" - return send_from_directory('charting_library', filename) - -@app.route('/') -def index(): - """主页""" - refresh_data_service_metadata() - tf_map = TIMEFRAMES if TIMEFRAMES else DEFAULT_TIMEFRAME_LABELS.copy() - default_main, default_element, default_sub_sub, timeframe_keys = compute_timeframe_defaults(OrderedDict(tf_map)) - symbols = SYMBOLS if SYMBOLS else DEFAULT_SYMBOLS - - default_symbol = 'BTC/USDT:USDT' if 'BTC/USDT:USDT' in symbols else (symbols[0] if symbols else '') - - return render_template( - 'index.html', - timeframes=tf_map, - symbols=symbols, - a_stock_symbols=A_STOCK_SYMBOLS, - default_main_timeframe=default_main, - default_element_timeframe=default_element, - default_sub_sub_timeframe=default_sub_sub, - default_symbol=default_symbol, - timeframe_keys_json=json.dumps(timeframe_keys), - data_service_available=DATA_SERVICE_AVAILABLE, - ) - - -@app.route('/api/chart_metadata') -def api_chart_metadata(): - """ - 按数据源返回图表用 K 线周期(中文标签)及主/次/次次默认周期。 - crypto:强制刷新 DATA_SERVICE_URL /health 元信息; - a_stock:读取 ASHARE_DP_URL 的 /api/v1/klines/available-freqs,不修改全局加密货币 TIMEFRAMES。 - """ - source = (request.args.get('source') or 'crypto').strip().lower() - if source not in ('crypto', 'a_stock'): - source = 'crypto' - try: - if source == 'a_stock': - raw = china_stock.get_available_kline_freqs() - labels_od = build_timeframe_labels(raw) - else: - refresh_data_service_metadata(force=True) - labels_od = OrderedDict(TIMEFRAMES if TIMEFRAMES else DEFAULT_TIMEFRAME_LABELS.copy()) - - default_main, default_element, default_sub_sub, keys = compute_timeframe_defaults(labels_od) - return jsonify({ - 'source': source, - 'timeframes': {k: v for k, v in labels_od.items()}, - 'timeframe_keys': keys, - 'default_main': default_main, - 'default_element': default_element, - 'default_sub_sub': default_sub_sub, - }) - except Exception as exc: - logger.exception('chart_metadata 失败: %s', exc) - return jsonify({'error': str(exc)}), 500 - - -@app.route('/api/analyze') -def analyze(): - """分析接口""" - symbol = request.args.get('symbol', 'SOL/USDT:USDT') - timeframe = request.args.get('timeframe', '5m') - - # 验证交易对不为空 - if not symbol or symbol.strip() == '': - return jsonify({'error': '交易对不能为空'}) - - # 获取时间范围参数 - start_time = request.args.get('start_time') - end_time = request.args.get('end_time') - - # 获取客户端请求的时区 - client_timezone = request.args.get('timezone', 'Asia/Shanghai') - - # 获取分形元素时间周期与次次周期 - element_timeframe = request.args.get('element_timeframe') - sub_sub_timeframe = request.args.get('sub_sub_timeframe') - - # 获取是否只需要分形元素数据的参数 - elements_only_param = request.args.get('elements_only') - elements_only = elements_only_param == 'true' - - # 验证小周期是否小于主周期 - if element_timeframe and not is_smaller_or_equal_timeframe(element_timeframe, timeframe): - return jsonify({'error': '分形元素时间周期必须小于或等于主图表时间周期'}) - # 验证次次周期是否小于等于次周期 - if sub_sub_timeframe and element_timeframe and not is_smaller_or_equal_timeframe(sub_sub_timeframe, element_timeframe): - return jsonify({'error': '次次周期必须小于或等于次周期'}) - - # 获取数据 - df = get_kl_data(symbol, timeframe, start_time=start_time, end_time=end_time) - if df is None: - return jsonify({'error': '获取数据失败'}) - - if len(df) == 0: - return jsonify({'error': '所选时间范围内没有数据'}) - - # 使用客户端指定的时区 - client_tz = timezone(client_timezone) - - # 如果只需要分形元素数据而不需要主周期数据,则初始化一个空结果 - result = { - 'timezone': client_timezone - } - - # 如果不是只需要分形元素数据,则添加主周期数据 - if not elements_only: - # 添加技术指标(包括布林带) - df = add_indicators(df) - - # 进行缠论分析 - analysis_result = analyze_chan(df, symbol, timeframe) - - # 计算MACD - macd_data = calculate_macd(df) - - # 基于已有 KLC 列表生成趋势标记(不做额外计算) - klc_trend = [] - try: - for klc in analysis_result.get('klc_list', []): - trend_val = getattr(klc, 'trend', None) - t_obj = getattr(klc, 'end_time', None) or getattr(klc, 'start_time', None) - if trend_val is None or t_obj is None: - continue - # 统一成字符串:UP/DOWN/FLAT/UNKNOWN - trend_name = str(trend_val) - if '.' in trend_name: - trend_name = trend_name.split('.')[-1] - time_str = format_time_safely(t_obj, client_tz) - if time_str: - klc_trend.append({'time': time_str, 'trend': trend_name}) - except Exception: - klc_trend = [] - - # 添加主周期分析结果到返回数据 - result.update({ - 'kline_data': clean_dataframe_for_json(df).to_dict('records'), - 'klc_list': [{ - 'date': klc.end_time if isinstance(klc.end_time, str) else klc.end_time.astimezone(client_tz).isoformat(), - 'open': float(klc.open), - 'high': float(klc.high), - 'low': float(klc.low), - 'close': float(klc.close), - 'volume': float(klc.volume) if hasattr(klc, 'volume') else 0, - 'direction': str(klc.dir).replace('Chan_KLINE_DIR.', ''), - 'fx_type': str(klc.fx).replace('Chan_FX_TYPE.', ''), - 'klc_fx_type': str(klc.klc_fx_type).replace('Chan_KLC_FX.', ''), - 'trend': str(klc.trend).replace('Chan_PRICE_TREND.', '') - } for klc in analysis_result['klc_list'] if hasattr(klc, 'end_time') and klc.end_time], - 'bi_list': [{ - 'start_time': bi.start_klc.end_time if isinstance(bi.start_klc.end_time, str) else bi.start_klc.end_time.astimezone(client_tz).isoformat(), - 'end_time': (bi.end_klc.end_time if isinstance(bi.end_klc.end_time, str) else bi.end_klc.end_time.astimezone(client_tz).isoformat()) if bi.end_klc else None, - 'sure_time': format_time_safely(bi.sure_time, client_tz) if bi.sure_time else None, - 'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high, - 'end_price': bi.end_klc.high if convert_direction(bi.dir) == 1 else bi.end_klc.low if bi.end_klc else None, - 'direction': convert_direction(bi.dir), - 'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0 - } for bi in analysis_result['bi_list'] if bi.is_sure], - # 添加未完成笔列表 - 'uncompleted_bi_list': [{ - 'start_time': bi.start_klc.end_time if isinstance(bi.start_klc.end_time, str) else bi.start_klc.end_time.astimezone(client_tz).isoformat(), - 'end_time': bi.end_time, # 未完成笔没有结束时间 - 'sure_time': format_time_safely(bi.sure_time, client_tz) if bi.sure_time else None, - 'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high, - 'end_price': bi.end_klc.low if convert_direction(bi.dir) == 1 else bi.end_klc.high, # 未完成笔没有结束价格 - 'direction': convert_direction(bi.dir), - 'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0 - } for bi in analysis_result['bi_list'] if not bi.is_sure], - 'seg_list': [{ - 'start_time': seg.start_bi.start_klc.end_time if isinstance(seg.start_bi.start_klc.end_time, str) else seg.start_bi.start_klc.end_time.astimezone(client_tz).isoformat(), - 'end_time': (seg.end_bi.end_klc.end_time if isinstance(seg.end_bi.end_klc.end_time, str) else seg.end_bi.end_klc.end_time.astimezone(client_tz).isoformat()) if seg.end_bi else None, - 'sure_time': format_time_safely(seg.sure_time, client_tz) if seg.sure_time else None, - 'start_price': seg.start_bi.start_klc.low if convert_direction(seg.dir) == 1 else seg.start_bi.start_klc.high, - 'end_price': seg.end_bi.end_klc.high if convert_direction(seg.dir) == 1 else seg.end_bi.end_klc.low if seg.end_bi else None, - 'direction': convert_direction(seg.dir) - } for seg in analysis_result['seg_list'] if seg.is_sure], - # 添加未完成线段列表 - 'uncompleted_seg_list': get_uncompleted_seg_list(analysis_result['seg_list'], client_tz), - 'zs_list': [{ - 'start_time': zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat(), - 'end_time': (zs.end_klc.end_time if isinstance(zs.end_klc.end_time, str) else zs.end_klc.end_time.astimezone(client_tz).isoformat()) if zs.end_klc else None, - 'zg': zs.zg, - 'zd': zs.zd, - 'gg': zs.gg, - 'dd': zs.dd, - 'is_sure': zs.is_sure # 添加中枢是否完成的标志 - } for zs in analysis_result['zs_list'] if zs.is_sure], - # 添加主周期BI中枢列表(已完成) - 'bi_zs_list': [{ - 'start_time': ( - (zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat()) - if getattr(zs.start_klc, 'end_time', None) else - (zs.start_klc.start_time if isinstance(zs.start_klc.start_time, str) else zs.start_klc.start_time.astimezone(client_tz).isoformat()) - ), - 'end_time': (zs.end_time if isinstance(zs.end_time, str) else zs.end_time.astimezone(client_tz).isoformat()) if getattr(zs, 'end_time', None) else None, - 'zg': zs.zg, - 'zd': zs.zd, - 'gg': zs.gg, - 'dd': zs.dd, - 'is_sure': bool(getattr(zs, 'is_sure', False)) - } for zs in analysis_result.get('bi_zs_list', []) if getattr(zs, 'is_sure', False)], - # 添加未完成中枢列表 - 'uncompleted_zs_list': [{ - 'start_time': zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat(), - 'end_time': None, # 未完成中枢没有结束时间 - 'zg': zs.zg, - 'zd': zs.zd, - 'gg': zs.gg, - 'dd': zs.dd, - 'is_sure': zs.is_sure # 未完成中枢的is_sure为False - } for zs in analysis_result['zs_list'] if not zs.is_sure], - # 添加未完成BI中枢列表 - 'uncompleted_bi_zs_list': [{ - 'start_time': ( - (zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat()) - if getattr(zs.start_klc, 'end_time', None) else - (zs.start_klc.start_time if isinstance(zs.start_klc.start_time, str) else zs.start_klc.start_time.astimezone(client_tz).isoformat()) - ), - 'end_time': None, - 'zg': zs.zg, - 'zd': zs.zd, - 'gg': zs.gg, - 'dd': zs.dd, - 'is_sure': bool(getattr(zs, 'is_sure', False)) - } for zs in analysis_result.get('bi_zs_list', []) if not getattr(zs, 'is_sure', False)], - - 'macd': macd_data, - # 添加布林带数据 - 'bollinger': { - 'upper': df['bb_upper'].tolist(), - 'middle': df['bb_middle'].tolist(), - 'lower': df['bb_lower'].tolist() - }, - 'element_bollinger': { - 'upper': df['element_bb_upper'].tolist(), - 'middle': df['element_bb_middle'].tolist(), - 'lower': df['element_bb_lower'].tolist() - }, - # 添加ATR数据 - 'atr': df['atr'].tolist(), - # 添加K线分型信息 - 'klc_fx_info': [{ - 'time': format_time_safely(point['time'], client_tz), - 'start_time': format_time_safely(point['start_time'], client_tz), - 'end_time': format_time_safely(point['end_time'], client_tz), - 'price': float(point['price']), - 'fx_type': point['fx_type'], - 'is_bottom': bool(point['is_bottom']), - 'fx_strength': float(point['fx_strength']), # 分型强度分数 - 'fx_strength_level': str(point['fx_strength_level']), # 分型强度等级 - 'is_strong_fx': bool(point['is_strong_fx']), # 是否为强分型 - # 分型框(虚线矩形)用到的高低价 - 'high': float(point['high']) if point.get('high') is not None else None, - 'low': float(point['low']) if point.get('low') is not None else None - } for point in analysis_result['klc_fx_info']], - # 添加ChanMACD分析数据 - 'chan_macd': serialize_chan_macd_data(analysis_result.get('chan_macd', {}), client_tz), - # 添加多时间周期EMA52数据 - 'ema52_dict': analysis_result.get('ema52_dict', {}), - # 直接输出KLC趋势标记(使用已有trend字段) - 'klc_trend': klc_trend, - # 添加主周期买卖点列表 - # 注意:部分枚举在转为字符串时可能形如 "Chan_BSP_TYPE.BSP1(1)", - # 这里进行健壮的解析,确保前端拿到的始终是 "BSP1" / "BUY" 这种简洁形式, - # 以便与前端的 BSP_STYLE 键(如 "BSP1_BUY")正确匹配。 - 'bsp_list': [{ - 'time': format_time_safely(bsp.end_time, client_tz), - 'price': float(bsp.klc.low if 'BUY' in str(bsp.dir) else bsp.klc.high), - # -- 规范化 type 名称,例如: - # "Chan_BSP_TYPE.BSP1" -> "BSP1" - # "Chan_BSP_TYPE.BSP1(1)" -> "BSP1" - # "BSP1" -> "BSP1" - 'type': ( - lambda raw: ( - (raw.split('.')[-1] if '.' in raw else raw).split('(')[0] - ) - )(str(bsp.type)), - # -- 规范化 dir 名称,例如: - # "Chan_BSP_DIR.BUY" -> "BUY" - # "Chan_BSP_DIR.BUY(1)" -> "BUY" - # "BUY" -> "BUY" - 'dir': ( - lambda raw: ( - (raw.split('.')[-1] if '.' in raw else raw).split('(')[0] - ) - )(str(bsp.dir)), - 'is_sure': bool(bsp.is_sure), - 'sure_time': format_time_safely(bsp.sure_time, client_tz) if bsp.sure_time else None, - 'zs_count': int(bsp.zs_count) if hasattr(bsp, 'zs_count') else 0 - } for bsp in analysis_result.get('bsp_list', [])] - }) - - - # 如果有指定分形元素时间周期,获取小周期数据 - if element_timeframe: - # 获取小周期数据,使用与主周期相同的时间范围 - element_df = get_kl_data(symbol, element_timeframe, start_time=start_time, end_time=end_time) - - if element_df is not None and len(element_df) > 0: - # 添加小周期技术指标(包括布林带) - element_df = add_indicators(element_df) - - # 对小周期数据进行缠论分析 - element_analysis = analyze_chan(element_df, symbol, element_timeframe) - - # 计算小周期MACD数据 - element_macd_data = calculate_macd(element_df) - - # 组装小周期 KLC 趋势(仅提取已有 trend,不做重算) - try: - element_klc_trend = [] - for klc in element_analysis.get('klc_list', []): - trend_val = getattr(klc, 'trend', None) - t_obj = getattr(klc, 'end_time', None) or getattr(klc, 'start_time', None) - if trend_val is None or t_obj is None: - continue - trend_name = str(trend_val) - if '.' in trend_name: - trend_name = trend_name.split('.')[-1] - time_str = format_time_safely(t_obj, client_tz) - if time_str: - element_klc_trend.append({'time': time_str, 'trend': trend_name}) - except Exception: - element_klc_trend = [] - - # 添加小周期分析结果到返回数据 - result['element_timeframe'] = element_timeframe - result['element_macd'] = element_macd_data # 添加小周期MACD数据 - - # 添加小周期布林带数据 - result['element_bollinger'] = { - 'upper': element_df['bb_upper'].tolist(), - 'middle': element_df['bb_middle'].tolist(), - 'lower': element_df['bb_lower'].tolist() - } - result['element_element_bollinger'] = { - 'upper': element_df['element_bb_upper'].tolist(), - 'middle': element_df['element_bb_middle'].tolist(), - 'lower': element_df['element_bb_lower'].tolist() - } - - # 添加小周期ATR数据 - result['element_atr'] = element_df['atr'].tolist() - - result['element_bi_list'] = [{ - 'start_time': bi.start_klc.end_time if isinstance(bi.start_klc.end_time, str) else bi.start_klc.end_time.astimezone(client_tz).isoformat(), - 'end_time': (bi.end_klc.end_time if isinstance(bi.end_klc.end_time, str) else bi.end_klc.end_time.astimezone(client_tz).isoformat()) if bi.end_klc else None, - 'sure_time': format_time_safely(bi.sure_time, client_tz) if bi.sure_time else None, - 'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high, - 'end_price': bi.end_klc.high if convert_direction(bi.dir) == 1 else bi.end_klc.low if bi.end_klc else None, - 'direction': convert_direction(bi.dir), - 'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0 - } for bi in element_analysis['bi_list'] if bi.is_sure] - # 添加次周期未完成笔列表 - result['element_uncompleted_bi_list'] = [{ - 'start_time': bi.start_klc.end_time if isinstance(bi.start_klc.end_time, str) else bi.start_klc.end_time.astimezone(client_tz).isoformat(), - 'end_time': bi.end_time, # 未完成笔没有结束时间 - 'sure_time': format_time_safely(bi.sure_time, client_tz) if bi.sure_time else None, - 'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high, - 'end_price': bi.end_klc.low if convert_direction(bi.dir) == 1 else bi.end_klc.high, # 未完成笔没有结束价格 - 'direction': convert_direction(bi.dir), - 'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0 - } for bi in element_analysis['bi_list'] if not bi.is_sure] - - # 添加小周期K线数据 - result['element_kline_data'] = clean_dataframe_for_json(element_df).to_dict('records') - - # 添加小周期KLC列表 - result['element_klc_list'] = [{ - 'date': klc.end_time if isinstance(klc.end_time, str) else klc.end_time.astimezone(client_tz).isoformat(), - 'open': float(klc.open), - 'high': float(klc.high), - 'low': float(klc.low), - 'close': float(klc.close), - 'volume': float(klc.volume) if hasattr(klc, 'volume') else 0, - 'direction': str(klc.dir).replace('Chan_KLINE_DIR.', ''), - 'fx_type': str(klc.fx).replace('Chan_FX_TYPE.', ''), - 'klc_fx_type': str(klc.klc_fx_type).replace('Chan_KLC_FX.', ''), - 'trend': str(klc.trend).replace('Chan_PRICE_TREND.', '') - } for klc in element_analysis['klc_list'] if hasattr(klc, 'end_time') and klc.end_time] - - result['element_seg_list'] = [{ - 'start_time': seg.start_bi.start_klc.end_time if isinstance(seg.start_bi.start_klc.end_time, str) else seg.start_bi.start_klc.end_time.astimezone(client_tz).isoformat(), - 'end_time': (seg.end_bi.end_klc.end_time if isinstance(seg.end_bi.end_klc.end_time, str) else seg.end_bi.end_klc.end_time.astimezone(client_tz).isoformat()) if seg.end_bi else None, - 'sure_time': format_time_safely(seg.sure_time, client_tz) if seg.sure_time else None, - 'start_price': seg.start_bi.start_klc.low if convert_direction(seg.dir) == 1 else seg.start_bi.start_klc.high, - 'end_price': seg.end_bi.end_klc.high if convert_direction(seg.dir) == 1 else seg.end_bi.end_klc.low if seg.end_bi else None, - 'direction': convert_direction(seg.dir) - } for seg in element_analysis['seg_list'] if seg.is_sure] - # 添加次周期未完成线段列表 - result['element_uncompleted_seg_list'] = get_uncompleted_seg_list(element_analysis['seg_list'], client_tz) - - result['element_zs_list'] = [{ - 'start_time': zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat(), - 'end_time': (zs.end_klc.end_time if isinstance(zs.end_klc.end_time, str) else zs.end_klc.end_time.astimezone(client_tz).isoformat()) if zs.end_klc else None, - 'zg': zs.zg, - 'zd': zs.zd, - 'gg': zs.gg, - 'dd': zs.dd, - 'is_sure': zs.is_sure # 添加中枢是否完成的标志 - } for zs in element_analysis['zs_list'] if zs.end_klc] - - result['element_uncompleted_zs_list'] = [{ - 'start_time': zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat(), - 'end_time': None, # 未完成中枢没有结束时间 - 'zg': zs.zg, - 'zd': zs.zd, - 'gg': zs.gg, - 'dd': zs.dd, - 'is_sure': zs.is_sure # 未完成中枢的is_sure为False - } for zs in element_analysis['zs_list'] if not zs.is_sure] - - # 添加次周期 BI 中枢(已完成/未完成) - result['element_bi_zs_list'] = [{ - 'start_time': ( - (zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat()) - if getattr(zs.start_klc, 'end_time', None) else - (zs.start_klc.start_time if isinstance(zs.start_klc.start_time, str) else zs.start_klc.start_time.astimezone(client_tz).isoformat()) - ), - 'end_time': (zs.end_time if isinstance(zs.end_time, str) else zs.end_time.astimezone(client_tz).isoformat()) if getattr(zs, 'end_time', None) else None, - 'zg': zs.zg, - 'zd': zs.zd, - 'gg': zs.gg, - 'dd': zs.dd, - 'is_sure': bool(getattr(zs, 'is_sure', False)) - } for zs in element_analysis.get('bi_zs_list', []) if getattr(zs, 'is_sure', False)] - result['element_uncompleted_bi_zs_list'] = [{ - 'start_time': ( - (zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat()) - if getattr(zs.start_klc, 'end_time', None) else - (zs.start_klc.start_time if isinstance(zs.start_klc.start_time, str) else zs.start_klc.start_time.astimezone(client_tz).isoformat()) - ), - 'end_time': None, - 'zg': zs.zg, - 'zd': zs.zd, - 'gg': zs.gg, - 'dd': zs.dd, - 'is_sure': bool(getattr(zs, 'is_sure', False)) - } for zs in element_analysis.get('bi_zs_list', []) if not getattr(zs, 'is_sure', False)] - - - # 添加小周期分型信息 - result['element_klc_fx_info'] = [{ - 'time': format_time_safely(point['time'], client_tz), - 'start_time': format_time_safely(point['start_time'], client_tz), - 'end_time': format_time_safely(point['end_time'], client_tz), - 'price': float(point['price']), - 'fx_type': point['fx_type'], - 'is_bottom': bool(point['is_bottom']), - 'fx_strength': float(point['fx_strength']), # 分型强度分数 - 'fx_strength_level': str(point['fx_strength_level']), # 分型强度等级 - 'is_strong_fx': bool(point['is_strong_fx']), # 是否为强分型 - # 分型框(虚线矩形)用到的高低价 - 'high': float(point['high']) if point.get('high') is not None else None, - 'low': float(point['low']) if point.get('low') is not None else None - } for point in element_analysis['klc_fx_info']] - - # 添加次周期ChanMACD分析数据 - result['element_chan_macd'] = serialize_chan_macd_data(element_analysis.get('chan_macd', {}), client_tz) - - # 添加小周期 KLC 趋势标记 - result['element_klc_trend'] = element_klc_trend - - # 添加次周期买卖点列表 - result['element_bsp_list'] = [{ - 'time': format_time_safely(bsp.end_time, client_tz), - 'price': float(bsp.klc.low if str(bsp.dir) == 'Chan_BSP_DIR.BUY' else bsp.klc.high), - 'type': str(bsp.type).replace('Chan_BSP_TYPE.', ''), - 'dir': str(bsp.dir).replace('Chan_BSP_DIR.', ''), - 'is_sure': bool(bsp.is_sure), - 'sure_time': format_time_safely(bsp.sure_time, client_tz) if bsp.sure_time else None, - 'zs_count': int(bsp.zs_count) if hasattr(bsp, 'zs_count') else 0 - } for bsp in element_analysis.get('bsp_list', [])] - - # 次次周期:仅当已指定次周期且次次周期有效时获取 - if sub_sub_timeframe and is_smaller_or_equal_timeframe(sub_sub_timeframe, element_timeframe): - sub_sub_df = get_kl_data(symbol, sub_sub_timeframe, start_time=start_time, end_time=end_time) - if sub_sub_df is not None and len(sub_sub_df) > 0: - sub_sub_df = add_indicators(sub_sub_df) - sub_sub_analysis = analyze_chan(sub_sub_df, symbol, sub_sub_timeframe) - result['sub_sub_timeframe'] = sub_sub_timeframe - result['sub_sub_kline_data'] = clean_dataframe_for_json(sub_sub_df).to_dict('records') - result['sub_sub_atr'] = sub_sub_df['atr'].tolist() - result['sub_sub_macd'] = calculate_macd(sub_sub_df) - result['sub_sub_bi_list'] = [{ - 'start_time': bi.start_klc.end_time if isinstance(bi.start_klc.end_time, str) else bi.start_klc.end_time.astimezone(client_tz).isoformat(), - 'end_time': (bi.end_klc.end_time if isinstance(bi.end_klc.end_time, str) else bi.end_klc.end_time.astimezone(client_tz).isoformat()) if bi.end_klc else None, - 'sure_time': format_time_safely(bi.sure_time, client_tz) if bi.sure_time else None, - 'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high, - 'end_price': bi.end_klc.high if convert_direction(bi.dir) == 1 else bi.end_klc.low if bi.end_klc else None, - 'direction': convert_direction(bi.dir), - 'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0 - } for bi in sub_sub_analysis['bi_list'] if bi.is_sure] - result['sub_sub_uncompleted_bi_list'] = [{ - 'start_time': bi.start_klc.end_time if isinstance(bi.start_klc.end_time, str) else bi.start_klc.end_time.astimezone(client_tz).isoformat(), - 'end_time': bi.end_time, - 'sure_time': format_time_safely(bi.sure_time, client_tz) if bi.sure_time else None, - 'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high, - 'end_price': bi.end_klc.low if convert_direction(bi.dir) == 1 else bi.end_klc.high, - 'direction': convert_direction(bi.dir), - 'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0 - } for bi in sub_sub_analysis['bi_list'] if not bi.is_sure] - # 次次周期 KLC 列表 - result['sub_sub_klc_list'] = [{ - 'date': klc.end_time if isinstance(klc.end_time, str) else klc.end_time.astimezone(client_tz).isoformat(), - 'open': float(klc.open), - 'high': float(klc.high), - 'low': float(klc.low), - 'close': float(klc.close), - 'volume': float(klc.volume) if hasattr(klc, 'volume') else 0, - 'direction': str(klc.dir).replace('Chan_KLINE_DIR.', ''), - 'fx_type': str(klc.fx).replace('Chan_FX_TYPE.', ''), - 'klc_fx_type': str(klc.klc_fx_type).replace('Chan_KLC_FX.', ''), - 'trend': str(klc.trend).replace('Chan_PRICE_TREND.', '') - } for klc in sub_sub_analysis.get('klc_list', []) if hasattr(klc, 'end_time') and klc.end_time] - - result['sub_sub_seg_list'] = [{ - 'start_time': seg.start_bi.start_klc.end_time if isinstance(seg.start_bi.start_klc.end_time, str) else seg.start_bi.start_klc.end_time.astimezone(client_tz).isoformat(), - 'end_time': (seg.end_bi.end_klc.end_time if isinstance(seg.end_bi.end_klc.end_time, str) else seg.end_bi.end_klc.end_time.astimezone(client_tz).isoformat()) if seg.end_bi else None, - 'sure_time': format_time_safely(seg.sure_time, client_tz) if seg.sure_time else None, - 'start_price': seg.start_bi.start_klc.low if convert_direction(seg.dir) == 1 else seg.start_bi.start_klc.high, - 'end_price': seg.end_bi.end_klc.high if convert_direction(seg.dir) == 1 else seg.end_bi.end_klc.low if seg.end_bi else None, - 'direction': convert_direction(seg.dir) - } for seg in sub_sub_analysis['seg_list'] if seg.is_sure] - result['sub_sub_uncompleted_seg_list'] = get_uncompleted_seg_list(sub_sub_analysis['seg_list'], client_tz) - result['sub_sub_zs_list'] = [{ - 'start_time': zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat(), - 'end_time': (zs.end_klc.end_time if isinstance(zs.end_klc.end_time, str) else zs.end_klc.end_time.astimezone(client_tz).isoformat()) if zs.end_klc else None, - 'zg': zs.zg, 'zd': zs.zd, 'gg': zs.gg, 'dd': zs.dd, 'is_sure': zs.is_sure - } for zs in sub_sub_analysis['zs_list'] if zs.end_klc] - result['sub_sub_uncompleted_zs_list'] = [{ - 'start_time': zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat(), - 'end_time': None, 'zg': zs.zg, 'zd': zs.zd, 'gg': zs.gg, 'dd': zs.dd, 'is_sure': zs.is_sure - } for zs in sub_sub_analysis['zs_list'] if not zs.is_sure] - result['sub_sub_bi_zs_list'] = [{ - 'start_time': ( - (zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat()) - if getattr(zs.start_klc, 'end_time', None) else - (zs.start_klc.start_time if isinstance(zs.start_klc.start_time, str) else zs.start_klc.start_time.astimezone(client_tz).isoformat()) - ), - 'end_time': (zs.end_time if isinstance(zs.end_time, str) else zs.end_time.astimezone(client_tz).isoformat()) if getattr(zs, 'end_time', None) else None, - 'zg': zs.zg, 'zd': zs.zd, 'gg': zs.gg, 'dd': zs.dd, 'is_sure': bool(getattr(zs, 'is_sure', False)) - } for zs in sub_sub_analysis.get('bi_zs_list', []) if getattr(zs, 'is_sure', False)] - result['sub_sub_uncompleted_bi_zs_list'] = [{ - 'start_time': ( - (zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat()) - if getattr(zs.start_klc, 'end_time', None) else - (zs.start_klc.start_time if isinstance(zs.start_klc.start_time, str) else zs.start_klc.start_time.astimezone(client_tz).isoformat()) - ), - 'end_time': None, 'zg': zs.zg, 'zd': zs.zd, 'gg': zs.gg, 'dd': zs.dd, 'is_sure': bool(getattr(zs, 'is_sure', False)) - } for zs in sub_sub_analysis.get('bi_zs_list', []) if not getattr(zs, 'is_sure', False)] - result['sub_sub_klc_fx_info'] = [{ - 'time': format_time_safely(point['time'], client_tz), - 'start_time': format_time_safely(point['start_time'], client_tz), - 'end_time': format_time_safely(point['end_time'], client_tz), - 'price': float(point['price']), - 'fx_type': point['fx_type'], - 'is_bottom': bool(point['is_bottom']), - 'fx_strength': float(point['fx_strength']), - 'fx_strength_level': str(point['fx_strength_level']), - 'is_strong_fx': bool(point['is_strong_fx']), - # 分型框(虚线矩形)用到的高低价 - 'high': float(point['high']) if point.get('high') is not None else None, - 'low': float(point['low']) if point.get('low') is not None else None - } for point in sub_sub_analysis['klc_fx_info']] - result['sub_sub_bsp_list'] = [{ - 'time': format_time_safely(bsp.end_time, client_tz), - 'price': float(bsp.klc.low if str(bsp.dir) == 'Chan_BSP_DIR.BUY' else bsp.klc.high), - 'type': str(bsp.type).replace('Chan_BSP_TYPE.', ''), - 'dir': str(bsp.dir).replace('Chan_BSP_DIR.', ''), - 'is_sure': bool(bsp.is_sure), - 'sure_time': format_time_safely(bsp.sure_time, client_tz) if bsp.sure_time else None, - 'zs_count': int(bsp.zs_count) if hasattr(bsp, 'zs_count') else 0 - } for bsp in sub_sub_analysis.get('bsp_list', [])] - result['sub_sub_chan_macd'] = serialize_chan_macd_data(sub_sub_analysis.get('chan_macd', {}), client_tz) - try: - sub_sub_klc_trend = [] - for klc in sub_sub_analysis.get('klc_list', []): - trend_val = getattr(klc, 'trend', None) - t_obj = getattr(klc, 'end_time', None) or getattr(klc, 'start_time', None) - if trend_val is None or t_obj is None: - continue - trend_name = str(trend_val) - if '.' in trend_name: - trend_name = trend_name.split('.')[-1] - time_str = format_time_safely(t_obj, client_tz) - if time_str: - sub_sub_klc_trend.append({'time': time_str, 'trend': trend_name}) - result['sub_sub_klc_trend'] = sub_sub_klc_trend - except Exception: - result['sub_sub_klc_trend'] = [] - - pass - - # 结构价值区分析(Structure Zone)—— 按需拉取:仅当 include_structure_zones 为真时执行多周期拉取(默认跳过以减轻负载) - include_zones_param = request.args.get('include_structure_zones', '') - include_structure_zones = str(include_zones_param).lower() in ('1', 'true', 'yes') - if include_structure_zones: - zone_timeframes_str = request.args.get('zone_timeframes', '') - zone_kl_lines = int(request.args.get('zone_kl_lines', 1000)) - try: - zone_config = StructureZoneConfig(kl_lines_per_tf=zone_kl_lines) - if zone_timeframes_str: - zone_config.zone_timeframes = [t.strip() for t in zone_timeframes_str.split(',') if t.strip()] - analyses = {} - ema52_dict = {} - latest_close = 0.0 - now = time.time() - - def _fetch_single_tf_zone(tf_name): - """单个时间周期的结构区数据拉取(线程安全)""" - cache_key = f"{symbol}:{tf_name}:{zone_kl_lines}" - cached = _zone_cache.get(cache_key) - if cached and cached['expires'] > now: - print(f" 结构区缓存命中: {tf_name}") - return { - 'tf_name': tf_name, - 'analyses': cached['analyses'], - 'ema52': cached['ema52'], - 'close': cached.get('close', 0.0), - 'cached': True, - } - - try: - tf_df = get_kl_data(symbol, tf_name, limit=zone_kl_lines) - if tf_df is None or len(tf_df) == 0: - return None - tf_df = add_indicators(tf_df) - tf_analysis = analyze_chan(tf_df, symbol, tf_name) - zs_serialized = [{ - 'start_time': (zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat()) if zs.start_klc else None, - 'end_time': (zs.end_klc.end_time if isinstance(zs.end_klc.end_time, str) else zs.end_klc.end_time.astimezone(client_tz).isoformat()) if zs.end_klc else None, - 'zg': zs.zg, 'zd': zs.zd, 'gg': zs.gg, 'dd': zs.dd, - 'is_sure': zs.is_sure - } for zs in tf_analysis.get('zs_list', []) if zs.is_sure] - bi_zs_serialized = [{ - 'start_time': ((zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat()) if getattr(zs.start_klc, 'end_time', None) else (zs.start_klc.start_time if isinstance(zs.start_klc.start_time, str) else zs.start_klc.start_time.astimezone(client_tz).isoformat())), - 'end_time': (zs.end_time if isinstance(zs.end_time, str) else zs.end_time.astimezone(client_tz).isoformat()) if getattr(zs, 'end_time', None) else None, - 'zg': zs.zg, 'zd': zs.zd, 'gg': zs.gg, 'dd': zs.dd, - 'is_sure': bool(getattr(zs, 'is_sure', False)) - } for zs in tf_analysis.get('bi_zs_list', []) if getattr(zs, 'is_sure', False)] - last_ema = tf_df['ema52'].iloc[-1] if 'ema52' in tf_df.columns else 0 - ema_val = float(last_ema) if last_ema and last_ema > 0 else None - last_close = float(tf_df['close'].iloc[-1]) - tf_result = { - 'tf_name': tf_name, - 'analyses': {'zs_list': zs_serialized, 'bi_zs_list': bi_zs_serialized}, - 'ema52': ema_val, - 'close': last_close, - 'cached': False, - } - # 写入缓存 - _zone_cache[cache_key] = { - 'analyses': tf_result['analyses'], - 'ema52': ema_val, - 'close': last_close, - 'expires': now + _zone_cache_ttl(tf_name), - } - print(f" 结构区数据: {tf_name} -> zs={len(zs_serialized)}, bi_zs={len(bi_zs_serialized)}, ema52={ema_val}") - return tf_result - except Exception as e: - print(f" 结构区 {tf_name} 拉取失败: {e}") - return None - - with ThreadPoolExecutor(max_workers=len(zone_config.zone_timeframes)) as executor: - futures = {executor.submit(_fetch_single_tf_zone, tf): tf for tf in zone_config.zone_timeframes} - for future in as_completed(futures): - tf_result = future.result() - if tf_result is None: - continue - tf_name = tf_result['tf_name'] - analyses[tf_name] = tf_result['analyses'] - ema52_dict[tf_name] = tf_result['ema52'] - if tf_result['close'] and (not latest_close or latest_close == 0.0): - latest_close = tf_result['close'] - - structure_zones = analyze_structure_zones_from_serialized( - analyses, ema52_dict, latest_close, config=zone_config - ) - result['structure_zones'] = [{ - 'id': z.id, - 'lower': z.lower, - 'upper': z.upper, - 'center': z.center, - 'width_pct': z.width_pct, - 'zone_type': z.zone_type, - 'timeframes': z.timeframes, - 'structure_types': z.structure_types, - 'boundary_types': z.boundary_types, - 'overlap_count': z.overlap_count, - 'touch_count': z.touch_count, - 'recency_score': z.recency_score, - 'ema52_distance_pct': z.ema52_distance_pct, - 'ema52_aligned': z.ema52_aligned, - 'strength_score': z.strength_score, - 'confidence': z.confidence, - 'first_seen': z.first_seen, - 'last_seen': z.last_seen, - 'metadata': z.metadata, - } for z in structure_zones] - except Exception as e: - print(f"StructureZone 分析出错: {e}") - import traceback - traceback.print_exc() - result['structure_zones'] = [] - else: - result['structure_zones'] = [] - - return jsonify(result) - -@app.route('/api/symbols') -def get_symbols(): - """获取可用交易对""" - refresh_data_service_metadata() - if SYMBOLS: - return jsonify(SYMBOLS) - try: - markets = exchange.load_markets() - # 合约交易对通常是以USDT结尾的永续合约 - symbols = [symbol for symbol in markets.keys() if '/USDT' in symbol and ':USDT' in symbol] - return jsonify(symbols) - except Exception as e: - return jsonify(DEFAULT_SYMBOLS) - -@app.route('/api/a_stocks') -def get_a_stocks(): - """获取A股股票列表""" - try: - stock_list = china_stock.get_stock_list() - return jsonify(stock_list) - except Exception as e: - return jsonify({'error': str(e)}) - -@app.route('/api/popular_a_stocks') -def get_popular_a_stocks(): - """获取热门A股股票""" - try: - return jsonify(china_stock.get_popular_stocks()) - except Exception as e: - return jsonify({'error': str(e)}) - -@app.route('/api/sectors') -def get_sectors(): - """获取所有行业分类""" - try: - sectors = china_stock.get_all_sectors() - return jsonify(sectors) - except Exception as e: - return jsonify({'error': str(e)}) - -@app.route('/api/stocks_by_sector') -def get_stocks_by_sector(): - """根据行业获取股票""" - try: - sector = request.args.get('sector') - if sector: - stocks = china_stock.get_stock_by_sector(sector) - return jsonify(stocks) - else: - # 返回所有行业的股票分组 - all_sectors = china_stock.get_stock_by_sector() - return jsonify(all_sectors) - except Exception as e: - return jsonify({'error': str(e)}) - -@app.route('/api/search_stock') -def search_stock(): - """搜索股票 - 增强版""" - try: - keyword = request.args.get('keyword', '') - if not keyword: - return jsonify({'error': '搜索关键词不能为空'}) - - results = china_stock.search_stock(keyword) - return jsonify(results) - except Exception as e: - return jsonify({'error': str(e)}) - - -@app.route('/api/macd_config', methods=['GET', 'POST']) -def macd_config(): - """获取或设置MACD参数""" - global macd_fast_period, macd_slow_period, macd_signal_period - if request.method == 'GET': - return jsonify({ - 'fast': macd_fast_period, - 'slow': macd_slow_period, - 'signal': macd_signal_period - }) - else: - data = request.get_json(silent=True) or {} - fast = data.get('fast') - slow = data.get('slow') - signal = data.get('signal') - if fast is not None: - macd_fast_period = int(fast) - if slow is not None: - macd_slow_period = int(slow) - if signal is not None: - macd_signal_period = int(signal) - return jsonify({ - 'fast': macd_fast_period, - 'slow': macd_slow_period, - 'signal': macd_signal_period - }) - - -def get_uncompleted_seg_list(seg_list, client_tz): - """获取未完成线段列表,正确处理倒数第二个和最后一个未完成线段""" - uncompleted_segs = [seg for seg in seg_list if not seg.is_sure] - - if len(uncompleted_segs) == 0: - return [] - - result = [] - - for i, seg in enumerate(uncompleted_segs): - is_last = (i == len(uncompleted_segs) - 1) # 是否为最后一个未完成线段 - - seg_data = { - 'start_time': seg.start_bi.start_klc.end_time if isinstance(seg.start_bi.start_klc.end_time, str) else seg.start_bi.start_klc.end_time.astimezone(client_tz).isoformat(), - 'sure_time': format_time_safely(seg.sure_time, client_tz) if seg.sure_time else None, - 'start_price': seg.start_bi.start_klc.low if convert_direction(seg.dir) == 1 else seg.start_bi.start_klc.high, - 'direction': convert_direction(seg.dir) - } - - if is_last: - # 最后一个未完成线段:没有结束时间和价格 - seg_data['end_time'] = None - seg_data['end_price'] = None - else: - # 倒数第二个及之前的未完成线段:使用实际的结束时间和价格 - if seg.end_bi and seg.end_bi.end_klc: - seg_data['end_time'] = seg.end_bi.end_klc.end_time if isinstance(seg.end_bi.end_klc.end_time, str) else seg.end_bi.end_klc.end_time.astimezone(client_tz).isoformat() - seg_data['end_price'] = seg.end_bi.end_klc.high if convert_direction(seg.dir) == 1 else seg.end_bi.end_klc.low - else: - # 如果没有结束笔,设为None - seg_data['end_time'] = None - seg_data['end_price'] = None - - result.append(seg_data) - - return result - -if __name__ == '__main__': - app.run(debug=True, host='0.0.0.0', port=8128) \ No newline at end of file +if __name__ == "__main__": + app.run(debug=True, host=FLASK_HOST, port=FLASK_PORT) diff --git a/web/cn_stock_data.py b/web/cn_stock_data.py index 449306e..8ac4f4d 100644 --- a/web/cn_stock_data.py +++ b/web/cn_stock_data.py @@ -1,966 +1,3 @@ -import os -import akshare as ak -import pandas as pd -from datetime import datetime, timedelta, time -import time as time_module -import traceback -from pytz import timezone -import warnings -warnings.filterwarnings('ignore') - -import logging - -logger = logging.getLogger(__name__) - -# 与 A-Share Data Platform REST 文档一致的周期(分钟线依赖服务端积累,无数据时会回退 AKShare) -ASHARE_REST_TIMEFRAMES = frozenset({'1m', '5m', '15m', '30m', '1h', '2h', '1d', '1w', '1M'}) - - -class ChinaStockData: - """A股数据获取类""" - - def __init__(self): - self.tz = timezone('Asia/Shanghai') - # A股交易时间配置 - self.trading_hours = { - 'morning': {'start': '09:30', 'end': '11:30'}, - 'afternoon': {'start': '13:00', 'end': '15:00'} - } - # 例: http://103.179.242.166:8000 — 设 ASHARE_DP_URL= 空字符串可禁用,仅用 AKShare - _base = os.environ.get('ASHARE_DP_URL', 'http://103.179.242.166:8000') - self.ashare_dp_base = _base.rstrip('/') if (_base or '').strip() else '' - # 全量股票列表内存缓存(秒),默认 1 小时 - try: - self.stock_list_cache_ttl = int(os.environ.get('ASHARE_STOCK_LIST_CACHE_SEC', '3600')) - except ValueError: - self.stock_list_cache_ttl = 3600 - self._stock_list_cache = None - self._stock_list_cache_expires = 0.0 - - def _get_stock_list_akshare(self): - """通过 AKShare 获取 A 股列表(约 2000 条非 ST,作备用)。""" - try: - import requests - - try: - original_timeout = getattr(requests, 'timeout', None) - requests.timeout = 10 - - stock_info = ak.stock_zh_a_spot_em() - - if original_timeout: - requests.timeout = original_timeout - else: - delattr(requests, 'timeout') - - except Exception: - return [] - - if stock_info is None or len(stock_info) == 0: - return [] - - stock_list = [] - for index, row in stock_info.head(2000).iterrows(): - try: - stock_name = str(row['名称']) - if 'ST' not in stock_name and '*' not in stock_name: - stock_list.append({ - 'symbol': row['代码'], - 'name': row['名称'], - 'price': float(row['最新价']) if pd.notna(row['最新价']) else 0.0, - 'change_pct': float(row['涨跌幅']) if pd.notna(row['涨跌幅']) else 0.0, - 'volume': float(row['成交量']) if pd.notna(row['成交量']) else 0.0, - 'amount': float(row['成交额']) if pd.notna(row['成交额']) else 0.0 - }) - except Exception: - continue - - stock_list.sort(key=lambda x: x['amount'], reverse=True) - return stock_list - - except Exception: - return [] - - def _fetch_all_stocks_ashare_dp(self): - """分页拉取 A-Share Data Platform /api/v1/stocks 全市场标的。""" - import requests - - page_size = 1000 - offset = 0 - all_rows = [] - reported_total = None - url = f'{self.ashare_dp_base}/api/v1/stocks' - while True: - resp = requests.get( - url, - params={'limit': page_size, 'offset': offset}, - timeout=45, - ) - resp.raise_for_status() - payload = resp.json() - items = payload.get('items') or [] - if reported_total is None: - reported_total = int(payload.get('total') or 0) - all_rows.extend(items) - if len(items) == 0: - break - if len(items) < page_size: - break - offset += page_size - if reported_total and offset >= reported_total: - break - if not all_rows: - return [] - out = [] - for row in all_rows: - sym = row.get('symbol') - if not sym and row.get('ts_code'): - sym = str(row['ts_code']).split('.')[0] - if not sym: - continue - name = row.get('name') or '' - out.append({ - 'symbol': str(sym).strip(), - 'name': str(name).strip(), - 'ts_code': row.get('ts_code'), - 'price': 0.0, - 'change_pct': 0.0, - 'volume': 0.0, - 'amount': 0.0, - }) - out.sort(key=lambda x: x['symbol']) - return out - - def get_stock_list(self, use_cache=True): - """获取 A 股股票列表:优先全量 REST(约 5500+),失败则 AKShare。""" - now = time_module.time() - if use_cache and self._stock_list_cache is not None and now < self._stock_list_cache_expires: - return list(self._stock_list_cache) - - if self.ashare_dp_base: - try: - dp_list = self._fetch_all_stocks_ashare_dp() - if dp_list: - self._stock_list_cache = dp_list - self._stock_list_cache_expires = now + self.stock_list_cache_ttl - return list(dp_list) - except Exception as exc: - logger.warning('A股列表从数据服务拉取失败,回退 AKShare: %s', exc) - - ak_list = self._get_stock_list_akshare() - if ak_list: - self._stock_list_cache = ak_list - self._stock_list_cache_expires = now + min(self.stock_list_cache_ttl, 300) - return ak_list or [] - - def get_available_kline_freqs(self): - """ - A-Share Data Platform 支持的 K 线周期列表(原始顺序不保证,由上层按粒度排序)。 - 文档: GET /api/v1/klines/available-freqs - """ - import requests - - fallback = ['1m', '5m', '15m', '30m', '1h', '2h', '1d', '1w', '1M'] - if not self.ashare_dp_base: - return list(fallback) - try: - url = f'{self.ashare_dp_base}/api/v1/klines/available-freqs' - resp = requests.get(url, timeout=10) - resp.raise_for_status() - data = resp.json() - freqs = data.get('frequencies') or [] - return list(freqs) if freqs else list(fallback) - except Exception as exc: - logger.warning('获取 A 股可用 K 线周期失败: %s', exc) - return list(fallback) - - def get_popular_stocks(self): - """获取热门A股股票代码列表 - 扩展版本,按行业分类""" - return [ - # 包装引印刷 - {'symbol': '002836', 'name': '新宏泽', 'sector': '包装印刷'}, - # 银行股 - {'symbol': '600036', 'name': '招商银行', 'sector': '银行'}, - {'symbol': '000001', 'name': '平安银行', 'sector': '银行'}, - {'symbol': '600000', 'name': '浦发银行', 'sector': '银行'}, - {'symbol': '002142', 'name': '宁波银行', 'sector': '银行'}, - {'symbol': '600016', 'name': '民生银行', 'sector': '银行'}, - {'symbol': '601288', 'name': '农业银行', 'sector': '银行'}, - {'symbol': '601398', 'name': '工商银行', 'sector': '银行'}, - {'symbol': '601328', 'name': '交通银行', 'sector': '银行'}, - - # 白酒股 - {'symbol': '600519', 'name': '贵州茅台', 'sector': '白酒'}, - {'symbol': '000858', 'name': '五粮液', 'sector': '白酒'}, - {'symbol': '002304', 'name': '洋河股份', 'sector': '白酒'}, - {'symbol': '000596', 'name': '古井贡酒', 'sector': '白酒'}, - {'symbol': '603369', 'name': '今世缘', 'sector': '白酒'}, - {'symbol': '000799', 'name': '酒鬼酒', 'sector': '白酒'}, - {'symbol': '600809', 'name': '山西汾酒', 'sector': '白酒'}, - - # 科技股 - {'symbol': '002415', 'name': '海康威视', 'sector': '科技'}, - {'symbol': '000063', 'name': '中兴通讯', 'sector': '科技'}, - {'symbol': '002475', 'name': '立讯精密', 'sector': '科技'}, - {'symbol': '300059', 'name': '东方财富', 'sector': '科技'}, - {'symbol': '000725', 'name': '京东方A', 'sector': '科技'}, - {'symbol': '002230', 'name': '科大讯飞', 'sector': '科技'}, - {'symbol': '300433', 'name': '蓝思科技', 'sector': '科技'}, - {'symbol': '002236', 'name': '大华股份', 'sector': '科技'}, - - # 新能源 - {'symbol': '300750', 'name': '宁德时代', 'sector': '新能源'}, - {'symbol': '002594', 'name': '比亚迪', 'sector': '新能源'}, - {'symbol': '300274', 'name': '阳光电源', 'sector': '新能源'}, - {'symbol': '002460', 'name': '赣锋锂业', 'sector': '新能源'}, - {'symbol': '300014', 'name': '亿纬锂能', 'sector': '新能源'}, - {'symbol': '600884', 'name': '杉杉股份', 'sector': '新能源'}, - {'symbol': '002812', 'name': '恩捷股份', 'sector': '新能源'}, - - # 房地产 - {'symbol': '000002', 'name': '万科A', 'sector': '房地产'}, - {'symbol': '000858', 'name': '五粮液', 'sector': '房地产'}, - {'symbol': '600048', 'name': '保利发展', 'sector': '房地产'}, - {'symbol': '001979', 'name': '招商蛇口', 'sector': '房地产'}, - {'symbol': '600606', 'name': '绿地控股', 'sector': '房地产'}, - - # 消费股 - {'symbol': '600887', 'name': '伊利股份', 'sector': '消费'}, - {'symbol': '000568', 'name': '泸州老窖', 'sector': '消费'}, - {'symbol': '600600', 'name': '青岛啤酒', 'sector': '消费'}, - {'symbol': '000895', 'name': '双汇发展', 'sector': '消费'}, - {'symbol': '002304', 'name': '洋河股份', 'sector': '消费'}, - {'symbol': '600779', 'name': '水井坊', 'sector': '消费'}, - - # 医药股 - {'symbol': '600196', 'name': '复星医药', 'sector': '医药'}, - {'symbol': '000661', 'name': '长春高新', 'sector': '医药'}, - {'symbol': '300015', 'name': '爱尔眼科', 'sector': '医药'}, - {'symbol': '002821', 'name': '凯莱英', 'sector': '医药'}, - {'symbol': '300760', 'name': '迈瑞医疗', 'sector': '医药'}, - {'symbol': '600276', 'name': '恒瑞医药', 'sector': '医药'}, - - # 证券股 - {'symbol': '000776', 'name': '广发证券', 'sector': '证券'}, - {'symbol': '600030', 'name': '中信证券', 'sector': '证券'}, - {'symbol': '000166', 'name': '申万宏源', 'sector': '证券'}, - {'symbol': '601688', 'name': '华泰证券', 'sector': '证券'}, - {'symbol': '600837', 'name': '海通证券', 'sector': '证券'}, - - # 化工股 - {'symbol': '600309', 'name': '万华化学', 'sector': '化工'}, - {'symbol': '002352', 'name': '顺丰控股', 'sector': '化工'}, - {'symbol': '600346', 'name': '恒力石化', 'sector': '化工'}, - {'symbol': '000792', 'name': '盐湖股份', 'sector': '化工'}, - - # 汽车股 - {'symbol': '600104', 'name': '上汽集团', 'sector': '汽车'}, - {'symbol': '000625', 'name': '长安汽车', 'sector': '汽车'}, - {'symbol': '601633', 'name': '长城汽车', 'sector': '汽车'}, - {'symbol': '002049', 'name': '紫光国微', 'sector': '汽车'}, - - # 军工股 - {'symbol': '002179', 'name': '中航光电', 'sector': '军工'}, - {'symbol': '600893', 'name': '航发动力', 'sector': '军工'}, - {'symbol': '000768', 'name': '中航飞机', 'sector': '军工'}, - - # 基建股 - {'symbol': '601186', 'name': '中国铁建', 'sector': '基建'}, - {'symbol': '601390', 'name': '中国中铁', 'sector': '基建'}, - {'symbol': '000001', 'name': '平安银行', 'sector': '基建'}, - - # 煤炭股 - {'symbol': '601225', 'name': '陕西煤业', 'sector': '煤炭'}, - {'symbol': '600188', 'name': '兖矿能源', 'sector': '煤炭'}, - {'symbol': '601898', 'name': '中煤能源', 'sector': '煤炭'}, - - # 钢铁股 - {'symbol': '000717', 'name': '韶钢松山', 'sector': '钢铁'}, - {'symbol': '600019', 'name': '宝钢股份', 'sector': '钢铁'}, - {'symbol': '000708', 'name': '中信特钢', 'sector': '钢铁'}, - ] - - def timeframe_to_period(self, timeframe): - """将时间周期转换为akshare的period参数""" - mapping = { - '1m': '1', # 1分钟 - '5m': '5', # 5分钟 - '15m': '15', # 15分钟 - '30m': '30', # 30分钟 - '1h': '60', # 60分钟 - '1d': 'daily', # 日线 - '1w': 'weekly',# 周线 - '1M': 'monthly'# 月线 - } - return mapping.get(timeframe, 'daily') - - @staticmethod - def symbol_to_ts_code(symbol): - """六位代码或已是 ts_code(000001.SZ)→ 交易所后缀。""" - if symbol is None: - return '' - s = str(symbol).strip().upper() - if '.' in s and s.count('.') == 1: - return s - if len(s) != 6 or not s.isdigit(): - return s - if s.startswith('6'): - return f'{s}.SH' - if s.startswith(('0', '3')): - return f'{s}.SZ' - if s.startswith('920'): - return f'{s}.BJ' - if s.startswith(('8', '4')): - return f'{s}.BJ' - return f'{s}.SZ' - - @staticmethod - def _ymd_compact_to_api_date(ymd_compact): - """YYYYMMDD → YYYY-MM-DD""" - if not ymd_compact or len(ymd_compact) != 8: - return None - return f'{ymd_compact[:4]}-{ymd_compact[4:6]}-{ymd_compact[6:8]}' - - def get_kl_data_from_ashare_dp(self, symbol, timeframe, start_date, end_date, limit): - """ - 从 A-Share Data Platform(/api/v1/klines/{freq})拉取 K 线。 - start_date / end_date 为 YYYYMMDD 字符串。 - """ - if not self.ashare_dp_base or timeframe not in ASHARE_REST_TIMEFRAMES: - return None - import requests - - ts_code = self.symbol_to_ts_code(symbol) - if not ts_code or '.' not in ts_code: - return None - start_api = self._ymd_compact_to_api_date(start_date) - end_api = self._ymd_compact_to_api_date(end_date) - if not start_api or not end_api: - return None - api_limit = 10000 - if limit is not None: - try: - api_limit = min(int(limit), 10000) - except (TypeError, ValueError): - api_limit = 10000 - url = f'{self.ashare_dp_base}/api/v1/klines/{timeframe}' - params = { - 'ts_code': ts_code, - 'start_date': start_api, - 'end_date': end_api, - 'limit': api_limit, - } - try: - resp = requests.get(url, params=params, timeout=20) - resp.raise_for_status() - payload = resp.json() - except Exception as exc: - logger.debug('A股数据服务 K 线请求失败: %s', exc) - return None - items = payload.get('items') or payload.get('data') or [] - if not items: - return None - rows = [] - for row in items: - t = row.get('trade_time') or row.get('trade_date') - if not t: - continue - rows.append({ - 'date': t, - 'open': row.get('open'), - 'high': row.get('high'), - 'low': row.get('low'), - 'close': row.get('close'), - 'volume': row.get('volume'), - }) - if not rows: - return None - df = pd.DataFrame(rows) - df['date'] = pd.to_datetime(df['date']) - for col in ('open', 'high', 'low', 'close', 'volume'): - if col in df.columns: - df[col] = pd.to_numeric(df[col], errors='coerce') - df = df.dropna(subset=['open', 'high', 'low', 'close']) - df = df.sort_values('date').reset_index(drop=True) - df = self.adjust_timestamp_for_trading_hours(df, timeframe) - df = self.clean_a_stock_data(df, timeframe) - if df is None or len(df) == 0: - return None - if limit is not None: - try: - lim = int(limit) - if len(df) > lim: - df = df.tail(lim).reset_index(drop=True) - except (TypeError, ValueError): - pass - elif len(df) > 10000: - df = df.tail(10000).reset_index(drop=True) - df = self.add_indicators(df) - return df - - def get_kl_data(self, symbol, timeframe='1d', start_date=None, end_date=None, limit=10000): - """ - 获取A股K线数据 - 支持分批次获取突破单次限制 - :param symbol: 股票代码,如 '000001' - :param timeframe: 时间周期,如 '1d', '1h', '5m' - :param start_date: 开始日期,格式 'YYYY-MM-DD' - :param end_date: 结束日期,格式 'YYYY-MM-DD' - :param limit: 数据条数限制 - :return: DataFrame - """ - try: - period = self.timeframe_to_period(timeframe) - - # 处理时间参数 - if start_date is None: - # 默认获取最近一年的数据 - start_date = (datetime.now() - timedelta(days=365)).strftime('%Y%m%d') - else: - # 将 YYYY-MM-DD 格式转换为 YYYYMMDD - if '-' in start_date: - start_date = start_date.replace('-', '') - - if end_date is None: - end_date = datetime.now().strftime('%Y%m%d') - else: - if '-' in end_date: - end_date = end_date.replace('-', '') - - if self.ashare_dp_base: - df_dp = self.get_kl_data_from_ashare_dp( - symbol, timeframe, start_date, end_date, limit - ) - if df_dp is not None and len(df_dp) > 0: - return df_dp - - # 分批次获取数据以突破单次限制 - all_data = [] - current_start = start_date - - # 计算时间间隔(根据时间周期调整批次大小) - if period in ['1', '5', '15', '30']: - # 分钟级数据,每次获取7天 - batch_days = 7 - elif period == '60': - # 小时级数据,每次获取30天 - batch_days = 30 - else: - # 日线及以上,每次获取365天 - batch_days = 365 - - max_iterations = 20 # 最大迭代次数,防止无限循环 - iteration_count = 0 - - while current_start <= end_date and iteration_count < max_iterations: - iteration_count += 1 - - # 计算当前批次的结束时间 - current_start_dt = datetime.strptime(current_start, '%Y%m%d') - current_end_dt = current_start_dt + timedelta(days=batch_days) - current_end = min(current_end_dt.strftime('%Y%m%d'), end_date) - - pass - - try: - # 根据时间周期选择不同的API - df_batch = None - if period in ['1', '5', '15', '30', '60']: - # 分钟级数据 - df_batch = ak.stock_zh_a_hist_min_em(symbol=symbol, period=period, - start_date=current_start, end_date=current_end) - if df_batch is not None and len(df_batch) > 0: - # 重命名列 - df_batch = df_batch.rename(columns={ - '时间': 'date', - '开盘': 'open', - '收盘': 'close', - '最高': 'high', - '最低': 'low', - '成交量': 'volume' - }) - else: - # 日线、周线、月线数据 - df_batch = ak.stock_zh_a_hist(symbol=symbol, period=period, - start_date=current_start, end_date=current_end) - if df_batch is not None and len(df_batch) > 0: - # 重命名列 - df_batch = df_batch.rename(columns={ - '日期': 'date', - '开盘': 'open', - '收盘': 'close', - '最高': 'high', - '最低': 'low', - '成交量': 'volume' - }) - - if df_batch is not None and len(df_batch) > 0: - # 转换时间格式 - df_batch['date'] = pd.to_datetime(df_batch['date']) - - # 根据A股交易时间调整时间戳 - df_batch = self.adjust_timestamp_for_trading_hours(df_batch, timeframe) - - all_data.append(df_batch) - pass - - except Exception as e: - # 继续下一个批次 - pass - - # 更新下一批次的开始时间 - current_start = (current_end_dt + timedelta(days=1)).strftime('%Y%m%d') - - # 防止API请求过于频繁 - time_module.sleep(0.5) - - # 合并所有批次的数据 - if not all_data: - return None - - # 合并DataFrame - df = pd.concat(all_data, ignore_index=True) - - # 数据清洗和格式化 - df = df.dropna() # 删除空值 - df = df.drop_duplicates(subset=['date']) # 删除重复数据 - df = df.sort_values('date').reset_index(drop=True) # 按时间排序 - - # A股特有的数据清理和时间处理 - df = self.clean_a_stock_data(df, timeframe) - - # 限制数据条数 - 只有在没有指定明确时间范围时才应用 - # 如果用户指定了start_date和end_date,应该返回该时间范围内的所有数据 - if limit is not None and len(df) > limit: - # 检查是否指定了明确的时间范围 - if start_date and end_date: - # 如果指定了时间范围,优先返回完整的时间范围数据 - if len(df) > 10000: # 防止数据量过大,设置一个合理的上限 - df = df.tail(10000).reset_index(drop=True) - else: - # 如果没有指定时间范围,使用默认的limit限制 - df = df.tail(limit).reset_index(drop=True) - elif limit is None and len(df) > 10000: - # 即使没有limit限制,也要防止数据量过大影响性能 - df = df.tail(10000).reset_index(drop=True) - - # 添加技术指标 - df = self.add_indicators(df) - - # 最终数据验证 - 确保没有NaN值 - import numpy as np - - # 检查并处理任何剩余的NaN值 - if df.isnull().any().any(): - # 对于数值列,用0填充NaN - numeric_cols = df.select_dtypes(include=[np.number]).columns - for col in numeric_cols: - if col in ['volume_ratio']: - df[col] = df[col].fillna(1.0) - else: - df[col] = df[col].fillna(0) - - # 删除仍然包含NaN的行 - df = df.dropna() - - # 确保所有数值都是有限的 - for col in df.select_dtypes(include=[np.number]).columns: - df[col] = df[col].replace([np.inf, -np.inf], 0 if col != 'volume_ratio' else 1.0) - - return df - - except Exception as e: - return None - - def add_indicators(self, df): - """添加技术指标""" - try: - import talib.abstract as ta - import numpy as np - - # MACD指标 - fast = 8 - slow = 16 - period = 6 - macd = ta.MACD(df, fastperiod=fast, slowperiod=slow, signalperiod=period) - - df['macd'] = macd['macd'].fillna(0) - df['macdsignal'] = macd['macdsignal'].fillna(0) - df['macdhist'] = macd['macdhist'].fillna(0) - - # 移动平均线 - df['ma5'] = ta.MA(df, timeperiod=5).fillna(0) - df['ma10'] = ta.MA(df, timeperiod=10).fillna(0) - df['ma30'] = ta.EMA(df, timeperiod=30).fillna(0) - df['ma250'] = ta.MA(df, timeperiod=250).fillna(0) - - # RSI指标 - df['rsi'] = ta.RSI(df, timeperiod=14).fillna(0) - - # 成交量指标 - df['avg_volume'] = df['volume'].rolling(10).mean().fillna(0) - df['volume_ratio'] = (df['volume'] / df['avg_volume']).fillna(1.0) - - # 处理Infinity和-Infinity值 - df['volume_ratio'] = df['volume_ratio'].replace([float('inf'), float('-inf')], 1.0) - - # 确保所有指标列都不包含NaN或无限值 - indicator_columns = ['macd', 'macdsignal', 'macdhist', 'ma5', 'ma10', 'ma30', 'ma250', 'rsi', 'avg_volume', 'volume_ratio'] - for col in indicator_columns: - if col in df.columns: - # 替换NaN、inf、-inf为合理的默认值 - df[col] = df[col].replace([np.nan, np.inf, -np.inf], 0 if col != 'volume_ratio' else 1.0) - - return df - - except Exception as e: - return df - - def search_stock(self, keyword): - """搜索股票 - 支持代码和名称模糊搜索""" - try: - if not keyword or len(keyword.strip()) == 0: - return [] - - keyword = keyword.strip().upper() - results = [] - - # 从热门股票中搜索 - popular_stocks = self.get_popular_stocks() - for stock in popular_stocks: - if (keyword in stock['symbol'] or - keyword.lower() in stock['name'].lower() or - stock['symbol'].startswith(keyword)): - results.append({ - 'symbol': stock['symbol'], - 'name': stock['name'], - 'sector': stock.get('sector', ''), - 'source': '热门股票' - }) - - # 如果热门股票中找到的结果少于10个,从完整股票列表中搜索 - if len(results) < 10: - try: - # 获取完整股票列表进行搜索 - stock_info = ak.stock_zh_a_spot_em() - - # 搜索前1000只活跃股票 - for index, row in stock_info.head(1000).iterrows(): - stock_code = str(row['代码']) - stock_name = str(row['名称']) - - # 过滤ST股票 - if 'ST' in stock_name or '*' in stock_name: - continue - - # 检查是否已经在结果中 - if any(r['symbol'] == stock_code for r in results): - continue - - # 搜索匹配 - if (keyword in stock_code or - keyword.lower() in stock_name.lower() or - stock_code.startswith(keyword)): - results.append({ - 'symbol': stock_code, - 'name': stock_name, - 'price': float(row['最新价']) if pd.notna(row['最新价']) else 0.0, - 'change_pct': float(row['涨跌幅']) if pd.notna(row['涨跌幅']) else 0.0, - 'source': '全市场搜索' - }) - - # 限制结果数量 - if len(results) >= 30: - break - - except Exception as e: - pass - - # 排序:优先显示代码匹配的结果 - def sort_key(item): - if item['symbol'].startswith(keyword): - return (0, item['symbol']) # 代码开头匹配优先级最高 - elif keyword in item['symbol']: - return (1, item['symbol']) # 代码包含匹配次之 - else: - return (2, item['symbol']) # 名称匹配最后 - - results.sort(key=sort_key) - - # 限制返回结果数量 - return results[:20] - - except Exception as e: - return [] - - def get_stock_by_sector(self, sector=None): - """根据行业获取股票列表""" - try: - popular_stocks = self.get_popular_stocks() - if sector: - return [stock for stock in popular_stocks if stock.get('sector', '') == sector] - else: - # 按行业分组 - sectors = {} - for stock in popular_stocks: - sector_name = stock.get('sector', '其他') - if sector_name not in sectors: - sectors[sector_name] = [] - sectors[sector_name].append(stock) - return sectors - except Exception as e: - return {} if sector is None else [] - - def get_all_sectors(self): - """获取所有行业分类""" - try: - popular_stocks = self.get_popular_stocks() - sectors = set() - for stock in popular_stocks: - sector = stock.get('sector', '其他') - sectors.add(sector) - return sorted(list(sectors)) - except Exception as e: - return [] - - def is_trading_day(self, date): - """判断是否为交易日(排除周末和节假日)""" - try: - # 将日期转换为datetime对象 - if isinstance(date, str): - date = datetime.strptime(date.split()[0], '%Y-%m-%d') - elif isinstance(date, pd.Timestamp): - date = date.to_pydatetime() - - # 周末不是交易日 - if date.weekday() >= 5: # 5=周六, 6=周日 - return False - - # 这里可以进一步添加节假日判断 - # 目前暂时只过滤周末 - return True - except Exception as e: - return True # 默认返回True,避免过度过滤 - - def is_trading_time(self, dt): - """判断是否为交易时间""" - try: - if isinstance(dt, str): - dt = pd.to_datetime(dt) - - time_str = dt.strftime('%H:%M') - - # 上午交易时间:09:30-11:30 - morning_start = self.trading_hours['morning']['start'] - morning_end = self.trading_hours['morning']['end'] - - # 下午交易时间:13:00-15:00 - afternoon_start = self.trading_hours['afternoon']['start'] - afternoon_end = self.trading_hours['afternoon']['end'] - - return ((morning_start <= time_str <= morning_end) or - (afternoon_start <= time_str <= afternoon_end)) - except Exception as e: - return True # 默认返回True,避免过度过滤 - - def adjust_timestamp_for_trading_hours(self, df, timeframe): - """根据A股交易时间调整时间戳""" - try: - if df is None or len(df) == 0: - return df - - # 确保date列是datetime类型 - if 'date' in df.columns: - df['date'] = pd.to_datetime(df['date']) - - # 对于日线数据,设置为收盘时间(15:00) - if timeframe == '1d': - df['date'] = df['date'].dt.normalize() + pd.Timedelta(hours=15) - - # 对于分钟级数据,过滤非交易时间的数据 - elif timeframe in ['1m', '5m', '15m', '30m', '1h']: - # 过滤交易日 - df = df[df['date'].apply(self.is_trading_day)] - - # 过滤交易时间(只在有足够数据时进行) - if len(df) > 10: # 避免过度过滤导致数据不足 - df = df[df['date'].apply(self.is_trading_time)] - - # 重新计算时间戳 - if 'date' in df.columns: - # 将时间转换为上海时区 - df['date'] = df['date'].dt.tz_localize('Asia/Shanghai', ambiguous='infer', nonexistent='shift_forward') - # 转换为毫秒时间戳 - df['timestamp'] = df['date'].astype('int64') // 10**6 - - return df.reset_index(drop=True) - - except Exception as e: - return df - - def get_trading_calendar(self, start_date, end_date): - """获取交易日历(简化版本)""" - try: - # 使用akshare获取交易日历 - trading_calendar = ak.tool_trade_date_hist_sina() - - # 过滤指定日期范围 - start_dt = pd.to_datetime(start_date) - end_dt = pd.to_datetime(end_date) - - trading_days = [] - for _, row in trading_calendar.iterrows(): - trade_date = pd.to_datetime(row['trade_date']) - if start_dt <= trade_date <= end_dt: - trading_days.append(trade_date.strftime('%Y-%m-%d')) - - return trading_days - except Exception as e: - # 如果获取失败,生成简单的工作日列表(排除周末) - trading_days = [] - current = pd.to_datetime(start_date) - end = pd.to_datetime(end_date) - - while current <= end: - if current.weekday() < 5: # 周一到周五 - trading_days.append(current.strftime('%Y-%m-%d')) - current += timedelta(days=1) - - return trading_days - - def fill_trading_gaps(self, df, timeframe): - """填补A股交易时间间隙,确保图表连续性""" - try: - if df is None or len(df) == 0: - return df - - # 对于日线数据,不需要填补间隙,因为本来就是每日一个数据点 - if timeframe == '1d': - return df - - # 对于分钟级数据,创建完整的交易时间序列 - if timeframe in ['1m', '5m', '15m', '30m', '1h']: - # 获取数据的开始和结束时间 - start_date = df['date'].min().date() - end_date = df['date'].max().date() - - # 创建完整的交易时间序列 - complete_times = [] - current_date = start_date - - # 获取时间间隔(分钟) - freq_map = {'1m': 1, '5m': 5, '15m': 15, '30m': 30, '1h': 60} - freq_minutes = freq_map.get(timeframe, 5) - - while current_date <= end_date: - # 只处理交易日 - if self.is_trading_day(current_date): - # 上午交易时间 - 使用datetime.time而不是pd.Time - morning_start = pd.Timestamp.combine(current_date, time(9, 30)) - morning_end = pd.Timestamp.combine(current_date, time(11, 30)) - - # 下午交易时间 - afternoon_start = pd.Timestamp.combine(current_date, time(13, 0)) - afternoon_end = pd.Timestamp.combine(current_date, time(15, 0)) - - # 生成上午时间序列 - current_time = morning_start - while current_time <= morning_end: - complete_times.append(current_time) - current_time += pd.Timedelta(minutes=freq_minutes) - - # 生成下午时间序列 - current_time = afternoon_start - while current_time <= afternoon_end: - complete_times.append(current_time) - current_time += pd.Timedelta(minutes=freq_minutes) - - current_date += timedelta(days=1) - - # 创建完整时间序列的DataFrame - if complete_times: - complete_df = pd.DataFrame({'date': complete_times}) - complete_df['date'] = complete_df['date'].dt.tz_localize('Asia/Shanghai') - complete_df['timestamp'] = complete_df['date'].astype('int64') // 10**6 - - # 将原始数据合并到完整时间序列 - # 使用时间戳进行合并,避免时区问题 - df_merged = pd.merge(complete_df, df, on='timestamp', how='left', suffixes=('', '_orig')) - - # 保持原有date列 - df_merged['date'] = df_merged['date'] - - # 对于缺失的OHLCV数据,使用前向填充 - price_cols = ['open', 'high', 'low', 'close'] - for col in price_cols: - if col in df_merged.columns: - df_merged[col] = df_merged[col].ffill() - - # 成交量缺失时设为0 - if 'volume' in df_merged.columns: - df_merged['volume'] = df_merged['volume'].fillna(0) - - # 删除辅助列 - cols_to_drop = [col for col in df_merged.columns if col.endswith('_orig')] - df_merged = df_merged.drop(columns=cols_to_drop) - - return df_merged - - return df - - except Exception as e: - return df - - def clean_a_stock_data(self, df, timeframe): - """清理A股数据,处理异常值和时间问题""" - try: - if df is None or len(df) == 0: - return df - - import numpy as np - - # 首先删除所有包含NaN的行 - df = df.dropna() - - # 删除价格异常的数据 - price_cols = ['open', 'high', 'low', 'close'] - for col in price_cols: - if col in df.columns: - # 删除价格为0、负数、NaN、inf的记录 - df = df[df[col] > 0] - df = df[np.isfinite(df[col])] - - # 检查OHLC逻辑合理性 - if all(col in df.columns for col in price_cols): - # high应该是最高价 - df = df[df['high'] >= df['open']] - df = df[df['high'] >= df['close']] - # low应该是最低价 - df = df[df['low'] <= df['open']] - df = df[df['low'] <= df['close']] - # high应该大于等于low - df = df[df['high'] >= df['low']] - - # 删除成交量异常的数据 - if 'volume' in df.columns: - # 删除成交量为负数、NaN、inf的记录 - df = df[df['volume'] >= 0] - df = df[np.isfinite(df['volume'])] - - # 确保所有数值列都不包含NaN或无限值 - numeric_cols = df.select_dtypes(include=[np.number]).columns - for col in numeric_cols: - # 替换NaN、inf、-inf为0(除了价格列,价格列的异常值已经被过滤掉了) - if col not in price_cols: - df[col] = df[col].replace([np.nan, np.inf, -np.inf], 0) - - # 确保时间序列连续性(仅对分钟级数据) - if timeframe in ['1m', '5m', '15m', '30m', '1h']: - df = self.fill_trading_gaps(df, timeframe) - - # 最后再次检查并清理任何剩余的NaN值 - df = df.dropna() - - return df.reset_index(drop=True) - - except Exception as e: - return df \ No newline at end of file +"""兼容 shim。""" +from services.cn_stock import * # noqa: F403 +from services.cn_stock import ChinaStockData # noqa: F401 diff --git a/web/config.py b/web/config.py new file mode 100644 index 0000000..83b968d --- /dev/null +++ b/web/config.py @@ -0,0 +1,31 @@ +"""Web 运行时配置(环境变量优先,去掉硬编码代理)。""" +from __future__ import annotations + +import os + +DATA_SERVICE_URL = os.environ.get( + "DATA_SERVICE_URL", + os.environ.get("DATASVC_URL", "https://provider.jackyu66.com"), +) +ASHARE_DP_URL = os.environ.get("ASHARE_DP_URL", "http://103.179.242.166:8000") + +# HTTP 代理:未设置则不走代理;可设 HTTP_PROXY/HTTPS_PROXY 或 CHAN_HTTP_PROXY +_CHAN_PROXY = os.environ.get("CHAN_HTTP_PROXY") or os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy") +_HTTPS_PROXY = os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy") or _CHAN_PROXY + +def ccxt_proxies() -> dict | None: + if not _CHAN_PROXY and not _HTTPS_PROXY: + return None + return { + "http": _CHAN_PROXY or _HTTPS_PROXY, + "https": _HTTPS_PROXY or _CHAN_PROXY, + } + +MACD_FACTOR = int(os.environ.get("MACD_FACTOR", "1")) +MACD_SMOOTH = int(os.environ.get("MACD_SMOOTH", "1")) +MACD_FAST = 12 * MACD_FACTOR +MACD_SLOW = 26 * MACD_FACTOR +MACD_SIGNAL = 9 * MACD_SMOOTH + +FLASK_HOST = os.environ.get("FLASK_HOST", "0.0.0.0") +FLASK_PORT = int(os.environ.get("FLASK_PORT", "8128")) diff --git a/web/services/__init__.py b/web/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/services/chan_analyze.py b/web/services/chan_analyze.py new file mode 100644 index 0000000..a1d07c1 --- /dev/null +++ b/web/services/chan_analyze.py @@ -0,0 +1,10 @@ +"""缠论分析服务。""" +from services.runtime import ( # noqa: F401 + add_indicators, + calculate_macd, + analyze_chan, + classify_trend_stage, + macd_fast_period, + macd_slow_period, + macd_signal_period, +) diff --git a/web/services/cn_stock.py b/web/services/cn_stock.py new file mode 100644 index 0000000..449306e --- /dev/null +++ b/web/services/cn_stock.py @@ -0,0 +1,966 @@ +import os +import akshare as ak +import pandas as pd +from datetime import datetime, timedelta, time +import time as time_module +import traceback +from pytz import timezone +import warnings +warnings.filterwarnings('ignore') + +import logging + +logger = logging.getLogger(__name__) + +# 与 A-Share Data Platform REST 文档一致的周期(分钟线依赖服务端积累,无数据时会回退 AKShare) +ASHARE_REST_TIMEFRAMES = frozenset({'1m', '5m', '15m', '30m', '1h', '2h', '1d', '1w', '1M'}) + + +class ChinaStockData: + """A股数据获取类""" + + def __init__(self): + self.tz = timezone('Asia/Shanghai') + # A股交易时间配置 + self.trading_hours = { + 'morning': {'start': '09:30', 'end': '11:30'}, + 'afternoon': {'start': '13:00', 'end': '15:00'} + } + # 例: http://103.179.242.166:8000 — 设 ASHARE_DP_URL= 空字符串可禁用,仅用 AKShare + _base = os.environ.get('ASHARE_DP_URL', 'http://103.179.242.166:8000') + self.ashare_dp_base = _base.rstrip('/') if (_base or '').strip() else '' + # 全量股票列表内存缓存(秒),默认 1 小时 + try: + self.stock_list_cache_ttl = int(os.environ.get('ASHARE_STOCK_LIST_CACHE_SEC', '3600')) + except ValueError: + self.stock_list_cache_ttl = 3600 + self._stock_list_cache = None + self._stock_list_cache_expires = 0.0 + + def _get_stock_list_akshare(self): + """通过 AKShare 获取 A 股列表(约 2000 条非 ST,作备用)。""" + try: + import requests + + try: + original_timeout = getattr(requests, 'timeout', None) + requests.timeout = 10 + + stock_info = ak.stock_zh_a_spot_em() + + if original_timeout: + requests.timeout = original_timeout + else: + delattr(requests, 'timeout') + + except Exception: + return [] + + if stock_info is None or len(stock_info) == 0: + return [] + + stock_list = [] + for index, row in stock_info.head(2000).iterrows(): + try: + stock_name = str(row['名称']) + if 'ST' not in stock_name and '*' not in stock_name: + stock_list.append({ + 'symbol': row['代码'], + 'name': row['名称'], + 'price': float(row['最新价']) if pd.notna(row['最新价']) else 0.0, + 'change_pct': float(row['涨跌幅']) if pd.notna(row['涨跌幅']) else 0.0, + 'volume': float(row['成交量']) if pd.notna(row['成交量']) else 0.0, + 'amount': float(row['成交额']) if pd.notna(row['成交额']) else 0.0 + }) + except Exception: + continue + + stock_list.sort(key=lambda x: x['amount'], reverse=True) + return stock_list + + except Exception: + return [] + + def _fetch_all_stocks_ashare_dp(self): + """分页拉取 A-Share Data Platform /api/v1/stocks 全市场标的。""" + import requests + + page_size = 1000 + offset = 0 + all_rows = [] + reported_total = None + url = f'{self.ashare_dp_base}/api/v1/stocks' + while True: + resp = requests.get( + url, + params={'limit': page_size, 'offset': offset}, + timeout=45, + ) + resp.raise_for_status() + payload = resp.json() + items = payload.get('items') or [] + if reported_total is None: + reported_total = int(payload.get('total') or 0) + all_rows.extend(items) + if len(items) == 0: + break + if len(items) < page_size: + break + offset += page_size + if reported_total and offset >= reported_total: + break + if not all_rows: + return [] + out = [] + for row in all_rows: + sym = row.get('symbol') + if not sym and row.get('ts_code'): + sym = str(row['ts_code']).split('.')[0] + if not sym: + continue + name = row.get('name') or '' + out.append({ + 'symbol': str(sym).strip(), + 'name': str(name).strip(), + 'ts_code': row.get('ts_code'), + 'price': 0.0, + 'change_pct': 0.0, + 'volume': 0.0, + 'amount': 0.0, + }) + out.sort(key=lambda x: x['symbol']) + return out + + def get_stock_list(self, use_cache=True): + """获取 A 股股票列表:优先全量 REST(约 5500+),失败则 AKShare。""" + now = time_module.time() + if use_cache and self._stock_list_cache is not None and now < self._stock_list_cache_expires: + return list(self._stock_list_cache) + + if self.ashare_dp_base: + try: + dp_list = self._fetch_all_stocks_ashare_dp() + if dp_list: + self._stock_list_cache = dp_list + self._stock_list_cache_expires = now + self.stock_list_cache_ttl + return list(dp_list) + except Exception as exc: + logger.warning('A股列表从数据服务拉取失败,回退 AKShare: %s', exc) + + ak_list = self._get_stock_list_akshare() + if ak_list: + self._stock_list_cache = ak_list + self._stock_list_cache_expires = now + min(self.stock_list_cache_ttl, 300) + return ak_list or [] + + def get_available_kline_freqs(self): + """ + A-Share Data Platform 支持的 K 线周期列表(原始顺序不保证,由上层按粒度排序)。 + 文档: GET /api/v1/klines/available-freqs + """ + import requests + + fallback = ['1m', '5m', '15m', '30m', '1h', '2h', '1d', '1w', '1M'] + if not self.ashare_dp_base: + return list(fallback) + try: + url = f'{self.ashare_dp_base}/api/v1/klines/available-freqs' + resp = requests.get(url, timeout=10) + resp.raise_for_status() + data = resp.json() + freqs = data.get('frequencies') or [] + return list(freqs) if freqs else list(fallback) + except Exception as exc: + logger.warning('获取 A 股可用 K 线周期失败: %s', exc) + return list(fallback) + + def get_popular_stocks(self): + """获取热门A股股票代码列表 - 扩展版本,按行业分类""" + return [ + # 包装引印刷 + {'symbol': '002836', 'name': '新宏泽', 'sector': '包装印刷'}, + # 银行股 + {'symbol': '600036', 'name': '招商银行', 'sector': '银行'}, + {'symbol': '000001', 'name': '平安银行', 'sector': '银行'}, + {'symbol': '600000', 'name': '浦发银行', 'sector': '银行'}, + {'symbol': '002142', 'name': '宁波银行', 'sector': '银行'}, + {'symbol': '600016', 'name': '民生银行', 'sector': '银行'}, + {'symbol': '601288', 'name': '农业银行', 'sector': '银行'}, + {'symbol': '601398', 'name': '工商银行', 'sector': '银行'}, + {'symbol': '601328', 'name': '交通银行', 'sector': '银行'}, + + # 白酒股 + {'symbol': '600519', 'name': '贵州茅台', 'sector': '白酒'}, + {'symbol': '000858', 'name': '五粮液', 'sector': '白酒'}, + {'symbol': '002304', 'name': '洋河股份', 'sector': '白酒'}, + {'symbol': '000596', 'name': '古井贡酒', 'sector': '白酒'}, + {'symbol': '603369', 'name': '今世缘', 'sector': '白酒'}, + {'symbol': '000799', 'name': '酒鬼酒', 'sector': '白酒'}, + {'symbol': '600809', 'name': '山西汾酒', 'sector': '白酒'}, + + # 科技股 + {'symbol': '002415', 'name': '海康威视', 'sector': '科技'}, + {'symbol': '000063', 'name': '中兴通讯', 'sector': '科技'}, + {'symbol': '002475', 'name': '立讯精密', 'sector': '科技'}, + {'symbol': '300059', 'name': '东方财富', 'sector': '科技'}, + {'symbol': '000725', 'name': '京东方A', 'sector': '科技'}, + {'symbol': '002230', 'name': '科大讯飞', 'sector': '科技'}, + {'symbol': '300433', 'name': '蓝思科技', 'sector': '科技'}, + {'symbol': '002236', 'name': '大华股份', 'sector': '科技'}, + + # 新能源 + {'symbol': '300750', 'name': '宁德时代', 'sector': '新能源'}, + {'symbol': '002594', 'name': '比亚迪', 'sector': '新能源'}, + {'symbol': '300274', 'name': '阳光电源', 'sector': '新能源'}, + {'symbol': '002460', 'name': '赣锋锂业', 'sector': '新能源'}, + {'symbol': '300014', 'name': '亿纬锂能', 'sector': '新能源'}, + {'symbol': '600884', 'name': '杉杉股份', 'sector': '新能源'}, + {'symbol': '002812', 'name': '恩捷股份', 'sector': '新能源'}, + + # 房地产 + {'symbol': '000002', 'name': '万科A', 'sector': '房地产'}, + {'symbol': '000858', 'name': '五粮液', 'sector': '房地产'}, + {'symbol': '600048', 'name': '保利发展', 'sector': '房地产'}, + {'symbol': '001979', 'name': '招商蛇口', 'sector': '房地产'}, + {'symbol': '600606', 'name': '绿地控股', 'sector': '房地产'}, + + # 消费股 + {'symbol': '600887', 'name': '伊利股份', 'sector': '消费'}, + {'symbol': '000568', 'name': '泸州老窖', 'sector': '消费'}, + {'symbol': '600600', 'name': '青岛啤酒', 'sector': '消费'}, + {'symbol': '000895', 'name': '双汇发展', 'sector': '消费'}, + {'symbol': '002304', 'name': '洋河股份', 'sector': '消费'}, + {'symbol': '600779', 'name': '水井坊', 'sector': '消费'}, + + # 医药股 + {'symbol': '600196', 'name': '复星医药', 'sector': '医药'}, + {'symbol': '000661', 'name': '长春高新', 'sector': '医药'}, + {'symbol': '300015', 'name': '爱尔眼科', 'sector': '医药'}, + {'symbol': '002821', 'name': '凯莱英', 'sector': '医药'}, + {'symbol': '300760', 'name': '迈瑞医疗', 'sector': '医药'}, + {'symbol': '600276', 'name': '恒瑞医药', 'sector': '医药'}, + + # 证券股 + {'symbol': '000776', 'name': '广发证券', 'sector': '证券'}, + {'symbol': '600030', 'name': '中信证券', 'sector': '证券'}, + {'symbol': '000166', 'name': '申万宏源', 'sector': '证券'}, + {'symbol': '601688', 'name': '华泰证券', 'sector': '证券'}, + {'symbol': '600837', 'name': '海通证券', 'sector': '证券'}, + + # 化工股 + {'symbol': '600309', 'name': '万华化学', 'sector': '化工'}, + {'symbol': '002352', 'name': '顺丰控股', 'sector': '化工'}, + {'symbol': '600346', 'name': '恒力石化', 'sector': '化工'}, + {'symbol': '000792', 'name': '盐湖股份', 'sector': '化工'}, + + # 汽车股 + {'symbol': '600104', 'name': '上汽集团', 'sector': '汽车'}, + {'symbol': '000625', 'name': '长安汽车', 'sector': '汽车'}, + {'symbol': '601633', 'name': '长城汽车', 'sector': '汽车'}, + {'symbol': '002049', 'name': '紫光国微', 'sector': '汽车'}, + + # 军工股 + {'symbol': '002179', 'name': '中航光电', 'sector': '军工'}, + {'symbol': '600893', 'name': '航发动力', 'sector': '军工'}, + {'symbol': '000768', 'name': '中航飞机', 'sector': '军工'}, + + # 基建股 + {'symbol': '601186', 'name': '中国铁建', 'sector': '基建'}, + {'symbol': '601390', 'name': '中国中铁', 'sector': '基建'}, + {'symbol': '000001', 'name': '平安银行', 'sector': '基建'}, + + # 煤炭股 + {'symbol': '601225', 'name': '陕西煤业', 'sector': '煤炭'}, + {'symbol': '600188', 'name': '兖矿能源', 'sector': '煤炭'}, + {'symbol': '601898', 'name': '中煤能源', 'sector': '煤炭'}, + + # 钢铁股 + {'symbol': '000717', 'name': '韶钢松山', 'sector': '钢铁'}, + {'symbol': '600019', 'name': '宝钢股份', 'sector': '钢铁'}, + {'symbol': '000708', 'name': '中信特钢', 'sector': '钢铁'}, + ] + + def timeframe_to_period(self, timeframe): + """将时间周期转换为akshare的period参数""" + mapping = { + '1m': '1', # 1分钟 + '5m': '5', # 5分钟 + '15m': '15', # 15分钟 + '30m': '30', # 30分钟 + '1h': '60', # 60分钟 + '1d': 'daily', # 日线 + '1w': 'weekly',# 周线 + '1M': 'monthly'# 月线 + } + return mapping.get(timeframe, 'daily') + + @staticmethod + def symbol_to_ts_code(symbol): + """六位代码或已是 ts_code(000001.SZ)→ 交易所后缀。""" + if symbol is None: + return '' + s = str(symbol).strip().upper() + if '.' in s and s.count('.') == 1: + return s + if len(s) != 6 or not s.isdigit(): + return s + if s.startswith('6'): + return f'{s}.SH' + if s.startswith(('0', '3')): + return f'{s}.SZ' + if s.startswith('920'): + return f'{s}.BJ' + if s.startswith(('8', '4')): + return f'{s}.BJ' + return f'{s}.SZ' + + @staticmethod + def _ymd_compact_to_api_date(ymd_compact): + """YYYYMMDD → YYYY-MM-DD""" + if not ymd_compact or len(ymd_compact) != 8: + return None + return f'{ymd_compact[:4]}-{ymd_compact[4:6]}-{ymd_compact[6:8]}' + + def get_kl_data_from_ashare_dp(self, symbol, timeframe, start_date, end_date, limit): + """ + 从 A-Share Data Platform(/api/v1/klines/{freq})拉取 K 线。 + start_date / end_date 为 YYYYMMDD 字符串。 + """ + if not self.ashare_dp_base or timeframe not in ASHARE_REST_TIMEFRAMES: + return None + import requests + + ts_code = self.symbol_to_ts_code(symbol) + if not ts_code or '.' not in ts_code: + return None + start_api = self._ymd_compact_to_api_date(start_date) + end_api = self._ymd_compact_to_api_date(end_date) + if not start_api or not end_api: + return None + api_limit = 10000 + if limit is not None: + try: + api_limit = min(int(limit), 10000) + except (TypeError, ValueError): + api_limit = 10000 + url = f'{self.ashare_dp_base}/api/v1/klines/{timeframe}' + params = { + 'ts_code': ts_code, + 'start_date': start_api, + 'end_date': end_api, + 'limit': api_limit, + } + try: + resp = requests.get(url, params=params, timeout=20) + resp.raise_for_status() + payload = resp.json() + except Exception as exc: + logger.debug('A股数据服务 K 线请求失败: %s', exc) + return None + items = payload.get('items') or payload.get('data') or [] + if not items: + return None + rows = [] + for row in items: + t = row.get('trade_time') or row.get('trade_date') + if not t: + continue + rows.append({ + 'date': t, + 'open': row.get('open'), + 'high': row.get('high'), + 'low': row.get('low'), + 'close': row.get('close'), + 'volume': row.get('volume'), + }) + if not rows: + return None + df = pd.DataFrame(rows) + df['date'] = pd.to_datetime(df['date']) + for col in ('open', 'high', 'low', 'close', 'volume'): + if col in df.columns: + df[col] = pd.to_numeric(df[col], errors='coerce') + df = df.dropna(subset=['open', 'high', 'low', 'close']) + df = df.sort_values('date').reset_index(drop=True) + df = self.adjust_timestamp_for_trading_hours(df, timeframe) + df = self.clean_a_stock_data(df, timeframe) + if df is None or len(df) == 0: + return None + if limit is not None: + try: + lim = int(limit) + if len(df) > lim: + df = df.tail(lim).reset_index(drop=True) + except (TypeError, ValueError): + pass + elif len(df) > 10000: + df = df.tail(10000).reset_index(drop=True) + df = self.add_indicators(df) + return df + + def get_kl_data(self, symbol, timeframe='1d', start_date=None, end_date=None, limit=10000): + """ + 获取A股K线数据 - 支持分批次获取突破单次限制 + :param symbol: 股票代码,如 '000001' + :param timeframe: 时间周期,如 '1d', '1h', '5m' + :param start_date: 开始日期,格式 'YYYY-MM-DD' + :param end_date: 结束日期,格式 'YYYY-MM-DD' + :param limit: 数据条数限制 + :return: DataFrame + """ + try: + period = self.timeframe_to_period(timeframe) + + # 处理时间参数 + if start_date is None: + # 默认获取最近一年的数据 + start_date = (datetime.now() - timedelta(days=365)).strftime('%Y%m%d') + else: + # 将 YYYY-MM-DD 格式转换为 YYYYMMDD + if '-' in start_date: + start_date = start_date.replace('-', '') + + if end_date is None: + end_date = datetime.now().strftime('%Y%m%d') + else: + if '-' in end_date: + end_date = end_date.replace('-', '') + + if self.ashare_dp_base: + df_dp = self.get_kl_data_from_ashare_dp( + symbol, timeframe, start_date, end_date, limit + ) + if df_dp is not None and len(df_dp) > 0: + return df_dp + + # 分批次获取数据以突破单次限制 + all_data = [] + current_start = start_date + + # 计算时间间隔(根据时间周期调整批次大小) + if period in ['1', '5', '15', '30']: + # 分钟级数据,每次获取7天 + batch_days = 7 + elif period == '60': + # 小时级数据,每次获取30天 + batch_days = 30 + else: + # 日线及以上,每次获取365天 + batch_days = 365 + + max_iterations = 20 # 最大迭代次数,防止无限循环 + iteration_count = 0 + + while current_start <= end_date and iteration_count < max_iterations: + iteration_count += 1 + + # 计算当前批次的结束时间 + current_start_dt = datetime.strptime(current_start, '%Y%m%d') + current_end_dt = current_start_dt + timedelta(days=batch_days) + current_end = min(current_end_dt.strftime('%Y%m%d'), end_date) + + pass + + try: + # 根据时间周期选择不同的API + df_batch = None + if period in ['1', '5', '15', '30', '60']: + # 分钟级数据 + df_batch = ak.stock_zh_a_hist_min_em(symbol=symbol, period=period, + start_date=current_start, end_date=current_end) + if df_batch is not None and len(df_batch) > 0: + # 重命名列 + df_batch = df_batch.rename(columns={ + '时间': 'date', + '开盘': 'open', + '收盘': 'close', + '最高': 'high', + '最低': 'low', + '成交量': 'volume' + }) + else: + # 日线、周线、月线数据 + df_batch = ak.stock_zh_a_hist(symbol=symbol, period=period, + start_date=current_start, end_date=current_end) + if df_batch is not None and len(df_batch) > 0: + # 重命名列 + df_batch = df_batch.rename(columns={ + '日期': 'date', + '开盘': 'open', + '收盘': 'close', + '最高': 'high', + '最低': 'low', + '成交量': 'volume' + }) + + if df_batch is not None and len(df_batch) > 0: + # 转换时间格式 + df_batch['date'] = pd.to_datetime(df_batch['date']) + + # 根据A股交易时间调整时间戳 + df_batch = self.adjust_timestamp_for_trading_hours(df_batch, timeframe) + + all_data.append(df_batch) + pass + + except Exception as e: + # 继续下一个批次 + pass + + # 更新下一批次的开始时间 + current_start = (current_end_dt + timedelta(days=1)).strftime('%Y%m%d') + + # 防止API请求过于频繁 + time_module.sleep(0.5) + + # 合并所有批次的数据 + if not all_data: + return None + + # 合并DataFrame + df = pd.concat(all_data, ignore_index=True) + + # 数据清洗和格式化 + df = df.dropna() # 删除空值 + df = df.drop_duplicates(subset=['date']) # 删除重复数据 + df = df.sort_values('date').reset_index(drop=True) # 按时间排序 + + # A股特有的数据清理和时间处理 + df = self.clean_a_stock_data(df, timeframe) + + # 限制数据条数 - 只有在没有指定明确时间范围时才应用 + # 如果用户指定了start_date和end_date,应该返回该时间范围内的所有数据 + if limit is not None and len(df) > limit: + # 检查是否指定了明确的时间范围 + if start_date and end_date: + # 如果指定了时间范围,优先返回完整的时间范围数据 + if len(df) > 10000: # 防止数据量过大,设置一个合理的上限 + df = df.tail(10000).reset_index(drop=True) + else: + # 如果没有指定时间范围,使用默认的limit限制 + df = df.tail(limit).reset_index(drop=True) + elif limit is None and len(df) > 10000: + # 即使没有limit限制,也要防止数据量过大影响性能 + df = df.tail(10000).reset_index(drop=True) + + # 添加技术指标 + df = self.add_indicators(df) + + # 最终数据验证 - 确保没有NaN值 + import numpy as np + + # 检查并处理任何剩余的NaN值 + if df.isnull().any().any(): + # 对于数值列,用0填充NaN + numeric_cols = df.select_dtypes(include=[np.number]).columns + for col in numeric_cols: + if col in ['volume_ratio']: + df[col] = df[col].fillna(1.0) + else: + df[col] = df[col].fillna(0) + + # 删除仍然包含NaN的行 + df = df.dropna() + + # 确保所有数值都是有限的 + for col in df.select_dtypes(include=[np.number]).columns: + df[col] = df[col].replace([np.inf, -np.inf], 0 if col != 'volume_ratio' else 1.0) + + return df + + except Exception as e: + return None + + def add_indicators(self, df): + """添加技术指标""" + try: + import talib.abstract as ta + import numpy as np + + # MACD指标 + fast = 8 + slow = 16 + period = 6 + macd = ta.MACD(df, fastperiod=fast, slowperiod=slow, signalperiod=period) + + df['macd'] = macd['macd'].fillna(0) + df['macdsignal'] = macd['macdsignal'].fillna(0) + df['macdhist'] = macd['macdhist'].fillna(0) + + # 移动平均线 + df['ma5'] = ta.MA(df, timeperiod=5).fillna(0) + df['ma10'] = ta.MA(df, timeperiod=10).fillna(0) + df['ma30'] = ta.EMA(df, timeperiod=30).fillna(0) + df['ma250'] = ta.MA(df, timeperiod=250).fillna(0) + + # RSI指标 + df['rsi'] = ta.RSI(df, timeperiod=14).fillna(0) + + # 成交量指标 + df['avg_volume'] = df['volume'].rolling(10).mean().fillna(0) + df['volume_ratio'] = (df['volume'] / df['avg_volume']).fillna(1.0) + + # 处理Infinity和-Infinity值 + df['volume_ratio'] = df['volume_ratio'].replace([float('inf'), float('-inf')], 1.0) + + # 确保所有指标列都不包含NaN或无限值 + indicator_columns = ['macd', 'macdsignal', 'macdhist', 'ma5', 'ma10', 'ma30', 'ma250', 'rsi', 'avg_volume', 'volume_ratio'] + for col in indicator_columns: + if col in df.columns: + # 替换NaN、inf、-inf为合理的默认值 + df[col] = df[col].replace([np.nan, np.inf, -np.inf], 0 if col != 'volume_ratio' else 1.0) + + return df + + except Exception as e: + return df + + def search_stock(self, keyword): + """搜索股票 - 支持代码和名称模糊搜索""" + try: + if not keyword or len(keyword.strip()) == 0: + return [] + + keyword = keyword.strip().upper() + results = [] + + # 从热门股票中搜索 + popular_stocks = self.get_popular_stocks() + for stock in popular_stocks: + if (keyword in stock['symbol'] or + keyword.lower() in stock['name'].lower() or + stock['symbol'].startswith(keyword)): + results.append({ + 'symbol': stock['symbol'], + 'name': stock['name'], + 'sector': stock.get('sector', ''), + 'source': '热门股票' + }) + + # 如果热门股票中找到的结果少于10个,从完整股票列表中搜索 + if len(results) < 10: + try: + # 获取完整股票列表进行搜索 + stock_info = ak.stock_zh_a_spot_em() + + # 搜索前1000只活跃股票 + for index, row in stock_info.head(1000).iterrows(): + stock_code = str(row['代码']) + stock_name = str(row['名称']) + + # 过滤ST股票 + if 'ST' in stock_name or '*' in stock_name: + continue + + # 检查是否已经在结果中 + if any(r['symbol'] == stock_code for r in results): + continue + + # 搜索匹配 + if (keyword in stock_code or + keyword.lower() in stock_name.lower() or + stock_code.startswith(keyword)): + results.append({ + 'symbol': stock_code, + 'name': stock_name, + 'price': float(row['最新价']) if pd.notna(row['最新价']) else 0.0, + 'change_pct': float(row['涨跌幅']) if pd.notna(row['涨跌幅']) else 0.0, + 'source': '全市场搜索' + }) + + # 限制结果数量 + if len(results) >= 30: + break + + except Exception as e: + pass + + # 排序:优先显示代码匹配的结果 + def sort_key(item): + if item['symbol'].startswith(keyword): + return (0, item['symbol']) # 代码开头匹配优先级最高 + elif keyword in item['symbol']: + return (1, item['symbol']) # 代码包含匹配次之 + else: + return (2, item['symbol']) # 名称匹配最后 + + results.sort(key=sort_key) + + # 限制返回结果数量 + return results[:20] + + except Exception as e: + return [] + + def get_stock_by_sector(self, sector=None): + """根据行业获取股票列表""" + try: + popular_stocks = self.get_popular_stocks() + if sector: + return [stock for stock in popular_stocks if stock.get('sector', '') == sector] + else: + # 按行业分组 + sectors = {} + for stock in popular_stocks: + sector_name = stock.get('sector', '其他') + if sector_name not in sectors: + sectors[sector_name] = [] + sectors[sector_name].append(stock) + return sectors + except Exception as e: + return {} if sector is None else [] + + def get_all_sectors(self): + """获取所有行业分类""" + try: + popular_stocks = self.get_popular_stocks() + sectors = set() + for stock in popular_stocks: + sector = stock.get('sector', '其他') + sectors.add(sector) + return sorted(list(sectors)) + except Exception as e: + return [] + + def is_trading_day(self, date): + """判断是否为交易日(排除周末和节假日)""" + try: + # 将日期转换为datetime对象 + if isinstance(date, str): + date = datetime.strptime(date.split()[0], '%Y-%m-%d') + elif isinstance(date, pd.Timestamp): + date = date.to_pydatetime() + + # 周末不是交易日 + if date.weekday() >= 5: # 5=周六, 6=周日 + return False + + # 这里可以进一步添加节假日判断 + # 目前暂时只过滤周末 + return True + except Exception as e: + return True # 默认返回True,避免过度过滤 + + def is_trading_time(self, dt): + """判断是否为交易时间""" + try: + if isinstance(dt, str): + dt = pd.to_datetime(dt) + + time_str = dt.strftime('%H:%M') + + # 上午交易时间:09:30-11:30 + morning_start = self.trading_hours['morning']['start'] + morning_end = self.trading_hours['morning']['end'] + + # 下午交易时间:13:00-15:00 + afternoon_start = self.trading_hours['afternoon']['start'] + afternoon_end = self.trading_hours['afternoon']['end'] + + return ((morning_start <= time_str <= morning_end) or + (afternoon_start <= time_str <= afternoon_end)) + except Exception as e: + return True # 默认返回True,避免过度过滤 + + def adjust_timestamp_for_trading_hours(self, df, timeframe): + """根据A股交易时间调整时间戳""" + try: + if df is None or len(df) == 0: + return df + + # 确保date列是datetime类型 + if 'date' in df.columns: + df['date'] = pd.to_datetime(df['date']) + + # 对于日线数据,设置为收盘时间(15:00) + if timeframe == '1d': + df['date'] = df['date'].dt.normalize() + pd.Timedelta(hours=15) + + # 对于分钟级数据,过滤非交易时间的数据 + elif timeframe in ['1m', '5m', '15m', '30m', '1h']: + # 过滤交易日 + df = df[df['date'].apply(self.is_trading_day)] + + # 过滤交易时间(只在有足够数据时进行) + if len(df) > 10: # 避免过度过滤导致数据不足 + df = df[df['date'].apply(self.is_trading_time)] + + # 重新计算时间戳 + if 'date' in df.columns: + # 将时间转换为上海时区 + df['date'] = df['date'].dt.tz_localize('Asia/Shanghai', ambiguous='infer', nonexistent='shift_forward') + # 转换为毫秒时间戳 + df['timestamp'] = df['date'].astype('int64') // 10**6 + + return df.reset_index(drop=True) + + except Exception as e: + return df + + def get_trading_calendar(self, start_date, end_date): + """获取交易日历(简化版本)""" + try: + # 使用akshare获取交易日历 + trading_calendar = ak.tool_trade_date_hist_sina() + + # 过滤指定日期范围 + start_dt = pd.to_datetime(start_date) + end_dt = pd.to_datetime(end_date) + + trading_days = [] + for _, row in trading_calendar.iterrows(): + trade_date = pd.to_datetime(row['trade_date']) + if start_dt <= trade_date <= end_dt: + trading_days.append(trade_date.strftime('%Y-%m-%d')) + + return trading_days + except Exception as e: + # 如果获取失败,生成简单的工作日列表(排除周末) + trading_days = [] + current = pd.to_datetime(start_date) + end = pd.to_datetime(end_date) + + while current <= end: + if current.weekday() < 5: # 周一到周五 + trading_days.append(current.strftime('%Y-%m-%d')) + current += timedelta(days=1) + + return trading_days + + def fill_trading_gaps(self, df, timeframe): + """填补A股交易时间间隙,确保图表连续性""" + try: + if df is None or len(df) == 0: + return df + + # 对于日线数据,不需要填补间隙,因为本来就是每日一个数据点 + if timeframe == '1d': + return df + + # 对于分钟级数据,创建完整的交易时间序列 + if timeframe in ['1m', '5m', '15m', '30m', '1h']: + # 获取数据的开始和结束时间 + start_date = df['date'].min().date() + end_date = df['date'].max().date() + + # 创建完整的交易时间序列 + complete_times = [] + current_date = start_date + + # 获取时间间隔(分钟) + freq_map = {'1m': 1, '5m': 5, '15m': 15, '30m': 30, '1h': 60} + freq_minutes = freq_map.get(timeframe, 5) + + while current_date <= end_date: + # 只处理交易日 + if self.is_trading_day(current_date): + # 上午交易时间 - 使用datetime.time而不是pd.Time + morning_start = pd.Timestamp.combine(current_date, time(9, 30)) + morning_end = pd.Timestamp.combine(current_date, time(11, 30)) + + # 下午交易时间 + afternoon_start = pd.Timestamp.combine(current_date, time(13, 0)) + afternoon_end = pd.Timestamp.combine(current_date, time(15, 0)) + + # 生成上午时间序列 + current_time = morning_start + while current_time <= morning_end: + complete_times.append(current_time) + current_time += pd.Timedelta(minutes=freq_minutes) + + # 生成下午时间序列 + current_time = afternoon_start + while current_time <= afternoon_end: + complete_times.append(current_time) + current_time += pd.Timedelta(minutes=freq_minutes) + + current_date += timedelta(days=1) + + # 创建完整时间序列的DataFrame + if complete_times: + complete_df = pd.DataFrame({'date': complete_times}) + complete_df['date'] = complete_df['date'].dt.tz_localize('Asia/Shanghai') + complete_df['timestamp'] = complete_df['date'].astype('int64') // 10**6 + + # 将原始数据合并到完整时间序列 + # 使用时间戳进行合并,避免时区问题 + df_merged = pd.merge(complete_df, df, on='timestamp', how='left', suffixes=('', '_orig')) + + # 保持原有date列 + df_merged['date'] = df_merged['date'] + + # 对于缺失的OHLCV数据,使用前向填充 + price_cols = ['open', 'high', 'low', 'close'] + for col in price_cols: + if col in df_merged.columns: + df_merged[col] = df_merged[col].ffill() + + # 成交量缺失时设为0 + if 'volume' in df_merged.columns: + df_merged['volume'] = df_merged['volume'].fillna(0) + + # 删除辅助列 + cols_to_drop = [col for col in df_merged.columns if col.endswith('_orig')] + df_merged = df_merged.drop(columns=cols_to_drop) + + return df_merged + + return df + + except Exception as e: + return df + + def clean_a_stock_data(self, df, timeframe): + """清理A股数据,处理异常值和时间问题""" + try: + if df is None or len(df) == 0: + return df + + import numpy as np + + # 首先删除所有包含NaN的行 + df = df.dropna() + + # 删除价格异常的数据 + price_cols = ['open', 'high', 'low', 'close'] + for col in price_cols: + if col in df.columns: + # 删除价格为0、负数、NaN、inf的记录 + df = df[df[col] > 0] + df = df[np.isfinite(df[col])] + + # 检查OHLC逻辑合理性 + if all(col in df.columns for col in price_cols): + # high应该是最高价 + df = df[df['high'] >= df['open']] + df = df[df['high'] >= df['close']] + # low应该是最低价 + df = df[df['low'] <= df['open']] + df = df[df['low'] <= df['close']] + # high应该大于等于low + df = df[df['high'] >= df['low']] + + # 删除成交量异常的数据 + if 'volume' in df.columns: + # 删除成交量为负数、NaN、inf的记录 + df = df[df['volume'] >= 0] + df = df[np.isfinite(df['volume'])] + + # 确保所有数值列都不包含NaN或无限值 + numeric_cols = df.select_dtypes(include=[np.number]).columns + for col in numeric_cols: + # 替换NaN、inf、-inf为0(除了价格列,价格列的异常值已经被过滤掉了) + if col not in price_cols: + df[col] = df[col].replace([np.nan, np.inf, -np.inf], 0) + + # 确保时间序列连续性(仅对分钟级数据) + if timeframe in ['1m', '5m', '15m', '30m', '1h']: + df = self.fill_trading_gaps(df, timeframe) + + # 最后再次检查并清理任何剩余的NaN值 + df = df.dropna() + + return df.reset_index(drop=True) + + except Exception as e: + return df \ No newline at end of file diff --git a/web/services/market_data.py b/web/services/market_data.py new file mode 100644 index 0000000..f25dca0 --- /dev/null +++ b/web/services/market_data.py @@ -0,0 +1,14 @@ +"""行情数据服务。""" +from services.runtime import ( # noqa: F401 + exchange, + china_stock, + DATA_SERVICE_AVAILABLE, + SYMBOLS, + DEFAULT_SYMBOLS, + refresh_data_service_metadata, + get_kl_data, + get_crypto_kl_data, + get_a_stock_kl_data, + detect_symbol_type, + load_crypto_symbols, +) diff --git a/web/services/runtime.py b/web/services/runtime.py new file mode 100644 index 0000000..553110a --- /dev/null +++ b/web/services/runtime.py @@ -0,0 +1,1176 @@ +from __future__ import annotations + +import sys +import os +from collections import OrderedDict +import json +import logging +import time +import traceback +import io +import base64 +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime, timedelta + +import ccxt +import numpy as np +import pandas as pd +import requests +import talib.abstract as ta +from pytz import timezone + +_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _ROOT not in sys.path: + sys.path.append(_ROOT) + +from chanlun import ChanLun, TF_DF +from chanlun.core.ChanEnum import Chan_BI_DIR, Chan_SEG_DIR, Chan_KLC_FX, Chan_FX_TYPE, Chan_MACDSEG_DIR, Chan_MACDHISTSET_DIR +from chanlun.indicators.ChanMACD import ChanMACD +from chanlun.analysis.ChanZone import StructureZoneConfig, analyze_structure_zones_from_serialized + +from config import ( + DATA_SERVICE_URL, + MACD_FAST, + MACD_SLOW, + MACD_SIGNAL, + ccxt_proxies, +) +from services.cn_stock import ChinaStockData + +logger = logging.getLogger(__name__) + +class TRADE_POINT_TYPE: + BUY1 = 1 # 一类买点 + BUY2 = 2 # 二类买点 + BUY3 = 3 # 三类买点 + SELL1 = -1 # 一类卖点 + SELL2 = -2 # 二类卖点 + SELL3 = -3 # 三类卖点 + + + +# mutable runtime state +macd_fast_period = MACD_FAST +macd_slow_period = MACD_SLOW +macd_signal_period = MACD_SIGNAL + +_proxies = ccxt_proxies() +_exchange_kwargs = {"enableRateLimit": True} +if _proxies: + _exchange_kwargs["proxies"] = _proxies +exchange = ccxt.binance(_exchange_kwargs) + +china_stock = ChinaStockData() +_zone_cache = {} + +DEFAULT_TIMEFRAME_LABELS = OrderedDict([ + ("1m", "1分钟"), + ("3m", "3分钟"), + ("5m", "5分钟"), + ("15m", "15分钟"), + ("30m", "30分钟"), + ("1h", "1小时"), + ("2h", "2小时"), + ("4h", "4小时"), + ("6h", "6小时"), + ("8h", "8小时"), + ("12h", "12小时"), + ("1d", "日线"), + ("3d", "3日线"), + ("1w", "周线"), + ("1M", "月线"), +]) + +DEFAULT_SYMBOLS = [ + 'SOL/USDT:USDT', 'BTC/USDT:USDT', 'ETH/USDT:USDT', 'BNB/USDT:USDT', 'XRP/USDT:USDT', 'WIF/USDT:USDT', + 'ADA/USDT:USDT', 'DOGE/USDT:USDT', 'AVAX/USDT:USDT', 'DOT/USDT:USDT', 'MATIC/USDT:USDT' +] + +TIMEFRAMES = DEFAULT_TIMEFRAME_LABELS.copy() +SYMBOLS = DEFAULT_SYMBOLS.copy() +DATA_SERVICE_AVAILABLE = False +SERVICE_METADATA_LAST_REFRESH = 0 + + +def _zone_cache_ttl(tf_name: str) -> int: + """根据时间周期返回缓存过期时间(秒)""" + minutes = timeframe_to_minutes(tf_name) or 5 + if minutes <= 5: + return 120 # 5m及以下: 2分钟 + elif minutes <= 15: + return 300 # 15m: 5分钟 + elif minutes <= 60: + return 600 # 1h: 10分钟 + else: + return 1800 # 4h+: 30分钟 + + +def timeframe_to_minutes(tf: str): + """将时间周期转换为分钟数,用于排序。""" + if not tf: + return None + unit = tf[-1] + try: + value = int(tf[:-1]) + except (ValueError, TypeError): + return None + multiplier = { + 'm': 1, + 'h': 60, + 'd': 1440, + 'w': 10080, + 'M': 43200, # 30天近似 + }.get(unit) + if multiplier is None: + return None + return value * multiplier + + +def format_timeframe_label(tf: str) -> str: + """将时间周期转换为可读标签。""" + if not tf: + return tf + unit = tf[-1] + try: + value = int(tf[:-1]) + except (ValueError, TypeError): + return tf + if unit == 'm': + return f"{value}分钟" + if unit == 'h': + return f"{value}小时" + if unit == 'd': + return "日线" if value == 1 else f"{value}日线" + if unit == 'w': + return "周线" if value == 1 else f"{value}周线" + if unit == 'M': + return "月线" if value == 1 else f"{value}月线" + return tf + + +def build_timeframe_labels(timeframes): + ordered = sorted( + timeframes, + key=lambda tf: timeframe_to_minutes(tf) if timeframe_to_minutes(tf) is not None else float('inf'), + ) + labels = OrderedDict() + for tf in ordered: + labels[tf] = format_timeframe_label(tf) + return labels + + +def compute_timeframe_defaults(labels_ordered): + """ + 根据已排序的「周期 → 中文标签」映射,计算主 / 次 / 次次周期默认值。 + labels_ordered: OrderedDict 或按插入顺序排列的 dict。 + """ + if not labels_ordered: + labels_ordered = DEFAULT_TIMEFRAME_LABELS.copy() + timeframe_keys = list(labels_ordered.keys()) + preferred_main = next((tf for tf in ['5m', '15m', '1h'] if tf in labels_ordered), None) + default_main = preferred_main or (timeframe_keys[0] if timeframe_keys else '1m') + if default_main not in labels_ordered and timeframe_keys: + default_main = timeframe_keys[0] + + if timeframe_keys: + try: + idx = timeframe_keys.index(default_main) + default_element = timeframe_keys[idx - 1] if idx > 0 else timeframe_keys[0] + except ValueError: + default_element = timeframe_keys[0] + else: + default_element = default_main + + if timeframe_keys: + try: + idx_el = timeframe_keys.index(default_element) + default_sub_sub = timeframe_keys[idx_el - 1] if idx_el > 0 else timeframe_keys[0] + except ValueError: + default_sub_sub = timeframe_keys[0] + else: + default_sub_sub = default_element + + return default_main, default_element, default_sub_sub, timeframe_keys + + +def _parse_time_input(value): + if value in (None, '', 0): + return None + try: + return int(float(value)) + except (ValueError, TypeError): + return None + + +def refresh_data_service_metadata(force=False): + """刷新数据服务提供的交易对与周期元信息。""" + global DATA_SERVICE_AVAILABLE, TIMEFRAMES, SYMBOLS, SERVICE_METADATA_LAST_REFRESH + now = time.time() + if not force and DATA_SERVICE_AVAILABLE and now - SERVICE_METADATA_LAST_REFRESH < 60: + return True + try: + resp = requests.get(f"{DATA_SERVICE_URL}/health", timeout=5) + resp.raise_for_status() + payload = resp.json() + service_symbols = payload.get("symbols") or payload.get("symbol_list") or [] + base_timeframes = payload.get("timeframes") or payload.get("base_timeframes") or [] + derived = payload.get("derived_timeframes") or [] + service_timeframes = list(base_timeframes) + for tf in derived: + if tf not in service_timeframes: + service_timeframes.append(tf) + if service_symbols: + SYMBOLS[:] = service_symbols + if service_timeframes: + TIMEFRAMES.clear() + TIMEFRAMES.update(build_timeframe_labels(service_timeframes)) + DATA_SERVICE_AVAILABLE = True + SERVICE_METADATA_LAST_REFRESH = now + return True + except Exception as exc: + logger.warning("无法加载数据服务元信息: %s", exc) + if not DATA_SERVICE_AVAILABLE: + TIMEFRAMES.clear() + TIMEFRAMES.update(DEFAULT_TIMEFRAME_LABELS) + SYMBOLS[:] = DEFAULT_SYMBOLS + DATA_SERVICE_AVAILABLE = False + return False + + +def _fetch_kl_from_datasvc(symbol, timeframe, start_ms=None, end_ms=None, limit=None): + params = {"symbol": symbol, "tf": timeframe} + if start_ms is not None: + params["start"] = int(start_ms) + if end_ms is not None: + params["end"] = int(end_ms) + if limit is not None: + params["limit"] = limit + resp = requests.get(f"{DATA_SERVICE_URL}/api/candles", params=params, timeout=10) + resp.raise_for_status() + data = resp.json() + if not data: + return None + df = pd.DataFrame(data) + if df.empty or "timestamp" not in df.columns: + return None + numeric_cols = ["open", "high", "low", "close", "volume"] + df["timestamp"] = pd.to_numeric(df["timestamp"], errors="coerce") + df = df.dropna(subset=["timestamp"]) + df["timestamp"] = df["timestamp"].astype("int64") + for col in numeric_cols: + if col in df.columns: + df[col] = pd.to_numeric(df[col], errors="coerce") + df = df.dropna(subset=numeric_cols) + df = df.sort_values("timestamp") + if limit and len(df) > limit: + df = df.tail(limit) + df = df.reset_index(drop=True) + df["date"] = pd.to_datetime(df["timestamp"], unit='ms', utc=True).dt.tz_convert('Asia/Shanghai') + return df + + +# 模块加载时尝试预取一次元信息,但失败不阻塞后续流程 +refresh_data_service_metadata(force=True) + +# A股热门股票 +# 模板中 A 股下拉仅放默认一项;用户切换到「A股」时由前端请求 /api/a_stocks 填充全市场(约 5500+) +A_STOCK_SYMBOLS = [{'symbol': '000001', 'name': '平安银行'}] + +def detect_symbol_type(symbol): + """检测交易对类型:crypto 或 a_stock""" + if '/' in symbol and 'USDT' in symbol: + return 'crypto' + elif len(symbol) == 6 and symbol.isdigit(): + return 'a_stock' + else: + return 'unknown' + +def get_kl_data(symbol, timeframe, limit=100000, start_time=None, end_time=None): + """获取K线数据,支持加密货币和A股""" + symbol_type = detect_symbol_type(symbol) + + if symbol_type == 'crypto': + return get_crypto_kl_data(symbol, timeframe, limit, start_time, end_time) + elif symbol_type == 'a_stock': + return get_a_stock_kl_data(symbol, timeframe, limit, start_time, end_time) + else: + return None + +def _get_crypto_kl_data_via_ccxt(symbol, timeframe, limit=100000, start_time=None, end_time=None): + """获取加密货币K线数据,支持分页加载确保获取指定时间范围内的所有数据""" + try: + # 初始化参数 + since = None + if start_time: + try: + since = int(start_time) + except ValueError: + pass + + # 结束时间处理 + until = None + if end_time: + try: + until = int(end_time) + except ValueError: + pass + + # 根据时间周期调整每次请求的数据量 + batch_size = 1000 # 默认批次大小 + if timeframe in ['1m', '3m', '5m']: + batch_size = 1000 # 分钟级数据减少批次大小 + elif timeframe in ['15m', '30m', '1h']: + batch_size = 1000 + else: + batch_size = 1500 # 日线及以上可以获取更多 + batch_size = 1500 # 默认批次大小 + # 初始化存储所有K线数据的列表 + all_ohlcv = [] + + # 初始化当前查询的开始时间 + current_since = since + + # 添加请求计数和最大限制 + request_count = 0 + max_requests = 300 # 最大请求次数,防止无限循环 + + # 分页加载数据 + while request_count < max_requests: + request_count += 1 + + try: + # 获取当前页的数据 + ohlcv = exchange.fetch_ohlcv(symbol, timeframe, since=current_since, limit=batch_size) + + # 如果没有获取到数据,结束循环 + if not ohlcv or len(ohlcv) == 0: + break + + # 将获取到的数据添加到总列表中 + all_ohlcv.extend(ohlcv) + + # 获取最后一条数据的时间戳 + last_timestamp = ohlcv[-1][0] + + # 如果已达到结束时间,结束循环 + if until and last_timestamp >= until: + break + + # 如果获取的数据条数小于限制数,说明已经获取完所有数据 + if len(ohlcv) < batch_size: + break + + # 更新下一页的开始时间(加1毫秒避免重复) + current_since = last_timestamp + 1 + + except Exception as e: + # 如果单个批次失败,继续尝试下一个批次 + if current_since: + # 尝试增加时间跳过可能的问题时间点 + current_since += 60000 # 跳过1分钟 + else: + break + + # 防止API请求过于频繁 + time.sleep(0.3) # 减少到0.3秒提高效率 + + # 数据为空的情况 + if not all_ohlcv or len(all_ohlcv) == 0: + return None + + # 转换为DataFrame + df = pd.DataFrame(all_ohlcv, columns=['timestamp', 'open', 'high', 'low', 'close', 'volume']) + df['date'] = pd.to_datetime(df['timestamp'], unit='ms').dt.tz_localize('UTC').dt.tz_convert('Asia/Shanghai') + + # 在客户端进行结束时间过滤 + if until: + df = df[df['timestamp'] <= until] + + # 去除重复数据 + df = df.drop_duplicates(subset=['timestamp']) + + # 按时间排序 + df = df.sort_values('timestamp') + + # 限制数据条数的逻辑 - 优先考虑时间范围 + if start_time and end_time: + # 如果指定了明确的时间范围,返回该时间范围内的所有数据 + if len(df) > 100000: # 防止数据量过大,设置一个合理的上限 + df = df.tail(100000).reset_index(drop=True) + elif limit and len(df) > limit: + # 如果没有指定明确时间范围,使用默认的limit限制 + df = df.tail(limit).reset_index(drop=True) + + # 如果过滤后没有数据,返回None + if len(df) == 0: + return None + return df + + except Exception as e: + return None + + +def get_crypto_kl_data(symbol, timeframe, limit=100000, start_time=None, end_time=None): + """优先通过本地数据服务获取加密货币K线,失败时回退至交易所API。""" + start_ms = _parse_time_input(start_time) + end_ms = _parse_time_input(end_time) + + refresh_data_service_metadata() + if DATA_SERVICE_AVAILABLE: + try: + df = _fetch_kl_from_datasvc( + symbol=symbol, + timeframe=timeframe, + start_ms=start_ms, + end_ms=end_ms, + limit=limit, + ) + if df is not None and not df.empty: + return df + except Exception as exc: + logger.warning("数据服务请求失败,准备回退至交易所 API:%s", exc) + + return _get_crypto_kl_data_via_ccxt(symbol, timeframe, limit, start_time, end_time) + + +def get_a_stock_kl_data(symbol, timeframe, limit=100000, start_time=None, end_time=None): + """获取A股K线数据""" + try: + # 处理时间戳参数转换为日期字符串 + start_date = None + end_date = None + + if start_time: + try: + # 尝试解析时间戳(毫秒) + start_timestamp = int(start_time) + start_date = datetime.fromtimestamp(start_timestamp / 1000).strftime('%Y-%m-%d') + except (ValueError, TypeError): + # 如果不是时间戳,尝试解析datetime-local格式 (YYYY-MM-DDTHH:MM) + try: + if 'T' in str(start_time): + # datetime-local格式:2025-05-19T06:07 + start_date = str(start_time).split('T')[0] # 只取日期部分 + else: + start_date = str(start_time) + except: + start_date = start_time + + if end_time: + try: + # 尝试解析时间戳(毫秒) + end_timestamp = int(end_time) + end_date = datetime.fromtimestamp(end_timestamp / 1000).strftime('%Y-%m-%d') + except (ValueError, TypeError): + # 如果不是时间戳,尝试解析datetime-local格式 + try: + if 'T' in str(end_time): + # datetime-local格式:2025-05-26T06:07 + end_date = str(end_time).split('T')[0] # 只取日期部分 + else: + end_date = str(end_time) + except: + end_date = end_time + + # 如果用户指定了时间范围,优先获取该范围内的所有数据 + actual_limit = limit + if start_date and end_date: + actual_limit = None # 不限制数据条数,获取完整时间范围数据 + + # 调用A股数据获取器 + df = china_stock.get_kl_data(symbol, timeframe, start_date, end_date, actual_limit) + + if df is None: + return None + return df + + except Exception as e: + return None + +def add_indicators(df): + global macd_fast_period, macd_slow_period, macd_signal_period + macd = ta.MACD(df, fastperiod=macd_fast_period, slowperiod=macd_slow_period, signalperiod=macd_signal_period) + + df['macd'] = macd['macd'] + df['macdsignal'] = macd['macdsignal'] + df['macdhist'] = macd['macdhist'] + df['ma5'] = (ta.MA(df, timeperiod=5)).fillna(0) + df['ma10'] = (ta.MA(df, timeperiod=10)).fillna(0) + df['ma30'] = (ta.EMA(df, timeperiod=30)).fillna(0) + df['ma250'] = (ta.MA(df, timeperiod=250)).fillna(0) + # 新增 EMA 指标 + df['ema5'] = (ta.EMA(df, timeperiod=5)).fillna(0) + df['ema10'] = (ta.EMA(df, timeperiod=10)).fillna(0) + df['ema24'] = (ta.EMA(df, timeperiod=24)).fillna(0) + df['ema52'] = (ta.EMA(df, timeperiod=52)).fillna(0) + df['ema26'] = (ta.EMA(df, timeperiod=26)).fillna(0) + df['ema13'] = (ta.EMA(df, timeperiod=13)).fillna(0) + df['ema7'] = (ta.EMA(df, timeperiod=7)).fillna(0) + df['ema104'] = (ta.EMA(df, timeperiod=104)).fillna(0) + df['ema156'] = (ta.EMA(df, timeperiod=156)).fillna(0) + df['ema208'] = (ta.EMA(df, timeperiod=208)).fillna(0) + # 常用SMA 24/52 + try: + df['sma24'] = (ta.SMA(df, timeperiod=24)).fillna(0) + df['sma52'] = (ta.SMA(df, timeperiod=52)).fillna(0) + except Exception: + df['sma24'] = 0 + df['sma52'] = 0 + df['rsi'] = ta.RSI(df, timeperiod=14) + + # 计算布林带 (当前周期 - 20周期,2标准差) + bb = ta.BBANDS(df, timeperiod=365, nbdevup=3.0, nbdevdn=3.0, matype=0) + df['bb_upper'] = bb['upperband'].fillna(0) + df['bb_middle'] = bb['middleband'].fillna(0) + df['bb_lower'] = bb['lowerband'].fillna(0) + bb30 = ta.BBANDS(df, timeperiod=41, nbdevup=2.3, nbdevdn=2.3, matype=0) + #bb30 = ta.BBANDS(df, timeperiod=20, nbdevup=2.0, nbdevdn=2.0, matype=0) + df['bbup30'] = bb30['upperband'].fillna(0) + df['bblow30'] = bb30['lowerband'].fillna(0) + bb302 = ta.BBANDS(df, timeperiod=41, nbdevup=2.0, nbdevdn=2.0, matype=0) + #bb302 = ta.BBANDS(df, timeperiod=20, nbdevup=2.0, nbdevdn=2.0, matype=0) + df['bbup302'] = bb302['upperband'].fillna(0) + df['bblow302'] = bb302['lowerband'].fillna(0) + # 计算次周期布林带 (14周期,2标准差) + bb_element = ta.BBANDS(df, timeperiod=14, nbdevup=2.0, nbdevdn=2.0, matype=0) + df['element_bb_upper'] = bb_element['upperband'].fillna(0) + df['element_bb_middle'] = bb_element['middleband'].fillna(0) + df['element_bb_lower'] = bb_element['lowerband'].fillna(0) + + df['macd'] = df['macd'].fillna(0) + df['macdsignal'] = df['macdsignal'].fillna(0) + df['macdhist'] = df['macdhist'].fillna(0) + df['ma5'] = df['ma5'].fillna(0) + df['ma10'] = df['ma10'].fillna(0) + df['ma30'] = df['ma30'].fillna(0) + df['ma250'] = df['ma250'].fillna(0) + df['ema5'] = df['ema5'].fillna(0) + df['ema10'] = df['ema10'].fillna(0) + df['ema24'] = df['ema24'].fillna(0) + df['ema52'] = df['ema52'].fillna(0) + df['sma24'] = df['sma24'].fillna(0) + df['sma52'] = df['sma52'].fillna(0) + df['rsi'] = df['rsi'].fillna(0) + df['avg_volume'] = df['volume'].rolling(10).mean() + # 计算量比,避免产生Infinity值 + df['volume_ratio'] = df['volume'] / df['avg_volume'] + # 填充缺失值(前N根K线) + df['volume_ratio'] = df['volume_ratio'].fillna(1.0) + df['avg_volume'] = df['avg_volume'].fillna(0) + + # 处理Infinity和-Infinity值 + df['volume_ratio'] = df['volume_ratio'].replace([float('inf'), float('-inf')], 1.0) + + # 计算ATR (Average True Range) - 14周期 + df['atr'] = ta.ATR(df, timeperiod=14) + df['atr'] = df['atr'].fillna(0) + bb2633 = ta.BBANDS(df, timeperiod=26, nbdevup=3.0, nbdevdn=3.0, matype=0) + bbp2633 = (df['close'] - bb2633['lowerband']) / (bb2633['upperband'] - bb2633['lowerband']) + df['bb2633upper'] = bb2633['upperband'].fillna(0) + df['bb2633lower'] = bb2633['lowerband'].fillna(0) + df['bbp2633'] = bbp2633.fillna(0) + df['bb2633middle'] = bb2633['middleband'].fillna(0) + return df + +def calculate_macd(df): + """计算MACD指标""" + global macd_fast_period, macd_slow_period, macd_signal_period + exp1 = df['close'].ewm(span=macd_fast_period, adjust=False).mean() + exp2 = df['close'].ewm(span=macd_slow_period, adjust=False).mean() + macd = exp1 - exp2 + signal = macd.ewm(span=macd_signal_period, adjust=False).mean() + histogram = macd - signal + + return { + 'macd': macd.tolist(), + 'signal': signal.tolist(), + 'histogram': histogram.tolist() + } + +def analyze_chan(df, symbol=None, timeframe=None): + """进行缠论分析""" + chan = TF_DF() + + # 初始化多时间周期数据以获取EMA52 + ema52_dict = None + # 获取分析结果 + klu_list = chan.get_kl_data(df) + klc_list = chan.get_klc_list(klu_list) + bi_list = chan.cal_bi_list(klc_list) + #for index in range(0, 10): + #print(bi_list[index].start_time, bi_list[index].start_klc.end_time, bi_list[index].dir) + seg_list = chan.get_seg_list(bi_list) + zs_list = chan.calculate_seg_zs(seg_list) + # 计算笔中枢(BI中枢)并拍平成列表 + + #bi_zs_list = chan.cal_bi_zs_list_pure(bi_list) + bi_zs_list = chan.cal_bi_zs(seg_list) + bsp_list = [] + if len(bi_zs_list) > 0: + bsp_list = chan.find_all_bsp(bi_list, bi_zs_list) + #bsp_state_list = chan.get_bsp_state(df) + #for bsp in bsp_list: + #print(bsp.end_time, bsp.type, bsp.dir) + # 添加买卖点识别 + for bi in bi_list: + bi.cal_macdhist() + for bi in bi_list: + bi.cal_macd_div() + #print(bi.start_time, bi.macd_hist, bi.macd_div) + + # 添加ChanMACD分析 + chan_macd = None + chan_macd_data = {} + try: + + if klu_list and len(klu_list) > 0: + print(f"获取到KLU列表,长度: {len(klu_list)}") + chan_macd = ChanMACD(klu_list) + chan_macd_data = { + 'seg_list': chan_macd.seg_list, + 'unittf_list': chan_macd.unittf_list, + 'histset_list': chan_macd.histset_list, + 'klu_list': chan_macd.klu_list, + 'high_position_list': chan_macd.high_position_list, + 'high_empty_list': chan_macd.high_empty_list, + 'low_position_list': getattr(chan_macd, 'low_position_list', []), + 'low_empty_list': getattr(chan_macd, 'low_empty_list', []), + 'return_zero_list': chan_macd.return_zero_list, + 'cross0_up_list': chan_macd.cross0_up_list, + 'cross0_down_list': chan_macd.cross0_down_list + } + print(f"ChanMACD分析完成: seg={len(chan_macd.seg_list)}, unittf={len(chan_macd.unittf_list)}, histset={len(chan_macd.histset_list)}") + else: + print("未能获取KLU列表或列表为空") + chan_macd_data = { + 'seg_list': [], + 'unittf_list': [], + 'histset_list': [], + 'high_position_list': [], + 'high_empty_list': [], + 'return_zero_list': [], + 'cross0_up_list': [], + 'cross0_down_list': [] + } + except Exception as e: + print(f"ChanMACD分析出错: {e}") + import traceback + traceback.print_exc() + chan_macd_data = { + 'seg_list': [], + 'unittf_list': [], + 'histset_list': [], + 'high_position_list': [], + 'high_empty_list': [], + 'low_position_list': [], + 'low_empty_list': [], + 'return_zero_list': [], + 'cross0_up_list': [], + 'cross0_down_list': [] + } + + # 提取K线分型信息 + klc_fx_info = [] + for klc in klc_list: + if hasattr(klc, 'klc_fx_type') and klc.klc_fx_type != Chan_KLC_FX.UNKNOWN: + try: + # 计算分型强度 + fx_strength = 0 + fx_strength_level = "" + is_strong_fx = False + + # 统一使用cal_fx_strength函数 + if hasattr(klc, 'cal_fx_strength'): + fx_strength = klc.cal_fx_strength(5) + + # 尝试获取分型强度等级 + if hasattr(klc, 'get_fx_strength_level'): + fx_strength_level = klc.get_fx_strength_level() + + # 尝试判断是否为强分型 + if hasattr(klc, 'is_strong_fx'): + is_strong_fx = klc.is_strong_fx() + + # 如果分型强度小于1,设为0 + if fx_strength < 1: + fx_strength = 0 + + # KLC 分型框(起止时间+高低价): + # 仅使用 cal_fx_box 通过 display 条件后生成的 klc.fx_box。 + # 若无 fx_box,则前端不应绘制分型框。 + fx_box = getattr(klc, 'fx_box', None) + box_start_time = getattr(fx_box, 'start_time', None) if fx_box else None + box_end_time = getattr(fx_box, 'end_time', None) if fx_box else None + box_high = getattr(fx_box, 'high', None) if fx_box else None + box_low = getattr(fx_box, 'low', None) if fx_box else None + + if klc.bb_out: + klc_fx_info.append({ + 'time': klc.end_time, + 'price': klc.low if klc.fx == Chan_FX_TYPE.BOTTOM else klc.high, + 'fx_type': str(klc.klc_fx_type).replace("Chan_KLC_FX.", ""), + 'is_bottom': klc.fx == Chan_FX_TYPE.BOTTOM, + 'fx_strength': fx_strength, # 分型强度分数 (0-100) + 'fx_strength_level': fx_strength_level, # 分型强度等级 (极强/强/中等/弱/极弱) + 'is_strong_fx': is_strong_fx, # 是否为强分型 + + # 虚线分型框信息(给前端画框用) + 'start_time': box_start_time, + 'end_time': box_end_time, + 'high': float(box_high) if box_high is not None else None, + 'low': float(box_low) if box_low is not None else None, + }) + except Exception as e: + # 如果出错,仍然添加基本信息,但分型强度为0 + fx_box = getattr(klc, 'fx_box', None) + box_start_time = getattr(fx_box, 'start_time', None) if fx_box else None + box_end_time = getattr(fx_box, 'end_time', None) if fx_box else None + box_high = getattr(fx_box, 'high', None) if fx_box else None + box_low = getattr(fx_box, 'low', None) if fx_box else None + + klc_fx_info.append({ + 'time': klc.end_time, + 'price': klc.low if klc.fx == Chan_FX_TYPE.BOTTOM else klc.high, + 'fx_type': str(klc.klc_fx_type).replace("Chan_KLC_FX.", ""), + 'is_bottom': klc.fx == Chan_FX_TYPE.BOTTOM, + 'fx_strength': 0, + 'fx_strength_level': "", + 'is_strong_fx': False, + + # 虚线分型框信息(给前端画框用) + 'start_time': box_start_time, + 'end_time': box_end_time, + 'high': float(box_high) if box_high is not None else None, + 'low': float(box_low) if box_low is not None else None, + }) + + + return { + 'klc_list': klc_list, + 'klu_list': klu_list, # 添加KLU列表 + 'bi_list': bi_list, + 'seg_list': seg_list, + 'zs_list': zs_list, + 'bi_zs_list': bi_zs_list, # 添加BI中枢列表 + 'bsp_list': bsp_list, # 添加买卖点列表 + 'klc_fx_info': klc_fx_info, # KLC分型信息 + 'chan_macd': chan_macd_data, # 添加ChanMACD分析数据 + 'ema52_dict': ema52_dict # 添加多时间周期EMA52数据 + } + +# 辅助函数,转换缠论方向枚举为整数 +def convert_direction(direction): + """转换方向枚举为数字""" + if direction == Chan_BI_DIR.UP or direction == Chan_SEG_DIR.UP: + return 1 + elif direction == Chan_BI_DIR.DOWN or direction == Chan_SEG_DIR.DOWN: + return -1 + else: + return 0 + +def format_time_safely(time_obj, client_tz): + """安全地格式化时间对象,处理字符串和datetime两种情况""" + if time_obj is None: + return None + + if isinstance(time_obj, str): + # 尝试将字符串解析为datetime + try: + from dateutil import parser + time_obj = parser.parse(time_obj) + return time_obj.astimezone(client_tz).isoformat() + except: + return time_obj + else: + # 已经是datetime对象 + return time_obj.astimezone(client_tz).isoformat() + +def serialize_chan_macd_data(chan_macd_data, client_tz): + """序列化ChanMACD数据为JSON可序列化格式""" + serialized_data = { + 'seg_list': [], + 'unittf_list': [], + 'histset_list': [], + # 状态标记数据 + 'high_position_list': [], + 'high_empty_list': [], + 'low_position_list': [], + 'low_empty_list': [], + 'return_zero_list': [], + 'cross0_up_list': [], + 'cross0_down_list': [], + # 新增:输出KLU的继续背驰/分离背驰标志 + 'klu_list': [] + } + + # 序列化seg_list + for seg in chan_macd_data.get('seg_list', []): + try: + seg_data = { + 'start_time': format_time_safely(seg.start_time, client_tz), + 'end_time': format_time_safely(seg.end_time, client_tz) if seg.end_time else None, + 'seg_dir': 'ABOVE' if seg.seg_dir == Chan_MACDSEG_DIR.ABOVE else 'UNDER', + 'klu_count': len(seg.klu_list) if hasattr(seg, 'klu_list') else 0, + 'unittf_count': len(seg.unittf_list) if hasattr(seg, 'unittf_list') else 0, + 'histset_count': len(seg.hist_set) if hasattr(seg, 'hist_set') else 0 + } + serialized_data['seg_list'].append(seg_data) + except Exception as e: + print(f"序列化seg出错: {e}") + continue + + # 序列化unittf_list(兼容新结构与枚举类型) + for unittf in chan_macd_data.get('unittf_list', []): + try: + dir_value = getattr(unittf, 'uinttf_dir', None) + dir_name = getattr(dir_value, 'name', dir_value if isinstance(dir_value, str) else None) + start_t = getattr(unittf, 'start_type', None) + start_type = getattr(start_t, 'name', start_t) + end_t = getattr(unittf, 'end_type', None) + end_type = getattr(end_t, 'name', end_t) + peak_abs = getattr(unittf, 'peak_abs', None) + if peak_abs is None: + peak_abs = getattr(unittf, 'peak_hist', None) + length = getattr(unittf, 'length', None) + if length is None: + length = len(unittf.klu_list) if hasattr(unittf, 'klu_list') else None + + unittf_data = { + 'start_time': format_time_safely(getattr(unittf, 'start_time', None), client_tz), + 'end_time': format_time_safely(getattr(unittf, 'end_time', None), client_tz) if getattr(unittf, 'end_time', None) else None, + 'dir': dir_name, # 'ABOVE' | 'UNDER' | None + 'start_type': start_type, # e.g. 'START' | 'CROSS0' | 'NEAR0_UP' | 'NEAR0_DOWN' + 'end_type': end_type, + 'invalid': getattr(unittf, 'invalid', False), + 'peak_abs': peak_abs, + 'length': length, + 'klu_count': len(unittf.klu_list) if hasattr(unittf, 'klu_list') else 0, + 'histset_count': len(unittf.histset_list) if hasattr(unittf, 'histset_list') else 0 + } + serialized_data['unittf_list'].append(unittf_data) + except Exception as e: + print(f"序列化unittf出错: {e}") + continue + + # 序列化histset_list + for histset in chan_macd_data.get('histset_list', []): + try: + histset_data = { + 'start_time': format_time_safely(getattr(histset, 'start_time', None), client_tz), + 'end_time': format_time_safely(getattr(histset, 'end_time', None), client_tz), + 'histset_dir': 'ABOVE' if histset.histset_dir == Chan_MACDHISTSET_DIR.ABOVE else 'UNDER', + 'klu_count': len(histset.klu_list) if hasattr(histset, 'klu_list') else 0 + } + serialized_data['histset_list'].append(histset_data) + except Exception as e: + print(f"序列化histset出错: {e}") + continue + + # 序列化状态标记数据 + # 序列化高位列表 + for high_pos in chan_macd_data.get('high_position_list', []): + try: + high_pos_data = { + 'time': format_time_safely(high_pos['time'], client_tz), + 'end_time': format_time_safely(high_pos.get('end_time'), client_tz) if high_pos.get('end_time') else None, + 'type': high_pos.get('type', 'start'), + 'macd': high_pos.get('macd'), + 'signal': high_pos.get('signal'), + 'macdhist': high_pos.get('macdhist'), + 'end_macd': high_pos.get('end_macd'), + 'end_signal': high_pos.get('end_signal'), + 'end_macdhist': high_pos.get('end_macdhist') + } + serialized_data['high_position_list'].append(high_pos_data) + except Exception as e: + print(f"序列化high_position出错: {e}") + continue + + # 序列化高位空列表 + for high_empty in chan_macd_data.get('high_empty_list', []): + try: + high_empty_data = { + 'time': format_time_safely(high_empty['time'], client_tz), + 'end_time': format_time_safely(high_empty.get('end_time'), client_tz) if high_empty.get('end_time') else None, + 'type': high_empty.get('type', 'start'), + 'macd': high_empty.get('macd'), + 'signal': high_empty.get('signal'), + 'macdhist': high_empty.get('macdhist'), + 'end_macd': high_empty.get('end_macd'), + 'end_signal': high_empty.get('end_signal'), + 'end_macdhist': high_empty.get('end_macdhist') + } + serialized_data['high_empty_list'].append(high_empty_data) + except Exception as e: + print(f"序列化high_empty出错: {e}") + continue + + # 序列化低位与低位空 + for low_pos in chan_macd_data.get('low_position_list', []): + try: + low_pos_data = { + 'time': format_time_safely(low_pos['time'], client_tz), + 'end_time': format_time_safely(low_pos.get('end_time'), client_tz) if low_pos.get('end_time') else None, + 'type': low_pos.get('type', 'start'), + 'macd': low_pos.get('macd'), + 'signal': low_pos.get('signal'), + 'macdhist': low_pos.get('macdhist'), + 'end_macd': low_pos.get('end_macd'), + 'end_signal': low_pos.get('end_signal'), + 'end_macdhist': low_pos.get('end_macdhist') + } + serialized_data['low_position_list'].append(low_pos_data) + except Exception as e: + print(f"序列化low_position出错: {e}") + continue + + for low_empty in chan_macd_data.get('low_empty_list', []): + try: + low_empty_data = { + 'time': format_time_safely(low_empty['time'], client_tz), + 'end_time': format_time_safely(low_empty.get('end_time'), client_tz) if low_empty.get('end_time') else None, + 'type': low_empty.get('type', 'start'), + 'macd': low_empty.get('macd'), + 'signal': low_empty.get('signal'), + 'macdhist': low_empty.get('macdhist'), + 'end_macd': low_empty.get('end_macd'), + 'end_signal': low_empty.get('end_signal'), + 'end_macdhist': low_empty.get('end_macdhist') + } + serialized_data['low_empty_list'].append(low_empty_data) + except Exception as e: + print(f"序列化low_empty出错: {e}") + continue + + # 序列化归零轴列表 + for return_zero in chan_macd_data.get('return_zero_list', []): + try: + return_zero_data = { + 'time': format_time_safely(return_zero['time'], client_tz), + 'end_time': format_time_safely(return_zero.get('end_time'), client_tz) if return_zero.get('end_time') else None, + 'type': return_zero.get('type', 'start'), + 'macd': return_zero.get('macd'), + 'signal': return_zero.get('signal'), + 'macdhist': return_zero.get('macdhist'), + 'end_macd': return_zero.get('end_macd'), + 'end_signal': return_zero.get('end_signal'), + 'end_macdhist': return_zero.get('end_macdhist') + } + serialized_data['return_zero_list'].append(return_zero_data) + except Exception as e: + print(f"序列化return_zero出错: {e}") + continue + + # 序列化穿越零轴列表 + for cross0_up in chan_macd_data.get('cross0_up_list', []): + try: + cross0_up_data = { + 'time': format_time_safely(cross0_up['time'], client_tz), + 'type': cross0_up.get('type', 'start'), + 'macd': cross0_up.get('macd'), + 'signal': cross0_up.get('signal'), + 'macdhist': cross0_up.get('macdhist') + } + serialized_data['cross0_up_list'].append(cross0_up_data) + except Exception as e: + print(f"序列化cross0_up出错: {e}") + continue + + for cross0_down in chan_macd_data.get('cross0_down_list', []): + try: + cross0_down_data = { + 'time': format_time_safely(cross0_down['time'], client_tz), + 'type': cross0_down.get('type', 'start'), + 'macd': cross0_down.get('macd'), + 'signal': cross0_down.get('signal'), + 'macdhist': cross0_down.get('macdhist') + } + serialized_data['cross0_down_list'].append(cross0_down_data) + except Exception as e: + print(f"序列化cross0_down出错: {e}") + continue + + # 序列化 KLU 列表(仅导出需要的时间与背驰标志) + for klu in chan_macd_data.get('klu_list', []): + try: + serialized_data['klu_list'].append({ + 'time': format_time_safely(getattr(klu, 'time', None), client_tz), + 'continue_div': bool(getattr(klu, 'continue_div', False)), + 'separate_div': int(getattr(klu, 'separate_div', 0)) if getattr(klu, 'separate_div', 0) is not None else 0, + 'near0_return': int(getattr(klu, 'near0_return', 0)) if getattr(klu, 'near0_return', 0) is not None else 0 + }) + except Exception as e: + print(f"序列化klu出错: {e}") + continue + + return serialized_data + +def is_smaller_timeframe(tf1, tf2): + """判断时间周期tf1是否小于tf2""" + tf1_value = timeframe_to_minutes(tf1) + tf2_value = timeframe_to_minutes(tf2) + if tf1_value is None or tf2_value is None: + return False + return tf1_value < tf2_value + +def is_smaller_or_equal_timeframe(tf1, tf2): + """判断时间周期tf1是否小于等于tf2""" + tf1_value = timeframe_to_minutes(tf1) + tf2_value = timeframe_to_minutes(tf2) + if tf1_value is None or tf2_value is None: + return False + return tf1_value <= tf2_value + +def clean_dataframe_for_json(df): + """清理DataFrame数据用于JSON序列化""" + # 创建副本避免修改原始数据 + clean_df = df.copy() + + # 替换NaN值为None + clean_df = clean_df.where(pd.notnull(clean_df), None) + + return clean_df + +# ====== 趋势判定与趋势筛选(币对) ====== + +def classify_trend_stage(df): + """根据 EMA 斜率与多空排列判断趋势方向与阶段 + 返回: direction in {"bull","bear","sideways"}, stage in {"early","mid","late"}, strength_score (0-100) + """ + if df is None or len(df) < 60: + return "sideways", "early", 0 + + # 使用 EMA5/10/24/52 + closes = df['close'].values + ema5 = df['ema5'].values if 'ema5' in df else ta.EMA(df, timeperiod=5) + ema10 = df['ema10'].values if 'ema10' in df else ta.EMA(df, timeperiod=10) + ema24 = df['ema24'].values if 'ema24' in df else ta.EMA(df, timeperiod=24) + ema52 = df['ema52'].values if 'ema52' in df else ta.EMA(df, timeperiod=52) + + # 最近N根用于斜率与排列判定 + lookback = min(30, len(df) - 1) + if lookback <= 5: + return "sideways", "early", 0 + + # 简单斜率: 最近k根的线性变化率近似 + def slope(arr, k=10): + k = min(k, len(arr) - 1) + if k < 2: + return 0.0 + y = arr[-k:] + x = np.arange(k) + # 最小二乘拟合斜率 + denom = np.dot(x - x.mean(), x - x.mean()) + if denom == 0: + return 0.0 + m = np.dot(y - y.mean(), x - x.mean()) / denom + return float(m) + + k_slope = 12 # 斜率窗口 + s5 = slope(ema5, k_slope) + s10 = slope(ema10, k_slope) + s24 = slope(ema24, k_slope) + s52 = slope(ema52, k_slope) + + # 多空排列 + last5, last10, last24, last52 = ema5[-1], ema10[-1], ema24[-1], ema52[-1] + bull_stack = last5 > last10 > last24 > last52 + bear_stack = last5 < last10 < last24 < last52 + + # 波动性与动量增强: MACD 柱体最近均值 + macdhist = df['macdhist'].values if 'macdhist' in df else calculate_macd(df)['histogram'] + hist_recent = macdhist[-lookback:] + hist_power = float(np.mean(np.abs(hist_recent))) if len(hist_recent) else 0.0 + + # 方向 + if bull_stack and s24 > 0 and s52 > 0: + direction = "bull" + elif bear_stack and s24 < 0 and s52 < 0: + direction = "bear" + else: + # 用价格相对 EMA52 辅助 + if closes[-1] > last52 and (s24 + s52) > 0: + direction = "bull" + elif closes[-1] < last52 and (s24 + s52) < 0: + direction = "bear" + else: + direction = "sideways" + + # 阶段: 依据(斜率大小、与EMA52距离、MACD柱体扩张/收敛) + dist52 = float((closes[-1] - last52) / last52) if last52 else 0.0 + slope_score = max(0.0, (abs(s24) + abs(s52)) * 1000.0) # 归一化 + dist_score = min(50.0, abs(dist52) * 200.0) + hist_score = min(30.0, hist_power * 10.0) + strength = float(min(100.0, slope_score + dist_score + hist_score)) + + # 简单阶段判定 + if direction == "sideways": + stage = "early" + strength = min(strength, 30.0) + else: + # 查看最近 hist 是否在扩大或收敛 + if len(hist_recent) >= 6: + recent_growth = np.mean(np.abs(hist_recent[-3:])) - np.mean(np.abs(hist_recent[-6:-3])) + else: + recent_growth = 0.0 + + if recent_growth > 0 and abs(dist52) < 0.05: + stage = "early" + elif recent_growth > 0 and abs(dist52) >= 0.05: + stage = "mid" + else: + stage = "late" + + return direction, stage, strength + + +def load_crypto_symbols(limit=200): + """加载常见USDT永续合约交易对,返回列表""" + refresh_data_service_metadata() + if SYMBOLS: + return SYMBOLS[:limit] + try: + markets = exchange.load_markets() + symbols = [s for s in markets.keys() if '/USDT' in s and ':USDT' in s] + return symbols[:limit] + except Exception: + return DEFAULT_SYMBOLS[:limit] + + + +def get_uncompleted_seg_list(seg_list, client_tz): + """获取未完成线段列表,正确处理倒数第二个和最后一个未完成线段""" + uncompleted_segs = [seg for seg in seg_list if not seg.is_sure] + + if len(uncompleted_segs) == 0: + return [] + + result = [] + + for i, seg in enumerate(uncompleted_segs): + is_last = (i == len(uncompleted_segs) - 1) # 是否为最后一个未完成线段 + + seg_data = { + 'start_time': seg.start_bi.start_klc.end_time if isinstance(seg.start_bi.start_klc.end_time, str) else seg.start_bi.start_klc.end_time.astimezone(client_tz).isoformat(), + 'sure_time': format_time_safely(seg.sure_time, client_tz) if seg.sure_time else None, + 'start_price': seg.start_bi.start_klc.low if convert_direction(seg.dir) == 1 else seg.start_bi.start_klc.high, + 'direction': convert_direction(seg.dir) + } + + if is_last: + # 最后一个未完成线段:没有结束时间和价格 + seg_data['end_time'] = None + seg_data['end_price'] = None + else: + # 倒数第二个及之前的未完成线段:使用实际的结束时间和价格 + if seg.end_bi and seg.end_bi.end_klc: + seg_data['end_time'] = seg.end_bi.end_klc.end_time if isinstance(seg.end_bi.end_klc.end_time, str) else seg.end_bi.end_klc.end_time.astimezone(client_tz).isoformat() + seg_data['end_price'] = seg.end_bi.end_klc.high if convert_direction(seg.dir) == 1 else seg.end_bi.end_klc.low + else: + # 如果没有结束笔,设为None + seg_data['end_time'] = None + seg_data['end_price'] = None + + result.append(seg_data) + + return result diff --git a/web/services/serializers.py b/web/services/serializers.py new file mode 100644 index 0000000..cbd66f6 --- /dev/null +++ b/web/services/serializers.py @@ -0,0 +1,8 @@ +"""序列化与 JSON 清洗。""" +from services.runtime import ( # noqa: F401 + convert_direction, + format_time_safely, + serialize_chan_macd_data, + clean_dataframe_for_json, + get_uncompleted_seg_list, +) diff --git a/web/services/timeframes.py b/web/services/timeframes.py new file mode 100644 index 0000000..f6a8075 --- /dev/null +++ b/web/services/timeframes.py @@ -0,0 +1,11 @@ +"""时间周期工具。""" +from services.runtime import ( # noqa: F401 + timeframe_to_minutes, + format_timeframe_label, + build_timeframe_labels, + compute_timeframe_defaults, + is_smaller_timeframe, + is_smaller_or_equal_timeframe, + DEFAULT_TIMEFRAME_LABELS, + TIMEFRAMES, +) diff --git a/web/static/js/TradingViewChart.tsx b/web/static/js/_unused/TradingViewChart.tsx similarity index 100% rename from web/static/js/TradingViewChart.tsx rename to web/static/js/_unused/TradingViewChart.tsx diff --git a/web/static/js/chanIndicator.ts b/web/static/js/_unused/chanIndicator.ts similarity index 100% rename from web/static/js/chanIndicator.ts rename to web/static/js/_unused/chanIndicator.ts diff --git a/web/static/js/app/api_client.js b/web/static/js/app/api_client.js new file mode 100644 index 0000000..c3c3e84 --- /dev/null +++ b/web/static/js/app/api_client.js @@ -0,0 +1,21 @@ +/* Chan web API client helpers */ +window.ChanApi = { + analyze: function(params) { + const q = new URLSearchParams(params); + return fetch('/api/analyze?' + q.toString()).then(r => r.json()); + }, + chartMetadata: function() { + return fetch('/api/chart_metadata').then(r => r.json()); + }, + symbols: function() { + return fetch('/api/symbols').then(r => r.json()); + }, + macdConfig: function(body) { + if (body === undefined) return fetch('/api/macd_config').then(r => r.json()); + return fetch('/api/macd_config', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(body) + }).then(r => r.json()); + } +}; diff --git a/web/static/js/app/chan_engine.js b/web/static/js/app/chan_engine.js new file mode 100644 index 0000000..b8e3716 --- /dev/null +++ b/web/static/js/app/chan_engine.js @@ -0,0 +1,1373 @@ +/** + * 缠论计算引擎 — 纯前端 JavaScript 实现 + * + * 严格参照 Python 实现 (TF_DF.py, ChanBI.py, ChanSBI.py) + * 输入: OHLCV bar 数组 [{timestamp, open, high, low, close, volume}, ...] + * 输出: ChanSlice 格式 {bis, segs, zs, segzs, bsps, seg_bsps} + */ + +var MIN_BI_KLC = 3 // Python: bi_klc_min = 3 + +// ======================== 包含处理 (KLC) ======================== + +/** + * 判断两根 K 线是否存在包含关系(a 完全包容 b) + * a 包含 b: a.high >= b.high && a.low <= b.low + */ +function hasInclusion(a, b) { + return a.high >= b.high && a.low <= b.low +} + +/** + * 包含处理 — 参照 Python get_klc_list() (TF_DF.py:637-655) + * + * 方向决定规则 (Python line 639): + * last_klc.high < klu.high → UP, 否则 DOWN + * 第一根 KLC: open <= close → UP, 否则 DOWN + * + * 合并规则 (Python ChanKLC.check_klu_included): + * UP: 取 max(high), max(low) — 把 low 抬高 + * DOWN: 取 min(high), min(low) — 把 high 压低 + */ +function inclusionMerge(bars) { + if (bars.length < 2) return bars.map(function (b, i) { + return { idx: i, dir: 0, fx: null, timestamp: b.timestamp, datetime: b.datetime, + open: b.open, high: b.high, low: b.low, close: b.close, volume: b.volume } + }) + + var klcList = [] + + // 第一根 KLC: 方向基于 open vs close (Python line 650-653) + var first = bars[0] + var firstDir = first.open <= first.close ? 1 : -1 + klcList.push({ + idx: 0, dir: firstDir, fx: null, + timestamp: first.timestamp, datetime: first.datetime, + open: first.open, high: first.high, low: first.low, close: first.close, + volume: first.volume, _barCount: 1, + }) + + for (var i = 1; i < bars.length; i++) { + var lastKlc = klcList[klcList.length - 1] + var cur = bars[i] + + if (hasInclusion(lastKlc, cur) || hasInclusion(cur, lastKlc)) { + // 包含关系:按上一 KLC 的方向合并 + var mergeDir = lastKlc.dir + if (mergeDir === 1) { + // UP: 取 max + lastKlc.high = Math.max(lastKlc.high, cur.high) + lastKlc.low = Math.max(lastKlc.low, cur.low) + } else { + // DOWN: 取 min + lastKlc.high = Math.min(lastKlc.high, cur.high) + lastKlc.low = Math.min(lastKlc.low, cur.low) + } + lastKlc.close = cur.close + lastKlc.volume += cur.volume + lastKlc.timestamp = cur.timestamp + lastKlc.datetime = cur.datetime + lastKlc._barCount = (lastKlc._barCount || 1) + 1 + } else { + // 无包含关系:创建新 KLC + var newDir = cur.high > lastKlc.high ? 1 : -1 // Python line 639 + klcList.push({ + idx: klcList.length, dir: newDir, fx: null, + timestamp: cur.timestamp, datetime: cur.datetime, + open: cur.open, high: cur.high, low: cur.low, close: cur.close, + volume: cur.volume, _barCount: 1, + }) + } + } + + return klcList +} + + +// ======================== 分型检测 (保留,供外部使用) ======================== + +/** + * 独立分型检测 — TF_DF.check_fx() 条件: + * TOP: klc.high > pre.high && klc.high > next.high + * && klc.low > pre.low && klc.low > next.low + * BOTTOM: klc.low < pre.low && klc.low < next.low + * && klc.high < pre.high && klc.high < next.high + */ +function findFractals(klcList) { + if (klcList.length < 3) return [] + var fractals = [] + for (var i = 1; i < klcList.length - 1; i++) { + var prev = klcList[i - 1], curr = klcList[i], next = klcList[i + 1] + if (curr.low < prev.low && curr.low < next.low && + curr.high < prev.high && curr.high < next.high) { + fractals.push({ idx: i, klcIdx: curr.idx, type: 'BOTTOM', timestamp: curr.timestamp, + datetime: curr.datetime, price: curr.low, high: curr.high, low: curr.low }) + curr.fx = 'BOTTOM' + } + if (curr.high > prev.high && curr.high > next.high && + curr.low > prev.low && curr.low > next.low) { + fractals.push({ idx: i, klcIdx: curr.idx, type: 'TOP', timestamp: curr.timestamp, + datetime: curr.datetime, price: curr.high, high: curr.high, low: curr.low }) + curr.fx = 'TOP' + } + } + return fractals +} + + +// ======================== 笔 (BI) 检测 ======================== + +/** + * 缺口测试 — Python TF_DF.check_top_fx (line 1277-1280) + * 当 last_bottom 的高点与候选顶分型两侧的低点重叠时,拒绝该顶分型 + */ +function checkTopFx(lastBottom, klc, pre, next) { + if ((lastBottom.high > pre.low || lastBottom.high > next.low) && + (klc.idx - lastBottom.idx < 100)) { + return false + } + return true +} + +/** + * 缺口测试 — Python TF_DF.check_bottom_fx (line 1282-1285) + */ +function checkBottomFx(lastTop, klc, pre, next) { + if ((lastTop.low < pre.high || lastTop.low < next.high) && + (klc.idx - lastTop.idx < 100)) { + return false + } + return true +} + +/** + * 将内部 bi 列表转换为输出 strokes 格式 + */ +function _biListToStrokes(biList) { + var strokes = [] + for (var i = 0; i < biList.length; i++) { + var bi = biList[i] + if (!bi._startKlc) continue + + var endKlc = bi._endKlc + if (!endKlc && bi._klcList && bi._klcList.length > 0) { + endKlc = bi._klcList[bi._klcList.length - 1] + } + // 末笔回退: 用 startKlc 作为终点 (至少显示一个点) + if (!endKlc) { + endKlc = bi._startKlc + } + + var isUp = bi.dir === 'UP' + strokes.push({ + idx: strokes.length, + t0: bi._startKlc.timestamp, + t1: endKlc.timestamp, + p0: isUp ? bi._startKlc.low : bi._startKlc.high, + p1: isUp ? endKlc.high : endKlc.low, + dir: isUp ? 1 : -1, + sure: bi._isSure || false, + startIdx: bi._startKlc.idx, + endIdx: endKlc.idx, + startType: isUp ? 'BOTTOM' : 'TOP', + endType: isUp ? 'TOP' : 'BOTTOM', + }) + } + return strokes +} + +function _addKlcToLastBi(biList, klc) { + if (biList.length > 0) { + biList[biList.length - 1]._klcList.push(klc) + } +} + +/** + * 笔检测 — 严格匹配 Python cal_bi_list() (TF_DF.py:946-1255) + * + * 遍历每一根 KLC,逐根检测分型 + 缺口测试 + 状态机处理。 + * 使用 lastTop/lastBottom 追踪,在满足结合律时确认笔。 + * + * @param {Array} klcList — KLC 数组 + * @returns {Array} strokes — 符合输出格式的笔列表 + */ +function findStrokes(klcList) { + if (klcList.length < 3) return [] + + var biList = [] + var lastTop = null // KLC 引用 + var lastBottom = null // KLC 引用 + + for (var i = 1; i < klcList.length - 1; i++) { + var klc = klcList[i] + var pre = klcList[i - 1] + var next = klcList[i + 1] + + // Step 1: 分型检测 — TF_DF.check_fx() + var fx = null + if (klc.high > pre.high && klc.high > next.high && + klc.low > pre.low && klc.low > next.low) { + fx = 'TOP' + } else if (klc.low < pre.low && klc.low < next.low && + klc.high < pre.high && klc.high < next.high) { + fx = 'BOTTOM' + } + + // Step 2: 缺口测试 — TF_DF.check_top_fx / check_bottom_fx + if (fx === 'TOP' && lastBottom) { + if (!checkTopFx(lastBottom, klc, pre, next)) { + fx = null + } + } + if (fx === 'BOTTOM' && lastTop) { + if (!checkBottomFx(lastTop, klc, pre, next)) { + fx = null + } + } + + // Step 3: 非分型 — 添加到当前笔 (Python line 967-971) + if (!fx) { + _addKlcToLastBi(biList, klc) + continue + } + + // 标记 KLC + klc.fx = fx + + // === 处理顶分型 === + if (fx === 'TOP') { + if (lastTop) { + if (lastBottom) { + if (lastBottom.idx < lastTop.idx) { + // 正常交替:底在前,顶在后 (Python line 1021-1037) + if (lastTop.high > klc.high) { + // 新顶更低 → 二类卖点,保持 lastTop + _addKlcToLastBi(biList, klc) + } else { + // 新顶更高 → 替换 lastTop (一类卖点) + lastTop = klc + _addKlcToLastBi(biList, klc) + } + } else { + // 不满足结合律:顶在前,底在后 (Python line 1038-1093) + if (lastBottom.idx + MIN_BI_KLC > klc.idx) { + // KLC 间距太小 (Python line 1042-1075) + if (lastTop.high > klc.high) { + _addKlcToLastBi(biList, klc) + } else { + // 无效顶分型 (Python line 1075) + _addKlcToLastBi(biList, klc) + } + } else { + // 满足结合律:确认上一个向下笔,开启新的向上笔 (Python line 1078-1093) + var lastBi = biList[biList.length - 1] + if (!lastBi._isSure) { + lastBi._endKlc = lastBottom + lastBi._isSure = true + } + var newBi = { + dir: 'UP', _startKlc: lastBottom, _endKlc: null, + _isSure: false, _klcList: [klc], idx: biList.length, + } + biList.push(newBi) + lastTop = klc + } + } + } else { + // lastBottom 为空:初始化阶段 (Python line 1095-1109) + if (lastTop.high < klc.high) { + // 新顶更高 → 更新笔起点 + var bi = biList[biList.length - 1] + bi._startKlc = klc + bi.dir = 'DOWN' + lastTop = klc + bi._klcList.push(klc) + } else { + _addKlcToLastBi(biList, klc) + } + } + } else { + // lastTop 为空 (Python line 1110-1133) + if (lastBottom) { + if (lastBottom.idx + MIN_BI_KLC > klc.idx) { + // 间距太小 (Python line 1114-1118) + _addKlcToLastBi(biList, klc) + } else { + // 第一个临时顶 (Python line 1120-1124) + lastTop = klc + _addKlcToLastBi(biList, klc) + } + } else { + // 第一个分型 → 创建第一个向下笔 (Python line 1126-1133) + lastTop = klc + var bi = { + dir: 'DOWN', _startKlc: klc, _endKlc: null, + _isSure: false, _klcList: [klc], idx: 0, + } + biList.push(bi) + } + } + } + + // === 处理底分型 (镜像) === + else { + if (lastBottom) { + if (lastTop) { + if (lastTop.idx < lastBottom.idx) { + // 正常交替:顶在前,底在后 (Python line 1139-1155) + if (lastBottom.low < klc.low) { + // 新底更高 → 二类买点,保持 lastBottom + _addKlcToLastBi(biList, klc) + } else { + // 新底更低 → 替换 lastBottom (一类买点) + lastBottom = klc + _addKlcToLastBi(biList, klc) + } + } else { + // 不满足结合律:底在前,顶在后 (Python line 1157-1208) + if (lastTop.idx + MIN_BI_KLC > klc.idx) { + // KLC 间距太小 (Python line 1160-1190) + if (lastBottom.low < klc.low) { + _addKlcToLastBi(biList, klc) + } else { + // 无效底分型 (Python line 1190) + _addKlcToLastBi(biList, klc) + } + } else { + // 满足结合律:确认上一个向上笔,开启新的向下笔 (Python line 1192-1208) + var lastBi = biList[biList.length - 1] + if (!lastBi._isSure) { + lastBi._endKlc = lastTop + lastBi._isSure = true + } + var newBi = { + dir: 'DOWN', _startKlc: lastTop, _endKlc: null, + _isSure: false, _klcList: [klc], idx: biList.length, + } + biList.push(newBi) + lastBottom = klc + } + } + } else { + // lastTop 为空:初始化阶段 (Python line 1210-1225) + if (lastBottom.low > klc.low) { + // 新底更低 → 更新笔起点 + var bi = biList[biList.length - 1] + bi._startKlc = klc + bi.dir = 'UP' + lastBottom = klc + bi._klcList.push(klc) + } else { + _addKlcToLastBi(biList, klc) + } + } + } else { + // lastBottom 为空 (Python line 1227-1251) + if (lastTop) { + if (lastTop.idx + MIN_BI_KLC > klc.idx) { + // 间距太小 (Python line 1230-1234) + _addKlcToLastBi(biList, klc) + } else { + // 第一个临时底 (Python line 1236-1240) + lastBottom = klc + _addKlcToLastBi(biList, klc) + } + } else { + // 第一个分型 → 创建第一个向上笔 (Python line 1242-1251) + lastBottom = klc + var bi = { + dir: 'UP', _startKlc: klc, _endKlc: null, + _isSure: false, _klcList: [klc], idx: 0, + } + biList.push(bi) + } + } + } + } + + return _biListToStrokes(biList) +} + + +// ======================== 线段 (SEG) 检测 ======================== + +/** + * 线段检测 — 特征序列 (SBI) 算法 + * + * 严格参照 Python get_seg_list() (TF_DF.py:660-938) 和 ChanSBI.py + * + * 核心机制: + * 1. 特征序列: UP 段取 DOWN 笔序列, DOWN 段取 UP 笔序列 + * 2. SBI 包含处理: 特征序列元素间存在包含关系时合并 + * 3. SBI 分型: 3+ 特征序列元素形成顶/底分型 → 段结束 + * 4. 缺口: 分型无重叠时触发 look_for 标志,确认下一段 + */ + +// ---- SBI (Special BI / 特征序列元素) ---- +function _makeSBI(bi, idx) { + return { + startBi: bi, + endBi: null, + idx: idx, + dir: bi.dir, + high: Math.max(bi.p0, bi.p1), + low: Math.min(bi.p0, bi.p1), + pre: null, + next: null, + fx: null, // 'TOP' | 'BOTTOM' | null + hasFxGap: false, + biList: [bi], + } +} + +/** + * SBI 包含检查 — 匹配 ChanSBI.check_bi_included() + * + * 条件: sbi.high > bi.high && sbi.low < bi.low (sbi 严格包容 bi) + * 且 sbi 也包容 sbi.pre (双重确认) + * + * 包含时更新: + * DOWN SBI (UP段特征序列): sbi.low = bi.low (gn>gn-1, 取更高的 low) + * UP SBI (DOWN段特征序列): sbi.high = bi.high (gn biHigh && sbi.low < biLow) { + included = true + } + + if (included && sbi.pre) { + // Python: pre check only reaffirms, never rejects + // if self.high > self.pre.high and self.low < self.pre.low: included = True + if (sbi.high > sbi.pre.high && sbi.low < sbi.pre.low) { + included = true + } + } + + if (included) { + sbi.biList.push(bi) + if (sbi.dir === -1) { + // DOWN SBI: gn > gn-1 → low 取 bi.low (更高的 low) + sbi.low = biLow + } else { + // UP SBI: gn < gn-1 → high 取 bi.high (更低的 high) + sbi.high = biHigh + } + } + return included +} + +/** + * SBI 分型检测 — 匹配 ChanSBI.check_fx() + * + * 需要 pre, self, next 三个 SBI + * TOP: self.high > pre.high && self.high > next.high + * 缺口: self.low > self.pre.high + * BOTTOM: self.low < pre.low && self.low < next.low + * 缺口: self.high < self.pre.low + */ +function _checkSBIFx(sbi) { + if (!sbi.pre || !sbi.next) return null + + if (sbi.high > sbi.pre.high && sbi.high > sbi.next.high) { + sbi.fx = 'TOP' + if (sbi.low > sbi.pre.high) { + sbi.hasFxGap = true + } + return 'TOP' + } + if (sbi.low < sbi.pre.low && sbi.low < sbi.next.low) { + sbi.fx = 'BOTTOM' + if (sbi.high < sbi.pre.low) { + sbi.hasFxGap = true + } + return 'BOTTOM' + } + return null +} + +/** + * 检查笔是否满足第一段起始条件 — 匹配 ChanBI.check_overlap() + * bi 必须有 next 和 next.next 且都已确认 + */ +function _checkOverlap(bi) { + if (!bi || !bi.next || !bi.next.next) return false + // 简化: 笔已确认且有足够的后续笔即可形成第一段 + // Python 原始条件要求 next.next.is_sure + if (bi.dir === 1) { + // UP bi: high > next.low && high < next.next.high + var biHigh = Math.max(bi.p0, bi.p1) + var nextLow = Math.min(bi.next.p0, bi.next.p1) + var nextNextHigh = Math.max(bi.next.next.p0, bi.next.next.p1) + return biHigh > nextLow && biHigh < nextNextHigh + } else { + // DOWN bi: high > next.high && low > next.next.low + var biHigh = Math.max(bi.p0, bi.p1) + var biLow = Math.min(bi.p0, bi.p1) + var nextHigh = Math.max(bi.next.p0, bi.next.p1) + var nextNextLow = Math.min(bi.next.next.p0, bi.next.next.p1) + return biHigh > nextHigh && biLow > nextNextLow + } +} + +/** + * 从 seg 对象生成输出格式 + * p0/p1 使用起始笔和结束笔的价格 (匹配 Python: seg.low=start_bi.low, seg.high=end_bi.high) + */ +function _segToOutput(seg, idx) { + var startBi = seg.startBi + var endBi = seg.endBi || seg.biList[seg.biList.length - 1] + if (!startBi || !endBi) { + startBi = seg.biList[0] + endBi = seg.biList[seg.biList.length - 1] + } + + var p0, p1 + if (seg.dir === 1) { + // UP 段: p0 = startBi.low (起始底), p1 = endBi.high (结束顶) + p0 = Math.min(startBi.p0, startBi.p1) + p1 = Math.max(endBi.p0, endBi.p1) + } else { + // DOWN 段: p0 = startBi.high (起始顶), p1 = endBi.low (结束底) + p0 = Math.max(startBi.p0, startBi.p1) + p1 = Math.min(endBi.p0, endBi.p1) + } + + return { + idx: idx, + dir: seg.dir, + sure: seg.isSure || false, + t0: startBi.t0, + t1: endBi.t1, + p0: p0, + p1: p1, + } +} + +function findSegments(strokes) { + if (strokes.length < 1) return [] + + // 建立双向链表 (Python bi.next / bi.pre) + for (var i = 0; i < strokes.length; i++) { + strokes[i].next = i + 1 < strokes.length ? strokes[i + 1] : null + strokes[i].pre = i > 0 ? strokes[i - 1] : null + } + + // 辅助: 填充 seg.biList 从 startBiIdx 到 currentBiIdx (匹配 Python ini_seg) + function _fillSegBiList(seg, startIdx, endIdx) { + seg.biList = [] + for (var j = startIdx; j <= endIdx && j < strokes.length; j++) { + seg.biList.push(strokes[j]) + } + } + + // 安全获取 stroke + function _safeStroke(idx) { + if (idx < 0) return strokes[0] + if (idx >= strokes.length) return strokes[strokes.length - 1] + return strokes[idx] + } + + var segList = [] + var upSbiList = [] + var downSbiList = [] + var lastUpBi = null + var lastDownBi = null + var lastUpSbi = null + var lastDownSbi = null + var lastSeg = null + var lookForBottom = false + var lookForTop = false + + for (var i = 0; i < strokes.length; i++) { + var bi = strokes[i] + + if (segList.length > 0) { + // ===== 已有段: 根据段方向处理 ===== + if (lastSeg.dir === 1) { + // ---- 当前是 UP 段,特征序列是 DOWN 笔 ---- + if (bi.dir === -1) { + // 反向笔 → 添加到特征序列 + if (downSbiList.length > 1) { + var included = _checkSBIInclusion(lastDownSbi, bi) + if (!included) { + var downSbi = _makeSBI(bi, downSbiList.length) + lastDownSbi.next = downSbi + lastDownSbi.endBi = lastDownBi + downSbi.pre = lastDownSbi + downSbiList.push(downSbi) + + var fx = _checkSBIFx(lastDownSbi) + if (fx === 'TOP') { + // 特征序列顶分型 → UP 段可能结束 + if (lookForTop) { + // 确认上一段(第二段前) + if (segList.length >= 2) { + segList[segList.length - 2].isSure = true + } + lookForTop = false + } + + if (lastDownSbi.hasFxGap) { + // 有缺口 → 段结束 + 新反向段开始 + // Python pre_set_end_bi: 不设 is_sure, 等下一段确认 + lookForBottom = true + lastSeg.endBi = _safeStroke(lastDownSbi.startBi.idx - 1) + // lastSeg.isSure stays false (gap segments are unsure until confirmed) + var newSeg = { + startBi: lastDownSbi.startBi, + dir: -1, + biList: [], + isSure: false, + endBi: bi, + } + _fillSegBiList(newSeg, lastDownSbi.startBi.idx, bi.idx) + segList.push(newSeg) + lastSeg.next = newSeg + newSeg.pre = lastSeg + lastSeg = newSeg + + // 重置 up_sbi_list + upSbiList = [] + lastUpSbi = _makeSBI(lastUpBi, 0) + upSbiList.push(lastUpSbi) + } else { + // 无缺口 + if (lookForBottom) { + // Python: 不创建新段, 调整当前段起点, 关闭前一段 + lookForBottom = false + lastSeg.startBi = lastDownSbi.startBi + if (segList.length >= 2) { + segList[segList.length - 2].endBi = _safeStroke(lastDownSbi.startBi.idx - 1) + // seg_list[-2].set_end_bi 会设 is_sure + // 但需要检查 endBi 是否为 sure... 这里简化, set_end_bi 总是设 sure + } + upSbiList = [] + lastUpSbi = _makeSBI(lastUpBi, 0) + upSbiList.push(lastUpSbi) + lastSeg.biList.push(bi) + } else { + // 正常段结束 — Python set_end_bi: 设 is_sure = True + lastSeg.endBi = _safeStroke(lastDownSbi.startBi.idx - 1) + lastSeg.isSure = true + var newSeg = { + startBi: lastDownSbi.startBi, + dir: -1, + biList: [], + isSure: false, + endBi: bi, + } + _fillSegBiList(newSeg, lastDownSbi.startBi.idx, bi.idx) + segList.push(newSeg) + lastSeg.next = newSeg + newSeg.pre = lastSeg + lastSeg = newSeg + + upSbiList = [] + lastUpSbi = _makeSBI(lastUpBi, 0) + upSbiList.push(lastUpSbi) + } + } + } + lastDownSbi = downSbi + } + lastSeg.biList.push(bi) + } else if (downSbiList.length === 1) { + var included = _checkSBIInclusion(lastDownSbi, bi) + if (!included) { + var downSbi = _makeSBI(bi, downSbiList.length) + lastDownSbi.next = downSbi + lastDownSbi.endBi = lastDownBi + downSbi.pre = lastDownSbi + downSbiList.push(downSbi) + lastDownSbi = downSbi + } + lastSeg.biList.push(bi) + } else { + // downSbiList.length === 0 + lastDownSbi = _makeSBI(bi, 0) + downSbiList.push(lastDownSbi) + lastSeg.biList.push(bi) + } + } else { + // bi.dir === UP (同向笔) → 添加到 up_sbi_list + if (lastUpSbi) { + var included = _checkSBIInclusion(lastUpSbi, bi) + if (!included) { + var upSbi = _makeSBI(bi, upSbiList.length) + lastUpSbi.next = upSbi + lastUpSbi.endBi = lastUpBi + upSbi.pre = lastUpSbi + upSbiList.push(upSbi) + lastUpSbi = upSbi + } + lastSeg.biList.push(bi) + } + } + } else { + // ---- 当前是 DOWN 段,特征序列是 UP 笔 ---- + if (bi.dir === 1) { + // 反向笔 → 添加到特征序列 + if (upSbiList.length > 1) { + var included = _checkSBIInclusion(lastUpSbi, bi) + if (!included) { + var upSbi = _makeSBI(bi, upSbiList.length) + lastUpSbi.next = upSbi + lastUpSbi.endBi = lastUpBi + upSbi.pre = lastUpSbi + upSbiList.push(upSbi) + + var fx = _checkSBIFx(lastUpSbi) + if (fx === 'BOTTOM') { + // 特征序列底分型 → DOWN 段可能结束 + if (lookForBottom) { + if (segList.length >= 2) { + segList[segList.length - 2].isSure = true + } + lookForBottom = false + } + + if (lastUpSbi.hasFxGap) { + // 有缺口 → 段结束 + 新反向段开始 + // Python pre_set_end_bi: 不设 is_sure, 等下一段确认 + lookForTop = true + lastSeg.endBi = _safeStroke(lastUpSbi.startBi.idx - 1) + // lastSeg.isSure stays false (gap segments are unsure) + var newSeg = { + startBi: lastUpSbi.startBi, + dir: 1, + biList: [], + isSure: false, + endBi: bi, + } + _fillSegBiList(newSeg, lastUpSbi.startBi.idx, bi.idx) + segList.push(newSeg) + lastSeg.next = newSeg + newSeg.pre = lastSeg + lastSeg = newSeg + + // 重置 down_sbi_list + downSbiList = [] + lastDownSbi = _makeSBI(lastDownBi, 0) + downSbiList.push(lastDownSbi) + } else { + // 无缺口 + if (lookForTop) { + // Python: 不创建新段, 调整当前段起点 + lookForTop = false + lastSeg.startBi = lastUpSbi.startBi + if (segList.length >= 2) { + segList[segList.length - 2].endBi = _safeStroke(lastUpSbi.startBi.idx - 1) + } + downSbiList = [] + lastDownSbi = _makeSBI(lastDownBi, 0) + downSbiList.push(lastDownSbi) + lastSeg.biList.push(bi) + } else { + // 正常段结束 — Python set_end_bi: 设 is_sure = True + lastSeg.endBi = _safeStroke(lastUpSbi.startBi.idx - 1) + lastSeg.isSure = true + var newSeg = { + startBi: lastUpSbi.startBi, + dir: 1, + biList: [], + isSure: false, + endBi: bi, + } + _fillSegBiList(newSeg, lastUpSbi.startBi.idx, bi.idx) + segList.push(newSeg) + lastSeg.next = newSeg + newSeg.pre = lastSeg + lastSeg = newSeg + + downSbiList = [] + lastDownSbi = _makeSBI(lastDownBi, 0) + downSbiList.push(lastDownSbi) + } + } + } + lastUpSbi = upSbi + } + lastSeg.biList.push(bi) + } else if (upSbiList.length === 1) { + var included = _checkSBIInclusion(lastUpSbi, bi) + if (!included) { + var upSbi = _makeSBI(bi, upSbiList.length) + lastUpSbi.next = upSbi + lastUpSbi.endBi = lastUpBi + upSbi.pre = lastUpSbi + upSbiList.push(upSbi) + lastUpSbi = upSbi + } + lastSeg.biList.push(bi) + } else { + // upSbiList.length === 0 + lastUpSbi = _makeSBI(bi, 0) + upSbiList.push(lastUpSbi) + lastSeg.biList.push(bi) + } + } else { + // bi.dir === DOWN (同向笔) → 添加到 down_sbi_list + if (lastDownSbi) { + var included = _checkSBIInclusion(lastDownSbi, bi) + if (!included) { + var downSbi = _makeSBI(bi, downSbiList.length) + lastDownSbi.next = downSbi + lastDownSbi.endBi = lastDownBi + downSbi.pre = lastDownSbi + downSbiList.push(downSbi) + lastDownSbi = downSbi + } + lastSeg.biList.push(bi) + } + } + } + } else { + // ===== 第一段: 需要 check_overlap ===== + if (_checkOverlap(bi)) { + if (bi.dir === 1) { + var seg = { + startBi: bi, + dir: 1, + biList: [], + isSure: false, + endBi: bi, + } + _fillSegBiList(seg, bi.idx, bi.idx) + lastUpSbi = _makeSBI(bi, 0) + upSbiList.push(lastUpSbi) + segList.push(seg) + lastSeg = seg + } else { + var seg = { + startBi: bi, + dir: -1, + biList: [], + isSure: false, + endBi: bi, + } + _fillSegBiList(seg, bi.idx, bi.idx) + lastDownSbi = _makeSBI(bi, 0) + downSbiList.push(lastDownSbi) + segList.push(seg) + lastSeg = seg + } + } + } + + // 更新 last bi 追踪 (Python line 880-885) + if (bi.dir === 1) { + lastUpBi = bi + } else { + lastDownBi = bi + } + } + + // Python: 不在此处标记 sure + // - 正常结束: set_end_bi → isSure=true (已在上面设置) + // - 跳空结束: pre_set_end_bi → isSure=false, 等 look_for 确认 + // - 最后一段: 永远 unsure + + // 转换为输出格式 + var result = [] + for (var s = 0; s < segList.length; s++) { + result.push(_segToOutput(segList[s], s)) + } + + console.log('[SBI段] 总计:', result.length, '段 from', strokes.length, '笔') + + return result +} + + +// ======================== 中枢 (ZS) 检测 ======================== + +/** + * 中枢 (Center/Pivot): 至少 3 段走势重叠的价格区间 + * + * ZG = min(各段的高点), ZD = max(各段的低点) + * 条件: ZG > ZD + * 后续段若仍在区间内,扩展中枢 + */ +/** + * 笔中枢检测 — 从 stroke 列表按 3 笔重叠规则计算笔级别中枢 + * + * 参照 Python cal_bi_zs_list() (TF_DF.py:1295-1424): + * - 从第4根笔开始(索引3),每3根确认笔为一组 + * - 上涨中枢:bi1.dir=DOWN, bi2.dir=UP, bi3.dir=DOWN → zg=min(highs), zd=max(lows) + * - 下跌中枢:bi1.dir=UP, bi2.dir=DOWN, bi3.dir=UP + * - 后中枢不重叠前中枢:UP_ZS → zg > last.zg; DOWN_ZS → zd < last.zd + * - 按两笔一组扩展到5根、7根... + * - 追踪 GG/DD 与 ZG/ZD + */ +function findBiCenters(biList) { + var zsList = [] + if (biList.length < 3) return zsList + + var lastZs = null + var startIdx = 3 + + while (startIdx + 2 < biList.length) { + var bi1 = biList[startIdx] + var bi2 = biList[startIdx + 1] + var bi3 = biList[startIdx + 2] + + // 三笔必须全部确认 + if (!(bi1.sure && bi2.sure && bi3.sure)) { + startIdx++ + continue + } + + var h1 = Math.max(bi1.p0, bi1.p1), l1 = Math.min(bi1.p0, bi1.p1) + var h2 = Math.max(bi2.p0, bi2.p1), l2 = Math.min(bi2.p0, bi2.p1) + var h3 = Math.max(bi3.p0, bi3.p1), l3 = Math.min(bi3.p0, bi3.p1) + + var zg = Math.min(h1, h2, h3) + var zd = Math.max(l1, l2, l3) + + if (zg <= zd) { startIdx++; continue } + + // 方向判定 + var valid = false + var zsDir = 0 // 1=UP, -1=DOWN + + if (!lastZs) { + // 首个中枢 + if (bi1.dir === -1 && bi2.dir === 1 && bi3.dir === -1) { + zsDir = 1 // UP_ZS + valid = true + } else if (bi1.dir === 1 && bi2.dir === -1 && bi3.dir === 1) { + zsDir = -1 // DOWN_ZS + valid = true + } + } else { + if (zg > lastZs.zg) { + zsDir = 1 // UP_ZS + valid = (bi1.dir === -1 && bi2.dir === 1 && bi3.dir === -1) + } else if (zd < lastZs.zd) { + zsDir = -1 // DOWN_ZS + valid = (bi1.dir === 1 && bi2.dir === -1 && bi3.dir === 1) + } + } + + if (!valid) { startIdx++; continue } + + var gg = Math.max(h1, h2, h3) + var dd = Math.min(l1, l2, l3) + var biList_for_zs = [bi1, bi2, bi3] + var endBiIdx = startIdx + 2 + + // 按两笔一组扩展 + var addedAfterLeave = [] + var leaveIdx = startIdx + 4 + while (leaveIdx < biList.length) { + var b = biList[leaveIdx] + if (!b.sure) break + if (Math.max(b.p0, b.p1) >= zd && Math.min(b.p0, b.p1) <= zg) { + addedAfterLeave.push(biList[leaveIdx - 1]) + addedAfterLeave.push(b) + } else { + break + } + leaveIdx += 2 + } + + if (addedAfterLeave.length > 0) { + biList_for_zs = biList_for_zs.concat(addedAfterLeave) + var highs = biList_for_zs.map(function(bi) { return Math.max(bi.p0, bi.p1) }) + var lows = biList_for_zs.map(function(bi) { return Math.min(bi.p0, bi.p1) }) + gg = Math.max.apply(null, highs) + dd = Math.min.apply(null, lows) + endBiIdx = startIdx + addedAfterLeave.length + } + + var zs = { + t0: bi1.t0, + t1: biList_for_zs[biList_for_zs.length - 1].t1, + high: zg, low: zd, + zg: zg, zd: zd, + gg: gg, dd: dd, + is_sure: biList_for_zs[biList_for_zs.length - 1].sure, + bi_count: biList_for_zs.length, + bi_list: biList_for_zs, // 中枢内的笔列表(按序) + start_bi_idx: startIdx, // 中枢首笔在总列表中的索引 + dir: zsDir, + pre: lastZs, + next: null, + } + + if (lastZs) { + lastZs.next = zs + } + + zsList.push(zs) + lastZs = zs + startIdx = startIdx + 4 + (addedAfterLeave.length > 0 ? addedAfterLeave.length : 0) + } + + // 末中枢确认 + if (lastZs && !lastZs.is_sure) { + var lastBiInZs = lastZs.bi_count > 0 ? biList_for_zs[biList_for_zs.length - 1] : null + if (lastBiInZs) { + var hasLeave = false + var lastBiIdx = biList.indexOf(lastBiInZs) + if (lastBiIdx >= 0) { + for (var i = lastBiIdx + 1; i < biList.length; i++) { + var bi = biList[i] + if (bi.sure) { + var bh = Math.max(bi.p0, bi.p1), bl = Math.min(bi.p0, bi.p1) + var leave = (bl > lastZs.zg && bh > lastZs.zg) || (bh < lastZs.zd && bl < lastZs.zd) + if (leave) { hasLeave = true; break } + } + } + } + if (hasLeave && lastBiInZs.sure) { + lastZs.t1 = lastBiInZs.t1 + } + } + } + + return zsList +} + +/** + * 段中枢检测 — 从 segment 列表按 3 段重叠规则计算段级别中枢 + * + * 参照 Python calculate_seg_zs() (TF_DF.py:1741+): + * - 从第4段开始(索引3),每3段为一组 + * - 上涨中枢:seg1.dir=DOWN, seg2.dir=UP, seg3.dir=DOWN + * - 下跌中枢:seg1.dir=UP, seg2.dir=DOWN, seg3.dir=UP + * - ZG = min(highs), ZD = max(lows),要求 ZG > ZD + * - 后中枢与前中枢不重叠 + * - 扩张/扩展检测 + */ +function findSegCenters(segs) { + var zsList = [] + if (segs.length < 3) return zsList + + var lastZs = null + var startIdx = 3 + + while (startIdx + 2 < segs.length) { + var s1 = segs[startIdx] + var s2 = segs[startIdx + 1] + var s3 = segs[startIdx + 2] + + if (!(s1.sure && s2.sure && s3.sure)) { + startIdx++ + continue + } + + var h1 = Math.max(s1.p0, s1.p1), l1 = Math.min(s1.p0, s1.p1) + var h2 = Math.max(s2.p0, s2.p1), l2 = Math.min(s2.p0, s2.p1) + var h3 = Math.max(s3.p0, s3.p1), l3 = Math.min(s3.p0, s3.p1) + + var zg = Math.min(h1, h2, h3) + var zd = Math.max(l1, l2, l3) + + if (zg <= zd) { startIdx++; continue } + + // 方向判定 + var valid = false + var zsDir = 0 + + if (!lastZs) { + if (s1.dir === -1 && s2.dir === 1 && s3.dir === -1) { + zsDir = 1; valid = true + } else if (s1.dir === 1 && s2.dir === -1 && s3.dir === 1) { + zsDir = -1; valid = true + } + } else { + if (zg > lastZs.zg) { + zsDir = 1 + valid = (s1.dir === -1 && s2.dir === 1 && s3.dir === -1) + } else if (zd < lastZs.zd) { + zsDir = -1 + valid = (s1.dir === 1 && s2.dir === -1 && s3.dir === 1) + } + } + + if (!valid) { startIdx++; continue } + + var gg = Math.max(h1, h2, h3) + var dd = Math.min(l1, l2, l3) + var endSegIdx = startIdx + 2 + + // 按两段一组扩展 + var addedSegs = [] + var extIdx = startIdx + 4 + while (extIdx < segs.length) { + var s = segs[extIdx] + if (!s.sure) break + if (Math.max(s.p0, s.p1) >= zd && Math.min(s.p0, s.p1) <= zg) { + addedSegs.push(segs[extIdx - 1]) + addedSegs.push(s) + } else { + break + } + extIdx += 2 + } + + var segList_for_zs = [s1, s2, s3] + if (addedSegs.length > 0) { + segList_for_zs = segList_for_zs.concat(addedSegs) + gg = Math.max.apply(null, segList_for_zs.map(function(s) { return Math.max(s.p0, s.p1) })) + dd = Math.min.apply(null, segList_for_zs.map(function(s) { return Math.min(s.p0, s.p1) })) + endSegIdx = startIdx + 2 + addedSegs.length + } + + var zs = { + t0: s1.t0, + t1: segs[endSegIdx].t1, + high: zg, low: zd, + zg: zg, zd: zd, + gg: gg, dd: dd, + is_sure: segs[endSegIdx].sure, + seg_count: segList_for_zs.length, + seg_list: segList_for_zs, // 中枢内的段列表(按序) + start_seg_idx: startIdx, // 中枢首段在总列表中的索引 + dir: zsDir, + pre: lastZs, + next: null, + } + + if (lastZs) lastZs.next = zs + + zsList.push(zs) + lastZs = zs + startIdx = startIdx + 4 + (addedSegs.length > 0 ? addedSegs.length : 0) + } + + return zsList +} + +/** @deprecated — use findBiCenters + findSegCenters */ +function findCenters(segs) { + var segZss = findSegCenters(segs) + return { zs: segZss, segzs: segZss } +} + + +// ======================== 买卖点 (BSP) 检测 ======================== + +/** + * 缠论三类买卖点 —— 基于缠论原定义 + * + * 一买:向下离开中枢的笔结束点(低于 ZD),与进入笔形成盘整背驰 → 位于中枢下方 + * 一卖:向上离开中枢的笔结束点(高于 ZG),与进入笔形成盘整背驰 → 位于中枢上方 + * 二买:一买后反弹再回调,底点不低于一买 → 位于中枢下方 + * 二卖:一卖后回调再反弹,顶点不高于一卖 → 位于中枢上方 + * 三买:向上离开中枢后回拉,回拉底点不破 ZG → 位于中枢上方 + * 三卖:向下离开中枢后反弹,反弹顶点不破 ZD → 位于中枢下方 + */ +function findBSP(strokes, biZsList, segZsList) { + var bsps = [] + var segBsps = [] + var bspsSeen = {} + var segBspsSeen = {} + + // ==================== 笔中枢买卖点 ==================== + if (biZsList && biZsList.length > 0) { + for (var zi = 0; zi < biZsList.length; zi++) { + var zs = biZsList[zi] + if (!zs.is_sure || !zs.bi_list || zs.bi_list.length < 3) continue + + // 中枢最后一笔在总笔列表中的索引 + var lastZsBi = zs.bi_list[zs.bi_list.length - 1] + var lastZsBiIdx = -1 + for (var k = 0; k < strokes.length; k++) { + if (strokes[k].t0 === lastZsBi.t0 && strokes[k].p0 === lastZsBi.p0) { + lastZsBiIdx = k + break + } + } + if (lastZsBiIdx < 0) continue + + // — 确定离开笔:中枢完成后第一根突破中枢区间的笔 — + var leaveBi = null + var leaveBiIdx = -1 + for (var j = lastZsBiIdx; j < strokes.length; j++) { + var bi = strokes[j] + if (!bi.sure) continue + var biHigh = Math.max(bi.p0, bi.p1) + var biLow = Math.min(bi.p0, bi.p1) + // 向上离开:高点突破 ZG(位于中枢上方) + if (bi.dir === 1 && biHigh > zs.zg) { leaveBi = bi; leaveBiIdx = j; break } + // 向下离开:低点跌破 ZD(位于中枢下方) + if (bi.dir === -1 && biLow < zs.zd) { leaveBi = bi; leaveBiIdx = j; break } + } + if (!leaveBi) continue + + var isUpLeave = leaveBi.dir === 1 + + // — 一类买卖点 — + // 一卖:向上离开,结束点在中枢上方 + // 一买:向下离开,结束点在中枢下方 + if (isUpLeave) { + var kS1 = leaveBi.t1 + '-bi-sell-T1P' + if (!bspsSeen[kS1]) { + bspsSeen[kS1] = true + bsps.push({ t: leaveBi.t1, price: leaveBi.p1, is_buy: false, types: ['T1P'], + reason: '一卖:向上离开中枢' }) + } + } else { + var kB1 = leaveBi.t1 + '-bi-buy-T1' + if (!bspsSeen[kB1]) { + bspsSeen[kB1] = true + bsps.push({ t: leaveBi.t1, price: leaveBi.p1, is_buy: true, types: ['T1'], + reason: '一买:向下离开中枢' }) + } + } + + // — 离开后第一笔(回拉/反弹)— + var nextBi = leaveBiIdx + 1 < strokes.length ? strokes[leaveBiIdx + 1] : null + if (nextBi && nextBi.sure) { + if (isUpLeave && nextBi.dir === -1) { + // 向上离开后向下回拉 → 潜在三买(位于中枢上方) + if (Math.min(nextBi.p0, nextBi.p1) >= zs.zg) { + var kB3 = nextBi.t1 + '-bi-buy-T3A' + if (!bspsSeen[kB3]) { + bspsSeen[kB3] = true + bsps.push({ t: nextBi.t1, price: nextBi.p1, is_buy: true, types: ['T3A'], + reason: '三买:回拉不进中枢' }) + } + } + } else if (!isUpLeave && nextBi.dir === 1) { + // 向下离开后向上反弹 → 潜在三卖(位于中枢下方) + if (Math.max(nextBi.p0, nextBi.p1) <= zs.zd) { + var kS3 = nextBi.t1 + '-bi-sell-T3B' + if (!bspsSeen[kS3]) { + bspsSeen[kS3] = true + bsps.push({ t: nextBi.t1, price: nextBi.p1, is_buy: false, types: ['T3B'], + reason: '三卖:反弹不进中枢' }) + } + } + } + + // — 离开后第二笔 → 二类买卖点 — + var secondBi = leaveBiIdx + 2 < strokes.length ? strokes[leaveBiIdx + 2] : null + if (secondBi && secondBi.sure) { + if (isUpLeave) { + // 一卖 → 回拉 → 再次向上:不创新高即二卖(位于中枢上方) + if (secondBi.dir === 1 && Math.max(secondBi.p0, secondBi.p1) < Math.max(leaveBi.p0, leaveBi.p1)) { + var kS2 = secondBi.t1 + '-bi-sell-T2S' + if (!bspsSeen[kS2]) { + bspsSeen[kS2] = true + bsps.push({ t: secondBi.t1, price: secondBi.p1, is_buy: false, types: ['T2S'], + reason: '二卖:反弹不创新高' }) + } + } + } else { + // 一买 → 反弹 → 再次向下:不创新低即二买(位于中枢下方) + if (secondBi.dir === -1 && Math.min(secondBi.p0, secondBi.p1) > Math.min(leaveBi.p0, leaveBi.p1)) { + var kB2 = secondBi.t1 + '-bi-buy-T2' + if (!bspsSeen[kB2]) { + bspsSeen[kB2] = true + bsps.push({ t: secondBi.t1, price: secondBi.p1, is_buy: true, types: ['T2'], + reason: '二买:回调不创新低' }) + } + } + } + } + } + } + } + + // ==================== 段中枢买卖点 ==================== + // 段中枢的买卖点同理笔中枢,使用段而非笔 + if (segZsList && segZsList.length > 0) { + for (var zj = 0; zj < segZsList.length; zj++) { + var szs = segZsList[zj] + if (!szs.is_sure || !szs.seg_list || szs.seg_list.length < 3) continue + + // 段中枢结束后第一笔突破中枢区间的笔 + var sLastSeg = szs.seg_list[szs.seg_list.length - 1] + var sLeaveBi = null + for (var sj = 0; sj < strokes.length; sj++) { + if (strokes[sj].t0 > sLastSeg.t1) { + sLeaveBi = strokes[sj] + break + } + } + if (!sLeaveBi || !sLeaveBi.sure) continue + + var slHigh = Math.max(sLeaveBi.p0, sLeaveBi.p1) + var slLow = Math.min(sLeaveBi.p0, sLeaveBi.p1) + var sLeaves = (sLeaveBi.dir === 1 && slHigh > szs.zg) || (sLeaveBi.dir === -1 && slLow < szs.zd) + if (!sLeaves) continue + + if (sLeaveBi.dir === 1) { + var skS1 = sLeaveBi.t1 + '-seg-sell-T1P' + if (!segBspsSeen[skS1]) { + segBspsSeen[skS1] = true + segBsps.push({ t: sLeaveBi.t1, price: sLeaveBi.p1, is_buy: false, types: ['T1P'], + reason: '段一卖:向上离开段中枢' }) + } + } else { + var skB1 = sLeaveBi.t1 + '-seg-buy-T1' + if (!segBspsSeen[skB1]) { + segBspsSeen[skB1] = true + segBsps.push({ t: sLeaveBi.t1, price: sLeaveBi.p1, is_buy: true, types: ['T1'], + reason: '段一买:向下离开段中枢' }) + } + } + } + } + + return { bsps: bsps, seg_bsps: segBsps } +} + + +// ======================== 主入口 ======================== + +/** + * 计算缠论结构 + * @param {Array} bars - OHLCV bar 数组 + * @returns {Object} ChanSlice {bis, segs, zs, segzs, bsps, seg_bsps} + */ +function computeChan(bars) { + if (!bars || bars.length < 10) { + return { bis: [], segs: [], zs: [], segzs: [], bsps: [], seg_bsps: [] } + } + + // Step 1: 包含处理 → KLC + var klcList = inclusionMerge(bars) + + // Step 2+3: 笔检测(内部包含分型检测 + 缺口测试) + var strokes = findStrokes(klcList) + + // Step 4: 线段检测 + var segs = findSegments(strokes) + + // Step 5: 中枢检测 + // 笔中枢:从笔列表计算(参照 cal_bi_zs_list) + var bi_zs = findBiCenters(strokes) + // 段中枢:从段列表计算(参照 calculate_seg_zs) + var seg_zs = findSegCenters(segs) + + // Step 6: 买卖点检测(基于笔中枢和段中枢) + var bspResult = findBSP(strokes, bi_zs, seg_zs) + var bsps = bspResult.bsps + var seg_bsps = bspResult.seg_bsps + + console.log('[缠论引擎] KLC:', klcList.length, + '笔:', strokes.length, + '段:', segs.length, + '笔中枢:', bi_zs.length, + '段中枢:', seg_zs.length, + 'BSP:', bsps.length) + + return { + bis: strokes, segs: segs, + zs: bi_zs, segzs: seg_zs.length > 0 ? seg_zs : bi_zs, + bsps: bsps, seg_bsps: seg_bsps, + } +} + +// Export +if (typeof module !== 'undefined' && module.exports) { + module.exports = { computeChan, inclusionMerge, findFractals, findStrokes, findSegments, findCenters, findBSP } +} diff --git a/web/static/js/app/chan_indicator.js b/web/static/js/app/chan_indicator.js new file mode 100644 index 0000000..d8e9383 --- /dev/null +++ b/web/static/js/app/chan_indicator.js @@ -0,0 +1,496 @@ +/** + * 缠论自定义指标 — TradingView Advanced Chart + * + * 从 chanIndicator.ts 转换为 vanilla JS。 + * 在 K 线上叠加:笔/段(实线+虚线)、中枢(填色区域)、买卖点(文字标签)。 + * + * 依赖: + * window.chanLookupHolder — 当前 Chan 结构数据 + * window.commitChanLookup — 累积合并新数据 + * window.makeChanIndicator — 创建 TV study 定义 + */ + +(function () { + 'use strict' + + // ---- BSP 子类型枚举 ---- + var BSP_SUBTYPES = ['T1', 'T1P', 'T2', 'T2S', 'T3A', 'T3B'] + + // ---- 全局状态: chanLookupHolder ---- + window.chanLookupHolder = { + current: null, + key: null, + } + + /** + * 累积/替换 chanLookup + * 同 key 累积合并(历史区间的 BSP 标签持续保留) + * 不同 key 整个替换 + */ + window.commitChanLookup = function (fresh, key) { + var holder = window.chanLookupHolder + if (holder.key !== key || !holder.current) { + holder.current = fresh + holder.key = key + return + } + // 同 key 合并 + var target = holder.current.byTimeMs + fresh.byTimeMs.forEach(function (e, t) { + var existed = target.get(t) + if (existed) { + Object.assign(existed, e) + } else { + target.set(t, e) + } + }) + } + + // ---- 工具函数 ---- + + function lowerBound(arr, v) { + var lo = 0, hi = arr.length + while (lo < hi) { + var mid = (lo + hi) >> 1 + if (arr[mid] < v) lo = mid + 1 + else hi = mid + } + return lo + } + + function upperBound(arr, v) { + var lo = 0, hi = arr.length + while (lo < hi) { + var mid = (lo + hi) >> 1 + if (arr[mid] <= v) lo = mid + 1 + else hi = mid + } + return lo + } + + /** + * 构建 ChanLookup:将 Chan 结构数据映射到每个 bar 的指标值 + * + * @param {Object} slice - ChanSlice {bis, segs, zs, segzs, bsps, seg_bsps} + * @param {Array} bars - OHLCV bars [{t: ms, h, l}, ...] + * @returns {Object} {byTimeMs: Map} + */ + window.buildChanLookup = function (slice, bars) { + var byTimeMs = new Map() + + function ensure(tsMs) { + // tsMs 已是毫秒(来自 data_provider 的 timestamp),无需再转换 + var key = tsMs + var e = byTimeMs.get(key) + if (!e) { + e = {} + byTimeMs.set(key, e) + } + return e + } + + var sortedBarTimes = bars.map(function (b) { return b.t }).sort(function (a, b) { return a - b }) + + // 线性插值填充笔/段到每个 bar + function fillLine(t0, t1, p0, p1, field) { + var lo = lowerBound(sortedBarTimes, t0) + var hi = upperBound(sortedBarTimes, t1) + var span = hi - 1 - lo + if (span <= 0) { + if (lo < sortedBarTimes.length) ensure(sortedBarTimes[lo])[field] = p0 + return + } + var step = (p1 - p0) / span + for (var i = lo; i < hi; i++) { + ensure(sortedBarTimes[i])[field] = p0 + step * (i - lo) + } + } + + // 笔 + if (slice.bis) { + slice.bis.forEach(function (b) { + fillLine(b.t0, b.t1, b.p0, b.p1, b.sure ? 'bi' : 'bi_pending') + }) + } + + // 段 + if (slice.segs) { + slice.segs.forEach(function (s) { + fillLine(s.t0, s.t1, s.p0, s.p1, s.sure ? 'seg' : 'seg_pending') + }) + } + + // 中枢填充:区间内每根 bar 写入 top/bottom + function fillZs(t0, t1, high, low, topField, botField) { + var lo = lowerBound(sortedBarTimes, t0) + var hi = upperBound(sortedBarTimes, t1) + for (var i = lo; i < hi; i++) { + var e = ensure(sortedBarTimes[i]) + e[topField] = high + e[botField] = low + } + } + + if (slice.zs) { + slice.zs.forEach(function (z) { + fillZs(z.t0, z.t1, z.high || z.zg, z.low || z.zd, 'zs_top', 'zs_bottom') + }) + } + if (slice.segzs) { + slice.segzs.forEach(function (z) { + fillZs(z.t0, z.t1, z.high || z.zg, z.low || z.zd, 'segzs_top', 'segzs_bottom') + }) + } + + // BSP 买卖点标记 + function placeBsps(list, prefix) { + if (!list) return + list.forEach(function (bsp) { + var dir = bsp.is_buy ? 'buy' : 'sell' + var e = ensure(bsp.t) + var types = bsp.types || [] + types.forEach(function (raw) { + var t = String(raw).toUpperCase() + if (BSP_SUBTYPES.indexOf(t) === -1) return + var key = prefix + '_' + dir + '_' + t + e[key] = 1 + }) + }) + } + + placeBsps(slice.bsps, 'bi_bsp') + placeBsps(slice.seg_bsps, 'seg_bsp') + + return { byTimeMs: byTimeMs } + } + + // ---- 样式持久化 ---- + + function currentTheme() { + try { return localStorage.getItem('chart-theme') || 'light' } + catch (e) { return 'light' } + } + + function chanStyleKey() { + return 'chan-indicator-styles-v7-' + currentTheme() + } + + function loadSavedChanStyles() { + try { + var raw = localStorage.getItem(chanStyleKey()) + return raw ? JSON.parse(raw) : null + } catch (e) { + return null + } + } + + window.saveChanStyles = function (sv) { + try { + localStorage.setItem(chanStyleKey(), JSON.stringify({ + styles: sv && sv.styles ? sv.styles : {}, + filledAreasStyle: sv && sv.filledAreasStyle ? sv.filledAreasStyle : {}, + })) + } catch (e) { /* ignore */ } + } + + // ---- 主体:创建 TV 自定义指标定义 ---- + + window.makeChanIndicator = function () { + var saved = loadSavedChanStyles() + var isDark = currentTheme() === 'dark' + var biColor = isDark ? '#ffffff' : '#000000' + var segColor = isDark ? '#42a5f5' : '#1565c0' + + function mergeStyle(id, base) { + var savedStyle = (saved && saved.styles && saved.styles[id]) || {} + var merged = {} + var keys = Object.keys(base).concat(Object.keys(savedStyle)) + keys.forEach(function (k) { + if (k in savedStyle) merged[k] = savedStyle[k] + else merged[k] = base[k] + }) + return merged + } + + function mergeFill(id, base) { + var savedFill = (saved && saved.filledAreasStyle && saved.filledAreasStyle[id]) || {} + var merged = {} + var keys = Object.keys(base).concat(Object.keys(savedFill)) + keys.forEach(function (k) { + if (k in savedFill) merged[k] = savedFill[k] + else merged[k] = base[k] + }) + return merged + } + + // 构建 plots 数组 + var plots = [ + { id: 'bi', type: 'line' }, + { id: 'bi_pending', type: 'line' }, + { id: 'seg', type: 'line' }, + { id: 'seg_pending', type: 'line' }, + { id: 'zs_top', type: 'line' }, + { id: 'zs_bottom', type: 'line' }, + { id: 'segzs_top', type: 'line' }, + { id: 'segzs_bottom', type: 'line' }, + ] + + BSP_SUBTYPES.forEach(function (t) { + plots.push({ id: 'bi_bsp_buy_' + t, type: 'chars' }) + plots.push({ id: 'bi_bsp_sell_' + t, type: 'chars' }) + plots.push({ id: 'seg_bsp_buy_' + t, type: 'chars' }) + plots.push({ id: 'seg_bsp_sell_' + t, type: 'chars' }) + }) + + // 构建 styles 对象 + // bi_pending/seg_pending: 虚线(linestyle:2),加粗 + 高亮色,确保末完成笔/段清晰可见 + var pendingBiColor = isDark ? '#ff9800' : '#e65100' // orange + var pendingSegColor = isDark ? '#e040fb' : '#aa00ff' // purple + var styles = { + bi: mergeStyle('bi', { + linestyle: 0, linewidth: 1, plottype: 0, trackPrice: false, + transparency: 0, visible: true, color: biColor, display: 3, + }), + bi_pending: mergeStyle('bi_pending', { + linestyle: 2, linewidth: 2, plottype: 0, trackPrice: false, + transparency: 0, visible: true, color: pendingBiColor, display: 3, + }), + seg: mergeStyle('seg', { + linestyle: 0, linewidth: 3, plottype: 0, trackPrice: false, + transparency: 0, visible: true, color: segColor, display: 3, + }), + seg_pending: mergeStyle('seg_pending', { + linestyle: 2, linewidth: 4, plottype: 0, trackPrice: false, + transparency: 0, visible: true, color: pendingSegColor, display: 3, + }), + zs_top: mergeStyle('zs_top', { + linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false, + transparency: 100, visible: false, color: '#e4eaf1', display: 0, + }), + zs_bottom: mergeStyle('zs_bottom', { + linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false, + transparency: 100, visible: false, color: '#1565c0', display: 0, + }), + segzs_top: mergeStyle('segzs_top', { + linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false, + transparency: 100, visible: false, color: '#ef6c00', display: 0, + }), + segzs_bottom: mergeStyle('segzs_bottom', { + linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false, + transparency: 100, visible: false, color: '#ef6c00', display: 0, + }), + } + + // BSP 样式 + BSP_SUBTYPES.forEach(function (t) { + styles['bi_bsp_buy_' + t] = mergeStyle('bi_bsp_buy_' + t, { + char: '●', location: 'BelowBar', visible: true, size: 'large', + color: '#d32f2f', display: 3, + }) + styles['bi_bsp_sell_' + t] = mergeStyle('bi_bsp_sell_' + t, { + char: '●', location: 'AboveBar', visible: true, size: 'large', + color: '#2e7d32', display: 3, + }) + styles['seg_bsp_buy_' + t] = mergeStyle('seg_bsp_buy_' + t, { + char: '●', location: 'BelowBar', visible: true, size: 'large', + color: '#d32f2f', display: 3, + }) + styles['seg_bsp_sell_' + t] = mergeStyle('seg_bsp_sell_' + t, { + char: '●', location: 'AboveBar', visible: true, size: 'large', + color: '#2e7d32', display: 3, + }) + }) + + // 构建 style titles + var styleTitles = { + bi: { title: '笔', histogramBase: 0 }, + bi_pending: { title: '笔(虚)', histogramBase: 0 }, + seg: { title: '段', histogramBase: 0 }, + seg_pending: { title: '段(虚)', histogramBase: 0 }, + zs_top: { title: '中枢上沿', histogramBase: 0, isHidden: true }, + zs_bottom: { title: '中枢下沿', histogramBase: 0, isHidden: true }, + segzs_top: { title: '段中枢上沿', histogramBase: 0, isHidden: true }, + segzs_bottom: { title: '段中枢下沿', histogramBase: 0, isHidden: true }, + } + + BSP_SUBTYPES.forEach(function (t) { + // 类型名映射:T1/T2/T3A 是买点, T1P/T2S/T3B 是卖点 + var typeInfo = { + T1: { cls: '一', side: 'buy', num: '1' }, + T1P: { cls: '一', side: 'sell', num: '1' }, + T2: { cls: '二', side: 'buy', num: '2' }, + T2S: { cls: '二', side: 'sell', num: '2' }, + T3A: { cls: '三', side: 'buy', num: '3' }, + T3B: { cls: '三', side: 'sell', num: '3' }, + }[t] || { cls: '', side: '', num: '' } + var buyText = 'B' + typeInfo.num + var sellText = 'S' + typeInfo.num + var isBuyType = typeInfo.side === 'buy' + var isSellType = typeInfo.side === 'sell' + + // 笔中枢 BSP:全部可见 + styleTitles['bi_bsp_buy_' + t] = { + title: '笔·' + typeInfo.cls + '类买点', + isHidden: !isBuyType, + text: buyText, + } + styleTitles['bi_bsp_sell_' + t] = { + title: '笔·' + typeInfo.cls + '类卖点', + isHidden: !isSellType, + text: sellText, + } + // 段中枢 BSP:只有一类买卖点有实际数据 + var segBuyVisible = t === 'T1' + var segSellVisible = t === 'T1P' + styleTitles['seg_bsp_buy_' + t] = { + title: '段·一类买点', + isHidden: !segBuyVisible, + text: '段B1', + } + styleTitles['seg_bsp_sell_' + t] = { + title: '段·一类卖点', + isHidden: !segSellVisible, + text: '段S1', + } + }) + + return { + name: '缠论', + metainfo: { + _metainfoVersion: 53, + id: 'Chan@tv-basicstudies-5', + scriptIdPart: '', + description: 'Chan 缠论', + shortDescription: '缠论', + is_hidden_study: false, + isCustomIndicator: true, + is_price_study: true, + linkedToSeries: true, + format: { type: 'inherit' }, + plots: plots, + filledAreas: [ + { id: 'zs_fill', objAId: 'zs_top', objBId: 'zs_bottom', type: 'plot_plot', + title: '中枢', isHidden: false }, + { id: 'segzs_fill', objAId: 'segzs_top', objBId: 'segzs_bottom', type: 'plot_plot', + title: '段中枢', isHidden: false }, + ], + defaults: { + styles: styles, + filledAreasStyle: { + zs_fill: mergeFill('zs_fill', { color: '#f1d96a', visible: true, transparency: 75 }), + segzs_fill: mergeFill('segzs_fill', { color: '#6361f7', visible: true, transparency: 75 }), + }, + precision: 2, + inputs: { epoch: 0 }, + }, + styles: styleTitles, + inputs: [ + { id: 'epoch', name: 'epoch', type: 'integer', defval: 0, isHidden: true }, + ], + }, + constructor: function () { + var self = this + this.init = function (ctx) { + self._context = ctx + } + this.main = function (context) { + // 32 个 plot: 8 结构 + 24 BSP + var NANS = new Array(32).fill(NaN) + // v31: sniffing pass 时 context.symbol.time 为 NaN + var t = context.symbol.time + if (isNaN(t)) return NANS + + var lookup = window.chanLookupHolder.current + if (!lookup) return NANS + + var e = lookup.byTimeMs.get(t) + if (!e) return NANS + + var out = [ + e.bi != null ? e.bi : NaN, + e.bi_pending != null ? e.bi_pending : NaN, + e.seg != null ? e.seg : NaN, + e.seg_pending != null ? e.seg_pending : NaN, + e.zs_top != null ? e.zs_top : NaN, + e.zs_bottom != null ? e.zs_bottom : NaN, + e.segzs_top != null ? e.segzs_top : NaN, + e.segzs_bottom != null ? e.segzs_bottom : NaN, + ] + + BSP_SUBTYPES.forEach(function (sub) { + out.push( + e['bi_bsp_buy_' + sub] != null ? e['bi_bsp_buy_' + sub] : NaN, + e['bi_bsp_sell_' + sub] != null ? e['bi_bsp_sell_' + sub] : NaN, + e['seg_bsp_buy_' + sub] != null ? e['seg_bsp_buy_' + sub] : NaN, + e['seg_bsp_sell_' + sub] != null ? e['seg_bsp_sell_' + sub] : NaN + ) + }) + + return out + } + }, + } + } + + // ---- Epoch bump 机制 ---- + var chanEpoch = 0 + var CHAN_STUDY_DESC = 'Chan 缠论' + + /** + * 确保缠论 study 存在并通过 epoch bump 触发重绘。 + * 与 TradingViewChart.tsx 中 ensureAndPokeChanStudy 逻辑一致。 + */ + window.ensureAndPokeChanStudy = function (chart) { + try { + var studies = chart.getAllStudies ? chart.getAllStudies() : [] + var existingId = null + for (var i = 0; i < studies.length; i++) { + if (studies[i].name === CHAN_STUDY_DESC) { + existingId = studies[i].id + break + } + } + + chanEpoch += 1 + + if (existingId) { + try { + var api = chart.getStudyById(existingId) + if (api && api.setInputValues) { + api.setInputValues([{ id: 'epoch', value: chanEpoch }]) + } + } catch (err) { + console.warn('setInputValues Chan failed', err) + } + return + } + + // 新建 study — 必须是 chart.createStudy(...) 保持 this 绑定! + if (!chart.createStudy) return + var result = chart.createStudy(CHAN_STUDY_DESC, false, false, { epoch: chanEpoch }) + // createStudy 返回 Promise + if (result && typeof result.then === 'function') { + result.then(function (id) { + if (!id) { + console.warn('[缠论] createStudy 返回空 id(指标未注册成功)') + return + } + console.log('[缠论] study 已创建', id) + try { + var studyApi = chart.getStudyById(id) + if (studyApi && studyApi.bringToFront) studyApi.bringToFront() + } catch (err) { + console.warn('bringToFront Chan failed', err) + } + }).catch(function (err) { + console.warn('createStudy Chan failed', err) + }) + } else if (result) { + // 同步返回(兜底) + console.log('[缠论] study 已创建 (sync)', result) + } + } catch (e) { + console.error('ensureAndPokeChanStudy error', e) + } + } +})() diff --git a/web/static/js/app/chart.js b/web/static/js/app/chart.js new file mode 100644 index 0000000..c0fa9ff --- /dev/null +++ b/web/static/js/app/chart.js @@ -0,0 +1 @@ +/* chart.js split into chart_format/view/tv/sync/tables — see index.html load order */ diff --git a/web/static/js/app/chart_format.js b/web/static/js/app/chart_format.js new file mode 100644 index 0000000..89b3b65 --- /dev/null +++ b/web/static/js/app/chart_format.js @@ -0,0 +1,85 @@ +/* chart_format.js — split from chart.js */ +/* chart.js */ +function updateChartDisplay() { + if (currentData) { + // 检测K线周期是否切换 + const curPeriod = $('#subSubPeriodKline').is(':checked') ? 'subsub' : + ($('#elementPeriodKline').is(':checked') ? 'element' : 'main'); + const periodChanged = (curPeriod !== _lastKlinePeriod); + _lastKlinePeriod = curPeriod; + + // 保存当前的可见范围(周期切换时不保留,避免范围越界) + if (!periodChanged && tvWidget && tvWidget.mainChart) { + try { + window._pendingRestoreView = captureChartViewState(tvWidget.mainChart); + } catch (e) { + window._pendingRestoreView = null; + } + } + + console.log('更新图表显示'); + + // 重新初始化图表(initTradingView 内部会在最终同步时读取 _pendingRestoreView) + initTradingView($('#symbol').val(), $('#timeframe').val()); + } +} +// 确保所有时间处理都使用UTC时间,包括表格数据显示 +function formatTime(timeStr) { + if (!timeStr) return ''; + + try { + // 使用用户选择的时区 + const timezone = $('#timezone').val(); + const date = new Date(timeStr); + + // 添加调试信息 + console.debug('表格时间格式化:', timeStr, '->', + date.toISOString(), '使用时区:', timezone); + + // 使用toLocaleString带时区参数 + return date.toLocaleString('zh-CN', { + timeZone: timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + } catch (e) { + console.error('时间格式化错误:', e, timeStr); + // 如果格式化失败,返回原始时间字符串 + return timeStr; + } +} + +// 专门用于确认时间的格式化函数,处理可能为空的情况 +function formatConfirmTime(timeStr) { + if (!timeStr || timeStr === null || timeStr === 'null' || timeStr === '') { + return '未确认'; + } + return formatTime(timeStr); +} + +function formatDirection(direction) { + const dirText = direction === 1 ? '向上' : '向下'; + const dirClass = direction === 1 ? 'direction-up' : 'direction-down'; + return '' + dirText + ''; +} + +function formatPrice(price) { + return price !== null ? parseFloat(price).toFixed(2) : ''; +} + +function formatMacdValue(value) { + const numValue = parseFloat(value); + const valueClass = numValue >= 0 ? 'positive' : 'negative'; + return '' + numValue.toFixed(4) + ''; +} + +function formatTradePointType(type) { + const typeText = type > 0 ? `买${Math.abs(type)}` : `卖${Math.abs(type)}`; + const typeClass = type > 0 ? 'direction-up' : 'direction-down'; + return '' + typeText + ''; +} + diff --git a/web/static/js/app/chart_sync.js b/web/static/js/app/chart_sync.js new file mode 100644 index 0000000..286e7b9 --- /dev/null +++ b/web/static/js/app/chart_sync.js @@ -0,0 +1,712 @@ +/* chart_sync.js — split from chart.js */ +function updateTradingViewData() { + try { + console.log('增量更新图表数据'); + + // 检查 currentData 是否存在 + if (!currentData) { + console.error('currentData为空,无法更新图表'); + return; + } + + // 保存当前的可视范围 + if (tvWidget.mainChart) { + tvWidget.state.visibleRange = tvWidget.mainChart.timeScale().getVisibleRange(); + tvWidget.state.logicalRange = tvWidget.mainChart.timeScale().getVisibleLogicalRange(); + } + + // 检查是否显示原始K线 + const showOriginalKline = $('#showOriginalKline').is(':checked'); + + // 检查是否使用次次周期 / 小周期数据 + const useSubSubPeriod = $('#subSubPeriodKline').is(':checked') && + currentData.sub_sub_timeframe && + currentData.sub_sub_kline_data && + Array.isArray(currentData.sub_sub_kline_data); + const useElementPeriod = !useSubSubPeriod && + $('#elementPeriodKline').is(':checked') && + currentData.element_timeframe && + currentData.element_kline_data && + Array.isArray(currentData.element_kline_data); + + // 转换K线数据 + let candles = []; + if (useSubSubPeriod) { + console.log('使用次次周期K线数据'); + candles = currentData.sub_sub_kline_data.map((kline) => { + const date = new Date(kline.date); + const timestamp = date.getTime() / 1000; + return { + time: timestamp, + open: parseFloat(kline.open), + high: parseFloat(kline.high), + low: parseFloat(kline.low), + close: parseFloat(kline.close), + }; + }); + } else if (useElementPeriod) { + console.log('使用小周期K线数据'); + candles = currentData.element_kline_data.map((kline) => { + const date = new Date(kline.date); + const timestamp = date.getTime() / 1000; + return { + time: timestamp, + open: parseFloat(kline.open), + high: parseFloat(kline.high), + low: parseFloat(kline.low), + close: parseFloat(kline.close), + }; + }); + } else if (currentData.kline_data && Array.isArray(currentData.kline_data)) { + console.log('使用主周期K线数据'); + candles = currentData.kline_data.map((kline) => { + const date = new Date(kline.date); + const timestamp = date.getTime() / 1000; + return { + time: timestamp, + open: parseFloat(kline.open), + high: parseFloat(kline.high), + low: parseFloat(kline.low), + close: parseFloat(kline.close), + }; + }); + } + + // 更新主系列数据(根据klineType) + const klineType = ($('#klineType').val() || (showOriginalKline ? 'candlestick' : 'line')); + if (klineType === 'candlestick' && tvWidget.series.candleSeries) { + tvWidget.series.candleSeries.setData(candles); + } else if (klineType === 'renko' && tvWidget.series.renkoSeries) { + const bricks = buildRenkoFromCandles(candles); + tvWidget.series.renkoSeries.setData(bricks); + } else if (klineType === 'heikin' && tvWidget.series.heikinSeries) { + const hk = buildHeikinFromCandles(candles); + tvWidget.series.heikinSeries.setData(hk); + } else if (klineType === 'bar' && tvWidget.series.barSeries) { + tvWidget.series.barSeries.setData(candles); + } else if (klineType === 'line' && tvWidget.series.lineSeries) { + const lineData = candles.map(c => ({ time: c.time, value: c.close })); + tvWidget.series.lineSeries.setData(lineData); + } else if (klineType === 'area' && tvWidget.series.areaSeries) { + const areaData = candles.map(c => ({ time: c.time, value: c.close })); + tvWidget.series.areaSeries.setData(areaData); + } else if (klineType === 'baseline' && tvWidget.series.baselineSeries) { + const baseData = candles.map(c => ({ time: c.time, value: c.close })); + tvWidget.series.baselineSeries.setData(baseData); + } else if (klineType === 'klc' && tvWidget.series.klcSeries) { + const klcCandles = buildKLCFromAnalysis(currentData); + tvWidget.series.klcSeries.setData(klcCandles); + } + + // 更新均线数据 + addMovingAveragesToChart(candles); + + // 更新布林带数据 + addBollingerBandsToChart(candles); + + // 更新成交量数据 + let volumes = []; + if (useSubSubPeriod && currentData.sub_sub_kline_data && Array.isArray(currentData.sub_sub_kline_data)) { + volumes = currentData.sub_sub_kline_data.map(kline => { + const timestamp = Math.floor(new Date(kline.date).getTime() / 1000); + return { + time: timestamp, + value: parseFloat(kline.volume), + color: parseFloat(kline.close) >= parseFloat(kline.open) ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)', + }; + }); + } else if (useElementPeriod && currentData.element_kline_data && Array.isArray(currentData.element_kline_data)) { + volumes = currentData.element_kline_data.map(kline => { + const timestamp = Math.floor(new Date(kline.date).getTime() / 1000); + return { + time: timestamp, + value: parseFloat(kline.volume), + color: parseFloat(kline.close) >= parseFloat(kline.open) ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)', + }; + }); + } else if (currentData.kline_data && Array.isArray(currentData.kline_data)) { + volumes = currentData.kline_data.map(kline => { + const timestamp = Math.floor(new Date(kline.date).getTime() / 1000); + return { + time: timestamp, + value: parseFloat(kline.volume), + color: parseFloat(kline.close) >= parseFloat(kline.open) ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)', + }; + }); + } + + if (tvWidget.series.volumeSeries) { + tvWidget.series.volumeSeries.setData(volumes); + } + + // 更新ATR数据 + if (tvWidget.series.atrLineSeries) { + const atrData = []; + const atrDataSource = useSubSubPeriod ? + (currentData.sub_sub_atr || currentData.atr) : + (useElementPeriod ? (currentData.element_atr || currentData.atr) : currentData.atr); + + if (atrDataSource && Array.isArray(atrDataSource)) { + const klineDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data); + // 修复:为每个K线时间点都创建ATR数据点,包括没有ATR值的前期数据 + for (let i = 0; i < klineDataSource.length; i++) { + const kline = klineDataSource[i]; + const timestamp = Math.floor(new Date(kline.date).getTime() / 1000); + + // 为每个时间点都添加数据以保持时间轴对齐,但ATR为0时不显示 + if (atrDataSource[i] !== undefined) { + if (atrDataSource[i] > 0) { + // ATR有效值,正常显示 + atrData.push({ + time: timestamp, + value: atrDataSource[i] + }); + } else { + // ATR为0,添加时间点但不显示线条(使用undefined作为value) + atrData.push({ + time: timestamp, + value: undefined + }); + } + } + } + + console.log('🔄 增量更新ATR数据点数:', atrData.length); + } + + tvWidget.series.atrLineSeries.setData(atrData); + } + + // 更新MACD数据 + if (tvWidget.series.macdLineSeries && currentData.macd && currentData.kline_data && Array.isArray(currentData.kline_data)) { + // 提取MACD数据 + const macdData = []; + const signalData = []; + const histogramData = []; + + for (let i = 0; i < currentData.kline_data.length; i++) { + const kline = currentData.kline_data[i]; + const timestamp = Math.floor(new Date(kline.date).getTime() / 1000); + + if (currentData.macd && currentData.macd.macd && currentData.macd.macd[i] !== undefined) { + macdData.push({ + time: timestamp, + value: currentData.macd.macd[i] + }); + + signalData.push({ + time: timestamp, + value: currentData.macd.signal[i] + }); + + // 设置直方图颜色 + const histValue = currentData.macd.histogram[i]; + histogramData.push({ + time: timestamp, + value: histValue, + color: histValue >= 0 ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)' + }); + } + } + + tvWidget.series.macdLineSeries.setData(macdData); + tvWidget.series.signalLineSeries.setData(signalData); + tvWidget.series.histogramSeries.setData(histogramData); + } + + // 更新 ChanMACD 数据与自定义标注 + if (tvWidget.series.chanMacdLineSeries && ((useSubSubPeriod && currentData.sub_sub_macd) || (useElementPeriod && currentData.element_macd) || currentData.macd) && (useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data))) { + const klineDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data); + const macdDataSource = useSubSubPeriod ? (currentData.sub_sub_macd || currentData.macd) : (useElementPeriod ? (currentData.element_macd || currentData.macd) : currentData.macd); + if (macdDataSource && macdDataSource.macd && macdDataSource.signal && macdDataSource.histogram) { + const chanMacdData = []; + const chanSignalData = []; + const chanHistData = []; + for (let i = 0; i < klineDataSource.length; i++) { + const kline = klineDataSource[i]; + if (kline && kline.date && i < macdDataSource.macd.length && macdDataSource.macd[i] !== null && macdDataSource.macd[i] !== undefined) { + const timestamp = Math.floor(new Date(kline.date).getTime() / 1000); + chanMacdData.push({ time: timestamp, value: macdDataSource.macd[i] }); + chanSignalData.push({ time: timestamp, value: macdDataSource.signal[i] }); + chanHistData.push({ time: timestamp, value: macdDataSource.histogram[i], color: macdDataSource.histogram[i] >= 0 ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)' }); + } + } + if (chanMacdData.length > 0) { + tvWidget.series.chanMacdLineSeries.setData(chanMacdData); + tvWidget.series.chanMacdSignalSeries.setData(chanSignalData); + tvWidget.series.chanMacdHistSeries.setData(chanHistData); + } + } + // 重新应用自定义标注(段/UnitTF/HistSet/状态点) + try { + if (typeof clearChanMacdMarkers === 'function') clearChanMacdMarkers(); + const cm = useSubSubPeriod ? (currentData.sub_sub_chan_macd || currentData.chan_macd) : (useElementPeriod ? (currentData.element_chan_macd || currentData.chan_macd) : currentData.chan_macd); + const allowU = useSubSubPeriod ? !!window.showUOnSubSub : (useElementPeriod ? !!window.showUOnElement : !!window.showUOnMain); + if (cm && allowU) { + addAllChanMacdMarkers( + cm.seg_list || [], + cm.unittf_list || [], + cm.histset_list || [], + { + high_position_list: cm.high_position_list || [], + high_empty_list: cm.high_empty_list || [], + low_position_list: cm.low_position_list || [], + low_empty_list: cm.low_empty_list || [], + return_zero_list: cm.return_zero_list || [], + cross0_up_list: cm.cross0_up_list || [], + cross0_down_list: cm.cross0_down_list || [] + } + ); + } + } catch (e) { + console.warn('更新ChanMACD标注失败:', e); + } + } + + // 重新显示笔、线段和中枢等图形 + redrawFractalElements(); + + // 更新EMA52显示 + updateEMA52Display(currentData); + + // 恢复之前的可视范围 - 优先使用visibleRange以确保时间轴对齐 + if (tvWidget.mainChart) { + if (tvWidget.state.visibleRange) { + console.log('🔄 恢复可见范围:', tvWidget.state.visibleRange); + tvWidget.mainChart.timeScale().setVisibleRange(tvWidget.state.visibleRange); + if (tvWidget.volumeChart) tvWidget.volumeChart.timeScale().setVisibleRange(tvWidget.state.visibleRange); + if (tvWidget.atrChart) tvWidget.atrChart.timeScale().setVisibleRange(tvWidget.state.visibleRange); + if (tvWidget.macdChart) tvWidget.macdChart.timeScale().setVisibleRange(tvWidget.state.visibleRange); + if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleRange(tvWidget.state.visibleRange); + } else if (tvWidget.state.logicalRange) { + console.log('🔄 恢复逻辑范围:', tvWidget.state.logicalRange); + tvWidget.mainChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange); + if (tvWidget.volumeChart) tvWidget.volumeChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange); + if (tvWidget.atrChart) tvWidget.atrChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange); + if (tvWidget.macdChart) tvWidget.macdChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange); + if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange); + } + } + + console.log('增量更新图表完成'); + } catch (e) { + console.error('增量更新图表错误,回退到完全重绘:', e); + // 出错时回退到完全重绘 + initTradingView($('#symbol').val(), $('#timeframe').val()); + } +} +function bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContainer, macdChartContainer, chanMacdChartContainer, mainChart, volumeChart, atrChart, macdChart, chanMacdChart, showMacd) { + // 清理上一轮绑定的事件监听器,防止累积 + if (window._bindSyncCleanups) { + window._bindSyncCleanups.forEach(fn => { try { fn(); } catch(e) {} }); + } + window._bindSyncCleanups = []; + + let syncInProgress = false; + + // 用于跟踪所有图表的拖动状态 - 在函数内部定义以确保作用域正确 + let localDragStates = { + main: false, + volume: false, + atr: false, + macd: false, + chanmacd: false + }; + + // 同步图表的时间范围 + function syncCharts(sourceChart, sourceContainer) { + if (syncInProgress) return; + + syncInProgress = true; + + try { + if (sourceChart && sourceChart.timeScale) { + const logicalRange = sourceChart.timeScale().getVisibleLogicalRange(); + + if (logicalRange && logicalRange.from !== undefined && logicalRange.to !== undefined) { + if (sourceChart !== mainChart && mainChart && mainChart.timeScale) { + try { mainChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {} + } + if (sourceChart !== volumeChart && volumeChart && volumeChart.timeScale) { + try { volumeChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {} + } + if (sourceChart !== atrChart && atrChart && atrChart.timeScale) { + try { atrChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {} + } + if (showMacd && macdChart && sourceChart !== macdChart && macdChart.timeScale) { + try { macdChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {} + } + if (showMacd && chanMacdChart && sourceChart !== chanMacdChart && chanMacdChart.timeScale) { + try { chanMacdChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {} + } + + if (tvWidget && tvWidget.state) { + tvWidget.state.logicalRange = logicalRange; + try { tvWidget.state.visibleRange = sourceChart.timeScale().getVisibleRange(); } catch (e) {} + } + } + } + } catch (e) { + console.error('同步图表出错:', e); + } + + setTimeout(() => { syncInProgress = false; }, 1); + } + + // 为每个图表添加事件监听 + const addChartSyncEvents = (chartContainer, chart) => { + const chartType = chart === mainChart ? 'main' : + chart === volumeChart ? 'volume' : + chart === atrChart ? 'atr' : + chart === macdChart ? 'macd' : + chart === chanMacdChart ? 'chanmacd' : 'unknown'; + + const timeRangeHandler = () => { + if (!syncInProgress) { + syncCharts(chart, chartContainer); + } + }; + chart.timeScale().subscribeVisibleTimeRangeChange(timeRangeHandler); + window._bindSyncCleanups.push(() => { + try { chart.timeScale().unsubscribeVisibleTimeRangeChange(timeRangeHandler); } catch(e) {} + }); + + let isScrolling = false; + + const mousedownHandler = () => { localDragStates[chartType] = true; }; + const mouseupHandler = () => { localDragStates[chartType] = false; }; + const mouseleaveHandler = () => { localDragStates[chartType] = false; }; + const wheelHandler = () => { + if (!isScrolling) { + isScrolling = true; + setTimeout(() => { + if (!syncInProgress) { + syncCharts(chart, chartContainer); + } + isScrolling = false; + }, 50); + } + }; + + chartContainer.addEventListener('mousedown', mousedownHandler); + chartContainer.addEventListener('mouseup', mouseupHandler); + chartContainer.addEventListener('mouseleave', mouseleaveHandler); + chartContainer.addEventListener('wheel', wheelHandler); + window._bindSyncCleanups.push(() => { + chartContainer.removeEventListener('mousedown', mousedownHandler); + chartContainer.removeEventListener('mouseup', mouseupHandler); + chartContainer.removeEventListener('mouseleave', mouseleaveHandler); + chartContainer.removeEventListener('wheel', wheelHandler); + }); + }; + + // 添加事件监听 + if (mainChartContainer && mainChart) { + addChartSyncEvents(mainChartContainer, mainChart); + } + if (volumeChartContainer && volumeChart) { + addChartSyncEvents(volumeChartContainer, volumeChart); + } + if (atrChartContainer && atrChart) { + addChartSyncEvents(atrChartContainer, atrChart); + } + if (showMacd && macdChartContainer && macdChart) { + addChartSyncEvents(macdChartContainer, macdChart); + } + if (showMacd && chanMacdChartContainer && chanMacdChart) { + addChartSyncEvents(chanMacdChartContainer, chanMacdChart); + } + + // 窗口大小变化时重绘图表 — 使用可清理的方式注册 + const resizeHandler = () => { + if (mainChart && mainChartContainer) { + mainChart.applyOptions({ width: mainChartContainer.clientWidth, height: mainChartContainer.clientHeight }); + } + if (volumeChart && volumeChartContainer) { + volumeChart.applyOptions({ width: volumeChartContainer.clientWidth, height: volumeChartContainer.clientHeight }); + } + if (atrChart && atrChartContainer) { + atrChart.applyOptions({ width: atrChartContainer.clientWidth, height: atrChartContainer.clientHeight }); + } + if (showMacd && macdChart && macdChartContainer) { + macdChart.applyOptions({ width: macdChartContainer.clientWidth, height: macdChartContainer.clientHeight }); + } + if (showMacd && chanMacdChart && chanMacdChartContainer) { + chanMacdChart.applyOptions({ width: chanMacdChartContainer.clientWidth, height: chanMacdChartContainer.clientHeight }); + } + setTimeout(() => { if (mainChart) syncCharts(mainChart, mainChartContainer); }, 200); + }; + window.addEventListener('resize', resizeHandler); + window._bindSyncCleanups.push(() => { window.removeEventListener('resize', resizeHandler); }); +} +function setupTooltip(mainChart, buyMarkers = [], sellMarkers = [], mainChartContainer, volumeChartContainer, atrChartContainer, macdChartContainer, chanMacdChartContainer, volumeChart, atrChart, macdChart, chanMacdChart, showMacd) { + // 清理上一轮 tooltip 的事件订阅 + if (window._tooltipCleanups) { + window._tooltipCleanups.forEach(fn => { try { fn(); } catch(e) {} }); + } + window._tooltipCleanups = []; + + window.debugMode = true; + // 初始化 U 显示状态(主/次周期分开控制) + const isShowUMain = $('#toggleUOnMain').is(':checked'); + const isShowUElement = $('#toggleUOnElement').is(':checked'); + window.showUOnMain = isShowUMain; + window.showUOnElement = isShowUElement; + if (!isShowUMain && !isShowUElement) { + // 隐藏时清空子图上的 U 标记 + if (tvWidget.series && tvWidget.series.chanMacdLineSeries) { + try { tvWidget.series.chanMacdLineSeries.setMarkers([]); } catch (e) {} + } + if (tvWidget.series && tvWidget.series.chanMacdSignalSeries) { + try { tvWidget.series.chanMacdSignalSeries.setMarkers([]); } catch (e) {} + } + } + + // 添加买卖点悬浮提示元素 + const tooltipElement = document.createElement('div'); + tooltipElement.className = 'point-tooltip'; + // document.body.appendChild(tooltipElement); + + // 添加自定义十字线信息显示 + const crosshairTooltip = document.createElement('div'); + crosshairTooltip.className = 'crosshair-tooltip'; + crosshairTooltip.style.position = 'absolute'; + crosshairTooltip.style.backgroundColor = 'rgba(0, 0, 0, 0.7)'; + crosshairTooltip.style.color = 'white'; + crosshairTooltip.style.padding = '5px 10px'; + crosshairTooltip.style.borderRadius = '4px'; + crosshairTooltip.style.fontSize = '12px'; + crosshairTooltip.style.zIndex = '1000'; + crosshairTooltip.style.pointerEvents = 'none'; + crosshairTooltip.style.display = 'none'; + // document.body.appendChild(crosshairTooltip); + + // 添加鼠标悬停事件显示提示 + if (mainChart) { + const crosshairHandler = (param) => { + // 十字线同步到其他图表 - 通过DOM元素绘制垂直线实现虚线延长效果 + if (param.time && param.point && volumeChart) { + try { + // 清除之前的十字线标记 + const existingVolumeLines = document.querySelectorAll('.volume-crosshair-line'); + existingVolumeLines.forEach(line => line.remove()); + const existingAtrLines = document.querySelectorAll('.atr-crosshair-line'); + existingAtrLines.forEach(line => line.remove()); + const existingMacdLines = document.querySelectorAll('.macd-crosshair-line'); + existingMacdLines.forEach(line => line.remove()); + const existingChanMacdLines = document.querySelectorAll('.chanmacd-crosshair-line'); + existingChanMacdLines.forEach(line => line.remove()); + + // 获取时间对应的坐标位置 + const mainTimeCoordinate = mainChart.timeScale().timeToCoordinate(param.time); + if (mainTimeCoordinate !== null) { + // 获取主图容器的位置 + const mainChartRect = mainChartContainer.getBoundingClientRect(); + + // 在交易量图上绘制垂直线 + const volumeTimeCoordinate = volumeChart.timeScale().timeToCoordinate(param.time); + if (volumeTimeCoordinate !== null) { + const volumeChartRect = volumeChartContainer.getBoundingClientRect(); + const volumeLine = document.createElement('div'); + volumeLine.className = 'volume-crosshair-line'; + volumeLine.style.position = 'fixed'; // 改为fixed定位 + volumeLine.style.left = (volumeChartRect.left + volumeTimeCoordinate) + 'px'; + volumeLine.style.top = volumeChartRect.top + 'px'; + volumeLine.style.width = '1px'; + volumeLine.style.height = volumeChartRect.height + 'px'; + volumeLine.style.backgroundColor = 'rgba(128, 128, 128, 0.5)'; + volumeLine.style.borderLeft = '1px dashed rgba(128, 128, 128, 0.5)'; + volumeLine.style.pointerEvents = 'none'; + volumeLine.style.zIndex = '1000'; + document.body.appendChild(volumeLine); + } + + // 在ATR图上绘制垂直线 + if (atrChart && atrChartContainer) { + const atrTimeCoordinate = atrChart.timeScale().timeToCoordinate(param.time); + if (atrTimeCoordinate !== null) { + const atrChartRect = atrChartContainer.getBoundingClientRect(); + const atrLine = document.createElement('div'); + atrLine.className = 'atr-crosshair-line'; + atrLine.style.position = 'fixed'; // 改为fixed定位 + atrLine.style.left = (atrChartRect.left + atrTimeCoordinate) + 'px'; + atrLine.style.top = atrChartRect.top + 'px'; + atrLine.style.width = '1px'; + atrLine.style.height = atrChartRect.height + 'px'; + atrLine.style.backgroundColor = 'rgba(128, 128, 128, 0.5)'; + atrLine.style.borderLeft = '1px dashed rgba(128, 128, 128, 0.5)'; + atrLine.style.pointerEvents = 'none'; + atrLine.style.zIndex = '1000'; + document.body.appendChild(atrLine); + } + } + + // 如果有MACD图,也在MACD图上绘制垂直线 + if (showMacd && macdChart && macdChartContainer) { + const macdTimeCoordinate = macdChart.timeScale().timeToCoordinate(param.time); + if (macdTimeCoordinate !== null) { + const macdChartRect = macdChartContainer.getBoundingClientRect(); + const macdLine = document.createElement('div'); + macdLine.className = 'macd-crosshair-line'; + macdLine.style.position = 'fixed'; // 改为fixed定位 + macdLine.style.left = (macdChartRect.left + macdTimeCoordinate) + 'px'; + macdLine.style.top = macdChartRect.top + 'px'; + macdLine.style.width = '1px'; + macdLine.style.height = macdChartRect.height + 'px'; + macdLine.style.backgroundColor = 'rgba(128, 128, 128, 0.5)'; + macdLine.style.borderLeft = '1px dashed rgba(128, 128, 128, 0.5)'; + macdLine.style.pointerEvents = 'none'; + macdLine.style.zIndex = '1000'; + document.body.appendChild(macdLine); + } + } + + // 如果有ChanMACD图,也在ChanMACD图上绘制垂直线 + if (showMacd && chanMacdChart && chanMacdChartContainer) { + const chanMacdTimeCoordinate = chanMacdChart.timeScale().timeToCoordinate(param.time); + if (chanMacdTimeCoordinate !== null) { + const chanMacdChartRect = chanMacdChartContainer.getBoundingClientRect(); + const chanMacdLine = document.createElement('div'); + chanMacdLine.className = 'chanmacd-crosshair-line'; + chanMacdLine.style.position = 'fixed'; + chanMacdLine.style.left = (chanMacdChartRect.left + chanMacdTimeCoordinate) + 'px'; + chanMacdLine.style.top = chanMacdChartRect.top + 'px'; + chanMacdLine.style.width = '1px'; + chanMacdLine.style.height = chanMacdChartRect.height + 'px'; + chanMacdLine.style.backgroundColor = 'rgba(128, 128, 128, 0.5)'; + chanMacdLine.style.borderLeft = '1px dashed rgba(128, 128, 128, 0.5)'; + chanMacdLine.style.pointerEvents = 'none'; + chanMacdLine.style.zIndex = '1000'; + document.body.appendChild(chanMacdLine); + } + } + } + } catch (e) { + console.debug('十字线同步出错:', e); + } + } else { + // 当十字线离开时,清除垂直线 + try { + const existingVolumeLines = document.querySelectorAll('.volume-crosshair-line'); + existingVolumeLines.forEach(line => line.remove()); + const existingAtrLines = document.querySelectorAll('.atr-crosshair-line'); + existingAtrLines.forEach(line => line.remove()); + const existingMacdLines = document.querySelectorAll('.macd-crosshair-line'); + existingMacdLines.forEach(line => line.remove()); + const existingChanMacdLines = document.querySelectorAll('.chanmacd-crosshair-line'); + existingChanMacdLines.forEach(line => line.remove()); + } catch (e) { + console.debug('清除十字线时出错:', e); + } + } + + if (param.time && param.point) { + const timeStr = param.time; + const markers = [...buyMarkers, ...sellMarkers].filter(m => m.time === timeStr); + + // 同时检查分型标记 + const fxMarkers = (window.fxMarkers || []).filter(m => m.time === timeStr); + const allMarkers = [...markers, ...fxMarkers]; + + // 显示时区调试信息 + if (window.debugMode) { + const timezone = $('#timezone').val(); + const formattedTime = formatTimeWithTimezone(timeStr * 1000, timezone); + + // 获取当前价格 - 通过param.seriesPrices获取 + let priceInfo = ''; + if (param.seriesPrices && param.seriesPrices.size > 0) { + // 依次从当前可能的主系列中获取价格 + if (tvWidget.series.candleSeries && param.seriesPrices.get(tvWidget.series.candleSeries)) { + const price = param.seriesPrices.get(tvWidget.series.candleSeries); + priceInfo = `价格: ${price.toFixed(2)}`; + } else if (tvWidget.series.renkoSeries && param.seriesPrices.get(tvWidget.series.renkoSeries)) { + const price = param.seriesPrices.get(tvWidget.series.renkoSeries); + priceInfo = `价格: ${price.toFixed(2)}`; + } else if (tvWidget.series.heikinSeries && param.seriesPrices.get(tvWidget.series.heikinSeries)) { + const price = param.seriesPrices.get(tvWidget.series.heikinSeries); + priceInfo = `价格: ${price.toFixed(2)}`; + } else if (tvWidget.series.barSeries && param.seriesPrices.get(tvWidget.series.barSeries)) { + const price = param.seriesPrices.get(tvWidget.series.barSeries); + priceInfo = `价格: ${price.toFixed(2)}`; + } else if (tvWidget.series.lineSeries && param.seriesPrices.get(tvWidget.series.lineSeries)) { + const price = param.seriesPrices.get(tvWidget.series.lineSeries); + priceInfo = `价格: ${price.toFixed(2)}`; + } else if (tvWidget.series.areaSeries && param.seriesPrices.get(tvWidget.series.areaSeries)) { + const price = param.seriesPrices.get(tvWidget.series.areaSeries); + priceInfo = `价格: ${price.toFixed(2)}`; + } else if (tvWidget.series.baselineSeries && param.seriesPrices.get(tvWidget.series.baselineSeries)) { + const price = param.seriesPrices.get(tvWidget.series.baselineSeries); + priceInfo = `价格: ${price.toFixed(2)}`; + } + // 如果没有蜡烛图系列价格,尝试从区域图系列获取 + else if (tvWidget.series.areaSeries && param.seriesPrices.get(tvWidget.series.areaSeries)) { + const price = param.seriesPrices.get(tvWidget.series.areaSeries); + priceInfo = `价格: ${price.toFixed(2)}`; + } + // 如果没有蜡烛图系列价格,尝试从基线图系列获取 + else if (tvWidget.series.baselineSeries && param.seriesPrices.get(tvWidget.series.baselineSeries)) { + const price = param.seriesPrices.get(tvWidget.series.baselineSeries); + priceInfo = `价格: ${price.toFixed(2)}`; + } + } + + // 显示自定义时区工具提示,包含价格信息 + crosshairTooltip.innerHTML = `
时间: ${formattedTime}
` + + (priceInfo ? `
${priceInfo}
` : ''); + crosshairTooltip.style.display = 'block'; + crosshairTooltip.style.left = (param.point.x + 15) + 'px'; + crosshairTooltip.style.top = (param.point.y - 30) + 'px'; + } + + if (allMarkers.length > 0) { + // 有买卖点或分型标记,显示自定义提示 + const tooltips = allMarkers.map(m => m.tooltip).join('

'); + tooltipElement.innerHTML = tooltips; + tooltipElement.style.display = 'block'; + tooltipElement.style.left = (param.point.x + 15) + 'px'; + tooltipElement.style.top = (param.point.y + 15) + 'px'; + } else { + // 隐藏提示 + tooltipElement.style.display = 'none'; + } + } else { + // 隐藏提示 + tooltipElement.style.display = 'none'; + crosshairTooltip.style.display = 'none'; + } + }; + mainChart.subscribeCrosshairMove(crosshairHandler); + window._tooltipCleanups.push(() => { + try { mainChart.unsubscribeCrosshairMove(crosshairHandler); } catch(e) {} + }); + + // 处理图表缩放、平移等事件,隐藏提示 + const hideTooltipHandler = () => { + tooltipElement.style.display = 'none'; + crosshairTooltip.style.display = 'none'; + }; + mainChart.timeScale().subscribeVisibleTimeRangeChange(hideTooltipHandler); + window._tooltipCleanups.push(() => { + try { mainChart.timeScale().unsubscribeVisibleTimeRangeChange(hideTooltipHandler); } catch(e) {} + }); + } +} + +// 辅助函数:使用指定时区格式化时间戳 +function formatTimeWithTimezone(timestamp, timezone) { + try { + return new Date(timestamp).toLocaleString('zh-CN', { + timeZone: timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + } catch (e) { + console.error('时区格式化错误:', e); + return new Date(timestamp).toLocaleString(); + } +} diff --git a/web/static/js/app/chart_tables.js b/web/static/js/app/chart_tables.js new file mode 100644 index 0000000..3e38571 --- /dev/null +++ b/web/static/js/app/chart_tables.js @@ -0,0 +1,356 @@ +/* chart_tables.js — split from chart.js */ +function updateTables(currentData) { + // 检查数据有效性 + if (!currentData) { + console.error('updateTables: 传入的数据为空'); + return; + } + + const data = currentData; + + const useSubSubPeriod = $('#subSubPeriodKline').is(':checked'); + const useElementPeriod = $('#elementPeriodKline').is(':checked'); + const periodLabel = useSubSubPeriod ? '次次周期' : (useElementPeriod ? '小周期' : '主周期'); + console.log('数据表显示周期选择:', periodLabel); + + // 笔数据表更新 + if (tables.bi) { + tables.bi.clear().destroy(); + } + + let biData, biSource; + if (useSubSubPeriod && data.sub_sub_bi_list && data.sub_sub_bi_list.length > 0) { + biData = data.sub_sub_bi_list; + biSource = '次次周期'; + } else if (useElementPeriod && data.element_bi_list && data.element_bi_list.length > 0) { + biData = data.element_bi_list; + biSource = '小周期'; + } else { + biData = data.bi_list; + biSource = '主周期'; + } + console.log(`表格显示${biSource}笔数据,共${biData ? biData.length : 0}条`); + + tables.bi = $('#biTable').DataTable({ + data: biData || [], + order: [[0, 'desc']], + pageLength: 25, + columns: [ + { data: 'start_time', render: formatTime }, + { data: 'end_time', render: formatTime }, + { data: 'sure_time', render: formatConfirmTime }, + { data: 'start_price', render: formatPrice }, + { data: 'end_price', render: formatPrice }, + { data: 'direction', render: formatDirection }, + { data: 'macd_div', render: formatMacdValue } + ] + }); + + // 线段数据表更新 + if (tables.seg) { + tables.seg.clear().destroy(); + } + + let segData, segSource, uncompletedSegData; + if (useSubSubPeriod && data.sub_sub_seg_list && data.sub_sub_seg_list.length > 0) { + segData = data.sub_sub_seg_list; + uncompletedSegData = data.sub_sub_uncompleted_seg_list || []; + segSource = '次次周期'; + } else if (useElementPeriod && data.element_seg_list && data.element_seg_list.length > 0) { + segData = data.element_seg_list; + uncompletedSegData = data.element_uncompleted_seg_list || []; + segSource = '小周期'; + } else { + segData = data.seg_list; + uncompletedSegData = data.uncompleted_seg_list || []; + segSource = '主周期'; + } + + // 合并已完成和未完成的线段数据 + let allSegData = []; + if (segData && segData.length > 0) { + allSegData = allSegData.concat(segData.map(seg => ({...seg, status: '已完成'}))); + } + if (uncompletedSegData && uncompletedSegData.length > 0) { + allSegData = allSegData.concat(uncompletedSegData.map(seg => ({...seg, status: '未完成'}))); + } + + console.log(`表格显示${segSource}线段数据,已完成${segData ? segData.length : 0}条,未完成${uncompletedSegData ? uncompletedSegData.length : 0}条,总计${allSegData.length}条`); + + tables.seg = $('#segTable').DataTable({ + data: allSegData, + order: [[0, 'desc']], + pageLength: 25, + columns: [ + { data: 'start_time', render: formatTime }, + { data: 'end_time', render: function(data, type, row) { + if (data === null || data === undefined) { + return type === 'display' ? '未完成' : ''; + } + return formatTime(data, type, row); + }}, + { data: 'sure_time', render: formatConfirmTime }, + { data: 'start_price', render: formatPrice }, + { data: 'end_price', render: function(data, type, row) { + if (data === null || data === undefined) { + return type === 'display' ? '未完成' : ''; + } + return formatPrice(data, type, row); + }}, + { data: 'direction', render: formatDirection }, + { data: 'status', render: function(data, type, row) { + if (type === 'display') { + const color = data === '已完成' ? 'green' : 'red'; + return `${data}`; + } + return data; + }} + ] + }); + + // 中枢数据表更新 + if (tables.zs) { + tables.zs.clear().destroy(); + } + + let zsData, zsSource; + if (useSubSubPeriod && data.sub_sub_zs_list && data.sub_sub_zs_list.length > 0) { + zsData = data.sub_sub_zs_list; + zsSource = '次次周期'; + } else if (useElementPeriod && data.element_zs_list && data.element_zs_list.length > 0) { + zsData = data.element_zs_list; + zsSource = '小周期'; + } else { + zsData = data.zs_list; + zsSource = '主周期'; + } + console.log(`表格显示${zsSource}中枢数据,共${zsData ? zsData.length : 0}条`); + + tables.zs = $('#zsTable').DataTable({ + data: zsData || [], + order: [[0, 'desc']], + pageLength: 25, + columns: [ + { data: 'start_time', render: formatTime }, + { data: 'end_time', render: formatTime }, + { data: 'zg', render: formatPrice }, + { data: 'zd', render: formatPrice } + ] + }); + + // 买卖点数据表更新 + if (tables.tradePoints) { + tables.tradePoints.clear().destroy(); + } + + let tradePointsData, tradePointsSource; + if (useSubSubPeriod && data.sub_sub_bsp_list && data.sub_sub_bsp_list.length > 0) { + tradePointsData = data.sub_sub_bsp_list; + tradePointsSource = '次次周期'; + } else if (useElementPeriod && (data.element_trade_points && data.element_trade_points.length > 0 || data.element_bsp_list && data.element_bsp_list.length > 0)) { + tradePointsData = data.element_trade_points || data.element_bsp_list; + tradePointsSource = '小周期'; + } else { + tradePointsData = data.trade_points || data.bsp_list; + tradePointsSource = '主周期'; + } + console.log(`表格显示${tradePointsSource}买卖点数据,共${tradePointsData ? tradePointsData.length : 0}条`); + + tables.tradePoints = $('#tradePointsTable').DataTable({ + data: tradePointsData || [], + order: [[0, 'desc']], + pageLength: 25, + columns: [ + { data: 'time', render: formatTime }, + { data: 'price', render: formatPrice }, + { data: 'type', render: formatTradePointType }, + { data: 'desc' } + ] + }); + + // 更新数据源信息显示 + const selectedPeriod = useSubSubPeriod ? '次次周期' : (useElementPeriod ? '小周期' : '主周期'); + const timeframe = useSubSubPeriod && data.sub_sub_timeframe ? data.sub_sub_timeframe : (useElementPeriod && data.element_timeframe ? data.element_timeframe : $('#timeframe').val()); + $('#dataSourceText').html(`当前显示的是${selectedPeriod} (${timeframe}) 数据`); + + // K线数据表更新 + if (tables.kline) { + tables.kline.clear().destroy(); + } + + // 根据用户选择决定使用哪个周期的K线数据 + let klineData, klineSource; + if (useSubSubPeriod && data.sub_sub_kline_data && data.sub_sub_kline_data.length > 0) { + klineData = data.sub_sub_kline_data; + klineSource = '次次周期'; + } else if (useElementPeriod && data.element_kline_data && data.element_kline_data.length > 0) { + klineData = data.element_kline_data; + klineSource = '小周期'; + } else { + klineData = data.kline_data; + klineSource = '主周期'; + } + console.log(`表格显示${klineSource}K线数据,共${klineData ? klineData.length : 0}条`); + + tables.kline = $('#klineTable').DataTable({ + data: klineData || [], + order: [[0, 'desc']], + pageLength: 25, + columns: [ + { data: 'date', render: function(data) { return formatTime(data); } }, + { data: 'open', render: formatPrice }, + { data: 'high', render: formatPrice }, + { data: 'low', render: formatPrice }, + { data: 'close', render: formatPrice }, + { data: 'volume', render: function(data) { return parseInt(data).toLocaleString(); } } + ] + }); + + // 未完成中枢数据表更新 + if (tables.uncompletedZs) { + tables.uncompletedZs.clear().destroy(); + } + + let uncompletedZsData, uncompletedZsSource; + if (useSubSubPeriod && data.sub_sub_uncompleted_zs_list && data.sub_sub_uncompleted_zs_list.length > 0) { + uncompletedZsData = data.sub_sub_uncompleted_zs_list; + uncompletedZsSource = '次次周期'; + } else if (useElementPeriod && data.element_uncompleted_zs_list && data.element_uncompleted_zs_list.length > 0) { + uncompletedZsData = data.element_uncompleted_zs_list; + uncompletedZsSource = '小周期'; + } else { + uncompletedZsData = data.uncompleted_zs_list; + uncompletedZsSource = '主周期'; + } + console.log(`表格显示${uncompletedZsSource}未完成中枢数据,共${uncompletedZsData ? uncompletedZsData.length : 0}条`); + + tables.uncompletedZs = $('#uncompletedZsTable').DataTable({ + data: uncompletedZsData || [], + order: [[0, 'desc']], + pageLength: 25, + columns: [ + { data: 'start_time', render: formatTime }, + { data: 'zg', render: formatPrice }, + { data: 'zd', render: formatPrice } + ] + }); + + // MACD数据表更新 + if (tables.macd) { + tables.macd.clear().destroy(); + } + + // 根据用户选择决定使用哪个周期的MACD数据 + let macdDisplayData = []; + let macdSource; + if (useElementPeriod && data.element_kline_data && data.element_macd) { + // 使用小周期数据 + macdSource = '小周期'; + macdDisplayData = data.element_kline_data.map((item, index) => { + return { + time: item.date, + close: item.close, + macd: data.element_macd.macd[index], + signal: data.element_macd.signal[index], + histogram: data.element_macd.histogram[index] + }; + }); + } else if (data.kline_data && data.macd) { + // 使用主周期数据 + macdSource = '主周期'; + macdDisplayData = data.kline_data.map((item, index) => { + return { + time: item.date, + close: item.close, + macd: data.macd.macd[index], + signal: data.macd.signal[index], + histogram: data.macd.histogram[index] + }; + }); + } + console.log(`表格显示${macdSource}MACD数据,共${macdDisplayData.length}条`); + + tables.macd = $('#macdTable').DataTable({ + data: macdDisplayData, + order: [[0, 'desc']], + pageLength: 25, + columns: [ + { data: 'time', render: formatTime }, + { data: 'close', render: formatPrice }, + { data: 'macd', render: formatMacdValue }, + { data: 'signal', render: formatMacdValue }, + { data: 'histogram', render: formatMacdValue } + ] + }); + + // 更新数据源信息 + setupDataSourceInfo(data); +} +// 设置数据源信息显示 +function setupDataSourceInfo(data) { + const useSubSubPeriod = $('#subSubPeriodKline').is(':checked'); + const useElementPeriod = $('#elementPeriodKline').is(':checked'); + const mainTimeframe = $('#timeframe').val(); + const elementTimeframe = data.element_timeframe || mainTimeframe; + const subSubTimeframe = data.sub_sub_timeframe || elementTimeframe; + + $('#kline-tab, #macd-tab').off('click').on('click', function() { + $('.data-source-info').show(); + if (useSubSubPeriod && data.sub_sub_kline_data && data.sub_sub_kline_data.length > 0) { + $('#dataSourceText').html(`当前显示的是次次周期 (${subSubTimeframe}) 数据`); + } else if (useElementPeriod && data.element_kline_data && data.element_kline_data.length > 0) { + $('#dataSourceText').html(`当前显示的是小周期 (${elementTimeframe}) 数据`); + } else { + $('#dataSourceText').html(`当前显示的是主周期 (${mainTimeframe}) 数据`); + } + }); + + $('#bi-tab').off('click').on('click', function() { + $('.data-source-info').show(); + if (useSubSubPeriod && data.sub_sub_bi_list && data.sub_sub_bi_list.length > 0) { + $('#dataSourceText').html(`当前显示的是次次周期 (${subSubTimeframe}) 笔数据`); + } else if (useElementPeriod && data.element_bi_list && data.element_bi_list.length > 0) { + $('#dataSourceText').html(`当前显示的是小周期 (${elementTimeframe}) 笔数据`); + } else { + $('#dataSourceText').html(`当前显示的是主周期 (${mainTimeframe}) 笔数据`); + } + }); + + $('#seg-tab').off('click').on('click', function() { + $('.data-source-info').show(); + if (useSubSubPeriod && data.sub_sub_seg_list && data.sub_sub_seg_list.length > 0) { + $('#dataSourceText').html(`当前显示的是次次周期 (${subSubTimeframe}) 线段数据`); + } else if (useElementPeriod && data.element_seg_list && data.element_seg_list.length > 0) { + $('#dataSourceText').html(`当前显示的是小周期 (${elementTimeframe}) 线段数据`); + } else { + $('#dataSourceText').html(`当前显示的是主周期 (${mainTimeframe}) 线段数据`); + } + }); + + $('#zs-tab').off('click').on('click', function() { + $('.data-source-info').show(); + if (useSubSubPeriod && data.sub_sub_zs_list && data.sub_sub_zs_list.length > 0) { + $('#dataSourceText').html(`当前显示的是次次周期 (${subSubTimeframe}) 中枢数据`); + } else if (useElementPeriod && data.element_zs_list && data.element_zs_list.length > 0) { + $('#dataSourceText').html(`当前显示的是小周期 (${elementTimeframe}) 中枢数据`); + } else { + $('#dataSourceText').html(`当前显示的是主周期 (${mainTimeframe}) 中枢数据`); + } + }); + + $('#trade-points-tab').off('click').on('click', function() { + $('.data-source-info').show(); + if (useSubSubPeriod && data.sub_sub_bsp_list && data.sub_sub_bsp_list.length > 0) { + $('#dataSourceText').html(`当前显示的是次次周期 (${subSubTimeframe}) 买卖点数据`); + } else if (useElementPeriod && data.element_trade_points && data.element_trade_points.length > 0) { + $('#dataSourceText').html(`当前显示的是小周期 (${elementTimeframe}) 买卖点数据`); + } else { + $('#dataSourceText').html(`当前显示的是主周期 (${mainTimeframe}) 买卖点数据`); + } + }); + + // 初始触发当前标签的点击事件 + $('.nav-link.active').trigger('click'); +} + +// 获取可用交易对 diff --git a/web/static/js/app/chart_tv.js b/web/static/js/app/chart_tv.js new file mode 100644 index 0000000..6ed1e1c --- /dev/null +++ b/web/static/js/app/chart_tv.js @@ -0,0 +1,4664 @@ +/* chart_tv.js — split from chart.js */ +function initTradingView(symbol, timeframe) { + try { +// 在重新初始化前,尝试释放旧图表与系列资源,避免 GPU 内存累积 +try { + if (tvWidget && tvWidget.state && tvWidget.state.isInitialized) { + // 主图 + if (tvWidget.mainChart && typeof tvWidget.mainChart.remove === 'function') { + tvWidget.mainChart.remove(); + } + // 成交量 + if (tvWidget.volumeChart && typeof tvWidget.volumeChart.remove === 'function') { + tvWidget.volumeChart.remove(); + } + // 旧 MACD(若存在) + if (tvWidget.macdChart && typeof tvWidget.macdChart.remove === 'function') { + tvWidget.macdChart.remove(); + } + // 新 ChanMACD(若存在) + if (tvWidget.chanMacdChart && typeof tvWidget.chanMacdChart.remove === 'function') { + tvWidget.chanMacdChart.remove(); + } + // ATR + if (tvWidget.atrChart && typeof tvWidget.atrChart.remove === 'function') { + tvWidget.atrChart.remove(); + } + } +} catch (e) { + console.warn('释放旧图表资源失败(可忽略):', e); +} + console.log('初始化TradingView图表:', symbol, timeframe); + + // 获取当前交易对的配置 + const symbolConfig = getSymbolConfig(symbol); + console.log('交易对配置:', symbolConfig); + + // 检查数据是否存在 + if (!currentData || !currentData.kline_data) { + console.error('数据加载失败或不存在'); + return; + } + + // 检查使用哪一档K线数据:次次周期 / 小周期 / 主周期 + const useSubSubPeriod = $('#subSubPeriodKline').is(':checked') && + currentData.sub_sub_kline_data && + Array.isArray(currentData.sub_sub_kline_data); + const useElementPeriod = $('#elementPeriodKline').is(':checked') && + currentData.element_kline_data && + Array.isArray(currentData.element_kline_data); + + // 输出K线周期选择状态 + const klinePeriodLabel = useSubSubPeriod ? '次次周期' : (useElementPeriod ? '小周期' : '主周期'); + console.log('K线周期选择:', klinePeriodLabel); + console.log('当前选择时区:', $('#timezone').val()); + console.log('交易对类型:', symbolConfig.type); + + let candles = []; + const klineDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? (currentData.element_kline_data || []) : (currentData.kline_data || [])); + + if (useSubSubPeriod || useElementPeriod) { + if (!klineDataSource.length) { + console.error(useSubSubPeriod ? '次次周期K线数据不存在或为空' : '小周期K线数据不存在或为空', klineDataSource); + return; + } + candles = klineDataSource.map((kline) => { + const date = new Date(kline.date); + const timestamp = date.getTime() / 1000; + return { + time: timestamp, + open: parseFloat(kline.open), + high: parseFloat(kline.high), + low: parseFloat(kline.low), + close: parseFloat(kline.close), + }; + }); + } else { + if (!currentData.kline_data || !Array.isArray(currentData.kline_data)) { + console.error('主周期K线数据不存在或不是数组:', currentData.kline_data); + return; + } + candles = currentData.kline_data.map((kline) => { + const date = new Date(kline.date); + const timestamp = date.getTime() / 1000; + return { + time: timestamp, + open: parseFloat(kline.open), + high: parseFloat(kline.high), + low: parseFloat(kline.low), + close: parseFloat(kline.close), + }; + }); + } + + // 根据交易对类型过滤数据(仅用于显示优化) + if (symbolConfig.type === 'a_stock' && timeframe.includes('m')) { + // 对于A股分钟级数据,过滤非交易时间 + const originalLength = candles.length; + candles = filterTradingHours(candles, symbolConfig); + console.log(`A股数据过滤: ${originalLength} -> ${candles.length} 条记录`); + } + // 清除图表容器(释放旧 DOM 与 Canvas) + const chartRoot = document.getElementById('tradingview_chart'); + if (chartRoot) chartRoot.innerHTML = ''; + + // 重置图表对象 + tvWidget = { + mainChart: null, + volumeChart: null, + macdChart: null, + series: { + candleSeries: null, + lineSeries: null, + volumeSeries: null, + macdLineSeries: null, + signalLineSeries: null, + histogramSeries: null, + mainBiSeries: [], + mainUncompletedBiSeries: [], + mainSegSeries: [], + mainUncompletedSegSeries: [], + mainZsSeries: [], + mainUncompletedZsSeries: [], + elementBiSeries: [], + elementUncompletedBiSeries: [], + elementSegSeries: [], + elementUncompletedSegSeries: [], + elementZsSeries: [], + elementUncompletedZsSeries: [], + subSubBiSeries: [], + subSubUncompletedBiSeries: [], + subSubSegSeries: [], + subSubUncompletedSegSeries: [], + subSubZsSeries: [], + subSubUncompletedZsSeries: [], + tradePointSeries: [], + mainBollingerSeries: [], + elementBollingerSeries: [], + maSeries: [], // 添加均线系列数组 + bbSeries: [], // 添加布林带系列数组 + ema52Series: [] // 添加EMA52系列数组 + }, + state: { + isInitialized: false, + visibleRange: null, + logicalRange: null + } + }; + + // 设置父容器样式 + const container = document.getElementById('tradingview_chart'); + container.style.position = 'relative'; + container.style.width = '100%'; + container.style.height = '100%'; + + // 是否显示MACD + const showMacd = $('#showMacd').is(':checked'); + const showOriginalKline = $('#showOriginalKline').is(':checked'); + + // 创建主图容器 + const mainChartContainer = document.createElement('div'); + mainChartContainer.style.width = '100%'; + mainChartContainer.style.position = 'absolute'; + mainChartContainer.style.top = '0'; + mainChartContainer.style.left = '0'; + mainChartContainer.style.right = '0'; + + // 创建成交量副图容器 + const volumeChartContainer = document.createElement('div'); + volumeChartContainer.style.width = '100%'; + volumeChartContainer.style.position = 'absolute'; + volumeChartContainer.style.left = '0'; + volumeChartContainer.style.right = '0'; + volumeChartContainer.style.borderTop = '1px solid #e0e0e0'; + + // 添加ATR图表容器 + const atrChartContainer = document.createElement('div'); + atrChartContainer.style.width = '100%'; + atrChartContainer.style.position = 'absolute'; + atrChartContainer.style.left = '0'; + atrChartContainer.style.right = '0'; + atrChartContainer.style.borderTop = '1px solid #e0e0e0'; + + // 如果需要显示MACD,创建MACD容器 + let macdChartContainer = null; + let chanMacdChartContainer = null; + if (showMacd) { + // 仅显示新的 ChanMACD 图:让其占用原 MACD+ChanMACD 的整体高度 + // 新布局:主图(40%) → ChanMACD(30%) → 成交量(17.5%) → ATR(12.5%) + mainChartContainer.style.height = '40%'; + + // 隐藏旧 MACD 容器(不创建) + // 创建 ChanMACD 容器占据原 MACD+ChanMACD 高度(30%) + chanMacdChartContainer = document.createElement('div'); + chanMacdChartContainer.style.width = '100%'; + chanMacdChartContainer.style.height = '30%'; + chanMacdChartContainer.style.position = 'absolute'; + chanMacdChartContainer.style.top = '40%'; + chanMacdChartContainer.style.left = '0'; + chanMacdChartContainer.style.right = '0'; + chanMacdChartContainer.style.borderTop = '1px solid #e0e0e0'; + chanMacdChartContainer.style.zIndex = '10'; + // 水印:便于区分是新的 ChanMACD 子图 + const chanMacdWatermark = document.createElement('div'); + chanMacdWatermark.textContent = 'ChanMACD'; + chanMacdWatermark.style.position = 'absolute'; + chanMacdWatermark.style.top = '4px'; + chanMacdWatermark.style.left = '8px'; + chanMacdWatermark.style.fontSize = '11px'; + chanMacdWatermark.style.color = '#888'; + chanMacdWatermark.style.pointerEvents = 'none'; + chanMacdChartContainer.appendChild(chanMacdWatermark); + + // 成交量位于 ChanMACD 之下 + volumeChartContainer.style.top = '70%'; + volumeChartContainer.style.height = '17.5%'; + + // ATR 位于最底部 + atrChartContainer.style.top = '87.5%'; + atrChartContainer.style.height = '12.5%'; + } else { + // 不显示MACD时的高度 - 主图、成交量图和ATR图分配 + mainChartContainer.style.height = '55%'; // 主图占55% + volumeChartContainer.style.top = '55%'; + volumeChartContainer.style.height = '22.5%'; // 成交量图占22.5% + + atrChartContainer.style.top = '77.5%'; // ATR图从77.5%位置开始 + atrChartContainer.style.height = '22.5%'; // ATR图占22.5% + } + + container.appendChild(mainChartContainer); + container.appendChild(volumeChartContainer); + container.appendChild(atrChartContainer); + if (showMacd) { + // 只追加新的 ChanMACD 容器 + container.appendChild(chanMacdChartContainer); + } + + // 防止同步过程中的无限循环 + let syncInProgress = false; + + // 创建统一的图表选项 + const createChartOptions = (showTimeScale = true, chartType = 'main') => { + // 根据图表类型确定高度 + let chartHeight; + if (chartType === 'main') { + chartHeight = mainChartContainer.clientHeight; + } else if (chartType === 'volume') { + chartHeight = volumeChartContainer.clientHeight; + } else if (chartType === 'atr') { + chartHeight = atrChartContainer.clientHeight; + } else if (chartType === 'macd') { + chartHeight = macdChartContainer ? macdChartContainer.clientHeight : 0; + } else if (chartType === 'chanmacd') { + chartHeight = chanMacdChartContainer ? chanMacdChartContainer.clientHeight : 0; + } else { + chartHeight = mainChartContainer.clientHeight; + } + + const baseOptions = { + width: mainChartContainer.clientWidth, + height: chartHeight, + layout: { + background: { color: '#ffffff' }, + textColor: '#333', + }, + grid: { + vertLines: { color: '#f0f0f0' }, + horzLines: { color: '#f0f0f0' }, + }, + crosshair: { + mode: LightweightCharts.CrosshairMode.Normal, + // 添加十字线工具提示本地化配置 + horzLine: { + labelVisible: true, + }, + vertLine: { + labelVisible: true, + // 自定义时间格式化 + labelFormatter: (time) => { + const selectedTimezone = $('#timezone').val(); + try { + const date = new Date(time * 1000); + if (symbolConfig.type === 'a_stock') { + // A股使用中国时区格式 + return date.toLocaleString('zh-CN', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + } else { + return date.toLocaleString('zh-CN', { + timeZone: selectedTimezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + } + } catch (e) { + console.error('十字线时间格式化错误:', e); + return new Date(time * 1000).toLocaleString(); + } + }, + }, + }, + rightPriceScale: { + borderColor: '#ddd', + scaleMargins: { + top: 0.1, + bottom: 0.1, + }, + // 为标签留出更多空间,防止遮挡 + minimumWidth: 80, + }, + // 添加左边距配置 + leftPriceScale: { + visible: false, + }, + // 添加本地化选项,确保所有时间显示都使用选定的时区 + localization: { + timeFormatter: (time) => { + const selectedTimezone = $('#timezone').val(); + try { + const date = new Date(time * 1000); + if (symbolConfig.type === 'a_stock') { + // A股使用中国时区格式 + return date.toLocaleString('zh-CN', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + } else { + return date.toLocaleString('zh-CN', { + timeZone: selectedTimezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + } + } catch (e) { + console.error('全局时间格式化错误:', e); + return new Date(time * 1000).toLocaleString(); + } + } + }, + timeScale: { + timeVisible: true, + secondsVisible: false, + visible: showTimeScale, + borderColor: '#ddd', + barSpacing: symbolConfig.type === 'a_stock' ? 6 : 10, + // 确保所有图表使用相同的边距设置 + rightOffset: 12, + // 移除可能影响拖动的固定边缘设置 + // fixLeftEdge: true, + // fixRightEdge: true, + lockVisibleTimeRangeOnResize: true, + tickMarkFormatter: (time) => { + const selectedTimezone = symbolConfig.type === 'a_stock' ? 'Asia/Shanghai' : $('#timezone').val(); + try { + // 使用完整的配置确保时区正确应用 + const date = new Date(time * 1000); + console.log('格式化时间:', time, '转换为:', date.toISOString(), '时区:', selectedTimezone); + + return date.toLocaleString('zh-CN', { + timeZone: selectedTimezone, + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } catch (e) { + console.error('时间格式化错误:', e); + // 如果时区格式化失败,返回简单格式 + return new Date(time * 1000).toLocaleString(); + } + }, + }, + }; + + // 根据交易对类型调整配置 + return adjustChartForSymbolType(baseOptions, symbolConfig); + }; + + // 创建主图表 + const mainChart = LightweightCharts.createChart(mainChartContainer, createChartOptions(true, 'main')); + + // 创建成交量图表 - 只显示底部的时间轴 + const volumeChart = LightweightCharts.createChart(volumeChartContainer, createChartOptions(false, 'volume')); + + // 创建ATR图表 + const atrChart = LightweightCharts.createChart(atrChartContainer, createChartOptions(false, 'atr')); + + // 创建MACD图表(如果需要):仅创建新的 ChanMACD 图 + let macdChart = null; + let chanMacdChart = null; + if (showMacd) { + chanMacdChart = LightweightCharts.createChart(chanMacdChartContainer, createChartOptions(false, 'chanmacd')); + } + + // 创建主价格系列并设置数据(支持多种图表类型) + (function(){ + const klineType = ($('#klineType').val() || 'candlestick'); + // 先清空旧的主系列引用 + tvWidget.series.candleSeries = null; + tvWidget.series.lineSeries = null; + tvWidget.series.barSeries = null; + tvWidget.series.areaSeries = null; + tvWidget.series.baselineSeries = null; + tvWidget.series.renkoSeries = null; + tvWidget.series.heikinSeries = null; + + if (klineType === 'candlestick') { + const series = mainChart.addCandlestickSeries({ + upColor: '#28a745', + downColor: '#dc3545', + borderVisible: false, + wickUpColor: '#28a745', + wickDownColor: '#dc3545', + }); + series.setData(candles); + tvWidget.series.candleSeries = series; + } else if (klineType === 'renko') { + const series = mainChart.addCandlestickSeries({ + upColor: '#28a745', + downColor: '#dc3545', + borderVisible: false, + wickUpColor: '#28a745', + wickDownColor: '#dc3545', + }); + const bricks = buildRenkoFromCandles(candles); + series.setData(bricks); + tvWidget.series.renkoSeries = series; + } else if (klineType === 'heikin') { + const series = mainChart.addCandlestickSeries({ + upColor: '#28a745', + downColor: '#dc3545', + borderVisible: false, + wickUpColor: '#28a745', + wickDownColor: '#dc3545', + }); + const hk = buildHeikinFromCandles(candles); + series.setData(hk); + tvWidget.series.heikinSeries = series; + } else if (klineType === 'bar') { + const series = mainChart.addBarSeries({ + upColor: '#28a745', + downColor: '#dc3545', + thinBars: false + }); + series.setData(candles); + tvWidget.series.barSeries = series; + } else if (klineType === 'line') { + const series = mainChart.addLineSeries({ + color: '#2962FF', + lineWidth: 2, + crosshairMarkerVisible: true, + lastValueVisible: true, + priceLineVisible: true, + }); + const lineData = candles.map(c => ({ time: c.time, value: c.close })); + series.setData(lineData); + tvWidget.series.lineSeries = series; + } else if (klineType === 'area') { + const series = mainChart.addAreaSeries({ + topColor: 'rgba(41, 98, 255, 0.4)', + bottomColor: 'rgba(41, 98, 255, 0.0)', + lineColor: '#2962FF', + lineWidth: 2, + }); + const areaData = candles.map(c => ({ time: c.time, value: c.close })); + series.setData(areaData); + tvWidget.series.areaSeries = series; + } else if (klineType === 'baseline') { + const series = mainChart.addBaselineSeries({ + baseValue: { type: 'price', price: candles.length ? candles[candles.length - 1].close : 0 }, + topLineColor: '#26a69a', + bottomLineColor: '#ef5350', + topFillColor1: 'rgba(38, 166, 154, 0.28)', + topFillColor2: 'rgba(38, 166, 154, 0.05)', + bottomFillColor1: 'rgba(239, 83, 80, 0.28)', + bottomFillColor2: 'rgba(239, 83, 80, 0.05)' + }); + const baseData = candles.map(c => ({ time: c.time, value: c.close })); + series.setData(baseData); + tvWidget.series.baselineSeries = series; + } else if (klineType === 'klc') { + // KLC显示模式 - 使用蜡烛线显示KLC数据 + const series = mainChart.addCandlestickSeries({ + upColor: '#28a745', + downColor: '#dc3545', + borderVisible: false, + wickUpColor: '#28a745', + wickDownColor: '#dc3545', + }); + // 使用KLC数据创建蜡烛图 + const klcCandles = buildKLCFromAnalysis(currentData); + series.setData(klcCandles); + tvWidget.series.klcSeries = series; + } + })(); + + // 转换成交量数据 - 与K线周期一致 + let volumes = []; + const volumeDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data); + + console.log('成交量数据源选择:', klinePeriodLabel); + console.log('成交量数据长度:', volumeDataSource.length); + + if (volumeDataSource && Array.isArray(volumeDataSource)) { + volumes = volumeDataSource.map(kline => { + // 使用与K线和MACD完全相同的时间戳计算方式 + const timestamp = Math.floor(new Date(kline.date).getTime() / 1000); + return { + time: timestamp, + value: parseFloat(kline.volume), + color: parseFloat(kline.close) >= parseFloat(kline.open) ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)', + }; + }); + + console.log('处理后的成交量数据点数:', volumes.length); + } + + // 添加成交量图表 + const volumeSeries = volumeChart.addHistogramSeries({ + color: '#26a69a', + priceFormat: { + type: 'volume', + }, + title: '成交量', + }); + volumeSeries.setData(volumes); + tvWidget.series.volumeSeries = volumeSeries; + + // 添加ATR图表 + const atrLineSeries = atrChart.addLineSeries({ + color: '#FF9800', + lineWidth: 2, + title: 'ATR', + lastValueVisible: false, + priceLineVisible: false, + }); + + // 准备ATR数据 + const atrData = []; + const atrKlineDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data); + const atrDataSource = useSubSubPeriod ? (currentData.sub_sub_atr || currentData.atr) : (useElementPeriod ? (currentData.element_atr || currentData.atr) : currentData.atr); + + console.log('ATR数据源选择:', klinePeriodLabel); + console.log('ATR数据长度:', atrDataSource ? atrDataSource.length : 0); + console.log('K线数据长度:', atrKlineDataSource ? atrKlineDataSource.length : 0); + + if (atrDataSource && Array.isArray(atrDataSource) && atrKlineDataSource && Array.isArray(atrKlineDataSource)) { + // 关键修复:为每个K线时间点都创建ATR数据点,包括没有ATR值的前期数据 + for (let i = 0; i < atrKlineDataSource.length; i++) { + const kline = atrKlineDataSource[i]; + const timestamp = Math.floor(new Date(kline.date).getTime() / 1000); + + // 为每个时间点都添加数据以保持时间轴对齐,但ATR为0时不显示 + if (atrDataSource[i] !== undefined) { + if (atrDataSource[i] > 0) { + // ATR有效值,正常显示 + atrData.push({ + time: timestamp, + value: atrDataSource[i] + }); + } else { + // ATR为0,添加时间点但不显示线条(使用undefined作为value) + atrData.push({ + time: timestamp, + value: undefined + }); + } + } + } + + console.log('处理后的ATR数据点数:', atrData.length); + console.log('ATR数据样本:', atrData.slice(0, 5)); + } + console.log('处理后的ATR数据点数:', atrData.length); + atrLineSeries.setData(atrData); + tvWidget.series.atrLineSeries = atrLineSeries; + + // 旧 MACD 图已移除,不再绘制(保留占位但彻底禁用) + if (FEATURES.legacyMacd && showMacd && currentData.macd && currentData.kline_data && Array.isArray(currentData.kline_data)) { + // 创建MACD线 + const macdLineSeries = macdChart.addLineSeries({ + color: '#2962FF', + lineWidth: 1, + title: 'MACD', + lastValueVisible: false, // 禁用最后值标签,防止遮挡 + priceLineVisible: false, // 禁用价格线 + }); + + // 创建信号线 + const signalLineSeries = macdChart.addLineSeries({ + color: '#FF6B6B', + lineWidth: 1, + title: 'Signal', + lastValueVisible: false, // 禁用最后值标签,防止遮挡 + priceLineVisible: false, // 禁用价格线 + }); + + // 创建直方图 + const histogramSeries = macdChart.addHistogramSeries({ + color: '#26a69a', + title: 'Histogram', + priceFormat: { + type: 'price', + precision: 4, + }, + }); + + // 提取MACD数据 - 使用和K线数据相同的时间处理逻辑 + const macdData = []; + const signalData = []; + const histogramData = []; + + // 使用与K线数据相同的数据源来确保时间对齐 + const klineDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data); + const macdDataSource = useElementPeriod ? + (currentData.element_macd || currentData.macd) : // 如果有次周期MACD数据则使用,否则使用主周期 + currentData.macd; // 主周期使用主周期MACD数据 + + console.log('MACD数据源选择:', useElementPeriod ? '次周期' : '主周期'); + console.log('K线数据长度:', klineDataSource.length); + console.log('MACD数据:', macdDataSource); + + for (let i = 0; i < klineDataSource.length; i++) { + const kline = klineDataSource[i]; + // 使用与K线完全相同的时间戳计算方式 + const timestamp = Math.floor(new Date(kline.date).getTime() / 1000); + + if (macdDataSource && macdDataSource.macd && macdDataSource.macd[i] !== undefined) { + macdData.push({ + time: timestamp, + value: macdDataSource.macd[i] + }); + + signalData.push({ + time: timestamp, + value: macdDataSource.signal[i] + }); + + // 设置直方图颜色 + const histValue = macdDataSource.histogram[i]; + histogramData.push({ + time: timestamp, + value: histValue, + color: histValue >= 0 ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)' + }); + } + } + + console.log('处理后的MACD数据点数:', macdData.length); + + macdLineSeries.setData(macdData); + signalLineSeries.setData(signalData); + histogramSeries.setData(histogramData); + + tvWidget.series.macdLineSeries = macdLineSeries; + tvWidget.series.signalLineSeries = signalLineSeries; + tvWidget.series.histogramSeries = histogramSeries; + } + // 添加ChanMACD图表 + console.log('ChanMACD图表创建条件检查:', { + showMacd: showMacd, + chanMacdChart: !!chanMacdChart, + hasMacd: !!currentData.macd, + hasKlineData: !!currentData.kline_data, + isArray: Array.isArray(currentData.kline_data) + }); + // 在创建 ChanMACD 前,确保一次性同步 U 显示开关到全局(默认不显示) + if (typeof window.showUOnMain === 'undefined') { + window.showUOnMain = $('#toggleUOnMain').is(':checked'); + } + if (typeof window.showUOnElement === 'undefined') { + window.showUOnElement = $('#toggleUOnElement').is(':checked'); + } + if (typeof window.showUOnSubSub === 'undefined') { + window.showUOnSubSub = $('#toggleUOnSubSub').is(':checked'); + } + if (showMacd && chanMacdChart && ((useSubSubPeriod && currentData.sub_sub_macd) || (useElementPeriod && currentData.element_macd) || currentData.macd) && (useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data))) { + console.log('✅ 开始创建 ChanMACD 系列'); + // 创建ChanMACD线系列 + const chanMacdLineSeries = chanMacdChart.addLineSeries({ + color: '#2962FF', + lineWidth: 1, + title: 'ChanMACD', + lastValueVisible: false, + priceLineVisible: false, + }); + + // 创建ChanMACD信号线系列 + const chanMacdSignalSeries = chanMacdChart.addLineSeries({ + color: '#FF6B6B', + lineWidth: 1, + title: 'ChanSignal', + lastValueVisible: false, + priceLineVisible: false, + }); + + // 创建ChanMACD柱状图系列 + const chanMacdHistSeries = chanMacdChart.addHistogramSeries({ + color: '#26a69a', + title: 'ChanHistogram', + priceFormat: { + type: 'price', + precision: 4, + }, + }); + + // 设置ChanMACD图表的字体大小 + chanMacdChart.applyOptions({ + layout: { + fontSize: 10, // 设置更小的字体大小 + }, + rightPriceScale: { + fontSize: 10, // 设置右侧价格轴的字体大小 + }, + timeScale: { + fontSize: 10, // 设置时间轴的字体大小 + }, + }); + + // 使用与主图一致的数据源(小周期开启时使用小周期MACD与K线) + const klineDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data); + const macdDataSource = useSubSubPeriod ? (currentData.sub_sub_macd || currentData.macd) : (useElementPeriod ? (currentData.element_macd || currentData.macd) : currentData.macd); + + // 准备ChanMACD数据 + const chanMacdData = []; + const chanSignalData = []; + const chanHistData = []; + + console.log('ChanMACD数据源检查:', { + klineDataSourceLength: klineDataSource.length, + macdDataSource: !!macdDataSource, + macdLength: macdDataSource ? macdDataSource.macd.length : 0 + }); + + console.log('ChanMACD数据源检查:', { + klineDataSourceLength: klineDataSource.length, + macdDataSource: !!macdDataSource, + macdLength: macdDataSource ? macdDataSource.macd.length : 0 + }); + + for (let i = 0; i < klineDataSource.length; i++) { + const kline = klineDataSource[i]; + if (kline && kline.date && + i < macdDataSource.macd.length && + macdDataSource.macd[i] !== null && macdDataSource.macd[i] !== undefined) { + + // 使用与K线完全相同的时间戳计算方式 + const timestamp = Math.floor(new Date(kline.date).getTime() / 1000); + + chanMacdData.push({ + time: timestamp, + value: macdDataSource.macd[i] + }); + + chanSignalData.push({ + time: timestamp, + value: macdDataSource.signal[i] + }); + + chanHistData.push({ + time: timestamp, + value: macdDataSource.histogram[i], + color: macdDataSource.histogram[i] >= 0 ? 'rgba(40, 167, 69, 0.5)' : 'rgba(220, 53, 69, 0.5)' + }); + } + } + + console.log('ChanMACD数据处理完成:', { + chanMacdDataLength: chanMacdData.length, + chanSignalDataLength: chanSignalData.length, + chanHistDataLength: chanHistData.length, + sampleData: chanMacdData.length > 0 ? chanMacdData[0] : null + }); + + // 设置ChanMACD数据 + console.log('ChanMACD数据长度:', chanMacdData.length, chanSignalData.length, chanHistData.length); + + if (chanMacdData.length > 0) { + chanMacdLineSeries.setData(chanMacdData); + chanMacdSignalSeries.setData(chanSignalData); + chanMacdHistSeries.setData(chanHistData); + console.log('✅ ChanMACD数据设置成功'); + } else { + console.warn('⚠️ ChanMACD数据为空,无法设置数据'); + } + + // 保存到tvWidget + tvWidget.series.chanMacdLineSeries = chanMacdLineSeries; + tvWidget.series.chanMacdSignalSeries = chanMacdSignalSeries; + tvWidget.series.chanMacdHistSeries = chanMacdHistSeries; + + console.log('✅ ChanMACD图表系列已保存到tvWidget'); + + // 添加ChanMACD分析标注 + // 根据主/次周期开关与各自的"显示U"独立控制 + const cm = useSubSubPeriod ? (currentData.sub_sub_chan_macd || currentData.chan_macd) : (useElementPeriod ? (currentData.element_chan_macd || currentData.chan_macd) : currentData.chan_macd); + // 默认不显示,必须用户勾选对应复选框 + const allowU = useSubSubPeriod ? !!window.showUOnSubSub : (useElementPeriod ? !!window.showUOnElement : !!window.showUOnMain); + if (cm && allowU) { + console.log('添加ChanMACD分析标注:', { + segListLength: cm.seg_list ? cm.seg_list.length : 0, + unittfListLength: cm.unittf_list ? cm.unittf_list.length : 0, + histsetListLength: cm.histset_list ? cm.histset_list.length : 0 + }); + + // 详细检查段数据 + if (cm.seg_list && cm.seg_list.length > 0) { + console.log('段数据详情:', cm.seg_list.slice(0, 3)); // 显示前3个段 + } else { + console.log('⚠️ 段数据为空或不存在'); + } + + addAllChanMacdMarkers( + cm.seg_list || [], + cm.unittf_list || [], + cm.histset_list || [], + { + high_position_list: cm.high_position_list || [], + high_empty_list: cm.high_empty_list || [], + low_position_list: cm.low_position_list || [], + low_empty_list: cm.low_empty_list || [], + return_zero_list: cm.return_zero_list || [], + cross0_up_list: cm.cross0_up_list || [], + cross0_down_list: cm.cross0_down_list || [] + } + ); + + // 同时从主/次周期的 klu_list 提取 SD/CD 标记,分别使用不同样式 + try { + const mainCm = currentData.chan_macd || {}; + const elementCm = currentData.element_chan_macd || {}; + + const mainMarkers = []; + const elementMarkers = []; + + // 基于时间构建 MACD 值映射,便于按时间快速获取对应的 MACD 值 + const buildMacdTimeMap = (macdObj, klineArr) => { + const map = new Map(); + if (!macdObj || !klineArr || !Array.isArray(klineArr)) return map; + for (let i = 0; i < klineArr.length; i++) { + const k = klineArr[i]; + if (!k || !k.date) continue; + const t = Math.floor(new Date(k.date).getTime() / 1000); + const val = (macdObj.macd && macdObj.macd[i] !== undefined && macdObj.macd[i] !== null) ? macdObj.macd[i] : null; + map.set(t, val); + } + return map; + }; + const mainMacdMap = buildMacdTimeMap(currentData.macd, currentData.kline_data); + const elementMacdMap = buildMacdTimeMap( + (currentData.element_macd || currentData.macd), + (currentData.element_kline_data || currentData.kline_data) + ); + + // 主周期 U 标记(蓝/橙,与原样式一致) + if (window.showUOnMain && Array.isArray(mainCm.klu_list)) { + mainCm.klu_list.forEach((item) => { + if (!item || !item.time) return; + const ts = Math.floor(new Date(item.time).getTime() / 1000); + if (isNaN(ts)) return; + if (Number(item.separate_div) > 0) { + const macdVal = mainMacdMap.get(ts); + const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar'; + mainMarkers.push({ time: ts, position: posSd, color: '#03a9f4', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 }); + } + if (item.continue_div === true) { + const macdVal = mainMacdMap.get(ts); + const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar'; + mainMarkers.push({ time: ts, position: posCd, color: '#ff9800', shape: 'arrowDown', text: 'CD', size: 0.6 }); + } + if (item.near0_return && Number(item.near0_return) > 0) { + mainMarkers.push({ time: ts, position: 'belowBar', color: '#8bc34a', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 }); + } + }); + } + + // 次周期 U 标记(使用不同配色以区分) + if (window.showUOnElement && Array.isArray(elementCm.klu_list)) { + elementCm.klu_list.forEach((item) => { + if (!item || !item.time) return; + const ts = Math.floor(new Date(item.time).getTime() / 1000); + if (isNaN(ts)) return; + if (Number(item.separate_div) > 0) { + const macdVal = elementMacdMap.get(ts); + const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar'; + elementMarkers.push({ time: ts, position: posSd, color: '#9c27b0', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 }); + } + if (item.continue_div === true) { + const macdVal = elementMacdMap.get(ts); + const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar'; + elementMarkers.push({ time: ts, position: posCd, color: '#4caf50', shape: 'arrowDown', text: 'CD', size: 0.6 }); + } + if (item.near0_return && Number(item.near0_return) > 0) { + elementMarkers.push({ time: ts, position: 'belowBar', color: '#009688', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 }); + } + }); + } + + const subSubCm = currentData.sub_sub_chan_macd || {}; + const subSubMarkers = []; + const subSubMacdMap = buildMacdTimeMap(currentData.macd, currentData.kline_data); + if (window.showUOnSubSub && Array.isArray(subSubCm.klu_list)) { + subSubCm.klu_list.forEach((item) => { + if (!item || !item.time) return; + const ts = Math.floor(new Date(item.time).getTime() / 1000); + if (isNaN(ts)) return; + if (Number(item.separate_div) > 0) { + const macdVal = subSubMacdMap.get(ts); + const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar'; + subSubMarkers.push({ time: ts, position: posSd, color: '#00897b', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 }); + } + if (item.continue_div === true) { + const macdVal = subSubMacdMap.get(ts); + const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar'; + subSubMarkers.push({ time: ts, position: posCd, color: '#26a69a', shape: 'arrowDown', text: 'CD', size: 0.6 }); + } + if (item.near0_return && Number(item.near0_return) > 0) { + subSubMarkers.push({ time: ts, position: 'belowBar', color: '#00695c', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 }); + } + }); + } + window.kluDivMarkersSubSub = subSubMarkers; + + // 保存到全局,供主图合并标记使用 + window.kluDivMarkersMain = mainMarkers; + window.kluDivMarkersElement = elementMarkers; + } catch (e) { + console.warn('处理 KLU 背驰标记出错:', e); + window.kluDivMarkersMain = []; + window.kluDivMarkersElement = []; + window.kluDivMarkersSubSub = []; + } + } else { + console.log('⚠️ 没有ChanMACD分析数据'); + // 无数据时清空本次的 KLU 背驰标记 + window.kluDivMarkersMain = []; + window.kluDivMarkersElement = []; + window.kluDivMarkersSubSub = []; + } + } else { + console.log('⚠️ ChanMACD图表创建条件不满足'); + } + // 独立于当前显示周期:计算主/次周期 SD/CD 标记(用于主图合并显示) + try { + const mainCmAll = currentData.chan_macd || {}; + const elementCmAll = currentData.element_chan_macd || {}; + const mainMarkersAll = []; + const elementMarkersAll = []; + + // 构建 MACD 时间映射,用于依据 MACD 正负决定 SD/CD 的显示上下位置 + const buildMacdTimeMapAll = (macdObj, klineArr) => { + const map = new Map(); + if (!macdObj || !klineArr || !Array.isArray(klineArr)) return map; + for (let i = 0; i < klineArr.length; i++) { + const k = klineArr[i]; + if (!k || !k.date) continue; + const t = Math.floor(new Date(k.date).getTime() / 1000); + const val = (macdObj.macd && macdObj.macd[i] !== undefined && macdObj.macd[i] !== null) ? macdObj.macd[i] : null; + map.set(t, val); + } + return map; + }; + const mainMacdMapAll = buildMacdTimeMapAll(currentData.macd, currentData.kline_data); + const elementMacdMapAll = buildMacdTimeMapAll( + (currentData.element_macd || currentData.macd), + (currentData.element_kline_data || currentData.kline_data) + ); + if ((typeof window.showUOnMain === 'undefined' ? false : window.showUOnMain) && Array.isArray(mainCmAll.klu_list)) { + mainCmAll.klu_list.forEach((item) => { + if (!item || !item.time) return; + const ts = Math.floor(new Date(item.time).getTime() / 1000); + if (isNaN(ts)) return; + if (Number(item.separate_div) > 0) { + const macdVal = mainMacdMapAll.get(ts); + const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar'; + mainMarkersAll.push({ time: ts, position: posSd, color: '#03a9f4', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 }); + } + if (item.continue_div === true) { + const macdVal = mainMacdMapAll.get(ts); + const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar'; + mainMarkersAll.push({ time: ts, position: posCd, color: '#ff9800', shape: 'arrowDown', text: 'CD', size: 0.6 }); + } + if (item.near0_return && Number(item.near0_return) > 0) { + mainMarkersAll.push({ time: ts, position: 'belowBar', color: '#8bc34a', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 }); + } + }); + } + if ((typeof window.showUOnElement === 'undefined' ? false : window.showUOnElement) && Array.isArray(elementCmAll.klu_list)) { + elementCmAll.klu_list.forEach((item) => { + if (!item || !item.time) return; + const ts = Math.floor(new Date(item.time).getTime() / 1000); + if (isNaN(ts)) return; + if (Number(item.separate_div) > 0) { + const macdVal = elementMacdMapAll.get(ts); + const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar'; + elementMarkersAll.push({ time: ts, position: posSd, color: '#9c27b0', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 }); + } + if (item.continue_div === true) { + const macdVal = elementMacdMapAll.get(ts); + const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar'; + elementMarkersAll.push({ time: ts, position: posCd, color: '#4caf50', shape: 'arrowDown', text: 'CD', size: 0.6 }); + } + if (item.near0_return && Number(item.near0_return) > 0) { + elementMarkersAll.push({ time: ts, position: 'belowBar', color: '#009688', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 }); + } + }); + } + window.kluDivMarkersMain = mainMarkersAll; + window.kluDivMarkersElement = elementMarkersAll; + const subSubCmAll = currentData.sub_sub_chan_macd || {}; + const subSubMarkersAll = []; + if (window.showUOnSubSub && Array.isArray(subSubCmAll.klu_list)) { + subSubCmAll.klu_list.forEach((item) => { + if (!item || !item.time) return; + const ts = Math.floor(new Date(item.time).getTime() / 1000); + if (isNaN(ts)) return; + if (Number(item.separate_div) > 0) { + subSubMarkersAll.push({ time: ts, position: 'aboveBar', color: '#00897b', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 }); + } + if (item.continue_div === true) { + subSubMarkersAll.push({ time: ts, position: 'belowBar', color: '#26a69a', shape: 'arrowDown', text: 'CD', size: 0.6 }); + } + if (item.near0_return && Number(item.near0_return) > 0) { + subSubMarkersAll.push({ time: ts, position: 'belowBar', color: '#00695c', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 }); + } + }); + } + window.kluDivMarkersSubSub = subSubMarkersAll; + } catch (e) { + console.warn('独立计算 KLU 背驰标记出错:', e); + window.kluDivMarkersMain = []; + window.kluDivMarkersElement = []; + window.kluDivMarkersSubSub = []; + } + + // 实现三图联动滚动 + + // 同步图表的时间范围 + function syncCharts(sourceChart, sourceContainer) { + // 防止无限循环 - 使用更精确的检查 + if (syncInProgress) { + console.log('🔄 同步正在进行中,跳过此次同步'); + return; + } + + syncInProgress = true; + console.log('🚀 开始同步图表,来源:', + sourceChart === mainChart ? '主图' : + sourceChart === volumeChart ? '成交量图' : + sourceChart === atrChart ? 'ATR图' : + sourceChart === macdChart ? 'MACD图' : + sourceChart === chanMacdChart ? 'ChanMACD图' : '未知图表'); + + try { + if (sourceChart && sourceChart.timeScale) { + const logicalRange = sourceChart.timeScale().getVisibleLogicalRange(); + + if (logicalRange && logicalRange.from !== undefined && logicalRange.to !== undefined) { + console.log('📊 同步时间范围:', logicalRange); + + // 同步主图 + if (sourceChart !== mainChart && mainChart && mainChart.timeScale) { + try { + mainChart.timeScale().setVisibleLogicalRange(logicalRange); + console.log('✅ 主图同步完成'); + } catch (e) { + console.error('❌ 主图同步失败:', e); + } + } + + // 同步成交量图 + if (sourceChart !== volumeChart && volumeChart && volumeChart.timeScale) { + try { + volumeChart.timeScale().setVisibleLogicalRange(logicalRange); + console.log('✅ 成交量图同步完成'); + } catch (e) { + console.error('❌ 成交量图同步失败:', e); + } + } + + // 同步ATR图 + if (sourceChart !== atrChart && atrChart && atrChart.timeScale) { + try { + atrChart.timeScale().setVisibleLogicalRange(logicalRange); + console.log('✅ ATR图同步完成'); + } catch (e) { + console.error('❌ ATR图同步失败:', e); + } + } + + // 同步MACD图 + if (showMacd && macdChart && sourceChart !== macdChart && macdChart.timeScale) { + try { + macdChart.timeScale().setVisibleLogicalRange(logicalRange); + console.log('✅ MACD图同步完成'); + } catch (e) { + console.error('❌ MACD图同步失败:', e); + } + } + + // 同步ChanMACD图 + if (showMacd && chanMacdChart && sourceChart !== chanMacdChart && chanMacdChart.timeScale) { + try { + chanMacdChart.timeScale().setVisibleLogicalRange(logicalRange); + console.log('✅ ChanMACD图同步完成'); + } catch (e) { + console.error('❌ ChanMACD图同步失败:', e); + } + } + + // 保存当前的可见范围到全局状态 + if (tvWidget && tvWidget.state) { + tvWidget.state.logicalRange = logicalRange; + } + } else { + console.warn('⚠️ 无效的逻辑范围:', logicalRange); + } + } else { + console.warn('⚠️ 无效的源图表或时间刻度'); + } + } catch (e) { + console.error('💥 同步图表出错:', e); + } + + // 立即重置同步标志,提高响应速度 + setTimeout(() => { + syncInProgress = false; + console.log('🔓 同步标志已重置'); + }, 1); + } + + // 用于跟踪所有图表的拖动状态 + let localDragStates = { + main: false, + volume: false, + atr: false, + macd: false, + chanmacd: false + }; + + // 全局鼠标抬起事件(只添加一次) + document.addEventListener('mouseup', () => { + // 重置所有拖动状态 + Object.keys(localDragStates).forEach(key => { + if (localDragStates[key]) { + console.log(`全局鼠标抬起,重置${key}图表拖动状态`); + localDragStates[key] = false; + } + }); + }); + + // 为每个图表添加事件监听 + const addChartSyncEvents = (chartContainer, chart) => { + console.log('为图表添加同步事件监听:', + chart === mainChart ? '主图' : + chart === volumeChart ? '成交量图' : + chart === atrChart ? 'ATR图' : + chart === macdChart ? 'MACD图' : + chart === chanMacdChart ? 'ChanMACD图' : '未知图表'); + + // 确定当前图表类型 + const chartType = chart === mainChart ? 'main' : + chart === volumeChart ? 'volume' : + chart === atrChart ? 'atr' : + chart === macdChart ? 'macd' : + chart === chanMacdChart ? 'chanmacd' : 'unknown'; + + // 使用LightweightCharts内置的时间范围变化事件(这是最可靠的方法) + chart.timeScale().subscribeVisibleTimeRangeChange(() => { + // 使用图表特定的同步标志防止递归 + if (!syncInProgress) { + console.log('✅ 检测到时间范围变化,触发同步:', chartType, '当前范围:', chart.timeScale().getVisibleLogicalRange()); + syncCharts(chart, chartContainer); + } else { + console.log('⏸️ 同步进行中,跳过时间范围变化事件:', chartType); + } + }); + + // 备用的DOM事件监听(用于调试和额外保障) + let isScrolling = false; + + // 鼠标按下事件 + chartContainer.addEventListener('mousedown', (e) => { + localDragStates[chartType] = true; + console.log('鼠标按下开始拖动:', chartType); + }); + + // 鼠标抬起事件 + chartContainer.addEventListener('mouseup', (e) => { + if (localDragStates[chartType]) { + localDragStates[chartType] = false; + console.log('鼠标抬起,结束拖动:', chartType); + } + }); + + // 鼠标离开事件 + chartContainer.addEventListener('mouseleave', (e) => { + if (localDragStates[chartType]) { + localDragStates[chartType] = false; + console.log('鼠标离开容器,结束拖动:', chartType); + } + }); + + // 滚轮缩放事件(保持原有逻辑) + chartContainer.addEventListener('wheel', (e) => { + if (!isScrolling) { + isScrolling = true; + console.log('滚轮缩放:', chartType); + setTimeout(() => { + if (!syncInProgress) { + syncCharts(chart, chartContainer); + } + isScrolling = false; + }, 50); + } + }); + }; + + // 添加事件监听 + addChartSyncEvents(mainChartContainer, mainChart); + addChartSyncEvents(volumeChartContainer, volumeChart); + addChartSyncEvents(atrChartContainer, atrChart); + if (showMacd && macdChart) { + addChartSyncEvents(macdChartContainer, macdChart); + } + if (showMacd && chanMacdChart) { + addChartSyncEvents(chanMacdChartContainer, chanMacdChart); + } + + // 窗口大小变化时重绘图表 + window.addEventListener('resize', () => { + // 调整主图大小 + mainChart.applyOptions({ + width: mainChartContainer.clientWidth, + height: mainChartContainer.clientHeight + }); + + // 调整成交量图大小 + volumeChart.applyOptions({ + width: volumeChartContainer.clientWidth, + height: volumeChartContainer.clientHeight + }); + + // 调整ATR图大小 + atrChart.applyOptions({ + width: atrChartContainer.clientWidth, + height: atrChartContainer.clientHeight + }); + + // 调整MACD图大小 + if (showMacd && macdChart && macdChartContainer) { + macdChart.applyOptions({ + width: macdChartContainer.clientWidth, + height: macdChartContainer.clientHeight + }); + } + + // 调整ChanMACD图大小 + if (showMacd && chanMacdChart && chanMacdChartContainer) { + chanMacdChart.applyOptions({ + width: chanMacdChartContainer.clientWidth, + height: chanMacdChartContainer.clientHeight + }); + } + + // 重新同步 - 使用主图作为同步源 + setTimeout(() => { + if (mainChart) { + syncCharts(mainChart, mainChartContainer); + } + }, 200); + }); + // 显示笔的绘制 - 分别处理主周期、次周期和次次周期 + if ($('#showMainBi').is(':checked') || $('#showElementBi').is(':checked') || $('#showSubSubBi').is(':checked')) { + console.log('绘制笔 - 已启用'); + let biLines = []; + + // 主周期笔 + if ($('#showMainBi').is(':checked') && currentData.bi_list && currentData.bi_list.length > 0) { + console.log(`绘制主周期笔数据,共${currentData.bi_list.length}条`); + + // 清空已有的主周期笔系列 + tvWidget.series.mainBiSeries = []; + tvWidget.series.mainUncompletedBiSeries = []; + + // 遍历处理每个笔 + currentData.bi_list.forEach(function(bi) { + try { + // 直接使用UTC时间戳(秒) + const startTime = Math.floor(new Date(bi.start_time).getTime() / 1000); + const endTime = Math.floor(new Date(bi.end_time).getTime() / 1000); + + if (isNaN(startTime) || isNaN(endTime)) { + console.error('主周期笔时间转换错误:', bi.start_time, bi.end_time); + return; + } + + const startPrice = parseFloat(bi.start_price); + const endPrice = parseFloat(bi.end_price); + + if (isNaN(startPrice) || isNaN(endPrice)) { + console.error('主周期笔价格转换错误:', bi.start_price, bi.end_price); + return; + } + + // 添加线段 + biLines.push({ + startTime: startTime, + endTime: endTime, + startPrice: startPrice, + endPrice: endPrice, + color: bi.direction === 1 ? '#dc3545' : '#28a745', // 主周期笔颜色 + lineWidth: 1, + }); + + // 添加到图表对象 + tvWidget.series.mainBiSeries.push({ + time: startTime, + value: startPrice, + color: bi.direction === 1 ? '#dc3545' : '#28a745', + lineWidth: 1 + }); + + // 在笔的末端添加macd_div值标记 + if (bi.macd_div && bi.macd_div !== 0 && $('#showMainMacdDiv').is(':checked')) { + console.log(`添加主周期macd_div标记: ${bi.macd_div.toFixed(2)}, 在时间点: ${endTime}`); + + const macdDivLabel = mainChart.addLineSeries({ + lastValueVisible: false, + priceLineVisible: false, + color: 'transparent', // 设置为透明色 + lineWidth: 0, // 线宽为0 + }); + + // 添加一个透明的数据点用于承载标记 + macdDivLabel.setData([ + { time: endTime, value: endPrice } + ]); + + // 主周期MACD背离标记根据笔方向显示,远离K线避免与分型重叠 + const markerPosition = bi.direction === 1 ? 'aboveBar' : 'belowBar'; + const textColor = bi.macd_div > 0 ? '#dc3545' : '#28a745'; + + // 只使用标记,不添加数据点 + macdDivLabel.setMarkers([ + { + time: endTime, + position: markerPosition, + color: textColor, + text: `${bi.macd_div.toFixed(2)}`, // 添加M前缀区分 + size: 0.6, // 更小的尺寸,远离分型标记 + } + ]); + } + } catch (e) { + console.error('主周期笔处理出错:', e); + } + }); + } + + // 次周期笔 + if ($('#showElementBi').is(':checked') && currentData.element_bi_list && currentData.element_bi_list.length > 0) { + console.log(`绘制次周期笔数据,共${currentData.element_bi_list.length}条`); + + // 清空已有的次周期笔系列 + tvWidget.series.elementBiSeries = []; + tvWidget.series.elementUncompletedBiSeries = []; + + currentData.element_bi_list.forEach(function(bi) { + try { + // 直接使用UTC时间戳(秒) + const startTime = Math.floor(new Date(bi.start_time).getTime() / 1000); + const endTime = Math.floor(new Date(bi.end_time).getTime() / 1000); + + if (isNaN(startTime) || isNaN(endTime)) { + console.error('次周期笔时间转换错误:', bi.start_time, bi.end_time); + return; + } + + const startPrice = parseFloat(bi.start_price); + const endPrice = parseFloat(bi.end_price); + + if (isNaN(startPrice) || isNaN(endPrice)) { + console.error('次周期笔价格转换错误:', bi.start_price, bi.end_price); + return; + } + + // 添加线段 + biLines.push({ + startTime: startTime, + endTime: endTime, + startPrice: startPrice, + endPrice: endPrice, + color: bi.direction === 1 ? '#9c27b0' : '#673ab7', // 次周期笔颜色 + lineWidth: 1, + }); + + // 添加到图表对象 + tvWidget.series.elementBiSeries.push({ + time: startTime, + value: startPrice, + color: bi.direction === 1 ? '#9c27b0' : '#673ab7', + lineWidth: 1 + }); + + // 在笔的末端添加macd_div值标记 + if (bi.macd_div && bi.macd_div !== 0 && $('#showElementMacdDiv').is(':checked')) { + console.log(`添加元素周期macd_div标记: ${bi.macd_div.toFixed(2)}, 在时间点: ${endTime}`); + + const macdDivLabel = mainChart.addLineSeries({ + lastValueVisible: false, + priceLineVisible: false, + color: 'transparent', // 设置为透明色 + lineWidth: 0, // 线宽为0 + }); + + // 添加一个透明的数据点用于承载标记 + macdDivLabel.setData([ + { time: endTime, value: endPrice } + ]); + + // 次周期MACD背离标记使用不同位置,进一步避免重叠 + const markerPosition = bi.direction === 1 ? 'aboveBar' : 'belowBar'; + const textColor = bi.macd_div > 0 ? '#9c27b0' : '#673ab7'; + + // 只使用标记,不添加数据点 + macdDivLabel.setMarkers([ + { + time: endTime, + position: markerPosition, + color: textColor, + text: `${bi.macd_div.toFixed(2)}`, // 添加E前缀区分次周期 + size: 0.4, // 更小的尺寸,让分型标记有更多空间 + } + ]); + } + } catch (e) { + console.error('次周期笔处理出错:', e); + } + }); + } + + // 绘制未完成笔 - 主周期 + if ($('#showMainBi').is(':checked') && currentData.uncompleted_bi_list && currentData.uncompleted_bi_list.length > 0) { + console.log(`绘制主周期未完成笔数据,共${currentData.uncompleted_bi_list.length}条`); + + currentData.uncompleted_bi_list.forEach(function(bi) { + try { + // 直接使用UTC时间戳(秒) + const startTime = Math.floor(new Date(bi.start_time).getTime() / 1000); + // 未完成笔的结束时间设为当前K线的最后时间 + const endTime = Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); + + if (isNaN(startTime) || isNaN(endTime)) { + console.error('主周期未完成笔时间转换错误:', bi.start_time); + return; + } + + const startPrice = parseFloat(bi.start_price); + + if (isNaN(startPrice)) { + console.error('主周期未完成笔价格转换错误:', bi.start_price); + return; + } + + // 根据笔的方向确定终点价格 + let endPrice; + const latestKline = currentData.kline_data[currentData.kline_data.length-1]; + if (bi.direction === 1) { + // 向上笔,终点为最新K线的最高点 + endPrice = parseFloat(latestKline.high); + } else { + // 向下笔,终点为最新K线的最低点 + endPrice = parseFloat(latestKline.low); + } + + // 添加未完成笔(红色虚线) + biLines.push({ + startTime: startTime, + endTime: endTime, + startPrice: startPrice, + endPrice: endPrice, + color: '#FF0000', // 红色 + lineWidth: 1, + lineStyle: 2 // 虚线 + }); + + // 添加到图表对象 + tvWidget.series.mainUncompletedBiSeries.push({ + time: startTime, + value: startPrice, + color: '#FF0000', + lineWidth: 1, + lineStyle: 2 + }); + } catch (e) { + console.error('主周期未完成笔处理出错:', e); + } + }); + } + + // 绘制未完成笔 - 次周期 + if ($('#showElementBi').is(':checked') && currentData.element_uncompleted_bi_list && currentData.element_uncompleted_bi_list.length > 0) { + console.log(`绘制次周期未完成笔数据,共${currentData.element_uncompleted_bi_list.length}条`); + + currentData.element_uncompleted_bi_list.forEach(function(bi) { + try { + // 直接使用UTC时间戳(秒) + const startTime = Math.floor(new Date(bi.start_time).getTime() / 1000); + // 未完成笔的结束时间设为当前K线的最后时间 + const endTime = Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); + + if (isNaN(startTime) || isNaN(endTime)) { + console.error('次周期未完成笔时间转换错误:', bi.start_time); + return; + } + + const startPrice = parseFloat(bi.start_price); + + if (isNaN(startPrice)) { + console.error('次周期未完成笔价格转换错误:', bi.start_price); + return; + } + + // 根据笔的方向确定终点价格 + let endPrice; + const latestKline = currentData.kline_data[currentData.kline_data.length-1]; + if (bi.direction === 1) { + // 向上笔,终点为最新K线的最高点 + endPrice = parseFloat(latestKline.high); + } else { + // 向下笔,终点为最新K线的最低点 + endPrice = parseFloat(latestKline.low); + } + + // 添加未完成笔(红色虚线) + biLines.push({ + startTime: startTime, + endTime: endTime, + startPrice: startPrice, + endPrice: endPrice, + color: '#FF0000', // 红色 + lineWidth: 1, + lineStyle: 2 // 虚线 + }); + + // 添加到图表对象 + tvWidget.series.elementUncompletedBiSeries.push({ + time: startTime, + value: startPrice, + color: '#FF0000', + lineWidth: 1, + lineStyle: 2 + }); + } catch (e) { + console.error('次周期未完成笔处理出错:', e); + } + }); + } + + // 次次周期笔 + if ($('#showSubSubBi').is(':checked') && currentData.sub_sub_bi_list && currentData.sub_sub_bi_list.length > 0) { + tvWidget.series.subSubBiSeries = []; + tvWidget.series.subSubUncompletedBiSeries = []; + currentData.sub_sub_bi_list.forEach(function(bi) { + try { + const startTime = Math.floor(new Date(bi.start_time).getTime() / 1000); + const endTime = bi.end_time ? Math.floor(new Date(bi.end_time).getTime() / 1000) : 0; + if (isNaN(startTime) || !endTime) return; + const startPrice = parseFloat(bi.start_price); + const endPrice = parseFloat(bi.end_price); + if (isNaN(startPrice) || isNaN(endPrice)) return; + biLines.push({ + startTime: startTime, endTime: endTime, startPrice: startPrice, endPrice: endPrice, + color: bi.direction === 1 ? '#00897b' : '#26a69a', lineWidth: 1, lineStyle: 0 + }); + tvWidget.series.subSubBiSeries.push({ time: startTime, value: startPrice, color: '#00897b', lineWidth: 1 }); + } catch (e) { console.error('次次周期笔处理出错:', e); } + }); + } + // 次次周期未完成笔 + if ($('#showSubSubBi').is(':checked') && currentData.sub_sub_uncompleted_bi_list && currentData.sub_sub_uncompleted_bi_list.length > 0) { + if (!tvWidget.series.subSubBiSeries) tvWidget.series.subSubBiSeries = []; + if (!tvWidget.series.subSubUncompletedBiSeries) tvWidget.series.subSubUncompletedBiSeries = []; + const klineData = currentData.kline_data || []; + const lastTime = klineData.length ? Math.floor(new Date(klineData[klineData.length-1].date).getTime() / 1000) : 0; + currentData.sub_sub_uncompleted_bi_list.forEach(function(bi) { + try { + const startTime = Math.floor(new Date(bi.start_time).getTime() / 1000); + if (isNaN(startTime) || !lastTime) return; + const startPrice = parseFloat(bi.start_price); + if (isNaN(startPrice)) return; + const lastK = klineData[klineData.length-1]; + const endPrice = bi.direction === 1 ? parseFloat(lastK.high) : parseFloat(lastK.low); + biLines.push({ + startTime: startTime, endTime: lastTime, startPrice: startPrice, endPrice: endPrice, + color: '#00695c', lineWidth: 1, lineStyle: 2 + }); + tvWidget.series.subSubUncompletedBiSeries.push({ time: startTime, value: startPrice, color: '#00695c', lineWidth: 1, lineStyle: 2 }); + } catch (e) { console.error('次次周期未完成笔处理出错:', e); } + }); + } + + // 添加所有笔到图表 + // 1m 等小周期叠加到大周期时,笔数量会非常大;每条笔创建一个 series 会导致主图渲染退化 + // 这里限制绘制数量,优先保留最新笔,避免把主K线和其他元素“挤没” + const MAX_BI_LINE_SERIES = 800; + if (biLines.length > MAX_BI_LINE_SERIES) { + console.warn(`BI线条过多(${biLines.length}),仅绘制最新 ${MAX_BI_LINE_SERIES} 条以保障主图稳定`); + } + const linesToDraw = biLines.length > MAX_BI_LINE_SERIES + ? biLines.slice(-MAX_BI_LINE_SERIES) + : biLines; + + linesToDraw.forEach(line => { + try { + if (!Number.isFinite(line.startTime) || !Number.isFinite(line.endTime)) return; + if (!Number.isFinite(line.startPrice) || !Number.isFinite(line.endPrice)) return; + if (line.endTime <= line.startTime) return; + + const lineSeries = mainChart.addLineSeries({ + color: line.color, + lineWidth: line.lineWidth, + lineStyle: line.lineStyle || 0, // 支持虚线样式 + lastValueVisible: false, + priceLineVisible: false, + }); + + lineSeries.setData([ + { time: line.startTime, value: line.startPrice }, + { time: line.endTime, value: line.endPrice } + ]); + } catch (e) { + console.warn('绘制BI线段失败,已跳过单条异常数据:', e); + } + }); + } else { + console.log('绘制笔 - 已禁用'); + } + // 显示线段的绘制 - 分别处理主周期、次周期和次次周期 + if ($('#showMainSeg').is(':checked') || $('#showElementSeg').is(':checked') || $('#showSubSubSeg').is(':checked')) { + console.log('绘制线段 - 已启用'); + let segLines = []; + + // 主周期线段 + if ($('#showMainSeg').is(':checked') && currentData.seg_list && currentData.seg_list.length > 0) { + console.log(`绘制主周期线段数据,共${currentData.seg_list.length}条`); + + // 清空已有的主周期线段系列 + tvWidget.series.mainSegSeries = []; + tvWidget.series.mainUncompletedSegSeries = []; + + currentData.seg_list.forEach(function(seg) { + try { + // 直接使用UTC时间戳(秒) + const startTime = Math.floor(new Date(seg.start_time).getTime() / 1000); + const endTime = Math.floor(new Date(seg.end_time).getTime() / 1000); + + if (isNaN(startTime) || isNaN(endTime)) { + console.error('主周期线段时间转换错误:', seg.start_time, seg.end_time); + return; + } + + const startPrice = parseFloat(seg.start_price); + const endPrice = parseFloat(seg.end_price); + + if (isNaN(startPrice) || isNaN(endPrice)) { + console.error('主周期线段价格转换错误:', seg.start_price, seg.end_price); + return; + } + + // 添加线段 + segLines.push({ + startTime: startTime, + endTime: endTime, + startPrice: startPrice, + endPrice: endPrice, + color: seg.direction === 1 ? '#FF6B6B' : '#4CAF50', // 主周期线段颜色 + lineWidth: 2, + }); + + // 添加到图表对象 + tvWidget.series.mainSegSeries.push({ + time: startTime, + value: startPrice, + color: seg.direction === 1 ? '#FF6B6B' : '#4CAF50', + lineWidth: 2 + }); + } catch (e) { + console.error('主周期线段处理出错:', e); + } + }); + } + + // 次周期线段 + if ($('#showElementSeg').is(':checked') && currentData.element_seg_list && currentData.element_seg_list.length > 0) { + console.log(`绘制次周期线段数据,共${currentData.element_seg_list.length}条`); + + // 清空已有的次周期线段系列 + tvWidget.series.elementSegSeries = []; + tvWidget.series.elementUncompletedSegSeries = []; + + currentData.element_seg_list.forEach(function(seg) { + try { + // 直接使用UTC时间戳(秒) + const startTime = Math.floor(new Date(seg.start_time).getTime() / 1000); + const endTime = Math.floor(new Date(seg.end_time).getTime() / 1000); + + if (isNaN(startTime) || isNaN(endTime)) { + console.error('次周期线段时间转换错误:', seg.start_time, seg.end_time); + return; + } + + const startPrice = parseFloat(seg.start_price); + const endPrice = parseFloat(seg.end_price); + + if (isNaN(startPrice) || isNaN(endPrice)) { + console.error('次周期线段价格转换错误:', seg.start_price, seg.end_price); + return; + } + + // 添加线段 + segLines.push({ + startTime: startTime, + endTime: endTime, + startPrice: startPrice, + endPrice: endPrice, + color: seg.direction === 1 ? '#673ab7' : '#9c27b0', // 次周期线段颜色 + lineWidth: 2, + }); + + // 添加到图表对象 + tvWidget.series.elementSegSeries.push({ + time: startTime, + value: startPrice, + color: seg.direction === 1 ? '#673ab7' : '#9c27b0', + lineWidth: 2 + }); + } catch (e) { + console.error('次周期线段处理出错:', e); + } + }); + } + + // 绘制未完成线段 - 主周期 + if ($('#showMainSeg').is(':checked') && currentData.uncompleted_seg_list && currentData.uncompleted_seg_list.length > 0) { + console.log(`绘制主周期未完成线段数据,共${currentData.uncompleted_seg_list.length}条`); + + currentData.uncompleted_seg_list.forEach(function(seg) { + try { + // 直接使用UTC时间戳(秒) + const startTime = Math.floor(new Date(seg.start_time).getTime() / 1000); + + if (isNaN(startTime)) { + console.error('主周期未完成线段时间转换错误:', seg.start_time); + return; + } + + const startPrice = parseFloat(seg.start_price); + + if (isNaN(startPrice)) { + console.error('主周期未完成线段价格转换错误:', seg.start_price); + return; + } + + let endTime, endPrice; + + if (seg.end_time && seg.end_price) { + // 有结束时间和价格的未完成线段(倒数第二个等) + endTime = Math.floor(new Date(seg.end_time).getTime() / 1000); + endPrice = parseFloat(seg.end_price); + + if (isNaN(endTime) || isNaN(endPrice)) { + console.error('主周期未完成线段结束时间或价格转换错误:', seg.end_time, seg.end_price); + return; + } + } else { + // 没有结束时间和价格的未完成线段(最后一个) + endTime = Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); + + if (isNaN(endTime)) { + console.error('主周期未完成线段结束时间转换错误'); + return; + } + + // 根据线段的方向确定终点价格 + const latestKline = currentData.kline_data[currentData.kline_data.length-1]; + if (seg.direction === 1) { + // 向上线段,终点为最新K线的最高点 + endPrice = parseFloat(latestKline.high); + } else { + // 向下线段,终点为最新K线的最低点 + endPrice = parseFloat(latestKline.low); + } + } + + // 添加未完成线段(红色虚线) + segLines.push({ + startTime: startTime, + endTime: endTime, + startPrice: startPrice, + endPrice: endPrice, + color: '#FF0000', // 红色 + lineWidth: 2, + lineStyle: 2 // 虚线 + }); + + // 添加到图表对象 + tvWidget.series.mainUncompletedSegSeries.push({ + time: startTime, + value: startPrice, + color: '#FF0000', + lineWidth: 2, + lineStyle: 2 + }); + } catch (e) { + console.error('主周期未完成线段处理出错:', e); + } + }); + } + + // 绘制未完成线段 - 次周期 + if ($('#showElementSeg').is(':checked') && currentData.element_uncompleted_seg_list && currentData.element_uncompleted_seg_list.length > 0) { + console.log(`绘制次周期未完成线段数据,共${currentData.element_uncompleted_seg_list.length}条`); + + currentData.element_uncompleted_seg_list.forEach(function(seg) { + try { + // 直接使用UTC时间戳(秒) + const startTime = Math.floor(new Date(seg.start_time).getTime() / 1000); + + if (isNaN(startTime)) { + console.error('次周期未完成线段时间转换错误:', seg.start_time); + return; + } + + const startPrice = parseFloat(seg.start_price); + + if (isNaN(startPrice)) { + console.error('次周期未完成线段价格转换错误:', seg.start_price); + return; + } + + let endTime, endPrice; + + if (seg.end_time && seg.end_price) { + // 有结束时间和价格的未完成线段(倒数第二个等) + endTime = Math.floor(new Date(seg.end_time).getTime() / 1000); + endPrice = parseFloat(seg.end_price); + + if (isNaN(endTime) || isNaN(endPrice)) { + console.error('次周期未完成线段结束时间或价格转换错误:', seg.end_time, seg.end_price); + return; + } + } else { + // 没有结束时间和价格的未完成线段(最后一个) + endTime = Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); + + if (isNaN(endTime)) { + console.error('次周期未完成线段结束时间转换错误'); + return; + } + + // 根据线段的方向确定终点价格 + const latestKline = currentData.kline_data[currentData.kline_data.length-1]; + if (seg.direction === 1) { + // 向上线段,终点为最新K线的最高点 + endPrice = parseFloat(latestKline.high); + } else { + // 向下线段,终点为最新K线的最低点 + endPrice = parseFloat(latestKline.low); + } + } + + // 添加未完成线段(红色虚线) + segLines.push({ + startTime: startTime, + endTime: endTime, + startPrice: startPrice, + endPrice: endPrice, + color: '#FF0000', // 红色 + lineWidth: 2, + lineStyle: 2 // 虚线 + }); + + // 添加到图表对象 + tvWidget.series.elementUncompletedSegSeries.push({ + time: startTime, + value: startPrice, + color: '#FF0000', + lineWidth: 2, + lineStyle: 2 + }); + } catch (e) { + console.error('次周期未完成线段处理出错:', e); + } + }); + } + + // 次次周期线段 + if ($('#showSubSubSeg').is(':checked') && currentData.sub_sub_seg_list && currentData.sub_sub_seg_list.length > 0) { + tvWidget.series.subSubSegSeries = []; + tvWidget.series.subSubUncompletedSegSeries = []; + currentData.sub_sub_seg_list.forEach(function(seg) { + try { + const startTime = Math.floor(new Date(seg.start_time).getTime() / 1000); + const endTime = seg.end_time ? Math.floor(new Date(seg.end_time).getTime() / 1000) : 0; + if (isNaN(startTime) || !endTime) return; + const startPrice = parseFloat(seg.start_price); + const endPrice = parseFloat(seg.end_price); + if (isNaN(startPrice) || isNaN(endPrice)) return; + segLines.push({ + startTime: startTime, endTime: endTime, startPrice: startPrice, endPrice: endPrice, + color: seg.direction === 1 ? '#00897b' : '#26a69a', lineWidth: 2, lineStyle: 0 + }); + tvWidget.series.subSubSegSeries.push({ time: startTime, value: startPrice, color: '#00897b', lineWidth: 2 }); + } catch (e) { console.error('次次周期线段处理出错:', e); } + }); + } + // 次次周期未完成线段 + if ($('#showSubSubSeg').is(':checked') && currentData.sub_sub_uncompleted_seg_list && currentData.sub_sub_uncompleted_seg_list.length > 0) { + if (!tvWidget.series.subSubUncompletedSegSeries) tvWidget.series.subSubUncompletedSegSeries = []; + const klineDataSeg = currentData.kline_data || []; + const lastTimeSeg = klineDataSeg.length ? Math.floor(new Date(klineDataSeg[klineDataSeg.length-1].date).getTime() / 1000) : 0; + currentData.sub_sub_uncompleted_seg_list.forEach(function(seg) { + try { + const startTime = Math.floor(new Date(seg.start_time).getTime() / 1000); + if (isNaN(startTime) || !lastTimeSeg) return; + const startPrice = parseFloat(seg.start_price); + if (isNaN(startPrice)) return; + let endTime = lastTimeSeg, endPrice; + if (seg.end_time && seg.end_price) { + endTime = Math.floor(new Date(seg.end_time).getTime() / 1000); + endPrice = parseFloat(seg.end_price); + } else { + const lastK = klineDataSeg[klineDataSeg.length-1]; + endPrice = seg.direction === 1 ? parseFloat(lastK.high) : parseFloat(lastK.low); + } + segLines.push({ + startTime: startTime, endTime: endTime, startPrice: startPrice, endPrice: endPrice, + color: '#00695c', lineWidth: 2, lineStyle: 2 + }); + tvWidget.series.subSubUncompletedSegSeries.push({ time: startTime, value: startPrice, color: '#00695c', lineWidth: 2 }); + } catch (e) { console.error('次次周期未完成线段处理出错:', e); } + }); + } + + // 添加所有线段到图表 + segLines.forEach(line => { + const lineSeries = mainChart.addLineSeries({ + color: line.color, + lineWidth: line.lineWidth, + lineStyle: line.lineStyle || 0, // 支持虚线样式 + lastValueVisible: false, + priceLineVisible: false, + }); + + lineSeries.setData([ + { time: line.startTime, value: line.startPrice }, + { time: line.endTime, value: line.endPrice } + ]); + }); + } else { + console.log('绘制线段 - 已禁用'); + } + // 显示中枢的绘制 - 分别处理主周期、次周期和次次周期(包含BI中枢,沿用同样样式与开关) + if ($('#showMainZs').is(':checked') || $('#showElementZs').is(':checked') || $('#showSubSubZs').is(':checked')) { + console.log('绘制中枢 - 已启用'); + + // 主周期中枢 + if ($('#showMainZs').is(':checked') && currentData.zs_list && currentData.zs_list.length > 0) { + console.log(`绘制主周期中枢数据,共${currentData.zs_list.length}条`); + + currentData.zs_list.forEach(function(zs) { + try { + // 直接使用UTC时间戳(秒) + const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); + const endTime = Math.floor(new Date(zs.end_time).getTime() / 1000); + + if (isNaN(startTime) || isNaN(endTime)) { + console.error('主周期中枢时间转换错误:', zs.start_time, zs.end_time); + return; + } + + const zg = parseFloat(zs.zg); // 中枢上沿 + const zd = parseFloat(zs.zd); // 中枢下沿 + const gg = parseFloat(zs.gg); // 中枢高高 + const dd = parseFloat(zs.dd); // 中枢低低 + + if (isNaN(zg) || isNaN(zd)) { + console.error('主周期中枢价格转换错误:', zs.zg, zs.zd); + return; + } + + // 创建中枢上边界 + const topSeries = mainChart.addLineSeries({ + color: '#F1C40F', // 主周期中枢颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + topSeries.setData([ + { time: startTime, value: zg }, + { time: endTime, value: zg } + ]); + + // 为下边界创建另一条线 + const bottomSeries = mainChart.addLineSeries({ + color: '#F1C40F', // 主周期中枢颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + bottomSeries.setData([ + { time: startTime, value: zd }, + { time: endTime, value: zd } + ]); + + // 添加左边界 + const leftSeries = mainChart.addLineSeries({ + color: '#F1C40F', // 主周期中枢颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + leftSeries.setData([ + { time: startTime, value: zd }, + { time: startTime, value: zg } + ]); + + // 添加右边界 + const rightSeries = mainChart.addLineSeries({ + color: '#F1C40F', // 主周期中枢颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + rightSeries.setData([ + { time: endTime, value: zd }, + { time: endTime, value: zg } + ]); + + // 绘制gg线(中枢高高) + if (!isNaN(gg) && gg > 0) { + const ggSeries = mainChart.addLineSeries({ + color: '#F1C40F', // 使用中枢自己的颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + ggSeries.setData([ + { time: startTime, value: gg }, + { time: endTime, value: gg } + ]); + } + + // 绘制dd线(中枢低低) + if (!isNaN(dd) && dd > 0) { + const ddSeries = mainChart.addLineSeries({ + color: '#F1C40F', // 使用中枢自己的颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + ddSeries.setData([ + { time: startTime, value: dd }, + { time: endTime, value: dd } + ]); + } + + // 添加到图表对象 + tvWidget.series.mainZsSeries.push({ + time: startTime, + value: zg, + color: '#F1C40F', + lineWidth: 1 + }); + tvWidget.series.mainZsSeries.push({ + time: endTime, + value: zg, + color: '#F1C40F', + lineWidth: 1 + }); + tvWidget.series.mainZsSeries.push({ + time: startTime, + value: zd, + color: '#F1C40F', + lineWidth: 1 + }); + tvWidget.series.mainZsSeries.push({ + time: endTime, + value: zd, + color: '#F1C40F', + lineWidth: 1 + }); + } catch (e) { + console.error('主周期中枢处理出错:', e); + } + }); + } + + // 次周期中枢 + if ($('#showElementZs').is(':checked') && currentData.element_zs_list && currentData.element_zs_list.length > 0) { + console.log(`绘制次周期中枢数据,共${currentData.element_zs_list.length}条`); + + currentData.element_zs_list.forEach(function(zs) { + try { + // 直接使用UTC时间戳(秒) + const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); + const endTime = Math.floor(new Date(zs.end_time).getTime() / 1000); + + if (isNaN(startTime) || isNaN(endTime)) { + console.error('次周期中枢时间转换错误:', zs.start_time, zs.end_time); + return; + } + + const zg = parseFloat(zs.zg); // 中枢上沿 + const zd = parseFloat(zs.zd); // 中枢下沿 + const gg = parseFloat(zs.gg); // 中枢高高 + const dd = parseFloat(zs.dd); // 中枢低低 + + if (isNaN(zg) || isNaN(zd)) { + console.error('次周期中枢价格转换错误:', zs.zg, zs.zd); + return; + } + + // 创建中枢上边界 + const topSeries = mainChart.addLineSeries({ + color: '#3f51b5', // 次周期中枢颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + topSeries.setData([ + { time: startTime, value: zg }, + { time: endTime, value: zg } + ]); + + // 为下边界创建另一条线 + const bottomSeries = mainChart.addLineSeries({ + color: '#3f51b5', // 次周期中枢颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + bottomSeries.setData([ + { time: startTime, value: zd }, + { time: endTime, value: zd } + ]); + + // 添加左边界 + const leftSeries = mainChart.addLineSeries({ + color: '#3f51b5', // 次周期中枢颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + leftSeries.setData([ + { time: startTime, value: zd }, + { time: startTime, value: zg } + ]); + + // 添加右边界 + const rightSeries = mainChart.addLineSeries({ + color: '#3f51b5', // 次周期中枢颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + rightSeries.setData([ + { time: endTime, value: zd }, + { time: endTime, value: zg } + ]); + + // 绘制gg线(中枢高高) + if (!isNaN(gg) && gg > 0) { + const ggSeries = mainChart.addLineSeries({ + color: '#3f51b5', // 使用次周期中枢自己的颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + ggSeries.setData([ + { time: startTime, value: gg }, + { time: endTime, value: gg } + ]); + } + + // 绘制dd线(中枢低低) + if (!isNaN(dd) && dd > 0) { + const ddSeries = mainChart.addLineSeries({ + color: '#3f51b5', // 使用次周期中枢自己的颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + ddSeries.setData([ + { time: startTime, value: dd }, + { time: endTime, value: dd } + ]); + } + + // 添加到图表对象 + tvWidget.series.elementZsSeries.push({ + time: startTime, + value: zg, + color: '#3f51b5', + lineWidth: 1 + }); + tvWidget.series.elementZsSeries.push({ + time: endTime, + value: zg, + color: '#3f51b5', + lineWidth: 1 + }); + tvWidget.series.elementZsSeries.push({ + time: startTime, + value: zd, + color: '#3f51b5', + lineWidth: 1 + }); + tvWidget.series.elementZsSeries.push({ + time: endTime, + value: zd, + color: '#3f51b5', + lineWidth: 1 + }); + } catch (e) { + console.error('次周期中枢处理出错:', e); + } + }); + } + // 次次周期SEG中枢 + if ($('#showSubSubZs').is(':checked') && currentData.sub_sub_zs_list && currentData.sub_sub_zs_list.length > 0) { + const subSubZsColor = '#00897b'; + currentData.sub_sub_zs_list.forEach(function(zs) { + try { + const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); + const endTime = zs.end_time ? Math.floor(new Date(zs.end_time).getTime() / 1000) : 0; + if (isNaN(startTime) || !endTime) return; + const zg = parseFloat(zs.zg); const zd = parseFloat(zs.zd); const gg = parseFloat(zs.gg); const dd = parseFloat(zs.dd); + if (isNaN(zg) || isNaN(zd)) return; + mainChart.addLineSeries({ color: subSubZsColor, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zg }, { time: endTime, value: zg }]); + mainChart.addLineSeries({ color: subSubZsColor, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zd }, { time: endTime, value: zd }]); + mainChart.addLineSeries({ color: subSubZsColor, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); + mainChart.addLineSeries({ color: subSubZsColor, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: endTime, value: zd }, { time: endTime, value: zg }]); + if (!isNaN(gg) && gg > 0) mainChart.addLineSeries({ color: subSubZsColor, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: gg }, { time: endTime, value: gg }]); + if (!isNaN(dd) && dd > 0) mainChart.addLineSeries({ color: subSubZsColor, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: dd }, { time: endTime, value: dd }]); + } catch (e) { console.error('次次周期中枢处理出错:', e); } + }); + } + } else { + console.log('绘制中枢 - 已禁用'); + } + // BI中枢(已完成)- 使用独立的BI开关 + if ($('#showMainBiZs').is(':checked') && currentData.bi_zs_list && currentData.bi_zs_list.length > 0) { + try { + console.log(`绘制主周期BI中枢数据,共${currentData.bi_zs_list.length}条`); + } catch (e) {} + currentData.bi_zs_list.forEach(function(zs) { + try { + const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); + const endTime = zs.end_time ? Math.floor(new Date(zs.end_time).getTime() / 1000) : Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); + if (isNaN(startTime) || isNaN(endTime)) { return; } + const zg = parseFloat(zs.zg); const zd = parseFloat(zs.zd); const gg = parseFloat(zs.gg); const dd = parseFloat(zs.dd); + if (isNaN(zg) || isNaN(zd)) { return; } + const color = '#F1C40F'; + const topSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + topSeries.setData([{ time: startTime, value: zg }, { time: endTime, value: zg }]); + const bottomSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + bottomSeries.setData([{ time: startTime, value: zd }, { time: endTime, value: zd }]); + const leftSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + leftSeries.setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); + const rightSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + rightSeries.setData([{ time: endTime, value: zd }, { time: endTime, value: zg }]); + if (!isNaN(gg) && gg > 0) { + const ggSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + ggSeries.setData([{ time: startTime, value: gg }, { time: endTime, value: gg }]); + } + if (!isNaN(dd) && dd > 0) { + const ddSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + ddSeries.setData([{ time: startTime, value: dd }, { time: endTime, value: dd }]); + } + } catch (e) { console.error('主周期BI中枢处理出错:', e); } + }); + } + if ($('#showSubSubBiZs').is(':checked') && currentData.sub_sub_bi_zs_list && currentData.sub_sub_bi_zs_list.length > 0) { + currentData.sub_sub_bi_zs_list.forEach(function(zs) { + try { + const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); + const kd = currentData.kline_data || []; + const endTime = zs.end_time ? Math.floor(new Date(zs.end_time).getTime() / 1000) : (kd.length ? Math.floor(new Date(kd[kd.length-1].date).getTime() / 1000) : 0); + if (isNaN(startTime) || !endTime) return; + const zg = parseFloat(zs.zg); const zd = parseFloat(zs.zd); const gg = parseFloat(zs.gg); const dd = parseFloat(zs.dd); + if (isNaN(zg) || isNaN(zd)) return; + const color = '#00897b'; + mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zg }, { time: endTime, value: zg }]); + mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zd }, { time: endTime, value: zd }]); + mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); + mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: endTime, value: zd }, { time: endTime, value: zg }]); + if (!isNaN(gg) && gg > 0) mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: gg }, { time: endTime, value: gg }]); + if (!isNaN(dd) && dd > 0) mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: dd }, { time: endTime, value: dd }]); + } catch (e) { console.error('次次周期BI中枢处理出错:', e); } + }); + } + if ($('#showElementBiZs').is(':checked') && currentData.element_bi_zs_list && currentData.element_bi_zs_list.length > 0) { + try { + console.log(`绘制次周期BI中枢数据,共${currentData.element_bi_zs_list.length}条`); + } catch (e) {} + currentData.element_bi_zs_list.forEach(function(zs) { + try { + const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); + const endTime = zs.end_time ? Math.floor(new Date(zs.end_time).getTime() / 1000) : Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); + if (isNaN(startTime) || isNaN(endTime)) { return; } + const zg = parseFloat(zs.zg); const zd = parseFloat(zs.zd); const gg = parseFloat(zs.gg); const dd = parseFloat(zs.dd); + if (isNaN(zg) || isNaN(zd)) { return; } + const color = '#3f51b5'; + const topSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + topSeries.setData([{ time: startTime, value: zg }, { time: endTime, value: zg }]); + const bottomSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + bottomSeries.setData([{ time: startTime, value: zd }, { time: endTime, value: zd }]); + const leftSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + leftSeries.setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); + const rightSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + rightSeries.setData([{ time: endTime, value: zd }, { time: endTime, value: zg }]); + if (!isNaN(gg) && gg > 0) { + const ggSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + ggSeries.setData([{ time: startTime, value: gg }, { time: endTime, value: gg }]); + } + if (!isNaN(dd) && dd > 0) { + const ddSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + ddSeries.setData([{ time: startTime, value: dd }, { time: endTime, value: dd }]); + } + } catch (e) { console.error('次周期BI中枢处理出错:', e); } + }); + } + // 结构价值区绘制(半透明填充区 + 边框) + if ($('#showMainStructureZone').is(':checked') && currentData.structure_zones && currentData.structure_zones.length > 0) { + try { + const kd = currentData.kline_data || []; + if (kd.length > 0) { + const chartStart = Math.floor(new Date(kd[0].date).getTime() / 1000); + const chartEnd = Math.floor(new Date(kd[kd.length-1].date).getTime() / 1000); + // 计算可见价格范围,过滤超出范围的区间 + let priceMin = Infinity, priceMax = -Infinity; + kd.forEach(function(k) { + const hi = parseFloat(k.high), lo = parseFloat(k.low); + if (!isNaN(hi) && hi > priceMax) priceMax = hi; + if (!isNaN(lo) && lo < priceMin) priceMin = lo; + }); + const priceMargin = (priceMax - priceMin) * 0.05; + priceMin -= priceMargin; + priceMax += priceMargin; + let drawnCount = 0; + currentData.structure_zones.forEach(function(zone) { + try { + // 跳过完全超出可视价格范围的区间 + if (zone.upper < priceMin || zone.lower > priceMax) return; + const fillColor = zone.zone_type === 'support' ? 'rgba(46, 204, 113, 0.08)' : + zone.zone_type === 'resistance' ? 'rgba(231, 76, 60, 0.08)' : + 'rgba(149, 165, 166, 0.06)'; + const borderColor = zone.zone_type === 'support' ? 'rgba(46, 204, 113, 0.7)' : + zone.zone_type === 'resistance' ? 'rgba(231, 76, 60, 0.7)' : + 'rgba(149, 165, 166, 0.6)'; + // 填充区:在上下边界之间画多条半透明线模拟填充 + const fillLines = 8; + const step = (zone.upper - zone.lower) / (fillLines + 1); + for (let fi = 1; fi <= fillLines; fi++) { + const fy = zone.lower + step * fi; + mainChart.addLineSeries({ color: fillColor, lineWidth: 2, lineStyle: 0, lastValueVisible: false, priceLineVisible: false }) + .setData([{ time: chartStart, value: fy }, { time: chartEnd, value: fy }]); + } + // 上边界(粗线) + mainChart.addLineSeries({ color: borderColor, lineWidth: 2, lineStyle: 0, lastValueVisible: false, priceLineVisible: false }) + .setData([{ time: chartStart, value: zone.upper }, { time: chartEnd, value: zone.upper }]); + // 下边界(粗线) + mainChart.addLineSeries({ color: borderColor, lineWidth: 2, lineStyle: 0, lastValueVisible: false, priceLineVisible: false }) + .setData([{ time: chartStart, value: zone.lower }, { time: chartEnd, value: zone.lower }]); + // 中心线(虚线) + mainChart.addLineSeries({ color: borderColor, lineWidth: 1, lineStyle: 2, lastValueVisible: false, priceLineVisible: false }) + .setData([{ time: chartStart, value: zone.center }, { time: chartEnd, value: zone.center }]); + drawnCount++; + } catch (e) { console.error('结构区绘制出错:', e); } + }); + console.log(`结构区: 共${currentData.structure_zones.length}个, 绘制${drawnCount}个 (可见价格范围: ${priceMin.toFixed(0)}-${priceMax.toFixed(0)})`); + } + } catch (e) { console.error('结构区整体绘制出错:', e); } + } + // 显示未完成中枢 - 分别处理主周期、次周期和次次周期 + if ($('#showMainZs').is(':checked') || $('#showElementZs').is(':checked') || $('#showSubSubZs').is(':checked') || $('#showSubSubBiZs').is(':checked')) { + console.log('绘制未完成中枢 - 已启用'); + // 显示BI中枢绘制(沿用中枢样式) + if ($('#showMainBiZs').is(':checked') || $('#showElementBiZs').is(':checked') || $('#showSubSubBiZs').is(':checked')) { + console.log('绘制BI中枢 - 已启用'); + // 主周期 BI 中枢 + console.log('主BI开关:', $('#showMainBiZs').is(':checked'), '数据长度:', currentData.bi_zs_list ? currentData.bi_zs_list.length : 0); + if ($('#showMainBiZs').is(':checked') && currentData.bi_zs_list && currentData.bi_zs_list.length > 0) { + console.log(`绘制主周期BI中枢数据,共${currentData.bi_zs_list.length}条`); + currentData.bi_zs_list.forEach(function(zs) { + try { + const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); + const endTime = zs.end_time ? Math.floor(new Date(zs.end_time).getTime() / 1000) : Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); + if (isNaN(startTime) || isNaN(endTime)) { return; } + const zg = parseFloat(zs.zg), zd = parseFloat(zs.zd), gg = parseFloat(zs.gg), dd = parseFloat(zs.dd); + if (isNaN(zg) || isNaN(zd)) { return; } + const color = '#9C27B0'; // 主周期BI中枢颜色(紫色) + const topSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + topSeries.setData([{ time: startTime, value: zg }, { time: endTime, value: zg }]); + const bottomSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + bottomSeries.setData([{ time: startTime, value: zd }, { time: endTime, value: zd }]); + const leftSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + leftSeries.setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); + const rightSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + rightSeries.setData([{ time: endTime, value: zd }, { time: endTime, value: zg }]); + if (!isNaN(gg) && gg > 0) { + const ggSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + ggSeries.setData([{ time: startTime, value: gg }, { time: endTime, value: gg }]); + } + if (!isNaN(dd) && dd > 0) { + const ddSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + ddSeries.setData([{ time: startTime, value: dd }, { time: endTime, value: dd }]); + } + } catch (e) { console.error('主周期BI中枢处理出错:', e); } + }); + } + // 主周期 未完成 BI 中枢 + console.log('主未完成BI长度:', currentData.uncompleted_bi_zs_list ? currentData.uncompleted_bi_zs_list.length : 0); + if ($('#showMainBiZs').is(':checked') && currentData.uncompleted_bi_zs_list && currentData.uncompleted_bi_zs_list.length > 0) { + console.log(`绘制主周期未完成BI中枢数据,共${currentData.uncompleted_bi_zs_list.length}条`); + currentData.uncompleted_bi_zs_list.forEach(function(zs) { + try { + const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); + const endTime = Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); + if (isNaN(startTime) || isNaN(endTime)) { return; } + const zg = parseFloat(zs.zg), zd = parseFloat(zs.zd), gg = parseFloat(zs.gg), dd = parseFloat(zs.dd); + if (isNaN(zg) || isNaN(zd)) { return; } + const color = '#9C27B0'; + const topSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + topSeries.setData([{ time: startTime, value: zg }, { time: endTime, value: zg }]); + const bottomSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + bottomSeries.setData([{ time: startTime, value: zd }, { time: endTime, value: zd }]); + const leftSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + leftSeries.setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); + if (!isNaN(gg) && gg > 0) { + const ggSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + ggSeries.setData([{ time: startTime, value: gg }, { time: endTime, value: gg }]); + } + if (!isNaN(dd) && dd > 0) { + const ddSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + ddSeries.setData([{ time: startTime, value: dd }, { time: endTime, value: dd }]); + } + } catch (e) { console.error('主周期未完成BI中枢处理出错:', e); } + }); + } + // 次周期 BI 中枢 + console.log('次BI开关:', $('#showElementBiZs').is(':checked'), '数据长度:', currentData.element_bi_zs_list ? currentData.element_bi_zs_list.length : 0); + if ($('#showElementBiZs').is(':checked') && currentData.element_bi_zs_list && currentData.element_bi_zs_list.length > 0) { + console.log(`绘制次周期BI中枢数据,共${currentData.element_bi_zs_list.length}条`); + currentData.element_bi_zs_list.forEach(function(zs) { + try { + const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); + const endTime = zs.end_time ? Math.floor(new Date(zs.end_time).getTime() / 1000) : Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); + if (isNaN(startTime) || isNaN(endTime)) { return; } + const zg = parseFloat(zs.zg), zd = parseFloat(zs.zd), gg = parseFloat(zs.gg), dd = parseFloat(zs.dd); + if (isNaN(zg) || isNaN(zd)) { return; } + const color = '#8BC34A'; // 次周期BI中枢颜色(绿) + const topSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + topSeries.setData([{ time: startTime, value: zg }, { time: endTime, value: zg }]); + const bottomSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + bottomSeries.setData([{ time: startTime, value: zd }, { time: endTime, value: zd }]); + const leftSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + leftSeries.setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); + const rightSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + rightSeries.setData([{ time: endTime, value: zd }, { time: endTime, value: zg }]); + if (!isNaN(gg) && gg > 0) { + const ggSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + ggSeries.setData([{ time: startTime, value: gg }, { time: endTime, value: gg }]); + } + if (!isNaN(dd) && dd > 0) { + const ddSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + ddSeries.setData([{ time: startTime, value: dd }, { time: endTime, value: dd }]); + } + } catch (e) { console.error('次周期BI中枢处理出错:', e); } + }); + } + // 次周期 未完成 BI 中枢 + console.log('次未完成BI长度:', currentData.element_uncompleted_bi_zs_list ? currentData.element_uncompleted_bi_zs_list.length : 0); + if ($('#showElementBiZs').is(':checked') && currentData.element_uncompleted_bi_zs_list && currentData.element_uncompleted_bi_zs_list.length > 0) { + console.log(`绘制次周期未完成BI中枢数据,共${currentData.element_uncompleted_bi_zs_list.length}条`); + currentData.element_uncompleted_bi_zs_list.forEach(function(zs) { + try { + const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); + const endTime = Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); + if (isNaN(startTime) || isNaN(endTime)) { return; } + const zg = parseFloat(zs.zg), zd = parseFloat(zs.zd), gg = parseFloat(zs.gg), dd = parseFloat(zs.dd); + if (isNaN(zg) || isNaN(zd)) { return; } + const color = '#8BC34A'; + const topSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + topSeries.setData([{ time: startTime, value: zg }, { time: endTime, value: zg }]); + const bottomSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + bottomSeries.setData([{ time: startTime, value: zd }, { time: endTime, value: zd }]); + const leftSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + leftSeries.setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); + if (!isNaN(gg) && gg > 0) { + const ggSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + ggSeries.setData([{ time: startTime, value: gg }, { time: endTime, value: gg }]); + } + if (!isNaN(dd) && dd > 0) { + const ddSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + ddSeries.setData([{ time: startTime, value: dd }, { time: endTime, value: dd }]); + } + } catch (e) { console.error('次周期未完成BI中枢处理出错:', e); } + }); + } + // 次次周期 未完成 BI 中枢 + if ($('#showSubSubBiZs').is(':checked') && currentData.sub_sub_uncompleted_bi_zs_list && currentData.sub_sub_uncompleted_bi_zs_list.length > 0) { + const kdBi = currentData.kline_data || []; + const endTimeBi = kdBi.length ? Math.floor(new Date(kdBi[kdBi.length-1].date).getTime() / 1000) : 0; + currentData.sub_sub_uncompleted_bi_zs_list.forEach(function(zs) { + try { + const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); + if (isNaN(startTime) || !endTimeBi) return; + const zg = parseFloat(zs.zg), zd = parseFloat(zs.zd), gg = parseFloat(zs.gg), dd = parseFloat(zs.dd); + if (isNaN(zg) || isNaN(zd)) return; + const color = '#00897b'; + mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zg }, { time: endTimeBi, value: zg }]); + mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zd }, { time: endTimeBi, value: zd }]); + mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); + if (!isNaN(gg) && gg > 0) mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: gg }, { time: endTimeBi, value: gg }]); + if (!isNaN(dd) && dd > 0) mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: dd }, { time: endTimeBi, value: dd }]); + } catch (e) { console.error('次次周期未完成BI中枢处理出错:', e); } + }); + } + } + // 主周期未完成中枢 + if ($('#showMainZs').is(':checked') && currentData.uncompleted_zs_list && currentData.uncompleted_zs_list.length > 0) { + console.log(`绘制主周期未完成中枢数据,共${currentData.uncompleted_zs_list.length}条`); + + currentData.uncompleted_zs_list.forEach(function(zs) { + try { + // 直接使用UTC时间戳(秒) + const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); + // 未完成中枢的结束时间设为当前K线的最后时间 + const endTime = Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); + + if (isNaN(startTime) || isNaN(endTime)) { + console.error('主周期未完成中枢时间转换错误:', zs.start_time); + return; + } + + const zg = parseFloat(zs.zg); // 中枢上沿 + const zd = parseFloat(zs.zd); // 中枢下沿 + const gg = parseFloat(zs.gg); // 中枢高高 + const dd = parseFloat(zs.dd); // 中枢低低 + + if (isNaN(zg) || isNaN(zd)) { + console.error('主周期未完成中枢价格转换错误:', zs.zg, zs.zd); + return; + } + + // 创建未完成中枢上边界 + const topSeries = mainChart.addLineSeries({ + color: '#F1C40F', // 主周期中枢颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + topSeries.setData([ + { time: startTime, value: zg }, + { time: endTime, value: zg } + ]); + + // 为下边界创建另一条线 + const bottomSeries = mainChart.addLineSeries({ + color: '#F1C40F', // 主周期中枢颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + bottomSeries.setData([ + { time: startTime, value: zd }, + { time: endTime, value: zd } + ]); + + // 添加左边界 + const leftSeries = mainChart.addLineSeries({ + color: '#F1C40F', // 主周期中枢颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + leftSeries.setData([ + { time: startTime, value: zd }, + { time: startTime, value: zg } + ]); + + // 添加一个标记,标识这是未完成中枢 + const markerSeries = mainChart.addLineSeries({ + lastValueVisible: false, + priceLineVisible: false, + }); + + markerSeries.setMarkers([ + { + time: startTime, + position: 'aboveBar', + color: '#F1C40F', + shape: 'circle', + text: '未完', + size: 1 + } + ]); + + // 绘制gg线(中枢高高) + if (!isNaN(gg) && gg > 0) { + const ggSeries = mainChart.addLineSeries({ + color: '#F1C40F', // 使用中枢自己的颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + ggSeries.setData([ + { time: startTime, value: gg }, + { time: endTime, value: gg } + ]); + } + + // 绘制dd线(中枢低低) + if (!isNaN(dd) && dd > 0) { + const ddSeries = mainChart.addLineSeries({ + color: '#F1C40F', // 使用中枢自己的颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + ddSeries.setData([ + { time: startTime, value: dd }, + { time: endTime, value: dd } + ]); + } + + // 添加到图表对象 + tvWidget.series.mainUncompletedZsSeries.push({ + time: startTime, + value: zg, + color: '#F1C40F', + lineWidth: 1 + }); + tvWidget.series.mainUncompletedZsSeries.push({ + time: endTime, + value: zg, + color: '#F1C40F', + lineWidth: 1 + }); + tvWidget.series.mainUncompletedZsSeries.push({ + time: startTime, + value: zd, + color: '#F1C40F', + lineWidth: 1 + }); + tvWidget.series.mainUncompletedZsSeries.push({ + time: endTime, + value: zd, + color: '#F1C40F', + lineWidth: 1 + }); + } catch (e) { + console.error('主周期未完成中枢处理出错:', e); + } + }); + } + + // 次周期未完成中枢 + if ($('#showElementZs').is(':checked') && currentData.element_uncompleted_zs_list && currentData.element_uncompleted_zs_list.length > 0) { + console.log(`绘制次周期未完成中枢数据,共${currentData.element_uncompleted_zs_list.length}条`); + + currentData.element_uncompleted_zs_list.forEach(function(zs) { + try { + // 直接使用UTC时间戳(秒) + const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); + // 未完成中枢的结束时间设为当前K线的最后时间 + const endTime = Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); + + if (isNaN(startTime) || isNaN(endTime)) { + console.error('次周期未完成中枢时间转换错误:', zs.start_time); + return; + } + + const zg = parseFloat(zs.zg); // 中枢上沿 + const zd = parseFloat(zs.zd); // 中枢下沿 + const gg = parseFloat(zs.gg); // 中枢高高 + const dd = parseFloat(zs.dd); // 中枢低低 + + if (isNaN(zg) || isNaN(zd)) { + console.error('次周期未完成中枢价格转换错误:', zs.zg, zs.zd); + return; + } + + // 创建未完成中枢上边界 + const topSeries = mainChart.addLineSeries({ + color: '#3f51b5', // 次周期中枢颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + topSeries.setData([ + { time: startTime, value: zg }, + { time: endTime, value: zg } + ]); + + // 为下边界创建另一条线 + const bottomSeries = mainChart.addLineSeries({ + color: '#3f51b5', // 次周期中枢颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + bottomSeries.setData([ + { time: startTime, value: zd }, + { time: endTime, value: zd } + ]); + + // 添加左边界 + const leftSeries = mainChart.addLineSeries({ + color: '#3f51b5', // 次周期中枢颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + leftSeries.setData([ + { time: startTime, value: zd }, + { time: startTime, value: zg } + ]); + + // 添加一个标记,标识这是未完成中枢 + const markerSeries = mainChart.addLineSeries({ + lastValueVisible: false, + priceLineVisible: false, + }); + + markerSeries.setMarkers([ + { + time: startTime, + position: 'aboveBar', + color: '#3f51b5', + shape: 'circle', + text: '未完', + size: 1 + } + ]); + + // 绘制gg线(中枢高高) + if (!isNaN(gg) && gg > 0) { + const ggSeries = mainChart.addLineSeries({ + color: '#3f51b5', // 使用次周期中枢自己的颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + ggSeries.setData([ + { time: startTime, value: gg }, + { time: endTime, value: gg } + ]); + } + + // 绘制dd线(中枢低低) + if (!isNaN(dd) && dd > 0) { + const ddSeries = mainChart.addLineSeries({ + color: '#3f51b5', // 使用次周期中枢自己的颜色 + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + }); + + ddSeries.setData([ + { time: startTime, value: dd }, + { time: endTime, value: dd } + ]); + } + + // 添加到图表对象 + tvWidget.series.elementUncompletedZsSeries.push({ + time: startTime, + value: zg, + color: '#3f51b5', + lineWidth: 1 + }); + tvWidget.series.elementUncompletedZsSeries.push({ + time: endTime, + value: zg, + color: '#3f51b5', + lineWidth: 1 + }); + tvWidget.series.elementUncompletedZsSeries.push({ + time: startTime, + value: zd, + color: '#3f51b5', + lineWidth: 1 + }); + tvWidget.series.elementUncompletedZsSeries.push({ + time: endTime, + value: zd, + color: '#3f51b5', + lineWidth: 1 + }); + } catch (e) { + console.error('次周期未完成中枢处理出错:', e); + } + }); + } + // 次次周期未完成SEG中枢 + if ($('#showSubSubZs').is(':checked') && currentData.sub_sub_uncompleted_zs_list && currentData.sub_sub_uncompleted_zs_list.length > 0) { + const kdZs = currentData.kline_data || []; + const endTimeZs = kdZs.length ? Math.floor(new Date(kdZs[kdZs.length-1].date).getTime() / 1000) : 0; + const subSubUZsColor = '#00897b'; + currentData.sub_sub_uncompleted_zs_list.forEach(function(zs) { + try { + const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); + if (isNaN(startTime) || !endTimeZs) return; + const zg = parseFloat(zs.zg); const zd = parseFloat(zs.zd); const gg = parseFloat(zs.gg); const dd = parseFloat(zs.dd); + if (isNaN(zg) || isNaN(zd)) return; + mainChart.addLineSeries({ color: subSubUZsColor, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zg }, { time: endTimeZs, value: zg }]); + mainChart.addLineSeries({ color: subSubUZsColor, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zd }, { time: endTimeZs, value: zd }]); + mainChart.addLineSeries({ color: subSubUZsColor, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); + if (!isNaN(gg) && gg > 0) mainChart.addLineSeries({ color: subSubUZsColor, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: gg }, { time: endTimeZs, value: gg }]); + if (!isNaN(dd) && dd > 0) mainChart.addLineSeries({ color: subSubUZsColor, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }).setData([{ time: startTime, value: dd }, { time: endTimeZs, value: dd }]); + } catch (e) { console.error('次次周期未完成中枢处理出错:', e); } + }); + } + } else { + console.log('绘制未完成中枢 - 已禁用'); + } + // 未完成BI中枢 - 使用独立的BI开关 + if ($('#showMainBiZs').is(':checked') && currentData.uncompleted_bi_zs_list && currentData.uncompleted_bi_zs_list.length > 0) { + try { console.log(`绘制主周期未完成BI中枢数据,共${currentData.uncompleted_bi_zs_list.length}条`); } catch (e) {} + currentData.uncompleted_bi_zs_list.forEach(function(zs) { + try { + const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); + const endTime = Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); + if (isNaN(startTime) || isNaN(endTime)) { return; } + const zg = parseFloat(zs.zg); const zd = parseFloat(zs.zd); const gg = parseFloat(zs.gg); const dd = parseFloat(zs.dd); + if (isNaN(zg) || isNaN(zd)) { return; } + const color = '#F1C40F'; + const topSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + topSeries.setData([{ time: startTime, value: zg }, { time: endTime, value: zg }]); + const bottomSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + bottomSeries.setData([{ time: startTime, value: zd }, { time: endTime, value: zd }]); + const leftSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + leftSeries.setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); + if (!isNaN(gg) && gg > 0) { + const ggSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + ggSeries.setData([{ time: startTime, value: gg }, { time: endTime, value: gg }]); + } + if (!isNaN(dd) && dd > 0) { + const ddSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + ddSeries.setData([{ time: startTime, value: dd }, { time: endTime, value: dd }]); + } + } catch (e) { console.error('主周期未完成BI中枢处理出错:', e); } + }); + } + // 次周期 未完成 BI 中枢 + if ($('#showElementBiZs').is(':checked') && currentData.element_uncompleted_bi_zs_list && currentData.element_uncompleted_bi_zs_list.length > 0) { + try { console.log(`绘制次周期未完成BI中枢数据,共${currentData.element_uncompleted_bi_zs_list.length}条`); } catch (e) {} + currentData.element_uncompleted_bi_zs_list.forEach(function(zs) { + try { + const startTime = Math.floor(new Date(zs.start_time).getTime() / 1000); + const endTime = Math.floor(new Date(currentData.kline_data[currentData.kline_data.length-1].date).getTime() / 1000); + if (isNaN(startTime) || isNaN(endTime)) { return; } + const zg = parseFloat(zs.zg); const zd = parseFloat(zs.zd); const gg = parseFloat(zs.gg); const dd = parseFloat(zs.dd); + if (isNaN(zg) || isNaN(zd)) { return; } + const color = '#3f51b5'; + const topSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + topSeries.setData([{ time: startTime, value: zg }, { time: endTime, value: zg }]); + const bottomSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + bottomSeries.setData([{ time: startTime, value: zd }, { time: endTime, value: zd }]); + const leftSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + leftSeries.setData([{ time: startTime, value: zd }, { time: startTime, value: zg }]); + if (!isNaN(gg) && gg > 0) { + const ggSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + ggSeries.setData([{ time: startTime, value: gg }, { time: endTime, value: gg }]); + } + if (!isNaN(dd) && dd > 0) { + const ddSeries = mainChart.addLineSeries({ color, lineWidth: 1, lastValueVisible: false, priceLineVisible: false }); + ddSeries.setData([{ time: startTime, value: dd }, { time: endTime, value: dd }]); + } + } catch (e) { console.error('次周期未完成BI中枢处理出错:', e); } + }); + } + // 添加买卖点标记(新版:基于 bsp_list / element_bsp_list / sub_sub_bsp_list,按与 KLC 分型相同方式合并到主图标记) + if ($('#showMainBsp').is(':checked') || $('#showElementBsp').is(':checked') || $('#showSubSubBsp').is(':checked')) { + console.log('绘制买卖点(BSP) - 已启用'); + + // BSP 样式定义 + const BSP_STYLE = { + 'BSP1_BUY': { color: '#FF1744', text: 'B1', position: 'belowBar', size: 0.5 }, + 'BSP2_BUY': { color: '#F50057', text: 'B2', position: 'belowBar', size: 0.5 }, + 'BSP3_BUY': { color: '#D500F9', text: 'B3', position: 'belowBar', size: 0.5 }, + 'BSP1_SELL': { color: '#00E676', text: 'S1', position: 'aboveBar', size: 0.5 }, + 'BSP2_SELL': { color: '#00B0FF', text: 'S2', position: 'aboveBar', size: 0.5 }, + 'BSP3_SELL': { color: '#8B4513', text: 'S3', position: 'aboveBar', size: 0.5 }, + }; + + const getBspStyleKey = (bsp) => { + // 统一 BSP key: + // - type 可能是 "BSP1"/"BSP2"/"BSP3", + // - 也可能是后端给的 "B1"/"B2"/"B3" 或 "S1"/"S2"/"S3" + // 最终都映射为 "BSP1_BUY" / "BSP1_SELL" 这类 key,方便复用现有样式定义 + let type = (bsp.type || '').toUpperCase(); + const dir = (bsp.dir || '').toUpperCase(); + + // 若是 "B1" / "B2" / "B3" 或 "S1" / "S2" / "S3" 形式,则提取数字并映射成 "BSP{n}" + const simpleMatch = type.match(/^([BS])(\d)$/); + if (simpleMatch) { + const n = simpleMatch[2]; // "1" / "2" / "3" + type = 'BSP' + n; + } + + return type + '_' + dir; + }; + + // 收集所有 BSP 标记 + const allBspMarkers = []; + + // 主周期买卖点 + // 兼容不同字段命名:优先使用 bsp_list,若不存在则尝试 bsp + const mainBspList = currentData.bsp_list || currentData.bsp || []; + // 调试:打印前几条主周期 BSP 的 key,方便排查样式不匹配问题 + if (mainBspList.length > 0) { + console.log( + '主周期 BSP 示例 (前5条):', + mainBspList.slice(0, 5).map(b => ({ + raw_type: b.type, + raw_dir: b.dir, + key: getBspStyleKey(b) + })) + ); + } + if ($('#showMainBsp').is(':checked') && mainBspList.length > 0) { + console.log(`绘制主周期买卖点,共${mainBspList.length}条`); + mainBspList.forEach(function(bsp) { + try { + const ts = Math.floor(new Date(bsp.time).getTime() / 1000); + if (isNaN(ts)) return; + const key = getBspStyleKey(bsp); + const style = BSP_STYLE[key] || { color: '#999', shape: 'circle', text: '?', position: 'inBar' }; + const sureText = bsp.is_sure ? '' : '?'; + allBspMarkers.push({ + time: ts, + position: style.position, + color: style.color, + shape: style.shape, + text: style.text + sureText, + size: 2 + }); + } catch (e) { + console.error('主周期BSP处理出错:', e); + } + }); + } + + // 次周期买卖点 + // 兼容不同字段命名:优先使用 element_bsp_list,若不存在则尝试 element_bsp + const elementBspList = currentData.element_bsp_list || currentData.element_bsp || []; + // 调试:打印前几条次周期 BSP 的 key + if (elementBspList.length > 0) { + console.log( + '次周期 BSP 示例 (前5条):', + elementBspList.slice(0, 5).map(b => ({ + raw_type: b.type, + raw_dir: b.dir, + key: getBspStyleKey(b) + })) + ); + } + if ($('#showElementBsp').is(':checked') && elementBspList.length > 0) { + console.log(`绘制次周期买卖点,共${elementBspList.length}条`); + elementBspList.forEach(function(bsp) { + try { + const ts = Math.floor(new Date(bsp.time).getTime() / 1000); + if (isNaN(ts)) return; + const key = getBspStyleKey(bsp); + const style = BSP_STYLE[key] || { color: '#999', shape: 'circle', text: '?', position: 'inBar' }; + const sureText = bsp.is_sure ? '' : '?'; + // 次周期使用稍小的标记和不同前缀以区分 + allBspMarkers.push({ + time: ts, + position: style.position, + color: style.color, + shape: style.shape, + text: 'e' + style.text + sureText, + size: 1 + }); + } catch (e) { + console.error('次周期BSP处理出错:', e); + } + }); + } + + // 次次周期买卖点 + const subSubBspList = currentData.sub_sub_bsp_list || []; + if ($('#showSubSubBsp').is(':checked') && subSubBspList.length > 0) { + subSubBspList.forEach(function(bsp) { + try { + const ts = Math.floor(new Date(bsp.time).getTime() / 1000); + if (isNaN(ts)) return; + const key = getBspStyleKey(bsp); + const style = BSP_STYLE[key] || { color: '#999', shape: 'circle', text: '?', position: 'inBar' }; + const sureText = bsp.is_sure ? '' : '?'; + allBspMarkers.push({ + time: ts, + position: style.position, + color: '#00897b', + shape: style.shape, + text: 's' + (style.text || '?') + sureText, + size: 1 + }); + } catch (e) { + console.error('次次周期BSP处理出错:', e); + } + }); + } + + // 将 BSP 标记挂到全局,后面与 KLC 分型等标记一起合并到主系列上 + if (allBspMarkers.length > 0) { + // 按时间排序(lightweight-charts 要求标记按时间升序) + allBspMarkers.sort((a, b) => a.time - b.time); + window.bspMarkers = allBspMarkers; + console.log(`准备合并 ${allBspMarkers.length} 个BSP标记到主图标记中`); + } else { + window.bspMarkers = []; + } + } else { + // 关闭 BSP 显示时,清空全局 BSP 标记 + window.bspMarkers = []; + } + + // 添加买卖点标记(旧版,保留兼容) + // 这里为了与主面板上的「买卖点」开关保持一致, + // 同时响应顶部的 `#showMainBsp` 复选框 + if ($('#showTradePoints').is(':checked') || $('#showMainBsp').is(':checked')) { + console.log('绘制买卖点 - 已启用(来源: showTradePoints / showMainBsp)'); + + // 优先使用小周期数据,如果不存在则使用主周期数据 + const tradePointsData = currentData.element_trade_points || currentData.trade_points; + console.log(`绘制${currentData.element_trade_points ? '元素周期' : '主周期'}买卖点数据,共${tradePointsData ? tradePointsData.length : 0}条`); + + // 调试信息 - 输出完整的买卖点数据 + if (tradePointsData && tradePointsData.length > 0) { + console.log("买卖点数据样例:", tradePointsData[0]); + + // 检查数据格式,如果time不是标准格式,进行格式化处理 + const checkDataFormat = () => { + for (let i = 0; i < tradePointsData.length; i++) { + if (tradePointsData[i].time) { + // 确保时间是标准格式 + try { + const timeValue = new Date(tradePointsData[i].time); + if (isNaN(timeValue.getTime())) { + console.error(`买卖点 #${i} 时间格式无效:`, tradePointsData[i].time); + } + } catch (e) { + console.error(`买卖点 #${i} 时间格式异常:`, e); + } + } else { + console.error(`买卖点 #${i} 缺少时间属性`); + } + } + }; + + // 执行格式检查 + checkDataFormat(); + + // 对买卖点按时间排序,用于后续优化显示 + const sortedPoints = [...tradePointsData].sort((a, b) => { + return new Date(a.time) - new Date(b.time); + }); + + // 记录已处理的时间点 - 按类型分开计数 + const processedTimes = {}; + + // 创建买卖点标记系列 + const buyMarkers = []; + const sellMarkers = []; + + // 计数器,追踪成功和失败的处理次数 + let successCount = 0; + let errorCount = 0; + + sortedPoints.forEach(function(point, index) { + try { + // 检查所有必要的属性是否存在且有效 + if (!point.time || !point.price || point.type === undefined) { + console.error(`买卖点 #${index} 数据不完整:`, point); + errorCount++; + return; + } + + const time = Math.floor(new Date(point.time).getTime() / 1000); + const price = parseFloat(point.price); + const type = parseInt(point.type); + + if (isNaN(time) || isNaN(price) || isNaN(type)) { + console.error(`买卖点 #${index} 数据格式错误:`, + { time: isNaN(time), price: isNaN(price), type: isNaN(type) }, point); + errorCount++; + return; + } + + // 获取买卖点样式 + const style = TRADE_POINT_STYLE[type] || { + color: '#999999', + shape: 'circle', + text: '?', + size: 1 + }; + + // 初始化该时间点的类型计数器 + if (!processedTimes[time]) { + processedTimes[time] = {}; + } + + // 优化:检查是否有相同时间点和相同类型的标记,如果有,进行类型内的偏移 + let stackIndex = 0; + if (processedTimes[time][type]) { + // 已经有相同时间和类型的标记,记录堆叠索引 + stackIndex = processedTimes[time][type]; + processedTimes[time][type]++; + } else { + // 第一次出现这个时间点的这个类型 + processedTimes[time][type] = 1; + } + + // 为不同类型的买卖点获取基础垂直偏移系数 + const baseOffset = TRADE_POINT_OFFSET[type] || 0; + + // 创建标记对象,包含额外的信息用于悬停提示 + const marker = { + time: time, + position: 'inBar', // 改为在K线内部显示,不影响数据 + color: style.color, + shape: style.shape, + text: style.text, + size: style.size, + // 记录堆叠索引 + stackIndex: stackIndex, + // 添加悬停提示的数据 + tooltip: `${point.desc || (type > 0 ? '买点' : '卖点')}
+ 时间: ${formatTime(point.time)}
+ 价格: ${price.toFixed(2)}`, + // 额外添加基础类型偏移 + baseOffset: baseOffset, + // 添加边框 + borderColor: 'white', + borderWidth: 1, + // 添加价格偏移系数 + pricePercentOffset: PRICE_PERCENT_OFFSET[type] || 0, + // 保存实际价格用于计算 + price: price, + // 保存类型 + type: type + }; + + // 区分买卖点 + if (type > 0) { + buyMarkers.push(marker); + } else { + sellMarkers.push(marker); + } + + successCount++; + } catch (e) { + console.error(`处理买卖点 #${index} 出错:`, e, point); + errorCount++; + } + }); + + console.log(`买卖点处理完成: 成功=${successCount}, 失败=${errorCount}, 买点=${buyMarkers.length}, 卖点=${sellMarkers.length}`); + + // 分别添加买卖点标记 + if (buyMarkers.length > 0) { + const buyMarkersSeries = mainChart.addLineSeries({ + lastValueVisible: false, + priceLineVisible: false, + lineVisible: false, + color: 'transparent', + title: '买点' + }); + + // 使用主K线的收盘价作为基准数据,保证买点标记与价格在同一纵轴范围 + if (Array.isArray(candles) && candles.length > 0) { + const baseData = candles.map(c => ({ time: c.time, value: c.close })); + buyMarkersSeries.setData(baseData); + } else { + // 兜底:至少一个数据点,避免报错 + buyMarkersSeries.setData([{ time: buyMarkers[0].time, value: buyMarkers[0].price || 0 }]); + } + + try { + // 设置买点标记:文字在价格上方,仅显示文字不显示形状 + buyMarkersSeries.setMarkers( + buyMarkers.map(marker => { + // 使用实际价格位置,买点显示在K线上方 + return { + ...marker, + position: 'aboveBar', // 买点:价格上方 + price: marker.price, + // 隐藏形状,仅保留文字 + size: 0, + color: 'rgba(0, 0, 0, 0)' + }; + }) + ); + console.log(`成功添加 ${buyMarkers.length} 个买点标记`); + } catch (e) { + console.error("设置买点标记时出错:", e); + } + } + + if (sellMarkers.length > 0) { + const sellMarkersSeries = mainChart.addLineSeries({ + lastValueVisible: false, + priceLineVisible: false, + lineVisible: false, + color: 'transparent', + title: '卖点' + }); + + // 使用主K线的收盘价作为基准数据,保证卖点标记与价格在同一纵轴范围 + if (Array.isArray(candles) && candles.length > 0) { + const baseData = candles.map(c => ({ time: c.time, value: c.close })); + sellMarkersSeries.setData(baseData); + } else { + // 兜底:至少一个数据点,避免报错 + sellMarkersSeries.setData([{ time: sellMarkers[0].time, value: sellMarkers[0].price || 0 }]); + } + + try { + // 设置卖点标记:文字在价格下方,仅显示文字不显示形状 + sellMarkersSeries.setMarkers( + sellMarkers.map(marker => { + // 使用实际价格位置,卖点显示在K线下方 + return { + ...marker, + position: 'belowBar', // 卖点:价格下方 + price: marker.price, + // 隐藏形状,仅保留文字 + size: 0, + color: 'rgba(0, 0, 0, 0)' + }; + }) + ); + console.log(`成功添加 ${sellMarkers.length} 个卖点标记`); + } catch (e) { + console.error("设置卖点标记时出错:", e); + } + } + + // 添加鼠标悬停事件显示提示 + mainChart.subscribeCrosshairMove(param => { + // 十字线同步到其他图表 - 通过DOM元素绘制垂直线实现虚线延长效果 + if (param.time && param.point && volumeChart) { + try { + // 清除之前的十字线标记 + const existingVolumeLines = document.querySelectorAll('.volume-crosshair-line'); + existingVolumeLines.forEach(line => line.remove()); + const existingAtrLines = document.querySelectorAll('.atr-crosshair-line'); + existingAtrLines.forEach(line => line.remove()); + const existingMacdLines = document.querySelectorAll('.macd-crosshair-line'); + existingMacdLines.forEach(line => line.remove()); + const existingChanMacdLines = document.querySelectorAll('.chanmacd-crosshair-line'); + existingChanMacdLines.forEach(line => line.remove()); + + // 获取时间对应的坐标位置 + const mainTimeCoordinate = mainChart.timeScale().timeToCoordinate(param.time); + if (mainTimeCoordinate !== null) { + // 获取主图容器的位置 + const mainChartRect = mainChartContainer.getBoundingClientRect(); + + // 在交易量图上绘制垂直线 + const volumeTimeCoordinate = volumeChart.timeScale().timeToCoordinate(param.time); + if (volumeTimeCoordinate !== null) { + const volumeChartRect = volumeChartContainer.getBoundingClientRect(); + const volumeLine = document.createElement('div'); + volumeLine.className = 'volume-crosshair-line'; + volumeLine.style.position = 'fixed'; // 改为fixed定位 + volumeLine.style.left = (volumeChartRect.left + volumeTimeCoordinate) + 'px'; + volumeLine.style.top = volumeChartRect.top + 'px'; + volumeLine.style.width = '1px'; + volumeLine.style.height = volumeChartRect.height + 'px'; + volumeLine.style.backgroundColor = 'rgba(128, 128, 128, 0.5)'; + volumeLine.style.borderLeft = '1px dashed rgba(128, 128, 128, 0.5)'; + volumeLine.style.pointerEvents = 'none'; + volumeLine.style.zIndex = '1000'; + document.body.appendChild(volumeLine); + } + + // 在ATR图上绘制垂直线 + if (atrChart && atrChartContainer) { + const atrTimeCoordinate = atrChart.timeScale().timeToCoordinate(param.time); + if (atrTimeCoordinate !== null) { + const atrChartRect = atrChartContainer.getBoundingClientRect(); + const atrLine = document.createElement('div'); + atrLine.className = 'atr-crosshair-line'; + atrLine.style.position = 'fixed'; // 改为fixed定位 + atrLine.style.left = (atrChartRect.left + atrTimeCoordinate) + 'px'; + atrLine.style.top = atrChartRect.top + 'px'; + atrLine.style.width = '1px'; + atrLine.style.height = atrChartRect.height + 'px'; + atrLine.style.backgroundColor = 'rgba(128, 128, 128, 0.5)'; + atrLine.style.borderLeft = '1px dashed rgba(128, 128, 128, 0.5)'; + atrLine.style.pointerEvents = 'none'; + atrLine.style.zIndex = '1000'; + document.body.appendChild(atrLine); + } + } + + // 如果有MACD图,也在MACD图上绘制垂直线 + if (showMacd && macdChart && macdChartContainer) { + const macdTimeCoordinate = macdChart.timeScale().timeToCoordinate(param.time); + if (macdTimeCoordinate !== null) { + const macdChartRect = macdChartContainer.getBoundingClientRect(); + const macdLine = document.createElement('div'); + macdLine.className = 'macd-crosshair-line'; + macdLine.style.position = 'fixed'; // 改为fixed定位 + macdLine.style.left = (macdChartRect.left + macdTimeCoordinate) + 'px'; + macdLine.style.top = macdChartRect.top + 'px'; + macdLine.style.width = '1px'; + macdLine.style.height = macdChartRect.height + 'px'; + macdLine.style.backgroundColor = 'rgba(128, 128, 128, 0.5)'; + macdLine.style.borderLeft = '1px dashed rgba(128, 128, 128, 0.5)'; + macdLine.style.pointerEvents = 'none'; + macdLine.style.zIndex = '1000'; + document.body.appendChild(macdLine); + } + } + + // 如果有ChanMACD图,也在ChanMACD图上绘制垂直线 + if (showMacd && chanMacdChart && chanMacdChartContainer) { + const chanMacdTimeCoordinate = chanMacdChart.timeScale().timeToCoordinate(param.time); + if (chanMacdTimeCoordinate !== null) { + const chanMacdChartRect = chanMacdChartContainer.getBoundingClientRect(); + console.log('ChanMACD图表位置:', { + left: chanMacdChartRect.left, + top: chanMacdChartRect.top, + width: chanMacdChartRect.width, + height: chanMacdChartRect.height, + timeCoordinate: chanMacdTimeCoordinate + }); + const chanMacdLine = document.createElement('div'); + chanMacdLine.className = 'chanmacd-crosshair-line'; + chanMacdLine.style.position = 'fixed'; + chanMacdLine.style.left = (chanMacdChartRect.left + chanMacdTimeCoordinate) + 'px'; + chanMacdLine.style.top = chanMacdChartRect.top + 'px'; + chanMacdLine.style.width = '1px'; + chanMacdLine.style.height = chanMacdChartRect.height + 'px'; + chanMacdLine.style.backgroundColor = 'rgba(128, 128, 128, 0.5)'; + chanMacdLine.style.borderLeft = '1px dashed rgba(128, 128, 128, 0.5)'; + chanMacdLine.style.pointerEvents = 'none'; + chanMacdLine.style.zIndex = '1000'; + document.body.appendChild(chanMacdLine); + console.log('ChanMACD垂直线已创建,位置:', chanMacdLine.style.left, chanMacdLine.style.top); + } else { + console.log('ChanMACD时间坐标为空'); + } + } else { + console.log('ChanMACD图表条件不满足:', { + showMacd: showMacd, + hasChanMacdChart: !!chanMacdChart, + hasChanMacdChartContainer: !!chanMacdChartContainer + }); + } + + + } + } catch (e) { + console.debug('十字线同步出错:', e); + } + } else { + // 当十字线离开时,清除垂直线 + try { + const existingVolumeLines = document.querySelectorAll('.volume-crosshair-line'); + existingVolumeLines.forEach(line => line.remove()); + const existingAtrLines = document.querySelectorAll('.atr-crosshair-line'); + existingAtrLines.forEach(line => line.remove()); + const existingMacdLines = document.querySelectorAll('.macd-crosshair-line'); + existingMacdLines.forEach(line => line.remove()); + const existingChanMacdLines = document.querySelectorAll('.chanmacd-crosshair-line'); + existingChanMacdLines.forEach(line => line.remove()); + } catch (e) { + console.debug('清除十字线时出错:', e); + } + } + + if (param.time && param.point) { + const timeStr = param.time; + const markers = [...buyMarkers, ...sellMarkers].filter(m => m.time === timeStr); + + // 同时检查分型标记 + const fxMarkers = (window.fxMarkers || []).filter(m => m.time === timeStr); + const allMarkers = [...markers, ...fxMarkers]; + + // 显示时区调试信息 + if (window.debugMode) { + const timezone = $('#timezone').val(); + const formattedTime = formatTimeWithTimezone(timeStr * 1000, timezone); + + // 获取当前价格 - 通过param.seriesPrices获取 + let priceInfo = ''; + if (param.seriesPrices && param.seriesPrices.size > 0) { + // 依次从当前可能的主系列中获取价格 + if (tvWidget.series.candleSeries && param.seriesPrices.get(tvWidget.series.candleSeries)) { + const price = param.seriesPrices.get(tvWidget.series.candleSeries); + priceInfo = `价格: ${price.toFixed(2)}`; + } else if (tvWidget.series.renkoSeries && param.seriesPrices.get(tvWidget.series.renkoSeries)) { + const price = param.seriesPrices.get(tvWidget.series.renkoSeries); + priceInfo = `价格: ${price.toFixed(2)}`; + } else if (tvWidget.series.heikinSeries && param.seriesPrices.get(tvWidget.series.heikinSeries)) { + const price = param.seriesPrices.get(tvWidget.series.heikinSeries); + priceInfo = `价格: ${price.toFixed(2)}`; + } else if (tvWidget.series.barSeries && param.seriesPrices.get(tvWidget.series.barSeries)) { + const price = param.seriesPrices.get(tvWidget.series.barSeries); + priceInfo = `价格: ${price.toFixed(2)}`; + } else if (tvWidget.series.lineSeries && param.seriesPrices.get(tvWidget.series.lineSeries)) { + const price = param.seriesPrices.get(tvWidget.series.lineSeries); + priceInfo = `价格: ${price.toFixed(2)}`; + } else if (tvWidget.series.areaSeries && param.seriesPrices.get(tvWidget.series.areaSeries)) { + const price = param.seriesPrices.get(tvWidget.series.areaSeries); + priceInfo = `价格: ${price.toFixed(2)}`; + } else if (tvWidget.series.baselineSeries && param.seriesPrices.get(tvWidget.series.baselineSeries)) { + const price = param.seriesPrices.get(tvWidget.series.baselineSeries); + priceInfo = `价格: ${price.toFixed(2)}`; + } + // 如果没有蜡烛图系列价格,尝试从区域图系列获取 + else if (tvWidget.series.areaSeries && param.seriesPrices.get(tvWidget.series.areaSeries)) { + const price = param.seriesPrices.get(tvWidget.series.areaSeries); + priceInfo = `价格: ${price.toFixed(2)}`; + } + // 如果没有蜡烛图系列价格,尝试从基线图系列获取 + else if (tvWidget.series.baselineSeries && param.seriesPrices.get(tvWidget.series.baselineSeries)) { + const price = param.seriesPrices.get(tvWidget.series.baselineSeries); + priceInfo = `价格: ${price.toFixed(2)}`; + } + } + + // 仅记录最简短的调试信息 + console.debug(`十字线: ${timeStr} -> ${formattedTime} (${timezone})`); + + // 显示自定义时区工具提示,包含价格信息 + crosshairTooltip.innerHTML = `
时间: ${formattedTime}
` + + (priceInfo ? `
${priceInfo}
` : ''); + crosshairTooltip.style.display = 'block'; + crosshairTooltip.style.left = (param.point.x + 15) + 'px'; + crosshairTooltip.style.top = (param.point.y - 30) + 'px'; + } + + if (allMarkers.length > 0) { + // 有买卖点或分型标记,显示自定义提示 + const tooltips = allMarkers.map(m => m.tooltip).join('

'); + tooltipElement.innerHTML = tooltips; + tooltipElement.style.display = 'block'; + tooltipElement.style.left = (param.point.x + 15) + 'px'; + tooltipElement.style.top = (param.point.y + 15) + 'px'; + } else { + // 隐藏提示 + tooltipElement.style.display = 'none'; + } + } else { + // 隐藏提示 + tooltipElement.style.display = 'none'; + crosshairTooltip.style.display = 'none'; + } + }); + + // 处理图表缩放、平移等事件,隐藏提示 + mainChart.timeScale().subscribeVisibleTimeRangeChange(() => { + tooltipElement.style.display = 'none'; + crosshairTooltip.style.display = 'none'; + }); + } + } else { + console.log('绘制买卖点 - 已禁用'); + } + // 绘制布林带 + if ($('#showMainBollinger').is(':checked') || $('#showElementBollinger').is(':checked')) { + console.log('绘制布林带 - 已启用'); + + // 主周期布林带 + if ($('#showMainBollinger').is(':checked') && currentData.bollinger && currentData.bollinger.upper && currentData.bollinger.lower && currentData.bollinger.middle) { + console.log(`绘制主周期布林带数据,共${currentData.bollinger.upper.length}条`); + + // 准备布林带数据 + const upperBandData = []; + const lowerBandData = []; + const middleBandData = []; + + // 主周期布林带始终使用主周期K线数据作为时间源 + const mainKlineData = currentData.kline_data; + + for (let i = 0; i < mainKlineData.length && i < currentData.bollinger.upper.length; i++) { + const kline = mainKlineData[i]; + const timestamp = Math.floor(new Date(kline.date).getTime() / 1000); + + // 只添加非0的有效数据点 + if (currentData.bollinger.upper[i] && currentData.bollinger.upper[i] !== 0) { + upperBandData.push({ + time: timestamp, + value: currentData.bollinger.upper[i] + }); + } + + if (currentData.bollinger.lower[i] && currentData.bollinger.lower[i] !== 0) { + lowerBandData.push({ + time: timestamp, + value: currentData.bollinger.lower[i] + }); + } + + if (currentData.bollinger.middle[i] && currentData.bollinger.middle[i] !== 0) { + middleBandData.push({ + time: timestamp, + value: currentData.bollinger.middle[i] + }); + } + } + + // 创建布林带上轨 + const upperBandSeries = mainChart.addLineSeries({ + color: '#2196F3', + lineWidth: 1, + lineStyle: 2, // 虚线 + lastValueVisible: false, + priceLineVisible: false, + title: '布林上轨' + }); + upperBandSeries.setData(upperBandData); + + // 创建布林带下轨 + const lowerBandSeries = mainChart.addLineSeries({ + color: '#2196F3', + lineWidth: 1, + lineStyle: 2, // 虚线 + lastValueVisible: false, + priceLineVisible: false, + title: '布林下轨' + }); + lowerBandSeries.setData(lowerBandData); + + // 创建布林带中轨(移动平均线) + const middleBandSeries = mainChart.addLineSeries({ + color: '#FF9800', + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + title: '布林中轨' + }); + middleBandSeries.setData(middleBandData); + + // 保存到tvWidget.series对象 + tvWidget.series.mainBollingerSeries.push(upperBandSeries); + tvWidget.series.mainBollingerSeries.push(lowerBandSeries); + tvWidget.series.mainBollingerSeries.push(middleBandSeries); + + console.log('主周期布林带绘制完成'); + } + + // 次周期布林带 + if ($('#showElementBollinger').is(':checked') && currentData.element_bollinger && currentData.element_bollinger.upper && currentData.element_bollinger.lower && currentData.element_bollinger.middle) { + console.log(`绘制次周期布林带数据,共${currentData.element_bollinger.upper.length}条`); + + // 准备次周期布林带数据 + const elementUpperBandData = []; + const elementLowerBandData = []; + const elementMiddleBandData = []; + + // 使用次周期K线数据 + const elementKlineData = currentData.element_kline_data || currentData.kline_data; + + for (let i = 0; i < elementKlineData.length && i < currentData.element_bollinger.upper.length; i++) { + const kline = elementKlineData[i]; + const timestamp = Math.floor(new Date(kline.date).getTime() / 1000); + + // 只添加非0的有效数据点 + if (currentData.element_bollinger.upper[i] && currentData.element_bollinger.upper[i] !== 0) { + elementUpperBandData.push({ + time: timestamp, + value: currentData.element_bollinger.upper[i] + }); + } + + if (currentData.element_bollinger.lower[i] && currentData.element_bollinger.lower[i] !== 0) { + elementLowerBandData.push({ + time: timestamp, + value: currentData.element_bollinger.lower[i] + }); + } + + if (currentData.element_bollinger.middle[i] && currentData.element_bollinger.middle[i] !== 0) { + elementMiddleBandData.push({ + time: timestamp, + value: currentData.element_bollinger.middle[i] + }); + } + } + + // 创建次周期布林带上轨 + const elementUpperBandSeries = mainChart.addLineSeries({ + color: '#9C27B0', + lineWidth: 1, + lineStyle: 2, // 虚线 + lastValueVisible: false, + priceLineVisible: false, + title: '次周期布林上轨' + }); + elementUpperBandSeries.setData(elementUpperBandData); + + // 创建次周期布林带下轨 + const elementLowerBandSeries = mainChart.addLineSeries({ + color: '#9C27B0', + lineWidth: 1, + lineStyle: 2, // 虚线 + lastValueVisible: false, + priceLineVisible: false, + title: '次周期布林下轨' + }); + elementLowerBandSeries.setData(elementLowerBandData); + + // 创建次周期布林带中轨 + const elementMiddleBandSeries = mainChart.addLineSeries({ + color: '#E91E63', + lineWidth: 1, + lastValueVisible: false, + priceLineVisible: false, + title: '次周期布林中轨' + }); + elementMiddleBandSeries.setData(elementMiddleBandData); + + // 保存到tvWidget.series对象 + tvWidget.series.elementBollingerSeries.push(elementUpperBandSeries); + tvWidget.series.elementBollingerSeries.push(elementLowerBandSeries); + tvWidget.series.elementBollingerSeries.push(elementMiddleBandSeries); + + console.log('次周期布林带绘制完成'); + } + } else { + console.log('绘制布林带 - 已禁用'); + } + + // 绘制分型类型标签 + console.log('=== 开始检查分型显示条件 ==='); + console.log('showKlcFxType勾选状态:', $('#showKlcFxType').is(':checked')); + console.log('showKluFxType勾选状态:', $('#showKluFxType').is(':checked')); + console.log('currentData.klc_fx_info存在:', !!currentData.klc_fx_info); + console.log('currentData.klu_fx_info存在:', !!currentData.klu_fx_info); + console.log('currentData.klc_fx_info长度:', currentData.klc_fx_info ? currentData.klc_fx_info.length : 'undefined'); + console.log('currentData.klu_fx_info长度:', currentData.klu_fx_info ? currentData.klu_fx_info.length : 'undefined'); + if (currentData.klc_fx_info && currentData.klc_fx_info.length > 0) { + console.log('前3个klc分型数据样本:', currentData.klc_fx_info.slice(0, 3)); + } + if (currentData.klu_fx_info && currentData.klu_fx_info.length > 0) { + console.log('前3个klu分型数据样本:', currentData.klu_fx_info.slice(0, 3)); + } + // 收集所有主周期分型标记 + const allMainFxMarkers = []; + const mainFxMarkers = []; // 用于tooltip支持 + // 处理主周期KLC分型 + if ($('#showKlcFxType').is(':checked') && currentData.klc_fx_info && currentData.klc_fx_info.length > 0) { + console.log(`绘制主周期K线合并分型标签,共${currentData.klc_fx_info.length}条`); + + currentData.klc_fx_info.forEach(function(fx) { + try { + // 直接使用UTC时间戳(秒) + const timestamp = Math.floor(new Date(fx.time).getTime() / 1000); + const price = parseFloat(fx.price); + + if (isNaN(timestamp) || isNaN(price)) { + console.error('主周期KLC分型时间或价格转换错误:', fx.time, fx.price); + return; + } + // 主周期 KLC + // 确定颜色和位置 + const color = fx.is_bottom ? '#28a745' : '#dc3545'; // 底分型绿色,顶分型红色 + + // 根据强度等级调整颜色强度 + let strengthColor = color; + + // 构建显示文本,包含分型类型和强度信息 + let displayText = `${fx.fx_strength.toFixed(1)}`; + if (fx.fx_strength < 1.0) { // 降低阈值,让更多分型显示 + displayText = fx.fx_strength >= 0.8 ? '' : '' // 0.8以上显示点,0.8以下不显示文本 + } + displayText = fx.fx_type.replace("TOP", "").replace("BOTTOM", "").replace("11", "").replace("21", "").replace("31", "").replace("41", "").replace("51", "").replace("01", ""); + // 添加标记配置 + const markerConfig = { + time: timestamp, + position: fx.is_bottom ? 'belowBar' : 'aboveBar', + color: strengthColor, + shape: 'triangle', + text: displayText, + size: 2 // 调整尺寸,强分型稍大,普通分型更小 + }; + + allMainFxMarkers.push(markerConfig); + + // 画虚线分型框(根据 start/end + high/low) + if (fx.start_time && fx.end_time && fx.high !== null && fx.high !== undefined && fx.low !== null && fx.low !== undefined) { + const startTs = Math.floor(new Date(fx.start_time).getTime() / 1000); + const endTs = Math.floor(new Date(fx.end_time).getTime() / 1000); + const high = parseFloat(fx.high); + const low = parseFloat(fx.low); + + if (!isNaN(startTs) && !isNaN(endTs) && !isNaN(high) && !isNaN(low)) { + const boxHigh = Math.max(high, low); + const boxLow = Math.min(high, low); + const boxColor = strengthColor; + + const topSeries = mainChart.addLineSeries({ + color: boxColor, + lineWidth: 1, + lineStyle: 2, // 虚线 + lastValueVisible: false, + priceLineVisible: false, + crosshairMarkerVisible: false, + }); + topSeries.setData([{ time: startTs, value: boxHigh }, { time: endTs, value: boxHigh }]); + + const bottomSeries = mainChart.addLineSeries({ + color: boxColor, + lineWidth: 1, + lineStyle: 2, // 虚线 + lastValueVisible: false, + priceLineVisible: false, + crosshairMarkerVisible: false, + }); + bottomSeries.setData([{ time: startTs, value: boxLow }, { time: endTs, value: boxLow }]); + + const leftSeries = mainChart.addLineSeries({ + color: boxColor, + lineWidth: 1, + lineStyle: 2, // 虚线 + lastValueVisible: false, + priceLineVisible: false, + crosshairMarkerVisible: false, + }); + // 左边竖线:同一 time 上下两个点(和你已有ZS绘制写法保持一致) + leftSeries.setData([{ time: startTs, value: boxLow }, { time: startTs, value: boxHigh }]); + + const rightSeries = mainChart.addLineSeries({ + color: boxColor, + lineWidth: 1, + lineStyle: 2, // 虚线 + lastValueVisible: false, + priceLineVisible: false, + crosshairMarkerVisible: false, + }); + rightSeries.setData([{ time: endTs, value: boxLow }, { time: endTs, value: boxHigh }]); + + if (!tvWidget.series.mainKlcFxBoxSeries) tvWidget.series.mainKlcFxBoxSeries = []; + tvWidget.series.mainKlcFxBoxSeries.push(topSeries, bottomSeries, leftSeries, rightSeries); + } + } + + // 创建分型标记对象,包含tooltip信息 + const fxMarker = { + time: timestamp, + tooltip: `
+ 主周期${fx.is_bottom ? '底分型' : '顶分型'}(合): ${fx.fx_type}
+ 强度分数: ${fx.fx_strength}分
+ 强度等级: ${fx.fx_strength_level}
+ 是否强分型: ${fx.is_strong_fx ? '是' : '否'}
+ 价格: ${price.toFixed(4)}
+ 时间: ${fx.time} +
` + }; + + mainFxMarkers.push(fxMarker); + + } catch (e) { + console.error('绘制主周期KLC分型标签出错:', e); + } + }); + } + + // 处理主周期KLU分型 + if ($('#showKluFxType').is(':checked') && currentData.klu_fx_info && currentData.klu_fx_info.length > 0) { + console.log(`绘制主周期K线未合并分型标签,共${currentData.klu_fx_info.length}条`); + + currentData.klu_fx_info.forEach(function(fx) { + try { + // 直接使用UTC时间戳(秒) + const timestamp = Math.floor(new Date(fx.time).getTime() / 1000); + const price = parseFloat(fx.price); + + if (isNaN(timestamp) || isNaN(price)) { + console.error('主周期KLU分型时间或价格转换错误:', fx.time, fx.price); + return; + } + + // 主周期 KLU + const color = fx.is_bottom ? '#17a2b8' : '#fd7e14'; // 底分型用青色,顶分型用橙色 + + // 根据强度等级调整颜色强度 + let strengthColor = color; + if (fx.is_strong_fx) { + // 强分型使用更亮的颜色 + strengthColor = fx.is_bottom ? '#20c997' : '#fd7e14'; + } + + // 构建显示文本,包含分型类型和强度信息 + let displayText = `${fx.fx_strength.toFixed(1)}`; + if (fx.fx_strength < 1.0) { // 降低阈值,让更多分型显示 + displayText = fx.fx_strength >= 1.5 ? '' : '' // 0.8以上显示点,0.8以下不显示文本 + } + displayText = fx.fx_type.replace("TOP", "").replace("BOTTOM", "").replace("11", ""); + // 添加标记配置 + const markerConfig = { + time: timestamp, + position: fx.is_bottom ? 'belowBar' : 'aboveBar', + color: strengthColor, + // shape: 'triangle', // 使用三角形区分KLU分型 + text: displayText, + size: fx.is_strong_fx ? 0.8 : 0.5 // KLU分型稍小一些 + }; + + allMainFxMarkers.push(markerConfig); + + // 创建分型标记对象,包含tooltip信息 + const fxMarker = { + time: timestamp, + tooltip: `
+ 主周期${fx.is_bottom ? '底分型' : '顶分型'}(原): ${fx.fx_type}
+ 强度分数: ${fx.fx_strength}分
+ 强度等级: ${fx.fx_strength_level}
+ 是否强分型: ${fx.is_strong_fx ? '是' : '否'}
+ 价格: ${price.toFixed(4)}
+ 时间: ${fx.time} +
` + }; + + mainFxMarkers.push(fxMarker); + + } catch (e) { + console.error('绘制主周期KLU分型标签出错:', e); + } + }); + } + + // 暂存主周期分型标记 + window.mainFxMarkers = allMainFxMarkers; + + // 将分型标记添加到全局markers中以支持tooltip功能 + if (window.fxMarkers) { + window.fxMarkers = [...window.fxMarkers, ...mainFxMarkers]; + } else { + window.fxMarkers = mainFxMarkers; + } + + // 检查是否有任何主周期分型数据 + const hasMainFxData = ($('#showKlcFxType').is(':checked') && currentData.klc_fx_info && currentData.klc_fx_info.length > 0) || + ($('#showKluFxType').is(':checked') && currentData.klu_fx_info && currentData.klu_fx_info.length > 0); + + if (!hasMainFxData) { + console.log('绘制主周期分型标记 - 已禁用或无数据'); + // 清空主周期分型标记 + window.mainFxMarkers = []; + window.fxMarkers = []; + } + // 绘制小周期分型标记(含次次周期) + if (($('#showElementKlcFxType').is(':checked') && currentData.element_klc_fx_info && currentData.element_klc_fx_info.length > 0) || + ($('#showElementKluFxType').is(':checked') && currentData.element_klu_fx_info && currentData.element_klu_fx_info.length > 0) || + ($('#showSubSubKlcFxType').is(':checked') && currentData.sub_sub_klc_fx_info && currentData.sub_sub_klc_fx_info.length > 0)) { + + // 收集所有小周期分型标记 + const allElementFxMarkers = []; + const elementFxMarkers = []; // 用于tooltip支持 + + // 处理小周期KLC分型 + if ($('#showElementKlcFxType').is(':checked') && currentData.element_klc_fx_info && currentData.element_klc_fx_info.length > 0) { + console.log(`绘制小周期K线合并分型标记,共${currentData.element_klc_fx_info.length}条`); + + currentData.element_klc_fx_info.forEach(function(fx) { + try { + // 直接使用UTC时间戳(秒) + const timestamp = Math.floor(new Date(fx.time).getTime() / 1000); + const price = parseFloat(fx.price); + + if (isNaN(timestamp) || isNaN(price)) { + console.error('小周期KLC分型时间或价格转换错误:', fx.time, fx.price); + return; + } + + // 小周期 KLC + let strengthColor = fx.is_bottom ? '#11116B' : '#222222'; // 底分型用珊瑚红,顶分型用薄荷绿 + let displayText = `${fx.fx_strength.toFixed(1)}`; + // 构建小周期分型显示文本 + if (fx.fx_strength < 1.0){ // 调整小周期阈值 + displayText = fx.fx_strength >= 0.6 ? '' : '' // 0.6以上显示点 + } + displayText = fx.fx_type.replace("TOP", "").replace("BOTTOM", "").replace("11", "").replace("21", "").replace("3", "").replace("41", "").replace("51", "").replace("0", ""); + // 小周期分型标记配置 + const markerConfig = { + time: timestamp, + position: fx.is_bottom ? 'belowBar' : 'aboveBar', + color: strengthColor, + shape: 'triangle', + text: displayText, + size: fx.is_strong_fx ? 0.8 : 0.6 // 小周期标记整体更小一些 + }; + + allElementFxMarkers.push(markerConfig); + + // 画虚线分型框(小周期) + if (fx.start_time && fx.end_time && fx.high !== null && fx.high !== undefined && fx.low !== null && fx.low !== undefined) { + const startTs = Math.floor(new Date(fx.start_time).getTime() / 1000); + const endTs = Math.floor(new Date(fx.end_time).getTime() / 1000); + const high = parseFloat(fx.high); + const low = parseFloat(fx.low); + + if (!isNaN(startTs) && !isNaN(endTs) && !isNaN(high) && !isNaN(low)) { + const boxHigh = Math.max(high, low); + const boxLow = Math.min(high, low); + const boxColor = strengthColor; + + const topSeries = mainChart.addLineSeries({ + color: boxColor, + lineWidth: 1, + lineStyle: 2, + lastValueVisible: false, + priceLineVisible: false, + crosshairMarkerVisible: false, + }); + topSeries.setData([{ time: startTs, value: boxHigh }, { time: endTs, value: boxHigh }]); + + const bottomSeries = mainChart.addLineSeries({ + color: boxColor, + lineWidth: 1, + lineStyle: 2, + lastValueVisible: false, + priceLineVisible: false, + crosshairMarkerVisible: false, + }); + bottomSeries.setData([{ time: startTs, value: boxLow }, { time: endTs, value: boxLow }]); + + const leftSeries = mainChart.addLineSeries({ + color: boxColor, + lineWidth: 1, + lineStyle: 2, + lastValueVisible: false, + priceLineVisible: false, + crosshairMarkerVisible: false, + }); + leftSeries.setData([{ time: startTs, value: boxLow }, { time: startTs, value: boxHigh }]); + + const rightSeries = mainChart.addLineSeries({ + color: boxColor, + lineWidth: 1, + lineStyle: 2, + lastValueVisible: false, + priceLineVisible: false, + crosshairMarkerVisible: false, + }); + rightSeries.setData([{ time: endTs, value: boxLow }, { time: endTs, value: boxHigh }]); + + if (!tvWidget.series.elementKlcFxBoxSeries) tvWidget.series.elementKlcFxBoxSeries = []; + tvWidget.series.elementKlcFxBoxSeries.push(topSeries, bottomSeries, leftSeries, rightSeries); + } + } + + // 创建小周期分型标记对象,包含tooltip信息 + const elementFxMarker = { + time: timestamp, + tooltip: `
+ 小周期${fx.is_bottom ? '底分型' : '顶分型'}(合): ${fx.fx_type}
+ 强度分数: ${fx.fx_strength}分
+ 强度等级: ${fx.fx_strength_level}
+ 是否强分型: ${fx.is_strong_fx ? '是' : '否'}
+ 价格: ${price.toFixed(4)}
+ 时间: ${fx.time} +
` + }; + + elementFxMarkers.push(elementFxMarker); + + } catch (e) { + console.error('绘制小周期KLC分型标记出错:', e); + } + }); + } + + // 处理小周期KLU分型 + if ($('#showElementKluFxType').is(':checked') && currentData.element_klu_fx_info && currentData.element_klu_fx_info.length > 0) { + console.log(`绘制小周期K线未合并分型标记,共${currentData.element_klu_fx_info.length}条`); + + currentData.element_klu_fx_info.forEach(function(fx) { + try { + // 直接使用UTC时间戳(秒) + const timestamp = Math.floor(new Date(fx.time).getTime() / 1000); + const price = parseFloat(fx.price); + + if (isNaN(timestamp) || isNaN(price)) { + console.error('小周期KLU分型时间或价格转换错误:', fx.time, fx.price); + return; + } + + // 小周期 KLU + let strengthColor = fx.is_bottom ? '#9A8C98' : '#F2CC8F'; // 底分型用灰紫色,顶分型用浅黄色 + let displayText = `${fx.fx_strength.toFixed(1)}`; + // 构建小周期分型显示文本 + if (fx.fx_strength < 2.0){ // 调整小周期阈值 + displayText = fx.fx_strength >= 1.5 ? '' : '' // 0.6以上显示点 + } + displayText = fx.fx_type.replace("TOP", "").replace("BOTTOM", "").replace("11", ""); + // 小周期KLU分型标记配置 + const markerConfig = { + time: timestamp, + position: fx.is_bottom ? 'belowBar' : 'aboveBar', + color: strengthColor, + // shape: 'triangle', // 使用三角形区分KLU分型 + text: displayText, + size: fx.is_strong_fx ? 0.7 : 0.5 // 小周期KLU标记更小一些 + }; + + allElementFxMarkers.push(markerConfig); + + // 创建小周期分型标记对象,包含tooltip信息 + const elementFxMarker = { + time: timestamp, + tooltip: `
+ 小周期${fx.is_bottom ? '底分型' : '顶分型'}(原): ${fx.fx_type}
+ 强度分数: ${fx.fx_strength}分
+ 强度等级: ${fx.fx_strength_level}
+ 是否强分型: ${fx.is_strong_fx ? '是' : '否'}
+ 价格: ${price.toFixed(4)}
+ 时间: ${fx.time} +
` + }; + + elementFxMarkers.push(elementFxMarker); + + } catch (e) { + console.error('绘制小周期KLU分型标记出错:', e); + } + }); + } + + // 次次周期KLC分型 + if ($('#showSubSubKlcFxType').is(':checked') && currentData.sub_sub_klc_fx_info && currentData.sub_sub_klc_fx_info.length > 0) { + currentData.sub_sub_klc_fx_info.forEach(function(fx) { + try { + const timestamp = Math.floor(new Date(fx.time).getTime() / 1000); + const price = parseFloat(fx.price); + if (isNaN(timestamp) || isNaN(price)) return; + const strengthColor = '#00897b'; + let displayText = (fx.fx_type || '').replace("TOP", "").replace("BOTTOM", "").replace("11", "").replace("21", "").replace("3", "").replace("41", "").replace("51", "").replace("0", ""); + const markerConfig = { + time: timestamp, + position: fx.is_bottom ? 'belowBar' : 'aboveBar', + color: strengthColor, + shape: 'triangle', + text: displayText, + size: (fx.is_strong_fx ? 0.6 : 0.5) + }; + allElementFxMarkers.push(markerConfig); + + // 画虚线分型框(次次周期) + if (fx.start_time && fx.end_time && fx.high !== null && fx.high !== undefined && fx.low !== null && fx.low !== undefined) { + const startTs = Math.floor(new Date(fx.start_time).getTime() / 1000); + const endTs = Math.floor(new Date(fx.end_time).getTime() / 1000); + const high = parseFloat(fx.high); + const low = parseFloat(fx.low); + + if (!isNaN(startTs) && !isNaN(endTs) && !isNaN(high) && !isNaN(low)) { + const boxHigh = Math.max(high, low); + const boxLow = Math.min(high, low); + const boxColor = strengthColor; + + const topSeries = mainChart.addLineSeries({ + color: boxColor, + lineWidth: 1, + lineStyle: 2, + lastValueVisible: false, + priceLineVisible: false, + crosshairMarkerVisible: false, + }); + topSeries.setData([{ time: startTs, value: boxHigh }, { time: endTs, value: boxHigh }]); + + const bottomSeries = mainChart.addLineSeries({ + color: boxColor, + lineWidth: 1, + lineStyle: 2, + lastValueVisible: false, + priceLineVisible: false, + crosshairMarkerVisible: false, + }); + bottomSeries.setData([{ time: startTs, value: boxLow }, { time: endTs, value: boxLow }]); + + const leftSeries = mainChart.addLineSeries({ + color: boxColor, + lineWidth: 1, + lineStyle: 2, + lastValueVisible: false, + priceLineVisible: false, + crosshairMarkerVisible: false, + }); + leftSeries.setData([{ time: startTs, value: boxLow }, { time: startTs, value: boxHigh }]); + + const rightSeries = mainChart.addLineSeries({ + color: boxColor, + lineWidth: 1, + lineStyle: 2, + lastValueVisible: false, + priceLineVisible: false, + crosshairMarkerVisible: false, + }); + rightSeries.setData([{ time: endTs, value: boxLow }, { time: endTs, value: boxHigh }]); + + if (!tvWidget.series.subSubKlcFxBoxSeries) tvWidget.series.subSubKlcFxBoxSeries = []; + tvWidget.series.subSubKlcFxBoxSeries.push(topSeries, bottomSeries, leftSeries, rightSeries); + } + } + } catch (e) { console.error('绘制次次周期KLC分型标记出错:', e); } + }); + } + + // 将小周期分型标记添加到全局markers中以支持tooltip功能 + if (window.fxMarkers) { + window.fxMarkers = [...window.fxMarkers, ...elementFxMarkers]; + } else { + window.fxMarkers = elementFxMarkers; + } + + // 基于后端提供的 KLC 趋势生成标记(不进行任何计算) + let klcTrendMarkers = []; + try { + if (currentData.klc_trend && currentData.klc_trend.length > 0) { + console.log('KLC趋势点数量:', currentData.klc_trend.length, currentData.klc_trend.slice(0, 3)); + // 当前图表的bar时间集合(秒)用于对齐标记到最近的K线 + const seriesTimes = (typeof candles !== 'undefined' && Array.isArray(candles)) ? new Set(candles.map(c => c.time)) : new Set(); + const nearestTime = (target) => { + if (!Array.isArray(candles) || candles.length === 0) return target; + // 简单线性查找(数据量通常可接受),必要时可替换为二分 + let best = candles[0].time; + let bestDiff = Math.abs(best - target); + for (let i = 1; i < candles.length; i++) { + const t = candles[i].time; + const d = Math.abs(t - target); + if (d < bestDiff) { best = t; bestDiff = d; } + } + return best; + }; + + klcTrendMarkers = currentData.klc_trend.map(t => { + const ts = Math.floor(new Date(t.time).getTime() / 1000); + const trendRaw = (t.trend || '').toString().toUpperCase(); + let timeAligned = seriesTimes.has(ts) ? ts : nearestTime(ts); + let marker = { time: timeAligned, position: 'inBar', color: '#9E9E9E', shape: 'square', size: 0.8 }; + if (trendRaw === 'UP') { + marker = { time: timeAligned, position: 'aboveBar', color: '#00C853', shape: 'arrowUp', size: 0.5 }; + } else if (trendRaw === 'DOWN') { + marker = { time: timeAligned, position: 'belowBar', color: '#D32F2F', shape: 'arrowDown', size: 0.5 }; + } else if (trendRaw === 'FLAT') { + marker = { time: timeAligned, position: 'inBar', color: '#9E9E9E', shape: 'circle', size: 0.8 }; + } else { + // UNKNOWN 或其他 + marker = { time: timeAligned, position: 'inBar', color: '#2196F3', shape: 'square', size: 0.8 }; + } + return marker; + }); + console.log('KLC趋势标记(对齐后)示例:', klcTrendMarkers.slice(0, 5)); + } + } catch (e) { + klcTrendMarkers = []; + } + // 暴露到全局以便调试或后续合并 + window.klcTrendMarkers = klcTrendMarkers; + + // 无论当前显示主/小周期,只要勾选对应Trend,就叠加出来 + let trendMarkersToUse = []; + if ($('#showMainTrend').is(':checked')) { + trendMarkersToUse = trendMarkersToUse.concat(window.klcTrendMarkers || []); + } + if ($('#showElementTrend').is(':checked') && currentData.element_klc_trend) { + const candlesTimes = (typeof candles !== 'undefined' && Array.isArray(candles)) ? new Set(candles.map(c => c.time)) : new Set(); + const nearestTime = (target) => { + if (!Array.isArray(candles) || candles.length === 0) return target; + let best = candles[0].time, bestDiff = Math.abs(best - target); + for (let i = 1; i < candles.length; i++) { + const t = candles[i].time, d = Math.abs(t - target); + if (d < bestDiff) { best = t; bestDiff = d; } + } + return best; + }; + const elementMarkers = currentData.element_klc_trend.map(t => { + const ts = Math.floor(new Date(t.time).getTime() / 1000); + const trendRaw = (t.trend || '').toString().toUpperCase(); + const timeAligned = candlesTimes.has(ts) ? ts : nearestTime(ts); + if (trendRaw === 'UP') return { time: timeAligned, position: 'aboveBar', color: '#00C853', shape: 'arrowUp', size: 0.5 }; + if (trendRaw === 'DOWN') return { time: timeAligned, position: 'belowBar', color: '#D32F2F', shape: 'arrowDown', size: 0.5 }; + if (trendRaw === 'FLAT') return { time: timeAligned, position: 'inBar', color: '#9E9E9E', shape: 'circle', size: 0.8 }; + return { time: timeAligned, position: 'inBar', color: '#2196F3', shape: 'square', size: 0.8 }; + }); + trendMarkersToUse = trendMarkersToUse.concat(elementMarkers); + } + if ($('#showSubSubTrend').is(':checked') && currentData.sub_sub_klc_trend && currentData.sub_sub_klc_trend.length > 0) { + const candlesTimesSs = (typeof candles !== 'undefined' && Array.isArray(candles)) ? new Set(candles.map(c => c.time)) : new Set(); + const nearestTimeSs = (target) => { + if (!Array.isArray(candles) || candles.length === 0) return target; + let best = candles[0].time, bestDiff = Math.abs(best - target); + for (let i = 1; i < candles.length; i++) { + const t = candles[i].time, d = Math.abs(t - target); + if (d < bestDiff) { best = t; bestDiff = d; } + } + return best; + }; + const subSubColor = '#00897b'; + const subSubMarkers = currentData.sub_sub_klc_trend.map(t => { + const ts = Math.floor(new Date(t.time).getTime() / 1000); + const trendRaw = (t.trend || '').toString().toUpperCase(); + const timeAligned = candlesTimesSs.has(ts) ? ts : nearestTimeSs(ts); + if (trendRaw === 'UP') return { time: timeAligned, position: 'aboveBar', color: subSubColor, shape: 'arrowUp', size: 0.4 }; + if (trendRaw === 'DOWN') return { time: timeAligned, position: 'belowBar', color: subSubColor, shape: 'arrowDown', size: 0.4 }; + if (trendRaw === 'FLAT') return { time: timeAligned, position: 'inBar', color: subSubColor, shape: 'circle', size: 0.5 }; + return { time: timeAligned, position: 'inBar', color: subSubColor, shape: 'square', size: 0.5 }; + }); + trendMarkersToUse = trendMarkersToUse.concat(subSubMarkers); + } + + // 合并标记并设置 + const combinedMarkers = [ + ...(window.mainFxMarkers || []), + ...allElementFxMarkers, + ...(window.kluDivMarkersMain || []), + ...(window.kluDivMarkersElement || []), + ...(window.kluDivMarkersSubSub || []), + ...trendMarkersToUse, + ...(window.bspMarkers || []) + ]; + if (combinedMarkers.length > 0) { + console.log( + '合并设置', combinedMarkers.length, '个标记(主周期分型:', + (window.mainFxMarkers || []).length, + '个,小周期分型:', allElementFxMarkers.length, + '个,UnitTF:', (window.unittfMarkers || []).length, + '个,BSP标记:', (window.bspMarkers || []).length, + '个)' + ); + + // 根据当前主系列类型设置标记 + const klineType = ($('#klineType').val() || (showOriginalKline ? 'candlestick' : 'line')); + let targetSeries = null; + if (klineType === 'candlestick') targetSeries = tvWidget.series.candleSeries; + else if (klineType === 'renko') targetSeries = tvWidget.series.renkoSeries; + else if (klineType === 'heikin') targetSeries = tvWidget.series.heikinSeries; + else if (klineType === 'bar') targetSeries = tvWidget.series.barSeries; + else if (klineType === 'line') targetSeries = tvWidget.series.lineSeries; + else if (klineType === 'area') targetSeries = tvWidget.series.areaSeries; + else if (klineType === 'baseline') targetSeries = tvWidget.series.baselineSeries; + else if (klineType === 'klc') targetSeries = tvWidget.series.klcSeries; + if (targetSeries) { + try { + targetSeries.setMarkers(combinedMarkers); + } catch (e) { + console.warn('设置主系列标记失败(可能series已释放):', e); + } + } else { + console.log('未找到主数据系列,无法设置标记'); + } + } + + } else { + console.log('绘制小周期分型标记 - 已禁用或无数据'); + + // 计算并缓存KLC趋势标记(即使未启用小周期分型,也应显示趋势) + try { + let klcTrendMarkers = []; + if (currentData.klc_trend && currentData.klc_trend.length > 0) { + console.log('KLC趋势点数量:', currentData.klc_trend.length, currentData.klc_trend.slice(0, 3)); + const seriesTimes = (typeof candles !== 'undefined' && Array.isArray(candles)) ? new Set(candles.map(c => c.time)) : new Set(); + const nearestTime = (target) => { + if (!Array.isArray(candles) || candles.length === 0) return target; + let best = candles[0].time; + let bestDiff = Math.abs(best - target); + for (let i = 1; i < candles.length; i++) { + const t = candles[i].time; + const d = Math.abs(t - target); + if (d < bestDiff) { best = t; bestDiff = d; } + } + return best; + }; + klcTrendMarkers = currentData.klc_trend.map(t => { + const ts = Math.floor(new Date(t.time).getTime() / 1000); + const trendRaw = (t.trend || '').toString().toUpperCase(); + const timeAligned = seriesTimes.has(ts) ? ts : nearestTime(ts); + if (trendRaw === 'UP') { + return { time: timeAligned, position: 'aboveBar', color: '#00C853', shape: 'arrowUp', size: 0.5 }; + } else if (trendRaw === 'DOWN') { + return { time: timeAligned, position: 'belowBar', color: '#D32F2F', shape: 'arrowDown', size: 0.5 }; + } else if (trendRaw === 'FLAT') { + return { time: timeAligned, position: 'inBar', color: '#9E9E9E', shape: 'circle', size: 0.8 }; + } else { + return { time: timeAligned, position: 'inBar', color: '#2196F3', shape: 'square', size: 0.8 }; + } + }); + console.log('KLC趋势标记(对齐后)示例:', klcTrendMarkers.slice(0, 5)); + } + window.klcTrendMarkers = klcTrendMarkers; + } catch (e) { + window.klcTrendMarkers = []; + } + + // 与上方一致:勾选哪个Trend就显示哪个 + let trendMarkersToUse = []; + if ($('#showMainTrend').is(':checked')) { + trendMarkersToUse = trendMarkersToUse.concat(window.klcTrendMarkers || []); + } + if ($('#showElementTrend').is(':checked') && currentData.element_klc_trend) { + const candlesTimes = (typeof candles !== 'undefined' && Array.isArray(candles)) ? new Set(candles.map(c => c.time)) : new Set(); + const nearestTime = (target) => { + if (!Array.isArray(candles) || candles.length === 0) return target; + let best = candles[0].time, bestDiff = Math.abs(best - target); + for (let i = 1; i < candles.length; i++) { + const t = candles[i].time, d = Math.abs(t - target); + if (d < bestDiff) { best = t; bestDiff = d; } + } + return best; + }; + const elementMarkers = currentData.element_klc_trend.map(t => { + const ts = Math.floor(new Date(t.time).getTime() / 1000); + const trendRaw = (t.trend || '').toString().toUpperCase(); + const timeAligned = candlesTimes.has(ts) ? ts : nearestTime(ts); + if (trendRaw === 'UP') return { time: timeAligned, position: 'aboveBar', color: '#00C853', shape: 'arrowUp', size: 0.5 }; + if (trendRaw === 'DOWN') return { time: timeAligned, position: 'belowBar', color: '#D32F2F', shape: 'arrowDown', size: 0.5 }; + if (trendRaw === 'FLAT') return { time: timeAligned, position: 'inBar', color: '#9E9E9E', shape: 'circle', size: 0.8 }; + return { time: timeAligned, position: 'inBar', color: '#2196F3', shape: 'square', size: 0.8 }; + }); + trendMarkersToUse = trendMarkersToUse.concat(elementMarkers); + } + if ($('#showSubSubTrend').is(':checked') && currentData.sub_sub_klc_trend && currentData.sub_sub_klc_trend.length > 0) { + const candlesTimesSs2 = (typeof candles !== 'undefined' && Array.isArray(candles)) ? new Set(candles.map(c => c.time)) : new Set(); + const nearestTimeSs2 = (target) => { + if (!Array.isArray(candles) || candles.length === 0) return target; + let best = candles[0].time, bestDiff = Math.abs(best - target); + for (let i = 1; i < candles.length; i++) { + const t = candles[i].time, d = Math.abs(t - target); + if (d < bestDiff) { best = t; bestDiff = d; } + } + return best; + }; + const subSubColor2 = '#00897b'; + const subSubMarkers2 = currentData.sub_sub_klc_trend.map(t => { + const ts = Math.floor(new Date(t.time).getTime() / 1000); + const trendRaw = (t.trend || '').toString().toUpperCase(); + const timeAligned = candlesTimesSs2.has(ts) ? ts : nearestTimeSs2(ts); + if (trendRaw === 'UP') return { time: timeAligned, position: 'aboveBar', color: subSubColor2, shape: 'arrowUp', size: 0.4 }; + if (trendRaw === 'DOWN') return { time: timeAligned, position: 'belowBar', color: subSubColor2, shape: 'arrowDown', size: 0.4 }; + if (trendRaw === 'FLAT') return { time: timeAligned, position: 'inBar', color: subSubColor2, shape: 'circle', size: 0.5 }; + return { time: timeAligned, position: 'inBar', color: subSubColor2, shape: 'square', size: 0.5 }; + }); + trendMarkersToUse = trendMarkersToUse.concat(subSubMarkers2); + } + // 这里的 onlyMainAndU 实际上是「最终要挂到主K线上」的一组标记 + // 之前没有把 window.bspMarkers 合进去,导致上面已经合并了 BSP 标记, + // 但在这里再次调用 setMarkers 时把 BSP 覆盖掉了,从而前端看不到买卖点。 + // 修复:把 BSP 标记一并合并进来。 + const onlyMainAndU = [ + ...(window.mainFxMarkers || []), + ...(window.kluDivMarkersMain || []), + ...(window.kluDivMarkersElement || []), + ...(window.kluDivMarkersSubSub || []), + ...trendMarkersToUse, + ...(window.bspMarkers || []) + ]; + if (onlyMainAndU.length > 0) { + console.log('仅设置', onlyMainAndU.length, '个主周期/UnitTF标记(主周期分型:', (window.mainFxMarkers || []).length, ',UnitTF:', (window.unittfMarkers || []).length, ')'); + + // 根据当前主系列类型设置标记 + const klineType2 = ($('#klineType').val() || (showOriginalKline ? 'candlestick' : 'line')); + let targetSeries2 = null; + if (klineType2 === 'candlestick') targetSeries2 = tvWidget.series.candleSeries; + else if (klineType2 === 'renko') targetSeries2 = tvWidget.series.renkoSeries; + else if (klineType2 === 'heikin') targetSeries2 = tvWidget.series.heikinSeries; + else if (klineType2 === 'bar') targetSeries2 = tvWidget.series.barSeries; + else if (klineType2 === 'line') targetSeries2 = tvWidget.series.lineSeries; + else if (klineType2 === 'area') targetSeries2 = tvWidget.series.areaSeries; + else if (klineType2 === 'baseline') targetSeries2 = tvWidget.series.baselineSeries; + else if (klineType2 === 'klc') targetSeries2 = tvWidget.series.klcSeries; + if (targetSeries2) { + try { + targetSeries2.setMarkers(onlyMainAndU); + } catch (e) { + console.warn('设置主系列标记失败(可能series已释放):', e); + } + } else { + console.log('未找到主数据系列,无法设置标记'); + } + } else { + console.log('没有分型标记需要显示,清空图表标记'); + // 清空图表上的所有主系列标记 + const klineType3 = ($('#klineType').val() || (showOriginalKline ? 'candlestick' : 'line')); + let targetSeries3 = null; + if (klineType3 === 'candlestick') targetSeries3 = tvWidget.series.candleSeries; + else if (klineType3 === 'renko') targetSeries3 = tvWidget.series.renkoSeries; + else if (klineType3 === 'heikin') targetSeries3 = tvWidget.series.heikinSeries; + else if (klineType3 === 'bar') targetSeries3 = tvWidget.series.barSeries; + else if (klineType3 === 'line') targetSeries3 = tvWidget.series.lineSeries; + else if (klineType3 === 'area') targetSeries3 = tvWidget.series.areaSeries; + else if (klineType3 === 'baseline') targetSeries3 = tvWidget.series.baselineSeries; + else if (klineType3 === 'klc') targetSeries3 = tvWidget.series.klcSeries; + if (targetSeries3) { + try { + targetSeries3.setMarkers([]); + } catch (e) { + console.warn('清空主系列标记失败(可能series已释放):', e); + } + } + } + } + + // 同步所有图表的时间轴配置 + const syncTimeScaleSettings = () => { + // 获取主图表的时间轴设置 + const mainTimeScale = mainChart.timeScale(); + const baseOptions = { + timeVisible: true, + secondsVisible: false, + borderColor: '#ddd', + barSpacing: symbolConfig.type === 'a_stock' ? 6 : 10, + rightOffset: 12, + lockVisibleTimeRangeOnResize: true, + // 关键:确保所有图表边缘行为完全一致 + fixLeftEdge: false, + fixRightEdge: false, + // 确保时间刻度行为一致 + ticksVisible: true, + minimumHeight: 0, + }; + + console.log('🔧 同步时间轴设置:', baseOptions); + + // 应用相同的设置到所有图表 + mainChart.timeScale().applyOptions(baseOptions); + volumeChart.timeScale().applyOptions(baseOptions); + atrChart.timeScale().applyOptions(baseOptions); + if (showMacd && macdChart) { + macdChart.timeScale().applyOptions(baseOptions); + } + }; + + // 首先同步时间轴设置 + syncTimeScaleSettings(); + + // 仅在没有待恢复视图时,设置默认可见范围 + const totalBars = candles ? candles.length : 0; + const visibleBarsCount = 200; + const hasPendingRestoreView = !!window._pendingRestoreView; + if (!hasPendingRestoreView) { + // 显示最近 200 根K线而非全部挤压(避免K线过多时重叠) + if (totalBars > visibleBarsCount) { + const rangeFrom = totalBars - visibleBarsCount; + const rangeTo = totalBars + 12; + mainChart.timeScale().setVisibleLogicalRange({ from: rangeFrom, to: rangeTo }); + } else { + mainChart.timeScale().fitContent(); + } + } + + // 立即同步其他图表到主图表的范围 + setTimeout(() => { + const logRange = mainChart.timeScale().getVisibleLogicalRange(); + if (logRange) { + console.log('🔧 同步可见范围:', logRange); + volumeChart.timeScale().setVisibleLogicalRange(logRange); + atrChart.timeScale().setVisibleLogicalRange(logRange); + if (showMacd && macdChart) { + macdChart.timeScale().setVisibleLogicalRange(logRange); + } + if (showMacd && chanMacdChart) { + chanMacdChart.timeScale().setVisibleLogicalRange(logRange); + } + console.log('🔧 时间轴同步完成'); + } + }, 50); + + // 保存图表对象 + tvWidget.mainChart = mainChart; + tvWidget.volumeChart = volumeChart; + tvWidget.atrChart = atrChart; + tvWidget.macdChart = macdChart; + tvWidget.chanMacdChart = chanMacdChart; + tvWidget.state.isInitialized = true; + // 注册窗口卸载时释放资源,避免GPU内存泄漏 + window.onbeforeunload = function() { + try { + if (tvWidget && tvWidget.state && tvWidget.state.isInitialized) { + if (tvWidget.mainChart && typeof tvWidget.mainChart.remove === 'function') tvWidget.mainChart.remove(); + if (tvWidget.volumeChart && typeof tvWidget.volumeChart.remove === 'function') tvWidget.volumeChart.remove(); + if (tvWidget.macdChart && typeof tvWidget.macdChart.remove === 'function') tvWidget.macdChart.remove(); + if (tvWidget.chanMacdChart && typeof tvWidget.chanMacdChart.remove === 'function') tvWidget.chanMacdChart.remove(); + if (tvWidget.atrChart && typeof tvWidget.atrChart.remove === 'function') tvWidget.atrChart.remove(); + } + } catch (e) {} + }; + + // 初始化默认均线/布林带配置(仅在首次初始化时) + if (!hasInitializedDefaultMAs && movingAverages.length === 0) { + console.log('初始化默认均线与布林带指标'); + + if (typeof maIdCounter !== 'number' || !Number.isFinite(maIdCounter)) { + maIdCounter = 0; + } + if (typeof bbIdCounter !== 'number' || !Number.isFinite(bbIdCounter)) { + bbIdCounter = 0; + } + + const defaultMAs = [ + { type: 'EMA', length: 13, color: '#800080', name: 'EMA13', visible: true }, // 紫色 + { type: 'EMA', length: 26, color: '#FF8C00', name: 'EMA26', visible: true }, // 橙色 + { type: 'EMA', length: 52, color: '#000000', name: 'EMA52', visible: false }, // 黑色 + { type: 'EMA', length: 104, color: '#1E90FF', name: 'EMA104', visible: false }, // 蓝色 + { type: 'EMA', length: 156, color: '#F700FF', name: 'EMA156', visible: false } // 粉色 + ]; + + defaultMAs.forEach(ma => { + const config = { + id: ++maIdCounter, + type: ma.type, + length: ma.length, + source: 'close', + smoothType: 'none', + smoothLength: 3, + lineWidth: 1, // 1px线宽 + lineStyle: 0, // 实线 + color: ma.color, + visible: ma.visible + }; + + movingAverages.push(config); + console.log(`添加默认${ma.name}:`, ma.color); + }); + + if (bollingerBands.length === 0) { + const defaultBB = { + id: ++bbIdCounter, + type: 'Bollinger Bands', + length: 20, + upperMultiplier: 2, + lowerMultiplier: 2, + source: 'close', + lineWidth: 1, + lineStyle: 0, + upperColor: '#ff6b6b', + middleColor: '#ffffff', + lowerColor: '#ff6b6b', + visible: false + }; + bollingerBands.push(defaultBB); + console.log('添加默认布林带: BB(20, 2, 2)'); + } + + console.log('默认指标配置完成,当前均线数量', movingAverages.length, '布林带数量', bollingerBands.length); + hasInitializedDefaultMAs = true; + } + + // 添加均线到图表 + addMovingAveragesToChart(candles); + + // 添加布林带到图表 + addBollingerBandsToChart(candles); + + // 更新技术指标面板显示 + updateIndicatorPanel(); + + // 绑定同步事件 + bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContainer, macdChartContainer, chanMacdChartContainer, mainChart, volumeChart, atrChart, macdChart, chanMacdChart, showMacd); + + // 最终确保所有图表时间轴对齐(同时恢复刷新前保存的缩放/位置) + setTimeout(() => { + const allCharts = [mainChart, volumeChart, atrChart]; + if (showMacd && macdChart) allCharts.push(macdChart); + if (showMacd && chanMacdChart) allCharts.push(chanMacdChart); + + // 检查是否有待恢复的视图(缩放 + 位置) + const pending = window._pendingRestoreView; + window._pendingRestoreView = null; + + if (pending) { + // 恢复刷新前的缩放和位置(优先可见范围/逻辑范围,最后回退到滚动位置) + console.log('📌 恢复图表视图:', JSON.stringify(pending)); + restoreChartViewState(allCharts, pending); + } else { + // 无保存视图,正常同步主图到子图 + const visibleRange = mainChart.timeScale().getVisibleRange(); + if (visibleRange) { + console.log('🔧 最终同步可见范围:', visibleRange); + [volumeChart, atrChart].concat( + showMacd && macdChart ? [macdChart] : [], + showMacd && chanMacdChart ? [chanMacdChart] : [] + ).forEach(c => { + try { c.timeScale().setVisibleRange(visibleRange); } catch(e) {} + }); + } + } + console.log('🔧 最终时间轴对齐完成'); + }, 150); + // 只有在时间输入框都为空时才设置图表默认时间范围 + if (!$('#start_time').val() && !$('#end_time').val()) { + setDefaultTimeRange(); + } + + // 添加买卖点提示 + // 初始化 tooltip 与 U 显示状态 + window.showUOnMain = $('#toggleUOnMain').is(':checked'); + window.showUOnElement = $('#toggleUOnElement').is(':checked'); + setupTooltip(mainChart, [], [], mainChartContainer, volumeChartContainer, atrChartContainer, macdChartContainer, chanMacdChartContainer, volumeChart, atrChart, macdChart, chanMacdChart, showMacd); + + // 更新EMA52显示 + if (currentData) { + updateEMA52Display(currentData); + } + + console.log('图表初始化完成'); + } catch (e) { + console.error('图表初始化错误:', e); + } +} +// 增量更新图表数据 diff --git a/web/static/js/app/chart_view.js b/web/static/js/app/chart_view.js new file mode 100644 index 0000000..35d8098 --- /dev/null +++ b/web/static/js/app/chart_view.js @@ -0,0 +1,148 @@ +/* chart_view.js — split from chart.js */ +function updateChart() { + // 只显示旋转加载图标 + $('#refreshLoadingSpinner').show(); + + // 获取参数 + const dataSource = $('#dataSource').val() || 'crypto'; + let symbol; + if (dataSource === 'crypto') { + symbol = $('#symbol').val() || 'BTC/USDT:USDT'; + } else { + symbol = $('#astockSymbol').val() || '000001'; + } + + const timeframe = $('#timeframe').val() || window.DEFAULT_MAIN_TIMEFRAME || '5m'; + const timezone = $('#timezone').val() || 'Asia/Shanghai'; + const elementTimeframe = $('#elementTimeframe').val() || window.DEFAULT_ELEMENT_TIMEFRAME || '1m'; + const subSubTimeframe = $('#subSubTimeframe').val() || ''; + + // 确保时区参数有效 + console.log('更新图表使用时区:', timezone); + console.log('数据源:', dataSource, '交易对/股票:', symbol); + + // 如果symbol为空,不发送请求 + if (!symbol) { + console.error('交易对/股票代码不能为空'); + $('#refreshLoadingSpinner').hide(); + return; + } + + console.log(`更新图表: symbol=${symbol}, timeframe=${timeframe}, elementTimeframe=${elementTimeframe}, timezone=${timezone}`); + + // 获取开始和结束时间(如果已设置) + let startTimeMs = null; + let endTimeMs = null; + + if ($('#start_time').val()) { + startTimeMs = new Date($('#start_time').val()).getTime(); + } + + if ($('#end_time').val()) { + endTimeMs = new Date($('#end_time').val()).getTime(); + } + + // 发送请求 + const requestId = ++lastRequestId; // 标记本次请求 + $.ajax({ + url: '/api/analyze', + data: { + symbol: symbol, + timeframe: timeframe, + timezone: timezone, + element_timeframe: elementTimeframe, + sub_sub_timeframe: subSubTimeframe || undefined, + start_time: startTimeMs, + end_time: endTimeMs, + elements_only: false, + zone_kl_lines: parseInt($('#zoneKlLines').val()) || 1000, + include_structure_zones: $('#showMainStructureZone').is(':checked') ? 1 : 0 + }, + success: function(data) { + // 隐藏加载图标 + $('#refreshLoadingSpinner').hide(); + + // 忽略过期响应 + if (requestId !== lastRequestId) { + return; + } + + // 保存当前数据 + if (currentData) { + // 覆盖前断开旧引用,帮助GC尽快回收 + delete currentData.original_kline_data; + delete currentData.original_macd; + } + currentData = data; + + refreshChart(data); + }, + error: function(jqXHR, textStatus, errorThrown) { + // 隐藏加载图标 + $('#refreshLoadingSpinner').hide(); + + // 显示错误信息 + console.error('加载数据失败:', errorThrown); + alert('加载数据失败: ' + (jqXHR.responseJSON?.error || errorThrown)); + } + }); +} +function captureChartViewState(chart) { + if (!chart || !chart.timeScale) return null; + const ts = chart.timeScale(); + const tsOptions = ts.options ? ts.options() : {}; + return { + barSpacing: tsOptions.barSpacing, + rightOffset: tsOptions.rightOffset, + scrollPosition: ts.scrollPosition ? ts.scrollPosition() : null, + visibleRange: ts.getVisibleRange ? ts.getVisibleRange() : null, + logicalRange: ts.getVisibleLogicalRange ? ts.getVisibleLogicalRange() : null + }; +} + +function restoreChartViewState(charts, viewState) { + if (!viewState || !Array.isArray(charts) || charts.length === 0) return; + const validCharts = charts.filter(c => c && c.timeScale); + if (validCharts.length === 0) return; + + validCharts.forEach(c => { + try { + const optionsPatch = {}; + if (typeof viewState.barSpacing === 'number') optionsPatch.barSpacing = viewState.barSpacing; + if (typeof viewState.rightOffset === 'number') optionsPatch.rightOffset = viewState.rightOffset; + if (Object.keys(optionsPatch).length) { + c.timeScale().applyOptions(optionsPatch); + } + } catch (e) {} + }); + + let restored = false; + + // 优先按逻辑范围恢复(对新数据更稳健) + if (viewState.logicalRange && viewState.logicalRange.from !== undefined && viewState.logicalRange.to !== undefined) { + validCharts.forEach(c => { + try { + c.timeScale().setVisibleLogicalRange(viewState.logicalRange); + restored = true; + } catch (e) {} + }); + } + + // 逻辑范围失败时,回退到时间可见范围 + if (!restored && viewState.visibleRange && viewState.visibleRange.from !== undefined && viewState.visibleRange.to !== undefined) { + validCharts.forEach(c => { + try { + c.timeScale().setVisibleRange(viewState.visibleRange); + restored = true; + } catch (e) {} + }); + } + + // 最后回退到滚动位置 + if (!restored && typeof viewState.scrollPosition === 'number') { + validCharts.forEach(c => { + try { c.timeScale().scrollToPosition(viewState.scrollPosition, false); } catch (e) {} + }); + } +} +// 初始化图表 diff --git a/web/static/js/app/datafeed.js b/web/static/js/app/datafeed.js new file mode 100644 index 0000000..2e26e2b --- /dev/null +++ b/web/static/js/app/datafeed.js @@ -0,0 +1,306 @@ +/** + * TradingView Datafeed — 对接 Data Provider 微服务 + * + * 数据源: http://103.179.242.166 + * - GET /timeframes → 可用周期 + * - GET /api/candles → 历史 OHLCV + * - WS /ws → 实时 K 线推送 + * + * 实现 IDatafeedChartApi 核心接口: + * onReady, resolveSymbol, getBars, subscribeBars, unsubscribeBars + */ + +var ChanTVDatafeed = (function () { + 'use strict' + + // 默认 data_provider 地址,可通过 URL param 覆盖 + var DATA_HOST = 'http://103.179.242.166' + + // ---- resolution <-> timeframe 转换 ---- + var RES_TO_TF = { + '1': '1m', '3': '3m', '5': '5m', '10': '10m', '15': '15m', '30': '30m', + '60': '1h', '120': '2h', '240': '4h', '360': '6h', '480': '8h', + '720': '12h', + 'D': '1d', '1D': '1d', + '3D': '3d', + 'W': '1w', '1W': '1w', + 'M': '1M', '1M': '1M', + } + + function resToTf(resolution) { + var r = String(resolution) + return RES_TO_TF[r] || r + } + + // ---- WebSocket 管理 ---- + var ws = null + var wsReconnectTimer = null + var wsSubs = {} // listenerGuid -> { symbol, tf, onTick, lastTickTime } + var wsUrl = DATA_HOST.replace(/^http/, 'ws') + '/ws' + + function wsConnect() { + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return + + try { + ws = new WebSocket(wsUrl) + } catch (e) { + console.warn('[TV Datafeed] WS 连接失败', e) + scheduleReconnect() + return + } + + ws.onopen = function () { + console.log('[TV Datafeed] WS 已连接') + // 重新订阅 + Object.keys(wsSubs).forEach(function (guid) { + var sub = wsSubs[guid] + sendWS({ action: 'subscribe', symbol: sub.symbol, timeframe: sub.tf }) + }) + } + + ws.onmessage = function (evt) { + try { + var msg = JSON.parse(evt.data) + var bars = msg.data || msg.bars // data_provider 用 'data' 字段 + if ((msg.type === 'kline' || msg.type === 'candles') && bars && bars.length > 0) { + // 只推送最新一根 bar,避免历史快照造成时间顺序冲突 + // 按时间升序排列取最后一个 + var sorted = bars.slice().sort(function (a, b) { return (a.timestamp || 0) - (b.timestamp || 0) }) + var latest = sorted[sorted.length - 1] + // 广播给所有匹配的 subscriber + Object.keys(wsSubs).forEach(function (guid) { + var sub = wsSubs[guid] + if (sub.symbol === msg.symbol && sub.tf === msg.timeframe) { + // 跳过已处理过的时间戳 + if (sub.lastTickTime && latest.timestamp <= sub.lastTickTime) return + try { + sub.onTick({ + time: latest.timestamp, + open: latest.open, + high: latest.high, + low: latest.low, + close: latest.close, + volume: latest.volume, + }) + sub.lastTickTime = latest.timestamp + } catch (e) { /* ignore */ } + } + }) + } + } catch (e) { + // ignore parse errors + } + } + + ws.onclose = function () { + console.log('[TV Datafeed] WS 断开') + ws = null + scheduleReconnect() + } + + ws.onerror = function () { + // onclose 会跟着触发 + } + } + + function scheduleReconnect() { + if (wsReconnectTimer) return + wsReconnectTimer = setTimeout(function () { + wsReconnectTimer = null + wsConnect() + }, 3000) + } + + function sendWS(data) { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(data)) + } + } + + // ---- Datafeed API ---- + + /** + * 主配置:返回支持的 resolutions、exchanges 等 + */ + function onReady(callback) { + // 使用固定 resolutions(避免 /timeframes 502 阻塞初始化) + var supported = ['1', '5', '15', '30', '60', '120', '240', 'D', 'W'] + console.log('[TV Datafeed] onReady — supported_resolutions:', supported) + + setTimeout(function () { + callback({ + supported_resolutions: supported, + supports_marks: false, + supports_timescale_marks: false, + supports_time: true, + exchanges: [{ value: 'BINANCE', name: 'Binance', desc: 'Binance Futures' }], + symbols_types: [{ name: 'Crypto', value: 'crypto' }], + }) + }, 0) + } + + /** + * 解析 symbol:'BINANCE:BTC/USDT:USDT' → 分离 exchange 和 symbol + */ + function resolveSymbol(symbolName, onResolve, onError) { + var name = String(symbolName) + var exchange = 'BINANCE' + var symbol = name + + // 解析 EXCHANGE:SYMBOL 格式 + // 如果第一段不含 '/',就是交易所名;否则整串就是 symbol + // 例: 'BINANCE:BTC/USDT:USDT' → exchange=BINANCE, sym=BTC/USDT:USDT + // 'BTC/USDT:USDT' → exchange=BINANCE, sym=BTC/USDT:USDT + // 'BTC/USDT' → exchange=BINANCE, sym=BTC/USDT:USDT + var firstColon = name.indexOf(':') + if (firstColon >= 0) { + var prefix = name.substring(0, firstColon) + if (prefix.indexOf('/') === -1) { + // 第一段是交易所名(如 'BINANCE') + exchange = prefix + symbol = name.substring(firstColon + 1) + } + // 否则第一段含 '/'(如 'BTC/USDT'),整串就是 symbol + } + + // data_provider 用 BTC/USDT:USDT 格式(需要 :USDT 后缀) + var dpSymbol = symbol + if (dpSymbol.indexOf(':USDT') === -1 && dpSymbol.indexOf('/USDT') >= 0) { + dpSymbol = dpSymbol + ':USDT' + } + + console.log('[TV Datafeed] resolveSymbol', name, '→ exchange:', exchange, 'symbol:', symbol, 'dp:', dpSymbol) + + // TV 要求异步回调(setTimeout 0) + setTimeout(function () { + onResolve({ + name: name, + ticker: name, + description: symbol, + exchange: exchange, + type: 'crypto', + session: '24x7', + timezone: 'Asia/Shanghai', + minmov: 1, + pricescale: 100, + has_intraday: true, + has_seconds: false, + has_daily: true, + has_weekly_and_monthly: true, + supported_resolutions: ['1', '5', '15', '30', '60', '120', '240', 'D', 'W'], + intraday_multipliers: ['1', '5', '15', '30', '60', '120', '240'], + volume_precision: 2, + _dpSymbol: dpSymbol, + }) + }, 0) + } + + /** + * 获取历史 bars + */ + function getBars(symbolInfo, resolution, periodParams, onResult, onError) { + var tf = resToTf(resolution) + var symbol = symbolInfo._dpSymbol || symbolInfo.ticker.split(':').slice(1).join(':') + // 确保 symbol 是 data_provider 格式 + if (symbol.indexOf(':USDT') === -1 && symbol.indexOf('/USDT') >= 0) { + symbol = symbol + ':USDT' + } + + var params = 'symbol=' + encodeURIComponent(symbol) + '&tf=' + encodeURIComponent(tf) + + // periodParams.from / to 是秒,data_provider 需要毫秒 + if (periodParams.from) { + params += '&start=' + (periodParams.from * 1000) + } + if (periodParams.to) { + params += '&end=' + (periodParams.to * 1000) + } + if (periodParams.firstDataRequest) { + // 首次请求多取一些数据供缠论计算 + params += '&limit=1000' + } + + var url = DATA_HOST + '/api/candles?' + params + console.log('[TV Datafeed] getBars', symbol, tf, '→', url) + + fetch(url) + .then(function (r) { + if (!r.ok) throw new Error('HTTP ' + r.status) + return r.json() + }) + .then(function (data) { + console.log('[TV Datafeed] getBars 返回', data.length, '条') + if (!Array.isArray(data) || data.length === 0) { + onResult([], { noData: true }) + return + } + + // 按时间升序排列并去重,避免跨请求重叠导致时间顺序冲突 + var seen = {} + var bars = [] + data.forEach(function (d) { + if (!seen[d.timestamp]) { + seen[d.timestamp] = true + bars.push({ + time: d.timestamp, // ms + open: d.open, + high: d.high, + low: d.low, + close: d.close, + volume: d.volume, + }) + } + }) + bars.sort(function (a, b) { return a.time - b.time }) + + // 传 noData: false 表示还有更多历史数据 + onResult(bars, { noData: false }) + }) + .catch(function (err) { + console.error('[TV Datafeed] getBars 失败', err) + onError(err.message || '获取数据失败') + }) + } + + /** + * 订阅实时数据(通过 WebSocket) + */ + function subscribeBars(symbolInfo, resolution, onTick, listenerGuid) { + var tf = resToTf(resolution) + var symbol = symbolInfo._dpSymbol || symbolInfo.ticker.split(':').slice(1).join(':') + if (symbol.indexOf(':USDT') === -1 && symbol.indexOf('/USDT') >= 0) { + symbol = symbol + ':USDT' + } + + wsSubs[listenerGuid] = { symbol: symbol, tf: tf, onTick: onTick } + + // 确保 WS 已连接 + wsConnect() + + // 如果已连接,立即订阅 + if (ws && ws.readyState === WebSocket.OPEN) { + sendWS({ action: 'subscribe', symbol: symbol, timeframe: tf }) + } + // 否则等 WS onopen 时会重新订阅所有 + } + + /** + * 取消订阅 + */ + function unsubscribeBars(listenerGuid) { + var sub = wsSubs[listenerGuid] + if (sub) { + sendWS({ action: 'unsubscribe', symbol: sub.symbol, timeframe: sub.tf }) + delete wsSubs[listenerGuid] + } + } + + // ---- 导出 ---- + return { + onReady: onReady, + resolveSymbol: resolveSymbol, + getBars: getBars, + subscribeBars: subscribeBars, + unsubscribeBars: unsubscribeBars, + } +})() diff --git a/web/static/js/app/macd_ui.js b/web/static/js/app/macd_ui.js new file mode 100644 index 0000000..326bc78 --- /dev/null +++ b/web/static/js/app/macd_ui.js @@ -0,0 +1,258 @@ +/* macd_ui.js */ +function showMacdConfig() { + $.get('/api/macd_config', function(data) { + $('#macdFastPeriod').val(data.fast); + $('#macdSlowPeriod').val(data.slow); + $('#macdSignalPeriod').val(data.signal); + $('#macdConfigModal').css('display', 'flex'); + }); +} + +function hideMacdConfig() { + $('#macdConfigModal').css('display', 'none'); +} + +function resetMacdConfig() { + $('#macdFastPeriod').val(24); + $('#macdSlowPeriod').val(52); + $('#macdSignalPeriod').val(9); +} + +function saveMacdConfig() { + const fast = parseInt($('#macdFastPeriod').val()); + const slow = parseInt($('#macdSlowPeriod').val()); + const signal = parseInt($('#macdSignalPeriod').val()); + if (fast >= slow) { + alert('快线周期必须小于慢线周期'); + return; + } + if (fast < 2 || slow < 2 || signal < 2) { + alert('周期值必须大于等于2'); + return; + } + $.ajax({ + url: '/api/macd_config', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify({ fast: fast, slow: slow, signal: signal }), + success: function() { + hideMacdConfig(); + updateChart(); + }, + error: function() { + alert('保存MACD参数失败'); + } + }); +} + +$(document).on('click', '#macdConfigModal', function(e) { + if (e.target === this) hideMacdConfig(); +}); + +// 添加原始K线复选框变更事件 +$('#showOriginalKline').change(function() { + updateChartDisplay(); +}); + +// 添加K线形态下拉变更事件(同步隐藏的原始K线开关并重绘) +$('#klineType').change(function() { + const type = $(this).val(); + $('#showOriginalKline').prop('checked', type === 'candlestick'); + updateChartDisplay(); +}); + +// 添加笔复选框变更事件 +$('#showMainBi').change(function() { + updateChartDisplay(); +}); + +// 添加线段复选框变更事件 +$('#showMainSeg').change(function() { + updateChartDisplay(); +}); + +// 添加中枢复选框变更事件 +$('#showMainZs').change(function() { + updateChartDisplay(); +}); +// 添加主周期BI中枢复选框变更事件(委托绑定,避免DOM更新后失效) +console.log('初始化BI中枢事件绑定'); +$(document).on('change', '#showMainBiZs', function() { + console.log('主BI中枢切换为:', $('#showMainBiZs').is(':checked')); + updateChartDisplay(); +}); +// 结构价值区复选框变更事件 +$(document).on('change', '#showMainStructureZone', function() { + const on = $('#showMainStructureZone').is(':checked'); + console.log('结构区切换为:', on); + // 勾选后才向服务器请求多周期结构区数据;取消勾选仅重绘,不重复拉取 + if (on) { + updateChart(); + } else { + updateChartDisplay(); + } +}); + +// 添加趋势显示复选框变更事件(主/元素),变更后刷新主图 +$('#showMainTrend').change(function() { + updateChartDisplay(); +}); +$('#showElementTrend').change(function() { + updateChartDisplay(); +}); + +// 添加买卖点复选框变更事件 +$('#showElementBi').change(function() { + updateChartDisplay(); +}); + +// 添加线段复选框变更事件 +$('#showElementSeg').change(function() { + updateChartDisplay(); +}); + +// 添加中枢复选框变更事件 +$('#showElementZs').change(function() { + updateChartDisplay(); +}); +// 添加次周期BI中枢复选框变更事件(委托绑定,避免DOM更新后失效) +$(document).on('change', '#showElementBiZs', function() { + console.log('次BI中枢切换为:', $('#showElementBiZs').is(':checked')); + updateChartDisplay(); +}); +// 次次周期显示开关变更事件 +$('#showSubSubBi, #showSubSubSeg, #showSubSubZs, #showSubSubBiZs, #showSubSubKlcFxType, #showSubSubTrend, #showSubSubBsp').change(function() { + updateChartDisplay(); +}); +$(document).on('change', '#toggleUOnSubSub', function() { + window.showUOnSubSub = $('#toggleUOnSubSub').is(':checked'); + updateChartDisplay(); +}); + +// 买卖点复选框已移除 + +// 趋势开关已移除 + +// 添加K线周期切换事件监听器 +$('input[name="klinePeriod"]').change(function() { + console.log('K线周期切换:', $(this).attr('id'), $(this).is(':checked')); + updateChartDisplay(); + + // 更新数据源信息 + if (currentData) { + setupDataSourceInfo(currentData); + } +}); + +// 当选择不同的元素时间周期时 +$('#elementTimeframe').change(function() { + const elementTimeframe = $(this).val(); + const mainTimeframe = $('#timeframe').val(); + + // 检查选择的元素时间周期是否小于等于主周期 + if (compareTimeframes(elementTimeframe, mainTimeframe) > 0) { + alert('元素时间周期必须小于或等于主图表时间周期。'); + setSmallerOrEqualTimeframe(); // 重置为最大的小于等于时间周期 + return; + } + // 次次周期必须小于等于次周期 + ensureSubSubLteElement(); + console.log(`当前选择的元素时间周期: ${elementTimeframe},需要点击分析按钮来应用更改`); +}); +// 次次周期变更时校验 <= 次周期 +$('#subSubTimeframe').change(function() { + const subSub = $(this).val(); + const elementTf = $('#elementTimeframe').val(); + if (compareTimeframes(subSub, elementTf) > 0) { + alert('次次周期必须小于或等于次周期。'); + ensureSubSubLteElement(); + return; + } +}); +function ensureSubSubLteElement() { + const timeframes = window.AVAILABLE_TIMEFRAMES || []; + const elementTf = $('#elementTimeframe').val(); + const subSubTf = $('#subSubTimeframe').val(); + if (compareTimeframes(subSubTf, elementTf) > 0) { + const idxEl = timeframes.indexOf(elementTf); + const validSubSub = idxEl > 0 ? timeframes[idxEl - 1] : timeframes[0]; + $('#subSubTimeframe').val(validSubSub || elementTf); + } +} + +/** 应用 /api/chart_metadata 返回的周期列表(切换 crypto / A股 时拉取) */ +function applyChartMetadata(meta) { + if (!meta || meta.error || !Array.isArray(meta.timeframe_keys) || meta.timeframe_keys.length === 0) { + return; + } + window.AVAILABLE_TIMEFRAMES = meta.timeframe_keys; + window.DEFAULT_MAIN_TIMEFRAME = meta.default_main; + window.DEFAULT_ELEMENT_TIMEFRAME = meta.default_element; + window.DEFAULT_SUB_SUB_TIMEFRAME = meta.default_sub_sub; + const labels = meta.timeframes || {}; + function refill(selId, preferredVal) { + const $el = $(selId); + const cur = $el.val(); + $el.empty(); + meta.timeframe_keys.forEach(function(k) { + $el.append($('