Implement asset rules as Asset Group children objects

This commit is contained in:
Mark Veidemanis 2023-02-13 20:45:23 +00:00
parent b48af50620
commit 6ff5f718ba
Signed by: m
GPG Key ID: 5ACFCEED46C0904F
18 changed files with 565 additions and 349 deletions

View File

@ -263,41 +263,25 @@ urlpatterns = [
assets.AssetGroupDelete.as_view(), assets.AssetGroupDelete.as_view(),
name="assetgroup_delete", name="assetgroup_delete",
), ),
# Asset Restrictions # Asset Rules
# path(
# "restriction/<str:type>/<str:group>/",
# assets.AssetRestrictionList.as_view(),
# name="assetrestrictions",
# ),
# path(
# "restriction/<str:type>/create/<str:group>/",
# assets.AssetRestrictionCreate.as_view(),
# name="assetrestriction_create",
# ),
# path(
# "restriction/<str:type>/update/<str:group>/<str:pk>/",
# assets.AssetRestrictionUpdate.as_view(),
# name="assetrestriction_update",
# ),
# path(
# "restriction/<str:type>/delete/<str:group>/<str:pk>/",
# assets.AssetRestrictionDelete.as_view(),
# name="assetrestriction_delete",
# ),
# Asset group filters
path( path(
"assetfilter/<str:group_id>/flip/<str:symbol>/", "assetrule/<str:type>/<str:group>/",
assets.AssetFilterFlip.as_view(), assets.AssetRuleList.as_view(),
name="assetfilter_flip", name="assetrules",
), ),
path( path(
"assetfilter/<str:group_id>/delete/<str:symbol>/", "assetrule/<str:type>/create/<str:group>/",
assets.AssetFilterDelete.as_view(), assets.AssetRuleCreate.as_view(),
name="assetfilter_delete", name="assetrule_create",
), ),
path( path(
"assetfilter/<str:type>/view/<str:group_id>/", "assetrule/<str:type>/update/<str:group>/<str:pk>/",
assets.AssetFilterList.as_view(), assets.AssetRuleUpdate.as_view(),
name="assetfilters", name="assetrule_update",
),
path(
"assetrule/<str:type>/delete/<str:group>/<str:pk>/",
assets.AssetRuleDelete.as_view(),
name="assetrule_delete",
), ),
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

View File

@ -7,6 +7,7 @@ from mixins.restrictions import RestrictedFormMixin
from .models import ( # AssetRestriction, from .models import ( # AssetRestriction,
Account, Account,
AssetGroup, AssetGroup,
AssetRule,
Hook, Hook,
NotificationSettings, NotificationSettings,
RiskModel, RiskModel,
@ -308,85 +309,38 @@ class AssetGroupForm(RestrictedFormMixin, ModelForm):
fields = ( fields = (
"name", "name",
"description", "description",
"aggregation", "when_no_data",
"trigger_below", "when_no_match",
"trigger_above", "when_no_aggregation",
"when_not_in_bounds",
"when_bullish",
"when_bearish",
) )
help_texts = { help_texts = {
"name": "Name of the asset group. Informational only.", "name": "Name of the asset group. Informational only.",
"description": "Description of the asset group. Informational only.", "description": "Description of the asset group. Informational only.",
"aggregation": "The aggregation method to use for this asset group.", "when_no_data": "The action to take when no webhooks have been received for an asset.",
"trigger_below": "Trigger when the aggregation is below this value.", "when_no_match": "The action to take when there were no matches last callback for an asset.",
"trigger_above": "Trigger when the aggregation is above this value.", "when_no_aggregation": "The action to take when there is no defined aggregations for the asset.",
"when_not_in_bounds": "The action to take when the aggregation is not breaching either bound.",
"when_bullish": "The action to take when the asset is bullish.",
"when_bearish": "The action to take when the asset is bearish.",
} }
def clean(self):
cleaned_data = super(AssetGroupForm, self).clean() class AssetRuleForm(RestrictedFormMixin, ModelForm):
if "aggregation" in cleaned_data: def __init__(self, *args, **kwargs):
if cleaned_data["aggregation"] == "none": super(AssetRuleForm, self).__init__(*args, **kwargs)
if "trigger_below" in cleaned_data and cleaned_data["trigger_below"]: self.fields["value"].disabled = True
self.add_error( self.fields["aggregation"].disabled = True
class Meta:
model = AssetRule
fields = (
"asset",
"aggregation",
"value",
"status",
"trigger_below", "trigger_below",
"You cannot specify a trigger below value when aggregation is set to none.",
)
return
if "trigger_above" in cleaned_data and cleaned_data["trigger_above"]:
self.add_error(
"trigger_above", "trigger_above",
"You cannot specify a trigger above value when aggregation is set to none.",
) )
return
else:
# Check if either trigger_below or trigger_above has been set
if not any(
[cleaned_data["trigger_below"], cleaned_data["trigger_above"]]
):
self.add_error(
"trigger_below",
"You must specify a trigger below and/or trigger above value when aggregation is set to anything other than none.",
)
self.add_error(
"trigger_above",
"You must specify a trigger below and/or trigger above value when aggregation is set to anything other than none.",
)
return
return cleaned_data
# class AssetRestrictionForm(RestrictedFormMixin, ModelForm):
# class Meta:
# model = AssetRestriction
# fields = (
# "name",
# "description",
# "pairs",
# "pairs_parsed",
# )
# help_texts = {
# "name": "Name of the asset restriction group. Informational only.",
# "description": "Description of the asset restriction group. Informational only.",
# "pairs": "Comma-separated list of pairs to restrict when a webhook is received. This does nothing on its own.",
# }
# pairs_parsed = forms.BooleanField(widget=forms.HiddenInput, required=False)
# def clean(self):
# cleaned_data = super(AssetRestrictionForm, self).clean()
# if "pairs" in cleaned_data and cleaned_data["pairs"]:
# new_pairs = []
# pair_split = cleaned_data["pairs"].split(",")
# if not pair_split:
# self.add_error("pairs", "You must specify at least one pair.")
# return
# for pair in pair_split:
# if pair:
# new_pairs.append(pair.strip())
# else:
# self.add_error("pairs", f"You cannot have an empty pair: {pair}")
# return
# cleaned_data["pairs_parsed"] = new_pairs
# else:
# cleaned_data["pairs_parsed"] = {}
# return cleaned_data

View File

@ -0,0 +1,29 @@
# Generated by Django 4.1.6 on 2023-02-13 18:56
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0060_assetgroup_aggregation_assetgroup_trigger_above_and_more'),
]
operations = [
migrations.CreateModel(
name='AssetRule',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('asset', models.CharField(max_length=64)),
('aggregation', models.CharField(choices=[('none', 'None'), ('avg_sentiment', 'Average sentiment')], default='none', max_length=255)),
('value', models.FloatField(blank=True, null=True)),
('status', models.FloatField(choices=[(0, 'No data'), (1, 'No match'), (2, 'Positive'), (3, 'Negative')], default=0)),
('trigger_below', models.FloatField(blank=True, null=True)),
('trigger_above', models.FloatField(blank=True, null=True)),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.assetgroup')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.6 on 2023-02-13 18:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0061_assetrule'),
]
operations = [
migrations.AlterField(
model_name='assetrule',
name='status',
field=models.IntegerField(choices=[(0, 'No data'), (1, 'No match'), (2, 'Positive'), (3, 'Negative')], default=0),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 4.1.6 on 2023-02-13 19:03
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0062_alter_assetrule_status'),
]
operations = [
migrations.AlterUniqueTogether(
name='assetrule',
unique_together={('asset', 'group')},
),
]

View File

@ -0,0 +1,29 @@
# Generated by Django 4.1.6 on 2023-02-13 19:28
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0063_alter_assetrule_unique_together'),
]
operations = [
migrations.RemoveField(
model_name='assetgroup',
name='aggregation',
),
migrations.RemoveField(
model_name='assetgroup',
name='allowed',
),
migrations.RemoveField(
model_name='assetgroup',
name='trigger_above',
),
migrations.RemoveField(
model_name='assetgroup',
name='trigger_below',
),
]

