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:
parent
972d66ab1b
commit
ce88afdb96
15
config.py
15
config.py
@ -35,8 +35,15 @@ CANDLE_LIMIT = 100
|
|||||||
|
|
||||||
# Trading parameters
|
# Trading parameters
|
||||||
MIN_ORDER_USDT = 5
|
MIN_ORDER_USDT = 5
|
||||||
STOP_LOSS_PCT = 0.02
|
MIN_ORDER_AMOUNT = {
|
||||||
TAKE_PROFIT_PCT = 0.03
|
"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_POSITION_PCT = 0.30 # Max 30% of portfolio per coin
|
||||||
MAX_TOTAL_EXPOSURE_PCT = 1.00 # No cap — use full balance
|
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_FILE = "positions.json"
|
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
|
# Schedule
|
||||||
RUN_INTERVAL_MINUTES = 5
|
RUN_INTERVAL_MINUTES = 5
|
||||||
STATUS_REPORT_INTERVAL_MINUTES = 60
|
STATUS_REPORT_INTERVAL_MINUTES = 60
|
||||||
|
|||||||
12
cost_tracking.json
Normal file
12
cost_tracking.json
Normal 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
|
||||||
|
}
|
||||||
@ -46,6 +46,8 @@ def _auth_post(path: str, body: dict | None = None) -> dict | list:
|
|||||||
}
|
}
|
||||||
url = f"{config.BFX_AUTH_URL}{path}"
|
url = f"{config.BFX_AUTH_URL}{path}"
|
||||||
resp = requests.post(url, headers=headers, data=body_json, timeout=15)
|
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()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
|||||||
155
main.py
155
main.py
@ -75,19 +75,67 @@ def run_cycle():
|
|||||||
port["available_usdt"] = 10000
|
port["available_usdt"] = 10000
|
||||||
logger.info("Paper trading: using default 10000 USDT balance")
|
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():
|
for sym, pos in port.get("positions", {}).items():
|
||||||
if pos.get("amount", 0) > 0 and not pos.get("stop_order_id"):
|
if pos.get("amount", 0) <= 0:
|
||||||
|
continue
|
||||||
entry = pos.get("entry_price", 0)
|
entry = pos.get("entry_price", 0)
|
||||||
amount = pos.get("amount", 0)
|
if entry <= 0:
|
||||||
if entry > 0 and amount > 0:
|
continue
|
||||||
logger.warning("Position %s missing stop-loss order, placing now", sym)
|
|
||||||
|
# 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)
|
sl = trader.place_stop_loss_order(sym, amount, entry)
|
||||||
if sl:
|
if sl:
|
||||||
pos["stop_order_id"] = sl["order_id"]
|
# Update stop_orders_by_sym so step 10 sees the correct stop ID
|
||||||
pos["stop_price"] = sl["stop_price"]
|
stop_orders_by_sym[sym] = [{"id": sl["order_id"], "symbol": sym,
|
||||||
|
"amount": -amount, "price": sl["stop_price"],
|
||||||
|
"type": "EXCHANGE STOP"}]
|
||||||
pf.save_positions(port)
|
pf.save_positions(port)
|
||||||
logger.info("Backfill stop-loss for %s @ %.6g", sym, sl["stop_price"])
|
logger.info("Stop-loss for %s: %.6f @ %.6g", sym, amount, sl["stop_price"])
|
||||||
|
|
||||||
# 3. Fetch market data
|
# 3. Fetch market data
|
||||||
try:
|
try:
|
||||||
@ -131,11 +179,17 @@ def run_cycle():
|
|||||||
amount = pos.get("amount", 0)
|
amount = pos.get("amount", 0)
|
||||||
price = current_prices.get(sym, 0)
|
price = current_prices.get(sym, 0)
|
||||||
if amount > 0 and price > 0:
|
if amount > 0 and price > 0:
|
||||||
# Cancel the exchange stop-loss order before selling
|
# Cancel exchange stop-loss orders before selling (from real-time data)
|
||||||
stop_order_id = pos.get("stop_order_id")
|
for s in stop_orders_by_sym.get(sym, []):
|
||||||
if stop_order_id:
|
trader.cancel_order(s["id"])
|
||||||
trader.cancel_order(stop_order_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
|
signal["amount_usdt"] = amount * price
|
||||||
result = trader.execute_trade(signal, current_prices)
|
result = trader.execute_trade(signal, current_prices)
|
||||||
if result and result.get("status") in ("filled", "submitted"):
|
if result and result.get("status") in ("filled", "submitted"):
|
||||||
@ -173,6 +227,19 @@ def run_cycle():
|
|||||||
rejected.append({**signal, "reject_reason": reject_reason})
|
rejected.append({**signal, "reject_reason": reject_reason})
|
||||||
continue
|
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)
|
result = trader.execute_trade(validated, current_prices)
|
||||||
if result and result.get("status") == "failed":
|
if result and result.get("status") == "failed":
|
||||||
logger.warning("Order failed at exchange: %s %s — %s", action, validated["symbol"], result.get("error", ""))
|
logger.warning("Order failed at exchange: %s %s — %s", action, validated["symbol"], result.get("error", ""))
|
||||||
@ -180,34 +247,50 @@ def run_cycle():
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if result and result.get("status") in ("filled", "submitted"):
|
if result and result.get("status") in ("filled", "submitted"):
|
||||||
sym = validated["symbol"]
|
|
||||||
amount = result.get("amount", 0)
|
amount = result.get("amount", 0)
|
||||||
price = result.get("price", 0)
|
price = result.get("price", 0)
|
||||||
amount_usdt = result.get("amount_usdt", 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)
|
port = pf.update_position(port, sym, action, amount, price, amount_usdt)
|
||||||
|
|
||||||
stop_price = None
|
stop_price = None
|
||||||
if action == "BUY":
|
if action == "BUY":
|
||||||
# Place exchange stop-loss order immediately after buy
|
# Cancel existing exchange stop orders before placing new one
|
||||||
sl = trader.place_stop_loss_order(sym, amount, price)
|
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:
|
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"]
|
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)
|
pf.save_positions(port)
|
||||||
trade_logger.log_trade(result)
|
trade_logger.log_trade(result)
|
||||||
executed.append({**result, "stop_price": stop_price})
|
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
|
# 11. Send unified cycle report to Slack
|
||||||
portfolio_summary = _build_portfolio_one_liner(port, current_prices)
|
portfolio_summary = _build_portfolio_one_liner(port, current_prices)
|
||||||
slack_notifier.send_cycle_report(
|
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."""
|
"""Build a one-line portfolio summary for the cycle report."""
|
||||||
available = port.get("available_usdt", 0)
|
available = port.get("available_usdt", 0)
|
||||||
positions = port.get("positions", {})
|
positions = port.get("positions", {})
|
||||||
pos_count = sum(1 for p in positions.values() if p.get("amount", 0) > 0)
|
pos_count = 0
|
||||||
unrealized = 0.0
|
total_cost = 0.0
|
||||||
|
total_market_value = 0.0
|
||||||
for sym, pos in positions.items():
|
for sym, pos in positions.items():
|
||||||
amount = pos.get("amount", 0)
|
amount = pos.get("amount", 0)
|
||||||
entry = pos.get("entry_price", 0)
|
entry = pos.get("entry_price", 0)
|
||||||
current = current_prices.get(sym, 0)
|
current = current_prices.get(sym, 0)
|
||||||
if amount > 0 and entry > 0 and current > 0:
|
min_amt = config.MIN_ORDER_AMOUNT.get(sym, 0)
|
||||||
unrealized += amount * (current - entry)
|
if amount > 0 and entry > 0 and amount >= min_amt:
|
||||||
return f"{available:.2f} USDT 可用 | 持倉 {pos_count} 筆 | 未實現 {unrealized:+.2f} USDT"
|
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:
|
def _build_account_string(port: dict, current_prices: dict) -> str:
|
||||||
|
|||||||
@ -108,7 +108,13 @@ def validate_trade(signal: dict, portfolio: dict, indicators_df=None) -> tuple[d
|
|||||||
return None, "HOLD signal"
|
return None, "HOLD signal"
|
||||||
|
|
||||||
symbol = signal["symbol"]
|
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)
|
pct = signal.get("suggested_amount_pct", 0.1)
|
||||||
amount_usdt = total_balance * pct
|
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)
|
logger.info("金額超出餘額,調整: %.2f → %.2f USDT", amount_usdt, available)
|
||||||
amount_usdt = available
|
amount_usdt = available
|
||||||
|
|
||||||
# Position limit — cap to remaining room instead of rejecting
|
# Check minimum order amount (exchange-enforced per-symbol minimum)
|
||||||
if not check_position_limit(symbol, amount_usdt, portfolio):
|
min_amt = config.MIN_ORDER_AMOUNT.get(symbol, 0)
|
||||||
limit = total_balance * config.MAX_POSITION_PCT
|
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)
|
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
|
room = limit - current_exposure
|
||||||
if room < config.MIN_ORDER_USDT:
|
if room < config.MIN_ORDER_USDT:
|
||||||
return None, f"持倉已達上限 ({current_exposure:.2f} / {limit:.2f} USDT)"
|
return None, f"持倉已達上限 ({current_exposure:.2f} / {limit:.2f} USDT)"
|
||||||
logger.info("持倉上限調整: %.2f → %.2f USDT", amount_usdt, room)
|
logger.info("持倉上限調整: %.2f → %.2f USDT", amount_usdt, room)
|
||||||
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":
|
elif action == "SELL":
|
||||||
# For sells, ensure we actually hold the position
|
# For sells, ensure we actually hold the position
|
||||||
pos = portfolio.get("positions", {}).get(symbol, {})
|
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:
|
if pos_value <= 0:
|
||||||
pos_value = pos.get("amount", 0) * pos.get("entry_price", 0)
|
pos_value = pos.get("amount", 0) * pos.get("entry_price", 0)
|
||||||
amount_usdt = pos_value
|
amount_usdt = pos_value
|
||||||
|
# 傳遞實際持倉量,避免 USDT→amount 反算誤差
|
||||||
|
signal = dict(signal)
|
||||||
|
signal["sell_amount"] = pos["amount"]
|
||||||
|
|
||||||
signal = dict(signal)
|
signal = dict(signal)
|
||||||
signal["amount_usdt"] = amount_usdt
|
signal["amount_usdt"] = amount_usdt
|
||||||
|
|||||||
340
sync_cost_basis.py
Normal file
340
sync_cost_basis.py
Normal 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()
|
||||||
19
trader.py
19
trader.py
@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import config
|
import config
|
||||||
@ -20,6 +21,10 @@ def execute_trade(signal: dict, current_prices: dict, mode: str | None = None) -
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# Calculate amount in base currency
|
# 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
|
amount = amount_usdt / price
|
||||||
if action == "SELL":
|
if action == "SELL":
|
||||||
amount = -abs(amount) # negative = sell on Bitfinex
|
amount = -abs(amount) # negative = sell on Bitfinex
|
||||||
@ -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.
|
dict with stop order info, or None on failure.
|
||||||
"""
|
"""
|
||||||
mode = mode or ("paper" if config.PAPER_TRADING else "live")
|
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)
|
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":
|
if mode == "paper":
|
||||||
order_id = f"paper_sl_{int(time.time() * 1000)}"
|
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)
|
logger.info("LIVE cancel order: %s", order_id)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
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)
|
logger.error("Failed to cancel order %s: %s", order_id, e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user