82c68fa8b0
- Switch to oil stocks (USO, XLE, OXY, CVX, XOM, SLB, HAL, DVN, MPC, VLO) - Add JPY/USD forex pairs for Japan targeting - 7-action RL space: long, short, close (was 5 long-only actions) - Bollinger Band mean-reversion scalp entries both directions - 5-minute candles with 60-second cycles for scalping - 35 features (added VWAP, fast RSI, fast ROC for scalping) - Short position support in backtest, executor, and RL environment - GA tuned for scalping: tighter SL/TP, shorter hold times Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
322 lines
12 KiB
Python
322 lines
12 KiB
Python
"""
|
|
OANDA broker interface for forex paper trading.
|
|
Uses OANDA v20 REST API - no extra dependency, just requests.
|
|
"""
|
|
|
|
import requests
|
|
from datetime import datetime, timedelta
|
|
from typing import Dict, List, Optional
|
|
from loguru import logger
|
|
|
|
|
|
class OandaBroker:
|
|
"""OANDA practice (paper) trading broker interface"""
|
|
|
|
PRACTICE_URL = "https://api-fxpractice.oanda.com"
|
|
LIVE_URL = "https://api-fxtrade.oanda.com"
|
|
|
|
# Forex pairs trade 24/5 (Sun 5PM ET to Fri 5PM ET)
|
|
# Map our timeframes to OANDA granularity
|
|
TF_MAP = {
|
|
'1m': 'M1', '5m': 'M5', '15m': 'M15',
|
|
'1h': 'H1', '4h': 'H4', '1d': 'D',
|
|
}
|
|
|
|
def __init__(self, config: Dict):
|
|
self.config = config
|
|
self.api_token = config['api_token']
|
|
self.account_id = config['account_id']
|
|
self.base_url = self.PRACTICE_URL if config.get('practice', True) else self.LIVE_URL
|
|
|
|
self.session = requests.Session()
|
|
self.session.headers.update({
|
|
'Authorization': f'Bearer {self.api_token}',
|
|
'Content-Type': 'application/json',
|
|
})
|
|
|
|
# Verify connection
|
|
try:
|
|
acct = self._get(f'/v3/accounts/{self.account_id}/summary')
|
|
balance = acct['account']['balance']
|
|
currency = acct['account']['currency']
|
|
logger.info(f"OANDA connected (PRACTICE) - Balance: {currency} {balance}")
|
|
except Exception as e:
|
|
logger.error(f"OANDA connection failed: {e}")
|
|
raise
|
|
|
|
def _get(self, path: str, params: Dict = None) -> Dict:
|
|
resp = self.session.get(f'{self.base_url}{path}', params=params, timeout=15)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
def _post(self, path: str, data: Dict) -> Dict:
|
|
resp = self.session.post(f'{self.base_url}{path}', json=data, timeout=15)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
def _put(self, path: str, data: Dict) -> Dict:
|
|
resp = self.session.put(f'{self.base_url}{path}', json=data, timeout=15)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
# --- Account / Portfolio ---
|
|
|
|
def get_account(self) -> Dict:
|
|
data = self._get(f'/v3/accounts/{self.account_id}/summary')
|
|
return data['account']
|
|
|
|
def get_portfolio(self) -> Dict:
|
|
acct = self.get_account()
|
|
nav = float(acct['NAV'])
|
|
balance = float(acct['balance'])
|
|
unrealized_pl = float(acct['unrealizedPL'])
|
|
pl = float(acct['pl'])
|
|
return {
|
|
'equity': nav,
|
|
'cash': balance,
|
|
'buying_power': float(acct.get('marginAvailable', balance)),
|
|
'portfolio_value': nav,
|
|
'long_market_value': nav - balance,
|
|
'day_pnl': unrealized_pl,
|
|
'day_pnl_pct': (unrealized_pl / balance * 100) if balance > 0 else 0,
|
|
}
|
|
|
|
def get_positions(self) -> List[Dict]:
|
|
data = self._get(f'/v3/accounts/{self.account_id}/openPositions')
|
|
positions = []
|
|
for p in data.get('positions', []):
|
|
# OANDA separates long/short
|
|
long_units = int(p['long']['units']) if p['long']['units'] != '0' else 0
|
|
short_units = abs(int(p['short']['units'])) if p['short']['units'] != '0' else 0
|
|
|
|
if long_units > 0:
|
|
unrealized = float(p['long']['unrealizedPL'])
|
|
avg_price = float(p['long']['averagePrice'])
|
|
# Estimate current price from avg + pnl
|
|
current_price = avg_price + (unrealized / long_units) if long_units else avg_price
|
|
positions.append({
|
|
'symbol': p['instrument'],
|
|
'qty': long_units,
|
|
'market_value': long_units * current_price,
|
|
'cost_basis': long_units * avg_price,
|
|
'unrealized_pl': unrealized,
|
|
'unrealized_plpc': (unrealized / (long_units * avg_price) * 100)
|
|
if avg_price > 0 else 0,
|
|
'current_price': current_price,
|
|
'avg_entry_price': avg_price,
|
|
})
|
|
if short_units > 0:
|
|
unrealized = float(p['short']['unrealizedPL'])
|
|
avg_price = float(p['short']['averagePrice'])
|
|
current_price = avg_price - (unrealized / short_units) if short_units else avg_price
|
|
positions.append({
|
|
'symbol': p['instrument'],
|
|
'qty': -short_units,
|
|
'market_value': short_units * current_price,
|
|
'cost_basis': short_units * avg_price,
|
|
'unrealized_pl': unrealized,
|
|
'unrealized_plpc': (unrealized / (short_units * avg_price) * 100)
|
|
if avg_price > 0 else 0,
|
|
'current_price': current_price,
|
|
'avg_entry_price': avg_price,
|
|
})
|
|
return positions
|
|
|
|
def is_market_open(self) -> bool:
|
|
"""Forex is open 24/5 - closed Saturday and most of Sunday"""
|
|
now = datetime.utcnow()
|
|
# Closed: Friday 22:00 UTC to Sunday 22:00 UTC (roughly)
|
|
if now.weekday() == 5: # Saturday
|
|
return False
|
|
if now.weekday() == 6 and now.hour < 22: # Sunday before 22:00
|
|
return False
|
|
if now.weekday() == 4 and now.hour >= 22: # Friday after 22:00
|
|
return False
|
|
return True
|
|
|
|
def get_market_hours(self) -> Dict:
|
|
return {
|
|
'is_open': self.is_market_open(),
|
|
'next_open': None,
|
|
'next_close': None,
|
|
}
|
|
|
|
# --- Orders ---
|
|
|
|
def place_market_order(self, symbol: str, qty, side: str) -> Dict:
|
|
"""Place a market order. qty is in units (not lots)."""
|
|
units = int(qty) if side == 'buy' else -int(qty)
|
|
data = {
|
|
'order': {
|
|
'type': 'MARKET',
|
|
'instrument': symbol,
|
|
'units': str(units),
|
|
'timeInForce': 'FOK',
|
|
}
|
|
}
|
|
try:
|
|
result = self._post(f'/v3/accounts/{self.account_id}/orders', data)
|
|
fill = result.get('orderFillTransaction', {})
|
|
order_id = fill.get('id', result.get('orderCreateTransaction', {}).get('id', ''))
|
|
logger.info(f"OANDA {side.upper()} {abs(units)} {symbol}")
|
|
return {
|
|
'id': order_id,
|
|
'symbol': symbol,
|
|
'qty': abs(units),
|
|
'side': side,
|
|
'type': 'market',
|
|
'status': 'filled' if fill else 'pending',
|
|
'fill_price': float(fill.get('price', 0)) if fill else 0,
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"OANDA order error: {e}")
|
|
raise
|
|
|
|
def place_limit_order(self, symbol: str, qty, side: str, limit_price: float) -> Dict:
|
|
units = int(qty) if side == 'buy' else -int(qty)
|
|
data = {
|
|
'order': {
|
|
'type': 'LIMIT',
|
|
'instrument': symbol,
|
|
'units': str(units),
|
|
'price': f'{limit_price:.5f}',
|
|
'timeInForce': 'GTC',
|
|
}
|
|
}
|
|
result = self._post(f'/v3/accounts/{self.account_id}/orders', data)
|
|
order = result.get('orderCreateTransaction', {})
|
|
return {
|
|
'id': order.get('id', ''),
|
|
'symbol': symbol,
|
|
'qty': abs(units),
|
|
'side': side,
|
|
'type': 'limit',
|
|
'limit_price': limit_price,
|
|
'status': 'pending',
|
|
}
|
|
|
|
def cancel_order(self, order_id: str) -> bool:
|
|
try:
|
|
self._put(f'/v3/accounts/{self.account_id}/orders/{order_id}/cancel', {})
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
def get_orders(self, status: str = "open") -> List[Dict]:
|
|
state = 'PENDING' if status == 'open' else 'ALL'
|
|
data = self._get(f'/v3/accounts/{self.account_id}/orders', {'state': state})
|
|
return [
|
|
{
|
|
'id': o['id'],
|
|
'symbol': o.get('instrument', ''),
|
|
'qty': abs(int(o.get('units', 0))),
|
|
'side': 'buy' if int(o.get('units', 0)) > 0 else 'sell',
|
|
'type': o.get('type', '').lower(),
|
|
'status': o.get('state', '').lower(),
|
|
'created_at': o.get('createTime', ''),
|
|
}
|
|
for o in data.get('orders', [])
|
|
]
|
|
|
|
# --- Market Data ---
|
|
|
|
def get_bars(self, symbol: str, timeframe: str = "1d", limit: int = 100) -> List[Dict]:
|
|
gran = self.TF_MAP.get(timeframe, 'H1')
|
|
params = {'granularity': gran, 'count': min(limit, 5000)}
|
|
try:
|
|
data = self._get(f'/v3/instruments/{symbol}/candles', params)
|
|
return self._parse_candles(data.get('candles', []))
|
|
except Exception as e:
|
|
logger.error(f"OANDA bars error for {symbol}: {e}")
|
|
return []
|
|
|
|
def fetch_bars_range(self, symbol: str, timeframe: str = "1h",
|
|
start=None, end=None) -> List[Dict]:
|
|
gran = self.TF_MAP.get(timeframe, 'H1')
|
|
params = {'granularity': gran, 'price': 'M'}
|
|
|
|
if start:
|
|
if isinstance(start, datetime):
|
|
params['from'] = start.strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
else:
|
|
params['from'] = str(start)
|
|
if end:
|
|
if isinstance(end, datetime):
|
|
params['to'] = end.strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
else:
|
|
params['to'] = str(end)
|
|
|
|
if 'from' not in params:
|
|
params['count'] = 500
|
|
|
|
try:
|
|
data = self._get(f'/v3/instruments/{symbol}/candles', params)
|
|
return self._parse_candles(data.get('candles', []))
|
|
except Exception as e:
|
|
logger.error(f"OANDA bars range error for {symbol}: {e}")
|
|
return []
|
|
|
|
def get_latest_price(self, symbol: str) -> Optional[float]:
|
|
try:
|
|
data = self._get(f'/v3/instruments/{symbol}/candles',
|
|
{'granularity': 'M1', 'count': 1, 'price': 'M'})
|
|
candles = data.get('candles', [])
|
|
if candles:
|
|
return float(candles[-1]['mid']['c'])
|
|
return None
|
|
except Exception as e:
|
|
logger.error(f"OANDA price error for {symbol}: {e}")
|
|
return None
|
|
|
|
def close_position(self, symbol: str) -> bool:
|
|
"""Close entire position for an instrument"""
|
|
try:
|
|
# Close long
|
|
try:
|
|
self._put(f'/v3/accounts/{self.account_id}/positions/{symbol}/close',
|
|
{'longUnits': 'ALL'})
|
|
except Exception:
|
|
pass
|
|
# Close short
|
|
try:
|
|
self._put(f'/v3/accounts/{self.account_id}/positions/{symbol}/close',
|
|
{'shortUnits': 'ALL'})
|
|
except Exception:
|
|
pass
|
|
logger.info(f"Closed OANDA position: {symbol}")
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Error closing OANDA position {symbol}: {e}")
|
|
return False
|
|
|
|
# --- Helpers ---
|
|
|
|
def _parse_candles(self, candles: List[Dict]) -> List[Dict]:
|
|
"""Convert OANDA candles to our standard format"""
|
|
result = []
|
|
for c in candles:
|
|
if not c.get('complete', True) and len(candles) > 1:
|
|
continue # Skip incomplete candles unless it's the only one
|
|
mid = c.get('mid', {})
|
|
ts = c.get('time', '')
|
|
try:
|
|
dt = datetime.fromisoformat(ts.replace('Z', '+00:00'))
|
|
ts_ms = int(dt.timestamp() * 1000)
|
|
except (ValueError, AttributeError):
|
|
continue
|
|
result.append({
|
|
'timestamp': ts_ms,
|
|
'open': float(mid.get('o', 0)),
|
|
'high': float(mid.get('h', 0)),
|
|
'low': float(mid.get('l', 0)),
|
|
'close': float(mid.get('c', 0)),
|
|
'volume': int(c.get('volume', 0)),
|
|
})
|
|
return result
|
|
|
|
def get_tradeable_instruments(self) -> List[str]:
|
|
"""Get list of available forex instruments"""
|
|
data = self._get(f'/v3/accounts/{self.account_id}/instruments')
|
|
return [i['name'] for i in data.get('instruments', [])
|
|
if i.get('type') == 'CURRENCY']
|