View File

@ -0,0 +1,53 @@
# Generated by Django 4.1.6 on 2023-02-13 20:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0064_remove_assetgroup_aggregation_and_more'),
]
operations = [
migrations.AddField(
model_name='assetgroup',
name='when_bearish',
field=models.IntegerField(choices=[(6, 'Ignore (no action)'), (-1, 'Default (no remapping)'), (2, 'Bullish'), (3, 'Bearish')], default=-1, max_length=255),
),
migrations.AddField(
model_name='assetgroup',
name='when_bullish',
field=models.IntegerField(choices=[(6, 'Ignore (no action)'), (-1, 'Default (no remapping)'), (2, 'Bullish'), (3, 'Bearish')], default=-1, max_length=255),
),
migrations.AddField(
model_name='assetgroup',
name='when_no_aggregation',
field=models.IntegerField(choices=[(6, 'Ignore (no action)'), (-1, 'Default (no remapping)'), (2, 'Bullish'), (3, 'Bearish')], default=-1, max_length=255),
),
migrations.AddField(
model_name='assetgroup',
name='when_no_data',
field=models.IntegerField(choices=[(6, 'Ignore (no action)'), (-1, 'Default (no remapping)'), (2, 'Bullish'), (3, 'Bearish')], default=-1, max_length=255),
),
migrations.AddField(
model_name='assetgroup',
name='when_no_match',
field=models.IntegerField(choices=[(6, 'Ignore (no action)'), (-1, 'Default (no remapping)'), (2, 'Bullish'), (3, 'Bearish')], default=-1, max_length=255),
),
migrations.AddField(
model_name='assetgroup',
name='when_not_in_bounds',
field=models.IntegerField(choices=[(6, 'Ignore (no action)'), (-1, 'Default (no remapping)'), (2, 'Bullish'), (3, 'Bearish')], default=-1, max_length=255),
),
migrations.AddField(
model_name='assetrule',
name='original_status',
field=models.IntegerField(choices=[(0, 'No data'), (1, 'No match'), (2, 'Bullish'), (3, 'Bearish'), (4, 'No aggregation'), (5, 'Not in bounds'), (6, 'No action')], default=0),
),
migrations.AlterField(
model_name='assetrule',
name='status',
field=models.IntegerField(choices=[(0, 'No data'), (1, 'No match'), (2, 'Bullish'), (3, 'Bearish'), (4, 'No aggregation'), (5, 'Not in bounds'), (6, 'No action')], default=0),
),
]

View File

