- Replace 3-tier LLM SELL thresholds with 5-tier descending confidence (1-2%→0.7, 2-3%→0.6, 3-5%→0.5, ≥5%→0.4), min profit 1% - Allow LLM SELL on losing positions (trust LLM signal for cut-loss) - Fix +-1.90% sign bug: use pnl_pct instead of pnl amount for sign Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
160 lines
5.6 KiB
Python
160 lines
5.6 KiB
Python
import logging
|
|
import time
|
|
|
|
import requests
|
|
|
|
import config
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _send(payload: dict):
|
|
"""Send a message to Slack via webhook."""
|
|
url = config.SLACK_WEBHOOK_URL
|
|
if not url:
|
|
logger.debug("Slack webhook not configured, skipping notification")
|
|
return
|
|
try:
|
|
resp = requests.post(url, json=payload, timeout=10)
|
|
if resp.status_code != 200:
|
|
logger.warning("Slack webhook returned %d: %s", resp.status_code, resp.text)
|
|
except Exception as e:
|
|
logger.error("Failed to send Slack notification: %s", e)
|
|
|
|
|
|
def send_trade_alert(trade: dict):
|
|
"""Send a trade execution notification."""
|
|
sym = trade.get("symbol", "?")
|
|
name = config.SYMBOL_NAMES.get(sym, sym)
|
|
action = trade.get("action", "?")
|
|
emoji = "🟢 BUY" if action == "BUY" else "🔴 SELL"
|
|
mode = trade.get("mode", "paper").upper()
|
|
|
|
text = (
|
|
f"{emoji} *{name}* [{mode}]\n"
|
|
f"Amount: {trade.get('amount', 0):.6f} (~{trade.get('amount_usdt', 0):.2f} USDT)\n"
|
|
f"Price: {trade.get('price', 0):.6g}\n"
|
|
f"Confidence: {trade.get('confidence', 0):.0%}\n"
|
|
f"Reason: {trade.get('reason', 'N/A')}\n"
|
|
f"Status: {trade.get('status', 'unknown')}"
|
|
)
|
|
_send({"text": text})
|
|
|
|
|
|
def send_status_update(summary: str):
|
|
"""Send periodic portfolio status update."""
|
|
_send({"text": summary})
|
|
|
|
|
|
def send_error_alert(error_msg: str):
|
|
"""Send an error alert."""
|
|
_send({"text": f"⚠️ *Trading Bot Error*\n```{error_msg}```"})
|
|
|
|
|
|
def send_cycle_report(
|
|
cycle_number: int,
|
|
signals: list[dict],
|
|
executed: list[dict],
|
|
rejected: list[dict],
|
|
tp_closed: list[dict],
|
|
portfolio_summary: str,
|
|
):
|
|
"""Send a unified cycle report combining analysis, execution, and portfolio status."""
|
|
lines = [f"📊 *Trading Cycle Report #{cycle_number}*\n"]
|
|
|
|
# --- Take-profit / auto actions ---
|
|
if tp_closed:
|
|
lines.append("⚡ *自動操作:*")
|
|
for tp in tp_closed:
|
|
name = config.SYMBOL_NAMES.get(tp["symbol"], tp["symbol"])
|
|
pnl = tp.get("reason", "")
|
|
amount_usdt = tp.get("amount_usdt", 0)
|
|
pnl_str = ""
|
|
if tp.get("realized_pnl") is not None:
|
|
pnl_val = tp["realized_pnl"]
|
|
pnl_pct_val = tp["realized_pnl_pct"]
|
|
sign = "+" if pnl_pct_val > 0 else ""
|
|
pnl_str = f",收益 {sign}{pnl_val:.2f} USDT ({sign}{pnl_pct_val:.2f}%)"
|
|
lines.append(f" 🔴 SELL {name} — {pnl},平倉 {amount_usdt:.1f} USDT{pnl_str}")
|
|
lines.append("")
|
|
|
|
# --- LLM signals ---
|
|
buys = [s for s in signals if s.get("action") == "BUY"]
|
|
sells = [s for s in signals if s.get("action") == "SELL"]
|
|
holds = [s for s in signals if s.get("action") == "HOLD"]
|
|
|
|
lines.append("🤖 *LLM 分析結果:*")
|
|
if buys:
|
|
for s in buys:
|
|
name = config.SYMBOL_NAMES.get(s["symbol"], s["symbol"])
|
|
lines.append(
|
|
f" 🟢 BUY {name} — conf {s.get('confidence', 0):.0%}, {s.get('reason', '')}"
|
|
)
|
|
if sells:
|
|
for s in sells:
|
|
name = config.SYMBOL_NAMES.get(s["symbol"], s["symbol"])
|
|
lines.append(
|
|
f" 🔴 SELL {name} — conf {s.get('confidence', 0):.0%}, {s.get('reason', '')}"
|
|
)
|
|
if holds:
|
|
lines.append(f" ⏸️ 其餘 {len(holds)} 幣種 HOLD")
|
|
if not buys and not sells:
|
|
lines.append(" All symbols → HOLD, no action this cycle.")
|
|
lines.append("")
|
|
|
|
# --- Executed trades ---
|
|
has_trades = bool(executed)
|
|
if executed:
|
|
lines.append("✅ *實際執行:*")
|
|
for t in executed:
|
|
name = config.SYMBOL_NAMES.get(t["symbol"], t["symbol"])
|
|
action = t.get("action", "?")
|
|
emoji = "🟢 BUY" if action == "BUY" else "🔴 SELL"
|
|
amount = t.get("amount", 0)
|
|
price = t.get("price", 0)
|
|
amount_usdt = t.get("amount_usdt", 0)
|
|
detail = f"{amount:.4f} @ {price:.6g} ({amount_usdt:.1f} USDT)"
|
|
extra = ""
|
|
if action == "BUY" and t.get("stop_price"):
|
|
extra = f" — 止損掛 {t['stop_price']:.6g}"
|
|
elif action == "SELL" and t.get("realized_pnl") is not None:
|
|
pnl = t["realized_pnl"]
|
|
pnl_pct = t["realized_pnl_pct"]
|
|
sign = "+" if pnl_pct > 0 else ""
|
|
extra = f" — 收益 {sign}{pnl:.2f} USDT ({sign}{pnl_pct:.2f}%)"
|
|
tag = " [止損觸發]" if t.get("is_stop_loss") else ""
|
|
lines.append(f" {emoji} {name} — {detail}{extra}{tag}")
|
|
lines.append("")
|
|
|
|
# --- Rejected signals ---
|
|
if rejected:
|
|
lines.append("❌ *未執行:*")
|
|
for r in rejected:
|
|
name = config.SYMBOL_NAMES.get(r["symbol"], r["symbol"])
|
|
action = r.get("action", "?")
|
|
emoji = "🟢 BUY" if action == "BUY" else "🔴 SELL"
|
|
lines.append(f" {emoji} {name} — {r.get('reject_reason', '未知原因')}")
|
|
lines.append("")
|
|
|
|
# --- Portfolio summary ---
|
|
lines.append(f"💰 {portfolio_summary}")
|
|
|
|
text = "\n".join(lines)
|
|
# @channel when actual trades were executed
|
|
if has_trades:
|
|
text = f"<!channel>\n{text}"
|
|
|
|
_send({"text": text})
|
|
|
|
|
|
def send_startup_message():
|
|
"""Notify that the bot has started."""
|
|
mode = "PAPER" if config.PAPER_TRADING else "LIVE"
|
|
_send({
|
|
"text": (
|
|
f"🚀 *Trading Bot Started* [{mode} mode]\n"
|
|
f"Monitoring {len(config.TOP_15_SYMBOLS)} symbols\n"
|
|
f"Interval: {config.RUN_INTERVAL_MINUTES}min"
|
|
)
|
|
})
|