Continue implementing live risk checks

This commit is contained in:
Mark Veidemanis 2023-01-11 19:46:47 +00:00
parent 93be9e6ffe
commit e55f903f42
Signed by: m
GPG Key ID: 5ACFCEED46C0904F
12 changed files with 527 additions and 244 deletions

View File

@ -1,6 +1,10 @@
from decimal import Decimal as D from decimal import Decimal as D
from core.exchanges import GenericAPIError
from core.lib.elastic import store_msg 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): 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. Get the pair for the given account and currencies.
Positive values indicate a profit, negative values indicate a loss. :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) # Currently we only have two exchanges with different pair separators
if side == "long": if account.exchange == "alpaca":
initial_price = D(current_price) - pl_per_unit separator = "/"
elif account.exchange == "oanda":
separator = "_"
# Flip the pair if needed
if invert:
symbol = f"{quote.upper()}{separator}{base.upper()}"
else: else:
initial_price = D(current_price) + pl_per_unit symbol = f"{base.upper()}{separator}{quote.upper()}"
# Check it exists
# Get the percent change of the TP price from the initial price. if symbol not in account.supported_symbols:
change_percent = ((initial_price - D(tp_price)) / initial_price) * 100 return False
return symbol
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): def to_currency(direction, account, amount, from_currency, to_currency):
""" """
Determine the percent change of the SL price from the initial price. Convert an amount from one currency to another.
Positive values indicate a loss, negative values indicate a profit. :param direction: Direction of the trade
This may seem backwards, but it is important to note that by default, :param account: Account object
SL indicates a loss, and positive values should be expected. :param amount: Amount to convert
Negative values indicate a negative loss, so a profit. :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 we're converting to the same currency, just return the amount
if side == "long": if from_currency == to_currency:
initial_price = D(current_price) - pl_per_unit return amount
else: inverted = False
initial_price = D(current_price) + pl_per_unit
# 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. # Bit of a hack but it works
change_percent = ((initial_price - D(sl_price)) / initial_price) * 100 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 we had to flip base and quote, we need to use the reciprocal of the price
# if side == "long": if inverted:
# change_percent *= -1 price = D(1.0) / price
if side == "long": # Convert the amount to the destination currency
if D(sl_price) > initial_price: converted = D(amount) * price
profit = True
else:
profit = False
else:
if D(sl_price) < initial_price:
profit = True
else:
profit = False
if profit: return converted
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

211
core/exchanges/convert.py Normal file
View File

@ -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

View File

@ -36,18 +36,25 @@ class OANDAExchange(BaseExchange):
response = self.get_instruments() response = self.get_instruments()
return [x["name"] for x in response["itemlist"]] 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) r = accounts.AccountSummary(self.account_id)
response = self.call(r) response = self.call(r)
print("RESPONSE", response)
balance = float(response["balance"]) 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( common.get_balance_hook(
self.account.user.id, self.account.user.id,
self.account.user.username, self.account.user.username,
self.account.id, self.account.id,
self.account.name, self.account.name,
balance, balance_usd,
) )
if return_usd:
return balance_usd
return balance return balance
def get_market_value(self, symbol): def get_market_value(self, symbol):
@ -138,7 +145,7 @@ class OANDAExchange(BaseExchange):
def get_all_open_trades(self): def get_all_open_trades(self):
r = trades.OpenTrades(accountID=self.account_id) r = trades.OpenTrades(accountID=self.account_id)
return self.call(r) return self.call(r)["itemlist"]
def close_position(self, side, symbol): def close_position(self, side, symbol):
data = { data = {

View File

@ -119,6 +119,7 @@ class AccountForm(RestrictedFormMixin, ModelForm):
"sandbox", "sandbox",
"enabled", "enabled",
"risk_model", "risk_model",
"initial_balance",
) )
help_texts = { help_texts = {
"name": "Name of the account. Informational only.", "name": "Name of the account. Informational only.",
@ -128,6 +129,7 @@ class AccountForm(RestrictedFormMixin, ModelForm):
"sandbox": "Whether to use the sandbox/demo or not.", "sandbox": "Whether to use the sandbox/demo or not.",
"enabled": "Whether the account is enabled.", "enabled": "Whether the account is enabled.",
"risk_model": "The risk model to use for this account.", "risk_model": "The risk model to use for this account.",
"initial_balance": "The initial balance of the account.",
} }

View File

@ -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),
),
]

View File

@ -116,6 +116,7 @@ class Account(models.Model):
risk_model = models.ForeignKey( risk_model = models.ForeignKey(
"core.RiskModel", on_delete=models.SET_NULL, null=True, blank=True "core.RiskModel", on_delete=models.SET_NULL, null=True, blank=True
) )
initial_balance = models.FloatField(default=0)
def __str__(self): def __str__(self):
name = f"{self.name} ({self.exchange})" name = f"{self.name} ({self.exchange})"
@ -167,6 +168,10 @@ class Account(models.Model):
def get_by_id(cls, account_id, user): def get_by_id(cls, account_id, user):
return cls.objects.get(id=account_id, user=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): class Session(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE)

View File

@ -1,6 +1,6 @@
from django.test import TestCase 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): class CommonTestCase(TestCase):

View File

@ -86,6 +86,7 @@ If you have done this, please see the following line for more information:
exchange=exchange, exchange=exchange,
api_key=api_key, api_key=api_key,
api_secret=api_secret, api_secret=api_secret,
initial_balance=100,
) )
def setUp(self): def setUp(self):

View File

@ -1,11 +1,12 @@
from decimal import Decimal as D from decimal import Decimal as D
from unittest.mock import patch
from django.test import TestCase from django.test import TestCase
from core.exchanges.common import convert_open_trades from core.exchanges.convert import convert_trades
from core.models import Trade from core.models import RiskModel, Trade
from core.tests.helpers import ElasticMock, LiveBase 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): class LiveTradingTestCase(ElasticMock, LiveBase, TestCase):
@ -21,6 +22,14 @@ class LiveTradingTestCase(ElasticMock, LiveBase, TestCase):
direction="buy", direction="buy",
) )
self.commission = 0.025 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): def test_account_functional(self):
""" """
@ -34,11 +43,12 @@ class LiveTradingTestCase(ElasticMock, LiveBase, TestCase):
if trade: if trade:
posted = trade.post() posted = trade.post()
else: else:
trade = self.trade
posted = self.trade.post() posted = self.trade.post()
# Check the opened trade # Check the opened trade
self.assertEqual(posted["type"], "MARKET_ORDER") self.assertEqual(posted["type"], "MARKET_ORDER")
self.assertEqual(posted["symbol"], "EUR_USD") self.assertEqual(posted["symbol"], trade.symbol)
self.assertEqual(posted["units"], "10") self.assertEqual(posted["units"], str(trade.amount))
self.assertEqual(posted["timeInForce"], "FOK") self.assertEqual(posted["timeInForce"], "FOK")
return posted return posted
@ -48,14 +58,17 @@ class LiveTradingTestCase(ElasticMock, LiveBase, TestCase):
trade.refresh_from_db() trade.refresh_from_db()
closed = self.account.client.close_trade(trade.order_id) closed = self.account.client.close_trade(trade.order_id)
else: else:
trade = self.trade
# refresh the trade to get the trade id # refresh the trade to get the trade id
self.trade.refresh_from_db() self.trade.refresh_from_db()
closed = self.account.client.close_trade(self.trade.order_id) closed = self.account.client.close_trade(self.trade.order_id)
# Check the feedback from closing the trade # Check the feedback from closing the trade
print("CLOSED", closed)
print("TRADE AMOUNT", trade.amount)
self.assertEqual(closed["type"], "MARKET_ORDER") self.assertEqual(closed["type"], "MARKET_ORDER")
self.assertEqual(closed["symbol"], "EUR_USD") self.assertEqual(closed["symbol"], trade.symbol)
self.assertEqual(closed["units"], "-10") self.assertEqual(closed["units"], str(0 - int(trade.amount)))
self.assertEqual(closed["timeInForce"], "FOK") self.assertEqual(closed["timeInForce"], "FOK")
self.assertEqual(closed["reason"], "TRADE_CLOSE") self.assertEqual(closed["reason"], "TRADE_CLOSE")
@ -78,7 +91,7 @@ class LiveTradingTestCase(ElasticMock, LiveBase, TestCase):
trades = self.account.client.get_all_open_trades() trades = self.account.client.get_all_open_trades()
self.trade.refresh_from_db() self.trade.refresh_from_db()
found = False found = False
for trade in trades["itemlist"]: for trade in trades:
if trade["id"] == self.trade.order_id: if trade["id"] == self.trade.order_id:
self.assertEqual(trade["symbol"], "EUR_USD") self.assertEqual(trade["symbol"], "EUR_USD")
self.assertEqual(trade["currentUnits"], "10") self.assertEqual(trade["currentUnits"], "10")
@ -91,14 +104,11 @@ class LiveTradingTestCase(ElasticMock, LiveBase, TestCase):
if not found: if not found:
self.fail("Could not find the trade in the list of open trades") self.fail("Could not find the trade in the list of open trades")
def test_convert_open_trades(self): def create_complex_trade(self, direction, amount, symbol, tp_percent, sl_percent):
""" eur_usd_price = market.get_price(self.account, direction, symbol)
Test converting open trades response to Trade-like format. trade_tp = market.get_tp(direction, tp_percent, eur_usd_price)
""" trade_sl = market.get_sl(direction, sl_percent, eur_usd_price)
eur_usd_price = get_price(self.account, "buy", "EUR_USD") # trade_tsl = market.get_sl("buy", 1, eur_usd_price, return_var=True)
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)
# # TP 1% profit # # TP 1% profit
# trade_tp = eur_usd_price * D(1.01) # trade_tp = eur_usd_price * D(1.01)
# # SL 2% loss # # SL 2% loss
@ -106,7 +116,7 @@ class LiveTradingTestCase(ElasticMock, LiveBase, TestCase):
# # TSL 1% loss # # TSL 1% loss
# trade_tsl = eur_usd_price * D(0.99) # 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 # Round everything to the display precision
trade_tp = round(trade_tp, display_precision) trade_tp = round(trade_tp, display_precision)
@ -116,19 +126,79 @@ class LiveTradingTestCase(ElasticMock, LiveBase, TestCase):
complex_trade = Trade.objects.create( complex_trade = Trade.objects.create(
user=self.user, user=self.user,
account=self.account, account=self.account,
symbol="EUR_USD", symbol=symbol,
time_in_force="FOK", time_in_force="FOK",
type="market", type="market",
amount=10, amount=amount,
direction="buy", direction=direction,
take_profit=trade_tp, take_profit=trade_tp,
stop_loss=trade_sl, stop_loss=trade_sl,
# trailing_stop_loss=trade_tsl, # 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) self.open_trade(complex_trade)
# Get and annotate the trades
trades = self.account.client.get_all_open_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) self.assertEqual(len(trades_converted), 1)
expected_tp_percent = D(1 - self.commission) expected_tp_percent = D(1 - self.commission)
expected_sl_percent = D(2 - 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(tp_percent_difference, max_difference)
self.assertLess(sl_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) self.close_trade(complex_trade)

View File

@ -1,6 +1,6 @@
from django.test import TestCase from django.test import TestCase
from core.exchanges import common from core.exchanges import convert
from core.models import RiskModel, User from core.models import RiskModel, User
from core.trading import risk from core.trading import risk
@ -228,7 +228,7 @@ class RiskModelTestCase(TestCase):
# Hardcoded prices to avoid calling market API here # Hardcoded prices to avoid calling market API here
"stop_loss_usd": 50, # 5% of $1000 "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_percent"], 5)
self.assertEqual(converted[0]["stop_loss_usd"], 50) self.assertEqual(converted[0]["stop_loss_usd"], 50)
max_risk_check = risk.check_max_risk(self.risk_model, 1000, converted) 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 # Hardcoded prices to avoid calling market API here
"stop_loss_usd": 40, # 4% of $1000 "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_percent"], 4)
self.assertEqual(converted[0]["stop_loss_usd"], 40) self.assertEqual(converted[0]["stop_loss_usd"], 40)
max_risk_check = risk.check_max_risk(self.risk_model, 1000, converted) 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 # Hardcoded prices to avoid calling market API here
"stop_loss_usd": 100, # 10% of $1000 "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_percent"], 10)
self.assertEqual(converted[0]["stop_loss_usd"], 100) self.assertEqual(converted[0]["stop_loss_usd"], 100)
max_risk_check = risk.check_max_risk(self.risk_model, 1000, converted) 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 # Hardcoded prices to avoid calling market API here
"stop_loss_usd": 50, # 5% of $1000 "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_percent"], 5)
self.assertEqual(converted[0]["stop_loss_usd"], 50) self.assertEqual(converted[0]["stop_loss_usd"], 50)
self.assertEqual(converted[1]["stop_loss_percent"], 5) self.assertEqual(converted[1]["stop_loss_percent"], 5)
@ -334,7 +334,7 @@ class RiskModelTestCase(TestCase):
trade2["trailingStopLossOrder"] = {"price": 0.95} trade2["trailingStopLossOrder"] = {"price": 0.95}
del trade2["stopLossOrder"] 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_percent"], 5)
self.assertEqual(converted[0]["stop_loss_usd"], 50) self.assertEqual(converted[0]["stop_loss_usd"], 50)
self.assertEqual(converted[1]["trailing_stop_loss_percent"], 5) self.assertEqual(converted[1]["trailing_stop_loss_percent"], 5)
@ -365,7 +365,7 @@ class RiskModelTestCase(TestCase):
trade2 = trade.copy() trade2 = trade.copy()
trade2["trailingStopLossOrder"] = {"price": 0.951} 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_percent"], 5)
self.assertEqual(converted[0]["stop_loss_usd"], 50) self.assertEqual(converted[0]["stop_loss_usd"], 50)
self.assertEqual(float(converted[1]["trailing_stop_loss_percent"]), 4.9) self.assertEqual(float(converted[1]["trailing_stop_loss_percent"]), 4.9)

View File

@ -1,7 +1,7 @@
from datetime import datetime from datetime import datetime
from decimal import Decimal as D 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.lib.notify import sendmsg
from core.models import Account, Strategy, Trade from core.models import Account, Strategy, Trade
from core.trading.crossfilter import crossfilter from core.trading.crossfilter import crossfilter
@ -10,21 +10,50 @@ from core.util import logs
log = logs.get_logger(__name__) log = logs.get_logger(__name__)
def side_to_direction(side): def side_to_direction(side, flip_direction=False):
""" """
Convert a side to a direction. 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 side == "long":
if flip_direction:
return "sell"
return "buy" return "buy"
elif side == "short": elif side == "short":
if flip_direction:
return "buy"
return "sell" return "sell"
else: else:
return False 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): 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: for trade in trades:
amount = trade["amount"] amount = trade["amount"]
@ -35,34 +64,19 @@ def convert_trades_to_usd(account, trades):
print("BASE", base) print("BASE", base)
print("QUOTE", quote) print("QUOTE", quote)
print("AMOUNT", amount) print("AMOUNT", amount)
amount_usd = to_currency(direction, account, amount, quote, "USD") amount_usd = common.to_currency(direction, account, amount, base, "USD")
print("AMOUNT USD", amount_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
return trades
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
def get_base_quote(exchange, symbol): def get_base_quote(exchange, symbol):
@ -80,49 +94,6 @@ def get_base_quote(exchange, symbol):
return (base, quote) 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): def get_price(account, direction, symbol):
""" """
Get the price for a given 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(): if account.currency.lower() == base.lower():
trade_size_in_base = amount_fiat trade_size_in_base = amount_fiat
else: else:
trade_size_in_base = to_currency( trade_size_in_base = common.to_currency(
direction, account, amount_fiat, account.currency, base direction, account, amount_fiat, account.currency, base
) )
log.debug(f"Trade size in base: {trade_size_in_base}") log.debug(f"Trade size in base: {trade_size_in_base}")
@ -381,7 +352,7 @@ def execute_strategy(callback, strategy, func):
return return
# Get the pair we are trading # Get the pair we are trading
symbol = get_pair(account, base, quote) symbol = common.get_pair(account, base, quote)
if not symbol: if not symbol:
sendmsg(user, f"Symbol not supported by account: {symbol}", title="Error") sendmsg(user, f"Symbol not supported by account: {symbol}", title="Error")
log.error(f"Symbol not supported by account: {symbol}") log.error(f"Symbol not supported by account: {symbol}")

View File

@ -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): def check_max_loss(risk_model, initial_balance, account_balance):
""" """
Check that the account balance is within the max loss limit. 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_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): 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. 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 # 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 total_risk = 0
for trade in account_trades: for trade in account_trades:
max_tmp = [] max_tmp = []
@ -24,8 +46,13 @@ def check_max_risk(risk_model, account_balance_usd, account_trades):
max_tmp.append(trade["stop_loss_usd"]) max_tmp.append(trade["stop_loss_usd"])
if "trailing_stop_loss_usd" in trade: if "trailing_stop_loss_usd" in trade:
max_tmp.append(trade["trailing_stop_loss_usd"]) 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 allowed = total_risk < max_risk_usd
print("check amx risk allowed", allowed)
return 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. 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 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( max_loss_check = check_max_loss(
risk_model, account.initial_balance, account.client.get_balance() risk_model, account.initial_balance, account.client.get_balance()
) )
print("Max loss check", max_loss_check)
if not max_loss_check: if not max_loss_check:
return {"allowed": False, "reason": "Maximum loss exceeded."} return {"allowed": False, "reason": "Maximum loss exceeded."}
# Check that the account max trades is not exceeded # Check that the account max trades is not exceeded
account_trades = account.client.get_all_open_trades() # TODO account_trades = account.client.get_all_open_trades()
account_trades.append(proposed_trade) # TODO 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) 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: if not max_open_trades_check:
return {"allowed": False, "reason": "Maximum open trades exceeded."} 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( max_open_trades_per_symbol_check = check_max_open_trades_per_symbol(
risk_model, account_trades 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: if not max_open_trades_per_symbol_check:
return {"allowed": False, "reason": "Maximum open trades per symbol exceeded."} return {"allowed": False, "reason": "Maximum open trades per symbol exceeded."}
# Check that the max risk is not 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: if not max_risk_check:
return {"allowed": False, "reason": "Maximum risk exceeded."} return {"allowed": False, "reason": "Maximum risk exceeded."}
return {"allowed": True}