@ -46,6 +46,24 @@ AGGREGATION_CHOICES = (
("avg_sentiment", "Average sentiment"), ("avg_sentiment", "Average sentiment"),
) )
STATUS_CHOICES = (
(0, "No data"),
(1, "No match"),
(2, "Bullish"),
(3, "Bearish"),
(4, "No aggregation"),
(5, "Not in bounds"),
(6, "Always allow"),
(7, "Always deny"),
)
MAPPING_CHOICES = (
(6, "Always allow"),
(7, "Always deny"),
(2, "Bullish"),
(3, "Bearish"),
)
class Plan(models.Model): class Plan(models.Model):
name = models.CharField(max_length=255, unique=True) name = models.CharField(max_length=255, unique=True)
@ -416,16 +434,17 @@ class AssetGroup(models.Model):
description = models.TextField(null=True, blank=True) description = models.TextField(null=True, blank=True)
# Dict like {"RUB": True, "USD": False} # Dict like {"RUB": True, "USD": False}
allowed = models.JSONField(null=True, blank=True, default=dict) # allowed = models.JSONField(null=True, blank=True, default=dict)
webhook_id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) webhook_id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
aggregation = models.CharField( when_no_data = models.IntegerField(choices=MAPPING_CHOICES, default=7)
choices=AGGREGATION_CHOICES, max_length=255, default="none" when_no_match = models.IntegerField(choices=MAPPING_CHOICES, default=6)
) when_no_aggregation = models.IntegerField(choices=MAPPING_CHOICES, default=6)
when_not_in_bounds = models.IntegerField(choices=MAPPING_CHOICES, default=6)
trigger_below = models.FloatField(null=True, blank=True) when_bullish = models.IntegerField(choices=MAPPING_CHOICES, default=2)
trigger_above = models.FloatField(null=True, blank=True) when_bearish = models.IntegerField(choices=MAPPING_CHOICES, default=3)
def __str__(self): def __str__(self):
return f"{self.name}" return f"{self.name}"
@ -435,20 +454,24 @@ class AssetGroup(models.Model):
""" """
Get the total number of matches for this group. Get the total number of matches for this group.
""" """
if isinstance(self.allowed, dict): asset_rule_total = AssetRule.objects.filter(group=self).count()
truthy_values = [x for x in self.allowed.values() if x is True] asset_rule_positive = AssetRule.objects.filter(group=self, status=2).count()
return f"{len(truthy_values)}/{len(self.allowed)}" return f"{asset_rule_positive}/{asset_rule_total}"
# class AssetRestriction(models.Model): class AssetRule(models.Model):
# user = models.ForeignKey(User, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE)
# name = models.CharField(max_length=255) asset = models.CharField(max_length=64)
# description = models.TextField(null=True, blank=True) group = models.ForeignKey(AssetGroup, on_delete=models.CASCADE)
# pairs = models.CharField(max_length=4096, null=True, blank=True) aggregation = models.CharField(
# pairs_parsed = models.JSONField(null=True, blank=True, default=list) choices=AGGREGATION_CHOICES, max_length=255, default="none"
)
value = models.FloatField(null=True, blank=True)
original_status = models.IntegerField(choices=STATUS_CHOICES, default=0)
status = models.IntegerField(choices=STATUS_CHOICES, default=0)
trigger_below = models.FloatField(null=True, blank=True)
trigger_above = models.FloatField(null=True, blank=True)
# webhook_id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) # Ensure that the asset is unique per group
class Meta:
# group = models.ForeignKey( unique_together = ("asset", "group")
# AssetGroup, on_delete=models.CASCADE, null=True, blank=True
# )

View File

@ -1,6 +1,6 @@
{% load cache %} {% load cache %}
{% load cachalot cache %} {% load cachalot cache %}
{% get_last_invalidation 'core.AssetGroup' as last %} {% get_last_invalidation 'core.AssetGroup' 'core.AssetRule' as last %}
{% include 'mixins/partials/notify.html' %} {% include 'mixins/partials/notify.html' %}
{% cache 600 objects_assetgroups request.user.id object_list last %} {% cache 600 objects_assetgroups request.user.id object_list last %}
<table <table
@ -27,11 +27,7 @@
<td>{{ item.description }}</td> <td>{{ item.description }}</td>
<td> <td>
<a <a
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' href="{% url 'assetrules' type='page' group=item.id %}">
hx-get="{% url 'assetfilters' type=type group_id=item.id %}"
hx-trigger="click"
hx-target="#{{ type }}s-here"
hx-swap="innerHTML">
{{ item.matches }} {{ item.matches }}
</a> </a>
</td> </td>

View File

