diff --git a/core/settings.py b/core/settings.py index f6f35fd..ac7d1e4 100644 --- a/core/settings.py +++ b/core/settings.py @@ -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'), } } diff --git a/invest/api_urls.py b/invest/api_urls.py index 2680424..22fd052 100644 --- a/invest/api_urls.py +++ b/invest/api_urls.py @@ -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'), ] diff --git a/invest/migrations/0003_benchmarkprice.py b/invest/migrations/0003_benchmarkprice.py index ca63072..6f8ea62 100644 --- a/invest/migrations/0003_benchmarkprice.py +++ b/invest/migrations/0003_benchmarkprice.py @@ -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')}, - }, ), ] diff --git a/invest/migrations/0004_cashflow_transaction_agent_fields.py b/invest/migrations/0004_cashflow_transaction_agent_fields.py new file mode 100644 index 0000000..eebbc5b --- /dev/null +++ b/invest/migrations/0004_cashflow_transaction_agent_fields.py @@ -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'), + ), + ] diff --git a/invest/models.py b/invest/models.py index 453c135..03acd6f 100644 --- a/invest/models.py +++ b/invest/models.py @@ -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')] diff --git a/invest/serializers.py b/invest/serializers.py index b99f380..416ded8 100644 --- a/invest/serializers.py +++ b/invest/serializers.py @@ -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() diff --git a/invest/services.py b/invest/services.py index fa77354..4229ce2 100644 --- a/invest/services.py +++ b/invest/services.py @@ -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, }) diff --git a/invest/tasks.py b/invest/tasks.py index 1ab9434..2a043d4 100644 --- a/invest/tasks.py +++ b/invest/tasks.py @@ -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() diff --git a/invest/template_views.py b/invest/template_views.py index c5c2d80..376149b 100644 --- a/invest/template_views.py +++ b/invest/template_views.py @@ -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) diff --git a/invest/templates/invest/dashboard.html b/invest/templates/invest/dashboard.html index 3bbc0c5..b74e90e 100644 --- a/invest/templates/invest/dashboard.html +++ b/invest/templates/invest/dashboard.html @@ -5,201 +5,225 @@ {% endblock %} -{% block title %}Dashboard{% endblock %} +{% block title %}Invest Dashboard{% endblock %} {% block content %}
Snapshot
+{{ fy_label }} Snapshot
Total Value
{% if overview.this_week_total is not None %} -${{ overview.this_week_total|floatformat:0 }}
+${{ overview.this_week_total|floatformat:0 }}
{% else %} -—
+—
{% endif %} -- Across {{ overview.portfolio_count }} portfolio{{ overview.portfolio_count|pluralize }} -
+Across {{ overview.portfolio_count }} portfolio{{ overview.portfolio_count|pluralize }}
Net Contributions
+${{ net_contributions|floatformat:0 }}
+External deposits minus withdrawals
+Investment Gain
++ {% if performance.cash_adjusted_gain >= 0 %}+{% endif %}${{ performance.cash_adjusted_gain|floatformat:0 }} +
+Cash-flow adjusted
+Top 5 Concentration
++ {% widthratio risk.top_5_weight 1 100 %}% +
+Risk: {{ risk.concentration_level }}
+This Week
{% if overview.week_gain is not None %} -+
{% if overview.week_gain >= 0 %}+{% endif %}${{ overview.week_gain|floatformat:0 }}
-- {% if overview.week_gain >= 0 %}+{% endif %}{{ overview.week_change_pct|floatformat:2 }}% vs last week -
+{% if overview.week_gain >= 0 %}+{% endif %}{{ overview.week_change_pct|floatformat:2 }}% vs last snapshot
{% else %} -—
-No prior snapshot
+—
+No prior snapshot
{% endif %}Week Change
- {% if overview.week_change_pct is not None %} -- {% if overview.week_change_pct >= 0 %}+{% endif %}{{ overview.week_change_pct|floatformat:2 }}% -
- {% if overview.last_week_date %} -Last: {{ overview.last_week_date|date:"M j" }}
- {% endif %} +Money Weighted Return
+ {% if performance.money_weighted_return is not None %} +{% widthratio performance.money_weighted_return 1 100 %}%
{% else %} -—
-Need 2+ snapshots
+—
{% endif %} +IRR based on cash flows
Last Snapshot
{% if overview.this_week_date %} -{{ overview.this_week_date|date:"M j" }}
-{{ overview.this_week_date|date:"l, Y" }}
+{{ overview.this_week_date|date:"M j" }}
+{{ overview.this_week_date|date:"l, Y" }}
{% else %} -—
-No snapshots yet
+—
+No snapshots yet
{% endif %}Same-cashflow Benchmark
+Actual end value
+${{ performance.end_value|floatformat:0 }}
+Same cash flows into {{ ticker }}
+${{ bench.end_value|floatformat:0 }}
+Return {% if bench.simple_return is not None %}{% widthratio bench.simple_return 1 100 %}%{% else %}—{% endif %}
+- Performance vs Benchmarks -
- +{{ fy_label }} Performance vs Benchmarks
+ +Snapshot value chart; cash-flow-adjusted metrics are shown in the cards above.
- Snapshots captured every Saturday 08:00 · Values in portfolio's quote currency -
+ +Risk Overview
+Top 1
{% widthratio risk.top_1_weight 1 100 %}%
Top 3
{% widthratio risk.top_3_weight 1 100 %}%
Semiconductors
{% widthratio risk.theme_exposure.semiconductors 1 100 %}%
AI / Cloud
{% widthratio risk.theme_exposure.ai_cloud 1 100 %}%
| Ticker | +Value | +Weight | +Portfolio | +
|---|---|---|---|
| {{ position.stock_code }} | +${{ position.current_value|floatformat:0 }} | +{% widthratio position.weight 1 100 %}% | +{{ position.portfolio_name }} | +
Snapshots captured every Saturday 08:00 · Prices are best-effort market data · Transaction prices are optional for AI sync
+ + {% if all_holdings %}- {{ group.portfolio.name }} -
-- {{ group.position_count }} position{{ group.position_count|pluralize }} - {% if overview.this_week_date %}· Snapshot {{ overview.this_week_date|date:"j M Y" }}{% endif %} -
+{{ group.portfolio.name }}
+{{ group.position_count }} position{{ group.position_count|pluralize }}{% if overview.this_week_date %} · Snapshot {{ overview.this_week_date|date:"j M Y" }}{% endif %}
- {% if group.total_value %}${{ group.total_value|floatformat:0 }}{% else %}—{% endif %} -
+{% if group.this_week_value is not None %}${{ group.this_week_value|floatformat:0 }}{% else %}—{% endif %}
{% if group.change is not None %} -- {% if group.change >= 0 %}+{% endif %}${{ group.change|floatformat:0 }} - ({% if group.change_pct >= 0 %}+{% endif %}{{ group.change_pct|floatformat:1 }}%) -
+{% if group.change >= 0 %}+{% endif %}${{ group.change|floatformat:0 }} ({% if group.change_pct >= 0 %}+{% endif %}{{ group.change_pct|floatformat:1 }}%)
{% else %}No prior snapshot
{% endif %}| Ticker | -Qty | -Price | -Week Change | -Mkt Value | +Ticker | +Qty | +Price | +Week Change | +Mkt Value |
|---|---|---|---|---|---|---|---|---|---|
| - - {{ stock.stock_code }} - - | -{{ stock.quantity|floatformat:0 }} | -- {% if stock.current_price %}${{ stock.current_price|floatformat:2 }}{% else %}—{% 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 %} - | -- {% if stock.current_value %}${{ stock.current_value|floatformat:0 }}{% else %}—{% endif %} - | +{{ stock.stock_code }} | +{{ stock.quantity|floatformat:0 }} | +{% if stock.current_price %}${{ stock.current_price|floatformat:2 }}{% else %}—{% 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 %} | +{% if stock.current_value %}${{ stock.current_value|floatformat:0 }}{% else %}—{% endif %} |
Transaction History
-Transaction History
+| Date | -Portfolio | -Action | -Ticker | -Qty | +Date | +Portfolio | +Action | +Ticker | +Qty | +Price | +Fee |
|---|---|---|---|---|---|---|---|---|---|---|---|
| {{ tx.date|date:"j M Y" }} | -{{ tx.portfolio.name }} | -- {% if tx.action == 'BUY' %} - BUY - {% else %} - SELL - {% endif %} - | -{{ tx.stock_code }} | -{{ tx.quantity|floatformat:0 }} | +|||||||
| {{ tx.date|date:"j M Y" }} | +{{ tx.portfolio.name }} | +{{ tx.action }} | +{{ tx.stock_code }} | +{{ tx.quantity|floatformat:0 }} | +{% if tx.price_per_share %}${{ tx.price_per_share|floatformat:2 }}{% else %}optional{% endif %} | +{% if tx.fee %}${{ tx.fee|floatformat:2 }}{% else %}—{% endif %} |