#!/usr/bin/env python3 """ 交易通知器 - 带Inline Keyboard按钮的推送 用法: python3 trade_notifier.py notify '{"symbol":"BTC","side":"long","leverage":10,...}' python3 trade_notifier.py callback # 处理按钮点击 """ import os import re import sys import json import time import requests def _load_env(): """Load TELEGRAM_BOT_TOKEN from ~/.hermes/.env""" env_path = os.path.expanduser("~/.hermes/.env") with open(env_path) as f: for line in f: m = re.match(r'TELEGRAM_BOT_TOKEN=(.*)', line.strip()) if m: return m.group(1).strip() return '' BOT_TOKEN = _load_env() PROXY = 'http://127.0.0.1:7890' PENDING_DIR = os.path.expanduser("~/.hermes/trading/pending") CALLBACK_LOG = os.path.expanduser("~/.hermes/trading/callbacks.jsonl") os.makedirs(PENDING_DIR, exist_ok=True) def send_message_with_buttons(chat_id, text, buttons=None): """Send message, optionally with inline keyboard buttons""" url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage" payload = { "chat_id": chat_id, "text": text, "parse_mode": "Markdown", } if buttons: payload["reply_markup"] = json.dumps({"inline_keyboard": buttons}) resp = requests.post(url, data=payload, proxies={"https": PROXY, "http": PROXY}, timeout=15) return resp.json() def edit_message_buttons(chat_id, message_id, text, buttons=None): """Edit message text and optionally update buttons""" url = f"https://api.telegram.org/bot{BOT_TOKEN}/editMessageText" payload = { "chat_id": chat_id, "message_id": message_id, "text": text, "parse_mode": "Markdown", } if buttons: payload["reply_markup"] = json.dumps({"inline_keyboard": buttons}) resp = requests.post(url, data=payload, proxies={"https": PROXY, "http": PROXY}, timeout=15) return resp.json() def answer_callback(callback_query_id, text=""): """Answer callback query to remove loading state""" url = f"https://api.telegram.org/bot{BOT_TOKEN}/answerCallbackQuery" payload = {"callback_query_id": callback_query_id} if text: payload["text"] = text resp = requests.post(url, data=payload, proxies={"https": PROXY, "http": PROXY}, timeout=10) return resp.json() def format_recommendation(rec): """Format recommendation for display""" symbol = rec.get("symbol", "?") side_cn = rec.get("side_cn", "做多" if rec.get("side") in ("long", "buy") else "做空") leverage = rec.get("leverage", 10) contracts = rec.get("contracts", 0) entry = rec.get("price", 0) tp = rec.get("tp_price", 0) sl = rec.get("sl_price", 0) margin = rec.get("margin", 0) margin_pct = rec.get("margin_pct", 0) tp_pct = rec.get("tp_pct", 0) # 标的价格变动% sl_pct = rec.get("sl_pct", 0) tp_pnl = rec.get("tp_pnl", 0) sl_pnl = rec.get("sl_pnl", 0) rr = rec.get("rr", 0) liq_price = rec.get("liq_price", 0) liq_pct = rec.get("liq_pct", 0) balance = rec.get("acct_free", 0) # 保证金收益率 tp_margin_pct = (tp_pnl / margin * 100) if margin > 0 else 0 sl_margin_pct = (sl_pnl / margin * 100) if margin > 0 else 0 lines = [ f"📊 *{symbol} {side_cn}* — 仓位推荐", "", f"💰 可用余额: {balance:.2f} USDT", f"📈 当前价: *{entry}*", "", "*开仓方案:*", f"• 方向: {side_cn}", f"• 杠杆: *{leverage}x*", f"• 张数: *{contracts}张*", f"• 保证金: {margin:.2f} USDT ({margin_pct:.0f}%)", "", "*止盈止损:*", f"• 🎯 止盈: *{tp}* (保证金+{tp_margin_pct:.0f}%) → +{tp_pnl:.2f} USDT", f"• 🛑 止损: *{sl}* (保证金-{sl_margin_pct:.0f}%) → -{sl_pnl:.2f} USDT", f"• 📐 盈亏比: *{rr}:1*", ] if liq_price: lines.append(f"• ⚠️ 清算价: {liq_price} (距离 {liq_pct}%)") return "\n".join(lines) def notify(chat_id, rec_json): """Send trade recommendation (text only, no buttons to avoid polling conflict)""" rec = json.loads(rec_json) if isinstance(rec_json, str) else rec_json symbol = rec.get("symbol", "").split("/")[0].replace("USDT", "") side = rec.get("side", "long") # Save pending pending_path = os.path.join(PENDING_DIR, f"{symbol}.json") with open(pending_path, "w") as f: json.dump({"symbol": symbol, "side": side, "rec": rec, "timestamp": time.time()}, f) text = format_recommendation(rec) # No buttons - use text Y/N reply instead (avoids getUpdates conflict with gateway) result = send_message_with_buttons(chat_id, text, None) return result def handle_callback(callback_data, chat_id, message_id, callback_query_id): """Handle button click""" action, symbol = callback_data.split(":", 1) # Log callback with open(CALLBACK_LOG, "a") as f: f.write(json.dumps({"action": action, "symbol": symbol, "time": time.time(), "chat_id": chat_id}) + "\n") if action == "trade_confirm": # Load pending pending_path = os.path.join(PENDING_DIR, f"{symbol}.json") if not os.path.exists(pending_path): answer_callback(callback_query_id, "❌ 未找到待确认交易") return {"error": "no pending"} with open(pending_path) as f: pending = json.load(f) rec = pending["rec"] # Execute trade import subprocess script_dir = os.path.dirname(os.path.abspath(__file__)) advisor = os.path.join(script_dir, "okx_position_advisor.py") rec_str = json.dumps(rec, ensure_ascii=False) cmd = ["bash", "-c", f"source ~/.bashrc && python3 {advisor} --symbol {symbol} --side {rec.get('side','long')} --execute --json --rec-json '{rec_str}'"] result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) # Remove pending os.remove(pending_path) if result.returncode != 0: answer_callback(callback_query_id, "❌ 下单失败") edit_message_buttons(chat_id, message_id, f"❌ *{symbol} 下单失败*\n\n{result.stderr[:200]}") return {"error": result.stderr} try: exec_result = json.loads(result.stdout) except: exec_result = {"raw": result.stdout} # Format result side_cn = "做多" if rec.get("side") in ("long", "buy") else "做空" result_text = f"✅ *{symbol} {side_cn} 开仓成功*\n\n" for step in exec_result.get("steps", []): if step.get("status") == "ok": if step["step"] == "leverage": result_text += "✅ 杠杆设置成功\n" elif step["step"] == "order": result_text += f"✅ 下单成功 (ID: {step.get('order_id', '?')})\n" elif step["step"] == "tp_sl": result_text += f"✅ 止盈止损设置成功\n" pos = exec_result.get("position") if pos: pnl_emoji = "🟢" if pos.get("pnl", 0) >= 0 else "🔴" result_text += f"\n📊 *持仓确认:*\n" result_text += f"• 数量: {pos.get('contracts', '?')}张\n" result_text += f"• 入场价: *{pos.get('entry', '?')}*\n" result_text += f"• {pnl_emoji} 浮盈: {pos.get('pnl', 0):.2f} USDT\n" algo = exec_result.get("algo") if algo: result_text += f"\n🎯 止盈: *{algo.get('tp', '?')}*\n" result_text += f"🛑 止损: *{algo.get('sl', '?')}*\n" answer_callback(callback_query_id, "✅ 已下单") edit_message_buttons(chat_id, message_id, result_text) return exec_result elif action == "trade_cancel": # Remove pending pending_path = os.path.join(PENDING_DIR, f"{symbol}.json") if os.path.exists(pending_path): os.remove(pending_path) answer_callback(callback_query_id, "❌ 已取消") edit_message_buttons(chat_id, message_id, f"❌ *{symbol} 交易已取消*") return {"cancelled": True} def main(): if len(sys.argv) < 2: print("用法: trade_notifier.py notify|callback [args]") sys.exit(1) action = sys.argv[1] if action == "notify": if len(sys.argv) < 4: print("用法: trade_notifier.py notify ") sys.exit(1) chat_id = sys.argv[2] rec_json = sys.argv[3] result = notify(chat_id, rec_json) print(json.dumps(result, ensure_ascii=False)) elif action == "callback": if len(sys.argv) < 6: print("用法: trade_notifier.py callback ") sys.exit(1) callback_data = sys.argv[2] chat_id = sys.argv[3] message_id = sys.argv[4] callback_query_id = sys.argv[5] result = handle_callback(callback_data, chat_id, message_id, callback_query_id) print(json.dumps(result, ensure_ascii=False, default=str)) else: print(f"Unknown action: {action}") sys.exit(1) if __name__ == "__main__": main()