mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
Merge pull request #82 from wahyd4/feature/invest-portfolio
feat(invest): Add investment portfolio management feature
This commit is contained in:
@@ -105,3 +105,17 @@ class CoreConfig(AppConfig):
|
||||
import sys
|
||||
print(f'routermon: startup error (non-fatal): {exc}', file=sys.stderr, flush=True)
|
||||
logger.warning("routermon: startup error (non-fatal): %s", exc)
|
||||
|
||||
# ── invest: weekly portfolio snapshot (Saturday 08:00) ─────────────────
|
||||
try:
|
||||
from invest.tasks import snapshot_all_portfolios
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
scheduler.add_job(
|
||||
snapshot_all_portfolios,
|
||||
CronTrigger(day_of_week='sat', hour=8, minute=0),
|
||||
id='weekly_portfolio_snapshot',
|
||||
replace_existing=True,
|
||||
)
|
||||
logger.info("invest: scheduled weekly portfolio snapshot (Saturday 08:00)")
|
||||
except Exception as exc:
|
||||
logger.warning("invest: snapshot scheduler setup failed: %s", exc)
|
||||
|
||||
@@ -20,6 +20,7 @@ INSTALLED_APPS = [
|
||||
'new_theme',
|
||||
'simplemde',
|
||||
'markdown', # 只需要基本的markdown包
|
||||
'invest',
|
||||
'netscan',
|
||||
'nginxmon',
|
||||
'routermon',
|
||||
|
||||
@@ -31,6 +31,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.api_urls')),
|
||||
# Media files
|
||||
path('media/<path:path>', serve, {
|
||||
'document_root': settings.MEDIA_ROOT,
|
||||
@@ -46,6 +47,8 @@ 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 netscan and files BEFORE links.urls to prevent the alias catch-all from intercepting them
|
||||
path('ui/netscan/', include('netscan.urls')),
|
||||
path('ui/nginxmon/', include('nginxmon.urls')),
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
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')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
path('ai-update/', AIUpdateView.as_view(), name='invest-ai-update'),
|
||||
]
|
||||
@@ -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'
|
||||
@@ -0,0 +1,57 @@
|
||||
# Generated by Django 5.2.12 on 2026-04-18 11:31
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from decimal import Decimal
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Portfolio',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Transaction',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('action', models.CharField(choices=[('BUY', 'Buy'), ('SELL', 'Sell')], max_length=4)),
|
||||
('stock_code', models.CharField(help_text="Stock ticker code, e.g., 'NVDA', '9988.HK'", max_length=20)),
|
||||
('quantity', models.DecimalField(decimal_places=6, help_text='Number of shares', max_digits=20, validators=[django.core.validators.MinValueValidator(Decimal('0.000001'))])),
|
||||
('price_per_share', models.DecimalField(decimal_places=6, help_text='Price per share at time of transaction', max_digits=20, validators=[django.core.validators.MinValueValidator(Decimal('0'))])),
|
||||
('date', models.DateField()),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('portfolio', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to='invest.portfolio')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-date', '-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Stock',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('stock_code', models.CharField(help_text="Stock ticker code, e.g., 'NVDA', '9988.HK'", max_length=20)),
|
||||
('quantity', models.DecimalField(decimal_places=6, default=Decimal('0'), help_text='Number of shares held', max_digits=20, validators=[django.core.validators.MinValueValidator(Decimal('0'))])),
|
||||
('portfolio', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stocks', to='invest.portfolio')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['stock_code'],
|
||||
'unique_together': {('portfolio', 'stock_code')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
# Generated by Django 5.2.12 on 2026-04-18 11:48
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from decimal import Decimal
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('invest', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='transaction',
|
||||
name='price_per_share',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='stock',
|
||||
name='quantity',
|
||||
field=models.DecimalField(decimal_places=6, default=Decimal('0'), max_digits=20, validators=[django.core.validators.MinValueValidator(Decimal('0'))]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='stock',
|
||||
name='stock_code',
|
||||
field=models.CharField(help_text="Stock ticker, e.g. 'NVDA', '9988.HK'", max_length=20),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transaction',
|
||||
name='quantity',
|
||||
field=models.DecimalField(decimal_places=6, max_digits=20, validators=[django.core.validators.MinValueValidator(Decimal('0.000001'))]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transaction',
|
||||
name='stock_code',
|
||||
field=models.CharField(max_length=20),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PortfolioSnapshot',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('captured_at', models.DateTimeField()),
|
||||
('total_value', models.DecimalField(decimal_places=2, max_digits=20)),
|
||||
('portfolio', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='snapshots', to='invest.portfolio')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-captured_at'],
|
||||
'indexes': [models.Index(fields=['portfolio', 'captured_at'], name='invest_port_portfol_2f1f60_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,74 @@
|
||||
from django.db import models
|
||||
from django.core.validators import MinValueValidator
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
class Portfolio(models.Model):
|
||||
"""Represents an investment account/portfolio (e.g., 'MOMO', 'User IBKR')."""
|
||||
name = models.CharField(max_length=100)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
|
||||
class Stock(models.Model):
|
||||
"""Current holdings for a portfolio. Quantity only — no cost tracking."""
|
||||
portfolio = models.ForeignKey(Portfolio, on_delete=models.CASCADE, related_name='stocks')
|
||||
stock_code = models.CharField(max_length=20, help_text="Stock ticker, e.g. 'NVDA', '9988.HK'")
|
||||
quantity = models.DecimalField(
|
||||
max_digits=20,
|
||||
decimal_places=6,
|
||||
default=Decimal('0'),
|
||||
validators=[MinValueValidator(Decimal('0'))],
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = [('portfolio', 'stock_code')]
|
||||
ordering = ['stock_code']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.stock_code} ({self.portfolio.name})"
|
||||
|
||||
|
||||
class Transaction(models.Model):
|
||||
"""Buy/sell event log. No price stored — only quantity changes tracked."""
|
||||
ACTION_BUY = 'BUY'
|
||||
ACTION_SELL = 'SELL'
|
||||
ACTION_CHOICES = [(ACTION_BUY, 'Buy'), (ACTION_SELL, 'Sell')]
|
||||
|
||||
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)
|
||||
quantity = models.DecimalField(
|
||||
max_digits=20,
|
||||
decimal_places=6,
|
||||
validators=[MinValueValidator(Decimal('0.000001'))],
|
||||
)
|
||||
date = models.DateField()
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-date', '-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.action} {self.quantity} {self.stock_code} on {self.date}"
|
||||
|
||||
|
||||
class PortfolioSnapshot(models.Model):
|
||||
"""Weekly total-value snapshot per portfolio, captured Saturday 8 AM."""
|
||||
portfolio = models.ForeignKey(Portfolio, on_delete=models.CASCADE, related_name='snapshots')
|
||||
captured_at = models.DateTimeField()
|
||||
total_value = models.DecimalField(max_digits=20, decimal_places=2)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-captured_at']
|
||||
indexes = [
|
||||
models.Index(fields=['portfolio', 'captured_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.portfolio.name} @ {self.captured_at:%Y-%m-%d %H:%M}: ${self.total_value}"
|
||||
@@ -0,0 +1,140 @@
|
||||
from rest_framework import serializers
|
||||
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):
|
||||
action_display = serializers.CharField(source='get_action_display', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Transaction
|
||||
fields = [
|
||||
'id', 'portfolio', 'action', 'action_display',
|
||||
'stock_code', 'quantity', 'date', 'created_at',
|
||||
]
|
||||
read_only_fields = ['id', 'created_at']
|
||||
|
||||
|
||||
class PortfolioSerializer(serializers.ModelSerializer):
|
||||
stocks = StockSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Portfolio
|
||||
fields = ['id', 'name', 'created_at', 'stocks']
|
||||
read_only_fields = ['id', 'created_at']
|
||||
|
||||
|
||||
class PortfolioListSerializer(serializers.ModelSerializer):
|
||||
stock_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Portfolio
|
||||
fields = ['id', 'name', 'created_at', 'stock_count']
|
||||
read_only_fields = ['id', 'created_at']
|
||||
|
||||
def get_stock_count(self, obj):
|
||||
return obj.stocks.count()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AI Update
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class AIHoldingInputSerializer(serializers.Serializer):
|
||||
stock_code = serializers.CharField()
|
||||
quantity = serializers.FloatField()
|
||||
|
||||
|
||||
class AIUpdateSerializer(serializers.Serializer):
|
||||
portfolio_id = serializers.IntegerField()
|
||||
holdings = AIHoldingInputSerializer(many=True)
|
||||
reset = serializers.BooleanField(default=False)
|
||||
|
||||
|
||||
|
||||
class TransactionSerializer(serializers.ModelSerializer):
|
||||
action_display = serializers.CharField(source='get_action_display', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Transaction
|
||||
fields = [
|
||||
'id', 'portfolio', 'action', 'action_display',
|
||||
'stock_code', 'quantity', 'price_per_share', 'date',
|
||||
'created_at',
|
||||
]
|
||||
read_only_fields = ['id', 'created_at']
|
||||
|
||||
|
||||
class PortfolioSerializer(serializers.ModelSerializer):
|
||||
stocks = StockSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Portfolio
|
||||
fields = ['id', 'name', 'created_at', 'stocks']
|
||||
read_only_fields = ['id', 'created_at']
|
||||
|
||||
|
||||
class PortfolioListSerializer(serializers.ModelSerializer):
|
||||
stock_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Portfolio
|
||||
fields = ['id', 'name', 'created_at', 'stock_count']
|
||||
read_only_fields = ['id', 'created_at']
|
||||
|
||||
def get_stock_count(self, obj):
|
||||
return obj.stocks.count()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------+
|
||||
# Holdings (with real-time prices) |
|
||||
# -------------------------------------------------------------------------+
|
||||
|
||||
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 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()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------+
|
||||
# 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()
|
||||
@@ -0,0 +1,360 @@
|
||||
"""
|
||||
Service layer for the invest app.
|
||||
Prices fetched from Yahoo Finance on demand via yfinance.
|
||||
No cost basis or P&L tracking.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from django.db.models import Sum
|
||||
|
||||
from .models import Portfolio, Stock, PortfolioSnapshot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-process price cache (5 min TTL)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_price_cache: dict[str, tuple[float, datetime]] = {}
|
||||
_PRICE_CACHE_TTL_SECONDS = 300
|
||||
|
||||
|
||||
def _get_yfinance_price(stock_code: str) -> Optional[float]:
|
||||
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
|
||||
|
||||
|
||||
def get_current_price(stock_code: str) -> Optional[float]:
|
||||
now = datetime.now()
|
||||
cached = _price_cache.get(stock_code)
|
||||
if cached:
|
||||
price, cached_at = cached
|
||||
if (now - cached_at).total_seconds() < _PRICE_CACHE_TTL_SECONDS:
|
||||
return price
|
||||
price = _get_yfinance_price(stock_code)
|
||||
if price is not None:
|
||||
_price_cache[stock_code] = (price, now)
|
||||
return price
|
||||
if cached:
|
||||
logger.info("Using stale cached price for %s", stock_code)
|
||||
return cached[0]
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Portfolio value (live prices, no cost tracking)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_portfolio_value(portfolio: Portfolio) -> dict:
|
||||
"""Return live holdings with current prices and total value."""
|
||||
holdings = []
|
||||
total_value = Decimal('0')
|
||||
|
||||
for stock in portfolio.stocks.filter(quantity__gt=0):
|
||||
price = get_current_price(stock.stock_code) or 0.0
|
||||
value = Decimal(str(price)) * stock.quantity
|
||||
holdings.append({
|
||||
'stock_code': stock.stock_code,
|
||||
'quantity': float(stock.quantity),
|
||||
'current_price': price,
|
||||
'current_value': float(value),
|
||||
})
|
||||
total_value += value
|
||||
|
||||
return {
|
||||
'portfolio_id': portfolio.id,
|
||||
'portfolio_name': portfolio.name,
|
||||
'holdings': holdings,
|
||||
'total_value': float(total_value),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Weekly snapshot overview
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_snapshot_total(date) -> Optional[float]:
|
||||
result = PortfolioSnapshot.objects.filter(
|
||||
captured_at__date=date
|
||||
).aggregate(total=Sum('total_value'))['total']
|
||||
return float(result) if result is not None else None
|
||||
|
||||
|
||||
def get_weekly_overview() -> dict:
|
||||
"""
|
||||
Compute overview from the two most recent Saturday snapshots.
|
||||
Returns totals, week-over-week change, and per-portfolio rows.
|
||||
"""
|
||||
# Find the 2 most recent distinct snapshot dates
|
||||
seen_days: list = []
|
||||
for dt in (PortfolioSnapshot.objects
|
||||
.values_list('captured_at', flat=True)
|
||||
.order_by('-captured_at')):
|
||||
day = dt.date() if hasattr(dt, 'date') else dt
|
||||
if day not in seen_days:
|
||||
seen_days.append(day)
|
||||
if len(seen_days) == 2:
|
||||
break
|
||||
|
||||
this_week_date = seen_days[0] if len(seen_days) >= 1 else None
|
||||
last_week_date = seen_days[1] if len(seen_days) >= 2 else None
|
||||
|
||||
this_week_total = _get_snapshot_total(this_week_date) if this_week_date else None
|
||||
last_week_total = _get_snapshot_total(last_week_date) if last_week_date else None
|
||||
|
||||
week_gain = None
|
||||
week_change_pct = None
|
||||
if this_week_total is not None and last_week_total is not None and last_week_total > 0:
|
||||
week_gain = this_week_total - last_week_total
|
||||
week_change_pct = round((week_gain / last_week_total) * 100, 2)
|
||||
|
||||
# Per-portfolio breakdown
|
||||
_palette = [
|
||||
{'badge': 'bg-indigo-100 text-indigo-800', 'row': 'bg-indigo-50', 'border': 'border-indigo-200'},
|
||||
{'badge': 'bg-emerald-100 text-emerald-800', 'row': 'bg-emerald-50', 'border': 'border-emerald-200'},
|
||||
{'badge': 'bg-amber-100 text-amber-800', 'row': 'bg-amber-50', 'border': 'border-amber-200'},
|
||||
{'badge': 'bg-rose-100 text-rose-800', 'row': 'bg-rose-50', 'border': 'border-rose-200'},
|
||||
{'badge': 'bg-sky-100 text-sky-800', 'row': 'bg-sky-50', 'border': 'border-sky-200'},
|
||||
]
|
||||
portfolio_rows = []
|
||||
for idx, portfolio in enumerate(Portfolio.objects.all()):
|
||||
def _snap(date):
|
||||
if not date:
|
||||
return None
|
||||
s = PortfolioSnapshot.objects.filter(
|
||||
portfolio=portfolio, captured_at__date=date
|
||||
).first()
|
||||
return float(s.total_value) if s else None
|
||||
|
||||
this_val = _snap(this_week_date)
|
||||
last_val = _snap(last_week_date)
|
||||
|
||||
change = change_pct = None
|
||||
if this_val is not None and last_val is not None and last_val > 0:
|
||||
change = this_val - last_val
|
||||
change_pct = round((change / last_val) * 100, 2)
|
||||
|
||||
portfolio_rows.append({
|
||||
'portfolio': portfolio,
|
||||
'this_week_value': this_val,
|
||||
'last_week_value': last_val,
|
||||
'change': change,
|
||||
'change_pct': change_pct,
|
||||
'position_count': portfolio.stocks.filter(quantity__gt=0).count(),
|
||||
'colors': _palette[idx % len(_palette)],
|
||||
})
|
||||
|
||||
return {
|
||||
'this_week_total': this_week_total,
|
||||
'last_week_total': last_week_total,
|
||||
'this_week_date': this_week_date,
|
||||
'last_week_date': last_week_date,
|
||||
'week_gain': week_gain,
|
||||
'week_change_pct': week_change_pct,
|
||||
'portfolio_rows': portfolio_rows,
|
||||
'portfolio_count': Portfolio.objects.count(),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Holdings sync (AI / manual)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_all_holdings() -> list[dict]:
|
||||
"""
|
||||
Return live holdings for every portfolio, grouped for dashboard display.
|
||||
Each entry: portfolio, portfolio_color_class, holdings (list), total_value
|
||||
"""
|
||||
# Assign a distinct Tailwind color set per portfolio (cycled if more than defined)
|
||||
palette = [
|
||||
{'badge': 'bg-indigo-100 text-indigo-800', 'row': 'bg-indigo-50', 'border': 'border-indigo-200'},
|
||||
{'badge': 'bg-emerald-100 text-emerald-800', 'row': 'bg-emerald-50', 'border': 'border-emerald-200'},
|
||||
{'badge': 'bg-amber-100 text-amber-800', 'row': 'bg-amber-50', 'border': 'border-amber-200'},
|
||||
{'badge': 'bg-rose-100 text-rose-800', 'row': 'bg-rose-50', 'border': 'border-rose-200'},
|
||||
{'badge': 'bg-sky-100 text-sky-800', 'row': 'bg-sky-50', 'border': 'border-sky-200'},
|
||||
]
|
||||
|
||||
result = []
|
||||
for idx, portfolio in enumerate(Portfolio.objects.all()):
|
||||
colors = palette[idx % len(palette)]
|
||||
data = get_portfolio_value(portfolio)
|
||||
result.append({
|
||||
'portfolio': portfolio,
|
||||
'colors': colors,
|
||||
'holdings': data['holdings'],
|
||||
'total_value': data['total_value'],
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Performance chart data (cumulative % from first snapshot + benchmarks)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Simple in-process cache — benchmarks don't need to refresh every page load
|
||||
_chart_cache: dict = {}
|
||||
_CHART_CACHE_TTL = 900 # 15 minutes
|
||||
|
||||
|
||||
def get_performance_chart_data() -> Optional[str]:
|
||||
"""
|
||||
Build Chart.js-ready JSON with cumulative % return from the earliest snapshot.
|
||||
Base week = 0%. Each portfolio gets a series; S&P 500 (SPY) and QQQ added as benchmarks.
|
||||
Returns a JSON string (safe to pass directly to the template) or None if no snapshots.
|
||||
"""
|
||||
now = datetime.now()
|
||||
cached = _chart_cache.get('performance')
|
||||
if cached:
|
||||
data, cached_at = cached
|
||||
if (now - cached_at).total_seconds() < _CHART_CACHE_TTL:
|
||||
return data
|
||||
|
||||
result = _build_performance_chart_data()
|
||||
_chart_cache['performance'] = (result, now)
|
||||
return result
|
||||
|
||||
|
||||
def _build_performance_chart_data() -> Optional[str]:
|
||||
# Collect all distinct snapshot dates in ascending order
|
||||
all_dates: list = []
|
||||
for dt in (PortfolioSnapshot.objects
|
||||
.values_list('captured_at', flat=True)
|
||||
.order_by('captured_at')):
|
||||
day = dt.date() if hasattr(dt, 'date') else dt
|
||||
if day not in all_dates:
|
||||
all_dates.append(day)
|
||||
|
||||
if not all_dates:
|
||||
return None
|
||||
|
||||
base_date = all_dates[0]
|
||||
|
||||
# Per-portfolio cumulative % series
|
||||
portfolio_colors = ['#2563EB', '#7C3AED', '#0D9488', '#DB2777', '#EA580C']
|
||||
datasets = []
|
||||
|
||||
for idx, portfolio in enumerate(Portfolio.objects.all()):
|
||||
snaps = {
|
||||
(snap.captured_at.date() if hasattr(snap.captured_at, 'date') else snap.captured_at): float(snap.total_value)
|
||||
for snap in PortfolioSnapshot.objects.filter(portfolio=portfolio).order_by('captured_at')
|
||||
}
|
||||
base_val = snaps.get(base_date)
|
||||
if not base_val or base_val == 0:
|
||||
continue
|
||||
|
||||
data_pts = [
|
||||
round((snaps[d] - base_val) / base_val * 100, 2) if d in snaps else None
|
||||
for d in all_dates
|
||||
]
|
||||
color = portfolio_colors[idx % len(portfolio_colors)]
|
||||
datasets.append({
|
||||
'label': portfolio.name,
|
||||
'data': data_pts,
|
||||
'borderColor': color,
|
||||
'backgroundColor': color,
|
||||
'borderWidth': 2,
|
||||
'pointRadius': 5,
|
||||
'pointHoverRadius': 7,
|
||||
'tension': 0.3,
|
||||
'borderDash': [],
|
||||
'fill': False,
|
||||
})
|
||||
|
||||
# Benchmark series — start 7 days before base to capture the last trading day prior
|
||||
start_str = (base_date - timedelta(days=7)).isoformat()
|
||||
end_str = (all_dates[-1] + timedelta(days=5)).isoformat()
|
||||
|
||||
def _benchmark(ticker: str, label: str, color: str) -> Optional[dict]:
|
||||
try:
|
||||
import yfinance as yf
|
||||
hist = yf.Ticker(ticker).history(start=start_str, end=end_str)
|
||||
if hist.empty:
|
||||
return None
|
||||
# Build date → close mapping
|
||||
closes = {
|
||||
(d.date() if hasattr(d, 'date') else d): float(v)
|
||||
for d, v in hist['Close'].items()
|
||||
}
|
||||
sorted_trading_days = sorted(closes.keys())
|
||||
|
||||
def closest_close(target):
|
||||
candidates = [td for td in sorted_trading_days if td <= target]
|
||||
return closes[candidates[-1]] if candidates else None
|
||||
|
||||
base_price = closest_close(base_date)
|
||||
if not base_price:
|
||||
return None
|
||||
data_pts = [
|
||||
round((closest_close(d) - base_price) / base_price * 100, 2)
|
||||
if closest_close(d) is not None else None
|
||||
for d in all_dates
|
||||
]
|
||||
return {
|
||||
'label': label,
|
||||
'data': data_pts,
|
||||
'borderColor': color,
|
||||
'backgroundColor': color,
|
||||
'borderWidth': 1.5,
|
||||
'pointRadius': 3,
|
||||
'pointHoverRadius': 5,
|
||||
'tension': 0.3,
|
||||
'borderDash': [5, 5],
|
||||
'fill': False,
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.warning("benchmark %s failed: %s", ticker, exc)
|
||||
return None
|
||||
|
||||
spy = _benchmark('SPY', 'S&P 500', '#D97706')
|
||||
qqq = _benchmark('QQQ', 'QQQ', '#16A34A')
|
||||
if spy:
|
||||
datasets.append(spy)
|
||||
if qqq:
|
||||
datasets.append(qqq)
|
||||
|
||||
labels = [d.strftime('%b %-d') for d in all_dates]
|
||||
return json.dumps({'labels': labels, 'datasets': datasets})
|
||||
|
||||
|
||||
def ai_update_holdings(portfolio: Portfolio, holdings: list[dict], reset: bool = False) -> dict:
|
||||
"""Update Stock records. No cost/price tracking."""
|
||||
from django.db import transaction as db_transaction
|
||||
|
||||
results = []
|
||||
with db_transaction.atomic():
|
||||
if reset:
|
||||
portfolio.stocks.all().delete()
|
||||
|
||||
for item in holdings:
|
||||
stock_code = item['stock_code']
|
||||
quantity = Decimal(str(item['quantity']))
|
||||
|
||||
stock, created = Stock.objects.update_or_create(
|
||||
portfolio=portfolio,
|
||||
stock_code=stock_code,
|
||||
defaults={'quantity': quantity},
|
||||
)
|
||||
results.append({
|
||||
'stock_code': stock_code,
|
||||
'quantity': float(quantity),
|
||||
'created': created,
|
||||
})
|
||||
|
||||
return {
|
||||
'portfolio_id': portfolio.id,
|
||||
'portfolio_name': portfolio.name,
|
||||
'reset': reset,
|
||||
'results': results,
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Background tasks for the invest app.
|
||||
"""
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def snapshot_all_portfolios():
|
||||
"""
|
||||
Capture a PortfolioSnapshot for every portfolio.
|
||||
Scheduled every Saturday at 08:00. Also callable manually for backfill.
|
||||
"""
|
||||
from django.utils import timezone
|
||||
from .models import Portfolio, PortfolioSnapshot
|
||||
from .services import get_portfolio_value
|
||||
|
||||
now = timezone.now()
|
||||
today = now.date()
|
||||
logger.info("invest: starting portfolio snapshot at %s", now)
|
||||
|
||||
count = 0
|
||||
for portfolio in Portfolio.objects.prefetch_related('stocks').all():
|
||||
try:
|
||||
data = get_portfolio_value(portfolio)
|
||||
total_value = Decimal(str(data['total_value']))
|
||||
|
||||
# One snapshot per portfolio per day — overwrite if run twice
|
||||
PortfolioSnapshot.objects.filter(
|
||||
portfolio=portfolio,
|
||||
captured_at__date=today,
|
||||
).delete()
|
||||
|
||||
PortfolioSnapshot.objects.create(
|
||||
portfolio=portfolio,
|
||||
captured_at=now,
|
||||
total_value=total_value,
|
||||
)
|
||||
count += 1
|
||||
logger.info("invest: snapshot %s = $%.2f", portfolio.name, total_value)
|
||||
except Exception as exc:
|
||||
logger.error("invest: snapshot failed for %s: %s", portfolio.name, exc, exc_info=True)
|
||||
|
||||
logger.info("invest: snapshot complete — %d portfolios", count)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Template views for the invest app."""
|
||||
import logging
|
||||
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import Portfolio
|
||||
from .services import get_portfolio_value, get_weekly_overview, get_all_holdings, get_performance_chart_data
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def dashboard(request):
|
||||
"""Landing page: weekly snapshot overview + per-portfolio table."""
|
||||
overview = get_weekly_overview()
|
||||
|
||||
# Determine current Australian financial year (Jul–Jun)
|
||||
now = timezone.now()
|
||||
fy_start = now.year if now.month >= 7 else now.year - 1
|
||||
fy_label = f"FY {str(fy_start)[2:]}-{str(fy_start + 1)[2:]}"
|
||||
|
||||
all_holdings = get_all_holdings()
|
||||
# Merge snapshot data (value, change, change_pct) into each holdings group
|
||||
rows_by_id = {row['portfolio'].id: row for row in overview.get('portfolio_rows', [])}
|
||||
for group in all_holdings:
|
||||
row = rows_by_id.get(group['portfolio'].id, {})
|
||||
group['this_week_value'] = row.get('this_week_value')
|
||||
group['change'] = row.get('change')
|
||||
group['change_pct'] = row.get('change_pct')
|
||||
group['position_count'] = row.get('position_count', len(group['holdings']))
|
||||
|
||||
return render(request, 'invest/dashboard.html', {
|
||||
'overview': overview,
|
||||
'fy_label': fy_label,
|
||||
'all_holdings': all_holdings,
|
||||
'chart_data_json': get_performance_chart_data() or 'null',
|
||||
})
|
||||
|
||||
|
||||
def portfolio_detail(request, pk):
|
||||
"""Portfolio detail: live holdings, no cost/P&L."""
|
||||
portfolio = get_object_or_404(Portfolio, pk=pk)
|
||||
try:
|
||||
summary = get_portfolio_value(portfolio)
|
||||
except Exception as exc:
|
||||
logger.error("get_portfolio_value failed for %s: %s", pk, exc)
|
||||
summary = {
|
||||
'portfolio_id': portfolio.id,
|
||||
'portfolio_name': portfolio.name,
|
||||
'holdings': [],
|
||||
'total_value': 0,
|
||||
}
|
||||
return render(request, 'invest/portfolio_detail.html', {
|
||||
'portfolio': portfolio,
|
||||
'summary': summary,
|
||||
})
|
||||
|
||||
|
||||
def portfolio_transactions(request, pk):
|
||||
"""Transaction history for a portfolio."""
|
||||
portfolio = get_object_or_404(Portfolio, pk=pk)
|
||||
transactions = portfolio.transactions.all().order_by('-date', '-created_at')
|
||||
return render(request, 'invest/transactions.html', {
|
||||
'portfolio': portfolio,
|
||||
'transactions': transactions,
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
{% 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-stone-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>
|
||||
</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>
|
||||
@@ -0,0 +1,217 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static i18n %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-2">
|
||||
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase">{{ fy_label }} SNAPSHOT</p>
|
||||
</div>
|
||||
|
||||
<!-- ── Top metric cards ──────────────────────────────────────── -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-3">
|
||||
|
||||
<!-- Total value -->
|
||||
<div class="bg-white rounded-lg p-5 shadow-sm">
|
||||
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">Total Value</p>
|
||||
{% if overview.this_week_total is not None %}
|
||||
<p class="text-3xl font-bold text-stone-900">${{ overview.this_week_total|floatformat:0 }}</p>
|
||||
{% else %}
|
||||
<p class="text-3xl font-bold text-stone-400">—</p>
|
||||
{% endif %}
|
||||
<p class="text-sm text-stone-400 mt-1">
|
||||
Across {{ overview.portfolio_count }} portfolio{{ overview.portfolio_count|pluralize }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- This week's gain -->
|
||||
<div class="bg-white rounded-lg p-5 shadow-sm">
|
||||
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">This Week</p>
|
||||
{% if overview.week_gain is not None %}
|
||||
<p class="text-3xl font-bold {% if overview.week_gain >= 0 %}text-green-800{% else %}text-red-700{% endif %}">
|
||||
{% if overview.week_gain >= 0 %}+{% endif %}${{ overview.week_gain|floatformat:0 }}
|
||||
</p>
|
||||
<p class="text-sm text-stone-400 mt-1">
|
||||
{% if overview.week_gain >= 0 %}+{% endif %}{{ overview.week_change_pct|floatformat:2 }}% vs last week
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="text-3xl font-bold text-stone-400">—</p>
|
||||
<p class="text-sm text-stone-400 mt-1">No prior snapshot</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Week change % -->
|
||||
<div class="bg-white rounded-lg p-5 shadow-sm">
|
||||
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">Week Change</p>
|
||||
{% if overview.week_change_pct is not None %}
|
||||
<p class="text-3xl font-bold {% if overview.week_change_pct >= 0 %}text-green-800{% else %}text-red-700{% endif %}">
|
||||
{% if overview.week_change_pct >= 0 %}+{% endif %}{{ overview.week_change_pct|floatformat:2 }}%
|
||||
</p>
|
||||
{% if overview.last_week_date %}
|
||||
<p class="text-sm text-stone-400 mt-1">Last: {{ overview.last_week_date|date:"M j" }}</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-3xl font-bold text-stone-400">—</p>
|
||||
<p class="text-sm text-stone-400 mt-1">Need 2+ snapshots</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Last snapshot date -->
|
||||
<div class="bg-white rounded-lg p-5 shadow-sm">
|
||||
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">Last Snapshot</p>
|
||||
{% if overview.this_week_date %}
|
||||
<p class="text-3xl font-bold text-stone-900">{{ overview.this_week_date|date:"M j" }}</p>
|
||||
<p class="text-sm text-stone-400 mt-1">{{ overview.this_week_date|date:"l, Y" }}</p>
|
||||
{% else %}
|
||||
<p class="text-3xl font-bold text-stone-400">—</p>
|
||||
<p class="text-sm text-stone-400 mt-1">No snapshots yet</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── Performance chart ─────────────────────────────────────── -->
|
||||
{% if chart_data_json != 'null' %}
|
||||
<div class="bg-white rounded-lg shadow-sm p-5 mb-3">
|
||||
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase mb-4">
|
||||
{{ fy_label }} Performance vs Benchmarks
|
||||
</p>
|
||||
<canvas id="performanceChart" height="90"></canvas>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- ── Hint ──────────────────────────────────────────────────── -->
|
||||
<p class="text-xs text-stone-400 text-center mt-2 mb-6">
|
||||
Snapshots captured every Saturday 08:00 · Values in portfolio's quote currency
|
||||
</p>
|
||||
|
||||
<!-- ── Live holdings (per-portfolio cards) ───────────────────── -->
|
||||
{% if all_holdings %}
|
||||
<div class="space-y-4">
|
||||
{% for group in all_holdings %}
|
||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<!-- Card header -->
|
||||
<div class="px-6 py-4 flex items-center justify-between border-b border-stone-100">
|
||||
<div>
|
||||
<p class="font-semibold text-stone-900 text-sm">
|
||||
{{ group.portfolio.name }}
|
||||
</p>
|
||||
<p class="text-xs text-stone-400 mt-0.5">
|
||||
{{ group.position_count }} position{{ group.position_count|pluralize }}
|
||||
{% if overview.this_week_date %}· Last updated {{ overview.this_week_date|date:"j M Y" }}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-bold text-stone-900 text-sm">
|
||||
{% if group.this_week_value is not None %}${{ group.this_week_value|floatformat:0 }}{% else %}<span class="text-stone-300">—</span>{% endif %}
|
||||
</p>
|
||||
{% if group.change is not None %}
|
||||
<p class="text-xs font-medium mt-0.5 {% if group.change >= 0 %}text-green-700{% else %}text-red-600{% endif %}">
|
||||
{% if group.change >= 0 %}+{% endif %}${{ group.change|floatformat:0 }}
|
||||
({% if group.change_pct >= 0 %}+{% endif %}{{ group.change_pct|floatformat:1 }}%)
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="text-xs text-stone-300 mt-0.5">No prior snapshot</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Holdings table -->
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-xs font-semibold tracking-widest text-stone-400 uppercase border-b border-stone-100">
|
||||
<th class="px-6 py-3 text-left">Ticker</th>
|
||||
<th class="px-6 py-3 text-right">Qty</th>
|
||||
<th class="px-6 py-3 text-right">Price</th>
|
||||
<th class="px-6 py-3 text-right">Mkt Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for stock in group.holdings %}
|
||||
<tr class="border-b border-stone-50 hover:bg-stone-50 {{ group.colors.row }}">
|
||||
<td class="px-6 py-3 font-medium text-stone-900">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{ group.colors.badge }}">
|
||||
{{ stock.stock_code }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-3 text-right text-stone-500">{{ stock.quantity|floatformat:0 }}</td>
|
||||
<td class="px-6 py-3 text-right text-stone-500">
|
||||
{% if stock.current_price %}${{ stock.current_price|floatformat:2 }}{% else %}<span class="text-stone-300">—</span>{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-3 text-right font-semibold text-stone-800">
|
||||
{% if stock.current_value %}${{ stock.current_value|floatformat:0 }}{% else %}<span class="text-stone-300">—</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{% if chart_data_json != 'null' %}
|
||||
<script>
|
||||
(function () {
|
||||
const raw = {{ chart_data_json|safe }};
|
||||
if (!raw) return;
|
||||
|
||||
const ctx = document.getElementById('performanceChart');
|
||||
if (!ctx) return;
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: raw,
|
||||
options: {
|
||||
responsive: true,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
align: 'start',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
pointStyle: 'rect',
|
||||
pointStyleWidth: 14,
|
||||
padding: 20,
|
||||
font: { size: 12, weight: '600' },
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function (ctx) {
|
||||
const v = ctx.parsed.y;
|
||||
if (v === null || v === undefined) return ctx.dataset.label + ': —';
|
||||
const sign = v >= 0 ? '+' : '';
|
||||
return ctx.dataset.label + ': ' + sign + v.toFixed(2) + '%';
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
ticks: {
|
||||
callback: function (v) {
|
||||
return (v >= 0 ? '+' : '') + v.toFixed(1) + '%';
|
||||
},
|
||||
font: { size: 11 },
|
||||
},
|
||||
grid: { color: '#f5f5f4' },
|
||||
},
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { font: { size: 11 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,71 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static i18n %}
|
||||
|
||||
{% block title %}{{ portfolio.name }} - Portfolio{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-4">
|
||||
<a href="{% url 'invest-dashboard' %}" class="text-stone-400 hover:text-stone-700 text-sm">← Dashboard</a>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase">{{ portfolio.name }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Total value card -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
|
||||
<div class="bg-white rounded-lg p-5 shadow-sm">
|
||||
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">Live Total Value</p>
|
||||
<p class="text-3xl font-bold text-stone-900">${{ summary.total_value|floatformat:0 }}</p>
|
||||
<p class="text-sm text-stone-400 mt-1">{{ summary.holdings|length }} position{{ summary.holdings|length|pluralize }}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg p-5 shadow-sm">
|
||||
<p class="text-xs font-semibold tracking-widest text-stone-400 uppercase mb-1">Transactions</p>
|
||||
<a href="{% url 'invest-portfolio-transactions' portfolio.id %}" class="text-3xl font-bold text-stone-700 hover:text-stone-900">
|
||||
{{ portfolio.transactions.count }}
|
||||
</a>
|
||||
<p class="text-sm text-stone-400 mt-1">Total recorded</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Holdings table -->
|
||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-stone-100">
|
||||
<p class="text-xs font-semibold tracking-widest text-stone-500 uppercase">Current Holdings</p>
|
||||
</div>
|
||||
{% if summary.holdings %}
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-xs font-semibold tracking-widest text-stone-400 uppercase border-b border-stone-100">
|
||||
<th class="px-6 py-3 text-left">Stock</th>
|
||||
<th class="px-6 py-3 text-right">Quantity</th>
|
||||
<th class="px-6 py-3 text-right">Current Price</th>
|
||||
<th class="px-6 py-3 text-right">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-stone-50">
|
||||
{% for stock in summary.holdings %}
|
||||
<tr class="hover:bg-stone-50">
|
||||
<td class="px-6 py-3 font-semibold text-stone-900">{{ stock.stock_code }}</td>
|
||||
<td class="px-6 py-3 text-right text-stone-600">{{ stock.quantity|floatformat:2 }}</td>
|
||||
<td class="px-6 py-3 text-right text-stone-600">
|
||||
{% if stock.current_price > 0 %}${{ stock.current_price|floatformat:2 }}{% else %}<span class="text-stone-300">—</span>{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-3 text-right font-medium text-stone-900">${{ stock.current_value|floatformat:0 }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="border-t-2 border-stone-200 bg-stone-50">
|
||||
<td class="px-6 py-3 font-semibold text-stone-500 text-xs uppercase tracking-wide" colspan="3">Total</td>
|
||||
<td class="px-6 py-3 text-right font-bold text-stone-900">${{ summary.total_value|floatformat:0 }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="px-6 py-12 text-center">
|
||||
<p class="text-stone-400">No holdings in this portfolio.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,58 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static i18n %}
|
||||
|
||||
{% block title %}{{ portfolio.name }} - Transactions{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<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">
|
||||
<!-- 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>
|
||||
|
||||
{% if transactions %}
|
||||
<div class="px-6 py-4">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-xs text-stone-400 font-semibold tracking-widest uppercase border-b border-stone-100">
|
||||
<th class="pb-3 text-left">Date</th>
|
||||
<th class="pb-3 text-left">Action</th>
|
||||
<th class="pb-3 text-left">Stock</th>
|
||||
<th class="pb-3 text-right">Quantity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-stone-50">
|
||||
{% for tx in transactions %}
|
||||
<tr class="hover:bg-stone-50">
|
||||
<td class="py-3 text-stone-500">{{ tx.date|date:"Y-m-d" }}</td>
|
||||
<td class="py-3">
|
||||
<span class="{% if tx.action == 'BUY' %}text-green-700{% else %}text-red-600{% endif %} font-medium">
|
||||
{{ tx.action }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-3 font-semibold text-stone-900">{{ tx.stock_code }}</td>
|
||||
<td class="py-3 text-right text-stone-600">{{ tx.quantity|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 %}
|
||||
@@ -0,0 +1,23 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
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')
|
||||
|
||||
# API URL patterns (mounted at /api/invest/ in core/urls.py)
|
||||
api_urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
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'),
|
||||
]
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
import logging
|
||||
|
||||
from django.shortcuts import get_object_or_404
|
||||
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
|
||||
from .serializers import (
|
||||
PortfolioSerializer, PortfolioListSerializer,
|
||||
StockSerializer, TransactionSerializer,
|
||||
AIUpdateSerializer,
|
||||
)
|
||||
from .services import get_portfolio_value, ai_update_holdings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PortfolioViewSet(viewsets.ModelViewSet):
|
||||
queryset = Portfolio.objects.prefetch_related('stocks').all()
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'list':
|
||||
return PortfolioListSerializer
|
||||
return PortfolioSerializer
|
||||
|
||||
@action(detail=True, methods=['get'], url_path='holdings')
|
||||
def holdings(self, request, pk=None):
|
||||
"""Return holdings with real-time prices."""
|
||||
portfolio = self.get_object()
|
||||
try:
|
||||
data = get_portfolio_value(portfolio)
|
||||
return Response(data)
|
||||
except Exception as exc:
|
||||
logger.error("get_portfolio_value 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 this portfolio."""
|
||||
portfolio = self.get_object()
|
||||
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()
|
||||
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)
|
||||
return qs
|
||||
|
||||
|
||||
class TransactionViewSet(viewsets.ModelViewSet):
|
||||
queryset = Transaction.objects.select_related('portfolio').all()
|
||||
serializer_class = TransactionSerializer
|
||||
|
||||
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)
|
||||
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')
|
||||
|
||||
|
||||
class AIUpdateView(APIView):
|
||||
"""
|
||||
POST /api/invest/ai-update/
|
||||
Sync portfolio holdings (quantity only, no price).
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
serializer = AIUpdateSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
data = serializer.validated_data
|
||||
portfolio = get_object_or_404(Portfolio, pk=data['portfolio_id'])
|
||||
|
||||
try:
|
||||
result = ai_update_holdings(
|
||||
portfolio=portfolio,
|
||||
holdings=data['holdings'],
|
||||
reset=data['reset'],
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("ai_update_holdings failed: %s", exc, exc_info=True)
|
||||
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
return Response(result, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PortfolioViewSet(viewsets.ModelViewSet):
|
||||
queryset = Portfolio.objects.prefetch_related('stocks').all()
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'list':
|
||||
return PortfolioListSerializer
|
||||
return PortfolioSerializer
|
||||
|
||||
@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 = get_portfolio_holdings(portfolio)
|
||||
return Response(data)
|
||||
except Exception as exc:
|
||||
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 this portfolio."""
|
||||
portfolio = self.get_object()
|
||||
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()
|
||||
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)
|
||||
return qs
|
||||
|
||||
|
||||
class TransactionViewSet(viewsets.ModelViewSet):
|
||||
queryset = Transaction.objects.select_related('portfolio').all()
|
||||
serializer_class = TransactionSerializer
|
||||
|
||||
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)
|
||||
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
|
||||
portfolio = data['portfolio']
|
||||
|
||||
try:
|
||||
tx = add_transaction(
|
||||
portfolio=portfolio,
|
||||
action=data['action'],
|
||||
stock_code=data['stock_code'],
|
||||
quantity=data['quantity'],
|
||||
price_per_share=data['price_per_share'],
|
||||
date=data['date'],
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
class AIUpdateView(APIView):
|
||||
"""
|
||||
POST /api/invest/ai-update/
|
||||
AI updates portfolio holdings with a simplified payload.
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
serializer = AIUpdateSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
data = serializer.validated_data
|
||||
portfolio = get_object_or_404(Portfolio, pk=data['portfolio_id'])
|
||||
|
||||
try:
|
||||
result = ai_update_holdings(
|
||||
portfolio=portfolio,
|
||||
holdings=data['holdings'],
|
||||
reset=data['reset'],
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("ai_update_holdings failed: %s", exc, exc_info=True)
|
||||
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
return Response(result, status=status.HTTP_200_OK)
|
||||
@@ -161,6 +161,8 @@ spec:
|
||||
secretKeyRef:
|
||||
name: r2-credentials
|
||||
key: key_id
|
||||
- name: FINNHUB_API_KEY
|
||||
value: "d7hbngpr01qhiu0b2pv0d7hbngpr01qhiu0b2pvg"
|
||||
- name: REDIS_URL
|
||||
value: "redis://redis.db.svc.cluster.local:6379/0"
|
||||
- name: CRAWL4AI_API_URL
|
||||
|
||||
@@ -161,6 +161,16 @@
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'invest-dashboard' %}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/>
|
||||
</svg>
|
||||
{% trans "Invest" %}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'file-list' %}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
Reference in New Issue
Block a user