Includes: Bitfinex API integration, technical indicators, LLM signal generation, risk management, Slack notifications. Recent fixes: - SELL orders use position value instead of total balance - SELL signals always close full position - Failed orders added to rejected list for Slack reporting - Position/exposure limits auto-cap to remaining room - BUY order minimum raised to 10% of portfolio Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
157 lines
5.5 KiB
Python
157 lines
5.5 KiB
Python
import logging
|
|
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
|
|
amount = amount_usdt / price
|
|
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, 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
|
|
mode: "paper" or "live"
|
|
|
|
Returns:
|
|
dict with stop order info, or None on failure.
|
|
"""
|
|
mode = mode or ("paper" if config.PAPER_TRADING else "live")
|
|
stop_price = round(entry_price * (1 - config.STOP_LOSS_PCT), 8)
|
|
sell_amount = -abs(amount) # negative = sell
|
|
|
|
if mode == "paper":
|
|
order_id = f"paper_sl_{int(time.time() * 1000)}"
|
|
logger.info(
|
|
"PAPER STOP-LOSS placed: %s sell %.6f @ %.6g (entry %.6g, SL %.1f%%)",
|
|
symbol, abs(amount), stop_price, entry_price, config.STOP_LOSS_PCT * 100,
|
|
)
|
|
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:
|
|
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
|