diff --git a/app/urls.py b/app/urls.py index 5fbe919..7ffc97f 100644 --- a/app/urls.py +++ b/app/urls.py @@ -21,7 +21,7 @@ from django.urls import include, path from django.views.generic import TemplateView from django_otp.forms import OTPAuthenticationForm -from core.views import accounts, base, callbacks, hooks, trades +from core.views import accounts, base, callbacks, hooks, trades, positions from core.views.stripe_callbacks import Callback urlpatterns = [ @@ -115,4 +115,26 @@ urlpatterns = [ trades.TradeAction.as_view(), name="trade_action", ), + path("positions//", positions.Positions.as_view(), name="positions"), + # path("trades//add/", trades.TradeAction.as_view(), name="trade_action"), + path( + "positions///", + positions.Positions.as_view(), + name="positions", + ), + # path( + # "trades//add//", + # trades.TradeAction.as_view(), + # name="trade_action", + # ), + # path( + # "trades//del//", + # trades.TradeAction.as_view(), + # name="trade_action", + # ), + # path( + # "trades//edit//", + # trades.TradeAction.as_view(), + # name="trade_action", + # ), ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/core/forms.py b/core/forms.py index b832d7b..fa93e04 100644 --- a/core/forms.py +++ b/core/forms.py @@ -51,6 +51,7 @@ class AccountForm(ModelForm): "exchange", "api_key", "api_secret", + "sandbox", ) @@ -65,4 +66,5 @@ class TradeForm(ModelForm): "price", "stop_loss", "take_profit", + "direction", ) diff --git a/core/lib/serde/ccxt_s.py b/core/lib/serde/ccxt_s.py new file mode 100644 index 0000000..baf08e4 --- /dev/null +++ b/core/lib/serde/ccxt_s.py @@ -0,0 +1,124 @@ +from serde import Model, fields +# { +# "id": "92f0b26b-4c98-4553-9c74-cdafc7e037db", +# "clientOrderId": "ccxt_26adcbf445674f01af38a66a15e6f5b5", +# "timestamp": 1666096856515, +# "datetime": "2022-10-18T12:40:56.515477181Z", +# "lastTradeTimeStamp": null, +# "status": "open", +# "symbol": "BTC/USD", +# "type": "market", +# "timeInForce": "gtc", +# "postOnly": null, +# "side": "buy", +# "price": null, +# "stopPrice": null, +# "cost": null, +# "average": null, +# "amount": 1.1, +# "filled": 0.0, +# "remaining": 1.1, +# "trades": [], +# "fee": null, +# "info": { +# "id": "92f0b26b-4c98-4553-9c74-cdafc7e037db", +# "client_order_id": "ccxt_26adcbf445674f01af38a66a15e6f5b5", +# "created_at": "2022-10-18T12:40:56.516095561Z", +# "updated_at": "2022-10-18T12:40:56.516173841Z", +# "submitted_at": "2022-10-18T12:40:56.515477181Z", +# "filled_at": null, +# "expired_at": null, +# "canceled_at": null, +# "failed_at": null, +# "replaced_at": null, +# "replaced_by": null, +# "replaces": null, +# "asset_id": "276e2673-764b-4ab6-a611-caf665ca6340", +# "symbol": "BTC/USD", +# "asset_class": "crypto", +# "notional": null, +# "qty": "1.1", +# "filled_qty": "0", +# "filled_avg_price": null, +# "order_class": "", +# "order_type": "market", +# "type": "market", +# "side": "buy", +# "time_in_force": "gtc", +# "limit_price": null, +# "stop_price": null, +# "status": "pending_new", +# "extended_hours": false, +# "legs": null, +# "trail_percent": null, +# "trail_price": null, +# "hwm": null, +# "subtag": null, +# "source": null +# }, +# "fees": [], +# "lastTradeTimestamp": null +# } + +class CCXTInfo(Model): + id = fields.Uuid() + client_order_id = fields.Str() + created_at = fields.Str() + updated_at = fields.Str() + submitted_at = fields.Str() + filled_at = fields.Optional(fields.Str()) + expired_at = fields.Optional(fields.Str()) + canceled_at = fields.Optional(fields.Str()) + failed_at = fields.Optional(fields.Str()) + replaced_at = fields.Optional(fields.Str()) + replaced_by = fields.Optional(fields.Str()) + replaces = fields.Optional(fields.Str()) + asset_id = fields.Uuid() + symbol = fields.Str() + asset_class = fields.Str() + notional = fields.Optional(fields.Str()) + qty = fields.Str() + filled_qty = fields.Str() + filled_avg_price = fields.Optional(fields.Str()) + order_class = fields.Str() + order_type = fields.Str() + type = fields.Str() + side = fields.Str() + time_in_force = fields.Str() + limit_price = fields.Optional(fields.Str()) + stop_price = fields.Optional(fields.Str()) + status = fields.Str() + extended_hours = fields.Bool() + legs = fields.Optional(fields.List(fields.Nested("CCXTInfo"))) + trail_percent = fields.Optional(fields.Str()) + trail_price = fields.Optional(fields.Str()) + hwm = fields.Optional(fields.Str()) + subtag = fields.Optional(fields.Str()) + source = fields.Optional(fields.Str()) + + + +class CCXTRoot(Model): + id = fields.Uuid() + clientOrderId = fields.Str() + timestamp = fields.Int() + datetime = fields.Str() + lastTradeTimeStamp = fields.Optional(fields.Str()) + status = fields.Str() + symbol = fields.Str() + type = fields.Str() + timeInForce = fields.Str() + postOnly = fields.Optional(fields.Str()) + side = fields.Str() + price = fields.Optional(fields.Float()) + stopPrice = fields.Optional(fields.Float()) + cost = fields.Optional(fields.Float()) + average = fields.Optional(fields.Float()) + amount = fields.Float() + filled = fields.Float() + remaining = fields.Float() + trades = fields.Optional(fields.List(fields.Dict())) + fee = fields.Optional(fields.Float()) + info = fields.Nested(CCXTInfo) + fees = fields.Optional(fields.List(fields.Dict())) + lastTradeTimestamp = fields.Optional(fields.Str()) diff --git a/core/lib/serde/drakdoo.py b/core/lib/serde/drakdoo_s.py similarity index 100% rename from core/lib/serde/drakdoo.py rename to core/lib/serde/drakdoo_s.py diff --git a/core/lib/trades.py b/core/lib/trades.py new file mode 100644 index 0000000..1730825 --- /dev/null +++ b/core/lib/trades.py @@ -0,0 +1,4 @@ +# Trade handling + +def sync_trades_with_db(user): + pass diff --git a/core/migrations/0011_alter_account_exchange.py b/core/migrations/0011_alter_account_exchange.py new file mode 100644 index 0000000..21e7c1f --- /dev/null +++ b/core/migrations/0011_alter_account_exchange.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.2 on 2022-10-18 08:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0010_account_sandbox_trade_direction_trade_status_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='account', + name='exchange', + field=models.CharField(choices=[('binance', 'Binance'), ('alpaca', 'Alpaca')], max_length=255), + ), + ] diff --git a/core/migrations/0012_rename_exchange_id_trade_client_order_id_and_more.py b/core/migrations/0012_rename_exchange_id_trade_client_order_id_and_more.py new file mode 100644 index 0000000..7580c0a --- /dev/null +++ b/core/migrations/0012_rename_exchange_id_trade_client_order_id_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.1.2 on 2022-10-18 13:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0011_alter_account_exchange'), + ] + + operations = [ + migrations.RenameField( + model_name='trade', + old_name='exchange_id', + new_name='client_order_id', + ), + migrations.AddField( + model_name='trade', + name='order_id', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='trade', + name='response', + field=models.JSONField(blank=True, null=True), + ), + migrations.AlterField( + model_name='trade', + name='symbol', + field=models.CharField(choices=[('BTC/USD', 'Bitcoin/US Dollar'), ('LTC/USD', 'Litecoin/US Dollar')], max_length=255), + ), + ] diff --git a/core/migrations/0013_alter_trade_direction_alter_trade_price.py b/core/migrations/0013_alter_trade_direction_alter_trade_price.py new file mode 100644 index 0000000..1535681 --- /dev/null +++ b/core/migrations/0013_alter_trade_direction_alter_trade_price.py @@ -0,0 +1,24 @@ +# Generated by Django 4.1.2 on 2022-10-18 13:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0012_rename_exchange_id_trade_client_order_id_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='trade', + name='direction', + field=models.CharField(choices=[('buy', 'Buy'), ('sell', 'Sell')], default='buy', max_length=255), + preserve_default=False, + ), + migrations.AlterField( + model_name='trade', + name='price', + field=models.FloatField(blank=True, null=True), + ), + ] diff --git a/core/models.py b/core/models.py index 197bfa3..54298a3 100644 --- a/core/models.py +++ b/core/models.py @@ -1,14 +1,15 @@ -import logging - import ccxt import stripe from django.conf import settings from django.contrib.auth.models import AbstractUser from django.db import models +from serde import ValidationError from core.lib.customers import get_or_create, update_customer_fields +from core.lib.serde import ccxt_s +from core.util import logs -logger = logging.getLogger(__name__) +log = logs.get_logger(__name__) class Plan(models.Model): @@ -59,7 +60,7 @@ class User(AbstractUser): if settings.STRIPE_ENABLED: if self.stripe_id: stripe.Customer.delete(self.stripe_id) - logger.info(f"Deleted Stripe customer {self.stripe_id}") + log.info(f"Deleted Stripe customer {self.stripe_id}") super().delete(*args, **kwargs) def has_plan(self, plan): @@ -96,7 +97,10 @@ class Hook(models.Model): class Trade(models.Model): - SYMBOL_CHOICES = (("BTCUSD", "Bitcoin/USD"),) + SYMBOL_CHOICES = ( + ("BTC/USD", "Bitcoin/US Dollar"), + ("LTC/USD", "Litecoin/US Dollar"), + ) TYPE_CHOICES = ( ("market", "Market"), ("limit", "Limit"), @@ -110,15 +114,19 @@ class Trade(models.Model): symbol = models.CharField(choices=SYMBOL_CHOICES, max_length=255) type = models.CharField(choices=TYPE_CHOICES, max_length=255) amount = models.FloatField() - price = models.FloatField() + price = models.FloatField(null=True, blank=True) stop_loss = models.FloatField(null=True, blank=True) take_profit = models.FloatField(null=True, blank=True) - exchange_id = models.CharField(max_length=255, null=True, blank=True) status = models.CharField(max_length=255, null=True, blank=True) direction = models.CharField( - choices=DIRECTION_CHOICES, max_length=255, null=True, blank=True + choices=DIRECTION_CHOICES, max_length=255 ) + # To populate from the trade + order_id = models.CharField(max_length=255, null=True, blank=True) + client_order_id = models.CharField(max_length=255, null=True, blank=True) + response = models.JSONField(null=True, blank=True) + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._original = self @@ -127,7 +135,7 @@ class Trade(models.Model): """ Override the save function to place the trade. """ - if self.exchange_id is None: + if self.response is None: # the trade is not placed yet if self.account.exchange == "alpaca": account = ccxt.alpaca( @@ -149,7 +157,15 @@ class Trade(models.Model): self.price, params, ) - self.status = "filled" + + print("ORDER", order) + try: + parsed = ccxt_s.CCXTRoot.from_dict(order) + except ValidationError as e: + log.error(f"Error creating trade: {e}") + return False + self.status = parsed.status + self.response = order else: # there is a trade open # get trade diff --git a/core/templates/base.html b/core/templates/base.html index 3e5b322..33545eb 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -201,6 +201,11 @@ Home + {% if user.is_authenticated %} + + Positions + + {% endif %} {% if user.is_authenticated %} Accounts @@ -208,7 +213,7 @@ {% endif %} {% if user.is_authenticated %} - Trades + Bot Trades {% endif %} {% if user.is_authenticated %} diff --git a/core/templates/partials/account-list.html b/core/templates/partials/account-list.html index 0838adf..25392ac 100644 --- a/core/templates/partials/account-list.html +++ b/core/templates/partials/account-list.html @@ -7,6 +7,7 @@ name exchange API key + sandbox actions {% for item in items %} @@ -15,7 +16,18 @@ {{ item.user }} {{ item.name }} {{ item.exchange }} - {{ item.api_jey }} + {{ item.api_key }} + + {% if item.sandbox %} + + + + {% else %} + + + + {% endif %} +
+ + {% if type == 'page' %} + + + {% else %} + + {% endif %} +
+ + + {% endfor %} + + diff --git a/core/templates/window-content/positions.html b/core/templates/window-content/positions.html new file mode 100644 index 0000000..4dbecda --- /dev/null +++ b/core/templates/window-content/positions.html @@ -0,0 +1,20 @@ +
+ +
+ + {% include 'partials/notify.html' %} + {% include 'partials/position-list.html' %} + + \ No newline at end of file diff --git a/core/views/accounts.py b/core/views/accounts.py index 013aa12..ce1decf 100644 --- a/core/views/accounts.py +++ b/core/views/accounts.py @@ -1,17 +1,14 @@ import uuid -import orjson from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import HttpResponse, HttpResponseBadRequest +from django.http import HttpResponseBadRequest from django.shortcuts import render from django.views import View -from rest_framework.parsers import FormParser, JSONParser +from rest_framework.parsers import FormParser from rest_framework.views import APIView -from serde import ValidationError from core.forms import AccountForm -from core.lib.serde import drakdoo -from core.models import Account, Callback +from core.models import Account from core.util import logs log = logs.get_logger(__name__) @@ -100,7 +97,7 @@ class AccountAction(LoginRequiredMixin, APIView): form = AccountForm( request.data, instance=Account.objects.get(id=account_id) ) - except account.DoesNotExist: + except Account.DoesNotExist: message = "Account does not exist" message_class = "danger" context = { diff --git a/core/views/hooks.py b/core/views/hooks.py index d08d845..a772579 100644 --- a/core/views/hooks.py +++ b/core/views/hooks.py @@ -10,7 +10,7 @@ from rest_framework.views import APIView from serde import ValidationError from core.forms import HookForm -from core.lib.serde import drakdoo +from core.lib.serde import drakdoo_s from core.models import Callback, Hook from core.util import logs @@ -36,7 +36,7 @@ class HookAPI(APIView): # Try validating the JSON try: - hook_resp = drakdoo.BaseDrakdoo.from_dict(request.data) + hook_resp = drakdoo_s.BaseDrakdoo.from_dict(request.data) except ValidationError as e: log.error(f"HookAPI POST: {e}") return HttpResponseBadRequest(e) diff --git a/core/views/positions.py b/core/views/positions.py new file mode 100644 index 0000000..4912088 --- /dev/null +++ b/core/views/positions.py @@ -0,0 +1,62 @@ +import uuid + +import orjson +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import HttpResponse, HttpResponseBadRequest +from django.shortcuts import render +from django.views import View +from rest_framework.parsers import FormParser, JSONParser +from rest_framework.views import APIView +from serde import ValidationError + +from core.forms import HookForm +from core.lib.serde import drakdoo_s +from core.models import Callback, Hook, Account +from core.util import logs +import ccxt +from ccxt.base.errors import NotSupported +log = logs.get_logger(__name__) + + +def get_positions(user, account_id=None): + items = [] + accounts = Account.objects.filter(user=user) + for account in accounts: + if hasattr(ccxt, account.exchange): + instance = getattr(ccxt, account.exchange)({"apiKey": account.api_key, "secret": account.api_secret}) + if account.sandbox: + instance.set_sandbox_mode(True) + try: + positions = instance.fetch_positions() + except NotSupported: + positions = [{"account": account.exchange, "error": "Not supported"}] + print("POSITIONS", positions) + # try: + # parsed = ccxt_s.CCXTRoot.from_dict(order) + # except ValidationError as e: + # log.error(f"Error creating trade: {e}") + # return False + # self.status = parsed.status + # self.response = order + + +class Positions(LoginRequiredMixin, View): + allowed_types = ["modal", "widget", "window", "page"] + window_content = "window-content/positions.html" + + async def get(self, request, type, account_id=None): + if type not in self.allowed_types: + return HttpResponseBadRequest + template_name = f"wm/{type}.html" + unique = str(uuid.uuid4())[:8] + items = get_positions(request.user, account_id) + if type == "page": + type = "modal" + context = { + "title": f"Hooks ({type})", + "unique": unique, + "window_content": self.window_content, + "items": items, + "type": type, + } + return render(request, template_name, context) \ No newline at end of file diff --git a/core/views/trades.py b/core/views/trades.py index 2d98827..35ca78b 100644 --- a/core/views/trades.py +++ b/core/views/trades.py @@ -1,17 +1,14 @@ import uuid -import orjson from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import HttpResponse, HttpResponseBadRequest +from django.http import HttpResponseBadRequest from django.shortcuts import render from django.views import View -from rest_framework.parsers import FormParser, JSONParser +from rest_framework.parsers import FormParser from rest_framework.views import APIView -from serde import ValidationError from core.forms import TradeForm -from core.lib.serde import drakdoo -from core.models import Account, Callback, Trade +from core.models import Account, Trade from core.util import logs log = logs.get_logger(__name__) @@ -127,7 +124,9 @@ class TradeAction(LoginRequiredMixin, APIView): form = TradeForm(request.data) if form.is_valid(): trade = form.save(commit=False) + print("PRESAVE TRADE", trade) trade.save() + print("SAVED TRADE", trade) if trade_id: message = f"Trade {trade_id} edited successfully" else: