From 851d021af2a815e1d82b8d8a0dc719a3ba9090b5 Mon Sep 17 00:00:00 2001 From: Mark Veidemanis Date: Tue, 29 Nov 2022 07:20:21 +0000 Subject: [PATCH] Make signals configurable --- app/urls.py | 19 +++- core/forms.py | 44 ++++++--- ...e_hook_direction_alter_hook_name_signal.py | 37 +++++++ core/migrations/0037_callback_signal.py | 20 ++++ ...y_hooks_strategy_entry_signals_and_more.py | 33 +++++++ .../0039_alter_strategy_exit_signals.py | 18 ++++ ...0_alter_strategy_entry_signals_and_more.py | 23 +++++ ...1_alter_strategy_entry_signals_and_more.py | 23 +++++ core/models.py | 26 ++++- core/templates/base.html | 3 + core/templates/partials/hook-list.html | 6 +- core/templates/partials/signal-list.html | 96 +++++++++++++++++++ core/views/callbacks.py | 30 ++++-- core/views/signals.py | 47 +++++++++ 14 files changed, 397 insertions(+), 28 deletions(-) create mode 100644 core/migrations/0036_remove_hook_direction_alter_hook_name_signal.py create mode 100644 core/migrations/0037_callback_signal.py create mode 100644 core/migrations/0038_remove_strategy_hooks_strategy_entry_signals_and_more.py create mode 100644 core/migrations/0039_alter_strategy_exit_signals.py create mode 100644 core/migrations/0040_alter_strategy_entry_signals_and_more.py create mode 100644 core/migrations/0041_alter_strategy_entry_signals_and_more.py create mode 100644 core/templates/partials/signal-list.html create mode 100644 core/views/signals.py diff --git a/app/urls.py b/app/urls.py index a6b07d4..327d240 100644 --- a/app/urls.py +++ b/app/urls.py @@ -28,6 +28,7 @@ from core.views import ( hooks, limits, positions, + signals, strategies, trades, ) @@ -69,8 +70,24 @@ urlpatterns = [ path( f"{settings.HOOK_PATH}//", hooks.HookAPI.as_view(), name="hook" ), + path("signals//", signals.SignalList.as_view(), name="signals"), path( - "callbacks///", + "signals//create/", + signals.SignalCreate.as_view(), + name="signal_create", + ), + path( + "signals//update//", + signals.SignalUpdate.as_view(), + name="signal_update", + ), + path( + "signals//delete//", + signals.SignalDelete.as_view(), + name="signal_delete", + ), + path( + "callbacks////", callbacks.Callbacks.as_view(), name="callbacks", ), diff --git a/core/forms.py b/core/forms.py index b8f043d..ac74392 100644 --- a/core/forms.py +++ b/core/forms.py @@ -3,7 +3,7 @@ from django.contrib.auth.forms import UserCreationForm from django.core.exceptions import FieldDoesNotExist from django.forms import ModelForm -from .models import Account, Hook, Strategy, Trade, TradingTime, User +from .models import Account, Hook, Signal, Strategy, Trade, TradingTime, User # flake8: noqa: E501 @@ -61,23 +61,36 @@ class CustomUserCreationForm(UserCreationForm): fields = "__all__" -# All string/multiple choice fields class HookForm(RestrictedFormMixin, ModelForm): class Meta: model = Hook fields = ( "name", "hook", - "direction", ) help_texts = { "name": "Name of the hook. Informational only.", "hook": "The URL slug to use for the hook. Make it unique.", - "direction": "The direction of the hook. This is used to determine if the hook is a buy or sell.", } -# All string/multiple choice fields +class SignalForm(RestrictedFormMixin, ModelForm): + class Meta: + model = Signal + fields = ( + "name", + "signal", + "hook", + "direction", + ) + help_texts = { + "name": "Name of the signal. Informational only.", + "signal": "The name of the signal in Drakdoo. Copy it from there.", + "hook": "The hook this signal belongs to.", + "direction": "The direction of the signal. This is used to determine if the signal is a buy or sell.", + } + + class AccountForm(RestrictedFormMixin, ModelForm): class Meta: model = Account @@ -97,7 +110,6 @@ class AccountForm(RestrictedFormMixin, ModelForm): } -# Restricted mixin for account and hooks class StrategyForm(RestrictedFormMixin, ModelForm): class Meta: model = Strategy @@ -108,7 +120,8 @@ class StrategyForm(RestrictedFormMixin, ModelForm): "trading_times", "order_type", "time_in_force", - "hooks", + "entry_signals", + "exit_signals", "enabled", "take_profit_percent", "stop_loss_percent", @@ -125,7 +138,8 @@ class StrategyForm(RestrictedFormMixin, ModelForm): "trading_times": "When the strategy will place new trades.", "order_type": "Market: Buy/Sell at the current market price. Limit: Buy/Sell at a specified price. Limits protect you more against market slippage.", "time_in_force": "The time in force controls how the order is executed.", - "hooks": "The hooks to attach to this strategy. Callbacks received to these hooks will trigger a trade.", + "entry_signals": "The entry signals to attach to this strategy. Callbacks received to these signals will trigger a trade.", + "exit_signals": "The exit signals to attach to this strategy. Callbacks received to these signals will close all trades for the symbol on the account.", "enabled": "Whether the strategy is enabled.", "take_profit_percent": "The take profit will be set at this percentage above/below the entry price.", "stop_loss_percent": "The stop loss will be set at this percentage above/below the entry price.", @@ -135,15 +149,23 @@ class StrategyForm(RestrictedFormMixin, ModelForm): "trade_size_percent": "Percentage of the account balance to use for each trade.", } - hooks = forms.ModelMultipleChoiceField( - queryset=Hook.objects.all(), widget=forms.CheckboxSelectMultiple + entry_signals = forms.ModelMultipleChoiceField( + queryset=Signal.objects.all(), + widget=forms.CheckboxSelectMultiple, + help_text=Meta.help_texts["entry_signals"], + required=False, + ) + exit_signals = forms.ModelMultipleChoiceField( + queryset=Signal.objects.all(), + widget=forms.CheckboxSelectMultiple, + help_text=Meta.help_texts["exit_signals"], + required=False, ) trading_times = forms.ModelMultipleChoiceField( queryset=TradingTime.objects.all(), widget=forms.CheckboxSelectMultiple ) -# Restricted mixin for account class TradeForm(RestrictedFormMixin, ModelForm): class Meta: model = Trade diff --git a/core/migrations/0036_remove_hook_direction_alter_hook_name_signal.py b/core/migrations/0036_remove_hook_direction_alter_hook_name_signal.py new file mode 100644 index 0000000..c14846f --- /dev/null +++ b/core/migrations/0036_remove_hook_direction_alter_hook_name_signal.py @@ -0,0 +1,37 @@ +# Generated by Django 4.1.3 on 2022-12-01 18:22 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0035_alter_tradingtime_end_day_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='hook', + name='direction', + ), + migrations.AlterField( + model_name='hook', + name='name', + field=models.CharField(default='Unknown', max_length=1024), + preserve_default=False, + ), + migrations.CreateModel( + name='Signal', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=1024)), + ('signal', models.CharField(max_length=256)), + ('direction', models.CharField(choices=[('buy', 'Buy'), ('sell', 'Sell')], max_length=255)), + ('received', models.IntegerField(default=0)), + ('hook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.hook')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/core/migrations/0037_callback_signal.py b/core/migrations/0037_callback_signal.py new file mode 100644 index 0000000..420883a --- /dev/null +++ b/core/migrations/0037_callback_signal.py @@ -0,0 +1,20 @@ +# Generated by Django 4.1.3 on 2022-12-01 18:33 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0036_remove_hook_direction_alter_hook_name_signal'), + ] + + operations = [ + migrations.AddField( + model_name='callback', + name='signal', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='core.signal'), + preserve_default=False, + ), + ] diff --git a/core/migrations/0038_remove_strategy_hooks_strategy_entry_signals_and_more.py b/core/migrations/0038_remove_strategy_hooks_strategy_entry_signals_and_more.py new file mode 100644 index 0000000..8c04d28 --- /dev/null +++ b/core/migrations/0038_remove_strategy_hooks_strategy_entry_signals_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.1.3 on 2022-12-01 18:40 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0037_callback_signal'), + ] + + operations = [ + migrations.RemoveField( + model_name='strategy', + name='hooks', + ), + migrations.AddField( + model_name='strategy', + name='entry_signals', + field=models.ManyToManyField(related_name='entry_strategies', to='core.signal'), + ), + migrations.AddField( + model_name='strategy', + name='exit_signals', + field=models.ManyToManyField(related_name='exit_signals', to='core.signal'), + ), + migrations.AddField( + model_name='trade', + name='signal', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='core.signal'), + ), + ] diff --git a/core/migrations/0039_alter_strategy_exit_signals.py b/core/migrations/0039_alter_strategy_exit_signals.py new file mode 100644 index 0000000..25d6f45 --- /dev/null +++ b/core/migrations/0039_alter_strategy_exit_signals.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.3 on 2022-12-01 18:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0038_remove_strategy_hooks_strategy_entry_signals_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='strategy', + name='exit_signals', + field=models.ManyToManyField(related_name='exit_strategies', to='core.signal'), + ), + ] diff --git a/core/migrations/0040_alter_strategy_entry_signals_and_more.py b/core/migrations/0040_alter_strategy_entry_signals_and_more.py new file mode 100644 index 0000000..5f2110b --- /dev/null +++ b/core/migrations/0040_alter_strategy_entry_signals_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.1.3 on 2022-12-01 18:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0039_alter_strategy_exit_signals'), + ] + + operations = [ + migrations.AlterField( + model_name='strategy', + name='entry_signals', + field=models.ManyToManyField(blank=True, null=True, related_name='entry_strategies', to='core.signal'), + ), + migrations.AlterField( + model_name='strategy', + name='exit_signals', + field=models.ManyToManyField(blank=True, null=True, related_name='exit_strategies', to='core.signal'), + ), + ] diff --git a/core/migrations/0041_alter_strategy_entry_signals_and_more.py b/core/migrations/0041_alter_strategy_entry_signals_and_more.py new file mode 100644 index 0000000..3d678db --- /dev/null +++ b/core/migrations/0041_alter_strategy_entry_signals_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.1.3 on 2022-12-01 18:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0040_alter_strategy_entry_signals_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='strategy', + name='entry_signals', + field=models.ManyToManyField(blank=True, related_name='entry_strategies', to='core.signal'), + ), + migrations.AlterField( + model_name='strategy', + name='exit_signals', + field=models.ManyToManyField(blank=True, related_name='exit_strategies', to='core.signal'), + ), + ] diff --git a/core/models.py b/core/models.py index b5f5758..84de8f1 100644 --- a/core/models.py +++ b/core/models.py @@ -166,19 +166,31 @@ class Session(models.Model): class Hook(models.Model): 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) + name = models.CharField(max_length=1024) + hook = models.CharField(max_length=255, unique=True) # hook URL received = models.IntegerField(default=0) def __str__(self): return f"{self.name} ({self.hook})" +class Signal(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE) + name = models.CharField(max_length=1024) + signal = models.CharField(max_length=256) # signal name + hook = models.ForeignKey(Hook, on_delete=models.CASCADE) + direction = models.CharField(choices=DIRECTION_CHOICES, max_length=255) + received = models.IntegerField(default=0) + + def __str__(self): + return f"{self.name} ({self.signal}) - {self.direction}" + + class Trade(models.Model): 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) + signal = models.ForeignKey(Signal, 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) @@ -224,6 +236,7 @@ class Trade(models.Model): class Callback(models.Model): hook = models.ForeignKey(Hook, on_delete=models.CASCADE) + signal = models.ForeignKey(Signal, on_delete=models.CASCADE) 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) @@ -310,7 +323,12 @@ class Strategy(models.Model): 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) + entry_signals = models.ManyToManyField( + Signal, related_name="entry_strategies", blank=True + ) + exit_signals = models.ManyToManyField( + Signal, related_name="exit_strategies", blank=True + ) enabled = models.BooleanField(default=False) take_profit_percent = models.FloatField(default=1.5) stop_loss_percent = models.FloatField(default=1.0) diff --git a/core/templates/base.html b/core/templates/base.html index fc1c637..c11a0fc 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -226,6 +226,9 @@ Hooks + + Signals + Accounts diff --git a/core/templates/partials/hook-list.html b/core/templates/partials/hook-list.html index fb555ad..8ec10d2 100644 --- a/core/templates/partials/hook-list.html +++ b/core/templates/partials/hook-list.html @@ -12,7 +12,6 @@ user name hook - direction received hooks actions @@ -22,7 +21,6 @@ {{ item.user }} {{ item.name }} {{settings.URL}}/{{settings.HOOK_PATH}}/{{ item.hook }}/ - {{ item.direction }} {{ item.received }}
@@ -54,7 +52,7 @@ {% if type == 'page' %} - + + {% if type == 'page' %} + + + {% else %} + + {% endif %} +
+ + + {% endfor %} + + diff --git a/core/views/callbacks.py b/core/views/callbacks.py index e526590..8598761 100644 --- a/core/views/callbacks.py +++ b/core/views/callbacks.py @@ -5,14 +5,15 @@ from django.http import HttpResponseBadRequest from django.shortcuts import render from django.views import View -from core.models import Callback, Hook +from core.models import Callback, Hook, Signal -def get_callbacks(user, hook=None): +def get_callbacks(user, hook=None, signal=None): if hook: - callbacks = Callback.objects.filter(hook=hook, hook__user=user) - else: - callbacks = Callback.objects.filter(hook__user=user) + cast = {"hook": hook, "hook__user": user} + elif signal: + cast = {"signal": signal, "signal__user": user} + callbacks = Callback.objects.filter(**cast) return callbacks @@ -22,15 +23,15 @@ class Callbacks(LoginRequiredMixin, View): list_template = "partials/callback-list.html" page_title = "List of received callbacks" - async def get(self, request, type, pk=None): + async def get(self, request, type, object_type, object_id): if type not in self.allowed_types: return HttpResponseBadRequest template_name = f"wm/{type}.html" unique = str(uuid.uuid4())[:8] - if pk: + if object_type == "hook": try: - hook = Hook.objects.get(id=pk, user=request.user) + hook = Hook.objects.get(id=object_id, user=request.user) except Hook.DoesNotExist: message = "Hook does not exist." message_class = "danger" @@ -41,6 +42,19 @@ class Callbacks(LoginRequiredMixin, View): } return render(request, template_name, context) callbacks = get_callbacks(request.user, hook) + elif object_type == "signal": + try: + signal = Signal.objects.get(id=object_id, user=request.user) + except Signal.DoesNotExist: + message = "Signal does not exist." + message_class = "danger" + context = { + "message": message, + "class": message_class, + "type": type, + } + return render(request, template_name, context) + callbacks = get_callbacks(request.user, signal=signal) else: callbacks = get_callbacks(request.user) if type == "page": diff --git a/core/views/signals.py b/core/views/signals.py new file mode 100644 index 0000000..1b67fae --- /dev/null +++ b/core/views/signals.py @@ -0,0 +1,47 @@ +from django.contrib.auth.mixins import LoginRequiredMixin + +from core.forms import SignalForm +from core.models import Signal +from core.util import logs +from core.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate + +log = logs.get_logger(__name__) + + +class SignalList(LoginRequiredMixin, ObjectList): + list_template = "partials/signal-list.html" + model = Signal + page_title = "List of signals. Linked to hooks and strategies." + page_subtitle = "Link signals you have defined in Drakdoo to their corresponding hooks." + + list_url_name = "signals" + list_url_args = ["type"] + + submit_url_name = "signal_create" + + +class SignalCreate(LoginRequiredMixin, ObjectCreate): + model = Signal + form_class = SignalForm + + list_url_name = "signals" + list_url_args = ["type"] + + submit_url_name = "signal_create" + + +class SignalUpdate(LoginRequiredMixin, ObjectUpdate): + model = Signal + form_class = SignalForm + + list_url_name = "signals" + list_url_args = ["type"] + + submit_url_name = "signal_update" + + +class SignalDelete(LoginRequiredMixin, ObjectDelete): + model = Signal + + list_url_name = "signals" + list_url_args = ["type"]