From ebcb3dce73e690acd92e69c604be8cd35a91f185 Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Tue, 12 May 2026 01:31:56 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20StructureZone=20=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E4=BB=B7=E5=80=BC=E5=8C=BA=E7=B3=BB=E7=BB=9F=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=A4=9A=E5=91=A8=E6=9C=9F=E6=94=AF=E6=92=91?= =?UTF-8?q?/=E9=98=BB=E5=8A=9B=E5=88=86=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ChanZone.py: 从笔中枢/线段中枢/EMA52 提取价格区,聚类评分 - ChanLun.py 新增 get_structure_zones() 方法 - web/app.py: 独立拉取多周期数据 + 缓存 + limit 传参避免全量传输 - web/index.html: 结构区勾选框 + K线数量输入 + 半透明填充区绘制 - tests/test_chan_zone.py: 24 个单元测试 Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 22 +- ChanLun.py | 11 + ChanZone.py | 566 +++++++++++++++++++++++++++++++++++++++ tests/test_chan_zone.py | 300 +++++++++++++++++++++ web/app.py | 132 ++++++++- web/templates/index.html | 65 ++++- 6 files changed, 1082 insertions(+), 14 deletions(-) create mode 100644 ChanZone.py create mode 100644 tests/test_chan_zone.py diff --git a/CLAUDE.md b/CLAUDE.md index 19b210d..0bdfec7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,36 +12,38 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Data processing pipeline (each step feeds the next): -1. **`ChanKLU.py`** — Raw K-line unit with TA indicators (EMA, MACD, RSI, Bollinger Bands) -2. **`ChanKLC.py`** — Combined K-line: inclusion processing (包含处理), fractal (分型) detection +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. **`ChanSEG.py`** — Segment (线段): built from strokes -5. **`ChanZS.py`** / **`ChanBIZS.py`** — Center/pivot (中枢): consolidation zones (stroke-level and segment-level) -6. **`ChanBSP.py`** — Buy/Sell points (买卖点): Type 1/2/3 signals -7. **`ChanLun.py`** — Main orchestrator: ties all steps together, entry point -8. **`TF_DF.py`** — Timeframe-aware DataFrame processor: resamples data, runs the full pipeline per timeframe, handles multi-timeframe analysis +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 -- **`ChanKLU.py`** — K-line unit with candlestick pattern recognition (`Chan_KLU_PATTERN`) - **`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 ### 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 (40+ strategies) +- **`strategies/`** — Freqtrade trading strategies using the Chan theory engine (53 strategies) - **`config/`** — Freqtrade JSON config files per pair/timeframe ### Data Flow ``` Exchange (CCXT) → data_provider (CSV cache) → Freqtrade → Strategy → ChanLun → TF_DF - → KLU → KLC → BI → SEG → ZS → BSP + → KLU → KLC → BI → SBI → SEG → ZS → BSP ``` ## Common Commands diff --git a/ChanLun.py b/ChanLun.py index 2eeee78..14ddabe 100644 --- a/ChanLun.py +++ b/ChanLun.py @@ -24,6 +24,7 @@ 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): @@ -126,6 +127,16 @@ class ChanLun(): + + 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) diff --git a/ChanZone.py b/ChanZone.py new file mode 100644 index 0000000..2245d76 --- /dev/null +++ b/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/tests/test_chan_zone.py b/tests/test_chan_zone.py new file mode 100644 index 0000000..472b380 --- /dev/null +++ b/tests/test_chan_zone.py @@ -0,0 +1,300 @@ +""" +StructureZone 系统单元测试 +""" +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import pytest +from ChanZone import ( + RawZonePoint, StructureZone, StructureZoneConfig, + cluster_raw_points, build_structure_zones, _calc_strength, _calc_confidence, _calc_recency, + extract_raw_points_from_serialized, analyze_structure_zones_from_serialized, +) + + +class TestClusterRawPoints: + """聚类算法测试""" + + def test_empty_points(self): + config = StructureZoneConfig() + result = cluster_raw_points([], config) + assert result == [] + + def test_single_point_filtered(self): + """单点被 min_overlap 过滤""" + config = StructureZoneConfig(min_overlap_for_zone=2) + points = [RawZonePoint(price=100, timeframe='5m', structure_type='bi_zhongshu', + boundary_type='ZG', source_zs_id=0, is_sure=True)] + result = cluster_raw_points(points, config) + assert result == [] + + def test_two_nearby_points_merge(self): + """相邻价格点归为一类""" + config = StructureZoneConfig(cluster_radius_pct=1.0, min_overlap_for_zone=2) + points = [ + RawZonePoint(price=100, timeframe='5m', structure_type='bi_zhongshu', + boundary_type='ZG', source_zs_id=0, is_sure=True), + RawZonePoint(price=100.5, timeframe='15m', structure_type='xd_zhongshu', + boundary_type='ZD', source_zs_id=0, is_sure=True), + ] + result = cluster_raw_points(points, config) + assert len(result) == 1 + assert len(result[0]) == 2 + + def test_two_distant_points_separate(self): + """远离的价格点不归为一类""" + config = StructureZoneConfig(cluster_radius_pct=0.1, min_overlap_for_zone=1) # 先用 1 看聚类 + points = [ + RawZonePoint(price=100, timeframe='5m', structure_type='bi_zhongshu', + boundary_type='ZG', source_zs_id=0, is_sure=True), + RawZonePoint(price=110, timeframe='15m', structure_type='xd_zhongshu', + boundary_type='ZD', source_zs_id=0, is_sure=True), + ] + # 先用 min_overlap=2 确认被过滤 + config2 = StructureZoneConfig(cluster_radius_pct=0.1, min_overlap_for_zone=2) + result = cluster_raw_points(points, config2) + assert result == [] # 两个单独点,都不够 min_overlap + + def test_multi_tf_convergence(self): + """多个时间周期在相同价格区间聚合""" + config = StructureZoneConfig(cluster_radius_pct=1.0, min_overlap_for_zone=2) + points = [] + for tf in ['5m', '15m', '30m', '1h']: + for btype in ['ZG', 'ZD']: + points.append(RawZonePoint(price=100 + abs(hash(tf + btype)) % 3 * 0.1, + timeframe=tf, structure_type='bi_zhongshu', + boundary_type=btype, source_zs_id=0, is_sure=True)) + result = cluster_raw_points(points, config) + assert len(result) >= 1 + # 所有点应该聚合在一起(价差很小) + total = sum(len(c) for c in result) + assert total == len(points) + + +class TestBuildStructureZones: + """评分和构建测试""" + + def _make_cluster(self, prices, tf='5m', st='bi_zhongshu'): + return [RawZonePoint(price=p, timeframe=tf, structure_type=st, + boundary_type='ZG', source_zs_id=0, is_sure=True, + candle_time='2025-01-01T00:00:00') + for p in prices] + + def test_zone_type_support(self): + """当前价上方区间是阻力,下方是支撑""" + config = StructureZoneConfig() + cluster = self._make_cluster([90, 92]) + ema52 = {'5m': 100} + zones = build_structure_zones([cluster], current_price=100, ema52_values=ema52, + latest_candle_time='2025-01-01T01:00:00', config=config) + assert len(zones) == 1 + assert zones[0].zone_type == 'support' # 在价格下方 + + def test_zone_type_resistance(self): + config = StructureZoneConfig() + cluster = self._make_cluster([110, 112]) + ema52 = {'5m': 100} + zones = build_structure_zones([cluster], current_price=100, ema52_values=ema52, + latest_candle_time='2025-01-01T01:00:00', config=config) + assert len(zones) == 1 + assert zones[0].zone_type == 'resistance' + + def test_zone_type_neutral(self): + config = StructureZoneConfig() + cluster = self._make_cluster([95, 105]) + ema52 = {'5m': 100} + zones = build_structure_zones([cluster], current_price=100, ema52_values=ema52, + latest_candle_time='2025-01-01T01:00:00', config=config) + assert len(zones) == 1 + assert zones[0].zone_type == 'neutral' + + def test_strength_score_range(self): + """评分在 0-100 之间""" + config = StructureZoneConfig() + cluster = self._make_cluster([100, 102, 104], '5m', 'bi_zhongshu') + cluster += self._make_cluster([100.5, 102.5], '15m', 'xd_zhongshu') + ema52 = {'5m': 0, '15m': 0} + zones = build_structure_zones([cluster], current_price=110, ema52_values=ema52, + latest_candle_time='2025-01-01T01:00:00', config=config) + assert len(zones) == 1 + assert 0 <= zones[0].strength_score <= 100 + + def test_ema52_aligned_true(self): + """EMA52 落在区间内""" + config = StructureZoneConfig() + cluster = self._make_cluster([95, 105]) + ema52 = {'5m': 100} + zones = build_structure_zones([cluster], current_price=110, ema52_values=ema52, + latest_candle_time='2025-01-01T01:00:00', config=config) + assert zones[0].ema52_aligned is True + + def test_ema52_aligned_false(self): + """EMA52 不在区间内""" + config = StructureZoneConfig() + cluster = self._make_cluster([95, 105]) + ema52 = {'5m': 120} + zones = build_structure_zones([cluster], current_price=110, ema52_values=ema52, + latest_candle_time='2025-01-01T01:00:00', config=config) + assert zones[0].ema52_aligned is False + + def test_confidence_range(self): + """置信度在 0-1 之间""" + config = StructureZoneConfig() + cluster = self._make_cluster([100, 101, 102, 103]) + ema52 = {} + zones = build_structure_zones([cluster], current_price=110, ema52_values=ema52, + latest_candle_time='2025-01-01T01:00:00', config=config) + assert 0 <= zones[0].confidence <= 1 + + def test_max_zones_cap(self): + """max_zones 限制返回数量""" + config = StructureZoneConfig(max_zones=3) + clusters = [self._make_cluster([100 + i * 10, 100 + i * 10 + 2]) for i in range(10)] + ema52 = {} + zones = build_structure_zones(clusters, current_price=150, ema52_values=ema52, + latest_candle_time='2025-01-01T01:00:00', config=config) + assert len(zones) <= 3 + + def test_sorted_by_strength(self): + """按 strength 降序排列""" + config = StructureZoneConfig(max_zones=0) + # 创建一个有更多重叠的聚类(更强)和一个较弱的聚类 + cluster_strong = self._make_cluster([100, 101, 102, 103, 104]) # 5 点 + cluster_weak = self._make_cluster([200, 201]) # 2 点 + ema52 = {'5m': 0} + zones = build_structure_zones([cluster_weak, cluster_strong], current_price=150, + ema52_values=ema52, + latest_candle_time='2025-01-01T01:00:00', config=config) + assert zones[0].strength_score >= zones[-1].strength_score + + +class TestExtractFromSerialized: + """从序列化数据提取测试""" + + def test_empty_analyses(self): + config = StructureZoneConfig() + points = extract_raw_points_from_serialized({}, {}, config) + assert points == [] + + def test_basic_extraction(self): + analyses = { + '5m': { + 'bi_zs_list': [ + {'zg': 100, 'zd': 95, 'gg': 102, 'dd': 93, 'is_sure': True, 'end_time': '2025-01-01T00:00'}, + ], + 'zs_list': [], + }, + '15m': { + 'bi_zs_list': [], + 'zs_list': [ + {'zg': 105, 'zd': 98, 'gg': 107, 'dd': 96, 'is_sure': True, 'end_time': '2025-01-01T00:00'}, + ], + }, + } + ema52 = {'5m': 101, '15m': 103} + config = StructureZoneConfig(zone_timeframes=['5m', '15m']) + points = extract_raw_points_from_serialized(analyses, ema52, config) + # bi_zs: 4 points (ZG/ZD/GG/DD) + zs_list: 4 points + 2 ema52 = 10 + assert len(points) == 10 + + def test_unsure_filtered(self): + analyses = { + '5m': { + 'bi_zs_list': [ + {'zg': 100, 'zd': 95, 'gg': 102, 'dd': 93, 'is_sure': False}, + ], + 'zs_list': [], + }, + } + ema52 = {} + config = StructureZoneConfig(zone_timeframes=['5m']) + points = extract_raw_points_from_serialized(analyses, ema52, config) + assert len(points) == 0 # is_sure=False 被过滤 + + def test_timeframe_filtering(self): + """仅提取 config.zone_timeframes 中的周期""" + analyses = { + '5m': { + 'bi_zs_list': [ + {'zg': 100, 'zd': 95, 'gg': 102, 'dd': 93, 'is_sure': True}, + ], + 'zs_list': [], + }, + '1h': { + 'bi_zs_list': [ + {'zg': 200, 'zd': 190, 'gg': 205, 'dd': 188, 'is_sure': True}, + ], + 'zs_list': [], + }, + } + ema52 = {'5m': 101, '1h': 195} + config = StructureZoneConfig(zone_timeframes=['5m']) # 只取 5m + points = extract_raw_points_from_serialized(analyses, ema52, config) + # 只有 5m: 4 bi_zs + 1 ema52 = 5 + assert len(points) == 5 + assert all(p.timeframe == '5m' for p in points) + + +class TestScoringHelpers: + """评分辅助函数测试""" + + def test_calc_recency_same_time(self): + """同一时间的 recency = 1.0""" + score = _calc_recency('2025-01-01T00:00:00', '2025-01-01T00:00:00', 50) + assert score == 1.0 + + def test_calc_recency_invalid(self): + """无效时间的 recency = 0.5""" + score = _calc_recency(None, '2025-01-01T00:00:00', 50) + assert score == 0.5 + + def test_calc_confidence_high(self): + """高重叠数 = 高置信度""" + conf = _calc_confidence(6, 3, []) + assert conf > 0.7 + + def test_calc_confidence_low(self): + """低重叠数 = 低置信度""" + conf = _calc_confidence(2, 1, []) + assert conf < 0.7 + + +class TestAnalyzeFromSerialized: + """端到端测试(从序列化数据到 StructureZone)""" + + def test_end_to_end(self): + analyses = { + '5m': { + 'bi_zs_list': [ + {'zg': 100, 'zd': 95, 'gg': 102, 'dd': 93, 'is_sure': True, 'end_time': '2025-01-01T00:00'}, + ], + 'zs_list': [], + }, + '15m': { + 'bi_zs_list': [ + {'zg': 101, 'zd': 96, 'gg': 103, 'dd': 94, 'is_sure': True, 'end_time': '2025-01-01T00:01'}, + ], + 'zs_list': [], + }, + } + ema52_dict = {'5m': 100.5, '15m': 99.5} + config = StructureZoneConfig(zone_timeframes=['5m', '15m'], cluster_radius_pct=2.0) + zones = analyze_structure_zones_from_serialized(analyses, ema52_dict, 110, config) + # 两个 TF 的 BI_ZS 价格接近,应聚合成一个区间 + assert len(zones) >= 1 + zone = zones[0] + assert zone.zone_type == 'support' # 价格在 93-103,current_price=110 + assert '5m' in zone.timeframes + assert '15m' in zone.timeframes + assert zone.structure_types == ['bi_zhongshu'] + assert 0 <= zone.strength_score <= 100 + assert 0 <= zone.confidence <= 1 + + def test_empty_returns_empty(self): + zones = analyze_structure_zones_from_serialized({}, {}, 100) + assert zones == [] + + +if __name__ == '__main__': + pytest.main([__file__, '-v']) diff --git a/web/app.py b/web/app.py index c2a3c70..b723b72 100644 --- a/web/app.py +++ b/web/app.py @@ -12,6 +12,7 @@ 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 @@ -22,6 +23,7 @@ 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 # 添加买卖点枚举类型 class TRADE_POINT_TYPE: @@ -48,9 +50,22 @@ china_stock = ChinaStockData() logger = logging.getLogger(__name__) -DATA_SERVICE_URL = os.environ.get("DATA_SERVICE_URL", os.environ.get("DATASVC_URL", "http://127.0.0.1:9009")) -#DATA_SERVICE_URL = os.environ.get("DATA_SERVICE_URL", os.environ.get("DATASVC_URL", "http://192.168.1.9:9009")) -#DATA_SERVICE_URL = os.environ.get("DATA_SERVICE_URL", os.environ.get("DATASVC_URL", "http://103.179.242.166")) +# 结构价值区缓存: {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分钟 + +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分钟"), @@ -185,6 +200,8 @@ def _fetch_kl_from_datasvc(symbol, timeframe, start_ms=None, end_ms=None, limit= 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() @@ -1778,6 +1795,115 @@ def analyze(): pass + # 结构价值区分析(Structure Zone)—— 独立拉取多周期数据,缓存避免重复请求 + 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'] = [] + return jsonify(result) @app.route('/api/symbols') diff --git a/web/templates/index.html b/web/templates/index.html index 4985279..884d798 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -958,6 +958,11 @@ +
+ + +
+ @@ -1911,6 +1916,11 @@ console.log('主BI中枢切换为:', $('#showMainBiZs').is(':checked')); updateChartDisplay(); }); + // 结构价值区复选框变更事件 + $(document).on('change', '#showMainStructureZone', function() { + console.log('结构区切换为:', $('#showMainStructureZone').is(':checked')); + updateChartDisplay(); + }); // 添加趋势显示复选框变更事件(主/元素),变更后刷新主图 $('#showMainTrend').change(function() { @@ -2179,7 +2189,8 @@ sub_sub_timeframe: subSubTimeframe || undefined, start_time: startTimeMs, end_time: endTimeMs, - elements_only: false + elements_only: false, + zone_kl_lines: parseInt($('#zoneKlLines').val()) || 1000 }, success: function(data) { // 隐藏加载图标 @@ -4634,6 +4645,58 @@ } 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('绘制未完成中枢 - 已启用');