bifitnex-trading/main.py
kroutony 972d66ab1b Initial commit: LLM-driven crypto trading bot
Includes: Bitfinex API integration, technical indicators,
LLM signal generation, risk management, Slack notifications.

Recent fixes:
- SELL orders use position value instead of total balance
- SELL signals always close full position
- Failed orders added to rejected list for Slack reporting
- Position/exposure limits auto-cap to remaining room
- BUY order minimum raised to 10% of portfolio

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 03:25:18 +00:00

283 lines
11 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
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
# ---------------------------------------------------------------------------
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler("trading.log"),
],
)
logger = logging.getLogger("main")
STATE_FILE = "bot_state.json"
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 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().isoformat())
# 1. Load portfolio state
port = pf.load_positions()
# 2. Fetch account status from exchange
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")
# 2b. Backfill missing stop-loss orders for existing positions
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"])
# 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
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)
# 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)
# --- Collect cycle results ---
tp_closed = [] # take-profit closures
executed = [] # successfully executed trades
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 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)
signal["amount_usdt"] = amount * price
result = trader.execute_trade(signal, current_prices)
if result and result.get("status") in ("filled", "submitted"):
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": amount * price})
logger.info("Take profit executed: %s", sym)
# 7. Build indicator summary for LLM
indicator_summary = indicators.summarize_all(indicators_by_symbol)
# 8. Build account status string for LLM
account_str = _build_account_string(port, current_prices)
# 9. Call LLM for analysis
signals = []
try:
signals = llm_analyzer.analyze_market(indicator_summary, account_str)
logger.info("LLM returned %d signals", len(signals))
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)
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
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')}"})
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)
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"])
pf.save_positions(port)
trade_logger.log_trade(result)
executed.append({**result, "stop_price": stop_price})
# 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,
)
# 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 = sum(1 for p in positions.values() if p.get("amount", 0) > 0)
unrealized = 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"
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()