From ce88afdb963c0805874176fc18866389f825e9c9 Mon Sep 17 00:00:00 2001 From: kroutony Date: Fri, 13 Mar 2026 11:18:49 +0000 Subject: [PATCH] Add cost basis sync, fix stop-loss reliability, improve reporting - 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 --- config.py | 15 +- cost_tracking.json | 12 ++ data_fetcher.py | 2 + main.py | 163 +++++++++++++++++----- risk_manager.py | 44 +++--- sync_cost_basis.py | 340 +++++++++++++++++++++++++++++++++++++++++++++ trader.py | 25 +++- 7 files changed, 544 insertions(+), 57 deletions(-) create mode 100644 cost_tracking.json create mode 100644 sync_cost_basis.py diff --git a/config.py b/config.py index fe20db0..b9b720c 100644 --- a/config.py +++ b/config.py @@ -35,8 +35,15 @@ CANDLE_LIMIT = 100 # Trading parameters MIN_ORDER_USDT = 5 -STOP_LOSS_PCT = 0.02 -TAKE_PROFIT_PCT = 0.03 +MIN_ORDER_AMOUNT = { + "tBTCUST": 0.00004, "tETHUST": 0.0008, "tDOTUST": 0.2, + "tSUIUST": 2.0, "tSOLUST": 0.02, "tLTCUST": 0.04, + "tADAUST": 4.0, "tAVAX:UST": 0.08, "tUNIUST": 0.2, + "tLINK:UST": 0.2, "tXRPUST": 4.0, "tSHIB:UST": 141010.0, + "tDOGE:UST": 22.0, "tATOUST": 0.02, "tXLMUST": 4.0, +} +STOP_LOSS_PCT = 0.03 +TAKE_PROFIT_PCT = 0.05 MAX_POSITION_PCT = 0.30 # Max 30% of portfolio per coin MAX_TOTAL_EXPOSURE_PCT = 1.00 # No cap — use full balance @@ -51,6 +58,10 @@ INDICATORS_CACHE_DIR = os.path.join(CACHE_DIR, "indicators/") # Positions file POSITIONS_FILE = "positions.json" +# Cost basis sync +COST_TRACKING_FILE = "cost_tracking.json" +INIT_COST_BASIS_START_MS = 1773244800000 # 2026-03-11 00:00:00 +08:00 + # Schedule RUN_INTERVAL_MINUTES = 5 STATUS_REPORT_INTERVAL_MINUTES = 60 diff --git a/cost_tracking.json b/cost_tracking.json new file mode 100644 index 0000000..fb47f36 --- /dev/null +++ b/cost_tracking.json @@ -0,0 +1,12 @@ +{ + "tETHUST": null, + "tSUIUST": 1773362755397, + "tSOLUST": null, + "tLTCUST": null, + "tADAUST": 1773380454544, + "tAVAX:UST": 1773380455009, + "tUNIUST": 1773380455473, + "tXRPUST": 1773363657836, + "tDOGE:UST": 1773244800000, + "tSHIB:UST": 1773396631966 +} \ No newline at end of file diff --git a/data_fetcher.py b/data_fetcher.py index 12f922c..78efb3f 100644 --- a/data_fetcher.py +++ b/data_fetcher.py @@ -46,6 +46,8 @@ def _auth_post(path: str, body: dict | None = None) -> dict | list: } url = f"{config.BFX_AUTH_URL}{path}" resp = requests.post(url, headers=headers, data=body_json, timeout=15) + if resp.status_code >= 400: + logger.error("API %s returned %d: %s", path, resp.status_code, resp.text[:500]) resp.raise_for_status() return resp.json() diff --git a/main.py b/main.py index 87f5483..bda239d 100644 --- a/main.py +++ b/main.py @@ -75,19 +75,67 @@ def run_cycle(): port["available_usdt"] = 10000 logger.info("Paper trading: using default 10000 USDT balance") - # 2b. Backfill missing stop-loss orders for existing positions + # 2b. Sync stop-loss orders with exchange (real-time, not local memory) + try: + active_orders = data_fetcher.fetch_active_orders() + except Exception as e: + logger.error("Failed to fetch active orders: %s", e) + active_orders = [] + # Build map: symbol → list of EXCHANGE STOP orders + stop_orders_by_sym = {} + for o in active_orders: + if o.get("type") == "EXCHANGE STOP": + stop_orders_by_sym.setdefault(o["symbol"], []).append(o) + + # Build map: currency → wallet balance (for accurate stop-loss amounts) + wallet_balances = {} + for w in account_status.get("wallets", []): + if w.get("type") == "exchange": + wallet_balances[w["currency"]] = w.get("balance", 0) + for sym, pos in port.get("positions", {}).items(): - if pos.get("amount", 0) > 0 and not pos.get("stop_order_id"): - entry = pos.get("entry_price", 0) - amount = pos.get("amount", 0) - if entry > 0 and amount > 0: - logger.warning("Position %s missing stop-loss order, placing now", sym) - sl = trader.place_stop_loss_order(sym, amount, entry) - if sl: - pos["stop_order_id"] = sl["order_id"] - pos["stop_price"] = sl["stop_price"] - pf.save_positions(port) - logger.info("Backfill stop-loss for %s @ %.6g", sym, sl["stop_price"]) + if pos.get("amount", 0) <= 0: + continue + entry = pos.get("entry_price", 0) + if entry <= 0: + continue + + # Use exchange wallet balance as the authoritative amount + currency = sym[1:].replace(":UST", "").replace("UST", "") + amount = wallet_balances.get(currency, 0) + if amount <= 0: + continue + + # Skip stop-loss for positions below exchange minimum order size + min_amt = config.MIN_ORDER_AMOUNT.get(sym, 0) + if min_amt > 0 and amount < min_amt: + continue + + expected_stop = round(entry * (1 - config.STOP_LOSS_PCT), 8) + existing_stops = stop_orders_by_sym.get(sym, []) + + if existing_stops: + # Check if existing stop matches expected amount & price + stop = existing_stops[0] + 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: + 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"]) + + logger.warning("Position %s missing/outdated stop-loss, placing now", sym) + sl = trader.place_stop_loss_order(sym, amount, entry) + if sl: + # Update stop_orders_by_sym so step 10 sees the correct stop ID + stop_orders_by_sym[sym] = [{"id": sl["order_id"], "symbol": sym, + "amount": -amount, "price": sl["stop_price"], + "type": "EXCHANGE STOP"}] + pf.save_positions(port) + logger.info("Stop-loss for %s: %.6f @ %.6g", sym, amount, sl["stop_price"]) # 3. Fetch market data try: @@ -131,11 +179,17 @@ def run_cycle(): amount = pos.get("amount", 0) price = current_prices.get(sym, 0) if amount > 0 and price > 0: - # Cancel the exchange stop-loss order before selling - stop_order_id = pos.get("stop_order_id") - if stop_order_id: - trader.cancel_order(stop_order_id) + # Cancel exchange stop-loss orders before selling (from real-time data) + 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) + # Use wallet balance as authoritative sell amount + currency = sym[1:].replace(":UST", "").replace("UST", "") + wallet_amt = wallet_balances.get(currency, 0) + if wallet_amt > 0: + amount = wallet_amt + signal["sell_amount"] = amount signal["amount_usdt"] = amount * price result = trader.execute_trade(signal, current_prices) if result and result.get("status") in ("filled", "submitted"): @@ -173,6 +227,19 @@ def run_cycle(): rejected.append({**signal, "reject_reason": reject_reason}) continue + sym = validated["symbol"] + + if action == "SELL": + # Cancel exchange stop-loss orders BEFORE selling (free up locked balance) + 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) + # Use wallet balance as authoritative sell amount + currency = sym[1:].replace(":UST", "").replace("UST", "") + wallet_amt = wallet_balances.get(currency, 0) + if wallet_amt > 0: + validated["sell_amount"] = wallet_amt + result = trader.execute_trade(validated, current_prices) if result and result.get("status") == "failed": logger.warning("Order failed at exchange: %s %s — %s", action, validated["symbol"], result.get("error", "")) @@ -180,34 +247,50 @@ def run_cycle(): continue if result and result.get("status") in ("filled", "submitted"): - sym = validated["symbol"] amount = result.get("amount", 0) price = result.get("price", 0) amount_usdt = result.get("amount_usdt", 0) - if action == "SELL": - # Cancel existing stop-loss order before selling - pos = port.get("positions", {}).get(sym, {}) - stop_order_id = pos.get("stop_order_id") - if stop_order_id: - trader.cancel_order(stop_order_id) - port = pf.update_position(port, sym, action, amount, price, amount_usdt) stop_price = None if action == "BUY": - # Place exchange stop-loss order immediately after buy - sl = trader.place_stop_loss_order(sym, amount, price) + # Cancel existing exchange stop orders before placing new one + pos = port.get("positions", {}).get(sym, {}) + for s in stop_orders_by_sym.get(sym, []): + trader.cancel_order(s["id"]) + logger.info("Cancelled old stop %s for %s (position size changed)", s["id"], sym) + + # Place new stop-loss for TOTAL position amount at new avg entry + total_amount = pos.get("amount", amount) + entry_price = pos.get("entry_price", price) + sl = trader.place_stop_loss_order(sym, total_amount, entry_price) if sl: - port.setdefault("positions", {}).setdefault(sym, {})["stop_order_id"] = sl["order_id"] - port["positions"][sym]["stop_price"] = sl["stop_price"] stop_price = sl["stop_price"] - logger.info("Stop-loss set for %s @ %.6g", sym, sl["stop_price"]) + # Update stop_orders_by_sym so subsequent actions in this cycle see it + stop_orders_by_sym[sym] = [{"id": sl["order_id"], "symbol": sym, + "amount": -total_amount, "price": stop_price, + "type": "EXCHANGE STOP"}] + 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}) + # 10b. Post-trade wallet refresh — ensure next trade uses latest balances + if executed or tp_closed: + try: + refreshed = data_fetcher.fetch_account_status() + port = pf.sync_with_exchange(port, refreshed) + # Rebuild wallet_balances for any remaining logic + wallet_balances = {} + for w in refreshed.get("wallets", []): + if w.get("type") == "exchange": + wallet_balances[w["currency"]] = w.get("balance", 0) + logger.info("Post-trade wallet refresh: %.2f USDT available", port.get("available_usdt", 0)) + except Exception as e: + logger.warning("Post-trade wallet refresh failed: %s", e) + # 11. Send unified cycle report to Slack portfolio_summary = _build_portfolio_one_liner(port, current_prices) slack_notifier.send_cycle_report( @@ -240,15 +323,27 @@ def _build_portfolio_one_liner(port: dict, current_prices: dict) -> str: """Build a one-line portfolio summary for the cycle report.""" available = port.get("available_usdt", 0) positions = port.get("positions", {}) - pos_count = sum(1 for p in positions.values() if p.get("amount", 0) > 0) - unrealized = 0.0 + pos_count = 0 + total_cost = 0.0 + total_market_value = 0.0 for sym, pos in positions.items(): amount = pos.get("amount", 0) entry = pos.get("entry_price", 0) current = current_prices.get(sym, 0) - if amount > 0 and entry > 0 and current > 0: - unrealized += amount * (current - entry) - return f"{available:.2f} USDT 可用 | 持倉 {pos_count} 筆 | 未實現 {unrealized:+.2f} USDT" + min_amt = config.MIN_ORDER_AMOUNT.get(sym, 0) + if amount > 0 and entry > 0 and amount >= min_amt: + pos_count += 1 + total_cost += amount * entry + if current > 0: + total_market_value += amount * current + unrealized = total_market_value - total_cost + total_value = available + total_market_value + total_capital = available + total_cost + total_return_pct = ((total_value / total_capital - 1) * 100) if total_capital > 0 else 0.0 + return ( + f"總值 {total_value:.2f} USDT | 總收益 {total_return_pct:+.2f}% | " + f"{available:.2f} 可用 | 持倉 {pos_count} 筆" + ) def _build_account_string(port: dict, current_prices: dict) -> str: diff --git a/risk_manager.py b/risk_manager.py index 858fa0d..f9d8279 100644 --- a/risk_manager.py +++ b/risk_manager.py @@ -108,7 +108,13 @@ def validate_trade(signal: dict, portfolio: dict, indicators_df=None) -> tuple[d return None, "HOLD signal" symbol = signal["symbol"] - total_balance = portfolio.get("total_balance_usdt", 0) + # 基準 = USDT 餘額 + 所有持倉成本 (entry_price × amount) + available = portfolio.get("available_usdt", 0) + positions_cost = sum( + pos.get("entry_price", 0) * pos.get("amount", 0) + for pos in portfolio.get("positions", {}).values() + ) + total_balance = available + positions_cost pct = signal.get("suggested_amount_pct", 0.1) amount_usdt = total_balance * pct @@ -135,28 +141,29 @@ def validate_trade(signal: dict, portfolio: dict, indicators_df=None) -> tuple[d logger.info("金額超出餘額,調整: %.2f → %.2f USDT", amount_usdt, available) amount_usdt = available - # Position limit — cap to remaining room instead of rejecting - if not check_position_limit(symbol, amount_usdt, portfolio): - limit = total_balance * config.MAX_POSITION_PCT - current_exposure = portfolio.get("positions", {}).get(symbol, {}).get("value_usdt", 0) + # Check minimum order amount (exchange-enforced per-symbol minimum) + min_amt = config.MIN_ORDER_AMOUNT.get(symbol, 0) + if min_amt > 0: + # Need price to convert USDT → base currency amount + if indicators_df is not None and not indicators_df.empty: + est_price = indicators_df.iloc[-1].get("close", 0) + else: + est_price = 0 + if est_price > 0: + est_amount = amount_usdt / est_price + if est_amount < min_amt: + return None, f"低於最低交易量 ({est_amount:.6f} < {min_amt} {symbol})" + + # Position limit — use same total_balance for consistency + current_exposure = portfolio.get("positions", {}).get(symbol, {}).get("value_usdt", 0) + limit = total_balance * config.MAX_POSITION_PCT + if current_exposure + amount_usdt > limit: room = limit - current_exposure if room < config.MIN_ORDER_USDT: return None, f"持倉已達上限 ({current_exposure:.2f} / {limit:.2f} USDT)" logger.info("持倉上限調整: %.2f → %.2f USDT", amount_usdt, room) amount_usdt = room - # Total exposure — cap to remaining room instead of rejecting - if not check_total_exposure(portfolio, amount_usdt): - limit = total_balance * config.MAX_TOTAL_EXPOSURE_PCT - current_total = sum( - pos.get("value_usdt", 0) for pos in portfolio.get("positions", {}).values() - ) - room = limit - current_total - if room < config.MIN_ORDER_USDT: - return None, f"總曝險已達上限 ({current_total:.2f} / {limit:.2f} USDT)" - logger.info("總曝險上限調整: %.2f → %.2f USDT", amount_usdt, room) - amount_usdt = room - elif action == "SELL": # For sells, ensure we actually hold the position pos = portfolio.get("positions", {}).get(symbol, {}) @@ -168,6 +175,9 @@ def validate_trade(signal: dict, portfolio: dict, indicators_df=None) -> tuple[d if pos_value <= 0: pos_value = pos.get("amount", 0) * pos.get("entry_price", 0) amount_usdt = pos_value + # 傳遞實際持倉量,避免 USDT→amount 反算誤差 + signal = dict(signal) + signal["sell_amount"] = pos["amount"] signal = dict(signal) signal["amount_usdt"] = amount_usdt diff --git a/sync_cost_basis.py b/sync_cost_basis.py new file mode 100644 index 0000000..c1a73f9 --- /dev/null +++ b/sync_cost_basis.py @@ -0,0 +1,340 @@ +#!/usr/bin/env python3 +"""Sync cost basis (entry_price), wallet balances, and per-coin amounts from order history. + +Designed to run every 30 minutes via cron. +""" + +import json +import logging +import os +import sys +import time + +import config +import data_fetcher +import slack_notifier + +# --------------------------------------------------------------------------- +# Logging setup (separate log file) +# --------------------------------------------------------------------------- +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + handlers=[ + logging.StreamHandler(sys.stdout), + logging.FileHandler("sync_cost_basis.log"), + ], +) +logger = logging.getLogger("sync_cost_basis") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _symbol_to_currency(symbol: str) -> str: + """Convert symbol like 'tETHUST' or 'tAVAX:UST' to currency code like 'ETH'.""" + return symbol[1:].replace(":UST", "").replace("UST", "") + + +def _build_currency_to_symbol() -> dict[str, str]: + """Build mapping: currency code → symbol from TOP_15_SYMBOLS.""" + return {_symbol_to_currency(sym): sym for sym in config.TOP_15_SYMBOLS} + + +# --------------------------------------------------------------------------- +# Cost tracking persistence +# --------------------------------------------------------------------------- + +def load_cost_tracking() -> dict: + if os.path.exists(config.COST_TRACKING_FILE): + try: + with open(config.COST_TRACKING_FILE) as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + pass + return {} + + +def save_cost_tracking(data: dict): + with open(config.COST_TRACKING_FILE, "w") as f: + json.dump(data, f, indent=2) + + +def init_cost_tracking(positions: dict) -> dict: + """Initialize cost_tracking for current positions with default start_ms.""" + tracking = {} + for sym in positions: + tracking[sym] = config.INIT_COST_BASIS_START_MS + return tracking + + +# --------------------------------------------------------------------------- +# Fetch order history with pagination +# --------------------------------------------------------------------------- + +def fetch_orders_hist(symbol: str, start_ms: int) -> list[list]: + """Fetch all filled orders for symbol from start_ms to now, with pagination.""" + all_orders = [] + end_ms = int(time.time() * 1000) + + while True: + body = {"start": start_ms, "end": end_ms, "limit": 500} + path = f"/v2/auth/r/orders/{symbol}/hist" + try: + raw = data_fetcher._auth_post(path, body) + except Exception as e: + logger.error("Failed to fetch orders for %s: %s", symbol, e) + break + + if not raw or not isinstance(raw, list): + break + + all_orders.extend(raw) + + if len(raw) < 500: + break + + # Paginate: default is descending, so oldest is last + last_mts = min(o[4] for o in raw) # earliest mts_create + end_ms = last_mts - 1 + + # Sort by mts_create ascending (Bitfinex default order is unreliable) + all_orders.sort(key=lambda o: o[4]) + return all_orders + + +# --------------------------------------------------------------------------- +# Cost basis calculation +# --------------------------------------------------------------------------- + +def calculate_cost_basis(orders: list[list]) -> tuple[float, float]: + """Calculate cost basis from sorted orders. + + Returns (amount, entry_price). + Order fields: [id, gid, cid, symbol, mts_create, mts_update, amount, amount_orig, + type, type_prev, mts_tif, _, flags, status, _, _, price, price_avg, + price_trailing, ...] + - amount (index 6): remaining amount (0 if fully filled) + - amount_orig (index 7): original order amount (positive=buy, negative=sell) + - price_avg (index 17): average execution price + """ + amount = 0.0 + total_cost = 0.0 + + for order in orders: + status = order[13] if len(order) > 13 else "" + if not isinstance(status, str): + continue + # Include EXECUTED and PARTIALLY FILLED orders; skip CANCELED with no fill + has_fill = "EXECUTED" in status or "PARTIALLY FILLED" in status + if not has_fill: + continue + + amount_orig = float(order[7]) # positive = buy, negative = sell + amount_remaining = float(order[6]) # 0 if fully filled + price_avg = float(order[17]) if len(order) > 17 and order[17] else 0 + + if price_avg <= 0: + continue + + # Actual executed amount = amount_orig - amount_remaining + exec_amt = abs(amount_orig - amount_remaining) + if exec_amt <= 0: + continue + + if amount_orig > 0: # BUY + total_cost += exec_amt * price_avg + amount += exec_amt + else: # SELL + if amount > 0: + ratio = min(exec_amt / amount, 1.0) + total_cost *= (1 - ratio) + amount -= exec_amt + if amount < 0: + amount = 0.0 + total_cost = 0.0 + + entry_price = total_cost / amount if amount > 0 else 0.0 + return amount, entry_price + + +# --------------------------------------------------------------------------- +# Main sync logic +# --------------------------------------------------------------------------- + +def run_sync(): + logger.info("=" * 60) + logger.info("Starting cost basis sync") + + # 1. Fetch wallet balances + try: + account_status = data_fetcher.fetch_account_status() + except Exception as e: + logger.error("Failed to fetch account status: %s", e) + return + + wallet_balances: dict[str, float] = {} + usdt_balance = 0.0 + usdt_available = 0.0 + for w in account_status.get("wallets", []): + if w.get("type") == "exchange": + currency = w["currency"] + bal = w.get("balance", 0) + if currency in ("UST", "USDT"): + usdt_balance = bal + usdt_available = w.get("available", 0) or bal + else: + if bal > 0: + wallet_balances[currency] = bal + + currency_to_symbol = _build_currency_to_symbol() + + # 2. Load cost tracking and positions + cost_tracking = load_cost_tracking() + positions_data = {} + if os.path.exists(config.POSITIONS_FILE): + try: + with open(config.POSITIONS_FILE) as f: + positions_data = json.load(f) + except (json.JSONDecodeError, IOError): + pass + + positions = positions_data.get("positions", {}) + + # Initialize cost_tracking if empty + if not cost_tracking: + cost_tracking = init_cost_tracking(positions) + save_cost_tracking(cost_tracking) + logger.info("Initialized cost_tracking.json with %d symbols", len(cost_tracking)) + + # 3. Determine which symbols to process + # Symbols from: current positions + cost_tracking + wallet balances + all_symbols = set() + all_symbols.update(positions.keys()) + all_symbols.update(k for k, v in cost_tracking.items() if v is not None) + for currency, bal in wallet_balances.items(): + sym = currency_to_symbol.get(currency) + if sym: + all_symbols.add(sym) + + # 4. Process each symbol + for sym in sorted(all_symbols): + currency = _symbol_to_currency(sym) + wallet_amt = wallet_balances.get(currency, 0) + start_ms = cost_tracking.get(sym) + + # If no start_ms and no wallet balance, skip + if start_ms is None and wallet_amt <= 0: + continue + + # If wallet has balance but no tracking entry, initialize + if start_ms is None and wallet_amt > 0: + start_ms = config.INIT_COST_BASIS_START_MS + cost_tracking[sym] = start_ms + + logger.info("Processing %s (wallet: %.6f, start_ms: %s)", sym, wallet_amt, start_ms) + + # Fetch order history + orders = fetch_orders_hist(sym, start_ms) + logger.info(" Fetched %d orders for %s", len(orders), sym) + + if not orders: + # No orders found — keep existing position data but update amount from wallet + if wallet_amt > 0: + existing = positions.get(sym, {}) + existing_entry = existing.get("entry_price", 0) + if existing_entry > 0: + positions[sym] = { + "amount": wallet_amt, + "entry_price": existing_entry, + "value_usdt": wallet_amt * existing_entry, + "last_update": time.time(), + } + elif sym in positions: + # No wallet balance and no orders — remove position + positions.pop(sym, None) + cost_tracking[sym] = None + time.sleep(0.5) + continue + + # Calculate cost basis + calc_amount, entry_price = calculate_cost_basis(orders) + + # Deviation check: calculated amount vs wallet balance + if wallet_amt > 0 and calc_amount > 0: + deviation = abs(calc_amount - wallet_amt) / wallet_amt + logger.info( + " %s: calc_amount=%.6f, wallet=%.6f, entry=%.6g, deviation=%.2f%%", + sym, calc_amount, wallet_amt, entry_price, deviation * 100, + ) + if deviation > 0.05: + msg = ( + f"\n" + f"⚠️ *成本基礎偏差警告*\n" + f"幣種: {config.SYMBOL_NAMES.get(sym, sym)}\n" + f"計算量: {calc_amount:.6f}\n" + f"錢包量: {wallet_amt:.6f}\n" + f"偏差: {deviation:.2%}\n" + f"建議檢查 cost_tracking 起始日期" + ) + slack_notifier._send({"text": msg}) + logger.warning(" Deviation > 5%% for %s, Slack notified", sym) + else: + logger.info( + " %s: calc_amount=%.6f, wallet=%.6f, entry=%.6g", + sym, calc_amount, wallet_amt, entry_price, + ) + + # Update positions: use wallet amount as authoritative, calculated entry_price + if wallet_amt > 0 and entry_price > 0: + positions[sym] = { + "amount": wallet_amt, + "entry_price": entry_price, + "value_usdt": wallet_amt * entry_price, + "last_update": time.time(), + } + elif wallet_amt <= 0: + # Fully sold — remove position and mark tracking as null + positions.pop(sym, None) + cost_tracking[sym] = None + logger.info(" %s fully sold, marking cost_tracking as null", sym) + + # Update cost_tracking: find first BUY mts_create + if wallet_amt > 0: + first_buy_mts = None + for order in orders: + status = order[13] if len(order) > 13 else "" + if isinstance(status, str) and "EXECUTED" in status and float(order[7]) > 0: + first_buy_mts = order[4] # mts_create + break + if first_buy_mts is not None: + cost_tracking[sym] = first_buy_mts + + time.sleep(1) # Rate limit between symbols (avoid nonce collisions) + + # 5. Remove positions for currencies no longer in wallet + for sym in list(positions.keys()): + currency = _symbol_to_currency(sym) + if wallet_balances.get(currency, 0) <= 0: + positions.pop(sym, None) + if sym in cost_tracking: + cost_tracking[sym] = None + + # 6. Save updated positions (update balances too) + positions_data["positions"] = positions + positions_data["total_balance_usdt"] = usdt_balance + positions_data["available_usdt"] = usdt_available + positions_data["last_updated"] = time.time() + + with open(config.POSITIONS_FILE, "w") as f: + json.dump(positions_data, f, indent=2) + + # 7. Save cost tracking + save_cost_tracking(cost_tracking) + + logger.info("Cost basis sync complete. Updated %d positions.", len(positions)) + + +if __name__ == "__main__": + run_sync() diff --git a/trader.py b/trader.py index c11c706..a86d1a3 100644 --- a/trader.py +++ b/trader.py @@ -1,4 +1,5 @@ import logging +import math import time import config @@ -20,9 +21,13 @@ def execute_trade(signal: dict, current_prices: dict, mode: str | None = None) - return None # Calculate amount in base currency - amount = amount_usdt / price - if action == "SELL": - amount = -abs(amount) # negative = sell on Bitfinex + 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, @@ -73,8 +78,17 @@ def place_stop_loss_order(symbol: str, amount: float, entry_price: float, mode: 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) - sell_amount = -abs(amount) # negative = sell + # 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)}" @@ -122,6 +136,9 @@ def cancel_order(order_id, mode: str | None = None) -> bool: 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