diff --git a/app/apps/dca/migrations/0002_alter_dcaentry_amount_paid_and_more.py b/app/apps/dca/migrations/0002_alter_dcaentry_amount_paid_and_more.py new file mode 100644 index 0000000..8f150bf --- /dev/null +++ b/app/apps/dca/migrations/0002_alter_dcaentry_amount_paid_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.2 on 2024-11-13 03:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dca', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='dcaentry', + name='amount_paid', + field=models.DecimalField(decimal_places=30, max_digits=42, verbose_name='Amount Paid'), + ), + migrations.AlterField( + model_name='dcaentry', + name='amount_received', + field=models.DecimalField(decimal_places=30, max_digits=42, verbose_name='Amount Received'), + ), + ] diff --git a/app/apps/dca/models.py b/app/apps/dca/models.py index 04e757b..4bfbfdf 100644 --- a/app/apps/dca/models.py +++ b/app/apps/dca/models.py @@ -1,6 +1,9 @@ +from datetime import timedelta from decimal import Decimal +from statistics import mean, stdev from django.db import models +from django.template.defaultfilters import date from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -64,6 +67,72 @@ class DCAStrategy(models.Model): return (self.total_profit_loss() / total_invested) * 100 return Decimal("0") + def investment_frequency_data(self): + def _empty_frequency_data(): + return { + "intervals_line": [], + "labels": [], + } + + entries = self.entries.order_by("date") + + if entries.count() < 2: + return _empty_frequency_data() + + dates = list(entries.values_list("date", flat=True)) + intervals = [(dates[i + 1] - dates[i]).days for i in range(len(dates) - 1)] + + # Create data points for the intervals chart + labels = [] + intervals_line = [] + + for i in range(len(dates) - 1): + labels.append( + f"{date(dates[i], 'SHORT_DATE_FORMAT')} → {date(dates[i + 1], 'SHORT_DATE_FORMAT')}" + ) + intervals_line.append(intervals[i]) + + return { + "intervals_line": intervals_line, + "labels": labels, + } + + def price_comparison_data(self): + entries = self.entries.order_by("date") + + if entries.count() < 1: + return { + "labels": [], + "entry_prices": [], + "current_prices": [], + "amounts_bought": [], + } + + labels = [] + entry_prices = [] + current_prices = [] + amounts_bought = [] + + for entry in entries: + # Entry price calculation + entry_price = entry.amount_paid or 0 + + # Current value calculation using exchange rate + current_price = entry.current_value() or 0 + + labels.append(date(entry.date, "SHORT_DATE_FORMAT")) + # We use floats here because it's easier to transpose to Django's template + entry_prices.append(float(entry_price)) + current_prices.append(float(current_price)) + amounts_bought.append(float(entry.amount_received)) + + return { + "labels": labels, + "entry_prices": entry_prices, + "current_prices": current_prices, + "amounts_bought": amounts_bought, + } + class DCAEntry(models.Model): strategy = models.ForeignKey( diff --git a/app/apps/dca/views.py b/app/apps/dca/views.py index 319a76f..ec868fb 100644 --- a/app/apps/dca/views.py +++ b/app/apps/dca/views.py @@ -152,7 +152,10 @@ def strategy_detail(request, strategy_id): "current_total_value": strategy.current_total_value(), "total_profit_loss": strategy.total_profit_loss(), "total_profit_loss_percentage": strategy.total_profit_loss_percentage(), + "investment_frequency": strategy.investment_frequency_data(), + "price_comparison_data": strategy.price_comparison_data(), } + print(strategy.price_comparison_data()) return render(request, "dca/fragments/strategy/details.html", context) diff --git a/app/templates/dca/fragments/strategy/details.html b/app/templates/dca/fragments/strategy/details.html index f45c216..52a0ade 100644 --- a/app/templates/dca/fragments/strategy/details.html +++ b/app/templates/dca/fragments/strategy/details.html @@ -1,180 +1,103 @@ {% load i18n %} -
+
{{ strategy.name }}
-
-
-
-
-
{% trans "Total Invested" %}
-
- -
-
-
-
-
-
-
-
{% 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 }}% -
-
-
-
-
- - -
-
+
+
{% spaceless %} -
{% trans "Entries" %} +
{% trans "Entries" %} -
+
{% endspaceless %} {% if entries %} -
- - - - - - - - - - - - - {% for entry in entries %} - - - - - - - - - {% endfor %} - -
{% trans "Date" %}{% trans "Amount Paid" %}{% trans "Amount Received" %}{% trans "Current Value" %}{% trans "P/L" %}
-
- - - -
-
{{ 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 %} -
-
+
+ + + + + + + + + + + + + {% for entry in entries %} + + + + + + + + + {% endfor %} + +
{% trans "Date" %}{% trans "Amount Paid" %}{% trans "Amount Received" %}{% trans "Current Value" %}{% trans "P/L" %}
+
+ + + +
+
{{ 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 %} +
+
{% else %}
-
- -
-
+
+
+
+
+
{% trans "Total Invested" %}
+
+ +
+
+
+
+
+
+
+
{% trans "Total Received" %}
+
+ +
+
+
+
+
+
+
+
{% trans "Current Total Value" %}
+
+ +
+
+
+
+
+
+
+
{% trans "Average Entry Price" %}
+
+ +
+
+
+
+
+
+
+
{% trans "Total P/L" %}
+
+ + +
+
+
+
+
+
+
+
{% trans "Total % P/L" %}
+
+ {{ strategy.total_profit_loss_percentage|floatformat:2 }}% +
+
+
+
+
+
+
{ - return ctx.p0.parsed.y >= 0 && ctx.p1.parsed.y >= 0 ? 'rgb(75, 192, 75)' : - ctx.p0.parsed.y < 0 && ctx.p1.parsed.y < 0 ? 'rgb(255, 99, 132)' : - ctx.p0.parsed.y < 0 ? 'rgb(255, 99, 132)' : 'rgb(75, 192, 75)'; - } - }, - borderWidth: 2 - }] - }, - options: { - responsive: true, - scales: { - y: { - beginAtZero: false - } - }, - interaction: { - intersect: false, - }, - } - }) + type: 'line', + data: { + labels: [{% for entry in entries_data %}'{{ entry.entry.date|date:"SHORT_DATE_FORMAT" }}'{% if not forloop.last %}, {% endif %}{% endfor %}], + datasets: [{ + label: '{% trans "P/L %" %}', + data: [{% for entry in entries_data %}{{ entry.profit_loss_percentage|floatformat:"-40u" }}{% if not forloop.last %}, {% endif %}{% endfor %}], + stepped: true, + segment: { + borderColor: ctx => { + const gradient = ctx.chart.ctx.createLinearGradient(ctx.p0.x, 0, ctx.p1.x, 0); + + if (ctx.p0.parsed.y >= 0 && ctx.p1.parsed.y >= 0) { + // Both positive - solid green + gradient.addColorStop(0, 'rgb(75, 192, 75)'); + gradient.addColorStop(1, 'rgb(75, 192, 75)'); + } else if (ctx.p0.parsed.y < 0 && ctx.p1.parsed.y < 0) { + // Both negative - solid red + gradient.addColorStop(0, 'rgb(255, 99, 132)'); + gradient.addColorStop(1, 'rgb(255, 99, 132)'); + } else if (ctx.p0.parsed.y >= 0 && ctx.p1.parsed.y < 0) { + // Positive to negative - green to red + gradient.addColorStop(0, 'rgb(75, 192, 75)'); + gradient.addColorStop(1, 'rgb(255, 99, 132)'); + } else { + // Negative to positive - red to green + gradient.addColorStop(0, 'rgb(255, 99, 132)'); + gradient.addColorStop(1, 'rgb(75, 192, 75)'); + } + + return gradient; + } + }, + fill: false, + borderWidth: 2 + }] + }, + options: { + responsive: true, + scales: { + y: { + beginAtZero: false + }, + x: { + ticks: { + display: false + } + } + }, + plugins: { + tooltip: { + mode: 'index', + intersect: false + }, + legend: { + display: false, + }, + title: { + display: false, + } + } + } +}) end "> -
-
-
{% trans "Performance Over Time" %}
- +
+
+
{% trans "Performance Over Time" %}
+ +
+
+
+
+
+
+
+
+
{% trans "Entry Price vs Current Price" %}
+ +
+
+
+
+
+
+
+
+
{% trans "Investment Frequency" %}
+

+ {% trans "The straighter the blue line, the more consistent your DCA strategy is." %} +

+ +
+