Continue implementing live risk checks
This commit is contained in:
parent
93be9e6ffe
commit
e55f903f42
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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 = {
|
||||
|
|
|
@ -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.",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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}")
|
||||
|
|
|
@ -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}
|
||||
|
|
Loading…
Reference in New Issue