feat(invest): Add investment portfolio management feature

- Portfolio management (MOMO, IBKR personal, IBKR Yanhua)
- Stock holdings with transaction history (buy/sell)
- Weekly AI report generation
- Real-time price cache via Finnhub API
- Dashboard with tree view and performance charts
- REST API with AI-friendly batch update endpoint
- Management command for scheduled report generation

For大哥's personal investment advisor system.
This commit is contained in:
OpenClaw Sub-agent
2026-04-18 11:26:48 +10:00
parent 6626ee3656
commit 8d05a11eb6
21 changed files with 1622 additions and 0 deletions
+1
View File
@@ -20,6 +20,7 @@ INSTALLED_APPS = [
'new_theme',
'simplemde',
'markdown', # 只需要基本的markdown包
'invest',
]
ROOT_URLCONF = 'core.urls'
+2
View File
@@ -11,6 +11,7 @@ urlpatterns = [
path('admin/', admin.site.urls),
# Add API URLs before locale URLs
path('api/', include('links.api_urls')), # New line for API routes
path('api/invest/', include('invest.urls', namespace='invest-api')),
# Media files
path('media/<path:path>', serve, {
'document_root': settings.MEDIA_ROOT,
@@ -26,6 +27,7 @@ urlpatterns = [
path('custom/<slug:alias>/', CustomLinkView.as_view(), name='custom_link'),
path('custom/<slug:alias>/edit/', LinkUpdateView.as_view(), name='custom_link_update'),
path('invest/', include('invest.urls')),
# Include main app URLs with locale
path('', include('links.urls')),
]
View File
+19
View File
@@ -0,0 +1,19 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import (
PortfolioViewSet, StockViewSet, TransactionViewSet,
ReportViewSet, QuotesView, AIUpdateView,
)
router = DefaultRouter()
router.register(r'portfolios', PortfolioViewSet, basename='invest-portfolio')
router.register(r'stocks', StockViewSet, basename='invest-stock')
router.register(r'transactions', TransactionViewSet, basename='invest-transaction')
router.register(r'reports', ReportViewSet, basename='invest-report')
urlpatterns = [
path('', include(router.urls)),
path('quotes/', QuotesView.as_view(), name='invest-quotes'),
path('ai-update/', AIUpdateView.as_view(), name='invest-ai-update'),
]
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class InvestConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'invest'
verbose_name = 'Investment Portfolio'
View File
@@ -0,0 +1,126 @@
"""
Management command: generate_report
Usage:
python manage.py generate_report [--portfolio-id ID] [--all] [--period-days N]
Generates a weekly portfolio performance report and saves it to the Report model.
"""
import json
import logging
from datetime import date, timedelta
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from invest.models import Portfolio, Report
from invest.services import portfolio_summary
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Generate a performance report for one or all portfolios."
def add_arguments(self, parser):
parser.add_argument(
'--portfolio-id', type=int, default=None,
help="ID of the portfolio to report on. Defaults to all portfolios."
)
parser.add_argument(
'--all', action='store_true', dest='all_portfolios',
help="Generate reports for all portfolios."
)
parser.add_argument(
'--period-days', type=int, default=7,
help="Number of days the report covers (default: 7)."
)
parser.add_argument(
'--report-type', choices=['WEEKLY', 'MANUAL'], default='MANUAL',
help="Report type label (default: MANUAL)."
)
def handle(self, *args, **options):
period_days = options['period_days']
report_type = options['report_type']
portfolio_id = options['portfolio_id']
if options['all_portfolios']:
portfolios = list(Portfolio.objects.all())
if not portfolios:
raise CommandError("No portfolios found in the database.")
elif portfolio_id:
try:
portfolios = [Portfolio.objects.get(pk=portfolio_id)]
except Portfolio.DoesNotExist:
raise CommandError(f"Portfolio with id={portfolio_id} does not exist.")
else:
portfolios = list(Portfolio.objects.all())
if not portfolios:
raise CommandError("No portfolios found. Use --portfolio-id or --all.")
period_end = date.today()
period_start = period_end - timedelta(days=period_days - 1)
for portfolio in portfolios:
self.stdout.write(f"Generating report for: {portfolio.name}")
try:
report = _generate_report(
portfolio=portfolio,
period_start=period_start,
period_end=period_end,
report_type=report_type,
)
self.stdout.write(
self.style.SUCCESS(f" ✓ Report #{report.id} saved: "{report.title}"")
)
except Exception as exc:
logger.error("Failed to generate report for %s: %s", portfolio.name, exc, exc_info=True)
self.stderr.write(self.style.ERROR(f" ✗ Failed for {portfolio.name}: {exc}"))
def _generate_report(
portfolio: Portfolio,
period_start: date,
period_end: date,
report_type: str = 'MANUAL',
) -> Report:
"""Build and persist a Report from the current portfolio summary."""
summary = portfolio_summary(portfolio)
lines = [
f"Portfolio: {portfolio.name}",
f"Period: {period_start}{period_end}",
"",
f" Market Value: {portfolio.base_currency} {summary['total_market_value']:,.2f}",
f" Cost Basis: {portfolio.base_currency} {summary['total_cost']:,.2f}",
f" Unrealized P&L: {portfolio.base_currency} {summary['total_unrealized_pnl']:+,.2f}",
f" Return: {summary['total_unrealized_pnl_pct']:+.2f}%",
"",
"Holdings:",
]
for stock in summary['stocks']:
if stock['shares_held'] > 0:
day_pct = f"{stock['change_percent']:+.2f}%" if stock['change_percent'] is not None else "N/A"
lines.append(
f" {stock['ticker']:<8} {stock['shares_held']:.4f} shares "
f"avg {stock['avg_cost']:.4f} "
f"cur {stock['current_price']:.4f} "
f"pnl {stock['unrealized_pnl']:+.2f} ({stock['unrealized_pnl_pct']:+.2f}%) "
f"day {day_pct}"
)
content = "\n".join(lines)
title = f"{portfolio.name} {report_type.capitalize()} Report ({period_end})"
report = Report.objects.create(
portfolio=portfolio,
title=title,
content=content,
period_start=period_start,
period_end=period_end,
report_type=report_type,
valuation_snapshot=summary,
)
return report
View File
+142
View File
@@ -0,0 +1,142 @@
from django.db import models
from django.core.validators import MinValueValidator
from decimal import Decimal
class Portfolio(models.Model):
name = models.CharField(max_length=100)
description = models.TextField(blank=True)
account_id = models.CharField(max_length=50, blank=True)
base_currency = models.CharField(max_length=10, default='USD')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.name
class Meta:
ordering = ['name']
class Stock(models.Model):
"""Represents a stock holding within a portfolio."""
portfolio = models.ForeignKey(Portfolio, on_delete=models.CASCADE, related_name='stocks')
ticker = models.CharField(max_length=20)
exchange = models.CharField(
max_length=10, blank=True, default='',
help_text="Exchange code, e.g. NASDAQ, HKG. Empty = US market default."
)
company_name = models.CharField(max_length=200, blank=True)
shares_held = models.DecimalField(
max_digits=20, decimal_places=6, default=Decimal('0'),
validators=[MinValueValidator(Decimal('0'))]
)
avg_cost = models.DecimalField(
max_digits=20, decimal_places=6, default=Decimal('0'),
validators=[MinValueValidator(Decimal('0'))],
help_text="Weighted average cost per share in quote_currency"
)
quote_currency = models.CharField(max_length=10, default='USD')
is_active = models.BooleanField(default=True)
notes = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"{self.ticker} ({self.portfolio.name})"
@property
def finnhub_symbol(self):
"""Return the symbol in the format Finnhub expects."""
if self.exchange.upper() == 'HKG':
return f"{self.ticker}.HK"
return self.ticker
class Meta:
unique_together = [('portfolio', 'ticker', 'exchange')]
ordering = ['ticker']
class Transaction(models.Model):
TX_BUY = 'BUY'
TX_SELL = 'SELL'
TX_TYPES = [(TX_BUY, 'Buy'), (TX_SELL, 'Sell')]
stock = models.ForeignKey(Stock, on_delete=models.CASCADE, related_name='transactions')
tx_type = models.CharField(max_length=4, choices=TX_TYPES)
date = models.DateField()
price_per_share = models.DecimalField(
max_digits=20, decimal_places=6,
validators=[MinValueValidator(Decimal('0'))]
)
shares = models.DecimalField(
max_digits=20, decimal_places=6,
validators=[MinValueValidator(Decimal('0.000001'))]
)
fee = models.DecimalField(
max_digits=20, decimal_places=6, default=Decimal('0'),
validators=[MinValueValidator(Decimal('0'))]
)
notes = models.TextField(blank=True)
source = models.CharField(
max_length=20, default='manual',
help_text="Origin of transaction: 'manual', 'ai', 'import'"
)
idempotency_key = models.CharField(
max_length=100, blank=True, null=True, unique=True,
help_text="Unique key to prevent duplicate AI writes"
)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.tx_type} {self.shares} {self.stock.ticker} @ {self.price_per_share}"
class Meta:
ordering = ['date', 'created_at']
class Report(models.Model):
REPORT_WEEKLY = 'WEEKLY'
REPORT_MANUAL = 'MANUAL'
REPORT_TYPES = [(REPORT_WEEKLY, 'Weekly'), (REPORT_MANUAL, 'Manual')]
portfolio = models.ForeignKey(
Portfolio, on_delete=models.SET_NULL, null=True, blank=True, related_name='reports'
)
title = models.CharField(max_length=200)
content = models.TextField()
period_start = models.DateField()
period_end = models.DateField()
generated_at = models.DateTimeField(auto_now_add=True)
report_type = models.CharField(max_length=10, choices=REPORT_TYPES, default=REPORT_WEEKLY)
valuation_snapshot = models.JSONField(
default=dict,
help_text="Snapshot of prices and holdings at time of report generation"
)
def __str__(self):
return f"{self.title} ({self.period_start} {self.period_end})"
class Meta:
ordering = ['-generated_at']
class PriceCache(models.Model):
"""Cache for real-time quotes fetched from Finnhub. TTL: 15 minutes."""
ticker = models.CharField(max_length=20)
exchange = models.CharField(max_length=10, blank=True, default='')
price = models.DecimalField(max_digits=20, decimal_places=6)
currency = models.CharField(max_length=10, default='USD')
change_percent = models.DecimalField(max_digits=10, decimal_places=4, null=True, blank=True)
prev_close = models.DecimalField(max_digits=20, decimal_places=6, null=True, blank=True)
fetched_at = models.DateTimeField(auto_now_add=True)
class Meta:
indexes = [
models.Index(fields=['ticker', 'exchange', 'fetched_at']),
]
def __str__(self):
return f"{self.ticker}: {self.price} @ {self.fetched_at}"
+112
View File
@@ -0,0 +1,112 @@
from rest_framework import serializers
from .models import Portfolio, Stock, Transaction, Report, PriceCache
class TransactionSerializer(serializers.ModelSerializer):
stock_ticker = serializers.CharField(source='stock.ticker', read_only=True)
stock_exchange = serializers.CharField(source='stock.exchange', read_only=True)
tx_type_display = serializers.CharField(source='get_tx_type_display', read_only=True)
class Meta:
model = Transaction
fields = [
'id', 'stock', 'stock_ticker', 'stock_exchange',
'tx_type', 'tx_type_display', 'date',
'price_per_share', 'shares', 'fee', 'notes',
'source', 'idempotency_key', 'created_at',
]
read_only_fields = ['id', 'created_at']
class StockSerializer(serializers.ModelSerializer):
transactions = TransactionSerializer(many=True, read_only=True)
portfolio_name = serializers.CharField(source='portfolio.name', read_only=True)
class Meta:
model = Stock
fields = [
'id', 'portfolio', 'portfolio_name', 'ticker', 'exchange',
'company_name', 'shares_held', 'avg_cost', 'quote_currency',
'is_active', 'notes', 'created_at', 'updated_at', 'transactions',
]
read_only_fields = ['id', 'shares_held', 'avg_cost', 'created_at', 'updated_at']
class StockListSerializer(serializers.ModelSerializer):
"""Lightweight serializer for list views (no transactions)."""
portfolio_name = serializers.CharField(source='portfolio.name', read_only=True)
class Meta:
model = Stock
fields = [
'id', 'portfolio', 'portfolio_name', 'ticker', 'exchange',
'company_name', 'shares_held', 'avg_cost', 'quote_currency',
'is_active', 'notes', 'created_at', 'updated_at',
]
read_only_fields = ['id', 'shares_held', 'avg_cost', 'created_at', 'updated_at']
class PortfolioSerializer(serializers.ModelSerializer):
stocks = StockListSerializer(many=True, read_only=True)
stock_count = serializers.SerializerMethodField()
class Meta:
model = Portfolio
fields = [
'id', 'name', 'description', 'account_id', 'base_currency',
'created_at', 'updated_at', 'stocks', 'stock_count',
]
read_only_fields = ['id', 'created_at', 'updated_at']
def get_stock_count(self, obj):
return obj.stocks.filter(is_active=True).count()
class PortfolioListSerializer(serializers.ModelSerializer):
"""Lightweight serializer for list views."""
stock_count = serializers.SerializerMethodField()
class Meta:
model = Portfolio
fields = [
'id', 'name', 'description', 'account_id', 'base_currency',
'created_at', 'updated_at', 'stock_count',
]
read_only_fields = ['id', 'created_at', 'updated_at']
def get_stock_count(self, obj):
return obj.stocks.filter(is_active=True).count()
class ReportSerializer(serializers.ModelSerializer):
portfolio_name = serializers.CharField(source='portfolio.name', read_only=True, allow_null=True)
class Meta:
model = Report
fields = [
'id', 'portfolio', 'portfolio_name', 'title', 'content',
'period_start', 'period_end', 'generated_at', 'report_type',
'valuation_snapshot',
]
read_only_fields = ['id', 'generated_at']
class PriceCacheSerializer(serializers.ModelSerializer):
class Meta:
model = PriceCache
fields = ['id', 'ticker', 'exchange', 'price', 'currency',
'change_percent', 'prev_close', 'fetched_at']
read_only_fields = ['id', 'fetched_at']
class AIBatchUpdateSerializer(serializers.Serializer):
"""Serializer for the AI batch update endpoint."""
operations = serializers.ListField(
child=serializers.DictField(),
min_length=1,
help_text=(
"List of operations. Supported types: "
"upsert_portfolio, upsert_stock, add_transaction"
),
)
dry_run = serializers.BooleanField(default=False)
+380
View File
@@ -0,0 +1,380 @@
"""
Service layer for the invest app.
All write operations go through here to ensure atomicity and consistent derived-field updates.
"""
import logging
from decimal import Decimal
from datetime import timedelta
from django.db import transaction
from django.utils import timezone
from .models import Portfolio, Stock, Transaction, PriceCache
logger = logging.getLogger(__name__)
PRICE_CACHE_TTL_MINUTES = 15
# ---------------------------------------------------------------------------
# Holdings helpers
# ---------------------------------------------------------------------------
def _recompute_holding(stock: Stock) -> None:
"""
Recompute shares_held and weighted-average cost from all transactions.
Must be called inside a transaction.atomic() block.
"""
buys = stock.transactions.filter(tx_type=Transaction.TX_BUY)
sells = stock.transactions.filter(tx_type=Transaction.TX_SELL)
total_bought = sum((t.shares for t in buys), Decimal('0'))
total_sold = sum((t.shares for t in sells), Decimal('0'))
shares_held = total_bought - total_sold
# Weighted average cost based only on buy transactions
total_cost = sum((t.shares * t.price_per_share + t.fee for t in buys), Decimal('0'))
avg_cost = total_cost / total_bought if total_bought > 0 else Decimal('0')
stock.shares_held = max(shares_held, Decimal('0'))
stock.avg_cost = avg_cost
stock.save(update_fields=['shares_held', 'avg_cost', 'updated_at'])
# ---------------------------------------------------------------------------
# Portfolio CRUD
# ---------------------------------------------------------------------------
def create_portfolio(name: str, description: str = '', account_id: str = '', base_currency: str = 'USD') -> Portfolio:
return Portfolio.objects.create(
name=name,
description=description,
account_id=account_id,
base_currency=base_currency,
)
# ---------------------------------------------------------------------------
# Stock CRUD
# ---------------------------------------------------------------------------
def upsert_stock(
portfolio: Portfolio,
ticker: str,
exchange: str = '',
company_name: str = '',
quote_currency: str = 'USD',
notes: str = '',
) -> tuple[Stock, bool]:
"""Create or update a stock holding. Returns (stock, created)."""
stock, created = Stock.objects.get_or_create(
portfolio=portfolio,
ticker=ticker.upper(),
exchange=exchange.upper(),
defaults={
'company_name': company_name,
'quote_currency': quote_currency,
'notes': notes,
},
)
if not created:
update_fields = []
if company_name and stock.company_name != company_name:
stock.company_name = company_name
update_fields.append('company_name')
if quote_currency and stock.quote_currency != quote_currency:
stock.quote_currency = quote_currency
update_fields.append('quote_currency')
if notes and stock.notes != notes:
stock.notes = notes
update_fields.append('notes')
if update_fields:
stock.save(update_fields=update_fields + ['updated_at'])
return stock, created
# ---------------------------------------------------------------------------
# Transaction CRUD
# ---------------------------------------------------------------------------
def add_transaction(
stock: Stock,
tx_type: str,
date,
price_per_share: Decimal,
shares: Decimal,
fee: Decimal = Decimal('0'),
notes: str = '',
source: str = 'manual',
idempotency_key: str | None = None,
) -> Transaction:
"""
Add a buy or sell transaction and recompute holdings atomically.
If idempotency_key is provided, skip if already recorded.
"""
with transaction.atomic():
if idempotency_key:
existing = Transaction.objects.filter(idempotency_key=idempotency_key).first()
if existing:
logger.info("Transaction with idempotency_key=%s already exists, skipping.", idempotency_key)
return existing
tx = Transaction.objects.create(
stock=stock,
tx_type=tx_type,
date=date,
price_per_share=price_per_share,
shares=shares,
fee=fee,
notes=notes,
source=source,
idempotency_key=idempotency_key or None,
)
_recompute_holding(stock)
return tx
def delete_transaction(tx: Transaction) -> None:
"""Delete a transaction and recompute holdings atomically."""
with transaction.atomic():
stock = tx.stock
tx.delete()
_recompute_holding(stock)
# ---------------------------------------------------------------------------
# AI batch update
# ---------------------------------------------------------------------------
def ai_batch_update(operations: list[dict], dry_run: bool = False) -> list[dict]:
"""
Apply a list of AI-driven operations atomically.
Supported operation types:
- upsert_portfolio: {type, name, description, account_id, base_currency}
- upsert_stock: {type, portfolio_name, ticker, exchange, company_name, quote_currency, notes}
- add_transaction: {type, portfolio_name, ticker, exchange, tx_type, date, price_per_share,
shares, fee, notes, idempotency_key}
Returns a list of per-operation results.
"""
results = []
try:
with transaction.atomic():
for i, op in enumerate(operations):
op_type = op.get('type')
try:
result = _process_op(op)
results.append({'index': i, 'type': op_type, 'status': 'ok', 'detail': result})
except Exception as exc:
results.append({'index': i, 'type': op_type, 'status': 'error', 'detail': str(exc)})
raise # bubble up to abort the atomic block
if dry_run:
raise _DryRunAbort()
except _DryRunAbort:
pass # Rollback on dry_run is expected
return results
class _DryRunAbort(Exception):
pass
def _process_op(op: dict) -> str:
op_type = op.get('type')
if op_type == 'upsert_portfolio':
portfolio, created = Portfolio.objects.update_or_create(
name=op['name'],
defaults={
'description': op.get('description', ''),
'account_id': op.get('account_id', ''),
'base_currency': op.get('base_currency', 'USD'),
},
)
return f"Portfolio '{portfolio.name}' {'created' if created else 'updated'}"
elif op_type == 'upsert_stock':
portfolio = Portfolio.objects.get(name=op['portfolio_name'])
stock, created = upsert_stock(
portfolio=portfolio,
ticker=op['ticker'],
exchange=op.get('exchange', ''),
company_name=op.get('company_name', ''),
quote_currency=op.get('quote_currency', 'USD'),
notes=op.get('notes', ''),
)
return f"Stock '{stock.ticker}' in '{portfolio.name}' {'created' if created else 'updated'}"
elif op_type == 'add_transaction':
from datetime import date as date_cls
portfolio = Portfolio.objects.get(name=op['portfolio_name'])
stock = Stock.objects.get(
portfolio=portfolio,
ticker=op['ticker'].upper(),
exchange=op.get('exchange', '').upper(),
)
date_val = op['date']
if isinstance(date_val, str):
from datetime import datetime
date_val = datetime.strptime(date_val, '%Y-%m-%d').date()
tx = add_transaction(
stock=stock,
tx_type=op['tx_type'].upper(),
date=date_val,
price_per_share=Decimal(str(op['price_per_share'])),
shares=Decimal(str(op['shares'])),
fee=Decimal(str(op.get('fee', 0))),
notes=op.get('notes', ''),
source='ai',
idempotency_key=op.get('idempotency_key'),
)
return f"Transaction {tx.id} recorded"
else:
raise ValueError(f"Unknown operation type: {op_type!r}")
# ---------------------------------------------------------------------------
# Price quotes (Finnhub)
# ---------------------------------------------------------------------------
def get_quotes(tickers_and_exchanges: list[tuple[str, str]], force_refresh: bool = False) -> dict:
"""
Fetch quotes for a list of (ticker, exchange) tuples.
Uses PriceCache with 15-minute TTL.
Returns {ticker: {price, change_percent, prev_close, currency, cached}}.
"""
import requests
from django.conf import settings
api_key = getattr(settings, 'FINNHUB_API_KEY', '')
cutoff = timezone.now() - timedelta(minutes=PRICE_CACHE_TTL_MINUTES)
results = {}
for ticker, exchange in tickers_and_exchanges:
cache_entry = (
PriceCache.objects
.filter(ticker=ticker, exchange=exchange, fetched_at__gte=cutoff)
.order_by('-fetched_at')
.first()
)
if cache_entry and not force_refresh:
results[ticker] = {
'price': float(cache_entry.price),
'change_percent': float(cache_entry.change_percent or 0),
'prev_close': float(cache_entry.prev_close or 0),
'currency': cache_entry.currency,
'cached': True,
}
continue
# Determine Finnhub symbol
if exchange.upper() == 'HKG':
symbol = f"{ticker}.HK"
currency = 'HKD'
else:
symbol = ticker
currency = 'USD'
try:
resp = requests.get(
'https://finnhub.io/api/v1/quote',
params={'symbol': symbol, 'token': api_key},
timeout=5,
)
data = resp.json()
price = Decimal(str(data.get('c', 0)))
prev_close = Decimal(str(data.get('pc', 0)))
change_pct = (
((price - prev_close) / prev_close * 100)
if prev_close and prev_close != 0
else Decimal('0')
)
PriceCache.objects.create(
ticker=ticker,
exchange=exchange,
price=price,
currency=currency,
change_percent=change_pct,
prev_close=prev_close,
)
results[ticker] = {
'price': float(price),
'change_percent': float(change_pct),
'prev_close': float(prev_close),
'currency': currency,
'cached': False,
}
except Exception as exc:
logger.warning("Failed to fetch quote for %s: %s", symbol, exc)
results[ticker] = {'price': None, 'change_percent': None, 'prev_close': None,
'currency': currency, 'cached': False, 'error': str(exc)}
return results
# ---------------------------------------------------------------------------
# Portfolio summary
# ---------------------------------------------------------------------------
def portfolio_summary(portfolio: Portfolio) -> dict:
"""Build a full summary dict with current prices and P&L."""
stocks = list(portfolio.stocks.prefetch_related('transactions'))
tickers = [(s.ticker, s.exchange) for s in stocks if s.shares_held > 0]
quotes = get_quotes(tickers) if tickers else {}
stock_summaries = []
total_cost = Decimal('0')
total_market_value = Decimal('0')
for stock in stocks:
cost_basis = stock.shares_held * stock.avg_cost
quote = quotes.get(stock.ticker, {})
current_price = Decimal(str(quote.get('price') or 0))
market_value = stock.shares_held * current_price
unrealized_pnl = market_value - cost_basis
unrealized_pnl_pct = (unrealized_pnl / cost_basis * 100) if cost_basis else Decimal('0')
stock_summaries.append({
'id': stock.id,
'ticker': stock.ticker,
'exchange': stock.exchange,
'company_name': stock.company_name,
'shares_held': float(stock.shares_held),
'avg_cost': float(stock.avg_cost),
'quote_currency': stock.quote_currency,
'current_price': float(current_price),
'cost_basis': float(cost_basis),
'market_value': float(market_value),
'unrealized_pnl': float(unrealized_pnl),
'unrealized_pnl_pct': float(unrealized_pnl_pct),
'change_percent': quote.get('change_percent'),
'is_active': stock.is_active,
})
total_cost += cost_basis
total_market_value += market_value
total_pnl = total_market_value - total_cost
total_pnl_pct = (total_pnl / total_cost * 100) if total_cost else Decimal('0')
return {
'portfolio': {
'id': portfolio.id,
'name': portfolio.name,
'account_id': portfolio.account_id,
'base_currency': portfolio.base_currency,
},
'stocks': stock_summaries,
'total_cost': float(total_cost),
'total_market_value': float(total_market_value),
'total_unrealized_pnl': float(total_pnl),
'total_unrealized_pnl_pct': float(total_pnl_pct),
}
+80
View File
@@ -0,0 +1,80 @@
"""Template views for the invest app."""
import json
import logging
from django.shortcuts import render, get_object_or_404
from .models import Portfolio, Report
from .services import portfolio_summary
logger = logging.getLogger(__name__)
def dashboard(request):
"""Landing page: list of portfolios with key metrics."""
portfolios = Portfolio.objects.prefetch_related('stocks').order_by('name')
summaries = []
for portfolio in portfolios:
try:
s = portfolio_summary(portfolio)
except Exception as exc:
logger.warning("portfolio_summary failed for %s: %s", portfolio.id, exc)
s = {
'portfolio': {'id': portfolio.id, 'name': portfolio.name},
'stocks': [],
'total_cost': 0,
'total_market_value': 0,
'total_unrealized_pnl': 0,
'total_unrealized_pnl_pct': 0,
}
summaries.append(s)
return render(request, 'invest/dashboard.html', {'summaries': summaries})
def portfolio_detail(request, pk):
"""Portfolio detail view with holdings table and allocation chart."""
portfolio = get_object_or_404(Portfolio, pk=pk)
try:
summary = portfolio_summary(portfolio)
except Exception as exc:
logger.error("portfolio_summary failed for %s: %s", pk, exc)
summary = {
'portfolio': {'id': portfolio.id, 'name': portfolio.name},
'stocks': [],
'total_cost': 0,
'total_market_value': 0,
'total_unrealized_pnl': 0,
'total_unrealized_pnl_pct': 0,
}
return render(request, 'invest/portfolio_detail.html', {
'portfolio': portfolio,
'summary': summary,
'summary_json': json.dumps(summary),
})
def portfolio_transactions(request, pk):
"""Transaction history for a portfolio."""
from .models import Transaction
portfolio = get_object_or_404(Portfolio, pk=pk)
transactions = Transaction.objects.filter(
stock__portfolio=portfolio
).select_related('stock').order_by('-date', '-created_at')
return render(request, 'invest/transactions.html', {
'portfolio': portfolio,
'transactions': transactions,
})
def reports_list(request):
"""List of all generated reports."""
reports = Report.objects.select_related('portfolio').order_by('-generated_at')
return render(request, 'invest/reports.html', {'reports': reports})
def report_detail(request, pk):
"""Single report view."""
report = get_object_or_404(Report.objects.select_related('portfolio'), pk=pk)
return render(request, 'invest/report_detail.html', {'report': report})
+38
View File
@@ -0,0 +1,38 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Portfolio{% endblock %} Invest</title>
<link href="{% static 'css/dist/styles.css' %}" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css" rel="stylesheet">
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
{% block extra_head %}{% endblock %}
</head>
<body class="bg-gray-100 min-h-screen">
<!-- Navigation -->
<nav class="bg-gray-900 text-white px-6 py-4 shadow-lg">
<div class="max-w-7xl mx-auto flex items-center justify-between">
<div class="flex items-center space-x-6">
<a href="{% url 'invest-dashboard' %}" class="text-lg font-bold text-white flex items-center">
<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
</a>
</div>
</nav>
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{% block content %}{% endblock %}
</main>
{% block extra_js %}{% endblock %}
</body>
</html>
+117
View File
@@ -0,0 +1,117 @@
{% extends "invest/base.html" %}
{% load static %}
{% block title %}Dashboard{% endblock %}
{% 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>
</div>
{% endif %}
{% for s in summaries %}
{% with p=s.portfolio %}
<div class="bg-white rounded-xl shadow mb-6 overflow-hidden">
<!-- Portfolio header -->
<div class="bg-gray-900 px-6 py-4 flex items-center justify-between">
<div>
<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>
</a>
</div>
<!-- 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>
</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>
</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>
</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>
</div>
</div>
<!-- Holdings mini-table -->
{% if s.stocks %}
<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-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">P&L</th>
<th class="pb-2 text-right font-medium">Day %</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
{% for stock in s.stocks %}
{% if stock.shares_held > 0 %}
<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 %}
</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">
{% if stock.current_price %}{{ stock.current_price|floatformat:4 }}{% 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 {% 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>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
{% endwith %}
{% endfor %}
{% endblock %}
@@ -0,0 +1,199 @@
{% 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 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>
<!-- 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>
<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>
</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>
</div>
<div id="allocationLegend" class="mt-4 space-y-1 text-xs"></div>
</div>
</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 %}
@@ -0,0 +1,47 @@
{% 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
@@ -0,0 +1,46 @@
{% 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 %}
+80
View File
@@ -0,0 +1,80 @@
{% extends "invest/base.html" %}
{% block title %}Transactions {{ portfolio.name }}{% 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>
<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>
<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">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>
</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 %}
</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>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+30
View File
@@ -0,0 +1,30 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import (
PortfolioViewSet, StockViewSet, TransactionViewSet,
ReportViewSet, QuotesView, AIUpdateView,
)
from . import template_views
router = DefaultRouter()
router.register(r'portfolios', PortfolioViewSet, basename='invest-portfolio')
router.register(r'stocks', StockViewSet, basename='invest-stock')
router.register(r'transactions', TransactionViewSet, basename='invest-transaction')
router.register(r'reports', ReportViewSet, basename='invest-report')
# API URL patterns (mounted at /api/invest/ in core/urls.py)
api_urlpatterns = [
path('', include(router.urls)),
path('quotes/', QuotesView.as_view(), name='invest-quotes'),
path('ai-update/', AIUpdateView.as_view(), name='invest-ai-update'),
]
# Template URL patterns (mounted at /invest/ in core/urls.py)
urlpatterns = [
path('', template_views.dashboard, name='invest-dashboard'),
path('portfolios/<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'),
]
+196
View File
@@ -0,0 +1,196 @@
import logging
from decimal import Decimal
from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import Portfolio, Stock, Transaction, Report, PriceCache
from .serializers import (
PortfolioSerializer, PortfolioListSerializer,
StockSerializer, StockListSerializer,
TransactionSerializer, ReportSerializer,
AIBatchUpdateSerializer,
)
from .services import (
portfolio_summary, ai_batch_update, get_quotes,
create_portfolio, upsert_stock, add_transaction, delete_transaction,
)
logger = logging.getLogger(__name__)
class PortfolioViewSet(viewsets.ModelViewSet):
queryset = Portfolio.objects.all()
def get_serializer_class(self):
if self.action == 'list':
return PortfolioListSerializer
return PortfolioSerializer
def perform_create(self, serializer):
serializer.save()
@action(detail=True, methods=['get'], url_path='summary')
def summary(self, request, pk=None):
"""Return full portfolio summary with current prices and P&L."""
portfolio = self.get_object()
try:
data = portfolio_summary(portfolio)
return Response(data)
except Exception as exc:
logger.error("portfolio_summary failed for %s: %s", portfolio.id, exc, exc_info=True)
return Response({'error': str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@action(detail=True, methods=['get'], url_path='transactions')
def transactions(self, request, pk=None):
"""List all transactions for all stocks in this portfolio."""
portfolio = self.get_object()
txs = Transaction.objects.filter(
stock__portfolio=portfolio
).select_related('stock').order_by('-date', '-created_at')
serializer = TransactionSerializer(txs, many=True)
return Response(serializer.data)
class StockViewSet(viewsets.ModelViewSet):
queryset = Stock.objects.select_related('portfolio').all()
def get_serializer_class(self):
if self.action == 'list':
return StockListSerializer
return StockSerializer
def get_queryset(self):
qs = super().get_queryset()
portfolio_id = self.request.query_params.get('portfolio')
if portfolio_id:
qs = qs.filter(portfolio_id=portfolio_id)
active_only = self.request.query_params.get('active')
if active_only and active_only.lower() in ('true', '1'):
qs = qs.filter(is_active=True)
return qs
@action(detail=True, methods=['get'], url_path='transactions')
def transactions(self, request, pk=None):
stock = self.get_object()
txs = stock.transactions.all().order_by('-date', '-created_at')
serializer = TransactionSerializer(txs, many=True)
return Response(serializer.data)
class TransactionViewSet(viewsets.ModelViewSet):
queryset = Transaction.objects.select_related('stock', 'stock__portfolio').all()
serializer_class = TransactionSerializer
def get_queryset(self):
qs = super().get_queryset()
stock_id = self.request.query_params.get('stock')
if stock_id:
qs = qs.filter(stock_id=stock_id)
portfolio_id = self.request.query_params.get('portfolio')
if portfolio_id:
qs = qs.filter(stock__portfolio_id=portfolio_id)
return qs.order_by('-date', '-created_at')
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
stock = data['stock']
try:
tx = add_transaction(
stock=stock,
tx_type=data['tx_type'],
date=data['date'],
price_per_share=data['price_per_share'],
shares=data['shares'],
fee=data.get('fee', Decimal('0')),
notes=data.get('notes', ''),
source=data.get('source', 'manual'),
idempotency_key=data.get('idempotency_key'),
)
except Exception as exc:
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
out = TransactionSerializer(tx)
return Response(out.data, status=status.HTTP_201_CREATED)
def destroy(self, request, *args, **kwargs):
tx = self.get_object()
try:
delete_transaction(tx)
except Exception as exc:
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(status=status.HTTP_204_NO_CONTENT)
class ReportViewSet(viewsets.ModelViewSet):
queryset = Report.objects.select_related('portfolio').all()
serializer_class = ReportSerializer
def get_queryset(self):
qs = super().get_queryset()
portfolio_id = self.request.query_params.get('portfolio')
if portfolio_id:
qs = qs.filter(portfolio_id=portfolio_id)
return qs
class QuotesView(APIView):
"""
POST /api/invest/quotes/
Body: {"tickers": [{"ticker": "AAPL", "exchange": ""}, ...], "force_refresh": false}
"""
def post(self, request):
tickers_data = request.data.get('tickers', [])
force_refresh = request.data.get('force_refresh', False)
if not isinstance(tickers_data, list) or not tickers_data:
return Response(
{'error': 'tickers must be a non-empty list'},
status=status.HTTP_400_BAD_REQUEST,
)
pairs = [(item.get('ticker', ''), item.get('exchange', '')) for item in tickers_data]
try:
quotes = get_quotes(pairs, force_refresh=force_refresh)
return Response(quotes)
except Exception as exc:
logger.error("get_quotes failed: %s", exc, exc_info=True)
return Response({'error': str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class AIUpdateView(APIView):
"""
POST /api/invest/ai-update/
Accepts a JSON body with a list of operations and applies them atomically.
Supports dry_run mode.
"""
def post(self, request):
serializer = AIBatchUpdateSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
operations = serializer.validated_data['operations']
dry_run = serializer.validated_data['dry_run']
try:
results = ai_batch_update(operations, dry_run=dry_run)
except Exception as exc:
logger.error("ai_batch_update failed: %s", exc, exc_info=True)
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
has_errors = any(r.get('status') == 'error' for r in results)
http_status = status.HTTP_200_OK if not has_errors else status.HTTP_207_MULTI_STATUS
return Response({
'dry_run': dry_run,
'results': results,
'applied': not dry_run and not has_errors,
}, status=http_status)