bifitnex-trading/risk_manager.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

175 lines
6.8 KiB
Python

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, ""