feat: add cashflow-adjusted invest dashboard

This commit is contained in:
OpenClaw Sub-agent
2026-06-14 08:19:44 +10:00
parent de893d47cf
commit f7a3d228c1
12 changed files with 1274 additions and 548 deletions
+2 -2
View File
@@ -59,8 +59,8 @@ ALLOWED_HOSTS = ['*']
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
# 'NAME': '/app/data/db.sqlite3', # Updated path
'NAME': BASE_DIR / 'data/db.sqlite3',
# Use SQLITE_DATABASE_PATH for local verification against a copied DB.
'NAME': os.environ.get('SQLITE_DATABASE_PATH', BASE_DIR / 'data/db.sqlite3'),
}
}
+19 -2
View File
@@ -1,14 +1,31 @@
from django.urls import path, include
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from .views import PortfolioViewSet, StockViewSet, TransactionViewSet, AIUpdateView
from .views import (
AIUpdateView,
AgentSummaryView,
BenchmarkPriceViewSet,
CashFlowViewSet,
PerformanceView,
PortfolioSnapshotViewSet,
PortfolioViewSet,
RiskView,
StockViewSet,
TransactionViewSet,
)
router = DefaultRouter()
router.register(r'portfolios', PortfolioViewSet, basename='invest-portfolio')
router.register(r'stocks', StockViewSet, basename='invest-stock')
router.register(r'transactions', TransactionViewSet, basename='invest-transaction')
router.register(r'cashflows', CashFlowViewSet, basename='invest-cashflow')
router.register(r'snapshots', PortfolioSnapshotViewSet, basename='invest-snapshot')
router.register(r'benchmarks', BenchmarkPriceViewSet, basename='invest-benchmark')
urlpatterns = [
path('', include(router.urls)),
path('ai-update/', AIUpdateView.as_view(), name='invest-ai-update'),
path('agent/summary/', AgentSummaryView.as_view(), name='invest-agent-summary'),
path('performance/', PerformanceView.as_view(), name='invest-performance'),
path('risk/', RiskView.as_view(), name='invest-risk'),
]
+45 -12
View File
@@ -1,4 +1,6 @@
# Generated by Django 5.2.12 on 2026-04-18 12:57
# Hand-adjusted so the migration is safe on production DBs that already have the
# hot-patched benchmark cache table.
from django.db import migrations, models
@@ -10,18 +12,49 @@ class Migration(migrations.Migration):
]
operations = [
migrations.CreateModel(
name='BenchmarkPrice',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ticker', models.CharField(max_length=10)),
('date', models.DateField()),
('close', models.DecimalField(decimal_places=4, max_digits=12)),
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.CreateModel(
name='BenchmarkPrice',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ticker', models.CharField(max_length=10)),
('date', models.DateField()),
('close', models.DecimalField(decimal_places=4, max_digits=12)),
],
options={
'ordering': ['ticker', 'date'],
'indexes': [models.Index(fields=['ticker', 'date'], name='invest_benc_ticker_17637a_idx')],
'unique_together': {('ticker', 'date')},
},
),
],
database_operations=[
migrations.RunSQL(
sql=(
'CREATE TABLE IF NOT EXISTS "invest_benchmarkprice" ('
'"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, '
'"ticker" varchar(10) NOT NULL, '
'"date" date NOT NULL, '
'"close" decimal NOT NULL)'
),
reverse_sql='DROP TABLE IF EXISTS "invest_benchmarkprice"',
),
migrations.RunSQL(
sql=(
'CREATE UNIQUE INDEX IF NOT EXISTS '
'"invest_benchmarkprice_ticker_date_uniq" '
'ON "invest_benchmarkprice" ("ticker", "date")'
),
reverse_sql='DROP INDEX IF EXISTS "invest_benchmarkprice_ticker_date_uniq"',
),
migrations.RunSQL(
sql=(
'CREATE INDEX IF NOT EXISTS "invest_benc_ticker_17637a_idx" '
'ON "invest_benchmarkprice" ("ticker", "date")'
),
reverse_sql='DROP INDEX IF EXISTS "invest_benc_ticker_17637a_idx"',
),
],
options={
'ordering': ['ticker', 'date'],
'indexes': [models.Index(fields=['ticker', 'date'], name='invest_benc_ticker_17637a_idx')],
'unique_together': {('ticker', 'date')},
},
),
]
@@ -0,0 +1,94 @@
# Generated by Django 5.2.12 on 2026-06-13 13:26
import django.core.validators
import django.db.models.deletion
from decimal import Decimal
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('invest', '0003_benchmarkprice'),
]
operations = [
migrations.AlterField(
model_name='benchmarkprice',
name='ticker',
field=models.CharField(max_length=20),
),
migrations.AlterField(
model_name='benchmarkprice',
name='close',
field=models.DecimalField(decimal_places=6, max_digits=20),
),
migrations.CreateModel(
name='CashFlow',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('flow_type', models.CharField(choices=[('DEPOSIT', 'Deposit'), ('WITHDRAWAL', 'Withdrawal'), ('DIVIDEND', 'Dividend'), ('FEE', 'Fee'), ('INTEREST', 'Interest'), ('TRANSFER_IN', 'Transfer In'), ('TRANSFER_OUT', 'Transfer Out')], max_length=20)),
('amount', models.DecimalField(decimal_places=2, max_digits=20, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))])),
('currency', models.CharField(default='USD', max_length=3)),
('date', models.DateField()),
('source', models.CharField(blank=True, default='', max_length=50)),
('note', models.TextField(blank=True, default='')),
('confidence', models.DecimalField(blank=True, decimal_places=4, max_digits=5, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0')), django.core.validators.MaxValueValidator(Decimal('1'))])),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['-date', '-created_at'],
},
),
migrations.AddField(
model_name='transaction',
name='broker_trade_id',
field=models.CharField(blank=True, default='', max_length=128),
),
migrations.AddField(
model_name='transaction',
name='confidence',
field=models.DecimalField(blank=True, decimal_places=4, max_digits=5, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0')), django.core.validators.MaxValueValidator(Decimal('1'))]),
),
migrations.AddField(
model_name='transaction',
name='currency',
field=models.CharField(default='USD', max_length=3),
),
migrations.AddField(
model_name='transaction',
name='fee',
field=models.DecimalField(blank=True, decimal_places=6, help_text='Optional broker fee/commission in transaction currency.', max_digits=20, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0'))]),
),
migrations.AddField(
model_name='transaction',
name='price_per_share',
field=models.DecimalField(blank=True, decimal_places=6, help_text='Optional execution price per share.', max_digits=20, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0'))]),
),
migrations.AddField(
model_name='transaction',
name='source',
field=models.CharField(blank=True, default='', max_length=50),
),
migrations.AddIndex(
model_name='transaction',
index=models.Index(fields=['portfolio', 'date'], name='invest_tran_portfol_962776_idx'),
),
migrations.AddIndex(
model_name='transaction',
index=models.Index(fields=['stock_code', 'date'], name='invest_tran_stock_c_90351a_idx'),
),
migrations.AddField(
model_name='cashflow',
name='portfolio',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cashflows', to='invest.portfolio'),
),
migrations.AddIndex(
model_name='cashflow',
index=models.Index(fields=['portfolio', 'date'], name='invest_cash_portfol_74c6cf_idx'),
),
migrations.AddIndex(
model_name='cashflow',
index=models.Index(fields=['flow_type', 'date'], name='invest_cash_flow_ty_53c969_idx'),
),
]
+124 -8
View File
@@ -1,10 +1,12 @@
from django.db import models
from django.core.validators import MinValueValidator
from decimal import Decimal
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
class Portfolio(models.Model):
"""Represents an investment account/portfolio (e.g., 'MOMO', 'User IBKR')."""
name = models.CharField(max_length=100)
created_at = models.DateTimeField(auto_now_add=True)
@@ -16,7 +18,8 @@ class Portfolio(models.Model):
class Stock(models.Model):
"""Current holdings for a portfolio. Quantity only — no cost tracking."""
"""Current holdings for a portfolio. Quantity only — prices are fetched on demand."""
portfolio = models.ForeignKey(Portfolio, on_delete=models.CASCADE, related_name='stocks')
stock_code = models.CharField(max_length=20, help_text="Stock ticker, e.g. 'NVDA', '9988.HK'")
quantity = models.DecimalField(
@@ -35,7 +38,13 @@ class Stock(models.Model):
class Transaction(models.Model):
"""Buy/sell event log. No price stored — only quantity changes tracked."""
"""
Buy/sell event log.
Price/currency/fee are intentionally optional: broker screenshots and AI/OCR syncs often
only provide ticker + quantity. When present, these fields enable cost basis and P&L.
"""
ACTION_BUY = 'BUY'
ACTION_SELL = 'SELL'
ACTION_CHOICES = [(ACTION_BUY, 'Buy'), (ACTION_SELL, 'Sell')]
@@ -48,18 +57,124 @@ class Transaction(models.Model):
decimal_places=6,
validators=[MinValueValidator(Decimal('0.000001'))],
)
price_per_share = models.DecimalField(
max_digits=20,
decimal_places=6,
null=True,
blank=True,
validators=[MinValueValidator(Decimal('0'))],
help_text='Optional execution price per share.',
)
currency = models.CharField(max_length=3, default='USD')
fee = models.DecimalField(
max_digits=20,
decimal_places=6,
null=True,
blank=True,
validators=[MinValueValidator(Decimal('0'))],
help_text='Optional broker fee/commission in transaction currency.',
)
broker_trade_id = models.CharField(max_length=128, blank=True, default='')
source = models.CharField(max_length=50, blank=True, default='')
confidence = models.DecimalField(
max_digits=5,
decimal_places=4,
null=True,
blank=True,
validators=[MinValueValidator(Decimal('0')), MaxValueValidator(Decimal('1'))],
)
date = models.DateField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-date', '-created_at']
indexes = [
models.Index(fields=['portfolio', 'date']),
models.Index(fields=['stock_code', 'date']),
]
def __str__(self):
return f"{self.action} {self.quantity} {self.stock_code} on {self.date}"
class CashFlow(models.Model):
"""External/internal cash ledger used for cash-flow-adjusted performance."""
FLOW_DEPOSIT = 'DEPOSIT'
FLOW_WITHDRAWAL = 'WITHDRAWAL'
FLOW_DIVIDEND = 'DIVIDEND'
FLOW_FEE = 'FEE'
FLOW_INTEREST = 'INTEREST'
FLOW_TRANSFER_IN = 'TRANSFER_IN'
FLOW_TRANSFER_OUT = 'TRANSFER_OUT'
FLOW_CHOICES = [
(FLOW_DEPOSIT, 'Deposit'),
(FLOW_WITHDRAWAL, 'Withdrawal'),
(FLOW_DIVIDEND, 'Dividend'),
(FLOW_FEE, 'Fee'),
(FLOW_INTEREST, 'Interest'),
(FLOW_TRANSFER_IN, 'Transfer In'),
(FLOW_TRANSFER_OUT, 'Transfer Out'),
]
EXTERNAL_POSITIVE = {FLOW_DEPOSIT, FLOW_TRANSFER_IN}
EXTERNAL_NEGATIVE = {FLOW_WITHDRAWAL, FLOW_TRANSFER_OUT}
VALUE_POSITIVE = {FLOW_DEPOSIT, FLOW_TRANSFER_IN, FLOW_DIVIDEND, FLOW_INTEREST}
VALUE_NEGATIVE = {FLOW_WITHDRAWAL, FLOW_TRANSFER_OUT, FLOW_FEE}
portfolio = models.ForeignKey(Portfolio, on_delete=models.CASCADE, related_name='cashflows')
flow_type = models.CharField(max_length=20, choices=FLOW_CHOICES)
amount = models.DecimalField(
max_digits=20,
decimal_places=2,
validators=[MinValueValidator(Decimal('0.01'))],
)
currency = models.CharField(max_length=3, default='USD')
date = models.DateField()
source = models.CharField(max_length=50, blank=True, default='')
note = models.TextField(blank=True, default='')
confidence = models.DecimalField(
max_digits=5,
decimal_places=4,
null=True,
blank=True,
validators=[MinValueValidator(Decimal('0')), MaxValueValidator(Decimal('1'))],
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-date', '-created_at']
indexes = [
models.Index(fields=['portfolio', 'date']),
models.Index(fields=['flow_type', 'date']),
]
@property
def signed_amount(self) -> Decimal:
if self.flow_type in self.VALUE_NEGATIVE:
return -self.amount
return self.amount
@property
def external_signed_amount(self) -> Decimal:
if self.flow_type in self.EXTERNAL_POSITIVE:
return self.amount
if self.flow_type in self.EXTERNAL_NEGATIVE:
return -self.amount
return Decimal('0')
@property
def is_external(self) -> bool:
return self.flow_type in self.EXTERNAL_POSITIVE.union(self.EXTERNAL_NEGATIVE)
def __str__(self):
return f"{self.flow_type} {self.amount} {self.currency} ({self.portfolio.name}) on {self.date}"
class PortfolioSnapshot(models.Model):
"""Weekly total-value snapshot per portfolio, captured Saturday 8 AM."""
"""Periodic total-value snapshot per portfolio."""
portfolio = models.ForeignKey(Portfolio, on_delete=models.CASCADE, related_name='snapshots')
captured_at = models.DateTimeField()
total_value = models.DecimalField(max_digits=20, decimal_places=2)
@@ -75,10 +190,11 @@ class PortfolioSnapshot(models.Model):
class BenchmarkPrice(models.Model):
"""Daily closing price for a benchmark ticker (SPY, QQQ, etc.). Cached from yfinance."""
ticker = models.CharField(max_length=10)
"""Daily close for benchmark tickers (QQQ, SPY, etc.) cached from market data."""
ticker = models.CharField(max_length=20)
date = models.DateField()
close = models.DecimalField(max_digits=12, decimal_places=4)
close = models.DecimalField(max_digits=20, decimal_places=6)
class Meta:
unique_together = [('ticker', 'date')]
+78 -21
View File
@@ -1,5 +1,6 @@
from rest_framework import serializers
from .models import Portfolio, Stock, Transaction
from .models import BenchmarkPrice, CashFlow, Portfolio, PortfolioSnapshot, Stock, Transaction
class StockSerializer(serializers.ModelSerializer):
@@ -15,10 +16,79 @@ class TransactionSerializer(serializers.ModelSerializer):
class Meta:
model = Transaction
fields = [
'id', 'portfolio', 'action', 'action_display',
'stock_code', 'quantity', 'date', 'created_at',
'id',
'portfolio',
'action',
'action_display',
'stock_code',
'quantity',
'price_per_share',
'currency',
'fee',
'broker_trade_id',
'source',
'confidence',
'date',
'created_at',
]
read_only_fields = ['id', 'created_at']
extra_kwargs = {
'price_per_share': {'required': False, 'allow_null': True},
'fee': {'required': False, 'allow_null': True},
'currency': {'required': False},
'broker_trade_id': {'required': False, 'allow_blank': True},
'source': {'required': False, 'allow_blank': True},
'confidence': {'required': False, 'allow_null': True},
}
class CashFlowSerializer(serializers.ModelSerializer):
flow_type_display = serializers.CharField(source='get_flow_type_display', read_only=True)
signed_amount = serializers.DecimalField(max_digits=20, decimal_places=2, read_only=True)
external_signed_amount = serializers.DecimalField(max_digits=20, decimal_places=2, read_only=True)
is_external = serializers.BooleanField(read_only=True)
class Meta:
model = CashFlow
fields = [
'id',
'portfolio',
'flow_type',
'flow_type_display',
'amount',
'signed_amount',
'external_signed_amount',
'is_external',
'currency',
'date',
'source',
'note',
'confidence',
'created_at',
]
read_only_fields = ['id', 'created_at']
extra_kwargs = {
'currency': {'required': False},
'source': {'required': False, 'allow_blank': True},
'note': {'required': False, 'allow_blank': True},
'confidence': {'required': False, 'allow_null': True},
}
class PortfolioSnapshotSerializer(serializers.ModelSerializer):
portfolio_name = serializers.CharField(source='portfolio.name', read_only=True)
class Meta:
model = PortfolioSnapshot
fields = ['id', 'portfolio', 'portfolio_name', 'captured_at', 'total_value']
read_only_fields = ['id']
class BenchmarkPriceSerializer(serializers.ModelSerializer):
class Meta:
model = BenchmarkPrice
fields = ['id', 'ticker', 'date', 'close']
read_only_fields = ['id']
class PortfolioSerializer(serializers.ModelSerializer):
@@ -42,10 +112,6 @@ class PortfolioListSerializer(serializers.ModelSerializer):
return obj.stocks.count()
# ---------------------------------------------------------------------------
# AI Update
# ---------------------------------------------------------------------------
class AIHoldingInputSerializer(serializers.Serializer):
stock_code = serializers.CharField()
quantity = serializers.FloatField()
@@ -57,19 +123,15 @@ class AIUpdateSerializer(serializers.Serializer):
reset = serializers.BooleanField(default=False)
# --------------------------------------------------------------------------+
# Holdings (with real-time prices) |
# -------------------------------------------------------------------------+
class HoldingSerializer(serializers.Serializer):
stock_code = serializers.CharField()
quantity = serializers.FloatField()
avg_cost = serializers.FloatField()
current_price = serializers.FloatField()
current_value = serializers.FloatField()
unrealized_pnl = serializers.FloatField()
unrealized_pnl_pct = serializers.FloatField()
ref_price = serializers.FloatField(required=False, allow_null=True)
price_change = serializers.FloatField(required=False, allow_null=True)
price_change_pct = serializers.FloatField(required=False, allow_null=True)
value_change = serializers.FloatField(required=False, allow_null=True)
class PortfolioHoldingsSerializer(serializers.Serializer):
@@ -77,14 +139,9 @@ class PortfolioHoldingsSerializer(serializers.Serializer):
portfolio_name = serializers.CharField()
holdings = HoldingSerializer(many=True)
total_value = serializers.FloatField()
total_cost = serializers.FloatField()
total_pnl = serializers.FloatField()
total_pnl_pct = serializers.FloatField()
class AIUpdateResultSerializer(serializers.Serializer):
stock_code = serializers.CharField()
quantity = serializers.FloatField()
avg_cost = serializers.FloatField()
stock_created = serializers.BooleanField()
tx_status = serializers.CharField()
created = serializers.BooleanField()
+491 -277
View File
@@ -1,35 +1,71 @@
"""
Service layer for the invest app.
Prices fetched from Yahoo Finance on demand via yfinance.
No cost basis or P&L tracking.
Design goals:
- Keep ticker/quantity sync simple for AI/OCR workflows.
- Treat transaction price/currency/fee as optional.
- Separate account-value growth from cash-flow-adjusted investment return.
"""
import json
import logging
from decimal import Decimal
from collections import defaultdict
from datetime import date as date_cls
from datetime import datetime, timedelta
from typing import Optional
from decimal import Decimal
from typing import Iterable, Optional
from django.db.models import Sum
from django.utils import timezone
from .models import Portfolio, Stock, PortfolioSnapshot
from .models import BenchmarkPrice, CashFlow, Portfolio, PortfolioSnapshot, Stock
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# In-process price cache (5 min TTL) + last-week price cache (1 hour TTL)
# Price cache
# ---------------------------------------------------------------------------
_price_cache: dict[str, tuple[float, datetime]] = {}
_PRICE_CACHE_TTL_SECONDS = 300
# Cache for historical prices keyed by (stock_code, date_iso) with 1-hour TTL
_historical_price_cache: dict[str, tuple[Optional[float], datetime]] = {}
_chart_cache: dict = {}
_PRICE_CACHE_TTL_SECONDS = 300
_HISTORICAL_CACHE_TTL_SECONDS = 3600
_CHART_CACHE_TTL = 900
SEMI_TICKERS = {'NVDA', 'AMD', 'AVGO', 'TSM', 'ASML', 'MU', 'MRVL', 'INTC', 'SOXX', 'DRAM'}
AI_CLOUD_TICKERS = {'NVDA', 'AMD', 'AVGO', 'TSM', 'ASML', 'MU', 'MRVL', 'INTC', 'SOXX', 'DRAM', 'NET', 'DDOG', 'GOOG', 'GOOGL', 'MSFT', 'AMZN'}
def _to_float(value) -> Optional[float]:
if value is None:
return None
return float(value)
def _as_date(value) -> Optional[date_cls]:
if value is None:
return None
if isinstance(value, datetime):
return timezone.localtime(value).date() if timezone.is_aware(value) else value.date()
if hasattr(value, 'date') and not isinstance(value, date_cls):
return value.date()
if isinstance(value, date_cls):
return value
if isinstance(value, str):
return date_cls.fromisoformat(value)
return value
# ---------------------------------------------------------------------------
# Market data
# ---------------------------------------------------------------------------
def _get_yfinance_price(stock_code: str) -> Optional[float]:
try:
import yfinance as yf
ticker = yf.Ticker(stock_code)
hist = ticker.history(period="1d")
if hist.empty:
@@ -40,42 +76,9 @@ def _get_yfinance_price(stock_code: str) -> Optional[float]:
return None
def _get_historical_price(stock_code: str, ref_date) -> Optional[float]:
"""
Return the closing price on or just before ref_date (handles weekends/holidays).
ref_date can be a date or datetime object.
"""
import datetime as dt
if hasattr(ref_date, 'date'):
ref_date = ref_date.date()
cache_key = f"{stock_code}:{ref_date.isoformat()}"
now = datetime.now()
cached = _historical_price_cache.get(cache_key)
if cached:
price, cached_at = cached
if (now - cached_at).total_seconds() < _HISTORICAL_CACHE_TTL_SECONDS:
return price
try:
import yfinance as yf
# Look back up to 7 days to find the nearest prior trading day
start = ref_date - dt.timedelta(days=7)
end = ref_date + dt.timedelta(days=1) # end is exclusive in yfinance
hist = yf.Ticker(stock_code).history(start=start.isoformat(), end=end.isoformat())
if hist.empty:
price = None
else:
price = float(hist["Close"].iloc[-1])
except Exception as exc:
logger.warning("yfinance historical price failed for %s @ %s: %s", stock_code, ref_date, exc)
price = None
_historical_price_cache[cache_key] = (price, now)
return price
def get_current_price(stock_code: str) -> Optional[float]:
now = datetime.now()
stock_code = stock_code.upper()
cached = _price_cache.get(stock_code)
if cached:
price, cached_at = cached
@@ -91,24 +94,99 @@ def get_current_price(stock_code: str) -> Optional[float]:
return None
def _get_historical_price(stock_code: str, ref_date) -> Optional[float]:
"""Return the close on or before ref_date, using BenchmarkPrice then yfinance fallback."""
ref_date = _as_date(ref_date)
if not ref_date:
return None
stock_code = stock_code.upper()
cache_key = f"{stock_code}:{ref_date.isoformat()}"
now = datetime.now()
# Prefer explicit DB fixtures/cache rows over in-process cache. Tests and manual backfills
# may create BenchmarkPrice rows after a previous best-effort yfinance lookup.
db_price = (
BenchmarkPrice.objects.filter(ticker=stock_code, date__lte=ref_date)
.order_by('-date')
.values_list('close', flat=True)
.first()
)
if db_price is not None:
price = float(db_price)
_historical_price_cache[cache_key] = (price, now)
return price
cached = _historical_price_cache.get(cache_key)
if cached and cached[0] is not None and (now - cached[1]).total_seconds() < _HISTORICAL_CACHE_TTL_SECONDS:
return cached[0]
price = None
try:
import yfinance as yf
start = ref_date - timedelta(days=7)
end = ref_date + timedelta(days=1)
hist = yf.Ticker(stock_code).history(start=start.isoformat(), end=end.isoformat())
if not hist.empty:
price = float(hist["Close"].iloc[-1])
BenchmarkPrice.objects.update_or_create(
ticker=stock_code,
date=hist.index[-1].date() if hasattr(hist.index[-1], 'date') else ref_date,
defaults={'close': Decimal(str(round(price, 6)))},
)
except Exception as exc:
logger.warning("historical price failed for %s @ %s: %s", stock_code, ref_date, exc)
_historical_price_cache[cache_key] = (price, now)
return price
def refresh_benchmark_prices(tickers: Iterable[str] = ('SPY', 'QQQ'), days: int = 540) -> int:
"""Best-effort benchmark cache refresh. Returns number of rows upserted."""
try:
import yfinance as yf
except Exception as exc:
logger.warning("yfinance unavailable for benchmark refresh: %s", exc)
return 0
end = timezone.now().date() + timedelta(days=1)
start = end - timedelta(days=days)
count = 0
for ticker in tickers:
try:
hist = yf.Ticker(ticker).history(start=start.isoformat(), end=end.isoformat())
rows = []
for d, v in hist['Close'].items():
row_date = d.date() if hasattr(d, 'date') else d
rows.append(BenchmarkPrice(ticker=ticker.upper(), date=row_date, close=Decimal(str(round(float(v), 6)))))
if rows:
BenchmarkPrice.objects.bulk_create(
rows,
update_conflicts=True,
unique_fields=['ticker', 'date'],
update_fields=['close'],
)
count += len(rows)
except Exception as exc:
logger.warning("benchmark refresh failed for %s: %s", ticker, exc)
return count
# ---------------------------------------------------------------------------
# Portfolio value (live prices, no cost tracking)
# Portfolio values
# ---------------------------------------------------------------------------
def get_portfolio_value(portfolio: Portfolio, reference_date=None) -> dict:
"""
Return live holdings with current prices, total value, and weekly price change per stock.
When reference_date is provided, per-stock change is relative to the closing price on that date
(the same baseline used by the portfolio-level change in the dashboard header).
"""
"""Return live holdings with current prices and optional change vs reference_date."""
holdings = []
total_value = Decimal('0')
for stock in portfolio.stocks.filter(quantity__gt=0):
price = get_current_price(stock.stock_code) or 0.0
ticker = stock.stock_code.upper()
price = get_current_price(ticker) or 0.0
value = Decimal(str(price)) * stock.quantity
ref_price = _get_historical_price(stock.stock_code, reference_date) if reference_date else None
ref_price = _get_historical_price(ticker, reference_date) if reference_date else None
price_change = None
price_change_pct = None
@@ -119,7 +197,7 @@ def get_portfolio_value(portfolio: Portfolio, reference_date=None) -> dict:
value_change = round(price_change * float(stock.quantity), 2)
holdings.append({
'stock_code': stock.stock_code,
'stock_code': ticker,
'quantity': float(stock.quantity),
'current_price': price,
'current_value': float(value),
@@ -138,91 +216,117 @@ def get_portfolio_value(portfolio: Portfolio, reference_date=None) -> dict:
}
# ---------------------------------------------------------------------------
# Weekly snapshot overview
# ---------------------------------------------------------------------------
def get_all_holdings(reference_date=None) -> list[dict]:
palette = [
{'badge': 'bg-indigo-100 text-indigo-800', 'row': 'bg-indigo-50', 'border': 'border-indigo-200'},
{'badge': 'bg-emerald-100 text-emerald-800', 'row': 'bg-emerald-50', 'border': 'border-emerald-200'},
{'badge': 'bg-amber-100 text-amber-800', 'row': 'bg-amber-50', 'border': 'border-amber-200'},
{'badge': 'bg-rose-100 text-rose-800', 'row': 'bg-rose-50', 'border': 'border-rose-200'},
{'badge': 'bg-sky-100 text-sky-800', 'row': 'bg-sky-50', 'border': 'border-sky-200'},
]
def _get_snapshot_total(date) -> Optional[float]:
result = PortfolioSnapshot.objects.filter(
captured_at__date=date
).aggregate(total=Sum('total_value'))['total']
return float(result) if result is not None else None
result = []
for idx, portfolio in enumerate(Portfolio.objects.all()):
data = get_portfolio_value(portfolio, reference_date=reference_date)
result.append({
'portfolio': portfolio,
'colors': palette[idx % len(palette)],
'holdings': data['holdings'],
'total_value': data['total_value'],
})
return result
def _snapshot_asof(portfolio: Portfolio, target_date) -> Optional[float]:
target_date = _as_date(target_date)
if not target_date:
return None
snap = (
PortfolioSnapshot.objects.filter(portfolio=portfolio, captured_at__date__lte=target_date)
.order_by('-captured_at')
.first()
)
return float(snap.total_value) if snap else None
def get_total_value_asof(target_date=None, live_if_today: bool = True) -> Optional[float]:
target_date = _as_date(target_date)
today = timezone.now().date()
portfolios = list(Portfolio.objects.prefetch_related('stocks').all())
if target_date is None or (live_if_today and target_date == today):
total = sum(get_portfolio_value(p)['total_value'] for p in portfolios)
return float(total)
values = [_snapshot_asof(p, target_date) for p in portfolios]
values = [v for v in values if v is not None]
if not values:
return None
return float(sum(values))
def _distinct_snapshot_dates() -> list[date_cls]:
days = []
for dt in PortfolioSnapshot.objects.values_list('captured_at', flat=True).order_by('captured_at'):
day = _as_date(dt)
if day and day not in days:
days.append(day)
return days
# ---------------------------------------------------------------------------
# Weekly overview
# ---------------------------------------------------------------------------
def get_weekly_overview() -> dict:
"""
Compute overview from the two most recent weekly snapshots.
'This week' = most recent snapshot date.
'Last week' = most recent snapshot date at least 5 days earlier (ensuring different week).
Per-portfolio values use as-of lookups (latest snapshot on or before the target date).
Compute overview from the latest snapshot and the prior snapshot at least 5 days earlier.
Uses as-of per-portfolio lookups to avoid duplicate/mixed-market snapshot dates double counting.
"""
latest_ts = (
PortfolioSnapshot.objects.order_by('-captured_at')
.values_list('captured_at', flat=True)
.first()
)
if not latest_ts:
return {
'this_week_total': None, 'last_week_total': None,
'this_week_date': None, 'last_week_date': None,
'week_gain': None, 'week_change_pct': None,
'portfolio_rows': [], 'portfolio_count': Portfolio.objects.count(),
}
latest_ts = PortfolioSnapshot.objects.order_by('-captured_at').values_list('captured_at', flat=True).first()
today = timezone.now().date()
this_week_date = latest_ts.date() if hasattr(latest_ts, 'date') else latest_ts
last_week_cutoff = this_week_date - timedelta(days=5)
if latest_ts:
this_week_date = _as_date(latest_ts)
snapshots_are_stale = this_week_date < today
else:
this_week_date = today
snapshots_are_stale = True
cutoff = this_week_date - timedelta(days=5)
prev_ts = (
PortfolioSnapshot.objects
.filter(captured_at__date__lte=last_week_cutoff)
PortfolioSnapshot.objects.filter(captured_at__date__lte=cutoff)
.order_by('-captured_at')
.values_list('captured_at', flat=True)
.first()
)
last_week_date = (prev_ts.date() if hasattr(prev_ts, 'date') else prev_ts) if prev_ts else None
last_week_date = _as_date(prev_ts) if prev_ts else None
def _snap_asof(portfolio, date):
"""Most recent snapshot for portfolio on or before date."""
if not date:
return None
s = (
PortfolioSnapshot.objects
.filter(portfolio=portfolio, captured_at__date__lte=date)
.order_by('-captured_at')
.first()
)
return float(s.total_value) if s else None
portfolios = list(Portfolio.objects.all())
this_week_total = sum(v for p in portfolios if (v := _snap_asof(p, this_week_date)) is not None) or None
last_week_total = sum(v for p in portfolios if (v := _snap_asof(p, last_week_date)) is not None) if last_week_date else None
if last_week_total == 0:
last_week_total = None
this_week_total = get_total_value_asof(this_week_date if not snapshots_are_stale else today)
last_week_total = get_total_value_asof(last_week_date, live_if_today=False) if last_week_date else None
week_gain = None
week_change_pct = None
if this_week_total is not None and last_week_total is not None and last_week_total > 0:
if this_week_total is not None and last_week_total and last_week_total > 0:
week_gain = this_week_total - last_week_total
week_change_pct = round((week_gain / last_week_total) * 100, 2)
# Per-portfolio breakdown
_palette = [
{'badge': 'bg-indigo-100 text-indigo-800', 'row': 'bg-indigo-50', 'border': 'border-indigo-200'},
palette = [
{'badge': 'bg-indigo-100 text-indigo-800', 'row': 'bg-indigo-50', 'border': 'border-indigo-200'},
{'badge': 'bg-emerald-100 text-emerald-800', 'row': 'bg-emerald-50', 'border': 'border-emerald-200'},
{'badge': 'bg-amber-100 text-amber-800', 'row': 'bg-amber-50', 'border': 'border-amber-200'},
{'badge': 'bg-rose-100 text-rose-800', 'row': 'bg-rose-50', 'border': 'border-rose-200'},
{'badge': 'bg-sky-100 text-sky-800', 'row': 'bg-sky-50', 'border': 'border-sky-200'},
{'badge': 'bg-amber-100 text-amber-800', 'row': 'bg-amber-50', 'border': 'border-amber-200'},
{'badge': 'bg-rose-100 text-rose-800', 'row': 'bg-rose-50', 'border': 'border-rose-200'},
{'badge': 'bg-sky-100 text-sky-800', 'row': 'bg-sky-50', 'border': 'border-sky-200'},
]
portfolio_rows = []
for idx, portfolio in enumerate(Portfolio.objects.all()):
this_val = _snap_asof(portfolio, this_week_date)
last_val = _snap_asof(portfolio, last_week_date)
this_val = get_portfolio_value(portfolio)['total_value'] if snapshots_are_stale else _snapshot_asof(portfolio, this_week_date)
last_val = _snapshot_asof(portfolio, last_week_date) if last_week_date else None
change = change_pct = None
if this_val is not None and last_val is not None and last_val > 0:
if this_val is not None and last_val and last_val > 0:
change = this_val - last_val
change_pct = round((change / last_val) * 100, 2)
portfolio_rows.append({
'portfolio': portfolio,
'this_week_value': this_val,
@@ -230,7 +334,7 @@ def get_weekly_overview() -> dict:
'change': change,
'change_pct': change_pct,
'position_count': portfolio.stocks.filter(quantity__gt=0).count(),
'colors': _palette[idx % len(_palette)],
'colors': palette[idx % len(palette)],
})
return {
@@ -246,51 +350,236 @@ def get_weekly_overview() -> dict:
# ---------------------------------------------------------------------------
# Holdings sync (AI / manual)
# Cash-flow adjusted performance
# ---------------------------------------------------------------------------
def get_all_holdings(reference_date=None) -> list[dict]:
"""
Return live holdings for every portfolio, grouped for dashboard display.
reference_date: if provided, per-stock week change is relative to closing prices on that date.
Each entry: portfolio, portfolio_color_class, holdings (list), total_value
"""
palette = [
{'badge': 'bg-indigo-100 text-indigo-800', 'row': 'bg-indigo-50', 'border': 'border-indigo-200'},
{'badge': 'bg-emerald-100 text-emerald-800', 'row': 'bg-emerald-50', 'border': 'border-emerald-200'},
{'badge': 'bg-amber-100 text-amber-800', 'row': 'bg-amber-50', 'border': 'border-amber-200'},
{'badge': 'bg-rose-100 text-rose-800', 'row': 'bg-rose-50', 'border': 'border-rose-200'},
{'badge': 'bg-sky-100 text-sky-800', 'row': 'bg-sky-50', 'border': 'border-sky-200'},
]
result = []
for idx, portfolio in enumerate(Portfolio.objects.all()):
colors = palette[idx % len(palette)]
data = get_portfolio_value(portfolio, reference_date=reference_date)
result.append({
'portfolio': portfolio,
'colors': colors,
'holdings': data['holdings'],
'total_value': data['total_value'],
})
return result
def _external_cashflows(start=None, end=None, include_start: bool = False):
qs = CashFlow.objects.all()
if start:
start_date = _as_date(start)
qs = qs.filter(date__gte=start_date) if include_start else qs.filter(date__gt=start_date)
if end:
qs = qs.filter(date__lte=_as_date(end))
return qs.order_by('date', 'created_at')
def _sum_external_cashflows(start=None, end=None, include_start: bool = False) -> Decimal:
total = Decimal('0')
for flow in _external_cashflows(start=start, end=end, include_start=include_start):
total += flow.external_signed_amount
return total
def get_net_external_cash_flow(start=None, end=None) -> float:
"""All external deposits/transfers in minus withdrawals/transfers out."""
return round(float(_sum_external_cashflows(start=start, end=end, include_start=True)), 2)
def _first_performance_date() -> Optional[date_cls]:
snapshot_date = PortfolioSnapshot.objects.order_by('captured_at').values_list('captured_at', flat=True).first()
flow_date = CashFlow.objects.order_by('date').values_list('date', flat=True).first()
candidates = [_as_date(v) for v in (snapshot_date, flow_date) if v]
return min(candidates) if candidates else None
def _xirr(cashflows: list[tuple[date_cls, Decimal]]) -> Optional[float]:
if not cashflows:
return None
if not any(amount < 0 for _, amount in cashflows) or not any(amount > 0 for _, amount in cashflows):
return None
start = cashflows[0][0]
def npv(rate: float) -> float:
total = 0.0
for flow_date, amount in cashflows:
years = (flow_date - start).days / 365.0
total += float(amount) / ((1 + rate) ** years)
return total
low, high = -0.9999, 10.0
try:
for _ in range(100):
mid = (low + high) / 2
val = npv(mid)
if abs(val) < 1e-7:
return round(mid, 6)
if val > 0:
low = mid
else:
high = mid
return round((low + high) / 2, 6)
except Exception:
return None
def _benchmark_same_cashflow(ticker: str, start: date_cls, end: date_cls, start_value: float, flows) -> Optional[dict]:
ticker = ticker.upper()
start_price = _get_historical_price(ticker, start)
end_price = _get_historical_price(ticker, end)
if not start_price or not end_price:
return None
units = Decimal(str(start_value)) / Decimal(str(start_price)) if start_value else Decimal('0')
net_external = Decimal('0')
for flow in flows:
price = _get_historical_price(ticker, flow.date)
if not price:
continue
amount = flow.external_signed_amount
net_external += amount
units += amount / Decimal(str(price))
end_value = units * Decimal(str(end_price))
cash_adjusted_gain = end_value - Decimal(str(start_value)) - net_external
capital_base = Decimal(str(start_value)) + sum(
f.external_signed_amount for f in flows if f.external_signed_amount > 0
)
return {
'ticker': ticker,
'start_price': round(start_price, 4),
'end_price': round(end_price, 4),
'end_value': round(float(end_value), 2),
'cash_adjusted_gain': round(float(cash_adjusted_gain), 2),
'simple_return': round(float(cash_adjusted_gain / capital_base), 6) if capital_base > 0 else None,
}
def get_cashflow_adjusted_performance(start=None, end=None, benchmark_tickers: Iterable[str] = ('QQQ', 'SPY')) -> dict:
end_date = _as_date(end) or timezone.now().date()
explicit_start = start is not None
start_date = _as_date(start) or _first_performance_date() or end_date
start_value = get_total_value_asof(start_date, live_if_today=False)
if start_value is None:
start_value = 0.0
end_value = get_total_value_asof(end_date)
if end_value is None:
end_value = 0.0
include_start_flows = not explicit_start and start_value == 0
flows = list(_external_cashflows(start=start_date, end=end_date, include_start=include_start_flows))
net_external = sum((flow.external_signed_amount for flow in flows), Decimal('0'))
positive_external = sum((flow.external_signed_amount for flow in flows if flow.external_signed_amount > 0), Decimal('0'))
cash_adjusted_gain = Decimal(str(end_value)) - Decimal(str(start_value)) - net_external
capital_base = Decimal(str(start_value)) + positive_external
simple_return = cash_adjusted_gain / capital_base if capital_base > 0 else None
xirr_flows = [(start_date, -Decimal(str(start_value)))] if start_value else []
for flow in flows:
xirr_flows.append((flow.date, -flow.external_signed_amount))
xirr_flows.append((end_date, Decimal(str(end_value))))
benchmarks = {}
for ticker in benchmark_tickers:
bench = _benchmark_same_cashflow(ticker, start_date, end_date, start_value, flows)
if bench:
benchmarks[ticker.upper()] = bench
return {
'start_date': start_date.isoformat(),
'end_date': end_date.isoformat(),
'start_value': round(start_value, 2),
'end_value': round(end_value, 2),
'net_external_cash_flow': round(float(net_external), 2),
'positive_external_cash_flow': round(float(positive_external), 2),
'cash_adjusted_gain': round(float(cash_adjusted_gain), 2),
'simple_return': round(float(simple_return), 6) if simple_return is not None else None,
'money_weighted_return': _xirr(xirr_flows),
'cashflows': [
{
'id': flow.id,
'portfolio_id': flow.portfolio_id,
'flow_type': flow.flow_type,
'date': flow.date.isoformat(),
'amount': float(flow.amount),
'external_signed_amount': float(flow.external_signed_amount),
'currency': flow.currency,
}
for flow in flows
],
'benchmarks': benchmarks,
}
# ---------------------------------------------------------------------------
# Performance chart data (cumulative % from first snapshot + benchmarks)
# Risk and agent summary
# ---------------------------------------------------------------------------
# Simple in-process cache — benchmarks don't need to refresh every page load
_chart_cache: dict = {}
_CHART_CACHE_TTL = 900 # 15 minutes
def get_risk_summary() -> dict:
holdings = []
for group in get_all_holdings():
for holding in group['holdings']:
holdings.append({
'portfolio_id': group['portfolio'].id,
'portfolio_name': group['portfolio'].name,
**holding,
})
total_value = sum(h['current_value'] for h in holdings)
holdings.sort(key=lambda h: h['current_value'], reverse=True)
for holding in holdings:
holding['weight'] = round(holding['current_value'] / total_value, 6) if total_value else 0
top_1 = holdings[0]['weight'] if holdings else 0
top_3 = sum(h['weight'] for h in holdings[:3])
top_5 = sum(h['weight'] for h in holdings[:5])
by_ticker = defaultdict(float)
for holding in holdings:
by_ticker[holding['stock_code']] += holding['current_value']
ticker_weights = {
ticker: value / total_value for ticker, value in by_ticker.items()
} if total_value else {}
semi_weight = sum(weight for ticker, weight in ticker_weights.items() if ticker in SEMI_TICKERS)
ai_cloud_weight = sum(weight for ticker, weight in ticker_weights.items() if ticker in AI_CLOUD_TICKERS)
concentration_level = 'LOW'
if top_1 >= 0.25 or top_5 >= 0.70:
concentration_level = 'HIGH'
elif top_3 >= 0.50 or top_5 >= 0.55:
concentration_level = 'MEDIUM'
return {
'total_value': round(total_value, 2),
'position_count': len(holdings),
'top_1_weight': round(top_1, 6),
'top_3_weight': round(top_3, 6),
'top_5_weight': round(top_5, 6),
'concentration_level': concentration_level,
'max_position': holdings[0] if holdings else None,
'top_positions': holdings[:10],
'theme_exposure': {
'semiconductors': round(semi_weight, 6),
'ai_cloud': round(ai_cloud_weight, 6),
},
}
def get_agent_summary() -> dict:
total_value = get_total_value_asof()
net_external_all_time = _sum_external_cashflows()
performance = get_cashflow_adjusted_performance()
risk = get_risk_summary()
return {
'as_of': timezone.now().isoformat(),
'portfolio_count': Portfolio.objects.count(),
'total_value': round(total_value or 0, 2),
'net_external_cash_flow': round(float(net_external_all_time), 2),
'performance': performance,
'risk': risk,
}
# ---------------------------------------------------------------------------
# Performance chart data (snapshot value % vs benchmarks)
# ---------------------------------------------------------------------------
def get_performance_chart_data() -> Optional[str]:
"""
Build Chart.js-ready JSON with cumulative % return from the earliest snapshot.
Base week = 0%. Each portfolio gets a series; S&P 500 (SPY) and QQQ added as benchmarks.
Returns a JSON string (safe to pass directly to the template) or None if no snapshots.
"""
now = datetime.now()
cached = _chart_cache.get('performance')
if cached:
@@ -304,81 +593,50 @@ def get_performance_chart_data() -> Optional[str]:
def _build_performance_chart_data() -> Optional[str]:
# Collect all snapshots, deduplicate by ISO week (keep latest date per portfolio per week)
# This merges HK-market Friday dates with US-market Monday dates for the same week.
from collections import defaultdict
all_snaps = list(
PortfolioSnapshot.objects.select_related('portfolio').order_by('captured_at')
)
all_snaps = list(PortfolioSnapshot.objects.select_related('portfolio').order_by('captured_at'))
if not all_snaps:
return None
# Group each portfolio's snapshots by ISO year-week, keep last per week
portfolio_weekly: dict = {} # portfolio_id -> {iso_week_key -> (date, value)}
portfolio_weekly: dict[int, dict[tuple[int, int], tuple[date_cls, float]]] = {}
for snap in all_snaps:
d = snap.captured_at.date() if hasattr(snap.captured_at, 'date') else snap.captured_at
key = d.isocalendar()[:2] # (year, week)
pid = snap.portfolio_id
if pid not in portfolio_weekly:
portfolio_weekly[pid] = {}
existing = portfolio_weekly[pid].get(key)
# keep the later date within the same week
if existing is None or d > existing[0]:
portfolio_weekly[pid][key] = (d, float(snap.total_value))
day = _as_date(snap.captured_at)
key = day.isocalendar()[:2]
portfolio_weekly.setdefault(snap.portfolio_id, {})
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))
# Build the union of all week keys, sorted chronologically
all_week_keys = sorted(
{wk for pw in portfolio_weekly.values() for wk in pw}
)
all_week_keys = sorted({wk for weekly in portfolio_weekly.values() for wk in weekly})
if not all_week_keys:
return None
# Representative label date: latest date seen in that week across all portfolios
week_label_date: dict = {}
for pw in portfolio_weekly.values():
for wk, (d, _) in pw.items():
if wk not in week_label_date or d > week_label_date[wk]:
week_label_date[wk] = d
import datetime as dt
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_date = week_label_date[all_week_keys[0]]
latest_date = week_label_date[all_week_keys[-1]]
start_str = (earliest_date - timedelta(days=7)).isoformat()
end_str = (latest_date + timedelta(days=1)).isoformat()
refresh_needed = not BenchmarkPrice.objects.filter(ticker='QQQ', date__gte=earliest_date).exists()
if refresh_needed:
refresh_benchmark_prices()
# Chart only shows up to the last Saturday snapshot — no live "Today" point
add_today = False
# Per-portfolio cumulative % series — based on the actual weekly PortfolioSnapshot totals.
# The snapshot captures the true portfolio value at that moment (including all positions,
# before and after rebalancing), so it is the authoritative measure of portfolio performance.
# When add_today is True, the current live value is appended as an extra "Today" data point
# so the chart always includes the current week even before the Saturday snapshot runs.
portfolio_colors = ['#2563EB', '#7C3AED', '#0D9488', '#DB2777', '#EA580C']
colors = ['#2563EB', '#7C3AED', '#0D9488', '#DB2777', '#EA580C']
datasets = []
for idx, portfolio in enumerate(Portfolio.objects.all()):
pw = portfolio_weekly.get(portfolio.id, {})
if not pw:
weekly = portfolio_weekly.get(portfolio.id, {})
if not weekly:
continue
first_week = min(pw.keys())
base_val = pw[first_week][1]
first_week = min(weekly.keys())
base_val = weekly[first_week][1]
if not base_val:
continue
data_pts = [
round((pw[wk][1] - base_val) / base_val * 100, 2) if wk in pw else None
for wk in all_week_keys
]
color = portfolio_colors[idx % len(portfolio_colors)]
datasets.append({
'label': portfolio.name,
'data': data_pts,
'borderColor': color,
'backgroundColor': color,
'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,
@@ -387,86 +645,42 @@ def _build_performance_chart_data() -> Optional[str]:
'fill': False,
})
# Benchmark series — fetched up to today so the final point aligns with portfolio live values
def _benchmark(ticker: str, label: str, color: str) -> Optional[dict]:
from .models import BenchmarkPrice
# Check DB coverage — refresh if no rows or latest price is stale
qs = BenchmarkPrice.objects.filter(ticker=ticker, date__gte=earliest_date - timedelta(days=7))
latest_db_date = qs.order_by('-date').values_list('date', flat=True).first()
need_refresh = latest_db_date is None or (latest_date - latest_db_date).days > 7
if need_refresh:
try:
import yfinance as yf
hist = yf.Ticker(ticker).history(start=start_str, end=end_str)
if not hist.empty:
rows = []
for d, v in hist['Close'].items():
date_val = d.date() if hasattr(d, 'date') else d
rows.append(BenchmarkPrice(ticker=ticker, date=date_val, close=round(float(v), 4)))
BenchmarkPrice.objects.bulk_create(rows, update_conflicts=True,
unique_fields=['ticker', 'date'],
update_fields=['close'])
logger.info("invest: cached %d prices for %s", len(rows), ticker)
except Exception as exc:
logger.warning("benchmark %s yfinance fetch failed: %s", ticker, exc)
try:
closes = {
row.date: float(row.close)
for row in BenchmarkPrice.objects.filter(
ticker=ticker,
date__gte=earliest_date - timedelta(days=7),
date__lte=latest_date + timedelta(days=1),
).order_by('date')
}
if not closes:
return None
sorted_trading_days = sorted(closes.keys())
def closest_close(target):
candidates = [td for td in sorted_trading_days if td <= target]
return closes[candidates[-1]] if candidates else None
base_price = closest_close(earliest_date)
if not base_price:
return None
data_pts = [
round((closest_close(week_label_date[wk]) - base_price) / base_price * 100, 2)
if closest_close(week_label_date[wk]) is not None else None
for wk in all_week_keys
]
return {
'label': label,
'data': data_pts,
'borderColor': color,
'backgroundColor': color,
'borderWidth': 1.5,
'pointRadius': 3,
'pointHoverRadius': 5,
'tension': 0.3,
'borderDash': [5, 5],
'fill': False,
}
except Exception as exc:
logger.warning("benchmark %s failed: %s", ticker, exc)
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,
}
spy = _benchmark('SPY', 'S&P 500', '#D97706')
qqq = _benchmark('QQQ', 'QQQ', '#16A34A')
if spy:
datasets.append(spy)
if qqq:
datasets.append(qqq)
for item in (benchmark_series('SPY', 'S&P 500', '#D97706'), benchmark_series('QQQ', 'QQQ', '#16A34A')):
if item:
datasets.append(item)
labels = [week_label_date[wk].strftime('%b %-d') for wk in all_week_keys]
labels = [week_label_date[w].strftime('%b %-d') for w in all_week_keys]
return json.dumps({'labels': labels, 'datasets': datasets})
# ---------------------------------------------------------------------------
# Holdings sync (AI / manual)
# ---------------------------------------------------------------------------
def ai_update_holdings(portfolio: Portfolio, holdings: list[dict], reset: bool = False) -> dict:
"""Update Stock records. No cost/price tracking."""
"""Update Stock records. No cost/price tracking required."""
from django.db import transaction as db_transaction
results = []
@@ -475,7 +689,7 @@ def ai_update_holdings(portfolio: Portfolio, holdings: list[dict], reset: bool =
portfolio.stocks.all().delete()
for item in holdings:
stock_code = item['stock_code']
stock_code = item['stock_code'].upper()
quantity = Decimal(str(item['quantity']))
stock, created = Stock.objects.update_or_create(
@@ -484,7 +698,7 @@ def ai_update_holdings(portfolio: Portfolio, holdings: list[dict], reset: bool =
defaults={'quantity': quantity},
)
results.append({
'stock_code': stock_code,
'stock_code': stock.stock_code,
'quantity': float(quantity),
'created': created,
})
+4 -3
View File
@@ -3,7 +3,6 @@ Background tasks for the invest app.
"""
import logging
from decimal import Decimal
from datetime import datetime
logger = logging.getLogger(__name__)
@@ -14,8 +13,9 @@ def snapshot_all_portfolios():
Scheduled every Saturday at 08:00. Also callable manually for backfill.
"""
from django.utils import timezone
from .models import Portfolio, PortfolioSnapshot
from .services import get_portfolio_value
from .services import get_portfolio_value, refresh_benchmark_prices
now = timezone.now()
today = now.date()
@@ -27,7 +27,7 @@ def snapshot_all_portfolios():
data = get_portfolio_value(portfolio)
total_value = Decimal(str(data['total_value']))
# One snapshot per portfolio per day — overwrite if run twice
# One snapshot per portfolio per day — overwrite if run twice.
PortfolioSnapshot.objects.filter(
portfolio=portfolio,
captured_at__date=today,
@@ -43,6 +43,7 @@ def snapshot_all_portfolios():
except Exception as exc:
logger.error("invest: snapshot failed for %s: %s", portfolio.name, exc, exc_info=True)
refresh_benchmark_prices()
logger.info("invest: snapshot complete — %d portfolios", count)
# Also refresh benchmark prices so the chart has up-to-date SPY/QQQ data
refresh_benchmark_prices()
+23 -39
View File
@@ -1,75 +1,59 @@
"""Template views for the invest app."""
import logging
from django.shortcuts import render, get_object_or_404
from django.shortcuts import get_object_or_404, render
from django.utils import timezone
from .models import Portfolio, Transaction
from .services import get_portfolio_value, get_weekly_overview, get_all_holdings, get_performance_chart_data
from .services import (
get_all_holdings,
get_cashflow_adjusted_performance,
get_net_external_cash_flow,
get_performance_chart_data,
get_portfolio_value,
get_risk_summary,
get_weekly_overview,
)
logger = logging.getLogger(__name__)
def dashboard(request):
"""Landing page: weekly snapshot overview + per-portfolio table."""
"""Landing page: agent-first metrics + human-readable holdings/risk dashboard."""
overview = get_weekly_overview()
# Determine current Australian financial year (JulJun)
now = timezone.now()
fy_start = now.year if now.month >= 7 else now.year - 1
fy_label = f"FY {str(fy_start)[2:]}-{str(fy_start + 1)[2:]}"
last_week_date = overview.get('last_week_date')
all_holdings = get_all_holdings(reference_date=last_week_date)
# Merge snapshot data into each holdings group.
# Change is computed as (live total last snapshot), so the card header and
# the change line are always consistent with the live holdings table.
reference_date = overview.get('last_week_date')
all_holdings = get_all_holdings(reference_date=reference_date)
rows_by_id = {row['portfolio'].id: row for row in overview.get('portfolio_rows', [])}
for group in all_holdings:
row = rows_by_id.get(group['portfolio'].id, {})
group['last_snapshot_value'] = row.get('last_week_value')
group['this_week_value'] = row.get('this_week_value')
group['change'] = row.get('change')
group['change_pct'] = row.get('change_pct')
group['position_count'] = row.get('position_count', len(group['holdings']))
# Derive portfolio-level change by summing per-stock value changes,
# so the header is always consistent with the individual rows.
stock_changes = [s['value_change'] for s in group['holdings'] if s['value_change'] is not None]
if stock_changes:
total_change = sum(stock_changes)
ref_total = group['total_value'] - total_change
group['change'] = total_change
group['change_pct'] = round((total_change / ref_total) * 100, 2) if ref_total else None
else:
group['change'] = None
group['change_pct'] = None
# Recalculate overview week_gain/week_change_pct from per-portfolio stock-level
# changes so the headline is consistent with the portfolio cards. The snapshot
# comparison inflates the figure whenever the portfolio composition changes
# (e.g. stocks sold/bought during the week), while price-movement only reflects
# actual market performance.
holdings_with_change = [g for g in all_holdings if g['change'] is not None]
if holdings_with_change:
total_change = sum(g['change'] for g in holdings_with_change)
total_ref = sum(g['total_value'] - g['change'] for g in holdings_with_change)
overview['week_gain'] = round(total_change, 2)
overview['week_change_pct'] = round((total_change / total_ref) * 100, 2) if total_ref else None
recent_transactions = (
Transaction.objects
.select_related('portfolio')
.order_by('-date', '-created_at')[:100]
)
performance = get_cashflow_adjusted_performance()
risk = get_risk_summary()
recent_transactions = Transaction.objects.select_related('portfolio').order_by('-date', '-created_at')[:30]
return render(request, 'invest/dashboard.html', {
'overview': overview,
'fy_label': fy_label,
'all_holdings': all_holdings,
'chart_data_json': get_performance_chart_data() or 'null',
'performance': performance,
'net_contributions': get_net_external_cash_flow(),
'risk': risk,
'recent_transactions': recent_transactions,
})
def portfolio_detail(request, pk):
"""Portfolio detail: live holdings, no cost/P&L."""
"""Portfolio detail: live holdings."""
portfolio = get_object_or_404(Portfolio, pk=pk)
try:
summary = get_portfolio_value(portfolio)
+140 -156
View File
@@ -5,201 +5,225 @@
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
{% endblock %}
{% block title %}Dashboard{% endblock %}
{% block title %}Invest Dashboard{% endblock %}
{% block content %}
<div class="mb-2">
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase">Snapshot</p>
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase">{{ fy_label }} Snapshot</p>
</div>
<!-- ── Top metric cards ──────────────────────────────────────── -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-3">
<!-- Total value -->
<div class="bg-white rounded-lg p-4 sm:p-5 shadow-sm">
<div class="bg-white rounded-lg p-5 shadow-sm">
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">Total Value</p>
{% if overview.this_week_total is not None %}
<p class="text-2xl sm:text-3xl font-bold text-stone-900">${{ overview.this_week_total|floatformat:0 }}</p>
<p class="text-3xl font-bold text-stone-900">${{ overview.this_week_total|floatformat:0 }}</p>
{% else %}
<p class="text-2xl sm:text-3xl font-bold text-stone-400"></p>
<p class="text-3xl font-bold text-stone-400"></p>
{% endif %}
<p class="text-xs sm:text-sm text-stone-400 mt-1">
Across {{ overview.portfolio_count }} portfolio{{ overview.portfolio_count|pluralize }}
</p>
<p class="text-sm text-stone-400 mt-1">Across {{ overview.portfolio_count }} portfolio{{ overview.portfolio_count|pluralize }}</p>
</div>
<!-- This week's gain -->
<div class="bg-white rounded-lg p-4 sm:p-5 shadow-sm">
<div class="bg-white rounded-lg p-5 shadow-sm">
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">Net Contributions</p>
<p class="text-3xl font-bold text-blue-800">${{ net_contributions|floatformat:0 }}</p>
<p class="text-sm text-stone-400 mt-1">External deposits minus withdrawals</p>
</div>
<div class="bg-white rounded-lg p-5 shadow-sm">
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">Investment Gain</p>
<p class="text-3xl font-bold {% if performance.cash_adjusted_gain >= 0 %}text-green-800{% else %}text-red-700{% endif %}">
{% if performance.cash_adjusted_gain >= 0 %}+{% endif %}${{ performance.cash_adjusted_gain|floatformat:0 }}
</p>
<p class="text-sm text-stone-400 mt-1">Cash-flow adjusted</p>
</div>
<div class="bg-white rounded-lg p-5 shadow-sm">
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">Top 5 Concentration</p>
<p class="text-3xl font-bold {% if risk.concentration_level == 'HIGH' %}text-red-700{% elif risk.concentration_level == 'MEDIUM' %}text-amber-700{% else %}text-green-800{% endif %}">
{% widthratio risk.top_5_weight 1 100 %}%
</p>
<p class="text-sm text-stone-400 mt-1">Risk: {{ risk.concentration_level }}</p>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-3 mb-3">
<div class="bg-white rounded-lg p-5 shadow-sm">
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">This Week</p>
{% if overview.week_gain is not None %}
<p class="text-2xl sm:text-3xl font-bold {% if overview.week_gain >= 0 %}text-green-800{% else %}text-red-700{% endif %}">
<p class="text-2xl font-bold {% if overview.week_gain >= 0 %}text-green-800{% else %}text-red-700{% endif %}">
{% if overview.week_gain >= 0 %}+{% endif %}${{ overview.week_gain|floatformat:0 }}
</p>
<p class="text-xs sm:text-sm text-stone-400 mt-1">
{% if overview.week_gain >= 0 %}+{% endif %}{{ overview.week_change_pct|floatformat:2 }}% vs last week
</p>
<p class="text-sm text-stone-400 mt-1">{% if overview.week_gain >= 0 %}+{% endif %}{{ overview.week_change_pct|floatformat:2 }}% vs last snapshot</p>
{% else %}
<p class="text-2xl sm:text-3xl font-bold text-stone-400"></p>
<p class="text-xs sm:text-sm text-stone-400 mt-1">No prior snapshot</p>
<p class="text-2xl font-bold text-stone-400"></p>
<p class="text-sm text-stone-400 mt-1">No prior snapshot</p>
{% endif %}
</div>
<!-- Week change % -->
<div class="bg-white rounded-lg p-4 sm:p-5 shadow-sm">
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">Week Change</p>
{% if overview.week_change_pct is not None %}
<p class="text-2xl sm:text-3xl font-bold {% if overview.week_change_pct >= 0 %}text-green-800{% else %}text-red-700{% endif %}">
{% if overview.week_change_pct >= 0 %}+{% endif %}{{ overview.week_change_pct|floatformat:2 }}%
</p>
{% if overview.last_week_date %}
<p class="text-xs sm:text-sm text-stone-400 mt-1">Last: {{ overview.last_week_date|date:"M j" }}</p>
{% endif %}
<div class="bg-white rounded-lg p-5 shadow-sm">
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">Money Weighted Return</p>
{% if performance.money_weighted_return is not None %}
<p class="text-2xl font-bold text-stone-900">{% widthratio performance.money_weighted_return 1 100 %}%</p>
{% else %}
<p class="text-2xl sm:text-3xl font-bold text-stone-400"></p>
<p class="text-xs sm:text-sm text-stone-400 mt-1">Need 2+ snapshots</p>
<p class="text-2xl font-bold text-stone-400"></p>
{% endif %}
<p class="text-sm text-stone-400 mt-1">IRR based on cash flows</p>
</div>
<!-- Last snapshot date -->
<div class="bg-white rounded-lg p-4 sm:p-5 shadow-sm">
<div class="bg-white rounded-lg p-5 shadow-sm">
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">Last Snapshot</p>
{% if overview.this_week_date %}
<p class="text-2xl sm:text-3xl font-bold text-stone-900">{{ overview.this_week_date|date:"M j" }}</p>
<p class="text-xs sm:text-sm text-stone-400 mt-1">{{ overview.this_week_date|date:"l, Y" }}</p>
<p class="text-2xl font-bold text-stone-900">{{ overview.this_week_date|date:"M j" }}</p>
<p class="text-sm text-stone-400 mt-1">{{ overview.this_week_date|date:"l, Y" }}</p>
{% else %}
<p class="text-2xl sm:text-3xl font-bold text-stone-400"></p>
<p class="text-xs sm:text-sm text-stone-400 mt-1">No snapshots yet</p>
<p class="text-2xl font-bold text-stone-400"></p>
<p class="text-sm text-stone-400 mt-1">No snapshots yet</p>
{% endif %}
</div>
</div>
<!-- ── Same-cashflow benchmark ───────────────────────────────── -->
<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">Same-cashflow Benchmark</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<p class="text-sm text-stone-400">Actual end value</p>
<p class="text-xl font-bold text-stone-900">${{ performance.end_value|floatformat:0 }}</p>
</div>
{% for ticker, bench in performance.benchmarks.items %}
<div>
<p class="text-sm text-stone-400">Same cash flows into {{ ticker }}</p>
<p class="text-xl font-bold text-stone-900">${{ bench.end_value|floatformat:0 }}</p>
<p class="text-xs text-stone-400">Return {% if bench.simple_return is not None %}{% widthratio bench.simple_return 1 100 %}%{% else %}—{% endif %}</p>
</div>
{% empty %}
<div class="text-sm text-stone-400">Benchmark prices unavailable. The API still returns portfolio metrics.</div>
{% endfor %}
</div>
</div>
<!-- ── 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">
Performance vs Benchmarks
</p>
<canvas id="performanceChart"></canvas>
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase mb-4">{{ fy_label }} Performance vs Benchmarks</p>
<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>
</div>
{% endif %}
<!-- ── Hint ──────────────────────────────────────────────────── -->
<p class="text-xs text-stone-400 text-center mt-2 mb-6">
Snapshots captured every Saturday 08:00 · Values in portfolio's quote currency
</p>
<!-- ── Risk panel ────────────────────────────────────────────── -->
<div class="bg-white rounded-lg shadow-sm p-5 mb-4">
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase mb-4">Risk Overview</p>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
<div><p class="text-xs text-stone-400">Top 1</p><p class="font-bold">{% widthratio risk.top_1_weight 1 100 %}%</p></div>
<div><p class="text-xs text-stone-400">Top 3</p><p class="font-bold">{% widthratio risk.top_3_weight 1 100 %}%</p></div>
<div><p class="text-xs text-stone-400">Semiconductors</p><p class="font-bold">{% widthratio risk.theme_exposure.semiconductors 1 100 %}%</p></div>
<div><p class="text-xs text-stone-400">AI / Cloud</p><p class="font-bold">{% widthratio risk.theme_exposure.ai_cloud 1 100 %}%</p></div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-xs font-semibold tracking-widest text-stone-400 uppercase border-b border-stone-100">
<th class="py-2 text-left">Ticker</th>
<th class="py-2 text-right">Value</th>
<th class="py-2 text-right">Weight</th>
<th class="py-2 text-left">Portfolio</th>
</tr>
</thead>
<tbody>
{% for position in risk.top_positions|slice:":5" %}
<tr class="border-b border-stone-50">
<td class="py-2 font-medium">{{ position.stock_code }}</td>
<td class="py-2 text-right">${{ position.current_value|floatformat:0 }}</td>
<td class="py-2 text-right">{% widthratio position.weight 1 100 %}%</td>
<td class="py-2 text-stone-500">{{ position.portfolio_name }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- ── Live holdings (per-portfolio cards) ───────────────────── -->
<p class="text-xs text-stone-400 text-center mt-2 mb-6">Snapshots captured every Saturday 08:00 · Prices are best-effort market data · Transaction prices are optional for AI sync</p>
<!-- ── Live holdings ─────────────────────────────────────────── -->
{% if all_holdings %}
<div class="space-y-4">
{% for group in all_holdings %}
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
<!-- Card header -->
<div class="px-6 py-4 flex items-center justify-between border-b border-stone-100">
<div>
<p class="font-semibold text-stone-900 text-sm">
{{ group.portfolio.name }}
</p>
<p class="text-xs text-stone-400 mt-0.5">
{{ group.position_count }} position{{ group.position_count|pluralize }}
{% if overview.this_week_date %}· Snapshot {{ overview.this_week_date|date:"j M Y" }}{% endif %}
</p>
<p class="font-semibold text-stone-900 text-sm">{{ group.portfolio.name }}</p>
<p class="text-xs text-stone-400 mt-0.5">{{ group.position_count }} position{{ group.position_count|pluralize }}{% if overview.this_week_date %} · Snapshot {{ overview.this_week_date|date:"j M Y" }}{% endif %}</p>
</div>
<div class="text-right">
<p class="font-bold text-stone-900 text-sm">
{% if group.total_value %}${{ group.total_value|floatformat:0 }}{% else %}<span class="text-stone-300"></span>{% endif %}
</p>
<p class="font-bold text-stone-900 text-sm">{% if group.this_week_value is not None %}${{ group.this_week_value|floatformat:0 }}{% else %}<span class="text-stone-300"></span>{% endif %}</p>
{% if group.change is not None %}
<p class="text-xs font-medium mt-0.5 {% if group.change >= 0 %}text-green-700{% else %}text-red-600{% endif %}">
{% if group.change >= 0 %}+{% endif %}${{ group.change|floatformat:0 }}
({% if group.change_pct >= 0 %}+{% endif %}{{ group.change_pct|floatformat:1 }}%)
</p>
<p class="text-xs font-medium mt-0.5 {% if group.change >= 0 %}text-green-700{% else %}text-red-600{% endif %}">{% if group.change >= 0 %}+{% endif %}${{ group.change|floatformat:0 }} ({% if group.change_pct >= 0 %}+{% endif %}{{ group.change_pct|floatformat:1 }}%)</p>
{% else %}
<p class="text-xs text-stone-300 mt-0.5">No prior snapshot</p>
{% endif %}
</div>
</div>
<!-- Holdings table -->
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-xs font-semibold tracking-widest text-stone-400 uppercase border-b border-stone-100">
<th class="px-3 sm:px-6 py-3 text-left">Ticker</th>
<th class="hidden sm:table-cell px-6 py-3 text-right">Qty</th>
<th class="hidden sm:table-cell px-6 py-3 text-right">Price</th>
<th class="px-3 sm:px-6 py-3 text-right">Week Change</th>
<th class="px-3 sm:px-6 py-3 text-right">Mkt Value</th>
<th class="px-6 py-3 text-left">Ticker</th>
<th class="px-6 py-3 text-right">Qty</th>
<th class="px-6 py-3 text-right">Price</th>
<th class="px-6 py-3 text-right">Week Change</th>
<th class="px-6 py-3 text-right">Mkt Value</th>
</tr>
</thead>
<tbody>
{% for stock in group.holdings %}
<tr class="border-b border-stone-50 hover:bg-stone-50 {{ group.colors.row }}">
<td class="px-3 sm:px-6 py-3 font-medium text-stone-900">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{ group.colors.badge }}">
{{ stock.stock_code }}
</span>
</td>
<td class="hidden sm:table-cell px-6 py-3 text-right text-stone-500">{{ stock.quantity|floatformat:0 }}</td>
<td class="hidden sm:table-cell px-6 py-3 text-right text-stone-500">
{% if stock.current_price %}${{ stock.current_price|floatformat:2 }}{% else %}<span class="text-stone-300"></span>{% endif %}
</td>
<td class="px-3 sm:px-6 py-3 text-right">
{% if stock.value_change is not None %}
<span class="text-xs font-medium {% if stock.value_change >= 0 %}text-green-700{% else %}text-red-600{% endif %}">
{% if stock.value_change >= 0 %}+{% endif %}${{ stock.value_change|floatformat:0 }} ({% if stock.price_change_pct >= 0 %}+{% endif %}{{ stock.price_change_pct|floatformat:1 }}%)
</span>
{% else %}
<span class="text-stone-300"></span>
{% endif %}
</td>
<td class="px-3 sm:px-6 py-3 text-right font-semibold text-stone-800">
{% if stock.current_value %}${{ stock.current_value|floatformat:0 }}{% else %}<span class="text-stone-300"></span>{% endif %}
</td>
<td class="px-6 py-3 font-medium text-stone-900"><span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{ group.colors.badge }}">{{ stock.stock_code }}</span></td>
<td class="px-6 py-3 text-right text-stone-500">{{ stock.quantity|floatformat:0 }}</td>
<td class="px-6 py-3 text-right text-stone-500">{% if stock.current_price %}${{ stock.current_price|floatformat:2 }}{% else %}<span class="text-stone-300"></span>{% endif %}</td>
<td class="px-6 py-3 text-right {% if stock.value_change >= 0 %}text-green-700{% elif stock.value_change < 0 %}text-red-600{% else %}text-stone-300{% endif %}">{% if stock.value_change is not None %}{% if stock.value_change >= 0 %}+{% endif %}${{ stock.value_change|floatformat:0 }} ({% if stock.price_change_pct >= 0 %}+{% endif %}{{ stock.price_change_pct|floatformat:1 }}%){% else %}—{% endif %}</td>
<td class="px-6 py-3 text-right font-semibold text-stone-800">{% if stock.current_value %}${{ stock.current_value|floatformat:0 }}{% else %}<span class="text-stone-300"></span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- ── Transaction history ──────────────────────────────────── -->
<!-- ── Transactions ──────────────────────────────────────────── -->
{% if recent_transactions %}
<div class="mt-8">
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-3">Transaction History</p>
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
<div class="overflow-x-auto">
<div class="bg-white rounded-lg shadow-sm p-5 mt-4">
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase mb-4">Transaction History</p>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-xs font-semibold tracking-widest text-stone-400 uppercase border-b border-stone-100">
<th class="px-3 sm:px-6 py-3 text-left">Date</th>
<th class="hidden sm:table-cell px-6 py-3 text-left">Portfolio</th>
<th class="px-3 sm:px-6 py-3 text-left">Action</th>
<th class="px-3 sm:px-6 py-3 text-left">Ticker</th>
<th class="px-3 sm:px-6 py-3 text-right">Qty</th>
<th class="py-2 text-left">Date</th>
<th class="py-2 text-left">Portfolio</th>
<th class="py-2 text-left">Action</th>
<th class="py-2 text-left">Ticker</th>
<th class="py-2 text-right">Qty</th>
<th class="py-2 text-right">Price</th>
<th class="py-2 text-right">Fee</th>
</tr>
</thead>
<tbody>
{% for tx in recent_transactions %}
<tr class="border-b border-stone-50 hover:bg-stone-50">
<td class="px-3 sm:px-6 py-3 text-stone-500 whitespace-nowrap">{{ tx.date|date:"j M Y" }}</td>
<td class="hidden sm:table-cell px-6 py-3 text-stone-500">{{ tx.portfolio.name }}</td>
<td class="px-3 sm:px-6 py-3">
{% if tx.action == 'BUY' %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-green-100 text-green-800">BUY</span>
{% else %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-red-100 text-red-700">SELL</span>
{% endif %}
</td>
<td class="px-3 sm:px-6 py-3 font-medium text-stone-900">{{ tx.stock_code }}</td>
<td class="px-3 sm:px-6 py-3 text-right text-stone-500">{{ tx.quantity|floatformat:0 }}</td>
<tr class="border-b border-stone-50">
<td class="py-2">{{ tx.date|date:"j M Y" }}</td>
<td class="py-2">{{ tx.portfolio.name }}</td>
<td class="py-2">{{ tx.action }}</td>
<td class="py-2 font-medium">{{ tx.stock_code }}</td>
<td class="py-2 text-right">{{ tx.quantity|floatformat:0 }}</td>
<td class="py-2 text-right">{% if tx.price_per_share %}${{ tx.price_per_share|floatformat:2 }}{% else %}<span class="text-stone-300">optional</span>{% endif %}</td>
<td class="py-2 text-right">{% if tx.fee %}${{ tx.fee|floatformat:2 }}{% else %}<span class="text-stone-300"></span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
@@ -212,61 +236,21 @@
(function () {
const raw = {{ chart_data_json|safe }};
if (!raw) return;
const ctx = document.getElementById('performanceChart');
if (!ctx) return;
new Chart(ctx, {
type: 'line',
data: raw,
options: {
responsive: true,
aspectRatio: window.innerWidth < 640 ? 1.5 : 3.5,
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) + '%';
},
},
},
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) + '%'; } } },
},
scales: {
y: {
ticks: {
callback: function (v) {
return (v >= 0 ? '+' : '') + v.toFixed(1) + '%';
},
font: { size: 11 },
},
grid: {
color: function (ctx) {
return ctx.tick.value === 0 ? '#a8a29e' : '#f5f5f4';
},
lineWidth: function (ctx) {
return ctx.tick.value === 0 ? 2 : 1;
},
},
},
x: {
grid: { display: false },
ticks: { font: { size: 11 } },
},
y: { ticks: { callback: function (v) { return (v >= 0 ? '+' : '') + v.toFixed(1) + '%'; }, font: { size: 11 } }, grid: { color: '#f5f5f4' } },
x: { grid: { display: false }, ticks: { font: { size: 11 } } },
},
},
});
+85 -28
View File
@@ -1,18 +1,29 @@
import logging
from django.shortcuts import get_object_or_404
from rest_framework import viewsets, status
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import Portfolio, Stock, Transaction
from .models import BenchmarkPrice, CashFlow, Portfolio, PortfolioSnapshot, Stock, Transaction
from .serializers import (
PortfolioSerializer, PortfolioListSerializer,
StockSerializer, TransactionSerializer,
AIUpdateSerializer,
BenchmarkPriceSerializer,
CashFlowSerializer,
PortfolioListSerializer,
PortfolioSerializer,
PortfolioSnapshotSerializer,
StockSerializer,
TransactionSerializer,
)
from .services import (
ai_update_holdings,
get_agent_summary,
get_cashflow_adjusted_performance,
get_portfolio_value,
get_risk_summary,
)
from .services import get_portfolio_value, ai_update_holdings
logger = logging.getLogger(__name__)
@@ -29,8 +40,9 @@ class PortfolioViewSet(viewsets.ModelViewSet):
def holdings(self, request, pk=None):
"""Return holdings with real-time prices."""
portfolio = self.get_object()
reference_date = request.query_params.get('reference_date')
try:
data = get_portfolio_value(portfolio)
data = get_portfolio_value(portfolio, reference_date=reference_date)
return Response(data)
except Exception as exc:
logger.error("get_portfolio_value failed for %s: %s", portfolio.id, exc, exc_info=True)
@@ -71,34 +83,51 @@ class TransactionViewSet(viewsets.ModelViewSet):
qs = qs.filter(stock_code=stock_code.upper())
return qs.order_by('-date', '-created_at')
def create(self, request, *args, **kwargs):
"""Create a new transaction."""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
def perform_create(self, serializer):
serializer.save(stock_code=serializer.validated_data['stock_code'].upper())
data = serializer.validated_data
portfolio = data['portfolio']
try:
tx = Transaction.objects.create(
portfolio=portfolio,
action=data['action'],
stock_code=data['stock_code'].upper(),
quantity=data['quantity'],
date=data['date'],
)
except Exception as exc:
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
class CashFlowViewSet(viewsets.ModelViewSet):
queryset = CashFlow.objects.select_related('portfolio').all()
serializer_class = CashFlowSerializer
out = TransactionSerializer(tx)
return Response(out.data, status=status.HTTP_201_CREATED)
def get_queryset(self):
qs = super().get_queryset()
portfolio_id = self.request.query_params.get('portfolio')
if portfolio_id:
qs = qs.filter(portfolio_id=portfolio_id)
flow_type = self.request.query_params.get('flow_type')
if flow_type:
qs = qs.filter(flow_type=flow_type.upper())
return qs.order_by('-date', '-created_at')
class PortfolioSnapshotViewSet(viewsets.ReadOnlyModelViewSet):
queryset = PortfolioSnapshot.objects.select_related('portfolio').all()
serializer_class = PortfolioSnapshotSerializer
def get_queryset(self):
qs = super().get_queryset()
portfolio_id = self.request.query_params.get('portfolio')
if portfolio_id:
qs = qs.filter(portfolio_id=portfolio_id)
return qs.order_by('-captured_at')
class BenchmarkPriceViewSet(viewsets.ReadOnlyModelViewSet):
queryset = BenchmarkPrice.objects.all()
serializer_class = BenchmarkPriceSerializer
def get_queryset(self):
qs = super().get_queryset()
ticker = self.request.query_params.get('ticker')
if ticker:
qs = qs.filter(ticker=ticker.upper())
return qs.order_by('ticker', 'date')
class AIUpdateView(APIView):
"""
POST /api/invest/ai-update/
Sync portfolio holdings (quantity only, no price).
"""
"""POST /api/invest/ai-update/ — Sync portfolio holdings (quantity only required)."""
def post(self, request):
serializer = AIUpdateSerializer(data=request.data)
@@ -120,3 +149,31 @@ class AIUpdateView(APIView):
return Response(result, status=status.HTTP_200_OK)
class AgentSummaryView(APIView):
"""GET /api/invest/agent/summary/ — agent-friendly portfolio summary."""
def get(self, request):
return Response(get_agent_summary())
class PerformanceView(APIView):
"""GET /api/invest/performance/?start=YYYY-MM-DD&end=YYYY-MM-DD&benchmarks=QQQ,SPY"""
def get(self, request):
benchmarks = request.query_params.get('benchmarks', 'QQQ,SPY')
tickers = [item.strip().upper() for item in benchmarks.split(',') if item.strip()]
return Response(
get_cashflow_adjusted_performance(
start=request.query_params.get('start'),
end=request.query_params.get('end'),
benchmark_tickers=tickers,
)
)
class RiskView(APIView):
"""GET /api/invest/risk/ — concentration and theme exposure."""
def get(self, request):
return Response(get_risk_summary())
+169
View File
@@ -0,0 +1,169 @@
from datetime import date
from decimal import Decimal
import pytest
from django.utils import timezone
from invest.models import BenchmarkPrice, CashFlow, Portfolio, PortfolioSnapshot, Stock, Transaction
@pytest.mark.django_db
def test_transaction_price_fields_are_optional(api_client):
portfolio = Portfolio.objects.create(name="Agent Test")
response = api_client.post(
"/api/invest/transactions/",
{
"portfolio": portfolio.id,
"action": "BUY",
"stock_code": "NVDA",
"quantity": "2",
"date": "2026-06-13",
},
format="json",
)
assert response.status_code == 201
tx = Transaction.objects.get()
assert tx.price_per_share is None
assert tx.currency == "USD"
assert tx.fee is None
assert response.data["price_per_share"] is None
@pytest.mark.django_db
def test_transaction_accepts_optional_price_currency_and_fee(api_client):
portfolio = Portfolio.objects.create(name="Agent Test")
response = api_client.post(
"/api/invest/transactions/",
{
"portfolio": portfolio.id,
"action": "BUY",
"stock_code": "NVDA",
"quantity": "2",
"price_per_share": "100.25",
"currency": "USD",
"fee": "1.50",
"date": "2026-06-13",
},
format="json",
)
assert response.status_code == 201
tx = Transaction.objects.get()
assert tx.price_per_share == Decimal("100.250000")
assert tx.fee == Decimal("1.500000")
assert response.data["price_per_share"] == "100.250000"
assert response.data["fee"] == "1.500000"
@pytest.mark.django_db
def test_cashflow_api_records_external_deposits(api_client):
portfolio = Portfolio.objects.create(name="Agent Test")
response = api_client.post(
"/api/invest/cashflows/",
{
"portfolio": portfolio.id,
"flow_type": "DEPOSIT",
"amount": "2500.00",
"currency": "USD",
"date": "2026-06-13",
"source": "salary",
"note": "monthly contribution",
},
format="json",
)
assert response.status_code == 201
flow = CashFlow.objects.get()
assert flow.signed_amount == Decimal("2500.00")
assert response.data["signed_amount"] == "2500.00"
@pytest.mark.django_db
def test_agent_summary_reports_cash_adjusted_return_and_concentration(api_client, monkeypatch):
portfolio = Portfolio.objects.create(name="Agent Test")
Stock.objects.create(portfolio=portfolio, stock_code="AAA", quantity=Decimal("10"))
Stock.objects.create(portfolio=portfolio, stock_code="BBB", quantity=Decimal("5"))
CashFlow.objects.create(
portfolio=portfolio,
flow_type=CashFlow.FLOW_DEPOSIT,
amount=Decimal("1000.00"),
currency="USD",
date=date(2026, 6, 1),
)
prices = {"AAA": 100.0, "BBB": 20.0}
monkeypatch.setattr("invest.services.get_current_price", lambda ticker: prices[ticker])
response = api_client.get("/api/invest/agent/summary/")
assert response.status_code == 200
payload = response.json()
assert payload["total_value"] == 1100.0
assert payload["performance"]["net_external_cash_flow"] == 1000.0
assert payload["performance"]["cash_adjusted_gain"] == 100.0
assert payload["risk"]["top_1_weight"] == pytest.approx(0.9091, rel=1e-3)
assert payload["risk"]["max_position"]["stock_code"] == "AAA"
assert payload["risk"]["concentration_level"] == "HIGH"
@pytest.mark.django_db
def test_performance_endpoint_excludes_deposits_from_gain(api_client):
portfolio = Portfolio.objects.create(name="Agent Test")
PortfolioSnapshot.objects.create(
portfolio=portfolio,
captured_at=timezone.make_aware(timezone.datetime(2026, 6, 1, 8, 0)),
total_value=Decimal("1000.00"),
)
CashFlow.objects.create(
portfolio=portfolio,
flow_type=CashFlow.FLOW_DEPOSIT,
amount=Decimal("500.00"),
currency="USD",
date=date(2026, 6, 8),
)
PortfolioSnapshot.objects.create(
portfolio=portfolio,
captured_at=timezone.make_aware(timezone.datetime(2026, 6, 15, 8, 0)),
total_value=Decimal("1700.00"),
)
BenchmarkPrice.objects.create(ticker="QQQ", date=date(2026, 6, 1), close=Decimal("100.00"))
BenchmarkPrice.objects.create(ticker="QQQ", date=date(2026, 6, 8), close=Decimal("110.00"))
BenchmarkPrice.objects.create(ticker="QQQ", date=date(2026, 6, 15), close=Decimal("120.00"))
response = api_client.get("/api/invest/performance/?start=2026-06-01&end=2026-06-15")
assert response.status_code == 200
payload = response.json()
assert payload["start_value"] == 1000.0
assert payload["end_value"] == 1700.0
assert payload["net_external_cash_flow"] == 500.0
assert payload["cash_adjusted_gain"] == 200.0
assert payload["simple_return"] == pytest.approx(0.1333, rel=1e-3)
assert payload["benchmarks"]["QQQ"]["end_value"] == pytest.approx(1745.45, rel=1e-3)
@pytest.mark.django_db
def test_dashboard_shows_agent_first_metrics(client, monkeypatch):
portfolio = Portfolio.objects.create(name="Agent Test")
Stock.objects.create(portfolio=portfolio, stock_code="AAA", quantity=Decimal("10"))
CashFlow.objects.create(
portfolio=portfolio,
flow_type=CashFlow.FLOW_DEPOSIT,
amount=Decimal("1000.00"),
currency="USD",
date=date(2026, 6, 1),
)
monkeypatch.setattr("invest.services.get_current_price", lambda ticker: 110.0)
response = client.get("/invest/")
assert response.status_code == 200
content = response.content.decode()
assert "Net Contributions" in content
assert "Investment Gain" in content
assert "Top 5 Concentration" in content
assert "Same-cashflow Benchmark" in content