@ -1,8 +1,8 @@
{% load cache %} {% load cache %}
{% load cachalot cache %} {% load cachalot cache %}
{% get_last_invalidation 'core.AssetRestriction' as last %} {% get_last_invalidation 'core.AssetRule' as last %}
{% include 'mixins/partials/notify.html' %} {% include 'mixins/partials/notify.html' %}
{% cache 600 objects_assetrestrictions request.user.id object_list last %} {% cache 600 objects_assetrules request.user.id object_list last %}
<table <table
class="table is-fullwidth is-hoverable" class="table is-fullwidth is-hoverable"
hx-target="#{{ context_object_name }}-table" hx-target="#{{ context_object_name }}-table"
@ -13,35 +13,37 @@
<thead> <thead>
<th>id</th> <th>id</th>
<th>user</th> <th>user</th>
<th>name</th> <th>asset</th>
<th>description</th>
<th>pairs</th>
<th>group</th> <th>group</th>
<th>hook</th> <th>aggregation</th>
<th>value</th>
<th>original status</th>
<th>status</th>
<th>trigger above</th>
<th>trigger below</th>
<th>actions</th> <th>actions</th>
</thead> </thead>
{% for item in object_list %} {% for item in object_list %}
<tr> <tr class="
{% if item.status == 2 %}has-background-success-light
{% elif item.status == 3 %}has-background-danger-light
{% elif item.status == 0 %}has-background-grey-light
{% endif %}">
<td>{{ item.id }}</td> <td>{{ item.id }}</td>
<td>{{ item.user }}</td> <td>{{ item.user }}</td>
<td>{{ item.name }}</td> <td>{{ item.asset }}</td>
<td>{{ item.description }}</td>
<td>{{ item.pairs_parsed|length }}</td>
<td>{{ item.group }}</td> <td>{{ item.group }}</td>
<td> <td>{{ item.get_aggregation_display }}</td>
<a <td>{{ item.value }}</td>
class="has-text-grey" <td>{{ item.get_original_status_display }}</td>
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{settings.URL}}/{{settings.ASSET_PATH}}/{{ item.webhook_id }}/');"> <td>{{ item.get_status_display }}</td>
<span class="icon" data-tooltip="Copy to clipboard"> <td>{{ item.trigger_above }}</td>
<i class="fa-solid fa-copy" aria-hidden="true"></i> <td>{{ item.trigger_below }}</td>
</span>
</a>
</td>
<td> <td>
<div class="buttons"> <div class="buttons">
<button <button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{% url 'assetrestriction_update' type=type group=item.group.id pk=item.id %}" hx-get="{% url 'assetrule_update' type=type group=item.group.id pk=item.id %}"
hx-trigger="click" hx-trigger="click"
hx-target="#{{ type }}s-here" hx-target="#{{ type }}s-here"
hx-swap="innerHTML" hx-swap="innerHTML"
@ -54,7 +56,7 @@
</button> </button>
<button <button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-delete="{% url 'assetrestriction_delete' type=type group=item.group.id pk=item.id %}" hx-delete="{% url 'assetrule_delete' type=type group=item.group.id pk=item.id %}"
hx-trigger="click" hx-trigger="click"
hx-target="#modals-here" hx-target="#modals-here"
hx-swap="innerHTML" hx-swap="innerHTML"

View File

