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)
+315 -105
View File
@@ -1,96 +1,11 @@
{% 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 %}
@@ -99,7 +14,7 @@
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>
@@ -148,26 +63,34 @@
</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>
<c-amount.display
:amount="entry.amount_paid" :amount="entry.amount_paid"
:prefix="entry.strategy.payment_currency.prefix" :prefix="entry.strategy.payment_currency.prefix"
:suffix="entry.strategy.payment_currency.suffix" :suffix="entry.strategy.payment_currency.suffix"
:decimal_places="entry.strategy.payment_currency.decimal_places"></c-amount.display></td> :decimal_places="entry.strategy.payment_currency.decimal_places"></c-amount.display>
<td><c-amount.display </td>
<td>
<c-amount.display
:amount="entry.amount_received" :amount="entry.amount_received"
:prefix="entry.strategy.target_currency.prefix" :prefix="entry.strategy.target_currency.prefix"
:suffix="entry.strategy.target_currency.suffix" :suffix="entry.strategy.target_currency.suffix"
:decimal_places="entry.strategy.target_currency.decimal_places"></c-amount.display></td> :decimal_places="entry.strategy.target_currency.decimal_places"></c-amount.display>
<td><c-amount.display </td>
<td>
<c-amount.display
:amount="entry.current_value" :amount="entry.current_value"
:prefix="entry.strategy.payment_currency.prefix" :prefix="entry.strategy.payment_currency.prefix"
:suffix="entry.strategy.payment_currency.suffix" :suffix="entry.strategy.payment_currency.suffix"
:decimal_places="entry.strategy.payment_currency.decimal_places"></c-amount.display></td> :decimal_places="entry.strategy.payment_currency.decimal_places"></c-amount.display>
</td>
<td> <td>
{% if entry.profit_loss_percentage > 0 %} {% if entry.profit_loss_percentage > 0 %}
<span class="badge text-bg-success"><i class="fa-solid fa-up-long me-2"></i>{{ entry.profit_loss_percentage|floatformat:"2g" }}%</span> <span class="badge text-bg-success"><i
class="fa-solid fa-up-long me-2"></i>{{ entry.profit_loss_percentage|floatformat:"2g" }}%</span>
{% elif entry.profit_loss_percentage < 0 %} {% elif entry.profit_loss_percentage < 0 %}
<span class="badge text-bg-danger"><i class="fa-solid fa-down-long me-2"></i>{{ entry.profit_loss_percentage|floatformat:"2g" }}%</span> <span class="badge text-bg-danger"><i
class="fa-solid fa-down-long me-2"></i>{{ entry.profit_loss_percentage|floatformat:"2g" }}%</span>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@@ -183,8 +106,92 @@
</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="col">
<div class="card h-100">
<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>
<div class="row mt-4"> <div class="row mt-4">
<div class="col-12" <div class="col-12"
_="on htmx:afterSettle from #strategy-details _="on htmx:afterSettle from #strategy-details
@@ -193,18 +200,37 @@
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)');
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 borderWidth: 2
}] }]
}, },
@@ -213,13 +239,27 @@
scales: { scales: {
y: { y: {
beginAtZero: false beginAtZero: false
},
x: {
ticks: {
display: false
}
} }
}, },
interaction: { plugins: {
intersect: false, tooltip: {
mode: 'index',
intersect: false
}, },
legend: {
display: false,
},
title: {
display: false,
} }
}) }
}
})
end end
"> ">
<div class="card"> <div class="card">
@@ -230,4 +270,174 @@
</div> </div>
</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>