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>
This commit is contained in:
commit
972d66ab1b
4
.env.example
Normal file
4
.env.example
Normal file
@ -0,0 +1,4 @@
|
||||
BFX_API_KEY=your_bitfinex_api_key_here
|
||||
BFX_API_SECRET=your_bitfinex_api_secret_here
|
||||
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/your/webhook/url
|
||||
PAPER_TRADING=true
|
||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
cache/
|
||||
positions.json
|
||||
trade_history.csv
|
||||
bot_state.json
|
||||
*.log
|
||||
.venv/
|
||||
venv/
|
||||
56
config.py
Normal file
56
config.py
Normal file
@ -0,0 +1,56 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Bitfinex API
|
||||
BFX_API_KEY = os.getenv("BFX_API_KEY", "")
|
||||
BFX_API_SECRET = os.getenv("BFX_API_SECRET", "")
|
||||
BFX_BASE_URL = "https://api-pub.bitfinex.com"
|
||||
BFX_AUTH_URL = "https://api.bitfinex.com"
|
||||
|
||||
# Slack
|
||||
SLACK_WEBHOOK_URL = os.getenv("SLACK_WEBHOOK_URL", "")
|
||||
|
||||
# Top USDT trading pairs on Bitfinex (verified active)
|
||||
# Note: some use "tXXXUST" format, others use "tXXX:UST" (5+ char base)
|
||||
TOP_15_SYMBOLS = [
|
||||
"tBTCUST", "tETHUST", "tSOLUST", "tXRPUST", "tADAUST",
|
||||
"tDOGE:UST", "tAVAX:UST", "tDOTUST", "tLINK:UST", "tLTCUST",
|
||||
"tSHIB:UST", "tATOUST", "tUNIUST", "tSUIUST", "tXLMUST",
|
||||
]
|
||||
|
||||
# Symbol display names for readability
|
||||
SYMBOL_NAMES = {
|
||||
"tBTCUST": "BTC/USDT", "tETHUST": "ETH/USDT", "tSOLUST": "SOL/USDT",
|
||||
"tXRPUST": "XRP/USDT", "tADAUST": "ADA/USDT", "tDOGE:UST": "DOGE/USDT",
|
||||
"tAVAX:UST": "AVAX/USDT", "tDOTUST": "DOT/USDT", "tLINK:UST": "LINK/USDT",
|
||||
"tLTCUST": "LTC/USDT", "tSHIB:UST": "SHIB/USDT", "tATOUST": "ATOM/USDT",
|
||||
"tUNIUST": "UNI/USDT", "tSUIUST": "SUI/USDT", "tXLMUST": "XLM/USDT",
|
||||
}
|
||||
|
||||
# Candle settings
|
||||
CANDLE_TIMEFRAME = "5m"
|
||||
CANDLE_LIMIT = 100
|
||||
|
||||
# Trading parameters
|
||||
MIN_ORDER_USDT = 5
|
||||
STOP_LOSS_PCT = 0.02
|
||||
TAKE_PROFIT_PCT = 0.03
|
||||
MAX_POSITION_PCT = 0.30 # Max 30% of portfolio per coin
|
||||
MAX_TOTAL_EXPOSURE_PCT = 1.00 # No cap — use full balance
|
||||
|
||||
# Mode
|
||||
PAPER_TRADING = os.getenv("PAPER_TRADING", "true").lower() == "true"
|
||||
|
||||
# Cache
|
||||
CACHE_DIR = "cache/"
|
||||
CANDLES_CACHE_DIR = os.path.join(CACHE_DIR, "candles/")
|
||||
INDICATORS_CACHE_DIR = os.path.join(CACHE_DIR, "indicators/")
|
||||
|
||||
# Positions file
|
||||
POSITIONS_FILE = "positions.json"
|
||||
|
||||
# Schedule
|
||||
RUN_INTERVAL_MINUTES = 5
|
||||
STATUS_REPORT_INTERVAL_MINUTES = 60
|
||||
224
data_fetcher.py
Normal file
224
data_fetcher.py
Normal file
@ -0,0 +1,224 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
import pandas as pd
|
||||
import requests
|
||||
|
||||
import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _pub_get(path: str, params: dict | None = None) -> dict | list:
|
||||
url = f"{config.BFX_BASE_URL}{path}"
|
||||
resp = requests.get(url, params=params, timeout=15)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Authenticated API helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _auth_post(path: str, body: dict | None = None) -> dict | list:
|
||||
body = body or {}
|
||||
nonce = str(int(time.time() * 1000000))
|
||||
body_json = json.dumps(body)
|
||||
signature_payload = f"/api{path}{nonce}{body_json}"
|
||||
sig = hmac.new(
|
||||
config.BFX_API_SECRET.encode("utf-8"),
|
||||
signature_payload.encode("utf-8"),
|
||||
hashlib.sha384,
|
||||
).hexdigest()
|
||||
|
||||
headers = {
|
||||
"bfx-nonce": nonce,
|
||||
"bfx-apikey": config.BFX_API_KEY,
|
||||
"bfx-signature": sig,
|
||||
"content-type": "application/json",
|
||||
}
|
||||
url = f"{config.BFX_AUTH_URL}{path}"
|
||||
resp = requests.post(url, headers=headers, data=body_json, timeout=15)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def fetch_tickers(symbols: list[str] | None = None) -> dict:
|
||||
"""Return {symbol: {bid, ask, last, volume, ...}} for given symbols."""
|
||||
symbols = symbols or config.TOP_15_SYMBOLS
|
||||
params = {"symbols": ",".join(symbols)}
|
||||
raw = _pub_get("/v2/tickers", params)
|
||||
tickers = {}
|
||||
for t in raw:
|
||||
sym = t[0]
|
||||
tickers[sym] = {
|
||||
"bid": t[1],
|
||||
"bid_size": t[2],
|
||||
"ask": t[3],
|
||||
"ask_size": t[4],
|
||||
"daily_change": t[5],
|
||||
"daily_change_pct": t[6],
|
||||
"last_price": t[7],
|
||||
"volume": t[8],
|
||||
"high": t[9],
|
||||
"low": t[10],
|
||||
}
|
||||
return tickers
|
||||
|
||||
|
||||
def fetch_candles(symbol: str, timeframe: str | None = None, limit: int | None = None) -> pd.DataFrame:
|
||||
"""Return DataFrame with columns: timestamp, open, close, high, low, volume."""
|
||||
timeframe = timeframe or config.CANDLE_TIMEFRAME
|
||||
limit = limit or config.CANDLE_LIMIT
|
||||
path = f"/v2/candles/trade:{timeframe}:{symbol}/hist"
|
||||
raw = _pub_get(path, {"limit": limit, "sort": -1})
|
||||
if not raw:
|
||||
return pd.DataFrame()
|
||||
df = pd.DataFrame(raw, columns=["timestamp", "open", "close", "high", "low", "volume"])
|
||||
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms")
|
||||
return df
|
||||
|
||||
|
||||
def fetch_order_book(symbol: str, precision: str = "P0") -> dict:
|
||||
"""Return raw order book entries."""
|
||||
path = f"/v2/book/{symbol}/{precision}"
|
||||
raw = _pub_get(path, {"len": 25})
|
||||
bids, asks = [], []
|
||||
for entry in raw:
|
||||
price, count, amount = entry[0], entry[1], entry[2]
|
||||
if amount > 0:
|
||||
bids.append({"price": price, "count": count, "amount": amount})
|
||||
else:
|
||||
asks.append({"price": price, "count": count, "amount": abs(amount)})
|
||||
return {"bids": bids, "asks": asks}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Authenticated endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def fetch_account_status() -> dict:
|
||||
"""Return wallet balances and active positions from Bitfinex."""
|
||||
if config.PAPER_TRADING and not config.BFX_API_KEY:
|
||||
return {"wallets": [], "positions": []}
|
||||
try:
|
||||
wallets_raw = _auth_post("/v2/auth/r/wallets")
|
||||
wallets = []
|
||||
for w in wallets_raw:
|
||||
wallets.append({
|
||||
"type": w[0],
|
||||
"currency": w[1],
|
||||
"balance": w[2],
|
||||
"unsettled": w[3],
|
||||
"available": w[4],
|
||||
})
|
||||
|
||||
positions_raw = _auth_post("/v2/auth/r/positions")
|
||||
positions = []
|
||||
for p in positions_raw:
|
||||
positions.append({
|
||||
"symbol": p[0],
|
||||
"status": p[1],
|
||||
"amount": p[2],
|
||||
"base_price": p[3],
|
||||
"pnl": p[6] if len(p) > 6 else 0,
|
||||
})
|
||||
return {"wallets": wallets, "positions": positions}
|
||||
except Exception as e:
|
||||
logger.error("Failed to fetch account status: %s", e)
|
||||
return {"wallets": [], "positions": []}
|
||||
|
||||
|
||||
def fetch_active_orders() -> list[dict]:
|
||||
"""Return list of active orders."""
|
||||
if config.PAPER_TRADING and not config.BFX_API_KEY:
|
||||
return []
|
||||
try:
|
||||
raw = _auth_post("/v2/auth/r/orders")
|
||||
orders = []
|
||||
for o in raw:
|
||||
orders.append({
|
||||
"id": o[0],
|
||||
"symbol": o[3],
|
||||
"amount": o[6],
|
||||
"price": o[16],
|
||||
"status": o[13],
|
||||
"type": o[8],
|
||||
})
|
||||
return orders
|
||||
except Exception as e:
|
||||
logger.error("Failed to fetch active orders: %s", e)
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Aggregation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def fetch_all_market_data(symbols: list[str] | None = None) -> dict:
|
||||
"""Fetch tickers + candles for all symbols. Returns dict keyed by symbol."""
|
||||
symbols = symbols or config.TOP_15_SYMBOLS
|
||||
tickers = fetch_tickers(symbols)
|
||||
data = {}
|
||||
for sym in symbols:
|
||||
try:
|
||||
candles = fetch_candles(sym)
|
||||
data[sym] = {
|
||||
"ticker": tickers.get(sym, {}),
|
||||
"candles": candles,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("Failed to fetch data for %s: %s", sym, e)
|
||||
return data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _ensure_cache_dirs():
|
||||
os.makedirs(config.CANDLES_CACHE_DIR, exist_ok=True)
|
||||
os.makedirs(config.INDICATORS_CACHE_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def cache_market_data(market_data: dict, indicators_data: dict | None = None):
|
||||
"""Write candle and indicator data to cache/ as JSON files."""
|
||||
_ensure_cache_dirs()
|
||||
for sym, md in market_data.items():
|
||||
candles_df = md.get("candles")
|
||||
if candles_df is not None and not candles_df.empty:
|
||||
path = os.path.join(config.CANDLES_CACHE_DIR, f"{sym}.json")
|
||||
candles_df.to_json(path, orient="records", date_format="iso")
|
||||
if indicators_data:
|
||||
for sym, ind_df in indicators_data.items():
|
||||
if ind_df is not None and not ind_df.empty:
|
||||
path = os.path.join(config.INDICATORS_CACHE_DIR, f"{sym}.json")
|
||||
ind_df.to_json(path, orient="records", date_format="iso")
|
||||
|
||||
|
||||
def load_cached_data() -> tuple[dict, dict]:
|
||||
"""Load cached candles and indicators. Returns (candles_dict, indicators_dict)."""
|
||||
_ensure_cache_dirs()
|
||||
candles, indicators = {}, {}
|
||||
for fname in os.listdir(config.CANDLES_CACHE_DIR):
|
||||
if fname.endswith(".json"):
|
||||
sym = fname[:-5]
|
||||
path = os.path.join(config.CANDLES_CACHE_DIR, fname)
|
||||
candles[sym] = pd.read_json(path, orient="records")
|
||||
for fname in os.listdir(config.INDICATORS_CACHE_DIR):
|
||||
if fname.endswith(".json"):
|
||||
sym = fname[:-5]
|
||||
path = os.path.join(config.INDICATORS_CACHE_DIR, fname)
|
||||
indicators[sym] = pd.read_json(path, orient="records")
|
||||
return candles, indicators
|
||||
101
indicators.py
Normal file
101
indicators.py
Normal file
@ -0,0 +1,101 @@
|
||||
import logging
|
||||
|
||||
import pandas as pd
|
||||
import ta
|
||||
|
||||
import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def calculate_indicators(candles_df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Add technical indicators to a candles DataFrame.
|
||||
|
||||
Expected input columns: timestamp, open, close, high, low, volume.
|
||||
Returns the DataFrame with additional indicator columns.
|
||||
"""
|
||||
df = candles_df.copy()
|
||||
if df.empty or len(df) < 26:
|
||||
logger.warning("Not enough candle data to compute indicators (got %d rows)", len(df))
|
||||
return df
|
||||
|
||||
close = df["close"]
|
||||
high = df["high"]
|
||||
low = df["low"]
|
||||
volume = df["volume"]
|
||||
|
||||
# RSI(14)
|
||||
df["rsi"] = ta.momentum.rsi(close, window=14)
|
||||
|
||||
# MACD(12, 26, 9)
|
||||
macd = ta.trend.MACD(close, window_slow=26, window_fast=12, window_sign=9)
|
||||
df["macd"] = macd.macd()
|
||||
df["macd_signal"] = macd.macd_signal()
|
||||
df["macd_hist"] = macd.macd_diff()
|
||||
|
||||
# Bollinger Bands(20, 2)
|
||||
bb = ta.volatility.BollingerBands(close, window=20, window_dev=2)
|
||||
df["bb_upper"] = bb.bollinger_hband()
|
||||
df["bb_middle"] = bb.bollinger_mavg()
|
||||
df["bb_lower"] = bb.bollinger_lband()
|
||||
|
||||
# EMA(9) and EMA(21)
|
||||
df["ema9"] = ta.trend.ema_indicator(close, window=9)
|
||||
df["ema21"] = ta.trend.ema_indicator(close, window=21)
|
||||
|
||||
# VWAP (cumulative for the session)
|
||||
tp = (high + low + close) / 3
|
||||
cumvol = volume.cumsum()
|
||||
df["vwap"] = (tp * volume).cumsum() / cumvol.replace(0, float("nan"))
|
||||
|
||||
# ATR(14)
|
||||
df["atr"] = ta.volatility.average_true_range(high, low, close, window=14)
|
||||
|
||||
# Volume moving average (20)
|
||||
df["vol_ma20"] = volume.rolling(window=20).mean()
|
||||
|
||||
return df
|
||||
|
||||
|
||||
def summarize_indicators(df: pd.DataFrame, symbol: str) -> str:
|
||||
"""Produce a concise text summary of the latest indicator values for LLM consumption."""
|
||||
if df.empty or len(df) < 2:
|
||||
return f"{symbol}: insufficient data\n"
|
||||
|
||||
last = df.iloc[-1]
|
||||
prev = df.iloc[-2]
|
||||
name = config.SYMBOL_NAMES.get(symbol, symbol)
|
||||
|
||||
lines = [f"### {name} ({symbol})"]
|
||||
lines.append(f"Price: {last['close']:.6g} | High: {last['high']:.6g} | Low: {last['low']:.6g}")
|
||||
lines.append(f"Volume: {last['volume']:.2f} | Vol MA20: {last.get('vol_ma20', 0):.2f}")
|
||||
|
||||
if pd.notna(last.get("rsi")):
|
||||
lines.append(f"RSI(14): {last['rsi']:.1f}")
|
||||
if pd.notna(last.get("macd")):
|
||||
hist_dir = "↑" if last["macd_hist"] > prev.get("macd_hist", 0) else "↓"
|
||||
lines.append(
|
||||
f"MACD: {last['macd']:.6g} | Signal: {last['macd_signal']:.6g} | "
|
||||
f"Hist: {last['macd_hist']:.6g} ({hist_dir})"
|
||||
)
|
||||
if pd.notna(last.get("bb_upper")):
|
||||
lines.append(
|
||||
f"BB: Upper={last['bb_upper']:.6g} Mid={last['bb_middle']:.6g} Lower={last['bb_lower']:.6g}"
|
||||
)
|
||||
if pd.notna(last.get("ema9")):
|
||||
trend = "bullish" if last["ema9"] > last["ema21"] else "bearish"
|
||||
lines.append(f"EMA9: {last['ema9']:.6g} | EMA21: {last['ema21']:.6g} → {trend}")
|
||||
if pd.notna(last.get("atr")):
|
||||
lines.append(f"ATR(14): {last['atr']:.6g}")
|
||||
if pd.notna(last.get("vwap")):
|
||||
lines.append(f"VWAP: {last['vwap']:.6g}")
|
||||
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def summarize_all(indicators_by_symbol: dict[str, pd.DataFrame]) -> str:
|
||||
"""Build a combined indicator summary for all symbols."""
|
||||
parts = []
|
||||
for sym, df in sorted(indicators_by_symbol.items()):
|
||||
parts.append(summarize_indicators(df, sym))
|
||||
return "\n".join(parts)
|
||||
101
llm_analyzer.py
Normal file
101
llm_analyzer.py
Normal file
@ -0,0 +1,101 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def analyze_market(indicator_summary: str, account_status: str) -> list[dict]:
|
||||
"""Call Claude CLI to analyze market data and return trade signals."""
|
||||
|
||||
prompt = f"""你是一位專業的加密貨幣短線交易分析師。
|
||||
|
||||
## 帳戶狀態
|
||||
{account_status}
|
||||
|
||||
## 目前市場指標摘要(5分鐘K線最新值)
|
||||
{indicator_summary}
|
||||
|
||||
請根據以下策略分析每個幣種並給出交易建議:
|
||||
|
||||
## 交易策略
|
||||
1. **趨勢確認**: EMA(9) > EMA(21) 為多頭,反之為空頭
|
||||
2. **進場訊號 (BUY)**:
|
||||
- RSI < 35 且 MACD histogram 由負轉正(超賣反彈)
|
||||
- 價格觸及 Bollinger 下軌且 RSI < 40(均值回歸)
|
||||
- MACD 金叉 + EMA(9) 上穿 EMA(21)(趨勢啟動)
|
||||
3. **進場訊號 (SELL)**:
|
||||
- RSI > 70 且 MACD histogram 由正轉負(超買回落)
|
||||
- 價格觸及 Bollinger 上軌且 RSI > 65
|
||||
- MACD 死叉 + EMA(9) 下穿 EMA(21)
|
||||
4. **過濾條件**:
|
||||
- 成交量需高於 20 期平均(確認動能)
|
||||
- ATR 過高時降低倉位(波動風控)
|
||||
|
||||
請以 JSON 格式回傳,每個幣種一個物件:
|
||||
```json
|
||||
[
|
||||
{{
|
||||
"symbol": "tBTCUST",
|
||||
"action": "BUY" | "SELL" | "HOLD",
|
||||
"confidence": 0.0-1.0,
|
||||
"reason": "簡短理由",
|
||||
"suggested_amount_pct": 0.10-0.20
|
||||
}}
|
||||
]
|
||||
```
|
||||
只回傳 JSON,不要其他文字。"""
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["claude", "-p", prompt, "--output-format", "json"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error("Claude CLI failed (rc=%d): %s", result.returncode, result.stderr)
|
||||
return []
|
||||
return _parse_llm_response(result.stdout)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("Claude CLI timed out")
|
||||
return []
|
||||
except FileNotFoundError:
|
||||
logger.error("Claude CLI not found — is 'claude' installed and in PATH?")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error("LLM analysis error: %s", e)
|
||||
return []
|
||||
|
||||
|
||||
def _parse_llm_response(raw: str) -> list[dict]:
|
||||
"""Extract the JSON array from Claude's response."""
|
||||
# First try: the output-format json wraps response in {"result": "..."}
|
||||
try:
|
||||
wrapper = json.loads(raw)
|
||||
if isinstance(wrapper, dict) and "result" in wrapper:
|
||||
raw = wrapper["result"]
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Try direct parse
|
||||
if isinstance(raw, list):
|
||||
return raw
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
if isinstance(parsed, list):
|
||||
return parsed
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Try extracting JSON array from markdown code block or mixed text
|
||||
match = re.search(r'\[[\s\S]*?\]', raw)
|
||||
if match:
|
||||
try:
|
||||
return json.loads(match.group())
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
logger.warning("Could not parse LLM response as JSON array")
|
||||
return []
|
||||
282
main.py
Normal file
282
main.py
Normal file
@ -0,0 +1,282 @@
|
||||
#!/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()
|
||||
169
portfolio.py
Normal file
169
portfolio.py
Normal file
@ -0,0 +1,169 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def load_positions() -> dict:
|
||||
"""Load positions from positions.json."""
|
||||
if not os.path.exists(config.POSITIONS_FILE):
|
||||
return _empty_portfolio()
|
||||
try:
|
||||
with open(config.POSITIONS_FILE) as f:
|
||||
data = json.load(f)
|
||||
return data
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
logger.error("Failed to load positions: %s", e)
|
||||
return _empty_portfolio()
|
||||
|
||||
|
||||
def save_positions(portfolio: dict):
|
||||
"""Save portfolio state to positions.json."""
|
||||
with open(config.POSITIONS_FILE, "w") as f:
|
||||
json.dump(portfolio, f, indent=2)
|
||||
|
||||
|
||||
def _empty_portfolio() -> dict:
|
||||
return {
|
||||
"total_balance_usdt": 0,
|
||||
"available_usdt": 0,
|
||||
"positions": {},
|
||||
"trade_history": [],
|
||||
"last_updated": time.time(),
|
||||
}
|
||||
|
||||
|
||||
def update_position(portfolio: dict, symbol: str, action: str, amount: float, price: float, amount_usdt: float) -> dict:
|
||||
"""Update portfolio after a trade execution."""
|
||||
positions = portfolio.setdefault("positions", {})
|
||||
|
||||
if action == "BUY":
|
||||
existing = positions.get(symbol, {"amount": 0, "entry_price": 0, "value_usdt": 0})
|
||||
old_amount = existing.get("amount", 0)
|
||||
old_entry = existing.get("entry_price", 0)
|
||||
new_amount = old_amount + amount
|
||||
# Weighted average entry price
|
||||
if new_amount > 0:
|
||||
new_entry = (old_amount * old_entry + amount * price) / new_amount
|
||||
else:
|
||||
new_entry = price
|
||||
positions[symbol] = {
|
||||
"amount": new_amount,
|
||||
"entry_price": new_entry,
|
||||
"value_usdt": new_amount * price,
|
||||
"last_update": time.time(),
|
||||
}
|
||||
portfolio["available_usdt"] = portfolio.get("available_usdt", 0) - amount_usdt
|
||||
|
||||
elif action == "SELL":
|
||||
existing = positions.get(symbol, {})
|
||||
old_amount = existing.get("amount", 0)
|
||||
sell_amount = min(amount, old_amount)
|
||||
remaining = old_amount - sell_amount
|
||||
if remaining <= 0.000001:
|
||||
positions.pop(symbol, None)
|
||||
else:
|
||||
positions[symbol] = {
|
||||
"amount": remaining,
|
||||
"entry_price": existing.get("entry_price", price),
|
||||
"value_usdt": remaining * price,
|
||||
"last_update": time.time(),
|
||||
}
|
||||
portfolio["available_usdt"] = portfolio.get("available_usdt", 0) + sell_amount * price
|
||||
|
||||
# Record trade
|
||||
portfolio.setdefault("trade_history", []).append({
|
||||
"symbol": symbol,
|
||||
"action": action,
|
||||
"amount": amount,
|
||||
"price": price,
|
||||
"amount_usdt": amount_usdt,
|
||||
"timestamp": time.time(),
|
||||
})
|
||||
|
||||
portfolio["last_updated"] = time.time()
|
||||
return portfolio
|
||||
|
||||
|
||||
def calculate_pnl(portfolio: dict, current_prices: dict) -> dict:
|
||||
"""Calculate unrealized P&L for all positions."""
|
||||
pnl = {}
|
||||
for sym, pos in portfolio.get("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 = (current - entry) * amount
|
||||
unrealized_pct = (current - entry) / entry
|
||||
pnl[sym] = {
|
||||
"amount": amount,
|
||||
"entry_price": entry,
|
||||
"current_price": current,
|
||||
"unrealized_usdt": unrealized,
|
||||
"unrealized_pct": unrealized_pct,
|
||||
"value_usdt": amount * current,
|
||||
}
|
||||
return pnl
|
||||
|
||||
|
||||
def get_portfolio_summary(portfolio: dict, current_prices: dict) -> str:
|
||||
"""Generate a human-readable portfolio summary."""
|
||||
pnl = calculate_pnl(portfolio, current_prices)
|
||||
total_value = portfolio.get("available_usdt", 0)
|
||||
total_unrealized = 0
|
||||
|
||||
lines = ["📊 *Portfolio Summary*"]
|
||||
lines.append(f"Available USDT: {portfolio.get('available_usdt', 0):.2f}")
|
||||
lines.append("")
|
||||
|
||||
if pnl:
|
||||
lines.append("*Open Positions:*")
|
||||
for sym, p in pnl.items():
|
||||
name = config.SYMBOL_NAMES.get(sym, sym)
|
||||
emoji = "🟢" if p["unrealized_pct"] >= 0 else "🔴"
|
||||
lines.append(
|
||||
f"{emoji} {name}: {p['amount']:.6f} @ {p['entry_price']:.6g} → "
|
||||
f"{p['current_price']:.6g} ({p['unrealized_pct']:+.2%}) "
|
||||
f"= {p['unrealized_usdt']:+.2f} USDT"
|
||||
)
|
||||
total_value += p["value_usdt"]
|
||||
total_unrealized += p["unrealized_usdt"]
|
||||
lines.append("")
|
||||
|
||||
lines.append(f"*Total Portfolio Value:* {total_value:.2f} USDT")
|
||||
lines.append(f"*Total Unrealized P&L:* {total_unrealized:+.2f} USDT")
|
||||
|
||||
history = portfolio.get("trade_history", [])
|
||||
if history:
|
||||
lines.append(f"\n*Trades today:* {len(history)}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def sync_with_exchange(portfolio: dict, account_status: dict) -> dict:
|
||||
"""Sync portfolio with exchange account status."""
|
||||
wallets = account_status.get("wallets", [])
|
||||
for w in wallets:
|
||||
if w.get("type") == "exchange" and w.get("currency") in ("UST", "USDT"):
|
||||
portfolio["total_balance_usdt"] = w.get("balance", 0)
|
||||
portfolio["available_usdt"] = w.get("available", 0) or w.get("balance", 0)
|
||||
break
|
||||
|
||||
# Sync exchange positions
|
||||
for p in account_status.get("positions", []):
|
||||
sym = p.get("symbol", "")
|
||||
if sym and p.get("amount", 0) != 0:
|
||||
portfolio.setdefault("positions", {})[sym] = {
|
||||
"amount": abs(p["amount"]),
|
||||
"entry_price": p.get("base_price", 0),
|
||||
"value_usdt": abs(p["amount"]) * p.get("base_price", 0),
|
||||
"last_update": time.time(),
|
||||
"source": "exchange",
|
||||
}
|
||||
|
||||
portfolio["last_updated"] = time.time()
|
||||
return portfolio
|
||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@ -0,0 +1,5 @@
|
||||
requests>=2.31.0
|
||||
pandas>=2.0.0
|
||||
ta>=0.11.0
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
174
risk_manager.py
Normal file
174
risk_manager.py
Normal file
@ -0,0 +1,174 @@
|
||||
import logging
|
||||
|
||||
import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def check_position_limit(symbol: str, amount_usdt: float, portfolio: dict) -> bool:
|
||||
"""Reject if a single position would exceed MAX_POSITION_PCT of total balance."""
|
||||
total_balance = portfolio.get("total_balance_usdt", 0)
|
||||
if total_balance <= 0:
|
||||
return False
|
||||
current_exposure = portfolio.get("positions", {}).get(symbol, {}).get("value_usdt", 0)
|
||||
new_exposure = current_exposure + amount_usdt
|
||||
limit = total_balance * config.MAX_POSITION_PCT
|
||||
if new_exposure > limit:
|
||||
logger.info(
|
||||
"Position limit: %s would be %.2f USDT (limit %.2f)",
|
||||
symbol, new_exposure, limit,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def check_total_exposure(portfolio: dict, additional_usdt: float = 0) -> bool:
|
||||
"""Reject if total exposure would exceed MAX_TOTAL_EXPOSURE_PCT."""
|
||||
total_balance = portfolio.get("total_balance_usdt", 0)
|
||||
if total_balance <= 0:
|
||||
return False
|
||||
current_exposure = sum(
|
||||
pos.get("value_usdt", 0) for pos in portfolio.get("positions", {}).values()
|
||||
)
|
||||
new_total = current_exposure + additional_usdt
|
||||
limit = total_balance * config.MAX_TOTAL_EXPOSURE_PCT
|
||||
if new_total > limit:
|
||||
logger.info(
|
||||
"Total exposure would be %.2f USDT (limit %.2f)", new_total, limit,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def apply_stop_loss_take_profit(positions: dict, current_prices: dict) -> list[dict]:
|
||||
"""Check positions for take-profit AND software stop-loss safety net.
|
||||
|
||||
Returns SELL signals for:
|
||||
- Take-profit hits (pnl >= TAKE_PROFIT_PCT)
|
||||
- Stop-loss safety net (pnl <= -STOP_LOSS_PCT) — second line of defence
|
||||
in case the exchange stop order was cancelled or failed.
|
||||
"""
|
||||
signals = []
|
||||
for sym, pos in positions.items():
|
||||
if pos.get("amount", 0) == 0:
|
||||
continue
|
||||
entry = pos.get("entry_price", 0)
|
||||
if entry <= 0:
|
||||
continue
|
||||
current = current_prices.get(sym, 0)
|
||||
if current <= 0:
|
||||
continue
|
||||
|
||||
pnl_pct = (current - entry) / entry
|
||||
|
||||
if pnl_pct >= config.TAKE_PROFIT_PCT:
|
||||
signals.append({
|
||||
"symbol": sym,
|
||||
"action": "SELL",
|
||||
"reason": f"停利 ({pnl_pct:+.2%})",
|
||||
"confidence": 1.0,
|
||||
"suggested_amount_pct": 1.0,
|
||||
})
|
||||
logger.info("TAKE PROFIT %s: %.6g → %.6g (%+.2f%%)", sym, entry, current, pnl_pct * 100)
|
||||
|
||||
elif pnl_pct <= -config.STOP_LOSS_PCT:
|
||||
signals.append({
|
||||
"symbol": sym,
|
||||
"action": "SELL",
|
||||
"reason": f"止損安全網 ({pnl_pct:+.2%})",
|
||||
"confidence": 1.0,
|
||||
"suggested_amount_pct": 1.0,
|
||||
})
|
||||
logger.warning(
|
||||
"STOP-LOSS SAFETY NET %s: %.6g → %.6g (%+.2f%%) — exchange stop order may have failed",
|
||||
sym, entry, current, pnl_pct * 100,
|
||||
)
|
||||
|
||||
return signals
|
||||
|
||||
|
||||
def adjust_size_by_volatility(amount_usdt: float, atr: float, price: float) -> float:
|
||||
"""Reduce position size when ATR is high relative to price."""
|
||||
if price <= 0 or atr <= 0:
|
||||
return amount_usdt
|
||||
atr_pct = atr / price
|
||||
# If ATR > 2%, scale down linearly (at 4% ATR → half size)
|
||||
if atr_pct > 0.02:
|
||||
factor = max(0.25, 0.02 / atr_pct)
|
||||
adjusted = amount_usdt * factor
|
||||
logger.info("Volatility adj: %.2f → %.2f USDT (ATR %.2f%%)", amount_usdt, adjusted, atr_pct * 100)
|
||||
return adjusted
|
||||
return amount_usdt
|
||||
|
||||
|
||||
def validate_trade(signal: dict, portfolio: dict, indicators_df=None) -> tuple[dict | None, str]:
|
||||
"""Run all risk checks. Returns (signal, "") on success or (None, reason) on rejection."""
|
||||
action = signal.get("action", "HOLD")
|
||||
if action == "HOLD":
|
||||
return None, "HOLD signal"
|
||||
|
||||
symbol = signal["symbol"]
|
||||
total_balance = portfolio.get("total_balance_usdt", 0)
|
||||
pct = signal.get("suggested_amount_pct", 0.1)
|
||||
amount_usdt = total_balance * pct
|
||||
|
||||
if action == "BUY":
|
||||
# Check minimum order
|
||||
if amount_usdt < config.MIN_ORDER_USDT:
|
||||
reason = f"Order too small: {amount_usdt:.2f} < {config.MIN_ORDER_USDT:.2f} USDT"
|
||||
logger.info(reason)
|
||||
return None, reason
|
||||
|
||||
# Volatility adjustment
|
||||
if indicators_df is not None and not indicators_df.empty:
|
||||
last = indicators_df.iloc[-1]
|
||||
atr = last.get("atr", 0)
|
||||
price = last.get("close", 0)
|
||||
if atr and price:
|
||||
amount_usdt = adjust_size_by_volatility(amount_usdt, atr, price)
|
||||
|
||||
# Available balance check — cap to available instead of rejecting
|
||||
available = portfolio.get("available_usdt", 0)
|
||||
if amount_usdt > available:
|
||||
if available < config.MIN_ORDER_USDT:
|
||||
return None, f"可用餘額不足 ({available:.2f} < {config.MIN_ORDER_USDT:.2f} USDT)"
|
||||
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)
|
||||
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, {})
|
||||
if pos.get("amount", 0) <= 0:
|
||||
logger.info("No position to sell for %s", symbol)
|
||||
return None, "無持倉,跳過"
|
||||
# SELL: 一律全部賣出
|
||||
pos_value = pos.get("value_usdt", 0)
|
||||
if pos_value <= 0:
|
||||
pos_value = pos.get("amount", 0) * pos.get("entry_price", 0)
|
||||
amount_usdt = pos_value
|
||||
|
||||
signal = dict(signal)
|
||||
signal["amount_usdt"] = amount_usdt
|
||||
return signal, ""
|
||||
147
slack_notifier.py
Normal file
147
slack_notifier.py
Normal file
@ -0,0 +1,147 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _send(payload: dict):
|
||||
"""Send a message to Slack via webhook."""
|
||||
url = config.SLACK_WEBHOOK_URL
|
||||
if not url:
|
||||
logger.debug("Slack webhook not configured, skipping notification")
|
||||
return
|
||||
try:
|
||||
resp = requests.post(url, json=payload, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
logger.warning("Slack webhook returned %d: %s", resp.status_code, resp.text)
|
||||
except Exception as e:
|
||||
logger.error("Failed to send Slack notification: %s", e)
|
||||
|
||||
|
||||
def send_trade_alert(trade: dict):
|
||||
"""Send a trade execution notification."""
|
||||
sym = trade.get("symbol", "?")
|
||||
name = config.SYMBOL_NAMES.get(sym, sym)
|
||||
action = trade.get("action", "?")
|
||||
emoji = "🟢 BUY" if action == "BUY" else "🔴 SELL"
|
||||
mode = trade.get("mode", "paper").upper()
|
||||
|
||||
text = (
|
||||
f"{emoji} *{name}* [{mode}]\n"
|
||||
f"Amount: {trade.get('amount', 0):.6f} (~{trade.get('amount_usdt', 0):.2f} USDT)\n"
|
||||
f"Price: {trade.get('price', 0):.6g}\n"
|
||||
f"Confidence: {trade.get('confidence', 0):.0%}\n"
|
||||
f"Reason: {trade.get('reason', 'N/A')}\n"
|
||||
f"Status: {trade.get('status', 'unknown')}"
|
||||
)
|
||||
_send({"text": text})
|
||||
|
||||
|
||||
def send_status_update(summary: str):
|
||||
"""Send periodic portfolio status update."""
|
||||
_send({"text": summary})
|
||||
|
||||
|
||||
def send_error_alert(error_msg: str):
|
||||
"""Send an error alert."""
|
||||
_send({"text": f"⚠️ *Trading Bot Error*\n```{error_msg}```"})
|
||||
|
||||
|
||||
def send_cycle_report(
|
||||
cycle_number: int,
|
||||
signals: list[dict],
|
||||
executed: list[dict],
|
||||
rejected: list[dict],
|
||||
tp_closed: list[dict],
|
||||
portfolio_summary: str,
|
||||
):
|
||||
"""Send a unified cycle report combining analysis, execution, and portfolio status."""
|
||||
lines = [f"📊 *Trading Cycle Report #{cycle_number}*\n"]
|
||||
|
||||
# --- Take-profit / auto actions ---
|
||||
if tp_closed:
|
||||
lines.append("⚡ *自動操作:*")
|
||||
for tp in tp_closed:
|
||||
name = config.SYMBOL_NAMES.get(tp["symbol"], tp["symbol"])
|
||||
pnl = tp.get("reason", "")
|
||||
amount_usdt = tp.get("amount_usdt", 0)
|
||||
lines.append(f" 🔴 SELL {name} — {pnl},平倉 {amount_usdt:.1f} USDT")
|
||||
lines.append("")
|
||||
|
||||
# --- LLM signals ---
|
||||
buys = [s for s in signals if s.get("action") == "BUY"]
|
||||
sells = [s for s in signals if s.get("action") == "SELL"]
|
||||
holds = [s for s in signals if s.get("action") == "HOLD"]
|
||||
|
||||
lines.append("🤖 *LLM 分析結果:*")
|
||||
if buys:
|
||||
for s in buys:
|
||||
name = config.SYMBOL_NAMES.get(s["symbol"], s["symbol"])
|
||||
lines.append(
|
||||
f" 🟢 BUY {name} — conf {s.get('confidence', 0):.0%}, {s.get('reason', '')}"
|
||||
)
|
||||
if sells:
|
||||
for s in sells:
|
||||
name = config.SYMBOL_NAMES.get(s["symbol"], s["symbol"])
|
||||
lines.append(
|
||||
f" 🔴 SELL {name} — conf {s.get('confidence', 0):.0%}, {s.get('reason', '')}"
|
||||
)
|
||||
if holds:
|
||||
lines.append(f" ⏸️ 其餘 {len(holds)} 幣種 HOLD")
|
||||
if not buys and not sells:
|
||||
lines.append(" All symbols → HOLD, no action this cycle.")
|
||||
lines.append("")
|
||||
|
||||
# --- Executed trades ---
|
||||
has_trades = bool(executed)
|
||||
if executed:
|
||||
lines.append("✅ *實際執行:*")
|
||||
for t in executed:
|
||||
name = config.SYMBOL_NAMES.get(t["symbol"], t["symbol"])
|
||||
action = t.get("action", "?")
|
||||
emoji = "🟢 BUY" if action == "BUY" else "🔴 SELL"
|
||||
amount = t.get("amount", 0)
|
||||
price = t.get("price", 0)
|
||||
amount_usdt = t.get("amount_usdt", 0)
|
||||
detail = f"{amount:.4f} @ {price:.6g} ({amount_usdt:.1f} USDT)"
|
||||
stop_info = ""
|
||||
if action == "BUY" and t.get("stop_price"):
|
||||
stop_info = f" — 止損掛 {t['stop_price']:.6g}"
|
||||
lines.append(f" {emoji} {name} — {detail}{stop_info}")
|
||||
lines.append("")
|
||||
|
||||
# --- Rejected signals ---
|
||||
if rejected:
|
||||
lines.append("❌ *未執行:*")
|
||||
for r in rejected:
|
||||
name = config.SYMBOL_NAMES.get(r["symbol"], r["symbol"])
|
||||
action = r.get("action", "?")
|
||||
emoji = "🟢 BUY" if action == "BUY" else "🔴 SELL"
|
||||
lines.append(f" {emoji} {name} — {r.get('reject_reason', '未知原因')}")
|
||||
lines.append("")
|
||||
|
||||
# --- Portfolio summary ---
|
||||
lines.append(f"💰 {portfolio_summary}")
|
||||
|
||||
text = "\n".join(lines)
|
||||
# @channel when actual trades were executed
|
||||
if has_trades:
|
||||
text = f"<!channel>\n{text}"
|
||||
|
||||
_send({"text": text})
|
||||
|
||||
|
||||
def send_startup_message():
|
||||
"""Notify that the bot has started."""
|
||||
mode = "PAPER" if config.PAPER_TRADING else "LIVE"
|
||||
_send({
|
||||
"text": (
|
||||
f"🚀 *Trading Bot Started* [{mode} mode]\n"
|
||||
f"Monitoring {len(config.TOP_15_SYMBOLS)} symbols\n"
|
||||
f"Interval: {config.RUN_INTERVAL_MINUTES}min"
|
||||
)
|
||||
})
|
||||
43
trade_logger.py
Normal file
43
trade_logger.py
Normal file
@ -0,0 +1,43 @@
|
||||
"""Persistent trade history logger — appends every trade to a CSV file."""
|
||||
|
||||
import csv
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
import config
|
||||
|
||||
TRADE_LOG_FILE = "trade_history.csv"
|
||||
FIELDS = [
|
||||
"timestamp", "datetime", "symbol", "name", "action", "amount",
|
||||
"price", "amount_usdt", "confidence", "reason", "mode", "status",
|
||||
]
|
||||
|
||||
|
||||
def _ensure_header():
|
||||
if not os.path.exists(TRADE_LOG_FILE):
|
||||
with open(TRADE_LOG_FILE, "w", newline="") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=FIELDS)
|
||||
writer.writeheader()
|
||||
|
||||
|
||||
def log_trade(trade: dict):
|
||||
"""Append a trade record to the CSV log."""
|
||||
_ensure_header()
|
||||
sym = trade.get("symbol", "")
|
||||
row = {
|
||||
"timestamp": trade.get("timestamp", ""),
|
||||
"datetime": datetime.now().isoformat(timespec="seconds"),
|
||||
"symbol": sym,
|
||||
"name": config.SYMBOL_NAMES.get(sym, sym),
|
||||
"action": trade.get("action", ""),
|
||||
"amount": trade.get("amount", 0),
|
||||
"price": trade.get("price", 0),
|
||||
"amount_usdt": trade.get("amount_usdt", 0),
|
||||
"confidence": trade.get("confidence", 0),
|
||||
"reason": trade.get("reason", ""),
|
||||
"mode": trade.get("mode", ""),
|
||||
"status": trade.get("status", ""),
|
||||
}
|
||||
with open(TRADE_LOG_FILE, "a", newline="") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=FIELDS)
|
||||
writer.writerow(row)
|
||||
156
trader.py
Normal file
156
trader.py
Normal file
@ -0,0 +1,156 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
import config
|
||||
from data_fetcher import _auth_post
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def execute_trade(signal: dict, current_prices: dict, mode: str | None = None) -> dict | None:
|
||||
"""Execute a trade signal. Returns trade result dict or None on failure."""
|
||||
mode = mode or ("paper" if config.PAPER_TRADING else "live")
|
||||
symbol = signal["symbol"]
|
||||
action = signal["action"]
|
||||
amount_usdt = signal.get("amount_usdt", 0)
|
||||
price = current_prices.get(symbol, 0)
|
||||
|
||||
if price <= 0:
|
||||
logger.error("No price for %s, cannot execute trade", symbol)
|
||||
return None
|
||||
|
||||
# Calculate amount in base currency
|
||||
amount = amount_usdt / price
|
||||
if action == "SELL":
|
||||
amount = -abs(amount) # negative = sell on Bitfinex
|
||||
|
||||
trade_result = {
|
||||
"symbol": symbol,
|
||||
"action": action,
|
||||
"amount": abs(amount),
|
||||
"amount_usdt": amount_usdt,
|
||||
"price": price,
|
||||
"timestamp": time.time(),
|
||||
"mode": mode,
|
||||
"reason": signal.get("reason", ""),
|
||||
"confidence": signal.get("confidence", 0),
|
||||
}
|
||||
|
||||
if mode == "paper":
|
||||
trade_result["status"] = "filled"
|
||||
trade_result["order_id"] = f"paper_{int(time.time() * 1000)}"
|
||||
logger.info(
|
||||
"PAPER %s %s: %.6f @ %.6g (%.2f USDT)",
|
||||
action, symbol, abs(amount), price, amount_usdt,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
order = _submit_order(symbol, amount)
|
||||
trade_result["status"] = "submitted"
|
||||
trade_result["order_id"] = order.get("id", "unknown")
|
||||
logger.info(
|
||||
"LIVE %s %s: %.6f @ %.6g (%.2f USDT) — order %s",
|
||||
action, symbol, abs(amount), price, amount_usdt, trade_result["order_id"],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Order submission failed for %s: %s", symbol, e)
|
||||
trade_result["status"] = "failed"
|
||||
trade_result["error"] = str(e)
|
||||
|
||||
return trade_result
|
||||
|
||||
|
||||
def place_stop_loss_order(symbol: str, amount: float, entry_price: float, mode: str | None = None) -> dict | None:
|
||||
"""Place an EXCHANGE STOP order as stop-loss after a BUY.
|
||||
|
||||
Args:
|
||||
symbol: Trading pair (e.g. tBTCUST)
|
||||
amount: Position size in base currency (positive, will be negated for sell)
|
||||
entry_price: The buy entry price
|
||||
mode: "paper" or "live"
|
||||
|
||||
Returns:
|
||||
dict with stop order info, or None on failure.
|
||||
"""
|
||||
mode = mode or ("paper" if config.PAPER_TRADING else "live")
|
||||
stop_price = round(entry_price * (1 - config.STOP_LOSS_PCT), 8)
|
||||
sell_amount = -abs(amount) # negative = sell
|
||||
|
||||
if mode == "paper":
|
||||
order_id = f"paper_sl_{int(time.time() * 1000)}"
|
||||
logger.info(
|
||||
"PAPER STOP-LOSS placed: %s sell %.6f @ %.6g (entry %.6g, SL %.1f%%)",
|
||||
symbol, abs(amount), stop_price, entry_price, config.STOP_LOSS_PCT * 100,
|
||||
)
|
||||
return {"order_id": order_id, "stop_price": stop_price, "symbol": symbol, "mode": "paper"}
|
||||
|
||||
# Live: submit EXCHANGE STOP order to Bitfinex
|
||||
try:
|
||||
body = {
|
||||
"type": "EXCHANGE STOP",
|
||||
"symbol": symbol,
|
||||
"amount": str(sell_amount),
|
||||
"price": str(stop_price),
|
||||
}
|
||||
result = _auth_post("/v2/auth/w/order/submit", body)
|
||||
order_id = "unknown"
|
||||
if isinstance(result, list) and len(result) > 4:
|
||||
order_data = result[4]
|
||||
if isinstance(order_data, list) and len(order_data) > 0:
|
||||
order = order_data[0] if isinstance(order_data[0], list) else order_data
|
||||
order_id = order[0]
|
||||
logger.info(
|
||||
"LIVE STOP-LOSS placed: %s sell %.6f @ %.6g — order %s",
|
||||
symbol, abs(amount), stop_price, order_id,
|
||||
)
|
||||
return {"order_id": order_id, "stop_price": stop_price, "symbol": symbol, "mode": "live"}
|
||||
except Exception as e:
|
||||
logger.error("Failed to place stop-loss for %s: %s", symbol, e)
|
||||
return None
|
||||
|
||||
|
||||
def cancel_order(order_id, mode: str | None = None) -> bool:
|
||||
"""Cancel an existing order by ID."""
|
||||
mode = mode or ("paper" if config.PAPER_TRADING else "live")
|
||||
|
||||
if mode == "paper":
|
||||
logger.info("PAPER cancel order: %s", order_id)
|
||||
return True
|
||||
|
||||
try:
|
||||
_auth_post("/v2/auth/w/order/cancel", {"id": int(order_id)})
|
||||
logger.info("LIVE cancel order: %s", order_id)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to cancel order %s: %s", order_id, e)
|
||||
return False
|
||||
|
||||
|
||||
def _submit_order(symbol: str, amount: float) -> dict:
|
||||
"""Submit an EXCHANGE MARKET order to Bitfinex."""
|
||||
body = {
|
||||
"type": "EXCHANGE MARKET",
|
||||
"symbol": symbol,
|
||||
"amount": str(amount),
|
||||
}
|
||||
result = _auth_post("/v2/auth/w/order/submit", body)
|
||||
if isinstance(result, list) and len(result) > 4:
|
||||
order_data = result[4]
|
||||
if isinstance(order_data, list) and len(order_data) > 0:
|
||||
order = order_data[0] if isinstance(order_data[0], list) else order_data
|
||||
return {"id": order[0], "raw": order}
|
||||
return {"id": "unknown", "raw": result}
|
||||
|
||||
|
||||
def get_wallet_balance() -> float:
|
||||
"""Get USDT balance from exchange wallet."""
|
||||
if config.PAPER_TRADING and not config.BFX_API_KEY:
|
||||
return 0
|
||||
try:
|
||||
wallets = _auth_post("/v2/auth/r/wallets")
|
||||
for w in wallets:
|
||||
if w[0] == "exchange" and w[1] in ("UST", "USDT"):
|
||||
return float(w[2])
|
||||
except Exception as e:
|
||||
logger.error("Failed to get wallet balance: %s", e)
|
||||
return 0
|
||||
Loading…
Reference in New Issue
Block a user