Make signals configurable

This commit is contained in:
Mark Veidemanis 2022-11-29 07:20:21 +00:00
parent f7242f4dd8
commit 851d021af2
Signed by: m
GPG Key ID: 5ACFCEED46C0904F
14 changed files with 397 additions and 28 deletions

View File

@ -28,6 +28,7 @@ from core.views import (
hooks,
limits,
positions,
signals,
strategies,
trades,
)
@ -69,8 +70,24 @@ urlpatterns = [
path(
f"{settings.HOOK_PATH}/<str:hook_name>/", hooks.HookAPI.as_view(), name="hook"
),
path("signals/<str:type>/", signals.SignalList.as_view(), name="signals"),
path(
"callbacks/<str:type>/<str:pk>/",
"signals/<str:type>/create/",
signals.SignalCreate.as_view(),
name="signal_create",
),
path(
"signals/<str:type>/update/<str:pk>/",
signals.SignalUpdate.as_view(),
name="signal_update",
),
path(
"signals/<str:type>/delete/<str:pk>/",
signals.SignalDelete.as_view(),
name="signal_delete",
),
path(
"callbacks/<str:type>/<str:object_type>/<str:object_id>/",
callbacks.Callbacks.as_view(),
name="callbacks",
),

View File

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

View File

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

View File

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

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

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

View File

@ -226,6 +226,9 @@
<a class="navbar-item" href="{% url 'hooks' type='page' %}">
Hooks
</a>
<a class="navbar-item" href="{% url 'signals' type='page' %}">
Signals
</a>
<a class="navbar-item" href="{% url 'accounts' type='page' %}">
Accounts
</a>

View File

@ -12,7 +12,6 @@
<th>user</th>
<th>name</th>
<th>hook</th>
<th>direction</th>
<th>received hooks</th>
<th>actions</th>
</thead>
@ -22,7 +21,6 @@
<td>{{ item.user }}</td>
<td>{{ item.name }}</td>
<td><code>{{settings.URL}}/{{settings.HOOK_PATH}}/{{ item.hook }}/</code></td>
<td>{{ item.direction }}</td>
<td>{{ item.received }}</td>
<td>
<div class="buttons">
@ -54,7 +52,7 @@
</span>
</button>
{% if type == 'page' %}
<a href="{% url 'callbacks' type='page' pk=item.id %}"><button
<a href="{% url 'callbacks' type='page' object_type='hook' object_id=item.id %}"><button
class="button is-success">
<span class="icon-text">
<span class="icon">
@ -66,7 +64,7 @@
{% else %}
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{% url 'callbacks' type=type pk=item.id %}"
hx-get="{% url 'callbacks' type=type object_type='hook' object_id=item.id %}"
hx-trigger="click"
hx-target="#{{ type }}s-here"
hx-swap="innerHTML"

View File

@ -0,0 +1,96 @@
{% include 'partials/notify.html' %}
<table
class="table is-fullwidth is-hoverable"
hx-target="#{{ context_object_name }}-table"
id="{{ context_object_name }}-table"
hx-swap="outerHTML"
hx-trigger="{{ context_object_name_singular }}Event from:body"
hx-get="{{ list_url }}">
<thead>
<th>id</th>
<th>user</th>
<th>name</th>
<th>signal</th>
<th>hook</th>
<th>direction</th>
<th>received hooks</th>
<th>actions</th>
</thead>
{% for item in object_list %}
<tr>
<td>{{ item.id }}</td>
<td>{{ item.user }}</td>
<td>{{ item.name }}</td>
<td>{{ item.signal }}</td>
<td>
<a
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{% url 'hook_update' type=type pk=item.hook.id %}"
hx-trigger="click"
hx-target="#{{ type }}s-here"
hx-swap="innerHTML">{{ item.hook.name }}
</a>
</td>
<td>{{ item.direction }}</td>
<td>{{ item.received }}</td>
<td>
<div class="buttons">
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{% url 'signal_update' type=type pk=item.id %}"
hx-trigger="click"
hx-target="#{{ type }}s-here"
hx-swap="innerHTML"
class="button is-info">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-pencil"></i>
</span>
</span>
</button>
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-delete="{% url 'signal_delete' type=type pk=item.id %}"
hx-trigger="click"
hx-target="#modals-here"
hx-swap="innerHTML"
hx-confirm="Are you sure you wish to delete {{ item.name }}?"
class="button is-danger">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-trash"></i>
</span>
</span>
</button>
{% if type == 'page' %}
<a href="{% url 'callbacks' type='page' object_type='signal' object_id=item.id %}"><button
class="button is-success">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-eye"></i>
</span>
</span>
</button>
</a>
{% else %}
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{% url 'callbacks' type=type object_type='signal' object_id=item.id %}"
hx-trigger="click"
hx-target="#{{ type }}s-here"
hx-swap="innerHTML"
class="button is-success">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-eye"></i>
</span>
</span>
</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</table>

View File

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

47
core/views/signals.py Normal file
View File

@ -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"]