833 lines
36 KiB
Python
833 lines
36 KiB
Python
"""
|
|
Dash Web应用:提供交互式缠论分析界面
|
|
"""
|
|
|
|
import dash
|
|
from dash import dcc, html, Input, Output, State, callback_context
|
|
import plotly.graph_objects as go
|
|
import pandas as pd
|
|
import numpy as np
|
|
from datetime import datetime, timedelta
|
|
import logging
|
|
|
|
from data.data_fetcher import DataFetcher
|
|
from data.data_processor import DataProcessor
|
|
from core.chan_analyzer import ChanAnalyzer
|
|
from .visualization import ChanVisualizer
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def create_app():
|
|
"""创建Dash应用"""
|
|
app = dash.Dash(__name__, external_stylesheets=[
|
|
'https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css'
|
|
])
|
|
|
|
# 添加自定义CSS样式
|
|
app.index_string = '''
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
{%metas%}
|
|
<title>{%title%}</title>
|
|
{%favicon%}
|
|
{%css%}
|
|
<style>
|
|
.dash-slider .rc-slider-rail {
|
|
height: 4px;
|
|
}
|
|
.dash-slider .rc-slider-track {
|
|
height: 4px;
|
|
}
|
|
.dash-slider .rc-slider-handle {
|
|
width: 12px;
|
|
height: 12px;
|
|
margin-top: -4px;
|
|
}
|
|
.form-label {
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
.row {
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
.small {
|
|
font-size: 0.875rem;
|
|
}
|
|
.btn-sm {
|
|
padding: 0.25rem 0.5rem;
|
|
font-size: 0.875rem;
|
|
}
|
|
.dash-graph {
|
|
margin-bottom: 1rem;
|
|
}
|
|
h1 {
|
|
font-size: 1.75rem;
|
|
}
|
|
h5 {
|
|
font-size: 1rem;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
{%app_entry%}
|
|
<footer>
|
|
{%config%}
|
|
{%scripts%}
|
|
{%renderer%}
|
|
</footer>
|
|
</body>
|
|
</html>
|
|
'''
|
|
|
|
# 初始化组件
|
|
data_fetcher = DataFetcher()
|
|
data_processor = DataProcessor()
|
|
visualizer = ChanVisualizer()
|
|
|
|
# 应用布局
|
|
app.layout = html.Div([
|
|
# 标题
|
|
html.H1("缠论分析系统", className="text-center mb-2"),
|
|
|
|
# 控制面板
|
|
html.Div([
|
|
html.Div([
|
|
html.Label("交易对:", className="small"),
|
|
dcc.Dropdown(
|
|
id='symbol-dropdown',
|
|
options=[
|
|
{'label': 'BTC/USDT', 'value': 'BTC/USDT'},
|
|
{'label': 'ETH/USDT', 'value': 'ETH/USDT'},
|
|
{'label': 'BNB/USDT', 'value': 'BNB/USDT'},
|
|
{'label': 'ADA/USDT', 'value': 'ADA/USDT'},
|
|
{'label': 'SOL/USDT', 'value': 'SOL/USDT'}
|
|
],
|
|
value='BTC/USDT',
|
|
className="mb-1"
|
|
)
|
|
], className="col-md-2"),
|
|
|
|
html.Div([
|
|
html.Label("时间周期:", className="small"),
|
|
dcc.Dropdown(
|
|
id='timeframe-dropdown',
|
|
options=[
|
|
{'label': '1分钟', 'value': '1m'},
|
|
{'label': '5分钟', 'value': '5m'},
|
|
{'label': '15分钟', 'value': '15m'},
|
|
{'label': '30分钟', 'value': '30m'},
|
|
{'label': '1小时', 'value': '1h'},
|
|
{'label': '4小时', 'value': '4h'},
|
|
{'label': '1天', 'value': '1d'}
|
|
],
|
|
value='5m',
|
|
className="form-select mb-1"
|
|
)
|
|
], className="col-md-2"),
|
|
|
|
html.Div([
|
|
html.Label("数据数量:", className="small"),
|
|
dcc.Slider(
|
|
id='limit-slider',
|
|
min=100,
|
|
max=1000,
|
|
step=50,
|
|
value=100,
|
|
marks={i: str(i) for i in range(100, 1001, 300)},
|
|
className="mb-1"
|
|
)
|
|
], className="col-md-2"),
|
|
|
|
html.Div([
|
|
html.Label("基础分型强度:", className="small"),
|
|
dcc.Slider(
|
|
id='fractal-strength-slider',
|
|
min=1,
|
|
max=5,
|
|
step=1,
|
|
value=5,
|
|
marks={i: str(i) for i in range(1, 6)},
|
|
className="mb-1"
|
|
)
|
|
], className="col-md-2"),
|
|
|
|
html.Div([
|
|
html.Label("增强强度过滤:", className="small"),
|
|
dcc.Slider(
|
|
id='enhanced-strength-filter',
|
|
min=0,
|
|
max=100,
|
|
step=10,
|
|
value=0,
|
|
marks={i: str(i) for i in range(0, 101, 50)},
|
|
className="mb-1"
|
|
)
|
|
], className="col-md-2"),
|
|
|
|
html.Div([
|
|
html.Label("显示级别:", className="small"),
|
|
dcc.Dropdown(
|
|
id='display-level-dropdown',
|
|
options=[
|
|
{'label': '全部分型', 'value': 'all'},
|
|
{'label': '仅强势(≥70分)', 'value': 'strong'},
|
|
{'label': '中等以上(≥40分)', 'value': 'medium_plus'},
|
|
{'label': '自定义过滤', 'value': 'custom'}
|
|
],
|
|
value='all',
|
|
className="form-select mb-1"
|
|
)
|
|
], className="col-md-2")
|
|
], className="row mb-2"),
|
|
|
|
# 自动刷新控制行
|
|
html.Div([
|
|
html.Div([
|
|
html.Label("自动刷新:", className="form-label small"),
|
|
dcc.Checklist(
|
|
id='auto-refresh-toggle',
|
|
options=[{'label': '启用', 'value': 'enabled'}],
|
|
value=[],
|
|
className="form-check small"
|
|
)
|
|
], className="col-md-2"),
|
|
|
|
html.Div([
|
|
html.Label("刷新间隔:", className="form-label small"),
|
|
dcc.Dropdown(
|
|
id='refresh-interval-dropdown',
|
|
options=[
|
|
{'label': '5秒', 'value': 5000},
|
|
{'label': '10秒', 'value': 10000},
|
|
{'label': '15秒', 'value': 15000},
|
|
{'label': '30秒', 'value': 30000},
|
|
{'label': '1分钟', 'value': 60000},
|
|
{'label': '5分钟', 'value': 300000}
|
|
],
|
|
value=15000, # 默认15秒
|
|
className="form-select small",
|
|
disabled=True # 初始状态禁用
|
|
)
|
|
], className="col-md-2"),
|
|
|
|
html.Div([
|
|
html.Label("状态:", className="form-label small"),
|
|
html.Div(id='refresh-status', children="未启用", className="text-muted small")
|
|
], className="col-md-4"),
|
|
|
|
html.Div([
|
|
html.Label("下次刷新:", className="form-label small"),
|
|
html.Div(id='next-refresh-time', children="--", className="text-muted small")
|
|
], className="col-md-4")
|
|
], className="row mb-2"),
|
|
|
|
# 按钮组
|
|
html.Div([
|
|
html.Button("获取数据并分析", id="analyze-btn",
|
|
className="btn btn-primary btn-sm me-2"),
|
|
html.Button("刷新数据", id="refresh-btn",
|
|
className="btn btn-secondary btn-sm me-2"),
|
|
html.Button("导出结果", id="export-btn",
|
|
className="btn btn-success btn-sm"),
|
|
], className="text-center mb-2"),
|
|
|
|
# 加载状态
|
|
dcc.Loading(
|
|
id="loading",
|
|
children=[
|
|
# 主图表
|
|
html.Div([
|
|
dcc.Graph(id='main-chart', style={'height': '600px'})
|
|
], className="mb-2"),
|
|
|
|
# 信息面板 - 使用三栏布局
|
|
html.Div([
|
|
# 统计信息
|
|
html.Div([
|
|
html.H5("分析统计", className="mb-1"),
|
|
html.Div(id='statistics-content')
|
|
], className="col-md-4 mb-2"),
|
|
|
|
# 市场结构
|
|
html.Div([
|
|
html.H5("市场结构", className="mb-1"),
|
|
html.Div(id='market-structure-content')
|
|
], className="col-md-4 mb-2"),
|
|
|
|
# 最新信号
|
|
html.Div([
|
|
html.H5("最新信号", className="mb-1"),
|
|
html.Div(id='latest-signals-content')
|
|
], className="col-md-4 mb-2")
|
|
], className="row")
|
|
]
|
|
),
|
|
|
|
# 存储数据和定时器组件
|
|
dcc.Store(id='analysis-data'),
|
|
dcc.Store(id='market-structure-data'),
|
|
dcc.Store(id='auto-refresh-settings'),
|
|
dcc.Store(id='refresh-start-time'), # 存储刷新开始时间
|
|
dcc.Interval(
|
|
id='auto-refresh-interval',
|
|
interval=15000, # 默认15秒
|
|
n_intervals=0,
|
|
disabled=True # 初始状态禁用
|
|
),
|
|
dcc.Interval(
|
|
id='countdown-timer',
|
|
interval=1000, # 每秒更新一次倒计时
|
|
n_intervals=0,
|
|
disabled=False # 始终启用
|
|
)
|
|
], className="container-fluid p-2")
|
|
|
|
# 回调函数
|
|
@app.callback(
|
|
[Output('analysis-data', 'data'),
|
|
Output('market-structure-data', 'data')],
|
|
[Input('analyze-btn', 'n_clicks'),
|
|
Input('refresh-btn', 'n_clicks'),
|
|
Input('auto-refresh-interval', 'n_intervals')],
|
|
[State('symbol-dropdown', 'value'),
|
|
State('timeframe-dropdown', 'value'),
|
|
State('limit-slider', 'value'),
|
|
State('fractal-strength-slider', 'value'),
|
|
State('enhanced-strength-filter', 'value'),
|
|
State('display-level-dropdown', 'value'),
|
|
State('auto-refresh-settings', 'data')]
|
|
)
|
|
def analyze_data_with_auto_refresh(analyze_clicks, refresh_clicks, auto_refresh_intervals,
|
|
symbol, timeframe, limit, fractal_strength,
|
|
enhanced_strength_filter, display_level, auto_refresh_settings):
|
|
"""带自动刷新功能的数据分析"""
|
|
ctx = callback_context
|
|
if not ctx.triggered:
|
|
return None, None
|
|
|
|
trigger_id = ctx.triggered[0]['prop_id'].split('.')[0]
|
|
|
|
# 检查是否是自动刷新触发,且自动刷新已启用
|
|
if trigger_id == 'auto-refresh-interval':
|
|
if not auto_refresh_settings or not auto_refresh_settings.get('enabled'):
|
|
return None, None
|
|
# 只有当auto_refresh_intervals > 0时才进行自动刷新
|
|
if auto_refresh_intervals == 0:
|
|
return None, None
|
|
elif not analyze_clicks and not refresh_clicks:
|
|
return None, None
|
|
|
|
try:
|
|
# 获取数据
|
|
fetcher = DataFetcher()
|
|
df = fetcher.fetch_klines(symbol=symbol, timeframe=timeframe, limit=limit)
|
|
|
|
if df.empty:
|
|
return None, None
|
|
|
|
# 进行缠论分析 - 使用正确的初始化方式
|
|
analyzer = ChanAnalyzer(df) # 传入原始DataFrame
|
|
result = analyzer.run_full_analysis(fractal_strength=fractal_strength)
|
|
|
|
# 重置索引,确保timestamp列存在,避免列名重复
|
|
df_reset = df.reset_index()
|
|
if 'timestamp' in df_reset.columns:
|
|
df_reset = df_reset.drop(columns=['timestamp']) # 删除可能重复的timestamp列
|
|
df_reset.rename(columns={'datetime': 'timestamp'}, inplace=True)
|
|
|
|
# 获取缠论分析的详细结果
|
|
fractals_data = []
|
|
if hasattr(analyzer, 'fractals') and analyzer.fractals:
|
|
for f in analyzer.fractals:
|
|
fractals_data.append({
|
|
'timestamp': f.timestamp.isoformat() if hasattr(f.timestamp, 'isoformat') else str(f.timestamp),
|
|
'price': float(f.price),
|
|
'type': f.fractal_type,
|
|
'strength': f.strength,
|
|
'enhanced_strength': getattr(f, 'enhanced_strength', 0),
|
|
'price_dominance': getattr(f, 'price_dominance', 0),
|
|
'volume_strength': getattr(f, 'volume_strength', 0),
|
|
'trend_position': getattr(f, 'trend_position', 0)
|
|
})
|
|
|
|
strokes_data = []
|
|
if hasattr(analyzer, 'strokes') and analyzer.strokes:
|
|
for s in analyzer.strokes:
|
|
strokes_data.append({
|
|
'start_time': s.start_fractal.timestamp.isoformat() if hasattr(s.start_fractal.timestamp, 'isoformat') else str(s.start_fractal.timestamp),
|
|
'end_time': s.end_fractal.timestamp.isoformat() if hasattr(s.end_fractal.timestamp, 'isoformat') else str(s.end_fractal.timestamp),
|
|
'start_price': float(s.start_fractal.price),
|
|
'end_price': float(s.end_fractal.price),
|
|
'direction': s.direction
|
|
})
|
|
|
|
central_banks_data = []
|
|
if hasattr(analyzer, 'central_banks') and analyzer.central_banks:
|
|
for cb in analyzer.central_banks:
|
|
central_banks_data.append({
|
|
'start_time': cb.start_time.isoformat() if hasattr(cb.start_time, 'isoformat') else str(cb.start_time),
|
|
'end_time': cb.end_time.isoformat() if hasattr(cb.end_time, 'isoformat') else str(cb.end_time),
|
|
'high_price': float(cb.high_price),
|
|
'low_price': float(cb.low_price),
|
|
'center_price': float(cb.center_price)
|
|
})
|
|
|
|
trading_points_data = []
|
|
if hasattr(analyzer, 'trading_points') and analyzer.trading_points:
|
|
for tp in analyzer.trading_points:
|
|
trading_points_data.append({
|
|
'timestamp': tp.timestamp.isoformat() if hasattr(tp.timestamp, 'isoformat') else str(tp.timestamp),
|
|
'price': float(tp.price),
|
|
'signal_type': tp.signal_type,
|
|
'point_class': tp.point_class,
|
|
'description': tp.description
|
|
})
|
|
|
|
# 序列化分析结果为简单的字典格式
|
|
analysis_data = {
|
|
'df': df_reset.to_dict('records'),
|
|
'fractals': fractals_data,
|
|
'strokes': strokes_data,
|
|
'central_banks': central_banks_data,
|
|
'trading_points': trading_points_data,
|
|
'processed_klines_count': result['data_info']['processed_klines'],
|
|
'fractals_count': result['fractal_info']['total'],
|
|
'strokes_count': result['stroke_info']['total'],
|
|
'segments_count': result['segment_info']['total'],
|
|
'central_banks_count': result['central_bank_info']['total'],
|
|
'trading_points_count': result['trading_signal_info']['total'],
|
|
'symbol': symbol,
|
|
'timeframe': timeframe,
|
|
'last_update': datetime.now().strftime("%Y-%m-%d %H:%M:%S"), # 添加更新时间
|
|
'update_type': 'auto' if trigger_id == 'auto-refresh-interval' else 'manual' # 标记更新类型
|
|
}
|
|
|
|
# 市场结构数据
|
|
market_data = {
|
|
'latest_price': float(df['close'].iloc[-1]) if not df.empty else 0,
|
|
'price_change': float(df['close'].iloc[-1] - df['close'].iloc[0]) if len(df) > 1 else 0,
|
|
'volume_avg': float(df['volume'].mean()) if not df.empty else 0,
|
|
'high_24h': float(df['high'].max()) if not df.empty else 0,
|
|
'low_24h': float(df['low'].min()) if not df.empty else 0
|
|
}
|
|
|
|
return analysis_data, market_data
|
|
|
|
except Exception as e:
|
|
print(f"分析数据时出错: {str(e)}")
|
|
return None, None
|
|
|
|
@app.callback(
|
|
Output('main-chart', 'figure'),
|
|
[Input('analysis-data', 'data'),
|
|
Input('enhanced-strength-filter', 'value'),
|
|
Input('display-level-dropdown', 'value')]
|
|
)
|
|
def update_main_chart(analysis_data, enhanced_strength_filter, display_level):
|
|
if not analysis_data or not analysis_data.get('df'):
|
|
return go.Figure()
|
|
|
|
# 从数据创建基本K线图
|
|
df_records = analysis_data['df']
|
|
df = pd.DataFrame(df_records)
|
|
df['timestamp'] = pd.to_datetime(df['timestamp'])
|
|
|
|
fig = go.Figure()
|
|
|
|
# 添加K线图
|
|
fig.add_trace(go.Candlestick(
|
|
x=df['timestamp'],
|
|
open=df['open'],
|
|
high=df['high'],
|
|
low=df['low'],
|
|
close=df['close'],
|
|
name="K线"
|
|
))
|
|
|
|
# 添加分型点(带过滤功能)
|
|
if analysis_data.get('fractals'):
|
|
fractals = analysis_data['fractals']
|
|
|
|
# 根据显示级别和增强强度过滤分型
|
|
filtered_fractals = []
|
|
for f in fractals:
|
|
enhanced_strength = f.get('enhanced_strength', 0)
|
|
|
|
# 应用显示级别过滤
|
|
if display_level == 'strong' and enhanced_strength < 70:
|
|
continue
|
|
elif display_level == 'medium_plus' and enhanced_strength < 40:
|
|
continue
|
|
elif display_level == 'custom' and enhanced_strength < enhanced_strength_filter:
|
|
continue
|
|
|
|
filtered_fractals.append(f)
|
|
|
|
top_fractals = [f for f in filtered_fractals if f['type'] == 'top']
|
|
bottom_fractals = [f for f in filtered_fractals if f['type'] == 'bottom']
|
|
|
|
if top_fractals:
|
|
# 根据增强强度确定大小和颜色
|
|
sizes = [max(8, min(f.get('enhanced_strength', 30) / 5, 20)) for f in top_fractals]
|
|
colors = [f'rgba(255, {max(0, 255 - int(f.get("enhanced_strength", 30) * 2))}, 0, 0.8)' for f in top_fractals]
|
|
|
|
fig.add_trace(go.Scatter(
|
|
x=[pd.to_datetime(f['timestamp']) for f in top_fractals],
|
|
y=[f['price'] for f in top_fractals],
|
|
mode='markers',
|
|
marker=dict(
|
|
symbol='triangle-down',
|
|
size=sizes,
|
|
color=colors,
|
|
line=dict(color='darkred', width=1)
|
|
),
|
|
name=f'顶分型({len(top_fractals)}个)',
|
|
hovertemplate=('顶分型<br>'
|
|
'时间: %{x}<br>'
|
|
'价格: %{y:.2f}<br>'
|
|
'基础强度: %{customdata[0]}<br>'
|
|
'增强强度: %{customdata[1]:.1f}<br>'
|
|
'价格优势: %{customdata[2]:.1f}<br>'
|
|
'成交量强度: %{customdata[3]:.1f}<br>'
|
|
'趋势位置: %{customdata[4]:.1f}<extra></extra>'),
|
|
customdata=[[f['strength'],
|
|
f.get('enhanced_strength', 0),
|
|
f.get('price_dominance', 0),
|
|
f.get('volume_strength', 0),
|
|
f.get('trend_position', 0)] for f in top_fractals]
|
|
))
|
|
|
|
if bottom_fractals:
|
|
# 根据增强强度确定大小和颜色
|
|
sizes = [max(8, min(f.get('enhanced_strength', 30) / 5, 20)) for f in bottom_fractals]
|
|
colors = [f'rgba(0, {max(100, 255 - int(f.get("enhanced_strength", 30) * 1.5))}, 0, 0.8)' for f in bottom_fractals]
|
|
|
|
fig.add_trace(go.Scatter(
|
|
x=[pd.to_datetime(f['timestamp']) for f in bottom_fractals],
|
|
y=[f['price'] for f in bottom_fractals],
|
|
mode='markers',
|
|
marker=dict(
|
|
symbol='triangle-up',
|
|
size=sizes,
|
|
color=colors,
|
|
line=dict(color='darkgreen', width=1)
|
|
),
|
|
name=f'底分型({len(bottom_fractals)}个)',
|
|
hovertemplate=('底分型<br>'
|
|
'时间: %{x}<br>'
|
|
'价格: %{y:.2f}<br>'
|
|
'基础强度: %{customdata[0]}<br>'
|
|
'增强强度: %{customdata[1]:.1f}<br>'
|
|
'价格优势: %{customdata[2]:.1f}<br>'
|
|
'成交量强度: %{customdata[3]:.1f}<br>'
|
|
'趋势位置: %{customdata[4]:.1f}<extra></extra>'),
|
|
customdata=[[f['strength'],
|
|
f.get('enhanced_strength', 0),
|
|
f.get('price_dominance', 0),
|
|
f.get('volume_strength', 0),
|
|
f.get('trend_position', 0)] for f in bottom_fractals]
|
|
))
|
|
|
|
# 添加笔
|
|
if analysis_data.get('strokes'):
|
|
strokes = analysis_data['strokes']
|
|
for i, stroke in enumerate(strokes):
|
|
color = 'blue' if stroke['direction'] == 1 else 'purple'
|
|
fig.add_trace(go.Scatter(
|
|
x=[pd.to_datetime(stroke['start_time']), pd.to_datetime(stroke['end_time'])],
|
|
y=[stroke['start_price'], stroke['end_price']],
|
|
mode='lines',
|
|
line=dict(color=color, width=2),
|
|
name='笔' if i == 0 else None,
|
|
showlegend=(i == 0),
|
|
hovertemplate=f'笔 {"↗" if stroke["direction"] == 1 else "↘"}<br>起点: %{{x[0]}}<br>终点: %{{x[1]}}<br>价格变化: {stroke["end_price"] - stroke["start_price"]:.2f}<extra></extra>'
|
|
))
|
|
|
|
# 添加中枢
|
|
if analysis_data.get('central_banks'):
|
|
central_banks = analysis_data['central_banks']
|
|
for i, cb in enumerate(central_banks):
|
|
# 中枢区域用矩形表示
|
|
fig.add_shape(
|
|
type="rect",
|
|
x0=pd.to_datetime(cb['start_time']),
|
|
x1=pd.to_datetime(cb['end_time']),
|
|
y0=cb['low_price'],
|
|
y1=cb['high_price'],
|
|
fillcolor="yellow",
|
|
opacity=0.3,
|
|
line=dict(color="orange", width=2),
|
|
layer="below"
|
|
)
|
|
|
|
# 中枢中轴线
|
|
fig.add_trace(go.Scatter(
|
|
x=[pd.to_datetime(cb['start_time']), pd.to_datetime(cb['end_time'])],
|
|
y=[cb['center_price'], cb['center_price']],
|
|
mode='lines',
|
|
line=dict(color='orange', width=2, dash='dash'),
|
|
name='中枢' if i == 0 else None,
|
|
showlegend=(i == 0),
|
|
hovertemplate=f'中枢<br>高点: {cb["high_price"]:.2f}<br>低点: {cb["low_price"]:.2f}<br>中轴: {cb["center_price"]:.2f}<extra></extra>'
|
|
))
|
|
|
|
# 添加买卖点
|
|
if analysis_data.get('trading_points'):
|
|
trading_points = analysis_data['trading_points']
|
|
buy_points = [tp for tp in trading_points if tp['signal_type'] == 'buy']
|
|
sell_points = [tp for tp in trading_points if tp['signal_type'] == 'sell']
|
|
|
|
if buy_points:
|
|
colors = {'first': 'lime', 'second': 'lightgreen', 'third': 'lightblue'}
|
|
for point_class in ['first', 'second', 'third']:
|
|
class_points = [tp for tp in buy_points if tp['point_class'] == point_class]
|
|
if class_points:
|
|
fig.add_trace(go.Scatter(
|
|
x=[pd.to_datetime(tp['timestamp']) for tp in class_points],
|
|
y=[tp['price'] for tp in class_points],
|
|
mode='markers',
|
|
marker=dict(
|
|
symbol='arrow-up',
|
|
size=15,
|
|
color=colors.get(point_class, 'lime'),
|
|
line=dict(color='darkgreen', width=2)
|
|
),
|
|
name=f'{point_class[0].upper() + point_class[1:]}类买点',
|
|
hovertemplate='%{fullData.name}<br>时间: %{x}<br>价格: %{y:.2f}<br>描述: %{customdata}<extra></extra>',
|
|
customdata=[tp['description'] for tp in class_points]
|
|
))
|
|
|
|
if sell_points:
|
|
colors = {'first': 'red', 'second': 'lightcoral', 'third': 'pink'}
|
|
for point_class in ['first', 'second', 'third']:
|
|
class_points = [tp for tp in sell_points if tp['point_class'] == point_class]
|
|
if class_points:
|
|
fig.add_trace(go.Scatter(
|
|
x=[pd.to_datetime(tp['timestamp']) for tp in class_points],
|
|
y=[tp['price'] for tp in class_points],
|
|
mode='markers',
|
|
marker=dict(
|
|
symbol='arrow-down',
|
|
size=15,
|
|
color=colors.get(point_class, 'red'),
|
|
line=dict(color='darkred', width=2)
|
|
),
|
|
name=f'{point_class[0].upper() + point_class[1:]}类卖点',
|
|
hovertemplate='%{fullData.name}<br>时间: %{x}<br>价格: %{y:.2f}<br>描述: %{customdata}<extra></extra>',
|
|
customdata=[tp['description'] for tp in class_points]
|
|
))
|
|
|
|
fig.update_layout(
|
|
title=f"{analysis_data['symbol']} {analysis_data['timeframe']} 缠论分析图",
|
|
xaxis_title="时间",
|
|
yaxis_title="价格",
|
|
height=550,
|
|
xaxis_rangeslider_visible=False,
|
|
hovermode='x unified',
|
|
margin=dict(l=50, r=50, t=50, b=50)
|
|
)
|
|
|
|
return fig
|
|
|
|
@app.callback(
|
|
Output('statistics-content', 'children'),
|
|
[Input('analysis-data', 'data'),
|
|
Input('enhanced-strength-filter', 'value'),
|
|
Input('display-level-dropdown', 'value')]
|
|
)
|
|
def update_statistics(analysis_data, enhanced_strength_filter, display_level):
|
|
if not analysis_data:
|
|
return "暂无数据"
|
|
|
|
# 基础统计
|
|
basic_stats = html.Div([
|
|
html.P(f"交易对: {analysis_data.get('symbol', 'N/A')}", className="mb-1 small"),
|
|
html.P(f"时间周期: {analysis_data.get('timeframe', 'N/A')}", className="mb-1 small"),
|
|
html.P(f"K线数量: {analysis_data.get('processed_klines_count', 0)}", className="mb-1 small"),
|
|
html.P(f"笔: {analysis_data.get('strokes_count', 0)} | 线段: {analysis_data.get('segments_count', 0)}", className="mb-1 small"),
|
|
html.P(f"中枢: {analysis_data.get('central_banks_count', 0)} | 买卖点: {analysis_data.get('trading_points_count', 0)}", className="mb-1 small"),
|
|
html.Hr(className="my-1"),
|
|
html.P(f"更新: {analysis_data.get('last_update', 'N/A')}", className="text-info small mb-1"),
|
|
html.P(f"方式: {'自动' if analysis_data.get('update_type') == 'auto' else '手动'}",
|
|
className="text-muted small mb-0")
|
|
])
|
|
|
|
# 分型强度统计
|
|
fractals = analysis_data.get('fractals', [])
|
|
if fractals:
|
|
# 计算强度分布
|
|
strong_fractals = [f for f in fractals if f.get('enhanced_strength', 0) >= 70]
|
|
medium_fractals = [f for f in fractals if 40 <= f.get('enhanced_strength', 0) < 70]
|
|
weak_fractals = [f for f in fractals if f.get('enhanced_strength', 0) < 40]
|
|
|
|
# 根据当前过滤条件计算显示的分型
|
|
filtered_fractals = []
|
|
for f in fractals:
|
|
enhanced_strength = f.get('enhanced_strength', 0)
|
|
if display_level == 'strong' and enhanced_strength < 70:
|
|
continue
|
|
elif display_level == 'medium_plus' and enhanced_strength < 40:
|
|
continue
|
|
elif display_level == 'custom' and enhanced_strength < enhanced_strength_filter:
|
|
continue
|
|
filtered_fractals.append(f)
|
|
|
|
# 平均强度
|
|
avg_enhanced = sum(f.get('enhanced_strength', 0) for f in fractals) / len(fractals) if fractals else 0
|
|
|
|
fractal_stats = html.Div([
|
|
html.Hr(className="my-1"),
|
|
html.P(f"🔥 分型分析", className="fw-bold small mb-1"),
|
|
html.P(f"总数: {len(fractals)} | 强势: {len(strong_fractals)} | 中等: {len(medium_fractals)}", className="mb-1 small"),
|
|
html.P(f"当前显示: {len(filtered_fractals)} 个", className="mb-1 small"),
|
|
html.P(f"平均强度: {avg_enhanced:.1f}分", className="mb-0 small text-muted")
|
|
])
|
|
|
|
return html.Div([basic_stats, fractal_stats])
|
|
else:
|
|
return basic_stats
|
|
|
|
@app.callback(
|
|
Output('market-structure-content', 'children'),
|
|
[Input('market-structure-data', 'data')]
|
|
)
|
|
def update_market_structure(market_data):
|
|
if not market_data:
|
|
return "暂无数据"
|
|
|
|
latest_price = market_data.get('latest_price', 0)
|
|
price_change = market_data.get('price_change', 0)
|
|
change_percent = (price_change / (latest_price - price_change)) * 100 if (latest_price - price_change) != 0 else 0
|
|
|
|
color = "text-success" if price_change >= 0 else "text-danger"
|
|
|
|
return html.Div([
|
|
html.P(f"当前价格: ${latest_price:.2f}", className="mb-1 small"),
|
|
html.P(f"变化: ${price_change:.2f} ({change_percent:+.2f}%)",
|
|
className=f"mb-1 small {color}"),
|
|
html.P(f"24h最高: ${market_data.get('high_24h', 0):.2f}", className="mb-1 small"),
|
|
html.P(f"24h最低: ${market_data.get('low_24h', 0):.2f}", className="mb-1 small"),
|
|
html.P(f"平均量: {market_data.get('volume_avg', 0):.0f}", className="mb-0 small text-muted")
|
|
])
|
|
|
|
@app.callback(
|
|
Output('latest-signals-content', 'children'),
|
|
[Input('analysis-data', 'data')]
|
|
)
|
|
def update_latest_signals(analysis_data):
|
|
if not analysis_data:
|
|
return "暂无数据"
|
|
|
|
trading_points_count = analysis_data.get('trading_points_count', 0)
|
|
fractals_count = analysis_data.get('fractals_count', 0)
|
|
|
|
return html.Div([
|
|
html.P(f"买卖点: {trading_points_count} 个", className="mb-1 small"),
|
|
html.P(f"分型: {fractals_count} 个", className="mb-1 small"),
|
|
html.P("详情见图表标记", className="mb-0 small text-muted")
|
|
])
|
|
|
|
# 自动刷新功能回调函数
|
|
@app.callback(
|
|
[Output('refresh-interval-dropdown', 'disabled'),
|
|
Output('auto-refresh-interval', 'disabled'),
|
|
Output('auto-refresh-interval', 'interval'),
|
|
Output('refresh-status', 'children'),
|
|
Output('auto-refresh-settings', 'data'),
|
|
Output('refresh-start-time', 'data')],
|
|
[Input('auto-refresh-toggle', 'value'),
|
|
Input('refresh-interval-dropdown', 'value')],
|
|
prevent_initial_call=True
|
|
)
|
|
def control_auto_refresh(auto_refresh_enabled, refresh_interval):
|
|
"""控制自动刷新功能"""
|
|
enabled = 'enabled' in (auto_refresh_enabled or [])
|
|
|
|
if enabled:
|
|
status = f"已启用 - 每{refresh_interval/1000:.0f}秒刷新"
|
|
dropdown_disabled = False
|
|
interval_disabled = False
|
|
else:
|
|
status = "未启用"
|
|
dropdown_disabled = True
|
|
interval_disabled = True
|
|
refresh_interval = 15000 # 保持默认值
|
|
|
|
settings = {
|
|
'enabled': enabled,
|
|
'interval': refresh_interval
|
|
}
|
|
|
|
# 记录当前时间作为刷新开始时间
|
|
from datetime import datetime
|
|
start_time = datetime.now().timestamp() if enabled else None
|
|
|
|
return dropdown_disabled, interval_disabled, refresh_interval, status, settings, start_time
|
|
|
|
@app.callback(
|
|
Output('next-refresh-time', 'children'),
|
|
[Input('countdown-timer', 'n_intervals'),
|
|
Input('auto-refresh-settings', 'data'),
|
|
Input('refresh-start-time', 'data'),
|
|
Input('auto-refresh-interval', 'n_intervals')],
|
|
prevent_initial_call=True
|
|
)
|
|
def update_countdown(timer_intervals, settings, start_time, auto_refresh_intervals):
|
|
"""更新倒计时显示"""
|
|
if not settings or not settings.get('enabled') or start_time is None:
|
|
return "--"
|
|
|
|
from datetime import datetime
|
|
import math
|
|
|
|
# 获取刷新间隔(秒)
|
|
interval_seconds = settings.get('interval', 15000) / 1000
|
|
|
|
# 计算自启用或上次刷新以来的时间
|
|
current_time = datetime.now().timestamp()
|
|
|
|
# 计算已经过去的时间
|
|
elapsed_time = current_time - start_time
|
|
|
|
# 考虑自动刷新的次数,重新计算开始时间
|
|
if auto_refresh_intervals > 0:
|
|
# 调整开始时间,考虑已经发生的自动刷新
|
|
adjusted_start_time = start_time + (auto_refresh_intervals * interval_seconds)
|
|
elapsed_time = current_time - adjusted_start_time
|
|
|
|
# 计算剩余时间
|
|
remaining_time = interval_seconds - (elapsed_time % interval_seconds)
|
|
|
|
if remaining_time <= 0:
|
|
remaining_time = interval_seconds
|
|
|
|
remaining_seconds = math.ceil(remaining_time)
|
|
|
|
if remaining_seconds <= 1:
|
|
return "即将刷新..."
|
|
else:
|
|
return f"还有 {remaining_seconds} 秒"
|
|
|
|
# 同时需要在自动刷新触发时重置开始时间
|
|
@app.callback(
|
|
Output('refresh-start-time', 'data', allow_duplicate=True),
|
|
[Input('auto-refresh-interval', 'n_intervals')],
|
|
[State('auto-refresh-settings', 'data')],
|
|
prevent_initial_call=True
|
|
)
|
|
def reset_refresh_start_time(auto_refresh_intervals, settings):
|
|
"""重置刷新开始时间"""
|
|
if auto_refresh_intervals > 0 and settings and settings.get('enabled'):
|
|
from datetime import datetime
|
|
return datetime.now().timestamp()
|
|
return dash.no_update
|
|
|
|
return app
|
|
|
|
|
|
def run_app(debug=True, port=8050):
|
|
"""运行Web应用"""
|
|
app = create_app()
|
|
app.run(debug=debug, port=port, host='0.0.0.0') |