diff --git a/core/settings.py b/core/settings.py index c1867ac..367af51 100644 --- a/core/settings.py +++ b/core/settings.py @@ -20,6 +20,7 @@ INSTALLED_APPS = [ 'new_theme', 'simplemde', 'markdown', # 只需要基本的markdown包 + 'invest', ] ROOT_URLCONF = 'core.urls' diff --git a/core/urls.py b/core/urls.py index 3b938e4..7b4242b 100644 --- a/core/urls.py +++ b/core/urls.py @@ -11,6 +11,7 @@ urlpatterns = [ path('admin/', admin.site.urls), # Add API URLs before locale URLs path('api/', include('links.api_urls')), # New line for API routes + path('api/invest/', include('invest.urls', namespace='invest-api')), # Media files path('media/', serve, { 'document_root': settings.MEDIA_ROOT, @@ -26,6 +27,7 @@ urlpatterns = [ path('custom//', CustomLinkView.as_view(), name='custom_link'), path('custom//edit/', LinkUpdateView.as_view(), name='custom_link_update'), + path('invest/', include('invest.urls')), # Include main app URLs with locale path('', include('links.urls')), ] diff --git a/invest/__init__.py b/invest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/invest/api_urls.py b/invest/api_urls.py new file mode 100644 index 0000000..708cdf9 --- /dev/null +++ b/invest/api_urls.py @@ -0,0 +1,19 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +from .views import ( + PortfolioViewSet, StockViewSet, TransactionViewSet, + ReportViewSet, QuotesView, AIUpdateView, +) + +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'reports', ReportViewSet, basename='invest-report') + +urlpatterns = [ + path('', include(router.urls)), + path('quotes/', QuotesView.as_view(), name='invest-quotes'), + path('ai-update/', AIUpdateView.as_view(), name='invest-ai-update'), +] diff --git a/invest/apps.py b/invest/apps.py new file mode 100644 index 0000000..387c518 --- /dev/null +++ b/invest/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class InvestConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'invest' + verbose_name = 'Investment Portfolio' diff --git a/invest/management/__init__.py b/invest/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/invest/management/commands/__init__.py b/invest/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/invest/management/commands/generate_report.py b/invest/management/commands/generate_report.py new file mode 100644 index 0000000..28de050 --- /dev/null +++ b/invest/management/commands/generate_report.py @@ -0,0 +1,126 @@ +""" +Management command: generate_report + +Usage: + python manage.py generate_report [--portfolio-id ID] [--all] [--period-days N] + +Generates a weekly portfolio performance report and saves it to the Report model. +""" +import json +import logging +from datetime import date, timedelta + +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone + +from invest.models import Portfolio, Report +from invest.services import portfolio_summary + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Generate a performance report for one or all portfolios." + + def add_arguments(self, parser): + parser.add_argument( + '--portfolio-id', type=int, default=None, + help="ID of the portfolio to report on. Defaults to all portfolios." + ) + parser.add_argument( + '--all', action='store_true', dest='all_portfolios', + help="Generate reports for all portfolios." + ) + parser.add_argument( + '--period-days', type=int, default=7, + help="Number of days the report covers (default: 7)." + ) + parser.add_argument( + '--report-type', choices=['WEEKLY', 'MANUAL'], default='MANUAL', + help="Report type label (default: MANUAL)." + ) + + def handle(self, *args, **options): + period_days = options['period_days'] + report_type = options['report_type'] + portfolio_id = options['portfolio_id'] + + if options['all_portfolios']: + portfolios = list(Portfolio.objects.all()) + if not portfolios: + raise CommandError("No portfolios found in the database.") + elif portfolio_id: + try: + portfolios = [Portfolio.objects.get(pk=portfolio_id)] + except Portfolio.DoesNotExist: + raise CommandError(f"Portfolio with id={portfolio_id} does not exist.") + else: + portfolios = list(Portfolio.objects.all()) + if not portfolios: + raise CommandError("No portfolios found. Use --portfolio-id or --all.") + + period_end = date.today() + period_start = period_end - timedelta(days=period_days - 1) + + for portfolio in portfolios: + self.stdout.write(f"Generating report for: {portfolio.name} …") + try: + report = _generate_report( + portfolio=portfolio, + period_start=period_start, + period_end=period_end, + report_type=report_type, + ) + self.stdout.write( + self.style.SUCCESS(f" ✓ Report #{report.id} saved: "{report.title}"") + ) + except Exception as exc: + logger.error("Failed to generate report for %s: %s", portfolio.name, exc, exc_info=True) + self.stderr.write(self.style.ERROR(f" ✗ Failed for {portfolio.name}: {exc}")) + + +def _generate_report( + portfolio: Portfolio, + period_start: date, + period_end: date, + report_type: str = 'MANUAL', +) -> Report: + """Build and persist a Report from the current portfolio summary.""" + summary = portfolio_summary(portfolio) + + lines = [ + f"Portfolio: {portfolio.name}", + f"Period: {period_start} → {period_end}", + "", + f" Market Value: {portfolio.base_currency} {summary['total_market_value']:,.2f}", + f" Cost Basis: {portfolio.base_currency} {summary['total_cost']:,.2f}", + f" Unrealized P&L: {portfolio.base_currency} {summary['total_unrealized_pnl']:+,.2f}", + f" Return: {summary['total_unrealized_pnl_pct']:+.2f}%", + "", + "Holdings:", + ] + + for stock in summary['stocks']: + if stock['shares_held'] > 0: + day_pct = f"{stock['change_percent']:+.2f}%" if stock['change_percent'] is not None else "N/A" + lines.append( + f" {stock['ticker']:<8} {stock['shares_held']:.4f} shares " + f"avg {stock['avg_cost']:.4f} " + f"cur {stock['current_price']:.4f} " + f"pnl {stock['unrealized_pnl']:+.2f} ({stock['unrealized_pnl_pct']:+.2f}%) " + f"day {day_pct}" + ) + + content = "\n".join(lines) + title = f"{portfolio.name} – {report_type.capitalize()} Report ({period_end})" + + report = Report.objects.create( + portfolio=portfolio, + title=title, + content=content, + period_start=period_start, + period_end=period_end, + report_type=report_type, + valuation_snapshot=summary, + ) + return report diff --git a/invest/migrations/__init__.py b/invest/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/invest/models.py b/invest/models.py new file mode 100644 index 0000000..0739d91 --- /dev/null +++ b/invest/models.py @@ -0,0 +1,142 @@ +from django.db import models +from django.core.validators import MinValueValidator +from decimal import Decimal + + +class Portfolio(models.Model): + name = models.CharField(max_length=100) + description = models.TextField(blank=True) + account_id = models.CharField(max_length=50, blank=True) + base_currency = models.CharField(max_length=10, default='USD') + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return self.name + + class Meta: + ordering = ['name'] + + +class Stock(models.Model): + """Represents a stock holding within a portfolio.""" + + portfolio = models.ForeignKey(Portfolio, on_delete=models.CASCADE, related_name='stocks') + ticker = models.CharField(max_length=20) + exchange = models.CharField( + max_length=10, blank=True, default='', + help_text="Exchange code, e.g. NASDAQ, HKG. Empty = US market default." + ) + company_name = models.CharField(max_length=200, blank=True) + shares_held = models.DecimalField( + max_digits=20, decimal_places=6, default=Decimal('0'), + validators=[MinValueValidator(Decimal('0'))] + ) + avg_cost = models.DecimalField( + max_digits=20, decimal_places=6, default=Decimal('0'), + validators=[MinValueValidator(Decimal('0'))], + help_text="Weighted average cost per share in quote_currency" + ) + quote_currency = models.CharField(max_length=10, default='USD') + is_active = models.BooleanField(default=True) + notes = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"{self.ticker} ({self.portfolio.name})" + + @property + def finnhub_symbol(self): + """Return the symbol in the format Finnhub expects.""" + if self.exchange.upper() == 'HKG': + return f"{self.ticker}.HK" + return self.ticker + + class Meta: + unique_together = [('portfolio', 'ticker', 'exchange')] + ordering = ['ticker'] + + +class Transaction(models.Model): + TX_BUY = 'BUY' + TX_SELL = 'SELL' + TX_TYPES = [(TX_BUY, 'Buy'), (TX_SELL, 'Sell')] + + stock = models.ForeignKey(Stock, on_delete=models.CASCADE, related_name='transactions') + tx_type = models.CharField(max_length=4, choices=TX_TYPES) + date = models.DateField() + price_per_share = models.DecimalField( + max_digits=20, decimal_places=6, + validators=[MinValueValidator(Decimal('0'))] + ) + shares = models.DecimalField( + max_digits=20, decimal_places=6, + validators=[MinValueValidator(Decimal('0.000001'))] + ) + fee = models.DecimalField( + max_digits=20, decimal_places=6, default=Decimal('0'), + validators=[MinValueValidator(Decimal('0'))] + ) + notes = models.TextField(blank=True) + source = models.CharField( + max_length=20, default='manual', + help_text="Origin of transaction: 'manual', 'ai', 'import'" + ) + idempotency_key = models.CharField( + max_length=100, blank=True, null=True, unique=True, + help_text="Unique key to prevent duplicate AI writes" + ) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.tx_type} {self.shares} {self.stock.ticker} @ {self.price_per_share}" + + class Meta: + ordering = ['date', 'created_at'] + + +class Report(models.Model): + REPORT_WEEKLY = 'WEEKLY' + REPORT_MANUAL = 'MANUAL' + REPORT_TYPES = [(REPORT_WEEKLY, 'Weekly'), (REPORT_MANUAL, 'Manual')] + + portfolio = models.ForeignKey( + Portfolio, on_delete=models.SET_NULL, null=True, blank=True, related_name='reports' + ) + title = models.CharField(max_length=200) + content = models.TextField() + period_start = models.DateField() + period_end = models.DateField() + generated_at = models.DateTimeField(auto_now_add=True) + report_type = models.CharField(max_length=10, choices=REPORT_TYPES, default=REPORT_WEEKLY) + valuation_snapshot = models.JSONField( + default=dict, + help_text="Snapshot of prices and holdings at time of report generation" + ) + + def __str__(self): + return f"{self.title} ({self.period_start} – {self.period_end})" + + class Meta: + ordering = ['-generated_at'] + + +class PriceCache(models.Model): + """Cache for real-time quotes fetched from Finnhub. TTL: 15 minutes.""" + + ticker = models.CharField(max_length=20) + exchange = models.CharField(max_length=10, blank=True, default='') + price = models.DecimalField(max_digits=20, decimal_places=6) + currency = models.CharField(max_length=10, default='USD') + change_percent = models.DecimalField(max_digits=10, decimal_places=4, null=True, blank=True) + prev_close = models.DecimalField(max_digits=20, decimal_places=6, null=True, blank=True) + fetched_at = models.DateTimeField(auto_now_add=True) + + class Meta: + indexes = [ + models.Index(fields=['ticker', 'exchange', 'fetched_at']), + ] + + def __str__(self): + return f"{self.ticker}: {self.price} @ {self.fetched_at}" diff --git a/invest/serializers.py b/invest/serializers.py new file mode 100644 index 0000000..62971ed --- /dev/null +++ b/invest/serializers.py @@ -0,0 +1,112 @@ +from rest_framework import serializers +from .models import Portfolio, Stock, Transaction, Report, PriceCache + + +class TransactionSerializer(serializers.ModelSerializer): + stock_ticker = serializers.CharField(source='stock.ticker', read_only=True) + stock_exchange = serializers.CharField(source='stock.exchange', read_only=True) + tx_type_display = serializers.CharField(source='get_tx_type_display', read_only=True) + + class Meta: + model = Transaction + fields = [ + 'id', 'stock', 'stock_ticker', 'stock_exchange', + 'tx_type', 'tx_type_display', 'date', + 'price_per_share', 'shares', 'fee', 'notes', + 'source', 'idempotency_key', 'created_at', + ] + read_only_fields = ['id', 'created_at'] + + +class StockSerializer(serializers.ModelSerializer): + transactions = TransactionSerializer(many=True, read_only=True) + portfolio_name = serializers.CharField(source='portfolio.name', read_only=True) + + class Meta: + model = Stock + fields = [ + 'id', 'portfolio', 'portfolio_name', 'ticker', 'exchange', + 'company_name', 'shares_held', 'avg_cost', 'quote_currency', + 'is_active', 'notes', 'created_at', 'updated_at', 'transactions', + ] + read_only_fields = ['id', 'shares_held', 'avg_cost', 'created_at', 'updated_at'] + + +class StockListSerializer(serializers.ModelSerializer): + """Lightweight serializer for list views (no transactions).""" + portfolio_name = serializers.CharField(source='portfolio.name', read_only=True) + + class Meta: + model = Stock + fields = [ + 'id', 'portfolio', 'portfolio_name', 'ticker', 'exchange', + 'company_name', 'shares_held', 'avg_cost', 'quote_currency', + 'is_active', 'notes', 'created_at', 'updated_at', + ] + read_only_fields = ['id', 'shares_held', 'avg_cost', 'created_at', 'updated_at'] + + +class PortfolioSerializer(serializers.ModelSerializer): + stocks = StockListSerializer(many=True, read_only=True) + stock_count = serializers.SerializerMethodField() + + class Meta: + model = Portfolio + fields = [ + 'id', 'name', 'description', 'account_id', 'base_currency', + 'created_at', 'updated_at', 'stocks', 'stock_count', + ] + read_only_fields = ['id', 'created_at', 'updated_at'] + + def get_stock_count(self, obj): + return obj.stocks.filter(is_active=True).count() + + +class PortfolioListSerializer(serializers.ModelSerializer): + """Lightweight serializer for list views.""" + stock_count = serializers.SerializerMethodField() + + class Meta: + model = Portfolio + fields = [ + 'id', 'name', 'description', 'account_id', 'base_currency', + 'created_at', 'updated_at', 'stock_count', + ] + read_only_fields = ['id', 'created_at', 'updated_at'] + + def get_stock_count(self, obj): + return obj.stocks.filter(is_active=True).count() + + +class ReportSerializer(serializers.ModelSerializer): + portfolio_name = serializers.CharField(source='portfolio.name', read_only=True, allow_null=True) + + class Meta: + model = Report + fields = [ + 'id', 'portfolio', 'portfolio_name', 'title', 'content', + 'period_start', 'period_end', 'generated_at', 'report_type', + 'valuation_snapshot', + ] + read_only_fields = ['id', 'generated_at'] + + +class PriceCacheSerializer(serializers.ModelSerializer): + class Meta: + model = PriceCache + fields = ['id', 'ticker', 'exchange', 'price', 'currency', + 'change_percent', 'prev_close', 'fetched_at'] + read_only_fields = ['id', 'fetched_at'] + + +class AIBatchUpdateSerializer(serializers.Serializer): + """Serializer for the AI batch update endpoint.""" + operations = serializers.ListField( + child=serializers.DictField(), + min_length=1, + help_text=( + "List of operations. Supported types: " + "upsert_portfolio, upsert_stock, add_transaction" + ), + ) + dry_run = serializers.BooleanField(default=False) diff --git a/invest/services.py b/invest/services.py new file mode 100644 index 0000000..c8b79db --- /dev/null +++ b/invest/services.py @@ -0,0 +1,380 @@ +""" +Service layer for the invest app. +All write operations go through here to ensure atomicity and consistent derived-field updates. +""" +import logging +from decimal import Decimal +from datetime import timedelta + +from django.db import transaction +from django.utils import timezone + +from .models import Portfolio, Stock, Transaction, PriceCache + +logger = logging.getLogger(__name__) + +PRICE_CACHE_TTL_MINUTES = 15 + + +# --------------------------------------------------------------------------- +# Holdings helpers +# --------------------------------------------------------------------------- + +def _recompute_holding(stock: Stock) -> None: + """ + Recompute shares_held and weighted-average cost from all transactions. + Must be called inside a transaction.atomic() block. + """ + buys = stock.transactions.filter(tx_type=Transaction.TX_BUY) + sells = stock.transactions.filter(tx_type=Transaction.TX_SELL) + + total_bought = sum((t.shares for t in buys), Decimal('0')) + total_sold = sum((t.shares for t in sells), Decimal('0')) + shares_held = total_bought - total_sold + + # Weighted average cost based only on buy transactions + total_cost = sum((t.shares * t.price_per_share + t.fee for t in buys), Decimal('0')) + avg_cost = total_cost / total_bought if total_bought > 0 else Decimal('0') + + stock.shares_held = max(shares_held, Decimal('0')) + stock.avg_cost = avg_cost + stock.save(update_fields=['shares_held', 'avg_cost', 'updated_at']) + + +# --------------------------------------------------------------------------- +# Portfolio CRUD +# --------------------------------------------------------------------------- + +def create_portfolio(name: str, description: str = '', account_id: str = '', base_currency: str = 'USD') -> Portfolio: + return Portfolio.objects.create( + name=name, + description=description, + account_id=account_id, + base_currency=base_currency, + ) + + +# --------------------------------------------------------------------------- +# Stock CRUD +# --------------------------------------------------------------------------- + +def upsert_stock( + portfolio: Portfolio, + ticker: str, + exchange: str = '', + company_name: str = '', + quote_currency: str = 'USD', + notes: str = '', +) -> tuple[Stock, bool]: + """Create or update a stock holding. Returns (stock, created).""" + stock, created = Stock.objects.get_or_create( + portfolio=portfolio, + ticker=ticker.upper(), + exchange=exchange.upper(), + defaults={ + 'company_name': company_name, + 'quote_currency': quote_currency, + 'notes': notes, + }, + ) + if not created: + update_fields = [] + if company_name and stock.company_name != company_name: + stock.company_name = company_name + update_fields.append('company_name') + if quote_currency and stock.quote_currency != quote_currency: + stock.quote_currency = quote_currency + update_fields.append('quote_currency') + if notes and stock.notes != notes: + stock.notes = notes + update_fields.append('notes') + if update_fields: + stock.save(update_fields=update_fields + ['updated_at']) + return stock, created + + +# --------------------------------------------------------------------------- +# Transaction CRUD +# --------------------------------------------------------------------------- + +def add_transaction( + stock: Stock, + tx_type: str, + date, + price_per_share: Decimal, + shares: Decimal, + fee: Decimal = Decimal('0'), + notes: str = '', + source: str = 'manual', + idempotency_key: str | None = None, +) -> Transaction: + """ + Add a buy or sell transaction and recompute holdings atomically. + If idempotency_key is provided, skip if already recorded. + """ + with transaction.atomic(): + if idempotency_key: + existing = Transaction.objects.filter(idempotency_key=idempotency_key).first() + if existing: + logger.info("Transaction with idempotency_key=%s already exists, skipping.", idempotency_key) + return existing + + tx = Transaction.objects.create( + stock=stock, + tx_type=tx_type, + date=date, + price_per_share=price_per_share, + shares=shares, + fee=fee, + notes=notes, + source=source, + idempotency_key=idempotency_key or None, + ) + _recompute_holding(stock) + return tx + + +def delete_transaction(tx: Transaction) -> None: + """Delete a transaction and recompute holdings atomically.""" + with transaction.atomic(): + stock = tx.stock + tx.delete() + _recompute_holding(stock) + + +# --------------------------------------------------------------------------- +# AI batch update +# --------------------------------------------------------------------------- + +def ai_batch_update(operations: list[dict], dry_run: bool = False) -> list[dict]: + """ + Apply a list of AI-driven operations atomically. + + Supported operation types: + - upsert_portfolio: {type, name, description, account_id, base_currency} + - upsert_stock: {type, portfolio_name, ticker, exchange, company_name, quote_currency, notes} + - add_transaction: {type, portfolio_name, ticker, exchange, tx_type, date, price_per_share, + shares, fee, notes, idempotency_key} + + Returns a list of per-operation results. + """ + results = [] + + try: + with transaction.atomic(): + for i, op in enumerate(operations): + op_type = op.get('type') + try: + result = _process_op(op) + results.append({'index': i, 'type': op_type, 'status': 'ok', 'detail': result}) + except Exception as exc: + results.append({'index': i, 'type': op_type, 'status': 'error', 'detail': str(exc)}) + raise # bubble up to abort the atomic block + + if dry_run: + raise _DryRunAbort() + + except _DryRunAbort: + pass # Rollback on dry_run is expected + + return results + + +class _DryRunAbort(Exception): + pass + + +def _process_op(op: dict) -> str: + op_type = op.get('type') + + if op_type == 'upsert_portfolio': + portfolio, created = Portfolio.objects.update_or_create( + name=op['name'], + defaults={ + 'description': op.get('description', ''), + 'account_id': op.get('account_id', ''), + 'base_currency': op.get('base_currency', 'USD'), + }, + ) + return f"Portfolio '{portfolio.name}' {'created' if created else 'updated'}" + + elif op_type == 'upsert_stock': + portfolio = Portfolio.objects.get(name=op['portfolio_name']) + stock, created = upsert_stock( + portfolio=portfolio, + ticker=op['ticker'], + exchange=op.get('exchange', ''), + company_name=op.get('company_name', ''), + quote_currency=op.get('quote_currency', 'USD'), + notes=op.get('notes', ''), + ) + return f"Stock '{stock.ticker}' in '{portfolio.name}' {'created' if created else 'updated'}" + + elif op_type == 'add_transaction': + from datetime import date as date_cls + portfolio = Portfolio.objects.get(name=op['portfolio_name']) + stock = Stock.objects.get( + portfolio=portfolio, + ticker=op['ticker'].upper(), + exchange=op.get('exchange', '').upper(), + ) + date_val = op['date'] + if isinstance(date_val, str): + from datetime import datetime + date_val = datetime.strptime(date_val, '%Y-%m-%d').date() + + tx = add_transaction( + stock=stock, + tx_type=op['tx_type'].upper(), + date=date_val, + price_per_share=Decimal(str(op['price_per_share'])), + shares=Decimal(str(op['shares'])), + fee=Decimal(str(op.get('fee', 0))), + notes=op.get('notes', ''), + source='ai', + idempotency_key=op.get('idempotency_key'), + ) + return f"Transaction {tx.id} recorded" + + else: + raise ValueError(f"Unknown operation type: {op_type!r}") + + +# --------------------------------------------------------------------------- +# Price quotes (Finnhub) +# --------------------------------------------------------------------------- + +def get_quotes(tickers_and_exchanges: list[tuple[str, str]], force_refresh: bool = False) -> dict: + """ + Fetch quotes for a list of (ticker, exchange) tuples. + Uses PriceCache with 15-minute TTL. + Returns {ticker: {price, change_percent, prev_close, currency, cached}}. + """ + import requests + from django.conf import settings + + api_key = getattr(settings, 'FINNHUB_API_KEY', '') + cutoff = timezone.now() - timedelta(minutes=PRICE_CACHE_TTL_MINUTES) + results = {} + + for ticker, exchange in tickers_and_exchanges: + cache_entry = ( + PriceCache.objects + .filter(ticker=ticker, exchange=exchange, fetched_at__gte=cutoff) + .order_by('-fetched_at') + .first() + ) + if cache_entry and not force_refresh: + results[ticker] = { + 'price': float(cache_entry.price), + 'change_percent': float(cache_entry.change_percent or 0), + 'prev_close': float(cache_entry.prev_close or 0), + 'currency': cache_entry.currency, + 'cached': True, + } + continue + + # Determine Finnhub symbol + if exchange.upper() == 'HKG': + symbol = f"{ticker}.HK" + currency = 'HKD' + else: + symbol = ticker + currency = 'USD' + + try: + resp = requests.get( + 'https://finnhub.io/api/v1/quote', + params={'symbol': symbol, 'token': api_key}, + timeout=5, + ) + data = resp.json() + price = Decimal(str(data.get('c', 0))) + prev_close = Decimal(str(data.get('pc', 0))) + change_pct = ( + ((price - prev_close) / prev_close * 100) + if prev_close and prev_close != 0 + else Decimal('0') + ) + + PriceCache.objects.create( + ticker=ticker, + exchange=exchange, + price=price, + currency=currency, + change_percent=change_pct, + prev_close=prev_close, + ) + results[ticker] = { + 'price': float(price), + 'change_percent': float(change_pct), + 'prev_close': float(prev_close), + 'currency': currency, + 'cached': False, + } + except Exception as exc: + logger.warning("Failed to fetch quote for %s: %s", symbol, exc) + results[ticker] = {'price': None, 'change_percent': None, 'prev_close': None, + 'currency': currency, 'cached': False, 'error': str(exc)} + + return results + + +# --------------------------------------------------------------------------- +# Portfolio summary +# --------------------------------------------------------------------------- + +def portfolio_summary(portfolio: Portfolio) -> dict: + """Build a full summary dict with current prices and P&L.""" + stocks = list(portfolio.stocks.prefetch_related('transactions')) + tickers = [(s.ticker, s.exchange) for s in stocks if s.shares_held > 0] + quotes = get_quotes(tickers) if tickers else {} + + stock_summaries = [] + total_cost = Decimal('0') + total_market_value = Decimal('0') + + for stock in stocks: + cost_basis = stock.shares_held * stock.avg_cost + quote = quotes.get(stock.ticker, {}) + current_price = Decimal(str(quote.get('price') or 0)) + market_value = stock.shares_held * current_price + unrealized_pnl = market_value - cost_basis + unrealized_pnl_pct = (unrealized_pnl / cost_basis * 100) if cost_basis else Decimal('0') + + stock_summaries.append({ + 'id': stock.id, + 'ticker': stock.ticker, + 'exchange': stock.exchange, + 'company_name': stock.company_name, + 'shares_held': float(stock.shares_held), + 'avg_cost': float(stock.avg_cost), + 'quote_currency': stock.quote_currency, + 'current_price': float(current_price), + 'cost_basis': float(cost_basis), + 'market_value': float(market_value), + 'unrealized_pnl': float(unrealized_pnl), + 'unrealized_pnl_pct': float(unrealized_pnl_pct), + 'change_percent': quote.get('change_percent'), + 'is_active': stock.is_active, + }) + + total_cost += cost_basis + total_market_value += market_value + + total_pnl = total_market_value - total_cost + total_pnl_pct = (total_pnl / total_cost * 100) if total_cost else Decimal('0') + + return { + 'portfolio': { + 'id': portfolio.id, + 'name': portfolio.name, + 'account_id': portfolio.account_id, + 'base_currency': portfolio.base_currency, + }, + 'stocks': stock_summaries, + 'total_cost': float(total_cost), + 'total_market_value': float(total_market_value), + 'total_unrealized_pnl': float(total_pnl), + 'total_unrealized_pnl_pct': float(total_pnl_pct), + } diff --git a/invest/template_views.py b/invest/template_views.py new file mode 100644 index 0000000..c81baa8 --- /dev/null +++ b/invest/template_views.py @@ -0,0 +1,80 @@ +"""Template views for the invest app.""" +import json +import logging + +from django.shortcuts import render, get_object_or_404 + +from .models import Portfolio, Report +from .services import portfolio_summary + +logger = logging.getLogger(__name__) + + +def dashboard(request): + """Landing page: list of portfolios with key metrics.""" + portfolios = Portfolio.objects.prefetch_related('stocks').order_by('name') + summaries = [] + for portfolio in portfolios: + try: + s = portfolio_summary(portfolio) + except Exception as exc: + logger.warning("portfolio_summary failed for %s: %s", portfolio.id, exc) + s = { + 'portfolio': {'id': portfolio.id, 'name': portfolio.name}, + 'stocks': [], + 'total_cost': 0, + 'total_market_value': 0, + 'total_unrealized_pnl': 0, + 'total_unrealized_pnl_pct': 0, + } + summaries.append(s) + + return render(request, 'invest/dashboard.html', {'summaries': summaries}) + + +def portfolio_detail(request, pk): + """Portfolio detail view with holdings table and allocation chart.""" + portfolio = get_object_or_404(Portfolio, pk=pk) + try: + summary = portfolio_summary(portfolio) + except Exception as exc: + logger.error("portfolio_summary failed for %s: %s", pk, exc) + summary = { + 'portfolio': {'id': portfolio.id, 'name': portfolio.name}, + 'stocks': [], + 'total_cost': 0, + 'total_market_value': 0, + 'total_unrealized_pnl': 0, + 'total_unrealized_pnl_pct': 0, + } + return render(request, 'invest/portfolio_detail.html', { + 'portfolio': portfolio, + 'summary': summary, + 'summary_json': json.dumps(summary), + }) + + +def portfolio_transactions(request, pk): + """Transaction history for a portfolio.""" + from .models import Transaction + portfolio = get_object_or_404(Portfolio, pk=pk) + transactions = Transaction.objects.filter( + stock__portfolio=portfolio + ).select_related('stock').order_by('-date', '-created_at') + + return render(request, 'invest/transactions.html', { + 'portfolio': portfolio, + 'transactions': transactions, + }) + + +def reports_list(request): + """List of all generated reports.""" + reports = Report.objects.select_related('portfolio').order_by('-generated_at') + return render(request, 'invest/reports.html', {'reports': reports}) + + +def report_detail(request, pk): + """Single report view.""" + report = get_object_or_404(Report.objects.select_related('portfolio'), pk=pk) + return render(request, 'invest/report_detail.html', {'report': report}) diff --git a/invest/templates/invest/base.html b/invest/templates/invest/base.html new file mode 100644 index 0000000..e9fdd7e --- /dev/null +++ b/invest/templates/invest/base.html @@ -0,0 +1,38 @@ +{% load static %} + + + + + + {% block title %}Portfolio{% endblock %} – Invest + + + + + {% block extra_head %}{% endblock %} + + + + + + +
+ {% block content %}{% endblock %} +
+ +{% block extra_js %}{% endblock %} + + diff --git a/invest/templates/invest/dashboard.html b/invest/templates/invest/dashboard.html new file mode 100644 index 0000000..d4cbdbc --- /dev/null +++ b/invest/templates/invest/dashboard.html @@ -0,0 +1,117 @@ +{% extends "invest/base.html" %} +{% load static %} + +{% block title %}Dashboard{% endblock %} + +{% block content %} +
+

Investment Portfolios

+ Prices may be delayed up to 15 min +
+ +{% if not summaries %} +
+ +

No portfolios yet.

+

Use the API or generate_report command to get started.

+
+{% endif %} + +{% for s in summaries %} +{% with p=s.portfolio %} +
+ +
+
+ + {{ p.name }} + + {% if p.account_id %} + {{ p.account_id }} + {% endif %} +
+ + View Details + +
+ + +
+
+

Market Value

+

+ {{ p.base_currency }} {{ s.total_market_value|floatformat:2 }} +

+
+
+

Cost Basis

+

+ {{ p.base_currency }} {{ s.total_cost|floatformat:2 }} +

+
+
+

Unrealized P&L

+

+ {% if s.total_unrealized_pnl >= 0 %}+{% endif %}{{ s.total_unrealized_pnl|floatformat:2 }} +

+
+
+

Return

+

+ {% if s.total_unrealized_pnl_pct >= 0 %}+{% endif %}{{ s.total_unrealized_pnl_pct|floatformat:2 }}% +

+
+
+ + + {% if s.stocks %} +
+ + + + + + + + + + + + + + {% for stock in s.stocks %} + {% if stock.shares_held > 0 %} + + + + + + + + + + {% endif %} + {% endfor %} + +
TickerSharesAvg CostPriceMarket ValueP&LDay %
+ {{ stock.ticker }} + {% if stock.exchange %}{{ stock.exchange }}{% endif %} + {% if stock.company_name %}
{{ stock.company_name }}
{% endif %} +
{{ stock.shares_held|floatformat:4 }}{{ stock.avg_cost|floatformat:4 }} + {% if stock.current_price %}{{ stock.current_price|floatformat:4 }}{% else %}{% endif %} + {{ stock.market_value|floatformat:2 }} + {% if stock.unrealized_pnl >= 0 %}+{% endif %}{{ stock.unrealized_pnl|floatformat:2 }} +
{% if stock.unrealized_pnl_pct >= 0 %}+{% endif %}{{ stock.unrealized_pnl_pct|floatformat:2 }}%
+
+ {% if stock.change_percent is not None %} + + {% if stock.change_percent >= 0 %}+{% endif %}{{ stock.change_percent|floatformat:2 }}% + + {% else %}{% endif %} +
+
+ {% endif %} +
+{% endwith %} +{% endfor %} +{% endblock %} diff --git a/invest/templates/invest/portfolio_detail.html b/invest/templates/invest/portfolio_detail.html new file mode 100644 index 0000000..0a59489 --- /dev/null +++ b/invest/templates/invest/portfolio_detail.html @@ -0,0 +1,199 @@ +{% extends "invest/base.html" %} +{% load static %} + +{% block title %}{{ portfolio.name }}{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} + + + + +
+
+

{{ portfolio.name }}

+ {% if portfolio.account_id %} +

Account: {{ portfolio.account_id }}

+ {% endif %} + {% if portfolio.description %} +

{{ portfolio.description }}

+ {% endif %} +
+ +
+ + +
+
+

Market Value

+

{{ summary.total_market_value|floatformat:2 }}

+

{{ portfolio.base_currency }}

+
+
+

Cost Basis

+

{{ summary.total_cost|floatformat:2 }}

+

{{ portfolio.base_currency }}

+
+
+

Unrealized P&L

+

+ {% if summary.total_unrealized_pnl >= 0 %}+{% endif %}{{ summary.total_unrealized_pnl|floatformat:2 }} +

+

+ {% if summary.total_unrealized_pnl_pct >= 0 %}+{% endif %}{{ summary.total_unrealized_pnl_pct|floatformat:2 }}% +

+
+
+

Holdings

+

{{ summary.stocks|length }}

+

active positions

+
+
+ +
+ +
+
+

Holdings

+ Prices may be delayed 15 min +
+
+ + + + + + + + + + + + + + {% for stock in summary.stocks %} + {% if stock.shares_held > 0 %} + + + + + + + + + + {% endif %} + {% empty %} + + + + {% endfor %} + +
StockSharesAvg CostCurrentMkt ValueP&LDay
+
+ {{ stock.ticker }} + {% if stock.exchange %}{{ stock.exchange }}{% endif %} +
+ {% if stock.company_name %} +
{{ stock.company_name }}
+ {% endif %} +
{{ stock.shares_held|floatformat:4 }}{{ stock.avg_cost|floatformat:4 }} + {% if stock.current_price %}{{ stock.current_price|floatformat:4 }}{% else %}{% endif %} + {{ stock.market_value|floatformat:2 }} + + {% if stock.unrealized_pnl >= 0 %}+{% endif %}{{ stock.unrealized_pnl|floatformat:2 }} + +
+ {% if stock.unrealized_pnl_pct >= 0 %}+{% endif %}{{ stock.unrealized_pnl_pct|floatformat:2 }}% +
+
+ {% if stock.change_percent is not None %} + + {% if stock.change_percent >= 0 %}+{% endif %}{{ stock.change_percent|floatformat:2 }}% + + {% else %}N/A{% endif %} +
No holdings with shares > 0
+
+
+ + +
+

Allocation

+
+ +
+
+
+
+{% endblock %} + +{% block extra_js %} +{{ summary_json|json_script:"summary-data" }} + +{% endblock %} diff --git a/invest/templates/invest/report_detail.html b/invest/templates/invest/report_detail.html new file mode 100644 index 0000000..112161b --- /dev/null +++ b/invest/templates/invest/report_detail.html @@ -0,0 +1,47 @@ +{% extends "invest/base.html" %} + +{% block title %}{{ report.title }}{% endblock %} + +{% block content %} + + +
+ +
+
+
+

{{ report.title }}

+

+ Period: {{ report.period_start }} – {{ report.period_end }} + {% if report.portfolio %} +  ·  {{ report.portfolio.name }} + {% endif %} +

+
+ + {{ report.get_report_type_display }} + +
+

Generated {{ report.generated_at|date:"M d, Y H:i" }}

+
+ + +
+
{{ report.content }}
+
+ + + {% if report.valuation_snapshot %} +
+

Valuation Snapshot

+
+
{{ report.valuation_snapshot }}
+
+
+ {% endif %} +
+{% endblock %} diff --git a/invest/templates/invest/reports.html b/invest/templates/invest/reports.html new file mode 100644 index 0000000..a1e3800 --- /dev/null +++ b/invest/templates/invest/reports.html @@ -0,0 +1,46 @@ +{% extends "invest/base.html" %} + +{% block title %}Reports{% endblock %} + +{% block content %} +
+

Reports

+

Weekly and manual performance reports.

+
+ +{% if not reports %} +
+ +

No reports generated yet.

+

Run python manage.py generate_report to create one.

+
+{% else %} + +{% endif %} +{% endblock %} diff --git a/invest/templates/invest/transactions.html b/invest/templates/invest/transactions.html new file mode 100644 index 0000000..68a3ee1 --- /dev/null +++ b/invest/templates/invest/transactions.html @@ -0,0 +1,80 @@ +{% extends "invest/base.html" %} + +{% block title %}Transactions – {{ portfolio.name }}{% endblock %} + +{% block content %} + + +
+

Transaction History

+ {{ transactions|length }} transaction{{ transactions|length|pluralize }} +
+ +
+ +
+ + + +
+ +
+ + + + + + + + + + + + + + + + {% for tx in transactions %} + + + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
DateTypeStockSharesPriceFeeTotalSourceNotes
{{ tx.date }} + {% if tx.tx_type == 'BUY' %} + BUY + {% else %} + SELL + {% endif %} + + {{ tx.stock.ticker }} + {% if tx.stock.exchange %}{{ tx.stock.exchange }}{% endif %} + {{ tx.shares|floatformat:4 }}{{ tx.price_per_share|floatformat:4 }}{{ tx.fee|floatformat:2 }} + {{ tx.shares|floatformat:4 }}×{{ tx.price_per_share|floatformat:4 }} + + {{ tx.source }} + {{ tx.notes }}
No transactions recorded yet.
+
+
+{% endblock %} diff --git a/invest/urls.py b/invest/urls.py new file mode 100644 index 0000000..1baab56 --- /dev/null +++ b/invest/urls.py @@ -0,0 +1,30 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +from .views import ( + PortfolioViewSet, StockViewSet, TransactionViewSet, + ReportViewSet, QuotesView, AIUpdateView, +) +from . import template_views + +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'reports', ReportViewSet, basename='invest-report') + +# API URL patterns (mounted at /api/invest/ in core/urls.py) +api_urlpatterns = [ + path('', include(router.urls)), + path('quotes/', QuotesView.as_view(), name='invest-quotes'), + path('ai-update/', AIUpdateView.as_view(), name='invest-ai-update'), +] + +# Template URL patterns (mounted at /invest/ in core/urls.py) +urlpatterns = [ + path('', template_views.dashboard, name='invest-dashboard'), + path('portfolios//', template_views.portfolio_detail, name='invest-portfolio-detail'), + path('portfolios//transactions/', template_views.portfolio_transactions, name='invest-portfolio-transactions'), + path('reports/', template_views.reports_list, name='invest-reports'), + path('reports//', template_views.report_detail, name='invest-report-detail'), +] diff --git a/invest/views.py b/invest/views.py new file mode 100644 index 0000000..01992e6 --- /dev/null +++ b/invest/views.py @@ -0,0 +1,196 @@ +import logging +from decimal import Decimal + +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.views import APIView + +from .models import Portfolio, Stock, Transaction, Report, PriceCache +from .serializers import ( + PortfolioSerializer, PortfolioListSerializer, + StockSerializer, StockListSerializer, + TransactionSerializer, ReportSerializer, + AIBatchUpdateSerializer, +) +from .services import ( + portfolio_summary, ai_batch_update, get_quotes, + create_portfolio, upsert_stock, add_transaction, delete_transaction, +) + +logger = logging.getLogger(__name__) + + +class PortfolioViewSet(viewsets.ModelViewSet): + queryset = Portfolio.objects.all() + + def get_serializer_class(self): + if self.action == 'list': + return PortfolioListSerializer + return PortfolioSerializer + + def perform_create(self, serializer): + serializer.save() + + @action(detail=True, methods=['get'], url_path='summary') + def summary(self, request, pk=None): + """Return full portfolio summary with current prices and P&L.""" + portfolio = self.get_object() + try: + data = portfolio_summary(portfolio) + return Response(data) + except Exception as exc: + logger.error("portfolio_summary failed for %s: %s", portfolio.id, exc, exc_info=True) + return Response({'error': str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @action(detail=True, methods=['get'], url_path='transactions') + def transactions(self, request, pk=None): + """List all transactions for all stocks in this portfolio.""" + portfolio = self.get_object() + txs = Transaction.objects.filter( + stock__portfolio=portfolio + ).select_related('stock').order_by('-date', '-created_at') + serializer = TransactionSerializer(txs, many=True) + return Response(serializer.data) + + +class StockViewSet(viewsets.ModelViewSet): + queryset = Stock.objects.select_related('portfolio').all() + + def get_serializer_class(self): + if self.action == 'list': + return StockListSerializer + return StockSerializer + + 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) + active_only = self.request.query_params.get('active') + if active_only and active_only.lower() in ('true', '1'): + qs = qs.filter(is_active=True) + return qs + + @action(detail=True, methods=['get'], url_path='transactions') + def transactions(self, request, pk=None): + stock = self.get_object() + txs = stock.transactions.all().order_by('-date', '-created_at') + serializer = TransactionSerializer(txs, many=True) + return Response(serializer.data) + + +class TransactionViewSet(viewsets.ModelViewSet): + queryset = Transaction.objects.select_related('stock', 'stock__portfolio').all() + serializer_class = TransactionSerializer + + def get_queryset(self): + qs = super().get_queryset() + stock_id = self.request.query_params.get('stock') + if stock_id: + qs = qs.filter(stock_id=stock_id) + portfolio_id = self.request.query_params.get('portfolio') + if portfolio_id: + qs = qs.filter(stock__portfolio_id=portfolio_id) + return qs.order_by('-date', '-created_at') + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + data = serializer.validated_data + stock = data['stock'] + try: + tx = add_transaction( + stock=stock, + tx_type=data['tx_type'], + date=data['date'], + price_per_share=data['price_per_share'], + shares=data['shares'], + fee=data.get('fee', Decimal('0')), + notes=data.get('notes', ''), + source=data.get('source', 'manual'), + idempotency_key=data.get('idempotency_key'), + ) + except Exception as exc: + return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST) + + out = TransactionSerializer(tx) + return Response(out.data, status=status.HTTP_201_CREATED) + + def destroy(self, request, *args, **kwargs): + tx = self.get_object() + try: + delete_transaction(tx) + except Exception as exc: + return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ReportViewSet(viewsets.ModelViewSet): + queryset = Report.objects.select_related('portfolio').all() + serializer_class = ReportSerializer + + 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 + + +class QuotesView(APIView): + """ + POST /api/invest/quotes/ + Body: {"tickers": [{"ticker": "AAPL", "exchange": ""}, ...], "force_refresh": false} + """ + + def post(self, request): + tickers_data = request.data.get('tickers', []) + force_refresh = request.data.get('force_refresh', False) + + if not isinstance(tickers_data, list) or not tickers_data: + return Response( + {'error': 'tickers must be a non-empty list'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + pairs = [(item.get('ticker', ''), item.get('exchange', '')) for item in tickers_data] + try: + quotes = get_quotes(pairs, force_refresh=force_refresh) + return Response(quotes) + except Exception as exc: + logger.error("get_quotes failed: %s", exc, exc_info=True) + return Response({'error': str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +class AIUpdateView(APIView): + """ + POST /api/invest/ai-update/ + Accepts a JSON body with a list of operations and applies them atomically. + Supports dry_run mode. + """ + + def post(self, request): + serializer = AIBatchUpdateSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + operations = serializer.validated_data['operations'] + dry_run = serializer.validated_data['dry_run'] + + try: + results = ai_batch_update(operations, dry_run=dry_run) + except Exception as exc: + logger.error("ai_batch_update failed: %s", exc, exc_info=True) + return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST) + + has_errors = any(r.get('status') == 'error' for r in results) + http_status = status.HTTP_200_OK if not has_errors else status.HTTP_207_MULTI_STATUS + return Response({ + 'dry_run': dry_run, + 'results': results, + 'applied': not dry_run and not has_errors, + }, status=http_status)