diff --git a/app/urls.py b/app/urls.py index 2f00258..afbbf3c 100644 --- a/app/urls.py +++ b/app/urls.py @@ -21,7 +21,7 @@ from django.urls import include, path from django.views.generic import TemplateView from django_otp.forms import OTPAuthenticationForm -from core.views import accounts, base, callbacks, hooks, positions, trades +from core.views import accounts, base, callbacks, hooks, positions, strategies, trades from core.views.stripe_callbacks import Callback urlpatterns = [ @@ -127,6 +127,11 @@ urlpatterns = [ positions.Positions.as_view(), name="positions", ), + path( + "positions////", + positions.PositionAction.as_view(), + name="position_action", + ), # path( # "trades//add//", # trades.TradeAction.as_view(), @@ -142,4 +147,25 @@ urlpatterns = [ # trades.TradeAction.as_view(), # name="trade_action", # ), + path("strategies//", strategies.Strategies.as_view(), name="strategies"), + path( + "strategies//add/", + strategies.StrategiesAction.as_view(), + name="strategies_action", + ), + path( + "strategies//add//", + strategies.StrategiesAction.as_view(), + name="strategies_action", + ), + path( + "strategies//del//", + strategies.StrategiesAction.as_view(), + name="strategies_action", + ), + path( + "strategies//edit//", + strategies.StrategiesAction.as_view(), + name="strategies_action", + ), ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/core/forms.py b/core/forms.py index 4220b01..a42f409 100644 --- a/core/forms.py +++ b/core/forms.py @@ -2,7 +2,7 @@ from django import forms from django.contrib.auth.forms import UserCreationForm from django.forms import ModelForm -from .models import Account, Hook, Trade, User +from .models import Account, Hook, Strategy, Trade, User # Create your forms here. @@ -41,6 +41,7 @@ class HookForm(ModelForm): fields = ( "name", "hook", + "direction", ) @@ -56,6 +57,26 @@ class AccountForm(ModelForm): ) +class StrategyForm(ModelForm): + class Meta: + model = Strategy + fields = ( + "name", + "description", + "account", + "hooks", + "enabled", + "take_profit_percent", + "stop_loss_percent", + "price_slippage_percent", + "trade_size_percent", + ) + + hooks = forms.ModelMultipleChoiceField( + queryset=Hook.objects.all(), widget=forms.CheckboxSelectMultiple + ) + + class TradeForm(ModelForm): class Meta: model = Trade diff --git a/core/lib/market.py b/core/lib/market.py new file mode 100644 index 0000000..f187910 --- /dev/null +++ b/core/lib/market.py @@ -0,0 +1,115 @@ +from alpaca.common.exceptions import APIError + +from core.models import Strategy, Trade +from core.util import logs + +log = logs.get_logger(__name__) + + +def get_balance(account): + account_info = account.client.get_account() + cash = account_info["equity"] + try: + return float(cash) + except ValueError: + return False + + +def get_market_value(account, symbol): + try: + position = account.client.get_position(symbol) + return float(position["market_value"]) + except APIError: + return False + + +def execute_strategy(callback, strategy): + cash_balance = get_balance(strategy.account) + log.debug(f"Cash balance: {cash_balance}") + if not cash_balance: + return None + + user = strategy.user + account = strategy.account + hook = callback.hook + base = callback.base + quote = callback.quote + direction = hook.direction + if quote not in ["usd", "usdt", "usdc", "busd"]: + log.error(f"Quote not compatible with Dollar: {quote}") + return False + quote = "usd" # TODO: MASSIVE HACK + symbol = f"{base.upper()}/{quote.upper()}" + + if symbol not in account.supported_assets: + log.error(f"Symbol not supported by account: {symbol}") + return False + + print(f"Identified pair from callback {symbol}") + + # 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 = "limit" + type = "market" + trade_size_as_ratio = strategy.trade_size_percent / 100 + log.debug(f"Trade size as ratio: {trade_size_as_ratio}") + amount_usd = trade_size_as_ratio * cash_balance + log.debug(f"Trade size: {amount_usd}") + price = callback.price + if not price: + return + log.debug(f"Extracted price of quote: {price}") + + # We can do this because the quote IS in $ or equivalent + trade_size_in_quote = amount_usd / price + log.debug(f"Trade size in quote: {trade_size_in_quote}") + + # calculate sl/tp + stop_loss_as_ratio = strategy.stop_loss_percent / 100 + take_profit_as_ratio = strategy.take_profit_percent / 100 + log.debug(f"Stop loss as ratio: {stop_loss_as_ratio}") + log.debug(f"Take profit as ratio: {take_profit_as_ratio}") + + stop_loss_subtract = price * stop_loss_as_ratio + take_profit_add = price * take_profit_as_ratio + log.debug(f"Stop loss subtract: {stop_loss_subtract}") + log.debug(f"Take profit add: {take_profit_add}") + + stop_loss = price - stop_loss_subtract + take_profit = price + take_profit_add + + log.debug(f"Stop loss: {stop_loss}") + log.debug(f"Take profit: {take_profit}") + + new_trade = Trade.objects.create( + user=user, + account=account, + hook=hook, + symbol=symbol, + type=type, + # amount_usd=amount_usd, + amount=trade_size_in_quote, + # price=price, + stop_loss=stop_loss, + take_profit=take_profit, + direction=direction, + ) + new_trade.save() + posted, info = new_trade.post() + log.debug(f"Posted trade: {posted} - {info}") + + +def process_callback(callback): + log.info(f"Received callback for {callback.hook}") + strategies = Strategy.objects.filter(hooks=callback.hook, enabled=True) + log.debug(f"Matched strategies: {strategies}") + for strategy in strategies: + log.debug(f"Executing strategy {strategy}") + if callback.hook.user != strategy.user: + log.error("Ownership differs between callback and strategy.") + return + execute_strategy(callback, strategy) diff --git a/core/lib/trades.py b/core/lib/trades.py index da99fac..714ee58 100644 --- a/core/lib/trades.py +++ b/core/lib/trades.py @@ -1,5 +1,83 @@ # Trade handling +from alpaca.common.exceptions import APIError +from alpaca.trading.enums import OrderSide, TimeInForce +from alpaca.trading.requests import LimitOrderRequest, MarketOrderRequest + +from core.util import logs + +log = logs.get_logger(__name__) def sync_trades_with_db(user): pass + + +def post_trade(trade): + # the trade is not placed yet + trading_client = trade.account.get_client() + if trade.direction == "buy": + direction = OrderSide.BUY + elif trade.direction == "sell": + direction = OrderSide.SELL + else: + raise Exception("Unknown direction") + + cast = {"symbol": trade.symbol, "side": direction, "time_in_force": TimeInForce.IOC} + if trade.amount is not None: + cast["qty"] = trade.amount + if trade.amount_usd is not None: + cast["notional"] = trade.amount_usd + if not trade.amount and not trade.amount_usd: + return (False, "No amount specified") + if trade.take_profit: + cast["take_profit"] = {"limit_price": trade.take_profit} + if trade.stop_loss: + stop_limit_price = trade.stop_loss - (trade.stop_loss * 0.005) + cast["stop_loss"] = { + "stop_price": trade.stop_loss, + "limit_price": stop_limit_price, + } + if trade.type == "market": + market_order_data = MarketOrderRequest(**cast) + try: + order = trading_client.submit_order(order_data=market_order_data) + except APIError as e: + log.error(f"Error placing market order: {e}") + return (False, e) + elif trade.type == "limit": + if not trade.price: + return (False, "Limit order with no price") + cast["limit_price"] = trade.price + limit_order_data = LimitOrderRequest(**cast) + try: + order = trading_client.submit_order(order_data=limit_order_data) + except APIError as e: + log.error(f"Error placing limit order: {e}") + return (False, e) + + print("ORDER", order) + else: + raise Exception("Unknown trade type") + trade.response = order + trade.status = "posted" + trade.order_id = order["id"] + trade.client_order_id = order["client_order_id"] + trade.save() + return (True, order) + + +def update_trade(self): + pass + + +def close_trade(trade): + pass + + +def get_position_info(account, asset_id): + trading_client = account.get_client() + try: + position = trading_client.get_open_position(asset_id) + except APIError as e: + return (False, e) + return (True, position) diff --git a/core/migrations/0015_strategy.py b/core/migrations/0015_strategy.py new file mode 100644 index 0000000..41c513d --- /dev/null +++ b/core/migrations/0015_strategy.py @@ -0,0 +1,27 @@ +# Generated by Django 4.1.2 on 2022-10-25 21:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0014_alter_account_exchange'), + ] + + operations = [ + migrations.CreateModel( + name='Strategy', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('description', models.TextField(blank=True, null=True)), + ('enabled', models.BooleanField(default=False)), + ('take_profit_percent', models.FloatField(default=300.0)), + ('stop_loss_percent', models.FloatField(default=100.0)), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.account')), + ('hooks', models.ManyToManyField(to='core.hook')), + ], + ), + ] diff --git a/core/migrations/0016_strategy_user_trade_user.py b/core/migrations/0016_strategy_user_trade_user.py new file mode 100644 index 0000000..21d751b --- /dev/null +++ b/core/migrations/0016_strategy_user_trade_user.py @@ -0,0 +1,27 @@ +# Generated by Django 4.1.2 on 2022-10-25 21:26 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0015_strategy'), + ] + + operations = [ + migrations.AddField( + model_name='strategy', + name='user', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + preserve_default=False, + ), + migrations.AddField( + model_name='trade', + name='user', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + preserve_default=False, + ), + ] diff --git a/core/migrations/0017_hook_direction_strategy_price_slippage_percent.py b/core/migrations/0017_hook_direction_strategy_price_slippage_percent.py new file mode 100644 index 0000000..1154443 --- /dev/null +++ b/core/migrations/0017_hook_direction_strategy_price_slippage_percent.py @@ -0,0 +1,24 @@ +# Generated by Django 4.1.2 on 2022-10-26 09:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0016_strategy_user_trade_user'), + ] + + operations = [ + migrations.AddField( + model_name='hook', + name='direction', + field=models.CharField(choices=[('buy', 'Buy'), ('sell', 'Sell')], default='buy', max_length=255), + preserve_default=False, + ), + migrations.AddField( + model_name='strategy', + name='price_slippage_percent', + field=models.FloatField(default=2.5), + ), + ] diff --git a/core/migrations/0018_strategy_trade_size_percent_trade_amount_usd_and_more.py b/core/migrations/0018_strategy_trade_size_percent_trade_amount_usd_and_more.py new file mode 100644 index 0000000..f4e70ba --- /dev/null +++ b/core/migrations/0018_strategy_trade_size_percent_trade_amount_usd_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.1.2 on 2022-10-26 09:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0017_hook_direction_strategy_price_slippage_percent'), + ] + + operations = [ + migrations.AddField( + model_name='strategy', + name='trade_size_percent', + field=models.FloatField(default=2.5), + ), + migrations.AddField( + model_name='trade', + name='amount_usd', + field=models.FloatField(blank=True, null=True), + ), + migrations.AlterField( + model_name='trade', + name='amount', + field=models.FloatField(blank=True, null=True), + ), + ] diff --git a/core/migrations/0019_account_supported_symbols_and_more.py b/core/migrations/0019_account_supported_symbols_and_more.py new file mode 100644 index 0000000..d7c2f84 --- /dev/null +++ b/core/migrations/0019_account_supported_symbols_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.1.2 on 2022-10-27 16:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0018_strategy_trade_size_percent_trade_amount_usd_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='account', + name='supported_symbols', + field=models.JSONField(default=list), + ), + migrations.AlterField( + model_name='strategy', + name='stop_loss_percent', + field=models.FloatField(default=1.0), + ), + migrations.AlterField( + model_name='strategy', + name='take_profit_percent', + field=models.FloatField(default=3.0), + ), + migrations.AlterField( + model_name='trade', + name='symbol', + field=models.CharField(max_length=255), + ), + ] diff --git a/core/migrations/0020_rename_market_item_callback_base_and_more.py b/core/migrations/0020_rename_market_item_callback_base_and_more.py new file mode 100644 index 0000000..affe327 --- /dev/null +++ b/core/migrations/0020_rename_market_item_callback_base_and_more.py @@ -0,0 +1,54 @@ +# Generated by Django 4.1.2 on 2022-10-27 16:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0019_account_supported_symbols_and_more'), + ] + + operations = [ + migrations.RenameField( + model_name='callback', + old_name='market_item', + new_name='base', + ), + migrations.RenameField( + model_name='callback', + old_name='market_contract', + new_name='contract', + ), + migrations.RenameField( + model_name='callback', + old_name='market_exchange', + new_name='exchange', + ), + migrations.RenameField( + model_name='callback', + old_name='market_currency', + new_name='quote', + ), + migrations.RenameField( + model_name='callback', + old_name='timestamp_sent', + new_name='sent', + ), + migrations.RenameField( + model_name='callback', + old_name='timestamp_trade', + new_name='trade', + ), + migrations.AddField( + model_name='callback', + name='price', + field=models.FloatField(blank=True, null=True), + ), + migrations.AddField( + model_name='callback', + name='symbol', + field=models.CharField(default='NUL/NUL', max_length=255), + preserve_default=False, + ), + ] diff --git a/core/models.py b/core/models.py index 1a3227e..671b138 100644 --- a/core/models.py +++ b/core/models.py @@ -1,14 +1,13 @@ import stripe +from alpaca.common.exceptions import APIError from alpaca.trading.client import TradingClient -from alpaca.trading.enums import OrderSide, TimeInForce -from alpaca.trading.requests import LimitOrderRequest, MarketOrderRequest +from alpaca.trading.requests import GetAssetsRequest from django.conf import settings from django.contrib.auth.models import AbstractUser from django.db import models -from serde import ValidationError +from core.lib import trades from core.lib.customers import get_or_create, update_customer_fields -from core.lib.serde import ccxt_s from core.util import logs log = logs.get_logger(__name__) @@ -78,12 +77,46 @@ class Account(models.Model): api_key = models.CharField(max_length=255) api_secret = models.CharField(max_length=255) sandbox = models.BooleanField(default=False) + supported_symbols = models.JSONField(default=list) - def get_account(self): + def __str__(self): + name = f"{self.name} ({self.exchange})" + if self.sandbox: + name += " (sandbox)" + return name + + def save(self, *args, **kwargs): + """ + Override the save function to update supported symbols. + """ + try: + request = GetAssetsRequest(status="active", asset_class="crypto") + assets = self.client.get_all_assets(filter=request) + except APIError as e: + log.error(f"Could not get asset list: {e}") + return False + asset_list = [x["symbol"] for x in assets if "symbol" in x] + self.supported_symbols = asset_list + print("Supported symbols", self.supported_symbols) + + super().save(*args, **kwargs) + + def get_client(self): trading_client = TradingClient( - self.api_key, self.api_secret, paper=self.sandbox + self.api_key, self.api_secret, paper=self.sandbox, raw_data=True ) - return trading_client.get_account() + return trading_client + + @property + def client(self): + """ + Convenience property for one-off API calls. + """ + return self.get_client() + + @classmethod + def get_by_id(cls, account_id, user): + return cls.objects.get(id=account_id, user=user) class Session(models.Model): @@ -95,17 +128,21 @@ 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) + direction = models.CharField(choices=DIRECTION_CHOICES, max_length=255) received = models.IntegerField(default=0) + def __str__(self): + return f"{self.name} ({self.hook})" + class Trade(models.Model): - SYMBOL_CHOICES = ( - ("BTC/USD", "Bitcoin/US Dollar"), - ("LTC/USD", "Litecoin/US Dollar"), - ) TYPE_CHOICES = ( ("market", "Market"), ("limit", "Limit"), @@ -114,11 +151,13 @@ class Trade(models.Model): ("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(choices=SYMBOL_CHOICES, max_length=255) + symbol = models.CharField(max_length=255) type = models.CharField(choices=TYPE_CHOICES, max_length=255) - amount = models.FloatField() + amount = models.FloatField(null=True, blank=True) + amount_usd = models.FloatField(null=True, blank=True) price = models.FloatField(null=True, blank=True) stop_loss = models.FloatField(null=True, blank=True) take_profit = models.FloatField(null=True, blank=True) @@ -134,56 +173,8 @@ class Trade(models.Model): super().__init__(*args, **kwargs) self._original = self - def save(self, *args, **kwargs): - """ - Override the save function to place the trade. - """ - if self.response is None: - # the trade is not placed yet - if self.account.exchange == "alpaca": - trading_client = TradingClient( - self.account.api_key, - self.account.api_secret, - paper=self.account.sandbox, - ) - if self.direction == "buy": - direction = OrderSide.BUY - elif self.direction == "sell": - direction = OrderSide.SELL - else: - raise Exception("Unknown direction") - if self.type == "market": - - market_order_data = MarketOrderRequest( - symbol=self.symbol, - qty=self.amount, - side=OrderSide.BUY, - time_in_force=TimeInForce.IOC, - ) - order = trading_client.submit_order(order_data=market_order_data) - elif self.type == "limit": - limit_order_data = LimitOrderRequest( - symbol=self.symbol, - limit_price=self.price, - qty=self.amount, - side=direction, - time_in_force=TimeInForce.IOC, - ) - order = trading_client.submit_order(order_data=limit_order_data) - else: - raise Exception("Unknown order type") - - print("ORDER", order) - - # self.status = parsed.status - # self.response = order - else: - # there is a trade open - # get trade - # update trade - pass - - super().save(*args, **kwargs) + def post(self): + return trades.post_trade(self) def delete(self, *args, **kwargs): # close the trade @@ -195,12 +186,30 @@ class Callback(models.Model): title = models.CharField(max_length=1024, null=True, blank=True) message = models.CharField(max_length=1024, null=True, blank=True) period = models.CharField(max_length=255, null=True, blank=True) - timestamp_sent = models.BigIntegerField(null=True, blank=True) - timestamp_trade = models.BigIntegerField(null=True, blank=True) - market_exchange = models.CharField(max_length=255, null=True, blank=True) - market_item = models.CharField(max_length=255, null=True, blank=True) - market_currency = models.CharField(max_length=255, null=True, blank=True) - market_contract = models.CharField(max_length=255, null=True, blank=True) + sent = models.BigIntegerField(null=True, blank=True) + trade = models.BigIntegerField(null=True, blank=True) + exchange = models.CharField(max_length=255, null=True, blank=True) + base = models.CharField(max_length=255, null=True, blank=True) + quote = models.CharField(max_length=255, null=True, blank=True) + contract = models.CharField(max_length=255, null=True, blank=True) + price = models.FloatField(null=True, blank=True) + symbol = models.CharField(max_length=255) + + +class Strategy(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE) + name = models.CharField(max_length=255) + description = models.TextField(null=True, blank=True) + account = models.ForeignKey(Account, on_delete=models.CASCADE) + hooks = models.ManyToManyField(Hook) + enabled = models.BooleanField(default=False) + take_profit_percent = models.FloatField(default=3.0) + stop_loss_percent = models.FloatField(default=1.0) + price_slippage_percent = models.FloatField(default=2.5) + trade_size_percent = models.FloatField(default=2.5) + + def __str__(self): + return self.name # class Perms(models.Model): diff --git a/core/templates/base.html b/core/templates/base.html index 33545eb..b94a596 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -202,29 +202,38 @@ Home {% if user.is_authenticated %} - - Positions - + {% endif %} {% if user.is_authenticated %} - - Accounts - - {% endif %} - {% if user.is_authenticated %} - - Bot Trades - - {% endif %} - {% if user.is_authenticated %} - - Hooks - - {% endif %} - {% if user.is_authenticated %} - - Callbacks - + {% endif %} {% if settings.STRIPE_ENABLED %} {% if user.is_authenticated %} @@ -233,22 +242,6 @@ {% endif %} {% endif %} - {% if user.is_superuser %} - - {% endif %} Install diff --git a/core/templates/partials/hook-list.html b/core/templates/partials/hook-list.html index fd8702e..dd6ebc1 100644 --- a/core/templates/partials/hook-list.html +++ b/core/templates/partials/hook-list.html @@ -6,6 +6,7 @@ user name hook + direction received hooks actions @@ -15,6 +16,7 @@ {{ item.user }} {{ item.name }} {{settings.URL}}/{{settings.HOOK_PATH}}/{{ item.hook }}/ + {{ item.direction }} {{ item.received }}
@@ -43,7 +45,7 @@ {% if type == 'page' %} - {% if type == 'page' %} - +