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

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()
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 = {

View File

@ -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.",
}

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(
"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)

View File

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

View File

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

View File

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

View File

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

View File

@ -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}")

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):
"""
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"])
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}