- Fix realized_pnl always being 0 on SELL (use amount*price instead of amount_usdt) - Add debug logging for all-HOLD LLM cycles (log sample HOLD reasons) - Fix nonce collision in Bitfinex API auth (add counter) - Fix timezone to UTC+8 in main, trade_logger, sync_cost_basis, check_errors - Fix stop-loss retry with longer delay after cancel - Add min order amount check in trader before BUY - Fix risk_manager to skip positions with amount <= 0 - Cap trade_history to 500 entries to prevent unbounded growth - Fix greedy regex in LLM response parser - Reset cost_tracking start dates to null Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
184 lines
6.4 KiB
Python
184 lines
6.4 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
|
|
history = portfolio.setdefault("trade_history", [])
|
|
history.append({
|
|
"symbol": symbol,
|
|
"action": action,
|
|
"amount": amount,
|
|
"price": price,
|
|
"amount_usdt": amount_usdt,
|
|
"timestamp": time.time(),
|
|
})
|
|
if len(history) > 500:
|
|
portfolio["trade_history"] = history[-500:]
|
|
|
|
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")
|
|
cost_basis = total_value - total_unrealized
|
|
if cost_basis > 0:
|
|
lines.append(f"*Total Unrealized P&L:* {total_unrealized:+.2f} USDT ({total_unrealized / cost_basis * 100:+.2f}%)")
|
|
else:
|
|
lines.append(f"*Total Unrealized P&L:* {total_unrealized:+.2f} USDT")
|
|
|
|
initial_capital = portfolio.get("initial_capital", 0)
|
|
if initial_capital > 0:
|
|
total_return = total_value - initial_capital
|
|
total_return_pct = (total_value / initial_capital - 1) * 100
|
|
sign = "+" if total_return >= 0 else ""
|
|
lines.append(f"*Total Return:* {sign}{total_return:.2f} USDT ({sign}{total_return_pct:.2f}%)")
|
|
|
|
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
|