mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
Merge pull request #93 from wahyd4/feat/invest-chart-value-mode
feat: add invest chart value mode
This commit is contained in:
+121
-55
@@ -580,15 +580,44 @@ def get_agent_summary() -> dict:
|
||||
|
||||
|
||||
def get_performance_chart_data() -> Optional[str]:
|
||||
latest_snapshot = PortfolioSnapshot.objects.order_by('-id').values_list('id', flat=True).first()
|
||||
snapshot_count = PortfolioSnapshot.objects.count()
|
||||
cache_key = f'performance:{latest_snapshot}:{snapshot_count}'
|
||||
now = datetime.now()
|
||||
cached = _chart_cache.get('performance')
|
||||
cached = _chart_cache.get(cache_key)
|
||||
if cached:
|
||||
data, cached_at = cached
|
||||
if (now - cached_at).total_seconds() < _CHART_CACHE_TTL:
|
||||
return data
|
||||
|
||||
result = _build_performance_chart_data()
|
||||
_chart_cache['performance'] = (result, now)
|
||||
_chart_cache.clear()
|
||||
_chart_cache[cache_key] = (result, now)
|
||||
return result
|
||||
|
||||
|
||||
def _line_dataset(label: str, data: list, color: str, dashed: bool = False, width: float = 2) -> dict:
|
||||
return {
|
||||
'label': label,
|
||||
'data': data,
|
||||
'borderColor': color,
|
||||
'backgroundColor': color,
|
||||
'borderWidth': width,
|
||||
'pointRadius': 4 if not dashed else 3,
|
||||
'pointHoverRadius': 7 if not dashed else 5,
|
||||
'tension': 0.3,
|
||||
'borderDash': [5, 5] if dashed else [],
|
||||
'fill': False,
|
||||
}
|
||||
|
||||
|
||||
def _unique_dates(values: Iterable[date_cls]) -> list[date_cls]:
|
||||
result = []
|
||||
seen = set()
|
||||
for value in values:
|
||||
if value and value not in seen:
|
||||
result.append(value)
|
||||
seen.add(value)
|
||||
return result
|
||||
|
||||
|
||||
@@ -598,6 +627,7 @@ def _build_performance_chart_data() -> Optional[str]:
|
||||
return None
|
||||
|
||||
portfolio_weekly: dict[int, dict[tuple[int, int], tuple[date_cls, float]]] = {}
|
||||
week_label_date = {}
|
||||
for snap in all_snaps:
|
||||
day = _as_date(snap.captured_at)
|
||||
key = day.isocalendar()[:2]
|
||||
@@ -605,73 +635,109 @@ def _build_performance_chart_data() -> Optional[str]:
|
||||
existing = portfolio_weekly[snap.portfolio_id].get(key)
|
||||
if existing is None or day > existing[0]:
|
||||
portfolio_weekly[snap.portfolio_id][key] = (day, float(snap.total_value))
|
||||
if key not in week_label_date or day > week_label_date[key]:
|
||||
week_label_date[key] = day
|
||||
|
||||
all_week_keys = sorted({wk for weekly in portfolio_weekly.values() for wk in weekly})
|
||||
if not all_week_keys:
|
||||
if not week_label_date:
|
||||
return None
|
||||
|
||||
week_label_date = {}
|
||||
for weekly in portfolio_weekly.values():
|
||||
for week, (day, _) in weekly.items():
|
||||
if week not in week_label_date or day > week_label_date[week]:
|
||||
week_label_date[week] = day
|
||||
earliest_snapshot_date = min(week_label_date.values())
|
||||
latest_date = max(week_label_date.values())
|
||||
requested_baseline = date_cls(latest_date.year, 1, 1)
|
||||
baseline_total = get_total_value_asof(requested_baseline, live_if_today=False)
|
||||
if baseline_total is None or baseline_total <= 0:
|
||||
requested_baseline = earliest_snapshot_date
|
||||
baseline_total = get_total_value_asof(requested_baseline, live_if_today=False)
|
||||
if baseline_total is None or baseline_total <= 0:
|
||||
return None
|
||||
|
||||
earliest_date = week_label_date[all_week_keys[0]]
|
||||
latest_date = week_label_date[all_week_keys[-1]]
|
||||
refresh_needed = not BenchmarkPrice.objects.filter(ticker='QQQ', date__gte=earliest_date).exists()
|
||||
chart_dates = _unique_dates(
|
||||
[requested_baseline]
|
||||
+ [day for _, day in sorted(week_label_date.items()) if day > requested_baseline]
|
||||
)
|
||||
if not chart_dates:
|
||||
return None
|
||||
|
||||
refresh_needed = not BenchmarkPrice.objects.filter(ticker='QQQ', date__gte=requested_baseline).exists()
|
||||
if refresh_needed:
|
||||
refresh_benchmark_prices()
|
||||
|
||||
colors = ['#2563EB', '#7C3AED', '#0D9488', '#DB2777', '#EA580C']
|
||||
datasets = []
|
||||
percentage_datasets = []
|
||||
|
||||
total_values = []
|
||||
total_percent = []
|
||||
for day in chart_dates:
|
||||
value = get_total_value_asof(day, live_if_today=(day == timezone.now().date()))
|
||||
rounded_value = round(value, 2) if value is not None else None
|
||||
total_values.append(rounded_value)
|
||||
total_percent.append(round((value - baseline_total) / baseline_total * 100, 2) if value is not None else None)
|
||||
|
||||
percentage_datasets.append(_line_dataset('All Portfolios', total_percent, '#111827', width=3))
|
||||
|
||||
for idx, portfolio in enumerate(Portfolio.objects.all()):
|
||||
weekly = portfolio_weekly.get(portfolio.id, {})
|
||||
if not weekly:
|
||||
continue
|
||||
first_week = min(weekly.keys())
|
||||
base_val = weekly[first_week][1]
|
||||
base_val = _snapshot_asof(portfolio, requested_baseline)
|
||||
if not base_val or base_val <= 0:
|
||||
weekly = portfolio_weekly.get(portfolio.id, {})
|
||||
if not weekly:
|
||||
continue
|
||||
first_day, base_val = min(weekly.values(), key=lambda item: item[0])
|
||||
if not base_val:
|
||||
continue
|
||||
datasets.append({
|
||||
'label': portfolio.name,
|
||||
'data': [round((weekly[w][1] - base_val) / base_val * 100, 2) if w in weekly else None for w in all_week_keys],
|
||||
'borderColor': colors[idx % len(colors)],
|
||||
'backgroundColor': colors[idx % len(colors)],
|
||||
'borderWidth': 2,
|
||||
'pointRadius': 5,
|
||||
'pointHoverRadius': 7,
|
||||
'tension': 0.3,
|
||||
'borderDash': [],
|
||||
'fill': False,
|
||||
})
|
||||
|
||||
def benchmark_series(ticker: str, label: str, color: str) -> Optional[dict]:
|
||||
base_price = _get_historical_price(ticker, earliest_date)
|
||||
if not base_price:
|
||||
return None
|
||||
data = []
|
||||
for week in all_week_keys:
|
||||
price = _get_historical_price(ticker, week_label_date[week])
|
||||
data.append(round((price - base_price) / base_price * 100, 2) if price else None)
|
||||
return {
|
||||
'label': label,
|
||||
'data': data,
|
||||
'borderColor': color,
|
||||
'backgroundColor': color,
|
||||
'borderWidth': 1.5,
|
||||
'pointRadius': 3,
|
||||
'pointHoverRadius': 5,
|
||||
'tension': 0.3,
|
||||
'borderDash': [5, 5],
|
||||
'fill': False,
|
||||
}
|
||||
for day in chart_dates:
|
||||
value = _snapshot_asof(portfolio, day)
|
||||
data.append(round((value - base_val) / base_val * 100, 2) if value is not None else None)
|
||||
percentage_datasets.append(_line_dataset(portfolio.name, data, colors[idx % len(colors)], width=1.75))
|
||||
|
||||
for item in (benchmark_series('SPY', 'S&P 500', '#D97706'), benchmark_series('QQQ', 'QQQ', '#16A34A')):
|
||||
if item:
|
||||
datasets.append(item)
|
||||
value_datasets = [_line_dataset('All Portfolios', total_values, '#111827', width=3)]
|
||||
|
||||
labels = [week_label_date[w].strftime('%b %-d') for w in all_week_keys]
|
||||
return json.dumps({'labels': labels, 'datasets': datasets})
|
||||
def benchmark_datasets(ticker: str, percent_label: str, value_label: str, color: str) -> tuple[Optional[dict], Optional[dict]]:
|
||||
base_price = _get_historical_price(ticker, requested_baseline)
|
||||
if not base_price:
|
||||
return None, None
|
||||
percent_data = []
|
||||
value_data = []
|
||||
for day in chart_dates:
|
||||
price = _get_historical_price(ticker, day)
|
||||
if not price:
|
||||
percent_data.append(None)
|
||||
value_data.append(None)
|
||||
continue
|
||||
growth_ratio = Decimal(str(price)) / Decimal(str(base_price))
|
||||
percent_data.append(round((price - base_price) / base_price * 100, 2))
|
||||
value_data.append(round(float(Decimal(str(baseline_total)) * growth_ratio), 2))
|
||||
return (
|
||||
_line_dataset(percent_label, percent_data, color, dashed=True, width=1.5),
|
||||
_line_dataset(value_label, value_data, color, dashed=True, width=1.5),
|
||||
)
|
||||
|
||||
for percent_ds, value_ds in (
|
||||
benchmark_datasets('SPY', 'S&P 500', 'S&P 500 benchmark', '#D97706'),
|
||||
benchmark_datasets('QQQ', 'QQQ', 'QQQ benchmark', '#16A34A'),
|
||||
):
|
||||
if percent_ds:
|
||||
percentage_datasets.append(percent_ds)
|
||||
if value_ds:
|
||||
value_datasets.append(value_ds)
|
||||
|
||||
labels = [day.strftime('%b %-d') for day in chart_dates]
|
||||
return json.dumps({
|
||||
'labels': labels,
|
||||
'baseline_date': requested_baseline.isoformat(),
|
||||
'modes': {
|
||||
'percentage': {
|
||||
'unit': 'percent',
|
||||
'description': 'Growth/decline since baseline',
|
||||
'datasets': percentage_datasets,
|
||||
},
|
||||
'value': {
|
||||
'unit': 'currency',
|
||||
'description': 'Portfolio value and same-baseline benchmark value',
|
||||
'datasets': value_datasets,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -4,6 +4,8 @@ import logging
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.utils import timezone
|
||||
|
||||
from links.models import Post, Tag
|
||||
|
||||
from .models import Portfolio, Transaction
|
||||
from .services import (
|
||||
get_all_holdings,
|
||||
@@ -39,6 +41,10 @@ def dashboard(request):
|
||||
performance = get_cashflow_adjusted_performance()
|
||||
risk = get_risk_summary()
|
||||
recent_transactions = Transaction.objects.select_related('portfolio').order_by('-date', '-created_at')[:30]
|
||||
invest_tag = Tag.objects.filter(slug__iexact='invest').first() or Tag.objects.filter(name__iexact='invest').first()
|
||||
investment_posts = Post.objects.none()
|
||||
if invest_tag:
|
||||
investment_posts = invest_tag.posts.all().order_by('-created_at')[:12]
|
||||
|
||||
return render(request, 'invest/dashboard.html', {
|
||||
'overview': overview,
|
||||
@@ -49,6 +55,8 @@ def dashboard(request):
|
||||
'net_contributions': get_net_external_cash_flow(),
|
||||
'risk': risk,
|
||||
'recent_transactions': recent_transactions,
|
||||
'invest_tag': invest_tag,
|
||||
'investment_posts': investment_posts,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -106,9 +106,18 @@
|
||||
<!-- ── Performance chart ─────────────────────────────────────── -->
|
||||
{% if chart_data_json != 'null' %}
|
||||
<div class="bg-white rounded-lg shadow-sm p-5 mb-3">
|
||||
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase mb-4">{{ fy_label }} Performance vs Benchmarks</p>
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between mb-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase">{{ fy_label }} Performance vs Benchmarks</p>
|
||||
<p class="text-xs text-stone-400 mt-1">Toggle between percentage return and dollar-value growth from the baseline date.</p>
|
||||
</div>
|
||||
<div class="inline-flex rounded-lg border border-stone-200 bg-stone-50 p-1 text-xs font-semibold" role="group" aria-label="Chart mode">
|
||||
<button type="button" id="chartModePercent" class="chart-mode-btn rounded-md px-3 py-1.5 bg-white text-stone-900 shadow-sm" data-mode="percentage">% Growth</button>
|
||||
<button type="button" id="chartModeValue" class="chart-mode-btn rounded-md px-3 py-1.5 text-stone-500" data-mode="value">$ Value</button>
|
||||
</div>
|
||||
</div>
|
||||
<canvas id="performanceChart" height="90"></canvas>
|
||||
<p class="text-xs text-stone-400 mt-3">Snapshot value chart; cash-flow-adjusted metrics are shown in the cards above.</p>
|
||||
<p id="performanceChartHelp" class="text-xs text-stone-400 mt-3">Snapshot value chart; cash-flow-adjusted metrics are shown in the cards above.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -193,6 +202,34 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- ── Investment reports ────────────────────────────────────── -->
|
||||
{% if investment_posts %}
|
||||
<div class="bg-white rounded-lg shadow-sm p-5 mt-4">
|
||||
<div class="flex items-center justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase">Investment Reports</p>
|
||||
<p class="text-xs text-stone-400 mt-1">AI-generated weekly reports linked by the <span class="font-semibold">invest</span> tag.</p>
|
||||
</div>
|
||||
{% if invest_tag %}
|
||||
<a href="{{ invest_tag.get_absolute_url }}" class="text-xs font-semibold text-blue-700 hover:text-blue-900">View all posts tagged invest</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="divide-y divide-stone-100">
|
||||
{% for post in investment_posts %}
|
||||
<a href="{{ post.get_absolute_url }}" class="block py-3 hover:bg-stone-50 rounded-md px-2 -mx-2">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-stone-900">{{ post.title }}</p>
|
||||
{% if post.summary %}<p class="text-xs text-stone-500 mt-1 line-clamp-2">{{ post.summary }}</p>{% endif %}
|
||||
</div>
|
||||
<p class="shrink-0 text-xs text-stone-400">{{ post.created_at|date:"M j" }}</p>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- ── Transactions ──────────────────────────────────────────── -->
|
||||
{% if recent_transactions %}
|
||||
<div class="bg-white rounded-lg shadow-sm p-5 mt-4">
|
||||
@@ -235,25 +272,75 @@
|
||||
<script>
|
||||
(function () {
|
||||
const raw = {{ chart_data_json|safe }};
|
||||
if (!raw) return;
|
||||
if (!raw || !raw.modes) return;
|
||||
const ctx = document.getElementById('performanceChart');
|
||||
if (!ctx) return;
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: raw,
|
||||
options: {
|
||||
|
||||
const help = document.getElementById('performanceChartHelp');
|
||||
const buttons = Array.from(document.querySelectorAll('.chart-mode-btn'));
|
||||
let currentMode = 'percentage';
|
||||
|
||||
function formatCurrency(value) {
|
||||
return '$' + value.toLocaleString(undefined, { maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
const sign = value >= 0 ? '+' : '';
|
||||
return sign + value.toFixed(2) + '%';
|
||||
}
|
||||
|
||||
function chartDataFor(mode) {
|
||||
const modeData = raw.modes[mode] || raw.modes.percentage;
|
||||
return {
|
||||
labels: raw.labels,
|
||||
datasets: modeData.datasets,
|
||||
};
|
||||
}
|
||||
|
||||
function chartOptionsFor(mode) {
|
||||
const unit = (raw.modes[mode] || raw.modes.percentage).unit;
|
||||
return {
|
||||
responsive: true,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { position: 'top', align: 'start', labels: { usePointStyle: true, pointStyle: 'rect', pointStyleWidth: 14, padding: 20, font: { size: 12, weight: '600' } } },
|
||||
tooltip: { callbacks: { label: function (ctx) { const v = ctx.parsed.y; if (v === null || v === undefined) return ctx.dataset.label + ': —'; const sign = v >= 0 ? '+' : ''; return ctx.dataset.label + ': ' + sign + v.toFixed(2) + '%'; } } },
|
||||
tooltip: { callbacks: { label: function (ctx) { const v = ctx.parsed.y; if (v === null || v === undefined) return ctx.dataset.label + ': —'; return ctx.dataset.label + ': ' + (unit === 'currency' ? formatCurrency(v) : formatPercent(v)); } } },
|
||||
},
|
||||
scales: {
|
||||
y: { ticks: { callback: function (v) { return (v >= 0 ? '+' : '') + v.toFixed(1) + '%'; }, font: { size: 11 } }, grid: { color: '#f5f5f4' } },
|
||||
y: { ticks: { callback: function (v) { return unit === 'currency' ? formatCurrency(Number(v)) : formatPercent(Number(v)); }, font: { size: 11 } }, grid: { color: '#f5f5f4' } },
|
||||
x: { grid: { display: false }, ticks: { font: { size: 11 } } },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: chartDataFor(currentMode),
|
||||
options: chartOptionsFor(currentMode),
|
||||
});
|
||||
|
||||
function setMode(mode) {
|
||||
currentMode = mode;
|
||||
chart.data = chartDataFor(mode);
|
||||
chart.options = chartOptionsFor(mode);
|
||||
chart.update();
|
||||
buttons.forEach((button) => {
|
||||
const active = button.dataset.mode === mode;
|
||||
button.classList.toggle('bg-white', active);
|
||||
button.classList.toggle('text-stone-900', active);
|
||||
button.classList.toggle('shadow-sm', active);
|
||||
button.classList.toggle('text-stone-500', !active);
|
||||
});
|
||||
if (help) {
|
||||
const baseline = raw.baseline_date || 'the baseline date';
|
||||
help.textContent = mode === 'value'
|
||||
? `Dollar mode: actual total portfolio value vs SPY/QQQ benchmark value from ${baseline}.`
|
||||
: `Percentage mode: growth/decline since ${baseline}; cash-flow-adjusted metrics are shown in the cards above.`;
|
||||
}
|
||||
}
|
||||
|
||||
buttons.forEach((button) => button.addEventListener('click', () => setMode(button.dataset.mode)));
|
||||
setMode(currentMode);
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from django.utils import timezone
|
||||
|
||||
from invest.models import BenchmarkPrice, CashFlow, Portfolio, PortfolioSnapshot, Stock, Transaction
|
||||
from invest.services import get_performance_chart_data
|
||||
from links.models import Post, Tag
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -167,3 +170,57 @@ def test_dashboard_shows_agent_first_metrics(client, monkeypatch):
|
||||
assert "Investment Gain" in content
|
||||
assert "Top 5 Concentration" in content
|
||||
assert "Same-cashflow Benchmark" in content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_performance_chart_data_has_percentage_and_value_modes():
|
||||
portfolio = Portfolio.objects.create(name="Agent Test")
|
||||
PortfolioSnapshot.objects.create(
|
||||
portfolio=portfolio,
|
||||
captured_at=timezone.make_aware(timezone.datetime(2026, 1, 1, 8, 0)),
|
||||
total_value=Decimal("1000.00"),
|
||||
)
|
||||
PortfolioSnapshot.objects.create(
|
||||
portfolio=portfolio,
|
||||
captured_at=timezone.make_aware(timezone.datetime(2026, 1, 8, 8, 0)),
|
||||
total_value=Decimal("1100.00"),
|
||||
)
|
||||
BenchmarkPrice.objects.create(ticker="SPY", date=date(2026, 1, 1), close=Decimal("100.00"))
|
||||
BenchmarkPrice.objects.create(ticker="SPY", date=date(2026, 1, 8), close=Decimal("110.00"))
|
||||
BenchmarkPrice.objects.create(ticker="QQQ", date=date(2026, 1, 1), close=Decimal("200.00"))
|
||||
BenchmarkPrice.objects.create(ticker="QQQ", date=date(2026, 1, 8), close=Decimal("220.00"))
|
||||
|
||||
payload = json.loads(get_performance_chart_data())
|
||||
|
||||
assert payload["baseline_date"] == "2026-01-01"
|
||||
assert set(payload["modes"]) == {"percentage", "value"}
|
||||
assert payload["modes"]["percentage"]["unit"] == "percent"
|
||||
assert payload["modes"]["value"]["unit"] == "currency"
|
||||
value_datasets = {dataset["label"]: dataset["data"] for dataset in payload["modes"]["value"]["datasets"]}
|
||||
assert value_datasets["All Portfolios"] == [1000.0, 1100.0]
|
||||
assert value_datasets["S&P 500 benchmark"] == [1000.0, 1100.0]
|
||||
assert value_datasets["QQQ benchmark"] == [1000.0, 1100.0]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_dashboard_links_posts_tagged_invest(client):
|
||||
invest_tag = Tag.objects.create(name="invest", slug="invest")
|
||||
other_tag = Tag.objects.create(name="life", slug="life")
|
||||
invest_post = Post.objects.create(
|
||||
title="Weekly investment report",
|
||||
summary="AI generated market and portfolio notes",
|
||||
content="details",
|
||||
)
|
||||
invest_post.tags.add(invest_tag)
|
||||
other_post = Post.objects.create(title="Cooking note", summary="not shown", content="details")
|
||||
other_post.tags.add(other_tag)
|
||||
|
||||
response = client.get("/invest/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
assert "Investment Reports" in content
|
||||
assert "Weekly investment report" in content
|
||||
assert invest_post.get_absolute_url() in content
|
||||
assert "Cooking note" not in content
|
||||
assert "View all posts tagged invest" in content
|
||||
|
||||
Reference in New Issue
Block a user