mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
feat(invest): simplify model - Portfolio/Stock/Transaction, real-time P/L via yfinance
This commit is contained in:
+1
-6
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
· {{ 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user