Merge pull request #93 from wahyd4/feat/invest-chart-value-mode

feat: add invest chart value mode
This commit is contained in:
2026-06-14 08:50:26 +10:00
committed by GitHub
4 changed files with 283 additions and 65 deletions
+121 -55
View File
@@ -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,
},
},
})
# ---------------------------------------------------------------------------
+8
View File
@@ -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,
})
+97 -10
View File
@@ -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 %}
+57
View File
@@ -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