bifitnex-trading/main.py
kroutony b2d7495ec0 Relax LLM entry filters, show 1h-bullish HOLD reasons in cycle report
- llm_analyzer.py: change 5m filters from hard requirements to confidence
  adjustments (volume, ADX<15, OBV direction now ±0.1 instead of blocking);
  1h ADX<20 lowers confidence instead of preventing entry
- slack_notifier.py: when all symbols HOLD, show 1h-bullish symbols with
  their HOLD reason instead of generic "no action" message
- main.py: log all 15 HOLD reasons instead of first 3

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 01:32:51 +00:00

594 lines
26 KiB
Python

#!/usr/bin/env python3
"""LLM-driven cryptocurrency trading system — single-run mode for crontab."""
import json
import logging
import os
import sys
import time
from datetime import datetime, timezone, timedelta
import config
import data_fetcher
import indicators
import llm_analyzer
import portfolio as pf
import risk_manager
import slack_notifier
import trade_logger
import trader
# ---------------------------------------------------------------------------
# Logging setup
# ---------------------------------------------------------------------------
_TZ_UTC8 = timezone(timedelta(hours=8))
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler("trading.log"),
],
)
logging.Formatter.converter = lambda *args: datetime.now(_TZ_UTC8).timetuple()
logger = logging.getLogger("main")
STATE_FILE = "bot_state.json"
STOP_ORDERS_FILE = "stop_orders.json"
def _calc_atr_stop_price(entry_price: float, atr: float) -> float:
"""根據 ATR 動態計算止損價,帶上下限。"""
atr_pct = (atr * config.ATR_SL_MULTIPLIER) / entry_price
bounded_pct = max(config.ATR_SL_MIN_PCT, min(config.ATR_SL_MAX_PCT, atr_pct))
return round(entry_price * (1 - bounded_pct), 8)
def _load_state() -> dict:
if os.path.exists(STATE_FILE):
try:
with open(STATE_FILE) as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
pass
return {"last_status_report": 0, "run_count": 0}
def _save_state(state: dict):
with open(STATE_FILE, "w") as f:
json.dump(state, f, indent=2)
def _load_tracked_stops() -> dict:
"""Load tracked stop orders from file. Returns {symbol: {order_id, stop_price, entry_price, amount}}."""
if os.path.exists(STOP_ORDERS_FILE):
try:
with open(STOP_ORDERS_FILE) as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
pass
return {}
def _save_tracked_stops(stops: dict):
with open(STOP_ORDERS_FILE, "w") as f:
json.dump(stops, f, indent=2)
def run_cycle():
"""Execute one full trading cycle."""
state = _load_state()
state["run_count"] = state.get("run_count", 0) + 1
logger.info("=" * 60)
logger.info("Trading cycle #%d at %s", state["run_count"], datetime.now(_TZ_UTC8).isoformat())
# 1. Load portfolio state
port = pf.load_positions()
# 2. Fetch account status from exchange
account_status = {"wallets": [], "positions": []}
try:
account_status = data_fetcher.fetch_account_status()
port = pf.sync_with_exchange(port, account_status)
logger.info(
"Account: %.2f USDT total, %.2f USDT available",
port.get("total_balance_usdt", 0),
port.get("available_usdt", 0),
)
except Exception as e:
logger.error("Failed to fetch account status: %s", e)
if config.PAPER_TRADING and port.get("total_balance_usdt", 0) == 0:
port["total_balance_usdt"] = 10000
port["available_usdt"] = 10000
logger.info("Paper trading: using default 10000 USDT balance")
# 2a. Fetch initial capital from deposit history
try:
port["initial_capital"] = data_fetcher.fetch_total_deposits()
except Exception as e:
logger.warning("Failed to fetch deposit history: %s", e)
# 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 available balance (for accurate stop-loss amounts)
# Use "available" (not "balance") to exclude coins locked by existing orders
wallet_balances = {}
for w in account_status.get("wallets", []):
if w.get("type") == "exchange":
wallet_balances[w["currency"]] = w.get("available", 0) or w.get("balance", 0)
# Load tracked stops early — needed for both stop sync and fill detection
tracked_stops = _load_tracked_stops()
for sym, pos in port.get("positions", {}).items():
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
# Use stored stop price if available (preserves ATR-calculated value);
# fall back to fixed % for legacy positions without a stored price
tracked = tracked_stops.get(sym, {})
expected_stop = tracked.get("stop_price") or 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:
# Ensure this stop is tracked for fill detection
tracked_stops[sym] = {
"order_id": stop["id"], "stop_price": stop["price"],
"entry_price": entry, "amount": amount,
}
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"])
tracked_stops.pop(sym, None)
time.sleep(1.0) # Wait for Bitfinex to release locked balance
logger.warning("Position %s missing/outdated stop-loss, placing now", sym)
sl = trader.place_stop_loss_order(sym, amount, entry, stop_price=expected_stop)
if sl is None and existing_stops:
# Retry once — Bitfinex may need more time to release locked balance
time.sleep(2.0)
logger.info("Retrying stop-loss for %s after balance release delay", sym)
sl = trader.place_stop_loss_order(sym, amount, entry, stop_price=expected_stop)
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"}]
# Track for fill detection
tracked_stops[sym] = {
"order_id": sl["order_id"], "stop_price": sl["stop_price"],
"entry_price": entry, "amount": amount,
}
pf.save_positions(port)
logger.info("Stop-loss for %s: %.6f @ %.6g", sym, amount, sl["stop_price"])
_save_tracked_stops(tracked_stops)
# 3. Fetch market data
try:
market_data = data_fetcher.fetch_all_market_data()
logger.info("Fetched market data for %d symbols", len(market_data))
except Exception as e:
logger.error("Failed to fetch market data: %s", e)
slack_notifier.send_error_alert(f"Market data fetch failed: {e}")
return
# 4. Calculate indicators (5m)
indicators_by_symbol = {}
current_prices = {}
for sym, md in market_data.items():
candles_df = md.get("candles")
if candles_df is not None and not candles_df.empty:
ind_df = indicators.calculate_indicators(candles_df)
indicators_by_symbol[sym] = ind_df
ticker = md.get("ticker", {})
if ticker:
current_prices[sym] = ticker.get("last_price", 0)
# 4b. Calculate HTF indicators (1h)
htf_by_symbol = {}
pivots_by_symbol = {}
for sym, md in market_data.items():
candles_htf = md.get("candles_htf")
if candles_htf is not None and not candles_htf.empty:
htf_by_symbol[sym] = indicators.calculate_htf_indicators(candles_htf)
pivots_by_symbol[sym] = indicators.calculate_pivot_points(candles_htf)
# 5. Cache data (persisted for next crontab run)
try:
data_fetcher.cache_market_data(market_data, indicators_by_symbol)
except Exception as e:
logger.warning("Cache write failed: %s", e)
# --- Detect filled stop-loss orders ---
sl_filled = []
active_order_ids = {str(o["id"]) for o in active_orders}
for sym, sinfo in list(tracked_stops.items()):
if str(sinfo.get("order_id")) in active_order_ids:
continue # still active, not filled
# Stop order is gone — check if wallet balance is also gone (= filled, not cancelled by us)
currency = sym[1:].replace(":UST", "").replace("UST", "")
wallet_amt = wallet_balances.get(currency, 0)
pos = port.get("positions", {}).get(sym, {})
pos_amt = pos.get("amount", 0)
# If wallet is empty or nearly empty but we had a position → stop was filled
min_amt = config.MIN_ORDER_AMOUNT.get(sym, 0)
if pos_amt > 0 and wallet_amt < max(min_amt, pos_amt * 0.05):
stop_price = sinfo.get("stop_price", 0)
entry_price = sinfo.get("entry_price", 0) or pos.get("entry_price", 0)
amount = sinfo.get("amount", 0) or pos_amt
amount_usdt = amount * stop_price if stop_price else 0
realized_pnl = None
realized_pnl_pct = None
if entry_price and stop_price:
realized_pnl = (stop_price - entry_price) * amount
realized_pnl_pct = (stop_price - entry_price) / entry_price * 100
sl_filled.append({
"symbol": sym,
"action": "SELL",
"amount": amount,
"price": stop_price,
"amount_usdt": amount_usdt,
"reason": "止損觸發",
"confidence": 1.0,
"mode": "live",
"status": "filled",
"is_stop_loss": True,
"entry_price": entry_price,
"realized_pnl": realized_pnl,
"realized_pnl_pct": realized_pnl_pct,
"stop_price": None,
})
# Update portfolio — position is closed
port = pf.update_position(port, sym, "SELL", amount, stop_price, amount_usdt)
pf.save_positions(port)
trade_logger.log_trade(sl_filled[-1])
logger.warning("Stop-loss FILLED for %s: %.6f @ %.6g, P&L: %.2f USDT (%.2f%%)",
sym, amount, stop_price, realized_pnl or 0, realized_pnl_pct or 0)
# Remove from tracked stops (filled or cancelled)
del tracked_stops[sym]
_save_tracked_stops(tracked_stops)
# --- Collect cycle results ---
tp_closed = [] # take-profit closures
executed = sl_filled # start with filled stop-losses
rejected = [] # signals rejected by risk manager
# 6. Check take-profit on existing positions (stop-loss is handled by exchange stop orders)
tp_signals = risk_manager.apply_stop_loss_take_profit(
port.get("positions", {}), current_prices
)
for signal in tp_signals:
sym = signal["symbol"]
pos = port.get("positions", {}).get(sym, {})
amount = pos.get("amount", 0)
price = current_prices.get(sym, 0)
if amount > 0 and price > 0:
# 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)
tracked_stops.pop(sym, None)
_save_tracked_stops(tracked_stops)
# 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
# Capture cost basis before selling
entry_price = pos.get("entry_price", 0)
cost_basis = entry_price * amount if entry_price else 0
result = trader.execute_trade(signal, current_prices)
if result and result.get("status") in ("filled", "submitted"):
sell_proceeds = amount * price
realized_pnl = sell_proceeds - cost_basis if cost_basis else None
realized_pnl_pct = ((price - entry_price) / entry_price * 100) if entry_price else None
port = pf.update_position(port, sym, "SELL", amount, price, signal["amount_usdt"])
pf.save_positions(port)
trade_logger.log_trade(result)
tp_closed.append({
**signal,
"amount_usdt": sell_proceeds,
"entry_price": entry_price,
"realized_pnl": realized_pnl,
"realized_pnl_pct": realized_pnl_pct,
})
logger.info("Take profit executed: %s, P&L: %.2f USDT (%.2f%%)",
sym, realized_pnl or 0, realized_pnl_pct or 0)
# 7. Build indicator summary for LLM (5m + 1h context + pivots)
indicator_summary = indicators.summarize_all(indicators_by_symbol)
if htf_by_symbol:
indicator_summary += "\n" + indicators.summarize_htf(htf_by_symbol)
if pivots_by_symbol:
indicator_summary += "\n" + indicators.summarize_pivots(pivots_by_symbol, current_prices)
# 8. Build account status string for LLM
account_str = _build_account_string(port, current_prices)
# 9. Call LLM for analysis
signals = []
llm_ok = False
try:
signals = llm_analyzer.analyze_market(indicator_summary, account_str)
llm_ok = True
logger.info("LLM returned %d signals", len(signals))
non_hold = [s for s in signals if s.get("action") != "HOLD"]
if non_hold:
for s in non_hold:
logger.info("LLM signal: %s %s conf=%.2f reason=%s",
s.get("action"), s.get("symbol"), s.get("confidence", 0), s.get("reason", ""))
else:
# Debug: log ALL hold reasons (not just first 3) to diagnose all-HOLD cycles
for s in signals:
logger.info("LLM HOLD: %s conf=%.2f reason=%s",
s.get("symbol"), s.get("confidence", 0), s.get("reason", ""))
except Exception as e:
logger.error("LLM analysis failed: %s", e)
slack_notifier.send_error_alert(f"LLM analysis failed: {e}")
# 10. Validate and execute trades
for signal in signals:
action = signal.get("action", "HOLD")
if action == "HOLD":
continue
ind_df = indicators_by_symbol.get(signal.get("symbol"))
validated, reject_reason = risk_manager.validate_trade(signal, port, ind_df, current_prices)
if validated is None:
logger.info("Signal rejected by risk manager: %s %s%s", action, signal.get("symbol"), reject_reason)
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)
tracked_stops.pop(sym, None)
_save_tracked_stops(tracked_stops)
# 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", ""))
rejected.append({**validated, "reject_reason": f"交易所錯誤: {result.get('error', 'unknown')}"})
# BUY failed at exchange — mark balance as unreliable to skip remaining BUYs
if action == "BUY" and "not enough" in result.get("error", "").lower():
port["available_usdt"] = 0
logger.info("Marked available_usdt=0 to prevent further BUY attempts this cycle")
continue
if result and result.get("status") in ("filled", "submitted"):
amount = result.get("amount", 0)
price = result.get("price", 0)
amount_usdt = result.get("amount_usdt", 0)
# Calculate realized P&L for SELL trades
realized_pnl = None
realized_pnl_pct = None
if action == "SELL":
pos = port.get("positions", {}).get(sym, {})
entry_price = pos.get("entry_price", 0)
if entry_price:
cost_basis = entry_price * amount
realized_pnl = amount * price - cost_basis
realized_pnl_pct = (price - entry_price) / entry_price * 100
port = pf.update_position(port, sym, action, amount, price, amount_usdt)
stop_price = None
if action == "BUY":
# Cancel existing exchange stop orders before placing new one
pos = port.get("positions", {}).get(sym, {})
cancelled_any = False
for s in stop_orders_by_sym.get(sym, []):
trader.cancel_order(s["id"])
cancelled_any = True
logger.info("Cancelled old stop %s for %s (position size changed)", s["id"], sym)
if cancelled_any:
time.sleep(1.0) # Wait for Bitfinex to release locked balance
# Place new stop-loss for TOTAL position amount at new avg entry
# Refresh wallet balance to get actual available amount (handles partial fills, fees)
try:
refreshed_status = data_fetcher.fetch_account_status()
currency = sym[1:].replace(":UST", "").replace("UST", "")
wallet_amt = 0
for w in refreshed_status.get("wallets", []):
if w.get("type") == "exchange" and w.get("currency") == currency:
wallet_amt = w.get("available", 0) or w.get("balance", 0)
break
total_amount = wallet_amt if wallet_amt > 0 else pos.get("amount", amount)
except Exception:
total_amount = pos.get("amount", amount)
entry_price = pos.get("entry_price", price)
# ATR-based dynamic stop price
atr_stop = None
ind_df = indicators_by_symbol.get(sym)
if ind_df is not None and "atr" in ind_df.columns:
atr_val = ind_df["atr"].dropna().iloc[-1] if not ind_df["atr"].dropna().empty else None
if atr_val and atr_val > 0:
atr_stop = _calc_atr_stop_price(entry_price, atr_val)
atr_pct = (1 - atr_stop / entry_price) * 100
logger.info("ATR stop for %s: ATR=%.6g, stop=%.6g (%.1f%%)",
sym, atr_val, atr_stop, atr_pct)
if atr_stop is None:
logger.info("ATR unavailable for %s, using fixed %.0f%% stop", sym, config.STOP_LOSS_PCT * 100)
sl = trader.place_stop_loss_order(sym, total_amount, entry_price, stop_price=atr_stop)
if sl is None and cancelled_any:
# Retry once — Bitfinex may need more time to release locked balance
time.sleep(2.0)
logger.info("Retrying stop-loss for %s after balance release delay", sym)
sl = trader.place_stop_loss_order(sym, total_amount, entry_price, stop_price=atr_stop)
if sl:
stop_price = 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"}]
# Track for fill detection
tracked_stops[sym] = {
"order_id": sl["order_id"], "stop_price": stop_price,
"entry_price": entry_price, "amount": total_amount,
}
_save_tracked_stops(tracked_stops)
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,
"realized_pnl": realized_pnl, "realized_pnl_pct": realized_pnl_pct})
# 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(
cycle_number=state["run_count"],
signals=signals,
executed=executed,
rejected=rejected,
tp_closed=tp_closed,
portfolio_summary=portfolio_summary,
llm_ok=llm_ok,
)
# 12. Save final state
pf.save_positions(port)
# 13. Hourly status report (check elapsed time across crontab runs)
now = time.time()
last_report = state.get("last_status_report", 0)
if now - last_report >= config.STATUS_REPORT_INTERVAL_MINUTES * 60:
summary = pf.get_portfolio_summary(port, current_prices)
slack_notifier.send_status_update(summary)
logger.info("Hourly status report sent")
state["last_status_report"] = now
state["last_run"] = now
_save_state(state)
logger.info("Cycle complete")
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 = 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)
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
total_value = available + total_market_value
# 總收益率(基於入金)
initial_capital = port.get("initial_capital", 0)
total_return_pct = ((total_value / initial_capital - 1) * 100) if initial_capital > 0 else 0.0
# 變動收益率(基於持倉成本)
unrealized = total_market_value - total_cost
total_capital = available + total_cost
change_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"變動 {change_pct:+.2f}% | {available:.2f} 可用 | 持倉 {pos_count}"
)
def _build_account_string(port: dict, current_prices: dict) -> str:
"""Build a concise account status string for the LLM prompt."""
lines = []
lines.append(f"Total Balance: {port.get('total_balance_usdt', 0):.2f} USDT")
lines.append(f"Available: {port.get('available_usdt', 0):.2f} USDT")
positions = port.get("positions", {})
if positions:
lines.append("\nOpen Positions:")
for sym, pos in positions.items():
amount = pos.get("amount", 0)
entry = pos.get("entry_price", 0)
current = current_prices.get(sym, 0)
pnl_pct = ((current - entry) / entry * 100) if entry > 0 and current > 0 else 0
name = config.SYMBOL_NAMES.get(sym, sym)
lines.append(
f" {name}: {amount:.6f} @ {entry:.6g} "
f"(now {current:.6g}, {pnl_pct:+.2f}%)"
)
else:
lines.append("\nNo open positions.")
return "\n".join(lines)
if __name__ == "__main__":
mode = "PAPER" if config.PAPER_TRADING else "LIVE"
logger.info("Running in %s mode, monitoring %d symbols", mode, len(config.TOP_15_SYMBOLS))
run_cycle()