diff --git a/cost_tracking.json b/cost_tracking.json index eda462a..f5ade98 100644 --- a/cost_tracking.json +++ b/cost_tracking.json @@ -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 } \ No newline at end of file diff --git a/main.py b/main.py index bda239d..e6aa475 100644 --- a/main.py +++ b/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: diff --git a/risk_manager.py b/risk_manager.py index f9d8279..6523db5 100644 --- a/risk_manager.py +++ b/risk_manager.py @@ -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)" diff --git a/slack_notifier.py b/slack_notifier.py index d0c179b..114cc08 100644 --- a/slack_notifier.py +++ b/slack_notifier.py @@ -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 ---