import pandas as pd import numpy as np import plotly.graph_objects as go from datetime import datetime, timezone, timedelta import warnings import os import uuid # ========== 配置 ========== KLINE_XLSX = "kline_data.xlsx" # K线数据文件名 ORDERS_XLSX = "做市策略.xls" # 订单数据文件名 OUTPUT_HTML = "kline_with_trades.html" SYMBOL = "ETH-USDT" # 交易对筛选 # 时间与对齐配置 ORDERS_TIME_IS_LOCAL_ASIA_SH = True # 订单时间是否为东八区时间 SNAP_TRADES_TO_NEAREST_CANDLE = True # 对齐交易点到最近的K线时间 SNAP_TOLERANCE_MULTIPLIER = 1.5 # 对齐容忍度倍数 # 图表尺寸配置 - 更宽更扁 CHART_WIDTH = 2200 # 更宽的图表 CHART_HEIGHT = 600 # 更矮的图表 FONT_SIZE = 12 # 字体大小 ANNOTATION_FONT_SIZE = 10 # 标注字体大小 MARKER_SIZE = 10 # 标记大小 LINE_WIDTH = 1.5 # 连接线宽度 # 颜色配置 - 所有文本使用黑色 TEXT_COLOR = "black" # 所有文本使用黑色 TEXT_OFFSET = 10 # 文本偏移量(像素) # ========== 工具函数 ========== def parse_numeric(x): """高效解析数值类型,支持多种格式""" if pd.isna(x): return np.nan try: # 尝试直接转换(大多数情况) return float(x) except: # 处理特殊格式 s = str(x).replace(",", "").replace("USDT", "").replace("张", "").strip() if s.endswith("%"): s = s[:-1] return float(s) if s else np.nan def epoch_to_dt(x): """将时间戳转换为上海时区时间""" try: return pd.to_datetime(int(x), unit="s", utc=True).tz_convert("Asia/Shanghai") except: return pd.NaT def zh_side(row): """解析交易方向""" direction = str(row.get("方向", "")).strip() if "开多" in direction: return "long_open" if "平多" in direction: return "long_close" if "开空" in direction: return "short_open" if "平空" in direction: return "short_close" return "unknown" # ========== 数据加载与预处理 ========== def load_kline_data(): """加载并预处理K线数据""" if not os.path.exists(KLINE_XLSX): raise FileNotFoundError(f"K线数据文件不存在: {KLINE_XLSX}") kdf = pd.read_excel(KLINE_XLSX, dtype=str) kdf.columns = [str(c).strip().lower() for c in kdf.columns] # 验证必要列 required_cols = {"id", "open", "close", "low", "high"} missing = required_cols - set(kdf.columns) if missing: raise ValueError(f"K线表缺少列: {missing}") # 时间转换 - 确保id是秒级时间戳 kdf["time"] = kdf["id"].apply(epoch_to_dt) # 数值转换(向量化操作提升性能) for col in ["open", "close", "low", "high"]: kdf[col] = pd.to_numeric(kdf[col].apply(parse_numeric), errors="coerce") # 清理无效数据 kdf = kdf.dropna(subset=["time", "open", "close", "low", "high"]) kdf = kdf.sort_values("time").reset_index(drop=True) # 计算K线周期(用于交易点对齐) if len(kdf) >= 3: median_step = kdf["time"].diff().median() else: median_step = pd.Timedelta(minutes=1) return kdf, median_step def load_order_data(): """加载并预处理订单数据""" if not os.path.exists(ORDERS_XLSX): raise FileNotFoundError(f"订单数据文件不存在: {ORDERS_XLSX}") odf = pd.read_excel(ORDERS_XLSX, dtype=str) # 验证必要列 need_order_cols = ["时间", "交易对", "方向", "模式", "数量(张)", "成交价", "交易额", "消耗手续费", "用户盈亏"] missing = set(need_order_cols) - set(odf.columns) if missing: raise ValueError(f"订单表缺少列: {missing}") # 筛选交易对 if SYMBOL and "交易对" in odf.columns: odf = odf[odf["交易对"].astype(str).str.strip() == SYMBOL] # 时间处理 - 确保时间格式正确 with warnings.catch_warnings(): warnings.simplefilter("ignore") if ORDERS_TIME_IS_LOCAL_ASIA_SH: # 尝试多种格式解析时间 odf["时间"] = pd.to_datetime(odf["时间"], errors="coerce", format="mixed") # 本地化为上海时区 odf["时间"] = odf["时间"].dt.tz_localize("Asia/Shanghai", ambiguous="NaT", nonexistent="shift_forward") else: # 如果Excel时间已经是UTC odf["时间"] = pd.to_datetime(odf["时间"], utc=True, errors="coerce").dt.tz_convert("Asia/Shanghai") # 数值转换 numeric_cols = { "数量(张)": "数量", "成交价": "价格", "交易额": "交易额_num", "消耗手续费": "手续费", "用户盈亏": "盈亏" } for src, dest in numeric_cols.items(): odf[dest] = pd.to_numeric(odf[src].apply(parse_numeric), errors="coerce") # 解析交易方向 odf["side"] = odf.apply(zh_side, axis=1) # 为每个订单生成唯一ID odf["order_id"] = [str(uuid.uuid4()) for _ in range(len(odf))] # 计算本金(数量 * 价格) odf["本金"] = odf["数量"] * odf["价格"] # 清理无效数据 odf = odf.dropna(subset=["时间", "价格"]) odf = odf.sort_values("时间").reset_index(drop=True) return odf def align_trades_to_candles(kdf, odf, median_step): """将交易点对齐到最近的K线时间""" if not SNAP_TRADES_TO_NEAREST_CANDLE or kdf.empty or odf.empty: return odf.assign(时间_x=odf["时间"]) snap_tolerance = pd.Timedelta(seconds=max(1, int(median_step.total_seconds() * SNAP_TOLERANCE_MULTIPLIER))) # 使用merge_asof高效对齐 - 使用方向为'backward'确保交易点对齐到前一个K线 anchor = kdf[["time"]].copy().rename(columns={"time": "k_time"}) odf_sorted = odf.sort_values("时间") # 关键优化:使用'backward'方向确保交易点对齐到前一个K线 aligned = pd.merge_asof( odf_sorted, anchor, left_on="时间", right_on="k_time", direction="backward", # 使用'backward'确保交易点对齐到前一个K线 tolerance=snap_tolerance ) # 保留原始时间作为参考 aligned["原始时间"] = aligned["时间"] aligned["时间_x"] = aligned["k_time"].fillna(aligned["时间"]) return aligned # ========== 持仓跟踪与盈亏计算 ========== class PositionTracker: """FIFO持仓跟踪器,支持订单走向可视化""" def __init__(self): self.long_lots = [] # (数量, 价格, 时间, 手续费, 订单ID) self.short_lots = [] # (数量, 价格, 时间, 手续费, 订单ID) self.realized_pnl = 0.0 self.history = [] # 记录所有交易历史 self.trade_connections = [] # 记录开平仓连接关系 def open_long(self, qty, price, time, fee, order_id): """开多仓""" if qty > 1e-9: self.long_lots.append((qty, price, time, fee, order_id)) def close_long(self, qty, price, time, fee, order_id): """平多仓""" remaining = qty local_pnl = 0.0 connections = [] # 本次平仓的连接关系 while remaining > 1e-9 and self.long_lots: lot_qty, lot_price, lot_time, lot_fee, open_order_id = self.long_lots[0] take = min(lot_qty, remaining) pnl = (price - lot_price) * take local_pnl += pnl lot_qty -= take remaining -= take # 记录开平仓连接 connection = { "open_time": lot_time, "close_time": time, "open_price": lot_price, "close_price": price, "qty": take, "pnl": pnl, "type": "long", "open_order_id": open_order_id, "close_order_id": order_id } self.trade_connections.append(connection) connections.append(connection) # 记录平仓详情 self.history.append({ "开仓时间": lot_time, "平仓时间": time, "数量": take, "开仓价": lot_price, "平仓价": price, "盈亏": pnl, "类型": "平多", "开仓订单ID": open_order_id, "平仓订单ID": order_id }) if lot_qty <= 1e-9: self.long_lots.pop(0) else: self.long_lots[0] = (lot_qty, lot_price, lot_time, lot_fee, open_order_id) local_pnl -= fee self.realized_pnl += local_pnl return local_pnl, connections def open_short(self, qty, price, time, fee, order_id): """开空仓""" if qty > 1e-9: self.short_lots.append((qty, price, time, fee, order_id)) def close_short(self, qty, price, time, fee, order_id): """平空仓""" remaining = qty local_pnl = 0.0 connections = [] # 本次平仓的连接关系 while remaining > 1e-9 and self.short_lots: lot_qty, lot_price, lot_time, lot_fee, open_order_id = self.short_lots[0] take = min(lot_qty, remaining) pnl = (lot_price - price) * take local_pnl += pnl lot_qty -= take remaining -= take # 记录开平仓连接 connection = { "open_time": lot_time, "close_time": time, "open_price": lot_price, "close_price": price, "q极": take, "pnl": pnl, "type": "short", "open_order_id": open_order_id, "close_order_id": order_id } self.trade_connections.append(connection) connections.append(connection) # 记录平仓详情 self.history.append({ "开仓时间": lot_time, "平仓时间": time, "数量": take, "开仓价": lot_price, "平仓价": price, "盈亏": pnl, "类型": "平空", "开仓订单ID": open_order_id, "平仓订单ID": order_id }) if lot_qty <= 1e-9: self.short_lots.pop(0) else: self.short_lots[0] = (lot_qty, lot_price, lot_time, lot_fee, open_order_id) local_pnl -= fee self.realized_pnl += local_pnl return local_pnl, connections def calculate_pnl(odf): """计算持仓盈亏和订单连接关系""" tracker = PositionTracker() all_connections = [] for idx, r in odf.iterrows(): qty = r["数量"] price = r["价格"] ts = r["时间"] fee = r["手续费"] side = r["side"] order_id = r["order_id"] if side == "long_open": tracker.open_long(qty, price, ts, fee, order_id) elif side == "long_close": _, connections = tracker.close_long(qty, price, ts, fee, order_id) all_connections.extend(connections) elif side == "short_open": tracker.open_short(qty, price, ts, fee, order_id) elif side == "short_close": _, connections = tracker.close_short(qty, price, ts, fee, order_id) all_connections.extend(connections) # 创建盈亏DataFrame if tracker.history: pnl_df = pd.DataFrame(tracker.history) # 添加对齐后的时间 pnl_df["时间_x"] = pnl_df["平仓时间"].apply( lambda x: odf.loc[odf["时间"] == x, "时间_x"].values[0] if not odf.empty else x ) else: pnl_df = pd.DataFrame() # 创建连接关系DataFrame connections_df = pd.DataFrame(all_connections) if all_connections else pd.DataFrame() return pnl_df, tracker.realized_pnl, connections_df # ========== 可视化 ========== def create_trade_scatter(df, name, color, symbol): """创建交易点散点图""" if df.empty: return None # 为不同类型的交易点创建不同的文本标签 if name == "开多": text = "开多\n" + df["价格"].apply(lambda x: f"{x:.2f}") + "\n" + df["本金"].apply(lambda x: f"{x:.0f}") elif name == "平多": text = "平多\n" + df["价格"].apply(lambda x: f"{x:.2f}") + "\n" + df["盈亏"].apply(lambda x: f"{x:.0f}") elif name == "开空": text = "开空\n" + df["价格"].apply(lambda x: f"{x:.2f}") + "\n" + df["本金"].apply(lambda x: f"{x:.0f}") elif name == "平空": text = "平空\n" + df["价格"].apply(lambda x: f"{x:.2f}") + "\n" + df["盈亏"].apply(lambda x: f"{x:.0f}") else: text = name return go.Scatter( x=df["时间_x"], y=df["价格"], mode="markers+text", name=name, text=text, textposition="middle right", # 文本放在右侧中间位置 textfont=dict(size=ANNOTATION_FONT_SIZE, color=TEXT_COLOR), # 使用黑色文本 marker=dict( size=MARKER_SIZE, color=color, symbol=symbol, line=dict(width=1.5, color="black") ), customdata=np.stack([ df["数量"].to_numpy(), df["价格"].to_numpy(), df["手续费"].to_numpy(), df.get("盈亏", np.nan).to_numpy(), df.get("原始时间", df["时间"]).dt.strftime("%Y-%m-%d %H:%M:%S").to_numpy(), df["order_id"].to_numpy(), df["本金"].to_numpy() ], axis=-1), hovertemplate=( f"{name}
" "数量: %{customdata[0]:.0f}张
" "价格: %{customdata[1]:.2f}
" "手续费: %{customdata[2]:.6f}
" "盈亏: %{customdata[3]:.4f}
" "本金: %{customdata[6]:.0f}
" "时间: %{customdata[4]}
" "订单ID: %{customdata[5]}" ) ) def add_trade_connections(fig, connections_df, odf): """添加开平仓连接线""" if connections_df.empty: return # 为盈利和亏损的连接线分别创建轨迹 profit_lines = [] loss_lines = [] for _, conn in connections_df.iterrows(): # 获取开仓点和平仓点的坐标 open_point = odf[odf["order_id"] == conn["open_order_id"]].iloc[0] close_point = odf[odf["order_id"] == conn["close_order_id"]].iloc[0] line_data = { "x": [open_point["时间_x"], close_point["时间_x"]], "y": [open_point["价格"], close_point["价格"]], "pnl": conn["pnl"], "type": conn["type"], "open_order_id": conn["open_order_id"], "close_order_id": conn["close_order_id"] } if conn["pnl"] >= 0: profit_lines.append(line_data) else: loss_lines.append(line_data) # 添加盈利连接线(绿色) if profit_lines: x_profit = [] y_profit = [] customdata_profit = [] for line in profit_lines: x_profit.extend(line["x"]) y_profit.extend(line["y"]) x_profit.append(None) y_profit.append(None) # 为每个点添加自定义数据 customdata_profit.append([ line["open_order_id"], line["close_order_id"], line["pnl"], line["type"] ]) customdata_profit.append([ line["open_order_id"], line["close_order_id"], line["pnl"], line["type"] ]) customdata_profit.append(None) fig.add_trace(go.Scatter( x=x_profit, y=y_profit, mode="lines", name="盈利订单", line=dict(color="rgba(46, 204, 113, 0.7)", width=LINE_WIDTH), hoverinfo="text", text=[f"盈利: {d[2]:.2f}" if d else None for d in customdata_profit], customdata=customdata_profit, hovertemplate=( "%{text}
" "类型: %{customdata[3]}
" "开仓订单ID: %{customdata[0]}
" "平仓订单ID: %{customdata[1]}" ) )) # 添加亏损连接线(红色) if loss_lines: x_loss = [] y_loss = [] customdata_loss = [] for line in loss_lines: x_loss.extend(line["x"]) y_loss.extend(line["y"]) x_loss.append(None) y_loss.append(None) # 为每个点添加自定义数据 customdata_loss.append([ line["open_order_id"], line["close_order_id"], line["pnl"], line["type"] ]) customdata_loss.append([ line["open_order_id"], line["close_order_id"], line["pnl"], line["type"] ]) customdata_loss.append(None) fig.add_trace(go.Scatter( x=x_loss, y=y_loss, mode="lines", name="亏损订单", line=dict(color="rgba(231, 76, 60, 0.7)", width=LINE_WIDTH), hoverinfo="text", text=[f"亏损: {abs(d[2]):.2f}" if d else None for d in customdata_loss], customdata=customdata_loss, hovertemplate=( "%{text}
" "类型: %{customdata[3]}
" "开仓订单ID: %{customdata[0]}<极>" "平仓订单ID: %{customdata[1]}" ) )) def generate_chart(kdf, odf, pnl_df, cum_realized, connections_df): """生成K线图与交易标注""" fig = go.Figure() # K线主图 fig.add_trace(go.Candlestick( x=kdf["time"], open=kdf["open"], high=kdf["high"], low=kdf["low"], close=kdf["close"], name="K线", increasing_line_color="#2ecc71", decreasing_line_color="#e74c3c" )) # 添加交易点 trade_types = [ (odf[odf["side"] == "long_open"], "开多", "#2ecc71", "triangle-up"), (odf[odf["side"] == "long_close"], "平多", "#27ae60", "circle"), (odf[odf["side"] == "short_open"], "开空", "#e74c3c", "triangle-down"), (odf[odf["side"] == "short_close"], "平空", "#c0392b", "x") ] for data, name, color, symbol in trade_types: trace = create_trade_scatter(data, name, color, symbol) if trace: fig.add_trace(trace) # 添加开平仓连接线 add_trade_connections(fig, connections_df, odf) # 计算时间范围,确保所有点都显示在图表中 all_times = pd.concat([kdf["time"], odf["时间_x"]]) min_time = all_times.min() - pd.Timedelta(minutes=10) max_time = all_times.max() + pd.Timedelta(minutes=10) # 计算价格范围,确保所有点都显示在图表中 min_price = min(kdf["low"].min(), odf["价格"].min()) * 0.99 max_price = max(kdf["high"].max(), odf["价格"].max()) * 1.01 # 布局配置 - 更宽更扁的图表 fig.update_layout( xaxis_title="时间", yaxis_title="价格 (USDT)", legend=dict( orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0, font=dict(size=FONT_SIZE, color=TEXT_COLOR) # 图例文字使用黑色 ), xaxis=dict( rangeslider=dict(visible=False), type="date", gridcolor="rgba(128, 128, 128, 0.2)", range=[min_time, max_time], # 设置时间范围 title_font=dict(color=TEXT_COLOR), # 坐标轴标题使用黑色 tickfont=dict(color=TEXT_COLOR) # 刻度标签使用黑色 ), yaxis=dict( gridcolor="rgba(128, 128, 128, 0.2)", range=[min_price, max_price], # 设置价格范围 title_font=dict(color=TEXT_COLOR), # 坐标轴标题使用黑色 tickfont=dict(color=TEXT_COLOR) # 刻度标签使用黑色 ), hovermode="x unified", hoverlabel=dict( namelength=-1, bgcolor="rgba(255, 255, 255, 0.9)", font_size=FONT_SIZE, font_color=TEXT_COLOR # 悬停标签文字使用黑色 ), margin=dict(l=50, r=50, t=80, b=50), plot_bgcolor="rgba(240, 240, 240, 1)", width=CHART_WIDTH, # 使用配置的宽度 height=CHART_HEIGHT, # 使用配置的高度 font=dict(size=FONT_SIZE, color=TEXT_COLOR), # 全局字体大小和颜色 # 增强交互性配置 dragmode="pan", # 默认拖拽模式为平移 clickmode="event+select", # 点击模式 selectdirection="h", # 水平选择方向 modebar=dict( orientation="h", # 水平方向工具栏 bgcolor="rgba(255, 255, 255, 0.7)", # 半透明背景 color="rgba(0, 0, 0, 0.7)", # 图标颜色 activecolor="rgba(0, 0, 0, 0.9)" # 激活图标颜色 ) ) # 添加模式栏按钮 fig.update_layout( modebar_add=[ "zoom2d", "pan2d", "select2d", "lasso2d", "zoomIn2d", "zoomOut2d", "autoScale2d", "resetScale2d", "toImage" ] ) # 配置缩放行为 - 确保滚轮缩放正常工作 fig.update_xaxes( autorange=False, fixedrange=False, # 允许缩放 constrain="domain", # 约束在域内 rangeslider=dict(visible=False) # 禁用范围滑块 ) fig.update_yaxes( autorange=False, fixedrange=False, # 允许缩放 scaleanchor="x", # 保持纵横比 scaleratio=1, # 缩放比例 constrain="domain" # 约束在域内 ) # 保存并打开结果 - 启用滚轮缩放 fig.write_html( OUTPUT_HTML, include_plotlyjs="cdn", auto_open=True, config={ 'scrollZoom': True, # 启用滚轮缩放 'displayModeBar': True, # 显示工具栏 'displaylogo': False, # 隐藏Plotly标志 'responsive': True # 响应式布局 } ) print(f"图表已生成: {OUTPUT_HTML}") # 返回盈亏详情 if not pnl_df.empty: pnl_df.to_csv("pnl_details.csv", index=False) print(f"盈亏详情已保存: pnl_details.csv") if not connections_df.empty: connections_df.to_csv("trade_connections.csv", index=False) print(f"订单连接关系已保存: trade_connections.csv") return fig # ========== 主执行流程 ========== def main(): print("开始处理数据...") # 加载数据 kdf, median_step = load_kline_data() odf = load_order_data() print(f"加载K线数据: {len(kdf)}条") print(f"加载订单数据: {len(odf)}条") # 对齐交易时间 odf = align_trades_to_candles(kdf, odf, median_step) # 检查时间范围 kline_min_time = kdf["time"].min() kline_max_time = kdf["time"].max() order_min_time = odf["时间"].min() order_max_time = odf["时间"].max() print(f"K线时间范围: {kline_min_time} 至 {kline_max_time}") print(f"订单时间范围: {order_min_time} 至 {order_max_time}") # 检查是否有订单在K线时间范围外 outside_orders = odf[(odf["时间"] < kline_min_time) | (odf["时间"] > kline_max_time)] if not outside_orders.empty: print(f"警告: 有 {len(outside_orders)} 个订单在K线时间范围外") print(outside_orders[["时间", "方向", "价格"]]) # 计算盈亏和订单连接关系 pnl_df, cum_realized, connections_df = calculate_pnl(odf) print(f"累计已实现盈亏: {cum_realized:.2f} USDT") print(f"订单连接关系: {len(connections_df)}条") # 生成图表 generate_chart(kdf, odf, pnl_df, cum_realized, connections_df) print("处理完成") if __name__ == "__main__": with warnings.catch_warnings(): warnings.simplefilter("ignore") main()