"""
智能盯盘 - K线图绘制模块
支持AI决策标注、实时更新
"""
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import pandas as pd
from datetime import datetime, timedelta
from typing import Dict, List, Optional
import logging
class SmartMonitorKline:
"""智能盯盘K线图"""
def __init__(self):
"""初始化K线图"""
self.logger = logging.getLogger(__name__)
def create_kline_with_decisions(
self,
stock_code: str,
stock_name: str,
kline_data: pd.DataFrame,
ai_decisions: List[Dict],
show_volume: bool = True,
show_ma: bool = True,
height: int = 600
) -> go.Figure:
"""
创建带AI决策标注的K线图
Args:
stock_code: 股票代码
stock_name: 股票名称
kline_data: K线数据(DataFrame)
ai_decisions: AI决策列表
show_volume: 是否显示成交量
show_ma: 是否显示均线
height: 图表高度
Returns:
plotly Figure对象
"""
try:
# 确保数据不为空
if kline_data is None or kline_data.empty:
self.logger.warning(f"K线数据为空 {stock_code}")
return self._create_empty_figure(stock_code, stock_name, height)
# 确保必需的列存在
required_cols = ['日期', '开盘', '收盘', '最高', '最低']
if not all(col in kline_data.columns for col in required_cols):
self.logger.error(f"K线数据缺少必需列 {stock_code}")
return self._create_empty_figure(stock_code, stock_name, height)
# 创建子图
if show_volume:
fig = make_subplots(
rows=2, cols=1,
shared_xaxes=True,
vertical_spacing=0.03,
row_heights=[0.7, 0.3],
subplot_titles=(f'{stock_code} {stock_name}', '成交量')
)
else:
fig = make_subplots(
rows=1, cols=1,
subplot_titles=(f'{stock_code} {stock_name}',)
)
# 1. 添加K线图
fig.add_trace(
go.Candlestick(
x=kline_data['日期'],
open=kline_data['开盘'],
high=kline_data['最高'],
low=kline_data['最低'],
close=kline_data['收盘'],
name='K线',
increasing_line_color='#ef5350', # 红色(涨)
decreasing_line_color='#26a69a' # 绿色(跌)
),
row=1, col=1
)
# 2. 添加均线(如果需要)
if show_ma:
self._add_moving_averages(fig, kline_data, row=1, col=1)
# 3. 添加AI决策标注
if ai_decisions:
self._add_ai_decision_markers(fig, kline_data, ai_decisions, row=1, col=1)
# 4. 添加成交量(如果需要)
if show_volume and '成交量' in kline_data.columns:
self._add_volume(fig, kline_data, row=2, col=1)
# 5. 更新布局
fig.update_layout(
height=height,
xaxis_rangeslider_visible=False,
showlegend=True,
hovermode='x unified',
template='plotly_white',
margin=dict(l=50, r=50, t=50, b=50),
)
# 更新x轴
fig.update_xaxes(
title_text="日期",
row=2 if show_volume else 1,
col=1
)
# 更新y轴
fig.update_yaxes(title_text="价格(元)", row=1, col=1)
if show_volume:
fig.update_yaxes(title_text="成交量", row=2, col=1)
return fig
except Exception as e:
self.logger.error(f"创建K线图失败 {stock_code}: {e}")
import traceback
self.logger.debug(traceback.format_exc())
return self._create_empty_figure(stock_code, stock_name, height)
def _add_moving_averages(self, fig, kline_data: pd.DataFrame, row: int, col: int):
"""添加均线"""
try:
# 计算均线
ma_periods = [5, 10, 20, 60]
ma_colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A']
for period, color in zip(ma_periods, ma_colors):
if len(kline_data) >= period:
ma = kline_data['收盘'].rolling(window=period).mean()
fig.add_trace(
go.Scatter(
x=kline_data['日期'],
y=ma,
name=f'MA{period}',
line=dict(color=color, width=1),
opacity=0.7
),
row=row, col=col
)
except Exception as e:
self.logger.warning(f"添加均线失败: {e}")
def _add_ai_decision_markers(
self,
fig,
kline_data: pd.DataFrame,
ai_decisions: List[Dict],
row: int,
col: int
):
"""在K线图上添加AI决策标注"""
try:
# 决策类型映射
action_config = {
'buy': {
'symbol': 'triangle-up',
'color': '#ef5350',
'text': '买入',
'size': 15
},
'sell': {
'symbol': 'triangle-down',
'color': '#26a69a',
'text': '卖出',
'size': 15
},
'add_position': {
'symbol': 'triangle-up',
'color': '#ff9800',
'text': '加仓',
'size': 12
},
'reduce_position': {
'symbol': 'triangle-down',
'color': '#9c27b0',
'text': '减仓',
'size': 12
},
'hold': {
'symbol': 'circle',
'color': '#607d8b',
'text': '持有',
'size': 8
}
}
# 将K线数据日期转换为字符串,便于匹配
kline_data['日期_str'] = pd.to_datetime(kline_data['日期']).dt.strftime('%Y-%m-%d')
# 按决策类型分组
for action_type, config in action_config.items():
decisions_of_type = [d for d in ai_decisions if d.get('action') == action_type]
if not decisions_of_type:
continue
# 提取决策的日期和价格
decision_dates = []
decision_prices = []
decision_texts = []
for decision in decisions_of_type:
decision_date = decision.get('decision_time', '').split()[0] # 只取日期部分
# 在K线数据中查找对应日期的收盘价
matching_rows = kline_data[kline_data['日期_str'] == decision_date]
if not matching_rows.empty:
price = matching_rows.iloc[0]['收盘']
decision_dates.append(decision_date)
decision_prices.append(price)
# 构建hover文本
confidence = decision.get('confidence', 0)
reasoning = decision.get('reasoning', '无')[:50] # 截断过长的推理
hover_text = (
f"{config['text']}
"
f"日期: {decision_date}
"
f"价格: ¥{price:.2f}
"
f"置信度: {confidence}%
"
f"推理: {reasoning}..."
)
decision_texts.append(hover_text)
# 添加标注
if decision_dates:
fig.add_trace(
go.Scatter(
x=decision_dates,
y=decision_prices,
mode='markers+text',
name=config['text'],
marker=dict(
symbol=config['symbol'],
size=config['size'],
color=config['color'],
line=dict(color='white', width=1)
),
text=[config['text']] * len(decision_dates),
textposition='top center',
textfont=dict(size=10, color=config['color']),
hovertext=decision_texts,
hoverinfo='text',
showlegend=True
),
row=row, col=col
)
except Exception as e:
self.logger.error(f"添加AI决策标注失败: {e}")
import traceback
self.logger.debug(traceback.format_exc())
def _add_volume(self, fig, kline_data: pd.DataFrame, row: int, col: int):
"""添加成交量柱状图"""
try:
# 计算颜色(红涨绿跌)
colors = []
for i in range(len(kline_data)):
if i == 0:
colors.append('#ef5350')
else:
if kline_data.iloc[i]['收盘'] >= kline_data.iloc[i-1]['收盘']:
colors.append('#ef5350') # 红色(涨)
else:
colors.append('#26a69a') # 绿色(跌)
fig.add_trace(
go.Bar(
x=kline_data['日期'],
y=kline_data['成交量'],
name='成交量',
marker_color=colors,
showlegend=False
),
row=row, col=col
)
except Exception as e:
self.logger.warning(f"添加成交量失败: {e}")
def _create_empty_figure(self, stock_code: str, stock_name: str, height: int) -> go.Figure:
"""创建空图表"""
fig = go.Figure()
fig.add_annotation(
text=f"暂无 {stock_code} {stock_name} 的K线数据",
xref="paper",
yref="paper",
x=0.5,
y=0.5,
showarrow=False,
font=dict(size=20, color="gray")
)
fig.update_layout(
height=height,
xaxis=dict(visible=False),
yaxis=dict(visible=False),
template='plotly_white'
)
return fig
def get_kline_data(self, stock_code: str, days: int = 60, data_fetcher=None) -> Optional[pd.DataFrame]:
"""
获取K线数据(带Tushare降级机制)
Args:
stock_code: 股票代码
days: 获取天数
data_fetcher: 数据获取器实例
Returns:
K线数据DataFrame
"""
try:
if data_fetcher is None:
from smart_monitor_data import SmartMonitorDataFetcher
data_fetcher = SmartMonitorDataFetcher()
# 计算日期范围
end_date = datetime.now().strftime('%Y%m%d')
start_date = (datetime.now() - timedelta(days=days + 30)).strftime('%Y%m%d') # 多取30天以确保足够数据
# 方法1: 尝试使用AKShare获取(只尝试1次,避免IP封禁)
try:
import akshare as ak
df = ak.stock_zh_a_hist(
symbol=stock_code,
period='daily',
start_date=start_date,
end_date=end_date,
adjust='qfq'
)
if df is not None and not df.empty:
# 只保留最近days天的数据
df = df.tail(days)
self.logger.info(f"✅ AKShare获取K线数据成功 {stock_code},共{len(df)}条")
return df
else:
self.logger.warning(f"AKShare未返回K线数据 {stock_code},尝试降级到Tushare")
except Exception as e:
self.logger.warning(f"AKShare获取K线数据失败 {stock_code}: {type(e).__name__}, 尝试降级到Tushare")
# 方法2: 降级到Tushare
if data_fetcher and data_fetcher.ts_pro:
self.logger.info(f"降级使用Tushare获取K线数据 {stock_code}")
df = self._get_kline_from_tushare(stock_code, days, data_fetcher.ts_pro)
if df is not None and not df.empty:
self.logger.info(f"✅ Tushare获取K线数据成功 {stock_code},共{len(df)}条")
return df
self.logger.error(f"所有数据源都无法获取K线数据 {stock_code}")
return None
except Exception as e:
self.logger.error(f"获取K线数据失败 {stock_code}: {e}")
import traceback
self.logger.debug(traceback.format_exc())
return None
def _get_kline_from_tushare(self, stock_code: str, days: int, ts_pro) -> Optional[pd.DataFrame]:
"""
从Tushare获取K线数据
Args:
stock_code: 股票代码
days: 获取天数
ts_pro: Tushare API实例
Returns:
K线数据DataFrame
"""
try:
# 转换股票代码格式
if stock_code.startswith('6'):
ts_code = f"{stock_code}.SH"
elif stock_code.startswith(('0', '3')):
ts_code = f"{stock_code}.SZ"
else:
ts_code = stock_code
# 计算日期范围(多取一些确保足够)
end_date = datetime.now().strftime('%Y%m%d')
start_date = (datetime.now() - timedelta(days=days + 60)).strftime('%Y%m%d')
# 获取日K线数据(前复权)
df = ts_pro.daily(
ts_code=ts_code,
start_date=start_date,
end_date=end_date,
adj='qfq'
)
if df is None or df.empty:
self.logger.error(f"Tushare未返回K线数据 {stock_code}")
return None
# Tushare数据是从新到旧,需要反转
df = df.sort_values('trade_date', ascending=True).reset_index(drop=True)
# 统一列名为AKShare格式
df = df.rename(columns={
'trade_date': '日期',
'open': '开盘',
'high': '最高',
'low': '最低',
'close': '收盘',
'vol': '成交量',
'amount': '成交额'
})
# 转换日期格式(Tushare: 20240115 -> 2024-01-15)
df['日期'] = pd.to_datetime(df['日期'])
# 只保留最近days天的数据
df = df.tail(days)
return df
except Exception as e:
self.logger.error(f"Tushare获取K线数据失败 {stock_code}: {type(e).__name__}: {str(e)}")
return None
if __name__ == '__main__':
# 测试代码
logging.basicConfig(level=logging.INFO)
kline = SmartMonitorKline()
# 测试获取K线数据
df = kline.get_kline_data('600519', days=60)
if df is not None:
print(f"获取到 {len(df)} 条K线数据")
print(df.head())
# 模拟AI决策
ai_decisions = [
{
'decision_time': '2024-01-15 10:00:00',
'action': 'buy',
'confidence': 85,
'reasoning': '技术指标良好,MACD金叉'
},
{
'decision_time': '2024-01-20 14:30:00',
'action': 'add_position',
'confidence': 75,
'reasoning': '突破关键压力位'
},
{
'decision_time': '2024-01-25 11:00:00',
'action': 'sell',
'confidence': 80,
'reasoning': 'RSI超买,建议止盈'
}
]
# 创建K线图
fig = kline.create_kline_with_decisions(
stock_code='600519',
stock_name='贵州茅台',
kline_data=df,
ai_decisions=ai_decisions,
show_volume=True,
show_ma=True
)
# 保存为HTML
fig.write_html('test_kline.html')
print("K线图已保存到 test_kline.html")
else:
print("获取K线数据失败")