From af9f874209c81cdd86553aefb135141e96addfab Mon Sep 17 00:00:00 2001 From: Mark Veidemanis Date: Thu, 10 Nov 2022 19:27:46 +0000 Subject: [PATCH] Improve posting trades to OANDA and make everything more robust --- core/exchanges/__init__.py | 12 +++ core/exchanges/oanda.py | 24 ++++-- core/lib/market.py | 76 +++++++++++++---- core/lib/schemas/oanda_s.py | 81 +++++++++++++++++++ ...ruments_alter_account_exchange_and_more.py | 33 ++++++++ core/migrations/0022_account_currency.py | 18 +++++ core/models.py | 8 +- core/templates/partials/account-list.html | 2 + .../window-content/account-info.html | 28 +++++-- core/templatetags/pretty.py | 11 +++ core/views/accounts.py | 4 +- 11 files changed, 267 insertions(+), 30 deletions(-) create mode 100644 core/migrations/0021_account_instruments_alter_account_exchange_and_more.py create mode 100644 core/migrations/0022_account_currency.py create mode 100644 core/templatetags/pretty.py diff --git a/core/exchanges/__init__.py b/core/exchanges/__init__.py index 6bd742a..dd44355 100644 --- a/core/exchanges/__init__.py +++ b/core/exchanges/__init__.py @@ -164,6 +164,18 @@ class BaseExchange(object): def get_account(self): raise NotImplementedError + def extract_instrument(self, instruments, instrument): + for x in instruments["itemlist"]: + if x["name"] == instrument: + return x + return None + + def get_currencies(self, symbols): + raise NotImplementedError + + def get_instruments(self): + raise NotImplementedError + def get_supported_assets(self): raise NotImplementedError diff --git a/core/exchanges/oanda.py b/core/exchanges/oanda.py index 01e51db..75a5383 100644 --- a/core/exchanges/oanda.py +++ b/core/exchanges/oanda.py @@ -1,5 +1,5 @@ from oandapyV20 import API -from oandapyV20.endpoints import accounts, orders, positions +from oandapyV20.endpoints import accounts, orders, positions, pricing from core.exchanges import BaseExchange @@ -20,9 +20,20 @@ class OANDAExchange(BaseExchange): r = accounts.AccountDetails(self.account_id) return self.call(r) - def get_supported_assets(self): + def get_instruments(self): r = accounts.AccountInstruments(accountID=self.account_id) response = self.call(r) + return response + + def get_currencies(self, currencies): + params = {"instruments": ",".join(currencies)} + r = pricing.PricingInfo(accountID=self.account_id, params=params) + response = self.call(r) + return response + + def get_supported_assets(self, response=None): + if not response: + response = self.get_instruments() return [x["name"] for x in response["itemlist"]] def get_balance(self): @@ -43,19 +54,22 @@ class OANDAExchange(BaseExchange): # "price": "1.5000", - added later "stopLossOnFill": {"timeInForce": "GTC", "price": str(trade.stop_loss)}, "takeProfitOnFill": {"price": str(trade.take_profit)}, - "timeInForce": "IOC", + # "timeInForce": "GTC", "instrument": trade.symbol, "units": str(amount), "type": trade.type.upper(), "positionFill": "DEFAULT", } } - print("SENDINGF ORDER", data) if trade.type == "limit": data["order"]["price"] = str(trade.price) r = orders.OrderCreate(self.account_id, data=data) response = self.call(r) - print("POSTED TRADE", response) + trade.response = response + trade.status = "posted" + trade.order_id = response["id"] + trade.client_order_id = response["requestID"] + trade.save() return response def get_trade(self, trade_id): diff --git a/core/lib/market.py b/core/lib/market.py index e00f35d..b2acad4 100644 --- a/core/lib/market.py +++ b/core/lib/market.py @@ -4,11 +4,46 @@ from alpaca.common.exceptions import APIError from core.models import Strategy, Trade from core.util import logs +from core.exchanges import GenericAPIError log = logs.get_logger(__name__) +def to_usd(account, amount, from_currency): + if account.exchange == "alpaca": + separator = "/" + elif account.exchange == "oanda": + separator = "_" + symbol = f"{from_currency.upper()}{separator}{to_currency.upper()}" + prices = account.client.get_currencies([symbol]) + +def to_currency(direction, account, amount, from_currency, to_currency): + if account.exchange == "alpaca": + separator = "/" + elif account.exchange == "oanda": + separator = "_" + if direction == "buy": + price_index = "bids" + elif direction == "sell": + price_index = "asks" + symbol = f"{from_currency.upper()}{separator}{to_currency.upper()}" + if symbol not in account.supported_symbols: + symbol = f"{to_currency.upper()}{separator}{from_currency.upper()}" + inverted = True + try: + prices = account.client.get_currencies([symbol]) + except GenericAPIError as e: + log.error(f"Error getting currencies and inverted currencies: {e}") + return None + price = prices["prices"][0][price_index][0]["price"] + if inverted: + price = D(1.0) / D(price) + converted = D(amount) * price + + return converted + def execute_strategy(callback, strategy): cash_balance = strategy.account.client.get_balance() + instruments = strategy.account.instruments log.debug(f"Cash balance: {cash_balance}") user = strategy.user @@ -20,21 +55,31 @@ def execute_strategy(callback, strategy): if callback.exchange != account.exchange: log.error("Market exchange differs from account exchange.") return + if account.exchange == "alpaca": + separator = "/" + elif account.exchange == "oanda": + separator = "_" if account.exchange == "alpaca": if quote not in ["usd", "usdt", "usdc", "busd"]: log.error(f"Quote not compatible with Dollar: {quote}") return False quote = "usd" # TODO: MASSIVE HACK - symbol = f"{base.upper()}/{quote.upper()}" - elif account.exchange == "oanda": - symbol = f"{base.upper()}_{quote.upper()}" + symbol = f"{base.upper()}{separator}{quote.upper()}" if symbol not in account.supported_symbols: log.error(f"Symbol not supported by account: {symbol}") return False - print(f"Identified pair from callback {symbol}") - + instrument = strategy.account.client.extract_instrument(instruments, symbol) + if not instrument: + log.error(f"Symbol not found: {symbol}") + return False + try: + trade_precision = instrument["tradeUnitsPrecision"] + display_precision = instrument["displayPrecision"] + except KeyError: + log.error(f"Precision not found for {symbol}") + return False # market_from_alpaca = get_market_value(account, symbol) # change_percent = abs(((float(market_from_alpaca)-price)/price)*100) # if change_percent > strategy.price_slippage_percent: @@ -45,16 +90,17 @@ def execute_strategy(callback, strategy): type = "market" trade_size_as_ratio = D(strategy.trade_size_percent) / D(100) log.debug(f"Trade size as ratio: {trade_size_as_ratio}") - amount_usd = D(trade_size_as_ratio) * D(cash_balance) - log.debug(f"Trade size: {amount_usd}") - price = round(D(callback.price), 8) + amount_fiat = D(trade_size_as_ratio) * D(cash_balance) + log.debug(f"Trade size: {amount_fiat}") + price = round(D(callback.price), display_precision) if not price: return log.debug(f"Extracted price of quote: {price}") # We can do this because the quote IS in $ or equivalent - trade_size_in_quote = D(amount_usd) / D(price) - log.debug(f"Trade size in quote: {trade_size_in_quote}") + # trade_size_in_base = D(amount_fiat) / D(price) + trade_size_in_base = to_currency(direction, account, amount_fiat, account.currency, base) + log.debug(f"Trade size in base: {trade_size_in_base}") # calculate sl/tp stop_loss_as_ratio = D(strategy.stop_loss_percent) / D(100) @@ -79,11 +125,11 @@ def execute_strategy(callback, strategy): hook=hook, symbol=symbol, type=type, - # amount_usd=amount_usd, - amount=float(round(trade_size_in_quote, 2)), + # amount_fiat=amount_fiat, + amount=float(round(trade_size_in_base, trade_precision)), # price=price, - stop_loss=float(round(stop_loss, 2)), - take_profit=float(round(take_profit, 2)), + stop_loss=float(round(stop_loss, display_precision)), + take_profit=float(round(take_profit, display_precision)), direction=direction, ) new_trade.save() @@ -99,5 +145,5 @@ def process_callback(callback): log.debug(f"Executing strategy {strategy}") if callback.hook.user != strategy.user: log.error("Ownership differs between callback and strategy.") - return + continue execute_strategy(callback, strategy) diff --git a/core/lib/schemas/oanda_s.py b/core/lib/schemas/oanda_s.py index 921e3d8..e703ac5 100644 --- a/core/lib/schemas/oanda_s.py +++ b/core/lib/schemas/oanda_s.py @@ -352,3 +352,84 @@ AccountInstrumentsSchema = { ], ) } + +class OrderTransaction(BaseModel): + id: str + accountID: str + userID: int + batchID: str + requestID: str + time: str + type: str + instrument: str + units: str + timeInForce: str + positionFill: str + reason: str + +class OrderCreate(BaseModel): + orderCreateTransaction: OrderTransaction + +OrderCreateSchema = { + "id": "orderCreateTransaction.id", + "accountID": "orderCreateTransaction.accountID", + "userID": "orderCreateTransaction.userID", + "batchID": "orderCreateTransaction.batchID", + "requestID": "orderCreateTransaction.requestID", + "time": "orderCreateTransaction.time", + "type": "orderCreateTransaction.type", + "symbol": "orderCreateTransaction.instrument", + "units": "orderCreateTransaction.units", + "timeInForce": "orderCreateTransaction.timeInForce", + "positionFill": "orderCreateTransaction.positionFill", + "reason": "orderCreateTransaction.reason", +} + +class PriceBid(BaseModel): + price: str + liquidity: int + +class PriceAsk(BaseModel): + price: str + liquidity: int + +class PriceQuoteHomeConversionFactors(BaseModel): + positiveUnits: str + negativeUnits: str + +class Price(BaseModel): + type: str + time: str + bids: list[PriceBid] + asks: list[PriceAsk] + closeoutBid: str + closeoutAsk: str + status: str + tradeable: bool + quoteHomeConversionFactors: PriceQuoteHomeConversionFactors + instrument: str + +class PricingInfo(BaseModel): + time: str + prices: list[Price] + +PricingInfoSchema = { + "time": "time", + "prices": ( + "prices", + [ + { + "type": "type", + "time": "time", + "bids": "bids", + "asks": "asks", + "closeoutBid": "closeoutBid", + "closeoutAsk": "closeoutAsk", + "status": "status", + "tradeable": "tradeable", + "quoteHomeConversionFactors": "quoteHomeConversionFactors", + "symbol": "instrument", + } + ], + ), +} diff --git a/core/migrations/0021_account_instruments_alter_account_exchange_and_more.py b/core/migrations/0021_account_instruments_alter_account_exchange_and_more.py new file mode 100644 index 0000000..b8c3a58 --- /dev/null +++ b/core/migrations/0021_account_instruments_alter_account_exchange_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.1.3 on 2022-11-10 18:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0020_rename_market_item_callback_base_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='account', + name='instruments', + field=models.JSONField(default=list), + ), + migrations.AlterField( + model_name='account', + name='exchange', + field=models.CharField(choices=[('alpaca', 'Alpaca'), ('oanda', 'OANDA')], max_length=255), + ), + migrations.AlterField( + model_name='strategy', + name='take_profit_percent', + field=models.FloatField(default=1.5), + ), + migrations.AlterField( + model_name='strategy', + name='trade_size_percent', + field=models.FloatField(default=0.5), + ), + ] diff --git a/core/migrations/0022_account_currency.py b/core/migrations/0022_account_currency.py new file mode 100644 index 0000000..a431232 --- /dev/null +++ b/core/migrations/0022_account_currency.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.3 on 2022-11-10 18:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0021_account_instruments_alter_account_exchange_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='account', + name='currency', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/core/models.py b/core/models.py index dfe918c..e399321 100644 --- a/core/models.py +++ b/core/models.py @@ -77,6 +77,8 @@ class Account(models.Model): api_secret = models.CharField(max_length=255) sandbox = models.BooleanField(default=False) supported_symbols = models.JSONField(default=list) + instruments = models.JSONField(default=list) + currency = models.CharField(max_length=255, null=True, blank=True) def __str__(self): name = f"{self.name} ({self.exchange})" @@ -90,9 +92,13 @@ class Account(models.Model): """ client = self.get_client() if client: - supported_symbols = client.get_supported_assets() + response = client.get_instruments() + supported_symbols = client.get_supported_assets(response) + currency = client.get_account()["currency"] log.debug(f"Supported symbols for {self.name}: {supported_symbols}") self.supported_symbols = supported_symbols + self.instruments = response + self.currency = currency super().save(*args, **kwargs) def get_client(self): diff --git a/core/templates/partials/account-list.html b/core/templates/partials/account-list.html index 62af3d8..2e9448a 100644 --- a/core/templates/partials/account-list.html +++ b/core/templates/partials/account-list.html @@ -12,6 +12,7 @@ user name exchange + currency API key sandbox actions @@ -22,6 +23,7 @@ {{ item.user }} {{ item.name }} {{ item.exchange }} + {{ item.currency }} {{ item.api_key }} {% if item.sandbox %} diff --git a/core/templates/window-content/account-info.html b/core/templates/window-content/account-info.html index 8641214..729484e 100644 --- a/core/templates/window-content/account-info.html +++ b/core/templates/window-content/account-info.html @@ -1,3 +1,4 @@ +{% load pretty %} {% include 'partials/notify.html' %}

