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

170 lines
5.7 KiB
Python

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