@ -1,6 +1,6 @@
from django.test import TestCase from django.test import TestCase
from core.models import AssetGroup, User from core.models import AssetGroup, AssetRule, User
from core.trading import assetfilter from core.trading import assetfilter
@ -16,13 +16,18 @@ class AssetfilterTestCase(TestCase):
description="Test group", description="Test group",
) )
def test_get_allowed_negative(self): def test_get_allowed_prohibited(self):
""" """
Test that the asset filter works on negative aggregations. Test that the asset filter works on negative aggregations.
""" """
# We have negative news about EUR # We have negative news about EUR
self.group.allowed = {"EUR": False} self.asset_rule = AssetRule.objects.create(
self.group.save() user=self.user,
group=self.group,
asset="EUR",
status=3,
)
self.asset_rule.save()
# This means that: # This means that:
# * base == EUR: long is not allowed, short is allowed # * base == EUR: long is not allowed, short is allowed
@ -41,13 +46,18 @@ class AssetfilterTestCase(TestCase):
# Test that short on quote of EUR is not allowed # Test that short on quote of EUR is not allowed
self.assertFalse(assetfilter.get_allowed(self.group, "USD", "EUR", "short")) self.assertFalse(assetfilter.get_allowed(self.group, "USD", "EUR", "short"))
def test_get_allowed_positive(self): def test_get_allowed_permitted(self):
""" """
Test that the asset filter works on positive aggregations. Test that the asset filter works on positive aggregations.
""" """
# We have positive news about EUR # We have positive news about EUR
self.group.allowed = {"EUR": True} self.asset_rule = AssetRule.objects.create(
self.group.save() user=self.user,
group=self.group,
asset="EUR",
status=2,
)
self.asset_rule.save()
# This means that: # This means that:
# * base == EUR: long is allowed, short is not allowed # * base == EUR: long is allowed, short is not allowed
@ -66,69 +76,188 @@ class AssetfilterTestCase(TestCase):
# Test that long on quote of EUR is not allowed # Test that long on quote of EUR is not allowed
self.assertFalse(assetfilter.get_allowed(self.group, "USD", "EUR", "long")) self.assertFalse(assetfilter.get_allowed(self.group, "USD", "EUR", "long"))
def test_get_allowed_no_data(self):
self.asset_rule = AssetRule.objects.create(
user=self.user,
group=self.group,
asset="EUR",
status=0,
)
self.asset_rule.save()
self.assertTrue(assetfilter.get_allowed(self.group, "EUR", "USD", "long"))
def test_get_allowed_no_match(self):
self.asset_rule = AssetRule.objects.create(
user=self.user,
group=self.group,
asset="EUR",
status=1,
)
self.asset_rule.save()
self.assertTrue(assetfilter.get_allowed(self.group, "EUR", "USD", "long"))
def test_get_allowed_no_aggregation(self):
self.asset_rule = AssetRule.objects.create(
user=self.user,
group=self.group,
asset="EUR",
status=4,
)
self.asset_rule.save()
self.assertTrue(assetfilter.get_allowed(self.group, "EUR", "USD", "long"))
def test_get_allowed_not_in_bounds(self):
self.asset_rule = AssetRule.objects.create(
user=self.user,
group=self.group,
asset="EUR",
status=5,
)
self.asset_rule.save()
def test_get_allowed_always_allow(self):
self.asset_rule = AssetRule.objects.create(
user=self.user,
group=self.group,
asset="EUR",
status=6,
)
self.asset_rule.save()
self.assertTrue(assetfilter.get_allowed(self.group, "EUR", "USD", "long"))
def test_get_allowed_always_deny(self):
self.asset_rule = AssetRule.objects.create(
user=self.user,
group=self.group,
asset="EUR",
status=7,
)
self.asset_rule.save()
self.assertFalse(assetfilter.get_allowed(self.group, "EUR", "USD", "long"))
def test_check_asset_aggregation(self): def test_check_asset_aggregation(self):
""" """
Test that the asset aggregation works. Test that the asset aggregation works.
""" """
# Test within lower bound # Test within lower bound
self.assertTrue( self.assertEqual(
assetfilter.check_asset_aggregation( assetfilter.check_asset_aggregation(
1.0, trigger_above=None, trigger_below=2.0 1.0, trigger_above=None, trigger_below=2.0
) ),
3, # Prohibited
) )
# Test within upper bound # Test within upper bound
self.assertTrue( self.assertEqual(
assetfilter.check_asset_aggregation( assetfilter.check_asset_aggregation(
1.0, trigger_above=0.0, trigger_below=None 1.0, trigger_above=0.0, trigger_below=None
) ),
2, # Permitted
) )
# Test within bounds # Test within bounds
self.assertTrue( self.assertEqual(
assetfilter.check_asset_aggregation( assetfilter.check_asset_aggregation(
1.0, trigger_above=0.0, trigger_below=2.0 1.0, trigger_above=0.0, trigger_below=2.0
) ),
) 3, # Prohibited
# Test outside bounds
self.assertFalse(
assetfilter.check_asset_aggregation(
1.0, trigger_above=2.0, trigger_below=3.0
)
) )
# Test outside lower bound # Test outside lower bound
self.assertFalse( self.assertEqual(
assetfilter.check_asset_aggregation( assetfilter.check_asset_aggregation(
1.0, trigger_above=None, trigger_below=0.0 1.0, trigger_above=None, trigger_below=0.0
) ),
5, # Not in bounds
) )
# Test outside upper bound # Test outside upper bound
self.assertFalse( self.assertEqual(
assetfilter.check_asset_aggregation( assetfilter.check_asset_aggregation(
1.0, trigger_above=2.0, trigger_below=None 1.0, trigger_above=2.0, trigger_below=None
) ),
5, # Not in bounds
) )
# Test no bounds, just to be sure # Test no bounds, just to be sure
self.assertFalse( self.assertEqual(
assetfilter.check_asset_aggregation( assetfilter.check_asset_aggregation(
1.0, trigger_above=None, trigger_below=None 1.0, trigger_above=None, trigger_below=None
) ),
4, # No aggregation
) )
# Test both bounds, but inverted # Test both bounds, but inverted
self.assertFalse( self.assertEqual(
assetfilter.check_asset_aggregation( assetfilter.check_asset_aggregation(
1.0, trigger_above=2.0, trigger_below=0.0 1.0, trigger_above=2.0, trigger_below=0.0
) ),
5, # Not in bounds
) )
# Test within negative and positive bounds # Test within negative and positive bounds
self.assertTrue( self.assertEqual(
assetfilter.check_asset_aggregation( assetfilter.check_asset_aggregation(
-1.0, trigger_above=-2.0, trigger_below=0.0 -1.0, trigger_above=-2.0, trigger_below=0.0
),
3, # Prohibited
) )
def test_update_status_from_mappings_when_no_data(self):
self.group.when_no_data = 2 # Bullish
self.group.save()
self.assertEqual(
assetfilter.update_status_from_mappings(0, self.group),
2, # Bullish
)
def test_update_status_from_mappings_when_no_match(self):
self.group.when_no_match = 2
self.group.save()
self.assertEqual(
assetfilter.update_status_from_mappings(1, self.group),
2, # Bullish
)
def test_update_status_from_mappings_when_no_aggregation(self):
self.group.when_no_aggregation = 2
self.group.save()
self.assertEqual(
assetfilter.update_status_from_mappings(4, self.group),
2, # Bullish
)
def test_update_status_from_mappings_when_not_in_bounds(self):
self.group.when_not_in_bounds = 2
self.group.save()
self.assertEqual(
assetfilter.update_status_from_mappings(5, self.group),
2, # Bullish
)
def test_update_status_from_mappings_when_bullish(self):
self.group.when_bullish = 3 # Bearish
self.group.save()
self.assertEqual(
assetfilter.update_status_from_mappings(2, self.group),
3, # Bearish
)
def test_update_status_from_mappings_when_bearish(self):
self.group.when_bearish = 2 # Bullish
self.group.save()
self.assertEqual(
assetfilter.update_status_from_mappings(3, self.group),
2, # Bullish
) )

View File

