drl-trading-bot-dev2 / src /api /testnet_executor.py
DRL Trading Bot
Feature: HTF Agent integration β€” live trading, API endpoints, UI tab
fc115d5
Raw
History Blame Contribute Delete
20.6 kB
"""
Testnet Trade Executor
Mirrors bot trading decisions to Binance Testnet with real order execution.
Stores results in logs/testnet_trades.json (line-delimited JSON).
"""
import os
import json
import logging
from datetime import datetime
from pathlib import Path
from typing import Optional, Dict, List, Any
from .binance import BinanceConnector
logger = logging.getLogger(__name__)
TESTNET_TRADES_FILE = Path('logs/testnet_trades.json')
# Minimum trade value in USDT
MIN_TRADE_VALUE_USDT = 10.0
# Position size as fraction of testnet balance (mirrors bot's position_size=0.25)
POSITION_SIZE = 0.25
def _get_amount_precision(symbol: str) -> int:
"""Return decimal places for base currency amount."""
s = symbol.upper()
if 'BTC' in s:
return 5
if 'ETH' in s:
return 4
if 'SOL' in s:
return 2
if 'XRP' in s:
return 0
return 4
def _get_price_precision(symbol: str) -> int:
"""Return decimal places for price."""
s = symbol.upper()
if 'BTC' in s:
return 2
if 'ETH' in s:
return 2
if 'SOL' in s:
return 3
if 'XRP' in s:
return 4
return 2
def _extract_filled_price(order: Dict, reference_price: float) -> float:
"""
Extract the actual average filled price from an order response.
Handles both ccxt normalized responses and raw Binance API responses.
"""
# ccxt normalized 'average' field
avg = order.get('average')
if avg and float(avg) > 0:
return float(avg)
# Raw Binance API response: 'fills' array with price/qty per fill
fills = order.get('fills') or []
if fills:
total_qty = sum(float(f.get('qty', f.get('amount', 0))) for f in fills)
if total_qty > 0:
weighted = sum(
float(f.get('price', 0)) * float(f.get('qty', f.get('amount', 0)))
for f in fills
)
return weighted / total_qty
# ccxt info.fills (raw response nested inside ccxt wrapper)
info_fills = (order.get('info') or {}).get('fills', [])
if info_fills:
total_qty = sum(float(f.get('qty', 0)) for f in info_fills)
if total_qty > 0:
weighted = sum(float(f.get('price', 0)) * float(f.get('qty', 0)) for f in info_fills)
return weighted / total_qty
# Raw Binance 'price' field (set for LIMIT orders, may be 0 for MARKET)
raw_price = order.get('price')
if raw_price and float(raw_price) > 0:
return float(raw_price)
return reference_price
def _to_ccxt_symbol(symbol: str) -> str:
"""Convert BTCUSDT β†’ BTC/USDT for ccxt."""
if '/' in symbol:
return symbol
if symbol.endswith('USDT'):
base = symbol[:-4]
return f"{base}/USDT"
return symbol
class TestnetExecutor:
"""
Executes real orders on Binance Testnet mirroring bot decisions.
LONG positions β†’ real BUY spot orders (market 50% + limit 50%)
CLOSE LONG β†’ real SELL spot orders
SHORT/CLOSE SHORT β†’ conceptual (spot testnet has no shorting);
any held base currency is sold if present
"""
def __init__(self):
api_key = os.getenv('BINANCE_TESTNET_API_KEY', '').strip()
api_secret = os.getenv('BINANCE_TESTNET_API_SECRET', '').strip()
if not api_key or not api_secret:
raise ValueError("BINANCE_TESTNET_API_KEY / BINANCE_TESTNET_API_SECRET not set")
self.connector = BinanceConnector(
api_key=api_key,
api_secret=api_secret,
testnet=True,
)
# In-memory position tracking (symbol β†’ position dict)
self._positions: Dict[str, Dict] = {}
self._load_positions_from_trades()
# ── Position loading ──────────────────────────────────────────────────────
def _load_positions_from_trades(self):
"""Reconstruct current open positions from trade history."""
trades = self.get_trades(limit=10000)
self._positions = {}
for t in trades:
symbol = t.get('symbol', '')
action = t.get('action', '')
if not symbol:
continue
if 'OPEN_LONG' in action:
self._positions[symbol] = {
'side': 'LONG',
'entry_price': t.get('filled_price') or t.get('price', 0),
'amount': t.get('amount', 0),
'sl': t.get('sl', 0),
'tp': t.get('tp', 0),
'timestamp': t.get('timestamp'),
'confidence': t.get('confidence', 0),
'order_id': t.get('order_id', ''),
'simulated': False,
}
elif 'CLOSE_LONG' in action or action in ('STOP_LOSS', 'TAKE_PROFIT', 'TRAILING_STOP'):
self._positions.pop(symbol, None)
elif 'OPEN_SHORT' in action:
self._positions[symbol] = {
'side': 'SHORT',
'entry_price': t.get('price', 0),
'amount': t.get('amount', 0),
'sl': t.get('sl', 0),
'tp': t.get('tp', 0),
'timestamp': t.get('timestamp'),
'confidence': t.get('confidence', 0),
'order_id': t.get('order_id', ''),
'simulated': True, # spot testnet β€” conceptual short
}
elif 'CLOSE_SHORT' in action:
self._positions.pop(symbol, None)
# ── Public API ────────────────────────────────────────────────────────────
def mirror_trade(self, bot_trade: Dict, bot_result: Dict) -> Optional[Dict]:
"""
Mirror a bot trade decision to Binance Testnet.
Args:
bot_trade: dict returned by MultiAssetTradingBot.execute_trade()
bot_result: dict returned by MultiAssetTradingBot.run_iteration()
Returns:
Testnet trade record saved to file, or None if no action taken.
"""
if not bot_trade:
return None
action = bot_trade.get('action', '')
symbol = bot_trade.get('symbol', '')
current_price = float(bot_trade.get('price', 0) or 0)
sl = float(bot_trade.get('sl', 0) or 0)
tp = float(bot_trade.get('tp', 0) or 0)
confidence = float(bot_trade.get('confidence', 0) or 0)
units = float(bot_trade.get('units', 0) or 0)
pnl = float(bot_trade.get('pnl', 0) or 0)
if not symbol or current_price <= 0:
return None
ccxt_symbol = _to_ccxt_symbol(symbol)
record: Dict[str, Any] = {
'symbol': symbol,
'ccxt_symbol': ccxt_symbol,
'action': action,
'price': current_price,
'filled_price': None,
'amount': 0.0,
'side': None,
'sl': sl,
'tp': tp,
'confidence': confidence,
'timestamp': datetime.now().isoformat(),
'order_id': None,
'executed': False,
'error': None,
'pnl': None,
'dry_run': False,
}
try:
if 'OPEN_LONG' in action:
record = self._execute_open_long(
record, ccxt_symbol, current_price, confidence, sl, tp
)
elif 'CLOSE_LONG' in action:
record = self._execute_close_long(record, ccxt_symbol, current_price, pnl)
elif 'OPEN_SHORT' in action:
record = self._execute_open_short(
record, ccxt_symbol, current_price, confidence, sl, tp
)
elif 'CLOSE_SHORT' in action:
record = self._execute_close_short(record, ccxt_symbol, current_price, pnl)
else:
logger.warning(f"TestnetExecutor: unknown action '{action}' for {symbol}")
return None
except Exception as exc:
logger.error(f"TestnetExecutor error ({action} {symbol}): {exc}", exc_info=True)
record['error'] = str(exc)
self._save_trade(record)
return record
def get_current_positions(self) -> List[Dict]:
"""Return open positions enriched with live price + unrealized PNL."""
result = []
for symbol, pos in list(self._positions.items()):
try:
ccxt_symbol = _to_ccxt_symbol(symbol)
ticker = self.connector.get_ticker(ccxt_symbol)
current_price = float(ticker.get('last', 0) or 0)
entry_price = float(pos.get('entry_price', 0) or 0)
amount = float(pos.get('amount', 0) or 0)
side = pos.get('side', 'LONG')
if entry_price > 0 and current_price > 0:
if side == 'LONG':
upnl = (current_price - entry_price) * amount
upnl_pct = (current_price - entry_price) / entry_price * 100
else:
upnl = (entry_price - current_price) * amount
upnl_pct = (entry_price - current_price) / entry_price * 100
else:
upnl = 0.0
upnl_pct = 0.0
result.append({
'symbol': symbol,
'side': side,
'entry_price': entry_price,
'current_price': current_price,
'amount': amount,
'sl': float(pos.get('sl', 0) or 0),
'tp': float(pos.get('tp', 0) or 0),
'unrealized_pnl': upnl,
'unrealized_pnl_pct': upnl_pct,
'confidence': float(pos.get('confidence', 0) or 0),
'timestamp': pos.get('timestamp'),
'order_id': pos.get('order_id', ''),
'simulated': bool(pos.get('simulated', False)),
})
except Exception as exc:
logger.error(f"TestnetExecutor: position fetch failed for {symbol}: {exc}")
return result
def get_trades(self, limit: int = 100) -> List[Dict]:
"""Return testnet trade history (oldest first, capped at limit)."""
if not TESTNET_TRADES_FILE.exists():
return []
trades = []
try:
with open(TESTNET_TRADES_FILE, 'r') as fh:
for line in fh:
line = line.strip()
if line:
try:
trades.append(json.loads(line))
except json.JSONDecodeError:
pass
except Exception as exc:
logger.error(f"TestnetExecutor: failed to read trades file: {exc}")
return trades[-limit:]
def get_pnl_summary(self) -> Dict:
"""Compute PNL summary + equity curve data from trade history."""
trades = self.get_trades(limit=10000)
realized_pnl = sum(float(t.get('pnl', 0) or 0) for t in trades)
positions = self.get_current_positions()
unrealized_pnl = sum(float(p.get('unrealized_pnl', 0) or 0) for p in positions)
closed_trades = [t for t in trades if t.get('pnl') is not None and t.get('pnl') != 0]
winning = [t for t in closed_trades if float(t.get('pnl', 0) or 0) > 0]
win_rate = len(winning) / max(1, len(closed_trades))
# Equity curve: cumulative realized PNL per trade timestamp
equity_curve = []
cumulative = 0.0
for t in trades:
pnl_val = float(t.get('pnl', 0) or 0)
if pnl_val != 0:
cumulative += pnl_val
equity_curve.append({
'timestamp': t.get('timestamp', ''),
'cumulative_pnl': round(cumulative, 4),
'trade_pnl': round(pnl_val, 4),
'symbol': t.get('symbol', ''),
})
return {
'realized_pnl': round(realized_pnl, 4),
'unrealized_pnl': round(unrealized_pnl, 4),
'total_pnl': round(realized_pnl + unrealized_pnl, 4),
'total_trades': len(trades),
'closed_trades': len(closed_trades),
'winning_trades': len(winning),
'win_rate': round(win_rate, 4),
'equity_curve': equity_curve,
}
# ── Private execution helpers ─────────────────────────────────────────────
def _execute_open_long(
self, record: Dict, ccxt_symbol: str, price: float,
confidence: float, sl: float, tp: float
) -> Dict:
"""Open LONG: 50% market buy + 50% limit buy at -0.5%."""
usdt_balance = self.connector.get_balance('USDT')
if usdt_balance <= 0:
record['error'] = "No USDT balance available"
return record
base_value = usdt_balance * POSITION_SIZE
# Scale by confidence (clamp to [0.5, 1.0] to avoid zero trades)
conf_scale = max(0.5, min(1.0, confidence)) if confidence > 0 else 0.75
scaled_value = base_value * conf_scale
if scaled_value < MIN_TRADE_VALUE_USDT:
record['error'] = f"Trade value ${scaled_value:.2f} below minimum ${MIN_TRADE_VALUE_USDT}"
return record
prec = _get_amount_precision(ccxt_symbol)
pprec = _get_price_precision(ccxt_symbol)
# 50% market order
market_value = scaled_value * 0.50
market_amount = round(market_value / price, prec)
if market_amount <= 0:
record['error'] = "Calculated market amount is zero"
return record
market_order = self.connector.place_market_order(
symbol=ccxt_symbol, side='buy', amount=market_amount
)
if not market_order:
record['error'] = "Market BUY order failed"
return record
filled_price = _extract_filled_price(market_order, price)
record['order_id'] = str(
market_order.get('orderId') or market_order.get('id') or ''
)
record['amount'] = market_amount
record['filled_price'] = filled_price
record['side'] = 'BUY'
record['executed'] = True
# 50% limit order at 0.5% dip
limit_price = round(price * 0.995, pprec)
limit_amount = round((scaled_value * 0.50) / limit_price, prec)
if limit_amount > 0:
limit_order = self.connector.place_limit_order(
symbol=ccxt_symbol, side='buy', amount=limit_amount, price=limit_price
)
if limit_order:
record['limit_order_id'] = str(
limit_order.get('orderId') or limit_order.get('id') or ''
)
record['limit_price'] = limit_price
record['limit_amount'] = limit_amount
# Track position
self._positions[record['symbol']] = {
'side': 'LONG',
'entry_price': filled_price,
'amount': market_amount,
'sl': sl,
'tp': tp,
'timestamp': record['timestamp'],
'confidence': confidence,
'order_id': record['order_id'],
'simulated': False,
}
logger.info(
f"πŸ§ͺ TESTNET LONG opened: {ccxt_symbol} market {market_amount} @ "
f"${filled_price:,.2f} | SL=${sl:,.2f} TP=${tp:,.2f}"
)
return record
def _execute_close_long(
self, record: Dict, ccxt_symbol: str, price: float, pnl: float
) -> Dict:
"""Close LONG: sell all held base currency at market."""
base_currency = ccxt_symbol.split('/')[0]
balance = self.connector.get_balance(base_currency)
if balance <= 0:
record['error'] = f"No {base_currency} balance to sell"
# Still clear position tracking
self._positions.pop(record['symbol'], None)
return record
prec = _get_amount_precision(ccxt_symbol)
sell_amount = round(balance, prec)
order = self.connector.place_market_order(
symbol=ccxt_symbol, side='sell', amount=sell_amount
)
if not order:
record['error'] = "Market SELL order failed"
return record
filled_price = _extract_filled_price(order, price)
record['order_id'] = str(order.get('orderId') or order.get('id') or '')
record['amount'] = sell_amount
record['filled_price'] = filled_price
record['side'] = 'SELL'
record['executed'] = True
record['pnl'] = pnl
self._positions.pop(record['symbol'], None)
logger.info(
f"πŸ§ͺ TESTNET LONG closed: {ccxt_symbol} sold {sell_amount} @ "
f"${filled_price:,.2f} | PNL=${pnl:+.2f}"
)
return record
def _execute_open_short(
self, record: Dict, ccxt_symbol: str, price: float,
confidence: float, sl: float, tp: float
) -> Dict:
"""
Open SHORT (conceptual on spot testnet).
If base currency is held, sell it; record position as simulated short.
"""
base_currency = ccxt_symbol.split('/')[0]
balance = self.connector.get_balance(base_currency)
record['side'] = 'SHORT_SIMULATED'
record['executed'] = True
record['note'] = (
'Spot testnet cannot truly short. '
'Sold any held base currency; position tracked conceptually.'
)
if balance > 0:
prec = _get_amount_precision(ccxt_symbol)
sell_amount = round(balance, prec)
order = self.connector.place_market_order(
symbol=ccxt_symbol, side='sell', amount=sell_amount
)
if order:
filled_price = _extract_filled_price(order, price)
record['order_id'] = str(order.get('orderId') or order.get('id') or '')
record['amount'] = sell_amount
record['filled_price'] = filled_price
logger.info(
f"πŸ§ͺ TESTNET SHORT (sim): sold {sell_amount} {base_currency} @ "
f"${filled_price:,.2f} to open conceptual short"
)
else:
record['error'] = "Sell order for short failed"
# Track as conceptual short
self._positions[record['symbol']] = {
'side': 'SHORT',
'entry_price': price,
'amount': float(record.get('amount', 0)),
'sl': sl,
'tp': tp,
'timestamp': record['timestamp'],
'confidence': confidence,
'order_id': record.get('order_id', ''),
'simulated': True,
}
return record
def _execute_close_short(
self, record: Dict, ccxt_symbol: str, price: float, pnl: float
) -> Dict:
"""Close SHORT (conceptual on spot testnet)."""
record['side'] = 'CLOSE_SHORT'
record['executed'] = True
record['pnl'] = pnl
record['note'] = 'Conceptual short closed (spot testnet β€” no real short was held)'
self._positions.pop(record['symbol'], None)
logger.info(f"πŸ§ͺ TESTNET SHORT (sim) closed: {ccxt_symbol} | PNL=${pnl:+.2f}")
return record
# ── Storage ───────────────────────────────────────────────────────────────
def _save_trade(self, trade: Dict):
"""Append a trade record to the testnet trades log file."""
try:
TESTNET_TRADES_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(TESTNET_TRADES_FILE, 'a') as fh:
fh.write(json.dumps(trade) + '\n')
except Exception as exc:
logger.error(f"TestnetExecutor: failed to save trade: {exc}")
def get_testnet_executor() -> Optional[TestnetExecutor]:
"""Factory: create TestnetExecutor if API keys are configured."""
try:
return TestnetExecutor()
except Exception as exc:
logger.warning(f"TestnetExecutor unavailable: {exc}")
return None