From 526868630f5a0dabffc480c339927533ce95f1dd Mon Sep 17 00:00:00 2001 From: OpenClaw Sub-agent Date: Sun, 14 Jun 2026 08:47:50 +1000 Subject: [PATCH] feat: add invest chart value mode --- invest/services.py | 176 +++++++++++++++++-------- invest/template_views.py | 8 ++ invest/templates/invest/dashboard.html | 107 +++++++++++++-- tests/test_invest_api.py | 57 ++++++++ 4 files changed, 283 insertions(+), 65 deletions(-) diff --git a/invest/services.py b/invest/services.py index 4229ce2..8addba2 100644 --- a/invest/services.py +++ b/invest/services.py @@ -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, + }, + }, + }) # --------------------------------------------------------------------------- diff --git a/invest/template_views.py b/invest/template_views.py index 376149b..0a79c6f 100644 --- a/invest/template_views.py +++ b/invest/template_views.py @@ -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, }) diff --git a/invest/templates/invest/dashboard.html b/invest/templates/invest/dashboard.html index b74e90e..48e0c55 100644 --- a/invest/templates/invest/dashboard.html +++ b/invest/templates/invest/dashboard.html @@ -106,9 +106,18 @@ {% if chart_data_json != 'null' %}
-

{{ fy_label }} Performance vs Benchmarks

+
+
+

{{ fy_label }} Performance vs Benchmarks

+

Toggle between percentage return and dollar-value growth from the baseline date.

+
+
+ + +
+
-

Snapshot value chart; cash-flow-adjusted metrics are shown in the cards above.

+

Snapshot value chart; cash-flow-adjusted metrics are shown in the cards above.

{% endif %} @@ -193,6 +202,34 @@ {% endif %} + +{% if investment_posts %} +
+
+
+

Investment Reports

+

AI-generated weekly reports linked by the invest tag.

+
+ {% if invest_tag %} + View all posts tagged invest + {% endif %} +
+ +
+{% endif %} + {% if recent_transactions %}
@@ -235,25 +272,75 @@ {% endif %} diff --git a/tests/test_invest_api.py b/tests/test_invest_api.py index 1858bf5..dec34ae 100644 --- a/tests/test_invest_api.py +++ b/tests/test_invest_api.py @@ -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