@ -1,3 +1,6 @@
from core.models import AssetRule
def get_allowed(group, base, quote, direction): def get_allowed(group, base, quote, direction):
""" """
Determine whether the trade is allowed according to the group. Determine whether the trade is allowed according to the group.
@ -8,25 +11,35 @@ def get_allowed(group, base, quote, direction):
:param direction: The direction of the trade :param direction: The direction of the trade
""" """
allowed = group.allowed
if not isinstance(allowed, dict):
return False
# If our base has allowed == False, we can only short it, or long the quote # If our base has allowed == False, we can only short it, or long the quote
if base in allowed: base_rule = AssetRule.objects.filter(group=group, asset=base).first()
if not allowed[base]: if base_rule:
if base_rule.status == 6:
# Always allow
return True
elif base_rule.status == 7:
# Always deny
return False
elif base_rule.status == 3:
if direction == "long": if direction == "long":
return False return False
else: elif base_rule.status == 2:
if direction == "short": if direction == "short":
return False return False
# If our quote has allowed == False, we can only long it, or short the base # If our quote has allowed == False, we can only long it, or short the base
if quote in allowed: quote_rule = AssetRule.objects.filter(group=group, asset=quote).first()
if not allowed[quote]: if quote_rule:
if quote_rule.status == 6:
# Always allow
return True
elif quote_rule.status == 7:
# Always deny
return False
elif quote_rule.status == 3:
if direction == "short": if direction == "short":
return False return False
else: elif quote_rule.status == 2:
if direction == "long": if direction == "long":
return False return False
@ -40,16 +53,39 @@ def check_asset_aggregation(value, trigger_above, trigger_below):
:param trigger_above: Only trigger if the value is above this :param trigger_above: Only trigger if the value is above this
:param trigger_below: Only trigger if the value is below this :param trigger_below: Only trigger if the value is below this
""" """
# If both are defined
if trigger_above is not None and trigger_below is not None:
if value > trigger_above and value < trigger_below:
return True
return False
if trigger_below is not None: if trigger_below is not None:
if value < trigger_below: if value < trigger_below:
# Value is less than lower bound, match # Prohibited
return True return 3
if trigger_above is not None: if trigger_above is not None:
if value > trigger_above: if value > trigger_above:
return True # Permitted
return False return 2
# Neither is defined, so we match
if not any(x is not None for x in [trigger_above, trigger_below]):
# No aggregation
return 4
# Not in bounds
return 5
def update_status_from_mappings(status, asset_group):
if status == 0:
if asset_group.when_no_data > -1:
return asset_group.when_no_data
elif status == 1:
if asset_group.when_no_match > -1:
return asset_group.when_no_match
elif status == 2:
if asset_group.when_bullish > -1:
return asset_group.when_bullish
elif status == 3:
if asset_group.when_bearish > -1:
return asset_group.when_bearish
elif status == 4:
if asset_group.when_no_aggregation > -1:
return asset_group.when_no_aggregation
elif status == 5:
if asset_group.when_not_in_bounds > -1:
return asset_group.when_not_in_bounds
return status

View File

