添加 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
+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('绘制未完成中枢 - 已启用');