mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
Merge pull request #92 from wahyd4/feat/invest-agent-performance-main
feat: add cashflow-adjusted invest dashboard to main
This commit is contained in:
+2
-2
@@ -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
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 (Jul–Jun)
|
||||
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)
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user