Files
biggfish/src/trading/oanda_broker.py
T
sami7777 82c68fa8b0 Scalping strategy overhaul: bidirectional trading, oil/JPY focus
- 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>
2026-03-12 16:45:19 -07:00

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']