diff --git a/invest/api_urls.py b/invest/api_urls.py index 708cdf9..2680424 100644 --- a/invest/api_urls.py +++ b/invest/api_urls.py @@ -1,19 +1,14 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter -from .views import ( - PortfolioViewSet, StockViewSet, TransactionViewSet, - ReportViewSet, QuotesView, AIUpdateView, -) +from .views import PortfolioViewSet, StockViewSet, TransactionViewSet, 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/management/commands/generate_report.py b/invest/management/commands/generate_report.py deleted file mode 100644 index 28de050..0000000 --- a/invest/management/commands/generate_report.py +++ /dev/null @@ -1,126 +0,0 @@ -""" -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/models.py b/invest/models.py index 0739d91..f9a72c4 100644 --- a/invest/models.py +++ b/invest/models.py @@ -4,12 +4,9 @@ from decimal import Decimal class Portfolio(models.Model): + """Represents an investment account/portfolio (e.g., 'MOMO', 'User IBKR').""" 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 @@ -19,124 +16,59 @@ class Portfolio(models.Model): class Stock(models.Model): - """Represents a stock holding within a portfolio.""" - + """ + Current holdings snapshot for a portfolio. + Only stores stock_code and quantity - NO price stored. + Real-time prices fetched from Yahoo Finance on demand. + """ 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'), + stock_code = models.CharField(max_length=20, help_text="Stock ticker code, e.g., 'NVDA', '9988.HK'") + quantity = 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" + help_text="Number of shares held" ) - 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'] + unique_together = [('portfolio', 'stock_code')] + ordering = ['stock_code'] + + def __str__(self): + return f"{self.stock_code} ({self.portfolio.name})" class Transaction(models.Model): - TX_BUY = 'BUY' - TX_SELL = 'SELL' - TX_TYPES = [(TX_BUY, 'Buy'), (TX_SELL, 'Sell')] + """ + Historical buy/sell transactions. + Used to calculate avg_cost and derive P/L on demand. + """ + ACTION_BUY = 'BUY' + ACTION_SELL = 'SELL' + ACTION_CHOICES = [(ACTION_BUY, 'Buy'), (ACTION_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() + portfolio = models.ForeignKey(Portfolio, on_delete=models.CASCADE, related_name='transactions') + action = models.CharField(max_length=4, choices=ACTION_CHOICES) + stock_code = models.CharField(max_length=20, help_text="Stock ticker code, e.g., 'NVDA', '9988.HK'") + quantity = models.DecimalField( + max_digits=20, + decimal_places=6, + validators=[MinValueValidator(Decimal('0.000001'))], + help_text="Number of shares" + ) 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" + max_digits=20, + decimal_places=6, + validators=[MinValueValidator(Decimal('0'))], + help_text="Price per share at time of transaction" ) + date = models.DateField() + 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" - ) + ordering = ['-date', '-created_at'] 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}" + return f"{self.action} {self.quantity} {self.stock_code} @ {self.price_per_share} on {self.date}" diff --git a/invest/serializers.py b/invest/serializers.py index 62971ed..d7358b1 100644 --- a/invest/serializers.py +++ b/invest/serializers.py @@ -1,112 +1,91 @@ from rest_framework import serializers -from .models import Portfolio, Stock, Transaction, Report, PriceCache +from .models import Portfolio, Stock, Transaction + + +class StockSerializer(serializers.ModelSerializer): + class Meta: + model = Stock + fields = ['id', 'portfolio', 'stock_code', 'quantity'] + read_only_fields = ['id'] 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) + action_display = serializers.CharField(source='get_action_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', + 'id', 'portfolio', 'action', 'action_display', + 'stock_code', 'quantity', 'price_per_share', 'date', + '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() + stocks = StockSerializer(many=True, read_only=True) 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() + fields = ['id', 'name', 'created_at', 'stocks'] + read_only_fields = ['id', 'created_at'] 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'] + fields = ['id', 'name', 'created_at', 'stock_count'] + read_only_fields = ['id', 'created_at'] def get_stock_count(self, obj): - return obj.stocks.filter(is_active=True).count() + return obj.stocks.count() -class ReportSerializer(serializers.ModelSerializer): - portfolio_name = serializers.CharField(source='portfolio.name', read_only=True, allow_null=True) +# --------------------------------------------------------------------------+ +# Holdings (with real-time prices) | +# -------------------------------------------------------------------------+ - 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 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() -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 PortfolioHoldingsSerializer(serializers.Serializer): + portfolio_id = serializers.IntegerField() + 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 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) +# --------------------------------------------------------------------------+ +# AI Update | +# -------------------------------------------------------------------------+ + +class AIHoldingInputSerializer(serializers.Serializer): + stock_code = serializers.CharField() + quantity = serializers.FloatField() + avg_cost = serializers.FloatField(required=False, default=0.0) + + +class AIUpdateSerializer(serializers.Serializer): + portfolio_id = serializers.IntegerField() + holdings = AIHoldingInputSerializer(many=True) + reset = serializers.BooleanField(default=False) + + +class AIUpdateResultSerializer(serializers.Serializer): + stock_code = serializers.CharField() + quantity = serializers.FloatField() + avg_cost = serializers.FloatField() + stock_created = serializers.BooleanField() + tx_status = serializers.CharField() diff --git a/invest/services.py b/invest/services.py index c8b79db..bfdb822 100644 --- a/invest/services.py +++ b/invest/services.py @@ -1,96 +1,257 @@ """ Service layer for the invest app. -All write operations go through here to ensure atomicity and consistent derived-field updates. +Real-time prices fetched from Yahoo Finance on demand using yfinance. """ import logging from decimal import Decimal -from datetime import timedelta +from datetime import datetime, date +from typing import Optional from django.db import transaction -from django.utils import timezone -from .models import Portfolio, Stock, Transaction, PriceCache +from .models import Portfolio, Stock, Transaction logger = logging.getLogger(__name__) -PRICE_CACHE_TTL_MINUTES = 15 +# In-memory cache for price failures (not persisted in DB) +_price_cache: dict[str, tuple[float, datetime]] = {} +_PRICE_CACHE_TTL_SECONDS = 300 # 5 minutes # --------------------------------------------------------------------------- -# Holdings helpers +# Price fetching via yfinance # --------------------------------------------------------------------------- -def _recompute_holding(stock: Stock) -> None: +def _get_yfinance_price(stock_code: str) -> Optional[float]: """ - Recompute shares_held and weighted-average cost from all transactions. - Must be called inside a transaction.atomic() block. + Fetch current price from Yahoo Finance using yfinance. + Returns None if fetch fails. """ - buys = stock.transactions.filter(tx_type=Transaction.TX_BUY) - sells = stock.transactions.filter(tx_type=Transaction.TX_SELL) + try: + import yfinance as yf + ticker = yf.Ticker(stock_code) + hist = ticker.history(period="1d") + if hist.empty: + return None + return float(hist["Close"].iloc[-1]) + except Exception as exc: + logger.warning("yfinance failed for %s: %s", stock_code, exc) + return None - 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') +def get_current_price(stock_code: str) -> Optional[float]: + """ + Get current price for a stock, with simple in-memory cache fallback. + """ + now = datetime.now() + cached = _price_cache.get(stock_code) - stock.shares_held = max(shares_held, Decimal('0')) - stock.avg_cost = avg_cost - stock.save(update_fields=['shares_held', 'avg_cost', 'updated_at']) + # Return cached price if fresh enough + if cached: + price, cached_at = cached + if (now - cached_at).total_seconds() < _PRICE_CACHE_TTL_SECONDS: + return price + + # Fetch fresh price + price = _get_yfinance_price(stock_code) + + if price is not None: + _price_cache[stock_code] = (price, now) + return price + + # Fallback to stale cache if fetch failed + if cached: + logger.info("Using stale cached price for %s", stock_code) + return cached[0] + + return None # --------------------------------------------------------------------------- -# Portfolio CRUD +# Holdings calculation from transactions # --------------------------------------------------------------------------- -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, - ) +def calculate_holdings_from_transactions(portfolio: Portfolio) -> dict[str, dict]: + """ + Calculate current holdings (stock_code -> {quantity, avg_cost}) from transactions. + """ + holdings: dict[str, dict] = {} + + txs = Transaction.objects.filter(portfolio=portfolio).order_by('date', 'created_at') + + for tx in txs: + code = tx.stock_code + if code not in holdings: + holdings[code] = { + 'quantity': Decimal('0'), + 'total_cost': Decimal('0'), + } + + if tx.action == Transaction.ACTION_BUY: + holdings[code]['quantity'] += tx.quantity + holdings[code]['total_cost'] += tx.quantity * tx.price_per_share + elif tx.action == Transaction.ACTION_SELL: + # Reduce quantity, proportionally reduce cost basis + if holdings[code]['quantity'] > 0: + sold_ratio = min(tx.quantity / holdings[code]['quantity'], Decimal('1')) + holdings[code]['total_cost'] *= (1 - sold_ratio) + holdings[code]['quantity'] -= tx.quantity + if holdings[code]['quantity'] < 0: + holdings[code]['quantity'] = Decimal('0') + + # Calculate avg_cost + for code, data in holdings.items(): + if data['quantity'] > 0: + data['avg_cost'] = float(data['total_cost'] / data['quantity']) + else: + data['avg_cost'] = 0.0 + data['quantity'] = float(data['quantity']) + data['total_cost'] = float(data['total_cost']) + + return holdings # --------------------------------------------------------------------------- -# Stock CRUD +# Portfolio holdings with real-time prices # --------------------------------------------------------------------------- -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 +def get_portfolio_holdings(portfolio: Portfolio) -> dict: + """ + Get portfolio holdings with real-time prices from Yahoo Finance. + Combines Stock snapshots with transaction history for avg_cost. + """ + # Get current Stock snapshot + stocks = {s.stock_code: float(s.quantity) for s in portfolio.stocks.all()} + + # Get holdings from transactions + tx_holdings = calculate_holdings_from_transactions(portfolio) + + # Merge: use Stock quantity as source of truth, tx_holdings for avg_cost + holdings_list = [] + total_value = Decimal('0') + total_cost = Decimal('0') + + all_codes = set(stocks.keys()) | set(tx_holdings.keys()) + + for code in all_codes: + quantity = stocks.get(code, tx_holdings.get(code, {}).get('quantity', 0)) + if isinstance(quantity, Decimal): + quantity = float(quantity) + + if quantity <= 0: + continue + + tx_data = tx_holdings.get(code, {}) + avg_cost = tx_data.get('avg_cost', 0.0) + cost = Decimal(str(avg_cost)) * Decimal(str(quantity)) + + current_price = get_current_price(code) + if current_price is None: + current_price = 0.0 + + value = Decimal(str(current_price)) * Decimal(str(quantity)) + pnl = value - cost + pnl_pct = (float(pnl / cost * 100) if cost > 0 else 0.0) if cost != 0 else 0.0 + + holdings_list.append({ + 'stock_code': code, + 'quantity': quantity, + 'avg_cost': avg_cost, + 'current_price': current_price, + 'current_value': float(value), + 'unrealized_pnl': float(pnl), + 'unrealized_pnl_pct': round(pnl_pct, 2), + }) + + total_value += value + total_cost += cost + + total_pnl = total_value - total_cost + total_pnl_pct = (float(total_pnl / total_cost * 100) if total_cost > 0 else 0.0) if total_cost != 0 else 0.0 + + return { + 'portfolio_id': portfolio.id, + 'portfolio_name': portfolio.name, + 'holdings': holdings_list, + 'total_value': float(total_value), + 'total_cost': float(total_cost), + 'total_pnl': float(total_pnl), + 'total_pnl_pct': round(total_pnl_pct, 2), + } + + +# --------------------------------------------------------------------------- +# AI update - sync holdings from AI +# --------------------------------------------------------------------------- + +def ai_update_holdings(portfolio: Portfolio, holdings: list[dict], reset: bool = False) -> dict: + """ + AI updates portfolio holdings. + Creates/updates Stock records and corresponding BUY transactions. + + Args: + portfolio: Portfolio to update + holdings: List of {"stock_code": str, "quantity": float, "avg_cost": float} + reset: If True, clear existing holdings first + """ + results = [] + + with transaction.atomic(): + if reset: + # Delete existing stocks and transactions + portfolio.stocks.all().delete() + portfolio.transactions.all().delete() + + for item in holdings: + stock_code = item['stock_code'] + quantity = Decimal(str(item['quantity'])) + avg_cost = Decimal(str(item.get('avg_cost', 0))) + + # Create or update Stock + stock, created = Stock.objects.update_or_create( + portfolio=portfolio, + stock_code=stock_code, + defaults={'quantity': quantity} + ) + + # Create a BUY transaction to record the holding + if quantity > 0 and avg_cost > 0: + # Check if transaction already exists for this stock_code with same avg_cost + # (to avoid duplicates on re-runs) + existing_tx = Transaction.objects.filter( + portfolio=portfolio, + stock_code=stock_code, + action=Transaction.ACTION_BUY, + price_per_share=avg_cost, + ).first() + + if not existing_tx: + Transaction.objects.create( + portfolio=portfolio, + action=Transaction.ACTION_BUY, + stock_code=stock_code, + quantity=quantity, + price_per_share=avg_cost, + date=date.today(), + ) + tx_status = 'created' + else: + tx_status = 'skipped_existing' + + results.append({ + 'stock_code': stock_code, + 'quantity': float(quantity), + 'avg_cost': float(avg_cost), + 'stock_created': created, + 'tx_status': tx_status if quantity > 0 and avg_cost > 0 else 'skipped', + }) + + return { + 'portfolio_id': portfolio.id, + 'portfolio_name': portfolio.name, + 'reset': reset, + 'results': results, + } # --------------------------------------------------------------------------- @@ -98,283 +259,19 @@ def upsert_stock( # --------------------------------------------------------------------------- def add_transaction( - stock: Stock, - tx_type: str, - date, + portfolio: Portfolio, + action: str, + stock_code: str, + quantity: Decimal, price_per_share: Decimal, - shares: Decimal, - fee: Decimal = Decimal('0'), - notes: str = '', - source: str = 'manual', - idempotency_key: str | None = None, + date: date, ) -> 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), - } + """Add a buy or sell transaction.""" + return Transaction.objects.create( + portfolio=portfolio, + action=action.upper(), + stock_code=stock_code.upper(), + quantity=quantity, + price_per_share=price_per_share, + date=date, + ) diff --git a/invest/template_views.py b/invest/template_views.py index c81baa8..e8b4670 100644 --- a/invest/template_views.py +++ b/invest/template_views.py @@ -4,8 +4,8 @@ import logging from django.shortcuts import render, get_object_or_404 -from .models import Portfolio, Report -from .services import portfolio_summary +from .models import Portfolio +from .services import get_portfolio_holdings logger = logging.getLogger(__name__) @@ -14,39 +14,49 @@ 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 = { + s = get_portfolio_holdings(portfolio) + # Simplify for dashboard display + summaries.append({ 'portfolio': {'id': portfolio.id, 'name': portfolio.name}, - 'stocks': [], + 'holdings': s['holdings'], + 'total_value': s['total_value'], + 'total_cost': s['total_cost'], + 'total_pnl': s['total_pnl'], + 'total_pnl_pct': s['total_pnl_pct'], + }) + except Exception as exc: + logger.warning("get_portfolio_holdings failed for %s: %s", portfolio.id, exc) + summaries.append({ + 'portfolio': {'id': portfolio.id, 'name': portfolio.name}, + 'holdings': [], + 'total_value': 0, 'total_cost': 0, - 'total_market_value': 0, - 'total_unrealized_pnl': 0, - 'total_unrealized_pnl_pct': 0, - } - summaries.append(s) + 'total_pnl': 0, + 'total_pnl_pct': 0, + }) return render(request, 'invest/dashboard.html', {'summaries': summaries}) def portfolio_detail(request, pk): - """Portfolio detail view with holdings table and allocation chart.""" + """Portfolio detail view with holdings table.""" portfolio = get_object_or_404(Portfolio, pk=pk) try: - summary = portfolio_summary(portfolio) + summary = get_portfolio_holdings(portfolio) except Exception as exc: - logger.error("portfolio_summary failed for %s: %s", pk, exc) + logger.error("get_portfolio_holdings failed for %s: %s", pk, exc) summary = { 'portfolio': {'id': portfolio.id, 'name': portfolio.name}, - 'stocks': [], + 'holdings': [], + 'total_value': 0, 'total_cost': 0, - 'total_market_value': 0, - 'total_unrealized_pnl': 0, - 'total_unrealized_pnl_pct': 0, + 'total_pnl': 0, + 'total_pnl_pct': 0, } + return render(request, 'invest/portfolio_detail.html', { 'portfolio': portfolio, 'summary': summary, @@ -56,25 +66,10 @@ def portfolio_detail(request, pk): 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') + transactions = portfolio.transactions.all().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 index e9fdd7e..0cbd8b1 100644 --- a/invest/templates/invest/base.html +++ b/invest/templates/invest/base.html @@ -21,7 +21,6 @@ Invest Portfolios - Reports GoLinks diff --git a/invest/templates/invest/dashboard.html b/invest/templates/invest/dashboard.html index d4cbdbc..fe2d68b 100644 --- a/invest/templates/invest/dashboard.html +++ b/invest/templates/invest/dashboard.html @@ -6,14 +6,13 @@ {% 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.

+

Use the API to create a portfolio and add holdings.

{% endif %} @@ -26,9 +25,6 @@
{{ p.name }} - {% if p.account_id %} - {{ p.account_id }} - {% endif %} View Details @@ -38,74 +34,65 @@
-

Market Value

-

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

+

Total Value

+

$ {{ s.total_value|floatformat:2 }}

Cost Basis

-

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

+

$ {{ s.total_cost|floatformat:2 }}

-

Unrealized P&L

-

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

P&L

+

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

Return

-

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

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

- - {% if s.stocks %} + + {% if s.holdings %}
- - + + - - + + - + - {% for stock in s.stocks %} - {% if stock.shares_held > 0 %} + {% for stock in s.holdings %} - - + + - + - - {% endif %} {% endfor %}
TickerSharesStockQty Avg CostPriceMarket ValueCurrentValue P&LDay %P&L %
- {{ stock.ticker }} - {% if stock.exchange %}{{ stock.exchange }}{% endif %} - {% if stock.company_name %}
{{ stock.company_name }}
{% endif %} + {{ stock.stock_code }}
{{ stock.shares_held|floatformat:4 }}{{ stock.avg_cost|floatformat:4 }}{{ stock.quantity|floatformat:2 }}$ {{ stock.avg_cost|floatformat:2 }} - {% if stock.current_price %}{{ stock.current_price|floatformat:4 }}{% else %}{% endif %} + {% if stock.current_price > 0 %} + $ {{ stock.current_price|floatformat:2 }} + {% else %} + + {% endif %} {{ stock.market_value|floatformat:2 }}$ {{ stock.current_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 %} + + {% if stock.unrealized_pnl_pct >= 0 %}+{% endif %}{{ stock.unrealized_pnl_pct|floatformat:2 }}%
diff --git a/invest/templates/invest/portfolio_detail.html b/invest/templates/invest/portfolio_detail.html index 0a59489..cc5e321 100644 --- a/invest/templates/invest/portfolio_detail.html +++ b/invest/templates/invest/portfolio_detail.html @@ -1,199 +1,100 @@ {% extends "invest/base.html" %} {% load static %} -{% block title %}{{ portfolio.name }}{% endblock %} - -{% block extra_head %} - -{% endblock %} +{% block title %}{{ portfolio.name }} - Portfolio{% 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 +
+ +
+
+

{{ portfolio.name }}

+

Portfolio #{{ portfolio.id }}

-
- - - - - - - - - - - - - - {% 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

-
- + +
+
+

Total Value

+

$ {{ summary.total_value|floatformat:2 }}

+
+
+

Cost Basis

+

$ {{ summary.total_cost|floatformat:2 }}

+
+
+

P&L

+

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

+
+
+

Return

+

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

-
+ + + {% if summary.holdings %} +
+

Holdings

+ + + + + + + + + + + + + + {% for stock in summary.holdings %} + + + + + + + + + + {% endfor %} + +
StockQtyAvg CostCurrentValueP&LP&L %
+ {{ stock.stock_code }} + {{ stock.quantity|floatformat:2 }}$ {{ stock.avg_cost|floatformat:2 }} + {% if stock.current_price > 0 %} + $ {{ stock.current_price|floatformat:2 }} + {% else %} + + {% endif %} + $ {{ stock.current_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 }}% +
+
+ {% else %} +
+

No holdings in this portfolio.

+
+ {% endif %}
{% 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 deleted file mode 100644 index 112161b..0000000 --- a/invest/templates/invest/report_detail.html +++ /dev/null @@ -1,47 +0,0 @@ -{% 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 deleted file mode 100644 index a1e3800..0000000 --- a/invest/templates/invest/reports.html +++ /dev/null @@ -1,46 +0,0 @@ -{% 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 index 68a3ee1..271a2d6 100644 --- a/invest/templates/invest/transactions.html +++ b/invest/templates/invest/transactions.html @@ -1,80 +1,62 @@ {% extends "invest/base.html" %} +{% load static %} -{% block title %}Transactions – {{ portfolio.name }}{% endblock %} +{% block title %}{{ portfolio.name }} - Transactions{% endblock %} {% block content %} - - -
-

Transaction History

- {{ transactions|length }} transaction{{ transactions|length|pluralize }} + -
- -
- - - +
+ +
+
+

{{ portfolio.name }}

+

Transaction History

+
+ + View Portfolio +
-
+ {% if transactions %} +
- - - - - - - - - - + + + + + + + {% for tx in transactions %} - - - + + - - - - - - - - - {% empty %} - - + + + + {% endfor %}
DateTypeStockSharesPriceFeeTotalSourceNotes
DateActionStockQtyPriceTotal
{{ tx.date }} - {% if tx.tx_type == 'BUY' %} - BUY - {% else %} - SELL - {% endif %} +
{{ tx.date|date:"Y-m-d" }} + + {{ tx.action }} + - {{ 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.{{ tx.stock_code }}{{ tx.quantity|floatformat:2 }}$ {{ tx.price_per_share|floatformat:2 }}$ {{ tx.total|floatformat:2 }}
+ {% else %} +
+

No transactions in this portfolio.

+
+ {% endif %}
{% endblock %} diff --git a/invest/urls.py b/invest/urls.py index 1baab56..fdbb9b6 100644 --- a/invest/urls.py +++ b/invest/urls.py @@ -1,22 +1,17 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter -from .views import ( - PortfolioViewSet, StockViewSet, TransactionViewSet, - ReportViewSet, QuotesView, AIUpdateView, -) +from .views import PortfolioViewSet, StockViewSet, TransactionViewSet, 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'), ] @@ -25,6 +20,4 @@ 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 index 01992e6..1919743 100644 --- a/invest/views.py +++ b/invest/views.py @@ -1,118 +1,95 @@ 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 .models import Portfolio, Stock, Transaction from .serializers import ( PortfolioSerializer, PortfolioListSerializer, - StockSerializer, StockListSerializer, - TransactionSerializer, ReportSerializer, - AIBatchUpdateSerializer, + StockSerializer, TransactionSerializer, + AIUpdateSerializer, + PortfolioHoldingsSerializer, ) from .services import ( - portfolio_summary, ai_batch_update, get_quotes, - create_portfolio, upsert_stock, add_transaction, delete_transaction, + get_portfolio_holdings, ai_update_holdings, add_transaction, ) logger = logging.getLogger(__name__) class PortfolioViewSet(viewsets.ModelViewSet): - queryset = Portfolio.objects.all() + queryset = Portfolio.objects.prefetch_related('stocks').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.""" + @action(detail=True, methods=['get'], url_path='holdings') + def holdings(self, request, pk=None): + """Return holdings with real-time prices from Yahoo Finance.""" portfolio = self.get_object() try: - data = portfolio_summary(portfolio) + data = get_portfolio_holdings(portfolio) return Response(data) except Exception as exc: - logger.error("portfolio_summary failed for %s: %s", portfolio.id, exc, exc_info=True) + logger.error("get_portfolio_holdings 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.""" + """List all transactions for this portfolio.""" portfolio = self.get_object() - txs = Transaction.objects.filter( - stock__portfolio=portfolio - ).select_related('stock').order_by('-date', '-created_at') + txs = portfolio.transactions.all().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 + serializer_class = 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() + queryset = Transaction.objects.select_related('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) + qs = qs.filter(portfolio_id=portfolio_id) + stock_code = self.request.query_params.get('stock_code') + if stock_code: + qs = qs.filter(stock_code=stock_code.upper()) return qs.order_by('-date', '-created_at') def create(self, request, *args, **kwargs): + """Create a new transaction.""" serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) data = serializer.validated_data - stock = data['stock'] + portfolio = data['portfolio'] + try: tx = add_transaction( - stock=stock, - tx_type=data['tx_type'], - date=data['date'], + portfolio=portfolio, + action=data['action'], + stock_code=data['stock_code'], + quantity=data['quantity'], 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'), + date=data['date'], ) except Exception as exc: return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST) @@ -120,77 +97,29 @@ class TransactionViewSet(viewsets.ModelViewSet): 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. + AI updates portfolio holdings with a simplified payload. """ def post(self, request): - serializer = AIBatchUpdateSerializer(data=request.data) + serializer = AIUpdateSerializer(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'] + data = serializer.validated_data + portfolio = get_object_or_404(Portfolio, pk=data['portfolio_id']) try: - results = ai_batch_update(operations, dry_run=dry_run) + result = ai_update_holdings( + portfolio=portfolio, + holdings=data['holdings'], + reset=data['reset'], + ) except Exception as exc: - logger.error("ai_batch_update failed: %s", exc, exc_info=True) + logger.error("ai_update_holdings 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) + return Response(result, status=status.HTTP_200_OK)