@ -1,22 +1,15 @@
import json import json
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.db import IntegrityError
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import render from mixins.views import AbortSave, ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
from django.views import View
from mixins.views import (
ObjectCreate,
ObjectDelete,
ObjectList,
ObjectNameMixin,
ObjectUpdate,
)
from rest_framework import status from rest_framework import status
from rest_framework.parsers import JSONParser from rest_framework.parsers import JSONParser
from rest_framework.views import APIView from rest_framework.views import APIView
from core.forms import AssetGroupForm # , AssetRestrictionForm from core.forms import AssetGroupForm, AssetRuleForm
from core.models import AssetGroup # , AssetRestriction from core.models import AssetGroup, AssetRule
from core.trading import assetfilter from core.trading import assetfilter
from core.util import logs from core.util import logs
@ -55,73 +48,69 @@ class AssetGroupDelete(LoginRequiredMixin, ObjectDelete):
model = AssetGroup model = AssetGroup
# Asset Restrictions # Asset Rules
# class AssetRestrictionsPermissionMixin: class AssetRulesPermissionMixin:
# # Check the user has permission to view the asset group # Check the user has permission to view the asset group
# # We have a user check on the AssetRestriction, but we need to check the # We have a user check on the AssetRestriction, but we need to check the
# # AssetGroup as well # AssetGroup as well
# def set_extra_args(self, user): def set_extra_args(self, user):
# self.extra_permission_args = { self.extra_permission_args = {
# "group__user": user, "group__user": user,
# "group__pk": self.kwargs["group"], "group__pk": self.kwargs["group"],
# } }
# class AssetRestrictionList( class AssetRuleList(LoginRequiredMixin, AssetRulesPermissionMixin, ObjectList):
# LoginRequiredMixin, AssetRestrictionsPermissionMixin, ObjectList list_template = "partials/assetrule-list.html"
# ): model = AssetRule
# list_template = "partials/assetrestriction-list.html" page_title = "List of asset rules"
# model = AssetRestriction
# page_title = "List of asset restrictions. Linked to asset groups."
# page_subtitle = (
# "Allows API calls to permit or prohibit trading on defined currency pairs."
# )
# list_url_name = "assetrestrictions" list_url_name = "assetrules"
# list_url_args = ["type", "group"] list_url_args = ["type", "group"]
# submit_url_name = "assetrestriction_create" submit_url_name = "assetrule_create"
# submit_url_args = ["type", "group"] submit_url_args = ["type", "group"]
# class AssetRestrictionCreate( class AssetRuleCreate(LoginRequiredMixin, AssetRulesPermissionMixin, ObjectCreate):
# LoginRequiredMixin, AssetRestrictionsPermissionMixin, ObjectCreate model = AssetRule
# ): form_class = AssetRuleForm
# model = AssetRestriction
# form_class = AssetRestrictionForm
# submit_url_name = "assetrestriction_create" submit_url_name = "assetrule_create"
# submit_url_args = ["type", "group"] submit_url_args = ["type", "group"]
# def form_invalid(self, form): def form_valid(self, form):
# """If the form is invalid, render the invalid form.""" """If the form is invalid, render the invalid form."""
# return self.get(self.request, **self.kwargs, form=form) try:
return super().form_valid(form)
except IntegrityError as e:
if "UNIQUE constraint failed" in str(e):
form.add_error("asset", "Asset rule already exists")
return self.form_invalid(form)
else:
raise e
# def pre_save_mutate(self, user, obj): def pre_save_mutate(self, user, obj):
# try: try:
# assetgroup = AssetGroup.objects.get(pk=self.kwargs["group"], user=user) assetgroup = AssetGroup.objects.get(pk=self.kwargs["group"], user=user)
# obj.group = assetgroup obj.group = assetgroup
# except AssetGroup.DoesNotExist: except AssetGroup.DoesNotExist:
# log.error(f"Asset Group {self.kwargs['group']} does not exist") log.error(f"Asset Group {self.kwargs['group']} does not exist")
# raise AbortSave("asset group does not exist or you don't have access") raise AbortSave("asset group does not exist or you don't have access")
# class AssetRestrictionUpdate( class AssetRuleUpdate(LoginRequiredMixin, AssetRulesPermissionMixin, ObjectUpdate):
# LoginRequiredMixin, AssetRestrictionsPermissionMixin, ObjectUpdate model = AssetRule
# ): form_class = AssetRuleForm
# model = AssetRestriction
# form_class = AssetRestrictionForm
# submit_url_name = "assetrestriction_update" submit_url_name = "assetrule_update"
# submit_url_args = ["type", "pk", "group"] submit_url_args = ["type", "pk", "group"]
# class AssetRestrictionDelete( class AssetRuleDelete(LoginRequiredMixin, AssetRulesPermissionMixin, ObjectDelete):
# LoginRequiredMixin, AssetRestrictionsPermissionMixin, ObjectDelete model = AssetRule
# ):
# model = AssetRestriction
class AssetGroupAPI(APIView): class AssetGroupAPI(APIView):
@ -164,7 +153,6 @@ class AssetGroupAPI(APIView):
return HttpResponse(status=status.HTTP_400_BAD_REQUEST) return HttpResponse(status=status.HTTP_400_BAD_REQUEST)
topic = request.data["topic"] topic = request.data["topic"]
print("YES TOPIC", topic)
new_assets = [] new_assets = []
assets_split = topic.split(",") assets_split = topic.split(",")
if not assets_split: if not assets_split:
@ -176,88 +164,46 @@ class AssetGroupAPI(APIView):
else: else:
log.error(f"Asset API {webhook_id} asset {asset} is not an asset") log.error(f"Asset API {webhook_id} asset {asset} is not an asset")
return HttpResponse(status=status.HTTP_400_BAD_REQUEST) return HttpResponse(status=status.HTTP_400_BAD_REQUEST)
print("YES PAIRS", new_assets)
aggregation = "none"
value = None
# Check if we have lower/upper bounds # Check if we have lower/upper bounds
if group.aggregation != "none":
if "aggs" in request.data["meta"]: if "aggs" in request.data["meta"]:
aggs = request.data["meta"]["aggs"] aggs = request.data["meta"]["aggs"]
if group.aggregation in aggs: # TODO: support more aggregations
if "value" in aggs[group.aggregation]: if len(aggs) != 1:
value = aggs[group.aggregation]["value"] log.error(
print("YES AVG", value) f"Asset API {webhook_id} has an invalid number of aggregations"
is_match = assetfilter.check_asset_aggregation(
value, group.trigger_above, group.trigger_below
) )
print("YES AVG IS MATCH", is_match) return HttpResponse(status=status.HTTP_400_BAD_REQUEST)
first_agg_key = list(aggs.keys())[0]
if "value" in aggs[first_agg_key]:
aggregation = first_agg_key
value = aggs[first_agg_key]["value"]
for asset in new_assets: for asset in new_assets:
group.allowed[asset] = is_match is True or None asset_rule, _ = AssetRule.objects.get_or_create(
group.save() group=group, user=group.user, asset=asset
)
if is_match:
# Make our own is_match based on the aggregations
is_match = assetfilter.check_asset_aggregation(
value, asset_rule.trigger_above, asset_rule.trigger_below
)
else:
# No matches
is_match = None
asset_rule.aggregation = aggregation
if value:
asset_rule.value = value
if is_match is not None:
our_status = is_match
else:
our_status = 1
asset_rule.original_status = our_status
asset_rule.status = assetfilter.update_status_from_mappings(
our_status, group
)
asset_rule.save()
return HttpResponse(status=status.HTTP_200_OK) return HttpResponse(status=status.HTTP_200_OK)
# Asset group allowed field
class AssetFilterList(LoginRequiredMixin, ObjectList):
list_template = "partials/asset-filter-list.html"
page_title = "List of asset filters."
page_subtitle = None
context_object_name_singular = "asset filter"
context_object_name = "asset filters"
list_url_name = "assetfilters"
list_url_args = ["type", "group_id"]
def get_queryset(self, **kwargs):
group_id = kwargs.get("group_id", None)
self.extra_context = {"group_id": group_id}
try:
group = AssetGroup.objects.get(id=group_id, user=self.request.user)
except AssetGroup.DoesNotExist:
context = {"message": "Asset group does not exist", "class": "danger"}
return self.render_to_response(context)
return group.allowed
class AssetFilterFlip(LoginRequiredMixin, ObjectNameMixin, View):
template_name = "mixins/partials/notify.html"
model = AssetGroup
def get(self, request, group_id, symbol):
try:
group = AssetGroup.objects.get(id=group_id, user=request.user)
except AssetGroup.DoesNotExist:
context = {"message": "Asset group does not exist", "class": "danger"}
return render(request, self.template_name, context)
if symbol not in group.allowed:
context = {"message": "Asset filter does not exist", "class": "danger"}
return render(request, self.template_name, context)
group.allowed[symbol] = not group.allowed[symbol]
group.save()
context = {"message": "Asset filter updated", "class": "success"}
response = render(request, self.template_name, context)
response["HX-Trigger"] = "assetgroupEvent"
return response
class AssetFilterDelete(LoginRequiredMixin, ObjectNameMixin, View):
template_name = "mixins/partials/notify.html"
model = AssetGroup
def delete(self, request, group_id, symbol):
try:
group = AssetGroup.objects.get(id=group_id, user=request.user)
except AssetGroup.DoesNotExist:
context = {"message": "Asset group does not exist", "class": "danger"}
return render(request, self.template_name, context)
if symbol not in group.allowed:
context = {"message": "Asset filter does not exist", "class": "danger"}
return render(request, self.template_name, context)
del group.allowed[symbol]
group.save()
context = {"message": "Asset filter deleted", "class": "success"}
response = render(request, self.template_name, context)
response["HX-Trigger"] = "assetgroupEvent"
return response

