#!/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()