From f6fa9bdbb6918764d6b6cee82593a69021910638 Mon Sep 17 00:00:00 2001 From: Mark Veidemanis Date: Sun, 30 Oct 2022 10:57:53 +0000 Subject: [PATCH] Begin implementing OANDA --- core/exchanges/__init__.py | 42 ++++++++++ core/exchanges/alpaca.py | 125 ++++++++++++++++++++++++++++++ core/exchanges/oanda.py | 36 +++++++++ core/models.py | 40 +++++----- core/views/accounts.py | 2 +- core/views/positions.py | 12 +-- docker/prod/requirements.prod.txt | 1 + 7 files changed, 227 insertions(+), 31 deletions(-) create mode 100644 core/exchanges/__init__.py create mode 100644 core/exchanges/alpaca.py create mode 100644 core/exchanges/oanda.py diff --git a/core/exchanges/__init__.py b/core/exchanges/__init__.py new file mode 100644 index 0000000..c144ad1 --- /dev/null +++ b/core/exchanges/__init__.py @@ -0,0 +1,42 @@ +from core.util import logs + + +class BaseExchange(object): + def __init__(self, account): + name = self.__class__.__name__ + self.account = account + self.log = logs.get_logger(name) + self.client = None + + self.connect() + + def connect(self): + raise NotImplementedError + + def get_supported_assets(self): + raise NotImplementedError + + def get_balance(self): + raise NotImplementedError + + def get_market_value(self, symbol): + raise NotImplementedError + + def post_trade(self, trade): + raise NotImplementedError + + def get_trade(self, trade_id): + raise NotImplementedError + + def update_trade(self, trade): + raise NotImplementedError + + def cancel_trade(self, trade_id): + raise NotImplementedError + + def get_position_info(self, asset_id): + raise NotImplementedError + + def get_all_positions(self): + raise NotImplementedError + \ No newline at end of file diff --git a/core/exchanges/alpaca.py b/core/exchanges/alpaca.py new file mode 100644 index 0000000..d9ff496 --- /dev/null +++ b/core/exchanges/alpaca.py @@ -0,0 +1,125 @@ +from core.exchanges import BaseExchange +from alpaca.trading.client import TradingClient +from alpaca.trading.requests import GetAssetsRequest +from alpaca.common.exceptions import APIError +from alpaca.trading.enums import OrderSide, TimeInForce +from alpaca.trading.requests import LimitOrderRequest, MarketOrderRequest + + + +class AlpacaExchange(BaseExchange): + def connect(self): + self.client = TradingClient( + self.account.api_key, self.account.api_secret, paper=self.account.sandbox, raw_data=True + ) + + def get_supported_assets(self): + try: + request = GetAssetsRequest(status="active", asset_class="crypto") + assets = self.client.get_all_assets(filter=request) + asset_list = [x["symbol"] for x in assets if "symbol" in x] + print("Supported symbols", asset_list) + except APIError as e: + log.error(f"Could not get asset list: {e}") + # return False + return asset_list + + def get_balance(self): + try: + account_info = self.client.get_account() + except APIError as e: + self.log.error(f"Could not get account balance: {e}") + return False + equity = account_info["equity"] + try: + balance = float(equity) + except ValueError: + return False + + return balance + + def get_market_value(self, symbol): + try: + position = self.client.get_position(symbol) + except APIError as e: + self.log.error(f"Could not get market value for {symbol}: {e}") + return False + return float(position["market_value"]) + + def post_trade(self, trade): + # the trade is not placed yet + if trade.direction == "buy": + direction = OrderSide.BUY + elif trade.direction == "sell": + direction = OrderSide.SELL + else: + raise Exception("Unknown direction") + + cast = {"symbol": trade.symbol, "side": direction, "time_in_force": TimeInForce.IOC} + if trade.amount is not None: + cast["qty"] = trade.amount + if trade.amount_usd is not None: + cast["notional"] = trade.amount_usd + if not trade.amount and not trade.amount_usd: + return (False, "No amount specified") + if trade.take_profit: + cast["take_profit"] = {"limit_price": trade.take_profit} + if trade.stop_loss: + stop_limit_price = trade.stop_loss - (trade.stop_loss * 0.005) + cast["stop_loss"] = { + "stop_price": trade.stop_loss, + "limit_price": stop_limit_price, + } + if trade.type == "market": + market_order_data = MarketOrderRequest(**cast) + try: + order = self.client.submit_order(order_data=market_order_data) + except APIError as e: + log.error(f"Error placing market order: {e}") + return (False, e) + elif trade.type == "limit": + if not trade.price: + return (False, "Limit order with no price") + cast["limit_price"] = trade.price + limit_order_data = LimitOrderRequest(**cast) + try: + order = self.client.submit_order(order_data=limit_order_data) + except APIError as e: + log.error(f"Error placing limit order: {e}") + return (False, e) + + else: + raise Exception("Unknown trade type") + trade.response = order + trade.status = "posted" + trade.order_id = order["id"] + trade.client_order_id = order["client_order_id"] + trade.save() + return (True, order) + + def get_trade(self, trade_id): + pass + + def update_trade(self, trade): + pass + + def cancel_trade(self, trade_id): + pass + + def get_position_info(self, asset_id): + try: + position = self.client.get_open_position(asset_id) + except APIError as e: + return (False, e) + return (True, position) + + def get_all_positions(self): + items = [] + positions = self.client.get_all_positions() + + for item in positions: + item = dict(item) + item["account_id"] = self.account.id + item["unrealized_pl"] = float(item["unrealized_pl"]) + items.append(item) + return items \ No newline at end of file diff --git a/core/exchanges/oanda.py b/core/exchanges/oanda.py new file mode 100644 index 0000000..ce8bfa2 --- /dev/null +++ b/core/exchanges/oanda.py @@ -0,0 +1,36 @@ +from core.exchanges import BaseExchange +from oandapyV20 import API +from oandapyV20.endpoints import trades +from oandapyV20.endpoints import positions + +class OANDAExchange(BaseExchange): + def connect(self): + self.client = API(access_token=self.account.api_secret) + self.account_id = self.account.api_key + + def get_supported_assets(self): + raise NotImplementedError + + def get_balance(self): + raise NotImplementedError + + def get_market_value(self, symbol): + raise NotImplementedError + + def post_trade(self, trade): + raise NotImplementedError + + def get_trade(self, trade_id): + raise NotImplementedError + + def update_trade(self, trade): + raise NotImplementedError + + def cancel_trade(self, trade_id): + raise NotImplementedError + + def get_position_info(self, asset_id): + raise NotImplementedError + + def get_all_positions(self): + pass diff --git a/core/models.py b/core/models.py index c409fc1..a4cd6b8 100644 --- a/core/models.py +++ b/core/models.py @@ -1,16 +1,16 @@ import stripe -from alpaca.common.exceptions import APIError -from alpaca.trading.client import TradingClient -from alpaca.trading.requests import GetAssetsRequest from django.conf import settings from django.contrib.auth.models import AbstractUser from django.db import models +from core.exchanges.alpaca import AlpacaExchange +from core.exchanges.oanda import OANDAExchange from core.lib import trades from core.lib.customers import get_or_create, update_customer_fields from core.util import logs log = logs.get_logger(__name__) +EXCHANGE_MAP = {"alpaca": AlpacaExchange, "oanda": OANDAExchange} class Plan(models.Model): @@ -70,7 +70,7 @@ class User(AbstractUser): class Account(models.Model): - EXCHANGE_CHOICES = (("alpaca", "Alpaca"),) + EXCHANGE_CHOICES = (("alpaca", "Alpaca"), ("oanda", "OANDA")) user = models.ForeignKey(User, on_delete=models.CASCADE) name = models.CharField(max_length=255) exchange = models.CharField(choices=EXCHANGE_CHOICES, max_length=255) @@ -89,23 +89,18 @@ class Account(models.Model): """ Override the save function to update supported symbols. """ - try: - request = GetAssetsRequest(status="active", asset_class="crypto") - assets = self.client.get_all_assets(filter=request) - asset_list = [x["symbol"] for x in assets if "symbol" in x] - self.supported_symbols = asset_list - print("Supported symbols", self.supported_symbols) - except APIError as e: - log.error(f"Could not get asset list: {e}") - # return False - + client = self.get_client() + if client: + supported_symbols = client.get_supported_assets() + if supported_symbols: + self.supported_symbols = supported_symbols super().save(*args, **kwargs) def get_client(self): - trading_client = TradingClient( - self.api_key, self.api_secret, paper=self.sandbox, raw_data=True - ) - return trading_client + if self.exchange in EXCHANGE_MAP: + return EXCHANGE_MAP[self.exchange](self) + else: + raise Exception("Exchange not supported") @property def client(self): @@ -114,6 +109,13 @@ class Account(models.Model): """ return self.get_client() + @property + def rawclient(self): + """ + Convenience property for one-off API calls. + """ + return self.get_client().client + @classmethod def get_by_id(cls, account_id, user): return cls.objects.get(id=account_id, user=user) @@ -174,7 +176,7 @@ class Trade(models.Model): self._original = self def post(self): - return trades.post_trade(self) + return self.account.client.post_trade(self) def delete(self, *args, **kwargs): # close the trade diff --git a/core/views/accounts.py b/core/views/accounts.py index da795c6..12b74c7 100644 --- a/core/views/accounts.py +++ b/core/views/accounts.py @@ -39,7 +39,7 @@ class AccountInfo(LoginRequiredMixin, View): } return render(request, template_name, context) - live_info = dict(account.client.get_account()) + live_info = dict(account.rawclient.get_account()) account_info = account.__dict__ account_info = { k: v for k, v in account_info.items() if k in self.VIEWABLE_FIELDS_MODEL diff --git a/core/views/positions.py b/core/views/positions.py index 748b10a..a00576c 100644 --- a/core/views/positions.py +++ b/core/views/positions.py @@ -20,17 +20,7 @@ def get_positions(user, account_id=None): positions = account.client.get_all_positions() for item in positions: - item = dict(item) - item["account_id"] = account.id - item["unrealized_pl"] = float(item["unrealized_pl"]) items.append(item) - # try: - # parsed = ccxt_s.CCXTRoot.from_dict(order) - # except ValidationError as e: - # log.error(f"Error creating trade: {e}") - # return False - # self.status = parsed.status - # self.response = order return items @@ -77,7 +67,7 @@ class PositionAction(LoginRequiredMixin, View): unique = str(uuid.uuid4())[:8] account = Account.get_by_id(account_id, request.user) - success, info = trades.get_position_info(account, asset_id) + success, info = account.client.get_position_info(asset_id) if not success: message = "Position does not exist" message_class = "danger" diff --git a/docker/prod/requirements.prod.txt b/docker/prod/requirements.prod.txt index fd29ba1..41c8898 100644 --- a/docker/prod/requirements.prod.txt +++ b/docker/prod/requirements.prod.txt @@ -17,3 +17,4 @@ django-otp qrcode serde[ext] alpaca-py +oandapyV20