Merge pull request #82 from wahyd4/feature/invest-portfolio

feat(invest): Add investment portfolio management feature
This commit is contained in:
2026-04-18 22:26:46 +10:00
committed by GitHub
24 changed files with 1460 additions and 0 deletions
+14
View File
@@ -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)
+1
View File
@@ -20,6 +20,7 @@ INSTALLED_APPS = [
'new_theme',
'simplemde',
'markdown', # 只需要基本的markdown包
'invest',
'netscan',
'nginxmon',
'routermon',
+3
View File
@@ -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')),
View File
+14
View File
@@ -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'),
]
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class InvestConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'invest'
verbose_name = 'Investment Portfolio'
View File
+57
View File
@@ -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')],
},
),
]
View File
+74
View File
@@ -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}"
+140
View File
@@ -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()
+360
View File
@@ -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,
}
+46
View File
@@ -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)
+66
View File
@@ -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 (JulJun)
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,
})
+37
View File
@@ -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>
+217
View File
@@ -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 %}
+58
View File
@@ -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 %}
+23
View File
@@ -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
View File
@@ -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)
+2
View File
@@ -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
+10
View File
@@ -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">