Live information

@@ -28,14 +29,25 @@ {% for key, item in db_info.items %} - - {{ key }} - - {% if item is not None %} - {{ item }} - {% endif %} - - + {% if key == 'instruments' %} + + {{ key }} + + {% if item is not None %} +
{{ item|pretty }}
+ {% endif %} + + + {% else %} + + {{ key }} + + {% if item is not None %} + {{ item }} + {% endif %} + + + {% endif %} {% endfor %} \ No newline at end of file diff --git a/core/templatetags/pretty.py b/core/templatetags/pretty.py new file mode 100644 index 0000000..0020438 --- /dev/null +++ b/core/templatetags/pretty.py @@ -0,0 +1,11 @@ +from django import template +import orjson + +register = template.Library() + + +@register.filter +def pretty(data): + return orjson.dumps( + data, option=orjson.OPT_INDENT_2 + ).decode("utf-8") diff --git a/core/views/accounts.py b/core/views/accounts.py index 41575c5..79cc289 100644 --- a/core/views/accounts.py +++ b/core/views/accounts.py @@ -1,5 +1,5 @@ import uuid - +import orjson from django.contrib.auth.mixins import LoginRequiredMixin from django.http import HttpResponseBadRequest from django.shortcuts import render @@ -17,9 +17,11 @@ class AccountInfo(LoginRequiredMixin, View): VIEWABLE_FIELDS_MODEL = [ "name", "exchange", + "currency", "api_key", "sandbox", "supported_symbols", + "instruments", ] allowed_types = ["modal", "widget", "window", "page"] window_content = "window-content/account-info.html"