feat: multiple improvements and new charts for DCA

This commit is contained in:
Herculino Trotta
2024-11-13 00:55:44 -03:00
parent a56e6a5b64
commit 0c67405f3e
4 changed files with 501 additions and 196 deletions
@@ -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'),
),
]
+69
View File
@@ -1,6 +1,9 @@
from datetime import timedelta
from decimal import Decimal from decimal import Decimal
from statistics import mean, stdev
from django.db import models from django.db import models
from django.template.defaultfilters import date
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ 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 (self.total_profit_loss() / total_invested) * 100
return Decimal("0") 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): class DCAEntry(models.Model):
strategy = models.ForeignKey( strategy = models.ForeignKey(
+3
View File
@@ -152,7 +152,10 @@ def strategy_detail(request, strategy_id):
"current_total_value": strategy.current_total_value(), "current_total_value": strategy.current_total_value(),
"total_profit_loss": strategy.total_profit_loss(), "total_profit_loss": strategy.total_profit_loss(),
"total_profit_loss_percentage": strategy.total_profit_loss_percentage(), "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) return render(request, "dca/fragments/strategy/details.html", context)
+406 -196
View File
@@ -1,180 +1,103 @@
{% load i18n %} {% load i18n %}
<div class="container px-md-3 py-3 column-gap-5"> <div class="container-fluid px-md-3 py-3 column-gap-5">
<div class="tw-text-3xl fw-bold font-monospace tw-w-full mb-3"> <div class="tw-text-3xl fw-bold font-monospace tw-w-full mb-3">
<div>{{ strategy.name }}</div> <div>{{ strategy.name }}</div>
</div> </div>
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 gy-3 gx-3"> <div class="row gy-3 gx-3">
<div class="col"> <div class="col-xl-6 col">
<div class="card">
<div class="card-body">
<h5 class="card-title">{% trans "Total Invested" %}</h5>
<div class="card-text">
<c-amount.display
:amount="strategy.total_invested"
:prefix="strategy.payment_currency.prefix"
:suffix="strategy.payment_currency.suffix"
:decimal_places="strategy.payment_currency.decimal_places"></c-amount.display>
</div>
</div>
</div>
</div>
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">{% trans "Total Received" %}</h5>
<div class="card-text">
<c-amount.display
:amount="strategy.total_received"
:prefix="strategy.target_currency.prefix"
:suffix="strategy.target_currency.suffix"
:decimal_places="strategy.target_currency.decimal_places"></c-amount.display>
</div>
</div>
</div>
</div>
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">{% trans "Average Entry Price" %}</h5>
<div class="card-text">
<c-amount.display
:amount="strategy.average_entry_price"
:prefix="strategy.payment_currency.prefix"
:suffix="strategy.payment_currency.suffix"
:decimal_places="strategy.payment_currency.decimal_places"></c-amount.display>
</div>
</div>
</div>
</div>
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">{% trans "Current Total Value" %}</h5>
<div class="card-text">
<c-amount.display
:amount="strategy.current_total_value"
:prefix="strategy.payment_currency.prefix"
:suffix="strategy.payment_currency.suffix"
:decimal_places="strategy.payment_currency.decimal_places"></c-amount.display>
</div>
</div>
</div>
</div>
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">{% trans "Total P/L" %}</h5>
<div class="card-text {% if strategy.total_profit_loss >= 0 %}text-success{% else %}text-danger{% endif %}">
<c-amount.display
:amount="strategy.total_profit_loss"
:prefix="strategy.payment_currency.prefix"
:suffix="strategy.payment_currency.suffix"
:decimal_places="strategy.payment_currency.decimal_places">
</c-amount.display>
</div>
</div>
</div>
</div>
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">{% trans "Total % P/L" %}</h5>
<div class="card-text {% if strategy.total_profit_loss >= 0 %}text-success{% else %}text-danger{% endif %}">
{{ strategy.total_profit_loss_percentage|floatformat:2 }}%
</div>
</div>
</div>
</div>
</div>
<!-- Entries Table -->
<div class="row mt-4">
<div class="col-12">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
{% spaceless %} {% spaceless %}
<div class="card-title tw-text-xl">{% trans "Entries" %}<span> <div class="card-title tw-text-xl">{% trans "Entries" %}<span>
<a class="text-decoration-none p-1 category-action" <a class="text-decoration-none p-1 category-action"
role="button" role="button"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-title="{% translate "Add" %}" data-bs-title="{% translate "Add" %}"
hx-get="{% url 'dca_entry_add' strategy_id=strategy.id%}" hx-get="{% url 'dca_entry_add' strategy_id=strategy.id %}"
hx-target="#generic-offcanvas"> hx-target="#generic-offcanvas">
<i class="fa-solid fa-circle-plus fa-fw"></i> <i class="fa-solid fa-circle-plus fa-fw"></i>
</a> </a>
</span> </span>
</div> </div>
{% endspaceless %} {% endspaceless %}
{% if entries %} {% if entries %}
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover text-nowrap"> <table class="table table-hover text-nowrap">
<thead> <thead>
<tr> <tr>
<th></th> <th></th>
<th>{% trans "Date" %}</th> <th>{% trans "Date" %}</th>
<th>{% trans "Amount Paid" %}</th> <th>{% trans "Amount Paid" %}</th>
<th>{% trans "Amount Received" %}</th> <th>{% trans "Amount Received" %}</th>
<th>{% trans "Current Value" %}</th> <th>{% trans "Current Value" %}</th>
<th>{% trans "P/L" %}</th> <th>{% trans "P/L" %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for entry in entries %} {% for entry in entries %}
<tr> <tr>
<td class="col-auto"> <td class="col-auto">
<div class="btn-group" role="group" aria-label="{% translate 'Actions' %}"> <div class="btn-group" role="group" aria-label="{% translate 'Actions' %}">
<a class="btn btn-secondary btn-sm" <a class="btn btn-secondary btn-sm"
role="button" role="button"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}" data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'dca_entry_edit' entry_id=entry.id strategy_id=entry.strategy.id %}" hx-get="{% url 'dca_entry_edit' entry_id=entry.id strategy_id=entry.strategy.id %}"
hx-target="#generic-offcanvas" hx-target="#generic-offcanvas"
hx-swap="innerHTML"> hx-swap="innerHTML">
<i class="fa-solid fa-pencil fa-fw"></i></a> <i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-secondary btn-sm text-danger" <a class="btn btn-secondary btn-sm text-danger"
role="button" role="button"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}" data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'dca_entry_delete' entry_id=entry.id strategy_id=entry.strategy.id %}" hx-delete="{% url 'dca_entry_delete' entry_id=entry.id strategy_id=entry.strategy.id %}"
hx-trigger='confirmed' hx-trigger='confirmed'
hx-swap="innerHTML" hx-swap="innerHTML"
data-bypass-on-ctrl="true" data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}" data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}" data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}" data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a> _="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
</div> </div>
</td> </td>
<td>{{ entry.date|date:"SHORT_DATE_FORMAT" }}</td> <td>{{ entry.date|date:"SHORT_DATE_FORMAT" }}</td>
<td><c-amount.display <td>
:amount="entry.amount_paid" <c-amount.display
:prefix="entry.strategy.payment_currency.prefix" :amount="entry.amount_paid"
:suffix="entry.strategy.payment_currency.suffix" :prefix="entry.strategy.payment_currency.prefix"
:decimal_places="entry.strategy.payment_currency.decimal_places"></c-amount.display></td> :suffix="entry.strategy.payment_currency.suffix"
<td><c-amount.display :decimal_places="entry.strategy.payment_currency.decimal_places"></c-amount.display>
:amount="entry.amount_received" </td>
:prefix="entry.strategy.target_currency.prefix" <td>
:suffix="entry.strategy.target_currency.suffix" <c-amount.display
:decimal_places="entry.strategy.target_currency.decimal_places"></c-amount.display></td> :amount="entry.amount_received"
<td><c-amount.display :prefix="entry.strategy.target_currency.prefix"
:amount="entry.current_value" :suffix="entry.strategy.target_currency.suffix"
:prefix="entry.strategy.payment_currency.prefix" :decimal_places="entry.strategy.target_currency.decimal_places"></c-amount.display>
:suffix="entry.strategy.payment_currency.suffix" </td>
:decimal_places="entry.strategy.payment_currency.decimal_places"></c-amount.display></td> <td>
<td> <c-amount.display
{% if entry.profit_loss_percentage > 0 %} :amount="entry.current_value"
<span class="badge text-bg-success"><i class="fa-solid fa-up-long me-2"></i>{{ entry.profit_loss_percentage|floatformat:"2g" }}%</span> :prefix="entry.strategy.payment_currency.prefix"
{% elif entry.profit_loss_percentage < 0 %} :suffix="entry.strategy.payment_currency.suffix"
<span class="badge text-bg-danger"><i class="fa-solid fa-down-long me-2"></i>{{ entry.profit_loss_percentage|floatformat:"2g" }}%</span> :decimal_places="entry.strategy.payment_currency.decimal_places"></c-amount.display>
{% endif %} </td>
</td> <td>
</tr> {% if entry.profit_loss_percentage > 0 %}
{% endfor %} <span class="badge text-bg-success"><i
</tbody> class="fa-solid fa-up-long me-2"></i>{{ entry.profit_loss_percentage|floatformat:"2g" }}%</span>
</table> {% elif entry.profit_loss_percentage < 0 %}
</div> <span class="badge text-bg-danger"><i
class="fa-solid fa-down-long me-2"></i>{{ entry.profit_loss_percentage|floatformat:"2g" }}%</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %} {% else %}
<c-msg.empty <c-msg.empty
title="{% translate 'No entries for this DCA' %}" title="{% translate 'No entries for this DCA' %}"
@@ -183,49 +106,336 @@
</div> </div>
</div> </div>
</div> </div>
</div> <div class="col-xl-6 col">
<div class="row row-cols-1 row-cols-md-2 row-cols-xl-3 gy-3 gx-3">
<div class="row mt-4"> <div class="col">
<div class="col-12" <div class="card h-100">
_="on htmx:afterSettle from #strategy-details <div class="card-body">
<h5 class="card-title">{% trans "Total Invested" %}</h5>
<div class="card-text">
<c-amount.display
:amount="strategy.total_invested"
:prefix="strategy.payment_currency.prefix"
:suffix="strategy.payment_currency.suffix"
:decimal_places="strategy.payment_currency.decimal_places"></c-amount.display>
</div>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">{% trans "Total Received" %}</h5>
<div class="card-text">
<c-amount.display
:amount="strategy.total_received"
:prefix="strategy.target_currency.prefix"
:suffix="strategy.target_currency.suffix"
:decimal_places="strategy.target_currency.decimal_places"></c-amount.display>
</div>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">{% trans "Current Total Value" %}</h5>
<div class="card-text">
<c-amount.display
:amount="strategy.current_total_value"
:prefix="strategy.payment_currency.prefix"
:suffix="strategy.payment_currency.suffix"
:decimal_places="strategy.payment_currency.decimal_places"></c-amount.display>
</div>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">{% trans "Average Entry Price" %}</h5>
<div class="card-text">
<c-amount.display
:amount="strategy.average_entry_price"
:prefix="strategy.payment_currency.prefix"
:suffix="strategy.payment_currency.suffix"
:decimal_places="strategy.payment_currency.decimal_places"></c-amount.display>
</div>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">{% trans "Total P/L" %}</h5>
<div
class="card-text {% if strategy.total_profit_loss >= 0 %}text-success{% else %}text-danger{% endif %}">
<c-amount.display
:amount="strategy.total_profit_loss"
:prefix="strategy.payment_currency.prefix"
:suffix="strategy.payment_currency.suffix"
:decimal_places="strategy.payment_currency.decimal_places">
</c-amount.display>
</div>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">{% trans "Total % P/L" %}</h5>
<div
class="card-text {% if strategy.total_profit_loss >= 0 %}text-success{% else %}text-danger{% endif %}">
{{ strategy.total_profit_loss_percentage|floatformat:2 }}%
</div>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-12"
_="on htmx:afterSettle from #strategy-details
set perfomancectx to #performanceChart.getContext('2d') set perfomancectx to #performanceChart.getContext('2d')
js(perfomancectx) js(perfomancectx)
new Chart(perfomancectx, { new Chart(perfomancectx, {
type: 'line', type: 'line',
data: { data: {
labels: [{% for entry in entries_data %}'{{ entry.entry.date|date:"Y-m-d" }}'{% if not forloop.last %}, {% endif %}{% endfor %}], labels: [{% for entry in entries_data %}'{{ entry.entry.date|date:"SHORT_DATE_FORMAT" }}'{% if not forloop.last %}, {% endif %}{% endfor %}],
datasets: [{ datasets: [{
label: '{% trans "P/L %" %}', label: '{% trans "P/L %" %}',
data: [{% for entry in entries_data %}{{ entry.profit_loss_percentage|floatformat:"-40u" }}{% if not forloop.last %}, {% endif %}{% endfor %}], data: [{% for entry in entries_data %}{{ entry.profit_loss_percentage|floatformat:"-40u" }}{% if not forloop.last %}, {% endif %}{% endfor %}],
stepped: true, stepped: true,
segment: { segment: {
borderColor: ctx => { borderColor: ctx => {
return ctx.p0.parsed.y >= 0 && ctx.p1.parsed.y >= 0 ? 'rgb(75, 192, 75)' : const gradient = ctx.chart.ctx.createLinearGradient(ctx.p0.x, 0, ctx.p1.x, 0);
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)'; if (ctx.p0.parsed.y >= 0 && ctx.p1.parsed.y >= 0) {
} // Both positive - solid green
}, gradient.addColorStop(0, 'rgb(75, 192, 75)');
borderWidth: 2 gradient.addColorStop(1, 'rgb(75, 192, 75)');
}] } else if (ctx.p0.parsed.y < 0 && ctx.p1.parsed.y < 0) {
}, // Both negative - solid red
options: { gradient.addColorStop(0, 'rgb(255, 99, 132)');
responsive: true, gradient.addColorStop(1, 'rgb(255, 99, 132)');
scales: { } else if (ctx.p0.parsed.y >= 0 && ctx.p1.parsed.y < 0) {
y: { // Positive to negative - green to red
beginAtZero: false gradient.addColorStop(0, 'rgb(75, 192, 75)');
} gradient.addColorStop(1, 'rgb(255, 99, 132)');
}, } else {
interaction: { // Negative to positive - red to green
intersect: false, 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 end
"> ">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">{% trans "Performance Over Time" %}</h5> <h5 class="card-title">{% trans "Performance Over Time" %}</h5>
<canvas id="performanceChart"></canvas> <canvas id="performanceChart"></canvas>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-12"
_="on htmx:afterSettle from #strategy-details
set pricectx to #priceChart.getContext('2d')
set priceData to {{ price_comparison_data|safe }}
js(pricectx, priceData)
new Chart(pricectx, {
type: 'bar',
data: {
labels: priceData.labels,
datasets: [
{
label: '{% trans "Entry Price" %}',
data: priceData.entry_prices,
backgroundColor: 'rgba(54, 162, 235, 0.5)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 1,
yAxisID: 'y'
},
{
label: '{% trans "Current Price" %}',
data: priceData.current_prices,
backgroundColor: 'rgba(75, 192, 192, 0.5)',
borderColor: 'rgba(75, 192, 192, 1)',
borderWidth: 1,
yAxisID: 'y'
},
{
label: '{% trans "Amount Bought" %}',
data: priceData.amounts_bought,
type: 'line',
borderColor: 'rgba(255, 99, 132, 1)',
borderWidth: 2,
fill: false,
yAxisID: 'y1',
tension: 0.1
}
]
},
options: {
responsive: true,
interaction: {
mode: 'index',
intersect: false,
},
scales: {
x: {
grid: {
display: false
},
title: {
display: false,
}
},
y: {
type: 'linear',
display: true,
position: 'left',
beginAtZero: true,
title: {
display: false,
},
ticks: {
format: { maximumFractionDigits: 40, minimumFractionDigits: 1 }
}
},
y1: {
type: 'linear',
display: true,
position: 'right',
beginAtZero: true,
grid: {
drawOnChartArea: false
},
title: {
display: false,
},
ticks: {
format: { maximumFractionDigits: 40, minimumFractionDigits: 1 }
}
}
},
plugins: {
legend: {
position: 'top'
},
title: {
display: false,
}
}
}
})
end
">
<div class="card">
<div class="card-body">
<h5 class="card-title">{% trans "Entry Price vs Current Price" %}</h5>
<canvas id="priceChart"></canvas>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-12"
_="on htmx:afterSettle from #strategy-details
set frequencyctx to #frequencyChart.getContext('2d')
js(frequencyctx)
new Chart(frequencyctx, {
type: 'line',
data: {
labels: {{ investment_frequency.labels|safe }},
datasets: [{
label: '{% trans "Days Between Investments" %}',
data: {{ investment_frequency.intervals_line|safe }},
borderColor: 'rgba(54, 162, 235, 1)',
backgroundColor: 'rgba(54, 162, 235, 0.1)',
fill: false,
tension: 0
}]
},
options: {
responsive: true,
scales: {
x: {
grid: {
display: true
},
title: {
display: false,
},
ticks: {
display: false
}
},
y: {
beginAtZero: false,
title: {
display: false
}
}
},
plugins: {
tooltip: {
mode: 'index',
intersect: false
},
legend: {
display: false,
},
title: {
display: false,
}
}
}
})
end
">
<div class="card">
<div class="card-body">
<h5 class="card-title">{% trans "Investment Frequency" %}</h5>
<p class="card-text tw-text-gray-400">
{% trans "The straighter the blue line, the more consistent your DCA strategy is." %}
</p>
<canvas id="frequencyChart"></canvas>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>