bifitnex-trading/trader.py
kroutony b6bd45b151 Fix SELL P&L calculation, add debug logging, and multiple improvements
- Fix realized_pnl always being 0 on SELL (use amount*price instead of amount_usdt)
- Add debug logging for all-HOLD LLM cycles (log sample HOLD reasons)
- Fix nonce collision in Bitfinex API auth (add counter)
- Fix timezone to UTC+8 in main, trade_logger, sync_cost_basis, check_errors
- Fix stop-loss retry with longer delay after cancel
- Add min order amount check in trader before BUY
- Fix risk_manager to skip positions with amount <= 0
- Cap trade_history to 500 entries to prevent unbounded growth
- Fix greedy regex in LLM response parser
- Reset cost_tracking start dates to null

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 06:23:54 +00:00

183 lines
6.7 KiB
Python

import logging
import math
import time
import config
from data_fetcher import _auth_post
logger = logging.getLogger(__name__)
def execute_trade(signal: dict, current_prices: dict, mode: str | None = None) -> dict | None:
"""Execute a trade signal. Returns trade result dict or None on failure."""
mode = mode or ("paper" if config.PAPER_TRADING else "live")
symbol = signal["symbol"]
action = signal["action"]
amount_usdt = signal.get("amount_usdt", 0)
price = current_prices.get(symbol, 0)
if price <= 0:
logger.error("No price for %s, cannot execute trade", symbol)
return None
# Calculate amount in base currency
if action == "SELL" and signal.get("sell_amount"):
# Truncate to 8 decimals (Bitfinex wallet precision) to avoid "not enough balance"
amount = -math.floor(abs(signal["sell_amount"]) * 1e8) / 1e8
else:
amount = amount_usdt / price
if action == "BUY":
min_amt = config.MIN_ORDER_AMOUNT.get(symbol, 0)
if min_amt > 0 and amount < min_amt:
logger.warning("BUY %s: amount %.6f < min %s, skipping", symbol, amount, min_amt)
return None
if action == "SELL":
amount = -abs(amount) # negative = sell on Bitfinex
trade_result = {
"symbol": symbol,
"action": action,
"amount": abs(amount),
"amount_usdt": amount_usdt,
"price": price,
"timestamp": time.time(),
"mode": mode,
"reason": signal.get("reason", ""),
"confidence": signal.get("confidence", 0),
}
if mode == "paper":
trade_result["status"] = "filled"
trade_result["order_id"] = f"paper_{int(time.time() * 1000)}"
logger.info(
"PAPER %s %s: %.6f @ %.6g (%.2f USDT)",
action, symbol, abs(amount), price, amount_usdt,
)
else:
try:
order = _submit_order(symbol, amount)
trade_result["status"] = "submitted"
trade_result["order_id"] = order.get("id", "unknown")
logger.info(
"LIVE %s %s: %.6f @ %.6g (%.2f USDT) — order %s",
action, symbol, abs(amount), price, amount_usdt, trade_result["order_id"],
)
except Exception as e:
logger.error("Order submission failed for %s: %s", symbol, e)
trade_result["status"] = "failed"
trade_result["error"] = str(e)
return trade_result
def place_stop_loss_order(symbol: str, amount: float, entry_price: float,
stop_price: float | None = None, mode: str | None = None) -> dict | None:
"""Place an EXCHANGE STOP order as stop-loss after a BUY.
Args:
symbol: Trading pair (e.g. tBTCUST)
amount: Position size in base currency (positive, will be negated for sell)
entry_price: The buy entry price
stop_price: Explicit stop price (e.g. from ATR calc). Falls back to fixed % if None.
mode: "paper" or "live"
Returns:
dict with stop order info, or None on failure.
"""
mode = mode or ("paper" if config.PAPER_TRADING else "live")
# Skip if below exchange minimum order size
min_amt = config.MIN_ORDER_AMOUNT.get(symbol, 0)
if min_amt > 0 and abs(amount) < min_amt:
logger.info("Skip stop-loss for %s: amount %.6f < min %s", symbol, abs(amount), min_amt)
return None
if stop_price is None:
stop_price = round(entry_price * (1 - config.STOP_LOSS_PCT), 8)
# Truncate to 8 decimals (Bitfinex wallet precision) to avoid "not enough balance"
amount = math.floor(abs(amount) * 1e8) / 1e8
sell_amount = -amount # negative = sell
if mode == "paper":
order_id = f"paper_sl_{int(time.time() * 1000)}"
sl_pct = (1 - stop_price / entry_price) * 100 if entry_price else 0
logger.info(
"PAPER STOP-LOSS placed: %s sell %.6f @ %.6g (entry %.6g, SL %.1f%%)",
symbol, abs(amount), stop_price, entry_price, sl_pct,
)
return {"order_id": order_id, "stop_price": stop_price, "symbol": symbol, "mode": "paper"}
# Live: submit EXCHANGE STOP order to Bitfinex
try:
body = {
"type": "EXCHANGE STOP",
"symbol": symbol,
"amount": str(sell_amount),
"price": str(stop_price),
}
result = _auth_post("/v2/auth/w/order/submit", body)
order_id = "unknown"
if isinstance(result, list) and len(result) > 4:
order_data = result[4]
if isinstance(order_data, list) and len(order_data) > 0:
order = order_data[0] if isinstance(order_data[0], list) else order_data
order_id = order[0]
logger.info(
"LIVE STOP-LOSS placed: %s sell %.6f @ %.6g — order %s",
symbol, abs(amount), stop_price, order_id,
)
return {"order_id": order_id, "stop_price": stop_price, "symbol": symbol, "mode": "live"}
except Exception as e:
logger.error("Failed to place stop-loss for %s: %s", symbol, e)
return None
def cancel_order(order_id, mode: str | None = None) -> bool:
"""Cancel an existing order by ID."""
mode = mode or ("paper" if config.PAPER_TRADING else "live")
if mode == "paper":
logger.info("PAPER cancel order: %s", order_id)
return True
try:
_auth_post("/v2/auth/w/order/cancel", {"id": int(order_id)})
logger.info("LIVE cancel order: %s", order_id)
return True
except Exception as e:
if "not found" in str(e).lower():
logger.info("Cancel order %s: already gone", order_id)
return True
logger.error("Failed to cancel order %s: %s", order_id, e)
return False
def _submit_order(symbol: str, amount: float) -> dict:
"""Submit an EXCHANGE MARKET order to Bitfinex."""
body = {
"type": "EXCHANGE MARKET",
"symbol": symbol,
"amount": str(amount),
}
result = _auth_post("/v2/auth/w/order/submit", body)
if isinstance(result, list) and len(result) > 4:
order_data = result[4]
if isinstance(order_data, list) and len(order_data) > 0:
order = order_data[0] if isinstance(order_data[0], list) else order_data
return {"id": order[0], "raw": order}
return {"id": "unknown", "raw": result}
def get_wallet_balance() -> float:
"""Get USDT balance from exchange wallet."""
if config.PAPER_TRADING and not config.BFX_API_KEY:
return 0
try:
wallets = _auth_post("/v2/auth/r/wallets")
for w in wallets:
if w[0] == "exchange" and w[1] in ("UST", "USDT"):
return float(w[2])
except Exception as e:
logger.error("Failed to get wallet balance: %s", e)
return 0