View File

@ -147,7 +147,7 @@ class HookList(LoginRequiredMixin, ObjectList):
# window_content = "window-content/hooks.html" # window_content = "window-content/hooks.html"
list_template = "partials/hook-list.html" list_template = "partials/hook-list.html"
model = Hook model = Hook
page_title = "List of active URL endpoints for receiving hooks." page_title = "List of active URL endpoints for receiving hooks"
page_subtitle = ( page_subtitle = (
"Add URLs here to receive Drakdoo callbacks. " "Add URLs here to receive Drakdoo callbacks. "
"Make then unique and hard to guess!" "Make then unique and hard to guess!"

View File

@ -19,7 +19,7 @@ log = logs.get_logger(__name__)
# Trend directions # Trend directions
class TrendDirectionList(LoginRequiredMixin, ObjectList): class TrendDirectionList(LoginRequiredMixin, ObjectList):
list_template = "partials/trend-direction-list.html" list_template = "partials/trend-direction-list.html"
page_title = "List of trend directions for a strategy." page_title = "List of trend directions for a strategy"
page_subtitle = None page_subtitle = None
context_object_name_singular = "trend direction" context_object_name_singular = "trend direction"
@ -86,7 +86,7 @@ class TrendDirectionDelete(LoginRequiredMixin, ObjectNameMixin, View):
class TradingTimeList(LoginRequiredMixin, ObjectList): class TradingTimeList(LoginRequiredMixin, ObjectList):
list_template = "partials/trading-time-list.html" list_template = "partials/trading-time-list.html"
model = TradingTime model = TradingTime
page_title = "List of allowed trading times. Used as options for a strategy." page_title = "List of allowed trading times, used as options for a strategy"
page_subtitle = "Add times here in order to permit trading." page_subtitle = "Add times here in order to permit trading."
list_url_name = "tradingtimes" list_url_name = "tradingtimes"

View File

@ -11,7 +11,7 @@ log = logs.get_logger(__name__)
class RiskList(LoginRequiredMixin, ObjectList): class RiskList(LoginRequiredMixin, ObjectList):
list_template = "partials/risk-list.html" list_template = "partials/risk-list.html"
model = RiskModel model = RiskModel
page_title = "List of risk management strategies. Linked to accounts." page_title = "List of risk management strategies, linked to accounts"
list_url_name = "risks" list_url_name = "risks"
list_url_args = ["type"] list_url_args = ["type"]

View File

@ -11,7 +11,7 @@ log = logs.get_logger(__name__)
class SignalList(LoginRequiredMixin, ObjectList): class SignalList(LoginRequiredMixin, ObjectList):
list_template = "partials/signal-list.html" list_template = "partials/signal-list.html"
model = Signal model = Signal
page_title = "List of signals. Linked to hooks and strategies." page_title = "List of signals, linked to hooks and strategies"
page_subtitle = ( page_subtitle = (
"Link signals you have defined in Drakdoo to their corresponding hooks." "Link signals you have defined in Drakdoo to their corresponding hooks."
) )

View File

@ -59,7 +59,7 @@ class TradeList(LoginRequiredMixin, OTPRequiredMixin, ObjectList):
list_template = "partials/trade-list.html" list_template = "partials/trade-list.html"
model = Trade model = Trade
page_title = ( page_title = (
"List of bot and manual trades. This may not reflect actual live trades." "List of bot and manual trades, this may not reflect actual live trades"
) )
page_subtitle = "Trades deleted here will not be closed on the exchange." page_subtitle = "Trades deleted here will not be closed on the exchange."