添加 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:
co-authored by
Claude Opus 4.6
parent
d8069e977f
commit
ebcb3dce73
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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'])
|
||||
+129
-3
@@ -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')
|
||||
|
||||
@@ -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('绘制未完成中枢 - 已启用');
|
||||
|
||||
Reference in New Issue
Block a user