feat(invest): simplify model - Portfolio/Stock/Transaction, real-time P/L via yfinance

This commit is contained in:
OpenClaw Sub-agent
2026-04-18 21:25:48 +10:00
parent f44d76e9ad
commit c34806a2e6
14 changed files with 560 additions and 1190 deletions
+1 -6
View File
@@ -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'),
]
@@ -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
+41 -109
View File
@@ -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}"
+59 -80
View File
@@ -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()
+237 -340
View File
@@ -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,
)
+30 -35
View File
@@ -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})
-1
View File
@@ -21,7 +21,6 @@
<i class="fas fa-chart-line mr-2 text-green-400"></i>Invest
</a>
<a href="{% url 'invest-dashboard' %}" class="text-gray-300 hover:text-white text-sm">Portfolios</a>
<a href="{% url 'invest-reports' %}" class="text-gray-300 hover:text-white text-sm">Reports</a>
</div>
<a href="{% url 'link_list' %}" class="text-gray-400 hover:text-white text-sm">
<i class="fas fa-arrow-left mr-1"></i>GoLinks
+28 -41
View File
@@ -6,14 +6,13 @@
{% block content %}
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900">Investment Portfolios</h1>
<span class="text-sm text-gray-500">Prices may be delayed up to 15 min</span>
</div>
{% if not summaries %}
<div class="bg-white rounded-xl shadow p-12 text-center">
<i class="fas fa-chart-pie text-gray-300 text-5xl mb-4"></i>
<p class="text-gray-500 text-lg">No portfolios yet.</p>
<p class="text-gray-400 mt-1">Use the API or <code>generate_report</code> command to get started.</p>
<p class="text-gray-400 mt-1">Use the API to create a portfolio and add holdings.</p>
</div>
{% endif %}
@@ -26,9 +25,6 @@
<a href="{% url 'invest-portfolio-detail' p.id %}" class="text-white text-xl font-semibold hover:text-green-300 transition">
{{ p.name }}
</a>
{% if p.account_id %}
<span class="ml-3 text-gray-400 text-sm">{{ p.account_id }}</span>
{% endif %}
</div>
<a href="{% url 'invest-portfolio-detail' p.id %}" class="text-gray-400 hover:text-white text-sm">
View Details <i class="fas fa-chevron-right ml-1"></i>
@@ -38,74 +34,65 @@
<!-- Summary metrics -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-0 divide-x divide-gray-100">
<div class="px-6 py-4">
<p class="text-xs text-gray-500 uppercase tracking-wide">Market Value</p>
<p class="text-xl font-bold text-gray-900 mt-1">
{{ p.base_currency }} {{ s.total_market_value|floatformat:2 }}
</p>
<p class="text-xs text-gray-500 uppercase tracking-wide">Total Value</p>
<p class="text-xl font-bold text-gray-900 mt-1">$ {{ s.total_value|floatformat:2 }}</p>
</div>
<div class="px-6 py-4">
<p class="text-xs text-gray-500 uppercase tracking-wide">Cost Basis</p>
<p class="text-xl font-bold text-gray-900 mt-1">
{{ p.base_currency }} {{ s.total_cost|floatformat:2 }}
</p>
<p class="text-xl font-bold text-gray-900 mt-1">$ {{ s.total_cost|floatformat:2 }}</p>
</div>
<div class="px-6 py-4">
<p class="text-xs text-gray-500 uppercase tracking-wide">Unrealized P&L</p>
<p class="text-xl font-bold mt-1 {% if s.total_unrealized_pnl >= 0 %}text-green-600{% else %}text-red-600{% endif %}">
{% if s.total_unrealized_pnl >= 0 %}+{% endif %}{{ s.total_unrealized_pnl|floatformat:2 }}
<p class="text-xs text-gray-500 uppercase tracking-wide">P&L</p>
<p class="text-xl font-bold mt-1 {% if s.total_pnl >= 0 %}text-green-600{% else %}text-red-600{% endif %}">
{% if s.total_pnl >= 0 %}+{% endif %}{{ s.total_pnl|floatformat:2 }}
</p>
</div>
<div class="px-6 py-4">
<p class="text-xs text-gray-500 uppercase tracking-wide">Return</p>
<p class="text-xl font-bold mt-1 {% if s.total_unrealized_pnl_pct >= 0 %}text-green-600{% else %}text-red-600{% endif %}">
{% if s.total_unrealized_pnl_pct >= 0 %}+{% endif %}{{ s.total_unrealized_pnl_pct|floatformat:2 }}%
<p class="text-xl font-bold mt-1 {% if s.total_pnl_pct >= 0 %}text-green-600{% else %}text-red-600{% endif %}">
{% if s.total_pnl_pct >= 0 %}+{% endif %}{{ s.total_pnl_pct|floatformat:2 }}%
</p>
</div>
</div>
<!-- Holdings mini-table -->
{% if s.stocks %}
<!-- Holdings table -->
{% if s.holdings %}
<div class="px-6 pb-4">
<table class="w-full text-sm">
<thead>
<tr class="text-xs text-gray-400 uppercase border-b border-gray-100">
<th class="pb-2 text-left font-medium">Ticker</th>
<th class="pb-2 text-right font-medium">Shares</th>
<th class="pb-2 text-left font-medium">Stock</th>
<th class="pb-2 text-right font-medium">Qty</th>
<th class="pb-2 text-right font-medium">Avg Cost</th>
<th class="pb-2 text-right font-medium">Price</th>
<th class="pb-2 text-right font-medium">Market Value</th>
<th class="pb-2 text-right font-medium">Current</th>
<th class="pb-2 text-right font-medium">Value</th>
<th class="pb-2 text-right font-medium">P&L</th>
<th class="pb-2 text-right font-medium">Day %</th>
<th class="pb-2 text-right font-medium">P&L %</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
{% for stock in s.stocks %}
{% if stock.shares_held > 0 %}
{% for stock in s.holdings %}
<tr class="hover:bg-gray-50">
<td class="py-2">
<span class="font-semibold text-gray-900">{{ stock.ticker }}</span>
{% if stock.exchange %}<span class="text-xs text-gray-400 ml-1">{{ stock.exchange }}</span>{% endif %}
{% if stock.company_name %}<div class="text-xs text-gray-400">{{ stock.company_name }}</div>{% endif %}
<span class="font-semibold text-gray-900">{{ stock.stock_code }}</span>
</td>
<td class="py-2 text-right text-gray-700">{{ stock.shares_held|floatformat:4 }}</td>
<td class="py-2 text-right text-gray-700">{{ stock.avg_cost|floatformat:4 }}</td>
<td class="py-2 text-right text-gray-700">{{ stock.quantity|floatformat:2 }}</td>
<td class="py-2 text-right text-gray-700">$ {{ stock.avg_cost|floatformat:2 }}</td>
<td class="py-2 text-right text-gray-700">
{% if stock.current_price %}{{ stock.current_price|floatformat:4 }}{% else %}<span class="text-gray-400"></span>{% endif %}
{% if stock.current_price > 0 %}
$ {{ stock.current_price|floatformat:2 }}
{% else %}
<span class="text-gray-400"></span>
{% endif %}
</td>
<td class="py-2 text-right text-gray-700">{{ stock.market_value|floatformat:2 }}</td>
<td class="py-2 text-right text-gray-700">$ {{ stock.current_value|floatformat:2 }}</td>
<td class="py-2 text-right {% if stock.unrealized_pnl >= 0 %}text-green-600{% else %}text-red-600{% endif %}">
{% if stock.unrealized_pnl >= 0 %}+{% endif %}{{ stock.unrealized_pnl|floatformat:2 }}
<div class="text-xs">{% if stock.unrealized_pnl_pct >= 0 %}+{% endif %}{{ stock.unrealized_pnl_pct|floatformat:2 }}%</div>
</td>
<td class="py-2 text-right">
{% if stock.change_percent is not None %}
<span class="{% if stock.change_percent >= 0 %}text-green-600{% else %}text-red-600{% endif %}">
{% if stock.change_percent >= 0 %}+{% endif %}{{ stock.change_percent|floatformat:2 }}%
</span>
{% else %}<span class="text-gray-400"></span>{% endif %}
<td class="py-2 text-right {% if stock.unrealized_pnl_pct >= 0 %}text-green-600{% else %}text-red-600{% endif %}">
{% if stock.unrealized_pnl_pct >= 0 %}+{% endif %}{{ stock.unrealized_pnl_pct|floatformat:2 }}%
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
+85 -184
View File
@@ -1,199 +1,100 @@
{% extends "invest/base.html" %}
{% load static %}
{% block title %}{{ portfolio.name }}{% endblock %}
{% block extra_head %}
<style>
.pnl-positive { color: #16a34a; }
.pnl-negative { color: #dc2626; }
</style>
{% endblock %}
{% block title %}{{ portfolio.name }} - Portfolio{% endblock %}
{% block content %}
<!-- Breadcrumb -->
<nav class="text-sm text-gray-500 mb-4">
<a href="{% url 'invest-dashboard' %}" class="hover:text-gray-700">Portfolios</a>
<span class="mx-2">/</span>
<span class="text-gray-900">{{ portfolio.name }}</span>
</nav>
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-900">{{ portfolio.name }}</h1>
{% if portfolio.account_id %}
<p class="text-gray-500 text-sm mt-0.5">Account: {{ portfolio.account_id }}</p>
{% endif %}
{% if portfolio.description %}
<p class="text-gray-600 mt-1">{{ portfolio.description }}</p>
{% endif %}
</div>
<div class="flex space-x-3">
<a href="{% url 'invest-portfolio-transactions' portfolio.pk %}"
class="inline-flex items-center px-4 py-2 bg-gray-800 text-white text-sm rounded-lg hover:bg-gray-700 transition">
<i class="fas fa-list mr-2"></i>Transactions
</a>
</div>
<div class="mb-6">
<a href="{% url 'invest-dashboard' %}" class="text-gray-500 hover:text-gray-700 text-sm">
<i class="fas fa-arrow-left mr-1"></i> Back to Dashboard
</a>
</div>
<!-- Summary Cards -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div class="bg-white rounded-xl shadow p-5">
<p class="text-xs text-gray-500 uppercase tracking-wide mb-1">Market Value</p>
<p class="text-2xl font-bold text-gray-900">{{ summary.total_market_value|floatformat:2 }}</p>
<p class="text-xs text-gray-400 mt-1">{{ portfolio.base_currency }}</p>
</div>
<div class="bg-white rounded-xl shadow p-5">
<p class="text-xs text-gray-500 uppercase tracking-wide mb-1">Cost Basis</p>
<p class="text-2xl font-bold text-gray-900">{{ summary.total_cost|floatformat:2 }}</p>
<p class="text-xs text-gray-400 mt-1">{{ portfolio.base_currency }}</p>
</div>
<div class="bg-white rounded-xl shadow p-5">
<p class="text-xs text-gray-500 uppercase tracking-wide mb-1">Unrealized P&L</p>
<p class="text-2xl font-bold {% if summary.total_unrealized_pnl >= 0 %}text-green-600{% else %}text-red-600{% endif %}">
{% if summary.total_unrealized_pnl >= 0 %}+{% endif %}{{ summary.total_unrealized_pnl|floatformat:2 }}
</p>
<p class="text-xs {% if summary.total_unrealized_pnl_pct >= 0 %}text-green-500{% else %}text-red-500{% endif %} mt-1">
{% if summary.total_unrealized_pnl_pct >= 0 %}+{% endif %}{{ summary.total_unrealized_pnl_pct|floatformat:2 }}%
</p>
</div>
<div class="bg-white rounded-xl shadow p-5">
<p class="text-xs text-gray-500 uppercase tracking-wide mb-1">Holdings</p>
<p class="text-2xl font-bold text-gray-900">{{ summary.stocks|length }}</p>
<p class="text-xs text-gray-400 mt-1">active positions</p>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Holdings Table -->
<div class="lg:col-span-2 bg-white rounded-xl shadow overflow-hidden">
<div class="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
<h2 class="font-semibold text-gray-900">Holdings</h2>
<span class="text-xs text-gray-400">Prices may be delayed 15 min</span>
<div class="bg-white rounded-xl shadow overflow-hidden">
<!-- Portfolio header -->
<div class="bg-gray-900 px-6 py-4 flex items-center justify-between">
<div>
<h1 class="text-white text-2xl font-bold">{{ portfolio.name }}</h1>
<p class="text-gray-400 text-sm mt-1">Portfolio #{{ portfolio.id }}</p>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="bg-gray-50 text-xs text-gray-500 uppercase">
<th class="px-6 py-3 text-left">Stock</th>
<th class="px-6 py-3 text-right">Shares</th>
<th class="px-6 py-3 text-right">Avg Cost</th>
<th class="px-6 py-3 text-right">Current</th>
<th class="px-6 py-3 text-right">Mkt Value</th>
<th class="px-6 py-3 text-right">P&L</th>
<th class="px-6 py-3 text-right">Day</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
{% for stock in summary.stocks %}
{% if stock.shares_held > 0 %}
<tr class="hover:bg-gray-50 transition">
<td class="px-6 py-3">
<div class="font-semibold text-gray-900">
{{ stock.ticker }}
{% if stock.exchange %}<span class="text-xs text-gray-400 font-normal ml-1">{{ stock.exchange }}</span>{% endif %}
</div>
{% if stock.company_name %}
<div class="text-xs text-gray-400">{{ stock.company_name }}</div>
{% endif %}
</td>
<td class="px-6 py-3 text-right text-gray-700">{{ stock.shares_held|floatformat:4 }}</td>
<td class="px-6 py-3 text-right text-gray-700">{{ stock.avg_cost|floatformat:4 }}</td>
<td class="px-6 py-3 text-right text-gray-700">
{% if stock.current_price %}{{ stock.current_price|floatformat:4 }}{% else %}<span class="text-gray-400"></span>{% endif %}
</td>
<td class="px-6 py-3 text-right font-medium text-gray-900">{{ stock.market_value|floatformat:2 }}</td>
<td class="px-6 py-3 text-right">
<span class="{% if stock.unrealized_pnl >= 0 %}text-green-600{% else %}text-red-600{% endif %} font-medium">
{% if stock.unrealized_pnl >= 0 %}+{% endif %}{{ stock.unrealized_pnl|floatformat:2 }}
</span>
<div class="text-xs {% if stock.unrealized_pnl_pct >= 0 %}text-green-500{% else %}text-red-500{% endif %}">
{% if stock.unrealized_pnl_pct >= 0 %}+{% endif %}{{ stock.unrealized_pnl_pct|floatformat:2 }}%
</div>
</td>
<td class="px-6 py-3 text-right">
{% if stock.change_percent is not None %}
<span class="{% if stock.change_percent >= 0 %}text-green-600{% else %}text-red-600{% endif %}">
{% if stock.change_percent >= 0 %}+{% endif %}{{ stock.change_percent|floatformat:2 }}%
</span>
{% else %}<span class="text-gray-400 text-xs">N/A</span>{% endif %}
</td>
</tr>
{% endif %}
{% empty %}
<tr>
<td colspan="7" class="px-6 py-8 text-center text-gray-400">No holdings with shares > 0</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="text-right">
<a href="{% url 'invest-portfolio-transactions' portfolio.id %}" class="text-gray-400 hover:text-white text-sm">
View Transactions <i class="fas fa-chevron-right ml-1"></i>
</a>
</div>
</div>
<!-- Allocation Chart -->
<div class="bg-white rounded-xl shadow p-6">
<h2 class="font-semibold text-gray-900 mb-4">Allocation</h2>
<div class="relative">
<canvas id="allocationChart" height="260"></canvas>
<!-- Summary metrics -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-0 divide-x divide-gray-100 border-b border-gray-100">
<div class="px-6 py-4">
<p class="text-xs text-gray-500 uppercase tracking-wide">Total Value</p>
<p class="text-xl font-bold text-gray-900 mt-1">$ {{ summary.total_value|floatformat:2 }}</p>
</div>
<div class="px-6 py-4">
<p class="text-xs text-gray-500 uppercase tracking-wide">Cost Basis</p>
<p class="text-xl font-bold text-gray-900 mt-1">$ {{ summary.total_cost|floatformat:2 }}</p>
</div>
<div class="px-6 py-4">
<p class="text-xs text-gray-500 uppercase tracking-wide">P&L</p>
<p class="text-xl font-bold mt-1 {% if summary.total_pnl >= 0 %}text-green-600{% else %}text-red-600{% endif %}">
{% if summary.total_pnl >= 0 %}+{% endif %}{{ summary.total_pnl|floatformat:2 }}
</p>
</div>
<div class="px-6 py-4">
<p class="text-xs text-gray-500 uppercase tracking-wide">Return</p>
<p class="text-xl font-bold mt-1 {% if summary.total_pnl_pct >= 0 %}text-green-600{% else %}text-red-600{% endif %}">
{% if summary.total_pnl_pct >= 0 %}+{% endif %}{{ summary.total_pnl_pct|floatformat:2 }}%
</p>
</div>
<div id="allocationLegend" class="mt-4 space-y-1 text-xs"></div>
</div>
<!-- Holdings table -->
{% if summary.holdings %}
<div class="px-6 py-4">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Holdings</h2>
<table class="w-full text-sm">
<thead>
<tr class="text-xs text-gray-400 uppercase border-b border-gray-100">
<th class="pb-2 text-left font-medium">Stock</th>
<th class="pb-2 text-right font-medium">Qty</th>
<th class="pb-2 text-right font-medium">Avg Cost</th>
<th class="pb-2 text-right font-medium">Current</th>
<th class="pb-2 text-right font-medium">Value</th>
<th class="pb-2 text-right font-medium">P&L</th>
<th class="pb-2 text-right font-medium">P&L %</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
{% for stock in summary.holdings %}
<tr class="hover:bg-gray-50">
<td class="py-2">
<span class="font-semibold text-gray-900">{{ stock.stock_code }}</span>
</td>
<td class="py-2 text-right text-gray-700">{{ stock.quantity|floatformat:2 }}</td>
<td class="py-2 text-right text-gray-700">$ {{ stock.avg_cost|floatformat:2 }}</td>
<td class="py-2 text-right text-gray-700">
{% if stock.current_price > 0 %}
$ {{ stock.current_price|floatformat:2 }}
{% else %}
<span class="text-gray-400"></span>
{% endif %}
</td>
<td class="py-2 text-right text-gray-700">$ {{ stock.current_value|floatformat:2 }}</td>
<td class="py-2 text-right {% if stock.unrealized_pnl >= 0 %}text-green-600{% else %}text-red-600{% endif %}">
{% if stock.unrealized_pnl >= 0 %}+{% endif %}{{ stock.unrealized_pnl|floatformat:2 }}
</td>
<td class="py-2 text-right {% if stock.unrealized_pnl_pct >= 0 %}text-green-600{% else %}text-red-600{% endif %}">
{% if stock.unrealized_pnl_pct >= 0 %}+{% endif %}{{ stock.unrealized_pnl_pct|floatformat:2 }}%
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="px-6 py-12 text-center">
<p class="text-gray-500">No holdings in this portfolio.</p>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
{{ summary_json|json_script:"summary-data" }}
<script>
(function() {
const summaryData = JSON.parse(document.getElementById('summary-data').textContent);
const stocks = summaryData.stocks;
const active = stocks.filter(s => s.shares_held > 0 && s.market_value > 0);
if (!active.length) return;
const labels = active.map(s => s.ticker + (s.exchange ? '.' + s.exchange : ''));
const values = active.map(s => s.market_value);
const total = values.reduce((a, b) => a + b, 0);
const palette = [
'#6366f1','#22c55e','#f59e0b','#3b82f6','#ec4899',
'#14b8a6','#f97316','#a855f7','#06b6d4','#84cc16',
];
const ctx = document.getElementById('allocationChart').getContext('2d');
new Chart(ctx, {
type: 'doughnut',
data: {
labels,
datasets: [{
data: values,
backgroundColor: palette.slice(0, active.length),
borderWidth: 2,
borderColor: '#fff',
}]
},
options: {
cutout: '62%',
plugins: { legend: { display: false }, tooltip: {
callbacks: {
label: ctx => ` ${ctx.label}: ${(ctx.parsed / total * 100).toFixed(1)}%`
}
}},
}
});
const legend = document.getElementById('allocationLegend');
active.forEach((s, i) => {
const pct = (s.market_value / total * 100).toFixed(1);
legend.innerHTML += `
<div class="flex items-center justify-between">
<div class="flex items-center">
<span class="w-3 h-3 rounded-full mr-2 flex-shrink-0" style="background:${palette[i % palette.length]}"></span>
<span class="text-gray-700">${labels[i]}</span>
</div>
<span class="text-gray-500">${pct}%</span>
</div>`;
});
})();
</script>
{% endblock %}
@@ -1,47 +0,0 @@
{% extends "invest/base.html" %}
{% block title %}{{ report.title }}{% endblock %}
{% block content %}
<nav class="text-sm text-gray-500 mb-4">
<a href="{% url 'invest-reports' %}" class="hover:text-gray-700">Reports</a>
<span class="mx-2">/</span>
<span class="text-gray-900">{{ report.title }}</span>
</nav>
<div class="bg-white rounded-xl shadow overflow-hidden">
<!-- Report header -->
<div class="bg-gray-900 px-8 py-6">
<div class="flex items-start justify-between">
<div>
<h1 class="text-xl font-bold text-white">{{ report.title }}</h1>
<p class="text-gray-400 mt-1 text-sm">
Period: {{ report.period_start }} {{ report.period_end }}
{% if report.portfolio %}
&nbsp;·&nbsp; {{ report.portfolio.name }}
{% endif %}
</p>
</div>
<span class="text-xs px-3 py-1.5 rounded-full {% if report.report_type == 'WEEKLY' %}bg-blue-500 text-white{% else %}bg-purple-500 text-white{% endif %}">
{{ report.get_report_type_display }}
</span>
</div>
<p class="text-gray-500 text-xs mt-3">Generated {{ report.generated_at|date:"M d, Y H:i" }}</p>
</div>
<!-- Report body -->
<div class="px-8 py-6">
<div class="prose max-w-none text-gray-700 whitespace-pre-line">{{ report.content }}</div>
</div>
<!-- Valuation snapshot -->
{% if report.valuation_snapshot %}
<div class="px-8 pb-6">
<h2 class="font-semibold text-gray-900 mb-3">Valuation Snapshot</h2>
<div class="bg-gray-50 rounded-lg p-4 overflow-x-auto">
<pre class="text-xs text-gray-600 whitespace-pre-wrap">{{ report.valuation_snapshot }}</pre>
</div>
</div>
{% endif %}
</div>
{% endblock %}
-46
View File
@@ -1,46 +0,0 @@
{% extends "invest/base.html" %}
{% block title %}Reports{% endblock %}
{% block content %}
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">Reports</h1>
<p class="text-gray-500 mt-1">Weekly and manual performance reports.</p>
</div>
{% if not reports %}
<div class="bg-white rounded-xl shadow p-12 text-center">
<i class="fas fa-file-alt text-gray-300 text-5xl mb-4"></i>
<p class="text-gray-500">No reports generated yet.</p>
<p class="text-gray-400 mt-1 text-sm">Run <code class="bg-gray-100 px-1 rounded">python manage.py generate_report</code> to create one.</p>
</div>
{% else %}
<div class="space-y-4">
{% for report in reports %}
<a href="{% url 'invest-report-detail' report.pk %}" class="block bg-white rounded-xl shadow hover:shadow-md transition p-5">
<div class="flex items-center justify-between">
<div>
<h2 class="font-semibold text-gray-900">{{ report.title }}</h2>
<p class="text-sm text-gray-500 mt-0.5">
{{ report.period_start }} {{ report.period_end }}
{% if report.portfolio %}
· {{ report.portfolio.name }}
{% endif %}
</p>
</div>
<div class="flex items-center space-x-3">
<span class="text-xs px-2 py-1 rounded-full {% if report.report_type == 'WEEKLY' %}bg-blue-100 text-blue-700{% else %}bg-purple-100 text-purple-700{% endif %}">
{{ report.get_report_type_display }}
</span>
<span class="text-xs text-gray-400">{{ report.generated_at|date:"M d, Y H:i" }}</span>
<i class="fas fa-chevron-right text-gray-300"></i>
</div>
</div>
{% if report.content %}
<p class="text-sm text-gray-500 mt-2 line-clamp-2">{{ report.content|truncatechars:200 }}</p>
{% endif %}
</a>
{% endfor %}
</div>
{% endif %}
{% endblock %}
+40 -58
View File
@@ -1,80 +1,62 @@
{% extends "invest/base.html" %}
{% load static %}
{% block title %}Transactions {{ portfolio.name }}{% endblock %}
{% block title %}{{ portfolio.name }} - Transactions{% endblock %}
{% block content %}
<nav class="text-sm text-gray-500 mb-4">
<a href="{% url 'invest-dashboard' %}" class="hover:text-gray-700">Portfolios</a>
<span class="mx-2">/</span>
<a href="{% url 'invest-portfolio-detail' portfolio.pk %}" class="hover:text-gray-700">{{ portfolio.name }}</a>
<span class="mx-2">/</span>
<span class="text-gray-900">Transactions</span>
</nav>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">Transaction History</h1>
<span class="text-sm text-gray-500">{{ transactions|length }} transaction{{ transactions|length|pluralize }}</span>
<div class="mb-6">
<a href="{% url 'invest-dashboard' %}" class="text-gray-500 hover:text-gray-700 text-sm">
<i class="fas fa-arrow-left mr-1"></i> Back to Dashboard
</a>
</div>
<div class="bg-white rounded-xl shadow overflow-hidden" x-data="{filter: 'ALL'}">
<!-- Filter pills -->
<div class="px-6 py-3 border-b border-gray-100 flex space-x-2">
<button @click="filter='ALL'" :class="filter==='ALL' ? 'bg-gray-900 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
class="px-3 py-1 rounded-full text-xs font-medium transition">All</button>
<button @click="filter='BUY'" :class="filter==='BUY' ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
class="px-3 py-1 rounded-full text-xs font-medium transition">Buy</button>
<button @click="filter='SELL'" :class="filter==='SELL' ? 'bg-red-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
class="px-3 py-1 rounded-full text-xs font-medium transition">Sell</button>
<div class="bg-white rounded-xl shadow overflow-hidden">
<!-- Header -->
<div class="bg-gray-900 px-6 py-4 flex items-center justify-between">
<div>
<h1 class="text-white text-2xl font-bold">{{ portfolio.name }}</h1>
<p class="text-gray-400 text-sm mt-1">Transaction History</p>
</div>
<a href="{% url 'invest-portfolio-detail' portfolio.id %}" class="text-gray-400 hover:text-white text-sm">
View Portfolio <i class="fas fa-chevron-right ml-1"></i>
</a>
</div>
<div class="overflow-x-auto">
{% if transactions %}
<div class="px-6 py-4">
<table class="w-full text-sm">
<thead>
<tr class="bg-gray-50 text-xs text-gray-500 uppercase">
<th class="px-6 py-3 text-left">Date</th>
<th class="px-6 py-3 text-left">Type</th>
<th class="px-6 py-3 text-left">Stock</th>
<th class="px-6 py-3 text-right">Shares</th>
<th class="px-6 py-3 text-right">Price</th>
<th class="px-6 py-3 text-right">Fee</th>
<th class="px-6 py-3 text-right">Total</th>
<th class="px-6 py-3 text-left">Source</th>
<th class="px-6 py-3 text-left">Notes</th>
<tr class="text-xs text-gray-400 uppercase border-b border-gray-100">
<th class="pb-2 text-left font-medium">Date</th>
<th class="pb-2 text-left font-medium">Action</th>
<th class="pb-2 text-left font-medium">Stock</th>
<th class="pb-2 text-right font-medium">Qty</th>
<th class="pb-2 text-right font-medium">Price</th>
<th class="pb-2 text-right font-medium">Total</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
{% for tx in transactions %}
<tr class="hover:bg-gray-50 transition" x-show="filter==='ALL' || filter==='{{ tx.tx_type }}'">
<td class="px-6 py-3 text-gray-700 whitespace-nowrap">{{ tx.date }}</td>
<td class="px-6 py-3">
{% if tx.tx_type == 'BUY' %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">BUY</span>
{% else %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">SELL</span>
{% endif %}
<tr class="hover:bg-gray-50">
<td class="py-2 text-gray-700">{{ tx.date|date:"Y-m-d" }}</td>
<td class="py-2">
<span class="{% if tx.action == 'BUY' %}text-green-600{% else %}text-red-600{% endif %} font-medium">
{{ tx.action }}
</span>
</td>
<td class="px-6 py-3">
<span class="font-semibold text-gray-900">{{ tx.stock.ticker }}</span>
{% if tx.stock.exchange %}<span class="text-xs text-gray-400 ml-1">{{ tx.stock.exchange }}</span>{% endif %}
</td>
<td class="px-6 py-3 text-right text-gray-700">{{ tx.shares|floatformat:4 }}</td>
<td class="px-6 py-3 text-right text-gray-700">{{ tx.price_per_share|floatformat:4 }}</td>
<td class="px-6 py-3 text-right text-gray-500">{{ tx.fee|floatformat:2 }}</td>
<td class="px-6 py-3 text-right font-medium text-gray-900">
{{ tx.shares|floatformat:4 }}×{{ tx.price_per_share|floatformat:4 }}
</td>
<td class="px-6 py-3">
<span class="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">{{ tx.source }}</span>
</td>
<td class="px-6 py-3 text-gray-500 max-w-xs truncate">{{ tx.notes }}</td>
</tr>
{% empty %}
<tr>
<td colspan="9" class="px-6 py-10 text-center text-gray-400">No transactions recorded yet.</td>
<td class="py-2 font-semibold text-gray-900">{{ tx.stock_code }}</td>
<td class="py-2 text-right text-gray-700">{{ tx.quantity|floatformat:2 }}</td>
<td class="py-2 text-right text-gray-700">$ {{ tx.price_per_share|floatformat:2 }}</td>
<td class="py-2 text-right text-gray-700">$ {{ tx.total|floatformat:2 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="px-6 py-12 text-center">
<p class="text-gray-500">No transactions in this portfolio.</p>
</div>
{% endif %}
</div>
{% endblock %}
+1 -8
View File
@@ -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/<int:pk>/', template_views.portfolio_detail, name='invest-portfolio-detail'),
path('portfolios/<int:pk>/transactions/', template_views.portfolio_transactions, name='invest-portfolio-transactions'),
path('reports/', template_views.reports_list, name='invest-reports'),
path('reports/<int:pk>/', template_views.report_detail, name='invest-report-detail'),
]
+38 -109
View File
@@ -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)