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:
parent
349f766635
commit
c473a581b0
@ -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
127
main.py
@ -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:
|
||||
|
||||
@ -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)"
|
||||
|
||||
@ -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 ---
|
||||
|
||||
Loading…
Reference in New Issue
Block a user