diff --git a/app/urls.py b/app/urls.py index d226624..b8b161c 100644 --- a/app/urls.py +++ b/app/urls.py @@ -76,7 +76,7 @@ urlpatterns = [ ), path( f"{settings.ASSET_PATH}//", - assets.AssetRestrictionAPI.as_view(), + assets.AssetGroupAPI.as_view(), name="asset", ), path("signals//", signals.SignalList.as_view(), name="signals"), @@ -264,26 +264,26 @@ urlpatterns = [ name="assetgroup_delete", ), # Asset Restrictions - path( - "restriction///", - assets.AssetRestrictionList.as_view(), - name="assetrestrictions", - ), - path( - "restriction//create//", - assets.AssetRestrictionCreate.as_view(), - name="assetrestriction_create", - ), - path( - "restriction//update///", - assets.AssetRestrictionUpdate.as_view(), - name="assetrestriction_update", - ), - path( - "restriction//delete///", - assets.AssetRestrictionDelete.as_view(), - name="assetrestriction_delete", - ), + # path( + # "restriction///", + # assets.AssetRestrictionList.as_view(), + # name="assetrestrictions", + # ), + # path( + # "restriction//create//", + # assets.AssetRestrictionCreate.as_view(), + # name="assetrestriction_create", + # ), + # path( + # "restriction//update///", + # assets.AssetRestrictionUpdate.as_view(), + # name="assetrestriction_update", + # ), + # path( + # "restriction//delete///", + # assets.AssetRestrictionDelete.as_view(), + # name="assetrestriction_delete", + # ), # Asset group filters path( "assetfilter//flip//", diff --git a/core/admin.py b/core/admin.py index 504e742..8f5d85b 100644 --- a/core/admin.py +++ b/core/admin.py @@ -2,10 +2,9 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin from .forms import CustomUserCreationForm -from .models import ( +from .models import ( # AssetRestriction, Account, AssetGroup, - AssetRestriction, Callback, Hook, NotificationSettings, @@ -94,11 +93,11 @@ class RiskModelAdmin(admin.ModelAdmin): class AssetGroupAdmin(admin.ModelAdmin): - list_display = ("user", "name", "description") + list_display = ("user", "name", "description", "webhook_id") -class AssetRestrictionAdmin(admin.ModelAdmin): - list_display = ("user", "name", "description", "webhook_id", "group") +# class AssetRestrictionAdmin(admin.ModelAdmin): +# list_display = ("user", "name", "description", "webhook_id", "group") admin.site.register(User, CustomUserAdmin) @@ -115,4 +114,4 @@ admin.site.register(Strategy, StrategyAdmin) admin.site.register(NotificationSettings, NotificationSettingsAdmin) admin.site.register(RiskModel, RiskModelAdmin) admin.site.register(AssetGroup, AssetGroupAdmin) -admin.site.register(AssetRestriction, AssetRestrictionAdmin) +# admin.site.register(AssetRestriction, AssetRestrictionAdmin) diff --git a/core/forms.py b/core/forms.py index a817d1c..3c2bb76 100644 --- a/core/forms.py +++ b/core/forms.py @@ -4,10 +4,9 @@ from django.core.exceptions import FieldDoesNotExist from django.forms import ModelForm from mixins.restrictions import RestrictedFormMixin -from .models import ( +from .models import ( # AssetRestriction, Account, AssetGroup, - AssetRestriction, Hook, NotificationSettings, RiskModel, @@ -309,47 +308,85 @@ class AssetGroupForm(RestrictedFormMixin, ModelForm): fields = ( "name", "description", + "aggregation", + "trigger_below", + "trigger_above", ) help_texts = { "name": "Name of the asset group. Informational only.", "description": "Description of the asset group. Informational only.", + "aggregation": "The aggregation method to use for this asset group.", + "trigger_below": "Trigger when the aggregation is below this value.", + "trigger_above": "Trigger when the aggregation is above this value.", } - -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}") + cleaned_data = super(AssetGroupForm, self).clean() + if "aggregation" in cleaned_data: + if cleaned_data["aggregation"] == "none": + if "trigger_below" in cleaned_data and cleaned_data["trigger_below"]: + self.add_error( + "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", + "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 - - cleaned_data["pairs_parsed"] = new_pairs - else: - cleaned_data["pairs_parsed"] = {} - 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 diff --git a/core/migrations/0059_assetgroup_webhook_id_delete_assetrestriction.py b/core/migrations/0059_assetgroup_webhook_id_delete_assetrestriction.py new file mode 100644 index 0000000..99b822e --- /dev/null +++ b/core/migrations/0059_assetgroup_webhook_id_delete_assetrestriction.py @@ -0,0 +1,23 @@ +# Generated by Django 4.1.6 on 2023-02-13 10:28 + +import uuid + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0058_remove_assetgroup_account'), + ] + + operations = [ + migrations.AddField( + model_name='assetgroup', + name='webhook_id', + field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + migrations.DeleteModel( + name='AssetRestriction', + ), + ] diff --git a/core/migrations/0060_assetgroup_aggregation_assetgroup_trigger_above_and_more.py b/core/migrations/0060_assetgroup_aggregation_assetgroup_trigger_above_and_more.py new file mode 100644 index 0000000..2625be4 --- /dev/null +++ b/core/migrations/0060_assetgroup_aggregation_assetgroup_trigger_above_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.1.6 on 2023-02-13 10:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0059_assetgroup_webhook_id_delete_assetrestriction'), + ] + + operations = [ + migrations.AddField( + model_name='assetgroup', + name='aggregation', + field=models.CharField(choices=[('none', 'None'), ('avg_sentiment', 'Average sentiment')], default='none', max_length=255), + ), + migrations.AddField( + model_name='assetgroup', + name='trigger_above', + field=models.FloatField(blank=True, null=True), + ), + migrations.AddField( + model_name='assetgroup', + name='trigger_below', + field=models.FloatField(blank=True, null=True), + ), + ] diff --git a/core/models.py b/core/models.py index 694ec26..802e06d 100644 --- a/core/models.py +++ b/core/models.py @@ -41,6 +41,10 @@ SIGNAL_TYPE_CHOICES = ( ("exit", "Exit"), ("trend", "Trend"), ) +AGGREGATION_CHOICES = ( + ("none", "None"), + ("avg_sentiment", "Average sentiment"), +) class Plan(models.Model): @@ -414,8 +418,17 @@ class AssetGroup(models.Model): # Dict like {"RUB": True, "USD": False} allowed = models.JSONField(null=True, blank=True, default=dict) + webhook_id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) + + aggregation = models.CharField( + choices=AGGREGATION_CHOICES, max_length=255, default="none" + ) + + trigger_below = models.FloatField(null=True, blank=True) + trigger_above = models.FloatField(null=True, blank=True) + def __str__(self): - return f"{self.name} ({self.restrictions})" + return f"{self.name}" @property def matches(self): @@ -426,23 +439,16 @@ class AssetGroup(models.Model): truthy_values = [x for x in self.allowed.values() if x is True] return f"{len(truthy_values)}/{len(self.allowed)}" - @property - def restrictions(self): - """ - Get the total number of restrictions for this group. - """ - return self.assetrestriction_set.count() +# class AssetRestriction(models.Model): +# user = models.ForeignKey(User, on_delete=models.CASCADE) +# name = models.CharField(max_length=255) +# description = models.TextField(null=True, blank=True) +# pairs = models.CharField(max_length=4096, null=True, blank=True) +# pairs_parsed = models.JSONField(null=True, blank=True, default=list) -class AssetRestriction(models.Model): - user = models.ForeignKey(User, on_delete=models.CASCADE) - name = models.CharField(max_length=255) - description = models.TextField(null=True, blank=True) - pairs = models.CharField(max_length=4096, null=True, blank=True) - pairs_parsed = models.JSONField(null=True, blank=True, default=list) +# webhook_id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) - webhook_id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) - - group = models.ForeignKey( - AssetGroup, on_delete=models.CASCADE, null=True, blank=True - ) +# group = models.ForeignKey( +# AssetGroup, on_delete=models.CASCADE, null=True, blank=True +# ) diff --git a/core/templates/partials/assetgroup-list.html b/core/templates/partials/assetgroup-list.html index b531cfa..3d31ae2 100644 --- a/core/templates/partials/assetgroup-list.html +++ b/core/templates/partials/assetgroup-list.html @@ -1,6 +1,6 @@ {% load cache %} {% load cachalot cache %} -{% get_last_invalidation 'core.AssetGroup' 'core.AssetRestriction' as last %} +{% get_last_invalidation 'core.AssetGroup' as last %} {% include 'mixins/partials/notify.html' %} {% cache 600 objects_assetgroups request.user.id object_list last %} name - + {% for item in object_list %} @@ -35,7 +35,15 @@ {{ item.matches }} - + diff --git a/core/tests/trading/test_assetfilter.py b/core/tests/trading/test_assetfilter.py index 9953cd9..dcbdcad 100644 --- a/core/tests/trading/test_assetfilter.py +++ b/core/tests/trading/test_assetfilter.py @@ -1,7 +1,7 @@ from django.test import TestCase from core.models import AssetGroup, User -from core.trading.assetfilter import get_allowed +from core.trading import assetfilter class AssetfilterTestCase(TestCase): @@ -21,8 +21,75 @@ class AssetfilterTestCase(TestCase): Test that the asset filter works. """ self.group.allowed = {"EUR_USD": True, "EUR_GBP": False} - self.assertTrue(get_allowed(self.group, "EUR_USD", "buy")) - self.assertFalse(get_allowed(self.group, "EUR_GBP", "sell")) + self.assertTrue(assetfilter.get_allowed(self.group, "EUR_USD", "buy")) + self.assertFalse(assetfilter.get_allowed(self.group, "EUR_GBP", "sell")) # Default true - self.assertTrue(get_allowed(self.group, "nonexistent", "sell")) + self.assertTrue(assetfilter.get_allowed(self.group, "nonexistent", "sell")) + + def test_check_asset_aggregation(self): + """ + Test that the asset aggregation works. + """ + # Test within lower bound + self.assertTrue( + assetfilter.check_asset_aggregation( + 1.0, trigger_above=None, trigger_below=2.0 + ) + ) + + # Test within upper bound + self.assertTrue( + assetfilter.check_asset_aggregation( + 1.0, trigger_above=0.0, trigger_below=None + ) + ) + + # Test within bounds + self.assertTrue( + assetfilter.check_asset_aggregation( + 1.0, trigger_above=0.0, trigger_below=2.0 + ) + ) + + # Test outside bounds + self.assertFalse( + assetfilter.check_asset_aggregation( + 1.0, trigger_above=2.0, trigger_below=3.0 + ) + ) + + # Test outside lower bound + self.assertFalse( + assetfilter.check_asset_aggregation( + 1.0, trigger_above=None, trigger_below=0.0 + ) + ) + + # Test outside upper bound + self.assertFalse( + assetfilter.check_asset_aggregation( + 1.0, trigger_above=2.0, trigger_below=None + ) + ) + + # Test no bounds, just to be sure + self.assertFalse( + assetfilter.check_asset_aggregation( + 1.0, trigger_above=None, trigger_below=None + ) + ) + + # Test both bounds, but inverted + self.assertFalse( + assetfilter.check_asset_aggregation( + 1.0, trigger_above=2.0, trigger_below=0.0 + ) + ) + + # Test within negative and positive bounds + self.assertTrue( + assetfilter.check_asset_aggregation( + -1.0, trigger_above=-2.0, trigger_below=0.0 + ) + ) diff --git a/core/trading/assetfilter.py b/core/trading/assetfilter.py index 7ef4f97..db77667 100644 --- a/core/trading/assetfilter.py +++ b/core/trading/assetfilter.py @@ -12,3 +12,22 @@ def get_allowed(group, symbol, direction): return True return allowed[symbol] + + +def check_asset_aggregation(value, trigger_above, trigger_below): + """ + Check if the value is within the bounds of the aggregation + """ + # 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 value < trigger_below: + # Value is less than lower bound, match + return True + if trigger_above is not None: + if value > trigger_above: + return True + return False diff --git a/core/views/assets.py b/core/views/assets.py index e61be37..55c8b04 100644 --- a/core/views/assets.py +++ b/core/views/assets.py @@ -1,12 +1,10 @@ import json -from cachalot.api import invalidate from django.contrib.auth.mixins import LoginRequiredMixin from django.http import HttpResponse from django.shortcuts import render from django.views import View from mixins.views import ( - AbortSave, ObjectCreate, ObjectDelete, ObjectList, @@ -17,8 +15,9 @@ from rest_framework import status from rest_framework.parsers import JSONParser from rest_framework.views import APIView -from core.forms import AssetGroupForm, AssetRestrictionForm -from core.models import AssetGroup, AssetRestriction +from core.forms import AssetGroupForm # , AssetRestrictionForm +from core.models import AssetGroup # , AssetRestriction +from core.trading import assetfilter from core.util import logs log = logs.get_logger(__name__) @@ -59,73 +58,73 @@ class AssetGroupDelete(LoginRequiredMixin, ObjectDelete): # Asset Restrictions -class AssetRestrictionsPermissionMixin: - # Check the user has permission to view the asset group - # We have a user check on the AssetRestriction, but we need to check the - # AssetGroup as well - def set_extra_args(self, user): - self.extra_permission_args = { - "group__user": user, - "group__pk": self.kwargs["group"], - } +# class AssetRestrictionsPermissionMixin: +# # Check the user has permission to view the asset group +# # We have a user check on the AssetRestriction, but we need to check the +# # AssetGroup as well +# def set_extra_args(self, user): +# self.extra_permission_args = { +# "group__user": user, +# "group__pk": self.kwargs["group"], +# } -class AssetRestrictionList( - LoginRequiredMixin, AssetRestrictionsPermissionMixin, ObjectList -): - list_template = "partials/assetrestriction-list.html" - 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." - ) +# class AssetRestrictionList( +# LoginRequiredMixin, AssetRestrictionsPermissionMixin, ObjectList +# ): +# list_template = "partials/assetrestriction-list.html" +# 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_args = ["type", "group"] +# list_url_name = "assetrestrictions" +# list_url_args = ["type", "group"] - submit_url_name = "assetrestriction_create" - submit_url_args = ["type", "group"] +# submit_url_name = "assetrestriction_create" +# submit_url_args = ["type", "group"] -class AssetRestrictionCreate( - LoginRequiredMixin, AssetRestrictionsPermissionMixin, ObjectCreate -): - model = AssetRestriction - form_class = AssetRestrictionForm +# class AssetRestrictionCreate( +# LoginRequiredMixin, AssetRestrictionsPermissionMixin, ObjectCreate +# ): +# model = AssetRestriction +# form_class = AssetRestrictionForm - submit_url_name = "assetrestriction_create" - submit_url_args = ["type", "group"] +# submit_url_name = "assetrestriction_create" +# submit_url_args = ["type", "group"] - def form_invalid(self, form): - """If the form is invalid, render the invalid form.""" - return self.get(self.request, **self.kwargs, form=form) +# def form_invalid(self, form): +# """If the form is invalid, render the invalid form.""" +# return self.get(self.request, **self.kwargs, form=form) - def pre_save_mutate(self, user, obj): - try: - assetgroup = AssetGroup.objects.get(pk=self.kwargs["group"], user=user) - obj.group = assetgroup - except AssetGroup.DoesNotExist: - log.error(f"Asset Group {self.kwargs['group']} does not exist") - raise AbortSave("asset group does not exist or you don't have access") +# def pre_save_mutate(self, user, obj): +# try: +# assetgroup = AssetGroup.objects.get(pk=self.kwargs["group"], user=user) +# obj.group = assetgroup +# except AssetGroup.DoesNotExist: +# log.error(f"Asset Group {self.kwargs['group']} does not exist") +# raise AbortSave("asset group does not exist or you don't have access") -class AssetRestrictionUpdate( - LoginRequiredMixin, AssetRestrictionsPermissionMixin, ObjectUpdate -): - model = AssetRestriction - form_class = AssetRestrictionForm +# class AssetRestrictionUpdate( +# LoginRequiredMixin, AssetRestrictionsPermissionMixin, ObjectUpdate +# ): +# model = AssetRestriction +# form_class = AssetRestrictionForm - submit_url_name = "assetrestriction_update" - submit_url_args = ["type", "pk", "group"] +# submit_url_name = "assetrestriction_update" +# submit_url_args = ["type", "pk", "group"] -class AssetRestrictionDelete( - LoginRequiredMixin, AssetRestrictionsPermissionMixin, ObjectDelete -): - model = AssetRestriction +# class AssetRestrictionDelete( +# LoginRequiredMixin, AssetRestrictionsPermissionMixin, ObjectDelete +# ): +# model = AssetRestriction -class AssetRestrictionAPI(APIView): +class AssetGroupAPI(APIView): parser_classes = [JSONParser] def post(self, request, webhook_id): @@ -133,16 +132,16 @@ class AssetRestrictionAPI(APIView): print(json.dumps(request.data, indent=2)) try: - restriction = AssetRestriction.objects.get(webhook_id=webhook_id) - except AssetRestriction.DoesNotExist: - log.error(f"Asset restriction {webhook_id} does not exist") + group = AssetGroup.objects.get(webhook_id=webhook_id) + except AssetGroup.DoesNotExist: + log.error(f"Asset group {webhook_id} does not exist") return HttpResponse(status=status.HTTP_404_NOT_FOUND) - if restriction.group is not None: - group = restriction.group - else: - log.error(f"Asset restriction {restriction} has no group") - return HttpResponse(status=status.HTTP_404_NOT_FOUND) + # if restriction.group is not None: + # group = restriction.group + # else: + # log.error(f"Asset restriction {restriction} has no group") + # return HttpResponse(status=status.HTTP_404_NOT_FOUND) # if group.strategy_set.exists() is not None: # strategies = group.strategy_set.all() @@ -151,18 +150,52 @@ class AssetRestrictionAPI(APIView): # return HttpResponse(status=status.HTTP_404_NOT_FOUND) # log.debug(f"Asset API {webhook_id} matched to strategies {strategies}") - if "meta" in request.data: - if "is_match" in request.data["meta"]: - is_match = request.data["meta"]["is_match"] - if isinstance(restriction.pairs_parsed, list): - for pair in restriction.pairs_parsed: - group.allowed[pair] = is_match - group.save() - invalidate(restriction) - invalidate(group) + if "meta" not in request.data: + log.error(f"Asset API {webhook_id} has no meta") + return HttpResponse(status=status.HTTP_400_BAD_REQUEST) - return HttpResponse(status=status.HTTP_200_OK) - return HttpResponse(status=status.HTTP_400_BAD_REQUEST) + if "is_match" not in request.data["meta"]: + log.error(f"Asset API {webhook_id} has no is_match") + return HttpResponse(status=status.HTTP_400_BAD_REQUEST) + + is_match = request.data["meta"]["is_match"] + if "topic" not in request.data: + log.error(f"Asset API {webhook_id} has no topic") + return HttpResponse(status=status.HTTP_400_BAD_REQUEST) + + topic = request.data["topic"] + print("YES TOPIC", topic) + new_pairs = [] + pair_split = topic.split(",") + if not pair_split: + log.error(f"Asset API {webhook_id} topic {topic} is not a pair") + return HttpResponse(status=status.HTTP_400_BAD_REQUEST) + for pair in pair_split: + if pair: + new_pairs.append(pair.strip()) + else: + log.error(f"Asset API {webhook_id} pair {pair} is not a pair") + return HttpResponse(status=status.HTTP_400_BAD_REQUEST) + print("YES PAIRS", new_pairs) + + # Check if we have lower/upper bounds + if group.aggregation != "none": + if "aggs" in request.data["meta"]: + aggs = request.data["meta"]["aggs"] + if group.aggregation in aggs: + if "value" in aggs[group.aggregation]: + value = aggs[group.aggregation]["value"] + print("YES AVG", value) + is_match = assetfilter.check_asset_aggregation( + value, group.trigger_above, group.trigger_below + ) + print("YES AVG IS MATCH", is_match) + + for pair in new_pairs: + group.allowed[pair] = is_match is True or None + group.save() + + return HttpResponse(status=status.HTTP_200_OK) # Asset group allowed field
description statusrestrictionshook actions {{ item.restrictions }} + + + + + +