diff --git a/app/WYGIWYH/settings.py b/app/WYGIWYH/settings.py index 69d84b9..4d744d3 100644 --- a/app/WYGIWYH/settings.py +++ b/app/WYGIWYH/settings.py @@ -71,6 +71,7 @@ INSTALLED_APPS = [ "django_cotton", "apps.rules.apps.RulesConfig", "apps.calendar_view.apps.CalendarViewConfig", + "apps.dca.apps.DcaConfig", ] MIDDLEWARE = [ diff --git a/app/WYGIWYH/urls.py b/app/WYGIWYH/urls.py index 671055e..2467f57 100644 --- a/app/WYGIWYH/urls.py +++ b/app/WYGIWYH/urls.py @@ -45,4 +45,5 @@ urlpatterns = [ path("", include("apps.currencies.urls")), path("", include("apps.rules.urls")), path("", include("apps.calendar_view.urls")), + path("", include("apps.dca.urls")), ] diff --git a/app/apps/dca/__init__.py b/app/apps/dca/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/apps/dca/admin.py b/app/apps/dca/admin.py new file mode 100644 index 0000000..7a29716 --- /dev/null +++ b/app/apps/dca/admin.py @@ -0,0 +1,7 @@ +from django.contrib import admin + +from apps.dca.models import DCAStrategy, DCAEntry + +# Register your models here. +admin.site.register(DCAStrategy) +admin.site.register(DCAEntry) diff --git a/app/apps/dca/apps.py b/app/apps/dca/apps.py new file mode 100644 index 0000000..009aa02 --- /dev/null +++ b/app/apps/dca/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class DcaConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.dca" diff --git a/app/apps/dca/forms.py b/app/apps/dca/forms.py new file mode 100644 index 0000000..a71e02d --- /dev/null +++ b/app/apps/dca/forms.py @@ -0,0 +1,60 @@ +from django import forms +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Layout, Row, Column +from django.utils.translation import gettext_lazy as _ + +from .models import DCAStrategy, DCAEntry +from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput + + +class DCAStrategyForm(forms.ModelForm): + class Meta: + model = DCAStrategy + fields = ["name", "target_currency", "payment_currency", "notes"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_tag = False + self.helper.layout = Layout( + "name", + Row( + Column("target_currency", css_class="form-group col-md-6"), + Column("payment_currency", css_class="form-group col-md-6"), + ), + "notes", + ) + + +class DCAEntryForm(forms.ModelForm): + class Meta: + model = DCAEntry + fields = [ + "date", + "amount_paid", + "amount_received", + "expense_transaction", + "income_transaction", + "notes", + ] + widgets = { + "amount_paid": ArbitraryDecimalDisplayNumberInput(decimal_places=8), + "amount_received": ArbitraryDecimalDisplayNumberInput(decimal_places=8), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_tag = False + self.helper.layout = Layout( + "date", + Row( + Column("amount_paid", css_class="form-group col-md-6"), + Column("amount_received", css_class="form-group col-md-6"), + ), + Row( + Column("expense_transaction", css_class="form-group col-md-6"), + Column("income_transaction", css_class="form-group col-md-6"), + ), + "notes", + ) diff --git a/app/apps/dca/migrations/0001_initial.py b/app/apps/dca/migrations/0001_initial.py new file mode 100644 index 0000000..fe6693f --- /dev/null +++ b/app/apps/dca/migrations/0001_initial.py @@ -0,0 +1,54 @@ +# Generated by Django 5.1.2 on 2024-11-12 01:41 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('currencies', '0006_currency_exchange_currency'), + ('transactions', '0022_rename_paused_recurringtransaction_is_paused'), + ] + + operations = [ + migrations.CreateModel( + name='DCAStrategy', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='Name')), + ('notes', models.TextField(blank=True, null=True, verbose_name='Notes')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('payment_currency', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='dca_payment_strategies', to='currencies.currency', verbose_name='Payment Currency')), + ('target_currency', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='dca_target_strategies', to='currencies.currency', verbose_name='Target Currency')), + ], + options={ + 'verbose_name': 'DCA Strategy', + 'verbose_name_plural': 'DCA Strategies', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='DCAEntry', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField(verbose_name='Date')), + ('amount_paid', models.DecimalField(decimal_places=8, max_digits=20, verbose_name='Amount Paid')), + ('amount_received', models.DecimalField(decimal_places=8, max_digits=20, verbose_name='Amount Received')), + ('notes', models.TextField(blank=True, null=True, verbose_name='Notes')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('expense_transaction', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dca_expense_entries', to='transactions.transaction', verbose_name='Expense Transaction')), + ('income_transaction', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dca_income_entries', to='transactions.transaction', verbose_name='Income Transaction')), + ('strategy', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='entries', to='dca.dcastrategy', verbose_name='Strategy')), + ], + options={ + 'verbose_name': 'DCA Entry', + 'verbose_name_plural': 'DCA Entries', + 'ordering': ['-date'], + }, + ), + ] diff --git a/app/apps/dca/migrations/__init__.py b/app/apps/dca/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/apps/dca/models.py b/app/apps/dca/models.py new file mode 100644 index 0000000..04e757b --- /dev/null +++ b/app/apps/dca/models.py @@ -0,0 +1,140 @@ +from decimal import Decimal + +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from apps.currencies.utils.convert import convert + + +class DCAStrategy(models.Model): + name = models.CharField(max_length=255, verbose_name=_("Name")) + target_currency = models.ForeignKey( + "currencies.Currency", + verbose_name=_("Target Currency"), + on_delete=models.PROTECT, + related_name="dca_target_strategies", + ) + payment_currency = models.ForeignKey( + "currencies.Currency", + verbose_name=_("Payment Currency"), + on_delete=models.PROTECT, + related_name="dca_payment_strategies", + ) + notes = models.TextField(blank=True, null=True, verbose_name=_("Notes")) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("DCA Strategy") + verbose_name_plural = _("DCA Strategies") + ordering = ["-created_at"] + + def __str__(self): + return self.name + + def total_invested(self): + return sum(entry.amount_paid for entry in self.entries.all()) + + def total_received(self): + return sum(entry.amount_received for entry in self.entries.all()) + + def average_entry_price(self): + total_invested = self.total_invested() + total_received = self.total_received() + if total_received: + return total_invested / total_received + return Decimal("0") + + def total_entries(self): + return self.entries.count() + + def current_total_value(self): + """Calculate current total value of all entries""" + return sum(entry.current_value() for entry in self.entries.all()) + + def total_profit_loss(self): + """Calculate total P/L in payment currency""" + return self.current_total_value() - self.total_invested() + + def total_profit_loss_percentage(self): + """Calculate total P/L percentage""" + total_invested = self.total_invested() + if total_invested: + return (self.total_profit_loss() / total_invested) * 100 + return Decimal("0") + + +class DCAEntry(models.Model): + strategy = models.ForeignKey( + DCAStrategy, + on_delete=models.CASCADE, + related_name="entries", + verbose_name=_("Strategy"), + ) + date = models.DateField(verbose_name=_("Date")) + amount_paid = models.DecimalField( + max_digits=42, decimal_places=30, verbose_name=_("Amount Paid") + ) + amount_received = models.DecimalField( + max_digits=42, decimal_places=30, verbose_name=_("Amount Received") + ) + expense_transaction = models.ForeignKey( + "transactions.Transaction", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="dca_expense_entries", + verbose_name=_("Expense Transaction"), + ) + income_transaction = models.ForeignKey( + "transactions.Transaction", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="dca_income_entries", + verbose_name=_("Income Transaction"), + ) + notes = models.TextField(blank=True, null=True, verbose_name=_("Notes")) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("DCA Entry") + verbose_name_plural = _("DCA Entries") + ordering = ["-date"] + + def __str__(self): + return f"{self.strategy.name} - {self.date}" + + def entry_price(self): + if self.amount_received: + return self.amount_paid / self.amount_received + return 0 + + def current_value(self): + """ + Calculate current value of received amount in payment currency + using latest exchange rate + """ + if not self.amount_received: + return Decimal("0") + + amount, _, _, _ = convert( + self.amount_received, + self.strategy.target_currency, + self.strategy.payment_currency, + timezone.now().date(), + ) + + return amount or Decimal("0") + + def profit_loss(self): + """Calculate P/L in payment currency""" + return self.current_value() - self.amount_paid + + def profit_loss_percentage(self): + """Calculate P/L percentage""" + if self.amount_paid: + return (self.profit_loss() / self.amount_paid) * Decimal("100") + return Decimal("0") diff --git a/app/apps/dca/tests.py b/app/apps/dca/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/app/apps/dca/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/app/apps/dca/urls.py b/app/apps/dca/urls.py new file mode 100644 index 0000000..3387734 --- /dev/null +++ b/app/apps/dca/urls.py @@ -0,0 +1,9 @@ +from django.urls import path +from . import views + + +urlpatterns = [ + path("dca/", views.strategy_list, name="strategy_list"), + path("dca//", views.strategy_detail, name="strategy_detail"), + # Add more URLs for CRUD operations +] diff --git a/app/apps/dca/views.py b/app/apps/dca/views.py new file mode 100644 index 0000000..f546662 --- /dev/null +++ b/app/apps/dca/views.py @@ -0,0 +1,67 @@ +# apps/dca_tracker/views.py +from django.contrib.auth.decorators import login_required +from django.shortcuts import render, get_object_or_404, redirect +from django.contrib import messages +from django.utils.translation import gettext_lazy as _ +from django.db.models import Sum, Avg +from django.db.models.functions import TruncMonth + +from .models import DCAStrategy, DCAEntry +from .forms import DCAStrategyForm, DCAEntryForm + + +@login_required +def strategy_list(request): + strategies = DCAStrategy.objects.all() + return render(request, "dca/strategy_list.html", {"strategies": strategies}) + + +from django.shortcuts import render, get_object_or_404, redirect +from django.contrib import messages +from django.utils.translation import gettext_lazy as _ +from django.db.models import Sum, Avg +from django.db.models.functions import TruncMonth + + +@login_required +def strategy_detail(request, pk): + strategy = get_object_or_404(DCAStrategy, id=pk) + entries = strategy.entries.all() + + # Calculate monthly aggregates + monthly_data = ( + entries.annotate(month=TruncMonth("date")) + .values("month") + .annotate( + total_paid=Sum("amount_paid"), + total_received=Sum("amount_received"), + avg_entry_price=Avg("amount_paid") / Avg("amount_received"), + ) + .order_by("month") + ) + + # Prepare entries data with current values + entries_data = [ + { + "entry": entry, + "current_value": entry.current_value(), + "profit_loss": entry.profit_loss(), + "profit_loss_percentage": entry.profit_loss_percentage(), + } + for entry in entries + ] + + context = { + "strategy": strategy, + "entries": entries, + "entries_data": entries_data, + "monthly_data": monthly_data, + "total_invested": strategy.total_invested(), + "total_received": strategy.total_received(), + "average_entry_price": strategy.average_entry_price(), + "total_entries": strategy.total_entries(), + "current_total_value": strategy.current_total_value(), + "total_profit_loss": strategy.total_profit_loss(), + "total_profit_loss_percentage": strategy.total_profit_loss_percentage(), + } + return render(request, "dca/strategy_detail.html", context) diff --git a/app/templates/dca/strategy_detail.html b/app/templates/dca/strategy_detail.html new file mode 100644 index 0000000..ea5554c --- /dev/null +++ b/app/templates/dca/strategy_detail.html @@ -0,0 +1,228 @@ +{% extends "layouts/base.html" %} +{% load i18n %} + +{% block content %} +
+

{{ strategy.name }}

+ +
+
+
+
+
{% trans "Total Invested" %}
+ {#

{{ strategy.total_invested }} {{ strategy.payment_currency }}

#} +
+ +
+
+
+
+
+
+
+
{% trans "Total Received" %}
+
+ +
+
+
+
+
+
+
+
{% trans "Average Entry Price" %}
+
+ +
+
+
+
+
+ +
+
+
+
+
{% trans "Current Total Value" %}
+
+ +
+
+
+
+ +
+
+
+
{% trans "Total P/L" %}
+
+ + +
+
+
+
+ +
+
+
+
{% trans "Total % P/L" %}
+
+ {{ strategy.total_profit_loss_percentage|floatformat:2 }}% +
+
+
+
+
+ + +
+
+
+
+ +
+
+
+
+ + +
+
+
+
+
{% trans "Entries" %}
+
+ + + + + + + + + + + + + {% for entry in entries %} + + + + + + + + + {% endfor %} + +
{% trans "Date" %}{% trans "Amount Paid" %}{% trans "Amount Received" %}{% trans "Current Value" %}{% trans "P/L" %}{% trans "Actions" %}
{{ entry.date|date:"SHORT_DATE_FORMAT" }} + {% if entry.profit_loss_percentage > 0 %} + {{ entry.profit_loss_percentage|floatformat:"2g" }}% + {% elif entry.profit_loss_percentage < 0 %} + {{ entry.profit_loss_percentage|floatformat:"2g" }}% + {% endif %} + + +
+
+
+
+
+
+ +
+
+
+
+
{% trans "Performance Over Time" %}
+ +
+
+
+
+
+{% endblock %} + +{% block extra_js_body %} + + +{% endblock %}