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 <noreply@anthropic.com>
This commit is contained in:
kroutony 2026-03-13 11:18:49 +00:00
parent 972d66ab1b
commit ce88afdb96
7 changed files with 544 additions and 57 deletions

View File

@ -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

12
cost_tracking.json Normal file
View File

@ -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
}

View File

@ -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()

163
main.py
View File

@ -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:

View File

@ -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

340
sync_cost_basis.py Normal file
View File

@ -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"<!channel>\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()

View File

@ -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