Add realized P&L reporting, stop-loss fill detection, and balance margin

- Show realized P&L (USDT + %) in Slack for all SELL trades (TP, LLM, stop-loss)
- Detect filled stop-loss orders via stop_orders.json persistence and report as [止損觸發]
- Deduct 1 USDT margin from available balance to prevent exchange insufficient balance errors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
kroutony 2026-03-14 02:44:46 +00:00
parent 349f766635
commit c473a581b0
4 changed files with 139 additions and 12 deletions

View File

@ -5,11 +5,11 @@
"tLTCUST": null,
"tADAUST": 1773380454544,
"tAVAX:UST": 1773380455009,
"tUNIUST": null,
"tUNIUST": 1773380455473,
"tXRPUST": null,
"tDOGE:UST": 1773407762194,
"tSHIB:UST": null,
"tBTCUST": null,
"tXLMUST": null,
"tLINK:UST": 1773361253747
"tLINK:UST": null
}

127
main.py
View File

@ -32,6 +32,7 @@ logging.basicConfig(
logger = logging.getLogger("main")
STATE_FILE = "bot_state.json"
STOP_ORDERS_FILE = "stop_orders.json"
def _load_state() -> dict:
@ -49,6 +50,22 @@ def _save_state(state: dict):
json.dump(state, f, indent=2)
def _load_tracked_stops() -> dict:
"""Load tracked stop orders from file. Returns {symbol: {order_id, stop_price, entry_price, amount}}."""
if os.path.exists(STOP_ORDERS_FILE):
try:
with open(STOP_ORDERS_FILE) as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
pass
return {}
def _save_tracked_stops(stops: dict):
with open(STOP_ORDERS_FILE, "w") as f:
json.dump(stops, f, indent=2)
def run_cycle():
"""Execute one full trading cycle."""
state = _load_state()
@ -120,12 +137,18 @@ def run_cycle():
stop_amt_ok = abs(abs(stop["amount"]) - amount) / amount < 0.01 # 1% tolerance
stop_px_ok = abs(stop["price"] - expected_stop) / expected_stop < 0.01
if stop_amt_ok and stop_px_ok:
# Ensure this stop is tracked for fill detection
tracked_stops[sym] = {
"order_id": stop["id"], "stop_price": stop["price"],
"entry_price": entry, "amount": amount,
}
continue # Stop order is correct
# Wrong amount or price — cancel all existing stops and re-place
for s in existing_stops:
trader.cancel_order(s["id"])
logger.info("Cancelled outdated stop %s for %s (amt=%.6f, px=%.6g)",
s["id"], sym, abs(s["amount"]), s["price"])
tracked_stops.pop(sym, None)
logger.warning("Position %s missing/outdated stop-loss, placing now", sym)
sl = trader.place_stop_loss_order(sym, amount, entry)
@ -134,8 +157,14 @@ def run_cycle():
stop_orders_by_sym[sym] = [{"id": sl["order_id"], "symbol": sym,
"amount": -amount, "price": sl["stop_price"],
"type": "EXCHANGE STOP"}]
# Track for fill detection
tracked_stops[sym] = {
"order_id": sl["order_id"], "stop_price": sl["stop_price"],
"entry_price": entry, "amount": amount,
}
pf.save_positions(port)
logger.info("Stop-loss for %s: %.6f @ %.6g", sym, amount, sl["stop_price"])
_save_tracked_stops(tracked_stops)
# 3. Fetch market data
try:
@ -164,9 +193,62 @@ def run_cycle():
except Exception as e:
logger.warning("Cache write failed: %s", e)
# --- Detect filled stop-loss orders ---
sl_filled = []
tracked_stops = _load_tracked_stops()
active_order_ids = {str(o["id"]) for o in active_orders}
for sym, sinfo in list(tracked_stops.items()):
if str(sinfo.get("order_id")) in active_order_ids:
continue # still active, not filled
# Stop order is gone — check if wallet balance is also gone (= filled, not cancelled by us)
currency = sym[1:].replace(":UST", "").replace("UST", "")
wallet_amt = wallet_balances.get(currency, 0)
pos = port.get("positions", {}).get(sym, {})
pos_amt = pos.get("amount", 0)
# If wallet is empty or nearly empty but we had a position → stop was filled
min_amt = config.MIN_ORDER_AMOUNT.get(sym, 0)
if pos_amt > 0 and wallet_amt < max(min_amt, pos_amt * 0.05):
stop_price = sinfo.get("stop_price", 0)
entry_price = sinfo.get("entry_price", 0) or pos.get("entry_price", 0)
amount = sinfo.get("amount", 0) or pos_amt
amount_usdt = amount * stop_price if stop_price else 0
realized_pnl = None
realized_pnl_pct = None
if entry_price and stop_price:
realized_pnl = (stop_price - entry_price) * amount
realized_pnl_pct = (stop_price - entry_price) / entry_price * 100
sl_filled.append({
"symbol": sym,
"action": "SELL",
"amount": amount,
"price": stop_price,
"amount_usdt": amount_usdt,
"reason": "止損觸發",
"confidence": 1.0,
"mode": "live",
"status": "filled",
"is_stop_loss": True,
"entry_price": entry_price,
"realized_pnl": realized_pnl,
"realized_pnl_pct": realized_pnl_pct,
"stop_price": None,
})
# Update portfolio — position is closed
port = pf.update_position(port, sym, "SELL", amount, stop_price, amount_usdt)
pf.save_positions(port)
trade_logger.log_trade(sl_filled[-1])
logger.warning("Stop-loss FILLED for %s: %.6f @ %.6g, P&L: %.2f USDT (%.2f%%)",
sym, amount, stop_price, realized_pnl or 0, realized_pnl_pct or 0)
# Remove from tracked stops (filled or cancelled)
del tracked_stops[sym]
_save_tracked_stops(tracked_stops)
# --- Collect cycle results ---
tp_closed = [] # take-profit closures
executed = [] # successfully executed trades
executed = sl_filled # start with filled stop-losses
rejected = [] # signals rejected by risk manager
# 6. Check take-profit on existing positions (stop-loss is handled by exchange stop orders)
@ -183,6 +265,8 @@ def run_cycle():
for s in stop_orders_by_sym.get(sym, []):
trader.cancel_order(s["id"])
logger.info("Cancelled stop %s for %s before TP sell", s["id"], sym)
tracked_stops.pop(sym, None)
_save_tracked_stops(tracked_stops)
# Use wallet balance as authoritative sell amount
currency = sym[1:].replace(":UST", "").replace("UST", "")
@ -191,13 +275,28 @@ def run_cycle():
amount = wallet_amt
signal["sell_amount"] = amount
signal["amount_usdt"] = amount * price
# Capture cost basis before selling
entry_price = pos.get("entry_price", 0)
cost_basis = entry_price * amount if entry_price else 0
result = trader.execute_trade(signal, current_prices)
if result and result.get("status") in ("filled", "submitted"):
sell_proceeds = amount * price
realized_pnl = sell_proceeds - cost_basis if cost_basis else None
realized_pnl_pct = ((price - entry_price) / entry_price * 100) if entry_price else None
port = pf.update_position(port, sym, "SELL", amount, price, signal["amount_usdt"])
pf.save_positions(port)
trade_logger.log_trade(result)
tp_closed.append({**signal, "amount_usdt": amount * price})
logger.info("Take profit executed: %s", sym)
tp_closed.append({
**signal,
"amount_usdt": sell_proceeds,
"entry_price": entry_price,
"realized_pnl": realized_pnl,
"realized_pnl_pct": realized_pnl_pct,
})
logger.info("Take profit executed: %s, P&L: %.2f USDT (%.2f%%)",
sym, realized_pnl or 0, realized_pnl_pct or 0)
# 7. Build indicator summary for LLM
indicator_summary = indicators.summarize_all(indicators_by_symbol)
@ -234,6 +333,8 @@ def run_cycle():
for s in stop_orders_by_sym.get(sym, []):
trader.cancel_order(s["id"])
logger.info("Cancelled stop %s for %s before sell", s["id"], sym)
tracked_stops.pop(sym, None)
_save_tracked_stops(tracked_stops)
# Use wallet balance as authoritative sell amount
currency = sym[1:].replace(":UST", "").replace("UST", "")
wallet_amt = wallet_balances.get(currency, 0)
@ -251,6 +352,17 @@ def run_cycle():
price = result.get("price", 0)
amount_usdt = result.get("amount_usdt", 0)
# Calculate realized P&L for SELL trades
realized_pnl = None
realized_pnl_pct = None
if action == "SELL":
pos = port.get("positions", {}).get(sym, {})
entry_price = pos.get("entry_price", 0)
if entry_price:
cost_basis = entry_price * amount
realized_pnl = amount_usdt - cost_basis
realized_pnl_pct = (price - entry_price) / entry_price * 100
port = pf.update_position(port, sym, action, amount, price, amount_usdt)
stop_price = None
@ -271,11 +383,18 @@ def run_cycle():
stop_orders_by_sym[sym] = [{"id": sl["order_id"], "symbol": sym,
"amount": -total_amount, "price": stop_price,
"type": "EXCHANGE STOP"}]
# Track for fill detection
tracked_stops[sym] = {
"order_id": sl["order_id"], "stop_price": stop_price,
"entry_price": entry_price, "amount": total_amount,
}
_save_tracked_stops(tracked_stops)
logger.info("Stop-loss set for %s: %.6f @ %.6g", sym, total_amount, stop_price)
pf.save_positions(port)
trade_logger.log_trade(result)
executed.append({**result, "stop_price": stop_price})
executed.append({**result, "stop_price": stop_price,
"realized_pnl": realized_pnl, "realized_pnl_pct": realized_pnl_pct})
# 10b. Post-trade wallet refresh — ensure next trade uses latest balances
if executed or tp_closed:

View File

@ -133,8 +133,8 @@ def validate_trade(signal: dict, portfolio: dict, indicators_df=None) -> tuple[d
if atr and price:
amount_usdt = adjust_size_by_volatility(amount_usdt, atr, price)
# Available balance check — cap to available instead of rejecting
available = portfolio.get("available_usdt", 0)
# Available balance check — cap to available (minus small margin for fees/rounding)
available = portfolio.get("available_usdt", 0) - 1
if amount_usdt > available:
if available < config.MIN_ORDER_USDT:
return None, f"可用餘額不足 ({available:.2f} < {config.MIN_ORDER_USDT:.2f} USDT)"

View File

@ -69,7 +69,11 @@ def send_cycle_report(
name = config.SYMBOL_NAMES.get(tp["symbol"], tp["symbol"])
pnl = tp.get("reason", "")
amount_usdt = tp.get("amount_usdt", 0)
lines.append(f" 🔴 SELL {name}{pnl},平倉 {amount_usdt:.1f} USDT")
pnl_str = ""
if tp.get("realized_pnl") is not None:
sign = "+" if tp["realized_pnl"] >= 0 else ""
pnl_str = f",收益 {sign}{tp['realized_pnl']:.2f} USDT ({sign}{tp['realized_pnl_pct']:.2f}%)"
lines.append(f" 🔴 SELL {name}{pnl},平倉 {amount_usdt:.1f} USDT{pnl_str}")
lines.append("")
# --- LLM signals ---
@ -108,10 +112,14 @@ def send_cycle_report(
price = t.get("price", 0)
amount_usdt = t.get("amount_usdt", 0)
detail = f"{amount:.4f} @ {price:.6g} ({amount_usdt:.1f} USDT)"
stop_info = ""
extra = ""
if action == "BUY" and t.get("stop_price"):
stop_info = f" — 止損掛 {t['stop_price']:.6g}"
lines.append(f" {emoji} {name}{detail}{stop_info}")
extra = f" — 止損掛 {t['stop_price']:.6g}"
elif action == "SELL" and t.get("realized_pnl") is not None:
sign = "+" if t["realized_pnl"] >= 0 else ""
extra = f" — 收益 {sign}{t['realized_pnl']:.2f} USDT ({sign}{t['realized_pnl_pct']:.2f}%)"
tag = " [止損觸發]" if t.get("is_stop_loss") else ""
lines.append(f" {emoji} {name}{detail}{extra}{tag}")
lines.append("")
# --- Rejected signals ---