- New sync_cost_basis.py: recalculate entry_price from order history, sync amounts/balances from wallet (cron every 30 min) - Fix stop-loss ID staleness: update stop_orders_by_sym in step 2b after placing new stops, preventing SELL failures from stale IDs - Fix position limit inconsistency: use same total_balance in validate_trade instead of calling check_position_limit - Skip stop-loss for positions below MIN_ORDER_AMOUNT - Add API response body logging for 500 errors - Cancel "Order not found" treated as success (not error) - Post-trade wallet refresh to ensure fresh balances - Report: show total value, total return %, exclude dust positions - Crontab offset +1 min from candle close for complete data - Handle PARTIALLY FILLED order status in cost calculation - Sort orders by mts_create in Python (Bitfinex sort unreliable) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
174 lines
6.2 KiB
Python
174 lines
6.2 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 == "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")
|
|
|
|
# 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
|
|
|
|
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)}"
|
|
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:
|
|
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
|