diff --git a/core/exchanges/common.py b/core/exchanges/common.py index 8d2126b..6b48898 100644 --- a/core/exchanges/common.py +++ b/core/exchanges/common.py @@ -1,6 +1,10 @@ from decimal import Decimal as D +from core.exchanges import GenericAPIError from core.lib.elastic import store_msg +from core.util import logs + +log = logs.get_logger(__name__) def get_balance_hook(user_id, user_name, account_id, account_name, balance): @@ -20,144 +24,73 @@ def get_balance_hook(user_id, user_name, account_id, account_name, balance): ) -def tp_price_to_percent(tp_price, side, current_price, current_units, unrealised_pl): +def get_pair(account, base, quote, invert=False): """ - Determine the percent change of the TP price from the initial price. - Positive values indicate a profit, negative values indicate a loss. + Get the pair for the given account and currencies. + :param account: Account object + :param base: Base currency + :param quote: Quote currency + :param invert: Invert the pair + :return: currency symbol, e.g. BTC_USD, BTC/USD, etc. """ - pl_per_unit = D(unrealised_pl) / D(current_units) - if side == "long": - initial_price = D(current_price) - pl_per_unit + # Currently we only have two exchanges with different pair separators + if account.exchange == "alpaca": + separator = "/" + elif account.exchange == "oanda": + separator = "_" + + # Flip the pair if needed + if invert: + symbol = f"{quote.upper()}{separator}{base.upper()}" else: - initial_price = D(current_price) + pl_per_unit - - # Get the percent change of the TP price from the initial price. - change_percent = ((initial_price - D(tp_price)) / initial_price) * 100 - - if side == "long": - if D(tp_price) < initial_price: - loss = True - else: - loss = False - else: - if D(tp_price) > initial_price: - loss = True - else: - loss = False - - # if we are in loss on the short side, we want to show a negative - if loss: - change_percent = 0 - abs(change_percent) - else: - change_percent = abs(change_percent) - - return round(change_percent, 5) + symbol = f"{base.upper()}{separator}{quote.upper()}" + # Check it exists + if symbol not in account.supported_symbols: + return False + return symbol -def sl_price_to_percent(sl_price, side, current_price, current_units, unrealised_pl): +def to_currency(direction, account, amount, from_currency, to_currency): """ - Determine the percent change of the SL price from the initial price. - Positive values indicate a loss, negative values indicate a profit. - This may seem backwards, but it is important to note that by default, - SL indicates a loss, and positive values should be expected. - Negative values indicate a negative loss, so a profit. + Convert an amount from one currency to another. + :param direction: Direction of the trade + :param account: Account object + :param amount: Amount to convert + :param from_currency: Currency to convert from + :param to_currency: Currency to convert to + :return: Converted amount """ - pl_per_unit = D(unrealised_pl) / D(current_units) - if side == "long": - initial_price = D(current_price) - pl_per_unit - else: - initial_price = D(current_price) + pl_per_unit + # If we're converting to the same currency, just return the amount + if from_currency == to_currency: + return amount + inverted = False - # initial_price = D(current_price) - pl_per_unit + # This is needed because OANDA has different values for bid and ask + if direction == "buy": + price_index = "bids" + elif direction == "sell": + price_index = "asks" + symbol = get_pair(account, from_currency, to_currency) + if not symbol: + symbol = get_pair(account, from_currency, to_currency, invert=True) + inverted = True - # Get the percent change of the SL price from the initial price. - change_percent = ((initial_price - D(sl_price)) / initial_price) * 100 + # Bit of a hack but it works + if not symbol: + log.error(f"Could not find symbol for {from_currency} -> {to_currency}") + raise Exception("Could not find symbol") + try: + prices = account.client.get_currencies([symbol]) + except GenericAPIError as e: + log.error(f"Error getting currencies and inverted currencies: {e}") + return None + price = D(prices["prices"][0][price_index][0]["price"]) - # If the trade is long, the SL price will be higher than the initial price. - # if side == "long": - # change_percent *= -1 + # If we had to flip base and quote, we need to use the reciprocal of the price + if inverted: + price = D(1.0) / price - if side == "long": - if D(sl_price) > initial_price: - profit = True - else: - profit = False - else: - if D(sl_price) < initial_price: - profit = True - else: - profit = False + # Convert the amount to the destination currency + converted = D(amount) * price - if profit: - change_percent = 0 - abs(change_percent) - else: - change_percent = abs(change_percent) - - return round(change_percent, 5) - - -def convert_open_trades(open_trades): - """ - Convert a list of open trades into a list of Trade-like objects. - """ - trades = [] - for trade in open_trades: - current_price = trade["price"] - current_units = trade["currentUnits"] - unrealised_pl = trade["unrealizedPL"] - side = trade["side"] - cast = { - "id": trade["id"], - "symbol": trade["symbol"], - "amount": current_units, - "side": side, - "state": trade["state"], - "price": current_price, - "pl": unrealised_pl, - } - - # Add some extra fields, sometimes we have already looked up the - # prices and don't need to call convert_trades_to_usd - if "take_profit_usd" in trade: - cast["take_profit_usd"] = trade["take_profit_usd"] - if "stop_loss_usd" in trade: - cast["stop_loss_usd"] = trade["stop_loss_usd"] - if "trailing_stop_loss_usd" in trade: - cast["trailing_stop_loss_usd"] = trade["trailing_stop_loss_usd"] - - if "takeProfitOrder" in trade: - if trade["takeProfitOrder"]: - take_profit = trade["takeProfitOrder"]["price"] - take_profit_percent = tp_price_to_percent( - take_profit, side, current_price, current_units, unrealised_pl - ) - - cast["take_profit"] = take_profit - cast["take_profit_percent"] = take_profit_percent - - if "stopLossOrder" in trade: - if trade["stopLossOrder"]: - stop_loss = trade["stopLossOrder"]["price"] - stop_loss_percent = sl_price_to_percent( - stop_loss, side, current_price, current_units, unrealised_pl - ) - - cast["stop_loss"] = stop_loss - cast["stop_loss_percent"] = stop_loss_percent - - if "trailingStopLossOrder" in trade: - if trade["trailingStopLossOrder"]: - trailing_stop_loss = trade["trailingStopLossOrder"]["price"] - trailing_stop_loss_percent = sl_price_to_percent( - trailing_stop_loss, - side, - current_price, - current_units, - unrealised_pl, - ) - - cast["trailing_stop_loss"] = trailing_stop_loss - cast["trailing_stop_loss_percent"] = trailing_stop_loss_percent - - trades.append(cast) - return trades + return converted diff --git a/core/exchanges/convert.py b/core/exchanges/convert.py new file mode 100644 index 0000000..31bf0f2 --- /dev/null +++ b/core/exchanges/convert.py @@ -0,0 +1,211 @@ +from decimal import Decimal as D + +from core.models import Account +from core.trading.market import direction_to_side, get_price, side_to_direction + +# Separate module to prevent circular import from +# models -> exchanges -> common -> models +# Since we need Account here to look up missing prices + + +def tp_price_to_percent(tp_price, side, current_price, current_units, unrealised_pl): + """ + Determine the percent change of the TP price from the initial price. + Positive values indicate a profit, negative values indicate a loss. + """ + pl_per_unit = D(unrealised_pl) / D(current_units) + if side == "long": + initial_price = D(current_price) - pl_per_unit + else: + initial_price = D(current_price) + pl_per_unit + + # Get the percent change of the TP price from the initial price. + change_percent = ((initial_price - D(tp_price)) / initial_price) * 100 + + if side == "long": + if D(tp_price) < initial_price: + loss = True + else: + loss = False + else: + if D(tp_price) > initial_price: + loss = True + else: + loss = False + + # if we are in loss on the short side, we want to show a negative + if loss: + change_percent = 0 - abs(change_percent) + else: + change_percent = abs(change_percent) + + return round(change_percent, 5) + + +def sl_price_to_percent(sl_price, side, current_price, current_units, unrealised_pl): + """ + Determine the percent change of the SL price from the initial price. + Positive values indicate a loss, negative values indicate a profit. + This may seem backwards, but it is important to note that by default, + SL indicates a loss, and positive values should be expected. + Negative values indicate a negative loss, so a profit. + """ + + pl_per_unit = D(unrealised_pl) / D(current_units) + if side == "long": + initial_price = D(current_price) - pl_per_unit + else: + initial_price = D(current_price) + pl_per_unit + + # initial_price = D(current_price) - pl_per_unit + + # Get the percent change of the SL price from the initial price. + change_percent = ((initial_price - D(sl_price)) / initial_price) * 100 + + # If the trade is long, the SL price will be higher than the initial price. + # if side == "long": + # change_percent *= -1 + + if side == "long": + if D(sl_price) > initial_price: + profit = True + else: + profit = False + else: + if D(sl_price) < initial_price: + profit = True + else: + profit = False + + if profit: + change_percent = 0 - abs(change_percent) + else: + change_percent = abs(change_percent) + + return round(change_percent, 5) + + +def annotate_trade_tp_sl_percent(trade): + """ + Annotate the trade with the TP and SL percent. + This works on Trade objects, which will require an additional market + lookup to get the current price. + """ + if "current_price" in trade: + current_price = trade["current_price"] + else: + account_id = trade["account_id"] + account = Account.get_by_id_no_user_check(account_id) + + current_price = get_price(account, trade["direction"], trade["symbol"]) + trade["current_price"] = current_price + + current_units = trade["amount"] + if "pl" in trade: + unrealised_pl = trade["pl"] + else: + unrealised_pl = 0 + + if "side" in trade: + side = trade["side"] + direction = side_to_direction(side) + trade["direction"] = direction + else: + direction = trade["direction"] + side = direction_to_side(direction) + trade["side"] = side + + if "take_profit" in trade: + if trade["take_profit"]: + take_profit = trade["take_profit"] + take_profit_percent = tp_price_to_percent( + take_profit, trade["side"], current_price, current_units, unrealised_pl + ) + + trade["take_profit_percent"] = take_profit_percent + + if "stop_loss" in trade: + if trade["stop_loss"]: + stop_loss = trade["stop_loss"] + stop_loss_percent = sl_price_to_percent( + stop_loss, side, current_price, current_units, unrealised_pl + ) + + trade["stop_loss_percent"] = stop_loss_percent + + if "trailing_stop_loss" in trade: + if trade["trailing_stop_loss"]: + trailing_stop_loss = trade["trailing_stop_loss"] + trailing_stop_loss_percent = sl_price_to_percent( + trailing_stop_loss, + trade["side"], + current_price, + current_units, + unrealised_pl, + ) + + trade["trailing_stop_loss_percent"] = trailing_stop_loss_percent + + return trade + + +def open_trade_to_unified_format(trade): + """ + Convert an open trade to a Trade-like format. + """ + current_price = trade["price"] + current_units = trade["currentUnits"] + unrealised_pl = trade["unrealizedPL"] + side = trade["side"] + cast = { + "id": trade["id"], + "symbol": trade["symbol"], + "amount": current_units, + "side": side, + "direction": side_to_direction(side), + "state": trade["state"], + "current_price": current_price, + "pl": unrealised_pl, + } + # Add some extra fields, sometimes we have already looked up the + # prices and don't need to call convert_trades_to_usd + # This is mostly for tests, but it can be useful in other places. + if "take_profit_usd" in trade: + cast["take_profit_usd"] = trade["take_profit_usd"] + if "stop_loss_usd" in trade: + cast["stop_loss_usd"] = trade["stop_loss_usd"] + if "trailing_stop_loss_usd" in trade: + cast["trailing_stop_loss_usd"] = trade["trailing_stop_loss_usd"] + if "takeProfitOrder" in trade: + if trade["takeProfitOrder"]: + take_profit = trade["takeProfitOrder"]["price"] + cast["take_profit"] = take_profit + + if "stopLossOrder" in trade: + if trade["stopLossOrder"]: + stop_loss = trade["stopLossOrder"]["price"] + cast["stop_loss"] = stop_loss + + if "trailingStopLossOrder" in trade: + if trade["trailingStopLossOrder"]: + trailing_stop_loss = trade["trailingStopLossOrder"]["price"] + cast["trailing_stop_loss"] = trailing_stop_loss + + return cast + + +def convert_trades(open_trades): + """ + Convert a list of open trades into a list of Trade-like objects. + """ + trades = [] + for trade in open_trades: + if "currentUnits" in trade: # Open trade + cast = open_trade_to_unified_format(trade) + cast = annotate_trade_tp_sl_percent(cast) + trades.append(cast) + else: + cast = annotate_trade_tp_sl_percent(trade) + trades.append(cast) + + return trades diff --git a/core/exchanges/oanda.py b/core/exchanges/oanda.py index 2f146c7..3f38882 100644 --- a/core/exchanges/oanda.py +++ b/core/exchanges/oanda.py @@ -36,18 +36,25 @@ class OANDAExchange(BaseExchange): response = self.get_instruments() return [x["name"] for x in response["itemlist"]] - def get_balance(self): + def get_balance(self, return_usd=False): r = accounts.AccountSummary(self.account_id) response = self.call(r) + print("RESPONSE", response) balance = float(response["balance"]) + currency = response["currency"] + balance_usd = common.to_currency("sell", self.account, balance, currency, "USD") + print("BALANCE", balance) + print("BALANCE USD", balance_usd) common.get_balance_hook( self.account.user.id, self.account.user.username, self.account.id, self.account.name, - balance, + balance_usd, ) + if return_usd: + return balance_usd return balance def get_market_value(self, symbol): @@ -138,7 +145,7 @@ class OANDAExchange(BaseExchange): def get_all_open_trades(self): r = trades.OpenTrades(accountID=self.account_id) - return self.call(r) + return self.call(r)["itemlist"] def close_position(self, side, symbol): data = { diff --git a/core/forms.py b/core/forms.py index ae38201..aeaa7f2 100644 --- a/core/forms.py +++ b/core/forms.py @@ -119,6 +119,7 @@ class AccountForm(RestrictedFormMixin, ModelForm): "sandbox", "enabled", "risk_model", + "initial_balance", ) help_texts = { "name": "Name of the account. Informational only.", @@ -128,6 +129,7 @@ class AccountForm(RestrictedFormMixin, ModelForm): "sandbox": "Whether to use the sandbox/demo or not.", "enabled": "Whether the account is enabled.", "risk_model": "The risk model to use for this account.", + "initial_balance": "The initial balance of the account.", } diff --git a/core/migrations/0051_account_initial_balance.py b/core/migrations/0051_account_initial_balance.py new file mode 100644 index 0000000..f351736 --- /dev/null +++ b/core/migrations/0051_account_initial_balance.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.4 on 2023-01-11 17:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0050_account_enabled'), + ] + + operations = [ + migrations.AddField( + model_name='account', + name='initial_balance', + field=models.FloatField(default=0), + ), + ] diff --git a/core/models.py b/core/models.py index c0ada3c..e13f16d 100644 --- a/core/models.py +++ b/core/models.py @@ -116,6 +116,7 @@ class Account(models.Model): risk_model = models.ForeignKey( "core.RiskModel", on_delete=models.SET_NULL, null=True, blank=True ) + initial_balance = models.FloatField(default=0) def __str__(self): name = f"{self.name} ({self.exchange})" @@ -167,6 +168,10 @@ class Account(models.Model): def get_by_id(cls, account_id, user): return cls.objects.get(id=account_id, user=user) + @classmethod + def get_by_id_no_user_check(cls, account_id): + return cls.objects.get(id=account_id) + class Session(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) diff --git a/core/tests/exchanges/test_common.py b/core/tests/exchanges/test_convert.py similarity index 99% rename from core/tests/exchanges/test_common.py rename to core/tests/exchanges/test_convert.py index 52e9134..0457db1 100644 --- a/core/tests/exchanges/test_common.py +++ b/core/tests/exchanges/test_convert.py @@ -1,6 +1,6 @@ from django.test import TestCase -from core.exchanges.common import sl_price_to_percent, tp_price_to_percent +from core.exchanges.convert import sl_price_to_percent, tp_price_to_percent class CommonTestCase(TestCase): diff --git a/core/tests/helpers.py b/core/tests/helpers.py index cbd6aa3..f824e8e 100644 --- a/core/tests/helpers.py +++ b/core/tests/helpers.py @@ -86,6 +86,7 @@ If you have done this, please see the following line for more information: exchange=exchange, api_key=api_key, api_secret=api_secret, + initial_balance=100, ) def setUp(self): diff --git a/core/tests/trading/test_live.py b/core/tests/trading/test_live.py index 714b67d..00e2d29 100644 --- a/core/tests/trading/test_live.py +++ b/core/tests/trading/test_live.py @@ -1,11 +1,12 @@ from decimal import Decimal as D +from unittest.mock import patch from django.test import TestCase -from core.exchanges.common import convert_open_trades -from core.models import Trade +from core.exchanges.convert import convert_trades +from core.models import RiskModel, Trade from core.tests.helpers import ElasticMock, LiveBase -from core.trading.market import get_precision, get_price, get_sl, get_tp +from core.trading import market, risk class LiveTradingTestCase(ElasticMock, LiveBase, TestCase): @@ -21,6 +22,14 @@ class LiveTradingTestCase(ElasticMock, LiveBase, TestCase): direction="buy", ) self.commission = 0.025 + self.risk_model = RiskModel.objects.create( + user=self.user, + name="Test Risk Model", + max_loss_percent=4, + max_risk_percent=2, + max_open_trades=3, + max_open_trades_per_symbol=2, + ) def test_account_functional(self): """ @@ -34,11 +43,12 @@ class LiveTradingTestCase(ElasticMock, LiveBase, TestCase): if trade: posted = trade.post() else: + trade = self.trade posted = self.trade.post() # Check the opened trade self.assertEqual(posted["type"], "MARKET_ORDER") - self.assertEqual(posted["symbol"], "EUR_USD") - self.assertEqual(posted["units"], "10") + self.assertEqual(posted["symbol"], trade.symbol) + self.assertEqual(posted["units"], str(trade.amount)) self.assertEqual(posted["timeInForce"], "FOK") return posted @@ -48,14 +58,17 @@ class LiveTradingTestCase(ElasticMock, LiveBase, TestCase): trade.refresh_from_db() closed = self.account.client.close_trade(trade.order_id) else: + trade = self.trade # refresh the trade to get the trade id self.trade.refresh_from_db() closed = self.account.client.close_trade(self.trade.order_id) # Check the feedback from closing the trade + print("CLOSED", closed) + print("TRADE AMOUNT", trade.amount) self.assertEqual(closed["type"], "MARKET_ORDER") - self.assertEqual(closed["symbol"], "EUR_USD") - self.assertEqual(closed["units"], "-10") + self.assertEqual(closed["symbol"], trade.symbol) + self.assertEqual(closed["units"], str(0 - int(trade.amount))) self.assertEqual(closed["timeInForce"], "FOK") self.assertEqual(closed["reason"], "TRADE_CLOSE") @@ -78,7 +91,7 @@ class LiveTradingTestCase(ElasticMock, LiveBase, TestCase): trades = self.account.client.get_all_open_trades() self.trade.refresh_from_db() found = False - for trade in trades["itemlist"]: + for trade in trades: if trade["id"] == self.trade.order_id: self.assertEqual(trade["symbol"], "EUR_USD") self.assertEqual(trade["currentUnits"], "10") @@ -91,14 +104,11 @@ class LiveTradingTestCase(ElasticMock, LiveBase, TestCase): if not found: self.fail("Could not find the trade in the list of open trades") - def test_convert_open_trades(self): - """ - Test converting open trades response to Trade-like format. - """ - eur_usd_price = get_price(self.account, "buy", "EUR_USD") - trade_tp = get_tp("buy", 1, eur_usd_price) - trade_sl = get_sl("buy", 2, eur_usd_price) - # trade_tsl = get_sl("buy", 1, eur_usd_price, return_var=True) + def create_complex_trade(self, direction, amount, symbol, tp_percent, sl_percent): + eur_usd_price = market.get_price(self.account, direction, symbol) + trade_tp = market.get_tp(direction, tp_percent, eur_usd_price) + trade_sl = market.get_sl(direction, sl_percent, eur_usd_price) + # trade_tsl = market.get_sl("buy", 1, eur_usd_price, return_var=True) # # TP 1% profit # trade_tp = eur_usd_price * D(1.01) # # SL 2% loss @@ -106,7 +116,7 @@ class LiveTradingTestCase(ElasticMock, LiveBase, TestCase): # # TSL 1% loss # trade_tsl = eur_usd_price * D(0.99) - trade_precision, display_precision = get_precision(self.account, "EUR_USD") + trade_precision, display_precision = market.get_precision(self.account, symbol) # Round everything to the display precision trade_tp = round(trade_tp, display_precision) @@ -116,19 +126,79 @@ class LiveTradingTestCase(ElasticMock, LiveBase, TestCase): complex_trade = Trade.objects.create( user=self.user, account=self.account, - symbol="EUR_USD", + symbol=symbol, time_in_force="FOK", type="market", - amount=10, - direction="buy", + amount=amount, + direction=direction, take_profit=trade_tp, stop_loss=trade_sl, # trailing_stop_loss=trade_tsl, ) + return complex_trade + + @patch("core.exchanges.oanda.OANDAExchange.get_balance", return_value=100) + def test_check_risk_max_risk_pass(self, mock_balance): + # SL of 19% on a 10 trade on a 100 account is 1.8 loss + # Should be comfortably under 2% risk + trade = self.create_complex_trade("buy", 10, "EUR_USD", 1, 18) + allowed = risk.check_risk(self.risk_model, self.account, trade) + self.assertTrue(allowed["allowed"]) + + @patch("core.exchanges.oanda.OANDAExchange.get_balance", return_value=100) + def test_check_risk_max_risk_fail(self, mock_balance): + # SL of 21% on a 10 trade on a 100 account is 2.2 loss + # Should be over 2% risk + trade = self.create_complex_trade("buy", 10, "EUR_USD", 1, 22) + allowed = risk.check_risk(self.risk_model, self.account, trade) + print("ALLOWED", allowed) + self.assertFalse(allowed["allowed"]) + self.assertEqual(allowed["reason"], "Maximum risk exceeded.") + + @patch("core.exchanges.oanda.OANDAExchange.get_balance", return_value=94) + # We have lost 6% of our account + def test_check_risk_max_loss_fail(self, mock_balance): + # Doesn't matter, shouldn't get as far as the trade + trade = self.create_complex_trade("buy", 1, "EUR_USD", 1, 1) + allowed = risk.check_risk(self.risk_model, self.account, trade) + self.assertFalse(allowed["allowed"]) + self.assertEqual(allowed["reason"], "Maximum loss exceeded.") + + @patch("core.exchanges.oanda.OANDAExchange.get_balance", return_value=100) + def test_check_risk_max_open_trades_fail(self, mock_balance): + # The maximum open trades is 3. Let's open 2 trades + trade1 = self.create_complex_trade("buy", 1, "EUR_USD", 1, 1) + self.open_trade(trade1) + + trade2 = self.create_complex_trade("buy", 1, "EUR_USD", 1, 1) + self.open_trade(trade2) + + trade3 = self.create_complex_trade("buy", 1, "EUR_USD", 1, 1) + + allowed = risk.check_risk(self.risk_model, self.account, trade3) + self.assertFalse(allowed["allowed"]) + self.assertEqual(allowed["reason"], "Maximum open trades exceeded.") + + self.close_trade(trade1) + self.close_trade(trade2) + + @patch("core.exchanges.oanda.OANDAExchange.get_balance", return_value=100) + def test_check_risk_max_open_trades_per_symbol_fail(self, mock_balance): + pass + + def test_convert_trades(self): + """ + Test converting open trades response to Trade-like format. + """ + complex_trade = self.create_complex_trade("buy", 10, "EUR_USD", 1, 2) self.open_trade(complex_trade) + + # Get and annotate the trades trades = self.account.client.get_all_open_trades() - trades_converted = convert_open_trades(trades["itemlist"]) + trades_converted = convert_trades(trades) + + # Check the converted trades self.assertEqual(len(trades_converted), 1) expected_tp_percent = D(1 - self.commission) expected_sl_percent = D(2 - self.commission) @@ -141,4 +211,24 @@ class LiveTradingTestCase(ElasticMock, LiveBase, TestCase): self.assertLess(tp_percent_difference, max_difference) self.assertLess(sl_percent_difference, max_difference) + + # Convert the trades to USD + trades_usd = market.convert_trades_to_usd(self.account, trades_converted) + + # Convert the trade to USD ourselves + trade_in_usd = D(trades_usd[0]["amount"]) * D(trades_usd[0]["current_price"]) + + # It will never be perfect, but let's check it's at least close + trade_usd_conversion_difference = ( + trades_usd[0]["trade_amount_usd"] - trade_in_usd + ) + self.assertLess(trade_usd_conversion_difference, D(0.01)) + + # Check the converted TP and SL values + trade_usd_tp_difference = trades_usd[0]["take_profit_usd"] - D(0.1) + trade_usd_sl_difference = trades_usd[0]["stop_loss_usd"] - D(0.2) + + self.assertLess(trade_usd_tp_difference, D(0.01)) + self.assertLess(trade_usd_sl_difference, D(0.02)) + self.close_trade(complex_trade) diff --git a/core/tests/trading/test_risk.py b/core/tests/trading/test_risk.py index 170e71c..86715c8 100644 --- a/core/tests/trading/test_risk.py +++ b/core/tests/trading/test_risk.py @@ -1,6 +1,6 @@ from django.test import TestCase -from core.exchanges import common +from core.exchanges import convert from core.models import RiskModel, User from core.trading import risk @@ -228,7 +228,7 @@ class RiskModelTestCase(TestCase): # Hardcoded prices to avoid calling market API here "stop_loss_usd": 50, # 5% of $1000 } - converted = common.convert_open_trades([trade]) + converted = convert.convert_trades([trade]) self.assertEqual(converted[0]["stop_loss_percent"], 5) self.assertEqual(converted[0]["stop_loss_usd"], 50) max_risk_check = risk.check_max_risk(self.risk_model, 1000, converted) @@ -253,7 +253,7 @@ class RiskModelTestCase(TestCase): # Hardcoded prices to avoid calling market API here "stop_loss_usd": 40, # 4% of $1000 } - converted = common.convert_open_trades([trade, trade]) + converted = convert.convert_trades([trade, trade]) self.assertEqual(converted[0]["stop_loss_percent"], 4) self.assertEqual(converted[0]["stop_loss_usd"], 40) max_risk_check = risk.check_max_risk(self.risk_model, 1000, converted) @@ -277,7 +277,7 @@ class RiskModelTestCase(TestCase): # Hardcoded prices to avoid calling market API here "stop_loss_usd": 100, # 10% of $1000 } - converted = common.convert_open_trades([trade]) + converted = convert.convert_trades([trade]) self.assertEqual(converted[0]["stop_loss_percent"], 10) self.assertEqual(converted[0]["stop_loss_usd"], 100) max_risk_check = risk.check_max_risk(self.risk_model, 1000, converted) @@ -302,7 +302,7 @@ class RiskModelTestCase(TestCase): # Hardcoded prices to avoid calling market API here "stop_loss_usd": 50, # 5% of $1000 } - converted = common.convert_open_trades([trade, trade]) + converted = convert.convert_trades([trade, trade]) self.assertEqual(converted[0]["stop_loss_percent"], 5) self.assertEqual(converted[0]["stop_loss_usd"], 50) self.assertEqual(converted[1]["stop_loss_percent"], 5) @@ -334,7 +334,7 @@ class RiskModelTestCase(TestCase): trade2["trailingStopLossOrder"] = {"price": 0.95} del trade2["stopLossOrder"] - converted = common.convert_open_trades([trade, trade2]) + converted = convert.convert_trades([trade, trade2]) self.assertEqual(converted[0]["stop_loss_percent"], 5) self.assertEqual(converted[0]["stop_loss_usd"], 50) self.assertEqual(converted[1]["trailing_stop_loss_percent"], 5) @@ -365,7 +365,7 @@ class RiskModelTestCase(TestCase): trade2 = trade.copy() trade2["trailingStopLossOrder"] = {"price": 0.951} - converted = common.convert_open_trades([trade, trade2]) + converted = convert.convert_trades([trade, trade2]) self.assertEqual(converted[0]["stop_loss_percent"], 5) self.assertEqual(converted[0]["stop_loss_usd"], 50) self.assertEqual(float(converted[1]["trailing_stop_loss_percent"]), 4.9) diff --git a/core/trading/market.py b/core/trading/market.py index 1f28f0f..6184f77 100644 --- a/core/trading/market.py +++ b/core/trading/market.py @@ -1,7 +1,7 @@ from datetime import datetime from decimal import Decimal as D -from core.exchanges import GenericAPIError +from core.exchanges import GenericAPIError, common from core.lib.notify import sendmsg from core.models import Account, Strategy, Trade from core.trading.crossfilter import crossfilter @@ -10,21 +10,50 @@ from core.util import logs log = logs.get_logger(__name__) -def side_to_direction(side): +def side_to_direction(side, flip_direction=False): """ Convert a side to a direction. + :param side: Side, e.g. long, short + :param flip_direction: Flip the direction + :return: Direction, e.g. buy, sell """ if side == "long": + if flip_direction: + return "sell" return "buy" elif side == "short": + if flip_direction: + return "buy" return "sell" else: return False +def direction_to_side(direction, flip_side=False): + """ + Convert a direction to a side. + :param direction: Direction, e.g. buy, sell + :param flip_side: Flip the side + :return: Side, e.g. long, short + """ + if direction == "buy": + if flip_side: + return "short" + return "long" + elif direction == "sell": + if flip_side: + return "long" + return "short" + else: + return False + + def convert_trades_to_usd(account, trades): """ - Convert a list of trades to USD. + Convert a list of trades to USD. Input will also be mutated. + :param account: Account object + :param trades: List of trades + :return: List of trades, with amount_usd added """ for trade in trades: amount = trade["amount"] @@ -35,34 +64,19 @@ def convert_trades_to_usd(account, trades): print("BASE", base) print("QUOTE", quote) print("AMOUNT", amount) - amount_usd = to_currency(direction, account, amount, quote, "USD") - print("AMOUNT USD", amount_usd) + amount_usd = common.to_currency(direction, account, amount, base, "USD") + print("TRADE AMOUNT USD", amount_usd) + trade["trade_amount_usd"] = amount_usd + if "stop_loss_percent" in trade: + trade["stop_loss_usd"] = (trade["stop_loss_percent"] / 100) * amount_usd + if "take_profit_percent" in trade: + trade["take_profit_usd"] = (trade["take_profit_percent"] / 100) * amount_usd + if "trailing_stop_loss_percent" in trade: + trade["trailing_stop_loss_usd"] = ( + trade["trailing_stop_loss_percent"] / 100 + ) * amount_usd - -def get_pair(account, base, quote, invert=False): - """ - Get the pair for the given account and currencies. - :param account: Account object - :param base: Base currency - :param quote: Quote currency - :param invert: Invert the pair - :return: currency symbol, e.g. BTC_USD, BTC/USD, etc. - """ - # Currently we only have two exchanges with different pair separators - if account.exchange == "alpaca": - separator = "/" - elif account.exchange == "oanda": - separator = "_" - - # Flip the pair if needed - if invert: - symbol = f"{quote.upper()}{separator}{base.upper()}" - else: - symbol = f"{base.upper()}{separator}{quote.upper()}" - # Check it exists - if symbol not in account.supported_symbols: - return False - return symbol + return trades def get_base_quote(exchange, symbol): @@ -80,49 +94,6 @@ def get_base_quote(exchange, symbol): return (base, quote) -def to_currency(direction, account, amount, from_currency, to_currency): - """ - Convert an amount from one currency to another. - :param direction: Direction of the trade - :param account: Account object - :param amount: Amount to convert - :param from_currency: Currency to convert from - :param to_currency: Currency to convert to - :return: Converted amount - """ - inverted = False - - # This is needed because OANDA has different values for bid and ask - if direction == "buy": - price_index = "bids" - elif direction == "sell": - price_index = "asks" - symbol = get_pair(account, from_currency, to_currency) - if not symbol: - symbol = get_pair(account, from_currency, to_currency, invert=True) - inverted = True - - # Bit of a hack but it works - if not symbol: - log.error(f"Could not find symbol for {from_currency} -> {to_currency}") - raise Exception("Could not find symbol") - try: - prices = account.client.get_currencies([symbol]) - except GenericAPIError as e: - log.error(f"Error getting currencies and inverted currencies: {e}") - return None - price = D(prices["prices"][0][price_index][0]["price"]) - - # If we had to flip base and quote, we need to use the reciprocal of the price - if inverted: - price = D(1.0) / price - - # Convert the amount to the destination currency - converted = D(amount) * price - - return converted - - def get_price(account, direction, symbol): """ Get the price for a given symbol. @@ -168,7 +139,7 @@ def get_trade_size_in_base(direction, account, strategy, cash_balance, base): if account.currency.lower() == base.lower(): trade_size_in_base = amount_fiat else: - trade_size_in_base = to_currency( + trade_size_in_base = common.to_currency( direction, account, amount_fiat, account.currency, base ) log.debug(f"Trade size in base: {trade_size_in_base}") @@ -381,7 +352,7 @@ def execute_strategy(callback, strategy, func): return # Get the pair we are trading - symbol = get_pair(account, base, quote) + symbol = common.get_pair(account, base, quote) if not symbol: sendmsg(user, f"Symbol not supported by account: {symbol}", title="Error") log.error(f"Symbol not supported by account: {symbol}") diff --git a/core/trading/risk.py b/core/trading/risk.py index 2cbf002..0f89c71 100644 --- a/core/trading/risk.py +++ b/core/trading/risk.py @@ -1,19 +1,41 @@ +from decimal import Decimal as D + +from core.exchanges import convert +from core.models import Trade +from core.trading import market + + def check_max_loss(risk_model, initial_balance, account_balance): """ Check that the account balance is within the max loss limit. """ + # print("Max loss percent", risk_model.max_loss_percent) + # print("Initial balance", initial_balance) + # print("Account balance", account_balance) + # max_loss_percent = risk_model.max_loss_percent + # print("Max loss ratio", (max_loss_percent / 100)) + # max_loss = initial_balance * (max_loss_percent / 100) + # print("Max loss", max_loss) + # return account_balance > max_loss + max_loss_percent = risk_model.max_loss_percent - max_loss = initial_balance * (max_loss_percent / 100) - return account_balance > max_loss + + # calculate the inverse of the max loss percent as a ratio + inverse_loss_multiplier = 1 - max_loss_percent / 100 + minimum_balance = initial_balance * inverse_loss_multiplier + + return account_balance > minimum_balance def check_max_risk(risk_model, account_balance_usd, account_trades): """ Check that all of the trades in the account are within the max risk limit. """ - max_risk_percent = risk_model.max_risk_percent + max_risk_percent = D(risk_model.max_risk_percent) + print("Max risk percent", max_risk_percent) # Calculate the max risk of the account in USD - max_risk_usd = account_balance_usd * (max_risk_percent / 100) + max_risk_usd = account_balance_usd * (max_risk_percent / D(100)) + print("Max risk USD", max_risk_usd) total_risk = 0 for trade in account_trades: max_tmp = [] @@ -24,8 +46,13 @@ def check_max_risk(risk_model, account_balance_usd, account_trades): max_tmp.append(trade["stop_loss_usd"]) if "trailing_stop_loss_usd" in trade: max_tmp.append(trade["trailing_stop_loss_usd"]) - total_risk += max(max_tmp) + if max_tmp: + print("MAX TMP", max_tmp) + total_risk += max(max_tmp) + + print("total risk", total_risk) allowed = total_risk < max_risk_usd + print("check amx risk allowed", allowed) return allowed @@ -33,6 +60,7 @@ def check_max_open_trades(risk_model, account_trades): """ Check that the number of trades in the account is within the max open trades limit. """ + print("LEN ACCOUNT TRADES", len(account_trades)) return len(account_trades) < risk_model.max_open_trades @@ -60,14 +88,25 @@ def check_risk(risk_model, account, proposed_trade): max_loss_check = check_max_loss( risk_model, account.initial_balance, account.client.get_balance() ) + print("Max loss check", max_loss_check) if not max_loss_check: return {"allowed": False, "reason": "Maximum loss exceeded."} # Check that the account max trades is not exceeded - account_trades = account.client.get_all_open_trades() # TODO - account_trades.append(proposed_trade) # TODO + account_trades = account.client.get_all_open_trades() + print("Account trades: ", account_trades) + if isinstance(proposed_trade, Trade): + proposed_trade = proposed_trade.__dict__ + account_trades.append(proposed_trade) + print("After append", account_trades) + + account_trades = convert.convert_trades(account_trades) + print("After convert", account_trades) + account_trades = market.convert_trades_to_usd(account, account_trades) + print("After convert to USD", account_trades) max_open_trades_check = check_max_open_trades(risk_model, account_trades) + print("Max open trades check: ", max_open_trades_check) if not max_open_trades_check: return {"allowed": False, "reason": "Maximum open trades exceeded."} @@ -75,10 +114,16 @@ def check_risk(risk_model, account, proposed_trade): max_open_trades_per_symbol_check = check_max_open_trades_per_symbol( risk_model, account_trades ) + print("Max open trades per symbol check: ", max_open_trades_per_symbol_check) if not max_open_trades_per_symbol_check: return {"allowed": False, "reason": "Maximum open trades per symbol exceeded."} # Check that the max risk is not exceeded - max_risk_check = check_max_risk(risk_model, account_trades) + account_balance_usd = account.client.get_balance(return_usd=True) + print("Account balance USD (not)", account_balance_usd) + max_risk_check = check_max_risk(risk_model, account_balance_usd, account_trades) + print("Max risk check: ", max_risk_check) if not max_risk_check: return {"allowed": False, "reason": "Maximum risk exceeded."} + + return {"allowed": True}