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>
102 lines
3.4 KiB
Python
102 lines
3.4 KiB
Python
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)
|