添加 StructureZone 结构价值区系统,支持多周期支撑/阻力分析

- 新增 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 <noreply@anthropic.com>
This commit is contained in:
jackyu66git
2026-05-12 01:31:56 +08:00
co-authored by Claude Opus 4.6
parent d8069e977f
commit ebcb3dce73
6 changed files with 1082 additions and 14 deletions
+12 -10
View File
@@ -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
+11
View File
@@ -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)
+566
View File
@@ -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)
+300
View File
@@ -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-103current_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'])
+129 -3
View File
@@ -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')
+64 -1
View File
@@ -958,6 +958,11 @@
<input class="form-check-input" type="checkbox" id="autoRefresh">
<label class="form-check-label" for="autoRefresh">启用</label>
</div>
<div class="form-check form-check-inline me-2">
<input class="form-check-input" type="checkbox" id="showMainStructureZone">
<label class="form-check-label" for="showMainStructureZone">结构区</label>
</div>
<input type="number" id="zoneKlLines" class="form-control form-control-sm" value="1000" min="100" max="5000" step="100" style="width:80px;" title="结构区K线数量">
<span id="nextRefreshTime" class="text-muted" style="display:none;font-size:0.85rem;"></span>
<div id="refreshLoadingSpinner" class="loading-spinner ms-2" style="display:none;"></div>
</div>
@@ -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('绘制未完成中枢 - 已启用');