From 5c68191e5b9fd59d22724217665f981db2553ee5 Mon Sep 17 00:00:00 2001 From: Mark Veidemanis Date: Tue, 15 Nov 2022 07:20:17 +0000 Subject: [PATCH] Calculate price slippage more reliably and allow specifying order type and time in force --- core/exchanges/oanda.py | 2 +- core/forms.py | 4 + core/lib/market.py | 100 ++++++++++++------ .../0023_alter_strategy_options_and_more.py | 27 +++++ .../migrations/0024_strategy_time_in_force.py | 18 ++++ .../0025_alter_strategy_time_in_force.py | 18 ++++ core/migrations/0026_trade_time_in_force.py | 18 ++++ core/models.py | 32 +++--- 8 files changed, 176 insertions(+), 43 deletions(-) create mode 100644 core/migrations/0023_alter_strategy_options_and_more.py create mode 100644 core/migrations/0024_strategy_time_in_force.py create mode 100644 core/migrations/0025_alter_strategy_time_in_force.py create mode 100644 core/migrations/0026_trade_time_in_force.py diff --git a/core/exchanges/oanda.py b/core/exchanges/oanda.py index 2a92097..a18dd2b 100644 --- a/core/exchanges/oanda.py +++ b/core/exchanges/oanda.py @@ -54,7 +54,7 @@ class OANDAExchange(BaseExchange): # "price": "1.5000", - added later "stopLossOnFill": {"timeInForce": "GTC", "price": str(trade.stop_loss)}, "takeProfitOnFill": {"price": str(trade.take_profit)}, - "timeInForce": "GTC", + "timeInForce": trade.time_in_force.upper(), "instrument": trade.symbol, "units": str(amount), "type": trade.type.upper(), diff --git a/core/forms.py b/core/forms.py index a42f409..d5b2db0 100644 --- a/core/forms.py +++ b/core/forms.py @@ -64,11 +64,14 @@ class StrategyForm(ModelForm): "name", "description", "account", + "order_type", + "time_in_force", "hooks", "enabled", "take_profit_percent", "stop_loss_percent", "price_slippage_percent", + "callback_price_deviation_percent", "trade_size_percent", ) @@ -84,6 +87,7 @@ class TradeForm(ModelForm): "account", "symbol", "type", + "time_in_force", "amount", "price", "stop_loss", diff --git a/core/lib/market.py b/core/lib/market.py index b2ead83..7c07b54 100644 --- a/core/lib/market.py +++ b/core/lib/market.py @@ -85,6 +85,27 @@ def to_currency(direction, account, amount, from_currency, to_currency): return converted +def get_price(account, direction, symbol): + """ + Get the price for a given symbol. + :param account: Account object + :param direction: direction of the trade + :param symbol: symbol + :return: price of bid for buys, price of ask for sells + """ + if direction == "buy": + price_index = "bids" + elif direction == "sell": + price_index = "asks" + try: + prices = account.client.get_currencies([symbol]) + except GenericAPIError as e: + log.error(f"Error getting currencies: {e}") + return None + price = D(prices["prices"][0][price_index][0]["price"]) + return price + + def get_trade_size_in_base(direction, account, strategy, cash_balance, base): """ Get the trade size in the base currency. @@ -159,31 +180,57 @@ def get_tp_sl(direction, strategy, price): return (stop_loss, take_profit) -def get_price_bound(direction, strategy, price): +def get_price_bound(direction, strategy, price, current_price): """ Get the price bound for a given price using the slippage from the strategy. + * Check that the price of the callback is within the callback price deviation of the + current price + * Calculate the price bounds such that the maximum slippage should be within the + price slippage relative to the current price. + Note that the maximum actual slippage may be as high as the sum of these two values. :param direction: Direction of the trade :param strategy: Strategy object :param price: Price of the trade + :param current_price: current price from the exchange :return: Price bound """ - # Convert the slippage to a ratio + # Convert the callback price deviation to a ratio + callback_price_deviation_as_ratio = D( + strategy.callback_price_deviation_percent + ) / D(100) + log.debug(f"Callback price deviation as ratio: {callback_price_deviation_as_ratio}") + + maximum_price_deviation = D(current_price) * D(callback_price_deviation_as_ratio) + + # Ensure the current price is within price_slippage_as_ratio of the callback price + if abs(current_price - price) <= maximum_price_deviation: + log.debug("Current price is within price deviation of callback price") + else: + log.error("Current price is not within price deviation of callback price") + log.debug(f"Difference: {abs(current_price - price)}") + return None + + # Convert the maximum price slippage to a ratio price_slippage_as_ratio = D(strategy.price_slippage_percent) / D(100) - log.debug(f"Price slippage as ratio: {price_slippage_as_ratio}") + log.debug(f"Maximum price slippage as ratio: {price_slippage_as_ratio}") # Calculate the price bound by multiplying with the price # The price bound is the worst price we are willing to pay for the trade - price_slippage = D(price) * D(price_slippage_as_ratio) - log.debug(f"Price slippage: {price_slippage}") + price_slippage = D(current_price) * D(price_slippage_as_ratio) + log.debug(f"Maximum deviation from callback price: {price_slippage}") + + current_price_slippage = D(current_price) * D(price_slippage_as_ratio) + log.debug(f"Maximum deviation from current price: {current_price_slippage}") # Subtract slippage for buys, since we lose money if the price goes down if direction == "buy": - price_bound = D(price) - D(price_slippage) + price_bound = D(current_price) - D(price_slippage) # Add slippage for sells, since we lose money if the price goes up elif direction == "sell": - price_bound = D(price) + D(price_slippage) + price_bound = D(current_price) + D(price_slippage) + log.debug(f"Price bound: {price_bound}") return price_bound @@ -247,22 +294,11 @@ def execute_strategy(callback, strategy): price = round(D(callback.price), display_precision) log.debug(f"Extracted price of quote: {price}") - # market_from_alpaca = get_market_value(account, symbol) - # change_percent = abs(((float(market_from_alpaca)-price)/price)*100) - # if change_percent > strategy.price_slippage_percent: - # log.error(f"Price slippage too high: {change_percent}") - # return False + type = strategy.order_type - # type = "limit" - - # Only using market orders for now, but with price bounds, so it's a similar - # amount of protection from market fluctuations - # type = "market" - - # For OANDA we can use the price since it should match exactly - # Not yet sure how to use both limit and market orders - # type = "limit" - type = "market" + current_price = get_price(account, direction, symbol) + log.debug(f"Callback price: {price}") + log.debug(f"Current price: {current_price}") # Convert the trade size, which is currently in the account's base currency, # to the base currency of the pair we are trading @@ -274,15 +310,18 @@ def execute_strategy(callback, strategy): stop_loss, take_profit = get_tp_sl(direction, strategy, price) # Calculate price bound and round to the display precision - price_bound = round(get_price_bound(direction, strategy, price), display_precision) + price_bound = get_price_bound(direction, strategy, price, current_price) + if not price_bound: + return + price_bound = round(price_bound, display_precision) - # Use the price reported by the callback for limit orders - if type == "limit": - price_for_trade = price + # # Use the price reported by the callback for limit orders + # if type == "limit": + # price_for_trade = price - # Use the price bound for market orders - elif type == "market": - price_for_trade = price_bound + # # Use the price bound for market orders + # elif type == "market": + # price_for_trade = price_bound # Create object, note that the amount is rounded to the trade precision new_trade = Trade.objects.create( @@ -291,10 +330,11 @@ def execute_strategy(callback, strategy): hook=hook, symbol=symbol, type=type, + time_in_force=strategy.time_in_force, # amount_fiat=amount_fiat, amount=float(round(trade_size_in_base, trade_precision)), # price=price_bound, - price=price_for_trade, + price=price_bound, stop_loss=float(round(stop_loss, display_precision)), take_profit=float(round(take_profit, display_precision)), direction=direction, diff --git a/core/migrations/0023_alter_strategy_options_and_more.py b/core/migrations/0023_alter_strategy_options_and_more.py new file mode 100644 index 0000000..1069df2 --- /dev/null +++ b/core/migrations/0023_alter_strategy_options_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 4.1.3 on 2022-11-15 15:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0022_account_currency'), + ] + + operations = [ + migrations.AlterModelOptions( + name='strategy', + options={'verbose_name_plural': 'strategies'}, + ), + migrations.AddField( + model_name='strategy', + name='callback_price_deviation_percent', + field=models.FloatField(default=0.5), + ), + migrations.AddField( + model_name='strategy', + name='order_type', + field=models.CharField(choices=[('market', 'Market'), ('limit', 'Limit')], default='market', max_length=255), + ), + ] diff --git a/core/migrations/0024_strategy_time_in_force.py b/core/migrations/0024_strategy_time_in_force.py new file mode 100644 index 0000000..6cf374c --- /dev/null +++ b/core/migrations/0024_strategy_time_in_force.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.3 on 2022-11-15 15:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0023_alter_strategy_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='strategy', + name='time_in_force', + field=models.CharField(choices=[('gtc', 'Good Til Cancelled'), ('gfd', 'Good For Day'), ('fok', 'Fill Or Kill'), ('ioc', 'Immediate Or Cancel')], default='gtc', max_length=255), + ), + ] diff --git a/core/migrations/0025_alter_strategy_time_in_force.py b/core/migrations/0025_alter_strategy_time_in_force.py new file mode 100644 index 0000000..f434160 --- /dev/null +++ b/core/migrations/0025_alter_strategy_time_in_force.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.3 on 2022-11-15 15:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0024_strategy_time_in_force'), + ] + + operations = [ + migrations.AlterField( + model_name='strategy', + name='time_in_force', + field=models.CharField(choices=[('gtc', 'GTC (Good Til Cancelled)'), ('gfd', 'GFD (Good For Day)'), ('fok', 'FOK (Fill Or Kill)'), ('ioc', 'IOC (Immediate Or Cancel)')], default='gtc', max_length=255), + ), + ] diff --git a/core/migrations/0026_trade_time_in_force.py b/core/migrations/0026_trade_time_in_force.py new file mode 100644 index 0000000..070a273 --- /dev/null +++ b/core/migrations/0026_trade_time_in_force.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.3 on 2022-11-15 15:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0025_alter_strategy_time_in_force'), + ] + + operations = [ + migrations.AddField( + model_name='trade', + name='time_in_force', + field=models.CharField(choices=[('gtc', 'GTC (Good Til Cancelled)'), ('gfd', 'GFD (Good For Day)'), ('fok', 'FOK (Fill Or Kill)'), ('ioc', 'IOC (Immediate Or Cancel)')], default='gtc', max_length=255), + ), + ] diff --git a/core/models.py b/core/models.py index 10e893d..bea1a86 100644 --- a/core/models.py +++ b/core/models.py @@ -10,6 +10,20 @@ from core.util import logs log = logs.get_logger(__name__) EXCHANGE_MAP = {"alpaca": AlpacaExchange, "oanda": OANDAExchange} +TYPE_CHOICES = ( + ("market", "Market"), + ("limit", "Limit"), +) +DIRECTION_CHOICES = ( + ("buy", "Buy"), + ("sell", "Sell"), +) +TIF_CHOICES = ( + ("gtc", "GTC (Good Til Cancelled)"), + ("gfd", "GFD (Good For Day)"), + ("fok", "FOK (Fill Or Kill)"), + ("ioc", "IOC (Immediate Or Cancel)"), +) class Plan(models.Model): @@ -140,10 +154,6 @@ class Session(models.Model): class Hook(models.Model): - DIRECTION_CHOICES = ( - ("buy", "Buy"), - ("sell", "Sell"), - ) user = models.ForeignKey(User, on_delete=models.CASCADE) name = models.CharField(max_length=1024, null=True, blank=True, unique=True) hook = models.CharField(max_length=255, unique=True) @@ -155,18 +165,11 @@ class Hook(models.Model): class Trade(models.Model): - TYPE_CHOICES = ( - ("market", "Market"), - ("limit", "Limit"), - ) - DIRECTION_CHOICES = ( - ("buy", "Buy"), - ("sell", "Sell"), - ) user = models.ForeignKey(User, on_delete=models.CASCADE) account = models.ForeignKey(Account, on_delete=models.CASCADE) hook = models.ForeignKey(Hook, on_delete=models.CASCADE, null=True, blank=True) symbol = models.CharField(max_length=255) + time_in_force = models.CharField(choices=TIF_CHOICES, max_length=255, default="gtc") type = models.CharField(choices=TYPE_CHOICES, max_length=255) amount = models.FloatField(null=True, blank=True) amount_usd = models.FloatField(null=True, blank=True) @@ -213,11 +216,16 @@ class Strategy(models.Model): name = models.CharField(max_length=255) description = models.TextField(null=True, blank=True) account = models.ForeignKey(Account, on_delete=models.CASCADE) + order_type = models.CharField( + choices=TYPE_CHOICES, max_length=255, default="market" + ) + time_in_force = models.CharField(choices=TIF_CHOICES, max_length=255, default="gtc") hooks = models.ManyToManyField(Hook) enabled = models.BooleanField(default=False) take_profit_percent = models.FloatField(default=1.5) stop_loss_percent = models.FloatField(default=1.0) price_slippage_percent = models.FloatField(default=2.5) + callback_price_deviation_percent = models.FloatField(default=0.5) trade_size_percent = models.FloatField(default=0.5) class Meta: