添加 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
+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